copillm 0.2.8 → 0.3.0-beta.1
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/README.md +70 -2
- package/dist/agentconfig/load.js +9 -1
- package/dist/agentconfig/render.js +8 -5
- package/dist/agentconfig/schema.js +6 -0
- package/dist/auth/accountManager.js +118 -0
- package/dist/auth/accounts.js +161 -0
- package/dist/auth/copilotToken.js +92 -23
- package/dist/auth/credentials.js +216 -40
- package/dist/auth/deviceFlow.js +110 -23
- package/dist/auth/githubIdentity.js +14 -10
- package/dist/cli/agentEnv.js +15 -9
- package/dist/cli/auth/runAuth.js +206 -9
- package/dist/cli/commands/agents/claude.js +22 -2
- package/dist/cli/commands/agents/codex.js +22 -2
- package/dist/cli/commands/agents/copilot.js +25 -4
- package/dist/cli/commands/agents/pi.js +22 -2
- package/dist/cli/commands/agents/shared.js +57 -0
- package/dist/cli/commands/auth.js +58 -7
- package/dist/cli/commands/daemon.js +79 -17
- package/dist/cli/commands/models.js +0 -5
- package/dist/cli/copillmFlags.js +8 -0
- package/dist/cli/daemon/lifecycle.js +26 -0
- package/dist/cli/daemon/probes.js +99 -33
- package/dist/cli/daemon/runDaemon.js +21 -2
- package/dist/cli/index.js +12 -0
- package/dist/cli/integrations/claudeExport.js +6 -4
- package/dist/cli/integrations/refreshCodex.js +5 -2
- package/dist/cli/integrations/refreshPi.js +5 -2
- package/dist/cli/packageInfo.js +1 -1
- package/dist/cli/shared/devMode.js +98 -0
- package/dist/config/accountId.js +44 -0
- package/dist/config/config.js +13 -2
- package/dist/config/home.js +69 -0
- package/dist/integrations/claude/cache.js +5 -2
- package/dist/integrations/claude/settingsConflict.js +5 -2
- package/dist/integrations/codex/init.js +31 -10
- package/dist/integrations/pi/init.js +8 -17
- package/dist/models/anthropicDefaults.js +13 -4
- package/dist/models/discovery.js +141 -15
- package/dist/server/accountResolver.js +85 -0
- package/dist/server/debugInfo.js +69 -24
- package/dist/server/errors.js +18 -0
- package/dist/server/proxy.js +40 -8
- package/dist/server/routes/debug.js +11 -1
- package/dist/server/routes/models.js +12 -6
- package/dist/server/routes/proxyForward.js +3 -3
- package/dist/server/routes/shared.js +66 -21
- package/dist/server/upstream/copilotClient.js +1 -30
- package/dist/server/upstream/retryPolicy.js +99 -0
- package/package.json +4 -1
package/dist/auth/credentials.js
CHANGED
|
@@ -2,19 +2,72 @@ import fs from "node:fs";
|
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { ensureAppHome } from "../config/config.js";
|
|
5
|
-
import { credentialsPath, credentialsReadPath } from "../config/home.js";
|
|
5
|
+
import { accountCredentialsPath, accountCredentialsReadPath, credentialsPath, credentialsReadPath } from "../config/home.js";
|
|
6
6
|
import { writeFileSecureAtomic } from "../config/fsSecurity.js";
|
|
7
|
+
import { assertValidAccountId, findAccount, readAccountsIndex, upsertAccount } from "./accounts.js";
|
|
7
8
|
const SERVICE = "copillm";
|
|
8
|
-
|
|
9
|
+
// The legacy keychain account string. Single-account installs (and the default
|
|
10
|
+
// account on multi-account installs) keep using this exact key so upgrading to
|
|
11
|
+
// a multi-account-aware build never invalidates an existing login. Additional
|
|
12
|
+
// accounts are namespaced as `${LEGACY_ACCOUNT}:<id>`.
|
|
13
|
+
const LEGACY_ACCOUNT = "github-oauth-token";
|
|
14
|
+
// Map key for the default account's in-memory session credential. Chosen so it
|
|
15
|
+
// can't collide with a real account id (those can't contain ':').
|
|
16
|
+
const DEFAULT_SESSION_KEY = "::default::";
|
|
17
|
+
function legacyStorage() {
|
|
18
|
+
return {
|
|
19
|
+
keychainAccount: LEGACY_ACCOUNT,
|
|
20
|
+
filePath: credentialsPath(),
|
|
21
|
+
fileReadPath: credentialsReadPath(),
|
|
22
|
+
sessionKey: DEFAULT_SESSION_KEY
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
function namespacedStorage(accountId) {
|
|
26
|
+
assertValidAccountId(accountId);
|
|
27
|
+
return {
|
|
28
|
+
keychainAccount: `${LEGACY_ACCOUNT}:${accountId}`,
|
|
29
|
+
filePath: accountCredentialsPath(accountId),
|
|
30
|
+
fileReadPath: accountCredentialsReadPath(accountId),
|
|
31
|
+
sessionKey: accountId
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
function storageForScheme(accountId, scheme) {
|
|
35
|
+
return scheme === "legacy" ? legacyStorage() : namespacedStorage(accountId);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Resolve the storage for a specific account id. A registered account uses the
|
|
39
|
+
* scheme recorded in its index entry (so the default/legacy account keeps the
|
|
40
|
+
* legacy keys even when addressed by id). An unregistered id is assumed to be a
|
|
41
|
+
* not-yet-persisted named account and gets namespaced storage.
|
|
42
|
+
*/
|
|
43
|
+
function storageForAccountId(accountId) {
|
|
44
|
+
const record = findAccount(accountId);
|
|
45
|
+
return record ? storageForScheme(record.id, record.storage) : namespacedStorage(accountId);
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Storage for the *default* account. With no accounts index this is the legacy
|
|
49
|
+
* single-account storage — the path every existing install takes — so the
|
|
50
|
+
* exported `loadStoredCredential` / `saveStoredCredential` / etc. behave
|
|
51
|
+
* identically to the pre-multi-account build.
|
|
52
|
+
*/
|
|
53
|
+
function defaultAccountStorage() {
|
|
54
|
+
const index = readAccountsIndex();
|
|
55
|
+
if (!index) {
|
|
56
|
+
return legacyStorage();
|
|
57
|
+
}
|
|
58
|
+
const record = index.accounts.find((account) => account.id === index.defaultAccount);
|
|
59
|
+
return record ? storageForScheme(record.id, record.storage) : legacyStorage();
|
|
60
|
+
}
|
|
9
61
|
const FileCredentialSchema = z.object({
|
|
10
62
|
version: z.literal(1),
|
|
11
63
|
github_token: z.string().min(1),
|
|
12
64
|
account_type: z.enum(["individual", "business", "enterprise"]),
|
|
13
65
|
saved_at: z.string().min(1)
|
|
14
66
|
});
|
|
15
|
-
// Module-level in-memory
|
|
16
|
-
// Never persisted; cleared on
|
|
17
|
-
|
|
67
|
+
// Module-level in-memory credentials, keyed by account session key. Only
|
|
68
|
+
// populated when SaveMode === "session". Never persisted; cleared on
|
|
69
|
+
// clearStoredCredential() and on process exit.
|
|
70
|
+
const sessionCredentials = new Map();
|
|
18
71
|
function forceSessionBackend() {
|
|
19
72
|
return process.env.COPILLM_FORCE_SESSION_BACKEND === "1";
|
|
20
73
|
}
|
|
@@ -97,18 +150,17 @@ async function resolveKeyring() {
|
|
|
97
150
|
}
|
|
98
151
|
return { keyring: null, reason: "keyring module is unavailable on this machine" };
|
|
99
152
|
}
|
|
100
|
-
function parseCredentialFile() {
|
|
101
|
-
const
|
|
102
|
-
const raw = readFileSync(path, "utf8");
|
|
153
|
+
function parseCredentialFile(readPath) {
|
|
154
|
+
const raw = readFileSync(readPath, "utf8");
|
|
103
155
|
let parsedJson;
|
|
104
156
|
try {
|
|
105
157
|
parsedJson = JSON.parse(raw);
|
|
106
158
|
}
|
|
107
159
|
catch (error) {
|
|
108
160
|
if (error instanceof Error) {
|
|
109
|
-
throw new Error(`Credential file exists but contains invalid JSON at ${
|
|
161
|
+
throw new Error(`Credential file exists but contains invalid JSON at ${readPath}: ${error.message}`);
|
|
110
162
|
}
|
|
111
|
-
throw new Error(`Credential file exists but contains invalid JSON at ${
|
|
163
|
+
throw new Error(`Credential file exists but contains invalid JSON at ${readPath}.`);
|
|
112
164
|
}
|
|
113
165
|
try {
|
|
114
166
|
const parsed = FileCredentialSchema.parse(parsedJson);
|
|
@@ -116,12 +168,12 @@ function parseCredentialFile() {
|
|
|
116
168
|
}
|
|
117
169
|
catch (error) {
|
|
118
170
|
if (error instanceof Error) {
|
|
119
|
-
throw new Error(`Credential file exists but is invalid at ${
|
|
171
|
+
throw new Error(`Credential file exists but is invalid at ${readPath}: ${error.message}`);
|
|
120
172
|
}
|
|
121
|
-
throw new Error(`Credential file exists but is invalid at ${
|
|
173
|
+
throw new Error(`Credential file exists but is invalid at ${readPath}.`);
|
|
122
174
|
}
|
|
123
175
|
}
|
|
124
|
-
function writeCredentialFile(token, accountType) {
|
|
176
|
+
function writeCredentialFile(writePath, token, accountType) {
|
|
125
177
|
ensureAppHome();
|
|
126
178
|
const payload = {
|
|
127
179
|
version: 1,
|
|
@@ -129,7 +181,7 @@ function writeCredentialFile(token, accountType) {
|
|
|
129
181
|
account_type: accountType,
|
|
130
182
|
saved_at: new Date().toISOString()
|
|
131
183
|
};
|
|
132
|
-
writeFileSecureAtomic(
|
|
184
|
+
writeFileSecureAtomic(writePath, JSON.stringify(payload, null, 2), 0o600);
|
|
133
185
|
}
|
|
134
186
|
function canUsePlaintextFallback() {
|
|
135
187
|
if (forceSessionBackend()) {
|
|
@@ -152,11 +204,11 @@ function isMissingKeyringError(error) {
|
|
|
152
204
|
* this helper to avoid accidentally pulling the secret into a code path that
|
|
153
205
|
* might log or print it.
|
|
154
206
|
*/
|
|
155
|
-
|
|
156
|
-
if (
|
|
207
|
+
async function inspectStorage(storage) {
|
|
208
|
+
if (sessionCredentials.has(storage.sessionKey)) {
|
|
157
209
|
return { stored: true, backend: "session" };
|
|
158
210
|
}
|
|
159
|
-
if (fs.existsSync(
|
|
211
|
+
if (fs.existsSync(storage.fileReadPath)) {
|
|
160
212
|
return { stored: true, backend: "file" };
|
|
161
213
|
}
|
|
162
214
|
const { keyring } = await resolveKeyring();
|
|
@@ -164,7 +216,7 @@ export async function inspectStoredCredential() {
|
|
|
164
216
|
return { stored: false, backend: null };
|
|
165
217
|
}
|
|
166
218
|
try {
|
|
167
|
-
const token = await keyring.getPassword(SERVICE,
|
|
219
|
+
const token = await keyring.getPassword(SERVICE, storage.keychainAccount);
|
|
168
220
|
if (token) {
|
|
169
221
|
return { stored: true, backend: "keyring" };
|
|
170
222
|
}
|
|
@@ -177,12 +229,13 @@ export async function inspectStoredCredential() {
|
|
|
177
229
|
throw new Error("Failed to read token from OS keychain.");
|
|
178
230
|
}
|
|
179
231
|
}
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
232
|
+
async function loadStorage(storage) {
|
|
233
|
+
const session = sessionCredentials.get(storage.sessionKey);
|
|
234
|
+
if (session) {
|
|
235
|
+
return { token: session.token, accountType: session.accountType, source: "session" };
|
|
183
236
|
}
|
|
184
|
-
if (fs.existsSync(
|
|
185
|
-
const parsed = parseCredentialFile();
|
|
237
|
+
if (fs.existsSync(storage.fileReadPath)) {
|
|
238
|
+
const parsed = parseCredentialFile(storage.fileReadPath);
|
|
186
239
|
return { token: parsed.token, accountType: parsed.accountType, source: "file" };
|
|
187
240
|
}
|
|
188
241
|
const { keyring } = await resolveKeyring();
|
|
@@ -191,7 +244,7 @@ export async function loadStoredCredential() {
|
|
|
191
244
|
}
|
|
192
245
|
let token;
|
|
193
246
|
try {
|
|
194
|
-
token = await keyring.getPassword(SERVICE,
|
|
247
|
+
token = await keyring.getPassword(SERVICE, storage.keychainAccount);
|
|
195
248
|
}
|
|
196
249
|
catch (error) {
|
|
197
250
|
if (error instanceof Error) {
|
|
@@ -204,21 +257,47 @@ export async function loadStoredCredential() {
|
|
|
204
257
|
}
|
|
205
258
|
return { token, accountType: "individual", source: "keyring" };
|
|
206
259
|
}
|
|
207
|
-
|
|
208
|
-
const
|
|
260
|
+
async function loadStorageForStatus(storage) {
|
|
261
|
+
const session = sessionCredentials.get(storage.sessionKey);
|
|
262
|
+
if (session) {
|
|
263
|
+
return { stored: true, backend: "session", token: session.token };
|
|
264
|
+
}
|
|
265
|
+
if (fs.existsSync(storage.fileReadPath)) {
|
|
266
|
+
const parsed = parseCredentialFile(storage.fileReadPath);
|
|
267
|
+
return { stored: true, backend: "file", token: parsed.token };
|
|
268
|
+
}
|
|
269
|
+
const { keyring } = await resolveKeyring();
|
|
270
|
+
if (!keyring) {
|
|
271
|
+
return { stored: false, backend: null, token: null };
|
|
272
|
+
}
|
|
273
|
+
try {
|
|
274
|
+
const token = await keyring.getPassword(SERVICE, storage.keychainAccount);
|
|
275
|
+
if (token) {
|
|
276
|
+
return { stored: true, backend: "keyring", token };
|
|
277
|
+
}
|
|
278
|
+
return { stored: false, backend: null, token: null };
|
|
279
|
+
}
|
|
280
|
+
catch (error) {
|
|
281
|
+
if (error instanceof Error) {
|
|
282
|
+
throw new Error(`Failed to read token from OS keychain: ${error.message}`);
|
|
283
|
+
}
|
|
284
|
+
throw new Error("Failed to read token from OS keychain.");
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
async function saveStorage(storage, token, accountType, mode) {
|
|
209
288
|
if (mode === "session") {
|
|
210
|
-
|
|
289
|
+
sessionCredentials.set(storage.sessionKey, { token, accountType });
|
|
211
290
|
return "session";
|
|
212
291
|
}
|
|
213
|
-
if (fs.existsSync(
|
|
214
|
-
parseCredentialFile();
|
|
215
|
-
writeCredentialFile(token, accountType);
|
|
292
|
+
if (fs.existsSync(storage.fileReadPath)) {
|
|
293
|
+
parseCredentialFile(storage.fileReadPath);
|
|
294
|
+
writeCredentialFile(storage.filePath, token, accountType);
|
|
216
295
|
return "file";
|
|
217
296
|
}
|
|
218
297
|
const { keyring, reason } = await resolveKeyring();
|
|
219
298
|
if (keyring) {
|
|
220
299
|
try {
|
|
221
|
-
await keyring.setPassword(SERVICE,
|
|
300
|
+
await keyring.setPassword(SERVICE, storage.keychainAccount, token);
|
|
222
301
|
return "keyring";
|
|
223
302
|
}
|
|
224
303
|
catch (error) {
|
|
@@ -231,15 +310,14 @@ export async function saveStoredCredential(token, accountType, options = {}) {
|
|
|
231
310
|
if (!canUsePlaintextFallback()) {
|
|
232
311
|
throw new Error(`OS keychain backend unavailable (${reason ?? "unknown reason"}). Plaintext fallback is disabled in non-interactive mode; set COPILLM_ALLOW_PLAINTEXT_CREDENTIALS=1 to allow it.`);
|
|
233
312
|
}
|
|
234
|
-
writeCredentialFile(token, accountType);
|
|
313
|
+
writeCredentialFile(storage.filePath, token, accountType);
|
|
235
314
|
return "file";
|
|
236
315
|
}
|
|
237
|
-
|
|
316
|
+
async function clearStorage(storage) {
|
|
238
317
|
// Always clear in-memory session token first; it shadows other backends.
|
|
239
|
-
const hadSession =
|
|
240
|
-
|
|
241
|
-
const
|
|
242
|
-
const canonicalPath = credentialsPath();
|
|
318
|
+
const hadSession = sessionCredentials.delete(storage.sessionKey);
|
|
319
|
+
const readablePath = storage.fileReadPath;
|
|
320
|
+
const canonicalPath = storage.filePath;
|
|
243
321
|
if (fs.existsSync(readablePath)) {
|
|
244
322
|
fs.unlinkSync(readablePath);
|
|
245
323
|
if (readablePath !== canonicalPath && fs.existsSync(canonicalPath)) {
|
|
@@ -250,7 +328,7 @@ export async function clearStoredCredential() {
|
|
|
250
328
|
const { keyring, reason } = await resolveKeyring();
|
|
251
329
|
if (keyring) {
|
|
252
330
|
try {
|
|
253
|
-
const removed = await keyring.deletePassword(SERVICE,
|
|
331
|
+
const removed = await keyring.deletePassword(SERVICE, storage.keychainAccount);
|
|
254
332
|
return { backend: "keyring", removed: removed || hadSession };
|
|
255
333
|
}
|
|
256
334
|
catch (error) {
|
|
@@ -265,8 +343,106 @@ export async function clearStoredCredential() {
|
|
|
265
343
|
}
|
|
266
344
|
throw new Error(`No credential backend available to clear credentials (${reason ?? "unknown reason"}).`);
|
|
267
345
|
}
|
|
268
|
-
//
|
|
346
|
+
// ---------------------------------------------------------------------------
|
|
347
|
+
// Default-account surface. With no accounts index these operate on the legacy
|
|
348
|
+
// single-account storage, so behaviour is identical to the pre-multi-account
|
|
349
|
+
// build. With an index they target whichever account is currently the default.
|
|
350
|
+
// ---------------------------------------------------------------------------
|
|
351
|
+
export async function inspectStoredCredential() {
|
|
352
|
+
return inspectStorage(defaultAccountStorage());
|
|
353
|
+
}
|
|
354
|
+
export async function loadStoredCredential() {
|
|
355
|
+
return loadStorage(defaultAccountStorage());
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* Coalesced inspect + load for status surfaces. Returns the same fields
|
|
359
|
+
* `inspectStoredCredential` exposes (`stored` + `backend`) AND the
|
|
360
|
+
* `token` — but performs only ONE backend probe.
|
|
361
|
+
*
|
|
362
|
+
* Previously, `auth status` with user-lookup enabled did:
|
|
363
|
+
* 1. `inspectStoredCredential` → one `keyring.getPassword` (backend probe)
|
|
364
|
+
* 2. `loadStoredCredential` (inside `inspectGithubIdentity`) → another
|
|
365
|
+
* `keyring.getPassword` (full token read)
|
|
366
|
+
*
|
|
367
|
+
* On macOS, each call is its own keychain audit-log entry and (on a
|
|
368
|
+
* misconfigured system) its own permission prompt. This helper folds both
|
|
369
|
+
* into a single backend probe + single read.
|
|
370
|
+
*
|
|
371
|
+
* SECURITY: callers MUST treat the `token` field as sensitive — do not log,
|
|
372
|
+
* print, or persist it. The status JSON output and `formatHumanAuthStatusLine`
|
|
373
|
+
* only consume `backend` (and the upstream identity summary returned by
|
|
374
|
+
* `inspectGithubIdentity`), never `token` directly. Enforced at the call
|
|
375
|
+
* site (`tests/integration/authStatusCli.test.ts` runs a substring-leak
|
|
376
|
+
* guard on the printed output).
|
|
377
|
+
*/
|
|
378
|
+
export async function loadStoredCredentialForStatus() {
|
|
379
|
+
return loadStorageForStatus(defaultAccountStorage());
|
|
380
|
+
}
|
|
381
|
+
export async function saveStoredCredential(token, accountType, options = {}) {
|
|
382
|
+
return saveStorage(defaultAccountStorage(), token, accountType, options.mode ?? "auto");
|
|
383
|
+
}
|
|
384
|
+
export async function clearStoredCredential() {
|
|
385
|
+
return clearStorage(defaultAccountStorage());
|
|
386
|
+
}
|
|
387
|
+
// ---------------------------------------------------------------------------
|
|
388
|
+
// Account-scoped surface. These address a specific account by id regardless of
|
|
389
|
+
// which one is the default. A registered account uses the storage scheme from
|
|
390
|
+
// its index entry (so the legacy/default account keeps the legacy keys); an
|
|
391
|
+
// unregistered id is treated as a not-yet-persisted namespaced account.
|
|
392
|
+
// ---------------------------------------------------------------------------
|
|
393
|
+
export async function inspectStoredCredentialForAccount(accountId) {
|
|
394
|
+
return inspectStorage(storageForAccountId(accountId));
|
|
395
|
+
}
|
|
396
|
+
export async function loadStoredCredentialForAccount(accountId) {
|
|
397
|
+
const loaded = await loadStorage(storageForAccountId(accountId));
|
|
398
|
+
if (!loaded) {
|
|
399
|
+
return loaded;
|
|
400
|
+
}
|
|
401
|
+
// The keychain backend can't store the account type, so `loadStorage`
|
|
402
|
+
// defaults it to "individual". The index records the real type per account —
|
|
403
|
+
// prefer it so model-discovery base-URL selection stays correct.
|
|
404
|
+
if (loaded.source === "keyring") {
|
|
405
|
+
const record = findAccount(accountId);
|
|
406
|
+
if (record) {
|
|
407
|
+
return { ...loaded, accountType: record.accountType };
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
return loaded;
|
|
411
|
+
}
|
|
412
|
+
export async function saveStoredCredentialForAccount(accountId, token, accountType, options = {}) {
|
|
413
|
+
return saveStorage(storageForAccountId(accountId), token, accountType, options.mode ?? "auto");
|
|
414
|
+
}
|
|
415
|
+
export async function clearStoredCredentialForAccount(accountId) {
|
|
416
|
+
return clearStorage(storageForAccountId(accountId));
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Materialize the accounts index from a pre-existing single-account install.
|
|
420
|
+
* Registers the current legacy credential as the default account with
|
|
421
|
+
* `storage: "legacy"` so its token is **not moved** — the keychain entry and
|
|
422
|
+
* `credentials.json` stay exactly where they are. Idempotent: if the index
|
|
423
|
+
* already exists it is returned unchanged. Use this before adding a second
|
|
424
|
+
* account so the original login is represented without any invalidation risk.
|
|
425
|
+
*/
|
|
426
|
+
export function registerExistingCredentialAsDefault(accountId, accountType) {
|
|
427
|
+
assertValidAccountId(accountId);
|
|
428
|
+
const existing = readAccountsIndex();
|
|
429
|
+
if (existing) {
|
|
430
|
+
const current = existing.accounts.find((account) => account.id === existing.defaultAccount);
|
|
431
|
+
if (current) {
|
|
432
|
+
return current;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
const record = {
|
|
436
|
+
id: accountId,
|
|
437
|
+
accountType,
|
|
438
|
+
storage: "legacy",
|
|
439
|
+
addedAt: new Date().toISOString()
|
|
440
|
+
};
|
|
441
|
+
upsertAccount(record);
|
|
442
|
+
return record;
|
|
443
|
+
}
|
|
444
|
+
// Test seam: forcibly clear all in-memory session credentials. Not exported via
|
|
269
445
|
// the package surface for end users — only used by unit tests.
|
|
270
446
|
export function __resetSessionCredentialForTests() {
|
|
271
|
-
|
|
447
|
+
sessionCredentials.clear();
|
|
272
448
|
}
|
package/dist/auth/deviceFlow.js
CHANGED
|
@@ -1,32 +1,67 @@
|
|
|
1
|
+
import { setTimeout as defaultSleep } from "node:timers/promises";
|
|
2
|
+
import { isRetryableStatus, isRetryableTransportError, retryDelayMs } from "../server/upstream/retryPolicy.js";
|
|
1
3
|
const GITHUB_CLIENT_ID = "Iv1.b507a08c87ecfe98";
|
|
2
4
|
const DEVICE_CODE_URL = "https://github.com/login/device/code";
|
|
3
5
|
const ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token";
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
const
|
|
6
|
+
/**
|
|
7
|
+
* Per-attempt timeout for the init POST + each poll POST. GitHub's device-
|
|
8
|
+
* flow endpoints typically respond in <500ms; 10s leaves room for slow
|
|
9
|
+
* networks without freezing the login flow for a full minute on a network
|
|
10
|
+
* black-hole. Previously the fetches had no timeout at all.
|
|
11
|
+
*/
|
|
12
|
+
const DEFAULT_FETCH_TIMEOUT_MS = 10_000;
|
|
13
|
+
const DEFAULT_INIT_MAX_ATTEMPTS = 3;
|
|
14
|
+
export async function loginViaDeviceFlow(deps) {
|
|
15
|
+
const fetchImpl = deps?.fetchImpl ?? ((input, init) => fetch(input, init));
|
|
16
|
+
const sleepImpl = deps?.sleepImpl ?? ((ms) => defaultSleep(ms));
|
|
17
|
+
const stdout = deps?.stdout ?? process.stdout;
|
|
18
|
+
const fetchTimeoutMs = deps?.fetchTimeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
|
|
19
|
+
const initMaxAttempts = deps?.initMaxAttempts ?? DEFAULT_INIT_MAX_ATTEMPTS;
|
|
20
|
+
// Phase 1: init POST. Retried up to `initMaxAttempts` on transient HTTP
|
|
21
|
+
// statuses + transport errors. A single 502 used to abort the whole
|
|
22
|
+
// device-flow login — the user would have to start over from a new code.
|
|
23
|
+
const payload = await initDeviceFlow({ fetchImpl, sleepImpl, fetchTimeoutMs, initMaxAttempts });
|
|
14
24
|
const verificationUrl = payload.verification_uri_complete ?? payload.verification_uri;
|
|
15
|
-
|
|
25
|
+
stdout.write(`Open ${verificationUrl} and enter code ${payload.user_code}\n`);
|
|
26
|
+
// Phase 2: poll loop. The device-flow `expires_in` is the natural deadline.
|
|
27
|
+
// Inside the loop, transient HTTP / transport failures `continue` instead
|
|
28
|
+
// of throwing — the loop's own `await sleep(intervalMs)` IS the backoff,
|
|
29
|
+
// and the `deadline` IS the budget. Previously, a single 503 from
|
|
30
|
+
// `github.com/login/oauth/access_token` aborted the whole login.
|
|
16
31
|
const deadline = Date.now() + payload.expires_in * 1000;
|
|
17
32
|
let intervalMs = Math.max(1, payload.interval) * 1000;
|
|
18
33
|
while (Date.now() < deadline) {
|
|
19
|
-
await
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
34
|
+
await sleepImpl(intervalMs);
|
|
35
|
+
let poll;
|
|
36
|
+
try {
|
|
37
|
+
poll = await fetchImpl(ACCESS_TOKEN_URL, {
|
|
38
|
+
method: "POST",
|
|
39
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
|
|
40
|
+
body: new URLSearchParams({
|
|
41
|
+
client_id: GITHUB_CLIENT_ID,
|
|
42
|
+
device_code: payload.device_code,
|
|
43
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code"
|
|
44
|
+
}),
|
|
45
|
+
signal: AbortSignal.timeout(fetchTimeoutMs)
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
if (isRetryableTransportError(error)) {
|
|
50
|
+
// Transient — the next loop iteration will retry naturally. Don't
|
|
51
|
+
// abort the user's login over an ECONNRESET / DNS soft-fail.
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
29
56
|
if (!poll.ok) {
|
|
57
|
+
// Transient HTTP errors (5xx, 429) keep polling — same justification
|
|
58
|
+
// as transport errors. Permanent errors (4xx other than 429) abort,
|
|
59
|
+
// because the device code itself is bad and no amount of polling
|
|
60
|
+
// will fix it.
|
|
61
|
+
if (isRetryableStatus(poll.status)) {
|
|
62
|
+
await discardResponseBody(poll);
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
30
65
|
throw new Error(`Access token poll failed (${poll.status}).`);
|
|
31
66
|
}
|
|
32
67
|
const tokenPayload = parseAccessTokenResponse((await poll.json()));
|
|
@@ -51,8 +86,60 @@ export async function loginViaDeviceFlow() {
|
|
|
51
86
|
}
|
|
52
87
|
throw new Error("Device authorization timed out.");
|
|
53
88
|
}
|
|
54
|
-
|
|
55
|
-
|
|
89
|
+
/**
|
|
90
|
+
* Init POST with bounded retries. Retries on retryable HTTP statuses (5xx,
|
|
91
|
+
* 429, 408, 409, 425) and on transient transport errors (ECONNRESET, DNS
|
|
92
|
+
* soft-fails, undici timeouts). Fast-fails on 4xx-other so a misconfigured
|
|
93
|
+
* client_id or missing scope shows up immediately instead of after three
|
|
94
|
+
* pointless retries.
|
|
95
|
+
*
|
|
96
|
+
* Throws the last error if the budget is exhausted. The wrapping
|
|
97
|
+
* `loginViaDeviceFlow` does not retry the init separately — this is the
|
|
98
|
+
* only init retry layer.
|
|
99
|
+
*/
|
|
100
|
+
async function initDeviceFlow(opts) {
|
|
101
|
+
let lastError;
|
|
102
|
+
for (let attempt = 1; attempt <= opts.initMaxAttempts; attempt += 1) {
|
|
103
|
+
let response;
|
|
104
|
+
try {
|
|
105
|
+
response = await opts.fetchImpl(DEVICE_CODE_URL, {
|
|
106
|
+
method: "POST",
|
|
107
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
|
|
108
|
+
body: new URLSearchParams({ client_id: GITHUB_CLIENT_ID, scope: "read:user" }),
|
|
109
|
+
signal: AbortSignal.timeout(opts.fetchTimeoutMs)
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
lastError = error;
|
|
114
|
+
if (isRetryableTransportError(error) && attempt < opts.initMaxAttempts) {
|
|
115
|
+
await opts.sleepImpl(retryDelayMs(attempt));
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
120
|
+
if (response.ok) {
|
|
121
|
+
return parseDeviceCodeResponse((await response.json()));
|
|
122
|
+
}
|
|
123
|
+
lastError = new Error(`Device flow init failed (${response.status}).`);
|
|
124
|
+
if (isRetryableStatus(response.status) && attempt < opts.initMaxAttempts) {
|
|
125
|
+
await discardResponseBody(response);
|
|
126
|
+
await opts.sleepImpl(retryDelayMs(attempt));
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
throw lastError;
|
|
130
|
+
}
|
|
131
|
+
// Unreachable: every iteration either returns, throws, or continues
|
|
132
|
+
// (with continue gated on `attempt < initMaxAttempts`). Defend anyway.
|
|
133
|
+
throw lastError ?? new Error("Device flow init exhausted retries without error context.");
|
|
134
|
+
}
|
|
135
|
+
async function discardResponseBody(response) {
|
|
136
|
+
try {
|
|
137
|
+
await response.arrayBuffer();
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
// Best-effort body drain; ignore failures so we don't surface a
|
|
141
|
+
// response-cleanup error in place of the real one we already captured.
|
|
142
|
+
}
|
|
56
143
|
}
|
|
57
144
|
function parseDeviceCodeResponse(value) {
|
|
58
145
|
if (!value || typeof value !== "object") {
|
|
@@ -14,18 +14,22 @@ import { getGithubUserSummary, GithubUserFetchError } from "../server/debugInfo.
|
|
|
14
14
|
* null so callers can gracefully fall back to existing offline output.
|
|
15
15
|
*/
|
|
16
16
|
export async function inspectGithubIdentity(options = {}) {
|
|
17
|
-
let
|
|
18
|
-
|
|
19
|
-
credential
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
17
|
+
let token = options.token;
|
|
18
|
+
if (typeof token !== "string") {
|
|
19
|
+
let credential;
|
|
20
|
+
try {
|
|
21
|
+
credential = await loadStoredCredential();
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
if (!credential) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
token = credential.token;
|
|
26
30
|
}
|
|
27
31
|
try {
|
|
28
|
-
const summary = await getGithubUserSummary(
|
|
32
|
+
const summary = await getGithubUserSummary(token, {
|
|
29
33
|
timeoutMs: options.timeoutMs ?? 4_000
|
|
30
34
|
});
|
|
31
35
|
if (!summary.login) {
|
package/dist/cli/agentEnv.js
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
import { computeAnthropicDefaults, readModelIdsFromCache } from "../models/anthropicDefaults.js";
|
|
2
|
+
import { claudeConfigDir, piAgentDir } from "../config/home.js";
|
|
2
3
|
export function buildClaudeEnvBundle(input) {
|
|
3
4
|
const defaults = input.defaults ?? computeAnthropicDefaults(readModelIdsFromCache());
|
|
4
5
|
const enableGateway = input.enableGatewayDiscovery !== false;
|
|
6
|
+
const prefix = input.pathPrefix ?? "";
|
|
5
7
|
const env = {
|
|
6
|
-
ANTHROPIC_BASE_URL: `http://127.0.0.1:${input.port}/anthropic`,
|
|
7
|
-
ANTHROPIC_AUTH_TOKEN: input.callerSecret ?? "copillm-local"
|
|
8
|
+
ANTHROPIC_BASE_URL: `http://127.0.0.1:${input.port}${prefix}/anthropic`,
|
|
9
|
+
ANTHROPIC_AUTH_TOKEN: input.callerSecret ?? "copillm-local",
|
|
10
|
+
// Point Claude at a copillm-owned config home so copillm-launched Claude
|
|
11
|
+
// never reads/writes the user's real ~/.claude (and dev mode isolates it).
|
|
12
|
+
CLAUDE_CONFIG_DIR: claudeConfigDir()
|
|
8
13
|
};
|
|
9
14
|
const trailingNotes = [];
|
|
10
15
|
if (defaults.opus) {
|
|
@@ -38,18 +43,19 @@ export function buildCodexEnvBundle(absHomeDir) {
|
|
|
38
43
|
};
|
|
39
44
|
}
|
|
40
45
|
/**
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
+
* pi reads its config from `<PI_CODING_AGENT_DIR>/models.json`. copillm owns
|
|
47
|
+
* that directory (see `piAgentDir()` in src/config/home.ts) and exports
|
|
48
|
+
* `PI_CODING_AGENT_DIR` so the launched pi reads the catalog copillm just wrote
|
|
49
|
+
* there — never the user's real `~/.pi`. This is also what makes dev mode
|
|
50
|
+
* isolate pi for free (the dev COPILLM_HOME relocates the agent dir).
|
|
46
51
|
*/
|
|
47
52
|
export function buildPiEnvBundle(absMirrorDir) {
|
|
53
|
+
const agentDir = piAgentDir();
|
|
48
54
|
return {
|
|
49
|
-
env: {},
|
|
55
|
+
env: { PI_CODING_AGENT_DIR: agentDir },
|
|
50
56
|
inlineComments: {},
|
|
51
57
|
trailingNotes: [
|
|
52
|
-
`pi reads
|
|
58
|
+
`pi reads ${agentDir}/models.json (copillm sets PI_CODING_AGENT_DIR).`,
|
|
53
59
|
`copillm regenerated it on \`copillm start\` and mirrored it at ${absMirrorDir}/models.json.`
|
|
54
60
|
]
|
|
55
61
|
};
|