@logicseek/hello-cc 1.0.2 → 1.1.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +111 -23
- package/README.md +10 -1
- package/README.zh-CN.md +8 -1
- package/docs/commands.md +28 -4
- package/docs/commands.zh-CN.md +20 -4
- package/docs/dsh.md +2 -2
- package/docs/dsh.zh-CN.md +3 -3
- package/docs/native.md +84 -10
- package/docs/native.zh-CN.md +66 -5
- package/docs/web-handoff.zh-CN.md +11 -3
- package/lib/cli/app.mjs +42 -1
- package/lib/cli/commands/coordination.mjs +7 -2
- package/lib/cli/commands/dsh.mjs +1 -1
- package/lib/cli/commands/gc.mjs +9 -4
- package/lib/cli/commands/hook.mjs +6 -1
- package/lib/cli/commands/install.mjs +48 -6
- package/lib/cli/commands/lifecycle.mjs +14 -3
- package/lib/cli/commands/native.mjs +16 -9
- package/lib/cli/commands/query.mjs +15 -10
- package/lib/cli/commands/run.mjs +7 -6
- package/lib/core/coordination/results.mjs +27 -10
- package/lib/db/connection.mjs +135 -10
- package/lib/db/schema.mjs +59 -0
- package/lib/discover.mjs +4 -4
- package/lib/guidance.mjs +3 -2
- 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 +12 -9
- package/lib/integrations/hooks.mjs +132 -40
- package/lib/integrations/mcp-elicitation.mjs +116 -0
- package/lib/integrations/mcp-url-elicitation.mjs +25 -0
- package/lib/integrations/native/interactions.mjs +2 -1
- package/lib/integrations/peers/identity.mjs +42 -7
- package/lib/process/identity.mjs +15 -14
- package/lib/runtime/buffer-evidence.mjs +2 -1
- package/lib/runtime/native/service.mjs +11 -5
- package/lib/runtime/native/store.mjs +70 -18
- 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 +148 -12
- package/lib/runtime/project-trust.mjs +79 -0
- package/lib/runtime/projects.mjs +6 -3
- package/lib/runtime/state-migration.mjs +697 -0
- package/lib/runtime/state.mjs +22 -11
- package/lib/shared/automatic-result-key.mjs +6 -0
- package/lib/shared/json-file.mjs +55 -6
- package/lib/ui/help.mjs +6 -4
- package/lib/web/codex-app-server.mjs +26 -5
- package/lib/web/codex-sessions.mjs +5 -3
- package/lib/web/cookie-auth.mjs +1 -1
- package/lib/web/external-sessions.mjs +54 -5
- package/lib/web/http-routes.mjs +13 -9
- package/lib/web/http.mjs +20 -2
- package/lib/web/native-sessions.mjs +14 -7
- package/lib/web/peer-actions.mjs +27 -27
- package/lib/web/project-contexts.mjs +2 -3
- package/lib/web/runtime-main.mjs +22 -5
- package/lib/web/runtime.mjs +18 -4
- package/lib/web/session-serialize.mjs +1 -1
- package/lib/web/startup.mjs +115 -22
- package/lib/web/task-results.mjs +20 -17
- package/lib/web/terminal-assets.mjs +18 -0
- package/lib/web/tls.mjs +84 -33
- package/lib/web/tmux-sessions.mjs +2 -2
- package/lib/web/tmux-stream.mjs +3 -4
- package/lib/web/ui-codex.mjs +161 -32
- package/lib/web/ui-interactions.mjs +137 -4
- package/lib/web/ui-native.mjs +31 -13
- package/lib/web/ui-template.mjs +10 -3
- package/package.json +1 -1
- package/scripts/mcp-form-live-fixture.mjs +81 -0
- package/scripts/native-installed-acceptance.mjs +413 -0
- package/scripts/regression.mjs +46 -29
- package/scripts/web-mcp-form-acceptance.mjs +119 -0
- package/scripts/web-mcp-form-live-acceptance.mjs +368 -0
- package/scripts/web-native-interaction-acceptance.mjs +77 -10
package/lib/runtime/state.mjs
CHANGED
|
@@ -5,6 +5,8 @@ import { compareProcessIdentity, inspectProcessIdentity } from '../process/ident
|
|
|
5
5
|
import { CliError } from '../shared/errors.mjs';
|
|
6
6
|
import { withFileLock } from '../shared/file-lock.mjs';
|
|
7
7
|
import { writeJsonSafe } from '../shared/json-file.mjs';
|
|
8
|
+
import { readPrivateTextFile } from './private-file.mjs';
|
|
9
|
+
import { resolveProjectDatabase, secureExistingProjectStateDirectory } from './project-path.mjs';
|
|
8
10
|
import {
|
|
9
11
|
globalRuntimePath,
|
|
10
12
|
runtimePath
|
|
@@ -196,9 +198,10 @@ function runtimeMetadataMatchesPointer(runtime, metadata) {
|
|
|
196
198
|
|
|
197
199
|
export function readGlobalRuntimeFile() {
|
|
198
200
|
const file = globalRuntimePath();
|
|
199
|
-
|
|
201
|
+
const raw = readPrivateTextFile(file);
|
|
202
|
+
if (raw === null) return null;
|
|
200
203
|
try {
|
|
201
|
-
return JSON.parse(
|
|
204
|
+
return JSON.parse(raw);
|
|
202
205
|
} catch {
|
|
203
206
|
// Do not delete on a parse failure: a torn/partial write must not orphan a
|
|
204
207
|
// healthy runtime pointer. Leave it for health probing / restart to reclaim.
|
|
@@ -213,6 +216,8 @@ export function writeGlobalRuntime(runtime) {
|
|
|
213
216
|
}
|
|
214
217
|
|
|
215
218
|
export function writeRuntime(ctx, runtime) {
|
|
219
|
+
// Path derivation alone does not validate the root-to-private-store binding.
|
|
220
|
+
resolveProjectDatabase({ root: ctx.root, createStateDir: true });
|
|
216
221
|
const file = runtimePath(ctx);
|
|
217
222
|
withFileLock(file, (lockedFile) => writeJsonSafe(lockedFile, runtime, { mode: 0o600 }));
|
|
218
223
|
return file;
|
|
@@ -231,10 +236,12 @@ export function readRuntime(ctx, opts = {}) {
|
|
|
231
236
|
source: 'env'
|
|
232
237
|
};
|
|
233
238
|
}
|
|
239
|
+
secureExistingProjectStateDirectory(ctx.root);
|
|
234
240
|
const file = runtimePath(ctx);
|
|
235
|
-
|
|
241
|
+
const raw = readPrivateTextFile(file);
|
|
242
|
+
if (raw !== null) {
|
|
236
243
|
try {
|
|
237
|
-
const runtime = JSON.parse(
|
|
244
|
+
const runtime = JSON.parse(raw);
|
|
238
245
|
if (!runtime.base_url) throw new Error('missing base_url');
|
|
239
246
|
if (!runtimePointerMatchesCurrentProcess(runtime, { inspect: inspectRuntimeProcessIdentity })) {
|
|
240
247
|
throw new Error('runtime process identity does not match');
|
|
@@ -260,9 +267,10 @@ export function readRuntime(ctx, opts = {}) {
|
|
|
260
267
|
}
|
|
261
268
|
|
|
262
269
|
export function readRuntimeFile(ctx) {
|
|
270
|
+
secureExistingProjectStateDirectory(ctx.root);
|
|
263
271
|
const file = runtimePath(ctx);
|
|
264
|
-
|
|
265
|
-
return JSON.parse(
|
|
272
|
+
const raw = readPrivateTextFile(file);
|
|
273
|
+
return raw === null ? null : JSON.parse(raw);
|
|
266
274
|
}
|
|
267
275
|
|
|
268
276
|
export async function probeRuntime(runtime) {
|
|
@@ -303,12 +311,14 @@ export async function readHealthyGlobalRuntime() {
|
|
|
303
311
|
}
|
|
304
312
|
|
|
305
313
|
export function clearRuntime(ctx, pid = process.pid) {
|
|
306
|
-
const file = runtimePath(ctx);
|
|
307
314
|
try {
|
|
315
|
+
secureExistingProjectStateDirectory(ctx.root);
|
|
316
|
+
const file = runtimePath(ctx);
|
|
308
317
|
withFileLock(file, (lockedFile) => {
|
|
309
|
-
|
|
318
|
+
const raw = readPrivateTextFile(lockedFile);
|
|
319
|
+
if (raw === null) return;
|
|
310
320
|
try {
|
|
311
|
-
const runtime = JSON.parse(
|
|
321
|
+
const runtime = JSON.parse(raw);
|
|
312
322
|
if (runtime.pid === pid) fs.rmSync(lockedFile, { force: true });
|
|
313
323
|
} catch {
|
|
314
324
|
// Leave an unparseable pointer in place: only remove when we can confirm
|
|
@@ -319,9 +329,10 @@ export function clearRuntime(ctx, pid = process.pid) {
|
|
|
319
329
|
const globalFile = globalRuntimePath();
|
|
320
330
|
try {
|
|
321
331
|
withFileLock(globalFile, (lockedFile) => {
|
|
322
|
-
|
|
332
|
+
const raw = readPrivateTextFile(lockedFile);
|
|
333
|
+
if (raw === null) return;
|
|
323
334
|
try {
|
|
324
|
-
const runtime = JSON.parse(
|
|
335
|
+
const runtime = JSON.parse(raw);
|
|
325
336
|
if (runtime.pid === pid) fs.rmSync(lockedFile, { force: true });
|
|
326
337
|
} catch {
|
|
327
338
|
// Same as above: do not blind-delete a torn global runtime pointer.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
export function automaticResultKey({ executorId, threadId, turnId, kind, itemId = null }) {
|
|
4
|
+
const identity = [executorId, threadId, turnId, kind, itemId];
|
|
5
|
+
return `automatic:${kind}:${createHash('sha256').update(JSON.stringify(identity)).digest('hex')}`;
|
|
6
|
+
}
|
package/lib/shared/json-file.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
1
2
|
import fs from 'node:fs';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
|
|
@@ -8,11 +9,59 @@ export function readJsonSafe(filePath) {
|
|
|
8
9
|
|
|
9
10
|
export function writeJsonSafe(filePath, data, opts = {}) {
|
|
10
11
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
11
|
-
const tmpPath = filePath
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
12
|
+
const tmpPath = `${filePath}.tmp.${process.pid}.${randomBytes(16).toString('hex')}`;
|
|
13
|
+
const contents = JSON.stringify(data, null, 2) + '\n';
|
|
14
|
+
let fd = null;
|
|
15
|
+
let opened = null;
|
|
16
|
+
let renamed = false;
|
|
17
|
+
try {
|
|
18
|
+
fd = fs.openSync(tmpPath, fs.constants.O_WRONLY | fs.constants.O_CREAT |
|
|
19
|
+
fs.constants.O_EXCL | (fs.constants.O_NOFOLLOW || 0), 0o600);
|
|
20
|
+
opened = fs.fstatSync(fd);
|
|
21
|
+
if (!opened.isFile() || opened.nlink !== 1 ||
|
|
22
|
+
(typeof process.geteuid === 'function' && opened.uid !== process.geteuid())) {
|
|
23
|
+
throw new Error(`unsafe JSON temporary file: ${tmpPath}`);
|
|
24
|
+
}
|
|
25
|
+
if (process.platform !== 'win32') fs.fchmodSync(fd, 0o600);
|
|
26
|
+
const privateStat = fs.fstatSync(fd);
|
|
27
|
+
if (!privateStat.isFile() || privateStat.nlink !== 1 ||
|
|
28
|
+
privateStat.dev !== opened.dev || privateStat.ino !== opened.ino ||
|
|
29
|
+
(typeof process.geteuid === 'function' && privateStat.uid !== process.geteuid()) ||
|
|
30
|
+
(process.platform !== 'win32' && (privateStat.mode & 0o777) !== 0o600)) {
|
|
31
|
+
throw new Error(`unsafe JSON temporary file: ${tmpPath}`);
|
|
32
|
+
}
|
|
33
|
+
fs.writeFileSync(fd, contents, 'utf8');
|
|
34
|
+
if (opts.mode !== undefined && opts.mode !== 0o600 && process.platform !== 'win32') {
|
|
35
|
+
fs.fchmodSync(fd, opts.mode);
|
|
36
|
+
}
|
|
37
|
+
const ready = fs.fstatSync(fd);
|
|
38
|
+
if (!ready.isFile() || ready.nlink !== 1 ||
|
|
39
|
+
ready.dev !== opened.dev || ready.ino !== opened.ino ||
|
|
40
|
+
(typeof process.geteuid === 'function' && ready.uid !== process.geteuid()) ||
|
|
41
|
+
(process.platform !== 'win32' && (ready.mode & 0o777) !== (opts.mode ?? 0o600))) {
|
|
42
|
+
throw new Error(`unsafe JSON temporary file: ${tmpPath}`);
|
|
43
|
+
}
|
|
44
|
+
fs.fsyncSync(fd);
|
|
45
|
+
fs.closeSync(fd);
|
|
46
|
+
fd = null;
|
|
47
|
+
const current = fs.lstatSync(tmpPath);
|
|
48
|
+
if (!current.isFile() || current.nlink !== 1 ||
|
|
49
|
+
current.dev !== opened.dev || current.ino !== opened.ino ||
|
|
50
|
+
(typeof process.geteuid === 'function' && current.uid !== process.geteuid()) ||
|
|
51
|
+
(process.platform !== 'win32' && (current.mode & 0o777) !== (opts.mode ?? 0o600))) {
|
|
52
|
+
throw new Error(`JSON temporary file changed before rename: ${tmpPath}`);
|
|
53
|
+
}
|
|
54
|
+
fs.renameSync(tmpPath, filePath);
|
|
55
|
+
renamed = true;
|
|
56
|
+
} finally {
|
|
57
|
+
if (fd !== null) fs.closeSync(fd);
|
|
58
|
+
if (!renamed && opened) {
|
|
59
|
+
try {
|
|
60
|
+
const current = fs.lstatSync(tmpPath);
|
|
61
|
+
if (current.dev === opened.dev && current.ino === opened.ino) fs.unlinkSync(tmpPath);
|
|
62
|
+
} catch {
|
|
63
|
+
// Preserve the write failure. Any remaining temp file is private.
|
|
64
|
+
}
|
|
65
|
+
}
|
|
17
66
|
}
|
|
18
67
|
}
|
package/lib/ui/help.mjs
CHANGED
|
@@ -21,7 +21,8 @@ Commands:
|
|
|
21
21
|
down Stop the running hello-cc web runtime
|
|
22
22
|
update Update the global npm install of hello-cc
|
|
23
23
|
uninstall Remove hooks, shims, and optional project data
|
|
24
|
-
init Initialize
|
|
24
|
+
init Initialize private managed mesh.db and guidance
|
|
25
|
+
migrate-state --offline --yes Copy stopped legacy state to private per-user storage
|
|
25
26
|
register --peer ID Register or update a peer session identity
|
|
26
27
|
join --peer ID Register this shell and print eval-able env
|
|
27
28
|
env --peer ID Print eval-able HCC_PEER/HCC_ROOT/HCC_DB exports
|
|
@@ -405,9 +406,10 @@ Usage:
|
|
|
405
406
|
Stops the current project runtime and removes user-level hello-cc integrations:
|
|
406
407
|
Claude/Codex hooks, claude/codex shims, and the shell PATH entry for shims.
|
|
407
408
|
|
|
408
|
-
With --purge --yes, also removes
|
|
409
|
-
|
|
410
|
-
|
|
409
|
+
With --purge --yes, also removes the selected managed project state directory
|
|
410
|
+
(project-local for stable roots, otherwise under ~/.hello-cc/projects/) and
|
|
411
|
+
hello-cc blocks from CLAUDE.md and AGENTS.md. An old directory retained by
|
|
412
|
+
offline migration is not removed.
|
|
411
413
|
`);
|
|
412
414
|
},
|
|
413
415
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { redactMcpUrlInteraction } from '../integrations/mcp-url-elicitation.mjs';
|
|
3
4
|
import path from 'node:path';
|
|
4
5
|
import { CODEX_INTERACTION_METHODS, codexInteractionKind, codexInteractionResponse, cancelCodexInteraction, codexInteractiveConfig } from '../integrations/codex-interactions.mjs';
|
|
5
6
|
|
|
@@ -10,6 +11,7 @@ const APPROVAL_METHODS = CODEX_INTERACTION_METHODS;
|
|
|
10
11
|
const TERMINAL_TURNS = new Set(['completed', 'interrupted', 'failed']);
|
|
11
12
|
const MAX_LINE_BYTES = 8 * 1024 * 1024;
|
|
12
13
|
const MAX_TEXT = 65536;
|
|
14
|
+
const STREAM_UPDATE_MS = 50;
|
|
13
15
|
|
|
14
16
|
export class CodexAppServerError extends Error {
|
|
15
17
|
constructor(code, message, extra = {}) {
|
|
@@ -70,6 +72,8 @@ export function createCodexAppServer({
|
|
|
70
72
|
let buffer = '';
|
|
71
73
|
let truncated = false;
|
|
72
74
|
let closePromise = null;
|
|
75
|
+
let streamTimer = null;
|
|
76
|
+
let streamEvent = null;
|
|
73
77
|
const threads = new Map();
|
|
74
78
|
const pending = new Map();
|
|
75
79
|
const approvals = new Map();
|
|
@@ -111,11 +115,26 @@ export function createCodexAppServer({
|
|
|
111
115
|
});
|
|
112
116
|
}
|
|
113
117
|
|
|
118
|
+
function publishChange(event) {
|
|
119
|
+
clearTimeout(streamTimer); streamTimer = null; streamEvent = null;
|
|
120
|
+
try { onChange(snapshot(), structuredClone(event)); } catch { /* UI observers do not own transport. */ }
|
|
121
|
+
}
|
|
122
|
+
|
|
114
123
|
function changed(method, params = {}) {
|
|
115
|
-
const event = { method, params: bounded(params, 8192), at: Date.now() };
|
|
124
|
+
const event = { method, params: bounded(redactMcpUrlInteraction(params), 8192), at: Date.now() };
|
|
116
125
|
events.push(event);
|
|
117
126
|
if (events.length > 100) events.shift();
|
|
118
|
-
|
|
127
|
+
// Keep protocol state current on every token, but copy/broadcast its complete
|
|
128
|
+
// retained history at most once per interval during a text-only burst.
|
|
129
|
+
// Approval, completion, error and connection changes publish immediately;
|
|
130
|
+
// their snapshot includes all pending text and cancels the trailing timer.
|
|
131
|
+
if (method === 'item/agentMessage/delta') {
|
|
132
|
+
streamEvent = event;
|
|
133
|
+
if (!streamTimer) {
|
|
134
|
+
streamTimer = setTimeout(() => publishChange(streamEvent), STREAM_UPDATE_MS);
|
|
135
|
+
streamTimer.unref?.();
|
|
136
|
+
}
|
|
137
|
+
} else publishChange(event);
|
|
119
138
|
}
|
|
120
139
|
|
|
121
140
|
function ensureThread(id) {
|
|
@@ -315,7 +334,8 @@ export function createCodexAppServer({
|
|
|
315
334
|
if (approvals.has(key)) throw new CodexAppServerError('CODEX_PROTOCOL_ERROR', 'server reused an unresolved request id');
|
|
316
335
|
if (approvals.size >= 64) throw new CodexAppServerError('CODEX_PROTOCOL_ERROR', 'too many unresolved approvals');
|
|
317
336
|
thread.activeTurnId = turnId;
|
|
318
|
-
|
|
337
|
+
const inputRequired = codexInteractionKind(message.method) === 'userInput' || (message.method === 'mcpServer/elicitation/request' && params.mode === 'form' && Object.keys(params.requestedSchema?.properties || {}).length > 0);
|
|
338
|
+
thread.status = { type: 'active', activeFlags: [inputRequired ? 'waitingOnUserInput' : 'waitingOnApproval'] };
|
|
319
339
|
const preview = bounded(params);
|
|
320
340
|
approvals.set(key, {
|
|
321
341
|
executorId, requestId: message.id, threadId, turnId, itemId: params.itemId || null,
|
|
@@ -533,7 +553,7 @@ export function createCodexAppServer({
|
|
|
533
553
|
assertActiveTurn(threadId, turnId);
|
|
534
554
|
return call('turn/interrupt', { threadId, turnId }, true).then(result => { expireApprovals(threadId, turnId); changed('approval.expired', { threadId, turnId }); return result; });
|
|
535
555
|
},
|
|
536
|
-
async approve({ executorId: submittedExecutorId, threadId, turnId, requestId, decision, permissions, scope, answers } = {}) {
|
|
556
|
+
async approve({ executorId: submittedExecutorId, threadId, turnId, requestId, decision, permissions, scope, answers, content } = {}) {
|
|
537
557
|
if (submittedExecutorId !== executorId) throw new CodexAppServerError('CODEX_APPROVAL_MISMATCH', 'approval identity or decision is invalid');
|
|
538
558
|
assertActiveTurn(threadId, turnId);
|
|
539
559
|
const approval = approvals.get(rpcKey(requestId));
|
|
@@ -543,13 +563,14 @@ export function createCodexAppServer({
|
|
|
543
563
|
if (approval.truncated && decision === 'accept') {
|
|
544
564
|
throw new CodexAppServerError('CODEX_APPROVAL_TRUNCATED', 'approval details exceed the preview limit; decline or cancel instead');
|
|
545
565
|
}
|
|
546
|
-
const result = codexInteractionResponse({ ...approval, kind: approval.kind || codexInteractionKind(approval.method) }, { decision, permissions, scope, answers });
|
|
566
|
+
const result = codexInteractionResponse({ ...approval, kind: approval.kind || codexInteractionKind(approval.method) }, { decision, permissions, scope, answers, content });
|
|
547
567
|
await initialize();
|
|
548
568
|
// No await between checking a live request and replying to its exact RPC id.
|
|
549
569
|
assertActiveTurn(threadId, turnId);
|
|
550
570
|
if (approvals.get(rpcKey(requestId)) !== approval) throw new CodexAppServerError('CODEX_APPROVAL_MISMATCH', 'approval was resolved');
|
|
551
571
|
write({ id: requestId, result });
|
|
552
572
|
approval.status = 'submitted';
|
|
573
|
+
approval.params = redactMcpUrlInteraction(approval).params;
|
|
553
574
|
changed('approval.submitted', { executorId, threadId, turnId, requestId, decision });
|
|
554
575
|
return { executorId, threadId, turnId, requestId, decision, status: 'submitted' };
|
|
555
576
|
}
|
|
@@ -80,8 +80,9 @@ export function createCodexSessions({ sessions, sessionKey, nextProjectSessionId
|
|
|
80
80
|
peer: id, executorId, ownerIdentity,
|
|
81
81
|
binding: { transport: 'app-server', runtimeSessionId: id } });
|
|
82
82
|
return { mcp_servers: { hello_cc_scoped: session.mcpCapability.config } };
|
|
83
|
-
}, onChange(state) {
|
|
84
|
-
|
|
83
|
+
}, onChange(state, event) {
|
|
84
|
+
session.executorId ||= state.executorId;
|
|
85
|
+
broadcast(session, { type: 'codex_state', state }, event);
|
|
85
86
|
if (state.status === 'disconnected' && !closeScheduled) {
|
|
86
87
|
closeScheduled = true;
|
|
87
88
|
queueMicrotask(() => { if (adapter) void adapter.close(); });
|
|
@@ -114,6 +115,7 @@ export function createCodexSessions({ sessions, sessionKey, nextProjectSessionId
|
|
|
114
115
|
throw new CliError('CODEX_DISCONNECTED', 'Executor disconnected while opening its thread; no session was registered');
|
|
115
116
|
}
|
|
116
117
|
session.pid = executorState.pid || null;
|
|
118
|
+
session.executorId = executorState.executorId;
|
|
117
119
|
const save = connectWebProject(pctx);
|
|
118
120
|
try {
|
|
119
121
|
tx(save, () => {
|
|
@@ -158,7 +160,7 @@ export function createCodexSessions({ sessions, sessionKey, nextProjectSessionId
|
|
|
158
160
|
if (action === 'interrupt') return adapter.interrupt(threadId, String(input.turnId || ''));
|
|
159
161
|
if (action === 'approve') return adapter.approve({ executorId: input.executorId,
|
|
160
162
|
threadId, turnId: input.turnId, requestId: input.requestId, decision: input.decision,
|
|
161
|
-
permissions: input.permissions, scope: input.scope, answers: input.answers });
|
|
163
|
+
permissions: input.permissions, scope: input.scope, answers: input.answers, content: input.content });
|
|
162
164
|
if (!['turn', 'steer'].includes(action)) throw new CliError('BAD_REQUEST', 'Unsupported Codex action');
|
|
163
165
|
const text = String(input.text || '').trim();
|
|
164
166
|
if (!text || text.length > 100000) throw new CliError('BAD_REQUEST', 'A message of 1 to 100000 characters is required');
|
package/lib/web/cookie-auth.mjs
CHANGED
|
@@ -99,7 +99,7 @@ export function createCookieAuth({
|
|
|
99
99
|
}
|
|
100
100
|
|
|
101
101
|
function webAuthMode(url, req) {
|
|
102
|
-
if (authOk(url, req, token)) return 'token';
|
|
102
|
+
if (authOk(url, req, token, { trustProxy, proxyOrigin })) return 'token';
|
|
103
103
|
return cookieSessionOk(req) ? 'cookie' : null;
|
|
104
104
|
}
|
|
105
105
|
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
|
|
6
6
|
import fs from 'node:fs';
|
|
7
7
|
import path from 'node:path';
|
|
8
|
+
import process from 'node:process';
|
|
9
|
+
import { ensurePrivateProjectBufferDirectory } from '../runtime/project-path.mjs';
|
|
8
10
|
import { withBufferDirectoryLease } from '../runtime/buffer-directory-lease.mjs';
|
|
9
11
|
import {
|
|
10
12
|
externalBufferEvidence,
|
|
@@ -13,19 +15,50 @@ import {
|
|
|
13
15
|
readExternalBufferMetadata
|
|
14
16
|
} from '../runtime/buffer-evidence.mjs';
|
|
15
17
|
import { inspectProcessIdentity } from '../process/identity.mjs';
|
|
18
|
+
import { unsafeDirectoryAcl } from '../runtime/project-trust.mjs';
|
|
19
|
+
|
|
20
|
+
function secureExistingBridgeFile(file) {
|
|
21
|
+
let stat;
|
|
22
|
+
try { stat = fs.lstatSync(file); }
|
|
23
|
+
catch (error) {
|
|
24
|
+
if (error?.code === 'ENOENT') return false;
|
|
25
|
+
throw error;
|
|
26
|
+
}
|
|
27
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 ||
|
|
28
|
+
(typeof process.getuid === 'function' && stat.uid !== process.getuid()) ||
|
|
29
|
+
(process.platform !== 'win32' && (stat.mode & 0o777) !== 0o600) ||
|
|
30
|
+
unsafeDirectoryAcl(file, stat)) {
|
|
31
|
+
throw new Error(`Unsafe external buffer file: ${file}`);
|
|
32
|
+
}
|
|
33
|
+
const fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
|
|
34
|
+
try {
|
|
35
|
+
const opened = fs.fstatSync(fd);
|
|
36
|
+
if (!opened.isFile() || opened.dev !== stat.dev || opened.ino !== stat.ino) {
|
|
37
|
+
throw new Error(`External buffer file changed: ${file}`);
|
|
38
|
+
}
|
|
39
|
+
if ((process.platform !== 'win32' && (opened.mode & 0o777) !== 0o600) ||
|
|
40
|
+
unsafeDirectoryAcl(file, opened)) {
|
|
41
|
+
throw new Error(`External buffer file lost private permissions: ${file}`);
|
|
42
|
+
}
|
|
43
|
+
const current = fs.lstatSync(file);
|
|
44
|
+
if (current.isSymbolicLink() || current.dev !== stat.dev || current.ino !== stat.ino) {
|
|
45
|
+
throw new Error(`External buffer file changed: ${file}`);
|
|
46
|
+
}
|
|
47
|
+
return true;
|
|
48
|
+
} finally { fs.closeSync(fd); }
|
|
49
|
+
}
|
|
16
50
|
|
|
17
51
|
export function createExternalSessions(deps) {
|
|
18
52
|
const {
|
|
19
53
|
ctx, sessions,
|
|
20
54
|
sessionKey, broadcast,
|
|
21
55
|
now, tx, connectWebProject,
|
|
22
|
-
|
|
56
|
+
runtimeProjectContexts,
|
|
23
57
|
refreshPeerIoHeartbeat, redactedLogText, BUFS_DIR_NAME
|
|
24
58
|
} = deps;
|
|
25
59
|
|
|
26
60
|
// ── Optional external buffer-file session adoption ───────────────────────
|
|
27
|
-
const bufsDir =
|
|
28
|
-
fs.mkdirSync(bufsDir, { recursive: true });
|
|
61
|
+
const bufsDir = ensurePrivateProjectBufferDirectory(ctx.root, BUFS_DIR_NAME);
|
|
29
62
|
|
|
30
63
|
function removeExternalBufferFiles(id, directory = bufsDir) {
|
|
31
64
|
for (const suffix of ['out', 'in', 'resize', 'meta']) {
|
|
@@ -34,6 +67,15 @@ export function createExternalSessions(deps) {
|
|
|
34
67
|
}
|
|
35
68
|
|
|
36
69
|
function adoptExternalSession(id, pctx = ctx, directory = bufsDir) {
|
|
70
|
+
let privateDirectory;
|
|
71
|
+
let suppliedDirectory;
|
|
72
|
+
try {
|
|
73
|
+
privateDirectory = ensurePrivateProjectBufferDirectory(pctx.root, BUFS_DIR_NAME, { create: false });
|
|
74
|
+
suppliedDirectory = fs.realpathSync.native(directory);
|
|
75
|
+
}
|
|
76
|
+
catch { return; }
|
|
77
|
+
if (!privateDirectory || suppliedDirectory !== privateDirectory) return;
|
|
78
|
+
directory = privateDirectory;
|
|
37
79
|
const key = sessionKey(pctx, id);
|
|
38
80
|
if (sessions.has(key)) return;
|
|
39
81
|
const outFile = path.join(directory, `${id}.out`);
|
|
@@ -44,6 +86,9 @@ export function createExternalSessions(deps) {
|
|
|
44
86
|
try {
|
|
45
87
|
adopted = withBufferDirectoryLease(directory, () => {
|
|
46
88
|
if (!fs.existsSync(outFile) || !fs.existsSync(metaFile)) return null;
|
|
89
|
+
for (const file of [outFile, inFile, resizeFile, metaFile]) {
|
|
90
|
+
secureExistingBridgeFile(file);
|
|
91
|
+
}
|
|
47
92
|
let meta;
|
|
48
93
|
try { meta = readExternalBufferMetadata(metaFile); } catch { return null; }
|
|
49
94
|
// The producer publishes wrapper evidence before the PTY child identity
|
|
@@ -211,7 +256,10 @@ export function createExternalSessions(deps) {
|
|
|
211
256
|
// Adopt any already-running external sessions
|
|
212
257
|
function scanExternalSessions() {
|
|
213
258
|
for (const projectCtx of runtimeProjectContexts()) {
|
|
214
|
-
|
|
259
|
+
let directory;
|
|
260
|
+
try { directory = ensurePrivateProjectBufferDirectory(projectCtx.root, BUFS_DIR_NAME, { create: false }); }
|
|
261
|
+
catch { continue; }
|
|
262
|
+
if (!directory) continue;
|
|
215
263
|
for (const id of externalBufferSessionIds(directory)) {
|
|
216
264
|
adoptExternalSession(id, projectCtx, directory);
|
|
217
265
|
}
|
|
@@ -230,8 +278,9 @@ export function createExternalSessions(deps) {
|
|
|
230
278
|
for (const projectCtx of runtimeProjectContexts()) {
|
|
231
279
|
const root = path.resolve(projectCtx.root);
|
|
232
280
|
if (bufsWatchers.has(root)) continue;
|
|
233
|
-
const directory = bufferDirectory(projectCtx);
|
|
234
281
|
try {
|
|
282
|
+
const directory = ensurePrivateProjectBufferDirectory(projectCtx.root, BUFS_DIR_NAME, { create: false });
|
|
283
|
+
if (!directory) continue;
|
|
235
284
|
const watcher = fs.watch(directory, { persistent: false }, (event, filename) => {
|
|
236
285
|
if (filename?.endsWith('.out')) {
|
|
237
286
|
setTimeout(() => adoptExternalSession(path.basename(filename, '.out'), projectCtx, directory), 300);
|
package/lib/web/http-routes.mjs
CHANGED
|
@@ -2,17 +2,17 @@
|
|
|
2
2
|
// The single handleWebRequest dispatcher: auth/cookie exchange, static UI,
|
|
3
3
|
// projects, sessions, peers, tmux/buffer-GC endpoints, and the status APIs.
|
|
4
4
|
|
|
5
|
-
import path from 'node:path';
|
|
6
5
|
import process from 'node:process';
|
|
7
6
|
import { URL } from 'node:url';
|
|
8
7
|
import { publicCliFailure, CliError } from '../shared/errors.mjs';
|
|
9
8
|
import { required } from '../cli-args.mjs';
|
|
10
|
-
import {
|
|
9
|
+
import { terminalAssetPath } from './terminal-assets.mjs';
|
|
11
10
|
import { projectRecord } from '../runtime/projects.mjs';
|
|
11
|
+
import { projectDbPath } from '../runtime/paths.mjs';
|
|
12
12
|
import { writeRuntime } from '../runtime/state.mjs';
|
|
13
13
|
import { normalizeStateResources } from '../ui/state-render.mjs';
|
|
14
14
|
import { requestUrl, runtimeBaseUrl } from '../web/runtime.mjs';
|
|
15
|
-
import { authOk, readJsonRequest, requestMatchesProxyOrigin, requestOriginMatches, sendFile, sendJson, tokenMatches } from '../web/http.mjs';
|
|
15
|
+
import { authOk, readJsonRequest, requestMatchesProxyOrigin, requestOriginMatches, sendFile, sendJson, tokenMatches, tokenlessRequestAllowed } from '../web/http.mjs';
|
|
16
16
|
import { API_VERSION, apiVersionUnsupportedBody, readHttpApiVersion } from '../web/api-version.mjs';
|
|
17
17
|
import { isDshHookBinding } from '../core/peers/bindings.mjs';
|
|
18
18
|
import { writeAdoptionState } from './adoption-state.mjs';
|
|
@@ -48,8 +48,12 @@ export function createHttpRoutes(deps) {
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
const handleWebRequest = async (req, res) => {
|
|
51
|
-
const url = requestUrl(req);
|
|
52
51
|
try {
|
|
52
|
+
const url = requestUrl(req);
|
|
53
|
+
if (!token && !tokenlessRequestAllowed(req, { trustProxy, proxyOrigin })) {
|
|
54
|
+
sendJson(res, 403, { ok: false, error: { code: 'UNTRUSTED_HOST', message: 'Tokenless access requires a trusted local Host' } });
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
53
57
|
if (req.method === 'GET' && url.pathname === '/') {
|
|
54
58
|
const accept = req.headers.accept || '';
|
|
55
59
|
const isBrowserNav = req.headers['sec-fetch-mode'] === 'navigate' || accept.includes('text/html');
|
|
@@ -85,19 +89,19 @@ export function createHttpRoutes(deps) {
|
|
|
85
89
|
return;
|
|
86
90
|
}
|
|
87
91
|
if (req.method === 'GET' && url.pathname === '/assets/xterm.js') {
|
|
88
|
-
sendFile(res,
|
|
92
|
+
sendFile(res, terminalAssetPath('xterm.js'), 'application/javascript; charset=utf-8');
|
|
89
93
|
return;
|
|
90
94
|
}
|
|
91
95
|
if (req.method === 'GET' && url.pathname === '/assets/xterm.css') {
|
|
92
|
-
sendFile(res,
|
|
96
|
+
sendFile(res, terminalAssetPath('xterm.css'), 'text/css; charset=utf-8');
|
|
93
97
|
return;
|
|
94
98
|
}
|
|
95
99
|
if (req.method === 'GET' && url.pathname === '/assets/addon-fit.js') {
|
|
96
|
-
sendFile(res,
|
|
100
|
+
sendFile(res, terminalAssetPath('addon-fit.js'), 'application/javascript; charset=utf-8');
|
|
97
101
|
return;
|
|
98
102
|
}
|
|
99
103
|
if (req.method === 'GET' && url.pathname === '/assets/addon-search.js') {
|
|
100
|
-
sendFile(res,
|
|
104
|
+
sendFile(res, terminalAssetPath('addon-search.js'), 'application/javascript; charset=utf-8');
|
|
101
105
|
return;
|
|
102
106
|
}
|
|
103
107
|
if (req.method === 'POST' && url.pathname === '/login') {
|
|
@@ -163,7 +167,7 @@ export function createHttpRoutes(deps) {
|
|
|
163
167
|
const requestedRoot = input.root || reqCtx.root;
|
|
164
168
|
const projectCtx = rememberProject(resolveWebProjectContext(
|
|
165
169
|
requestedRoot,
|
|
166
|
-
input.db ||
|
|
170
|
+
input.db || projectDbPath(requestedRoot)
|
|
167
171
|
), { register: true, nonblocking: true });
|
|
168
172
|
const db = connectWebProject(projectCtx);
|
|
169
173
|
db.close();
|
package/lib/web/http.mjs
CHANGED
|
@@ -84,6 +84,24 @@ export function isLoopbackRemote(req) {
|
|
|
84
84
|
return addr === '::1' || addr.startsWith('127.') || addr.startsWith('::ffff:127.');
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
function localHostAuthority(req) {
|
|
88
|
+
const host = req?.headers?.host;
|
|
89
|
+
if (typeof host !== 'string') return false;
|
|
90
|
+
const match = /^(?:localhost|127\.0\.0\.1|\[::1\])(?::(\d{1,5}))?$/i.exec(host);
|
|
91
|
+
if (!match) return false;
|
|
92
|
+
return !match[1] || (Number(match[1]) > 0 && Number(match[1]) <= 65535);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function tokenlessRequestAllowed(req, options = {}) {
|
|
96
|
+
if (!isLoopbackRemote(req)) return false;
|
|
97
|
+
if (localHostAuthority(req)) return true;
|
|
98
|
+
if (!options.trustProxy || !requestMatchesProxyOrigin(req, options)) return false;
|
|
99
|
+
const expected = normalizedProxyOrigin(options.proxyOrigin);
|
|
100
|
+
const actual = normalizedOriginAuthority(req?.headers?.host, expected?.protocol);
|
|
101
|
+
return Boolean(expected && actual && actual.hostname === expected.authority.hostname &&
|
|
102
|
+
actual.port === expected.authority.port);
|
|
103
|
+
}
|
|
104
|
+
|
|
87
105
|
export function tokenMatches(provided, expected) {
|
|
88
106
|
if (!provided || !expected || provided.length !== expected.length) return false;
|
|
89
107
|
try {
|
|
@@ -174,10 +192,10 @@ export function requestOriginMatches(req, options = {}) {
|
|
|
174
192
|
}
|
|
175
193
|
}
|
|
176
194
|
|
|
177
|
-
export function authOk(url, req, token) {
|
|
195
|
+
export function authOk(url, req, token, options = {}) {
|
|
178
196
|
// No token configured: only trust loopback clients. A tokenless runtime is
|
|
179
197
|
// refused on non-loopback binds at startup, so this is defense-in-depth.
|
|
180
|
-
if (!token) return
|
|
198
|
+
if (!token) return tokenlessRequestAllowed(req, options);
|
|
181
199
|
// v1-token-query-csrf-bypass: the query token is accepted ONLY on the cookie
|
|
182
200
|
// exchange paths (the console page and the login endpoint). Everywhere else
|
|
183
201
|
// — including all API routes and the WS upgrade — a leaked ?token= URL must
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
3
4
|
import { CliError } from '../shared/errors.mjs';
|
|
4
5
|
import { redactSecrets } from '../shared/redact.mjs';
|
|
5
6
|
import { tx } from '../db/schema.mjs';
|
|
@@ -29,10 +30,17 @@ export function createNativeSessions({ sessions, sessionKey, connectWebProject,
|
|
|
29
30
|
return redactSecrets({ code: error.code || 'NATIVE_RUNTIME_OFFLINE',
|
|
30
31
|
message: error.message || 'Native runtime is disconnected' });
|
|
31
32
|
}
|
|
33
|
+
function publishState(session, state) {
|
|
34
|
+
// Event cursors do not cover approvals, delivery receipts or capabilities.
|
|
35
|
+
// Compare the complete normalized state; validation and lease revocation
|
|
36
|
+
// still run on every read, including reads that need no new UI frame.
|
|
37
|
+
const changed = !isDeepStrictEqual(session.nativeState, state);
|
|
38
|
+
session.nativeState = state;
|
|
39
|
+
if (changed) broadcast(session, { type: 'native_state', state: session.nativeSnapshot() });
|
|
40
|
+
}
|
|
32
41
|
function disconnect(session, error, retire = false) {
|
|
33
42
|
session.status = 'disconnected';
|
|
34
|
-
session
|
|
35
|
-
broadcast(session, { type: 'native_state', state: session.nativeSnapshot() });
|
|
43
|
+
publishState(session, { ...session.nativeState, connected: false, status: 'disconnected', error: errorInfo(error) });
|
|
36
44
|
if (retire && !session.nativeRetired) {
|
|
37
45
|
session.nativeRetired = true;
|
|
38
46
|
closeSessionClients(session);
|
|
@@ -100,7 +108,7 @@ export function createNativeSessions({ sessions, sessionKey, connectWebProject,
|
|
|
100
108
|
const turnId = value.snapshot.turnId || value.snapshot.activeTurnId || value.active_delivery?.turn_id || null;
|
|
101
109
|
session.binding = binding;
|
|
102
110
|
session.cwd = value.cwd;
|
|
103
|
-
|
|
111
|
+
const state = redactSecrets({ backend: 'native', connected: true, root: value.root,
|
|
104
112
|
peer: value.peer, provider: value.provider, generation: value.generation, owner: value.owner,
|
|
105
113
|
executorId: value.owner, sessionId: value.snapshot.sessionId || null, turnId,
|
|
106
114
|
status: value.snapshot.status, cwd: value.cwd, capabilities: value.snapshot.capabilities || {}, pendingApprovals: value.snapshot.pendingApprovals || [],
|
|
@@ -111,7 +119,7 @@ export function createNativeSessions({ sessions, sessionKey, connectWebProject,
|
|
|
111
119
|
session.status = CLOSED.has(value.snapshot.status) ? 'exited'
|
|
112
120
|
: value.snapshot.status === 'disconnected' ? 'disconnected' : 'running';
|
|
113
121
|
if (session.status === 'exited') session.exitedAt = now();
|
|
114
|
-
|
|
122
|
+
publishState(session, state);
|
|
115
123
|
return session.nativeSnapshot();
|
|
116
124
|
}
|
|
117
125
|
function makeSession(ctx, checked) {
|
|
@@ -249,7 +257,7 @@ export function createNativeSessions({ sessions, sessionKey, connectWebProject,
|
|
|
249
257
|
...identity, ...(action === 'send' ? { from: 'web', body: text, submissionId }
|
|
250
258
|
: action === 'interrupt' ? { turnId: input.turnId || session.nativeState.turnId || undefined }
|
|
251
259
|
: action === 'respond' ? { executorId: input.executorId, requestId: input.requestId, turnId: input.turnId,
|
|
252
|
-
decision: input.decision, permissions: input.permissions, scope: input.scope, answers: input.answers } : {})
|
|
260
|
+
decision: input.decision, permissions: input.permissions, scope: input.scope, answers: input.answers, content: input.content } : {})
|
|
253
261
|
});
|
|
254
262
|
if (action === 'send') {
|
|
255
263
|
if (!Number.isSafeInteger(result?.message_id) || result.submission_id !== submissionId || typeof result.state !== 'string') {
|
|
@@ -261,8 +269,7 @@ export function createNativeSessions({ sessions, sessionKey, connectWebProject,
|
|
|
261
269
|
if (action === 'respond') { await readState(session); current(session); }
|
|
262
270
|
if (action === 'close') {
|
|
263
271
|
session.status = 'exited'; session.exitedAt = now();
|
|
264
|
-
session
|
|
265
|
-
broadcast(session, { type: 'native_state', state: session.nativeSnapshot() });
|
|
272
|
+
publishState(session, { ...session.nativeState, connected: false, status: 'closed', closing: false });
|
|
266
273
|
closeSessionClients(session);
|
|
267
274
|
session.actionTokens.clear();
|
|
268
275
|
}
|