@cocorograph/hub-agent 0.7.45 → 0.7.46

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.45",
3
+ "version": "0.7.46",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -1,202 +1,98 @@
1
1
  /**
2
- * Claude Code 認証情報 (macOS Keychain) のプロファイル別スワップ。
2
+ * Claude Code の OAuth 認証情報 (macOS Keychain) の在否判定。
3
3
  *
4
- * WHY: macOS の Claude Code は OAuth トークンを Keychain の単一エントリ
5
- * 「Claude Code-credentials」に保存し、サービス名は `CLAUDE_CONFIG_DIR` に
6
- * 依存しない。そのため config ディレクトリを切り替えても全プロファイルが
7
- * 同じトークンを読んでしまい、アカウントが切り替わらない (2026-06-07 実測)。
4
+ * 2026-07 更新 (B案 / swap 撤去): Claude Code は OAuth トークンを Keychain の
5
+ * `Claude Code-credentials-<sha256(CLAUDE_CONFIG_DIR)先頭8桁>` に保存し、config
6
+ * ディレクトリごとにネイティブに分離するようになった (Claude Code 2.1.x で実測確認)。
7
+ * そのため hub-agent が旧来行っていた「素の `Claude Code-credentials` を profile 切替
8
+ * のたびに手動退避/復元 (swap) する」処理は不要かつ有害 (Claude Code が認証に使わない
9
+ * 別エントリ = acct "hub-agent" を触るだけで空振りし、既定プロファイル等で再ログインを
10
+ * 誘発していた) になったため撤去した。ここは UI の「要ログイン」表示用に「その config
11
+ * ディレクトリで Claude Code がログイン済みか」を在否チェックするだけに縮小する。
12
+ * シークレットは復号しない (`-w` を付けない) ため Keychain の ACL プロンプトを誘発しない。
8
13
  *
9
- * 対策: profile.switch のタイミングで
10
- * 1. 正規エントリ (canonical) を切替元プロファイルのスロットへ退避
11
- * 2. 切替先プロファイルのスロットから canonical へ復元
12
- * 3. 切替先スロットが無ければ canonical を削除して needs_login を返す
13
- * (削除しないと旧アカウントのトークンが残り「切り替わらない」が再発する)
14
+ * 旧 swap の経緯 (2026-06-07): 当時の Claude Code は全 config ディレクトリで単一の素の
15
+ * `Claude Code-credentials` を共有していたため、ディレクトリ切替だけではアカウントが
16
+ * 変わらず、Keychain を手動退避/復元する必要があった。実装は git 履歴を参照
17
+ * (CANONICAL_SERVICE / slotService / swapCredentials 等)。
14
18
  *
15
- * スロットは「Claude Code-credentials (hub-agent profile <id>)」という
16
- * 別サービス名の Keychain エントリ。Claude Code 本体はスロットを読まない。
17
- *
18
- * Linux 等 Keychain の無いプラットフォームでは Claude Code が
19
- * `<configDir>/.credentials.json` を使うため、config ディレクトリの切替だけで
20
- * 認証も分離される。スワップは darwin 限定で no-op を返す。
21
- *
22
- * 書き込みは `security -i` (stdin) 経由で行い、トークンが argv (ps で他
23
- * プロセスから観測可能) に露出しないようにする。`security -w` の読み出しは
24
- * データが非 printable のとき hex を返すため decode を挟む。
19
+ * Linux 等 Keychain の無い環境は Claude Code が `<configDir>/.credentials.json` を使い
20
+ * もともと config ディレクトリ単位で分離済み (profiles.mjs の hasFileCredentials を使う)。
25
21
  */
26
- import { execFile, spawn } from "node:child_process"
22
+ import { promises as fs } from "node:fs"
23
+ import { createHash } from "node:crypto"
24
+ import { execFile } from "node:child_process"
27
25
  import { promisify } from "node:util"
28
26
 
29
27
  const execFileP = promisify(execFile)
30
28
 
31
- /** Claude Code 本体が読む正規エントリのサービス名 (固定)。 */
32
- export const CANONICAL_SERVICE = "Claude Code-credentials"
33
-
34
- /** プロファイル別退避スロットのサービス名。 */
35
- export function slotService(profileId) {
36
- return `Claude Code-credentials (hub-agent profile ${profileId})`
37
- }
38
-
39
- /** Keychain スワップが必要なプラットフォームか。 */
29
+ /** Keychain を使うプラットフォームか (認証分離が Keychain 依存なのは darwin のみ)。 */
40
30
  export function isKeychainPlatform(platform = process.platform) {
41
31
  return platform === "darwin"
42
32
  }
