@cocorograph/hub-agent 0.7.64 → 0.7.65

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 +355 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cocorograph/hub-agent",
3
- "version": "0.7.64",
3
+ "version": "0.7.65",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -20,6 +20,7 @@ import { promises as fs } from "node:fs"
20
20
  import path from "node:path"
21
21
  import os from "node:os"
22
22
  import { spawn } from "node:child_process"
23
+ import { randomBytes } from "node:crypto"
23
24
 
24
25
  const HUB_AI_RULES_BEGIN = "<!-- BEGIN HUB-AI-RULES -->"
25
26
  const HUB_AI_RULES_END = "<!-- END HUB-AI-RULES -->"
@@ -469,7 +470,360 @@ export async function applyCodexMcpServers(bundle, { logger } = {}) {
469
470
  skipped.push(s.name)
470
471
  }
471
472
  }
472
- return { added, skipped }
473
+ // MCP 承認モードを `~/.codex/config.toml` に反映する(2026-07-15)。
474
+ // ⚠️ `codex mcp add` には承認モードの永続設定オプションが無いため、Bundle が宣言した
475
+ // `default_tools_approval_mode` を config.toml の `[mcp_servers.<name>]` テーブルへ直接
476
+ // マージする。add の成否に関わらず全 target に対して実行する(add が「already exists」で
477
+ // 失敗しても承認設定は更新する要件)。config 破壊を避けるため fail-soft(warn のみ)。
478
+ let approvalUpdated = []
479
+ try {
480
+ const res = await applyCodexMcpApprovalModes(servers, { logger })
481
+ approvalUpdated = res.updated
482
+ } catch (err) {
483
+ logger?.warn({ err: err.message }, "codex MCP approval mode の config.toml 反映に失敗")
484
+ }
485
+ return { added, skipped, approvalUpdated }
486
+ }
487
+
488
+ // Codex の MCP サーバーごと承認モードとして `~/.codex/config.toml` へ書いてよい許可値
489
+ // (Codex 公式値: auto / prompt / writes / approve)。Bundle backend でロール判定後に
490
+ // フィルタ済みだが、hub-agent 側でも defense-in-depth で検証し、不正値は端末設定へ流さない。
491
+ const _CODEX_MCP_APPROVAL_MODES = new Set(["auto", "prompt", "writes", "approve"])
492
+
493
+ /**
494
+ * `~/.codex/config.toml` の絶対パスを解決する。`codex mcp add` が書く実ファイルと一致
495
+ * させるため、codex 本体と同じく `CODEX_HOME` を最優先で見る。次に hub-agent の
496
+ * `CODEX_DIR` override(既存テスト/運用)、最後に `~/.codex` にフォールバックする。
497
+ * Windows/macOS/Linux とも `os.homedir()`(Windows は USERPROFILE)で解決する。
498
+ */
499
+ function resolveCodexConfigPath() {
500
+ const dir =
501
+ process.env.CODEX_HOME || process.env.CODEX_DIR || path.join(os.homedir(), ".codex")
502
+ return path.join(dir, "config.toml")
503
+ }
504
+
505
+ /**
506
+ * config.toml テキストへ、`[mcp_servers.<name>]` テーブルの `url` /
507
+ * `default_tools_approval_mode` を**テーブル単位でサージカルに** upsert して返す(純関数)。
508
+ *
509
+ * 設計(config 全体を再生成しない・既存を壊さない):
510
+ * - 対象テーブルの行範囲(ヘッダ行の次〜次の `[...]` テーブルヘッダ直前)だけを編集する。
511
+ * 他テーブル・model / approval_policy / projects / profiles / hooks・コメント・未知設定は
512
+ * 一切触らない。
513
+ * - 既存キーは行内で値だけ差し替え(インデント保持)。無ければヘッダ直後に 1 行挿入する。
514
+ * → 何度呼んでも同名キー・同名テーブルを重複させない(冪等)。
515
+ * - テーブル自体が無ければ末尾に新規テーブルを追記する(呼び出し側が Bundle 由来の
516
+ * server のみ渡す前提。一般ユーザー環境に `hub_staff` を作らないのは呼び出し側の責務)。
517
+ * - 不正な approvalMode は無視(url のみ・または no-op)。改行コード(LF/CRLF)は維持する。
518
+ *
519
+ * @param {string} configText
520
+ * @param {{name: string, url?: string, approvalMode?: string}} opts
521
+ * @returns {string}
522
+ */
523
+ export function upsertCodexMcpApprovalMode(configText, { name, url, approvalMode } = {}) {
524
+ if (!name) return configText
525
+ const mode = _CODEX_MCP_APPROVAL_MODES.has(approvalMode) ? approvalMode : null
526
+ const kv = []
527
+ if (typeof url === "string" && url) kv.push(["url", JSON.stringify(url)])
528
+ if (mode) kv.push(["default_tools_approval_mode", JSON.stringify(mode)])
529
+ if (kv.length === 0) return configText
530
+
531
+ const eol = configText.includes("\r\n") ? "\r\n" : "\n"
532
+ const lines = configText.split(/\r?\n/)
533
+ const headerBare = `[mcp_servers.${name}]`
534
+ const headerQuoted = `[mcp_servers."${name}"]`
535
+
536
+ let headerIdx = -1
537
+ for (let i = 0; i < lines.length; i++) {
538
+ const t = lines[i].trim()
539
+ if (t === headerBare || t === headerQuoted) {
540
+ headerIdx = i
541
+ break
542
+ }
543
+ }
544
+
545
+ if (headerIdx === -1) {
546
+ // 標準形ヘッダが無い。ただし **ドット記法 / インラインテーブル / 空白入りヘッダ**
547
+ // (`mcp_servers.hub.url = ...` / `mcp_servers.hub = {..}` / `[mcp_servers . hub]`)で
548
+ // 既に定義されている場合、標準テーブルを追記すると重複定義(不正 TOML)になる。安全側で
549
+ // 何も変えずに返す(apply 側がスキップ扱いにする)。2026-07-15 レビュー指摘対応。
550
+ if (_codexServerAlreadyDefined(configText, name)) return configText
551
+ // 真に未定義 → 末尾に新規追記(Bundle 由来 server のみ・呼び出し側が保証)。
552
+ const block = [headerBare, ...kv.map(([k, v]) => `${k} = ${v}`)].join(eol)
553
+ let base = configText
554
+ if (base.length > 0 && !/\r?\n$/.test(base)) base += eol
555
+ const sep = base.length > 0 ? eol : "" // 既存内容とテーブルの間に空行を 1 つ挟む
556
+ return base + sep + block + eol
557
+ }
558
+
559
+ // テーブル終端 = 次の `[...]`(テーブル/配列テーブル)ヘッダ直前、無ければ末尾。
560
+ let endIdx = lines.length
561
+ for (let j = headerIdx + 1; j < lines.length; j++) {
562
+ if (lines[j].trim().startsWith("[")) {
563
+ endIdx = j
564
+ break
565
+ }
566
+ }
567
+
568
+ const toInsert = []
569
+ let urlLineIdx = -1
570
+ for (const [key, valStr] of kv) {
571
+ const re = new RegExp(`^(\\s*)${key}\\s*=`)
572
+ let found = -1
573
+ for (let i = headerIdx + 1; i < endIdx; i++) {
574
+ if (re.test(lines[i])) {
575
+ found = i
576
+ break
577
+ }
578
+ }
579
+ if (found >= 0) {
580
+ // 値だけ差し替え、インデント・キー左辺・**行末インラインコメント**を保持する。
581
+ lines[found] = _replaceTomlStringValue(lines[found], key, valStr)
582
+ if (key === "url") urlLineIdx = found
583
+ } else {
584
+ toInsert.push(`${key} = ${valStr}`)
585
+ }
586
+ }
587
+ if (toInsert.length) {
588
+ // 挿入位置: 既存 url 行があればその直後(url → 承認モードの順で可読)、
589
+ // 無ければヘッダ直後(この場合 kv 順で url→承認モードが入る)。
590
+ const anchor = urlLineIdx >= 0 ? urlLineIdx + 1 : headerIdx + 1
591
+ lines.splice(anchor, 0, ...toInsert)
592
+ }
593
+ return lines.join(eol)
594
+ }
595
+
596
+ /**
597
+ * Bundle の mcp_servers のうち承認モードを宣言したものについて、`~/.codex/config.toml` の
598
+ * 該当テーブルへ `default_tools_approval_mode`(と url)を安全マージする。
599
+ *
600
+ * - 承認モードが許可値でない server はスキップ(不正値を端末設定へ流さない)。
601
+ * - 変更が無ければ書き込まない(完全冪等)。
602
+ * - 書き込みは同一ディレクトリの一時ファイル → atomic rename(部分失敗で壊れた config を
603
+ * 残さない)。rename が失敗した場合は破壊的フォールバックを行わず中止し、既存 config を無変更で維持する。
604
+ *
605
+ * @param {Array<{name?: string, url?: string, default_tools_approval_mode?: string}>} servers
606
+ * @param {{configPath?: string, logger?: any, fsImpl?: any, isWindows?: boolean}} [opts]
607
+ * configPath 省略時は resolveCodexConfigPath()。fsImpl / isWindows はテスト用の注入点(既定は実 fs / 実 OS)。
608
+ * @returns {Promise<{updated: string[], skipped: string[]}>}
609
+ */
610
+ export async function applyCodexMcpApprovalModes(
611
+ servers,
612
+ { configPath, logger, fsImpl = fs, isWindows = process.platform === "win32" } = {},
613
+ ) {
614
+ const targets = (Array.isArray(servers) ? servers : []).filter(
615
+ (s) => s?.name && _CODEX_MCP_APPROVAL_MODES.has(s?.default_tools_approval_mode),
616
+ )
617
+ if (targets.length === 0) return { updated: [], skipped: [] }
618
+
619
+ const cfgPath = configPath || resolveCodexConfigPath()
620
+ let text = ""
621
+ // 既存ファイルの権限(mode)を引き継ぐ。config.toml には MCP の静的ヘッダ等の秘密が入り得る
622
+ // ため、新規作成は 0o600 に固定する(デフォルト権限 0644 で秘密を晒さない)。2026-07-15 対応。
623
+ let fileMode = 0o600
624
+ try {
625
+ const st = await fsImpl.stat(cfgPath)
626
+ fileMode = st.mode & 0o777
627
+ text = await fsImpl.readFile(cfgPath, "utf-8")
628
+ } catch (err) {
629
+ if (err.code !== "ENOENT") throw err
630
+ text = "" // 新規環境: 空から始める(mode は 0o600 のまま)
631
+ }
632
+
633
+ let next = text
634
+ const updated = []
635
+ const skipped = []
636
+ for (const s of targets) {
637
+ const candidate = upsertCodexMcpApprovalMode(next, {
638
+ name: s.name,
639
+ url: s.url,
640
+ approvalMode: s.default_tools_approval_mode,
641
+ })
642
+ if (candidate === next) {
643
+ // 変化なし。原因が「既に正しく反映済み(= no-op)」か「非標準形で upsert が編集を拒否した」
644
+ // かを検証で区別する。標準テーブルが健全なら no-op、そうでなければ適用不能としてスキップ報告。
645
+ if (!_verifyCodexServerTable(next, s.name, s.default_tools_approval_mode)) {
646
+ logger?.warn(
647
+ { name: s.name },
648
+ "codex config.toml の該当テーブルを安全に編集できないためスキップ(既存 config は不変)",
649
+ )
650
+ skipped.push(s.name)
651
+ }
652
+ continue
653
+ }
654
+ // ⚠️ 安全ネット: 書き込み前に「その server のテーブルが標準形でちょうど 1 つ・承認モードが
655
+ // 期待値でちょうど 1 つ・url は高々 1 つ・他形式(空白入り/インライン)の重複定義が無い」
656
+ // ことを検証する。素の文字列編集が非標準 config で誤動作しても、ここで弾いて **既存 config を
657
+ // 一切書き換えない**(worst case = 承認設定が付かない=現状維持)。
658
+ if (!_verifyCodexServerTable(candidate, s.name, s.default_tools_approval_mode)) {
659
+ logger?.warn(
660
+ { name: s.name },
661
+ "codex config.toml の該当テーブルを安全に編集できないためスキップ(既存 config は不変)",
662
+ )
663
+ skipped.push(s.name)
664
+ continue
665
+ }
666
+ next = candidate
667
+ updated.push(s.name)
668
+ }
669
+ if (next === text) return { updated: [], skipped } // 冪等 no-op / 全スキップ
670
+
671
+ // ⚠️ 失敗しても既存 config.toml を必ず維持する fail-safe な atomic write。
672
+ await _atomicWriteFilePreserving(cfgPath, next, fileMode, { fsImpl, isWindows, logger })
673
+ logger?.info({ updated, skipped }, "codex MCP approval mode を config.toml へ反映")
674
+ return { updated, skipped }
675
+ }
676
+
677
+ /**
678
+ * 失敗しても既存ファイルを必ず維持する atomic write(同一ディレクトリ一時ファイル → rename)。
679
+ *
680
+ * 要件(2026-07-15 レビュー指摘対応):
681
+ * - Unix では `chmod` 失敗で中止する(緩い権限 0644 の一時ファイルを本番化しない)。Windows は
682
+ * POSIX 権限概念が無いので chmod は best-effort(失敗無視)。
683
+ * - 一時ファイルは**最初から 0o600** で作成し(作成直後の 0644 露出を防ぐ)、その後に既存 mode へ合わせる。
684
+ * - 置換は全 OS で `rename`(libuv が Windows でも MOVEFILE_REPLACE_EXISTING で既存を原子置換)。
685
+ * rename は「完全成功」か「原本無変更」のどちらかなので、**失敗時は破壊的フォールバック(退避/削除)を
686
+ * 一切行わず throw** し、既存 config を必ず無変更で維持する(退避方式は置換+復元の二重失敗で消失しうるため廃止)。
687
+ * - 一時ファイルは PID + ランダム値で命名(並行実行の衝突回避)し、`finally` で必ず削除する。
688
+ *
689
+ * @param {string} targetPath
690
+ * @param {string} content
691
+ * @param {number} mode
692
+ * @param {{fsImpl?: any, isWindows?: boolean, logger?: any}} [opts]
693
+ */
694
+ async function _atomicWriteFilePreserving(
695
+ targetPath,
696
+ content,
697
+ mode,
698
+ { fsImpl = fs, isWindows = process.platform === "win32", logger } = {},
699
+ ) {
700
+ const dir = path.dirname(targetPath)
701
+ const base = path.basename(targetPath)
702
+ const rand = randomBytes(6).toString("hex")
703
+ const tmp = path.join(dir, `.${base}.hub-tmp-${process.pid}-${rand}`)
704
+ await fsImpl.mkdir(dir, { recursive: true })
705
+ try {
706
+ // 一時ファイルは**最初から 0o600** で作成する。writeFile の mode 未指定だと作成直後は 0644 に
707
+ // なり、config 内の静的ヘッダ等の秘密が一瞬でも world-readable に晒される(2026-07-15 指摘)。
708
+ await fsImpl.writeFile(tmp, content, { encoding: "utf-8", mode: 0o600 })
709
+ if (isWindows) {
710
+ // Windows は POSIX 権限が無い。既存 mode への合わせ込みは best-effort(失敗無視)。
711
+ try {
712
+ await fsImpl.chmod(tmp, mode)
713
+ } catch {
714
+ /* ignore on Windows */
715
+ }
716
+ } else {
717
+ // Unix: 既存ファイルの mode に合わせる。失敗したら中止(緩い権限のまま本番化しない)。
718
+ await fsImpl.chmod(tmp, mode)
719
+ }
720
+ // 全 OS で既存を原子置換する(libuv は Windows でも MOVEFILE_REPLACE_EXISTING で置換)。
721
+ // ⚠️ rename は「完全成功」か「原本無変更」のどちらかで、部分状態を残さない。よって失敗時は
722
+ // **破壊的フォールバック(原本を退避/削除する方式)を一切行わず throw** し、原本を必ず維持する。
723
+ // 退避方式は「置換失敗 + 復元失敗」で config.toml を消滅させうるため廃止(2026-07-15 指摘)。
724
+ await fsImpl.rename(tmp, targetPath)
725
+ } catch (err) {
726
+ logger?.warn(
727
+ { err: err?.message, path: targetPath },
728
+ "codex config.toml への安全な書き込みに失敗(既存 config は無変更で維持)",
729
+ )
730
+ throw err
731
+ } finally {
732
+ // 成功時 tmp は rename 済みで存在しない(force で no-op)。失敗時の残骸を必ず掃除する。
733
+ await fsImpl.rm(tmp, { force: true }).catch(() => {})
734
+ }
735
+ }
736
+
737
+ function _escapeRegExp(s) {
738
+ return String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
739
+ }
740
+
741
+ /**
742
+ * `name` の MCP サーバーが config.toml に**何らかの形で既に定義されている**かを判定する。
743
+ * 標準形ヘッダ `[mcp_servers.<name>]`(空白/引用ゆらぎ含む)に加え、**ドット記法**
744
+ * (`mcp_servers.<name>.url = ...`)・**インラインテーブル**(`mcp_servers.<name> = {..}`)も検出する。
745
+ * これらは標準テーブルを追記すると TOML の重複定義になるため、呼び出し側は変更をスキップする。
746
+ */
747
+ function _codexServerAlreadyDefined(text, name) {
748
+ const n = _escapeRegExp(name)
749
+ const bracket = new RegExp(`^\\s*\\[\\s*mcp_servers\\s*\\.\\s*"?${n}"?\\s*\\]\\s*$`, "m")
750
+ // `mcp_servers.<name>` の後が `.`(ドット記法サブキー)または `=`(インラインテーブル)。
751
+ const dotted = new RegExp(`^\\s*mcp_servers\\s*\\.\\s*"?${n}"?\\s*(\\.|=)`, "m")
752
+ return bracket.test(text) || dotted.test(text)
753
+ }
754
+
755
+ /**
756
+ * TOML の `key = "..."` 行について、**値(クォート文字列)だけ**を差し替えて返す。
757
+ * インデント・キー左辺・行末インラインコメント(`# ...`)を保持する。値がクォート文字列で
758
+ * 無い等でマッチしない場合はインデント保持の全置換にフォールバックする。
759
+ */
760
+ function _replaceTomlStringValue(line, key, quotedVal) {
761
+ const k = _escapeRegExp(key)
762
+ // group1 = "<indent>key = "、次に既存のクォート文字列(エスケープ対応)、group2 = 行末(コメント等)。
763
+ const re = new RegExp(`^(\\s*${k}\\s*=\\s*)"(?:[^"\\\\]|\\\\.)*"(.*)$`)
764
+ const m = line.match(re)
765
+ if (m) return `${m[1]}${quotedVal}${m[2]}`
766
+ const indent = line.match(/^(\s*)/)[1]
767
+ return `${indent}${key} = ${quotedVal}`
768
+ }
769
+
770
+ /**
771
+ * マージ結果の config.toml テキストで、`name` の MCP サーバーテーブルが**安全に**編集された
772
+ * 状態か検証する(書き込み前のフェイルセーフ)。以下を全て満たすときのみ true:
773
+ * - 標準形ヘッダ `[mcp_servers.<name>]`(空白/引用ゆらぎ含む)がちょうど 1 つで、それが厳密な
774
+ * 標準形である(重複テーブルや空白入り別形を作っていない)。
775
+ * - `mcp_servers.<name> = { ... }` のインラインテーブル/ドット記法定義が存在しない。
776
+ * - その標準テーブル内で `default_tools_approval_mode` がちょうど 1 つ、値が期待どおり。
777
+ * - `url` は高々 1 つ(重複していない)。
778
+ * いずれか外れたら false(=呼び出し側は書き込まず既存 config を保持する)。
779
+ */
780
+ function _verifyCodexServerTable(text, name, mode) {
781
+ const n = _escapeRegExp(name)
782
+ const lines = text.split(/\r?\n/)
783
+ const bracketRe = new RegExp(`^\\s*\\[\\s*mcp_servers\\s*\\.\\s*"?${n}"?\\s*\\]\\s*$`)
784
+ // ドット記法サブキー(`mcp_servers.hub.url = ...`)とインラインテーブル(`mcp_servers.hub = {..}`)
785
+ // の両方を検出する。`<name>` の後が `.` か `=` のどちらでも拾う(2026-07-15 レビュー指摘対応)。
786
+ const dottedRe = new RegExp(`^\\s*mcp_servers\\s*\\.\\s*"?${n}"?\\s*(\\.|=)`)
787
+ const headerBare = `[mcp_servers.${name}]`
788
+ const headerQuoted = `[mcp_servers."${name}"]`
789
+
790
+ let bracketCount = 0
791
+ let dottedCount = 0
792
+ let stdHeaderIdx = -1
793
+ for (let i = 0; i < lines.length; i++) {
794
+ if (bracketRe.test(lines[i])) {
795
+ bracketCount++
796
+ const t = lines[i].trim()
797
+ if (t === headerBare || t === headerQuoted) stdHeaderIdx = i
798
+ }
799
+ if (dottedRe.test(lines[i])) dottedCount++
800
+ }
801
+ if (bracketCount !== 1 || dottedCount !== 0 || stdHeaderIdx < 0) return false
802
+
803
+ let end = lines.length
804
+ for (let j = stdHeaderIdx + 1; j < lines.length; j++) {
805
+ if (lines[j].trim().startsWith("[")) {
806
+ end = j
807
+ break
808
+ }
809
+ }
810
+ let modeCount = 0
811
+ let urlCount = 0
812
+ let modeOk = false
813
+ const expectedVal = JSON.stringify(mode) // 例: "approve"
814
+ // 値(クォート文字列)だけを解析し、行末インラインコメント(` # ...`)は許容する。
815
+ // 行全体の厳密一致にすると `default_tools_approval_mode = "approve" # keep` を弾いてしまい、
816
+ // upsert が正しく更新しても書き込みがスキップされて古い値が残る(2026-07-15 レビュー指摘)。
817
+ const modeValRe = /^\s*default_tools_approval_mode\s*=\s*("(?:[^"\\]|\\.)*")\s*(?:#.*)?$/
818
+ for (let i = stdHeaderIdx + 1; i < end; i++) {
819
+ if (/^\s*default_tools_approval_mode\s*=/.test(lines[i])) {
820
+ modeCount++
821
+ const m = lines[i].match(modeValRe)
822
+ if (m && m[1] === expectedVal) modeOk = true
823
+ }
824
+ if (/^\s*url\s*=/.test(lines[i])) urlCount++
825
+ }
826
+ return modeCount === 1 && modeOk && urlCount <= 1
473
827
  }
474
828
 
475
829
  /**