@logicseek/hello-cc 1.1.0-rc.9 → 1.1.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 +96 -0
- package/README.md +76 -21
- package/README.zh-CN.md +45 -11
- package/assets/screenshots/web-console.png +0 -0
- package/docs/commands.md +5 -0
- package/docs/commands.zh-CN.md +5 -0
- package/docs/native.md +40 -0
- package/docs/native.zh-CN.md +34 -0
- package/lib/core/peers/tmux-safety.mjs +5 -0
- package/lib/integrations/native/jsonrpc.mjs +45 -11
- package/lib/runtime/native/cli-submissions.mjs +9 -1
- package/lib/runtime/native/service.mjs +3 -0
- package/lib/shared/file-lock.mjs +6 -2
- package/lib/shared/socket-lock-diagnostics.mjs +31 -0
- package/lib/shared/socket-lock-worker.mjs +7 -5
- package/lib/web/context-files.mjs +2 -89
- package/lib/web/http-routes.mjs +4 -4
- package/lib/web/native-sessions.mjs +10 -1
- package/lib/web/project-files.mjs +24 -35
- package/package.json +1 -1
- package/scripts/claude-fork-acceptance.mjs +202 -0
- package/scripts/regression.mjs +65 -11
- package/scripts/web-native-retry-checks.mjs +28 -4
|
@@ -34,6 +34,7 @@ export class JsonRpcProcess {
|
|
|
34
34
|
this.buffer = '';
|
|
35
35
|
this.closed = false;
|
|
36
36
|
this.child = null;
|
|
37
|
+
this.childClosed = false;
|
|
37
38
|
this.closePromise = null;
|
|
38
39
|
this.lastCloseError = null;
|
|
39
40
|
}
|
|
@@ -45,6 +46,8 @@ export class JsonRpcProcess {
|
|
|
45
46
|
this.child = spawnPinnedCwdProcess(binary, args, {
|
|
46
47
|
cwd: cwd || process.cwd(), env, expectedIdentity, shell: false, stdio: ['pipe', 'pipe', 'pipe']
|
|
47
48
|
});
|
|
49
|
+
// Keep the close receipt even when close() is called after direct exit.
|
|
50
|
+
this.child.once('close', () => { this.childClosed = true; });
|
|
48
51
|
this.child.stdout.setEncoding('utf8');
|
|
49
52
|
this.child.stdout.on('data', (chunk) => this.read(chunk));
|
|
50
53
|
// Drain stderr without putting credentials or unbounded provider logs into
|
|
@@ -164,27 +167,47 @@ export class JsonRpcProcess {
|
|
|
164
167
|
this.fail(new CliError('NATIVE_TRANSPORT_CLOSED', 'Native provider connection closed', { uncertain: true }));
|
|
165
168
|
const child = this.child;
|
|
166
169
|
// A failed spawn has no PID and emits close/error, but never exit.
|
|
167
|
-
if (!child?.pid ||
|
|
170
|
+
if (!child?.pid || this.childClosed) {
|
|
168
171
|
this.closePromise = Promise.resolve();
|
|
169
172
|
return this.closePromise;
|
|
170
173
|
}
|
|
171
174
|
this.closePromise = new Promise((resolve, reject) => {
|
|
175
|
+
let eofTimer;
|
|
172
176
|
let termTimer;
|
|
173
177
|
let killTimer;
|
|
178
|
+
let stdioTimer;
|
|
179
|
+
let settled = false;
|
|
180
|
+
const hasExited = () => child.exitCode !== null || child.signalCode !== null;
|
|
174
181
|
const cleanup = () => {
|
|
182
|
+
clearTimeout(eofTimer);
|
|
175
183
|
clearTimeout(termTimer);
|
|
176
184
|
clearTimeout(killTimer);
|
|
185
|
+
clearTimeout(stdioTimer);
|
|
177
186
|
child.removeListener('exit', exited);
|
|
187
|
+
child.removeListener('close', finished);
|
|
178
188
|
child.removeListener('error', failed);
|
|
179
189
|
};
|
|
180
|
-
const
|
|
190
|
+
const finished = () => {
|
|
191
|
+
if (settled) return;
|
|
192
|
+
settled = true; cleanup(); resolve();
|
|
193
|
+
};
|
|
181
194
|
const failed = (error) => {
|
|
195
|
+
if (settled) return;
|
|
196
|
+
settled = true;
|
|
182
197
|
cleanup();
|
|
183
198
|
this.lastCloseError = new CliError('NATIVE_CLOSE_FAILED', 'Cannot confirm native provider process termination', {
|
|
184
199
|
pid: child.pid, uncertain: true, cause: error.code || error.message
|
|
185
200
|
});
|
|
186
201
|
reject(this.lastCloseError);
|
|
187
202
|
};
|
|
203
|
+
const exited = () => {
|
|
204
|
+
clearTimeout(eofTimer); clearTimeout(termTimer); clearTimeout(killTimer);
|
|
205
|
+
if (this.childClosed) return finished();
|
|
206
|
+
// Never signal an exited PID or guess ownership of descendants. Keep
|
|
207
|
+
// an unclosed inherited pipe visible as incomplete transport cleanup.
|
|
208
|
+
if (!stdioTimer) stdioTimer = setTimeout(() => failed(new CliError('NATIVE_STDIO_CLOSE_TIMEOUT',
|
|
209
|
+
'Native provider exited but its stdio did not close')), this.options.closeKillTimeoutMs ?? 2000);
|
|
210
|
+
};
|
|
188
211
|
const signal = (name) => {
|
|
189
212
|
try {
|
|
190
213
|
if (!child.kill(name)) {
|
|
@@ -195,16 +218,27 @@ export class JsonRpcProcess {
|
|
|
195
218
|
} catch (error) { failed(error); return false; }
|
|
196
219
|
};
|
|
197
220
|
child.once('exit', exited);
|
|
221
|
+
child.once('close', finished);
|
|
198
222
|
child.once('error', failed);
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
223
|
+
if (hasExited()) return exited();
|
|
224
|
+
const terminate = () => {
|
|
225
|
+
if (settled) return;
|
|
226
|
+
clearTimeout(eofTimer);
|
|
227
|
+
if (hasExited()) return exited();
|
|
228
|
+
termTimer = setTimeout(() => {
|
|
229
|
+
if (hasExited()) return exited();
|
|
230
|
+
// Sending SIGKILL is not a close receipt. Continue waiting for exit
|
|
231
|
+
// and closed stdio, with a separate bounded wait after direct exit.
|
|
232
|
+
killTimer = setTimeout(() => failed(new CliError('NATIVE_EXIT_TIMEOUT',
|
|
233
|
+
'Native provider did not report exit after SIGKILL')), this.options.closeKillTimeoutMs ?? 2000);
|
|
234
|
+
signal('SIGKILL');
|
|
235
|
+
}, this.options.closeGraceMs ?? 2000);
|
|
236
|
+
signal('SIGTERM');
|
|
237
|
+
};
|
|
238
|
+
// Let the provider handle EOF and reap its own ordinary subprocesses
|
|
239
|
+
// before escalating. closeGraceMs retains its SIGTERM-to-SIGKILL meaning.
|
|
240
|
+
eofTimer = setTimeout(terminate, this.options.closeEofGraceMs ?? 1000);
|
|
241
|
+
try { child.stdin.end(); } catch { terminate(); }
|
|
208
242
|
});
|
|
209
243
|
const closing = this.closePromise;
|
|
210
244
|
void closing.catch(() => {
|
|
@@ -124,6 +124,9 @@ function optionalRecord(directory, name) {
|
|
|
124
124
|
function validatePrepared(record, id, root, meshDbRelative) {
|
|
125
125
|
if (record?.version !== 1 || record.submission_id !== id ||
|
|
126
126
|
record.root !== root || record.mesh_db_relative !== meshDbRelative ||
|
|
127
|
+
typeof record.mesh_db_relative !== 'string' || !record.mesh_db_relative ||
|
|
128
|
+
path.isAbsolute(record.mesh_db_relative) || path.win32.isAbsolute(record.mesh_db_relative) ||
|
|
129
|
+
record.mesh_db_relative.split(/[\\/]/).includes('..') ||
|
|
127
130
|
typeof record.peer !== 'string' || !record.peer || typeof record.from !== 'string' || !record.from ||
|
|
128
131
|
!(record.task_id === null || (Number.isSafeInteger(record.task_id) && record.task_id > 0)) ||
|
|
129
132
|
!/^[a-f0-9]{64}$/.test(record.body_sha256) ||
|
|
@@ -200,7 +203,12 @@ export function listCliSubmissions(ctx, { peer = null } = {}) {
|
|
|
200
203
|
for (const name of fs.readdirSync(directory)) {
|
|
201
204
|
const match = name.match(PREPARED);
|
|
202
205
|
if (!match) continue;
|
|
203
|
-
|
|
206
|
+
// Journals are shared by all coordination databases in this project. Check
|
|
207
|
+
// the saved binding before filtering; retry lookup still requires the
|
|
208
|
+
// currently selected database so an ID cannot be replayed across them.
|
|
209
|
+
const saved = readRecord(directory, name);
|
|
210
|
+
const record = validatePrepared(saved, match[1], root, saved?.mesh_db_relative);
|
|
211
|
+
if (record.mesh_db_relative !== meshDbRelative) continue;
|
|
204
212
|
if (peer && record.peer !== peer) continue;
|
|
205
213
|
const receiptName = `receipt-${record.submission_id}.json`;
|
|
206
214
|
let receipt = optionalRecord(directory, receiptName);
|
|
@@ -90,6 +90,9 @@ export async function startNativeService(ctx, deps, options = {}) {
|
|
|
90
90
|
? 'An ownership listener has no identity handshake and may belong to an older native runtime. Stop that runtime before retrying.'
|
|
91
91
|
: 'A native runtime already owns this project, or all ownership ports are unavailable.');
|
|
92
92
|
}
|
|
93
|
+
if (error?.code === 'ERR_FILE_LOCK_WORKER_START_FAILED' && error.listener) {
|
|
94
|
+
throw new CliError('NATIVE_OWNERSHIP_UNAVAILABLE', 'Cannot start the native runtime ownership listener', error.listener);
|
|
95
|
+
}
|
|
93
96
|
throw error;
|
|
94
97
|
}
|
|
95
98
|
let store;
|
package/lib/shared/file-lock.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import fs from 'node:fs';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { Worker } from 'node:worker_threads';
|
|
5
5
|
import { aggregateCleanupFailure } from './cleanup-error.mjs';
|
|
6
|
+
import { readSocketLockFailure, SOCKET_LOCK_STATE_LENGTH } from './socket-lock-diagnostics.mjs';
|
|
6
7
|
|
|
7
8
|
const NO_ERROR = Symbol('no-file-lock-error');
|
|
8
9
|
const STATE_INDEX = 0;
|
|
@@ -193,7 +194,10 @@ function injectedFailure(worker, state, phase, key) {
|
|
|
193
194
|
} catch (error) {
|
|
194
195
|
return workerError(key, phase, error);
|
|
195
196
|
}
|
|
196
|
-
|
|
197
|
+
const error = workerError(key, phase);
|
|
198
|
+
const listener = readSocketLockFailure(state);
|
|
199
|
+
if (listener) error.listener = listener;
|
|
200
|
+
return error;
|
|
197
201
|
}
|
|
198
202
|
|
|
199
203
|
function terminateWorker(worker, primary, key) {
|
|
@@ -241,7 +245,7 @@ function acquisitionError(status, resources) {
|
|
|
241
245
|
}
|
|
242
246
|
|
|
243
247
|
function acquireWorker(location, options, workerFactory) {
|
|
244
|
-
const buffer = new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT *
|
|
248
|
+
const buffer = new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT * SOCKET_LOCK_STATE_LENGTH);
|
|
245
249
|
const state = new Int32Array(buffer);
|
|
246
250
|
const endpoints = endpointsForLocation(location);
|
|
247
251
|
const targets = lockTargetsForLocation(location);
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Only bounded numeric fields cross the synchronous worker boundary. Never
|
|
2
|
+
// copy a listener error's message, path, address, or arbitrary properties.
|
|
3
|
+
export const SOCKET_LOCK_STATE_LENGTH = 6;
|
|
4
|
+
const CODE_INDEX = 2;
|
|
5
|
+
const ERRNO_INDEX = 3;
|
|
6
|
+
const SYSCALL_INDEX = 4;
|
|
7
|
+
const PORT_INDEX = 5;
|
|
8
|
+
const CODES = Object.freeze([null, 'EACCES', 'EPERM', 'EMFILE', 'ENFILE', 'ENOBUFS',
|
|
9
|
+
'ENOMEM', 'EADDRNOTAVAIL', 'EAFNOSUPPORT', 'EINVAL', 'ENOTSOCK', 'EBADF', 'EADDRINUSE']);
|
|
10
|
+
const SYSCALLS = Object.freeze([null, 'listen', 'bind', 'accept', 'close']);
|
|
11
|
+
|
|
12
|
+
export function writeSocketLockFailure(state, error, port) {
|
|
13
|
+
if (state.length < SOCKET_LOCK_STATE_LENGTH) return;
|
|
14
|
+
Atomics.store(state, CODE_INDEX, Math.max(0, CODES.indexOf(error?.code)));
|
|
15
|
+
const errno = error?.errno;
|
|
16
|
+
Atomics.store(state, ERRNO_INDEX, Number.isInteger(errno) && Math.abs(errno) <= 65535 ? errno : 0);
|
|
17
|
+
Atomics.store(state, SYSCALL_INDEX, Math.max(0, SYSCALLS.indexOf(error?.syscall)));
|
|
18
|
+
Atomics.store(state, PORT_INDEX, Number.isInteger(port) && port > 0 && port <= 65535 ? port : 0);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function readSocketLockFailure(state) {
|
|
22
|
+
if (state.length < SOCKET_LOCK_STATE_LENGTH) return null;
|
|
23
|
+
const port = Atomics.load(state, PORT_INDEX);
|
|
24
|
+
if (port < 1 || port > 65535) return null;
|
|
25
|
+
return {
|
|
26
|
+
cause: { code: CODES[Atomics.load(state, CODE_INDEX)] ?? null,
|
|
27
|
+
errno: Atomics.load(state, ERRNO_INDEX) || null,
|
|
28
|
+
syscall: SYSCALLS[Atomics.load(state, SYSCALL_INDEX)] ?? null },
|
|
29
|
+
endpoint: { host: '127.0.0.1', port }
|
|
30
|
+
};
|
|
31
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import net from 'node:net';
|
|
2
2
|
import { parentPort } from 'node:worker_threads';
|
|
3
|
+
import { writeSocketLockFailure } from './socket-lock-diagnostics.mjs';
|
|
3
4
|
|
|
4
5
|
const STATE_INDEX = 0;
|
|
5
6
|
const DETAIL_INDEX = 1;
|
|
@@ -85,8 +86,9 @@ export function runSocketLockWorker(workerData) {
|
|
|
85
86
|
complete();
|
|
86
87
|
}
|
|
87
88
|
|
|
88
|
-
function requestTerminalFailure() {
|
|
89
|
+
function requestTerminalFailure(error = null, port = 0) {
|
|
89
90
|
if (finished) return;
|
|
91
|
+
if (!terminalFailureRequested) writeSocketLockFailure(state, error, port);
|
|
90
92
|
terminalFailureRequested = true;
|
|
91
93
|
generation += 1;
|
|
92
94
|
closeServers(() => finish(states.FAILED, 1));
|
|
@@ -184,7 +186,7 @@ export function runSocketLockWorker(workerData) {
|
|
|
184
186
|
const bindError = (error) => {
|
|
185
187
|
if (finished || attemptGeneration !== generation) return;
|
|
186
188
|
if (error?.code !== 'EADDRINUSE') {
|
|
187
|
-
requestTerminalFailure();
|
|
189
|
+
requestTerminalFailure(error, port);
|
|
188
190
|
return;
|
|
189
191
|
}
|
|
190
192
|
probeOccupant(port, target.identity, (matching, legacy) => {
|
|
@@ -206,12 +208,12 @@ export function runSocketLockWorker(workerData) {
|
|
|
206
208
|
return;
|
|
207
209
|
}
|
|
208
210
|
candidate.off('error', bindError);
|
|
209
|
-
candidate.on('error', requestTerminalFailure);
|
|
211
|
+
candidate.on('error', error => requestTerminalFailure(error, port));
|
|
210
212
|
servers.push(candidate);
|
|
211
213
|
bindCandidate(targetIndex, candidateIndex + 1, boundForTarget + 1, attemptGeneration);
|
|
212
214
|
});
|
|
213
|
-
} catch {
|
|
214
|
-
requestTerminalFailure();
|
|
215
|
+
} catch (error) {
|
|
216
|
+
requestTerminalFailure(error, port);
|
|
215
217
|
}
|
|
216
218
|
}
|
|
217
219
|
|
|
@@ -1,10 +1,8 @@
|
|
|
1
|
-
import fs from 'node:fs/promises';
|
|
2
1
|
import path from 'node:path';
|
|
3
|
-
import process from 'node:process';
|
|
4
2
|
import { CliError } from '../shared/errors.mjs';
|
|
5
3
|
import { captureSelectedCwdIdentity } from '../process/selected-cwd-identity.mjs';
|
|
6
4
|
import { privateComponent, PROJECT_PRIVATE_COMPONENT_WORKER_SOURCE,
|
|
7
|
-
runPinnedDirectoryWorker } from './project-files.mjs';
|
|
5
|
+
runPinnedDirectoryWorker, assertProjectFileReadsAvailable } from './project-files.mjs';
|
|
8
6
|
|
|
9
7
|
const contextIgnored = new Set(['.git', 'node_modules', '.hcc', '.hello-cc', '.codex', '.claude', '.dsh', '.ssh', '.aws', 'dist', 'build', 'coverage', '.next']);
|
|
10
8
|
|
|
@@ -17,90 +15,6 @@ function assertBoundRoot(rootIdentity) {
|
|
|
17
15
|
rootIdentity.assertUnchanged();
|
|
18
16
|
}
|
|
19
17
|
|
|
20
|
-
function sameDirectory(left, right) {
|
|
21
|
-
return left.isDirectory() && right.isDirectory() && left.dev === right.dev &&
|
|
22
|
-
left.ino === right.ino && left.mode === right.mode && left.size === right.size &&
|
|
23
|
-
left.mtimeNs === right.mtimeNs && left.ctimeNs === right.ctimeNs;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
// Windows cannot use the POSIX pinned helper, so keep the existing read-only
|
|
27
|
-
// traversal there. Its transient ancestor-replacement risk is not closed here.
|
|
28
|
-
async function listContextFilesByPath(root, query, { limit, maxEntries, timeBudgetMs, now, rootIdentity }) {
|
|
29
|
-
assertBoundRoot(rootIdentity);
|
|
30
|
-
const base = await fs.realpath(root), pending = [''], paths = [], needle = query.toLocaleLowerCase();
|
|
31
|
-
const directorySnapshots = [];
|
|
32
|
-
if (rootIdentity) {
|
|
33
|
-
const parent = path.dirname(base);
|
|
34
|
-
directorySnapshots.push({ directory: parent, stat: await fs.lstat(parent, { bigint: true }) });
|
|
35
|
-
const initial = await fs.lstat(base, { bigint: true });
|
|
36
|
-
assertBoundRoot(rootIdentity);
|
|
37
|
-
if (rootIdentity.identity.dev !== initial.dev.toString() ||
|
|
38
|
-
rootIdentity.identity.ino !== initial.ino.toString() ||
|
|
39
|
-
rootIdentity.identity.birthtimeNs !== (initial.birthtimeNs?.toString() || null)) throw changed();
|
|
40
|
-
directorySnapshots.push({ directory: base, stat: initial });
|
|
41
|
-
}
|
|
42
|
-
const exact = await exactContextFile(base, query, rootIdentity);
|
|
43
|
-
if (exact) paths.push(exact);
|
|
44
|
-
let seen = 0, truncated = false;
|
|
45
|
-
const deadline = now() + timeBudgetMs;
|
|
46
|
-
outer: while (pending.length) {
|
|
47
|
-
if (now() >= deadline || seen >= maxEntries || paths.length >= limit) { truncated = true; break; }
|
|
48
|
-
assertBoundRoot(rootIdentity);
|
|
49
|
-
const relative = pending.shift(), directory = path.join(base, relative);
|
|
50
|
-
try {
|
|
51
|
-
const before = await fs.lstat(directory, { bigint: true });
|
|
52
|
-
if (before.isSymbolicLink() || !before.isDirectory()) continue;
|
|
53
|
-
if (rootIdentity) directorySnapshots.push({ directory, stat: before });
|
|
54
|
-
const resolved = await fs.realpath(directory), fromRoot = path.relative(base, resolved);
|
|
55
|
-
if (fromRoot === '..' || fromRoot.startsWith('..' + path.sep) || path.isAbsolute(fromRoot)) continue;
|
|
56
|
-
const handle = await fs.opendir(directory);
|
|
57
|
-
for await (const item of handle) {
|
|
58
|
-
seen++;
|
|
59
|
-
if (seen > maxEntries || now() >= deadline) { truncated = true; break outer; }
|
|
60
|
-
if (item.isSymbolicLink() || contextIgnored.has(item.name) || privateComponent(item.name) ||
|
|
61
|
-
/[\\:\x00-\x1f\x7f]/.test(item.name)) continue;
|
|
62
|
-
const name = relative ? relative + '/' + item.name : item.name;
|
|
63
|
-
if (item.isDirectory()) pending.push(name);
|
|
64
|
-
else if (item.isFile() && name !== exact && name.toLocaleLowerCase().includes(needle)) {
|
|
65
|
-
paths.push(name);
|
|
66
|
-
if (paths.length >= limit) { truncated = true; break outer; }
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
if (rootIdentity && !sameDirectory(before, await fs.lstat(directory, { bigint: true }))) throw changed();
|
|
70
|
-
} catch (error) { if (!['ENOENT', 'ENOTDIR', 'EACCES', 'EPERM'].includes(error.code)) throw error; }
|
|
71
|
-
}
|
|
72
|
-
assertBoundRoot(rootIdentity);
|
|
73
|
-
if (rootIdentity) {
|
|
74
|
-
for (const { directory, stat } of directorySnapshots) {
|
|
75
|
-
if (!sameDirectory(stat, await fs.lstat(directory, { bigint: true }))) throw changed();
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
return { paths: paths.sort(), truncated };
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
async function exactContextFile(base, query, rootIdentity) {
|
|
82
|
-
const parts = query.split('/');
|
|
83
|
-
if (!query || parts.some(part => !part || part === '.' || contextIgnored.has(part) || privateComponent(part))) return null;
|
|
84
|
-
let directory = base;
|
|
85
|
-
try {
|
|
86
|
-
for (let index = 0; index < parts.length; index++) {
|
|
87
|
-
assertBoundRoot(rootIdentity);
|
|
88
|
-
const target = path.join(directory, parts[index]), stat = await fs.lstat(target);
|
|
89
|
-
if (stat.isSymbolicLink() || (index < parts.length - 1 ? !stat.isDirectory() : !stat.isFile())) return null;
|
|
90
|
-
const resolved = await fs.realpath(target), relative = path.relative(base, resolved);
|
|
91
|
-
if (relative === '..' || relative.startsWith('..' + path.sep) || path.isAbsolute(relative) ||
|
|
92
|
-
relative.split(path.sep).some(part => contextIgnored.has(part) || privateComponent(part))) return null;
|
|
93
|
-
// Recheck the named entry after resolving it and discard any observed
|
|
94
|
-
// link or entry replacement before offering the filename.
|
|
95
|
-
const confirmed = await fs.lstat(target);
|
|
96
|
-
if (confirmed.isSymbolicLink() || confirmed.dev !== stat.dev || confirmed.ino !== stat.ino) return null;
|
|
97
|
-
directory = resolved;
|
|
98
|
-
if (index === parts.length - 1) return relative.split(path.sep).join('/');
|
|
99
|
-
}
|
|
100
|
-
} catch (error) { if (!['ENOENT', 'ENOTDIR', 'EACCES', 'EPERM'].includes(error.code)) throw error; }
|
|
101
|
-
return null;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
18
|
function contextListWorker() {
|
|
105
19
|
const fs = require('node:fs');
|
|
106
20
|
const { query, limit, maxEntries, timeBudgetMs } = JSON.parse(process.argv[1]);
|
|
@@ -206,8 +120,7 @@ export async function listContextFiles(root, query = '', { limit = 80, maxEntrie
|
|
|
206
120
|
if (!Number.isSafeInteger(limit) || limit < 1 || !Number.isSafeInteger(maxEntries) || maxEntries < 1 || !Number.isFinite(timeBudgetMs) || timeBudgetMs < 0 || typeof now !== 'function') {
|
|
207
121
|
throw new TypeError('Invalid context file listing budget');
|
|
208
122
|
}
|
|
209
|
-
|
|
210
|
-
{ limit, maxEntries, timeBudgetMs, now, rootIdentity });
|
|
123
|
+
assertProjectFileReadsAvailable();
|
|
211
124
|
const ownedIdentity = rootIdentity ? null : captureSelectedCwdIdentity(root);
|
|
212
125
|
const identity = rootIdentity || ownedIdentity;
|
|
213
126
|
try {
|
package/lib/web/http-routes.mjs
CHANGED
|
@@ -235,12 +235,12 @@ export function createHttpRoutes(deps) {
|
|
|
235
235
|
const safeMethod = ['GET', 'HEAD', 'OPTIONS'].includes(req.method || '');
|
|
236
236
|
// Cookie-authenticated writes require affirmative same-origin evidence;
|
|
237
237
|
// a missing Origin is not sufficient. Token-authenticated CLI requests
|
|
238
|
-
// without cookies
|
|
239
|
-
// requests
|
|
238
|
+
// without cookies may omit Origin, as may tokenless loopback CLI
|
|
239
|
+
// requests. Any supplied Origin must match, including with a Bearer token.
|
|
240
240
|
const cookieAuthenticated = authMode === 'cookie' || cookieSessionOk(req);
|
|
241
|
-
const originRequired = cookieAuthenticated ||
|
|
241
|
+
const originRequired = cookieAuthenticated || Boolean(req.headers.origin);
|
|
242
242
|
if (!safeMethod && originRequired && !requestOriginMatches(req, { trustProxy, proxyOrigin })) {
|
|
243
|
-
sendJson(res, 403, { ok: false, error: { code: 'CSRF_ORIGIN', message: '
|
|
243
|
+
sendJson(res, 403, { ok: false, error: { code: 'CSRF_ORIGIN', message: 'Writes with cookies or Origin require a same-origin request' } });
|
|
244
244
|
return;
|
|
245
245
|
}
|
|
246
246
|
if (req.method === 'POST' && url.pathname === '/logout') {
|
|
@@ -591,7 +591,16 @@ export function createNativeSessions({ sessions, sessionKey, connectWebProject,
|
|
|
591
591
|
}
|
|
592
592
|
let saved;
|
|
593
593
|
try { saved = JSON.parse(previous.payload); } catch { /* Reject below. */ }
|
|
594
|
-
|
|
594
|
+
// A fresh Claude query receives its session ID on its first input.
|
|
595
|
+
// The initial durable intent can therefore precede that ID. A
|
|
596
|
+
// fenced read above has confirmed the current provider session;
|
|
597
|
+
// allow this one-way confirmation only for the same owned executor.
|
|
598
|
+
// All other identity/body fields still have to match exactly.
|
|
599
|
+
const initializedClaude = saved?.provider === 'claude' && pending.provider === 'claude' &&
|
|
600
|
+
saved.provider_session_id === null && typeof pending.provider_session_id === 'string' &&
|
|
601
|
+
pending.provider_session_id.length > 0;
|
|
602
|
+
if (!saved || Object.keys(pending).some(key => saved[key] !== pending[key] &&
|
|
603
|
+
!(key === 'provider_session_id' && initializedClaude))) {
|
|
595
604
|
throw new CliError('NATIVE_SUBMISSION_MISMATCH', 'Retry must keep the original worker, executor and message');
|
|
596
605
|
}
|
|
597
606
|
} else {
|
|
@@ -30,11 +30,22 @@ const media = new Map([['.png', 'image/png'], ['.jpg', 'image/jpeg'], ['.jpeg',
|
|
|
30
30
|
const errorStatus = new Map([['PROJECT_FILE_BAD_PATH', 400], ['PROJECT_FILE_FORBIDDEN', 403],
|
|
31
31
|
['PROJECT_FILE_NOT_FOUND', 404], ['PROJECT_FILE_CHANGED', 409], ['PROJECT_FILE_TOO_LARGE', 413],
|
|
32
32
|
['PROJECT_FILE_CONFLICT', 409], ['PROJECT_FILE_EXISTS', 409],
|
|
33
|
+
['PROJECT_FILE_READ_UNAVAILABLE', 503],
|
|
33
34
|
['PROJECT_FILE_WRITE_UNCONFIRMED', 503], ['PROJECT_FILE_WRITE_UNAVAILABLE', 503],
|
|
34
35
|
['PROJECT_FILE_NOT_DIRECTORY', 400], ['PROJECT_FILE_NOT_REGULAR', 400], ['PROJECT_FILE_UNAVAILABLE', 500]]);
|
|
35
36
|
|
|
36
37
|
export function projectFileErrorStatus(error) { return errorStatus.get(error?.code); }
|
|
37
38
|
|
|
39
|
+
// Native Windows is unsupported (use WSL). Its path-based fallback cannot
|
|
40
|
+
// protect against transient ancestor replacement. Refuse before any lookup,
|
|
41
|
+
// including callers that supply an already-captured project identity.
|
|
42
|
+
export function assertProjectFileReadsAvailable() {
|
|
43
|
+
if (!['darwin', 'linux'].includes(process.platform) || !constants.O_NOFOLLOW || !constants.O_DIRECTORY) {
|
|
44
|
+
throw new CliError('PROJECT_FILE_READ_UNAVAILABLE',
|
|
45
|
+
'Safe project file access requires Linux or macOS; on Windows run HCC inside WSL');
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
38
49
|
export function privateComponent(name) {
|
|
39
50
|
const lower = name.toLowerCase();
|
|
40
51
|
return ignored.has(lower) || privateNames.has(lower) || /^\.hcc-file-write-/.test(lower) || /^(?:\.?env)(?:[._-]|$)/.test(lower) ||
|
|
@@ -154,30 +165,6 @@ export const PROJECT_PRIVATE_COMPONENT_WORKER_SOURCE = `const ignored = new Set(
|
|
|
154
165
|
const PROJECT_LIST_WORKER_SOURCE = PROJECT_PRIVATE_COMPONENT_WORKER_SOURCE +
|
|
155
166
|
`(${projectListWorker.toString()})()`;
|
|
156
167
|
|
|
157
|
-
// Windows has no trusted pinned-cwd launch. Keep its existing read-only
|
|
158
|
-
// listing behavior; transient ancestor replacement remains outside this POSIX fix.
|
|
159
|
-
async function listProjectFilesByPath(snapshot) {
|
|
160
|
-
let directory;
|
|
161
|
-
try {
|
|
162
|
-
directory = await fs.opendir(snapshot.absolute);
|
|
163
|
-
const entries = [];
|
|
164
|
-
let scanned = 0, truncated = false;
|
|
165
|
-
for await (const item of directory) {
|
|
166
|
-
if (++scanned > PROJECT_FILE_LIMITS.scannedEntries) { truncated = true; break; }
|
|
167
|
-
if (privateComponent(item.name) || /[\\:\x00-\x1f\x7f]/.test(item.name) || item.isSymbolicLink()) continue;
|
|
168
|
-
if (!item.isDirectory() && !item.isFile()) continue;
|
|
169
|
-
let stat;
|
|
170
|
-
try { stat = await fs.lstat(path.join(snapshot.absolute, item.name), { bigint: true }); }
|
|
171
|
-
catch (error) { if (error.code === 'ENOENT') throw changed(); throw error; }
|
|
172
|
-
if (stat.isSymbolicLink() || (!stat.isDirectory() && (!stat.isFile() || stat.nlink !== 1n))) continue;
|
|
173
|
-
entries.push({ name: item.name, type: stat.isDirectory() ? 'directory' : 'file',
|
|
174
|
-
...(stat.isFile() ? { size: Number(stat.size) } : {}) });
|
|
175
|
-
}
|
|
176
|
-
directory = null;
|
|
177
|
-
return { entries, truncated };
|
|
178
|
-
} finally { await directory?.close().catch(() => {}); }
|
|
179
|
-
}
|
|
180
|
-
|
|
181
168
|
async function inspectPath(root, parts, directory, rootIdentity) {
|
|
182
169
|
assertBoundRoot(rootIdentity);
|
|
183
170
|
const base = await fs.realpath(root), chain = [];
|
|
@@ -215,6 +202,7 @@ export async function verifyProjectFileParent(snapshot, handle) { await verifyPa
|
|
|
215
202
|
export async function prepareProjectFileParent(root, relative, { rootIdentity } = {}) {
|
|
216
203
|
try {
|
|
217
204
|
const parts = relativePath(relative, false);
|
|
205
|
+
assertProjectFileReadsAvailable();
|
|
218
206
|
const snapshot = await inspectPath(root, parts.slice(0, -1), true, rootIdentity);
|
|
219
207
|
const handle = await openChecked(snapshot, true);
|
|
220
208
|
return { snapshot, handle, name: parts.at(-1) };
|
|
@@ -237,19 +225,16 @@ export async function listProjectFiles(root, relative = '', { rootIdentity } = {
|
|
|
237
225
|
let handle, ownedIdentity;
|
|
238
226
|
try {
|
|
239
227
|
const parts = relativePath(relative, true);
|
|
228
|
+
assertProjectFileReadsAvailable();
|
|
240
229
|
ownedIdentity = rootIdentity ? null : captureSelectedCwdIdentity(root);
|
|
241
230
|
const identity = rootIdentity || ownedIdentity;
|
|
242
231
|
const snapshot = await inspectPath(root, parts, true, identity);
|
|
243
232
|
handle = await openChecked(snapshot, true);
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
birthtimeNs: snapshot.stat.birthtimeNs?.toString() || null } };
|
|
250
|
-
listing = await runPinnedDirectoryWorker(snapshot.absolute, expectedIdentity,
|
|
251
|
-
PROJECT_LIST_WORKER_SOURCE, { scannedEntries: PROJECT_FILE_LIMITS.scannedEntries });
|
|
252
|
-
}
|
|
233
|
+
const expectedIdentity = { canonical: snapshot.absolute, identity: {
|
|
234
|
+
dev: snapshot.stat.dev.toString(), ino: snapshot.stat.ino.toString(),
|
|
235
|
+
birthtimeNs: snapshot.stat.birthtimeNs?.toString() || null } };
|
|
236
|
+
const listing = await runPinnedDirectoryWorker(snapshot.absolute, expectedIdentity,
|
|
237
|
+
PROJECT_LIST_WORKER_SOURCE, { scannedEntries: PROJECT_FILE_LIMITS.scannedEntries });
|
|
253
238
|
const entries = listing.entries.map(item => ({ ...item,
|
|
254
239
|
path: relative ? relative + '/' + item.name : item.name }));
|
|
255
240
|
await verifyPath(snapshot, handle);
|
|
@@ -304,7 +289,9 @@ function validMedia(bytes, mime) {
|
|
|
304
289
|
export async function previewProjectFile(root, relative, { rootIdentity } = {}) {
|
|
305
290
|
let handle;
|
|
306
291
|
try {
|
|
307
|
-
const parts = relativePath(relative, false)
|
|
292
|
+
const parts = relativePath(relative, false);
|
|
293
|
+
assertProjectFileReadsAvailable();
|
|
294
|
+
const snapshot = await inspectPath(root, parts, false, rootIdentity);
|
|
308
295
|
handle = await openChecked(snapshot, false);
|
|
309
296
|
const name = parts.at(-1), size = Number(snapshot.stat.size), extension = path.extname(name).toLowerCase();
|
|
310
297
|
const result = { path: relative, name, size, kind: 'unsupported', mime: 'application/octet-stream', encoding: 'base64', content: '', editable: false };
|
|
@@ -338,7 +325,9 @@ export async function previewProjectFile(root, relative, { rootIdentity } = {})
|
|
|
338
325
|
export async function inspectProjectFileStatus(root, relative, { rootIdentity } = {}) {
|
|
339
326
|
let handle;
|
|
340
327
|
try {
|
|
341
|
-
const parts = relativePath(relative, false)
|
|
328
|
+
const parts = relativePath(relative, false);
|
|
329
|
+
assertProjectFileReadsAvailable();
|
|
330
|
+
const snapshot = await inspectPath(root, parts, false, rootIdentity);
|
|
342
331
|
handle = await openChecked(snapshot, false);
|
|
343
332
|
const size = Number(snapshot.stat.size);
|
|
344
333
|
if (size > PROJECT_FILE_LIMITS.mediaBytes) throw new CliError('PROJECT_FILE_TOO_LARGE', 'File status reads are limited to 10 MiB');
|