copillm 0.2.7 → 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 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/cli-reference/) | Commands and flags |
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 piHome = path.join(process.env.HOME ?? "", ".pi");
296
- const extensionDir = path.join(piHome, "agent", "extensions", PI_EXTENSION_DIRNAME);
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(piHome, "agent", "prompts", "copillm-profile.md");
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
- const promptPath = path.join(process.env.HOME ?? "", ".pi", "agent", "prompts", "copillm-profile.md");
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 {
@@ -384,18 +387,67 @@ export default function activate(pi: PiApi): void {
384
387
  }
385
388
  }
386
389
  `;
387
- // ─── Copilot CLI (stub) ───────────────────────────────────────────────────
388
- export function renderCopilot(_input) {
390
+ // ─── Copilot CLI ──────────────────────────────────────────────────────────
391
+ export function renderCopilot(input) {
392
+ const writes = [];
393
+ const notes = [];
394
+ const cliArgs = [];
395
+ const mcpConfigPath = path.join(getCopillmHome(), "copilot", "mcp-config.json");
396
+ const serverCount = Object.keys(input.resolved.mcpServers).length;
397
+ if (serverCount > 0) {
398
+ const content = renderCopilotMcp(input.resolved.mcpServers);
399
+ const existing = fs.existsSync(mcpConfigPath) ? fs.readFileSync(mcpConfigPath, "utf8") : null;
400
+ if (existing !== content) {
401
+ writes.push({
402
+ path: mcpConfigPath,
403
+ content,
404
+ mode: 0o600,
405
+ description: "Copilot CLI MCP config (copillm-managed)"
406
+ });
407
+ }
408
+ cliArgs.push("--additional-mcp-config", `@${mcpConfigPath}`);
409
+ }
410
+ else if (fs.existsSync(mcpConfigPath)) {
411
+ fs.rmSync(mcpConfigPath, { force: true });
412
+ notes.push(`Removed stale ${mcpConfigPath} (no MCP servers in active profile).`);
413
+ }
389
414
  return {
390
- writes: [],
415
+ writes,
391
416
  envOverlay: {},
392
- cliArgs: [],
393
- notes: [
394
- "Copilot CLI: native MCP config format is not yet documented publicly. " +
395
- "Skipping fan-out. Track upstream and remove this stub when the path is known."
396
- ]
417
+ cliArgs,
418
+ notes
397
419
  };
398
420
  }
421
+ function renderCopilotMcp(servers) {
422
+ const out = {};
423
+ for (const [name, server] of Object.entries(servers)) {
424
+ if (server.transport === "stdio") {
425
+ const entry = {
426
+ type: "local",
427
+ command: server.command,
428
+ tools: ["*"]
429
+ };
430
+ if (server.args)
431
+ entry.args = server.args;
432
+ if (server.env)
433
+ entry.env = server.env;
434
+ if (server.cwd)
435
+ entry.cwd = server.cwd;
436
+ out[name] = entry;
437
+ }
438
+ else {
439
+ const entry = {
440
+ type: server.transport,
441
+ url: server.url,
442
+ tools: ["*"]
443
+ };
444
+ if (server.headers)
445
+ entry.headers = server.headers;
446
+ out[name] = entry;
447
+ }
448
+ }
449
+ return `${JSON.stringify({ mcpServers: out }, null, 2)}\n`;
450
+ }
399
451
  export function planRender(opts, load) {
400
452
  const baseInput = { resolved: load.resolved, cwd: opts.cwd };
401
453
  switch (opts.agent) {
@@ -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
- constructor(githubToken) {
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
- const response = await fetch(tokenExchangeUrl(), {
68
- method: "GET",
69
- headers: {
70
- Authorization: `token ${this.githubToken}`,
71
- "User-Agent": "copillm/0.1.0",
72
- Accept: "application/json"
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
- if (!response.ok) {
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
- throw new CopilotTokenExchangeError(`Copilot token exchange failed (${response.status}).`, response.status, snippet);
79
- }
80
- const payload = (await response.json());
81
- if (!payload.token || !payload.expires_at || !Number.isFinite(payload.expires_at)) {
82
- throw new CopilotTokenPayloadError("Token exchange response was missing required fields.");
83
- }
84
- const now = this.nowUnix();
85
- const ttl = payload.expires_at - now;
86
- if (ttl <= MIN_ACCEPTABLE_TTL_SECONDS) {
87
- throw new CopilotTokenExpiredError(`Received near-expired Copilot token (ttl_seconds=${Math.max(0, ttl)}).`);
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
- return {
90
- token: payload.token,
91
- expiresAtUnix: payload.expires_at
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") {
@@ -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") {
@@ -1,32 +1,67 @@
1
+ import { setTimeout as defaultSleep } from "node:timers/promises";
2
+ import { isRetryableStatus, isRetryableTransportError, retryDelayMs } from "../server/upstream/retryPolicy.js";
1
3
  const GITHUB_CLIENT_ID = "Iv1.b507a08c87ecfe98";
2
4
  const DEVICE_CODE_URL = "https://github.com/login/device/code";
3
5
  const ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token";
4
- export async function loginViaDeviceFlow() {
5
- const start = await fetch(DEVICE_CODE_URL, {
6
- method: "POST",
7
- headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
8
- body: new URLSearchParams({ client_id: GITHUB_CLIENT_ID, scope: "read:user" })
9
- });
10
- if (!start.ok) {
11
- throw new Error(`Device flow init failed (${start.status}).`);
12
- }
13
- const payload = parseDeviceCodeResponse((await start.json()));
6
+ /**
7
+ * Per-attempt timeout for the init POST + each poll POST. GitHub's device-
8
+ * flow endpoints typically respond in <500ms; 10s leaves room for slow
9
+ * networks without freezing the login flow for a full minute on a network
10
+ * black-hole. Previously the fetches had no timeout at all.
11
+ */
12
+ const DEFAULT_FETCH_TIMEOUT_MS = 10_000;
13
+ const DEFAULT_INIT_MAX_ATTEMPTS = 3;
14
+ export async function loginViaDeviceFlow(deps) {
15
+ const fetchImpl = deps?.fetchImpl ?? ((input, init) => fetch(input, init));
16
+ const sleepImpl = deps?.sleepImpl ?? ((ms) => defaultSleep(ms));
17
+ const stdout = deps?.stdout ?? process.stdout;
18
+ const fetchTimeoutMs = deps?.fetchTimeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
19
+ const initMaxAttempts = deps?.initMaxAttempts ?? DEFAULT_INIT_MAX_ATTEMPTS;
20
+ // Phase 1: init POST. Retried up to `initMaxAttempts` on transient HTTP
21
+ // statuses + transport errors. A single 502 used to abort the whole
22
+ // device-flow login — the user would have to start over from a new code.
23
+ const payload = await initDeviceFlow({ fetchImpl, sleepImpl, fetchTimeoutMs, initMaxAttempts });
14
24
  const verificationUrl = payload.verification_uri_complete ?? payload.verification_uri;
15
- process.stdout.write(`Open ${verificationUrl} and enter code ${payload.user_code}\n`);
25
+ stdout.write(`Open ${verificationUrl} and enter code ${payload.user_code}\n`);
26
+ // Phase 2: poll loop. The device-flow `expires_in` is the natural deadline.
27
+ // Inside the loop, transient HTTP / transport failures `continue` instead
28
+ // of throwing — the loop's own `await sleep(intervalMs)` IS the backoff,
29
+ // and the `deadline` IS the budget. Previously, a single 503 from
30
+ // `github.com/login/oauth/access_token` aborted the whole login.
16
31
  const deadline = Date.now() + payload.expires_in * 1000;
17
32
  let intervalMs = Math.max(1, payload.interval) * 1000;
18
33
  while (Date.now() < deadline) {
19
- await sleep(intervalMs);
20
- const poll = await fetch(ACCESS_TOKEN_URL, {
21
- method: "POST",
22
- headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
23
- body: new URLSearchParams({
24
- client_id: GITHUB_CLIENT_ID,
25
- device_code: payload.device_code,
26
- grant_type: "urn:ietf:params:oauth:grant-type:device_code"
27
- })
28
- });
34
+ await sleepImpl(intervalMs);
35
+ let poll;
36
+ try {
37
+ poll = await fetchImpl(ACCESS_TOKEN_URL, {
38
+ method: "POST",
39
+ headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
40
+ body: new URLSearchParams({
41
+ client_id: GITHUB_CLIENT_ID,
42
+ device_code: payload.device_code,
43
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code"
44
+ }),
45
+ signal: AbortSignal.timeout(fetchTimeoutMs)
46
+ });
47
+ }
48
+ catch (error) {
49
+ if (isRetryableTransportError(error)) {
50
+ // Transient — the next loop iteration will retry naturally. Don't
51
+ // abort the user's login over an ECONNRESET / DNS soft-fail.
52
+ continue;
53
+ }
54
+ throw error;
55
+ }
29
56
  if (!poll.ok) {
57
+ // Transient HTTP errors (5xx, 429) keep polling — same justification
58
+ // as transport errors. Permanent errors (4xx other than 429) abort,
59
+ // because the device code itself is bad and no amount of polling
60
+ // will fix it.
61
+ if (isRetryableStatus(poll.status)) {
62
+ await discardResponseBody(poll);
63
+ continue;
64
+ }
30
65
  throw new Error(`Access token poll failed (${poll.status}).`);
31
66
  }
32
67
  const tokenPayload = parseAccessTokenResponse((await poll.json()));
@@ -51,8 +86,60 @@ export async function loginViaDeviceFlow() {
51
86
  }
52
87
  throw new Error("Device authorization timed out.");
53
88
  }
54
- function sleep(ms) {
55
- return new Promise((resolve) => setTimeout(resolve, ms));
89
+ /**
90
+ * Init POST with bounded retries. Retries on retryable HTTP statuses (5xx,
91
+ * 429, 408, 409, 425) and on transient transport errors (ECONNRESET, DNS
92
+ * soft-fails, undici timeouts). Fast-fails on 4xx-other so a misconfigured
93
+ * client_id or missing scope shows up immediately instead of after three
94
+ * pointless retries.
95
+ *
96
+ * Throws the last error if the budget is exhausted. The wrapping
97
+ * `loginViaDeviceFlow` does not retry the init separately — this is the
98
+ * only init retry layer.
99
+ */
100
+ async function initDeviceFlow(opts) {
101
+ let lastError;
102
+ for (let attempt = 1; attempt <= opts.initMaxAttempts; attempt += 1) {
103
+ let response;
104
+ try {
105
+ response = await opts.fetchImpl(DEVICE_CODE_URL, {
106
+ method: "POST",
107
+ headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
108
+ body: new URLSearchParams({ client_id: GITHUB_CLIENT_ID, scope: "read:user" }),
109
+ signal: AbortSignal.timeout(opts.fetchTimeoutMs)
110
+ });
111
+ }
112
+ catch (error) {
113
+ lastError = error;
114
+ if (isRetryableTransportError(error) && attempt < opts.initMaxAttempts) {
115
+ await opts.sleepImpl(retryDelayMs(attempt));
116
+ continue;
117
+ }
118
+ throw error;
119
+ }
120
+ if (response.ok) {
121
+ return parseDeviceCodeResponse((await response.json()));
122
+ }
123
+ lastError = new Error(`Device flow init failed (${response.status}).`);
124
+ if (isRetryableStatus(response.status) && attempt < opts.initMaxAttempts) {
125
+ await discardResponseBody(response);
126
+ await opts.sleepImpl(retryDelayMs(attempt));
127
+ continue;
128
+ }
129
+ throw lastError;
130
+ }
131
+ // Unreachable: every iteration either returns, throws, or continues
132
+ // (with continue gated on `attempt < initMaxAttempts`). Defend anyway.
133
+ throw lastError ?? new Error("Device flow init exhausted retries without error context.");
134
+ }
135
+ async function discardResponseBody(response) {
136
+ try {
137
+ await response.arrayBuffer();
138
+ }
139
+ catch {
140
+ // Best-effort body drain; ignore failures so we don't surface a
141
+ // response-cleanup error in place of the real one we already captured.
142
+ }
56
143
  }
57
144
  function parseDeviceCodeResponse(value) {
58
145
  if (!value || typeof value !== "object") {
@@ -14,18 +14,22 @@ import { getGithubUserSummary, GithubUserFetchError } from "../server/debugInfo.
14
14
  * null so callers can gracefully fall back to existing offline output.
15
15
  */
16
16
  export async function inspectGithubIdentity(options = {}) {
17
- let credential;
18
- try {
19
- credential = await loadStoredCredential();
20
- }
21
- catch {
22
- return null;
23
- }
24
- if (!credential) {
25
- return null;
17
+ let token = options.token;
18
+ if (typeof token !== "string") {
19
+ let credential;
20
+ try {
21
+ credential = await loadStoredCredential();
22
+ }
23
+ catch {
24
+ return null;
25
+ }
26
+ if (!credential) {
27
+ return null;
28
+ }
29
+ token = credential.token;
26
30
  }
27
31
  try {
28
- const summary = await getGithubUserSummary(credential.token, {
32
+ const summary = await getGithubUserSummary(token, {
29
33
  timeoutMs: options.timeoutMs ?? 4_000
30
34
  });
31
35
  if (!summary.login) {
@@ -1,10 +1,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
- * Pi has no environment-variable override for its config directory; it reads
42
- * `~/.pi/agent/models.json` unconditionally. So this bundle is intentionally
43
- * empty the real configuration work happens in `generatePiHome()` writing
44
- * that file. We expose the helper for symmetry with the other agents and to
45
- * carry a trailing note explaining what to look at when debugging.
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 ~/.pi/agent/models.json directly (no env var override).`,
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
- info = await inspectStoredCredential();
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
- // commander's --no-user toggles opts.user to false; when the flag is
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;