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.
Files changed (50) hide show
  1. package/README.md +70 -2
  2. package/dist/agentconfig/load.js +9 -1
  3. package/dist/agentconfig/render.js +8 -5
  4. package/dist/agentconfig/schema.js +6 -0
  5. package/dist/auth/accountManager.js +118 -0
  6. package/dist/auth/accounts.js +161 -0
  7. package/dist/auth/copilotToken.js +92 -23
  8. package/dist/auth/credentials.js +216 -40
  9. package/dist/auth/deviceFlow.js +110 -23
  10. package/dist/auth/githubIdentity.js +14 -10
  11. package/dist/cli/agentEnv.js +15 -9
  12. package/dist/cli/auth/runAuth.js +206 -9
  13. package/dist/cli/commands/agents/claude.js +22 -2
  14. package/dist/cli/commands/agents/codex.js +22 -2
  15. package/dist/cli/commands/agents/copilot.js +25 -4
  16. package/dist/cli/commands/agents/pi.js +22 -2
  17. package/dist/cli/commands/agents/shared.js +57 -0
  18. package/dist/cli/commands/auth.js +58 -7
  19. package/dist/cli/commands/daemon.js +79 -17
  20. package/dist/cli/commands/models.js +0 -5
  21. package/dist/cli/copillmFlags.js +8 -0
  22. package/dist/cli/daemon/lifecycle.js +26 -0
  23. package/dist/cli/daemon/probes.js +99 -33
  24. package/dist/cli/daemon/runDaemon.js +21 -2
  25. package/dist/cli/index.js +12 -0
  26. package/dist/cli/integrations/claudeExport.js +6 -4
  27. package/dist/cli/integrations/refreshCodex.js +5 -2
  28. package/dist/cli/integrations/refreshPi.js +5 -2
  29. package/dist/cli/packageInfo.js +1 -1
  30. package/dist/cli/shared/devMode.js +98 -0
  31. package/dist/config/accountId.js +44 -0
  32. package/dist/config/config.js +13 -2
  33. package/dist/config/home.js +69 -0
  34. package/dist/integrations/claude/cache.js +5 -2
  35. package/dist/integrations/claude/settingsConflict.js +5 -2
  36. package/dist/integrations/codex/init.js +31 -10
  37. package/dist/integrations/pi/init.js +8 -17
  38. package/dist/models/anthropicDefaults.js +13 -4
  39. package/dist/models/discovery.js +141 -15
  40. package/dist/server/accountResolver.js +85 -0
  41. package/dist/server/debugInfo.js +69 -24
  42. package/dist/server/errors.js +18 -0
  43. package/dist/server/proxy.js +40 -8
  44. package/dist/server/routes/debug.js +11 -1
  45. package/dist/server/routes/models.js +12 -6
  46. package/dist/server/routes/proxyForward.js +3 -3
  47. package/dist/server/routes/shared.js +66 -21
  48. package/dist/server/upstream/copilotClient.js +1 -30
  49. package/dist/server/upstream/retryPolicy.js +99 -0
  50. package/package.json +4 -1
@@ -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
- const ACCOUNT = "github-oauth-token";
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 credential. Only populated when SaveMode === "session".
16
- // Never persisted; cleared on clearStoredCredential() and on process exit.
17
- let sessionCredential = null;
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 path = credentialsReadPath();
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 ${path}: ${error.message}`);
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 ${path}.`);
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 ${path}: ${error.message}`);
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 ${path}.`);
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(credentialsPath(), JSON.stringify(payload, null, 2), 0o600);
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
- export async function inspectStoredCredential() {
156
- if (sessionCredential) {
207
+ async function inspectStorage(storage) {
208
+ if (sessionCredentials.has(storage.sessionKey)) {
157
209
  return { stored: true, backend: "session" };
158
210
  }
159
- if (fs.existsSync(credentialsReadPath())) {
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, ACCOUNT);
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
- export async function loadStoredCredential() {
181
- if (sessionCredential) {
182
- return { token: sessionCredential.token, accountType: sessionCredential.accountType, source: "session" };
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(credentialsReadPath())) {
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, ACCOUNT);
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
- export async function saveStoredCredential(token, accountType, options = {}) {
208
- const mode = options.mode ?? "auto";
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
- sessionCredential = { token, accountType };
289
+ sessionCredentials.set(storage.sessionKey, { token, accountType });
211
290
  return "session";
212
291
  }
213
- if (fs.existsSync(credentialsReadPath())) {
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, ACCOUNT, token);
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
- export async function clearStoredCredential() {
316
+ async function clearStorage(storage) {
238
317
  // Always clear in-memory session token first; it shadows other backends.
239
- const hadSession = sessionCredential !== null;
240
- sessionCredential = null;
241
- const readablePath = credentialsReadPath();
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, ACCOUNT);
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
- // Test seam: forcibly clear the in-memory session credential. Not exported via
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
- sessionCredential = null;
447
+ sessionCredentials.clear();
272
448
  }
@@ -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
- export async function loginViaDeviceFlow() {
5
- const start = await fetch(DEVICE_CODE_URL, {
6
- method: "POST",
7
- headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
8
- body: new URLSearchParams({ client_id: GITHUB_CLIENT_ID, scope: "read:user" })
9
- });
10
- if (!start.ok) {
11
- throw new Error(`Device flow init failed (${start.status}).`);
12
- }
13
- const payload = parseDeviceCodeResponse((await start.json()));
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
- process.stdout.write(`Open ${verificationUrl} and enter code ${payload.user_code}\n`);
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 sleep(intervalMs);
20
- const poll = await fetch(ACCESS_TOKEN_URL, {
21
- method: "POST",
22
- headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
23
- body: new URLSearchParams({
24
- client_id: GITHUB_CLIENT_ID,
25
- device_code: payload.device_code,
26
- grant_type: "urn:ietf:params:oauth:grant-type:device_code"
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
- function sleep(ms) {
55
- return new Promise((resolve) => setTimeout(resolve, ms));
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 credential;
18
- try {
19
- credential = await loadStoredCredential();
20
- }
21
- catch {
22
- return null;
23
- }
24
- if (!credential) {
25
- return null;
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(credential.token, {
32
+ const summary = await getGithubUserSummary(token, {
29
33
  timeoutMs: options.timeoutMs ?? 4_000
30
34
  });
31
35
  if (!summary.login) {
@@ -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
- * Pi has no environment-variable override for its config directory; it reads
42
- * `~/.pi/agent/models.json` unconditionally. So this bundle is intentionally
43
- * empty the real configuration work happens in `generatePiHome()` writing
44
- * that file. We expose the helper for symmetry with the other agents and to
45
- * carry a trailing note explaining what to look at when debugging.
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 ~/.pi/agent/models.json directly (no env var override).`,
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
  };