diff --git a/README.md b/README.md index db805d8..fd6e87c 100644 --- a/README.md +++ b/README.md @@ -286,6 +286,7 @@ sandbase inspect sandbase_twitter_web_search_timeline # fields, price, rea sandbase run sandbase_twitter_web_search_timeline -i '{"keyword":"AI agents"}' sandbase run -i '{"prompt":"..."}' -w -o result.json # wait with backoff, save output to disk sandbase runs get -r -w -o result.json # resume a long run instead of re-running it +sandbase upload ./photo.png # local image/audio/video -> public URL for tools that take a URL sandbase runs list | sandbase balance ``` diff --git a/assets/mcp-bridge.mjs b/assets/mcp-bridge.mjs index 848bef7..bbcb2f5 100644 --- a/assets/mcp-bridge.mjs +++ b/assets/mcp-bridge.mjs @@ -19,6 +19,8 @@ import { pathToFileURL } from "node:url"; 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 BILLING_URL = "https://www.sandbase.ai/console/billing"; const RECONNECT = "npx -y @sandbaseai/cli@latest connect"; +const KEYS_URL = "https://www.sandbase.ai/console/keys"; +const SPENDING_LIMIT = `This SandBase key reached the spending limit set on it. Stop and tell the user: topping up does not lift it; they can raise or remove the limit at ${KEYS_URL}, then retry.`; // Tool calls get no bridge-side deadline (long synchronous tools must be able to // finish); control-plane methods are bounded so offline detection stays quick. const CONTROL_TIMEOUT_MS = 60_000; @@ -71,9 +73,11 @@ export function isIdempotentRequest(request) { } export function hintFor(message) { const text = String(message || "").toLowerCase(); + // Before auth: "API key spending limit exceeded" is neither a sign-in nor a balance problem. + if (/spending limit/.test(text)) return SPENDING_LIMIT; // Auth first: "API key not found" is a sign-in problem, not a misspelled tool. if (/unauthori[sz]ed|api key|credential|revoked|expired|authenticat|sign-in|signin/.test(text)) return `The SandBase sign-in is not valid. Ask the user to run \`${RECONNECT}\` in a terminal.`; - if (/insufficient (balance|credit|funds)|payment required|spending limit|top up/.test(text)) return `Tell the user their SandBase balance is insufficient; they can top up at ${BILLING_URL}.`; + if (/insufficient (balance|credit|funds)|payment required|top up/.test(text)) return `Tell the user their SandBase balance is insufficient; they can top up at ${BILLING_URL}.`; if (/rate.?limit|too many requests|429/.test(text)) return "Rate limited: wait 10-30 seconds, then retry once."; if (/run .*not found|task not found/.test(text)) return "Use the prediction_id/run_id returned by sandbase_run, or list recent runs with sandbase_runs. Agent Service runs also need service_id."; if (/invalid service request|unknown tool|tool(?: '[^']*')? (?:was )?not found|model not found|tool name is required/.test(text)) return "Check the exact tool name with sandbase_discover (names look like sandbase_twitter_web_search_timeline), then sandbase_inspect it."; @@ -86,7 +90,8 @@ function httpFailure(status, text, host) { try { const parsed = JSON.parse(text); const e = parsed?.error; detail = typeof e === "string" ? e : typeof e?.message === "string" ? e.message : typeof parsed?.message === "string" ? parsed.message : ""; } catch { /* non-JSON gateway body is never forwarded */ } const suffix = detail ? `: ${detail.slice(0, 300)}` : ""; if (status === 401) return `SandBase rejected the saved sign-in (401)${suffix}. Ask the user to run \`${RECONNECT}\` in a terminal to sign in again.`; - if (status === 402) return `SandBase balance is insufficient or a spending limit was reached (402)${suffix}. The user can top up at ${BILLING_URL}.`; + if (status === 402 && /spending limit/i.test(detail)) return `${SPENDING_LIMIT} (402${suffix})`; + if (status === 402) return `SandBase balance is insufficient (402)${suffix}. The user can top up at ${BILLING_URL}.`; if (status === 403) return `This SandBase credential is not allowed for this request (403)${suffix}. Ask the user to run \`${RECONNECT}\`.`; if (status === 429) return `SandBase rate limit reached (429)${suffix}. Wait 10-30 seconds, then retry once.`; if (status >= 500) return `SandBase returned HTTP ${status} from ${host}${suffix}. Temporary server error: retry in a minute; the user can run \`sandbase doctor\` if it persists.`; diff --git a/package-lock.json b/package-lock.json index 317e1ba..11b4e87 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@sandbaseai/cli", - "version": "0.3.4", + "version": "0.3.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@sandbaseai/cli", - "version": "0.3.4", + "version": "0.3.5", "license": "Apache-2.0", "dependencies": { "jsonc-parser": "3.3.1", diff --git a/package.json b/package.json index 0c45dd9..c606084 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@sandbaseai/cli", - "version": "0.3.4", + "version": "0.3.5", "mcpName": "io.github.sandbaseai/cli", "description": "Give 25 AI clients—Claude Code, Codex, Cursor, Hermes, OpenClaw and more—2,000+ models and APIs through one MCP server and an agent-friendly CLI", "type": "module", diff --git a/server.json b/server.json index 0c23b34..4f8b250 100644 --- a/server.json +++ b/server.json @@ -3,7 +3,7 @@ "name": "io.github.sandbaseai/cli", "title": "SandBase", "description": "Connect MCP clients to 2,000+ AI models without managing provider API keys.", - "version": "0.3.4", + "version": "0.3.5", "repository": { "url": "https://github.com/sandbaseai/cli", "source": "github" @@ -26,7 +26,7 @@ { "registryType": "npm", "identifier": "@sandbaseai/cli", - "version": "0.3.4", + "version": "0.3.5", "packageArguments": [ { "type": "positional", diff --git a/skills/sandbase/SKILL.md b/skills/sandbase/SKILL.md index 800628a..f901fb3 100644 --- a/skills/sandbase/SKILL.md +++ b/skills/sandbase/SKILL.md @@ -1,6 +1,6 @@ --- name: sandbase -version: 0.3.4 +version: 0.3.5 description: >- Discover better ways to complete tasks. Proactively search SandBase (the sandbase_discover MCP tool, or `sandbase discover` in a shell) before writing a @@ -84,6 +84,19 @@ Discovery, inspection, run status, and balance are **free**; only `sandbase_run` **Agent Services** (`svc_…` names from `type: "service"`): pass `arguments: {"idempotency_key": "", "input": "..."}`; reuse the same key and input on retries. Poll with `sandbase_run_get({"run_id": ..., "service_id": "svc_…"})`; `succeeded`, `failed`, and `cancelled` are terminal. +### Run statuses + +| Status | Meaning | Do this | +|---|---|---| +| `pending` | Accepted, waiting to start | Poll every 5–10 s | +| `running` | Executing (on the first `sandbase_run` reply this also covers a job whose files are still being copied) | Poll every 5–10 s | +| `completed` / `succeeded` | Done; `output` holds the result | Use or save the output. If `output` is missing, read `hints`: "still being transferred" means poll again in a few seconds; "no stored output" means stop polling | +| `failed` | Ended with an error; read `error` | Fix the input (inspect) and retry once, or pick another tool | +| `timeout` | Hit the provider's time limit | Retry once with a smaller job, or pick another tool | +| `cancelled` | Stopped on request (Agent Services only) | Nothing to poll | + +Any other value means still in progress: keep polling. Statuses are lowercase. Most runs finish in 1–120 seconds; video can take several minutes. + --- ## 3. Transport B — CLI (fallback) @@ -92,7 +105,14 @@ Discovery, inspection, run status, and balance are **free**; only `sandbase_run` sandbase --version || npx -y @sandbaseai/cli@latest --version ``` -If `sandbase` is not installed, prefix every command with `npx -y @sandbaseai/cli@latest` (or install it: `npm install -g @sandbaseai/cli@latest`). If the CLI prints "Update available", run `npx -y @sandbaseai/cli@latest setup` — it refreshes the CLI's MCP bridge and this Skill. +If `sandbase` is not installed, prefix every command with `npx -y @sandbaseai/cli@latest` (or install it: `npm install -g @sandbaseai/cli@latest`). + +**Keep the CLI and this Skill on the same version.** Run `npx -y @sandbaseai/cli@latest setup` (it refreshes the CLI's MCP bridge and this Skill) when any of these is true: + +- The CLI prints "Update available". +- `sandbase --version` does not match the `version` at the top of this file. + +Always move both to the latest; never downgrade one to match the other. If they still differ after `setup`, save the latest copy from https://www.sandbase.ai/SKILL.md over this file. **Authentication** is automatic once the user has run `connect` (it reuses that browser sign-in). Otherwise the CLI reports `AUTH_REQUIRED` with the exact fix; an API key also works via `SANDBASE_API_KEY` or `sandbase keys add -k `. Never ask the user to paste a key into the chat. @@ -114,6 +134,50 @@ If `sandbase` is not installed, prefix every command with `npx -y @sandbaseai/cl - If an error mentions a `run_id`, the run already started: resume it with `sandbase runs get -r -w` — never re-run (that bills again). - To store an API key without exposing it: `printf %s "$KEY" | sandbase keys add -k -`. +### Local files → URL + +Many tools (image-to-video, voice cloning, transcription, image editing) take a **URL**, not a file. When the user's file is only on their machine, upload it first (free; needs a shell): + +```sh +sandbase upload ./photo.png +# -> URL: https://…/uploads/2026/10/….png +``` + +Pass that URL in the tool's URL field (`image_url`, `audio_url`, `video_url`, …). Accepted: images (jpg, png, webp, gif) up to 20 MB, audio (mp3, m4a, wav, ogg, aac, flac) up to 50 MB, video (mp4, mov, webm) up to 100 MB. **Anyone with the URL can read the file**, so ask before uploading anything private. If the file is already online, pass its URL directly instead. + +### Example flows + +Compare two platforms (one discover and one run per source, small limits, results to disk): + +```sh +sandbase discover -q "twitter posts" +sandbase discover -q "reddit posts" +sandbase inspect # read inputSchema, pass one search term +sandbase inspect +sandbase run -i '{"keyword":"AI agents","limit":10}' -w -o twitter.json +sandbase run -i '{"query":"AI agents","limit":10}' -w -o reddit.json +# then read both files and compare +``` + +Turn a local photo into a video (upload, then an async run you poll): + +```sh +sandbase upload ./photo.png # -> URL: https://… +sandbase discover -q "image to video" +sandbase inspect +sandbase run -i '{"image_url":"https://…","prompt":"slow pan"}' +# -> Run: Status: running +sandbase runs get -r -w -o video.json # waits, then saves the output +``` + +Large or reusable input: write the JSON to a file and pass it with `-f`: + +```sh +sandbase run -f params.json -w -o result.json +``` + +Field names above are placeholders; always use the ones from `inspect`. + --- ## 4. When to use SandBase — and when not @@ -149,6 +213,7 @@ Do not use SandBase for purely local work (editing files, reasoning over code al - Many data endpoints are billed **per result** and limits often apply **per query**: three search terms with `limit: 10` can return 30 results. Pass one search term per call and start with small limits (5–10). - Check `sandbase_account` before a batch of paid calls, and report cost when the user cares about budget (`cost` appears on `sandbase_run_get` and `sandbase_runs`). - Never re-run a slow tool just because it is still running; poll it. +- A key can carry a **spending limit** set in the Dashboard. When it is reached, calls fail with "spending limit" until the user raises or removes it; topping up does not help. --- @@ -160,9 +225,11 @@ Tool failures come back as error results whose text names the cause **and the ne |---|---| | "unreachable" / `NETWORK` | Stop retrying. Tell the user to check network/proxy/VPN and run `sandbase doctor`. | | "sign-in expired" / "rejected the saved sign-in" / 401 / `AUTH_FAILED` | Ask the user to run `npx -y @sandbaseai/cli@latest connect`. | -| "insufficient" / 402 | Tell the user to top up at https://www.sandbase.ai/console/billing. | +| "spending limit" (402 / `SPENDING_LIMIT`) | Stop. Tell the user this key reached its spending limit; they can raise or remove it at https://www.sandbase.ai/console/keys. Topping up does not lift it. Do not retry until they do. | +| "insufficient" balance (402 / `INSUFFICIENT_BALANCE`) | Tell the user to top up at https://www.sandbase.ai/console/billing. | | "not found" / "unknown tool" | Re-run `sandbase_discover`; copy `tool_name` exactly. | | "invalid params" / schema | Re-run `sandbase_inspect`; pass exactly its `inputSchema` fields. | +| A tool rejects a local path where it wants a URL | Upload the file with `sandbase upload ` and pass the URL (§3). | | "upstream" / "provider" / "capability call failed" | Retry once, then pick another tool from discover. | | 429 / rate limit | Wait 10–30 seconds, retry once. | @@ -181,11 +248,13 @@ Responses may include `hints` (and `warnings`): suggested next steps, caveats, o 3. **Prefer MCP when connected**; use the CLI when MCP is missing or broken, or to write large outputs to disk. 4. **Always inspect before running.** Never guess parameters; `inputSchema` is the source of truth. 5. **Keep discover queries short** and decompose multi-source tasks. -6. **Poll, don't re-run.** Slow tools return a run id; poll every 5–10 seconds. +6. **Poll, don't re-run.** Slow tools return a run id; poll every 5–10 seconds. Save large or file results with `-o `. 7. **Start with conservative limits** (5–10 results). 8. **Report cost when relevant**, and check the balance before batches. -9. **Read error text and hints, and act on them.** They name the fix. -10. **Tool schemas and `sandbase --help` are authoritative** if they disagree with this document. +9. **Read error text and hints, and act on them.** They name the fix. A spending limit or empty balance is the user's call: stop and tell them. +10. **Keep the CLI and this Skill on the same, latest version** (§3). +11. **Ask before uploading private files**; uploaded URLs are publicly readable. +12. **Tool schemas and `sandbase --help` are authoritative** if they disagree with this document. Before sending sensitive or regulated data, review the [SandBase Privacy Policy](https://www.sandbase.ai/privacy) and [Terms of Service](https://www.sandbase.ai/terms), plus the selected upstream provider's policies. Send only the minimum data needed. diff --git a/skills/sandbase/skill.json b/skills/sandbase/skill.json index 960bf91..79a0f81 100644 --- a/skills/sandbase/skill.json +++ b/skills/sandbase/skill.json @@ -1,6 +1,6 @@ { "name": "sandbase", - "version": "0.3.4", + "version": "0.3.5", "description": "Discover and run 2,000+ AI models and APIs (media generation, social data, search, scraping) through one MCP interface or CLI, paid per call from one balance.", "author": "SandBase AI", "license": "Apache-2.0", diff --git a/src/agent/client.ts b/src/agent/client.ts index 6a3cedf..79a165d 100644 --- a/src/agent/client.ts +++ b/src/agent/client.ts @@ -12,7 +12,7 @@ export const DEFAULT_MCP_URL = "https://api.sandbase.ai/v1/mcp"; export const UNREACHABLE_HINT = "Check this machine's network, proxy, or VPN, then run `sandbase doctor` and retry."; export type ErrorCode = - | "AUTH_REQUIRED" | "AUTH_FAILED" | "INSUFFICIENT_BALANCE" | "FORBIDDEN" | "NOT_FOUND" | "RATE_LIMITED" + | "AUTH_REQUIRED" | "AUTH_FAILED" | "INSUFFICIENT_BALANCE" | "SPENDING_LIMIT" | "FORBIDDEN" | "NOT_FOUND" | "RATE_LIMITED" | "INVALID_INPUT" | "NETWORK" | "UPSTREAM" | "RUN_FAILED" | "INVALID_RESPONSE" | "TOOL_ERROR" | "USAGE"; export class SandBaseError extends Error { @@ -23,6 +23,7 @@ export function webUrl(env = process.env): string { return (env.SANDBASE_WEB_URL export function billingUrl(env = process.env): string { return `${webUrl(env)}/console/billing`; } export function keysUrl(env = process.env): string { return `${webUrl(env)}/console/keys`; } export const CONNECT_COMMAND = "npx -y @sandbaseai/cli@latest connect"; +export function spendingLimitHint(env = process.env): string { return `Stop and tell the user: this key's spending limit is reached. Topping up does not lift it; they can raise or remove the limit at ${keysUrl(env)}, then retry.`; } export function validMcpUrl(value: unknown): string | undefined { if (typeof value !== "string") return undefined; @@ -100,9 +101,12 @@ export function describeNetworkFailure(error: unknown): string { /** Map a server/tool error message to the most useful next step. */ export function hintForMessage(message: string, env = process.env): string | undefined { const text = message.toLowerCase(); + // "API key spending limit exceeded" mentions the key but is not a sign-in problem, and + // topping up does not lift it, so it is checked before both auth and balance. + if (/spending limit/.test(text)) return spendingLimitHint(env); // Auth first: "API key not found" is a sign-in problem, not a misspelled tool. if (/unauthori[sz]ed|api key|credential|revoked|expired|authenticat|sign-in|signin/.test(text)) return `Sign in again with \`${CONNECT_COMMAND}\`, or check SANDBASE_API_KEY.`; - if (/insufficient (balance|credit|funds)|payment required|spending limit|top up/.test(text)) return `Top up at ${billingUrl(env)}, then retry.`; + if (/insufficient (balance|credit|funds)|payment required|top up/.test(text)) return `Top up at ${billingUrl(env)}, then retry.`; if (/rate.?limit|too many requests|429/.test(text)) return "Rate limited. Wait 10-30 seconds, then retry once."; if (/run .*not found|task not found/.test(text)) return "Use the run_id printed by `sandbase run` (or find it with `sandbase runs list`). Agent Service runs also need --service ."; if (/invalid service request|unknown tool|tool(?: '[^']*')? (?:was )?not found|model not found|tool name is required/.test(text)) return "Check the exact name with `sandbase discover -q \"\"` (MCP: sandbase_discover). Names look like sandbase_twitter_web_search_timeline."; @@ -116,7 +120,8 @@ export function httpError(status: number, body: string, host: string, env = proc try { const parsed = JSON.parse(body) as { error?: unknown; message?: unknown }; const e = parsed.error; message = typeof e === "string" ? e : e && typeof e === "object" && typeof (e as { message?: unknown }).message === "string" ? (e as { message: string }).message : typeof parsed.message === "string" ? parsed.message : ""; } catch { /* gateway HTML or empty body: the status carries the meaning */ } const detail = message ? `: ${message}` : ""; if (status === 401) return new SandBaseError("AUTH_FAILED", `SandBase rejected the saved sign-in or API key (401)${detail}.`, `Sign in again with \`${CONNECT_COMMAND}\`, or create a new key at ${keysUrl(env)}.`, status); - if (status === 402) return new SandBaseError("INSUFFICIENT_BALANCE", `Insufficient balance or spending limit reached (402)${detail}.`, `Top up at ${billingUrl(env)}, then retry.`, status); + if (status === 402 && /spending limit/i.test(message)) return new SandBaseError("SPENDING_LIMIT", `This credential reached the spending limit set on it in the SandBase Dashboard (402)${detail}.`, spendingLimitHint(env), status); + if (status === 402) return new SandBaseError("INSUFFICIENT_BALANCE", `Insufficient balance (402)${detail}.`, `Top up at ${billingUrl(env)}, then retry.`, status); if (status === 403) return new SandBaseError("FORBIDDEN", `This credential is not allowed to call ${host} (403)${detail}.`, `Use a credential issued by \`${CONNECT_COMMAND}\` or a full API key from ${keysUrl(env)}.`, status); if (status === 404) return new SandBaseError("NOT_FOUND", `MCP endpoint not found on ${host} (404)${detail}.`, "Check SANDBASE_MCP_URL; it must end in /v1/mcp.", status); if (status === 429) return new SandBaseError("RATE_LIMITED", `Rate limited (429)${detail}.`, "Wait 10-30 seconds, then retry once.", status); @@ -138,7 +143,7 @@ function parseRpcBody(text: string, contentType: string, id: number): RpcRespons } export interface ToolOutput { data: Record; text: string } -export type FetchLike = (url: string, init: { method: string; headers: Record; body: string; signal: AbortSignal }) => Promise<{ ok: boolean; status: number; headers: { get(name: string): string | null }; text(): Promise }>; +export type FetchLike = (url: string, init: { method: string; headers: Record; body: string | FormData; signal: AbortSignal }) => Promise<{ ok: boolean; status: number; headers: { get(name: string): string | null }; text(): Promise }>; export class SandBaseMCPClient { private session: string | undefined; @@ -185,7 +190,7 @@ export class SandBaseMCPClient { } } -const serverErrorCodes: Record = { tool_not_found: "NOT_FOUND", run_not_found: "NOT_FOUND", invalid_arguments: "INVALID_INPUT", insufficient_balance: "INSUFFICIENT_BALANCE", rate_limited: "RATE_LIMITED", unauthorized: "AUTH_FAILED", run_failed: "RUN_FAILED", upstream_error: "UPSTREAM", internal_error: "UPSTREAM" }; +const serverErrorCodes: Record = { tool_not_found: "NOT_FOUND", run_not_found: "NOT_FOUND", invalid_arguments: "INVALID_INPUT", insufficient_balance: "INSUFFICIENT_BALANCE", spending_limit: "SPENDING_LIMIT", rate_limited: "RATE_LIMITED", unauthorized: "AUTH_FAILED", run_failed: "RUN_FAILED", upstream_error: "UPSTREAM", internal_error: "UPSTREAM" }; export function toolOutput(result: unknown, env = process.env): ToolOutput { const value = (result || {}) as { structuredContent?: unknown; content?: Array<{ type?: string; text?: string }>; isError?: boolean }; diff --git a/src/agent/commands.ts b/src/agent/commands.ts index c3a7a6b..8e05402 100644 --- a/src/agent/commands.ts +++ b/src/agent/commands.ts @@ -3,14 +3,14 @@ // agent without MCP (or with a broken MCP session) still has a working path, // and so large results can go straight to disk with -o instead of the context. import { constants as fsConstants } from "node:fs"; -import { access, readFile, rm, writeFile } from "node:fs/promises"; -import { dirname, resolve as resolvePath } from "node:path"; +import { access, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { basename, dirname, extname, resolve as resolvePath } from "node:path"; import { parseArgs } from "node:util"; import { atomicWrite } from "../fs-safe.js"; -import { CONNECT_COMMAND, keyFilePath, keysUrl, readStoredKey, resolveEndpoint, SandBaseError, SandBaseMCPClient, toolOutput, validMcpUrl, type FetchLike } from "./client.js"; +import { CONNECT_COMMAND, describeNetworkFailure, httpError, keyFilePath, keysUrl, readStoredKey, resolveEndpoint, SandBaseError, SandBaseMCPClient, toolOutput, validMcpUrl, type FetchLike } from "./client.js"; import { updateNotice } from "./update.js"; -export const agentCommands = ["discover", "inspect", "run", "runs", "balance", "keys"] as const; +export const agentCommands = ["discover", "inspect", "run", "runs", "upload", "balance", "keys"] as const; export type AgentCommand = typeof agentCommands[number]; export function isAgentCommand(value: string | undefined): value is AgentCommand { return agentCommands.includes(value as AgentCommand); } @@ -22,6 +22,7 @@ export const agentUsage = `SandBase agent commands (same capabilities as the san sandbase run [-i '' | -f input.json] [-w] [--timeout 300] [-o result.json] [-j] sandbase runs get -r [--service ] [-w] [--timeout 300] [-o result.json] [-j] sandbase runs list [-l 10] [--model ] [--service ] [-j] + sandbase upload [-j] (local image/audio/video → public URL for tools that take a URL) sandbase balance [-j] sandbase keys add -k - (reads the key from stdin) | keys list | keys remove Global: --client picks a connected client's credential; SANDBASE_API_KEY overrides all; -j prints JSON.`; @@ -338,6 +339,48 @@ async function runs(argv: string[], out: Output, env: NodeJS.ProcessEnv, deps: R throw new SandBaseError("USAGE", "Use `sandbase runs get -r ` or `sandbase runs list`."); } +// What /v1/upload accepts, by extension. Server limits are 20/50/500 MB; video is +// capped at 100 MB here because the API sits behind Cloudflare's request-body limit. +const MB = 1024 * 1024; +const uploadTypes: Record = { + ".jpg": { mime: "image/jpeg", kind: "image", max: 20 * MB }, ".jpeg": { mime: "image/jpeg", kind: "image", max: 20 * MB }, ".png": { mime: "image/png", kind: "image", max: 20 * MB }, ".webp": { mime: "image/webp", kind: "image", max: 20 * MB }, ".gif": { mime: "image/gif", kind: "image", max: 20 * MB }, + ".mp3": { mime: "audio/mpeg", kind: "audio", max: 50 * MB }, ".m4a": { mime: "audio/mp4", kind: "audio", max: 50 * MB }, ".wav": { mime: "audio/wav", kind: "audio", max: 50 * MB }, ".ogg": { mime: "audio/ogg", kind: "audio", max: 50 * MB }, ".aac": { mime: "audio/aac", kind: "audio", max: 50 * MB }, ".flac": { mime: "audio/flac", kind: "audio", max: 50 * MB }, + ".mp4": { mime: "video/mp4", kind: "video", max: 100 * MB }, ".mov": { mime: "video/quicktime", kind: "video", max: 100 * MB }, ".webm": { mime: "video/webm", kind: "video", max: 100 * MB }, +}; +export function uploadUrlFor(mcpUrl: string): string { const url = new URL(mcpUrl); url.pathname = url.pathname.replace(/\/mcp$/, "/upload"); url.search = ""; return url.href; } + +async function upload(argv: string[], out: Output, env: NodeJS.ProcessEnv, fetchImpl?: FetchLike): Promise { + const { values, positionals } = parse(argv, { file: { type: "string", short: "f" } }); + const path = (values.file ?? positionals[0] ?? "").trim(); + if (!path) throw new SandBaseError("USAGE", "Pass the local file to upload.", "Example: sandbase upload ./photo.png"); + const type = uploadTypes[extname(path).toLowerCase()]; + if (!type) throw new SandBaseError("USAGE", `Cannot upload ${basename(path)}: only images (jpg, png, webp, gif), audio (mp3, m4a, wav, ogg, aac, flac) and video (mp4, mov, webm) are accepted.`); + let size: number; + try { const info = await stat(path); if (!info.isFile()) throw new Error("not a file"); size = info.size; } + catch { throw new SandBaseError("USAGE", `Cannot read ${path}: it does not exist or is not a file.`); } + if (size > type.max) throw new SandBaseError("USAGE", `${basename(path)} is ${Math.ceil(size / MB)} MB; the ${type.kind} limit is ${type.max / MB} MB.`, type.kind === "video" ? "Trim or compress the video, or pass a URL where it is already hosted." : "Compress the file, or pass a URL where it is already hosted."); + const endpoint = await resolveEndpoint(values.client ? { client: values.client } : {}, env); + const target = uploadUrlFor(endpoint.url); + const host = new URL(target).host; + const form = new FormData(); + form.append("file", new Blob([await readFile(path)], { type: type.mime }), basename(path)); + let response; + try { + response = await (fetchImpl ?? (globalThis.fetch as unknown as FetchLike))(target, { method: "POST", headers: { authorization: `Bearer ${endpoint.credential}`, "x-sandbase-client": "cli" }, body: form, signal: AbortSignal.timeout(600_000) }); + } catch (error) { throw new SandBaseError("NETWORK", `Upload to ${host} failed (${describeNetworkFailure(error)}).`, "Check the network, then retry; nothing is billed for uploads."); } + const text = await response.text(); + if (response.status === 403 && /insufficient_scope/.test(text)) throw new SandBaseError("FORBIDDEN", `This sign-in cannot upload files on ${host} yet (403).`, `Ask the user for a full API key from ${keysUrl(env)} and run the upload with SANDBASE_API_KEY set (never paste the key into chat).`, 403); + if (response.status === 404) throw new SandBaseError("NOT_FOUND", `${host} does not accept file uploads (404).`, "Pass a URL where the file is already hosted instead.", 404); + if (!response.ok) throw httpError(response.status, text, host, env); + let data: Record; + try { data = JSON.parse(text) as Record; } catch { data = {}; } + const inner = (data.data && typeof data.data === "object" ? data.data : data) as Record; + if (typeof inner.url !== "string" || !inner.url) throw new SandBaseError("INVALID_RESPONSE", `Expected an uploaded file URL from ${host}.`, "Retry once; run `sandbase doctor` if it persists."); + if (out.json) { out.data(inner); return 0; } + out.print([`Uploaded ${basename(path)} (${inner.type ?? type.mime}, ${Math.max(1, Math.round(size / 1024))} KB)`, `URL: ${inner.url}`, ...(inner.duration_seconds ? [`Duration: ${trimNumber(inner.duration_seconds)}s`] : []), "", "Pass this URL to the tool's image/audio/video URL field. Anyone with the URL can read the file."].join("\n")); + return 0; +} + async function balance(argv: string[], out: Output, env: NodeJS.ProcessEnv, fetchImpl?: FetchLike): Promise { const { values } = parse(argv, {}); const api = await client(values, env, fetchImpl); @@ -405,6 +448,7 @@ export async function runAgentCommand(command: AgentCommand, argv: string[], dep : command === "inspect" ? await inspect(argv, out, env, deps.fetchImpl) : command === "run" ? await run(argv, out, env, timing, deps.fetchImpl) : command === "runs" ? await runs(argv, out, env, timing, deps.fetchImpl) + : command === "upload" ? await upload(argv, out, env, deps.fetchImpl) : command === "balance" ? await balance(argv, out, env, deps.fetchImpl) : await keys(argv, out, env, deps); if (!json) await notice(); diff --git a/test/agent.test.ts b/test/agent.test.ts index 9e96192..dd84509 100644 --- a/test/agent.test.ts +++ b/test/agent.test.ts @@ -6,7 +6,7 @@ import { PassThrough } from "node:stream"; import { pathToFileURL } from "node:url"; import test from "node:test"; import { httpError, resolveEndpoint, SandBaseError, SandBaseMCPClient, type FetchLike } from "../src/agent/client.js"; -import { formatPrice, resolveSchema, runAgentCommand } from "../src/agent/commands.js"; +import { formatPrice, resolveSchema, runAgentCommand, uploadUrlFor } from "../src/agent/commands.js"; import { probeEndpoint } from "../src/agent/probe.js"; import { compareVersions, updateNotice } from "../src/agent/update.js"; @@ -16,7 +16,7 @@ function connectTimeout(): Error { const error = new TypeError("fetch failed") a function fakeFetch(handler: (call: Call) => Reply | Error): { fetch: FetchLike; calls: Call[] } { const calls: Call[] = []; const fetch: FetchLike = async (url, init) => { - const request = JSON.parse(init.body) as { id: number; method: string; params?: Record }; + const request = JSON.parse(String(init.body)) as { id: number; method: string; params?: Record }; const call = { url, method: request.method, params: request.params, authorization: init.headers.authorization }; calls.push(call); const reply = handler(call); @@ -71,7 +71,9 @@ test("network and HTTP failures carry a code and a next step", async () => { 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 at api\.sandbase\.ai \(connection timed out\)/); assert.match(result.err, /Next: .*sandbase doctor/); assert.doesNotMatch(result.err, /FALLBACK/); - assert.equal(httpError(402, '{"error":"API key spending limit exceeded"}', "api.sandbase.ai").code, "INSUFFICIENT_BALANCE"); + const limit = httpError(402, '{"error":"API key spending limit exceeded"}', "api.sandbase.ai"); + assert.equal(limit.code, "SPENDING_LIMIT"); assert.match(limit.hint ?? "", /console\/keys/); assert.doesNotMatch(limit.hint ?? "", /connect|Top up at/); + assert.equal(httpError(402, '{"error":"insufficient balance"}', "api.sandbase.ai").code, "INSUFFICIENT_BALANCE"); assert.match(httpError(401, "", "api.sandbase.ai").hint ?? "", /connect/); const json = await cli("balance", ["-j"], env, fetch); assert.equal(JSON.parse(json.out).error.code, "NETWORK"); assert.doesNotMatch(json.out + json.err, /test-credential-value/); @@ -223,7 +225,7 @@ test("bridge keeps the server usable offline and turns tool failures into visibl 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: {} } }]); 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.equal(broke!.result.isError, true); assert.match(broke!.result.content[0].text, /spending limit.*console\/keys.*\(402: API key spending limit exceeded\)/); const cachedOffline = await bridge(env, 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"); @@ -329,7 +331,52 @@ test("setup refreshes the bridge and SandBase-owned Skills but leaves edited Ski const lines: string[] = []; assert.equal(await setup(new FileCredentialStore(env.SANDBASE_HOME!), line => void lines.push(line)), true); assert.match(lines.join("\n"), /bridge: status=updated/); assert.match(lines.join("\n"), /claude-code: skill=updated/); assert.match(lines.join("\n"), /kiro: skill=modified/); - assert.match(await readFile(join(claudeDir, "SKILL.md"), "utf8"), /version: 0\.3\.4/); + assert.match(await readFile(join(claudeDir, "SKILL.md"), "utf8"), /version: 0\.3\.5/); assert.match(await readFile(join(kiroDir, "SKILL.md"), "utf8"), /user edit/); } finally { for (const [key, value] of Object.entries(previous)) { if (value === undefined) delete process.env[key]; else process.env[key] = value; } } }); + +test("upload posts the file as multipart to /v1/upload with the connected credential and prints the URL", async () => { + const env = await home(); await connected(env); + const file = join(env.HOME!, "photo.png"); await writeFile(file, Buffer.from([0x89, 0x50, 0x4e, 0x47])); + const seen: Array<{ url: string; authorization: string | undefined; body: unknown }> = []; + const fetch: FetchLike = async (url, init) => { seen.push({ url, authorization: init.headers.authorization, body: init.body }); return { ok: true, status: 200, headers: { get: () => "application/json" }, text: async () => JSON.stringify({ url: "https://cdn.example/uploads/x.png", type: "image/png", size: 4 }) }; }; + const text = await cli("upload", [file], env, fetch); + assert.equal(text.code, 0); assert.match(text.out, /URL: https:\/\/cdn\.example\/uploads\/x\.png/); assert.match(text.out, /Anyone with the URL/); + assert.equal(seen[0]!.url, "https://api.sandbase.ai/v1/upload"); assert.equal(seen[0]!.authorization, "Bearer test-credential-value"); + const form = seen[0]!.body as FormData; const part = form.get("file") as File; + assert.equal(part.name, "photo.png"); assert.equal(part.type, "image/png"); assert.equal(part.size, 4); + const json = await cli("upload", [file, "-j"], env, fetch); + assert.equal(JSON.parse(json.out).url, "https://cdn.example/uploads/x.png"); + assert.equal(uploadUrlFor("https://www.sandbase.ai/v1/mcp"), "https://www.sandbase.ai/v1/upload"); +}); +test("upload rejects unsupported, missing and oversized files before any request, and explains a scope 403", async () => { + const env = await home(); await connected(env); + let calls = 0; + const denied: FetchLike = async () => { calls++; return { ok: false, status: 403, headers: { get: () => "application/json" }, text: async () => '{"error":{"code":"insufficient_scope","message":"API key scope does not allow this request"}}' }; }; + const doc = join(env.HOME!, "notes.pdf"); await writeFile(doc, "x"); + assert.equal((await cli("upload", [doc], env, denied)).code, 2); + assert.equal((await cli("upload", [join(env.HOME!, "missing.png")], env, denied)).code, 2); + assert.equal((await cli("upload", [], env, denied)).code, 2); + const big = join(env.HOME!, "big.gif"); await writeFile(big, Buffer.alloc(20 * 1024 * 1024 + 1)); + const tooBig = await cli("upload", [big], env, denied); + assert.equal(tooBig.code, 2); assert.match(tooBig.err, /limit is 20 MB/); + assert.equal(calls, 0); + const png = join(env.HOME!, "a.png"); await writeFile(png, "x"); + const scope = await cli("upload", [png], env, denied); + assert.equal(scope.code, 1); assert.match(scope.err, /FORBIDDEN/); assert.match(scope.err, /SANDBASE_API_KEY/); assert.match(scope.err, /never paste/); +}); + +test("upload follows SANDBASE_MCP_URL, maps 402/404, and rejects a response without a URL", async () => { + const env: NodeJS.ProcessEnv = { ...(await home()), SANDBASE_MCP_URL: "https://www.example.test/v1/mcp" }; await connected(env); + const png = join(env.HOME!, "a.png"); await writeFile(png, "x"); + const urls: string[] = []; + const reply = (status: number, body: string): FetchLike => async url => { urls.push(url); return { ok: status < 300, status, headers: { get: () => "application/json" }, text: async () => body }; }; + const limit = await cli("upload", [png, "-j"], env, reply(402, '{"error":"API key spending limit exceeded"}')); + assert.equal(limit.code, 1); assert.equal(JSON.parse(limit.out).error.code, "SPENDING_LIMIT"); + assert.equal(urls[0], "https://www.example.test/v1/upload"); + const missing = await cli("upload", [png, "-j"], env, reply(404, "not found")); + assert.equal(JSON.parse(missing.out).error.code, "NOT_FOUND"); assert.doesNotMatch(missing.out, /v1\/mcp/); + const empty = await cli("upload", [png, "-j"], env, reply(200, "{}")); + assert.equal(JSON.parse(empty.out).error.code, "INVALID_RESPONSE"); +});