@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
@@ -2,13 +2,19 @@
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
9
  import { terminalAssetPath } from './terminal-assets.mjs';
10
+ import { browserAssetPath } from './browser-assets.mjs';
11
+ import { listContextFiles } from './context-files.mjs';
12
+ import { createAgentDefaults } from './agent-defaults.mjs';
13
+ import { listProjectFiles, previewProjectFile, inspectProjectFileStatus, projectFileErrorStatus } from './project-files.mjs';
14
+ import { saveProjectFile, uploadProjectFile } from './project-file-write.mjs';
11
15
  import { projectRecord } from '../runtime/projects.mjs';
16
+ import { projectDbPath } from '../runtime/paths.mjs';
17
+ import { resolveProjectDatabase } from '../runtime/project-path.mjs';
12
18
  import { writeRuntime } from '../runtime/state.mjs';
13
19
  import { normalizeStateResources } from '../ui/state-render.mjs';
14
20
  import { requestUrl, runtimeBaseUrl } from '../web/runtime.mjs';
@@ -17,6 +23,60 @@ import { API_VERSION, apiVersionUnsupportedBody, readHttpApiVersion } from '../w
17
23
  import { isDshHookBinding } from '../core/peers/bindings.mjs';
18
24
  import { writeAdoptionState } from './adoption-state.mjs';
19
25
  import { CodexAppServerError } from './codex-app-server.mjs';
26
+ import { sameSelectedCwdIdentity } from '../process/selected-cwd-identity.mjs';
27
+ import { scheduleResponseShutdown } from './shutdown-response.mjs';
28
+
29
+ // Keep unknown API requests out of project resolution: resolving an arbitrary
30
+ // root can create its managed state directory and refresh the project registry
31
+ // even when the dispatcher ultimately returns 404. Null means the matching
32
+ // dispatcher branch handles its own method error.
33
+ const projectApiPaths = new Map([
34
+ ['/api/agent-defaults', null],
35
+ ['/api/files/tree', ['GET']], ['/api/files/preview', ['GET']],
36
+ ['/api/files/status', ['GET']], ['/api/files/content', ['PUT']], ['/api/files/upload', ['POST']],
37
+ ['/api/context/files', ['GET']],
38
+ ['/api/projects/select', ['POST']], ['/api/projects', ['GET', 'POST']],
39
+ ['/api/state', ['GET']], ['/api/detected', ['GET']], ['/api/resumable', ['GET']],
40
+ ['/api/runtime', ['GET']], ['/api/runtime/gc-buffers', ['POST']], ['/api/runtime/stop', ['POST']],
41
+ ['/api/sessions', ['GET', 'POST']], ['/api/sessions/attach', ['POST']],
42
+ ['/api/native/history', ['GET']], ['/api/codex/threads', ['GET']]
43
+ ]);
44
+ const projectApiPatterns = [
45
+ [/^\/api\/peers\/[^/]+\/actions\/[^/]+$/, null],
46
+ [/^\/api\/native\/history\/[^/]+(?:\/resume)?$/, null],
47
+ [/^\/api\/sessions\/[^/]+\/native\/([^/]+)$/, null, {
48
+ GET: ['state', 'account'], POST: ['send', 'interrupt', 'close', 'respond']
49
+ }],
50
+ [/^\/api\/codex\/threads\/[^/]+(?:\/fork)?$/, null],
51
+ [/^\/api\/sessions\/[^/]+\/results$/, ['GET', 'POST']],
52
+ [/^\/api\/sessions\/[^/]+\/codex\/([^/]+)$/, null, {
53
+ GET: ['state', 'read', 'account'], POST: ['turn', 'steer', 'interrupt', 'approve']
54
+ }],
55
+ [/^\/api\/sessions\/[^/]+\/(?:input|stop)$/, ['POST']],
56
+ [/^\/api\/detected\/[^/]+\/(?:msg|stop|restart)$/, ['POST']]
57
+ ];
58
+
59
+ function projectApiRouteExists(method, pathname) {
60
+ if (projectApiPaths.has(pathname)) {
61
+ const methods = projectApiPaths.get(pathname);
62
+ return methods === null || methods.includes(method);
63
+ }
64
+ return projectApiPatterns.some(([pattern, methods, actions]) => {
65
+ const match = pathname.match(pattern);
66
+ if (!match || methods !== null && !methods.includes(method)) return false;
67
+ if (!actions) return true;
68
+ try { return Array.isArray(actions[method]) && actions[method].includes(decodeURIComponent(match[1])); }
69
+ catch { return false; }
70
+ });
71
+ }
72
+
73
+ function legacyForbiddenCodexAction(method, pathname) {
74
+ if (method !== 'POST') return false;
75
+ const match = pathname.match(/^\/api\/sessions\/[^/]+\/codex\/([^/]+)$/);
76
+ if (!match) return false;
77
+ try { return ['account', 'login', 'logout', 'refreshToken'].includes(decodeURIComponent(match[1])); }
78
+ catch { return false; }
79
+ }
20
80
 
