@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 +9 -0
- package/dist/credentials.d.ts +21 -6
- package/dist/credentials.js +22 -4
- package/dist/helpers.d.ts +8 -1
- package/dist/helpers.js +17 -3
- package/dist/index.js +44 -9
- package/dist/lifecycle.d.ts +30 -0
- package/dist/lifecycle.js +59 -0
- package/dist/refresh.d.ts +54 -0
- package/dist/refresh.js +157 -0
- package/package.json +1 -1
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;
|
package/dist/credentials.d.ts
CHANGED
|
@@ -1,12 +1,21 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
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 {};
|
package/dist/credentials.js
CHANGED
|
@@ -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
|
-
/**
|
|
9
|
-
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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:
|
|
84
|
+
version: 2,
|
|
73
85
|
access_token: token.access_token,
|
|
74
|
-
|
|
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,
|
|
15
|
-
import { formatTtl,
|
|
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
|
-
//
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
109
|
-
|
|
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>;
|
package/dist/refresh.js
ADDED
|
@@ -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.
|
|
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",
|