diff --git a/.github/workflows/publish-mcp-registry.yml b/.github/workflows/publish-mcp-registry.yml index 894d8ac..9a3b999 100644 --- a/.github/workflows/publish-mcp-registry.yml +++ b/.github/workflows/publish-mcp-registry.yml @@ -116,6 +116,6 @@ jobs: '.servers | any( .server.name == "io.github.sandbaseai/cli" and .server.version == $version - and ((.server.remotes // []) | any(.url == "https://sandbase.ai/v1/mcp")) + and ((.server.remotes // []) | any(.url == "https://api.sandbase.ai/v1/mcp" or .url == "https://sandbase.ai/v1/mcp")) )' \ <<< "$response" diff --git a/assets/mcp-bridge.mjs b/assets/mcp-bridge.mjs index 21fc77a..2ceb9da 100644 --- a/assets/mcp-bridge.mjs +++ b/assets/mcp-bridge.mjs @@ -14,10 +14,9 @@ import { pathToFileURL } from "node:url"; // silently drops the server. // 2. Every tool failure reaches the model as an `isError` tool result that // names the cause and the next step, instead of an opaque protocol error. -// 3. If the recorded endpoint is unreachable, a known mirror of the same API -// is tried automatically. +// 3. The API is reached only at api.sandbase.ai; extra mirrors are tried only +// when the user opts in via SANDBASE_MCP_FALLBACK_URLS. const allowedClients = new Set(["codex", "claude-code", "cursor", "gemini-cli", "hermes", "openclaw", "opencode", "qwen-code", "windsurf", "cursor-cli", "kimi-cli", "kiro", "kiro-cli", "amp", "crush", "iflow-cli", "warp", "claude-desktop", "cowork", "antigravity", "trae", "qoder", "workbuddy", "pi"]); -const FALLBACK_URL = "https://www.sandbase.ai/v1/mcp"; const BILLING_URL = "https://www.sandbase.ai/console/billing"; const RECONNECT = "npx -y @sandbaseai/cli@latest connect"; // Tool calls get no bridge-side deadline (long synchronous tools must be able to @@ -46,10 +45,10 @@ function validEndpoint(value) { } export function endpointCandidates(primary, env = process.env) { const out = [primary]; + // The API is served only from api.sandbase.ai. There is no implicit + // cross-host fallback; users can still opt into extra mirrors explicitly. if (env.SANDBASE_MCP_FALLBACK_URLS !== undefined) { for (const item of env.SANDBASE_MCP_FALLBACK_URLS.split(",")) { const url = validEndpoint(item.trim()); if (url) out.push(url); } - } else { - try { const host = new URL(primary).hostname; if (host === "api.sandbase.ai" || host === "sandbase.ai") out.push(FALLBACK_URL); } catch { /* validated earlier */ } } return [...new Set(out)]; } diff --git a/server.json b/server.json index eb92352..e89d525 100644 --- a/server.json +++ b/server.json @@ -11,7 +11,7 @@ "remotes": [ { "type": "streamable-http", - "url": "https://sandbase.ai/v1/mcp", + "url": "https://api.sandbase.ai/v1/mcp", "headers": [ { "name": "Authorization", diff --git a/src/a1-adapters.ts b/src/a1-adapters.ts index 4652041..6ec4a32 100644 --- a/src/a1-adapters.ts +++ b/src/a1-adapters.ts @@ -7,6 +7,17 @@ import { configPath, sandbaseHome } from "./paths.js"; export const a1Clients = ["opencode", "qwen-code", "windsurf"] as const; export type A1Client = typeof a1Clients[number]; + +// Endpoints an installed `sandbase` entry may legitimately point at, used to +// prove SandBase ownership before touching a client config. The canonical API +// host leads; the bare and www hosts stay recognized so configs written by +// older CLI versions (before the api.sandbase.ai migration) are not rejected +// as foreign on upgrade. +export const OWNED_MCP_ENDPOINTS = [ + "https://api.sandbase.ai/v1/mcp", + "https://sandbase.ai/v1/mcp", + "https://www.sandbase.ai/v1/mcp", +] as const; export function isA1Client(client: string): client is A1Client { return a1Clients.includes(client as A1Client); } export interface A1CommandResult { code: number | null; stdout: string; stderr: string } @@ -73,7 +84,7 @@ async function inspectConfig(client: A1Client, env = process.env, expectedBridge return entry === undefined ? { state: "missing", detail: "SandBase is not registered" } : openCodeOwned(entry, bridges) ? { state: "configured", detail: "global configuration ownership verified" } : { state: "conflict", detail: "the sandbase entry is not SandBase-owned" }; } const servers = root.mcpServers === undefined ? undefined : objectMap(root.mcpServers, client); const entry = servers?.sandbase; - const endpoints = [...new Set(["https://sandbase.ai/v1/mcp", identity?.endpoint?.replace(/\/$/, ""), expectedEndpoint?.replace(/\/$/, "")].filter((value): value is string => !!value))]; + const endpoints = [...new Set([...OWNED_MCP_ENDPOINTS, identity?.endpoint?.replace(/\/$/, ""), expectedEndpoint?.replace(/\/$/, "")].filter((value): value is string => !!value))]; const owned = client === "qwen-code" ? qwenOwned(entry, bridges) : windsurfOwned(entry, endpoints, env); if (entry === undefined) return { state: "missing", detail: "SandBase is not registered" }; if (!owned) return { state: "conflict", detail: "the sandbase entry is not SandBase-owned" }; @@ -127,7 +138,7 @@ export async function installA1(client: A1Client, bridge: string, credential: st catch (error) { await restoreSnapshot(path, backupPath); if (identityChanged) identityPrevious === undefined ? await rm(ownerPath, { force: true }) : await atomicWrite(ownerPath, identityPrevious); throw error; } } const root = parseObject(text, client); const servers = root.mcpServers === undefined ? {} : objectMap(root.mcpServers, client); const existing = servers.sandbase; - if (existing !== undefined && !windsurfOwned(existing, ["https://sandbase.ai/v1/mcp", mcpUrl.replace(/\/$/, "")], env)) throw new Error("windsurf: the sandbase entry is not SandBase-owned; no changes were made"); + if (existing !== undefined && !windsurfOwned(existing, [...OWNED_MCP_ENDPOINTS, mcpUrl.replace(/\/$/, "")], env)) throw new Error("windsurf: the sandbase entry is not SandBase-owned; no changes were made"); const desired = { serverUrl: mcpUrl, headers: { Authorization: windsurfAuthorization(env) } }; const next = applyJsonc(text, ["mcpServers", "sandbase"], desired); const secretPath = credentialPath(env); const currentSecret = await readOptional(secretPath); const configBackup = next === raw ? undefined : await backup(path); const secretBackup = currentSecret === credential ? undefined : await backup(secretPath); try { diff --git a/src/agent/client.ts b/src/agent/client.ts index 3cb0490..f2bc9ff 100644 --- a/src/agent/client.ts +++ b/src/agent/client.ts @@ -10,7 +10,6 @@ import { sandbaseHome } from "../paths.js"; import { clients, type Client, type CredentialRecord } from "../types.js"; export const DEFAULT_MCP_URL = "https://api.sandbase.ai/v1/mcp"; -export const FALLBACK_MCP_URL = "https://www.sandbase.ai/v1/mcp"; const PREFERENCE_TTL_MS = 6 * 60 * 60 * 1000; export type ErrorCode = @@ -34,14 +33,17 @@ export function validMcpUrl(value: unknown): string | undefined { } catch { return undefined; } } -/** Ordered endpoint candidates: the recorded URL first, then known-good mirrors of the same API. */ +/** + * Ordered endpoint candidates: the recorded URL first, then any extra mirrors + * the user explicitly opted into via SANDBASE_MCP_FALLBACK_URLS. The API is + * served only from api.sandbase.ai, so there is no implicit cross-host + * fallback — the main-site and bare hosts are not API entry points. + */ export function endpointCandidates(primary: string, env = process.env): string[] { const out = [primary]; const configured = env.SANDBASE_MCP_FALLBACK_URLS; if (configured !== undefined) { for (const item of configured.split(",").map(value => value.trim()).filter(Boolean)) { const url = validMcpUrl(item); if (url) out.push(url); } - } else { - try { const host = new URL(primary).hostname; if (host === "api.sandbase.ai" || host === "sandbase.ai") out.push(FALLBACK_MCP_URL); } catch { /* primary was validated by the caller */ } } return [...new Set(out)]; } diff --git a/src/commands.ts b/src/commands.ts index 20eb370..7a60b08 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -97,7 +97,7 @@ async function prepareOpenClawSkill(detected: Detection, log: (message: string) } async function connectOpenClaw(deps: CommandDependencies): Promise { - const api = deps.api || new AuthorizationApi((process.env.SANDBASE_API_URL || "https://sandbase.ai").replace(/\/$/, "")); const store = deps.store || new FileCredentialStore(); const log = deps.log || console.log; const detect = deps.detect || detectClient; + const api = deps.api || new AuthorizationApi((process.env.SANDBASE_API_URL || "https://api.sandbase.ai").replace(/\/$/, "")); const store = deps.store || new FileCredentialStore(); const log = deps.log || console.log; const detect = deps.detect || detectClient; if (!(await prepareOpenClawSkill(detect("openclaw"), log, deps.openClawRunner, deps.openClawReadbackVerifier))) return; const previous = await store.get("openclaw"); const { authorizationId, exchange } = await authorize(api, "openclaw", { open: deps.open || openBrowser, sleep, log, ...(deps.signal ? { signal: deps.signal } : {}) }); let bridgeResult; try { @@ -113,7 +113,7 @@ async function connectOpenClaw(deps: CommandDependencies): Promise { } async function connectDesktop(client: Extract, deps: CommandDependencies): Promise { - const api = deps.api || new AuthorizationApi((process.env.SANDBASE_API_URL || "https://sandbase.ai").replace(/\/$/, "")); const store = deps.store || new FileCredentialStore(); const log = deps.log || console.log; + const api = deps.api || new AuthorizationApi((process.env.SANDBASE_API_URL || "https://api.sandbase.ai").replace(/\/$/, "")); const store = deps.store || new FileCredentialStore(); const log = deps.log || console.log; const previous = await store.get(client); const { authorizationId, exchange } = await authorize(api, client, { open: deps.open || openBrowser, sleep, log, ...(deps.signal ? { signal: deps.signal } : {}) }); let bridgeResult; let identityResult; try { await store.save(recordFor(client, exchange)); bridgeResult = await installBridge(); identityResult = await writeDesktopIdentity(client); @@ -126,7 +126,7 @@ async function connectDesktop(client: Extract { - const api = deps.api || new AuthorizationApi((process.env.SANDBASE_API_URL || "https://sandbase.ai").replace(/\/$/, "")); const store = deps.store || new FileCredentialStore(); const log = deps.log || console.log; + const api = deps.api || new AuthorizationApi((process.env.SANDBASE_API_URL || "https://api.sandbase.ai").replace(/\/$/, "")); const store = deps.store || new FileCredentialStore(); const log = deps.log || console.log; const previous = await store.get(client); const { authorizationId, exchange } = await authorize(api, client, { open: deps.open || openBrowser, sleep, log, ...(deps.signal ? { signal: deps.signal } : {}) }); let bridgeResult; try { await store.save(recordFor(client, exchange)); bridgeResult = await installBridge(); @@ -139,7 +139,7 @@ async function connectPromptAssisted(client: PromptAssistedClient, deps: Command } async function connectChatGPT(deps: CommandDependencies): Promise { - const api = deps.api || new AuthorizationApi((process.env.SANDBASE_API_URL || "https://sandbase.ai").replace(/\/$/, "")); const store = deps.store || new FileCredentialStore(); const log = deps.log || console.log; + const api = deps.api || new AuthorizationApi((process.env.SANDBASE_API_URL || "https://api.sandbase.ai").replace(/\/$/, "")); const store = deps.store || new FileCredentialStore(); const log = deps.log || console.log; const previous = await store.get("chatgpt"); const { authorizationId, exchange } = await authorize(api, "chatgpt", { open: deps.open || openBrowser, sleep, log, ...(deps.signal ? { signal: deps.signal } : {}) }); let bridgeResult; try { await store.save(recordFor("chatgpt", exchange)); bridgeResult = await installBridge(); @@ -152,7 +152,7 @@ async function connectChatGPT(deps: CommandDependencies): Promise { } async function connectOne(client: Client, deps: CommandDependencies): Promise { - const api = deps.api || new AuthorizationApi((process.env.SANDBASE_API_URL || "https://sandbase.ai").replace(/\/$/, "")); const store = deps.store || new FileCredentialStore(); const log = deps.log || console.log; const detect = deps.detect || detectClient; + const api = deps.api || new AuthorizationApi((process.env.SANDBASE_API_URL || "https://api.sandbase.ai").replace(/\/$/, "")); const store = deps.store || new FileCredentialStore(); const log = deps.log || console.log; const detect = deps.detect || detectClient; const profile = clientProfiles[client]; const capability = nativeCapabilities[client]; const v2 = capabilityRegistry[client]; if (client === "chatgpt") { await connectChatGPT(deps); return; } @@ -186,7 +186,7 @@ async function connectOne(client: Client, deps: CommandDependencies): Promise { - const api = deps.api || new AuthorizationApi((process.env.SANDBASE_API_URL || "https://sandbase.ai").replace(/\/$/, "")); const store = deps.store || new FileCredentialStore(); const log = deps.log || console.log; const detect = deps.detect || detectClient; + const api = deps.api || new AuthorizationApi((process.env.SANDBASE_API_URL || "https://api.sandbase.ai").replace(/\/$/, "")); const store = deps.store || new FileCredentialStore(); const log = deps.log || console.log; const detect = deps.detect || detectClient; const targets: Client[] = await resolveAutoSharedSlots(plannedAutoClients(detect).filter(client => client !== "openclaw") as Client[],log); const nativeSkillTargets = clients.filter(target => targets.includes(target) && !!skillsAgentRegistry[target] && detect(target).installed); // Native Skills are an independent local lifecycle: do not let their result diff --git a/test/a1-adapters.test.ts b/test/a1-adapters.test.ts index e38cdbe..c59034c 100644 --- a/test/a1-adapters.test.ts +++ b/test/a1-adapters.test.ts @@ -86,7 +86,7 @@ test("Windsurf ownership requires the exact SandBase endpoint", async () => { await mkdir(join(token, ".."), { recursive: true }); await writeFile(path, original); await writeFile(token, "third-party-secret", { mode: 0o600 }); - await assert.rejects(installA1("windsurf", "/unused", "sandbase-secret", "https://sandbase.ai/v1/mcp", env), /not SandBase-owned/); + await assert.rejects(installA1("windsurf", "/unused", "sandbase-secret", "https://api.sandbase.ai/v1/mcp", env), /not SandBase-owned/); assert.equal(await readFile(path, "utf8"), original); assert.equal(await readFile(token, "utf8"), "third-party-secret"); }); diff --git a/test/agent.test.ts b/test/agent.test.ts index 296ef4f..1406f81 100644 --- a/test/agent.test.ts +++ b/test/agent.test.ts @@ -51,28 +51,31 @@ test("credential precedence: env key, then stored key, then a connected client", await assert.rejects(resolveEndpoint({ client: "cursor" }, env), /No SandBase credential is stored for client "cursor"/); }); -test("the public API host gets the Cloudflare mirror as a fallback; overrides are honored", () => { - assert.deepEqual(endpointCandidates("https://api.sandbase.ai/v1/mcp", {}), ["https://api.sandbase.ai/v1/mcp", "https://www.sandbase.ai/v1/mcp"]); +test("the API is reached only at its own host; explicit overrides are honored", () => { + // The API lives only on api.sandbase.ai: no implicit cross-host fallback. + assert.deepEqual(endpointCandidates("https://api.sandbase.ai/v1/mcp", {}), ["https://api.sandbase.ai/v1/mcp"]); assert.deepEqual(endpointCandidates("http://127.0.0.1:9/v1/mcp", {}), ["http://127.0.0.1:9/v1/mcp"]); assert.deepEqual(endpointCandidates("https://api.sandbase.ai/v1/mcp", { SANDBASE_MCP_FALLBACK_URLS: "" }), ["https://api.sandbase.ai/v1/mcp"]); + // Users can still opt into extra mirrors explicitly. + assert.deepEqual(endpointCandidates("https://api.sandbase.ai/v1/mcp", { SANDBASE_MCP_FALLBACK_URLS: "https://sandbase.ai/v1/mcp" }), ["https://api.sandbase.ai/v1/mcp", "https://sandbase.ai/v1/mcp"]); }); -test("an unreachable primary falls back to the mirror and the preference persists across processes", async () => { - const env = await home(); await connected(env); +test("an explicitly configured mirror is used when the primary is unreachable, and the preference persists across processes", async () => { + const env = { ...(await home()), SANDBASE_MCP_FALLBACK_URLS: "https://sandbase.ai/v1/mcp" }; await connected(env); const { fetch, calls } = fakeFetch(call => call.url.includes("api.sandbase.ai") ? connectTimeout() : toolResult({ balance: "1.5", currency: "USD" })); const first = new SandBaseMCPClient(await resolveEndpoint({}, env), fetch, 1000, env); assert.equal((await first.tool("sandbase_account")).data.balance, "1.5"); - assert.deepEqual(calls.map(call => new URL(call.url).host), ["api.sandbase.ai", "www.sandbase.ai"]); + assert.deepEqual(calls.map(call => new URL(call.url).host), ["api.sandbase.ai", "sandbase.ai"]); const second = new SandBaseMCPClient(await resolveEndpoint({}, env), fetch, 1000, env); await second.tool("sandbase_account"); - assert.equal(new URL(calls.at(-1)!.url).host, "www.sandbase.ai"); assert.equal(calls.length, 3, "second process skips the dead primary"); + assert.equal(new URL(calls.at(-1)!.url).host, "sandbase.ai"); assert.equal(calls.length, 3, "second process skips the dead primary"); }); test("network and HTTP failures carry a code and a next step", async () => { const env = await home(); await connected(env, "codex", "https://api.sandbase.ai/v1/mcp"); const { fetch } = fakeFetch(() => connectTimeout()); const result = await cli("balance", [], env, fetch); - assert.equal(result.code, 1); assert.match(result.err, /Error \[NETWORK\]: Cannot reach SandBase \(api\.sandbase\.ai: connection timed out; www\.sandbase\.ai: connection timed out\)/); assert.match(result.err, /Next: .*sandbase doctor/); + assert.equal(result.code, 1); assert.match(result.err, /Error \[NETWORK\]: Cannot reach SandBase \(api\.sandbase\.ai: connection timed out\)/); assert.match(result.err, /Next: .*sandbase doctor/); assert.equal(httpError(402, '{"error":"API key spending limit exceeded"}', "api.sandbase.ai").code, "INSUFFICIENT_BALANCE"); assert.match(httpError(401, "", "api.sandbase.ai").hint ?? "", /connect/); const json = await cli("balance", ["-j"], env, fetch); @@ -164,11 +167,20 @@ test("keys add stores a 0600 key used before client records", async () => { assert.equal((await resolveEndpoint({}, env)).source, "client:codex"); }); -test("doctor probe reports the mirror and balance, or the exact failure", async () => { - const ok = fakeFetch(call => call.url.includes("api.sandbase.ai") ? connectTimeout() : call.method === "tools/list" ? { body: { result: { tools: [{ name: "sandbase_discover" }] } } } : call.method === "initialize" ? { body: { result: {} } } : toolResult({ balance: "3.2" })); +test("doctor probe reports the active host and balance, or the exact failure", async () => { + // Happy path: the API host answers directly, no cross-host fallback involved. + const ok = fakeFetch(call => call.method === "tools/list" ? { body: { result: { tools: [{ name: "sandbase_discover" }] } } } : call.method === "initialize" ? { body: { result: {} } } : toolResult({ balance: "3.2" })); const env = await home(); const good = await probeEndpoint("codex", "secret", "https://api.sandbase.ai/v1/mcp", env, ok.fetch, 1000); - assert.equal(good.ok, true); assert.match(good.line, /network=ok, endpoint=www\.sandbase\.ai/); assert.match(good.line, /primary=api\.sandbase\.ai \(connection timed out\)/); assert.match(good.line, /balance=\$3\.2/); + assert.equal(good.ok, true); assert.match(good.line, /network=ok, endpoint=api\.sandbase\.ai/); assert.match(good.line, /balance=\$3\.2/); assert.doesNotMatch(good.line, /fallback=/); + // With an explicitly configured mirror, a dead primary is reported and the probe switches hosts. + const mirrorEnv = { ...env, SANDBASE_MCP_FALLBACK_URLS: "https://sandbase.ai/v1/mcp" }; + const okMirror = fakeFetch(call => call.url.includes("api.sandbase.ai") ? connectTimeout() : call.method === "tools/list" ? { body: { result: { tools: [{ name: "sandbase_discover" }] } } } : call.method === "initialize" ? { body: { result: {} } } : toolResult({ balance: "3.2" })); + const viaMirror = await probeEndpoint("codex", "secret", "https://api.sandbase.ai/v1/mcp", mirrorEnv, okMirror.fetch, 1000); + assert.equal(viaMirror.ok, true); assert.match(viaMirror.line, /endpoint=sandbase\.ai/); assert.match(viaMirror.line, /primary=api\.sandbase\.ai \(connection timed out\)/); + // Unreachable with no mirror: a plain network failure on the one host. + const down = await probeEndpoint("codex", "secret", "https://api.sandbase.ai/v1/mcp", env, fakeFetch(() => connectTimeout()).fetch, 1000); + assert.equal(down.ok, false); assert.match(down.line, /network=failed, code=NETWORK/); assert.match(down.line, /api\.sandbase\.ai/); const denied = fakeFetch(() => ({ status: 401, body: '{"error":"invalid API key"}' })); const bad = await probeEndpoint("codex", "secret", "http://127.0.0.1:9/v1/mcp", env, denied.fetch, 1000); assert.equal(bad.ok, false); assert.match(bad.line, /network=failed, code=AUTH_FAILED/); assert.match(bad.line, /next_step=.*connect/); assert.doesNotMatch(bad.line, /secret/); @@ -198,38 +210,48 @@ test("bridge keeps the server usable offline and turns tool failures into visibl const [init, list, call] = await bridge(env, offline.fetch, [{ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2025-06-18" } }, { jsonrpc: "2.0", id: 2, method: "tools/list" }, { jsonrpc: "2.0", id: 3, method: "tools/call", params: { name: "sandbase_discover", arguments: { q: "x" } } }]); assert.equal(init!.result.protocolVersion, "2025-06-18"); assert.match(init!.result.instructions, /unreachable/); assert.ok(list!.result.tools.some((tool: { name: string }) => tool.name === "sandbase_discover")); - assert.equal(call!.result.isError, true); assert.match(call!.result.content[0].text, /unreachable from this machine \(api\.sandbase\.ai: connection timed out; www\.sandbase\.ai: connection timed out\).*sandbase doctor/); + assert.equal(call!.result.isError, true); assert.match(call!.result.content[0].text, /unreachable from this machine \(api\.sandbase\.ai: connection timed out\).*sandbase doctor/); + // The mixed/failover scenario needs a second host to switch to; there is no + // implicit fallback, so configure one explicitly for this session. The API + // ships only on api.sandbase.ai — this mirror is a test fixture. + const mirrorEnv = { ...env, SANDBASE_MCP_FALLBACK_URLS: "https://sandbase.ai/v1/mcp" }; const mixed = fakeFetch(call => call.url.includes("api.sandbase.ai") ? connectTimeout() : call.method === "tools/list" ? { body: { result: { tools: [{ name: "sandbase_discover", description: "live" }] } } } : call.method === "tools/call" && (call.params as { name: string }).name === "sandbase_account" ? { status: 402, body: '{"error":"API key spending limit exceeded"}' } : { body: { error: { code: -32603, message: "capability call failed" } } }); - const [listed, failed, broke] = await bridge(env, mixed.fetch, [{ jsonrpc: "2.0", id: 1, method: "tools/list" }, { jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "sandbase_run", arguments: {} } }, { jsonrpc: "2.0", id: 3, method: "tools/call", params: { name: "sandbase_account", arguments: {} } }]); + const [listed, failed, broke] = await bridge(mirrorEnv, mixed.fetch, [{ jsonrpc: "2.0", id: 1, method: "tools/list" }, { jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "sandbase_run", arguments: {} } }, { jsonrpc: "2.0", id: 3, method: "tools/call", params: { name: "sandbase_account", arguments: {} } }]); assert.equal(listed!.result.tools[0].description, "live"); assert.equal(failed!.result.isError, true); assert.match(failed!.result.content[0].text, /capability call failed\. The upstream provider failed\. Retry once/); assert.equal(broke!.result.isError, true); assert.match(broke!.result.content[0].text, /\(402\): API key spending limit exceeded.*console\/billing/); assert.ok(mixed.calls.filter(call => call.url.includes("api.sandbase.ai")).length <= 1, "after one fallback the session stays on the mirror"); - const cachedOffline = await bridge(env, offline.fetch, [{ jsonrpc: "2.0", id: 9, method: "tools/list" }]); + const cachedOffline = await bridge(mirrorEnv, offline.fetch, [{ jsonrpc: "2.0", id: 9, method: "tools/list" }]); assert.equal(cachedOffline[0]!.result.tools[0].description, "live", "offline tools/list serves the last live list"); }); function resetAfterSend(): Error { const error = new TypeError("fetch failed") as TypeError & { cause?: unknown }; error.cause = { code: "ECONNRESET" }; return error; } +// Failover is exercised through an explicitly configured mirror, since there +// is no implicit cross-host fallback anymore. The mirror host here is only a +// test fixture for the failover rules; the API itself ships only on +// api.sandbase.ai. +async function homeWithMirror(): Promise { return { ...(await home()), SANDBASE_MCP_FALLBACK_URLS: "https://sandbase.ai/v1/mcp" }; } + test("a paid run interrupted after sending is never replayed on the mirror", async () => { - const env = await home(); await connected(env); + const env = await homeWithMirror(); await connected(env); const { fetch, calls } = fakeFetch(call => call.url.includes("api.sandbase.ai") ? resetAfterSend() : toolResult({ prediction_id: "pred_dup", status: "completed", output: "x" })); const result = await cli("run", ["sandbase_paid", "-i", "{}", "-j"], env, fetch); assert.equal(result.code, 1); assert.equal(calls.filter(call => call.method === "tools/call").length, 1, "no second sandbase_run"); assert.match(JSON.parse(result.out).error.message, /may have started a run/); const reads = fakeFetch(call => call.url.includes("api.sandbase.ai") ? resetAfterSend() : toolResult({ balance: "2" })); - assert.equal((await cli("balance", [], await (async () => { const e = await home(); await connected(e); return e; })(), reads.fetch)).code, 0, "idempotent reads may fail over"); + assert.equal((await cli("balance", [], await (async () => { const e = await homeWithMirror(); await connected(e); return e; })(), reads.fetch)).code, 0, "idempotent reads may fail over"); }); test("HTTP 5xx on the primary fails over for idempotent calls only", async () => { - const env = await home(); await connected(env); + const env = await homeWithMirror(); await connected(env); const { fetch, calls } = fakeFetch(call => call.url.includes("api.sandbase.ai") ? { status: 502, body: "bad gateway" } : toolResult({ balance: "4" })); const ok = await cli("balance", [], env, fetch); assert.equal(ok.code, 0); assert.match(ok.out, /\$4/); assert.equal(calls.length, 2); - const env2 = await home(); await connected(env2); + const env2 = await homeWithMirror(); await connected(env2); const paid = fakeFetch(call => call.url.includes("api.sandbase.ai") ? { status: 502, body: "" } : toolResult({ prediction_id: "p", status: "completed" })); const failed = await cli("run", ["sandbase_paid", "-i", "{}"], env2, paid.fetch); assert.equal(failed.code, 1); assert.equal(paid.calls.length, 1); assert.match(failed.err, /\[UPSTREAM\].*HTTP 502/); diff --git a/test/package.test.ts b/test/package.test.ts index 4aab7be..a895e8a 100644 --- a/test/package.test.ts +++ b/test/package.test.ts @@ -42,7 +42,7 @@ test("MCP Registry metadata exposes the authenticated remote endpoint", async () assert.deepEqual(manifest.remotes, [ { type: "streamable-http", - url: "https://sandbase.ai/v1/mcp", + url: "https://api.sandbase.ai/v1/mcp", headers: [ { name: "Authorization",