Skip to content

ACP: authenticate returns success but session/new still fails with -32000 (in-band auth state never refreshes) #3902

Description

@haonanttt

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/newsucceeds, 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);
})();

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions