@triflux/core 10.53.0 → 10.55.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/hooks/codex-session-hook.mjs +1 -17
- package/hub/cli-adapter-base.mjs +3 -424
- package/hub/index.mjs +0 -5
- package/hub/lib/hosts-compat.mjs +18 -64
- package/hub/lib/private-tmp.mjs +35 -0
- package/hub/lib/process-utils.mjs +19 -220
- package/hub/lib/prompt-tmp.mjs +6 -24
- package/hub/lib/spawn-trace.mjs +112 -5
- package/hub/lib/timeout-defaults.mjs +34 -6
- package/hub/lib/worker-lifecycle.mjs +3 -0
- package/hub/platform.mjs +3 -1
- package/hub/team/agent-map.json +29 -0
- package/hub/team/intervention.mjs +6 -4
- package/hub/team/nativeProxy.mjs +2 -48
- package/hub/team/pane.mjs +329 -0
- package/hub/team/psmux.mjs +1765 -0
- package/hub/team/retry-state-machine.mjs +1 -1
- package/hub/team/session.mjs +541 -0
- package/hud/constants.mjs +0 -3
- package/hud/providers/claude.mjs +35 -15
- package/hud/providers/codex.mjs +169 -42
- package/hud/utils.mjs +0 -23
- package/package.json +2 -2
- package/scripts/lib/backup-rotation.mjs +60 -0
- package/scripts/lib/codex-profile-config.mjs +20 -7
- package/scripts/lib/codex-recovery.sh +2 -3
- package/scripts/lib/install-blob-history.json +3196 -0
- package/scripts/lib/install-retire.mjs +203 -0
- package/scripts/lib/legacy-hook-cleanup.mjs +148 -13
- package/scripts/lib/legacy-mcp-cleanup.mjs +405 -104
- package/scripts/lib/machine-profile.mjs +2 -2
- package/scripts/lib/mcp-filter.mjs +1 -42
- package/scripts/lib/mcp-guard-engine.mjs +198 -19
- package/scripts/lib/mcp-manifest.mjs +0 -40
- package/scripts/lib/process-utils.mjs +19 -0
- package/scripts/lib/stable-node.mjs +35 -0
- package/scripts/lib/test-env.mjs +20 -0
- package/scripts/lib/tmr-install.mjs +183 -0
- package/hooks/agy-session-hook.mjs +0 -155
- package/hooks/claude-cwd-projection-refresh.mjs +0 -11
- package/hooks/hook-orchestrator.mjs +0 -11
- package/hooks/lib/resolve-root.mjs +0 -62
- package/hub/codex-adapter.mjs +0 -193
- package/hub/codex-preflight.mjs +0 -219
- package/hub/lib/cto-env.mjs +0 -70
- package/scripts/lib/remote-spawn-transfer.mjs +0 -226
- package/scripts/lib/session-presence.mjs +0 -151
|
@@ -15,63 +15,82 @@ import {
|
|
|
15
15
|
writeFileSync,
|
|
16
16
|
} from "node:fs";
|
|
17
17
|
import { createRequire } from "node:module";
|
|
18
|
-
import { homedir, platform as osPlatform } from "node:os";
|
|
18
|
+
import { homedir, platform as osPlatform, userInfo } from "node:os";
|
|
19
19
|
import { basename, dirname, join, resolve } from "node:path";
|
|
20
20
|
import { isDeepStrictEqual } from "node:util";
|
|
21
|
+
import { loadRegistryOrDefault, pinnedArgs } from "./mcp-guard-engine.mjs";
|
|
21
22
|
|
|
22
23
|
const require = createRequire(import.meta.url);
|
|
23
24
|
const toml = require("@iarna/toml");
|
|
25
|
+
// 이주가 써 넣는 직접 연결 패키지. 버전은 이 표 한 곳에서만 올린다(2026-10-08 npm 최신, serena 는 v1.7.0 커밋).
|
|
26
|
+
export const MCP_PACKAGE_PINS = Object.freeze({
|
|
27
|
+
context7: "@upstash/context7-mcp@4.2.0",
|
|
28
|
+
"brave-search": "@brave/brave-search-mcp-server@2.1.4",
|
|
29
|
+
exa: "exa-mcp-server@3.4.2",
|
|
30
|
+
tavily: "tavily-mcp@0.2.22",
|
|
31
|
+
jira: "mcp-jira-cloud@4.4.0",
|
|
32
|
+
serena:
|
|
33
|
+
"git+https://github.com/oraios/serena@949a27ef1e5fda1a6e7b561e777bcece345c6ffd",
|
|
34
|
+
notion: "@notionhq/notion-mcp-server@2.5.2",
|
|
35
|
+
});
|
|
36
|
+
// gatewayCmd 는 옛 게이트웨이가 띄운 명령 그대로다. 남은 프로세스를 알아보는 데만 쓴다.
|
|
24
37
|
const SERVERS = [
|
|
25
|
-
["context7", 8100, "npx
|
|
38
|
+
["context7", 8100, "npx -y @upstash/context7-mcp@latest", []],
|
|
26
39
|
[
|
|
27
40
|
"brave-search",
|
|
28
41
|
8101,
|
|
29
|
-
"npx",
|
|
30
|
-
["-y", "@brave/brave-search-mcp-server"],
|
|
42
|
+
"npx -y @brave/brave-search-mcp-server",
|
|
31
43
|
["BRAVE_API_KEY"],
|
|
32
44
|
],
|
|
33
|
-
["exa", 8102, "npx
|
|
34
|
-
["tavily", 8103, "npx
|
|
45
|
+
["exa", 8102, "npx -y exa-mcp-server", ["EXA_API_KEY"]],
|
|
46
|
+
["tavily", 8103, "npx -y tavily-mcp@latest", ["TAVILY_API_KEY"]],
|
|
35
47
|
[
|
|
36
48
|
"jira",
|
|
37
49
|
8104,
|
|
38
|
-
"npx",
|
|
39
|
-
["-y", "mcp-jira-cloud@latest"],
|
|
50
|
+
"npx -y mcp-jira-cloud@latest",
|
|
40
51
|
["JIRA_API_TOKEN", "JIRA_EMAIL", "JIRA_INSTANCE_URL"],
|
|
41
52
|
],
|
|
42
53
|
[
|
|
43
54
|
"serena",
|
|
44
55
|
8105,
|
|
45
|
-
"uvx",
|
|
46
|
-
[
|
|
47
|
-
"--from",
|
|
48
|
-
"git+https://github.com/oraios/serena",
|
|
49
|
-
"serena",
|
|
50
|
-
"start-mcp-server",
|
|
51
|
-
],
|
|
56
|
+
"uvx --from git+https://github.com/oraios/serena serena start-mcp-server",
|
|
52
57
|
[],
|
|
53
58
|
],
|
|
54
|
-
[
|
|
55
|
-
"notion",
|
|
56
|
-
8106,
|
|
57
|
-
"npx",
|
|
58
|
-
["-y", "@notionhq/notion-mcp-server"],
|
|
59
|
-
["NOTION_TOKEN"],
|
|
60
|
-
],
|
|
59
|
+
["notion", 8106, "npx -y @notionhq/notion-mcp-server", ["NOTION_TOKEN"]],
|
|
61
60
|
[
|
|
62
61
|
"notion-guest",
|
|
63
62
|
8107,
|
|
64
|
-
"npx",
|
|
65
|
-
["-y", "@notionhq/notion-mcp-server"],
|
|
63
|
+
"npx -y @notionhq/notion-mcp-server",
|
|
66
64
|
["NOTION_TOKEN"],
|
|
67
65
|
],
|
|
68
|
-
].map(([name, port,
|
|
69
|
-
name
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
66
|
+
].map(([name, port, gatewayCmd, envVars]) => {
|
|
67
|
+
const pin = MCP_PACKAGE_PINS[name === "notion-guest" ? "notion" : name];
|
|
68
|
+
return {
|
|
69
|
+
name,
|
|
70
|
+
port,
|
|
71
|
+
gatewayCmd,
|
|
72
|
+
command: name === "serena" ? "uvx" : "npx",
|
|
73
|
+
args:
|
|
74
|
+
name === "serena"
|
|
75
|
+
? ["--from", pin, "serena", "start-mcp-server"]
|
|
76
|
+
: ["-y", pin],
|
|
77
|
+
envVars,
|
|
78
|
+
};
|
|
79
|
+
});
|
|
80
|
+
// 게이트웨이 이주와 허브 정리가 함께 쓰는 사용자 MCP 설정 파일. HOME 기준 경로와 형식.
|
|
81
|
+
// ~/.mcp.json 은 HOME 에서 Claude 를 열 때 읽는 파일이라 프로젝트 파일이 아니라 사용자 파일로 본다.
|
|
82
|
+
const USER_MCP_FILES = [
|
|
83
|
+
[".claude.json", "json"],
|
|
84
|
+
[".mcp.json", "json"],
|
|
85
|
+
[".claude/mcp.json", "json"],
|
|
86
|
+
[".claude/.mcp.json", "json"],
|
|
87
|
+
[".claude/settings.json", "json"],
|
|
88
|
+
[".codex/config.json", "json"],
|
|
89
|
+
[".codex/config.toml", "toml"],
|
|
90
|
+
[".gemini/settings.json", "json"],
|
|
91
|
+
// agy 는 \${VAR} 참조를 풀지 않으므로 env 없이 바꾸고 셸 환경을 물려받게 한다.
|
|
92
|
+
[".gemini/config/mcp_config.json", "agy"],
|
|
93
|
+
];
|
|
75
94
|
const BY_PORT = new Map(SERVERS.map((server) => [server.port, server]));
|
|
76
95
|
const LABEL = "com.tellang.mcp-gateway";
|
|
77
96
|
const TASK = "MCP Gateway";
|
|
@@ -94,6 +113,13 @@ function fileTarget(file) {
|
|
|
94
113
|
return target;
|
|
95
114
|
}
|
|
96
115
|
|
|
116
|
+
function markFirst(seen, target) {
|
|
117
|
+
const key = realpathSync(target);
|
|
118
|
+
if (seen.has(key)) return false;
|
|
119
|
+
seen.add(key);
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
|
|
97
123
|
function ownedUrl(value) {
|
|
98
124
|
if (typeof value !== "string") return null;
|
|
99
125
|
let url;
|
|
@@ -349,8 +375,11 @@ function backupStartup(home, file) {
|
|
|
349
375
|
return backupFile(join(directory, basename(file)), readFileSync(file));
|
|
350
376
|
}
|
|
351
377
|
|
|
352
|
-
|
|
353
|
-
|
|
378
|
+
// setup 한 번은 게이트웨이 이주와 허브 정리에 같은 backups 를 넘긴다.
|
|
379
|
+
// 같은 파일은 그 setup 에서 처음 바꾸기 직전의 원본만 백업한다.
|
|
380
|
+
function writeAtomic(file, target, output, backups = new Map()) {
|
|
381
|
+
const backup = backups.get(target) ?? backupFile(file);
|
|
382
|
+
backups.set(target, backup);
|
|
354
383
|
const temp = join(
|
|
355
384
|
dirname(target),
|
|
356
385
|
`.tfx-mcp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.tmp`,
|
|
@@ -600,9 +629,9 @@ function ownedCommand(command) {
|
|
|
600
629
|
"--healthEndpoint /healthz",
|
|
601
630
|
].every((flag) => command.includes(flag)) &&
|
|
602
631
|
SERVERS.some(
|
|
603
|
-
({ port,
|
|
632
|
+
({ port, gatewayCmd }) =>
|
|
604
633
|
new RegExp(`\\s--port\\s+${port}(?:\\s|$)`).test(command) &&
|
|
605
|
-
command.includes(
|
|
634
|
+
command.includes(gatewayCmd),
|
|
606
635
|
)
|
|
607
636
|
);
|
|
608
637
|
}
|
|
@@ -676,6 +705,7 @@ export function cleanupLegacyMcp({
|
|
|
676
705
|
uid = process.getuid?.(),
|
|
677
706
|
env = process.env,
|
|
678
707
|
dryRun = false,
|
|
708
|
+
backups,
|
|
679
709
|
} = {}) {
|
|
680
710
|
const result = {
|
|
681
711
|
ok: true,
|
|
@@ -713,21 +743,35 @@ export function cleanupLegacyMcp({
|
|
|
713
743
|
result.warnings.push("별도 HOME과 실제 작업 디렉터리가 섞여 이주를 중단함");
|
|
714
744
|
return result;
|
|
715
745
|
}
|
|
716
|
-
const files = [
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
[join(home, ".gemini/config/mcp_config.json"), "agy"],
|
|
723
|
-
];
|
|
746
|
+
const files = USER_MCP_FILES.map(([relative, kind]) => [
|
|
747
|
+
join(home, relative),
|
|
748
|
+
kind,
|
|
749
|
+
]);
|
|
750
|
+
if (resolve(repoRoot) !== resolve(home))
|
|
751
|
+
files.push([join(repoRoot, ".mcp.json"), "json"]);
|
|
724
752
|
const plans = [];
|
|
753
|
+
const kinds = new Map();
|
|
725
754
|
let blocked = false;
|
|
726
755
|
for (const [file, kind] of files) {
|
|
727
756
|
try {
|
|
728
757
|
const target = fileTarget(file);
|
|
729
758
|
if (!target) continue;
|
|
759
|
+
// symlink 별칭이 같은 파일을 두 번 계획하면 두 번째 쓰기가 원문 검증에서 막힌다.
|
|
760
|
+
// 형식이 다른 별칭(agy 와 json)은 env 처리가 달라 한쪽이 깨지므로 이주를 멈춘다.
|
|
761
|
+
const key = realpathSync(target);
|
|
762
|
+
if (kinds.has(key)) {
|
|
763
|
+
if (kinds.get(key) !== kind) {
|
|
764
|
+
result.warnings.push(
|
|
765
|
+
`${file}: 형식이 다른 설정과 같은 파일이라 이주를 멈춤`,
|
|
766
|
+
);
|
|
767
|
+
blocked = true;
|
|
768
|
+
}
|
|
769
|
+
continue;
|
|
770
|
+
}
|
|
771
|
+
kinds.set(key, kind);
|
|
730
772
|
const original = readFileSync(target, "utf8");
|
|
773
|
+
// 빈 파일은 항목이 없는 것이다. 형식 오류로 보면 이주 전체가 멈춘다.
|
|
774
|
+
if (!original.trim()) continue;
|
|
731
775
|
const plan =
|
|
732
776
|
kind === "toml"
|
|
733
777
|
? patchToml(original, file, home, env, result.warnings)
|
|
@@ -754,7 +798,9 @@ export function cleanupLegacyMcp({
|
|
|
754
798
|
readFileSync(plan.target, "utf8") !== plan.original
|
|
755
799
|
)
|
|
756
800
|
throw new Error(`${plan.file}: 검사 후 변경됨`);
|
|
757
|
-
result.backups.push(
|
|
801
|
+
result.backups.push(
|
|
802
|
+
writeAtomic(plan.file, plan.target, plan.output, backups),
|
|
803
|
+
);
|
|
758
804
|
result.changed = true;
|
|
759
805
|
}
|
|
760
806
|
} catch (error) {
|
|
@@ -784,15 +830,11 @@ export function cleanupLegacyMcp({
|
|
|
784
830
|
}
|
|
785
831
|
|
|
786
832
|
const HUB_SERVER = "tfx-hub";
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
["
|
|
791
|
-
["
|
|
792
|
-
[".codex/config.json", "json"],
|
|
793
|
-
[".codex/config.toml", "toml"],
|
|
794
|
-
[".gemini/settings.json", "json"],
|
|
795
|
-
[".gemini/config/mcp_config.json", "json"],
|
|
833
|
+
// [작업 이름, 그 작업이 허브용임을 보여 주는 실행 명령 조각]
|
|
834
|
+
// \Triflux\Hub 는 만든 코드가 이력에 없지만 지워진 `tfx hub ensure` 를 실행해 로그온마다 실패한다.
|
|
835
|
+
const HUB_TASKS = [
|
|
836
|
+
["TrifluxHubEnsure", "hub-ensure.mjs"],
|
|
837
|
+
["\\Triflux\\Hub", "tfx hub ensure"],
|
|
796
838
|
];
|
|
797
839
|
|
|
798
840
|
// 허브가 등록한 항목은 loopback 의 /mcp 주소다. 다른 모양이면 사용자 항목으로 본다.
|
|
@@ -899,32 +941,130 @@ function hubCommandLine(pid, platform, run) {
|
|
|
899
941
|
return platform !== "win32" && result.status === 1 ? null : undefined;
|
|
900
942
|
}
|
|
901
943
|
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
if (
|
|
906
|
-
|
|
944
|
+
// 명령줄을 argv 로 나눈다. Linux 는 /proc 의 실제 argv, Windows 는 따옴표 규칙을 쓴다.
|
|
945
|
+
// macOS ps 는 인자 경계를 보존하지 않아 공백으로 나누고 exact 를 false 로 둔다.
|
|
946
|
+
function processArgv(pid, command, platform) {
|
|
947
|
+
if (platform === "linux") {
|
|
948
|
+
try {
|
|
949
|
+
const argv = readFileSync(`/proc/${pid}/cmdline`, "utf8").split("\0");
|
|
950
|
+
if (argv.at(-1) === "") argv.pop();
|
|
951
|
+
if (argv.length) return { argv, exact: true };
|
|
952
|
+
} catch {
|
|
953
|
+
// 프로세스가 사라졌거나 /proc 를 읽을 수 없다.
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
if (platform === "win32") return splitWindowsCommandLine(command);
|
|
957
|
+
return { argv: command.trim().split(/\s+/), exact: false };
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
// 따옴표 밖 space, tab 에서만 끊고 따옴표 구간은 앞뒤 글자와 한 인자로 잇는다(Windows CRT 규칙).
|
|
961
|
+
// 백슬래시로 가린 따옴표는 규칙이 복잡해 경계를 믿지 않는다.
|
|
962
|
+
function splitWindowsCommandLine(command) {
|
|
963
|
+
const argv = [];
|
|
964
|
+
let current = "";
|
|
965
|
+
let quoted = false;
|
|
966
|
+
let started = false;
|
|
967
|
+
for (const char of command) {
|
|
968
|
+
if (char === '"') {
|
|
969
|
+
quoted = !quoted;
|
|
970
|
+
started = true;
|
|
971
|
+
} else if ((char === " " || char === "\t") && !quoted) {
|
|
972
|
+
if (started) argv.push(current);
|
|
973
|
+
current = "";
|
|
974
|
+
started = false;
|
|
975
|
+
} else {
|
|
976
|
+
current += char;
|
|
977
|
+
started = true;
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
if (started) argv.push(current);
|
|
981
|
+
return { argv, exact: !command.includes('\\"') };
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
// 허용 목록. 값을 받는 옵션(다음 인자 또는 --name=value)과 값이 없는 플래그만 건너뛴다.
|
|
985
|
+
// 그 밖의 옵션(--run, --eval, --test, --watch 같은 실행 모드 포함)은 entry 를 특정할 수 없어
|
|
986
|
+
// 허브가 아닌 것으로 본다.
|
|
987
|
+
const NODE_VALUE_OPTIONS = new Set([
|
|
988
|
+
"-r",
|
|
989
|
+
"--require",
|
|
990
|
+
"--import",
|
|
991
|
+
"--loader",
|
|
992
|
+
"--experimental-loader",
|
|
993
|
+
"--env-file",
|
|
994
|
+
"--input-type",
|
|
995
|
+
"-C",
|
|
996
|
+
"--conditions",
|
|
997
|
+
]);
|
|
998
|
+
const NODE_FLAG_OPTIONS = new Set([
|
|
999
|
+
"--no-warnings",
|
|
1000
|
+
"--enable-source-maps",
|
|
1001
|
+
"--trace-warnings",
|
|
1002
|
+
"--trace-uncaught",
|
|
1003
|
+
"--inspect",
|
|
1004
|
+
"--expose-gc",
|
|
1005
|
+
]);
|
|
1006
|
+
|
|
1007
|
+
// node 의 entry script 가 triflux 패키지의 hub/server.mjs 일 때만 패키지 루트를 돌려준다.
|
|
1008
|
+
// 같은 경로를 인자로 받거나 편집하는 다른 프로세스는 허브가 아니다.
|
|
1009
|
+
// argv 경계를 믿을 수 없으면(exact=false) 값이 붙는 옵션은 공백 든 값일 수 있어 판정하지 않는다.
|
|
1010
|
+
function trifluxHubRoot({ argv, exact }) {
|
|
1011
|
+
if (!/(?:^|[/\\])node(?:\.exe)?$/i.test(argv[0] ?? "")) return null;
|
|
1012
|
+
let index = 1;
|
|
1013
|
+
while (argv[index]?.startsWith("-")) {
|
|
1014
|
+
const [name, value] = argv[index].split("=");
|
|
1015
|
+
if (NODE_FLAG_OPTIONS.has(name) && value === undefined) index += 1;
|
|
1016
|
+
else if (exact && NODE_VALUE_OPTIONS.has(name))
|
|
1017
|
+
index += value === undefined ? 2 : 1;
|
|
1018
|
+
else return null;
|
|
1019
|
+
}
|
|
1020
|
+
// 공백이 든 경로가 잘린 조각은 상대 경로로 남으므로 절대 경로만 받는다.
|
|
1021
|
+
const entry = argv[index]?.match(
|
|
1022
|
+
/^((?:[A-Za-z]:)?[/\\].*)[/\\]hub[/\\]server\.mjs$/,
|
|
1023
|
+
);
|
|
1024
|
+
if (!entry) return null;
|
|
1025
|
+
// 공백 든 entry 가 잘린 조각일 수 있으므로 뒤에 인자가 남으면 판정하지 않는다.
|
|
1026
|
+
if (!exact && argv.length > index + 1) return null;
|
|
907
1027
|
try {
|
|
908
|
-
|
|
1028
|
+
const pkg = JSON.parse(
|
|
1029
|
+
readFileSync(join(entry[1], "package.json"), "utf8"),
|
|
1030
|
+
);
|
|
1031
|
+
return pkg?.name === "triflux" ? entry[1] : null;
|
|
909
1032
|
} catch {
|
|
910
|
-
|
|
1033
|
+
return null;
|
|
911
1034
|
}
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
function listHubProcesses(platform, run) {
|
|
1038
|
+
if (platform === "win32") {
|
|
1039
|
+
const result = runPowerShell(
|
|
1040
|
+
run,
|
|
1041
|
+
"$items=@(Get-CimInstance Win32_Process -Filter \"Name='node.exe'\" | ForEach-Object { @{ pid=[string]$_.ProcessId; command=$_.CommandLine } }); ConvertTo-Json -InputObject $items -Compress",
|
|
1042
|
+
);
|
|
1043
|
+
if (!result.ok) return null;
|
|
1044
|
+
return JSON.parse(result.output || "[]");
|
|
915
1045
|
}
|
|
916
|
-
const
|
|
917
|
-
if (
|
|
918
|
-
|
|
1046
|
+
const result = tryRun(run, "ps", ["-axo", "pid=,command="]);
|
|
1047
|
+
if (!result.ok) return null;
|
|
1048
|
+
return result.output.split(/\r?\n/).flatMap((line) => {
|
|
1049
|
+
const match = line.match(/^\s*(\d+)\s+(.+)$/);
|
|
1050
|
+
return match ? [{ pid: match[1], command: match[2] }] : [];
|
|
1051
|
+
});
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
// 허브는 제거됐으므로 triflux 패키지의 hub/server.mjs 로 확인된 프로세스는 설치본,
|
|
1055
|
+
// 체크아웃, worktree 를 가리지 않고 종료한다(2026-10-08 결정).
|
|
1056
|
+
function stopHubs(home, platform, run, result, log) {
|
|
1057
|
+
const processes = listHubProcesses(platform, run);
|
|
1058
|
+
if (!processes) {
|
|
1059
|
+
result.warnings.push("허브 프로세스 조회 실패, 종료하지 않음");
|
|
919
1060
|
return;
|
|
920
1061
|
}
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
}
|
|
1062
|
+
let allStopped = true;
|
|
1063
|
+
for (const { pid, command } of processes) {
|
|
1064
|
+
if (Number(pid) === process.pid || typeof command !== "string") continue;
|
|
1065
|
+
const root = trifluxHubRoot(processArgv(pid, command, platform));
|
|
1066
|
+
if (!root) continue;
|
|
1067
|
+
log(`허브 종료: pid ${pid} (${root})`);
|
|
928
1068
|
const stopped =
|
|
929
1069
|
platform === "win32"
|
|
930
1070
|
? tryRun(run, "taskkill", ["/F", "/T", "/PID", String(pid)])
|
|
@@ -935,38 +1075,67 @@ function stopHub(home, platform, run, result) {
|
|
|
935
1075
|
if (!gone)
|
|
936
1076
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100);
|
|
937
1077
|
}
|
|
938
|
-
if (
|
|
939
|
-
|
|
940
|
-
|
|
1078
|
+
if (gone) result.hubStopped = true;
|
|
1079
|
+
else {
|
|
1080
|
+
allStopped = false;
|
|
1081
|
+
result.warnings.push(`허브 pid ${pid}: 종료 확인 실패`);
|
|
941
1082
|
}
|
|
942
|
-
result.hubStopped = true;
|
|
943
1083
|
}
|
|
944
|
-
|
|
1084
|
+
const pidFile = join(home, ".claude/cache/tfx-hub/hub.pid");
|
|
1085
|
+
if (!allStopped || !fileTarget(pidFile)) return;
|
|
1086
|
+
// 판정을 못 했지만 허브로 보이는 프로세스가 hub.pid 에 있으면 단서를 남긴다.
|
|
1087
|
+
let pid;
|
|
1088
|
+
try {
|
|
1089
|
+
pid = Number(JSON.parse(readFileSync(pidFile, "utf8")).pid);
|
|
1090
|
+
} catch {
|
|
1091
|
+
/* 읽을 수 없는 pid 파일은 지운다 */
|
|
1092
|
+
}
|
|
1093
|
+
const command = Number.isInteger(pid)
|
|
1094
|
+
? hubCommandLine(pid, platform, run)
|
|
1095
|
+
: null;
|
|
1096
|
+
if (command && /[/\\]hub[/\\]server\.mjs/.test(command)) {
|
|
1097
|
+
result.warnings.push(`${pidFile}: pid ${pid} 를 허브로 확인하지 못해 보존`);
|
|
1098
|
+
return;
|
|
1099
|
+
}
|
|
1100
|
+
unlinkSync(pidFile);
|
|
945
1101
|
result.changed = true;
|
|
946
1102
|
}
|
|
947
1103
|
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
1104
|
+
// 작업 XML 에서 실행 동작만 꺼낸다. 설명 같은 다른 필드의 문구로 판정하지 않는다.
|
|
1105
|
+
function taskExecCommand(xml) {
|
|
1106
|
+
const decode = (text = "") =>
|
|
1107
|
+
text
|
|
1108
|
+
.replace(/"/g, '"')
|
|
1109
|
+
.replace(/'/g, "'")
|
|
1110
|
+
.replace(/</g, "<")
|
|
1111
|
+
.replace(/>/g, ">")
|
|
1112
|
+
.replace(/&/g, "&");
|
|
1113
|
+
const actions = xml.match(/<Actions\b[^>]*>([\s\S]*?)<\/Actions>/)?.[1] ?? "";
|
|
1114
|
+
const execs = [...actions.matchAll(/<Exec\b[^>]*>([\s\S]*?)<\/Exec>/g)];
|
|
1115
|
+
if (
|
|
1116
|
+
execs.length !== 1 ||
|
|
1117
|
+
/<(?:ComHandler|SendEmail|ShowMessage)\b/.test(actions)
|
|
1118
|
+
)
|
|
1119
|
+
return null;
|
|
1120
|
+
const field = (name) =>
|
|
1121
|
+
decode(
|
|
1122
|
+
execs[0][1].match(new RegExp(`<${name}>([\\s\\S]*?)</${name}>`))?.[1],
|
|
1123
|
+
);
|
|
1124
|
+
return `${field("Command")} ${field("Arguments")}`;
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
function removeHubTasks(run, result) {
|
|
1128
|
+
for (const [task, marker] of HUB_TASKS) {
|
|
1129
|
+
const query = tryRun(run, "schtasks.exe", ["/Query", "/TN", task, "/XML"]);
|
|
1130
|
+
if (!query.ok) continue;
|
|
1131
|
+
if (!taskExecCommand(query.output)?.includes(marker)) {
|
|
1132
|
+
result.warnings.push(`${task}: 허브 작업이 아니라 보존`);
|
|
1133
|
+
continue;
|
|
1134
|
+
}
|
|
1135
|
+
const removed = tryRun(run, "schtasks.exe", ["/Delete", "/TN", task, "/F"]);
|
|
1136
|
+
if (removed.ok) result.changed = true;
|
|
1137
|
+
else result.warnings.push(`${task}: 예약 작업 삭제 실패`);
|
|
961
1138
|
}
|
|
962
|
-
const removed = tryRun(run, "schtasks.exe", [
|
|
963
|
-
"/Delete",
|
|
964
|
-
"/TN",
|
|
965
|
-
HUB_TASK,
|
|
966
|
-
"/F",
|
|
967
|
-
]);
|
|
968
|
-
if (removed.ok) result.changed = true;
|
|
969
|
-
else result.warnings.push(`${HUB_TASK}: 예약 작업 삭제 실패`);
|
|
970
1139
|
}
|
|
971
1140
|
|
|
972
1141
|
/** cwd 의 프로젝트 MCP 파일 중 제거된 허브가 만든 tfx-hub 항목이 있는 파일을 돌려준다. */
|
|
@@ -988,6 +1157,8 @@ export function cleanupTfxHub({
|
|
|
988
1157
|
platform = osPlatform(),
|
|
989
1158
|
run = defaultRun,
|
|
990
1159
|
pluginRoot,
|
|
1160
|
+
log = () => {},
|
|
1161
|
+
backups,
|
|
991
1162
|
} = {}) {
|
|
992
1163
|
const result = {
|
|
993
1164
|
ok: true,
|
|
@@ -1006,12 +1177,14 @@ export function cleanupTfxHub({
|
|
|
1006
1177
|
result.skipped = true;
|
|
1007
1178
|
return result;
|
|
1008
1179
|
}
|
|
1009
|
-
|
|
1180
|
+
const seen = new Set();
|
|
1181
|
+
for (const [relative, kind] of USER_MCP_FILES) {
|
|
1010
1182
|
const file = join(home, relative);
|
|
1011
1183
|
try {
|
|
1012
1184
|
const target = fileTarget(file);
|
|
1013
|
-
if (!target) continue;
|
|
1185
|
+
if (!target || !markFirst(seen, target)) continue;
|
|
1014
1186
|
const original = readFileSync(target, "utf8");
|
|
1187
|
+
if (!original.trim()) continue;
|
|
1015
1188
|
const plan =
|
|
1016
1189
|
kind === "toml"
|
|
1017
1190
|
? removeHubFromToml(original, file, result.warnings)
|
|
@@ -1024,7 +1197,7 @@ export function cleanupTfxHub({
|
|
|
1024
1197
|
result.warnings.push(`${file}: 검사 후 변경되어 건너뜀`);
|
|
1025
1198
|
continue;
|
|
1026
1199
|
}
|
|
1027
|
-
result.backups.push(writeAtomic(file, target, plan.output));
|
|
1200
|
+
result.backups.push(writeAtomic(file, target, plan.output, backups));
|
|
1028
1201
|
result.removed += plan.count;
|
|
1029
1202
|
result.changed = true;
|
|
1030
1203
|
} catch {
|
|
@@ -1037,8 +1210,8 @@ export function cleanupTfxHub({
|
|
|
1037
1210
|
(resolve(home) === resolve(homedir()) && platform === osPlatform());
|
|
1038
1211
|
if (realSystem) {
|
|
1039
1212
|
try {
|
|
1040
|
-
|
|
1041
|
-
if (platform === "win32")
|
|
1213
|
+
stopHubs(home, platform, run, result, log);
|
|
1214
|
+
if (platform === "win32") removeHubTasks(run, result);
|
|
1042
1215
|
} catch (error) {
|
|
1043
1216
|
result.ok = false;
|
|
1044
1217
|
result.warnings.push(`허브 프로세스 정리 실패: ${error.message}`);
|
|
@@ -1064,3 +1237,131 @@ export function cleanupTfxHub({
|
|
|
1064
1237
|
}
|
|
1065
1238
|
return result;
|
|
1066
1239
|
}
|
|
1240
|
+
|
|
1241
|
+
// doctor 가 레지스트리와 비교하는 사용자 설정 파일. [HOME 기준 경로, 형식, 클라이언트]
|
|
1242
|
+
// 프로젝트의 .mcp.json 은 저장소에 커밋될 수 있어 setup 이 바꾸지 않는다(doctor --fix, mcp sync 몫).
|
|
1243
|
+
const REGISTRY_PIN_FILES = [
|
|
1244
|
+
[".claude.json", "json", "claude"],
|
|
1245
|
+
[".mcp.json", "json", "claude"],
|
|
1246
|
+
[".claude/mcp.json", "json", "claude"],
|
|
1247
|
+
[".codex/config.toml", "toml", "codex"],
|
|
1248
|
+
[".gemini/config/mcp_config.json", "json", "antigravity"],
|
|
1249
|
+
];
|
|
1250
|
+
|
|
1251
|
+
function pinJson(original, servers) {
|
|
1252
|
+
const data = JSON.parse(original);
|
|
1253
|
+
let count = 0;
|
|
1254
|
+
for (const [name, server] of servers) {
|
|
1255
|
+
const entry = data?.mcpServers?.[name];
|
|
1256
|
+
const args = pinnedArgs(entry, server);
|
|
1257
|
+
if (!args) continue;
|
|
1258
|
+
entry.args = args;
|
|
1259
|
+
count++;
|
|
1260
|
+
}
|
|
1261
|
+
return {
|
|
1262
|
+
output: count ? `${JSON.stringify(data, null, 2)}\n` : original,
|
|
1263
|
+
count,
|
|
1264
|
+
};
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
// [mcp_servers.<name>] 머리글 다음 줄부터 다음 표 머리글 전까지의 범위.
|
|
1268
|
+
function tomlSectionBody(text, name) {
|
|
1269
|
+
let offset = 0;
|
|
1270
|
+
let start = -1;
|
|
1271
|
+
for (const line of text.split(/(?<=\n)/)) {
|
|
1272
|
+
if (start >= 0 && /^\s*\[/.test(line)) return { start, end: offset };
|
|
1273
|
+
const match = line.match(
|
|
1274
|
+
/^\s*\[mcp_servers\.(?:"([^"]+)"|'([^']+)'|([A-Za-z0-9_-]+))\]\s*(?:#.*)?\r?\n?$/,
|
|
1275
|
+
);
|
|
1276
|
+
offset += line.length;
|
|
1277
|
+
if ((match?.[1] ?? match?.[2] ?? match?.[3]) === name) start = offset;
|
|
1278
|
+
}
|
|
1279
|
+
return start >= 0 ? { start, end: offset } : null;
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
function pinToml(original, servers) {
|
|
1283
|
+
const data = toml.parse(original);
|
|
1284
|
+
let output = original;
|
|
1285
|
+
let count = 0;
|
|
1286
|
+
for (const [name, server] of servers) {
|
|
1287
|
+
const args = pinnedArgs(data.mcp_servers?.[name], server);
|
|
1288
|
+
const section = args && tomlSectionBody(output, name);
|
|
1289
|
+
if (!section) continue;
|
|
1290
|
+
const body = output.slice(section.start, section.end);
|
|
1291
|
+
const next = body.replace(
|
|
1292
|
+
/^([ \t]*(?:args|"args"|'args')[ \t]*=[ \t]*)\[[^\]]*\]/m,
|
|
1293
|
+
`$1${JSON.stringify(args)}`,
|
|
1294
|
+
);
|
|
1295
|
+
if (next === body) continue;
|
|
1296
|
+
output = output.slice(0, section.start) + next + output.slice(section.end);
|
|
1297
|
+
data.mcp_servers[name].args = args;
|
|
1298
|
+
count++;
|
|
1299
|
+
}
|
|
1300
|
+
if (count && !isDeepStrictEqual(toml.parse(output), data))
|
|
1301
|
+
throw new Error("TOML 구조 불일치");
|
|
1302
|
+
return { output, count };
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
/** 레지스트리 stdio 항목 중 패키지 버전만 다른 것을 레지스트리 고정 버전으로 바꾼다. */
|
|
1306
|
+
export function pinRegistryMcpPackages({
|
|
1307
|
+
home = homedir(),
|
|
1308
|
+
registry = loadRegistryOrDefault(),
|
|
1309
|
+
backups,
|
|
1310
|
+
} = {}) {
|
|
1311
|
+
const result = {
|
|
1312
|
+
ok: true,
|
|
1313
|
+
changed: false,
|
|
1314
|
+
pinned: 0,
|
|
1315
|
+
backups: [],
|
|
1316
|
+
warnings: [],
|
|
1317
|
+
};
|
|
1318
|
+
// 테스트가 실제 홈의 설정을 바꾸지 않게 한다.
|
|
1319
|
+
if (
|
|
1320
|
+
process.env.NODE_TEST_CONTEXT &&
|
|
1321
|
+
resolve(home) === resolve(userInfo().homedir)
|
|
1322
|
+
) {
|
|
1323
|
+
result.skipped = true;
|
|
1324
|
+
return result;
|
|
1325
|
+
}
|
|
1326
|
+
const stdio = Object.entries(registry?.servers ?? {}).filter(
|
|
1327
|
+
([, server]) =>
|
|
1328
|
+
typeof server?.command === "string" && Array.isArray(server.args),
|
|
1329
|
+
);
|
|
1330
|
+
const seen = new Set();
|
|
1331
|
+
for (const [relative, kind, client] of REGISTRY_PIN_FILES) {
|
|
1332
|
+
const servers = stdio.filter(([, server]) =>
|
|
1333
|
+
// mcp-guard-engine 의 serverTargets 처럼 빈 targets 는 기본 세 클라이언트다.
|
|
1334
|
+
(server.targets?.length
|
|
1335
|
+
? server.targets
|
|
1336
|
+
: ["claude", "codex", "antigravity"]
|
|
1337
|
+
).includes(client),
|
|
1338
|
+
);
|
|
1339
|
+
if (!servers.length) continue;
|
|
1340
|
+
const file = join(home, relative);
|
|
1341
|
+
try {
|
|
1342
|
+
const target = fileTarget(file);
|
|
1343
|
+
if (!target || !markFirst(seen, target)) continue;
|
|
1344
|
+
const original = readFileSync(target, "utf8");
|
|
1345
|
+
if (!original.trim()) continue;
|
|
1346
|
+
const plan =
|
|
1347
|
+
kind === "toml"
|
|
1348
|
+
? pinToml(original, servers)
|
|
1349
|
+
: pinJson(original, servers);
|
|
1350
|
+
if (!plan.count) continue;
|
|
1351
|
+
if (
|
|
1352
|
+
fileTarget(file) !== target ||
|
|
1353
|
+
readFileSync(target, "utf8") !== original
|
|
1354
|
+
) {
|
|
1355
|
+
result.warnings.push(`${file}: 검사 후 변경되어 건너뜀`);
|
|
1356
|
+
continue;
|
|
1357
|
+
}
|
|
1358
|
+
result.backups.push(writeAtomic(file, target, plan.output, backups));
|
|
1359
|
+
result.pinned += plan.count;
|
|
1360
|
+
result.changed = true;
|
|
1361
|
+
} catch {
|
|
1362
|
+
result.warnings.push(`${file}: MCP 버전 고정 반영 실패, 파일 보존`);
|
|
1363
|
+
result.ok = false;
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
return result;
|
|
1367
|
+
}
|
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
// 신뢰 경계: 파일을 source 하거나 eval 하지 않는다. allowlist key 와 안전한
|
|
8
8
|
// literal 값만 읽으므로 셸 표현식은 실행되지 않는다.
|
|
9
9
|
//
|
|
10
|
-
// 부수효과: 모듈 로드 시 파일을 읽지 않고 환경도 바꾸지 않는다.
|
|
11
|
-
//
|
|
10
|
+
// 부수효과: 모듈 로드 시 파일을 읽지 않고 환경도 바꾸지 않는다. 순수 함수만
|
|
11
|
+
// 노출하므로 HUD, hook, hub 어느 소비자에서도 안전하게 import 한다.
|
|
12
12
|
|
|
13
13
|
import { readFileSync } from "node:fs";
|
|
14
14
|
import { homedir } from "node:os";
|