@logicseek/hello-cc 1.0.5 → 1.1.0-rc.3

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 (146) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/docs/codex-account.zh-CN.md +38 -0
  3. package/docs/codex-protocol.md +59 -0
  4. package/docs/commands.md +9 -0
  5. package/docs/commands.zh-CN.md +7 -0
  6. package/docs/macos-process-identity.md +2 -0
  7. package/docs/native.md +179 -14
  8. package/docs/native.zh-CN.md +136 -6
  9. package/docs/web-handoff.zh-CN.md +118 -8
  10. package/lib/cli/app.mjs +47 -4
  11. package/lib/cli/commands/coordination.mjs +14 -4
  12. package/lib/cli/commands/dsh.mjs +1 -1
  13. package/lib/cli/commands/gc.mjs +22 -15
  14. package/lib/cli/commands/hook.mjs +19 -3
  15. package/lib/cli/commands/install.mjs +80 -10
  16. package/lib/cli/commands/misc.mjs +36 -9
  17. package/lib/cli/commands/native.mjs +4 -38
  18. package/lib/cli/commands/peer.mjs +3 -0
  19. package/lib/cli/commands/run.mjs +13 -6
  20. package/lib/cli/commands/tmux.mjs +1 -2
  21. package/lib/cli-runtime.mjs +19 -2
  22. package/lib/core/coordination/results.mjs +27 -10
  23. package/lib/core/sessions/hook-root-identity.mjs +47 -0
  24. package/lib/core/sessions/launch.mjs +7 -1
  25. package/lib/db/connection.mjs +140 -42
  26. package/lib/db/schema.mjs +59 -0
  27. package/lib/diagnostics/codex-protocol.mjs +105 -0
  28. package/lib/guidance.mjs +21 -36
  29. package/lib/integrations/codex-account.mjs +143 -0
  30. package/lib/integrations/codex-interactions.mjs +13 -11
  31. package/lib/integrations/dsh-collaboration.mjs +6 -12
  32. package/lib/integrations/dsh-cordis.mjs +17 -13
  33. package/lib/integrations/dsh.mjs +16 -11
  34. package/lib/integrations/mcp-elicitation.mjs +116 -0
  35. package/lib/integrations/mcp-url-elicitation.mjs +25 -0
  36. package/lib/integrations/native/claude.mjs +24 -6
  37. package/lib/integrations/native/codex.mjs +38 -7
  38. package/lib/integrations/native/dsh-acp.mjs +67 -24
  39. package/lib/integrations/native/index.mjs +4 -1
  40. package/lib/integrations/native/interactions.mjs +2 -1
  41. package/lib/integrations/native/jsonrpc.mjs +37 -5
  42. package/lib/integrations/native/telemetry.mjs +30 -0
  43. package/lib/process/identity.mjs +4 -0
  44. package/lib/process/pinned-cwd.mjs +302 -0
  45. package/lib/process/pinned-guidance.mjs +138 -0
  46. package/lib/process/pinned-project-state.mjs +105 -0
  47. package/lib/process/selected-cwd-identity.mjs +95 -0
  48. package/lib/runtime/buffer-evidence.mjs +2 -1
  49. package/lib/runtime/client.mjs +9 -0
  50. package/lib/runtime/native/client.mjs +4 -1
  51. package/lib/runtime/native/history.mjs +140 -0
  52. package/lib/runtime/native/launcher.mjs +106 -0
  53. package/lib/runtime/native/service.mjs +64 -8
  54. package/lib/runtime/native/store.mjs +83 -58
  55. package/lib/runtime/paths.mjs +17 -2
  56. package/lib/runtime/private-file.mjs +76 -0
  57. package/lib/runtime/private-state.mjs +546 -0
  58. package/lib/runtime/project-context.mjs +2 -1
  59. package/lib/runtime/project-path.mjs +92 -20
  60. package/lib/runtime/project-trust.mjs +79 -0
  61. package/lib/runtime/projects.mjs +9 -3
  62. package/lib/runtime/state-migration.mjs +697 -0
  63. package/lib/runtime/state.mjs +69 -27
  64. package/lib/shared/automatic-result-key.mjs +6 -0
  65. package/lib/shared/json-file.mjs +55 -6
  66. package/lib/shared/redact.mjs +2 -1
  67. package/lib/ui/help.mjs +6 -4
  68. package/lib/web/agent-defaults.mjs +110 -0
  69. package/lib/web/auto-attach.mjs +3 -2
  70. package/lib/web/browser/contracts.d.ts +10 -0
  71. package/lib/web/browser/core.mjs +1997 -0
  72. package/lib/web/browser/project-data.mjs +158 -0
  73. package/lib/web/browser/session-sync.mjs +82 -0
  74. package/lib/web/browser-assets.mjs +19 -0
  75. package/lib/web/buffer-gc-runtime.mjs +2 -2
  76. package/lib/web/codex-app-server.mjs +111 -37
  77. package/lib/web/codex-sessions.mjs +61 -12
  78. package/lib/web/context-files.mjs +82 -0
  79. package/lib/web/cookie-auth.mjs +51 -3
  80. package/lib/web/external-sessions.mjs +76 -9
  81. package/lib/web/http-routes.mjs +238 -25
  82. package/lib/web/http.mjs +50 -4
  83. package/lib/web/liveness-reaper.mjs +2 -2
  84. package/lib/web/native-sessions.mjs +323 -14
  85. package/lib/web/project-contexts.mjs +236 -44
  86. package/lib/web/project-file-write.mjs +249 -0
  87. package/lib/web/project-files.mjs +268 -0
  88. package/lib/web/pty-sessions.mjs +11 -3
  89. package/lib/web/runtime-main.mjs +132 -80
  90. package/lib/web/session-serialize.mjs +87 -8
  91. package/lib/web/session-state-sync.mjs +98 -0
  92. package/lib/web/shutdown-diagnostics.mjs +38 -0
  93. package/lib/web/shutdown-response.mjs +21 -0
  94. package/lib/web/shutdown-state.mjs +91 -0
  95. package/lib/web/startup.mjs +103 -35
  96. package/lib/web/task-results.mjs +19 -19
  97. package/lib/web/tls.mjs +84 -33
  98. package/lib/web/tmux-sessions.mjs +73 -9
  99. package/lib/web/tmux-stream.mjs +36 -8
  100. package/lib/web/ui-agent-defaults.mjs +149 -0
  101. package/lib/web/ui-agent-start.mjs +330 -0
  102. package/lib/web/ui-codex-account.mjs +81 -0
  103. package/lib/web/ui-codex.mjs +105 -85
  104. package/lib/web/ui-command-palette.mjs +21 -5
  105. package/lib/web/ui-file-writes.mjs +220 -0
  106. package/lib/web/ui-files.mjs +231 -0
  107. package/lib/web/ui-handoff.mjs +10 -2
  108. package/lib/web/ui-history.mjs +175 -105
  109. package/lib/web/ui-i18n.mjs +280 -8
  110. package/lib/web/ui-interactions.mjs +107 -6
  111. package/lib/web/ui-native-timeline.mjs +326 -0
  112. package/lib/web/ui-native.mjs +93 -43
  113. package/lib/web/ui-review.mjs +5 -3
  114. package/lib/web/ui-safe-markdown.mjs +79 -0
  115. package/lib/web/ui-session-tools.mjs +224 -0
  116. package/lib/web/ui-template.mjs +29 -1968
  117. package/lib/web/ui-terminal-find.mjs +5 -3
  118. package/lib/web/ui-workbench.mjs +5 -3
  119. package/lib/web/ui-workspace.mjs +3 -0
  120. package/package.json +8 -2
  121. package/scripts/benchmark-session-sync.mjs +53 -0
  122. package/scripts/codex-protocol.mjs +88 -0
  123. package/scripts/dsh-approval-layers-acceptance.mjs +172 -0
  124. package/scripts/dsh-package-provenance.mjs +118 -0
  125. package/scripts/mcp-form-live-fixture.mjs +81 -0
  126. package/scripts/native-bounded-live-acceptance.mjs +193 -0
  127. package/scripts/native-fixture-root.mjs +49 -0
  128. package/scripts/native-installed-acceptance.mjs +611 -0
  129. package/scripts/native-stability-checks.mjs +40 -0
  130. package/scripts/regression-cookie-expiry.mjs +70 -0
  131. package/scripts/regression-http-transport.mjs +9 -0
  132. package/scripts/regression-terminal-marker.mjs +95 -0
  133. package/scripts/regression.mjs +481 -219
  134. package/scripts/shim-reentry-probe.mjs +105 -0
  135. package/scripts/web-acceptance-diagnostics.mjs +90 -0
  136. package/scripts/web-codex-account-acceptance.mjs +178 -0
  137. package/scripts/web-file-preview-checks.mjs +302 -0
  138. package/scripts/web-mcp-form-acceptance.mjs +134 -0
  139. package/scripts/web-mcp-form-live-acceptance.mjs +368 -0
  140. package/scripts/web-native-handoff-checks.mjs +128 -0
  141. package/scripts/web-native-interaction-acceptance.mjs +219 -18
  142. package/scripts/web-native-interaction-diagnostics.mjs +80 -0
  143. package/scripts/web-native-model-acceptance.mjs +22 -7
  144. package/scripts/web-session-tools-checks.mjs +112 -0
  145. package/scripts/web-workbench-acceptance.mjs +435 -0
  146. package/scripts/web-workbench-installed-acceptance.mjs +122 -0
