@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 +1 -1
- package/src/claude-credentials.mjs +39 -0
- package/src/codex-profiles.mjs +60 -12
- package/src/dispatch-scheduler.mjs +3 -2
- package/src/main.mjs +40 -0
- package/src/profiles.mjs +81 -12
package/package.json
CHANGED
|
@@ -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
|
*
|
package/src/codex-profiles.mjs
CHANGED
|
@@ -149,19 +149,22 @@ export async function loadRegistry() {
|
|
|
149
149
|
|
|
150
150
|
/** レジストリを永続化する。default は profiles[] に含めず defaultLabel / activeId だけ保存。 */
|
|
151
151
|
async function saveRegistry(reg) {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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)
|