@pharaoh-so/mcp 0.3.36 → 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.
- package/README.md +1 -1
- package/dist/credentials.d.ts +12 -1
- package/dist/credentials.js +116 -4
- package/dist/gateway.js +28 -0
- package/dist/index.js +26 -17
- package/dist/lifecycle.d.ts +7 -2
- package/dist/lifecycle.js +8 -11
- package/dist/refresh.d.ts +18 -6
- package/dist/refresh.js +55 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -152,7 +152,7 @@ claude mcp add --scope user pharaoh -- npx @pharaoh-so/mcp --server https://your
|
|
|
152
152
|
- Credentials are stored with restrictive file permissions (`0600` — owner-read/write only)
|
|
153
153
|
- Authentication uses the [RFC 8628](https://datatracker.ietf.org/doc/html/rfc8628) device authorization flow — no secrets are embedded in the package
|
|
154
154
|
- All communication with the Pharaoh server uses HTTPS/TLS
|
|
155
|
-
-
|
|
155
|
+
- The proxy never reads or sends your local files. Pharaoh reads your code from GitHub through its GitHub App, maps it into a knowledge graph of structural metadata (function names, file paths, dependency relationships), and deletes the clone when mapping ends.
|
|
156
156
|
- Tokens expire after 7 days and are refreshed automatically
|
|
157
157
|
- Only use trusted server URLs — the proxy sends your auth token to the configured server
|
|
158
158
|
|
package/dist/credentials.d.ts
CHANGED
|
@@ -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
|
/**
|
package/dist/credentials.js
CHANGED
|
@@ -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 {
|
|
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
|
-
//
|
|
120
|
-
//
|
|
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
|
|
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
|
-
//
|
|
218
|
-
//
|
|
219
|
-
//
|
|
220
|
-
//
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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(
|
|
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
|
|
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
|
|
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);
|
package/dist/lifecycle.d.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
|
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
|
|
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
|
-
*
|
|
39
|
+
* Renew the access token, coordinating with every other proxy on the machine.
|
|
34
40
|
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
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
|
|
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
|
-
*
|
|
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
|
-
*
|
|
29
|
-
*
|
|
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
|
|
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
|
-
|
|
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.
|
|
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",
|