@stigmer/runner 3.18.0 → 3.19.0

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.
@@ -1 +1 @@
1
- {"hash":"743b3c1774352cc9","builtAt":"2026-09-18T15:39:55.306Z","fileCount":329}
1
+ {"hash":"6742d02a5e9d864d","builtAt":"2026-09-19T18:45:10.207Z","fileCount":329}
@@ -1,23 +1,4 @@
1
- /**
2
- * OAuth-challenge classification for HTTP MCP endpoints.
3
- *
4
- * When an HTTP MCP endpoint requires OAuth (per the MCP Authorization spec /
5
- * RFC 9728), an unauthenticated or statically-tokened request is answered with
6
- * `401` and a `WWW-Authenticate: Bearer ...` header pointing at OAuth protected
7
- * resource metadata. The MCP client SDK surfaces this only as an opaque
8
- * aggregate error ("unhandled errors in a TaskGroup"), which tells the user
9
- * nothing.
10
- *
11
- * This module turns that opaque failure into a precise, actionable signal: it
12
- * re-probes the endpoint once (only on the failure path, so the happy path pays
13
- * nothing) and, if it sees an OAuth challenge, returns an {@link
14
- * OAuthRequiredError} whose message tells the user to connect via OAuth instead
15
- * of a manual token.
16
- *
17
- * Kept transport-library-agnostic (uses global `fetch`) so it is reusable by
18
- * connect-time discovery, execution-time error enrichment, and any other caller
19
- * that holds an MCP endpoint URL + headers.
20
- */
1
+ export { isOAuthChallenge, parseResourceMetadataUrl } from "@stigmer/outbound/mcp-oauth";
21
2
  /**
22
3
  * Raised when an HTTP MCP endpoint answers with an OAuth authentication
23
4
  * challenge. The message is self-contained and user-facing: it survives the
@@ -31,17 +12,6 @@ export declare class OAuthRequiredError extends Error {
31
12
  readonly resourceMetadataUrl?: string | undefined;
32
13
  constructor(serverSlug: string, resourceMetadataUrl?: string | undefined);
33
14
  }
34
- /**
35
- * Whether a `WWW-Authenticate` header value is an OAuth challenge.
36
- *
37
- * The MCP auth spec emits `Bearer realm="OAuth", resource_metadata="..."`. We
38
- * require the `Bearer` scheme plus either an OAuth realm or a resource-metadata
39
- * pointer, so a plain `Bearer` 401 from a static-token API (an invalid key, not
40
- * an OAuth requirement) is not misclassified.
41
- */
42
- export declare function isOAuthChallenge(wwwAuthenticate: string): boolean;
43
- /** Extract the `resource_metadata` URL from a `WWW-Authenticate` value, if present. */
44
- export declare function parseResourceMetadataUrl(wwwAuthenticate: string): string | undefined;
45
15
  /**
46
16
  * Probe an HTTP MCP endpoint once to decide whether its failure is an OAuth
47
17
  * challenge. Returns an {@link OAuthRequiredError} to throw, or `null` when the
@@ -14,11 +14,23 @@
14
14
  * OAuthRequiredError} whose message tells the user to connect via OAuth instead
15
15
  * of a manual token.
16
16
  *
17
- * Kept transport-library-agnostic (uses global `fetch`) so it is reusable by
18
- * connect-time discovery, execution-time error enrichment, and any other caller
19
- * that holds an MCP endpoint URL + headers.
17
+ * The rule and the request have one home, `@stigmer/outbound/mcp-oauth`,
18
+ * shared with the control plane (which asks the same question of a URL-only
19
+ * server at save time, so the Sign in button exists before the first run)
20
+ * and the catalogue audit. The request is the complete `initialize`
21
+ * (protocol version, capabilities, client info): several hosted servers
22
+ * validate the request before they check authentication and answer a bare
23
+ * one with HTTP 200 and a JSON-RPC error, so the bare request this module
24
+ * once sent never reached their 401 (stigmer #1188). What stays here is the
25
+ * runner's own: its deadline, its name in the handshake, and the sentence
26
+ * the user reads.
20
27
  */
28
+ import { probeEndpointAuth } from "@stigmer/outbound/mcp-oauth";
29
+ export { isOAuthChallenge, parseResourceMetadataUrl } from "@stigmer/outbound/mcp-oauth";
30
+ /** The failure path can afford a patient probe; a save cannot, and uses its own. */
21
31
  const OAUTH_PROBE_TIMEOUT_MS = 10_000;
32
+ /** How the runner names itself in the handshake's `clientInfo`. */
33
+ const PROBE_CLIENT_NAME = "stigmer-runner";
22
34
  /**
23
35
  * Raised when an HTTP MCP endpoint answers with an OAuth authentication
24
36
  * challenge. The message is self-contained and user-facing: it survives the
@@ -39,25 +51,6 @@ export class OAuthRequiredError extends Error {
39
51
  this.name = "OAuthRequiredError";
40
52
  }
41
53
  }
42
- /**
43
- * Whether a `WWW-Authenticate` header value is an OAuth challenge.
44
- *
45
- * The MCP auth spec emits `Bearer realm="OAuth", resource_metadata="..."`. We
46
- * require the `Bearer` scheme plus either an OAuth realm or a resource-metadata
47
- * pointer, so a plain `Bearer` 401 from a static-token API (an invalid key, not
48
- * an OAuth requirement) is not misclassified.
49
- */
50
- export function isOAuthChallenge(wwwAuthenticate) {
51
- const value = wwwAuthenticate.toLowerCase();
52
- if (!value.includes("bearer"))
53
- return false;
54
- return value.includes("oauth") || value.includes("resource_metadata");
55
- }
56
- /** Extract the `resource_metadata` URL from a `WWW-Authenticate` value, if present. */
57
- export function parseResourceMetadataUrl(wwwAuthenticate) {
58
- const match = /resource_metadata="([^"]+)"/i.exec(wwwAuthenticate);
59
- return match?.[1];
60
- }
61
54
  /**
62
55
  * Probe an HTTP MCP endpoint once to decide whether its failure is an OAuth
63
56
  * challenge. Returns an {@link OAuthRequiredError} to throw, or `null` when the
@@ -67,33 +60,13 @@ export function parseResourceMetadataUrl(wwwAuthenticate) {
67
60
  * step can never mask or replace the original discovery error.
68
61
  */
