@triflux/core 10.76.1 → 10.77.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@triflux/core",
3
- "version": "10.76.1",
3
+ "version": "10.77.0",
4
4
  "description": "triflux core — CLI routing, adapters. Zero native dependencies.",
5
5
  "type": "module",
6
6
  "main": "hub/index.mjs",
@@ -0,0 +1,578 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { resolveStableNodeBin } from "./stable-node.mjs";
5
+
6
+ const hash = (text) => createHash("sha256").update(text).digest("hex");
7
+ const quote = (value) => `'${value.replaceAll("'", "'\\''")}'`;
8
+ const read = (file) =>
9
+ fs.existsSync(file) ? fs.readFileSync(file, "utf8") : null;
10
+ const trustedTmuxPath = () =>
11
+ [
12
+ "/opt/homebrew/bin/tmux",
13
+ "/usr/local/bin/tmux",
14
+ "/usr/bin/tmux",
15
+ "/bin/tmux",
16
+ "/home/linuxbrew/.linuxbrew/bin/tmux",
17
+ ].find((file) => existing(file));
18
+ function trustedHelperPath(file) {
19
+ if (!file || !path.isAbsolute(file)) return false;
20
+ const base = path.dirname(path.dirname(file));
21
+ let resolved;
22
+ try {
23
+ resolved = fs.realpathSync(file);
24
+ } catch {
25
+ return false;
26
+ }
27
+ const safe = (target, alias = false) => {
28
+ const rel = path.relative(base, target);
29
+ if (rel.startsWith("..") || path.isAbsolute(rel)) return false;
30
+ let current = base;
31
+ const parts = rel.split(path.sep);
32
+ const baseMeta = existing(base);
33
+ if (
34
+ !baseMeta?.isDirectory() ||
35
+ ![0, process.getuid()].includes(baseMeta.uid) ||
36
+ (baseMeta.mode & 0o002) !== 0
37
+ )
38
+ return false;
39
+ for (let i = 0; i < parts.length; i++) {
40
+ current = path.join(current, parts[i]);
41
+ const meta = existing(current);
42
+ if (!meta || ![0, process.getuid()].includes(meta.uid)) return false;
43
+ if (meta.isSymbolicLink()) {
44
+ if (!alias || i !== parts.length - 1) return false;
45
+ continue;
46
+ }
47
+ if ((meta.mode & 0o002) !== 0) return false;
48
+ if (i < parts.length - 1 ? !meta.isDirectory() : !meta.isFile())
49
+ return false;
50
+ }
51
+ return true;
52
+ };
53
+ return (
54
+ safe(file, true) &&
55
+ safe(resolved) &&
56
+ (existing(resolved).mode & 0o111) !== 0
57
+ );
58
+ }
59
+ function trustedPackageTree(packageRoot, nodePath, tmuxPath) {
60
+ if (!trustedHelperPath(tmuxPath)) return false;
61
+ for (const dir of ["/usr/bin", "/bin", "/usr/sbin", "/sbin"]) {
62
+ let meta;
63
+ try {
64
+ meta = fs.statSync(dir);
65
+ } catch {
66
+ return false;
67
+ }
68
+ if (!meta.isDirectory() || meta.uid !== 0 || (meta.mode & 0o002) !== 0)
69
+ return false;
70
+ }
71
+ const prefix = path.dirname(path.dirname(nodePath));
72
+ try {
73
+ if (fs.realpathSync(packageRoot) !== packageRoot) return false;
74
+ } catch {
75
+ return false;
76
+ }
77
+ if (
78
+ path.resolve(packageRoot) !==
79
+ path.join(prefix, "lib", "node_modules", "triflux")
80
+ )
81
+ return false;
82
+ const safePath = (target, { base = prefix, finalLink = false } = {}) => {
83
+ const rel = path.relative(base, target);
84
+ if (rel.startsWith("..") || path.isAbsolute(rel)) return false;
85
+ let current = base;
86
+ const parts = rel ? ["", ...rel.split(path.sep)] : [""];
87
+ for (let index = 0; index < parts.length; index++) {
88
+ if (index) current = path.join(current, parts[index]);
89
+ const meta = existing(current);
90
+ if (!meta || ![0, process.getuid()].includes(meta.uid)) return false;
91
+ if (meta.isSymbolicLink()) {
92
+ if (!finalLink || index !== parts.length - 1) return false;
93
+ continue;
94
+ }
95
+ if ((meta.mode & 0o002) !== 0) return false;
96
+ if (index < parts.length - 1 && !meta.isDirectory()) return false;
97
+ }
98
+ return true;
99
+ };
100
+ let resolvedNode;
101
+ let resolvedPrefix;
102
+ try {
103
+ resolvedNode = fs.realpathSync(nodePath);
104
+ resolvedPrefix = fs.realpathSync(prefix);
105
+ } catch {
106
+ return false;
107
+ }
108
+ if (
109
+ !safePath(path.join(prefix, "lib", "node_modules")) ||
110
+ !safePath(nodePath, { finalLink: true }) ||
111
+ !safePath(resolvedNode, { base: resolvedPrefix }) ||
112
+ !existing(resolvedNode)?.isFile()
113
+ )
114
+ return false;
115
+ let ancestor = packageRoot;
116
+ while (true) {
117
+ if (existing(path.join(ancestor, ".git"))) return false;
118
+ const parent = path.dirname(ancestor);
119
+ if (parent === ancestor) break;
120
+ ancestor = parent;
121
+ }
122
+ const stack = [packageRoot];
123
+ while (stack.length) {
124
+ const current = stack.pop();
125
+ const meta = existing(current);
126
+ if (
127
+ !meta ||
128
+ meta.isSymbolicLink() ||
129
+ ![0, process.getuid()].includes(meta.uid) ||
130
+ (meta.mode & 0o002) !== 0
131
+ )
132
+ return false;
133
+ if (meta.isDirectory())
134
+ for (const child of fs.readdirSync(current))
135
+ stack.push(path.join(current, child));
136
+ else if (!meta.isFile()) return false;
137
+ }
138
+ return true;
139
+ }
140
+ function existing(file) {
141
+ try {
142
+ return fs.lstatSync(file);
143
+ } catch (error) {
144
+ if (error.code === "ENOENT") return null;
145
+ throw error;
146
+ }
147
+ }
148
+ function atomic(file, contents, mode = 0o600) {
149
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
150
+ const temp = `${file}.${process.pid}.${randomBytes(5).toString("hex")}.tmp`;
151
+ try {
152
+ fs.writeFileSync(temp, contents, { mode, flag: "wx" });
153
+ fs.renameSync(temp, file);
154
+ fs.chmodSync(file, mode);
155
+ } finally {
156
+ try {
157
+ fs.unlinkSync(temp);
158
+ } catch {}
159
+ }
160
+ }
161
+ function backup(file) {
162
+ if (!fs.existsSync(file)) return null;
163
+ const dest = `${file}.tfx-peer-backup-${Date.now()}-${randomBytes(4).toString("hex")}`;
164
+ fs.copyFileSync(file, dest, fs.constants.COPYFILE_EXCL);
165
+ fs.chmodSync(dest, 0o600);
166
+ return dest;
167
+ }
168
+ function keepOnlyLatestSettingsBackup(file, latest) {
169
+ if (!latest) return;
170
+ const prefix = `${path.basename(file)}.tfx-peer-backup-`;
171
+ for (const name of fs.readdirSync(path.dirname(file))) {
172
+ if (
173
+ !name.startsWith(prefix) ||
174
+ !/^\d{13}-[0-9a-f]{8}$/.test(name.slice(prefix.length))
175
+ )
176
+ continue;
177
+ const candidate = path.join(path.dirname(file), name);
178
+ const meta = existing(candidate);
179
+ if (
180
+ candidate !== latest &&
181
+ meta?.isFile() &&
182
+ meta.uid === process.getuid() &&
183
+ (meta.mode & 0o777) === 0o600
184
+ )
185
+ fs.unlinkSync(candidate);
186
+ }
187
+ }
188
+ export function peerPermissionPlan({
189
+ home,
190
+ packageRoot,
191
+ nodePath = resolveStableNodeBin(),
192
+ tmuxPath = trustedTmuxPath(),
193
+ }) {
194
+ if (
195
+ !path.isAbsolute(home) ||
196
+ !path.isAbsolute(packageRoot) ||
197
+ !path.isAbsolute(nodePath) ||
198
+ (tmuxPath && !path.isAbsolute(tmuxPath))
199
+ )
200
+ throw new Error("Absolute paths required");
201
+ const wrapper = path.join(home, ".claude", "scripts", "tfx-peer");
202
+ const entry = path.join(packageRoot, "bin", "tfx-peer.mjs");
203
+ const script = `#!/bin/sh\nunset NODE_OPTIONS NODE_PATH PSMUX_BIN PSMUX_SHELL DYLD_INSERT_LIBRARIES LD_PRELOAD LD_LIBRARY_PATH\nPATH=${quote(`${tmuxPath ? `${path.dirname(tmuxPath)}:` : ""}/usr/bin:/bin:/usr/sbin:/sbin`)}\nexport PATH\nr=${quote(packageRoot)}\n[ "$(cd -P "$r" 2>/dev/null && pwd -P)" = "$r" ] && [ ! -e "$r/.git" ] || { echo 'tfx-peer: untrusted package tree' >&2; exit 126; }\nexec ${quote(nodePath)} ${quote(entry)} "$@"\n`;
204
+ const allow = `Bash(${wrapper} send:*)`;
205
+ const codexRule = `prefix_rule(pattern=[${JSON.stringify(wrapper)}, "send"], decision="allow")\n`;
206
+ return {
207
+ entry,
208
+ wrapper,
209
+ script,
210
+ allow,
211
+ codexRule,
212
+ settings: path.join(home, ".claude", "settings.json"),
213
+ rule: path.join(home, ".codex", "rules", "triflux-native-peer.rules"),
214
+ manifest: path.join(home, ".claude", "scripts", "tfx-peer-consent.json"),
215
+ contract: hash(
216
+ JSON.stringify({
217
+ wrapper,
218
+ entry,
219
+ nodePath,
220
+ tmuxPath,
221
+ script,
222
+ allow,
223
+ codexRule,
224
+ }),
225
+ ),
226
+ };
227
+ }
228
+ export function inspectPeerPermission({
229
+ home,
230
+ packageRoot,
231
+ nodePath,
232
+ tmuxPath,
233
+ platform = process.platform,
234
+ } = {}) {
235
+ const plan = peerPermissionPlan({ home, packageRoot, nodePath, tmuxPath });
236
+ if (platform === "win32") return { status: "unsupported", plan };
237
+ const effectiveNodePath = nodePath || resolveStableNodeBin();
238
+ if (
239
+ !trustedPackageTree(
240
+ packageRoot,
241
+ effectiveNodePath,
242
+ tmuxPath || trustedTmuxPath(),
243
+ )
244
+ )
245
+ return { status: "unavailable", plan };
246
+ const boundaries = [
247
+ home,
248
+ path.join(home, ".claude"),
249
+ path.join(home, ".claude", "scripts"),
250
+ path.join(home, ".codex"),
251
+ path.join(home, ".codex", "rules"),
252
+ ];
253
+ if (
254
+ boundaries.some((dir) => {
255
+ const meta = existing(dir);
256
+ return (
257
+ meta &&
258
+ (!meta.isDirectory() ||
259
+ meta.isSymbolicLink() ||
260
+ meta.uid !== process.getuid() ||
261
+ (meta.mode & 0o022) !== 0)
262
+ );
263
+ })
264
+ )
265
+ return { status: "conflict", plan };
266
+ const entry = existing(plan.entry);
267
+ if (
268
+ !entry?.isFile() ||
269
+ entry.isSymbolicLink() ||
270
+ ![0, process.getuid()].includes(entry.uid) ||
271
+ entry.mode & 0o002
272
+ )
273
+ return { status: "unavailable", plan };
274
+ if (
275
+ [plan.wrapper, plan.settings, plan.rule, plan.manifest].some((file) => {
276
+ const meta = existing(file);
277
+ return (
278
+ meta &&
279
+ (!meta.isFile() ||
280
+ meta.isSymbolicLink() ||
281
+ meta.uid !== process.getuid() ||
282
+ (meta.mode & 0o022) !== 0 ||
283
+ (file === plan.wrapper && (meta.mode & 0o777) !== 0o700))
284
+ );
285
+ })
286
+ )
287
+ return { status: "conflict", plan };
288
+ const previous = read(plan.manifest);
289
+ let manifest = null;
290
+ try {
291
+ manifest = previous ? JSON.parse(previous) : null;
292
+ } catch {
293
+ return { status: "modified", plan };
294
+ }
295
+ if (!manifest)
296
+ return {
297
+ status:
298
+ existing(plan.wrapper) || existing(plan.rule)
299
+ ? "conflict"
300
+ : "unconfigured",
301
+ plan,
302
+ };
303
+ if (manifest.contract !== plan.contract)
304
+ return { status: "different-contract", plan };
305
+ const wrapper = read(plan.wrapper),
306
+ rule = read(plan.rule);
307
+ let settings;
308
+ try {
309
+ settings = JSON.parse(read(plan.settings) || "{}");
310
+ } catch {
311
+ return { status: "modified", plan };
312
+ }
313
+ if (
314
+ wrapper !== plan.script ||
315
+ rule !== plan.codexRule ||
316
+ !settings.permissions?.allow?.includes(plan.allow)
317
+ )
318
+ return { status: "modified", plan };
319
+ return { status: "approved", plan };
320
+ }
321
+ export function installPeerPermission({
322
+ home,
323
+ packageRoot,
324
+ nodePath,
325
+ tmuxPath,
326
+ consent = false,
327
+ platform = process.platform,
328
+ } = {}) {
329
+ const inspected = inspectPeerPermission({
330
+ home,
331
+ packageRoot,
332
+ nodePath,
333
+ tmuxPath,
334
+ platform,
335
+ });
336
+ const { plan, status } = inspected;
337
+ if (status === "approved") return { status: "approved", changed: false };
338
+ if (
339
+ status === "conflict" ||
340
+ status === "unavailable" ||
341
+ status === "unsupported"
342
+ )
343
+ return {
344
+ status,
345
+ changed: false,
346
+ error:
347
+ status === "unavailable"
348
+ ? "Trusted peer adapter entry is absent or unsafe"
349
+ : "Existing peer approval files are not owned by this exact consent contract",
350
+ };
351
+ if (!consent)
352
+ return {
353
+ status,
354
+ changed: false,
355
+ preview: {
356
+ wrapper: plan.wrapper,
357
+ settings: plan.settings,
358
+ rule: plan.rule,
359
+ trustsPackageRoot: packageRoot,
360
+ },
361
+ };
362
+ const lock = `${plan.manifest}.lock`;
363
+ fs.mkdirSync(path.dirname(lock), { recursive: true, mode: 0o700 });
364
+ let lockHandle;
365
+ try {
366
+ lockHandle = fs.openSync(lock, "wx", 0o600);
367
+ } catch {
368
+ return {
369
+ status: "busy",
370
+ changed: false,
371
+ error: "Peer approval install already in progress",
372
+ };
373
+ }
374
+ const lockInode = fs.fstatSync(lockHandle).ino;
375
+ try {
376
+ const fresh = inspectPeerPermission({
377
+ home,
378
+ packageRoot,
379
+ nodePath,
380
+ tmuxPath,
381
+ platform,
382
+ });
383
+ if (fresh.status !== status)
384
+ return {
385
+ status: "conflict",
386
+ changed: false,
387
+ error: "Peer approval changed before locked install",
388
+ };
389
+ const settingsBefore = read(plan.settings);
390
+ const settingsMeta = existing(plan.settings);
391
+ let settings;
392
+ try {
393
+ settings = JSON.parse(settingsBefore || "{}");
394
+ } catch {
395
+ return {
396
+ status: "failed",
397
+ changed: false,
398
+ error: "Claude settings JSON invalid",
399
+ };
400
+ }
401
+ if (
402
+ settings.permissions &&
403
+ !Array.isArray(settings.permissions.allow) &&
404
+ settings.permissions.allow !== undefined
405
+ )
406
+ return {
407
+ status: "failed",
408
+ changed: false,
409
+ error: "Claude allow is not an array",
410
+ };
411
+ if (
412
+ (existing(plan.wrapper) && read(plan.wrapper) !== plan.script) ||
413
+ (existing(plan.rule) && read(plan.rule) !== plan.codexRule)
414
+ )
415
+ return {
416
+ status: "conflict",
417
+ changed: false,
418
+ error:
419
+ "Existing peer wrapper or rule was modified; remove it explicitly before regrant",
420
+ };
421
+ const allow = new Set(settings.permissions?.allow || []);
422
+ allow.add(plan.allow);
423
+ settings.permissions = {
424
+ ...(settings.permissions || {}),
425
+ allow: [...allow],
426
+ };
427
+ const steps = [];
428
+ try {
429
+ if (
430
+ read(plan.settings) !== settingsBefore ||
431
+ existing(plan.settings)?.ino !== settingsMeta?.ino
432
+ )
433
+ return {
434
+ status: "conflict",
435
+ changed: false,
436
+ error: "Claude settings changed before install",
437
+ };
438
+ let settingsBackup = null;
439
+ for (const file of [
440
+ plan.wrapper,
441
+ plan.settings,
442
+ plan.rule,
443
+ plan.manifest,
444
+ ])
445
+ if (file === plan.settings) settingsBackup = backup(file);
446
+ else backup(file);
447
+ atomic(plan.wrapper, plan.script, 0o700);
448
+ steps.push("wrapper");
449
+ if (
450
+ read(plan.settings) !== settingsBefore ||
451
+ existing(plan.settings)?.ino !== settingsMeta?.ino
452
+ )
453
+ throw new Error(
454
+ "Claude settings changed during install; existing deny preserved",
455
+ );
456
+ atomic(plan.settings, `${JSON.stringify(settings, null, 2)}\n`);
457
+ steps.push("claude");
458
+ atomic(plan.rule, plan.codexRule);
459
+ steps.push("codex");
460
+ atomic(
461
+ plan.manifest,
462
+ `${JSON.stringify({ contract: plan.contract, packageRoot, wrapper: plan.wrapper, allow: plan.allow, rule: plan.rule }, null, 2)}\n`,
463
+ );
464
+ steps.push("manifest");
465
+ try {
466
+ keepOnlyLatestSettingsBackup(plan.settings, settingsBackup);
467
+ } catch {}
468
+ return { status: "approved", changed: true, steps };
469
+ } catch (error) {
470
+ return {
471
+ status: "partial",
472
+ changed: steps.length > 0,
473
+ retrySafe: false,
474
+ steps,
475
+ error: error.message,
476
+ };
477
+ }
478
+ } finally {
479
+ fs.closeSync(lockHandle);
480
+ try {
481
+ if (fs.lstatSync(lock).ino === lockInode) fs.unlinkSync(lock);
482
+ } catch {}
483
+ }
484
+ }
485
+ export async function offerPeerPermission({
486
+ home,
487
+ packageRoot,
488
+ interactive = false,
489
+ fromUpdate = false,
490
+ verbose = false,
491
+ ask = async () => false,
492
+ notice = () => {},
493
+ warn = () => {},
494
+ nodePath,
495
+ tmuxPath,
496
+ platform = process.platform,
497
+ } = {}) {
498
+ const state = inspectPeerPermission({
499
+ home,
500
+ packageRoot,
501
+ nodePath,
502
+ tmuxPath,
503
+ platform,
504
+ });
505
+ if (state.status === "unsupported")
506
+ return { status: "unsupported", changed: false };
507
+ if (state.status === "approved")
508
+ return { status: "approved", changed: false };
509
+ if (fromUpdate) {
510
+ if (verbose)
511
+ notice(
512
+ `Native peer adapter: ${state.status}; update는 승인 설정을 바꾸지 않는다. 재승인은 tfx setup에서 명시적으로 선택한다.`,
513
+ );
514
+ return { status: state.status, changed: false };
515
+ }
516
+ const cleanup = `확인 대상: ${state.plan.wrapper}, ${state.plan.rule}, ${state.plan.manifest}, ${state.plan.manifest}.lock. Claude ${state.plan.settings}의 permissions.allow에서는 ${state.plan.allow} 항목만 확인한다. settings 전체나 다른 allow/deny는 삭제하지 않는다.`;
517
+ if (state.status === "conflict" || state.status === "unavailable") {
518
+ if (state.status === "conflict")
519
+ warn(
520
+ `Peer 승인 파일이 충돌한다. ${cleanup} 해당 어댑터 항목만 직접 정리한 뒤 재동의한다. 자동 삭제하지 않는다.`,
521
+ );
522
+ else {
523
+ notice(
524
+ "신뢰할 수 있는 설치본과 Node 런타임 경로가 없어 native peer 전역 승인은 불가하다. 권한을 완화하지 않는다.",
525
+ );
526
+ notice(
527
+ "이미 수동 설정한 ~/.codex/rules/tfx-live.rules는 기존 tfx-live ask/queue/tmux 릴레이에 쓸 수 있다. native tfx-peer 전송이나 동일 승인으로 취급하지 않는다.",
528
+ );
529
+ if (existing(state.plan.manifest))
530
+ notice(
531
+ `기존 승인은 설정에 남아 있을 수 있다. Claude ${state.plan.allow}와 Codex ${state.plan.rule}을 확인해 수동 철회한다. 자동 삭제하지 않는다.`,
532
+ );
533
+ }
534
+ return { status: state.status, changed: false };
535
+ }
536
+ if (!interactive) {
537
+ notice(
538
+ "Native peer adapter 전역 승인은 선택 사항이다. tfx setup 에서 설정할 수 있다.",
539
+ );
540
+ return { status: "deferred", changed: false };
541
+ }
542
+ try {
543
+ const settings = JSON.parse(read(state.plan.settings) || "{}");
544
+ if (
545
+ settings.permissions?.classifyAllShell === true ||
546
+ settings.classifyAllShell === true
547
+ )
548
+ notice(
549
+ "Claude classifyAllShell=true이며 관리형 deny도 이 승인보다 우선할 수 있다. 기존 설정은 바꾸지 않는다.",
550
+ );
551
+ } catch {}
552
+ notice(
553
+ `승인 예정: ${state.plan.wrapper} send, ${state.plan.settings}, ${state.plan.rule}. 이 경로의 향후 Triflux 패키지 업데이트도 신뢰한다. Bash/node 전체 권한은 확대하지 않는다.`,
554
+ );
555
+ if (
556
+ !(await ask(
557
+ "Claude·Codex에서 tfx-peer send 어댑터만 전역 자동 승인할까요?",
558
+ ))
559
+ )
560
+ return { status: "declined", changed: false };
561
+ const result = installPeerPermission({
562
+ home,
563
+ packageRoot,
564
+ nodePath,
565
+ tmuxPath,
566
+ platform,
567
+ consent: true,
568
+ });
569
+ if (result.status === "busy")
570
+ warn(
571
+ `Peer adapter 승인 설치 잠금: ${state.plan.manifest}.lock. 실행 중인 설치가 없는지 확인한 뒤 직접 정리한다. 자동 삭제하지 않는다.`,
572
+ );
573
+ if (result.status === "conflict" || result.status === "partial")
574
+ warn(`Peer adapter 승인 ${result.status}: ${result.error}. ${cleanup}`);
575
+ else if (result.status === "failed")
576
+ warn(`Peer adapter 승인 실패: ${result.error}`);
577
+ return result;
578
+ }