@pharaoh-so/mcp 0.3.21 → 0.3.22

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/dist/auth.d.ts CHANGED
@@ -12,6 +12,15 @@ export interface TokenResponse {
12
12
  access_token: string;
13
13
  token_type: string;
14
14
  expires_in: number;
15
+ /**
16
+ * Refresh token for silent access-token renewal. Present on all new sessions
17
+ * (server at src/web/routes/agent-auth.ts). Optional at the type level only
18
+ * for backward compat with hypothetical older servers — if missing, the proxy
19
+ * falls through to the full re-auth path on expiry.
20
+ */
21
+ refresh_token?: string;
22
+ /** Refresh token TTL in seconds. */
23
+ refresh_token_expires_in?: number;
15
24
  sse_url: string;
16
25
  github_login?: string;
17
26
  tenant_name?: string;
@@ -1,12 +1,21 @@
1
- /** Current credential schema version — bump when the format changes. */
2
- declare const SCHEMA_VERSION = 1;
1
+ /**
2
+ * Current credential schema version. v2 adds refresh_token + refresh_expires_at
3
+ * so the proxy can silently renew access tokens. v1 files parse as null, which
4
+ * forces one re-auth on upgrade — unavoidable because v1 never had a refresh
5
+ * token to upgrade from.
6
+ */
7
+ declare const SCHEMA_VERSION = 2;
3
8
  /** Stored credential shape. */
4
9
  export interface Credentials {
5
10
  version: typeof SCHEMA_VERSION;
6
11
  access_token: string;
7
- /** ISO 8601 timestamp when the token expires. */
12
+ /** Refresh token used by refreshIfNeeded() to renew the access token. */
13
+ refresh_token: string;
14
+ /** ISO 8601 timestamp when the access token expires. */
8
15
  expires_at: string;
9
- /** Original TTL in seconds (from the token response). */
16
+ /** ISO 8601 timestamp when the refresh token itself expires. */
17
+ refresh_expires_at: string;
18
+ /** Original access token TTL in seconds (from the token response). */
10
19
  expires_in: number;
11
20
  sse_url: string;
12
21
  github_login: string | null;
@@ -15,7 +24,8 @@ export interface Credentials {
15
24
  }
16
25
  /**
17
26
  * Read and parse stored credentials. Returns null if missing, unreadable,
18
- * malformed, or wrong schema version.
27
+ * malformed, or wrong schema version (v1 credentials will return null here,
28
+ * forcing one last re-auth on upgrade to v2).
19
29
  */
20
30
  export declare function readCredentials(path?: string): Credentials | null;
21
31
  /**
@@ -26,8 +36,13 @@ export declare function writeCredentials(creds: Credentials, path?: string): voi
26
36
  /** Delete the credential file. No-op if it doesn't exist. */
27
37
  export declare function deleteCredentials(path?: string): void;
28
38
  /**
29
- * Check if credentials are expired or within 10% of expiry.
39
+ * Check if the access token is expired or within 10% of expiry.
30
40
  * The 10% buffer prevents connecting with a token that will expire mid-session.
31
41
  */
32
42
  export declare function isExpired(creds: Credentials): boolean;
43
+ /**
44
+ * Check if the refresh token itself has expired. When this returns true, even
45
+ * refresh cannot rescue the session — the user must re-authenticate.
46
+ */
47
+ export declare function isRefreshExpired(creds: Credentials): boolean;
33
48
  export {};
@@ -5,13 +5,19 @@
5
5
  import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
6
6
  import { homedir } from "node:os";
7
7
  import { dirname, join } from "node:path";
8
- /** Current credential schema version — bump when the format changes. */
9
- const SCHEMA_VERSION = 1;
8
+ /**
9
+ * Current credential schema version. v2 adds refresh_token + refresh_expires_at
10
+ * so the proxy can silently renew access tokens. v1 files parse as null, which
11
+ * forces one re-auth on upgrade — unavoidable because v1 never had a refresh
12
+ * token to upgrade from.
13
+ */
14
+ const SCHEMA_VERSION = 2;
10
15
  /** Default credential file path. */
11
16
  const CREDENTIALS_PATH = join(homedir(), ".pharaoh", "credentials.json");
12
17
  /**
13
18
  * Read and parse stored credentials. Returns null if missing, unreadable,
14
- * malformed, or wrong schema version.
19
+ * malformed, or wrong schema version (v1 credentials will return null here,
20
+ * forcing one last re-auth on upgrade to v2).
15
21
  */
16
22
  export function readCredentials(path = CREDENTIALS_PATH) {
17
23
  try {
@@ -46,7 +52,7 @@ export function deleteCredentials(path = CREDENTIALS_PATH) {
46
52
  }
47
53
  }
48
54
  /**
49
- * Check if credentials are expired or within 10% of expiry.
55
+ * Check if the access token is expired or within 10% of expiry.
50
56
  * The 10% buffer prevents connecting with a token that will expire mid-session.
51
57
  */
52
58
  export function isExpired(creds) {
@@ -58,6 +64,16 @@ export function isExpired(creds) {
58
64
  const bufferMs = totalTtlMs * 0.1;
59
65
  return now >= expiresAt - bufferMs;
60
66
  }
67
+ /**
68
+ * Check if the refresh token itself has expired. When this returns true, even
69
+ * refresh cannot rescue the session — the user must re-authenticate.
70
+ */
71
+ export function isRefreshExpired(creds) {
72
+ const refreshExpiresAt = new Date(creds.refresh_expires_at).getTime();
73
+ if (Number.isNaN(refreshExpiresAt))
74
+ return true;
75
+ return Date.now() >= refreshExpiresAt;
76
+ }
61
77
  /** Type guard for valid credential objects. */
62
78
  function isValidCredentials(value) {
63
79
  if (typeof value !== "object" || value === null)
@@ -65,7 +81,9 @@ function isValidCredentials(value) {
65
81
  const obj = value;
66
82
  return (obj.version === SCHEMA_VERSION &&
67
83
  typeof obj.access_token === "string" &&
84
+ typeof obj.refresh_token === "string" &&
68
85
  typeof obj.expires_at === "string" &&
86
+ typeof obj.refresh_expires_at === "string" &&
69
87
  typeof obj.expires_in === "number" &&
70
88
  typeof obj.sse_url === "string" &&
71
89
  Array.isArray(obj.repos));
package/dist/helpers.d.ts CHANGED
@@ -20,7 +20,14 @@ export declare function printUsage(): void;
20
20
  * Falls back to `${server}/sse` if the URL is missing, malformed, or cross-origin.
21
21
  */
22
22
  export declare function resolveSseUrl(tokenSseUrl: string | undefined, server: string): string;
23
- /** Convert a token response to storable credentials. */
23
+ /**
24
+ * Convert a token response to storable credentials.
25
+ *
26
+ * @throws if the server response omits refresh_token — this is a breaking
27
+ * contract, not a graceful degradation: without a refresh token the proxy
28
+ * will eventually hit the same silent-expiry cliff we're trying to eliminate.
29
+ * Fail loud so the server regression surfaces instead of being absorbed.
30
+ */
24
31
  export declare function tokenToCredentials(token: TokenResponse, sseUrl: string): Credentials;
25
32
  /** Format remaining TTL as human-readable string (e.g. "5d 12h"). */
26
33
  export declare function formatTtl(expiresAt: string): string;
package/dist/helpers.js CHANGED
@@ -66,12 +66,26 @@ export function resolveSseUrl(tokenSseUrl, server) {
66
66
  return fallback;
67
67
  }
68
68
  }
69
- /** Convert a token response to storable credentials. */
69
+ /**
70
+ * Convert a token response to storable credentials.
71
+ *
72
+ * @throws if the server response omits refresh_token — this is a breaking
73
+ * contract, not a graceful degradation: without a refresh token the proxy
74
+ * will eventually hit the same silent-expiry cliff we're trying to eliminate.
75
+ * Fail loud so the server regression surfaces instead of being absorbed.
76
+ */
70
77
  export function tokenToCredentials(token, sseUrl) {
78
+ if (!token.refresh_token || !token.refresh_token_expires_in) {
79
+ throw new Error("Pharaoh server response missing refresh_token — server is older than this client. " +
80
+ "Update the server to a version that issues refresh tokens, or downgrade the client.");
81
+ }
82
+ const now = Date.now();
71
83
  return {
72
- version: 1,
84
+ version: 2,
73
85
  access_token: token.access_token,
74
- expires_at: new Date(Date.now() + token.expires_in * 1000).toISOString(),
86
+ refresh_token: token.refresh_token,
87
+ expires_at: new Date(now + token.expires_in * 1000).toISOString(),
88
+ refresh_expires_at: new Date(now + token.refresh_token_expires_in * 1000).toISOString(),
75
89
  expires_in: token.expires_in,
76
90
  sse_url: sseUrl,
77
91
  github_login: token.github_login ?? null,
package/dist/index.js CHANGED
@@ -11,10 +11,12 @@
11
11
  * - Proxy (pipe): require pre-existing credentials, bridge stdio ↔ SSE.
12
12
  */
13
13
  import { pollForToken, printActivationPrompt, printAuthSuccess, requestDeviceCode, } from "./auth.js";
14
- import { deleteCredentials, isExpired, readCredentials, writeCredentials } from "./credentials.js";
15
- import { formatTtl, NPX_COMMAND, parseArgs, printLines, printUsage, resolveSseUrl, tokenToCredentials, } from "./helpers.js";
14
+ import { deleteCredentials, writeCredentials } from "./credentials.js";
15
+ import { formatTtl, parseArgs, printLines, printUsage, resolveSseUrl, tokenToCredentials, } from "./helpers.js";
16
16
  import { runInstallSkills } from "./install-skills.js";
17
+ import { exitForReauth, handleRefreshFailure, tryMidSessionRefresh } from "./lifecycle.js";
17
18
  import { startProxy, TenantSuspendedError, TokenExpiredError } from "./proxy.js";
19
+ import { loadAndRefresh } from "./refresh.js";
18
20
  const BANNER = [
19
21
  "",
20
22
  " ___ _ _ _ ___ _ ___ _ _ ",
@@ -57,7 +59,6 @@ async function main() {
57
59
  printLines("Pharaoh: credentials and MCP config cleared");
58
60
  process.exit(0);
59
61
  }
60
- const creds = readCredentials();
61
62
  const isInteractive = Boolean(process.stdin.isTTY);
62
63
  // ── Interactive mode (user running in a terminal) ──
63
64
  // Full setup every time: fresh auth → register MCP → install skills → done.
@@ -86,11 +87,18 @@ async function main() {
86
87
  process.exit(0);
87
88
  }
88
89
  // ── Proxy mode (Claude Code spawned us as a stdio MCP server) ──
89
- // If no credentials, we can't run the device flow (no TTY for user interaction).
90
- if (!creds || isExpired(creds)) {
91
- printLines("Pharaoh: no valid credentials — cannot start proxy.", "Run this command first to authenticate:", ` ${NPX_COMMAND}`, "");
92
- process.exit(1);
90
+ // Load credentials; refresh silently if the access token is expired but the
91
+ // refresh token is still valid. Anything else → exit with guidance.
92
+ const outcome = await loadAndRefresh(server);
93
+ if (outcome === null) {
94
+ // No credentials file at all — first-time run or user logged out.
95
+ exitForReauth("no credentials — run authentication first");
93
96
  }
97
+ if (!outcome.ok) {
98
+ handleRefreshFailure(outcome.action, outcome.message);
99
+ return; // unreachable — handleRefreshFailure calls process.exit
100
+ }
101
+ let creds = outcome.creds;
94
102
  // Valid credentials — ensure skills are installed before starting proxy
95
103
  // Silently installs/updates on first run or package update; fast no-op if current.
96
104
  try {
@@ -105,8 +113,35 @@ async function main() {
105
113
  }
106
114
  catch (err) {
107
115
  if (err instanceof TokenExpiredError) {
108
- printLines("Pharaoh: token expired or revoked.", "Run this command to re-authenticate:", ` ${NPX_COMMAND}`, "");
109
- deleteCredentials();
116
+ // Mid-session token expired — attempt one silent refresh + retry
117
+ // before giving up. This is what makes the proxy self-heal across
118
+ // long sessions rather than dying at arbitrary moments.
119
+ const retryCreds = await tryMidSessionRefresh(creds, server);
120
+ if (retryCreds) {
121
+ creds = retryCreds;
122
+ printLines("Pharaoh: token refreshed mid-session — reconnecting");
123
+ try {
124
+ await startProxy(creds.sse_url, creds.access_token);
125
+ return;
126
+ }
127
+ catch (retryErr) {
128
+ if (retryErr instanceof TokenExpiredError) {
129
+ // Second 401 after a successful refresh — the fresh tokens
130
+ // are already rejected, so the session is genuinely dead.
131
+ // Delete creds before exit so the next spawn shows the
132
+ // re-auth prompt instead of looping on the just-rejected
133
+ // access token (which `isExpired` would still say is fresh).
134
+ deleteCredentials();
135
+ exitForReauth("token rejected after refresh");
136
+ }
137
+ if (retryErr instanceof TenantSuspendedError) {
138
+ printLines(`Pharaoh: ${retryErr.message}`);
139
+ process.exit(1);
140
+ }
141
+ throw retryErr;
142
+ }
143
+ }
144
+ // Refresh failed — exit message already printed by tryMidSessionRefresh
110
145
  process.exit(1);
111
146
  }
112
147
  if (err instanceof TenantSuspendedError) {
@@ -0,0 +1,30 @@
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 { type Credentials } from "./credentials.js";
9
+ import { type RefreshFailureAction } from "./refresh.js";
10
+ /**
11
+ * Attempt a silent refresh triggered by a mid-session TokenExpiredError.
12
+ * Returns new credentials on success, null on any failure (messages already
13
+ * printed). Caller decides whether to retry startProxy.
14
+ */
15
+ export declare function tryMidSessionRefresh(creds: Credentials, server: string): Promise<Credentials | null>;
16
+ /**
17
+ * Handle a failed `loadAndRefresh` at startup. Exits the process with the
18
+ * appropriate message and creds-file state per the classified action.
19
+ */
20
+ export declare function handleRefreshFailure(action: RefreshFailureAction, message: string): never;
21
+ /**
22
+ * Print a re-auth prompt and exit.
23
+ *
24
+ * Used for terminal lifecycle states where no refresh is possible:
25
+ * - Missing credentials file (first-time run, post-logout)
26
+ * - A fresh access token is rejected mid-session AFTER a successful refresh
27
+ * (deleteCredentials is called explicitly before this in that case so the
28
+ * next spawn sees a cold-start rather than looping on stale creds)
29
+ */
30
+ export declare function exitForReauth(reason: string): never;
@@ -0,0 +1,59 @@
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
+ import { NPX_COMMAND, printLines } from "./helpers.js";
10
+ import { refreshIfNeeded } from "./refresh.js";
11
+ /**
12
+ * Attempt a silent refresh triggered by a mid-session TokenExpiredError.
13
+ * Returns new credentials on success, null on any failure (messages already
14
+ * printed). Caller decides whether to retry startProxy.
15
+ */
16
+ export async function tryMidSessionRefresh(creds, server) {
17
+ const outcome = await refreshIfNeeded(creds, server);
18
+ if (outcome.ok)
19
+ return outcome.creds;
20
+ // Log the failure reason before caller's exit path runs.
21
+ if (outcome.action === "reauth") {
22
+ printLines("Pharaoh: refresh rejected — re-authentication required.", ` ${NPX_COMMAND}`, "");
23
+ deleteCredentials();
24
+ }
25
+ else {
26
+ // preserve / transient — keep creds, print diagnostic + next-step hint so
27
+ // the user knows their credentials file is intact (matches startup path).
28
+ printLines(`Pharaoh: refresh failed (${outcome.action}) — ${outcome.message}`, "Credentials preserved; next startup will retry.", "");
29
+ }
30
+ return null;
31
+ }
32
+ /**
33
+ * Handle a failed `loadAndRefresh` at startup. Exits the process with the
34
+ * appropriate message and creds-file state per the classified action.
35
+ */
36
+ export function handleRefreshFailure(action, message) {
37
+ if (action === "reauth") {
38
+ deleteCredentials();
39
+ printLines("Pharaoh: session no longer valid — re-authenticate.", ` ${NPX_COMMAND}`, "");
40
+ process.exit(1);
41
+ }
42
+ // preserve / transient — keep creds, exit with diagnostic. Next spawn will
43
+ // try again; if the underlying cause clears, it just works.
44
+ printLines(`Pharaoh: refresh failed (${action}) — ${message}`, "Credentials preserved; next startup will retry.", "");
45
+ process.exit(1);
46
+ }
47
+ /**
48
+ * Print a re-auth prompt and exit.
49
+ *
50
+ * Used for terminal lifecycle states where no refresh is possible:
51
+ * - Missing credentials file (first-time run, post-logout)
52
+ * - A fresh access token is rejected mid-session AFTER a successful refresh
53
+ * (deleteCredentials is called explicitly before this in that case so the
54
+ * next spawn sees a cold-start rather than looping on stale creds)
55
+ */
56
+ export function exitForReauth(reason) {
57
+ printLines(`Pharaoh: ${reason}.`, "Run this command to authenticate:", ` ${NPX_COMMAND}`, "");
58
+ process.exit(1);
59
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Silent access-token renewal via OAuth2 refresh grant.
3
+ *
4
+ * Called at proxy startup (when access is expired but refresh is valid) and on
5
+ * mid-session 401 (from proxy.ts TokenExpiredError handler). Success writes new
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.
8
+ */
9
+ import type { Credentials } from "./credentials.js";
10
+ /** Outcome of a refresh attempt. */
11
+ export type RefreshOutcome = {
12
+ ok: true;
13
+ creds: Credentials;
14
+ } | {
15
+ ok: false;
16
+ action: RefreshFailureAction;
17
+ message: string;
18
+ };
19
+ /**
20
+ * How the caller should handle a failed refresh.
21
+ *
22
+ * - `reauth`: server says the refresh token is dead (revoked, expired past
23
+ * grace, user removed from org). Delete credentials and prompt
24
+ * for re-auth on next start.
25
+ * - `preserve`: transient server/network failure (503, fetch error). Keep the
26
+ * credentials file intact — the next startup may succeed.
27
+ * - `transient`: unexpected response shape we can't confidently classify. Keep
28
+ * credentials, exit with a diagnostic message. If this branch
29
+ * fires often in production, tighten classification.
30
+ */
31
+ export type RefreshFailureAction = "reauth" | "preserve" | "transient";
32
+ /**
33
+ * Attempt to renew the access token using the stored refresh token.
34
+ *
35
+ * On success: writes new credentials to disk and returns them.
36
+ * On failure: returns a classified action — caller owns the creds lifecycle.
37
+ *
38
+ * @param creds current (possibly-expired-access) credentials
39
+ * @param server Pharaoh server base URL (e.g. "https://mcp.pharaoh.so")
40
+ * @param path optional custom credentials path (for tests)
41
+ */
42
+ export declare function refreshIfNeeded(creds: Credentials, server: string, path?: string): Promise<RefreshOutcome>;
43
+ /**
44
+ * Read credentials from disk and refresh if the access token is expired.
45
+ *
46
+ * Returns:
47
+ * - `null` if no credentials exist (caller should show re-auth instructions)
48
+ * - `RefreshOutcome` with `ok: true` if creds are fresh or refresh succeeded
49
+ * - `RefreshOutcome` with `ok: false` if refresh failed (check `.action`)
50
+ *
51
+ * Intended use: called at proxy startup to replace the current "die if expired"
52
+ * branch in index.ts.
53
+ */
54
+ export declare function loadAndRefresh(server: string, path?: string): Promise<RefreshOutcome | null>;
@@ -0,0 +1,157 @@
1
+ import { isRefreshExpired, readCredentials, writeCredentials } from "./credentials.js";
2
+ /** Classify a non-200 response from the /token refresh endpoint. */
3
+ function classifyRefreshError(status, body) {
4
+ // 503 or network — the server or GitHub API had a blip. Keep the refresh
5
+ // token; the next proxy spawn will retry. Blowing away good creds on a
6
+ // transient failure is exactly the "forced re-auth" pattern we're trying
7
+ // to eliminate (VestaTech-BV churn was this exact shape).
8
+ if (status >= 500)
9
+ return "preserve";
10
+ // 400 invalid_grant covers all three terminal cases from the server:
11
+ // • user removed from GitHub org (not_member)
12
+ // • session unreadable / pre-GCM CBC (reauth_required)
13
+ // • refresh token expired or rotated out of grace window
14
+ // All three mean: the creds cannot produce a working session. Delete them
15
+ // so the next spawn shows the re-auth prompt instead of looping forever.
16
+ if (status === 400 && body.error === "invalid_grant")
17
+ return "reauth";
18
+ // Anything else — 401 without invalid_grant, 403, 404, parse error, etc.
19
+ // Don't delete creds (might be recoverable), but don't pretend it's
20
+ // transient either. Log-friendly exit, preserve creds, let the next spawn
21
+ // retry. If this case fires in practice, we'll see it in logs and tighten
22
+ // the classification.
23
+ return "transient";
24
+ }
25
+ /**
26
+ * Attempt to renew the access token using the stored refresh token.
27
+ *
28
+ * On success: writes new credentials to disk and returns them.
29
+ * On failure: returns a classified action — caller owns the creds lifecycle.
30
+ *
31
+ * @param creds current (possibly-expired-access) credentials
32
+ * @param server Pharaoh server base URL (e.g. "https://mcp.pharaoh.so")
33
+ * @param path optional custom credentials path (for tests)
34
+ */
35
+ export async function refreshIfNeeded(creds, server, path) {
36
+ // Guard: refresh token itself expired. No point hitting the server — the
37
+ // grant will fail with invalid_grant. Short-circuit to reauth.
38
+ if (isRefreshExpired(creds)) {
39
+ return {
40
+ ok: false,
41
+ action: "reauth",
42
+ message: "Refresh token expired",
43
+ };
44
+ }
45
+ let res;
46
+ try {
47
+ // POST to /device/token-refresh — RFC 8628 device-flow refresh. Distinct
48
+ // from /token (browser OAuth) because device flow has no client_id —
49
+ // the refresh_token itself is the credential.
50
+ res = await fetch(`${server}/device/token-refresh`, {
51
+ method: "POST",
52
+ headers: { "Content-Type": "application/json" },
53
+ body: JSON.stringify({
54
+ refresh_token: creds.refresh_token,
55
+ }),
56
+ });
57
+ }
58
+ catch (err) {
59
+ // Network failure — keep creds, let the next spawn retry.
60
+ return {
61
+ ok: false,
62
+ action: "preserve",
63
+ message: `Network error: ${err instanceof Error ? err.message : String(err)}`,
64
+ };
65
+ }
66
+ if (res.ok) {
67
+ // Parse body inside try/catch — a 200 response with truncated/malformed
68
+ // JSON (CDN/proxy mangling, connection drop mid-stream) would otherwise
69
+ // throw SyntaxError out of refreshIfNeeded and hit main()'s redact-only
70
+ // catch, giving the user no classification.
71
+ let token;
72
+ try {
73
+ token = (await res.json());
74
+ }
75
+ catch (err) {
76
+ return {
77
+ ok: false,
78
+ action: "transient",
79
+ message: `Malformed JSON in refresh response: ${err instanceof Error ? err.message : String(err)}`,
80
+ };
81
+ }
82
+ if (!token.access_token ||
83
+ !token.refresh_token ||
84
+ !token.expires_in ||
85
+ !token.refresh_token_expires_in) {
86
+ // Server OK'd the request but returned an incomplete body. Treat as
87
+ // transient rather than reauth — the creds we have are still valid.
88
+ // (0 is not a legal OAuth2 TTL per RFC 6749; a server returning 0 is
89
+ // a regression, not a signal to delete credentials.)
90
+ return {
91
+ ok: false,
92
+ action: "transient",
93
+ message: "Refresh response missing required fields",
94
+ };
95
+ }
96
+ const now = Date.now();
97
+ const newCreds = {
98
+ ...creds,
99
+ access_token: token.access_token,
100
+ refresh_token: token.refresh_token,
101
+ expires_at: new Date(now + token.expires_in * 1000).toISOString(),
102
+ refresh_expires_at: new Date(now + token.refresh_token_expires_in * 1000).toISOString(),
103
+ expires_in: token.expires_in,
104
+ };
105
+ // Persist new credentials. If the disk write fails (EACCES, ENOSPC,
106
+ // EROFS), report `preserve` — the server already rotated the token
107
+ // server-side, so the old refresh_token is now in grace. Returning
108
+ // `ok: true` here would mean the next spawn reads stale creds and
109
+ // attempts to refresh with a rotated token, forcing an unnecessary
110
+ // re-auth. Preserving lets the user fix the disk issue and retry
111
+ // within the grace window.
112
+ try {
113
+ writeCredentials(newCreds, path);
114
+ }
115
+ catch (err) {
116
+ return {
117
+ ok: false,
118
+ action: "preserve",
119
+ message: `Could not persist refreshed credentials: ${err instanceof Error ? err.message : String(err)}`,
120
+ };
121
+ }
122
+ return { ok: true, creds: newCreds };
123
+ }
124
+ let body = {};
125
+ try {
126
+ body = (await res.json());
127
+ }
128
+ catch {
129
+ // ALLOWED-CATCH: non-JSON body — classify on status alone
130
+ }
131
+ const action = classifyRefreshError(res.status, body);
132
+ const message = body.error_description ?? body.error ?? `HTTP ${res.status} ${res.statusText}`;
133
+ return { ok: false, action, message };
134
+ }
135
+ /**
136
+ * Read credentials from disk and refresh if the access token is expired.
137
+ *
138
+ * Returns:
139
+ * - `null` if no credentials exist (caller should show re-auth instructions)
140
+ * - `RefreshOutcome` with `ok: true` if creds are fresh or refresh succeeded
141
+ * - `RefreshOutcome` with `ok: false` if refresh failed (check `.action`)
142
+ *
143
+ * Intended use: called at proxy startup to replace the current "die if expired"
144
+ * branch in index.ts.
145
+ */
146
+ export async function loadAndRefresh(server, path) {
147
+ const creds = readCredentials(path);
148
+ if (!creds)
149
+ return null;
150
+ // Access token still valid — no refresh needed.
151
+ const expiresAt = new Date(creds.expires_at).getTime();
152
+ const bufferMs = creds.expires_in * 1000 * 0.1;
153
+ if (Date.now() < expiresAt - bufferMs) {
154
+ return { ok: true, creds };
155
+ }
156
+ return refreshIfNeeded(creds, server, path);
157
+ }
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.21",
4
+ "version": "0.3.22",
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",