@cocorograph/hub-agent 0.7.69 → 0.7.71

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.69",
3
+ "version": "0.7.71",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -11,6 +11,7 @@
11
11
  * claude.input → input() (turn/start、ターン中は turn/steer)
12
12
  * claude.interrupt → interrupt() (turn/interrupt)
13
13
  * claude.detach → detach() (TTL 付き graceful close)
14
+ * (codex 固有) → keepalive() (閲覧中の生存シグナル。pty.resize 相当)
14
15
  * claude.permission.reply → permissionReply() (client.respond/respondError)
15
16
  * claude.event (生 SDK イベント素通し) → 'event' (生 notification 素通し、同じ思想)
16
17
  * claude.permission.request → 'permission' (承認/入力要求の生 method+params 素通し)
@@ -509,6 +510,26 @@ export class CodexStreamBridge extends EventEmitter {
509
510
  session.client.respond(request_id, result)
510
511
  }
511
512
 
513
+ /**
514
+ * ブラウザ閲覧中の生存シグナル (codex.keepalive)。frontend が Codex チャットを開いて
515
+ * いる間だけ定期送信するため、これが届く = 「人が見ている」の観測可能な証跡になる。
516
+ * gcAbandonedSessions は lastActivityMs しか見ないので、touch するだけで
517
+ * 「開いたまま無入力でも 1h で殺される」ギャップが閉じる (TTL の数字を増やす必要は無い)。
518
+ *
519
+ * ⚠️ 意図的に `_cancelIdleReap` を **呼ばない**。detach 済みセッションの 5 分 TTL は
520
+ * 「ブラウザが去った」という別事実に基づく予約であり、detach と in-flight keepalive が
521
+ * 競合しても予約を解除しない (= 明示 detach は必ず 5 分で回収される) 方が意味論が明快。
522
+ * 閲覧を再開したブラウザは必ず attach を送り直すので、再養子はそちらの経路で足りる。
523
+ *
524
+ * @returns {boolean} 生きているセッションを touch できたら true (未知 stream は false)。
525
+ */
526
+ keepalive({ stream_id }) {
527
+ const session = this.sessions.get(stream_id)
528
+ if (!session || session.dead) return false
529
+ this._touch(session)
530
+ return true
531
+ }
532
+
512
533
  /**
513
534
  * グレースフル切断。ターン実行中は完走を待ち、非 busy ならアイドル TTL 後に
514
535
  * プロセスを reap する (claude-stream-bridge.mjs の softDetach と同じ思想。
@@ -744,17 +765,17 @@ export class CodexStreamBridge extends EventEmitter {
744
765
  * 進行中の正常なターンは app-server 通知で都度 touch されるので回収されない。
745
766
  * main.mjs の state loop から claudeBridge.gcDeadTerminals と並べて定期実行する想定。
746
767
  *
747
- * ⚠️ 既知のギャップ (2026-07-17、次段の keepalive PR で解消予定): この reaper は
748
- * `detachRequested` を参照せず、`_touch()` は input() と app-server 通知でしか呼ばれない。
749
- * そのため **ブラウザで Codex チャットを開いたままでも、1 時間まったく入力も生成も
750
- * 無ければ回収される**。アイドル TTL(5 分) の誤爆を直しても、この 1h が次の上限になる。
751
- * TTL の数字を大きくするのではなく「見ている」を観測可能にするのが正しい解:
752
- * frontend/components/cockpit/CockpitTerminal.tsx の PTY keepalive (閲覧中は定期的に
753
- * pty.resize で lastSeenAt を触り、ブラウザが離れれば keepalive も止まるので orphan GC が
754
- * 正しく効く) と同じ構造を codex チャットへ移植する (codex.keepalive を新設し、backend
755
- * consumers.py の中継テーブルと main.mjs の dispatch をセットで変更する)。そうすれば
756
- * 「ブラウザ開 = 無期限 / 明示 detach = 5 分 / ブラウザ落ち = 1h reaper」になり、
757
- * 「N 時間放置しても生きていてほしい」という数字合わせ自体が不要になる。
768
+ * この reaper は `detachRequested` を **意図的に参照しない**。detach を送れずに消えた
769
+ * ブラウザ (タブごとクラッシュ / 端末スリープ) は detachRequested が立たないまま残るため、
770
+ * ここでフラグを見ると回収不能になりリークする。「無通信が続いた事実」だけで回収する。
771
+ *
772
+ * 閲覧中の誤爆は keepalive で防ぐ (2026-07-17 に解消。旧: 開いたままでも 1h 無入力で
773
+ * 回収されていた)。frontend は Codex チャットを開いている間 codex.keepalive を定期送信し、
774
+ * keepalive() が lastActivityMs を touch する。ブラウザが閉じれば keepalive も止まるので
775
+ * この reaper は本来の orphan GC として正しく効く。CockpitTerminal.tsx の PTY keepalive
776
+ * (pty.resize で lastSeenAt を触る) と同じ構造。結果として TTL の意味は
777
+ * 「ブラウザ開 = 無期限 / 明示 detach = 5 分 / ブラウザ落ち = 1h reaper」に整理され、
778
+ * 「N 時間放置しても生きていてほしい」という数字合わせ自体が不要になった。
758
779
  *
759
780
  * @param {number} [ttlMs] 無通信とみなす閾値 (テスト用に上書き可)。
760
781
  * @returns {number} 回収したセッション数。
package/src/config.mjs CHANGED
@@ -10,6 +10,7 @@
10
10
  import { promises as fs, constants as fsConsts, readFileSync } from "node:fs"
11
11
  import path from "node:path"
12
12
  import os from "node:os"
13
+ import { randomUUID } from "node:crypto"
13
14
  import { execFile, execFileSync } from "node:child_process"
14
15
  import { promisify } from "node:util"
15
16
 
@@ -90,6 +91,9 @@ function resolveConfigDir() {
90
91
  return path.join(os.homedir(), ".hub")
91
92
  }
92
93
 
94
+ /** canonical UUID (v4 想定だが版は問わない)。host_key の契約。 */
95
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
96
+
93
97
  export const paths = {
94
98
  get configDir() {
95
99
  return resolveConfigDir()
@@ -97,11 +101,111 @@ export const paths = {
97
101
  get configFile() {
98
102
  return path.join(resolveConfigDir(), "agent.json")
99
103
  },
104
+ get hostKeyFile() {
105
+ return path.join(resolveConfigDir(), "host_key")
106
+ },
100
107
  get pluginsDir() {
101
108
  return path.join(resolveConfigDir(), "plugins")
102
109
  },
103
110
  }
104
111
 
112
+ /**
113
+ * このインストール (物理ホスト) の不変な識別子を返す (無ければ生成して永続化)。
114
+ *
115
+ * ## なぜ必要か (2026-07-17)
116
+ *
117
+ * Hub 側に「このワークスペースで今どの会話 (claude=session_id / codex=thread_id) を
118
+ * 開くか」を保存するにあたり、**その会話がどのホストのファイルシステムに存在するか**
119
+ * を指す必要がある。ところが Hub には物理ホストの安定した ID が無かった:
120
+ *
121
+ * - `CockpitAgent`(agent_id) = **credential のレコード**。再 enroll のたびに revoke /
122
+ * 再作成される (= ホストの同一性を表さない)
123
+ * - `hostname` = **表示用の属性**。Hub の PATCH API で変更でき、OS rename でも変わり、
124
+ * 別の物理端末が同名を名乗れる (= 一意でも不変でもない)
125
+ *
126
+ * そこで install 単位の opaque UUID をここで生成し、enroll 時に Hub へ送る。
127
+ *
128
+ * ## なぜ agent.json ではなく専用ファイルなのか
129
+ *
130
+ * `writeConfig()` は **agent.json を全置換**し、`clearConfig()` は **削除**する。
131
+ * host_key を agent.json に入れると credential のライフサイクルに巻き込まれ、
132
+ * 再 enroll / unenroll で消える = 「credential から独立した ID」という目的を
133
+ * 自ら壊す。別ファイルにして、credential の rotate と完全に分離する。
134
+ *
135
+ * ## 並行安全 (2026-07-17, Codex レビュー指摘)
136
+ *
137
+ * read → ENOENT → write の check-then-create は非原子的で、同じ config dir で 2 つの
138
+ * プロセスが初回起動すると **別々の UUID を生成して互いに上書きし得る** (片方が A を
139
+ * Hub へ送ったのに最終ファイルは B になり、次回起動で B を送る = identity が変わる)。
140
+ * 「install 単位で不変」を生成関数自身が保証できない。
141
+ *
142
+ * ⚠️ 単純な `flag: "wx"` でも不十分だった (2026-07-17、Codex レビュー 6 巡目):
143
+ * wx は **open の排他性**しか保証せず、勝者が open → write → close する間に、
144
+ * EEXIST を受けた敗者が**空 / 書きかけのファイルを読む窓**が残る (その起動だけ
145
+ * host_key の解決に失敗し、ポインタ機能が無効化される)。
146
+ *
147
+ * そこで **temp file へ完成品を書いてから `link()` で公開**する:
148
+ * link(2) は「リンク先が既に存在すれば EEXIST」で原子的なので、**排他性と
149
+ * 「公開されたファイルは常に完成済み」の両方**が同時に満たされる。敗者は勝者の
150
+ * 完成済みファイルを読むだけでよい。
151
+ *
152
+ * @param {{ randomUUIDImpl?: () => string, onBeforePublish?: () => Promise<void> }} [opts]
153
+ * テスト用の注入口 (onBeforePublish は publish 直前に割り込んで競合窓を再現する)
154
+ * @returns {Promise<string>} 既存の値、または新規生成した UUID
155
+ */
156
+ export async function getOrCreateHostKey(opts = {}) {
157
+ const existing = await _readHostKey()
158
+ if (existing) return existing
159
+
160
+ await ensureConfigDir()
161
+ const uuid = (opts.randomUUIDImpl || randomUUID)()
162
+ // 一意な temp 名 (同一 dir。link は同一 FS でしか張れないため)。
163
+ const tmp = `${paths.hostKeyFile}.${process.pid}.${randomUUID()}.tmp`
164
+ try {
165
+ // 0o600 は秘密だからではなく、書き換えられると別ホストになりすませるため。
166
+ await fs.writeFile(tmp, `${uuid}\n`, { mode: 0o600 })
167
+ if (opts.onBeforePublish) await opts.onBeforePublish()
168
+ try {
169
+ // 原子的 publish: 既にあれば EEXIST (= 競合の敗者)。
170
+ await fs.link(tmp, paths.hostKeyFile)
171
+ return uuid
172
+ } catch (err) {
173
+ if (err.code !== "EEXIST") throw err
174
+ // 敗者: 勝者が publish したのは **完成済み**のファイルなので、そのまま読める。
175
+ const winner = await _readHostKey()
176
+ if (!winner) {
177
+ throw new Error("host_key の生成が競合し、勝者の値も読めませんでした")
178
+ }
179
+ return winner
180
+ }
181
+ } finally {
182
+ await fs.unlink(tmp).catch(() => {})
183
+ }
184
+ }
185
+
186
+ /** host_key ファイルを読む。無ければ null。中身が UUID でなければ throw。 */
187
+ async function _readHostKey() {
188
+ let raw
189
+ try {
190
+ raw = await fs.readFile(paths.hostKeyFile, "utf-8")
191
+ } catch (err) {
192
+ if (err.code === "ENOENT") return null
193
+ throw err
194
+ }
195
+ const value = raw.trim()
196
+ if (!value) return null
197
+ if (!UUID_RE.test(value)) {
198
+ // ⚠️ 黙って新しい値へ rotate しない: identity が静かに変わると、Hub 側の
199
+ // 「今どの会話を開くか」が別ホスト扱いで引けなくなり、原因も追えない
200
+ // (Codex レビュー指摘、2026-07-17)。壊れているなら明示的に落とす。
201
+ throw new Error(
202
+ `${paths.hostKeyFile} の内容が UUID ではありません (${value.slice(0, 16)}...)。` +
203
+ "手動で修復するか、ファイルを削除して再生成してください。",
204
+ )
205
+ }
206
+ return value
207
+ }
208
+
105
209
  /** ~/.hub を作成する (chmod 700)。 */
106
210
  export async function ensureConfigDir() {
107
211
  await fs.mkdir(paths.configDir, { recursive: true, mode: 0o700 })
package/src/enroll.mjs CHANGED
@@ -8,7 +8,13 @@
8
8
  */
9
9
  import os from "node:os"
10
10
 
11
- import { detectAvailableClis, detectPlatform, hasConfig, writeConfig } from "./config.mjs"
11
+ import {
12
+ detectAvailableClis,
13
+ detectPlatform,
14
+ getOrCreateHostKey,
15
+ hasConfig,
16
+ writeConfig,
17
+ } from "./config.mjs"
12
18
 
13
19
  const DEFAULT_HUB_URL = process.env.HUB_URL || "https://hub.cocorograph.com"
14
20
 
@@ -35,6 +41,11 @@ export async function enroll(enrollmentToken, opts = {}) {
35
41
  }
36
42
 
37
43
  const availableClis = await detectAvailableClis()
44
+ // このインストール (物理ホスト) の不変な ID。credential (agent_id) と違い、
45
+ // 再 enroll しても同じ値を送り続けるため、Hub 側は「同じホスト」だと分かる。
46
+ // Hub の「今どの会話を開くか」ポインタがこれをキーにする (config.mjs の
47
+ // getOrCreateHostKey の docstring 参照)。
48
+ const hostKey = await getOrCreateHostKey()
38
49
 
39
50
  const res = await fetchImpl(`${hubUrl}/api/cockpit/agents/enroll-complete/`, {
40
51
  method: "POST",
@@ -42,6 +53,7 @@ export async function enroll(enrollmentToken, opts = {}) {
42
53
  body: JSON.stringify({
43
54
  enrollment_token: enrollmentToken,
44
55
  hostname,
56
+ host_key: hostKey,
45
57
  version,
46
58
  platform: detectPlatform(),
47
59
  available_clis: availableClis,
package/src/main.mjs CHANGED
@@ -19,7 +19,13 @@ import path from "node:path"
19
19
 
20
20
  import pino from "pino"
21
21
 
22
- import { detectAvailableClis, detectPlatform, readConfig, writeConfig } from "./config.mjs"
22
+ import {
23
+ detectAvailableClis,
24
+ detectPlatform,
25
+ getOrCreateHostKey,
26
+ readConfig,
27
+ writeConfig,
28
+ } from "./config.mjs"
23
29
  import {
24
30
  codexTerminalSkipVersion,
25
31
  compareBundleVersions,
@@ -775,9 +781,25 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
775
781
  }
776
782
  }
777
783
 
784
+ // 物理ホストの不変 ID。**全起動が通るこの経路で解決**し hello に載せる
785
+ // (enroll 専用にすると既存端末が更新しても Hub に届かない。config.mjs の
786
+ // getOrCreateHostKey docstring 参照)。読めない/壊れている場合は fail-soft:
787
+ // 空文字で続行し、Hub 側はポインタ機能だけを無効化する (agent は通常どおり動く)。
788
+ let hostKey = ""
789
+ try {
790
+ hostKey = await getOrCreateHostKey()
791
+ logger.info({ host_key: hostKey }, "host key resolved")
792
+ } catch (err) {
793
+ logger.warn(
794
+ { err: err.message },
795
+ "host key の解決に失敗 (会話ポインタ機能のみ無効化。agent は通常どおり動作)",
796
+ )
797
+ }
798
+
778
799
  const client = new WsClient(config, {
779
800
  logger,
780
801
  version,
802
+ hostKey,
781
803
  platform: detectPlatform(),
782
804
  availableClis,
783
805
  bundleVersion,
@@ -4335,6 +4357,13 @@ async function dispatch(msg, ctx) {
4335
4357
  if (!ctx.codexBridge) return
4336
4358
  ctx.codexBridge.detach({ stream_id: msg.stream_id })
4337
4359
  return
4360
+ case "codex.keepalive":
4361
+ // ブラウザが Codex チャットを開いている間の生存シグナル (CockpitTerminal.tsx の
4362
+ // PTY keepalive と同じ構造)。閲覧中に gcAbandonedSessions (既定 1h 無通信) が
4363
+ // 誤爆するのを防ぐ。未知 stream_id は false が返るだけ (無害な no-op)。
4364
+ if (!ctx.codexBridge) return
4365
+ ctx.codexBridge.keepalive({ stream_id: msg.stream_id })
4366
+ return
4338
4367
  case "codex.sessions.request": {
4339
4368
  // cwd 配下の Codex thread 一覧を返す (claude.sessions.request と対称。
4340
4369
  // Cockpit のセッションピッカー/最新セッション自動選択用)。
package/src/ws-client.mjs CHANGED
@@ -114,6 +114,11 @@ export class WsClient extends EventEmitter {
114
114
  this.config = config
115
115
  this.logger = opts.logger
116
116
  this.version = opts.version || "0.1.0"
117
+ // このインストール (物理ホスト) の不変 ID。**enroll だけでなく毎起動の hello で送る**:
118
+ // 既に enroll 済みの端末は更新後も enroll-complete を通らないため、enroll 専用に
119
+ // すると「更新しても host_key が Hub に届かず機能が永久に無効」になる
120
+ // (Codex レビュー指摘、2026-07-17)。backend は hello で空の host_key を backfill する。
121
+ this.hostKey = opts.hostKey || ""
117
122
  this.bundleVersion = opts.bundleVersion || null
118
123
  this.bundleVersionProvider =
119
124
  typeof opts.bundleVersionProvider === "function"
@@ -243,6 +248,7 @@ export class WsClient extends EventEmitter {
243
248
  type: "hello",
244
249
  agent_id: this.config.agent_id,
245
250
  hostname: this.hostname,
251
+ host_key: this.hostKey,
246
252
  version: this.version,
247
253
  bundle_version: this.bundleVersion,
248
254
  platform: this.platform,