@maxiaochao/pi-toolkit 0.11.7 → 0.11.9
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/CHANGELOG.md +13 -0
- package/README.md +1 -0
- package/package.json +5 -3
- package/packages/openai-account-pool/CHANGELOG.md +15 -0
- package/packages/openai-account-pool/README.md +67 -0
- package/packages/openai-account-pool/TESTING.md +28 -0
- package/packages/openai-account-pool/docs/demo.gif +0 -0
- package/packages/openai-account-pool/docs/screenshot.png +0 -0
- package/packages/openai-account-pool/extensions/openai-account-pool.ts +368 -0
- package/packages/openai-account-pool/package.json +45 -0
- package/packages/openai-account-pool/src/auth.ts +418 -0
- package/packages/openai-account-pool/src/availability.ts +230 -0
- package/packages/openai-account-pool/src/command-completions.ts +18 -0
- package/packages/openai-account-pool/src/enrollment.ts +339 -0
- package/packages/openai-account-pool/src/lease.ts +128 -0
- package/packages/openai-account-pool/src/lock.ts +171 -0
- package/packages/openai-account-pool/src/login-slot.ts +158 -0
- package/packages/openai-account-pool/src/policy.ts +176 -0
- package/packages/openai-account-pool/src/provider.ts +511 -0
- package/packages/openai-account-pool/src/status-widget.ts +62 -0
- package/packages/openai-account-pool/src/store.ts +259 -0
- package/packages/openai-account-pool/src/usage.ts +291 -0
- package/packages/openai-account-pool/tests/command-completions.mjs +10 -0
- package/packages/openai-account-pool/tests/enrollment.mjs +322 -0
- package/packages/openai-account-pool/tests/failover-stream.mjs +554 -0
- package/packages/openai-account-pool/tests/helpers/fixtures.mjs +40 -0
- package/packages/openai-account-pool/tests/helpers/native-credential-store.mjs +58 -0
- package/packages/openai-account-pool/tests/helpers/pi-runtime.mjs +24 -0
- package/packages/openai-account-pool/tests/helpers/refresh-child.mjs +29 -0
- package/packages/openai-account-pool/tests/integration-login-ownership.mjs +299 -0
- package/packages/openai-account-pool/tests/login-slot.mjs +131 -0
- package/packages/openai-account-pool/tests/multiprocess.mjs +73 -0
- package/packages/openai-account-pool/tests/selection.mjs +286 -0
- package/packages/openai-account-pool/tests/store-ownership.mjs +369 -0
- package/packages/openai-account-pool/tests/usage.mjs +428 -0
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
import {
|
|
2
|
+
accountLabel,
|
|
3
|
+
commitAccount,
|
|
4
|
+
disablePool,
|
|
5
|
+
loadPoolSnapshot,
|
|
6
|
+
maskAccountId,
|
|
7
|
+
removeAccount,
|
|
8
|
+
type PoolAccount,
|
|
9
|
+
type PoolSnapshot,
|
|
10
|
+
} from "./auth.ts";
|
|
11
|
+
import { availabilityFor, setPreferredAccountId } from "./availability.ts";
|
|
12
|
+
import { withEnrollmentLease } from "./lease.ts";
|
|
13
|
+
import { emptyPoolState, readPoolState, withPoolStateTransaction, type AuthEntry, type PoolStateFile } from "./store.ts";
|
|
14
|
+
import { describeSoonestReady, describeUsage, refreshPoolUsage, storedUsage, usageAvailable } from "./usage.ts";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* `/codex_pool`: ask, log in once, save, ask again. Everything here is UI-agnostic so the TUI,
|
|
18
|
+
* RPC and tests drive the same loop. Each successful login is saved immediately, so ending the
|
|
19
|
+
* loop, pressing escape or closing pi never loses an account that was already added.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export type EnrollmentChoice = "continue" | "remove" | "end";
|
|
23
|
+
|
|
24
|
+
export interface EnrollmentView {
|
|
25
|
+
/** Accounts already in the pool before this iteration. */
|
|
26
|
+
savedAccounts: number;
|
|
27
|
+
/** Those accounts by label, so the user can see which ones are already in. */
|
|
28
|
+
savedLabels: string[];
|
|
29
|
+
/** An existing plain login that the first add will keep as a pool account. */
|
|
30
|
+
nativeLoginPending: boolean;
|
|
31
|
+
addedThisRun: number;
|
|
32
|
+
/** Non-secret sentence about what the previous iteration did. */
|
|
33
|
+
lastOutcome?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The prompt the user reads before every add. Shared by the TUI, RPC and media. */
|
|
37
|
+
export function confirmText(view: EnrollmentView): string {
|
|
38
|
+
const lines: string[] = [];
|
|
39
|
+
if (view.lastOutcome) lines.push(view.lastOutcome);
|
|
40
|
+
if (view.savedLabels.length === 0) lines.push("账号池里还没有账号。");
|
|
41
|
+
else lines.push(`已保存 ${view.savedAccounts} 个账号:${view.savedLabels.join("、")}。`);
|
|
42
|
+
if (view.nativeLoginPending) lines.push("原有 openai-codex 登录会在第一次添加时保留并入池。");
|
|
43
|
+
if (view.savedAccounts === 1) lines.push("再添加一个账号才有备用额度。");
|
|
44
|
+
if (view.savedAccounts > 0) lines.push("重复登录已有的账号只会更新它的凭据,不会多出一个。");
|
|
45
|
+
return lines.join("\n");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface EnrollmentUi {
|
|
49
|
+
/** Shows the state and asks what to do next. */
|
|
50
|
+
confirm(view: EnrollmentView): Promise<EnrollmentChoice>;
|
|
51
|
+
/** Runs exactly one native OAuth login. Rejects when the user cancels. */
|
|
52
|
+
login(): Promise<AuthEntry>;
|
|
53
|
+
/** Picks the account to delete, or undefined when the user backs out. */
|
|
54
|
+
pickRemoval(accounts: readonly PoolAccount[]): Promise<PoolAccount | undefined>;
|
|
55
|
+
notify(message: string, kind?: "info" | "warning" | "error"): void;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface EnrollmentResult {
|
|
59
|
+
added: number;
|
|
60
|
+
updated: number;
|
|
61
|
+
removed: number;
|
|
62
|
+
/** Accounts in the pool when the loop ended. */
|
|
63
|
+
accounts: number;
|
|
64
|
+
cancelled: boolean;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Login failures can quote a raw token response, so only a short, scrubbed reason is shown. */
|
|
68
|
+
export function redactMessage(error: unknown): string {
|
|
69
|
+
const raw = error instanceof Error ? error.message : String(error);
|
|
70
|
+
const scrubbed = raw
|
|
71
|
+
.replace(/https?:\/\/\S+/g, "<url>")
|
|
72
|
+
.replace(/[A-Za-z0-9._~+/=-]{24,}/g, "<redacted>")
|
|
73
|
+
.replace(/\s+/g, " ")
|
|
74
|
+
.trim();
|
|
75
|
+
return scrubbed.length > 160 ? `${scrubbed.slice(0, 160)}…` : scrubbed || "未知错误";
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function isCancellation(error: unknown): boolean {
|
|
79
|
+
if (error instanceof Error && error.name === "AbortError") return true;
|
|
80
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
81
|
+
return /Login cancelled|cancelled|canceled|aborted/i.test(message);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function describeCommit(result: Awaited<ReturnType<typeof commitAccount>>): string {
|
|
85
|
+
const label = accountLabel(result.account);
|
|
86
|
+
const parts = [result.outcome === "added" ? `已添加账号 ${label}` : `${label} 是已在池里的账号,凭据已更新,没有新增`];
|
|
87
|
+
if (result.migratedExistingLogin) parts.push("原有登录已保留并入池");
|
|
88
|
+
if (result.readoptedAccounts > 0) parts.push(`恢复了 ${result.readoptedAccounts} 个此前保存的账号`);
|
|
89
|
+
parts.push(`当前共 ${result.accountCount} 个账号`);
|
|
90
|
+
return `${parts.join(",")}。`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function runEnrollment(input: {
|
|
94
|
+
authPath: string;
|
|
95
|
+
statePath: string;
|
|
96
|
+
ui: EnrollmentUi;
|
|
97
|
+
signal?: AbortSignal;
|
|
98
|
+
}): Promise<EnrollmentResult> {
|
|
99
|
+
// One wizard at a time per agent directory: two callback servers must never race.
|
|
100
|
+
return withEnrollmentLease({ statePath: input.statePath, purpose: "codex_pool", signal: input.signal }, async () => {
|
|
101
|
+
const result: EnrollmentResult = { added: 0, updated: 0, removed: 0, accounts: 0, cancelled: false };
|
|
102
|
+
let lastOutcome: string | undefined;
|
|
103
|
+
|
|
104
|
+
while (true) {
|
|
105
|
+
const snapshot = await loadPoolSnapshot(input.authPath);
|
|
106
|
+
const sorted = [...snapshot.accounts].sort((left, right) => left.ordinal - right.ordinal);
|
|
107
|
+
result.accounts = sorted.length;
|
|
108
|
+
const choice = await input.ui.confirm({
|
|
109
|
+
savedAccounts: sorted.length,
|
|
110
|
+
savedLabels: sorted.map((account) => accountLabel(account)),
|
|
111
|
+
nativeLoginPending: !snapshot.enabled && snapshot.nativeLoginPresent,
|
|
112
|
+
addedThisRun: result.added + result.updated,
|
|
113
|
+
lastOutcome,
|
|
114
|
+
});
|
|
115
|
+
if (choice === "end") break;
|
|
116
|
+
|
|
117
|
+
if (choice === "remove") {
|
|
118
|
+
lastOutcome = await removeThroughWizard({ ...input, accounts: sorted, result });
|
|
119
|
+
if (lastOutcome) input.ui.notify(lastOutcome);
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
let credential: AuthEntry;
|
|
124
|
+
try {
|
|
125
|
+
credential = await input.ui.login();
|
|
126
|
+
} catch (error) {
|
|
127
|
+
if (isCancellation(error)) {
|
|
128
|
+
result.cancelled = true;
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
// A failed login never retries by itself; the next loop asks again.
|
|
132
|
+
lastOutcome = `上一次登录失败(${redactMessage(error)}),已保存的账号不受影响。`;
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
try {
|
|
137
|
+
const committed = await commitAccount({ authPath: input.authPath, credential, signal: input.signal });
|
|
138
|
+
if (committed.outcome === "added") result.added += 1;
|
|
139
|
+
else result.updated += 1;
|
|
140
|
+
result.accounts = committed.accountCount;
|
|
141
|
+
lastOutcome = describeCommit(committed);
|
|
142
|
+
input.ui.notify(lastOutcome);
|
|
143
|
+
} catch (error) {
|
|
144
|
+
lastOutcome = `上一次保存失败(${redactMessage(error)}),已保存的账号不受影响。`;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const finalSnapshot = await loadPoolSnapshot(input.authPath);
|
|
149
|
+
result.accounts = finalSnapshot.accounts.length;
|
|
150
|
+
return result;
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Deleting from inside the wizard, so adding and dropping an account need one command. */
|
|
155
|
+
async function removeThroughWizard(input: {
|
|
156
|
+
authPath: string;
|
|
157
|
+
statePath: string;
|
|
158
|
+
ui: EnrollmentUi;
|
|
159
|
+
accounts: readonly PoolAccount[];
|
|
160
|
+
result: EnrollmentResult;
|
|
161
|
+
}): Promise<string | undefined> {
|
|
162
|
+
if (input.accounts.length === 0) return "账号池里没有可删除的账号。";
|
|
163
|
+
let chosen: PoolAccount | undefined;
|
|
164
|
+
try {
|
|
165
|
+
chosen = await input.ui.pickRemoval(input.accounts);
|
|
166
|
+
} catch (error) {
|
|
167
|
+
if (isCancellation(error)) return undefined;
|
|
168
|
+
throw error;
|
|
169
|
+
}
|
|
170
|
+
if (!chosen) return undefined;
|
|
171
|
+
try {
|
|
172
|
+
const removal = await removePoolAccount({ authPath: input.authPath, statePath: input.statePath, accountId: chosen.id });
|
|
173
|
+
input.result.removed += 1;
|
|
174
|
+
input.result.accounts = removal.remaining;
|
|
175
|
+
return removal.poolDisabled
|
|
176
|
+
? `已删除最后一个账号 ${accountLabel(chosen)},账号池已关闭。`
|
|
177
|
+
: `已删除账号 ${accountLabel(chosen)},还剩 ${removal.remaining} 个。`;
|
|
178
|
+
} catch (error) {
|
|
179
|
+
return `删除失败(${redactMessage(error)}),账号池没有变化。`;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export type AccountState = "ready" | "cooling" | "reauth" | "probing";
|
|
184
|
+
|
|
185
|
+
export interface AccountStatusLine {
|
|
186
|
+
label: string;
|
|
187
|
+
state: AccountState;
|
|
188
|
+
detail: string;
|
|
189
|
+
isCurrent: boolean;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export interface PoolStatus {
|
|
193
|
+
enabled: boolean;
|
|
194
|
+
nativeLoginPresent: boolean;
|
|
195
|
+
accounts: AccountStatusLine[];
|
|
196
|
+
/** Short non-secret summary line for the user. */
|
|
197
|
+
summary: string;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Pins the account every process starts from, or returns to automatic order with `undefined`.
|
|
202
|
+
* Failover still applies: a pinned account that runs out of quota hands the request on.
|
|
203
|
+
*/
|
|
204
|
+
export async function setDefaultAccount(input: { authPath: string; statePath: string; accountId: string | undefined }): Promise<PoolAccount | undefined> {
|
|
205
|
+
if (input.accountId === undefined) {
|
|
206
|
+
await setPreferredAccountId({ statePath: input.statePath, accountId: undefined });
|
|
207
|
+
return undefined;
|
|
208
|
+
}
|
|
209
|
+
const snapshot = await loadPoolSnapshot(input.authPath);
|
|
210
|
+
const account = snapshot.accounts.find((candidate) => candidate.id === input.accountId);
|
|
211
|
+
if (!account) throw new AccountQueryError("这个账号不在账号池里,无法设为默认。");
|
|
212
|
+
await setPreferredAccountId({ statePath: input.statePath, accountId: account.id });
|
|
213
|
+
return account;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export async function poolStatus(input: {
|
|
217
|
+
authPath: string;
|
|
218
|
+
statePath: string;
|
|
219
|
+
now?: number;
|
|
220
|
+
modelId?: string;
|
|
221
|
+
refresh?: (credential: AuthEntry, signal?: AbortSignal) => Promise<AuthEntry>;
|
|
222
|
+
fetch?: typeof globalThis.fetch;
|
|
223
|
+
signal?: AbortSignal;
|
|
224
|
+
}): Promise<PoolStatus> {
|
|
225
|
+
const now = input.now ?? Date.now();
|
|
226
|
+
const snapshot = await loadPoolSnapshot(input.authPath);
|
|
227
|
+
if (!snapshot.enabled) {
|
|
228
|
+
return {
|
|
229
|
+
enabled: false,
|
|
230
|
+
nativeLoginPresent: snapshot.nativeLoginPresent,
|
|
231
|
+
accounts: [],
|
|
232
|
+
summary: snapshot.nativeLoginPresent
|
|
233
|
+
? "账号池未启用,当前使用原生单账号登录。运行 /codex_pool 添加账号即可启用。"
|
|
234
|
+
: "账号池未启用,且没有 openai-codex 登录。运行 /codex_pool 添加账号。",
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const refreshed = input.refresh
|
|
239
|
+
? await refreshPoolUsage({
|
|
240
|
+
authPath: input.authPath,
|
|
241
|
+
statePath: input.statePath,
|
|
242
|
+
accounts: snapshot.accounts,
|
|
243
|
+
refresh: input.refresh,
|
|
244
|
+
now: () => now,
|
|
245
|
+
fetch: input.fetch,
|
|
246
|
+
signal: input.signal,
|
|
247
|
+
})
|
|
248
|
+
: [];
|
|
249
|
+
const refreshById = new Map(refreshed.map((result) => [result.account.id, result]));
|
|
250
|
+
const state = await readPoolState(input.statePath);
|
|
251
|
+
const availability = availabilityFor({
|
|
252
|
+
accounts: snapshot.accounts,
|
|
253
|
+
state,
|
|
254
|
+
now,
|
|
255
|
+
modelId: input.modelId,
|
|
256
|
+
});
|
|
257
|
+
const accounts = availability.map((entry, index): AccountStatusLine => {
|
|
258
|
+
const account = snapshot.accounts[index];
|
|
259
|
+
const label = accountLabel(account);
|
|
260
|
+
const isCurrent = account.id === state.currentAccountId;
|
|
261
|
+
const live = refreshById.get(account.id);
|
|
262
|
+
const usage = live?.usage ?? storedUsage(state.accounts[account.id]);
|
|
263
|
+
const usageText = live?.error ? "用量未知" : describeUsage(usage, now) ?? "用量未知";
|
|
264
|
+
if (entry.authInvalid) return { label, state: "reauth", detail: "需要重新授权,运行 /codex_pool 重新登录这个账号", isCurrent };
|
|
265
|
+
if (entry.unavailable?.reason === "probe_in_progress") return { label, state: "probing", detail: "另一个进程正在重试", isCurrent };
|
|
266
|
+
if (entry.unavailable || (usage && !usageAvailable(usage))) {
|
|
267
|
+
const reset = entry.unavailable?.serverResetAtMs;
|
|
268
|
+
return {
|
|
269
|
+
label,
|
|
270
|
+
state: "cooling",
|
|
271
|
+
detail: `${usageText};${reset ? `额度冷却中,约 ${Math.max(1, Math.round((reset - now) / 60_000))} 分钟后恢复` : "额度暂不可用"}`,
|
|
272
|
+
isCurrent,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
return { label, state: "ready", detail: `${usageText};${entry.needsProbe ? "冷却已过,下次请求会先试这个账号" : "可用"}`, isCurrent };
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
const ready = accounts.filter((entry) => entry.state === "ready").length;
|
|
279
|
+
let summary =
|
|
280
|
+
accounts.length === 1
|
|
281
|
+
? "账号池已启用,但只有 1 个账号,暂无备用账号。运行 /codex_pool 继续添加。"
|
|
282
|
+
: `账号池已启用:${accounts.length} 个账号,其中 ${ready} 个现在可用。`;
|
|
283
|
+
if (ready === 0) {
|
|
284
|
+
const recovery = describeSoonestReady({ accounts: snapshot.accounts, state, now, modelId: input.modelId });
|
|
285
|
+
if (recovery) summary += ` ${recovery}。`;
|
|
286
|
+
}
|
|
287
|
+
return { enabled: true, nativeLoginPresent: false, accounts, summary };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export class AccountQueryError extends Error {}
|
|
291
|
+
|
|
292
|
+
/** Accepts the ordinal, the sign-in address, a full account id or the masked suffix from status. */
|
|
293
|
+
export function resolveAccount(snapshot: PoolSnapshot, query: string): PoolAccount {
|
|
294
|
+
const trimmed = query.trim().replace(/^[#…\s]+/, "");
|
|
295
|
+
if (!trimmed) throw new AccountQueryError("请提供要删除的账号编号、邮箱或标识,例如 /codex_pool remove 2。");
|
|
296
|
+
const byOrdinal = /^\d+$/.test(trimmed) ? snapshot.accounts.find((account) => account.ordinal === Number(trimmed)) : undefined;
|
|
297
|
+
if (byOrdinal) return byOrdinal;
|
|
298
|
+
const needle = trimmed.toLowerCase();
|
|
299
|
+
const byEmail = snapshot.accounts.filter((account) => account.email?.toLowerCase().includes(needle));
|
|
300
|
+
if (byEmail.length === 1) return byEmail[0];
|
|
301
|
+
if (byEmail.length > 1) throw new AccountQueryError(`“${trimmed}”匹配到 ${byEmail.length} 个账号,请用编号,例如 /codex_pool remove 2。`);
|
|
302
|
+
const matches = snapshot.accounts.filter((account) => account.id === trimmed || account.id.endsWith(trimmed));
|
|
303
|
+
if (matches.length === 1) return matches[0];
|
|
304
|
+
if (matches.length > 1) throw new AccountQueryError(`“${trimmed}”匹配到 ${matches.length} 个账号,请用编号,例如 /codex_pool remove 2。`);
|
|
305
|
+
throw new AccountQueryError(`没有找到账号“${trimmed}”。用 /codex_pool status 查看编号。`);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Forgets everything the pool recorded about accounts it no longer owns. */
|
|
309
|
+
async function forgetState(statePath: string, accountIds: readonly string[] | "all"): Promise<void> {
|
|
310
|
+
await withPoolStateTransaction(statePath, (state) => {
|
|
311
|
+
if (accountIds === "all") return { value: { ...emptyPoolState(), enrollment: state.enrollment }, result: undefined };
|
|
312
|
+
const accounts = { ...state.accounts };
|
|
313
|
+
for (const accountId of accountIds) delete accounts[accountId];
|
|
314
|
+
const next: PoolStateFile = { ...state, accounts };
|
|
315
|
+
// References to an account that no longer exists would silently lie; drop them instead.
|
|
316
|
+
if (next.preferredAccountId && accountIds.includes(next.preferredAccountId)) delete next.preferredAccountId;
|
|
317
|
+
if (next.currentAccountId && accountIds.includes(next.currentAccountId)) delete next.currentAccountId;
|
|
318
|
+
return { value: next, result: undefined };
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export async function endPool(input: { authPath: string; statePath: string; keepAccountId?: string }): Promise<{ kept: PoolAccount; dormantAccounts: number }> {
|
|
323
|
+
const snapshot = await loadPoolSnapshot(input.authPath);
|
|
324
|
+
if (!snapshot.enabled) throw new AccountQueryError("账号池未启用,无需关闭。");
|
|
325
|
+
const keep =
|
|
326
|
+
snapshot.accounts.find((account) => account.id === input.keepAccountId) ??
|
|
327
|
+
[...snapshot.accounts].sort((left, right) => left.ordinal - right.ordinal)[0];
|
|
328
|
+
const result = await disablePool({ authPath: input.authPath, accountId: keep.id });
|
|
329
|
+
await forgetState(input.statePath, "all");
|
|
330
|
+
return { kept: result.restored, dormantAccounts: result.dormantAccounts };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export async function removePoolAccount(input: { authPath: string; statePath: string; accountId: string }): Promise<{ removed: PoolAccount; remaining: number; poolDisabled: boolean }> {
|
|
334
|
+
const result = await removeAccount({ authPath: input.authPath, accountId: input.accountId });
|
|
335
|
+
await forgetState(input.statePath, result.poolDisabled ? "all" : [input.accountId]);
|
|
336
|
+
return result;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export { maskAccountId };
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { readPoolState, withPoolStateTransaction, type EnrollmentLeaseRecord, type PoolStateFile } from "./store.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Membership changes hold a lease instead of the credential lock, because adding an account
|
|
6
|
+
* waits for a browser. The lease keeps two pi processes from running wizards or an off at the
|
|
7
|
+
* same time, and it expires so a crashed command recovers without manual cleanup.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const DEFAULT_ENROLLMENT_TTL_MS = 120_000;
|
|
11
|
+
const RENEW_SAFETY_MS = 5_000;
|
|
12
|
+
|
|
13
|
+
export class EnrollmentInProgressError extends Error {
|
|
14
|
+
constructor(record: EnrollmentLeaseRecord) {
|
|
15
|
+
super(`Another pi process is changing the openai-codex account pool (${record.purpose}, pid ${record.processId}). Finish or close it first.`);
|
|
16
|
+
this.name = "EnrollmentInProgressError";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class EnrollmentLeaseLostError extends Error {
|
|
21
|
+
constructor() {
|
|
22
|
+
super("This pool operation lost its lease, so it must not change accounts. Run the command again.");
|
|
23
|
+
this.name = "EnrollmentLeaseLostError";
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface EnrollmentLease {
|
|
28
|
+
record: EnrollmentLeaseRecord;
|
|
29
|
+
/** Extend while a long step such as a browser authorization is still running. */
|
|
30
|
+
renew(): Promise<void>;
|
|
31
|
+
release(): Promise<void>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function isProcessAlive(processId: number): boolean {
|
|
35
|
+
if (!Number.isInteger(processId) || processId <= 0) return false;
|
|
36
|
+
try {
|
|
37
|
+
process.kill(processId, 0);
|
|
38
|
+
return true;
|
|
39
|
+
} catch (error) {
|
|
40
|
+
// EPERM means the process exists but belongs to someone else.
|
|
41
|
+
return (error as NodeJS.ErrnoException).code === "EPERM";
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function activeEnrollment(state: PoolStateFile, now: number): EnrollmentLeaseRecord | undefined {
|
|
46
|
+
const record = state.enrollment;
|
|
47
|
+
if (!record) return undefined;
|
|
48
|
+
if (record.expiresAtMs <= now) return undefined;
|
|
49
|
+
if (record.processId !== process.pid && !isProcessAlive(record.processId)) return undefined;
|
|
50
|
+
return record;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function assertNoActiveEnrollment(input: { statePath: string; now?: number }): Promise<void> {
|
|
54
|
+
const record = activeEnrollment(await readPoolState(input.statePath), input.now ?? Date.now());
|
|
55
|
+
if (record) throw new EnrollmentInProgressError(record);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function acquireEnrollmentLease(input: {
|
|
59
|
+
statePath: string;
|
|
60
|
+
purpose: string;
|
|
61
|
+
ttlMs?: number;
|
|
62
|
+
now?: () => number;
|
|
63
|
+
signal?: AbortSignal;
|
|
64
|
+
}): Promise<EnrollmentLease> {
|
|
65
|
+
const now = input.now ?? (() => Date.now());
|
|
66
|
+
const ttlMs = input.ttlMs ?? DEFAULT_ENROLLMENT_TTL_MS;
|
|
67
|
+
const owner = randomUUID();
|
|
68
|
+
|
|
69
|
+
const record = await withPoolStateTransaction(input.statePath, (state) => {
|
|
70
|
+
const held = activeEnrollment(state, now());
|
|
71
|
+
if (held) throw new EnrollmentInProgressError(held);
|
|
72
|
+
const mine: EnrollmentLeaseRecord = {
|
|
73
|
+
owner,
|
|
74
|
+
processId: process.pid,
|
|
75
|
+
purpose: input.purpose,
|
|
76
|
+
startedAtMs: now(),
|
|
77
|
+
expiresAtMs: now() + ttlMs,
|
|
78
|
+
};
|
|
79
|
+
return { value: { ...state, enrollment: mine }, result: mine };
|
|
80
|
+
}, { signal: input.signal });
|
|
81
|
+
|
|
82
|
+
let current = record;
|
|
83
|
+
let released = false;
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
get record() {
|
|
87
|
+
return current;
|
|
88
|
+
},
|
|
89
|
+
async renew() {
|
|
90
|
+
if (released) throw new EnrollmentLeaseLostError();
|
|
91
|
+
current = await withPoolStateTransaction(input.statePath, (state) => {
|
|
92
|
+
if (state.enrollment?.owner !== owner) throw new EnrollmentLeaseLostError();
|
|
93
|
+
const renewed: EnrollmentLeaseRecord = { ...state.enrollment, expiresAtMs: now() + ttlMs };
|
|
94
|
+
return { value: { ...state, enrollment: renewed }, result: renewed };
|
|
95
|
+
});
|
|
96
|
+
},
|
|
97
|
+
async release() {
|
|
98
|
+
if (released) return;
|
|
99
|
+
released = true;
|
|
100
|
+
await withPoolStateTransaction(input.statePath, (state) => {
|
|
101
|
+
// Only the holder clears it; a reclaimed lease now belongs to another process.
|
|
102
|
+
if (state.enrollment?.owner !== owner) return { result: undefined };
|
|
103
|
+
const next = { ...state };
|
|
104
|
+
delete next.enrollment;
|
|
105
|
+
return { value: next, result: undefined };
|
|
106
|
+
});
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Run one membership change under a lease, renewing it while the body is still working. */
|
|
112
|
+
export async function withEnrollmentLease<T>(
|
|
113
|
+
input: { statePath: string; purpose: string; ttlMs?: number; signal?: AbortSignal },
|
|
114
|
+
run: (lease: EnrollmentLease) => Promise<T>,
|
|
115
|
+
): Promise<T> {
|
|
116
|
+
const lease = await acquireEnrollmentLease(input);
|
|
117
|
+
const ttlMs = input.ttlMs ?? DEFAULT_ENROLLMENT_TTL_MS;
|
|
118
|
+
const timer = setInterval(() => {
|
|
119
|
+
void lease.renew().catch(() => { /* The next write fails visibly through assertHeld/renew. */ });
|
|
120
|
+
}, Math.max(ttlMs - RENEW_SAFETY_MS, RENEW_SAFETY_MS));
|
|
121
|
+
timer.unref?.();
|
|
122
|
+
try {
|
|
123
|
+
return await run(lease);
|
|
124
|
+
} finally {
|
|
125
|
+
clearInterval(timer);
|
|
126
|
+
await lease.release();
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { rmdirSync } from "node:fs";
|
|
2
|
+
import { mkdir, rmdir, stat, utimes } from "node:fs/promises";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Directory lock using the on-disk protocol of `proper-lockfile`, the library pi's own auth
|
|
7
|
+
* backend locks `auth.json` with: `<file>.lock` is a directory, its mtime is the holder's
|
|
8
|
+
* heartbeat, and a heartbeat older than the stale window may be reclaimed. Sharing the
|
|
9
|
+
* protocol is what makes pool writes mutually exclusive with pi's own credential writes.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Reclaim window for a holder that stopped heart-beating. Matches pi's auth-lock wait window. */
|
|
13
|
+
const DEFAULT_STALE_MS = 30_000;
|
|
14
|
+
const DEFAULT_WAIT_MS = 30_000;
|
|
15
|
+
/** Well inside proper-lockfile's 10s default stale window, so pi never reclaims a lock we hold. */
|
|
16
|
+
const HEARTBEAT_MS = 2_000;
|
|
17
|
+
const MAX_BACKOFF_MS = 500;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Stamps the heartbeat with a unique sub-millisecond time, so a directory another holder
|
|
21
|
+
* recreated is recognised even when it reuses our inode and lands in the same millisecond.
|
|
22
|
+
* Filesystems that only keep whole milliseconds degrade to proper-lockfile's own mtime check.
|
|
23
|
+
*/
|
|
24
|
+
async function stampHeartbeat(directory: string): Promise<bigint> {
|
|
25
|
+
const seconds = (Date.now() + Math.random()) / 1000;
|
|
26
|
+
await utimes(directory, seconds, seconds);
|
|
27
|
+
return (await stat(directory, { bigint: true })).mtimeNs;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class LockTimeoutError extends Error {
|
|
31
|
+
constructor(path: string) {
|
|
32
|
+
super(`Timed out waiting for ${path}. Another pi process is writing credentials.`);
|
|
33
|
+
this.name = "LockTimeoutError";
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export class LockCompromisedError extends Error {
|
|
38
|
+
constructor(path: string, options?: { cause?: unknown }) {
|
|
39
|
+
super(`Lost the lock on ${path} before the write completed.`, options);
|
|
40
|
+
this.name = "LockCompromisedError";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface HeldLock {
|
|
45
|
+
/** Throws when the lock was reclaimed or released, so a caller never writes unprotected. */
|
|
46
|
+
assertHeld(): void;
|
|
47
|
+
/** Re-reads the directory, so ownership is checked at write time rather than at the last heartbeat. */
|
|
48
|
+
verifyHeld(): Promise<void>;
|
|
49
|
+
release(): Promise<void>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface FileLockOptions {
|
|
53
|
+
staleMs?: number;
|
|
54
|
+
waitMs?: number;
|
|
55
|
+
signal?: AbortSignal;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const heldDirectories = new Set<string>();
|
|
59
|
+
let exitHookInstalled = false;
|
|
60
|
+
|
|
61
|
+
function installExitHook(): void {
|
|
62
|
+
if (exitHookInstalled) return;
|
|
63
|
+
exitHookInstalled = true;
|
|
64
|
+
process.on("exit", () => {
|
|
65
|
+
for (const directory of heldDirectories) {
|
|
66
|
+
try { rmdirSync(directory); } catch { /* Nothing to clean up when the directory is already gone. */ }
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function lockDirectoryFor(targetPath: string): string {
|
|
72
|
+
return `${resolve(targetPath)}.lock`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const delay = (milliseconds: number, signal?: AbortSignal) => new Promise<void>((resolveDelay, reject) => {
|
|
76
|
+
const onAbort = () => { clearTimeout(timer); reject(signal?.reason ?? new Error("Lock wait aborted.")); };
|
|
77
|
+
const timer = setTimeout(() => { signal?.removeEventListener("abort", onAbort); resolveDelay(); }, milliseconds);
|
|
78
|
+
if (signal?.aborted) onAbort();
|
|
79
|
+
else signal?.addEventListener("abort", onAbort, { once: true });
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
/** True when the caller should retry `mkdir`, either because the directory is gone or it was stale. */
|
|
83
|
+
async function reclaimIfStale(directory: string, staleMs: number): Promise<boolean> {
|
|
84
|
+
let identity;
|
|
85
|
+
try { identity = await stat(directory); }
|
|
86
|
+
catch (error) {
|
|
87
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return true;
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
90
|
+
if (identity.mtimeMs >= Date.now() - staleMs) return false;
|
|
91
|
+
try { await rmdir(directory); }
|
|
92
|
+
catch (error) {
|
|
93
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
94
|
+
}
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function holdLock(directory: string, stamp: bigint): HeldLock {
|
|
99
|
+
installExitHook();
|
|
100
|
+
heldDirectories.add(directory);
|
|
101
|
+
let ownStamp = stamp;
|
|
102
|
+
let compromised: Error | undefined;
|
|
103
|
+
let released = false;
|
|
104
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
105
|
+
|
|
106
|
+
const scheduleHeartbeat = () => {
|
|
107
|
+
timer = setTimeout(() => { void heartbeat(); }, HEARTBEAT_MS);
|
|
108
|
+
timer.unref?.();
|
|
109
|
+
};
|
|
110
|
+
/** Any other holder's mkdir or heartbeat replaces our stamp. */
|
|
111
|
+
const assertOwnStamp = async (): Promise<void> => {
|
|
112
|
+
if ((await stat(directory, { bigint: true })).mtimeNs !== ownStamp) throw new LockCompromisedError(directory);
|
|
113
|
+
};
|
|
114
|
+
const heartbeat = async () => {
|
|
115
|
+
if (released) return;
|
|
116
|
+
try {
|
|
117
|
+
await assertOwnStamp();
|
|
118
|
+
ownStamp = await stampHeartbeat(directory);
|
|
119
|
+
} catch (error) {
|
|
120
|
+
compromised = error instanceof LockCompromisedError ? error : new LockCompromisedError(directory, { cause: error });
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (!released) scheduleHeartbeat();
|
|
124
|
+
};
|
|
125
|
+
scheduleHeartbeat();
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
assertHeld() {
|
|
129
|
+
if (compromised) throw compromised;
|
|
130
|
+
if (released) throw new LockCompromisedError(directory);
|
|
131
|
+
},
|
|
132
|
+
async verifyHeld() {
|
|
133
|
+
this.assertHeld();
|
|
134
|
+
try { await assertOwnStamp(); }
|
|
135
|
+
catch (error) {
|
|
136
|
+
compromised = error instanceof LockCompromisedError ? error : new LockCompromisedError(directory, { cause: error });
|
|
137
|
+
throw compromised;
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
async release() {
|
|
141
|
+
if (released) return;
|
|
142
|
+
released = true;
|
|
143
|
+
if (timer) clearTimeout(timer);
|
|
144
|
+
heldDirectories.delete(directory);
|
|
145
|
+
// A reclaimed directory belongs to another holder; removing it would break their lock.
|
|
146
|
+
if (compromised) return;
|
|
147
|
+
try { await rmdir(directory); }
|
|
148
|
+
catch (error) {
|
|
149
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export async function acquireFileLock(targetPath: string, options: FileLockOptions = {}): Promise<HeldLock> {
|
|
156
|
+
const directory = lockDirectoryFor(targetPath);
|
|
157
|
+
const staleMs = options.staleMs ?? DEFAULT_STALE_MS;
|
|
158
|
+
const deadline = Date.now() + (options.waitMs ?? DEFAULT_WAIT_MS);
|
|
159
|
+
for (let attempt = 0; ; attempt++) {
|
|
160
|
+
options.signal?.throwIfAborted();
|
|
161
|
+
try {
|
|
162
|
+
await mkdir(directory, { mode: 0o700 });
|
|
163
|
+
return holdLock(directory, await stampHeartbeat(directory));
|
|
164
|
+
} catch (error) {
|
|
165
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
166
|
+
}
|
|
167
|
+
if (await reclaimIfStale(directory, staleMs)) continue;
|
|
168
|
+
if (Date.now() >= deadline) throw new LockTimeoutError(directory);
|
|
169
|
+
await delay(Math.round(Math.min(10 * 2 ** attempt, MAX_BACKOFF_MS) * (1 + Math.random())), options.signal);
|
|
170
|
+
}
|
|
171
|
+
}
|