@cocorograph/hub-agent 0.8.113 → 0.8.115
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 +2 -1
- package/src/chat-removal-guard.mjs +1 -1
- package/src/chat-session-starter.mjs +3 -2
- package/src/claude-stream-bridge.mjs +7 -0
- package/src/codex-binary.mjs +43 -27
- package/src/config.mjs +9 -4
- package/src/dispatch-scheduler.mjs +13 -11
- package/src/enroll.mjs +5 -0
- package/src/grok-acp-client.mjs +130 -0
- package/src/grok-binary.mjs +50 -0
- package/src/grok-stream-bridge.mjs +756 -0
- package/src/hub-bundle.mjs +183 -26
- package/src/live-sessions.mjs +12 -11
- package/src/main.mjs +295 -57
- package/src/review-event-store.mjs +1 -1
- package/src/session-names.mjs +21 -12
- package/src/sibling-inject.mjs +12 -8
- package/src/ws-client.mjs +26 -0
package/src/hub-bundle.mjs
CHANGED
|
@@ -21,6 +21,8 @@ import path from "node:path"
|
|
|
21
21
|
import os from "node:os"
|
|
22
22
|
import { spawn } from "node:child_process"
|
|
23
23
|
import { randomBytes, createHash } from "node:crypto"
|
|
24
|
+
import { isDeepStrictEqual } from "node:util"
|
|
25
|
+
import { parse } from "smol-toml"
|
|
24
26
|
import { resolveCodexBinary } from "./codex-binary.mjs"
|
|
25
27
|
|
|
26
28
|
const HUB_AI_RULES_BEGIN = "<!-- BEGIN HUB-AI-RULES -->"
|
|
@@ -650,11 +652,48 @@ export async function applyCodexMcpServers(
|
|
|
650
652
|
return { added, skipped, approvalUpdated, approvalPending, approvalSkipped, approvalError }
|
|
651
653
|
}
|
|
652
654
|
|
|
655
|
+
/** Grok は X-Cockpit-Agent で接続するため、非対話でも Hub MCP を新規登録できる。 */
|
|
656
|
+
export async function applyGrokMcpServers(
|
|
657
|
+
bundle,
|
|
658
|
+
{ agentId, agentToken, logger, configPath, fsImpl = fs } = {},
|
|
659
|
+
) {
|
|
660
|
+
const servers = (Array.isArray(bundle?.mcp_servers) ? bundle.mcp_servers : [])
|
|
661
|
+
.filter((s) => HUB_MCP_NAMES.has(s?.name) && s?.url)
|
|
662
|
+
if (servers.length === 0) return { updated: [], skipped: [], pending: [] }
|
|
663
|
+
const cfgPath = configPath || resolveGrokConfigPath()
|
|
664
|
+
// I/O 例外にも資格情報が含まれ得るので、Grok 経路では生の例外をログへ渡さない。
|
|
665
|
+
const safeLogger = {
|
|
666
|
+
info: (data) => logger?.info?.(data, "grok MCP config sync"),
|
|
667
|
+
warn: (data, message) => logger?.warn?.({ name: data?.name },
|
|
668
|
+
message === "TOML の解析に失敗した" ? message : "grok MCP config sync skipped or failed"),
|
|
669
|
+
}
|
|
670
|
+
try {
|
|
671
|
+
try {
|
|
672
|
+
await fsImpl.stat(path.dirname(cfgPath))
|
|
673
|
+
} catch (err) {
|
|
674
|
+
if (err.code !== "ENOENT") throw err
|
|
675
|
+
return { updated: [], skipped: servers.map((s) => s.name), pending: [], grokMissing: true }
|
|
676
|
+
}
|
|
677
|
+
return await applyCodexMcpApprovalModes(servers, {
|
|
678
|
+
agentId, agentToken, logger: safeLogger, configPath: cfgPath, fsImpl,
|
|
679
|
+
onlyExisting: false, headersKey: "headers", writeApprovalMode: false,
|
|
680
|
+
secretFileMode: 0o600,
|
|
681
|
+
})
|
|
682
|
+
} catch {
|
|
683
|
+
throw new Error("Grok MCP config sync failed")
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
653
687
|
// Codex の MCP サーバーごと承認モードとして `~/.codex/config.toml` へ書いてよい許可値
|
|
654
688
|
// (Codex 公式値: auto / prompt / writes / approve)。Bundle backend でロール判定後に
|
|
655
689
|
// フィルタ済みだが、hub-agent 側でも defense-in-depth で検証し、不正値は端末設定へ流さない。
|
|
656
690
|
const _CODEX_MCP_APPROVAL_MODES = new Set(["auto", "prompt", "writes", "approve"])
|
|
657
691
|
|
|
692
|
+
// Grok 本体と同じ GROK_HOME / ~/.grok を使う。
|
|
693
|
+
function resolveGrokConfigPath() {
|
|
694
|
+
return path.join(process.env.GROK_HOME || path.join(os.homedir(), ".grok"), "config.toml")
|
|
695
|
+
}
|
|
696
|
+
|
|
658
697
|
/**
|
|
659
698
|
* `~/.codex/config.toml` の絶対パスを解決する。`codex mcp add` が書く実ファイルと一致
|
|
660
699
|
* させるため、codex 本体と同じく `CODEX_HOME` を最優先で見る。次に hub-agent の
|
|
@@ -682,7 +721,7 @@ function resolveCodexConfigPath() {
|
|
|
682
721
|
* - 不正な approvalMode は無視(url のみ・または no-op)。改行コード(LF/CRLF)は維持する。
|
|
683
722
|
*
|
|
684
723
|
* @param {string} configText
|
|
685
|
-
* @param {{name: string, url?: string, approvalMode?: string, hostCredential?: string, onlyExisting?: boolean}} opts
|
|
724
|
+
* @param {{name: string, url?: string, approvalMode?: string, hostCredential?: string, onlyExisting?: boolean, headersKey?: string}} opts
|
|
686
725
|
* onlyExisting=true のとき、標準テーブルが**まだ無い** server には新規テーブルを追記しない
|
|
687
726
|
* (config を無変更で返す)。自動同期(非対話)で使う: 未登録 MCP に承認モードだけ書くと
|
|
688
727
|
* OAuth 未認証の「半登録テーブル」を作ってしまうため、登録は対話経路(`codex mcp add`)に
|
|
@@ -691,14 +730,15 @@ function resolveCodexConfigPath() {
|
|
|
691
730
|
*/
|
|
692
731
|
export function upsertCodexMcpApprovalMode(
|
|
693
732
|
configText,
|
|
694
|
-
{ name, url, approvalMode, hostCredential, onlyExisting = false } = {},
|
|
733
|
+
{ name, url, approvalMode, hostCredential, onlyExisting = false, headersKey = "http_headers" } = {},
|
|
695
734
|
) {
|
|
696
735
|
if (!name) return configText
|
|
736
|
+
if (hostCredential && _codexHeadersAlternativelyDefined(configText, name, headersKey)) return configText
|
|
697
737
|
const mode = _CODEX_MCP_APPROVAL_MODES.has(approvalMode) ? approvalMode : null
|
|
698
738
|
const kv = []
|
|
699
739
|
if (typeof url === "string" && url) kv.push(["url", JSON.stringify(url)])
|
|
700
740
|
if (typeof hostCredential === "string" && hostCredential) {
|
|
701
|
-
kv.push([
|
|
741
|
+
kv.push([headersKey, `{ "X-Cockpit-Agent" = ${JSON.stringify(hostCredential)} }`])
|
|
702
742
|
}
|
|
703
743
|
if (mode) kv.push(["default_tools_approval_mode", JSON.stringify(mode)])
|
|
704
744
|
if (kv.length === 0) return configText
|
|
@@ -755,13 +795,13 @@ export function upsertCodexMcpApprovalMode(
|
|
|
755
795
|
}
|
|
756
796
|
}
|
|
757
797
|
if (found >= 0) {
|
|
758
|
-
if (key ===
|
|
798
|
+
if (key === headersKey) {
|
|
759
799
|
// `http_headers` はユーザーが別のヘッダを持ち得る。行全体を置換せず、管理対象の
|
|
760
800
|
// X-Cockpit-Agent だけを inline table へマージする。安全に解釈できない形式なら
|
|
761
801
|
// config 全体を無変更で返し、呼び出し側の検証で skipped 扱いにする。
|
|
762
802
|
const merged = _upsertTomlInlineStringMapValue(
|
|
763
803
|
lines[found],
|
|
764
|
-
|
|
804
|
+
headersKey,
|
|
765
805
|
"X-Cockpit-Agent",
|
|
766
806
|
JSON.stringify(hostCredential),
|
|
767
807
|
)
|
|
@@ -795,7 +835,9 @@ export function upsertCodexMcpApprovalMode(
|
|
|
795
835
|
* 残さない)。rename が失敗した場合は破壊的フォールバックを行わず中止し、既存 config を無変更で維持する。
|
|
796
836
|
*
|
|
797
837
|
* @param {Array<{name?: string, url?: string, default_tools_approval_mode?: string}>} servers
|
|
798
|
-
* @param {{configPath?: string, logger?: any, fsImpl?: any, isWindows?: boolean, onlyExisting?: boolean, agentId?: string, agentToken?: string}} [opts]
|
|
838
|
+
* @param {{configPath?: string, logger?: any, fsImpl?: any, isWindows?: boolean, onlyExisting?: boolean, agentId?: string, agentToken?: string, headersKey?: string, writeApprovalMode?: boolean, secretFileMode?: number}} [opts]
|
|
839
|
+
* secretFileMode 指定時は、hostCredential を含む書き込みの作成・置換権限を固定する。
|
|
840
|
+
* 未指定時は既存権限を維持する。権限設定失敗時は原本を変更しない。
|
|
799
841
|
* configPath 省略時は resolveCodexConfigPath()。fsImpl / isWindows はテスト用の注入点(既定は実 fs / 実 OS)。
|
|
800
842
|
* onlyExisting=true(自動同期・非対話)のとき、標準テーブルが**まだ無い** server には新規
|
|
801
843
|
* テーブルを追記せず `pending` として報告する(未登録 MCP に承認モードだけ書いて OAuth 未認証の
|
|
@@ -813,13 +855,16 @@ export async function applyCodexMcpApprovalModes(
|
|
|
813
855
|
onlyExisting = false,
|
|
814
856
|
agentId,
|
|
815
857
|
agentToken,
|
|
858
|
+
headersKey = "http_headers",
|
|
859
|
+
writeApprovalMode = true,
|
|
860
|
+
secretFileMode,
|
|
816
861
|
} = {},
|
|
817
862
|
) {
|
|
818
863
|
const hostCredential = agentId && agentToken ? `${agentId}:${agentToken}` : null
|
|
819
864
|
const targets = (Array.isArray(servers) ? servers : []).filter(
|
|
820
865
|
(s) =>
|
|
821
866
|
s?.name &&
|
|
822
|
-
(_CODEX_MCP_APPROVAL_MODES.has(s?.default_tools_approval_mode) ||
|
|
867
|
+
((!writeApprovalMode && s.url) || _CODEX_MCP_APPROVAL_MODES.has(s?.default_tools_approval_mode) ||
|
|
823
868
|
(hostCredential && HUB_MCP_NAMES.has(s.name))),
|
|
824
869
|
)
|
|
825
870
|
if (targets.length === 0) return { updated: [], skipped: [], pending: [] }
|
|
@@ -838,17 +883,28 @@ export async function applyCodexMcpApprovalModes(
|
|
|
838
883
|
text = "" // 新規環境: 空から始める(mode は 0o600 のまま)
|
|
839
884
|
}
|
|
840
885
|
|
|
886
|
+
let document
|
|
887
|
+
try {
|
|
888
|
+
document = parse(text)
|
|
889
|
+
} catch {
|
|
890
|
+
// 解析器の例外は文書断片(資格情報)を含み得る。外へ渡さない。
|
|
891
|
+
for (const s of targets) logger?.warn?.({ name: s.name }, "TOML の解析に失敗した")
|
|
892
|
+
return { updated: [], skipped: targets.map((s) => s.name), pending: [] }
|
|
893
|
+
}
|
|
894
|
+
|
|
841
895
|
let next = text
|
|
842
896
|
const updated = []
|
|
843
897
|
const skipped = []
|
|
844
898
|
const pending = []
|
|
845
899
|
for (const s of targets) {
|
|
900
|
+
const approvalMode = writeApprovalMode ? s.default_tools_approval_mode : undefined
|
|
846
901
|
const serverHostCredential = hostCredential && HUB_MCP_NAMES.has(s.name) ? hostCredential : null
|
|
847
902
|
// 自動同期(onlyExisting)で標準テーブルが無い server は、新規テーブルを作らない。
|
|
848
903
|
// 非標準形(空白入り/ドット記法/インライン)で既に定義されていれば「安全に編集できない」
|
|
849
904
|
// → skipped、真に未登録なら「登録が必要」→ pending として区別する(ブラウザは開かない)。
|
|
850
905
|
if (onlyExisting && !_codexStandardTableExists(next, s.name)) {
|
|
851
|
-
if (_codexServerAlreadyDefined(next, s.name)
|
|
906
|
+
if (_codexServerAlreadyDefined(next, s.name) ||
|
|
907
|
+
(serverHostCredential && _codexHeadersAlternativelyDefined(next, s.name, headersKey))) {
|
|
852
908
|
logger?.warn(
|
|
853
909
|
{ name: s.name },
|
|
854
910
|
"codex config.toml の該当テーブルが非標準形のため自動同期では編集できずスキップ(既存 config は不変)",
|
|
@@ -866,14 +922,30 @@ export async function applyCodexMcpApprovalModes(
|
|
|
866
922
|
const candidate = upsertCodexMcpApprovalMode(next, {
|
|
867
923
|
name: s.name,
|
|
868
924
|
url: s.url,
|
|
869
|
-
approvalMode
|
|
925
|
+
approvalMode,
|
|
926
|
+
headersKey,
|
|
870
927
|
hostCredential: serverHostCredential,
|
|
871
928
|
onlyExisting,
|
|
872
929
|
})
|
|
930
|
+
let candidateDocument
|
|
931
|
+
try {
|
|
932
|
+
candidateDocument = parse(candidate)
|
|
933
|
+
} catch {
|
|
934
|
+
logger?.warn?.({ name: s.name }, "TOML の解析に失敗した")
|
|
935
|
+
skipped.push(s.name)
|
|
936
|
+
continue
|
|
937
|
+
}
|
|
938
|
+
if (!_verifyParsedMcpCandidate(document, candidateDocument, {
|
|
939
|
+
name: s.name, url: s.url, approvalMode, hostCredential: serverHostCredential, headersKey,
|
|
940
|
+
})) {
|
|
941
|
+
logger?.warn?.({ name: s.name }, "MCP 設定の解析結果が期待値と一致しないためスキップ")
|
|
942
|
+
skipped.push(s.name)
|
|
943
|
+
continue
|
|
944
|
+
}
|
|
873
945
|
if (candidate === next) {
|
|
874
946
|
// 変化なし。原因が「既に正しく反映済み(= no-op)」か「非標準形で upsert が編集を拒否した」
|
|
875
947
|
// かを検証で区別する。標準テーブルが健全なら no-op、そうでなければ適用不能としてスキップ報告。
|
|
876
|
-
if (!_verifyCodexServerTable(next, s.name,
|
|
948
|
+
if (!_verifyCodexServerTable(next, s.name, approvalMode, serverHostCredential, headersKey)) {
|
|
877
949
|
logger?.warn(
|
|
878
950
|
{ name: s.name },
|
|
879
951
|
"codex config.toml の該当テーブルを安全に編集できないためスキップ(既存 config は不変)",
|
|
@@ -886,7 +958,7 @@ export async function applyCodexMcpApprovalModes(
|
|
|
886
958
|
// 期待値でちょうど 1 つ・url は高々 1 つ・他形式(空白入り/インライン)の重複定義が無い」
|
|
887
959
|
// ことを検証する。素の文字列編集が非標準 config で誤動作しても、ここで弾いて **既存 config を
|
|
888
960
|
// 一切書き換えない**(worst case = 承認設定が付かない=現状維持)。
|
|
889
|
-
if (!_verifyCodexServerTable(candidate, s.name,
|
|
961
|
+
if (!_verifyCodexServerTable(candidate, s.name, approvalMode, serverHostCredential, headersKey)) {
|
|
890
962
|
logger?.warn(
|
|
891
963
|
{ name: s.name },
|
|
892
964
|
"codex config.toml の該当テーブルを安全に編集できないためスキップ(既存 config は不変)",
|
|
@@ -895,23 +967,60 @@ export async function applyCodexMcpApprovalModes(
|
|
|
895
967
|
continue
|
|
896
968
|
}
|
|
897
969
|
next = candidate
|
|
970
|
+
document = candidateDocument
|
|
898
971
|
updated.push(s.name)
|
|
899
972
|
}
|
|
900
|
-
|
|
901
|
-
|
|
973
|
+
// 資格情報を含む書き込みでは、指定された権限を作成時から置換完了まで維持する。
|
|
974
|
+
const protectSecret = secretFileMode !== undefined && hostCredential && next.includes(JSON.stringify(hostCredential))
|
|
975
|
+
if (next === text) {
|
|
976
|
+
// 内容が一致していても旧版が残した緩い権限は修正する。失敗は呼び出し側へ伝える。
|
|
977
|
+
if (protectSecret && fileMode !== secretFileMode) await fsImpl.chmod(cfgPath, secretFileMode)
|
|
978
|
+
return { updated: [], skipped, pending } // updated は内容を変更した server のみ
|
|
979
|
+
}
|
|
980
|
+
if (protectSecret) fileMode = secretFileMode
|
|
902
981
|
// ⚠️ 失敗しても既存 config.toml を必ず維持する fail-safe な atomic write。
|
|
903
|
-
await _atomicWriteFilePreserving(cfgPath, next, fileMode, {
|
|
982
|
+
await _atomicWriteFilePreserving(cfgPath, next, fileMode, {
|
|
983
|
+
fsImpl, isWindows, logger,
|
|
984
|
+
creationMode: protectSecret ? secretFileMode : 0o600,
|
|
985
|
+
requireMode: Boolean(protectSecret),
|
|
986
|
+
})
|
|
904
987
|
logger?.info({ updated, skipped, pending }, "codex MCP 設定を config.toml へ反映")
|
|
905
988
|
return { updated, skipped, pending }
|
|
906
989
|
}
|
|
907
990
|
|
|
991
|
+
/** 編集結果の意味を検証する。対象外の設定と既存の他ヘッダーは保存する。 */
|
|
992
|
+
function _verifyParsedMcpCandidate(before, after, { name, url, approvalMode, hostCredential, headersKey }) {
|
|
993
|
+
const server = after.mcp_servers?.[name]
|
|
994
|
+
if (!server || typeof server !== "object" || Array.isArray(server)) return false
|
|
995
|
+
if (typeof url === "string" && url && server.url !== url) return false
|
|
996
|
+
if (_CODEX_MCP_APPROVAL_MODES.has(approvalMode) && server.default_tools_approval_mode !== approvalMode) return false
|
|
997
|
+
const without = (object, key) => Object.fromEntries(Object.entries(object ?? {}).filter(([k]) => k !== key))
|
|
998
|
+
if (hostCredential) {
|
|
999
|
+
if (server[headersKey]?.["X-Cockpit-Agent"] !== hostCredential) return false
|
|
1000
|
+
if (!isDeepStrictEqual(
|
|
1001
|
+
without(before.mcp_servers?.[name]?.[headersKey], "X-Cockpit-Agent"),
|
|
1002
|
+
without(server[headersKey], "X-Cockpit-Agent"),
|
|
1003
|
+
)) return false
|
|
1004
|
+
}
|
|
1005
|
+
// 対象 server の中でも、管理するキー(url・承認モード・資格ヘッダーのテーブル)以外は変わってはいけない。
|
|
1006
|
+
// 文字列編集が複数行文字列の中身を書き換える等の破壊は、ここで検出する。
|
|
1007
|
+
const managed = new Set()
|
|
1008
|
+
if (typeof url === "string" && url) managed.add("url")
|
|
1009
|
+
if (_CODEX_MCP_APPROVAL_MODES.has(approvalMode)) managed.add("default_tools_approval_mode")
|
|
1010
|
+
if (hostCredential) managed.add(headersKey)
|
|
1011
|
+
const unmanaged = (object) => Object.fromEntries(Object.entries(object ?? {}).filter(([k]) => !managed.has(k)))
|
|
1012
|
+
if (!isDeepStrictEqual(unmanaged(before.mcp_servers?.[name]), unmanaged(server))) return false
|
|
1013
|
+
return isDeepStrictEqual(without(before, "mcp_servers"), without(after, "mcp_servers")) &&
|
|
1014
|
+
isDeepStrictEqual(without(before.mcp_servers, name), without(after.mcp_servers, name))
|
|
1015
|
+
}
|
|
1016
|
+
|
|
908
1017
|
/**
|
|
909
1018
|
* 失敗しても既存ファイルを必ず維持する atomic write(同一ディレクトリ一時ファイル → rename)。
|
|
910
1019
|
*
|
|
911
1020
|
* 要件(2026-07-15 レビュー指摘対応):
|
|
912
|
-
* - Unix
|
|
1021
|
+
* - Unix または requireMode 指定時は `chmod` 失敗で中止する。通常の Windows は
|
|
913
1022
|
* POSIX 権限概念が無いので chmod は best-effort(失敗無視)。
|
|
914
|
-
* -
|
|
1023
|
+
* - 一時ファイルは creationMode(既定 0o600)で作成し、その後に指定 mode へ合わせる。
|
|
915
1024
|
* - 置換は全 OS で `rename`(libuv が Windows でも MOVEFILE_REPLACE_EXISTING で既存を原子置換)。
|
|
916
1025
|
* rename は「完全成功」か「原本無変更」のどちらかなので、**失敗時は破壊的フォールバック(退避/削除)を
|
|
917
1026
|
* 一切行わず throw** し、既存 config を必ず無変更で維持する(退避方式は置換+復元の二重失敗で消失しうるため廃止)。
|
|
@@ -920,13 +1029,13 @@ export async function applyCodexMcpApprovalModes(
|
|
|
920
1029
|
* @param {string} targetPath
|
|
921
1030
|
* @param {string} content
|
|
922
1031
|
* @param {number} mode
|
|
923
|
-
* @param {{fsImpl?: any, isWindows?: boolean, logger?: any}} [opts]
|
|
1032
|
+
* @param {{fsImpl?: any, isWindows?: boolean, logger?: any, creationMode?: number, requireMode?: boolean}} [opts]
|
|
924
1033
|
*/
|
|
925
1034
|
async function _atomicWriteFilePreserving(
|
|
926
1035
|
targetPath,
|
|
927
1036
|
content,
|
|
928
1037
|
mode,
|
|
929
|
-
{ fsImpl = fs, isWindows = process.platform === "win32", logger } = {},
|
|
1038
|
+
{ fsImpl = fs, isWindows = process.platform === "win32", logger, creationMode = 0o600, requireMode = false } = {},
|
|
930
1039
|
) {
|
|
931
1040
|
const dir = path.dirname(targetPath)
|
|
932
1041
|
const base = path.basename(targetPath)
|
|
@@ -934,10 +1043,10 @@ async function _atomicWriteFilePreserving(
|
|
|
934
1043
|
const tmp = path.join(dir, `.${base}.hub-tmp-${process.pid}-${rand}`)
|
|
935
1044
|
await fsImpl.mkdir(dir, { recursive: true })
|
|
936
1045
|
try {
|
|
937
|
-
//
|
|
1046
|
+
// 一時ファイルは最初から指定権限(既定 0o600)で作成する。mode 未指定だと作成直後は 0644 に
|
|
938
1047
|
// なり、config 内の静的ヘッダ等の秘密が一瞬でも world-readable に晒される(2026-07-15 指摘)。
|
|
939
|
-
await fsImpl.writeFile(tmp, content, { encoding: "utf-8", mode:
|
|
940
|
-
if (isWindows) {
|
|
1048
|
+
await fsImpl.writeFile(tmp, content, { encoding: "utf-8", mode: creationMode })
|
|
1049
|
+
if (isWindows && !requireMode) {
|
|
941
1050
|
// Windows は POSIX 権限が無い。既存 mode への合わせ込みは best-effort(失敗無視)。
|
|
942
1051
|
try {
|
|
943
1052
|
await fsImpl.chmod(tmp, mode)
|
|
@@ -945,7 +1054,7 @@ async function _atomicWriteFilePreserving(
|
|
|
945
1054
|
/* ignore on Windows */
|
|
946
1055
|
}
|
|
947
1056
|
} else {
|
|
948
|
-
//
|
|
1057
|
+
// 指定 mode に合わせる。失敗したら中止(緩い権限のまま本番化しない)。
|
|
949
1058
|
await fsImpl.chmod(tmp, mode)
|
|
950
1059
|
}
|
|
951
1060
|
// 全 OS で既存を原子置換する(libuv は Windows でも MOVEFILE_REPLACE_EXISTING で置換)。
|
|
@@ -969,6 +1078,36 @@ function _escapeRegExp(s) {
|
|
|
969
1078
|
return String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
970
1079
|
}
|
|
971
1080
|
|
|
1081
|
+
/** ヘッダーのサブテーブル/ドット記法は inline table と併記できないため編集しない。 */
|
|
1082
|
+
function _codexHeadersAlternativelyDefined(text, name, headersKey) {
|
|
1083
|
+
const key = (value) => {
|
|
1084
|
+
const escaped = _escapeRegExp(value)
|
|
1085
|
+
return `(?:${escaped}|"${escaped}"|'${escaped}')`
|
|
1086
|
+
}
|
|
1087
|
+
const dot = "\\s*\\.\\s*"
|
|
1088
|
+
const server = `${key("mcp_servers")}${dot}${key(name)}`
|
|
1089
|
+
const headers = key(headersKey)
|
|
1090
|
+
const subtable = new RegExp(`^\\s*\\[\\s*${server}${dot}${headers}\\s*(?:\\.|\\])`)
|
|
1091
|
+
const parent = new RegExp(`^\\s*\\[\\s*${server}\\s*\\]\\s*(?:#.*)?$`)
|
|
1092
|
+
const rootDotted = new RegExp(`^\\s*${server}${dot}${headers}\\s*(?:\\.|=)`)
|
|
1093
|
+
const parentDotted = new RegExp(`^\\s*${headers}\\s*\\.`)
|
|
1094
|
+
const escapedHeadersKey = _escapeRegExp(headersKey)
|
|
1095
|
+
const parentQuotedDirect = new RegExp(`^\\s*(?:"${escapedHeadersKey}"|'${escapedHeadersKey}')\\s*=`)
|
|
1096
|
+
let inRoot = true
|
|
1097
|
+
let inParent = false
|
|
1098
|
+
for (const line of text.split(/\r?\n/)) {
|
|
1099
|
+
if (subtable.test(line)) return true
|
|
1100
|
+
if (line.trim().startsWith("[")) {
|
|
1101
|
+
inRoot = false
|
|
1102
|
+
inParent = parent.test(line)
|
|
1103
|
+
} else if ((inRoot && rootDotted.test(line)) ||
|
|
1104
|
+
(inParent && (parentDotted.test(line) || parentQuotedDirect.test(line)))) {
|
|
1105
|
+
return true
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
return false
|
|
1109
|
+
}
|
|
1110
|
+
|
|
972
1111
|
/**
|
|
973
1112
|
* `name` の MCP サーバーが config.toml に**何らかの形で既に定義されている**かを判定する。
|
|
974
1113
|
* 標準形ヘッダ `[mcp_servers.<name>]`(空白/引用ゆらぎ含む)に加え、**ドット記法**
|
|
@@ -1096,7 +1235,8 @@ function _readTomlInlineStringMapValue(line, key, entryKey) {
|
|
|
1096
1235
|
* - `url` は高々 1 つ(重複していない)。
|
|
1097
1236
|
* いずれか外れたら false(=呼び出し側は書き込まず既存 config を保持する)。
|
|
1098
1237
|
*/
|
|
1099
|
-
function _verifyCodexServerTable(text, name, mode, hostCredential = null) {
|
|
1238
|
+
function _verifyCodexServerTable(text, name, mode, hostCredential = null, headersKey = "http_headers") {
|
|
1239
|
+
if (hostCredential && _codexHeadersAlternativelyDefined(text, name, headersKey)) return false
|
|
1100
1240
|
const n = _escapeRegExp(name)
|
|
1101
1241
|
const lines = text.split(/\r?\n/)
|
|
1102
1242
|
const bracketRe = new RegExp(`^\\s*\\[\\s*mcp_servers\\s*\\.\\s*"?${n}"?\\s*\\]\\s*$`)
|
|
@@ -1143,10 +1283,10 @@ function _verifyCodexServerTable(text, name, mode, hostCredential = null) {
|
|
|
1143
1283
|
if (m && m[1] === expectedVal) modeOk = true
|
|
1144
1284
|
}
|
|
1145
1285
|
if (/^\s*url\s*=/.test(lines[i])) urlCount++
|
|
1146
|
-
if (
|
|
1286
|
+
if (new RegExp(`^\\s*${_escapeRegExp(headersKey)}\\s*=`).test(lines[i])) {
|
|
1147
1287
|
httpHeadersCount++
|
|
1148
1288
|
if (hostCredential) {
|
|
1149
|
-
const parsed = _readTomlInlineStringMapValue(lines[i],
|
|
1289
|
+
const parsed = _readTomlInlineStringMapValue(lines[i], headersKey, "X-Cockpit-Agent")
|
|
1150
1290
|
hostHeaderOk =
|
|
1151
1291
|
parsed.valid && parsed.count === 1 && parsed.value === JSON.stringify(hostCredential)
|
|
1152
1292
|
}
|
|
@@ -1627,6 +1767,23 @@ async function _syncBundleLocked({ hubUrl, agentId, agentToken, logger, fetchImp
|
|
|
1627
1767
|
} catch (err) {
|
|
1628
1768
|
logger?.warn({ cliKind, err: err.message }, "applyMcpServers failed (continuing)")
|
|
1629
1769
|
}
|
|
1770
|
+
let grokMcp
|
|
1771
|
+
if (cliKind === "claude") {
|
|
1772
|
+
try {
|
|
1773
|
+
grokMcp = await applyGrokMcpServers(bundle, { agentId, agentToken, logger })
|
|
1774
|
+
// Grok 未導入なら記録しない(後から導入したとき同じ版で同期させるため)。
|
|
1775
|
+
// skip / pending は未適用なので記録せず失敗扱いにし、既存 backoff で再試行する
|
|
1776
|
+
// (記録すると、利用者が設定を直しても同じ版では二度と同期されない)。
|
|
1777
|
+
if (!grokMcp.grokMissing) {
|
|
1778
|
+
if (grokMcp.skipped?.length || grokMcp.pending?.length) throw new Error("Grok MCP config not applied")
|
|
1779
|
+
await updateHubAiState("claude", { grok_mcp_applied_version: bundle.version }, { fsImpl, logger })
|
|
1780
|
+
}
|
|
1781
|
+
} catch {
|
|
1782
|
+
// 一時失敗は syncBundleTargets の failures 経由で既存 backoff に乗せる。
|
|
1783
|
+
// 資格情報が含まれ得る元の例外は伝播しない。
|
|
1784
|
+
throw new Error("Grok MCP config sync failed")
|
|
1785
|
+
}
|
|
1786
|
+
}
|
|
1630
1787
|
// Codex のみ: 承認モード反映の結果を `.hub_ai_state.json` に記録する。
|
|
1631
1788
|
// installed_version(files/hooks 済み)とは別に mcp_approval_applied_version を持つことで、
|
|
1632
1789
|
// 「files は更新済みだが承認未反映」の状態を「完了」と誤認せず、次回の自己修復対象にできる。
|
|
@@ -1646,7 +1803,7 @@ async function _syncBundleLocked({ hubUrl, agentId, agentToken, logger, fetchImp
|
|
|
1646
1803
|
approvalRetriable = true
|
|
1647
1804
|
}
|
|
1648
1805
|
}
|
|
1649
|
-
return { version: bundle.version, ...result, mcp, approvalComplete, approvalRetriable }
|
|
1806
|
+
return { version: bundle.version, ...result, mcp, ...(grokMcp ? { grokMcp } : {}), approvalComplete, approvalRetriable }
|
|
1650
1807
|
}
|
|
1651
1808
|
|
|
1652
1809
|
/**
|
package/src/live-sessions.mjs
CHANGED
|
@@ -24,21 +24,22 @@
|
|
|
24
24
|
import { cliKindForSessionName } from "./session-names.mjs"
|
|
25
25
|
|
|
26
26
|
/**
|
|
27
|
-
* claude / codex ブリッジの live セッションを union で返す (宛先解決の候補集合)。
|
|
27
|
+
* claude / codex / grok ブリッジの live セッションを union で返す (宛先解決の候補集合)。
|
|
28
28
|
*
|
|
29
|
-
* `cwd` を渡すとその cwd のみ、`cliKind` に 'claude' / 'codex' を渡すとその CLI のみへ
|
|
30
|
-
* 絞り込む。どちらも省略すると全 cwd
|
|
31
|
-
* なら絞り込まない (
|
|
29
|
+
* `cwd` を渡すとその cwd のみ、`cliKind` に 'claude' / 'codex' / 'grok' を渡すとその CLI のみへ
|
|
30
|
+
* 絞り込む。どちらも省略すると全 cwd・全 CLI (op:"list-all" 用)。`cliKind` が既知値以外
|
|
31
|
+
* なら絞り込まない (全部返す) — 呼び出し側で既に検証済みの前提。
|
|
32
32
|
*
|
|
33
|
-
* @param {{claudeBridge?: object, codexBridge?: object}} bridges
|
|
34
|
-
* @param {{cwd?: string, cliKind?: 'claude'|'codex'}} [filter]
|
|
35
|
-
* @returns {Array<object>} describeLiveSessions の記述子配列 (claude → codex の順)
|
|
33
|
+
* @param {{claudeBridge?: object, codexBridge?: object, grokBridge?: object}} bridges
|
|
34
|
+
* @param {{cwd?: string, cliKind?: 'claude'|'codex'|'grok'}} [filter]
|
|
35
|
+
* @returns {Array<object>} describeLiveSessions の記述子配列 (claude → codex → grok の順)
|
|
36
36
|
*/
|
|
37
37
|
export function describeLiveSessionsUnion(bridges, { cwd, cliKind } = {}) {
|
|
38
|
-
const { claudeBridge, codexBridge } = bridges || {}
|
|
38
|
+
const { claudeBridge, codexBridge, grokBridge } = bridges || {}
|
|
39
39
|
const out = []
|
|
40
|
-
if (cliKind !== "codex") out.push(...(claudeBridge?.describeLiveSessions?.(cwd) ?? []))
|
|
41
|
-
if (cliKind !== "claude") out.push(...(codexBridge?.describeLiveSessions?.(cwd) ?? []))
|
|
40
|
+
if (cliKind !== "codex" && cliKind !== "grok") out.push(...(claudeBridge?.describeLiveSessions?.(cwd) ?? []))
|
|
41
|
+
if (cliKind !== "claude" && cliKind !== "grok") out.push(...(codexBridge?.describeLiveSessions?.(cwd) ?? []))
|
|
42
|
+
if (cliKind !== "claude" && cliKind !== "codex") out.push(...(grokBridge?.describeLiveSessions?.(cwd) ?? []))
|
|
42
43
|
return out
|
|
43
44
|
}
|
|
44
45
|
|
|
@@ -48,7 +49,7 @@ export function describeLiveSessionsUnion(bridges, { cwd, cliKind } = {}) {
|
|
|
48
49
|
* 判定は上記 union に一本化する。`cwd` が取れていない行 (tmux の cwd 解決失敗など) は
|
|
49
50
|
* 宛先解決のスコープを作れないため false (fail-closed。送れないものを送れると見せない)。
|
|
50
51
|
*
|
|
51
|
-
* @param {{claudeBridge?: object, codexBridge?: object}} bridges
|
|
52
|
+
* @param {{claudeBridge?: object, codexBridge?: object, grokBridge?: object}} bridges
|
|
52
53
|
* @param {{name?: string, cwd?: string|null}} row tmux.list_sessions の session 行
|
|
53
54
|
* @returns {boolean}
|
|
54
55
|
*/
|