defluff: strip em-dashes + shorten docstrings across backend (cosmetic only, no logic change)

This commit is contained in:
ciregenz
2026-05-20 05:25:23 -07:00
parent c138615083
commit 5b0c6e1df3
32 changed files with 353 additions and 1022 deletions
+14 -71
View File
@@ -1,34 +1,6 @@
// Node-runtime patch loaded via `node --require <this>` before 9router boots.
//
// Why this exists
// ---------------
// OpenAI's GPT-5 family (gpt-5.4, gpt-5.4-mini, gpt-5.5, gpt-5.3-codex, …)
// rejects the legacy `max_tokens` parameter with HTTP 400, requiring
// `max_completion_tokens`. 9router (every released version, including 0.4.20)
// blindly forwards `max_tokens` in its Anthropic→OpenAI translator. We can't
// fix 9router from outside (env vars are ignored, baseUrl on the openai
// provider is hardcoded, prefix routing falls back). Instead we intercept
// the HTTPS write at the Node syscall layer — the actual boundary OpenAI
// sees — and rename the field on the way out.
//
// Safety contract
// ---------------
// • Scope: only requests whose hostname is `api.openai.com`. Every other
// outbound HTTP/HTTPS call passes through unmodified.
// • Model gate: only requests whose body parses as JSON with
// `model.startsWith("gpt-5")` (after stripping common prefixes 9router
// adds). GPT-4 / Claude / etc. unaffected.
// • Failure mode: every step is wrapped in try/catch and falls back to the
// unmodified original on any error. Worst case is "request behaves
// exactly as it would without this patch" — never worse than baseline.
// • Idempotency: the patch self-flags so re-loading via multiple --require
// doesn't double-wrap.
//
// Verification
// ------------
// Set OPENSWARM_DEBUG_GPT5_PATCH=1 in the env to log "[openswarm] 9router-
// gpt5-patch installed" on stderr and "rewrote max_tokens → max_completion_tokens"
// on each rewrite.
// Rewrites `max_tokens` to `max_completion_tokens` for GPT-5 calls (which 9router still emits) and floors completion tokens at 32K for reasoning headroom.
// Hostname-gated to api.openai.com; every step is try/catch so failure falls back to baseline behavior.
'use strict';
@@ -40,7 +12,7 @@ const DEBUG = process.env.OPENSWARM_DEBUG_GPT5_PATCH === '1';
function _log(msg) {
if (DEBUG) {
try { process.stderr.write('[openswarm-gpt5-patch] ' + msg + '\n'); } catch (_) { /* ignore */ }
try { process.stderr.write('[openswarm-gpt5-patch] ' + msg + '\n'); } catch (_) {}
}
}
@@ -48,9 +20,7 @@ function isGpt5Model(model) {
if (typeof model !== 'string') return false;
let m = model.trim().toLowerCase();
if (!m) return false;
// Strip routing prefixes 9router may have added: cp-openai/, openai/,
// cx/, openrouter/, or:openai/. Don't strip cp- (custom-provider) blindly
// because cp-anything could match a non-OpenAI custom node.
// Strip 9router prefixes; don't blindly strip cp- (could be a non-OpenAI custom node).
const prefixes = ['cp-openai/', 'openai/', 'cx/', 'openrouter/', 'or:openai/'];
for (const p of prefixes) {
if (m.startsWith(p)) { m = m.slice(p.length); break; }
@@ -58,19 +28,7 @@ function isGpt5Model(model) {
return m.startsWith('gpt-5');
}
// Minimum completion-token budget for GPT-5 reasoning models.
// GPT-5 burns 8-30K tokens on internal reasoning BEFORE producing any
// user-visible output. The Anthropic CLI's default max_tokens (~4096) is
// way under that floor — OpenAI accepts the request, runs reasoning until
// it hits the cap, then returns "Could not finish the message because
// max_tokens or model output limit was reached" with zero user-visible
// content. Floor at 32K so reasoning has room AND the user gets an
// actual response. Cost is unaffected because OpenAI bills for
// tokens-consumed, not max_completion_tokens (which is just a cap).
//
// We use max(requestedValue, 32K) — never lower the user's value, only
// raise it. If the user explicitly sets a high value (e.g. 100K) we
// honor it untouched.
// GPT-5 burns 8-30K reasoning tokens before any output; the CLI's default 4096 caps before content lands. Floor at 32K and only raise, never lower.
const GPT5_MIN_COMPLETION_TOKENS = 32768;
function maybeRewriteBody(bodyStr) {
@@ -80,8 +38,7 @@ function maybeRewriteBody(bodyStr) {
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return bodyStr;
if (!isGpt5Model(parsed.model)) return bodyStr;
let mutated = false;
// Both fields present (unlikely but possible): drop the legacy one so
// OpenAI doesn't reject for "both specified".
// Drop legacy field if both present, else OpenAI 400s on "both specified".
if ('max_tokens' in parsed && 'max_completion_tokens' in parsed) {
delete parsed.max_tokens;
mutated = true;
@@ -90,15 +47,13 @@ function maybeRewriteBody(bodyStr) {
parsed.max_completion_tokens = parsed.max_tokens;
delete parsed.max_tokens;
mutated = true;
_log('rewrote max_tokens max_completion_tokens for ' + parsed.model);
_log('rewrote max_tokens to max_completion_tokens for ' + parsed.model);
}
// Floor max_completion_tokens at 32K for reasoning headroom. Only raise,
// never lower — if the user explicitly set 100K, keep 100K.
if (typeof parsed.max_completion_tokens === 'number' && parsed.max_completion_tokens < GPT5_MIN_COMPLETION_TOKENS) {
const orig = parsed.max_completion_tokens;
parsed.max_completion_tokens = GPT5_MIN_COMPLETION_TOKENS;
mutated = true;
_log('raised max_completion_tokens ' + orig + ' ' + GPT5_MIN_COMPLETION_TOKENS + ' for ' + parsed.model + ' (reasoning headroom)');
_log('raised max_completion_tokens ' + orig + ' to ' + GPT5_MIN_COMPLETION_TOKENS + ' for ' + parsed.model);
}
return mutated ? JSON.stringify(parsed) : bodyStr;
}
@@ -112,7 +67,6 @@ function _hostFromOpts(opts) {
function patchHttpRequest(orig) {
return function patchedRequest() {
const args = Array.prototype.slice.call(arguments);
// First arg may be a URL string, URL object, or options object.
let opts = args[0];
let host = '';
try {
@@ -125,33 +79,28 @@ function patchHttpRequest(orig) {
return orig.apply(this, args);
}
// Outbound request to OpenAI: intercept body. The Anthropic SDK and
// 9router both call .write(body) then .end(), or .end(body) directly.
let req;
try { req = orig.apply(this, args); } catch (e) { throw e; }
const origWrite = req.write.bind(req);
const origEnd = req.end.bind(req);
const chunks = [];
let isStringMode = null; // null until first chunk; then true=string, false=buffer
let isStringMode = null;
function recordChunk(chunk) {
if (chunk == null) return;
if (typeof chunk === 'string') {
if (isStringMode === false) {
// Mixed mode — fall back: convert prior buffers to string
for (let i = 0; i < chunks.length; i++) chunks[i] = chunks[i].toString('utf8');
}
isStringMode = true;
chunks.push(chunk);
} else if (Buffer.isBuffer(chunk)) {
if (isStringMode === true) {
// Mixed: convert prior strings to buffers
for (let i = 0; i < chunks.length; i++) chunks[i] = Buffer.from(chunks[i], 'utf8');
}
isStringMode = false;
chunks.push(chunk);
} else {
// Unknown shape — abandon interception
throw new Error('unknown-chunk-shape');
}
}
@@ -162,12 +111,10 @@ function patchHttpRequest(orig) {
recordChunk(chunk);
return true;
} catch (_) {
// Abandon interception — pass through immediately and disable buffering.
// Flush anything we'd buffered so far.
try {
for (const c of chunks) origWrite(c);
chunks.length = 0;
} catch (_) { /* ignore */ }
} catch (_) {}
return origWrite.apply(req, [chunk].concat(restArgs));
}
};
@@ -186,19 +133,17 @@ function patchHttpRequest(orig) {
if (req.getHeader && typeof req.getHeader === 'function' && req.getHeader('content-length')) {
req.setHeader('Content-Length', newBuf.length);
}
} catch (_) { /* ignore */ }
} catch (_) {}
return origEnd.call(req, newBuf);
}
// No rewrite — send original body intact
if (chunks.length === 0) return origEnd.apply(req, restArgs);
if (isStringMode === true) return origEnd.call(req, chunks.join(''));
return origEnd.call(req, Buffer.concat(chunks));
} catch (_) {
// Abandon — flush any buffered content + tail chunk
try {
for (const c of chunks) origWrite(c);
chunks.length = 0;
} catch (_) { /* ignore */ }
} catch (_) {}
if (chunk != null) return origEnd.apply(req, [chunk].concat(restArgs));
return origEnd.apply(req, restArgs);
}
@@ -216,13 +161,11 @@ if (!_https.__openswarm_gpt5_patched) {
_http.__openswarm_gpt5_patched = true;
_log('installed https.request + http.request interceptors');
} catch (e) {
// Patch failed — log and continue. 9router will work as normal,
// GPT-5 calls will fail with the same 400 they did before. Never worse.
_log('install failed: ' + (e && e.message ? e.message : String(e)));
}
}
// Also patch global fetch (Node 18+). 9router uses fetch in some paths.
// Node 18+ fetch path; 9router uses fetch in some routes.
if (typeof globalThis.fetch === 'function' && !globalThis.fetch.__openswarm_gpt5_patched) {
try {
const origFetch = globalThis.fetch;
@@ -249,7 +192,7 @@ if (typeof globalThis.fetch === 'function' && !globalThis.fetch.__openswarm_gpt5
if (k.toLowerCase() === 'content-length') newInit.headers[k] = newLen;
}
}
} catch (_) { /* ignore */ }
} catch (_) {}
}
return origFetch.call(this, input, newInit);
}
+20 -81
View File
@@ -1,20 +1,4 @@
"""Lightweight Anthropic-format HTTP proxy.
When a user is on openswarm-pro with a non-Claude primary (GPT/Gemini/etc.),
the Claude Code CLI needs a single `ANTHROPIC_BASE_URL` that can serve BOTH:
1. the primary model calls (e.g. `cx/gpt-5` → must go to 9Router)
2. auxiliary Claude calls for subagents, WebSearch delegation
(e.g. `claude-haiku-4-5` → must go to OpenSwarm Pro's cloud proxy)
9Router doesn't know about OpenSwarm Pro, and we don't want to maintain a
custom 9Router provider-node for that. This proxy splits requests by the
`model` field in the body and forwards each to the correct upstream.
Mounted at `/api/anthropic-proxy`. Set `ANTHROPIC_BASE_URL` to
`http://127.0.0.1:<backend-port>/api/anthropic-proxy` in the CLI env for
Pro users with non-Claude primaries.
"""
"""Anthropic-format HTTP proxy splitting requests by model field; primary to 9Router, aux Claude to Pro proxy."""
import json
import logging
@@ -48,42 +32,27 @@ _CLAUDE_MODEL_PREFIXES = (
_GEMINI_MODEL_PREFIXES = ("gemini/", "gc/", "ag/")
# Bare-model patterns that resolve to Gemini's native API (gemini-3-flash-api,
# gemini-3.1-pro-api, gemini-3.1-flash-lite-api, etc. — when user supplies own
# Google API key in Settings → Models). These bypass our `gemini/` prefix so
# the prefix-only check above misses them; we match on the bare-name shape
# here too so $schema scrubbing fires for own-key Gemini sessions.
# Pre-fix: 8/8 own-key Gemini sessions in production failed with 400 because
# JSON Schema's $schema field leaked into Google's tools[].function_declarations
# payload. (See raw_payloads where status=error on every gemini-*-api session.)
# Own-key Gemini ("gemini-3-flash-api" etc.) skips the gemini/ prefix; match bare names so $schema scrub still fires.
_GEMINI_BARE_MODEL_PATTERNS = ("gemini-",)
# Fields Gemini's function_declarations validator rejects. 9Router 0.3.60's
# translator strips allOf/anyOf/oneOf/const-toplevel/required but misses
# these. Each one we've seen Gemini 400 on in production with "Unknown
# name 'X' at request.tools[N].function_declarations[N].parameters.…"
# Keys 9Router 0.3.60 misses that Gemini's function_declarations validator 400s on. Each was caught in prod.
_GEMINI_FORBIDDEN_SCHEMA_KEYS = {
# JSON-Schema metadata fields Gemini's stricter validator doesn't accept.
"$schema",
"$id", # ag/gemini-3.1-pro-high session, 2026-05-08
"$ref", # JSON-Schema reference; Gemini wants inlined types
"$defs", # ditto
"definitions", # legacy alias for $defs
# Constraint fields Gemini doesn't implement.
"$id",
"$ref",
"$defs",
"definitions",
"additionalProperties",
"propertyNames",
"patternProperties",
"exclusiveMinimum",
"exclusiveMaximum",
"const", # nested const leaks through 9Router's top-level-only strip.
# Anthropic-specific tool-call hints not part of vanilla JSON Schema.
# Anthropic's CLI emits these on tools that benefit from response
# priming; Gemini's validator rejects all unknown keys.
"prefill", # ag/gemini-3.1-pro-high session, 2026-05-08
"enumTitles", # human-readable enum labels; OpenAI-only convention
"title", # safe to keep usually but Gemini sometimes rejects under nested arrays
"examples", # JSON-Schema 2019-09 keyword Gemini doesn't honor
"default", # often allowed but rejected in nested array.items
"const",
"prefill",
"enumTitles",
"title",
"examples",
"default",
"readOnly",
"writeOnly",
"deprecated",
@@ -106,28 +75,15 @@ def _scrub_gemini_schema(node):
return node
# Models that REQUIRE max_completion_tokens instead of max_tokens.
# OpenAI's GPT-5.x family (gpt-5.4, gpt-5.4-mini, gpt-5.5, gpt-5.3-codex,
# etc.) introduced this in late 2025 — the legacy `max_tokens` field returns
# a 400 "Unsupported parameter: 'max_tokens' is not supported with this
# model. Use 'max_completion_tokens' instead." Anthropic's CLI / SDK still
# emits `max_tokens` because that's the Anthropic-format wire shape; we
# rename it on the way out for OpenAI-routed GPT-5 models.
# GPT-5.x rejects max_tokens; needs max_completion_tokens. Anthropic-format wire still emits max_tokens; we rename on the way out.
_OPENAI_MAX_COMPLETION_TOKENS_MODELS = ("gpt-5",)
def _is_openai_max_completion_tokens_model(model: str) -> bool:
"""Match every shape a GPT-5 model name might arrive in. Includes:
- bare: "gpt-5", "gpt-5.5", "gpt-5.4-mini"
- api-suffixed: "gpt-5.5-api" (desktop's pinned-api naming)
- 9router-prefixed: "openai/gpt-5.5" (post-translation name)
- codex-routed: "cx/gpt-5.3-codex" (CLI subscription)
Anything WITHOUT "gpt-5" in the (lowercased) string is rejected.
"""
"""Match every shape a GPT-5 name might arrive in (bare, api-suffixed, openai/-prefixed, cx/-routed)."""
m = (model or "").strip().lower()
if not m:
return False
# Strip common routing prefixes so we can match the bare model body.
for prefix in ("openai/", "cx/", "openrouter/", "or:openai/", "cp/", "cp-"):
if m.startswith(prefix):
m = m[len(prefix):]
@@ -136,12 +92,7 @@ def _is_openai_max_completion_tokens_model(model: str) -> bool:
def _scrub_request_for_openai_gpt5(body: bytes) -> bytes:
"""Rename `max_tokens` → `max_completion_tokens` for GPT-5 models.
Bytes-in/out, never raises. No-op if the body isn't JSON or doesn't
contain `max_tokens`. Drops the legacy field if BOTH are present so
the API doesn't reject for "both fields specified".
"""
"""Rename max_tokens to max_completion_tokens for GPT-5; bytes in/out, never raises."""
if not body:
return body
try:
@@ -179,8 +130,7 @@ def _scrub_request_for_gemini(body: bytes) -> bytes:
return json.dumps(parsed).encode("utf-8")
# Headers we strip before forwarding — these change hop-by-hop or we
# replace them with upstream-specific auth.
# Hop-by-hop headers or auth we replace with the upstream-specific value.
_HOP_HEADERS = {
"host",
"content-length",
@@ -206,9 +156,7 @@ def _is_gemini_model(model: str) -> bool:
m = (model or "").strip().lower()
if m.startswith(_GEMINI_MODEL_PREFIXES):
return True
# Bare-name match: "gemini-3-flash-api", "gemini-3.1-pro-api", etc.
# Excludes anthropic-routed gemini models (those carry "/" or other
# routing prefixes via the registry).
# Bare-name match for own-key Gemini; excludes anthropic-routed gemini (those carry "/").
if "/" in m:
return False
return any(m.startswith(p) for p in _GEMINI_BARE_MODEL_PATTERNS)
@@ -220,15 +168,12 @@ def _pick_upstream(model: str) -> tuple[str, dict[str, str]]:
s = load_settings()
if _is_claude_model(model):
# Prefer Pro cloud proxy when configured.
if getattr(s, "connection_mode", "own_key") == "openswarm-pro":
bearer = getattr(s, "openswarm_bearer_token", "") or ""
proxy = (getattr(s, "openswarm_proxy_url", "") or "https://api.openswarm.com").rstrip("/")
if bearer and proxy:
return (proxy, {"Authorization": f"Bearer {bearer}"})
# Fall through — let 9Router handle it (maybe user has a real Claude sub).
# Default: 9Router for everything else (cx/, gc/, gh/, apikey-routed models).
return ("http://127.0.0.1:20128", {"x-api-key": "9router"})
@@ -243,7 +188,7 @@ def _pick_upstream(model: str) -> tuple[str, dict[str, str]]:
include_in_schema=False,
)
async def _healthcheck():
"""CLI healthchecks the proxy root return 200 so it doesn't 404."""
"""CLI healthchecks the proxy root; return 200 so it doesn't 404."""
return {"ok": True}
@@ -272,13 +217,7 @@ async def proxy(rest: str, request: Request):
for k, v in request.headers.items():
if k.lower() in _HOP_HEADERS:
continue
# The CLI we spawn carries our per-install auth token via
# `x-api-key` (we set `ANTHROPIC_API_KEY=<our_token>` on the
# spawn env, and the CLI forwards that value as x-api-key). We
# must NOT forward that header to the real upstream — it would
# leak our local token to api.openswarm.com / 9Router, AND it
# would shadow the real upstream auth (bearer or `9router`
# literal) that `_pick_upstream` wants to set. Strip it here.
# CLI carries our install token as x-api-key; never forward (leak + shadows real upstream auth).
if k.lower() == "x-api-key":
continue
forward_headers[k] = v
+43 -43
View File
@@ -73,7 +73,7 @@ def _hash_tool_call(tool_name: str, tool_input: dict, result: dict) -> tuple[str
"""Build a stable hash key for a tool call, including its result.
Including the result hash means that legitimate progress (same input,
different output e.g. BrowserScroll on a long feed) does NOT count
different output; e.g. BrowserScroll on a long feed) does NOT count
as a loop. Only same-input + same-output is treated as stuck.
"""
try:
@@ -108,7 +108,7 @@ def _detect_loop(
_LOOP_WARNING_TEXT = (
"LOOP DETECTED: You have called this tool with these exact parameters and "
"gotten the same result {count} times in a row. STOP retrying this approach "
" it is not working. Try a fundamentally different strategy: "
", it is not working. Try a fundamentally different strategy: "
"(1) check the page state with BrowserScreenshot or BrowserGetText, "
"(2) try a different selector or a different tool, "
"(3) use BrowserPressKey for keyboard shortcuts if the site supports them, "
@@ -121,7 +121,7 @@ def _validate_message_pairing(messages: list[dict]) -> bool:
message in the same list. Returns False if there's an orphan, which means
the cached history would 400 if sent to the API.
This is the last line of defense against cache corruption if it ever
This is the last line of defense against cache corruption; if it ever
returns False on a resume, we drop the cache and start fresh rather than
crash on the next API call.
"""
@@ -145,7 +145,7 @@ def _validate_message_pairing(messages: list[dict]) -> bool:
def _is_fresh_user_message(msg: dict) -> bool:
"""A 'fresh' user message starts a new turn string content or a list
"""A 'fresh' user message starts a new turn; string content or a list
that contains no tool_result blocks. These are the only safe cut points
because they don't reference any prior assistant tool_use blocks."""
if msg.get("role") != "user":
@@ -165,7 +165,7 @@ def _summarize_messages(messages: list[dict]) -> str:
Extracts the original user task, a count of tool calls by name with their
key parameters, the last few ReportProgress brain states, and the most
recent assistant text. No LLM call required this is purely structural
recent assistant text. No LLM call required; this is purely structural
extraction from the existing message history.
"""
if not messages:
@@ -256,7 +256,7 @@ def _trim_history_by_turns(messages: list[dict], max_messages: int) -> list[dict
function avoids that by:
1. Walking forward to find a clean turn boundary (a fresh user-text
message that starts a new turn no tool_result content).
message that starts a new turn; no tool_result content).
2. Summarizing everything BEFORE that boundary into a single user-text
message and prepending it to the kept tail.
3. If no clean boundary exists at all, returning the original history
@@ -285,7 +285,7 @@ def _trim_history_by_turns(messages: list[dict], max_messages: int) -> list[dict
# Second pass: if no cut point gets us under the cap (e.g. the current
# turn alone is bigger than max_messages), use the LATEST clean cut point
# available. The tail will still exceed the cap, but it's the smallest
# safe history we can produce and any compaction is better than none.
# safe history we can produce; and any compaction is better than none.
if cut_index is None:
for i in range(len(messages) - 1, 0, -1):
if _is_fresh_user_message(messages[i]):
@@ -293,7 +293,7 @@ def _trim_history_by_turns(messages: list[dict], max_messages: int) -> list[dict
break
if cut_index is None:
# No clean cut anywhere in the history. Return original better to
# No clean cut anywhere in the history. Return original; better to
# exceed the cap than to corrupt the conversation.
return list(messages)
@@ -326,7 +326,7 @@ BROWSER_TOOLS_SCHEMA = [
"working_memory": {
"type": "string",
"description": (
"Short notes about what you've learned about this site so far "
"Short notes about what you've learned about this site so far; "
"selectors that work, keyboard shortcuts, layout quirks, what "
"you've tried that failed. Carry this forward across turns."
),
@@ -459,7 +459,7 @@ BROWSER_TOOLS_SCHEMA = [
"[2]<link \"Settings\">, etc. Use this BEFORE BrowserClickIndex. This is "
"the PREFERRED way to discover clickable elements on hostile sites "
"(Tinder, Instagram, TikTok) where CSS selectors fail because the page "
"uses unlabeled <div>s the accessibility tree sees roles and names "
"uses unlabeled <div>s; the accessibility tree sees roles and names "
"even when raw HTML doesn't expose them. Much more reliable than "
"BrowserGetElements (which uses CSS selectors)."
),
@@ -476,7 +476,7 @@ BROWSER_TOOLS_SCHEMA = [
"Uses native OS-level mouse events (event.isTrusted=true) so it works "
"on sites that filter out synthetic JS events. Always call "
"BrowserListInteractives first to get a fresh index list. If the click "
"returns 'index no longer valid', the page changed re-list and retry."
"returns 'index no longer valid', the page changed; re-list and retry."
),
"input_schema": {
"type": "object",
@@ -496,7 +496,7 @@ BROWSER_TOOLS_SCHEMA = [
"is executed in order, with the URL captured before/after each one. "
"If the URL changes mid-batch (the page navigated), the rest of the "
"batch is aborted and you get a partial result. Use this when you "
"have a known sequence typing then pressing Enter, swiping multiple "
"have a known sequence; typing then pressing Enter, swiping multiple "
"times, clicking through pagination. Max 5 actions per batch.\n\n"
"Sub-action types and their params:\n"
"- click_index: { index: int }\n"
@@ -537,7 +537,7 @@ BROWSER_TOOLS_SCHEMA = [
"description": (
"Press a keyboard key (or key combination) on the page using a real native "
"input event. Use this for keyboard shortcuts when JS-dispatched events get "
"ignored sites like Tinder, Slack, Notion, Gmail listen for trusted key "
"ignored; sites like Tinder, Slack, Notion, Gmail listen for trusted key "
"events. Examples: 'ArrowLeft', 'ArrowRight', 'Enter', 'Escape', 'Tab', "
"'Space', single letters like 'a'. Prefer this over BrowserEvaluate with "
"dispatchEvent for keyboard shortcuts."
@@ -579,7 +579,7 @@ BROWSER_TOOLS_SCHEMA = [
"name": "RequestHumanIntervention",
"description": (
"Request the user's help when you encounter an obstacle you cannot solve "
"programmatically captchas, login prompts, cookie consent walls, "
"programmatically; captchas, login prompts, cookie consent walls, "
"two-factor authentication, or any blocking popup. The agent will pause "
"until the user resolves the issue and clicks Continue."
),
@@ -590,7 +590,7 @@ BROWSER_TOOLS_SCHEMA = [
"type": "string",
"description": (
"One short sentence describing the obstacle. Keep it under "
"15 words. Example: 'Login required please sign in to X/Twitter.'"
"15 words. Example: 'Login required; please sign in to X/Twitter.'"
),
},
"instruction": {
@@ -624,7 +624,7 @@ ACTION_MAP = {
SYSTEM_PROMPT = (
"You are a website-agnostic browser automation agent. You can operate on ANY "
"website the user is signed into social media, dating apps, email, productivity "
"website the user is signed into; social media, dating apps, email, productivity "
"tools, dashboards, ecommerce, anything. Assume the user has already logged in.\n\n"
"## Required output structure: ReportProgress before every action\n"
@@ -654,36 +654,36 @@ SYSTEM_PROMPT = (
"If this is a continuation of an earlier conversation on the same browser, the "
"messages above already contain everything you've tried, what worked, what failed, "
"and the page state. READ THAT HISTORY before acting. Do NOT take a fresh screenshot "
"or re-explore the DOM if you already know what's on screen just act. Only re-orient "
"or re-explore the DOM if you already know what's on screen; just act. Only re-orient "
"if the page has clearly changed (after navigation, after a multi-second wait, or if "
"your last action mutated the page in unexpected ways).\n\n"
"## Try multiple strategies, learn from failures\n"
"Sites vary wildly. When one approach fails, switch tactics don't retry the same "
"Sites vary wildly. When one approach fails, switch tactics; don't retry the same "
"thing. The escalation ladder, fastest to slowest:\n"
"1. **Keyboard shortcuts via BrowserPressKey** fastest and most reliable on sites "
"1. **Keyboard shortcuts via BrowserPressKey**; fastest and most reliable on sites "
"that support them (Tinder swipes, Gmail navigation, Slack message jump, etc.). "
"Always check if the site shows keyboard hints in the UI before falling back to clicks. "
"BrowserPressKey sends real native events that pass the `event.isTrusted` check, so "
"it works where dispatchEvent in BrowserEvaluate silently fails.\n"
"2. **Accessibility tree via BrowserListInteractives + BrowserClickIndex** the "
"2. **Accessibility tree via BrowserListInteractives + BrowserClickIndex**; the "
"accessibility tree sees roles and names that the raw DOM doesn't, even on sites "
"like Tinder, Instagram, and TikTok that use unlabeled <div>s with click handlers. "
"Call BrowserListInteractives to get a numbered list (`[1]<button \"Like\">`, "
"`[2]<link \"Settings\">`), then BrowserClickIndex with the number. The click uses "
"native OS-level mouse events so it works where DOM .click() doesn't. THIS IS YOUR "
"GO-TO STRATEGY for unlabeled or hostile sites try this BEFORE BrowserGetElements.\n"
"3. **Semantic CSS selectors** `button[aria-label='X']`, `[role='button']`, "
"GO-TO STRATEGY for unlabeled or hostile sites; try this BEFORE BrowserGetElements.\n"
"3. **Semantic CSS selectors**; `button[aria-label='X']`, `[role='button']`, "
"`a[href*='...']`. Try these via BrowserGetElements + BrowserClick when the site "
"actually has semantic HTML.\n"
"4. **Text-based JS query** when both of the above fail, use BrowserEvaluate to "
"4. **Text-based JS query**; when both of the above fail, use BrowserEvaluate to "
"find elements by visible text: `Array.from(document.querySelectorAll('*')).find(el => el.textContent.trim() === 'Like')`.\n"
"5. **Coordinate-based fallback** last resort: take a screenshot, identify the "
"5. **Coordinate-based fallback**; last resort: take a screenshot, identify the "
"button visually, then click by approximate coords.\n\n"
"## Batch known sequences with BrowserBatch\n"
"When you have a known sequence of actions typing then pressing Enter, "
"swiping multiple times, clicking through pagination emit them all in a "
"When you have a known sequence of actions; typing then pressing Enter, "
"swiping multiple times, clicking through pagination; emit them all in a "
"single BrowserBatch call instead of one tool per turn. The batch executes "
"sub-actions sequentially and aborts if the URL changes mid-batch (so you "
"won't operate on stale state). Max 5 sub-actions per batch.\n"
@@ -703,21 +703,21 @@ SYSTEM_PROMPT = (
"- Do NOT call the same failing tool twice with identical parameters. If selector "
"X failed, try a DIFFERENT selector or a DIFFERENT strategy.\n"
"- For repeated actions (swiping through profiles, going through inbox messages), "
"use BrowserPressKey if available it's an order of magnitude faster than DOM clicks.\n\n"
"use BrowserPressKey if available; it's an order of magnitude faster than DOM clicks.\n\n"
"## When you genuinely cannot proceed\n"
"Use RequestHumanIntervention for:\n"
"- Login walls (the user thinks they're logged in but the session expired)\n"
"- Captchas, 2FA prompts, age verification gates\n"
"- Anything genuinely ambiguous about user intent\n"
"Don't use it for normal tool failures try a different approach first.\n\n"
"Don't use it for normal tool failures; try a different approach first.\n\n"
"## Tool reference\n"
"- BrowserScreenshot: visual snapshot. Use sparingly, not after every action.\n"
"- BrowserGetText: returns up to 15000 chars of visible text. Useful for reading "
"content without an image.\n"
"- BrowserScroll: handles nested scroll containers (Notion, Gmail). Returns "
"atTop/atBottom stop looping when scroll delta is 0.\n"
"atTop/atBottom; stop looping when scroll delta is 0.\n"
"- BrowserGetElements: enumerate interactive elements with selectors.\n"
"- BrowserClick / BrowserType: standard DOM interaction.\n"
"- BrowserPressKey: native key events (preferred for shortcuts).\n"
@@ -730,7 +730,7 @@ SYSTEM_PROMPT = (
MAX_TURNS = 40
# Tools that count as "action tools" calling any of these in a turn requires
# Tools that count as "action tools"; calling any of these in a turn requires
# the model to also call ReportProgress in the same turn (after the first
# turn). Read-only tools and meta tools are exempt.
_ACTION_TOOLS_REQUIRING_REPORT = {
@@ -906,17 +906,17 @@ async def run_browser_agent(
# When the parent session is running on a non-Claude model (e.g. gpt-5.4),
# the browser agent inherits it and we route through 9Router's prefix.
# Tool-use fidelity for browser-specific tools (BrowserNavigate, click,
# type, etc.) through 9Router's claude→openai translator is UNVERIFIED
# type, etc.) through 9Router's claude→openai translator is UNVERIFIED ,
# if translation is poor, the user should manually switch this session
# back to Claude in the model picker.
if _find_builtin_model(model) is not None:
api_model = resolve_model_id_for_sdk(model, browser_settings)
else:
# Unknown model string fall back to whatever aux model is available
# Unknown model string; fall back to whatever aux model is available
try:
api_model, _ = await resolve_aux_model(browser_settings, preferred_tier="haiku")
except ValueError:
# Nothing connected at all surface a clear error so the caller
# Nothing connected at all; surface a clear error so the caller
# (parent agent) sees it in the tool result instead of crashing
# on a 400 from 9Router.
session.status = "error"
@@ -953,13 +953,13 @@ async def run_browser_agent(
# Resume prior conversation on this browser if we have one cached. This
# lets the sub-agent skip the "take a screenshot to figure out where I am"
# cycle every time the parent issues a new task. Defensively validate
# the cache if it's somehow corrupted (orphaned tool_use_ids), drop
# the cache; if it's somehow corrupted (orphaned tool_use_ids), drop
# it and start fresh rather than crash on the next API call.
prior_messages = _browser_history.get(browser_id) or []
if prior_messages and not _validate_message_pairing(prior_messages):
logger.warning(
f"[browser-agent {session_id}] cached history for {browser_id} has "
f"orphaned tool_use_ids dropping cache and starting fresh"
f"orphaned tool_use_ids; dropping cache and starting fresh"
)
_browser_history.pop(browser_id, None)
prior_messages = []
@@ -967,7 +967,7 @@ async def run_browser_agent(
action_log: list[dict] = []
final_screenshot: str | None = None
# Loop detection state sliding window of recent state-mutating tool calls
# Loop detection state; sliding window of recent state-mutating tool calls
recent_tool_calls: list[tuple[str, str, str]] = []
loop_trigger_count = 0
@@ -1094,7 +1094,7 @@ async def run_browser_agent(
logger.error(
f"[browser-agent {session_id}] hit "
f"{MAX_CONSECUTIVE_VIOLATIONS} consecutive ReportProgress "
f"violations aborting to prevent runaway loop"
f"violations; aborting to prevent runaway loop"
)
# Surface a user-visible error message so the frontend
# shows something coherent instead of just stopping.
@@ -1113,7 +1113,7 @@ async def run_browser_agent(
})
break
else:
# Reset on a clean turn only CONSECUTIVE violations
# Reset on a clean turn; only CONSECUTIVE violations
# count toward the limit. A single bad turn followed by
# a good one shouldn't kill the agent.
consecutive_violations = 0
@@ -1128,7 +1128,7 @@ async def run_browser_agent(
cancelled = True
break
# Handle ReportProgress no-op execution that just records the
# Handle ReportProgress; no-op execution that just records the
# model's brain state and streams it to the dashboard.
if tu.name == "ReportProgress":
eval_prev = tu.input.get("evaluation_previous", "")
@@ -1163,7 +1163,7 @@ async def run_browser_agent(
rejection_text = (
"REJECTED: You called an action tool without first calling "
"ReportProgress in the same turn. ReportProgress is REQUIRED "
"before every batch of action tools it's how you reflect "
"before every batch of action tools; it's how you reflect "
"on what just happened and articulate your next goal. Try "
"again: emit ReportProgress and your action tool(s) in the "
"same response."
@@ -1189,7 +1189,7 @@ async def run_browser_agent(
})
continue
# Handle RequestHumanIntervention pause and wait for user
# Handle RequestHumanIntervention; pause and wait for user
if tu.name == "RequestHumanIntervention":
problem = tu.input.get("problem", "")
instruction = tu.input.get("instruction", "")
@@ -1341,7 +1341,7 @@ async def run_browser_agent(
if loop_trigger_count >= _LOOP_HARD_CAP:
logger.warning(
f"[browser-agent {session_id}] hit loop hard cap "
f"({_LOOP_HARD_CAP}) force-exiting"
f"({_LOOP_HARD_CAP}); force-exiting"
)
break
@@ -1376,7 +1376,7 @@ async def run_browser_agent(
# Persist conversation history so the next BrowserAgent call on this
# browser can resume rather than re-orient. Trim to the most recent
# _MAX_HISTORY_MESSAGES turns to keep token usage bounded but
# _MAX_HISTORY_MESSAGES turns to keep token usage bounded; but
# never split a tool_use ↔ tool_result pair across the cut, or the
# next API request will 400.
_browser_history[browser_id] = _trim_history_by_turns(
@@ -1,10 +1,5 @@
#!/usr/bin/env python3
"""
Stdio MCP server that exposes BrowserAgent and BrowserAgents delegation tools.
Launched as a subprocess by the Claude Agent SDK. Proxies task delegation
to the OpenSwarm backend via HTTP, which runs browser sub-agents.
"""
"""Stdio MCP server exposing BrowserAgent/BrowserAgents delegation tools."""
import base64
import json
@@ -1,11 +1,5 @@
#!/usr/bin/env python3
"""
Stdio MCP server that exposes the InvokeAgent tool.
Launched as a subprocess by the Claude Agent SDK. Proxies invocation
requests to the OpenSwarm backend via HTTP, which forks the target
agent session and runs it with the new message.
"""
"""Stdio MCP server exposing the InvokeAgent tool; proxies to /api/invoke-agent/run."""
import json
import sys
@@ -113,7 +107,7 @@ def handle_tool_call(tool_name: str, arguments: dict) -> dict:
lines = [f"**Invoked Agent Result** (forked session: {forked_id})"]
if source_name:
lines[0] = f"**Invoked Agent Result** {source_name} (forked session: {forked_id})"
lines[0] = f"**Invoked Agent Result**; {source_name} (forked session: {forked_id})"
if cost > 0:
lines.append(f"*Cost: ${cost:.4f}*")
lines.append("")
+21 -86
View File
@@ -1,20 +1,4 @@
"""Pre-flight MCP suggestion classifier.
Runs before a new agent launches. Given the user's initial prompt, decides:
1. Is this prompt vague or information-gathering (is_vague) — used to
conditionally inject the discovery scaffolding into the system prompt.
2. Does it suggest a not-yet-connected MCP that would dramatically
improve the outcome — surfaced to the user as a one-click
"Connect X" modal before the agent runs.
Only the curated shortlist of MCPs we ship and have vetted is considered.
The full community MCP registry is NOT mined for suggestions — flaky/
unvetted entries would make the "magic" moment feel broken.
Provider-agnostic: calls whatever cheap-tier aux model the user has wired
via `resolve_aux_model` (Haiku / GPT-5.4-mini / Gemini-2.5-flash / etc.).
If no provider is connected, fails open (no suggestions, no scaffolding).
"""
"""Pre-flight classifier; decides is_vague (scaffolding inject) + suggests an MCP to connect. Fails open."""
from __future__ import annotations
@@ -32,56 +16,45 @@ from backend.apps.tools_lib.tools_lib import _load_all as load_all_tools
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Tool-agnostic discovery scaffolding — appended to the agent's system prompt
# only when preflight flags the prompt as vague/information-gathering.
# ---------------------------------------------------------------------------
# Tool-agnostic discovery scaffolding; appended only when preflight flags the prompt as vague/info-gathering.
DISCOVERY_SCAFFOLDING = (
"# Discovery before action\n"
"When a request is vague or could be grounded in user context, do not "
"guess generic defaults. First silently enumerate what would change the "
"output voice, tone, audience, prior context, recent precedent, facts "
"output; voice, tone, audience, prior context, recent precedent, facts "
"that only live in the user's data. Then look at your available tools and "
"pick the ones that could answer those unknowns. Read a few examples "
"(usually 310 is enough), summarize what you found into a few bullets, "
"(usually 3, 10 is enough), summarize what you found into a few bullets, "
"then act confidently.\n\n"
"Tool-selection hierarchy for information gathering:\n"
" 1. Direct local access (filesystem reads, code search, shell) "
" 1. Direct local access (filesystem reads, code search, shell); "
"cheapest and fastest.\n"
" 2. Connected services / MCP tools for user data that lives in a "
" 2. Connected services / MCP tools; for user data that lives in a "
"linked account (email, calendar, notes, tickets, etc.).\n"
" 3. Web search / fetch for public information that isn't in your "
" 3. Web search / fetch; for public information that isn't in your "
"training cutoff.\n"
" 4. Browser automation only when a real interactive session or "
" 4. Browser automation; only when a real interactive session or "
"login is required.\n"
" 5. Sub-agents only for parallelizable subtasks or to isolate heavy "
" 5. Sub-agents; only for parallelizable subtasks or to isolate heavy "
"context. Not for serial steps.\n\n"
"Asking the user is a fallback, not a first move. Never fabricate. If "
"no tool can ground a critical unknown, ask one concise question."
)
# ---------------------------------------------------------------------------
# Curated MCP shortlist. These `id` values MUST match the exact `name` field
# on ToolDefinition entries that OpenSwarm ships as defaults (see
# `backend/data/tools/*.json` — one file per tool, `name` is the canonical
# key used everywhere else in the app). Mismatches would cause the
# enabled/disabled filter to no-op and the frontend modal to render nothing.
#
# Keep in sync with the Custom Action Sets list in Settings → Tools.
# ---------------------------------------------------------------------------
# Curated shortlist; `id` MUST match ToolDefinition.name exactly or the enabled/dismissed filter no-ops and the modal renders nothing.
CuratedEntry = dict[str, Any]
CURATED_SHORTLIST: list[CuratedEntry] = [
{
"id": "Google Workspace",
"title": "Google Workspace",
"description": "Gmail, Calendar, Drive, Docs, Sheets, Slides for reading/sending email, checking the user's schedule, and pulling context from their documents.",
"description": "Gmail, Calendar, Drive, Docs, Sheets, Slides; for reading/sending email, checking the user's schedule, and pulling context from their documents.",
},
{
"id": "Microsoft 365",
"title": "Microsoft 365",
"description": "Outlook email, Calendar, OneDrive, Teams, Excel, OneNote Microsoft-stack equivalent of Google Workspace.",
"description": "Outlook email, Calendar, OneDrive, Teams, Excel, OneNote; Microsoft-stack equivalent of Google Workspace.",
},
{
"id": "Slack",
@@ -101,7 +74,7 @@ CURATED_SHORTLIST: list[CuratedEntry] = [
{
"id": "HubSpot",
"title": "HubSpot",
"description": "CRM contacts, deals, companies, tickets when the user's task involves their customer relationships.",
"description": "CRM contacts, deals, companies, tickets; when the user's task involves their customer relationships.",
},
{
"id": "Airtable",
@@ -111,58 +84,35 @@ CURATED_SHORTLIST: list[CuratedEntry] = [
{
"id": "Reddit",
"title": "Reddit",
"description": "Browse subreddits, search posts, analyze users when the task involves public Reddit content.",
"description": "Browse subreddits, search posts, analyze users; when the task involves public Reddit content.",
},
{
"id": "YouTube",
"title": "YouTube",
"description": "Video transcripts, details, comments, channel stats, search when the task involves YouTube content.",
"description": "Video transcripts, details, comments, channel stats, search; when the task involves YouTube content.",
},
]
# ---------------------------------------------------------------------------
# Local skip filter — short-circuits the LLM call for obviously-local prompts
# where no MCP could add value. Saves ~200ms + ~$0.0001 per launch.
# ---------------------------------------------------------------------------
# Short-circuit for obviously-local prompts where no MCP helps. Saves ~200ms + ~$0.0001 per launch.
_PATH_LIKE = re.compile(r"^[./~]|/[\w\-]+/|\.[a-zA-Z]{1,5}\b")
_SHELL_PREFIX = re.compile(r"^\s*[\$!/]")
def _is_obviously_local(prompt: str) -> bool:
"""Heuristic: does this prompt obviously not need any MCP?
Returns True for:
- very short prompts (< 8 chars, likely greetings or acknowledgments)
- shell-command-ish prompts ("! ls", "$ git status", "/clear")
- prompts that are essentially a single file path reference
On True we skip preflight entirely; the agent launches with no
scaffolding and no suggestion modal.
"""
"""True for prompts that obviously can't benefit from MCP (very short, shell-ish, single path)."""
s = prompt.strip()
if len(s) < 8:
return True
if _SHELL_PREFIX.match(s):
return True
# Single-token path-ish prompt (e.g. "./src/foo.ts")
if " " not in s and _PATH_LIKE.search(s):
return True
return False
# ---------------------------------------------------------------------------
# Main entry point
# ---------------------------------------------------------------------------
async def run_preflight(prompt: str, timeout_s: float = 2.0) -> dict:
"""Classify the user's prompt and return suggestions + vagueness flag.
Always returns a dict of shape:
{"is_vague": bool, "suggestions": [Suggestion, ...]}
Never raises: any failure (no provider, aux model timeout, bad JSON)
fails open — returns is_vague=False and empty suggestions.
"""
"""Classify the prompt and return {is_vague, suggestions}; never raises."""
default: dict[str, Any] = {"is_vague": False, "suggestions": []}
if not prompt or not prompt.strip():
@@ -174,33 +124,20 @@ async def run_preflight(prompt: str, timeout_s: float = 2.0) -> dict:
try:
settings = load_settings()
available = _build_available_shortlist(settings)
if not available:
# Everything in the shortlist is either enabled, dismissed, or
# out-of-scope for this user. We still run the classifier (for
# is_vague) but with an empty candidate list — the model will
# only fill in is_vague and return no suggestions.
pass
result = await asyncio.wait_for(
_call_classifier(settings, prompt, available),
timeout=timeout_s,
)
# Re-validate suggestion ids against the curated shortlist so a
# hallucinated id can't reach the frontend.
# Re-validate ids against the curated shortlist so hallucinations can't reach the frontend.
valid_ids = {e["id"] for e in CURATED_SHORTLIST}
result["suggestions"] = [
_decorate(s, available) for s in result.get("suggestions", [])
if isinstance(s, dict) and s.get("id") in valid_ids
]
# Drop anything that ended up with no matching available entry
# (e.g. already enabled by the user between preflight and now).
result["suggestions"] = [s for s in result["suggestions"] if s is not None]
result["is_vague"] = bool(result.get("is_vague"))
# Suppress suggestions on concrete prompts. False-positives here
# are worse than missed positives — interrupting a user who typed
# "refactor foo.ts" to ask about GitHub MCP would feel broken.
# Vague/info-gathering prompts are where suggestions help; concrete
# tasks should just launch.
# Suppress on concrete prompts; false-positives feel broken (interrupting "refactor foo.ts" to suggest GitHub MCP).
if not result["is_vague"]:
result["suggestions"] = []
return result
@@ -246,7 +183,7 @@ async def _call_classifier(settings, prompt: str, available: list[CuratedEntry])
client = get_anthropic_client_for_model(settings, aux_model)
catalog_lines = "\n".join(
f"- id: {e['id']} | {e['title']} {e['description']}"
f"- id: {e['id']} | {e['title']}; {e['description']}"
for e in available
) or "- (no candidate services available for this user)"
@@ -282,7 +219,6 @@ async def _call_classifier(settings, prompt: str, available: list[CuratedEntry])
messages=[{"role": "user", "content": user_turn}],
)
# Extract text content. Handle both string and content-block shapes.
text = ""
if isinstance(resp.content, list):
for block in resp.content:
@@ -293,7 +229,6 @@ async def _call_classifier(settings, prompt: str, available: list[CuratedEntry])
text = str(resp.content)
text = text.strip()
# Strip any accidental code fences
if text.startswith("```"):
text = re.sub(r"^```(?:json)?\s*", "", text)
text = re.sub(r"\s*```\s*$", "", text)
+4 -37
View File
@@ -1,28 +1,4 @@
"""Tiny OpenAI-API pass-through with `max_tokens` → `max_completion_tokens`
rename for GPT-5.x models.
Why this exists
---------------
OpenAI's GPT-5 family (gpt-5.4-mini, gpt-5.5, gpt-5.3-codex, etc.)
rejects the legacy `max_tokens` parameter with HTTP 400:
"Unsupported parameter: 'max_tokens' is not supported with this model.
Use 'max_completion_tokens'."
Anthropic's CLI emits requests in Anthropic format (which uses `max_tokens`),
9Router 0.3.60 translates Anthropic→OpenAI and preserves `max_tokens`
(it doesn't know about the GPT-5 change). We can't bump 9Router because
0.3.60 is pinned to fix a separate WebSearch regression in the 0.3.x
range (see backend/apps/nine_router.py:27-36).
So we slot a thin proxy between 9Router and api.openai.com. The CLI is
unaware: it sees its OPENAI_BASE_URL pointing at this local passthrough,
not OpenAI. We rename the field for GPT-5 models and forward unchanged
otherwise. Streaming + non-streaming both work because we proxy bytes.
Mounted at `/api/openai-passthrough` and consumed by setting
OPENAI_BASE_URL to `http://127.0.0.1:<port>/api/openai-passthrough/v1`
in the CLI's spawn env (see agent_manager.py).
"""
"""Tiny OpenAI passthrough renaming max_tokens to max_completion_tokens for GPT-5; 9Router 0.3.60 is pinned and doesn't know the change."""
import json
import logging
@@ -45,8 +21,7 @@ async def openai_passthrough_lifespan():
openai_passthrough = SubApp("openai-passthrough", openai_passthrough_lifespan)
# Models that REQUIRE max_completion_tokens. Mirrors anthropic_proxy.py's
# matcher but lives here so this module doesn't depend on that one.
# Mirrors anthropic_proxy.py's GPT-5 matcher; duplicated to avoid the cross-module dep.
_GPT5_PREFIXES = ("gpt-5",)
_OPENAI_UPSTREAM = "https://api.openai.com/v1"
_HOP_HEADERS = {
@@ -60,7 +35,6 @@ def _is_gpt5(model: str) -> bool:
m = (model or "").strip().lower()
if not m:
return False
# Strip routing prefixes 9Router may have added.
for prefix in ("openai/", "cx/", "openrouter/", "or:openai/", "cp/", "cp-"):
if m.startswith(prefix):
m = m[len(prefix):]
@@ -69,12 +43,7 @@ def _is_gpt5(model: str) -> bool:
def _scrub_max_tokens(body: bytes) -> bytes:
"""Rename max_tokens max_completion_tokens for GPT-5 models.
Bytes-in/out, never raises. No-op if body isn't JSON, model isn't GPT-5,
or max_tokens isn't present. If both fields are present (unlikely),
drops the legacy field so OpenAI doesn't 400 on the conflict.
"""
"""Rename max_tokens to max_completion_tokens for GPT-5; bytes in/out, never raises."""
if not body:
return body
try:
@@ -113,9 +82,7 @@ async def passthrough(rest: str, request: Request):
if request.url.query:
upstream_url = f"{upstream_url}?{request.url.query}"
# Stream upstream response body straight back to the caller. httpx's
# streaming context handles Server-Sent Events the CLI uses for chat
# completions without buffering the full response in memory.
# Stream upstream body back; httpx handles SSE without buffering the full response.
client = httpx.AsyncClient(timeout=httpx.Timeout(connect=10.0, read=300.0, write=60.0, pool=30.0))
try:
upstream_req = client.build_request(
+17 -17
View File
@@ -251,7 +251,7 @@ async def fetch_openrouter_models(api_key: str | None) -> list[dict]:
continue
if isinstance(out_mods, list) and out_mods and "text" not in out_mods:
continue
# Tools required agent loop doesn't work without function calling.
# Tools required; agent loop doesn't work without function calling.
params = m.get("supported_parameters") or []
if not isinstance(params, list) or "tools" not in params:
continue
@@ -329,7 +329,7 @@ _CUSTOM_VALUE_PREFIX = "custom/"
def _custom_provider_slug_for_lookup(name: str) -> str:
"""Mirror nine_router._custom_provider_slug duplicated here to avoid
"""Mirror nine_router._custom_provider_slug; duplicated here to avoid
importing from nine_router (circular: nine_router imports from settings)."""
import re
s = re.sub(r"[^a-zA-Z0-9-]+", "-", (name or "").strip().lower()).strip("-")
@@ -355,7 +355,7 @@ def _find_builtin_model(short_name: str) -> dict | None:
"""Look up a model entry by its short `value`.
OpenRouter entries (prefixed `or:<vendor>/<model>`) and custom-provider
entries (prefixed `custom/<slug>/<model_id>`) aren't in BUILTIN_MODELS
entries (prefixed `custom/<slug>/<model_id>`) aren't in BUILTIN_MODELS ,
they're synthesised on demand so the rest of the routing code can treat
them like BUILTIN_MODELS entries."""
for models in BUILTIN_MODELS.values():
@@ -520,7 +520,7 @@ async def resolve_aux_model(
return ("cx/gpt-5.4-mini", base_url)
if "gemini-cli" in connected:
return ("gc/gemini-3.1-flash-lite-preview", base_url)
# OR is metered, hence last saves OR-only users from "Untitled session" hell.
# OR is metered, hence last; saves OR-only users from "Untitled session" hell.
if "openrouter" in connected:
return (or_aux, base_url)
@@ -538,7 +538,7 @@ def get_context_window(provider: str, model: str, settings: AppSettings | None =
if m["value"] == model:
return m.get("context_window", 128_000)
# Check custom providers picker values are `custom/<slug>/<bare_model>`;
# Check custom providers; picker values are `custom/<slug>/<bare_model>`;
# cp.models[].value stores the bare model id the user typed. Match the
# bare-model tail against any custom provider's models list.
if settings:
@@ -557,7 +557,7 @@ def get_context_window(provider: str, model: str, settings: AppSettings | None =
# ---------------------------------------------------------------------------
# Curated model tiers Intelligence, Speed, Cost on a 1-5 scale
# Curated model tiers; Intelligence, Speed, Cost on a 1-5 scale
# ---------------------------------------------------------------------------
#
# Hand-tuned from public benchmarks + per-token pricing (knowledge cutoff
@@ -756,7 +756,7 @@ def _heuristic_tiers(label: str, output_cost_per_1m: float, reasoning: bool) ->
import re as _re
out = output_cost_per_1m or 0.0
# Cost bucket same 5-tier cost ladder as before.
# Cost bucket; same 5-tier cost ladder as before.
if out < 0.5:
cb = 1
elif out < 2:
@@ -793,7 +793,7 @@ def _heuristic_tiers(label: str, output_cost_per_1m: float, reasoning: bool) ->
elif param_b > 0:
size_tier = 1
else:
size_tier = 0 # unknown fall back to cost
size_tier = 0 # unknown; fall back to cost
# Intelligence is the max of cost bucket and parsed size tier.
# Cost is high-confidence for closed-source frontier; size is
@@ -802,7 +802,7 @@ def _heuristic_tiers(label: str, output_cost_per_1m: float, reasoning: bool) ->
intel = max(cb, size_tier)
if reasoning and intel < 4:
# Reasoning is a strong intelligence signal but only for
# genuinely smaller models frontier closed-source already
# genuinely smaller models; frontier closed-source already
# caps at 5, so don't double-count there.
intel += 1
@@ -870,15 +870,15 @@ def compute_billing_kind(
settings,
) -> str:
"""Return one of:
'subscription' covered by an OAuth sub or Pro plan; hide cost row
'api_key' direct API-key path (Anthropic / OpenAI / Gemini)
'free' genuinely $0 per token (rate-limited OR :free tier)
'paid' per-token metering through OpenRouter; show pricing
'subscription'; covered by an OAuth sub or Pro plan; hide cost row
'api_key' ; direct API-key path (Anthropic / OpenAI / Gemini)
'free' ; genuinely $0 per token (rate-limited OR :free tier)
'paid' ; per-token metering through OpenRouter; show pricing
Why 'api_key' is split from 'paid': both meter per-token, but the user
is paying a different counterparty. Letting the picker filter chips
"API key" vs "Subscription" gives users a clear way to scope to their
billing relationship direct API key vs OAuth subscription instead
billing relationship; direct API key vs OAuth subscription; instead
of conflating them under a generic "paid" bucket.
Subscription paths:
@@ -913,7 +913,7 @@ def compute_billing_kind(
COST_PER_1M_TOKENS: dict[tuple[str, str], tuple[float, float]] = {
# (provider, model): (input_cost_per_1M, output_cost_per_1M)
# NOTE: `calculate_cost` is currently unused in the live path real
# NOTE: `calculate_cost` is currently unused in the live path; real
# cost numbers come from 9Router's usage stats. These entries are kept
# so the table matches BUILTIN_MODELS and can
# be used by any future native-loop path. Subscription-routed models
@@ -924,14 +924,14 @@ COST_PER_1M_TOKENS: dict[tuple[str, str], tuple[float, float]] = {
("Anthropic", "opus"): (5.0, 25.0),
("Anthropic", "opus-4-7"): (5.0, 25.0),
("Anthropic", "haiku"): (1.0, 5.0),
# OpenAI Codex subscription path, user pays nothing per token
# OpenAI; Codex subscription path, user pays nothing per token
("OpenAI", "gpt-5.5"): (0.0, 0.0),
("OpenAI", "gpt-5.4"): (0.0, 0.0),
("OpenAI", "gpt-5.4-mini"): (0.0, 0.0),
("OpenAI", "gpt-5.3-codex"): (0.0, 0.0),
("OpenAI", "gpt-5.3-codex-high"): (0.0, 0.0),
("OpenAI", "gpt-5.3-codex-xhigh"): (0.0, 0.0),
# Google Gemini CLI subscription path, user pays nothing per token
# Google; Gemini CLI subscription path, user pays nothing per token
("Google", "gemini-3.1-pro"): (0.0, 0.0),
("Google", "gemini-3.1-flash-lite"): (0.0, 0.0),
("Google", "gemini-3-pro"): (0.0, 0.0),
+10 -88
View File
@@ -1,49 +1,4 @@
"""Per-session WS event sequencing, ring buffer, and terminal-event persistence.
Why this exists
---------------
WS sockets die for a thousand reasons that have nothing to do with the
agent task: laptop sleep, captive portals, NAT idle timeout, VPN
renegotiation. Without this module, a transient drop is fatal —
mid-stream events are lost forever and the UI can't tell whether the
run finished or merely went quiet.
Contract
--------
Every WS event for a session goes through `stamp(...)`, which is an
async context manager that:
1. Acquires the per-session lock.
2. Bumps a monotonic `seq` integer.
3. Appends the JSON payload to a bounded ring buffer.
4. Yields (seq, payload_str) to the caller.
5. Holds the lock until the caller exits the `async with` — meaning
the caller's `ws.send_text(...)` happens *under the same lock*,
guaranteeing wire order == seq order even when many coroutines
broadcast concurrently.
Without (5), two coroutines can each get a unique seq under separate
lock acquisitions, yet the higher-seq event can reach the wire first
because asyncio scheduled its `send_text` earlier. That corrupts both
wire order and the ring buffer on resume.
Resume protocol
---------------
On reconnect, the client sends `client:resume {connection_uuid,
last_seq}`. The server:
- Returns ring-buffer events with `seq > last_seq` if available.
- Returns `agent:gap_detected` if `last_seq` is older than the
oldest buffered seq — the client falls back to a REST refresh.
- Returns the persisted terminal event (if any) when the session
is no longer in memory at all (e.g. after a process restart).
Persistence
-----------
Terminal events (status: completed/stopped/error) are written
atomically to disk so a client that comes back hours later — long
after the in-memory ring buffer has been GC'd — still sees the right
outcome instead of a spinner that never resolves. Persistence is
opportunistic: an I/O error never blocks the broadcast path.
"""
"""Per-session WS event sequencing, ring buffer, and terminal-event persistence for resilient reconnects."""
from __future__ import annotations
@@ -57,9 +12,7 @@ from typing import AsyncIterator, Optional
logger = logging.getLogger(__name__)
# Ring buffer size per session. ~500 events comfortably covers a 30s
# transient drop even in the busiest streams (thinking deltas at
# ~20Hz). Memory is bounded: ~50KB per active session.
# 500 events covers a 30s drop even at ~20Hz thinking deltas (~50KB/session).
BUFFER_LIMIT = 500
TERMINAL_STATUSES = {"completed", "stopped", "error"}
@@ -73,8 +26,7 @@ class _SessionSeqLog:
def __init__(self) -> None:
self.lock: asyncio.Lock = asyncio.Lock()
self.seq: int = 0
# Each entry: (seq, json_payload_str). Pre-serialized so a
# replay doesn't redo json.dumps for every reconnect.
# (seq, json_payload_str): pre-serialized so replays don't redo json.dumps per reconnect.
self.buffer: deque[tuple[int, str]] = deque(maxlen=BUFFER_LIMIT)
@@ -83,8 +35,7 @@ class SeqLogStore:
def __init__(self, persist_dir: Optional[str] = None) -> None:
self._per_session: dict[str, _SessionSeqLog] = {}
# Coarse lock guarding only the dict's setdefault path. Held
# for nanoseconds; never crosses an `await` past the `_get`.
# Coarse lock guards only the setdefault path; never crosses an await.
self._dict_lock = asyncio.Lock()
self._persist_dir = persist_dir
if persist_dir:
@@ -111,13 +62,7 @@ class SeqLogStore:
async def stamp(
self, session_id: str, event: str, data: dict
) -> AsyncIterator[tuple[int, str]]:
"""Atomically assign a seq, buffer it, and yield (seq, payload).
Caller is expected to perform the actual `send_text` *inside*
the `async with` block. The per-session lock is held for the
entire body, so wire order is guaranteed equal to seq order
no matter how many tasks broadcast concurrently.
"""
"""Atomically assign seq, buffer, and yield (seq, payload); caller's send must happen inside the with-block."""
log = await self._get_or_create(session_id)
async with log.lock:
log.seq += 1
@@ -135,23 +80,11 @@ class SeqLogStore:
def replay(
self, session_id: str, last_seq: int
) -> tuple[Optional[int], Optional[int], list[str]]:
"""Return (oldest_buffered_seq, newest_buffered_seq, events).
Caller decides what to do with the result:
- `events` empty AND newest_buffered_seq is None: no buffer
for this session in memory. Fall back to persisted
terminal event.
- `last_seq` < `oldest_buffered_seq`: there's a gap. Send
`agent:gap_detected`; the client REST-refreshes.
- Otherwise `events` are the missed payloads in seq order.
"""
"""Return (oldest_buffered_seq, newest_buffered_seq, events)."""
log = self._peek(session_id)
if log is None:
return (None, None, [])
# Snapshot the deque under the lock-free fast path. asyncio is
# single-threaded so a list() of a deque mutated by append is
# safe; eviction (via maxlen) is also a single-step op. We
# don't need to hold the per-session lock for a read.
# asyncio is single-threaded; deque list() is safe vs concurrent append/eviction. No lock needed for read.
snapshot = list(log.buffer)
if not snapshot:
return (None, log.seq, [])
@@ -165,23 +98,17 @@ class SeqLogStore:
log = self._peek(session_id)
return log.seq if log else 0
# ----- Terminal-event persistence -----
def _terminal_path(self, session_id: str) -> Optional[str]:
if not self._persist_dir:
return None
# session ids are uuid4 hex in this codebase, but sanitize
# against path traversal anyway.
# Session ids are uuid4 hex; sanitize anyway against path traversal.
safe = "".join(c for c in session_id if c.isalnum() or c in ("-", "_"))
if not safe:
return None
return os.path.join(self._persist_dir, f"{safe}.json")
def persist_terminal(self, session_id: str, payload_str: str) -> None:
"""Atomic write of a terminal event for post-restart clients.
Best-effort: an I/O failure must never block the broadcast.
"""
"""Atomic write of a terminal event for post-restart clients; best-effort, never blocks broadcast."""
path = self._terminal_path(session_id)
if not path:
return
@@ -206,11 +133,7 @@ class SeqLogStore:
return None
def clear(self, session_id: str) -> None:
"""Drop in-memory log + persisted terminal event.
Use on full session deletion. Closed-but-retained sessions
keep their terminal file so late reconnects still resolve.
"""
"""Drop in-memory log and persisted terminal; for full deletion only, closed-but-retained sessions keep it."""
self._per_session.pop(session_id, None)
path = self._terminal_path(session_id)
if path and os.path.exists(path):
@@ -228,5 +151,4 @@ def _default_persist_dir() -> Optional[str]:
return None
# Process-wide singleton wired to the agents data dir.
seq_log = SeqLogStore(persist_dir=_default_persist_dir())
+6 -27
View File
@@ -10,8 +10,8 @@ import httpx
from backend.apps.agents.tools.base import BaseTool, ToolContext
_HTTP_TIMEOUT = 30 # seconds
_MAX_OUTPUT_BYTES = 250 * 1024 # ~250 KB covers ~95% of articles/wikis/docs
_HTTP_TIMEOUT = 30
_MAX_OUTPUT_BYTES = 250 * 1024 # ~250 KB covers ~95% of articles/wikis/docs.
_USER_AGENT = (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
@@ -25,24 +25,15 @@ def _truncate(text: str, limit: int = _MAX_OUTPUT_BYTES) -> str:
def _strip_html(raw_html: str) -> str:
"""Naive but effective HTML plain-text conversion."""
# Remove script/style blocks
"""Naive but effective HTML to plain-text conversion."""
text = re.sub(r"<(script|style)[^>]*>.*?</\1>", "", raw_html, flags=re.DOTALL | re.IGNORECASE)
# Remove HTML tags
text = re.sub(r"<[^>]+>", " ", text)
# Decode HTML entities
text = html.unescape(text)
# Collapse whitespace
text = re.sub(r"[ \t]+", " ", text)
text = re.sub(r"\n{3,}", "\n\n", text)
return text.strip()
# ───────────────────────────────────────────────────────────────────────────
# WebSearchTool
# ───────────────────────────────────────────────────────────────────────────
class WebSearchTool(BaseTool):
name = "WebSearch"
description = (
@@ -96,8 +87,6 @@ class WebSearchTool(BaseTool):
body = resp.text
# Parse result blocks DuckDuckGo wraps each result in
# <div class="result ..."> ... </div>
result_blocks = re.findall(
r'<div[^>]*class="[^"]*result[^"]*"[^>]*>(.*?)</div>\s*(?=<div[^>]*class="[^"]*result|$)',
body,
@@ -109,14 +98,13 @@ class WebSearchTool(BaseTool):
if len(entries) >= num_results:
break
# Title + URL — handle both class-before-href and href-before-class
# Handle both class-before-href and href-before-class attribute orders.
link_match = re.search(
r'<a[^>]*class="[^"]*result__a[^"]*"[^>]*href="([^"]*)"[^>]*>(.*?)</a>',
block,
flags=re.DOTALL,
)
if not link_match:
# Try reversed attribute order
link_match = re.search(
r'<a[^>]*href="([^"]*)"[^>]*class="[^"]*result__a[^"]*"[^>]*>(.*?)</a>',
block,
@@ -128,7 +116,6 @@ class WebSearchTool(BaseTool):
raw_url = html.unescape(link_match.group(1))
title = _strip_html(link_match.group(2)).strip()
# Snippet
snippet_match = re.search(
r'<a[^>]*class="[^"]*result__snippet[^"]*"[^>]*>(.*?)</a>',
block,
@@ -136,7 +123,7 @@ class WebSearchTool(BaseTool):
)
snippet = _strip_html(snippet_match.group(1)).strip() if snippet_match else ""
# DuckDuckGo wraps URLs through a redirect; try to extract the real URL
# DDG wraps URLs in a redirect; extract the real one.
real_url_match = re.search(r"uddg=([^&]+)", raw_url)
if real_url_match:
from urllib.parse import unquote
@@ -152,11 +139,6 @@ class WebSearchTool(BaseTool):
return "\n\n".join(entries)
# ───────────────────────────────────────────────────────────────────────────
# WebFetchTool
# ───────────────────────────────────────────────────────────────────────────
class WebFetchTool(BaseTool):
name = "WebFetch"
description = (
@@ -202,10 +184,7 @@ class WebFetchTool(BaseTool):
is_html = "html" in content_type or resp.text.strip().startswith("<!")
if is_html:
# Prefer trafilatura for article/main-content extraction — strips
# nav, footer, ads, sidebars and returns the primary text. Falls
# back to regex HTML-strip if trafilatura can't extract (rare
# pages: pure apps, login walls, heavily JS-rendered content).
# Prefer trafilatura for main-content extraction; fall back to regex strip on apps/login walls/JS-heavy pages.
text: str | None = None
try:
import trafilatura # type: ignore
+2 -25
View File
@@ -1,25 +1,5 @@
#!/usr/bin/env python3
"""
Stdio MCP server exposing `WebSearch` and `WebFetch` backed by the
OpenSwarm backend's free DuckDuckGo + trafilatura implementation.
Purpose: the Claude Code CLI's built-in `WebSearch` / `WebFetch` tools
wrap Anthropic's server-side `web_search_20250305` / `web_fetch_20250807`
which require a Claude credential somewhere (Claude subscription on
9Router, openswarm-pro cloud proxy, or direct Anthropic API key). Users
who only connect ChatGPT Plus or Gemini Advanced and don't have any
Claude-backed credential get "No credentials for provider: claude" from
the CLI and either see hallucinated or empty results.
This server is registered by `agent_manager.py` only in that gap case.
When it is registered, the built-in `WebSearch` / `WebFetch` are added
to `disallowed_tools` so the model picks our MCP-prefixed versions.
Proxies tool calls to the backend at /api/web/search and /api/web/fetch
so the DDG / trafilatura logic lives in one place
(`backend/apps/agents/tools/web.py`) and can be evolved without
restarting the MCP subprocess.
"""
"""Stdio MCP server exposing WebSearch/WebFetch; registered only when no Claude credential is available."""
import json
import os
@@ -32,10 +12,7 @@ BACKEND_AUTH = os.environ.get("OPENSWARM_AUTH_TOKEN", "")
SEARCH_URL = f"http://127.0.0.1:{BACKEND_PORT}/api/web/search"
FETCH_URL = f"http://127.0.0.1:{BACKEND_PORT}/api/web/fetch"
# Primary-provider hint set by agent_manager at spawn time. Lets the
# backend pick the corresponding native search tool (googleSearch for
# Gemini, web_search_preview for OpenAI) — so searches come out of the
# budget the user is already paying for.
# Primary-provider hint from agent_manager; backend picks the native search tool (googleSearch/web_search_preview) so searches use the user's existing budget.
PRIMARY_HINT = os.environ.get("OPENSWARM_PRIMARY_API", "") or None
TOOLS = [
+9 -9
View File
@@ -15,7 +15,7 @@ POST /api/auth/signout
identity fields. Brings the user back to the sign-in gate.
POST /api/auth/identity-status {install_id?}
Local proxy to cloud /api/me/identity-status drives the gate's
Local proxy to cloud /api/me/identity-status; drives the gate's
soft-vs-hard decision. Wraps it in our local backend so the renderer
doesn't need to know the cloud URL.
"""
@@ -88,8 +88,8 @@ async def signin_activate(body: SigninActivateRequest):
The bearer-handoff page (cloud lib/authMint.ts → bearerHandoffPage())
POSTs to this endpoint after a Google OAuth or magic-link flow. We
re-validate the bearer with the cloud never just trust whatever
arrives at the localhost endpoint then write user_id + email +
re-validate the bearer with the cloud; never just trust whatever
arrives at the localhost endpoint; then write user_id + email +
signin_method to settings so the renderer can dismiss the gate.
"""
if not body.token or len(body.token) < 16:
@@ -134,7 +134,7 @@ async def signin_activate(body: SigninActivateRequest):
# If the user happens to be a paying customer too (Stripe + sign-in
# share a user row by email), surface plan/expires so the chat picker
# exposes Pro models. Free-tier signups land here with plan="free"
# and expires=null connection_mode stays own_key.
# and expires=null; connection_mode stays own_key.
if isinstance(plan, str) and plan != "free":
settings_obj.connection_mode = "openswarm-pro"
settings_obj.openswarm_bearer_token = body.token
@@ -145,7 +145,7 @@ async def signin_activate(body: SigninActivateRequest):
else:
# Free-tier: still store the bearer so future API calls can identify
# the user (used by /api/me/profile, /api/auth/signout). Do NOT flip
# connection_mode that's reserved for paid plans only so chat
# connection_mode; that's reserved for paid plans only so chat
# routing keeps using own_key/BYO.
settings_obj.openswarm_bearer_token = body.token
settings_obj.openswarm_proxy_url = proxy
@@ -199,7 +199,7 @@ async def signout():
# the previous identity's Claude account; resuming against the new
# bearer would 404 or 401 because the new account has no record
# of that thread. Wiping it forces the SDK to start a fresh thread
# on next send (transcript replay still works only the SDK's
# on next send (transcript replay still works; only the SDK's
# server-side resume cache is reset).
# Best-effort: failures here shouldn't block the sign-out itself.
try:
@@ -266,10 +266,10 @@ async def identity_status():
"hard_gate": False,
}
# Not signed in defer to cloud for install-age + grace-window math.
# Not signed in; defer to cloud for install-age + grace-window math.
install_id = getattr(settings_obj, "installation_id", None)
if not install_id:
# No install_id yet (very fresh install before first sync) hard gate.
# No install_id yet (very fresh install before first sync); hard gate.
return {"authed": False, "hard_gate": True, "install_age_days": 0, "deadline_ts": None}
proxy = _proxy_url()
@@ -290,6 +290,6 @@ async def identity_status():
except httpx.HTTPError as e:
logger.debug("identity-status cloud fetch failed: %s", e)
# Cloud unreachable fail open with soft gate so a flaky network
# Cloud unreachable; fail open with soft gate so a flaky network
# doesn't lock the user out. Renderer will retry on next mount.
return {"authed": False, "hard_gate": False, "install_age_days": 0, "deadline_ts": None}
+3 -3
View File
@@ -60,7 +60,7 @@ def _migrate_if_needed():
if existing:
return
logger.info("No dashboards found running one-time migration")
logger.info("No dashboards found; running one-time migration")
layout = DashboardLayout()
if os.path.exists(OLD_LAYOUT_FILE):
@@ -203,7 +203,7 @@ async def seed_orchestration_demo(dashboard_id: str):
agent for the user to attach to a new orchestrator. We seed a single
completed-looking session that pretends to have done research on
OpenSwarm, with messages mentioning what it found. The user then
drags it into a new agent and asks for a PDF report which
drags it into a new agent and asks for a PDF report; which
delegates back to this seeded agent.
"""
_load(dashboard_id) # validate dashboard exists
@@ -255,7 +255,7 @@ async def seed_orchestration_demo(dashboard_id: str):
"- A Hono cloud service handles auth, billing, and account pooling.\n"
"- Built-in browser cards let agents drive web pages directly.\n"
"- Skills and Apps let users teach the system new capabilities.\n\n"
"Ready when you are let me know what you'd like to do with this."
"Ready when you are; let me know what you'd like to do with this."
),
"timestamp": now.isoformat(),
"branch_id": "main",
+2 -2
View File
@@ -228,7 +228,7 @@ def _call(
locally so the user gets a clear error instead of an opaque 401.
"""
if not INSTALL_ID:
return 0, "OPENSWARM_INSTALL_ID env var not set cannot call Discord proxy"
return 0, "OPENSWARM_INSTALL_ID env var not set; cannot call Discord proxy"
url = f"{PROXY_BASE}/api/discord{path}"
if query:
@@ -282,7 +282,7 @@ def _check_guild(guild_id: str) -> str | None:
The set is sourced from OPENSWARM_DISCORD_GUILD_IDS env var (CSV) which
tools_lib.py populates from the tool's oauth_tokens.guilds. If the env
var is empty (no guild authorization yet), allow all agent shouldn't
var is empty (no guild authorization yet), allow all; agent shouldn't
be able to spawn this MCP without an OAuth flow having happened.
"""
if not ALLOWED_GUILDS:
+52 -141
View File
@@ -30,13 +30,13 @@ NINE_ROUTER_V1 = f"{NINE_ROUTER_URL}/v1"
# cross-provider WebSearch: the CLI's WebSearch call from Codex/Gemini
# primaries used to route cleanly through 9Router's translator and hit
# Anthropic's server-side web_search (returning real results), but later
# translator changes broke that path non-Claude primaries now see
# translator changes broke that path; non-Claude primaries now see
# "claude-haiku-4-5-20251001 unavailable" or hallucinated output.
# Pinning to 0.3.60 restores v1.0.25 behavior.
#
# Note: 0.3.60-0.4.20 ALL emit `max_tokens` (not max_completion_tokens)
# when translating Anthropic→OpenAI, which OpenAI's GPT-5 family rejects.
# The fix lives in our /api/openai-passthrough proxy see openai_passthrough.py
# The fix lives in our /api/openai-passthrough proxy; see openai_passthrough.py
# and sync_openai_api_key for how the translation lane is rerouted via an
# `openai-compatible` provider-node that honors `baseUrl`.
NINE_ROUTER_NPM_VERSION = "0.3.60"
@@ -75,16 +75,12 @@ def _find_9router_dir() -> str | None:
_is_packaged = os.environ.get("OPENSWARM_PACKAGED") == "1"
if _is_packaged:
# Packaged Electron app — router is in extraResources
import sys
# In packaged mode, backend is at <resources>/backend/
# So router is at <resources>/router/
_resources = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
_candidate = os.path.join(_resources, "router")
if os.path.isdir(_candidate):
return _candidate
else:
# Dev mode — router is at project root
_backend_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
_project_root = os.path.dirname(_backend_dir)
_candidate = os.path.join(_project_root, "router")
@@ -103,7 +99,7 @@ def _gpt5_patch_path() -> str | None:
every gpt-5* own-key session 400's because OpenAI rejects the legacy
field name and 9router (every version including 0.4.20) emits it.
Returns None if the file is missing `subprocess.Popen` would fail
Returns None if the file is missing; `subprocess.Popen` would fail
on `node --require <missing-path>`, so the caller drops the flag and
spawns 9router unpatched (failure mode = identical to pre-patch
baseline; GPT-5 still 400's but everything else works).
@@ -121,14 +117,14 @@ def _find_node() -> str | None:
"""Find a Node.js binary (works in both dev and packaged mode).
Priority order:
1. OPENSWARM_NODE_PATH set by electron/main.js when a real Node
1. OPENSWARM_NODE_PATH; set by electron/main.js when a real Node
binary is bundled in extraResources. Always preferred on user
machines because it (a) avoids the bouncing "exec" Dock icon
that ELECTRON_RUN_AS_NODE produces on fresh Macs and (b) starts
in ~50ms vs Electron-as-Node's 515s cold-start, shrinking the
in ~50ms vs Electron-as-Node's 5, 15s cold-start, shrinking the
splash window the user stares at.
2. System `node` on PATH dev convenience.
3. ELECTRON_RUN_AS_NODE fallback last resort. Only hits this on
2. System `node` on PATH; dev convenience.
3. ELECTRON_RUN_AS_NODE fallback; last resort. Only hits this on
packaged builds that for some reason shipped without the bundled
node payload.
"""
@@ -163,7 +159,7 @@ def _ensure_router_cached() -> str | None:
"""Ensure the npm 9router package is installed in the dev cache.
Returns the absolute path to `app/server.js` on success, or None if
npm isn't available or the install fails. Idempotent returns
npm isn't available or the install fails. Idempotent; returns
immediately when the server file already exists.
Running `node app/server.js` directly (instead of `npx 9router`)
@@ -178,7 +174,7 @@ def _ensure_router_cached() -> str | None:
npm = shutil.which("npm")
if not npm:
logger.warning("npm not found install Node.js to auto-start 9Router in dev.")
logger.warning("npm not found; install Node.js to auto-start 9Router in dev.")
return None
try:
@@ -242,7 +238,7 @@ async def ensure_running():
_9router_dir = _find_9router_dir()
if _is_packaged and _9router_dir:
# Packaged mode run the pre-built standalone server staged at
# Packaged mode; run the pre-built standalone server staged at
# <resources>/router/server.js by scripts/fetch-router.sh at build time.
standalone_server = os.path.join(_9router_dir, "server.js")
if not os.path.exists(standalone_server):
@@ -253,7 +249,7 @@ async def ensure_running():
node = _find_node()
if not node:
logger.warning("Node.js not found cannot start 9Router in packaged mode.")
logger.warning("Node.js not found; cannot start 9Router in packaged mode.")
return
logger.info("Starting 9Router (production) on port %d...", NINE_ROUTER_PORT)
@@ -268,7 +264,7 @@ async def ensure_running():
env["ELECTRON_RUN_AS_NODE"] = "1"
else:
# Dev mode install the pinned 9router npm package into a local
# Dev mode; install the pinned 9router npm package into a local
# cache the first time run.sh boots, then spawn `node app/server.js`
# directly on subsequent launches. Bypassing the package's cli.js
# avoids its menu-bar tray icon (which users confusingly quit,
@@ -280,7 +276,7 @@ async def ensure_running():
node = _find_node()
if not node:
logger.warning("Node.js not found cannot start 9Router in dev mode.")
logger.warning("Node.js not found; cannot start 9Router in dev mode.")
return
logger.info(
@@ -298,7 +294,7 @@ async def ensure_running():
# By default, 9Router's stdout/stderr go to /dev/null (Next.js dev mode
# is extremely chatty and floods the openswarm console otherwise). When
# debugging is needed, set OPENSWARM_DEBUG_9ROUTER=1 in the environment
# before launching the backend output will then be appended to
# before launching the backend; output will then be appended to
# backend/data/9router.log line-buffered, which can be `tail -f`'d.
if os.environ.get("OPENSWARM_DEBUG_9ROUTER"):
_log_path = os.path.join(
@@ -323,7 +319,6 @@ async def ensure_running():
env=env,
)
# Wait up to 30 seconds for startup (production standalone is faster)
timeout = 20 if _is_packaged else 30
for _ in range(timeout * 2):
await asyncio.sleep(0.5)
@@ -352,10 +347,6 @@ def stop():
logger.info("9Router stopped")
# ---------------------------------------------------------------------------
# API proxy helpers — call 9Router's API from OpenSwarm
# ---------------------------------------------------------------------------
async def get_usage_stats(period: str = "all") -> dict | None:
"""Get usage statistics from 9Router."""
try:
@@ -378,8 +369,8 @@ async def get_latest_reasoning_tokens(model_hint: str | None = None) -> int | No
in reverse chronological order with full token breakdowns including
`reasoning_tokens` (OpenAI's `completion_tokens_details.reasoning_tokens`)
and `thoughtsTokenCount` (Gemini's). For Anthropic via 9Router this
field will be absent/zero Anthropic doesn't break out reasoning
tokens in its API response so callers get None and should fall
field will be absent/zero; Anthropic doesn't break out reasoning
tokens in its API response; so callers get None and should fall
back to the heuristic.
"""
if not is_running():
@@ -393,9 +384,6 @@ async def get_latest_reasoning_tokens(model_hint: str | None = None) -> int | No
if r.status_code != 200:
return None
data = r.json()
# Endpoint returns either {requests: [...]} or {data: [...]} —
# be defensive about the shape since 9Router has rolled out
# multiple variants.
requests = data.get("requests") or data.get("data") or []
for req in requests:
tokens = req.get("tokens") or req.get("usage") or {}
@@ -415,7 +403,7 @@ async def get_latest_reasoning_tokens(model_hint: str | None = None) -> int | No
async def get_providers() -> list[dict]:
"""Get all providers and their connection status from 9Router.
9Router's GET /api/providers returns `{"connections": [...]}` we
9Router's GET /api/providers returns `{"connections": [...]}`; we
unwrap so callers always see a plain list of connection dicts.
"""
try:
@@ -432,21 +420,11 @@ async def get_providers() -> list[dict]:
return []
# ---------------------------------------------------------------------------
# API-key connection sync (Gemini AI Studio, etc.)
# ---------------------------------------------------------------------------
#
# 9Router supports both OAuth (e.g. gemini-cli) and direct API-key auth
# (provider="gemini", authType="apikey"). The two hit different Google
# quotas — OAuth uses the Code Assist free tier which is aggressively
# rate-limited (429s on Gemini 3 Pro/Flash even for paid-subscription
# users), while an AI Studio API key uses the generativelanguage.googleapis.com
# quota which is independent and far higher.
#
# We expose `google_api_key` in settings; this helper mirrors it into
# 9Router's provider-connections list so the API-key path is preferred
# when a key is set. On removal, we delete the key-based connection so
# 9Router falls back to whatever OAuth connection the user still has.
# API-key auth (provider="gemini", authType="apikey") and OAuth hit different
# Google quotas: OAuth uses the Code Assist free tier (aggressively rate-limited;
# 429s on Gemini 3 Pro/Flash even for paid users), while an AI Studio API key
# uses generativelanguage.googleapis.com (independent and far higher). We mirror
# google_api_key into 9Router so the API-key path is preferred when a key is set.
NINE_ROUTER_KEYED_NAME = "AI Studio (OpenSwarm-managed)"
NINE_ROUTER_OPENAI_KEYED_NAME = "OpenAI (OpenSwarm-managed)"
@@ -533,7 +511,7 @@ async def sync_openai_api_key(api_key: str | None) -> None:
`baseUrl` field on the connection. Only the `openai-compatible-*`
provider-node type honors `baseUrl` (verified statically against
9Router's compiled bundle). So we register our OpenAI lane AS an
openai-compatible node same upstream protocol, different routing.
openai-compatible node; same upstream protocol, different routing.
Why we route through openai-passthrough at all: OpenAI's GPT-5 family
rejects the legacy `max_tokens` parameter with HTTP 400, but every
@@ -565,7 +543,6 @@ async def _sync_openai_compat_node(api_key: str | None) -> None:
base_url = f"http://127.0.0.1:{port}/api/openai-passthrough/v1"
managed_name = f"OpenAI{NINE_ROUTER_CUSTOM_NAME_SUFFIX}"
# List existing managed nodes — we own the prefix `cp-openai`.
try:
async with httpx.AsyncClient(timeout=5.0) as client:
r = await client.get(f"{NINE_ROUTER_API}/provider-nodes")
@@ -578,7 +555,6 @@ async def _sync_openai_compat_node(api_key: str | None) -> None:
None,
)
# Tear down when api_key is cleared.
if not api_key:
if existing_node:
try:
@@ -623,7 +599,6 @@ async def _sync_openai_compat_node(api_key: str | None) -> None:
logger.warning(f"9Router OpenAI compat node sync failed: {e}")
return
# Connection record carrying the api key, scoped to this provider node.
try:
existing_conn = await _find_keyed_connection(node_id, managed_name)
conn_payload = {
@@ -657,19 +632,10 @@ async def sync_openrouter_api_key(api_key: str | None) -> None:
)
# ---------------------------------------------------------------------------
# Custom OpenAI-compatible providers (Ollama Cloud, Together AI, local Ollama, etc.)
# ---------------------------------------------------------------------------
#
# 9Router supports arbitrary OpenAI-compatible endpoints via "provider nodes"
# (POST /api/provider-nodes with type="openai-compatible"). Each node gets a
# unique provider id like `openai-compatible-chat-<rand>` and a user-defined
# `prefix`. At request time, model_id `<prefix>/<bare_model>` routes to that
# node's baseUrl, with auth from a connection of the node's provider type.
#
# We mirror settings.custom_providers[] into 9Router with prefix `cp-<slug>`,
# letting us address each provider as `cp-<slug>/<model_id>` without colliding
# with the user's primary OpenAI key (different provider type).
# 9Router exposes arbitrary OpenAI-compatible endpoints via "provider nodes"
# (POST /api/provider-nodes, type="openai-compatible"). model_id <prefix>/<model>
# routes to that node's baseUrl. We mirror settings.custom_providers[] with
# prefix `cp-<slug>` so they don't collide with the user's primary OpenAI key.
NINE_ROUTER_CUSTOM_NAME_SUFFIX = " (OpenSwarm-managed)"
@@ -711,19 +677,14 @@ async def sync_custom_providers(providers: list) -> None:
seen_prefixes: set[str] = set()
for cp in providers or []:
# Tolerate both Pydantic instances and plain dicts.
name = getattr(cp, "name", None) or (cp.get("name") if isinstance(cp, dict) else None) or ""
base_url = getattr(cp, "base_url", None) or (cp.get("base_url") if isinstance(cp, dict) else None) or ""
api_key = getattr(cp, "api_key", None) or (cp.get("api_key") if isinstance(cp, dict) else None) or ""
if not name.strip() or not base_url.strip():
continue
# Local OpenAI-compatible servers (LM Studio, Ollama, vLLM, llama.cpp,
# text-generation-webui, etc.) ship with auth disabled by default —
# they ignore the Authorization header entirely. But 9Router still
# creates the connection with `authType: "apikey"` and would send a
# blank Bearer if api_key is "", which some servers reject as a
# malformed header. Substitute a harmless placeholder when blank;
# servers that DO require auth always have api_key set anyway.
# Local OpenAI-compat servers (LM Studio, Ollama, etc.) reject a blank
# Bearer header even with auth disabled. Substitute a placeholder; real
# auth deployments always have api_key set.
api_key = api_key.strip() or "no-auth-required"
slug = _custom_provider_slug(name)
prefix = f"cp-{slug}"
@@ -765,7 +726,6 @@ async def sync_custom_providers(providers: list) -> None:
logger.warning(f"9Router custom node {prefix} sync failed: {e}")
continue
# Ensure a connection exists for this provider node carrying the apikey.
try:
existing_conn = await _find_keyed_connection(node_id, managed_name)
conn_payload = {
@@ -793,8 +753,7 @@ async def sync_custom_providers(providers: list) -> None:
except Exception as e:
logger.warning(f"9Router custom connection {prefix} sync failed: {e}")
# Drop managed nodes that no longer correspond to any settings entry.
# DELETE on a node cascades to its connections.
# Drop managed nodes no longer in settings; DELETE cascades to connections.
for prefix, node in managed_by_prefix.items():
if prefix in seen_prefixes:
continue
@@ -818,13 +777,13 @@ async def sync_openswarm_pro_as_claude(bearer_token: str | None, proxy_url: str
path for openswarm-pro users, so the search fails with
"no credentials for provider: claude". With this sync, 9Router sees
the OpenSwarm-Pro-backed Claude connection and routes the search
call through our cloud same quota the user's Pro subscription
call through our cloud; same quota the user's Pro subscription
already covers, no extra cost."""
if not is_running():
return
# 9Router's POST /api/providers only accepts direct-API provider ids
# for apikey auth `claude` is the subscription/IDE id, `anthropic`
# for apikey auth; `claude` is the subscription/IDE id, `anthropic`
# is the direct-API id. Use `anthropic`.
existing = await _find_keyed_connection("anthropic", NINE_ROUTER_CLAUDE_PRO_NAME)
try:
@@ -865,31 +824,15 @@ async def sync_openswarm_pro_as_claude(bearer_token: str | None, proxy_url: str
logger.warning(f"9Router OpenSwarm-Pro Claude sync failed: {e}")
# ---------------------------------------------------------------------------
# Per-provider OAuth redirect URIs
# ---------------------------------------------------------------------------
#
# Each upstream OAuth client is registered with the identity provider against
# a specific redirect URI. Anthropic's Claude Code client is lenient — any
# `http://localhost:*/callback` works — so we can use 9Router's built-in
# callback page at port 20128 for it. OpenAI's Codex client is NOT: it's
# registered with `http://localhost:1455/auth/callback` and OpenAI rejects
# any other redirect_uri with `unknown_error` at the auth page. Google's
# Gemini CLI client accepts arbitrary localhost URIs so we keep 20128 there.
#
# For Codex specifically we spawn a one-shot HTTP listener on port 1455
# below that serves a callback page mirroring 9Router's callback page —
# postMessage to window.opener, BroadcastChannel fan-out, then close. This
# lets the frontend reuse its existing Claude/Anthropic flow unchanged
# (window.open popup + postMessage handler in Settings.tsx).
# OpenAI's Codex OAuth client is registered with a fixed redirect URI
# `http://localhost:1455/auth/callback` and rejects any other with `unknown_error`.
# Anthropic and Google's clients accept arbitrary localhost callbacks (we use
# 9Router's 20128 callback page). For Codex we spawn a one-shot listener on
# 1455 that serves the same postMessage/BroadcastChannel/localStorage relay so
# the frontend's existing popup + msgHandler flow works unchanged.
_CODEX_CALLBACK_PORT = 1455
_CODEX_CALLBACK_PATH = "/auth/callback"
# Minimal callback page inlined as bytes. Mirrors 9router/src/app/callback/
# page.js:27-55 — posts the OAuth data to window.opener via postMessage,
# BroadcastChannel, and localStorage so whatever detection path the caller
# is using will fire. Served to the Electron popup that OAuth redirects to.
_CODEX_CALLBACK_HTML = b"""<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>Authorization Complete</title>
<style>body{font-family:-apple-system,system-ui,sans-serif;background:#111;color:#eee;
@@ -930,7 +873,7 @@ async def _start_codex_callback_listener(timeout: float = 300.0) -> asyncio.base
Serves GET /auth/callback with _CODEX_CALLBACK_HTML. After serving the
callback (or after `timeout` seconds with no callback) the listener
closes itself in a background task. Safe to call even if 1455 is busy
closes itself in a background task. Safe to call even if 1455 is busy ,
logs the collision and returns None so start_oauth can still proceed and
surface whatever error OpenAI returns.
@@ -940,7 +883,7 @@ async def _start_codex_callback_listener(timeout: float = 300.0) -> asyncio.base
stuck on "Connecting…" until the 30s timeout fires. Exchanging here
(the same pattern backend/main.py uses for the Gemini callback) makes
the connection land in 9Router's DB regardless of whether the UI's
postMessage listener ever gets notified the Settings / OnboardingModal
postMessage listener ever gets notified; the Settings / OnboardingModal
status pollers then pick it up within a couple seconds.
"""
@@ -951,7 +894,6 @@ async def _start_codex_callback_listener(timeout: float = 300.0) -> asyncio.base
# Read the request line ("GET /auth/callback?... HTTP/1.1\r\n")
raw_request_line = await asyncio.wait_for(reader.readline(), timeout=5.0)
request_line = raw_request_line.decode("latin-1", errors="replace").strip()
# Drain headers so the browser's request is fully consumed
while True:
line = await asyncio.wait_for(reader.readline(), timeout=5.0)
if not line or line in (b"\r\n", b"\n"):
@@ -1024,7 +966,6 @@ async def _start_codex_callback_listener(timeout: float = 300.0) -> asyncio.base
await writer.drain()
callback_served.set()
else:
# Unrelated request (favicon, preflight) — 404 and move on
writer.write(
b"HTTP/1.1 404 Not Found\r\n"
b"Content-Length: 0\r\n"
@@ -1043,7 +984,7 @@ async def _start_codex_callback_listener(timeout: float = 300.0) -> asyncio.base
try:
server = await asyncio.start_server(_handle, "127.0.0.1", _CODEX_CALLBACK_PORT)
except OSError as e:
# Port already in use probably another Codex connect attempt still
# Port already in use; probably another Codex connect attempt still
# running, or an actual Codex CLI process holding 1455. Log and bail.
logger.warning(
f"Could not start Codex callback listener on port {_CODEX_CALLBACK_PORT}: {e}. "
@@ -1074,38 +1015,15 @@ async def _start_codex_callback_listener(timeout: float = 300.0) -> asyncio.base
return server
# Providers that cannot use the in-Electron `window.open` popup flow and
# must be opened in the user's system browser instead.
#
# Google enforces an "Embedded WebView Restrictions" policy on its OAuth
# consent pages that uses JS-based fingerprinting, not just user-agent
# sniffing. We tried defeating it with a combination of Chrome UA spoof +
# sandboxed webPreferences + fresh session partition + a preload script
# that patches navigator.webdriver/plugins/mimeTypes/languages/chrome and
# overrides navigator.permissions.query — it was still rejected. Google's
# detection is a moving target and actively adversarial. The supported
# workaround (and what Google recommends for Desktop app OAuth) is to run
# the flow in the user's real browser via shell.openExternal.
#
# When a provider is in this set the frontend calls
# window.openswarm.openExternal (shell.openExternal) instead of
# window.open, and the callback lands on OpenSwarm's own
# /api/subscriptions/callback endpoint (backend/main.py:138) which
# exchanges the code and serves a "Connected!" page. Detection on the
# OpenSwarm side happens via the existing status poller on the
# Settings page.
# Providers that hand off to the user's default browser instead of using
# our embedded Electron popup:
# - gemini-cli, antigravity: Google blocks embedded browsers wholesale
# ("Your browser is not supported anymore") — no UA spoof defeats it.
# - codex: OpenAI's auth.openai.com renders blank inside our popup on
# some users' machines (likely a mix of newer embed detection and
# regional access checks) and the system browser surfaces the real
# error rather than a silent blank window. Also what RFC 8252 mandates
# for native-app OAuth, so this is the correct long-term shape anyway.
# Codex's callback URL is special-cased below to stay on localhost:1455
# (OpenAI's hardcoded redirect URI) — the listener catches the system
# browser's redirect just like it caught the popup's.
# Providers whose OAuth flow MUST run in the user's real browser via
# shell.openExternal, not the in-Electron window.open popup:
# - gemini-cli, antigravity: Google's Embedded WebView Restrictions policy uses
# JS-fingerprint detection that no UA spoof defeats. RFC 8252 and Google's
# own Desktop-app OAuth guidance both prescribe the system browser.
# - codex: auth.openai.com renders blank in our popup on some machines (newer
# embed detection + regional checks); system browser surfaces the real error.
# The callback for gemini-cli/antigravity lands on /api/subscriptions/callback
# and runs the exchange server-side; codex uses its fixed 1455 listener.
_EXTERNAL_BROWSER_PROVIDERS: set[str] = {"gemini-cli", "antigravity", "codex"}
@@ -1133,7 +1051,7 @@ def _callback_uri_for_provider(provider: str) -> str:
Most providers accept 9Router's built-in callback page at port 20128.
Two special cases:
- Codex/OpenAI's OAuth client is bound to a fixed
http://localhost:1455/auth/callback URI handled by
http://localhost:1455/auth/callback URI; handled by
_start_codex_callback_listener above.
- Gemini/Google's OAuth consent page rejects embedded browsers, so we
route the callback through OpenSwarm's backend endpoint at
@@ -1155,7 +1073,6 @@ async def start_oauth(provider: str) -> dict:
For authorization_code providers (claude, codex, gemini-cli): returns {authUrl, codeVerifier, state}
"""
async with httpx.AsyncClient(timeout=15.0) as client:
# Try device-code flow first
try:
r = await client.get(f"{NINE_ROUTER_API}/oauth/{provider}/device-code")
if r.status_code == 200:
@@ -1171,12 +1088,6 @@ async def start_oauth(provider: str) -> dict:
except Exception:
pass
# Authorization code flow. Most providers accept 9Router's own
# callback page at port 20128, but Codex's OAuth client is bound
# to a fixed http://localhost:1455/auth/callback URI — spawn an
# in-process listener on that port before returning the auth URL,
# so the popup can redirect there after login and relay the code
# back to the frontend via postMessage (same flow as Claude).
callback_url = _callback_uri_for_provider(provider)
if provider == "codex":
await _start_codex_callback_listener()
+10 -10
View File
@@ -15,7 +15,7 @@ TIMEOUT_SECONDS = 30
# can find ways around this (e.g. string-encoded imports via tricks the AST
# validator can't see), but the allowlist kills the easy paths cheaply and
# pairs with cwd=tempdir + minimal env so the blast radius is small even if
# a payload slips past. Keep this list to "data shaping" libraries no I/O,
# a payload slips past. Keep this list to "data shaping" libraries; no I/O,
# no networking, no subprocess.
_ALLOWED_MODULES = frozenset({
"json", "math", "re", "datetime", "collections", "itertools",
@@ -43,9 +43,9 @@ def get_code_warnings(code: str) -> list[str]:
"""Return human-readable warnings for AST-visible risks, without raising.
Used by `/api/outputs/execute` to surface risks to the user in the run
dialog before executing so a legit Output that needs `pandas` doesn't
dialog before executing; so a legit Output that needs `pandas` doesn't
silently 500 with "import not allowed," it gets a "this Output uses
unsafe imports review and click Run Anyway" affordance.
unsafe imports; review and click Run Anyway" affordance.
Returns [] for code that's fully inside the allowlist. A syntax error
is reported as a single warning rather than raised so the dialog can
@@ -97,7 +97,7 @@ def _validate_code_safety(code: str) -> None:
# Env vars we always scrub from the subprocess, regardless of strict-vs-force.
# These are the keys an attacker would actually want install token, provider
# These are the keys an attacker would actually want; install token, provider
# API keys, cloud credentials. Everything else is local-machine convenience.
_SCRUBBED_ENV_KEYS = frozenset({
"OPENSWARM_AUTH_TOKEN",
@@ -120,13 +120,13 @@ def _minimal_env(force: bool = False) -> dict:
"""Build the env for the executor subprocess.
Strict mode (force=False): only language essentials. AST-validated code
is data-shaping only `import os` and `open()` are blocked, so the
is data-shaping only; `import os` and `open()` are blocked, so the
subprocess can't read env vars or expand `~` anyway. Minimal env is
correct here.
Force mode (force=True): user has explicitly approved unsafe imports
via the HITL preview. They expect the code to behave like a normal
Python process read HOME, find files, etc. Inherit the real env
Python process; read HOME, find files, etc. Inherit the real env
minus credentials, so an `open(os.path.expanduser("~/data.csv"))`
actually works instead of silently misbehaving.
@@ -166,7 +166,7 @@ async def execute_backend_code(
result to a global ``result`` dict. User print() calls are captured
separately from the result via an in-process StringIO redirect.
Security boundaries (defense in depth none alone is sufficient):
Security boundaries (defense in depth; none alone is sufficient):
1. AST allowlist on imports + blocked-builtin call list.
2. Subprocess cwd = fresh temp dir (not the OpenSwarm process cwd).
3. Subprocess env strips PATH, all *_TOKEN / *_API_KEY inheritance.
@@ -174,9 +174,9 @@ async def execute_backend_code(
to catch AST-bypass tricks (e.g. metaclass shenanigans).
5. 30s wall-clock timeout, killed on overrun.
`skip_validation=True` bypasses #1 intended ONLY for callers that
`skip_validation=True` bypasses #1; intended ONLY for callers that
have already surfaced the warnings to a user and gotten explicit
consent (the `/api/outputs/execute` HITL flow). #2#5 always run.
consent (the `/api/outputs/execute` HITL flow). #2, #5 always run.
"""
if not skip_validation:
@@ -186,7 +186,7 @@ async def execute_backend_code(
"import json, sys, io, builtins\n"
# Defense-in-depth: scrub dangerous attrs off `builtins` so
# attribute-style accesses (metaclass.__subclasses__ chains) can't
# reach them. NOTE: __import__ is deliberately NOT scrubbed
# reach them. NOTE: __import__ is deliberately NOT scrubbed ,
# Python's `import` statement bytecode reads `__import__` from
# builtins, so removing it makes EVERY import (including allowlisted
# ones like `import math`) fail with "ImportError: __import__ not
+25 -25
View File
@@ -27,7 +27,7 @@ SWARM_DEBUG_SKILL_SOURCE_PATH = os.path.join(os.path.dirname(__file__), "swarm_d
# scripts/fetch-webapp-template.sh for the snapshot fetch + patches.
WEBAPP_TEMPLATE_DIR = os.path.join(os.path.dirname(__file__), "webapp_template")
# Bundled default used as the read-once fallback if the user-editable
# Bundled default; used as the read-once fallback if the user-editable
# copy at ~/.claude/skills/app_builder_skill.md has been removed despite
# the built-in flag (defensive; shouldn't happen in normal use).
with open(APP_BUILDER_SKILL_SOURCE_PATH, encoding="utf-8") as _f:
@@ -38,7 +38,7 @@ def load_app_builder_skill() -> str:
"""Return the live App Builder skill content. Prefers the
user-editable copy at ~/.claude/skills/app_builder_skill.md (so a
user's edit on the Skills page takes effect on the very next App
Builder agent turn no restart, no copy-on-edit dance). Falls back
Builder agent turn; no restart, no copy-on-edit dance). Falls back
to the bundled default if the user file is somehow gone."""
user_path = os.path.expanduser("~/.claude/skills/app_builder_skill.md")
if os.path.exists(user_path):
@@ -50,7 +50,7 @@ def load_app_builder_skill() -> str:
return APP_BUILDER_SKILL_DEFAULT
# Backward-compat alias. Older callers import VIEW_BUILDER_SKILL directly
# Backward-compat alias. Older callers import VIEW_BUILDER_SKILL directly ,
# point them at the same content as the user-editable version so a "frozen
# at import" stale copy can't drift from what the skills page shows.
VIEW_BUILDER_SKILL = APP_BUILDER_SKILL_DEFAULT
@@ -127,7 +127,7 @@ VIEW_TEMPLATE_FILES = {
# ---------------------------------------------------------------------------
def _ignore_backend(src: str, names: list[str]) -> list[str]:
"""copytree filter when copying the template root, drop only the
"""copytree filter; when copying the template root, drop only the
top-level `backend/` directory. Subdirectories named `backend` deeper
in the tree (none today, but defensively scoped) are unaffected."""
if os.path.abspath(src) == os.path.abspath(WEBAPP_TEMPLATE_DIR):
@@ -142,13 +142,13 @@ _TEMPLATE_BACKEND_PATH = os.path.abspath(os.path.join(WEBAPP_TEMPLATE_DIR, "back
# ---------------------------------------------------------------------------
# Shared node_modules cache every new webapp-template workspace symlinks
# Shared node_modules cache; every new webapp-template workspace symlinks
# its frontend/node_modules to a single warm directory. First-app create
# pays the ~22s npm-install cost once; every subsequent app is instant
# (just a symlink + vite startup, ~1s).
#
# Cache directory is keyed by a sha of the template's package.json, so a
# template dep bump invalidates the cache automatically old caches sit
# template dep bump invalidates the cache automatically; old caches sit
# until the user clears ~/.openswarm/cache.
# ---------------------------------------------------------------------------
@@ -159,7 +159,7 @@ _warm_cache_thread: threading.Thread | None = None
# Pre-built node_modules archive bundled with packaged releases. Generated
# by `scripts/build-template-archive.sh` and shipped at this path inside
# the app's resources. When present (and tagged with the current
# package.json sha), extract instead of running npm decompression is
# package.json sha), extract instead of running npm; decompression is
# ~3 s vs ~22 s for the live install. Stale archives (package.json bumped
# but archive not rebuilt) are silently ignored, so the live-install
# fallback always wins on correctness.
@@ -191,7 +191,7 @@ def _try_extract_bundled_archive(cache_dir: str, digest: str) -> bool:
)
os.makedirs(cache_dir, exist_ok=True)
# Archive root is `node_modules/`; extracting into cache_dir places
# it at the expected path. tarfile uses zlib internally for .gz
# it at the expected path. tarfile uses zlib internally for .gz ,
# no extra dep needed.
with tarfile.open(archive_path, "r:gz") as tar:
tar.extractall(cache_dir)
@@ -214,7 +214,7 @@ def _try_extract_bundled_archive(cache_dir: str, digest: str) -> bool:
def _warm_cache_digest() -> str:
"""Sha of the template's frontend/package.json used as the cache
"""Sha of the template's frontend/package.json; used as the cache
key + the bundled-archive filename suffix so a package.json bump
invalidates both at once."""
pkg_path = os.path.join(WEBAPP_TEMPLATE_DIR, "frontend", "package.json")
@@ -237,7 +237,7 @@ def _warm_cache_dir() -> str:
def _ensure_warm_cache() -> str | None:
"""Populate the warm-cache node_modules if missing. Returns the
absolute path to the populated `node_modules` directory, or None on
failure. Thread-safe concurrent callers block on a single install
failure. Thread-safe; concurrent callers block on a single install
instead of racing. Idempotent and fast after the first call."""
cache_dir = _warm_cache_dir()
cache_modules = os.path.join(cache_dir, "node_modules")
@@ -259,7 +259,7 @@ def _ensure_warm_cache() -> str | None:
os.makedirs(cache_dir, exist_ok=True)
# Copy package.json + lockfile (if it exists) into the cache
# dir so npm has something to install from. We don't write
# back to the template the lockfile generated here stays
# back to the template; the lockfile generated here stays
# local to the cache.
tmpl_pkg = os.path.join(WEBAPP_TEMPLATE_DIR, "frontend", "package.json")
tmpl_lock = os.path.join(WEBAPP_TEMPLATE_DIR, "frontend", "package-lock.json")
@@ -269,7 +269,7 @@ def _ensure_warm_cache() -> str | None:
shutil.copyfile(tmpl_lock, os.path.join(cache_dir, "package-lock.json"))
cmd = ["npm", "ci", *base_flags]
else:
# No lockfile yet `npm install` resolves the tree and
# No lockfile yet; `npm install` resolves the tree and
# writes one into the cache dir for future use.
cmd = ["npm", "install", *base_flags]
logger.info("webapp-template: warming node_modules cache at %s", cache_dir)
@@ -291,7 +291,7 @@ def _ensure_warm_cache() -> str | None:
def _link_node_modules(workspace_dir: str) -> None:
"""After copytree, point the workspace's frontend/node_modules at
the warm-cache directory. Safe fallback if the cache isn't ready,
the warm-cache directory. Safe fallback; if the cache isn't ready,
the workspace's run.sh will fall through to its own install path."""
cache_modules = _ensure_warm_cache()
if not cache_modules:
@@ -309,9 +309,9 @@ def _link_node_modules(workspace_dir: str) -> None:
return
elif os.path.isdir(target):
# If the dir is EMPTY (left over from copytree of the template's
# placeholder node_modules `.gitkeep`-style scenarios) nuke it
# placeholder node_modules; `.gitkeep`-style scenarios) nuke it
# so we can symlink to the warm cache. A non-empty directory is
# treated as a real npm install respect it and bail.
# treated as a real npm install; respect it and bail.
try:
has_content = any(True for _ in os.scandir(target))
except OSError:
@@ -331,7 +331,7 @@ def _link_node_modules(workspace_dir: str) -> None:
# ---------------------------------------------------------------------------
# Shared Python venv cache same pattern as the node_modules cache, but
# Shared Python venv cache; same pattern as the node_modules cache, but
# for the workspace backend's FastAPI + transitive deps. Eliminates the
# ~25s `python -m venv` + `pip install -e .` that backend_init.sh
# otherwise pays per workspace.
@@ -358,7 +358,7 @@ def _warm_venv_dir() -> str:
def _ensure_warm_python_venv() -> str | None:
"""Populate the warm-cache backend venv if missing. Returns the
absolute path to the populated `.venv` directory, or None on
failure. Thread-safe and idempotent fast return after first call."""
failure. Thread-safe and idempotent; fast return after first call."""
cache_dir = _warm_venv_dir()
venv_dir = os.path.join(cache_dir, ".venv")
sentinel = os.path.join(cache_dir, ".populated")
@@ -374,7 +374,7 @@ def _ensure_warm_python_venv() -> str | None:
# Pick the same python the workspace's run.sh would have
# picked, so the venv's binary is compatible. Includes
# bare `python` as the last fallback for Windows, where
# there's no `python3` symlink the installer ships just
# there's no `python3` symlink; the installer ships just
# `python.exe`. On macOS/Linux the versioned candidates
# match first so we don't accidentally pick a system
# Python 2.x via the bare name.
@@ -405,7 +405,7 @@ def _ensure_warm_python_venv() -> str | None:
return None
# Install the template's dependencies (fastapi[standard],
# typeguard, transitives) NOT the workspace's own backend,
# typeguard, transitives); NOT the workspace's own backend,
# which gets editable-installed per-workspace by run.sh after
# the cache copy. The venv layout differs by platform:
# POSIX puts executables in `bin/`, Windows in `Scripts/`,
@@ -461,7 +461,7 @@ def warm_cache_in_background() -> None:
_warm_cache_thread.start()
# Trigger pre-warm on module import backend startup hits this and the
# Trigger pre-warm on module import; backend startup hits this and the
# installs run in parallel with the rest of the boot. By the time the
# user creates their first app, node_modules + the backend venv are
# usually ready.
@@ -495,10 +495,10 @@ def seed_webapp_template_workspace(workspace_dir: str, frontend_port: int) -> No
1. Copy `.env.example` → `.env` verbatim (preserves the upstream
defaults `FRONTEND_PORT=4949` and `BACKEND_PORT=NONE`).
2. Sed both `.env` and `.env.example` to set `FRONTEND_PORT=<port>`.
BACKEND_PORT stays NONE in both (per spec the agent flips it
BACKEND_PORT stays NONE in both (per spec; the agent flips it
via backend_init.sh when it needs a backend).
3. Append two install-specific paths to `.env` ONLY (NOT
`.env.example` these are absolute paths on the current
`.env.example`; these are absolute paths on the current
machine, not template defaults):
OPENSWARM_TEMPLATE_BACKEND_PATH=<abs path to master template's backend/>
OPENSWARM_DEBUGGER_PATH=<abs path to OpenSwarm's debugger/ package>
@@ -506,7 +506,7 @@ def seed_webapp_template_workspace(workspace_dir: str, frontend_port: int) -> No
the template's `backend/run.sh` to install our local debugger
before `pip install -e .`.
Idempotent within reason re-running over an existing workspace
Idempotent within reason; re-running over an existing workspace
overwrites template files and re-asserts the env values.
"""
os.makedirs(workspace_dir, exist_ok=True)
@@ -528,10 +528,10 @@ def seed_webapp_template_workspace(workspace_dir: str, frontend_port: int) -> No
_patch_env_port(env_path, "FRONTEND_PORT", str(frontend_port))
_patch_env_port(env_example_path, "FRONTEND_PORT", str(frontend_port))
# Install-specific paths .env only.
# Install-specific paths; .env only.
_patch_env_port(env_path, "OPENSWARM_TEMPLATE_BACKEND_PATH", _TEMPLATE_BACKEND_PATH)
_patch_env_port(env_path, "OPENSWARM_DEBUGGER_PATH", _DEBUGGER_PATH)
# Backend-venv warm-cache path backend_init.sh checks this for a
# Backend-venv warm-cache path; backend_init.sh checks this for a
# pre-populated `.venv/` to cp -aR into the workspace instead of
# paying the ~25s venv-create + pip-install cost. Written even if
# the cache isn't ready yet; backend_init.sh re-checks at run time.
@@ -3,7 +3,7 @@
#
# Idempotent. The workspace is seeded frontend-only (no backend/ dir,
# BACKEND_PORT=NONE). Run this script when your App needs server-side
# code it copies the master template's backend/ into the workspace
# code; it copies the master template's backend/ into the workspace
# and flips BACKEND_PORT in both .env files to a free port.
#
# After running this, hard-reload the preview (right-click the reload
@@ -15,7 +15,7 @@ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$HERE"
if [[ ! -f .env ]]; then
echo "ERROR: .env not found at $HERE — is this the workspace root?" >&2
echo "ERROR: .env not found at $HERE. Is this the workspace root?" >&2
exit 1
fi
@@ -26,12 +26,12 @@ source .env
set +a
if [[ "${BACKEND_PORT:-NONE}" != "NONE" ]]; then
echo "Backend already enabled on port $BACKEND_PORT nothing to do." >&2
echo "Backend already enabled on port $BACKEND_PORT, nothing to do." >&2
exit 0
fi
if [[ -d ./backend ]]; then
echo "ERROR: ./backend/ already exists but BACKEND_PORT=NONE your" >&2
echo "ERROR: ./backend/ already exists but BACKEND_PORT=NONE; your" >&2
echo " workspace is in an inconsistent state. Either delete" >&2
echo " ./backend/ and re-run, or set BACKEND_PORT manually." >&2
exit 1
@@ -56,7 +56,7 @@ echo "Copying backend/ from $OPENSWARM_TEMPLATE_BACKEND_PATH..."
cp -R "$OPENSWARM_TEMPLATE_BACKEND_PATH" ./backend
chmod +x ./backend/run.sh
# Reuse the warm-cache backend venv if available this skips the
# Reuse the warm-cache backend venv if available; this skips the
# ~5s venv-create + ~20s pip-install in the workspace's backend/run.sh.
# The cache holds FastAPI + transitives pre-installed; the workspace's
# own editable install (`pip install -e .`) still runs once on first
+5 -5
View File
@@ -3,8 +3,8 @@
When the desktop is offline (laptop closed, no internet, cloud unreachable),
the service-sync layer can't reach `api.openswarm.com`. Rather than drop
data on the floor, we spool submissions to a small SQLite file and replay
them on the next online tick. The spool is bounded when full, the oldest
entries are dropped so it can never balloon to a problem.
them on the next online tick. The spool is bounded; when full, the oldest
entries are dropped; so it can never balloon to a problem.
Single file, single table, single thread guarded by a sqlite3 connection's
implicit lock. No concurrency model beyond "don't write from two processes
@@ -24,7 +24,7 @@ from typing import Iterator, Optional
logger = logging.getLogger(__name__)
# Cap the spool at 50 MB on disk. SQLite's overhead means the actual ceiling
# on retained payloads is somewhat smaller, which is fine this is a
# on retained payloads is somewhat smaller, which is fine; this is a
# best-effort cushion, not a guaranteed retention window.
_MAX_BYTES = 50 * 1024 * 1024
@@ -63,7 +63,7 @@ def enqueue(spool_path: str, kind: str, payload: dict, *, now: float) -> None:
"INSERT INTO spool (kind, payload, created_at) VALUES (?, ?, ?)",
(kind, body, now),
)
# Cheap size check only run trim when stat says we're over.
# Cheap size check; only run trim when stat says we're over.
try:
size = os.path.getsize(spool_path)
except OSError:
@@ -108,7 +108,7 @@ def drain(spool_path: str, batch_size: int = 50) -> list[tuple[int, str, dict]]:
try:
out.append((rid, kind, json.loads(body)))
except json.JSONDecodeError:
# Corrupt row discard so it doesn't block draining behind it.
# Corrupt row; discard so it doesn't block draining behind it.
with _lock, _conn(spool_path) as c:
c.execute("DELETE FROM spool WHERE id = ?", (rid,))
logger.warning("Dropped corrupt spool row id=%s", rid)
+8 -8
View File
@@ -4,7 +4,7 @@ Single public surface: `submit(kind, payload)`. The desktop hands off
opaque payload dicts; the cloud at api.openswarm.com is responsible for
parsing and routing them. The desktop has no schema knowledge.
Three `kind` values are accepted they're the routing primitive the
Three `kind` values are accepted; they're the routing primitive the
cloud needs to send the payload to the right backend handler. The shape
of `payload` is opaque from the desktop's perspective; the cloud knows
how to read it.
@@ -61,7 +61,7 @@ def _spool_path() -> str:
def set_test_sink(fn: Optional[Any]) -> None:
"""Test seam receives every submission instead of the network."""
"""Test seam; receives every submission instead of the network."""
global _test_sink
_test_sink = fn
@@ -92,7 +92,7 @@ def _get_user_id() -> Optional[str]:
from backend.apps.settings.settings import load_settings
s = load_settings()
# Prefer the cloud-issued user_id (UUID) if the user has signed in
# via Google OAuth, magic link, or Stripe checkout that's the
# via Google OAuth, magic link, or Stripe checkout; that's the
# authoritative identity. Falls back to user_email for installs
# that haven't completed sign-in yet (so existing onboarding-only
# installs don't lose their Person history during the v1.0.29
@@ -164,7 +164,7 @@ def _envelope() -> dict:
except Exception:
pass
# Locale: BCP 47 string ("en-US", "es-ES", etc.) injected by Electron via
# app.getLocale() see electron/main.js. We don't fall back to Python's
# app.getLocale(); see electron/main.js. We don't fall back to Python's
# locale.getdefaultlocale() because that's deprecated, often empty, and
# returns inconsistent OS-specific values across macOS/Windows/Linux.
try:
@@ -265,7 +265,7 @@ def _log(kind: str, payload: dict) -> None:
def sync(data: dict | None = None) -> None:
"""Sync operational state to the cloud. Single entry point.
Accepts any dict the cloud determines what it is from the shape.
Accepts any dict; the cloud determines what it is from the shape.
The desktop has no knowledge of event types, schemas, or routing.
Each call carries:
@@ -295,12 +295,12 @@ def sync(data: dict | None = None) -> None:
_schedule(_post_or_spool(_DEFAULT_SYNC_PATH, body, "s"))
# Internal routing the cloud has one endpoint for everything.
# Internal routing; the cloud has one endpoint for everything.
_DEFAULT_SYNC_PATH = "/api/service/sync"
def submit(kind: str, payload: dict) -> None:
"""Legacy shim routes through sync(). Kept for back-compat during
"""Legacy shim; routes through sync(). Kept for back-compat during
migration. New code should call sync() directly."""
sync(payload)
@@ -379,7 +379,7 @@ def record(
session_id: Optional[str] = None,
dashboard_id: Optional[str] = None,
) -> None:
"""Legacy collector.record() shim splits dotted name into surface/action."""
"""Legacy collector.record() shim; splits dotted name into surface/action."""
if "." in event_type:
surface, action = event_type.split(".", 1)
else:
+1 -7
View File
@@ -1,10 +1,4 @@
"""Fixed-size event log for operational diagnostics.
Maintains a rolling window of the last N app events so support
diagnostics can include context about recent activity. Used by
the error report builder to attach "what just happened" when
something goes wrong.
"""
"""Fixed-size rolling event log for support diagnostics."""
from __future__ import annotations
+9 -9
View File
@@ -33,7 +33,7 @@ logger = logging.getLogger(__name__)
def _read_app_version() -> str:
# Preferred: Electron's main process injects this when spawning the
# backend (see electron/main.js OPENSWARM_APP_VERSION). Always reliable
# backend (see electron/main.js; OPENSWARM_APP_VERSION). Always reliable
# in packaged builds because it comes from app.getVersion() rather than
# path-based file resolution.
env_v = os.environ.get("OPENSWARM_APP_VERSION", "").strip()
@@ -42,7 +42,7 @@ def _read_app_version() -> str:
# Fallback: read electron/package.json via relative path. Works in
# `bash run.sh` dev mode where the repo layout is intact, but FAILS in
# packaged dmg/exe builds because electron/package.json isn't shipped
# into Resources/ which made every shipped install report
# into Resources/; which made every shipped install report
# app_version="unknown" pre-fix. Kept for backward compatibility with
# dev runs and as a safety net if the env var is ever unset.
try:
@@ -120,7 +120,7 @@ async def _pulse_loop():
if _pulse_count >= _pulse_batch_size:
try:
from backend.apps.agents.agent_manager import agent_manager
# Compact field names the wire stays small and the cloud
# Compact field names; the wire stays small and the cloud
# is the only place that knows what each key means.
svc.sync({
"a": len(agent_manager.sessions), # active sessions
@@ -412,26 +412,26 @@ async def service_status():
async def post_submit(body=Body(...)):
"""Accepts three body shapes for backward compatibility:
1. Frontend `report()` shape flat `{s, a, p, submission_id, t}`.
1. Frontend `report()` shape; flat `{s, a, p, submission_id, t}`.
This is what `frontend/src/shared/serviceClient.ts:report()` sends
on every UI interaction. Pass through unchanged so the cloud sees
it as a frontend.event.
2. Legacy `{kind, payload}` shape used by older callers that wrapped
2. Legacy `{kind, payload}` shape; used by older callers that wrapped
the payload in a kind+payload envelope before submitting. Unwrap
and forward the payload.
3. Batched array frontend collects up to 1s of events and sends them
3. Batched array; frontend collects up to 1s of events and sends them
as a single JSON array to cut N POSTs/sec down to 1. Each item is
processed exactly as if it had arrived as its own request.
Pre-fix this endpoint required shape #2 and silently rejected shape #1
with a 200 + `{ok:false}`, so every UI event from `report()` was
dropped `frontend.event` count was 0 in production analytics.
dropped; `frontend.event` count was 0 in production analytics.
"""
# Shape 3: batched array. Recurse per-item so single-item handling
# logic stays in one place. Returns a single ok regardless of
# individual item shape analytics calls aren't transactional.
# individual item shape; analytics calls aren't transactional.
if isinstance(body, list):
for item in body:
if isinstance(item, dict):
@@ -446,7 +446,7 @@ async def post_submit(body=Body(...)):
return {"ok": True}
if not isinstance(body, dict):
return {"ok": False, "error": "JSON object or array required"}
# Shape 1: frontend `report()` flat {s, a, p, ...}
# Shape 1: frontend `report()`; flat {s, a, p, ...}
if any(k in body for k in ("s", "a", "p")):
svc.sync(body)
return {"ok": True}
@@ -43,7 +43,7 @@ def _parse_frontmatter(raw: str) -> tuple[dict, str]:
async def _fetch_skill_paths(client: httpx.AsyncClient) -> list[tuple[str, str]]:
"""Fetch the marketplace.json manifest and return (skill_folder, plugin_name) pairs.
Uses raw.githubusercontent.com no GitHub API needed, no rate limiting.
Uses raw.githubusercontent.com; no GitHub API needed, no rate limiting.
"""
resp = await client.get(MANIFEST_URL)
resp.raise_for_status()
+6 -6
View File
@@ -32,7 +32,7 @@ def _save_index(index: dict[str, dict]):
# skill file we copy into ~/.claude/skills/ on first boot and tag with
# `built_in: true` in the index. Users can edit the content (their
# changes flow through to the matching agent's prompt on the next turn),
# but they can't delete the file the DELETE endpoint refuses with 409.
# but they can't delete the file; the DELETE endpoint refuses with 409.
def _built_in_skill_registry() -> list[dict]:
# Imported lazily so this module stays cheap to import from
# everywhere (the skills outputs module pulls in pydantic+fastapi
@@ -47,7 +47,7 @@ def _built_in_skill_registry() -> list[dict]:
"name": "App Builder",
"description": (
"Reference doc the App Builder agent reads on every turn. "
"Edit this to change how every App Builder agent behaves "
"Edit this to change how every App Builder agent behaves; "
"your edits take effect on the next turn, no restart. "
"Built-in: can be edited but not deleted."
),
@@ -58,7 +58,7 @@ def _built_in_skill_registry() -> list[dict]:
"id": "swarm_debug_skill",
"name": "swarm-debug Logger",
"description": (
"How to use `swarm_debug.debug()` in an App backend the "
"How to use `swarm_debug.debug()` in an App backend; the "
"colored frame-aware logger that lands in the App Builder's "
"Terminal pane under [BACKEND]. Edit to teach your debugging "
"conventions to the App Builder agent. Built-in: editable, "
@@ -73,7 +73,7 @@ def _built_in_skill_registry() -> list[dict]:
def _seed_built_in_skills() -> None:
"""Copy each built-in skill into SKILLS_DIR if not already present, and
ensure the index has the `built_in: true` flag so the UI and DELETE
endpoint know to treat it specially. Idempotent safe to call on
endpoint know to treat it specially. Idempotent; safe to call on
every boot. Doesn't overwrite the file once it exists (so user edits
are preserved across restarts and upgrades)."""
index = _load_index()
@@ -114,7 +114,7 @@ async def skills_lifespan():
try:
_seed_built_in_skills()
except Exception:
# Don't block app startup on a skill-seed failure the worst
# Don't block app startup on a skill-seed failure; the worst
# case is the user has to manually paste the skill in once.
logger.exception("failed to seed built-in skills")
yield
@@ -296,7 +296,7 @@ async def delete_skill(skill_id: str):
status_code=409,
detail=(
f"'{skill_id}' is a built-in skill and can't be deleted "
"(edit its content instead your edits take effect on "
"(edit its content instead; your edits take effect on "
"the next agent turn)."
),
)
+6 -6
View File
@@ -41,7 +41,7 @@ async def _clear_subscription(settings_obj, *, drop_bearer: bool = True) -> None
`drop_bearer=True` (the default) is the original behavior, used when the
cloud reports the bearer as revoked (401) or the subscription as past its
grace period (402) the bearer is dead so we have to clear it.
grace period (402); the bearer is dead so we have to clear it.
`drop_bearer=False` is used by the explicit user-initiated /disconnect
endpoint: the bearer still authenticates the user's account at api.me
@@ -62,7 +62,7 @@ async def _clear_subscription(settings_obj, *, drop_bearer: bool = True) -> None
def _sync_subscription_identity(settings_obj) -> None:
"""Push the installation's current subscription state into service-sync person
properties so every event from this user is segmentable by plan /
paying-vs-free. Safe to call from hot paths service-sync is fire-and-forget
paying-vs-free. Safe to call from hot paths; service-sync is fire-and-forget
and swallows errors internally."""
try:
from backend.apps.service.client import identify as _identify
@@ -178,7 +178,7 @@ async def status():
"connection_mode": mode,
}
# Best-effort live fetch surface stale cache if cloud is unreachable.
# Best-effort live fetch; surface stale cache if cloud is unreachable.
# Network errors leave upstream_code=None so we keep the cached state;
# only explicit 401/402 from the cloud trigger a local clear.
live_usage = None
@@ -203,7 +203,7 @@ async def status():
logger.debug("subscription/status live fetch failed: %s", e)
# Cloud says the bearer is gone (401) or the sub is past its grace
# period (402) drop local credentials so the desktop stops routing
# period (402); drop local credentials so the desktop stops routing
# through a dead subscription. Settings UI sees connected=False and
# falls back to the Subscribe CTA; chat reverts to own_key routing.
if upstream_code in (401, 402):
@@ -237,7 +237,7 @@ async def sync():
state forever.
No-op when not in openswarm-pro mode. Best-effort: network failures are
swallowed the caller still gets a 200 with whatever local state we
swallowed; the caller still gets a 200 with whatever local state we
already had."""
# Lazy-import the service-sync helper so subscription/router doesn't pay the
# cost when analytics are disabled.
@@ -285,7 +285,7 @@ async def sync():
cloud_plan = data.get("plan")
period_end_ms = data.get("current_period_end")
# Only touch local fields the cloud explicitly confirmed don't paper
# Only touch local fields the cloud explicitly confirmed; don't paper
# over missing keys with defaults that would downgrade an older record.
if cloud_plan:
settings_obj.openswarm_subscription_plan = cloud_plan
+19 -19
View File
@@ -71,7 +71,7 @@ def _load(tool_id: str) -> ToolDefinition:
tool = ToolDefinition(**json.load(f))
# Migrate Discord tool configs from the old npx-based spawn (which
# broke whenever the npx cache was partially populated) to the local
# Python shim. Idempotent if it's already on the shim, no-op.
# Python shim. Idempotent; if it's already on the shim, no-op.
if (
tool.name.lower() == "discord"
and tool.mcp_config
@@ -264,7 +264,7 @@ def _resolve_command(command: str) -> str | None:
if found:
return found
# Windows binaries need an extension. shutil.which() handles PATHEXT for
# PATH lookups, but we manually scan _extra_bin_dirs below replicate
# PATH lookups, but we manually scan _extra_bin_dirs below; replicate
# the suffix probing here so `uvx` finds `uvx.exe`, etc.
if sys.platform == "win32":
suffixes = [""] + os.environ.get("PATHEXT", ".COM;.EXE;.BAT;.CMD").lower().split(os.pathsep)
@@ -336,9 +336,9 @@ def derive_mcp_config(tool: ToolDefinition) -> Optional[dict]:
# local CLIENT_ID/SECRET on every API call. The OAuth flow
# itself runs through the cloud's rotation pool, so the
# refresh_token is bound to whichever pool slot minted it,
# not the single client baked into the DMG. Mismatch returns
# unauthorized_client. We point token_uri at a local proxy
# that forwards the refresh to our cloud's pool-aware
# not the single client baked into the DMG. Mismatch -> Google
# returns unauthorized_client. We point token_uri at a local
# proxy that forwards the refresh to our cloud's pool-aware
# /api/oauth/google/refresh endpoint; CLIENT_ID/SECRET become
# unused placeholders (gauth.py only validates non-empty).
_port = os.environ.get("OPENSWARM_PORT", "8324")
@@ -376,7 +376,7 @@ def derive_mcp_config(tool: ToolDefinition) -> Optional[dict]:
if guild_ids:
env["OPENSWARM_DISCORD_GUILD_IDS"] = ",".join(guild_ids)
# The shim runs as a subprocess and needs to import
# `backend.apps.discord_mcp_shim` set PYTHONPATH to the project
# `backend.apps.discord_mcp_shim`; set PYTHONPATH to the project
# root (parent of the backend/ dir) so that import resolves.
_project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
existing_pp = env.get("PYTHONPATH") or os.environ.get("PYTHONPATH", "")
@@ -394,7 +394,7 @@ def derive_mcp_config(tool: ToolDefinition) -> Optional[dict]:
if config.get("command"):
# `python` (no version suffix) doesn't exist on a stock macOS,
# so a tool config that asks for "python" silently fails to
# spawn Claude Agent SDK then exposes zero tools from that
# spawn; Claude Agent SDK then exposes zero tools from that
# MCP. We resolve to the actual interpreter running the
# backend (sys.executable), which is guaranteed to exist and
# have backend modules importable. `python3` and absolute
@@ -403,7 +403,7 @@ def derive_mcp_config(tool: ToolDefinition) -> Optional[dict]:
resolved_python = sys.executable or shutil.which("python3") or shutil.which("python")
if resolved_python:
config["command"] = resolved_python
# Check for bundled npm MCP servers use Electron's Node.js instead of npx
# Check for bundled npm MCP servers; use Electron's Node.js instead of npx
if config["command"] in ("npx", "bunx"):
pkg_name = next((a for a in (config.get("args") or []) if not a.startswith("-")), None)
if pkg_name:
@@ -481,7 +481,7 @@ def derive_mcp_config(tool: ToolDefinition) -> Optional[dict]:
env = config.setdefault("env", {})
env.setdefault("PATH", _augmented_path())
env.setdefault("PYTHONPATH", "")
# Point uv/uvx at our bundled Python avoids macOS CLT popup on fresh Macs
# Point uv/uvx at our bundled Python; avoids macOS CLT popup on fresh Macs
# and avoids downloading Python at runtime
_is_packaged = os.environ.get("OPENSWARM_PACKAGED") == "1"
_is_windows = sys.platform == "win32"
@@ -663,7 +663,7 @@ def _try_heal_npx_cache(stderr: str) -> str | None:
Why: interrupted npx installs leave a `package-lock.json` in the cache dir so
subsequent spawns reuse a partially-extracted node_modules tree, which dies at
import time. Scoped strictly to the extracted hash subdir never touches
import time. Scoped strictly to the extracted hash subdir; never touches
anything outside `~/.npm/_npx/`.
"""
if "ERR_MODULE_NOT_FOUND" not in stderr:
@@ -785,7 +785,7 @@ async def _discover_mcp_tools_stdio(command: str, args: list[str] | None = None,
except HTTPException as e:
# Heal-on-corrupt-npx-cache still triggers from the EOF branch,
# which now includes the full stderr tail in `e.detail` so the
# which now includes the full stderr tail in `e.detail`; so the
# ERR_MODULE_NOT_FOUND signature is still discoverable here.
if _attempt == 0 and _try_heal_npx_cache(str(e.detail) if e.detail is not None else ""):
return await _discover_mcp_tools_stdio(command, args, env, _attempt=1)
@@ -793,10 +793,10 @@ async def _discover_mcp_tools_stdio(command: str, args: list[str] | None = None,
except asyncio.TimeoutError:
# Most common cause: cold npx cache on Windows. The npm install
# persists across attempts, so a retry usually finishes against a
# warm cache. Surface npx's own progress line if we have one it
# warm cache. Surface npx's own progress line if we have one; it
# makes the cause obvious ("downloading X...") instead of opaque.
tail_text = "".join(stderr_tail[-5:]).strip()
detail = "MCP discovery timed out the server may still be downloading on first run"
detail = "MCP discovery timed out; the server may still be downloading on first run"
if tail_text:
preview = tail_text[-200:].replace("\n", " ").strip()
detail += f" (last output: {preview})"
@@ -927,7 +927,7 @@ def _m365_server_script() -> str:
backend/mcp-bundles/softeria-ms-365-mcp-server/dist/index.js (4.7MB).
The new path mirrors the SDK's internal layout (dist/index.js + sibling
package.json) because cli.js reads __dirname/../package.json for the
--version flag see scripts/build-app.sh `build_mcp_bundle_dir`.
--version flag; see scripts/build-app.sh `build_mcp_bundle_dir`.
"""
_backend = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
bundle = os.path.join(
@@ -1011,7 +1011,7 @@ async def m365_device_login(tool_id: str):
login_state["status"] = "awaiting_auth"
if url_match and "microsoft" in url_match.group(1).lower():
login_state["device_code_url"] = url_match.group(1)
# Process ended check result
# Process ended; check result
proc.wait()
remaining_stderr = proc.stderr.read() if proc.stderr else ""
login_state["output"] += remaining_stderr
@@ -1219,7 +1219,7 @@ async def oauth_cloud_claim(
data = resp.json()
tokens = data.get("tokens", {}) or {}
tool = _load(tool_id)
# Google's token endpoint doesn't include the user's email fetch it
# Google's token endpoint doesn't include the user's email; fetch it
# from userinfo so the UI can show "you connected ericzeng@gmail.com"
# rather than the generic "Google account" placeholder.
if tool.name.lower() == "google" and tokens.get("access_token") and not tokens.get("email"):
@@ -1242,7 +1242,7 @@ def _persist_cloud_tokens(tool: ToolDefinition, tokens: dict) -> None:
"""Normalise the cloud's claim response into tool.oauth_tokens.
Per-provider shaping mirrors what the v1.0.25 local-callback flow used
to write the rest of the app (refresh helpers, MCP env injection)
to write; the rest of the app (refresh helpers, MCP env injection)
expects exactly this shape.
"""
name = tool.name.lower()
@@ -1298,7 +1298,7 @@ async def _refresh_via_proxy(provider: str, tool: ToolDefinition, default_expiry
json={"refresh_token": refresh_token},
)
if resp.status_code == 401:
# Provider rejected user revoked at the provider's side. Mark
# Provider rejected; user revoked at the provider's side. Mark
# as needing re-auth so the UI prompts a Reconnect.
tool.auth_status = "expired"
_save(tool)
@@ -1331,7 +1331,7 @@ async def _refresh_via_proxy(provider: str, tool: ToolDefinition, default_expiry
async def refresh_google_token(tool: ToolDefinition) -> Optional[str]:
"""Refresh an expired Google access_token via the Fly cloud-proxy.
The client_secret never leaves Fly desktop only POSTs the
The client_secret never leaves Fly; desktop only POSTs the
refresh_token. Same pattern as Airtable/HubSpot. Pre-v1.0.29 builds
held the secret in their bundled .env; v1.0.29 removed it.
"""
+8 -8
View File
@@ -41,7 +41,7 @@ class SearchBody(BaseModel):
num_results: int = Field(5, ge=1, le=10, description="Max results to return.")
# Hint from the MCP server about which primary provider the session
# is using. Lets us route to that provider's native search tool
# (Gemini googleSearch, OpenAI web_search_preview) when available
# (Gemini googleSearch, OpenAI web_search_preview) when available ,
# costs come out of the user's existing primary budget.
primary: str | None = Field(None, description="Primary provider hint: 'gemini' | 'openai' | 'anthropic' | None")
@@ -53,7 +53,7 @@ class FetchBody(BaseModel):
# ---------------------------------------------------------------------------
# Helper extract plain text from a tool's structured output list
# Helper; extract plain text from a tool's structured output list
# ---------------------------------------------------------------------------
@@ -143,7 +143,7 @@ def _format_grounded_as_fetch(grounded: dict, url: str) -> str:
if chunks:
parts.append("\nCited sources:")
for i, (title, uri) in enumerate(chunks[:5], start=1):
parts.append(f" [{i}] {title} {uri}")
parts.append(f" [{i}] {title}; {uri}")
return "\n".join(parts)
@@ -167,7 +167,7 @@ def _resolve_openai_api_key() -> str | None:
# Cache of which 9Router subscriptions are connected. Refreshed via
# `_refresh_9r_connected()` rather than hit on every search call
# `_refresh_9r_connected()` rather than hit on every search call ,
# 9Router's /api/providers is fast but not free, and we already
# query it from many places.
_NINE_ROUTER_CONNECTED: set[str] = set()
@@ -196,7 +196,7 @@ async def _refresh_9r_connected() -> set[str]:
}
_NINE_ROUTER_CACHE_AT = now
except Exception:
# Cache stays best-effort.
# Cache stays; best-effort.
pass
return _NINE_ROUTER_CONNECTED
@@ -207,7 +207,7 @@ async def _gemini_grounded_via_9router(prompt: str, use_url_context: bool) -> di
search call instead of needing a separate AI Studio API key.
Routes through Anthropic-shape against 9Router's translator. We
request a tool result naturally the translator surfaces grounded
request a tool result naturally; the translator surfaces grounded
URIs as text + cited sources in the response body. Format-shape
matches the existing `_gemini_grounded_call` so downstream
`_format_grounded_as_search_results` works unchanged."""
@@ -482,14 +482,14 @@ async def search(body: SearchBody) -> dict:
has_subscription = bool(connected & {"codex", "antigravity", "gemini-cli"})
if not (gemini_key or openai_key or has_subscription):
hint = (
"\n\n(DuckDuckGo returned no results likely rate-limiting this IP. "
"\n\n(DuckDuckGo returned no results; likely rate-limiting this IP. "
"Connect Codex / Antigravity / Gemini CLI in Settings, or add an "
"OpenAI / Gemini API key, for reliable native search.)"
)
else:
hint = (
"\n\n(DuckDuckGo returned no results and the connected providers "
"didn't return useful results either try rephrasing the query.)"
"didn't return useful results either; try rephrasing the query.)"
)
return {
"query": body.query,
+20 -184
View File
@@ -1,28 +1,4 @@
"""Per-install auth token for the localhost API.
OpenSwarm's backend runs a FastAPI server on `127.0.0.1:<random-port>`
and streams sensitive agent data (tool inputs, approval requests,
messages) over WebSockets. Without auth, any webpage loaded in any
browser on the same machine can connect to those endpoints WebSockets
aren't subject to Same-Origin Policy — and impersonate the user.
This module issues a cryptographically random token on first boot,
writes it 0600 to `<DATA_ROOT>/auth.token`, and reuses it on subsequent
restarts (so dev-mode hot-reload doesn't break the renderer's cached
copy). The token is regenerated only when the file is missing or empty.
Only code running as the same OS user can read the file.
Delivery to legitimate consumers:
- Electron main process reads the file and exposes it to the renderer
via a contextBridge method in preload.js (NOT plain window global).
- Our Python MCP subprocesses receive it via env var
`OPENSWARM_AUTH_TOKEN` that agent_manager passes when spawning.
- The Claude Code CLI we spawn receives it as `ANTHROPIC_API_KEY` in
env; the anthropic-proxy route trusts that value.
None of those paths are accessible from a third-party webpage.
"""
"""Per-install bearer token gating the localhost API and WS streams."""
from __future__ import annotations
@@ -38,13 +14,7 @@ _TOKEN: str = ""
def _write_atomic(path: str, data: str, mode: int = 0o600) -> None:
"""Write `data` to `path` atomically with the given file mode.
Uses `os.open(..., O_CREAT|O_WRONLY|O_TRUNC, mode)` + rename so the
final file is never world-readable and never left half-written if
the backend crashes mid-write. Windows-safe (rename of a file over
an existing one works on NTFS when the source was just closed).
"""
"""Atomic write to `path` at the given file mode; never world-readable or half-written."""
os.makedirs(os.path.dirname(path), exist_ok=True)
tmp = path + ".tmp"
fd = os.open(tmp, os.O_CREAT | os.O_WRONLY | os.O_TRUNC, mode)
@@ -60,30 +30,8 @@ def _write_atomic(path: str, data: str, mode: int = 0o600) -> None:
def init_auth_token() -> str:
"""Initialise the per-install auth token, persisting to disk.
Behaviour: prefer an existing token on disk; only mint a fresh one
when the file is absent or empty. This matters for two cases:
1. Dev mode (`bash run.sh`) uvicorn's WatchFiles reload restarts
the worker process and re-runs init_auth_token. If we generated
a fresh token every reload, Electron's cached token (read once
at app boot) would mismatch and every authed request 401s
until the user fully restarts. Preserving the on-disk token
keeps Electron and the backend in sync across reloads.
2. Packaged builds the user can restart the backend (Quit + reopen)
without the renderer reloading. Same mismatch risk, same fix.
Security trade-off: we no longer rotate the token on every restart.
The threat model that rotation was protecting against (a stale token
sitting in a log/crash dump being usable later) is marginal anyone
who can read the artifact can also re-read the on-disk token, and
real rotation requires the file to be deleted (e.g. by signing out
or wiping the data root). Net: dev-mode reliability wins.
"""
"""Load the per-install token from disk, or mint one if missing; reused across restarts so Electron's cached copy stays valid."""
global _TOKEN
# Try existing on-disk token first.
try:
if os.path.exists(AUTH_TOKEN_FILE):
with open(AUTH_TOKEN_FILE, "r", encoding="utf-8") as f:
@@ -95,7 +43,6 @@ def init_auth_token() -> str:
)
return _TOKEN
except Exception as e:
# Fall through to fresh generation on any read error.
logger.warning(f"auth: failed to read existing token, generating new: {e}")
_TOKEN = secrets.token_urlsafe(32)
@@ -103,9 +50,7 @@ def init_auth_token() -> str:
_write_atomic(AUTH_TOKEN_FILE, _TOKEN, mode=0o600)
logger.info(f"auth: wrote token to {AUTH_TOKEN_FILE} (mode 0600)")
except Exception as e:
# Fail open is NOT an option here — if we can't write the file,
# Electron can't read it, and the user sees a broken app. But
# don't hard-crash the backend either; log loudly.
# If we can't write the file, Electron can't read it; log loudly but don't crash.
logger.error(f"auth: failed to write token file: {e}")
return _TOKEN
@@ -116,25 +61,13 @@ def get_auth_token() -> str:
class _TokenScrubFilter(logging.Filter):
"""Logging filter that redacts the install token from any log record.
The token leaks into logs in a few mundane ways: subprocess env dicts
that get logged when an MCP server fails to spawn, urllib retry logs
that include `?token=...` query strings, exception tracebacks that
print the response body of a failed proxied request. None of those
are intentional but they all happen, and the file ends up in crash
dumps / shared bug reports / hosted log aggregators. This filter is
pure defense in depth behavior is unchanged when the token is
absent from a record.
"""
"""Logging filter that redacts the install token from log records (defense in depth)."""
_PLACEHOLDER = "<REDACTED:openswarm-token>"
@staticmethod
def _args_might_contain_token(args) -> bool:
"""Cheap pre-check: does any positional arg or dict-arg value mention
the token? Avoids the cost of `record.getMessage()` (which eagerly
does %-formatting) on the >99% of log lines that don't touch it."""
"""Cheap pre-check; avoids eager %-formatting on the >99% of records that don't mention the token."""
if not args:
return False
items = args if isinstance(args, (tuple, list)) else (args,)
@@ -149,13 +82,7 @@ class _TokenScrubFilter(logging.Filter):
@classmethod
def _scrub_args(cls, args):
"""Replace token within args in-place-equivalent, preserving the
original tuple/dict shape. Uvicorn's AccessFormatter unpacks
record.args as a 5-tuple (client_addr, method, full_path,
http_version, status_code); blanking args to None what the
previous slow path did triggered `cannot unpack non-iterable
NoneType object` for every access-logged line that contained
`?token=...`. Returns the same object if nothing was rewritten."""
"""Scrub token from args while preserving tuple/dict shape; uvicorn's AccessFormatter unpacks args as a 5-tuple and explodes on None."""
if args is None:
return args
if isinstance(args, dict):
@@ -181,27 +108,11 @@ class _TokenScrubFilter(logging.Filter):
def filter(self, record: logging.LogRecord) -> bool: # pragma: no cover (defensive)
if not _TOKEN:
return True
# Fast path: the overwhelming majority of log records don't mention
# the token at all. Two cheap string-in-string scans (raw msg + each
# arg) are far cheaper than forcing record.getMessage(), which would
# do eager %-formatting on every record in the process.
# Fast path: skip eager %-formatting on records that don't mention the token.
raw_msg = record.msg if isinstance(record.msg, str) else ""
if _TOKEN not in raw_msg and not self._args_might_contain_token(record.args):
return True
# Slow path. Two-step scrub so we cover both shapes:
# 1. In-place rewrite of record.msg and any string in record.args
# (or string-valued dict entry). Preserves args shape so
# uvicorn's AccessFormatter — which unpacks record.args as a
# 5-tuple and would explode on args=None — keeps working.
# 2. Render via record.getMessage() and check the substituted
# output. If a token survived step 1 (because it was buried
# inside a nested structure or a custom object's repr, e.g.
# `logger.info("env: %s", env_dict)` where the dict's repr
# exposes the value), bake the redacted final string into
# record.msg and clear args. This last-resort path only
# trips for records that the in-place pass couldn't reach,
# and uvicorn access logs never hit it (their args are
# always primitive strings/ints, fully scrubbed by step 1).
# Slow path: in-place args rewrite (preserves shape for AccessFormatter), then re-render to catch tokens buried in custom reprs.
try:
if isinstance(record.msg, str) and _TOKEN in record.msg:
record.msg = record.msg.replace(_TOKEN, self._PLACEHOLDER)
@@ -216,9 +127,7 @@ class _TokenScrubFilter(logging.Filter):
except Exception:
pass
except Exception:
# Never let the scrubber suppress a log line — if formatting
# fails for any reason, fall through and let normal handling
# proceed (worst case the token leaks for that one record).
# Never let the scrubber suppress a log line; worst case the token leaks for that one record.
pass
return True
@@ -227,27 +136,7 @@ _scrubber_installed = False
def install_token_scrubber() -> None:
"""Attach the token-scrubbing filter to every log handler in the process.
Why not just `root.addFilter(...)`: filters attached to a Logger only
fire on records emitted DIRECTLY on that logger. Records propagated up
from child loggers (uvicorn.access, uvicorn.error, websockets, etc.)
flow into root's HANDLERS without ever consulting root's logger-level
filters. So a logger-level install silently misses the access log
which is exactly the line that contains `?token=...` query params.
This implementation:
1. Walks every currently-registered logger (root + everything in
`Logger.manager.loggerDict`) and attaches the scrubber to each
of their handlers.
2. Monkey-patches `Logger.addHandler` so any handler installed
AFTER this call (uvicorn configures its loggers during startup,
after main.py finishes importing) also gets the scrubber.
3. Keeps the root-logger filter as belt-and-suspenders for the
records that ARE emitted directly on root.
Idempotent repeated calls are a no-op.
"""
"""Attach the scrubbing filter to every existing AND future log handler; logger-level filters miss propagated child records."""
global _scrubber_installed
if _scrubber_installed:
return
@@ -258,7 +147,6 @@ def install_token_scrubber() -> None:
if not any(isinstance(f, _TokenScrubFilter) for f in handler.filters):
handler.addFilter(scrubber)
# 1. Existing handlers across every known logger.
loggers: list[logging.Logger] = [logging.getLogger()]
for logger in logging.root.manager.loggerDict.values():
if isinstance(logger, logging.Logger):
@@ -267,9 +155,7 @@ def install_token_scrubber() -> None:
for h in list(logger.handlers):
_attach(h)
# 2. Future handlers — patch Logger.addHandler so anything attached
# after this point (uvicorn finishing its log config, plugins
# that reconfigure logging on their own) also gets scrubbed.
# Patch addHandler so handlers attached later (uvicorn finishes log config after main.py imports) get the scrubber too.
_original_addHandler = logging.Logger.addHandler
def _patched_addHandler(self: logging.Logger, hdlr: logging.Handler) -> None:
@@ -278,7 +164,6 @@ def install_token_scrubber() -> None:
logging.Logger.addHandler = _patched_addHandler # type: ignore[assignment]
# 3. Belt-and-suspenders on root logger itself.
root = logging.getLogger()
if not any(isinstance(f, _TokenScrubFilter) for f in root.filters):
root.addFilter(scrubber)
@@ -286,30 +171,11 @@ def install_token_scrubber() -> None:
_scrubber_installed = True
# Paths that never require auth. These are the public surface.
# Auth-exempt paths: external redirects with their own nonce/state validation, plus the bootstrap health probe.
_AUTH_EXEMPT_EXACT = {
# External OAuth providers redirect the user's browser here. The
# browser has no way to inject our bearer token (it's a 302 from
# Google/Anthropic/etc). The `state` query param is already a
# one-time nonce validated against `_pending_oauth`.
"/api/subscriptions/callback",
# Same pattern for the per-tool OAuth flow (Notion / Google Workspace /
# Airtable / HubSpot / Discord). The browser hits this with ?code=...&state=...
# after the user approves on the provider's site; the `state` param is
# the tool_id which we cross-check against _pending_oauth in tools_lib.py.
# Without this exemption the redirect lands a 401 page in the user's
# browser — see tools_lib.py:1156 where redirect_uri is constructed.
"/api/tools/oauth/callback",
# Browser-redirect target for the proxied OAuth claim handoff. Browser
# has no way to inject our bearer token; the install_id check inside
# the handler is what binds the request to this user.
"/api/tools/oauth/cloud-claim",
# Bearer-handoff endpoints called by api.openswarm.com's success page
# AFTER Stripe checkout / Google sign-in / magic-link sign-in. The
# request POSTs the just-minted cloud bearer; the handler then re-
# validates it against the cloud (/api/me or /api/auth/signin-activate).
# The browser has no way to attach our per-install token here — the
# cloud-validated bearer in the body is the actual auth mechanism.
"/api/subscription/activate",
"/api/auth/signin-activate",
"/api/version",
@@ -322,23 +188,11 @@ _AUTH_EXEMPT_EXACT = {
"/api/tools/google-oauth-token",
}
# Path prefixes that never require auth. Trailing slash optional.
_AUTH_EXEMPT_PREFIX = (
# Electron's boot handshake polls /api/health/check before it has a
# token (the HTTP port is up before main.js calls loadAuthToken()).
# Use a prefix so /api/health/check — and any future sub-route — is
# covered without re-introducing the bootstrap deadlock that an
# exact "/api/health" match caused.
# Electron polls /api/health/check before loading the token.
"/api/health",
# OpenAI API pass-through. 9Router calls this with the user's
# OpenAI Bearer token (sk-…), NOT our local auth token, so our
# middleware would reject. Localhost-only network boundary is the
# security gate — the route only forwards to api.openai.com and
# never touches user data on this machine. See
# backend/apps/agents/openai_passthrough.py for why this exists.
# 9Router proxies OpenAI requests with the user's sk-... bearer, not our local token; localhost-only is the gate.
"/api/openai-passthrough",
# FastAPI's default health/docs/schema surface (packaged app never
# ships /docs, but be defensive).
"/docs",
"/openapi",
"/redoc",
@@ -368,20 +222,9 @@ def extract_bearer(header_value: str | None) -> str:
def request_matches_token(request_headers: dict, query_params: dict | None = None) -> bool:
"""Validate that an incoming HTTP / WS request carries our token.
Accepts any of:
- `Authorization: Bearer <token>`
- `x-openswarm-token: <token>` (custom header for callers that
can't easily set Authorization — e.g. future CLI clients)
- `?token=<token>` query param (WS only; browsers can't easily
set custom WS headers, so the token rides in the URL)
The token comparison is constant-time via `secrets.compare_digest`.
"""
"""Validate that an HTTP/WS request carries our token (Bearer, x-openswarm-token, or ?token=); constant-time compare."""
if not _TOKEN:
# Backend started without auth init — fail closed. This should
# only happen in test fixtures that intentionally bypass main.
# Backend not initialized: fail closed. Only test fixtures that bypass main hit this.
return False
candidates: list[str] = []
@@ -409,14 +252,10 @@ def request_matches_token(request_headers: dict, query_params: dict | None = Non
return False
# Origin allowlist for WS handshakes. Electron's renderer loads from
# `file://` when packaged; `http://localhost:3000` (Vite dev server) and
# `http://127.0.0.1:3000` in dev. A bare `null` Origin is sent by some
# Electron contexts.
# WS Origin allowlist: Electron packaged is file://, dev is localhost:3000, some Electron contexts send bare "null".
_ORIGIN_ALLOWLIST_DEV = {
"http://localhost:3000",
"http://127.0.0.1:3000",
# Electron may load prod build from file:// or an app:// scheme.
"file://",
"null",
}
@@ -425,16 +264,13 @@ _ORIGIN_ALLOWLIST_DEV = {
def is_origin_allowed(origin: str | None) -> bool:
"""True if the WS connection's Origin header is from our app."""
if origin is None:
# No Origin header = curl / native WS client / MCP subprocess.
# Token check is still required, so allow.
# Native WS client / curl / MCP subprocess: token check still required, so allow.
return True
if origin in _ORIGIN_ALLOWLIST_DEV:
return True
# file:// origins in Electron prod sometimes include paths like
# file:///Applications/OpenSwarm.app/... — match by prefix.
# Packaged Electron file:// includes paths like file:///Applications/OpenSwarm.app/...; match by prefix.
if origin.startswith("file://"):
return True
# localhost + any port (dev servers, tools the developer is running).
if origin.startswith("http://localhost:") or origin.startswith("http://127.0.0.1:"):
return True
return False
+1 -2
View File
@@ -37,8 +37,7 @@ class MainApp:
yield
self.app = FastAPI(lifespan=lifespan)
# Include all sub-app routers in the main app with their prefixes
for sub_app in sub_apps:
self.app.include_router(
sub_app.router,
+2 -19
View File
@@ -1,13 +1,4 @@
"""Per-install identifier.
A UUID4 generated on first run, persisted at ``<DATA_ROOT>/install_id``
with 0600 perms. Used to bind an in-flight OAuth claim to the install
that started it, so a leaked session_id alone is useless.
Not a secret. Not a user identity. Not stable across reinstalls
(reinstalling generates a new ID, by design the previous install's
in-flight OAuth flows shouldn't follow the user across reinstalls).
"""
"""Per-install UUID4 binding in-flight OAuth claims to the install that started them."""
from __future__ import annotations
@@ -21,12 +12,7 @@ _cached: str | None = None
def get_install_id() -> str:
"""Return the persistent install_id, generating + persisting on first call.
Idempotent across processes if the file already exists we read it.
Concurrent first-call from two processes is safe: both write a UUID,
last-writer-wins, neither side cares which one is canonical.
"""
"""Return the persistent install_id, generating and persisting on first call."""
global _cached
if _cached:
return _cached
@@ -40,13 +26,10 @@ def get_install_id() -> str:
except FileNotFoundError:
pass
except Exception:
# Corrupt file — overwrite below.
pass
fresh = str(uuid.uuid4())
os.makedirs(os.path.dirname(_INSTALL_ID_FILE) or ".", exist_ok=True)
# 0600 so other accounts on the same machine can't read it. We're not
# treating it as a secret, but no reason to be sloppy.
fd = os.open(_INSTALL_ID_FILE, os.O_CREAT | os.O_WRONLY | os.O_TRUNC, 0o600)
try:
os.write(fd, fresh.encode("utf-8"))
+2 -12
View File
@@ -1,10 +1,4 @@
"""Centralised path definitions for the OpenSwarm backend.
In dev mode (default) data lives under ``backend/data/``.
When packaged as a desktop app, Electron sets ``OPENSWARM_PACKAGED=1`` and
data is stored in a platform-appropriate location
(``~/Library/Application Support/OpenSwarm/data/`` on macOS).
"""
"""Path definitions: dev under backend/data/, packaged under platform app-support."""
import os
import sys
@@ -36,11 +30,7 @@ DASHBOARD_LAYOUT_DIR = os.path.join(DATA_ROOT, "dashboard_layout")
BUILTIN_PERMISSIONS_PATH = os.path.join(DATA_ROOT, "builtin_permissions.json")
TRUSTED_SENSITIVE_PATHS_PATH = os.path.join(DATA_ROOT, "trusted_sensitive_paths.json")
# Per-install auth token for the localhost WS + HTTP API. Regenerated
# every backend start. Only code running as the current OS user (Electron
# main process, our Python MCP subprocesses, the Claude Code CLI we
# spawn) can read this file. Webpages loaded in any browser on the
# machine cannot — which is the whole point. See auth.py.
# Per-install auth token for the localhost API; see auth.py.
AUTH_TOKEN_FILE = os.path.join(DATA_ROOT, "auth.token")
BACKEND_DIR = _BACKEND_DIR
+19 -52
View File
@@ -6,17 +6,17 @@ const SETTINGS_API = `${API_BASE}/settings`;
export const DEFAULT_SYSTEM_PROMPT =
`You are a personal AI assistant running inside OpenSwarm.\n\n` +
`## Tool Priority\n` +
`When a dedicated MCP tool exists for a task, use it directly — do not use the browser for things MCP tools can handle.\n` +
`When a dedicated MCP tool exists for a task, use it directly. Do not use the browser for things MCP tools can handle.\n` +
`Priority order:\n` +
`1. MCP tools first (Reddit, Google Workspace, etc.) fastest and most reliable\n` +
`2. WebSearch / WebFetch for general web lookups without a dedicated MCP\n` +
`3. BrowserAgent only when you need to visually interact with a website, fill forms, or do something no other tool can handle\n\n` +
`1. MCP tools first (Reddit, Google Workspace, etc.); fastest and most reliable\n` +
`2. WebSearch / WebFetch for general web lookups without a dedicated MCP\n` +
`3. BrowserAgent only when you need to visually interact with a website, fill forms, or do something no other tool can handle\n\n` +
`## Tool Call Style\n` +
`Default: do not narrate routine tool calls — just call the tool.\n` +
`Default: do not narrate routine tool calls. Just call the tool.\n` +
`Narrate only when it helps: multi-step work, complex problems, or when the user explicitly asks.\n` +
`Keep narration brief. Use plain language.\n\n` +
`## Interaction Style\n` +
`Be direct and action-oriented. Do not ask clarifying questions unless genuinely ambiguous ` +
`Be direct and action-oriented. Do not ask clarifying questions unless genuinely ambiguous; ` +
`make reasonable assumptions and act. If you need to ask, use the AskUserQuestion tool.\n` +
`Do not over-explain what you are about to do. Just do it and show the results.`;
@@ -31,7 +31,8 @@ export interface SubscriptionUsage {
requests_in_window: number;
plan_limit: number;
window_hours: number;
window_ends_at: number; // unix ms
/** unix ms */
window_ends_at: number;
}
export interface AppSettings {
@@ -55,23 +56,18 @@ export interface AppSettings {
auto_reveal_sub_agents: boolean;
dev_mode: boolean;
allow_experimental_updates: boolean;
// Optional managed-subscription state (surfaces only when user has
// subscribed via the cloud). Mirrors AppSettings on the backend.
/** Managed subscription state; surfaces only when user has subscribed via cloud. */
connection_mode?: 'own_key' | 'openswarm-pro';
openswarm_bearer_token?: string | null;
openswarm_proxy_url?: string | null;
openswarm_subscription_plan?: string | null;
openswarm_subscription_expires?: string | null;
openswarm_usage_cached?: SubscriptionUsage | null;
// Identity (v1.0.29+). Populated after a successful Google sign-in via
// /api/auth/signin-activate. Stripe checkout also populates these because
// the cloud's bearer-mint always returns user info.
/** Identity populated by /api/auth/signin-activate; Stripe checkout also fills these. */
user_id?: string | null;
user_email?: string | null;
signin_method?: 'google' | 'stripe' | null;
// Anonymous device identifier. Generated locally on first run, persists
// across launches. Used to bind cloud OAuth flows to this install and to
// stitch anonymous → authenticated PostHog Persons after sign-in.
/** Anonymous device id (first-run generated); stitches anon to authed PostHog Persons. */
installation_id?: string | null;
}
@@ -101,13 +97,7 @@ interface SettingsState {
modalOpen: boolean;
/** When non-null, Settings opens to this tab instead of 'general'. */
initialTab: string | null;
/**
* In-flight form edits, preserved across modal close/reopen so the user
* can step away from Settings (browse the dashboard, open a doc, etc.)
* and come back to find their typing intact. `null` means the form is in
* sync with `data` no unsaved edits. Cleared automatically on a
* successful save, or explicitly via clearDraft.
*/
/** In-flight form edits preserved across modal close/reopen; null = synced with `data`. */
draft: AppSettings | null;
/** Tab the user was on when they closed the modal with unsaved edits. */
draftTab: string | null;
@@ -176,9 +166,7 @@ export const browseDirectories = createAsyncThunk(
}
);
// POST /api/subscription/activate — called after the desktop catches an
// openswarm://auth?token=... deep link. Validates + persists on the backend,
// then refreshes settings so the Settings UI flips to "Pro" mode.
/** POST /api/subscription/activate after catching openswarm://auth deep link; flips UI to Pro. */
export const activateSubscription = createAsyncThunk(
'settings/activateSubscription',
async (payload: ActivateSubscriptionPayload, { dispatch }) => {
@@ -188,18 +176,12 @@ export const activateSubscription = createAsyncThunk(
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error((await res.text()) || 'Activation failed');
// Pull the fresh settings so UI reflects connection_mode + plan.
await dispatch(fetchSettings());
return (await res.json()) as { ok: boolean; plan: string };
}
);
// POST /api/auth/signin-activate — called after the desktop catches the
// bearer from a Google OAuth / magic-link sign-in flow. Validates the
// bearer with the cloud (checks signature + user_id + email) and persists
// it locally as a free-tier identity. The same backend route also handles
// "user signed in AND has an active subscription" — plan/expires are set
// when the cloud returns them.
/** POST /api/auth/signin-activate after catching Google OAuth/magic-link bearer; persists identity. */
export const activateSignin = createAsyncThunk(
'settings/activateSignin',
async (payload: ActivateSigninPayload, { dispatch }) => {
@@ -220,8 +202,7 @@ export const activateSignin = createAsyncThunk(
},
);
// POST /api/auth/signout revokes the cloud-side bearer and clears local
// identity fields. Brings the user back to the sign-in gate.
/** POST /api/auth/signout; revokes cloud bearer, clears local identity, returns to sign-in gate. */
export const signOut = createAsyncThunk(
'settings/signOut',
async (_: void, { dispatch }) => {
@@ -232,8 +213,7 @@ export const signOut = createAsyncThunk(
},
);
// POST /api/subscription/disconnect clears bearer + reverts to own_key.
// Doesn't cancel the Stripe subscription (that's the Portal).
/** POST /api/subscription/disconnect; clears bearer, reverts to own_key. Doesn't cancel Stripe. */
export const disconnectSubscription = createAsyncThunk(
'settings/disconnectSubscription',
async (_: void, { dispatch }) => {
@@ -256,13 +236,7 @@ const settingsSlice = createSlice({
state.modalOpen = false;
state.initialTab = null;
},
/**
* Persist the user's in-flight form edits + active tab so they survive
* modal close. Settings.tsx calls this on every form mutation (React's
* batching keeps it cheap). When the form matches saved data, callers
* pass null/clearDraft to drop the marker `hasChanges` then reads
* false correctly.
*/
/** Persist in-flight form edits + tab so they survive modal close; clearDraft drops the marker. */
setDraft(state, action: PayloadAction<{ form: AppSettings; tab: string }>) {
state.draft = action.payload.form;
state.draftTab = action.payload.tab;
@@ -280,13 +254,7 @@ const settingsSlice = createSlice({
.addCase(fetchSettings.fulfilled, (state, action) => {
state.loading = false;
state.loaded = true;
// Belt-and-suspenders: skip the assignment when the payload is
// byte-identical to what we already have. The SignInGate's 2s poll
// would otherwise flip the `state.data` reference on every tick,
// re-running every effect that depends on `s.settings.data` —
// including form-sync useEffects elsewhere in the tree. Cheap on
// a small object, prevents an entire class of "polling wipes my
// form" bugs without needing every consumer to be defensive.
// Skip ref-assignment when byte-identical; prevents SignInGate 2s poll from re-firing every effect.
const next = JSON.stringify(action.payload);
const prev = JSON.stringify(state.data);
if (next !== prev) {
@@ -299,8 +267,7 @@ const settingsSlice = createSlice({
})
.addCase(updateSettings.fulfilled, (state, action) => {
state.data = action.payload;
// Save consumes the draft — clear it so the next modal-open
// doesn't restore stale edits over freshly-saved values.
// Save consumes the draft so reopening doesn't restore stale edits.
state.draft = null;
state.draftTab = null;
})