@cocorograph/hub-agent 0.7.97 → 0.7.98

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/hub-bundle.mjs +159 -26
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cocorograph/hub-agent",
3
- "version": "0.7.97",
3
+ "version": "0.7.98",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -471,7 +471,7 @@ export async function applyMcpServers(
471
471
  */
472
472
  export async function applyCodexMcpServers(
473
473
  bundle,
474
- { logger, nonInteractive = false, resolveBinImpl, runImpl } = {},
474
+ { agentId, agentToken, logger, nonInteractive = false, resolveBinImpl, runImpl } = {},
475
475
  ) {
476
476
  const servers = Array.isArray(bundle?.mcp_servers) ? bundle.mcp_servers : []
477
477
  if (servers.length === 0) {
@@ -512,25 +512,34 @@ export async function applyCodexMcpServers(
512
512
  }
513
513
  }
514
514
 
515
- // MCP 承認モードを `~/.codex/config.toml` に反映する(2026-07-15)。
515
+ // MCP 承認モードと Hub のホスト資格ヘッダを `~/.codex/config.toml` に反映する。
516
516
  // ⚠️ `codex mcp add` には承認モードの永続設定オプションが無いため、Bundle が宣言した
517
517
  // `default_tools_approval_mode` を config.toml の `[mcp_servers.<name>]` テーブルへ直接
518
- // マージする。対話 add の成否に関わらず全 target に対して実行する(add が「already exists」で
519
- // 失敗しても承認設定は更新する要件)。非対話モードでは onlyExisting=true で「既存テーブルの承認モード
520
- // 補完」だけを行い、未登録 server には新規テーブルを作らず pending 報告する。I/O・ネットワーク失敗
521
- // (transient)は approvalError として上位(syncBundle)へ返し、state に「再試行すべき失敗」として
522
- // 記録させる。config 破壊は _atomicWriteFilePreserving が防ぐ。
518
+ // マージする。また Codex CLI 0.145.0 時点では `mcp add --header` が無いため、Claude と同じ
519
+ // `X-Cockpit-Agent` を公式設定 `http_headers` へ直接マージする。ヘッダは `hub` / `hub_staff`
520
+ // の両方だけに付け、外部 MCP には付けない。
521
+ //
522
+ // 対話 add の成否に関わらず全 target に対して実行する(add が「already exists」で失敗しても
523
+ // 設定は更新する要件)。非対話モードでは onlyExisting=true で「既存テーブルの補完」だけを行い、
524
+ // 未登録 server には新規テーブルを作らず pending 報告する。I/O・ネットワーク失敗(transient)は
525
+ // approvalError として上位(syncBundle)へ返し、state に「再試行すべき失敗」として記録させる。
526
+ // config 破壊は _atomicWriteFilePreserving が防ぐ。
523
527
  let approvalUpdated = []
524
528
  let approvalPending = []
525
529
  let approvalSkipped = []
526
530
  let approvalError = null
527
531
  try {
528
- const res = await applyCodexMcpApprovalModes(servers, { logger, onlyExisting: nonInteractive })
532
+ const res = await applyCodexMcpApprovalModes(servers, {
533
+ agentId,
534
+ agentToken,
535
+ logger,
536
+ onlyExisting: nonInteractive,
537
+ })
529
538
  approvalUpdated = res.updated
530
539
  approvalPending = res.pending || []
531
540
  approvalSkipped = res.skipped || []
532
541
  } catch (err) {
533
- logger?.warn({ err: err.message }, "codex MCP approval mode の config.toml 反映に失敗")
542
+ logger?.warn({ err: err.message }, "codex MCP 設定の config.toml 反映に失敗")
534
543
  approvalError = err?.message || String(err)
535
544
  }
536
545
  return { added, skipped, approvalUpdated, approvalPending, approvalSkipped, approvalError }
@@ -554,7 +563,8 @@ function resolveCodexConfigPath() {
554
563
 
555
564
  /**
556
565
  * config.toml テキストへ、`[mcp_servers.<name>]` テーブルの `url` /
557
- * `default_tools_approval_mode` を**テーブル単位でサージカルに** upsert して返す(純関数)。
566
+ * `http_headers.X-Cockpit-Agent` / `default_tools_approval_mode` を**テーブル単位で
567
+ * サージカルに** upsert して返す(純関数)。
558
568
  *
559
569
  * 設計(config 全体を再生成しない・既存を壊さない):
560
570
  * - 対象テーブルの行範囲(ヘッダ行の次〜次の `[...]` テーブルヘッダ直前)だけを編集する。
@@ -567,18 +577,24 @@ function resolveCodexConfigPath() {
567
577
  * - 不正な approvalMode は無視(url のみ・または no-op)。改行コード(LF/CRLF)は維持する。
568
578
  *
569
579
  * @param {string} configText
570
- * @param {{name: string, url?: string, approvalMode?: string, onlyExisting?: boolean}} opts
580
+ * @param {{name: string, url?: string, approvalMode?: string, hostCredential?: string, onlyExisting?: boolean}} opts
571
581
  * onlyExisting=true のとき、標準テーブルが**まだ無い** server には新規テーブルを追記しない
572
582
  * (config を無変更で返す)。自動同期(非対話)で使う: 未登録 MCP に承認モードだけ書くと
573
583
  * OAuth 未認証の「半登録テーブル」を作ってしまうため、登録は対話経路(`codex mcp add`)に
574
584
  * 限定し、自動同期は既存テーブルの承認モード補完だけを行う。
575
585
  * @returns {string}
576
586
  */
577
- export function upsertCodexMcpApprovalMode(configText, { name, url, approvalMode, onlyExisting = false } = {}) {
587
+ export function upsertCodexMcpApprovalMode(
588
+ configText,
589
+ { name, url, approvalMode, hostCredential, onlyExisting = false } = {},
590
+ ) {
578
591
  if (!name) return configText
579
592
  const mode = _CODEX_MCP_APPROVAL_MODES.has(approvalMode) ? approvalMode : null
580
593
  const kv = []
581
594
  if (typeof url === "string" && url) kv.push(["url", JSON.stringify(url)])
595
+ if (typeof hostCredential === "string" && hostCredential) {
596
+ kv.push(["http_headers", `{ "X-Cockpit-Agent" = ${JSON.stringify(hostCredential)} }`])
597
+ }
582
598
  if (mode) kv.push(["default_tools_approval_mode", JSON.stringify(mode)])
583
599
  if (kv.length === 0) return configText
584
600
 
@@ -634,8 +650,22 @@ export function upsertCodexMcpApprovalMode(configText, { name, url, approvalMode
634
650
  }
635
651
  }
636
652
  if (found >= 0) {
637
- // 値だけ差し替え、インデント・キー左辺・**行末インラインコメント**を保持する。
638
- lines[found] = _replaceTomlStringValue(lines[found], key, valStr)
653
+ if (key === "http_headers") {
654
+ // `http_headers` はユーザーが別のヘッダを持ち得る。行全体を置換せず、管理対象の
655
+ // X-Cockpit-Agent だけを inline table へマージする。安全に解釈できない形式なら
656
+ // config 全体を無変更で返し、呼び出し側の検証で skipped 扱いにする。
657
+ const merged = _upsertTomlInlineStringMapValue(
658
+ lines[found],
659
+ "http_headers",
660
+ "X-Cockpit-Agent",
661
+ JSON.stringify(hostCredential),
662
+ )
663
+ if (merged === null) return configText
664
+ lines[found] = merged
665
+ } else {
666
+ // 値だけ差し替え、インデント・キー左辺・**行末インラインコメント**を保持する。
667
+ lines[found] = _replaceTomlStringValue(lines[found], key, valStr)
668
+ }
639
669
  if (key === "url") urlLineIdx = found
640
670
  } else {
641
671
  toInsert.push(`${key} = ${valStr}`)
@@ -651,8 +681,8 @@ export function upsertCodexMcpApprovalMode(configText, { name, url, approvalMode
651
681
  }
652
682
 
653
683
  /**
654
- * Bundle の mcp_servers のうち承認モードを宣言したものについて、`~/.codex/config.toml` の
655
- * 該当テーブルへ `default_tools_approval_mode`(と url)を安全マージする。
684
+ * Bundle の mcp_servers のうち承認モードを宣言したもの、またはホスト資格を付ける Hub MCP
685
+ * について、`~/.codex/config.toml` の該当テーブルへ承認モード・url・http_headers を安全マージする。
656
686
  *
657
687
  * - 承認モードが許可値でない server はスキップ(不正値を端末設定へ流さない)。
658
688
  * - 変更が無ければ書き込まない(完全冪等)。
@@ -660,7 +690,7 @@ export function upsertCodexMcpApprovalMode(configText, { name, url, approvalMode
660
690
  * 残さない)。rename が失敗した場合は破壊的フォールバックを行わず中止し、既存 config を無変更で維持する。
661
691
  *
662
692
  * @param {Array<{name?: string, url?: string, default_tools_approval_mode?: string}>} servers
663
- * @param {{configPath?: string, logger?: any, fsImpl?: any, isWindows?: boolean, onlyExisting?: boolean}} [opts]
693
+ * @param {{configPath?: string, logger?: any, fsImpl?: any, isWindows?: boolean, onlyExisting?: boolean, agentId?: string, agentToken?: string}} [opts]
664
694
  * configPath 省略時は resolveCodexConfigPath()。fsImpl / isWindows はテスト用の注入点(既定は実 fs / 実 OS)。
665
695
  * onlyExisting=true(自動同期・非対話)のとき、標準テーブルが**まだ無い** server には新規
666
696
  * テーブルを追記せず `pending` として報告する(未登録 MCP に承認モードだけ書いて OAuth 未認証の
@@ -670,10 +700,22 @@ export function upsertCodexMcpApprovalMode(configText, { name, url, approvalMode
670
700
  */
671
701
  export async function applyCodexMcpApprovalModes(
672
702
  servers,
673
- { configPath, logger, fsImpl = fs, isWindows = process.platform === "win32", onlyExisting = false } = {},
703
+ {
704
+ configPath,
705
+ logger,
706
+ fsImpl = fs,
707
+ isWindows = process.platform === "win32",
708
+ onlyExisting = false,
709
+ agentId,
710
+ agentToken,
711
+ } = {},
674
712
  ) {
713
+ const hostCredential = agentId && agentToken ? `${agentId}:${agentToken}` : null
675
714
  const targets = (Array.isArray(servers) ? servers : []).filter(
676
- (s) => s?.name && _CODEX_MCP_APPROVAL_MODES.has(s?.default_tools_approval_mode),
715
+ (s) =>
716
+ s?.name &&
717
+ (_CODEX_MCP_APPROVAL_MODES.has(s?.default_tools_approval_mode) ||
718
+ (hostCredential && HUB_MCP_NAMES.has(s.name))),
677
719
  )
678
720
  if (targets.length === 0) return { updated: [], skipped: [], pending: [] }
679
721
 
@@ -696,6 +738,7 @@ export async function applyCodexMcpApprovalModes(
696
738
  const skipped = []
697
739
  const pending = []
698
740
  for (const s of targets) {
741
+ const serverHostCredential = hostCredential && HUB_MCP_NAMES.has(s.name) ? hostCredential : null
699
742
  // 自動同期(onlyExisting)で標準テーブルが無い server は、新規テーブルを作らない。
700
743
  // 非標準形(空白入り/ドット記法/インライン)で既に定義されていれば「安全に編集できない」
701
744
  // → skipped、真に未登録なら「登録が必要」→ pending として区別する(ブラウザは開かない)。
@@ -719,12 +762,13 @@ export async function applyCodexMcpApprovalModes(
719
762
  name: s.name,
720
763
  url: s.url,
721
764
  approvalMode: s.default_tools_approval_mode,
765
+ hostCredential: serverHostCredential,
722
766
  onlyExisting,
723
767
  })
724
768
  if (candidate === next) {
725
769
  // 変化なし。原因が「既に正しく反映済み(= no-op)」か「非標準形で upsert が編集を拒否した」
726
770
  // かを検証で区別する。標準テーブルが健全なら no-op、そうでなければ適用不能としてスキップ報告。
727
- if (!_verifyCodexServerTable(next, s.name, s.default_tools_approval_mode)) {
771
+ if (!_verifyCodexServerTable(next, s.name, s.default_tools_approval_mode, serverHostCredential)) {
728
772
  logger?.warn(
729
773
  { name: s.name },
730
774
  "codex config.toml の該当テーブルを安全に編集できないためスキップ(既存 config は不変)",
@@ -737,7 +781,7 @@ export async function applyCodexMcpApprovalModes(
737
781
  // 期待値でちょうど 1 つ・url は高々 1 つ・他形式(空白入り/インライン)の重複定義が無い」
738
782
  // ことを検証する。素の文字列編集が非標準 config で誤動作しても、ここで弾いて **既存 config を
739
783
  // 一切書き換えない**(worst case = 承認設定が付かない=現状維持)。
740
- if (!_verifyCodexServerTable(candidate, s.name, s.default_tools_approval_mode)) {
784
+ if (!_verifyCodexServerTable(candidate, s.name, s.default_tools_approval_mode, serverHostCredential)) {
741
785
  logger?.warn(
742
786
  { name: s.name },
743
787
  "codex config.toml の該当テーブルを安全に編集できないためスキップ(既存 config は不変)",
@@ -752,7 +796,7 @@ export async function applyCodexMcpApprovalModes(
752
796
 
753
797
  // ⚠️ 失敗しても既存 config.toml を必ず維持する fail-safe な atomic write。
754
798
  await _atomicWriteFilePreserving(cfgPath, next, fileMode, { fsImpl, isWindows, logger })
755
- logger?.info({ updated, skipped, pending }, "codex MCP approval mode を config.toml へ反映")
799
+ logger?.info({ updated, skipped, pending }, "codex MCP 設定を config.toml へ反映")
756
800
  return { updated, skipped, pending }
757
801
  }
758
802
 
@@ -862,6 +906,81 @@ function _replaceTomlStringValue(line, key, quotedVal) {
862
906
  return `${indent}${key} = ${quotedVal}`
863
907
  }
864
908
 
909
+ /**
910
+ * TOML inline table の string entry を 1 件だけ upsert する。
911
+ *
912
+ * 例: `http_headers = { "Authorization" = "..." } # keep` に
913
+ * `"X-Cockpit-Agent" = "ag:token"` を追加する。既存 entry・行末コメント・他ヘッダは保持する。
914
+ * inline table として安全に読めない場合は null(呼び出し側は config を変更しない)。
915
+ */
916
+ function _upsertTomlInlineStringMapValue(line, key, entryKey, quotedVal) {
917
+ const keyRe = new RegExp(`^(\\s*${_escapeRegExp(key)}\\s*=\\s*)\\{`)
918
+ const prefixMatch = line.match(keyRe)
919
+ if (!prefixMatch) return null
920
+
921
+ // `#` は TOML 文字列の外側だけコメント開始。コメント内の `}` を inline table の終端と
922
+ // 誤認しないよう、quote / escape を追跡してコード部分を切り出す。
923
+ let inDouble = false
924
+ let inSingle = false
925
+ let escaped = false
926
+ let commentIdx = line.length
927
+ for (let i = prefixMatch[0].length; i < line.length; i++) {
928
+ const ch = line[i]
929
+ if (escaped) {
930
+ escaped = false
931
+ continue
932
+ }
933
+ if (inDouble && ch === "\\") {
934
+ escaped = true
935
+ continue
936
+ }
937
+ if (!inSingle && ch === '"') inDouble = !inDouble
938
+ else if (!inDouble && ch === "'") inSingle = !inSingle
939
+ else if (!inDouble && !inSingle && ch === "#") {
940
+ commentIdx = i
941
+ break
942
+ }
943
+ }
944
+ if (inDouble || inSingle || escaped) return null
945
+
946
+ const code = line.slice(0, commentIdx)
947
+ const openIdx = prefixMatch[0].lastIndexOf("{")
948
+ const closeIdx = code.lastIndexOf("}")
949
+ if (closeIdx <= openIdx || code.slice(closeIdx + 1).trim() !== "") return null
950
+
951
+ const body = code.slice(openIdx + 1, closeIdx)
952
+ const entryRe = new RegExp(
953
+ `((?:"${_escapeRegExp(entryKey)}"|${_escapeRegExp(entryKey)})\\s*=\\s*)"(?:[^"\\\\]|\\\\.)*"`,
954
+ )
955
+ let nextBody
956
+ if (entryRe.test(body)) {
957
+ nextBody = body.replace(entryRe, (_whole, lhs) => `${lhs}${quotedVal}`)
958
+ } else {
959
+ const trailing = body.match(/\\s*$/)?.[0] || ""
960
+ const content = body.slice(0, body.length - trailing.length)
961
+ const entry = `${JSON.stringify(entryKey)} = ${quotedVal}`
962
+ nextBody = content.trim() ? `${content}, ${entry}${trailing}` : ` ${entry} `
963
+ }
964
+ return `${line.slice(0, openIdx + 1)}${nextBody}${line.slice(closeIdx)}`
965
+ }
966
+
967
+ /** 対象 inline table 内の string entry の raw quoted value と出現数を返す。 */
968
+ function _readTomlInlineStringMapValue(line, key, entryKey) {
969
+ const keyRe = new RegExp(`^\\s*${_escapeRegExp(key)}\\s*=\\s*\\{(.*)\\}\\s*(?:#.*)?$`)
970
+ const m = line.match(keyRe)
971
+ if (!m) return { count: 0, value: null, valid: false }
972
+ const entryRe = new RegExp(
973
+ `(?:"${_escapeRegExp(entryKey)}"|${_escapeRegExp(entryKey)})\\s*=\\s*("(?:[^"\\\\]|\\\\.)*")`,
974
+ "g",
975
+ )
976
+ const matches = [...m[1].matchAll(entryRe)]
977
+ return {
978
+ count: matches.length,
979
+ value: matches.length === 1 ? matches[0][1] : null,
980
+ valid: true,
981
+ }
982
+ }
983
+
865
984
  /**
866
985
  * マージ結果の config.toml テキストで、`name` の MCP サーバーテーブルが**安全に**編集された
867
986
  * 状態か検証する(書き込み前のフェイルセーフ)。以下を全て満たすときのみ true:
@@ -872,7 +991,7 @@ function _replaceTomlStringValue(line, key, quotedVal) {
872
991
  * - `url` は高々 1 つ(重複していない)。
873
992
  * いずれか外れたら false(=呼び出し側は書き込まず既存 config を保持する)。
874
993
  */
875
- function _verifyCodexServerTable(text, name, mode) {
994
+ function _verifyCodexServerTable(text, name, mode, hostCredential = null) {
876
995
  const n = _escapeRegExp(name)
877
996
  const lines = text.split(/\r?\n/)
878
997
  const bracketRe = new RegExp(`^\\s*\\[\\s*mcp_servers\\s*\\.\\s*"?${n}"?\\s*\\]\\s*$`)
@@ -904,8 +1023,10 @@ function _verifyCodexServerTable(text, name, mode) {
904
1023
  }
905
1024
  let modeCount = 0
906
1025
  let urlCount = 0
1026
+ let httpHeadersCount = 0
907
1027
  let modeOk = false
908
- const expectedVal = JSON.stringify(mode) // 例: "approve"
1028
+ const expectedVal = _CODEX_MCP_APPROVAL_MODES.has(mode) ? JSON.stringify(mode) : null // 例: "approve"
1029
+ let hostHeaderOk = !hostCredential
909
1030
  // 値(クォート文字列)だけを解析し、行末インラインコメント(` # ...`)は許容する。
910
1031
  // 行全体の厳密一致にすると `default_tools_approval_mode = "approve" # keep` を弾いてしまい、
911
1032
  // upsert が正しく更新しても書き込みがスキップされて古い値が残る(2026-07-15 レビュー指摘)。
@@ -917,8 +1038,20 @@ function _verifyCodexServerTable(text, name, mode) {
917
1038
  if (m && m[1] === expectedVal) modeOk = true
918
1039
  }
919
1040
  if (/^\s*url\s*=/.test(lines[i])) urlCount++
1041
+ if (/^\s*http_headers\s*=/.test(lines[i])) {
1042
+ httpHeadersCount++
1043
+ if (hostCredential) {
1044
+ const parsed = _readTomlInlineStringMapValue(lines[i], "http_headers", "X-Cockpit-Agent")
1045
+ hostHeaderOk =
1046
+ parsed.valid && parsed.count === 1 && parsed.value === JSON.stringify(hostCredential)
1047
+ }
1048
+ }
920
1049
  }
921
- return modeCount === 1 && modeOk && urlCount <= 1
1050
+ const modeValid = expectedVal ? modeCount === 1 && modeOk : modeCount <= 1
1051
+ const headersValid = hostCredential
1052
+ ? httpHeadersCount === 1 && hostHeaderOk
1053
+ : httpHeadersCount <= 1
1054
+ return modeValid && headersValid && urlCount <= 1
922
1055
  }
923
1056
 
924
1057
  /**
@@ -1384,7 +1517,7 @@ async function _syncBundleLocked({ hubUrl, agentId, agentToken, logger, fetchImp
1384
1517
  let mcp = { added: [], skipped: [] }
1385
1518
  try {
1386
1519
  mcp = cliKind === "codex"
1387
- ? await applyCodexMcpServers(bundle, { logger, nonInteractive })
1520
+ ? await applyCodexMcpServers(bundle, { agentId, agentToken, logger, nonInteractive })
1388
1521
  : await applyMcpServers(bundle, { agentId, agentToken, logger, nonInteractive })
1389
1522
  } catch (err) {
1390
1523
  logger?.warn({ cliKind, err: err.message }, "applyMcpServers failed (continuing)")