69
62
  export async function detectOAuthChallenge(url, headers, slug) {
70
- try {
71
- const response = await fetch(url, {
72
- method: "POST",
73
- headers: {
74
- "Content-Type": "application/json",
75
- Accept: "application/json, text/event-stream",
76
- ...(headers ?? {}),
77
- },
78
- // A minimal MCP initialize request — enough to trigger the endpoint's
79
- // auth check without establishing a session.
80
- body: JSON.stringify({
81
- jsonrpc: "2.0",
82
- id: 1,
83
- method: "initialize",
84
- params: {},
85
- }),
86
- signal: AbortSignal.timeout(OAUTH_PROBE_TIMEOUT_MS),
87
- });
88
- if (response.status !== 401)
89
- return null;
90
- const wwwAuthenticate = response.headers.get("www-authenticate") ?? "";
91
- if (!isOAuthChallenge(wwwAuthenticate))
92
- return null;
93
- return new OAuthRequiredError(slug, parseResourceMetadataUrl(wwwAuthenticate));
94
- }
95
- catch {
63
+ const outcome = await probeEndpointAuth(url, headers, {
64
+ fetchImpl: fetch,
65
+ timeoutMs: OAUTH_PROBE_TIMEOUT_MS,
66
+ clientName: PROBE_CLIENT_NAME,
67
+ });
68
+ if (outcome.kind !== "oauth")
96
69
  return null;
97
- }
70
+ return new OAuthRequiredError(slug, outcome.resourceMetadataUrl);
98
71
  }
99
72
  //# sourceMappingURL=mcp-oauth-detect.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"mcp-oauth-detect.js","sourceRoot":"","sources":["../../src/shared/mcp-oauth-detect.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,MAAM,sBAAsB,GAAG,MAAM,CAAC;AAEtC;;;;;;;GAOG;AACH,MAAM,OAAO,kBAAmB,SAAQ,KAAK;IAEzB;IACA;IAFlB,YACkB,UAAkB,EAClB,mBAA4B;QAE5C,KAAK,CACH,eAAe,UAAU,6CAA6C;YACpE,yEAAyE;YACzE,mEAAmE,CACtE,CAAC;QAPc,eAAU,GAAV,UAAU,CAAQ;QAClB,wBAAmB,GAAnB,mBAAmB,CAAS;QAO5C,IAAI,CAAC,IAAI,GAAG,oBAAoB,CAAC;IACnC,CAAC;CACF;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,gBAAgB,CAAC,eAAuB;IACtD,MAAM,KAAK,GAAG,eAAe,CAAC,WAAW,EAAE,CAAC;IAC5C,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC,QAAQ,CAAC;QAAE,OAAO,KAAK,CAAC;IAC5C,OAAO,KAAK,CAAC,QAAQ,CAAC,OAAO,CAAC,IAAI,KAAK,CAAC,QAAQ,CAAC,mBAAmB,CAAC,CAAC;AACxE,CAAC;AAED,uFAAuF;AACvF,MAAM,UAAU,wBAAwB,CACtC,eAAuB;IAEvB,MAAM,KAAK,GAAG,8BAA8B,CAAC,IAAI,CAAC,eAAe,CAAC,CAAC;IACnE,OAAO,KAAK,EAAE,CAAC,CAAC,CAAC,CAAC;AACpB,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,CAAC,KAAK,UAAU,oBAAoB,CACxC,GAAW,EACX,OAA2C,EAC3C,IAAY;IAEZ,IAAI,CAAC;QACH,MAAM,QAAQ,GAAG,MAAM,KAAK,CAAC,GAAG,EAAE;YAChC,MAAM,EAAE,MAAM;YACd,OAAO,EAAE;gBACP,cAAc,EAAE,kBAAkB;gBAClC,MAAM,EAAE,qCAAqC;gBAC7C,GAAG,CAAC,OAAO,IAAI,EAAE,CAAC;aACnB;YACD,sEAAsE;YACtE,6CAA6C;YAC7C,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC;gBACnB,OAAO,EAAE,KAAK;gBACd,EAAE,EAAE,CAAC;gBACL,MAAM,EAAE,YAAY;gBACpB,MAAM,EAAE,EAAE;aACX,CAAC;YACF,MAAM,EAAE,WAAW,CAAC,OAAO,CAAC,sBAAsB,CAAC;SACpD,CAAC,CAAC;QAEH,IAAI,QAAQ,CAAC,MAAM,KAAK,GAAG;YAAE,OAAO,IAAI,CAAC;QAEzC,MAAM,eAAe,GAAG,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC,kBAAkB,CAAC,IAAI,EAAE,CAAC;QACvE,IAAI,CAAC,gBAAgB,CAAC,eAAe,CAAC;YAAE,OAAO,IAAI,CAAC;QAEpD,OAAO,IAAI,kBAAkB,CAC3B,IAAI,EACJ,wBAAwB,CAAC,eAAe,CAAC,CAC1C,CAAC;IACJ,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAC;IACd,CAAC;AACH,CAAC"}
