Describe the bug
In ACP mode (copilot --acp --stdio), a successful authenticate request does not refresh the process's authentication state.
If a copilot --acp --stdio process is started while a stale/invalid credential is present, it stays unauthenticated for its entire lifetime. Even after the user signs in (so valid credentials are on disk) and the ACP authenticate request returns success ({}), session/new keeps returning:
{"code":-32000,"message":"Authentication required"}
A freshly-spawned copilot --acp --stdio process using the identical on-disk credentials creates a session successfully — proving the credentials are valid and the stuck state is purely in-process.
This violates the ACP specification's authentication guarantee:
"After successful authentication, the Client can create new sessions without receiving an auth_required error for authentication-gated requests."
— https://agentclientprotocol.com/protocol/v1/authentication
So either authenticate should actually (re)establish authentication, or it should return an error instead of a misleading success.
Affected version
GitHub Copilot CLI 1.0.64-3 (Windows 11). The authMethods advertised is copilot-login (default agent type) with _meta.terminal-auth pointing at copilot login.
Steps to reproduce
A self-contained Node.js script (zero dependencies) is attached below. It speaks newline-delimited JSON-RPC 2.0 to copilot --acp --stdio. It runs these steps (matching its on-screen SETUP / STEP 1..5 / CLEANUP banners):
- Setup: plant a stale/invalid credential so the process binds to it at startup. On Windows:
cmdkey /generic:copilot-cli /user:x /pass:x. (In the wild, an expired/revoked token does the same.)
- Step 1: start
copilot --acp --stdio (P1); send initialize; send session/new → -32000 Authentication required.
- Step 2: in another terminal, complete the agent's own advertised auth method —
copilot login — which writes a valid token to disk.
- Step 3: on P1, send
authenticate with { "methodId": "copilot-login" } → returns success {}.
- Step 4: on P1 (same process), send
session/new again → still -32000 Authentication required. ← the bug
- Step 5: spawn a fresh
copilot --acp --stdio (P2); initialize + session/new → succeeds, returning a real sessionId.
- Cleanup: delete the planted credential (
cmdkey /delete:copilot-cli).
The script also has a SKIP_PREAUTH_PROBE=1 mode that omits step 2's pre-auth session/new, so P1's first-ever session/new happens after authenticate. It still fails — ruling out "the pre-auth call poisoned the connection".
Both modes reproduce deterministically. Observed VERDICT:
mode A (with pre-auth probe):
1. stale-cred session/new : FAIL (auth -32000)
3. authenticate : SUCCESS
4. SAME-process session/new : FAIL (-32000)
5. FRESH-process session/new : SUCCESS
mode B (SKIP_PREAUTH_PROBE=1):
1. stale-cred session/new : (skipped)
3. authenticate : SUCCESS
4. SAME-process session/new : FAIL (-32000)
5. FRESH-process session/new : SUCCESS
Expected behavior
After authenticate returns success on a connection, session/new on that same connection should succeed (the credentials are valid, as proven by a fresh process). If authenticate cannot actually authenticate the running process, it should return an error rather than a success response.
Actual behavior
authenticate returns success {}, but session/new on the same long-lived process keeps returning -32000 Authentication required for the life of the process. Only spawning a new process recovers.
Full self-contained reproduction script (Node.js, zero deps)
#!/usr/bin/env node
/*
* copilot-acp-authenticate-noop.js
*
* Minimal, self-contained reproduction of a GitHub Copilot CLI bug in ACP mode
* (`copilot --acp --stdio`). NOT specific to any host application.
*
* THE BUG
* If a `copilot --acp --stdio` process is started while a STALE/INVALID
* credential is present, it binds to that bad auth state for its whole life.
* After the user then signs in with copilot's OWN advertised auth method
* (`copilot login` in a terminal):
* - the ACP `authenticate` request returns SUCCESS, but
* - `session/new` keeps returning -32000 "Authentication required".
* A freshly-spawned process with the IDENTICAL on-disk credentials creates a
* session successfully — proving the credentials are valid and the stuck
* state is purely in-process. I.e. ACP `authenticate` is effectively a no-op:
* its success response is misleading and it never refreshes the auth state.
*
* PRECONDITION (how we create "a stale/invalid credential at startup")
* We plant a junk Windows Credential Manager entry named `copilot-cli`:
* cmdkey /generic:copilot-cli /user:x /pass:x
* In the wild, an expired/revoked/half-written token does the same thing.
*
* STEPS
* setup : plant the invalid `copilot-cli` credential
* 1. start P1 = copilot --acp --stdio ; initialize ; session/new => -32000
* 2. [you] `copilot login` in another terminal (copilot's advertised method)
* 3. P1 authenticate => success
* 4. P1 session/new (SAME process) => -32000 <-- BUG
* 5. fresh P2 ; session/new => success
* cleanup: delete the planted `copilot-cli` credential
*
* RUN
* copilot logout # start from no real token
* node copilot-acp-authenticate-noop.js
* # at the pause, run `copilot login` in another terminal, then press ENTER
*
* # Variant that rules out "the pre-auth session/new poisoned the connection":
* # PowerShell: $env:SKIP_PREAUTH_PROBE=1 ; node copilot-acp-authenticate-noop.js
* # P1 then does initialize -> (login) -> authenticate -> its FIRST session/new.
*
* Zero npm deps — Node built-ins only. Windows (uses cmdkey).
*/
const { spawn, execSync } = require("child_process");
const readline = require("readline");
const COPILOT = process.env.COPILOT_BIN || "copilot";
const CWD = process.cwd();
const TIMEOUT_MS = 20000;
function sh(cmd) { try { return execSync(cmd, { encoding: "utf8" }).trim(); } catch (e) { return String((e.stdout || "") + (e.stderr || "") + e.message).trim(); } }
function creds(tag) {
const hits = sh("cmdkey /list").split(/\r?\n/).filter((l) => /copilot/i.test(l)).map((l) => l.trim());
console.log(` [creds ${tag}] ${hits.length ? hits.join(" || ") : "(none)"}`);
}
function log(s) { console.log(s); }
function banner(s) { console.log("\n" + "=".repeat(70) + "\n " + s + "\n" + "=".repeat(70)); }
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
function isAuthErr(e) { return !!e && (e.code === -32000 || /auth|login|unauthor/i.test((e.message || "") + JSON.stringify(e.data || ""))); }
function waitEnter(p) { const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); return new Promise((r) => rl.question(p, () => { rl.close(); r(); })); }
class Acp {
constructor(tag) {
this.tag = tag; this.id = 1; this.waiters = new Map();
this.p = spawn(COPILOT, ["--acp", "--stdio"], { stdio: ["pipe", "pipe", "pipe"] });
readline.createInterface({ input: this.p.stdout }).on("line", (l) => {
if (!l.trim()) return;
let m; try { m = JSON.parse(l); } catch { return; }
if (m.id !== undefined && (m.result !== undefined || m.error !== undefined)) {
const w = this.waiters.get(m.id); if (w) { this.waiters.delete(m.id); w(m); return; }
}
// Any agent->client request/notification. Log it (so the transcript proves
// there were no unhandled auth/permission/fs requests) and answer requests
// with method-not-found so nothing is ever left hanging.
if (m.method !== undefined) {
log(` <inbound[${this.tag}] ${m.id !== undefined ? "request" : "notification"}: ${m.method}>`);
if (m.id !== undefined) {
this.p.stdin.write(JSON.stringify({ jsonrpc: "2.0", id: m.id, error: { code: -32601, message: "method not found (minimal repro client)" } }) + "\n");
}
}
});
// Drain (and surface) stderr so a noisy child can't block on a full pipe.
readline.createInterface({ input: this.p.stderr }).on("line", (l) => { if (l.trim()) log(` <stderr[${this.tag}] ${l}>`); });
}
call(method, params) {
const id = this.id++;
this.p.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n");
return new Promise((res, rej) => {
const t = setTimeout(() => { this.waiters.delete(id); rej(new Error(`timeout: ${method}`)); }, TIMEOUT_MS);
this.waiters.set(id, (m) => { clearTimeout(t); res(m); });
});
}
init() { return this.call("initialize", { protocolVersion: 1, clientCapabilities: { terminal: false, fs: { readTextFile: false, writeTextFile: false } }, clientInfo: { name: "acp-min-repro", version: "1.0.0" } }); }
newSession() { return this.call("session/new", { cwd: CWD, mcpServers: [] }); }
authenticate(methodId) { return this.call("authenticate", { methodId }); }
kill() { try { this.p.kill(); } catch {} }
}
function showSession(label, resp) {
if (resp.result && resp.result.sessionId) { log(` ${label}: SUCCESS sessionId=${resp.result.sessionId}`); return { ok: true }; }
if (resp.error) { log(` ${label}: FAIL ${JSON.stringify(resp.error)}`); return { ok: false, auth: isAuthErr(resp.error) }; }
log(` ${label}: ??? ${JSON.stringify(resp)}`); return { ok: false };
}
(async () => {
const R = {};
const SKIP_PREAUTH = /^(1|true|yes)$/i.test(process.env.SKIP_PREAUTH_PROBE || "");
let p1 = null, p2 = null;
try {
banner("SETUP — plant an INVALID `copilot-cli` credential");
creds("before");
log(" " + sh('cmdkey /generic:copilot-cli /user:x /pass:x'));
creds("after-plant");
banner("STEP 1 — start P1, initialize, session/new (expect -32000)");
p1 = new Acp("P1");
const init = await p1.init();
const pv = init.result && init.result.protocolVersion;
if (pv !== 1) log(` [warn] agent negotiated protocolVersion=${pv} (expected 1)`);
const methods = (init.result && init.result.authMethods) || [];
const chosen = methods.find((m) => m.id === "copilot-login") || methods[0];
const methodId = chosen ? chosen.id : null;
log(` initialize OK. protocolVersion=${pv}. authMethods = ${JSON.stringify(methods.map((m) => m.id))}`);
if (!SKIP_PREAUTH) {
R.s1 = showSession("session/new (#1, stale cred, not signed in)", await p1.newSession());
} else {
log(" (SKIP_PREAUTH_PROBE set — skipping the pre-authenticate session/new, to rule out 'the first call poisoned the connection')");
}
banner("STEP 2 — SIGN IN NOW in another terminal, then press ENTER");
log(" Leave this process running. In a SEPARATE terminal run:\n");
log(" copilot login\n");
log(" Finish the sign-in, then come back here.");
await waitEnter(" >> Press ENTER once `copilot login` has SUCCEEDED... ");
creds("after-login");
banner("STEP 3 — P1 authenticate (does it claim success?)");
if (methodId) {
const a = await p1.authenticate(methodId);
R.auth = a.error === undefined;
log(` authenticate(${JSON.stringify(methodId)}) => ${R.auth ? "SUCCESS" : "FAIL " + JSON.stringify(a.error)}`);
} else { R.auth = null; log(" (no auth method advertised; skipping)"); }
banner("STEP 4 — P1 session/new on the SAME process <-- moment of truth");
R.s4 = showSession("session/new (#2, same process, after authenticate)", await p1.newSession());
p1.kill(); p1 = null; await sleep(800);
banner("STEP 5 — FRESH process P2, same on-disk credentials");
p2 = new Acp("P2");
await p2.init();
R.s5 = showSession("session/new (fresh process)", await p2.newSession());
p2.kill(); p2 = null; await sleep(200);
banner("VERDICT");
const s1ok = SKIP_PREAUTH ? true : !!(R.s1 && !R.s1.ok && R.s1.auth);
const bug = s1ok && R.auth === true && R.s4 && !R.s4.ok && R.s4.auth && R.s5 && R.s5.ok;
log(` 1. stale-cred session/new : ${SKIP_PREAUTH ? "(skipped)" : (R.s1 && R.s1.ok ? "SUCCESS(!?)" : (R.s1 && R.s1.auth ? "FAIL (auth -32000)" : "FAIL (non-auth)"))}`);
log(` 3. authenticate : ${R.auth === true ? "SUCCESS" : R.auth === false ? "FAIL" : "n/a"}`);
log(` 4. SAME-process session/new : ${R.s4 && R.s4.ok ? "SUCCESS" : "FAIL (-32000)"}`);
log(` 5. FRESH-process session/new : ${R.s5 && R.s5.ok ? "SUCCESS" : "FAIL"}`);
log("");
if (bug) {
log(" ✅ BUG REPRODUCED: `authenticate` returned SUCCESS, yet the SAME process");
log(" still failed `session/new` with -32000, while a FRESH process using the");
log(" identical on-disk credentials succeeded. ACP `authenticate` does not");
log(" refresh the process's auth state (its success response is misleading).");
} else {
log(" ⚠️ Pattern not matched — see the per-step results above.");
log(" (If step 4 SUCCEEDED, this build may have fixed in-band auth refresh.)");
}
} catch (e) {
log("\n [error] " + (e && e.stack ? e.stack : e));
} finally {
try { if (p1) p1.kill(); } catch {}
try { if (p2) p2.kill(); } catch {}
banner("CLEANUP — remove the planted invalid credential");
log(" " + sh("cmdkey /delete:copilot-cli"));
creds("after-cleanup");
log(" (Your real token under `copilot-cli/https://github.com:<user>` is a different");
log(" target and is NOT removed. If sign-in looks lost, just `copilot login` again.)");
}
process.exit(0);
})();
Describe the bug
In ACP mode (
copilot --acp --stdio), a successfulauthenticaterequest does not refresh the process's authentication state.If a
copilot --acp --stdioprocess is started while a stale/invalid credential is present, it stays unauthenticated for its entire lifetime. Even after the user signs in (so valid credentials are on disk) and the ACPauthenticaterequest returns success ({}),session/newkeeps returning:{"code":-32000,"message":"Authentication required"}A freshly-spawned
copilot --acp --stdioprocess using the identical on-disk credentials creates a session successfully — proving the credentials are valid and the stuck state is purely in-process.This violates the ACP specification's authentication guarantee:
So either
authenticateshould actually (re)establish authentication, or it should return an error instead of a misleading success.Affected version
GitHub Copilot CLI 1.0.64-3 (Windows 11). The
authMethodsadvertised iscopilot-login(defaultagenttype) with_meta.terminal-authpointing atcopilot login.Steps to reproduce
A self-contained Node.js script (zero dependencies) is attached below. It speaks newline-delimited JSON-RPC 2.0 to
copilot --acp --stdio. It runs these steps (matching its on-screenSETUP/STEP 1..5/CLEANUPbanners):cmdkey /generic:copilot-cli /user:x /pass:x. (In the wild, an expired/revoked token does the same.)copilot --acp --stdio(P1); sendinitialize; sendsession/new→-32000 Authentication required.copilot login— which writes a valid token to disk.authenticatewith{ "methodId": "copilot-login" }→ returns success{}.session/newagain → still-32000 Authentication required. ← the bugcopilot --acp --stdio(P2);initialize+session/new→ succeeds, returning a realsessionId.cmdkey /delete:copilot-cli).The script also has a
SKIP_PREAUTH_PROBE=1mode that omits step 2's pre-authsession/new, so P1's first-eversession/newhappens afterauthenticate. It still fails — ruling out "the pre-auth call poisoned the connection".Both modes reproduce deterministically. Observed VERDICT:
Expected behavior
After
authenticatereturns success on a connection,session/newon that same connection should succeed (the credentials are valid, as proven by a fresh process). Ifauthenticatecannot actually authenticate the running process, it should return an error rather than a success response.Actual behavior
authenticatereturns success{}, butsession/newon the same long-lived process keeps returning-32000 Authentication requiredfor the life of the process. Only spawning a new process recovers.Full self-contained reproduction script (Node.js, zero deps)