@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.
- package/CHANGELOG.md +72 -0
- package/docs/codex-account.zh-CN.md +38 -0
- package/docs/codex-protocol.md +59 -0
- package/docs/commands.md +9 -0
- package/docs/commands.zh-CN.md +7 -0
- package/docs/macos-process-identity.md +2 -0
- package/docs/native.md +179 -14
- package/docs/native.zh-CN.md +136 -6
- package/docs/web-handoff.zh-CN.md +118 -8
- package/lib/cli/app.mjs +47 -4
- package/lib/cli/commands/coordination.mjs +14 -4
- package/lib/cli/commands/dsh.mjs +1 -1
- package/lib/cli/commands/gc.mjs +22 -15
- package/lib/cli/commands/hook.mjs +19 -3
- package/lib/cli/commands/install.mjs +80 -10
- package/lib/cli/commands/misc.mjs +36 -9
- package/lib/cli/commands/native.mjs +4 -38
- package/lib/cli/commands/peer.mjs +3 -0
- package/lib/cli/commands/run.mjs +13 -6
- package/lib/cli/commands/tmux.mjs +1 -2
- package/lib/cli-runtime.mjs +19 -2
- package/lib/core/coordination/results.mjs +27 -10
- package/lib/core/sessions/hook-root-identity.mjs +47 -0
- package/lib/core/sessions/launch.mjs +7 -1
- package/lib/db/connection.mjs +140 -42
- package/lib/db/schema.mjs +59 -0
- package/lib/diagnostics/codex-protocol.mjs +105 -0
- package/lib/guidance.mjs +21 -36
- package/lib/integrations/codex-account.mjs +143 -0
- package/lib/integrations/codex-interactions.mjs +13 -11
- package/lib/integrations/dsh-collaboration.mjs +6 -12
- package/lib/integrations/dsh-cordis.mjs +17 -13
- package/lib/integrations/dsh.mjs +16 -11
- package/lib/integrations/mcp-elicitation.mjs +116 -0
- package/lib/integrations/mcp-url-elicitation.mjs +25 -0
- package/lib/integrations/native/claude.mjs +24 -6
- package/lib/integrations/native/codex.mjs +38 -7
- package/lib/integrations/native/dsh-acp.mjs +67 -24
- package/lib/integrations/native/index.mjs +4 -1
- package/lib/integrations/native/interactions.mjs +2 -1
- package/lib/integrations/native/jsonrpc.mjs +37 -5
- package/lib/integrations/native/telemetry.mjs +30 -0
- package/lib/process/identity.mjs +4 -0
- package/lib/process/pinned-cwd.mjs +302 -0
- package/lib/process/pinned-guidance.mjs +138 -0
- package/lib/process/pinned-project-state.mjs +105 -0
- package/lib/process/selected-cwd-identity.mjs +95 -0
- package/lib/runtime/buffer-evidence.mjs +2 -1
- package/lib/runtime/client.mjs +9 -0
- package/lib/runtime/native/client.mjs +4 -1
- package/lib/runtime/native/history.mjs +140 -0
- package/lib/runtime/native/launcher.mjs +106 -0
- package/lib/runtime/native/service.mjs +64 -8
- package/lib/runtime/native/store.mjs +83 -58
- package/lib/runtime/paths.mjs +17 -2
- package/lib/runtime/private-file.mjs +76 -0
- package/lib/runtime/private-state.mjs +546 -0
- package/lib/runtime/project-context.mjs +2 -1
- package/lib/runtime/project-path.mjs +92 -20
- package/lib/runtime/project-trust.mjs +79 -0
- package/lib/runtime/projects.mjs +9 -3
- package/lib/runtime/state-migration.mjs +697 -0
- package/lib/runtime/state.mjs +69 -27
- package/lib/shared/automatic-result-key.mjs +6 -0
- package/lib/shared/json-file.mjs +55 -6
- package/lib/shared/redact.mjs +2 -1
- package/lib/ui/help.mjs +6 -4
- package/lib/web/agent-defaults.mjs +110 -0
- package/lib/web/auto-attach.mjs +3 -2
- package/lib/web/browser/contracts.d.ts +10 -0
- package/lib/web/browser/core.mjs +1997 -0
- package/lib/web/browser/project-data.mjs +158 -0
- package/lib/web/browser/session-sync.mjs +82 -0
- package/lib/web/browser-assets.mjs +19 -0
- package/lib/web/buffer-gc-runtime.mjs +2 -2
- package/lib/web/codex-app-server.mjs +111 -37
- package/lib/web/codex-sessions.mjs +61 -12
- package/lib/web/context-files.mjs +82 -0
- package/lib/web/cookie-auth.mjs +51 -3
- package/lib/web/external-sessions.mjs +76 -9
- package/lib/web/http-routes.mjs +238 -25
- package/lib/web/http.mjs +50 -4
- package/lib/web/liveness-reaper.mjs +2 -2
- package/lib/web/native-sessions.mjs +323 -14
- package/lib/web/project-contexts.mjs +236 -44
- package/lib/web/project-file-write.mjs +249 -0
- package/lib/web/project-files.mjs +268 -0
- package/lib/web/pty-sessions.mjs +11 -3
- package/lib/web/runtime-main.mjs +132 -80
- package/lib/web/session-serialize.mjs +87 -8
- package/lib/web/session-state-sync.mjs +98 -0
- package/lib/web/shutdown-diagnostics.mjs +38 -0
- package/lib/web/shutdown-response.mjs +21 -0
- package/lib/web/shutdown-state.mjs +91 -0
- package/lib/web/startup.mjs +103 -35
- package/lib/web/task-results.mjs +19 -19
- package/lib/web/tls.mjs +84 -33
- package/lib/web/tmux-sessions.mjs +73 -9
- package/lib/web/tmux-stream.mjs +36 -8
- package/lib/web/ui-agent-defaults.mjs +149 -0
- package/lib/web/ui-agent-start.mjs +330 -0
- package/lib/web/ui-codex-account.mjs +81 -0
- package/lib/web/ui-codex.mjs +105 -85
- package/lib/web/ui-command-palette.mjs +21 -5
- package/lib/web/ui-file-writes.mjs +220 -0
- package/lib/web/ui-files.mjs +231 -0
- package/lib/web/ui-handoff.mjs +10 -2
- package/lib/web/ui-history.mjs +175 -105
- package/lib/web/ui-i18n.mjs +280 -8
- package/lib/web/ui-interactions.mjs +107 -6
- package/lib/web/ui-native-timeline.mjs +326 -0
- package/lib/web/ui-native.mjs +93 -43
- package/lib/web/ui-review.mjs +5 -3
- package/lib/web/ui-safe-markdown.mjs +79 -0
- package/lib/web/ui-session-tools.mjs +224 -0
- package/lib/web/ui-template.mjs +29 -1968
- package/lib/web/ui-terminal-find.mjs +5 -3
- package/lib/web/ui-workbench.mjs +5 -3
- package/lib/web/ui-workspace.mjs +3 -0
- package/package.json +8 -2
- package/scripts/benchmark-session-sync.mjs +53 -0
- package/scripts/codex-protocol.mjs +88 -0
- package/scripts/dsh-approval-layers-acceptance.mjs +172 -0
- package/scripts/dsh-package-provenance.mjs +118 -0
- package/scripts/mcp-form-live-fixture.mjs +81 -0
- package/scripts/native-bounded-live-acceptance.mjs +193 -0
- package/scripts/native-fixture-root.mjs +49 -0
- package/scripts/native-installed-acceptance.mjs +611 -0
- package/scripts/native-stability-checks.mjs +40 -0
- package/scripts/regression-cookie-expiry.mjs +70 -0
- package/scripts/regression-http-transport.mjs +9 -0
- package/scripts/regression-terminal-marker.mjs +95 -0
- package/scripts/regression.mjs +481 -219
- package/scripts/shim-reentry-probe.mjs +105 -0
- package/scripts/web-acceptance-diagnostics.mjs +90 -0
- package/scripts/web-codex-account-acceptance.mjs +178 -0
- package/scripts/web-file-preview-checks.mjs +302 -0
- package/scripts/web-mcp-form-acceptance.mjs +134 -0
- package/scripts/web-mcp-form-live-acceptance.mjs +368 -0
- package/scripts/web-native-handoff-checks.mjs +128 -0
- package/scripts/web-native-interaction-acceptance.mjs +219 -18
- package/scripts/web-native-interaction-diagnostics.mjs +80 -0
- package/scripts/web-native-model-acceptance.mjs +22 -7
- package/scripts/web-session-tools-checks.mjs +112 -0
- package/scripts/web-workbench-acceptance.mjs +435 -0
- package/scripts/web-workbench-installed-acceptance.mjs +122 -0
package/lib/web/http-routes.mjs
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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 =
|
|
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
|
|
326
|
+
const selected = resolveWebProjectContext(
|
|
169
327
|
requestedRoot,
|
|
170
|
-
input.db ||
|
|
171
|
-
)
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 === '
|
|
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;
|