1
+ {"version":3,"file":"mcp-oauth-detect.js","sourceRoot":"","sources":["../../src/shared/mcp-oauth-detect.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,OAAO,EAAE,iBAAiB,EAAE,MAAM,6BAA6B,CAAC;AAEhE,OAAO,EAAE,gBAAgB,EAAE,wBAAwB,EAAE,MAAM,6BAA6B,CAAC;AAEzF,oFAAoF;AACpF,MAAM,sBAAsB,GAAG,MAAM,CAAC;AAEtC,mEAAmE;AACnE,MAAM,iBAAiB,GAAG,gBAAgB,CAAC;AAE3C;;;;;;;GAOG;AACH,MAAM,OAAO,kBAAmB,SAAQ,KAAK;IAEzB;IACA;IAFlB,YACkB,UAAkB,EAClB,mBAA4B;QAE5C,KAAK,CACH,eAAe,UAAU,6CAA6C;YACpE,yEAAyE;YACzE,mEAAmE,CACtE,CAAC;QAPc,eAAU,GAAV,UAAU,CAAQ;QAClB,wBAAmB,GAAnB,mBAAmB,CAAS;QAO5C,IAAI,CAAC,IAAI,GAAG,oBAAoB,CAAC;IACnC,CAAC;CACF;AAED;;;;;;;GAOG;AACH,MAAM,CAAC,KAAK,UAAU,oBAAoB,CACxC,GAAW,EACX,OAA2C,EAC3C,IAAY;IAEZ,MAAM,OAAO,GAAG,MAAM,iBAAiB,CAAC,GAAG,EAAE,OAAO,EAAE;QACpD,SAAS,EAAE,KAAK;QAChB,SAAS,EAAE,sBAAsB;QACjC,UAAU,EAAE,iBAAiB;KAC9B,CAAC,CAAC;IACH,IAAI,OAAO,CAAC,IAAI,KAAK,OAAO;QAAE,OAAO,IAAI,CAAC;IAC1C,OAAO,IAAI,kBAAkB,CAAC,IAAI,EAAE,OAAO,CAAC,mBAAmB,CAAC,CAAC;AACnE,CAAC"}
@@ -8,6 +8,12 @@
8
8
  * with network reach to internal services and the cloud metadata endpoint —
9
9
  * without this guard, "fetch a URL" becomes "read my pod's credentials".
10
10
  *
11
+ * The address rule itself (which ranges each posture refuses, how an
12
+ * IPv4-mapped IPv6 is unwrapped, how every resolved address is judged) has
13
+ * one home, `@stigmer/outbound/egress`, shared with the control plane, which
14
+ * dials user-supplied URLs too. This module keeps what is the runner's:
15
+ * deriving the posture from its mode, and the sentence a model reads.
16
+ *
11
17
  * Posture is mode-aware because locality differs, not trust:
12
18
  *
13
19
  * - "strict" (managed cloud runners): loopback, RFC 1918 private,
@@ -28,12 +34,11 @@
28
34
  *
29
35
  * Validation runs on the addresses DNS resolves to immediately before the
30
36
  * request is dispatched, and the caller re-validates every redirect hop.
31
- * Accepted limitation: a DNS-rebinding window remains between our lookup
32
- * and the socket connect — Node's fetch (undici) offers no lookup pinning
33
- * without replacing the dispatcher, and the strict posture's range blocks
34
- * make the rebinding payoff (an internal address) unreachable anyway.
37
+ * The accepted DNS-rebinding limitation is stated in the shared check's
38
+ * header.
35
39
  */
36
- export type GuardPosture = "strict" | "relaxed";
40
+ import { type EgressPosture } from "@stigmer/outbound/egress";
41
+ export type GuardPosture = EgressPosture;
37
42
  /** Thrown for every guard rejection; message is safe to surface to the model. */
38
43
  export declare class UrlGuardError extends Error {
39
44
  constructor(message: string);
@@ -8,6 +8,12 @@
8
8
  * with network reach to internal services and the cloud metadata endpoint —
9
9
  * without this guard, "fetch a URL" becomes "read my pod's credentials".
10
10
  *
11
+ * The address rule itself (which ranges each posture refuses, how an
12
+ * IPv4-mapped IPv6 is unwrapped, how every resolved address is judged) has
13
+ * one home, `@stigmer/outbound/egress`, shared with the control plane, which
14
+ * dials user-supplied URLs too. This module keeps what is the runner's:
15
+ * deriving the posture from its mode, and the sentence a model reads.
16
+ *
11
17
  * Posture is mode-aware because locality differs, not trust:
12
18
  *
13
19
  * - "strict" (managed cloud runners): loopback, RFC 1918 private,
@@ -28,13 +34,10 @@
28
34
  *
29
35
  * Validation runs on the addresses DNS resolves to immediately before the
30
36
  * request is dispatched, and the caller re-validates every redirect hop.
31
- * Accepted limitation: a DNS-rebinding window remains between our lookup
32
- * and the socket connect — Node's fetch (undici) offers no lookup pinning
33
- * without replacing the dispatcher, and the strict posture's range blocks
34
- * make the rebinding payoff (an internal address) unreachable anyway.
37
+ * The accepted DNS-rebinding limitation is stated in the shared check's
38
+ * header.
35
39
  */
36
- import { lookup } from "node:dns/promises";
37
- import { isIP } from "node:net";
40
+ import { checkEgress, egressPolicyForPosture, nodeLookup, blockedReason as sharedBlockedReason, } from "@stigmer/outbound/egress";
38
41
  /** Thrown for every guard rejection; message is safe to surface to the model. */
39
42
  export class UrlGuardError extends Error {
40
43
  constructor(message) {
@@ -56,6 +59,7 @@ export function resolveGuardPosture(mode, env = process.env) {
56
59
  return "strict";
57
60
  return mode === "cloud" ? "strict" : "relaxed";
58
61
  }
62
+ const lookup = nodeLookup();
59
63
  /**
60
64
  * Validate a URL for fetching under the given posture.
61
65
  *
@@ -67,41 +71,28 @@ export function resolveGuardPosture(mode, env = process.env) {
67
71
  * @throws UrlGuardError with a model-readable reason on rejection.
68
72
  */
69
73
  export async function validateFetchUrl(rawUrl, posture) {
70
- let url;
71
- try {
72
- url = new URL(rawUrl);
73
- }
74
- catch {
75
- throw new UrlGuardError(`Invalid URL: ${rawUrl}`);
76
- }
77
- if (url.protocol !== "http:" && url.protocol !== "https:") {
78
- throw new UrlGuardError(`Unsupported URL scheme "${url.protocol.replace(/:$/, "")}" — only http and https are allowed.`);
79
- }
80
- const addresses = await resolveAddresses(url.hostname);
81
- for (const address of addresses) {
82
- const reason = blockedReason(address, posture);
83
- if (reason) {
84
- throw new UrlGuardError(`Refusing to fetch ${url.hostname}: it resolves to ${address}, a ${reason} address that this runner does not fetch from.`);
74
+ const check = await checkEgress(rawUrl, egressPolicyForPosture(posture), { lookup });
75
+ if (check.ok)
76
+ return check.url;
77
+ const refusal = check.refusal;
78
+ switch (refusal.kind) {
79
+ case "invalid-url":
80
+ throw new UrlGuardError(`Invalid URL: ${refusal.url}`);
81
+ case "unsupported-scheme":
82
+ throw new UrlGuardError(`Unsupported URL scheme "${refusal.scheme}" — only http and https are allowed.`);
83
+ case "unresolvable":
84
+ throw new UrlGuardError(`Could not resolve hostname: ${refusal.hostname}`);
85
+ case "blocked":
86
+ throw new UrlGuardError(`Refusing to fetch ${refusal.hostname}: it resolves to ${refusal.address}, a ${refusal.reason} address that this runner does not fetch from.`);
87
+ case "too-many-redirects":
88
+ // The check itself never follows a redirect; web_fetch walks hops
89
+ // one validateFetchUrl at a time, so this refusal cannot arise here.
90
+ throw new UrlGuardError(`Refusing to follow more than ${refusal.hops} redirects.`);
91
+ default: {
92
+ const exhaustive = refusal;
93
+ throw new UrlGuardError(String(exhaustive));
85
94
  }
86
95
  }
87
- return url;
88
- }
89
- /**
90
- * Resolve a hostname to all of its addresses. Literal IPs pass through
91
- * (bracketed IPv6 hosts arrive from URL.hostname still bracketed).
92
- */
93
- async function resolveAddresses(hostname) {
94
- const literal = hostname.replace(/^\[|\]$/g, "");
95
- if (isIP(literal) !== 0) {
96
- return [literal];
97
- }
98
- try {
99
- const records = await lookup(hostname, { all: true, verbatim: true });
100
- return records.map((r) => r.address);
101
- }
102
- catch {
103
- throw new UrlGuardError(`Could not resolve hostname: ${hostname}`);
104
- }
105
96
  }
106
97
  /**
107
98
  * Classify an IP address against the posture's blocked ranges.
@@ -109,103 +100,6 @@ async function resolveAddresses(hostname) {
109
100
  * @returns a human-readable range name when blocked, or null when allowed.
110
101
  */
111
102
  export function blockedReason(address, posture) {
112
- const family = isIP(address);
113
- if (family === 4) {
114
- return blockedReasonV4(address, posture);
115
- }
116
- if (family === 6) {
117
- const groups = expandV6(address);
118
- if (!groups)
119
- return "unrecognized";
120
- // IPv4-mapped IPv6 (::ffff:a.b.c.d, in dotted OR hex form) — classify
121
- // the embedded IPv4 so the mapping cannot smuggle a blocked v4 address
122
- // past the guard.
123
- const embedded = extractMappedV4(groups);
124
- if (embedded) {
125
- return blockedReasonV4(embedded, posture);
126
- }
127
- return blockedReasonV6(groups, posture);
128
- }
129
- // Unparseable — fail closed; only real addresses get sockets.
130
- return "unrecognized";
131
- }
132
- function blockedReasonV4(address, posture) {
133
- const octets = address.split(".").map(Number);
134
- if (octets.length !== 4 || octets.some((o) => Number.isNaN(o) || o < 0 || o > 255)) {
135
- return "unrecognized";
136
- }
137
- const [a, b] = octets;
138
- // Link-local (169.254.0.0/16) hosts the cloud metadata service at
139
- // 169.254.169.254 — blocked under BOTH postures.
140
- if (a === 169 && b === 254)
141
- return "link-local (cloud metadata)";
142
- if (posture === "relaxed")
143
- return null;
144
- if (a === 127)
145
- return "loopback";
146
- if (a === 0)
147
- return "unspecified";
148
- if (a === 10)
149
- return "private (RFC 1918)";
150
- if (a === 172 && b >= 16 && b <= 31)
151
- return "private (RFC 1918)";
152
- if (a === 192 && b === 168)
153
- return "private (RFC 1918)";
154
- return null;
155
- }
156
- function blockedReasonV6(groups, posture) {
157
- // fe80::/10 — IPv6 link-local, the v6 sibling of the metadata range;
158
- // blocked under BOTH postures for symmetry with v4.
159
- if ((groups[0] & 0xffc0) === 0xfe80)
160
- return "link-local (cloud metadata)";
161
- if (posture === "relaxed")
162
- return null;
163
- const allZero = groups.every((g) => g === 0);
164
- if (allZero)
165
- return "unspecified";
166
- if (groups.slice(0, 7).every((g) => g === 0) && groups[7] === 1)
167
- return "loopback";
168
- // fc00::/7 — unique local (private) addresses.
169
- if ((groups[0] & 0xfe00) === 0xfc00)
170
- return "private (unique local)";
171
- return null;
172
- }
173
- /**
174
- * Expand an IPv6 address (already validated by isIP) into its 8 groups.
175
- * Handles `::` compression, a trailing dotted-IPv4 tail, and zone suffixes.
176
- */
177
- function expandV6(address) {
178
- let text = address.toLowerCase().split("%")[0];
179
- // Convert a dotted-IPv4 tail (e.g. ::ffff:127.0.0.1) into two hex groups.
180
- const v4Tail = /(\d+\.\d+\.\d+\.\d+)$/.exec(text);
181
- if (v4Tail) {
182
- const octets = v4Tail[1].split(".").map(Number);
183
- if (octets.length !== 4 || octets.some((o) => o > 255))
184
- return null;
185
- const hex = `${((octets[0] << 8) | octets[1]).toString(16)}:${((octets[2] << 8) | octets[3]).toString(16)}`;
186
- text = text.slice(0, -v4Tail[1].length) + hex;
187
- }
188
- const halves = text.split("::");
189
- if (halves.length > 2)
190
- return null;
191
- const parseHalf = (half) => half === "" ? [] : half.split(":").map((g) => parseInt(g, 16));
192
- const head = parseHalf(halves[0]);
193
- const tail = halves.length === 2 ? parseHalf(halves[1]) : [];
194
- const fill = 8 - head.length - tail.length;
195
- if (halves.length === 2 ? fill < 0 : head.length !== 8)
196
- return null;
197
- const groups = [...head, ...(halves.length === 2 ? Array(fill).fill(0) : []), ...tail];
198
- if (groups.length !== 8 || groups.some((g) => Number.isNaN(g) || g < 0 || g > 0xffff)) {
199
- return null;
200
- }
201
- return groups;
202
- }
203
- /** Extract the IPv4 payload from IPv4-mapped groups (::ffff:0:0/96), if any. */
204
- function extractMappedV4(groups) {
205
- const isMapped = groups.slice(0, 5).every((g) => g === 0) && groups[5] === 0xffff;
206
- if (!isMapped)
207
- return null;
208
- const [hi, lo] = [groups[6], groups[7]];
209
- return `${hi >> 8}.${hi & 0xff}.${lo >> 8}.${lo & 0xff}`;
103
+ return sharedBlockedReason(address, posture);
210
104
  }
211
105
  //# sourceMappingURL=url-guard.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"url-guard.js","sourceRoot":"","sources":["../../src/tools/url-guard.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"url-guard.js","sourceRoot":"","sources":["../../src/tools/url-guard.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsCG;AACH,OAAO,EACL,WAAW,EACX,sBAAsB,EACtB,UAAU,EACV,aAAa,IAAI,mBAAmB,GAErC,MAAM,0BAA0B,CAAC;AAIlC,iFAAiF;AACjF,MAAM,OAAO,aAAc,SAAQ,KAAK;IACtC,YAAY,OAAe;QACzB,KAAK,CAAC,OAAO,CAAC,CAAC;QACf,IAAI,CAAC,IAAI,GAAG,eAAe,CAAC;IAC9B,CAAC;CACF;AAED;;;;;GAKG;AACH,MAAM,UAAU,mBAAmB,CACjC,IAAuB,EACvB,MAAyB,OAAO,CAAC,GAAG;IAEpC,MAAM,QAAQ,GAAG,GAAG,CAAC,+BAA+B,CAAC;IACrD,IAAI,QAAQ,KAAK,MAAM;QAAE,OAAO,SAAS,CAAC;IAC1C,IAAI,QAAQ,KAAK,OAAO;QAAE,OAAO,QAAQ,CAAC;IAC1C,OAAO,IAAI,KAAK,OAAO,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,SAAS,CAAC;AACjD,CAAC;AAED,MAAM,MAAM,GAAG,UAAU,EAAE,CAAC;AAE5B;;;;;;;;;GASG;AACH,MAAM,CAAC,KAAK,UAAU,gBAAgB,CACpC,MAAc,EACd,OAAqB;IAErB,MAAM,KAAK,GAAG,MAAM,WAAW,CAAC,MAAM,EAAE,sBAAsB,CAAC,OAAO,CAAC,EAAE,EAAE,MAAM,EAAE,CAAC,CAAC;IACrF,IAAI,KAAK,CAAC,EAAE;QAAE,OAAO,KAAK,CAAC,GAAG,CAAC;IAE/B,MAAM,OAAO,GAAG,KAAK,CAAC,OAAO,CAAC;IAC9B,QAAQ,OAAO,CAAC,IAAI,EAAE,CAAC;QACrB,KAAK,aAAa;YAChB,MAAM,IAAI,aAAa,CAAC,gBAAgB,OAAO,CAAC,GAAG,EAAE,CAAC,CAAC;QACzD,KAAK,oBAAoB;YACvB,MAAM,IAAI,aAAa,CACrB,2BAA2B,OAAO,CAAC,MAAM,sCAAsC,CAChF,CAAC;QACJ,KAAK,cAAc;YACjB,MAAM,IAAI,aAAa,CAAC,+BAA+B,OAAO,CAAC,QAAQ,EAAE,CAAC,CAAC;QAC7E,KAAK,SAAS;YACZ,MAAM,IAAI,aAAa,CACrB,qBAAqB,OAAO,CAAC,QAAQ,oBAAoB,OAAO,CAAC,OAAO,OAAO,OAAO,CAAC,MAAM,gDAAgD,CAC9I,CAAC;QACJ,KAAK,oBAAoB;YACvB,kEAAkE;YAClE,qEAAqE;YACrE,MAAM,IAAI,aAAa,CAAC,gCAAgC,OAAO,CAAC,IAAI,aAAa,CAAC,CAAC;QACrF,OAAO,CAAC,CAAC,CAAC;YACR,MAAM,UAAU,GAAU,OAAO,CAAC;YAClC,MAAM,IAAI,aAAa,CAAC,MAAM,CAAC,UAAU,CAAC,CAAC,CAAC;QAC9C,CAAC;IACH,CAAC;AACH,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,aAAa,CAAC,OAAe,EAAE,OAAqB;IAClE,OAAO,mBAAmB,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;AAC/C,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stigmer/runner",
3
- "version": "3.18.0",
3
+ "version": "3.19.0",
4
4
  "description": "Embeddable Temporal worker for the Stigmer AI agent platform — handles agent execution, workflow orchestration, and MCP server management",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -104,9 +104,10 @@
104
104
  "@opentelemetry/sdk-metrics": "^2.0.0",
105
105
  "@opentelemetry/sdk-trace-base": "^2.0.0",
106
106
  "@opentelemetry/sdk-trace-node": "^2.0.0",
107
- "@stigmer/protos": "3.18.0",
108
- "@stigmer/temporal-codecs": "3.18.0",
109
- "@stigmer/zip-structure": "3.18.0",
107
+ "@stigmer/outbound": "3.19.0",
108
+ "@stigmer/protos": "3.19.0",
109
+ "@stigmer/temporal-codecs": "3.19.0",
110
+ "@stigmer/zip-structure": "3.19.0",
110
111
  "@temporalio/activity": "1.16.2",
111
112
  "@temporalio/common": "1.16.2",
112
113
  "@temporalio/interceptors-opentelemetry": "1.16.2",
@@ -144,4 +144,24 @@ describe("detectOAuthChallenge", () => {
144
144
  );
145
145
  expect(init?.method).toBe("POST");
146
146
  });
147
+
148
+ it("sends a complete initialize naming the runner, so servers that validate before they authenticate reach their 401 (#1188)", async () => {
149
+ const fetchMock = vi.fn(
150
+ async (_url: string | URL, _init?: RequestInit) =>
151
+ new Response(null, { status: 200 }),
152
+ );
153
+ vi.stubGlobal("fetch", fetchMock);
154
+
155
+ await detectOAuthChallenge("https://mcp.example.com/mcp", undefined, "example");
156
+
157
+ const init = fetchMock.mock.calls[0]?.[1];
158
+ const body = JSON.parse(String(init?.body)) as {
159
+ method: string;
160
+ params: { protocolVersion?: string; capabilities?: object; clientInfo?: { name: string } };
161
+ };
162
+ expect(body.method).toBe("initialize");
163
+ expect(body.params.protocolVersion).toBe("2025-06-18");
164
+ expect(body.params.capabilities).toEqual({});
165
+ expect(body.params.clientInfo?.name).toBe("stigmer-runner");
166
+ });
147
167
  });
@@ -14,13 +14,27 @@
14
14
  * OAuthRequiredError} whose message tells the user to connect via OAuth instead
15
15
  * of a manual token.
16
16
  *
17
- * Kept transport-library-agnostic (uses global `fetch`) so it is reusable by
18
- * connect-time discovery, execution-time error enrichment, and any other caller
19
- * that holds an MCP endpoint URL + headers.
17
+ * The rule and the request have one home, `@stigmer/outbound/mcp-oauth`,
18
+ * shared with the control plane (which asks the same question of a URL-only
19
+ * server at save time, so the Sign in button exists before the first run)
20
+ * and the catalogue audit. The request is the complete `initialize`
21
+ * (protocol version, capabilities, client info): several hosted servers
22
+ * validate the request before they check authentication and answer a bare
23
+ * one with HTTP 200 and a JSON-RPC error, so the bare request this module
24
+ * once sent never reached their 401 (stigmer #1188). What stays here is the
25
+ * runner's own: its deadline, its name in the handshake, and the sentence
26
+ * the user reads.
20
27
  */
28
+ import { probeEndpointAuth } from "@stigmer/outbound/mcp-oauth";
21
29
 
30
+ export { isOAuthChallenge, parseResourceMetadataUrl } from "@stigmer/outbound/mcp-oauth";
31
+
32
+ /** The failure path can afford a patient probe; a save cannot, and uses its own. */
22
33
  const OAUTH_PROBE_TIMEOUT_MS = 10_000;
23
34
 
35
+ /** How the runner names itself in the handshake's `clientInfo`. */
36
+ const PROBE_CLIENT_NAME = "stigmer-runner";
37
+
24
38
  /**
25
39
  * Raised when an HTTP MCP endpoint answers with an OAuth authentication
26
40
  * challenge. The message is self-contained and user-facing: it survives the
@@ -43,28 +57,6 @@ export class OAuthRequiredError extends Error {
43
57
  }
44
58
  }
45
59
 
46
- /**
47
- * Whether a `WWW-Authenticate` header value is an OAuth challenge.
48
- *
49
- * The MCP auth spec emits `Bearer realm="OAuth", resource_metadata="..."`. We
50
- * require the `Bearer` scheme plus either an OAuth realm or a resource-metadata
51
- * pointer, so a plain `Bearer` 401 from a static-token API (an invalid key, not
52
- * an OAuth requirement) is not misclassified.
53
- */
54
- export function isOAuthChallenge(wwwAuthenticate: string): boolean {
55
- const value = wwwAuthenticate.toLowerCase();
56
- if (!value.includes("bearer")) return false;
57
- return value.includes("oauth") || value.includes("resource_metadata");
58
- }
59
-
60
- /** Extract the `resource_metadata` URL from a `WWW-Authenticate` value, if present. */
61
- export function parseResourceMetadataUrl(
62
- wwwAuthenticate: string,
63
- ): string | undefined {
64
- const match = /resource_metadata="([^"]+)"/i.exec(wwwAuthenticate);
65
- return match?.[1];
66
- }
67
-
68
60
  /**
69
61
  * Probe an HTTP MCP endpoint once to decide whether its failure is an OAuth
70
62
  * challenge. Returns an {@link OAuthRequiredError} to throw, or `null` when the
@@ -78,35 +70,11 @@ export async function detectOAuthChallenge(
78
70
  headers: Record<string, string> | undefined,
79
71
  slug: string,
80
72
  ): Promise<OAuthRequiredError | null> {
81
- try {
82
- const response = await fetch(url, {
83
- method: "POST",
84
- headers: {
85
- "Content-Type": "application/json",
86
- Accept: "application/json, text/event-stream",
87
- ...(headers ?? {}),
88
- },
89
- // A minimal MCP initialize request — enough to trigger the endpoint's
90
- // auth check without establishing a session.
91
- body: JSON.stringify({
92
- jsonrpc: "2.0",
93
- id: 1,
94
- method: "initialize",
95
- params: {},
96
- }),
97
- signal: AbortSignal.timeout(OAUTH_PROBE_TIMEOUT_MS),
98
- });
99
-
100
- if (response.status !== 401) return null;
101
-
102
- const wwwAuthenticate = response.headers.get("www-authenticate") ?? "";
103
- if (!isOAuthChallenge(wwwAuthenticate)) return null;
104
-
105
- return new OAuthRequiredError(
106
- slug,
107
- parseResourceMetadataUrl(wwwAuthenticate),
108
- );
109
- } catch {
110
- return null;
111
- }
73
+ const outcome = await probeEndpointAuth(url, headers, {
74
+ fetchImpl: fetch,
75
+ timeoutMs: OAUTH_PROBE_TIMEOUT_MS,
76
+ clientName: PROBE_CLIENT_NAME,
77
+ });
78
+ if (outcome.kind !== "oauth") return null;
79
+ return new OAuthRequiredError(slug, outcome.resourceMetadataUrl);
112
80
  }
@@ -8,6 +8,12 @@
8
8
  * with network reach to internal services and the cloud metadata endpoint —
9
9
  * without this guard, "fetch a URL" becomes "read my pod's credentials".
10
10
  *
11
+ * The address rule itself (which ranges each posture refuses, how an
12
+ * IPv4-mapped IPv6 is unwrapped, how every resolved address is judged) has
13
+ * one home, `@stigmer/outbound/egress`, shared with the control plane, which
14
+ * dials user-supplied URLs too. This module keeps what is the runner's:
15
+ * deriving the posture from its mode, and the sentence a model reads.
16
+ *
11
17
  * Posture is mode-aware because locality differs, not trust:
12
18
  *
13
19
  * - "strict" (managed cloud runners): loopback, RFC 1918 private,
@@ -28,16 +34,18 @@
28
34
  *
29
35
  * Validation runs on the addresses DNS resolves to immediately before the
30
36
  * request is dispatched, and the caller re-validates every redirect hop.
31
- * Accepted limitation: a DNS-rebinding window remains between our lookup
32
- * and the socket connect — Node's fetch (undici) offers no lookup pinning
33
- * without replacing the dispatcher, and the strict posture's range blocks
34
- * make the rebinding payoff (an internal address) unreachable anyway.
37
+ * The accepted DNS-rebinding limitation is stated in the shared check's
38
+ * header.
35
39
  */
40
+ import {
41
+ checkEgress,
42
+ egressPolicyForPosture,
43
+ nodeLookup,
44
+ blockedReason as sharedBlockedReason,
45
+ type EgressPosture,
46
+ } from "@stigmer/outbound/egress";
36
47
 
37
- import { lookup } from "node:dns/promises";
38
- import { isIP } from "node:net";
39
-
40
- export type GuardPosture = "strict" | "relaxed";
48
+ export type GuardPosture = EgressPosture;
41
49
 
42
50
  /** Thrown for every guard rejection; message is safe to surface to the model. */
43
51
  export class UrlGuardError extends Error {
@@ -63,6 +71,8 @@ export function resolveGuardPosture(
63
71
  return mode === "cloud" ? "strict" : "relaxed";
64
72
  }
65
73
 
74
+ const lookup = nodeLookup();
75
+
66
76
  /**
67
77
  * Validate a URL for fetching under the given posture.
68
78
  *
@@ -77,48 +87,32 @@ export async function validateFetchUrl(
77
87
  rawUrl: string,
78
88
  posture: GuardPosture,
79
89
  ): Promise<URL> {
80
- let url: URL;
81
- try {
82
- url = new URL(rawUrl);
83
- } catch {
84
- throw new UrlGuardError(`Invalid URL: ${rawUrl}`);
85
- }
86
-
87
- if (url.protocol !== "http:" && url.protocol !== "https:") {
88
- throw new UrlGuardError(
89
- `Unsupported URL scheme "${url.protocol.replace(/:$/, "")}" — only http and https are allowed.`,
90
- );
91
- }
92
-
93
- const addresses = await resolveAddresses(url.hostname);
94
- for (const address of addresses) {
95
- const reason = blockedReason(address, posture);
96
- if (reason) {
90
+ const check = await checkEgress(rawUrl, egressPolicyForPosture(posture), { lookup });
91
+ if (check.ok) return check.url;
92
+
93
+ const refusal = check.refusal;
94
+ switch (refusal.kind) {
95
+ case "invalid-url":
96
+ throw new UrlGuardError(`Invalid URL: ${refusal.url}`);
97
+ case "unsupported-scheme":
97
98
  throw new UrlGuardError(
98
- `Refusing to fetch ${url.hostname}: it resolves to ${address}, a ${reason} address that this runner does not fetch from.`,
99
+ `Unsupported URL scheme "${refusal.scheme}" — only http and https are allowed.`,
99
100
  );
101
+ case "unresolvable":
102
+ throw new UrlGuardError(`Could not resolve hostname: ${refusal.hostname}`);
103
+ case "blocked":
104
+ throw new UrlGuardError(
105
+ `Refusing to fetch ${refusal.hostname}: it resolves to ${refusal.address}, a ${refusal.reason} address that this runner does not fetch from.`,
106
+ );
107
+ case "too-many-redirects":
108
+ // The check itself never follows a redirect; web_fetch walks hops
109
+ // one validateFetchUrl at a time, so this refusal cannot arise here.
110
+ throw new UrlGuardError(`Refusing to follow more than ${refusal.hops} redirects.`);
111
+ default: {
112
+ const exhaustive: never = refusal;
113
+ throw new UrlGuardError(String(exhaustive));
100
114
  }
101
115
  }
102
-
103
- return url;
104
- }
105
-
106
- /**
107
- * Resolve a hostname to all of its addresses. Literal IPs pass through
108
- * (bracketed IPv6 hosts arrive from URL.hostname still bracketed).
109
- */
110
- async function resolveAddresses(hostname: string): Promise<string[]> {
111
- const literal = hostname.replace(/^\[|\]$/g, "");
112
- if (isIP(literal) !== 0) {
113
- return [literal];
114
- }
115
-
116
- try {
117
- const records = await lookup(hostname, { all: true, verbatim: true });
118
- return records.map((r) => r.address);
119
- } catch {
120
- throw new UrlGuardError(`Could not resolve hostname: ${hostname}`);
121
- }
122
116
  }
123
117
 
124
118
  /**
@@ -127,106 +121,5 @@ async function resolveAddresses(hostname: string): Promise<string[]> {
127
121
  * @returns a human-readable range name when blocked, or null when allowed.
128
122
  */
129
123
  export function blockedReason(address: string, posture: GuardPosture): string | null {
130
- const family = isIP(address);
131
-
132
- if (family === 4) {
133
- return blockedReasonV4(address, posture);
134
- }
135
-
136
- if (family === 6) {
137
- const groups = expandV6(address);
138
- if (!groups) return "unrecognized";
139
-
140
- // IPv4-mapped IPv6 (::ffff:a.b.c.d, in dotted OR hex form) — classify
141
- // the embedded IPv4 so the mapping cannot smuggle a blocked v4 address
142
- // past the guard.
143
- const embedded = extractMappedV4(groups);
144
- if (embedded) {
145
- return blockedReasonV4(embedded, posture);
146
- }
147
- return blockedReasonV6(groups, posture);
148
- }
149
-
150
- // Unparseable — fail closed; only real addresses get sockets.
151
- return "unrecognized";
152
- }
153
-
154
- function blockedReasonV4(address: string, posture: GuardPosture): string | null {
155
- const octets = address.split(".").map(Number);
156
- if (octets.length !== 4 || octets.some((o) => Number.isNaN(o) || o < 0 || o > 255)) {
157
- return "unrecognized";
158
- }
159
- const [a, b] = octets;
160
-
161
- // Link-local (169.254.0.0/16) hosts the cloud metadata service at
162
- // 169.254.169.254 — blocked under BOTH postures.
163
- if (a === 169 && b === 254) return "link-local (cloud metadata)";
164
-
165
- if (posture === "relaxed") return null;
166
-
167
- if (a === 127) return "loopback";
168
- if (a === 0) return "unspecified";
169
- if (a === 10) return "private (RFC 1918)";
170
- if (a === 172 && b >= 16 && b <= 31) return "private (RFC 1918)";
171
- if (a === 192 && b === 168) return "private (RFC 1918)";
172
-
173
- return null;
174
- }
175
-
176
- function blockedReasonV6(groups: readonly number[], posture: GuardPosture): string | null {
177
- // fe80::/10 — IPv6 link-local, the v6 sibling of the metadata range;
178
- // blocked under BOTH postures for symmetry with v4.
179
- if ((groups[0] & 0xffc0) === 0xfe80) return "link-local (cloud metadata)";
180
-
181
- if (posture === "relaxed") return null;
182
-
183
- const allZero = groups.every((g) => g === 0);
184
- if (allZero) return "unspecified";
185
- if (groups.slice(0, 7).every((g) => g === 0) && groups[7] === 1) return "loopback";
186
- // fc00::/7 — unique local (private) addresses.
187
- if ((groups[0] & 0xfe00) === 0xfc00) return "private (unique local)";
188
-
189
- return null;
190
- }
191
-
192
- /**
193
- * Expand an IPv6 address (already validated by isIP) into its 8 groups.
194
- * Handles `::` compression, a trailing dotted-IPv4 tail, and zone suffixes.
195
- */
196
- function expandV6(address: string): number[] | null {
197
- let text = address.toLowerCase().split("%")[0];
198
-
199
- // Convert a dotted-IPv4 tail (e.g. ::ffff:127.0.0.1) into two hex groups.
200
- const v4Tail = /(\d+\.\d+\.\d+\.\d+)$/.exec(text);
201
- if (v4Tail) {
202
- const octets = v4Tail[1].split(".").map(Number);
203
- if (octets.length !== 4 || octets.some((o) => o > 255)) return null;
204
- const hex = `${((octets[0] << 8) | octets[1]).toString(16)}:${((octets[2] << 8) | octets[3]).toString(16)}`;
205
- text = text.slice(0, -v4Tail[1].length) + hex;
206
- }
207
-
208
- const halves = text.split("::");
209
- if (halves.length > 2) return null;
210
-
211
- const parseHalf = (half: string): number[] =>
212
- half === "" ? [] : half.split(":").map((g) => parseInt(g, 16));
213
-
214
- const head = parseHalf(halves[0]);
215
- const tail = halves.length === 2 ? parseHalf(halves[1]) : [];
216
- const fill = 8 - head.length - tail.length;
217
- if (halves.length === 2 ? fill < 0 : head.length !== 8) return null;
218
-
219
- const groups = [...head, ...(halves.length === 2 ? Array(fill).fill(0) : []), ...tail];
220
- if (groups.length !== 8 || groups.some((g) => Number.isNaN(g) || g < 0 || g > 0xffff)) {
221
- return null;
222
- }
223
- return groups;
224
- }
225
-
226
- /** Extract the IPv4 payload from IPv4-mapped groups (::ffff:0:0/96), if any. */
227
- function extractMappedV4(groups: readonly number[]): string | null {
228
- const isMapped = groups.slice(0, 5).every((g) => g === 0) && groups[5] === 0xffff;
229
- if (!isMapped) return null;
230
- const [hi, lo] = [groups[6], groups[7]];
231
- return `${hi >> 8}.${hi & 0xff}.${lo >> 8}.${lo & 0xff}`;
124
+ return sharedBlockedReason(address, posture);
232
125
  }