@cocorograph/hub-agent 0.7.37 → 0.7.39

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cocorograph/hub-agent",
3
- "version": "0.7.37",
3
+ "version": "0.7.39",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -31,6 +31,25 @@ import {
31
31
  createEventMasker,
32
32
  maskJsonlFileAtRest,
33
33
  } from "./tool-result-mask.mjs"
34
+ import { getOrCreateSiblingToken } from "./sibling-token.mjs"
35
+
36
+ /**
37
+ * sibling-inject (2026-07-04) 用: spawn する claude プロセス自身の環境変数に、
38
+ * この cwd の capability トークンを乗せる。ディスクには書かず、プロセス環境変数
39
+ * としてのみ渡す (src/sibling-token.mjs のコメント参照)。
40
+ * @param {string} cwd
41
+ * @returns {{COCKPIT_SIBLING_TOKEN: string, COCKPIT_SIBLING_CWD: string}}
42
+ */
43
+ function siblingEnvFor(cwd) {
44
+ return {
45
+ ...process.env,
46
+ COCKPIT_SIBLING_TOKEN: getOrCreateSiblingToken(cwd),
47
+ COCKPIT_SIBLING_CWD: cwd,
48
+ // ask-sibling スキル (cockpit_orch.py) が「自分は claude/codex どちらか」を
49
+ // 環境変数から確実に判定できるようにする (ヒューリスティック不要)。
50
+ COCKPIT_SIBLING_SELF_KIND: "claude",
51
+ }
52
+ }
34
53
 
35
54
  /** browser 切断後、セッションを生かしたまま再接続を待つ idle TTL (ミリ秒)。これを過ぎ、
36
55
  * かつ走行中でなければ撤去する。端末スリープ/長期離席後の再接続も吸収できるよう 7 日に
@@ -709,6 +728,7 @@ class ClaudeStreamSession {
709
728
 
710
729
  const options = {
711
730
  cwd: this.cwd,
731
+ env: siblingEnvFor(this.cwd),
712
732
  canUseTool: (toolName, input) => this._canUseTool(toolName, input),
713
733
  includePartialMessages: true,
714
734
  abortController: this._abortController,
@@ -1017,6 +1037,7 @@ class ClaudeStreamSession {
1017
1037
  async _runResidentQuery() {
1018
1038
  const options = {
1019
1039
  cwd: this.cwd,
1040
+ env: siblingEnvFor(this.cwd),
1020
1041
  canUseTool: (toolName, input) => this._canUseTool(toolName, input),
1021
1042
  includePartialMessages: true,
1022
1043
  }
@@ -1678,6 +1699,50 @@ export class ClaudeStreamBridge extends EventEmitter {
1678
1699
  return best
1679
1700
  }
1680
1701
 
1702
+ /**
1703
+ * 指定 cwd でライブなセッションへメッセージを直接注入する (sibling-inject 用、
1704
+ * 2026-07-04)。同一マシン内の別プロセス (ask-sibling スキル経由の cockpit_orch.py)
1705
+ * が、Cockpit ブラウザの WS 接続を経由せず、hub-agent デーモンへローカル IPC
1706
+ * (src/sibling-inject.mjs) 経由でこの cwd の生きているチャットセッションへ新規
1707
+ * ターンを開始させる。liveSessionIdForCwd と同じ走査ロジックだが、返すのは
1708
+ * sessionId 文字列ではなく session オブジェクト自体 (sendMessage を直接呼べるように
1709
+ * するため)。同一 cwd に複数ライブセッションがある場合は最終アクティビティが最も
1710
+ * 新しいものを採用する。
1711
+ *
1712
+ * ⚠️ このメソッド自体は認証を行わない (呼び出し元の sibling-inject.mjs が
1713
+ * src/sibling-token.mjs の capability トークンで入力元を検証する前提)。
1714
+ *
1715
+ * @param {string} cwd
1716
+ * @param {string} text
1717
+ * @returns {boolean} 送信できたか (対象セッションが見つからなければ false)
1718
+ */
1719
+ sendToCwd(cwd, text) {
1720
+ if (!cwd || !text) return false
1721
+ let best = null
1722
+ let bestAt = -1
1723
+ const seen = new Set()
1724
+ for (const session of this.sessions.values()) {
1725
+ if (seen.has(session) || session.cwd !== cwd) continue
1726
+ seen.add(session)
1727
+ let at = 0
1728
+ for (const t of session.lastActivityByStream.values()) {
1729
+ if (t > at) at = t
1730
+ }
1731
+ if (at >= bestAt) {
1732
+ bestAt = at
1733
+ best = session
1734
+ }
1735
+ }
1736
+ if (!best) return false
1737
+ best.sendMessage({ role: "user", content: text }).catch((err) => {
1738
+ this.logger?.error(
1739
+ { cwd, err: err?.message },
1740
+ "sendToCwd: sendMessage threw unexpectedly",
1741
+ )
1742
+ })
1743
+ return true
1744
+ }
1745
+
1681
1746
  /**
1682
1747
  * `mcpServerStatus()` の結果に、needs-auth キャッシュにあるが結果へ含まれない
1683
1748
  * サーバーを `needs-auth` として補完する。
@@ -34,6 +34,26 @@
34
34
  import { EventEmitter } from "node:events"
35
35
 
36
36
  import { CodexAppServerClient } from "./codex-appserver-client.mjs"
37
+ import { getOrCreateSiblingToken } from "./sibling-token.mjs"
38
+
39
+ /**
40
+ * sibling-inject (2026-07-04) 用: spawn する codex app-server プロセス自身の
41
+ * 環境変数に、この cwd の capability トークンを乗せる。ディスクには書かず、
42
+ * プロセス環境変数としてのみ渡す (src/sibling-token.mjs のコメント参照、
43
+ * claude-stream-bridge.mjs の同名ヘルパーと対)。
44
+ * @param {string} cwd
45
+ * @returns {NodeJS.ProcessEnv}
46
+ */
47
+ function siblingEnvFor(cwd) {
48
+ return {
49
+ ...process.env,
50
+ COCKPIT_SIBLING_TOKEN: getOrCreateSiblingToken(cwd),
51
+ COCKPIT_SIBLING_CWD: cwd,
52
+ // ask-sibling スキル (cockpit_orch.py) が「自分は claude/codex どちらか」を
53
+ // 環境変数から確実に判定できるようにする (ヒューリスティック不要)。
54
+ COCKPIT_SIBLING_SELF_KIND: "codex",
55
+ }
56
+ }
37
57
 
