copillm 0.2.8 → 0.2.9
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 +1 -1
- package/dist/agentconfig/render.js +8 -5
- package/dist/auth/copilotToken.js +92 -23
- package/dist/auth/credentials.js +47 -0
- package/dist/auth/deviceFlow.js +110 -23
- package/dist/auth/githubIdentity.js +14 -10
- package/dist/cli/agentEnv.js +13 -8
- package/dist/cli/commands/auth.js +31 -6
- package/dist/cli/commands/daemon.js +79 -17
- package/dist/cli/commands/models.js +0 -5
- package/dist/cli/daemon/lifecycle.js +26 -0
- package/dist/cli/daemon/probes.js +99 -33
- package/dist/cli/index.js +12 -0
- package/dist/cli/integrations/refreshCodex.js +3 -2
- package/dist/cli/integrations/refreshPi.js +3 -2
- package/dist/cli/packageInfo.js +1 -1
- package/dist/cli/shared/devMode.js +98 -0
- package/dist/config/config.js +13 -2
- package/dist/config/home.js +34 -0
- package/dist/integrations/claude/cache.js +5 -2
- package/dist/integrations/claude/settingsConflict.js +5 -2
- package/dist/integrations/codex/init.js +21 -9
- package/dist/integrations/pi/init.js +5 -15
- package/dist/models/discovery.js +112 -8
- package/dist/server/debugInfo.js +69 -24
- package/dist/server/errors.js +18 -0
- package/dist/server/routes/debug.js +4 -1
- package/dist/server/routes/models.js +7 -1
- 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
|
@@ -55,7 +55,7 @@ Full documentation is published at **[jcjc-dev.github.io/copillm](https://jcjc-d
|
|
|
55
55
|
| Topic | Description |
|
|
56
56
|
| --- | --- |
|
|
57
57
|
| [Getting started](https://jcjc-dev.github.io/copillm/getting-started/) | Installation, authentication, and first run |
|
|
58
|
-
| [CLI reference](https://jcjc-dev.github.io/copillm/
|
|
58
|
+
| [CLI reference](https://jcjc-dev.github.io/copillm/commands/) | Commands and flags |
|
|
59
59
|
| [Using with Claude Code](https://jcjc-dev.github.io/copillm/claude-code/) | Environment wiring, gateway discovery, the `[1m]` 1M-context alias |
|
|
60
60
|
| [Using with Codex CLI](https://jcjc-dev.github.io/copillm/codex/) | Environment wiring and `config.toml` generation |
|
|
61
61
|
| [HTTP API reference](https://jcjc-dev.github.io/copillm/http-api/) | Endpoints and translation behaviour |
|
|
@@ -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 {
|
|
@@ -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") {
|
package/dist/auth/credentials.js
CHANGED
|
@@ -204,6 +204,53 @@ export async function loadStoredCredential() {
|
|
|
204
204
|
}
|
|
205
205
|
return { token, accountType: "individual", source: "keyring" };
|
|
206
206
|
}
|
|
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 };
|
|
231
|
+
}
|
|
232
|
+
if (fs.existsSync(credentialsReadPath())) {
|
|
233
|
+
const parsed = parseCredentialFile();
|
|
234
|
+
return { stored: true, backend: "file", token: parsed.token };
|
|
235
|
+
}
|
|
236
|
+
const { keyring } = await resolveKeyring();
|
|
237
|
+
if (!keyring) {
|
|
238
|
+
return { stored: false, backend: null, token: null };
|
|
239
|
+
}
|
|
240
|
+
try {
|
|
241
|
+
const token = await keyring.getPassword(SERVICE, ACCOUNT);
|
|
242
|
+
if (token) {
|
|
243
|
+
return { stored: true, backend: "keyring", token };
|
|
244
|
+
}
|
|
245
|
+
return { stored: false, backend: null, token: null };
|
|
246
|
+
}
|
|
247
|
+
catch (error) {
|
|
248
|
+
if (error instanceof Error) {
|
|
249
|
+
throw new Error(`Failed to read token from OS keychain: ${error.message}`);
|
|
250
|
+
}
|
|
251
|
+
throw new Error("Failed to read token from OS keychain.");
|
|
252
|
+
}
|
|
253
|
+
}
|
|
207
254
|
export async function saveStoredCredential(token, accountType, options = {}) {
|
|
208
255
|
const mode = options.mode ?? "auto";
|
|
209
256
|
if (mode === "session") {
|
package/dist/auth/deviceFlow.js
CHANGED
|
@@ -1,32 +1,67 @@
|
|
|
1
|
+
import { setTimeout as defaultSleep } from "node:timers/promises";
|
|
2
|
+
import { isRetryableStatus, isRetryableTransportError, retryDelayMs } from "../server/upstream/retryPolicy.js";
|
|
1
3
|
const GITHUB_CLIENT_ID = "Iv1.b507a08c87ecfe98";
|
|
2
4
|
const DEVICE_CODE_URL = "https://github.com/login/device/code";
|
|
3
5
|
const ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token";
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
const
|
|
6
|
+
/**
|
|
7
|
+
* Per-attempt timeout for the init POST + each poll POST. GitHub's device-
|
|
8
|
+
* flow endpoints typically respond in <500ms; 10s leaves room for slow
|
|
9
|
+
* networks without freezing the login flow for a full minute on a network
|
|
10
|
+
* black-hole. Previously the fetches had no timeout at all.
|
|
11
|
+
*/
|
|
12
|
+
const DEFAULT_FETCH_TIMEOUT_MS = 10_000;
|
|
13
|
+
const DEFAULT_INIT_MAX_ATTEMPTS = 3;
|
|
14
|
+
export async function loginViaDeviceFlow(deps) {
|
|
15
|
+
const fetchImpl = deps?.fetchImpl ?? ((input, init) => fetch(input, init));
|
|
16
|
+
const sleepImpl = deps?.sleepImpl ?? ((ms) => defaultSleep(ms));
|
|
17
|
+
const stdout = deps?.stdout ?? process.stdout;
|
|
18
|
+
const fetchTimeoutMs = deps?.fetchTimeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
|
|
19
|
+
const initMaxAttempts = deps?.initMaxAttempts ?? DEFAULT_INIT_MAX_ATTEMPTS;
|
|
20
|
+
// Phase 1: init POST. Retried up to `initMaxAttempts` on transient HTTP
|
|
21
|
+
// statuses + transport errors. A single 502 used to abort the whole
|
|
22
|
+
// device-flow login — the user would have to start over from a new code.
|
|
23
|
+
const payload = await initDeviceFlow({ fetchImpl, sleepImpl, fetchTimeoutMs, initMaxAttempts });
|
|
14
24
|
const verificationUrl = payload.verification_uri_complete ?? payload.verification_uri;
|
|
15
|
-
|
|
25
|
+
stdout.write(`Open ${verificationUrl} and enter code ${payload.user_code}\n`);
|
|
26
|
+
// Phase 2: poll loop. The device-flow `expires_in` is the natural deadline.
|
|
27
|
+
// Inside the loop, transient HTTP / transport failures `continue` instead
|
|
28
|
+
// of throwing — the loop's own `await sleep(intervalMs)` IS the backoff,
|
|
29
|
+
// and the `deadline` IS the budget. Previously, a single 503 from
|
|
30
|
+
// `github.com/login/oauth/access_token` aborted the whole login.
|
|
16
31
|
const deadline = Date.now() + payload.expires_in * 1000;
|
|
17
32
|
let intervalMs = Math.max(1, payload.interval) * 1000;
|
|
18
33
|
while (Date.now() < deadline) {
|
|
19
|
-
await
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
34
|
+
await sleepImpl(intervalMs);
|
|
35
|
+
let poll;
|
|
36
|
+
try {
|
|
37
|
+
poll = await fetchImpl(ACCESS_TOKEN_URL, {
|
|
38
|
+
method: "POST",
|
|
39
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
|
|
40
|
+
body: new URLSearchParams({
|
|
41
|
+
client_id: GITHUB_CLIENT_ID,
|
|
42
|
+
device_code: payload.device_code,
|
|
43
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code"
|
|
44
|
+
}),
|
|
45
|
+
signal: AbortSignal.timeout(fetchTimeoutMs)
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
if (isRetryableTransportError(error)) {
|
|
50
|
+
// Transient — the next loop iteration will retry naturally. Don't
|
|
51
|
+
// abort the user's login over an ECONNRESET / DNS soft-fail.
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
29
56
|
if (!poll.ok) {
|
|
57
|
+
// Transient HTTP errors (5xx, 429) keep polling — same justification
|
|
58
|
+
// as transport errors. Permanent errors (4xx other than 429) abort,
|
|
59
|
+
// because the device code itself is bad and no amount of polling
|
|
60
|
+
// will fix it.
|
|
61
|
+
if (isRetryableStatus(poll.status)) {
|
|
62
|
+
await discardResponseBody(poll);
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
30
65
|
throw new Error(`Access token poll failed (${poll.status}).`);
|
|
31
66
|
}
|
|
32
67
|
const tokenPayload = parseAccessTokenResponse((await poll.json()));
|
|
@@ -51,8 +86,60 @@ export async function loginViaDeviceFlow() {
|
|
|
51
86
|
}
|
|
52
87
|
throw new Error("Device authorization timed out.");
|
|
53
88
|
}
|
|
54
|
-
|
|
55
|
-
|
|
89
|
+
/**
|
|
90
|
+
* Init POST with bounded retries. Retries on retryable HTTP statuses (5xx,
|
|
91
|
+
* 429, 408, 409, 425) and on transient transport errors (ECONNRESET, DNS
|
|
92
|
+
* soft-fails, undici timeouts). Fast-fails on 4xx-other so a misconfigured
|
|
93
|
+
* client_id or missing scope shows up immediately instead of after three
|
|
94
|
+
* pointless retries.
|
|
95
|
+
*
|
|
96
|
+
* Throws the last error if the budget is exhausted. The wrapping
|
|
97
|
+
* `loginViaDeviceFlow` does not retry the init separately — this is the
|
|
98
|
+
* only init retry layer.
|
|
99
|
+
*/
|
|
100
|
+
async function initDeviceFlow(opts) {
|
|
101
|
+
let lastError;
|
|
102
|
+
for (let attempt = 1; attempt <= opts.initMaxAttempts; attempt += 1) {
|
|
103
|
+
let response;
|
|
104
|
+
try {
|
|
105
|
+
response = await opts.fetchImpl(DEVICE_CODE_URL, {
|
|
106
|
+
method: "POST",
|
|
107
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
|
|
108
|
+
body: new URLSearchParams({ client_id: GITHUB_CLIENT_ID, scope: "read:user" }),
|
|
109
|
+
signal: AbortSignal.timeout(opts.fetchTimeoutMs)
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
lastError = error;
|
|
114
|
+
if (isRetryableTransportError(error) && attempt < opts.initMaxAttempts) {
|
|
115
|
+
await opts.sleepImpl(retryDelayMs(attempt));
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
120
|
+
if (response.ok) {
|
|
121
|
+
return parseDeviceCodeResponse((await response.json()));
|
|
122
|
+
}
|
|
123
|
+
lastError = new Error(`Device flow init failed (${response.status}).`);
|
|
124
|
+
if (isRetryableStatus(response.status) && attempt < opts.initMaxAttempts) {
|
|
125
|
+
await discardResponseBody(response);
|
|
126
|
+
await opts.sleepImpl(retryDelayMs(attempt));
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
throw lastError;
|
|
130
|
+
}
|
|
131
|
+
// Unreachable: every iteration either returns, throws, or continues
|
|
132
|
+
// (with continue gated on `attempt < initMaxAttempts`). Defend anyway.
|
|
133
|
+
throw lastError ?? new Error("Device flow init exhausted retries without error context.");
|
|
134
|
+
}
|
|
135
|
+
async function discardResponseBody(response) {
|
|
136
|
+
try {
|
|
137
|
+
await response.arrayBuffer();
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
// Best-effort body drain; ignore failures so we don't surface a
|
|
141
|
+
// response-cleanup error in place of the real one we already captured.
|
|
142
|
+
}
|
|
56
143
|
}
|
|
57
144
|
function parseDeviceCodeResponse(value) {
|
|
58
145
|
if (!value || typeof value !== "object") {
|
|
@@ -14,18 +14,22 @@ import { getGithubUserSummary, GithubUserFetchError } from "../server/debugInfo.
|
|
|
14
14
|
* null so callers can gracefully fall back to existing offline output.
|
|
15
15
|
*/
|
|
16
16
|
export async function inspectGithubIdentity(options = {}) {
|
|
17
|
-
let
|
|
18
|
-
|
|
19
|
-
credential
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
17
|
+
let token = options.token;
|
|
18
|
+
if (typeof token !== "string") {
|
|
19
|
+
let credential;
|
|
20
|
+
try {
|
|
21
|
+
credential = await loadStoredCredential();
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
if (!credential) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
token = credential.token;
|
|
26
30
|
}
|
|
27
31
|
try {
|
|
28
|
-
const summary = await getGithubUserSummary(
|
|
32
|
+
const summary = await getGithubUserSummary(token, {
|
|
29
33
|
timeoutMs: options.timeoutMs ?? 4_000
|
|
30
34
|
});
|
|
31
35
|
if (!summary.login) {
|
package/dist/cli/agentEnv.js
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
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;
|
|
5
6
|
const env = {
|
|
6
7
|
ANTHROPIC_BASE_URL: `http://127.0.0.1:${input.port}/anthropic`,
|
|
7
|
-
ANTHROPIC_AUTH_TOKEN: input.callerSecret ?? "copillm-local"
|
|
8
|
+
ANTHROPIC_AUTH_TOKEN: input.callerSecret ?? "copillm-local",
|
|
9
|
+
// Point Claude at a copillm-owned config home so copillm-launched Claude
|
|
10
|
+
// never reads/writes the user's real ~/.claude (and dev mode isolates it).
|
|
11
|
+
CLAUDE_CONFIG_DIR: claudeConfigDir()
|
|
8
12
|
};
|
|
9
13
|
const trailingNotes = [];
|
|
10
14
|
if (defaults.opus) {
|
|
@@ -38,18 +42,19 @@ export function buildCodexEnvBundle(absHomeDir) {
|
|
|
38
42
|
};
|
|
39
43
|
}
|
|
40
44
|
/**
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
45
|
+
* pi reads its config from `<PI_CODING_AGENT_DIR>/models.json`. copillm owns
|
|
46
|
+
* that directory (see `piAgentDir()` in src/config/home.ts) and exports
|
|
47
|
+
* `PI_CODING_AGENT_DIR` so the launched pi reads the catalog copillm just wrote
|
|
48
|
+
* there — never the user's real `~/.pi`. This is also what makes dev mode
|
|
49
|
+
* isolate pi for free (the dev COPILLM_HOME relocates the agent dir).
|
|
46
50
|
*/
|
|
47
51
|
export function buildPiEnvBundle(absMirrorDir) {
|
|
52
|
+
const agentDir = piAgentDir();
|
|
48
53
|
return {
|
|
49
|
-
env: {},
|
|
54
|
+
env: { PI_CODING_AGENT_DIR: agentDir },
|
|
50
55
|
inlineComments: {},
|
|
51
56
|
trailingNotes: [
|
|
52
|
-
`pi reads
|
|
57
|
+
`pi reads ${agentDir}/models.json (copillm sets PI_CODING_AGENT_DIR).`,
|
|
53
58
|
`copillm regenerated it on \`copillm start\` and mirrored it at ${absMirrorDir}/models.json.`
|
|
54
59
|
]
|
|
55
60
|
};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { inspectStoredCredential } from "../../auth/credentials.js";
|
|
1
|
+
import { inspectStoredCredential, loadStoredCredentialForStatus } from "../../auth/credentials.js";
|
|
2
2
|
import { inspectGithubIdentity } from "../../auth/githubIdentity.js";
|
|
3
3
|
import { ensureAuthenticatedInteractive } from "../auth/ensure.js";
|
|
4
4
|
import { runAuthLogin, runAuthLogout } from "../auth/runAuth.js";
|
|
@@ -48,9 +48,36 @@ export function register(program) {
|
|
|
48
48
|
.option("--json", "JSON output")
|
|
49
49
|
.option("--no-user", "Skip the GitHub /user lookup that fetches the login name")
|
|
50
50
|
.action(async (opts) => {
|
|
51
|
+
// commander's --no-user toggles opts.user to false; when the flag is
|
|
52
|
+
// omitted opts.user is undefined and we treat that as "fetch by default".
|
|
53
|
+
const wantUserLookup = opts.user !== false;
|
|
54
|
+
// Two paths to minimize keychain probes:
|
|
55
|
+
// - With user lookup (default): `loadStoredCredentialForStatus()`
|
|
56
|
+
// does ONE keychain read that yields backend + token. Pass the
|
|
57
|
+
// token into `inspectGithubIdentity({ token })` so it doesn't
|
|
58
|
+
// re-read the keychain.
|
|
59
|
+
// - Without user lookup (--no-user): `inspectStoredCredential()`
|
|
60
|
+
// does ONE keychain probe and never sees the token. Preserves
|
|
61
|
+
// the no-token invariant for the surface where it matters most.
|
|
62
|
+
//
|
|
63
|
+
// Previously, the user-lookup path made TWO keychain reads — one in
|
|
64
|
+
// `inspectStoredCredential` then another in `inspectGithubIdentity` →
|
|
65
|
+
// `loadStoredCredential`. That doubled macOS keychain audit-log
|
|
66
|
+
// entries and doubled permission-prompt exposure on misconfigured
|
|
67
|
+
// systems.
|
|
51
68
|
let info;
|
|
69
|
+
let token;
|
|
52
70
|
try {
|
|
53
|
-
|
|
71
|
+
if (wantUserLookup) {
|
|
72
|
+
const loaded = await loadStoredCredentialForStatus();
|
|
73
|
+
info = { stored: loaded.stored, backend: loaded.backend };
|
|
74
|
+
if (loaded.stored) {
|
|
75
|
+
token = loaded.token;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
info = await inspectStoredCredential();
|
|
80
|
+
}
|
|
54
81
|
}
|
|
55
82
|
catch (error) {
|
|
56
83
|
const message = error instanceof Error ? error.message : "unknown_error";
|
|
@@ -62,9 +89,7 @@ export function register(program) {
|
|
|
62
89
|
}
|
|
63
90
|
process.exit(1);
|
|
64
91
|
}
|
|
65
|
-
|
|
66
|
-
// omitted opts.user is undefined and we treat that as "fetch by default".
|
|
67
|
-
const userLookupEnabled = info.stored && opts.user !== false;
|
|
92
|
+
const userLookupEnabled = info.stored && wantUserLookup;
|
|
68
93
|
let identity = null;
|
|
69
94
|
if (userLookupEnabled) {
|
|
70
95
|
// inspectGithubIdentity is designed to return null on any failure, but
|
|
@@ -74,7 +99,7 @@ export function register(program) {
|
|
|
74
99
|
// must never break the auth-status command. Status output should always
|
|
75
100
|
// succeed even when the network is broken.
|
|
76
101
|
try {
|
|
77
|
-
identity = await inspectGithubIdentity();
|
|
102
|
+
identity = await inspectGithubIdentity({ token });
|
|
78
103
|
}
|
|
79
104
|
catch {
|
|
80
105
|
identity = null;
|