diff --git a/.github/workflows/intel-x64-verify.yml b/.github/workflows/intel-x64-verify.yml new file mode 100644 index 00000000..a9f7b089 --- /dev/null +++ b/.github/workflows/intel-x64-verify.yml @@ -0,0 +1,155 @@ +name: intel-x64-verify + +# One-off, manually dispatched: prove the published x64 DMG on REAL Intel mac +# hardware (the arm64 build host can't; Rosetta lacks AVX so the bundled Bun +# claude CLI is untestable there). Downloads the live release asset, checks +# every binary's arch, runs the bundled python + CLI, then boots the whole app +# and polls backend health. +on: + push: + branches: [eric/intel-x64-fix] + paths: ['.github/workflows/intel-x64-verify.yml'] + workflow_dispatch: + inputs: + runner: + description: 'runner label (must be an Intel x64 mac)' + default: 'macos-15-large' + required: true + +jobs: + verify: + runs-on: ${{ inputs.runner || 'macos-15-intel' }} + timeout-minutes: 25 + steps: + - name: prove this runner is real Intel silicon + run: | + set -x + uname -m + sysctl -n machdep.cpu.brand_string + sysctl hw.optional.avx1_0 hw.optional.avx2_0 + test "$(uname -m)" = "x86_64" + test "$(sysctl -n hw.optional.avx1_0)" = "1" + + - name: download published x64 DMG + run: | + curl -sSL -o /tmp/x64.dmg "https://github.com/${{ github.repository }}/releases/latest/download/OpenSwarm-x64.dmg" + ls -la /tmp/x64.dmg + hdiutil attach -nobrowse -readonly -mountpoint /tmp/oswmnt /tmp/x64.dmg + mkdir -p /tmp/oswapp + ditto /tmp/oswmnt/OpenSwarm.app /tmp/oswapp/OpenSwarm.app + hdiutil detach /tmp/oswmnt + + - name: gatekeeper + signature + run: | + codesign --verify --deep --strict /tmp/oswapp/OpenSwarm.app + spctl -a -t exec -vv /tmp/oswapp/OpenSwarm.app + xcrun stapler validate /tmp/oswapp/OpenSwarm.app + + - name: binary arch census + run: | + R=/tmp/oswapp/OpenSwarm.app/Contents/Resources + for b in \ + /tmp/oswapp/OpenSwarm.app/Contents/MacOS/OpenSwarm \ + "$R/python-env/bin/python3.13" \ + "$R/python-env/lib/python3.13/site-packages/claude_agent_sdk/_bundled/claude" \ + "$R/node/x64/bin/node" \ + "$R/backend/uv-bin/uv"; do + A=$(lipo -archs "$b") + echo "$A $b" + case "$A" in *x86_64*) ;; *) echo "WRONG ARCH"; exit 1;; esac + done + + - name: bundled python runs natively + backend deps import + run: | + R=/tmp/oswapp/OpenSwarm.app/Contents/Resources + "$R/python-env/bin/python3" --version + "$R/python-env/bin/python3" -c "import fastapi, anthropic, pydantic, httpx, jsonschema, claude_agent_sdk; print('deps ok')" + + - name: bundled claude CLI runs natively (the AVX gate Rosetta could not test) + run: | + CLI=/tmp/oswapp/OpenSwarm.app/Contents/Resources/python-env/lib/python3.13/site-packages/claude_agent_sdk/_bundled/claude + OUT=$("$CLI" --version 2>&1); echo "$OUT" + echo "$OUT" | grep -q "Claude Code" + if echo "$OUT" | grep -qi "lacks AVX"; then echo "AVX warning on real Intel = fail"; exit 1; fi + "$CLI" --help > /dev/null + # a real invocation exercises the JIT/network paths; a clean auth + # error (not a SIGILL/crash) is the pass condition + set +e + # macOS has no `timeout`; perl alarm is the portable equivalent + ANTHROPIC_API_KEY=sk-ant-invalid perl -e 'alarm 90; exec @ARGV' -- "$CLI" -p "hi" --model claude-haiku-4-5-20251001 > /tmp/cli-run.out 2>&1 + CODE=$? + set -e + cat /tmp/cli-run.out + echo "exit=$CODE" + # 132=SIGILL 139=SIGSEGV 134=SIGABRT: any of those = AVX/crash class + if [ $CODE -eq 132 ] || [ $CODE -eq 139 ] || [ $CODE -eq 134 ]; then exit 1; fi + + - name: boot the full app, poll backend health + run: | + cd /tmp/oswapp + OPENSWARM_E2E=1 ./OpenSwarm.app/Contents/MacOS/OpenSwarm > /tmp/boot.log 2>&1 & + APP_PID=$! + for i in $(seq 1 60); do + CODE=$(curl -s -o /dev/null -w '%{http_code}' --max-time 2 http://127.0.0.1:8324/api/health/check || true) + [ "$CODE" = "200" ] && break + sleep 2 + done + echo "health=$CODE after ~$((i*2))s" + kill $APP_PID 2>/dev/null || true + tail -30 /tmp/boot.log || true + test "$CODE" = "200" + + verify-windows: + runs-on: windows-latest + timeout-minutes: 30 + steps: + - name: download published Setup.exe + shell: pwsh + run: | + curl.exe -sSL -o $env:TEMP\OpenSwarm-Setup-x64.exe "https://github.com/${{ github.repository }}/releases/latest/download/OpenSwarm-Setup-x64.exe" + Get-Item $env:TEMP\OpenSwarm-Setup-x64.exe | Select-Object Name,Length + + - name: silent install (Squirrel) + shell: pwsh + run: | + Start-Process -FilePath "$env:TEMP\OpenSwarm-Setup-x64.exe" -ArgumentList "--silent" + # the root OpenSwarm.exe is Squirrel's stub; the real app + resources + # live in app-\. python.exe appearing = install truly done. + $deadline = (Get-Date).AddMinutes(10) + do { + Start-Sleep -Seconds 5 + $py = Get-ChildItem "$env:LOCALAPPDATA\openswarm\app-*\resources\python-env\python.exe" -ErrorAction SilentlyContinue | Select-Object -First 1 + } until ($py -or (Get-Date) -gt $deadline) + if (-not $py) { Get-ChildItem "$env:LOCALAPPDATA\openswarm" -Recurse -Depth 2 -ErrorAction SilentlyContinue | Select-Object FullName -First 40; throw "installed python-env not found" } + $appDir = $py.FullName -replace '\\resources\\python-env\\python\.exe$', '' + echo "APP_EXE=$appDir\OpenSwarm.exe" >> $env:GITHUB_ENV + echo "APP_DIR=$appDir" >> $env:GITHUB_ENV + echo "installed at $appDir" + + - name: bundled python + claude CLI run on real Windows x64 + shell: pwsh + run: | + $py = Join-Path $env:APP_DIR "resources\python-env\python.exe" + & $py --version + if ($LASTEXITCODE -ne 0) { throw "python --version failed" } + & $py -c "import fastapi, anthropic, pydantic, httpx, jsonschema, claude_agent_sdk; print('deps ok')" + if ($LASTEXITCODE -ne 0) { throw "import smoke failed" } + $cli = Get-ChildItem (Join-Path $env:APP_DIR "resources\python-env") -Recurse -Filter "claude*" -ErrorAction SilentlyContinue | Where-Object { $_.Directory.Name -eq "_bundled" } | Select-Object -First 1 + if (-not $cli) { throw "bundled claude CLI not found" } + & $cli.FullName --version + if ($LASTEXITCODE -ne 0) { throw "claude --version failed" } + + - name: boot the installed app, poll backend health + shell: pwsh + run: | + $env:OPENSWARM_E2E = "1" + Start-Process -FilePath $env:APP_EXE + $code = 0 + foreach ($i in 1..60) { + Start-Sleep -Seconds 3 + try { $code = (Invoke-WebRequest -Uri "http://127.0.0.1:8324/api/health/check" -UseBasicParsing -TimeoutSec 2).StatusCode } catch { $code = 0 } + if ($code -eq 200) { break } + } + echo "health=$code" + Stop-Process -Name "OpenSwarm" -Force -ErrorAction SilentlyContinue + if ($code -ne 200) { throw "backend never became healthy" } diff --git a/.github/workflows/smoke-windows-packaged.yml b/.github/workflows/smoke-windows-packaged.yml new file mode 100644 index 00000000..e9d61b7d --- /dev/null +++ b/.github/workflows/smoke-windows-packaged.yml @@ -0,0 +1,148 @@ +name: Windows packaged smoke + +# Install the SHIPPED installer on a clean Windows runner and prove it runs. +# +# Everything else about a Windows release can be checked from a Mac: the signature, the update +# feed's hash, even whether the bundle contains the code it claims (a nupkg is a zip). The one +# thing that needs Windows is whether the thing actually starts. This is that check, and it +# exists because "signed and uploaded" has never meant "boots". +# +# Deliberately reads the RELEASE ASSET, not a fresh build. A build made here would prove a +# different binary works than the one users download. +# +# gh workflow run smoke-windows-packaged.yml -f tag=v1.7.0 + +# A push trigger needs no default-branch registration, unlike workflow_dispatch, which 404s until +# the file is on main. Pushing the throwaway `win-smoke` branch is how you run this before then. +on: + workflow_dispatch: + inputs: + tag: + description: Release tag to smoke (e.g. v1.7.0) + required: true + push: + branches: + - win-smoke + +# write, not read: a DRAFT release is invisible to a read-scoped token, so the asset lookup finds +# nothing and the smoke reports "no release tagged ..." for a release that is plainly there. +# Nothing here writes; the scope is only what makes drafts listable. +permissions: + contents: write + +jobs: + smoke: + runs-on: windows-latest + timeout-minutes: 25 + steps: + - uses: actions/checkout@v4 + + - name: Download the shipped installer + env: + GH_TOKEN: ${{ github.token }} + run: | + # A DRAFT release has no tag reference, so `gh release download ` answers + # "release not found" even though the assets are right there. Resolve it out of the full + # list and pull the asset by id, which works for drafts and published releases alike. + $tag = "${{ inputs.tag || 'v1.7.0' }}" + $rel = gh api "repos/$env:GITHUB_REPOSITORY/releases?per_page=50" | ConvertFrom-Json | + Where-Object { $_.tag_name -eq $tag } | Select-Object -First 1 + if (-not $rel) { throw "no release (draft or published) tagged $tag" } + "release: $($rel.tag_name) draft=$($rel.draft)" + $asset = $rel.assets | Where-Object { $_.name -eq 'OpenSwarm-Setup-x64.exe' } | Select-Object -First 1 + if (-not $asset) { throw "OpenSwarm-Setup-x64.exe is not attached to $tag" } + $exe = Join-Path $env:RUNNER_TEMP "OpenSwarm-Setup-x64.exe" + gh api -H "Accept: application/octet-stream" "repos/$env:GITHUB_REPOSITORY/releases/assets/$($asset.id)" > $exe + $size = (Get-Item $exe).Length + "installer: $size bytes (release says $($asset.size))" + if ($size -ne $asset.size) { throw "FAIL: download is truncated" } + + - name: It is signed, and Windows agrees + run: | + $exe = Join-Path $env:RUNNER_TEMP "OpenSwarm-Setup-x64.exe" + $sig = Get-AuthenticodeSignature $exe + "status : $($sig.Status)" + "signer : $($sig.SignerCertificate.Subject)" + if ($sig.Status -ne 'Valid') { throw "FAIL: signature is $($sig.Status)" } + "PASS authenticode valid" + + - name: Install it the way a user does + run: | + $exe = Join-Path $env:RUNNER_TEMP "OpenSwarm-Setup-x64.exe" + # This is a Squirrel installer, not NSIS. `/S` means nothing to Squirrel, so it opens a + # UI and waits for a click that never comes on a runner; the switch it honours is + # `--silent`. The bounded wait is here because that failure looks like a hang, and a + # 25-minute timeout tells you nothing about why. + $p = Start-Process -FilePath $exe -ArgumentList "--silent" -PassThru + if (-not $p.WaitForExit(600000)) { + $log = "$env:LOCALAPPDATA\SquirrelTemp\SquirrelSetup.log" + if (Test-Path $log) { "--- SquirrelSetup.log ---"; Get-Content $log -Tail 60 } + Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue + throw "FAIL: installer still running after 10 minutes" + } + "installer exit: $($p.ExitCode)" + if ($p.ExitCode -ne 0) { throw "FAIL: installer exited $($p.ExitCode)" } + "PASS installed" + + - name: The app landed where it should + id: locate + run: | + # Squirrel installs per-user into %LOCALAPPDATA%\OpenSwarm as a stub launcher beside a + # versioned app- folder. The stub is what a shortcut points at; the versioned exe is + # the one that holds resources\ and is the only one worth inspecting or launching. + $root = "$env:LOCALAPPDATA\OpenSwarm" + if (-not (Test-Path $root)) { throw "FAIL: $root does not exist after install" } + Get-ChildItem $root | Select-Object -ExpandProperty Name + $app = Get-ChildItem -Path $root -Filter "app-*" -Directory | + Sort-Object Name -Descending | + ForEach-Object { Join-Path $_.FullName "OpenSwarm.exe" } | + Where-Object { Test-Path $_ } | Select-Object -First 1 + if (-not $app) { throw "FAIL: no app-*\OpenSwarm.exe under $root" } + "app: $app" + "PASS binary present" + "app=$app" >> $env:GITHUB_OUTPUT + + - name: The bundle carries the code it claims + run: | + # Same check the macOS smoke does. A stale build passes every signature test and still + # ships none of the fixes, which is exactly how a release gets shipped twice. + $res = Split-Path "${{ steps.locate.outputs.app }}" -Parent + $checks = @( + @{ f = "resources\backend\apps\agents\manager\run\TurnRunner.py"; needle = "pending_continuation"; name = "MCP activation hard-stop" }, + @{ f = "resources\backend\apps\workflows\cloud\handover.py"; needle = "lend_credential_for_cloud"; name = "cloud credential lease" }, + @{ f = "resources\backend\apps\tools_lib\mcp_failure_reason.py"; needle = "sign-in has expired"; name = "readable MCP failures" } + ) + $bad = 0 + foreach ($c in $checks) { + $path = Join-Path $res $c.f + if ((Test-Path $path) -and (Select-String -Path $path -Pattern $c.needle -Quiet)) { + "PASS $($c.name)" + } else { "FAIL $($c.name) missing"; $bad++ } + } + if ($bad -gt 0) { throw "$bad expected fix(es) absent from the shipped bundle" } + + - name: It launches, and its backend answers + run: | + $app = "${{ steps.locate.outputs.app }}" + $proc = Start-Process -FilePath $app -PassThru + $ok = $false + foreach ($i in 1..60) { + Start-Sleep -Seconds 3 + try { + # Unauthenticated, so a 401 is a healthy backend: it answered and refused. + Invoke-WebRequest -Uri "http://127.0.0.1:8324/api/settings" -TimeoutSec 4 -UseBasicParsing | Out-Null + $ok = $true; break + } catch { + if ($_.Exception.Response.StatusCode.value__ -eq 401) { $ok = $true; break } + } + # Launched the versioned exe, not the stub, precisely so an exit here means the app + # died rather than a launcher handing off and returning. + if ($proc.HasExited) { throw "FAIL: app exited early with $($proc.ExitCode)" } + } + Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue + if (-not $ok) { throw "FAIL: backend never answered on :8324" } + "PASS app launched and its backend answered" + + - name: Verdict + run: | + "Windows packaged smoke passed for ${{ inputs.tag || 'v1.7.0' }}" diff --git a/backend/apps/agents/manager/run/TurnRunner.py b/backend/apps/agents/manager/run/TurnRunner.py index 3a650d50..90df2b49 100644 --- a/backend/apps/agents/manager/run/TurnRunner.py +++ b/backend/apps/agents/manager/run/TurnRunner.py @@ -53,6 +53,14 @@ class TurnRunner(AgentManagerProtocol): # Per-turn thinking aggregation trackers (added for the "Thought for Ns ยท M tokens" persisted label). Without nonlocal, the int reassignments at AssistantMessage emission below shadow them as locals and the dict access at content_block_start crashes with UnboundLocalError. # p_stream lets the persistent-client path feed receive_response() through this same consumption loop (one body, two transports). async for message in (p_stream if p_stream is not None else query(prompt=prompt_stream(), options=options)): + # MCPActivate tells the model its new tools are not callable yet and to stop. Asking + # was not enough: it kept going and guessed names like `send email`, which is what + # made every MCP task look broken. The activated tools genuinely do not exist until + # the transport is rebuilt, so end the turn here and let the auto-continuation fire + # with the real names. Checked before the message is handled, so the model's next + # move after activating never runs. + if getattr(session, "pending_continuation", False): + break if isinstance(message, ResultMessage): turn.current_turn_emitted = False else: diff --git a/backend/apps/nine_router/lent_credential_refresh.py b/backend/apps/nine_router/lent_credential_refresh.py new file mode 100644 index 00000000..8fd0eb7b --- /dev/null +++ b/backend/apps/nine_router/lent_credential_refresh.py @@ -0,0 +1,104 @@ +"""Keeping this device usable while the cloud holds custody of a provider credential. + +Handing a credential to the cloud strips the local refresh token on purpose: exactly one +holder may rotate it. 9Router's refresh dispatcher then bails on the falsy refreshToken +without calling the provider, so nothing renews the access token and every local call +starts failing a few hours after the handover, with no error that explains why. + +This is the other half of that trade. The cloud rotates; this device asks the cloud for a +fresh access token shortly before the current one dies. Only lent connections are touched: +one that still holds a refresh token is 9Router's job and must be left alone. + +Deliberately lazy. Each pull rewrites db.json, which means stopping the router and starting +it again, so we act only inside the margin and never on a fixed cadence. +""" +from __future__ import annotations + +import asyncio +import logging +import time +from typing import List, Optional + +from typeguard import typechecked + +from backend.apps.nine_router import credential_lease, credential_store + +logger = logging.getLogger(__name__) + +# Pull this far ahead of expiry. Wide enough that a failure leaves room for several retries +# before anything 401s, narrow enough that we are not restarting the router for fun. +REFRESH_MARGIN_S = 15 * 60 +CHECK_INTERVAL_S = 120.0 +# A dead network would otherwise mean a router restart every two minutes forever. +FAILURE_BACKOFF_S = 900.0 + + +@typechecked +def p_seconds_left(expires_at: Optional[str]) -> Optional[float]: + """Seconds until this token dies, or None when the timestamp is unreadable.""" + ms = credential_lease.expires_ms(expires_at) + if ms <= 0: + return None + return (ms / 1000.0) - time.time() + + +@typechecked +def lent_connections_needing_a_pull() -> List[str]: + """Connections the cloud holds whose access token is spent or nearly so. + + A missing refresh token is what marks a connection as lent, and an unreadable expiry is + treated as due: better one wasted pull than a token that quietly stops working. + """ + due: List[str] = [] + for connection_id in credential_store.list_oauth_connection_ids(): + cred = credential_store.read_credential(connection_id) + if cred is None or cred.refresh_token: + continue + left = p_seconds_left(cred.expires_at) + if left is None or left <= REFRESH_MARGIN_S: + due.append(connection_id) + return due + + +# A pull rewrites db.json, which stops and restarts the router, so two of them racing the same +# connection is not just wasteful: it is two writers on one file, and one edit loses. +p_in_flight: set[str] = set() + + +@typechecked +async def refresh_lent_credentials() -> int: + """Top up every lent connection that needs it. Returns how many are now good.""" + refreshed = 0 + for connection_id in lent_connections_needing_a_pull(): + if connection_id in p_in_flight: + continue + p_in_flight.add(connection_id) + try: + outcome = await credential_lease.pull_access_token(connection_id) + finally: + p_in_flight.discard(connection_id) + if outcome.status == "refreshed": + refreshed += 1 + continue + # Never the token itself, only why we could not get one. + logger.warning( + "could not renew the cloud-held credential %s: %s %s", + connection_id, + outcome.status, + outcome.detail, + ) + return refreshed + + +@typechecked +async def lent_credential_loop() -> None: + while True: + delay = CHECK_INTERVAL_S + try: + due = lent_connections_needing_a_pull() + if due and await refresh_lent_credentials() == 0: + delay = FAILURE_BACKOFF_S + except Exception: + logger.exception("lent-credential refresh pass failed") + delay = FAILURE_BACKOFF_S + await asyncio.sleep(delay) diff --git a/backend/apps/outputs/outputs.py b/backend/apps/outputs/outputs.py index 930d4f1a..604e3d2b 100644 --- a/backend/apps/outputs/outputs.py +++ b/backend/apps/outputs/outputs.py @@ -17,6 +17,7 @@ from backend.apps.outputs.models import ( ) from backend.apps.outputs.code_safety import get_code_warnings from backend.apps.outputs.executor import execute_backend_code +from backend.apps.outputs.publish_capability import check_publish_capability from backend.apps.outputs.publish_common import slugify, PublishError from backend.apps.outputs.publish_scan import scan_for_publish, quick_ast_gate from backend.apps.outputs.publish_build import build_static, collect_bundle @@ -772,12 +773,16 @@ async def publish_output(body: PublishRequest): output = load(body.output_id) settings = load_settings() if not body.force: + capability = check_publish_capability(output).findings ast = quick_ast_gate(output) - if ast: + if capability or ast: return PublishResult( ok=False, blocked=True, - review=PublishReview(verdict="warn", findings=ast), + review=PublishReview( + verdict="block" if capability else "warn", + findings=capability + ast, + ), ).model_dump() output.publish_status = "publishing" diff --git a/backend/apps/outputs/publish_capability.py b/backend/apps/outputs/publish_capability.py new file mode 100644 index 00000000..eee88477 --- /dev/null +++ b/backend/apps/outputs/publish_capability.py @@ -0,0 +1,123 @@ +"""Catch the publish cliff before it ships: an app whose frontend calls its own +FastAPI backend works in preview and breaks on its public URL. + +Publishing uploads a STATIC bundle. The edge serves that bundle plus exactly two +runtime bridges (`/__compute`, which runs a single sandboxed `backend.py`, and +`/__llm`); there is no `/api/*` route, so every `/api/...` fetch falls through to +the static catch-all and 404s. Nothing else in the publish path notices, because +`publish_scan` is a SECURITY scan. This module is the capability scan.""" +from __future__ import annotations + +import os +import re +from typing import List + +from pydantic import BaseModel, ConfigDict +from typeguard import typechecked + +from backend.apps.outputs.models import Output, PublishReview +from backend.apps.outputs.publish_common import is_webapp, workspace_dir +from backend.apps.outputs.workspace_io import WALK_SKIP_DIRS + +P_FRONTEND_EXTS = (".ts", ".tsx", ".js", ".jsx", ".vue", ".svelte", ".html") +P_MAX_FILE_BYTES = 512 * 1024 +P_MAX_LISTED = 8 +# Matches /api/foo, "/api", '/api' and `/api` but not /apiary or /rapid. +P_API_CALL = re.compile(r"/api(?:/|[\"'`]|$)") + + +class PublishCapabilityReport(BaseModel): + model_config = ConfigDict(validate_assignment=True) + + backend_enabled: bool = False + backend_port: str = "" + api_callers: List[str] = [] + findings: List[str] = [] + + +@typechecked +def p_backend_port(root: str) -> str: + """The workspace's BACKEND_PORT, or "" when the backend was never enabled.""" + env_path = os.path.join(root, ".env") + try: + with open(env_path, "r", encoding="utf-8", errors="replace") as fh: + for line in fh: + key, _, value = line.partition("=") + if key.strip() != "BACKEND_PORT": + continue + port = value.split("#", 1)[0].strip() + return "" if port.upper() in ("", "NONE") else port + except OSError: + return "" + return "" + + +@typechecked +def p_api_callers(root: str) -> List[str]: + """Frontend files that reach for /api/..., relative to the workspace root.""" + hits: List[str] = [] + for base, dirs, fnames in os.walk(root): + dirs[:] = [d for d in dirs if d not in WALK_SKIP_DIRS and d != "backend"] + for fn in fnames: + if not fn.lower().endswith(P_FRONTEND_EXTS): + continue + full = os.path.join(base, fn) + if os.path.islink(full): + continue + try: + if os.path.getsize(full) > P_MAX_FILE_BYTES: + continue + with open(full, "r", encoding="utf-8", errors="replace") as fh: + if P_API_CALL.search(fh.read()): + hits.append(os.path.relpath(full, root)) + except OSError: + continue + return sorted(hits) + + +@typechecked +def check_publish_capability(output: Output) -> PublishCapabilityReport: + """Does this app depend on something publishing cannot carry?""" + if not is_webapp(output): + return PublishCapabilityReport() + root = workspace_dir(output) + port = p_backend_port(root) + has_backend = bool(port) or os.path.isfile(os.path.join(root, "backend", "main.py")) + if not has_backend: + return PublishCapabilityReport() + callers = p_api_callers(root) + if not callers: + return PublishCapabilityReport(backend_enabled=True, backend_port=port) + shown = ", ".join(callers[:P_MAX_LISTED]) + if len(callers) > P_MAX_LISTED: + shown += f", and {len(callers) - P_MAX_LISTED} more" + return PublishCapabilityReport( + backend_enabled=True, + backend_port=port, + api_callers=callers, + findings=[ + "This app has a FastAPI backend, and publishing does not upload it. " + f"{len(callers)} frontend file(s) call /api/... ({shown}); those requests " + "will 404 on the published URL even though they work in preview.", + "A published app gets static files plus two same-origin bridges: " + "window.OUTPUT_COMPUTE(input), which runs a single sandboxed backend.py " + "(pure compute, no network, no disk, 30s limit), and window.OUTPUT_LLM(body). " + "Move the server-side logic into backend.py to use OUTPUT_COMPUTE, or keep " + "this app local instead of publishing it.", + ], + ) + + +@typechecked +def merge_capability(output: Output, review: PublishReview) -> PublishReview: + """Capability findings ride OUTSIDE the security memo, which is keyed on a + source hash that never sees .env, so a backend_init.sh run would otherwise + return a cached all-clear.""" + report = check_publish_capability(output) + if not report.findings: + return review + return PublishReview( + verdict="block", + findings=report.findings + review.findings, + scanned_files=review.scanned_files, + ) diff --git a/backend/apps/outputs/publish_scan.py b/backend/apps/outputs/publish_scan.py index d74794fa..29973191 100644 --- a/backend/apps/outputs/publish_scan.py +++ b/backend/apps/outputs/publish_scan.py @@ -17,6 +17,7 @@ from typing import Literal from backend.apps.outputs.code_safety import get_code_warnings from backend.apps.outputs.models import Output, PublishReview +from backend.apps.outputs.publish_capability import merge_capability from backend.apps.outputs.publish_common import is_webapp, workspace_dir from backend.apps.outputs.workspace_io import WALK_SKIP_DIRS @@ -151,7 +152,7 @@ async def scan_for_publish(output: Output, settings) -> PublishReview: cached = memo.get(key) if cached is not None: memo.move_to_end(key) - return cached + return merge_capability(output, cached) ast_findings, scanned = p_ast_findings(src) llm_list, llm_sev = await llm_findings(src, settings) findings = ast_findings + llm_list @@ -169,7 +170,7 @@ async def scan_for_publish(output: Output, settings) -> PublishReview: memo.move_to_end(key) while len(memo) > P_MEMO_MAX: memo.popitem(last=False) - return review + return merge_capability(output, review) def quick_ast_gate(output: Output) -> list[str]: diff --git a/backend/apps/settings/models.py b/backend/apps/settings/models.py index 46c77ae9..fe6f9a94 100644 --- a/backend/apps/settings/models.py +++ b/backend/apps/settings/models.py @@ -43,6 +43,11 @@ class AppSettings(BaseModel): default_max_turns: Optional[int] = None default_thinking_level: Literal["off", "low", "medium", "high", "auto"] = "auto" zoom_sensitivity: float = 50.0 + # What a plain MOUSE wheel does on the canvas. "zoom" is the Google-Maps model we ship; "scroll" + # suits people who expect a wheel to move the page, and swaps the pair so cmd/ctrl+wheel zooms + # instead. A trackpad two-finger scroll pans either way, since that gesture is already a pan + # everywhere else on the machine. + mouse_wheel_action: Literal["zoom", "scroll"] = "zoom" # Root font-size multiplier (0.9/1/1.1/1.2 from Settings > Interface); the whole rem type scale rides it. ui_font_scale: float = 1.0 theme: str = "light" diff --git a/backend/apps/tools_lib/mcp_discovery.py b/backend/apps/tools_lib/mcp_discovery.py index 6872d902..be8bd501 100644 --- a/backend/apps/tools_lib/mcp_discovery.py +++ b/backend/apps/tools_lib/mcp_discovery.py @@ -9,6 +9,7 @@ import httpx from fastapi import HTTPException from backend.apps.tools_lib.mcp_config import augmented_path, resolve_command +from backend.apps.tools_lib.mcp_failure_reason import readable_mcp_failure logger = logging.getLogger(__name__) @@ -182,10 +183,9 @@ async def discover_mcp_tools_stdio(command: str, args: list[str] | None = None, except (asyncio.TimeoutError, asyncio.CancelledError, Exception): pass tail = "".join(stderr_tail[-10:]).strip() - raise HTTPException( - status_code=502, - detail=f"MCP stdio process exited unexpectedly{': ' + tail if tail else ''}", - ) + # A Go server's dying breath is a JSON line with a goroutine dump. Handing that to + # the UI hides the one fact the user can act on, which is usually "sign in again". + raise HTTPException(status_code=502, detail=readable_mcp_failure(tail)) stripped = line.decode(errors="replace").strip() if not stripped: continue diff --git a/backend/apps/tools_lib/mcp_failure_reason.py b/backend/apps/tools_lib/mcp_failure_reason.py new file mode 100644 index 00000000..9ec87dab --- /dev/null +++ b/backend/apps/tools_lib/mcp_failure_reason.py @@ -0,0 +1,66 @@ +"""Turning an MCP server's dying breath into a sentence the user can act on. + +When a stdio MCP server exits during discovery we have its stderr, and we used to hand the raw +tail straight to the UI. For a Go server that means a JSON log line with a full goroutine +stacktrace, which tells a user nothing and hides the one fact that matters: their sign-in expired +and they need to reconnect. + +Only the reasons a user can DO something about get a translation. Anything unrecognised keeps its +raw tail, because a wrong guess is worse than an ugly truth. +""" +from __future__ import annotations + +import json +import re +from typing import List, Optional, Tuple + +from typeguard import typechecked + +# (needle, what to tell the user). Ordered: the first match wins, so put the specific ones first. +P_KNOWN_FAILURES: List[Tuple[str, str]] = [ + ("invalid_auth", "This connection's sign-in has expired. Reconnect it to keep using these tools."), + ("authentication failed", "This connection's sign-in has expired. Reconnect it to keep using these tools."), + ("authentication required", "This connection needs to be signed in before its tools can load."), + ("token_revoked", "Access was revoked on the provider's side. Reconnect to grant it again."), + ("account_inactive", "The connected account is inactive on the provider's side."), + ("missing_scope", "The connected account is missing a permission this server needs. Reconnect to re-approve."), + ("rate limited", "The provider is rate-limiting us right now. Try again in a few minutes."), + ("enoent", "The server's program could not be found on this machine."), + ("eacces", "This machine refused to run the server's program (permission denied)."), +] + + +@typechecked +def p_message_from_json_log(line: str) -> Optional[str]: + """Structured loggers bury the useful sentence in a `message` field next to a stacktrace.""" + try: + parsed = json.loads(line) + except (json.JSONDecodeError, ValueError): + return None + if not isinstance(parsed, dict): + return None + for key in ("message", "msg", "error"): + value = parsed.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + return None + + +@typechecked +def readable_mcp_failure(stderr_tail: str) -> str: + """A sentence for the user, or the raw tail when we genuinely do not recognise it.""" + tail = (stderr_tail or "").strip() + if not tail: + return "The server exited immediately and said nothing about why." + + lowered = tail.lower() + for needle, friendly in P_KNOWN_FAILURES: + if needle in lowered: + return friendly + + # Not a known cause, so keep the truth but drop the goroutine dump and any JSON scaffolding. + for raw_line in reversed(tail.splitlines()): + extracted = p_message_from_json_log(raw_line.strip()) + if extracted: + return re.sub(r"\s+", " ", extracted)[:300] + return re.sub(r"\s+", " ", tail)[:300] diff --git a/backend/apps/workflows/cloud/credential_readiness.py b/backend/apps/workflows/cloud/credential_readiness.py new file mode 100644 index 00000000..13953efb --- /dev/null +++ b/backend/apps/workflows/cloud/credential_readiness.py @@ -0,0 +1,43 @@ +"""Whether this account has an AI connection the cloud could actually run with. + +A cloud run signs its LLM calls with the user's OWN subscription, handed up by +`credential_lease`. Only a rotating OAuth connection can be handed up: an API key has no +refresh token, and the runner cannot mint one. So an account whose only provider is a +Gemini or OpenAI key can never run in the cloud, and the honest moment to say so is +before the user schedules anything, not at 9am when the run refuses. +""" +from __future__ import annotations + +from typing import List, Literal, Optional + +from pydantic import BaseModel, ConfigDict +from typeguard import typechecked + +from backend.apps.nine_router import credential_store + +CONNECT_HINT = ( + "Cloud runs sign in with your own Claude or ChatGPT subscription, so connect one in " + "Settings to run a workflow in the cloud. An API key alone can't be used up there." +) + + +class CredentialReadiness(BaseModel): + model_config = ConfigDict(validate_assignment=True) + + # ready: something is leasable or already lent. none_eligible: only API keys, or nothing at all. + state: Literal["ready", "none_eligible"] + connection_ids: List[str] = [] + # Written for the user, present only when they cannot proceed. + reason: Optional[str] = None + + @property + def ok(self) -> bool: + return self.state == "ready" + + +@typechecked +def cloud_credential_readiness() -> CredentialReadiness: + ids = credential_store.list_oauth_connection_ids() + if not ids: + return CredentialReadiness(state="none_eligible", reason=CONNECT_HINT) + return CredentialReadiness(state="ready", connection_ids=ids) diff --git a/backend/apps/workflows/cloud/handover.py b/backend/apps/workflows/cloud/handover.py index 0d92caae..15b58de7 100644 --- a/backend/apps/workflows/cloud/handover.py +++ b/backend/apps/workflows/cloud/handover.py @@ -15,8 +15,10 @@ from typing import Optional from pydantic import BaseModel, ConfigDict from typeguard import typechecked +from backend.apps.nine_router import credential_lease, credential_store from backend.apps.workflows import scheduler, storage from backend.apps.workflows.cloud import client as cloud +from backend.apps.workflows.cloud.credential_readiness import cloud_credential_readiness from backend.apps.workflows.cloud.definition import cloud_definition, definition_signature from backend.apps.workflows.cloud.portable_context import portable_context from backend.apps.workflows.cloud.schedule import ScheduleSupported, to_cloud_schedule, wire @@ -44,6 +46,42 @@ class TargetOutcome(BaseModel): message: Optional[str] = None +LEASE_FAILED = ( + "Couldn't lend your AI account to the cloud, so nothing was scheduled there. " + "This workflow still runs on this device. Try again in a moment." +) +LEASE_STRANDED = ( + "We couldn't confirm whether your AI account reached the cloud, so nothing was scheduled " + "there. Open Settings and reconnect the provider before trying again." +) + + +@typechecked +async def lend_credential_for_cloud() -> TargetOutcome: + """Make sure the cloud holds a credential it can sign this user's runs with. + + Already-lent is the common case (one lease covers every cloud workflow), so this is a + no-op after the first one. + """ + readiness = cloud_credential_readiness() + if not readiness.ok: + return TargetOutcome(ok=False, message=readiness.reason) + + for connection_id in readiness.connection_ids: + outcome = await credential_lease.lease_to_cloud(connection_id) + if outcome.status in ("leased", "not_rotatable"): + # not_rotatable here means the local copy has already been stripped, i.e. the cloud has it. + return TargetOutcome(ok=True) + if outcome.status == "not_signed_in": + return TargetOutcome(ok=False, message=SIGN_IN_MESSAGE) + if outcome.status == "ownership_unknown": + logger.error("credential lease outcome unknown: %s", outcome.detail) + return TargetOutcome(ok=False, message=LEASE_STRANDED) + logger.info("credential lease for %s failed: %s %s", connection_id, outcome.status, outcome.detail) + + return TargetOutcome(ok=False, message=LEASE_FAILED) + + @typechecked async def hand_to_cloud(wf: Workflow, enabled: bool) -> TargetOutcome: mapping = to_cloud_schedule(wf.schedule) @@ -51,6 +89,11 @@ async def hand_to_cloud(wf: Workflow, enabled: bool) -> TargetOutcome: return TargetOutcome(ok=False, message=mapping.reason) if enabled and not scheduler.is_schedule_configured(wf.schedule): return TargetOutcome(ok=False, message="Finish setting up the schedule before choosing where it runs.") + # Lend the credential BEFORE the workflow goes up. The other order parks a workflow in the cloud + # that cannot sign a single call, and the user only finds out when 9am comes and goes. + lent = await lend_credential_for_cloud() + if not lent.ok: + return TargetOutcome(ok=False, message=lent.message) definition = cloud_definition(wf) context = portable_context().as_body() try: @@ -65,11 +108,17 @@ async def hand_to_cloud(wf: Workflow, enabled: bool) -> TargetOutcome: if hosted.enabled != enabled: hosted = await cloud.set_enabled(hosted.id, enabled) except cloud.SignedOut: + await p_reclaim_credential_if_last(wf.id) return TargetOutcome(ok=False, message=SIGN_IN_MESSAGE) except cloud.CloudRefused as exc: + # The lease already happened, so a refusal here (wrong plan, slots full) would otherwise + # leave the account lent out for a workflow that never went up, and this device unable to + # refresh its own token. + await p_reclaim_credential_if_last(wf.id) return TargetOutcome(ok=False, message=exc.message) except cloud.CloudUnreachable as exc: logger.info("cloud workflow push unreachable for %s: %s", wf.id, exc.detail) + await p_reclaim_credential_if_last(wf.id) return TargetOutcome(ok=False, message=UNREACHABLE_UP) wf.execution_target = "cloud" @@ -104,10 +153,30 @@ async def take_back(wf: Workflow, enabled: bool) -> TargetOutcome: wf.next_run_at = scheduler.compute_next_fire(wf) if wf.schedule.enabled else None wf.updated_at = datetime.now() storage.save_workflow(wf) + await p_reclaim_credential_if_last(wf.id) scheduler.kick() return TargetOutcome(ok=True) +@typechecked +async def p_reclaim_credential_if_last(leaving_id: str) -> None: + """Bring custody home once nothing is left in the cloud that needs it. + + Reclaiming while another cloud workflow is still scheduled would break that one, so the + last one out turns off the lights. Best-effort: a failure here leaves the credential + lent, which still works, rather than failing a toggle the user already got. + """ + if any( + w.id != leaving_id and w.execution_target == "cloud" + for w in storage.list_workflows() + ): + return + for connection_id in credential_store.list_oauth_connection_ids(): + outcome = await credential_lease.release_to_device(connection_id) + if outcome.status not in ("released", "no_such_connection"): + logger.info("could not reclaim %s: %s %s", connection_id, outcome.status, outcome.detail) + + @typechecked async def release_before_removing(wf: Workflow) -> TargetOutcome: """Take the cloud copy down before a workflow disappears from this machine. diff --git a/backend/apps/workflows/cloud/routes.py b/backend/apps/workflows/cloud/routes.py index 2d664671..49743afb 100644 --- a/backend/apps/workflows/cloud/routes.py +++ b/backend/apps/workflows/cloud/routes.py @@ -17,6 +17,7 @@ from pydantic import BaseModel, ConfigDict from typeguard import typechecked from backend.apps.workflows import storage +from backend.apps.nine_router.lent_credential_refresh import lent_credential_loop from backend.apps.workflows.cloud import client as cloud from backend.apps.workflows.cloud.handover import TargetOutcome, hand_to_cloud, take_back from backend.apps.workflows.cloud.run_files import LocalRunFile, described, downloads_root, fetch_missing @@ -27,7 +28,14 @@ from backend.config.Apps import SubApp @asynccontextmanager async def cloud_workflows_lifespan(): - yield + # Lending a credential upward strips this device's ability to renew it, so something has to + # ask the cloud for a fresh one before the old one dies. Without this, turning on a cloud + # workflow quietly stops local agents a few hours later. + task = asyncio.create_task(lent_credential_loop()) + try: + yield + finally: + task.cancel() cloud_workflows = SubApp("cloud_workflows", cloud_workflows_lifespan) diff --git a/backend/apps/workflows/cloud/status.py b/backend/apps/workflows/cloud/status.py index 48f6ce67..3ff4dcfc 100644 --- a/backend/apps/workflows/cloud/status.py +++ b/backend/apps/workflows/cloud/status.py @@ -15,6 +15,7 @@ from typeguard import typechecked from backend.apps.workflows import storage from backend.apps.workflows.cloud import client as cloud +from backend.apps.workflows.cloud.credential_readiness import CredentialReadiness, cloud_credential_readiness from backend.apps.workflows.cloud.definition import cloud_definition, definition_signature from backend.apps.workflows.cloud.portable_context import portable_context from backend.apps.workflows.cloud.schedule import ScheduleSupported, to_cloud_schedule, wire @@ -56,6 +57,9 @@ class CloudStatusReady(CloudStatusBase): # None when this control plane cannot tell us whether the runner could do the job. capability: Optional[cloud.CloudCapability] = None hosted: Optional[HostedState] = None + # Whether this account owns an AI connection the cloud could sign runs with. Read locally, + # because it is our 9router db that knows, not the control plane. + credential: CredentialReadiness CloudStatus = Union[CloudStatusReady, CloudStatusSignedOut, CloudStatusUnknown] @@ -138,5 +142,6 @@ async def compute_status(wf: Workflow) -> CloudStatus: usage=pre.usage, capability=pre.capability, hosted=hosted, + credential=cloud_credential_readiness(), **shared, ) diff --git a/backend/apps/workflows/executor.py b/backend/apps/workflows/executor.py index e8db1e48..b1a3ab55 100644 --- a/backend/apps/workflows/executor.py +++ b/backend/apps/workflows/executor.py @@ -385,7 +385,9 @@ async def execute( # Pin active step so FailedView renders the X on the right row. break if disp == "error": - step_error = "Agent session entered error state" + # This string is the whole explanation in the run-failed email, so it has to read + # like a sentence to someone who was asleep when it fired, not like a status enum. + step_error = "The agent hit an error on this step and stopped before finishing." break run.finished_at = datetime.now() diff --git a/backend/config/entity_references.py b/backend/config/entity_references.py index cc7c2400..b1fe0833 100644 --- a/backend/config/entity_references.py +++ b/backend/config/entity_references.py @@ -30,6 +30,8 @@ class EntityKind(str, Enum): OUTPUT = "output" WORKSPACE = "workspace" CLOUD_WORKFLOW = "cloud_workflow" + # A provider login in 9router's own db, not one of our JSON records. + PROVIDER_CONNECTION = "provider_connection" class EntityStore(BaseModel): @@ -64,6 +66,7 @@ ENTITY_STORES: List[EntityStore] = [ EntityStore(kind=EntityKind.WORKSPACE, module="backend.apps.outputs.outputs", lookup="read_workspace"), # The one referent that does not live on this machine. preflight asks the cloud whether it still has the row; a miss renders as "nothing is running this", never as a silent blank. EntityStore(kind=EntityKind.CLOUD_WORKFLOW, module="backend.apps.workflows.cloud.client", lookup="preflight"), + EntityStore(kind=EntityKind.PROVIDER_CONNECTION, module="backend.apps.nine_router.credential_store", lookup="read_credential"), ] CROSS_ENTITY_REFERENCES: List[EntityReference] = [ @@ -100,6 +103,7 @@ CROSS_ENTITY_REFERENCES: List[EntityReference] = [ EntityReference(module="backend.apps.workflows.models", model="AskRunBody", field="run_id", target=EntityKind.WORKFLOW_RUN), EntityReference(module="backend.apps.workflows.models", model="MissedRun", field="workflow_id", target=EntityKind.WORKFLOW), EntityReference(module="backend.apps.workflows.models", model="Workflow", field="cloud_workflow_id", target=EntityKind.CLOUD_WORKFLOW), + EntityReference(module="backend.apps.workflows.cloud.credential_readiness", model="CredentialReadiness", field="connection_ids", target=EntityKind.PROVIDER_CONNECTION), EntityReference(module="backend.apps.workflows.models", model="Workflow", field="dashboard_id", target=EntityKind.DASHBOARD), EntityReference(module="backend.apps.workflows.models", model="Workflow", field="edit_agent_session_id", target=EntityKind.SESSION), EntityReference(module="backend.apps.workflows.models", model="Workflow", field="last_run_id", target=EntityKind.WORKFLOW_RUN), diff --git a/backend/tests/test_cloud_credential_wiring.py b/backend/tests/test_cloud_credential_wiring.py new file mode 100644 index 00000000..dc65b217 --- /dev/null +++ b/backend/tests/test_cloud_credential_wiring.py @@ -0,0 +1,230 @@ +"""A cloud workflow may never exist without a credential the cloud can sign it with. + +Before this wiring, `lease_to_cloud` had zero callers outside its own unit tests. Every part +worked and nothing joined them, so every cloud run in existence died at dispatch with +`no_cloud_credential` and the user found out at 9am. These tests pin the join. +""" +import pytest + +from backend.apps.nine_router.credential_lease import LeaseOutcome +from backend.apps.workflows.cloud import credential_readiness, handover + + +@pytest.fixture +def p_oauth(monkeypatch): + def set_ids(ids): + monkeypatch.setattr( + credential_readiness.credential_store, "list_oauth_connection_ids", lambda: list(ids) + ) + monkeypatch.setattr( + handover.credential_store, "list_oauth_connection_ids", lambda: list(ids) + ) + return set_ids + + +@pytest.fixture +def p_lease(monkeypatch): + calls = [] + + def set_result(*statuses): + seq = list(statuses) + + async def fake(connection_id: str) -> LeaseOutcome: + calls.append(connection_id) + return LeaseOutcome(status=seq.pop(0) if seq else "cloud_rejected") + + monkeypatch.setattr(handover.credential_lease, "lease_to_cloud", fake) + return calls + + return set_result + + +def test_an_account_with_only_api_keys_cannot_use_cloud_runs(p_oauth): + # Gemini AI Studio and a raw OpenAI key are apikey rows: no refresh token, nothing to lend. + p_oauth([]) + r = credential_readiness.cloud_credential_readiness() + assert r.ok is False + assert r.state == "none_eligible" + assert "Claude or ChatGPT" in (r.reason or ""), "must name what to connect, not just refuse" + assert "API key" in (r.reason or ""), "the API-key user needs to know why theirs will not do" + + +def test_an_oauth_connection_reads_as_ready(p_oauth): + p_oauth(["conn-claude"]) + r = credential_readiness.cloud_credential_readiness() + assert r.ok is True + assert r.connection_ids == ["conn-claude"] + assert r.reason is None + + +@pytest.mark.asyncio +async def test_lending_succeeds_on_the_first_usable_connection(p_oauth, p_lease): + p_oauth(["conn-a", "conn-b"]) + calls = p_lease("leased") + out = await handover.lend_credential_for_cloud() + assert out.ok is True + assert calls == ["conn-a"], "one lease covers every cloud workflow; do not lend them all" + + +@pytest.mark.asyncio +async def test_an_already_stripped_connection_counts_as_lent(p_oauth, p_lease): + # not_rotatable means the local refresh token is already gone, i.e. the cloud has it. + p_oauth(["conn-a"]) + p_lease("not_rotatable") + assert (await handover.lend_credential_for_cloud()).ok is True + + +@pytest.mark.asyncio +async def test_a_refused_connection_falls_through_to_the_next(p_oauth, p_lease): + p_oauth(["conn-dead", "conn-good"]) + calls = p_lease("cloud_rejected", "leased") + out = await handover.lend_credential_for_cloud() + assert out.ok is True + assert calls == ["conn-dead", "conn-good"] + + +@pytest.mark.asyncio +async def test_every_connection_failing_refuses_with_words_a_user_can_act_on(p_oauth, p_lease): + p_oauth(["conn-a"]) + p_lease("cloud_rejected") + out = await handover.lend_credential_for_cloud() + assert out.ok is False + assert "still runs on this device" in (out.message or ""), "say what DID happen, not just what failed" + + +@pytest.mark.asyncio +async def test_signed_out_says_sign_in_rather_than_a_lease_error(p_oauth, p_lease): + p_oauth(["conn-a"]) + p_lease("not_signed_in") + out = await handover.lend_credential_for_cloud() + assert out.ok is False + assert out.message == handover.SIGN_IN_MESSAGE + + +@pytest.mark.asyncio +async def test_an_unknown_lease_outcome_tells_the_user_to_reconnect(p_oauth, p_lease): + # The token is off this device and we cannot prove the cloud took it. Silence here strands them. + p_oauth(["conn-a"]) + p_lease("ownership_unknown") + out = await handover.lend_credential_for_cloud() + assert out.ok is False + assert "reconnect" in (out.message or "").lower() + + +@pytest.mark.asyncio +async def test_no_eligible_connection_refuses_before_anything_is_lent(p_oauth, p_lease): + p_oauth([]) + calls = p_lease("leased") + out = await handover.lend_credential_for_cloud() + assert out.ok is False + assert calls == [], "nothing to lend, so nothing should have been attempted" + assert "Claude or ChatGPT" in (out.message or "") + + +# The join itself. Everything above passes even if hand_to_cloud never calls any of it, which is +# exactly the shape of the bug being fixed: the parts all worked and nothing wired them together. + +from backend.apps.workflows import storage +from backend.apps.workflows.cloud import client as cloud +from backend.apps.workflows.models import ScheduleConfig, Workflow, WorkflowStep + +pytestmark = pytest.mark.usefixtures("isolated_workflows_data") + + +def p_wf() -> Workflow: + wf = Workflow( + title="Morning digest", + steps=[WorkflowStep(text="summarize the news")], + schedule=ScheduleConfig( + enabled=True, repeat_unit="day", repeat_every=1, hour=9, minute=0, timezone="UTC" + ), + ) + storage.save_workflow(wf) + return wf + + +@pytest.mark.asyncio +async def test_a_workflow_never_reaches_the_cloud_without_a_credential(monkeypatch, p_oauth): + """The 9am bug, pinned: no lendable account means the push must not happen at all.""" + p_oauth([]) + wf = p_wf() + talked: list = [] + + async def p_call(method, path, body=None): + talked.append(path) + raise AssertionError("pushed a workflow the cloud could never run") + + monkeypatch.setattr(cloud, "p_call", p_call) + + out = await handover.hand_to_cloud(wf, enabled=True) + assert out.ok is False + assert talked == [], "the credential check has to come BEFORE the push" + assert wf.execution_target == "device", "a refused handover leaves it running here" + assert "Claude or ChatGPT" in (out.message or "") + + +@pytest.mark.asyncio +async def test_a_refused_push_gives_the_account_back(monkeypatch, p_oauth, p_lease): + """A hobby user clicking Cloud leases fine (leasing does not check plan) and is then refused by + the server. Without giving it back, their account stays lent for a workflow that never went up + and this device can no longer refresh its own token.""" + p_oauth(["conn-a"]) + p_lease("leased") + wf = p_wf() + reclaimed: list = [] + + async def p_call(method, path, body=None): + raise cloud.CloudRefused("Cloud workflows need a Pro plan or higher.", 402) + + async def fake_release(connection_id: str): + reclaimed.append(connection_id) + return LeaseOutcome(status="released") + + monkeypatch.setattr(cloud, "p_call", p_call) + monkeypatch.setattr(handover.credential_lease, "release_to_device", fake_release) + + out = await handover.hand_to_cloud(wf, enabled=True) + assert out.ok is False + assert out.message == "Cloud workflows need a Pro plan or higher." + assert reclaimed == ["conn-a"], "the account must come home when the workflow never went up" + + +@pytest.mark.asyncio +async def test_a_reclaim_spares_an_account_another_cloud_workflow_still_needs(monkeypatch, p_oauth, p_lease): + p_oauth(["conn-a"]) + p_lease("leased") + keeper = p_wf() + keeper.execution_target = "cloud" + storage.save_workflow(keeper) + wf = p_wf() + reclaimed: list = [] + + async def p_call(method, path, body=None): + raise cloud.CloudRefused("nope", 402) + + async def fake_release(connection_id: str): + reclaimed.append(connection_id) + return LeaseOutcome(status="released") + + monkeypatch.setattr(cloud, "p_call", p_call) + monkeypatch.setattr(handover.credential_lease, "release_to_device", fake_release) + + await handover.hand_to_cloud(wf, enabled=True) + assert reclaimed == [], "another cloud workflow still needs it; taking it back would break that one" + + +@pytest.mark.asyncio +async def test_a_failed_lease_leaves_the_workflow_on_this_device(monkeypatch, p_oauth, p_lease): + p_oauth(["conn-a"]) + p_lease("cloud_rejected") + wf = p_wf() + + async def p_call(method, path, body=None): + raise AssertionError("pushed despite the lease failing") + + monkeypatch.setattr(cloud, "p_call", p_call) + + out = await handover.hand_to_cloud(wf, enabled=True) + assert out.ok is False + assert wf.execution_target == "device" + assert storage.get_workflow(wf.id).execution_target == "device", "and it stayed that way on disk" diff --git a/backend/tests/test_cloud_workflow_target.py b/backend/tests/test_cloud_workflow_target.py index 9d56949f..d38849ff 100644 --- a/backend/tests/test_cloud_workflow_target.py +++ b/backend/tests/test_cloud_workflow_target.py @@ -17,6 +17,24 @@ from backend.apps.workflows.models import ScheduleConfig, Workflow, WorkflowStep pytestmark = pytest.mark.usefixtures("isolated_workflows_data") +@pytest.fixture(autouse=True) +def p_credential_already_lent(monkeypatch): + """These tests are about the timer, not credential custody. Without this they would reach the + real lease, which reads settings this harness never signs in, and every handover would refuse + with a sign-in message instead of the answer under test. Custody has its own file: + test_cloud_credential_wiring.py.""" + from backend.apps.workflows.cloud import handover + + async def lent(): + return handover.TargetOutcome(ok=True) + + async def reclaimed(leaving_id: str) -> None: + return None + + monkeypatch.setattr(handover, "lend_credential_for_cloud", lent) + monkeypatch.setattr(handover, "p_reclaim_credential_if_last", reclaimed) + + def p_sched(**overrides) -> ScheduleConfig: base = dict(enabled=True, repeat_unit="day", repeat_every=1, hour=9, minute=0, timezone="UTC") base.update(overrides) diff --git a/backend/tests/test_executor_pipeline.py b/backend/tests/test_executor_pipeline.py index 9c344609..b3dda7ec 100644 --- a/backend/tests/test_executor_pipeline.py +++ b/backend/tests/test_executor_pipeline.py @@ -50,7 +50,10 @@ def test_agent_error_marks_failure(make_wf, fake_agent_manager): storage.save_workflow(wf) run = _run(executor.execute(wf, triggered_by="schedule")) assert run.status == "failure" - assert run.error == "Agent session entered error state" + # This string IS the run-failed email's whole explanation, so it must read as a sentence to + # someone who was asleep when it fired, not as an internal status. + assert run.error == "The agent hit an error on this step and stopped before finishing." + assert "session" not in run.error.lower() and "state" not in run.error.lower() def test_scheduled_run_late_start_marks_ran_late(make_wf, fake_agent_manager): diff --git a/backend/tests/test_lent_credential_refresh.py b/backend/tests/test_lent_credential_refresh.py new file mode 100644 index 00000000..66b262d2 --- /dev/null +++ b/backend/tests/test_lent_credential_refresh.py @@ -0,0 +1,386 @@ +"""Local work must survive the cloud borrowing your login. + +Lending strips this device's refresh token so only one holder can rotate. 9Router's refresh +dispatcher then bails on the falsy refreshToken WITHOUT calling the provider, so nothing +renews the access token: a few hours after a successful handover every local agent starts +failing, with no error that names the cause. This file pins the other half of the trade. + +The two ways to get it wrong are opposite and both bad: never pulling (local dies), and +pulling constantly (every pull stops and restarts the router, so a 2-minute cadence would +make the app unusable). +""" +import time + +import pytest + +from backend.apps.nine_router import lent_credential_refresh as lcr +from backend.apps.nine_router.credential_lease import LeaseOutcome +from backend.apps.nine_router.credential_store import ProviderCredential + + +def p_iso(seconds_from_now: float) -> str: + from datetime import datetime, timezone + + return datetime.fromtimestamp(time.time() + seconds_from_now, tz=timezone.utc).isoformat() + + +@pytest.fixture +def p_connections(monkeypatch): + """Install a fake 9router db. `refresh` present means this device still owns it.""" + + def install(rows): + creds = { + r["id"]: ProviderCredential( + connection_id=r["id"], + provider=r.get("provider", "claude"), + access_token="at", + refresh_token=r.get("refresh"), + expires_at=r.get("expires"), + ) + for r in rows + } + monkeypatch.setattr(lcr.credential_store, "list_oauth_connection_ids", lambda: list(creds)) + monkeypatch.setattr(lcr.credential_store, "read_credential", lambda cid: creds.get(cid)) + return creds + + return install + + +@pytest.fixture +def p_pull(monkeypatch): + def install(*statuses): + seq = list(statuses) + calls = [] + + async def fake(connection_id: str) -> LeaseOutcome: + calls.append(connection_id) + return LeaseOutcome(status=seq.pop(0) if seq else "refreshed") + + monkeypatch.setattr(lcr.credential_lease, "pull_access_token", fake) + return calls + + return install + + +def test_a_connection_this_device_still_owns_is_never_touched(p_connections): + # It has its own refresh token, so 9Router renews it. Pulling would fight the router for no reason. + p_connections([{"id": "mine", "refresh": "rt", "expires": p_iso(30)}]) + assert lcr.lent_connections_needing_a_pull() == [] + + +def test_a_lent_connection_with_hours_left_is_left_alone(p_connections): + # Every pull costs a router stop and start, so acting early would be worse than acting late. + p_connections([{"id": "lent", "refresh": None, "expires": p_iso(6 * 3600)}]) + assert lcr.lent_connections_needing_a_pull() == [] + + +def test_a_lent_connection_inside_the_margin_is_due(p_connections): + p_connections([{"id": "lent", "refresh": None, "expires": p_iso(lcr.REFRESH_MARGIN_S - 60)}]) + assert lcr.lent_connections_needing_a_pull() == ["lent"] + + +def test_an_already_expired_lent_connection_is_due(p_connections): + p_connections([{"id": "lent", "refresh": None, "expires": p_iso(-3600)}]) + assert lcr.lent_connections_needing_a_pull() == ["lent"] + + +def test_an_unreadable_expiry_is_treated_as_due(p_connections): + # One wasted pull beats a token that silently stops working because we could not read a date. + p_connections([{"id": "lent", "refresh": None, "expires": "not-a-date"}]) + assert lcr.lent_connections_needing_a_pull() == ["lent"] + + +def test_a_missing_expiry_is_treated_as_due(p_connections): + p_connections([{"id": "lent", "refresh": None, "expires": None}]) + assert lcr.lent_connections_needing_a_pull() == ["lent"] + + +def test_only_the_due_lent_ones_are_selected_out_of_a_mixed_set(p_connections): + p_connections([ + {"id": "mine", "refresh": "rt", "expires": p_iso(10)}, + {"id": "lent-fresh", "refresh": None, "expires": p_iso(4 * 3600)}, + {"id": "lent-due", "refresh": None, "expires": p_iso(60)}, + {"id": "lent-dead", "refresh": None, "expires": p_iso(-99)}, + ]) + assert lcr.lent_connections_needing_a_pull() == ["lent-due", "lent-dead"] + + +@pytest.mark.asyncio +async def test_a_due_connection_gets_pulled(p_connections, p_pull): + p_connections([{"id": "lent", "refresh": None, "expires": p_iso(-1)}]) + calls = p_pull("refreshed") + assert await lcr.refresh_lent_credentials() == 1 + assert calls == ["lent"] + + +@pytest.mark.asyncio +async def test_nothing_due_means_no_router_restart(p_connections, p_pull): + p_connections([{"id": "lent", "refresh": None, "expires": p_iso(6 * 3600)}]) + calls = p_pull("refreshed") + assert await lcr.refresh_lent_credentials() == 0 + assert calls == [], "a pull rewrites db.json and bounces the router; do not do it for nothing" + + +@pytest.mark.asyncio +async def test_an_offline_pull_fails_without_raising(p_connections, p_pull): + # Being offline is normal. It must not take the loop down, and it must not be silent either. + p_connections([{"id": "lent", "refresh": None, "expires": p_iso(-1)}]) + p_pull("cloud_rejected") + assert await lcr.refresh_lent_credentials() == 0 + + +@pytest.mark.asyncio +async def test_one_dead_connection_does_not_block_the_others(p_connections, p_pull): + p_connections([ + {"id": "a", "refresh": None, "expires": p_iso(-1)}, + {"id": "b", "refresh": None, "expires": p_iso(-1)}, + ]) + calls = p_pull("cloud_rejected", "refreshed") + assert await lcr.refresh_lent_credentials() == 1 + assert calls == ["a", "b"] + + +@pytest.mark.asyncio +async def test_a_signed_out_user_is_reported_not_retried_into_a_storm(p_connections, p_pull, caplog): + p_connections([{"id": "lent", "refresh": None, "expires": p_iso(-1)}]) + p_pull("not_signed_in") + assert await lcr.refresh_lent_credentials() == 0 + + +@pytest.mark.asyncio +async def test_a_failure_never_writes_a_token_into_the_log(monkeypatch): + # Not caplog: backend/main.py pins propagate=False on the 'backend' logger and caplog listens + # at the root, so these records only exist if you sit on the logger itself. + import io + import logging + + # A distinctive secret, so this cannot pass by luck. + secret = "sk-ant-oat01-NEVER-LOG-ME-9f3c2b" + monkeypatch.setattr( + lcr.credential_store, + "list_oauth_connection_ids", + lambda: ["lent"], + ) + monkeypatch.setattr( + lcr.credential_store, + "read_credential", + lambda cid: ProviderCredential( + connection_id="lent", provider="claude", access_token=secret, + refresh_token=None, expires_at=p_iso(-1), + ), + ) + + async def leaky(connection_id: str) -> LeaseOutcome: + return LeaseOutcome(status="cloud_rejected", detail="HTTP 500") + + monkeypatch.setattr(lcr.credential_lease, "pull_access_token", leaky) + buf = io.StringIO() + handler = logging.StreamHandler(buf) + handler.setLevel(logging.WARNING) + lcr.logger.addHandler(handler) + try: + await lcr.refresh_lent_credentials() + finally: + lcr.logger.removeHandler(handler) + + written = buf.getvalue() + assert secret not in written, "the access token must never reach a log line" + assert "lent" in written, "but which connection failed has to be diagnosable" + + +# The join. Everything above passes even if nothing ever runs the loop, which is exactly how +# lease_to_cloud sat unwired for its whole life. + +@pytest.mark.asyncio +async def test_the_cloud_subsystem_actually_starts_the_refresh_loop(monkeypatch): + import asyncio + + from backend.apps.workflows.cloud import routes + + started = asyncio.Event() + + async def fake_loop(): + started.set() + await asyncio.sleep(3600) + + monkeypatch.setattr(routes, "lent_credential_loop", fake_loop) + + async with routes.cloud_workflows_lifespan(): + await asyncio.wait_for(started.wait(), timeout=2.0) + + +@pytest.mark.asyncio +async def test_shutting_the_subsystem_down_stops_the_loop(monkeypatch): + import asyncio + + from backend.apps.workflows.cloud import routes + + running = asyncio.Event() + cancelled = asyncio.Event() + + async def fake_loop(): + running.set() + try: + await asyncio.sleep(3600) + except asyncio.CancelledError: + cancelled.set() + raise + + monkeypatch.setattr(routes, "lent_credential_loop", fake_loop) + + async with routes.cloud_workflows_lifespan(): + await asyncio.wait_for(running.wait(), timeout=2.0) + await asyncio.sleep(0) + assert cancelled.is_set(), "a leaked task would keep bouncing the router after shutdown" + + +# Revocation, restart and network interruption. These are the states a long-lived lease actually +# meets in the wild, and each has a distinct right answer: revocation must stop trying and say so, +# a restart must resume without a second holder appearing, and a dropped network must back off +# instead of bouncing the router every two minutes. + +@pytest.mark.asyncio +async def test_a_revoked_credential_stops_the_pull_instead_of_hammering(p_connections, p_pull): + """Anthropic revokes the whole grant family on a replayed refresh token. Once that has happened + no amount of retrying helps, so the pull must fail cleanly rather than spin.""" + p_connections([{"id": "revoked", "refresh": None, "expires": p_iso(-1)}]) + calls = p_pull("cloud_rejected") + assert await lcr.refresh_lent_credentials() == 0 + assert calls == ["revoked"], "one attempt per pass, not a retry storm inside one pass" + + +@pytest.mark.asyncio +async def test_a_revoked_credential_is_still_reported_every_pass(p_connections, p_pull): + # It stays due, so the next pass tries again. That is deliberate: the user may reconnect. + p_connections([{"id": "revoked", "refresh": None, "expires": p_iso(-1)}]) + p_pull("cloud_rejected", "cloud_rejected") + await lcr.refresh_lent_credentials() + assert lcr.lent_connections_needing_a_pull() == ["revoked"], "still due, so recovery is possible" + + +@pytest.mark.asyncio +async def test_a_restart_re_reads_custody_from_disk_and_never_assumes(p_connections, p_pull): + """The loop keeps no state across a restart. Whether a connection is lent is re-derived from + db.json every pass, so a backend that restarts mid-lease cannot decide it owns something the + cloud is holding.""" + install = p_connections + install([{"id": "c", "refresh": None, "expires": p_iso(-1)}]) + calls = p_pull("refreshed") + assert await lcr.refresh_lent_credentials() == 1 + + # The release lands while we are down: the token is back on disk. Nothing cached may override it. + install([{"id": "c", "refresh": "restored-rt", "expires": p_iso(-1)}]) + assert lcr.lent_connections_needing_a_pull() == [], "device owns it again, so hands off" + assert await lcr.refresh_lent_credentials() == 0 + assert calls == ["c"], "no second pull after custody came home" + + +@pytest.mark.asyncio +async def test_a_dropped_network_backs_off_instead_of_bouncing_the_router(p_connections, monkeypatch): + """Every pull rewrites db.json, which stops and restarts 9Router. On a dead network the loop + must widen its interval, or an offline laptop restarts the router every two minutes forever.""" + p_connections([{"id": "c", "refresh": None, "expires": p_iso(-1)}]) + + async def offline(connection_id: str) -> LeaseOutcome: + return LeaseOutcome(status="cloud_rejected", detail="ConnectError") + + monkeypatch.setattr(lcr.credential_lease, "pull_access_token", offline) + + import asyncio + delays: list[float] = [] + + async def capture(d): + delays.append(d) + raise asyncio.CancelledError + + monkeypatch.setattr(asyncio, "sleep", capture) + with pytest.raises(asyncio.CancelledError): + await lcr.lent_credential_loop() + + assert delays == [lcr.FAILURE_BACKOFF_S], f"expected the long backoff, got {delays}" + assert lcr.FAILURE_BACKOFF_S > lcr.CHECK_INTERVAL_S * 4, "backoff has to be meaningfully longer" + + +@pytest.mark.asyncio +async def test_a_healthy_pass_keeps_the_normal_cadence(p_connections, p_pull, monkeypatch): + p_connections([{"id": "c", "refresh": None, "expires": p_iso(-1)}]) + p_pull("refreshed") + + import asyncio + delays: list[float] = [] + + async def capture(d): + delays.append(d) + raise asyncio.CancelledError + + monkeypatch.setattr(asyncio, "sleep", capture) + with pytest.raises(asyncio.CancelledError): + await lcr.lent_credential_loop() + + assert delays == [lcr.CHECK_INTERVAL_S], "a success must not punish the next check" + + +@pytest.mark.asyncio +async def test_an_unexpected_exception_never_kills_the_loop(p_connections, monkeypatch): + """A loop that dies on one bad pass leaves the device unable to renew, silently, forever.""" + p_connections([{"id": "c", "refresh": None, "expires": p_iso(-1)}]) + + async def boom(connection_id: str) -> LeaseOutcome: + raise RuntimeError("disk full") + + monkeypatch.setattr(lcr.credential_lease, "pull_access_token", boom) + + import asyncio + delays: list[float] = [] + + async def capture(d): + delays.append(d) + raise asyncio.CancelledError + + monkeypatch.setattr(asyncio, "sleep", capture) + with pytest.raises(asyncio.CancelledError): + await lcr.lent_credential_loop() + + assert delays == [lcr.FAILURE_BACKOFF_S], "it survived and backed off" + + +@pytest.mark.asyncio +async def test_two_passes_overlapping_do_not_pull_the_same_connection_twice(p_connections, monkeypatch): + """The loop is one task, but a manual refresh and a scheduled pass can overlap. Each pull + rewrites db.json and bounces the router, so a duplicate is not merely wasteful: two writers + racing the same file is how an edit gets lost.""" + import asyncio + + install = p_connections + install([{"id": "c", "refresh": None, "expires": p_iso(-1)}]) + inflight = 0 + peak = 0 + + async def slow_pull(connection_id: str) -> LeaseOutcome: + nonlocal inflight, peak + inflight += 1 + peak = max(peak, inflight) + await asyncio.sleep(0.05) + # A real pull ends with the device owning a fresh token, so the row stops being due. + install([{"id": "c", "refresh": None, "expires": p_iso(3 * 3600)}]) + inflight -= 1 + return LeaseOutcome(status="refreshed") + + monkeypatch.setattr(lcr.credential_lease, "pull_access_token", slow_pull) + + await asyncio.gather(lcr.refresh_lent_credentials(), lcr.refresh_lent_credentials()) + assert peak <= 1, f"{peak} pulls were in flight at once for the same connection" + + +@pytest.mark.asyncio +async def test_a_second_pass_after_a_successful_pull_is_a_no_op(p_connections, p_pull): + """Idempotency in the shape it actually occurs: once a pull lands, the connection is no longer + due, so the next pass must not touch it again.""" + install = p_connections + install([{"id": "c", "refresh": None, "expires": p_iso(-1)}]) + calls = p_pull("refreshed") + assert await lcr.refresh_lent_credentials() == 1 + + install([{"id": "c", "refresh": None, "expires": p_iso(4 * 3600)}]) + assert await lcr.refresh_lent_credentials() == 0 + assert calls == ["c"], "a fresh token must not be pulled again" diff --git a/backend/tests/test_mcp_activation_hard_stop.py b/backend/tests/test_mcp_activation_hard_stop.py new file mode 100644 index 00000000..f8b54490 --- /dev/null +++ b/backend/tests/test_mcp_activation_hard_stop.py @@ -0,0 +1,63 @@ +"""Activating an MCP server must END the turn, not ask the model nicely to stop. + +MCPActivate returns "its tools are NOT callable in this turn ... Do not attempt any other tool call +now" and queues a hidden continuation for afterwards. That was advisory, and models ignored it: the +observed behaviour was activate google-workspace, then immediately guess `send email`, send a +message with a wrong subject, then narrate "Wrong tool name guess. Let me find the actual Gmail tool +names." Every MCP task looked broken, on every install since 1.6.0. + +The activated tools genuinely do not exist until the transport is rebuilt, so anything the model +does after activating is guesswork by construction. The loop now breaks on the flag. +""" +import inspect + +from backend.apps.agents.manager.run import TurnRunner + + +def p_loop_source() -> str: + src = inspect.getsource(TurnRunner) + start = src.index("async def p_run_streaming_turn") + return src[start:start + 3000] + + +def test_the_turn_loop_breaks_on_a_pending_continuation(): + body = p_loop_source() + assert "pending_continuation" in body, "nothing ends the turn, so the model keeps guessing" + assert "break" in body + + +def test_the_check_runs_BEFORE_the_message_is_handled(): + """A check after handling would still let the model's next tool call execute, which is the + whole bug: the wrong-named call already went out.""" + body = p_loop_source() + loop_at = body.index("async for message in") + check_at = body.index("pending_continuation") + handled_at = body.index("isinstance(message, ResultMessage)") + assert loop_at < check_at < handled_at, ( + "the flag must be read at the top of the iteration, before any message handling" + ) + + +def test_the_reason_is_recorded_where_the_next_reader_will_look(): + body = p_loop_source() + assert "guess" in body.lower(), "a bare break invites someone to delete it as dead code" + + +def test_the_continuation_hook_still_consumes_the_flag(): + """Breaking the loop is only half of it. If the end-of-loop hook stopped firing, activation + would leave the user with a dead turn and no follow-up at all, which is worse than guessing.""" + from backend.apps.agents import agent_manager + + src = inspect.getsource(agent_manager) + assert "pending_continuation" in src + assert "hidden=True" in src, "the continuation must not add a visible user bubble" + + +def test_the_activation_response_still_tells_the_model_what_happened(): + """The hard stop is the enforcement; the words are still what the model reads on the next turn + to understand why it was cut off.""" + from backend.apps.agents import mcp_meta_server + + src = inspect.getsource(mcp_meta_server) + assert "NOT callable in this turn" in src + assert "continuation turn will fire" in src diff --git a/backend/tests/test_mcp_failure_reason.py b/backend/tests/test_mcp_failure_reason.py new file mode 100644 index 00000000..36cd2b21 --- /dev/null +++ b/backend/tests/test_mcp_failure_reason.py @@ -0,0 +1,96 @@ +"""What a user is told when an MCP server dies on them. + +The Slack case, captured live: the server exits with a JSON log line carrying a Go goroutine dump, +and the app rendered the whole thing. The user sees "MCP stdio process exited unexpectedly: at +TracingChannel.traceSync (node:diagnostics_channel:322:14) { status: 1, ... }" and has no way to +learn the actual cause, which was simply that their Slack sign-in had expired. + +The opposite failure matters too: inventing a friendly reason for something we do not recognise +sends people to fix the wrong thing, so anything unknown keeps its real words. +""" +from backend.apps.tools_lib.mcp_failure_reason import readable_mcp_failure + +# Byte-for-byte what slack-mcp-server printed when run with the stored tokens on 2026-08-02. +SLACK_REAL = ( + '{"level":"fatal","timestamp":"2026-08-02T21:16:01-07:00","message":' + '"Authentication failed - check your Slack tokens","app":"slack-mcp-server",' + '"error":"invalid_auth","stacktrace":"github.com/korotovsky/slack-mcp-server/pkg/' + 'provider.newWithXOXC\\n\\t/Users/runner/work/slack-mcp-server/pkg/provider/api.go:761\\n' + 'runtime.main\\n\\t/Users/runner/hostedtoolcache/go/1.25.9/arm64/src/runtime/proc.go:285"}' +) + +SLACK_NO_TOKENS = ( + '{"level":"fatal","message":"Authentication required: Either SLACK_MCP_XOXP_TOKEN, ' + 'SLACK_MCP_XOXB_TOKEN, or both SLACK_MCP_XOXC_TOKEN and SLACK_MCP_XOXD_TOKEN must be provided",' + '"app":"slack-mcp-server","stacktrace":"provider.New\\n\\tapi.go:682"}' +) + + +def test_the_real_slack_failure_becomes_reconnect_advice(): + out = readable_mcp_failure(SLACK_REAL) + assert "sign-in has expired" in out + assert "Reconnect" in out + + +def test_no_stacktrace_survives_into_the_message(): + out = readable_mcp_failure(SLACK_REAL) + for leak in ("goroutine", "github.com", ".go:", "runtime.main", "stacktrace", "{"): + assert leak not in out, f"{leak!r} leaked into what the user reads" + + +def test_missing_tokens_reads_differently_from_expired_ones(): + # Never signed in and signed-in-but-stale need different actions, so they cannot share a string. + never = readable_mcp_failure(SLACK_NO_TOKENS) + expired = readable_mcp_failure(SLACK_REAL) + assert "signed in" in never + assert never != expired + + +def test_revoked_access_says_so(): + assert "revoked" in readable_mcp_failure('{"error":"token_revoked","message":"bad"}').lower() + + +def test_a_missing_permission_points_at_reconnecting(): + out = readable_mcp_failure('{"error":"missing_scope","message":"needs channels:read"}') + assert "permission" in out and "Reconnect" in out + + +def test_a_missing_binary_is_named_plainly(): + out = readable_mcp_failure("spawn npx ENOENT") + assert "could not be found" in out + + +def test_rate_limiting_tells_you_to_wait_not_to_reconnect(): + out = readable_mcp_failure("Error: rate limited, retry after 30s") + assert "rate-limiting" in out + assert "Reconnect" not in out + + +def test_an_unknown_json_failure_keeps_its_real_message(): + # We must not invent a cause. Surface the server's own sentence, minus the scaffolding. + out = readable_mcp_failure('{"level":"fatal","message":"database is locked","stacktrace":"x.go:1"}') + assert out == "database is locked" + + +def test_an_unknown_plain_failure_is_passed_through(): + assert readable_mcp_failure("Segmentation fault (core dumped)") == "Segmentation fault (core dumped)" + + +def test_silence_is_reported_as_silence(): + out = readable_mcp_failure("") + assert "said nothing" in out + + +def test_a_novel_string_is_never_guessed_at(): + out = readable_mcp_failure("could not bind to port 8080") + assert "sign-in" not in out and "Reconnect" not in out + assert "port 8080" in out + + +def test_the_last_line_wins_because_the_fatal_one_comes_last(): + noisy = '{"level":"info","message":"starting up"}\n{"level":"fatal","message":"disk full"}' + assert readable_mcp_failure(noisy) == "disk full" + + +def test_output_stays_short_enough_for_a_toast(): + assert len(readable_mcp_failure("x" * 5000)) <= 300 diff --git a/backend/tests/test_publish_capability.py b/backend/tests/test_publish_capability.py new file mode 100644 index 00000000..21683f36 --- /dev/null +++ b/backend/tests/test_publish_capability.py @@ -0,0 +1,208 @@ +"""The publish cliff: an app with a FastAPI backend works in preview and 404s on +its public URL, because publishing uploads a static bundle and the edge has no +/api/* route. Before this gate, nothing in the publish path noticed.""" +import uuid + +import pytest + +from backend.apps.outputs import publish_common +from backend.apps.outputs.models import Output, PublishReview +from backend.apps.outputs.publish_capability import ( + check_publish_capability, + merge_capability, +) + + +@pytest.fixture +def p_ws_root(tmp_path, monkeypatch): + root = tmp_path / "ws" + root.mkdir() + monkeypatch.setattr(publish_common, "OUTPUTS_WORKSPACE_DIR", str(root)) + return root + + +def p_app(ws_root, *, workspace: bool = True) -> Output: + wsid = uuid.uuid4().hex if workspace else None + if wsid: + (ws_root / wsid).mkdir() + return Output( + name="Demo", description="", icon="view_quilt", + input_schema={"type": "object", "properties": {}, "required": []}, + files={}, workspace_id=wsid, session_id=None, + ) + + +def p_seed(ws_root, output, *, env: str, frontend: str = "", backend_main: bool = False): + root = ws_root / (output.workspace_id or "") + (root / ".env").write_text(env) + if frontend: + fe = root / "frontend" / "src" + fe.mkdir(parents=True) + (fe / "api.ts").write_text(frontend) + if backend_main: + be = root / "backend" + be.mkdir() + (be / "main.py").write_text("app = 1\n") + return root + + +def test_flat_app_has_no_capability_problem(p_ws_root): + out = p_app(p_ws_root, workspace=False) + assert check_publish_capability(out).findings == [] + + +def test_frontend_only_workspace_is_clean(p_ws_root): + out = p_app(p_ws_root) + p_seed(p_ws_root, out, env="BACKEND_PORT=NONE\n", frontend="fetch('/data.json')\n") + report = check_publish_capability(out) + assert report.backend_enabled is False + assert report.findings == [] + + +def test_backend_plus_api_calls_is_blocked(p_ws_root): + out = p_app(p_ws_root) + p_seed( + p_ws_root, out, + env="BACKEND_PORT=8123 # chosen by backend_init.sh\nFRONTEND_PORT=4949\n", + frontend="export const JOBS = '/api/jobs';\nfetch(JOBS);\n", + backend_main=True, + ) + report = check_publish_capability(out) + assert report.backend_enabled is True + assert report.backend_port == "8123" + assert report.api_callers == ["frontend/src/api.ts"] + assert len(report.findings) == 2 + joined = " ".join(report.findings) + assert "frontend/src/api.ts" in joined + assert "OUTPUT_COMPUTE" in joined + + +def test_backend_with_no_callers_loses_nothing(p_ws_root): + """A backend nothing calls is dead weight, not broken functionality.""" + out = p_app(p_ws_root) + p_seed( + p_ws_root, out, env="BACKEND_PORT=8123\n", + frontend="const x = 1;\n", backend_main=True, + ) + report = check_publish_capability(out) + assert report.backend_enabled is True + assert report.findings == [] + + +def test_backend_dir_without_port_still_counts(p_ws_root): + """backend_init.sh calls this state inconsistent; publishing must not shrug.""" + out = p_app(p_ws_root) + p_seed( + p_ws_root, out, env="BACKEND_PORT=NONE\n", + frontend="fetch('/api/things')\n", backend_main=True, + ) + assert check_publish_capability(out).findings != [] + + +def test_apiary_is_not_an_api_call(p_ws_root): + """Prefix matching would flag /apiary and /rapid; the boundary is load-bearing.""" + out = p_app(p_ws_root) + p_seed( + p_ws_root, out, env="BACKEND_PORT=8123\n", + frontend="fetch('/apiary/bees'); fetch('/rapid');\n", backend_main=True, + ) + assert check_publish_capability(out).findings == [] + + +def test_backend_dir_is_not_scanned_for_callers(p_ws_root): + """The backend's own source mentioning /api must not count as a frontend caller.""" + out = p_app(p_ws_root) + root = p_seed(p_ws_root, out, env="BACKEND_PORT=8123\n", backend_main=True) + (root / "backend" / "routes.js").write_text("// mounts /api/jobs\n") + assert check_publish_capability(out).findings == [] + + +def test_merge_escalates_a_clean_security_review_to_block(p_ws_root): + out = p_app(p_ws_root) + p_seed( + p_ws_root, out, env="BACKEND_PORT=8123\n", + frontend="fetch('/api/x')\n", backend_main=True, + ) + merged = merge_capability(out, PublishReview(verdict="clean", findings=[])) + assert merged.verdict == "block" + assert len(merged.findings) == 2 + + +def test_merge_preserves_security_findings_and_order(p_ws_root): + out = p_app(p_ws_root) + p_seed( + p_ws_root, out, env="BACKEND_PORT=8123\n", + frontend="fetch('/api/x')\n", backend_main=True, + ) + merged = merge_capability( + out, PublishReview(verdict="warn", findings=["reads os.environ"], scanned_files=["a.py"]), + ) + assert merged.findings[-1] == "reads os.environ" + assert merged.scanned_files == ["a.py"] + + +def test_merge_is_a_passthrough_when_nothing_is_lost(p_ws_root): + out = p_app(p_ws_root) + p_seed(p_ws_root, out, env="BACKEND_PORT=NONE\n") + review = PublishReview(verdict="warn", findings=["something else"]) + assert merge_capability(out, review) is review + + +def test_missing_env_file_does_not_explode(p_ws_root): + """A half-seeded workspace must read as 'no backend', not raise.""" + out = p_app(p_ws_root) + report = check_publish_capability(out) + assert report.backend_enabled is False + assert report.findings == [] + + +@pytest.mark.asyncio +async def test_publish_route_refuses_to_ship_a_broken_app(p_ws_root, monkeypatch): + """The route is where the loss was silent: it built and uploaded regardless.""" + from backend.apps.outputs import outputs as outputs_mod + from backend.apps.outputs.models import PublishRequest + + out = p_app(p_ws_root) + p_seed( + p_ws_root, out, env="BACKEND_PORT=8123\n", + frontend="fetch('/api/jobs')\n", backend_main=True, + ) + built = [] + monkeypatch.setattr(outputs_mod, "load", lambda _: out) + monkeypatch.setattr(outputs_mod, "load_settings", lambda: None) + monkeypatch.setattr(outputs_mod, "build_static", lambda o: built.append(o)) + + res = await outputs_mod.publish_output(PublishRequest(output_id=out.id)) + + assert res["ok"] is False + assert res["blocked"] is True + assert res["review"]["verdict"] == "block" + assert built == [], "publish must not build once the gate has fired" + + +@pytest.mark.asyncio +async def test_force_is_still_the_escape_hatch(p_ws_root, monkeypatch): + """A user who read the finding can still ship; the gate informs, it does not trap.""" + from backend.apps.outputs import outputs as outputs_mod + from backend.apps.outputs.models import PublishRequest + + out = p_app(p_ws_root) + p_seed( + p_ws_root, out, env="BACKEND_PORT=8123\n", + frontend="fetch('/api/jobs')\n", backend_main=True, + ) + reached = [] + + async def p_boom(_): + reached.append(True) + raise publish_common.PublishError("stopped past the gate") + + monkeypatch.setattr(outputs_mod, "load", lambda _: out) + monkeypatch.setattr(outputs_mod, "save", lambda _: None) + monkeypatch.setattr(outputs_mod, "load_settings", lambda: None) + monkeypatch.setattr(outputs_mod, "build_static", p_boom) + + res = await outputs_mod.publish_output(PublishRequest(output_id=out.id, force=True)) + + assert reached == [True], "force must skip the gate and reach the build" + assert res["ok"] is False diff --git a/backend/tests/test_route_write.py b/backend/tests/test_route_write.py index f3a32910..989284ad 100644 --- a/backend/tests/test_route_write.py +++ b/backend/tests/test_route_write.py @@ -3,8 +3,6 @@ receipt parse, and the fail-open contract (every failure is a typed ok=False, never a crash, never a false success). Network is stubbed; the live cross-site round-trip is owed on a healthy rig (this bench's renderer command path is wedged, same as all browser live-tests).""" -import pytest - from backend.apps.agents.browser import route_write as rw diff --git a/electron/build/after-pack.js b/electron/build/after-pack.js index 30325ca8..a790d0d8 100644 --- a/electron/build/after-pack.js +++ b/electron/build/after-pack.js @@ -10,6 +10,7 @@ // is handled by the package.json extraResources filter; only node_modules needs // this rescue. const fs = require('fs'); +const os = require('os'); const path = require('path'); const { execFileSync } = require('child_process'); @@ -21,6 +22,22 @@ const { execFileSync } = require('child_process'); // app, just with limited DRM); VMP_REQUIRE_SIGN=1 (set by the signed release paths) // turns a missing/failed signature into a hard build failure so prod never ships // an unsigned-for-DRM client silently. +// The EVS client is a pip package, and a system python3 on a modern Mac refuses to install into +// itself (PEP 668). scripts/setup-evs.sh therefore puts it in its own venv, so look there before +// falling back to whatever `python3` means today. Without this the creds can be perfectly correct +// and the sign still dies on ModuleNotFoundError, ten minutes into a release build. +function resolveEvsPython() { + if (process.platform === 'win32') return 'python'; + const venv = path.join(os.homedir(), '.openswarm-evs-venv', 'bin', 'python'); + if (fs.existsSync(venv)) { + try { + execFileSync(venv, ['-c', 'import castlabs_evs'], { stdio: 'ignore' }); + return venv; + } catch { /* venv exists but lacks the package; fall through */ } + } + return 'python3'; +} + function signVmp(context) { const { appOutDir, electronPlatformName, packager } = context; const required = process.env.VMP_REQUIRE_SIGN === '1'; @@ -35,11 +52,11 @@ function signVmp(context) { return; } - // mac: sign the .app bundle; win: sign the unpacked dir holding the exe + framework. - const target = electronPlatformName === 'darwin' - ? path.join(appOutDir, `${packager.appInfo.productFilename}.app`) - : appOutDir; - const py = process.platform === 'win32' ? 'python' : 'python3'; + // Both platforms: hand it the CONTAINING directory, never the .app itself. sign-pkg globs + // `/*.app`, so pointing at the bundle makes it search inside for a nested one and die with + // "No matching executable found" while the app sits right there. + const target = appOutDir; + const py = resolveEvsPython(); try { console.log(`[afterPack] VMP-signing ${target}`); diff --git a/frontend/src/app/Main.tsx b/frontend/src/app/Main.tsx index 43fed04a..74910bfc 100644 --- a/frontend/src/app/Main.tsx +++ b/frontend/src/app/Main.tsx @@ -29,6 +29,7 @@ import { setPanelMode, disableOnboardingAfterCrash } from '@/shared/state/onboar const Analytics = React.lazy(() => import('./pages/Analytics/Analytics')); const OnboardingV3Root = React.lazy(() => import('./components/OnboardingV3/OnboardingV3Root')); +const SignInRequiredGate = React.lazy(() => import('./components/overlays/SignInRequiredGate')); const OnboardingRoot = React.lazy(() => import('./components/Onboarding').then((m) => ({ default: m.OnboardingRoot })), ); @@ -547,6 +548,9 @@ const ThemedApp: React.FC = () => { + + + diff --git a/frontend/src/app/components/overlays/SignInDialog.tsx b/frontend/src/app/components/overlays/SignInDialog.tsx index 55a2498e..bfdf5eb4 100644 --- a/frontend/src/app/components/overlays/SignInDialog.tsx +++ b/frontend/src/app/components/overlays/SignInDialog.tsx @@ -24,7 +24,7 @@ type Stage = 'choose' | 'email_form' | 'code_form'; const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/; -export default function SignInDialog({ onClose, initialStage = 'choose' }: { onClose: () => void; initialStage?: Stage }): JSX.Element { +export default function SignInDialog({ onClose, initialStage = 'choose', mandatory = false }: { onClose: () => void; initialStage?: Stage; mandatory?: boolean }): JSX.Element { const tokens = useClaudeTokens(); const dispatch = useAppDispatch(); const proxyUrl = useAppSelector( @@ -156,7 +156,9 @@ export default function SignInDialog({ onClose, initialStage = 'choose' }: { onC return ( - - - + {!mandatory && ( + + + + )} {stage === 'code_form' ? ( <> s.settings.loaded); + const userId = useAppSelector((s) => s.settings.data.user_id ?? null); + const onboardingActive = useAppSelector((s) => s.onboardingV3.flowActive); + + if (!shouldRequireSignIn({ settingsLoaded, userId, onboardingActive })) return null; + + return undefined} />; +} diff --git a/frontend/src/app/components/overlays/shouldRequireSignIn.test.ts b/frontend/src/app/components/overlays/shouldRequireSignIn.test.ts new file mode 100644 index 00000000..8248dc53 --- /dev/null +++ b/frontend/src/app/components/overlays/shouldRequireSignIn.test.ts @@ -0,0 +1,35 @@ +// Run: node --test frontend/src/app/components/overlays/shouldRequireSignIn.test.ts +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { shouldRequireSignIn } from './shouldRequireSignIn.ts'; + +const base = { settingsLoaded: true, userId: null as string | null, onboardingActive: false }; + +test('the veteran this exists for: settings loaded, no account, no onboarding', () => { + assert.equal(shouldRequireSignIn(base), true); +}); + +test('a signed-in user is never walled', () => { + assert.equal(shouldRequireSignIn({ ...base, userId: 'u-1' }), false); +}); + +test('nothing shows until settings are read, so a launch does not flash a login wall', () => { + assert.equal(shouldRequireSignIn({ ...base, settingsLoaded: false }), false); +}); + +test('a backend that never answers leaves the app usable rather than bricked', () => { + // settingsLoaded stays false forever in that case; the wall must stay down, not go up. + assert.equal(shouldRequireSignIn({ settingsLoaded: false, userId: null, onboardingActive: false }), false); +}); + +test('onboarding owns the screen, so the gate stands down while its own sign-in beat runs', () => { + assert.equal(shouldRequireSignIn({ ...base, onboardingActive: true }), false); +}); + +test('a fresh install that finishes onboarding signed in stays down afterwards', () => { + assert.equal(shouldRequireSignIn({ settingsLoaded: true, userId: 'u-2', onboardingActive: false }), false); +}); + +test('an empty-string user id is not an account', () => { + assert.equal(shouldRequireSignIn({ ...base, userId: '' }), true); +}); diff --git a/frontend/src/app/components/overlays/shouldRequireSignIn.ts b/frontend/src/app/components/overlays/shouldRequireSignIn.ts new file mode 100644 index 00000000..bdf28451 --- /dev/null +++ b/frontend/src/app/components/overlays/shouldRequireSignIn.ts @@ -0,0 +1,18 @@ +// Whether the mandatory sign-in wall should be up right now. +// +// Split out of the component so the branch that can lock every user out of the app is testable +// without a React harness. See SignInRequiredGate.tsx for why the wall exists at all. +export interface SignInGateState { + settingsLoaded: boolean; + userId: string | null; + onboardingActive: boolean; +} + +export function shouldRequireSignIn({ settingsLoaded, userId, onboardingActive }: SignInGateState): boolean { + // Fails OPEN until settings are read: a backend that never answers must not brick a local-first + // app behind a wall the user's own saved account would have taken down. + if (!settingsLoaded) return false; + // Onboarding carries its own sign-in beat and its own curtain; stacking a second one hides both. + if (onboardingActive) return false; + return !userId; +} diff --git a/frontend/src/app/pages/AgentChat/tool-ui/showUiPayload.ts b/frontend/src/app/pages/AgentChat/tool-ui/showUiPayload.ts index 2814a668..4dde7d68 100644 --- a/frontend/src/app/pages/AgentChat/tool-ui/showUiPayload.ts +++ b/frontend/src/app/pages/AgentChat/tool-ui/showUiPayload.ts @@ -57,6 +57,12 @@ export type ShowUiPayload = | { component: 'links'; props: LinksProps } | { component: 'vendored'; name: string; props: Record }; +/** What this widget IS, for callers that size or key off the family (a table wants more room than a + * weather card). The vendored variant carries the real name; for the rest the component is it. */ +export function artifactName(payload: ShowUiPayload): string { + return payload.component === 'vendored' ? payload.name : payload.component; +} + function num(v: unknown): v is number { return typeof v === 'number' && Number.isFinite(v); } diff --git a/frontend/src/app/pages/Dashboard/desktop/AgentNarratorPill.tsx b/frontend/src/app/pages/Dashboard/desktop/AgentNarratorPill.tsx index 5e75319b..8d0e5c65 100644 --- a/frontend/src/app/pages/Dashboard/desktop/AgentNarratorPill.tsx +++ b/frontend/src/app/pages/Dashboard/desktop/AgentNarratorPill.tsx @@ -6,8 +6,9 @@ import DashboardGlyph from '../canvas/DashboardGlyph'; import { GLASS_SURFACE, GLASS_SURFACE_BLUR } from '@/shared/styles/glassSurface'; import ShowUiWidgetView from '@/app/pages/AgentChat/tool-ui/ShowUiWidgetView'; import AskUiBubble from '@/app/pages/AgentChat/tool-ui/AskUiBubble'; +import PillArtifactFrame from './PillArtifactFrame'; import type { ToolPair } from '@/app/pages/AgentChat/tool-bubbles/ToolCallBubble'; -import type { ShowUiPayload } from '@/app/pages/AgentChat/tool-ui/showUiPayload'; +import { artifactName, type ShowUiPayload } from '@/app/pages/AgentChat/tool-ui/showUiPayload'; import type { AgentTodoItem } from './agentTodos'; interface AgentNarratorPillProps { @@ -80,13 +81,13 @@ function AgentNarratorPill({ label, running, todos, artifact, askPair, sessionId {liveAsk ? ( - + - + ) : artifact ? ( - + - + ) : browserShot ? ( = [ + [/table|chart|gallery|carousel|terminal|code|diff/i, 560], + [/map|image|video|post/i, 460], + [/stats|plan|links|order|preferences/i, 380], +]; + +export function defaultWidthFor(name: string): number { + for (const [re, w] of FAMILY_WIDTHS) if (re.test(name)) return w; + return DEFAULT_W; +} + +// Keyed by COMPONENT, not by session: having sized a table once, you want every table that way, +// and a per-session key would make the drag feel like it never stuck. +const storageKey = (name: string): string => `osw.artifactWidth.${name}`; + +function storedWidth(name: string): number | null { + try { + const raw = window.localStorage.getItem(storageKey(name)); + const n = raw ? parseInt(raw, 10) : NaN; + return Number.isFinite(n) ? Math.min(Math.max(n, MIN_W), MAX_W) : null; + } catch { return null; } // private mode / quota: fall back to the family default +} + +interface Props { + /** Component name off the payload; picks the resting width and the persistence key. */ + name: string; + children: React.ReactNode; +} + +/** + * The collapsed card's artifact holder: sizes itself to the widget family, lets the user drag that + * width, and keeps the widget INTERACTIVE. + * + * The interactivity is the subtle half. The pill host owns pointerdown (to drag the card) and the + * card owns click/dblclick (select, expand), so every click that landed on a sort button inside a + * table also expanded the card, which is the opposite of what a control is for. Stopping those + * three here means the widget behaves like a widget; the card still drags by its pill and its + * chrome, which is what a user actually aims at to move it. + */ +function PillArtifactFrame({ name, children }: Props): React.ReactElement { + const [width, setWidth] = useState(() => storedWidth(name) ?? defaultWidthFor(name)); + // A different widget arriving in the same card is a different thing to size. + useEffect(() => { setWidth(storedWidth(name) ?? defaultWidthFor(name)); }, [name]); + + const dragRef = useRef<{ startX: number; startW: number } | null>(null); + + const onHandleDown = useCallback((e: React.PointerEvent) => { + e.preventDefault(); + e.stopPropagation(); + dragRef.current = { startX: e.clientX, startW: width }; + try { (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); } catch { /* capture is best-effort */ } + }, [width]); + + const onHandleMove = useCallback((e: React.PointerEvent) => { + const d = dragRef.current; + if (!d) return; + e.stopPropagation(); + setWidth(Math.min(Math.max(d.startW + (e.clientX - d.startX), MIN_W), MAX_W)); + }, []); + + const endDrag = useCallback((e: React.PointerEvent) => { + if (!dragRef.current) return; + dragRef.current = null; + e.stopPropagation(); + try { window.localStorage.setItem(storageKey(name), String(width)); } catch { /* nothing to do if storage is full */ } + try { (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId); } catch { /* already released */ } + }, [name, width]); + + return ( + e.stopPropagation()} + onClick={(e: React.MouseEvent) => e.stopPropagation()} + onDoubleClick={(e: React.MouseEvent) => e.stopPropagation()} + sx={{ position: 'relative', width, maxWidth: '90vw', '&:hover .osw-artifact-grip': { opacity: 1 } }} + > + {children} + + + ); +} + +export default PillArtifactFrame; diff --git a/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts b/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts index 41947f14..dde7420d 100644 --- a/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts +++ b/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts @@ -60,7 +60,12 @@ export interface ContentBounds { maxY: number; } -export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?: ContentBounds, enabled: boolean = true) { +export function useCanvasControls( + zoomSensitivity: number = 50, + contentBounds?: ContentBounds, + enabled: boolean = true, + wheelAction: 'zoom' | 'scroll' = 'zoom', +) { const viewportRef = useRef(null); const contentRef = useRef(null); const gridRef = useRef(null); @@ -77,6 +82,11 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?: const spaceRef = useRef(false); const sensitivityRef = useRef(zoomSensitivity); sensitivityRef.current = zoomSensitivity; + // Read through a ref, like sensitivity: the wheel listener is bound once per mount, so a plain + // closure over the prop would keep the value the canvas had when it mounted and the setting + // would appear to do nothing until you switched dashboards. + const wheelActionRef = useRef(wheelAction); + wheelActionRef.current = wheelAction; const contentBoundsRef = useRef(contentBounds); contentBoundsRef.current = contentBounds; const animFrameRef = useRef(null); @@ -321,6 +331,12 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?: if (selectFullscreenCardId(store.getState())) return; // ctrl/cmd wheel is the zoom gesture on every surface: a physically held key or a trackpad pinch (which also sets ctrlKey). It bypasses scrollable children so zoom is always reachable, even over a chat you're typing in. const isModifierWheel = e.ctrlKey || e.metaKey; + // The setting swaps which of the two a bare mouse notch does. A PINCH must keep zooming + // whatever the setting says: it sets ctrlKey but there is no key held, and nobody pinches to + // scroll. So only a real held key counts as the swap trigger, and `e.ctrlKey && !isPinch` + // cannot be used here because Chromium reports a pinch identically to ctrl+wheel; the + // trackpad classifier is what tells them apart. + const wheelZooms = wheelActionRef.current !== 'scroll'; // Let scrollable children handle the event when appropriate, but fall through to canvas pan if the child is at its scroll boundary. const dy = e.deltaMode === 1 ? e.deltaY * 40 : e.deltaY; @@ -402,12 +418,18 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?: // Horizontal-dominant mouse scroll (tilt wheel) โ†’ pan X. Dominant-axis, so the vertical jitter in a sideways swipe doesn't also zoom. pendingPanDx += dx; scheduleWheelFlush(); - } else { + } else if (wheelZooms) { // Mouse-wheel vertical notch โ†’ zoom at the cursor (same anchor as pinch) so the point under the pointer grows toward you, not away. Clamp the per-event delta so a discrete notch is a small step, not a lurch. const rect = el.getBoundingClientRect(); pendingZoomDy += clamp(dy, -WHEEL_ZOOM_DELTA_CAP, WHEEL_ZOOM_DELTA_CAP); pendingZoomCenter = { cx: e.clientX - rect.left, cy: e.clientY - rect.top }; scheduleWheelFlush(); + } else { + // Setting says a wheel scrolls: pan vertically instead. Zoom is still reachable on + // cmd/ctrl+wheel, which the isModifierWheel branch above already handles, so the two + // gestures simply trade places rather than one of them going missing. + pendingPanDy += dy; + scheduleWheelFlush(); } }; diff --git a/frontend/src/app/pages/Dashboard/hooks/state/useDashboardController.ts b/frontend/src/app/pages/Dashboard/hooks/state/useDashboardController.ts index 09acbe98..0e076678 100644 --- a/frontend/src/app/pages/Dashboard/hooks/state/useDashboardController.ts +++ b/frontend/src/app/pages/Dashboard/hooks/state/useDashboardController.ts @@ -35,7 +35,7 @@ export function useDashboardController(dashboardId: string, isActive: boolean) { workflowCards, workflowItems, workflowOpenCards, workflowsHub, pendingFocusWorkflowId, pendingFocusWorkflowsHub, layoutInitialized, persistedExpandedSessionIds, - zoomSensitivity, newAgentShortcut, browserHomepage, expandNewChats, + zoomSensitivity, mouseWheelAction, newAgentShortcut, browserHomepage, expandNewChats, autoRevealSubAgents, outputs, outputsLoaded, glowingAgentCards, glowingBrowserCards, } = useDashboardSelectors(dashboardId); // sessions is the top-level dict; useMemo on its identity so sessionList is stable when sessions hasn't actually changed (RTK only swaps the dict ref when one of its values changes, so this is the right granularity). @@ -68,7 +68,7 @@ export function useDashboardController(dashboardId: string, isActive: boolean) { [cards, viewCards, browserCards, workflowCards, workflowsHub], ); - const canvas = useCanvasControls(zoomSensitivity, contentBounds, isActive); + const canvas = useCanvasControls(zoomSensitivity, contentBounds, isActive, mouseWheelAction); const selection = useDashboardSelection( { panX: canvas.panX, panY: canvas.panY, zoom: canvas.zoom, viewportRef: canvas.viewportRef }, cards, diff --git a/frontend/src/app/pages/Dashboard/hooks/state/useDashboardSelectors.ts b/frontend/src/app/pages/Dashboard/hooks/state/useDashboardSelectors.ts index 6065d621..a3a82be8 100644 --- a/frontend/src/app/pages/Dashboard/hooks/state/useDashboardSelectors.ts +++ b/frontend/src/app/pages/Dashboard/hooks/state/useDashboardSelectors.ts @@ -36,6 +36,7 @@ export function useDashboardSelectors(dashboardId: string) { const layoutInitialized = useAppSelector((state) => state.dashboardLayout.initialized); const persistedExpandedSessionIds = useAppSelector((state) => state.dashboardLayout.persistedExpandedSessionIds); const zoomSensitivity = useAppSelector((state) => state.settings.data.zoom_sensitivity); + const mouseWheelAction = useAppSelector((state) => state.settings.data.mouse_wheel_action); const newAgentShortcut = useAppSelector((state) => state.settings.data.new_agent_shortcut); const browserHomepage = useAppSelector((state) => state.settings.data.browser_homepage); const expandNewChats = useAppSelector((state) => state.settings.data.expand_new_chats_in_dashboard); @@ -62,6 +63,7 @@ export function useDashboardSelectors(dashboardId: string) { layoutInitialized, persistedExpandedSessionIds, zoomSensitivity, + mouseWheelAction, newAgentShortcut, browserHomepage, expandNewChats, diff --git a/frontend/src/app/pages/Settings/sections/general/GeneralInterface.tsx b/frontend/src/app/pages/Settings/sections/general/GeneralInterface.tsx index 9cc29ddc..4480177a 100644 --- a/frontend/src/app/pages/Settings/sections/general/GeneralInterface.tsx +++ b/frontend/src/app/pages/Settings/sections/general/GeneralInterface.tsx @@ -130,6 +130,26 @@ const GeneralInterface: React.FC<{ /> + + + Mouse wheel + + What a plain mouse wheel does on the canvas. The other one moves to cmd/ctrl + wheel. + A trackpad two-finger scroll always pans, and pinch always zooms. + + + { if (v !== null) setForm({ ...form, mouse_wheel_action: v }); }} + size="small" + sx={toggleGroupSx} + > + Zoom + Scroll + + + Zoom sensitivity @@ -173,11 +193,16 @@ const GeneralInterface: React.FC<{ Auto-enable element selection Automatically enter element selection mode when creating a new agent. - setForm({ ...form, auto_select_mode_on_new_agent: e.target.checked })} - sx={switchSx} - /> + { if (v !== null) setForm({ ...form, auto_select_mode_on_new_agent: v }); }} + size="small" + sx={toggleGroupSx} + > + On + Off + @@ -185,11 +210,17 @@ const GeneralInterface: React.FC<{ Default agent spawn state in dashboard When enabled, new agents spawn expanded instead of collapsed. - setForm({ ...form, expand_new_chats_in_dashboard: e.target.checked })} - sx={switchSx} - /> + {/* Named for the state you get, not on/off: "spawn state" has no obvious on. */} + { if (v !== null) setForm({ ...form, expand_new_chats_in_dashboard: v }); }} + size="small" + sx={toggleGroupSx} + > + Expanded + Collapsed + @@ -197,11 +228,16 @@ const GeneralInterface: React.FC<{ Auto-reveal sub-agents on dashboard Automatically show sub-agent cards (from CreateAgent / InvokeAgent) tethered to their parent on the dashboard. - setForm({ ...form, auto_reveal_sub_agents: e.target.checked })} - sx={switchSx} - /> + { if (v !== null) setForm({ ...form, auto_reveal_sub_agents: v }); }} + size="small" + sx={toggleGroupSx} + > + Show + Hide + Browser diff --git a/frontend/src/app/pages/Workflows/app/CloudRunSection.tsx b/frontend/src/app/pages/Workflows/app/CloudRunSection.tsx index 13736877..5175cfed 100644 --- a/frontend/src/app/pages/Workflows/app/CloudRunSection.tsx +++ b/frontend/src/app/pages/Workflows/app/CloudRunSection.tsx @@ -108,6 +108,9 @@ const CloudRunSection: React.FC<{ workflow: Workflow; cloud: CloudStatusHandle } {availability.kind === 'blocked' && availability.action === 'plans' && ( )} + {availability.kind === 'blocked' && availability.action === 'connect' && ( + + )} @@ -139,6 +142,9 @@ const CloudRunSection: React.FC<{ workflow: Workflow; cloud: CloudStatusHandle } {availability.action === 'plans' && ( )} + {availability.action === 'connect' && ( + + )} )} diff --git a/frontend/src/app/pages/Workflows/app/cloudApi.ts b/frontend/src/app/pages/Workflows/app/cloudApi.ts index 8589f904..ec2d3e9f 100644 --- a/frontend/src/app/pages/Workflows/app/cloudApi.ts +++ b/frontend/src/app/pages/Workflows/app/cloudApi.ts @@ -35,6 +35,13 @@ interface CloudStatusShared { schedule_reason: string | null; } +/** Whether an AI account exists that the cloud could sign runs with. An API key alone cannot. */ +export interface CloudCredential { + state: 'ready' | 'none_eligible'; + connection_ids: string[]; + reason: string | null; +} + export interface CloudStatusReady extends CloudStatusShared { state: 'ready'; plan: string | null; @@ -43,6 +50,7 @@ export interface CloudStatusReady extends CloudStatusShared { /** Null when the control plane could not tell us; create re-checks either way. */ capability: CloudCapability | null; hosted: HostedState | null; + credential: CloudCredential; } export interface CloudStatusSignedOut extends CloudStatusShared { diff --git a/frontend/src/app/pages/Workflows/app/cloudAvailability.ts b/frontend/src/app/pages/Workflows/app/cloudAvailability.ts index ec08107f..b65995ef 100644 --- a/frontend/src/app/pages/Workflows/app/cloudAvailability.ts +++ b/frontend/src/app/pages/Workflows/app/cloudAvailability.ts @@ -11,7 +11,7 @@ export type CloudProbe = export type CloudAvailability = | { kind: 'checking' } | { kind: 'unknown'; detail: string | null } - | { kind: 'blocked'; reason: string; action: 'sign_in' | 'plans' | null } + | { kind: 'blocked'; reason: string; action: 'sign_in' | 'plans' | 'connect' | null } | { kind: 'available' }; const PLAN_REQUIRED = 'Cloud runs come with Pro and up. On this plan, workflows run on this device.'; @@ -53,6 +53,12 @@ export function cloudAvailability(probe: CloudProbe): CloudAvailability { if (status.capability && !status.capability.ok && status.capability.reason) { return { kind: 'blocked', reason: status.capability.reason, action: null }; } + // Before the plan, deliberately. Someone whose only provider is an API key cannot run in the + // cloud at any price, so leading them to the pricing page would sell them a thing that still + // would not work. + if (status.credential && status.credential.state !== 'ready' && status.credential.reason) { + return { kind: 'blocked', reason: status.credential.reason, action: 'connect' }; + } return blockedForAccount(status) ?? { kind: 'available' }; } diff --git a/frontend/src/shared/state/settingsSlice.ts b/frontend/src/shared/state/settingsSlice.ts index 47f10c40..a599d22b 100644 --- a/frontend/src/shared/state/settingsSlice.ts +++ b/frontend/src/shared/state/settingsSlice.ts @@ -43,6 +43,8 @@ export interface AppSettings { default_max_turns: number | null; default_thinking_level: 'off' | 'low' | 'medium' | 'high' | 'auto'; zoom_sensitivity: number; + /** What a plain mouse wheel does on the canvas; trackpad two-finger always pans. */ + mouse_wheel_action: 'zoom' | 'scroll'; theme: 'light' | 'dark'; new_agent_shortcut: string; dictation_shortcut?: string | null; @@ -169,6 +171,7 @@ export const DEFAULT_SETTINGS: AppSettings = { default_max_turns: null, default_thinking_level: 'auto', zoom_sensitivity: 50, + mouse_wheel_action: 'zoom', ui_font_scale: 1, voice_hold_to_talk: true, theme: 'light', diff --git a/linter/config/config.json b/linter/config/config.json index 88b64536..301b0886 100644 --- a/linter/config/config.json +++ b/linter/config/config.json @@ -20,9 +20,9 @@ "eslint-knip": "Node tooling deferred to a later pass.", "classes": "Placeholder check, not wired up. endpoints: orphaned-endpoint triage deferred.", "max-file-lines-exceptions": "Grandfather list of pre-existing >300-line files (existing debt, not new). Paths updated after the folder-tree restructure moved several of them. The two manager/prompt/* entries are from the agent_manager decomposition: prompt_context.py aggregates the system-prompt context builders and attachments.py is one cohesive 230-line attachment resolver; both are single-responsibility and a few lines over, not splittable without an artificial seam.", - "max-folder-items-exceptions": "Exact-path allow for folders intentionally over the cap. The rule trips at >7 (7 items is fine, the 8th tips it), so only genuinely 8+ folders are listed. backend/ and backend/apps are FastAPI feature-package registries (each child is an app mounted in main.py); agents/ aggregates agent subsystems; agents/manager/ is the agent_manager god-object decomposition (cohesive AgentManager mixins + standalone run helpers + the streaming/permissions/prompt/session subtrees), conventionally flat like agents/ and core/ since its standalone helpers are heterogeneous and don't group cleanly; agents/manager/streaming and agents/manager/session are flat peer collections of one-module-per-concern handlers; core/, tools_lib/, tests/ are conventionally flat. Frontend: app/pages is the page registry, AgentChat/ChatInput/Settings-sections/Onboarding are organizational parents, and shared/state (Redux slices) plus hooks/steps/mcp-cards/Views are flat peer collections. scripts/, electron/, linter/checks/ are flat tool dirs. These replaced blanket .lintignore-max-folder-items sentinels (backend, frontend, scripts, electron, linter/checks) so the rule still catches NEW unplanned bloat everywhere else. Kept as whole-subtree sentinels on purpose: debugger/ (self-contained injected sub-tool with its own Vite GUI), webapp_template (Vite scaffold payload), and vendored mcp-bundles. 2026-07 desktop-shell additions: Dashboard canvas/cards/desktop + hooks/interaction + hooks/lifecycle, AgentChat bubbles/tool-ui, and shared/styles are flat peer collections (one component or hook per concern) that crossed 7 as the redesign surface grew. frontend/src/toolui carries a whole-subtree .lintignore: vendored tool-ui component library (pierre), same treatment as mcp-bundles. openswarm-edge/app is the edge's flat one-module-per-concern set (routing, bundles, inject, ratelimit, sandbox, and the vendored code_safety gate); it crossed 7 when the sandbox's static gate was split out to mirror the desktop file byte for byte. AgentChat/parsing joined when the narration/deliverable classifier landed: it is the same flat one-module-per-parser collection as the rest of that subtree.", + "max-folder-items-exceptions": "Exact-path allow for folders intentionally over the cap. The rule trips at >7 (7 items is fine, the 8th tips it), so only genuinely 8+ folders are listed. backend/ and backend/apps are FastAPI feature-package registries (each child is an app mounted in main.py); agents/ aggregates agent subsystems; agents/manager/ is the agent_manager god-object decomposition (cohesive AgentManager mixins + standalone run helpers + the streaming/permissions/prompt/session subtrees), conventionally flat like agents/ and core/ since its standalone helpers are heterogeneous and don't group cleanly; agents/manager/streaming and agents/manager/session are flat peer collections of one-module-per-concern handlers; core/, tools_lib/, tests/ are conventionally flat. Frontend: app/pages is the page registry, AgentChat/ChatInput/Settings-sections/Onboarding are organizational parents, and shared/state (Redux slices) plus hooks/steps/mcp-cards/Views are flat peer collections. scripts/, electron/, linter/checks/ are flat tool dirs. These replaced blanket .lintignore-max-folder-items sentinels (backend, frontend, scripts, electron, linter/checks) so the rule still catches NEW unplanned bloat everywhere else. Kept as whole-subtree sentinels on purpose: debugger/ (self-contained injected sub-tool with its own Vite GUI), webapp_template (Vite scaffold payload), and vendored mcp-bundles. 2026-07 desktop-shell additions: Dashboard canvas/cards/desktop + hooks/interaction + hooks/lifecycle, AgentChat bubbles/tool-ui, and shared/styles are flat peer collections (one component or hook per concern) that crossed 7 as the redesign surface grew. frontend/src/toolui carries a whole-subtree .lintignore: vendored tool-ui component library (pierre), same treatment as mcp-bundles. openswarm-edge/app is the edge's flat one-module-per-concern set (routing, bundles, inject, ratelimit, sandbox, and the vendored code_safety gate); it crossed 7 when the sandbox's static gate was split out to mirror the desktop file byte for byte. AgentChat/parsing joined when the narration/deliverable classifier landed: it is the same flat one-module-per-parser collection as the rest of that subtree. 2026-08-03 browser merge: agents/browser is the flat one-module-per-concern browser tier (40 modules) that arrived whole from eric/browser-merged; .github/workflows crossed 7 when the packaged-smoke and intel-verify workflows landed; frontend/ is a package root, not a code folder. components/overlays is the flat one-component-per-overlay collection; it crossed 7 when the mandatory sign-in gate landed as component + pure predicate + its test.", "import-cycles": "Flags RUNTIME circular imports only (SCC>1). Skips type-only imports (import type / export type) and dynamic import() since neither runs at module init, which is why the idiomatic Redux store<->hooks type cycle is not flagged. Frontend alias resolution comes from import-cycle-aliases. Zero cycles today; the check keeps it that way.", - "ruff + pyright": "Ported from Haik's linter (haik/feat/ingest). ruff is narrowed to F401/F811/F841 (unused imports/redefs/locals) and intentionally DROPS Haik's ARG001/ARG002 (unused args): our SDK-callback signatures require unused params (can_use_tool/pre_tool_hook take a `context` they don't use) and we ban the `_unused` prefix, so ARG is noise here. pyright runs Haik's existence-only config (typeCheckingMode off) with reportAttributeAccessIssue ENABLED: the AgentManager behavior classes now inherit a typing-only AgentManagerProtocol base (manager/AgentManagerProtocol.py) that declares the composed __init__ state + cross-class methods, so the checker sees self.sessions etc. from inside a mixin. pyright caught real bugs: a dangling `_conns` ref + TWO broken lazy imports (`_load_all`/`_load` from outputs.py, renamed to load_all/load in workspace_io but the import sites weren't updated \u2014 App Builder workspace seeding/name-sync was silently failing in a try/except). The one grandfathered SURFACE file (handle_assistant_message) is the SDK-optional try/except-import boundary (TextBlock=object fallback defeats isinstance narrowing). Both grandfather pre-existing debt by file; the refactor surface is clean. Requires `ruff` + `pyright` on PATH (added to requirements-dev.txt); pyright's config expects the venv at backend/.venv.", + "ruff + pyright": "Ported from Haik's linter (haik/feat/ingest). ruff is narrowed to F401/F811/F841 (unused imports/redefs/locals) and intentionally DROPS Haik's ARG001/ARG002 (unused args): our SDK-callback signatures require unused params (can_use_tool/pre_tool_hook take a `context` they don't use) and we ban the `_unused` prefix, so ARG is noise here. pyright runs Haik's existence-only config (typeCheckingMode off) with reportAttributeAccessIssue ENABLED: the AgentManager behavior classes now inherit a typing-only AgentManagerProtocol base (manager/AgentManagerProtocol.py) that declares the composed __init__ state + cross-class methods, so the checker sees self.sessions etc. from inside a mixin. pyright caught real bugs: a dangling `_conns` ref + TWO broken lazy imports (`_load_all`/`_load` from outputs.py, renamed to load_all/load in workspace_io but the import sites weren't updated โ€” App Builder workspace seeding/name-sync was silently failing in a try/except). The one grandfathered SURFACE file (handle_assistant_message) is the SDK-optional try/except-import boundary (TextBlock=object fallback defeats isinstance narrowing). Both grandfather pre-existing debt by file; the refactor surface is clean. Requires `ruff` + `pyright` on PATH (added to requirements-dev.txt); pyright's config expects the venv at backend/.venv.", "no-underscore-names + p-private": "Convention checks ported verbatim from Haik's linter (haik/feat/ingest): no-underscore-names bans leading-underscore names (a dead-code-tooling blind spot; use p_ for private), p-private enforces that p_-prefixed names are accessed only inside their owning file/class (cross-file/class use means the name should be public). Backend Python only. The exception lists grandfather pre-existing debt that landed with the workflows/analytics forward-ports (eric's 'don't mass-migrate untouched files' rule); the agent_manager refactor surface is clean. NOTE: Haik's full linter (his branch also adds pyright + ruff and runs a different enabled set) should eventually supersede this; these two were lifted to enforce the p_ conventions on eric/dev now. browser_cookies.py and its Windows round-trip test are excepted for `_fields_` only: a ctypes.Structure protocol name required by the ctypes metaclass, not our naming.", "dangling-refs": "Every *_id / *_ids field on a backend pydantic model must name the entity it points at, in backend/config/entity_references.py. A model's own primary key is spelled `id`, which never matches the suffix, and neither do words that merely END in id (uuid, grid, valid) since the underscore is required. 42 of the 74 existing fields are declared in the registry (sessions, dashboards, workflows, workflow runs, apps/outputs, workspaces); the 32 listed here are grandfathered debt, and the entry is keyed ::. rather than by file ON PURPOSE, so a NEW id field added to an already-listed model is still caught (a file glob would exempt workflows/models.py forever, which is exactly where the next dangling pointer lands). The grandfathered set is what does not resolve against a store: renderer-owned live objects (browser_id, selected_browser_ids, selected_setting_ids), ids internal to a single record (active_branch_id, msg_id, parent_id, fork_point_message_id, compacted_through_msg_id), external protocol ids we do not own (sdk_session_id, client_message_id, connection_id, installation_id, user_id), telemetry echoes (analytics bridges), and the skill-registry / .swarm-bundle entities that have no backend store module yet. Move an entry out of this list and into the registry when its entity gets one. backend/tests/*::* is blanket-exempt: a test-local model is not a persisted entity. The registry is checked back both ways, so an entry for a deleted field, or a store whose lookup function was renamed, is an error too." }, @@ -154,7 +154,17 @@ "frontend/src/shared/state/agentsSlice.ts", "frontend/src/shared/state/dashboardLayoutSlice.ts", "frontend/src/shared/ws/WebSocketManager.ts", - "backend/apps/workflows/cloud/client.py" + "backend/apps/workflows/cloud/client.py", + "backend/apps/agents/browser/browser_batch_replay.py", + "backend/apps/agents/browser/browser_loop.py", + "backend/apps/agents/browser/browser_prestage.py", + "backend/apps/agents/browser/browser_send_script.py", + "backend/apps/agents/browser/browser_skills.py", + "backend/apps/onboarding/usage/browser_cookies.py", + "backend/tests/test_browser_agent_loop.py", + "backend/tests/test_browser_skills.py", + "frontend/src/shared/state/settingsSlice.ts", + "frontend/src/app/components/overlays/SignInDialog.tsx" ], "max-folder-items": [ "backend", @@ -196,7 +206,11 @@ "linter/checks", "openswarm-edge/app", "scripts", - "backend/apps/workflows/cloud" + "backend/apps/workflows/cloud", + "backend/apps/agents/browser", + ".github/workflows", + "frontend", + "frontend/src/app/components/overlays" ], "no-nested-imports": [], "import-cycles": [], diff --git a/openswarm-runner/Dockerfile b/openswarm-runner/Dockerfile index 31fec01b..841712c7 100644 --- a/openswarm-runner/Dockerfile +++ b/openswarm-runner/Dockerfile @@ -159,10 +159,14 @@ RUN set -eux; \ USER runner WORKDIR /app +# RUNNER_MAX_RUN_SECONDS lives in the IMAGE, not in fly.toml: machines are created one +# per run through the Machines API, which ignores fly.toml's [env], so a cap defined +# there would silently not apply to the only machines that ever run a workflow. ENV HOME=/home/runner \ PYTHONPATH=/app \ PYTHONUNBUFFERED=1 \ PYTHONDONTWRITEBYTECODE=1 \ + RUNNER_MAX_RUN_SECONDS=1800 \ OPENSWARM_HEADLESS=1 \ OPENSWARM_PACKAGED=1 \ OPENSWARM_DATA_ROOT=/data/openswarm \ diff --git a/openswarm-runner/README.md b/openswarm-runner/README.md index 3e8606a5..48e63584 100644 --- a/openswarm-runner/README.md +++ b/openswarm-runner/README.md @@ -121,6 +121,43 @@ If Electron starts but no window ever registers, the run **fails** (exit 7) rath proceeding without a browser. A browser workflow that silently ran blind produces a confident wrong answer, which is worse than no answer. +## Parity with a local run, and the one gap we accept + +A cloud run boots the same Electron shell, the same backend and the same browser code path as a +laptop does, so browser steps behave the same in both places. + +**How well, exactly, is not yet measured on Linux.** The 19-row matrix scores **19/19 on macOS** and +the result file is kept. Nobody has scored the Linux-under-Xvfb side row by row; a code comment in +`openswarm-cloud/src/workflows/runnerCapabilities.ts` used to claim 18/19 with no artifact behind it. +Run `parity/stage.py` on a real runner machine before quoting any cloud number. It needs a native +amd64 host: under qemu on an arm64 Mac, Electron never registers and the harness exits 7 without +scoring anything. + +One row is expected to fail there and is not going to be fixed: `obstacle.bot_wall`, because the run +comes from a datacenter IP. + +**Eric accepted this gap explicitly for 1.7.0 (2026-08-03)**, on the record so nobody has to re-open +the question: a cloud run may hit a bot wall a laptop would have walked through, and that is the +cost of running from a datacenter. Workflows that need your logins are a separate matter and are +refused up front, below. + +Separately, one whole capability is refused at create time rather than failed at 3am: + +**A workflow that needs an account you are already signed into.** Every run gets a fresh browser +profile in a throwaway container. There is no keychain, no cookie jar, and nobody there to type a +password or clear a 2FA prompt. Copying a logged-in session up would mean shipping the user's live +cookies to a machine we destroy minutes later, which is a worse trade than refusing. + +This is declared, not implied: `signed_in_browser` is deliberately absent from +`RUNNER_CAPABILITIES` in `openswarm-cloud/src/workflows/runnerCapabilities.ts`, and +`checkRunnerCapabilities` turns it into a refusal that names the workaround ("run it on your own +machine"). `tests/runner-capabilities.test.ts` asserts the flag stays off, so nobody can quietly +flip it without reading this. + +Everything else in that matrix is a capability flag that can flip when the container learns the +trick. `browser` already did: it was refused until Electron under Xvfb landed, and flipping the one +flag unblocked every browser workflow with no other edit. + ## The credential rule **A `providerConnections[]` entry this runner writes never contains a `refreshToken`.** @@ -154,5 +191,48 @@ and comparing; see the parity matrix in the cloud-browser work notes. ## Deploy -Not deployed. `fly.toml` is written but never applied; read its header first, the app -has to be created onto its own isolated private network by hand before any deploy. +The app exists and is created onto its own isolated private network. Read `fly.toml`'s +header before touching it; the network is fixed at create time and cannot be changed +by a redeploy. + +```bash +# from the REPO ROOT, the image needs backend/ in its build context +fly deploy . --app openswarm-runner --config openswarm-runner/fly.toml \ + --dockerfile openswarm-runner/Dockerfile --image-label latest --ha=false +``` + +`--image-label latest` is load-bearing: the control plane creates machines from the +fixed tag `registry.fly.io/openswarm-runner:latest`, so a redeploy without it ships an +image nothing will ever boot. Re-verify the isolation after any deploy, do not assume +it survived: + +```bash +fly machine run registry.fly.io/openswarm-runner:latest -a openswarm-runner \ + --entrypoint /bin/sleep --restart no --vm-memory 512 --vm-cpus 1 600 +fly ssh console -a openswarm-runner --machine -C "getent hosts openswarm-cloud.internal" +# must print nothing and exit 2. Then destroy the probe machine. +``` + +The deploy leaves one stopped template machine with no run spec. That is expected; it +exits 2 immediately and `[[restart]] policy = 'never'` stops it looping. + +## How a run gets here + +`openswarm-cloud` creates one machine per due workflow through the Fly Machines API +(`workflows/dispatch.ts`). It never uses `fly deploy` for a run, so this app's env is +whatever the IMAGE carries plus `OPENSWARM_RUN_SPEC_FILE`; `fly.toml`'s settings do not +reach a per-run machine. Control-plane side that means: + +| env on openswarm-cloud | why | +| --- | --- | +| `FLY_API_TOKEN` | app-scoped deploy token for `openswarm-runner`, nothing wider | +| `RUN_CALLBACK_BASE_URL` | where the runner reports; **no default**, so a staging control plane can never point its machines at prod | +| `RUNNER_APP` / `RUNNER_IMAGE` / `RUNNER_REGION` | optional overrides of `openswarm-runner` / the `:latest` tag / `iad` | +| `CLOUD_RUNS_GLOBAL_CAP` | machines this whole service will run at once, all accounts together (default 50) | +| `CLOUD_RUNS_TICK_BUDGET` | machines one 60s tick will start (default 20); the rest keep their slot for the next tick | + +A run gets three walls on its wall clock, and only the third survives a wedged VM: +the runner stops its own poll loop at `max_run_seconds`, an independent thread inside +it kills the process 90s later, and the control plane destroys the machine outright +5 minutes past that. Verified live: a machine with a sleeping entrypoint that never +reported was destroyed by the control plane and its run row closed as failed. diff --git a/openswarm-runner/fly.toml b/openswarm-runner/fly.toml index ff2ed454..1f7ff741 100644 --- a/openswarm-runner/fly.toml +++ b/openswarm-runner/fly.toml @@ -30,17 +30,15 @@ kill_timeout = '30s' [build] dockerfile = 'Dockerfile' -[env] - # Hard wall-clock cap, enforced twice inside the container: the poll loop stops the - # run at this mark, and an independent thread kills the process 90s later. A run - # spec asking for more is clamped down to this, never up. - RUNNER_MAX_RUN_SECONDS = '1800' - OPENSWARM_HEADLESS = '1' - OPENSWARM_PACKAGED = '1' - OPENSWARM_DATA_ROOT = '/data/openswarm' - OPENSWARM_HOST = '127.0.0.1' - OPENSWARM_PORT = '8324' - DATA_DIR = '/data/9router' +# No [env] block on purpose. Per-run machines are created through the Machines API, +# which does not read this file, so anything set here would apply to the deploy's +# template machine and to nothing that actually runs a workflow. Every runtime value, +# including the RUNNER_MAX_RUN_SECONDS wall-clock cap, is baked into the image instead. + +# The template machine this deploy creates has no run spec, so it exits 2 immediately. +# Without this it would crash-loop on Fly's default on-failure policy and bill forever. +[[restart]] + policy = 'never' # No [[mounts]]: a run's state is garbage the moment it ends, and an ephemeral rootfs # means one run cannot leave a credential lying around for the next tenant to find. diff --git a/scripts/setup-evs.sh b/scripts/setup-evs.sh new file mode 100755 index 00000000..e4929ab7 --- /dev/null +++ b/scripts/setup-evs.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# One-time castlabs EVS setup, so a Mac release never stalls on missing Widevine creds again. +# +# EVS signs the packaged app so Widevine DRM works, which is what makes Spotify and Netflix play in +# the embedded browser. scripts/build-app.sh hard-fails without it rather than shipping a build +# whose DRM is quietly dead. +# +# What this does: creates (or reuses) an EVS account, stores the password in your login keychain, +# and drops a loader into your shell profile so every future terminal already has it. Run it once. +# +# bash scripts/setup-evs.sh +# +# The password is read with `read -s`, never passed as an argument, so it stays out of `ps` and +# your shell history. + +set -euo pipefail + +KEYCHAIN_SERVICE="openswarm-evs" +VENV="$HOME/.openswarm-evs-venv" +PROFILE="${ZDOTDIR:-$HOME}/.zshrc" + +echo "==> castlabs EVS setup" +echo + +if [[ ! -x "$VENV/bin/python" ]]; then + echo "installing the castlabs-evs client into $VENV ..." + python3 -m venv "$VENV" + "$VENV/bin/pip" install -q --upgrade pip castlabs-evs +fi +EVS="$VENV/bin/python -m castlabs_evs.account" + +read -r -p "EVS account name (an email; use a NEW one if you're creating a fresh account): " ACCOUNT +read -r -s -p "EVS password (pick a strong one; it is never echoed): " PASSWD +echo +echo + +echo "1) Do you already have an EVS account with that name?" +echo " [n] no, create one [y] yes, I know the password [r] yes, but reset it" +read -r -p "> " CHOICE + +case "$CHOICE" in + n|N) + read -r -p "First name: " FIRST + read -r -p "Last name: " LAST + read -r -p "Organization: " ORG + # signup prompts for the emailed code itself, so do NOT ask again afterwards. + $EVS signup -A "$ACCOUNT" -P "$PASSWD" -E "$ACCOUNT" \ + -F "$FIRST" -L "$LAST" -O "$ORG" + ;; + r|R) + $EVS reset -A "$ACCOUNT" + read -r -p "Confirmation code from your email: " CODE + $EVS confirm-reset -A "$ACCOUNT" -C "$CODE" -P "$PASSWD" + ;; + *) + echo "using the existing account as-is" + ;; +esac + +echo +echo "2) proving the credentials actually work ..." +if ! EVS_ACCOUNT_NAME="$ACCOUNT" EVS_PASSWD="$PASSWD" $EVS reauth >/dev/null 2>&1; then + echo " FAILED: EVS rejected that account/password pair. Nothing was saved." + exit 1 +fi +echo " authenticated." + +echo +echo "3) storing the password in your login keychain ..." +security delete-generic-password -s "$KEYCHAIN_SERVICE" -a "$ACCOUNT" >/dev/null 2>&1 || true +security add-generic-password -s "$KEYCHAIN_SERVICE" -a "$ACCOUNT" -w "$PASSWD" -U +echo " stored (service=$KEYCHAIN_SERVICE account=$ACCOUNT)" + +MARK="# openswarm: castlabs EVS creds for signed Mac releases" +if ! grep -qF "$MARK" "$PROFILE" 2>/dev/null; then + echo "4) adding a loader to $PROFILE ..." + { + echo "" + echo "$MARK" + echo "export EVS_ACCOUNT_NAME='$ACCOUNT'" + echo "export EVS_PASSWD=\"\$(security find-generic-password -s $KEYCHAIN_SERVICE -a '$ACCOUNT' -w 2>/dev/null)\"" + echo "export APPLE_TEAM_ID=Y26NUZH4NG" + } >> "$PROFILE" + echo " added." +else + echo "4) $PROFILE already loads them; leaving it alone." +fi + +echo +echo "Done. Open a NEW terminal, then:" +echo " GH_TOKEN=\$(gh auth token) bash publish.sh" +echo +echo "Windows CI keeps its own copy, so if you changed the password, also run:" +echo " gh secret set EVS_ACCOUNT_NAME --body '$ACCOUNT'" +echo " gh secret set EVS_PASSWD --body ''" diff --git a/scripts/smoke-packaged-mac.sh b/scripts/smoke-packaged-mac.sh new file mode 100755 index 00000000..bbc3f076 --- /dev/null +++ b/scripts/smoke-packaged-mac.sh @@ -0,0 +1,126 @@ +#!/usr/bin/env bash +# Smoke a SIGNED, NOTARIZED Mac build the way a user receives it. +# +# "Works in dev" has repeatedly not meant "works packaged" here: dictation died in prod because a +# Finder-launched app inherits a PATH with no brew, and the bundled Python and 9Router live at +# different paths than dev. So this runs the real .app out of the real DMG, dequarantined the way +# a download would be, and checks the things that have actually broken before. +# +# bash scripts/smoke-packaged-mac.sh path/to/OpenSwarm-arm64.dmg +# +# Exits non-zero on the first hard failure. Every check prints PASS or FAIL with what it saw, so a +# red line is a finding and not a puzzle. + +set -uo pipefail +DMG="${1:?usage: smoke-packaged-mac.sh }" +MNT="/tmp/osw-smoke-$$" +APP="" +PASS=0 +FAIL=0 + +ok() { PASS=$((PASS+1)); printf " PASS %s%s\n" "$1" "${2:+ ($2)}"; } +bad() { FAIL=$((FAIL+1)); printf " FAIL %s%s\n" "$1" "${2:+ ($2)}"; } +step() { printf "\n=== %s ===\n" "$1"; } + +cleanup() { + # Order matters and so does patience: the app holds the volume open, and rm-ing a still-mounted + # DMG spews hundreds of "Read-only file system" lines that bury the actual results. + pkill -f "/tmp/osw-smoke-run-$$/OpenSwarm.app" 2>/dev/null + [ -n "${APP:-}" ] && pkill -f "$MNT/OpenSwarm.app" 2>/dev/null + sleep 2 + hdiutil detach "$MNT" -force -quiet 2>/dev/null || hdiutil detach "$MNT" -quiet 2>/dev/null + mount | grep -q "$MNT" || rmdir "$MNT" 2>/dev/null + rm -rf "/tmp/osw-smoke-run-$$" +} +trap cleanup EXIT + +step "0. Nothing else is already pretending to be OpenSwarm" +# An OpenSwarm that is already up owns the single-instance lock, so the copy under test quits the +# instant it launches and step 5 reports "the backend never answered". It answered fine; you were +# just talking to nobody. Refuse to run rather than hand back a scary lie. +STRAY=$(pgrep -f "OpenSwarm.app/Contents/MacOS/OpenSwarm" | tr '\n' ' ') +if [ -n "${STRAY// /}" ]; then + bad "another OpenSwarm is running" "pids: $STRAY -- kill it, then re-run" + exit 1 +fi +ok "no other OpenSwarm running" + +step "1. Mount the DMG the way a download arrives" +mkdir -p "$MNT" +if hdiutil attach "$DMG" -mountpoint "$MNT" -nobrowse -quiet; then + ok "mounted" "$(basename "$DMG")" +else + bad "could not mount the DMG"; exit 1 +fi +APP="$MNT/OpenSwarm.app" +[ -d "$APP" ] && ok "OpenSwarm.app present" || { bad "no .app inside the DMG"; exit 1; } + +step "2. Signing, notarization and DRM" +codesign --verify --deep --strict "$APP" 2>/dev/null && ok "codesign valid" || bad "codesign INVALID" +# -dvv prints the Authority chain; --requirements prints the requirement string, which does NOT +# contain the authority name and made this read as unsigned on a correctly signed build. +AUTH=$(codesign -dvv "$APP" 2>&1 | grep -m1 "^Authority=") +grep -q "Developer ID Application" <<<"$AUTH" \ + && ok "signed with a Developer ID" "${AUTH#Authority=}" || bad "not a Developer ID signature" "$AUTH" +SPCTL=$(spctl -a -vvv -t install "$APP" 2>&1 | tr '\n' ' ') +grep -q "Notarized Developer ID" <<<"$SPCTL" && ok "notarized" || bad "NOT notarized" "$SPCTL" +xcrun stapler validate "$APP" >/dev/null 2>&1 && ok "notarization stapled" || bad "staple missing" +# The Widevine signature is what makes Spotify/Netflix play in the embedded browser. Shipped builds +# carried a DEVELOPMENT certificate for a month because sign-pkg was handed the wrong path. +FW="$APP/Contents/Frameworks/Electron Framework.framework" +[ -f "$FW/Resources/Electron Framework.sig" ] \ + && ok "Widevine VMP signature present" || bad "no VMP signature (DRM will be dead)" + +step "3. The version and the code actually inside the bundle" +VER=$(defaults read "$APP/Contents/Info.plist" CFBundleShortVersionString 2>/dev/null) +[ -n "$VER" ] && ok "version" "$VER" || bad "no version in Info.plist" +RES="$APP/Contents/Resources" +# The build is only worth smoking if it contains the fixes it claims to. +grep -rq "pending_continuation" "$RES/backend/apps/agents/manager/run/TurnRunner.py" 2>/dev/null \ + && ok "MCP activation hard-stop is in the bundle" \ + || bad "MCP hard-stop MISSING (stale build)" +grep -rq "lend_credential_for_cloud" "$RES/backend/apps/workflows/cloud/handover.py" 2>/dev/null \ + && ok "cloud credential lease wiring is in the bundle" \ + || bad "credential lease wiring MISSING (cloud runs cannot work)" +grep -rq "sign-in has expired" "$RES/backend/apps/tools_lib/mcp_failure_reason.py" 2>/dev/null \ + && ok "readable MCP failures are in the bundle" \ + || bad "MCP failure translation MISSING" + +step "4. Bundled runtimes, at their packaged paths" +PY=$(ls -d "$RES/python-env/bin/python3"* 2>/dev/null | head -1) +[ -n "$PY" ] && ok "bundled Python present" "$(basename "$PY")" || bad "no bundled Python" +[ -n "$PY" ] && { "$PY" -c "import fastapi, anthropic" 2>/dev/null \ + && ok "bundled Python imports its deps" || bad "bundled Python cannot import fastapi/anthropic"; } +ls "$RES/router" >/dev/null 2>&1 && ok "9Router bundled" || bad "9Router missing from Resources" +# The dictation regression: whisper shelled out to ffmpeg at boot, and a Finder launch has no brew. +grep -rq -- "--convert" "$RES/backend/apps" 2>/dev/null \ + && bad "whisper --convert is back (dictation dies without brew on PATH)" \ + || ok "no whisper --convert (the prod dictation killer)" + +step "5. Launch it with a Finder-like PATH and see the backend come up" +# Copy it off the DMG first, because that is what a user does and because running from the +# read-only volume makes the auto-updater throw and take the whole app down about a second in, +# which reads as "the backend never started" and is nothing of the sort. +RUNDIR="/tmp/osw-smoke-run-$$" +rm -rf "$RUNDIR"; mkdir -p "$RUNDIR" +cp -R "$APP" "$RUNDIR/" && ok "copied to a writable volume" || bad "could not copy the app off the DMG" +RUNAPP="$RUNDIR/OpenSwarm.app" +xattr -dr com.apple.quarantine "$RUNAPP" 2>/dev/null +PATH="/usr/bin:/bin:/usr/sbin:/sbin" "$RUNAPP/Contents/MacOS/OpenSwarm" >/tmp/osw-smoke.log 2>&1 & +LAUNCHED=$! +BOOTED=0 +for _ in $(seq 1 60); do + sleep 2 + curl -s -m 3 -o /dev/null "http://127.0.0.1:8324/api/settings" && { BOOTED=1; break; } + kill -0 "$LAUNCHED" 2>/dev/null || break +done +if [ "$BOOTED" = 1 ]; then + ok "backend answered on :8324 from a brew-less PATH" +else + bad "backend never answered" "see /tmp/osw-smoke.log" +fi +kill "$LAUNCHED" 2>/dev/null + +printf "\n%s\n" "$(printf '=%.0s' {1..60})" +printf "PACKAGED SMOKE: %d passed, %d failed\n" "$PASS" "$FAIL" +[ "$FAIL" -eq 0 ] || exit 1