@logicseek/hello-cc 1.1.0-rc.8 → 1.1.0

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.
Files changed (74) hide show
  1. package/CHANGELOG.md +107 -0
  2. package/README.md +2 -0
  3. package/README.zh-CN.md +2 -0
  4. package/docs/README.md +2 -0
  5. package/docs/README.zh-CN.md +2 -0
  6. package/docs/commands.md +16 -0
  7. package/docs/commands.zh-CN.md +14 -0
  8. package/docs/dsh.md +6 -0
  9. package/docs/dsh.zh-CN.md +5 -0
  10. package/docs/native.md +21 -1
  11. package/docs/native.zh-CN.md +19 -1
  12. package/docs/private-state.md +115 -0
  13. package/docs/private-state.zh-CN.md +92 -0
  14. package/lib/cli/app.mjs +46 -5
  15. package/lib/cli/commands/hook.mjs +31 -4
  16. package/lib/cli/commands/mcp.mjs +6 -0
  17. package/lib/cli/commands/native.mjs +17 -4
  18. package/lib/cli/commands/peer.mjs +3 -0
  19. package/lib/cli/commands/run.mjs +196 -62
  20. package/lib/cli/commands/tmux.mjs +28 -19
  21. package/lib/cli/commands/up.mjs +25 -17
  22. package/lib/cli-runtime.mjs +16 -1
  23. package/lib/core/coordination/event-retention.mjs +2 -0
  24. package/lib/core/sessions/codex-thread-root.mjs +144 -0
  25. package/lib/core/sessions/launch.mjs +4 -0
  26. package/lib/discover.mjs +95 -231
  27. package/lib/integrations/dsh-collaboration.mjs +9 -2
  28. package/lib/integrations/native/claude.mjs +6 -0
  29. package/lib/integrations/native/codex.mjs +4 -2
  30. package/lib/integrations/native/dsh-acp.mjs +1 -1
  31. package/lib/integrations/native/jsonrpc.mjs +2 -2
  32. package/lib/mcp/scope.mjs +50 -8
  33. package/lib/mcp/tools.mjs +15 -2
  34. package/lib/process/pinned-cwd.mjs +64 -0
  35. package/lib/process/pinned-guidance.mjs +1 -0
  36. package/lib/process/pinned-project-state.mjs +1 -0
  37. package/lib/process/tmux-cwd-handoff.mjs +277 -0
  38. package/lib/runtime/native/cli-submissions.mjs +220 -0
  39. package/lib/runtime/native/client.mjs +7 -1
  40. package/lib/runtime/native/launcher.mjs +20 -4
  41. package/lib/runtime/native/service.mjs +143 -50
  42. package/lib/runtime/native/store.mjs +303 -14
  43. package/lib/runtime/private-state.mjs +435 -24
  44. package/lib/runtime/project-path.mjs +53 -1
  45. package/lib/runtime/project-trust.mjs +6 -2
  46. package/lib/runtime/state-migration.mjs +66 -13
  47. package/lib/shared/redact.mjs +4 -1
  48. package/lib/terminal/tmux.mjs +13 -2
  49. package/lib/ui/help.mjs +1 -0
  50. package/lib/web/codex-app-server.mjs +3 -1
  51. package/lib/web/codex-sessions.mjs +68 -8
  52. package/lib/web/context-files.mjs +194 -41
  53. package/lib/web/http-routes.mjs +43 -12
  54. package/lib/web/native-sessions.mjs +3 -0
  55. package/lib/web/project-contexts.mjs +27 -5
  56. package/lib/web/project-files.mjs +108 -26
  57. package/lib/web/pty-sessions.mjs +15 -2
  58. package/lib/web/session-serialize.mjs +6 -1
  59. package/lib/web/startup.mjs +3 -1
  60. package/lib/web/tmux-sessions.mjs +153 -50
  61. package/lib/web/ui-interactions.mjs +7 -4
  62. package/native/bin/darwin-arm64/hcc-cwd-handoff +0 -0
  63. package/native/bin/darwin-x64/hcc-cwd-handoff +0 -0
  64. package/native/bin/linux-arm64/hcc-cwd-handoff +0 -0
  65. package/native/bin/linux-x64/hcc-cwd-handoff +0 -0
  66. package/native/cwd-handoff/README.md +45 -0
  67. package/native/cwd-handoff/hcc-cwd-handoff.c +693 -0
  68. package/package.json +8 -2
  69. package/scripts/build-cwd-handoff.mjs +47 -0
  70. package/scripts/pack-cwd-handoff.mjs +27 -0
  71. package/scripts/regression.mjs +113 -7
  72. package/scripts/verify-cwd-handoff-package.mjs +60 -0
  73. package/scripts/web-workbench-acceptance.mjs +79 -1
  74. package/scripts/web-workbench-installed-acceptance.mjs +11 -1
