diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000000..228e34c7ac --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,17 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "gui", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev", "--prefix", "surfaces/gui"], + "port": 1420 + }, + { + "name": "server", + "runtimeExecutable": ".venv/Scripts/python.exe", + "runtimeArgs": ["-m", "coworker.server.run", "--cwd", "."], + "port": 8765 + } + ] +} diff --git a/.gitignore b/.gitignore index f7166ef094..4cc93b88b7 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ dist/ # Local secrets (live-smoke BYO keys) — never committed .env +.claude/settings.local.json diff --git a/README.md b/README.md index b32a299dce..60172b41d0 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,14 @@ -# OpenWorker +
openworker.com · Download · Issues
-{body}
""" + + +def _http_response(status: str, title: str, body: str) -> bytes: + html = _PAGE.format(title=title, body=body).encode("utf-8") + head = ( + f"HTTP/1.1 {status}\r\nContent-Type: text/html; charset=utf-8\r\n" + f"Content-Length: {len(html)}\r\nConnection: close\r\n\r\n" + ) + return head.encode("ascii") + html + + +async def _start_callback_server( + expected_state: str, +) -> tuple[asyncio.AbstractServer, "asyncio.Future[str]"]: + """Bind the fixed loopback port and resolve the future with the auth code when + the redirect (carrying the matching `state`) lands.""" + loop = asyncio.get_running_loop() + future: asyncio.Future[str] = loop.create_future() + + async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + try: + request_line = await reader.readline() + while True: # drain headers; the redirect is a bare GET + line = await reader.readline() + if line in (b"\r\n", b"\n", b""): + break + parts = request_line.decode("ascii", errors="replace").split() + target = urlsplit(parts[1] if len(parts) > 1 else "/") + if target.path != CALLBACK_PATH: + writer.write(_http_response("404 Not Found", "Not found", "")) + return + query = parse_qs(target.query) + error = (query.get("error") or [""])[0] + code = (query.get("code") or [""])[0] + state = (query.get("state") or [""])[0] + if error: + writer.write( + _http_response( + "400 Bad Request", + "Sign-in failed", + "The service reported an error. Return to OpenWorker and try again.", + ) + ) + if not future.done(): + future.set_exception( + CodexAuthError(f"Sign-in failed — the service returned: {error}") + ) + return + # Same loopback gate as mcp/oauth.py: a stray local hit with the wrong + # state must not consume the flow — only the genuine redirect resolves it. + if not code or not pysecrets.compare_digest(state, expected_state): + writer.write( + _http_response( + "400 Bad Request", + "Nothing waiting for this sign-in", + "The sign-in may have timed out. Return to OpenWorker and start it again.", + ) + ) + return + writer.write( + _http_response( + "200 OK", + "Signed in", + "You can close this tab and return to OpenWorker.", + ) + ) + if not future.done(): + future.set_result(code) + finally: + try: + await writer.drain() + writer.close() + except Exception: + pass + + try: + server = await asyncio.start_server(handle, "127.0.0.1", CALLBACK_PORT) + except OSError as exc: + raise CodexAuthError(PORT_BUSY_ERROR) from exc + return server, future + + +async def sign_in( + secrets: Any, + *, + timeout: float = FLOW_TIMEOUT_SECONDS, + open_browser: bool = True, +) -> dict[str, Any]: + """Run the full interactive flow: loopback server → browser → code → tokens. + + Explicit-action only (a Settings button) — never called from an engine turn, so + unlike mcp/oauth.py it needs no non-interactive refusal path. + """ + global last_authorize_url, _active_server + if _active_server is not None: + # A stale flow lost its browser tab; the new one takes the port. + _active_server.close() + await _active_server.wait_closed() + _active_server = None + verifier, challenge = create_pkce() + state = pysecrets.token_urlsafe(24) + url = build_authorize_url(state, challenge) + last_authorize_url = url + server, code_future = await _start_callback_server(state) + _active_server = server + try: + if open_browser: + import webbrowser + + logger.info("codex auth: opening browser for sign-in") + await asyncio.get_running_loop().run_in_executor(None, webbrowser.open, url) + try: + code = await asyncio.wait_for(code_future, timeout) + except asyncio.TimeoutError: + raise CodexAuthError( + "Sign-in timed out — the browser window was not completed in " + f"{int(timeout) // 60} minutes." + ) + finally: + server.close() + await server.wait_closed() + if _active_server is server: + _active_server = None + tokens = await asyncio.to_thread(exchange_code, code, verifier) + store = CodexTokenStore(secrets) + store.save(tokens) + if not (store._data().get("tokens") or {}).get("access_token"): + store.clear() + raise CodexAuthError("Sign-in failed — the token response had no access token.") + return {"ok": True, "account": store.account_label()} + + +# -- verify probe ------------------------------------------------------------------- + + +def verify(secrets: Any, timeout: float = 10.0) -> dict[str, Any]: + """Test-button probe: one cheap authenticated request against the backend. + + Distinguishes signed-out (no/rejected tokens) vs expired (401 with a bearer we + thought was live) vs OK. Never raises; {ok, error?, state?} like the other + provider verifies. + """ + import httpx + + store = CodexTokenStore(secrets) + if not store.signed_in(): + return {"ok": False, "error": SIGNED_OUT_ERROR, "state": "signed_out"} + try: + token, account = store.access_token() + except CodexSignInRequired as exc: + return {"ok": False, "error": str(exc), "state": "signed_out"} + except CodexAuthError as exc: + return {"ok": False, "error": str(exc)} + try: + resp = httpx.post( + CODEX_BASE_URL + "/responses", + headers={ + "Authorization": f"Bearer {token}", + **backend_headers(account, str(uuid.uuid4())), + }, + json={ + "model": _VERIFY_MODEL, + "input": "Reply with OK.", + "store": False, + "stream": True, + "max_output_tokens": 16, + }, + timeout=timeout, + ) + except Exception as exc: + return { + "ok": False, + "error": f"Couldn't reach the ChatGPT backend ({exc.__class__.__name__}).", + } + if resp.status_code < 300: + return {"ok": True, "account": store.account_label()} + if resp.status_code in (401, 403): + return {"ok": False, "error": EXPIRED_ERROR, "state": "expired"} + if resp.status_code == 429: + # Auth is fine — the plan window is just used up right now. + return {"ok": True, "account": store.account_label(), "note": PLAN_LIMIT_ERROR} + return { + "ok": False, + "error": f"The ChatGPT backend returned HTTP {resp.status_code}.", + } diff --git a/coworker/providers/codex_provider.py b/coworker/providers/codex_provider.py new file mode 100644 index 0000000000..7fe9010f42 --- /dev/null +++ b/coworker/providers/codex_provider.py @@ -0,0 +1,135 @@ +"""`openai-codex` provider — OpenAI models through a ChatGPT subscription. + +The backend speaks the same Responses wire as `/v1/responses` (stateless: full +history each turn, `store: false`, encrypted reasoning in the `_openai` sidecar), so +all conversion/parsing is inherited from `OpenAIResponsesProvider` — this subclass +only swaps the credential: a short-lived OAuth bearer from `codex_auth` instead of an +API key, plus the account/originator/session headers the backend requires. + +Differences from the API-key path: + - The backend serves streamed responses only, so `complete()` drains `stream()`. + - 401 → one refresh-and-retry (the bearer died mid-flight); a rejected refresh + token surfaces as a typed sign-in-required error, never a crash loop. + - 429 → the plan's rolling usage window, surfaced as a user-readable message. +""" + +from __future__ import annotations + +import uuid +from typing import Any, Optional + +from .base import AssistantTurn +from .codex_auth import ( + CODEX_BASE_URL, + PLAN_LIMIT_ERROR, + CodexTokenStore, + backend_headers, +) +from .openai_responses import OpenAIResponsesProvider + + +def _status_code(exc: Exception) -> Optional[int]: + status = getattr(exc, "status_code", None) + if isinstance(status, int): + return status + status = getattr(getattr(exc, "response", None), "status_code", None) + return status if isinstance(status, int) else None + + +class CodexProvider(OpenAIResponsesProvider): + def __init__( + self, + client: Any = None, + *, + secrets: Any = None, + default_model: str = "gpt-5.2-codex", + reasoning_summary: bool = True, + ): + super().__init__( + client=client, + default_model=default_model, + base_url=CODEX_BASE_URL, + reasoning_summary=reasoning_summary, + ) + self._store = CodexTokenStore(secrets) + # One conversation per provider instance in practice (the router caches one + # client per provider); a uuid per instance satisfies the per-conversation + # session header without threading conversation ids through ProviderClient. + self._session_id = str(uuid.uuid4()) + self._client_token: Optional[str] = None + self._injected = client is not None + + def _ensure_client(self) -> Any: + if self._injected: + return self._client + # The bearer is short-lived: fetch per call (refreshes itself near expiry) + # and rebuild the SDK client whenever the token rotated. + token, account = self._store.access_token() + if self._client is None or token != self._client_token: + from openai import OpenAI + + self._client = OpenAI( + api_key=token, + base_url=CODEX_BASE_URL, + default_headers=backend_headers(account, self._session_id), + ) + self._client_token = token + return self._client + + def _request_kwargs( + self, + *, + model: str, + messages: list[dict[str, Any]], + tools: Optional[list[dict[str, Any]]], + settings: dict[str, Any], + ) -> dict[str, Any]: + kwargs = super()._request_kwargs( + model=model, messages=messages, tools=tools, settings=settings + ) + # This backend 400s ("Unsupported parameter") on standard sampling/cap knobs — + # max_output_tokens and temperature confirmed live, top_p same family — which + # silently killed every autotitle attempt on plan sessions (owner catch + # 2026-08-24). Callers may pass them freely; they just cannot ride to this + # backend. + for unsupported in ("max_output_tokens", "temperature", "top_p"): + kwargs.pop(unsupported, None) + # Unlike stock /v1/responses, this backend honors a reasoning effort knob. + effort = settings.get("reasoning_effort") + if isinstance(effort, str) and effort: + kwargs["reasoning"] = {**kwargs.get("reasoning", {}), "effort": effort} + # The backend rejects requests without instructions; history normally + # carries a system prompt — this is only the bare-call fallback. + kwargs.setdefault("instructions", "You are a helpful assistant.") + return kwargs + + def _create(self, client: Any, kwargs: dict[str, Any]) -> Any: + try: + return super()._create(client, kwargs) + except Exception as exc: + status = _status_code(exc) + if status == 401 and not self._injected: + # The bearer died mid-flight: force one refresh and retry once. + # A rejected refresh raises CodexSignInRequired out of the store. + self._store.refresh() + self._client = None + self._client_token = None + return super()._create(self._ensure_client(), kwargs) + if status == 429: + raise RuntimeError(PLAN_LIMIT_ERROR) from exc + raise + + def complete( + self, + *, + model: str, + messages: list[dict[str, Any]], + tools: Optional[list[dict[str, Any]]] = None, + **settings: Any, + ) -> AssistantTurn: + # The backend only serves streamed responses — aggregate the stream. + turn: Optional[AssistantTurn] = None + for chunk in self.stream(model=model, messages=messages, tools=tools, **settings): + if chunk.turn is not None: + turn = chunk.turn + return turn if turn is not None else AssistantTurn() diff --git a/coworker/providers/matrix.py b/coworker/providers/matrix.py index bf6a878c86..052a19ff00 100644 --- a/coworker/providers/matrix.py +++ b/coworker/providers/matrix.py @@ -57,6 +57,56 @@ class ModelEntry: "gpt-5.6-terra": ModelEntry("GPT-5.6 Terra · OpenAI", _AGENTIC_VISION, 400_000), "gpt-5.6-luna": ModelEntry("GPT-5.6 Luna · OpenAI", _AGENTIC_VISION, 400_000), "gpt-5.5": ModelEntry("GPT-5.5 · OpenAI", _AGENTIC_VISION, 400_000), + # ChatGPT-subscription catalog (the `openai-codex` OAuth provider). Curated to the + # ids the subscription backend actually serves; vision per the vendor's model docs, + # PDF unverified over this backend → local fallback via pdf_support.py. + # 5.6 tiers (Sol flagship / Terra balanced / Luna fast) serve over the subscription + # backend by plan — Sol is rate-limited on Plus, full on Pro. + "openai-codex:gpt-5.6-sol": ModelEntry( + "GPT-5.6 Sol · ChatGPT plan", + ModelCapabilities( + tools=True, vision=True, parallel_tool_calls=True, streaming=True + ), + 400_000, + ), + "openai-codex:gpt-5.6-terra": ModelEntry( + "GPT-5.6 Terra · ChatGPT plan", + ModelCapabilities( + tools=True, vision=True, parallel_tool_calls=True, streaming=True + ), + 400_000, + ), + "openai-codex:gpt-5.6-luna": ModelEntry( + "GPT-5.6 Luna · ChatGPT plan", + ModelCapabilities( + tools=True, vision=True, parallel_tool_calls=True, streaming=True + ), + 400_000, + ), + "openai-codex:gpt-5.2-codex": ModelEntry( + "GPT-5.2 Codex · ChatGPT plan", + ModelCapabilities( + tools=True, vision=True, parallel_tool_calls=True, streaming=True + ), + 400_000, + ), + "openai-codex:gpt-5.2": ModelEntry( + "GPT-5.2 · ChatGPT plan", + ModelCapabilities( + tools=True, vision=True, parallel_tool_calls=True, streaming=True + ), + 400_000, + ), + "openai-codex:gpt-5.1-codex": ModelEntry( + "GPT-5.1 Codex · ChatGPT plan", + ModelCapabilities( + tools=True, vision=True, parallel_tool_calls=True, streaming=True + ), + 400_000, + ), + "openai-codex:gpt-5.1-codex-mini": ModelEntry( + "GPT-5.1 Codex Mini · ChatGPT plan", _AGENTIC, 400_000 + ), # Fable 5 (2026-06-09) is GA; its Mythos 5 sibling is approved-orgs-only, so it # stays out of a picker meant for the public. "anthropic:claude-fable-5": ModelEntry( @@ -85,6 +135,21 @@ class ModelEntry: "gemini:gemini-2.5-flash": ModelEntry( "Gemini 2.5 Flash · Google", _AGENTIC_VISION, 1_048_576 ), + # Ark Responses API providers (verified 2026-08-14). BytePlus pay-as-you-go and + # Volcengine Agent Plan intentionally use separate provider prefixes because their + # endpoints, credentials, regions, and model catalogs are not interchangeable. + "ark:dola-seed-evolving-latest-version": ModelEntry( + "Dola Seed Evolving · BytePlus Ark", context_window=256_000 + ), + "ark:dola-seed-2-1-turbo-260628": ModelEntry( + "Dola Seed 2.1 Turbo · BytePlus Ark", context_window=256_000 + ), + "ark-agent-plan-cn:doubao-seed-evolving": ModelEntry( + "Doubao Seed Evolving · Volcengine Agent Plan", context_window=256_000 + ), + "ark-agent-plan-cn:doubao-seed-2.1-turbo": ModelEntry( + "Doubao Seed 2.1 Turbo · Volcengine Agent Plan", context_window=256_000 + ), # -- direct OpenAI-compatible vendors ---------------------------------------- # Muse Spark (Meta Model API, public preview 2026-07-09): multimodal + tools via # their OpenAI-compat surface. Vision yes; PDFs unverified over compat — falls @@ -157,6 +222,12 @@ class ModelEntry: "openrouter:meta-llama/llama-4-maverick": ModelEntry( "Llama 4 Maverick · via OpenRouter", _AGENTIC, 1_000_000 ), + # Stealth/cloaked alpha (catalog-checked 2026-08-24: 1,048,576 ctx, tool calling). + # These are temporary lab previews — expect the slug to vanish when the lab ships + # the real model; keep it until OpenRouter retires it. + "openrouter:stealth/ox-alpha": ModelEntry( + "Ox Alpha · via OpenRouter", _AGENTIC, 1_048_576 + ), # -- cloud accounts (models running in the user's own AWS/GCP) ---------------- # Bedrock ids carry a family segment (claude/ → native Anthropic path, other/ → # Converse) plus AWS's own `-v
',
+ ].join("\n"),
+ });
+ }
+ if (/\/v1\/sessions\/[^/]+\/artifacts\/reveal$/.test(p)) return json({ ok: true });
+ // Item detail (merged event timeline + attachments) for the detail pane.
+ if (/\/v1\/sessions\/[^/]+\/board\/item$/.test(p)) {
+ const id = Number(new URL(req.url()).searchParams.get("id"));
+ const item = boardItems.find((i) => i.id === id);
+ if (!item) return json({ error: "no such item" });
+ const at = new Date().toISOString();
+ const timeline =
+ id === 5
+ ? [
+ { seq: 30, ts: at, actor: "lead", kind: "created" },
+ { seq: 31, ts: at, actor: "lead", kind: "assigned", assignee: "security" },
+ { seq: 32, ts: at, actor: "security", kind: "moved", to: "in_progress" },
+ {
+ seq: 41,
+ ts: at,
+ actor: "security",
+ kind: "comment",
+ body: "Rolled all four sections into report.md — balances reconcile against the seeded rows.",
+ },
+ {
+ seq: 42,
+ ts: at,
+ actor: "security",
+ kind: "comment",
+ body: "attached the rendered page",
+ refs: [`attachment://${"a".repeat(64)}.png#rendered-page.png`],
+ },
+ {
+ seq: 43,
+ ts: at,
+ actor: "security",
+ kind: "moved",
+ to: "review",
+ body: "Ready — balances verified against seeded rows.",
+ },
+ ]
+ : [{ seq: 30, ts: at, actor: "lead", kind: "created" }];
+ return json({ ...item, timeline: timeline.concat(itemNotes[id] || []) });
+ }
+ if (/\/v1\/sessions\/[^/]+\/board\/comment$/.test(p) && m === "POST") {
+ const b = req.postDataJSON() || {};
+ const id = Number(b.item);
+ (itemNotes[id] = itemNotes[id] || []).push({
+ seq: 90 + (itemNotes[id]?.length || 0),
+ ts: new Date().toISOString(),
+ actor: "user",
+ kind: "comment",
+ body: String(b.body || ""),
+ });
+ return json({ ok: true });
+ }
+ if (/\/v1\/sessions\/[^/]+\/board\/attachment$/.test(p)) {
+ // A real 1x1 PNG so the - Off mutes it for this session only — the connector stays connected. -
- )} {/* §32 addendum (owner ask 2026-07-13; FB-012): the catalog's long tail, in-session. A quiet row that becomes a typeahead: full list on focus, filter as you type. */} {adding ? ({c.blurb}
}- Connecting makes {c.title} available to all your coworkers — the toggle in this list - controls just this session. +
+ {tt("access.scope_note", { title: c.title })}
- The agent receives messages posted to these channels. Removing one stops this session - from listening — the connector stays connected. +
+ {tt("access.channels_note")}
{g.target}
-
- {g.access === "write" ? " — always allowed once you approve" : " — read-only"}
+ {grants.map((g, i) => {
+ const verbKey = TOOL_VERBS[g.tool];
+ return (
+ {g.target}
+
+ {g.access === "write" ? t("approval.grant.always_after_approve") : t("approval.grant.read_only")}
+
+
+ - The bot must be a member of the channel — invite @OpenWorker in Slack if it isn't. + {t("automations.bot_member_hint")}
> )} -- This automation only reads on schedule — reading - never needs approval. +
+ {t("automations.read_only_pref")} {t("automations.reads")} {t("automations.read_only_suff")}
) : null}{event.body}
} + {loadAttachment && + shots.map((ref) => ( +{create - ? "Pick a folder or enter a path. If the path doesn't exist, it will be created." - : "This coworker needs a workspace to read, edit, and run in."} + ? t("folder_gate.create_sub") + : t("folder_gate.choose_sub")}
- Inbound messages and background-turn failures nothing claimed — nothing vanishes - silently. +
+ {t("inbox.unrouted_sub")}
- Channel where an Unattended session posts Approve/Deny buttons. Currently mirroring to{" "} + {tt("inbox.mirror_desc_prefix")}{" "} - {known ? `#${known}` : target || "in-app Inbox only"} + {known ? `#${known}` : target || tt("inbox.in_app_inbox_only")} - . + {tt("inbox.mirror_desc_suffix")}
- Choose an approval owner under Integrations → Slack before routing approvals here. +
+ {tt("inbox.missing_slack_owner")}
)} - {error &&{error}
} + {error &&{error}
}- Session that handles DMs to the bot. With none, DMs park under Unrouted below. + {tt("inbox.dm_desc")}
| Session | -Listens to | -Inbox routes to | +{tt("inbox.col_session")} | +{tt("inbox.col_listens_to")} | +{tt("inbox.col_inbox_routes_to")} |
|
|---|
| When | -Source | -Reason | -Message | +{tt("inbox.col_when")} | +{tt("inbox.col_source")} | +{tt("inbox.col_reason")} | +{tt("inbox.col_message")} |
|---|