Files
openworker/coworker/server/app.py
T
Rohit C Prasad b952ed1e1e Board overlay rework: raw-state list + item detail with event timeline
Sections are the store's states (In progress / Awaiting review / Queued), no row buttons or badges; blocked rows carry the blocker fact.
Detail pane merges the item's events into one timeline (filed/assigned/moves/comments/attachments), links the assignee to its worker session, and hosts the verdicts: Mark done / Request changes… (returns to the worker with the comment).
2026-08-17 14:55:40 -07:00

2624 lines
113 KiB
Python

"""FastAPI app — OpenAI-compatible endpoint + WS session API + REST.
The control plane every surface (GUI/IDE/messaging) rides on. The WS carries the engine
event stream and the approval channel; `/v1/chat/completions` is the OpenAI-compatible
proxy so any OpenAI-format client can use the runtime as a backend.
"""
from __future__ import annotations
import asyncio
import base64
import binascii
import json
import os
import re
import secrets
import uuid
from collections import deque
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any, Optional
from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
# Origins allowed to talk to the local sidecar. It binds to 127.0.0.1, but a page in the
# user's own browser can still reach loopback — so without an origin gate, any website they
# visit could read `GET /v1/sessions` (CORS was `*`) and drive a session over the WS (which
# CORS never covers) into shell/file tools. We pin to the desktop webview's own origins
# (`tauri://localhost`, Windows' `http(s)://tauri.localhost`) and localhost dev/browser
# builds. Requests with NO Origin header (curl, native clients, tests, server-to-server) are
# allowed — the gate targets browsers, which always attach an unforgeable Origin.
_ALLOWED_ORIGIN_RE = re.compile(
r"^(tauri://localhost"
r"|https?://localhost(:\d+)?"
r"|https?://127\.0\.0\.1(:\d+)?"
r"|https?://tauri\.localhost)$"
)
def _origin_allowed(origin: str | None) -> bool:
"""True if a browser Origin may use the API. Missing Origin (non-browser) passes."""
return origin is None or bool(_ALLOWED_ORIGIN_RE.match(origin))
# Caps on inbound WebSocket traffic. The loopback socket is unauthenticated (any local
# process can reach it), so bound frames, messages, and per-connection request rate before
# building model content or starting a turn.
_WS_MAX_FRAME_BYTES = 16 * 1024 * 1024
_WS_RATE_LIMIT_COUNT = 30
_WS_RATE_LIMIT_WINDOW_SECONDS = 10.0
_MAX_MESSAGE_TEXT_CHARS = 200_000
_MAX_ATTACHMENTS_BYTES = 15_000_000 # leaves JSON overhead below the 16 MiB frame cap
def _json_value_size(value: Any) -> int:
"""Conservative UTF-8 size of parsed JSON without allocating another giant string."""
if isinstance(value, str):
return len(value.encode("utf-8"))
if isinstance(value, dict):
return sum(_json_value_size(k) + _json_value_size(v) for k, v in value.items())
if isinstance(value, list):
return sum(_json_value_size(v) for v in value)
return 8 # numbers, booleans, null, separators
# Brand colors for the connector badge riding the ✓ (UX-DECISIONS §30). The GUI owns the
# real logos; this page must render offline with zero assets, so a colored initial stands in.
_BRAND_COLORS = {
"slack": "#4A154B",
"github": "#24292f",
"hubspot": "#ff7a59",
"gmail": "#ea4335",
"google_calendar": "#4285f4",
}
def _browser_page(
title: str, detail: str, *, ok: bool = True, error: str = "", connector: str = ""
) -> str:
"""The page shown in the user's browser at the end of a loopback flow (sign-in or
connector callback) — one branded card (UX-DECISIONS §30): OCW mark, ok/fail icon
(the connector's initial rides the ✓), the friendly detail, and the raw error
preserved on failures (it's the debugging breadcrumb). Inline CSS, light/dark via
prefers-color-scheme, no external assets — it must render offline."""
import html as _html
badge = ""
if ok and connector:
color = _BRAND_COLORS.get(connector, "#3670b2")
initial = _html.escape((connector[:1] or "?").upper())
badge = f'<span class="mini" style="background:{color}">{initial}</span>'
icon = (
f'<div class="ico ok">✓{badge}</div>' if ok else '<div class="ico bad">✕</div>'
)
err = f'<div class="err">{_html.escape(error)}</div>' if error else ""
return (
"<!doctype html><html><head><meta charset='utf-8'>"
"<meta name='viewport' content='width=device-width, initial-scale=1'>"
f"<title>{_html.escape(title)} — OpenWorker</title><style>"
":root{--paper:#f6f5f2;--panel:#fff;--line:#e4e2dc;--ink:#2c2c2a;--muted:#6f6e68;"
"--faint:#a3a19a;--accent:#3670b2;--ok:#2e7d4f;--ok-soft:#e3f2e9;--bad:#b3423a;"
"--bad-soft:#f8e7e5}"
"@media(prefers-color-scheme:dark){:root{--paper:#191918;--panel:#232322;"
"--line:#373633;--ink:#e8e6e1;--muted:#9d9b94;--faint:#6b6a64;--accent:#6ba3dd;"
"--ok:#5cb884;--ok-soft:#20362a;--bad:#d97b74;--bad-soft:#3a2422}}"
"body{margin:0;min-height:100vh;display:flex;flex-direction:column;align-items:center;"
"justify-content:center;gap:18px;background:var(--paper);color:var(--ink);"
'font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;padding:24px}'
".card{background:var(--panel);border:1px solid var(--line);border-radius:16px;"
"padding:34px 32px 28px;max-width:320px;width:100%;text-align:center;"
"box-shadow:0 10px 30px rgba(0,0,0,.06);box-sizing:border-box}"
".mark{display:flex;align-items:center;justify-content:center;gap:7px;margin-bottom:22px;"
"font-size:13px;font-weight:650}"
".mark i{width:20px;height:20px;border-radius:6px;background:var(--accent);"
"display:inline-block;position:relative}"
".mark i::after{content:'';position:absolute;inset:5px;border-radius:2px;"
"background:conic-gradient(from 0deg,#fff 0 25%,transparent 0 50%,#fff 0 75%,transparent 0)}"
".ico{width:52px;height:52px;border-radius:50%;margin:0 auto 14px;display:flex;"
"align-items:center;justify-content:center;font-size:24px;position:relative}"
".ico.ok{background:var(--ok-soft);color:var(--ok)}"
".ico.bad{background:var(--bad-soft);color:var(--bad)}"
".mini{position:absolute;right:-3px;bottom:-3px;width:22px;height:22px;border-radius:7px;"
"display:flex;align-items:center;justify-content:center;color:#fff;font-size:10px;"
"font-weight:700;border:2px solid var(--panel)}"
"h1{font-size:17px;font-weight:650;margin:0 0 6px;letter-spacing:-.01em}"
"p{font-size:12.5px;color:var(--muted);margin:0}"
".err{font-size:11.5px;color:var(--bad);background:var(--bad-soft);border-radius:8px;"
"padding:7px 10px;margin-top:12px;text-align:left;word-break:break-word}"
".foot{font-size:10.5px;color:var(--faint)}"
"</style></head><body>"
'<div class="card"><div class="mark"><i></i>OpenWorker</div>'
f"{icon}<h1>{_html.escape(title)}</h1><p>{_html.escape(detail)}</p>{err}</div>"
'<div class="foot">Served locally by OpenWorker on your Mac</div>'
"</body></html>"
)
def _connector_title(name: str) -> str:
"""Display name for the loopback page — 'Slack connected', never 'slack connected'."""
from ..connectors.descriptors import get_descriptor
d = get_descriptor(name)
return d.title if d else (name[:1].upper() + name[1:])
_CONNECT_FAILED_DETAIL = (
"Something went wrong finishing this connection. "
"Close this tab and try again from OpenWorker."
)
from ..attachments import (
MAX_ATTACHMENTS as _MAX_ATTACHMENTS,
MAX_IMAGE_CHARS,
MAX_PDF_CHARS,
MAX_TEXT_CHARS,
build_user_content,
)
from ..engine import ApprovalOutcome
from ..inbox import VIS_INBOX, VIS_INLINE, args_preview
from ..permissions import Mode
from ..providers import AssistantTurn
from .. import toolchain
from ..teams.model import AuthorityError as TeamsAuthorityError
from ..teams.model import BoardError as TeamsBoardError
from .manager import SessionManager
def create_app(manager: SessionManager) -> FastAPI:
@asynccontextmanager
async def lifespan(_app: FastAPI):
try:
live = (
await manager.start_gateway()
) # start messaging listeners (if configured)
if live:
print(f"[coworker] messaging gateway live: {', '.join(live)}")
except Exception: # never let a bad connector stop the server
import traceback
traceback.print_exc()
yield
await manager.aclose() # stop gateway + close MCP connections on shutdown
app = FastAPI(title="coworker", version="0.0.0", lifespan=lifespan)
api_token = os.environ.get("COWORKER_API_TOKEN", "")
tokenless_paths = {
"/v1/health",
"/auth/callback",
"/mcp/oauth/callback",
"/oauth/callback",
}
def _request_authenticated(request: Request) -> bool:
provided = request.headers.get("x-openworker-token", "")
return bool(
api_token
and provided
and secrets.compare_digest(provided, api_token)
)
def _websocket_authenticated(ws: WebSocket) -> bool:
if not api_token:
return True
protocols = {
part.strip()
for part in ws.headers.get("sec-websocket-protocol", "").split(",")
if part.strip()
}
return any(secrets.compare_digest(part, api_token) for part in protocols)
@app.middleware("http")
async def require_sidecar_token(request: Request, call_next):
# Preflights carry the requested header name, not its value. CORS checks the
# Origin; the actual state-changing request still must authenticate.
if (
not api_token
or request.method == "OPTIONS"
or request.url.path in tokenless_paths
# `/v1/board` carries its own, stronger auth: per-actor board tokens
# (identity + access), designed to be handed to external harnesses and
# other machines — which can never hold the machine-local sidecar token.
or request.url.path.startswith("/v1/board/")
or _request_authenticated(request)
):
return await call_next(request)
return JSONResponse(
{"error": "missing or invalid OpenWorker sidecar token"},
status_code=401,
)
app.add_middleware(
CORSMiddleware,
# Pinned to the desktop webview + localhost (see _ALLOWED_ORIGIN_RE): stops a random
# website the user visits from reading local API responses cross-origin.
allow_origin_regex=_ALLOWED_ORIGIN_RE.pattern,
allow_methods=["*"],
allow_headers=["*"],
)
app.state.manager = manager
@app.get("/v1/health")
def health(request: Request) -> dict[str, Any]:
if api_token and not _request_authenticated(request):
return {"status": "ok"}
return {
"status": "ok",
"default_workspace": manager.default_workspace,
"model": manager.model,
}
@app.get("/v1/agents")
def agents() -> dict[str, Any]:
return {"agents": manager.list_agents()}
@app.get("/v1/personas")
def personas() -> dict[str, Any]:
return {"personas": manager.personas.list_all()}
@app.get("/v1/inbox")
def inbox(session_id: str = "", state: str = "") -> dict[str, Any]:
from dataclasses import asdict
# The cross-session Inbox list shows only Unattended (inbox-visibility) items; a per-session
# query returns inline ones too, so the answer-in-context card sees parked attended prompts.
items = manager.inbox.list(
session_id=session_id or None,
state=state or None,
visibility=None if session_id else VIS_INBOX,
)
# Enrich with the originating session's context so the Inbox is self-contained — the
# "go to session" chip needs title/agent/workspace without depending on a (possibly stale)
# client-side session list, and can link straight to it.
out: list[dict[str, Any]] = []
for i in items:
d = asdict(i)
rec = manager.session_store.load(i.session_id)
if (
rec is None
and not session_id
and i.state == "pending"
and i.session_id not in manager._engines
):
# Lazy cleanup for legacy orphans (sessions deleted before delete_session
# started closing their items): an orphaned prompt can never be answered.
# A LIVE engine without a record yet (brand-new session, first turn still
# running) is NOT an orphan — hence the engine guard.
manager.inbox.resolve_session(i.session_id)
continue
d["session_title"] = (rec.title if rec else None) or i.session_id
d["session_agent"] = rec.agent if rec else None
d["session_workspace"] = rec.workspace if rec else None
d["session_exists"] = rec is not None
out.append(d)
return {"items": out}
@app.post("/v1/inbox/{item_id}/resolve")
async def resolve_inbox_item(item_id: str, body: dict) -> dict[str, Any]:
# Idempotent + first-responder-wins: ok=False means it was already resolved elsewhere.
# Routes through resolve_inbox so a restart-orphaned prompt durably resumes its turn.
ok = await manager.resolve_inbox(item_id, str(body.get("resolution", "deny")))
return {"ok": ok}
@app.get("/v1/subscriptions")
def subscriptions() -> dict[str, Any]:
# Global view-only list: each (session → channel) subscription, enriched with the session's
# title/agent and the channel its Inbox routes OUT to (so an inbound/outbound collision on
# the same channel is visible).
out: list[dict[str, Any]] = []
for sub in manager.subscriptions.all():
rec = manager.session_store.load(sub.session_id)
agent = rec.agent if rec else ""
routing = manager._routing_targets(sub.session_id, agent or "cowork")
out.append(
{
"session_id": sub.session_id,
"session_title": (rec.title if rec else None) or sub.session_id,
"agent": agent,
"channel": sub.channel,
# Display name from the channel buffer ("#ocw-test"), when any inbound
# message has carried one — the address stays the identifier.
"channel_name": manager.channel_buffer.name_for(sub.channel),
"routing_target": routing[0] if routing else None,
"collision": bool(routing and sub.channel in routing),
}
)
return {"subscriptions": out}
@app.get("/v1/channels/recent")
def recent_channels() -> dict[str, Any]:
# The picker's "recently-seen" source: channels the bot has received messages from.
return {"channels": manager.channel_buffer.channels()}
@app.get("/v1/unrouted")
def unrouted() -> dict[str, Any]:
# Dead-letter view: inbound messages with no destination + background-turn failures.
return {"items": manager.unrouted.list()}
@app.post("/v1/subscriptions")
def subscribe(body: dict) -> dict[str, Any]:
from ..subscriptions import resolve_channel
session_id = str(body.get("session_id", "")).strip()
raw = str(body.get("channel", ""))
addr = resolve_channel(raw)
if not session_id or not addr or ":" not in addr:
if raw.strip().startswith("#"):
# A bare #name can't be looked up locally — storing it literally would create a
# subscription that never matches real traffic (resolve_channel returns "").
return {
"ok": False,
"error": "Channel names can't be looked up — paste the channel ID "
"(channel name ▸ About) or the channel's Copy-link URL.",
}
return {"ok": False, "error": "need a session_id and a channel"}
manager.subscriptions.subscribe(session_id, addr)
return {"ok": True, "channel": addr}
@app.post("/v1/subscriptions/remove")
def unsubscribe(body: dict) -> dict[str, Any]:
from ..subscriptions import resolve_channel
session_id = str(body.get("session_id", "")).strip()
addr = resolve_channel(str(body.get("channel", "")))
removed = manager.subscriptions.unsubscribe(session_id, addr)
return {"ok": True, "removed": removed}
@app.get("/v1/inbox/reconcile")
def reconcile_inbox(session_id: str) -> dict[str, Any]:
# Called when a session resumes attended control (surface pending + recap inline).
return manager.inbox.reconcile_on_resume(session_id)
@app.get("/v1/inbox/routing")
def inbox_routing() -> dict[str, Any]:
return {"bindings": manager.inbox_routing.bindings()}
@app.post("/v1/inbox/routing/binding")
def set_inbox_binding(body: dict) -> dict[str, Any]:
name = str(body.get("name", "")).strip()
if not name:
return {"ok": False, "error": "binding needs a `name`"}
return manager.set_inbox_binding(
name,
channel=body.get("channel") or None,
target=str(body.get("target", "")),
)
@app.get("/v1/sessions/{session_id}/unattended")
def get_unattended(session_id: str) -> dict[str, Any]:
return {"unattended": manager.unattended.is_unattended(session_id)}
@app.post("/v1/sessions/{session_id}/unattended")
def set_unattended(session_id: str, body: dict) -> dict[str, Any]:
# The GUI gates the on-transition behind a one-tap confirm.
on = bool(body.get("unattended"))
manager.unattended.set(session_id, on)
return {"ok": True, "session_id": session_id, "unattended": on}
@app.get("/v1/sessions/{session_id}/skills")
def session_skills(session_id: str, workspace: str = "") -> dict[str, Any]:
# The rail's Skills group + the composer popup both read this (SKILLS-SPEC §4.1).
return manager.session_skills_view(session_id, workspace or None)
@app.post("/v1/sessions/{session_id}/skills")
def set_session_skill(session_id: str, body: dict) -> dict[str, Any]:
# A session mute. `clear` drops the override (inherit again); otherwise explicit
# on/off. Nothing on disk changes — Settings owns permanent state.
body = body or {}
skill = str(body.get("skill", "")).strip()
if not skill:
return {"ok": False, "error": "skill required"}
if body.get("clear"):
manager.session_skills.clear(session_id, skill)
else:
manager.session_skills.set(
session_id, skill, bool(body.get("enabled", False))
)
return manager.session_skills_view(
session_id, str(body.get("workspace", "")) or None
)
@app.get("/v1/sessions/{session_id}/connections")
def session_connections(session_id: str, persona: str = "") -> dict[str, Any]:
# `persona` is the GUI's hint for brand-new sessions (no record yet) — without it the
# view resolves to the default persona and shows the wrong defaults/recommends.
# §6: the Sources drawer payload — connected connectors w/ state + recommended + ⚠ count.
return manager.session_connections_view(session_id, persona or None)
@app.post("/v1/sessions/{session_id}/connections")
def set_session_connection(session_id: str, body: dict) -> dict[str, Any]:
# §6: a session override. `clear` drops the override (inherit the persona default again);
# otherwise set an explicit on/off. Return the refreshed view so the drawer can re-render.
body = body or {}
connector = str(body.get("connector", "")).strip()
if not connector:
return {"ok": False, "error": "connector required"}
if body.get("clear"):
manager.session_connections.clear(session_id, connector)
else:
manager.session_connections.set(
session_id, connector, bool(body.get("enabled", False))
)
persona = str(body.get("persona", "")) or None
return {
"ok": True,
"connections": manager.session_connections_view(session_id, persona),
}
@app.post("/v1/personas/install")
def install_persona(body: dict) -> dict[str, Any]:
# Returns a consent summary per persona; they land disabled pending the user's approval
# (then POST /v1/personas/{id} {enabled:true, surfaced:true}).
reg = manager.personas
try:
if body.get("git_url"):
summaries = reg.install_from_git(str(body["git_url"]))
elif body.get("dir"):
summaries = reg.install_from_dir(str(body["dir"]))
elif body.get("zip_b64"):
# Sharing v1 (OPE-7): a bundle zip — the export format — round-trips
# through the same dir installer + consent path.
try:
data = base64.b64decode(str(body["zip_b64"]), validate=True)
except (ValueError, binascii.Error):
return {"ok": False, "error": "Invalid archive encoding."}
summaries = reg.install_from_zip(
data, str(body.get("filename", ""))
)
elif body.get("gallery_slug"):
# Gallery install = fetch the manifest markdown from the cloud
# (sign-in required), verify its hash, then reuse the exact
# same parser + consent path as a local/Git install. The
# gallery never changes the trust model: no executable code,
# lands disabled pending consent.
import hashlib
import tempfile
from .. import cloud
from ..config import load_config
slug = str(body["gallery_slug"]).strip()
manifest = cloud.gallery_manifest(manager.secrets, load_config(), slug)
if manifest is None:
return {
"ok": False,
"error": "gallery requires cloud sign-in (or the cloud is unreachable)",
}
markdown = manifest.get("manifest_markdown", "")
digest = "sha256:" + hashlib.sha256(markdown.encode()).hexdigest()
if (
manifest.get("manifest_hash")
and manifest["manifest_hash"] != digest
):
return {"ok": False, "error": "manifest hash mismatch"}
with tempfile.TemporaryDirectory() as td:
(Path(td) / f"{slug}.md").write_text(markdown)
summaries = reg.install_from_dir(td)
cloud.gallery_install_event(manager.secrets, load_config(), slug)
else:
return {
"ok": False,
"error": "provide a `dir`, `git_url`, `zip_b64`, or `gallery_slug`",
}
except Exception as e: # surface manifest/clone errors to the caller
return {"ok": False, "error": str(e)}
return {"ok": True, "consent": summaries, "personas": reg.list_all()}
@app.post("/v1/personas/{persona_id}/export")
def export_persona(persona_id: str, body: dict) -> dict[str, Any]:
# Sharing v1 (OPE-7): zip the persona's bundle into the chosen folder. The zip
# is the import format — send it to a teammate, they import it from the picker.
return manager.personas.export_persona(
persona_id, str((body or {}).get("dir", ""))
)
@app.get("/v1/cloud/gallery/{slug}")
def cloud_gallery_detail(slug: str) -> dict[str, Any]:
"""Solo page for one gallery coworker: publisher pitch + capabilities
derived locally from the manifest (same parser as install)."""
from .. import cloud
from ..config import load_config
body = cloud.gallery_detail(manager.secrets, load_config(), slug)
if body is None:
return {"ok": False, "error": "gallery requires cloud sign-in"}
return body
@app.get("/v1/cloud/gallery")
def cloud_gallery() -> dict[str, Any]:
"""Gallery cards for the GUI. Signed out ⇒ ok:false (the gallery is a
signed-in feature by design; local personas are unaffected)."""
from .. import cloud
from ..config import load_config
body = cloud.gallery_list(manager.secrets, load_config())
if body is None:
return {
"ok": False,
"error": "gallery requires cloud sign-in",
"personas": [],
}
return {"ok": True, "personas": body.get("personas", [])}
@app.post("/v1/personas/{persona_id}")
def update_persona(persona_id: str, body: dict) -> dict[str, Any]:
reg = manager.personas
archived = 0
try:
if "enabled" in body:
# Disable archives the persona's sessions atomically (server-side, one
# request) so any client gets the same semantic. See set_persona_enabled.
archived = manager.set_persona_enabled(
persona_id, bool(body["enabled"])
)["archived_sessions"]
if "surfaced" in body:
reg.set_surfaced(persona_id, bool(body["surfaced"]))
if body.get("default"):
reg.set_default(persona_id)
except KeyError:
return {"ok": False, "error": f"unknown persona: {persona_id}"}
return {"ok": True, "personas": reg.list_all(), "archived_sessions": archived}
@app.delete("/v1/personas/{persona_id}")
def persona_delete(persona_id: str) -> dict[str, Any]:
# Uninstall a non-builtin persona (snapshot dir + lifecycle state). Local
# operation — works signed out, regardless of where the persona came from.
try:
manager.personas.uninstall(persona_id)
except KeyError:
return {"ok": False, "error": f"unknown persona: {persona_id}"}
except ValueError as e:
return {"ok": False, "error": str(e)}
return {"ok": True, "personas": manager.personas.list_all()}
@app.get("/v1/personas/{persona_id}")
def persona_detail(persona_id: str) -> dict[str, Any]:
# §5 detail page: identity + capabilities + recommends(+connected) + default connections.
detail = manager.persona_detail(persona_id)
if detail is None:
return {"ok": False, "error": f"unknown persona: {persona_id}"}
return detail
@app.post("/v1/personas/{persona_id}/enable")
def persona_enable(persona_id: str, body: dict) -> dict[str, Any]:
# Dedicated §5/§8 route; delegates to the same manager toggle as POST /v1/personas/{id}
# (so disable archives the persona's sessions here too).
try:
manager.set_persona_enabled(
persona_id, bool((body or {}).get("enabled", True))
)
except KeyError:
return {"ok": False, "error": f"unknown persona: {persona_id}"}
return {"ok": True, "personas": manager.personas.list_all()}
@app.post("/v1/personas/{persona_id}/connections")
def persona_set_connection(persona_id: str, body: dict) -> dict[str, Any]:
# §5: flip a persona-default connector on/off; re-reads so the client can refresh.
body = body or {}
connector = str(body.get("connector", "")).strip()
if not connector:
return {"ok": False, "error": "connector required"}
return manager.set_persona_connection(
persona_id, connector, bool(body.get("enabled", False))
)
@app.get("/v1/skills")
def skills(workspace: str = "") -> dict[str, Any]:
return {"skills": manager.list_skills(workspace or None)}
@app.post("/v1/skills")
def create_skill(body: dict) -> dict[str, Any]:
return manager.create_skill(body or {})
@app.patch("/v1/skills/{name}")
def update_skill(name: str, body: dict) -> dict[str, Any]:
return manager.update_skill(name, body or {})
@app.delete("/v1/skills/{name}")
def delete_skill(name: str, workspace: str = "") -> dict[str, Any]:
return manager.delete_skill(name, workspace or None)
@app.post("/v1/skills/{name}/move")
def move_skill(name: str, body: dict) -> dict[str, Any]:
return manager.move_skill(name, body or {})
@app.post("/v1/skills/{name}/reveal")
def reveal_skill(name: str, body: dict) -> dict[str, Any]:
# §6 "Show folder": open the skill's folder in the OS file manager (local machine).
return manager.reveal_skill(name, str((body or {}).get("workspace", "")) or None)
@app.post("/v1/skills/upload")
def stage_skill_upload(body: dict) -> dict[str, Any]:
# Stage → preview; nothing is installed until /upload/confirm (SKILLS-SPEC §4.2).
data_b64 = str((body or {}).get("data_b64", ""))
if not data_b64:
return {"ok": False, "error": "No archive supplied."}
try:
data = base64.b64decode(data_b64, validate=True)
except (ValueError, binascii.Error):
return {"ok": False, "error": "Invalid archive encoding."}
return manager.stage_skill_upload(data, str((body or {}).get("filename", "")))
@app.post("/v1/skills/upload/confirm")
def confirm_skill_upload(body: dict) -> dict[str, Any]:
return manager.confirm_skill_upload(body or {})
@app.get("/v1/workspaces/recent")
def recent_workspaces() -> dict[str, Any]:
return {"workspaces": manager.recent_workspaces()}
@app.post("/v1/workspaces/open")
def open_workspace(body: dict) -> dict[str, Any]:
return manager.open_workspace(
body.get("path", ""), create=bool(body.get("create"))
)
@app.get("/v1/workspaces/trusted")
def trusted_workspaces() -> dict[str, Any]:
return {"workspaces": manager.trusted_workspaces()}
@app.post("/v1/workspaces/trust")
def set_workspace_trust(body: dict) -> dict[str, Any]:
return manager.set_workspace_trust(
str((body or {}).get("path", "")),
trusted=bool((body or {}).get("trusted", False)),
)
@app.post("/v1/workspaces/temp")
def provision_temp_workspace(body: dict) -> dict[str, Any]:
# UX-029: a code-family session starting "in a temporary folder" — created only
# at send time, with git ready. Knowledge families keep their auto-provisioned dir.
return manager.provision_temp_workspace(
str((body or {}).get("session_id", "")),
git=bool((body or {}).get("git", True)),
)
@app.post("/v1/sessions/{session_id}/save-as-project")
def save_session_as_project(session_id: str, body: dict) -> dict[str, Any]:
# UX-029 "Save as project…": move the temporary folder somewhere real. The GUI
# reconnects afterwards so the engine rebinds to the new path.
return manager.save_temp_as_project(session_id, str((body or {}).get("path", "")))
@app.post("/v1/workspaces/pick")
async def pick_workspace() -> dict[str, Any]:
# Native folder picker opened by the LOCAL sidecar (browser GUIs can't get absolute
# paths from web file dialogs). Off the event loop: blocks until pick/cancel.
return await asyncio.to_thread(manager.pick_native_folder)
@app.get("/v1/sessions")
def sessions(workspace: str | None = None) -> dict[str, Any]:
return {"sessions": manager.list_sessions(workspace)}
@app.get("/v1/sessions/{session_id}/messages")
def session_messages(session_id: str) -> dict[str, Any]:
return {"messages": manager.session_messages(session_id)}
@app.patch("/v1/sessions/{session_id}")
def session_patch(session_id: str, body: dict) -> dict[str, Any]:
body = body or {}
if "pinned" in body or "archived" in body:
return manager.set_session_flags(
session_id,
pinned=bool(body["pinned"]) if "pinned" in body else None,
archived=bool(body["archived"]) if "archived" in body else None,
)
return manager.rename_session(session_id, str(body.get("title", "")))
@app.delete("/v1/sessions/{session_id}")
def session_delete(session_id: str) -> dict[str, Any]:
return manager.delete_session(session_id)
@app.get("/v1/sessions/{session_id}/roots")
def session_roots(session_id: str) -> dict[str, Any]:
return {"roots": manager.get_roots(session_id)}
@app.post("/v1/sessions/{session_id}/roots")
def session_add_root(session_id: str, body: dict) -> dict[str, Any]:
body = body or {}
return manager.add_root(
session_id, str(body.get("path", "")), bool(body.get("writable", False))
)
@app.delete("/v1/sessions/{session_id}/roots")
def session_remove_root(session_id: str, path: str) -> dict[str, Any]:
return manager.remove_root(session_id, path)
@app.get("/v1/sessions/{session_id}/artifacts")
def session_artifacts(session_id: str) -> dict[str, Any]:
return {"artifacts": manager.list_artifacts(session_id)}
@app.get("/v1/sessions/{session_id}/artifacts/read")
def session_artifact_read(session_id: str, path: str) -> dict[str, Any]:
return manager.read_artifact(session_id, path)
@app.post("/v1/sessions/{session_id}/artifacts/reveal")
def session_artifact_reveal(session_id: str, body: dict) -> dict[str, Any]:
body = body or {}
return manager.reveal_artifact(
session_id, str(body.get("path", "")), str(body.get("mode", "reveal"))
)
# Agent teams (OPE-96): the session's board (workspace-keyed space) + journal
# overview. Mutations act as the USER — the human side of the gates.
@app.get("/v1/sessions/{session_id}/board")
def session_board(session_id: str) -> dict[str, Any]:
return manager.session_board(session_id)
@app.get("/v1/sessions/{session_id}/board/item")
def session_board_item(session_id: str, id: int) -> dict[str, Any]:
return manager.board_item_detail(session_id, int(id))
@app.get("/v1/sessions/{session_id}/board/attachment")
def session_board_attachment(session_id: str, name: str):
from fastapi.responses import Response
try:
path = manager.attachment_store.path_for(name)
except TeamsBoardError as error:
return JSONResponse({"error": str(error)}, status_code=404)
return Response(
content=path.read_bytes(),
media_type=manager.attachment_store.mime_for(name),
)
@app.post("/v1/sessions/{session_id}/board/transition")
def session_board_transition(session_id: str, body: dict) -> dict[str, Any]:
body = body or {}
return manager.board_transition(
session_id,
int(body.get("item", 0)),
str(body.get("to", "")),
comment=str(body.get("comment", "")),
)
@app.get("/v1/teams/{team_id}/chat")
def team_chat(team_id: str) -> dict[str, Any]:
return manager.team_chat(team_id)
@app.post("/v1/teams/{team_id}/chat")
def team_chat_post(team_id: str, body: dict) -> dict[str, Any]:
return manager.post_team_chat(team_id, str((body or {}).get("text", "")))
@app.get("/v1/teams/journal")
def teams_journal() -> dict[str, Any]:
return {"cases": manager.journal_overview()}
# ---- The open board surface (OPE-100): token-authenticated `/v1/board` API.
# Identity is the TOKEN (actor+role bound at mint, resolved per request, never
# client-asserted); authority is the STORE — the same double gate in-app agents
# get. This is the one wire protocol every external front door rides:
# RemoteDialect (the `ocw` CLI, the team-board MCP server, headless instances)
# today, a hosted board service later. Tokens are required even on loopback —
# they carry identity, not just access.
def _board_actor(request: Request):
auth = request.headers.get("authorization", "")
token = auth[7:] if auth.lower().startswith("bearer ") else ""
return manager.board_tokens.resolve(token)
def _board(request: Request, handler):
actor = _board_actor(request)
if actor is None:
return JSONResponse(
{"error": "board token required (Authorization: Bearer …) — mint"
" one with `ocw board token` on the serving machine"},
status_code=401,
)
try:
return handler(actor)
except TeamsAuthorityError as error:
return JSONResponse({"error": str(error)}, status_code=403)
except (TeamsBoardError, ValueError) as error:
return JSONResponse({"error": str(error)}, status_code=400)
@app.get("/v1/board/whoami")
def board_whoami(request: Request):
return _board(
request, lambda actor: {"actor": actor.id, "role": actor.role.value}
)
@app.get("/v1/board/spaces")
def board_spaces(request: Request):
return _board(request, lambda actor: {"spaces": manager.team_store.spaces()})
@app.get("/v1/board/items")
def board_list_items(
request: Request, space: str, state: str = "", assignee: str = ""
):
return _board(
request,
lambda actor: {
"items": manager.team_store.list_items(
space, actor, state=state or None, assignee=assignee or None
)
},
)
@app.get("/v1/board/item")
def board_get_item(request: Request, space: str, id: int):
return _board(
request, lambda actor: manager.team_store.get_item(space, int(id))
)
@app.post("/v1/board/items")
def board_create_item(request: Request, body: dict):
body = body or {}
def run(actor):
item = manager.team_store.create_item(
str(body.get("space", "")),
actor,
title=str(body.get("title", "")),
criteria=str(body.get("criteria", "")),
description=str(body.get("description", "")),
parent=(
int(body["parent"]) if body.get("parent") is not None else None
),
case=str(body.get("case") or "") or None,
)
manager.kick_team_tick() # a new filing is lead-subscription news
return item
return _board(request, run)
@app.post("/v1/board/items/transition")
def board_transition_item(request: Request, body: dict):
body = body or {}
def run(actor):
item = manager.team_store.transition(
str(body.get("space", "")),
actor,
int(body.get("id", 0)),
str(body.get("to", "")),
comment=str(body.get("comment", "")),
refs=[str(ref) for ref in body.get("refs") or []],
)
manager.kick_team_tick() # review/blocked should reach the lead now
return item
return _board(request, run)
@app.post("/v1/board/items/comment")
def board_comment_item(request: Request, body: dict):
body = body or {}
return _board(
request,
lambda actor: manager.team_store.comment(
str(body.get("space", "")),
actor,
int(body.get("id", 0)),
str(body.get("body", "")),
refs=[str(ref) for ref in body.get("refs") or []],
),
)
@app.post("/v1/board/items/assign")
def board_assign_item(request: Request, body: dict):
body = body or {}
def run(actor):
item = manager.team_store.assign(
str(body.get("space", "")),
actor,
int(body.get("id", 0)),
str(body.get("assignee", "")),
)
manager.kick_team_tick() # the assignee's queue has news
return item
return _board(request, run)
@app.post("/v1/board/items/claim")
def board_claim_item(request: Request, body: dict):
body = body or {}
def run(actor):
item = manager.team_store.claim(
str(body.get("space", "")), actor, int(body.get("id", 0))
)
manager.kick_team_tick() # claims land in the lead's feed
return item
return _board(request, run)
@app.post("/v1/board/link")
def board_link_items(request: Request, body: dict):
body = body or {}
return _board(
request,
lambda actor: manager.team_store.link(
str(body.get("space", "")),
actor,
int(body.get("src", 0)),
str(body.get("kind", "")),
int(body.get("dst", 0)),
),
)
@app.post("/v1/board/items/attach")
def board_attach(request: Request, body: dict):
body = body or {}
def run(actor):
raw = str(body.get("data_b64", ""))
# Cheap pre-decode bound: base64 is ~4/3 of the payload, so anything
# multiples over the cap is refused before allocating the decode.
if len(raw) > 15 * 1024 * 1024:
return JSONResponse(
{"error": "attachment exceeds 10MB"}, status_code=400
)
try:
data = base64.b64decode(raw, validate=True)
except (binascii.Error, ValueError):
return JSONResponse(
{"error": "data_b64 is not valid base64"}, status_code=400
)
ref = manager.attachment_store.put(
data, str(body.get("filename", ""))
)
filename = str(body.get("filename", ""))
event = manager.team_store.comment(
str(body.get("space", "")),
actor,
int(body.get("id", 0)),
str(body.get("caption", "")) or f"attached {filename}",
refs=[ref],
)
return {"ref": ref, "seq": event["seq"]}
return _board(request, run)
@app.get("/v1/board/attachment")
def board_attachment(request: Request, name: str):
def run(actor):
from fastapi.responses import Response
path = manager.attachment_store.path_for(name)
return Response(
content=path.read_bytes(),
media_type=manager.attachment_store.mime_for(name),
)
return _board(request, run)
@app.get("/v1/board/policy")
def board_get_policy(request: Request, space: str):
return _board(request, lambda actor: manager.team_store.policy(space))
@app.post("/v1/board/policy")
def board_set_policy(request: Request, body: dict):
body = body or {}
return _board(
request,
lambda actor: manager.team_store.set_policy(
str(body.get("space", "")), actor, claims=str(body.get("claims", ""))
),
)
@app.get("/v1/board/pending")
def board_pending(request: Request, limit: int = 200):
return _board(
request,
lambda actor: {
"events": manager.team_store.pending_for(actor.id, limit=int(limit))
},
)
@app.post("/v1/board/consume")
def board_consume(request: Request, body: dict):
body = body or {}
def run(actor):
manager.team_store.consume(actor.id, int(body.get("upto_seq", 0)))
return {"ok": True}
return _board(request, run)
@app.get("/v1/board/journal/cases")
def board_journal_cases(request: Request):
return _board(
request, lambda actor: {"cases": manager.journal_store.overview(actor)}
)
@app.get("/v1/board/journal")
def board_journal_read(
request: Request,
case: str,
item: Optional[int] = None,
author: str = "",
kind: str = "",
entity: str = "",
include_raw: str = "",
limit: int = 100,
):
return _board(
request,
lambda actor: {
"entries": manager.journal_store.read(
actor,
case,
item=item,
author=author or None,
kind=kind or None,
entity=entity or None,
include_raw=bool(include_raw),
limit=int(limit),
)
},
)
@app.post("/v1/board/journal")
def board_journal_append(request: Request, body: dict):
body = body or {}
return _board(
request,
lambda actor: manager.journal_store.append(
actor,
str(body.get("case", "")),
str(body.get("body", "")),
kind=str(body.get("kind") or "note"),
space=str(body.get("space") or "") or None,
item=int(body["item"]) if body.get("item") is not None else None,
entities=[str(e) for e in body.get("entities") or []],
refs=[str(ref) for ref in body.get("refs") or []],
),
)
@app.get("/v1/memory")
def memory() -> dict[str, Any]:
return {"memory": manager.list_memory()}
@app.post("/v1/memory")
def add_memory(body: dict) -> dict[str, Any]:
body = body or {}
return manager.add_memory(
str(body.get("content", "")), str(body.get("scope", "workspace"))
)
# Declared before the /{item_id} routes so "settings" can never be parsed as an id.
@app.get("/v1/memory/settings")
def memory_settings() -> dict[str, Any]:
return manager.get_memory_settings()
@app.put("/v1/memory/settings")
def memory_settings_put(body: dict) -> dict[str, Any]:
body = body or {}
return manager.set_memory_settings(
enabled=bool(body["enabled"]) if "enabled" in body else None,
user_rules=str(body["user_rules"]) if "user_rules" in body else None,
)
@app.patch("/v1/memory/{item_id}")
def memory_patch(item_id: int, body: dict) -> dict[str, Any]:
return manager.update_memory(item_id, str((body or {}).get("content", "")))
@app.delete("/v1/memory/{item_id}")
def memory_delete(item_id: int) -> dict[str, Any]:
return manager.delete_memory(item_id)
@app.delete("/v1/memory")
def memory_delete_all() -> dict[str, Any]:
return manager.delete_all_memory()
@app.post("/v1/chat/completions")
def chat_completions(body: dict) -> dict[str, Any]:
model = body.get("model", manager.model)
turn = manager.provider_complete(
model, body.get("messages", []), body.get("tools")
)
return _openai_response(model, turn)
# -- MCP servers ------------------------------------------------------------
@app.get("/v1/mcp")
def mcp_list() -> dict[str, Any]:
return {"servers": manager.list_mcp()}
@app.post("/v1/mcp")
def mcp_add(body: dict) -> dict[str, Any]:
name = body.get("name")
config = body.get("config")
if not name or not isinstance(config, dict):
return {"ok": False, "error": "name and config required"}
return manager.add_mcp(name, config)
@app.patch("/v1/mcp/{name}")
def mcp_patch(name: str, body: dict) -> dict[str, Any]:
return manager.patch_mcp(name, body or {})
@app.delete("/v1/mcp/{name}")
def mcp_delete(name: str) -> dict[str, Any]:
return manager.delete_mcp(name)
@app.get("/v1/mcp/{name}/tools")
async def mcp_tools(name: str) -> dict[str, Any]:
return await manager.mcp_tools(name)
@app.post("/v1/mcp/{name}/connect")
async def mcp_connect(name: str) -> dict[str, Any]:
# Connect now. For `auth: oauth` servers the first connect opens the system
# browser and waits on the loopback callback — that can take minutes, so it
# runs as a background task; the GUI polls /v1/mcp for the status flip
# (authorizing → connected | needs_auth + last_error).
asyncio.create_task(manager.connect_mcp(name))
return {"ok": True, "started": True}
@app.post("/v1/mcp/{name}/signout")
async def mcp_signout(name: str) -> dict[str, Any]:
return await manager.signout_mcp(name)
@app.get("/mcp/oauth/callback")
async def mcp_oauth_callback(
code: str = "", state: str = "", error: str = ""
) -> Any:
# Loopback landing for the MCP OAuth browser flow (mcp/oauth.py). Browser-facing:
# returns the same styled page as the managed-connector callbacks.
from fastapi.responses import HTMLResponse
from ..mcp import oauth as mcp_oauth
if error:
return HTMLResponse(
_browser_page(
"Sign-in failed",
"The service reported an error. Return to OpenWorker and try again.",
ok=False,
error=error,
),
status_code=400,
)
if not code or not mcp_oauth.deliver_callback(code, state or None):
return HTMLResponse(
_browser_page(
"Nothing waiting for this sign-in",
"The sign-in may have timed out. Return to OpenWorker and start it again.",
ok=False,
),
status_code=400,
)
return HTMLResponse(
_browser_page(
"Connected",
"Sign-in complete. You can close this tab and return to OpenWorker.",
ok=True,
)
)
@app.post("/v1/mcp/reload")
async def mcp_reload() -> dict[str, Any]:
return await manager.reload_mcp()
# -- connectors (Slack / Telegram / …) --------------------------------------
@app.get("/v1/connectors")
def connectors_list() -> dict[str, Any]:
return {"connectors": manager.list_connectors()}
async def _refresh_listeners_if_two_way(name: str) -> None:
# New/removed creds only take effect when the platform socket reconnects (Socket Mode
# authenticates at connect time) — hot-reload the listeners in-process so pasting
# tokens works immediately, no sidecar restart (§19).
from ..connectors.config import PLATFORMS
if name in PLATFORMS:
try:
await manager.refresh_gateway()
except Exception:
pass # a listener that fails to come up must not fail the save
@app.post("/v1/connectors/{name}/connect")
async def connector_connect(name: str, body: dict) -> dict[str, Any]:
fields = body.get("fields") if isinstance(body, dict) else None
# experimental connectors require the caller to explicitly acknowledge the risk notice
acknowledged = bool(isinstance(body, dict) and body.get("acknowledge_risk"))
# token validation does a blocking HTTP call → keep it off the event loop
result = await asyncio.to_thread(
lambda: manager.connect_connector(
name, fields or {}, acknowledged=acknowledged
)
)
if result.get("ok"):
await _refresh_listeners_if_two_way(name)
return result
@app.post("/v1/connectors/{name}/mcp-connect")
async def connector_mcp_connect(name: str) -> dict[str, Any]:
# One-click connect for an MCP-backed connector: the browser OAuth flow can
# take minutes, so it runs in the background; the GUI polls /v1/connectors
# until the card flips to connected (mode "mcp").
from ..connectors.descriptors import get_descriptor
d = get_descriptor(name)
if d is None or not d.mcp_url:
return {"ok": False, "error": f"{name} has no MCP connect path"}
asyncio.create_task(manager.mcp_connect_connector(name))
return {"ok": True, "started": True}
@app.post("/v1/connectors/{name}/disconnect")
async def connector_disconnect(name: str) -> dict[str, Any]:
# Managed profiles: best-effort flip of the cloud metadata record first
# (network call → off the loop). Local deletion always proceeds.
from .. import cloud
from ..config import load_config
await asyncio.to_thread(
lambda: cloud.cloud_disconnect(manager.secrets, load_config(), name)
)
result = manager.disconnect_connector(name)
await _refresh_listeners_if_two_way(name)
return result
@app.post("/v1/connectors/slack/workspaces/{team_id}/disconnect")
async def slack_workspace_disconnect(team_id: str) -> dict[str, Any]:
"""Stop relaying one workspace (managed relay). Cloud routing row deleted
best-effort, local per-team token removed, gateway hot-reloaded."""
return await manager.disconnect_slack_workspace(team_id)
@app.get("/v1/connectors/slack/status")
async def slack_status() -> dict[str, Any]:
"""Slack health, three layers: relay socket / cloud sign-in / per-team tokens."""
return manager.slack_status()
@app.post("/v1/connectors/github/installations/{installation_id}/disconnect")
async def github_installation_disconnect(installation_id: str) -> dict[str, Any]:
"""Stop relaying one GitHub App installation (managed relay). Cloud
routing rows deleted best-effort, local profile removed, gateway
hot-reloaded."""
return await manager.disconnect_github_installation(installation_id)
@app.get("/v1/connectors/github/status")
async def github_status() -> dict[str, Any]:
"""GitHub health: relay socket / cloud sign-in / per-installation tokens."""
return manager.github_status()
@app.post("/v1/connectors/gmail/accounts/{email}/disconnect")
async def gmail_account_disconnect(email: str) -> dict[str, Any]:
"""Drop ONE mailbox (cloud metadata best-effort first, like a full
disconnect); the default pointer moves to the next account."""
from .. import cloud
from ..config import load_config
from ..connectors import gmail_accounts
profile_key = gmail_accounts.PREFIX + email.strip().lower()
await asyncio.to_thread(
lambda: cloud.cloud_disconnect(
manager.secrets, load_config(), "gmail", profile_key=profile_key
)
)
return gmail_accounts.disconnect_account(manager.secrets, email)
@app.post("/v1/connectors/gmail/accounts/{email}/default")
def gmail_account_default(email: str) -> dict[str, Any]:
from ..connectors import gmail_accounts
return gmail_accounts.set_default(manager.secrets, email)
@app.patch("/v1/connectors/gmail/filters")
def gmail_filters(body: dict) -> dict[str, Any]:
"""Replace the "Never show agents" lists. Enforced in the local tool
layer; agents see silent omissions, the user sees counts + audit."""
from ..connectors import gmail_accounts
senders = body.get("senders") if isinstance(body, dict) else None
labels = body.get("labels") if isinstance(body, dict) else None
if senders is not None and not isinstance(senders, list):
return {"ok": False, "error": "senders must be a list"}
if labels is not None and not isinstance(labels, list):
return {"ok": False, "error": "labels must be a list"}
return gmail_accounts.set_filters(manager.secrets, senders, labels)
@app.post("/v1/connectors/google_calendar/accounts/{email}/disconnect")
async def gcal_account_disconnect(email: str) -> dict[str, Any]:
"""Drop ONE Google Calendar account (cloud metadata best-effort first);
the default pointer moves to the next account."""
from .. import cloud
from ..config import load_config
from ..connectors import gcal_accounts
profile_key = gcal_accounts.PREFIX + email.strip().lower()
await asyncio.to_thread(
lambda: cloud.cloud_disconnect(
manager.secrets,
load_config(),
"google_calendar",
profile_key=profile_key,
)
)
return gcal_accounts.disconnect_account(manager.secrets, email)
@app.post("/v1/connectors/google_calendar/accounts/{email}/default")
def gcal_account_default(email: str) -> dict[str, Any]:
from ..connectors import gcal_accounts
return gcal_accounts.set_default(manager.secrets, email)
@app.post("/v1/connectors/hubspot/portals/{hub_id}/disconnect")
async def hubspot_portal_disconnect(hub_id: str) -> dict[str, Any]:
from .. import cloud
from ..config import load_config
from ..connectors import hubspot_portals
profile_key = hubspot_portals.PREFIX + hub_id.strip()
await asyncio.to_thread(
lambda: cloud.cloud_disconnect(
manager.secrets, load_config(), "hubspot", profile_key=profile_key
)
)
return hubspot_portals.disconnect_portal(manager.secrets, hub_id)
@app.post("/v1/connectors/hubspot/portals/{hub_id}/default")
def hubspot_portal_default(hub_id: str) -> dict[str, Any]:
from ..connectors import hubspot_portals
return hubspot_portals.set_default(manager.secrets, hub_id)
@app.post("/v1/connectors/{name}/accounts/{account_id}/disconnect")
async def account_disconnect(name: str, account_id: str) -> dict[str, Any]:
"""Generic per-account disconnect for account-patterned connectors
(batch 2+). Gmail/Calendar keep their specific email routes."""
from .. import cloud
from ..config import load_config
from ..connectors import accounts
if not accounts.is_account_connector(name):
return {"ok": False, "error": "not a multi-account connector"}
_id, profile_key, profile = accounts.resolve(manager.secrets, name, account_id)
if profile and profile.get("managed"):
await asyncio.to_thread(
lambda: cloud.cloud_disconnect(
manager.secrets, load_config(), name, profile_key=profile_key
)
)
return accounts.disconnect_account(manager.secrets, name, account_id)
@app.post("/v1/connectors/{name}/accounts/{account_id}/default")
def account_default(name: str, account_id: str) -> dict[str, Any]:
from ..connectors import accounts
if not accounts.is_account_connector(name):
return {"ok": False, "error": "not a multi-account connector"}
return accounts.set_default(manager.secrets, name, account_id)
@app.patch("/v1/connectors/hubspot/hidden-fields")
def hubspot_hidden_fields(body: dict) -> dict[str, Any]:
"""Replace the hidden-fields denylist (property names stripped from every
record agents read — model-facing policy, not a human ACL)."""
from ..connectors import hubspot_portals
fields = body.get("hidden_fields") if isinstance(body, dict) else None
if not isinstance(fields, list):
return {"ok": False, "error": "hidden_fields must be a list"}
return hubspot_portals.set_hidden_fields(manager.secrets, fields)
@app.post("/v1/connectors/{name}/unauthorized/{item_id}")
async def connector_unauthorized_resolve(
name: str, item_id: str, body: dict
) -> dict[str, Any]:
# Resolve a parked unauthorized message: dismiss / allow / allow_deliver (§19).
action = str((body or {}).get("action", "")).strip()
return await manager.resolve_unauthorized(name, item_id, action)
# -- OpenWorker Cloud: sign-in + managed one-click connect ---------------
# All optional: the app is fully functional signed out (manual token paste
# stays available for every connector, before and after sign-in).
@app.get("/v1/cloud/status")
def cloud_status() -> dict[str, Any]:
from .. import cloud
return {
**cloud.status(manager.secrets),
"telemetry_enabled": cloud.telemetry_enabled(manager.secrets),
}
@app.post("/v1/cloud/telemetry")
def cloud_telemetry(body: dict) -> dict[str, Any]:
"""The Phase 5 opt-out toggle. Local preference only — signed-out users
send nothing regardless of this value."""
from .. import cloud
return cloud.set_telemetry_enabled(
manager.secrets, bool((body or {}).get("enabled", True))
)
@app.post("/v1/cloud/login")
def cloud_login() -> dict[str, Any]:
"""Start browser sign-in. The sidecar opens the system browser itself
(works identically under Tauri and plain-browser dev)."""
import webbrowser
from .. import cloud
from ..config import load_config
out = cloud.begin_login(load_config())
webbrowser.open(out["authorize_url"])
return {"ok": True, "authorize_url": out["authorize_url"]}
@app.post("/v1/cloud/logout")
def cloud_logout() -> dict[str, Any]:
from .. import cloud
return cloud.logout(manager.secrets)
@app.get("/auth/callback")
async def cloud_auth_callback(code: str = "", state: str = "", error: str = ""):
from fastapi.responses import HTMLResponse
from .. import cloud
from ..config import load_config
signin_failed_detail = (
"Close this tab and try signing in again from OpenWorker."
)
if error:
return HTMLResponse(
_browser_page(
"Sign-in failed", signin_failed_detail, ok=False, error=error
),
status_code=400,
)
result = await asyncio.to_thread(
lambda: cloud.complete_login(manager.secrets, load_config(), code, state)
)
if not result.get("ok"):
return HTMLResponse(
_browser_page(
"Sign-in failed",
signin_failed_detail,
ok=False,
error=result.get("error", ""),
),
status_code=400,
)
# Restore managed connections in the background: best-effort metadata work
# that must not hold the "Signed in" page (or the GUI's signed-in flip)
# hostage to another broker round trip. Restored GitHub installs hot-add
# the gateway so the relay connects without a restart.
async def _restore_connections() -> None:
try:
out = await asyncio.to_thread(
lambda: cloud.sync_connections(manager.secrets, load_config())
)
if out.get("restored"):
await manager.refresh_gateway()
except Exception:
pass # sign-in stands; the user can still connect by hand
asyncio.get_running_loop().create_task(_restore_connections())
return HTMLResponse(
_browser_page(
"Signed in",
"You're signed in to OpenWorker Cloud. "
"You can close this tab and return to OpenWorker.",
)
)
@app.post("/v1/connectors/{name}/connect-managed")
async def connector_connect_managed(
name: str, body: Optional[dict] = None
) -> dict[str, Any]:
"""One-click managed OAuth (requires cloud sign-in). Opens the provider
consent page in the system browser; the broker's callback page will
form-POST the tokens to /oauth/callback below. `access` picks a consent
tier by NAME (e.g. hubspot read | write) — the broker owns the scopes."""
import webbrowser
from .. import cloud
from ..config import load_config
from ..connectors.descriptors import get_descriptor
d = get_descriptor(name)
if d is not None and d.managed_paused:
# GUI shows the Coming-soon state; this guard covers stale GUIs/API callers.
return {
"ok": False,
"error": f"one-click connect for {d.title} is coming soon — connect manually for now",
}
access = str((body or {}).get("access") or "")
flow = str((body or {}).get("flow") or "") # github: "" install | "authorize"
out = await asyncio.to_thread(
lambda: cloud.begin_managed_connect(
manager.secrets, load_config(), name, access=access, flow=flow
)
)
if out.get("ok"):
webbrowser.open(out["authorize_url"])
return out
@app.post("/oauth/callback")
async def managed_oauth_callback(request: Request) -> Any:
from fastapi.responses import HTMLResponse
from .. import cloud
from ..connectors.setup import (
managed_connect_connector,
managed_connect_slack_install,
)
form = await request.form()
data = {k: str(v) for k, v in form.items()}
connector = data.get("connector", "")
if not cloud.consume_managed_state(data.get("app_state", "")):
return HTMLResponse(
_browser_page(
"Connection failed",
_CONNECT_FAILED_DETAIL,
ok=False,
error="unknown or expired connection attempt",
),
status_code=400,
)
if data.get("error"):
return HTMLResponse(
_browser_page(
"Connection failed",
_CONNECT_FAILED_DETAIL,
ok=False,
error=data["error"],
),
status_code=400,
)
# Managed GitHub deliberately carries NO token fields — the loopback POST
# is routing metadata only (installation tokens are minted on demand,
# github-relay-spec §4) — so its branch precedes the access_token check.
if connector == "github" and data.get("installation_id"):
from ..connectors.github_installs import managed_connect_install
result = managed_connect_install(manager.secrets, data)
if result.get("ok"):
await manager.refresh_gateway() # hot-add, like a workspace
if not result.get("ok"):
return HTMLResponse(
_browser_page(
"Connection failed",
_CONNECT_FAILED_DETAIL,
ok=False,
error=result.get("error", ""),
),
status_code=400,
)
return HTMLResponse(
_browser_page(
"GitHub connected",
"You can close this tab and return to OpenWorker.",
connector="github",
)
)
if not connector or not data.get("access_token"):
return HTMLResponse(
_browser_page(
"Connection failed",
_CONNECT_FAILED_DETAIL,
ok=False,
error="missing fields",
),
status_code=400,
)
# Managed Slack is multi-workspace + relay: store the per-team bot token
# and flip to relay mode, rather than the single-token connector path.
if connector == "slack" and data.get("team_id"):
result = managed_connect_slack_install(manager.secrets, data)
if result.get("ok"):
# Hot-add: rebuild the gateway so the new workspace's token loads
# (and the relay socket opens on a first-ever install) right away.
await manager.refresh_gateway()
elif connector == "gmail":
# Multi-account: each sign-in lands in its own gmail:account:<email>
# profile; the first becomes the default mailbox.
from ..connectors import gmail_accounts
result = gmail_accounts.managed_connect_account(
manager.secrets, cloud.managed_profile_from_callback(data)
)
elif connector == "google_calendar":
# Multi-account, same shape as gmail: google_calendar:account:<email>.
from ..connectors import gcal_accounts
result = gcal_accounts.managed_connect_account(
manager.secrets, cloud.managed_profile_from_callback(data)
)
elif connector == "hubspot" and data.get("hub_id"):
# Multi-portal: keyed by hub_id (broker sends it like Slack's team_id).
from ..connectors import hubspot_portals
profile = cloud.managed_profile_from_callback(data)
profile["hub_id"] = data.get("hub_id", "")
if data.get("sandbox"):
profile["sandbox"] = True
result = hubspot_portals.managed_connect_portal(manager.secrets, profile)
else:
result = managed_connect_connector(
manager.secrets, connector, cloud.managed_profile_from_callback(data)
)
if not result.get("ok"):
return HTMLResponse(
_browser_page(
"Connection failed",
_CONNECT_FAILED_DETAIL,
ok=False,
error=result.get("error", ""),
),
status_code=400,
)
return HTMLResponse(
_browser_page(
f"{_connector_title(connector)} connected",
"You can close this tab and return to OpenWorker.",
connector=connector,
)
)
@app.patch("/v1/connectors/{name}/tools")
def connector_tools_patch(name: str, body: dict) -> dict[str, Any]:
enabled = (body or {}).get("enabled")
if not isinstance(enabled, dict):
return {"ok": False, "error": "enabled map required"}
return manager.update_connector_tools(name, enabled)
@app.post("/v1/connectors/{name}/allow")
def connector_allow(name: str, body: dict) -> dict[str, Any]:
# `team_id` scopes the edit to one workspace (managed relay); absent → flat list.
# `name` (optional) seeds the people directory so a directory-picked user's
# chip shows their display name before they've ever sent a message.
return manager.allow_user(
name,
str(body.get("user_id", "")),
str(body.get("team_id", "")) or None,
display_name=str(body.get("name", "")),
)
@app.get("/v1/connectors/slack/workspaces/{team_id}/directory")
async def slack_directory(
team_id: str, q: str = "", limit: int = 25
) -> dict[str, Any]:
"""Workspace member roster for the people picker (team_id "default" =
the manual Socket-Mode workspace). Cached locally; never leaves this machine."""
from ..connectors import slack_directory as roster
return await asyncio.to_thread(
lambda: roster.list_members(manager.secrets, team_id, q, limit)
)
@app.get("/v1/connectors/slack/workspaces/{team_id}/channels")
async def slack_channels(
team_id: str, q: str = "", limit: int = 25
) -> dict[str, Any]:
"""Channel roster for the channel typeahead: all public channels, private
ones only where the bot is a member (Slack API constraint)."""
from ..connectors import slack_directory as roster
return await asyncio.to_thread(
lambda: roster.list_channels(manager.secrets, team_id, q, limit)
)
@app.post("/v1/connectors/{name}/disallow")
def connector_disallow(name: str, body: dict) -> dict[str, Any]:
return manager.disallow_user(
name, str(body.get("user_id", "")), str(body.get("team_id", "")) or None
)
@app.post("/v1/connectors/slack/approval-owners/add")
def slack_approval_owner_add(body: dict) -> dict[str, Any]:
return manager.set_slack_approval_owner(
str(body.get("user_id", "")),
add=True,
display_name=str(body.get("name", "")),
)
@app.post("/v1/connectors/slack/approval-owners/remove")
def slack_approval_owner_remove(body: dict) -> dict[str, Any]:
return manager.set_slack_approval_owner(
str(body.get("user_id", "")), add=False
)
# -- audit / browser observability ------------------------------------------
@app.get("/v1/audit")
def audit_list(
limit: int = 100,
session_id: str | None = None,
connector: str | None = None,
tool: str | None = None,
) -> dict[str, Any]:
return {
"events": manager.list_audit(
limit=limit, session_id=session_id, connector=connector, tool=tool
)
}
@app.get("/v1/browser/state")
def browser_state_get() -> dict[str, Any]:
return manager.browser_state()
@app.post("/v1/browser/screenshot")
def browser_screenshot_post() -> dict[str, Any]:
return manager.browser_screenshot()
@app.post("/v1/browser/close")
def browser_close_post() -> dict[str, Any]:
return manager.browser_close()
# -- web search -------------------------------------------------------------
@app.get("/v1/web-search")
def web_search_get() -> dict[str, Any]:
return manager.get_web_search()
@app.post("/v1/web-search")
def web_search_set(body: dict) -> dict[str, Any]:
provider = (body or {}).get("provider", "")
if not provider:
return {"ok": False, "error": "provider required"}
return manager.set_web_search(provider, (body or {}).get("api_key"))
# -- model providers (OpenAI, Ollama, …) ------------------------------------
@app.get("/v1/providers")
def providers_get() -> list[dict[str, Any]]:
return manager.get_providers()
@app.post("/v1/providers")
def providers_set(body: dict) -> dict[str, Any]:
name = (body or {}).get("name", "")
if not name:
return {"ok": False, "error": "name required"}
return manager.set_provider(name, (body or {}).get("fields"))
@app.delete("/v1/providers/{name}")
def providers_remove(name: str) -> dict[str, Any]:
return manager.remove_provider(name)
@app.post("/v1/providers/verify")
async def providers_verify(body: dict) -> dict[str, Any]:
# Live read-only credential check (sync httpx) — run off the event loop.
name = (body or {}).get("name", "") or "openai"
return await asyncio.to_thread(
manager.verify_provider, name, (body or {}).get("fields")
)
# -- settings (model API key) -----------------------------------------------
@app.get("/v1/settings")
def settings_get() -> dict[str, Any]:
return manager.get_settings()
@app.post("/v1/settings/model-key")
def settings_set_model_key(body: dict) -> dict[str, Any]:
return manager.set_model_key((body or {}).get("api_key", ""))
@app.post("/v1/settings/default-model")
def settings_set_default_model(body: dict) -> dict[str, Any]:
return manager.set_default_model((body or {}).get("model", ""))
@app.post("/v1/settings/models/add")
def settings_models_add(body: dict) -> dict[str, Any]:
return manager.add_model((body or {}).get("model", ""))
@app.post("/v1/settings/models/remove")
def settings_models_remove(body: dict) -> dict[str, Any]:
return manager.remove_model((body or {}).get("model", ""))
@app.post("/v1/settings/onboarded")
def settings_set_onboarded(body: dict) -> dict[str, Any]:
return manager.set_onboarded(bool((body or {}).get("value", True)))
@app.post("/v1/settings/experimental-connectors")
def settings_set_experimental(body: dict) -> dict[str, Any]:
return manager.set_experimental_connectors(bool((body or {}).get("value")))
@app.post("/v1/settings/surfaces")
def settings_set_surfaces(body: dict) -> dict[str, Any]:
b = body or {}
return manager.set_surfaces(chat=b.get("chat"), code=b.get("code"))
@app.post("/v1/settings/scratch-base")
def settings_set_scratch_base(body: dict) -> dict[str, Any]:
return manager.set_scratch_base(str((body or {}).get("path", "")))
@app.post("/v1/settings/nav-layout")
def settings_set_nav_layout(body: dict) -> dict[str, Any]:
return manager.set_nav_layout(str((body or {}).get("nav_layout", "")))
@app.post("/v1/settings/sessions-peek")
def settings_set_sessions_peek(body: dict) -> dict[str, Any]:
# Sidebar: sessions shown per group before "Show more" (owner ask, 2026-07-03).
return manager.set_sessions_peek((body or {}).get("sessions_peek", 5))
@app.post("/v1/settings/context-bar")
def settings_set_context_bar(body: dict) -> dict[str, Any]:
# Composer: show the context-window fill bar, or just the popover (owner ask).
return manager.set_context_bar((body or {}).get("context_bar", True))
@app.post("/v1/settings/pdf")
def settings_set_pdf(body: dict) -> dict[str, Any]:
# Token savings (owner ask, 2026-07-17): fallback mode for models without native
# PDF support + attach-time page/size thresholds.
b = body or {}
return manager.set_pdf_settings(
fallback=b.get("pdf_fallback"),
max_pages=b.get("pdf_max_pages"),
max_mb=b.get("pdf_max_mb"),
)
@app.post("/v1/settings/compaction")
def settings_set_compaction(body: dict) -> dict[str, Any]:
# Auto-compaction overrides (OPE-27): threshold % of the context window, the
# absolute token cap, and the summarizer-model pin ("" → session's own model).
b = body or {}
return manager.set_compaction_settings(
threshold_pct=b.get("compaction_threshold_pct"),
cap_tokens=b.get("compaction_cap_tokens"),
model=b.get("compaction_model"),
)
@app.post("/v1/attachments/inspect-pdf")
def attachments_inspect_pdf(body: dict) -> dict[str, Any]:
# Attach-time page/size probe for the composer's threshold check. Local only.
from ..pdf_support import inspect
return inspect(str((body or {}).get("data_url", "")))
# -- direct-message routing -------------------------------------------------
@app.get("/v1/messaging/dm-route")
def dm_route_get() -> dict[str, Any]:
return {"dm_session": manager.dm_session()}
@app.post("/v1/messaging/dm-route")
def dm_route_set(body: dict) -> dict[str, Any]:
# A falsy session_id clears the designation (DMs then park as unrouted).
return manager.set_dm_session((body or {}).get("session_id", ""))
if os.environ.get("COWORKER_DEBUG_INJECT") == "1":
# Dev-only (env-gated, localhost): feed a message through the real inbound path so the
# messaging stack can be exercised without a live bot connection. Not registered otherwise.
@app.post("/v1/_debug/inject_inbound")
async def debug_inject_inbound(body: dict) -> dict[str, Any]:
from ..connectors.base import MessageEvent, SessionSource
event = MessageEvent(
text=str((body or {}).get("text", "")),
source=SessionSource(
platform=str(body.get("platform", "slack")),
chat_id=str(body.get("chat_id", "C0BD7KZ1AH5")),
user_id=str(body.get("user_id", "U07JK68S4BH")),
user_name=str(body.get("user_name", "tester")),
chat_type=str(body.get("chat_type", "channel")),
chat_name=str(body.get("chat_name", "")) or None,
thread_id=str(body.get("thread_ts", "")) or None,
team_id=str(body.get("team_id", "")) or None,
),
message_id=str(body.get("ts", "")) or None,
# §31 mention router: the flag is normally computed from the raw Slack text
# at mapping time; the injector sets it directly.
mentions_me=bool(body.get("mentions_me")),
)
await manager._dispatch_inbound(event)
return {"ok": True}
# -- automations (scheduled tasks) ------------------------------------------
@app.get("/v1/automations")
def automations_list() -> dict[str, Any]:
return manager.list_automations()
@app.post("/v1/automations")
def automations_create(body: dict) -> dict[str, Any]:
return manager.create_automation(body or {})
@app.get("/v1/automations/{task_id}")
def automation_get(task_id: str) -> dict[str, Any]:
return manager.get_automation(task_id)
@app.patch("/v1/automations/{task_id}")
def automation_update(task_id: str, body: dict) -> dict[str, Any]:
return manager.update_automation(task_id, body or {})
@app.delete("/v1/automations/{task_id}")
def automation_delete(task_id: str) -> dict[str, Any]:
return manager.delete_automation(task_id)
@app.post("/v1/automations/{task_id}/seen")
def automations_seen(task_id: str) -> dict[str, Any]:
return manager.mark_automation_seen(task_id)
@app.post("/v1/automations/{task_id}/run")
def automation_run(task_id: str) -> dict[str, Any]:
# Prepare a live manual run; the GUI opens the returned session and drives it.
return manager.prepare_manual_run(task_id)
@app.post("/v1/automations/{task_id}/runs/{run_id}/finalize")
def automation_run_finalize(task_id: str, run_id: str) -> dict[str, Any]:
return manager.finalize_manual_run(task_id, run_id)
@app.websocket("/ws/session/{session_id}")
async def ws_session(ws: WebSocket, session_id: str) -> None:
if not _websocket_authenticated(ws):
await ws.close(code=1008)
return
# CORS never gates WebSockets, so a cross-site page could otherwise open this socket
# and drive the session into tool calls. Reject a disallowed browser Origin before
# accepting the handshake (1008 = policy violation).
if not _origin_allowed(ws.headers.get("origin")):
await ws.close(code=1008)
return
await ws.accept(subprotocol="openworker" if api_token else None)
agent = ws.query_params.get("agent") or "code"
# All four interactive prompts (approval / question / directory / plan) are parked as Inbox
# items and awaited via inbox.wait — so they survive a dropped socket (redelivered on
# reconnect) and can be resolved from any surface. `visibility` decides where they SHOW:
# Unattended → the cross-session Inbox; attended → inline in this session only. The agent
# stays blocked until the item is resolved (live WS response, REST, or a bound channel).
def _visibility() -> str:
return (
VIS_INBOX
if manager.unattended.is_unattended(session_id)
else VIS_INLINE
)
async def _mirror(item) -> None:
# Unattended items mirror to a bound channel as buttons (see mirror_inbox_item).
await manager.mirror_inbox_item(item)
def _route() -> str:
return manager.inbox_routing.route_for(session_id, agent)
async def approver(_request) -> ApprovalOutcome:
# The engine has already emitted PERMISSION_REQUIRED (the live inline card). Park the
# item so the answer can also come from the Inbox / a reconnect / after a restart.
item = manager.inbox.add_approval(
session_id,
f"Run `{_request.tool_name}`?",
body="\n".join(
p
for p in (
(getattr(_request, "reason", "") or "").strip(),
args_preview(getattr(_request, "arguments", None)),
)
if p
),
inbox=_route(),
visibility=_visibility(),
# Automation-run context (manual "Run now" rides this socket): lets the
# card offer the task-persistent "Allow every time" (§25). {} elsewhere.
data=manager.approval_prompt_data(session_id, _request),
tool_call_id=getattr(_request, "tool_call_id", None),
)
if (
item.state == "pending"
): # freshly raised (not a durable-resume re-raise)
manager.persist_session(
session_id
) # the pending tool call is now on disk
if item.visibility == VIS_INBOX:
await _mirror(item)
resolution = await manager.inbox.wait(item.id)
# Accept every vocabulary: the live card sends once/always_tool/always_command/
# always_task/deny; the Inbox / a channel send allow/always/deny.
return manager.approval_outcome(resolution, _request, session_id)
async def question_asker(args: dict, tool_call_id=None) -> dict:
# ask_user (engine does NOT emit the event — we do, only when attended).
from ..tools.ask import answer_result, question_item_fields
fields = question_item_fields(args)
if fields is None: # engine guards too; belt-and-braces
return {"answer": "", "error": "no question"}
item = manager.inbox.add_question(
session_id,
inbox=_route(),
visibility=_visibility(),
tool_call_id=tool_call_id,
**fields,
)
if item.state == "pending":
manager.persist_session(session_id)
if item.visibility == VIS_INBOX:
await _mirror(item)
else:
await ws.send_json(
{
"type": "question_requested",
"data": {
"question": item.title,
"options": item.options,
"allow_text": item.allow_text,
"multi": item.multi,
"header": item.header,
"questions": item.questions,
},
}
)
return answer_result(item.questions, await manager.inbox.wait(item.id))
async def tool_requester(args: dict, tool_call_id=None) -> dict:
"""Park a TOOL_REQUESTED prompt, then install the PINNED build if approved.
Declining is a first-class outcome: the agent is told to fall back and disclose
the gap rather than drop the check (OPE-85). Installs only ever come from the
pinned registry with its digest verified — an approval is consent to install
THAT artifact, not licence to fetch whatever a prompt asked for.
"""
name = str(args.get("name", "")).strip()
info = toolchain.describe(name)
item = manager.inbox.add_tool_request(
session_id,
f"Install {name}?" if name else "Install a tool?",
body=str(args.get("reason", "")),
inbox=_route(),
visibility=_visibility(),
data={
"tool": name,
"installable": bool(info),
"version": (info or {}).get("version", ""),
"summary": (info or {}).get("summary", ""),
"url": (info or {}).get("url", ""),
"source": (info or {}).get("source", ""),
},
tool_call_id=tool_call_id,
)
if item.state == "pending":
manager.persist_session(session_id)
if item.visibility == VIS_INBOX:
await _mirror(item)
resp = _parse_json(await manager.inbox.wait(item.id)) # {approved}
if not resp.get("approved"):
return {
"installed": False,
"reason": "the user declined to install it",
}
if not info:
return {
"installed": False,
"error": f"no pinned build of {name} is available for this platform",
}
try:
path = await asyncio.to_thread(toolchain.install, name)
except Exception as exc: # noqa: BLE001 - surfaced to the agent verbatim
return {"installed": False, "error": str(exc)}
return {"installed": True, "path": path, "version": info["version"]}
async def directory_requester(args: dict, tool_call_id=None) -> dict:
# The engine has already emitted DIRECTORY_REQUESTED. Park, await, then apply the grant.
item = manager.inbox.add_directory(
session_id,
"Grant access to a folder?",
body=str(args.get("reason", "")),
inbox=_route(),
visibility=_visibility(),
data={
"path": str(args.get("path", "")),
"writable": bool(args.get("writable", False)),
},
tool_call_id=tool_call_id,
)
if item.state == "pending":
manager.persist_session(session_id)
if item.visibility == VIS_INBOX:
await _mirror(item)
resp = _parse_json(
await manager.inbox.wait(item.id)
) # {granted, path, writable}
if not resp.get("granted"):
return {"granted": False, "reason": "the user declined the request"}
path = (resp.get("path") or args.get("path") or "").strip()
if not path:
return {"granted": False, "error": "no directory was provided"}
writable = bool(resp.get("writable", args.get("writable", False)))
res = manager.add_root(session_id, path, writable)
if not res.get("ok"):
return {
"granted": False,
"error": res.get("error", "could not grant access"),
}
primary = next(
(
r
for r in res.get("roots", [])
if r.get("path")
and Path(r["path"]).expanduser().resolve()
== Path(path).expanduser().resolve()
),
None,
)
return {
"granted": True,
"path": (primary or {}).get("path", path),
"writable": writable,
}
async def plan_approver(_args: dict, tool_call_id=None) -> dict:
# The engine has already emitted PLAN_PROPOSED. Park, await the verdict.
item = manager.inbox.add_plan(
session_id,
"Approve the plan?",
body=str(_args.get("plan", "")),
inbox=_route(),
visibility=_visibility(),
tool_call_id=tool_call_id,
)
if item.state == "pending":
manager.persist_session(session_id)
if item.visibility == VIS_INBOX:
await _mirror(item)
resp = _parse_json(
await manager.inbox.wait(item.id)
) # {approved, mode, feedback}
if not resp.get("approved"):
return {
"approved": False,
"feedback": resp.get("feedback") or "the user rejected the plan",
}
return {"approved": True, "mode": resp.get("mode") or "interactive"}
async def team_approver(_args: dict, tool_call_id=None) -> dict:
# The staffing gate. The engine already emitted TEAM_PROPOSED; park an
# Inbox item as the durable resolution vehicle, wait for the verdict, and
# on approval PRE-SPAWN the team (create_team fails closed on non-worker
# personas, so a bad roster reads as a rejection with the reason).
members = _args.get("members") or []
roster = "\n".join(
f"- {m.get('persona', '?')}"
+ (f" · {m['model']}" if m.get("model") else "")
+ (f" — {m['reason']}" if m.get("reason") else "")
for m in members
if isinstance(m, dict)
)
item = manager.inbox.add_plan(
session_id,
"Create this team?",
body=roster,
inbox=_route(),
visibility=_visibility(),
tool_call_id=tool_call_id,
)
if item.state == "pending":
manager.persist_session(session_id)
if item.visibility == VIS_INBOX:
await _mirror(item)
resp = _parse_json(await manager.inbox.wait(item.id))
if not resp.get("approved"):
return {
"approved": False,
"feedback": resp.get("feedback") or "the user declined this roster",
}
# The gate checkbox is the USER's call: an explicit enable_chat in the
# response overrides whatever the lead proposed.
enable_chat = bool(
resp["enable_chat"]
if "enable_chat" in resp
else _args.get("enable_chat", False)
)
return manager.create_team(
session_id,
[m for m in members if isinstance(m, dict)],
enable_chat=enable_chat,
)
async def items_approver(_args: dict, tool_call_id=None) -> dict:
# The decomposition gate. TEAMS-flavored sibling of plan_approver:
# park a durable Inbox item, wait, and on approval create the items.
items = _args.get("items") or []
body = "\n".join(
f"- {i.get('title', '?')} — Done when: {i.get('criteria', '?')}"
for i in items
if isinstance(i, dict)
)
item = manager.inbox.add_plan(
session_id,
"Approve the proposed work items?",
body=body,
inbox=_route(),
visibility=_visibility(),
tool_call_id=tool_call_id,
)
if item.state == "pending":
manager.persist_session(session_id)
if item.visibility == VIS_INBOX:
await _mirror(item)
resp = _parse_json(await manager.inbox.wait(item.id))
if not resp.get("approved"):
return {
"approved": False,
"feedback": resp.get("feedback") or "the user declined the split",
}
return manager.board_create_items(
session_id, [i for i in items if isinstance(i, dict)]
)
async def _apply_model(model: Optional[str]) -> None:
# Mid-session rebind is allowed (roadmap item 3, supersedes the 2026-07-04
# lock): history is canonical and providers convert per call. A real switch
# appends a persisted notice; broadcast it so live views render the marker
# and update their header. Never rebind mid-turn — the running loop reads
# `engine.model` per iteration and a mixed turn is exactly the breakage the
# old lock existed to prevent.
if not model or manager.is_running(session_id):
return
notice = engine.switch_model(model)
if notice is None: # same model, or first bind on a fresh session
return
manager.persist_session(session_id)
await manager.broadcast_session(
session_id,
{"type": "model_changed", "data": {"model": model, "text": notice}},
)
def _resolve_pending(resolution: str) -> None:
# Live WS responses resolve THE session's single pending prompt (one at a time, since the
# agent blocks). Reconnect / Inbox resolve by id via REST instead.
pend = manager.inbox.pending(session_id)
if pend:
manager.inbox.resolve(pend[0].id, resolution)
workspace = ws.query_params.get("workspace")
mcp_tools = await manager.prepare_mcp_tools(
session_id, workspace=workspace, agent=agent
)
engine = manager.get_engine(
session_id,
workspace=workspace,
agent=agent,
approver=approver,
extra_tools=mcp_tools,
directory_requester=directory_requester,
plan_approver=plan_approver,
question_asker=question_asker,
tool_requester=tool_requester,
team_approver=team_approver,
items_approver=items_approver,
)
if engine is None:
await ws.send_json(
{
"type": "error",
"data": {
"error": "no valid workspace — choose a project folder first"
},
}
)
await ws.close()
return
# Auto-compaction failure prompt (OPE-27): only an ATTENDED session may be asked
# Retry/Trim — unattended runs auto-trim (the policy in engine._compact_now).
engine.is_attended = lambda: _visibility() == VIS_INLINE
await ws.send_json(
{
"type": "ready",
"data": {
"session_id": session_id,
"agent": getattr(engine, "agent_name", "code"),
"model": engine.model,
"mode": engine.permissions.mode.value,
"workspace": (
str(getattr(engine, "executor").cwd)
if getattr(engine, "executor", None)
else None
),
# UX-029: the GUI never shows a temporary folder's raw path — this flag
# is how it knows to say "Temporary folder" (and offer Save as project).
"temp_workspace": manager.is_temp_workspace(
str(getattr(engine, "executor").cwd)
if getattr(engine, "executor", None)
else None
),
"command_trust": manager.workspace_command_trust(
str(getattr(engine, "audit_context", {}).get("workspace", ""))
),
},
}
)
# Checkpoint events: persist mid-turn so a crash/quit can't eat the conversation.
# turn_start = the user message just landed (a brand-new session gets its row here,
# not at connect — empty never-used sessions shouldn't appear in Recents);
# permission_required/directory_requested = parked indefinitely on the user;
# iteration_end = a model response + its tool results completed.
_CHECKPOINTS = {
"turn_start",
"permission_required",
"directory_requested",
"plan_proposed",
"iteration_end",
}
async def run_turn(content, *, retry: bool = False, display=None) -> None:
# The receive loop atomically claims this session before scheduling the task.
# Keeping the claim outside prevents two back-to-back frames from both starting.
try:
events = (
engine.retry()
if retry
else engine.run(content, display=display)
)
async for event in events:
# Broadcast to every socket viewing this session (this socket included — it's a
# registered client), so a second view of the same session stays in sync too.
await manager.broadcast_session(
session_id, {"type": event.type.value, "data": event.data}
)
if event.type.value in _CHECKPOINTS:
manager.save(session_id, engine)
finally:
manager.mark_idle(session_id)
manager.save(session_id, engine)
await manager.broadcast_session(
session_id, {"type": "turn_done", "data": {}}
)
# This socket is now a live view of the session; background turns (channel delivery,
# self-wake, durable resume) broadcast here too, not just locally driven run_turns.
manager.register_session_client(session_id, ws.send_json)
inbound_times: deque[float] = deque()
async def reject_input(reason: str) -> None:
# Input validation failures are not provider failures and must not offer "Retry"
# or flush an in-progress assistant stream in the GUI.
await ws.send_json({"type": "input_rejected", "data": {"error": reason}})
async def claim_turn(*, retry: bool = False, content=None, display=None) -> None:
if not manager.try_mark_running(session_id):
await reject_input(
"This session is already running a turn. Wait for it to finish or stop it."
)
return
asyncio.create_task(run_turn(content, retry=retry, display=display))
try:
while True:
try:
message = await ws.receive_json()
except (json.JSONDecodeError, UnicodeDecodeError):
await reject_input("Invalid WebSocket message: expected JSON.")
continue
now = asyncio.get_running_loop().time()
while (
inbound_times
and now - inbound_times[0] > _WS_RATE_LIMIT_WINDOW_SECONDS
):
inbound_times.popleft()
if len(inbound_times) >= _WS_RATE_LIMIT_COUNT:
await reject_input("Too many WebSocket messages; reconnect and try again.")
await ws.close(code=1008)
return
inbound_times.append(now)
if not isinstance(message, dict):
await reject_input("Invalid WebSocket message: expected an object.")
continue
kind = message.get("type")
if not isinstance(kind, str):
await reject_input("Invalid WebSocket message: missing string type.")
continue
if kind == "approval":
_resolve_pending(message.get("decision", "deny"))
elif kind == "directory_response":
_resolve_pending(
json.dumps(
{
"granted": bool(message.get("granted")),
"path": message.get("path", ""),
"writable": bool(message.get("writable", False)),
}
)
)
elif kind == "tool_response":
_resolve_pending(
json.dumps({"approved": bool(message.get("approved"))})
)
elif kind == "plan_response":
_resolve_pending(
json.dumps(
{
"approved": bool(message.get("approved")),
"mode": message.get("mode", "interactive"),
"feedback": message.get("feedback", ""),
}
)
)
elif kind in ("team_response", "items_response"):
_resolve_pending(
json.dumps(
{
"approved": bool(message.get("approved")),
"feedback": message.get("feedback", ""),
**(
{"enable_chat": bool(message.get("enable_chat"))}
if "enable_chat" in message
else {}
),
}
)
)
elif kind == "question_response":
_resolve_pending(str(message.get("answer", "")))
elif kind == "interrupt":
engine.request_interrupt()
elif kind == "retry":
# Re-run after a provider error (engine guards on the error-notice
# tail, so a stray frame is a no-op that still ends with turn_done).
await claim_turn(retry=True)
elif kind == "set_mode":
try:
engine.permissions.mode = Mode(message.get("mode"))
except (TypeError, ValueError):
pass
elif kind == "set_model":
model = message.get("model")
if model is not None and not isinstance(model, str):
await reject_input("Invalid model: expected a string.")
else:
await _apply_model(model)
elif kind == "user_message":
raw_text = message.get("text")
if raw_text is None:
raw_text = ""
if not isinstance(raw_text, str):
await reject_input("Invalid message text: expected a string.")
continue
text = raw_text.strip()
raw_attachments = message.get("attachments")
attachments = [] if raw_attachments is None else raw_attachments
# Reject an oversized frame instead of buffering it into a turn. Send a
# visible error so the surface can tell the user, and drop the message.
if not isinstance(attachments, list):
await reject_input("Invalid attachments: expected a list.")
continue
reject = None
if len(text) > _MAX_MESSAGE_TEXT_CHARS:
reject = (
f"Message too long ({len(text)} chars; "
f"limit {_MAX_MESSAGE_TEXT_CHARS})."
)
elif len(attachments) > _MAX_ATTACHMENTS:
reject = (
f"Too many attachments ({len(attachments)}; "
f"limit {_MAX_ATTACHMENTS})."
)
elif any(not isinstance(a, dict) for a in attachments):
reject = "Invalid attachment: expected an object."
elif _json_value_size(attachments) > _MAX_ATTACHMENTS_BYTES:
reject = "Attachments too large (limit 15 MB per message)."
else:
for attachment in attachments:
attachment_kind = attachment.get("kind")
name = attachment.get("name")
mime = attachment.get("mime")
if attachment_kind not in {"image", "pdf", "text"}:
reject = "Invalid attachment kind."
elif name is not None and (
not isinstance(name, str) or len(name) > 1024
):
reject = "Invalid attachment name."
elif mime is not None and (
not isinstance(mime, str) or len(mime) > 255
):
reject = "Invalid attachment MIME type."
elif attachment_kind == "image":
data = attachment.get("data_url")
if (
not isinstance(data, str)
or not data.startswith("data:image/")
or ";base64," not in data
or len(data) > MAX_IMAGE_CHARS
):
reject = "Invalid or oversized image attachment."
elif attachment_kind == "pdf":
data = attachment.get("data_url")
if (
not isinstance(data, str)
or not data.startswith(
"data:application/pdf;base64,"
)
or len(data) > MAX_PDF_CHARS
):
reject = "Invalid or oversized PDF attachment."
else:
body = attachment.get("text")
if (
not isinstance(body, str)
or len(body) > MAX_TEXT_CHARS
):
reject = "Invalid or oversized text attachment."
if reject is not None:
break
if reject is not None:
await reject_input(reject)
continue
# The composer sends its visible model with every message — the FIRST
# one binds the session (race-proof across reconnects; see api.ts
# Session.userMessage), later ones may switch it (notice persisted).
model = message.get("model")
if model is not None and not isinstance(model, str):
await reject_input("Invalid model: expected a string.")
continue
# Force-run (SKILLS-SPEC §4.1 #3): the composer's `/skill` pick rides as a
# separate field. Validated against the session's effective menu — a muted
# or unknown skill is a visible error, never a silent no-op (§4.6 #15).
# The model-facing framing goes into `content`; the transcript shows the
# user's literal "/name …" line via the `_display` sidecar (one bubble).
skill = message.get("skill")
display = None
if skill is not None:
if not isinstance(skill, str) or not skill.strip():
await reject_input("Invalid skill: expected a name.")
continue
skill = skill.strip()
menu = manager.effective_skill_names(session_id, workspace)
if skill not in menu:
await reject_input(
f"Skill '{skill}' is not available in this session."
)
continue
display = f"/{skill}" + (f" {text}" if text else "")
text = (
f'Use the skill "{skill}" for this request: first call '
f'load_skill("{skill}") and follow its instructions.'
+ (f"\n\n{text}" if text else "")
)
await _apply_model(model)
if text or attachments:
content = build_user_content(text, attachments)
await claim_turn(content=content, display=display)
else:
await reject_input(f"Unknown WebSocket message type: {kind}.")
except WebSocketDisconnect:
pass
finally:
manager.unregister_session_client(session_id, ws.send_json)
@app.websocket("/ws/events")
async def ws_events(ws: WebSocket) -> None:
"""App-wide event stream (session-independent): the GUI keeps one open for
pushes like automation_run_started (the UX-026 toast). Read-only — inbound
frames are ignored; the receive loop just detects disconnect."""
if not _websocket_authenticated(ws):
await ws.close(code=1008)
return
if not _origin_allowed(ws.headers.get("origin")):
await ws.close(code=1008)
return
await ws.accept(subprotocol="openworker" if api_token else None)
manager.register_event_client(ws.send_json)
try:
while True:
await ws.receive_text()
except WebSocketDisconnect:
pass
finally:
manager.unregister_event_client(ws.send_json)
return app
def _parse_json(s: str) -> dict[str, Any]:
"""Parse a structured Inbox resolution (directory/plan carry their reply as a JSON string)."""
try:
v = json.loads(s) if s else {}
return v if isinstance(v, dict) else {}
except Exception:
return {}
def _openai_response(model: str, turn: AssistantTurn) -> dict[str, Any]:
message: dict[str, Any] = {"role": "assistant", "content": turn.text or ""}
if turn.tool_calls:
message["tool_calls"] = [
{
"id": tc.id,
"type": "function",
"function": {"name": tc.name, "arguments": json.dumps(tc.arguments)},
}
for tc in turn.tool_calls
]
return {
"id": "chatcmpl-" + uuid.uuid4().hex[:12],
"object": "chat.completion",
"model": model,
"choices": [
{
"index": 0,
"message": message,
"finish_reason": turn.finish_reason or "stop",
}
],
}