@cocorograph/hub-agent 0.8.48 → 0.8.50

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.
@@ -20,8 +20,10 @@
20
20
  * postinstall 等) はどの cwd のトークンも持たないため排除される核心は維持。ターゲット
21
21
  * 解決は引き続き要求の cwd でスコープする (誤 cwd 着弾・別 workspace 情報漏洩を防ぐ)。
22
22
  * トークンが一致しない要求は無視し (実行せず削除)、warn ログのみ残す。
23
+ * 例外は同一UIDのサービス再起動 control (`op:"restart"`) だけで、本文を持たず、所有者限定
24
+ * directory/file (0700/0600) を境界にする。他の op の token 要件は緩めない。
23
25
  *
24
- * 契約: request file `<request_id>.json` は op で 3 形態 (2026-07-19 / T06271):
26
+ * 契約: request file `<request_id>.json` は op で用途を明示する (2026-07-19 / T06271):
25
27
  * - 通常注入 (op 無し): `{cwd, target_cli_kind, text, token}`
26
28
  * cwd 内が 1 本なら注入、複数なら黙って推測せず `ambiguous_target` + 候補一覧。
27
29
  * - 明示注入: `{op:"inject", cwd, target_cli_kind, token, target_session_id, message}`
@@ -32,6 +34,8 @@
32
34
  * - 一覧: `{op:"list", cwd, target_cli_kind, token}` → `{ok, live_sessions}` (--list 用)。
33
35
  * - 全一覧: `{op:"list-all", token}` → `{ok, live_sessions}` (全 cwd・両 CLI。クロス cwd
34
36
  * 宛先探索用、2026-07-23。cwd/target_cli_kind 不要、token のみ必須)。
37
+ * - 安全な再起動: `{op:"restart"}` → `{ok:true, restart_pending:true}`。本文と token を持たず、
38
+ * 所有者限定の IPC directory/request file (0700/0600) だけを境界として扱う。
35
39
  * 処理後は必ず削除し (成否問わず)、通常 IPC は呼び出し側が待ち受けられるよう
