@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.
@@ -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 || child.exitCode !== null || child.signalCode !== null) {
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 exited = () => { cleanup(); resolve(); };
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
- termTimer = setTimeout(() => {
200
- if (child.exitCode !== null || child.signalCode !== null) return exited();
201
- // Sending SIGKILL is not an exit receipt. Continue waiting for exit.
202
- killTimer = setTimeout(() => failed(new CliError('NATIVE_EXIT_TIMEOUT',
203
- 'Native provider did not report exit after SIGKILL')), this.options.closeKillTimeoutMs ?? 2000);
204
- signal('SIGKILL');
205
- }, this.options.closeGraceMs ?? 2000);
206
- try { child.stdin.end(); } catch {}
207
- signal('SIGTERM');
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
- const record = validatePrepared(readRecord(directory, name), match[1], root, meshDbRelative);
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;
@@ -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
- return workerError(key, phase);
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 * 2);
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
- if (process.platform === 'win32') return listContextFilesByPath(root, query,
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 {
@@ -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 remain origin-free, as do tokenless loopback CLI
239
- // requests; a supplied Origin on the tokenless runtime must still match.
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 || (!token && Boolean(req.headers.origin));
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: 'Cookie-authenticated writes require a same-origin request' } });
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
- if (!saved || Object.keys(pending).some(key => saved[key] !== pending[key])) {
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
- let listing;
245
- if (process.platform === 'win32') listing = await listProjectFilesByPath(snapshot);
246
- else {
247
- const expectedIdentity = { canonical: snapshot.absolute, identity: {
248
- dev: snapshot.stat.dev.toString(), ino: snapshot.stat.ino.toString(),
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), snapshot = await inspectPath(root, parts, false, rootIdentity);
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), snapshot = await inspectPath(root, parts, false, rootIdentity);
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');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@logicseek/hello-cc",
3
- "version": "1.1.0-rc.9",
3
+ "version": "1.1.1",
4
4
  "description": "Local control plane for coordinating Claude Code, Codex, and other coding CLI sessions.",
5
5
  "type": "module",
6
6
  "bin": {