@logicseek/hello-cc 1.1.0 → 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.
@@ -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",
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": {
@@ -0,0 +1,202 @@
1
+ // Actual SDK, CLI subprocess and production adapter; optional local Messages
2
+ // fixture isolates transcript/fork behavior from model recall and credentials.
3
+ import assert from 'node:assert/strict';
4
+ import fs from 'node:fs';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import http from 'node:http';
8
+ import { createHash, randomUUID } from 'node:crypto';
9
+ import { createRequire } from 'node:module';
10
+ import { fileURLToPath, pathToFileURL } from 'node:url';
11
+ import { setTimeout as delay } from 'node:timers/promises';
12
+ import { createClaudeAdapter } from '../lib/integrations/native/claude.mjs';
13
+ import { redactSecrets } from '../lib/shared/redact.mjs';
14
+
15
+ const args = process.argv.slice(2);
16
+ if (args.includes('--help') || (!args.includes('--fixture') && !args.includes('--run-live'))) {
17
+ console.log('Usage: node scripts/claude-fork-acceptance.mjs --fixture|--run-live [--claude-package DIR] [--output FILE]');
18
+ console.log('--fixture uses a local Messages server, no model or credentials. --run-live copies existing model routing/auth into a private provider home and consumes quota.');
19
+ process.exit(0);
20
+ }
21
+ const flags = new Set(['--fixture', '--run-live']), values = new Set(['--claude-package', '--output']);
22
+ const options = new Map();
23
+ for (let index = 0; index < args.length; index++) {
24
+ const name = args[index];
25
+ assert.ok(!options.has(name) && (flags.has(name) || values.has(name)), 'Unknown or repeated option');
26
+ if (flags.has(name)) options.set(name, true);
27
+ else { assert.ok(args[index + 1] && !args[index + 1].startsWith('--'), 'Missing option value'); options.set(name, args[++index]); }
28
+ }
29
+ assert.notEqual(Boolean(options.get('--fixture')), Boolean(options.get('--run-live')), 'Select exactly one mode');
30
+ assert.ok(['linux', 'darwin'].includes(process.platform), 'Run inside Linux, macOS or WSL');
31
+ const live = Boolean(options.get('--run-live'));
32
+ const repo = fileURLToPath(new URL('..', import.meta.url));
33
+ const output = path.resolve(options.get('--output') || path.join(os.tmpdir(), 'hcc-claude-fork-' + randomUUID() + '.json'));
34
+ const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'hcc-claude-fork-')));
35
+ fs.chmodSync(sandbox, 0o700);
36
+ const root = path.join(sandbox, 'project'), home = path.join(sandbox, 'home'), providerHome = path.join(home, '.claude');
37
+ for (const directory of [root, home, providerHome]) fs.mkdirSync(directory, { mode: 0o700 });
38
+ const hash = value => createHash('sha256').update(value).digest('hex');
39
+ const digest = file => hash(fs.readFileSync(file));
40
+ const sourceFiles = Object.fromEntries(['scripts/claude-fork-acceptance.mjs', 'lib/integrations/native/claude.mjs',
41
+ 'lib/integrations/native/interactions.mjs', 'lib/process/selected-cwd-identity.mjs'].map(name => [name, digest(path.join(repo, name))]));
42
+ const originalEnv = { HOME: process.env.HOME, CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR };
43
+ const settings = path.join(originalEnv.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude'), 'settings.json');
44
+ const protectedBefore = fs.existsSync(settings) ? digest(settings) : null;
45
+ const report = { schemaVersion: 1, startedAt: new Date().toISOString(), sourceFiles,
46
+ mode: live ? 'real configured model' : 'deterministic local Messages fixture; actual SDK and CLI subprocess',
47
+ modelCallsRequested: live, realModelCalls: false, queryInjected: false, checks: [], snapshots: [], requests: [], cleanup: {}, success: false,
48
+ limitations: ['Adapter/SDK transcript and recall acceptance; native service ownership guards are covered separately.',
49
+ 'A successful routed model call establishes only the model actually reported, not Anthropic model acceptance.'] };
50
+ const flush = () => { fs.mkdirSync(path.dirname(output), { recursive: true }); fs.writeFileSync(output, JSON.stringify(redactSecrets(report), null, 2) + '\n', { mode: 0o600 }); };
51
+ const check = name => { report.checks.push(name); console.log('PASS ' + name); flush(); };
52
+ const adapters = [], observations = new Map();
53
+ let server, sdk;
54
+ function adapter(name) {
55
+ const events = [];
56
+ const value = createClaudeAdapter({ cwd: root, env: { ...process.env, HCC_SHIM_ENSURED: '1', HCC_SHIM_NO_ATTACH: '1' },
57
+ executorId: randomUUID(), timeoutMs: 15000,
58
+ onEvent(event) {
59
+ if (event.type === 'metadata') report.reportedModel = event.runtimeMetadata?.model || report.reportedModel;
60
+ if (event.type === 'completed') events.push({ type: event.type, turnId: event.turnId, status: event.status, text: event.text });
61
+ } });
62
+ adapters.push({ name, value }); observations.set(value, events); return value;
63
+ }
64
+ async function turn(value, prompt, expected) {
65
+ const sent = await value.send({ text: prompt, submissionId: randomUUID() });
66
+ const deadline = Date.now() + 90000;
67
+ let completion;
68
+ while (!(completion = observations.get(value).find(event => event.turnId === sent.turnId)) && Date.now() < deadline) {
69
+ const state = value.snapshot();
70
+ assert.equal(state.pendingApprovals?.length || 0, 0, 'Nonce-only fork test must never approve tools');
71
+ assert.ok(!state.error, 'Unexpected SDK error during fork acceptance');
72
+ await delay(50);
73
+ }
74
+ assert.ok(completion, 'Fork acceptance turn timed out');
75
+ if (live) report.realModelCalls = true;
76
+ assert.equal(completion.status, 'completed');
77
+ // Persist a non-sensitive actual/expected digest even when the assertion fails.
78
+ report.lastTurn = { sessionId: value.snapshot().sessionId, turnId: sent.turnId,
79
+ actualSha256: hash(completion.text || ''), expectedSha256: hash(expected), matches: completion.text?.trim() === expected };
80
+ flush(); assert.equal(completion.text?.trim(), expected);
81
+ }
82
+ async function history(sessionId, label) {
83
+ const messages = (await sdk.getSessionMessages(sessionId, { dir: root })).filter(message => ['user', 'assistant'].includes(message.type));
84
+ const snapshot = { label, sessionId, messages: messages.length,
85
+ contentHashes: messages.map(message => hash(JSON.stringify({ type: message.type, message: message.message }))),
86
+ messageIds: messages.map(message => message.uuid) };
87
+ report.snapshots.push(snapshot); flush(); return snapshot;
88
+ }
89
+ async function assertCopied(parentId, childId, label) {
90
+ // Capture both immediately after fork and before the child's first query.
91
+ const parent = await history(parentId, label + ': parent'), child = await history(childId, label + ': child before first query');
92
+ assert.ok(parent.messages >= 2, 'Parent transcript must include the seed turn');
93
+ assert.deepEqual(child.contentHashes, parent.contentHashes, 'Fork must copy the whole parent history before recall');
94
+ check(label + ': complete parent/child transcript copy before first child query');
95
+ }
96
+ function fixtureText(messages) {
97
+ const users = messages.filter(message => message.role === 'user').map(message =>
98
+ typeof message.content === 'string' ? message.content : (message.content || []).filter(block => block.type === 'text').map(block => block.text).join('\n'));
99
+ const seed = users.join('\n').match(/HCC_FORK_SEED (HCC_BASE_[a-f0-9]+)\|(HCC_BRANCH_[a-f0-9]+)/);
100
+ let branch = seed?.[2];
101
+ for (const text of users) { const changed = /HCC_FORK_SET (HCC_BRANCH_[a-f0-9]+)/.exec(text); if (changed) branch = changed[1]; }
102
+ report.requests.push({ historyHasSeed: Boolean(seed), userMessages: users.length, branchSha256: branch ? hash(branch) : null });
103
+ return seed ? seed[1] + '|' + branch : 'MISSING_CONTEXT';
104
+ }
105
+ function stream(response, model, text) {
106
+ response.writeHead(200, { 'content-type': 'text/event-stream' });
107
+ const event = (type, value) => response.write('event: ' + type + '\ndata: ' + JSON.stringify({ type, ...value }) + '\n\n');
108
+ event('message_start', { message: { id: 'msg_' + randomUUID().replaceAll('-', ''), type: 'message', role: 'assistant', model,
109
+ content: [], stop_reason: null, stop_sequence: null, usage: { input_tokens: 1, output_tokens: 0 } } });
110
+ event('content_block_start', { index: 0, content_block: { type: 'text', text: '' } });
111
+ event('content_block_delta', { index: 0, delta: { type: 'text_delta', text } });
112
+ event('content_block_stop', { index: 0 });
113
+ event('message_delta', { delta: { stop_reason: 'end_turn', stop_sequence: null }, usage: { output_tokens: 1 } });
114
+ event('message_stop', {}); response.end();
115
+ }
116
+ try {
117
+ if (options.get('--claude-package')) {
118
+ const installed = fs.realpathSync(path.resolve(options.get('--claude-package')));
119
+ const pkg = JSON.parse(fs.readFileSync(path.join(installed, 'package.json'), 'utf8'));
120
+ assert.equal(pkg.name, '@anthropic-ai/claude-agent-sdk');
121
+ const scope = path.join(root, 'node_modules', '@anthropic-ai'); fs.mkdirSync(scope, { recursive: true, mode: 0o700 });
122
+ fs.symlinkSync(installed, path.join(scope, 'claude-agent-sdk'), 'dir');
123
+ }
124
+ const entry = createRequire(path.join(root, 'package.json')).resolve('@anthropic-ai/claude-agent-sdk');
125
+ report.sdk = { entrySha256: digest(entry), version: JSON.parse(fs.readFileSync(path.join(path.dirname(entry), 'package.json'), 'utf8')).version };
126
+ process.env.HOME = home; process.env.CLAUDE_CONFIG_DIR = providerHome;
127
+ if (live) {
128
+ if (protectedBefore) {
129
+ const source = JSON.parse(fs.readFileSync(settings, 'utf8'));
130
+ const env = Object.fromEntries(Object.entries(source.env || {}).filter(([key, value]) => key.startsWith('ANTHROPIC_') && typeof value === 'string'));
131
+ fs.writeFileSync(path.join(providerHome, 'settings.json'), JSON.stringify({ env,
132
+ ...(typeof source.model === 'string' ? { model: source.model } : {}) }), { mode: 0o600 });
133
+ }
134
+ } else {
135
+ server = http.createServer(async (request, response) => {
136
+ try {
137
+ assert.equal(request.method, 'POST'); assert.match(request.url, /^\/v1\/messages(?:\?|$)/);
138
+ const chunks = []; let size = 0;
139
+ for await (const chunk of request) { size += chunk.length; assert.ok(size <= 2 * 1024 * 1024); chunks.push(chunk); }
140
+ assert.ok(report.requests.length < 10, 'Fixture request bound exceeded');
141
+ const body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
142
+ const text = fixtureText(body.messages);
143
+ if (body.stream) stream(response, body.model, text);
144
+ else { response.writeHead(200, { 'content-type': 'application/json' }); response.end(JSON.stringify({ id: 'msg_' + randomUUID(), type: 'message', role: 'assistant', model: body.model,
145
+ content: [{ type: 'text', text }], stop_reason: 'end_turn', stop_sequence: null, usage: { input_tokens: 1, output_tokens: 1 } })); }
146
+ } catch { response.writeHead(500); response.end(JSON.stringify({ error: { type: 'api_error', message: 'Fixture protocol failure' } })); }
147
+ });
148
+ await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
149
+ fs.writeFileSync(path.join(providerHome, 'settings.json'), JSON.stringify({ model: 'claude-sonnet-4-5-20250929', env: {
150
+ ANTHROPIC_BASE_URL: 'http://127.0.0.1:' + server.address().port, ANTHROPIC_API_KEY: 'local-fixture-no-credential',
151
+ ANTHROPIC_AUTH_TOKEN: '', CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1' } }), { mode: 0o600 });
152
+ }
153
+ sdk = await import(pathToFileURL(entry).href);
154
+ assert.equal(typeof sdk.forkSession, 'function'); assert.equal(typeof sdk.getSessionMessages, 'function');
155
+ const base = 'HCC_BASE_' + randomUUID().replaceAll('-', '').slice(0, 16);
156
+ const initial = 'HCC_BRANCH_' + randomUUID().replaceAll('-', '').slice(0, 16);
157
+ const changed = 'HCC_BRANCH_' + randomUUID().replaceAll('-', '').slice(0, 16);
158
+ const recall = 'HCC_FORK_RECALL: Without tools or reading files, reply only with the full base|branch values established earlier, including the original HCC_BASE_ and HCC_BRANCH_ prefixes and their hexadecimal suffixes. Do not infer or invent missing values.';
159
+ const parent = adapter('parent'); await parent.open();
160
+ await turn(parent, 'HCC_FORK_SEED ' + base + '|' + initial + '. Remember both complete values in this conversation. Reply with exactly this complete string including both HCC_ prefixes: ' + base + '|' + initial + '. Do not use tools or read files.', base + '|' + initial);
161
+ const parentId = parent.snapshot().sessionId;
162
+ const child = adapter('child'); await child.open({ forkSessionId: parentId });
163
+ const childId = child.snapshot().sessionId; assert.ok(childId && childId !== parentId);
164
+ await assertCopied(parentId, childId, 'open-parent fork');
165
+ if (!live) assert.equal(report.requests.length, 1, 'Fork must not perform hidden inference');
166
+ await turn(child, recall, base + '|' + initial); check('first child turn recalls both copied values without prompt replay');
167
+ await turn(child, 'HCC_FORK_SET ' + changed + '. Replace only the branch value, keep the original full base value, and reply only with base|branch including the HCC_BASE_ and HCC_BRANCH_ prefixes and hexadecimal suffixes. No tools or file reads.', base + '|' + changed);
168
+ const parentBefore = await history(parentId, 'parent after child-only branch change');
169
+ await turn(parent, recall, base + '|' + initial); check('child branch update leaves parent model-visible values intact');
170
+ const childBeforeResume = await history(childId, 'child before close/resume');
171
+ assert.ok(childBeforeResume.messages > parentBefore.messages);
172
+ await child.close();
173
+ const resumed = adapter('resumed-child'); await resumed.open({ sessionId: childId });
174
+ await turn(resumed, recall, base + '|' + changed);
175
+ assert.equal(resumed.snapshot().sessionId, childId); check('child close/resume retains its own identity and changed branch');
176
+ await parent.close();
177
+ const closedFork = adapter('closed-parent-child'); await closedFork.open({ forkSessionId: parentId });
178
+ const closedForkId = closedFork.snapshot().sessionId;
179
+ assert.ok(closedForkId && closedForkId !== childId && closedForkId !== parentId);
180
+ await assertCopied(parentId, closedForkId, 'closed-parent fork');
181
+ await turn(closedFork, recall, base + '|' + initial); check('closed-parent fork recalls the original branch with a distinct identity');
182
+ report.completed = true;
183
+ } catch (error) {
184
+ report.error = { code: error.code || 'ACCEPTANCE_FAILED', message: redactSecrets(error.message).slice(0, 1500) };
185
+ if (sdk?.getSessionMessages) for (const { name, value } of adapters) {
186
+ const sessionId = value.snapshot().sessionId;
187
+ if (sessionId) try { await history(sessionId, 'failure: ' + name); } catch { /* Preserve earlier snapshots. */ }
188
+ }
189
+ } finally {
190
+ const closed = await Promise.allSettled(adapters.map(({ value }) => value.close()));
191
+ report.cleanup.adaptersClosed = closed.every(value => value.status === 'fulfilled');
192
+ if (server) { server.closeAllConnections(); await new Promise(resolve => server.close(resolve)); }
193
+ for (const [key, value] of Object.entries(originalEnv)) { if (value === undefined) delete process.env[key]; else process.env[key] = value; }
194
+ report.protectedSettingsUnchanged = protectedBefore === null ? !fs.existsSync(settings) : fs.existsSync(settings) && digest(settings) === protectedBefore;
195
+ report.sourceChanges = Object.entries(sourceFiles).filter(([name, before]) => digest(path.join(repo, name)) !== before).map(([name]) => name);
196
+ try { fs.rmSync(sandbox, { recursive: true, force: true }); report.cleanup.privateProjectAndHomeRemoved = !fs.existsSync(sandbox); }
197
+ catch { report.cleanup.privateProjectAndHomeRemoved = false; }
198
+ report.success = report.completed === true && !report.error && report.cleanup.adaptersClosed && report.cleanup.privateProjectAndHomeRemoved && report.protectedSettingsUnchanged && !report.sourceChanges.length;
199
+ report.finishedAt = new Date().toISOString(); flush();
200
+ }
201
+ console.log(JSON.stringify({ output, success: report.success, realModelCalls: report.realModelCalls, checks: report.checks.length, error: report.error, cleanup: report.cleanup }));
202
+ process.exitCode = report.success ? 0 : 1;