copillm 0.2.9 → 0.3.0-beta.2

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.
@@ -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,33 +257,13 @@ export async function loadStoredCredential() {
204
257
  }
205
258
  return { token, accountType: "individual", source: "keyring" };
206
259
  }
207
- /**
208
- * Coalesced inspect + load for status surfaces. Returns the same fields
209
- * `inspectStoredCredential` exposes (`stored` + `backend`) AND the
210
- * `token` but performs only ONE backend probe.
211
- *
212
- * Previously, `auth status` with user-lookup enabled did:
213
- * 1. `inspectStoredCredential` → one `keyring.getPassword` (backend probe)
214
- * 2. `loadStoredCredential` (inside `inspectGithubIdentity`) → another
215
- * `keyring.getPassword` (full token read)
216
- *
217
- * On macOS, each call is its own keychain audit-log entry and (on a
218
- * misconfigured system) its own permission prompt. This helper folds both
219
- * into a single backend probe + single read.
220
- *
221
- * SECURITY: callers MUST treat the `token` field as sensitive — do not log,
222
- * print, or persist it. The status JSON output and `formatHumanAuthStatusLine`
223
- * only consume `backend` (and the upstream identity summary returned by
224
- * `inspectGithubIdentity`), never `token` directly. Enforced at the call
225
- * site (`tests/integration/authStatusCli.test.ts` runs a substring-leak
226
- * guard on the printed output).
227
- */
228
- export async function loadStoredCredentialForStatus() {
229
- if (sessionCredential) {
230
- return { stored: true, backend: "session", token: sessionCredential.token };
260
+ async function loadStorageForStatus(storage) {
261
+ const session = sessionCredentials.get(storage.sessionKey);
262
+ if (session) {
263
+ return { stored: true, backend: "session", token: session.token };
231
264
  }
232
- if (fs.existsSync(credentialsReadPath())) {
233
- const parsed = parseCredentialFile();
265
+ if (fs.existsSync(storage.fileReadPath)) {
266
+ const parsed = parseCredentialFile(storage.fileReadPath);
234
267
  return { stored: true, backend: "file", token: parsed.token };
235
268
  }
236
269
  const { keyring } = await resolveKeyring();
@@ -238,7 +271,7 @@ export async function loadStoredCredentialForStatus() {
238
271
  return { stored: false, backend: null, token: null };
239
272
  }
240
273
  try {
241
- const token = await keyring.getPassword(SERVICE, ACCOUNT);
274
+ const token = await keyring.getPassword(SERVICE, storage.keychainAccount);
242
275
  if (token) {
243
276
  return { stored: true, backend: "keyring", token };
244
277
  }
@@ -251,21 +284,20 @@ export async function loadStoredCredentialForStatus() {
251
284
  throw new Error("Failed to read token from OS keychain.");
252
285
  }
253
286
  }
254
- export async function saveStoredCredential(token, accountType, options = {}) {
255
- const mode = options.mode ?? "auto";
287
+ async function saveStorage(storage, token, accountType, mode) {
256
288
  if (mode === "session") {
257
- sessionCredential = { token, accountType };
289
+ sessionCredentials.set(storage.sessionKey, { token, accountType });
258
290
  return "session";
259
291
  }
260
- if (fs.existsSync(credentialsReadPath())) {
261
- parseCredentialFile();
262
- writeCredentialFile(token, accountType);
292
+ if (fs.existsSync(storage.fileReadPath)) {
293
+ parseCredentialFile(storage.fileReadPath);
294
+ writeCredentialFile(storage.filePath, token, accountType);
263
295
  return "file";
264
296
  }
265
297
  const { keyring, reason } = await resolveKeyring();
266
298
  if (keyring) {
267
299
  try {
268
- await keyring.setPassword(SERVICE, ACCOUNT, token);
300
+ await keyring.setPassword(SERVICE, storage.keychainAccount, token);
269
301
  return "keyring";
270
302
  }
271
303
  catch (error) {
@@ -278,15 +310,14 @@ export async function saveStoredCredential(token, accountType, options = {}) {
278
310
  if (!canUsePlaintextFallback()) {
279
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.`);
280
312
  }
281
- writeCredentialFile(token, accountType);
313
+ writeCredentialFile(storage.filePath, token, accountType);
282
314
  return "file";
283
315
  }
284
- export async function clearStoredCredential() {
316
+ async function clearStorage(storage) {
285
317
  // Always clear in-memory session token first; it shadows other backends.
286
- const hadSession = sessionCredential !== null;
287
- sessionCredential = null;
288
- const readablePath = credentialsReadPath();
289
- const canonicalPath = credentialsPath();
318
+ const hadSession = sessionCredentials.delete(storage.sessionKey);
319
+ const readablePath = storage.fileReadPath;
320
+ const canonicalPath = storage.filePath;
290
321
  if (fs.existsSync(readablePath)) {
291
322
  fs.unlinkSync(readablePath);
292
323
  if (readablePath !== canonicalPath && fs.existsSync(canonicalPath)) {
@@ -297,7 +328,7 @@ export async function clearStoredCredential() {
297
328
  const { keyring, reason } = await resolveKeyring();
298
329
  if (keyring) {
299
330
  try {
300
- const removed = await keyring.deletePassword(SERVICE, ACCOUNT);
331
+ const removed = await keyring.deletePassword(SERVICE, storage.keychainAccount);
301
332
  return { backend: "keyring", removed: removed || hadSession };
302
333
  }
303
334
  catch (error) {
@@ -312,8 +343,106 @@ export async function clearStoredCredential() {
312
343
  }
313
344
  throw new Error(`No credential backend available to clear credentials (${reason ?? "unknown reason"}).`);
314
345
  }
315
- // 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
316
445
  // the package surface for end users — only used by unit tests.
317
446
  export function __resetSessionCredentialForTests() {
318
- sessionCredential = null;
447
+ sessionCredentials.clear();
319
448
  }
@@ -3,8 +3,9 @@ import { claudeConfigDir, piAgentDir } from "../config/home.js";
3
3
  export function buildClaudeEnvBundle(input) {
4
4
  const defaults = input.defaults ?? computeAnthropicDefaults(readModelIdsFromCache());
5
5
  const enableGateway = input.enableGatewayDiscovery !== false;
6
+ const prefix = input.pathPrefix ?? "";
6
7
  const env = {
7
- ANTHROPIC_BASE_URL: `http://127.0.0.1:${input.port}/anthropic`,
8
+ ANTHROPIC_BASE_URL: `http://127.0.0.1:${input.port}${prefix}/anthropic`,
8
9
  ANTHROPIC_AUTH_TOKEN: input.callerSecret ?? "copillm-local",
9
10
  // Point Claude at a copillm-owned config home so copillm-launched Claude
10
11
  // never reads/writes the user's real ~/.claude (and dev mode isolates it).