@logicseek/hello-cc 1.1.0-rc.7 → 1.1.0-rc.9
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 +79 -0
- package/README.md +2 -0
- package/README.zh-CN.md +2 -0
- package/docs/README.md +2 -0
- package/docs/README.zh-CN.md +2 -0
- package/docs/commands.md +16 -0
- package/docs/commands.zh-CN.md +14 -0
- package/docs/dsh.md +6 -0
- package/docs/dsh.zh-CN.md +5 -0
- package/docs/native.md +21 -1
- package/docs/native.zh-CN.md +19 -1
- package/docs/private-state.md +115 -0
- package/docs/private-state.zh-CN.md +92 -0
- package/lib/cli/app.mjs +46 -5
- package/lib/cli/commands/hook.mjs +31 -4
- package/lib/cli/commands/mcp.mjs +6 -0
- package/lib/cli/commands/native.mjs +17 -4
- package/lib/cli/commands/peer.mjs +3 -0
- package/lib/cli/commands/run.mjs +196 -62
- package/lib/cli/commands/tmux.mjs +28 -19
- package/lib/cli/commands/up.mjs +25 -17
- package/lib/cli-runtime.mjs +16 -1
- package/lib/core/coordination/event-retention.mjs +2 -0
- package/lib/core/sessions/codex-thread-root.mjs +144 -0
- package/lib/core/sessions/launch.mjs +4 -0
- package/lib/discover.mjs +95 -231
- package/lib/integrations/dsh-collaboration.mjs +9 -2
- package/lib/integrations/native/claude.mjs +6 -0
- package/lib/integrations/native/codex.mjs +4 -2
- package/lib/integrations/native/dsh-acp.mjs +1 -1
- package/lib/integrations/native/jsonrpc.mjs +2 -2
- package/lib/mcp/scope.mjs +50 -8
- package/lib/mcp/tools.mjs +15 -2
- package/lib/process/pinned-cwd.mjs +64 -0
- package/lib/process/pinned-guidance.mjs +1 -0
- package/lib/process/pinned-project-state.mjs +1 -0
- package/lib/process/tmux-cwd-handoff.mjs +277 -0
- package/lib/runtime/native/cli-submissions.mjs +220 -0
- package/lib/runtime/native/client.mjs +7 -1
- package/lib/runtime/native/launcher.mjs +20 -4
- package/lib/runtime/native/service.mjs +143 -50
- package/lib/runtime/native/store.mjs +303 -14
- package/lib/runtime/private-state.mjs +435 -24
- package/lib/runtime/project-path.mjs +53 -1
- package/lib/runtime/project-trust.mjs +6 -2
- package/lib/runtime/state-migration.mjs +66 -13
- package/lib/shared/redact.mjs +4 -1
- package/lib/terminal/tmux.mjs +79 -11
- package/lib/ui/help.mjs +1 -0
- package/lib/web/codex-app-server.mjs +3 -1
- package/lib/web/codex-sessions.mjs +68 -8
- package/lib/web/context-files.mjs +194 -41
- package/lib/web/http-routes.mjs +43 -12
- package/lib/web/native-sessions.mjs +3 -0
- package/lib/web/project-contexts.mjs +27 -5
- package/lib/web/project-files.mjs +108 -26
- package/lib/web/pty-sessions.mjs +15 -2
- package/lib/web/runtime-main.mjs +2 -2
- package/lib/web/session-serialize.mjs +6 -1
- package/lib/web/startup.mjs +3 -1
- package/lib/web/tmux-sessions.mjs +156 -52
- package/lib/web/tmux-stream.mjs +71 -6
- package/lib/web/ui-interactions.mjs +7 -4
- package/native/bin/darwin-arm64/hcc-cwd-handoff +0 -0
- package/native/bin/darwin-x64/hcc-cwd-handoff +0 -0
- package/native/bin/linux-arm64/hcc-cwd-handoff +0 -0
- package/native/bin/linux-x64/hcc-cwd-handoff +0 -0
- package/native/cwd-handoff/README.md +45 -0
- package/native/cwd-handoff/hcc-cwd-handoff.c +693 -0
- package/package.json +8 -2
- package/scripts/build-cwd-handoff.mjs +47 -0
- package/scripts/pack-cwd-handoff.mjs +27 -0
- package/scripts/regression.mjs +116 -9
- package/scripts/verify-cwd-handoff-package.mjs +60 -0
- package/scripts/web-workbench-acceptance.mjs +79 -1
- package/scripts/web-workbench-installed-acceptance.mjs +11 -1
|
@@ -205,7 +205,7 @@ export function createDshAcpAdapter(options = {}) {
|
|
|
205
205
|
cwdBinding.assertUnchanged();
|
|
206
206
|
const config = {
|
|
207
207
|
binary: options.binary || 'dsh', args: ['--profile', 'acp'],
|
|
208
|
-
cwd, env: options.env, timeoutMs: options.timeoutMs,
|
|
208
|
+
cwd, env: options.env, expectedIdentity: cwdBinding, timeoutMs: options.timeoutMs,
|
|
209
209
|
onNotification, onRequest, onExit, onError: onTransportError
|
|
210
210
|
};
|
|
211
211
|
rpc = options.rpcFactory ? options.rpcFactory(config) : new JsonRpcProcess(config);
|
|
@@ -40,10 +40,10 @@ export class JsonRpcProcess {
|
|
|
40
40
|
|
|
41
41
|
async start() {
|
|
42
42
|
if (this.child || this.closed) throw new CliError('NATIVE_TRANSPORT_STATE', 'Transport cannot be started twice');
|
|
43
|
-
const { binary, args = [], cwd, env } = this.options;
|
|
43
|
+
const { binary, args = [], cwd, env, expectedIdentity } = this.options;
|
|
44
44
|
assertExecutableAvailable(binary, cwd || process.cwd(), env);
|
|
45
45
|
this.child = spawnPinnedCwdProcess(binary, args, {
|
|
46
|
-
cwd: cwd || process.cwd(), env, shell: false, stdio: ['pipe', 'pipe', 'pipe']
|
|
46
|
+
cwd: cwd || process.cwd(), env, expectedIdentity, shell: false, stdio: ['pipe', 'pipe', 'pipe']
|
|
47
47
|
});
|
|
48
48
|
this.child.stdout.setEncoding('utf8');
|
|
49
49
|
this.child.stdout.on('data', (chunk) => this.read(chunk));
|
package/lib/mcp/scope.mjs
CHANGED
|
@@ -6,6 +6,9 @@ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
7
|
import { CliError } from '../shared/errors.mjs';
|
|
8
8
|
import { inspectProcessIdentity } from '../process/identity.mjs';
|
|
9
|
+
import { assertSelectedCwdSnapshot, captureSelectedCwdSnapshot,
|
|
10
|
+
sameSelectedCwdIdentity } from '../process/selected-cwd-identity.mjs';
|
|
11
|
+
import { nativeOwnerGeneration } from '../runtime/native/store.mjs';
|
|
9
12
|
|
|
10
13
|
const PATH_KEY = 'HCC_MCP_BOOTSTRAP_PATH';
|
|
11
14
|
const TOKEN_KEY = 'HCC_MCP_BOOTSTRAP_TOKEN';
|
|
@@ -20,23 +23,54 @@ function validIdentity(value) {
|
|
|
20
23
|
function sameIdentity(a, b) {
|
|
21
24
|
return a?.pid === b?.pid && a?.startToken === b?.startToken && a?.commandHash === b?.commandHash;
|
|
22
25
|
}
|
|
26
|
+
function validRootIdentity(value) {
|
|
27
|
+
const identity = value?.identity;
|
|
28
|
+
return typeof value?.requested === 'string' && path.isAbsolute(value.requested) &&
|
|
29
|
+
typeof value.canonical === 'string' && path.isAbsolute(value.canonical) &&
|
|
30
|
+
typeof identity?.dev === 'string' && /^\d+$/.test(identity.dev) &&
|
|
31
|
+
typeof identity.ino === 'string' && /^\d+$/.test(identity.ino) &&
|
|
32
|
+
(identity.birthtimeNs === null ||
|
|
33
|
+
(typeof identity.birthtimeNs === 'string' && /^\d+$/.test(identity.birthtimeNs)));
|
|
34
|
+
}
|
|
35
|
+
function assertLaunchRoot(root, rootIdentity) {
|
|
36
|
+
if (!validRootIdentity(rootIdentity)) fail();
|
|
37
|
+
if (typeof rootIdentity.assertUnchanged === 'function') rootIdentity.assertUnchanged();
|
|
38
|
+
else assertSelectedCwdSnapshot(rootIdentity);
|
|
39
|
+
const selected = captureSelectedCwdSnapshot(root);
|
|
40
|
+
if (!sameSelectedCwdIdentity(rootIdentity, selected)) {
|
|
41
|
+
throw new CliError('PROJECT_PATH_CHANGED', 'Selected project directory changed');
|
|
42
|
+
}
|
|
43
|
+
return { requested: selected.canonical, canonical: selected.canonical,
|
|
44
|
+
identity: { ...rootIdentity.identity } };
|
|
45
|
+
}
|
|
46
|
+
function assertCapabilityRoot(receipt, root) {
|
|
47
|
+
if (!validRootIdentity(receipt) || receipt.requested !== root || receipt.canonical !== root) fail();
|
|
48
|
+
try { assertSelectedCwdSnapshot(receipt); }
|
|
49
|
+
catch { fail(); }
|
|
50
|
+
}
|
|
23
51
|
|
|
24
52
|
// Per executor only: callers pass this entry to its thread configuration. No
|
|
25
53
|
// user/global Codex configuration is read or written.
|
|
26
|
-
export function createScopedMcpConfig({ root, dbPath, peer, executorId, ownerIdentity,
|
|
54
|
+
export function createScopedMcpConfig({ root, dbPath, peer, executorId, ownerIdentity, rootIdentity,
|
|
27
55
|
binding = { transport: 'app-server', runtimeSessionId: peer },
|
|
28
56
|
cliPath = fileURLToPath(new URL('../../bin/hcc.mjs', import.meta.url)) }) {
|
|
29
57
|
if (typeof peer !== 'string' || !peer.trim() || peer.length > 256 || /[\r\n\0]/.test(peer) ||
|
|
30
58
|
typeof executorId !== 'string' || !executorId || executorId.length > 256 || !validIdentity(ownerIdentity) ||
|
|
31
|
-
(binding.transport === 'native' && (binding.runtimeTarget !== executorId || binding.runtimeSessionId !== peer
|
|
32
|
-
|
|
59
|
+
(binding.transport === 'native' && (binding.runtimeTarget !== executorId || binding.runtimeSessionId !== peer ||
|
|
60
|
+
!nativeOwnerGeneration(executorId)))) fail();
|
|
61
|
+
const launchRoot = assertLaunchRoot(root, rootIdentity);
|
|
62
|
+
const scope = { root: launchRoot.canonical, dbPath: canonical(dbPath), peer, executorId,
|
|
63
|
+
rootIdentity: launchRoot,
|
|
33
64
|
ownerIdentity: { ...ownerIdentity }, binding: { ...binding } };
|
|
65
|
+
if (typeof rootIdentity.assertUnchanged === 'function') rootIdentity.assertUnchanged();
|
|
66
|
+
assertCapabilityRoot(scope.rootIdentity, scope.root);
|
|
34
67
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'hcc-mcp-'));
|
|
35
68
|
fs.chmodSync(directory, 0o700);
|
|
36
69
|
const bootstrapPath = path.join(directory, 'scope.json');
|
|
37
70
|
const token = randomBytes(32).toString('base64url');
|
|
38
71
|
try {
|
|
39
|
-
|
|
72
|
+
assertCapabilityRoot(scope.rootIdentity, scope.root);
|
|
73
|
+
fs.writeFileSync(bootstrapPath, JSON.stringify({ version: 2, ...scope, tokenHash: digest(token) }),
|
|
40
74
|
{ flag: 'wx', mode: 0o600 });
|
|
41
75
|
} catch (error) { fs.rmSync(directory, { recursive: true, force: true }); throw error; }
|
|
42
76
|
let disposed = false;
|
|
@@ -64,13 +98,16 @@ export function loadScopedMcpBootstrap(ctx, peer, env = process.env) {
|
|
|
64
98
|
(process.platform !== 'win32' && (stat.mode & 0o077)) ||
|
|
65
99
|
(process.getuid && stat.uid !== process.getuid())) fail();
|
|
66
100
|
const value = JSON.parse(fs.readFileSync(fd, 'utf8'));
|
|
67
|
-
if (value.version !==
|
|
68
|
-
!timingSafeEqual(Buffer.from(value.tokenHash, 'hex'), Buffer.from(digest(token), 'hex'))
|
|
101
|
+
if (value.version !== 2 || typeof value.tokenHash !== 'string' || !/^[a-f0-9]{64}$/.test(value.tokenHash) ||
|
|
102
|
+
!timingSafeEqual(Buffer.from(value.tokenHash, 'hex'), Buffer.from(digest(token), 'hex'))) fail();
|
|
103
|
+
assertCapabilityRoot(value.rootIdentity, value.root);
|
|
104
|
+
if (
|
|
69
105
|
value.root !== canonical(ctx.root) || value.dbPath !== canonical(ctx.dbPath) ||
|
|
70
106
|
value.peer !== peer || !validIdentity(value.ownerIdentity) ||
|
|
71
107
|
typeof value.executorId !== 'string' || !value.executorId || !value.binding ||
|
|
72
108
|
typeof value.binding.transport !== 'string' ||
|
|
73
|
-
(value.binding.transport === 'native' && (value.binding.runtimeTarget !== value.executorId || value.binding.runtimeSessionId !== peer
|
|
109
|
+
(value.binding.transport === 'native' && (value.binding.runtimeTarget !== value.executorId || value.binding.runtimeSessionId !== peer ||
|
|
110
|
+
!nativeOwnerGeneration(value.executorId)))) fail();
|
|
74
111
|
return value;
|
|
75
112
|
} catch { fail(); } finally { if (fd !== undefined) fs.closeSync(fd); }
|
|
76
113
|
}
|
|
@@ -99,6 +136,11 @@ export function loadScopedMcpBootstrap(ctx, peer, env = process.env) {
|
|
|
99
136
|
}
|
|
100
137
|
}
|
|
101
138
|
assertValid();
|
|
139
|
+
const rootIdentity = Object.freeze({ requested: original.rootIdentity.requested,
|
|
140
|
+
canonical: original.rootIdentity.canonical,
|
|
141
|
+
identity: Object.freeze({ ...original.rootIdentity.identity }) });
|
|
102
142
|
return { scope: Object.freeze({ root: original.root, dbPath: original.dbPath, peer,
|
|
103
|
-
executorId: original.executorId
|
|
143
|
+
executorId: original.executorId, rootIdentity,
|
|
144
|
+
nativeOwnerGeneration: original.binding.transport === 'native' ? nativeOwnerGeneration(original.executorId) : null }),
|
|
145
|
+
assertValid, assertOwnership };
|
|
104
146
|
}
|
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 {
|
|
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
|
+
}
|