@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 +1 -1
- package/src/config.mjs +104 -0
- package/src/enroll.mjs +13 -1
- package/src/main.mjs +23 -1
- package/src/ws-client.mjs +6 -0
package/package.json
CHANGED
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,
|
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,
|