38
58
  // detach 後、非 busy なセッションを生かしておく猶予。claude 側 (7 日) と違い短くする:
39
59
  // Slice 2 は in-memory reattach 非対応のため、browser の WS 再接続ごとに新 stream_id で
@@ -89,7 +109,12 @@ export class CodexStreamBridge extends EventEmitter {
89
109
  if (this.sessions.has(stream_id)) {
90
110
  throw new Error(`stream_id already attached: ${stream_id}`)
91
111
  }
92
- const client = this._clientFactory({ cwd, codexCmd: this.codexCmd, logger: this.logger })
112
+ const client = this._clientFactory({
113
+ cwd,
114
+ codexCmd: this.codexCmd,
115
+ logger: this.logger,
116
+ env: siblingEnvFor(cwd),
117
+ })
93
118
  const session = {
94
119
  stream_id,
95
120
  cwd,
@@ -317,6 +342,35 @@ export class CodexStreamBridge extends EventEmitter {
317
342
  return null
318
343
  }
319
344
 
345
+ /**
346
+ * 指定 cwd でライブなセッションへメッセージを直接注入する (sibling-inject 用、
347
+ * 2026-07-04)。Claude 版 `sendToCwd` の対。同一 cwd に複数ライブセッションがある
348
+ * 場合は `threadIdForCwd` と同じく先に見つかったものを使う。
349
+ *
350
+ * ⚠️ このメソッド自体は認証を行わない (呼び出し元の sibling-inject.mjs が
351
+ * src/sibling-token.mjs の capability トークンで入力元を検証する前提)。
352
+ *
353
+ * @param {string} cwd
354
+ * @param {string} text
355
+ * @returns {boolean} 送信できたか (対象セッションが見つからなければ false)
356
+ */
357
+ sendToCwd(cwd, text) {
358
+ if (!cwd || !text) return false
359
+ for (const session of this.sessions.values()) {
360
+ if (session.cwd !== cwd) continue
361
+ this.input({ stream_id: session.stream_id, message: { role: "user", content: text } }).catch(
362
+ (err) => {
363
+ this.logger?.error(
364
+ { cwd, err: err?.message },
365
+ "sendToCwd: input threw unexpectedly",
366
+ )
367
+ },
368
+ )
369
+ return true
370
+ }
371
+ return false
372
+ }
373
+
320
374
  _maybeReapAfterIdle(session) {
321
375
  if (session.activeTurnId) return // 完走まで待つ (turn/completed で再評価される)
322
376
  this._clearDetachTimer(session)
package/src/main.mjs CHANGED
@@ -93,6 +93,7 @@ import {
93
93
  import { TuiPermissionBridge } from "./tui-permission-bridge.mjs"
94
94
  import { TuiViewerRegistry } from "./tui-viewer-registry.mjs"
95
95
  import { JsonlLiveWatchers } from "./jsonl-live-watchers.mjs"
96
+ import { SiblingInjectBridge } from "./sibling-inject.mjs"
96
97
  import {
97
98
  boundSessionId,
98
99
  codexSessionUsages,
@@ -868,6 +869,14 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
868
869
  await tuiViewerRegistry.start()
869
870
  ctx.tuiViewerRegistry = tuiViewerRegistry
870
871
 
872
+ // sibling-inject (ask-sibling スキル, 2026-07-04): 同一 cwd の claude⇄codex
873
+ // 相互依頼のためのローカル IPC 受信口。cwd スコープ capability トークン
874
+ // (src/sibling-token.mjs) で検証した要求のみ claudeBridge/codexBridge.sendToCwd()
875
+ // へ橋渡しする。
876
+ const siblingInjectBridge = new SiblingInjectBridge({ claudeBridge, codexBridge, logger })
877
+ await siblingInjectBridge.start()
878
+ ctx.siblingInjectBridge = siblingInjectBridge
879
+
871
880
  // jsonl ライブ追従 (T04784 逐次表示): TUI チャットの閲覧ハートビートを起点に対象
872
881
  // session の jsonl を tail し、追記行を claude.jsonl.event として逐次 push する。
873
882
  // これで session.event 合図 → 全体再 hydrate + 2.5s ポーリングの「どさっと」を廃せる。
@@ -926,6 +935,7 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
926
935
  tuiPermissionBridge.stop()
927
936
  tuiViewerRegistry.stop()
928
937
  jsonlLiveWatchers.stop()
938
+ siblingInjectBridge.stop()
929
939
  ptyBridge.shutdown()
930
940
  claudeBridge?.shutdown?.()
931
941
  codexBridge?.shutdown?.()
@@ -3299,6 +3309,58 @@ async function dispatch(msg, ctx) {
3299
3309
  }
3300
3310
  return
3301
3311
  }
3312
+ case "codex.upload": {
3313
+ // B4 (T05699 残タスク, 2026-07-04): Cockpit Codexチャットの添付ファイル受信
3314
+ // (チャンク1件)。claude.upload と同じ UploadManager を再利用する (cwd ベースで
3315
+ // CLI 非依存の実装のため無改修で流用可能)。全チャンク到着で cwd 配下の
3316
+ // .cockpit-uploads/<日付>/ に保存し、保存パスを browser へ返す。browser は
3317
+ // そのパスを codex.input 本文に載せ、Codex がシェル/ファイルツールで読む
3318
+ // (Claude 版と同じ「パス参照方式」、Codex 側の model/effort 分離と同様に
3319
+ // アカウント/プロトコルが違うだけでファイル保存の仕組みは共有する)。
3320
+ if (!ctx.uploadManager) {
3321
+ ctx.client.send({
3322
+ type: "codex.upload.done",
3323
+ stream_id: msg.stream_id,
3324
+ upload_id: msg.upload_id,
3325
+ ok: false,
3326
+ error: "upload_unavailable",
3327
+ })
3328
+ return
3329
+ }
3330
+ try {
3331
+ const res = await ctx.uploadManager.addChunk({
3332
+ upload_id: msg.upload_id,
3333
+ filename: msg.filename,
3334
+ media_type: msg.media_type,
3335
+ total_size: msg.total_size,
3336
+ total_chunks: msg.total_chunks,
3337
+ chunk_index: msg.chunk_index,
3338
+ data: msg.data,
3339
+ cwd: msg.cwd || ctx.config?.claude_cwd || undefined,
3340
+ })
3341
+ if (res.done) {
3342
+ ctx.client.send({
3343
+ type: "codex.upload.done",
3344
+ stream_id: msg.stream_id,
3345
+ upload_id: msg.upload_id,
3346
+ ok: true,
3347
+ path: res.path,
3348
+ filename: res.filename,
3349
+ media_type: res.media_type,
3350
+ size: res.size,
3351
+ })
3352
+ }
3353
+ } catch (err) {
3354
+ ctx.client.send({
3355
+ type: "codex.upload.done",
3356
+ stream_id: msg.stream_id,
3357
+ upload_id: msg.upload_id,
3358
+ ok: false,
3359
+ error: err?.message || String(err),
3360
+ })
3361
+ }
3362
+ return
3363
+ }
3302
3364
  case "codex.input":
3303
3365
  // model/effort: turn/start (非 busy 時) にのみ反映される override。turn/steer
3304
3366
  // (busy 時) は公式スキーマ上 model/effort 非対応のため、その場合は
@@ -0,0 +1,150 @@
1
+ /**
2
+ * sibling-inject (同一 cwd の claude⇄codex 相互依頼) の受信ブリッジ (2026-07-04)。
3
+ *
4
+ * ask-sibling スキル経由の `cockpit_orch.py` (Cockpit ブラウザの WS 接続を持たない、
5
+ * Bash tool 経由で動く別プロセス) が、同一マシン上で稼働中の hub-agent デーモンへ
6
+ * 「この cwd の生きているチャットセッション (claude/codex) へ新規ターンを注入して
7
+ * ほしい」と頼むための受信口。
8
+ *
9
+ * 設計 (WHY): hub-agent はローカル HTTP/Unix ソケットサーバを持たないため、既存の
10
+ * ファイル IPC 前例 (tui-permission-bridge.mjs 等、`/tmp/cockpit_*` + fs.watch +
11
+ * atomic write) と同じパターンに揃える。
12
+ *
13
+ * ⚠️ セキュリティ設計 (2026-07-04 セッションでユーザー承認済み): この機構は
14
+ * 「生きている自律エージェント (シェル/API/MCP ツールへのフルアクセス権を持つ)
15
+ * へ外部プロセスから新規指示を注入する」という強い書き込み権限を持つため、
16
+ * `src/sibling-token.mjs` の cwd スコープ capability トークンによる検証を必須とする。
17
+ * トークンが一致しない要求は無視し (実行せず削除)、warn ログのみ残す。
18
+ *
19
+ * 契約: request file `<request_id>.json` = `{cwd, target_cli_kind, text, token}`。
20
+ * 処理後は必ず削除し (成否問わず)、呼び出し側が待ち受けられるよう
21
+ * `<request_id>.result.json` = `{ok, error?}` を書く。
22
+ */
23
+
24
+ import { watch } from "node:fs"
25
+ import { mkdir, readFile, writeFile, rename, unlink, readdir } from "node:fs/promises"
26
+ import path from "node:path"
27
+
28
+ import { verifySiblingToken } from "./sibling-token.mjs"
29
+
30
+ export const SIBLING_REQUESTS_DIR =
31
+ process.env.COCKPIT_SIBLING_REQUESTS_DIR || "/tmp/cockpit_sibling_requests"
32
+
33
+ export class SiblingInjectBridge {
34
+ /**
35
+ * @param {{dir?: string, claudeBridge?: object, codexBridge?: object, logger?: object}} [opts]
36
+ */
37
+ constructor({ dir = SIBLING_REQUESTS_DIR, claudeBridge, codexBridge, logger } = {}) {
38
+ this.dir = dir
39
+ this.claudeBridge = claudeBridge
40
+ this.codexBridge = codexBridge
41
+ this.logger = logger
42
+ /** @type {Set<string>} 二重処理防止 (request_id) */
43
+ this._seen = new Set()
44
+ this._watcher = null
45
+ }
46
+
47
+ /** 監視ディレクトリを作成し、既存要求を sweep してから fs.watch を張る。 */
48
+ async start() {
49
+ await mkdir(this.dir, { recursive: true })
50
+ await this._sweep()
51
+ try {
52
+ this._watcher = watch(this.dir, (_event, filename) => {
53
+ if (filename && filename.endsWith(".json") && !filename.endsWith(".result.json")) {
54
+ this._ingest(filename).catch((err) => {
55
+ this.logger?.warn({ err: err?.message, filename }, "sibling-inject ingest failed")
56
+ })
57
+ }
58
+ })
59
+ } catch (err) {
60
+ this.logger?.warn(
61
+ { err: err?.message },
62
+ "sibling-inject watcher: fs.watch failed, watcher disabled",
63
+ )
64
+ }
65
+ }
66
+
67
+ /** 起動時 / 取りこぼし保険: dir 内の全要求ファイルを読む。 */
68
+ async _sweep() {
69
+ let names = []
70
+ try {
71
+ names = await readdir(this.dir)
72
+ } catch {
73
+ return
74
+ }
75
+ for (const n of names) {
76
+ if (n.endsWith(".json") && !n.endsWith(".result.json")) {
77
+ await this._ingest(n).catch((err) => {
78
+ this.logger?.warn({ err: err?.message, filename: n }, "sibling-inject sweep failed")
79
+ })
80
+ }
81
+ }
82
+ }
83
+
84
+ /**
85
+ * 要求ファイル 1 件を読み、トークン検証後に対象 cwd のセッションへ注入する。
86
+ * 成否によらず要求ファイルは削除し、結果ファイルを書く。
87
+ * @param {string} filename `<request_id>.json`
88
+ */
89
+ async _ingest(filename) {
90
+ const request_id = filename.slice(0, -".json".length)
91
+ if (this._seen.has(request_id)) return
92
+ this._seen.add(request_id)
93
+ const fp = path.join(this.dir, filename)
94
+ let body
95
+ try {
96
+ body = JSON.parse(await readFile(fp, "utf-8"))
97
+ } catch {
98
+ // partial write / 既に消えた → 次の watch イベントで拾い直す (seen から外す)。
99
+ this._seen.delete(request_id)
100
+ return
101
+ }
102
+
103
+ const { cwd, target_cli_kind, text, token } = body || {}
104
+ let result
105
+ if (!cwd || !text || (target_cli_kind !== "claude" && target_cli_kind !== "codex")) {
106
+ result = { ok: false, error: "invalid_request" }
107
+ } else if (!verifySiblingToken(cwd, token)) {
108
+ this.logger?.warn({ cwd, request_id }, "sibling-inject: token mismatch, request denied")
109
+ result = { ok: false, error: "invalid_token" }
110
+ } else {
111
+ const bridge = target_cli_kind === "codex" ? this.codexBridge : this.claudeBridge
112
+ const sent = bridge?.sendToCwd?.(cwd, text) ?? false
113
+ result = sent
114
+ ? { ok: true }
115
+ : { ok: false, error: "no_live_session_for_cwd" }
116
+ }
117
+
118
+ await this._writeResult(request_id, result)
119
+ // 要求ファイルは処理後に必ず削除する (成否問わず、再処理させないため)。
120
+ try {
121
+ await unlink(fp)
122
+ } catch {
123
+ /* 既に無ければ no-op */
124
+ }
125
+ }
126
+
127
+ /** 結果ファイルを atomic write する (呼び出し側 CLI がポーリングする)。 */
128
+ async _writeResult(request_id, result) {
129
+ const fp = path.join(this.dir, `${request_id}.result.json`)
130
+ const tmp = `${fp}.tmp`
131
+ try {
132
+ await writeFile(tmp, JSON.stringify(result))
133
+ await rename(tmp, fp)
134
+ } catch (err) {
135
+ this.logger?.warn({ err: err?.message, request_id }, "sibling-inject result write failed")
136
+ }
137
+ }
138
+
139
+ /** watcher を閉じる。 */
140
+ stop() {
141
+ try {
142
+ this._watcher?.close()
143
+ } catch {
144
+ /* ignore */
145
+ }
146
+ this._watcher = null
147
+ }
148
+ }
149
+
150
+ export default SiblingInjectBridge
@@ -0,0 +1,62 @@
1
+ /**
2
+ * sibling-inject (同一 cwd の claude⇄codex 相互依頼) 用の cwd スコープ capability
3
+ * トークンストア (2026-07-04)。
4
+ *
5
+ * 設計の背景 (WHY):
6
+ * hub-agent はローカル HTTP/Unix ソケットサーバを持たず、既存のローカル IPC は
7
+ * すべて `/tmp/cockpit_*` 配下のファイルベース (tui-permission-bridge.mjs 等) で、
8
+ * 認証機構を持たない (同一 UID なら誰でも読み書きできる想定)。
9
+ *
10
+ * sibling-inject はこの上に「生きているチャットセッションへ新規ターンを注入する」
11
+ * という強い書き込み権限を追加するため、無認証のままだと「同一マシン上の無関係な
12
+ * 別プロセス (npm postinstall スクリプト等) が自律エージェント (シェル/API/MCP
13
+ * ツールへのフルアクセス権を持つ) へ任意の指示を注入できる」という新しい攻撃面に
14
+ * なる (2026-07-04 セッションで実装時に確認済みの懸念、ユーザー承認済み)。
15
+ *
16
+ * 対策: cwd ごとにランダムな capability トークンを発行し、hub-agent がそのセッション
17
+ * (claude/codex いずれも) を spawn する際に **そのプロセス自身の環境変数にのみ**
18
+ * 渡す (`COCKPIT_SIBLING_TOKEN`、ディスクには書かない)。sibling-inject の受信側は
19
+ * このトークンが一致した場合のみ注入を実行する。これにより「/tmp を書き換えられる
20
+ * 全プロセス」ではなく「そのセッション自身の環境変数を読めるプロセス (= 既に
21
+ * hub-agent が spawn した agent 自身、またはその子プロセス)」だけが注入できる設計に
22
+ * 絞り込める。Hub ユーザートークン (agent 全体を認証する、より広い権限を持つ鍵) を
23
+ * 転用しない理由: ディスク保存されるため同じ攻撃者から結局読める上、漏洩時の被害
24
+ * 範囲がこの cwd 限定トークンより大きくなるため。
25
+ */
26
+
27
+ import { randomBytes } from "node:crypto"
28
+
29
+ /** @type {Map<string, string>} cwd -> token */
30
+ const tokensByCwd = new Map()
31
+
32
+ /**
33
+ * 指定 cwd の capability トークンを返す (無ければ新規発行)。
34
+ * @param {string} cwd
35
+ * @returns {string}
36
+ */
37
+ export function getOrCreateSiblingToken(cwd) {
38
+ if (!cwd) throw new Error("getOrCreateSiblingToken: cwd is required")
39
+ let token = tokensByCwd.get(cwd)
40
+ if (!token) {
41
+ token = randomBytes(32).toString("hex")
42
+ tokensByCwd.set(cwd, token)
43
+ }
44
+ return token
45
+ }
46
+
47
+ /**
48
+ * 指定 cwd に対して提示されたトークンが正しいか検証する。
49
+ * @param {string} cwd
50
+ * @param {string} token
51
+ * @returns {boolean}
52
+ */
53
+ export function verifySiblingToken(cwd, token) {
54
+ if (!cwd || !token) return false
55
+ const expected = tokensByCwd.get(cwd)
56
+ return typeof expected === "string" && expected === token
57
+ }
58
+
59
+ /** テスト用: トークンストアを全クリアする。 */
60
+ export function _resetSiblingTokens() {
61
+ tokensByCwd.clear()
62
+ }