package/lib/mcp/tools.mjs CHANGED
@@ -20,6 +20,7 @@ import { captureLockAcquireSubject, sameLockAcquireSubject, observeLockOwnerEvid
20
20
  import { runOptimisticEvidenceMutation } from '../core/coordination/optimistic-evidence.mjs';
21
21
  import { normalizeListText } from '../handoff.mjs';
22
22
  import { listTaskResults, recordTaskResult } from '../core/coordination/results.mjs';
23
+ import { acquireNativeOwnerWrite } from '../runtime/native/store.mjs';
23
24
 
24
25
  const string = (maxLength = 4096) => ({ type: 'string', minLength: 1, maxLength });
25
26
  const integer = (maximum = Number.MAX_SAFE_INTEGER) => ({ type: 'integer', minimum: 1, maximum });
@@ -81,7 +82,9 @@ function validate(value, schema, label) {
81
82
 
82
83
  export function createScopedMcpTools({ ctx, authority, connect: baseConnect, touchPeer, now, addEvent }) {
83
84
  const peer = authority.scope.peer, scopedCtx = { ...ctx, cwd: authority.scope.root,
84
- root: authority.scope.root, dbPath: authority.scope.dbPath, json: true };
85
+ root: authority.scope.root, dbPath: authority.scope.dbPath, json: true,
86
+ rootIdentity: authority.scope.rootIdentity || ctx.rootIdentity,
87
+ initialRootIdentity: authority.scope.rootIdentity || ctx.initialRootIdentity };
85
88
  let captured, databases = [], active = false, taskGuard = null;
86
89
  function assertWrite(db) {
87
90
  authority.assertOwnership(db);
@@ -91,6 +94,7 @@ export function createScopedMcpTools({ ctx, authority, connect: baseConnect, tou
91
94
  }
92
95
  }
93
96
  function connect() {
97
+ authority.assertValid();
94
98
  const db = baseConnect(scopedCtx, { create: false, migrateRegistered: false });
95
99
  databases.push(db);
96
100
  authority.assertOwnership(db);
@@ -178,10 +182,16 @@ export function createScopedMcpTools({ ctx, authority, connect: baseConnect, tou
178
182
  async call(name, args) {
179
183
  if (active) return { isError: true, content: [{ type: 'text', text: 'A scoped tool call is already running' }] };
180
184
  active = true; captured = undefined; databases = []; taskGuard = null;
185
+ let finishNative;
181
186
  try {
182
187
  if (!byName.has(name)) throw new CliError('BAD_ARGS', 'Unknown scoped tool');
183
188
  validate(args, byName.get(name).inputSchema, 'arguments');
184
189
  authority.assertValid();
190
+ // The state owner lock precedes connect(), which may initialize the
191
+ // mesh schema, and stays held through every nested command transaction.
192
+ if (authority.scope.nativeOwnerGeneration) {
193
+ finishNative = acquireNativeOwnerWrite(scopedCtx, authority.scope.nativeOwnerGeneration);
194
+ }
185
195
  const direct = await execute(name, args);
186
196
  const structuredContent = redactSecrets({ ok: true, peer, data: captured === undefined ? direct : captured });
187
197
  return { content: [{ type: 'text', text: JSON.stringify(structuredContent) }], structuredContent, isError: false };
@@ -191,7 +201,10 @@ export function createScopedMcpTools({ ctx, authority, connect: baseConnect, tou
191
201
  message: error instanceof CliError ? error.message : 'The scoped tool call failed'
192
202
  } });
193
203
  return { content: [{ type: 'text', text: JSON.stringify(structuredContent) }], structuredContent, isError: true };
194
- } finally { for (const db of databases) { try { db.close(); } catch {} } active = false; }
204
+ } finally {
205
+ for (const db of databases) { try { db.close(); } catch {} }
206
+ try { finishNative?.(); } finally { active = false; }
207
+ }
195
208
  }
196
209
  };
197
210
  }
@@ -144,6 +144,25 @@ function pinnedBootstrap() {
144
144
 
145
145
  export const PINNED_CWD_BOOTSTRAP_SOURCE = '(' + pinnedBootstrap.toString() + ')()';
146
146
 
147
+ export const PINNED_LAUNCH_MODE_ENV = 'HCC_PINNED_LAUNCH_MODE';
148
+
149
+ export function assertPinnedSessionLaunchAllowed(options = {}) {
150
+ if (options.purpose === 'maintenance') return;
151
+ // Provider child environments may deliberately strip HCC_* variables. The
152
+ // parent rollout setting must still govern the launch in that case; a
153
+ // per-launch environment can only make the gate stricter, never clear it.
154
+ const modes = [process.env[PINNED_LAUNCH_MODE_ENV], options.env?.[PINNED_LAUNCH_MODE_ENV]]
155
+ .filter(value => value !== undefined && value !== '');
156
+ if (modes.some(mode => mode !== 'pinned' && mode !== 'hold')) {
157
+ throw new CliError('PINNED_LAUNCH_MODE_INVALID',
158
+ `${PINNED_LAUNCH_MODE_ENV} must be pinned or hold`);
159
+ }
160
+ if (modes.includes('hold')) {
161
+ throw new CliError('PINNED_LAUNCH_PAUSED',
162
+ 'New session launches are paused; existing processes remain running');
163
+ }
164
+ }
165
+
147
166
  function sameIdentity(left, right) {
148
167
  return left.dev === right.dev && left.ino === right.ino &&
149
168
  left.birthtimeNs === right.birthtimeNs;
@@ -169,6 +188,50 @@ function bootstrapDirectory(override) {
169
188
  return ensurePrivateGlobalSubdirectory('launches');
170
189
  }
171
190
 
191
+ // Capture a directory descriptor for helpers that can pass it across a
192
+ // process boundary with SCM_RIGHTS. Unlike the Node bootstrap, the consumer
193
+ // never has to resolve the pathname again after this function returns.
194
+ export function captureTrustedDirectoryFd(cwd, expectedIdentity = null) {
195
+ assertPinnedSessionLaunchAllowed();
196
+ if (process.platform !== 'darwin' && process.platform !== 'linux') {
197
+ throw new CliError('PINNED_CWD_UNAVAILABLE', 'Trusted directory handoff requires macOS or Linux');
198
+ }
199
+ const requested = path.resolve(cwd);
200
+ let canonical;
201
+ try { canonical = fs.realpathSync.native(requested); }
202
+ catch { throw new CliError('PROJECT_PATH_FORBIDDEN', 'Selected working directory is unavailable'); }
203
+
204
+ let fd;
205
+ try {
206
+ fd = fs.openSync(canonical,
207
+ fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW);
208
+ const stat = fs.fstatSync(fd, { bigint: true });
209
+ const current = fs.statSync(requested, { bigint: true });
210
+ if (!stat.isDirectory() || !sameIdentity(stat, current)) {
211
+ throw new CliError('PROJECT_PATH_CHANGED', 'Selected working directory changed during capture');
212
+ }
213
+ const identity = directoryIdentity(stat, canonical, requested, 'descriptor');
214
+ if (expectedIdentity &&
215
+ (identity.cwd !== expectedIdentity.canonical ||
216
+ identity.dev !== expectedIdentity.identity?.dev ||
217
+ identity.ino !== expectedIdentity.identity?.ino ||
218
+ identity.birthtimeNs !== expectedIdentity.identity?.birthtimeNs)) {
219
+ throw new CliError('PROJECT_PATH_CHANGED', 'Selected working directory changed before launch');
220
+ }
221
+ return {
222
+ fd,
223
+ identity,
224
+ release() {
225
+ if (fd !== undefined) { fs.closeSync(fd); fd = undefined; }
226
+ }
227
+ };
228
+ } catch (error) {
229
+ if (fd !== undefined) fs.closeSync(fd);
230
+ if (error instanceof CliError) throw error;
231
+ throw new CliError('PROJECT_PATH_FORBIDDEN', 'Selected working directory cannot be pinned');
232
+ }
233
+ }
234
+
172
235
  function acknowledgementState(ack) {
173
236
  if (!ack) return null;
174
237
  let fd;
@@ -198,6 +261,7 @@ function removeOwnedAcknowledgement(ack) {
198
261
  // only when the child has established its cwd or exited; retaining it while a
199
262
  // child starts prevents inode reuse from satisfying a stale dev/ino snapshot.
200
263
  export function preparePinnedCwdLaunch(cwd, command, args = [], options = {}) {
264
+ assertPinnedSessionLaunchAllowed(options);
201
265
  if (process.platform === 'win32') {
202
266
  throw new CliError('PINNED_CWD_UNAVAILABLE', 'Pinned cwd launch requires POSIX directory identity');
203
267
  }
@@ -97,6 +97,7 @@ function runPinnedGuidance(root, mode, content = '', {
97
97
  if (expectedSnapshot) assertSelectedCwdSnapshot(expectedSnapshot);
98
98
  const binding = preparePinnedCwdLaunch(root, process.execPath,
99
99
  ['-e', GUIDANCE_WORKER_SOURCE, mode, content], {
100
+ purpose: 'maintenance',
100
101
  // The worker needs only built-ins. Do not permit inherited Node preload
101
102
  // hooks to run before it can operate on relative names.
102
103
  env: { PATH: '/usr/bin:/bin', NODE_OPTIONS: '', NODE_NO_WARNINGS: '1' }
@@ -63,6 +63,7 @@ function runPinnedStateWorker(cwd, mode, expectedCwd, targetIdentity, content, s
63
63
  expectedCwd.assertUnchanged();
64
64
  const binding = preparePinnedCwdLaunch(cwd, process.execPath,
65
65
  ['-e', PROJECT_STATE_WORKER_SOURCE, mode, JSON.stringify(targetIdentity), content], {
66
+ purpose: 'maintenance',
66
67
  env: { PATH: '/usr/bin:/bin', NODE_OPTIONS: '', NODE_NO_WARNINGS: '1' }
67
68
  });
68
69
  try {
@@ -0,0 +1,277 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import process from 'node:process';
4
+ import { spawn } from 'node:child_process';
5
+ import { createHash, randomBytes } from 'node:crypto';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { CliError } from '../shared/errors.mjs';
8
+ import { assertPinnedSessionLaunchAllowed, captureTrustedDirectoryFd } from './pinned-cwd.mjs';
9
+
10
+ const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
11
+ const BROKER_TIMEOUT_MS = 25_000;
12
+ const BIND_TIMEOUT_MS = 6_000;
13
+ const bindSleep = new Int32Array(new SharedArrayBuffer(4));
14
+
15
+ function writeBrokerAuthorizationSync(broker, message) {
16
+ // The Web launch API is synchronous. A Writable.end(message) can queue its
17
+ // pipe write on the Node event loop, which is blocked while waiting for the
18
+ // native bind receipt. Require an actual child-stdin descriptor and send the
19
+ // tiny ALLOW line synchronously; if this Node runtime stops exposing it,
20
+ // refuse the launch rather than hang or admit an unbound pane.
21
+ const fd = broker.stdin?._handle?.fd;
22
+ if (!Number.isSafeInteger(fd) || fd < 0) {
23
+ throw new CliError('PINNED_CWD_UNAVAILABLE', 'Trusted tmux broker stdin is unavailable');
24
+ }
25
+ const bytes = Buffer.from(message, 'ascii');
26
+ const deadline = Date.now() + 250;
27
+ let offset = 0;
28
+ while (offset < bytes.length) {
29
+ try {
30
+ const written = fs.writeSync(fd, bytes, offset, bytes.length - offset);
31
+ if (written <= 0) throw new Error('Broker authorization pipe made no progress');
32
+ offset += written;
33
+ } catch (error) {
34
+ if (!['EAGAIN', 'EINTR'].includes(error?.code) || Date.now() >= deadline) {
35
+ throw new CliError('PINNED_CWD_UNAVAILABLE',
36
+ 'Could not authorize the trusted tmux broker synchronously');
37
+ }
38
+ Atomics.wait(bindSleep, 0, 0, 5);
39
+ }
40
+ }
41
+ broker.stdin.end();
42
+ }
43
+
44
+ function killStartedBroker(broker) {
45
+ // ChildProcess.kill() is unsafe for a failed spawn with pid === undefined:
46
+ // some Node/libuv versions may signal the calling process instead.
47
+ if (Number.isSafeInteger(broker?.pid) && broker.pid > 0 &&
48
+ broker.exitCode === null && broker.signalCode === null) broker.kill();
49
+ }
50
+
51
+ export function trustedCwdHandoffBinary() {
52
+ if (!['darwin', 'linux'].includes(process.platform)) {
53
+ throw new CliError('PINNED_CWD_UNAVAILABLE', 'Trusted tmux directory handoff requires macOS or Linux');
54
+ }
55
+ const binary = path.join(packageRoot, 'native', 'bin',
56
+ `${process.platform}-${process.arch}`, 'hcc-cwd-handoff');
57
+ let stat;
58
+ try { stat = fs.lstatSync(binary); }
59
+ catch {
60
+ throw new CliError('PINNED_CWD_UNAVAILABLE',
61
+ `Trusted tmux directory helper is not packaged for ${process.platform}-${process.arch}`);
62
+ }
63
+ if (!stat.isFile() || (stat.mode & 0o022) !== 0) {
64
+ throw new CliError('PINNED_CWD_UNAVAILABLE', 'Trusted tmux directory helper is unsafe');
65
+ }
66
+ try { fs.accessSync(binary, fs.constants.X_OK); }
67
+ catch { throw new CliError('PINNED_CWD_UNAVAILABLE', 'Trusted tmux directory helper is not executable'); }
68
+ // A source checkout can contain an old prebuilt helper after a C edit. The
69
+ // release gate checks all four targets; also fail closed during local use.
70
+ try {
71
+ const source = fs.readFileSync(path.join(packageRoot, 'native', 'cwd-handoff', 'hcc-cwd-handoff.c'));
72
+ const marker = Buffer.from('HCC_SOURCE_SHA256:' + createHash('sha256').update(source).digest('hex'));
73
+ if (!fs.readFileSync(binary).includes(marker)) throw new Error('source hash mismatch');
74
+ } catch {
75
+ throw new CliError('PINNED_CWD_UNAVAILABLE', 'Trusted tmux directory helper does not match packaged source');
76
+ }
77
+ return binary;
78
+ }
79
+
80
+ function removeOwnedSocketDirectory(directory, identity) {
81
+ try {
82
+ const current = fs.lstatSync(directory);
83
+ if (!current.isDirectory() || current.dev !== identity.dev || current.ino !== identity.ino) return;
84
+ const socket = path.join(directory, 's');
85
+ try {
86
+ if (fs.lstatSync(socket).isSocket()) fs.unlinkSync(socket);
87
+ } catch (error) { if (error?.code !== 'ENOENT') return; }
88
+ for (const name of ['s.bound', 's.bound.tmp']) {
89
+ const receipt = path.join(directory, name);
90
+ try {
91
+ const stat = fs.lstatSync(receipt);
92
+ if (!stat.isFile() || stat.uid !== process.getuid?.() || stat.nlink !== 1) return;
93
+ fs.unlinkSync(receipt);
94
+ } catch (error) { if (error?.code !== 'ENOENT') return; }
95
+ }
96
+ fs.rmdirSync(directory);
97
+ } catch {}
98
+ }
99
+
100
+ function executableFromPinnedCwd(command, identity) {
101
+ if (!path.isAbsolute(command)) return command;
102
+ for (const root of [identity.cwd, identity.requestedCwd]) {
103
+ const relative = path.relative(root, command);
104
+ if (relative === '') return '.';
105
+ if (relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) {
106
+ return `.${path.sep}${relative}`;
107
+ }
108
+ }
109
+ return command;
110
+ }
111
+
112
+ function handoffEnvironment(env) {
113
+ const safe = { ...env };
114
+ // The client is a native executable. Dynamic-loader hooks would execute
115
+ // before its peer authentication and fchdir, so they cannot be inherited
116
+ // across this boundary. PWD is published only by the client after binding.
117
+ for (const key of Object.keys(safe)) {
118
+ if (key === 'PWD' || key.startsWith('LD_') || key.startsWith('DYLD_')) delete safe[key];
119
+ }
120
+ return safe;
121
+ }
122
+
123
+ // The broker inherits the already-validated directory as fd 3. The tmux pane
124
+ // sees only a trusted helper and a private socket path; it never resolves the
125
+ // user's pathname. No directory fd is sent until the parent authorizes the
126
+ // immutable pane PID returned by tmux new-session -P.
127
+ export function prepareTmuxCwdHandoff({ cwd, expectedIdentity, shell, command, env }, {
128
+ binary,
129
+ spawnBroker = spawn,
130
+ tempRoot = '/tmp'
131
+ } = {}) {
132
+ assertPinnedSessionLaunchAllowed({ env });
133
+ if (typeof shell !== 'string' || !shell || typeof command !== 'string') {
134
+ throw new CliError('BAD_ARGS', 'Trusted tmux launch requires shell and command');
135
+ }
136
+ binary ||= trustedCwdHandoffBinary();
137
+ const captured = captureTrustedDirectoryFd(cwd, expectedIdentity);
138
+ const nonce = randomBytes(32).toString('hex');
139
+ let directory;
140
+ let broker;
141
+ let failed = false;
142
+ try {
143
+ directory = fs.mkdtempSync(path.join(tempRoot, 'hccfd-'));
144
+ fs.chmodSync(directory, 0o700);
145
+ const socket = path.join(directory, 's');
146
+ const receipt = `${socket}.bound`;
147
+ if (Buffer.byteLength(socket) >= 100) {
148
+ throw new CliError('PINNED_CWD_UNAVAILABLE', 'Private helper socket path is too long');
149
+ }
150
+ const directoryStat = fs.lstatSync(directory);
151
+ const brokerArgs = [
152
+ 'broker', '--socket', socket, '--dir-fd', '3',
153
+ '--dev', captured.identity.dev, '--ino', captured.identity.ino,
154
+ '--nonce', nonce
155
+ ];
156
+ broker = spawnBroker(binary, brokerArgs, {
157
+ cwd: '/',
158
+ env: { PATH: '/usr/bin:/bin' },
159
+ stdio: ['pipe', 'pipe', 'pipe', captured.fd]
160
+ });
161
+ // spawn() can return a ChildProcess without a PID and emit ENOENT on the
162
+ // next tick. Install both listeners before validating the returned child
163
+ // so a failed helper launch cannot crash the Web runtime afterward.
164
+ broker?.on?.('error', () => { failed = true; });
165
+ broker?.stdin?.on?.('error', () => { failed = true; });
166
+ if (!broker?.pid || !broker.stdin || !broker.stdout) {
167
+ throw new CliError('PINNED_CWD_UNAVAILABLE', 'Trusted tmux directory broker did not start');
168
+ }
169
+ // The child now owns its independent copy; the parent no longer needs to
170
+ // retain A's fd. The broker's native timeout bounds any ambiguous launch.
171
+ captured.release();
172
+ let bound = false;
173
+ let released = false;
174
+ let authorized = false;
175
+ let output = '';
176
+ const timer = setTimeout(() => {
177
+ failed = true;
178
+ killStartedBroker(broker);
179
+ }, BROKER_TIMEOUT_MS);
180
+ timer.unref?.();
181
+ broker.stdout.on('data', chunk => {
182
+ output += chunk.toString('utf8');
183
+ if (output.length > 1024) { failed = true; killStartedBroker(broker); return; }
184
+ for (;;) {
185
+ const end = output.indexOf('\n');
186
+ if (end < 0) break;
187
+ const line = output.slice(0, end);
188
+ output = output.slice(end + 1);
189
+ if (line === 'READY') continue;
190
+ if (line === 'BOUND' && authorized) { bound = true; continue; }
191
+ failed = true;
192
+ killStartedBroker(broker);
193
+ break;
194
+ }
195
+ });
196
+ broker.on('close', () => {
197
+ if (!bound) failed = true;
198
+ clearTimeout(timer);
199
+ removeOwnedSocketDirectory(directory, directoryStat);
200
+ });
201
+ return {
202
+ cwd: '/',
203
+ command: binary,
204
+ args: ['client', '--socket', socket,
205
+ '--dev', captured.identity.dev, '--ino', captured.identity.ino,
206
+ '--nonce', nonce, '--', executableFromPinnedCwd(shell, captured.identity), '-c', command],
207
+ env: handoffEnvironment(env),
208
+ identity: captured.identity,
209
+ authorize(panePid) {
210
+ if (authorized || failed || !Number.isSafeInteger(panePid) || panePid <= 0) {
211
+ throw new CliError('PINNED_CWD_UNAVAILABLE', 'Cannot authorize trusted tmux pane');
212
+ }
213
+ authorized = true;
214
+ writeBrokerAuthorizationSync(broker, `ALLOW ${panePid} ${nonce}\n`);
215
+ },
216
+ waitBoundSync(timeoutMs = BIND_TIMEOUT_MS) {
217
+ if (!authorized || failed || released) {
218
+ throw new CliError('PINNED_CWD_UNAVAILABLE', 'Trusted tmux pane is not authorized for directory handoff');
219
+ }
220
+ const deadline = Date.now() + timeoutMs;
221
+ while (Date.now() <= deadline) {
222
+ try {
223
+ const parent = fs.lstatSync(directory);
224
+ if (!parent.isDirectory() || parent.isSymbolicLink() ||
225
+ parent.dev !== directoryStat.dev || parent.ino !== directoryStat.ino ||
226
+ parent.uid !== process.getuid?.() || (parent.mode & 0o077) !== 0) {
227
+ throw new CliError('PINNED_CWD_UNAVAILABLE', 'Private directory handoff parent changed');
228
+ }
229
+ const before = fs.lstatSync(receipt);
230
+ if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1 ||
231
+ before.uid !== process.getuid?.() || (before.mode & 0o777) !== 0o600 ||
232
+ before.size !== 65) {
233
+ throw new CliError('PINNED_CWD_UNAVAILABLE', 'Private directory handoff receipt is unsafe');
234
+ }
235
+ const fd = fs.openSync(receipt, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_CLOEXEC);
236
+ let data;
237
+ try {
238
+ const opened = fs.fstatSync(fd);
239
+ if (opened.dev !== before.dev || opened.ino !== before.ino || opened.size !== 65) {
240
+ throw new CliError('PINNED_CWD_UNAVAILABLE', 'Private directory handoff receipt changed');
241
+ }
242
+ data = fs.readFileSync(fd, 'ascii');
243
+ } finally { fs.closeSync(fd); }
244
+ if (data !== `${nonce}\n`) {
245
+ throw new CliError('PINNED_CWD_UNAVAILABLE', 'Private directory handoff receipt does not match');
246
+ }
247
+ fs.unlinkSync(receipt);
248
+ bound = true;
249
+ return true;
250
+ } catch (error) {
251
+ if (error?.code !== 'ENOENT') throw error;
252
+ }
253
+ Atomics.wait(bindSleep, 0, 0, 20);
254
+ }
255
+ throw new CliError('PINNED_CWD_UNAVAILABLE', 'Trusted tmux pane did not confirm directory binding');
256
+ },
257
+ acknowledged() { return bound; },
258
+ failed() { return failed; },
259
+ release() {
260
+ if (released) return;
261
+ released = true;
262
+ clearTimeout(timer);
263
+ if (!bound) killStartedBroker(broker);
264
+ if (broker.exitCode !== null || broker.signalCode !== null) {
265
+ removeOwnedSocketDirectory(directory, directoryStat);
266
+ }
267
+ }
268
+ };
269
+ } catch (error) {
270
+ captured.release();
271
+ killStartedBroker(broker);
272
+ if (directory) {
273
+ try { fs.rmdirSync(directory); } catch {}
274
+ }
275
+ throw error;
276
+ }
277
+ }
@@ -0,0 +1,220 @@
1
+ import { createHash, randomBytes, randomUUID } from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { CliError } from '../../shared/errors.mjs';
5
+ import { ensurePrivateProjectStateSubdirectory } from '../project-path.mjs';
6
+ import { readPrivateTextFile } from '../private-file.mjs';
7
+ import { unsafeDirectoryAcl } from '../project-trust.mjs';
8
+ import { nativePaths } from './store.mjs';
9
+
10
+ const DIRECTORY = 'native-cli-submissions';
11
+ const UUID = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}';
12
+ const UUID_VALUE = new RegExp(`^${UUID}$`);
13
+ const PREPARED = new RegExp(`^submission-(${UUID})\\.json$`);
14
+
15
+ function journalDirectory(ctx, create) {
16
+ const native = nativePaths(ctx, { create });
17
+ if (!native) return null;
18
+ const directory = ensurePrivateProjectStateSubdirectory(ctx.root, DIRECTORY, { create });
19
+ if (directory && path.dirname(directory) !== path.dirname(native.dir)) {
20
+ throw new CliError('PROJECT_PATH_CHANGED', 'CLI submissions do not match the selected native state');
21
+ }
22
+ if (directory && create) syncDirectory(path.dirname(directory));
23
+ return directory ? { directory, meshDbRelative: path.relative(path.dirname(native.dir), native.meshDb) } : null;
24
+ }
25
+
26
+ function syncDirectory(directory) {
27
+ if (process.platform === 'win32') return;
28
+ const fd = fs.openSync(directory, fs.constants.O_RDONLY | fs.constants.O_DIRECTORY |
29
+ (fs.constants.O_NOFOLLOW || 0));
30
+ try { fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
31
+ }
32
+
33
+ function writeRecord(directory, name, value) {
34
+ const file = path.join(directory, name);
35
+ const temporaryName = `.${name}.tmp.${randomBytes(12).toString('hex')}`;
36
+ const temporary = path.join(directory, temporaryName);
37
+ let fd;
38
+ let opened;
39
+ try {
40
+ fd = fs.openSync(temporary, fs.constants.O_WRONLY | fs.constants.O_CREAT |
41
+ fs.constants.O_EXCL | (fs.constants.O_NOFOLLOW || 0), 0o600);
42
+ opened = fs.fstatSync(fd);
43
+ if (process.platform !== 'win32') fs.fchmodSync(fd, 0o600);
44
+ const secured = fs.fstatSync(fd);
45
+ if (!secured.isFile() || secured.nlink !== 1 || secured.dev !== opened.dev || secured.ino !== opened.ino ||
46
+ (typeof process.geteuid === 'function' && secured.uid !== process.geteuid()) ||
47
+ (process.platform !== 'win32' && (secured.mode & 0o777) !== 0o600) ||
48
+ unsafeDirectoryAcl(temporary, secured)) {
49
+ throw new Error('Unsafe CLI submission journal file');
50
+ }
51
+ fs.writeFileSync(fd, JSON.stringify(value) + '\n');
52
+ fs.fsyncSync(fd);
53
+ fs.closeSync(fd);
54
+ fd = undefined;
55
+ const ready = fs.lstatSync(temporary);
56
+ if (!ready.isFile() || ready.isSymbolicLink() || ready.nlink !== 1 ||
57
+ ready.dev !== opened.dev || ready.ino !== opened.ino) {
58
+ throw new Error('CLI submission journal temporary file changed before publication');
59
+ }
60
+ fs.linkSync(temporary, file);
61
+ try { fs.unlinkSync(temporary); }
62
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
63
+ syncDirectory(directory);
64
+ const published = fs.lstatSync(file);
65
+ if (!published.isFile() || published.isSymbolicLink() || published.nlink !== 1 ||
66
+ published.dev !== opened.dev || published.ino !== opened.ino) {
67
+ throw new Error('CLI submission journal file changed after publication');
68
+ }
69
+ } catch (error) {
70
+ if (fd !== undefined) try { fs.closeSync(fd); } catch { /* Preserve the write failure. */ }
71
+ if (opened) {
72
+ try {
73
+ const current = fs.lstatSync(temporary);
74
+ if (current.dev === opened.dev && current.ino === opened.ino) fs.unlinkSync(temporary);
75
+ } catch { /* The failed write must never authorize a send. */ }
76
+ }
77
+ throw new CliError('NATIVE_CLI_JOURNAL_FAILED', 'Cannot durably save the native CLI submission record',
78
+ { cause: error.code || 'WRITE_FAILED' });
79
+ }
80
+ }
81
+
82
+ function repairPublishedLink(directory, name) {
83
+ const file = path.join(directory, name);
84
+ const published = fs.lstatSync(file);
85
+ if (published.nlink === 1) return;
86
+ if (!published.isFile() || published.isSymbolicLink() || published.nlink !== 2) {
87
+ throw new CliError('NATIVE_CLI_JOURNAL_INVALID', 'Native CLI submission journal has an unexpected hard link');
88
+ }
89
+ const prefix = `.${name}.tmp.`;
90
+ for (const candidate of fs.readdirSync(directory)) {
91
+ if (!candidate.startsWith(prefix)) continue;
92
+ const temporary = path.join(directory, candidate);
93
+ let stat;
94
+ try { stat = fs.lstatSync(temporary); }
95
+ catch (error) { if (error.code === 'ENOENT') continue; throw error; }
96
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.dev !== published.dev || stat.ino !== published.ino) continue;
97
+ try { fs.unlinkSync(temporary); }
98
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
99
+ syncDirectory(directory);
100
+ const repaired = fs.lstatSync(file);
101
+ if (repaired.dev === published.dev && repaired.ino === published.ino && repaired.nlink === 1) return;
102
+ break;
103
+ }
104
+ // Another reader may have removed the matching temp link after our first stat.
105
+ const repaired = fs.lstatSync(file);
106
+ if (repaired.dev === published.dev && repaired.ino === published.ino && repaired.nlink === 1) return;
107
+ throw new CliError('NATIVE_CLI_JOURNAL_INVALID', 'Native CLI submission journal has an unresolved hard link');
108
+ }
109
+
110
+ function readRecord(directory, name) {
111
+ repairPublishedLink(directory, name);
112
+ const raw = readPrivateTextFile(path.join(directory, name), { maxBytes: 4096 });
113
+ if (raw === null) throw new CliError('NATIVE_CLI_JOURNAL_INVALID', 'Native CLI submission journal changed during inspection');
114
+ try { return JSON.parse(raw); }
115
+ catch { throw new CliError('NATIVE_CLI_JOURNAL_INVALID', 'Native CLI submission journal is invalid'); }
116
+ }
117
+
118
+ function optionalRecord(directory, name) {
119
+ try { fs.lstatSync(path.join(directory, name)); }
120
+ catch (error) { if (error.code === 'ENOENT') return null; throw error; }
121
+ return readRecord(directory, name);
122
+ }
123
+
124
+ function validatePrepared(record, id, root, meshDbRelative) {
125
+ if (record?.version !== 1 || record.submission_id !== id ||
126
+ record.root !== root || record.mesh_db_relative !== meshDbRelative ||
127
+ typeof record.peer !== 'string' || !record.peer || typeof record.from !== 'string' || !record.from ||
128
+ !(record.task_id === null || (Number.isSafeInteger(record.task_id) && record.task_id > 0)) ||
129
+ !/^[a-f0-9]{64}$/.test(record.body_sha256) ||
130
+ !Number.isFinite(Date.parse(record.created_at))) {
131
+ throw new CliError('NATIVE_CLI_JOURNAL_INVALID', 'Native CLI submission journal does not match this project');
132
+ }
133
+ return record;
134
+ }
135
+
136
+ export function prepareCliSubmission(ctx, input) {
137
+ const { directory, meshDbRelative } = journalDirectory(ctx, true);
138
+ const record = {
139
+ version: 1, submission_id: randomUUID(), root: fs.realpathSync(ctx.root), mesh_db_relative: meshDbRelative,
140
+ peer: input.peer, from: input.from, task_id: input.taskId || null,
141
+ body_sha256: createHash('sha256').update(input.body).digest('hex'), created_at: new Date().toISOString()
142
+ };
143
+ writeRecord(directory, `submission-${record.submission_id}.json`, record);
144
+ return record;
145
+ }
146
+
147
+ export function recordCliSubmissionReceipt(ctx, record, receipt) {
148
+ if (!Number.isSafeInteger(receipt?.message_id) || receipt.message_id < 1 ||
149
+ receipt.submission_id !== record.submission_id || typeof receipt.state !== 'string') {
150
+ throw new CliError('NATIVE_RESPONSE_INVALID', 'Native queue receipt does not match the prepared CLI submission',
151
+ { uncertain: true });
152
+ }
153
+ const { directory } = journalDirectory(ctx, true);
154
+ const name = `receipt-${record.submission_id}.json`;
155
+ const existing = optionalRecord(directory, name);
156
+ if (existing) {
157
+ if (existing.version !== 1 || existing.submission_id !== record.submission_id ||
158
+ existing.message_id !== receipt.message_id || typeof existing.state !== 'string') {
159
+ throw new CliError('NATIVE_CLI_JOURNAL_INVALID', 'Native CLI submission receipt conflicts with the saved receipt');
160
+ }
161
+ return;
162
+ }
163
+ try {
164
+ writeRecord(directory, name, {
165
+ version: 1, submission_id: record.submission_id, message_id: receipt.message_id, state: receipt.state
166
+ });
167
+ } catch (error) {
168
+ if (error.code !== 'NATIVE_CLI_JOURNAL_FAILED' || error.extra?.cause !== 'EEXIST') throw error;
169
+ const winner = optionalRecord(directory, name);
170
+ if (!winner) throw error;
171
+ if (winner.version !== 1 || winner.submission_id !== record.submission_id ||
172
+ winner.message_id !== receipt.message_id || typeof winner.state !== 'string') {
173
+ throw new CliError('NATIVE_CLI_JOURNAL_INVALID', 'Native CLI submission receipt conflicts with the saved receipt');
174
+ }
175
+ }
176
+ }
177
+
178
+ export function matchingCliSubmission(ctx, input) {
179
+ if (!UUID_VALUE.test(input.submissionId)) return null;
180
+ const journal = journalDirectory(ctx, false);
181
+ if (!journal) return null;
182
+ const { directory, meshDbRelative } = journal;
183
+ const saved = optionalRecord(directory, `submission-${input.submissionId}.json`);
184
+ if (!saved) return null;
185
+ const record = validatePrepared(saved, input.submissionId, fs.realpathSync(ctx.root), meshDbRelative);
186
+ if (record.peer !== input.peer || record.from !== input.from ||
187
+ record.task_id !== (input.taskId || null) ||
188
+ record.body_sha256 !== createHash('sha256').update(input.body).digest('hex')) {
189
+ throw new CliError('NATIVE_SUBMISSION_MISMATCH', 'Retry must keep the prepared CLI peer, sender, task and body');
190
+ }
191
+ return record;
192
+ }
193
+
194
+ export function listCliSubmissions(ctx, { peer = null } = {}) {
195
+ const journal = journalDirectory(ctx, false);
196
+ if (!journal) return [];
197
+ const { directory, meshDbRelative } = journal;
198
+ const root = fs.realpathSync(ctx.root);
199
+ const entries = [];
200
+ for (const name of fs.readdirSync(directory)) {
201
+ const match = name.match(PREPARED);
202
+ if (!match) continue;
203
+ const record = validatePrepared(readRecord(directory, name), match[1], root, meshDbRelative);
204
+ if (peer && record.peer !== peer) continue;
205
+ const receiptName = `receipt-${record.submission_id}.json`;
206
+ let receipt = optionalRecord(directory, receiptName);
207
+ if (receipt) {
208
+ if (receipt?.version !== 1 || receipt.submission_id !== record.submission_id ||
209
+ !Number.isSafeInteger(receipt.message_id) || receipt.message_id < 1 || typeof receipt.state !== 'string') {
210
+ throw new CliError('NATIVE_CLI_JOURNAL_INVALID', 'Native CLI submission receipt is invalid');
211
+ }
212
+ receipt = { message_id: receipt.message_id, submission_id: receipt.submission_id, state: receipt.state };
213
+ }
214
+ entries.push({ submission_id: record.submission_id, peer: record.peer, from: record.from,
215
+ task_id: record.task_id, body_sha256: record.body_sha256, created_at: record.created_at,
216
+ confirmation: receipt ? 'received' : 'unconfirmed', receipt });
217
+ }
218
+ return entries.sort((a, b) => b.created_at.localeCompare(a.created_at) ||
219
+ b.submission_id.localeCompare(a.submission_id));
220
+ }