mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-13 13:17:40 +02:00
defluff: strip em-dashes + shorten docstrings across backend (cosmetic only, no logic change)
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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("")
|
||||
|
||||
@@ -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 3–10 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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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 5–15s 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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)."
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user