@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.
@@ -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(["http_headers", `{ "X-Cockpit-Agent" = ${JSON.stringify(hostCredential)} }`])
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 === "http_headers") {
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
- "http_headers",
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: s.default_tools_approval_mode,
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, s.default_tools_approval_mode, serverHostCredential)) {
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, s.default_tools_approval_mode, serverHostCredential)) {
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
- if (next === text) return { updated: [], skipped, pending } // 冪等 no-op / 全スキップ
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, { fsImpl, isWindows, logger })
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 では `chmod` 失敗で中止する(緩い権限 0644 の一時ファイルを本番化しない)。Windows は
1021
+ * - Unix または requireMode 指定時は `chmod` 失敗で中止する。通常の Windows は
913
1022
  * POSIX 権限概念が無いので chmod は best-effort(失敗無視)。
914
- * - 一時ファイルは**最初から 0o600** で作成し(作成直後の 0644 露出を防ぐ)、その後に既存 mode へ合わせる。
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
- // 一時ファイルは**最初から 0o600** で作成する。writeFile の mode 未指定だと作成直後は 0644 に
1046
+ // 一時ファイルは最初から指定権限(既定 0o600)で作成する。mode 未指定だと作成直後は 0644 に
938
1047
  // なり、config 内の静的ヘッダ等の秘密が一瞬でも world-readable に晒される(2026-07-15 指摘)。
939
- await fsImpl.writeFile(tmp, content, { encoding: "utf-8", mode: 0o600 })
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
- // Unix: 既存ファイルの mode に合わせる。失敗したら中止(緩い権限のまま本番化しない)。
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 (/^\s*http_headers\s*=/.test(lines[i])) {
1286
+ if (new RegExp(`^\\s*${_escapeRegExp(headersKey)}\\s*=`).test(lines[i])) {
1147
1287
  httpHeadersCount++
1148
1288
  if (hostCredential) {
1149
- const parsed = _readTomlInlineStringMapValue(lines[i], "http_headers", "X-Cockpit-Agent")
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
  /**
@@ -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・両 CLI (op:"list-all" 用)。`cliKind` が既知値以外
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
  */