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/README.md
CHANGED
|
@@ -28,6 +28,18 @@ Alternatively, you can invoke it directly with `npx` without a global install:
|
|
|
28
28
|
npx copillm --help
|
|
29
29
|
```
|
|
30
30
|
|
|
31
|
+
### Preview (beta) releases
|
|
32
|
+
|
|
33
|
+
Experimental builds are published to the `beta` channel ahead of a stable
|
|
34
|
+
release. They let you try in-progress features early; expect rough edges. Stable
|
|
35
|
+
installs are never affected unless you explicitly opt in:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
npm install -g copillm@beta
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
To return to the stable channel, reinstall without the tag: `npm install -g copillm@latest`.
|
|
42
|
+
|
|
31
43
|
## Quick start
|
|
32
44
|
|
|
33
45
|
```bash
|
|
@@ -48,6 +60,62 @@ copillm claude --model opus
|
|
|
48
60
|
copillm codex --help
|
|
49
61
|
```
|
|
50
62
|
|
|
63
|
+
## Multiple accounts
|
|
64
|
+
|
|
65
|
+
copillm can hold more than one GitHub account at once and serve them from the
|
|
66
|
+
same daemon. If you only ever use one account, nothing changes — you never see
|
|
67
|
+
any of this.
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
# Your first login is the default account (no naming needed).
|
|
71
|
+
copillm auth login
|
|
72
|
+
|
|
73
|
+
# Add another account under a name of your choice.
|
|
74
|
+
copillm auth login --as work
|
|
75
|
+
copillm auth login --as work --account-type business # set its plan type
|
|
76
|
+
|
|
77
|
+
# See every account; the default is marked with *.
|
|
78
|
+
copillm auth status
|
|
79
|
+
|
|
80
|
+
# Change which account is the default.
|
|
81
|
+
copillm auth switch work
|
|
82
|
+
|
|
83
|
+
# Log out of one account, or all of them.
|
|
84
|
+
copillm auth logout --account work
|
|
85
|
+
copillm auth logout --all
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
The **default account** is what every agent and the model endpoints use unless
|
|
89
|
+
told otherwise. `copillm auth status` lists each account with its plan type and
|
|
90
|
+
whether a credential is stored; tokens are never printed.
|
|
91
|
+
|
|
92
|
+
Different accounts can be entitled to different models, so each account keeps
|
|
93
|
+
its own model list.
|
|
94
|
+
|
|
95
|
+
### Launching an agent against a specific account
|
|
96
|
+
|
|
97
|
+
Point any agent at a non-default account for a single launch with `--account`,
|
|
98
|
+
or set `COPILLM_ACCOUNT` in the environment:
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
copillm codex --account work
|
|
102
|
+
COPILLM_ACCOUNT=work copillm claude
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
To make it automatic, pin an account to a profile in `~/.copillm/agent.toml`
|
|
106
|
+
(or a project's `.copillm/agent.toml`):
|
|
107
|
+
|
|
108
|
+
```toml
|
|
109
|
+
[profiles.work]
|
|
110
|
+
account = "work"
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Then `copillm codex --profile work` always uses the `work` account. Precedence
|
|
114
|
+
is `--account` > `COPILLM_ACCOUNT` > the profile's pinned account > the default
|
|
115
|
+
account. copillm prints a short notice such as `using account "work" (from
|
|
116
|
+
profile)` so you always know which account a launch is using, and refuses to
|
|
117
|
+
launch with a clear error if the account isn't one you've logged into.
|
|
118
|
+
|
|
51
119
|
## Documentation
|
|
52
120
|
|
|
53
121
|
Full documentation is published at **[jcjc-dev.github.io/copillm](https://jcjc-dev.github.io/copillm/)**.
|
|
@@ -55,7 +123,7 @@ Full documentation is published at **[jcjc-dev.github.io/copillm](https://jcjc-d
|
|
|
55
123
|
| Topic | Description |
|
|
56
124
|
| --- | --- |
|
|
57
125
|
| [Getting started](https://jcjc-dev.github.io/copillm/getting-started/) | Installation, authentication, and first run |
|
|
58
|
-
| [CLI reference](https://jcjc-dev.github.io/copillm/
|
|
126
|
+
| [CLI reference](https://jcjc-dev.github.io/copillm/commands/) | Commands and flags |
|
|
59
127
|
| [Using with Claude Code](https://jcjc-dev.github.io/copillm/claude-code/) | Environment wiring, gateway discovery, the `[1m]` 1M-context alias |
|
|
60
128
|
| [Using with Codex CLI](https://jcjc-dev.github.io/copillm/codex/) | Environment wiring and `config.toml` generation |
|
|
61
129
|
| [HTTP API reference](https://jcjc-dev.github.io/copillm/http-api/) | Endpoints and translation behaviour |
|
|
@@ -63,7 +131,7 @@ Full documentation is published at **[jcjc-dev.github.io/copillm](https://jcjc-d
|
|
|
63
131
|
|
|
64
132
|
## Contributing
|
|
65
133
|
|
|
66
|
-
Bug reports and pull requests are welcome.
|
|
134
|
+
Bug reports and pull requests are welcome. Develop against an isolated dev daemon (`npm run dev:start`) so you don't disturb a running copillm, and run `npm run lint && npm test && npm run test:e2e:pr` before opening a pull request. See the [development guide](https://jcjc-dev.github.io/copillm/development/) for the full workflow.
|
|
67
135
|
|
|
68
136
|
## Disclaimer
|
|
69
137
|
|
package/dist/agentconfig/load.js
CHANGED
|
@@ -97,6 +97,14 @@ function mergeAndResolve(input) {
|
|
|
97
97
|
const instructions = instructionsBody !== null && instructionsBody.trim().length > 0
|
|
98
98
|
? { body: instructionsBody }
|
|
99
99
|
: null;
|
|
100
|
+
// Merge the pinned account: later layers (project over global, profile over
|
|
101
|
+
// defaults) win. Empty string is treated as unset.
|
|
102
|
+
let account = null;
|
|
103
|
+
for (const layer of layers) {
|
|
104
|
+
if (layer.account !== undefined && layer.account.trim().length > 0) {
|
|
105
|
+
account = layer.account.trim();
|
|
106
|
+
}
|
|
107
|
+
}
|
|
100
108
|
// Merge mcp.servers map; later layers replace earlier same-named entries.
|
|
101
109
|
// Defaults are always-on: a profile may override a default by name but
|
|
102
110
|
// cannot remove it.
|
|
@@ -114,7 +122,7 @@ function mergeAndResolve(input) {
|
|
|
114
122
|
permissions: mergeRecord(layers, "permissions")
|
|
115
123
|
};
|
|
116
124
|
const yolo = mergeYolo(layers);
|
|
117
|
-
return { instructions, mcpServers: servers, yolo, reserved };
|
|
125
|
+
return { instructions, mcpServers: servers, account, yolo, reserved };
|
|
118
126
|
}
|
|
119
127
|
/**
|
|
120
128
|
* Layer yolo blocks across defaults + active profile. Later layers (project
|
|
@@ -3,7 +3,7 @@ import os from "node:os";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { parse as parseToml, stringify as stringifyToml, TomlError } from "smol-toml";
|
|
5
5
|
import { AgentConfigError } from "./load.js";
|
|
6
|
-
import { getCopillmHome } from "../config/home.js";
|
|
6
|
+
import { getCopillmHome, piAgentDir } from "../config/home.js";
|
|
7
7
|
import { HASH_COMMENT, HTML_COMMENT, upsertManagedBlock } from "./markerBlock.js";
|
|
8
8
|
// ─── Codex ────────────────────────────────────────────────────────────────
|
|
9
9
|
export function renderCodex(input) {
|
|
@@ -292,8 +292,8 @@ const PI_EXTENSION_DIRNAME = "copillm-mcp";
|
|
|
292
292
|
export function renderPi(input) {
|
|
293
293
|
const writes = [];
|
|
294
294
|
const notes = [];
|
|
295
|
-
const
|
|
296
|
-
const extensionDir = path.join(
|
|
295
|
+
const piAgent = piAgentDir();
|
|
296
|
+
const extensionDir = path.join(piAgent, "extensions", PI_EXTENSION_DIRNAME);
|
|
297
297
|
// 1. servers.json — the resolved server list the extension reads at startup.
|
|
298
298
|
const serversJson = renderPiServersJson(input.resolved.mcpServers);
|
|
299
299
|
writes.push({
|
|
@@ -311,7 +311,7 @@ export function renderPi(input) {
|
|
|
311
311
|
});
|
|
312
312
|
// 3. instructions prompt registered by the extension on session_start.
|
|
313
313
|
if (input.resolved.instructions) {
|
|
314
|
-
const promptPath = path.join(
|
|
314
|
+
const promptPath = path.join(piAgent, "prompts", "copillm-profile.md");
|
|
315
315
|
writes.push({
|
|
316
316
|
path: promptPath,
|
|
317
317
|
content: `${input.resolved.instructions.body.trim()}\n`,
|
|
@@ -369,7 +369,10 @@ export default function activate(pi: PiApi): void {
|
|
|
369
369
|
return "copillm-managed MCP servers:\\n" + names.map((n) => " - " + n).join("\\n");
|
|
370
370
|
});
|
|
371
371
|
|
|
372
|
-
|
|
372
|
+
// Resolve the prompt relative to this extension's own directory. copillm
|
|
373
|
+
// owns the pi agent dir (via PI_CODING_AGENT_DIR), and the extension lives at
|
|
374
|
+
// <agentDir>/extensions/<name>/, so the prompt is two levels up under prompts/.
|
|
375
|
+
const promptPath = path.join(__dirname, "..", "..", "prompts", "copillm-profile.md");
|
|
373
376
|
if (fs.existsSync(promptPath) && typeof pi.on === "function") {
|
|
374
377
|
pi.on("session_start", () => {
|
|
375
378
|
try {
|
|
@@ -69,6 +69,12 @@ const SectionSchema = z
|
|
|
69
69
|
instructions: InstructionsSchema.optional(),
|
|
70
70
|
mcp: McpSchema.optional(),
|
|
71
71
|
yolo: YoloSchema.optional(),
|
|
72
|
+
/**
|
|
73
|
+
* Pin a copillm account for launches that use this profile. The launcher
|
|
74
|
+
* routes the agent at this account unless overridden by `--account` /
|
|
75
|
+
* `COPILLM_ACCOUNT`. Must name an account from `copillm auth status`.
|
|
76
|
+
*/
|
|
77
|
+
account: z.string().min(1).optional(),
|
|
72
78
|
// v1 reserved sections: validated as objects but not interpreted.
|
|
73
79
|
skills: PassthroughRecord.optional(),
|
|
74
80
|
agents: PassthroughRecord.optional(),
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { findAccount, readAccountsIndex, removeAccount, setDefaultAccountId, upsertAccount, assertValidAccountId } from "./accounts.js";
|
|
2
|
+
import { clearStoredCredential, clearStoredCredentialForAccount, inspectStoredCredentialForAccount, saveStoredCredentialForAccount } from "./credentials.js";
|
|
3
|
+
/**
|
|
4
|
+
* Add or update an explicitly-identified account, materializing/extending the
|
|
5
|
+
* accounts index, and store its credential.
|
|
6
|
+
*
|
|
7
|
+
* Storage scheme follows the credential-store invariant: the **first** account
|
|
8
|
+
* (no index yet) takes legacy storage so it keeps the original keychain entry /
|
|
9
|
+
* `credentials.json`; every account added afterwards is namespaced. An existing
|
|
10
|
+
* account keeps whatever storage it already has.
|
|
11
|
+
*/
|
|
12
|
+
export async function addAccount(input) {
|
|
13
|
+
assertValidAccountId(input.id);
|
|
14
|
+
const index = readAccountsIndex();
|
|
15
|
+
const existing = findAccount(input.id);
|
|
16
|
+
const storage = existing ? existing.storage : index ? "namespaced" : "legacy";
|
|
17
|
+
upsertAccount({
|
|
18
|
+
id: input.id,
|
|
19
|
+
accountType: input.accountType,
|
|
20
|
+
storage,
|
|
21
|
+
addedAt: existing?.addedAt ?? new Date().toISOString()
|
|
22
|
+
});
|
|
23
|
+
// saveStoredCredentialForAccount resolves storage from the index record we
|
|
24
|
+
// just wrote, so it lands in the right (legacy vs namespaced) location.
|
|
25
|
+
const backend = await saveStoredCredentialForAccount(input.id, input.token, input.accountType, {
|
|
26
|
+
mode: input.mode ?? "auto"
|
|
27
|
+
});
|
|
28
|
+
let isDefault = readAccountsIndex()?.defaultAccount === input.id;
|
|
29
|
+
if (input.makeDefault && !isDefault) {
|
|
30
|
+
setDefaultAccountId(input.id);
|
|
31
|
+
isDefault = true;
|
|
32
|
+
}
|
|
33
|
+
return { id: input.id, accountType: input.accountType, storage, backend, isDefault };
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Detailed, token-free view of every registered account for `auth status`.
|
|
37
|
+
* Returns `hasIndex: false` for single-account installs so the caller can use
|
|
38
|
+
* the legacy single-account output unchanged.
|
|
39
|
+
*/
|
|
40
|
+
export async function listAccountsDetailed() {
|
|
41
|
+
const index = readAccountsIndex();
|
|
42
|
+
if (!index) {
|
|
43
|
+
return { hasIndex: false, defaultAccount: null, accounts: [] };
|
|
44
|
+
}
|
|
45
|
+
const accounts = [];
|
|
46
|
+
for (const record of index.accounts) {
|
|
47
|
+
const info = await inspectStoredCredentialForAccount(record.id);
|
|
48
|
+
accounts.push({
|
|
49
|
+
id: record.id,
|
|
50
|
+
accountType: record.accountType,
|
|
51
|
+
storage: record.storage,
|
|
52
|
+
isDefault: record.id === index.defaultAccount,
|
|
53
|
+
stored: info.stored,
|
|
54
|
+
backend: info.backend
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
return { hasIndex: true, defaultAccount: index.defaultAccount, accounts };
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Remove one account: clear its credential first (while its index record still
|
|
61
|
+
* exists, so the correct storage location is targeted), then drop it from the
|
|
62
|
+
* index (which reassigns the default, or deletes the index when it was the
|
|
63
|
+
* last account). Clearing is best-effort — an absent credential is reported as
|
|
64
|
+
* `removed: false` rather than failing the removal.
|
|
65
|
+
*/
|
|
66
|
+
export async function removeAccountAndCredential(id) {
|
|
67
|
+
assertValidAccountId(id);
|
|
68
|
+
let removed = false;
|
|
69
|
+
let backend = "file";
|
|
70
|
+
try {
|
|
71
|
+
const cleared = await clearStoredCredentialForAccount(id);
|
|
72
|
+
removed = cleared.removed;
|
|
73
|
+
backend = cleared.backend;
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
// No backend available to clear (e.g. nothing was stored). The account is
|
|
77
|
+
// still removed from the index below.
|
|
78
|
+
removed = false;
|
|
79
|
+
}
|
|
80
|
+
const index = removeAccount(id);
|
|
81
|
+
return {
|
|
82
|
+
id,
|
|
83
|
+
removed,
|
|
84
|
+
backend,
|
|
85
|
+
newDefault: index?.defaultAccount ?? null,
|
|
86
|
+
indexDeleted: index === null
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Remove every account and delete the index. For a single-account install (no
|
|
91
|
+
* index) this just clears the legacy credential.
|
|
92
|
+
*/
|
|
93
|
+
export async function removeAllAccounts() {
|
|
94
|
+
const index = readAccountsIndex();
|
|
95
|
+
if (!index) {
|
|
96
|
+
const cleared = await clearStoredCredential();
|
|
97
|
+
return { clearedCount: cleared.removed ? 1 : 0, removedAccountIds: [], indexDeleted: false };
|
|
98
|
+
}
|
|
99
|
+
const ids = index.accounts.map((account) => account.id);
|
|
100
|
+
let clearedCount = 0;
|
|
101
|
+
for (const id of ids) {
|
|
102
|
+
try {
|
|
103
|
+
const cleared = await clearStoredCredentialForAccount(id);
|
|
104
|
+
if (cleared.removed) {
|
|
105
|
+
clearedCount += 1;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
// Best-effort: an account with nothing stored still gets removed.
|
|
110
|
+
}
|
|
111
|
+
removeAccount(id);
|
|
112
|
+
}
|
|
113
|
+
return { clearedCount, removedAccountIds: ids, indexDeleted: true };
|
|
114
|
+
}
|
|
115
|
+
/** Point the default at an existing account. Throws `UnknownAccountError`. */
|
|
116
|
+
export function switchDefaultAccount(id) {
|
|
117
|
+
return setDefaultAccountId(id);
|
|
118
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { ensureAppHome } from "../config/config.js";
|
|
4
|
+
import { accountsIndexPath, accountsIndexReadPath } from "../config/home.js";
|
|
5
|
+
import { writeFileSecureAtomic } from "../config/fsSecurity.js";
|
|
6
|
+
import { ACCOUNT_ID_PATTERN, MAX_ACCOUNT_ID_LENGTH, assertValidAccountId, InvalidAccountIdError } from "../config/accountId.js";
|
|
7
|
+
// Re-exported for callers that historically imported account-id validation
|
|
8
|
+
// from this module (e.g. `credentials.ts`). The canonical definition now lives
|
|
9
|
+
// in `config/accountId.ts` so the `models` layer can share it.
|
|
10
|
+
export { assertValidAccountId, InvalidAccountIdError };
|
|
11
|
+
// GitHub logins are `[A-Za-z0-9-]` and copillm allows `.` / `_` for synthetic
|
|
12
|
+
// ids. The id is embedded in a filename (`credentials.<id>.json`) and a
|
|
13
|
+
// keychain account string; the canonical validation lives in
|
|
14
|
+
// `config/accountId.ts` (shared with the models layer).
|
|
15
|
+
const AccountRecordSchema = z.object({
|
|
16
|
+
id: z.string().min(1).max(MAX_ACCOUNT_ID_LENGTH).regex(ACCOUNT_ID_PATTERN),
|
|
17
|
+
accountType: z.enum(["individual", "business", "enterprise"]),
|
|
18
|
+
storage: z.enum(["legacy", "namespaced"]),
|
|
19
|
+
addedAt: z.string().min(1)
|
|
20
|
+
});
|
|
21
|
+
const AccountsIndexSchema = z
|
|
22
|
+
.object({
|
|
23
|
+
version: z.literal(1),
|
|
24
|
+
defaultAccount: z.string().min(1),
|
|
25
|
+
accounts: z.array(AccountRecordSchema)
|
|
26
|
+
})
|
|
27
|
+
.superRefine((value, ctx) => {
|
|
28
|
+
const ids = value.accounts.map((account) => account.id);
|
|
29
|
+
if (new Set(ids).size !== ids.length) {
|
|
30
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "accounts.json contains duplicate account ids." });
|
|
31
|
+
}
|
|
32
|
+
if (!ids.includes(value.defaultAccount)) {
|
|
33
|
+
ctx.addIssue({
|
|
34
|
+
code: z.ZodIssueCode.custom,
|
|
35
|
+
message: `accounts.json defaultAccount "${value.defaultAccount}" is not present in accounts.`
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
if (value.accounts.filter((account) => account.storage === "legacy").length > 1) {
|
|
39
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "accounts.json may declare at most one legacy-storage account." });
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
/**
|
|
43
|
+
* Read and validate the accounts index. Returns `null` when no index exists
|
|
44
|
+
* (the single-account / legacy case). Throws if the file exists but is
|
|
45
|
+
* corrupt, so a damaged index surfaces loudly rather than silently dropping
|
|
46
|
+
* accounts.
|
|
47
|
+
*/
|
|
48
|
+
export function readAccountsIndex() {
|
|
49
|
+
const path = accountsIndexReadPath();
|
|
50
|
+
if (!fs.existsSync(path)) {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
let raw;
|
|
54
|
+
try {
|
|
55
|
+
raw = JSON.parse(fs.readFileSync(path, "utf8"));
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
const detail = error instanceof Error ? error.message : "unknown error";
|
|
59
|
+
throw new Error(`Accounts index exists but contains invalid JSON at ${path}: ${detail}`);
|
|
60
|
+
}
|
|
61
|
+
const parsed = AccountsIndexSchema.safeParse(raw);
|
|
62
|
+
if (!parsed.success) {
|
|
63
|
+
throw new Error(`Accounts index exists but is invalid at ${path}: ${parsed.error.issues.map((i) => i.message).join("; ")}`);
|
|
64
|
+
}
|
|
65
|
+
return parsed.data;
|
|
66
|
+
}
|
|
67
|
+
export function writeAccountsIndex(index) {
|
|
68
|
+
const validated = AccountsIndexSchema.parse(index);
|
|
69
|
+
ensureAppHome();
|
|
70
|
+
writeFileSecureAtomic(accountsIndexPath(), JSON.stringify(validated, null, 2), 0o600);
|
|
71
|
+
}
|
|
72
|
+
export function listAccounts() {
|
|
73
|
+
return readAccountsIndex()?.accounts ?? [];
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* The default account id, or `null` when no index exists. A `null` return is
|
|
77
|
+
* the signal to callers to use the legacy single-account storage path.
|
|
78
|
+
*/
|
|
79
|
+
export function getDefaultAccountId() {
|
|
80
|
+
return readAccountsIndex()?.defaultAccount ?? null;
|
|
81
|
+
}
|
|
82
|
+
export function findAccount(accountId) {
|
|
83
|
+
return readAccountsIndex()?.accounts.find((account) => account.id === accountId) ?? null;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Insert or update an account record, then persist the index. When the index
|
|
87
|
+
* does not yet exist it is created with this account as the default. Returns
|
|
88
|
+
* the resulting index.
|
|
89
|
+
*/
|
|
90
|
+
export function upsertAccount(record) {
|
|
91
|
+
assertValidAccountId(record.id);
|
|
92
|
+
AccountRecordSchema.parse(record);
|
|
93
|
+
const existing = readAccountsIndex();
|
|
94
|
+
if (!existing) {
|
|
95
|
+
const index = { version: 1, defaultAccount: record.id, accounts: [record] };
|
|
96
|
+
writeAccountsIndex(index);
|
|
97
|
+
return index;
|
|
98
|
+
}
|
|
99
|
+
const accounts = existing.accounts.filter((account) => account.id !== record.id);
|
|
100
|
+
accounts.push(record);
|
|
101
|
+
const index = { ...existing, accounts };
|
|
102
|
+
writeAccountsIndex(index);
|
|
103
|
+
return index;
|
|
104
|
+
}
|
|
105
|
+
export class UnknownAccountError extends Error {
|
|
106
|
+
accountId;
|
|
107
|
+
constructor(accountId) {
|
|
108
|
+
super(`Unknown account "${accountId}".`);
|
|
109
|
+
this.accountId = accountId;
|
|
110
|
+
this.name = "UnknownAccountError";
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Point the default at an existing account. Throws `UnknownAccountError` if the
|
|
115
|
+
* id isn't registered, so a typo can't silently orphan the default.
|
|
116
|
+
*/
|
|
117
|
+
export function setDefaultAccountId(accountId) {
|
|
118
|
+
const existing = readAccountsIndex();
|
|
119
|
+
if (!existing || !existing.accounts.some((account) => account.id === accountId)) {
|
|
120
|
+
throw new UnknownAccountError(accountId);
|
|
121
|
+
}
|
|
122
|
+
const index = { ...existing, defaultAccount: accountId };
|
|
123
|
+
writeAccountsIndex(index);
|
|
124
|
+
return index;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Remove an account from the index. Returns the updated index, or `null` if no
|
|
128
|
+
* index existed. When the removed account was the default, the default falls
|
|
129
|
+
* back to the first remaining account (or the index is deleted entirely if no
|
|
130
|
+
* accounts remain). Token removal is the caller's responsibility.
|
|
131
|
+
*/
|
|
132
|
+
export function removeAccount(accountId) {
|
|
133
|
+
const existing = readAccountsIndex();
|
|
134
|
+
if (!existing) {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
const accounts = existing.accounts.filter((account) => account.id !== accountId);
|
|
138
|
+
if (accounts.length === existing.accounts.length) {
|
|
139
|
+
return existing;
|
|
140
|
+
}
|
|
141
|
+
if (accounts.length === 0) {
|
|
142
|
+
deleteAccountsIndex();
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
const defaultAccount = accounts.some((account) => account.id === existing.defaultAccount)
|
|
146
|
+
? existing.defaultAccount
|
|
147
|
+
: accounts[0].id;
|
|
148
|
+
const index = { ...existing, defaultAccount, accounts };
|
|
149
|
+
writeAccountsIndex(index);
|
|
150
|
+
return index;
|
|
151
|
+
}
|
|
152
|
+
function deleteAccountsIndex() {
|
|
153
|
+
const canonical = accountsIndexPath();
|
|
154
|
+
if (fs.existsSync(canonical)) {
|
|
155
|
+
fs.unlinkSync(canonical);
|
|
156
|
+
}
|
|
157
|
+
const readable = accountsIndexReadPath();
|
|
158
|
+
if (readable !== canonical && fs.existsSync(readable)) {
|
|
159
|
+
fs.unlinkSync(readable);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
@@ -1,6 +1,10 @@
|
|
|
1
|
+
import { setTimeout as defaultSleep } from "node:timers/promises";
|
|
1
2
|
import { tokenExchangeUrl } from "../config/upstream.js";
|
|
3
|
+
import { isRetryableStatus, isRetryableTransportError, retryDelayMs } from "../server/upstream/retryPolicy.js";
|
|
2
4
|
const DEFAULT_REFRESH_THRESHOLD_SECONDS = 300;
|
|
3
5
|
const MIN_ACCEPTABLE_TTL_SECONDS = 30;
|
|
6
|
+
const DEFAULT_ATTEMPT_TIMEOUT_MS = 10_000;
|
|
7
|
+
const DEFAULT_MAX_ATTEMPTS = 3;
|
|
4
8
|
export class CopilotTokenManagerError extends Error {
|
|
5
9
|
constructor(message) {
|
|
6
10
|
super(message);
|
|
@@ -29,12 +33,29 @@ export class CopilotTokenExpiredError extends CopilotTokenManagerError {
|
|
|
29
33
|
this.name = "CopilotTokenExpiredError";
|
|
30
34
|
}
|
|
31
35
|
}
|
|
36
|
+
/**
|
|
37
|
+
* `CopilotTokenExchangeError` is thrown both for "retryable" upstream statuses
|
|
38
|
+
* (after retries are exhausted) and for terminal credential failures (401/403
|
|
39
|
+
* — bad OAuth token, never retried). Tests and error-mapping code can use
|
|
40
|
+
* this helper to keep the classification consistent across surfaces.
|
|
41
|
+
*/
|
|
42
|
+
export function isTerminalCredentialStatus(status) {
|
|
43
|
+
return status === 401 || status === 403 || status === 404;
|
|
44
|
+
}
|
|
32
45
|
export class CopilotTokenManager {
|
|
33
46
|
githubToken;
|
|
34
47
|
state = null;
|
|
35
48
|
refreshInFlight = null;
|
|
36
|
-
|
|
49
|
+
fetchImpl;
|
|
50
|
+
sleepImpl;
|
|
51
|
+
attemptTimeoutMs;
|
|
52
|
+
maxAttempts;
|
|
53
|
+
constructor(githubToken, deps) {
|
|
37
54
|
this.githubToken = githubToken;
|
|
55
|
+
this.fetchImpl = deps?.fetchImpl ?? ((input, init) => fetch(input, init));
|
|
56
|
+
this.sleepImpl = deps?.sleepImpl ?? ((ms) => defaultSleep(ms));
|
|
57
|
+
this.attemptTimeoutMs = deps?.attemptTimeoutMs ?? DEFAULT_ATTEMPT_TIMEOUT_MS;
|
|
58
|
+
this.maxAttempts = deps?.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
|
|
38
59
|
}
|
|
39
60
|
get current() {
|
|
40
61
|
return this.state;
|
|
@@ -63,33 +84,81 @@ export class CopilotTokenManager {
|
|
|
63
84
|
this.state = null;
|
|
64
85
|
this.refreshInFlight = null;
|
|
65
86
|
}
|
|
87
|
+
/**
|
|
88
|
+
* Perform the upstream token exchange with bounded retries.
|
|
89
|
+
*
|
|
90
|
+
* Retry policy mirrors `src/server/upstream/copilotClient.ts`:
|
|
91
|
+
* - 3 attempts max (configurable via constructor deps)
|
|
92
|
+
* - exponential backoff: 200ms, 400ms (no sleep after final attempt)
|
|
93
|
+
* - retry on status ∈ {408, 409, 425, 429, 500, 502, 503, 504}
|
|
94
|
+
* - retry on transient transport errors (ECONNRESET / EAI_AGAIN / ...)
|
|
95
|
+
* - 401/403/404 are terminal: bad credentials, not a blip — fail fast
|
|
96
|
+
*
|
|
97
|
+
* Each attempt gets its own `AbortSignal.timeout(attemptTimeoutMs)` so a
|
|
98
|
+
* hung upstream can't freeze `copillm start` for the lifetime of the
|
|
99
|
+
* whole process. The previous version had no timeout at all.
|
|
100
|
+
*/
|
|
66
101
|
async exchange() {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
102
|
+
let lastErrorThrown;
|
|
103
|
+
let lastStatusError = null;
|
|
104
|
+
for (let attempt = 1; attempt <= this.maxAttempts; attempt += 1) {
|
|
105
|
+
let response;
|
|
106
|
+
try {
|
|
107
|
+
response = await this.fetchImpl(tokenExchangeUrl(), {
|
|
108
|
+
method: "GET",
|
|
109
|
+
headers: {
|
|
110
|
+
Authorization: `token ${this.githubToken}`,
|
|
111
|
+
"User-Agent": "copillm/0.1.0",
|
|
112
|
+
Accept: "application/json"
|
|
113
|
+
},
|
|
114
|
+
signal: AbortSignal.timeout(this.attemptTimeoutMs)
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
lastErrorThrown = error;
|
|
119
|
+
if (isRetryableTransportError(error) && attempt < this.maxAttempts) {
|
|
120
|
+
await this.sleepImpl(retryDelayMs(attempt));
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
throw error;
|
|
124
|
+
}
|
|
125
|
+
if (response.ok) {
|
|
126
|
+
const payload = (await response.json());
|
|
127
|
+
if (!payload.token || !payload.expires_at || !Number.isFinite(payload.expires_at)) {
|
|
128
|
+
throw new CopilotTokenPayloadError("Token exchange response was missing required fields.");
|
|
129
|
+
}
|
|
130
|
+
const now = this.nowUnix();
|
|
131
|
+
const ttl = payload.expires_at - now;
|
|
132
|
+
if (ttl <= MIN_ACCEPTABLE_TTL_SECONDS) {
|
|
133
|
+
throw new CopilotTokenExpiredError(`Received near-expired Copilot token (ttl_seconds=${Math.max(0, ttl)}).`);
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
token: payload.token,
|
|
137
|
+
expiresAtUnix: payload.expires_at
|
|
138
|
+
};
|
|
73
139
|
}
|
|
74
|
-
|
|
75
|
-
|
|
140
|
+
// Non-OK response. Capture body once so the error message is informative
|
|
141
|
+
// whether we retry or fail here.
|
|
76
142
|
const responseBody = await response.text();
|
|
77
143
|
const snippet = responseBody.slice(0, 256);
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
144
|
+
lastStatusError = new CopilotTokenExchangeError(`Copilot token exchange failed (${response.status}).`, response.status, snippet);
|
|
145
|
+
if (isTerminalCredentialStatus(response.status)) {
|
|
146
|
+
// Bad OAuth token, account disabled, or endpoint missing — no amount
|
|
147
|
+
// of retry will fix any of these. Throw immediately so the user gets
|
|
148
|
+
// a fast, actionable signal.
|
|
149
|
+
throw lastStatusError;
|
|
150
|
+
}
|
|
151
|
+
if (isRetryableStatus(response.status) && attempt < this.maxAttempts) {
|
|
152
|
+
await this.sleepImpl(retryDelayMs(attempt));
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
throw lastStatusError;
|
|
88
156
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
157
|
+
// Unreachable: every loop iteration either returns, throws, or continues
|
|
158
|
+
// (and the continue branch is gated by `attempt < this.maxAttempts`, so
|
|
159
|
+
// the final iteration always takes one of the throwing branches). Defend
|
|
160
|
+
// anyway so a future refactor doesn't silently drop the error.
|
|
161
|
+
throw lastStatusError ?? lastErrorThrown ?? new Error("Copilot token exchange exhausted retries without error context.");
|
|
93
162
|
}
|
|
94
163
|
normalizeEnsureTokenOptions(input) {
|
|
95
164
|
if (typeof input === "boolean") {
|