@cocorograph/hub-agent 0.7.70 → 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.70",
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",
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,
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,