@cocorograph/hub-agent 0.7.67 → 0.7.69
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/bin/hub-agent.mjs +8 -0
- package/package.json +1 -1
- package/src/claude-stream-bridge.mjs +30 -0
- package/src/codex-stream-bridge.mjs +242 -15
- package/src/hub-bundle.mjs +562 -48
- package/src/main.mjs +188 -0
- package/src/sibling-inject.mjs +31 -1
- package/src/ws-client.mjs +29 -2
package/src/hub-bundle.mjs
CHANGED
|
@@ -16,11 +16,11 @@
|
|
|
16
16
|
*
|
|
17
17
|
* 仕様書: ナレッジ/インフラ/cockpit-hub-hosted-integration-spec (id=6080)
|
|
18
18
|
*/
|
|
19
|
-
import { promises as fs } from "node:fs"
|
|
19
|
+
import { promises as fs, readFileSync } 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
|
+
import { randomBytes, createHash } from "node:crypto"
|
|
24
24
|
|
|
25
25
|
const HUB_AI_RULES_BEGIN = "<!-- BEGIN HUB-AI-RULES -->"
|
|
26
26
|
const HUB_AI_RULES_END = "<!-- END HUB-AI-RULES -->"
|
|
@@ -32,9 +32,19 @@ const _CLI_DIR_NAME = { claude: ".claude", codex: ".codex" }
|
|
|
32
32
|
|
|
33
33
|
function resolveClaudeDir(cliKind = "claude") {
|
|
34
34
|
// CLAUDE_DIR env override は既存の claude 専用テスト/運用で使われているため
|
|
35
|
-
// cli_kind='claude'
|
|
35
|
+
// cli_kind='claude' のときのみ効かせる。
|
|
36
|
+
//
|
|
37
|
+
// ⚠️ codex は **config / state / lock / bundle files で同一の canonical resolver を使う**
|
|
38
|
+
// (2026-07-16 修正)。以前は本関数が `CODEX_HOME` を無視して `CODEX_DIR || ~/.codex` を返す一方、
|
|
39
|
+
// config.toml のパス解決だけ `CODEX_HOME` を最優先していたため、active profile(CODEX_HOME 指定)
|
|
40
|
+
// 環境で「config は profile 側・state/lock/files は ~/.codex 側」に分裂した。codex 本体と同じく
|
|
41
|
+
// `CODEX_HOME` を最優先し、次に hub-agent の `CODEX_DIR` override、最後に `~/.codex` に揃える。
|
|
36
42
|
if (cliKind === "codex") {
|
|
37
|
-
return
|
|
43
|
+
return (
|
|
44
|
+
process.env.CODEX_HOME ||
|
|
45
|
+
process.env.CODEX_DIR ||
|
|
46
|
+
path.join(os.homedir(), _CLI_DIR_NAME.codex)
|
|
47
|
+
)
|
|
38
48
|
}
|
|
39
49
|
return process.env.CLAUDE_DIR || path.join(os.homedir(), _CLI_DIR_NAME.claude)
|
|
40
50
|
}
|
|
@@ -353,12 +363,27 @@ function runOnce(argv, { logger } = {}) {
|
|
|
353
363
|
*/
|
|
354
364
|
export const HUB_MCP_NAMES = new Set(["hub", "hub_staff"])
|
|
355
365
|
|
|
356
|
-
export async function applyMcpServers(
|
|
366
|
+
export async function applyMcpServers(
|
|
367
|
+
bundle,
|
|
368
|
+
{ agentId, agentToken, logger, nonInteractive = false, resolveBinImpl, runImpl } = {},
|
|
369
|
+
) {
|
|
357
370
|
const servers = Array.isArray(bundle?.mcp_servers) ? bundle.mcp_servers : []
|
|
358
371
|
if (servers.length === 0) return { added: [], skipped: [] }
|
|
372
|
+
// CLI 解決 / 実行はテスト注入可能(既定は実 which/spawn)。決定的テストのため。
|
|
373
|
+
const resolveBin = resolveBinImpl || resolveCliBin
|
|
374
|
+
const run = runImpl || runOnce
|
|
375
|
+
|
|
376
|
+
// ⚠️ 非対話(自動同期)モードでは Claude MCP の登録に一切触らない(2026-07-16 修正)。
|
|
377
|
+
// Claude 版は既存 server を **remove → add** で再登録するため、add が失敗すると正常に動いていた
|
|
378
|
+
// Hub MCP が消えてしまう(破壊的)。自動同期は files/hooks/setup(+ codex 承認補完)が目的で、
|
|
379
|
+
// Claude MCP の登録は enroll / 明示手動 sync(対話)に委ねる。ここでは非破壊に全 server を skip する。
|
|
380
|
+
if (nonInteractive) {
|
|
381
|
+
logger?.info?.("非対話モードのため Claude MCP 登録(remove→add)はスキップ(既存登録を破壊しない)")
|
|
382
|
+
return { added: [], skipped: servers.map((s) => s?.name || "<unnamed>") }
|
|
383
|
+
}
|
|
359
384
|
|
|
360
385
|
// `claude` CLI の存在確認 (which / where 相当)。なければ no-op。
|
|
361
|
-
const claudeBin = await
|
|
386
|
+
const claudeBin = await resolveBin("claude")
|
|
362
387
|
if (!claudeBin) {
|
|
363
388
|
logger?.info("claude CLI が PATH に無いため MCP server 自動登録をスキップ")
|
|
364
389
|
return { added: [], skipped: servers.map((s) => s.name) }
|
|
@@ -411,12 +436,12 @@ export async function applyMcpServers(bundle, { agentId, agentToken, logger } =
|
|
|
411
436
|
if (wantHeader) {
|
|
412
437
|
// 既存があると add が弾かれるため、先に落とす(無ければ失敗するので無視)。
|
|
413
438
|
try {
|
|
414
|
-
await
|
|
439
|
+
await run([claudeBin, "mcp", "remove", s.name, "-s", scope], { logger })
|
|
415
440
|
} catch {
|
|
416
441
|
// 未登録 → remove は失敗するが正常系。
|
|
417
442
|
}
|
|
418
443
|
}
|
|
419
|
-
await
|
|
444
|
+
await run(argv, { logger })
|
|
420
445
|
added.push(s.name)
|
|
421
446
|
} catch (err) {
|
|
422
447
|
// ヘッダ不要の外部 MCP は既存なら "already exists" で失敗する。これは「既に正しく
|
|
@@ -444,45 +469,71 @@ export async function applyMcpServers(bundle, { agentId, agentToken, logger } =
|
|
|
444
469
|
* ユーザー自身が `codex mcp login hub`/`hub_staff` を一度実行する運用に
|
|
445
470
|
* なる可能性が高い)。
|
|
446
471
|
*/
|
|
447
|
-
export async function applyCodexMcpServers(
|
|
472
|
+
export async function applyCodexMcpServers(
|
|
473
|
+
bundle,
|
|
474
|
+
{ logger, nonInteractive = false, resolveBinImpl, runImpl } = {},
|
|
475
|
+
) {
|
|
448
476
|
const servers = Array.isArray(bundle?.mcp_servers) ? bundle.mcp_servers : []
|
|
449
|
-
if (servers.length === 0)
|
|
450
|
-
|
|
451
|
-
const codexBin = await resolveCliBin("codex")
|
|
452
|
-
if (!codexBin) {
|
|
453
|
-
logger?.info("codex CLI が PATH に無いため MCP server 自動登録をスキップ")
|
|
454
|
-
return { added: [], skipped: servers.map((s) => s.name) }
|
|
477
|
+
if (servers.length === 0) {
|
|
478
|
+
return { added: [], skipped: [], approvalUpdated: [], approvalPending: [], approvalSkipped: [], approvalError: null }
|
|
455
479
|
}
|
|
480
|
+
// CLI バイナリ解決 / 実行はテスト注入可能(既定は実 which/spawn)。実 subprocess に依存しない
|
|
481
|
+
// 決定的テストのため(環境の TMPDIR noexec / PATH 解決差で偽バイナリ方式が不安定になる問題の対処)。
|
|
482
|
+
const resolveBin = resolveBinImpl || resolveCliBin
|
|
483
|
+
const run = runImpl || runOnce
|
|
456
484
|
|
|
457
485
|
const added = []
|
|
458
486
|
const skipped = []
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
487
|
+
|
|
488
|
+
// ⚠️ 非対話(自動同期)モードでは **`codex mcp add` を一切呼ばない**。`codex mcp add` は Hub MCP の
|
|
489
|
+
// OAuth 2.0 + PKCE 登録を伴い、ブラウザ認証ポップアップを起動しうる(バックグラウンド自動同期が
|
|
490
|
+
// ユーザーの操作を勝手に奪う)。登録=対話(enroll / 明示手動 sync / 明示的認証)に限定し、自動同期は
|
|
491
|
+
// 「既に登録済み(= OAuth 認証済み)テーブルへ承認モードを補完する」ことだけを行う。
|
|
492
|
+
if (!nonInteractive) {
|
|
493
|
+
const codexBin = await resolveBin("codex")
|
|
494
|
+
if (!codexBin) {
|
|
495
|
+
logger?.info("codex CLI が PATH に無いため MCP server 自動登録をスキップ")
|
|
496
|
+
skipped.push(...servers.map((s) => s?.name || "<unnamed>"))
|
|
497
|
+
} else {
|
|
498
|
+
for (const s of servers) {
|
|
499
|
+
if (!s?.name || !s?.url) {
|
|
500
|
+
skipped.push(s?.name || "<unnamed>")
|
|
501
|
+
continue
|
|
502
|
+
}
|
|
503
|
+
const argv = [codexBin, "mcp", "add", s.name, "--url", s.url]
|
|
504
|
+
try {
|
|
505
|
+
await run(argv, { logger })
|
|
506
|
+
added.push(s.name)
|
|
507
|
+
} catch (err) {
|
|
508
|
+
logger?.warn({ name: s.name, err: err.message }, "codex mcp add failed")
|
|
509
|
+
skipped.push(s.name)
|
|
510
|
+
}
|
|
511
|
+
}
|
|
471
512
|
}
|
|
472
513
|
}
|
|
514
|
+
|
|
473
515
|
// MCP 承認モードを `~/.codex/config.toml` に反映する(2026-07-15)。
|
|
474
516
|
// ⚠️ `codex mcp add` には承認モードの永続設定オプションが無いため、Bundle が宣言した
|
|
475
517
|
// `default_tools_approval_mode` を config.toml の `[mcp_servers.<name>]` テーブルへ直接
|
|
476
|
-
//
|
|
477
|
-
//
|
|
518
|
+
// マージする。対話 add の成否に関わらず全 target に対して実行する(add が「already exists」で
|
|
519
|
+
// 失敗しても承認設定は更新する要件)。非対話モードでは onlyExisting=true で「既存テーブルの承認モード
|
|
520
|
+
// 補完」だけを行い、未登録 server には新規テーブルを作らず pending 報告する。I/O・ネットワーク失敗
|
|
521
|
+
// (transient)は approvalError として上位(syncBundle)へ返し、state に「再試行すべき失敗」として
|
|
522
|
+
// 記録させる。config 破壊は _atomicWriteFilePreserving が防ぐ。
|
|
478
523
|
let approvalUpdated = []
|
|
524
|
+
let approvalPending = []
|
|
525
|
+
let approvalSkipped = []
|
|
526
|
+
let approvalError = null
|
|
479
527
|
try {
|
|
480
|
-
const res = await applyCodexMcpApprovalModes(servers, { logger })
|
|
528
|
+
const res = await applyCodexMcpApprovalModes(servers, { logger, onlyExisting: nonInteractive })
|
|
481
529
|
approvalUpdated = res.updated
|
|
530
|
+
approvalPending = res.pending || []
|
|
531
|
+
approvalSkipped = res.skipped || []
|
|
482
532
|
} catch (err) {
|
|
483
533
|
logger?.warn({ err: err.message }, "codex MCP approval mode の config.toml 反映に失敗")
|
|
534
|
+
approvalError = err?.message || String(err)
|
|
484
535
|
}
|
|
485
|
-
return { added, skipped, approvalUpdated }
|
|
536
|
+
return { added, skipped, approvalUpdated, approvalPending, approvalSkipped, approvalError }
|
|
486
537
|
}
|
|
487
538
|
|
|
488
539
|
// Codex の MCP サーバーごと承認モードとして `~/.codex/config.toml` へ書いてよい許可値
|
|
@@ -497,9 +548,8 @@ const _CODEX_MCP_APPROVAL_MODES = new Set(["auto", "prompt", "writes", "approve"
|
|
|
497
548
|
* Windows/macOS/Linux とも `os.homedir()`(Windows は USERPROFILE)で解決する。
|
|
498
549
|
*/
|
|
499
550
|
function resolveCodexConfigPath() {
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
return path.join(dir, "config.toml")
|
|
551
|
+
// config / state / lock / bundle files で同一 resolver を使う(2026-07-16 修正)。
|
|
552
|
+
return path.join(resolveClaudeDir("codex"), "config.toml")
|
|
503
553
|
}
|
|
504
554
|
|
|
505
555
|
/**
|
|
@@ -517,10 +567,14 @@ function resolveCodexConfigPath() {
|
|
|
517
567
|
* - 不正な approvalMode は無視(url のみ・または no-op)。改行コード(LF/CRLF)は維持する。
|
|
518
568
|
*
|
|
519
569
|
* @param {string} configText
|
|
520
|
-
* @param {{name: string, url?: string, approvalMode?: string}} opts
|
|
570
|
+
* @param {{name: string, url?: string, approvalMode?: string, onlyExisting?: boolean}} opts
|
|
571
|
+
* onlyExisting=true のとき、標準テーブルが**まだ無い** server には新規テーブルを追記しない
|
|
572
|
+
* (config を無変更で返す)。自動同期(非対話)で使う: 未登録 MCP に承認モードだけ書くと
|
|
573
|
+
* OAuth 未認証の「半登録テーブル」を作ってしまうため、登録は対話経路(`codex mcp add`)に
|
|
574
|
+
* 限定し、自動同期は既存テーブルの承認モード補完だけを行う。
|
|
521
575
|
* @returns {string}
|
|
522
576
|
*/
|
|
523
|
-
export function upsertCodexMcpApprovalMode(configText, { name, url, approvalMode } = {}) {
|
|
577
|
+
export function upsertCodexMcpApprovalMode(configText, { name, url, approvalMode, onlyExisting = false } = {}) {
|
|
524
578
|
if (!name) return configText
|
|
525
579
|
const mode = _CODEX_MCP_APPROVAL_MODES.has(approvalMode) ? approvalMode : null
|
|
526
580
|
const kv = []
|
|
@@ -548,6 +602,9 @@ export function upsertCodexMcpApprovalMode(configText, { name, url, approvalMode
|
|
|
548
602
|
// 既に定義されている場合、標準テーブルを追記すると重複定義(不正 TOML)になる。安全側で
|
|
549
603
|
// 何も変えずに返す(apply 側がスキップ扱いにする)。2026-07-15 レビュー指摘対応。
|
|
550
604
|
if (_codexServerAlreadyDefined(configText, name)) return configText
|
|
605
|
+
// 自動同期(非対話)は未登録テーブルを作らない。OAuth 未認証の半登録を避け、登録は
|
|
606
|
+
// 対話経路(codex mcp add)に委ねる。呼び出し側が pending として記録する。
|
|
607
|
+
if (onlyExisting) return configText
|
|
551
608
|
// 真に未定義 → 末尾に新規追記(Bundle 由来 server のみ・呼び出し側が保証)。
|
|
552
609
|
const block = [headerBare, ...kv.map(([k, v]) => `${k} = ${v}`)].join(eol)
|
|
553
610
|
let base = configText
|
|
@@ -603,18 +660,22 @@ export function upsertCodexMcpApprovalMode(configText, { name, url, approvalMode
|
|
|
603
660
|
* 残さない)。rename が失敗した場合は破壊的フォールバックを行わず中止し、既存 config を無変更で維持する。
|
|
604
661
|
*
|
|
605
662
|
* @param {Array<{name?: string, url?: string, default_tools_approval_mode?: string}>} servers
|
|
606
|
-
* @param {{configPath?: string, logger?: any, fsImpl?: any, isWindows?: boolean}} [opts]
|
|
663
|
+
* @param {{configPath?: string, logger?: any, fsImpl?: any, isWindows?: boolean, onlyExisting?: boolean}} [opts]
|
|
607
664
|
* configPath 省略時は resolveCodexConfigPath()。fsImpl / isWindows はテスト用の注入点(既定は実 fs / 実 OS)。
|
|
608
|
-
*
|
|
665
|
+
* onlyExisting=true(自動同期・非対話)のとき、標準テーブルが**まだ無い** server には新規
|
|
666
|
+
* テーブルを追記せず `pending` として報告する(未登録 MCP に承認モードだけ書いて OAuth 未認証の
|
|
667
|
+
* 半登録を作らない。登録は対話経路に委ねる)。
|
|
668
|
+
* @returns {Promise<{updated: string[], skipped: string[], pending: string[]}>}
|
|
669
|
+
* pending: onlyExisting で未登録のためスキップした server(対話 sync / codex mcp login が必要)。
|
|
609
670
|
*/
|
|
610
671
|
export async function applyCodexMcpApprovalModes(
|
|
611
672
|
servers,
|
|
612
|
-
{ configPath, logger, fsImpl = fs, isWindows = process.platform === "win32" } = {},
|
|
673
|
+
{ configPath, logger, fsImpl = fs, isWindows = process.platform === "win32", onlyExisting = false } = {},
|
|
613
674
|
) {
|
|
614
675
|
const targets = (Array.isArray(servers) ? servers : []).filter(
|
|
615
676
|
(s) => s?.name && _CODEX_MCP_APPROVAL_MODES.has(s?.default_tools_approval_mode),
|
|
616
677
|
)
|
|
617
|
-
if (targets.length === 0) return { updated: [], skipped: [] }
|
|
678
|
+
if (targets.length === 0) return { updated: [], skipped: [], pending: [] }
|
|
618
679
|
|
|
619
680
|
const cfgPath = configPath || resolveCodexConfigPath()
|
|
620
681
|
let text = ""
|
|
@@ -633,11 +694,32 @@ export async function applyCodexMcpApprovalModes(
|
|
|
633
694
|
let next = text
|
|
634
695
|
const updated = []
|
|
635
696
|
const skipped = []
|
|
697
|
+
const pending = []
|
|
636
698
|
for (const s of targets) {
|
|
699
|
+
// 自動同期(onlyExisting)で標準テーブルが無い server は、新規テーブルを作らない。
|
|
700
|
+
// 非標準形(空白入り/ドット記法/インライン)で既に定義されていれば「安全に編集できない」
|
|
701
|
+
// → skipped、真に未登録なら「登録が必要」→ pending として区別する(ブラウザは開かない)。
|
|
702
|
+
if (onlyExisting && !_codexStandardTableExists(next, s.name)) {
|
|
703
|
+
if (_codexServerAlreadyDefined(next, s.name)) {
|
|
704
|
+
logger?.warn(
|
|
705
|
+
{ name: s.name },
|
|
706
|
+
"codex config.toml の該当テーブルが非標準形のため自動同期では編集できずスキップ(既存 config は不変)",
|
|
707
|
+
)
|
|
708
|
+
skipped.push(s.name)
|
|
709
|
+
} else {
|
|
710
|
+
logger?.info(
|
|
711
|
+
{ name: s.name },
|
|
712
|
+
"codex MCP が未登録のため承認モードの自動反映を保留(対話 sync / codex mcp login が必要)",
|
|
713
|
+
)
|
|
714
|
+
pending.push(s.name)
|
|
715
|
+
}
|
|
716
|
+
continue
|
|
717
|
+
}
|
|
637
718
|
const candidate = upsertCodexMcpApprovalMode(next, {
|
|
638
719
|
name: s.name,
|
|
639
720
|
url: s.url,
|
|
640
721
|
approvalMode: s.default_tools_approval_mode,
|
|
722
|
+
onlyExisting,
|
|
641
723
|
})
|
|
642
724
|
if (candidate === next) {
|
|
643
725
|
// 変化なし。原因が「既に正しく反映済み(= no-op)」か「非標準形で upsert が編集を拒否した」
|
|
@@ -666,12 +748,12 @@ export async function applyCodexMcpApprovalModes(
|
|
|
666
748
|
next = candidate
|
|
667
749
|
updated.push(s.name)
|
|
668
750
|
}
|
|
669
|
-
if (next === text) return { updated: [], skipped } // 冪等 no-op / 全スキップ
|
|
751
|
+
if (next === text) return { updated: [], skipped, pending } // 冪等 no-op / 全スキップ
|
|
670
752
|
|
|
671
753
|
// ⚠️ 失敗しても既存 config.toml を必ず維持する fail-safe な atomic write。
|
|
672
754
|
await _atomicWriteFilePreserving(cfgPath, next, fileMode, { fsImpl, isWindows, logger })
|
|
673
|
-
logger?.info({ updated, skipped }, "codex MCP approval mode を config.toml へ反映")
|
|
674
|
-
return { updated, skipped }
|
|
755
|
+
logger?.info({ updated, skipped, pending }, "codex MCP approval mode を config.toml へ反映")
|
|
756
|
+
return { updated, skipped, pending }
|
|
675
757
|
}
|
|
676
758
|
|
|
677
759
|
/**
|
|
@@ -752,6 +834,19 @@ function _codexServerAlreadyDefined(text, name) {
|
|
|
752
834
|
return bracket.test(text) || dotted.test(text)
|
|
753
835
|
}
|
|
754
836
|
|
|
837
|
+
/**
|
|
838
|
+
* `name` の MCP サーバーが **標準形ヘッダ** `[mcp_servers.<name>]`(空白/引用ゆらぎ含む)で
|
|
839
|
+
* 定義されているかを判定する。自動同期(onlyExisting)で「既に登録済み(承認モードだけ補完すれば
|
|
840
|
+
* よい)」server と「未登録(登録=OAuth が必要なため保留すべき)」server を区別するのに使う。
|
|
841
|
+
* 非標準形(ドット記法/インライン/空白入り)は標準テーブルではないため false を返す
|
|
842
|
+
* (その場合の可否判定は `_codexServerAlreadyDefined` と組み合わせて呼び出し側が行う)。
|
|
843
|
+
*/
|
|
844
|
+
function _codexStandardTableExists(text, name) {
|
|
845
|
+
const n = _escapeRegExp(name)
|
|
846
|
+
const bracket = new RegExp(`^\\s*\\[\\s*mcp_servers\\s*\\.\\s*"?${n}"?\\s*\\]\\s*$`, "m")
|
|
847
|
+
return bracket.test(text)
|
|
848
|
+
}
|
|
849
|
+
|
|
755
850
|
/**
|
|
756
851
|
* TOML の `key = "..."` 行について、**値(クォート文字列)だけ**を差し替えて返す。
|
|
757
852
|
* インデント・キー左辺・行末インラインコメント(`# ...`)を保持する。値がクォート文字列で
|
|
@@ -867,6 +962,351 @@ export function resolveSyncTargets(availableClis) {
|
|
|
867
962
|
return detected.length > 0 ? detected : ["claude"]
|
|
868
963
|
}
|
|
869
964
|
|
|
965
|
+
/**
|
|
966
|
+
* `<cliDir>/.hub_ai_state.json` の絶対パス。setup_hub_ai.py(Python)が `installed_version` を書き、
|
|
967
|
+
* hub-agent(JS)が `mcp_approval_applied_version` / `last_sync_*` を書く共有 state。
|
|
968
|
+
* 同一 syncBundle シーケンス内では post_install(setup) → 承認反映の順で直列に書き、跨プロセスの
|
|
969
|
+
* 競合は acquireBundleSyncLock で防ぐ(read-modify-write の lost update を避ける)。
|
|
970
|
+
*/
|
|
971
|
+
export function hubAiStatePath(cliKind = "claude") {
|
|
972
|
+
return path.join(resolveClaudeDir(cliKind), ".hub_ai_state.json")
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
/**
|
|
976
|
+
* ドット区切り version 文字列を数値比較する。a < b で -1、a === b で 0、a > b で 1。
|
|
977
|
+
* 解析不能な要素は 0 扱い(session_start.py の `_version_tuple` と同じ寛容さ)。
|
|
978
|
+
*/
|
|
979
|
+
export function compareBundleVersions(a, b) {
|
|
980
|
+
const pa = String(a || "0").split(".").map((x) => parseInt(x, 10) || 0)
|
|
981
|
+
const pb = String(b || "0").split(".").map((x) => parseInt(x, 10) || 0)
|
|
982
|
+
const len = Math.max(pa.length, pb.length)
|
|
983
|
+
for (let i = 0; i < len; i++) {
|
|
984
|
+
const da = pa[i] || 0
|
|
985
|
+
const db = pb[i] || 0
|
|
986
|
+
if (da < db) return -1
|
|
987
|
+
if (da > db) return 1
|
|
988
|
+
}
|
|
989
|
+
return 0
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
// codex の恒久 skip とみなす last_sync_error(同一 config・同一 version 再試行を抑止する終端状態)。
|
|
993
|
+
const _CODEX_TERMINAL_ERRORS = new Set(["nonstandard_config", "mcp_not_registered"])
|
|
994
|
+
|
|
995
|
+
/**
|
|
996
|
+
* state + 現在の config.toml テキストから、**有効な** 恒久 skip version を返す(無効なら null)。
|
|
997
|
+
* `last_sync_error` が terminal(非標準 config / 未登録)**かつ** 記録した config fingerprint が現在の
|
|
998
|
+
* config と一致するときだけ有効。config が変われば(あるいは io にリセットされれば)null を返し、再試行を許す。
|
|
999
|
+
* heartbeat 報告(sync)と daemon の `_bundleTargetSatisfied`(async)で同一判定を使うための共通純関数。
|
|
1000
|
+
*/
|
|
1001
|
+
export function codexTerminalSkipVersion(state, configText) {
|
|
1002
|
+
if (!state || !_CODEX_TERMINAL_ERRORS.has(state.last_sync_error)) return null
|
|
1003
|
+
const v = state.mcp_approval_skipped_version
|
|
1004
|
+
const h = state.mcp_approval_skipped_config_hash
|
|
1005
|
+
if (!v || !h) return null
|
|
1006
|
+
const cur = createHash("sha256")
|
|
1007
|
+
.update(typeof configText === "string" ? configText : "")
|
|
1008
|
+
.digest("hex")
|
|
1009
|
+
return h === cur ? v : null
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
/** `.hub_ai_state.json` を **sync** で読む(壊れていれば {})。heartbeat 用。 */
|
|
1013
|
+
export function readHubAiStateSync(cliKind = "claude") {
|
|
1014
|
+
try {
|
|
1015
|
+
const obj = JSON.parse(readFileSync(hubAiStatePath(cliKind), "utf-8"))
|
|
1016
|
+
return obj && typeof obj === "object" ? obj : {}
|
|
1017
|
+
} catch {
|
|
1018
|
+
return {}
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
/** codex config.toml のテキストを読む(読めなければ空文字)。terminal skip 判定用。 */
|
|
1023
|
+
export async function readCodexConfigText({ fsImpl = fs } = {}) {
|
|
1024
|
+
try {
|
|
1025
|
+
return await fsImpl.readFile(resolveCodexConfigPath(), "utf-8")
|
|
1026
|
+
} catch {
|
|
1027
|
+
return ""
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
/**
|
|
1032
|
+
* heartbeat が報告する **setup 完了済み** version(`installed_version`)を sync で読む。
|
|
1033
|
+
* ⚠️ `<cliDir>/scripts/manifest.json` は post_install/setup **より先に** 配置されるため、setup が失敗しても
|
|
1034
|
+
* manifest だけ新版になる。backend の「反映済み」判定に manifest を使うと setup 失敗を「適用済み」と誤報告して
|
|
1035
|
+
* 再要求が止まる。正本は setup 完了時に書かれる `installed_version`(2026-07-16 修正)。未セットアップは null。
|
|
1036
|
+
*/
|
|
1037
|
+
export function readCliInstalledVersionSync(cliKind) {
|
|
1038
|
+
const st = readHubAiStateSync(cliKind)
|
|
1039
|
+
return typeof st.installed_version === "string" ? st.installed_version : null
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
/**
|
|
1043
|
+
* `<cliDir>/scripts/manifest.json` の version を **sync** で読む(未セットアップは null)。
|
|
1044
|
+
* これは「配置済み(setup 完了とは限らない)」= downloaded version。backend の反映判定には使わず、参考報告用。
|
|
1045
|
+
*/
|
|
1046
|
+
export function readCliBundleVersionSync(cliKind) {
|
|
1047
|
+
try {
|
|
1048
|
+
const p = path.join(resolveClaudeDir(cliKind), "scripts", "manifest.json")
|
|
1049
|
+
const data = JSON.parse(readFileSync(p, "utf-8"))
|
|
1050
|
+
return typeof data?.version === "string" ? data.version : null
|
|
1051
|
+
} catch {
|
|
1052
|
+
return null
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
/**
|
|
1057
|
+
* codex の承認反映状況を **sync** で読む(heartbeat 用)。
|
|
1058
|
+
* `applied` = 承認反映済み version。`skipped` = **fingerprint 検証済み** の有効な恒久 skip version
|
|
1059
|
+
* (config が変われば無効化されて null。io リセット後も null)。backend はこれで再要求を抑止/再開する。
|
|
1060
|
+
*/
|
|
1061
|
+
export function readCodexApprovalStatusSync() {
|
|
1062
|
+
const state = readHubAiStateSync("codex")
|
|
1063
|
+
let configText = ""
|
|
1064
|
+
try {
|
|
1065
|
+
configText = readFileSync(resolveCodexConfigPath(), "utf-8")
|
|
1066
|
+
} catch {
|
|
1067
|
+
/* config 無し → 空文字(skip fingerprint は一致しないので skip は無効化される) */
|
|
1068
|
+
}
|
|
1069
|
+
return {
|
|
1070
|
+
applied:
|
|
1071
|
+
typeof state.mcp_approval_applied_version === "string"
|
|
1072
|
+
? state.mcp_approval_applied_version
|
|
1073
|
+
: null,
|
|
1074
|
+
skipped: codexTerminalSkipVersion(state, configText),
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
/** `.hub_ai_state.json` を読む(壊れていれば {})。 */
|
|
1079
|
+
export async function readHubAiState(cliKind = "claude", { fsImpl = fs } = {}) {
|
|
1080
|
+
try {
|
|
1081
|
+
const obj = JSON.parse(await fsImpl.readFile(hubAiStatePath(cliKind), "utf-8"))
|
|
1082
|
+
return obj && typeof obj === "object" ? obj : {}
|
|
1083
|
+
} catch {
|
|
1084
|
+
return {}
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
/**
|
|
1089
|
+
* `.hub_ai_state.json` に patch をマージ保存する(既存キーは保持)。失敗は warn のみ。
|
|
1090
|
+
* ⚠️ **atomic write**(temp → rename)で torn read / 破損を防ぐ(2026-07-16)。並行書き込みの主保護は
|
|
1091
|
+
* bundle 同期ロックだが、SessionStart(Python)等ロック外の reader/writer もあるため atomic を defense-in-depth
|
|
1092
|
+
* で担保する(読む側は常に「古い完全な JSON」か「新しい完全な JSON」のどちらかを見る)。
|
|
1093
|
+
*/
|
|
1094
|
+
export async function updateHubAiState(cliKind, patch, { fsImpl = fs, logger } = {}) {
|
|
1095
|
+
const p = hubAiStatePath(cliKind)
|
|
1096
|
+
const state = await readHubAiState(cliKind, { fsImpl })
|
|
1097
|
+
const next = { ...state, ...patch }
|
|
1098
|
+
let mode = 0o644
|
|
1099
|
+
try {
|
|
1100
|
+
mode = (await fsImpl.stat(p)).mode & 0o777
|
|
1101
|
+
} catch {
|
|
1102
|
+
/* 新規 → 0o644 */
|
|
1103
|
+
}
|
|
1104
|
+
try {
|
|
1105
|
+
await fsImpl.mkdir(path.dirname(p), { recursive: true })
|
|
1106
|
+
await _atomicWriteFilePreserving(p, JSON.stringify(next, null, 2), mode, { fsImpl, logger })
|
|
1107
|
+
} catch (err) {
|
|
1108
|
+
logger?.warn?.({ err: err?.message }, ".hub_ai_state.json の書き込みに失敗")
|
|
1109
|
+
}
|
|
1110
|
+
return next
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
/**
|
|
1114
|
+
* 跨プロセス同期ロック(CLI 種別 = configDir 単位)。daemon / SessionStart 経由の
|
|
1115
|
+
* `hub-agent sync-bundle` / 手動 sync が同時に走っても、同一 cli_kind の bundle 同期は 1 本に直列化する。
|
|
1116
|
+
* mkdir は atomic なので EEXIST でロック保持を検知できる。TTL 超過の stale ロックは回収する
|
|
1117
|
+
* (プロセスがクラッシュしてロックを残した場合の自己回復)。取得できなければ null を返す(呼び出し側は skip)。
|
|
1118
|
+
*
|
|
1119
|
+
* @returns {Promise<null | (() => Promise<void>)>} release 関数(取得成功時)、または null(保持中)。
|
|
1120
|
+
*/
|
|
1121
|
+
/** PID が生存しているか(signal 0)。EPERM は「存在するが権限なし=生きている」。 */
|
|
1122
|
+
function _pidAlive(pid) {
|
|
1123
|
+
if (!Number.isInteger(pid) || pid <= 0) return false
|
|
1124
|
+
try {
|
|
1125
|
+
process.kill(pid, 0)
|
|
1126
|
+
return true
|
|
1127
|
+
} catch (err) {
|
|
1128
|
+
return err?.code === "EPERM"
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
export async function acquireBundleSyncLock(
|
|
1133
|
+
cliKind = "claude",
|
|
1134
|
+
{ fsImpl = fs, ttlMs = 5 * 60 * 1000, nowMs, logger, pidAliveImpl } = {},
|
|
1135
|
+
) {
|
|
1136
|
+
const dir = resolveClaudeDir(cliKind)
|
|
1137
|
+
const lockDir = path.join(dir, ".hub_ai_sync.lock")
|
|
1138
|
+
const metaPath = path.join(lockDir, "owner.json")
|
|
1139
|
+
const now = typeof nowMs === "number" ? nowMs : Date.now()
|
|
1140
|
+
const isAlive = pidAliveImpl || _pidAlive
|
|
1141
|
+
await fsImpl.mkdir(dir, { recursive: true })
|
|
1142
|
+
|
|
1143
|
+
// 各取得ごとに一意な token を発行し、**自分の token と一致するときだけ lockDir を消す** release を返す。
|
|
1144
|
+
// stale 回収でロックが他者へ渡った後に、元保持者の release が他者のロックを誤削除するのを防ぐ。
|
|
1145
|
+
const makeRelease = (token) => async () => {
|
|
1146
|
+
try {
|
|
1147
|
+
const meta = JSON.parse(await fsImpl.readFile(metaPath, "utf-8"))
|
|
1148
|
+
if (meta?.token && meta.token !== token) return // 自分のものでない → 触らない
|
|
1149
|
+
} catch {
|
|
1150
|
+
/* meta 無し/破損: 自分が取得直後で meta を書けなかった等 → 下で後始末する */
|
|
1151
|
+
}
|
|
1152
|
+
try {
|
|
1153
|
+
await fsImpl.rm(lockDir, { recursive: true, force: true })
|
|
1154
|
+
} catch {
|
|
1155
|
+
/* best-effort */
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
const tryAcquire = async () => {
|
|
1160
|
+
const token = randomBytes(12).toString("hex")
|
|
1161
|
+
try {
|
|
1162
|
+
await fsImpl.mkdir(lockDir) // recursive:false → 既存なら EEXIST(atomic)
|
|
1163
|
+
} catch (err) {
|
|
1164
|
+
if (err?.code === "EEXIST") return null
|
|
1165
|
+
throw err
|
|
1166
|
+
}
|
|
1167
|
+
// ロック取得成功。owner.json(token / pid / at)を書く。
|
|
1168
|
+
// ⚠️ owner.json の書き込みに失敗したら **ロック取得自体を失敗させる**(2026-07-16 修正)。
|
|
1169
|
+
// owner 無しのロックを残すと staleness 判定が pid ベースにならず、初期化窓の曖昧さが復活するため。
|
|
1170
|
+
try {
|
|
1171
|
+
await fsImpl.writeFile(
|
|
1172
|
+
metaPath,
|
|
1173
|
+
JSON.stringify({ token, pid: process.pid, at: now }),
|
|
1174
|
+
{ mode: 0o600 },
|
|
1175
|
+
)
|
|
1176
|
+
} catch (err) {
|
|
1177
|
+
logger?.warn?.({ err: err?.message, cliKind }, "lock owner.json 書き込み失敗 → ロックを解放")
|
|
1178
|
+
try {
|
|
1179
|
+
await fsImpl.rm(lockDir, { recursive: true, force: true })
|
|
1180
|
+
} catch {
|
|
1181
|
+
/* best-effort */
|
|
1182
|
+
}
|
|
1183
|
+
return null
|
|
1184
|
+
}
|
|
1185
|
+
return makeRelease(token)
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
// stale ロックの所有権を **atomic rename で獲得** する(2026-07-16 修正)。複数プロセスが同一 stale を
|
|
1189
|
+
// 検知しても、lockDir を一意な隔離名へ rename できるのは 1 プロセスだけ(他は ENOENT)。`rm→mkdir` の
|
|
1190
|
+
// 非原子的な回収では両者が mkdir 成功して二重取得できたため、rename で「回収の勝者」を 1 本に絞る。
|
|
1191
|
+
const reclaimStale = async () => {
|
|
1192
|
+
const isoName = `${lockDir}.stale-${randomBytes(6).toString("hex")}`
|
|
1193
|
+
try {
|
|
1194
|
+
await fsImpl.rename(lockDir, isoName)
|
|
1195
|
+
} catch {
|
|
1196
|
+
return false // 別プロセスが先に回収した(ENOENT 等)→ 敗者
|
|
1197
|
+
}
|
|
1198
|
+
try {
|
|
1199
|
+
await fsImpl.rm(isoName, { recursive: true, force: true })
|
|
1200
|
+
} catch {
|
|
1201
|
+
/* 隔離ディレクトリの掃除失敗は無害 */
|
|
1202
|
+
}
|
|
1203
|
+
return true
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
const first = await tryAcquire()
|
|
1207
|
+
if (first) return first
|
|
1208
|
+
|
|
1209
|
+
// 保持中 → stale 判定。
|
|
1210
|
+
// ⚠️ 生存中 PID からは TTL だけでロックを奪わない(2026-07-16 修正)。5 分以上かかる正当な同期を
|
|
1211
|
+
// 途中から並行実行させないため。回収するのは「保持プロセスが死亡」または「owner.json 不在かつ
|
|
1212
|
+
// lockDir mtime が TTL 超過(= crash で owner を書けなかった / 初期化窓を大幅超過)」のときだけ。
|
|
1213
|
+
let stale = false
|
|
1214
|
+
let ownerMeta = null
|
|
1215
|
+
try {
|
|
1216
|
+
ownerMeta = JSON.parse(await fsImpl.readFile(metaPath, "utf-8"))
|
|
1217
|
+
} catch {
|
|
1218
|
+
ownerMeta = null
|
|
1219
|
+
}
|
|
1220
|
+
if (ownerMeta && Number.isInteger(ownerMeta.pid)) {
|
|
1221
|
+
// owner がいる。死んでいれば回収、生きていれば(TTL 超過でも)奪わない。
|
|
1222
|
+
if (!isAlive(ownerMeta.pid)) stale = true
|
|
1223
|
+
} else {
|
|
1224
|
+
// owner.json 不在/破損 = 初期化中 or crash。lockDir の mtime で短い猶予を見て TTL 超過なら回収。
|
|
1225
|
+
try {
|
|
1226
|
+
const st = await fsImpl.stat(lockDir)
|
|
1227
|
+
const mtimeMs = st.mtimeMs ?? (st.mtime ? st.mtime.getTime() : now)
|
|
1228
|
+
if (now - mtimeMs > ttlMs) stale = true
|
|
1229
|
+
} catch {
|
|
1230
|
+
/* stat 失敗(レース中に消えた等)→ 回収しない */
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
if (!stale) return null
|
|
1235
|
+
|
|
1236
|
+
logger?.warn?.({ cliKind }, "stale な bundle 同期ロックを回収します")
|
|
1237
|
+
const won = await reclaimStale()
|
|
1238
|
+
if (!won) return null // 回収レースの敗者は取得しない
|
|
1239
|
+
// 回収に勝ったら新規取得(lockDir は rename で消えているので mkdir は成功するはず)。
|
|
1240
|
+
return (await tryAcquire()) || null
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
/** codex config.toml の内容ハッシュ(恒久 skip の fingerprint 用。読めなければ null)。 */
|
|
1244
|
+
async function _hashCodexConfig(fsImpl) {
|
|
1245
|
+
try {
|
|
1246
|
+
const text = await fsImpl.readFile(resolveCodexConfigPath(), "utf-8")
|
|
1247
|
+
return createHash("sha256").update(text).digest("hex")
|
|
1248
|
+
} catch {
|
|
1249
|
+
return null
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
/**
|
|
1254
|
+
* codex 承認反映の結果を `.hub_ai_state.json` に記録する。
|
|
1255
|
+
* - 完全反映 → `mcp_approval_applied_version = version`、skip マーカー消去
|
|
1256
|
+
* - I/O 等の一時失敗(approvalError)→ `last_sync_error="io"`・**retriable**(applied 進めず再試行)
|
|
1257
|
+
* - 恒久 skip(非標準 config / 未登録)→ skip version + **config fingerprint** を記録し、同一 config・同一
|
|
1258
|
+
* version では再試行しない(session_start / daemon の無限ループ防止)
|
|
1259
|
+
* 併せて role フィルタ済み expected server 名を保存し、session_start がハードコードに頼らず判定できるようにする。
|
|
1260
|
+
*
|
|
1261
|
+
* @returns {{approvalComplete: boolean, retriable: boolean, patch: object}}
|
|
1262
|
+
*/
|
|
1263
|
+
async function _recordCodexApprovalState({ version, mcp, bundle, fsImpl, logger }) {
|
|
1264
|
+
const expected = (Array.isArray(bundle?.mcp_servers) ? bundle.mcp_servers : [])
|
|
1265
|
+
.filter((s) => _CODEX_MCP_APPROVAL_MODES.has(s?.default_tools_approval_mode))
|
|
1266
|
+
.map((s) => s.name)
|
|
1267
|
+
const patch = {
|
|
1268
|
+
last_sync_attempt: new Date().toISOString(),
|
|
1269
|
+
codex_expected_mcp_servers: expected,
|
|
1270
|
+
}
|
|
1271
|
+
let approvalComplete = false
|
|
1272
|
+
let retriable = false
|
|
1273
|
+
const _markComplete = () => {
|
|
1274
|
+
patch.mcp_approval_applied_version = version
|
|
1275
|
+
patch.last_sync_error = null
|
|
1276
|
+
patch.mcp_approval_skipped_version = null
|
|
1277
|
+
patch.mcp_approval_skipped_config_hash = null
|
|
1278
|
+
approvalComplete = true
|
|
1279
|
+
}
|
|
1280
|
+
if (expected.length === 0) {
|
|
1281
|
+
// 承認モードを宣言する server が無い(後方互換の旧レスポンス)→ 承認反映は不要。
|
|
1282
|
+
_markComplete()
|
|
1283
|
+
} else if (mcp?.approvalError) {
|
|
1284
|
+
// I/O・ネットワーク等の一時失敗 → applied は進めず再試行させる。
|
|
1285
|
+
// ⚠️ 旧 skip マーカーを **消す**(2026-07-16 修正)。config を直して再同期→I/O 失敗した場合、
|
|
1286
|
+
// 古い skip version/hash が残っていると heartbeat が「恒久 skip 済み」と誤報告し、backend が再要求を
|
|
1287
|
+
// 止めてしまう。io は retriable なので skip は必ずクリアする。
|
|
1288
|
+
patch.last_sync_error = "io"
|
|
1289
|
+
patch.mcp_approval_skipped_version = null
|
|
1290
|
+
patch.mcp_approval_skipped_config_hash = null
|
|
1291
|
+
retriable = true
|
|
1292
|
+
} else {
|
|
1293
|
+
const pending = mcp?.approvalPending || []
|
|
1294
|
+
const skipped = mcp?.approvalSkipped || []
|
|
1295
|
+
const unresolved = expected.filter((n) => pending.includes(n) || skipped.includes(n))
|
|
1296
|
+
if (unresolved.length === 0) {
|
|
1297
|
+
_markComplete()
|
|
1298
|
+
} else {
|
|
1299
|
+
// 恒久 skip(非標準 config で安全に編集不可 / MCP 未登録で OAuth 必要)。config fingerprint を
|
|
1300
|
+
// 記録し、同一 config・同一 version では再試行しない(無限ループ防止)。config が変われば再試行する。
|
|
1301
|
+
patch.last_sync_error = skipped.length > 0 ? "nonstandard_config" : "mcp_not_registered"
|
|
1302
|
+
patch.mcp_approval_skipped_version = version
|
|
1303
|
+
patch.mcp_approval_skipped_config_hash = await _hashCodexConfig(fsImpl)
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
await updateHubAiState("codex", patch, { fsImpl, logger })
|
|
1307
|
+
return { approvalComplete, retriable, patch }
|
|
1308
|
+
}
|
|
1309
|
+
|
|
870
1310
|
/**
|
|
871
1311
|
* fetch + apply + post_install(setup) + MCP server 自動登録を **同期的に** まとめて実行。
|
|
872
1312
|
* cliKind='codex' で Codex 向け(`~/.codex/`、`codex mcp add`)に切り替わる
|
|
@@ -886,7 +1326,43 @@ export function resolveSyncTargets(availableClis) {
|
|
|
886
1326
|
* (手動 `hub-agent sync-bundle` は setup 完了まで同期的に待つ)。CLI 別パスは backend が
|
|
887
1327
|
* `{{CLI_DIR_NAME}}` を解決して返すため、Codex 同期で `~/.claude` の setup を誤実行しない。
|
|
888
1328
|
*/
|
|
889
|
-
export async function syncBundle({
|
|
1329
|
+
export async function syncBundle({
|
|
1330
|
+
hubUrl,
|
|
1331
|
+
agentId,
|
|
1332
|
+
agentToken,
|
|
1333
|
+
logger,
|
|
1334
|
+
fetchImpl,
|
|
1335
|
+
cliKind = "claude",
|
|
1336
|
+
nonInteractive = false,
|
|
1337
|
+
lock = true,
|
|
1338
|
+
fsImpl = fs,
|
|
1339
|
+
} = {}) {
|
|
1340
|
+
// 跨プロセス/経路の同時実行を直列化する(daemon / SessionStart 経由 CLI / 手動 sync)。
|
|
1341
|
+
// 取得できなければ他が同期中なので skip(冪等・重複作業回避)。lock:false でテスト時に無効化可能。
|
|
1342
|
+
let release = null
|
|
1343
|
+
if (lock) {
|
|
1344
|
+
release = await acquireBundleSyncLock(cliKind, { fsImpl, logger })
|
|
1345
|
+
if (!release) {
|
|
1346
|
+
logger?.info?.({ cliKind }, "別経路が bundle 同期中のためスキップ(lock 取得できず)")
|
|
1347
|
+
return {
|
|
1348
|
+
version: null,
|
|
1349
|
+
written: [],
|
|
1350
|
+
skipped_same: [],
|
|
1351
|
+
merged_claude_md: false,
|
|
1352
|
+
mcp: { added: [], skipped: [] },
|
|
1353
|
+
skipped: true,
|
|
1354
|
+
reason: "locked",
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
try {
|
|
1359
|
+
return await _syncBundleLocked({ hubUrl, agentId, agentToken, logger, fetchImpl, cliKind, nonInteractive, fsImpl })
|
|
1360
|
+
} finally {
|
|
1361
|
+
if (release) await release()
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
async function _syncBundleLocked({ hubUrl, agentId, agentToken, logger, fetchImpl, cliKind, nonInteractive, fsImpl }) {
|
|
890
1366
|
const bundle = await fetchBundle({ hubUrl, agentId, agentToken, fetchImpl, cliKind })
|
|
891
1367
|
const result = await applyBundle({ bundle, logger, cliKind })
|
|
892
1368
|
try {
|
|
@@ -908,12 +1384,31 @@ export async function syncBundle({ hubUrl, agentId, agentToken, logger, fetchImp
|
|
|
908
1384
|
let mcp = { added: [], skipped: [] }
|
|
909
1385
|
try {
|
|
910
1386
|
mcp = cliKind === "codex"
|
|
911
|
-
? await applyCodexMcpServers(bundle, { logger })
|
|
912
|
-
: await applyMcpServers(bundle, { agentId, agentToken, logger })
|
|
1387
|
+
? await applyCodexMcpServers(bundle, { logger, nonInteractive })
|
|
1388
|
+
: await applyMcpServers(bundle, { agentId, agentToken, logger, nonInteractive })
|
|
913
1389
|
} catch (err) {
|
|
914
1390
|
logger?.warn({ cliKind, err: err.message }, "applyMcpServers failed (continuing)")
|
|
915
1391
|
}
|
|
916
|
-
|
|
1392
|
+
// Codex のみ: 承認モード反映の結果を `.hub_ai_state.json` に記録する。
|
|
1393
|
+
// installed_version(files/hooks 済み)とは別に mcp_approval_applied_version を持つことで、
|
|
1394
|
+
// 「files は更新済みだが承認未反映」の状態を「完了」と誤認せず、次回の自己修復対象にできる。
|
|
1395
|
+
// approvalComplete=false かつ retriable(I/O 失敗)の場合は、bundle 本体の version が最新でも
|
|
1396
|
+
// 「component 未完了」として呼び出し側(syncBundleTargets / daemon)へ再試行を促す。
|
|
1397
|
+
let approvalComplete = true // claude / 承認不要 codex は完了扱い
|
|
1398
|
+
let approvalRetriable = false
|
|
1399
|
+
if (cliKind === "codex") {
|
|
1400
|
+
try {
|
|
1401
|
+
const rec = await _recordCodexApprovalState({ version: bundle.version, mcp, bundle, fsImpl, logger })
|
|
1402
|
+
approvalComplete = rec.approvalComplete
|
|
1403
|
+
approvalRetriable = rec.retriable
|
|
1404
|
+
} catch (err) {
|
|
1405
|
+
// 記録自体に失敗 = 完了を保証できない → 再試行対象。
|
|
1406
|
+
logger?.warn?.({ err: err?.message }, "codex approval state の記録に失敗(再試行対象)")
|
|
1407
|
+
approvalComplete = false
|
|
1408
|
+
approvalRetriable = true
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
return { version: bundle.version, ...result, mcp, approvalComplete, approvalRetriable }
|
|
917
1412
|
}
|
|
918
1413
|
|
|
919
1414
|
/**
|
|
@@ -933,13 +1428,32 @@ export async function syncBundleTargets({
|
|
|
933
1428
|
fetchImpl,
|
|
934
1429
|
logger,
|
|
935
1430
|
syncImpl,
|
|
1431
|
+
nonInteractive = false,
|
|
1432
|
+
lock,
|
|
936
1433
|
} = {}) {
|
|
937
1434
|
const sync = syncImpl || syncBundle
|
|
938
1435
|
const results = {}
|
|
939
1436
|
const failures = []
|
|
940
1437
|
for (const cliKind of Array.isArray(targets) ? targets : []) {
|
|
941
1438
|
try {
|
|
942
|
-
|
|
1439
|
+
const r = await sync({
|
|
1440
|
+
hubUrl,
|
|
1441
|
+
agentId,
|
|
1442
|
+
agentToken,
|
|
1443
|
+
fetchImpl,
|
|
1444
|
+
cliKind,
|
|
1445
|
+
logger,
|
|
1446
|
+
nonInteractive,
|
|
1447
|
+
...(lock !== undefined ? { lock } : {}),
|
|
1448
|
+
})
|
|
1449
|
+
results[cliKind] = r
|
|
1450
|
+
// ⚠️ component 単位の未完了を failure として扱う(2026-07-16 修正)。codex の承認反映が
|
|
1451
|
+
// I/O 等で一時失敗(retriable)した場合、bundle 本体の version が最新になっても「承認だけ」を
|
|
1452
|
+
// 再試行対象にする(daemon の backoff・backend の再要求が続くよう failure に載せる)。
|
|
1453
|
+
// 恒久 skip(非標準 config / 未登録)は retriable=false なので failure にはしない(無限リトライ回避)。
|
|
1454
|
+
if (r && r.approvalRetriable) {
|
|
1455
|
+
failures.push(cliKind)
|
|
1456
|
+
}
|
|
943
1457
|
} catch (err) {
|
|
944
1458
|
// exit code を message から救い出す(runOnce は "... (exit N): ..." を投げる)。
|
|
945
1459
|
const m = /exit (\d+)/.exec(err?.cause?.message || err?.message || "")
|