triflux 9.5.1 → 9.7.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/.claude-plugin/plugin.json +1 -1
- package/bin/triflux.mjs +498 -0
- package/hooks/hook-registry.json +24 -2
- package/hooks/mcp-config-watcher.mjs +85 -0
- package/hub/team/headless.mjs +8 -1
- package/hub/team/psmux.mjs +24 -4
- package/package.json +1 -1
- package/scripts/__tests__/mcp-guard-engine.test.mjs +118 -0
- package/scripts/headless-guard.mjs +1 -1
- package/scripts/lib/mcp-guard-engine.mjs +940 -0
- package/scripts/mcp-safety-guard.mjs +44 -0
- package/scripts/setup.mjs +71 -0
- package/scripts/tfx-route.sh +17 -5
- package/skills/tfx-analysis/SKILL.md +4 -0
- package/skills/tfx-auto/SKILL.md +4 -0
- package/skills/tfx-auto-codex/SKILL.md +4 -0
- package/skills/tfx-autopilot/SKILL.md +4 -0
- package/skills/tfx-autoresearch/SKILL.md +4 -0
- package/skills/tfx-autoroute/SKILL.md +4 -0
- package/skills/tfx-codex/SKILL.md +4 -0
- package/skills/tfx-codex-swarm/SKILL.md +33 -2
- package/skills/tfx-consensus/SKILL.md +4 -0
- package/skills/tfx-debate/SKILL.md +4 -0
- package/skills/tfx-deep-analysis/SKILL.md +4 -0
- package/skills/tfx-deep-interview/SKILL.md +4 -0
- package/skills/tfx-deep-plan/SKILL.md +4 -0
- package/skills/tfx-deep-qa/SKILL.md +4 -0
- package/skills/tfx-deep-research/SKILL.md +4 -0
- package/skills/tfx-deep-review/SKILL.md +4 -0
- package/skills/tfx-doctor/SKILL.md +3 -0
- package/skills/tfx-find/SKILL.md +4 -0
- package/skills/tfx-forge/SKILL.md +4 -0
- package/skills/tfx-fullcycle/SKILL.md +4 -0
- package/skills/tfx-gemini/SKILL.md +4 -0
- package/skills/tfx-hub/SKILL.md +4 -0
- package/skills/tfx-index/SKILL.md +4 -0
- package/skills/tfx-interview/SKILL.md +4 -0
- package/skills/tfx-multi/SKILL.md +4 -0
- package/skills/tfx-panel/SKILL.md +4 -0
- package/skills/tfx-persist/SKILL.md +4 -0
- package/skills/tfx-plan/SKILL.md +4 -0
- package/skills/tfx-prune/SKILL.md +4 -0
- package/skills/tfx-qa/SKILL.md +4 -0
- package/skills/tfx-ralph/SKILL.md +4 -0
- package/skills/tfx-remote-setup/SKILL.md +4 -0
- package/skills/tfx-remote-spawn/SKILL.md +4 -0
- package/skills/tfx-research/SKILL.md +4 -0
- package/skills/tfx-review/SKILL.md +4 -0
package/hub/team/headless.mjs
CHANGED
|
@@ -243,10 +243,12 @@ function dispatchBatch(sessionName, assignments, opts = {}) {
|
|
|
243
243
|
* @returns {Promise<Array<{d, completion, output}>>}
|
|
244
244
|
*/
|
|
245
245
|
async function awaitAll(sessionName, dispatches, timeoutSec, safeProgress, progressIntervalSec) {
|
|
246
|
+
const ac = new AbortController();
|
|
247
|
+
|
|
246
248
|
// 병렬 대기 (Promise.all — 모든 pane 동시 폴링, 총 시간 = max(개별 시간))
|
|
247
249
|
return Promise.all(dispatches.map(async (d) => {
|
|
248
250
|
// onPoll → onProgress 변환 (throttle by progressIntervalSec)
|
|
249
|
-
const pollOpts = {};
|
|
251
|
+
const pollOpts = { signal: ac.signal };
|
|
250
252
|
if (safeProgress && progressIntervalSec > 0) {
|
|
251
253
|
let lastProgressAt = 0;
|
|
252
254
|
const intervalMs = progressIntervalSec * 1000;
|
|
@@ -268,6 +270,11 @@ async function awaitAll(sessionName, dispatches, timeoutSec, safeProgress, progr
|
|
|
268
270
|
if (d.logPath) pollOpts.logPath = d.logPath;
|
|
269
271
|
const completion = await waitForCompletion(sessionName, d.paneId || d.paneName, d.token, timeoutSec, pollOpts);
|
|
270
272
|
|
|
273
|
+
// 세션 사망 감지 시 나머지 워커 폴링 즉시 중단
|
|
274
|
+
if (completion.sessionDead && !ac.signal.aborted) {
|
|
275
|
+
ac.abort();
|
|
276
|
+
}
|
|
277
|
+
|
|
271
278
|
const output = completion.matched
|
|
272
279
|
? readResult(d.resultFile, d.paneId)
|
|
273
280
|
: "";
|
package/hub/team/psmux.mjs
CHANGED
|
@@ -570,7 +570,7 @@ function killOrphanPipeHelpers(sessionName) {
|
|
|
570
570
|
const safeSession = sanitizePathPart(sessionName);
|
|
571
571
|
try {
|
|
572
572
|
const output = childProcess.execSync(
|
|
573
|
-
`powershell -NoProfile -WindowStyle Hidden -Command "$ErrorActionPreference='SilentlyContinue'; Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -match 'pipe-pane-capture' -and $_.CommandLine -match '${safeSession}' } | Select-Object -ExpandProperty ProcessId"`,
|
|
573
|
+
`powershell -NoProfile -WindowStyle Hidden -Command "$ErrorActionPreference='SilentlyContinue'; Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -match 'pipe-pane-capture' -and $_.CommandLine -match 'tfx-headless[/\\\\]${safeSession}' } | Select-Object -ExpandProperty ProcessId"`,
|
|
574
574
|
{ encoding: "utf8", timeout: 8000, stdio: ["pipe", "pipe", "pipe"], windowsHide: true },
|
|
575
575
|
);
|
|
576
576
|
const pids = output.split(/\r?\n/).map((l) => Number.parseInt(l.trim(), 10)).filter((p) => Number.isFinite(p) && p > 0);
|
|
@@ -605,7 +605,7 @@ function killOrphanMcpProcesses(sessionName) {
|
|
|
605
605
|
try {
|
|
606
606
|
// 세션 결과 디렉토리 패턴으로 MCP 서버 프로세스 식별
|
|
607
607
|
const output = childProcess.execSync(
|
|
608
|
-
`powershell -NoProfile -WindowStyle Hidden -Command "$ErrorActionPreference='SilentlyContinue'; Get-CimInstance Win32_Process | Where-Object { $_.Name -eq 'node.exe' -and $_.CommandLine -match '${safeSession}' } | Select-Object -ExpandProperty ProcessId"`,
|
|
608
|
+
`powershell -NoProfile -WindowStyle Hidden -Command "$ErrorActionPreference='SilentlyContinue'; Get-CimInstance Win32_Process | Where-Object { $_.Name -eq 'node.exe' -and $_.CommandLine -match 'tfx-headless[/\\\\]${safeSession}' } | Select-Object -ExpandProperty ProcessId"`,
|
|
609
609
|
{ encoding: "utf8", timeout: 8000, stdio: ["pipe", "pipe", "pipe"], windowsHide: true },
|
|
610
610
|
);
|
|
611
611
|
const pids = output.split(/\r?\n/).map((l) => Number.parseInt(l.trim(), 10)).filter((p) => Number.isFinite(p) && p > 0 && p !== hubPid);
|
|
@@ -632,6 +632,9 @@ export function killPsmuxSession(sessionName) {
|
|
|
632
632
|
}
|
|
633
633
|
disableAllPipeCaptures(sessionName, paneIds);
|
|
634
634
|
|
|
635
|
+
// pipe-pane reader가 EOF를 처리하고 정상 종료할 시간 확보
|
|
636
|
+
sleepMs(500);
|
|
637
|
+
|
|
635
638
|
// 2. pane 프로세스 트리 강제 종료 (MCP 서버 포함)
|
|
636
639
|
const pids = collectPanePids(sessionName);
|
|
637
640
|
for (const pid of pids) {
|
|
@@ -856,7 +859,8 @@ export function dispatchCommand(sessionName, paneNameOrTarget, commandText) {
|
|
|
856
859
|
* @param {number} timeoutSec
|
|
857
860
|
* @param {object} [opts]
|
|
858
861
|
* @param {(snapshot: {content: string, paneId: string, paneName: string, elapsed: number}) => void} [opts.onPoll] — 각 폴링 주기마다 호출
|
|
859
|
-
* @
|
|
862
|
+
* @param {AbortSignal} [opts.signal] — 외부에서 폴링 중단 요청 시 사용
|
|
863
|
+
* @returns {{ matched: boolean, paneId: string, paneName: string, logPath: string, match: string|null, aborted?: boolean }}
|
|
860
864
|
*/
|
|
861
865
|
export async function waitForPattern(sessionName, paneNameOrTarget, pattern, timeoutSec = 300, opts = {}) {
|
|
862
866
|
ensurePsmuxInstalled();
|
|
@@ -892,7 +896,14 @@ export async function waitForPattern(sessionName, paneNameOrTarget, pattern, tim
|
|
|
892
896
|
const deadline = startTime + Math.max(0, Math.trunc(timeoutSec * 1000));
|
|
893
897
|
const regex = toPatternRegExp(pattern);
|
|
894
898
|
|
|
899
|
+
if (opts?.signal?.aborted) {
|
|
900
|
+
return { matched: false, paneId: pane.paneId, paneName, logPath, match: null, aborted: true };
|
|
901
|
+
}
|
|
902
|
+
|
|
895
903
|
while (Date.now() <= deadline) {
|
|
904
|
+
if (opts?.signal?.aborted) {
|
|
905
|
+
return { matched: false, paneId: pane.paneId, paneName, logPath, match: null, aborted: true };
|
|
906
|
+
}
|
|
896
907
|
// E4 크래시 복구: capture 실패 시 세션 생존 체크
|
|
897
908
|
try {
|
|
898
909
|
if (opts.logPath) {
|
|
@@ -940,6 +951,9 @@ export async function waitForPattern(sessionName, paneNameOrTarget, pattern, tim
|
|
|
940
951
|
break;
|
|
941
952
|
}
|
|
942
953
|
await sleepMsAsync(POLL_INTERVAL_MS);
|
|
954
|
+
if (opts?.signal?.aborted) {
|
|
955
|
+
return { matched: false, paneId: pane.paneId, paneName, logPath, match: null, aborted: true };
|
|
956
|
+
}
|
|
943
957
|
}
|
|
944
958
|
|
|
945
959
|
return {
|
|
@@ -1112,7 +1126,13 @@ export function killWorker(sessionName, workerName) {
|
|
|
1112
1126
|
// send-keys 실패 무시
|
|
1113
1127
|
}
|
|
1114
1128
|
|
|
1115
|
-
|
|
1129
|
+
try {
|
|
1130
|
+
psmuxExec(["send-keys", "-t", paneId, "exit", "Enter"]);
|
|
1131
|
+
} catch {
|
|
1132
|
+
// send-keys 실패 무시
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
sleepMs(2000);
|
|
1116
1136
|
|
|
1117
1137
|
try {
|
|
1118
1138
|
psmuxExec(["kill-pane", "-t", paneId]);
|
package/package.json
CHANGED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { dirname, join, resolve } from "node:path";
|
|
5
|
+
import { describe, it, afterEach } from "node:test";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
isWatchedPath,
|
|
10
|
+
loadRegistry,
|
|
11
|
+
remediate,
|
|
12
|
+
resolveHubUrl,
|
|
13
|
+
scanForStdioServers,
|
|
14
|
+
} from "../lib/mcp-guard-engine.mjs";
|
|
15
|
+
|
|
16
|
+
const TEST_DIR = dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
const PROJECT_ROOT = resolve(TEST_DIR, "..", "..");
|
|
18
|
+
const originalHome = {
|
|
19
|
+
HOME: process.env.HOME,
|
|
20
|
+
USERPROFILE: process.env.USERPROFILE,
|
|
21
|
+
TFX_HUB_PORT: process.env.TFX_HUB_PORT,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function createHomeDir(prefix = "mcp-guard-") {
|
|
25
|
+
const base = join(tmpdir(), `${prefix}${Date.now()}-${Math.random().toString(16).slice(2)}`);
|
|
26
|
+
mkdirSync(base, { recursive: true });
|
|
27
|
+
mkdirSync(join(base, ".gemini"), { recursive: true });
|
|
28
|
+
mkdirSync(join(base, ".claude", "cache", "tfx-hub"), { recursive: true });
|
|
29
|
+
mkdirSync(join(base, ".codex"), { recursive: true });
|
|
30
|
+
return base;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function withHome(homeDir) {
|
|
34
|
+
process.env.HOME = homeDir;
|
|
35
|
+
process.env.USERPROFILE = homeDir;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
if (originalHome.HOME === undefined) delete process.env.HOME;
|
|
40
|
+
else process.env.HOME = originalHome.HOME;
|
|
41
|
+
|
|
42
|
+
if (originalHome.USERPROFILE === undefined) delete process.env.USERPROFILE;
|
|
43
|
+
else process.env.USERPROFILE = originalHome.USERPROFILE;
|
|
44
|
+
|
|
45
|
+
if (originalHome.TFX_HUB_PORT === undefined) delete process.env.TFX_HUB_PORT;
|
|
46
|
+
else process.env.TFX_HUB_PORT = originalHome.TFX_HUB_PORT;
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("mcp guard engine", () => {
|
|
50
|
+
it("loads the MCP registry", () => {
|
|
51
|
+
const registry = loadRegistry();
|
|
52
|
+
assert.equal(registry.version, 1);
|
|
53
|
+
assert.equal(registry.servers["tfx-hub"].url, "http://127.0.0.1:27888/mcp");
|
|
54
|
+
assert.equal(registry.policies.watched_paths.length, 5);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("matches watched paths for Gemini and local .mcp.json", () => {
|
|
58
|
+
const homeDir = createHomeDir();
|
|
59
|
+
withHome(homeDir);
|
|
60
|
+
|
|
61
|
+
assert.equal(isWatchedPath(join(homeDir, ".gemini", "settings.json")), true);
|
|
62
|
+
assert.equal(isWatchedPath(join(PROJECT_ROOT, "nested", ".mcp.json")), true);
|
|
63
|
+
assert.equal(isWatchedPath(join(PROJECT_ROOT, "nested", "settings.yaml")), false);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("detects stdio MCP servers from JSON config", () => {
|
|
67
|
+
const homeDir = createHomeDir();
|
|
68
|
+
withHome(homeDir);
|
|
69
|
+
|
|
70
|
+
const settingsPath = join(homeDir, ".gemini", "settings.json");
|
|
71
|
+
writeFileSync(settingsPath, JSON.stringify({
|
|
72
|
+
mcpServers: {
|
|
73
|
+
"unsafe-stdio": { command: "node", args: ["server.js"] },
|
|
74
|
+
"safe-url": { url: "http://127.0.0.1:27888/mcp" },
|
|
75
|
+
},
|
|
76
|
+
}, null, 2));
|
|
77
|
+
|
|
78
|
+
const found = scanForStdioServers(settingsPath);
|
|
79
|
+
assert.deepEqual(found.map((server) => server.name), ["unsafe-stdio"]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("replaces stdio MCP entries with tfx-hub and writes a backup", () => {
|
|
83
|
+
const homeDir = createHomeDir();
|
|
84
|
+
withHome(homeDir);
|
|
85
|
+
|
|
86
|
+
const pidPath = join(homeDir, ".claude", "cache", "tfx-hub", "hub.pid");
|
|
87
|
+
writeFileSync(pidPath, JSON.stringify({ host: "127.0.0.1", port: 30123 }), "utf8");
|
|
88
|
+
|
|
89
|
+
const settingsPath = join(homeDir, ".gemini", "settings.json");
|
|
90
|
+
writeFileSync(settingsPath, JSON.stringify({
|
|
91
|
+
mcpServers: {
|
|
92
|
+
"unsafe-stdio": { command: "node", args: ["server.js"] },
|
|
93
|
+
},
|
|
94
|
+
}, null, 2));
|
|
95
|
+
|
|
96
|
+
const result = remediate(settingsPath, scanForStdioServers(settingsPath), { stdio_action: "replace-with-hub" });
|
|
97
|
+
const updated = JSON.parse(readFileSync(settingsPath, "utf8"));
|
|
98
|
+
|
|
99
|
+
assert.equal(result.modified, true);
|
|
100
|
+
assert.equal(existsSync(`${settingsPath}.bak`), true);
|
|
101
|
+
assert.deepEqual(result.removedServers, ["unsafe-stdio"]);
|
|
102
|
+
assert.equal(updated.mcpServers["tfx-hub"].url, "http://127.0.0.1:30123/mcp");
|
|
103
|
+
assert.equal(Object.hasOwn(updated.mcpServers, "unsafe-stdio"), false);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("uses hub.pid port before registry fallback when resolving Hub URL", () => {
|
|
107
|
+
const homeDir = createHomeDir();
|
|
108
|
+
withHome(homeDir);
|
|
109
|
+
|
|
110
|
+
writeFileSync(
|
|
111
|
+
join(homeDir, ".claude", "cache", "tfx-hub", "hub.pid"),
|
|
112
|
+
JSON.stringify({ host: "127.0.0.1", port: 29991 }),
|
|
113
|
+
"utf8",
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
assert.equal(resolveHubUrl(), "http://127.0.0.1:29991/mcp");
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -193,7 +193,7 @@ async function main() {
|
|
|
193
193
|
}
|
|
194
194
|
|
|
195
195
|
// codex/gemini 직접 CLI 호출 → deny
|
|
196
|
-
if (/\bcodex\
|
|
196
|
+
if (/\bcodex\b.*\bexec\b/.test(cmd) || /\bgemini\s+(-p|--prompt)\b/.test(cmd)) {
|
|
197
197
|
deny(
|
|
198
198
|
"[headless-guard] codex/gemini 직접 호출은 spawn-session에서 차단됩니다. " +
|
|
199
199
|
`승인된 경로: ${HEADLESS_FALLBACK_COMMAND}. ` +
|