@@ -8,15 +8,32 @@ import { childSessionEnv } from '../core/sessions/launch.mjs';
8
8
  import { createCodexAppServer } from './codex-app-server.mjs';
9
9
  import { waitForLiveProcessIdentity } from '../process/identity.mjs';
10
10
  import { createScopedMcpConfig } from '../mcp/scope.mjs';
11
+ import { sameSelectedCwdIdentity } from '../process/selected-cwd-identity.mjs';
11
12
 
12
13
  // Each structured session owns one executor. Existing TUI sessions are never
13
14
  // silently resumed here: provider identity and live-owner evidence fence that.
14
15
  export function createCodexSessions({ sessions, sessionKey, nextProjectSessionId,
15
16
  connectWebProject, upsertPeer, upsertCanonicalPeerBinding, observePeerEvidence,
16
17
  broadcast, closeSessionClients, now, addEvent, adapterFactory = createCodexAppServer,
17
- waitForOwnerIdentity = waitForLiveProcessIdentity, mcpConfigFactory = createScopedMcpConfig }) {
18
+ waitForOwnerIdentity = waitForLiveProcessIdentity, mcpConfigFactory = createScopedMcpConfig,
19
+ isStopping = () => false }) {
18
20
  const startingThreads = new Set();
19
21
 
22
+ function assertSelectedProject(pctx) {
23
+ if (isStopping()) throw new CliError('RUNTIME_STOPPING', 'Web runtime is stopping');
24
+ pctx.rootIdentity?.assertUnchanged();
25
+ }
26
+
27
+ function managedSessionMatchesProject(session, pctx) {
28
+ if (session.root !== pctx.root ||
29
+ (session.ctx?.dbPath && pctx.dbPath && path.resolve(session.ctx.dbPath) !== path.resolve(pctx.dbPath))) return false;
30
+ const owner = session.ctx?.rootIdentity;
31
+ const requested = pctx.rootIdentity;
32
+ return owner || requested
33
+ ? Boolean(owner && requested && sameSelectedCwdIdentity(owner, requested))
34
+ : true;
35
+ }
36
+
20
37
  function event(session, type, payload) {
21
38
  const db = connectWebProject(session.ctx);
22
39
  try { addEvent(db, type, session.peerId, null, payload); } finally { db.close(); }
@@ -28,6 +45,7 @@ export function createCodexSessions({ sessions, sessionKey, nextProjectSessionId
28
45
  throw new CliError('BAD_REQUEST', 'App Server uses the local Codex executable and runtime environment');
29
46
  }
30
47
  const pctx = input.projectCtx;
48
+ assertSelectedProject(pctx);
31
49
  const cwd = pctx.root;
32
50
  const mode = input.mode || 'new';
33
51
  if (!['new', 'resume'].includes(mode)) throw new CliError('BAD_REQUEST', 'App Server supports new or explicit resume');
@@ -70,19 +88,21 @@ export function createCodexSessions({ sessions, sessionKey, nextProjectSessionId
70
88
  let closeScheduled = false;
71
89
  let adapter;
72
90
  let ownerIdentity = null;
73
- try { adapter = adapterFactory({ cwd, env: childSessionEnv({
91
+ try { adapter = adapterFactory({ cwd, expectedCwdIdentity: pctx.rootIdentity || pctx.initialRootIdentity,
92
+ changeView: true, env: childSessionEnv({
74
93
  HCC_PEER: id, HCC_ROOT: pctx.root, HCC_DB: pctx.dbPath, HCC_SHIM_NO_ATTACH: '1'
75
- }, process.env), async onSpawn({ pid, executorId }) {
94
+ }, process.env, { rootIdentity: pctx.rootIdentity || pctx.initialRootIdentity }), async onSpawn({ pid, executorId }) {
76
95
  const owner = await waitForOwnerIdentity(pid);
77
96
  if (owner.state !== 'live' || !owner.identity) throw new CliError('CODEX_OWNER_IDENTITY_UNKNOWN', 'Executor ownership could not be verified');
97
+ assertSelectedProject(pctx);
78
98
  ownerIdentity = owner.identity;
79
99
  session.mcpCapability = mcpConfigFactory({ root: pctx.root, dbPath: pctx.dbPath,
80
100
  peer: id, executorId, ownerIdentity,
81
101
  binding: { transport: 'app-server', runtimeSessionId: id } });
82
102
  return { mcp_servers: { hello_cc_scoped: session.mcpCapability.config } };
83
- }, onChange(state) {
103
+ }, onChange(state, event, metadata) {
84
104
  session.executorId ||= state.executorId;
85
- broadcast(session, { type: 'codex_state', state });
105
+ broadcast(session, { type: 'codex_state', state }, event, metadata);
86
106
  if (state.status === 'disconnected' && !closeScheduled) {
87
107
  closeScheduled = true;
88
108
  queueMicrotask(() => { if (adapter) void adapter.close(); });
@@ -90,6 +110,8 @@ export function createCodexSessions({ sessions, sessionKey, nextProjectSessionId
90
110
  if (!registered) return;
91
111
  const ended = ['disconnected', 'closed'].includes(state.status);
92
112
  if (ended) { session.status = 'exited'; session.exitedAt = now(); session.mcpCapability?.dispose(); }
113
+ if (!ended && session.lastHeartbeatWrite === now()) return;
114
+ session.lastHeartbeatWrite = now();
93
115
  const update = connectWebProject(pctx);
94
116
  try {
95
117
  if (ended) update.prepare("UPDATE peers SET status = 'exited' WHERE id = ?").run(session.peerId);
@@ -106,7 +128,9 @@ export function createCodexSessions({ sessions, sessionKey, nextProjectSessionId
106
128
  const source = await adapter.peekThread(threadId);
107
129
  assertProjectThread(pctx, source.thread, threadId);
108
130
  }
131
+ assertSelectedProject(pctx);
109
132
  const response = threadId ? await adapter.resumeThread(threadId, params) : await adapter.startThread(params);
133
+ assertSelectedProject(pctx);
110
134
  const thread = response.thread;
111
135
  if (!thread?.id) throw new CliError('CODEX_PROTOCOL_ERROR', 'App Server returned no thread identity');
112
136
  if (threadId && thread.id !== threadId) throw new CliError('CODEX_PROTOCOL_ERROR', 'Resumed thread identity changed');
@@ -118,6 +142,7 @@ export function createCodexSessions({ sessions, sessionKey, nextProjectSessionId
118
142
  session.executorId = executorState.executorId;
119
143
  const save = connectWebProject(pctx);
120
144
  try {
145
+ assertSelectedProject(pctx);
121
146
  tx(save, () => {
122
147
  const owners = save.prepare(`SELECT p.*, b.transport, b.runtime_target FROM peer_bindings b
123
148
  JOIN peers p ON p.id = b.peer WHERE b.provider = 'codex' AND b.provider_session_id = ?`).all(thread.id);
@@ -154,13 +179,18 @@ export function createCodexSessions({ sessions, sessionKey, nextProjectSessionId
154
179
  if (session.type !== 'app-server' || session.status !== 'running') {
155
180
  throw new CliError('SESSION_NOT_RUNNING', 'A running App Server session is required');
156
181
  }
182
+ assertSelectedProject(session.ctx);
157
183
  const adapter = session.adapter;
158
184
  const threadId = session.binding.provider_session_id;
159
185
  if (action === 'read') return adapter.readThread(threadId, { includeTurns: true });
186
+ if (action === 'account') {
187
+ if (input.executorId !== adapter.snapshot().executorId) throw new CliError('CODEX_EXECUTOR_MISMATCH', 'Account view belongs to another executor');
188
+ return adapter.readAccount();
189
+ }
160
190
  if (action === 'interrupt') return adapter.interrupt(threadId, String(input.turnId || ''));
161
191
  if (action === 'approve') return adapter.approve({ executorId: input.executorId,
162
192
  threadId, turnId: input.turnId, requestId: input.requestId, decision: input.decision,
163
- permissions: input.permissions, scope: input.scope, answers: input.answers });
193
+ permissions: input.permissions, scope: input.scope, answers: input.answers, content: input.content });
164
194
  if (!['turn', 'steer'].includes(action)) throw new CliError('BAD_REQUEST', 'Unsupported Codex action');
165
195
  const text = String(input.text || '').trim();
166
196
  if (!text || text.length > 100000) throw new CliError('BAD_REQUEST', 'A message of 1 to 100000 characters is required');
@@ -204,8 +234,10 @@ export function createCodexSessions({ sessions, sessionKey, nextProjectSessionId
204
234
  closeSessionClients(session);
205
235
  }
206
236
  function assertProjectThread(pctx, thread, expectedId = null) {
237
+ assertSelectedProject(pctx);
207
238
  let matches = false;
208
239
  try { matches = typeof thread?.cwd === 'string' && fs.realpathSync(thread.cwd) === fs.realpathSync(pctx.root); } catch {}
240
+ assertSelectedProject(pctx);
209
241
  if (!matches || typeof thread?.id !== 'string' || (expectedId && thread.id !== expectedId)) {
210
242
  throw new CliError('PROJECT_PATH_FORBIDDEN', 'Codex thread does not belong to this project');
211
243
  }
@@ -220,12 +252,22 @@ export function createCodexSessions({ sessions, sessionKey, nextProjectSessionId
220
252
  }
221
253
 
222
254
  async function withHistoryAdapter(pctx, action, fresh = false) {
255
+ assertSelectedProject(pctx);
223
256
  const managed = !fresh && [...sessions.values()].find(session => session.type === 'app-server' &&
224
- session.root === pctx.root && session.status === 'running' && session.adapter.snapshot().status === 'ready');
225
- if (managed) return action(managed.adapter);
257
+ managedSessionMatchesProject(session, pctx) && session.status === 'running' && session.adapter.snapshot().status === 'ready');
258
+ if (managed) {
259
+ const result = await action(managed.adapter);
260
+ assertSelectedProject(pctx);
261
+ return result;
262
+ }
226
263
  const adapter = adapterFactory({ cwd: pctx.root,
227
- env: childSessionEnv({ HCC_ROOT: pctx.root, HCC_DB: pctx.dbPath, HCC_SHIM_NO_ATTACH: '1' }, process.env) });
228
- try { return await action(adapter); } finally {
264
+ env: childSessionEnv({ HCC_ROOT: pctx.root, HCC_DB: pctx.dbPath, HCC_SHIM_NO_ATTACH: '1' },
265
+ process.env, { rootIdentity: pctx.rootIdentity || pctx.initialRootIdentity }) });
266
+ try {
267
+ const result = await action(adapter);
268
+ assertSelectedProject(pctx);
269
+ return result;
270
+ } finally {
229
271
  await adapter.close();
230
272
  const state = adapter.snapshot();
231
273
  if (state.pid && !state.processExited) {
@@ -240,13 +282,19 @@ export function createCodexSessions({ sessions, sessionKey, nextProjectSessionId
240
282
  throw new CliError('BAD_REQUEST', 'History limit must be 1 to 100 and cursor must be bounded text');
241
283
  }
242
284
  const result = await withHistoryAdapter(pctx, adapter => adapter.listThreads({ cwd: pctx.root, limit, cursor }));
285
+ assertSelectedProject(pctx);
243
286
  const threads = (result.data || []).filter(thread => {
244
- try { assertProjectThread(pctx, thread); return true; } catch { return false; }
287
+ try { assertProjectThread(pctx, thread); return true; }
288
+ catch (error) {
289
+ if (error?.code === 'PROJECT_PATH_CHANGED') throw error;
290
+ return false;
291
+ }
245
292
  }).map(thread => {
246
- const managed = [...sessions.values()].find(session => session.root === pctx.root &&
293
+ const managed = [...sessions.values()].find(session => managedSessionMatchesProject(session, pctx) &&
247
294
  session.binding?.provider_session_id === thread.id && session.status === 'running');
248
295
  return { ...thread, managedSessionId: managed?.id || null };
249
296
  });
297
+ assertSelectedProject(pctx);
250
298
  return { threads, nextCursor: result.nextCursor || null };
251
299
  }
252
300
 
@@ -270,6 +318,7 @@ export function createCodexSessions({ sessions, sessionKey, nextProjectSessionId
270
318
  const result = await withHistoryAdapter(pctx, async adapter => {
271
319
  const source = await adapter.peekThread(threadId);
272
320
  assertProjectThread(pctx, source.thread, threadId);
321
+ assertSelectedProject(pctx);
273
322
  const forked = await adapter.forkThread(threadId, input.lastTurnId ? { lastTurnId: input.lastTurnId } : {}, input.authorizeMutation);
274
323
  assertProjectThread(pctx, forked.thread);
275
324
  if (forked.thread.id === threadId) throw new CliError('CODEX_PROTOCOL_ERROR', 'Fork did not create a new thread');
@@ -0,0 +1,82 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { CliError } from '../shared/errors.mjs';
4
+
5
+ const ignored = new Set(['.git', 'node_modules', '.hcc', '.hello-cc', '.codex', '.claude', '.dsh', '.ssh', '.aws', 'dist', 'build', 'coverage', '.next']);
6
+ const ignoredName = name => ignored.has(name) || name === '.env' || name.startsWith('.env.');
7
+
8
+ function assertBoundRoot(rootIdentity, stat = null) {
9
+ if (!rootIdentity) return;
10
+ rootIdentity.assertUnchanged();
11
+ if (stat && (rootIdentity.identity.dev !== stat.dev.toString() ||
12
+ rootIdentity.identity.ino !== stat.ino.toString() ||
13
+ rootIdentity.identity.birthtimeNs !== (stat.birthtimeNs?.toString() || null))) {
14
+ throw new CliError('PROJECT_PATH_CHANGED', 'Selected project directory changed; select its current location again');
15
+ }
16
+ }
17
+
18
+ async function exactContextFile(base, query, rootIdentity) {
19
+ const parts = query.split('/');
20
+ if (!query || parts.some(part => !part || part === '.' || ignoredName(part))) return null;
21
+ let directory = base;
22
+ try {
23
+ for (let index = 0; index < parts.length; index++) {
24
+ assertBoundRoot(rootIdentity);
25
+ const target = path.join(directory, parts[index]), stat = await fs.lstat(target);
26
+ if (stat.isSymbolicLink() || (index < parts.length - 1 ? !stat.isDirectory() : !stat.isFile())) return null;
27
+ const resolved = await fs.realpath(target), relative = path.relative(base, resolved);
28
+ if (relative === '..' || relative.startsWith('..' + path.sep) || path.isAbsolute(relative) ||
29
+ relative.split(path.sep).some(ignoredName)) return null;
30
+ // Recheck the named entry after resolving it and discard any observed
31
+ // link or entry replacement before offering the filename.
32
+ const confirmed = await fs.lstat(target);
33
+ if (confirmed.isSymbolicLink() || confirmed.dev !== stat.dev || confirmed.ino !== stat.ino) return null;
34
+ directory = resolved;
35
+ if (index === parts.length - 1) return relative.split(path.sep).join('/');
36
+ }
37
+ } catch (error) { if (!['ENOENT', 'ENOTDIR', 'EACCES', 'EPERM'].includes(error.code)) throw error; }
38
+ return null;
39
+ }
40
+
41
+ /** List bounded project-relative filenames only. Never open or upload their contents. */
42
+ export async function listContextFiles(root, query = '', { limit = 80, maxEntries = 10000, timeBudgetMs = 150, now = Date.now, rootIdentity = null } = {}) {
43
+ if (typeof query !== 'string' || query.length > 256 || /[\0\r\n]/.test(query) || path.isAbsolute(query) || query.includes('\\') || query.split('/').includes('..')) {
44
+ throw new CliError('INVALID_CONTEXT_QUERY', 'Use a project-relative filename query');
45
+ }
46
+ if (!Number.isSafeInteger(limit) || limit < 1 || !Number.isSafeInteger(maxEntries) || maxEntries < 1 || !Number.isFinite(timeBudgetMs) || timeBudgetMs < 0 || typeof now !== 'function') {
47
+ throw new TypeError('Invalid context file listing budget');
48
+ }
49
+ assertBoundRoot(rootIdentity);
50
+ const base = await fs.realpath(root), pending = [''], paths = [], needle = query.toLocaleLowerCase();
51
+ if (rootIdentity) assertBoundRoot(rootIdentity, await fs.lstat(base, { bigint: true }));
52
+ // An explicit filename remains usable even if an unrelated directory read
53
+ // consumes the scan budget. Keep the bounded substring scan for other hits.
54
+ const exact = await exactContextFile(base, query, rootIdentity);
55
+ if (exact) paths.push(exact);
56
+ let seen = 0, truncated = false;
57
+ const deadline = now() + timeBudgetMs;
58
+ outer: while (pending.length) {
59
+ if (now() >= deadline || seen >= maxEntries || paths.length >= limit) { truncated = true; break; }
60
+ assertBoundRoot(rootIdentity);
61
+ const relative = pending.shift(), directory = path.join(base, relative);
62
+ try {
63
+ if ((await fs.lstat(directory)).isSymbolicLink()) continue;
64
+ const resolved = await fs.realpath(directory), fromRoot = path.relative(base, resolved);
65
+ if (fromRoot === '..' || fromRoot.startsWith('..' + path.sep) || path.isAbsolute(fromRoot)) continue;
66
+ const handle = await fs.opendir(directory);
67
+ for await (const item of handle) {
68
+ seen++;
69
+ if (seen > maxEntries || now() >= deadline) { truncated = true; break outer; }
70
+ if (item.isSymbolicLink() || ignoredName(item.name)) continue;
71
+ const name = relative ? relative + '/' + item.name : item.name;
72
+ if (item.isDirectory()) pending.push(name);
73
+ else if (item.isFile() && name.toLocaleLowerCase().includes(needle) && name !== exact) {
74
+ paths.push(name);
75
+ if (paths.length >= limit) { truncated = true; break outer; }
76
+ }
77
+ }
78
+ } catch (error) { if (!['ENOENT', 'ENOTDIR', 'EACCES', 'EPERM'].includes(error.code)) throw error; }
79
+ }
80
+ assertBoundRoot(rootIdentity);
81
+ return { paths: paths.sort(), truncated };
82
+ }
@@ -4,23 +4,71 @@
4
4
 
5
5
  import { randomBytes } from 'node:crypto';
6
6
 
7
+ // Cookies are shared across ports on one hostname, while the session Map is
8
+ // private to a runtime. Scope the name to the configured public endpoint so
9
+ // ordinary logins to another local runtime cannot replace this runtime's SID.
10
+ // Hostnames remain isolated by the browser's host-only cookie rules. This is
11
+ // coexistence isolation, not an authority boundary between same-host servers.
12
+ export function createCookieNameForRequest({ useTls = false, trustProxy = false, proxyOrigin = '' } = {}) {
13
+ let protocol = useTls ? 'https' : 'http';
14
+ let proxyPort;
15
+ if (trustProxy) {
16
+ let parsed;
17
+ try { parsed = new URL(proxyOrigin); } catch {}
18
+ if (!parsed || !['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password ||
19
+ parsed.pathname !== '/' || parsed.search || parsed.hash) {
20
+ throw new TypeError('Web cookie naming requires a valid configured proxy origin');
21
+ }
22
+ protocol = parsed.protocol.slice(0, -1);
23
+ proxyPort = Number(parsed.port || (protocol === 'https' ? 443 : 80));
24
+ if (!Number.isInteger(proxyPort) || proxyPort < 1 || proxyPort > 65535) {
25
+ throw new TypeError('Web cookie naming requires a valid configured proxy port');
26
+ }
27
+ } else if (proxyOrigin) {
28
+ throw new TypeError('Web cookie proxy origin requires trustProxy');
29
+ }
30
+ return (req) => {
31
+ // The socket reports the port actually bound (including --port 0 and
32
+ // auto-increment). Do not use Host or forwarded request headers here.
33
+ const port = proxyPort ?? req?.socket?.localPort;
34
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
35
+ throw new TypeError('Web cookie naming requires a valid listener port');
36
+ }
37
+ return `hcc_sid_v2_${protocol}_${port}`;
38
+ };
39
+ }
40
+
7
41
  export function createCookieAuth({
8
42
  now,
9
43
  ttlSec,
10
44
  maxSessions,
45
+ cookieNameForRequest,
11
46
  requestIsSecure,
12
47
  trustProxy,
13
48
  proxyOrigin,
14
49
  authOk,
15
50
  token
16
51
  }) {
52
+ if (typeof cookieNameForRequest !== 'function') {
53
+ throw new TypeError('Web cookie authentication requires cookieNameForRequest');
54
+ }
17
55
  const webSessions = new Map();
18
56
 
57
+ function cookieName(req) {
58
+ const name = cookieNameForRequest(req);
59
+ if (typeof name !== 'string' || !/^hcc_sid_v2_(?:http|https)_[1-9]\d{0,4}$/.test(name) ||
60
+ Number(name.slice(name.lastIndexOf('_') + 1)) > 65535) {
61
+ throw new TypeError('Web cookie authentication requires a scoped cookie name');
62
+ }
63
+ return name;
64
+ }
65
+
19
66
  function parseCookieSid(req) {
67
+ const name = cookieName(req);
20
68
  const header = req.headers.cookie || '';
21
69
  for (const part of header.split(';')) {
22
70
  const [k, ...rest] = part.trim().split('=');
23
- if (k === 'hcc_sid') {
71
+ if (k === name) {
24
72
  const raw = rest.join('=');
25
73
  if (raw.length > 128) return '';
26
74
  try { return decodeURIComponent(raw); } catch { return ''; }
@@ -57,13 +105,13 @@ export function createCookieAuth({
57
105
  }
58
106
 
59
107
  function sessionCookieHeader(sid, req) {
60
- const parts = [`hcc_sid=${sid}`, 'Path=/', 'HttpOnly', 'SameSite=Lax', `Max-Age=${ttlSec}`];
108
+ const parts = [`${cookieName(req)}=${sid}`, 'Path=/', 'HttpOnly', 'SameSite=Lax', `Max-Age=${ttlSec}`];
61
109
  if (requestIsSecure(req, { trustProxy, proxyOrigin })) parts.push('Secure');
62
110
  return parts.join('; ');
63
111
  }
64
112
 
65
113
  function expiredSessionCookieHeader(req) {
66
- const parts = ['hcc_sid=', 'Path=/', 'HttpOnly', 'SameSite=Lax', 'Max-Age=0'];
114
+ const parts = [`${cookieName(req)}=`, 'Path=/', 'HttpOnly', 'SameSite=Lax', 'Max-Age=0'];
67
115
  if (requestIsSecure(req, { trustProxy, proxyOrigin })) parts.push('Secure');
68
116
  return parts.join('; ');
69
117
  }
@@ -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,59 @@ 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
- ctx, sessions,
53
+ ctx, sessions, isStopping = () => false,
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);
62
+ let stopped = false;
63
+ const pendingAdoptions = new Set();
64
+ const discoveryStopped = () => stopped || isStopping();
65
+
66
+ function stopExternalDiscovery() {
67
+ stopped = true;
68
+ for (const timer of pendingAdoptions) clearTimeout(timer);
69
+ pendingAdoptions.clear();
70
+ }
29
71
 
30
72
  function removeExternalBufferFiles(id, directory = bufsDir) {
31
73
  for (const suffix of ['out', 'in', 'resize', 'meta']) {
@@ -34,6 +76,16 @@ export function createExternalSessions(deps) {
34
76
  }
35
77
 
36
78
  function adoptExternalSession(id, pctx = ctx, directory = bufsDir) {
79
+ if (discoveryStopped()) return;
80
+ let privateDirectory;
81
+ let suppliedDirectory;
82
+ try {
83
+ privateDirectory = ensurePrivateProjectBufferDirectory(pctx.root, BUFS_DIR_NAME, { create: false });
84
+ suppliedDirectory = fs.realpathSync.native(directory);
85
+ }
86
+ catch { return; }
87
+ if (!privateDirectory || suppliedDirectory !== privateDirectory) return;
88
+ directory = privateDirectory;
37
89
  const key = sessionKey(pctx, id);
38
90
  if (sessions.has(key)) return;
39
91
  const outFile = path.join(directory, `${id}.out`);
@@ -44,6 +96,9 @@ export function createExternalSessions(deps) {
44
96
  try {
45
97
  adopted = withBufferDirectoryLease(directory, () => {
46
98
  if (!fs.existsSync(outFile) || !fs.existsSync(metaFile)) return null;
99
+ for (const file of [outFile, inFile, resizeFile, metaFile]) {
100
+ secureExistingBridgeFile(file);
101
+ }
47
102
  let meta;
48
103
  try { meta = readExternalBufferMetadata(metaFile); } catch { return null; }
49
104
  // The producer publishes wrapper evidence before the PTY child identity
@@ -116,6 +171,7 @@ export function createExternalSessions(deps) {
116
171
  outputOffset = fs.fstatSync(session.outputFd).size;
117
172
  } catch {}
118
173
  session.outputPoller = setInterval(() => {
174
+ if (discoveryStopped()) return;
119
175
  try {
120
176
  if (session.outputFd === null) return;
121
177
  const stat = fs.fstatSync(session.outputFd);
@@ -175,6 +231,7 @@ export function createExternalSessions(deps) {
175
231
  // producer. Owner changes detach only this stale in-memory view; they never
176
232
  // mark the replacement producer exited or remove its files.
177
233
  session.exitPoller = setInterval(() => {
234
+ if (discoveryStopped()) return;
178
235
  try {
179
236
  withBufferDirectoryLease(directory, () => {
180
237
  const outExists = fs.existsSync(outFile);
@@ -210,8 +267,12 @@ export function createExternalSessions(deps) {
210
267
 
211
268
  // Adopt any already-running external sessions
212
269
  function scanExternalSessions() {
270
+ if (discoveryStopped()) return;
213
271
  for (const projectCtx of runtimeProjectContexts()) {
214
- const directory = bufferDirectory(projectCtx);
272
+ let directory;
273
+ try { directory = ensurePrivateProjectBufferDirectory(projectCtx.root, BUFS_DIR_NAME, { create: false }); }
274
+ catch { continue; }
275
+ if (!directory) continue;
215
276
  for (const id of externalBufferSessionIds(directory)) {
216
277
  adoptExternalSession(id, projectCtx, directory);
217
278
  }
@@ -227,14 +288,20 @@ export function createExternalSessions(deps) {
227
288
  // scanExternalSessions get their own watcher on the next scan tick.
228
289
  const bufsWatchers = new Map();
229
290
  function ensureBufsWatchers() {
291
+ if (discoveryStopped()) return;
230
292
  for (const projectCtx of runtimeProjectContexts()) {
231
293
  const root = path.resolve(projectCtx.root);
232
294
  if (bufsWatchers.has(root)) continue;
233
- const directory = bufferDirectory(projectCtx);
234
295
  try {
296
+ const directory = ensurePrivateProjectBufferDirectory(projectCtx.root, BUFS_DIR_NAME, { create: false });
297
+ if (!directory) continue;
235
298
  const watcher = fs.watch(directory, { persistent: false }, (event, filename) => {
236
- if (filename?.endsWith('.out')) {
237
- setTimeout(() => adoptExternalSession(path.basename(filename, '.out'), projectCtx, directory), 300);
299
+ if (!discoveryStopped() && filename?.endsWith('.out')) {
300
+ const timer = setTimeout(() => {
301
+ pendingAdoptions.delete(timer);
302
+ adoptExternalSession(path.basename(filename, '.out'), projectCtx, directory);
303
+ }, 300);
304
+ pendingAdoptions.add(timer);
238
305
  }
239
306
  });
240
307
  watcher.on('error', (err) => {
@@ -249,6 +316,6 @@ export function createExternalSessions(deps) {
249
316
 
250
317
  return {
251
318
  bufsWatchers, externalScanPoller, bufsWatcherSyncPoller,
252
- scanExternalSessions, adoptExternalSession, removeExternalBufferFiles
319
+ scanExternalSessions, adoptExternalSession, removeExternalBufferFiles, stopExternalDiscovery
253
320
  };
254
321
  }