@logicseek/hello-cc 1.0.2 → 1.0.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 +34 -0
- package/docs/dsh.md +3 -3
- package/docs/dsh.zh-CN.md +3 -3
- package/lib/cli/app.mjs +1 -1
- package/lib/cli/commands/hook.mjs +3 -1
- package/lib/cli/commands/lifecycle.mjs +14 -3
- package/lib/cli/commands/query.mjs +15 -10
- package/lib/cli/commands/run.mjs +7 -6
- package/lib/db/connection.mjs +82 -9
- package/lib/discover.mjs +4 -4
- package/lib/integrations/hooks.mjs +132 -40
- package/lib/integrations/peers/identity.mjs +42 -7
- package/lib/runtime/native/service.mjs +107 -24
- package/lib/runtime/native/store.mjs +63 -6
- package/lib/runtime/project-path.mjs +90 -7
- package/lib/shared/file-lock.mjs +62 -17
- package/lib/shared/socket-lock-worker.mjs +15 -6
- package/lib/web/codex-app-server.mjs +19 -1
- package/lib/web/codex-sessions.mjs +2 -0
- package/lib/web/cookie-auth.mjs +1 -1
- package/lib/web/http-routes.mjs +11 -7
- package/lib/web/http.mjs +20 -2
- package/lib/web/native-sessions.mjs +13 -6
- package/lib/web/peer-actions.mjs +27 -27
- package/lib/web/runtime-main.mjs +20 -3
- package/lib/web/runtime.mjs +18 -4
- package/lib/web/session-serialize.mjs +1 -1
- package/lib/web/startup.mjs +84 -10
- package/lib/web/task-results.mjs +8 -5
- package/lib/web/terminal-assets.mjs +18 -0
- package/lib/web/tmux-stream.mjs +3 -4
- package/lib/web/ui-codex.mjs +137 -20
- package/lib/web/ui-interactions.mjs +37 -3
- package/lib/web/ui-native.mjs +21 -9
- package/lib/web/ui-template.mjs +8 -2
- package/package.json +1 -1
- package/scripts/regression.mjs +8 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import process from 'node:process';
|
|
3
4
|
import { DatabaseSync } from 'node:sqlite';
|
|
4
5
|
import { randomBytes } from 'node:crypto';
|
|
5
6
|
import { CliError } from '../../shared/errors.mjs';
|
|
@@ -11,12 +12,62 @@ function statOrMissing(file) {
|
|
|
11
12
|
|
|
12
13
|
function directory(file, create) {
|
|
13
14
|
let stat = statOrMissing(file);
|
|
14
|
-
if (!stat && create) {
|
|
15
|
+
if (!stat && create) {
|
|
16
|
+
try { fs.mkdirSync(file, { mode: 0o700 }); }
|
|
17
|
+
catch (error) { if (error?.code !== 'EEXIST') throw error; }
|
|
18
|
+
stat = fs.lstatSync(file);
|
|
19
|
+
}
|
|
15
20
|
if (!stat) return false;
|
|
16
21
|
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new CliError('NATIVE_STATE_UNSAFE', `Native state must be a real directory: ${file}`);
|
|
22
|
+
if (create) {
|
|
23
|
+
if (typeof process.getuid === 'function' && stat.uid !== process.getuid()) {
|
|
24
|
+
throw new CliError('NATIVE_STATE_UNSAFE', `Native state must be owned by the current user: ${file}`);
|
|
25
|
+
}
|
|
26
|
+
const fd = fs.openSync(file, fs.constants.O_RDONLY |
|
|
27
|
+
(fs.constants.O_DIRECTORY || 0) | (fs.constants.O_NOFOLLOW || 0));
|
|
28
|
+
try {
|
|
29
|
+
const opened = fs.fstatSync(fd);
|
|
30
|
+
if (!opened.isDirectory() || opened.dev !== stat.dev || opened.ino !== stat.ino) {
|
|
31
|
+
throw new CliError('NATIVE_STATE_UNSAFE', `Native state directory changed: ${file}`);
|
|
32
|
+
}
|
|
33
|
+
if (process.platform !== 'win32' && (opened.mode & 0o777) !== 0o700) fs.fchmodSync(fd, 0o700);
|
|
34
|
+
const current = fs.lstatSync(file);
|
|
35
|
+
if (current.isSymbolicLink() || current.dev !== stat.dev || current.ino !== stat.ino) {
|
|
36
|
+
throw new CliError('NATIVE_STATE_UNSAFE', `Native state directory changed: ${file}`);
|
|
37
|
+
}
|
|
38
|
+
} finally { fs.closeSync(fd); }
|
|
39
|
+
}
|
|
17
40
|
return true;
|
|
18
41
|
}
|
|
19
42
|
|
|
43
|
+
function privateDatabase(file) {
|
|
44
|
+
let stat = statOrMissing(file);
|
|
45
|
+
if (!stat) {
|
|
46
|
+
try {
|
|
47
|
+
const fd = fs.openSync(file, fs.constants.O_RDWR | fs.constants.O_CREAT |
|
|
48
|
+
fs.constants.O_EXCL | (fs.constants.O_NOFOLLOW || 0), 0o600);
|
|
49
|
+
fs.closeSync(fd);
|
|
50
|
+
} catch (error) { if (error?.code !== 'EEXIST') throw error; }
|
|
51
|
+
stat = fs.lstatSync(file);
|
|
52
|
+
}
|
|
53
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 ||
|
|
54
|
+
(typeof process.getuid === 'function' && stat.uid !== process.getuid())) {
|
|
55
|
+
throw new CliError('NATIVE_STATE_UNSAFE', `Native database must be an owned regular file: ${file}`);
|
|
56
|
+
}
|
|
57
|
+
const fd = fs.openSync(file, fs.constants.O_RDWR | (fs.constants.O_NOFOLLOW || 0));
|
|
58
|
+
try {
|
|
59
|
+
const opened = fs.fstatSync(fd);
|
|
60
|
+
if (!opened.isFile() || opened.dev !== stat.dev || opened.ino !== stat.ino) {
|
|
61
|
+
throw new CliError('NATIVE_STATE_UNSAFE', `Native database changed during validation: ${file}`);
|
|
62
|
+
}
|
|
63
|
+
if (process.platform !== 'win32' && (opened.mode & 0o777) !== 0o600) fs.fchmodSync(fd, 0o600);
|
|
64
|
+
const current = fs.lstatSync(file);
|
|
65
|
+
if (current.isSymbolicLink() || current.dev !== stat.dev || current.ino !== stat.ino) {
|
|
66
|
+
throw new CliError('NATIVE_STATE_UNSAFE', `Native database changed during validation: ${file}`);
|
|
67
|
+
}
|
|
68
|
+
} finally { fs.closeSync(fd); }
|
|
69
|
+
}
|
|
70
|
+
|
|
20
71
|
export function nativePaths(ctx, { create = false } = {}) {
|
|
21
72
|
const root = fs.realpathSync(ctx.root);
|
|
22
73
|
const parent = path.join(root, '.hello-cc');
|
|
@@ -60,8 +111,8 @@ export function readNativePointer(ctx) {
|
|
|
60
111
|
|
|
61
112
|
export function createNativeStore(ctx) {
|
|
62
113
|
const paths = nativePaths(ctx, { create: true });
|
|
114
|
+
privateDatabase(paths.db);
|
|
63
115
|
const db = new DatabaseSync(paths.db, { timeout: 5000 });
|
|
64
|
-
fs.chmodSync(paths.db, 0o600);
|
|
65
116
|
db.exec(`PRAGMA journal_mode = WAL;
|
|
66
117
|
PRAGMA busy_timeout = 5000;
|
|
67
118
|
PRAGMA foreign_keys = ON;
|
|
@@ -72,11 +123,16 @@ export function createNativeStore(ctx) {
|
|
|
72
123
|
CREATE TABLE IF NOT EXISTS deliveries (
|
|
73
124
|
id INTEGER PRIMARY KEY, peer TEXT NOT NULL, message_id INTEGER NOT NULL,
|
|
74
125
|
submission_id TEXT NOT NULL UNIQUE, state TEXT NOT NULL, turn_id TEXT,
|
|
75
|
-
detail TEXT, updated_at INTEGER NOT NULL, UNIQUE(peer, message_id)
|
|
126
|
+
detail TEXT, origin TEXT NOT NULL DEFAULT 'peer', updated_at INTEGER NOT NULL, UNIQUE(peer, message_id)
|
|
76
127
|
);
|
|
77
128
|
CREATE TABLE IF NOT EXISTS provider_events (
|
|
78
129
|
id INTEGER PRIMARY KEY, peer TEXT NOT NULL, payload TEXT NOT NULL, created_at INTEGER NOT NULL
|
|
79
130
|
);`);
|
|
131
|
+
// Existing deliveries came from the shared message bus. Migration must not
|
|
132
|
+
// promote a stored sender name into user authority.
|
|
133
|
+
if (!db.prepare('PRAGMA table_info(deliveries)').all().some(column => column.name === 'origin')) {
|
|
134
|
+
db.exec("ALTER TABLE deliveries ADD COLUMN origin TEXT NOT NULL DEFAULT 'peer'");
|
|
135
|
+
}
|
|
80
136
|
const now = () => Date.now();
|
|
81
137
|
return {
|
|
82
138
|
db,
|
|
@@ -88,9 +144,10 @@ export function createNativeStore(ctx) {
|
|
|
88
144
|
cwd=excluded.cwd, status=excluded.status, updated_at=excluded.updated_at`)
|
|
89
145
|
.run(worker.peer, worker.provider, worker.sessionId || null, worker.cwd, worker.status, now());
|
|
90
146
|
},
|
|
91
|
-
queue(peer, messageId, submissionId) {
|
|
92
|
-
|
|
93
|
-
|
|
147
|
+
queue(peer, messageId, submissionId, origin = 'peer') {
|
|
148
|
+
if (!['peer', 'user'].includes(origin)) throw new CliError('BAD_ARGS', 'Unknown native message origin');
|
|
149
|
+
db.prepare(`INSERT OR IGNORE INTO deliveries(peer,message_id,submission_id,state,origin,updated_at) VALUES (?,?,?,'queued',?,?)`)
|
|
150
|
+
.run(peer, messageId, submissionId, origin, now());
|
|
94
151
|
return this.delivery(peer, messageId);
|
|
95
152
|
},
|
|
96
153
|
delivery(peer, messageId) { return db.prepare('SELECT * FROM deliveries WHERE peer=? AND message_id=?').get(peer, messageId) || null; },
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import process from 'node:process';
|
|
3
4
|
import { CliError } from '../shared/errors.mjs';
|
|
4
5
|
|
|
5
6
|
function pathIsWithin(parent, candidate) {
|
|
@@ -22,7 +23,40 @@ function canonicalProjectRoot(root) {
|
|
|
22
23
|
}
|
|
23
24
|
}
|
|
24
25
|
|
|
25
|
-
function
|
|
26
|
+
function privateDirectory(directory, description) {
|
|
27
|
+
let stat;
|
|
28
|
+
try { stat = fs.lstatSync(directory); }
|
|
29
|
+
catch { throw projectPathForbidden(`Cannot inspect ${description}: ${directory}`); }
|
|
30
|
+
if (stat.isSymbolicLink() || !stat.isDirectory() ||
|
|
31
|
+
(typeof process.getuid === 'function' && stat.uid !== process.getuid())) {
|
|
32
|
+
throw projectPathForbidden(`${description} must be an owned real directory: ${directory}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let fd;
|
|
36
|
+
try {
|
|
37
|
+
fd = fs.openSync(directory, fs.constants.O_RDONLY |
|
|
38
|
+
(fs.constants.O_DIRECTORY || 0) | (fs.constants.O_NOFOLLOW || 0));
|
|
39
|
+
const opened = fs.fstatSync(fd);
|
|
40
|
+
if (!opened.isDirectory() || opened.dev !== stat.dev || opened.ino !== stat.ino) {
|
|
41
|
+
throw projectPathForbidden(`${description} changed during validation: ${directory}`);
|
|
42
|
+
}
|
|
43
|
+
if (process.platform !== 'win32' && (opened.mode & 0o777) !== 0o700) {
|
|
44
|
+
fs.fchmodSync(fd, 0o700);
|
|
45
|
+
}
|
|
46
|
+
const current = fs.lstatSync(directory);
|
|
47
|
+
if (current.isSymbolicLink() || current.dev !== stat.dev || current.ino !== stat.ino) {
|
|
48
|
+
throw projectPathForbidden(`${description} changed during validation: ${directory}`);
|
|
49
|
+
}
|
|
50
|
+
return fs.realpathSync(directory);
|
|
51
|
+
} catch (error) {
|
|
52
|
+
if (error instanceof CliError) throw error;
|
|
53
|
+
throw projectPathForbidden(`Cannot secure ${description}: ${directory}`);
|
|
54
|
+
} finally {
|
|
55
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function ensureStateDirectory(root, createStateDir, secureExisting = createStateDir) {
|
|
26
60
|
const stateDir = path.join(root, '.hello-cc');
|
|
27
61
|
let stateStat;
|
|
28
62
|
try {
|
|
@@ -35,8 +69,9 @@ function ensureStateDirectory(root, createStateDir) {
|
|
|
35
69
|
try {
|
|
36
70
|
fs.mkdirSync(stateDir, { mode: 0o700 });
|
|
37
71
|
stateStat = fs.lstatSync(stateDir);
|
|
38
|
-
} catch {
|
|
39
|
-
|
|
72
|
+
} catch (error) {
|
|
73
|
+
if (error?.code === 'EEXIST') stateStat = fs.lstatSync(stateDir);
|
|
74
|
+
else throw projectPathForbidden(`Cannot create project state directory: ${stateDir}`);
|
|
40
75
|
}
|
|
41
76
|
}
|
|
42
77
|
|
|
@@ -44,7 +79,9 @@ function ensureStateDirectory(root, createStateDir) {
|
|
|
44
79
|
throw projectPathForbidden(`Project state directory must be a real directory: ${stateDir}`);
|
|
45
80
|
}
|
|
46
81
|
try {
|
|
47
|
-
const canonical =
|
|
82
|
+
const canonical = secureExisting
|
|
83
|
+
? privateDirectory(stateDir, 'Project state directory')
|
|
84
|
+
: fs.realpathSync(stateDir);
|
|
48
85
|
if (canonical !== stateDir || !pathIsWithin(root, canonical)) {
|
|
49
86
|
throw projectPathForbidden(`Project state directory escapes its project root: ${stateDir}`);
|
|
50
87
|
}
|
|
@@ -55,13 +92,26 @@ function ensureStateDirectory(root, createStateDir) {
|
|
|
55
92
|
}
|
|
56
93
|
}
|
|
57
94
|
|
|
58
|
-
function
|
|
95
|
+
export function secureExistingProjectStateDirectory(root) {
|
|
96
|
+
const canonicalRoot = canonicalProjectRoot(root);
|
|
97
|
+
return ensureStateDirectory(canonicalRoot, false, true);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function validateDatabaseParent(stateDir, db, createParents = false) {
|
|
59
101
|
const parent = path.dirname(db);
|
|
60
102
|
const relative = path.relative(stateDir, parent);
|
|
61
103
|
const segments = relative === '' ? [] : relative.split(path.sep);
|
|
62
104
|
let current = stateDir;
|
|
63
105
|
for (const segment of segments) {
|
|
64
106
|
current = path.join(current, segment);
|
|
107
|
+
if (createParents) {
|
|
108
|
+
try { fs.mkdirSync(current, { mode: 0o700 }); }
|
|
109
|
+
catch (error) {
|
|
110
|
+
if (error?.code !== 'EEXIST') {
|
|
111
|
+
throw projectPathForbidden(`Cannot create database parent directory: ${current}`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
65
115
|
let stat;
|
|
66
116
|
try {
|
|
67
117
|
stat = fs.lstatSync(current);
|
|
@@ -71,6 +121,9 @@ function validateDatabaseParent(stateDir, db) {
|
|
|
71
121
|
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
72
122
|
throw projectPathForbidden(`Database parent must contain only real directories: ${current}`);
|
|
73
123
|
}
|
|
124
|
+
if (createParents && privateDirectory(current, 'Database parent directory') !== current) {
|
|
125
|
+
throw projectPathForbidden(`Database parent directory changed: ${current}`);
|
|
126
|
+
}
|
|
74
127
|
}
|
|
75
128
|
|
|
76
129
|
try {
|
|
@@ -107,7 +160,7 @@ function validateDatabaseTarget(stateDir, db) {
|
|
|
107
160
|
}
|
|
108
161
|
}
|
|
109
162
|
|
|
110
|
-
export function resolveProjectDatabase({ root, db, createStateDir = false }) {
|
|
163
|
+
export function resolveProjectDatabase({ root, db, createStateDir = false, createDatabaseParents = false }) {
|
|
111
164
|
const requestedRoot = path.resolve(String(root || ''));
|
|
112
165
|
const canonicalRoot = canonicalProjectRoot(requestedRoot);
|
|
113
166
|
const rawDb = path.resolve(String(db || path.join(requestedRoot, '.hello-cc', 'mesh.db')));
|
|
@@ -130,10 +183,40 @@ export function resolveProjectDatabase({ root, db, createStateDir = false }) {
|
|
|
130
183
|
return { root: canonicalRoot, stateDir: state.stateDir, db: requestedDb };
|
|
131
184
|
}
|
|
132
185
|
|
|
133
|
-
validateDatabaseParent(state.stateDir, requestedDb);
|
|
186
|
+
validateDatabaseParent(state.stateDir, requestedDb, Boolean(createDatabaseParents));
|
|
134
187
|
return {
|
|
135
188
|
root: canonicalRoot,
|
|
136
189
|
stateDir: state.stateDir,
|
|
137
190
|
db: validateDatabaseTarget(state.stateDir, requestedDb)
|
|
138
191
|
};
|
|
139
192
|
}
|
|
193
|
+
|
|
194
|
+
export function ensurePrivateProjectBufferDirectory(root, directoryName = 'bufs', { create = true } = {}) {
|
|
195
|
+
if (!directoryName || directoryName !== path.basename(directoryName) ||
|
|
196
|
+
directoryName === '.' || directoryName === '..') {
|
|
197
|
+
throw projectPathForbidden(`Invalid buffer directory name: ${directoryName}`);
|
|
198
|
+
}
|
|
199
|
+
const canonicalRoot = canonicalProjectRoot(root);
|
|
200
|
+
const state = ensureStateDirectory(canonicalRoot, create, true);
|
|
201
|
+
if (!state.exists) return null;
|
|
202
|
+
const { stateDir } = state;
|
|
203
|
+
const directory = path.join(stateDir, directoryName);
|
|
204
|
+
if (create) {
|
|
205
|
+
try { fs.mkdirSync(directory, { mode: 0o700 }); }
|
|
206
|
+
catch (error) {
|
|
207
|
+
if (error?.code !== 'EEXIST') {
|
|
208
|
+
throw projectPathForbidden(`Cannot create buffer directory: ${directory}`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
} else {
|
|
212
|
+
try { fs.lstatSync(directory); }
|
|
213
|
+
catch (error) {
|
|
214
|
+
if (error?.code === 'ENOENT') return null;
|
|
215
|
+
throw projectPathForbidden(`Cannot inspect buffer directory: ${directory}`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
if (privateDirectory(directory, 'Buffer directory') !== directory) {
|
|
219
|
+
throw projectPathForbidden(`Buffer directory escapes project state directory: ${directory}`);
|
|
220
|
+
}
|
|
221
|
+
return directory;
|
|
222
|
+
}
|
package/lib/shared/file-lock.mjs
CHANGED
|
@@ -23,6 +23,7 @@ const PORT_CANDIDATE_COUNT = 4;
|
|
|
23
23
|
const STARTUP_GRACE_MS = 5000;
|
|
24
24
|
const RELEASE_GRACE_MS = 5000;
|
|
25
25
|
const WORKER_URL = new URL('./socket-lock-worker.mjs', import.meta.url);
|
|
26
|
+
const leaseResources = new WeakMap();
|
|
26
27
|
const WORKER_BOOTSTRAP = String.raw`
|
|
27
28
|
import('node:worker_threads').then(({ parentPort, workerData }) => {
|
|
28
29
|
const state = new Int32Array(workerData.stateBuffer);
|
|
@@ -224,7 +225,14 @@ function terminateWorker(worker, primary, key) {
|
|
|
224
225
|
}
|
|
225
226
|
|
|
226
227
|
function acquisitionError(status, resources) {
|
|
227
|
-
if (status === STATE.BUSY)
|
|
228
|
+
if (status === STATE.BUSY) {
|
|
229
|
+
if (Atomics.load(resources.state, DETAIL_INDEX) === 2) {
|
|
230
|
+
const error = new Error(`A listener without an identity handshake may own the legacy lock: ${resources.location.key}`);
|
|
231
|
+
error.code = 'ERR_FILE_LOCK_LEGACY_OWNER';
|
|
232
|
+
return error;
|
|
233
|
+
}
|
|
234
|
+
return busyError(resources.location.key);
|
|
235
|
+
}
|
|
228
236
|
if (status === STATE.TIMEOUT) return timeoutError(resources.location.key);
|
|
229
237
|
if (status === STATE.FAILED) {
|
|
230
238
|
return injectedFailure(resources.worker, resources.state, 'startup', resources.location.key);
|
|
@@ -250,7 +258,8 @@ function acquireWorker(location, options, workerFactory) {
|
|
|
250
258
|
moduleUrl: WORKER_URL.href,
|
|
251
259
|
timeoutMs: options.timeoutMs,
|
|
252
260
|
retryMs: options.retryMs,
|
|
253
|
-
nonblocking: options.nonblocking
|
|
261
|
+
nonblocking: options.nonblocking,
|
|
262
|
+
rejectLegacyOwner: options.rejectLegacyOwner
|
|
254
263
|
},
|
|
255
264
|
state,
|
|
256
265
|
states: STATE
|
|
@@ -264,7 +273,13 @@ function acquireWorker(location, options, workerFactory) {
|
|
|
264
273
|
// A module-load failure happens before the Worker can publish FAILED.
|
|
265
274
|
// Suppress the otherwise-unhandled event; the bounded startup wait below
|
|
266
275
|
// converts this path to ERR_FILE_LOCK_WORKER_START_TIMEOUT.
|
|
267
|
-
|
|
276
|
+
const lost = () => {
|
|
277
|
+
if (Atomics.compareExchange(state, STATE_INDEX, STATE.ACQUIRED, STATE.FAILED) === STATE.ACQUIRED) {
|
|
278
|
+
Atomics.notify(state, STATE_INDEX);
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
resources.worker.on('error', lost);
|
|
282
|
+
resources.worker.on('exit', lost);
|
|
268
283
|
}
|
|
269
284
|
resources.worker.unref();
|
|
270
285
|
const waitMs = STARTUP_GRACE_MS +
|
|
@@ -323,20 +338,18 @@ function releaseWorker(resources, primary) {
|
|
|
323
338
|
: error;
|
|
324
339
|
}
|
|
325
340
|
|
|
326
|
-
|
|
341
|
+
// A lease may span asynchronous work. Its caller must release it only after all
|
|
342
|
+
// work protected by the ownership boundary has conclusively stopped.
|
|
343
|
+
export function createFileLockLease({ workerFactory = defaultWorkerFactory } = {}) {
|
|
327
344
|
if (typeof workerFactory !== 'function') {
|
|
328
345
|
throw new TypeError('file lock workerFactory must be a function');
|
|
329
346
|
}
|
|
330
|
-
return function
|
|
331
|
-
target,
|
|
332
|
-
fn,
|
|
333
|
-
options = {}
|
|
334
|
-
) {
|
|
347
|
+
return function (target, options = {}) {
|
|
335
348
|
const timeoutMs = options.timeoutMs === undefined ? 5000 : options.timeoutMs;
|
|
336
349
|
const retryMs = options.retryMs === undefined ? 25 : options.retryMs;
|
|
337
350
|
const nonblocking = options.nonblocking === undefined ? false : options.nonblocking;
|
|
338
351
|
const createParent = options.createParent === undefined ? true : options.createParent;
|
|
339
|
-
|
|
352
|
+
const rejectLegacyOwner = options.rejectLegacyOwner === undefined ? false : options.rejectLegacyOwner;
|
|
340
353
|
if (!Number.isFinite(timeoutMs) || timeoutMs < 0) {
|
|
341
354
|
throw new RangeError('file lock timeoutMs must be a non-negative finite number');
|
|
342
355
|
}
|
|
@@ -349,18 +362,51 @@ export function createFileLock({ workerFactory = defaultWorkerFactory } = {}) {
|
|
|
349
362
|
if (typeof createParent !== 'boolean') {
|
|
350
363
|
throw new TypeError('file lock createParent must be a boolean');
|
|
351
364
|
}
|
|
352
|
-
if (
|
|
353
|
-
throw new TypeError('
|
|
365
|
+
if (typeof rejectLegacyOwner !== 'boolean') {
|
|
366
|
+
throw new TypeError('file lock rejectLegacyOwner must be a boolean');
|
|
354
367
|
}
|
|
355
|
-
|
|
356
368
|
const location = canonicalTarget(target, { createParent });
|
|
357
|
-
const resources = acquireWorker(location, { timeoutMs, retryMs, nonblocking }, workerFactory);
|
|
369
|
+
const resources = acquireWorker(location, { timeoutMs, retryMs, nonblocking, rejectLegacyOwner }, workerFactory);
|
|
370
|
+
const lease = Object.freeze({
|
|
371
|
+
canonicalTarget: location.canonicalTarget,
|
|
372
|
+
assertHeld() {
|
|
373
|
+
if (Atomics.load(resources.state, STATE_INDEX) !== STATE.ACQUIRED || leaseResources.get(lease).finished) {
|
|
374
|
+
const error = new Error(`file lock ownership is no longer confirmed: ${location.key}`);
|
|
375
|
+
error.code = 'ERR_FILE_LOCK_LOST';
|
|
376
|
+
throw error;
|
|
377
|
+
}
|
|
378
|
+
},
|
|
379
|
+
release() { finishLease(lease, NO_ERROR); }
|
|
380
|
+
});
|
|
381
|
+
leaseResources.set(lease, { resources, finished: false, error: NO_ERROR });
|
|
382
|
+
return lease;
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function finishLease(lease, primary) {
|
|
387
|
+
const entry = leaseResources.get(lease);
|
|
388
|
+
if (!entry.finished) {
|
|
389
|
+
entry.finished = true;
|
|
390
|
+
entry.error = releaseWorker(entry.resources, primary);
|
|
391
|
+
}
|
|
392
|
+
if (entry.error !== NO_ERROR) throw entry.error;
|
|
393
|
+
if (primary !== NO_ERROR) throw primary;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
export const acquireFileLock = createFileLockLease();
|
|
397
|
+
|
|
398
|
+
export function createFileLock(options = {}) {
|
|
399
|
+
const acquire = createFileLockLease(options);
|
|
400
|
+
return function withConfiguredFileLock(target, fn, lockOptions = {}) {
|
|
401
|
+
if (typeof fn !== 'function') throw new TypeError('file lock callback must be a function');
|
|
402
|
+
if (fn.constructor?.name === 'AsyncFunction') throw new TypeError('withFileLock requires a synchronous callback');
|
|
403
|
+
const lease = acquire(target, lockOptions);
|
|
358
404
|
let result;
|
|
359
405
|
let primary = NO_ERROR;
|
|
360
406
|
try {
|
|
361
407
|
// Protected I/O must use this fixed path. The supplied parent alias may
|
|
362
408
|
// be retargeted after acquisition and belongs to a different lock domain.
|
|
363
|
-
result = fn(
|
|
409
|
+
result = fn(lease.canonicalTarget);
|
|
364
410
|
if (result && typeof result.then === 'function') {
|
|
365
411
|
Promise.resolve(result).catch(() => {});
|
|
366
412
|
throw new TypeError('withFileLock requires a synchronous callback');
|
|
@@ -368,8 +414,7 @@ export function createFileLock({ workerFactory = defaultWorkerFactory } = {}) {
|
|
|
368
414
|
} catch (error) {
|
|
369
415
|
primary = error;
|
|
370
416
|
}
|
|
371
|
-
|
|
372
|
-
if (finalError !== NO_ERROR) throw finalError;
|
|
417
|
+
finishLease(lease, primary);
|
|
373
418
|
return result;
|
|
374
419
|
};
|
|
375
420
|
}
|
|
@@ -139,14 +139,14 @@ export function runSocketLockWorker(workerData) {
|
|
|
139
139
|
let received = '';
|
|
140
140
|
let settled = false;
|
|
141
141
|
let socket;
|
|
142
|
-
const settle = (matching) => {
|
|
142
|
+
const settle = (matching, legacy = false) => {
|
|
143
143
|
if (settled) return;
|
|
144
144
|
settled = true;
|
|
145
145
|
clearTimeout(timer);
|
|
146
146
|
try { socket?.destroy(); } catch {}
|
|
147
|
-
callback(matching);
|
|
147
|
+
callback(matching, legacy);
|
|
148
148
|
};
|
|
149
|
-
const timer = setTimeout(() => settle(false), PROBE_TIMEOUT_MS);
|
|
149
|
+
const timer = setTimeout(() => settle(false, received.length === 0), PROBE_TIMEOUT_MS);
|
|
150
150
|
try {
|
|
151
151
|
socket = net.createConnection({ host: '127.0.0.1', port });
|
|
152
152
|
socket.setEncoding('utf8');
|
|
@@ -156,7 +156,10 @@ export function runSocketLockWorker(workerData) {
|
|
|
156
156
|
settle(received === expected);
|
|
157
157
|
}
|
|
158
158
|
});
|
|
159
|
-
|
|
159
|
+
// The previous native runtime owner accepted and immediately ended the
|
|
160
|
+
// connection with no banner. Native leases must not treat it as an
|
|
161
|
+
// unrelated service and start a second runtime on another candidate.
|
|
162
|
+
socket.once('end', () => settle(received === expected, received.length === 0));
|
|
160
163
|
socket.once('error', () => settle(false));
|
|
161
164
|
} catch {
|
|
162
165
|
settle(false);
|
|
@@ -184,9 +187,15 @@ export function runSocketLockWorker(workerData) {
|
|
|
184
187
|
requestTerminalFailure();
|
|
185
188
|
return;
|
|
186
189
|
}
|
|
187
|
-
probeOccupant(port, target.identity, (matching) => {
|
|
190
|
+
probeOccupant(port, target.identity, (matching, legacy) => {
|
|
188
191
|
if (finished || attemptGeneration !== generation) return;
|
|
189
|
-
if (
|
|
192
|
+
if (legacy && workerData.rejectLegacyOwner) {
|
|
193
|
+
generation += 1;
|
|
194
|
+
closeServers((error) => {
|
|
195
|
+
if (error) requestTerminalFailure();
|
|
196
|
+
else finish(states.BUSY, 2);
|
|
197
|
+
});
|
|
198
|
+
} else if (matching) retryCleanly(attemptGeneration);
|
|
190
199
|
else bindCandidate(targetIndex, candidateIndex + 1, boundForTarget, attemptGeneration);
|
|
191
200
|
});
|
|
192
201
|
};
|
|
@@ -10,6 +10,7 @@ const APPROVAL_METHODS = CODEX_INTERACTION_METHODS;
|
|
|
10
10
|
const TERMINAL_TURNS = new Set(['completed', 'interrupted', 'failed']);
|
|
11
11
|
const MAX_LINE_BYTES = 8 * 1024 * 1024;
|
|
12
12
|
const MAX_TEXT = 65536;
|
|
13
|
+
const STREAM_UPDATE_MS = 50;
|
|
13
14
|
|
|
14
15
|
export class CodexAppServerError extends Error {
|
|
15
16
|
constructor(code, message, extra = {}) {
|
|
@@ -70,6 +71,8 @@ export function createCodexAppServer({
|
|
|
70
71
|
let buffer = '';
|
|
71
72
|
let truncated = false;
|
|
72
73
|
let closePromise = null;
|
|
74
|
+
let streamTimer = null;
|
|
75
|
+
let streamEvent = null;
|
|
73
76
|
const threads = new Map();
|
|
74
77
|
const pending = new Map();
|
|
75
78
|
const approvals = new Map();
|
|
@@ -111,11 +114,26 @@ export function createCodexAppServer({
|
|
|
111
114
|
});
|
|
112
115
|
}
|
|
113
116
|
|
|
117
|
+
function publishChange(event) {
|
|
118
|
+
clearTimeout(streamTimer); streamTimer = null; streamEvent = null;
|
|
119
|
+
try { onChange(snapshot(), structuredClone(event)); } catch { /* UI observers do not own transport. */ }
|
|
120
|
+
}
|
|
121
|
+
|
|
114
122
|
function changed(method, params = {}) {
|
|
115
123
|
const event = { method, params: bounded(params, 8192), at: Date.now() };
|
|
116
124
|
events.push(event);
|
|
117
125
|
if (events.length > 100) events.shift();
|
|
118
|
-
|
|
126
|
+
// Keep protocol state current on every token, but copy/broadcast its complete
|
|
127
|
+
// retained history at most once per interval during a text-only burst.
|
|
128
|
+
// Approval, completion, error and connection changes publish immediately;
|
|
129
|
+
// their snapshot includes all pending text and cancels the trailing timer.
|
|
130
|
+
if (method === 'item/agentMessage/delta') {
|
|
131
|
+
streamEvent = event;
|
|
132
|
+
if (!streamTimer) {
|
|
133
|
+
streamTimer = setTimeout(() => publishChange(streamEvent), STREAM_UPDATE_MS);
|
|
134
|
+
streamTimer.unref?.();
|
|
135
|
+
}
|
|
136
|
+
} else publishChange(event);
|
|
119
137
|
}
|
|
120
138
|
|
|
121
139
|
function ensureThread(id) {
|
|
@@ -81,6 +81,7 @@ export function createCodexSessions({ sessions, sessionKey, nextProjectSessionId
|
|
|
81
81
|
binding: { transport: 'app-server', runtimeSessionId: id } });
|
|
82
82
|
return { mcp_servers: { hello_cc_scoped: session.mcpCapability.config } };
|
|
83
83
|
}, onChange(state) {
|
|
84
|
+
session.executorId ||= state.executorId;
|
|
84
85
|
broadcast(session, { type: 'codex_state', state });
|
|
85
86
|
if (state.status === 'disconnected' && !closeScheduled) {
|
|
86
87
|
closeScheduled = true;
|
|
@@ -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, () => {
|
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
|
|
package/lib/web/http-routes.mjs
CHANGED
|
@@ -7,12 +7,12 @@ import process from 'node:process';
|
|
|
7
7
|
import { URL } from 'node:url';
|
|
8
8
|
import { publicCliFailure, CliError } from '../shared/errors.mjs';
|
|
9
9
|
import { required } from '../cli-args.mjs';
|
|
10
|
-
import {
|
|
10
|
+
import { terminalAssetPath } from './terminal-assets.mjs';
|
|
11
11
|
import { projectRecord } from '../runtime/projects.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') {
|
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
|