@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,29 @@
|
|
|
1
|
+
import { writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
import { ensureFreshAccountCredential, loadPoolSnapshot } from "../../src/auth.ts";
|
|
5
|
+
import { withAuthTransaction } from "../../src/store.ts";
|
|
6
|
+
import { fakeAccessToken } from "./fixtures.mjs";
|
|
7
|
+
|
|
8
|
+
/** One pi process: refresh the pooled credential, and write an unrelated provider entry. */
|
|
9
|
+
const [authPath, accountId, markerDir, label] = process.argv.slice(2);
|
|
10
|
+
const snapshot = await loadPoolSnapshot(authPath);
|
|
11
|
+
const account = snapshot.accounts.find((entry) => entry.id === accountId);
|
|
12
|
+
|
|
13
|
+
const fresh = await ensureFreshAccountCredential({
|
|
14
|
+
authPath,
|
|
15
|
+
account,
|
|
16
|
+
refresh: async (credential) => {
|
|
17
|
+
// Record that this process really performed the token exchange.
|
|
18
|
+
writeFileSync(join(markerDir, `refresh-${label}`), credential.refresh, "utf8");
|
|
19
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
20
|
+
return { type: "oauth", access: fakeAccessToken(accountId, `rotated-${label}`), refresh: `rotated-refresh-${label}`, expires: Date.now() + 3_600_000, accountId };
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
await withAuthTransaction(authPath, (entries) => ({
|
|
25
|
+
value: { ...entries, [`unrelated-${label}`]: { type: "api_key", key: `\$KEY_${label}` } },
|
|
26
|
+
result: undefined,
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
process.stdout.write(JSON.stringify({ label, access: fresh.credential.access, refreshed: fresh.refreshed }));
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
accountCredential,
|
|
6
|
+
accountIdOf,
|
|
7
|
+
commitAccount,
|
|
8
|
+
disablePool,
|
|
9
|
+
ensureFreshAccountCredential,
|
|
10
|
+
isPoolReferenceEntry,
|
|
11
|
+
loadPoolSnapshot,
|
|
12
|
+
POOL_REFERENCE_TOKEN,
|
|
13
|
+
POOL_UNRESOLVED_API_KEY,
|
|
14
|
+
removeAccount,
|
|
15
|
+
} from "../src/auth.ts";
|
|
16
|
+
import { poolEntryKey } from "../src/store.ts";
|
|
17
|
+
import { fakeCredential, makeAgentDir, seedAuthFile } from "./helpers/fixtures.mjs";
|
|
18
|
+
import { NativeCredentialStore } from "./helpers/native-credential-store.mjs";
|
|
19
|
+
import { piAiEntry } from "./helpers/pi-runtime.mjs";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Blocking integration check for the account pool.
|
|
23
|
+
*
|
|
24
|
+
* It runs against the real pi-ai `Models` orchestration to answer two questions before any
|
|
25
|
+
* further implementation: does a second plain login to the same provider really overwrite the
|
|
26
|
+
* first one, and can the pool keep several accounts while pi resolves request auth from a
|
|
27
|
+
* non-secret reference and the provider substitutes the selected account?
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const { createModels, createProvider, createAssistantMessageEventStream, lazyStream } = await import(piAiEntry());
|
|
31
|
+
|
|
32
|
+
const UNRELATED_ENTRY = { type: "api_key", key: "unrelated-provider-key" };
|
|
33
|
+
|
|
34
|
+
const model = {
|
|
35
|
+
id: "gpt-5-codex",
|
|
36
|
+
name: "GPT-5 Codex",
|
|
37
|
+
api: "openai-codex-responses",
|
|
38
|
+
provider: "openai-codex",
|
|
39
|
+
baseUrl: "https://chatgpt.com/backend-api",
|
|
40
|
+
input: ["text"],
|
|
41
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
42
|
+
contextWindow: 400_000,
|
|
43
|
+
maxTokens: 128_000,
|
|
44
|
+
reasoning: true,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const request = { systemPrompt: "test", messages: [{ role: "user", content: "hi", timestamp: Date.now() }] };
|
|
48
|
+
|
|
49
|
+
function recordingApi(seen) {
|
|
50
|
+
const run = (requestModel, _context, options) => {
|
|
51
|
+
seen.push({ apiKey: options?.apiKey });
|
|
52
|
+
const stream = createAssistantMessageEventStream();
|
|
53
|
+
const message = {
|
|
54
|
+
role: "assistant",
|
|
55
|
+
content: [{ type: "text", text: "ok" }],
|
|
56
|
+
api: requestModel.api,
|
|
57
|
+
provider: requestModel.provider,
|
|
58
|
+
model: requestModel.id,
|
|
59
|
+
usage: { input: 1, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: 2, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
|
|
60
|
+
stopReason: "stop",
|
|
61
|
+
timestamp: Date.now(),
|
|
62
|
+
};
|
|
63
|
+
stream.push({ type: "start", partial: message });
|
|
64
|
+
stream.push({ type: "done", reason: "stop", message });
|
|
65
|
+
stream.end(message);
|
|
66
|
+
return stream;
|
|
67
|
+
};
|
|
68
|
+
return { stream: run, streamSimple: run };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Codex-shaped provider: subscription OAuth, one chat model, a recording API implementation. */
|
|
72
|
+
function codexShapedProvider({ login, seen, poolAware = false, refreshed = [] }) {
|
|
73
|
+
return createProvider({
|
|
74
|
+
id: "openai-codex",
|
|
75
|
+
name: "OpenAI Codex (integration check)",
|
|
76
|
+
baseUrl: model.baseUrl,
|
|
77
|
+
auth: {
|
|
78
|
+
oauth: {
|
|
79
|
+
name: "OpenAI (ChatGPT Plus/Pro)",
|
|
80
|
+
isSubscription: true,
|
|
81
|
+
login,
|
|
82
|
+
async refresh(credential) {
|
|
83
|
+
refreshed.push(credential.refresh);
|
|
84
|
+
return credential;
|
|
85
|
+
},
|
|
86
|
+
async toAuth(credential) {
|
|
87
|
+
// Pre-request resolution of a pool reference must not touch any account.
|
|
88
|
+
if (poolAware && isPoolReferenceEntry(credential)) return { apiKey: POOL_UNRESOLVED_API_KEY };
|
|
89
|
+
return { apiKey: credential.access };
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
models: [model],
|
|
94
|
+
api: recordingApi(seen),
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Minimal stand-in for the streaming wrapper: it only proves that the provider can replace the
|
|
100
|
+
* unresolved placeholder with a selected account before the API implementation runs. Account
|
|
101
|
+
* selection and failover are implemented and tested separately.
|
|
102
|
+
*/
|
|
103
|
+
function withAccountSubstitution(native, authPath) {
|
|
104
|
+
const substitute = async (options) => {
|
|
105
|
+
if (options?.apiKey !== POOL_UNRESOLVED_API_KEY) return options;
|
|
106
|
+
const snapshot = await loadPoolSnapshot(authPath);
|
|
107
|
+
const [account] = snapshot.accounts;
|
|
108
|
+
const { credential } = await ensureFreshAccountCredential({
|
|
109
|
+
authPath,
|
|
110
|
+
account,
|
|
111
|
+
refresh: async () => { throw new Error("The integration check uses credentials that are still valid."); },
|
|
112
|
+
});
|
|
113
|
+
return { ...options, apiKey: credential.access };
|
|
114
|
+
};
|
|
115
|
+
return {
|
|
116
|
+
...native,
|
|
117
|
+
stream: (requestModel, context, options) => lazyStream(requestModel, async () => native.stream(requestModel, context, await substitute(options))),
|
|
118
|
+
streamSimple: (requestModel, context, options) => lazyStream(requestModel, async () => native.streamSimple(requestModel, context, await substitute(options))),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const interaction = { prompt: async () => { throw new Error("No prompt expected."); }, notify: () => {} };
|
|
123
|
+
const readEntries = (authPath) => JSON.parse(readFileSync(authPath, "utf8"));
|
|
124
|
+
/** Stored accounts also carry non-secret membership data, which is not part of the credential. */
|
|
125
|
+
const storedCredential = (entries, accountId) => {
|
|
126
|
+
const entry = entries[poolEntryKey(accountId)];
|
|
127
|
+
return entry === undefined ? undefined : accountCredential(entry);
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
// 1. A second plain login to the same provider overwrites the first account.
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
{
|
|
134
|
+
const agent = makeAgentDir();
|
|
135
|
+
try {
|
|
136
|
+
const credentialA = fakeCredential("account-aaaa");
|
|
137
|
+
const credentialB = fakeCredential("account-bbbb");
|
|
138
|
+
seedAuthFile(agent.authPath, { anthropic: UNRELATED_ENTRY, "openai-codex": credentialA });
|
|
139
|
+
|
|
140
|
+
const seen = [];
|
|
141
|
+
const models = createModels({ credentials: new NativeCredentialStore(agent.authPath) });
|
|
142
|
+
models.setProvider(codexShapedProvider({ login: async () => credentialB, seen }));
|
|
143
|
+
|
|
144
|
+
await models.login("openai-codex", "oauth", interaction);
|
|
145
|
+
|
|
146
|
+
const entries = readEntries(agent.authPath);
|
|
147
|
+
assert.deepEqual(entries["openai-codex"], credentialB, "the plain login must store the new credential");
|
|
148
|
+
assert.equal(
|
|
149
|
+
JSON.stringify(entries).includes(credentialA.refresh),
|
|
150
|
+
false,
|
|
151
|
+
"reproduced: pi keeps one credential per provider, so the first account is gone",
|
|
152
|
+
);
|
|
153
|
+
assert.deepEqual(entries.anthropic, UNRELATED_ENTRY, "other providers must survive a login");
|
|
154
|
+
} finally {
|
|
155
|
+
agent.dispose();
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
// 2. The dedicated commit path keeps every account and publishes a reference.
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
{
|
|
163
|
+
const agent = makeAgentDir();
|
|
164
|
+
try {
|
|
165
|
+
const credentialA = fakeCredential("account-aaaa");
|
|
166
|
+
const credentialB = fakeCredential("account-bbbb");
|
|
167
|
+
const credentialC = fakeCredential("account-cccc");
|
|
168
|
+
seedAuthFile(agent.authPath, { anthropic: UNRELATED_ENTRY, "openai-codex": credentialA });
|
|
169
|
+
|
|
170
|
+
const first = await commitAccount({ authPath: agent.authPath, credential: credentialB });
|
|
171
|
+
assert.equal(first.outcome, "added");
|
|
172
|
+
assert.equal(first.migratedExistingLogin, true, "the existing plain login is kept as an account");
|
|
173
|
+
assert.equal(first.accountCount, 2);
|
|
174
|
+
|
|
175
|
+
const second = await commitAccount({ authPath: agent.authPath, credential: credentialC });
|
|
176
|
+
assert.equal(second.accountCount, 3);
|
|
177
|
+
|
|
178
|
+
let entries = readEntries(agent.authPath);
|
|
179
|
+
assert.deepEqual(storedCredential(entries, "account-aaaa"), credentialA, "account A must stay unchanged");
|
|
180
|
+
assert.deepEqual(storedCredential(entries, "account-bbbb"), credentialB);
|
|
181
|
+
assert.deepEqual(storedCredential(entries, "account-cccc"), credentialC);
|
|
182
|
+
assert.deepEqual(entries.anthropic, UNRELATED_ENTRY);
|
|
183
|
+
|
|
184
|
+
// The main entry is a non-secret reference, and no account token leaks into it.
|
|
185
|
+
const main = entries["openai-codex"];
|
|
186
|
+
assert.equal(isPoolReferenceEntry(main), true);
|
|
187
|
+
assert.equal(main.access, POOL_REFERENCE_TOKEN);
|
|
188
|
+
assert.equal(main.refresh, POOL_REFERENCE_TOKEN);
|
|
189
|
+
assert.equal(accountIdOf(main), undefined, "the reference carries no account claim, so the API cannot use it");
|
|
190
|
+
for (const credential of [credentialA, credentialB, credentialC]) {
|
|
191
|
+
assert.equal(JSON.stringify(main).includes(credential.access), false);
|
|
192
|
+
assert.equal(JSON.stringify(main).includes(credential.refresh), false);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Re-authorizing the same account updates it instead of adding a quota source.
|
|
196
|
+
const rotatedB = fakeCredential("account-bbbb", { marker: "v2" });
|
|
197
|
+
const again = await commitAccount({ authPath: agent.authPath, credential: rotatedB });
|
|
198
|
+
assert.equal(again.outcome, "updated");
|
|
199
|
+
assert.equal(again.accountCount, 3);
|
|
200
|
+
assert.equal(again.account.ordinal, 2, "the account keeps its display number");
|
|
201
|
+
|
|
202
|
+
entries = readEntries(agent.authPath);
|
|
203
|
+
assert.deepEqual(storedCredential(entries, "account-bbbb"), rotatedB);
|
|
204
|
+
assert.deepEqual(storedCredential(entries, "account-aaaa"), credentialA);
|
|
205
|
+
assert.deepEqual(storedCredential(entries, "account-cccc"), credentialC);
|
|
206
|
+
} finally {
|
|
207
|
+
agent.dispose();
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
// 3. pi resolves request auth from the reference; the provider substitutes an account.
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
{
|
|
215
|
+
const agent = makeAgentDir();
|
|
216
|
+
try {
|
|
217
|
+
const credentialA = fakeCredential("account-aaaa");
|
|
218
|
+
seedAuthFile(agent.authPath, { "openai-codex": credentialA });
|
|
219
|
+
await commitAccount({ authPath: agent.authPath, credential: fakeCredential("account-bbbb") });
|
|
220
|
+
|
|
221
|
+
const seen = [];
|
|
222
|
+
const refreshed = [];
|
|
223
|
+
const models = createModels({ credentials: new NativeCredentialStore(agent.authPath) });
|
|
224
|
+
const native = codexShapedProvider({ login: async () => { throw new Error("No login expected."); }, seen, poolAware: true, refreshed });
|
|
225
|
+
models.setProvider(withAccountSubstitution(native, agent.authPath));
|
|
226
|
+
|
|
227
|
+
const resolved = await models.getAuth(model);
|
|
228
|
+
assert.equal(resolved?.auth.apiKey, POOL_UNRESOLVED_API_KEY, "pre-request resolution returns the placeholder");
|
|
229
|
+
assert.deepEqual(refreshed, [], "resolving the reference must not refresh any account");
|
|
230
|
+
|
|
231
|
+
const message = await models.streamSimple(model, request).result();
|
|
232
|
+
assert.equal(message.stopReason, "stop");
|
|
233
|
+
assert.equal(seen.length, 1);
|
|
234
|
+
assert.equal(seen[0].apiKey, credentialA.access, "the selected account's token reaches the API implementation");
|
|
235
|
+
assert.notEqual(seen[0].apiKey, POOL_UNRESOLVED_API_KEY, "the placeholder must never reach the API implementation");
|
|
236
|
+
} finally {
|
|
237
|
+
agent.dispose();
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ---------------------------------------------------------------------------
|
|
242
|
+
// 4. Off restores one native login, and plain login semantics come back with it.
|
|
243
|
+
// ---------------------------------------------------------------------------
|
|
244
|
+
{
|
|
245
|
+
const agent = makeAgentDir();
|
|
246
|
+
try {
|
|
247
|
+
const credentialA = fakeCredential("account-aaaa");
|
|
248
|
+
const credentialB = fakeCredential("account-bbbb");
|
|
249
|
+
seedAuthFile(agent.authPath, { anthropic: UNRELATED_ENTRY, "openai-codex": credentialA });
|
|
250
|
+
await commitAccount({ authPath: agent.authPath, credential: credentialB });
|
|
251
|
+
|
|
252
|
+
const off = await disablePool({ authPath: agent.authPath, accountId: "account-aaaa" });
|
|
253
|
+
assert.equal(off.dormantAccounts, 1);
|
|
254
|
+
|
|
255
|
+
let entries = readEntries(agent.authPath);
|
|
256
|
+
assert.deepEqual(entries["openai-codex"], credentialA, "off restores the chosen account as the plain login, without pool bookkeeping");
|
|
257
|
+
assert.equal(entries[poolEntryKey("account-aaaa")], undefined, "the restored account has exactly one refresh authority");
|
|
258
|
+
assert.deepEqual(storedCredential(entries, "account-bbbb"), credentialB, "the other account stays stored but dormant");
|
|
259
|
+
assert.deepEqual(entries.anthropic, UNRELATED_ENTRY);
|
|
260
|
+
|
|
261
|
+
// With the pool off, a plain login overwrites the main entry again, as pi normally does.
|
|
262
|
+
const replacement = fakeCredential("account-dddd");
|
|
263
|
+
const models = createModels({ credentials: new NativeCredentialStore(agent.authPath) });
|
|
264
|
+
models.setProvider(codexShapedProvider({ login: async () => replacement, seen: [] }));
|
|
265
|
+
await models.login("openai-codex", "oauth", interaction);
|
|
266
|
+
|
|
267
|
+
entries = readEntries(agent.authPath);
|
|
268
|
+
assert.deepEqual(entries["openai-codex"], replacement);
|
|
269
|
+
assert.deepEqual(storedCredential(entries, "account-bbbb"), credentialB, "a plain login never touches isolated account entries");
|
|
270
|
+
} finally {
|
|
271
|
+
agent.dispose();
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ---------------------------------------------------------------------------
|
|
276
|
+
// 5. Removing the last account logs openai-codex out instead of leaving a dangling reference.
|
|
277
|
+
// ---------------------------------------------------------------------------
|
|
278
|
+
{
|
|
279
|
+
const agent = makeAgentDir();
|
|
280
|
+
try {
|
|
281
|
+
seedAuthFile(agent.authPath, { anthropic: UNRELATED_ENTRY });
|
|
282
|
+
await commitAccount({ authPath: agent.authPath, credential: fakeCredential("account-aaaa") });
|
|
283
|
+
const snapshot = await loadPoolSnapshot(agent.authPath);
|
|
284
|
+
assert.equal(snapshot.enabled, true);
|
|
285
|
+
assert.equal(snapshot.accounts.length, 1, "one account enables the pool but leaves no spare");
|
|
286
|
+
|
|
287
|
+
const removal = await removeAccount({ authPath: agent.authPath, accountId: "account-aaaa" });
|
|
288
|
+
assert.equal(removal.poolDisabled, true);
|
|
289
|
+
|
|
290
|
+
const entries = readEntries(agent.authPath);
|
|
291
|
+
assert.equal(entries["openai-codex"], undefined, "no reference may survive without accounts");
|
|
292
|
+
assert.equal(entries[poolEntryKey("account-aaaa")], undefined);
|
|
293
|
+
assert.deepEqual(entries.anthropic, UNRELATED_ENTRY);
|
|
294
|
+
} finally {
|
|
295
|
+
agent.dispose();
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
console.log("Login ownership integration checks passed");
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
|
|
3
|
+
import { LoginSlot } from "../src/login-slot.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The bug this guards: pi gives extensions one slot below the chat, and closing a selector puts
|
|
7
|
+
* the editor back there. A dialog mounted before the selector is therefore gone afterwards, and
|
|
8
|
+
* the Codex flow asks for the login method first, so the sign-in URL and the manual-code input
|
|
9
|
+
* would render into a component nobody can see.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** A slot that records every dialog call together with whether that dialog was still mounted. */
|
|
13
|
+
function fakeUi(answers) {
|
|
14
|
+
const dialogs = [];
|
|
15
|
+
const selects = [];
|
|
16
|
+
const calls = [];
|
|
17
|
+
const record = (dialog, name, detail) => calls.push({ name, detail, visible: dialog.live });
|
|
18
|
+
return {
|
|
19
|
+
dialogs,
|
|
20
|
+
selects,
|
|
21
|
+
calls,
|
|
22
|
+
ui: {
|
|
23
|
+
mount: (register) =>
|
|
24
|
+
new Promise((resolve) => {
|
|
25
|
+
const state = { live: true };
|
|
26
|
+
dialogs.push(state);
|
|
27
|
+
register(
|
|
28
|
+
{
|
|
29
|
+
signal: new AbortController().signal,
|
|
30
|
+
showAuth: (url) => record(state, "auth", url),
|
|
31
|
+
showDeviceCode: (info) => record(state, "device_code", info.userCode),
|
|
32
|
+
showInfo: (message) => record(state, "info", message),
|
|
33
|
+
showProgress: (message) => record(state, "progress", message),
|
|
34
|
+
showWaiting: (message) => record(state, "waiting", message),
|
|
35
|
+
showManualInput: async (message) => {
|
|
36
|
+
record(state, "manual_code", message);
|
|
37
|
+
return answers.shift();
|
|
38
|
+
},
|
|
39
|
+
showPrompt: async (message) => {
|
|
40
|
+
record(state, "prompt", message);
|
|
41
|
+
return answers.shift();
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
() => {
|
|
45
|
+
state.live = false;
|
|
46
|
+
resolve(undefined);
|
|
47
|
+
},
|
|
48
|
+
);
|
|
49
|
+
}),
|
|
50
|
+
select: async (message, options) => {
|
|
51
|
+
selects.push({ message, visible: dialogs.filter((dialog) => dialog.live).length });
|
|
52
|
+
return options[answers.shift()];
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const LOGIN_METHOD = {
|
|
59
|
+
type: "select",
|
|
60
|
+
message: "选择登录方式",
|
|
61
|
+
options: [
|
|
62
|
+
{ id: "browser", label: "浏览器登录" },
|
|
63
|
+
{ id: "device", label: "设备码登录" },
|
|
64
|
+
],
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// The Codex order: pick a method, then show the URL, then take the pasted code.
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
{
|
|
71
|
+
const { ui, dialogs, selects, calls } = fakeUi([0, "pasted-code"]);
|
|
72
|
+
const slot = new LoginSlot(ui);
|
|
73
|
+
const interaction = slot.interaction;
|
|
74
|
+
|
|
75
|
+
assert.equal(await interaction.prompt(LOGIN_METHOD), "browser");
|
|
76
|
+
assert.deepEqual(selects, [{ message: "选择登录方式", visible: 0 }], "no dialog is mounted while a selector owns the slot");
|
|
77
|
+
|
|
78
|
+
interaction.notify({ type: "auth_url", url: "https://login.invalid/authorize", instructions: "在浏览器中完成" });
|
|
79
|
+
assert.equal(await interaction.prompt({ type: "manual_code", message: "粘贴回调代码" }), "pasted-code");
|
|
80
|
+
|
|
81
|
+
assert.deepEqual(
|
|
82
|
+
calls.map((call) => call.name),
|
|
83
|
+
["auth", "manual_code"],
|
|
84
|
+
"the steps after the selector reach the dialog in order",
|
|
85
|
+
);
|
|
86
|
+
for (const call of calls) assert.ok(call.visible, `${call.name} rendered into a dialog the user can see`);
|
|
87
|
+
assert.equal(dialogs.length, 1, "one dialog is enough once the selector is out of the way");
|
|
88
|
+
|
|
89
|
+
await slot.close();
|
|
90
|
+
assert.deepEqual(dialogs.map((dialog) => dialog.live), [false], "closing the login gives the slot back");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
// A selector in the middle of a login re-mounts the dialog instead of losing it.
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
{
|
|
97
|
+
const { ui, dialogs, selects, calls } = fakeUi([1, "device-done"]);
|
|
98
|
+
const slot = new LoginSlot(ui);
|
|
99
|
+
const interaction = slot.interaction;
|
|
100
|
+
|
|
101
|
+
interaction.notify({ type: "progress", message: "正在准备授权…" });
|
|
102
|
+
assert.equal(await interaction.prompt(LOGIN_METHOD), "device");
|
|
103
|
+
assert.deepEqual(selects, [{ message: "选择登录方式", visible: 0 }], "the dialog steps aside for the selector");
|
|
104
|
+
|
|
105
|
+
interaction.notify({ type: "device_code", userCode: "ABCD-EFGH", verificationUri: "https://login.invalid/device" });
|
|
106
|
+
assert.equal(await interaction.prompt({ type: "manual_code", message: "粘贴回调代码" }), "device-done");
|
|
107
|
+
|
|
108
|
+
assert.deepEqual(
|
|
109
|
+
calls.map((call) => call.name),
|
|
110
|
+
["progress", "device_code", "waiting", "manual_code"],
|
|
111
|
+
"events keep the order the provider emitted them in",
|
|
112
|
+
);
|
|
113
|
+
for (const call of calls) assert.ok(call.visible, `${call.name} rendered into a dialog the user can see`);
|
|
114
|
+
assert.equal(dialogs.length, 2, "the dialog is mounted again after the selector took the slot");
|
|
115
|
+
|
|
116
|
+
await slot.close();
|
|
117
|
+
assert.deepEqual(dialogs.map((dialog) => dialog.live), [false, false]);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// Backing out of the selector cancels the login rather than hanging on the slot.
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
{
|
|
124
|
+
const { ui, dialogs } = fakeUi([undefined]);
|
|
125
|
+
const slot = new LoginSlot(ui);
|
|
126
|
+
await assert.rejects(() => slot.interaction.prompt(LOGIN_METHOD), /Login cancelled/);
|
|
127
|
+
await slot.close();
|
|
128
|
+
assert.deepEqual(dialogs, [], "a cancelled method choice never opens a dialog");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
console.log("Login slot tests passed");
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { mkdirSync, readdirSync, readFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
|
|
7
|
+
import { commitAccount } from "../src/auth.ts";
|
|
8
|
+
import { poolEntryKey, withAuthTransaction } from "../src/store.ts";
|
|
9
|
+
import { fakeCredential, makeAgentDir } from "./helpers/fixtures.mjs";
|
|
10
|
+
|
|
11
|
+
const run = promisify(execFile);
|
|
12
|
+
const CHILD = new URL("./helpers/refresh-child.mjs", import.meta.url).pathname;
|
|
13
|
+
const ACCOUNT = "account-aaaa";
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Two real pi processes racing for a rotating refresh token exchange it once.
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
{
|
|
19
|
+
const agent = makeAgentDir();
|
|
20
|
+
try {
|
|
21
|
+
const markerDir = join(agent.directory, "markers");
|
|
22
|
+
mkdirSync(markerDir);
|
|
23
|
+
await commitAccount({ authPath: agent.authPath, credential: fakeCredential(ACCOUNT, { expiresInMs: -1_000 }) });
|
|
24
|
+
|
|
25
|
+
const [first, second] = await Promise.all([
|
|
26
|
+
run(process.execPath, ["--experimental-strip-types", CHILD, agent.authPath, ACCOUNT, markerDir, "one"]),
|
|
27
|
+
run(process.execPath, ["--experimental-strip-types", CHILD, agent.authPath, ACCOUNT, markerDir, "two"]),
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
const results = [JSON.parse(first.stdout), JSON.parse(second.stdout)];
|
|
31
|
+
assert.deepEqual(readdirSync(markerDir).length, 1, "a rotating refresh token must be exchanged exactly once");
|
|
32
|
+
assert.equal(results[0].access, results[1].access, "both processes end up with the same live credential");
|
|
33
|
+
assert.equal(results.filter((result) => result.refreshed).length, 1, "only the winner reports a refresh");
|
|
34
|
+
|
|
35
|
+
const entries = JSON.parse(readFileSync(agent.authPath, "utf8"));
|
|
36
|
+
assert.equal(entries[poolEntryKey(ACCOUNT)].access, results[0].access, "the stored credential is the one both processes use");
|
|
37
|
+
assert.deepEqual(entries["unrelated-one"], { type: "api_key", key: "$KEY_one" });
|
|
38
|
+
assert.deepEqual(entries["unrelated-two"], { type: "api_key", key: "$KEY_two" }, "concurrent writes from another process are not lost");
|
|
39
|
+
assert.equal(entries[poolEntryKey(ACCOUNT)].piOpenAIAccountPoolMembership.ordinal, 1, "membership survives rotation");
|
|
40
|
+
} finally {
|
|
41
|
+
agent.dispose();
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// A write from this process interleaved with four child writes keeps every entry.
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
{
|
|
49
|
+
const agent = makeAgentDir();
|
|
50
|
+
try {
|
|
51
|
+
const markerDir = join(agent.directory, "markers");
|
|
52
|
+
mkdirSync(markerDir);
|
|
53
|
+
await commitAccount({ authPath: agent.authPath, credential: fakeCredential(ACCOUNT) });
|
|
54
|
+
|
|
55
|
+
const children = ["a", "b", "c", "d"].map((label) =>
|
|
56
|
+
run(process.execPath, ["--experimental-strip-types", CHILD, agent.authPath, ACCOUNT, markerDir, label]),
|
|
57
|
+
);
|
|
58
|
+
const local = withAuthTransaction(agent.authPath, (entries) => ({
|
|
59
|
+
value: { ...entries, anthropic: { type: "api_key", key: "$ANTHROPIC_API_KEY" } },
|
|
60
|
+
result: undefined,
|
|
61
|
+
}));
|
|
62
|
+
await Promise.all([...children, local]);
|
|
63
|
+
|
|
64
|
+
const entries = JSON.parse(readFileSync(agent.authPath, "utf8"));
|
|
65
|
+
for (const label of ["a", "b", "c", "d"]) assert.deepEqual(entries[`unrelated-${label}`], { type: "api_key", key: `$KEY_${label}` });
|
|
66
|
+
assert.deepEqual(entries.anthropic, { type: "api_key", key: "$ANTHROPIC_API_KEY" });
|
|
67
|
+
assert.equal(readdirSync(markerDir).length, 0, "a valid credential is never refreshed");
|
|
68
|
+
} finally {
|
|
69
|
+
agent.dispose();
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
console.log("Multiprocess tests passed");
|