@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
@@ -3,12 +3,13 @@
3
3
  // fail-closed path re-resolution, per-project DB connections, and
4
4
  // session/peer lookup helpers used by the HTTP and WS layers.
5
5
 
6
- import path from 'node:path';
7
6
  import { randomBytes } from 'node:crypto';
7
+ import path from 'node:path';
8
8
  import WebSocket from 'ws';
9
9
  import { CliError } from '../shared/errors.mjs';
10
- import { contextForProject } from '../runtime/paths.mjs';
10
+ import { contextForProject, projectDbPath } from '../runtime/paths.mjs';
11
11
  import { resolveProjectDatabase } from '../runtime/project-path.mjs';
12
+ import { assertSelectedCwdSnapshot, captureSelectedCwdIdentity, sameSelectedCwdIdentity } from '../process/selected-cwd-identity.mjs';
12
13
  import {
13
14
  projectRecord,
14
15
  readProjectRegistry,
@@ -32,30 +33,139 @@ export function createProjectContexts(deps) {
32
33
  } = deps;
33
34
 
34
35
  const projectContexts = new Map();
36
+ const MAX_IDLE_PROJECT_DESCRIPTORS = 32;
37
+
38
+ function changedProject() {
39
+ return new CliError('PROJECT_PATH_CHANGED', 'Selected project directory changed; select its current location again');
40
+ }
41
+
42
+ // This is a comparison value, not an authority token: it contains only the
43
+ // canonical path and stat identity already used by CLI-carried requests.
44
+ function selectedProjectIdentity(projectCtx) {
45
+ projectCtx.rootIdentity?.assertUnchanged();
46
+ const { canonical, identity } = projectCtx.rootIdentity || {};
47
+ if (!canonical || !identity) throw changedProject();
48
+ return Buffer.from(JSON.stringify({ canonical, identity })).toString('base64url');
49
+ }
50
+
51
+ function sessionBelongsToProject(session, projectCtx) {
52
+ if (session?.ctx?.dbPath && projectCtx?.dbPath &&
53
+ path.resolve(session.ctx.dbPath) !== path.resolve(projectCtx.dbPath)) return false;
54
+ const owner = session?.ctx?.rootIdentity || session?.rootIdentity;
55
+ const requested = projectCtx?.rootIdentity;
56
+ // New managed sessions must never be found through another inode merely
57
+ // because their old root pathname now resolves to the requested project.
58
+ if (owner || requested) return Boolean(owner && requested && sameSelectedCwdIdentity(owner, requested));
59
+ return true; // Pre-existing contexts retain their legacy lookup behavior.
60
+ }
61
+
62
+ function hasLiveManagedSession(binding) {
63
+ for (const session of sessions.values()) {
64
+ if (['exited', 'closed'].includes(session.status)) continue;
65
+ const owner = session.ctx?.rootIdentity || session.rootIdentity;
66
+ if (sameSelectedCwdIdentity(owner, binding)) return true;
67
+ }
68
+ return false;
69
+ }
70
+
71
+ function dropIdleProjectContextDescriptors(except = null, maxIdle = MAX_IDLE_PROJECT_DESCRIPTORS) {
72
+ let retainedIdle = 0;
73
+ // Map insertion order is selection recency. Keep a small hot cache plus
74
+ // descriptors held by live managed sessions; registry-only and older idle
75
+ // projects retain immutable stat identities without consuming an FD.
76
+ for (const project of [...projectContexts.values()].reverse()) {
77
+ const binding = project.rootIdentity;
78
+ if (!binding || binding.mode !== 'descriptor' || binding === except || hasLiveManagedSession(binding)) continue;
79
+ retainedIdle += 1;
80
+ if (retainedIdle > maxIdle) binding.dropDescriptor();
81
+ }
82
+ }
83
+
84
+ function retainProjectContextDescriptor(projectCtx) {
85
+ const previous = projectCtx.rootIdentity;
86
+ if (!previous) throw changedProject();
87
+ previous.assertUnchanged();
88
+ if (previous.mode === 'descriptor') return previous;
89
+ const refreshed = captureSelectedCwdIdentity(previous.canonical);
90
+ try {
91
+ refreshed.assertUnchanged();
92
+ previous.assertUnchanged();
93
+ if (!sameSelectedCwdIdentity(previous, refreshed)) throw changedProject();
94
+ if (refreshed.mode !== 'descriptor' || hasLiveManagedSession(previous)) {
95
+ refreshed.release();
96
+ return previous;
97
+ }
98
+ projectCtx.rootIdentity = refreshed;
99
+ previous.release();
100
+ return refreshed;
101
+ } catch (error) {
102
+ refreshed.release();
103
+ throw error;
104
+ }
105
+ }
35
106
 
36
107
  function newSessionActionToken() {
37
108
  return randomBytes(32).toString('base64url');
38
109
  }
39
110
 
40
111
  function rememberProject(projectCtx, { activity = false, register = false, nonblocking = false } = {}) {
41
- const normalized = contextForProject(projectCtx.root, projectCtx.dbPath, { cwd: projectCtx.cwd, json: ctx.json });
42
- const isNew = !projectContexts.has(normalized.root);
43
- // Activity refresh is already nonblocking and throttled before it tries the
44
- // registry lock. Calling it on each request keeps last_seen_at current
45
- // without putting the synchronous lock back on the HTTP hot path.
46
- if (activity) registerProjectActivity(normalized);
47
- else if (isNew || register) {
48
- try {
49
- registerProject(normalized, { nonblocking });
50
- } catch (error) {
51
- if (nonblocking && ['ERR_FILE_LOCK_BUSY', 'ERR_FILE_LOCK_TIMEOUT'].includes(error?.code)) {
52
- throw new CliError('REGISTRY_BUSY', 'Project registry is busy; retry the request');
112
+ const incoming = projectCtx.rootIdentity || captureSelectedCwdIdentity(projectCtx.root);
113
+ let retained = incoming;
114
+ try {
115
+ incoming.assertUnchanged();
116
+ const normalized = contextForProject(projectCtx.root, projectCtx.dbPath, { cwd: projectCtx.cwd, json: ctx.json });
117
+ if (normalized.root !== incoming.canonical) throw changedProject();
118
+ const existing = projectContexts.get(normalized.root);
119
+ let reusableContext = false;
120
+ if (existing?.rootIdentity && sameSelectedCwdIdentity(existing.rootIdentity, incoming)) {
121
+ try {
122
+ existing.rootIdentity.assertUnchanged();
123
+ reusableContext = existing.dbPath === normalized.dbPath;
124
+ // A dormant stat-only record is upgraded from this request's fresh
125
+ // descriptor. Live sessions keep their original binding until exit.
126
+ if (existing.rootIdentity.mode === 'descriptor' || incoming.mode === 'stat-only' ||
127
+ hasLiveManagedSession(existing.rootIdentity)) retained = existing.rootIdentity;
128
+ } catch { /* A released or stale binding is never reused. */ }
129
+ }
130
+ normalized.rootIdentity = retained;
131
+ const isNew = !existing;
132
+ // Activity refresh is already nonblocking and throttled before it tries
133
+ // the registry lock. Reusing the existing binding avoids one open FD per
134
+ // browser request while keeping the initial inode attached to sessions.
135
+ if (activity) registerProjectActivity(normalized);
136
+ else if (isNew || register) {
137
+ try {
138
+ registerProject(normalized, { nonblocking });
139
+ } catch (error) {
140
+ if (nonblocking && ['ERR_FILE_LOCK_BUSY', 'ERR_FILE_LOCK_TIMEOUT'].includes(error?.code)) {
141
+ throw new CliError('REGISTRY_BUSY', 'Project registry is busy; retry the request');
142
+ }
143
+ throw error;
53
144
  }
54
- throw error;
55
145
  }
146
+ retained.assertUnchanged();
147
+ if (retained !== incoming) incoming.release();
148
+ const previous = existing?.rootIdentity;
149
+ if (reusableContext) {
150
+ // Background scanners retain this object. Refresh its idle binding in
151
+ // place only while both the selected directory and database match.
152
+ existing.rootIdentity = retained;
153
+ projectContexts.delete(normalized.root);
154
+ projectContexts.set(normalized.root, existing);
155
+ if (previous !== retained) previous.release();
156
+ dropIdleProjectContextDescriptors(retained);
157
+ return existing;
158
+ }
159
+ if (previous && previous !== retained) previous.release();
160
+ projectContexts.set(normalized.root, normalized);
161
+ dropIdleProjectContextDescriptors(retained);
162
+ return normalized;
163
+ } catch (error) {
164
+ if (retained !== incoming || ![...projectContexts.values()].some(value => value.rootIdentity === incoming)) {
165
+ incoming.release();
166
+ }
167
+ throw error;
56
168
  }
57
- projectContexts.set(normalized.root, normalized);
58
- return normalized;
59
169
  }
60
170
 
61
171
  function knownProjects() {
@@ -63,60 +173,134 @@ export function createProjectContexts(deps) {
63
173
  if (!rows.some((p) => sameResolvedPath(p.root, ctx.root))) rows.unshift(projectRecord(ctx));
64
174
  for (const project of rows) {
65
175
  if (!projectContexts.has(project.root)) {
66
- projectContexts.set(project.root, contextForProject(project.root, project.db, { json: ctx.json }));
176
+ const normalized = contextForProject(project.root, project.db, { json: ctx.json });
177
+ try {
178
+ normalized.rootIdentity = captureSelectedCwdIdentity(normalized.root);
179
+ normalized.rootIdentity.dropDescriptor();
180
+ }
181
+ catch { /* An unavailable registered project remains discoverable, not active. */ }
182
+ projectContexts.set(project.root, normalized);
67
183
  }
68
184
  }
69
185
  return rows;
70
186
  }
71
187
 
72
188
  function resolveWebProjectContext(root, db) {
73
- const first = resolveProjectDatabase({ root, db, createStateDir: true });
74
- // Re-read every filesystem identity immediately before the returned
75
- // context can reach connect(). A path rebound after the first pass fails
76
- // closed instead of carrying a stale lexical decision into SQLite.
77
- const final = resolveProjectDatabase({
78
- root: first.root,
79
- db: first.db,
80
- createStateDir: true
81
- });
82
- return contextForProject(final.root, final.db, { cwd: final.root, json: ctx.json });
189
+ const selected = captureSelectedCwdIdentity(root);
190
+ let binding = selected;
191
+ try {
192
+ // Freeze the canonical target, not an alias that may later be retargeted
193
+ // while the originally selected directory remains available at its own
194
+ // pathname. The alias must still be unchanged throughout selection.
195
+ if (selected.requested !== selected.canonical) binding = captureSelectedCwdIdentity(selected.canonical);
196
+ selected.assertUnchanged();
197
+ // Preserve explicit legacy DB spellings under a selected alias while
198
+ // resolving state against the captured canonical directory only.
199
+ const selectedLegacy = path.join(selected.requested, '.hello-cc');
200
+ const relativeDb = db ? path.relative(selectedLegacy, path.resolve(String(db))) : null;
201
+ const underSelectedLegacy = relativeDb !== null && relativeDb !== '..' &&
202
+ !relativeDb.startsWith(`..${path.sep}`) && !path.isAbsolute(relativeDb);
203
+ const stableDb = underSelectedLegacy
204
+ ? path.join(binding.canonical, '.hello-cc', relativeDb)
205
+ : db;
206
+ const first = resolveProjectDatabase({ root: binding.canonical, db: stableDb, createStateDir: true });
207
+ selected.assertUnchanged();
208
+ binding.assertUnchanged();
209
+ if (first.root !== binding.canonical) throw changedProject();
210
+ const final = resolveProjectDatabase({ root: first.root, db: first.db, createStateDir: true });
211
+ selected.assertUnchanged();
212
+ binding.assertUnchanged();
213
+ if (final.root !== binding.canonical) throw changedProject();
214
+ return { ...contextForProject(final.root, final.db, { cwd: final.root, json: ctx.json }),
215
+ rootIdentity: binding };
216
+ } catch (error) {
217
+ binding.release();
218
+ throw error;
219
+ } finally {
220
+ if (selected !== binding) selected.release();
221
+ }
83
222
  }
84
223
 
85
224
  function connectWebProject(projectCtx, options = {}) {
225
+ const binding = projectCtx.rootIdentity;
226
+ binding?.assertUnchanged();
86
227
  const final = resolveProjectDatabase({
87
228
  root: projectCtx.root,
88
229
  db: projectCtx.dbPath,
89
230
  createStateDir: options.create !== false
90
231
  });
91
- return connect(contextForProject(final.root, final.db, {
232
+ binding?.assertUnchanged();
233
+ if (binding && final.root !== binding.canonical) throw changedProject();
234
+ const db = connect(contextForProject(final.root, final.db, {
92
235
  cwd: final.root,
93
236
  json: projectCtx.json
94
237
  }), options);
238
+ try { binding?.assertUnchanged(); }
239
+ catch (error) { try { db.close(); } catch {} throw error; }
240
+ return db;
95
241
  }
96
242
 
97
- function projectFromRequest(req, url) {
98
- const requestedRoot = url.searchParams.get('root') ||
243
+ function projectFromRequest(req, url, { requireIdentity = false } = {}) {
244
+ const selectedRoot = url.searchParams.get('root') ||
99
245
  url.searchParams.get('project') ||
100
- req.headers['x-hcc-root'] ||
101
- ctx.root;
246
+ req.headers['x-hcc-root'];
247
+ const requestedRoot = selectedRoot || ctx.root;
102
248
  const requestedDb = url.searchParams.get('db') ||
103
- req.headers['x-hcc-db'] ||
104
- path.join(requestedRoot, '.hello-cc', 'mesh.db');
105
- return rememberProject(
106
- resolveWebProjectContext(requestedRoot, requestedDb),
107
- { activity: true }
108
- );
249
+ req.headers['x-hcc-db'] || null;
250
+ let carried = null;
251
+ let resolved = null;
252
+ try {
253
+ const headerIdentity = req.headers['x-hcc-root-identity'];
254
+ const queryIdentity = url.searchParams.get('root_identity');
255
+ if (headerIdentity !== undefined && queryIdentity !== null && headerIdentity !== queryIdentity) {
256
+ throw changedProject();
257
+ }
258
+ const encoded = headerIdentity === undefined ? queryIdentity : headerIdentity;
259
+ const browserRequest = req.headers['x-hcc-browser'] === '1' || url.searchParams.get('browser') === '1';
260
+ const explicitSelection = (url.pathname === '/api/projects/select' && req.method === 'POST') ||
261
+ (url.pathname === '/api/projects' && req.method === 'POST');
262
+ if ((browserRequest || requireIdentity) && !explicitSelection && !encoded) throw changedProject();
263
+ // A request that names no project falls back to the Web process's
264
+ // startup root. Its automatic bootstrap is not an explicit selection of
265
+ // a new inode at the same pathname after that startup root was moved.
266
+ if (!selectedRoot && ctx.initialRootIdentity) {
267
+ assertSelectedCwdSnapshot(ctx.initialRootIdentity);
268
+ }
269
+ if (encoded !== undefined && encoded !== null) {
270
+ if (typeof encoded !== 'string' || encoded.length > 2048 || !/^[A-Za-z0-9_-]+$/.test(encoded)) {
271
+ throw changedProject();
272
+ }
273
+ let expected;
274
+ try { expected = JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8')); }
275
+ catch { throw changedProject(); }
276
+ if (!expected || typeof expected.canonical !== 'string' || !path.isAbsolute(expected.canonical) ||
277
+ !expected.identity || typeof expected.identity.dev !== 'string' ||
278
+ typeof expected.identity.ino !== 'string' ||
279
+ !(typeof expected.identity.birthtimeNs === 'string' || expected.identity.birthtimeNs === null)) {
280
+ throw changedProject();
281
+ }
282
+ carried = captureSelectedCwdIdentity(requestedRoot, expected);
283
+ }
284
+ resolved = resolveWebProjectContext(requestedRoot, requestedDb || projectDbPath(requestedRoot));
285
+ if (carried && !sameSelectedCwdIdentity(carried, resolved.rootIdentity)) throw changedProject();
286
+ const remembered = rememberProject(resolved, { activity: true });
287
+ resolved = null;
288
+ return remembered;
289
+ } finally {
290
+ carried?.release();
291
+ resolved?.rootIdentity?.release();
292
+ }
109
293
  }
110
294
 
111
295
  function getSession(projectCtx, id, db = null) {
112
296
  const direct = sessions.get(sessionKey(projectCtx, id));
113
- if (direct) return direct;
297
+ if (direct && sessionBelongsToProject(direct, projectCtx)) return direct;
114
298
  for (const session of sessionsForProject(projectCtx)) {
115
- if (session.peerId === id) return session;
299
+ if (sessionBelongsToProject(session, projectCtx) && session.peerId === id) return session;
116
300
  }
117
301
  if (db) {
118
302
  for (const session of sessionsForProject(projectCtx)) {
119
- if (resolveSessionPeerId(db, session) === id) return session;
303
+ if (sessionBelongsToProject(session, projectCtx) && resolveSessionPeerId(db, session) === id) return session;
120
304
  }
121
305
  }
122
306
  return null;
@@ -155,9 +339,16 @@ export function createProjectContexts(deps) {
155
339
  throw new CliError("PEER_IDENTITY_REQUIRED", "Web peer action for " + peer + " requires the managed session action token", { peer });
156
340
  }
157
341
  assertSessionControl?.(session, provided, input.epoch);
342
+ projectCtx.rootIdentity?.assertUnchanged();
343
+ session.ctx?.rootIdentity?.assertUnchanged();
158
344
  return actorPeer;
159
345
  }
160
346
 
347
+ function releaseProjectContexts() {
348
+ for (const project of projectContexts.values()) project.rootIdentity?.release();
349
+ projectContexts.clear();
350
+ }
351
+
161
352
  function knownPeerIds(projectCtx) {
162
353
  const db = connectWebProject(projectCtx);
163
354
  try {
@@ -175,8 +366,9 @@ export function createProjectContexts(deps) {
175
366
  }
176
367
 
177
368
  return {
178
- projectContexts,
179
- newSessionActionToken, rememberProject, knownProjects,
369
+ projectContexts, releaseProjectContexts, dropIdleProjectContextDescriptors,
370
+ retainProjectContextDescriptor,
371
+ newSessionActionToken, rememberProject, knownProjects, selectedProjectIdentity,
180
372
  resolveWebProjectContext, connectWebProject, projectFromRequest,
181
373
  getSession, readActionToken, resolveWebActionSession,
182
374
  knownPeerIds, nextProjectSessionId
@@ -0,0 +1,249 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import process from 'node:process';
4
+ import { createHash } from 'node:crypto';
5
+ import { spawnSync } from 'node:child_process';
6
+ import { CliError } from '../shared/errors.mjs';
7
+ import { withFileLock } from '../shared/file-lock.mjs';
8
+ import { preparePinnedCwdLaunch } from '../process/pinned-cwd.mjs';
9
+ import { PROJECT_FILE_LIMITS, projectFilePathParts, projectTextContent, isProjectTextPath,
10
+ projectContentRevision, prepareProjectFileParent, verifyProjectFileParent } from './project-files.mjs';
11
+
12
+ // Serialized into a built-ins-only Node child. The trusted bootstrap pins cwd
13
+ // to the validated parent inode; every mutation below uses a single basename.
14
+ // HCC writers share a cross-process lock. Other editors do not share that lock,
15
+ // so the final revision check is optimistic, not an OS-level compare-and-swap.
16
+ function fileWriteWorker(revisionOf) {
17
+ const fs = require('node:fs');
18
+ const { createHash, randomBytes } = require('node:crypto');
19
+ const { TextDecoder } = require('node:util');
20
+ const textOf = bytes => {
21
+ try {
22
+ const value = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes);
23
+ if (/[\x00-\x08\x0b\x0e-\x1f\x7f]/.test(value)) return null;
24
+ return value;
25
+ } catch { return null; }
26
+ };
27
+ let temporary = null, tempFd, oldFd, tempIdentity, published = false, attempted = false;
28
+ const fail = code => { const error = new Error(code); error.code = code; throw error; };
29
+ const same = (a, b) => a.dev === b.dev && a.ino === b.ino && a.mode === b.mode && a.nlink === b.nlink &&
30
+ a.size === b.size && a.mtimeNs === b.mtimeNs && a.ctimeNs === b.ctimeNs;
31
+ const ordinary = stat => stat.isFile() && stat.nlink === 1n;
32
+ const readBounded = (fd, stat, maxBytes, code) => {
33
+ if (stat.size > BigInt(maxBytes)) fail(code);
34
+ const result = Buffer.alloc(Number(stat.size));
35
+ let position = 0;
36
+ while (position < result.length) {
37
+ const count = fs.readSync(fd, result, position, result.length - position, position);
38
+ if (!count) fail(code);
39
+ position += count;
40
+ }
41
+ if (!same(stat, fs.fstatSync(fd, { bigint: true }))) fail(code);
42
+ return result;
43
+ };
44
+ let receipt, failure;
45
+ try {
46
+ const request = JSON.parse(fs.readFileSync(0, 'utf8'));
47
+ const name = request.name, bytes = Buffer.from(request.content, 'base64');
48
+ if (!name || /[\\/\x00-\x1f\x7f]/.test(name) || name === '.' || name === '..') fail('PROJECT_FILE_BAD_PATH');
49
+ let before;
50
+ if (request.operation === 'save') {
51
+ oldFd = fs.openSync(name, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK);
52
+ before = fs.fstatSync(oldFd, { bigint: true });
53
+ if (!ordinary(before)) fail('PROJECT_FILE_FORBIDDEN');
54
+ if (before.size > 1048576n) fail('PROJECT_FILE_TOO_LARGE');
55
+ const oldBytes = readBounded(oldFd, before, 1048576, 'PROJECT_FILE_CONFLICT');
56
+ if (!same(before, fs.fstatSync(oldFd, { bigint: true })) ||
57
+ revisionOf(before, oldBytes) !== request.revision) fail('PROJECT_FILE_CONFLICT');
58
+ if (textOf(oldBytes, false) === null) fail('PROJECT_FILE_FORBIDDEN');
59
+ } else if (request.operation === 'upload') {
60
+ try { fs.lstatSync(name); fail('PROJECT_FILE_EXISTS'); }
61
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
62
+ } else fail('PROJECT_FILE_BAD_PATH');
63
+ temporary = '.hcc-file-write-' + randomBytes(18).toString('hex');
64
+ tempFd = fs.openSync(temporary, fs.constants.O_RDWR | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW, 0o600);
65
+ tempIdentity = fs.fstatSync(tempFd, { bigint: true });
66
+ let offset = 0;
67
+ while (offset < bytes.length) {
68
+ const written = fs.writeSync(tempFd, bytes, offset, bytes.length - offset, offset);
69
+ if (!written) fail('PROJECT_FILE_WRITE_UNAVAILABLE');
70
+ offset += written;
71
+ }
72
+ fs.fchmodSync(tempFd, request.operation === 'save' ? Number(before.mode & 0o777n) : 0o644);
73
+ fs.fsyncSync(tempFd);
74
+ /* PINNED_FILE_TEST_BEFORE_PUBLISH */
75
+ const prepared = fs.fstatSync(tempFd, { bigint: true });
76
+ if (!ordinary(prepared) || !same(prepared, fs.lstatSync(temporary, { bigint: true })) ||
77
+ !readBounded(tempFd, prepared, bytes.length, 'PROJECT_FILE_CHANGED').equals(bytes)) fail('PROJECT_FILE_CHANGED');
78
+ if (request.operation === 'save') {
79
+ const current = fs.lstatSync(name, { bigint: true }), oldStat = fs.fstatSync(oldFd, { bigint: true });
80
+ if (!ordinary(current) || !same(before, current) || !same(before, oldStat)) fail('PROJECT_FILE_CONFLICT');
81
+ // Re-read immediately before publication to detect same-inode edits too.
82
+ const currentBytes = readBounded(oldFd, oldStat, 1048576, 'PROJECT_FILE_CONFLICT');
83
+ if (revisionOf(fs.fstatSync(oldFd, { bigint: true }), currentBytes) !== request.revision) fail('PROJECT_FILE_CONFLICT');
84
+ attempted = true;
85
+ fs.renameSync(temporary, name);
86
+ published = true; temporary = null;
87
+ } else {
88
+ // link is an atomic, exclusive publication: a concurrent creator wins
89
+ // with EEXIST and readers never observe a partly uploaded file.
90
+ attempted = true;
91
+ try { fs.linkSync(temporary, name); }
92
+ catch (error) { if (error.code === 'EEXIST') { attempted = false; fail('PROJECT_FILE_EXISTS'); } throw error; }
93
+ published = true;
94
+ fs.unlinkSync(temporary); temporary = null;
95
+ }
96
+ /* PINNED_FILE_TEST_AFTER_PUBLISH */
97
+ const final = fs.fstatSync(tempFd, { bigint: true }), visible = fs.lstatSync(name, { bigint: true });
98
+ if (!ordinary(final) || !same(final, visible) || final.size !== BigInt(bytes.length)) fail('PROJECT_FILE_WRITE_UNCONFIRMED');
99
+ const finalBytes = readBounded(tempFd, final, bytes.length, 'PROJECT_FILE_WRITE_UNCONFIRMED');
100
+ if (!finalBytes.equals(bytes) || !same(final, fs.lstatSync(name, { bigint: true }))) fail('PROJECT_FILE_WRITE_UNCONFIRMED');
101
+ const directoryFd = fs.openSync('.', fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW);
102
+ try { fs.fsyncSync(directoryFd); } finally { fs.closeSync(directoryFd); }
103
+ receipt = { size: finalBytes.length, contentHash: createHash('sha256').update(finalBytes).digest('hex'),
104
+ ...(request.operation === 'save' ? { saved: true, revision: revisionOf(final, finalBytes) } : { created: true }) };
105
+ } catch (error) {
106
+ failure = published || attempted ? 'PROJECT_FILE_WRITE_UNCONFIRMED' : error.code || 'PROJECT_FILE_WRITE_UNAVAILABLE';
107
+ } finally {
108
+ // Only our random temporary inode may be removed. Never unlink the public
109
+ // name on errors: publication may already have succeeded.
110
+ if (temporary && tempIdentity) {
111
+ try {
112
+ const current = fs.lstatSync(temporary, { bigint: true });
113
+ if (current.isFile() && current.dev === tempIdentity.dev && current.ino === tempIdentity.ino) fs.unlinkSync(temporary);
114
+ } catch {}
115
+ }
116
+ for (const fd of [oldFd, tempFd]) if (fd !== undefined) { try { fs.closeSync(fd); } catch { failure = 'PROJECT_FILE_WRITE_UNCONFIRMED'; } }
117
+ }
118
+ process.stdout.write(JSON.stringify(failure ? { error: { code: failure } } : { receipt }));
119
+ }
120
+
121
+ function workerSource() {
122
+ return "const { createHash } = require('node:crypto'); (" + fileWriteWorker.toString() + ')(' + projectContentRevision.toString() + ');';
123
+ }
124
+
125
+ function exactKeys(value, keys) {
126
+ return value && typeof value === 'object' && !Array.isArray(value) &&
127
+ Object.keys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key));
128
+ }
129
+ function validateSave(input) {
130
+ if (!exactKeys(input, ['path', 'revision', 'content']) || typeof input.revision !== 'string' ||
131
+ !/^v1-[a-f0-9]{64}$/.test(input.revision) || typeof input.content !== 'string') {
132
+ throw new CliError('BAD_REQUEST', 'Text saves require only path, preview revision and content');
133
+ }
134
+ projectFilePathParts(input.path, false);
135
+ if (!isProjectTextPath(input.path)) throw new CliError('PROJECT_FILE_FORBIDDEN', 'This file type is not editable text');
136
+ if (!input.content.isWellFormed()) throw new CliError('BAD_REQUEST', 'Text must contain valid Unicode');
137
+ const bytes = Buffer.from(input.content, 'utf8');
138
+ if (bytes.length > PROJECT_FILE_LIMITS.textBytes) throw new CliError('PROJECT_FILE_TOO_LARGE', 'Text edits are limited to 1 MiB');
139
+ if (projectTextContent(bytes, false) === null) throw new CliError('BAD_REQUEST', 'Text edits cannot contain binary control characters');
140
+ return bytes;
141
+ }
142
+ function validateUpload(input) {
143
+ if (!exactKeys(input, ['path', 'encoding', 'content']) || input.encoding !== 'base64' || typeof input.content !== 'string') {
144
+ throw new CliError('BAD_REQUEST', 'Uploads require only path, encoding:base64 and content');
145
+ }
146
+ projectFilePathParts(input.path, false);
147
+ if (input.content.length > Math.ceil(PROJECT_FILE_LIMITS.mediaBytes / 3) * 4) throw new CliError('PROJECT_FILE_TOO_LARGE', 'Uploads are limited to 10 MiB');
148
+ if (input.content.length % 4 || !/^[A-Za-z0-9+/]*={0,2}$/.test(input.content)) {
149
+ throw new CliError('BAD_REQUEST', 'Upload content must be canonical base64');
150
+ }
151
+ const bytes = Buffer.from(input.content, 'base64');
152
+ if (bytes.toString('base64') !== input.content) throw new CliError('BAD_REQUEST', 'Upload content must be canonical base64');
153
+ if (bytes.length > PROJECT_FILE_LIMITS.mediaBytes) throw new CliError('PROJECT_FILE_TOO_LARGE', 'Uploads are limited to 10 MiB');
154
+ return bytes;
155
+ }
156
+ function failure(code) {
157
+ const messages = {
158
+ PROJECT_FILE_CONFLICT: 'The file changed since this preview; reload before saving your draft',
159
+ PROJECT_FILE_EXISTS: 'A file already exists at this path; uploads never overwrite it',
160
+ PROJECT_FILE_WRITE_UNCONFIRMED: 'The write result is unknown; read the file back and compare its content before retrying',
161
+ PROJECT_FILE_WRITE_UNAVAILABLE: 'The file could not be safely written',
162
+ PROJECT_FILE_FORBIDDEN: 'Only ordinary project files with one link may be edited',
163
+ PROJECT_FILE_TOO_LARGE: 'The file exceeds the allowed write size',
164
+ PROJECT_FILE_NOT_FOUND: 'The target file or its parent directory no longer exists',
165
+ PROJECT_FILE_CHANGED: 'The selected project directory changed before the write'
166
+ };
167
+ const normalized = code === 'ENOENT' || code === 'ENOTDIR' ? 'PROJECT_FILE_NOT_FOUND'
168
+ : ['EACCES', 'EPERM', 'ELOOP'].includes(code) ? 'PROJECT_FILE_FORBIDDEN'
169
+ : Object.hasOwn(messages, code) ? code : 'PROJECT_FILE_WRITE_UNAVAILABLE';
170
+ return new CliError(normalized, messages[normalized]);
171
+ }
172
+
173
+ // Creating or replacing a child changes directory timestamps and link counts.
174
+ // After publication only directory identity is stable, but the requested path
175
+ // must still resolve through the same ancestors before reporting success.
176
+ async function verifyPublishedParent(snapshot, handle) {
177
+ snapshot.rootIdentity?.assertUnchanged();
178
+ if (await fs.promises.realpath(snapshot.root) !== snapshot.base ||
179
+ await fs.promises.realpath(snapshot.absolute) !== snapshot.absolute) throw failure('PROJECT_FILE_WRITE_UNCONFIRMED');
180
+ const identityMatches = (current, original) => current.isDirectory() &&
181
+ current.dev === original.dev && current.ino === original.ino && current.birthtimeNs === original.birthtimeNs;
182
+ for (const item of snapshot.chain) {
183
+ const current = await fs.promises.lstat(item.absolute, { bigint: true });
184
+ if (!identityMatches(current, item.stat)) throw failure('PROJECT_FILE_WRITE_UNCONFIRMED');
185
+ }
186
+ if (!identityMatches(await handle.stat({ bigint: true }), snapshot.stat)) throw failure('PROJECT_FILE_WRITE_UNCONFIRMED');
187
+ snapshot.rootIdentity?.assertUnchanged();
188
+ }
189
+
190
+ async function writeProjectFile(root, input, operation, bytes, { rootIdentity, spawnProcess = spawnSync } = {}) {
191
+ if (!['darwin', 'linux'].includes(process.platform) || !fs.constants.O_NOFOLLOW || !fs.constants.O_DIRECTORY) throw failure('PROJECT_FILE_WRITE_UNAVAILABLE');
192
+ const parent = await prepareProjectFileParent(root, input.path, { rootIdentity });
193
+ let receipt;
194
+ try {
195
+ await verifyProjectFileParent(parent.snapshot, parent.handle);
196
+ rootIdentity?.assertUnchanged();
197
+ const expectedIdentity = { canonical: parent.snapshot.absolute, identity: {
198
+ dev: parent.snapshot.stat.dev.toString(), ino: parent.snapshot.stat.ino.toString(),
199
+ birthtimeNs: parent.snapshot.stat.birthtimeNs?.toString() || null } };
200
+ receipt = withFileLock(path.join(parent.snapshot.absolute, parent.name), () => {
201
+ rootIdentity?.assertUnchanged();
202
+ const binding = preparePinnedCwdLaunch(parent.snapshot.absolute, process.execPath, ['-e', workerSource()], {
203
+ purpose: 'maintenance', expectedIdentity,
204
+ env: { PATH: '/usr/bin:/bin', NODE_OPTIONS: '', NODE_NO_WARNINGS: '1' }
205
+ });
206
+ try {
207
+ const result = spawnProcess(binding.command, binding.args, { cwd: binding.cwd, env: binding.env,
208
+ input: JSON.stringify({ operation, name: parent.name, revision: input.revision, content: bytes.toString('base64') }),
209
+ encoding: 'utf8', timeout: 10000, maxBuffer: 65536 });
210
+ if (result.error || result.status !== 0) {
211
+ if (/HCC_PINNED_CWD_CHANGED:/.test(result.stderr || '')) throw failure('PROJECT_FILE_CHANGED');
212
+ // The worker may have published before a timeout or interrupted pipe.
213
+ throw failure('PROJECT_FILE_WRITE_UNCONFIRMED');
214
+ }
215
+ let value;
216
+ try { value = JSON.parse(result.stdout); } catch { throw failure('PROJECT_FILE_WRITE_UNCONFIRMED'); }
217
+ if (value.error) throw failure(value.error.code);
218
+ const valueReceipt = value.receipt;
219
+ if (!valueReceipt || valueReceipt.size !== bytes.length ||
220
+ valueReceipt.contentHash !== createHash('sha256').update(bytes).digest('hex') ||
221
+ (operation === 'save' ? valueReceipt.saved !== true || !/^v1-[a-f0-9]{64}$/.test(valueReceipt.revision) : valueReceipt.created !== true)) {
222
+ throw failure('PROJECT_FILE_WRITE_UNCONFIRMED');
223
+ }
224
+ // Record publication before the lock/bootstrap release paths run.
225
+ receipt = valueReceipt;
226
+ try { rootIdentity?.assertUnchanged(); } catch { throw failure('PROJECT_FILE_WRITE_UNCONFIRMED'); }
227
+ return valueReceipt;
228
+ } finally { binding.release(); }
229
+ }, { createParent: false, timeoutMs: 5000 });
230
+ await verifyPublishedParent(parent.snapshot, parent.handle);
231
+ return { path: input.path, ...receipt };
232
+ } catch (error) {
233
+ if (receipt) throw failure('PROJECT_FILE_WRITE_UNCONFIRMED');
234
+ if (error instanceof CliError) throw error;
235
+ throw failure(error.code);
236
+ } finally {
237
+ try { await parent.handle.close(); }
238
+ catch { if (receipt) throw failure('PROJECT_FILE_WRITE_UNCONFIRMED'); }
239
+ }
240
+ }
241
+
242
+ export async function saveProjectFile(root, input, options) {
243
+ const bytes = validateSave(input);
244
+ return writeProjectFile(root, input, 'save', bytes, options);
245
+ }
246
+ export async function uploadProjectFile(root, input, options) {
247
+ const bytes = validateUpload(input);
248
+ return writeProjectFile(root, input, 'upload', bytes, options);
249
+ }