21
81
  export function createHttpRoutes(deps) {
22
82
  const {
@@ -25,21 +85,32 @@ export function createHttpRoutes(deps) {
25
85
  broadcast, serializeSession, sessionsForProject, resolveSessionPeerId,
26
86
  parseCookieSid, closeWebSession, issueSession, sessionCookieHeader,
27
87
  expiredSessionCookieHeader, cookieSessionOk, webAuthMode,
28
- rememberProject, knownProjects, resolveWebProjectContext,
88
+ rememberProject, knownProjects, selectedProjectIdentity, resolveWebProjectContext,
29
89
  connectWebProject, projectFromRequest, getSession, resolveWebActionSession,
30
90
  prepareRuntimeBufferGc, applyPreparedRuntimeBufferGc,
31
91
  detachTmuxSession, safeTmuxKillPlan, executeTmuxKillPlan,
32
92
  killDbProvenTmuxSession, attachTmuxSession, writeSessionInput, startSession,
33
93
  startCodexSession, codexAction, stopCodexSession, assertWebWrite,
34
94
  listCodexThreads, readCodexThread, forkCodexThread, readSessionResults, writeSessionResult,
35
- discoverNativeSessions, nativeAction,
95
+ startNativeSession, listNativeHistory, nativeWorkerHistory, resumeNativeSession, discoverNativeSessions, nativeAction,
36
96
  webStatusSnapshot, webPeerActionForProject,
37
97
  now, addEvent, auditPayload, sendMessage,
38
98
  getProcessIdentity, getActualPort,
39
- shutdown, tlsCredentials,
99
+ shutdown, prepareShutdown = () => {}, isStopping = () => false,
100
+ shutdownDiagnostic = () => {}, tlsCredentials,
40
101
  renderWebIndex, renderWebLogin, sendWebHtml, webErrorStatus,
41
102
  ACTIVE_PEER_TTL, DETECTED_PEER_MAX_AGE, PRODUCT_NAME, VERSION
42
103
  } = deps;
104
+ const { readAgentDefaults, saveAgentDefaults } = createAgentDefaults({ connectWebProject });
105
+
106
+ function assertRunning() {
107
+ if (isStopping()) throw new CliError('RUNTIME_STOPPING', 'Web runtime is stopping');
108
+ }
109
+ async function readActiveJsonRequest(req, options) {
110
+ const input = await readJsonRequest(req, options);
111
+ assertRunning();
112
+ return input;
113
+ }
43
114
 
44
115
  function ownsExecutor(session) {
45
116
  return session.status === 'running' || (session.type === 'app-server' &&
@@ -129,6 +200,10 @@ export function createHttpRoutes(deps) {
129
200
  sendJson(res, 401, { ok: false, error: { code: 'UNAUTHORIZED', message: 'Missing or invalid token' } });
130
201
  return;
131
202
  }
203
+ if (req.method === 'GET' && browserAssetPath(url.pathname)) {
204
+ sendFile(res, browserAssetPath(url.pathname), 'application/javascript; charset=utf-8');
205
+ return;
206
+ }
132
207
  if (req.method === 'GET' && url.pathname === '/pane') {
133
208
  // Only an existing browser session may create a workspace pane. Never
134
209
  // exchange a query token here or make the login page embeddable.
@@ -157,18 +232,112 @@ export function createHttpRoutes(deps) {
157
232
  res.end();
158
233
  return;
159
234
  }
160
- const reqCtx = projectFromRequest(req, url);
235
+ // All remaining routes are versioned API routes. An unknown top-level
236
+ // navigation carries a SameSite=Lax cookie but cannot supply the API
237
+ // version header; do not resolve (and possibly create) a project before
238
+ // returning its 404.
239
+ if (!url.pathname.startsWith('/api/')) {
240
+ sendJson(res, 404, { ok: false, error: { code: 'NOT_FOUND', message: 'Route not found' } });
241
+ return;
242
+ }
243
+ if (!projectApiRouteExists(req.method, url.pathname)) {
244
+ // The legacy Codex account-control POSTs have a stable BAD_REQUEST
245
+ // contract. Reject them here without resolving an arbitrary project.
246
+ if (legacyForbiddenCodexAction(req.method, url.pathname)) {
247
+ throw new CliError('BAD_REQUEST', 'Unsupported Codex action');
248
+ }
249
+ sendJson(res, 404, { ok: false, error: { code: 'NOT_FOUND', message: 'Route not found' } });
250
+ return;
251
+ }
252
+ if (url.pathname !== '/api/runtime/stop' && url.pathname !== '/api/runtime') assertRunning();
253
+ // Project registration may explicitly name a replacement root in the
254
+ // JSON body. Read it after auth/origin checks, then resolve that root
255
+ // directly; falling through the stale startup root would reject B
256
+ // before its explicit selection is even examined.
257
+ const projectInput = req.method === 'POST' && url.pathname === '/api/projects'
258
+ ? await readActiveJsonRequest(req) : null;
259
+ // Reject an outside DB before resolving the body-selected root, which
260
+ // can create its managed state directory and record registry activity.
261
+ if (projectInput?.db) {
262
+ resolveProjectDatabase({
263
+ root: projectInput.root || url.searchParams.get('root') ||
264
+ url.searchParams.get('project') || req.headers['x-hcc-root'] || ctx.root,
265
+ db: projectInput.db,
266
+ createStateDir: false
267
+ });
268
+ }
269
+ const projectUrl = projectInput?.root && !url.searchParams.has('root') &&
270
+ !url.searchParams.has('project') && !req.headers['x-hcc-root']
271
+ ? new URL(url) : url;
272
+ if (projectUrl !== url) projectUrl.searchParams.set('root', projectInput.root);
273
+ const reqCtx = projectFromRequest(req, projectUrl, { requireIdentity: cookieAuthenticated });
274
+ if (url.pathname === '/api/agent-defaults') {
275
+ if (req.method === 'GET') { sendJson(res, 200, readAgentDefaults(reqCtx)); return; }
276
+ if (req.method === 'PUT') { sendJson(res, 200, saveAgentDefaults(reqCtx, await readActiveJsonRequest(req))); return; }
277
+ throw new CliError('BAD_REQUEST', 'Agent defaults support GET and PUT only');
278
+ }
279
+
280
+ if (req.method === 'GET' && url.pathname === '/api/files/tree') {
281
+ sendJson(res, 200, await listProjectFiles(reqCtx.root, url.searchParams.get('path') || '',
282
+ { rootIdentity: reqCtx.rootIdentity }));
283
+ return;
284
+ }
285
+ if (req.method === 'GET' && url.pathname === '/api/files/preview') {
286
+ sendJson(res, 200, await previewProjectFile(reqCtx.root, url.searchParams.get('path'),
287
+ { rootIdentity: reqCtx.rootIdentity }));
288
+ return;
289
+ }
290
+ if (req.method === 'GET' && url.pathname === '/api/files/status') {
291
+ sendJson(res, 200, await inspectProjectFileStatus(reqCtx.root, url.searchParams.get('path'),
292
+ { rootIdentity: reqCtx.rootIdentity })); return;
293
+ }
294
+ if (req.method === 'PUT' && url.pathname === '/api/files/content') {
295
+ const input = await readActiveJsonRequest(req, { maxBytes: 6 * 1024 * 1024 + 65536 });
296
+ sendJson(res, 200, await saveProjectFile(reqCtx.root, input, { rootIdentity: reqCtx.rootIdentity })); return;
297
+ }
298
+ if (req.method === 'POST' && url.pathname === '/api/files/upload') {
299
+ const input = await readActiveJsonRequest(req, { maxBytes: 14 * 1024 * 1024 + 65536 });
300
+ sendJson(res, 201, await uploadProjectFile(reqCtx.root, input, { rootIdentity: reqCtx.rootIdentity })); return;
301
+ }
302
+ if (req.method === 'GET' && url.pathname === '/api/context/files') {
303
+ sendJson(res, 200, await listContextFiles(reqCtx.root, url.searchParams.get('query') || '',
304
+ { rootIdentity: reqCtx.rootIdentity }));
305
+ return;
306
+ }
307
+ if (req.method === 'POST' && url.pathname === '/api/projects/select') {
308
+ // Selection is deliberate and may name any accessible server directory.
309
+ // It is not constrained to the registry, and does not inherit a stale
310
+ // identity from the previously selected browser project.
311
+ const selected = rememberProject(reqCtx, { register: true, nonblocking: true });
312
+ sendJson(res, 200, { projects: knownProjects(), current: projectRecord(selected),
313
+ project_identity: selectedProjectIdentity(selected) });
314
+ return;
315
+ }
161
316
  if (req.method === 'GET' && url.pathname === '/api/projects') {
162
- sendJson(res, 200, { projects: knownProjects(), current: projectRecord(reqCtx) });
317
+ sendJson(res, 200, { projects: knownProjects(), current: projectRecord(reqCtx), project_identity: selectedProjectIdentity(reqCtx) });
163
318
  return;
164
319
  }
165
320
  if (req.method === 'POST' && url.pathname === '/api/projects') {
166
- const input = await readJsonRequest(req);
321
+ const input = projectInput;
322
+ if (!input || typeof input !== 'object' || Array.isArray(input)) {
323
+ throw new CliError('BAD_REQUEST', 'Project registration requires a JSON object');
324
+ }
167
325
  const requestedRoot = input.root || reqCtx.root;
168
- const projectCtx = rememberProject(resolveWebProjectContext(
326
+ const selected = resolveWebProjectContext(
169
327
  requestedRoot,
170
- input.db || path.join(requestedRoot, '.hello-cc', 'mesh.db')
171
- ), { register: true, nonblocking: true });
328
+ input.db || projectDbPath(requestedRoot)
329
+ );
330
+ let projectCtx;
331
+ try {
332
+ if ((req.headers['x-hcc-root-identity'] !== undefined || url.searchParams.has('root_identity')) &&
333
+ !sameSelectedCwdIdentity(reqCtx.rootIdentity, selected.rootIdentity)) {
334
+ throw new CliError('PROJECT_PATH_CHANGED', 'Selected project directory changed during request');
335
+ }
336
+ projectCtx = rememberProject(selected, { register: true, nonblocking: true });
337
+ } catch (error) {
338
+ selected.rootIdentity?.release();
339
+ throw error;
340
+ }
172
341
  const db = connectWebProject(projectCtx);
173
342
  db.close();
174
343
  writeRuntime(projectCtx, {
@@ -190,7 +359,7 @@ export function createHttpRoutes(deps) {
190
359
  global_runtime: true,
191
360
  started_at: now()
192
361
  });
193
- sendJson(res, 200, { project: projectRecord(projectCtx), projects: knownProjects() });
362
+ sendJson(res, 200, { project: projectRecord(projectCtx), projects: knownProjects(), project_identity: selectedProjectIdentity(projectCtx) });
194
363
  return;
195
364
  }
196
365
  if (req.method === 'GET' && url.pathname === '/api/state') {
@@ -223,7 +392,7 @@ export function createHttpRoutes(deps) {
223
392
  ...Object.fromEntries(url.searchParams.entries()),
224
393
  resource: url.searchParams.getAll('resource')
225
394
  }
226
- : await readJsonRequest(req);
395
+ : await readActiveJsonRequest(req);
227
396
  if (!readOnly && nativeAction) {
228
397
  const nativeSession = sessionsForProject(reqCtx).find(s => s.type === 'native' && [s.id, s.peerId].includes(peer));
229
398
  if (nativeSession) await nativeAction(nativeSession, 'read');
@@ -321,7 +490,7 @@ export function createHttpRoutes(deps) {
321
490
  return;
322
491
  }
323
492
  if (req.method === 'POST' && url.pathname === '/api/runtime/gc-buffers') {
324
- const input = await readJsonRequest(req);
493
+ const input = await readActiveJsonRequest(req);
325
494
  if (!input || typeof input !== 'object' || Array.isArray(input)) {
326
495
  throw new CliError('BAD_REQUEST', 'buffer GC request must be an object');
327
496
  }
@@ -354,8 +523,10 @@ export function createHttpRoutes(deps) {
354
523
  }
355
524
  if (req.method === 'POST' && url.pathname === '/api/runtime/stop') {
356
525
  if (cookieAuthenticated) throw new CliError('RUNTIME_ADMIN_REQUIRED', 'Stop the runtime with the local administrator CLI');
526
+ shutdownDiagnostic('stop_accepted');
527
+ prepareShutdown();
528
+ scheduleResponseShutdown(res, shutdown);
357
529
  sendJson(res, 200, { ok: true, pid: process.pid });
358
- setTimeout(shutdown, 10);
359
530
  return;
360
531
  }
361
532
  if (req.method === 'GET' && url.pathname === '/api/sessions') {
@@ -370,8 +541,34 @@ export function createHttpRoutes(deps) {
370
541
  }
371
542
  return;
372
543
  }
544
+ if (req.method === 'GET' && url.pathname === '/api/native/history') {
545
+ sendJson(res, 200, await listNativeHistory(reqCtx)); return;
546
+ }
547
+ const nativeHistoryMatch = url.pathname.match(/^\/api\/native\/history\/([^/]+)(\/resume)?$/);
548
+ if (nativeHistoryMatch) {
549
+ const peer = decodeURIComponent(nativeHistoryMatch[1]);
550
+ if (req.method === 'GET' && !nativeHistoryMatch[2]) {
551
+ const afterText = url.searchParams.get('after') || '0';
552
+ if (!/^(?:0|[1-9][0-9]*)$/.test(afterText) || !Number.isSafeInteger(Number(afterText))) throw new CliError('BAD_REQUEST', 'History cursor must be a nonnegative safe integer');
553
+ sendJson(res, 200, await nativeWorkerHistory(reqCtx, peer, { after: Number(afterText) })); return;
554
+ }
555
+ if (req.method === 'POST' && nativeHistoryMatch[2]) {
556
+ const session = await resumeNativeSession(reqCtx, peer, await readActiveJsonRequest(req));
557
+ sendJson(res, 200, { session: serializeSession(session) }); return;
558
+ }
559
+ throw new CliError('BAD_REQUEST', 'Unsupported native history action');
560
+ }
373
561
  if (req.method === 'POST' && url.pathname === '/api/sessions') {
374
- const input = await readJsonRequest(req);
562
+ const input = await readActiveJsonRequest(req);
563
+ if (!input || typeof input !== 'object' || Array.isArray(input)) throw new CliError('BAD_REQUEST', 'Session creation requires a JSON object');
564
+ if (input.transport === 'native' || input.backend === 'native') {
565
+ if (Object.keys(input).some(key => !['transport', 'kind', 'id', 'cwd', 'model'].includes(key))) {
566
+ throw new CliError('BAD_REQUEST', 'Native creation accepts only transport, kind, id, cwd and model');
567
+ }
568
+ const session = await startNativeSession({ ...input, projectCtx: reqCtx });
569
+ sendJson(res, 200, { session: serializeSession(session) });
570
+ return;
571
+ }
375
572
  const existing = input.id && sessionsForProject(reqCtx).find(s => s.id === input.id || s.peerId === input.id);
376
573
  if (existing) assertWebWrite?.(existing, input, authMode, req);
377
574
  const start = input.transport === 'app-server' || input.backend === 'app-server' ? startCodexSession : startSession;
@@ -381,7 +578,7 @@ export function createHttpRoutes(deps) {
381
578
  return;
382
579
  }
383
580
  if (req.method === 'POST' && url.pathname === '/api/sessions/attach') {
384
- const input = await readJsonRequest(req);
581
+ const input = await readActiveJsonRequest(req);
385
582
  const bindingDb = connectWebProject(reqCtx);
386
583
  let binding;
387
584
  try {
@@ -416,8 +613,12 @@ export function createHttpRoutes(deps) {
416
613
  if (req.method === 'GET' && action === 'state') {
417
614
  sendJson(res, 200, { state: await nativeAction(session, 'read') }); return;
418
615
  }
616
+ if (req.method === 'GET' && action === 'account') {
617
+ sendJson(res, 200, { state: await nativeAction(session, 'account', {
618
+ generation: url.searchParams.get('generation'), owner: url.searchParams.get('owner'), sessionId: url.searchParams.get('sessionId') }) }); return;
619
+ }
419
620
  if (req.method !== 'POST' || !['send', 'interrupt', 'close', 'respond'].includes(action)) throw new CliError('BAD_REQUEST', 'Unsupported native action');
420
- const input = await readJsonRequest(req);
621
+ const input = await readActiveJsonRequest(req);
421
622
  const authorizeMutation = () => assertWebWrite(session, input, 'cookie', req);
422
623
  authorizeMutation();
423
624
  const receipt = await nativeAction(session, action, { ...input, authorizeMutation });
@@ -436,7 +637,7 @@ export function createHttpRoutes(deps) {
436
637
  sendJson(res, 200, await readCodexThread(reqCtx, threadId)); return;
437
638
  }
438
639
  if (req.method !== 'POST' || !historyMatch[2]) throw new CliError('BAD_REQUEST', 'Unsupported history action');
439
- const input = await readJsonRequest(req);
640
+ const input = await readActiveJsonRequest(req);
440
641
  // Fork keeps the source task owner; it is still a mutation of history
441
642
  // and must respect a source session's browser control when present.
442
643
  for (const source of sessionsForProject(reqCtx)) {
@@ -462,7 +663,7 @@ export function createHttpRoutes(deps) {
462
663
  if (!session) throw new CliError('NOT_FOUND', 'Session not found');
463
664
  if (req.method === 'GET') { sendJson(res, 200, readSessionResults(session)); return; }
464
665
  if (req.method !== 'POST') throw new CliError('BAD_REQUEST', 'Unsupported result action');
465
- const input = await readJsonRequest(req);
666
+ const input = await readActiveJsonRequest(req);
466
667
  assertWebWrite(session, input, 'cookie', req);
467
668
  if (session.type === 'native') {
468
669
  await nativeAction(session, 'read');
@@ -484,10 +685,14 @@ export function createHttpRoutes(deps) {
484
685
  if (req.method === 'GET' && action === 'read') {
485
686
  sendJson(res, 200, await codexAction(session, 'read', {})); return;
486
687
  }
688
+ if (req.method === 'GET' && action === 'account') {
689
+ const account = await codexAction(session, 'account', { executorId: url.searchParams.get('executorId') });
690
+ sendJson(res, 200, { executorId: session.adapter.snapshot().executorId, account }); return;
691
+ }
487
692
  if (req.method !== 'POST' || !['turn', 'steer', 'interrupt', 'approve'].includes(action)) {
488
693
  throw new CliError('BAD_REQUEST', 'Unsupported Codex action');
489
694
  }
490
- const input = await readJsonRequest(req);
695
+ const input = await readActiveJsonRequest(req);
491
696
  // Structured turns and approvals always need a live browser controller,
492
697
  // even when the caller also possesses the runtime administrator token.
493
698
  assertWebWrite(session, input, 'cookie', req);
@@ -513,7 +718,7 @@ export function createHttpRoutes(deps) {
513
718
  sendJson(res, 409, { ok: false, error: { code: 'SESSION_NOT_RUNNING', message: 'Session is not running' } });
514
719
  return;
515
720
  }
516
- const input = await readJsonRequest(req);
721
+ const input = await readActiveJsonRequest(req);
517
722
  assertWebWrite?.(session, input, authMode, req);
518
723
  if (['app-server', 'native'].includes(session.type)) throw new CliError('BAD_REQUEST', 'Use structured worker actions');
519
724
  const text = String(input.text ?? input.data ?? '');
@@ -541,7 +746,8 @@ export function createHttpRoutes(deps) {
541
746
  if (req.method === 'POST' && stopMatch) {
542
747
  const id = decodeURIComponent(stopMatch[1]);
543
748
  let stopInput = {};
544
- try { stopInput = await readJsonRequest(req); } catch {}
749
+ try { stopInput = await readActiveJsonRequest(req); } catch {}
750
+ assertRunning();
545
751
  const lookupDb = connectWebProject(reqCtx);
546
752
  let session;
547
753
  try {
@@ -605,7 +811,7 @@ export function createHttpRoutes(deps) {
605
811
  const detectedMsgMatch = url.pathname.match(/^\/api\/detected\/([^/]+)\/msg$/);
606
812
  if (req.method === 'POST' && detectedMsgMatch) {
607
813
  const peerId = decodeURIComponent(detectedMsgMatch[1]);
608
- const input = await readJsonRequest(req);
814
+ const input = await readActiveJsonRequest(req);
609
815
  if (['all', '*'].includes(peerId)) throw new CliError('CONTROL_REQUIRED', 'Detected inbox messaging requires an individual peer; use a controlled coordination action for broadcast');
610
816
  if (sessionsForProject(reqCtx).some(s => [s.id, s.peerId].includes(peerId))) {
611
817
  throw new CliError('CONTROL_REQUIRED', 'Use the managed session message endpoint and its control lease');
@@ -633,7 +839,8 @@ export function createHttpRoutes(deps) {
633
839
  throw new CliError('CONTROL_REQUIRED', 'Use the managed session stop endpoint and its control lease');
634
840
  }
635
841
  let input = {};
636
- try { input = await readJsonRequest(req); } catch {}
842
+ try { input = await readActiveJsonRequest(req); } catch {}
843
+ assertRunning();
637
844
  const db = connectWebProject(reqCtx);
638
845
  try {
639
846
  const binding = db.prepare('SELECT provider, transport FROM peer_bindings WHERE peer = ?').get(peerId);
@@ -712,12 +919,18 @@ export function createHttpRoutes(deps) {
712
919
  const detail = publicFailure || process.env.HCC_DEBUG
713
920
  ? publicError.message
714
921
  : 'internal server error';
715
- const status = publicError.code === 'DSH_SESSION_CONTROL_UNSUPPORTED' ? 409 : webErrorStatus(publicError);
922
+ const status = publicError.code === 'RUNTIME_STOPPING' ? 503 : projectFileErrorStatus(publicError) || (['DSH_SESSION_CONTROL_UNSUPPORTED', 'AGENT_DEFAULTS_CONFLICT', 'AGENT_DEFAULTS_INVALID'].includes(publicError.code) ? 409
923
+ : publicError.code === 'INVALID_CONTEXT_QUERY' ? 400 : webErrorStatus(publicError));
716
924
  sendJson(res, status, {
717
925
  ok: false,
718
926
  error: {
719
927
  code: publicError.code || 'SERVER_ERROR',
720
928
  message: detail,
929
+ ...(['NATIVE_WORKER_DISCOVERY_FAILED', 'NATIVE_CREATE_UNCONFIRMED', 'NATIVE_RESUME_UNCONFIRMED'].includes(publicError.code) ? {
930
+ extra: { peer: publicError.extra.peer, provider: publicError.extra.provider,
931
+ ...(publicError.extra.executorId ? { executorId: publicError.extra.executorId } : {}),
932
+ created: publicError.extra.created === true, uncertain: publicError.extra.uncertain === true }
933
+ } : {}),
721
934
  ...(publicFailure?.cleanupFailed ? { cleanup_failed: true } : {})
722
935
  }
723
936
  });
package/lib/web/http.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import { timingSafeEqual } from 'node:crypto';
3
+ import { TextDecoder } from 'node:util';
3
4
  import { CliError } from '../shared/errors.mjs';
4
5
 
5
6
  function readRequestBody(req, limit = 1024 * 1024) {
@@ -17,8 +18,8 @@ function readRequestBody(req, limit = 1024 * 1024) {
17
18
  });
18
19
  }
19
20
 
20
- export async function readJsonRequest(req) {
21
- const body = await readRequestBody(req);
21
+ export async function readJsonRequest(req, { maxBytes } = {}) {
22
+ const body = maxBytes === undefined ? await readRequestBody(req) : await readBoundedUtf8Body(req, maxBytes);
22
23
  if (!body.trim()) return {};
23
24
  try {
24
25
  return JSON.parse(body);
@@ -30,6 +31,51 @@ export async function readJsonRequest(req) {
30
31
  }
31
32
  }
32
33
 
34
+ // File writes need a decoded-byte budget and enough JSON space for escaped
35
+ // text or base64. Keep legacy callers' default limits unchanged.
36
+ function readBoundedUtf8Body(req, maxBytes) {
37
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) throw new TypeError('Invalid request byte limit');
38
+ return new Promise((resolve, reject) => {
39
+ const chunks = []; let length = 0, settled = false;
40
+ const tooLarge = () => new CliError('PROJECT_FILE_TOO_LARGE', 'The file request exceeds its byte limit');
41
+ const interrupted = () => new CliError('BAD_REQUEST', 'JSON request body was interrupted');
42
+ function cleanup() {
43
+ req.removeListener('data', onData); req.removeListener('end', onEnd);
44
+ req.removeListener('error', onError); req.removeListener('aborted', onAborted); req.removeListener('close', onClose);
45
+ }
46
+ function fail(error) {
47
+ if (settled) return;
48
+ settled = true; chunks.length = 0; reject(error);
49
+ }
50
+ function onData(chunk) {
51
+ if (settled) return;
52
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
53
+ length += buffer.length;
54
+ // Exiting IncomingMessage's async iterator destroys the request and may
55
+ // close its socket before the route can send JSON. Discard the remainder
56
+ // in flowing mode so an over-limit request still gets its 413 response.
57
+ if (length > maxBytes) { fail(tooLarge()); req.resume(); return; }
58
+ chunks.push(buffer);
59
+ }
60
+ function onEnd() {
61
+ cleanup();
62
+ if (settled) return;
63
+ let body;
64
+ try { body = new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks, length)); }
65
+ catch { fail(new CliError('BAD_REQUEST', 'JSON request body must use valid UTF-8')); return; }
66
+ settled = true; chunks.length = 0; resolve(body);
67
+ }
68
+ function onError(error) { cleanup(); fail(error); }
69
+ function onAborted() { fail(interrupted()); }
70
+ function onClose() { cleanup(); if (!req.readableEnded) fail(interrupted()); }
71
+ req.on('data', onData); req.once('end', onEnd); req.once('error', onError);
72
+ req.once('aborted', onAborted); req.once('close', onClose);
73
+ if (req.destroyed || req.aborted) { cleanup(); fail(interrupted()); return; }
74
+ const declared = req.headers?.['content-length'];
75
+ if (declared && /^[0-9]+$/.test(declared) && Number(declared) > maxBytes) { fail(tooLarge()); req.resume(); }
76
+ });
77
+ }
78
+
33
79
  export function contentSecurityPolicy(nonce, { pane = false } = {}) {
34
80
  const value = String(nonce || '');
35
81
  if (!/^[A-Za-z0-9_-]{16,}$/.test(value)) {
@@ -38,10 +84,10 @@ export function contentSecurityPolicy(nonce, { pane = false } = {}) {
38
84
  return "default-src 'self'; " +
39
85
  `script-src 'self' 'nonce-${value}'; ` +
40
86
  "style-src 'self' 'unsafe-inline'; " +
41
- "img-src 'self' data:; " +
87
+ "img-src 'self' data: blob:; " +
42
88
  "connect-src 'self' ws: wss:; " +
43
89
  "object-src 'none'; base-uri 'none'; " +
44
- (pane === true ? "frame-ancestors 'self'; frame-src 'none'" : "frame-ancestors 'none'");
90
+ (pane === true ? "frame-ancestors 'self'; frame-src 'none'" : "frame-ancestors 'none'; frame-src 'self' blob:");
45
91
  }
46
92
 
47
93
  export function sendHttp(res, status, contentType, body, options = {}) {
@@ -11,7 +11,7 @@ import { peerEvidenceAllowsReap } from '../core/peers/evidence.mjs';
11
11
 
12
12
  export function createLivenessReaper(deps) {
13
13
  const {
14
- ctx, projectContexts, sessions,
14
+ ctx, projectContexts, sessions, isStopping = () => false,
15
15
  sessionsForProject, connectWebProject,
16
16
  now, addEvent,
17
17
  peerEvidenceFromDb, mutatePeerWithEvidence, observeClockSafetyOrThrow,
@@ -57,7 +57,7 @@ export function createLivenessReaper(deps) {
57
57
  }
58
58
 
59
59
  function runClockAwareReaper() {
60
- if (reaperInFlight) return;
60
+ if (isStopping() || reaperInFlight) return;
61
61
  reaperInFlight = true;
62
62
  try {
63
63
  // Probe exactly once per scheduler tick. A clock jump is machine-wide;