@cocorograph/hub-agent 0.8.55 → 0.8.56

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.8.55",
3
+ "version": "0.8.56",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの Claude / Codex チャットと tmux セッションを中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -131,6 +131,45 @@ export async function existsGenericPassword(service, opts = {}) {
131
131
  }
132
132
  }
133
133
 
134
+ /**
135
+ * 非既定プロファイルのハッシュ付き Keychain 認証を削除する。
136
+ *
137
+ * プロファイル削除 (profiles.mjs deleteProfile) の beforeRemove で呼ぶ。候補は literal / realpath の
138
+ * `credentialService()` だけで、素の `PLAIN_CREDENTIAL_SERVICE` (既定プロファイル用) は
139
+ * **絶対に対象へ含めない**。未存在は無視し、それ以外の失敗は削除全体を止める。
140
+ *
141
+ * @param {string} configDir
142
+ * @param {{execFileP?: Function, platform?: string, realpath?: string|null}} [opts]
143
+ * realpath は呼び出し側が rm 前に取った値 (削除後は fs.realpath が失敗するため)
144
+ * @returns {Promise<number>} 削除できた件数 (非 darwin は 0)
145
+ */
146
+ export async function deleteKeychainCredentials(configDir, opts = {}) {
147
+ if (!isKeychainPlatform(opts.platform)) return 0
148
+ const exec = opts.execFileP || execFileP
149
+ const dirs = new Set([configDir])
150
+ if (opts.realpath) dirs.add(opts.realpath)
151
+ else {
152
+ try { dirs.add(await fs.realpath(configDir)) } catch { /* 削除済み等 */ }
153
+ }
154
+ const services = new Set([...dirs].map(credentialService))
155
+ services.delete(PLAIN_CREDENTIAL_SERVICE)
156
+ let deleted = 0
157
+ for (const service of services) {
158
+ try {
159
+ await exec("security", ["delete-generic-password", "-s", service])
160
+ deleted += 1
161
+ } catch (err) {
162
+ const msg = `${err?.message || ""}${err?.stderr || ""}`
163
+ if (msg.includes("could not be found") || msg.includes("SecKeychainSearchCopyNext")) continue
164
+ const detail = String(err?.stderr || err?.message || "unknown error").trim().replace(/\s+/g, " ")
165
+ const failure = new Error(`Keychain service ${service} の削除に失敗しました: ${detail}`)
166
+ failure.code = "io"
167
+ throw failure
168
+ }
169
+ }
170
+ return deleted
171
+ }
172
+
134
173
  /**
135
174
  * その config ディレクトリで Claude Code がログイン済みか (UI の「要ログイン」表示用)。
136
175
  *
@@ -149,19 +149,22 @@ export async function loadRegistry() {
149
149
 
150
150
  /** レジストリを永続化する。default は profiles[] に含めず defaultLabel / activeId だけ保存。 */