43
33
 
44
34
  /**
45
- * `security find-generic-password -w` の出力を decode する。
46
- * security はデータに非 printable バイトが含まれると hex で出力する。
47
- * Claude の credentials は ASCII JSON なので通常 raw のまま返るが、
48
- * hex らしき文字列で decode 結果が JSON (`{` 始まり) なら decode を採用する。
35
+ * ある config ディレクトリの認証情報を Claude Code が保存する Keychain サービス名。
36
+ * `Claude Code-credentials-<sha256(configDir)先頭8桁>` (Claude Code 2.1.x の実装)。
37
+ * ⚠️ このハッシュ式が本体とズレると在否チェックが全部空振りするため、
38
+ * test/claude-credentials.test.mjs が実機既知値で固定している。
39
+ *
40
+ * @param {string} configDir CLAUDE_CONFIG_DIR (既定プロファイルは `~/.claude`)
41
+ * @returns {string}
49
42
  */
50
- export function decodeSecurityOutput(raw) {
51
- const s = (raw || "").replace(/\r?\n$/, "")
52
- if (s.startsWith("{")) return s
53
- if (/^(?:[0-9a-f]{2})+$/.test(s)) {
54
- try {
55
- const decoded = Buffer.from(s, "hex").toString("utf-8")
56
- if (decoded.startsWith("{")) return decoded
57
- } catch {
58
- /* hex ではなかった — raw を返す */
59
- }
60
- }
61
- return s
62
- }
63
-
64
- /** err が「項目が見つからない」(security exit 44) かを判定する。 */
65
- function isNotFound(err) {
66
- const msg = `${err?.message || ""}${err?.stderr || ""}`
67
- return msg.includes("could not be found") || msg.includes("SecKeychainSearchCopyNext")
43
+ export function credentialService(configDir) {
44
+ const hash = createHash("sha256")
45
+ .update(String(configDir))
46
+ .digest("hex")
47
+ .slice(0, 8)
48
+ return `Claude Code-credentials-${hash}`
68
49
  }
69
50
 
70
51
  /**
71
- * Keychain からシークレットを読む。存在しなければ null。
72
- * @returns {Promise<string|null>}
52
+ * Keychain にエントリが存在するか (シークレットは復号しない = ACL プロンプト無し)。
53
+ * @returns {Promise<boolean>}
73
54
  */
74
- export async function readGenericPassword(service, opts = {}) {
75
- const exec = opts.execFileP || execFileP
76
- try {
77
- const { stdout } = await exec("security", [
78
- "find-generic-password",
79
- "-s",
80
- service,
81
- "-w",
82
- ])
83
- return decodeSecurityOutput(stdout)
84
- } catch (err) {
85
- if (isNotFound(err)) return null
86
- throw err
87
- }
88
- }
89
-
90
- /** Keychain にエントリが存在するか (シークレットは復号しない = ACL プロンプトを誘発しない)。 */
91
55
  export async function existsGenericPassword(service, opts = {}) {
92
56
  const exec = opts.execFileP || execFileP
93
57
  try {
94
58
  await exec("security", ["find-generic-password", "-s", service])
95
59
  return true
96
60
  } catch (err) {
97
- if (isNotFound(err)) return false
98
- throw err
99
- }
100
- }
101
-
102
- /** Keychain からエントリを削除する。存在しなければ何もしない。 */
103
- export async function deleteGenericPassword(service, opts = {}) {
104
- const exec = opts.execFileP || execFileP
105
- try {
106
- await exec("security", ["delete-generic-password", "-s", service])
107
- return true
108
- } catch (err) {
109
- if (isNotFound(err)) return false
61
+ const msg = `${err?.message || ""}${err?.stderr || ""}`
62
+ if (
63
+ msg.includes("could not be found") ||
64
+ msg.includes("SecKeychainSearchCopyNext")
65
+ ) {
66
+ return false
67
+ }
110
68
  throw err
111
69
  }
112
70
  }
113
71
 
114
- /** `security -i` の対話コマンド用にダブルクォート文字列をエスケープする。 */
115
- export function escapeSecurityArg(value) {
116
- return String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"')
117
- }
118
-
119
- /**
120
- * Keychain へシークレットを書き込む (既存エントリは作り直し)。
121
- * `-U` での更新は ACL 制約で失敗し得るため delete → add の順で行う。
122
- * シークレットは stdin (`security -i`) 経由で渡し argv へ露出させない。
123
- */
124
- export async function writeGenericPassword(service, secret, opts = {}) {
125
- await deleteGenericPassword(service, opts).catch(() => {})
126
- const spawnImpl = opts.spawn || spawn
127
- const account = opts.account || "hub-agent"
128
- const line =
129
- `add-generic-password -a "${escapeSecurityArg(account)}"` +
130
- ` -s "${escapeSecurityArg(service)}"` +
131
- ` -w "${escapeSecurityArg(secret)}"\n`
132
- await new Promise((resolve, reject) => {
133
- const child = spawnImpl("security", ["-i"], {
134
- stdio: ["pipe", "ignore", "pipe"],
135
- })
136
- let stderr = ""
137
- child.stderr?.on("data", (d) => {
138
- stderr += String(d)
139
- })
140
- child.on("error", reject)
141
- child.on("close", (code) => {
142
- if (code === 0) resolve()
143
- else reject(new Error(`security add-generic-password failed (exit ${code}): ${stderr.trim()}`))
144
- })
145
- child.stdin.write(line)
146
- child.stdin.end()
147
- })
148
- }
149
-
150
72
  /**
151
- * プロファイル切替に伴う認証情報のスワップ本体。
73
+ * その config ディレクトリで Claude Code がログイン済みか (UI の「要ログイン」表示用)。
152
74
  *
153
- * @param {{fromId: string, toId: string, logger?: object,
154
- * execFileP?: Function, spawn?: Function, platform?: string}} opts
155
- * @returns {Promise<{saved: boolean, restored: boolean, needsLogin: boolean, skipped?: string}>}
156
- */
157
- export async function swapCredentials(opts) {
158
- const { fromId, toId, logger } = opts
159
- if (!isKeychainPlatform(opts.platform)) {
160
- // Keychain の無い環境は <configDir>/.credentials.json でもともと分離済み。
161
- return { saved: false, restored: false, needsLogin: false, skipped: "not-darwin" }
162
- }
163
- const io = { execFileP: opts.execFileP, spawn: opts.spawn }
164
- const result = { saved: false, restored: false, needsLogin: false }
165
-
166
- // 1. 現在の canonical を切替元スロットへ退避 (リフレッシュ済み最新トークンを保全)。
167
- const current = await readGenericPassword(CANONICAL_SERVICE, io)
168
- if (current) {
169
- await writeGenericPassword(slotService(fromId), current, io)
170
- result.saved = true
171
- }
172
-
173
- // 2. 切替先スロットから canonical を復元。無ければ canonical を削除して要ログイン。
174
- const target = await readGenericPassword(slotService(toId), io)
175
- if (target) {
176
- await writeGenericPassword(CANONICAL_SERVICE, target, io)
177
- result.restored = true
178
- } else {
179
- await deleteGenericPassword(CANONICAL_SERVICE, io).catch(() => {})
180
- result.needsLogin = true
181
- }
182
-
183
- logger?.info?.(
184
- { from: fromId, to: toId, ...result },
185
- "claude credentials swapped for profile switch",
186
- )
187
- return result
188
- }
189
-
190
- /**
191
- * プロファイルがログイン可能な認証情報を持っているか (UI 表示用)。
192
- * darwin: active なら canonical、非 active ならスロットの存在を見る。
193
- * その他: `<configDir>/.credentials.json` の存在を見る (呼び出し側で判定)。
75
+ * `~/.claude` が symlink (例: iCloud Drive への集約) のとき、Claude Code が
76
+ * symlink パスと realpath のどちらでハッシュを計算するかに依存しないよう、両方の
77
+ * ハッシュ候補を在否チェックし、いずれか存在すればログイン済みとみなす。
78
+ *
79
+ * @param {string} configDir
80
+ * @param {{execFileP?: Function}} [opts]
81
+ * @returns {Promise<boolean>}
194
82
  */
195
- export async function profileHasKeychainCredentials(profileId, isActive, opts = {}) {
196
- const service = isActive ? CANONICAL_SERVICE : slotService(profileId)
83
+ export async function profileHasKeychainCredentials(configDir, opts = {}) {
84
+ const candidates = new Set([configDir])
197
85
  try {
198
- return await existsGenericPassword(service, opts)
86
+ candidates.add(await fs.realpath(configDir))
199
87
  } catch {
200
- return false
88
+ /* realpath 不能 (ディレクトリ未作成等) は無視して literal パスのみ評価 */
89
+ }
90
+ for (const dir of candidates) {
91
+ try {
92
+ if (await existsGenericPassword(credentialService(dir), opts)) return true
93
+ } catch {
94
+ /* security 実行エラーは「不明」→ 存在しない寄せ */
95
+ }
201
96
  }
97
+ return false
202
98
  }
package/src/main.mjs CHANGED
@@ -56,11 +56,11 @@ import {
56
56
  readProfileAccount,
57
57
  setActiveProfile,
58
58
  addProfile,
59
+ renameProfile,
59
60
  } from "./profiles.mjs"
60
61
  import {
61
62
  isKeychainPlatform,
62
63
  profileHasKeychainCredentials,
63
- swapCredentials,
64
64
  } from "./claude-credentials.mjs"
65
65
  import {
66
66
  answerAskUserQuestion,
@@ -3871,7 +3871,7 @@ async function dispatch(msg, ctx) {
3871
3871
  reg.profiles.map(async (p) => {
3872
3872
  const account = await readProfileAccount(p.configDir)
3873
3873
  const hasCreds = isKeychainPlatform()
3874
- ? await profileHasKeychainCredentials(p.id, p.id === reg.activeId)
3874
+ ? await profileHasKeychainCredentials(p.configDir)
3875
3875
  : await hasFileCredentials(p.configDir)
3876
3876
  return {
3877
3877
  id: p.id,
@@ -3892,35 +3892,18 @@ async function dispatch(msg, ctx) {
3892
3892
  return
3893
3893
  }
3894
3894
  case "profile.switch": {
3895
- // アカウント切替。認証情報スワップ → active 永続化 → env 反映 (process/tmux) →
3896
- // 全 Claude セッション強制再起動 (SDK チャット + tmux TUI)。走行中ターンも中断する。
3895
+ // アカウント切替。active 永続化 → env 反映 (process/tmux) → 全 Claude セッション
3896
+ // 強制再起動 (SDK チャット + tmux TUI)。走行中ターンも中断する。
3897
3897
  //
3898
- // WHY 認証スワップ: macOS では OAuth トークンが Keychain の単一エントリに保存され
3899
- // CLAUDE_CONFIG_DIR と無関係なため、ディレクトリ切替だけではアカウントが変わらない
3900
- // (2026-06-07 実測)。claude-credentials.mjs の docstring 参照。
3898
+ // WHY 認証スワップ廃止 (B案, 2026-07): Claude Code 2.1.x は OAuth トークンを
3899
+ // `Claude Code-credentials-<sha256(CLAUDE_CONFIG_DIR)>` に config ディレクトリ別
3900
+ // で保存するようになり、CLAUDE_CONFIG_DIR を切り替えるだけでアカウントも切り替わる。
3901
+ // 旧来の Keychain 手動スワップは Claude Code が読まない素エントリを触るだけの空振り
3902
+ // となり、既定プロファイル等で不要な再ログインを誘発していたため撤去した。
3903
+ // claude-credentials.mjs の docstring 参照。
3901
3904
  const id = msg.profile_id || msg.id || ""
3902
3905
  try {
3903
- const regBefore = await listProfiles()
3904
- const fromId = regBefore.activeId
3905
3906
  const target = await setActiveProfile(id)
3906
- let cred = { saved: false, restored: false, needsLogin: false }
3907
- if (fromId !== target.id) {
3908
- try {
3909
- cred = await swapCredentials({
3910
- fromId,
3911
- toId: target.id,
3912
- logger: ctx.logger,
3913
- })
3914
- } catch (err) {
3915
- // スワップ失敗 (Keychain ACL 拒否等) でも切替自体は続行する。
3916
- // この場合 Keychain には切替前のトークンが残るため、その旨を返す。
3917
- ctx.logger.warn(
3918
- { err: err?.message || String(err), from: fromId, to: target.id },
3919
- "credential swap failed; keychain may still hold previous account",
3920
- )
3921
- cred = { saved: false, restored: false, needsLogin: false, error: err?.message }
3922
- }
3923
- }
3924
3907
  applyActiveProfileEnv(target, ctx.logger)
3925
3908
  await syncTmuxProfileEnv(target, ctx.logger)
3926
3909
  // 全セッションを撤去 (frontend は exit 通知でセッション一覧をリロードする)。
@@ -3928,6 +3911,11 @@ async function dispatch(msg, ctx) {
3928
3911
  ctx.claudeBridge?.shutdown()
3929
3912
  // tmux TUI セッションも旧アカウントのまま残さない: kill → 同名・同 cwd で再作成。
3930
3913
  const tmuxResult = await restartAllTmuxSessionsForProfileSwitch(ctx)
3914
+ // 切替先の config ディレクトリに認証情報が無ければ要ログイン (バッジ/案内用)。
3915
+ const hasCreds = isKeychainPlatform()
3916
+ ? await profileHasKeychainCredentials(target.configDir)
3917
+ : await hasFileCredentials(target.configDir)
3918
+ const needsLogin = !hasCreds
3931
3919
  ctx.logger.info(
3932
3920
  {
3933
3921
  profile_id: target.id,
@@ -3935,9 +3923,7 @@ async function dispatch(msg, ctx) {
3935
3923
  tmux_killed: tmuxResult.killed.length,
3936
3924
  tmux_recreated: tmuxResult.recreated.length,
3937
3925
  tmux_failed: tmuxResult.failed.length,
3938
- cred_saved: cred.saved,
3939
- cred_restored: cred.restored,
3940
- needs_login: cred.needsLogin,
3926
+ needs_login: needsLogin,
3941
3927
  },
3942
3928
  "claude profile switched; all sessions reaped",
3943
3929
  )
@@ -3954,13 +3940,12 @@ async function dispatch(msg, ctx) {
3954
3940
  },
3955
3941
  reaped: before,
3956
3942
  tmux_restarted: tmuxResult.recreated.length,
3957
- needs_login: !!cred.needsLogin,
3958
- login_command: cred.needsLogin
3943
+ needs_login: needsLogin,
3944
+ login_command: needsLogin
3959
3945
  ? isDefaultTarget
3960
3946
  ? "claude"
3961
3947
  : `CLAUDE_CONFIG_DIR=${target.configDir} claude`
3962
3948
  : undefined,
3963
- credential_error: cred.error || undefined,
3964
3949
  })
3965
3950
  } catch (err) {
3966
3951
  ctx.client.send({
@@ -4005,6 +3990,34 @@ async function dispatch(msg, ctx) {
4005
3990
  }
4006
3991
  return
4007
3992
  }
3993
+ case "profile.rename": {
3994
+ // プロファイルの表示ラベルのみ変更する。id・configDir・認証情報 (Keychain
3995
+ // スロット / .credentials.json) には一切触れないため、切替や再ログインは不要。
3996
+ // 既定プロファイルのラベルも変更可 (configDir は常に ~/.claude のまま)。
3997
+ const id = msg.profile_id || msg.id || ""
3998
+ try {
3999
+ const updated = await renameProfile(id, msg.label)
4000
+ ctx.client.send({
4001
+ type: "profile.rename.result",
4002
+ request_id: msg.request_id,
4003
+ ok: true,
4004
+ profile: {
4005
+ id: updated.id,
4006
+ label: updated.label,
4007
+ config_dir: updated.configDir,
4008
+ },
4009
+ })
4010
+ } catch (err) {
4011
+ ctx.client.send({
4012
+ type: "profile.rename.result",
4013
+ request_id: msg.request_id,
4014
+ ok: false,
4015
+ profile_id: id,
4016
+ error: err?.message || String(err),
4017
+ })
4018
+ }
4019
+ return
4020
+ }
4008
4021
  case "tmux.exec": {
4009
4022
  const args = Array.isArray(msg.args) ? msg.args : []
4010
4023
  const hookResult = await runHookChain(ctx.plugins, "interceptTmuxExec", {
package/src/profiles.mjs CHANGED
@@ -169,6 +169,26 @@ export async function setActiveProfile(id) {
169
169
  return target
170
170
  }
171
171
 
172
+ /**
173
+ * プロファイルの表示ラベルを変更する。id・configDir・認証情報には一切触れない。
174
+ * 既定 (プライマリ) プロファイルのラベルも変更可能で、saveRegistry が defaultLabel
175
+ * として永続化する (configDir は常に `~/.claude` のまま = ディレクトリは動かさない)。
176
+ *
177
+ * @param {string} id 対象プロファイルの id (既定は "default")
178
+ * @param {string} label 新しいラベル (前後空白を除去。空文字列は不可)
179
+ * @returns {Promise<{id:string,label:string,configDir:string}>} 更新後のプロファイル
180
+ */
181
+ export async function renameProfile(id, label) {
182
+ const trimmed = typeof label === "string" ? label.trim() : ""
183
+ if (!trimmed) throw new Error("label must be a non-empty string")
184
+ const reg = await loadRegistry()
185
+ const target = reg.profiles.find((p) => p.id === id)
186
+ if (!target) throw new Error(`unknown profile: ${id}`)
187
+ target.label = trimmed
188
+ await saveRegistry(reg)
189
+ return { id: target.id, label: target.label, configDir: target.configDir }
190
+ }
191
+
172
192
  /** 次の連番 ID (既存の数値 id の最大 +1、最低 1)。 */
173
193
  function nextNumericId(profiles) {
174
194
  let max = 0