36
40
  * `<request_id>.result.json` = `{ok, error?, delivered_to?, candidates?, live_sessions?, hint?,
37
41
  * candidates_nameable?}` を書く (`candidates_nameable` は曖昧系エラーのときのみ。CLI が
@@ -60,8 +64,9 @@
60
64
  */
61
65
 
62
66
  import { watch } from "node:fs"
63
- import { chmod, lstat, mkdir, readFile, writeFile, rename, stat, unlink, readdir } from "node:fs/promises"
67
+ import { chmod, lstat, mkdir, readFile, writeFile, rename, unlink, readdir } from "node:fs/promises"
64
68
  import { execFile as execFileCallback } from "node:child_process"
69
+ import { randomUUID } from "node:crypto"
65
70
  import { promisify } from "node:util"
66
71
  import path from "node:path"
67
72
 
@@ -71,6 +76,10 @@ import { describeLiveSessionsUnion } from "./live-sessions.mjs"
71
76
  export const SIBLING_REQUESTS_DIR =
72
77
  process.env.COCKPIT_SIBLING_REQUESTS_DIR || "/tmp/cockpit_sibling_requests"
73
78
 
79
+ const RESTART_REQUEST_TIMEOUT_MS = 5_000
80
+ const RESTART_RESULT_POLL_MS = 20
81
+ const SIBLING_SWEEP_DEBOUNCE_MS = 25
82
+
74
83
  export const ACTIVE_DELEGATIONS_PER_SESSION_LIMIT = 100
75
84
  export const DELEGATION_LEDGER_RETENTION_MS = 24 * 60 * 60 * 1000
76
85
  const SIBLING_REQUEST_MAX_BYTES = 1024 * 1024
@@ -155,6 +164,69 @@ export async function defaultPidStartedAt(pid, exec = execFile) {
155
164
  return normalizeProcessStartedAt(stdout)
156
165
  }
157
166
 
167
+ async function assertOwnedIpcPath(fp, { type, mode }) {
168
+ const info = await lstat(fp)
169
+ if (info.isSymbolicLink()) throw new Error(`${type} must not be a symbolic link`)
170
+ if (type === "IPC directory" ? !info.isDirectory() : !info.isFile()) {
171
+ throw new Error(`${type} has an unexpected file type`)
172
+ }
173
+ const uid = process.getuid?.()
174
+ if (uid == null || info.uid !== uid) throw new Error(`${type} is not owned by the current user`)
175
+ if ((info.mode & 0o777) !== mode) {
176
+ throw new Error(`${type} permissions must be ${mode.toString(8).padStart(4, "0")}`)
177
+ }
178
+ }
179
+
180
+ /**
181
+ * 所有者限定ファイル IPC で、稼働中 daemon へ deferred restart を要求する。
182
+ * restart op だけは capability token を使わず、既存 IPC の所有者・mode を認可境界にする。
183
+ */
184
+ export async function requestDeferredRestart({
185
+ dir = SIBLING_REQUESTS_DIR,
186
+ timeoutMs = RESTART_REQUEST_TIMEOUT_MS,
187
+ pollMs = RESTART_RESULT_POLL_MS,
188
+ } = {}) {
189
+ await assertOwnedIpcPath(dir, { type: "IPC directory", mode: 0o700 })
190
+ const requestId = `restart-${process.pid}-${randomUUID()}`
191
+ const requestPath = path.join(dir, `${requestId}.json`)
192
+ const resultPath = path.join(dir, `${requestId}.result.json`)
193
+ const tmpPath = `${requestPath}.tmp`
194
+ try {
195
+ await writeFile(tmpPath, JSON.stringify({ op: "restart" }), { mode: 0o600, flag: "wx" })
196
+ await rename(tmpPath, requestPath)
197
+ const deadline = Date.now() + timeoutMs
198
+ while (Date.now() < deadline) {
199
+ try {
200
+ await assertOwnedIpcPath(resultPath, { type: "IPC result", mode: 0o600 })
201
+ const result = JSON.parse(await readFile(resultPath, "utf-8"))
202
+ if (!result?.ok) {
203
+ const daemonError = result?.error || "unknown"
204
+ const rejected = new Error(`daemon rejected deferred restart: ${daemonError}`)
205
+ // filesystem ENOENT 等とdaemon契約エラーのnamespaceを分離する。
206
+ rejected.code = "restart_rejected"
207
+ rejected.daemonError = daemonError
208
+ throw rejected
209
+ }
210
+ return result
211
+ } catch (err) {
212
+ if (err?.code !== "ENOENT") throw err
213
+ }
214
+ await new Promise((resolve) => setTimeout(resolve, pollMs))
215
+ }
216
+ const timeout = new Error(
217
+ "deferred restart acknowledgement timed out. If the daemon was just starting, the request " +
218
+ "may have been discarded; wait a few seconds and retry. It may also already have been accepted, " +
219
+ "so check the daemon log before deciding whether interruption with --force is acceptable.",
220
+ )
221
+ timeout.code = "restart_timeout"
222
+ throw timeout
223
+ } finally {
224
+ await unlink(tmpPath).catch(() => {})
225
+ await unlink(requestPath).catch(() => {})
226
+ await unlink(resultPath).catch(() => {})
227
+ }
228
+ }
229
+
158
230
  const DELIVERY_UNCONFIRMED_HINT =
159
231
  "turn RPC は受理されましたが userMessage echo を確認できませんでした。既に届いている可能性があり、" +
160
232
  "再送すると重複配送になります。再送前に対象セッションの受信状態または rollout 監査を確認してください。"
@@ -337,7 +409,8 @@ export class SiblingInjectBridge {
337
409
  /**
338
410
  * @param {{dir?: string, claudeBridge?: object, codexBridge?: object, logger?: object,
339
411
  * now?: () => number, processKill?: (pid: number, signal: number) => void,
340
- * pidStartedAt?: (pid: number) => Promise<string>}} [opts]
412
+ * pidStartedAt?: (pid: number) => Promise<string>, onRestartRequested?: () => object,
413
+ * isInputBlocked?: () => boolean, watchFn?: typeof watch}} [opts]
341
414
  */
342
415
  constructor({
343
416
  dir = SIBLING_REQUESTS_DIR,
@@ -347,6 +420,9 @@ export class SiblingInjectBridge {
347
420
  now = Date.now,
348
421
  processKill = process.kill.bind(process),
349
422
  pidStartedAt = defaultPidStartedAt,
423
+ onRestartRequested,
424
+ isInputBlocked,
425
+ watchFn = watch,
350
426
  } = {}) {
351
427
  this.dir = dir
352
428
  this.claudeBridge = claudeBridge
@@ -355,15 +431,46 @@ export class SiblingInjectBridge {
355
431
  /** @type {Set<string>} 二重処理防止 (request_id) */
356
432
  this._seen = new Set()
357
433
  this._watcher = null
434
+ this._sweepTimer = null
358
435
  this._now = now
359
436
  this._processKill = processKill
360
437
  this._pidStartedAt = pidStartedAt
438
+ this._onRestartRequested = onRestartRequested
439
+ this._isInputBlocked = isInputBlocked
440
+ this._watchFn = watchFn
441
+ this._newTurnInFlight = 0
361
442
  /** @type {Map<string, Map<string, {event: object, recordedAt: number}>>} cwd+session ごとの進行中委譲。 */
362
443
  this._activeDelegations = new Map()
363
444
  /** @type {Map<string, Map<string, {startedEvent?: object, terminalEvent: object, startedAt?: number, recordedAt: number}>>} 終了済み委譲の再送情報を兼ねた墓標。 */
364
445
  this._terminalDelegations = new Map()
365
446
  }
366
447
 
448
+ /** restart coordinator が通常/明示注入の bridge await 中を観測する。 */
449
+ hasActiveWork() {
450
+ return this._newTurnInFlight > 0
451
+ }
452
+
453
+ async _withNewTurnInFlight(operation) {
454
+ this._newTurnInFlight += 1
455
+ try {
456
+ return await operation()
457
+ } finally {
458
+ this._newTurnInFlight -= 1
459
+ }
460
+ }
461
+
462
+ async _sendNewTurn(operation, fields) {
463
+ try {
464
+ return await this._withNewTurnInFlight(operation)
465
+ } catch (err) {
466
+ this.logger?.error?.(
467
+ { ...fields, err: err?.message, stack: err?.stack },
468
+ "sibling-inject bridge delivery failed",
469
+ )
470
+ return { status: "internal_error" }
471
+ }
472
+ }
473
+
367
474
  _delegationSessionKey(cwd, sessionId) {
368
475
  return `${cwd}\u0000${sessionId}`
369
476
  }
@@ -551,6 +658,7 @@ export class SiblingInjectBridge {
551
658
 
552
659
  /** 監視ディレクトリを作成し、既存要求を sweep してから fs.watch を張る。 */
553
660
  async start() {
661
+ this.stop()
554
662
  try {
555
663
  await this._prepareDir()
556
664
  } catch (err) {
@@ -560,14 +668,19 @@ export class SiblingInjectBridge {
560
668
  )
561
669
  return
562
670
  }
563
- await this._sweep()
671
+ await this._sweep({ startup: true })
564
672
  try {
565
- this._watcher = watch(this.dir, (_event, filename) => {
566
- if (filename && filename.endsWith(".json") && !filename.endsWith(".result.json")) {
567
- this._ingest(filename).catch((err) => {
568
- this.logger?.warn({ err: err?.message, filename }, "sibling-inject ingest failed")
673
+ this._watcher = this._watchFn(this.dir, () => {
674
+ // fs.watch のfilenameはOS都合で欠落・統合されうる。tmp作成とrenameを
675
+ // 短くまとめ、通知された1件でディレクトリ全体を正本として再走査する。
676
+ if (this._sweepTimer) clearTimeout(this._sweepTimer)
677
+ this._sweepTimer = setTimeout(() => {
678
+ this._sweepTimer = null
679
+ this._sweep().catch((err) => {
680
+ this.logger?.warn({ err: err?.message }, "sibling-inject event sweep failed")
569
681
  })
570
- }
682
+ }, SIBLING_SWEEP_DEBOUNCE_MS)
683
+ this._sweepTimer.unref?.()
571
684
  })
572
685
  } catch (err) {
573
686
  this.logger?.warn(
@@ -578,7 +691,7 @@ export class SiblingInjectBridge {
578
691
  }
579
692
 
580
693
  /** 起動時 / 取りこぼし保険: dir 内の全要求ファイルを読む。 */
581
- async _sweep() {
694
+ async _sweep({ startup = false } = {}) {
582
695
  let names = []
583
696
  try {
584
697
  names = await readdir(this.dir)
@@ -587,7 +700,7 @@ export class SiblingInjectBridge {
587
700
  }
588
701
  for (const n of names) {
589
702
  if (n.endsWith(".json") && !n.endsWith(".result.json")) {
590
- await this._ingest(n).catch((err) => {
703
+ await this._ingest(n, { startup }).catch((err) => {
591
704
  this.logger?.warn({ err: err?.message, filename: n }, "sibling-inject sweep failed")
592
705
  })
593
706
  }
@@ -599,17 +712,18 @@ export class SiblingInjectBridge {
599
712
  * 成否によらず要求ファイルは削除する。delegate-event 以外は結果ファイルも書く。
600
713
  * @param {string} filename `<request_id>.json`
601
714
  */
602
- async _ingest(filename) {
715
+ async _ingest(filename, { startup = false } = {}) {
603
716
  const request_id = filename.slice(0, -".json".length)
604
717
  if (this._seen.has(request_id)) return
605
718
  this._seen.add(request_id)
606
719
  const fp = path.join(this.dir, filename)
607
720
  let body
721
+ let requestInfo
608
722
  try {
609
- const fileStat = await stat(fp)
610
- if (fileStat.size > SIBLING_REQUEST_MAX_BYTES) {
723
+ requestInfo = await lstat(fp)
724
+ if (requestInfo.size > SIBLING_REQUEST_MAX_BYTES) {
611
725
  this.logger?.warn(
612
- { filename, size: fileStat.size, max_size: SIBLING_REQUEST_MAX_BYTES },
726
+ { filename, size: requestInfo.size, max_size: SIBLING_REQUEST_MAX_BYTES },
613
727
  "sibling-inject: request file rejected because it exceeds the size limit",
614
728
  )
615
729
  // ⚠️ **結果ファイルを必ず書く**。ここで書かずに戻ると、結果をポーリングしている
@@ -620,7 +734,7 @@ export class SiblingInjectBridge {
620
734
  await this._writeResult(request_id, {
621
735
  ok: false,
622
736
  error: "request_too_large",
623
- size: fileStat.size,
737
+ size: requestInfo.size,
624
738
  max_size: SIBLING_REQUEST_MAX_BYTES,
625
739
  })
626
740
  try {
@@ -637,10 +751,11 @@ export class SiblingInjectBridge {
637
751
  return
638
752
  }
639
753
 
640
- // 契約の 3 形態を op で判別する:
754
+ // 注入・一覧・control の契約形を op で判別する:
641
755
  // - op:"list" → 候補列挙 (top-level text 無し)
642
756
  // - op:"inject" → 明示宛先 (target_session_id + message、top-level text は **持たない**)
643
757
  // - op 無し → 通常 (cwd 最新、top-level text あり) ← 旧 agent と後方互換
758
+ // - op:"restart" → 本文なしの deferred restart control
644
759
  // ⚠️ 明示宛先を「op:inject + message (top-level text 無し)」にするのが fail-closed の肝
645
760
  // (T06271 レビュー major①): 旧 hub-agent は `!text` で invalid_request を返すため、
646
761
  // 未知フィールドを無視して sendToCwd 推測注入する事故が **単一リクエストで原理的に
@@ -672,6 +787,7 @@ export class SiblingInjectBridge {
672
787
  const isList = op === "list"
673
788
  const isExplicit = op === "inject"
674
789
  const isDelegateEvent = op === "delegate-event"
790
+ const isRestart = op === "restart"
675
791
  let result
676
792
  const badKind = target_cli_kind !== "claude" && target_cli_kind !== "codex"
677
793
  const delegatePhases = new Set(["started", "completed", "failed", "stopped"])
@@ -696,7 +812,29 @@ export class SiblingInjectBridge {
696
812
  : isExplicit
697
813
  ? !target_session_id || !message // 明示は id と message 必須 (空指定 fail-open を防ぐ)
698
814
  : !text // 通常は top-level text 必須 (=旧契約)
699
- if (isListAll) {
815
+ if (isRestart) {
816
+ // restart は同一ユーザーが所有する 0700/0600 IPC だけを認可境界にする専用 control op。
817
+ // 本文・token・cwd 等を許すと他 op の契約へ認証緩和が波及するため、op 単独だけを受理する。
818
+ const uid = process.getuid?.()
819
+ const unsafeRequestFile =
820
+ requestInfo.isSymbolicLink() ||
821
+ !requestInfo.isFile() ||
822
+ uid == null ||
823
+ requestInfo.uid !== uid ||
824
+ (requestInfo.mode & 0o777) !== 0o600
825
+ const hasBody = Object.keys(body || {}).some((key) => key !== "op")
826
+ if (unsafeRequestFile || hasBody) {
827
+ result = { ok: false, error: "invalid_request" }
828
+ } else if (startup) {
829
+ // 前プロセスがACK前に落ちて残した要求を、次のdaemon起動時に勝手に実行しない。
830
+ // live watcher到着分だけがrestartを開始できる。
831
+ result = { ok: false, error: "restart_stale" }
832
+ } else if (typeof this._onRestartRequested !== "function") {
833
+ result = { ok: false, error: "restart_unavailable" }
834
+ } else {
835
+ result = this._onRestartRequested()
836
+ }
837
+ } else if (isListAll) {
700
838
  // op:"list-all" (2026-07-23 統一再設計 1-2: クロス cwd 宛先探索): 全 cwd・両 CLI の
701
839
  // ライブセッションを列挙する。cwd / target_cli_kind / 本文は不要 (token のみ必須)。
702
840
  // 認可は verifySiblingTokenAny (同一ユーザーの spawn 済みセッションであれば全 cwd を
@@ -783,15 +921,23 @@ export class SiblingInjectBridge {
783
921
  cliKind: target_cli_kind,
784
922
  }),
785
923
  }
924
+ } else if (this._isInputBlocked?.()) {
925
+ // token検証後にのみ状態を返す。delegate-event/list/controlは新turnを作らないため通す。
926
+ result = { ok: false, error: "restart_pending" }
786
927
  } else if (isExplicit) {
787
928
  // 明示宛先: session_id (claude) / stream_id|thread_id (codex) で cwd 一致下に確定解決。
788
929
  // sendToStream は {status:"delivered"|"not_found"|"ambiguous"} を返す。
789
930
  // request_id を渡すのは、bridge 側が配送 ID を **決定的に** 導出して同一依頼の再処理を
790
931
  // 重複ガードで止められるようにするため (claude bridge は余分な引数を無視する)。
791
932
  const r = bridge?.sendToStream
792
- ? await bridge.sendToStream(cwd, target_session_id, message, injectSource, request_id)
933
+ ? await this._sendNewTurn(
934
+ () => bridge.sendToStream(cwd, target_session_id, message, injectSource, request_id),
935
+ { request_id, cwd, target_cli_kind, op: "inject" },
936
+ )
793
937
  : { status: "not_found" }
794
- if (r.status === "delivered") {
938
+ if (r.status === "internal_error") {
939
+ result = { ok: false, error: "internal_error" }
940
+ } else if (r.status === "delivered") {
795
941
  result = {
796
942
  ok: true,
797
943
  delivered_to: r.session,
@@ -857,9 +1003,14 @@ export class SiblingInjectBridge {
857
1003
  // あった。sendToCwd を status 返却にして解決と配送を 1 呼び出しに統合し、delivered_to は
858
1004
  // **実配送先 (r.session)** を返す。
859
1005
  const r = bridge?.sendToCwd
860
- ? await bridge.sendToCwd(cwd, text, injectSource, request_id)
1006
+ ? await this._sendNewTurn(
1007
+ () => bridge.sendToCwd(cwd, text, injectSource, request_id),
1008
+ { request_id, cwd, target_cli_kind, op: "normal" },
1009
+ )
861
1010
  : { status: "not_found" }
862
- if (r.status === "delivered") {
1011
+ if (r.status === "internal_error") {
1012
+ result = { ok: false, error: "internal_error" }
1013
+ } else if (r.status === "delivered") {
863
1014
  result = {
864
1015
  ok: true,
865
1016
  delivered_to: r.session ?? null,
@@ -933,7 +1084,8 @@ export class SiblingInjectBridge {
933
1084
  : "sibling-inject dispatched"
934
1085
  : "sibling-inject failed",
935
1086
  )
936
- if (!isDelegateEvent) await this._writeResult(request_id, result)
1087
+ // startup sweep 由来の restart には待受者がいない。孤児 result を残さない。
1088
+ if (!isDelegateEvent && !(isRestart && startup)) await this._writeResult(request_id, result)
937
1089
  // 要求ファイルは処理後に必ず削除する (成否問わず、再処理させないため)。
938
1090
  try {
939
1091
  await unlink(fp)
@@ -989,6 +1141,8 @@ export class SiblingInjectBridge {
989
1141
  /* ignore */
990
1142
  }
991
1143
  this._watcher = null
1144
+ if (this._sweepTimer) clearTimeout(this._sweepTimer)
1145
+ this._sweepTimer = null
992
1146
  }
993
1147
  }
994
1148