From a9081d1b1a917e846c728a6a1c0a70b4770dde47 Mon Sep 17 00:00:00 2001 From: liyb Date: Sun, 11 Oct 2026 14:56:02 +0800 Subject: [PATCH] Serve all API traffic from api.sandbase.ai Point the authorization API, MCP bridge default, and published MCP registry remote at api.sandbase.ai, and drop the implicit cross-host fallback so the main-site and bare hosts are no longer API entry points. Ownership checks still recognize the legacy sandbase.ai and www hosts so configs written by older CLI versions keep working after upgrade. Users can still opt into extra mirrors via SANDBASE_MCP_FALLBACK_URLS. Web links (console, docs, billing) stay on www.sandbase.ai. --- .github/workflows/publish-mcp-registry.yml | 2 +- assets/mcp-bridge.mjs | 9 ++-- server.json | 2 +- src/a1-adapters.ts | 15 +++++- src/agent/client.ts | 10 ++-- src/commands.ts | 12 ++--- test/a1-adapters.test.ts | 2 +- test/agent.test.ts | 56 +++++++++++++++------- test/package.test.ts | 2 +- 9 files changed, 72 insertions(+), 38 deletions(-) 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",