@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 +1 -1
- package/src/codex-stream-bridge.mjs +32 -11
- package/src/config.mjs +104 -0
- package/src/enroll.mjs +13 -1
- package/src/main.mjs +30 -1
- package/src/ws-client.mjs +6 -0
package/package.json
CHANGED
|
@@ -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
|
-
*
|
|
748
|
-
*
|
|
749
|
-
*
|
|
750
|
-
*
|
|
751
|
-
*
|
|
752
|
-
* frontend
|
|
753
|
-
*
|
|
754
|
-
*
|
|
755
|
-
*
|
|
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 {
|
|
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 {
|
|
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,
|