151
151
  async function saveRegistry(reg) {
152
- await ensureConfigDir()
153
- const def = reg.profiles.find((p) => p.id === DEFAULT_CODEX_PROFILE_ID)
154
- const payload = {
155
- activeId: reg.activeId,
156
- defaultLabel: def ? def.label : undefined,
157
- profiles: reg.profiles
158
- .filter((p) => p.id !== DEFAULT_CODEX_PROFILE_ID)
159
- .map((p) => ({ id: p.id, label: p.label, configDir: p.configDir })),
152
+ try {
153
+ await ensureConfigDir()
154
+ const def = reg.profiles.find((p) => p.id === DEFAULT_CODEX_PROFILE_ID)
155
+ const payload = {
156
+ activeId: reg.activeId,
157
+ defaultLabel: def ? def.label : undefined,
158
+ profiles: reg.profiles
159
+ .filter((p) => p.id !== DEFAULT_CODEX_PROFILE_ID)
160
+ .map((p) => ({ id: p.id, label: p.label, configDir: p.configDir })),
161
+ }
162
+ await fs.writeFile(registryPath(), JSON.stringify(payload, null, 2), {
163
+ mode: 0o600,
164
+ })
165
+ } finally {
166
+ _cache = null
160
167
  }
161
- await fs.writeFile(registryPath(), JSON.stringify(payload, null, 2), {
162
- mode: 0o600,
163
- })
164
- _cache = null
165
168
  }
166
169
 
167
170
  /** プロファイル一覧と active を返す (UI 表示用)。 */
@@ -252,8 +255,53 @@ export async function renameProfile(id, label) {
252
255
  return { id: target.id, label: target.label, configDir: target.configDir }
253
256
  }
254
257
 
258
+ function deletionError(code, message, cause) {
259
+ const err = new Error(message, cause ? { cause } : undefined)
260
+ err.code = code
261
+ return err
262
+ }
263
+
264
+ export async function deleteProfile(id) {
265
+ if (id === DEFAULT_CODEX_PROFILE_ID) throw deletionError("default", "既定プロファイルは削除できません")
266
+ const reg = await loadRegistry()
267
+ const target = reg.profiles.find((p) => p.id === id)
268
+ if (!target) throw deletionError("unknown", `未知のプロファイルです: ${id}`)
269
+ if (reg.activeId === id) throw deletionError("active", "アクティブなプロファイルです。先に別のアカウントへ切り替えてください")
270
+ const configDir = target.configDir
271
+ const expected = `.codex-${id}`
272
+ if (!path.isAbsolute(configDir) || path.dirname(configDir) !== os.homedir()
273
+ || path.basename(configDir) !== expected || !/^\.codex-\d+$/.test(expected)) {
274
+ throw deletionError("unsafe_path", `安全でない configDir です: ${configDir}`)
275
+ }
276
+ const defaults = new Set([defaultCodexDir()])
277
+ try { defaults.add(await fs.realpath(defaultCodexDir())) } catch { /* 未作成 */ }
278
+ let st = null
279
+ let realpath = null
280
+ try {
281
+ st = await fs.lstat(configDir)
282
+ realpath = await fs.realpath(configDir)
283
+ } catch (err) {
284
+ if (err?.code !== "ENOENT") throw deletionError("io", `configDir の確認に失敗しました: ${err.message}`, err)
285
+ }
286
+ if ((st && (!st.isDirectory() || st.isSymbolicLink())) || defaults.has(configDir)
287
+ || (realpath !== null && defaults.has(realpath))) {
288
+ throw deletionError("unsafe_path", `安全でない configDir です: ${configDir}`)
289
+ }
290
+ let configDirRemoved = false
291
+ if (st) {
292
+ try { await fs.rm(configDir, { recursive: true, force: true }); configDirRemoved = true }
293
+ catch (err) { throw deletionError("io", `configDir の削除に失敗しました: ${err.message}`, err) }
294
+ }
295
+ const updated = { ...reg, profiles: reg.profiles.filter((p) => p.id !== id) }
296
+ try { await saveRegistry(updated) } catch (err) { throw deletionError("io", `レジストリの保存に失敗しました: ${err.message}`, err) }
297
+ return { id: target.id, label: target.label, configDir, configDirRemoved, realpath }
298
+ }
299
+
255
300
  /** 次の連番 ID (既存の数値 id の最大 +1、最低 1)。 */
256
301
  function nextNumericId(profiles) {
302
+ // 削除済み id の再利用は安全: Codex の認証は configDir 内の auth.json だけで、
303
+ // deleteProfile はディレクトリ削除が成功した後にしかレジストリから id を外さない
304
+ // (不変条件: 認証を残したまま id が解放されることはない)。
257
305
  let max = 0
258
306
  for (const p of profiles) {
259
307
  const n = Number.parseInt(p.id, 10)
@@ -69,13 +69,14 @@ function chatMode(type) {
69
69
  }
70
70
 
71
71
  function globalMutationMode(type) {
72
- if (type === "profile.switch" || type === "profile.add" || type === "profile.rename") {
72
+ if (type === "profile.switch" || type === "profile.add" || type === "profile.rename" || type === "profile.delete") {
73
73
  return "claude"
74
74
  }
75
75
  if (
76
76
  type === "codex.profile.switch" ||
77
77
  type === "codex.profile.add" ||
78
- type === "codex.profile.rename"
78
+ type === "codex.profile.rename" ||
79
+ type === "codex.profile.delete"
79
80
  ) {
80
81
  return "codex"
81
82
  }
package/src/main.mjs CHANGED
@@ -82,6 +82,7 @@ import {
82
82
  setActiveProfile,
83
83
  addProfile,
84
84
  renameProfile,
85
+ deleteProfile,
85
86
  } from "./profiles.mjs"
86
87
  // Codex アカウント・プロファイル (独立切替)。profiles.mjs と同名 export が多いため
87
88
  // 名前衝突を避けて namespace import する。
@@ -91,6 +92,7 @@ import {
91
92
  hasFileCredentialsCached,
92
93
  isKeychainPlatform,
93
94
  profileHasKeychainCredentials,
95
+ deleteKeychainCredentials,
94
96
  } from "./claude-credentials.mjs"
95
97
  import {
96
98
  buildClaudeCmd,
@@ -5046,6 +5048,30 @@ export async function dispatch(msg, ctx) {
5046
5048
  }
5047
5049
  return
5048
5050
  }
5051
+ case "profile.delete": {
5052
+ const id = msg.profile_id || ""
5053
+ try {
5054
+ const profileApi = ctx.profiles || { deleteProfile }
5055
+ let keychainDeleted = false
5056
+ const deleted = await profileApi.deleteProfile(id, {
5057
+ beforeRemove: async (target, realpath) => {
5058
+ keychainDeleted = (ctx.isKeychainPlatform || isKeychainPlatform)()
5059
+ ? (await (ctx.deleteKeychainCredentials || deleteKeychainCredentials)(target.configDir, { realpath })) > 0
5060
+ : false
5061
+ },
5062
+ })
5063
+ ;(ctx.clearCredentialCheckCache || clearCredentialCheckCache)(deleted.configDir)
5064
+ ctx.logger.info({ profile_id: id, config_dir_removed: deleted.configDirRemoved, keychain_deleted: keychainDeleted }, "claude profile deleted")
5065
+ ctx.client.send({
5066
+ type: "profile.delete.result", request_id: msg.request_id, ok: true,
5067
+ profile_id: id, config_dir_removed: deleted.configDirRemoved, keychain_deleted: keychainDeleted,
5068
+ })
5069
+ } catch (err) {
5070
+ ctx.client.send({ type: "profile.delete.result", request_id: msg.request_id, ok: false,
5071
+ profile_id: id, reason: err?.code || "io", error: err?.message || String(err) })
5072
+ }
5073
+ return
5074
+ }
5049
5075
  case "codex.profile.list": {
5050
5076
  // Codex アカウント・プロファイル一覧と active を返す (Cockpit の Codex 切替UI用)。
5051
5077
  // 認証はファイル (<CODEX_HOME>/auth.json) の有無で判定 (Keychain 不要)。
@@ -5183,6 +5209,20 @@ export async function dispatch(msg, ctx) {
5183
5209
  }
5184
5210
  return
5185
5211
  }
5212
+ case "codex.profile.delete": {
5213
+ const id = msg.profile_id || ""
5214
+ try {
5215
+ const profileApi = ctx.codexProfiles || codexProfiles
5216
+ const deleted = await profileApi.deleteProfile(id)
5217
+ ctx.logger.info({ profile_id: id, config_dir_removed: deleted.configDirRemoved }, "codex profile deleted")
5218
+ ctx.client.send({ type: "codex.profile.delete.result", request_id: msg.request_id, ok: true,
5219
+ profile_id: id, config_dir_removed: deleted.configDirRemoved, keychain_deleted: false })
5220
+ } catch (err) {
5221
+ ctx.client.send({ type: "codex.profile.delete.result", request_id: msg.request_id, ok: false,
5222
+ profile_id: id, reason: err?.code || "io", error: err?.message || String(err) })
5223
+ }
5224
+ return
5225
+ }
5186
5226
  case "tmux.exec": {
5187
5227
  // ロジックは handleTmuxExec へ抽出 (テストから直接呼べる)。
5188
5228
  await handleTmuxExec(msg, ctx)
package/src/profiles.mjs CHANGED
@@ -114,19 +114,22 @@ export async function loadRegistry() {
114
114
 
115
115
  /** レジストリを永続化する。default は profiles[] に含めず、追加分と activeId / defaultLabel だけ保存。 */
116
116
  async function saveRegistry(reg) {
117
- await ensureConfigDir()
118
- const def = reg.profiles.find((p) => p.id === DEFAULT_PROFILE_ID)
119
- const payload = {
120
- activeId: reg.activeId,
121
- defaultLabel: def ? def.label : undefined,
122
- profiles: reg.profiles
123
- .filter((p) => p.id !== DEFAULT_PROFILE_ID)
124
- .map((p) => ({ id: p.id, label: p.label, configDir: p.configDir })),
117
+ try {
118
+ await ensureConfigDir()
119
+ const def = reg.profiles.find((p) => p.id === DEFAULT_PROFILE_ID)
120
+ const payload = {
121
+ activeId: reg.activeId,
122
+ defaultLabel: def ? def.label : undefined,
123
+ profiles: reg.profiles
124
+ .filter((p) => p.id !== DEFAULT_PROFILE_ID)
125
+ .map((p) => ({ id: p.id, label: p.label, configDir: p.configDir })),
126
+ }
127
+ await fs.writeFile(registryPath(), JSON.stringify(payload, null, 2), {
128
+ mode: 0o600,
129
+ })
130
+ } finally {
131
+ _cache = null
125
132
  }
126
- await fs.writeFile(registryPath(), JSON.stringify(payload, null, 2), {
127
- mode: 0o600,
128
- })
129
- _cache = null
130
133
  }
131
134
 
132
135
  /** プロファイル一覧と active を返す (UI 表示用)。 */
@@ -231,8 +234,74 @@ export async function renameProfile(id, label) {
231
234
  return { id: target.id, label: target.label, configDir: target.configDir }
232
235
  }
233
236
 
237
+ function deletionError(code, message, cause) {
238
+ const err = new Error(message, cause ? { cause } : undefined)
239
+ err.code = code
240
+ return err
241
+ }
242
+
243
+ /**
244
+ * 非既定・非 active プロファイルの実体と登録を安全に削除する。
245
+ * @param {string} id
246
+ * @param {{beforeRemove?: (target: {id:string,label:string,configDir:string}, realpath: string|null) => Promise<void>}} [opts]
247
+ */
248
+ export async function deleteProfile(id, opts = {}) {
249
+ if (id === DEFAULT_PROFILE_ID) throw deletionError("default", "既定プロファイルは削除できません")
250
+ const reg = await loadRegistry()
251
+ const target = reg.profiles.find((p) => p.id === id)
252
+ if (!target) throw deletionError("unknown", `未知のプロファイルです: ${id}`)
253
+ if (reg.activeId === id) {
254
+ throw deletionError("active", "アクティブなプロファイルです。先に別のアカウントへ切り替えてください")
255
+ }
256
+
257
+ const configDir = target.configDir
258
+ const expected = `.claude-${id}`
259
+ if (!path.isAbsolute(configDir) || path.dirname(configDir) !== os.homedir()
260
+ || path.basename(configDir) !== expected || !/^\.claude-\d+$/.test(expected)) {
261
+ throw deletionError("unsafe_path", `安全でない configDir です: ${configDir}`)
262
+ }
263
+ const defaults = new Set([defaultConfigDir()])
264
+ try { defaults.add(await fs.realpath(defaultConfigDir())) } catch { /* 未作成 */ }
265
+ let realpath = null
266
+ let st = null
267
+ try {
268
+ st = await fs.lstat(configDir)
269
+ realpath = await fs.realpath(configDir)
270
+ } catch (err) {
271
+ if (err?.code !== "ENOENT") throw deletionError("io", `configDir の確認に失敗しました: ${err.message}`, err)
272
+ }
273
+ if ((st && (!st.isDirectory() || st.isSymbolicLink())) || defaults.has(configDir)
274
+ || (realpath !== null && defaults.has(realpath))) {
275
+ throw deletionError("unsafe_path", `安全でない configDir です: ${configDir}`)
276
+ }
277
+ if (opts.beforeRemove) {
278
+ try {
279
+ await opts.beforeRemove({ id: target.id, label: target.label, configDir }, realpath)
280
+ } catch (err) {
281
+ if (!err.code) err.code = "io"
282
+ throw err
283
+ }
284
+ }
285
+ let configDirRemoved = false
286
+ if (st) {
287
+ try {
288
+ await fs.rm(configDir, { recursive: true, force: true })
289
+ configDirRemoved = true
290
+ } catch (err) {
291
+ throw deletionError("io", `configDir の削除に失敗しました: ${err.message}`, err)
292
+ }
293
+ }
294
+ const updated = { ...reg, profiles: reg.profiles.filter((p) => p.id !== id) }
295
+ try { await saveRegistry(updated) } catch (err) {
296
+ throw deletionError("io", `レジストリの保存に失敗しました: ${err.message}`, err)
297
+ }
298
+ return { id: target.id, label: target.label, configDir, configDirRemoved, realpath }
299
+ }
300
+
234
301
  /** 次の連番 ID (既存の数値 id の最大 +1、最低 1)。 */
235
302
  function nextNumericId(profiles) {
303
+ // 削除は Keychain → ディレクトリ → レジストリの順で、認証が残ったまま id が
304
+ // 解放されることはない (deleteProfile の beforeRemove)。
236
305
  let max = 0
237
306
  for (const p of profiles) {
238
307
  const n = Number.parseInt(p.id, 10)