copillm 0.2.9 → 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 +69 -1
- package/dist/agentconfig/load.js +9 -1
- package/dist/agentconfig/schema.js +6 -0
- package/dist/auth/accountManager.js +118 -0
- package/dist/auth/accounts.js +161 -0
- package/dist/auth/credentials.js +196 -67
- package/dist/cli/agentEnv.js +2 -1
- 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 +27 -1
- package/dist/cli/copillmFlags.js +8 -0
- package/dist/cli/daemon/runDaemon.js +21 -2
- package/dist/cli/integrations/claudeExport.js +6 -4
- package/dist/cli/integrations/refreshCodex.js +4 -2
- package/dist/cli/integrations/refreshPi.js +4 -2
- package/dist/cli/packageInfo.js +1 -1
- package/dist/config/accountId.js +44 -0
- package/dist/config/home.js +35 -0
- package/dist/integrations/codex/init.js +12 -3
- package/dist/integrations/pi/init.js +4 -3
- package/dist/models/anthropicDefaults.js +13 -4
- package/dist/models/discovery.js +32 -10
- package/dist/server/accountResolver.js +85 -0
- package/dist/server/proxy.js +40 -8
- package/dist/server/routes/debug.js +7 -0
- package/dist/server/routes/models.js +5 -5
- package/dist/server/routes/proxyForward.js +3 -3
- package/dist/server/routes/shared.js +66 -21
- package/package.json +1 -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,33 +257,13 @@ export async function loadStoredCredential() {
|
|
|
204
257
|
}
|
|
205
258
|
return { token, accountType: "individual", source: "keyring" };
|
|
206
259
|
}
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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(
|
|
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,
|
|
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
|
-
|
|
255
|
-
const mode = options.mode ?? "auto";
|
|
287
|
+
async function saveStorage(storage, token, accountType, mode) {
|
|
256
288
|
if (mode === "session") {
|
|
257
|
-
|
|
289
|
+
sessionCredentials.set(storage.sessionKey, { token, accountType });
|
|
258
290
|
return "session";
|
|
259
291
|
}
|
|
260
|
-
if (fs.existsSync(
|
|
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,
|
|
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
|
-
|
|
316
|
+
async function clearStorage(storage) {
|
|
285
317
|
// Always clear in-memory session token first; it shadows other backends.
|
|
286
|
-
const hadSession =
|
|
287
|
-
|
|
288
|
-
const
|
|
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,
|
|
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
|
-
//
|
|
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
|
-
|
|
447
|
+
sessionCredentials.clear();
|
|
319
448
|
}
|
package/dist/cli/agentEnv.js
CHANGED
|
@@ -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).
|
package/dist/cli/auth/runAuth.js
CHANGED
|
@@ -1,26 +1,93 @@
|
|
|
1
|
-
import { clearStoredCredential, saveStoredCredential } from "../../auth/credentials.js";
|
|
1
|
+
import { clearStoredCredential, loadStoredCredential, loadStoredCredentialForAccount, registerExistingCredentialAsDefault, saveStoredCredential } from "../../auth/credentials.js";
|
|
2
|
+
import { readAccountsIndex, assertValidAccountId, InvalidAccountIdError, UnknownAccountError } from "../../auth/accounts.js";
|
|
3
|
+
import { addAccount, listAccountsDetailed, removeAccountAndCredential, removeAllAccounts, switchDefaultAccount } from "../../auth/accountManager.js";
|
|
2
4
|
import { loginViaDeviceFlow } from "../../auth/deviceFlow.js";
|
|
5
|
+
import { inspectGithubIdentity } from "../../auth/githubIdentity.js";
|
|
3
6
|
import { loadConfig } from "../../config/config.js";
|
|
4
7
|
import { inspectLock, releaseLock } from "../../server/lock.js";
|
|
5
8
|
import { stopByPid } from "../daemon/lifecycle.js";
|
|
6
|
-
import { describeBackend } from "../shared/backends.js";
|
|
9
|
+
import { describeBackend, formatHumanAuthStatusLine } from "../shared/backends.js";
|
|
7
10
|
import { writeCommandOutput } from "../shared/output.js";
|
|
11
|
+
/**
|
|
12
|
+
* Derive a friendly, path-safe account id from the GitHub login behind a token.
|
|
13
|
+
* Returns null when the lookup fails or the login isn't a valid id.
|
|
14
|
+
*/
|
|
15
|
+
async function deriveAccountId(token) {
|
|
16
|
+
let identity;
|
|
17
|
+
try {
|
|
18
|
+
identity = await inspectGithubIdentity({ token });
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
const login = identity?.login;
|
|
24
|
+
if (!login) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
try {
|
|
28
|
+
assertValidAccountId(login);
|
|
29
|
+
return login;
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
8
35
|
export async function runAuthLogin(opts, options) {
|
|
9
36
|
if (options.forceSession) {
|
|
10
37
|
process.env.COPILLM_FORCE_SESSION_BACKEND = "1";
|
|
11
38
|
}
|
|
12
39
|
const config = loadConfig();
|
|
40
|
+
const accountType = opts.accountType ?? config.accountType;
|
|
41
|
+
const namedRequested = typeof opts.as === "string" && opts.as.trim().length > 0;
|
|
42
|
+
if (namedRequested) {
|
|
43
|
+
try {
|
|
44
|
+
assertValidAccountId(opts.as.trim());
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
const message = error instanceof InvalidAccountIdError ? error.message : "invalid account id";
|
|
48
|
+
writeCommandOutput(opts, `Login failed: ${message}`, { status: "error", action: "login", error: message });
|
|
49
|
+
process.exitCode = 1;
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
13
53
|
const token = await loginViaDeviceFlow();
|
|
14
54
|
const saveMode = options.forceSession ? "session" : "auto";
|
|
15
|
-
const
|
|
16
|
-
|
|
55
|
+
const index = readAccountsIndex();
|
|
56
|
+
// Pure single-account login: no index and no explicit name. Preserve the
|
|
57
|
+
// historical behaviour exactly — legacy storage, no accounts index created.
|
|
58
|
+
if (!index && !namedRequested) {
|
|
59
|
+
const backend = await saveStoredCredential(token, accountType, { mode: saveMode });
|
|
60
|
+
writeCommandOutput(opts, `Login succeeded. Credentials stored via ${describeBackend(backend)}.`, {
|
|
61
|
+
status: "ok",
|
|
62
|
+
action: "login",
|
|
63
|
+
credential_backend: backend
|
|
64
|
+
});
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const accountId = namedRequested ? opts.as.trim() : index.defaultAccount;
|
|
68
|
+
// First time we materialize the index via an explicit name: preserve any
|
|
69
|
+
// pre-existing single account as the default so its token isn't clobbered.
|
|
70
|
+
if (!index && namedRequested) {
|
|
71
|
+
const existing = await loadStoredCredential();
|
|
72
|
+
if (existing) {
|
|
73
|
+
const existingId = (await deriveAccountId(existing.token)) ?? "default";
|
|
74
|
+
if (existingId !== accountId) {
|
|
75
|
+
registerExistingCredentialAsDefault(existingId, existing.accountType);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const result = await addAccount({ id: accountId, accountType, token, mode: saveMode });
|
|
80
|
+
const defaultSuffix = result.isDefault ? " (default)" : "";
|
|
81
|
+
writeCommandOutput(opts, `Login succeeded for account "${result.id}"${defaultSuffix}. Credentials stored via ${describeBackend(result.backend)}.`, {
|
|
17
82
|
status: "ok",
|
|
18
83
|
action: "login",
|
|
19
|
-
|
|
84
|
+
account: result.id,
|
|
85
|
+
account_type: result.accountType,
|
|
86
|
+
is_default: result.isDefault,
|
|
87
|
+
credential_backend: result.backend
|
|
20
88
|
});
|
|
21
89
|
}
|
|
22
|
-
|
|
23
|
-
const result = await clearStoredCredential();
|
|
90
|
+
async function stopRunningDaemon() {
|
|
24
91
|
const lockState = inspectLock();
|
|
25
92
|
if (lockState.state === "running") {
|
|
26
93
|
await stopByPid(lockState.lock.pid);
|
|
@@ -28,11 +95,141 @@ export async function runAuthLogout(opts) {
|
|
|
28
95
|
else if (lockState.state === "stale") {
|
|
29
96
|
releaseLock();
|
|
30
97
|
}
|
|
98
|
+
}
|
|
99
|
+
export async function runAuthLogout(opts) {
|
|
100
|
+
// Stopping the daemon is always part of logout — its in-memory bearers are
|
|
101
|
+
// derived from the credentials we're clearing.
|
|
102
|
+
if (opts.all) {
|
|
103
|
+
const result = await removeAllAccounts();
|
|
104
|
+
await stopRunningDaemon();
|
|
105
|
+
writeCommandOutput(opts, `Logged out of all accounts (${result.clearedCount} credential(s) cleared).`, {
|
|
106
|
+
status: "ok",
|
|
107
|
+
action: "logout",
|
|
108
|
+
scope: "all",
|
|
109
|
+
cleared_count: result.clearedCount,
|
|
110
|
+
removed_accounts: result.removedAccountIds
|
|
111
|
+
});
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const index = readAccountsIndex();
|
|
115
|
+
// Single-account install (no index) and no explicit target: preserve the
|
|
116
|
+
// historical single-account logout behaviour.
|
|
117
|
+
if (!index && !opts.account) {
|
|
118
|
+
const result = await clearStoredCredential();
|
|
119
|
+
await stopRunningDaemon();
|
|
120
|
+
const credentialStatus = result.removed ? "removed" : "not present";
|
|
121
|
+
writeCommandOutput(opts, `Logged out. Credentials ${credentialStatus} from ${describeBackend(result.backend)}.`, {
|
|
122
|
+
status: "ok",
|
|
123
|
+
action: "logout",
|
|
124
|
+
credential_backend: result.backend,
|
|
125
|
+
credential_removed: result.removed
|
|
126
|
+
});
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (opts.account) {
|
|
130
|
+
try {
|
|
131
|
+
assertValidAccountId(opts.account);
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
const message = error instanceof InvalidAccountIdError ? error.message : "invalid account id";
|
|
135
|
+
writeCommandOutput(opts, `Logout failed: ${message}`, { status: "error", action: "logout", error: message });
|
|
136
|
+
process.exitCode = 1;
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const targetId = opts.account ?? index.defaultAccount;
|
|
141
|
+
const result = await removeAccountAndCredential(targetId);
|
|
142
|
+
await stopRunningDaemon();
|
|
31
143
|
const credentialStatus = result.removed ? "removed" : "not present";
|
|
32
|
-
|
|
144
|
+
const tail = result.indexDeleted
|
|
145
|
+
? " No accounts remain."
|
|
146
|
+
: result.newDefault
|
|
147
|
+
? ` Default is now "${result.newDefault}".`
|
|
148
|
+
: "";
|
|
149
|
+
writeCommandOutput(opts, `Logged out of account "${result.id}". Credentials ${credentialStatus} from ${describeBackend(result.backend)}.${tail}`, {
|
|
33
150
|
status: "ok",
|
|
34
151
|
action: "logout",
|
|
152
|
+
account: result.id,
|
|
35
153
|
credential_backend: result.backend,
|
|
36
|
-
credential_removed: result.removed
|
|
154
|
+
credential_removed: result.removed,
|
|
155
|
+
new_default: result.newDefault,
|
|
156
|
+
index_deleted: result.indexDeleted
|
|
37
157
|
});
|
|
38
158
|
}
|
|
159
|
+
export async function runAuthSwitch(opts, accountId) {
|
|
160
|
+
try {
|
|
161
|
+
assertValidAccountId(accountId);
|
|
162
|
+
const index = switchDefaultAccount(accountId);
|
|
163
|
+
writeCommandOutput(opts, `Default account is now "${index.defaultAccount}".`, {
|
|
164
|
+
status: "ok",
|
|
165
|
+
action: "switch",
|
|
166
|
+
default_account: index.defaultAccount
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
catch (error) {
|
|
170
|
+
const message = error instanceof UnknownAccountError
|
|
171
|
+
? `Unknown account "${accountId}". Run \`copillm auth status\` to list accounts.`
|
|
172
|
+
: error instanceof InvalidAccountIdError
|
|
173
|
+
? error.message
|
|
174
|
+
: error instanceof Error
|
|
175
|
+
? error.message
|
|
176
|
+
: "switch failed";
|
|
177
|
+
writeCommandOutput(opts, `Switch failed: ${message}`, { status: "error", action: "switch", error: message });
|
|
178
|
+
process.exitCode = 1;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Multi-account `auth status` listing (used when an accounts index exists).
|
|
183
|
+
* Returns whether any account has a stored credential so the caller can pick
|
|
184
|
+
* the process exit code. Never prints a token.
|
|
185
|
+
*/
|
|
186
|
+
export async function runAuthStatusList(opts) {
|
|
187
|
+
const wantUser = opts.user !== false;
|
|
188
|
+
const listing = await listAccountsDetailed();
|
|
189
|
+
const anyStored = listing.accounts.some((account) => account.stored);
|
|
190
|
+
const enriched = await Promise.all(listing.accounts.map(async (account) => {
|
|
191
|
+
let login = null;
|
|
192
|
+
let name = null;
|
|
193
|
+
if (wantUser && account.stored) {
|
|
194
|
+
try {
|
|
195
|
+
const credential = await loadStoredCredentialForAccount(account.id);
|
|
196
|
+
if (credential) {
|
|
197
|
+
const identity = await inspectGithubIdentity({ token: credential.token });
|
|
198
|
+
login = identity?.login ?? null;
|
|
199
|
+
name = identity?.name ?? null;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
login = null;
|
|
204
|
+
name = null;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return { ...account, login, name };
|
|
208
|
+
}));
|
|
209
|
+
if (opts.json) {
|
|
210
|
+
process.stdout.write(JSON.stringify({
|
|
211
|
+
status: anyStored ? "logged_in" : "logged_out",
|
|
212
|
+
default: listing.defaultAccount,
|
|
213
|
+
accounts: enriched.map((account) => ({
|
|
214
|
+
id: account.id,
|
|
215
|
+
account_type: account.accountType,
|
|
216
|
+
storage: account.storage,
|
|
217
|
+
default: account.isDefault,
|
|
218
|
+
stored: account.stored,
|
|
219
|
+
backend: account.backend,
|
|
220
|
+
user: account.login ? { login: account.login, name: account.name } : null
|
|
221
|
+
}))
|
|
222
|
+
}, null, 2) + "\n");
|
|
223
|
+
return { anyStored };
|
|
224
|
+
}
|
|
225
|
+
process.stdout.write(`copillm — ${enriched.length} account(s)\n`);
|
|
226
|
+
for (const account of enriched) {
|
|
227
|
+
const marker = account.isDefault ? "*" : " ";
|
|
228
|
+
const who = account.login ? ` @${account.login}` : "";
|
|
229
|
+
const state = account.stored
|
|
230
|
+
? formatHumanAuthStatusLine(account.backend, account.login ? { login: account.login, name: account.name } : null)
|
|
231
|
+
: "no credential";
|
|
232
|
+
process.stdout.write(`${marker} ${account.id} [${account.accountType}]${who} — ${state}\n`);
|
|
233
|
+
}
|
|
234
|
+
return { anyStored };
|
|
235
|
+
}
|