@pharaoh-so/mcp 0.3.37 → 0.3.38

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.
@@ -30,9 +30,20 @@ export interface Credentials {
30
30
  export declare function readCredentials(path?: string): Credentials | null;
31
31
  /**
32
32
  * Write credentials to disk with restrictive permissions.
33
- * Creates the parent directory (0700) if it doesn't exist.
33
+ * Creates the parent directory (0700) if it doesn't exist. Writes a 0600 temp
34
+ * file and renames it over the target, so a reader never sees a half-written file.
34
35
  */
35
36
  export declare function writeCredentials(creds: Credentials, path?: string): void;
37
+ /**
38
+ * Take the lock that serializes token refreshes across every proxy on this
39
+ * machine. Waits while another proxy holds it; breaks a lock older than
40
+ * LOCK_STALE_MS (its owner died mid-refresh).
41
+ *
42
+ * @param path - Credentials file the lock guards (lock lives at `${path}.lock`).
43
+ * @returns A release function that removes the lock only if this caller still owns it.
44
+ * @throws If the lock stays held for LOCK_WAIT_MS, or a stale lock can't be removed.
45
+ */
46
+ export declare function acquireCredentialsLock(path?: string): Promise<() => void>;
36
47
  /** Delete the credential file. No-op if it doesn't exist. */
37
48
  export declare function deleteCredentials(path?: string): void;
38
49
  /**
@@ -1,10 +1,24 @@
1
1
  /**
2
2
  * Credential storage for Pharaoh MCP proxy — persists device flow tokens to ~/.pharaoh/credentials.json.
3
- * File permissions: 0600 (credentials.json), 0700 (~/.pharaoh/).
3
+ * File permissions: 0600 (credentials.json, its lock and temp files), 0700 (~/.pharaoh/).
4
+ *
5
+ * Every proxy on a machine shares this file and the one OAuth session behind it, so
6
+ * refreshes take a lock (acquireCredentialsLock) and writes are atomic.
4
7
  */
5
- import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
8
+ import { randomUUID } from "node:crypto";
9
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
6
10
  import { homedir } from "node:os";
7
11
  import { dirname, join } from "node:path";
12
+ import { setTimeout as sleep } from "node:timers/promises";
13
+ /**
14
+ * A lock older than this was left by a proxy that died mid-refresh; it may be
15
+ * broken. Above refresh.ts REFRESH_TIMEOUT_MS, so a live refresh never looks stale.
16
+ */
17
+ const LOCK_STALE_MS = 15_000;
18
+ /** How long to wait for another proxy's refresh — longer than LOCK_STALE_MS, so a dead holder's lock gets broken. */
19
+ const LOCK_WAIT_MS = 20_000;
20
+ /** How often to check whether the lock is free. */
21
+ const LOCK_POLL_MS = 50;
8
22
  /**
9
23
  * Current credential schema version. v2 adds refresh_token + refresh_expires_at
10
24
  * so the proxy can silently renew access tokens. v1 files parse as null, which
@@ -33,14 +47,112 @@ export function readCredentials(path = CREDENTIALS_PATH) {
33
47
  }
34
48
  /**
35
49
  * Write credentials to disk with restrictive permissions.
36
- * Creates the parent directory (0700) if it doesn't exist.
50
+ * Creates the parent directory (0700) if it doesn't exist. Writes a 0600 temp
51
+ * file and renames it over the target, so a reader never sees a half-written file.
37
52
  */
38
53
  export function writeCredentials(creds, path = CREDENTIALS_PATH) {
54
+ ensureDir(path);
55
+ const tmp = `${path}.${randomUUID()}.tmp`;
56
+ try {
57
+ writeFileSync(tmp, JSON.stringify(creds, null, "\t"), { mode: 0o600 });
58
+ renameSync(tmp, path);
59
+ }
60
+ catch (err) {
61
+ rmSync(tmp, { force: true });
62
+ throw err;
63
+ }
64
+ }
65
+ /**
66
+ * Take the lock that serializes token refreshes across every proxy on this
67
+ * machine. Waits while another proxy holds it; breaks a lock older than
68
+ * LOCK_STALE_MS (its owner died mid-refresh).
69
+ *
70
+ * @param path - Credentials file the lock guards (lock lives at `${path}.lock`).
71
+ * @returns A release function that removes the lock only if this caller still owns it.
72
+ * @throws If the lock stays held for LOCK_WAIT_MS, or a stale lock can't be removed.
73
+ */
74
+ export async function acquireCredentialsLock(path = CREDENTIALS_PATH) {
75
+ ensureDir(path);
76
+ const lockPath = `${path}.lock`;
77
+ const owner = `${process.pid}:${randomUUID()}`;
78
+ const deadline = Date.now() + LOCK_WAIT_MS;
79
+ for (;;) {
80
+ try {
81
+ writeFileSync(lockPath, owner, { flag: "wx", mode: 0o600 });
82
+ return () => {
83
+ try {
84
+ releaseLock(lockPath, owner);
85
+ }
86
+ catch (err) {
87
+ // The refresh is done; a lock left behind goes stale and is broken later.
88
+ process.stderr.write(`Pharaoh: couldn't remove ${lockPath}: ${err instanceof Error ? err.message : String(err)}\n`);
89
+ }
90
+ };
91
+ }
92
+ catch (err) {
93
+ if (err.code !== "EEXIST")
94
+ throw err;
95
+ }
96
+ // Remove only the lock judged stale: another waiter may already have broken
97
+ // it and taken a fresh one, which an unconditional unlink would delete.
98
+ const holder = lockHolder(lockPath);
99
+ if (holder !== null && lockAgeMs(lockPath) > LOCK_STALE_MS) {
100
+ releaseLock(lockPath, holder);
101
+ continue;
102
+ }
103
+ if (Date.now() >= deadline) {
104
+ throw new Error(`Another Pharaoh process has held ${lockPath} for over ${LOCK_WAIT_MS / 1000}s`);
105
+ }
106
+ await sleep(LOCK_POLL_MS);
107
+ }
108
+ }
109
+ /** The lock's owner tag, or null when no lock exists. */
110
+ function lockHolder(lockPath) {
111
+ try {
112
+ return readFileSync(lockPath, "utf-8");
113
+ }
114
+ catch (err) {
115
+ if (!isMissing(err))
116
+ throw err;
117
+ return null; // released between our create attempt and this read — retry immediately
118
+ }
119
+ }
120
+ /**
121
+ * Remove the lock file — only while it still holds `owner`.
122
+ * @throws When the lock exists but can't be read or removed (permissions).
123
+ */
124
+ function releaseLock(lockPath, owner) {
125
+ try {
126
+ if (readFileSync(lockPath, "utf-8") !== owner)
127
+ return;
128
+ unlinkSync(lockPath);
129
+ }
130
+ catch (err) {
131
+ if (!isMissing(err))
132
+ throw err; // already gone — nothing to release
133
+ }
134
+ }
135
+ /** True when a filesystem error means the file isn't there — the only one the lock helpers expect. */
136
+ function isMissing(err) {
137
+ return err.code === "ENOENT";
138
+ }
139
+ /** Milliseconds since the lock was written; 0 if it vanished meanwhile. */
140
+ function lockAgeMs(lockPath) {
141
+ try {
142
+ return Date.now() - statSync(lockPath).mtimeMs;
143
+ }
144
+ catch (err) {
145
+ if (!isMissing(err))
146
+ throw err;
147
+ return 0; // released between our create attempt and this stat — retry immediately
148
+ }
149
+ }
150
+ /** Create the credentials directory (0700) if it doesn't exist. */
151
+ function ensureDir(path) {
39
152
  const dir = dirname(path);
40
153
  if (!existsSync(dir)) {
41
154
  mkdirSync(dir, { recursive: true, mode: 0o700 });
42
155
  }
43
- writeFileSync(path, JSON.stringify(creds, null, "\t"), { mode: 0o600 });
44
156
  }
45
157
  /** Delete the credential file. No-op if it doesn't exist. */
46
158
  export function deleteCredentials(path = CREDENTIALS_PATH) {
package/dist/gateway.js CHANGED
@@ -17,6 +17,7 @@
17
17
  import { openBrowser, pollOnce, requestDeviceCode, } from "./auth.js";
18
18
  import { readCredentials, writeCredentials } from "./credentials.js";
19
19
  import { resolveSseUrl, tokenToCredentials } from "./helpers.js";
20
+ import { refreshIfNeeded } from "./refresh.js";
20
21
  /**
21
22
  * True when the credentials' access token is at or past its expiry.
22
23
  *
@@ -146,6 +147,13 @@ export function createGateway(opts) {
146
147
  return state.response;
147
148
  }
148
149
  async function handleToolCall() {
150
+ // Another process may have signed in since this proxy started — a sibling
151
+ // proxy finishing device flow, or `npx @pharaoh-so/mcp` in a terminal.
152
+ // Use those credentials rather than asking the user to sign in again.
153
+ if (await useStoredCredentials()) {
154
+ pending = null;
155
+ return null;
156
+ }
149
157
  // No active cycle — start one.
150
158
  if (pending === null) {
151
159
  return startNewCycle();
@@ -174,6 +182,26 @@ export function createGateway(opts) {
174
182
  lastCreds = creds;
175
183
  return !isAccessExpired(creds, now);
176
184
  }
185
+ /**
186
+ * True when credentials on disk are usable now: valid as stored, or
187
+ * refreshed successfully. Sets lastCreds to whatever will be used.
188
+ */
189
+ async function useStoredCredentials() {
190
+ if (hasValidCredentials())
191
+ return true;
192
+ // Read the file again rather than reuse lastCreds: a rejected refresh
193
+ // deletes the file, and its dead token must not be sent on every call.
194
+ const stored = readCredentials(opts.credentialsPath);
195
+ if (!stored)
196
+ return false;
197
+ const refreshed = await refreshIfNeeded(stored, opts.server, opts.credentialsPath);
198
+ if (!refreshed.ok) {
199
+ process.stderr.write(`Pharaoh: stored credentials unusable (${refreshed.action}) — ${refreshed.message}\n`);
200
+ return false;
201
+ }
202
+ lastCreds = refreshed.creds;
203
+ return true;
204
+ }
177
205
  function getCredentials() {
178
206
  return lastCreds;
179
207
  }
package/dist/index.js CHANGED
@@ -14,7 +14,7 @@
14
14
  */
15
15
  import { pollForToken, printActivationPrompt, printAuthSuccess, requestDeviceCode, } from "./auth.js";
16
16
  import { AuthRequiredBackend, createSseBackend } from "./backend.js";
17
- import { deleteCredentials, readCredentials, writeCredentials } from "./credentials.js";
17
+ import { deleteCredentials, readCredentials, writeCredentials, } from "./credentials.js";
18
18
  import { createGateway } from "./gateway.js";
19
19
  import { formatTtl, parseArgs, printLines, printUsage, resolveSseUrl, tokenToCredentials, } from "./helpers.js";
20
20
  import { runInstallSkills } from "./install-skills.js";
@@ -116,11 +116,8 @@ async function main() {
116
116
  // device flow, then swap to SSE via the onAuthorized callback.
117
117
  const needsAuth = outcome === null || (!outcome.ok && outcome.action === "reauth");
118
118
  if (needsAuth) {
119
- // If we reached here via a rejected refresh, delete the stale creds
120
- // file so the gateway's internal readCredentials() doesn't pick up the
121
- // just-invalidated refresh token on restart.
122
- if (outcome && !outcome.ok)
123
- deleteCredentials();
119
+ // A rejected refresh has already removed the stale creds file
120
+ // (refreshIfNeeded, under the lock) unless another process replaced it.
124
121
  // The callback closure captures `proxy` via the destructured `const`
125
122
  // binding declared below. The closure is only invoked once authorization
126
123
  // completes (after the user's device-flow round trip), long after the
@@ -128,7 +125,7 @@ async function main() {
128
125
  const { proxy } = await startInAuthRequiredMode(server, {
129
126
  onAuthorized: async (creds) => {
130
127
  printLines("Pharaoh: authenticated — swapping to SSE backend.");
131
- await proxy.setBackend(createSseBackend(creds.sse_url, creds.access_token));
128
+ await useSse(proxy, creds);
132
129
  },
133
130
  });
134
131
  printLines("Pharaoh: awaiting authentication via Claude Code...");
@@ -147,6 +144,7 @@ async function main() {
147
144
  // handle any mid-session failures by swapping backends in place.
148
145
  const creds = outcome.creds;
149
146
  printLines(`Pharaoh: token valid for ${formatTtl(creds.expires_at)} — connecting`);
147
+ activeCreds = creds;
150
148
  const proxy = await startInSseMode(creds);
151
149
  await waitForProxyLifetime(proxy, server);
152
150
  }
@@ -214,21 +212,22 @@ async function waitForProxyLifetime(proxy, server) {
214
212
  * swap-back or process.exit()'d for a non-recoverable case).
215
213
  */
216
214
  async function recoverFromTokenExpired(proxy, server) {
217
- // Re-read creds from disk — the SSE backend doesn't expose its token, and
218
- // a prior refresh may have rotated what's on disk. Silent-refresh the
219
- // token regardless of the on-disk expiry (the 401 is authoritative: the
220
- // server rejected us, so force a round trip).
221
- const diskCreds = readCredentials();
222
- if (!diskCreds) {
223
- // No creds on disk — shouldn't happen mid-session, but if it does,
215
+ // The rejected token is the one the live backend was built from. Pass
216
+ // those creds, not a fresh disk read: if another proxy already refreshed
217
+ // (which is what invalidated ours — the server keeps one access token per
218
+ // session), refreshIfNeeded sees the newer token on disk and adopts it
219
+ // instead of refreshing again and invalidating everyone else in turn.
220
+ const rejected = activeCreds ?? readCredentials();
221
+ if (!rejected) {
222
+ // No creds anywhere — shouldn't happen mid-session, but if it does,
224
223
  // fall through to auth-required mode to get new ones.
225
224
  await swapToAuthRequired(proxy, server);
226
225
  return true;
227
226
  }
228
- const result = await tryMidSessionRefresh(diskCreds, server);
227
+ const result = await tryMidSessionRefresh(rejected, server);
229
228
  if (result.ok) {
230
229
  printLines("Pharaoh: token refreshed mid-session — swapping SSE backend");
231
- await proxy.setBackend(createSseBackend(result.creds.sse_url, result.creds.access_token));
230
+ await useSse(proxy, result.creds);
232
231
  return true;
233
232
  }
234
233
  // Branch on the explicit classification returned by tryMidSessionRefresh
@@ -244,6 +243,16 @@ async function recoverFromTokenExpired(proxy, server) {
244
243
  printLines(`Pharaoh: mid-session refresh failed (${result.action}) — credentials preserved; restart to retry.`);
245
244
  process.exit(1);
246
245
  }
246
+ /**
247
+ * Credentials the live SSE backend was built from. After a 401 these are the
248
+ * rejected ones — recoverFromTokenExpired compares them with what's on disk.
249
+ */
250
+ let activeCreds = null;
251
+ /** Point the proxy at a fresh SSE backend for `creds`, remembering which creds it uses. */
252
+ async function useSse(proxy, creds) {
253
+ activeCreds = creds;
254
+ await proxy.setBackend(createSseBackend(creds.sse_url, creds.access_token));
255
+ }
247
256
  /**
248
257
  * Swap the live ProxyServer over to an AuthRequiredBackend, wiring an
249
258
  * onAuthorized callback that swaps back to SSE. Used on both mid-session
@@ -258,7 +267,7 @@ async function swapToAuthRequired(proxy, server) {
258
267
  return;
259
268
  }
260
269
  printLines("Pharaoh: re-authenticated — swapping back to SSE backend.");
261
- await proxy.setBackend(createSseBackend(fresh.sse_url, fresh.access_token));
270
+ await useSse(proxy, fresh);
262
271
  });
263
272
  printLines("Pharaoh: re-authentication required — next tool call will surface the URL.");
264
273
  await proxy.setBackend(authBackend);
@@ -5,7 +5,7 @@
5
5
  * booting the full CLI. Imported by main() and exercised as a real runtime
6
6
  * dependency — no "test-only exports" here.
7
7
  */
8
- import { type Credentials } from "./credentials.js";
8
+ import type { Credentials } from "./credentials.js";
9
9
  import { type RefreshFailureAction } from "./refresh.js";
10
10
  /** Discriminated result from tryMidSessionRefresh. */
11
11
  export type MidSessionRefreshResult = {
@@ -21,10 +21,15 @@ export type MidSessionRefreshResult = {
21
21
  * Returns new credentials on success, or a tagged failure carrying the
22
22
  * RefreshFailureAction so the caller can branch on the real classification
23
23
  * instead of re-inferring from disk state.
24
+ *
25
+ * @param creds - The credentials whose access token the server just rejected.
26
+ * When another proxy has already refreshed, its newer credentials come back
27
+ * without a second refresh.
24
28
  */
25
29
  export declare function tryMidSessionRefresh(creds: Credentials, server: string): Promise<MidSessionRefreshResult>;
26
30
  /**
27
31
  * Handle a failed `loadAndRefresh` at startup. Exits the process with the
28
- * appropriate message and creds-file state per the classified action.
32
+ * appropriate message per the classified action. Never deletes credentials:
33
+ * refreshIfNeeded already did, under the lock, if they were the rejected ones.
29
34
  */
30
35
  export declare function handleRefreshFailure(action: RefreshFailureAction, message: string): never;
package/dist/lifecycle.js CHANGED
@@ -1,11 +1,3 @@
1
- /**
2
- * Proxy lifecycle helpers — credential state transitions and re-auth messaging.
3
- *
4
- * Extracted from index.ts so the branching logic can be unit-tested without
5
- * booting the full CLI. Imported by main() and exercised as a real runtime
6
- * dependency — no "test-only exports" here.
7
- */
8
- import { deleteCredentials } from "./credentials.js";
9
1
  import { NPX_COMMAND, printLines } from "./helpers.js";
10
2
  import { refreshIfNeeded } from "./refresh.js";
11
3
  /**
@@ -13,6 +5,10 @@ import { refreshIfNeeded } from "./refresh.js";
13
5
  * Returns new credentials on success, or a tagged failure carrying the
14
6
  * RefreshFailureAction so the caller can branch on the real classification
15
7
  * instead of re-inferring from disk state.
8
+ *
9
+ * @param creds - The credentials whose access token the server just rejected.
10
+ * When another proxy has already refreshed, its newer credentials come back
11
+ * without a second refresh.
16
12
  */
17
13
  export async function tryMidSessionRefresh(creds, server) {
18
14
  const outcome = await refreshIfNeeded(creds, server);
@@ -20,8 +16,9 @@ export async function tryMidSessionRefresh(creds, server) {
20
16
  return { ok: true, creds: outcome.creds };
21
17
  // Log the failure reason before the caller decides what to do.
22
18
  if (outcome.action === "reauth") {
19
+ // refreshIfNeeded already removed the rejected credentials (if no other
20
+ // process had replaced them) — deletion has one owner, under the lock.
23
21
  printLines("Pharaoh: refresh rejected — re-authentication required.", ` ${NPX_COMMAND}`, "");
24
- deleteCredentials();
25
22
  }
26
23
  else {
27
24
  // preserve / transient — keep creds, print diagnostic + next-step hint so
@@ -32,11 +29,11 @@ export async function tryMidSessionRefresh(creds, server) {
32
29
  }
33
30
  /**
34
31
  * Handle a failed `loadAndRefresh` at startup. Exits the process with the
35
- * appropriate message and creds-file state per the classified action.
32
+ * appropriate message per the classified action. Never deletes credentials:
33
+ * refreshIfNeeded already did, under the lock, if they were the rejected ones.
36
34
  */
37
35
  export function handleRefreshFailure(action, message) {
38
36
  if (action === "reauth") {
39
- deleteCredentials();
40
37
  printLines("Pharaoh: session no longer valid — re-authenticate.", ` ${NPX_COMMAND}`, "");
41
38
  process.exit(1);
42
39
  }
package/dist/refresh.d.ts CHANGED
@@ -4,7 +4,13 @@
4
4
  * Called at proxy startup (when access is expired but refresh is valid) and on
5
5
  * mid-session 401 (from proxy.ts TokenExpiredError handler). Success writes new
6
6
  * credentials to disk. Failure returns a classified action so the caller can
7
- * decide whether to delete credentials or preserve them for a later retry.
7
+ * decide whether to preserve credentials for a later retry or re-authenticate.
8
+ *
9
+ * Every proxy on the machine shares one credentials file and one OAuth session,
10
+ * and the server keeps one access token per session — each refresh invalidates
11
+ * every other proxy's token. So refreshes run one at a time under the
12
+ * credentials lock, and a proxy whose token was replaced by another proxy's
13
+ * refresh adopts the new credentials instead of refreshing again.
8
14
  */
9
15
  import type { Credentials } from "./credentials.js";
10
16
  /** Outcome of a refresh attempt. */
@@ -30,16 +36,22 @@ export type RefreshOutcome = {
30
36
  */
31
37
  export type RefreshFailureAction = "reauth" | "preserve" | "transient";
32
38
  /**
33
- * Attempt to renew the access token using the stored refresh token.
39
+ * Renew the access token, coordinating with every other proxy on the machine.
34
40
  *
35
- * On success: writes new credentials to disk and returns them.
36
- * On failure: returns a classified action — caller owns the creds lifecycle.
41
+ * Under the credentials lock: if the file already holds a different, unexpired
42
+ * access token than `creds` (another proxy refreshed, or someone signed in), those
43
+ * credentials are returned without a request. Otherwise the refresh grant runs.
44
+ * On `reauth`, the file is deleted only if it still holds the rejected refresh
45
+ * token; a file rewritten while the request was in flight is adopted instead.
37
46
  *
38
- * @param creds current (possibly-expired-access) credentials
47
+ * @param creds the credentials the caller was using (possibly access-expired or rejected)
39
48
  * @param server Pharaoh server base URL (e.g. "https://mcp.pharaoh.so")
40
49
  * @param path optional custom credentials path (for tests)
50
+ * @param timeoutMs how long the refresh request may take (for tests)
51
+ * @returns New or adopted credentials, or a classified failure. A lock held by
52
+ * another proxy for too long is `transient` (credentials untouched).
41
53
  */
42
- export declare function refreshIfNeeded(creds: Credentials, server: string, path?: string): Promise<RefreshOutcome>;
54
+ export declare function refreshIfNeeded(creds: Credentials, server: string, path?: string, timeoutMs?: number): Promise<RefreshOutcome>;
43
55
  /**
44
56
  * Read credentials from disk and refresh if the access token is expired.
45
57
  *
package/dist/refresh.js CHANGED
@@ -1,4 +1,4 @@
1
- import { isRefreshExpired, readCredentials, writeCredentials } from "./credentials.js";
1
+ import { acquireCredentialsLock, deleteCredentials, isExpired, isRefreshExpired, readCredentials, writeCredentials, } from "./credentials.js";
2
2
  /** Classify a non-200 response from the /token refresh endpoint. */
3
3
  function classifyRefreshError(status, body) {
4
4
  // 503 or network — the server or GitHub API had a blip. Keep the refresh
@@ -23,16 +23,64 @@ function classifyRefreshError(status, body) {
23
23
  return "transient";
24
24
  }
25
25
  /**
26
- * Attempt to renew the access token using the stored refresh token.
26
+ * Deadline for the refresh request. Kept under the lock's stale age
27
+ * (credentials.ts LOCK_STALE_MS) so a refresh still in flight is never broken
28
+ * by another proxy that would then refresh the same token again.
29
+ */
30
+ const REFRESH_TIMEOUT_MS = 10_000;
31
+ /**
32
+ * Renew the access token, coordinating with every other proxy on the machine.
27
33
  *
28
- * On success: writes new credentials to disk and returns them.
29
- * On failure: returns a classified action — caller owns the creds lifecycle.
34
+ * Under the credentials lock: if the file already holds a different, unexpired
35
+ * access token than `creds` (another proxy refreshed, or someone signed in), those
36
+ * credentials are returned without a request. Otherwise the refresh grant runs.
37
+ * On `reauth`, the file is deleted only if it still holds the rejected refresh
38
+ * token; a file rewritten while the request was in flight is adopted instead.
30
39
  *
31
- * @param creds current (possibly-expired-access) credentials
40
+ * @param creds the credentials the caller was using (possibly access-expired or rejected)
32
41
  * @param server Pharaoh server base URL (e.g. "https://mcp.pharaoh.so")
33
42
  * @param path optional custom credentials path (for tests)
43
+ * @param timeoutMs how long the refresh request may take (for tests)
44
+ * @returns New or adopted credentials, or a classified failure. A lock held by
45
+ * another proxy for too long is `transient` (credentials untouched).
46
+ */
47
+ export async function refreshIfNeeded(creds, server, path, timeoutMs = REFRESH_TIMEOUT_MS) {
48
+ let release;
49
+ try {
50
+ release = await acquireCredentialsLock(path);
51
+ }
52
+ catch (err) {
53
+ return {
54
+ ok: false,
55
+ action: "transient",
56
+ message: err instanceof Error ? err.message : String(err),
57
+ };
58
+ }
59
+ try {
60
+ const current = readCredentials(path);
61
+ if (current && current.access_token !== creds.access_token && !isExpired(current)) {
62
+ return { ok: true, creds: current };
63
+ }
64
+ const source = current ?? creds;
65
+ const outcome = await requestRefresh(source, server, timeoutMs, path);
66
+ if (outcome.ok || outcome.action !== "reauth")
67
+ return outcome;
68
+ const after = readCredentials(path);
69
+ if (!after || after.refresh_token === source.refresh_token) {
70
+ deleteCredentials(path);
71
+ return outcome;
72
+ }
73
+ return isExpired(after) ? outcome : { ok: true, creds: after };
74
+ }
75
+ finally {
76
+ release();
77
+ }
78
+ }
79
+ /**
80
+ * One refresh-grant round trip: POST the refresh token, persist and return the
81
+ * new credentials on success, or classify the failure. Caller holds the lock.
34
82
  */
35
- export async function refreshIfNeeded(creds, server, path) {
83
+ async function requestRefresh(creds, server, timeoutMs, path) {
36
84
  // Guard: refresh token itself expired. No point hitting the server — the
37
85
  // grant will fail with invalid_grant. Short-circuit to reauth.
38
86
  if (isRefreshExpired(creds)) {
@@ -53,6 +101,7 @@ export async function refreshIfNeeded(creds, server, path) {
53
101
  body: JSON.stringify({
54
102
  refresh_token: creds.refresh_token,
55
103
  }),
104
+ signal: AbortSignal.timeout(timeoutMs),
56
105
  });
57
106
  }
58
107
  catch (err) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@pharaoh-so/mcp",
3
3
  "mcpName": "so.pharaoh/pharaoh",
4
- "version": "0.3.37",
4
+ "version": "0.3.38",
5
5
  "description": "MCP proxy for Pharaoh — maps codebases into queryable knowledge graphs for AI agents. Enables Claude Code in headless environments (VPS, SSH, CI) via device flow auth.",
6
6
  "type": "module",
7
7
  "main": "dist/index.js",