@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.
- package/CHANGELOG.md +50 -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
|
@@ -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');
|
package/package.json
CHANGED
|
@@ -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;
|