@pharaoh-so/mcp 0.3.24 → 0.3.26
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 +6 -0
- package/dist/auth.js +8 -0
- package/dist/backend.d.ts +12 -3
- package/dist/backend.js +52 -6
- package/dist/gateway.js +25 -6
- package/dist/index.js +167 -55
- package/dist/lifecycle.d.ts +13 -3
- package/dist/lifecycle.js +6 -5
- package/dist/proxy-server.d.ts +39 -7
- package/dist/proxy-server.js +90 -12
- package/dist/proxy.d.ts +13 -20
- package/dist/proxy.js +25 -42
- package/package.json +1 -1
package/dist/auth.d.ts
CHANGED
|
@@ -85,6 +85,12 @@ export declare function pollForToken(baseUrl: string, deviceCode: string, interv
|
|
|
85
85
|
* to pre-fill the activation URL in the user's browser. Silent-on-failure is
|
|
86
86
|
* critical for headless environments: a missing `open`/`xdg-open` binary must
|
|
87
87
|
* not crash the proxy.
|
|
88
|
+
*
|
|
89
|
+
* Test / CI escape hatch: set PHARAOH_MCP_DISABLE_BROWSER=1 when spawning the
|
|
90
|
+
* proxy as a child process during E2E testing. The openBrowser dependency-
|
|
91
|
+
* injection path in gateway.ts is sufficient for in-process tests, but child-
|
|
92
|
+
* process tests bypass module mocking — the env-var short-circuit catches
|
|
93
|
+
* every spawn path (gateway, printActivationPrompt, direct index.ts calls).
|
|
88
94
|
*/
|
|
89
95
|
export declare function openBrowser(url: string): void;
|
|
90
96
|
/**
|
package/dist/auth.js
CHANGED
|
@@ -112,8 +112,16 @@ export async function pollForToken(baseUrl, deviceCode, interval) {
|
|
|
112
112
|
* to pre-fill the activation URL in the user's browser. Silent-on-failure is
|
|
113
113
|
* critical for headless environments: a missing `open`/`xdg-open` binary must
|
|
114
114
|
* not crash the proxy.
|
|
115
|
+
*
|
|
116
|
+
* Test / CI escape hatch: set PHARAOH_MCP_DISABLE_BROWSER=1 when spawning the
|
|
117
|
+
* proxy as a child process during E2E testing. The openBrowser dependency-
|
|
118
|
+
* injection path in gateway.ts is sufficient for in-process tests, but child-
|
|
119
|
+
* process tests bypass module mocking — the env-var short-circuit catches
|
|
120
|
+
* every spawn path (gateway, printActivationPrompt, direct index.ts calls).
|
|
115
121
|
*/
|
|
116
122
|
export function openBrowser(url) {
|
|
123
|
+
if (process.env.PHARAOH_MCP_DISABLE_BROWSER === "1")
|
|
124
|
+
return;
|
|
117
125
|
if (process.platform === "darwin") {
|
|
118
126
|
execFile("open", [url], () => { });
|
|
119
127
|
}
|
package/dist/backend.d.ts
CHANGED
|
@@ -26,6 +26,12 @@ export interface Backend {
|
|
|
26
26
|
start?(): Promise<void>;
|
|
27
27
|
/** Optional teardown (e.g. close SSE). Idempotent. */
|
|
28
28
|
stop?(): Promise<void>;
|
|
29
|
+
/**
|
|
30
|
+
* Optional long-running promise that completes on clean exit or rejects with
|
|
31
|
+
* a typed error (TokenExpiredError, TenantSuspendedError) the main loop can
|
|
32
|
+
* act on. Backends without a supervisor loop (AuthRequiredBackend) omit this.
|
|
33
|
+
*/
|
|
34
|
+
waitForExit?(): Promise<void>;
|
|
29
35
|
}
|
|
30
36
|
/**
|
|
31
37
|
* Minimal pre-auth catalog. Single tool — matches Pharaoh's "call this first"
|
|
@@ -41,13 +47,16 @@ export declare const AUTH_REQUIRED_TOOLS: Tool[];
|
|
|
41
47
|
/**
|
|
42
48
|
* Pre-auth backend. Every tool call triggers a device-flow state step via the
|
|
43
49
|
* gateway; returns a structured auth-required response. Once the gateway
|
|
44
|
-
* reports the user has authorized (handleToolCall returns null),
|
|
45
|
-
*
|
|
50
|
+
* reports the user has authorized (handleToolCall returns null), this backend
|
|
51
|
+
* invokes the onAuthorized callback so the caller (index.ts main loop) can
|
|
52
|
+
* swap the ProxyServer over to an SseProxyBackend. The backend itself does NOT
|
|
53
|
+
* swap — callbacks keep the ownership of the proxy reference in one place.
|
|
46
54
|
*/
|
|
47
55
|
export declare class AuthRequiredBackend implements Backend {
|
|
48
56
|
private readonly gateway;
|
|
57
|
+
private readonly onAuthorized?;
|
|
49
58
|
readonly name: "auth-required";
|
|
50
|
-
constructor(gateway: DeviceFlowGateway);
|
|
59
|
+
constructor(gateway: DeviceFlowGateway, onAuthorized?: (() => Promise<void>) | undefined);
|
|
51
60
|
listTools(): Promise<Tool[]>;
|
|
52
61
|
callTool(_name: string, _args: Record<string, unknown>): Promise<CallToolResult>;
|
|
53
62
|
}
|
package/dist/backend.js
CHANGED
|
@@ -48,14 +48,18 @@ export const AUTH_REQUIRED_TOOLS = [
|
|
|
48
48
|
/**
|
|
49
49
|
* Pre-auth backend. Every tool call triggers a device-flow state step via the
|
|
50
50
|
* gateway; returns a structured auth-required response. Once the gateway
|
|
51
|
-
* reports the user has authorized (handleToolCall returns null),
|
|
52
|
-
*
|
|
51
|
+
* reports the user has authorized (handleToolCall returns null), this backend
|
|
52
|
+
* invokes the onAuthorized callback so the caller (index.ts main loop) can
|
|
53
|
+
* swap the ProxyServer over to an SseProxyBackend. The backend itself does NOT
|
|
54
|
+
* swap — callbacks keep the ownership of the proxy reference in one place.
|
|
53
55
|
*/
|
|
54
56
|
export class AuthRequiredBackend {
|
|
55
57
|
gateway;
|
|
58
|
+
onAuthorized;
|
|
56
59
|
name = "auth-required";
|
|
57
|
-
constructor(gateway) {
|
|
60
|
+
constructor(gateway, onAuthorized) {
|
|
58
61
|
this.gateway = gateway;
|
|
62
|
+
this.onAuthorized = onAuthorized;
|
|
59
63
|
}
|
|
60
64
|
async listTools() {
|
|
61
65
|
return AUTH_REQUIRED_TOOLS;
|
|
@@ -63,9 +67,41 @@ export class AuthRequiredBackend {
|
|
|
63
67
|
async callTool(_name, _args) {
|
|
64
68
|
const response = await this.gateway.handleToolCall();
|
|
65
69
|
if (response === null) {
|
|
66
|
-
// Credentials just became valid.
|
|
67
|
-
//
|
|
68
|
-
//
|
|
70
|
+
// Credentials just became valid. Fire the authorization callback
|
|
71
|
+
// BEFORE returning success so the caller's backend swap is in place
|
|
72
|
+
// by the time the next tool call arrives.
|
|
73
|
+
if (this.onAuthorized) {
|
|
74
|
+
try {
|
|
75
|
+
await this.onAuthorized();
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
// TenantSuspendedError from the post-auth SSE handshake means
|
|
79
|
+
// the tenant was suspended between authorize and swap. Rethrow
|
|
80
|
+
// so the supervisor exits cleanly instead of looping in
|
|
81
|
+
// auth-required mode forever.
|
|
82
|
+
if (err instanceof TenantSuspendedError)
|
|
83
|
+
throw err;
|
|
84
|
+
// Any other swap failure (SSE start rejected, gateway returned
|
|
85
|
+
// no creds, network blip) must surface as an error to the
|
|
86
|
+
// agent. Returning success would leave the proxy in a zombie
|
|
87
|
+
// state — this backend still active, onAuthorized already
|
|
88
|
+
// fired, every subsequent tool call burning a fresh device
|
|
89
|
+
// flow cycle. isError:true lets the agent see the failure and
|
|
90
|
+
// retry the tool call, which will re-enter the gateway's post-
|
|
91
|
+
// auth branch and attempt the swap again.
|
|
92
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
93
|
+
process.stderr.write(`Pharaoh: onAuthorized callback failed — ${msg}\n`);
|
|
94
|
+
return {
|
|
95
|
+
content: [
|
|
96
|
+
{
|
|
97
|
+
type: "text",
|
|
98
|
+
text: `Pharaoh: authenticated but backend swap failed — ${msg}. Retry the tool call.`,
|
|
99
|
+
},
|
|
100
|
+
],
|
|
101
|
+
isError: true,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
}
|
|
69
105
|
return {
|
|
70
106
|
content: [
|
|
71
107
|
{
|
|
@@ -207,6 +243,16 @@ export class SseProxyBackend {
|
|
|
207
243
|
if (err instanceof TokenExpiredError || err instanceof TenantSuspendedError) {
|
|
208
244
|
throw err;
|
|
209
245
|
}
|
|
246
|
+
// connectOnce() inside reconnectAfter() can throw an SseError with
|
|
247
|
+
// code === 401 / 403 when the stored token is rejected on
|
|
248
|
+
// reconnect (the common mid-session token-expiry shape). Run it
|
|
249
|
+
// through classifySSEError — that helper throws typed errors,
|
|
250
|
+
// which propagate out of the current catch via the enclosing
|
|
251
|
+
// catch's implicit re-throw. The session supervisor sees the
|
|
252
|
+
// typed error and can swap backends instead of treating it as a
|
|
253
|
+
// transient retry.
|
|
254
|
+
if (err instanceof Error)
|
|
255
|
+
classifySSEError(err);
|
|
210
256
|
// Do NOT increment attempt here — the try block already incremented
|
|
211
257
|
// it before calling reconnectAfter. A second bump would halve the
|
|
212
258
|
// effective retry budget so only ~3 of the 5 BACKOFF_MS slots
|
package/dist/gateway.js
CHANGED
|
@@ -17,17 +17,31 @@
|
|
|
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
|
-
|
|
20
|
+
/**
|
|
21
|
+
* True when the credentials' access token is at or past its expiry.
|
|
22
|
+
*
|
|
23
|
+
* Takes `now` as a parameter so boundary tests (Date.now() === expires_at
|
|
24
|
+
* exactly) work naturally with the gateway's injected clock — previously
|
|
25
|
+
* this used Date.now() directly, which couldn't be controlled from tests.
|
|
26
|
+
*/
|
|
27
|
+
function isAccessExpired(creds, now) {
|
|
21
28
|
const expiresAt = new Date(creds.expires_at).getTime();
|
|
22
29
|
if (Number.isNaN(expiresAt))
|
|
23
30
|
return true;
|
|
24
|
-
return
|
|
31
|
+
return now() >= expiresAt;
|
|
25
32
|
}
|
|
26
33
|
function buildAuthResponse(device) {
|
|
34
|
+
// Stryker disable next-line StringLiteral: user-facing UX copy; wording
|
|
35
|
+
// changes are deliberate and covered by manual review, not mutation testing.
|
|
27
36
|
const prefix = device.errorMessage ? `${device.errorMessage}\n\n` : "";
|
|
28
|
-
const text =
|
|
37
|
+
const text =
|
|
38
|
+
// Stryker disable next-line StringLiteral: user-facing UX copy
|
|
39
|
+
`${prefix}Pharaoh authentication required.\n\n` +
|
|
40
|
+
// Stryker disable next-line StringLiteral: user-facing UX copy
|
|
29
41
|
` 1. Visit ${device.verification_uri_complete}\n` +
|
|
42
|
+
// Stryker disable next-line StringLiteral: user-facing UX copy
|
|
30
43
|
` 2. Enter code: ${device.user_code}\n` +
|
|
44
|
+
// Stryker disable next-line StringLiteral: user-facing UX copy
|
|
31
45
|
` 3. Re-run this tool after authorizing.`;
|
|
32
46
|
return {
|
|
33
47
|
content: [{ type: "text", text }],
|
|
@@ -45,6 +59,12 @@ function buildAuthResponse(device) {
|
|
|
45
59
|
*/
|
|
46
60
|
export function createGateway(opts) {
|
|
47
61
|
const now = opts.now ?? (() => Date.now());
|
|
62
|
+
// Stryker disable next-line LogicalOperator: the `??` fallback is exercised
|
|
63
|
+
// at runtime when no opts.openBrowser is passed, but tests vi.mock auth.js's
|
|
64
|
+
// openBrowser to a noop, so a mutated operator can't be distinguished via
|
|
65
|
+
// observable behavior. vi.mock is the safety net; the fallback is defense
|
|
66
|
+
// in depth for production. See packages/mcp-proxy/tests/*.test.ts for the
|
|
67
|
+
// module-level mock that keeps real browser-open calls out of CI.
|
|
48
68
|
const openBrowserImpl = opts.openBrowser ?? openBrowser;
|
|
49
69
|
let pending = null;
|
|
50
70
|
let lastCreds = null;
|
|
@@ -91,8 +111,6 @@ export function createGateway(opts) {
|
|
|
91
111
|
}
|
|
92
112
|
/** Decide whether the cached response is still valid without polling. */
|
|
93
113
|
function shouldSkipPoll(state) {
|
|
94
|
-
if (state.last_poll_at === null)
|
|
95
|
-
return false;
|
|
96
114
|
return now() - state.last_poll_at < state.interval_ms;
|
|
97
115
|
}
|
|
98
116
|
/**
|
|
@@ -110,6 +128,7 @@ export function createGateway(opts) {
|
|
|
110
128
|
// inline so the agent sees why; the user will naturally retry.
|
|
111
129
|
pending = null;
|
|
112
130
|
return {
|
|
131
|
+
// Stryker disable next-line StringLiteral: user-facing UX copy
|
|
113
132
|
content: [{ type: "text", text: `Pharaoh auth denied: ${result.error}` }],
|
|
114
133
|
isError: true,
|
|
115
134
|
_meta: {
|
|
@@ -153,7 +172,7 @@ export function createGateway(opts) {
|
|
|
153
172
|
if (!creds)
|
|
154
173
|
return false;
|
|
155
174
|
lastCreds = creds;
|
|
156
|
-
return !isAccessExpired(creds);
|
|
175
|
+
return !isAccessExpired(creds, now);
|
|
157
176
|
}
|
|
158
177
|
function getCredentials() {
|
|
159
178
|
return lastCreds;
|
package/dist/index.js
CHANGED
|
@@ -8,14 +8,19 @@
|
|
|
8
8
|
*
|
|
9
9
|
* Two modes determined by whether stdin is a TTY:
|
|
10
10
|
* - Interactive (TTY): authenticate, print setup instructions, exit.
|
|
11
|
-
* - Proxy (pipe):
|
|
11
|
+
* - Proxy (pipe): bridge stdio ↔ SSE. No valid creds? Run in auth-required
|
|
12
|
+
* mode until device flow completes, then swap to SSE in
|
|
13
|
+
* place — the stdio session never disconnects.
|
|
12
14
|
*/
|
|
13
15
|
import { pollForToken, printActivationPrompt, printAuthSuccess, requestDeviceCode, } from "./auth.js";
|
|
14
|
-
import {
|
|
16
|
+
import { AuthRequiredBackend, createSseBackend } from "./backend.js";
|
|
17
|
+
import { deleteCredentials, readCredentials, writeCredentials } from "./credentials.js";
|
|
18
|
+
import { createGateway } from "./gateway.js";
|
|
15
19
|
import { formatTtl, parseArgs, printLines, printUsage, resolveSseUrl, tokenToCredentials, } from "./helpers.js";
|
|
16
20
|
import { runInstallSkills } from "./install-skills.js";
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
21
|
+
import { handleRefreshFailure, tryMidSessionRefresh } from "./lifecycle.js";
|
|
22
|
+
import { TenantSuspendedError, TokenExpiredError } from "./proxy.js";
|
|
23
|
+
import { startInAuthRequiredMode, startInSseMode } from "./proxy-server.js";
|
|
19
24
|
import { loadAndRefresh } from "./refresh.js";
|
|
20
25
|
const BANNER = [
|
|
21
26
|
"",
|
|
@@ -87,70 +92,177 @@ async function main() {
|
|
|
87
92
|
process.exit(0);
|
|
88
93
|
}
|
|
89
94
|
// ── Proxy mode (Claude Code spawned us as a stdio MCP server) ──
|
|
90
|
-
//
|
|
91
|
-
//
|
|
95
|
+
// Single flow: load creds → pick starting backend → wait for lifetime
|
|
96
|
+
// events → swap backends in place. Session C design note: we never exit
|
|
97
|
+
// on recoverable conditions (missing creds, expired-refresh, mid-session
|
|
98
|
+
// 401). The stdio session stays alive for the whole life of the agent;
|
|
99
|
+
// only non-recoverable failures (tenant suspended, transient refresh error
|
|
100
|
+
// on cold start) terminate the process.
|
|
92
101
|
const outcome = await loadAndRefresh(server);
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
if (!outcome.ok) {
|
|
98
|
-
handleRefreshFailure(outcome.action, outcome.message);
|
|
99
|
-
return; // unreachable — handleRefreshFailure calls process.exit
|
|
100
|
-
}
|
|
101
|
-
let creds = outcome.creds;
|
|
102
|
-
// Valid credentials — ensure skills are installed before starting proxy
|
|
103
|
-
// Silently installs/updates on first run or package update; fast no-op if current.
|
|
102
|
+
// Install skills opportunistically — must not block startup. Failures
|
|
103
|
+
// here include network hiccups (npm registry blips) and transient fs
|
|
104
|
+
// issues, neither of which should prevent the proxy serving MCP traffic.
|
|
105
|
+
// Log the message so "silent" is still distinguishable from "never ran."
|
|
104
106
|
try {
|
|
105
107
|
runInstallSkills();
|
|
106
108
|
}
|
|
107
|
-
catch {
|
|
109
|
+
catch (err) {
|
|
108
110
|
// ALLOWED-CATCH: skill install failure must not block MCP proxy startup
|
|
111
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
112
|
+
process.stderr.write(`Pharaoh: skill install skipped — ${msg}\n`);
|
|
109
113
|
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
114
|
+
// Needs auth: either no creds file OR refresh token is terminally rejected.
|
|
115
|
+
// Both cases: start in auth-required mode, wait for the user to complete
|
|
116
|
+
// device flow, then swap to SSE via the onAuthorized callback.
|
|
117
|
+
const needsAuth = outcome === null || (!outcome.ok && outcome.action === "reauth");
|
|
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();
|
|
124
|
+
// The callback closure captures `proxy` via the destructured `const`
|
|
125
|
+
// binding declared below. The closure is only invoked once authorization
|
|
126
|
+
// completes (after the user's device-flow round trip), long after the
|
|
127
|
+
// assignment — so TS's strict-binding checks accept the reference.
|
|
128
|
+
const { proxy } = await startInAuthRequiredMode(server, {
|
|
129
|
+
onAuthorized: async (creds) => {
|
|
130
|
+
printLines("Pharaoh: authenticated — swapping to SSE backend.");
|
|
131
|
+
await proxy.setBackend(createSseBackend(creds.sse_url, creds.access_token));
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
printLines("Pharaoh: awaiting authentication via Claude Code...");
|
|
135
|
+
await waitForProxyLifetime(proxy, server);
|
|
136
|
+
return;
|
|
113
137
|
}
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
138
|
+
// Non-recoverable refresh failure (transient / preserve). Don't delete
|
|
139
|
+
// creds; the next spawn will retry. Match the pre-Session-C behavior for
|
|
140
|
+
// these codepaths exactly — if we can't refresh on cold start, we can't
|
|
141
|
+
// serve any tool calls, so bailing is honest.
|
|
142
|
+
if (!outcome.ok) {
|
|
143
|
+
handleRefreshFailure(outcome.action, outcome.message);
|
|
144
|
+
return; // unreachable — handleRefreshFailure calls process.exit
|
|
145
|
+
}
|
|
146
|
+
// Happy path: valid credentials. Start SSE backend, wait for lifetime,
|
|
147
|
+
// handle any mid-session failures by swapping backends in place.
|
|
148
|
+
const creds = outcome.creds;
|
|
149
|
+
printLines(`Pharaoh: token valid for ${formatTtl(creds.expires_at)} — connecting`);
|
|
150
|
+
const proxy = await startInSseMode(creds);
|
|
151
|
+
await waitForProxyLifetime(proxy, server);
|
|
152
|
+
}
|
|
153
|
+
/** Circuit breaker: abort after this many TokenExpiredError recoveries in the window. */
|
|
154
|
+
const RECOVERY_WINDOW_MS = 60_000;
|
|
155
|
+
const MAX_RECOVERIES_PER_WINDOW = 3;
|
|
156
|
+
/**
|
|
157
|
+
* Block on proxy.waitForLifetime() and reconcile mid-session failures by
|
|
158
|
+
* swapping the backend in place. Loops so successive swaps (e.g. SSE → auth
|
|
159
|
+
* → SSE) all stay under the same stdio connection.
|
|
160
|
+
*
|
|
161
|
+
* Terminates (returns normally) only when:
|
|
162
|
+
* - stdio closes (Claude Code disconnects), or
|
|
163
|
+
* - a non-recoverable branch explicitly process.exit()s.
|
|
164
|
+
*
|
|
165
|
+
* Circuit breaker: a buggy or hostile upstream can force a SSE↔auth churn by
|
|
166
|
+
* issuing a fresh 401 immediately after each successful reauth. Cap the
|
|
167
|
+
* recovery rate so the proxy exits with a diagnostic instead of pinning the
|
|
168
|
+
* user in a loop that re-prompts device flow every few seconds.
|
|
169
|
+
*/
|
|
170
|
+
async function waitForProxyLifetime(proxy, server) {
|
|
171
|
+
const recoveryTimestamps = [];
|
|
172
|
+
while (true) {
|
|
173
|
+
try {
|
|
174
|
+
await proxy.waitForLifetime();
|
|
175
|
+
return; // stdio closed cleanly — end of session
|
|
176
|
+
}
|
|
177
|
+
catch (err) {
|
|
178
|
+
if (err instanceof TokenExpiredError) {
|
|
179
|
+
const now = Date.now();
|
|
180
|
+
// Drop timestamps older than the window.
|
|
181
|
+
while (recoveryTimestamps.length && now - recoveryTimestamps[0] > RECOVERY_WINDOW_MS) {
|
|
182
|
+
recoveryTimestamps.shift();
|
|
126
183
|
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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;
|
|
184
|
+
recoveryTimestamps.push(now);
|
|
185
|
+
if (recoveryTimestamps.length > MAX_RECOVERIES_PER_WINDOW) {
|
|
186
|
+
printLines(`Pharaoh: ${recoveryTimestamps.length} token-expired recoveries in ${RECOVERY_WINDOW_MS / 1000}s — aborting to avoid an auth loop. Restart to retry.`);
|
|
187
|
+
process.exit(1);
|
|
142
188
|
}
|
|
189
|
+
const recovered = await recoverFromTokenExpired(proxy, server);
|
|
190
|
+
if (recovered)
|
|
191
|
+
continue; // swapped to a new backend; keep waiting
|
|
192
|
+
return; // recovery exited / handed off to auth-required loop
|
|
143
193
|
}
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
process.exit(1);
|
|
194
|
+
if (err instanceof TenantSuspendedError) {
|
|
195
|
+
printLines(`Pharaoh: ${err.message}`);
|
|
196
|
+
process.exit(1);
|
|
197
|
+
}
|
|
198
|
+
throw err;
|
|
150
199
|
}
|
|
151
|
-
throw err;
|
|
152
200
|
}
|
|
153
201
|
}
|
|
202
|
+
/**
|
|
203
|
+
* Handle a mid-session 401 without tearing down the stdio session:
|
|
204
|
+
* 1. Attempt a silent refresh. If it works, swap to a fresh SSE backend.
|
|
205
|
+
* 2. If the refresh says reauth, swap to AuthRequiredBackend — the next
|
|
206
|
+
* tool call surfaces the auth URL. The onAuthorized callback swaps us
|
|
207
|
+
* back to SSE once the user completes device flow.
|
|
208
|
+
* 3. If the refresh is transient/preserve, the session can't recover here
|
|
209
|
+
* — exit with the classified message.
|
|
210
|
+
*
|
|
211
|
+
* Returns `true` when a backend swap happened and the caller should keep
|
|
212
|
+
* waiting on waitForLifetime(); returns `false` when the caller's loop should
|
|
213
|
+
* exit (because this function either handed off to an async auth-required
|
|
214
|
+
* swap-back or process.exit()'d for a non-recoverable case).
|
|
215
|
+
*/
|
|
216
|
+
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,
|
|
224
|
+
// fall through to auth-required mode to get new ones.
|
|
225
|
+
await swapToAuthRequired(proxy, server);
|
|
226
|
+
return true;
|
|
227
|
+
}
|
|
228
|
+
const result = await tryMidSessionRefresh(diskCreds, server);
|
|
229
|
+
if (result.ok) {
|
|
230
|
+
printLines("Pharaoh: token refreshed mid-session — swapping SSE backend");
|
|
231
|
+
await proxy.setBackend(createSseBackend(result.creds.sse_url, result.creds.access_token));
|
|
232
|
+
return true;
|
|
233
|
+
}
|
|
234
|
+
// Branch on the explicit classification returned by tryMidSessionRefresh
|
|
235
|
+
// rather than inferring from disk state. `reauth` means the refresh token
|
|
236
|
+
// is terminally rejected — swap to auth-required so the user can re-
|
|
237
|
+
// authenticate without the session tearing down. `preserve`/`transient`
|
|
238
|
+
// mean the server's temporarily unable to refresh us; exit with the
|
|
239
|
+
// message so the user knows their credentials are intact.
|
|
240
|
+
if (result.action === "reauth") {
|
|
241
|
+
await swapToAuthRequired(proxy, server);
|
|
242
|
+
return true;
|
|
243
|
+
}
|
|
244
|
+
printLines(`Pharaoh: mid-session refresh failed (${result.action}) — credentials preserved; restart to retry.`);
|
|
245
|
+
process.exit(1);
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Swap the live ProxyServer over to an AuthRequiredBackend, wiring an
|
|
249
|
+
* onAuthorized callback that swaps back to SSE. Used on both mid-session
|
|
250
|
+
* reauth and first-missing-creds recovery.
|
|
251
|
+
*/
|
|
252
|
+
async function swapToAuthRequired(proxy, server) {
|
|
253
|
+
const gateway = createGateway({ server });
|
|
254
|
+
const authBackend = new AuthRequiredBackend(gateway, async () => {
|
|
255
|
+
const fresh = gateway.getCredentials();
|
|
256
|
+
if (!fresh) {
|
|
257
|
+
process.stderr.write("Pharaoh: swap-back aborted — gateway returned no creds\n");
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
printLines("Pharaoh: re-authenticated — swapping back to SSE backend.");
|
|
261
|
+
await proxy.setBackend(createSseBackend(fresh.sse_url, fresh.access_token));
|
|
262
|
+
});
|
|
263
|
+
printLines("Pharaoh: re-authentication required — next tool call will surface the URL.");
|
|
264
|
+
await proxy.setBackend(authBackend);
|
|
265
|
+
}
|
|
154
266
|
main().catch((err) => {
|
|
155
267
|
// Default: print only error name/code to avoid leaking tokens, internal URLs,
|
|
156
268
|
// or stack fragments that persist in CI logs. Full message behind PHARAOH_DEBUG.
|
package/dist/lifecycle.d.ts
CHANGED
|
@@ -7,12 +7,22 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { type Credentials } from "./credentials.js";
|
|
9
9
|
import { type RefreshFailureAction } from "./refresh.js";
|
|
10
|
+
/** Discriminated result from tryMidSessionRefresh. */
|
|
11
|
+
export type MidSessionRefreshResult = {
|
|
12
|
+
ok: true;
|
|
13
|
+
creds: Credentials;
|
|
14
|
+
} | {
|
|
15
|
+
ok: false;
|
|
16
|
+
action: RefreshFailureAction;
|
|
17
|
+
message: string;
|
|
18
|
+
};
|
|
10
19
|
/**
|
|
11
20
|
* Attempt a silent refresh triggered by a mid-session TokenExpiredError.
|
|
12
|
-
* Returns new credentials on success,
|
|
13
|
-
*
|
|
21
|
+
* Returns new credentials on success, or a tagged failure carrying the
|
|
22
|
+
* RefreshFailureAction so the caller can branch on the real classification
|
|
23
|
+
* instead of re-inferring from disk state.
|
|
14
24
|
*/
|
|
15
|
-
export declare function tryMidSessionRefresh(creds: Credentials, server: string): Promise<
|
|
25
|
+
export declare function tryMidSessionRefresh(creds: Credentials, server: string): Promise<MidSessionRefreshResult>;
|
|
16
26
|
/**
|
|
17
27
|
* Handle a failed `loadAndRefresh` at startup. Exits the process with the
|
|
18
28
|
* appropriate message and creds-file state per the classified action.
|
package/dist/lifecycle.js
CHANGED
|
@@ -10,14 +10,15 @@ import { NPX_COMMAND, printLines } from "./helpers.js";
|
|
|
10
10
|
import { refreshIfNeeded } from "./refresh.js";
|
|
11
11
|
/**
|
|
12
12
|
* Attempt a silent refresh triggered by a mid-session TokenExpiredError.
|
|
13
|
-
* Returns new credentials on success,
|
|
14
|
-
*
|
|
13
|
+
* Returns new credentials on success, or a tagged failure carrying the
|
|
14
|
+
* RefreshFailureAction so the caller can branch on the real classification
|
|
15
|
+
* instead of re-inferring from disk state.
|
|
15
16
|
*/
|
|
16
17
|
export async function tryMidSessionRefresh(creds, server) {
|
|
17
18
|
const outcome = await refreshIfNeeded(creds, server);
|
|
18
19
|
if (outcome.ok)
|
|
19
|
-
return outcome.creds;
|
|
20
|
-
// Log the failure reason before caller
|
|
20
|
+
return { ok: true, creds: outcome.creds };
|
|
21
|
+
// Log the failure reason before the caller decides what to do.
|
|
21
22
|
if (outcome.action === "reauth") {
|
|
22
23
|
printLines("Pharaoh: refresh rejected — re-authentication required.", ` ${NPX_COMMAND}`, "");
|
|
23
24
|
deleteCredentials();
|
|
@@ -27,7 +28,7 @@ export async function tryMidSessionRefresh(creds, server) {
|
|
|
27
28
|
// the user knows their credentials file is intact (matches startup path).
|
|
28
29
|
printLines(`Pharaoh: refresh failed (${outcome.action}) — ${outcome.message}`, "Credentials preserved; next startup will retry.", "");
|
|
29
30
|
}
|
|
30
|
-
return
|
|
31
|
+
return { ok: false, action: outcome.action, message: outcome.message };
|
|
31
32
|
}
|
|
32
33
|
/**
|
|
33
34
|
* Handle a failed `loadAndRefresh` at startup. Exits the process with the
|
package/dist/proxy-server.d.ts
CHANGED
|
@@ -8,11 +8,18 @@
|
|
|
8
8
|
*
|
|
9
9
|
* - startInAuthRequiredMode: cold start, no (or expired-refresh) credentials.
|
|
10
10
|
* Spins up a gateway, wraps it in AuthRequiredBackend, starts the server.
|
|
11
|
-
*
|
|
12
|
-
*
|
|
11
|
+
* The caller supplies an `onAuthorized` callback that fires from inside the
|
|
12
|
+
* backend the moment device flow completes — the callback swaps ProxyServer
|
|
13
|
+
* over to an SseProxyBackend in place.
|
|
13
14
|
*
|
|
14
15
|
* - startInSseMode: happy path — valid credentials are already on disk. Starts
|
|
15
16
|
* the server directly with an SSE backend.
|
|
17
|
+
*
|
|
18
|
+
* Lifecycle:
|
|
19
|
+
* - `waitForLifetime()` is the single awaitable the main loop blocks on. It
|
|
20
|
+
* resolves when Claude Code disconnects (stdio transport closed) and rejects
|
|
21
|
+
* with typed errors when the active backend hits a terminal failure. Backend
|
|
22
|
+
* swaps happen internally via callbacks; the main loop does NOT re-await.
|
|
16
23
|
*/
|
|
17
24
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
18
25
|
import { type Backend } from "./backend.js";
|
|
@@ -27,6 +34,8 @@ export declare class ProxyServer {
|
|
|
27
34
|
private transport;
|
|
28
35
|
private backend;
|
|
29
36
|
private started;
|
|
37
|
+
private transportClosed;
|
|
38
|
+
private transportClosedResolve;
|
|
30
39
|
constructor(initialBackend: Backend);
|
|
31
40
|
/**
|
|
32
41
|
* Wire up list/call handlers. Each handler reads this.backend fresh at
|
|
@@ -45,6 +54,26 @@ export declare class ProxyServer {
|
|
|
45
54
|
* Kept separate from start() so the stdio wiring stays implicit in prod.
|
|
46
55
|
*/
|
|
47
56
|
startWithTransport(transport: StdioServerTransport): Promise<void>;
|
|
57
|
+
/**
|
|
58
|
+
* Block until either (a) the stdio transport closes cleanly (Claude Code
|
|
59
|
+
* disconnected) or (b) the current backend's supervisor promise rejects
|
|
60
|
+
* with a terminal error. Returns when the transport closes. Throws when
|
|
61
|
+
* a backend's waitForExit rejects.
|
|
62
|
+
*
|
|
63
|
+
* Swap-aware: if setBackend() is called while waitForLifetime is blocked,
|
|
64
|
+
* the old backend's exit promise is abandoned and the new backend's is
|
|
65
|
+
* awaited. This matters for "SSE → auth-required → SSE2" flows where we
|
|
66
|
+
* want errors from SSE2 to bubble up, not the already-dead SSE1.
|
|
67
|
+
*/
|
|
68
|
+
waitForLifetime(): Promise<void>;
|
|
69
|
+
/**
|
|
70
|
+
* Used by setBackend to wake waitForLifetime so it can re-sample. Default
|
|
71
|
+
* no-op so a swap fired before waitForLifetime's first iteration (e.g. an
|
|
72
|
+
* onAuthorized that completes between proxy.start() and the first
|
|
73
|
+
* waitForLifetime call) doesn't crash. The missed wake is harmless — the
|
|
74
|
+
* next waitForLifetime iteration samples this.backend directly.
|
|
75
|
+
*/
|
|
76
|
+
private backendSwappedSignal;
|
|
48
77
|
/**
|
|
49
78
|
* Swap the backend reference and fire a tools/list_changed notification so
|
|
50
79
|
* the client re-fetches. No in-flight tracking — the SDK routes each call
|
|
@@ -59,12 +88,15 @@ export declare class ProxyServer {
|
|
|
59
88
|
* the proxy in auth-required mode so the stdio connection stays alive while
|
|
60
89
|
* the user completes the device flow.
|
|
61
90
|
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
* real
|
|
91
|
+
* The caller supplies `opts.onAuthorized`, which fires from inside the
|
|
92
|
+
* AuthRequiredBackend the moment device flow completes. Typical implementation:
|
|
93
|
+
* swap the ProxyServer over to an SseProxyBackend so the next tool call goes
|
|
94
|
+
* to the real server. This keeps the proxy reference under the caller's
|
|
95
|
+
* control — the backend never imports ProxyServer.
|
|
66
96
|
*/
|
|
67
|
-
export declare function startInAuthRequiredMode(server: string
|
|
97
|
+
export declare function startInAuthRequiredMode(server: string, opts: {
|
|
98
|
+
onAuthorized: (creds: Credentials) => Promise<void>;
|
|
99
|
+
}): Promise<{
|
|
68
100
|
proxy: ProxyServer;
|
|
69
101
|
gateway: DeviceFlowGateway;
|
|
70
102
|
}>;
|
package/dist/proxy-server.js
CHANGED
|
@@ -8,11 +8,18 @@
|
|
|
8
8
|
*
|
|
9
9
|
* - startInAuthRequiredMode: cold start, no (or expired-refresh) credentials.
|
|
10
10
|
* Spins up a gateway, wraps it in AuthRequiredBackend, starts the server.
|
|
11
|
-
*
|
|
12
|
-
*
|
|
11
|
+
* The caller supplies an `onAuthorized` callback that fires from inside the
|
|
12
|
+
* backend the moment device flow completes — the callback swaps ProxyServer
|
|
13
|
+
* over to an SseProxyBackend in place.
|
|
13
14
|
*
|
|
14
15
|
* - startInSseMode: happy path — valid credentials are already on disk. Starts
|
|
15
16
|
* the server directly with an SSE backend.
|
|
17
|
+
*
|
|
18
|
+
* Lifecycle:
|
|
19
|
+
* - `waitForLifetime()` is the single awaitable the main loop blocks on. It
|
|
20
|
+
* resolves when Claude Code disconnects (stdio transport closed) and rejects
|
|
21
|
+
* with typed errors when the active backend hits a terminal failure. Backend
|
|
22
|
+
* swaps happen internally via callbacks; the main loop does NOT re-await.
|
|
16
23
|
*/
|
|
17
24
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
18
25
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
@@ -29,6 +36,8 @@ export class ProxyServer {
|
|
|
29
36
|
transport = null;
|
|
30
37
|
backend;
|
|
31
38
|
started = false;
|
|
39
|
+
transportClosed;
|
|
40
|
+
transportClosedResolve;
|
|
32
41
|
constructor(initialBackend) {
|
|
33
42
|
this.backend = initialBackend;
|
|
34
43
|
this.server = new Server({ name: "pharaoh-proxy", version: getPackageVersion() }, {
|
|
@@ -36,6 +45,9 @@ export class ProxyServer {
|
|
|
36
45
|
// to throw; the SDK's assertNotificationCapability gates it.
|
|
37
46
|
capabilities: { tools: { listChanged: true } },
|
|
38
47
|
});
|
|
48
|
+
this.transportClosed = new Promise((resolve) => {
|
|
49
|
+
this.transportClosedResolve = resolve;
|
|
50
|
+
});
|
|
39
51
|
this.registerHandlers();
|
|
40
52
|
}
|
|
41
53
|
/**
|
|
@@ -64,6 +76,11 @@ export class ProxyServer {
|
|
|
64
76
|
await this.backend.start();
|
|
65
77
|
this.transport = new StdioServerTransport();
|
|
66
78
|
await this.server.connect(this.transport);
|
|
79
|
+
// Server.connect() wraps transport.onclose; use the Protocol-level hook
|
|
80
|
+
// instead so we don't clobber the SDK's chain. Fires when the transport
|
|
81
|
+
// closes cleanly (client disconnect) — not on error paths, which surface
|
|
82
|
+
// through backend.waitForExit() and the server's own onerror plumbing.
|
|
83
|
+
this.server.onclose = () => this.transportClosedResolve();
|
|
67
84
|
this.started = true;
|
|
68
85
|
}
|
|
69
86
|
/**
|
|
@@ -78,8 +95,44 @@ export class ProxyServer {
|
|
|
78
95
|
this.transport = transport;
|
|
79
96
|
// biome-ignore lint/suspicious/noExplicitAny: test transport is a Transport, but the type import chain is awkward here
|
|
80
97
|
await this.server.connect(transport);
|
|
98
|
+
this.server.onclose = () => this.transportClosedResolve();
|
|
81
99
|
this.started = true;
|
|
82
100
|
}
|
|
101
|
+
/**
|
|
102
|
+
* Block until either (a) the stdio transport closes cleanly (Claude Code
|
|
103
|
+
* disconnected) or (b) the current backend's supervisor promise rejects
|
|
104
|
+
* with a terminal error. Returns when the transport closes. Throws when
|
|
105
|
+
* a backend's waitForExit rejects.
|
|
106
|
+
*
|
|
107
|
+
* Swap-aware: if setBackend() is called while waitForLifetime is blocked,
|
|
108
|
+
* the old backend's exit promise is abandoned and the new backend's is
|
|
109
|
+
* awaited. This matters for "SSE → auth-required → SSE2" flows where we
|
|
110
|
+
* want errors from SSE2 to bubble up, not the already-dead SSE1.
|
|
111
|
+
*/
|
|
112
|
+
async waitForLifetime() {
|
|
113
|
+
while (true) {
|
|
114
|
+
const swapSignal = new Promise((resolve) => {
|
|
115
|
+
this.backendSwappedSignal = () => resolve("swap");
|
|
116
|
+
});
|
|
117
|
+
const backendExit = this.backend.waitForExit?.() ?? new Promise(() => { });
|
|
118
|
+
const outcome = await Promise.race([
|
|
119
|
+
this.transportClosed.then(() => "closed"),
|
|
120
|
+
backendExit.then(() => "exit"),
|
|
121
|
+
swapSignal,
|
|
122
|
+
]);
|
|
123
|
+
if (outcome === "swap")
|
|
124
|
+
continue; // re-sample waitForExit against the new backend
|
|
125
|
+
return; // "closed" or "exit" (the backend's waitForExit resolved cleanly)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Used by setBackend to wake waitForLifetime so it can re-sample. Default
|
|
130
|
+
* no-op so a swap fired before waitForLifetime's first iteration (e.g. an
|
|
131
|
+
* onAuthorized that completes between proxy.start() and the first
|
|
132
|
+
* waitForLifetime call) doesn't crash. The missed wake is harmless — the
|
|
133
|
+
* next waitForLifetime iteration samples this.backend directly.
|
|
134
|
+
*/
|
|
135
|
+
backendSwappedSignal = () => { };
|
|
83
136
|
/**
|
|
84
137
|
* Swap the backend reference and fire a tools/list_changed notification so
|
|
85
138
|
* the client re-fetches. No in-flight tracking — the SDK routes each call
|
|
@@ -99,15 +152,26 @@ export class ProxyServer {
|
|
|
99
152
|
// Fire the notification after the swap so a client that races a list
|
|
100
153
|
// request against it sees the new catalog.
|
|
101
154
|
await this.server.sendToolListChanged();
|
|
102
|
-
//
|
|
103
|
-
//
|
|
155
|
+
// Wake any in-flight waitForLifetime so it re-samples the new backend's
|
|
156
|
+
// waitForExit. Without this, SSE2 errors after a swap-back would be lost.
|
|
157
|
+
this.backendSwappedSignal();
|
|
158
|
+
// Fire-and-forget stop of the old backend. The swap has already happened
|
|
159
|
+
// so stop failures can't break the running session — but log them so
|
|
160
|
+
// resource leaks (file descriptors, keepalive timers, EventSource auto-
|
|
161
|
+
// reconnect surviving a failed close) are visible.
|
|
104
162
|
if (prev !== next && prev.stop) {
|
|
105
|
-
prev.stop().catch(() => {
|
|
163
|
+
prev.stop().catch((err) => {
|
|
164
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
165
|
+
process.stderr.write(`Pharaoh: previous backend stop() failed — ${msg}\n`);
|
|
166
|
+
});
|
|
106
167
|
}
|
|
107
168
|
}
|
|
108
169
|
async stop() {
|
|
109
170
|
if (this.backend.stop) {
|
|
110
|
-
await this.backend.stop().catch(() => {
|
|
171
|
+
await this.backend.stop().catch((err) => {
|
|
172
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
173
|
+
process.stderr.write(`Pharaoh: backend stop() failed during shutdown — ${msg}\n`);
|
|
174
|
+
});
|
|
111
175
|
}
|
|
112
176
|
if (this.transport) {
|
|
113
177
|
await this.server.close().catch(() => { });
|
|
@@ -124,14 +188,28 @@ export class ProxyServer {
|
|
|
124
188
|
* the proxy in auth-required mode so the stdio connection stays alive while
|
|
125
189
|
* the user completes the device flow.
|
|
126
190
|
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
* real
|
|
191
|
+
* The caller supplies `opts.onAuthorized`, which fires from inside the
|
|
192
|
+
* AuthRequiredBackend the moment device flow completes. Typical implementation:
|
|
193
|
+
* swap the ProxyServer over to an SseProxyBackend so the next tool call goes
|
|
194
|
+
* to the real server. This keeps the proxy reference under the caller's
|
|
195
|
+
* control — the backend never imports ProxyServer.
|
|
131
196
|
*/
|
|
132
|
-
export async function startInAuthRequiredMode(server) {
|
|
197
|
+
export async function startInAuthRequiredMode(server, opts) {
|
|
133
198
|
const gateway = createGateway({ server });
|
|
134
|
-
const backend = new AuthRequiredBackend(gateway)
|
|
199
|
+
const backend = new AuthRequiredBackend(gateway, async () => {
|
|
200
|
+
// Pull fresh creds from the gateway (which just wrote them to disk) so
|
|
201
|
+
// the callback doesn't race a separate disk read. The gateway is the
|
|
202
|
+
// authoritative post-auth source.
|
|
203
|
+
const creds = gateway.getCredentials();
|
|
204
|
+
if (!creds) {
|
|
205
|
+
// Should never happen — gateway.handleToolCall() only returns null
|
|
206
|
+
// after persistAuthorizedToken sets lastCreds. Keep the guard so a
|
|
207
|
+
// future refactor doesn't silently ship a swap-to-nothing bug.
|
|
208
|
+
process.stderr.write("Pharaoh: authorized but no credentials available — skipping swap\n");
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
await opts.onAuthorized(creds);
|
|
212
|
+
});
|
|
135
213
|
const proxy = new ProxyServer(backend);
|
|
136
214
|
await proxy.start();
|
|
137
215
|
return { proxy, gateway };
|
package/dist/proxy.d.ts
CHANGED
|
@@ -1,16 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* directly and `startProxy` will disappear at that point.
|
|
2
|
+
* Typed SSE errors + classifier. The stdio ↔ SSE wiring itself lives in
|
|
3
|
+
* backend.ts (SseProxyBackend); this module exports only the error types and
|
|
4
|
+
* the classifier so both the backend and tests can produce/assert on the
|
|
5
|
+
* same typed errors.
|
|
7
6
|
*
|
|
8
|
-
*
|
|
9
|
-
* legacy startProxy wrapper and the new SseProxyBackend (in backend.ts) need
|
|
10
|
-
* to produce the same typed errors so downstream consumers don't care which
|
|
11
|
-
* path surfaced them.
|
|
12
|
-
*
|
|
13
|
-
* RECONNECTION DESIGN (inherited by SseProxyBackend):
|
|
7
|
+
* RECONNECTION DESIGN (implemented in backend.ts SseProxyBackend):
|
|
14
8
|
* The MCP SDK's SSEClientTransport wraps an EventSource, which has built-in
|
|
15
9
|
* auto-reconnect (every 3s by default). This is problematic because:
|
|
16
10
|
* 1. Each reconnect creates a NEW server-side session (new auth, new ID)
|
|
@@ -33,14 +27,13 @@ export declare class TenantSuspendedError extends Error {
|
|
|
33
27
|
* Classify an SSE error — detect 401/403 and throw typed errors.
|
|
34
28
|
* Exported for testability; used internally by the reconnect loop in
|
|
35
29
|
* backend.ts.
|
|
36
|
-
*/
|
|
37
|
-
export declare function classifySSEError(err: Error): void;
|
|
38
|
-
/**
|
|
39
|
-
* Legacy thin wrapper that starts the new ProxyServer in SSE mode and blocks
|
|
40
|
-
* until the underlying SSE backend exits. Preserved so index.ts (Session C)
|
|
41
|
-
* keeps compiling until the wiring PR; the return/throw contract matches the
|
|
42
|
-
* original (resolves on clean exit, throws TokenExpiredError / TenantSuspendedError).
|
|
43
30
|
*
|
|
44
|
-
*
|
|
31
|
+
* The fallback uses status-prefix regex patterns rather than a bare
|
|
32
|
+
* `msg.includes("401")`. Session C calls this classifier on every reconnect
|
|
33
|
+
* error (not just SSE transport onerror events), so a substring match would
|
|
34
|
+
* misclassify any unrelated error text containing "401" — e.g. a port number,
|
|
35
|
+
* a timestamp suffix, or HTML body text echoed from a proxy. The regex
|
|
36
|
+
* requires an HTTP-shaped context so only actual status codes trigger a reauth
|
|
37
|
+
* loop.
|
|
45
38
|
*/
|
|
46
|
-
export declare function
|
|
39
|
+
export declare function classifySSEError(err: Error): void;
|
package/dist/proxy.js
CHANGED
|
@@ -1,16 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* directly and `startProxy` will disappear at that point.
|
|
2
|
+
* Typed SSE errors + classifier. The stdio ↔ SSE wiring itself lives in
|
|
3
|
+
* backend.ts (SseProxyBackend); this module exports only the error types and
|
|
4
|
+
* the classifier so both the backend and tests can produce/assert on the
|
|
5
|
+
* same typed errors.
|
|
7
6
|
*
|
|
8
|
-
*
|
|
9
|
-
* legacy startProxy wrapper and the new SseProxyBackend (in backend.ts) need
|
|
10
|
-
* to produce the same typed errors so downstream consumers don't care which
|
|
11
|
-
* path surfaced them.
|
|
12
|
-
*
|
|
13
|
-
* RECONNECTION DESIGN (inherited by SseProxyBackend):
|
|
7
|
+
* RECONNECTION DESIGN (implemented in backend.ts SseProxyBackend):
|
|
14
8
|
* The MCP SDK's SSEClientTransport wraps an EventSource, which has built-in
|
|
15
9
|
* auto-reconnect (every 3s by default). This is problematic because:
|
|
16
10
|
* 1. Each reconnect creates a NEW server-side session (new auth, new ID)
|
|
@@ -39,10 +33,19 @@ export class TenantSuspendedError extends Error {
|
|
|
39
33
|
* Classify an SSE error — detect 401/403 and throw typed errors.
|
|
40
34
|
* Exported for testability; used internally by the reconnect loop in
|
|
41
35
|
* backend.ts.
|
|
36
|
+
*
|
|
37
|
+
* The fallback uses status-prefix regex patterns rather than a bare
|
|
38
|
+
* `msg.includes("401")`. Session C calls this classifier on every reconnect
|
|
39
|
+
* error (not just SSE transport onerror events), so a substring match would
|
|
40
|
+
* misclassify any unrelated error text containing "401" — e.g. a port number,
|
|
41
|
+
* a timestamp suffix, or HTML body text echoed from a proxy. The regex
|
|
42
|
+
* requires an HTTP-shaped context so only actual status codes trigger a reauth
|
|
43
|
+
* loop.
|
|
42
44
|
*/
|
|
43
45
|
export function classifySSEError(err) {
|
|
44
46
|
const msg = err.message ?? "";
|
|
45
|
-
// SseError from SDK includes the HTTP status code
|
|
47
|
+
// SseError from SDK includes the HTTP status code on `.code` — the
|
|
48
|
+
// authoritative signal.
|
|
46
49
|
if ("code" in err) {
|
|
47
50
|
const code = err.code;
|
|
48
51
|
if (code === 401)
|
|
@@ -50,35 +53,15 @@ export function classifySSEError(err) {
|
|
|
50
53
|
if (code === 403)
|
|
51
54
|
throw new TenantSuspendedError(msg || "Tenant suspended");
|
|
52
55
|
}
|
|
53
|
-
// Fallback
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
* original (resolves on clean exit, throws TokenExpiredError / TenantSuspendedError).
|
|
64
|
-
*
|
|
65
|
-
* @deprecated Session C will call ProxyServer / startInSseMode directly.
|
|
66
|
-
*/
|
|
67
|
-
export async function startProxy(sseUrl, token) {
|
|
68
|
-
// Lazy imports to keep module load order clean — backend.ts imports from
|
|
69
|
-
// proxy.ts for the typed errors and classifier above, so a top-level
|
|
70
|
-
// import would create a cycle.
|
|
71
|
-
const { createSseBackend } = await import("./backend.js");
|
|
72
|
-
const { ProxyServer } = await import("./proxy-server.js");
|
|
73
|
-
const backend = createSseBackend(sseUrl, token);
|
|
74
|
-
const proxy = new ProxyServer(backend);
|
|
75
|
-
await proxy.start();
|
|
76
|
-
// Block on the SSE reconnect loop — matches the original semantics of
|
|
77
|
-
// startProxy, which resolved only when the connection terminated.
|
|
78
|
-
try {
|
|
79
|
-
await backend.waitForExit();
|
|
80
|
-
}
|
|
81
|
-
finally {
|
|
82
|
-
await proxy.stop().catch(() => { });
|
|
56
|
+
// Fallback regex for errors that don't expose `.code`. Matches common HTTP
|
|
57
|
+
// status renderings: "HTTP 401", "HTTP/1.1 401", "401 Unauthorized",
|
|
58
|
+
// "status 401", "status: 401" — but NOT bare "401" anywhere in the string.
|
|
59
|
+
const m = msg.match(/\b(?:HTTP(?:\/[\d.]+)?|status:?)\s+(\d{3})\b|\b(\d{3})\s+(?:Unauthorized|Forbidden)\b/i);
|
|
60
|
+
if (m) {
|
|
61
|
+
const code = Number(m[1] ?? m[2]);
|
|
62
|
+
if (code === 401)
|
|
63
|
+
throw new TokenExpiredError();
|
|
64
|
+
if (code === 403)
|
|
65
|
+
throw new TenantSuspendedError(msg || "Tenant suspended");
|
|
83
66
|
}
|
|
84
67
|
}
|
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.26",
|
|
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",
|