diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 39aaadcf0..5033453f6 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -91,6 +91,7 @@ - Fixed `tool_search` and codemode `searchTools()` ranking a tool below tools that only mention its name in their descriptions, for example `peekaboo app` ranking `mcp__peekaboo__app` 14th. A tool matching more of the query's words now ranks above one matching fewer, a query word in a tool's name scores more than any number of mentions in its description, a tool whose whole name is in the query gets a large bonus, and long server instructions no longer lower the scores of that server's tools ([#11331](https://github.com/earendil-works/pi/issues/11331)) - Fixed `tool_search` and codemode `searchTools()` finding nothing for Chinese, Japanese, Thai, and other non-ASCII queries, because the tokenizer kept only ASCII letters and digits. Words in any script now match, text without spaces between words such as `搜索记录` is split into words, and canonically equivalent text such as `café` and `cafe\u0301` matches ([#10816](https://github.com/earendil-works/pi/issues/10816), [#11037](https://github.com/earendil-works/pi/pull/11037) by [@haoqixu](https://github.com/haoqixu)) - Fixed `pi update` stopping with `git clean -fdx failed with code 1` on Windows when another pi session had a git extension's native module or executable open. Windows cannot delete such files, so the update left the new code with the old dependencies. pi now warns, installs the dependencies anyway, and retries the clean on the next update ([#11597](https://github.com/earendil-works/pi/issues/11597)) +- Fixed two pi processes refreshing OAuth credentials at the same time when they reach the same `auth.json` through different paths, such as a symlinked `auth.json` in a separate agent directory. The auth lock now uses the file's resolved path ([#11655](https://github.com/earendil-works/pi/issues/11655)) - Fixed tool rows and streamed answers highlighting the same code again on every redraw. A running codemode row highlighted its whole script on every progress update and timer tick, a complete `write` row its whole file on every update and expand toggle, a streamed `write` its first 50 lines on every delta, and a streamed answer every earlier code block on every delta. Rows now reuse their last highlighting until the code, theme, or loaded grammars change, and assistant messages keep their rendered Markdown blocks while streaming ([#10971](https://github.com/earendil-works/pi/issues/10971)) - Fixed resized photos being sent as multi-megabyte PNGs even when JPEG was much smaller, causing large requests to fail or time out. Image resizing now tries the smaller encoding first ([#11551](https://github.com/earendil-works/pi/issues/11551)) - Fixed `pi update --extensions` reporting success without warning when npm's stale registry metadata left a package at its old version. pi now reports the installed and expected versions ([#11536](https://github.com/earendil-works/pi/issues/11536)) diff --git a/packages/coding-agent/src/core/auth-storage.ts b/packages/coding-agent/src/core/auth-storage.ts index 5a8fa417a..de4e57244 100644 --- a/packages/coding-agent/src/core/auth-storage.ts +++ b/packages/coding-agent/src/core/auth-storage.ts @@ -83,7 +83,8 @@ export class FileAuthStorageBackend implements AuthStorageBackend { const delayMs = 20; for (let attempt = 1; ; attempt++) { try { - return lockfile.lockSync(path, { realpath: false }); + // Lock the resolved file so symlinked aliases of auth.json share one lock (#11655). + return lockfile.lockSync(path); } catch (error) { if (!getRetryableLockErrorCode(error) || attempt === maxAttempts) throw error; const start = Date.now(); @@ -127,8 +128,8 @@ export class FileAuthStorageBackend implements AuthStorageBackend { signal?.throwIfAborted(); let release: (() => Promise) | undefined; try { + // Lock the resolved file so symlinked aliases of auth.json share one lock (#11655). release = await lockfile.lock(this.authPath, { - realpath: false, retries: 0, stale: staleMs, onCompromised, diff --git a/packages/coding-agent/test/auth-storage.test.ts b/packages/coding-agent/test/auth-storage.test.ts index 8005bf978..35230c73b 100644 --- a/packages/coding-agent/test/auth-storage.test.ts +++ b/packages/coding-agent/test/auth-storage.test.ts @@ -1,4 +1,4 @@ -import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { type CredentialStore, createModels, type Provider } from "@earendil-works/pi-ai"; @@ -366,6 +366,33 @@ describe("AuthStorage", () => { expect(existsSync(authJsonPath)).toBe(false); }); + // #11655: aliases of the same auth.json must share one lock. + test("a symlinked auth.json path waits for the lock held on the real file", async () => { + writeAuthJson({ anthropic: { type: "api_key", key: "stored" } }); + const aliasPath = join(tempDir, "alias.json"); + symlinkSync(authJsonPath, aliasPath); + const release = await lockfile.lock(authJsonPath, { realpath: false }); + const backend = new FileAuthStorageBackend(aliasPath); + const update = vi.fn(async () => ({ result: undefined, next: JSON.stringify({}) })); + const syncUpdate = vi.fn(() => ({ result: undefined, next: JSON.stringify({}) })); + + try { + const controller = new AbortController(); + const pending = backend.withLockAsync(update, { signal: controller.signal }); + await new Promise((resolve) => setTimeout(resolve, 200)); + controller.abort(); + await expect(pending).rejects.toMatchObject({ name: "AbortError" }); + expect(() => backend.withLock(syncUpdate)).toThrow(expect.objectContaining({ code: "ELOCKED" })); + } finally { + await release(); + } + expect(update).not.toHaveBeenCalled(); + expect(syncUpdate).not.toHaveBeenCalled(); + expect(JSON.parse(readFileSync(authJsonPath, "utf8"))).toEqual({ + anthropic: { type: "api_key", key: "stored" }, + }); + }); + test("aborts while waiting for a held file lock without running the mutation later", async () => { writeAuthJson({ anthropic: { type: "api_key", key: "stored" } }); const release = await lockfile.lock(authJsonPath, { realpath: false });