@logicseek/hello-cc 1.0.5 → 1.1.0-rc.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +72 -0
- package/docs/codex-account.zh-CN.md +38 -0
- package/docs/codex-protocol.md +59 -0
- package/docs/commands.md +9 -0
- package/docs/commands.zh-CN.md +7 -0
- package/docs/macos-process-identity.md +2 -0
- package/docs/native.md +179 -14
- package/docs/native.zh-CN.md +136 -6
- package/docs/web-handoff.zh-CN.md +118 -8
- package/lib/cli/app.mjs +47 -4
- package/lib/cli/commands/coordination.mjs +14 -4
- package/lib/cli/commands/dsh.mjs +1 -1
- package/lib/cli/commands/gc.mjs +22 -15
- package/lib/cli/commands/hook.mjs +19 -3
- package/lib/cli/commands/install.mjs +80 -10
- package/lib/cli/commands/misc.mjs +36 -9
- package/lib/cli/commands/native.mjs +4 -38
- package/lib/cli/commands/peer.mjs +3 -0
- package/lib/cli/commands/run.mjs +13 -6
- package/lib/cli/commands/tmux.mjs +1 -2
- package/lib/cli-runtime.mjs +19 -2
- package/lib/core/coordination/results.mjs +27 -10
- package/lib/core/sessions/hook-root-identity.mjs +47 -0
- package/lib/core/sessions/launch.mjs +7 -1
- package/lib/db/connection.mjs +140 -42
- package/lib/db/schema.mjs +59 -0
- package/lib/diagnostics/codex-protocol.mjs +105 -0
- package/lib/guidance.mjs +21 -36
- package/lib/integrations/codex-account.mjs +143 -0
- package/lib/integrations/codex-interactions.mjs +13 -11
- package/lib/integrations/dsh-collaboration.mjs +6 -12
- package/lib/integrations/dsh-cordis.mjs +17 -13
- package/lib/integrations/dsh.mjs +16 -11
- package/lib/integrations/mcp-elicitation.mjs +116 -0
- package/lib/integrations/mcp-url-elicitation.mjs +25 -0
- package/lib/integrations/native/claude.mjs +24 -6
- package/lib/integrations/native/codex.mjs +38 -7
- package/lib/integrations/native/dsh-acp.mjs +67 -24
- package/lib/integrations/native/index.mjs +4 -1
- package/lib/integrations/native/interactions.mjs +2 -1
- package/lib/integrations/native/jsonrpc.mjs +37 -5
- package/lib/integrations/native/telemetry.mjs +30 -0
- package/lib/process/identity.mjs +4 -0
- package/lib/process/pinned-cwd.mjs +302 -0
- package/lib/process/pinned-guidance.mjs +138 -0
- package/lib/process/pinned-project-state.mjs +105 -0
- package/lib/process/selected-cwd-identity.mjs +95 -0
- package/lib/runtime/buffer-evidence.mjs +2 -1
- package/lib/runtime/client.mjs +9 -0
- package/lib/runtime/native/client.mjs +4 -1
- package/lib/runtime/native/history.mjs +140 -0
- package/lib/runtime/native/launcher.mjs +106 -0
- package/lib/runtime/native/service.mjs +64 -8
- package/lib/runtime/native/store.mjs +83 -58
- package/lib/runtime/paths.mjs +17 -2
- package/lib/runtime/private-file.mjs +76 -0
- package/lib/runtime/private-state.mjs +546 -0
- package/lib/runtime/project-context.mjs +2 -1
- package/lib/runtime/project-path.mjs +92 -20
- package/lib/runtime/project-trust.mjs +79 -0
- package/lib/runtime/projects.mjs +9 -3
- package/lib/runtime/state-migration.mjs +697 -0
- package/lib/runtime/state.mjs +69 -27
- package/lib/shared/automatic-result-key.mjs +6 -0
- package/lib/shared/json-file.mjs +55 -6
- package/lib/shared/redact.mjs +2 -1
- package/lib/ui/help.mjs +6 -4
- package/lib/web/agent-defaults.mjs +110 -0
- package/lib/web/auto-attach.mjs +3 -2
- package/lib/web/browser/contracts.d.ts +10 -0
- package/lib/web/browser/core.mjs +1997 -0
- package/lib/web/browser/project-data.mjs +158 -0
- package/lib/web/browser/session-sync.mjs +82 -0
- package/lib/web/browser-assets.mjs +19 -0
- package/lib/web/buffer-gc-runtime.mjs +2 -2
- package/lib/web/codex-app-server.mjs +111 -37
- package/lib/web/codex-sessions.mjs +61 -12
- package/lib/web/context-files.mjs +82 -0
- package/lib/web/cookie-auth.mjs +51 -3
- package/lib/web/external-sessions.mjs +76 -9
- package/lib/web/http-routes.mjs +238 -25
- package/lib/web/http.mjs +50 -4
- package/lib/web/liveness-reaper.mjs +2 -2
- package/lib/web/native-sessions.mjs +323 -14
- package/lib/web/project-contexts.mjs +236 -44
- package/lib/web/project-file-write.mjs +249 -0
- package/lib/web/project-files.mjs +268 -0
- package/lib/web/pty-sessions.mjs +11 -3
- package/lib/web/runtime-main.mjs +132 -80
- package/lib/web/session-serialize.mjs +87 -8
- package/lib/web/session-state-sync.mjs +98 -0
- package/lib/web/shutdown-diagnostics.mjs +38 -0
- package/lib/web/shutdown-response.mjs +21 -0
- package/lib/web/shutdown-state.mjs +91 -0
- package/lib/web/startup.mjs +103 -35
- package/lib/web/task-results.mjs +19 -19
- package/lib/web/tls.mjs +84 -33
- package/lib/web/tmux-sessions.mjs +73 -9
- package/lib/web/tmux-stream.mjs +36 -8
- package/lib/web/ui-agent-defaults.mjs +149 -0
- package/lib/web/ui-agent-start.mjs +330 -0
- package/lib/web/ui-codex-account.mjs +81 -0
- package/lib/web/ui-codex.mjs +105 -85
- package/lib/web/ui-command-palette.mjs +21 -5
- package/lib/web/ui-file-writes.mjs +220 -0
- package/lib/web/ui-files.mjs +231 -0
- package/lib/web/ui-handoff.mjs +10 -2
- package/lib/web/ui-history.mjs +175 -105
- package/lib/web/ui-i18n.mjs +280 -8
- package/lib/web/ui-interactions.mjs +107 -6
- package/lib/web/ui-native-timeline.mjs +326 -0
- package/lib/web/ui-native.mjs +93 -43
- package/lib/web/ui-review.mjs +5 -3
- package/lib/web/ui-safe-markdown.mjs +79 -0
- package/lib/web/ui-session-tools.mjs +224 -0
- package/lib/web/ui-template.mjs +29 -1968
- package/lib/web/ui-terminal-find.mjs +5 -3
- package/lib/web/ui-workbench.mjs +5 -3
- package/lib/web/ui-workspace.mjs +3 -0
- package/package.json +8 -2
- package/scripts/benchmark-session-sync.mjs +53 -0
- package/scripts/codex-protocol.mjs +88 -0
- package/scripts/dsh-approval-layers-acceptance.mjs +172 -0
- package/scripts/dsh-package-provenance.mjs +118 -0
- package/scripts/mcp-form-live-fixture.mjs +81 -0
- package/scripts/native-bounded-live-acceptance.mjs +193 -0
- package/scripts/native-fixture-root.mjs +49 -0
- package/scripts/native-installed-acceptance.mjs +611 -0
- package/scripts/native-stability-checks.mjs +40 -0
- package/scripts/regression-cookie-expiry.mjs +70 -0
- package/scripts/regression-http-transport.mjs +9 -0
- package/scripts/regression-terminal-marker.mjs +95 -0
- package/scripts/regression.mjs +481 -219
- package/scripts/shim-reentry-probe.mjs +105 -0
- package/scripts/web-acceptance-diagnostics.mjs +90 -0
- package/scripts/web-codex-account-acceptance.mjs +178 -0
- package/scripts/web-file-preview-checks.mjs +302 -0
- package/scripts/web-mcp-form-acceptance.mjs +134 -0
- package/scripts/web-mcp-form-live-acceptance.mjs +368 -0
- package/scripts/web-native-handoff-checks.mjs +128 -0
- package/scripts/web-native-interaction-acceptance.mjs +219 -18
- package/scripts/web-native-interaction-diagnostics.mjs +80 -0
- package/scripts/web-native-model-acceptance.mjs +22 -7
- package/scripts/web-session-tools-checks.mjs +112 -0
- package/scripts/web-workbench-acceptance.mjs +435 -0
- package/scripts/web-workbench-installed-acceptance.mjs +122 -0
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import process from 'node:process';
|
|
4
|
+
import { randomBytes } from 'node:crypto';
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
6
|
+
import { CliError } from '../shared/errors.mjs';
|
|
7
|
+
import { ensurePrivateGlobalSubdirectory } from '../runtime/private-state.mjs';
|
|
8
|
+
import { stableProjectStateRoot } from '../runtime/project-trust.mjs';
|
|
9
|
+
|
|
10
|
+
// This function is serialized into node -e. Keep it self-contained: loading a
|
|
11
|
+
// script from the selected project before checking its identity would recreate
|
|
12
|
+
// the directory-rebind race that this bootstrap is intended to narrow.
|
|
13
|
+
function pinnedBootstrap() {
|
|
14
|
+
const fs = require('node:fs');
|
|
15
|
+
const path = require('node:path');
|
|
16
|
+
const config = JSON.parse(process.argv[1]);
|
|
17
|
+
const identity = config.identity;
|
|
18
|
+
const changed = (stage) => {
|
|
19
|
+
process.stderr.write('HCC_PINNED_CWD_CHANGED:' + stage + '\n');
|
|
20
|
+
process.exit(stage === 'entry' ? 41 : 42);
|
|
21
|
+
};
|
|
22
|
+
const matches = (stat) => stat.isDirectory() &&
|
|
23
|
+
stat.dev.toString() === identity.dev &&
|
|
24
|
+
stat.ino.toString() === identity.ino &&
|
|
25
|
+
(identity.birthtimeNs === null ||
|
|
26
|
+
stat.birthtimeNs?.toString() === identity.birthtimeNs);
|
|
27
|
+
|
|
28
|
+
let fd;
|
|
29
|
+
let opened;
|
|
30
|
+
try {
|
|
31
|
+
fd = fs.openSync(identity.cwd,
|
|
32
|
+
fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW);
|
|
33
|
+
opened = fs.fstatSync(fd, { bigint: true });
|
|
34
|
+
} catch (error) {
|
|
35
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
36
|
+
if (identity.mode !== 'stat-only' || error?.code !== 'EACCES') changed('entry');
|
|
37
|
+
fd = undefined;
|
|
38
|
+
try { opened = fs.statSync(identity.cwd, { bigint: true }); }
|
|
39
|
+
catch { changed('entry'); }
|
|
40
|
+
}
|
|
41
|
+
if (!matches(opened)) changed('entry');
|
|
42
|
+
/* PINNED_TEST_AFTER_OPEN */
|
|
43
|
+
try { process.chdir(identity.cwd); }
|
|
44
|
+
catch { changed('chdir'); }
|
|
45
|
+
let current;
|
|
46
|
+
try { current = fs.statSync('.', { bigint: true }); }
|
|
47
|
+
catch { changed('chdir'); }
|
|
48
|
+
if (!matches(current) ||
|
|
49
|
+
(fd !== undefined &&
|
|
50
|
+
(current.dev !== opened.dev || current.ino !== opened.ino))) changed('chdir');
|
|
51
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
52
|
+
if (config.ack) {
|
|
53
|
+
let ackFd;
|
|
54
|
+
try {
|
|
55
|
+
ackFd = fs.openSync(config.ack.path,
|
|
56
|
+
fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY | fs.constants.O_NOFOLLOW,
|
|
57
|
+
0o600);
|
|
58
|
+
const bytes = Buffer.from(config.ack.nonce);
|
|
59
|
+
let offset = 0;
|
|
60
|
+
while (offset < bytes.length) offset += fs.writeSync(ackFd, bytes, offset, bytes.length - offset);
|
|
61
|
+
fs.closeSync(ackFd);
|
|
62
|
+
ackFd = undefined;
|
|
63
|
+
} catch (error) {
|
|
64
|
+
if (ackFd !== undefined) fs.closeSync(ackFd);
|
|
65
|
+
process.stderr.write('HCC_PINNED_ACK_FAILED:' + (error?.code || 'unknown') + '\n');
|
|
66
|
+
process.exit(43);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/* PINNED_TEST_BEFORE_EXEC */
|
|
70
|
+
|
|
71
|
+
const env = { ...process.env };
|
|
72
|
+
if (config.nodeOptions !== null) env.NODE_OPTIONS = config.nodeOptions;
|
|
73
|
+
try { env.PWD = process.cwd(); } catch { changed('chdir'); }
|
|
74
|
+
const command = config.command;
|
|
75
|
+
const fromPinnedCwd = (candidate) => {
|
|
76
|
+
if (!path.isAbsolute(candidate)) return candidate;
|
|
77
|
+
for (const root of [identity.cwd, identity.requestedCwd]) {
|
|
78
|
+
const relative = path.relative(root, candidate);
|
|
79
|
+
if (relative === '') return '.';
|
|
80
|
+
if (relative !== '..' && !relative.startsWith('..' + path.sep) && !path.isAbsolute(relative)) {
|
|
81
|
+
return '.' + path.sep + relative;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return candidate;
|
|
85
|
+
};
|
|
86
|
+
let executable;
|
|
87
|
+
if (command.includes('/')) {
|
|
88
|
+
executable = fromPinnedCwd(command);
|
|
89
|
+
} else {
|
|
90
|
+
const search = env.PATH === undefined ? '/usr/bin:/bin' : env.PATH;
|
|
91
|
+
for (const entry of search.split(path.delimiter)) {
|
|
92
|
+
const candidate = fromPinnedCwd(path.join(entry || '.', command));
|
|
93
|
+
try {
|
|
94
|
+
fs.accessSync(candidate, fs.constants.X_OK);
|
|
95
|
+
if (fs.statSync(candidate).isFile()) { executable = candidate; break; }
|
|
96
|
+
} catch {}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (!executable || typeof process.execve !== 'function') {
|
|
100
|
+
process.stderr.write('HCC_PINNED_EXEC_FAILED:unavailable\n');
|
|
101
|
+
process.exit(127);
|
|
102
|
+
}
|
|
103
|
+
let execArgs = [command, ...config.args];
|
|
104
|
+
// Node 24's experimental execve aborts the process (rather than throwing)
|
|
105
|
+
// for ENOEXEC. Handle plain executable text without a shebang before execve:
|
|
106
|
+
// Linux ordinary spawn runs it via /bin/sh, whereas macOS rejects it.
|
|
107
|
+
try {
|
|
108
|
+
const probeFd = fs.openSync(executable, fs.constants.O_RDONLY);
|
|
109
|
+
try {
|
|
110
|
+
const prefix = Buffer.alloc(128);
|
|
111
|
+
const length = fs.readSync(probeFd, prefix, 0, prefix.length, 0);
|
|
112
|
+
const plainText = length > 0 && prefix.subarray(0, length).every(
|
|
113
|
+
byte => (byte >= 0x20 && byte <= 0x7e) || byte === 0x09 || byte === 0x0a || byte === 0x0d);
|
|
114
|
+
if (plainText && !(prefix[0] === 0x23 && prefix[1] === 0x21)) {
|
|
115
|
+
if (process.platform === 'linux') {
|
|
116
|
+
// Linux's ordinary Node spawn uses the POSIX shell fallback for an
|
|
117
|
+
// executable text file without a shebang. Preserve that behavior
|
|
118
|
+
// without asking Node's aborting execve to handle ENOEXEC.
|
|
119
|
+
try { fs.accessSync(executable, fs.constants.X_OK); }
|
|
120
|
+
catch {
|
|
121
|
+
process.stderr.write('HCC_PINNED_EXEC_FAILED:EACCES\n');
|
|
122
|
+
process.exit(127);
|
|
123
|
+
}
|
|
124
|
+
execArgs = ['sh', executable, ...config.args];
|
|
125
|
+
executable = '/bin/sh';
|
|
126
|
+
} else {
|
|
127
|
+
process.stderr.write('HCC_PINNED_EXEC_FAILED:ENOEXEC\n');
|
|
128
|
+
process.exit(127);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
} finally { fs.closeSync(probeFd); }
|
|
132
|
+
} catch (error) {
|
|
133
|
+
if (error?.code !== 'EACCES') {
|
|
134
|
+
process.stderr.write('HCC_PINNED_EXEC_FAILED:' + (error?.code || 'unknown') + '\n');
|
|
135
|
+
process.exit(127);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
try { process.execve(executable, execArgs, env); }
|
|
139
|
+
catch (error) {
|
|
140
|
+
process.stderr.write('HCC_PINNED_EXEC_FAILED:' + (error?.code || 'unknown') + '\n');
|
|
141
|
+
process.exit(127);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export const PINNED_CWD_BOOTSTRAP_SOURCE = '(' + pinnedBootstrap.toString() + ')()';
|
|
146
|
+
|
|
147
|
+
function sameIdentity(left, right) {
|
|
148
|
+
return left.dev === right.dev && left.ino === right.ino &&
|
|
149
|
+
left.birthtimeNs === right.birthtimeNs;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function directoryIdentity(stat, cwd, requestedCwd, mode) {
|
|
153
|
+
return {
|
|
154
|
+
cwd,
|
|
155
|
+
requestedCwd,
|
|
156
|
+
dev: stat.dev.toString(),
|
|
157
|
+
ino: stat.ino.toString(),
|
|
158
|
+
birthtimeNs: stat.birthtimeNs?.toString() || null,
|
|
159
|
+
mode
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function bootstrapDirectory(override) {
|
|
164
|
+
if (override !== undefined) {
|
|
165
|
+
const trusted = stableProjectStateRoot(override);
|
|
166
|
+
if (!trusted) throw new CliError('PROJECT_PATH_FORBIDDEN', 'Bootstrap cwd must have stable trusted ancestry');
|
|
167
|
+
return trusted;
|
|
168
|
+
}
|
|
169
|
+
return ensurePrivateGlobalSubdirectory('launches');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function acknowledgementState(ack) {
|
|
173
|
+
if (!ack) return null;
|
|
174
|
+
let fd;
|
|
175
|
+
try {
|
|
176
|
+
fd = fs.openSync(ack.path, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
|
177
|
+
const stat = fs.fstatSync(fd);
|
|
178
|
+
if (!stat.isFile() || (stat.mode & 0o077) !== 0 || stat.size !== ack.nonce.length) return null;
|
|
179
|
+
if (fs.readFileSync(fd, 'utf8') !== ack.nonce) return null;
|
|
180
|
+
return { dev: stat.dev, ino: stat.ino };
|
|
181
|
+
} catch {
|
|
182
|
+
return null;
|
|
183
|
+
} finally {
|
|
184
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function removeOwnedAcknowledgement(ack) {
|
|
189
|
+
const owned = acknowledgementState(ack);
|
|
190
|
+
if (!owned) return;
|
|
191
|
+
try {
|
|
192
|
+
const current = fs.lstatSync(ack.path);
|
|
193
|
+
if (current.dev === owned.dev && current.ino === owned.ino) fs.unlinkSync(ack.path);
|
|
194
|
+
} catch {}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// The returned binding deliberately retains its directory FD. Call release()
|
|
198
|
+
// only when the child has established its cwd or exited; retaining it while a
|
|
199
|
+
// child starts prevents inode reuse from satisfying a stale dev/ino snapshot.
|
|
200
|
+
export function preparePinnedCwdLaunch(cwd, command, args = [], options = {}) {
|
|
201
|
+
if (process.platform === 'win32') {
|
|
202
|
+
throw new CliError('PINNED_CWD_UNAVAILABLE', 'Pinned cwd launch requires POSIX directory identity');
|
|
203
|
+
}
|
|
204
|
+
if (typeof command !== 'string' || !command || !Array.isArray(args) ||
|
|
205
|
+
args.some(arg => typeof arg !== 'string')) {
|
|
206
|
+
throw new CliError('BAD_ARGS', 'Pinned launch requires a command and string arguments');
|
|
207
|
+
}
|
|
208
|
+
const requested = path.resolve(cwd);
|
|
209
|
+
let canonical;
|
|
210
|
+
try { canonical = fs.realpathSync.native(requested); }
|
|
211
|
+
catch { throw new CliError('PROJECT_PATH_FORBIDDEN', 'Selected working directory is unavailable'); }
|
|
212
|
+
|
|
213
|
+
let fd;
|
|
214
|
+
let stat;
|
|
215
|
+
let mode = 'descriptor';
|
|
216
|
+
try {
|
|
217
|
+
fd = fs.openSync(canonical,
|
|
218
|
+
fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW);
|
|
219
|
+
stat = fs.fstatSync(fd, { bigint: true });
|
|
220
|
+
} catch (error) {
|
|
221
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
222
|
+
fd = undefined;
|
|
223
|
+
if (error?.code !== 'EACCES') {
|
|
224
|
+
throw new CliError('PROJECT_PATH_FORBIDDEN', 'Selected working directory cannot be pinned');
|
|
225
|
+
}
|
|
226
|
+
mode = 'stat-only';
|
|
227
|
+
try { stat = fs.statSync(canonical, { bigint: true }); }
|
|
228
|
+
catch { throw new CliError('PROJECT_PATH_FORBIDDEN', 'Selected working directory is unavailable'); }
|
|
229
|
+
}
|
|
230
|
+
try {
|
|
231
|
+
const current = fs.statSync(requested, { bigint: true });
|
|
232
|
+
if (!stat.isDirectory() || !sameIdentity(stat, current)) {
|
|
233
|
+
throw new CliError('PROJECT_PATH_FORBIDDEN', 'Selected working directory changed during capture');
|
|
234
|
+
}
|
|
235
|
+
const env = { ...(options.env || process.env) };
|
|
236
|
+
const nodeOptions = Object.hasOwn(env, 'NODE_OPTIONS') ? String(env.NODE_OPTIONS) : null;
|
|
237
|
+
delete env.NODE_OPTIONS;
|
|
238
|
+
const identity = directoryIdentity(stat, canonical, requested, mode);
|
|
239
|
+
if (options.expectedIdentity &&
|
|
240
|
+
(identity.cwd !== options.expectedIdentity.canonical ||
|
|
241
|
+
identity.dev !== options.expectedIdentity.identity?.dev ||
|
|
242
|
+
identity.ino !== options.expectedIdentity.identity?.ino ||
|
|
243
|
+
identity.birthtimeNs !== options.expectedIdentity.identity?.birthtimeNs)) {
|
|
244
|
+
throw new CliError('PROJECT_PATH_CHANGED', 'Selected working directory changed before launch');
|
|
245
|
+
}
|
|
246
|
+
const trustedCwd = bootstrapDirectory(options.bootstrapCwd);
|
|
247
|
+
const ack = options.acknowledge === true ? {
|
|
248
|
+
path: path.join(trustedCwd, 'ack-' + randomBytes(16).toString('hex')),
|
|
249
|
+
nonce: randomBytes(32).toString('hex')
|
|
250
|
+
} : null;
|
|
251
|
+
const config = { identity, command, args, nodeOptions, ack };
|
|
252
|
+
return {
|
|
253
|
+
command: process.execPath,
|
|
254
|
+
args: ['-e', PINNED_CWD_BOOTSTRAP_SOURCE, JSON.stringify(config)],
|
|
255
|
+
cwd: trustedCwd,
|
|
256
|
+
env,
|
|
257
|
+
identity,
|
|
258
|
+
ackPath: ack?.path || null,
|
|
259
|
+
acknowledged() { return acknowledgementState(ack) !== null; },
|
|
260
|
+
release() {
|
|
261
|
+
if (fd !== undefined) { fs.closeSync(fd); fd = undefined; }
|
|
262
|
+
removeOwnedAcknowledgement(ack);
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
} catch (error) {
|
|
266
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
267
|
+
throw error;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export function spawnPinnedCwdProcess(command, args, options = {}, spawnProcess = spawn) {
|
|
272
|
+
const binding = preparePinnedCwdLaunch(options.cwd, command, args,
|
|
273
|
+
{ env: options.env, expectedIdentity: options.expectedIdentity });
|
|
274
|
+
const { cwd, env, expectedIdentity, ...rest } = options;
|
|
275
|
+
try {
|
|
276
|
+
const child = spawnProcess(binding.command, binding.args, {
|
|
277
|
+
...rest, cwd: binding.cwd, env: binding.env
|
|
278
|
+
});
|
|
279
|
+
child.once('exit', binding.release);
|
|
280
|
+
child.once('error', binding.release);
|
|
281
|
+
return child;
|
|
282
|
+
} catch (error) {
|
|
283
|
+
binding.release();
|
|
284
|
+
throw error;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export function spawnPinnedCwdPty(pty, command, args, options = {}) {
|
|
289
|
+
const binding = preparePinnedCwdLaunch(options.cwd, command, args,
|
|
290
|
+
{ env: options.env, expectedIdentity: options.expectedIdentity });
|
|
291
|
+
const { cwd, env, expectedIdentity, ...rest } = options;
|
|
292
|
+
try {
|
|
293
|
+
const child = pty.spawn(binding.command, binding.args, {
|
|
294
|
+
...rest, cwd: binding.cwd, env: binding.env
|
|
295
|
+
});
|
|
296
|
+
child.onExit(() => binding.release());
|
|
297
|
+
return child;
|
|
298
|
+
} catch (error) {
|
|
299
|
+
binding.release();
|
|
300
|
+
throw error;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import process from 'node:process';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { CliError } from '../shared/errors.mjs';
|
|
4
|
+
import { preparePinnedCwdLaunch } from './pinned-cwd.mjs';
|
|
5
|
+
import { assertSelectedCwdSnapshot } from './selected-cwd-identity.mjs';
|
|
6
|
+
|
|
7
|
+
// This worker only uses relative names after the bootstrap has entered the
|
|
8
|
+
// selected directory inode. It cannot follow a rebound absolute project path.
|
|
9
|
+
function guidanceWorker() {
|
|
10
|
+
const fs = require('node:fs');
|
|
11
|
+
const files = ['CLAUDE.md', 'AGENTS.md'];
|
|
12
|
+
const mode = process.argv[1];
|
|
13
|
+
const content = process.argv[2] || '';
|
|
14
|
+
const clause = '\n<!-- hello-cc:start -->\n\n' + content + '\n<!-- hello-cc:end -->\n';
|
|
15
|
+
const changed = [];
|
|
16
|
+
const opened = [];
|
|
17
|
+
|
|
18
|
+
function readTarget(name) {
|
|
19
|
+
let fd;
|
|
20
|
+
try { fd = fs.openSync(name, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW); }
|
|
21
|
+
catch (error) {
|
|
22
|
+
if (error?.code === 'ENOENT') return { name, fd: null, text: null };
|
|
23
|
+
throw error;
|
|
24
|
+
}
|
|
25
|
+
try {
|
|
26
|
+
const stat = fs.fstatSync(fd);
|
|
27
|
+
if (!stat.isFile() || stat.nlink !== 1) throw new Error('GUIDANCE_FILE_UNSAFE');
|
|
28
|
+
const text = fs.readFileSync(fd, 'utf8');
|
|
29
|
+
return { name, fd, text, dev: stat.dev, ino: stat.ino };
|
|
30
|
+
} catch (error) {
|
|
31
|
+
fs.closeSync(fd);
|
|
32
|
+
throw error;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function writeTarget(target, text) {
|
|
37
|
+
let fd = null;
|
|
38
|
+
try {
|
|
39
|
+
if (target.fd === null) {
|
|
40
|
+
fd = fs.openSync(target.name,
|
|
41
|
+
fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL |
|
|
42
|
+
fs.constants.O_NOFOLLOW, 0o644);
|
|
43
|
+
} else {
|
|
44
|
+
fd = fs.openSync(target.name, fs.constants.O_RDWR | fs.constants.O_NOFOLLOW);
|
|
45
|
+
const stat = fs.fstatSync(fd);
|
|
46
|
+
if (!stat.isFile() || stat.nlink !== 1 || stat.dev !== target.dev || stat.ino !== target.ino) {
|
|
47
|
+
throw new Error('GUIDANCE_FILE_CHANGED');
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const bytes = Buffer.from(text);
|
|
51
|
+
let offset = 0;
|
|
52
|
+
while (offset < bytes.length) {
|
|
53
|
+
offset += fs.writeSync(fd, bytes, offset, bytes.length - offset, offset);
|
|
54
|
+
}
|
|
55
|
+
fs.ftruncateSync(fd, bytes.length);
|
|
56
|
+
fs.fsyncSync(fd);
|
|
57
|
+
} finally {
|
|
58
|
+
if (fd !== null) fs.closeSync(fd);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
// Inspect both targets before the first write, so a pre-existing symlink
|
|
64
|
+
// or hardlink cannot lead to a partial ordinary guidance installation.
|
|
65
|
+
for (const name of files) opened.push(readTarget(name));
|
|
66
|
+
for (const target of opened) {
|
|
67
|
+
if (mode === 'write') {
|
|
68
|
+
const updated = target.text === null ? clause.trimStart() :
|
|
69
|
+
target.text.includes('<!-- hello-cc:start -->')
|
|
70
|
+
? target.text.replace(/<!-- hello-cc:start -->[\s\S]*?<!-- hello-cc:end -->/, clause.trim())
|
|
71
|
+
: target.text.trimEnd() + '\n' + clause;
|
|
72
|
+
if (updated !== target.text) { writeTarget(target, updated); changed.push(target.name); }
|
|
73
|
+
} else if (mode === 'remove') {
|
|
74
|
+
if (target.text === null) continue;
|
|
75
|
+
const updated = target.text
|
|
76
|
+
.replace(/\n?<!-- hello-cc:start -->[\s\S]*?<!-- hello-cc:end -->\n?/g, '\n')
|
|
77
|
+
.replace(/\n{3,}/g, '\n\n').trimEnd();
|
|
78
|
+
const result = updated ? updated + '\n' : '';
|
|
79
|
+
if (result !== target.text) { writeTarget(target, result); changed.push(target.name); }
|
|
80
|
+
} else throw new Error('GUIDANCE_MODE_INVALID');
|
|
81
|
+
}
|
|
82
|
+
process.stdout.write(JSON.stringify(changed));
|
|
83
|
+
} catch (error) {
|
|
84
|
+
process.stderr.write('HCC_GUIDANCE_FAILED:' + (error?.code || error?.message || 'unknown') + '\n');
|
|
85
|
+
process.exitCode = 1;
|
|
86
|
+
} finally {
|
|
87
|
+
for (const target of opened) if (target.fd !== null) fs.closeSync(target.fd);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const GUIDANCE_WORKER_SOURCE = '(' + guidanceWorker.toString() + ')()';
|
|
92
|
+
|
|
93
|
+
function runPinnedGuidance(root, mode, content = '', {
|
|
94
|
+
spawnProcess = spawnSync, expectedBinding = null, expectedSnapshot = null
|
|
95
|
+
} = {}) {
|
|
96
|
+
expectedBinding?.assertUnchanged();
|
|
97
|
+
if (expectedSnapshot) assertSelectedCwdSnapshot(expectedSnapshot);
|
|
98
|
+
const binding = preparePinnedCwdLaunch(root, process.execPath,
|
|
99
|
+
['-e', GUIDANCE_WORKER_SOURCE, mode, content], {
|
|
100
|
+
// The worker needs only built-ins. Do not permit inherited Node preload
|
|
101
|
+
// hooks to run before it can operate on relative names.
|
|
102
|
+
env: { PATH: '/usr/bin:/bin', NODE_OPTIONS: '', NODE_NO_WARNINGS: '1' }
|
|
103
|
+
});
|
|
104
|
+
try {
|
|
105
|
+
const expected = expectedBinding || expectedSnapshot;
|
|
106
|
+
if (expected && (binding.identity.cwd !== expected.canonical ||
|
|
107
|
+
binding.identity.dev !== expected.identity.dev ||
|
|
108
|
+
binding.identity.ino !== expected.identity.ino ||
|
|
109
|
+
binding.identity.birthtimeNs !== expected.identity.birthtimeNs)) {
|
|
110
|
+
throw new CliError('PROJECT_PATH_CHANGED', 'Selected working directory changed before guidance update');
|
|
111
|
+
}
|
|
112
|
+
const result = spawnProcess(binding.command, binding.args, {
|
|
113
|
+
cwd: binding.cwd, env: binding.env, encoding: 'utf8',
|
|
114
|
+
stdio: ['ignore', 'pipe', 'pipe'], timeout: 10000, maxBuffer: 64 * 1024
|
|
115
|
+
});
|
|
116
|
+
if (result.error || result.status !== 0) {
|
|
117
|
+
const marker = String(result.stderr || '');
|
|
118
|
+
if (/HCC_PINNED_CWD_CHANGED:/.test(marker)) {
|
|
119
|
+
throw new CliError('PROJECT_PATH_CHANGED', 'Selected working directory changed before guidance update');
|
|
120
|
+
}
|
|
121
|
+
throw new CliError('GUIDANCE_WRITE_FAILED',
|
|
122
|
+
`Cannot ${mode} project guidance: ${result.error?.code || marker.trim() || `exit ${result.status}`}`);
|
|
123
|
+
}
|
|
124
|
+
const changed = JSON.parse(result.stdout || '[]');
|
|
125
|
+
if (!Array.isArray(changed) || changed.some(name => !['CLAUDE.md', 'AGENTS.md'].includes(name))) {
|
|
126
|
+
throw new CliError('GUIDANCE_WRITE_FAILED', 'Guidance worker returned an invalid receipt');
|
|
127
|
+
}
|
|
128
|
+
return changed.map(name => `${binding.identity.cwd}/${name}`);
|
|
129
|
+
} finally { binding.release(); }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function writeProjectGuidanceFiles(root, content, options = {}) {
|
|
133
|
+
return runPinnedGuidance(root, 'write', content, options);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function removeProjectGuidanceFiles(root, options = {}) {
|
|
137
|
+
return runPinnedGuidance(root, 'remove', '', options);
|
|
138
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import process from 'node:process';
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import { CliError } from '../shared/errors.mjs';
|
|
5
|
+
import { preparePinnedCwdLaunch } from './pinned-cwd.mjs';
|
|
6
|
+
|
|
7
|
+
// The bootstrap enters the checked directory inode before this worker runs.
|
|
8
|
+
// All destructive names below are relative to that cwd, never to a project
|
|
9
|
+
// pathname that could have been rebound after validation.
|
|
10
|
+
function projectStateWorker() {
|
|
11
|
+
const fs = require('node:fs');
|
|
12
|
+
const mode = process.argv[1];
|
|
13
|
+
const expected = JSON.parse(process.argv[2]);
|
|
14
|
+
const content = process.argv[3] || '';
|
|
15
|
+
const sameDirectory = (stat) => stat.isDirectory() &&
|
|
16
|
+
stat.dev.toString() === expected.dev && stat.ino.toString() === expected.ino &&
|
|
17
|
+
(expected.birthtimeNs === null ||
|
|
18
|
+
stat.birthtimeNs?.toString() === expected.birthtimeNs);
|
|
19
|
+
let fd;
|
|
20
|
+
let temporary;
|
|
21
|
+
try {
|
|
22
|
+
if (mode === 'write-guidance') {
|
|
23
|
+
if (!sameDirectory(fs.statSync('.', { bigint: true }))) {
|
|
24
|
+
throw new Error('STATE_DIRECTORY_CHANGED');
|
|
25
|
+
}
|
|
26
|
+
temporary = `HCC.md.tmp.${process.pid}.${require('node:crypto').randomBytes(16).toString('hex')}`;
|
|
27
|
+
fd = fs.openSync(temporary, fs.constants.O_WRONLY | fs.constants.O_CREAT |
|
|
28
|
+
fs.constants.O_EXCL | fs.constants.O_NOFOLLOW, 0o600);
|
|
29
|
+
fs.writeFileSync(fd, content);
|
|
30
|
+
fs.fsyncSync(fd);
|
|
31
|
+
fs.closeSync(fd);
|
|
32
|
+
fd = undefined;
|
|
33
|
+
/* PINNED_STATE_TEST_BEFORE_RENAME */
|
|
34
|
+
fs.renameSync(temporary, 'HCC.md');
|
|
35
|
+
} else if (mode === 'purge-legacy') {
|
|
36
|
+
const state = fs.lstatSync('.hello-cc', { bigint: true });
|
|
37
|
+
if (!sameDirectory(state)) throw new Error('STATE_DIRECTORY_CHANGED');
|
|
38
|
+
/* PINNED_STATE_TEST_BEFORE_PURGE */
|
|
39
|
+
fs.rmSync('.hello-cc', { recursive: true, force: true });
|
|
40
|
+
} else throw new Error('STATE_OPERATION_INVALID');
|
|
41
|
+
process.stdout.write('ok');
|
|
42
|
+
} catch (error) {
|
|
43
|
+
process.stderr.write('HCC_PINNED_STATE_FAILED:' + (error?.code || error?.message || 'unknown') + '\n');
|
|
44
|
+
process.exitCode = 1;
|
|
45
|
+
} finally {
|
|
46
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
47
|
+
if (temporary) {
|
|
48
|
+
try { fs.unlinkSync(temporary); } catch {}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const PROJECT_STATE_WORKER_SOURCE = '(' + projectStateWorker.toString() + ')()';
|
|
54
|
+
|
|
55
|
+
function sameBinding(actual, expected) {
|
|
56
|
+
return actual.identity.cwd === expected.canonical &&
|
|
57
|
+
actual.identity.dev === expected.identity.dev &&
|
|
58
|
+
actual.identity.ino === expected.identity.ino &&
|
|
59
|
+
actual.identity.birthtimeNs === expected.identity.birthtimeNs;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function runPinnedStateWorker(cwd, mode, expectedCwd, targetIdentity, content, spawnProcess) {
|
|
63
|
+
expectedCwd.assertUnchanged();
|
|
64
|
+
const binding = preparePinnedCwdLaunch(cwd, process.execPath,
|
|
65
|
+
['-e', PROJECT_STATE_WORKER_SOURCE, mode, JSON.stringify(targetIdentity), content], {
|
|
66
|
+
env: { PATH: '/usr/bin:/bin', NODE_OPTIONS: '', NODE_NO_WARNINGS: '1' }
|
|
67
|
+
});
|
|
68
|
+
try {
|
|
69
|
+
if (!sameBinding(binding, expectedCwd)) {
|
|
70
|
+
throw new CliError('PROJECT_PATH_CHANGED', 'Project state directory changed before pinned operation');
|
|
71
|
+
}
|
|
72
|
+
const result = spawnProcess(binding.command, binding.args, {
|
|
73
|
+
cwd: binding.cwd, env: binding.env, encoding: 'utf8',
|
|
74
|
+
stdio: ['ignore', 'pipe', 'pipe'], timeout: 300000, maxBuffer: 64 * 1024
|
|
75
|
+
});
|
|
76
|
+
if (result.error || result.status !== 0 || result.stdout !== 'ok') {
|
|
77
|
+
const marker = String(result.stderr || '');
|
|
78
|
+
if (/HCC_PINNED_CWD_CHANGED:|HCC_PINNED_STATE_FAILED:STATE_DIRECTORY_CHANGED/.test(marker)) {
|
|
79
|
+
throw new CliError('PROJECT_PATH_CHANGED', 'Project state directory changed during pinned operation');
|
|
80
|
+
}
|
|
81
|
+
throw new CliError('PROJECT_STATE_OPERATION_FAILED',
|
|
82
|
+
`Pinned project state operation failed: ${result.error?.code || marker.trim() || `exit ${result.status}`}`);
|
|
83
|
+
}
|
|
84
|
+
} finally { binding.release(); }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function writePinnedProjectGuidance(stateDir, content, expectedState, {
|
|
88
|
+
spawnProcess = spawnSync
|
|
89
|
+
} = {}) {
|
|
90
|
+
runPinnedStateWorker(stateDir, 'write-guidance', expectedState, expectedState.identity,
|
|
91
|
+
content, spawnProcess);
|
|
92
|
+
return path.join(stateDir, 'HCC.md');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function purgePinnedLegacyProjectState(root, stateDir, expectedRoot, expectedState, {
|
|
96
|
+
spawnProcess = spawnSync
|
|
97
|
+
} = {}) {
|
|
98
|
+
if (path.resolve(stateDir) !== path.join(expectedRoot.canonical, '.hello-cc')) {
|
|
99
|
+
throw new CliError('PROJECT_PATH_FORBIDDEN', 'Legacy state is not under the selected project root');
|
|
100
|
+
}
|
|
101
|
+
expectedState.assertUnchanged();
|
|
102
|
+
runPinnedStateWorker(root, 'purge-legacy', expectedRoot, expectedState.identity,
|
|
103
|
+
'', spawnProcess);
|
|
104
|
+
return stateDir;
|
|
105
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { CliError } from '../shared/errors.mjs';
|
|
4
|
+
|
|
5
|
+
function changed() {
|
|
6
|
+
return new CliError('PROJECT_PATH_CHANGED', 'Selected working directory changed; create a new session from its current location');
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function sameDirectory(left, right) {
|
|
10
|
+
return left.isDirectory() && right.isDirectory() && left.dev === right.dev &&
|
|
11
|
+
left.ino === right.ino && left.birthtimeNs === right.birthtimeNs;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function sameSelectedCwdIdentity(left, right) {
|
|
15
|
+
return Boolean(left && right && left.canonical === right.canonical &&
|
|
16
|
+
left.identity?.dev === right.identity?.dev && left.identity?.ino === right.identity?.ino &&
|
|
17
|
+
left.identity?.birthtimeNs === right.identity?.birthtimeNs);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function captureSelectedCwdSnapshot(cwd) {
|
|
21
|
+
const binding = captureSelectedCwdIdentity(cwd);
|
|
22
|
+
try {
|
|
23
|
+
return Object.freeze({ requested: binding.requested, canonical: binding.canonical,
|
|
24
|
+
identity: binding.identity });
|
|
25
|
+
} finally { binding.release(); }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function assertSelectedCwdSnapshot(snapshot) {
|
|
29
|
+
const current = captureSelectedCwdIdentity(snapshot.requested);
|
|
30
|
+
try {
|
|
31
|
+
if (!sameSelectedCwdIdentity(snapshot, current)) throw changed();
|
|
32
|
+
} finally { current.release(); }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// A provider protocol may require an absolute path even after its process was
|
|
36
|
+
// started in a pinned cwd. Keep the originally selected directory identifiable
|
|
37
|
+
// until the adapter closes, and recheck immediately before each path handoff.
|
|
38
|
+
// This does not make the subsequent provider-internal path lookup atomic.
|
|
39
|
+
export function captureSelectedCwdIdentity(cwd, expectedIdentity = null) {
|
|
40
|
+
if (typeof cwd !== 'string' || !cwd) throw new CliError('BAD_ARGS', 'Selected working directory is required');
|
|
41
|
+
const requested = path.resolve(cwd);
|
|
42
|
+
let canonical;
|
|
43
|
+
try { canonical = fs.realpathSync.native(requested); }
|
|
44
|
+
catch { throw changed(); }
|
|
45
|
+
|
|
46
|
+
let fd;
|
|
47
|
+
let initial;
|
|
48
|
+
let mode = 'descriptor';
|
|
49
|
+
try {
|
|
50
|
+
fd = fs.openSync(canonical, fs.constants.O_RDONLY |
|
|
51
|
+
(fs.constants.O_DIRECTORY || 0) | (fs.constants.O_NOFOLLOW || 0));
|
|
52
|
+
initial = fs.fstatSync(fd, { bigint: true });
|
|
53
|
+
} catch (error) {
|
|
54
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
55
|
+
fd = undefined;
|
|
56
|
+
// Execute-only directories can still be valid provider workspaces. The
|
|
57
|
+
// weaker stat-only mode preserves that contract without hiding the mode.
|
|
58
|
+
if (error?.code !== 'EACCES') throw changed();
|
|
59
|
+
mode = 'stat-only';
|
|
60
|
+
try { initial = fs.statSync(canonical, { bigint: true }); }
|
|
61
|
+
catch { throw changed(); }
|
|
62
|
+
}
|
|
63
|
+
let released = false;
|
|
64
|
+
const identity = Object.freeze({ dev: initial.dev.toString(), ino: initial.ino.toString(),
|
|
65
|
+
birthtimeNs: initial.birthtimeNs?.toString() || null });
|
|
66
|
+
const binding = {
|
|
67
|
+
requested, canonical, mode, identity,
|
|
68
|
+
assertUnchanged() {
|
|
69
|
+
if (released) throw changed();
|
|
70
|
+
try {
|
|
71
|
+
const selected = fs.statSync(requested, { bigint: true });
|
|
72
|
+
const target = fs.statSync(canonical, { bigint: true });
|
|
73
|
+
const original = fd === undefined ? initial : fs.fstatSync(fd, { bigint: true });
|
|
74
|
+
if (!sameDirectory(initial, original) || !sameDirectory(initial, selected) ||
|
|
75
|
+
!sameDirectory(initial, target)) throw changed();
|
|
76
|
+
} catch { throw changed(); }
|
|
77
|
+
},
|
|
78
|
+
dropDescriptor() {
|
|
79
|
+
if (released) return;
|
|
80
|
+
if (fd !== undefined) { fs.closeSync(fd); fd = undefined; }
|
|
81
|
+
binding.mode = 'stat-only';
|
|
82
|
+
},
|
|
83
|
+
release() {
|
|
84
|
+
if (released) return;
|
|
85
|
+
released = true;
|
|
86
|
+
if (fd !== undefined) { fs.closeSync(fd); fd = undefined; }
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
try {
|
|
90
|
+
binding.assertUnchanged();
|
|
91
|
+
if (expectedIdentity && !sameSelectedCwdIdentity(expectedIdentity, binding)) throw changed();
|
|
92
|
+
}
|
|
93
|
+
catch (error) { binding.release(); throw error; }
|
|
94
|
+
return binding;
|
|
95
|
+
}
|
|
@@ -4,6 +4,7 @@ import { performance } from 'node:perf_hooks';
|
|
|
4
4
|
|
|
5
5
|
import { isPeerEvidenceCompatibilityProtected, resolvePeerEvidence } from '../core/peers/evidence.mjs';
|
|
6
6
|
import { inspectProcessIdentity } from '../process/identity.mjs';
|
|
7
|
+
import { resolveProjectDatabase } from './project-path.mjs';
|
|
7
8
|
|
|
8
9
|
function canonicalDirectories(values) {
|
|
9
10
|
const directories = [];
|
|
@@ -139,7 +140,7 @@ function safeTmuxPath(directory, row, runtimeId) {
|
|
|
139
140
|
}
|
|
140
141
|
|
|
141
142
|
function contextBufferDirectory(ctx) {
|
|
142
|
-
return path.
|
|
143
|
+
return path.join(resolveProjectDatabase({ root: ctx.root, createStateDir: false }).stateDir, 'bufs');
|
|
143
144
|
}
|
|
144
145
|
|
|
145
146
|
function boundedUnknown(referenceFile, {
|
package/lib/runtime/client.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import { CliError } from '../shared/errors.mjs';
|
|
|
2
2
|
import { readRuntime } from './state.mjs';
|
|
3
3
|
import { runtimeApiUrl, runtimeHttpRequest } from '../web/runtime.mjs';
|
|
4
4
|
import { withRuntimeApiVersionHeader } from '../web/api-version.mjs';
|
|
5
|
+
import { assertSelectedCwdSnapshot } from '../process/selected-cwd-identity.mjs';
|
|
5
6
|
import { performance } from 'node:perf_hooks';
|
|
6
7
|
|
|
7
8
|
const DEFAULT_CLI_NAME = 'hcc';
|
|
@@ -47,12 +48,20 @@ function waitForAbort(promise, signal) {
|
|
|
47
48
|
}
|
|
48
49
|
|
|
49
50
|
export async function runtimeRequest(ctx, method, route, body = null, runtime = null, opts = {}) {
|
|
51
|
+
const expectedRootIdentity = ctx.initialRootIdentity || ctx.rootIdentity;
|
|
52
|
+
if (expectedRootIdentity) assertSelectedCwdSnapshot(expectedRootIdentity);
|
|
50
53
|
const cliName = opts.cliName || DEFAULT_CLI_NAME;
|
|
51
54
|
const rt = runtime || readRuntime(ctx, { cliName, localOnly: Boolean(opts.localOnly) });
|
|
52
55
|
const url = runtimeApiUrl(rt, route);
|
|
53
56
|
const headers = withRuntimeApiVersionHeader({ 'Content-Type': 'application/json' });
|
|
54
57
|
headers['X-HCC-Root'] = ctx.root;
|
|
55
58
|
headers['X-HCC-DB'] = ctx.dbPath;
|
|
59
|
+
if (expectedRootIdentity) {
|
|
60
|
+
headers['X-HCC-Root-Identity'] = Buffer.from(JSON.stringify({
|
|
61
|
+
canonical: expectedRootIdentity.canonical,
|
|
62
|
+
identity: expectedRootIdentity.identity
|
|
63
|
+
})).toString('base64url');
|
|
64
|
+
}
|
|
56
65
|
if (rt.token) headers.Authorization = `Bearer ${rt.token}`;
|
|
57
66
|
let res;
|
|
58
67
|
let text;
|