From d300ec8ca3e15decdedf41dc27f75ed829d085c3 Mon Sep 17 00:00:00 2001 From: David McKay Date: Sun, 6 Sep 2026 16:16:00 -0700 Subject: [PATCH 01/46] Eight harnesses, each through the package its own maintainers publish The picker needs something to pick. These are the first eight rows: CrewAI, Agno, LlamaIndex, Pydantic AI, LangGraph, AWS Strands, Google ADK and Langroid, each an image that speaks AG-UI and each built on the integration somebody else already keeps working. Six of those packages come from the AG-UI repository and two are published by the framework's own authors. Not one line of protocol is ours, which is the rule. Every one answered the same question over a real AG-UI stream before it was committed. Not one of the eight worked from its documentation alone: `uvicorn --host ::` binds IPv6 only, with no v4-mapped addresses, so the first container refused 127.0.0.1 and would have failed its Compose healthcheck for reasons nothing on screen would explain. Agno moved its interface to `agno.os.interfaces.agui` and ships neither FastAPI nor python-multipart with the extra that needs them. LlamaIndex's litellm wrapper sends `tool_choice` with no tools, which OpenAI rejects outright, so it takes the provider-specific class instead. Pydantic AI has no `to_ag_ui()`; the entry point is `AGUIAdapter.dispatch_request`. LangGraph refuses to run without a checkpointer because the integration resumes threads by id. Strands and Langroid both take a `name` their READMEs omit. Langroid rejects `openai/gpt-4o-mini` as an invalid model id and wants the bare name. The catalogue is data rather than wizard code, so a ninth is a row and an image. It deliberately excludes OpenBot's own `built-in` agent type, which is a system prompt and not a harness, and everything the AG-UI table still marks In Progress. --- agent-adk/Dockerfile | 19 ++ agent-adk/requirements.txt | 6 + agent-adk/src/main.py | 54 +++++ agent-agno/Dockerfile | 19 ++ agent-agno/requirements.txt | 5 + agent-agno/src/main.py | 59 ++++++ agent-crewai/Dockerfile | 19 ++ agent-crewai/requirements.txt | 2 + agent-crewai/src/main.py | 87 ++++++++ agent-langgraph-agui/Dockerfile | 19 ++ agent-langgraph-agui/requirements.txt | 9 + agent-langgraph-agui/src/main.py | 62 ++++++ agent-langroid/Dockerfile | 19 ++ agent-langroid/requirements.txt | 5 + agent-langroid/src/main.py | 49 +++++ agent-llamaindex/Dockerfile | 19 ++ agent-llamaindex/requirements.txt | 6 + agent-llamaindex/src/main.py | 42 ++++ agent-pydantic-ai/Dockerfile | 19 ++ agent-pydantic-ai/requirements.txt | 4 + agent-pydantic-ai/src/main.py | 50 +++++ agent-strands/Dockerfile | 19 ++ agent-strands/requirements.txt | 6 + agent-strands/src/main.py | 43 ++++ desktop/src-tauri/src/harness.rs | 281 ++++++++++++++++++++++++++ desktop/src-tauri/src/lib.rs | 1 + 26 files changed, 923 insertions(+) create mode 100644 agent-adk/Dockerfile create mode 100644 agent-adk/requirements.txt create mode 100644 agent-adk/src/main.py create mode 100644 agent-agno/Dockerfile create mode 100644 agent-agno/requirements.txt create mode 100644 agent-agno/src/main.py create mode 100644 agent-crewai/Dockerfile create mode 100644 agent-crewai/requirements.txt create mode 100644 agent-crewai/src/main.py create mode 100644 agent-langgraph-agui/Dockerfile create mode 100644 agent-langgraph-agui/requirements.txt create mode 100644 agent-langgraph-agui/src/main.py create mode 100644 agent-langroid/Dockerfile create mode 100644 agent-langroid/requirements.txt create mode 100644 agent-langroid/src/main.py create mode 100644 agent-llamaindex/Dockerfile create mode 100644 agent-llamaindex/requirements.txt create mode 100644 agent-llamaindex/src/main.py create mode 100644 agent-pydantic-ai/Dockerfile create mode 100644 agent-pydantic-ai/requirements.txt create mode 100644 agent-pydantic-ai/src/main.py create mode 100644 agent-strands/Dockerfile create mode 100644 agent-strands/requirements.txt create mode 100644 agent-strands/src/main.py create mode 100644 desktop/src-tauri/src/harness.rs diff --git a/agent-adk/Dockerfile b/agent-adk/Dockerfile new file mode 100644 index 000000000..635be0137 --- /dev/null +++ b/agent-adk/Dockerfile @@ -0,0 +1,19 @@ +# Google ADK, as a Bot. Python rather than Bun because that is what the integration is published in, +# and the protocol is the only thing a Bot has to share with the others. +FROM python:3.12-slim + +WORKDIR /app + +# Dependencies before source, so editing the Bot does not re-resolve the framework. +COPY agent-adk/requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +COPY agent-adk/src ./src + +ENV PORT=4208 +EXPOSE 4208 +# 0.0.0.0, not `::`. uvicorn binds `::` as IPv6-only, with no v4-mapped addresses, so a container +# started that way refuses 127.0.0.1: the Compose healthcheck never passes and the server cannot +# reach the Bot. Measured, not assumed. Inside a container this is the container's own namespace, +# and Compose is what decides which host addresses it is published on. +CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "4208"] diff --git a/agent-adk/requirements.txt b/agent-adk/requirements.txt new file mode 100644 index 000000000..0841b663d --- /dev/null +++ b/agent-adk/requirements.txt @@ -0,0 +1,6 @@ +ag-ui-adk +google-adk +litellm +fastapi +python-multipart +uvicorn[standard] diff --git a/agent-adk/src/main.py b/agent-adk/src/main.py new file mode 100644 index 000000000..0ed02c2c5 --- /dev/null +++ b/agent-adk/src/main.py @@ -0,0 +1,54 @@ +"""Google ADK as a Bot, through `ag_ui_adk`, which AG-UI maintains. + +ADK is Gemini-first and model-agnostic after that, so the provider stays the person's choice: ADK +reads LiteLLM model strings, and OpenBot writes the one it was told. +""" + +import os + +from ag_ui_adk import ADKAgent, add_adk_fastapi_endpoint +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse +from google.adk.agents import Agent +from google.adk.models.lite_llm import LiteLlm + +TOKEN_HEADER = "x-openbot-agent-token" + + +def _model_id() -> str: + provider = (os.environ.get("BOT_PROVIDER") or "openai").strip() + model = (os.environ.get("BOT_MODEL") or "gpt-4o-mini").strip() + return model if "/" in model else f"{provider}/{model}" + + +app = FastAPI() + + +@app.middleware("http") +async def refuse_without_the_server_token(request: Request, call_next): + if request.url.path != "/health": + expected = (os.environ.get("MANAGED_AGENT_TOKEN") or "").strip() + offered = (request.headers.get(TOKEN_HEADER) or "").strip() + if not expected or offered != expected: + return JSONResponse({"error": "unauthorised"}, status_code=401) + return await call_next(request) + + +@app.get("/health") +async def health(): + return {"ok": True, "harness": "google-adk"} + + +add_adk_fastapi_endpoint( + app, + ADKAgent( + adk_agent=Agent( + name="openbot", + model=LiteLlm(model=_model_id()), + instruction="Answer the question you are asked, briefly and correctly.", + ), + app_name="openbot", + user_id="openbot", + ), + path="/", +) diff --git a/agent-agno/Dockerfile b/agent-agno/Dockerfile new file mode 100644 index 000000000..6a790e9ad --- /dev/null +++ b/agent-agno/Dockerfile @@ -0,0 +1,19 @@ +# Agno, as a Bot. Python rather than Bun because that is what the integration is published in, +# and the protocol is the only thing a Bot has to share with the others. +FROM python:3.12-slim + +WORKDIR /app + +# Dependencies before source, so editing the Bot does not re-resolve the framework. +COPY agent-agno/requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +COPY agent-agno/src ./src + +ENV PORT=4203 +EXPOSE 4203 +# 0.0.0.0, not `::`. uvicorn binds `::` as IPv6-only, with no v4-mapped addresses, so a container +# started that way refuses 127.0.0.1: the Compose healthcheck never passes and the server cannot +# reach the Bot. Measured, not assumed. Inside a container this is the container's own namespace, +# and Compose is what decides which host addresses it is published on. +CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "4203"] diff --git a/agent-agno/requirements.txt b/agent-agno/requirements.txt new file mode 100644 index 000000000..b33b313ba --- /dev/null +++ b/agent-agno/requirements.txt @@ -0,0 +1,5 @@ +agno[agui] +litellm +fastapi +python-multipart +uvicorn[standard] diff --git a/agent-agno/src/main.py b/agent-agno/src/main.py new file mode 100644 index 000000000..c7c23f3c8 --- /dev/null +++ b/agent-agno/src/main.py @@ -0,0 +1,59 @@ +"""Agno as a Bot. + +The AG-UI support is an extra in Agno's own package rather than a separate bridge, so `agno[agui]` +is the whole dependency and `AGUIApp` is the whole integration. Nothing of the protocol is written +here, which is the rule. +""" + +import os + +from agno.agent import Agent +from agno.db.in_memory import InMemoryDb +from agno.models.litellm import LiteLLM +from agno.os import AgentOS +from agno.os.interfaces.agui import AGUI +from fastapi import Request +from fastapi.responses import JSONResponse + +TOKEN_HEADER = "x-openbot-agent-token" + + +def _model_id() -> str: + """`provider/model`, which is how litellm addresses one and how OpenBot stores the choice.""" + provider = (os.environ.get("BOT_PROVIDER") or "openai").strip() + model = (os.environ.get("BOT_MODEL") or "gpt-5.5").strip() + return model if "/" in model else f"{provider}/{model}" + + +agent = Agent( + # In memory, because a Bot's history lives in OpenBot's database and not in the harness. Two + # places remembering the same conversation is how they come to disagree. + db=InMemoryDb(), + model=LiteLLM(id=_model_id()), + # No role, goal or backstory invented on somebody's behalf. A Bot answers the question it is + # asked, and anybody who wants a persona sets one in OpenBot where the rest of them live. + instructions="Answer the question you are asked, briefly and correctly.", +) + +app = AgentOS(agents=[agent], interfaces=[AGUI(agent=agent)]).get_app() + + +@app.middleware("http") +async def refuse_without_the_server_token(request: Request, call_next): + """Everything but `/health` carries the server's token. + + `/health` is exempt because Compose polls it before any token exists, and a healthcheck that + authenticates is a container that never reports healthy. + """ + if request.url.path != "/health": + expected = (os.environ.get("MANAGED_AGENT_TOKEN") or "").strip() + offered = (request.headers.get(TOKEN_HEADER) or "").strip() + # Unset means unconfigured, not open. + if not expected or offered != expected: + return JSONResponse({"error": "unauthorised"}, status_code=401) + return await call_next(request) + + +@app.get("/health") +async def health(): + return {"ok": True, "harness": "agno"} diff --git a/agent-crewai/Dockerfile b/agent-crewai/Dockerfile new file mode 100644 index 000000000..df4a07d3b --- /dev/null +++ b/agent-crewai/Dockerfile @@ -0,0 +1,19 @@ +# CrewAI, as a Bot. Python rather than Bun because that is what the integration is published in, +# and the protocol is the only thing a Bot has to share with the others. +FROM python:3.12-slim + +WORKDIR /app + +# Dependencies before source, so editing the Bot does not re-resolve the framework. +COPY agent-crewai/requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +COPY agent-crewai/src ./src + +ENV PORT=4202 +EXPOSE 4202 +# 0.0.0.0, not `::`. uvicorn binds `::` as IPv6-only, with no v4-mapped addresses, so a container +# started that way refuses 127.0.0.1: the Compose healthcheck never passes and the server cannot +# reach the Bot. Measured, not assumed. Inside a container this is the container's own namespace, +# and Compose is what decides which host addresses it is published on. +CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "4202"] diff --git a/agent-crewai/requirements.txt b/agent-crewai/requirements.txt new file mode 100644 index 000000000..57bf9f8af --- /dev/null +++ b/agent-crewai/requirements.txt @@ -0,0 +1,2 @@ +ag-ui-crewai +uvicorn[standard] diff --git a/agent-crewai/src/main.py b/agent-crewai/src/main.py new file mode 100644 index 000000000..458729269 --- /dev/null +++ b/agent-crewai/src/main.py @@ -0,0 +1,87 @@ +"""CrewAI as a Bot. + +The third harness in the box, and the first built the way every one after it will be: the AG-UI +integration that CrewAI's own ecosystem publishes, mounted on FastAPI, with nothing of the protocol +written here. `agent-bot` and `agent-langgraph` speak AG-UI by hand because they predate the rule +that we do not write adapters. This one imports `ag_ui_crewai` and stops. + +The contract with the rest of OpenBot is the same one the other Bots meet, and it is small: +serve AG-UI on a port, answer `/health`, and refuse anybody who does not carry the server's token. +""" + +import os + +from ag_ui_crewai import add_crewai_flow_fastapi_endpoint +from crewai.flow.flow import Flow, listen, start +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse +from litellm import acompletion + +# The one header OpenBot's server sends when it calls a managed Bot. Same name the TypeScript Bots +# check, because a Bot is a Bot whatever it is written in. +TOKEN_HEADER = "x-openbot-agent-token" + + +def _expected_token() -> str: + return (os.environ.get("MANAGED_AGENT_TOKEN") or "").strip() + + +def _model() -> str: + """The provider and model OpenBot chose, in the form litellm wants. + + `BOT_PROVIDER` and `BOT_MODEL` are set by the shell from the model screen. litellm addresses a + model as `provider/model`, and it reads that provider's key from the environment itself, which + is why nothing here touches a key. + """ + provider = (os.environ.get("BOT_PROVIDER") or "openai").strip() + model = (os.environ.get("BOT_MODEL") or "gpt-5.5").strip() + return model if "/" in model else f"{provider}/{model}" + + +class OpenBotFlow(Flow): + """A crew of one, which is the right size for a Bot answering a person. + + CrewAI's own examples build multi-agent crews, and a person who wants that edits this. What + ships has to answer the first question somebody asks it without a role, a goal and a backstory + being invented on their behalf. + """ + + @start() + async def answer(self): + messages = self.state.get("messages", []) + response = await acompletion( + model=_model(), + messages=messages, + stream=False, + ) + self.state.setdefault("messages", []).append( + response.choices[0].message.model_dump() + ) + + +app = FastAPI() + + +@app.middleware("http") +async def refuse_without_the_server_token(request: Request, call_next): + """Everything but `/health` carries the server's token. + + `/health` is exempt because Compose polls it before anything has a token to send, and a + healthcheck that authenticates is a container that never reports healthy. + """ + if request.url.path != "/health": + expected = _expected_token() + offered = (request.headers.get(TOKEN_HEADER) or "").strip() + # An unset token means unconfigured, not open. A Bot that answers anybody because nobody + # set a secret is the failure this check exists for. + if not expected or offered != expected: + return JSONResponse({"error": "unauthorised"}, status_code=401) + return await call_next(request) + + +@app.get("/health") +async def health(): + return {"ok": True, "harness": "crewai"} + + +add_crewai_flow_fastapi_endpoint(app, OpenBotFlow(), "/") diff --git a/agent-langgraph-agui/Dockerfile b/agent-langgraph-agui/Dockerfile new file mode 100644 index 000000000..655796cd1 --- /dev/null +++ b/agent-langgraph-agui/Dockerfile @@ -0,0 +1,19 @@ +# LangGraph, as a Bot. Python rather than Bun because that is what the integration is published in, +# and the protocol is the only thing a Bot has to share with the others. +FROM python:3.12-slim + +WORKDIR /app + +# Dependencies before source, so editing the Bot does not re-resolve the framework. +COPY agent-langgraph-agui/requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +COPY agent-langgraph-agui/src ./src + +ENV PORT=4206 +EXPOSE 4206 +# 0.0.0.0, not `::`. uvicorn binds `::` as IPv6-only, with no v4-mapped addresses, so a container +# started that way refuses 127.0.0.1: the Compose healthcheck never passes and the server cannot +# reach the Bot. Measured, not assumed. Inside a container this is the container's own namespace, +# and Compose is what decides which host addresses it is published on. +CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "4206"] diff --git a/agent-langgraph-agui/requirements.txt b/agent-langgraph-agui/requirements.txt new file mode 100644 index 000000000..4c8aae1cf --- /dev/null +++ b/agent-langgraph-agui/requirements.txt @@ -0,0 +1,9 @@ +ag-ui-langgraph +langgraph +langchain +langchain-openai +langchain-anthropic +langchain-google-genai +fastapi +python-multipart +uvicorn[standard] diff --git a/agent-langgraph-agui/src/main.py b/agent-langgraph-agui/src/main.py new file mode 100644 index 000000000..c977eda0a --- /dev/null +++ b/agent-langgraph-agui/src/main.py @@ -0,0 +1,62 @@ +"""LangGraph as a Bot, through the AG-UI integration rather than by hand. + +OpenBot already ships `agent-langgraph`, which speaks AG-UI itself because it predates the rule +against writing adapters. This is the same framework served through `ag-ui-langgraph`, which is the +package the AG-UI project maintains, so the protocol stops being ours to keep working. +""" + +import os + +from ag_ui_langgraph import LangGraphAgent, add_langgraph_fastapi_endpoint +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse +from langchain.chat_models import init_chat_model +from langgraph.checkpoint.memory import MemorySaver +from langgraph.graph import END, START, MessagesState, StateGraph + +TOKEN_HEADER = "x-openbot-agent-token" + + +def _model(): + """`provider:model`, which is what `init_chat_model` reads, so the provider stays a choice.""" + provider = (os.environ.get("BOT_PROVIDER") or "openai").strip() + model = (os.environ.get("BOT_MODEL") or "gpt-4o-mini").strip() + return init_chat_model(model if ":" in model else f"{provider}:{model}") + + +async def answer(state: MessagesState): + return {"messages": [await _model().ainvoke(state["messages"])]} + + +builder = StateGraph(MessagesState) +builder.add_node("answer", answer) +builder.add_edge(START, "answer") +builder.add_edge("answer", END) +# A checkpointer, because the AG-UI integration resumes a thread by id and LangGraph refuses to +# without one. In memory rather than in Postgres: OpenBot's database is where a conversation lives, +# and two stores remembering the same thread is how they come to disagree. +graph = builder.compile(checkpointer=MemorySaver()) + +app = FastAPI() + + +@app.middleware("http") +async def refuse_without_the_server_token(request: Request, call_next): + if request.url.path != "/health": + expected = (os.environ.get("MANAGED_AGENT_TOKEN") or "").strip() + offered = (request.headers.get(TOKEN_HEADER) or "").strip() + if not expected or offered != expected: + return JSONResponse({"error": "unauthorised"}, status_code=401) + return await call_next(request) + + +@app.get("/health") +async def health(): + return {"ok": True, "harness": "langgraph"} + + +add_langgraph_fastapi_endpoint( + app=app, + agent=LangGraphAgent(name="openbot", graph=graph), + path="/", +) diff --git a/agent-langroid/Dockerfile b/agent-langroid/Dockerfile new file mode 100644 index 000000000..bfa27a1bb --- /dev/null +++ b/agent-langroid/Dockerfile @@ -0,0 +1,19 @@ +# Langroid, as a Bot. Python rather than Bun because that is what the integration is published in, +# and the protocol is the only thing a Bot has to share with the others. +FROM python:3.12-slim + +WORKDIR /app + +# Dependencies before source, so editing the Bot does not re-resolve the framework. +COPY agent-langroid/requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +COPY agent-langroid/src ./src + +ENV PORT=4209 +EXPOSE 4209 +# 0.0.0.0, not `::`. uvicorn binds `::` as IPv6-only, with no v4-mapped addresses, so a container +# started that way refuses 127.0.0.1: the Compose healthcheck never passes and the server cannot +# reach the Bot. Measured, not assumed. Inside a container this is the container's own namespace, +# and Compose is what decides which host addresses it is published on. +CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "4209"] diff --git a/agent-langroid/requirements.txt b/agent-langroid/requirements.txt new file mode 100644 index 000000000..06d9397df --- /dev/null +++ b/agent-langroid/requirements.txt @@ -0,0 +1,5 @@ +ag-ui-langroid +langroid +fastapi +python-multipart +uvicorn[standard] diff --git a/agent-langroid/src/main.py b/agent-langroid/src/main.py new file mode 100644 index 000000000..c713932b2 --- /dev/null +++ b/agent-langroid/src/main.py @@ -0,0 +1,49 @@ +"""Langroid as a Bot, through `ag-ui-langroid`, which AG-UI maintains.""" + +import os + +from ag_ui_langroid import LangroidAgent, create_langroid_app +from fastapi import Request +from fastapi.responses import JSONResponse +from langroid import ChatAgent, ChatAgentConfig +from langroid.language_models import OpenAIGPTConfig + +TOKEN_HEADER = "x-openbot-agent-token" + + +def _model_id() -> str: + """Langroid names an OpenAI model bare and everything else through litellm. + + `openai/gpt-4o-mini` is rejected by its OpenAI client as an invalid model id, so the prefix goes + on only when the provider is somebody else. + """ + provider = (os.environ.get("BOT_PROVIDER") or "openai").strip() + model = (os.environ.get("BOT_MODEL") or "gpt-4o-mini").strip() + if "/" in model or provider == "openai": + return model + return f"litellm/{provider}/{model}" + + +agent = ChatAgent( + ChatAgentConfig( + llm=OpenAIGPTConfig(chat_model=_model_id()), + system_message="Answer the question you are asked, briefly and correctly.", + ) +) + +app = create_langroid_app(LangroidAgent(name="openbot", agent=agent)) + + +@app.middleware("http") +async def refuse_without_the_server_token(request: Request, call_next): + if request.url.path != "/health": + expected = (os.environ.get("MANAGED_AGENT_TOKEN") or "").strip() + offered = (request.headers.get(TOKEN_HEADER) or "").strip() + if not expected or offered != expected: + return JSONResponse({"error": "unauthorised"}, status_code=401) + return await call_next(request) + + +@app.get("/health") +async def health(): + return {"ok": True, "harness": "langroid"} diff --git a/agent-llamaindex/Dockerfile b/agent-llamaindex/Dockerfile new file mode 100644 index 000000000..84c2212f0 --- /dev/null +++ b/agent-llamaindex/Dockerfile @@ -0,0 +1,19 @@ +# LlamaIndex, as a Bot. Python rather than Bun because that is what the integration is published in, +# and the protocol is the only thing a Bot has to share with the others. +FROM python:3.12-slim + +WORKDIR /app + +# Dependencies before source, so editing the Bot does not re-resolve the framework. +COPY agent-llamaindex/requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +COPY agent-llamaindex/src ./src + +ENV PORT=4204 +EXPOSE 4204 +# 0.0.0.0, not `::`. uvicorn binds `::` as IPv6-only, with no v4-mapped addresses, so a container +# started that way refuses 127.0.0.1: the Compose healthcheck never passes and the server cannot +# reach the Bot. Measured, not assumed. Inside a container this is the container's own namespace, +# and Compose is what decides which host addresses it is published on. +CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "4204"] diff --git a/agent-llamaindex/requirements.txt b/agent-llamaindex/requirements.txt new file mode 100644 index 000000000..aeda0194d --- /dev/null +++ b/agent-llamaindex/requirements.txt @@ -0,0 +1,6 @@ +llama-index-core +llama-index-protocols-ag-ui +llama-index-llms-openai +fastapi +python-multipart +uvicorn[standard] diff --git a/agent-llamaindex/src/main.py b/agent-llamaindex/src/main.py new file mode 100644 index 000000000..ed9c0015d --- /dev/null +++ b/agent-llamaindex/src/main.py @@ -0,0 +1,42 @@ +"""LlamaIndex as a Bot. + +The AG-UI support is LlamaIndex's own package, `llama-index-protocols-ag-ui`, and it hands back a +FastAPI router. Mount it and stop. +""" + +import os + +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse +from llama_index.llms.openai import OpenAI +from llama_index.protocols.ag_ui.router import get_ag_ui_workflow_router + +TOKEN_HEADER = "x-openbot-agent-token" + + +def _model_id() -> str: + provider = (os.environ.get("BOT_PROVIDER") or "openai").strip() + model = (os.environ.get("BOT_MODEL") or "gpt-5.5").strip() + return model if "/" in model else f"{provider}/{model}" + + +app = FastAPI() + + +@app.middleware("http") +async def refuse_without_the_server_token(request: Request, call_next): + """Everything but `/health`, which Compose polls before any token exists.""" + if request.url.path != "/health": + expected = (os.environ.get("MANAGED_AGENT_TOKEN") or "").strip() + offered = (request.headers.get(TOKEN_HEADER) or "").strip() + if not expected or offered != expected: + return JSONResponse({"error": "unauthorised"}, status_code=401) + return await call_next(request) + + +@app.get("/health") +async def health(): + return {"ok": True, "harness": "llamaindex"} + + +app.include_router(get_ag_ui_workflow_router(llm=OpenAI(model=(os.environ.get("BOT_MODEL") or "gpt-4o-mini")))) diff --git a/agent-pydantic-ai/Dockerfile b/agent-pydantic-ai/Dockerfile new file mode 100644 index 000000000..6894de471 --- /dev/null +++ b/agent-pydantic-ai/Dockerfile @@ -0,0 +1,19 @@ +# Pydantic AI, as a Bot. Python rather than Bun because that is what the integration is published in, +# and the protocol is the only thing a Bot has to share with the others. +FROM python:3.12-slim + +WORKDIR /app + +# Dependencies before source, so editing the Bot does not re-resolve the framework. +COPY agent-pydantic-ai/requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +COPY agent-pydantic-ai/src ./src + +ENV PORT=4205 +EXPOSE 4205 +# 0.0.0.0, not `::`. uvicorn binds `::` as IPv6-only, with no v4-mapped addresses, so a container +# started that way refuses 127.0.0.1: the Compose healthcheck never passes and the server cannot +# reach the Bot. Measured, not assumed. Inside a container this is the container's own namespace, +# and Compose is what decides which host addresses it is published on. +CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "4205"] diff --git a/agent-pydantic-ai/requirements.txt b/agent-pydantic-ai/requirements.txt new file mode 100644 index 000000000..1ef30a642 --- /dev/null +++ b/agent-pydantic-ai/requirements.txt @@ -0,0 +1,4 @@ +pydantic-ai-slim[openai,anthropic,google,ag-ui] +fastapi +python-multipart +uvicorn[standard] diff --git a/agent-pydantic-ai/src/main.py b/agent-pydantic-ai/src/main.py new file mode 100644 index 000000000..c65f83b17 --- /dev/null +++ b/agent-pydantic-ai/src/main.py @@ -0,0 +1,50 @@ +"""Pydantic AI as a Bot. + +AG-UI is built into Pydantic AI itself, as the `ag-ui` extra on `pydantic-ai-slim`, so the agent +carries its own ASGI app and there is nothing to bridge. +""" + +import os + +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse +from pydantic_ai import Agent +from pydantic_ai.ui.ag_ui import AGUIAdapter + +TOKEN_HEADER = "x-openbot-agent-token" + + +def _model_id() -> str: + """`provider:model`, which is the form Pydantic AI names a model in.""" + provider = (os.environ.get("BOT_PROVIDER") or "openai").strip() + model = (os.environ.get("BOT_MODEL") or "gpt-4o-mini").strip() + return model if ":" in model else f"{provider}:{model}" + + +agent = Agent(_model_id()) +app = FastAPI() + + +@app.middleware("http") +async def refuse_without_the_server_token(request: Request, call_next): + if request.url.path != "/health": + expected = (os.environ.get("MANAGED_AGENT_TOKEN") or "").strip() + offered = (request.headers.get(TOKEN_HEADER) or "").strip() + if not expected or offered != expected: + return JSONResponse({"error": "unauthorised"}, status_code=401) + return await call_next(request) + + +@app.get("/health") +async def health(): + return {"ok": True, "harness": "pydantic-ai"} + + +@app.post("/") +async def run(request: Request): + """One route, because a Bot is one endpoint. + + Pydantic AI hands back the whole streaming response, so this route holds no protocol logic of + its own: it passes the request and the agent and returns what comes back. + """ + return await AGUIAdapter.dispatch_request(request, agent=agent) diff --git a/agent-strands/Dockerfile b/agent-strands/Dockerfile new file mode 100644 index 000000000..b0c106685 --- /dev/null +++ b/agent-strands/Dockerfile @@ -0,0 +1,19 @@ +# AWS Strands, as a Bot. Python rather than Bun because that is what the integration is published in, +# and the protocol is the only thing a Bot has to share with the others. +FROM python:3.12-slim + +WORKDIR /app + +# Dependencies before source, so editing the Bot does not re-resolve the framework. +COPY agent-strands/requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +COPY agent-strands/src ./src + +ENV PORT=4207 +EXPOSE 4207 +# 0.0.0.0, not `::`. uvicorn binds `::` as IPv6-only, with no v4-mapped addresses, so a container +# started that way refuses 127.0.0.1: the Compose healthcheck never passes and the server cannot +# reach the Bot. Measured, not assumed. Inside a container this is the container's own namespace, +# and Compose is what decides which host addresses it is published on. +CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "4207"] diff --git a/agent-strands/requirements.txt b/agent-strands/requirements.txt new file mode 100644 index 000000000..91c4a449c --- /dev/null +++ b/agent-strands/requirements.txt @@ -0,0 +1,6 @@ +ag-ui-strands +strands-agents +litellm +fastapi +python-multipart +uvicorn[standard] diff --git a/agent-strands/src/main.py b/agent-strands/src/main.py new file mode 100644 index 000000000..bafa22eda --- /dev/null +++ b/agent-strands/src/main.py @@ -0,0 +1,43 @@ +"""AWS Strands as a Bot, through `ag_ui_strands`, which AG-UI maintains.""" + +import os + +from ag_ui_strands import StrandsAgent, add_strands_fastapi_endpoint +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse +from strands import Agent +from strands.models.litellm import LiteLLMModel + + +def _model_id() -> str: + provider = (os.environ.get("BOT_PROVIDER") or "openai").strip() + model = (os.environ.get("BOT_MODEL") or "gpt-4o-mini").strip() + return model if "/" in model else f"{provider}/{model}" + + +app = FastAPI() + +TOKEN_HEADER = "x-openbot-agent-token" + + +@app.middleware("http") +async def refuse_without_the_server_token(request: Request, call_next): + """Everything but `/health`, which Compose polls before any token exists.""" + if request.url.path != "/health": + expected = (os.environ.get("MANAGED_AGENT_TOKEN") or "").strip() + offered = (request.headers.get(TOKEN_HEADER) or "").strip() + if not expected or offered != expected: + return JSONResponse({"error": "unauthorised"}, status_code=401) + return await call_next(request) + + +@app.get("/health") +async def health(): + return {"ok": True, "harness": "strands"} + + +add_strands_fastapi_endpoint( + app, + StrandsAgent(name="openbot", agent=Agent(model=LiteLLMModel(model_id=_model_id()))), + "/", +) diff --git a/desktop/src-tauri/src/harness.rs b/desktop/src-tauri/src/harness.rs new file mode 100644 index 000000000..c2e9f1f07 --- /dev/null +++ b/desktop/src-tauri/src/harness.rs @@ -0,0 +1,281 @@ +//! The harness picker's list, as data. +//! +//! One list, and every row resolves to the same thing: an AG-UI URL registered as a Bot. A row is +//! a manifest rather than a branch in wizard code, so adding a harness is an entry here plus an +//! image, and never a new screen. +//! +//! Two things this list deliberately does not contain. OpenBot's own `built-in` agent type, which +//! is a system prompt and not a harness: everybody leaves setup with a real one, either an image we +//! publish or an address they already run. And anything whose AG-UI integration we would have to +//! write ourselves. A harness earns a row only when the integration exists and somebody other than +//! us keeps it working, which is why Codex and Gemini CLI are absent despite being the two most +//! popular harnesses there are. +//! +//! Rows and maintainer classes come from the AG-UI repository's own support table, which is +//! canonical. `docs.ag-ui.com` disagrees on several and is wrong. + +use serde::{Deserialize, Serialize}; + +/// Who keeps the AG-UI integration working. +/// +/// Recorded because it is what the no-adapters rule is decided on, not because it ranks anything. +/// "Community" does not mean strangers: `integrations/claude-agent-sdk` lives in the AG-UI +/// repository and its history is largely CopilotKit's own people. It means the model vendor does +/// not maintain it. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum Maintainer { + FirstParty, + Partnership, + Community, +} + +/// What a harness needs before it can answer. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum Credential { + /// Any provider the model screen offers. The choice is the person's and this constrains it not + /// at all. + AnyProvider, + /// Anthropic, and therefore the one row where a subscription can stand in for a key. + /// + /// Not because the SDK cannot reach another model: `ANTHROPIC_BASE_URL` aimed at a gateway that + /// speaks the Anthropic Messages API runs GPT or Gemini through it perfectly well. It is that a + /// *subscription* only ever buys its own vendor's models, and this is the row where the + /// subscription path exists. + Anthropic, + /// The person's own endpoint. Nothing is installed and no key is ours to ask for. + TheirEndpoint, +} + +/// One row. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Harness { + pub id: String, + pub name: String, + pub summary: String, + /// The image that speaks AG-UI, pinned by the release like every other image. + /// + /// `None` only for the row where the person supplies the address. + pub image: Option, + /// Where the container says it is ready. + pub health_path: Option, + pub credential: Credential, + pub maintainer: Maintainer, +} + +/// The list, ranked as the build doc ranks it: stars first, with downloads as the sanity check, +/// because each misleads alone. +/// +/// Anything the AG-UI table marks In Progress is left out. OpenAI's Agents SDK, AWS Bedrock Agents +/// and Cloudflare Agents are all In Progress, and a picker that offers a harness which cannot yet +/// answer is worse than a shorter picker. +pub fn catalogue() -> Vec { + let ours = |id: &str, name: &str, summary: &str, maintainer: Maintainer| Harness { + id: id.into(), + name: name.into(), + summary: summary.into(), + image: Some(format!("openbot-harness-{id}")), + health_path: Some("/health".into()), + credential: Credential::AnyProvider, + maintainer, + }; + + vec![ + ours( + "crewai", + "CrewAI", + "Crews of agents with roles and tasks.", + Maintainer::Partnership, + ), + ours( + "llamaindex", + "LlamaIndex", + "Agents built around your own documents.", + Maintainer::FirstParty, + ), + ours( + "agno", + "Agno", + "Fast, small, and multi-modal.", + Maintainer::FirstParty, + ), + ours( + "langgraph", + "LangGraph", + "Graphs you can change, from LangChain.", + Maintainer::Partnership, + ), + ours( + "mastra", + "Mastra", + "TypeScript end to end.", + Maintainer::FirstParty, + ), + ours( + "google-adk", + "Google ADK", + "Google's agent kit. Gemini first, any model after.", + Maintainer::FirstParty, + ), + ours( + "pydantic-ai", + "Pydantic AI", + "Typed agents, validated in and out.", + Maintainer::FirstParty, + ), + ours( + "microsoft-agent-framework", + "Microsoft Agent Framework", + "Microsoft's, model-agnostic by design.", + Maintainer::FirstParty, + ), + Harness { + id: "claude-agent-sdk".into(), + name: "Claude Agent SDK".into(), + summary: "Anthropic's own. The one that takes a Claude plan instead of a key.".into(), + image: Some("openbot-harness-claude-agent-sdk".into()), + health_path: Some("/health".into()), + credential: Credential::Anthropic, + maintainer: Maintainer::Community, + }, + ours( + "strands", + "AWS Strands", + "Amazon's. Bedrock first, any model after.", + Maintainer::FirstParty, + ), + ours( + "ag2", + "AG2", + "The AutoGen line, continued.", + Maintainer::FirstParty, + ), + ours( + "langroid", + "Langroid", + "Multi-agent, deliberately small.", + Maintainer::Community, + ), + Harness { + id: "byo-url".into(), + name: "An agent you already run".into(), + summary: "Give its address. It is proved with a real AG-UI run before it is saved." + .into(), + image: None, + health_path: None, + credential: Credential::TheirEndpoint, + maintainer: Maintainer::Community, + }, + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + /// OpenBot's own `built-in` agent type is a system prompt, not a harness, and the doc is + /// explicit that it is not offered. Everybody leaves setup with a real one. + #[test] + fn the_built_in_agent_type_is_not_offered() { + for harness in catalogue() { + assert_ne!(harness.id, "built-in", "the built-in agent type is offered"); + assert_ne!( + harness.id, "agent-bot", + "the built-in agent type is offered" + ); + } + } + + /// Every row either ships an image or is the row where the person brings the address. A row + /// that is neither cannot be started and should not be on screen. + #[test] + fn every_row_is_either_an_image_we_publish_or_an_address_they_give() { + for harness in catalogue() { + match harness.credential { + Credential::TheirEndpoint => { + assert!( + harness.image.is_none(), + "{} installs and should not", + harness.id + ); + assert!( + harness.health_path.is_none(), + "{} has no container to poll", + harness.id + ); + } + _ => { + assert!(harness.image.is_some(), "{} offers no image", harness.id); + assert!( + harness.health_path.is_some(), + "{} has no readiness path", + harness.id + ); + } + } + } + } + + /// Anything the AG-UI table marks In Progress stays off. These three were In Progress when the + /// list was read, and offering one would mean a row that cannot answer. + #[test] + fn nothing_still_in_progress_upstream_is_offered() { + let ids: Vec = catalogue().into_iter().map(|h| h.id).collect(); + for absent in ["openai-agents-sdk", "bedrock-agents", "cloudflare-agents"] { + assert!( + !ids.contains(&absent.to_string()), + "{absent} is In Progress upstream" + ); + } + } + + /// Codex and Gemini CLI have no integration and we do not write adapters, so they cannot appear + /// however popular they are. + #[test] + fn harnesses_with_no_integration_are_absent() { + let ids: Vec = catalogue().into_iter().map(|h| h.id).collect(); + for absent in ["codex", "gemini-cli"] { + assert!( + !ids.contains(&absent.to_string()), + "{absent} has no AG-UI integration" + ); + } + } + + /// Exactly one row can take a subscription instead of a key, and the screen branches on it. + /// Two would mean the branch is wrong; none would mean the Claude row was dropped. + #[test] + fn one_row_takes_a_plan_rather_than_a_key() { + let anthropic: Vec = catalogue() + .into_iter() + .filter(|h| h.credential == Credential::Anthropic) + .map(|h| h.id) + .collect(); + assert_eq!(anthropic, vec!["claude-agent-sdk".to_string()]); + } + + /// Ranked, and the order is load-bearing: it is what somebody reads top-down. CrewAI leads on + /// stars and the paste-a-URL row is last because it is the one that installs nothing. + #[test] + fn the_list_is_ranked_and_ends_with_the_address_row() { + let ids: Vec = catalogue().into_iter().map(|h| h.id).collect(); + assert_eq!(ids.first().map(String::as_str), Some("crewai")); + assert_eq!(ids.last().map(String::as_str), Some("byo-url")); + } + + /// Ids become image names and Compose service names, so they have to stay boring. + #[test] + fn ids_are_safe_to_use_as_image_and_service_names() { + for harness in catalogue() { + assert!( + harness + .id + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'), + "{} is not a usable image name", + harness.id + ); + } + } +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 169f6674c..cf1071fc5 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -3,6 +3,7 @@ pub mod acquire; pub mod deployment; pub mod engine; pub mod env; +pub mod harness; pub mod quiet; pub mod stack; pub mod supervise; From 519522d417cac9690877479236f115aa653dd736 Mon Sep 17 00:00:00 2001 From: David McKay Date: Sun, 6 Sep 2026 16:33:40 -0700 Subject: [PATCH 02/46] Three more harnesses, and Mastra taken off the list until it can be served AG2, Microsoft Agent Framework and Claude Agent SDK, on the same terms as the other eight: an image, the integration its own maintainers publish, and nothing of the protocol written here. AG2 and Microsoft each answered a real AG-UI run before being committed. AG2 mounts through `build_asgi` rather than the helper its README names, and Microsoft's client takes the model positionally and builds an agent with `as_agent`, not `create_agent`. Claude Agent SDK is the row where a plan stands in for a key, so it carries the precedence trap the build doc warned about: Anthropic resolves `ANTHROPIC_API_KEY` ahead of `CLAUDE_CODE_OAUTH_TOKEN`, and a container given both bills the key while the subscription somebody deliberately chose goes unused. It refuses to start with both set, and refuses to start with neither. That guard is tested; the harness answering is not, because it needs a subscription token or an Anthropic key and this machine has neither. Mastra comes off the list. Not a judgement on Mastra: there is no way to serve it over AG-UI today without breaking the rule this list exists for. `@mastra/agui` is Mastra's own server-side helper and peer-depends on `@mastra/core >=0.10.7 <0.12.0`, last published July 2025, against a core now at 1.64. `@ag-ui/mastra` from the AG-UI repository is a client abstraction with no HTTP handler on it. The two remaining options were a year-old core or an HTTP layer of ours. A test pins its absence so that putting it back is deliberate. --- agent-ag2/Dockerfile | 19 +++++++++ agent-ag2/requirements.txt | 4 ++ agent-ag2/src/main.py | 39 +++++++++++++++++++ agent-claude-sdk/Dockerfile | 19 +++++++++ agent-claude-sdk/requirements.txt | 4 ++ agent-claude-sdk/src/main.py | 64 +++++++++++++++++++++++++++++++ agent-microsoft/Dockerfile | 19 +++++++++ agent-microsoft/requirements.txt | 5 +++ agent-microsoft/src/main.py | 34 ++++++++++++++++ desktop/src-tauri/src/harness.rs | 22 ++++++++--- 10 files changed, 223 insertions(+), 6 deletions(-) create mode 100644 agent-ag2/Dockerfile create mode 100644 agent-ag2/requirements.txt create mode 100644 agent-ag2/src/main.py create mode 100644 agent-claude-sdk/Dockerfile create mode 100644 agent-claude-sdk/requirements.txt create mode 100644 agent-claude-sdk/src/main.py create mode 100644 agent-microsoft/Dockerfile create mode 100644 agent-microsoft/requirements.txt create mode 100644 agent-microsoft/src/main.py diff --git a/agent-ag2/Dockerfile b/agent-ag2/Dockerfile new file mode 100644 index 000000000..de95c3aac --- /dev/null +++ b/agent-ag2/Dockerfile @@ -0,0 +1,19 @@ +# AG2, as a Bot. Python rather than Bun because that is what the integration is published in, +# and the protocol is the only thing a Bot has to share with the others. +FROM python:3.12-slim + +WORKDIR /app + +# Dependencies before source, so editing the Bot does not re-resolve the framework. +COPY agent-ag2/requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +COPY agent-ag2/src ./src + +ENV PORT=4210 +EXPOSE 4210 +# 0.0.0.0, not `::`. uvicorn binds `::` as IPv6-only, with no v4-mapped addresses, so a container +# started that way refuses 127.0.0.1: the Compose healthcheck never passes and the server cannot +# reach the Bot. Measured, not assumed. Inside a container this is the container's own namespace, +# and Compose is what decides which host addresses it is published on. +CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "4210"] diff --git a/agent-ag2/requirements.txt b/agent-ag2/requirements.txt new file mode 100644 index 000000000..f4a06cb11 --- /dev/null +++ b/agent-ag2/requirements.txt @@ -0,0 +1,4 @@ +ag2[ag-ui,openai] +fastapi +python-multipart +uvicorn[standard] diff --git a/agent-ag2/src/main.py b/agent-ag2/src/main.py new file mode 100644 index 000000000..916739fcb --- /dev/null +++ b/agent-ag2/src/main.py @@ -0,0 +1,39 @@ +"""AG2 as a Bot. AG-UI is an extra in AG2's own package, so `ag2[ag-ui]` is the dependency.""" + +import os + +from ag2 import Agent +from ag2.ag_ui import AGUIStream +from ag2.config import OpenAIConfig +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse + +TOKEN_HEADER = "x-openbot-agent-token" + +agent = Agent( + name="openbot", + prompt="Answer the question you are asked, briefly and correctly.", + config=OpenAIConfig(model=(os.environ.get("BOT_MODEL") or "gpt-4o-mini").strip()), +) +stream = AGUIStream(agent) + +app = FastAPI() + + +@app.middleware("http") +async def refuse_without_the_server_token(request: Request, call_next): + if request.url.path != "/health": + expected = (os.environ.get("MANAGED_AGENT_TOKEN") or "").strip() + offered = (request.headers.get(TOKEN_HEADER) or "").strip() + if not expected or offered != expected: + return JSONResponse({"error": "unauthorised"}, status_code=401) + return await call_next(request) + + +@app.get("/health") +async def health(): + return {"ok": True, "harness": "ag2"} + + +# `build_asgi` rather than a helper: AG2 hands back a plain ASGI app to mount. +app.mount("/", stream.build_asgi()) diff --git a/agent-claude-sdk/Dockerfile b/agent-claude-sdk/Dockerfile new file mode 100644 index 000000000..6ace4d928 --- /dev/null +++ b/agent-claude-sdk/Dockerfile @@ -0,0 +1,19 @@ +# Claude Agent SDK, as a Bot. Python rather than Bun because that is what the integration is published in, +# and the protocol is the only thing a Bot has to share with the others. +FROM python:3.12-slim + +WORKDIR /app + +# Dependencies before source, so editing the Bot does not re-resolve the framework. +COPY agent-claude-sdk/requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +COPY agent-claude-sdk/src ./src + +ENV PORT=4212 +EXPOSE 4212 +# 0.0.0.0, not `::`. uvicorn binds `::` as IPv6-only, with no v4-mapped addresses, so a container +# started that way refuses 127.0.0.1: the Compose healthcheck never passes and the server cannot +# reach the Bot. Measured, not assumed. Inside a container this is the container's own namespace, +# and Compose is what decides which host addresses it is published on. +CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "4212"] diff --git a/agent-claude-sdk/requirements.txt b/agent-claude-sdk/requirements.txt new file mode 100644 index 000000000..7589503e7 --- /dev/null +++ b/agent-claude-sdk/requirements.txt @@ -0,0 +1,4 @@ +ag-ui-claude-sdk +fastapi +python-multipart +uvicorn[standard] diff --git a/agent-claude-sdk/src/main.py b/agent-claude-sdk/src/main.py new file mode 100644 index 000000000..dfa491641 --- /dev/null +++ b/agent-claude-sdk/src/main.py @@ -0,0 +1,64 @@ +"""Claude Agent SDK as a Bot, through `ag-ui-claude-sdk`. + +This is the row where a plan can stand in for a key. `claude setup-token` mints a +`CLAUDE_CODE_OAUTH_TOKEN` against a Pro or Max subscription and the SDK accepts it, which is why +this harness is the one the model screen offers a subscription on. + +The precedence trap is the thing to get right: `ANTHROPIC_API_KEY` wins over the OAuth token, so a +deployment that sets both silently bills the key and the plan goes unused. OpenBot sets one. +""" + +import os + +from ag_ui_claude_sdk import ClaudeAgentAdapter, add_claude_fastapi_endpoint +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse + +TOKEN_HEADER = "x-openbot-agent-token" + + +def _refuse_both_credentials() -> None: + """One credential or the other, never both. + + Anthropic resolves `ANTHROPIC_API_KEY` ahead of `CLAUDE_CODE_OAUTH_TOKEN`, so a container given + both uses the key and quietly ignores the subscription somebody chose. Failing here is the only + way that becomes visible: the alternative is a correct-looking Bot on the wrong credential. + """ + key = (os.environ.get("ANTHROPIC_API_KEY") or "").strip() + plan = (os.environ.get("CLAUDE_CODE_OAUTH_TOKEN") or "").strip() + if key and plan: + raise SystemExit( + "Both ANTHROPIC_API_KEY and CLAUDE_CODE_OAUTH_TOKEN are set. Anthropic prefers the key, " + "so the subscription would be ignored. Set one." + ) + if not key and not plan: + raise SystemExit( + "Set ANTHROPIC_API_KEY, or CLAUDE_CODE_OAUTH_TOKEN from `claude setup-token`." + ) + + +_refuse_both_credentials() + +app = FastAPI() + + +@app.middleware("http") +async def refuse_without_the_server_token(request: Request, call_next): + if request.url.path != "/health": + expected = (os.environ.get("MANAGED_AGENT_TOKEN") or "").strip() + offered = (request.headers.get(TOKEN_HEADER) or "").strip() + if not expected or offered != expected: + return JSONResponse({"error": "unauthorised"}, status_code=401) + return await call_next(request) + + +@app.get("/health") +async def health(): + return {"ok": True, "harness": "claude-agent-sdk"} + + +add_claude_fastapi_endpoint( + app=app, + adapter=ClaudeAgentAdapter(), + path="/", +) diff --git a/agent-microsoft/Dockerfile b/agent-microsoft/Dockerfile new file mode 100644 index 000000000..2c4d71c36 --- /dev/null +++ b/agent-microsoft/Dockerfile @@ -0,0 +1,19 @@ +# Microsoft Agent Framework, as a Bot. Python rather than Bun because that is what the integration is published in, +# and the protocol is the only thing a Bot has to share with the others. +FROM python:3.12-slim + +WORKDIR /app + +# Dependencies before source, so editing the Bot does not re-resolve the framework. +COPY agent-microsoft/requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +COPY agent-microsoft/src ./src + +ENV PORT=4211 +EXPOSE 4211 +# 0.0.0.0, not `::`. uvicorn binds `::` as IPv6-only, with no v4-mapped addresses, so a container +# started that way refuses 127.0.0.1: the Compose healthcheck never passes and the server cannot +# reach the Bot. Measured, not assumed. Inside a container this is the container's own namespace, +# and Compose is what decides which host addresses it is published on. +CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "4211"] diff --git a/agent-microsoft/requirements.txt b/agent-microsoft/requirements.txt new file mode 100644 index 000000000..d96241559 --- /dev/null +++ b/agent-microsoft/requirements.txt @@ -0,0 +1,5 @@ +agent-framework-ag-ui +agent-framework-openai +fastapi +python-multipart +uvicorn[standard] diff --git a/agent-microsoft/src/main.py b/agent-microsoft/src/main.py new file mode 100644 index 000000000..471bda940 --- /dev/null +++ b/agent-microsoft/src/main.py @@ -0,0 +1,34 @@ +"""Microsoft Agent Framework as a Bot, through `agent-framework-ag-ui`, which Microsoft publishes.""" + +import os + +from agent_framework.openai import OpenAIChatClient +from agent_framework_ag_ui import add_agent_framework_fastapi_endpoint +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse + +TOKEN_HEADER = "x-openbot-agent-token" + +agent = OpenAIChatClient( + (os.environ.get("BOT_MODEL") or "gpt-4o-mini").strip() +).as_agent(instructions="Answer the question you are asked, briefly and correctly.") + +app = FastAPI() + + +@app.middleware("http") +async def refuse_without_the_server_token(request: Request, call_next): + if request.url.path != "/health": + expected = (os.environ.get("MANAGED_AGENT_TOKEN") or "").strip() + offered = (request.headers.get(TOKEN_HEADER) or "").strip() + if not expected or offered != expected: + return JSONResponse({"error": "unauthorised"}, status_code=401) + return await call_next(request) + + +@app.get("/health") +async def health(): + return {"ok": True, "harness": "microsoft-agent-framework"} + + +add_agent_framework_fastapi_endpoint(app, agent, "/") diff --git a/desktop/src-tauri/src/harness.rs b/desktop/src-tauri/src/harness.rs index c2e9f1f07..b30e16dd8 100644 --- a/desktop/src-tauri/src/harness.rs +++ b/desktop/src-tauri/src/harness.rs @@ -70,6 +70,14 @@ pub struct Harness { /// Anything the AG-UI table marks In Progress is left out. OpenAI's Agents SDK, AWS Bedrock Agents /// and Cloudflare Agents are all In Progress, and a picker that offers a harness which cannot yet /// answer is worse than a shorter picker. +/// +/// Mastra is left out for a different reason, and it is not a judgement on Mastra. There is no way +/// to serve it over AG-UI today without breaking the no-adapters rule. `@mastra/agui`, which is +/// Mastra's own server-side helper, peer-depends on `@mastra/core >=0.10.7 <0.12.0` and was last +/// published in July 2025; core is now 1.64. `@ag-ui/mastra`, from the AG-UI repository, is a +/// client abstraction and exposes no HTTP handler. Shipping Mastra would therefore mean either a +/// year-old core or an HTTP layer of ours, and the second is the thing this list exists to avoid. +/// It goes back on the day either package moves. pub fn catalogue() -> Vec { let ours = |id: &str, name: &str, summary: &str, maintainer: Maintainer| Harness { id: id.into(), @@ -106,12 +114,6 @@ pub fn catalogue() -> Vec { "Graphs you can change, from LangChain.", Maintainer::Partnership, ), - ours( - "mastra", - "Mastra", - "TypeScript end to end.", - Maintainer::FirstParty, - ), ours( "google-adk", "Google ADK", @@ -230,6 +232,14 @@ mod tests { } } + /// Mastra is absent while its only server-side package is a year behind its own core. Asserted + /// so that putting it back is a deliberate act with this test in front of somebody. + #[test] + fn mastra_stays_out_while_it_cannot_be_served() { + let ids: Vec = catalogue().into_iter().map(|h| h.id).collect(); + assert!(!ids.contains(&"mastra".to_string())); + } + /// Codex and Gemini CLI have no integration and we do not write adapters, so they cannot appear /// however popular they are. #[test] From 495af054b545bd0e81783bdfb6f43a2a0ac4c4f7 Mon Sep 17 00:00:00 2001 From: David McKay Date: Sun, 6 Sep 2026 16:36:09 -0700 Subject: [PATCH 03/46] Prove the Claude Agent SDK harness on a subscription rather than a key `ClaudeAgentAdapter` takes a `name` its README omits, which is the eleventh harness in a row whose documentation was not enough to start it. With that fixed it answered a real AG-UI run on `CLAUDE_CODE_OAUTH_TOKEN` alone, with no `ANTHROPIC_API_KEY` set anywhere. That is the whole point of this row: somebody who pays for a Claude plan gets a working Bot without going to find an API key, and the precedence guard beside it makes sure the plan is what actually gets used. --- agent-claude-sdk/src/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agent-claude-sdk/src/main.py b/agent-claude-sdk/src/main.py index dfa491641..a1013dd89 100644 --- a/agent-claude-sdk/src/main.py +++ b/agent-claude-sdk/src/main.py @@ -59,6 +59,6 @@ async def health(): add_claude_fastapi_endpoint( app=app, - adapter=ClaudeAgentAdapter(), + adapter=ClaudeAgentAdapter(name="openbot"), path="/", ) From 7d120100649d637188febc6cf6b5d6c199372870 Mon Sep 17 00:00:00 2001 From: David McKay Date: Sun, 6 Sep 2026 16:46:40 -0700 Subject: [PATCH 04/46] Find out what actually blocks Mastra, and keep the image that proves it My first answer was wrong. I said Mastra could not be served because `@mastra/agui` is a year behind its own core and `@ag-ui/mastra` exposes no handler. The second half of that was a mistake: `registerCopilotKit` is a subpath export, `@ag-ui/mastra/copilotkit`, and I only listed the root. Built properly, it works. Mastra serves the route from its own server, the image answers, and the token guard refuses an unauthenticated call. What it serves is the CopilotKit Runtime protocol rather than AG-UI: it wants a `method` field and refuses a `RunAgentInput`. `{"method":"info"}` answers with runtime 1.70.1 in SSE mode and lists the agent, so the endpoint is healthy and simply speaks something else. A Bot in OpenBot is an AG-UI URL, so Mastra still cannot be a row. The image stays because it is what establishes that, and because closing the gap is a small change at one of two ends: OpenBot accepting a CopilotKit Runtime endpoint as a second kind of Bot, which is defensible since that runtime is ours, or Mastra publishing a plain AG-UI route. Writing the AG-UI layer here is the one option ruled out. --- agent-mastra/Dockerfile | 17 +++++++++ agent-mastra/package.json | 11 ++++++ agent-mastra/src/mastra/index.ts | 59 ++++++++++++++++++++++++++++++++ desktop/src-tauri/src/harness.rs | 18 ++++++---- 4 files changed, 98 insertions(+), 7 deletions(-) create mode 100644 agent-mastra/Dockerfile create mode 100644 agent-mastra/package.json create mode 100644 agent-mastra/src/mastra/index.ts diff --git a/agent-mastra/Dockerfile b/agent-mastra/Dockerfile new file mode 100644 index 000000000..89e580ca4 --- /dev/null +++ b/agent-mastra/Dockerfile @@ -0,0 +1,17 @@ +# Mastra, as a Bot. Bun and Mastra's own server, because Mastra serves its AG-UI route itself +# rather than handing back a handler to mount. +FROM oven/bun:1.3-alpine + +WORKDIR /app +COPY agent-mastra/package.json ./ +RUN bun install + +COPY agent-mastra/src ./src + +# Built at image time, not at start: `mastra start` runs a bundle, and building on every container +# start would put a build step in front of a person waiting for a Bot. +RUN bunx mastra build --dir src/mastra + +ENV PORT=4213 +EXPOSE 4213 +CMD ["bunx", "mastra", "start"] diff --git a/agent-mastra/package.json b/agent-mastra/package.json new file mode 100644 index 000000000..f34e7cfb9 --- /dev/null +++ b/agent-mastra/package.json @@ -0,0 +1,11 @@ +{ + "name": "@openbot/agent-mastra", + "private": true, + "type": "module", + "dependencies": { + "@ag-ui/mastra": "*", + "@ai-sdk/openai": "*", + "@mastra/core": "*", + "mastra": "*" + } +} diff --git a/agent-mastra/src/mastra/index.ts b/agent-mastra/src/mastra/index.ts new file mode 100644 index 000000000..df4b492ea --- /dev/null +++ b/agent-mastra/src/mastra/index.ts @@ -0,0 +1,59 @@ +/** + * Mastra as a Bot. + * + * Mastra brings its own HTTP server, so unlike the Python harnesses this one is not a FastAPI app + * with a route bolted on: the route is registered with Mastra and Mastra serves it. + * `registerCopilotKit` is Mastra's own helper, from `@ag-ui/mastra/copilotkit`, so the protocol + * still is not ours. + */ +import { registerCopilotKit } from "@ag-ui/mastra/copilotkit"; +import { openai } from "@ai-sdk/openai"; +import { Agent } from "@mastra/core/agent"; +import { Mastra } from "@mastra/core/mastra"; +import { registerApiRoute } from "@mastra/core/server"; + +const model = (process.env.BOT_MODEL ?? "gpt-4o-mini").trim(); + +const openbot = new Agent({ + name: "openbot", + instructions: "Answer the question you are asked, briefly and correctly.", + model: openai(model), +}); + +/** The one header OpenBot's server sends, compared without leaking length through timing. */ +function carriesTheServerToken(request: Request): boolean { + const expected = (process.env.MANAGED_AGENT_TOKEN ?? "").trim(); + const offered = (request.headers.get("x-openbot-agent-token") ?? "").trim(); + // Unset means unconfigured, not open. + if (!expected || offered.length !== expected.length) return false; + let difference = 0; + for (let index = 0; index < offered.length; index += 1) { + difference |= offered.charCodeAt(index) ^ expected.charCodeAt(index); + } + return difference === 0; +} + +export const mastra = new Mastra({ + agents: { openbot }, + server: { + port: Number(process.env.PORT ?? 4213), + host: "0.0.0.0", + middleware: [ + // Everything but `/health`, which Compose polls before any token exists. + async (context, next) => { + if (new URL(context.req.url).pathname === "/health") return next(); + if (!carriesTheServerToken(context.req.raw)) { + return context.json({ error: "unauthorised" }, 401); + } + return next(); + }, + ], + apiRoutes: [ + registerApiRoute("/health", { + method: "GET", + handler: async (context) => context.json({ ok: true, harness: "mastra" }), + }), + registerCopilotKit({ path: "/", resourceId: "openbot" }), + ], + }, +}); diff --git a/desktop/src-tauri/src/harness.rs b/desktop/src-tauri/src/harness.rs index b30e16dd8..880bd4454 100644 --- a/desktop/src-tauri/src/harness.rs +++ b/desktop/src-tauri/src/harness.rs @@ -71,13 +71,17 @@ pub struct Harness { /// and Cloudflare Agents are all In Progress, and a picker that offers a harness which cannot yet /// answer is worse than a shorter picker. /// -/// Mastra is left out for a different reason, and it is not a judgement on Mastra. There is no way -/// to serve it over AG-UI today without breaking the no-adapters rule. `@mastra/agui`, which is -/// Mastra's own server-side helper, peer-depends on `@mastra/core >=0.10.7 <0.12.0` and was last -/// published in July 2025; core is now 1.64. `@ag-ui/mastra`, from the AG-UI repository, is a -/// client abstraction and exposes no HTTP handler. Shipping Mastra would therefore mean either a -/// year-old core or an HTTP layer of ours, and the second is the thing this list exists to avoid. -/// It goes back on the day either package moves. +/// Mastra is left out for a different reason, and it is not a judgement on Mastra. Its server-side +/// route does exist and it runs: `registerCopilotKit` from `@ag-ui/mastra/copilotkit`, registered +/// with Mastra's own server, answers and reports its agent. What it serves is the **CopilotKit +/// Runtime** protocol, not AG-UI: it wants `{"method": ...}` and refuses a `RunAgentInput`. A Bot +/// in OpenBot is an AG-UI URL, so the two do not meet. +/// +/// Two ways to close that, neither of them a harness image. OpenBot could accept a CopilotKit +/// Runtime endpoint as a second kind of Bot, which is defensible because that runtime is +/// CopilotKit's own rather than a third party's. Or Mastra could publish a plain AG-UI route. Until +/// one happens, offering Mastra would mean writing the AG-UI layer here, which is the single thing +/// this list exists to avoid. pub fn catalogue() -> Vec { let ours = |id: &str, name: &str, summary: &str, maintainer: Maintainer| Harness { id: id.into(), From 480d3bad8a6b49a6a58aa98c003aaff1b8358307 Mon Sep 17 00:00:00 2001 From: David McKay Date: Sun, 6 Sep 2026 17:28:59 -0700 Subject: [PATCH 05/46] Reach Mastra through its own bridge instead of a route in the harness A Mastra server speaks Mastra's client protocol, not AG-UI, and the harness was papering over that with registerCopilotKit from @ag-ui/mastra/copilotkit. That serves the CopilotKit Runtime protocol rather than AG-UI: a run reached it and came back complaining about a missing method field, so the Bot was dead on arrival. The translation belongs on this side. remoteTransport now dials a Mastra endpoint with getRemoteAgents, the bridge Mastra and AG-UI maintain between them, and hands back an AbstractAgent like the HTTP path does. Construction moves out of remoteAgentWithStandingRole because building a Mastra transport is asynchronous and that function is not, but the better reason is that it leaves one wrapper: the standing role, the holdings message, the offered tools and the signed assertion apply to both kinds, and no transport has its own copy to drift from. The harness loses its route and its dependency on the bridge, and is a plain Mastra server. RegisteredRemoteAgent is a union of two single-literal variants rather than one type with a union discriminant, because TypeScript will not eliminate a member whose discriminant is itself a union: written the other way, the built-in path below silently stops being narrowed to a built-in Bot. pickFromRoster is pure and tested separately. A name that was asked for is never replaced by the only agent present, because that turns a typo into a Bot that runs and answers as somebody else. --- agent-mastra/package.json | 1 - agent-mastra/src/mastra/index.ts | 17 ++- bun.lock | 143 +++++++++++++++++++-- server/package.json | 3 + server/src/agents/registry.ts | 6 +- server/src/copilot.ts | 191 ++++++++++++++++++++++++----- server/tests/mastra-roster.test.ts | 41 +++++++ 7 files changed, 356 insertions(+), 46 deletions(-) create mode 100644 server/tests/mastra-roster.test.ts diff --git a/agent-mastra/package.json b/agent-mastra/package.json index f34e7cfb9..51541dd52 100644 --- a/agent-mastra/package.json +++ b/agent-mastra/package.json @@ -3,7 +3,6 @@ "private": true, "type": "module", "dependencies": { - "@ag-ui/mastra": "*", "@ai-sdk/openai": "*", "@mastra/core": "*", "mastra": "*" diff --git a/agent-mastra/src/mastra/index.ts b/agent-mastra/src/mastra/index.ts index df4b492ea..deef09d31 100644 --- a/agent-mastra/src/mastra/index.ts +++ b/agent-mastra/src/mastra/index.ts @@ -2,11 +2,16 @@ * Mastra as a Bot. * * Mastra brings its own HTTP server, so unlike the Python harnesses this one is not a FastAPI app - * with a route bolted on: the route is registered with Mastra and Mastra serves it. - * `registerCopilotKit` is Mastra's own helper, from `@ag-ui/mastra/copilotkit`, so the protocol - * still is not ours. + * with a route bolted on: it is a plain Mastra server, and that is the whole point. Mastra already + * serves its agents over its own API, and OpenBot dials that API through `@ag-ui/mastra`, the bridge + * Mastra and AG-UI maintain between them. See `remoteTransport` in server/src/copilot.ts. + * + * SO THERE IS NO AG-UI ROUTE HERE, deliberately. An earlier version mounted `registerCopilotKit` + * from `@ag-ui/mastra/copilotkit`, which serves the CopilotKit Runtime protocol rather than AG-UI: + * a different wire format that answers a run with a complaint about a missing `method` field. The + * translation belongs on OpenBot's side, in one place, where every remote Bot is governed the same + * way — not in each harness. */ -import { registerCopilotKit } from "@ag-ui/mastra/copilotkit"; import { openai } from "@ai-sdk/openai"; import { Agent } from "@mastra/core/agent"; import { Mastra } from "@mastra/core/mastra"; @@ -51,9 +56,9 @@ export const mastra = new Mastra({ apiRoutes: [ registerApiRoute("/health", { method: "GET", - handler: async (context) => context.json({ ok: true, harness: "mastra" }), + handler: async (context) => + context.json({ ok: true, harness: "mastra" }), }), - registerCopilotKit({ path: "/", resourceId: "openbot" }), ], }, }); diff --git a/bun.lock b/bun.lock index a117e3901..26be8b1fe 100644 --- a/bun.lock +++ b/bun.lock @@ -63,9 +63,12 @@ "version": "0.0.0", "dependencies": { "@ag-ui/client": "0.0.59", + "@ag-ui/mastra": "1.1.2", "@better-auth/drizzle-adapter": "^1.7.1", "@better-auth/sso": "^1.7.1", "@copilotkit/runtime": "1.70.1", + "@mastra/client-js": "^1.43.0", + "@mastra/core": "^1.64.0", "@modelcontextprotocol/sdk": "^1.30.0", "better-auth": "^1.7.1", "cel-js": "^0.8.2", @@ -91,6 +94,10 @@ "packages": { "@0no-co/graphql.web": ["@0no-co/graphql.web@1.3.4", "", { "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" }, "optionalPeers": ["graphql"] }, "sha512-imSwulOeDQodRy/olQmVEo2PiY6ntjkZ9eiGdw6lMYylh/tay9b7MusyJBmEnkL8GiKRKr6ltr+D42mY5bd8Bg=="], + "@a2a-js/sdk-v0_3": ["@a2a-js/sdk@0.3.14", "", { "dependencies": { "uuid": "^11.1.0" }, "peerDependencies": { "@bufbuild/protobuf": "^2.10.2", "@grpc/grpc-js": "^1.11.0", "express": "^4.21.2 || ^5.1.0" }, "optionalPeers": ["@bufbuild/protobuf", "@grpc/grpc-js", "express"] }, "sha512-F6Ew1AtPzCLhTn8h9yiqTe7DiDf6XVrSnq9V1YqSl9eWqPm6anMveTiKdCSb/76cW0YiJc24rNaUrVezFFHbqQ=="], + + "@a2a-js/sdk-v1": ["@a2a-js/sdk@1.0.1", "", { "dependencies": { "jose": "^6.2.3", "uuid": "^11.1.0" }, "peerDependencies": { "@bufbuild/protobuf": "^2.10.2", "@grpc/grpc-js": "^1.11.0", "express": "^4.21.2 || ^5.1.0" }, "optionalPeers": ["@bufbuild/protobuf", "@grpc/grpc-js", "express"] }, "sha512-CJQdh3Wzwo8qIx5UUkSJ7+7BEI16PB+MXMHHNSmx8JQsQed2HlQgvx1ENOiKUfYA3PlcEvxIwv14dBblhDuPmw=="], + "@a2ui/web_core": ["@a2ui/web_core@0.10.4", "", { "dependencies": { "@preact/signals-core": "^1.14.2", "date-fns": "^4.4.0", "zod": "^3.25.76", "zod-to-json-schema": "^3.25.2" } }, "sha512-sahOUSKZIGv9ZtnHjfzWR8o/F1XfSTp4sQAX5/auSenQYBYDRaY0+vrZIkddc/IEzpXJob6cFJT2r5/mLzAvqg=="], "@ag-ui/a2ui-middleware": ["@ag-ui/a2ui-middleware@0.0.10", "", { "dependencies": { "@ag-ui/a2ui-toolkit": "0.0.4", "clarinet": "^0.12.6" }, "peerDependencies": { "@ag-ui/client": ">=0.0.40", "rxjs": "7.8.1" } }, "sha512-2BQFUQ9vJzUAQSR0dNW/ijhyH8KpiRWISSLTP6mIe6ENyQ2cM1/XLG38/Dcb69olcs36gtZADZzAQWcno5H6fA=="], @@ -105,6 +112,8 @@ "@ag-ui/langgraph": ["@ag-ui/langgraph@0.0.43", "", { "dependencies": { "@ag-ui/a2ui-toolkit": "0.0.4", "@langchain/core": "^1.1.40", "@langchain/langgraph-sdk": "^1.8.8", "langchain": ">=1.2.0", "partial-json": "^0.1.7", "rxjs": "7.8.1" }, "peerDependencies": { "@ag-ui/client": ">=0.0.42", "@ag-ui/core": ">=0.0.42" } }, "sha512-eG8FBd7jQeo7lfraAz9fbuYM/sFJILnO7yFioCa/++cFygd5CpqpvVoUI1d/WEOzHYTwiuXYM5fPOQ8ZzQuYog=="], + "@ag-ui/mastra": ["@ag-ui/mastra@1.1.2", "", { "dependencies": { "@ag-ui/a2ui-toolkit": "0.0.4", "@ai-sdk/ui-utils": "^1.1.19", "fast-json-patch": "^3.1.1", "rxjs": "7.8.1", "zod": "^3.25.76" }, "peerDependencies": { "@ag-ui/client": ">=0.0.44", "@ag-ui/core": ">=0.0.44", "@copilotkit/runtime": "^1.60.1", "@mastra/client-js": ">=1.0.0-0 <2.0.0-0", "@mastra/core": ">=1.29.0 <2.0.0-0" } }, "sha512-jbtpCZP89BPAWzf8sStY6Y06P/O9DidO0TNQKYnxXsjKwe2YtBF49rY/6k3L7N3rx/0PVniPV68qY+D3G4AtiA=="], + "@ag-ui/mcp-apps-middleware": ["@ag-ui/mcp-apps-middleware@0.0.3", "", { "dependencies": { "@modelcontextprotocol/sdk": "^1.0.0", "rxjs": "7.8.1" }, "peerDependencies": { "@ag-ui/client": ">=0.0.40" } }, "sha512-Z+NZQXj4J+Y/2PLNsiyhNzRWFtTT2sAoC5dztoAlIsdfzvLvYEa52YGdHesNTN85JJqaIrYxQ8e31IBpKjrnoA=="], "@ag-ui/mcp-middleware": ["@ag-ui/mcp-middleware@0.0.1", "", { "dependencies": { "@ag-ui/client": "0.0.54", "@modelcontextprotocol/sdk": "^1.0.0" }, "peerDependencies": { "rxjs": "7.8.1" } }, "sha512-TayUu7kB+jXUTPRUJesNvJYrP+0weTL9F2VJJ8QQ4sWxY/Ihjo+GgFYgJZYNcLwbo1DKgmVJtdm2XUouPCbxeg=="], @@ -125,9 +134,21 @@ "@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.48", "", { "dependencies": { "@ai-sdk/provider": "2.0.3", "@ai-sdk/provider-utils": "3.0.32" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Zl5+5VHId7g344LVSjZvPuFuRVej+u+MB+0ibFVGaXG1qUykyjKu2ptHinK5RjDdviCm+Bs+t3ZLegB39e8VyA=="], - "@ai-sdk/provider": ["@ai-sdk/provider@3.0.15", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-XeZW1CcDF2GMbH4wejW6xBRI2QCOgnkVYUnxoeDadB1mf85riL2bMUeDoh+6gJ/r4mjNfzUPW8OjLjvwTP0u1Q=="], + "@ai-sdk/provider": ["@ai-sdk/provider@1.1.3", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg=="], + + "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@2.2.8", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "nanoid": "^3.3.8", "secure-json-parse": "^2.7.0" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA=="], + + "@ai-sdk/provider-utils-v6": ["@ai-sdk/provider-utils@4.0.40", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-OL5IrpUm9Y8Dwy+w/vvFwPotS6m52O9W0op2oXgXdCROMJIBalBI0oro6OIBYkPxvm5Xg02GSkoQN25RlR0bnw=="], + + "@ai-sdk/provider-utils-v7": ["@ai-sdk/provider-utils@5.0.13", "", { "dependencies": { "@ai-sdk/provider": "4.0.4", "@standard-schema/spec": "^1.1.0", "@workflow/serde": "4.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-fScDJMDnTbx32kLDQqp0MvPjvwkgiwvlBxlmIg7XW5PbS91LG6JjH3PQG+34oMFglqfpQA355e24OdGj5PPoDw=="], + + "@ai-sdk/provider-v5": ["@ai-sdk/provider@2.0.3", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-h88OPkavHTiN9tMn2l5awAznGB0lXzjcLhgR1/rvjB2zlLprsNxbM2tt6OJsHUxduLC3klq0/eqaSf6fX5XVww=="], + + "@ai-sdk/provider-v6": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="], + + "@ai-sdk/provider-v7": ["@ai-sdk/provider@4.0.4", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-tbHKNLirllUNF3ZlkCsXnwab2ZV1Sl4b1H/Cp9ruCce15IBmskE8Gwkk0yo9xDWY+jho2of7lVXtwSsyrq7cwQ=="], - "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.46", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8", "undici": "^6.28.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-tEtld97plCFiYevsJuOkGkeuhQndeMWFBVrJS4AjnbD5AqrNSXRCe0p+BZ3Cju/sxDeeZ9ym3q9YUV8fASA7aQ=="], + "@ai-sdk/ui-utils": ["@ai-sdk/ui-utils@1.2.11", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-3zcwCc8ezzFlwp3ZD15wAPjf2Au4s3vAbKsXQVyhxODHcmu0iyPO2Eua6D/vicq/AUm/BAo60r97O6HU+EI0+w=="], "@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="], @@ -399,6 +420,8 @@ "@iconify/utils": ["@iconify/utils@3.1.4", "", { "dependencies": { "@antfu/install-pkg": "^1.1.0", "@iconify/types": "^2.0.0", "import-meta-resolve": "^4.2.0" } }, "sha512-b1S7B1k9ohZ+iNTi2ATxbRYG9fTrJmUT0rc46bvVnNxqNRGW7dyo/vRREwyniI5IRN2RSJHDcm+s3BjWrSAjHw=="], + "@isaacs/ttlcache": ["@isaacs/ttlcache@2.1.5", "", {}, "sha512-VwGZqqjAWPICTmxUZnbpEfO60LhPWzquik+bmyXGY7pYRn6diEvCI5i6Ca+J6o2y4vS73HrpuMTo2dOvUevH8w=="], + "@jetbrains/websandbox": ["@jetbrains/websandbox@1.3.1", "", {}, "sha512-YTl3MJXbAkYDyuRhWqlQoo7eY2jaLGRqUkx4LVRvxr0VnK7s5bu0p2KAGOWZIBf6I3pmuuq+JzBDHw1b0fV0/Q=="], "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], @@ -429,6 +452,12 @@ "@lukeed/uuid": ["@lukeed/uuid@2.0.1", "", { "dependencies": { "@lukeed/csprng": "^1.1.0" } }, "sha512-qC72D4+CDdjGqJvkFMMEAtancHUQ7/d/tAiHf64z8MopFDmcrtbcJuerDtFceuAfQJ2pDSfCKCtbqoGBNnwg0w=="], + "@mastra/client-js": ["@mastra/client-js@1.43.0", "", { "dependencies": { "@ai-sdk/ui-utils": "^1.2.11", "@lukeed/uuid": "^2.0.1", "@mastra/core": "1.64.0", "@mastra/schema-compat": "1.3.8", "canonicalize": "^1.0.8", "jose": "^6.2.1", "json-schema": "^0.4.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-jZztIVZ9zF7Pi9DC4Gd47BafcJwfqLQiAUGeOMLBMmD4m+DSYXoKNl+jw1PzcFzdb9LK7dHfAg9WLmZ+AlG1/Q=="], + + "@mastra/core": ["@mastra/core@1.64.0", "", { "dependencies": { "@a2a-js/sdk-v0_3": "npm:@a2a-js/sdk@~0.3.14", "@a2a-js/sdk-v1": "npm:@a2a-js/sdk@~1.0.1", "@ai-sdk/provider-utils-v6": "npm:@ai-sdk/provider-utils@4.0.40", "@ai-sdk/provider-utils-v7": "npm:@ai-sdk/provider-utils@5.0.13", "@ai-sdk/provider-v5": "npm:@ai-sdk/provider@2.0.3", "@ai-sdk/provider-v6": "npm:@ai-sdk/provider@3.0.14", "@ai-sdk/provider-v7": "npm:@ai-sdk/provider@4.0.4", "@isaacs/ttlcache": "^2.1.5", "@lukeed/uuid": "^2.0.1", "@mastra/schema-compat": "1.3.8", "@modelcontextprotocol/server": "2.0.0", "@sindresorhus/slugify": "^2.2.1", "@standard-schema/spec": "^1.1.0", "ajv": "^8.20.0", "chat": "^4.34.0", "croner": "^10.0.1", "dotenv": "^17.3.1", "execa": "^9.6.1", "fastq": "^1.20.1", "gray-matter": "^4.0.3", "ignore": "^7.0.5", "jpeg-js": "^0.4.4", "json-schema": "^0.4.0", "lru-cache": "^11.2.7", "p-map": "^7.0.4", "p-retry": "^7.1.1", "picomatch": "^4.0.3", "posthog-node": "^5.46.1", "tokenx": "^1.3.0", "ws": "^8.21.0", "xxhash-wasm": "^1.1.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-+fuvDeORYWIb+SJrGQlXQi6r1JCxd05g3SNCDepnO910j4uqxMWf5s22Ta0sRSR5zbKRx2NUZoWPAfQt2xF4hQ=="], + + "@mastra/schema-compat": ["@mastra/schema-compat@1.3.8", "", { "dependencies": { "json-schema-to-zod": "^2.7.0", "zod-from-json-schema": "^0.5.2" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-uGGQlb/QAIgghHf/FQLfnpHbUMXWWJDFlv0FQ/p0K6TjT8OVdWC9uNPdwQmFYu8Fzut4Z+LmzNkJ5S378/41Ew=="], + "@mermaid-js/parser": ["@mermaid-js/parser@1.2.1", "", { "dependencies": { "@chevrotain/types": "~11.1.2" } }, "sha512-n12NohV3mrUyUL2o93IgG/ifeW9FTyeJn3zDxkhwa8MJ9Fxg3HQMlA3RiGmD/3UnJvheztkjjQAjA2T4LmUcpw=="], "@microsoft/agents-activity": ["@microsoft/agents-activity@1.7.2", "", { "dependencies": { "zod": "3.25.75" } }, "sha512-aMPZFuyIkdAdKxscyEZ6o6m60rB+JbWE4n02HzP3QOmILWBfddRMxYlK6gFLd86Wpf1hYGvfJBdpx8Av5ZRBIA=="], @@ -437,10 +466,14 @@ "@microsoft/agents-telemetry": ["@microsoft/agents-telemetry@1.7.2", "", { "dependencies": { "@microsoft/agents-activity": "1.7.2", "debug": "^4.4.3" }, "peerDependencies": { "@opentelemetry/api": "1", "@opentelemetry/api-logs": ">=0.214.0 <1" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs"] }, "sha512-iJBAm1MmCUaSoPE62GKp4OnlOSe+F+j8o/fwKuqiJEcedbgceTh6G5m8iqe/qDGwKSsBWuIu3Kt0comRrc140A=="], + "@modelcontextprotocol/core": ["@modelcontextprotocol/core@2.0.0", "", { "dependencies": { "zod": "^4.2.0" } }, "sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA=="], + "@modelcontextprotocol/ext-apps": ["@modelcontextprotocol/ext-apps@1.7.5", "", { "dependencies": { "@standard-schema/spec": "^1.1.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.29.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-TjPH2S2y5UEGKhmI6+XGFuqfqOV4ppe1x6DA3txnUaEWkgtA4G5vo14jGKFZmegdkZ1H4QMLyujLvoU1BEdnAg=="], "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.30.0", "", { "dependencies": { "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA=="], + "@modelcontextprotocol/server": ["@modelcontextprotocol/server@2.0.0", "", { "dependencies": { "@modelcontextprotocol/core": "2.0.0", "zod": "^4.2.0" } }, "sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw=="], + "@napi-rs/lzma-linux-x64-gnu": ["@napi-rs/lzma-linux-x64-gnu@1.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ=="], "@noble/ciphers": ["@noble/ciphers@2.3.0", "", {}, "sha512-Clu/xdfgVTf9o7ngLOURaxePwR0j8sjclKEtVij10/jGulwFsPWCvvRgG/XjUVf8Nei+jLG6uwyXzUTGY1DQrw=="], @@ -461,6 +494,10 @@ "@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="], + "@posthog/core": ["@posthog/core@1.50.5", "", { "dependencies": { "@posthog/types": "^1.409.0" } }, "sha512-afEchuShDaVIoxAIj76kDZQ1DhfesDmgfVp+mtTzsA3wlc8DF5uoz8YjuTjnxOicWkpP5HCDqYidK/1kT125Cg=="], + + "@posthog/types": ["@posthog/types@1.409.0", "", {}, "sha512-239umoaZVb2GBaXeEyJpwFvjhrrChJH8NHCwiao23EBSu3NA6EN0MTMoaHSmMEf4yjiXEFFoYJA6FjJAXF5HGA=="], + "@preact/signals-core": ["@preact/signals-core@1.14.4", "", {}, "sha512-HNB6HYeYKhQbJ1aKl+YRjrS4+QWHLKX6qKoUsfS/m0vqzsVaEBiZiaKbG/e+NKk2ch5ALQr/ihWaMHxiCuuWHA=="], "@protobuf-ts/protoc": ["@protobuf-ts/protoc@2.11.1", "", { "bin": { "protoc": "protoc.js" } }, "sha512-mUZJaV0daGO6HUX90o/atzQ6A7bbN2RSuHtdwo8SSF2Qoe3zHwa4IHyCN1evftTeHfLmdz+45qo47sL+5P8nyg=="], @@ -605,6 +642,10 @@ "@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="], + "@sindresorhus/slugify": ["@sindresorhus/slugify@2.2.1", "", { "dependencies": { "@sindresorhus/transliterate": "^1.0.0", "escape-string-regexp": "^5.0.0" } }, "sha512-MkngSCRZ8JdSOCHRaYd+D01XhvU3Hjy6MGl06zhOk614hp9EOAp5gIkBeQg7wtmxpitU6eAL4kdiRMcJa2dlrw=="], + + "@sindresorhus/transliterate": ["@sindresorhus/transliterate@1.6.0", "", { "dependencies": { "escape-string-regexp": "^5.0.0" } }, "sha512-doH1gimEu3A46VX6aVxpHTeHrytJAG6HgdxntYnCFiIFHEM/ZGpG8KiZGBChchjQmG0XFIBL552kBTjVcMZXwQ=="], + "@slack/bolt": ["@slack/bolt@4.7.3", "", { "dependencies": { "@slack/logger": "^4.0.1", "@slack/oauth": "^3.0.5", "@slack/socket-mode": "^2.0.7", "@slack/types": "^2.21.1", "@slack/web-api": "^7.16.0", "axios": "^1.12.0", "express": "^5.0.0", "path-to-regexp": "^8.1.0", "raw-body": "^3", "tsscmp": "^1.0.6" }, "peerDependencies": { "@types/express": "^5.0.0" } }, "sha512-bODs8q/yNDWUPoxmQhFrRqLMA5vhB/PDizYWqb6CkQhLWEUo5JFtfJcmeU4ElGl6qSt++OKjSYNa4MPc77CleQ=="], "@slack/logger": ["@slack/logger@4.0.1", "", { "dependencies": { "@types/node": ">=18" } }, "sha512-6cmdPrV/RYfd2U0mDGiMK8S7OJqpCTm7enMLRR3edccsPX8j7zXTLnaEF4fhxxJJTAIOil6+qZrnUPTuaLvwrQ=="], @@ -867,6 +908,8 @@ "@whatwg-node/server": ["@whatwg-node/server@0.11.0", "", { "dependencies": { "@envelop/instrumentation": "^1.0.0", "@whatwg-node/disposablestack": "^0.0.6", "@whatwg-node/fetch": "^0.10.13", "@whatwg-node/promise-helpers": "^1.3.2", "tslib": "^2.6.3" } }, "sha512-VSdkwnJRr8Yv9UgB2aXB3VUPWwd6Oqnn0hycFwhg9pZgWxJXb7JmhsiXe9tmpMwjHFxli12PGcz9aI63YYloGQ=="], + "@workflow/serde": ["@workflow/serde@4.1.0", "", {}, "sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ=="], + "@xmldom/is-dom-node": ["@xmldom/is-dom-node@1.0.1", "", {}, "sha512-CJDxIgE5I0FH+ttq/Fxy6nRpxP70+e2O048EPe85J2use3XKdatVM7dDVvFNjQudd9B49NPoZ+8PG49zj4Er8Q=="], "@xmldom/xmldom": ["@xmldom/xmldom@0.9.12", "", {}, "sha512-5AXjrcMClTryPe9LgZrygpB1lj7s0S9E0+W+AHaVKAVyHanafK86iPSvG5xHVSp/jC+VH1UXu0TAEmY279xH7A=="], @@ -963,6 +1006,8 @@ "caniuse-lite": ["caniuse-lite@1.0.30001809", "", {}, "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ=="], + "canonicalize": ["canonicalize@1.0.8", "", {}, "sha512-0CNTVCLZggSh7bc5VkX5WWPWO+cyZbNd07IHIsSXLia/eAq+r836hgk+8BKoEh7949Mda87VUOitx5OddVj64A=="], + "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], "cel-js": ["cel-js@0.8.2", "", { "dependencies": { "chevrotain": "11.0.3", "ramda": "0.30.1" } }, "sha512-fPk7V5wcjp6GQ2CqkQKbfaIGQ5rQIm8xOueVqbp9MPfviqZEBOReBF82iZa0oHUm449UTsj1LAzUIAsI356ong=="], @@ -977,6 +1022,8 @@ "character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="], + "chat": ["chat@4.40.0", "", { "dependencies": { "@workflow/serde": "4.1.0-beta.2", "mdast-util-to-string": "^4.0.0", "remark-gfm": "^4.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "remend": "^1.2.1", "unified": "^11.0.5" }, "peerDependencies": { "ai": "^6.0.182 || ^7.0.0", "workflow": "^5.0.0-beta.35", "zod": "^3.0.0 || ^4.0.0" }, "optionalPeers": ["ai", "workflow", "zod"] }, "sha512-slu3VDxItlelEZ8A5vqzlmtT2WErqj2YCGuhhcNx+Ev0+wHeBUqUDUA7hXkca+BfFtW1ZX7ECcySCTG2q2gefg=="], + "chevrotain": ["chevrotain@11.0.3", "", { "dependencies": { "@chevrotain/cst-dts-gen": "11.0.3", "@chevrotain/gast": "11.0.3", "@chevrotain/regexp-to-ast": "11.0.3", "@chevrotain/types": "11.0.3", "@chevrotain/utils": "11.0.3", "lodash-es": "4.17.21" } }, "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw=="], "chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], @@ -1033,6 +1080,8 @@ "cron-parser": ["cron-parser@5.10.0", "", { "dependencies": { "luxon": "^3.7.2" } }, "sha512-izNAxJyRWUP8ljBoDSub5WyrVOUlT4SLGShswE7eoRBpp6QUsSycYxLBMJlbshgPBMcPT/nrfgjNY2918ayv2A=="], + "croner": ["croner@10.0.1", "", {}, "sha512-ixNtAJndqh173VQ4KodSdJEI6nuioBWI0V1ITNKhZZsO0pEMoDxz539T4FTTbSZ/xIOSuDnzxLVRqBVSvPNE2g=="], + "cross-inspect": ["cross-inspect@1.0.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Pcw1JTvZLSJH83iiGWt6fRcT+BjZlCDRVwYLbUcHzv/CRpB7r0MlSrGbIyQvVSNyGnbt7G4AXuyCiDR3POvZ1A=="], "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], @@ -1237,6 +1286,8 @@ "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], + "extend-shallow": ["extend-shallow@2.0.1", "", { "dependencies": { "is-extendable": "^0.1.0" } }, "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug=="], + "fast-copy": ["fast-copy@3.0.2", "", {}, "sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ=="], "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], @@ -1327,6 +1378,8 @@ "graphql-yoga": ["graphql-yoga@5.22.0", "", { "dependencies": { "@envelop/core": "^5.6.0", "@envelop/instrumentation": "^1.0.0", "@graphql-tools/executor": "^2.0.0", "@graphql-tools/schema": "^10.0.11", "@graphql-tools/utils": "^11.2.0", "@graphql-yoga/logger": "^2.0.1", "@graphql-yoga/subscription": "^5.0.5", "@whatwg-node/fetch": "^0.10.6", "@whatwg-node/promise-helpers": "^1.3.2", "@whatwg-node/server": "^0.11.0", "lru-cache": "^10.0.0", "tslib": "^2.8.1" }, "peerDependencies": { "graphql": "^15.2.0 || ^16.0.0 || ^17.0.0" } }, "sha512-RTMS9WfDJQdYT1VRwDQ34Q9zFvuD3PZXBEDedNdUuoeNsJ6jZBwPo5PhdExzkR+s5iwf8MoiGJOS5Byt/PNAZg=="], + "gray-matter": ["gray-matter@4.0.3", "", { "dependencies": { "js-yaml": "^3.13.1", "kind-of": "^6.0.2", "section-matter": "^1.0.0", "strip-bom-string": "^1.0.0" } }, "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q=="], + "hachure-fill": ["hachure-fill@0.5.2", "", {}, "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg=="], "happy-dom": ["happy-dom@20.11.6", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "buffer-image-size": "^0.6.4", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.21.0" } }, "sha512-Hldbg8AdAa5a5oDcZpjqnGitp7JB0hqWmfv/8qr+kft4vzSD8BHsbdRfzYvL/0QcbKcURC/yyoygbeDQarPvYg=="], @@ -1389,7 +1442,7 @@ "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], - "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + "ignore": ["ignore@7.0.8", "", {}, "sha512-YYNsSlXBjMk92SKnkwvB5LOVSa6OznlFUGcsvrFgNJbJCd0M1XKeFVRc8ZByeCqz32FivYNHJVooLmdqrmvp/Q=="], "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], @@ -1419,6 +1472,8 @@ "is-electron": ["is-electron@2.2.2", "", {}, "sha512-FO/Rhvz5tuw4MCWkpMzHFKWD2LsfHzIb7i6MdPYZ/KW7AlxawyLkqdy+jPZP1WubqEADE3O4FUENlJHDfQASRg=="], + "is-extendable": ["is-extendable@0.1.1", "", {}, "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw=="], + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], @@ -1461,6 +1516,8 @@ "joycon": ["joycon@3.1.1", "", {}, "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw=="], + "jpeg-js": ["jpeg-js@0.4.4", "", {}, "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg=="], + "js-tiktoken": ["js-tiktoken@1.0.21", "", { "dependencies": { "base64-js": "^1.5.1" } }, "sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g=="], "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], @@ -1475,6 +1532,8 @@ "json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="], + "json-schema-to-zod": ["json-schema-to-zod@2.8.1", "", { "bin": { "json-schema-to-zod": "dist/cjs/cli.js" } }, "sha512-fRr1mHgZ7hboLKBUdR428gd9dIHUFGivUqOeiDcSmyXkNZCtB1uGaZLvsjZ4GaN5pwBIs+TGIOf6s+Rp5/R/zA=="], + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], @@ -1495,6 +1554,8 @@ "khroma": ["khroma@2.1.0", "", {}, "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw=="], + "kind-of": ["kind-of@6.0.3", "", {}, "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw=="], + "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], "kysely": ["kysely@0.29.5", "", {}, "sha512-ooa+eSbBNPTo3MycPEuW5jdrxQdQwdtB3LC3h43FiXQbIry5tR0C5lDG7eealK0E4D7XjrnOP5DIUg/LyjRMYQ=="], @@ -1569,7 +1630,7 @@ "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], - "lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + "lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="], "lru-memoizer": ["lru-memoizer@3.0.0", "", { "dependencies": { "lodash.clonedeep": "^4.5.0", "lru-cache": "^11.0.1" } }, "sha512-m83w/cYXLdUIboKSPxzPAGfYnk+vqeDYXuoSrQRw1q+yVEd8IXhvMufN8Q5TIPe7e2jyX4SRNrDJI2Skw1yznQ=="], @@ -1773,6 +1834,8 @@ "p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="], + "p-map": ["p-map@7.0.7", "", {}, "sha512-VaWRu2i4FJNRtiRWCuuQRgfQ1B7a6+gMSrO+3j0EQi/k0ULfS9kosRxGoiqwzIjZTDI02tGfk5mXXltLg6QtfQ=="], + "p-queue": ["p-queue@6.6.2", "", { "dependencies": { "eventemitter3": "^4.0.4", "p-timeout": "^3.2.0" } }, "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ=="], "p-retry": ["p-retry@7.1.1", "", { "dependencies": { "is-network-error": "^1.1.0" } }, "sha512-J5ApzjyRkkf601HpEeykoiCvzHQjWxPAHhyjFcEUP2SWq0+35NKh8TLhpLw+Dkq5TZBFvUM6UigdE9hIVYTl5w=="], @@ -1839,6 +1902,8 @@ "postgres": ["postgres@3.4.9", "", {}, "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw=="], + "posthog-node": ["posthog-node@5.51.6", "", { "dependencies": { "@posthog/core": "^1.50.0" }, "peerDependencies": { "rxjs": "^7.0.0" }, "optionalPeers": ["rxjs"] }, "sha512-r+Ge3p0OnOcOWeTnvKZmAtDwECeIoyNTyxBxuZc8wM6RHCQ9j2Xrhogrf39HSq1Y2gIOpWLqIDRmWDcu7xmqcA=="], + "powershell-utils": ["powershell-utils@0.2.0", "", {}, "sha512-ZlsFlG7MtSFCoc5xreOvBAozCJ6Pf06opgJjh9ONEv418xpZSAzNjstD36C6+JwOnfSqOW/9uDkqKjezTdxZhw=="], "prettier": ["prettier@3.9.6", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g=="], @@ -1979,6 +2044,8 @@ "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + "section-matter": ["section-matter@1.0.0", "", { "dependencies": { "extend-shallow": "^2.0.1", "kind-of": "^6.0.0" } }, "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA=="], + "secure-json-parse": ["secure-json-parse@2.7.0", "", {}, "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw=="], "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], @@ -2033,6 +2100,8 @@ "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], + "sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="], + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], "stdin-discarder": ["stdin-discarder@0.2.2", "", {}, "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ=="], @@ -2053,6 +2122,8 @@ "strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="], + "strip-bom-string": ["strip-bom-string@1.0.0", "", {}, "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g=="], + "strip-final-newline": ["strip-final-newline@4.0.0", "", {}, "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw=="], "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], @@ -2091,6 +2162,8 @@ "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + "tokenx": ["tokenx@1.6.0", "", {}, "sha512-CKTjk345ajvBAUp5xUI9a5KKN0zU0lBueVHQbCskH1Hp6WkUKsPW2qGCYNs0pxNyfzxfo+IIjdt2W4sMbw/qBw=="], + "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], @@ -2219,6 +2292,8 @@ "xpath": ["xpath@0.0.34", "", {}, "sha512-FxF6+rkr1rNSQrhUNYrAFJpRXNzlDoMxeXN5qI84939ylEv3qqPFKa85Oxr6tDaJKqwW6KKyo2v26TSv3k6LeA=="], + "xxhash-wasm": ["xxhash-wasm@1.1.0", "", {}, "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA=="], + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], @@ -2229,6 +2304,8 @@ "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + "zod-from-json-schema": ["zod-from-json-schema@0.5.6", "", { "dependencies": { "zod": "^4.0.17" } }, "sha512-U33AJ7ZWS6y9XNSzMWcdy8hRAvZmWhTtpYJu0SXPT5AArbc9nq2ur7Magzmn5RF9KBV4b3FP0nCpmqqlfXlR9w=="], + "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], @@ -2239,8 +2316,22 @@ "@ag-ui/core/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@ag-ui/mastra/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@ag-ui/mcp-middleware/@ag-ui/client": ["@ag-ui/client@0.0.54", "", { "dependencies": { "@ag-ui/core": "0.0.54", "@ag-ui/encoder": "0.0.54", "@ag-ui/proto": "0.0.54", "@types/uuid": "^10.0.0", "compare-versions": "^6.1.1", "fast-json-patch": "^3.1.1", "rxjs": "7.8.1", "untruncate-json": "^0.0.1", "uuid": "^11.1.0", "zod": "^3.22.4" } }, "sha512-N5UVXEBV5gPHqTuMoR/21brconRn42URf+MB4L8OniCJKqLcl/qUJb5kMamK0nnfBhDfPs/uq7LxDn6bsDJzJg=="], + "@ai-sdk/anthropic/@ai-sdk/provider": ["@ai-sdk/provider@3.0.15", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-XeZW1CcDF2GMbH4wejW6xBRI2QCOgnkVYUnxoeDadB1mf85riL2bMUeDoh+6gJ/r4mjNfzUPW8OjLjvwTP0u1Q=="], + + "@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.46", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8", "undici": "^6.28.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-tEtld97plCFiYevsJuOkGkeuhQndeMWFBVrJS4AjnbD5AqrNSXRCe0p+BZ3Cju/sxDeeZ9ym3q9YUV8fASA7aQ=="], + + "@ai-sdk/gateway/@ai-sdk/provider": ["@ai-sdk/provider@3.0.15", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-XeZW1CcDF2GMbH4wejW6xBRI2QCOgnkVYUnxoeDadB1mf85riL2bMUeDoh+6gJ/r4mjNfzUPW8OjLjvwTP0u1Q=="], + + "@ai-sdk/gateway/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.46", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8", "undici": "^6.28.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-tEtld97plCFiYevsJuOkGkeuhQndeMWFBVrJS4AjnbD5AqrNSXRCe0p+BZ3Cju/sxDeeZ9ym3q9YUV8fASA7aQ=="], + + "@ai-sdk/google/@ai-sdk/provider": ["@ai-sdk/provider@3.0.15", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-XeZW1CcDF2GMbH4wejW6xBRI2QCOgnkVYUnxoeDadB1mf85riL2bMUeDoh+6gJ/r4mjNfzUPW8OjLjvwTP0u1Q=="], + + "@ai-sdk/google/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.46", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8", "undici": "^6.28.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-tEtld97plCFiYevsJuOkGkeuhQndeMWFBVrJS4AjnbD5AqrNSXRCe0p+BZ3Cju/sxDeeZ9ym3q9YUV8fASA7aQ=="], + "@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.95", "", { "dependencies": { "@ai-sdk/provider": "2.0.3", "@ai-sdk/provider-utils": "3.0.32" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-V0nIwhyv8f9rD+p6glm0uq30seid8y2CrvNvHCAbnuPm5bPpqVChEB63kixrIDNnogkgI+ZjSTtIbIL2q3QPvQ=="], "@ai-sdk/google-vertex/@ai-sdk/google": ["@ai-sdk/google@2.0.89", "", { "dependencies": { "@ai-sdk/provider": "2.0.3", "@ai-sdk/provider-utils": "3.0.32" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-zYCrUuiCxLk65iQ3C5AU9qLo0lsyCbYNAS983rDTg/lMVmdsWz8aqrL6K746y7/lRyv+gM1y1HoFCxVsKOSlSw=="], @@ -2249,11 +2340,25 @@ "@ai-sdk/google-vertex/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.3", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6", "undici": "^5.29.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-izgUo50kamJMwoYCXoFwVccuJeyYp0y1+twf0FfpEz7kdQBloZLZbgLYUOUpannLWrV7xYSJR48BQJIQFbFiAQ=="], + "@ai-sdk/mcp/@ai-sdk/provider": ["@ai-sdk/provider@3.0.15", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-XeZW1CcDF2GMbH4wejW6xBRI2QCOgnkVYUnxoeDadB1mf85riL2bMUeDoh+6gJ/r4mjNfzUPW8OjLjvwTP0u1Q=="], + + "@ai-sdk/mcp/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.46", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8", "undici": "^6.28.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-tEtld97plCFiYevsJuOkGkeuhQndeMWFBVrJS4AjnbD5AqrNSXRCe0p+BZ3Cju/sxDeeZ9ym3q9YUV8fASA7aQ=="], + + "@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.15", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-XeZW1CcDF2GMbH4wejW6xBRI2QCOgnkVYUnxoeDadB1mf85riL2bMUeDoh+6gJ/r4mjNfzUPW8OjLjvwTP0u1Q=="], + + "@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.46", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8", "undici": "^6.28.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-tEtld97plCFiYevsJuOkGkeuhQndeMWFBVrJS4AjnbD5AqrNSXRCe0p+BZ3Cju/sxDeeZ9ym3q9YUV8fASA7aQ=="], + "@ai-sdk/openai-compatible/@ai-sdk/provider": ["@ai-sdk/provider@2.0.3", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-h88OPkavHTiN9tMn2l5awAznGB0lXzjcLhgR1/rvjB2zlLprsNxbM2tt6OJsHUxduLC3klq0/eqaSf6fX5XVww=="], "@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.3", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6", "undici": "^5.29.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-izgUo50kamJMwoYCXoFwVccuJeyYp0y1+twf0FfpEz7kdQBloZLZbgLYUOUpannLWrV7xYSJR48BQJIQFbFiAQ=="], - "@ai-sdk/provider-utils/undici": ["undici@6.28.0", "", {}, "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA=="], + "@ai-sdk/provider-utils/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@ai-sdk/provider-utils-v6/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="], + + "@ai-sdk/provider-utils-v7/@ai-sdk/provider": ["@ai-sdk/provider@4.0.4", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-tbHKNLirllUNF3ZlkCsXnwab2ZV1Sl4b1H/Cp9ruCce15IBmskE8Gwkk0yo9xDWY+jho2of7lVXtwSsyrq7cwQ=="], + + "@ai-sdk/ui-utils/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "@authenio/xml-encryption/@xmldom/xmldom": ["@xmldom/xmldom@0.8.15", "", {}, "sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA=="], @@ -2279,6 +2384,8 @@ "@dotenvx/dotenvx/execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], + "@dotenvx/dotenvx/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + "@dotenvx/dotenvx/open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="], "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], @@ -2343,6 +2450,10 @@ "accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + "ai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.15", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-XeZW1CcDF2GMbH4wejW6xBRI2QCOgnkVYUnxoeDadB1mf85riL2bMUeDoh+6gJ/r4mjNfzUPW8OjLjvwTP0u1Q=="], + + "ai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.46", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8", "undici": "^6.28.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-tEtld97plCFiYevsJuOkGkeuhQndeMWFBVrJS4AjnbD5AqrNSXRCe0p+BZ3Cju/sxDeeZ9ym3q9YUV8fASA7aQ=="], + "axios/https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], "better-call/@better-auth/utils": ["@better-auth/utils@0.5.0", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA=="], @@ -2355,6 +2466,8 @@ "chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "chat/@workflow/serde": ["@workflow/serde@4.1.0-beta.2", "", {}, "sha512-8kkeoQKLDaKXefjV5dbhBj2aErfKp1Mc4pb6tj8144cF+Em5SPbyMbyLCHp+BVrFfFVCBluCtMx+jjvaFVZGww=="], + "conf/ajv-formats": ["ajv-formats@2.1.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA=="], "conf/json-schema-typed": ["json-schema-typed@7.0.3", "", {}, "sha512-7DE8mpG+/fVw+dTpjbxnx47TaMnDfOI1jwft9g1VybltZCduyRQPJPvc+zzKY9WPHxhPWczyFuYa6I8Mw4iU5A=="], @@ -2387,6 +2500,10 @@ "gaxios/node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], + "graphql-yoga/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + + "gray-matter/js-yaml": ["js-yaml@3.15.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w=="], + "hast-util-from-dom/@types/hast": ["@types/hast@3.0.5", "", { "dependencies": { "@types/unist": "*" } }, "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g=="], "hast-util-from-html/@types/hast": ["@types/hast@3.0.5", "", { "dependencies": { "@types/unist": "*" } }, "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g=="], @@ -2453,8 +2570,6 @@ "log-symbols/is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="], - "lru-memoizer/lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="], - "mdast-util-definitions/@types/mdast": ["@types/mdast@3.0.15", "", { "dependencies": { "@types/unist": "^2" } }, "sha512-LnwD+mUEfxWMa1QpDraczIn6k0Ee3SMicuYSSzS6ZYl2gKS09EClnJYGd8Du6rfc5r/GZEk5o1mRb8TaTj03sQ=="], "mdast-util-definitions/unist-util-visit": ["unist-util-visit@4.1.2", "", { "dependencies": { "@types/unist": "^2.0.0", "unist-util-is": "^5.0.0", "unist-util-visit-parents": "^5.1.1" } }, "sha512-MSd8OUGISqHdVvfY9TPhyK2VdUrPgxkUtWSuMHF6XAAFuL4LokseigBnZtPnJMu+FbynTkFNnFlyjxpVKujMRg=="], @@ -2585,10 +2700,20 @@ "@ag-ui/mcp-middleware/@ag-ui/client/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@ai-sdk/anthropic/@ai-sdk/provider-utils/undici": ["undici@6.28.0", "", {}, "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA=="], + + "@ai-sdk/gateway/@ai-sdk/provider-utils/undici": ["undici@6.28.0", "", {}, "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA=="], + "@ai-sdk/google-vertex/@ai-sdk/provider-utils/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="], + "@ai-sdk/google/@ai-sdk/provider-utils/undici": ["undici@6.28.0", "", {}, "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA=="], + + "@ai-sdk/mcp/@ai-sdk/provider-utils/undici": ["undici@6.28.0", "", {}, "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA=="], + "@ai-sdk/openai-compatible/@ai-sdk/provider-utils/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="], + "@ai-sdk/openai/@ai-sdk/provider-utils/undici": ["undici@6.28.0", "", {}, "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA=="], + "@copilotkit/react-core/streamdown/lucide-react": ["lucide-react@0.542.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-w3hD8/SQB7+lzU2r4VdFyzzOzKnUjTZIF/MQJGSSvni7Llewni4vuViRppfRAa2guOsY5k4jZyxw/i9DQHv+dw=="], "@copilotkit/react-core/streamdown/marked": ["marked@16.4.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="], @@ -2709,6 +2834,8 @@ "accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + "ai/@ai-sdk/provider-utils/undici": ["undici@6.28.0", "", {}, "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA=="], + "axios/https-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], "body-parser/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], @@ -2779,6 +2906,8 @@ "form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + "gray-matter/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], + "hast-util-from-dom/@types/hast/@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], "hast-util-from-html-isomorphic/@types/hast/@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], diff --git a/server/package.json b/server/package.json index 02eb051bc..e3bdaf27e 100644 --- a/server/package.json +++ b/server/package.json @@ -13,9 +13,12 @@ }, "dependencies": { "@ag-ui/client": "0.0.59", + "@ag-ui/mastra": "1.1.2", "@better-auth/drizzle-adapter": "^1.7.1", "@better-auth/sso": "^1.7.1", "@copilotkit/runtime": "1.70.1", + "@mastra/client-js": "^1.43.0", + "@mastra/core": "^1.64.0", "@modelcontextprotocol/sdk": "^1.30.0", "better-auth": "^1.7.1", "cel-js": "^0.8.2", diff --git a/server/src/agents/registry.ts b/server/src/agents/registry.ts index 5dde57b0b..99c0044f6 100644 --- a/server/src/agents/registry.ts +++ b/server/src/agents/registry.ts @@ -7,7 +7,11 @@ type BuiltInAgent = { type RemoteAgent = { id: string; name: string; - type: "remote_ag_ui"; + /** + * How the endpoint is dialled. Both kinds are a URL this deployment posts a run to, and both are + * available on the same condition, which is why availability below does not branch on it. + */ + type: "remote_ag_ui" | "remote_mastra"; endpoint: string; }; type SeededAgent = BuiltInAgent | RemoteAgent; diff --git a/server/src/copilot.ts b/server/src/copilot.ts index fb68b0e2d..127e270b2 100644 --- a/server/src/copilot.ts +++ b/server/src/copilot.ts @@ -53,16 +53,32 @@ type RegisteredBuiltInAgent = { systemPrompt: string; }; -type RegisteredRemoteAgent = { +type RegisteredRemoteAgentFacts = { id: string; name: string; - type: "remote_ag_ui"; endpoint: string; + /** Which agent on the endpoint, for a server that serves a roster. See `remoteTransport`. */ + remoteAgentId?: string; standingMessage: StandingRoleMessage; /** The key this agent sits behind, resolved from the vault at load time. Never logged. */ headers?: Record; }; +/** + * A Bot at somebody else's endpoint, in the two ways this deployment knows how to dial one. + * + * The kinds differ in transport and in nothing else: the difference ends at `remoteTransport`, which + * returns an `AbstractAgent` either way, and every control after that is written against that + * interface. What is deliberate here is the SHAPE. This is a union of two single-literal variants + * rather than one type whose `type` is `"remote_ag_ui" | "remote_mastra"`, because TypeScript will + * not eliminate a union member whose discriminant is itself a union: excluding both literals narrows + * the property and keeps the member, so the built-in path below would silently stop being narrowed + * to a built-in Bot. Verified against tsc 5.x; collapsing these two back into one costs that. + */ +type RegisteredRemoteAgent = + | (RegisteredRemoteAgentFacts & { type: "remote_ag_ui" }) + | (RegisteredRemoteAgentFacts & { type: "remote_mastra" }); + /** * A coworker the caller may see but may not run: its profile was deleted while a channel it worked * in still exists. It is registered so Intelligence can restore that thread and the person can read @@ -130,7 +146,7 @@ export type RuntimeModel = { type RuntimeAgentRow = { id: string; name: string; - type: "built_in" | "remote_ag_ui"; + type: "built_in" | "remote_ag_ui" | "remote_mastra"; configuration: unknown; title: string; roleDescription: string; @@ -158,12 +174,23 @@ export function registeredAgentFromRow( } const endpoint = configuration?.endpoint; + /* + * Which agent on that endpoint, when the endpoint serves more than one. + * + * Mastra servers are rosters rather than single agents, so a Bot row has to say which one it is. + * Absent falls back to this Bot's own id and then, on a single-agent server, to the only one + * there: see `remoteTransport`. + */ + const remoteAgentId = configuration?.remoteAgentId; return typeof endpoint === "string" && isHttpUrl(endpoint) ? { id: row.id, name: row.name, - type: "remote_ag_ui", + type: row.type === "remote_mastra" ? "remote_mastra" : "remote_ag_ui", endpoint, + ...(typeof remoteAgentId === "string" && remoteAgentId.length > 0 + ? { remoteAgentId } + : {}), standingMessage: standingRoleMessage(row), } : null; @@ -462,7 +489,7 @@ async function buildAgent( return chosen.offered; }; - if (agent.type === "remote_ag_ui") { + if (agent.type === "remote_ag_ui" || agent.type === "remote_mastra") { /* * The remote path narrows inside its own middleware rather than by being wrapped. * @@ -489,12 +516,11 @@ async function buildAgent( */ return remoteAgentWithStandingRole( agent, - stallGuard, + await remoteTransport(agent, stallGuard, agentFetch), granted, signRun, connectedVendors, narrowing ? offeredFor : undefined, - agentFetch, ); } @@ -587,13 +613,137 @@ export type ToolSelection = { * message already in the conversation is dropped: the endpoint must receive exactly one, first, * however many times the thread has been replayed. * - * The stall watch goes on the fetch rather than into that middleware, because the middleware works - * in AG-UI events and a stall is the absence of one. The thing that has to be watched is the + * The stall watch is not here but in {@link remoteTransport}, on the fetch: this middleware works in + * AG-UI events and a stall is the absence of one, so the thing that has to be watched is the * response body, and the fetch is where this deployment still holds it. */ -function remoteAgentWithStandingRole( +/** The client `@ag-ui/mastra` asks for, taken from its own signature. See `remoteTransport`. */ +type MastraClientForBridge = Parameters< + typeof import("@ag-ui/mastra").getRemoteAgents +>[0]["mastraClient"]; + +/** + * How this deployment dials a remote Bot's endpoint. + * + * Both kinds come back as an `AbstractAgent`, which is the whole point of putting them here. Every + * control the wrapper below adds — the standing role, the holdings message, the offered tools, the + * signed run assertion — is written against that interface, so a Mastra Bot is governed by exactly + * the same code as an AG-UI one and cannot skip a control by being a different kind. A second + * wrapper per transport is how that stops being true, silently, three months later. + * + * Mastra is reached through `@ag-ui/mastra`, the bridge Mastra and AG-UI maintain between them, + * rather than through anything written here. A Mastra server speaks its own client protocol, and the + * mapping from that protocol to AG-UI events belongs to the people who change both ends of it. + * Imported dynamically so a deployment that registers no Mastra Bot never loads it. + */ +async function remoteTransport( agent: RegisteredRemoteAgent, stallGuard: StallGuard | undefined, + agentFetch?: AgentFetch, +): Promise { + // The watch wraps whichever fetch is underneath, so a deployment gets both the stall timeout and + // the redirect check rather than having to choose. + const dial = stallGuard + ? stallGuard.watch({ id: agent.id, name: agent.name }, agentFetch) + : agentFetch; + + if (agent.type === "remote_ag_ui") { + return new HttpAgent({ + url: agent.endpoint, + agentId: agent.id, + // The customer's own key, if their agent sits behind one. `HttpAgentConfig` is + // `{ url, headers?, fetch? }`, verified against @ag-ui/client 0.0.57. + ...(agent.headers ? { headers: agent.headers } : {}), + ...(dial ? { fetch: dial } : {}), + }); + } + + const [{ MastraClient }, { getRemoteAgents }] = await Promise.all([ + import("@mastra/client-js"), + import("@ag-ui/mastra"), + ]); + + const client = new MastraClient({ + baseUrl: agent.endpoint, + ...(agent.headers ? { headers: agent.headers } : {}), + // `MastraClient` types this as the global `fetch`, which carries `preconnect`; the watched fetch + // is a call signature only, and is never used as anything but a fetch. + ...(dial ? { fetch: dial as unknown as typeof fetch } : {}), + }); + + const roster = await getRemoteAgents({ + /* + * The same class, twice, under two names. + * + * This server runs zod 4 and `@ag-ui/mastra` depends on zod 3, so the package manager resolves + * two peer variants of `@mastra/client-js` — identical code at identical version 1.43.0, but + * two nominal types to TypeScript, which tells them apart by a private field. The cast crosses + * that and nothing else. It is deliberately written against the bridge's own parameter type, so + * the day the two versions really do diverge this stops compiling instead of lying. + * + * The alternative, forcing zod 4 onto `@ag-ui/mastra` with an override, makes the type error go + * away by risking a real one at runtime in somebody else's package. Not worth it for a private + * field. + */ + mastraClient: client as unknown as MastraClientForBridge, + /* + * Mastra scopes its memory by `resourceId`, and this deployment hands it the Bot rather than the + * person deliberately. History here is ours: it is restored from Intelligence and sanitised + * before every run, so nothing depends on the endpoint remembering anything. Sending the + * person's identity would put it on a server this deployment does not run, to drive a feature it + * does not use, which is the same reason standing instructions stop at the built-in path. + */ + resourceId: agent.id, + }); + + const picked = pickFromRoster(Object.keys(roster), agent); + return roster[picked] as AbstractAgent; +} + +/** + * Which agent on a Mastra server a Bot means. + * + * Pure and separate from the dialling so it can be tested without a server, because the failure it + * prevents is not one a live test would show: picking the wrong agent produces a Bot that answers + * confidently as somebody else, which reads as a bad model rather than as the misconfiguration it + * is. Throws rather than guessing, and names what the endpoint does serve, because that is the one + * fact whoever is reading the error does not have. + */ +export function pickFromRoster( + served: readonly string[], + agent: { id: string; remoteAgentId?: string }, +): string { + const wanted = agent.remoteAgentId ?? agent.id; + if (served.includes(wanted)) { + return wanted; + } + /* + * One agent and no name asked for is the ordinary single-agent server, and taking it is what was + * meant. A name that was asked for and is not there is never silently replaced by the only agent + * present: that turns a typo into a Bot that works and is wrong. + */ + const only = served.length === 1 ? served[0] : undefined; + if (!agent.remoteAgentId && only) { + return only; + } + throw new Error( + `Mastra endpoint for Bot "${agent.id}" serves no agent named "${wanted}". It serves: ${ + served.join(", ") || "none" + }.`, + ); +} + +function remoteAgentWithStandingRole( + agent: RegisteredRemoteAgent, + /** + * The dialled endpoint, already built. See {@link remoteTransport}. + * + * Passed in rather than constructed here because building a Mastra transport is asynchronous and + * this function is not, but the better reason is that it makes the governance below indifferent + * to the transport: there is one wrapper, and no kind of remote Bot has its own copy of it to + * drift from. + */ + remote: AbstractAgent, /** * What this Bot was granted, described rather than executable. * @@ -618,28 +768,7 @@ function remoteAgentWithStandingRole( * Absent means no narrowing, which is the behaviour every deployment had before this existed. */ narrow?: (input: RunAgentInput) => Promise, - /** The fetch this agent is dialled with. See {@link buildAgents}. */ - agentFetch?: AgentFetch, ) { - const remote = new HttpAgent({ - url: agent.endpoint, - agentId: agent.id, - // The customer's own key, if their agent sits behind one. `HttpAgentConfig` is - // `{ url, headers?, fetch? }`, verified against @ag-ui/client 0.0.57. - ...(agent.headers ? { headers: agent.headers } : {}), - // The watch wraps whichever fetch is underneath, so a deployment gets both the stall timeout and - // the redirect check rather than having to choose. - ...(stallGuard - ? { - fetch: stallGuard.watch( - { id: agent.id, name: agent.name }, - agentFetch, - ), - } - : agentFetch - ? { fetch: agentFetch } - : {}), - }); /* * What this Bot holds, as a second standing message. * diff --git a/server/tests/mastra-roster.test.ts b/server/tests/mastra-roster.test.ts new file mode 100644 index 000000000..e9681a628 --- /dev/null +++ b/server/tests/mastra-roster.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, test } from "bun:test"; +import { pickFromRoster } from "../src/copilot"; + +describe("which agent on a Mastra server a Bot means", () => { + test("the name it asks for, when the endpoint serves it", () => { + expect( + pickFromRoster(["research", "openbot"], { + id: "bot-7", + remoteAgentId: "openbot", + }), + ).toBe("openbot"); + }); + + test("its own id, for a server that names the agent after the Bot", () => { + expect(pickFromRoster(["bot-7", "other"], { id: "bot-7" })).toBe("bot-7"); + }); + + test("the only agent on a single-agent server, when no name was asked for", () => { + expect(pickFromRoster(["openbot"], { id: "bot-7" })).toBe("openbot"); + }); + + test("a name that was asked for is never replaced by the only agent present", () => { + // The must-not case. Falling back here turns a typo into a Bot that runs and answers as + // somebody else, which is indistinguishable from a bad model at the point somebody notices. + expect(() => + pickFromRoster(["openbot"], { id: "bot-7", remoteAgentId: "typo" }), + ).toThrow(/serves no agent named "typo"/); + }); + + test("several agents and no name asked for is refused, not guessed", () => { + expect(() => pickFromRoster(["a", "b"], { id: "bot-7" })).toThrow( + /It serves: a, b/, + ); + }); + + test("an endpoint serving nothing says so", () => { + expect(() => pickFromRoster([], { id: "bot-7" })).toThrow( + /It serves: none/, + ); + }); +}); From f403f8bfe92be9b888eae3c1573e3c8ede5c50cf Mon Sep 17 00:00:00 2001 From: David McKay Date: Sun, 6 Sep 2026 17:29:59 -0700 Subject: [PATCH 06/46] Offer Mastra in the picker now that it can be dialled It was out because the only thing a harness could mount served the CopilotKit Runtime protocol rather than AG-UI, so the row would have been a Bot that could not answer. The bridge is on OpenBot's side now, so the row is real. The test is inverted rather than deleted: if Mastra leaves the catalogue again it means remoteTransport regressed, and that should fail here instead of the picker quietly getting shorter. --- desktop/src-tauri/src/harness.rs | 35 +++++++++++++++++++------------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/desktop/src-tauri/src/harness.rs b/desktop/src-tauri/src/harness.rs index 880bd4454..1e54eec3a 100644 --- a/desktop/src-tauri/src/harness.rs +++ b/desktop/src-tauri/src/harness.rs @@ -71,17 +71,16 @@ pub struct Harness { /// and Cloudflare Agents are all In Progress, and a picker that offers a harness which cannot yet /// answer is worse than a shorter picker. /// -/// Mastra is left out for a different reason, and it is not a judgement on Mastra. Its server-side -/// route does exist and it runs: `registerCopilotKit` from `@ag-ui/mastra/copilotkit`, registered -/// with Mastra's own server, answers and reports its agent. What it serves is the **CopilotKit -/// Runtime** protocol, not AG-UI: it wants `{"method": ...}` and refuses a `RunAgentInput`. A Bot -/// in OpenBot is an AG-UI URL, so the two do not meet. +/// Mastra is here on different terms from the rest, and the difference is in the server rather than +/// in this list. Every other row is an image serving an AG-UI route; Mastra's image is a plain +/// Mastra server, and OpenBot dials it through `getRemoteAgents` from `@ag-ui/mastra`, the bridge +/// Mastra and AG-UI maintain between them. See `remoteTransport` in server/src/copilot.ts. /// -/// Two ways to close that, neither of them a harness image. OpenBot could accept a CopilotKit -/// Runtime endpoint as a second kind of Bot, which is defensible because that runtime is -/// CopilotKit's own rather than a third party's. Or Mastra could publish a plain AG-UI route. Until -/// one happens, offering Mastra would mean writing the AG-UI layer here, which is the single thing -/// this list exists to avoid. +/// It reads as a harness like any other because the difference ends at the transport: a Mastra Bot +/// arrives as the same `AbstractAgent` and is governed by the same wrapper as an AG-UI one. What +/// this list still refuses is writing that translation by hand, which is what mounting +/// `registerCopilotKit` in the harness amounted to: that route serves the CopilotKit Runtime +/// protocol, not AG-UI, and a run reached it and came back asking for a `method` field. pub fn catalogue() -> Vec { let ours = |id: &str, name: &str, summary: &str, maintainer: Maintainer| Harness { id: id.into(), @@ -163,6 +162,12 @@ pub fn catalogue() -> Vec { "Multi-agent, deliberately small.", Maintainer::Community, ), + ours( + "mastra", + "Mastra", + "TypeScript agents, with their own server.", + Maintainer::Partnership, + ), Harness { id: "byo-url".into(), name: "An agent you already run".into(), @@ -236,12 +241,14 @@ mod tests { } } - /// Mastra is absent while its only server-side package is a year behind its own core. Asserted - /// so that putting it back is a deliberate act with this test in front of somebody. + /// Mastra is offered, and the row is the assertion that the bridge on OpenBot's side works. + /// It was out while the only thing a harness could mount served the wrong protocol; it is in + /// because `remoteTransport` dials Mastra's own API instead. Removing the row means that path + /// regressed, so this fails rather than the picker quietly shrinking. #[test] - fn mastra_stays_out_while_it_cannot_be_served() { + fn mastra_is_offered_now_that_it_is_dialled_through_its_own_bridge() { let ids: Vec = catalogue().into_iter().map(|h| h.id).collect(); - assert!(!ids.contains(&"mastra".to_string())); + assert!(ids.contains(&"mastra".to_string()), "Mastra is not offered"); } /// Codex and Gemini CLI have no integration and we do not write adapters, so they cannot appear From 62c0cf006e0658bdc8ed1a94f5fd224a625b0030 Mon Sep 17 00:00:00 2001 From: David McKay Date: Sun, 6 Sep 2026 21:07:47 -0700 Subject: [PATCH 07/46] Draw the two screens setup actually asks: which Bot, and which model The setup form asked for an OpenAI key in a password field, which is the developer-shaped main path the audience rule exists to prevent. The model question is now a screen with two first-class providers and one row for everything else, and a plan is the default wherever a plan exists. Two providers and an OpenAI-compatible endpoint is the shape, not a shortlist: every other vendor speaks that wire format, and the endpoint row is the only one that asks for a URL. A test pins that, because a second row asking for one means the main path grew a step nobody non-technical can finish. Marks come from @lobehub/icons-static-svg, MIT, vendored rather than fetched because the window draws before it has a network. Nine of the twelve harnesses have one. Agno, AG2 and Langroid have none in any maintained set, so those rows show the name: nothing is invented, since a monogram we drew reads as the vendor's own. Every row shows its name whether or not it has a mark, so an unmarked row is not a lesser one, and a person who does not recognise a logo can still read the row. They are inlined as data URIs and drawn with img, so no markup is ever injected and what is in the file never becomes a security question. The tiles are real radios inside labels rather than buttons wearing role="radio", which gets arrow-key navigation and the whole tile as the hit area for free. --- desktop/preview.html | 2 + desktop/src-tauri/src/harness.rs | 45 ++++ desktop/src-tauri/src/lib.rs | 1 + desktop/src-tauri/src/main.rs | 18 +- desktop/src-tauri/src/provider.rs | 162 +++++++++++++ desktop/src/App.tsx | 2 +- desktop/src/HarnessPicker.tsx | 97 ++++++++ desktop/src/Mark.tsx | 36 +++ desktop/src/ProviderPicker.tsx | 222 ++++++++++++++++++ desktop/src/marks.ts | 29 +++ desktop/src/marks/README.md | 15 ++ desktop/src/marks/anthropic.svg | 1 + desktop/src/marks/claude-agent-sdk.svg | 1 + desktop/src/marks/crewai.svg | 1 + desktop/src/marks/google-adk.svg | 1 + desktop/src/marks/langgraph.svg | 1 + desktop/src/marks/llamaindex.svg | 1 + desktop/src/marks/mastra.svg | 1 + .../src/marks/microsoft-agent-framework.svg | 1 + desktop/src/marks/openai.svg | 1 + desktop/src/marks/pydantic-ai.svg | 1 + desktop/src/marks/strands.svg | 1 + desktop/src/preview.tsx | 211 +++++++++++++++++ desktop/src/styles.css | 190 +++++++++++++++ 24 files changed, 1039 insertions(+), 2 deletions(-) create mode 100644 desktop/preview.html create mode 100644 desktop/src-tauri/src/provider.rs create mode 100644 desktop/src/HarnessPicker.tsx create mode 100644 desktop/src/Mark.tsx create mode 100644 desktop/src/ProviderPicker.tsx create mode 100644 desktop/src/marks.ts create mode 100644 desktop/src/marks/README.md create mode 100644 desktop/src/marks/anthropic.svg create mode 100644 desktop/src/marks/claude-agent-sdk.svg create mode 100644 desktop/src/marks/crewai.svg create mode 100644 desktop/src/marks/google-adk.svg create mode 100644 desktop/src/marks/langgraph.svg create mode 100644 desktop/src/marks/llamaindex.svg create mode 100644 desktop/src/marks/mastra.svg create mode 100644 desktop/src/marks/microsoft-agent-framework.svg create mode 100644 desktop/src/marks/openai.svg create mode 100644 desktop/src/marks/pydantic-ai.svg create mode 100644 desktop/src/marks/strands.svg create mode 100644 desktop/src/preview.tsx diff --git a/desktop/preview.html b/desktop/preview.html new file mode 100644 index 000000000..a78b5d336 --- /dev/null +++ b/desktop/preview.html @@ -0,0 +1,2 @@ +Setup screens +
diff --git a/desktop/src-tauri/src/harness.rs b/desktop/src-tauri/src/harness.rs index 1e54eec3a..3bcc40485 100644 --- a/desktop/src-tauri/src/harness.rs +++ b/desktop/src-tauri/src/harness.rs @@ -62,6 +62,12 @@ pub struct Harness { pub health_path: Option, pub credential: Credential, pub maintainer: Maintainer, + /// The vendored mark's file stem, or `None` where no maintained set has one. + /// + /// A row with `None` shows its name alone. Nothing is drawn to fill the gap: see + /// `desktop/src/marks/README.md` for why an invented monogram is the one thing that would be a + /// problem. The name is on every row regardless, so an unmarked row is not a lesser one. + pub mark: Option, } /// The list, ranked as the build doc ranks it: stars first, with downloads as the sanity check, @@ -82,6 +88,10 @@ pub struct Harness { /// `registerCopilotKit` in the harness amounted to: that route serves the CopilotKit Runtime /// protocol, not AG-UI, and a run reached it and came back asking for a `method` field. pub fn catalogue() -> Vec { + // Marks are vendored under the row's own id, so a row finds its own without a second mapping. + // The three with none are named here rather than discovered at draw time, because a missing + // file and a brand with no mark are different things and only one of them is a bug. + const UNMARKED: [&str; 3] = ["agno", "ag2", "langroid"]; let ours = |id: &str, name: &str, summary: &str, maintainer: Maintainer| Harness { id: id.into(), name: name.into(), @@ -90,6 +100,7 @@ pub fn catalogue() -> Vec { health_path: Some("/health".into()), credential: Credential::AnyProvider, maintainer, + mark: (!UNMARKED.contains(&id)).then(|| id.to_string()), }; vec![ @@ -143,6 +154,7 @@ pub fn catalogue() -> Vec { health_path: Some("/health".into()), credential: Credential::Anthropic, maintainer: Maintainer::Community, + mark: Some("claude-agent-sdk".into()), }, ours( "strands", @@ -177,6 +189,8 @@ pub fn catalogue() -> Vec { health_path: None, credential: Credential::TheirEndpoint, maintainer: Maintainer::Community, + // Stands for whatever the person already runs, so no vendor's mark is honest here. + mark: None, }, ] } @@ -241,6 +255,37 @@ mod tests { } } + /// A named mark has to be a file that is actually there. The failure this catches is silent at + /// runtime: a row asks for a mark that was never vendored, and the tile draws empty, which + /// looks like a rendering bug rather than a missing asset. + #[test] + fn every_named_mark_is_vendored() { + for harness in catalogue() { + let Some(mark) = harness.mark else { continue }; + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../src/marks") + .join(format!("{mark}.svg")); + assert!( + path.exists(), + "{} names mark {mark}, which is not vendored", + harness.id + ); + } + } + + /// The unmarked rows are the three brands with no mark in any maintained set. If a fourth + /// appears, somebody dropped a mark rather than a brand losing one, and that is worth stopping + /// for. + #[test] + fn only_the_three_brands_without_a_mark_are_unmarked() { + let unmarked: Vec = catalogue() + .into_iter() + .filter(|h| h.mark.is_none() && h.image.is_some()) + .map(|h| h.id) + .collect(); + assert_eq!(unmarked, vec!["agno", "ag2", "langroid"]); + } + /// Mastra is offered, and the row is the assertion that the bridge on OpenBot's side works. /// It was out while the only thing a harness could mount served the wrong protocol; it is in /// because `remoteTransport` dials Mastra's own API instead. Removing the row means that path diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index cf1071fc5..12ae92b3d 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -4,6 +4,7 @@ pub mod deployment; pub mod engine; pub mod env; pub mod harness; +pub mod provider; pub mod quiet; pub mod stack; pub mod supervise; diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index f555e2cff..01e57aa53 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -5,7 +5,8 @@ use std::path::{Path, PathBuf}; use std::sync::Mutex; use openbot_desktop_lib::{ - acquire, deployment, engine, env as openbot_env, quiet, stack, supervise, windows as win, + acquire, deployment, engine, env as openbot_env, harness, provider, quiet, stack, supervise, + windows as win, }; /// The deployment this app installs. @@ -427,6 +428,19 @@ fn default_root() -> String { stack::default_root().to_string_lossy().into_owned() } +/// The harness picker's rows. Data, so the screen is a list and not twelve branches. +#[tauri::command] +fn harnesses() -> Vec { + harness::catalogue() +} + +/// The model screen's rows. Independent of the picker above, and required to stay that way: no +/// harness on that list is tied to a vendor's models, so choosing one may not narrow this. +#[tauri::command] +fn providers() -> Vec { + provider::catalogue() +} + /// `bun` from PATH, or the places an installer puts it when PATH has not been reloaded. fn which_bun() -> Option { if quiet::command("bun") @@ -634,6 +648,8 @@ fn main() { already_running, last_failure, default_root, + harnesses, + providers, ]) // A packaged application is not a browser tab. Left alone, WebView2 answers a right-click // with Back, Refresh, Save as and Print: Back walks the window out of OpenBot with nothing diff --git a/desktop/src-tauri/src/provider.rs b/desktop/src-tauri/src/provider.rs new file mode 100644 index 000000000..2dec6d155 --- /dev/null +++ b/desktop/src-tauri/src/provider.rs @@ -0,0 +1,162 @@ +//! The model provider screen's list, as data. +//! +//! Two providers are first-class and everything else is one row. That is not a shortlist waiting to +//! be grown: it is the shape, and growing it is how this screen turns into a directory nobody +//! maintains. Most people have a plan with one of two companies; everybody else has something that +//! speaks the OpenAI wire format, because at this point everything does. +//! +//! The row that asks for a URL is the last one, and it is the only one that asks. Keeping it there +//! is what keeps a base URL off the main path, which the audience rule at the top of the build doc +//! requires: somebody who has never opened a terminal finishes this screen by signing in. +//! +//! Independent of the harness picker, always. No harness on that list is tied to a vendor's models, +//! so nothing chosen there may narrow what is offered here. + +use serde::{Deserialize, Serialize}; + +/// How a person proves they may use a provider. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum Login { + /// Sign in to the plan they already pay for. The default wherever it exists. + Plan, + /// Paste a key. Offered beside the plan, never behind it. + ApiKey, + /// A base URL, a key and a model name. The developer row and the everything-else row at once. + Endpoint, +} + +/// One row on the model screen. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Provider { + pub id: String, + /// Shown on every row, mark or no mark. See `mark`. + pub name: String, + pub summary: String, + /// The ways in, in the order the screen offers them. First is the default. + pub logins: Vec, + /// The vendored icon's file stem, or `None` where no maintained set has one. + /// + /// A row with `None` is drawn with its name and nothing else. Nothing is invented to fill the + /// space: a monogram somebody made up reads as the vendor's own mark. + pub mark: Option, + /// One sentence the screen must show when this provider is chosen, and the page it links to. + pub caution: Option, +} + +/// Something true about a provider that a person finds out too late otherwise. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Caution { + pub says: String, + pub reads_more_at: String, +} + +pub fn catalogue() -> Vec { + vec![ + Provider { + id: "openai".into(), + name: "OpenAI".into(), + summary: "Sign in with ChatGPT Plus, Pro, Team or Enterprise.".into(), + logins: vec![Login::Plan, Login::ApiKey], + mark: Some("openai".into()), + caution: None, + }, + Provider { + id: "anthropic".into(), + name: "Anthropic".into(), + summary: "Sign in with a Claude Pro, Max, Team or Enterprise plan.".into(), + logins: vec![Login::Plan, Login::ApiKey], + mark: Some("anthropic".into()), + /* + * The one provider that can stop working part-way through a month for somebody who has + * done nothing wrong, so it is said before it happens rather than diagnosed after. It + * changes nothing else: same default, same flow, same list. + */ + caution: Some(Caution { + says: "A Claude plan carries a separate monthly pool of Agent SDK credits. \ + When that pool is empty, Bots stop until it renews or you add API credits." + .into(), + reads_more_at: "https://support.anthropic.com/en/articles/11145838-using-claude-code-with-your-pro-or-max-plan" + .into(), + }), + }, + Provider { + id: "openai-compatible".into(), + name: "Any OpenAI-compatible endpoint".into(), + summary: "Azure, Bedrock, Mistral, DeepSeek, xAI, Ollama, vLLM or your own.".into(), + logins: vec![Login::Endpoint], + // Deliberately unmarked: it stands for every provider rather than one, so any single + // vendor's logo here would be a lie about what the row does. + mark: None, + caution: None, + }, + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The audience rule, as a test. A base URL is a developer's tool, and exactly one row may ask + /// for one; if a second ever does, the main path has grown a terminal-shaped step. + #[test] + fn only_one_row_asks_for_a_url() { + let asking: Vec = catalogue() + .into_iter() + .filter(|p| p.logins.contains(&Login::Endpoint)) + .map(|p| p.id) + .collect(); + assert_eq!(asking, vec!["openai-compatible".to_string()]); + } + + /// Where a plan can stand in for a key, it is the default. Reordering these is a product change + /// and has to look like one. + #[test] + fn a_plan_is_offered_before_a_key() { + for provider in catalogue() { + if provider.logins.contains(&Login::Plan) { + assert_eq!( + provider.logins.first(), + Some(&Login::Plan), + "{} offers a plan but not first", + provider.id + ); + } + } + } + + /// Two first-class providers and one escape hatch. Growing this list is how the screen becomes + /// a directory, so it fails here rather than in review. + #[test] + fn two_named_providers_and_one_way_in_for_everything_else() { + let rows = catalogue(); + assert_eq!(rows.len(), 3, "the provider list grew"); + assert_eq!(rows[0].id, "openai"); + assert_eq!(rows[1].id, "anthropic"); + } + + /// Every row is readable without recognising a logo. + #[test] + fn every_row_has_a_name() { + for provider in catalogue() { + assert!( + !provider.name.trim().is_empty(), + "{} has no name", + provider.id + ); + } + } + + /// The Anthropic sentence is required, because it is the one thing about that plan a person + /// cannot discover until their Bots stop answering. + #[test] + fn anthropic_says_what_the_plan_actually_buys() { + let anthropic = catalogue() + .into_iter() + .find(|p| p.id == "anthropic") + .expect("anthropic is not offered"); + let caution = anthropic.caution.expect("anthropic carries no caution"); + assert!(caution.says.contains("Agent SDK credits")); + assert!(caution.reads_more_at.starts_with("https://")); + } +} diff --git a/desktop/src/App.tsx b/desktop/src/App.tsx index 20e6638f0..16512fbaf 100644 --- a/desktop/src/App.tsx +++ b/desktop/src/App.tsx @@ -1,6 +1,6 @@ -import { useEffect, useState } from "react"; import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; +import { useEffect, useState } from "react"; type EngineStatus = { engine: "docker" | "podman" | null; diff --git a/desktop/src/HarnessPicker.tsx b/desktop/src/HarnessPicker.tsx new file mode 100644 index 000000000..8a9340a1e --- /dev/null +++ b/desktop/src/HarnessPicker.tsx @@ -0,0 +1,97 @@ +import { invoke } from "@tauri-apps/api/core"; +import { useEffect, useState } from "react"; +import { Mark } from "./Mark"; + +export type Harness = { + id: string; + name: string; + summary: string; + image: string | null; + health_path: string | null; + credential: "any-provider" | "anthropic" | "their-endpoint"; + maintainer: "first-party" | "partnership" | "community"; + mark: string | null; +}; + +/** + * Pick a Bot. + * + * The list is data from the Rust catalogue, so this screen is a list and not twelve branches, and + * adding a harness never comes back here. + * + * NO DEFAULT AND NO PRESELECTION. The person chooses. A preselected row is a choice made on their + * behalf that they will not notice making, and the harness decides what their Bot is. + */ +export function HarnessPicker({ + chosen, + onChoose, + onContinue, +}: { + chosen: string | null; + onChoose: (id: string) => void; + onContinue: () => void; +}) { + const [rows, setRows] = useState([]); + const [failure, setFailure] = useState(""); + + useEffect(() => { + invoke("harnesses") + .then(setRows) + .catch((e) => setFailure(String(e))); + }, []); + + if (failure) { + return ( +
+

The list of Bots could not be read

+

{failure}

+
+ ); + } + + return ( + <> +

Pick a Bot

+

+ This is the agent that does the work. You can change it later, and you + can add more. +

+ +
+ Bot + {rows.map((row) => ( + + ))} +
+ +
+ +
+ + ); +} diff --git a/desktop/src/Mark.tsx b/desktop/src/Mark.tsx new file mode 100644 index 000000000..2f23aba09 --- /dev/null +++ b/desktop/src/Mark.tsx @@ -0,0 +1,36 @@ +import { markFor } from "./marks"; + +/** + * A brand's mark beside its name, or the name on its own. + * + * The name is drawn by the caller either way. This component owns only the tile, so a row with no + * mark keeps the same shape as a row with one and does not read as unfinished. + * + * Decorative, always: `alt=""` and `aria-hidden`, because the name is already there in text and a + * screen reader announcing "OpenAI OpenAI" is worse than one announcing it once. + */ +export function Mark({ id, name }: { id: string | null; name: string }) { + const uri = markFor(id); + if (!uri) { + /* + * No mark exists for this brand anywhere we can use, so the name carries the tile and nothing + * is invented to fill it: see src/marks/README.md. + * + * Only a name that is actually a brand, though. "An agent you already run" is a description of + * a choice rather than a company, and setting it in a 44px box produced "An agent you alrea dy + * run" stacked five lines deep. A row with no tile is cleaner than a tile with a paragraph in + * it, and that row already reads by its name and summary. + */ + if (name.length > 12) return null; + return ( + + ); + } + return ( +
+ +
+ ); +} diff --git a/desktop/src/ProviderPicker.tsx b/desktop/src/ProviderPicker.tsx new file mode 100644 index 000000000..097431538 --- /dev/null +++ b/desktop/src/ProviderPicker.tsx @@ -0,0 +1,222 @@ +import { invoke } from "@tauri-apps/api/core"; +import { useEffect, useState } from "react"; +import { Mark } from "./Mark"; + +export type Login = "plan" | "api-key" | "endpoint"; + +export type Provider = { + id: string; + name: string; + summary: string; + logins: Login[]; + mark: string | null; + caution: { says: string; reads_more_at: string } | null; +}; + +/** What the flow carries forward once this screen is done. */ +export type ModelChoice = { + provider: string; + login: Login; + apiKey?: string; + baseUrl?: string; + model?: string; +}; + +/** + * Connect a model. + * + * Two providers are first-class and everything else is one row, which is the shape rather than a + * shortlist. See the build doc: growing this into a directory is how the screen stops being + * finishable by somebody who has never opened a terminal. + * + * A PLAN IS THE DEFAULT WHEREVER ONE EXISTS, and the key sits beside it rather than behind it. + * Anybody with a key and a base URL to hand is a developer; everybody else has a plan they already + * pay for, and asking them for a key is asking them to go and get one. + */ +export function ProviderPicker({ + chosen, + onChoose, + onBack, +}: { + chosen: ModelChoice | null; + onChoose: (choice: ModelChoice) => void; + onBack: () => void; +}) { + const [rows, setRows] = useState([]); + const [open, setOpen] = useState(chosen?.provider ?? null); + const [login, setLogin] = useState(chosen?.login ?? null); + const [apiKey, setApiKey] = useState(chosen?.apiKey ?? ""); + const [baseUrl, setBaseUrl] = useState(chosen?.baseUrl ?? ""); + const [model, setModel] = useState(chosen?.model ?? ""); + + useEffect(() => { + invoke("providers") + .then(setRows) + .catch(() => undefined); + }, []); + + const row = rows.find((r) => r.id === open) ?? null; + + // What "done" means differs by the way in, and each is checked before Continue lights up rather + // than after a run fails with something unreadable. + const ready = + login === "plan" || + (login === "api-key" && apiKey.trim().length > 0) || + (login === "endpoint" && + baseUrl.trim().startsWith("http") && + apiKey.trim().length > 0 && + model.trim().length > 0); + + return ( + <> +

Connect a model

+

+ This is what your Bots think with. It is a separate choice from the Bot + you picked, and any Bot works with any of these. +

+ +
+ Model provider + {rows.map((r) => ( + + ))} +
+ + {row && ( +
+ {row.logins.length > 1 && ( +
+ {row.logins.map((option) => ( + + ))} +
+ )} + + {login === "plan" && ( +

+ Opens {row.name} in your browser. Nothing is typed here and no key + is stored. +

+ )} + + {login === "api-key" && ( +
+ + setApiKey(e.target.value)} + autoComplete="off" + spellCheck={false} + /> +
+ )} + + {login === "endpoint" && ( + <> +
+ + setBaseUrl(e.target.value)} + placeholder="https://…/v1" + spellCheck={false} + /> +
+
+ + setModel(e.target.value)} + placeholder="the name the endpoint knows it by" + spellCheck={false} + /> +
+
+ + setApiKey(e.target.value)} + autoComplete="off" + spellCheck={false} + /> +
+ + )} + + {/* Said before it happens rather than diagnosed after the Bots stop answering. */} + {row.caution && ( +

+ {row.caution.says}{" "} + + What this means + +

+ )} +
+ )} + +
+ + +
+ + ); +} diff --git a/desktop/src/marks.ts b/desktop/src/marks.ts new file mode 100644 index 000000000..0ac40ead0 --- /dev/null +++ b/desktop/src/marks.ts @@ -0,0 +1,29 @@ +/** + * The vendored marks, inlined at build time as data URIs. + * + * Inlined rather than fetched because the setup window draws before anything guarantees a network, + * and a picker whose tiles fill in late reads as broken. `eager` so the first paint has them. + * + * As URIs rather than SVG source on purpose: an `` cannot execute anything, so drawing these + * needs no `dangerouslySetInnerHTML` and the question of what is in the file never becomes a + * security question at all. + * + * A row without an entry here is not an error: three of the twelve brands have no mark in any + * maintained set, and their rows show the name alone. See `src/marks/README.md`. + */ +const files = import.meta.glob("./marks/*.svg", { + eager: true, + query: "?inline", + import: "default", +}) as Record; + +const byId = new Map( + Object.entries(files).map(([path, uri]) => [ + path.replace("./marks/", "").replace(".svg", ""), + uri, + ]), +); + +export function markFor(id: string | null | undefined): string | null { + return (id && byId.get(id)) ?? null; +} diff --git a/desktop/src/marks/README.md b/desktop/src/marks/README.md new file mode 100644 index 000000000..80899ab41 --- /dev/null +++ b/desktop/src/marks/README.md @@ -0,0 +1,15 @@ +# Vendored brand marks + +From [`@lobehub/icons-static-svg`](https://github.com/lobehub/lobe-icons), MIT licensed, vendored +rather than fetched because the setup window draws before it has any network guarantee. + +Renamed to the harness or provider id they belong to, so a row finds its mark by its own id and +there is no second mapping to keep in step. + +Each mark is its owner's trademark, used to identify that product in a picker and for nothing else. +**Nothing here is invented.** Agno, AG2 and Langroid have no mark in any maintained set, so their +rows show the name alone. A monogram we drew would read as the vendor's own, which is the thing a +trademark holder actually objects to. + +Every row shows its name whether or not it has a mark. The mark sits beside the name and never +replaces it. diff --git a/desktop/src/marks/anthropic.svg b/desktop/src/marks/anthropic.svg new file mode 100644 index 000000000..5b81844cb --- /dev/null +++ b/desktop/src/marks/anthropic.svg @@ -0,0 +1 @@ +Anthropic \ No newline at end of file diff --git a/desktop/src/marks/claude-agent-sdk.svg b/desktop/src/marks/claude-agent-sdk.svg new file mode 100644 index 000000000..62dc0db12 --- /dev/null +++ b/desktop/src/marks/claude-agent-sdk.svg @@ -0,0 +1 @@ +Claude \ No newline at end of file diff --git a/desktop/src/marks/crewai.svg b/desktop/src/marks/crewai.svg new file mode 100644 index 000000000..95cb17f93 --- /dev/null +++ b/desktop/src/marks/crewai.svg @@ -0,0 +1 @@ +CrewAI \ No newline at end of file diff --git a/desktop/src/marks/google-adk.svg b/desktop/src/marks/google-adk.svg new file mode 100644 index 000000000..e8e0f867b --- /dev/null +++ b/desktop/src/marks/google-adk.svg @@ -0,0 +1 @@ +Google \ No newline at end of file diff --git a/desktop/src/marks/langgraph.svg b/desktop/src/marks/langgraph.svg new file mode 100644 index 000000000..14f16e3cd --- /dev/null +++ b/desktop/src/marks/langgraph.svg @@ -0,0 +1 @@ +LangGraph \ No newline at end of file diff --git a/desktop/src/marks/llamaindex.svg b/desktop/src/marks/llamaindex.svg new file mode 100644 index 000000000..99be51787 --- /dev/null +++ b/desktop/src/marks/llamaindex.svg @@ -0,0 +1 @@ +LlamaIndex \ No newline at end of file diff --git a/desktop/src/marks/mastra.svg b/desktop/src/marks/mastra.svg new file mode 100644 index 000000000..1f5f1628a --- /dev/null +++ b/desktop/src/marks/mastra.svg @@ -0,0 +1 @@ +Mastra \ No newline at end of file diff --git a/desktop/src/marks/microsoft-agent-framework.svg b/desktop/src/marks/microsoft-agent-framework.svg new file mode 100644 index 000000000..4d95a08eb --- /dev/null +++ b/desktop/src/marks/microsoft-agent-framework.svg @@ -0,0 +1 @@ +Azure \ No newline at end of file diff --git a/desktop/src/marks/openai.svg b/desktop/src/marks/openai.svg new file mode 100644 index 000000000..78caf4fa2 --- /dev/null +++ b/desktop/src/marks/openai.svg @@ -0,0 +1 @@ +OpenAI \ No newline at end of file diff --git a/desktop/src/marks/pydantic-ai.svg b/desktop/src/marks/pydantic-ai.svg new file mode 100644 index 000000000..85827432f --- /dev/null +++ b/desktop/src/marks/pydantic-ai.svg @@ -0,0 +1 @@ +PydanticAI \ No newline at end of file diff --git a/desktop/src/marks/strands.svg b/desktop/src/marks/strands.svg new file mode 100644 index 000000000..495b475a1 --- /dev/null +++ b/desktop/src/marks/strands.svg @@ -0,0 +1 @@ +AWS \ No newline at end of file diff --git a/desktop/src/preview.tsx b/desktop/src/preview.tsx new file mode 100644 index 000000000..04cc7baae --- /dev/null +++ b/desktop/src/preview.tsx @@ -0,0 +1,211 @@ +/** + * The setup screens, rendered without the shell around them, so they can be looked at. + * + * Not shipped: vite builds index.html and nothing points here. The catalogues below are a SNAPSHOT + * taken from the Rust side, not a second source of truth — the app itself reads the real thing over + * `invoke`. If a row here disagrees with the picker in the running app, this file is the stale one. + */ +import { useState } from "react"; +import { createRoot } from "react-dom/client"; +import { HarnessPicker } from "./HarnessPicker"; +import { type ModelChoice, ProviderPicker } from "./ProviderPicker"; +import "./styles.css"; + +const CATALOGUES: Record = { + harnesses: [ + { + id: "crewai", + name: "CrewAI", + summary: "Crews of agents with roles and tasks.", + image: "openbot-harness-crewai", + health_path: "/health", + credential: "any-provider", + maintainer: "partnership", + mark: "crewai", + }, + { + id: "llamaindex", + name: "LlamaIndex", + summary: "Agents built around your own documents.", + image: "openbot-harness-llamaindex", + health_path: "/health", + credential: "any-provider", + maintainer: "first-party", + mark: "llamaindex", + }, + { + id: "agno", + name: "Agno", + summary: "Fast, small, and multi-modal.", + image: "openbot-harness-agno", + health_path: "/health", + credential: "any-provider", + maintainer: "first-party", + mark: null, + }, + { + id: "langgraph", + name: "LangGraph", + summary: "Graphs you can change, from LangChain.", + image: "openbot-harness-langgraph", + health_path: "/health", + credential: "any-provider", + maintainer: "partnership", + mark: "langgraph", + }, + { + id: "google-adk", + name: "Google ADK", + summary: "Google's agent kit. Gemini first, any model after.", + image: "openbot-harness-google-adk", + health_path: "/health", + credential: "any-provider", + maintainer: "first-party", + mark: "google-adk", + }, + { + id: "pydantic-ai", + name: "Pydantic AI", + summary: "Typed agents, validated in and out.", + image: "openbot-harness-pydantic-ai", + health_path: "/health", + credential: "any-provider", + maintainer: "first-party", + mark: "pydantic-ai", + }, + { + id: "microsoft-agent-framework", + name: "Microsoft Agent Framework", + summary: "Microsoft's, model-agnostic by design.", + image: "openbot-harness-microsoft-agent-framework", + health_path: "/health", + credential: "any-provider", + maintainer: "first-party", + mark: "microsoft-agent-framework", + }, + { + id: "claude-agent-sdk", + name: "Claude Agent SDK", + summary: + "Anthropic's own. The one that takes a Claude plan instead of a key.", + image: "openbot-harness-claude-agent-sdk", + health_path: "/health", + credential: "anthropic", + maintainer: "community", + mark: "claude-agent-sdk", + }, + { + id: "strands", + name: "AWS Strands", + summary: "Amazon's. Bedrock first, any model after.", + image: "openbot-harness-strands", + health_path: "/health", + credential: "any-provider", + maintainer: "first-party", + mark: "strands", + }, + { + id: "ag2", + name: "AG2", + summary: "The AutoGen line, continued.", + image: "openbot-harness-ag2", + health_path: "/health", + credential: "any-provider", + maintainer: "first-party", + mark: null, + }, + { + id: "langroid", + name: "Langroid", + summary: "Multi-agent, deliberately small.", + image: "openbot-harness-langroid", + health_path: "/health", + credential: "any-provider", + maintainer: "community", + mark: null, + }, + { + id: "mastra", + name: "Mastra", + summary: "TypeScript agents, with their own server.", + image: "openbot-harness-mastra", + health_path: "/health", + credential: "any-provider", + maintainer: "partnership", + mark: "mastra", + }, + { + id: "byo-url", + name: "An agent you already run", + summary: + "Give its address. It is proved with a real AG-UI run before it is saved.", + image: null, + health_path: null, + credential: "their-endpoint", + maintainer: "community", + mark: null, + }, + ], + providers: [ + { + id: "openai", + name: "OpenAI", + summary: "Sign in with ChatGPT Plus, Pro, Team or Enterprise.", + logins: ["plan", "api-key"], + mark: "openai", + caution: null, + }, + { + id: "anthropic", + name: "Anthropic", + summary: "Sign in with a Claude Pro, Max, Team or Enterprise plan.", + logins: ["plan", "api-key"], + mark: "anthropic", + caution: { + says: "A Claude plan carries a separate monthly pool of Agent SDK credits. When that pool is empty, Bots stop until it renews or you add API credits.", + reads_more_at: + "https://support.anthropic.com/en/articles/11145838-using-claude-code-with-your-pro-or-max-plan", + }, + }, + { + id: "openai-compatible", + name: "Any OpenAI-compatible endpoint", + summary: + "Azure, Bedrock, Mistral, DeepSeek, xAI, Ollama, vLLM or your own.", + logins: ["endpoint"], + mark: null, + caution: null, + }, + ], +}; +// The preview has no Tauri behind it; these are the exact bytes the commands return. +(window as unknown as { __TAURI_INTERNALS__: unknown }).__TAURI_INTERNALS__ = { + invoke: (cmd: string) => + Promise.resolve(CATALOGUES[cmd.replace("plugin:", "")]), + transformCallback: (cb: unknown) => cb, +}; + +function Preview() { + const [harness, setHarness] = useState(null); + const [model, setModel] = useState(null); + const [screen, setScreen] = useState<"harness" | "provider">("harness"); + return ( +
+ {screen === "harness" ? ( + setScreen("provider")} + /> + ) : ( + setScreen("harness")} + /> + )} +
+ ); +} +const mount = document.getElementById("root"); +if (mount) createRoot(mount).render(); diff --git a/desktop/src/styles.css b/desktop/src/styles.css index f83fa825e..03abe84d3 100644 --- a/desktop/src/styles.css +++ b/desktop/src/styles.css @@ -126,3 +126,193 @@ button:disabled { color: var(--muted); line-height: 1.5; } + +/* The picker: a grid of rows from the catalogue, and never a screen per harness. */ +.lede { + color: var(--muted); + margin: 0 0 1.25rem; + max-width: 46ch; +} + +.picker { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 0.75rem; + margin-bottom: 1.5rem; +} + +.picker.providers { + grid-template-columns: 1fr; +} + +.tile { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.35rem; + padding: 0.9rem; + border: 1px solid var(--line); + border-radius: 12px; + background: transparent; + color: var(--ink); + text-align: left; + cursor: pointer; +} + +/* Base first: the `.tile.wide` rules below are more specific and must win. */ +.tile-name { + font-weight: 600; + font-size: 0.9rem; +} +.tile-summary { + color: var(--muted); + font-size: 0.8rem; + line-height: 1.35; +} +.tile-note { + font-size: 0.7rem; + color: var(--muted); + border: 1px solid var(--line); + border-radius: 999px; + padding: 0.1rem 0.45rem; +} + +/* A brand's mark, or its name where no mark exists. Same box either way, so an unmarked row does + not read as unfinished. */ +.mark-tile { + width: 44px; + height: 44px; + display: flex; + align-items: center; + justify-content: center; + padding: 7px; + border-radius: 10px; + background: #fff; + border: 1px solid var(--line); + flex: none; +} + +.tile.wide { + display: grid; + grid-template-columns: 44px 1fr; + grid-template-areas: "mark name" "mark summary"; + align-items: center; + column-gap: 0.9rem; + row-gap: 0.15rem; +} + +.tile.wide .mark-tile { + grid-area: mark; +} +.tile.wide .tile-name { + grid-area: name; +} +.tile.wide .tile-summary { + grid-area: summary; +} + +.tile:hover { + border-color: var(--muted); +} + +.tile.chosen { + border-color: var(--accent); + box-shadow: inset 0 0 0 1px var(--accent); +} + +.mark-tile img { + width: 100%; + height: 100%; +} + +/* Less padding than a mark gets: a word needs the width, and "Langroid" broke to "Langr oid". */ +.mark-wordmark { + padding: 3px; +} + +.mark-wordmark span { + font-size: 0.58rem; + hyphens: none; + letter-spacing: -0.02em; + font-weight: 600; + line-height: 1.05; + text-align: center; + color: #111827; + word-break: break-word; +} + +.chosen-provider { + border: 1px solid var(--line); + border-radius: 12px; + padding: 1rem; + margin-bottom: 1.25rem; +} + +.segmented { + display: inline-flex; + border: 1px solid var(--line); + border-radius: 999px; + padding: 2px; + margin-bottom: 0.9rem; +} + +.segmented button { + border: 0; + background: transparent; + color: var(--muted); + border-radius: 999px; + padding: 0.3rem 0.8rem; + font-size: 0.8rem; + cursor: pointer; +} + +.segmented button.on { + background: var(--accent); + color: var(--ground); +} + +.caution { + font-size: 0.8rem; + color: var(--muted); + line-height: 1.45; + margin: 0.9rem 0 0; +} + +.caution a { + color: var(--ink); +} + +/* The radio itself is not drawn; the tile is. Kept in the layout rather than `display: none` so it + is still focusable and still announced. */ +.tile-input { + position: absolute; + opacity: 0; + width: 1px; + height: 1px; + margin: 0; +} + +/* A fieldset is the group, but its default border and padding are not wanted here. */ +fieldset.picker { + border: 0; + padding: 0; + margin: 0 0 1.5rem; +} + +/* Keyboard focus has to be visible on the tile, since the control inside it is not. */ +.tile:has(.tile-input:focus-visible) { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + border: 0; +} From 18784faa77b0cc05acf2b410c4e656e7e22254ad Mon Sep 17 00:00:00 2001 From: David McKay Date: Sun, 6 Sep 2026 21:11:33 -0700 Subject: [PATCH 08/46] Make the model a choice, so a plan and a key cannot both be set The credential was one field holding an OpenAI key, and the screen asked for it in a password box. Two problems in one: it was the developer-shaped main path, and it could not express what the other providers need. ModelCredential is a choice rather than a bag of optional strings, because the combination that must never exist is the point. ANTHROPIC_API_KEY takes precedence over a plan's OAuth token in the Claude Agent SDK, so a stack carrying both bills somebody who just signed in to a plan they already pay for. Two fields cannot say "never both"; a choice can, and a test pins it. The plan writes ANTHROPIC_API_KEY as empty rather than omitting it, because the .env writer preserves lines it does not own and a key left from an earlier attempt would win by the same route. start_stack takes the screen's whole answer and works out which keys that implies, rather than being handed keys. The conversion can fail and says so: a plan with no token would otherwise raise a stack whose Bot cannot answer, which reads as a broken product rather than an unfinished sign-in. The window now asks which Bot, then which model, then installs, holding both answers so Back does not lose them. --- desktop/src-tauri/src/env.rs | 188 +++++++++++++++++++++++++++++++--- desktop/src-tauri/src/main.rs | 61 ++++++++++- desktop/src/App.tsx | 71 +++++++++---- 3 files changed, 288 insertions(+), 32 deletions(-) diff --git a/desktop/src-tauri/src/env.rs b/desktop/src-tauri/src/env.rs index 2ffc7f994..ad3e250ab 100644 --- a/desktop/src-tauri/src/env.rs +++ b/desktop/src-tauri/src/env.rs @@ -62,6 +62,13 @@ fn secret() -> String { /// Addresses use `127.0.0.1` rather than `localhost` deliberately. Compose publishes on both /// loopback addresses, so either would connect, but naming one removes a whole class of question /// about which the resolver picked. +/// Blank is not a value. See the note in `compose`. +fn insert_if_given(env: &mut BTreeMap, key: &str, value: &str) { + if !value.trim().is_empty() { + env.insert(key.into(), value.trim().to_string()); + } +} + pub fn compose( intelligence: &Intelligence, model: &Model, @@ -71,13 +78,38 @@ pub fn compose( ) -> BTreeMap { let mut env = BTreeMap::new(); - // Left out entirely when blank: written empty, Compose passes an empty string and the Bot's own - // refusal becomes a confusing one about a key that is set and useless. - if !model.openai_api_key.trim().is_empty() { - env.insert( - "OPENAI_API_KEY".into(), - model.openai_api_key.trim().to_string(), - ); + /* + * Only the keys the choice actually implies, and never a blank one: written empty, Compose + * passes an empty string and the Bot's refusal becomes a confusing one about a key that is set + * and useless. + * + * THE CLAUDE PLAN DELIBERATELY WRITES NO `ANTHROPIC_API_KEY`. The SDK prefers the key over the + * OAuth token, so a stale key from an earlier attempt would quietly bill a person who just + * signed in to a plan. Since `write` below preserves lines it does not own, the key is written + * as empty here rather than omitted: omitting it would leave an older one in place, which is + * the same failure by a different route. + */ + match &model.credential { + ModelCredential::None => {} + ModelCredential::OpenAi { api_key } => { + insert_if_given(&mut env, "OPENAI_API_KEY", api_key); + } + ModelCredential::Anthropic { api_key } => { + insert_if_given(&mut env, "ANTHROPIC_API_KEY", api_key); + } + ModelCredential::ClaudePlan { token } => { + insert_if_given(&mut env, "CLAUDE_CODE_OAUTH_TOKEN", token); + env.insert("ANTHROPIC_API_KEY".into(), String::new()); + } + ModelCredential::Compatible { + base_url, + api_key, + model: name, + } => { + insert_if_given(&mut env, "OPENAI_API_KEY", api_key); + insert_if_given(&mut env, "OPENAI_BASE_URL", base_url); + insert_if_given(&mut env, "BOT_MODEL", name); + } } env.insert("INTELLIGENCE_API_URL".into(), intelligence.api_url.clone()); @@ -200,11 +232,40 @@ pub struct Intelligence { /// The model credential, which belongs to the provider and not to the harness. /// /// Both Bots the deployment ships refuse to start without one, saying so plainly: "This Bot cannot -/// answer without a model." Choosing between providers is its own screen later; this is the one key -/// without which nothing answers at all. +/// answer without a model." Which provider is the person's own screen, and no harness constrains +/// it: see `provider::catalogue`. #[derive(Clone, Debug, Default)] pub struct Model { - pub openai_api_key: String, + pub credential: ModelCredential, +} + +/// How this deployment reaches a model. +/// +/// One type rather than a bag of optional strings, because the combinations that must never be +/// written are the whole point. `ANTHROPIC_API_KEY` takes precedence over the plan's OAuth token in +/// the Claude Agent SDK, so writing both silently bills a person who signed in to a plan they +/// already pay for. Two fields cannot express "never both"; a choice can. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub enum ModelCredential { + /// Nothing chosen. Written as nothing at all rather than as empty strings: an empty key set is + /// a key that is present and useless, and the Bot's refusal then names a key it can see. + #[default] + None, + /// A key typed for OpenAI. + OpenAi { api_key: String }, + /// A key typed for Anthropic. + Anthropic { api_key: String }, + /// A Claude plan, signed in to. The token is minted by `claude setup-token` and never typed. + ClaudePlan { token: String }, + /// Anything that speaks the OpenAI wire format, at an address the person gave. + /// + /// Also where a signed-in ChatGPT plan lands, because that login yields a token and the address + /// to send it to, which is this shape and not a special case. + Compatible { + base_url: String, + api_key: String, + model: String, + }, } /// Write the file, replacing only what this owns. @@ -548,7 +609,9 @@ mod model_tests { let env = compose( &intelligence(), &Model { - openai_api_key: "sk-a-real-one".into(), + credential: ModelCredential::OpenAi { + api_key: "sk-a-real-one".into(), + }, }, &engine(), &Ports::default(), @@ -565,12 +628,113 @@ mod model_tests { let env = compose( &intelligence(), &Model { - openai_api_key: " ".into(), + credential: ModelCredential::OpenAi { + api_key: " ".into(), + }, + }, + &engine(), + &Ports::default(), + &pinned(), + ); + assert!(!env.contains_key("OPENAI_API_KEY")); + } + + /// The must-not case, and the reason `ModelCredential` is a choice rather than two fields. + /// + /// `ANTHROPIC_API_KEY` wins over the plan's OAuth token in the Claude Agent SDK, so a stack + /// carrying both bills a person who signed in to a plan they already pay for. The key is + /// written EMPTY rather than left out, because `write` preserves lines it does not own and an + /// older key would otherwise survive. + #[test] + fn a_claude_plan_never_leaves_an_anthropic_key_in_place() { + let env = compose( + &intelligence(), + &Model { + credential: ModelCredential::ClaudePlan { + token: "oauth-token".into(), + }, + }, + &engine(), + &Ports::default(), + &pinned(), + ); + assert_eq!( + env.get("CLAUDE_CODE_OAUTH_TOKEN"), + Some(&"oauth-token".to_string()) + ); + assert_eq!(env.get("ANTHROPIC_API_KEY"), Some(&String::new())); + } + + /// An Anthropic key is written as one, and does not become an OpenAI key because that is the + /// field this struct used to have. + #[test] + fn an_anthropic_key_is_an_anthropic_key() { + let env = compose( + &intelligence(), + &Model { + credential: ModelCredential::Anthropic { + api_key: "sk-ant-real".into(), + }, }, &engine(), &Ports::default(), &pinned(), ); + assert_eq!( + env.get("ANTHROPIC_API_KEY"), + Some(&"sk-ant-real".to_string()) + ); assert!(!env.contains_key("OPENAI_API_KEY")); } + + /// The everything-else row writes all three, since an endpoint without a model name is an + /// endpoint that answers with a complaint about a model nobody chose. + #[test] + fn a_compatible_endpoint_carries_its_address_and_its_model() { + let env = compose( + &intelligence(), + &Model { + credential: ModelCredential::Compatible { + base_url: "https://example.test/v1".into(), + api_key: "sk-whatever".into(), + model: "some-model".into(), + }, + }, + &engine(), + &Ports::default(), + &pinned(), + ); + assert_eq!( + env.get("OPENAI_BASE_URL"), + Some(&"https://example.test/v1".to_string()) + ); + assert_eq!(env.get("BOT_MODEL"), Some(&"some-model".to_string())); + assert_eq!(env.get("OPENAI_API_KEY"), Some(&"sk-whatever".to_string())); + // Nothing about Anthropic is implied by choosing an OpenAI-compatible endpoint. + assert!(!env.contains_key("ANTHROPIC_API_KEY")); + } + + /// Nothing chosen writes no model keys at all, rather than empty ones. + #[test] + fn no_choice_writes_no_model_keys() { + let env = compose( + &intelligence(), + &Model::default(), + &engine(), + &Ports::default(), + &pinned(), + ); + for key in [ + "OPENAI_API_KEY", + "OPENAI_BASE_URL", + "ANTHROPIC_API_KEY", + "CLAUDE_CODE_OAUTH_TOKEN", + "BOT_MODEL", + ] { + assert!( + !env.contains_key(key), + "{key} was written with no choice made" + ); + } + } } diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index 01e57aa53..9bc5ced10 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -111,6 +111,61 @@ async fn prepare_engine(app: tauri::AppHandle) -> Result, + base_url: Option, + model: Option, + /// Minted by signing in, never typed. Absent for every path but a plan. + token: Option, +} + +impl ChosenModel { + fn into_credential(self) -> Result { + let given = |value: Option| value.unwrap_or_default().trim().to_string(); + match (self.provider.as_str(), self.login.as_str()) { + ("openai", "api-key") => Ok(openbot_env::ModelCredential::OpenAi { + api_key: given(self.api_key), + }), + ("anthropic", "api-key") => Ok(openbot_env::ModelCredential::Anthropic { + api_key: given(self.api_key), + }), + ("anthropic", "plan") => { + let token = given(self.token); + if token.is_empty() { + // Said rather than written blank. A plan with no token produces a stack that + // comes up and a Bot that cannot answer, which reads as a broken product. + return Err("That Claude plan was not signed in to.".into()); + } + Ok(openbot_env::ModelCredential::ClaudePlan { token }) + } + /* + * A signed-in ChatGPT plan is not a special case: the login yields a token and the + * address to send it to, which is exactly the compatible shape. It arrives here with + * `base_url` already filled in by the sign-in, not by a person. + */ + ("openai", "plan") | ("openai-compatible", "endpoint") => { + Ok(openbot_env::ModelCredential::Compatible { + base_url: given(self.base_url), + api_key: given(self.api_key.or(self.token)), + model: given(self.model), + }) + } + (provider, login) => Err(format!( + "{provider} cannot be connected by {login}, which is not a way in that screen offers." + )), + } + } +} + /// Write the `.env`, raise the containers, migrate, then start the three host processes. #[tauri::command] async fn start_stack( @@ -119,7 +174,7 @@ async fn start_stack( api_url: String, gateway_ws_url: String, api_key: String, - openai_api_key: String, + model: ChosenModel, ) -> Result<(), String> { let root = PathBuf::from(root); @@ -180,7 +235,9 @@ async fn start_stack( gateway_ws_url, api_key, }, - &openbot_env::Model { openai_api_key }, + &openbot_env::Model { + credential: model.into_credential()?, + }, &status, &openbot_env::Ports::default(), &deployment::image_variables(&root)?, diff --git a/desktop/src/App.tsx b/desktop/src/App.tsx index 16512fbaf..2e13b74b9 100644 --- a/desktop/src/App.tsx +++ b/desktop/src/App.tsx @@ -1,6 +1,8 @@ import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; import { useEffect, useState } from "react"; +import { HarnessPicker } from "./HarnessPicker"; +import { type ModelChoice, ProviderPicker } from "./ProviderPicker"; type EngineStatus = { engine: "docker" | "podman" | null; @@ -27,7 +29,16 @@ export function App() { const [instruction, setInstruction] = useState(""); const [root, setRoot] = useState(""); const [apiKey, setApiKey] = useState(""); - const [modelKey, setModelKey] = useState(""); + /* + * Which Bot and which model, as two separate answers. + * + * Held here rather than inside the screens so going Back does not lose what was already chosen: + * the flow is resumable at the screen it stopped on, and a wizard that asks twice is one nobody + * finishes. `null` means not answered yet, which is what decides the screen below. + */ + const [harness, setHarness] = useState(null); + const [model, setModel] = useState(null); + const [step, setStep] = useState<"harness" | "model" | "install">("harness"); const [apiUrl, setApiUrl] = useState( "https://api.intelligence.copilotkit.ai", ); @@ -108,7 +119,10 @@ export function App() { apiUrl, gatewayWsUrl: wsUrl, apiKey, - openaiApiKey: modelKey, + // The whole answer from the model screen, so the Rust side decides which keys that + // implies. Sending a bare key here is what made `ANTHROPIC_API_KEY` and a plan token + // expressible at the same time. + model, }); setRunning(true); // The window becomes OpenBot. Nobody double-clicked this to look at a status screen. @@ -151,6 +165,40 @@ export function App() { ); } + /* + * Which Bot, then which model, then install. Before this the screen asked for an OpenAI key in a + * password field, which is the developer-shaped main path the audience rule exists to prevent. + * + * Skipped entirely when a stack is already up: somebody returning to a running OpenBot is not + * setting one up, and asking them to pick a Bot again would be the wizard asking twice. + */ + if (!running && step === "harness") { + return ( +
+ setStep("model")} + /> +
+ ); + } + + if (!running && step === "model") { + return ( +
+ { + setModel(choice); + setStep("install"); + }} + onBack={() => setStep("harness")} + /> +
+ ); + } + return (
{/* A failure outranks `running`. The supervisor gives up on a process and sends the window @@ -189,18 +237,6 @@ export function App() { spellCheck={false} /> -
- - setModelKey(event.target.value)} - placeholder="an OpenAI key, so the Bots can answer" - autoComplete="off" - spellCheck={false} - /> -
{busy ? "Working…" : "Start OpenBot"} From fef19ed4e685f8d11d421d63184bd0413ddf91a6 Mon Sep 17 00:00:00 2001 From: David McKay Date: Sun, 6 Sep 2026 21:31:48 -0700 Subject: [PATCH 09/46] Sign in to a Claude plan without installing anything The plan is the default path on the model screen, and it needed a credential nobody types. `claude setup-token` does the OAuth, but it is a terminal program: given plain pipes it writes nothing at all and waits. Measured, not assumed. The same command produced zero bytes on a pipe and four kilobytes under a pty. It also needs Anthropic's CLI, which a non-technical person is never going to be told to install. They don't have to: the Claude Agent SDK ships a self-contained binary inside the Python package, so the harness image OpenBot already pulls carries a working CLI. The sign-in runs there, in a throwaway container, and the machine needs no Node, no npm and no CLI of its own. Running in a container is also why the code is pasted rather than redirected. The CLI's callback server is unreachable from a browser outside the container, so it falls back to printing a code, which Anthropic documents for exactly this case. The URL is read from the OSC-8 hyperlink and not from the text beside it. The CLI prints it twice: once as the link target, intact, and once as display text with the terminal's line breaks spliced into the query string. The visible copy opens and then fails on a mangled state, which is a bug that looks like Anthropic's rather than ours. Engine addressing goes through Address::parts so a Podman machine named everywhere else is named here too. Both parsers are pure and tested, the must-not case included: an API key is never taken for a plan token, since it outranks one and would bill somebody who just signed in. OpenAI offers a key only for now, and its summary says so. Its plan login is proved and belongs here as the default, but it is not wired to a command, and a button that cannot finish is worse than one that is absent. --- desktop/src-tauri/Cargo.lock | 128 ++++- desktop/src-tauri/Cargo.toml | 2 + .../src-tauri/gen/schemas/acl-manifests.json | 2 +- .../src-tauri/gen/schemas/desktop-schema.json | 233 +++++++++ .../src-tauri/gen/schemas/macOS-schema.json | 233 +++++++++ desktop/src-tauri/src/engine.rs | 21 +- desktop/src-tauri/src/lib.rs | 1 + desktop/src-tauri/src/main.rs | 65 +++ desktop/src-tauri/src/plan.rs | 447 ++++++++++++++++++ desktop/src-tauri/src/provider.rs | 12 +- desktop/src/ProviderPicker.tsx | 109 ++++- desktop/src/styles.css | 11 + 12 files changed, 1247 insertions(+), 17 deletions(-) create mode 100644 desktop/src-tauri/src/plan.rs diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 78596e733..8575c4199 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -453,6 +453,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" + [[package]] name = "cfg_aliases" version = "0.2.2" @@ -840,6 +846,12 @@ dependencies = [ "tendril", ] +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + [[package]] name = "dpi" version = "0.1.2" @@ -902,7 +914,7 @@ dependencies = [ "rustc_version", "toml 1.1.5+spec-1.1.0", "vswhom", - "winreg", + "winreg 0.55.0", ] [[package]] @@ -1019,6 +1031,17 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + [[package]] name = "filetime" version = "0.2.29" @@ -1990,6 +2013,12 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libappindicator" version = "0.9.0" @@ -2195,6 +2224,18 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases 0.1.1", + "libc", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -2451,6 +2492,7 @@ dependencies = [ "base64 0.22.1", "flate2", "libc", + "portable-pty", "rand 0.9.5", "reqwest 0.12.28", "serde", @@ -2458,6 +2500,7 @@ dependencies = [ "tar", "tauri", "tauri-build", + "tauri-plugin-opener", "tauri-plugin-shell", "tauri-plugin-single-instance", ] @@ -2692,6 +2735,27 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "portable-pty" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e" +dependencies = [ + "anyhow", + "bitflags 1.3.2", + "downcast-rs", + "filedescriptor", + "lazy_static", + "libc", + "log", + "nix", + "serial2", + "shared_library", + "shell-words", + "winapi", + "winreg 0.10.1", +] + [[package]] name = "potential_utf" version = "0.1.6" @@ -2800,7 +2864,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" dependencies = [ "bytes", - "cfg_aliases", + "cfg_aliases 0.2.2", "pin-project-lite", "quinn-proto", "quinn-udp", @@ -2841,7 +2905,7 @@ version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" dependencies = [ - "cfg_aliases", + "cfg_aliases 0.2.2", "libc", "once_cell", "socket2", @@ -3398,6 +3462,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "serial2" +version = "0.2.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b16809bc35793b19ce4e0c53924bc0dce3937f15487997cfdaed936004180730" +dependencies = [ + "cfg-if", + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "serialize-to-javascript" version = "0.1.2" @@ -3451,6 +3526,22 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "shared_library" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11" +dependencies = [ + "lazy_static", + "libc", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shlex" version = "2.0.1" @@ -3885,6 +3976,28 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-opener" +version = "2.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60d60366174b745b4ef5824b8bbc1c457fd08f0ce101ff643c0a49181a9f4e91" +dependencies = [ + "dunce", + "glob", + "objc2-app-kit", + "objc2-foundation", + "open", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.20", + "url", + "windows", + "zbus", +] + [[package]] name = "tauri-plugin-shell" version = "2.3.6" @@ -5238,6 +5351,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + [[package]] name = "winreg" version = "0.55.0" diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index d22691cf2..7e5e890b5 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -27,6 +27,8 @@ reqwest = { version = "0.12", default-features = false, features = ["rustls-tls" flate2 = "1" tar = "0.4" tauri-plugin-single-instance = "2.0.0-rc.5" +portable-pty = "0.9.0" +tauri-plugin-opener = "2.5.5" [target.'cfg(unix)'.dependencies] libc = "0.2" diff --git a/desktop/src-tauri/gen/schemas/acl-manifests.json b/desktop/src-tauri/gen/schemas/acl-manifests.json index f43b85280..f688caf03 100644 --- a/desktop/src-tauri/gen/schemas/acl-manifests.json +++ b/desktop/src-tauri/gen/schemas/acl-manifests.json @@ -1 +1 @@ -{"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener","allow-supports-multiple-windows"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-supports-multiple-windows":{"identifier":"allow-supports-multiple-windows","description":"Enables the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":["supports_multiple_windows"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-supports-multiple-windows":{"identifier":"deny-supports-multiple-windows","description":"Denies the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["supports_multiple_windows"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-icon-with-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-icon-with-as-template":{"identifier":"allow-set-icon-with-as-template","description":"Enables the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_with_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-icon-with-as-template":{"identifier":"deny-set-icon-with-as-template","description":"Denies the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_with_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-activity-name","allow-scene-identifier","allow-internal-toggle-maximize"]},"permissions":{"allow-activity-name":{"identifier":"allow-activity-name","description":"Enables the activity_name command without any pre-configured scope.","commands":{"allow":["activity_name"],"deny":[]}},"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-scene-identifier":{"identifier":"allow-scene-identifier","description":"Enables the scene_identifier command without any pre-configured scope.","commands":{"allow":["scene_identifier"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-activity-name":{"identifier":"deny-activity-name","description":"Denies the activity_name command without any pre-configured scope.","commands":{"allow":[],"deny":["activity_name"]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-scene-identifier":{"identifier":"deny-scene-identifier","description":"Denies the scene_identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["scene_identifier"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null},"shell":{"default_permission":{"identifier":"default","description":"This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n","permissions":["allow-open"]},"permissions":{"allow-execute":{"identifier":"allow-execute","description":"Enables the execute command without any pre-configured scope.","commands":{"allow":["execute"],"deny":[]}},"allow-kill":{"identifier":"allow-kill","description":"Enables the kill command without any pre-configured scope.","commands":{"allow":["kill"],"deny":[]}},"allow-open":{"identifier":"allow-open","description":"Enables the open command without any pre-configured scope.","commands":{"allow":["open"],"deny":[]}},"allow-spawn":{"identifier":"allow-spawn","description":"Enables the spawn command without any pre-configured scope.","commands":{"allow":["spawn"],"deny":[]}},"allow-stdin-write":{"identifier":"allow-stdin-write","description":"Enables the stdin_write command without any pre-configured scope.","commands":{"allow":["stdin_write"],"deny":[]}},"deny-execute":{"identifier":"deny-execute","description":"Denies the execute command without any pre-configured scope.","commands":{"allow":[],"deny":["execute"]}},"deny-kill":{"identifier":"deny-kill","description":"Denies the kill command without any pre-configured scope.","commands":{"allow":[],"deny":["kill"]}},"deny-open":{"identifier":"deny-open","description":"Denies the open command without any pre-configured scope.","commands":{"allow":[],"deny":["open"]}},"deny-spawn":{"identifier":"deny-spawn","description":"Denies the spawn command without any pre-configured scope.","commands":{"allow":[],"deny":["spawn"]}},"deny-stdin-write":{"identifier":"deny-stdin-write","description":"Denies the stdin_write command without any pre-configured scope.","commands":{"allow":[],"deny":["stdin_write"]}}},"permission_sets":{},"global_scope_schema":{"$schema":"http://json-schema.org/draft-07/schema#","anyOf":[{"additionalProperties":false,"properties":{"args":{"allOf":[{"$ref":"#/definitions/ShellScopeEntryAllowedArgs"}],"description":"The allowed arguments for the command execution."},"cmd":{"description":"The command name. It can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.","type":"string"},"name":{"description":"The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.","type":"string"}},"required":["cmd","name"],"type":"object"},{"additionalProperties":false,"properties":{"args":{"allOf":[{"$ref":"#/definitions/ShellScopeEntryAllowedArgs"}],"description":"The allowed arguments for the command execution."},"name":{"description":"The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.","type":"string"},"sidecar":{"description":"If this command is a sidecar command.","type":"boolean"}},"required":["name","sidecar"],"type":"object"}],"definitions":{"ShellScopeEntryAllowedArg":{"anyOf":[{"description":"A non-configurable argument that is passed to the command in the order it was specified.","type":"string"},{"additionalProperties":false,"description":"A variable that is set while calling the command from the webview API.","properties":{"raw":{"default":false,"description":"Marks the validator as a raw regex, meaning the plugin should not make any modification at runtime.\n\nThis means the regex will not match on the entire string by default, which might be exploited if your regex allow unexpected input to be considered valid. When using this option, make sure your regex is correct.","type":"boolean"},"validator":{"description":"[regex] validator to require passed values to conform to an expected input.\n\nThis will require the argument value passed to this variable to match the `validator` regex before it will be executed.\n\nThe regex string is by default surrounded by `^...$` to match the full string. For example the `https?://\\w+` regex would be registered as `^https?://\\w+$`.\n\n[regex]: ","type":"string"}},"required":["validator"],"type":"object"}],"description":"A command argument allowed to be executed by the webview API."},"ShellScopeEntryAllowedArgs":{"anyOf":[{"description":"Use a simple boolean to allow all or disable all arguments to this command configuration.","type":"boolean"},{"description":"A specific set of [`ShellScopeEntryAllowedArg`] that are valid to call for the command configuration.","items":{"$ref":"#/definitions/ShellScopeEntryAllowedArg"},"type":"array"}],"description":"A set of command arguments allowed to be executed by the webview API.\n\nA value of `true` will allow any arguments to be passed to the command. `false` will disable all arguments. A list of [`ShellScopeEntryAllowedArg`] will set those arguments as the only valid arguments to be passed to the attached command configuration."}},"description":"Shell scope entry.","title":"ShellScopeEntry"}}} \ No newline at end of file +{"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener","allow-supports-multiple-windows"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-supports-multiple-windows":{"identifier":"allow-supports-multiple-windows","description":"Enables the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":["supports_multiple_windows"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-supports-multiple-windows":{"identifier":"deny-supports-multiple-windows","description":"Denies the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["supports_multiple_windows"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-icon-with-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-icon-with-as-template":{"identifier":"allow-set-icon-with-as-template","description":"Enables the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_with_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-icon-with-as-template":{"identifier":"deny-set-icon-with-as-template","description":"Denies the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_with_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-activity-name","allow-scene-identifier","allow-internal-toggle-maximize"]},"permissions":{"allow-activity-name":{"identifier":"allow-activity-name","description":"Enables the activity_name command without any pre-configured scope.","commands":{"allow":["activity_name"],"deny":[]}},"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-scene-identifier":{"identifier":"allow-scene-identifier","description":"Enables the scene_identifier command without any pre-configured scope.","commands":{"allow":["scene_identifier"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-activity-name":{"identifier":"deny-activity-name","description":"Denies the activity_name command without any pre-configured scope.","commands":{"allow":[],"deny":["activity_name"]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-scene-identifier":{"identifier":"deny-scene-identifier","description":"Denies the scene_identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["scene_identifier"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null},"opener":{"default_permission":{"identifier":"default","description":"This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer","permissions":["allow-open-url","allow-reveal-item-in-dir","allow-default-urls"]},"permissions":{"allow-default-urls":{"identifier":"allow-default-urls","description":"This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.","commands":{"allow":[],"deny":[]},"scope":{"allow":[{"url":"mailto:*"},{"url":"tel:*"},{"url":"http://*"},{"url":"https://*"}]}},"allow-open-path":{"identifier":"allow-open-path","description":"Enables the open_path command without any pre-configured scope.","commands":{"allow":["open_path"],"deny":[]}},"allow-open-url":{"identifier":"allow-open-url","description":"Enables the open_url command without any pre-configured scope.","commands":{"allow":["open_url"],"deny":[]}},"allow-reveal-item-in-dir":{"identifier":"allow-reveal-item-in-dir","description":"Enables the reveal_item_in_dir command without any pre-configured scope.","commands":{"allow":["reveal_item_in_dir"],"deny":[]}},"deny-open-path":{"identifier":"deny-open-path","description":"Denies the open_path command without any pre-configured scope.","commands":{"allow":[],"deny":["open_path"]}},"deny-open-url":{"identifier":"deny-open-url","description":"Denies the open_url command without any pre-configured scope.","commands":{"allow":[],"deny":["open_url"]}},"deny-reveal-item-in-dir":{"identifier":"deny-reveal-item-in-dir","description":"Denies the reveal_item_in_dir command without any pre-configured scope.","commands":{"allow":[],"deny":["reveal_item_in_dir"]}}},"permission_sets":{},"global_scope_schema":{"$schema":"http://json-schema.org/draft-07/schema#","anyOf":[{"properties":{"app":{"allOf":[{"$ref":"#/definitions/Application"}],"description":"An application to open this url with, for example: firefox."},"url":{"description":"A URL that can be opened by the webview when using the Opener APIs.\n\nWildcards can be used following the UNIX glob pattern.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"","type":"string"}},"required":["url"],"type":"object"},{"properties":{"app":{"allOf":[{"$ref":"#/definitions/Application"}],"description":"An application to open this path with, for example: xdg-open."},"path":{"description":"A path that can be opened by the webview when using the Opener APIs.\n\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.","type":"string"}},"required":["path"],"type":"object"}],"definitions":{"Application":{"anyOf":[{"description":"Open in default application.","type":"null"},{"description":"If true, allow open with any application.","type":"boolean"},{"description":"Allow specific application to open with.","type":"string"}],"description":"Opener scope application."}},"description":"Opener scope entry.","title":"OpenerScopeEntry"}},"shell":{"default_permission":{"identifier":"default","description":"This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n","permissions":["allow-open"]},"permissions":{"allow-execute":{"identifier":"allow-execute","description":"Enables the execute command without any pre-configured scope.","commands":{"allow":["execute"],"deny":[]}},"allow-kill":{"identifier":"allow-kill","description":"Enables the kill command without any pre-configured scope.","commands":{"allow":["kill"],"deny":[]}},"allow-open":{"identifier":"allow-open","description":"Enables the open command without any pre-configured scope.","commands":{"allow":["open"],"deny":[]}},"allow-spawn":{"identifier":"allow-spawn","description":"Enables the spawn command without any pre-configured scope.","commands":{"allow":["spawn"],"deny":[]}},"allow-stdin-write":{"identifier":"allow-stdin-write","description":"Enables the stdin_write command without any pre-configured scope.","commands":{"allow":["stdin_write"],"deny":[]}},"deny-execute":{"identifier":"deny-execute","description":"Denies the execute command without any pre-configured scope.","commands":{"allow":[],"deny":["execute"]}},"deny-kill":{"identifier":"deny-kill","description":"Denies the kill command without any pre-configured scope.","commands":{"allow":[],"deny":["kill"]}},"deny-open":{"identifier":"deny-open","description":"Denies the open command without any pre-configured scope.","commands":{"allow":[],"deny":["open"]}},"deny-spawn":{"identifier":"deny-spawn","description":"Denies the spawn command without any pre-configured scope.","commands":{"allow":[],"deny":["spawn"]}},"deny-stdin-write":{"identifier":"deny-stdin-write","description":"Denies the stdin_write command without any pre-configured scope.","commands":{"allow":[],"deny":["stdin_write"]}}},"permission_sets":{},"global_scope_schema":{"$schema":"http://json-schema.org/draft-07/schema#","anyOf":[{"additionalProperties":false,"properties":{"args":{"allOf":[{"$ref":"#/definitions/ShellScopeEntryAllowedArgs"}],"description":"The allowed arguments for the command execution."},"cmd":{"description":"The command name. It can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.","type":"string"},"name":{"description":"The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.","type":"string"}},"required":["cmd","name"],"type":"object"},{"additionalProperties":false,"properties":{"args":{"allOf":[{"$ref":"#/definitions/ShellScopeEntryAllowedArgs"}],"description":"The allowed arguments for the command execution."},"name":{"description":"The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.","type":"string"},"sidecar":{"description":"If this command is a sidecar command.","type":"boolean"}},"required":["name","sidecar"],"type":"object"}],"definitions":{"ShellScopeEntryAllowedArg":{"anyOf":[{"description":"A non-configurable argument that is passed to the command in the order it was specified.","type":"string"},{"additionalProperties":false,"description":"A variable that is set while calling the command from the webview API.","properties":{"raw":{"default":false,"description":"Marks the validator as a raw regex, meaning the plugin should not make any modification at runtime.\n\nThis means the regex will not match on the entire string by default, which might be exploited if your regex allow unexpected input to be considered valid. When using this option, make sure your regex is correct.","type":"boolean"},"validator":{"description":"[regex] validator to require passed values to conform to an expected input.\n\nThis will require the argument value passed to this variable to match the `validator` regex before it will be executed.\n\nThe regex string is by default surrounded by `^...$` to match the full string. For example the `https?://\\w+` regex would be registered as `^https?://\\w+$`.\n\n[regex]: ","type":"string"}},"required":["validator"],"type":"object"}],"description":"A command argument allowed to be executed by the webview API."},"ShellScopeEntryAllowedArgs":{"anyOf":[{"description":"Use a simple boolean to allow all or disable all arguments to this command configuration.","type":"boolean"},{"description":"A specific set of [`ShellScopeEntryAllowedArg`] that are valid to call for the command configuration.","items":{"$ref":"#/definitions/ShellScopeEntryAllowedArg"},"type":"array"}],"description":"A set of command arguments allowed to be executed by the webview API.\n\nA value of `true` will allow any arguments to be passed to the command. `false` will disable all arguments. A list of [`ShellScopeEntryAllowedArg`] will set those arguments as the only valid arguments to be passed to the attached command configuration."}},"description":"Shell scope entry.","title":"ShellScopeEntry"}}} \ No newline at end of file diff --git a/desktop/src-tauri/gen/schemas/desktop-schema.json b/desktop/src-tauri/gen/schemas/desktop-schema.json index d1e536142..847cd9c71 100644 --- a/desktop/src-tauri/gen/schemas/desktop-schema.json +++ b/desktop/src-tauri/gen/schemas/desktop-schema.json @@ -134,6 +134,174 @@ "description": "Reference a permission or permission set by identifier and extends its scope.", "type": "object", "allOf": [ + { + "if": { + "properties": { + "identifier": { + "anyOf": [ + { + "description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`", + "type": "string", + "const": "opener:default", + "markdownDescription": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`" + }, + { + "description": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.", + "type": "string", + "const": "opener:allow-default-urls", + "markdownDescription": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application." + }, + { + "description": "Enables the open_path command without any pre-configured scope.", + "type": "string", + "const": "opener:allow-open-path", + "markdownDescription": "Enables the open_path command without any pre-configured scope." + }, + { + "description": "Enables the open_url command without any pre-configured scope.", + "type": "string", + "const": "opener:allow-open-url", + "markdownDescription": "Enables the open_url command without any pre-configured scope." + }, + { + "description": "Enables the reveal_item_in_dir command without any pre-configured scope.", + "type": "string", + "const": "opener:allow-reveal-item-in-dir", + "markdownDescription": "Enables the reveal_item_in_dir command without any pre-configured scope." + }, + { + "description": "Denies the open_path command without any pre-configured scope.", + "type": "string", + "const": "opener:deny-open-path", + "markdownDescription": "Denies the open_path command without any pre-configured scope." + }, + { + "description": "Denies the open_url command without any pre-configured scope.", + "type": "string", + "const": "opener:deny-open-url", + "markdownDescription": "Denies the open_url command without any pre-configured scope." + }, + { + "description": "Denies the reveal_item_in_dir command without any pre-configured scope.", + "type": "string", + "const": "opener:deny-reveal-item-in-dir", + "markdownDescription": "Denies the reveal_item_in_dir command without any pre-configured scope." + } + ] + } + } + }, + "then": { + "properties": { + "allow": { + "items": { + "title": "OpenerScopeEntry", + "description": "Opener scope entry.", + "anyOf": [ + { + "type": "object", + "required": [ + "url" + ], + "properties": { + "app": { + "description": "An application to open this url with, for example: firefox.", + "allOf": [ + { + "$ref": "#/definitions/Application" + } + ] + }, + "url": { + "description": "A URL that can be opened by the webview when using the Opener APIs.\n\nWildcards can be used following the UNIX glob pattern.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"", + "type": "string" + } + } + }, + { + "type": "object", + "required": [ + "path" + ], + "properties": { + "app": { + "description": "An application to open this path with, for example: xdg-open.", + "allOf": [ + { + "$ref": "#/definitions/Application" + } + ] + }, + "path": { + "description": "A path that can be opened by the webview when using the Opener APIs.\n\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.", + "type": "string" + } + } + } + ] + } + }, + "deny": { + "items": { + "title": "OpenerScopeEntry", + "description": "Opener scope entry.", + "anyOf": [ + { + "type": "object", + "required": [ + "url" + ], + "properties": { + "app": { + "description": "An application to open this url with, for example: firefox.", + "allOf": [ + { + "$ref": "#/definitions/Application" + } + ] + }, + "url": { + "description": "A URL that can be opened by the webview when using the Opener APIs.\n\nWildcards can be used following the UNIX glob pattern.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"", + "type": "string" + } + } + }, + { + "type": "object", + "required": [ + "path" + ], + "properties": { + "app": { + "description": "An application to open this path with, for example: xdg-open.", + "allOf": [ + { + "$ref": "#/definitions/Application" + } + ] + }, + "path": { + "description": "A path that can be opened by the webview when using the Opener APIs.\n\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.", + "type": "string" + } + } + } + ] + } + } + } + }, + "properties": { + "identifier": { + "description": "Identifier of the permission or permission set.", + "allOf": [ + { + "$ref": "#/definitions/Identifier" + } + ] + } + } + }, { "if": { "properties": { @@ -2402,6 +2570,54 @@ "const": "core:window:deny-unminimize", "markdownDescription": "Denies the unminimize command without any pre-configured scope." }, + { + "description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`", + "type": "string", + "const": "opener:default", + "markdownDescription": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`" + }, + { + "description": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.", + "type": "string", + "const": "opener:allow-default-urls", + "markdownDescription": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application." + }, + { + "description": "Enables the open_path command without any pre-configured scope.", + "type": "string", + "const": "opener:allow-open-path", + "markdownDescription": "Enables the open_path command without any pre-configured scope." + }, + { + "description": "Enables the open_url command without any pre-configured scope.", + "type": "string", + "const": "opener:allow-open-url", + "markdownDescription": "Enables the open_url command without any pre-configured scope." + }, + { + "description": "Enables the reveal_item_in_dir command without any pre-configured scope.", + "type": "string", + "const": "opener:allow-reveal-item-in-dir", + "markdownDescription": "Enables the reveal_item_in_dir command without any pre-configured scope." + }, + { + "description": "Denies the open_path command without any pre-configured scope.", + "type": "string", + "const": "opener:deny-open-path", + "markdownDescription": "Denies the open_path command without any pre-configured scope." + }, + { + "description": "Denies the open_url command without any pre-configured scope.", + "type": "string", + "const": "opener:deny-open-url", + "markdownDescription": "Denies the open_url command without any pre-configured scope." + }, + { + "description": "Denies the reveal_item_in_dir command without any pre-configured scope.", + "type": "string", + "const": "opener:deny-reveal-item-in-dir", + "markdownDescription": "Denies the reveal_item_in_dir command without any pre-configured scope." + }, { "description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`", "type": "string", @@ -2564,6 +2780,23 @@ } ] }, + "Application": { + "description": "Opener scope application.", + "anyOf": [ + { + "description": "Open in default application.", + "type": "null" + }, + { + "description": "If true, allow open with any application.", + "type": "boolean" + }, + { + "description": "Allow specific application to open with.", + "type": "string" + } + ] + }, "ShellScopeEntryAllowedArg": { "description": "A command argument allowed to be executed by the webview API.", "anyOf": [ diff --git a/desktop/src-tauri/gen/schemas/macOS-schema.json b/desktop/src-tauri/gen/schemas/macOS-schema.json index d1e536142..847cd9c71 100644 --- a/desktop/src-tauri/gen/schemas/macOS-schema.json +++ b/desktop/src-tauri/gen/schemas/macOS-schema.json @@ -134,6 +134,174 @@ "description": "Reference a permission or permission set by identifier and extends its scope.", "type": "object", "allOf": [ + { + "if": { + "properties": { + "identifier": { + "anyOf": [ + { + "description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`", + "type": "string", + "const": "opener:default", + "markdownDescription": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`" + }, + { + "description": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.", + "type": "string", + "const": "opener:allow-default-urls", + "markdownDescription": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application." + }, + { + "description": "Enables the open_path command without any pre-configured scope.", + "type": "string", + "const": "opener:allow-open-path", + "markdownDescription": "Enables the open_path command without any pre-configured scope." + }, + { + "description": "Enables the open_url command without any pre-configured scope.", + "type": "string", + "const": "opener:allow-open-url", + "markdownDescription": "Enables the open_url command without any pre-configured scope." + }, + { + "description": "Enables the reveal_item_in_dir command without any pre-configured scope.", + "type": "string", + "const": "opener:allow-reveal-item-in-dir", + "markdownDescription": "Enables the reveal_item_in_dir command without any pre-configured scope." + }, + { + "description": "Denies the open_path command without any pre-configured scope.", + "type": "string", + "const": "opener:deny-open-path", + "markdownDescription": "Denies the open_path command without any pre-configured scope." + }, + { + "description": "Denies the open_url command without any pre-configured scope.", + "type": "string", + "const": "opener:deny-open-url", + "markdownDescription": "Denies the open_url command without any pre-configured scope." + }, + { + "description": "Denies the reveal_item_in_dir command without any pre-configured scope.", + "type": "string", + "const": "opener:deny-reveal-item-in-dir", + "markdownDescription": "Denies the reveal_item_in_dir command without any pre-configured scope." + } + ] + } + } + }, + "then": { + "properties": { + "allow": { + "items": { + "title": "OpenerScopeEntry", + "description": "Opener scope entry.", + "anyOf": [ + { + "type": "object", + "required": [ + "url" + ], + "properties": { + "app": { + "description": "An application to open this url with, for example: firefox.", + "allOf": [ + { + "$ref": "#/definitions/Application" + } + ] + }, + "url": { + "description": "A URL that can be opened by the webview when using the Opener APIs.\n\nWildcards can be used following the UNIX glob pattern.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"", + "type": "string" + } + } + }, + { + "type": "object", + "required": [ + "path" + ], + "properties": { + "app": { + "description": "An application to open this path with, for example: xdg-open.", + "allOf": [ + { + "$ref": "#/definitions/Application" + } + ] + }, + "path": { + "description": "A path that can be opened by the webview when using the Opener APIs.\n\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.", + "type": "string" + } + } + } + ] + } + }, + "deny": { + "items": { + "title": "OpenerScopeEntry", + "description": "Opener scope entry.", + "anyOf": [ + { + "type": "object", + "required": [ + "url" + ], + "properties": { + "app": { + "description": "An application to open this url with, for example: firefox.", + "allOf": [ + { + "$ref": "#/definitions/Application" + } + ] + }, + "url": { + "description": "A URL that can be opened by the webview when using the Opener APIs.\n\nWildcards can be used following the UNIX glob pattern.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"", + "type": "string" + } + } + }, + { + "type": "object", + "required": [ + "path" + ], + "properties": { + "app": { + "description": "An application to open this path with, for example: xdg-open.", + "allOf": [ + { + "$ref": "#/definitions/Application" + } + ] + }, + "path": { + "description": "A path that can be opened by the webview when using the Opener APIs.\n\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.", + "type": "string" + } + } + } + ] + } + } + } + }, + "properties": { + "identifier": { + "description": "Identifier of the permission or permission set.", + "allOf": [ + { + "$ref": "#/definitions/Identifier" + } + ] + } + } + }, { "if": { "properties": { @@ -2402,6 +2570,54 @@ "const": "core:window:deny-unminimize", "markdownDescription": "Denies the unminimize command without any pre-configured scope." }, + { + "description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`", + "type": "string", + "const": "opener:default", + "markdownDescription": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`" + }, + { + "description": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.", + "type": "string", + "const": "opener:allow-default-urls", + "markdownDescription": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application." + }, + { + "description": "Enables the open_path command without any pre-configured scope.", + "type": "string", + "const": "opener:allow-open-path", + "markdownDescription": "Enables the open_path command without any pre-configured scope." + }, + { + "description": "Enables the open_url command without any pre-configured scope.", + "type": "string", + "const": "opener:allow-open-url", + "markdownDescription": "Enables the open_url command without any pre-configured scope." + }, + { + "description": "Enables the reveal_item_in_dir command without any pre-configured scope.", + "type": "string", + "const": "opener:allow-reveal-item-in-dir", + "markdownDescription": "Enables the reveal_item_in_dir command without any pre-configured scope." + }, + { + "description": "Denies the open_path command without any pre-configured scope.", + "type": "string", + "const": "opener:deny-open-path", + "markdownDescription": "Denies the open_path command without any pre-configured scope." + }, + { + "description": "Denies the open_url command without any pre-configured scope.", + "type": "string", + "const": "opener:deny-open-url", + "markdownDescription": "Denies the open_url command without any pre-configured scope." + }, + { + "description": "Denies the reveal_item_in_dir command without any pre-configured scope.", + "type": "string", + "const": "opener:deny-reveal-item-in-dir", + "markdownDescription": "Denies the reveal_item_in_dir command without any pre-configured scope." + }, { "description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`", "type": "string", @@ -2564,6 +2780,23 @@ } ] }, + "Application": { + "description": "Opener scope application.", + "anyOf": [ + { + "description": "Open in default application.", + "type": "null" + }, + { + "description": "If true, allow open with any application.", + "type": "boolean" + }, + { + "description": "Allow specific application to open with.", + "type": "string" + } + ] + }, "ShellScopeEntryAllowedArg": { "description": "A command argument allowed to be executed by the webview API.", "anyOf": [ diff --git a/desktop/src-tauri/src/engine.rs b/desktop/src-tauri/src/engine.rs index b27694163..27f8c37bd 100644 --- a/desktop/src-tauri/src/engine.rs +++ b/desktop/src-tauri/src/engine.rs @@ -63,12 +63,25 @@ impl Address { Self { engine, connection } } - /// A command aimed at this engine, and the only way one should be built. - pub fn command(&self) -> Command { - let mut command = command(self.engine.binary()); + /// The binary and the arguments that name this engine, and the one place that decides them. + /// + /// Split out because not every caller can use a `std::process::Command`: the plan sign-in runs + /// under a pty and has to build the pty crate's own command type. Both go through here, so a + /// machine addressed by name cannot be addressed by name on one path and not the other. + pub fn parts(&self) -> (&'static str, Vec) { + let mut arguments = Vec::new(); if let Some(connection) = &self.connection { - command.args(["--connection", connection]); + arguments.push("--connection".to_string()); + arguments.push(connection.clone()); } + (self.engine.binary(), arguments) + } + + /// A command aimed at this engine, and the only way one should be built. + pub fn command(&self) -> Command { + let (binary, arguments) = self.parts(); + let mut command = command(binary); + command.args(arguments); command } diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 12ae92b3d..15ffe8a69 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -4,6 +4,7 @@ pub mod deployment; pub mod engine; pub mod env; pub mod harness; +pub mod plan; pub mod provider; pub mod quiet; pub mod stack; diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index 9bc5ced10..ab84e1e45 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -36,6 +36,12 @@ struct Shell { /// moment it is worth reading. Held here instead, and asked for on load. last_failure: Mutex>, root: Mutex>, + /// A plan sign-in waiting for the code from the browser. + /// + /// Held across two commands because a person has to leave and approve in the middle of it, and + /// the flow that showed the URL is the only one that can redeem the code: each start mints its + /// own PKCE challenge and state, so a second start invalidates the first. + signing_in: Mutex>, /// Where the shell's own interface lives, read from the window rather than spelled out. /// /// Tauri does not serve the bundle from the same address on every platform: macOS and Linux @@ -491,6 +497,62 @@ fn harnesses() -> Vec { harness::catalogue() } +/// Start a Claude plan sign-in and return the address a browser has to open. +/// +/// Blocking work on a blocking thread: it starts a container and waits on its output, and doing +/// that on the UI thread is a window that stops repainting mid-setup. +#[tauri::command] +async fn begin_claude_sign_in(app: tauri::AppHandle) -> Result { + /* + * The image is decided here, not by the window, and it is the Claude Agent SDK harness whatever + * harness the person picked. It is not being used as a Bot: it is the container that happens to + * carry Anthropic's bundled CLI, which is what does the OAuth. Letting the screen name an image + * would make the sign-in depend on a choice that has nothing to do with it. + */ + let image = openbot_desktop_lib::plan::SIGN_IN_IMAGE.to_string(); + let address = engine::detect().address.ok_or_else(|| { + "No container engine is answering, so the sign-in cannot run.".to_string() + })?; + let (signing, url) = tauri::async_runtime::spawn_blocking(move || { + openbot_desktop_lib::plan::SigningIn::begin(&address, &image) + }) + .await + .map_err(|error| format!("The sign-in did not run: {error}"))??; + *app.state::().signing_in.lock().unwrap() = Some(signing); + + /* + * Opened here rather than by the window, because the window would need the shell plugin's JS + * half for the one call. The URL is returned as well, and the screen shows it: on Linux without + * a registered browser, and in a session where the open silently does nothing, a link somebody + * can copy is the difference between a stuck screen and a finished sign-in. + */ + let _ = tauri_plugin_opener::OpenerExt::opener(&app).open_url(&url, None::<&str>); + Ok(url) +} + +/** +Redeem the code from the browser and return the plan token. + +The token crosses to the window and comes back in the model choice, which is the same path a typed +key takes. It is never logged, and the failure messages never carry the command's output: see +`SigningIn::gave_up`. +*/ +#[tauri::command] +async fn finish_claude_sign_in(app: tauri::AppHandle, code: String) -> Result { + // Taken, not borrowed. A sign-in is single-use, and leaving it in place would let a second + // attempt write a code into a flow that has already finished. + let signing = app + .state::() + .signing_in + .lock() + .unwrap() + .take() + .ok_or_else(|| "That sign-in is no longer running. Start it again.".to_string())?; + tauri::async_runtime::spawn_blocking(move || signing.finish(&code)) + .await + .map_err(|error| format!("The sign-in did not finish: {error}"))? +} + /// The model screen's rows. Independent of the picker above, and required to stay that way: no /// harness on that list is tied to a vendor's models, so choosing one may not narrow this. #[tauri::command] @@ -692,6 +754,7 @@ fn main() { show_whichever_applies(app); })) .plugin(tauri_plugin_shell::init()) + .plugin(tauri_plugin_opener::init()) .manage(Shell::default()) .invoke_handler(tauri::generate_handler![ detect_engine, @@ -707,6 +770,8 @@ fn main() { default_root, harnesses, providers, + begin_claude_sign_in, + finish_claude_sign_in, ]) // A packaged application is not a browser tab. Left alone, WebView2 answers a right-click // with Back, Refresh, Save as and Print: Back walks the window out of OpenBot with nothing diff --git a/desktop/src-tauri/src/plan.rs b/desktop/src-tauri/src/plan.rs new file mode 100644 index 000000000..15c2dc89d --- /dev/null +++ b/desktop/src-tauri/src/plan.rs @@ -0,0 +1,447 @@ +//! Signing in to a Claude plan, so nobody has to hold an API key. +//! +//! This is the default path on the model screen: anybody with a key and a base URL to hand is a +//! developer, and everybody else has a plan they already pay for. +//! +//! ANTHROPIC'S OWN CLI DOES THE FLOW. It starts the OAuth, shows the consent URL, takes the code +//! back and exchanges it. Reimplementing that here would mean holding somebody else's OAuth client +//! id, redirect and PKCE details and re-shipping them whenever any of it moves. Driving the +//! vendor's command is the same call as reaching Mastra through Mastra's own bridge. +//! +//! AND NOTHING HAS TO BE INSTALLED FOR IT. The Claude Agent SDK ships a self-contained `claude` +//! binary inside the Python package, so the harness image OpenBot already pulls has a working CLI +//! at `_bundled/claude` and the person's machine needs no Node, no npm and no CLI of their own. +//! +//! The flow runs in that container, which is why the code is pasted rather than redirected. The +//! CLI's local callback server is unreachable from a browser outside the container, so it falls +//! back to `code=true` and prints a code for the person to bring back. Anthropic documents that +//! fallback for exactly this case: "common in WSL2, SSH sessions, and containers". + +use std::io::{Read, Write}; +use std::time::{Duration, Instant}; + +use portable_pty::{native_pty_system, CommandBuilder, PtySize}; + +/// The image whose bundled CLI runs the sign-in. +/// +/// The Claude Agent SDK harness, used here as a tool rather than as a Bot: it is simply the image +/// that carries Anthropic's own CLI, so nothing has to be installed on the person's machine. Pinned +/// by the release like every other image; the tag here is what a development tree builds. +pub const SIGN_IN_IMAGE: &str = "openbot-harness-claude-sdk:test"; + +/// Where the SDK keeps the binary it bundles. +/// +/// A path inside the harness image rather than anything on the person's machine. It moves when the +/// package is restructured, which is why the failure to find it is reported as itself rather than +/// as a spawn error. +pub const BUNDLED_CLI: &str = + "/usr/local/lib/python3.12/site-packages/claude_agent_sdk/_bundled/claude"; + +/// The start of a plan token, which is what tells it apart from an API key. +/// +/// Only the prefix lives here. A key and a plan token are both opaque strings and only this +/// distinguishes them, and taking an API key for a plan token would write the one credential the +/// plan path exists to avoid. +const PLAN_TOKEN_PREFIX: &str = concat!("sk", "-ant-oat"); + +/// Everything the terminal drew, with the escapes taken out. +/// +/// The CLI is a TUI: it writes cursor moves, colours, and OSC-8 hyperlinks, and it line-wraps the +/// URL it prints so the visible text is not the URL. Reading it means stripping first. +fn plain(output: &str) -> String { + let mut out = String::with_capacity(output.len()); + let mut chars = output.chars().peekable(); + while let Some(c) = chars.next() { + if c != '\u{1b}' { + out.push(c); + continue; + } + // CSI: ESC [ … final byte in @-~ + if chars.peek() == Some(&'[') { + chars.next(); + for c in chars.by_ref() { + if ('@'..='~').contains(&c) { + break; + } + } + continue; + } + // OSC: ESC ] … terminated by BEL or ESC \ + if chars.peek() == Some(&']') { + chars.next(); + while let Some(c) = chars.next() { + if c == '\u{7}' { + break; + } + if c == '\u{1b}' && chars.peek() == Some(&'\\') { + chars.next(); + break; + } + } + continue; + } + /* + * Everything else: ESC, then zero or more intermediate bytes (0x20-0x2F), then one final + * byte (0x30-0x7E). `ESC ( B` is the common one — a charset designation — and it is three + * bytes, not two. Dropping a fixed pair left its `B` in the text, which is the sort of + * thing that turns a token scan into a near-miss. + */ + while let Some(&c) = chars.peek() { + chars.next(); + if !(' '..='/').contains(&c) { + break; + } + } + } + out +} + +/** +The consent URL the CLI wants a browser opened on. + +Taken from the OSC-8 hyperlink rather than from the visible text, and that is the whole point of +this function. The CLI prints the URL twice: once as the hyperlink's target, which is intact, and +once as wrapped display text, which has the terminal's line breaks spliced into the middle of the +query string. Reading the visible copy yields a URL that looks right, opens, and fails, because +`state` and `code_challenge` have had characters inserted into them. +*/ +pub fn authorize_url_in(output: &str) -> Option { + // ESC ] 8 ; ; ST — the uri is the second `;`-separated field. + for start in find_all(output, "\u{1b}]8;") { + let after = &output[start + 4..]; + let Some(semicolon) = after.find(';') else { + continue; + }; + let uri = &after[semicolon + 1..]; + let end = uri + .find('\u{7}') + .or_else(|| uri.find('\u{1b}')) + .unwrap_or(uri.len()); + let uri = uri[..end].trim(); + if uri.contains("/oauth/authorize") { + return Some(uri.to_string()); + } + } + None +} + +fn find_all(haystack: &str, needle: &str) -> Vec { + let mut found = Vec::new(); + let mut from = 0; + while let Some(at) = haystack[from..].find(needle) { + found.push(from + at); + from += at + needle.len(); + } + found +} + +/// Whether the CLI is waiting for the code from the browser. +/// +/// Asked before writing, so a code is never typed into a prompt that is not there: written early it +/// is consumed by whatever the TUI is drawing and the flow stalls with no sign of why. +pub fn wants_the_code(output: &str) -> bool { + plain(output).contains("Paste code here") +} + +/** +The token in whatever the command printed. + +Pure and separate from the running of it, because the shape of this output is the thing here most +likely to change without warning: it is a human-facing CLI, not an API. + +Scanned for by prefix rather than by position. Matching "the last line", or the text after a label, +breaks the first time a hint or a colour is added, and breaking here means telling somebody who +approved in their browser that it failed. +*/ +pub fn token_in(output: &str) -> Option { + plain(output) + .split(|c: char| c.is_whitespace() || c == '"' || c == '\'') + .map(|word| word.trim_matches(|c: char| !c.is_ascii_alphanumeric() && c != '-' && c != '_')) + .find(|word| word.starts_with(PLAN_TOKEN_PREFIX) && word.len() > 30) + .map(str::to_string) +} + +/// A sign-in in progress: the CLI is running and waiting for the code from the browser. +/// +/// Held rather than completed in one call because a person has to go and approve in a browser in +/// the middle of it. One call starts it and returns the URL; a second brings the code back. +pub struct SigningIn { + child: Box, + writer: Box, + output: std::sync::Arc>, +} + +/// How long to wait for the CLI to show the URL. Machine time: a container start and an HTTP call. +const PATIENCE_FOR_THE_LINK: Duration = Duration::from_secs(90); + +/// How long to wait once a code has been sent. Also machine time, but through Anthropic. +const PATIENCE_FOR_THE_TOKEN: Duration = Duration::from_secs(120); + +/// How long a person is given to approve in their browser. +/// +/// Generous on purpose: this covers finding a password, a second factor, and possibly choosing +/// between accounts. The failure of being too short is telling somebody who did nothing wrong that +/// it did not work, and making them start again. +const PATIENCE_FOR_THE_PERSON: Duration = Duration::from_secs(600); + +impl SigningIn { + /** + Start the flow and return the URL a browser has to open. + + In a throwaway container from the harness image, because this runs on the model screen, before + any stack is up, and because the image is where the bundled CLI lives. Nothing is installed on + the person's machine and nothing is left behind: `--rm`, no ports, no mounts, no name. + + Under a pty because the CLI draws a terminal. Given plain pipes it writes nothing at all and + waits — measured, not assumed: the same command produced zero bytes on a pipe and 4 kB on a pty. + */ + pub fn begin(engine: &crate::engine::Address, image: &str) -> Result<(Self, String), String> { + let pty = native_pty_system() + .openpty(PtySize { + rows: 48, + // Wide on purpose. The CLI wraps the consent URL to the terminal's width, and while + // the intact copy is read from the hyperlink rather than the wrapped text, a narrow + // terminal also wraps the prompt this has to recognise. + cols: 200, + pixel_width: 0, + pixel_height: 0, + }) + .map_err(|e| format!("A terminal could not be opened for the sign-in: {e}"))?; + + // Through `Address::parts`, so a Podman machine addressed by name here is addressed by + // name exactly as it is everywhere else. A sign-in run against the default connection on a + // machine that has two is the "Cannot connect to Podman" class of failure all over again. + let (binary, arguments) = engine.parts(); + let mut command = CommandBuilder::new(binary); + for argument in arguments { + command.arg(argument); + } + command.arg("run"); + command.arg("--rm"); + command.arg("-i"); + command.arg("-t"); + command.arg(image); + command.arg(BUNDLED_CLI); + command.arg("setup-token"); + + let child = pty + .slave + .spawn_command(command) + .map_err(|e| format!("The sign-in did not start: {e}"))?; + // Held by the child now. Dropping ours is what makes a read see EOF when it exits, rather + // than blocking on a handle nobody will ever write to. + drop(pty.slave); + + let writer = pty + .master + .take_writer() + .map_err(|e| format!("The sign-in could not be typed into: {e}"))?; + let mut reader = pty + .master + .try_clone_reader() + .map_err(|e| format!("The sign-in could not be read: {e}"))?; + + /* + * Read on its own thread and accumulate. + * + * A pty read blocks, and everything this needs appears before the command exits: the URL + * first, the token later. Waiting for exit would mean waiting out the whole flow before + * showing anybody the URL they have to open. + */ + let output = std::sync::Arc::new(std::sync::Mutex::new(String::new())); + let filling = std::sync::Arc::clone(&output); + std::thread::spawn(move || { + let mut buffer = [0u8; 8192]; + while let Ok(read) = reader.read(&mut buffer) { + if read == 0 { + break; + } + let Ok(mut held) = filling.lock() else { break }; + held.push_str(&String::from_utf8_lossy(&buffer[..read])); + } + }); + + let mut signing = Self { + child, + writer, + output, + }; + let url = signing + .wait_for(authorize_url_in, PATIENCE_FOR_THE_LINK) + .ok_or_else(|| signing.gave_up("The sign-in never offered a link to open."))?; + Ok((signing, url)) + } + + /// Hand back the code from the browser and wait for the token. + pub fn finish(mut self, code: &str) -> Result { + if self + .wait_for( + |seen| wants_the_code(seen).then_some(()), + PATIENCE_FOR_THE_PERSON, + ) + .is_none() + { + return Err(self.gave_up("The sign-in stopped before it asked for the code.")); + } + // Trimmed, because a code arrives pasted and a trailing newline or space is the person's + // clipboard rather than their intent. + writeln!(self.writer, "{}", code.trim()) + .map_err(|e| format!("The code could not be sent to the sign-in: {e}"))?; + self.writer + .flush() + .map_err(|e| format!("The code could not be sent to the sign-in: {e}"))?; + + match self.wait_for(token_in, PATIENCE_FOR_THE_TOKEN) { + Some(token) => { + self.stop(); + Ok(token) + } + None => Err(self.gave_up( + "That code was not accepted. Start the sign-in again and copy the code from the browser once more.", + )), + } + } + + /// Poll the accumulated output until `found` finds something, the command exits, or patience + /// runs out. + fn wait_for(&mut self, found: impl Fn(&str) -> Option, patience: Duration) -> Option { + let began = Instant::now(); + while began.elapsed() < patience { + if let Ok(seen) = self.output.lock() { + if let Some(value) = found(&seen) { + return Some(value); + } + } + // A finished command with nothing found is a refusal, not something still to wait for. + if matches!(self.child.try_wait(), Ok(Some(_))) { + // One more look: the last write and the exit race, and the token is written first. + std::thread::sleep(Duration::from_millis(150)); + return self.output.lock().ok().and_then(|seen| found(&seen)); + } + std::thread::sleep(Duration::from_millis(200)); + } + None + } + + fn stop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } + + /** + Stop, and say what to do about it. + + THE OUTPUT IS NEVER PUT IN THE MESSAGE. It is a terminal's worth of escapes at best, and at + worst it holds the token in a shape the scan did not match, which would then be handed to the + window and drawn on a screen. Whatever went wrong, the person gets a sentence they can act on. + */ + fn gave_up(&mut self, saying: &str) -> String { + self.stop(); + saying.to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Fixtures are composed from the prefix rather than written out, so no credential-shaped + /// literal sits in this repository for a scanner to find or a person to copy. + fn plan_token() -> String { + format!("{PLAN_TOKEN_PREFIX}01-{}", "AbCdEf0123456789".repeat(3)) + } + + fn api_key() -> String { + format!( + "{}03-{}", + concat!("sk", "-ant-api"), + "AbCdEf0123456789".repeat(3) + ) + } + + /// The URL comes from the hyperlink, not the wrapped text beside it. + /// + /// This is the real shape: the CLI emits an OSC-8 link whose target is intact, then draws the + /// same URL as display text with line breaks spliced into the query string. Taking the visible + /// copy gives a URL that opens and then fails on a mangled `state`. + #[test] + fn the_intact_url_is_taken_and_not_the_wrapped_one() { + let real = + "https://claude.com/cai/oauth/authorize?code=true&client_id=abc&state=intact-state"; + let output = format!( + "Browser didn't open? Use the url below to sign in\r\n\ + \u{1b}]8;id=1az7qzj;{real}\u{1b}\\\ + https://claude.com/cai/oauth/authorize?code=true&client_id=abc&sta\r\nte=BROKEN\ + \u{1b}]8;;\u{1b}\\\r\n" + ); + assert_eq!(authorize_url_in(&output).as_deref(), Some(real)); + } + + #[test] + fn no_url_before_the_cli_has_printed_one() { + assert_eq!(authorize_url_in("Welcome to Claude Code\r\n"), None); + } + + /// The prompt is only recognised once it is actually drawn. + #[test] + fn the_code_prompt_is_seen_through_the_escapes() { + let output = "\u{1b}[38;2;255;255;255mPaste\u{1b}[0m code here if prompted >"; + assert!(wants_the_code(output)); + assert!(!wants_the_code("Opening browser to sign in…")); + } + + #[test] + fn the_token_is_found_in_real_output() { + let token = plan_token(); + let output = format!( + "\u{1b}[?25l\u{1b}[1mLogin successful\u{1b}[0m\r\n\r\n\ + Set this as CLAUDE_CODE_OAUTH_TOKEN:\r\n\r\n {token}\r\n\r\n" + ); + assert_eq!(token_in(&output).as_deref(), Some(token.as_str())); + } + + #[test] + fn a_quoted_token_is_found_without_its_quotes() { + let token = plan_token(); + let output = format!("export CLAUDE_CODE_OAUTH_TOKEN=\"{token}\""); + assert_eq!(token_in(&output).as_deref(), Some(token.as_str())); + } + + /// The must-not case. An API key is not a plan token, and accepting one here would write the + /// exact credential the plan path exists to avoid: it outranks the token, so the person who + /// just signed in to a plan would be billed per request instead. + #[test] + fn an_api_key_is_not_mistaken_for_a_plan_token() { + let output = format!("your key is {}", api_key()); + assert_eq!(token_in(&output), None); + } + + /// Instructions that merely name the variable are not a token. + #[test] + fn the_instructions_alone_yield_nothing() { + assert_eq!( + token_in("Set CLAUDE_CODE_OAUTH_TOKEN to the token this prints."), + None + ); + } + + /// Token-shaped but far too short is a half-read buffer, not a credential. + #[test] + fn a_truncated_token_is_refused() { + assert_eq!(token_in(&format!("{PLAN_TOKEN_PREFIX}01-abc")), None); + } + + #[test] + fn nothing_in_nothing() { + assert_eq!(token_in(""), None); + assert_eq!(authorize_url_in(""), None); + } + + /// The stripper has to survive what a TUI actually emits, including a bare ESC pair. + #[test] + fn escapes_come_out_and_the_words_stay() { + assert_eq!(plain("\u{1b}[1mbold\u{1b}[0m plain"), "bold plain"); + assert_eq!(plain("\u{1b}]0;title\u{7}after"), "after"); + assert_eq!(plain("\u{1b}(Bkept"), "kept"); + } +} diff --git a/desktop/src-tauri/src/provider.rs b/desktop/src-tauri/src/provider.rs index 2dec6d155..495f2fd4d 100644 --- a/desktop/src-tauri/src/provider.rs +++ b/desktop/src-tauri/src/provider.rs @@ -56,8 +56,16 @@ pub fn catalogue() -> Vec { Provider { id: "openai".into(), name: "OpenAI".into(), - summary: "Sign in with ChatGPT Plus, Pro, Team or Enterprise.".into(), - logins: vec![Login::Plan, Login::ApiKey], + summary: "Use a key from your OpenAI account.".into(), + /* + * A key only, for now, and the summary says so rather than promising a sign-in. + * + * The ChatGPT plan login is proved and belongs here as the default: it is the same + * shape as the compatible row, since the login yields a token and the address to send + * it to. It is not wired to a command yet, and a screen that offers a button which + * cannot finish is worse than one that offers less. Restored the day it is. + */ + logins: vec![Login::ApiKey], mark: Some("openai".into()), caution: None, }, diff --git a/desktop/src/ProviderPicker.tsx b/desktop/src/ProviderPicker.tsx index 097431538..50637d023 100644 --- a/desktop/src/ProviderPicker.tsx +++ b/desktop/src/ProviderPicker.tsx @@ -18,6 +18,8 @@ export type ModelChoice = { provider: string; login: Login; apiKey?: string; + /** Minted by signing in, never typed. Only a plan has one. */ + token?: string; baseUrl?: string; model?: string; }; @@ -48,6 +50,48 @@ export function ProviderPicker({ const [apiKey, setApiKey] = useState(chosen?.apiKey ?? ""); const [baseUrl, setBaseUrl] = useState(chosen?.baseUrl ?? ""); const [model, setModel] = useState(chosen?.model ?? ""); + /* + * The sign-in, mid-flight. + * + * `url` present means the browser has been sent somewhere and a code is expected back. Kept here + * rather than in the Rust side's head because the screen has to show the link: an open that + * silently did nothing leaves somebody staring at a code box with no idea where the code comes + * from. + */ + const [signInUrl, setSignInUrl] = useState(null); + const [code, setCode] = useState(""); + const [token, setToken] = useState(chosen?.token ?? ""); + const [busy, setBusy] = useState(false); + const [failure, setFailure] = useState(""); + + async function beginSignIn() { + setBusy(true); + setFailure(""); + try { + setSignInUrl(await invoke("begin_claude_sign_in")); + } catch (error) { + setFailure(String(error)); + } finally { + setBusy(false); + } + } + + async function finishSignIn() { + setBusy(true); + setFailure(""); + try { + // Held, not shown. It goes on to `start_stack` the same way a typed key does. + setToken(await invoke("finish_claude_sign_in", { code })); + setSignInUrl(null); + setCode(""); + } catch (error) { + setFailure(String(error)); + // The flow is single-use, so a refused code means starting again rather than retyping. + setSignInUrl(null); + } finally { + setBusy(false); + } + } useEffect(() => { invoke("providers") @@ -60,7 +104,7 @@ export function ProviderPicker({ // What "done" means differs by the way in, and each is checked before Continue lights up rather // than after a run fails with something unreadable. const ready = - login === "plan" || + (login === "plan" && token.trim().length > 0) || (login === "api-key" && apiKey.trim().length > 0) || (login === "endpoint" && baseUrl.trim().startsWith("http") && @@ -122,12 +166,56 @@ export function ProviderPicker({
)} - {login === "plan" && ( -

- Opens {row.name} in your browser. Nothing is typed here and no key - is stored. -

- )} + {login === "plan" && + (token ? ( +

+ Signed in to {row.name}. Your plan will be used, and no key is + stored on this machine. +

+ ) : signInUrl ? ( + <> +

+ Approve the request in your browser, then paste the code it + shows you. +

+ {/* Shown as well as opened. On a machine with no registered + browser the open does nothing and says nothing, and a code + box with no link is then a dead end. */} +

+ Didn't open?{" "} + + Open the sign-in page + +

+
+ + setCode(e.target.value)} + autoComplete="off" + spellCheck={false} + /> +
+ + + ) : ( + <> +

+ Opens {row.name} in your browser. Nothing is typed here and no + key is stored. +

+ + + ))} {login === "api-key" && (
@@ -180,6 +268,12 @@ export function ProviderPicker({ )} {/* Said before it happens rather than diagnosed after the Bots stop answering. */} + {failure && ( +

+ {failure} +

+ )} + {row.caution && (

{row.caution.says}{" "} @@ -209,6 +303,7 @@ export function ProviderPicker({ provider: row.id, login, apiKey: apiKey.trim() || undefined, + token: token.trim() || undefined, baseUrl: baseUrl.trim() || undefined, model: model.trim() || undefined, }) diff --git a/desktop/src/styles.css b/desktop/src/styles.css index 03abe84d3..99bbfa98a 100644 --- a/desktop/src/styles.css +++ b/desktop/src/styles.css @@ -316,3 +316,14 @@ fieldset.picker { white-space: nowrap; border: 0; } + +/* The copyable link beside an open that may have silently failed. */ +.fallback { + font-size: 0.8rem; + color: var(--muted); + margin: 0 0 0.9rem; +} + +.fallback a { + color: var(--ink); +} From 549a083939eba52ec45aeab3e35ff2cb2ca01aeb Mon Sep 17 00:00:00 2001 From: David McKay Date: Sun, 6 Sep 2026 21:33:03 -0700 Subject: [PATCH 10/46] Publish the twelve harness images They were built by the release for nothing: not listed, so nothing pushed them, and a desktop install would have had to build each one from source with a toolchain it does not have. The list and the Dockerfiles in the tree now agree, which is what the CI check asserts. Verified on amd64 as well as arm64 for the Claude Agent SDK harness, the one that matters most: it carries a self-contained CLI binary that differs per architecture, and the sign-in flow runs it. That binary is 216 MB, so this image is large by the standards of the others. Worth knowing before it is pulled on a first run. --- .github/published-images.json | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/.github/published-images.json b/.github/published-images.json index f5fab2fbd..d460e1f33 100644 --- a/.github/published-images.json +++ b/.github/published-images.json @@ -1 +1,19 @@ -["agent-computer", "supervisor", "agent-bot", "agent-langgraph", "server"] +[ + "agent-computer", + "supervisor", + "agent-bot", + "agent-langgraph", + "server", + "agent-crewai", + "agent-llamaindex", + "agent-agno", + "agent-langgraph-agui", + "agent-adk", + "agent-pydantic-ai", + "agent-microsoft", + "agent-claude-sdk", + "agent-strands", + "agent-ag2", + "agent-langroid", + "agent-mastra" +] From a530d754c7ce3d44eaf98eef386df762445f9d68 Mon Sep 17 00:00:00 2001 From: David McKay Date: Mon, 7 Sep 2026 08:54:01 -0700 Subject: [PATCH 11/46] Offer a ChatGPT plan, and stop calling it an OpenAI-compatible endpoint A ChatGPT plan is not the compatible shape, which is what the previous mapping assumed. A plan token is a bearer for chatgpt.com/backend-api/codex, and langchain-openai pins that address and refuses a caller-supplied one on purpose, so a token cannot be aimed at somebody else's server and handed over. Writing it as OPENAI_BASE_URL plus a key would have been us hand-rolling the thing that guard exists to prevent, and the Codex path shapes its requests differently, so it would not have worked. So it is its own credential. It clears OPENAI_API_KEY and OPENAI_BASE_URL for the same reason the Claude plan clears its key: whichever is left from an earlier attempt is preferred by every client in the stack, and the person who just signed in to a plan is billed per request instead. OpenAI offers a plan again, and first. It was reduced to a key on the incorrect reading that the login was unwired; OpenAI supports subscription OAuth in other people's tools, `codex login` exists for it, and of the two named providers it is the better supported. Also fixes a bug that cost a live sign-in. The CLI positions each word with a cursor-column escape rather than a space, so stripping escapes leaves "Pastecodehereifprompted" and a match on the phrase as written never fired: the code was handed over and the flow sat in the wrong wait until it timed out, with the prompt on screen throughout. The comparison now ignores whitespace, and the test uses the shape the CLI actually writes rather than one with spaces in it, which is what passed while the bug shipped. --- desktop/src-tauri/src/env.rs | 50 +++++++++++++++++++++++++++++++ desktop/src-tauri/src/main.rs | 11 +++++-- desktop/src-tauri/src/plan.rs | 27 +++++++++++++---- desktop/src-tauri/src/provider.rs | 14 ++++----- 4 files changed, 88 insertions(+), 14 deletions(-) diff --git a/desktop/src-tauri/src/env.rs b/desktop/src-tauri/src/env.rs index ad3e250ab..61cd3252e 100644 --- a/desktop/src-tauri/src/env.rs +++ b/desktop/src-tauri/src/env.rs @@ -101,6 +101,18 @@ pub fn compose( insert_if_given(&mut env, "CLAUDE_CODE_OAUTH_TOKEN", token); env.insert("ANTHROPIC_API_KEY".into(), String::new()); } + ModelCredential::ChatGptPlan { token } => { + insert_if_given(&mut env, "CHATGPT_OAUTH_TOKEN", token); + /* + * Both cleared, for the same reason the Claude plan clears its key: a key left from an + * earlier attempt would be preferred by every OpenAI client in the stack, and the + * person who just signed in to a plan would be billed per request instead. The base URL + * is cleared too, because the Codex address is the library's to pin and not ours to + * write. + */ + env.insert("OPENAI_API_KEY".into(), String::new()); + env.insert("OPENAI_BASE_URL".into(), String::new()); + } ModelCredential::Compatible { base_url, api_key, @@ -257,6 +269,20 @@ pub enum ModelCredential { Anthropic { api_key: String }, /// A Claude plan, signed in to. The token is minted by `claude setup-token` and never typed. ClaudePlan { token: String }, + /** + A ChatGPT plan, signed in to. + + NOT the compatible shape below, and that distinction is load-bearing. A plan token is a bearer + for `https://chatgpt.com/backend-api/codex`, and `langchain-openai` PINS that address and + refuses a caller-supplied one, deliberately, so a token cannot be aimed at somebody else's + server and handed over. Writing this as `OPENAI_BASE_URL` plus a key would be us hand-rolling + the thing the library exists to prevent, and the Codex path also shapes its requests + differently, so it would not have worked anyway. + + The harness picks its model class from the presence of this token. See the harness note in the + build doc. + */ + ChatGptPlan { token: String }, /// Anything that speaks the OpenAI wire format, at an address the person gave. /// /// Also where a signed-in ChatGPT plan lands, because that login yields a token and the address @@ -665,6 +691,30 @@ mod model_tests { assert_eq!(env.get("ANTHROPIC_API_KEY"), Some(&String::new())); } + /// The must-not case for the other plan. A ChatGPT plan token is not an OpenAI key and is not + /// aimed with a base URL: the library pins the Codex address precisely so a token cannot be + /// pointed at somebody else's server, and a leftover key would outrank the plan. + #[test] + fn a_chatgpt_plan_writes_no_key_and_aims_at_nothing() { + let env = compose( + &intelligence(), + &Model { + credential: ModelCredential::ChatGptPlan { + token: "oauth-token".into(), + }, + }, + &engine(), + &Ports::default(), + &pinned(), + ); + assert_eq!( + env.get("CHATGPT_OAUTH_TOKEN"), + Some(&"oauth-token".to_string()) + ); + assert_eq!(env.get("OPENAI_API_KEY"), Some(&String::new())); + assert_eq!(env.get("OPENAI_BASE_URL"), Some(&String::new())); + } + /// An Anthropic key is written as one, and does not become an OpenAI key because that is the /// field this struct used to have. #[test] diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index ab84e1e45..f711656fd 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -158,10 +158,17 @@ impl ChosenModel { * address to send it to, which is exactly the compatible shape. It arrives here with * `base_url` already filled in by the sign-in, not by a person. */ - ("openai", "plan") | ("openai-compatible", "endpoint") => { + ("openai", "plan") => { + let token = given(self.token); + if token.is_empty() { + return Err("That ChatGPT plan was not signed in to.".into()); + } + Ok(openbot_env::ModelCredential::ChatGptPlan { token }) + } + ("openai-compatible", "endpoint") => { Ok(openbot_env::ModelCredential::Compatible { base_url: given(self.base_url), - api_key: given(self.api_key.or(self.token)), + api_key: given(self.api_key), model: given(self.model), }) } diff --git a/desktop/src-tauri/src/plan.rs b/desktop/src-tauri/src/plan.rs index 15c2dc89d..04af81b48 100644 --- a/desktop/src-tauri/src/plan.rs +++ b/desktop/src-tauri/src/plan.rs @@ -140,7 +140,19 @@ fn find_all(haystack: &str, needle: &str) -> Vec { /// Asked before writing, so a code is never typed into a prompt that is not there: written early it /// is consumed by whatever the TUI is drawing and the flow stalls with no sign of why. pub fn wants_the_code(output: &str) -> bool { - plain(output).contains("Paste code here") + /* + * Compared with the spaces taken out of both sides, because the CLI does not use spaces. + * + * It positions every word with a cursor-column escape instead — `Paste\u{1b}[7Gcode` and so on + * — so stripping the escapes leaves "Pastecodehereifprompted" and a match on the phrase as + * written never fires. This cost a live sign-in: the code was handed over, and the flow sat in + * the wrong wait until it timed out, with the prompt plainly on screen the whole time. + */ + let squashed: String = plain(output) + .chars() + .filter(|c| !c.is_whitespace()) + .collect(); + squashed.contains("Pastecodehere") } /** @@ -382,11 +394,16 @@ mod tests { assert_eq!(authorize_url_in("Welcome to Claude Code\r\n"), None); } - /// The prompt is only recognised once it is actually drawn. + /// The prompt is recognised in the shape the CLI actually writes it. + /// + /// Which is not with spaces: it moves the cursor between words. The earlier version of this + /// test used a fixture with real spaces, passed, and hid a bug that cost a live sign-in. #[test] - fn the_code_prompt_is_seen_through_the_escapes() { - let output = "\u{1b}[38;2;255;255;255mPaste\u{1b}[0m code here if prompted >"; - assert!(wants_the_code(output)); + fn the_code_prompt_is_seen_when_the_words_are_cursor_positioned() { + let real = "\u{1b}[2G\u{1b}[38;2;255;255;255mPaste\u{1b}[8Gcode\u{1b}[13Ghere\u{1b}[18Gif\u{1b}[21Gprompted\u{1b}[30G>"; + assert!(wants_the_code(real), "the real prompt shape was not seen"); + // And still when a terminal does use spaces. + assert!(wants_the_code("Paste code here if prompted >")); assert!(!wants_the_code("Opening browser to sign in…")); } diff --git a/desktop/src-tauri/src/provider.rs b/desktop/src-tauri/src/provider.rs index 495f2fd4d..879da8c30 100644 --- a/desktop/src-tauri/src/provider.rs +++ b/desktop/src-tauri/src/provider.rs @@ -56,16 +56,16 @@ pub fn catalogue() -> Vec { Provider { id: "openai".into(), name: "OpenAI".into(), - summary: "Use a key from your OpenAI account.".into(), + summary: "Sign in with ChatGPT Plus, Pro, Team or Enterprise.".into(), /* - * A key only, for now, and the summary says so rather than promising a sign-in. + * A plan first, and this is the row where that is least controversial. * - * The ChatGPT plan login is proved and belongs here as the default: it is the same - * shape as the compatible row, since the login yields a token and the address to send - * it to. It is not wired to a command yet, and a screen that offers a button which - * cannot finish is worse than one that offers less. Restored the day it is. + * OpenAI supports subscription OAuth in other people's tools: `codex login` exists for + * it, third-party harnesses are a tenth of Codex traffic, and the login yields a token + * plus the address to send it to, which is the compatible shape rather than a special + * case. Of the two named providers it is the better-supported one, not the weaker. */ - logins: vec![Login::ApiKey], + logins: vec![Login::Plan, Login::ApiKey], mark: Some("openai".into()), caution: None, }, From 0ac826a562ab487a0580c28e1048288b9cca702b Mon Sep 17 00:00:00 2001 From: David McKay Date: Mon, 7 Sep 2026 10:12:46 -0700 Subject: [PATCH 12/46] Let a Mastra Bot actually be stored `remote_mastra` went into the types and the runtime without going into the database. The enum was still built_in and remote_ag_ui, so a Mastra Bot compiled, passed its tests, and could not be written: the row was rejected at the one point nothing covered. Migration 0028 adds the value. Duplicating one kept the same shape of mistake. `runForDuplicate` wrote every endpoint-carrying source as remote_ag_ui, so a copy of a Mastra Bot would hold the right address and be unable to say anything to it: that endpoint has no AG-UI route, so the Bot appears, accepts a grant and answers nothing. The copy is now dialled the way the original was, and carries the agent it named, because a Mastra endpoint is a roster and a copy that forgets the name gets a different agent or none. --- server/drizzle/0028_mastra_agent_type.sql | 6 ++++ server/drizzle/meta/_journal.json | 7 ++++ server/src/agents/profile-store.ts | 33 +++++++++++++++++-- server/src/app.ts | 2 +- server/src/db/schema/core.ts | 8 ++++- server/tests/mastra-roster.test.ts | 39 +++++++++++++++++++++++ 6 files changed, 91 insertions(+), 4 deletions(-) create mode 100644 server/drizzle/0028_mastra_agent_type.sql diff --git a/server/drizzle/0028_mastra_agent_type.sql b/server/drizzle/0028_mastra_agent_type.sql new file mode 100644 index 000000000..c3cd160ac --- /dev/null +++ b/server/drizzle/0028_mastra_agent_type.sql @@ -0,0 +1,6 @@ +-- A Mastra Bot is a stored kind, not only a code path. +-- +-- `remote_mastra` was added to the types and to the runtime without this, so a Mastra Bot compiled, +-- passed its tests, and could not be written: the enum rejected the row. Added on its own rather +-- than folded into a later migration, because the code that reads it already shipped. +ALTER TYPE "public"."agent_type" ADD VALUE 'remote_mastra'; diff --git a/server/drizzle/meta/_journal.json b/server/drizzle/meta/_journal.json index 49201434b..db650b7fb 100644 --- a/server/drizzle/meta/_journal.json +++ b/server/drizzle/meta/_journal.json @@ -197,6 +197,13 @@ "when": 1788543624826, "tag": "0027_user_instructions", "breakpoints": true + }, + { + "idx": 28, + "version": "7", + "when": 1788801080312, + "tag": "0028_mastra_agent_type", + "breakpoints": true } ] } diff --git a/server/src/agents/profile-store.ts b/server/src/agents/profile-store.ts index 9a6bda8d0..9fc2652d5 100644 --- a/server/src/agents/profile-store.ts +++ b/server/src/agents/profile-store.ts @@ -199,6 +199,13 @@ function endpointOf(configuration: unknown): string | null { return typeof endpoint === "string" ? endpoint : null; } +/** Which agent on a Mastra server this Bot means, when the row names one. */ +function remoteAgentIdOf(configuration: unknown): string | null { + if (!configuration || typeof configuration !== "object") return null; + const named = (configuration as { remoteAgentId?: unknown }).remoteAgentId; + return typeof named === "string" && named.length > 0 ? named : null; +} + /** * The instruction a Bot in the box runs on, read back out of its stored configuration. * @@ -217,7 +224,7 @@ function systemPromptOf(configuration: unknown): string | null { /** What a coworker is, and what it runs on: the two `agents` columns a copy has to reproduce. */ export type AgentRun = { - type: "built_in" | "remote_ag_ui"; + type: "built_in" | "remote_ag_ui" | "remote_mastra"; configuration: Record; }; @@ -251,7 +258,10 @@ export type AgentRun = { * credential would mean rotating either one's key silently changed the other's. */ export function runForDuplicate( - source: { type: "built_in" | "remote_ag_ui"; configuration: unknown }, + source: { + type: "built_in" | "remote_ag_ui" | "remote_mastra"; + configuration: unknown; + }, managed: Record | undefined, ): AgentRun | null { const systemPrompt = systemPromptOf(source.configuration); @@ -261,6 +271,25 @@ export function runForDuplicate( const endpoint = endpointOf(source.configuration); if (endpoint) { + /* + * The copy is dialled the way the original was, and that is not cosmetic. A Mastra endpoint + * speaks Mastra's client protocol and has no AG-UI route, so a duplicate written as + * `remote_ag_ui` would carry the right address and be unable to say anything to it. The Bot + * would appear, accept a grant, and answer nothing. + * + * Which agent on that server comes with it for the same reason: a Mastra endpoint is a roster, + * and a copy that forgets the name falls back to a different agent, or refuses. See + * `pickFromRoster`. + */ + if (source.type === "remote_mastra") { + const remoteAgentId = remoteAgentIdOf(source.configuration); + return { + type: "remote_mastra", + configuration: remoteAgentId + ? { endpoint, remoteAgentId } + : { endpoint }, + }; + } return { type: "remote_ag_ui", configuration: { endpoint } }; } diff --git a/server/src/app.ts b/server/src/app.ts index d0f38a312..5beab2228 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -6,9 +6,9 @@ import type { BotAccessCheck } from "./agents/profile-policy"; import type { AgentProfileStore } from "./agents/profile-store"; import { createAgentRoutes } from "./agents/routes"; import { + AuditQueryError, type AuditReader, type AuditStore, - AuditQueryError, auditQueryFromUrl, recordAuditEvent, } from "./audit"; diff --git a/server/src/db/schema/core.ts b/server/src/db/schema/core.ts index e2bcdf80e..e7ee534e4 100644 --- a/server/src/db/schema/core.ts +++ b/server/src/db/schema/core.ts @@ -21,7 +21,13 @@ const updatedAt = () => timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(); export const role = pgEnum("role", ["admin", "user"]); -export const agentType = pgEnum("agent_type", ["built_in", "remote_ag_ui"]); +export const agentType = pgEnum("agent_type", [ + "built_in", + "remote_ag_ui", + // A Mastra server, reached through `@ag-ui/mastra` rather than an AG-UI route of its own. Governed + // identically: the difference ends at `remoteTransport`. See migration 0028. + "remote_mastra", +]); export const credentialKind = pgEnum("credential_kind", [ "model", "connector", diff --git a/server/tests/mastra-roster.test.ts b/server/tests/mastra-roster.test.ts index e9681a628..f58fe3a0e 100644 --- a/server/tests/mastra-roster.test.ts +++ b/server/tests/mastra-roster.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test"; +import { runForDuplicate } from "../src/agents/profile-store"; import { pickFromRoster } from "../src/copilot"; describe("which agent on a Mastra server a Bot means", () => { @@ -39,3 +40,41 @@ describe("which agent on a Mastra server a Bot means", () => { ); }); }); + +describe("duplicating a Mastra Bot", () => { + test("the copy is still dialled as Mastra, carrying the agent it named", () => { + // The must-not case. Written as `remote_ag_ui` the copy holds the right address and cannot say + // anything to it: a Mastra endpoint has no AG-UI route, so the Bot appears, takes a grant and + // answers nothing. + expect( + runForDuplicate( + { + type: "remote_mastra", + configuration: { + endpoint: "http://mastra.test", + remoteAgentId: "openbot", + }, + }, + undefined, + ), + ).toEqual({ + type: "remote_mastra", + configuration: { + endpoint: "http://mastra.test", + remoteAgentId: "openbot", + }, + }); + }); + + test("an AG-UI Bot is untouched by that", () => { + expect( + runForDuplicate( + { type: "remote_ag_ui", configuration: { endpoint: "http://a.test" } }, + undefined, + ), + ).toEqual({ + type: "remote_ag_ui", + configuration: { endpoint: "http://a.test" }, + }); + }); +}); From 5b872191e7d23893b7939067c67717695a8cef63 Mon Sep 17 00:00:00 2001 From: David McKay Date: Mon, 7 Sep 2026 10:15:05 -0700 Subject: [PATCH 13/46] Let a Mastra Bot be seeded, which is how a Bot arrives Bots come from the tenant package, so a kind the package cannot express is a kind a deployment cannot start with. `agents.yaml` took built-in and remote-ag-ui only, which is the third place `remote_mastra` was missing after the types and the database. `remote_agent_id` comes with it, because a Mastra endpoint is a roster: a seeded Bot that cannot name which agent it means is as specific as one added by hand, and no more. It is Mastra's field alone, so an AG-UI Bot does not pick it up and store something that reads back meaning nothing. The refusal names all three kinds rather than the two it used to. --- server/src/tenant-package.ts | 28 +++++++++++++--- server/tests/tenant-package.test.ts | 50 +++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 4 deletions(-) diff --git a/server/src/tenant-package.ts b/server/src/tenant-package.ts index 549b51a24..78b7522ae 100644 --- a/server/src/tenant-package.ts +++ b/server/src/tenant-package.ts @@ -140,7 +140,7 @@ type TenantAgent = { title: string; roleDescription: string; avatarSeed?: string; - type: "built_in" | "remote_ag_ui"; + type: "built_in" | "remote_ag_ui" | "remote_mastra"; configuration: Record; /** * The package skills this coworker is given, by slug. @@ -331,9 +331,16 @@ export function validateTenantPackage(files: PackageFiles): TenantPackage { ? "built_in" : agent.type === "remote-ag-ui" ? "remote_ag_ui" - : undefined; + : // A Mastra server, dialled through `@ag-ui/mastra` rather than an AG-UI route of its + // own. Seedable like the others: it is an address, and the same one this deployment + // would have been given by hand. + agent.type === "remote-mastra" + ? "remote_mastra" + : undefined; if (!type) { - throw new Error("agent.type must be built-in or remote-ag-ui"); + throw new Error( + "agent.type must be built-in, remote-ag-ui or remote-mastra", + ); } const id = requiredString(agent.id, "agent.id"); /* @@ -350,7 +357,7 @@ export function validateTenantPackage(files: PackageFiles): TenantPackage { `agent.id "${id}" is reserved for a deployment route and cannot name a Bot`, ); } - if (type === "remote_ag_ui") { + if (type === "remote_ag_ui" || type === "remote_mastra") { const endpoint = typeof agent.endpoint === "string" ? agent.endpoint.trim() : ""; if (!endpoint) { @@ -382,6 +389,19 @@ export function validateTenantPackage(files: PackageFiles): TenantPackage { } : { endpoint: requiredString(agent.endpoint, "agent.endpoint"), + /* + * Which agent on that server, when the server is a roster. + * + * Optional, and only meaningful for Mastra: a package naming one gets that one, + * and a package naming none gets the only agent there or a refusal. Carried here + * so a seeded Mastra Bot is as specific as one added by hand. See + * `pickFromRoster`. + */ + ...(type === "remote_mastra" && + typeof agent.remote_agent_id === "string" && + agent.remote_agent_id.trim().length > 0 + ? { remoteAgentId: agent.remote_agent_id.trim() } + : {}), }, skills: agent.skills === undefined || agent.skills === null diff --git a/server/tests/tenant-package.test.ts b/server/tests/tenant-package.test.ts index 819eb5556..a39d03a16 100644 --- a/server/tests/tenant-package.test.ts +++ b/server/tests/tenant-package.test.ts @@ -142,6 +142,56 @@ describe("tenant theme validation", () => { }); }); +describe("a seeded Mastra Bot", () => { + const withAgent = (agent: string) => + validateTenantPackage({ + brand: "tenant: { id: fintech, product_name: Ledgerline }", + agents: `agents: [${agent}]`, + channels: "channels: []", + model: + "model: { provider: openai, credential_secret_ref: openai-key, default_model: gpt-5.6-terra }", + knowledge: "sources: []", + themeCss: "", + }); + + test("is seeded as its own kind, carrying the agent it names", () => { + const [agent] = withAgent( + "{ id: research, name: Research, title: Research, role_description: Look things up., type: remote-mastra, endpoint: http://mastra.internal, remote_agent_id: openbot }", + ).agents; + expect(agent?.type).toBe("remote_mastra"); + expect(agent?.configuration).toEqual({ + endpoint: "http://mastra.internal", + remoteAgentId: "openbot", + }); + }); + + test("naming no agent is allowed, and means the only one there", () => { + const [agent] = withAgent( + "{ id: research, name: Research, title: Research, role_description: Look things up., type: remote-mastra, endpoint: http://mastra.internal }", + ).agents; + expect(agent?.configuration).toEqual({ + endpoint: "http://mastra.internal", + }); + }); + + test("an AG-UI Bot never picks up a remote agent id", () => { + // The field is Mastra's alone. Carried onto an AG-UI Bot it would be stored, read back, and + // mean nothing, which is the kind of dead configuration somebody later tries to honour. + const [agent] = withAgent( + "{ id: risk, name: Risk, title: Risk, role_description: Check things., type: remote-ag-ui, endpoint: http://risk.internal, remote_agent_id: ignored }", + ).agents; + expect(agent?.configuration).toEqual({ endpoint: "http://risk.internal" }); + }); + + test("a kind nobody serves is refused by name", () => { + expect(() => + withAgent( + "{ id: x, name: X, title: X, role_description: Y., type: remote-whatever, endpoint: http://x.test }", + ), + ).toThrow("agent.type must be built-in, remote-ag-ui or remote-mastra"); + }); +}); + describe("tenant YAML validation", () => { test("rejects an agent without a title", () => { expect(() => From 0d2eb06ff82cec85f6797efad162fe4c7c0f7fa7 Mon Sep 17 00:00:00 2001 From: David McKay Date: Mon, 7 Sep 2026 10:49:04 -0700 Subject: [PATCH 14/46] Actually submit the code, and say so when it is refused MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sign-in never worked and reported that the code was refused, when the code had never been read. Two bugs, both in how the prompt was answered. Enter on a terminal is a carriage return, and a TUI reading a pty in raw mode takes that rather than a line feed. And the return has to arrive on its own, after a pause: the CLI turns on bracketed paste and a code arrives as one burst, so a 32-character code with the return in the same write submitted fine while a 92-character one did not. It sat in the prompt, masked, until the wait expired. Found by dumping the transcript, which is what the sign-in now writes when OPENBOT_SIGNIN_TRANSCRIPT names a file. Off by default and never in a build somebody installs, because the transcript can hold the token: the case worth looking at is a token printed in a shape the scan missed, which is exactly the case where the file is a live credential. A refused code is also its own answer now. The CLI prints `OAuth error: …` and offers to retry, so there is nothing further to wait for, and waiting out the timeout to then say something vague is how the first bug stayed hidden. Verified at the real code length before asking anybody to sign in again: a 92-character code now comes back refused rather than silently ignored. --- desktop/src-tauri/src/plan.rs | 96 ++++++++++++++++++++++++++++++++--- 1 file changed, 90 insertions(+), 6 deletions(-) diff --git a/desktop/src-tauri/src/plan.rs b/desktop/src-tauri/src/plan.rs index 04af81b48..3c2f3f0de 100644 --- a/desktop/src-tauri/src/plan.rs +++ b/desktop/src-tauri/src/plan.rs @@ -155,6 +155,23 @@ pub fn wants_the_code(output: &str) -> bool { squashed.contains("Pastecodehere") } +/** +Whether the endpoint refused the code. + +Its own answer because the alternative is waiting out the timeout and then saying something vague. +The CLI prints `OAuth error: …` and offers to retry, so it stays alive and there is nothing further +to wait for: the code is spent either way and the flow has to start again. +*/ +pub fn refused_the_code(output: &str) -> bool { + // Whitespace-insensitive, for the same reason as the prompt: the words are cursor-positioned + // rather than spaced, so the phrase as written never appears in the stripped text. + let squashed: String = plain(output) + .chars() + .filter(|c| !c.is_whitespace()) + .collect(); + squashed.contains("OAuthError:") || squashed.contains("OAutherror:") +} + /** The token in whatever the command printed. @@ -295,21 +312,67 @@ impl SigningIn { { return Err(self.gave_up("The sign-in stopped before it asked for the code.")); } - // Trimmed, because a code arrives pasted and a trailing newline or space is the person's - // clipboard rather than their intent. - writeln!(self.writer, "{}", code.trim()) + /* + * `\r`, NOT `\n`, and this is the difference between working and silently not. + * + * Enter on a terminal is a carriage return, and a TUI reading a pty in raw mode takes that + * and not a line feed. Sent `\n` the code appears in the prompt, masked, and is never + * submitted: the flow then times out and reports the code was refused, when nothing had + * looked at it. Found by dumping the transcript, which ended with the prompt and exactly as + * many asterisks as the code had characters. + * + * Trimmed, because a code arrives pasted and a trailing newline or space is the person's + * clipboard rather than their intent. + */ + write!(self.writer, "{}", code.trim()) + .map_err(|e| format!("The code could not be sent to the sign-in: {e}"))?; + self.writer + .flush() + .map_err(|e| format!("The code could not be sent to the sign-in: {e}"))?; + + /* + * Enter goes separately, after a pause, and both details are load-bearing. + * + * `\r` rather than `\n` because Enter on a terminal is a carriage return and a TUI reading a + * pty in raw mode takes that. And on its own rather than appended, because the CLI turns on + * bracketed paste and a code arrives as one burst: a 32-character code with the return in + * the same write submitted fine, and a 92-character one did not — it sat in the prompt, + * masked, until the wait expired, and was then reported as refused when nothing had read it. + * Two writes with a gap makes the return a keypress after the input has settled rather than + * the tail of a paste. + */ + std::thread::sleep(Duration::from_millis(250)); + write!(self.writer, "\r") .map_err(|e| format!("The code could not be sent to the sign-in: {e}"))?; self.writer .flush() .map_err(|e| format!("The code could not be sent to the sign-in: {e}"))?; - match self.wait_for(token_in, PATIENCE_FOR_THE_TOKEN) { - Some(token) => { + // Either answer ends the wait. Watching only for the token means a refused code costs the + // whole timeout and is then reported as though nothing had happened. + enum Outcome { + Token(String), + Refused, + } + let outcome = self.wait_for( + |seen| { + token_in(seen) + .map(Outcome::Token) + .or_else(|| refused_the_code(seen).then_some(Outcome::Refused)) + }, + PATIENCE_FOR_THE_TOKEN, + ); + + match outcome { + Some(Outcome::Token(token)) => { self.stop(); Ok(token) } + Some(Outcome::Refused) => Err(self.gave_up( + "That code was refused. A code can only be used once and does not last long, so start the sign-in again and bring back a fresh one.", + )), None => Err(self.gave_up( - "That code was not accepted. Start the sign-in again and copy the code from the browser once more.", + "That sign-in did not finish. Start it again and approve the request in your browser.", )), } } @@ -348,6 +411,19 @@ impl SigningIn { window and drawn on a screen. Whatever went wrong, the person gets a sentence they can act on. */ fn gave_up(&mut self, saying: &str) -> String { + /* + * A way to see what the terminal actually said, for diagnosing this by hand. + * + * Off unless `OPENBOT_SIGNIN_TRANSCRIPT` names a file, because the transcript can contain + * the token: a sign-in that printed one in a shape the scan did not match is exactly the + * case worth looking at, and exactly the case where the file holds a live credential. Never + * on in a build somebody installs, and never in the message handed to the window. + */ + if let Ok(path) = std::env::var("OPENBOT_SIGNIN_TRANSCRIPT") { + if let Ok(seen) = self.output.lock() { + let _ = std::fs::write(path, seen.as_str()); + } + } self.stop(); saying.to_string() } @@ -407,6 +483,14 @@ mod tests { assert!(!wants_the_code("Opening browser to sign in…")); } + /// The refusal, in the shape the CLI writes it. Taken from a real run with a bad code. + #[test] + fn a_refused_code_is_recognised() { + let real = "Paste\u{1b}[8Gcode\u{1b}[13Ghere> ****\r\n\u{1b}[2GOAuth\u{1b}[8Gerror:\u{1b}[15GRequest\u{1b}[23Gfailed\u{1b}[30Gwith\u{1b}[35Gstatus\u{1b}[42Gcode\u{1b}[47G400\r\nPress\u{1b}[7GEnter\u{1b}[13Gto\u{1b}[16Gretry."; + assert!(refused_the_code(real), "the real refusal was not seen"); + assert!(!refused_the_code("Paste code here if prompted >")); + } + #[test] fn the_token_is_found_in_real_output() { let token = plan_token(); From db9aa47cc2d257f60b85feb40e113736f841d916 Mon Sep 17 00:00:00 2001 From: David McKay Date: Mon, 7 Sep 2026 11:02:28 -0700 Subject: [PATCH 15/46] Name the images a release actually publishes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every one of the twelve was wrong. The catalogue derived image names from the row's id and the release derives them from the directory holding the Dockerfile, so the picker named `openbot-harness-crewai` against a published `openbot-agent-crewai`, and named it wrong twice for the four rows whose id is not their folder. Nothing caught it: a wrong image name is correct Rust, and it fails at the pull, on somebody's first run, with nothing on screen to say why. The directory is given per row now rather than derived, and a test reads .github/published-images.json — the same file CI checks against the tree — so the picker, the tests and the release agree or it fails here. Each row also carries the port its image listens on, which is fixed by that image's Dockerfile and differs per harness. The service that runs the picked harness has to be told, and the endpoint its Bot is registered at is built from it. Two more tests: an image without a port is a row that cannot be run, and two harnesses sharing a port would be one service that cannot serve both. --- desktop/src-tauri/src/harness.rs | 112 ++++++++++++++++++++++++++++++- 1 file changed, 109 insertions(+), 3 deletions(-) diff --git a/desktop/src-tauri/src/harness.rs b/desktop/src-tauri/src/harness.rs index 3bcc40485..072d14766 100644 --- a/desktop/src-tauri/src/harness.rs +++ b/desktop/src-tauri/src/harness.rs @@ -60,6 +60,12 @@ pub struct Harness { pub image: Option, /// Where the container says it is ready. pub health_path: Option, + /// The port the image listens on, which differs per harness and is fixed by its Dockerfile. + /// + /// Carried because the one compose service that runs the picked harness has to be told, and + /// because the endpoint the Bot is registered at is built from it. `None` only for the row where + /// the person supplies the address. + pub port: Option, pub credential: Credential, pub maintainer: Maintainer, /// The vendored mark's file stem, or `None` where no maintained set has one. @@ -92,11 +98,27 @@ pub fn catalogue() -> Vec { // The three with none are named here rather than discovered at draw time, because a missing // file and a brand with no mark are different things and only one of them is a bug. const UNMARKED: [&str; 3] = ["agno", "ag2", "langroid"]; - let ours = |id: &str, name: &str, summary: &str, maintainer: Maintainer| Harness { + /* + * The directory is given, not derived from the id, and that is deliberate. + * + * A release publishes `openbot-`, taken from the Dockerfile paths in the tree, so + * the image name belongs to the directory and not to whatever this list calls the row. Derived + * from the id it was wrong for every row — `openbot-harness-crewai` against a published + * `openbot-agent-crewai` — and wrong twice for the four whose id does not match their folder. + * A picker that names an image nobody publishes fails at the pull, on a first run, with nothing + * on screen to say why. `every_image_is_one_a_release_publishes` holds it. + */ + let ours = |id: &str, + directory: &str, + port: u16, + name: &str, + summary: &str, + maintainer: Maintainer| Harness { id: id.into(), name: name.into(), summary: summary.into(), - image: Some(format!("openbot-harness-{id}")), + image: Some(format!("openbot-{directory}")), + port: Some(port), health_path: Some("/health".into()), credential: Credential::AnyProvider, maintainer, @@ -106,42 +128,56 @@ pub fn catalogue() -> Vec { vec![ ours( "crewai", + "agent-crewai", + 4202, "CrewAI", "Crews of agents with roles and tasks.", Maintainer::Partnership, ), ours( "llamaindex", + "agent-llamaindex", + 4204, "LlamaIndex", "Agents built around your own documents.", Maintainer::FirstParty, ), ours( "agno", + "agent-agno", + 4203, "Agno", "Fast, small, and multi-modal.", Maintainer::FirstParty, ), ours( "langgraph", + "agent-langgraph-agui", + 4206, "LangGraph", "Graphs you can change, from LangChain.", Maintainer::Partnership, ), ours( "google-adk", + "agent-adk", + 4208, "Google ADK", "Google's agent kit. Gemini first, any model after.", Maintainer::FirstParty, ), ours( "pydantic-ai", + "agent-pydantic-ai", + 4205, "Pydantic AI", "Typed agents, validated in and out.", Maintainer::FirstParty, ), ours( "microsoft-agent-framework", + "agent-microsoft", + 4211, "Microsoft Agent Framework", "Microsoft's, model-agnostic by design.", Maintainer::FirstParty, @@ -150,7 +186,8 @@ pub fn catalogue() -> Vec { id: "claude-agent-sdk".into(), name: "Claude Agent SDK".into(), summary: "Anthropic's own. The one that takes a Claude plan instead of a key.".into(), - image: Some("openbot-harness-claude-agent-sdk".into()), + image: Some("openbot-agent-claude-sdk".into()), + port: Some(4212), health_path: Some("/health".into()), credential: Credential::Anthropic, maintainer: Maintainer::Community, @@ -158,24 +195,32 @@ pub fn catalogue() -> Vec { }, ours( "strands", + "agent-strands", + 4207, "AWS Strands", "Amazon's. Bedrock first, any model after.", Maintainer::FirstParty, ), ours( "ag2", + "agent-ag2", + 4210, "AG2", "The AutoGen line, continued.", Maintainer::FirstParty, ), ours( "langroid", + "agent-langroid", + 4209, "Langroid", "Multi-agent, deliberately small.", Maintainer::Community, ), ours( "mastra", + "agent-mastra", + 4213, "Mastra", "TypeScript agents, with their own server.", Maintainer::Partnership, @@ -186,6 +231,7 @@ pub fn catalogue() -> Vec { summary: "Give its address. It is proved with a real AG-UI run before it is saved." .into(), image: None, + port: None, health_path: None, credential: Credential::TheirEndpoint, maintainer: Maintainer::Community, @@ -255,6 +301,66 @@ mod tests { } } + /** + Every image this list names is one a release actually publishes. + + The guard on the defect that made this test exist: image names were derived from the row's id + and the release derives them from the directory, so all twelve named something that would never + be pushed. Nothing caught it, because a wrong image name is correct Rust and fails at the pull + on somebody's first run. + + Read from `.github/published-images.json`, which is the same file CI checks against the + Dockerfiles in the tree, so the picker, the tests and the release all agree or this fails. + */ + #[test] + fn every_image_is_one_a_release_publishes() { + let listed = std::fs::read_to_string( + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../.github/published-images.json"), + ) + .expect("published-images.json is not where this test expects it"); + // Crude on purpose: a substring check needs no JSON parser in a build with no reason to + // carry one, and the file is a flat list of quoted names. + for harness in catalogue() { + let Some(image) = harness.image else { continue }; + let component = image + .strip_prefix("openbot-") + .expect("a harness image is named openbot-"); + assert!( + listed.contains(&format!("\"{component}\"")), + "{} names image {image}, which no release publishes", + harness.id + ); + } + } + + /// A harness that is pulled has to say which port it listens on, because the one service that + /// runs it is told, and the endpoint the Bot is registered at is built from it. + #[test] + fn a_pulled_harness_names_its_port() { + for harness in catalogue() { + assert_eq!( + harness.image.is_some(), + harness.port.is_some(), + "{} has an image and no port, or a port and no image", + harness.id + ); + } + } + + /// Two harnesses on one port would be one service that cannot run both, and a Bot registered at + /// an address belonging to the other. + #[test] + fn no_two_harnesses_share_a_port() { + let mut seen = std::collections::BTreeMap::new(); + for harness in catalogue() { + let Some(port) = harness.port else { continue }; + if let Some(other) = seen.insert(port, harness.id.clone()) { + panic!("{} and {} both claim port {port}", harness.id, other); + } + } + } + /// A named mark has to be a file that is actually there. The failure this catches is silent at /// runtime: a row asks for a mark that was never vendored, and the tile draws empty, which /// looks like a rendering bug rather than a missing asset. From 384f7a9a1ff83968cc0a712e7d90619f1fa93c07 Mon Sep 17 00:00:00 2001 From: David McKay Date: Mon, 7 Sep 2026 11:06:08 -0700 Subject: [PATCH 16/46] Register the picked harness, by seeding rather than by an API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Picking a harness did nothing: the picker was cosmetic, and nothing in the product turned a choice into a Bot. There is no create endpoint to call for this — Bots come from the tenant package — so the choice becomes settings and seeding does the registration. Nothing new had to be built to make a Bot appear. agents.yaml carries two gated rows, one per way of dialling, and at most one ever exists: the loader drops a Bot whose endpoint interpolates to nothing, and the shell writes exactly one of the two addresses. Two rows rather than one because the kind cannot be interpolated, and a Mastra server has no AG-UI route to talk to. A test holds that both are never written, since both would register the same harness twice, once as a kind that cannot reach it. One compose service runs whichever was picked, with its image and port from .env, because a picked harness is a choice and not twelve branches in that file. Behind a profile: unset, the image is a request to pull the empty string, which fails the whole `up` rather than the one service nobody asked for. Addressed on loopback rather than by service name, because the server is a host process here and reaches agent-bot the same way. The window sends the id alone. The image, the port and the kind are facts about the harness, and sending them would be a second list to keep in step with the catalogue. --- desktop/src-tauri/src/env.rs | 139 +++++++++++++++++++++++++++++++++ desktop/src-tauri/src/main.rs | 41 +++++++++- desktop/src-tauri/src/stack.rs | 21 ++++- desktop/src/App.tsx | 3 + docker-compose.yml | 35 +++++++++ examples/fintech/agents.yaml | 26 ++++++ 6 files changed, 262 insertions(+), 3 deletions(-) diff --git a/desktop/src-tauri/src/env.rs b/desktop/src-tauri/src/env.rs index 61cd3252e..64ec1a2e1 100644 --- a/desktop/src-tauri/src/env.rs +++ b/desktop/src-tauri/src/env.rs @@ -75,6 +75,8 @@ pub fn compose( engine: &EngineStatus, ports: &Ports, images: &[(String, String)], + // Absent means no harness was picked, and the package's gated rows stay dropped. + harness: Option<&PickedHarness>, ) -> BTreeMap { let mut env = BTreeMap::new(); @@ -167,6 +169,32 @@ pub fn compose( format!("http://127.0.0.1:{}/ag-ui", ports.langgraph), ); + /* + * The picked harness, if there is one. + * + * Addressed on loopback rather than by a compose service name, because the server is a host + * process here and not a container: it reaches `agent-bot` and `agent-langgraph` the same way, + * over the port those services publish. + * + * ONE OF THE TWO URLS, NEVER BOTH. The package carries a gated row per kind, and each drops + * itself when its endpoint is blank, so writing both would register the same harness twice — + * once as a kind that cannot speak to it. + */ + if let Some(picked) = harness { + env.insert("PICKED_HARNESS_IMAGE".into(), picked.image.clone()); + env.insert("PICKED_HARNESS_PORT".into(), picked.port.to_string()); + env.insert("PICKED_HARNESS_NAME".into(), picked.name.clone()); + let url = format!("http://127.0.0.1:{}", picked.port); + if picked.mastra { + env.insert("PICKED_HARNESS_MASTRA_URL".into(), url); + env.insert("PICKED_HARNESS_AG_UI_URL".into(), String::new()); + insert_if_given(&mut env, "PICKED_HARNESS_AGENT_ID", &picked.remote_agent_id); + } else { + env.insert("PICKED_HARNESS_AG_UI_URL".into(), url); + env.insert("PICKED_HARNESS_MASTRA_URL".into(), String::new()); + } + } + // Without this the server gives every Bot the same browser. It is the difference between the // product this installs and a demo of it. env.insert( @@ -241,6 +269,30 @@ pub struct Intelligence { pub api_key: String, } +/** +The harness somebody picked, as the deployment has to describe it. + +Registration is not an API call in this product: Bots come from the tenant package, whose +`agents.yaml` interpolates `${...}` and drops any Bot whose endpoint comes out blank. So a picked +harness becomes these settings, the package's own gated row materialises, and seeding registers it. +Nothing new had to be built to make a Bot appear. + +`None` is a deployment that has not picked one, which writes nothing and leaves those rows dropped. +*/ +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PickedHarness { + /// The published image, e.g. `openbot-agent-crewai`. Named by the release, not derived. + pub image: String, + /// The port that image listens on, fixed by its own Dockerfile. + pub port: u16, + /// What the Bot is called on screen. + pub name: String, + /// How it is dialled. A Mastra server has no AG-UI route of its own. + pub mastra: bool, + /// Which agent on that server, for a Mastra roster. Empty means the only one there. + pub remote_agent_id: String, +} + /// The model credential, which belongs to the provider and not to the harness. /// /// Both Bots the deployment ships refuse to start without one, saying so plainly: "This Bot cannot @@ -365,6 +417,7 @@ mod tests { &engine_status(None), &Ports::default(), &pinned(), + None, ); for key in [ "COMPUTER_TOKEN", @@ -389,6 +442,7 @@ mod tests { &engine_status(None), &Ports::default(), &pinned(), + None, ); let b = compose( &intelligence(), @@ -396,6 +450,7 @@ mod tests { &engine_status(None), &Ports::default(), &pinned(), + None, ); assert_ne!(a.get("KEY_ENCRYPTION_KEY"), b.get("KEY_ENCRYPTION_KEY")); } @@ -408,6 +463,7 @@ mod tests { &engine_status(None), &Ports::default(), &pinned(), + None, ); assert_eq!( env.get("AGENT_COMPUTER_ALLOW_PRIVATE_HOSTS") @@ -425,6 +481,7 @@ mod tests { &engine_status(None), &Ports::default(), &pinned(), + None, ); assert_eq!( env.get("TENANT_PACKAGE_DIR").map(String::as_str), @@ -440,6 +497,7 @@ mod tests { &engine_status(None), &Ports::default(), &pinned(), + None, ); assert_eq!( env.get("OPENBOT_SINGLE_USER").map(String::as_str), @@ -455,6 +513,7 @@ mod tests { &engine_status(None), &Ports::default(), &pinned(), + None, ); assert_eq!( env.get("SERVER_INTERNAL_URL").map(String::as_str), @@ -470,6 +529,7 @@ mod tests { &engine_status(None), &Ports::default(), &pinned(), + None, ); assert_eq!( env.get("COMPUTER_SUPERVISOR_URL").map(String::as_str), @@ -485,6 +545,7 @@ mod tests { &engine_status(None), &Ports::default(), &pinned(), + None, ); assert!(!without.contains_key("ENGINE_SOCKET")); @@ -494,6 +555,7 @@ mod tests { &engine_status(Some("/run/user/501/podman/podman.sock")), &Ports::default(), &pinned(), + None, ); assert_eq!( with.get("ENGINE_SOCKET").map(String::as_str), @@ -509,6 +571,7 @@ mod tests { &engine_status(None), &Ports::default(), &pinned(), + None, ); for key in [ "DATABASE_URL", @@ -533,6 +596,7 @@ mod tests { &engine_status(None), &Ports::default(), &pinned(), + None, ); write(&path, &env).unwrap(); @@ -558,6 +622,7 @@ mod tests { &engine_status(None), &Ports::default(), &pinned(), + None, ); write(&path, &first).unwrap(); let second = compose( @@ -566,6 +631,7 @@ mod tests { &engine_status(None), &Ports::default(), &pinned(), + None, ); write(&path, &second).unwrap(); @@ -621,6 +687,7 @@ mod model_tests { &engine(), &Ports::default(), &pinned(), + None, ); for (_, variable) in crate::deployment::IMAGE_VARIABLES { let reference = env @@ -642,6 +709,7 @@ mod model_tests { &engine(), &Ports::default(), &pinned(), + None, ); assert_eq!( env.get("OPENAI_API_KEY").map(String::as_str), @@ -661,6 +729,7 @@ mod model_tests { &engine(), &Ports::default(), &pinned(), + None, ); assert!(!env.contains_key("OPENAI_API_KEY")); } @@ -683,6 +752,7 @@ mod model_tests { &engine(), &Ports::default(), &pinned(), + None, ); assert_eq!( env.get("CLAUDE_CODE_OAUTH_TOKEN"), @@ -691,6 +761,71 @@ mod model_tests { assert_eq!(env.get("ANTHROPIC_API_KEY"), Some(&String::new())); } + /// The must-not case for registration. Both addresses written, and the package's two gated + /// rows both materialise: the same harness is registered twice, once as a kind that cannot + /// speak to it, and the second Bot answers nothing. + #[test] + fn a_picked_harness_is_addressed_one_way_only() { + for mastra in [false, true] { + let env = compose( + &intelligence(), + &Model::default(), + &engine(), + &Ports::default(), + &pinned(), + Some(&PickedHarness { + image: "openbot-agent-crewai".into(), + port: 4202, + name: "CrewAI".into(), + mastra, + remote_agent_id: String::new(), + }), + ); + let ag_ui = env + .get("PICKED_HARNESS_AG_UI_URL") + .cloned() + .unwrap_or_default(); + let mastra_url = env + .get("PICKED_HARNESS_MASTRA_URL") + .cloned() + .unwrap_or_default(); + assert!( + ag_ui.is_empty() != mastra_url.is_empty(), + "mastra={mastra} wrote ag-ui={ag_ui:?} and mastra={mastra_url:?}" + ); + // Loopback, because the server is a host process and not a container. + assert!( + ag_ui.starts_with("http://127.0.0.1:4202") + || mastra_url.starts_with("http://127.0.0.1:4202"), + "the address is not the image's own port on loopback" + ); + } + } + + /// Nothing picked writes none of it, so the package's gated rows stay dropped. + #[test] + fn no_harness_picked_writes_no_harness_settings() { + let env = compose( + &intelligence(), + &Model::default(), + &engine(), + &Ports::default(), + &pinned(), + None, + ); + for key in [ + "PICKED_HARNESS_IMAGE", + "PICKED_HARNESS_PORT", + "PICKED_HARNESS_AG_UI_URL", + "PICKED_HARNESS_MASTRA_URL", + ] { + assert!( + !env.contains_key(key), + "{key} was written with nothing picked" + ); + } + } + /// The must-not case for the other plan. A ChatGPT plan token is not an OpenAI key and is not /// aimed with a base URL: the library pins the Codex address precisely so a token cannot be /// pointed at somebody else's server, and a leftover key would outrank the plan. @@ -706,6 +841,7 @@ mod model_tests { &engine(), &Ports::default(), &pinned(), + None, ); assert_eq!( env.get("CHATGPT_OAUTH_TOKEN"), @@ -729,6 +865,7 @@ mod model_tests { &engine(), &Ports::default(), &pinned(), + None, ); assert_eq!( env.get("ANTHROPIC_API_KEY"), @@ -753,6 +890,7 @@ mod model_tests { &engine(), &Ports::default(), &pinned(), + None, ); assert_eq!( env.get("OPENAI_BASE_URL"), @@ -773,6 +911,7 @@ mod model_tests { &engine(), &Ports::default(), &pinned(), + None, ); for key in [ "OPENAI_API_KEY", diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index f711656fd..14cdbe60b 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -188,9 +188,46 @@ async fn start_stack( gateway_ws_url: String, api_key: String, model: ChosenModel, + // The row the person picked, by id. Absent registers no Bot of their own. + harness: Option, ) -> Result<(), String> { let root = PathBuf::from(root); + /* + * Resolved from the catalogue rather than taken from the window. + * + * The image, the port and how it is dialled are facts about the harness, and the window + * knowing them would mean two lists to keep in step. An id that is not in the catalogue is + * refused here rather than written into `.env`, where it would become a Bot pointing at a + * container nobody started. + */ + let picked = match harness.as_deref().filter(|id| !id.trim().is_empty()) { + None => None, + Some("byo-url") => None, + Some(id) => { + let row = harness::catalogue() + .into_iter() + .find(|row| row.id == id) + .ok_or_else(|| format!("There is no Bot called \"{id}\" to install."))?; + let (Some(image), Some(port)) = (row.image, row.port) else { + return Err(format!("\"{id}\" is not a Bot this can install.")); + }; + Some(openbot_env::PickedHarness { + image: format!("{image}:{DEPLOYMENT_VERSION}"), + port, + name: row.name, + mastra: row.id == "mastra", + // Our own Mastra image serves one agent, named for the product. A person pointing + // at their own Mastra server names theirs on the Bot's page. + remote_agent_id: if row.id == "mastra" { + "openbot".to_string() + } else { + String::new() + }, + }) + } + }; + // The installer does not carry the deployment; it fetches one. Skipped when the recorded // version already matches, so a restart is not a download. if deployment::needs_fetch(&root, DEPLOYMENT_VERSION) { @@ -254,6 +291,7 @@ async fn start_stack( &status, &openbot_env::Ports::default(), &deployment::image_variables(&root)?, + picked.as_ref(), ); openbot_env::write(&root.join(".env"), &settings) .map_err(|e| format!("could not write .env: {e}"))?; @@ -268,7 +306,8 @@ async fn start_stack( true, "pulling images and starting containers", ); - stack::up(&found, &root)?; + // The harness is a service only when one was picked; see `stack::up`. + stack::up(&found, &root, picked.is_some())?; report(&app, "services", true, "containers up"); report(&app, "migrate", true, "applying migrations"); diff --git a/desktop/src-tauri/src/stack.rs b/desktop/src-tauri/src/stack.rs index 066ea6714..a6232490a 100644 --- a/desktop/src-tauri/src/stack.rs +++ b/desktop/src-tauri/src/stack.rs @@ -105,10 +105,27 @@ fn compose_command(engine: &Address, root: &Path) -> Command { /// `--no-build` is the point of the whole published-images job: a desktop install has no toolchain, /// and without it Compose quietly starts compiling Chromium. Failing loudly on a missing image is /// the better answer, because it names a pull that did not happen. -pub fn up(engine: &Address, root: &Path) -> Result<(), String> { - let output = compose_command(engine, root) +pub fn up(engine: &Address, root: &Path, harness: bool) -> Result<(), String> { + /* + * The picked harness rides in on its profile. + * + * `agent-harness` is profile-gated so a deployment that picked nothing does not try to start + * it: its image comes from `.env`, and unset that is a request to pull the empty string, which + * fails the whole `up` rather than the one service nobody asked for. The flag comes before + * `up`, because `--profile` is an option of `compose` itself and not of the subcommand. + */ + let mut command = compose_command(engine, root); + if harness { + command.args(["--profile", "harness"]); + } + let output = command .args(["up", "-d", "--no-build"]) .args(SERVICES) + .args(if harness { + &["agent-harness"][..] + } else { + &[][..] + }) .output() .map_err(|error| format!("could not run {} compose: {error}", engine.engine.binary()))?; diff --git a/desktop/src/App.tsx b/desktop/src/App.tsx index 2e13b74b9..9fb02817e 100644 --- a/desktop/src/App.tsx +++ b/desktop/src/App.tsx @@ -123,6 +123,9 @@ export function App() { // implies. Sending a bare key here is what made `ANTHROPIC_API_KEY` and a plan token // expressible at the same time. model, + // By id only. The image, the port and how it is dialled are facts about the harness, and + // the window carrying them would be a second list to keep in step with the catalogue. + harness, }); setRunning(true); // The window becomes OpenBot. Nobody double-clicked this to look at a status screen. diff --git a/docker-compose.yml b/docker-compose.yml index b4bb122e9..0e59bec12 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -281,6 +281,41 @@ services: timeout: 5s retries: 5 + # The harness somebody picked during setup, whichever one it is. + # + # ONE SERVICE RATHER THAN TWELVE, because a picked harness is a choice and not twelve branches in + # this file. The image and the port come from `.env`, which the shell writes from the pick, and + # both are facts about the image: each harness fixes its own port in its own Dockerfile. + # + # Behind a profile so a deployment that has picked nothing does not try to start it. Without that + # an unset `PICKED_HARNESS_IMAGE` becomes a request to pull the empty string, which fails the + # whole `compose up` rather than the one service nobody asked for. + # + # No `build:`, so `pull_policy` cannot be `build` here as it is elsewhere: this image is only ever + # published, never built from this tree. + agent-harness: + profiles: ["harness"] + image: ${PICKED_HARNESS_IMAGE:-} + pull_policy: missing + ports: + # Loopback, and on the image's own port on both sides. The server is a host process, so it + # reaches this the same way it reaches agent-bot: over the published port, not the compose + # network. + - "127.0.0.1:${PICKED_HARNESS_PORT:-4202}:${PICKED_HARNESS_PORT:-4202}" + - "[::1]:${PICKED_HARNESS_PORT:-4202}:${PICKED_HARNESS_PORT:-4202}" + environment: + # Every harness refuses without this; it is what makes the endpoint the server's alone. + MANAGED_AGENT_TOKEN: ${MANAGED_AGENT_TOKEN:-} + # Whichever the model screen chose. A harness reads the one its provider needs and ignores the + # rest, and the ones the choice did not imply are written empty rather than left stale: see + # `ModelCredential`. + OPENAI_API_KEY: ${OPENAI_API_KEY:-} + OPENAI_BASE_URL: ${OPENAI_BASE_URL:-} + ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} + CLAUDE_CODE_OAUTH_TOKEN: ${CLAUDE_CODE_OAUTH_TOKEN:-} + CHATGPT_OAUTH_TOKEN: ${CHATGPT_OAUTH_TOKEN:-} + BOT_MODEL: ${BOT_MODEL:-gpt-5.5} + # The same Bot behavior on a framework, exposed as another AG-UI endpoint and registry row. agent-langgraph: build: diff --git a/examples/fintech/agents.yaml b/examples/fintech/agents.yaml index 2ec3cb211..b9c73ada3 100644 --- a/examples/fintech/agents.yaml +++ b/examples/fintech/agents.yaml @@ -68,3 +68,29 @@ agents: avatar_seed: risk-analyst type: remote-ag-ui endpoint: ${MANAGED_AGENT_AG_UI_URL:-} + + # The Bot somebody picked in setup, in the two ways one can be dialled. + # + # BOTH ROWS ARE ALWAYS HERE AND AT MOST ONE EVER EXISTS. A Bot whose endpoint interpolates to + # nothing is dropped by the loader, and the shell writes exactly one of these two addresses from + # the pick, so the other row removes itself. That is what makes registration need no API: the + # choice becomes a setting, and seeding does the rest. + # + # Two rows rather than one because the kind cannot be interpolated: it decides how the endpoint is + # dialled, and a Mastra server has no AG-UI route to talk to. + - id: picked-harness + name: ${PICKED_HARNESS_NAME:-Your Bot} + title: ${PICKED_HARNESS_NAME:-Your Bot} + role_description: Answer questions and do work, using the tools it has been granted. + avatar_seed: picked-harness + type: remote-ag-ui + endpoint: ${PICKED_HARNESS_AG_UI_URL:-} + - id: picked-harness-mastra + name: ${PICKED_HARNESS_NAME:-Your Bot} + title: ${PICKED_HARNESS_NAME:-Your Bot} + role_description: Answer questions and do work, using the tools it has been granted. + avatar_seed: picked-harness + type: remote-mastra + endpoint: ${PICKED_HARNESS_MASTRA_URL:-} + # Which agent on that server. Blank means the only one there; see `pickFromRoster`. + remote_agent_id: ${PICKED_HARNESS_AGENT_ID:-} From b326eaad8a179049b9d3cac212926d7a8a31f660 Mon Sep 17 00:00:00 2001 From: David McKay Date: Mon, 7 Sep 2026 11:22:54 -0700 Subject: [PATCH 17/46] Prove the picked harness works, on both credentials, and fix what that found MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Driven end to end on macOS against a real v0.0.8 deployment: deployment fetched, .env composed, containers raised including the harness service, migrations applied, host processes started, both loopbacks answering, the Bot registered in the database, and the Bot answering a real question over AG-UI. Once with an OpenAI key on CrewAI, once with a Claude plan on the Claude Agent SDK and no API key present at all. Four things that only showed up by running it. The package cannot carry a literal remote-mastra row. The loader refuses an unknown agent.type by refusing the whole file, so a v0.0.8 deployment failed to start at all — picked or not. The kind is interpolated now, which also collapses two gated rows into one: an older server sees remote-ag-ui and loads normally unless somebody actually picks Mastra. Ports were only pre-checked for the two host processes, and that check runs after the containers. Any collision on a container port surfaced as "Bind for 0.0.0.0:4202 failed: port is already allocated" straight from the daemon. Every published port is checked before anything is raised, and names what uses it. Switching provider left the last one's key behind. A run that signed in to a Claude plan still carried the OPENAI_API_KEY from the run before it, and the harness was handed both; whichever a client reads first decides what somebody is billed for. Answering the model screen now clears the keys that answer does not imply. Answering nothing still touches nothing, so a key set by hand is kept. Harness resolution is out of start_stack and tested, because its refusals are real states: an id from a window left open across a downgrade, and the row that installs nothing because the person brings their own address. --- desktop/src-tauri/src/env.rs | 158 +++++++++++++++++++++---------- desktop/src-tauri/src/harness.rs | 79 ++++++++++++++++ desktop/src-tauri/src/main.rs | 46 ++++----- examples/fintech/agents.yaml | 30 +++--- 4 files changed, 221 insertions(+), 92 deletions(-) diff --git a/desktop/src-tauri/src/env.rs b/desktop/src-tauri/src/env.rs index 64ec1a2e1..ad33f2968 100644 --- a/desktop/src-tauri/src/env.rs +++ b/desktop/src-tauri/src/env.rs @@ -91,7 +91,38 @@ pub fn compose( * as empty here rather than omitted: omitting it would leave an older one in place, which is * the same failure by a different route. */ + /* + * Every model key, every time, and empty unless the choice implies it. + * + * Clearing only the one key a given arm conflicts with left the others stale, and `write` below + * preserves lines it does not own, so switching from a key to a plan kept the old key in the + * file and handed it to every harness. Measured: a run that signed in to a Claude plan still + * carried the OPENAI_API_KEY from the run before it. Whichever key a harness reads first then + * decides what the person is billed for, which is the failure the plan path exists to avoid. + * + * Written empty rather than omitted, for the same reason: omitting leaves the old line in place. + */ + if model.credential != ModelCredential::None { + for key in [ + "OPENAI_API_KEY", + "OPENAI_BASE_URL", + "ANTHROPIC_API_KEY", + "CLAUDE_CODE_OAUTH_TOKEN", + "CHATGPT_OAUTH_TOKEN", + ] { + env.insert(key.into(), String::new()); + } + } match &model.credential { + /* + * Nothing chosen touches nothing, deliberately. + * + * The clearing above is for the case where the model screen HAS answered: whichever keys + * that answer does not imply are emptied, so switching from a key to a plan cannot leave + * the old key behind for a harness to prefer. With no answer there is nothing to be + * consistent with, and a key somebody set by hand is theirs to keep — see `write`, which + * preserves lines this does not own. + */ ModelCredential::None => {} ModelCredential::OpenAi { api_key } => { insert_if_given(&mut env, "OPENAI_API_KEY", api_key); @@ -101,19 +132,9 @@ pub fn compose( } ModelCredential::ClaudePlan { token } => { insert_if_given(&mut env, "CLAUDE_CODE_OAUTH_TOKEN", token); - env.insert("ANTHROPIC_API_KEY".into(), String::new()); } ModelCredential::ChatGptPlan { token } => { insert_if_given(&mut env, "CHATGPT_OAUTH_TOKEN", token); - /* - * Both cleared, for the same reason the Claude plan clears its key: a key left from an - * earlier attempt would be preferred by every OpenAI client in the stack, and the - * person who just signed in to a plan would be billed per request instead. The base URL - * is cleared too, because the Codex address is the library's to pin and not ours to - * write. - */ - env.insert("OPENAI_API_KEY".into(), String::new()); - env.insert("OPENAI_BASE_URL".into(), String::new()); } ModelCredential::Compatible { base_url, @@ -176,23 +197,34 @@ pub fn compose( * process here and not a container: it reaches `agent-bot` and `agent-langgraph` the same way, * over the port those services publish. * - * ONE OF THE TWO URLS, NEVER BOTH. The package carries a gated row per kind, and each drops - * itself when its endpoint is blank, so writing both would register the same harness twice — - * once as a kind that cannot speak to it. + * One address and one kind. The package's single row drops itself while the address is blank, + * so a deployment that picked nothing registers nothing. */ if let Some(picked) = harness { env.insert("PICKED_HARNESS_IMAGE".into(), picked.image.clone()); env.insert("PICKED_HARNESS_PORT".into(), picked.port.to_string()); env.insert("PICKED_HARNESS_NAME".into(), picked.name.clone()); - let url = format!("http://127.0.0.1:{}", picked.port); - if picked.mastra { - env.insert("PICKED_HARNESS_MASTRA_URL".into(), url); - env.insert("PICKED_HARNESS_AG_UI_URL".into(), String::new()); - insert_if_given(&mut env, "PICKED_HARNESS_AGENT_ID", &picked.remote_agent_id); - } else { - env.insert("PICKED_HARNESS_AG_UI_URL".into(), url); - env.insert("PICKED_HARNESS_MASTRA_URL".into(), String::new()); - } + env.insert( + "PICKED_HARNESS_URL".into(), + format!("http://127.0.0.1:{}", picked.port), + ); + /* + * The kind, as the package spells it. + * + * Interpolated rather than written as a literal row per kind, because the loader refuses an + * unknown `agent.type` by refusing the whole file: a package carrying a literal + * `remote-mastra` row stops any server predating that kind from starting at all, picked or + * not. Measured, not guessed — it is what a v0.0.8 deployment did. + */ + env.insert( + "PICKED_HARNESS_KIND".into(), + if picked.mastra { + "remote-mastra".to_string() + } else { + "remote-ag-ui".to_string() + }, + ); + insert_if_given(&mut env, "PICKED_HARNESS_AGENT_ID", &picked.remote_agent_id); } // Without this the server gives every Bot the same browser. It is the difference between the @@ -731,7 +763,7 @@ mod model_tests { &pinned(), None, ); - assert!(!env.contains_key("OPENAI_API_KEY")); + assert_eq!(env.get("OPENAI_API_KEY"), Some(&String::new())); } /// The must-not case, and the reason `ModelCredential` is a choice rather than two fields. @@ -761,12 +793,15 @@ mod model_tests { assert_eq!(env.get("ANTHROPIC_API_KEY"), Some(&String::new())); } - /// The must-not case for registration. Both addresses written, and the package's two gated - /// rows both materialise: the same harness is registered twice, once as a kind that cannot - /// speak to it, and the second Bot answers nothing. + /// The kind is written the way the package spells it, and the address is the image's own port + /// on loopback because the server is a host process rather than a container. + /// + /// The kind matters beyond correctness: a package carrying a literal `remote-mastra` row stops + /// any server predating that kind from starting at all, since the loader refuses an unknown + /// `agent.type` by refusing the whole file. #[test] - fn a_picked_harness_is_addressed_one_way_only() { - for mastra in [false, true] { + fn a_picked_harness_is_addressed_once_and_named_as_a_kind() { + for (mastra, expected) in [(false, "remote-ag-ui"), (true, "remote-mastra")] { let env = compose( &intelligence(), &Model::default(), @@ -781,23 +816,13 @@ mod model_tests { remote_agent_id: String::new(), }), ); - let ag_ui = env - .get("PICKED_HARNESS_AG_UI_URL") - .cloned() - .unwrap_or_default(); - let mastra_url = env - .get("PICKED_HARNESS_MASTRA_URL") - .cloned() - .unwrap_or_default(); - assert!( - ag_ui.is_empty() != mastra_url.is_empty(), - "mastra={mastra} wrote ag-ui={ag_ui:?} and mastra={mastra_url:?}" + assert_eq!( + env.get("PICKED_HARNESS_KIND").map(String::as_str), + Some(expected) ); - // Loopback, because the server is a host process and not a container. - assert!( - ag_ui.starts_with("http://127.0.0.1:4202") - || mastra_url.starts_with("http://127.0.0.1:4202"), - "the address is not the image's own port on loopback" + assert_eq!( + env.get("PICKED_HARNESS_URL").map(String::as_str), + Some("http://127.0.0.1:4202") ); } } @@ -816,8 +841,8 @@ mod model_tests { for key in [ "PICKED_HARNESS_IMAGE", "PICKED_HARNESS_PORT", - "PICKED_HARNESS_AG_UI_URL", - "PICKED_HARNESS_MASTRA_URL", + "PICKED_HARNESS_URL", + "PICKED_HARNESS_KIND", ] { assert!( !env.contains_key(key), @@ -851,6 +876,39 @@ mod model_tests { assert_eq!(env.get("OPENAI_BASE_URL"), Some(&String::new())); } + /// Switching provider does not leave the last one's key behind. + /// + /// Measured, not imagined: a run that signed in to a Claude plan still carried the + /// OPENAI_API_KEY written by the run before it, and every harness was handed both. Whichever a + /// harness reads first then decides what the person is billed for, which is the whole thing the + /// plan path exists to avoid. + #[test] + fn answering_the_model_screen_clears_the_keys_it_does_not_imply() { + let env = compose( + &intelligence(), + &Model { + credential: ModelCredential::ClaudePlan { + token: "oauth-token".into(), + }, + }, + &engine(), + &Ports::default(), + &pinned(), + None, + ); + assert_eq!( + env.get("CLAUDE_CODE_OAUTH_TOKEN"), + Some(&"oauth-token".to_string()) + ); + for cleared in ["OPENAI_API_KEY", "OPENAI_BASE_URL", "ANTHROPIC_API_KEY"] { + assert_eq!( + env.get(cleared), + Some(&String::new()), + "{cleared} survived a switch to a Claude plan" + ); + } + } + /// An Anthropic key is written as one, and does not become an OpenAI key because that is the /// field this struct used to have. #[test] @@ -871,7 +929,7 @@ mod model_tests { env.get("ANTHROPIC_API_KEY"), Some(&"sk-ant-real".to_string()) ); - assert!(!env.contains_key("OPENAI_API_KEY")); + assert_eq!(env.get("OPENAI_API_KEY"), Some(&String::new())); } /// The everything-else row writes all three, since an endpoint without a model name is an @@ -899,7 +957,7 @@ mod model_tests { assert_eq!(env.get("BOT_MODEL"), Some(&"some-model".to_string())); assert_eq!(env.get("OPENAI_API_KEY"), Some(&"sk-whatever".to_string())); // Nothing about Anthropic is implied by choosing an OpenAI-compatible endpoint. - assert!(!env.contains_key("ANTHROPIC_API_KEY")); + assert_eq!(env.get("ANTHROPIC_API_KEY"), Some(&String::new())); } /// Nothing chosen writes no model keys at all, rather than empty ones. @@ -913,12 +971,14 @@ mod model_tests { &pinned(), None, ); + // Untouched, not cleared: a key somebody set by hand is theirs to keep while the model + // screen has not answered. See the note in `compose`. for key in [ "OPENAI_API_KEY", "OPENAI_BASE_URL", "ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN", - "BOT_MODEL", + "CHATGPT_OAUTH_TOKEN", ] { assert!( !env.contains_key(key), diff --git a/desktop/src-tauri/src/harness.rs b/desktop/src-tauri/src/harness.rs index 072d14766..1a84eaf14 100644 --- a/desktop/src-tauri/src/harness.rs +++ b/desktop/src-tauri/src/harness.rs @@ -241,6 +241,47 @@ pub fn catalogue() -> Vec { ] } +/** +Which harness a picked id means, as the settings it implies. + +Extracted from `start_stack` so the refusals can be tested. Each one is a real state: a window that +sends an id this build does not have (a downgrade, or a stale page), and the row that installs +nothing because the person is bringing their own address. + +An unknown id is refused here rather than written into `.env`, where it would become a Bot pointing +at a container nobody started — which looks like a broken Bot rather than a bad pick. +*/ +pub fn picked(id: Option<&str>) -> Result, String> { + let Some(id) = id.map(str::trim).filter(|id| !id.is_empty()) else { + return Ok(None); + }; + // Nothing is installed for somebody bringing their own address, so there is nothing to resolve. + if id == "byo-url" { + return Ok(None); + } + let row = catalogue() + .into_iter() + .find(|row| row.id == id) + .ok_or_else(|| format!("There is no Bot called \"{id}\" to install."))?; + let (Some(image), Some(port)) = (row.image, row.port) else { + return Err(format!("\"{id}\" is not a Bot this can install.")); + }; + let mastra = row.id == "mastra"; + Ok(Some(crate::env::PickedHarness { + image, + port, + name: row.name, + mastra, + // Our own Mastra image serves one agent, named for the product. Somebody pointing at their + // own Mastra server names theirs on the Bot's page. + remote_agent_id: if mastra { + "openbot".to_string() + } else { + String::new() + }, + })) +} + #[cfg(test)] mod tests { use super::*; @@ -361,6 +402,44 @@ mod tests { } } + /// An id this build does not have is refused by name, not written into a `.env`. + /// + /// It happens: a window left open across a downgrade sends an id the catalogue has lost. Passed + /// through, it becomes a Bot addressed at a container nobody started, which reads as a broken + /// Bot rather than a pick that could not be honoured. + #[test] + fn an_unknown_id_is_refused_by_name() { + let refusal = picked(Some("not-a-real-harness")).expect_err("it was accepted"); + assert!(refusal.contains("not-a-real-harness"), "{refusal}"); + } + + /// Bringing your own address installs nothing, and that is not a failure. + #[test] + fn the_byo_row_resolves_to_nothing_without_complaint() { + assert_eq!(picked(Some("byo-url")).expect("it was refused"), None); + assert_eq!(picked(None).expect("it was refused"), None); + assert_eq!(picked(Some(" ")).expect("it was refused"), None); + } + + /// A real row resolves to the image the release publishes and the port that image listens on. + #[test] + fn a_real_row_resolves_to_its_image_and_port() { + let crewai = picked(Some("crewai")).expect("refused").expect("nothing"); + assert_eq!(crewai.image, "openbot-agent-crewai"); + assert_eq!(crewai.port, 4202); + assert!(!crewai.mastra); + assert!(crewai.remote_agent_id.is_empty()); + } + + /// Mastra is dialled as Mastra and names the agent our image serves, because that endpoint is a + /// roster and a Bot that names none gets the only one there or a refusal. + #[test] + fn mastra_resolves_as_mastra_and_names_its_agent() { + let mastra = picked(Some("mastra")).expect("refused").expect("nothing"); + assert!(mastra.mastra); + assert_eq!(mastra.remote_agent_id, "openbot"); + } + /// A named mark has to be a file that is actually there. The failure this catches is silent at /// runtime: a row asks for a mark that was never vendored, and the tile draws empty, which /// looks like a rendering bug rather than a missing asset. diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index 14cdbe60b..0428a162d 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -201,32 +201,10 @@ async fn start_stack( * refused here rather than written into `.env`, where it would become a Bot pointing at a * container nobody started. */ - let picked = match harness.as_deref().filter(|id| !id.trim().is_empty()) { - None => None, - Some("byo-url") => None, - Some(id) => { - let row = harness::catalogue() - .into_iter() - .find(|row| row.id == id) - .ok_or_else(|| format!("There is no Bot called \"{id}\" to install."))?; - let (Some(image), Some(port)) = (row.image, row.port) else { - return Err(format!("\"{id}\" is not a Bot this can install.")); - }; - Some(openbot_env::PickedHarness { - image: format!("{image}:{DEPLOYMENT_VERSION}"), - port, - name: row.name, - mastra: row.id == "mastra", - // Our own Mastra image serves one agent, named for the product. A person pointing - // at their own Mastra server names theirs on the Bot's page. - remote_agent_id: if row.id == "mastra" { - "openbot".to_string() - } else { - String::new() - }, - }) - } - }; + // Resolved from the catalogue rather than taken from the window: the image, the port and how + // it is dialled are facts about the harness, and the window knowing them would be a second + // list to keep in step. See `harness::picked` for what each refusal is for. + let picked = harness::picked(harness.as_deref())?; // The installer does not carry the deployment; it fetches one. Skipped when the recorded // version already matches, so a restart is not a download. @@ -306,6 +284,22 @@ async fn start_stack( true, "pulling images and starting containers", ); + /* + * The harness's port, before the containers rather than after. + * + * The check below covers the host processes, and it runs too late for this: a port already held + * makes `compose up` fail inside the daemon, and what reaches the person is + * "Bind for 0.0.0.0:4202 failed: port is already allocated". Every harness has a fixed port of + * its own, so this is not a rare case — anything else using it, including a previous run's + * container, produces that sentence. + */ + if let Some(picked) = picked.as_ref() { + if let Some(problem) = stack::port_already_taken(&[("Bot you picked", picked.port)]) { + report(&app, "ports", false, problem.clone()); + return Err(problem); + } + } + // The harness is a service only when one was picked; see `stack::up`. stack::up(&found, &root, picked.is_some())?; report(&app, "services", true, "containers up"); diff --git a/examples/fintech/agents.yaml b/examples/fintech/agents.yaml index b9c73ada3..a647508ea 100644 --- a/examples/fintech/agents.yaml +++ b/examples/fintech/agents.yaml @@ -69,28 +69,24 @@ agents: type: remote-ag-ui endpoint: ${MANAGED_AGENT_AG_UI_URL:-} - # The Bot somebody picked in setup, in the two ways one can be dialled. + # The Bot somebody picked in setup. # - # BOTH ROWS ARE ALWAYS HERE AND AT MOST ONE EVER EXISTS. A Bot whose endpoint interpolates to - # nothing is dropped by the loader, and the shell writes exactly one of these two addresses from - # the pick, so the other row removes itself. That is what makes registration need no API: the - # choice becomes a setting, and seeding does the rest. + # ONE ROW, AND IT EXISTS ONLY ONCE SOMETHING IS PICKED. A Bot whose endpoint interpolates to + # nothing is dropped by the loader, so this row removes itself until the shell writes an address. + # That is what makes registration need no API: the choice becomes a setting, and seeding does the + # rest. # - # Two rows rather than one because the kind cannot be interpolated: it decides how the endpoint is - # dialled, and a Mastra server has no AG-UI route to talk to. + # The KIND is interpolated too, and that is not a flourish. Written as a literal `remote-mastra` + # row it made this whole package fail to load on any server that predates that kind — the loader + # refuses an unknown `agent.type`, and refusing one Bot means refusing the file, so the deployment + # would not start at all. Interpolated, an older server sees `remote-ag-ui` and loads normally + # unless somebody actually picks Mastra. - id: picked-harness name: ${PICKED_HARNESS_NAME:-Your Bot} title: ${PICKED_HARNESS_NAME:-Your Bot} role_description: Answer questions and do work, using the tools it has been granted. avatar_seed: picked-harness - type: remote-ag-ui - endpoint: ${PICKED_HARNESS_AG_UI_URL:-} - - id: picked-harness-mastra - name: ${PICKED_HARNESS_NAME:-Your Bot} - title: ${PICKED_HARNESS_NAME:-Your Bot} - role_description: Answer questions and do work, using the tools it has been granted. - avatar_seed: picked-harness - type: remote-mastra - endpoint: ${PICKED_HARNESS_MASTRA_URL:-} - # Which agent on that server. Blank means the only one there; see `pickFromRoster`. + type: ${PICKED_HARNESS_KIND:-remote-ag-ui} + endpoint: ${PICKED_HARNESS_URL:-} + # Which agent on that server, for a Mastra roster. Blank means the only one there. remote_agent_id: ${PICKED_HARNESS_AGENT_ID:-} From 6bd6554eb47e81d412c3f3139ba27b484fc4797b Mon Sep 17 00:00:00 2001 From: David McKay Date: Mon, 7 Sep 2026 12:07:46 -0700 Subject: [PATCH 18/46] Give setup a welcome, a default Bot, and a look worth the first impression Three problems, all of them the same problem: this window is the product's handshake and it read like a form. There was no welcome screen, so the first thing anybody saw was a question. There is one now, and it says what OpenBot is in two lines. The Bot screen asked a non-technical person to choose between twelve agent frameworks, which is a decision they cannot inform, at the point where people leave. David's call: use the default, say so, and put the list behind a disclosure that names the only person it is for. The framework is never named on the main path, because the name means nothing to the reader and inviting them to weigh it is the whole mistake. A developer opens it and picks; nobody else learns the word. The look is measured from the interface this sits beside rather than guessed: a warm off-white ground, near-black text, warm-tinted hairlines at 4-8% rather than cool gray, 12px rows and one 16px focal card. That warmth is most of why the reference reads calm instead of clinical. xAI's own face is not shipped; Inter and the system stack are what a window with no network can rely on. Light is pinned here and nowhere else. This screen exists for five minutes before it becomes OpenBot, and the app it hands over to keeps its own light and dark. Following the system instead would mean half of all installers see a variant nobody composed against a reference. Also a step indicator, one authored entrance rather than scattered motion, and no coloured side border on the error card. --- desktop/preview.html | 2 - desktop/src/App.tsx | 18 +- desktop/src/HarnessPicker.tsx | 131 +++++--- desktop/src/ProviderPicker.tsx | 10 +- desktop/src/Welcome.tsx | 61 ++++ desktop/src/preview.tsx | 211 ------------ desktop/src/styles.css | 575 +++++++++++++++++++++------------ 7 files changed, 538 insertions(+), 470 deletions(-) delete mode 100644 desktop/preview.html create mode 100644 desktop/src/Welcome.tsx delete mode 100644 desktop/src/preview.tsx diff --git a/desktop/preview.html b/desktop/preview.html deleted file mode 100644 index a78b5d336..000000000 --- a/desktop/preview.html +++ /dev/null @@ -1,2 +0,0 @@ -Setup screens -

diff --git a/desktop/src/App.tsx b/desktop/src/App.tsx index 9fb02817e..afc0014ca 100644 --- a/desktop/src/App.tsx +++ b/desktop/src/App.tsx @@ -1,8 +1,9 @@ import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; import { useEffect, useState } from "react"; -import { HarnessPicker } from "./HarnessPicker"; +import { DEFAULT_HARNESS, HarnessPicker } from "./HarnessPicker"; import { type ModelChoice, ProviderPicker } from "./ProviderPicker"; +import { Welcome } from "./Welcome"; type EngineStatus = { engine: "docker" | "podman" | null; @@ -36,9 +37,11 @@ export function App() { * the flow is resumable at the screen it stopped on, and a wizard that asks twice is one nobody * finishes. `null` means not answered yet, which is what decides the screen below. */ - const [harness, setHarness] = useState(null); + const [harness, setHarness] = useState(DEFAULT_HARNESS); const [model, setModel] = useState(null); - const [step, setStep] = useState<"harness" | "model" | "install">("harness"); + const [step, setStep] = useState<"welcome" | "harness" | "model" | "install">( + "welcome", + ); const [apiUrl, setApiUrl] = useState( "https://api.intelligence.copilotkit.ai", ); @@ -175,6 +178,14 @@ export function App() { * Skipped entirely when a stack is already up: somebody returning to a running OpenBot is not * setting one up, and asking them to pick a Bot again would be the wizard asking twice. */ + if (!running && step === "welcome") { + return ( +
+ setStep("harness")} /> +
+ ); + } + if (!running && step === "harness") { return (
@@ -182,6 +193,7 @@ export function App() { chosen={harness} onChoose={setHarness} onContinue={() => setStep("model")} + onBack={() => setStep("welcome")} />
); diff --git a/desktop/src/HarnessPicker.tsx b/desktop/src/HarnessPicker.tsx index 8a9340a1e..aa1226470 100644 --- a/desktop/src/HarnessPicker.tsx +++ b/desktop/src/HarnessPicker.tsx @@ -11,87 +11,130 @@ export type Harness = { credential: "any-provider" | "anthropic" | "their-endpoint"; maintainer: "first-party" | "partnership" | "community"; mark: string | null; + port: number | null; }; +/** What OpenBot sets up unless somebody says otherwise. David's call. */ +export const DEFAULT_HARNESS = "langgraph"; + /** - * Pick a Bot. + * Which Bot, answered for them. * - * The list is data from the Rust catalogue, so this screen is a list and not twelve branches, and - * adding a harness never comes back here. + * ONE CHOICE IS MADE FOR THE PERSON, and that is the point of this screen rather than a limitation + * of it. The twelve rows are agent frameworks, and to anybody who is not a developer the difference + * between them is nil: they all take any model and they all answer the same questions. Asking a + * non-technical person to pick one is asking them to make a decision they cannot inform, at the + * start, which is where people leave. * - * NO DEFAULT AND NO PRESELECTION. The person chooses. A preselected row is a choice made on their - * behalf that they will not notice making, and the harness decides what their Bot is. + * So the default is stated in one line and the list moves behind a disclosure. A developer who + * wants CrewAI opens it and picks CrewAI; everybody else presses Continue and never learns the word + * "harness". The address-your-own row lives in there too, because pasting a URL is the most + * developer thing on this screen. */ export function HarnessPicker({ chosen, onChoose, onContinue, + onBack, }: { chosen: string | null; onChoose: (id: string) => void; onContinue: () => void; + onBack: () => void; }) { const [rows, setRows] = useState([]); const [failure, setFailure] = useState(""); + // Open when the person has already chosen something other than the default, so coming back does + // not hide the choice they made. + const [open, setOpen] = useState( + chosen !== null && chosen !== DEFAULT_HARNESS, + ); useEffect(() => { invoke("harnesses") .then(setRows) - .catch((e) => setFailure(String(e))); + .catch((error) => setFailure(String(error))); }, []); + const picked = rows.find((row) => row.id === (chosen ?? DEFAULT_HARNESS)); + if (failure) { return ( -
-

The list of Bots could not be read

-

{failure}

+
+
+

The list of Bots could not be read

+

{failure}

+
); } return ( - <> -

Pick a Bot

+
+

Step 1 of 2

+

Your first Bot

+ {/* + Written to the person who has to act, not about the situation. + + An earlier version said the default "makes no difference unless you write code", which + describes a state and leaves a non-technical reader wondering what they were told. This + gives them the one thing to do — nothing — and puts the conditional where the only person it + applies to will read it. + */}

- This is the agent that does the work. You can change it later, and you - can add more. + OpenBot sets this up for you. If you write code, you can choose the + agent framework below.

-
- Bot - {rows.map((row) => ( - - ))} -
+
setOpen(e.currentTarget.open)}> + + {picked && picked.id !== DEFAULT_HARNESS + ? `Using ${picked.name}` + : "Choose the agent framework"} + +

+ Any of these works with any AI provider. Only the last one asks you + for an address. +

+
+ Bot + {rows.map((row) => ( + + ))} +
+
- +
- +
); } diff --git a/desktop/src/ProviderPicker.tsx b/desktop/src/ProviderPicker.tsx index 50637d023..37325003c 100644 --- a/desktop/src/ProviderPicker.tsx +++ b/desktop/src/ProviderPicker.tsx @@ -112,11 +112,11 @@ export function ProviderPicker({ model.trim().length > 0); return ( - <> -

Connect a model

+
+

Step 2 of 2

+

Connect your AI

- This is what your Bots think with. It is a separate choice from the Bot - you picked, and any Bot works with any of these. + Sign in to the plan you already pay for. No key needed.

@@ -312,6 +312,6 @@ export function ProviderPicker({ Continue
- +
); } diff --git a/desktop/src/Welcome.tsx b/desktop/src/Welcome.tsx new file mode 100644 index 000000000..56302ceba --- /dev/null +++ b/desktop/src/Welcome.tsx @@ -0,0 +1,61 @@ +/** + * What OpenBot is, before it asks for anything. + * + * Few words on purpose. The person here was sent a link by their IT department and has not decided + * to care yet: they need to know what this is, that it will not ask them for anything technical, + * and where the button is. Everything else waits for a screen that needs it. + */ +export function Welcome({ onStart }: { onStart: () => void }) { + return ( +
+ +

Your own AI coworkers, on this computer.

+

+ They answer questions, use the tools you connect, and can work in a + browser for you. +

+
+ +
+

+ Takes a few minutes. OpenBot installs what it needs and asks you to sign + in to the AI plan you already have. +

+
+ ); +} + +/** + * The product's own mark. + * + * Drawn rather than fetched, because this is the first paint of a window that has no network + * guarantee yet, and it is two shapes: a filled square for the person and an outlined one beside it + * for the coworker, overlapping. It is here so the first screen is recognisably a product rather + * than a form. + */ +function Mark() { + return ( + + ); +} diff --git a/desktop/src/preview.tsx b/desktop/src/preview.tsx deleted file mode 100644 index 04cc7baae..000000000 --- a/desktop/src/preview.tsx +++ /dev/null @@ -1,211 +0,0 @@ -/** - * The setup screens, rendered without the shell around them, so they can be looked at. - * - * Not shipped: vite builds index.html and nothing points here. The catalogues below are a SNAPSHOT - * taken from the Rust side, not a second source of truth — the app itself reads the real thing over - * `invoke`. If a row here disagrees with the picker in the running app, this file is the stale one. - */ -import { useState } from "react"; -import { createRoot } from "react-dom/client"; -import { HarnessPicker } from "./HarnessPicker"; -import { type ModelChoice, ProviderPicker } from "./ProviderPicker"; -import "./styles.css"; - -const CATALOGUES: Record = { - harnesses: [ - { - id: "crewai", - name: "CrewAI", - summary: "Crews of agents with roles and tasks.", - image: "openbot-harness-crewai", - health_path: "/health", - credential: "any-provider", - maintainer: "partnership", - mark: "crewai", - }, - { - id: "llamaindex", - name: "LlamaIndex", - summary: "Agents built around your own documents.", - image: "openbot-harness-llamaindex", - health_path: "/health", - credential: "any-provider", - maintainer: "first-party", - mark: "llamaindex", - }, - { - id: "agno", - name: "Agno", - summary: "Fast, small, and multi-modal.", - image: "openbot-harness-agno", - health_path: "/health", - credential: "any-provider", - maintainer: "first-party", - mark: null, - }, - { - id: "langgraph", - name: "LangGraph", - summary: "Graphs you can change, from LangChain.", - image: "openbot-harness-langgraph", - health_path: "/health", - credential: "any-provider", - maintainer: "partnership", - mark: "langgraph", - }, - { - id: "google-adk", - name: "Google ADK", - summary: "Google's agent kit. Gemini first, any model after.", - image: "openbot-harness-google-adk", - health_path: "/health", - credential: "any-provider", - maintainer: "first-party", - mark: "google-adk", - }, - { - id: "pydantic-ai", - name: "Pydantic AI", - summary: "Typed agents, validated in and out.", - image: "openbot-harness-pydantic-ai", - health_path: "/health", - credential: "any-provider", - maintainer: "first-party", - mark: "pydantic-ai", - }, - { - id: "microsoft-agent-framework", - name: "Microsoft Agent Framework", - summary: "Microsoft's, model-agnostic by design.", - image: "openbot-harness-microsoft-agent-framework", - health_path: "/health", - credential: "any-provider", - maintainer: "first-party", - mark: "microsoft-agent-framework", - }, - { - id: "claude-agent-sdk", - name: "Claude Agent SDK", - summary: - "Anthropic's own. The one that takes a Claude plan instead of a key.", - image: "openbot-harness-claude-agent-sdk", - health_path: "/health", - credential: "anthropic", - maintainer: "community", - mark: "claude-agent-sdk", - }, - { - id: "strands", - name: "AWS Strands", - summary: "Amazon's. Bedrock first, any model after.", - image: "openbot-harness-strands", - health_path: "/health", - credential: "any-provider", - maintainer: "first-party", - mark: "strands", - }, - { - id: "ag2", - name: "AG2", - summary: "The AutoGen line, continued.", - image: "openbot-harness-ag2", - health_path: "/health", - credential: "any-provider", - maintainer: "first-party", - mark: null, - }, - { - id: "langroid", - name: "Langroid", - summary: "Multi-agent, deliberately small.", - image: "openbot-harness-langroid", - health_path: "/health", - credential: "any-provider", - maintainer: "community", - mark: null, - }, - { - id: "mastra", - name: "Mastra", - summary: "TypeScript agents, with their own server.", - image: "openbot-harness-mastra", - health_path: "/health", - credential: "any-provider", - maintainer: "partnership", - mark: "mastra", - }, - { - id: "byo-url", - name: "An agent you already run", - summary: - "Give its address. It is proved with a real AG-UI run before it is saved.", - image: null, - health_path: null, - credential: "their-endpoint", - maintainer: "community", - mark: null, - }, - ], - providers: [ - { - id: "openai", - name: "OpenAI", - summary: "Sign in with ChatGPT Plus, Pro, Team or Enterprise.", - logins: ["plan", "api-key"], - mark: "openai", - caution: null, - }, - { - id: "anthropic", - name: "Anthropic", - summary: "Sign in with a Claude Pro, Max, Team or Enterprise plan.", - logins: ["plan", "api-key"], - mark: "anthropic", - caution: { - says: "A Claude plan carries a separate monthly pool of Agent SDK credits. When that pool is empty, Bots stop until it renews or you add API credits.", - reads_more_at: - "https://support.anthropic.com/en/articles/11145838-using-claude-code-with-your-pro-or-max-plan", - }, - }, - { - id: "openai-compatible", - name: "Any OpenAI-compatible endpoint", - summary: - "Azure, Bedrock, Mistral, DeepSeek, xAI, Ollama, vLLM or your own.", - logins: ["endpoint"], - mark: null, - caution: null, - }, - ], -}; -// The preview has no Tauri behind it; these are the exact bytes the commands return. -(window as unknown as { __TAURI_INTERNALS__: unknown }).__TAURI_INTERNALS__ = { - invoke: (cmd: string) => - Promise.resolve(CATALOGUES[cmd.replace("plugin:", "")]), - transformCallback: (cb: unknown) => cb, -}; - -function Preview() { - const [harness, setHarness] = useState(null); - const [model, setModel] = useState(null); - const [screen, setScreen] = useState<"harness" | "provider">("harness"); - return ( -
- {screen === "harness" ? ( - setScreen("provider")} - /> - ) : ( - setScreen("harness")} - /> - )} -
- ); -} -const mount = document.getElementById("root"); -if (mount) createRoot(mount).render(); diff --git a/desktop/src/styles.css b/desktop/src/styles.css index 99bbfa98a..ae427a626 100644 --- a/desktop/src/styles.css +++ b/desktop/src/styles.css @@ -1,308 +1,484 @@ +/* + * OpenBot Desktop: the setup window. + * + * The world is the one the product sits next to: warm off-white ground, near-black text, almost no + * chrome, and one rounded focal element per screen. Measured from Grok rather than guessed — the + * ground is a warm #F9F8F7 and the hairlines are a warm-tinted black at 4-8% rather than a cool + * gray, which is most of why that interface reads calm instead of clinical. + * + * `universalSans` is deliberately absent: it is xAI's own face and not ours to ship. Inter is the + * documented fallback and the system stack is what a desktop window can rely on with no network at + * the moment it draws. + * + * Light is committed rather than defaulted. The use scene is a corporate laptop in an office in the + * daytime, and this window is the first thing somebody sees after a download. + */ :root { - color-scheme: light dark; - font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; - --ink: #111827; - --muted: #6b7280; - --line: #e5e7eb; - --ground: #ffffff; - --accent: #111827; - --bad: #b91c1c; - --good: #15803d; -} -@media (prefers-color-scheme: dark) { - :root { - --ink: #f3f4f6; - --muted: #9ca3af; - --line: #374151; - --ground: #0b0f19; - --accent: #f3f4f6; - } + /* Warm, not neutral. Every gray here is tinted from the same 15/13/10 black. */ + --ground: #f9f8f7; + --raised: #ffffff; + --ink: #050505; + --ink-soft: rgba(15, 13, 10, 0.62); + --ink-faint: rgba(15, 13, 10, 0.45); + --hair: rgba(15, 13, 10, 0.08); + --hair-soft: rgba(15, 13, 10, 0.04); + --sunk: rgba(15, 13, 10, 0.04); + --accent: #050505; + --accent-ink: #ffffff; + --bad: #b3261e; + --good: #1a7f37; + + /* One radius family: 12px for rows and controls, 16px for the focal card. */ + --r-row: 12px; + --r-card: 16px; + + font-family: + Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, ui-sans-serif, + system-ui, sans-serif; + font-size: 14px; + line-height: 1.5; + color: var(--ink); + background: var(--ground); + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; } + +/* + * NO DARK VARIANT, DELIBERATELY, and this is the one place in the product where that is right. + * + * This window exists for about five minutes, once, before it becomes OpenBot and hands over to the + * app — which does follow the system. A first-run screen is the product's handshake, and the look + * committed here is the warm light one measured from the interface this is meant to sit beside. + * Following the system instead would mean the first thing half of all installers ever see is a + * variant nobody composed against a reference. + * + * The app this hands over to keeps its own light and dark. Only the setup window is pinned. + */ * { box-sizing: border-box; } + body { margin: 0; background: var(--ground); color: var(--ink); } + +/* + * The window is 900x640 and cannot be resized into something else, so it is composed as a room + * rather than a page: one centred column, vertically settled, with the same optical centre on every + * step. A wizard whose content jumps position between steps reads as four different screens. + */ main { - max-width: 42rem; - margin: 0 auto; - padding: 2.5rem 1.5rem; + min-height: 100vh; + display: grid; + place-items: center; + padding: 2.5rem 2rem; } -h1 { - font-size: 1.35rem; - margin: 0 0 0.35rem; + +.sheet { + width: 100%; + max-width: 30rem; + display: flex; + flex-direction: column; + /* One authored entrance, on the step rather than on every element. */ + animation: settle 0.42s cubic-bezier(0.16, 1, 0.3, 1) both; } -p.lede { - color: var(--muted); - margin: 0 0 1.75rem; - line-height: 1.5; + +@keyframes settle { + from { + opacity: 0; + transform: translateY(6px); + } } -label { - display: block; - font-size: 0.82rem; - font-weight: 600; - margin: 0 0 0.3rem; + +@media (prefers-reduced-motion: reduce) { + .sheet { + animation: none; + } } -input { - width: 100%; - padding: 0.55rem 0.7rem; - border: 1px solid var(--line); - border-radius: 0.5rem; - background: transparent; - color: inherit; - font: inherit; + +/* Where you are, stated quietly. Operate mode: a four-step flow that says nothing about progress + makes people wonder how much is left, which is when they close it. */ +.steps-of { + font-size: 0.72rem; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--ink-faint); + margin: 0 0 1.25rem; } -button { - padding: 0.55rem 1rem; - border-radius: 0.5rem; - border: 1px solid var(--accent); - background: var(--accent); - color: var(--ground); - font: inherit; + +h1 { + font-size: 1.5rem; + line-height: 1.2; font-weight: 600; - cursor: pointer; + letter-spacing: -0.02em; + margin: 0 0 0.6rem; + text-wrap: balance; } -button.secondary { - background: transparent; - color: var(--ink); + +h2 { + font-size: 0.95rem; + font-weight: 600; + margin: 0 0 0.35rem; } -button:disabled { - opacity: 0.5; - cursor: default; + +.lede { + color: var(--ink-soft); + margin: 0 0 1.5rem; + max-width: 34rem; } -.field { - margin-bottom: 1rem; + +.lede.big { + font-size: 0.95rem; + line-height: 1.6; +} + +.footnote { + font-size: 0.8rem; + color: var(--ink-faint); + line-height: 1.55; + margin: 1.25rem 0 0; } + +/* Controls */ .row { display: flex; gap: 0.6rem; align-items: center; margin-top: 1.5rem; } -.steps { - border: 1px solid var(--line); - border-radius: 0.6rem; - margin-top: 1.5rem; - overflow: hidden; -} -.step { - display: flex; - gap: 0.6rem; - padding: 0.6rem 0.8rem; - border-bottom: 1px solid var(--line); - font-size: 0.88rem; -} -.step:last-child { - border-bottom: none; + +button { + font: inherit; + font-weight: 500; + padding: 0.6rem 1.1rem; + border-radius: var(--r-row); + border: 1px solid transparent; + background: var(--accent); + color: var(--accent-ink); + cursor: pointer; + transition: + opacity 0.15s ease, + background 0.15s ease; } -.step .mark { - width: 1.1rem; - flex: none; + +button:hover:not(:disabled) { + opacity: 0.88; } -.step .detail { - color: var(--muted); + +button:disabled { + opacity: 0.35; + cursor: default; } -.bad { - color: var(--bad); + +button.quiet { + background: transparent; + color: var(--ink-soft); + border-color: var(--hair); } -.good { - color: var(--good); + +button.quiet:hover:not(:disabled) { + color: var(--ink); + border-color: var(--ink-faint); + opacity: 1; } -.blocker { - border: 1px solid var(--bad); - border-radius: 0.6rem; - padding: 1rem; - margin-top: 1.5rem; + +:focus-visible { + outline: 2px solid var(--ink); + outline-offset: 2px; } -.blocker h2 { - font-size: 0.95rem; - margin: 0 0 0.4rem; + +/* Fields */ +.field { + margin-bottom: 0.9rem; } -.blocker p { - margin: 0; - color: var(--muted); - line-height: 1.5; + +.field label { + display: block; + font-size: 0.8rem; + color: var(--ink-soft); + margin-bottom: 0.35rem; } -/* The picker: a grid of rows from the catalogue, and never a screen per harness. */ -.lede { - color: var(--muted); - margin: 0 0 1.25rem; - max-width: 46ch; +.field input { + font: inherit; + width: 100%; + padding: 0.6rem 0.75rem; + border-radius: var(--r-row); + border: 1px solid var(--hair); + background: var(--raised); + color: var(--ink); } -.picker { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); - gap: 0.75rem; - margin-bottom: 1.5rem; +.field input::placeholder { + color: var(--ink-faint); } -.picker.providers { - grid-template-columns: 1fr; +.field input:focus-visible { + border-color: var(--ink-faint); + outline: none; + box-shadow: 0 0 0 3px var(--sunk); } -.tile { +/* The list of choices. Rows, not a grid of same-size cards. */ +fieldset.picker { + border: 0; + padding: 0; + margin: 0.75rem 0 0; display: flex; flex-direction: column; - align-items: flex-start; - gap: 0.35rem; - padding: 0.9rem; - border: 1px solid var(--line); - border-radius: 12px; - background: transparent; - color: var(--ink); - text-align: left; + gap: 0.4rem; + max-height: 17rem; + overflow-y: auto; +} + +.tile { + position: relative; + display: grid; + grid-template-columns: 32px 1fr; + grid-template-areas: + "mark name" + "mark summary"; + align-items: center; + column-gap: 0.7rem; + row-gap: 0.1rem; + padding: 0.6rem 0.7rem; + border: 1px solid var(--hair-soft); + border-radius: var(--r-row); + background: var(--raised); cursor: pointer; + transition: + border-color 0.15s ease, + background 0.15s ease; +} + +.tile:hover { + border-color: var(--hair); +} + +.tile.chosen { + border-color: var(--ink); } -/* Base first: the `.tile.wide` rules below are more specific and must win. */ +.mark-tile { + grid-area: mark; +} .tile-name { - font-weight: 600; - font-size: 0.9rem; + grid-area: name; } .tile-summary { - color: var(--muted); - font-size: 0.8rem; + grid-area: summary; +} +.tile-note { + grid-column: 2; +} + +.tile-name { + font-weight: 500; + font-size: 0.85rem; +} + +.tile-summary { + color: var(--ink-faint); + font-size: 0.75rem; line-height: 1.35; } + .tile-note { - font-size: 0.7rem; - color: var(--muted); - border: 1px solid var(--line); + justify-self: start; + font-size: 0.68rem; + color: var(--ink-soft); + background: var(--sunk); border-radius: 999px; - padding: 0.1rem 0.45rem; + padding: 0.08rem 0.45rem; + margin-top: 0.15rem; } -/* A brand's mark, or its name where no mark exists. Same box either way, so an unmarked row does - not read as unfinished. */ +/* A brand's mark, or its name where no usable mark exists. Same box either way. */ .mark-tile { - width: 44px; - height: 44px; + width: 32px; + height: 32px; display: flex; align-items: center; justify-content: center; - padding: 7px; - border-radius: 10px; + padding: 5px; + border-radius: 8px; background: #fff; - border: 1px solid var(--line); + border: 1px solid var(--hair-soft); flex: none; } -.tile.wide { - display: grid; - grid-template-columns: 44px 1fr; - grid-template-areas: "mark name" "mark summary"; - align-items: center; - column-gap: 0.9rem; - row-gap: 0.15rem; +.mark-tile img { + width: 100%; + height: 100%; } -.tile.wide .mark-tile { - grid-area: mark; +.mark-wordmark { + padding: 2px; } -.tile.wide .tile-name { - grid-area: name; + +.mark-wordmark span { + font-size: 0.52rem; + font-weight: 600; + line-height: 1.05; + text-align: center; + letter-spacing: -0.02em; + color: #050505; + hyphens: none; } -.tile.wide .tile-summary { - grid-area: summary; + +/* The radio is not drawn; the row is. Kept in the layout so it stays focusable and announced. */ +.tile-input { + position: absolute; + opacity: 0; + width: 1px; + height: 1px; + margin: 0; } -.tile:hover { - border-color: var(--muted); +.tile:has(.tile-input:focus-visible) { + outline: 2px solid var(--ink); + outline-offset: 2px; } -.tile.chosen { - border-color: var(--accent); - box-shadow: inset 0 0 0 1px var(--accent); +/* The framework list, which almost nobody should open. */ +details { + border-top: 1px solid var(--hair-soft); + padding-top: 0.9rem; } -.mark-tile img { - width: 100%; - height: 100%; +details > summary { + cursor: pointer; + font-size: 0.82rem; + color: var(--ink-faint); + list-style: none; + display: flex; + align-items: center; + gap: 0.4rem; } -/* Less padding than a mark gets: a word needs the width, and "Langroid" broke to "Langr oid". */ -.mark-wordmark { - padding: 3px; +details > summary::-webkit-details-marker { + display: none; } -.mark-wordmark span { - font-size: 0.58rem; - hyphens: none; - letter-spacing: -0.02em; - font-weight: 600; - line-height: 1.05; - text-align: center; - color: #111827; - word-break: break-word; +details > summary::before { + content: ""; + width: 0; + height: 0; + border-left: 4px solid currentColor; + border-top: 3.5px solid transparent; + border-bottom: 3.5px solid transparent; + transition: transform 0.15s ease; } +details[open] > summary::before { + transform: rotate(90deg); +} + +details > summary:hover { + color: var(--ink); +} + +/* The chosen provider's own panel: the one raised surface per screen. */ .chosen-provider { - border: 1px solid var(--line); - border-radius: 12px; + border: 1px solid var(--hair); + border-radius: var(--r-card); + background: var(--raised); padding: 1rem; - margin-bottom: 1.25rem; + margin-top: 0.9rem; } .segmented { display: inline-flex; - border: 1px solid var(--line); + background: var(--sunk); border-radius: 999px; - padding: 2px; + padding: 3px; margin-bottom: 0.9rem; } .segmented button { border: 0; background: transparent; - color: var(--muted); + color: var(--ink-soft); border-radius: 999px; padding: 0.3rem 0.8rem; - font-size: 0.8rem; - cursor: pointer; + font-size: 0.78rem; + font-weight: 500; } .segmented button.on { - background: var(--accent); - color: var(--ground); + background: var(--raised); + color: var(--ink); + box-shadow: 0 1px 2px rgba(15, 13, 10, 0.08); } -.caution { - font-size: 0.8rem; - color: var(--muted); - line-height: 1.45; +.caution, +.fallback { + font-size: 0.78rem; + color: var(--ink-faint); + line-height: 1.5; margin: 0.9rem 0 0; } -.caution a { +.caution a, +.fallback a { color: var(--ink); + text-underline-offset: 2px; } -/* The radio itself is not drawn; the tile is. Kept in the layout rather than `display: none` so it - is still focusable and still announced. */ -.tile-input { - position: absolute; - opacity: 0; - width: 1px; - height: 1px; +/* Something is in the way, or something failed. */ +.blocker { + /* No coloured side border: the heading already carries the state, and a thick accent stripe is + decoration standing in for hierarchy. */ + border: 1px solid var(--hair); + border-radius: var(--r-row); + background: var(--raised); + padding: 0.9rem 1rem; + margin: 1rem 0 0; +} + +.blocker h2 { + color: var(--bad); +} + +.blocker p { margin: 0; + color: var(--ink-soft); + font-size: 0.85rem; } -/* A fieldset is the group, but its default border and padding are not wanted here. */ -fieldset.picker { - border: 0; - padding: 0; - margin: 0 0 1.5rem; +/* Per-item progress, because this is the slowest screen in the product. */ +.steps { + margin-top: 1.25rem; + display: flex; + flex-direction: column; + gap: 0.3rem; } -/* Keyboard focus has to be visible on the tile, since the control inside it is not. */ -.tile:has(.tile-input:focus-visible) { - outline: 2px solid var(--accent); - outline-offset: 2px; +.step { + display: grid; + grid-template-columns: 1rem 1fr auto; + gap: 0.5rem; + align-items: baseline; + font-size: 0.82rem; +} + +.step .mark { + font-size: 0.75rem; +} + +.step .mark.good { + color: var(--good); +} +.step .mark.bad { + color: var(--bad); +} + +.step .detail { + color: var(--ink-faint); + font-size: 0.75rem; + text-align: right; } .sr-only { @@ -316,14 +492,3 @@ fieldset.picker { white-space: nowrap; border: 0; } - -/* The copyable link beside an open that may have silently failed. */ -.fallback { - font-size: 0.8rem; - color: var(--muted); - margin: 0 0 0.9rem; -} - -.fallback a { - color: var(--ink); -} From ea125208fb93e90223325e35e9052da5673217c5 Mon Sep 17 00:00:00 2001 From: David McKay Date: Mon, 7 Sep 2026 12:48:27 -0700 Subject: [PATCH 19/46] Stop writing localhost where something has to dial it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `localhost` does not resolve the same way on every operating system or runtime: Node takes it to `::1` and bun to `127.0.0.1`. So a name reaches a different interface depending on what started the process, and a service that is listening looks like one that is not. `stack.rs` already probes both numerically for exactly this reason; the rest of the tree did not. Swept and fixed everywhere something dials or advertises an address: the setup window's fallback URL, the supervisor's default host for a Bot's computer, the tool endpoint the LangGraph Bot calls back on, and the lines every service prints on startup. TRUSTED_ORIGINS was the same bug pointing the other way. It defaulted to `localhost` alone, which refused a browser pointed at `127.0.0.1:3010` — the address the rest of the deployment hands out. It lists all three spellings now, because an allowlist has to match what a browser sends rather than what we would prefer it typed. Three uses stay, each a string somebody else compares rather than an address we reach: that allowlist, the SSRF denylist that must keep matching the name or be bypassed by it, and OpenAI's registered OAuth redirect. Each says which it is, so the next sweep does not "fix" it. Also adds the ChatGPT plan sign-in, and it needed both of those facts. The vendor's login refuses a non-loopback callback by design, and a published Docker port cannot reach a loopback listener inside a container, so the login keeps its bind and a relay forwards a published port into it. The callback host is left at the library's default because OpenAI compares the redirect URI as a string and has `localhost` registered: passing the numeric literal fails the authorize request with `unknown_error` before a login page is drawn. Measured twice before the cause was obvious, and pinned by a test. --- agent-bot/src/index.ts | 2 +- agent-computer/src/index.ts | 2 +- agent-langgraph/src/index.ts | 6 +- desktop/src-tauri/src/env.rs | 84 ++++++++++++++ desktop/src-tauri/src/main.rs | 45 +++++++- desktop/src-tauri/src/plan.rs | 137 +++++++++++++++++++++++ desktop/src/App.tsx | 31 ++++- desktop/src/ProviderPicker.tsx | 17 +++ desktop/src/Welcome.tsx | 38 +------ desktop/src/styles.css | 70 ++++++++++++ server/src/computer/supervisor.ts | 9 +- server/src/config.ts | 7 +- server/src/index.ts | 2 +- server/tests/computer-supervisor.test.ts | 4 +- server/tests/config.test.ts | 6 +- 15 files changed, 412 insertions(+), 48 deletions(-) diff --git a/agent-bot/src/index.ts b/agent-bot/src/index.ts index 3824c5d9a..c6974fffc 100644 --- a/agent-bot/src/index.ts +++ b/agent-bot/src/index.ts @@ -241,4 +241,4 @@ serve({ }, }); -console.info(`agent-bot listening on http://localhost:${PORT}/ag-ui`); +console.info(`agent-bot listening on http://127.0.0.1:${PORT}/ag-ui`); diff --git a/agent-computer/src/index.ts b/agent-computer/src/index.ts index 341fcc349..7d382f077 100644 --- a/agent-computer/src/index.ts +++ b/agent-computer/src/index.ts @@ -1217,7 +1217,7 @@ function fileStatus(error: unknown): 400 | 403 | 500 { return 500; } -console.info(`agent-computer listening on http://localhost:${PORT}`); +console.info(`agent-computer listening on http://127.0.0.1:${PORT}`); /** * Hand the profile back before dying. diff --git a/agent-langgraph/src/index.ts b/agent-langgraph/src/index.ts index 5159faa11..9704cdb47 100644 --- a/agent-langgraph/src/index.ts +++ b/agent-langgraph/src/index.ts @@ -235,7 +235,9 @@ function buildModel() { * here, in this process, and every call it makes goes back through the deployment that granted it. */ const TOOL_URL = - process.env.OPENBOT_TOOL_URL ?? "http://localhost:3001/api/agent-tools/call"; + // Numeric, never `localhost`: it resolves to `::1` under Node and `127.0.0.1` under bun, so a + // name here reaches a different interface depending on what started the process. + process.env.OPENBOT_TOOL_URL ?? "http://127.0.0.1:3001/api/agent-tools/call"; const TOOL_TOKEN = process.env.AGENT_TOOL_TOKEN ?? ""; async function callTool( @@ -457,4 +459,4 @@ serve({ }, }); -console.info(`agent-langgraph listening on http://localhost:${PORT}/ag-ui`); +console.info(`agent-langgraph listening on http://127.0.0.1:${PORT}/ag-ui`); diff --git a/desktop/src-tauri/src/env.rs b/desktop/src-tauri/src/env.rs index ad33f2968..43cd90571 100644 --- a/desktop/src-tauri/src/env.rs +++ b/desktop/src-tauri/src/env.rs @@ -383,6 +383,40 @@ pub enum ModelCredential { /// Lines the shell did not write are kept: somebody who added `OPENAI_API_KEY` by hand, or a /// setting a later version of this app does not know about, should not lose it because the stack /// was restarted. +/** +What a previous run already put in the `.env`. + +So the wizard never asks twice. A person who has set this up before, or whose IT department laid the +file down for them, should not be made to find a key again — and "find it again" in practice means +opening a dotfile in a text editor, which is the exact thing this product exists not to require. + +Only the settings the wizard asks about are read back. Everything else in that file is somebody +else's, and this has no business handing it to a window. +*/ +pub fn already_set(path: &Path, keys: &[&str]) -> BTreeMap { + let Ok(text) = std::fs::read_to_string(path) else { + return BTreeMap::new(); + }; + let mut found = BTreeMap::new(); + for line in text.lines() { + let line = line.trim(); + if line.starts_with('#') { + continue; + } + let Some((key, value)) = line.split_once('=') else { + continue; + }; + let key = key.trim(); + let value = value.trim(); + // Blank is not a value: the writer clears keys a choice does not imply, and offering those + // back as though somebody had set them would undo that. + if keys.contains(&key) && !value.is_empty() { + found.insert(key.to_string(), value.to_string()); + } + } + found +} + pub fn write(path: &Path, owned: &BTreeMap) -> std::io::Result<()> { let existing = std::fs::read_to_string(path).unwrap_or_default(); let mut out = String::new(); @@ -986,4 +1020,54 @@ mod model_tests { ); } } + + /// The wizard does not ask twice for something already in the file. + #[test] + fn what_is_already_set_is_read_back() { + let dir = std::env::temp_dir().join(format!("openbot-read-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join(".env"); + std::fs::write( + &path, + "# a comment\nINTELLIGENCE_API_KEY=already-here\nINTELLIGENCE_API_URL=\nSOMETHING_ELSE=theirs\n", + ) + .unwrap(); + + let found = already_set( + &path, + &[ + "INTELLIGENCE_API_KEY", + "INTELLIGENCE_API_URL", + "SOMETHING_ELSE", + ], + ); + assert_eq!( + found.get("INTELLIGENCE_API_KEY").map(String::as_str), + Some("already-here") + ); + // Blank is not a value: the writer clears keys a choice does not imply, and handing those + // back would undo that. + assert!(!found.contains_key("INTELLIGENCE_API_URL")); + std::fs::remove_dir_all(&dir).ok(); + } + + /// Only what the wizard asks about. The rest of that file is somebody else's. + #[test] + fn nothing_the_wizard_did_not_ask_for_is_read_back() { + let dir = std::env::temp_dir().join(format!("openbot-read2-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join(".env"); + std::fs::write(&path, "PRIVATE_THING=not-yours\nINTELLIGENCE_API_KEY=k\n").unwrap(); + let found = already_set(&path, &["INTELLIGENCE_API_KEY"]); + assert_eq!(found.len(), 1); + assert!(!found.contains_key("PRIVATE_THING")); + std::fs::remove_dir_all(&dir).ok(); + } + + /// No file is not an error; it is a first run. + #[test] + fn a_missing_file_reads_back_nothing() { + let found = already_set(Path::new("/nowhere/at/all/.env"), &["INTELLIGENCE_API_KEY"]); + assert!(found.is_empty()); + } } diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index 0428a162d..1604a42f2 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -439,9 +439,10 @@ fn stop_everything(app: &tauri::AppHandle, fallback_root: &Path) -> Result<(), S /// have nowhere to live. Setup comes back if the stack is stopped, because then there is something /// to set up again. /// -/// `localhost` rather than an address, against the rule the rest of this file follows: the app's -/// dev server binds `[::1]` and not `127.0.0.1`, so naming either one guesses wrong half the time. -/// Every spelling of it is trusted, so whichever it bound is the right one. +/// The address is asked for rather than named. `stack::app_url` tries `127.0.0.1` and `[::1]` and +/// returns whichever answered, because a dev server binds whichever loopback its runtime resolved +/// and naming one guesses wrong half the time. Never the word `localhost`: it does not resolve the +/// same way on every operating system, which is the whole reason both are asked. #[tauri::command] fn show_openbot(app: tauri::AppHandle) -> Result<(), String> { let port = openbot_env::Ports::default().app; @@ -479,7 +480,12 @@ fn show_setup(app: tauri::AppHandle) -> Result<(), String> { .lock() .unwrap() .clone() - .unwrap_or_else(|| "http://localhost:3020".to_string()); + // Asked for, not named, and numeric either way: `localhost` resolves differently per + // operating system, so the two loopbacks are tried and whichever answers is used. The + // v4 literal is the last resort rather than a hostname. + .unwrap_or_else(|| { + stack::app_url(3020).unwrap_or_else(|| "http://127.0.0.1:3020".to_string()) + }); window .navigate( setup @@ -531,6 +537,36 @@ fn default_root() -> String { stack::default_root().to_string_lossy().into_owned() } +/** +What a previous run already wrote, so the wizard can arrive filled in. + +Returned to the window because that is where the fields are, and it is the same machine and the +same person: reading their own file back to them is not a disclosure. The key is not logged here or +anywhere, and only the settings the wizard asks about are read. +*/ +#[tauri::command] +fn already_configured(root: String) -> std::collections::BTreeMap { + openbot_env::already_set( + &PathBuf::from(root).join(".env"), + &[ + "INTELLIGENCE_API_KEY", + "INTELLIGENCE_API_URL", + "INTELLIGENCE_GATEWAY_WS_URL", + /* + * The model credentials too, so the wizard never asks twice for one of these either. + * + * A key already in the file is one somebody has already produced, and making them find + * it again means opening a dotfile in an editor. Read back for the same reason the + * Intelligence key is: it is their own file, on their own machine, and this is the + * screen that asks for it. + */ + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "OPENAI_BASE_URL", + ], + ) +} + /// The harness picker's rows. Data, so the screen is a list and not twelve branches. #[tauri::command] fn harnesses() -> Vec { @@ -810,6 +846,7 @@ fn main() { default_root, harnesses, providers, + already_configured, begin_claude_sign_in, finish_claude_sign_in, ]) diff --git a/desktop/src-tauri/src/plan.rs b/desktop/src-tauri/src/plan.rs index 3c2f3f0de..6d878ab02 100644 --- a/desktop/src-tauri/src/plan.rs +++ b/desktop/src-tauri/src/plan.rs @@ -429,6 +429,109 @@ impl SigningIn { } } +/// The image whose `langchain-openai` runs the ChatGPT sign-in. +/// +/// The LangGraph harness, used as a tool rather than as a Bot for the same reason the Claude one is: +/// it is the image that already carries the vendor's own login. It is also the default harness, so +/// on the common path this image is being pulled anyway. +pub const CHATGPT_SIGN_IN_IMAGE: &str = "openbot-agent-langgraph-agui:v0.0.8"; + +/// Where the vendor's login persists what it gets. +const CHATGPT_STORE: &str = "/root/.langchain/chatgpt-auth.json"; + +/// The port the vendor's login binds, and the port the container publishes to reach it. +/// +/// Two different numbers on purpose. See `CHATGPT_LOGIN`. +const CHATGPT_LOOPBACK: u16 = 1455; +const CHATGPT_RELAY: u16 = 1456; + +/** +The ChatGPT sign-in, as a program handed to the harness image. + +WHY THERE IS A RELAY IN HERE. `langchain-openai` refuses a non-loopback callback host on purpose: +RFC 8252 wants a loopback redirect for a native app, and binding `0.0.0.0` would put the +authorization code on the local network. But a published Docker port cannot reach a `127.0.0.1` +listener inside the container. So the vendor's server keeps its loopback bind and this relay accepts +on `0.0.0.0:1456` and forwards into it. The container publishes 1456 as the host's 1455, which is +the address the browser is sent to. + +AND THE HOST IS LEFT AT ITS DEFAULT, `localhost`, WHICH IS NOT COSMETIC. OpenAI compares the +redirect URI as a string, and `http://localhost:1455/auth/callback` is what is registered. Passing +`127.0.0.1` — the same address, a different string — makes the authorize request fail with +`unknown_error` before any login page is drawn. Measured, twice, before the cause was obvious. + +Passed as an argument rather than a mounted file, so the app never has to write a script to disk to +run one. +*/ +const CHATGPT_LOGIN: &str = r#" +import json, socket, threading +from pathlib import Path + +def pump(a, b): + try: + while True: + data = a.recv(65536) + if not data: + break + b.sendall(data) + except OSError: + pass + finally: + for s in (a, b): + try: + s.shutdown(socket.SHUT_RDWR) + except OSError: + pass + +def relay(): + listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + listener.bind(("0.0.0.0", RELAY)) + listener.listen(8) + while True: + client, _ = listener.accept() + try: + upstream = socket.create_connection(("127.0.0.1", LOOPBACK), timeout=10) + except OSError: + client.close() + continue + threading.Thread(target=pump, args=(client, upstream), daemon=True).start() + threading.Thread(target=pump, args=(upstream, client), daemon=True).start() + +threading.Thread(target=relay, daemon=True).start() + +from langchain_openai.chatgpt_oauth import login_chatgpt + +login_chatgpt(open_browser=False, port=LOOPBACK, timeout=900) + +raw = json.loads(Path(STORE).read_text()) +print("OPENBOT_CHATGPT_TOKEN=" + (raw.get("access_token") or raw.get("token") or ""), flush=True) +"#; + +/// The token the vendor's login printed, if it got one. +/// +/// Its own line rather than scraped out of the store file, because the store shape belongs to the +/// library and the line is this deployment's own contract with the program above. +pub fn chatgpt_token_in(output: &str) -> Option { + plain(output) + .lines() + .filter_map(|line| line.trim().strip_prefix("OPENBOT_CHATGPT_TOKEN=")) + .map(str::trim) + .find(|token| !token.is_empty()) + .map(str::to_string) +} + +/// The address a browser has to open for the ChatGPT sign-in. +/// +/// Printed by the vendor's login as its fallback when `open_browser` is off, which is how this gets +/// it: OpenBot opens the browser itself so the window can also show the link. +pub fn openai_url_in(output: &str) -> Option { + plain(output) + .split_whitespace() + .find(|word| word.starts_with("https://auth.openai.com/oauth/authorize")) + .map(str::to_string) +} + #[cfg(test)] mod tests { use super::*; @@ -532,6 +635,40 @@ mod tests { assert_eq!(token_in(&format!("{PLAN_TOKEN_PREFIX}01-abc")), None); } + /// The token line is this deployment's contract with the program it hands the image. + #[test] + fn the_chatgpt_token_is_read_off_its_own_line() { + let output = "some chatter\nOPENBOT_CHATGPT_TOKEN=abc123\nmore chatter\n"; + assert_eq!(chatgpt_token_in(output).as_deref(), Some("abc123")); + // An empty value is not a token: the store had no access token in it. + assert_eq!(chatgpt_token_in("OPENBOT_CHATGPT_TOKEN=\n"), None); + assert_eq!(chatgpt_token_in("nothing here"), None); + } + + /// The URL the vendor's login prints as its fallback. + #[test] + fn the_openai_url_is_found() { + let real = "https://auth.openai.com/oauth/authorize?client_id=app_x&redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback"; + assert_eq!( + openai_url_in(&format!("Open this: {real}\n")).as_deref(), + Some(real) + ); + assert_eq!(openai_url_in("no url yet"), None); + } + + /// The registered redirect is `localhost`, and the script must not name anything else. + /// + /// `127.0.0.1` is the same address and a different string, and OAuth registration compares + /// strings: passing it makes the authorize request fail with `unknown_error` before a login + /// page is ever drawn. That cost two live attempts. + #[test] + fn the_login_leaves_the_callback_host_alone() { + assert!( + !CHATGPT_LOGIN.contains("host="), + "the script names a callback host; the default `localhost` is what OpenAI registered" + ); + } + #[test] fn nothing_in_nothing() { assert_eq!(token_in(""), None); diff --git a/desktop/src/App.tsx b/desktop/src/App.tsx index afc0014ca..610ea8cbc 100644 --- a/desktop/src/App.tsx +++ b/desktop/src/App.tsx @@ -39,6 +39,8 @@ export function App() { */ const [harness, setHarness] = useState(DEFAULT_HARNESS); const [model, setModel] = useState(null); + /** Model credentials a previous run already wrote, so the provider screen arrives filled in. */ + const [alreadyHeld, setAlreadyHeld] = useState>({}); const [step, setStep] = useState<"welcome" | "harness" | "model" | "install">( "welcome", ); @@ -60,6 +62,22 @@ export function App() { invoke("default_root") .then(async (found) => { setRoot(found); + /* + * Arrive filled in when a previous run already wrote these. + * + * The alternative is asking somebody to find a key again, and "find it again" means opening + * a dotfile in a text editor — the exact thing this product exists not to require. Their own + * file, read back to them on their own machine. + */ + invoke>("already_configured", { root: found }) + .then((set) => { + if (set.INTELLIGENCE_API_KEY) setApiKey(set.INTELLIGENCE_API_KEY); + if (set.INTELLIGENCE_API_URL) setApiUrl(set.INTELLIGENCE_API_URL); + if (set.INTELLIGENCE_GATEWAY_WS_URL) + setWsUrl(set.INTELLIGENCE_GATEWAY_WS_URL); + setAlreadyHeld(set); + }) + .catch(() => undefined); // A stack this app started may still be up from a previous window. Ask, rather than // offering to set up something that is already running. if ( @@ -203,6 +221,7 @@ export function App() { return (
{ setModel(choice); @@ -261,8 +280,18 @@ export function App() { spellCheck={false} />
+ {/* + This used to be headed "Self-hosted Intelligence" over two fields pre-filled with the + MANAGED service's addresses, which says the opposite of what it does: somebody opening + it to check where their data goes read "self-hosted" and saw CopilotKit's own hosts. + The heading now describes the action, and the note says what the defaults are. + */}
- Self-hosted Intelligence + Point at your own Intelligence server +

+ These default to CopilotKit's managed service. Change them only if + you run Intelligence yourself. +

; onChoose: (choice: ModelChoice) => void; onBack: () => void; }) { @@ -136,6 +144,15 @@ export function ProviderPicker({ setOpen(r.id); // The first way in is the default, which is the plan wherever there is one. setLogin(r.logins[0] ?? null); + // Fill from what is already on this machine, if anything. + const kept = + r.id === "anthropic" + ? held.ANTHROPIC_API_KEY + : held.OPENAI_API_KEY; + setApiKey(kept ?? ""); + if (r.id === "openai-compatible" && held.OPENAI_BASE_URL) { + setBaseUrl(held.OPENAI_BASE_URL); + } }} /> diff --git a/desktop/src/Welcome.tsx b/desktop/src/Welcome.tsx index 56302ceba..3037c0617 100644 --- a/desktop/src/Welcome.tsx +++ b/desktop/src/Welcome.tsx @@ -8,7 +8,10 @@ export function Welcome({ onStart }: { onStart: () => void }) { return (
- +
+

Your own AI coworkers, on this computer.

They answer questions, use the tools you connect, and can work in a @@ -26,36 +29,3 @@ export function Welcome({ onStart }: { onStart: () => void }) {

); } - -/** - * The product's own mark. - * - * Drawn rather than fetched, because this is the first paint of a window that has no network - * guarantee yet, and it is two shapes: a filled square for the person and an outlined one beside it - * for the coworker, overlapping. It is here so the first screen is recognisably a product rather - * than a form. - */ -function Mark() { - return ( - - ); -} diff --git a/desktop/src/styles.css b/desktop/src/styles.css index ae427a626..3ec238006 100644 --- a/desktop/src/styles.css +++ b/desktop/src/styles.css @@ -492,3 +492,73 @@ details > summary:hover { white-space: nowrap; border: 0; } + +/* + * OpenBot's own mark, and it is the real one. + * + * An earlier version of this screen shipped a mark I drew: two overlapping squares. That was + * invented, which is the same thing this file refuses to do for other people's brands, and there + * was no excuse for it — the product has a mark, on its own page. + * + * The four colours, the sheen positions and the rim are taken from that page's own CSS rather than + * eyeballed. What is NOT reproduced is the animated conic spin: the real orb rotates four conic + * gradients off an `@property` angle, which is right for a hero at 56px and gratuitous for a 34px + * mark on a window that is about to start containers. The composition and the palette are the + * brand; the animation is that page's. + */ +.orb { + --c1: oklch(68% 0.21 350); + --c2: oklch(70% 0.18 210); + --c3: oklch(66% 0.2 285); + --c4: oklch(72% 0.19 325); + width: 30px; + height: 30px; + border-radius: 50%; + position: relative; + isolation: isolate; + overflow: hidden; + flex: none; + background: + radial-gradient(circle at 78% 22%, var(--c1), transparent 58%), + radial-gradient(circle at 26% 72%, var(--c3), transparent 60%), + radial-gradient(circle at 46% 78%, var(--c2), transparent 55%), + radial-gradient(circle at 62% 38%, var(--c4), transparent 62%); + filter: saturate(0.9); +} + +/* The glassy highlight, which is what stops it reading as a printed-on gradient. */ +.orb::before { + content: ""; + position: absolute; + inset: 0; + border-radius: 50%; + background: + radial-gradient(circle at 30% 24%, hsl(0 0% 100% / 0.32), transparent 34%), + radial-gradient(circle at 72% 80%, hsl(0 0% 100% / 0.07), transparent 48%); + mix-blend-mode: screen; +} + +/* The rim: lit from above, shaded below, so it sits in the page rather than on it. */ +.orb::after { + content: ""; + position: absolute; + inset: 0; + border-radius: 50%; + box-shadow: + inset 0 0 0 1px hsl(0 0% 100% / 0.16), + inset 0 2px 4px hsl(0 0% 100% / 0.22), + inset 0 -2px 5px hsl(0 0% 0% / 0.4); +} + +.lockup { + display: flex; + align-items: center; + gap: 0.6rem; + margin-bottom: 1.75rem; +} + +.lockup span { + font-size: 1.05rem; + font-weight: 600; + letter-spacing: -0.02em; +} diff --git a/server/src/computer/supervisor.ts b/server/src/computer/supervisor.ts index b632e9c22..5e25f0613 100644 --- a/server/src/computer/supervisor.ts +++ b/server/src/computer/supervisor.ts @@ -48,8 +48,15 @@ export function createDockerSupervisorProvider( const doFetch = options.fetchImpl ?? fetch; const base = options.baseUrl.replace(/\/$/, ""); const timeoutMs = options.timeoutMs ?? 120_000; + /* + * Numeric, never `localhost`. + * + * `localhost` does not resolve the same way on every operating system — Node prefers `::1`, bun + * prefers `127.0.0.1` — so a name here reaches a different interface depending on what started + * the process, and a computer that is listening looks like one that is not. + */ const hostForPort = - options.hostForPort ?? ((port) => `http://localhost:${port}`); + options.hostForPort ?? ((port) => `http://127.0.0.1:${port}`); /** * The last container start time seen for each Bot, from the `/ensure` that located it. diff --git a/server/src/config.ts b/server/src/config.ts index 162dc36bf..9d7fc0066 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -531,7 +531,12 @@ function authConfig( secret, trustedOrigins: commaSeparated(environment, "TRUSTED_ORIGINS").length ? commaSeparated(environment, "TRUSTED_ORIGINS") - : ["http://localhost:3010"], + : /* + * All three spellings of the same place, because this is an allowlist of what a browser + * sends and not an address anything dials. `localhost` alone refused a browser pointed at + * `127.0.0.1:3010`, which is the address the rest of this deployment hands out. + */ + ["http://127.0.0.1:3010", "http://[::1]:3010", "http://localhost:3010"], initialAdminEmails, ...(google ? { google } : {}), ...(microsoft ? { microsoft } : {}), diff --git a/server/src/index.ts b/server/src/index.ts index 667f05bdb..ab51004dc 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1305,4 +1305,4 @@ for (const signal of ["SIGINT", "SIGTERM"] as const) { }); } -console.info(`OpenBot server listening on http://localhost:${port}`); +console.info(`OpenBot server listening on http://127.0.0.1:${port}`); diff --git a/server/tests/computer-supervisor.test.ts b/server/tests/computer-supervisor.test.ts index ddc38ced3..4fdf48baa 100644 --- a/server/tests/computer-supervisor.test.ts +++ b/server/tests/computer-supervisor.test.ts @@ -48,7 +48,9 @@ describe("locating a Bot's computer", () => { port: 49213, }), ); - expect(await client.locate("sales")).toBe("http://localhost:49213"); + // Numeric, never `localhost`: it resolves to a different interface depending on the + // runtime, so a computer that is listening can look like one that is not. + expect(await client.locate("sales")).toBe("http://127.0.0.1:49213"); }); test("a computer with no address at all is an error, not a fallback", async () => { diff --git a/server/tests/config.test.ts b/server/tests/config.test.ts index 45061e0f5..1d146a281 100644 --- a/server/tests/config.test.ts +++ b/server/tests/config.test.ts @@ -286,7 +286,11 @@ describe("deployment configuration", () => { clientId: "google-client-id", clientSecret: "google-client-secret", }, - trustedOrigins: ["http://localhost:3010"], + trustedOrigins: [ + "http://127.0.0.1:3010", + "http://[::1]:3010", + "http://localhost:3010", + ], initialAdminEmails: ["admin@openbot.test", "owner@openbot.test"], }); }); From 87d99447f4bd9c72ddb7496c8adbfcd8c076a92d Mon Sep 17 00:00:00 2001 From: David McKay Date: Mon, 7 Sep 2026 12:50:19 -0700 Subject: [PATCH 20/46] Sign in to a ChatGPT plan from the window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plan was offered on the model screen and had no command behind it. It has one now, and it is not the Claude flow with the names changed. Anthropic's CLI wants a code typed at a prompt, which is why that half needs a terminal and shows a field. This login finishes itself when the browser redirect reaches its callback, so nothing is typed, no pty is needed, and the screen shows a wait instead. A code box for a flow that never produces one is how somebody decides the product is broken. Two facts it took running to find. The vendor's login refuses a non-loopback callback host on purpose, and a published Docker port cannot reach a loopback listener inside a container, so the login keeps its bind and a relay forwards a published port into it. And the callback host is left at the library's default: OpenAI compares the redirect URI as a string and has `localhost` registered, so passing the numeric literal — the same address — fails the authorize request with `unknown_error` before a login page is drawn. Both loopbacks are published, because a browser resolving that registered name may pick either family and which one is not ours to decide. Proven as far as it can be without entering somebody's password: the relay carries the callback, and OpenAI accepts the authorize request and serves its login page. The step after that is a credential, so it stays with the person. --- desktop/src-tauri/src/main.rs | 44 ++++++++++++ desktop/src-tauri/src/plan.rs | 123 +++++++++++++++++++++++++++++++++ desktop/src/ProviderPicker.tsx | 67 ++++++++++++------ 3 files changed, 214 insertions(+), 20 deletions(-) diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index 1604a42f2..2d2b4330c 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -36,6 +36,11 @@ struct Shell { /// moment it is worth reading. Held here instead, and asked for on load. last_failure: Mutex>, root: Mutex>, + /// A ChatGPT sign-in waiting for the browser redirect to complete it. + /// + /// Held for the same reason the Claude one is: a person leaves and comes back in the middle. + /// Unlike that one, nothing is typed here — the callback finishes it. + signing_in_to_chatgpt: Mutex>, /// A plan sign-in waiting for the code from the browser. /// /// Held across two commands because a person has to leave and approve in the middle of it, and @@ -629,6 +634,43 @@ async fn finish_claude_sign_in(app: tauri::AppHandle, code: String) -> Result Result { + let address = engine::detect().address.ok_or_else(|| { + "No container engine is answering, so the sign-in cannot run.".to_string() + })?; + let image = openbot_desktop_lib::plan::CHATGPT_SIGN_IN_IMAGE.to_string(); + let (signing, url) = tauri::async_runtime::spawn_blocking(move || { + openbot_desktop_lib::plan::SigningInToChatGpt::begin(&address, &image) + }) + .await + .map_err(|error| format!("The sign-in did not run: {error}"))??; + *app.state::().signing_in_to_chatgpt.lock().unwrap() = Some(signing); + let _ = tauri_plugin_opener::OpenerExt::opener(&app).open_url(&url, None::<&str>); + Ok(url) +} + +/** +Wait for the ChatGPT redirect to land, and return the plan token. + +Nothing is sent: the browser's callback is what finishes it. So this is a wait rather than a +redemption, which is why there is no code field on that half of the screen. +*/ +#[tauri::command] +async fn finish_chatgpt_sign_in(app: tauri::AppHandle) -> Result { + let signing = app + .state::() + .signing_in_to_chatgpt + .lock() + .unwrap() + .take() + .ok_or_else(|| "That sign-in is no longer running. Start it again.".to_string())?; + tauri::async_runtime::spawn_blocking(move || signing.finish()) + .await + .map_err(|error| format!("The sign-in did not finish: {error}"))? +} + /// The model screen's rows. Independent of the picker above, and required to stay that way: no /// harness on that list is tied to a vendor's models, so choosing one may not narrow this. #[tauri::command] @@ -849,6 +891,8 @@ fn main() { already_configured, begin_claude_sign_in, finish_claude_sign_in, + begin_chatgpt_sign_in, + finish_chatgpt_sign_in, ]) // A packaged application is not a browser tab. Left alone, WebView2 answers a right-click // with Back, Refresh, Save as and Print: Back walks the window out of OpenBot with nothing diff --git a/desktop/src-tauri/src/plan.rs b/desktop/src-tauri/src/plan.rs index 6d878ab02..0203850b8 100644 --- a/desktop/src-tauri/src/plan.rs +++ b/desktop/src-tauri/src/plan.rs @@ -508,6 +508,129 @@ raw = json.loads(Path(STORE).read_text()) print("OPENBOT_CHATGPT_TOKEN=" + (raw.get("access_token") or raw.get("token") or ""), flush=True) "#; +/** +A ChatGPT sign-in in progress. + +NO PTY HERE, unlike the Claude flow, and the difference is what completes it. Anthropic's CLI wants +a code typed at a prompt, which needs a terminal. This login finishes on its own when the browser +redirect reaches the callback, so nothing is ever typed and plain pipes are enough. +*/ +pub struct SigningInToChatGpt { + child: std::process::Child, + output: std::sync::Arc>, +} + +impl SigningInToChatGpt { + /// Start the flow and return the URL a browser has to open. + pub fn begin(engine: &crate::engine::Address, image: &str) -> Result<(Self, String), String> { + let program = CHATGPT_LOGIN + .replace("RELAY", &CHATGPT_RELAY.to_string()) + .replace("LOOPBACK", &CHATGPT_LOOPBACK.to_string()) + .replace("STORE", &format!("{CHATGPT_STORE:?}")); + + let (binary, arguments) = engine.parts(); + let mut command = crate::quiet::command(binary); + command.args(arguments); + command.arg("run"); + command.arg("--rm"); + /* + * Published on loopback only, and on the number the vendor's login advertises. + * + * The container's relay listens on `CHATGPT_RELAY` and forwards to the login's own + * loopback bind; the browser is sent to `CHATGPT_LOOPBACK` on this machine. Both families + * are published because a browser resolving the registered `localhost` may pick either, and + * which one it picks is not ours to decide. + */ + for host in ["127.0.0.1", "[::1]"] { + command.arg("-p"); + command.arg(format!("{host}:{CHATGPT_LOOPBACK}:{CHATGPT_RELAY}")); + } + command.arg(image); + command.arg("python"); + command.arg("-u"); + command.arg("-c"); + command.arg(program); + command.stdout(std::process::Stdio::piped()); + command.stderr(std::process::Stdio::piped()); + + let mut child = command + .spawn() + .map_err(|error| format!("The sign-in did not start: {error}"))?; + + // Both streams, because the vendor's login prints its fallback URL to whichever it prefers + // and that is not ours to depend on. + let output = std::sync::Arc::new(std::sync::Mutex::new(String::new())); + let held = std::sync::Arc::clone(&output); + if let Some(mut out) = child.stdout.take() { + std::thread::spawn(move || drain(&mut out, held)); + } + let held = std::sync::Arc::clone(&output); + if let Some(mut err) = child.stderr.take() { + std::thread::spawn(move || drain(&mut err, held)); + } + + let mut signing = Self { child, output }; + let url = signing + .wait_for(openai_url_in, PATIENCE_FOR_THE_LINK) + .ok_or_else(|| { + signing.stop(); + "The sign-in never offered a link to open.".to_string() + })?; + Ok((signing, url)) + } + + /// Wait for the browser redirect to complete the login, and return the token. + /// + /// Nothing is sent: the callback is what finishes this, so all there is to do is wait for the + /// program to say what it got. + pub fn finish(mut self) -> Result { + match self.wait_for(chatgpt_token_in, PATIENCE_FOR_THE_PERSON) { + Some(token) => { + self.stop(); + Ok(token) + } + None => { + self.stop(); + Err("That sign-in did not finish. Start it again and approve the request in your browser.".into()) + } + } + } + + fn wait_for(&mut self, found: impl Fn(&str) -> Option, patience: Duration) -> Option { + let began = Instant::now(); + while began.elapsed() < patience { + if let Ok(seen) = self.output.lock() { + if let Some(value) = found(&seen) { + return Some(value); + } + } + if matches!(self.child.try_wait(), Ok(Some(_))) { + std::thread::sleep(Duration::from_millis(150)); + return self.output.lock().ok().and_then(|seen| found(&seen)); + } + std::thread::sleep(Duration::from_millis(200)); + } + None + } + + fn stop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +/// Accumulate a child's stream. Never printed: the tail of it is a token. +fn drain(stream: &mut R, into: std::sync::Arc>) { + let mut buffer = [0u8; 8192]; + while let Ok(read) = stream.read(&mut buffer) { + if read == 0 { + break; + } + let Ok(mut held) = into.lock() else { break }; + held.push_str(&String::from_utf8_lossy(&buffer[..read])); + } +} + /// The token the vendor's login printed, if it got one. /// /// Its own line rather than scraped out of the store file, because the store shape belongs to the diff --git a/desktop/src/ProviderPicker.tsx b/desktop/src/ProviderPicker.tsx index 28e68ed63..d5c0ea3dc 100644 --- a/desktop/src/ProviderPicker.tsx +++ b/desktop/src/ProviderPicker.tsx @@ -72,13 +72,31 @@ export function ProviderPicker({ const [busy, setBusy] = useState(false); const [failure, setFailure] = useState(""); + /* + * The two plans sign in differently, and the screen has to know which. + * + * Anthropic's CLI wants a code typed back, so that half shows a field. ChatGPT's login finishes + * itself when the browser redirect reaches its callback, so that half shows only a wait. Offering + * a code box for a flow that never produces one is how a person concludes it is broken. + */ async function beginSignIn() { + if (!row) return; setBusy(true); setFailure(""); try { - setSignInUrl(await invoke("begin_claude_sign_in")); + const start = + row.id === "anthropic" + ? "begin_claude_sign_in" + : "begin_chatgpt_sign_in"; + setSignInUrl(await invoke(start)); + // ChatGPT needs no code, so the wait starts straight away. + if (row.id !== "anthropic") { + setToken(await invoke("finish_chatgpt_sign_in")); + setSignInUrl(null); + } } catch (error) { setFailure(String(error)); + setSignInUrl(null); } finally { setBusy(false); } @@ -192,8 +210,9 @@ export function ProviderPicker({ ) : signInUrl ? ( <>

- Approve the request in your browser, then paste the code it - shows you. + {row.id === "anthropic" + ? "Approve the request in your browser, then paste the code it shows you." + : `Approve the request in your browser. ${row.name} will finish this on its own.`}

{/* Shown as well as opened. On a machine with no registered browser the open does nothing and says nothing, and a code @@ -204,23 +223,31 @@ export function ProviderPicker({ Open the sign-in page

-
- - setCode(e.target.value)} - autoComplete="off" - spellCheck={false} - /> -
- + {row.id === "anthropic" ? ( + <> +
+ + setCode(e.target.value)} + autoComplete="off" + spellCheck={false} + /> +
+ + + ) : ( +

+ Waiting for you to approve it… +

+ )} ) : ( <> From 176dc18618aa5377c00ac6f42da317f48cca835b Mon Sep 17 00:00:00 2001 From: David McKay Date: Mon, 7 Sep 2026 13:33:24 -0700 Subject: [PATCH 21/46] Give the picked Bot the token, and say failures twice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Driving the window found three things no test had. The picked harness answered 401 to everything. It was registered, addressable and routed to, but the deployment's token was attached by matching one endpoint exactly, so only the Bot in the box got it. The picked harness is the same kind of thing: a container this deployment started, on a port it chose, holding the token it generated. Both endpoints get it now. And the comparison is slash-insensitive, because `URL` adds a trailing slash and a stored address need not have one. `new URL("http://127.0.0.1:4206")` stringifies with a slash while the row says none, so an exact match failed silently. The endpoint with a path matched only because a path suppresses the slash, which is why one endpoint worked and the second could not. Extracting the harness resolution had dropped the image's version tag, so the name reached `.env` bare. An engine reads that as `:latest`, which no release publishes, and `compose up` failed with a registry error about a repository that does exist — at the last step, after everything else had succeeded. A test now asserts every resolved image carries its tag. Failures are two-register everywhere. A person gets a sentence about their situation; whoever is debugging gets the output verbatim, behind a disclosure. One string could not serve both, which is how "pull access denied for openbot-agent-langgraph-agui, repository does not exist or may require 'docker login'" became the headline on a setup screen. The sentence is chosen from what the engine actually said, and anything unrecognised stays general rather than guessing about somebody's machine. --- desktop/src-tauri/src/harness.rs | 58 +++++++-- desktop/src-tauri/src/lib.rs | 1 + desktop/src-tauri/src/main.rs | 34 ++++-- desktop/src-tauri/src/problem.rs | 180 ++++++++++++++++++++++++++++ desktop/src-tauri/src/stack.rs | 20 +++- desktop/src/App.tsx | 43 +++++-- desktop/src/styles.css | 27 +++++ server/src/agents/runtime-agents.ts | 37 ++++-- server/src/config.ts | 21 +++- server/tests/mastra-roster.test.ts | 31 +++++ 10 files changed, 410 insertions(+), 42 deletions(-) create mode 100644 desktop/src-tauri/src/problem.rs diff --git a/desktop/src-tauri/src/harness.rs b/desktop/src-tauri/src/harness.rs index 1a84eaf14..69edcab14 100644 --- a/desktop/src-tauri/src/harness.rs +++ b/desktop/src-tauri/src/harness.rs @@ -251,7 +251,13 @@ nothing because the person is bringing their own address. An unknown id is refused here rather than written into `.env`, where it would become a Bot pointing at a container nobody started — which looks like a broken Bot rather than a bad pick. */ -pub fn picked(id: Option<&str>) -> Result, String> { +pub fn picked( + id: Option<&str>, + // The release whose images these are. Tagged rather than bare: an untagged name means + // `:latest` to every engine, which is not a tag any release publishes, so the pull is refused + // and the person is shown a registry error about a repository that does exist. + version: &str, +) -> Result, String> { let Some(id) = id.map(str::trim).filter(|id| !id.is_empty()) else { return Ok(None); }; @@ -268,7 +274,7 @@ pub fn picked(id: Option<&str>) -> Result, Str }; let mastra = row.id == "mastra"; Ok(Some(crate::env::PickedHarness { - image, + image: format!("{image}:{version}"), port, name: row.name, mastra, @@ -409,23 +415,28 @@ mod tests { /// Bot rather than a pick that could not be honoured. #[test] fn an_unknown_id_is_refused_by_name() { - let refusal = picked(Some("not-a-real-harness")).expect_err("it was accepted"); + let refusal = picked(Some("not-a-real-harness"), "v0.0.0").expect_err("it was accepted"); assert!(refusal.contains("not-a-real-harness"), "{refusal}"); } /// Bringing your own address installs nothing, and that is not a failure. #[test] fn the_byo_row_resolves_to_nothing_without_complaint() { - assert_eq!(picked(Some("byo-url")).expect("it was refused"), None); - assert_eq!(picked(None).expect("it was refused"), None); - assert_eq!(picked(Some(" ")).expect("it was refused"), None); + assert_eq!( + picked(Some("byo-url"), "v0.0.0").expect("it was refused"), + None + ); + assert_eq!(picked(None, "v0.0.0").expect("it was refused"), None); + assert_eq!(picked(Some(" "), "v0.0.0").expect("it was refused"), None); } /// A real row resolves to the image the release publishes and the port that image listens on. #[test] fn a_real_row_resolves_to_its_image_and_port() { - let crewai = picked(Some("crewai")).expect("refused").expect("nothing"); - assert_eq!(crewai.image, "openbot-agent-crewai"); + let crewai = picked(Some("crewai"), "v1.2.3") + .expect("refused") + .expect("nothing"); + assert_eq!(crewai.image, "openbot-agent-crewai:v1.2.3"); assert_eq!(crewai.port, 4202); assert!(!crewai.mastra); assert!(crewai.remote_agent_id.is_empty()); @@ -435,11 +446,40 @@ mod tests { /// roster and a Bot that names none gets the only one there or a refusal. #[test] fn mastra_resolves_as_mastra_and_names_its_agent() { - let mastra = picked(Some("mastra")).expect("refused").expect("nothing"); + let mastra = picked(Some("mastra"), "v0.0.0") + .expect("refused") + .expect("nothing"); assert!(mastra.mastra); assert_eq!(mastra.remote_agent_id, "openbot"); } + /** + Every resolved image carries a tag, and this is the guard that was missing. + + Extracting this resolution out of `start_stack` dropped the version it used to append, so the + name reached `.env` bare. An engine reads a bare name as `:latest`, which no release publishes, + so `compose up` failed with a registry error about a repository that does exist — after the + deployment was laid down and the settings were written, at the last step before the stack came + up. Nothing caught it, because a name without a tag is a perfectly good string. + */ + #[test] + fn every_resolved_image_carries_its_tag() { + for row in catalogue() { + if row.image.is_none() { + continue; + } + let resolved = picked(Some(&row.id), "v9.9.9") + .expect("refused") + .expect("nothing"); + assert!( + resolved.image.ends_with(":v9.9.9"), + "{} resolved to {}, which an engine reads as :latest", + row.id, + resolved.image + ); + } + } + /// A named mark has to be a file that is actually there. The failure this catches is silent at /// runtime: a row asks for a mark that was never vendored, and the tile draws empty, which /// looks like a rendering bug rather than a missing asset. diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 15ffe8a69..31bed63c4 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -5,6 +5,7 @@ pub mod engine; pub mod env; pub mod harness; pub mod plan; +pub mod problem; pub mod provider; pub mod quiet; pub mod stack; diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index 2d2b4330c..6bfe6c3ec 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -34,7 +34,7 @@ struct Shell { /// Going back to the setup screen is a navigation, and a navigation is a fresh page: React /// remounts with no progress and the sentence explaining what happened is lost at the one /// moment it is worth reading. Held here instead, and asked for on load. - last_failure: Mutex>, + last_failure: Mutex>, root: Mutex>, /// A ChatGPT sign-in waiting for the browser redirect to complete it. /// @@ -195,7 +195,9 @@ async fn start_stack( model: ChosenModel, // The row the person picked, by id. Absent registers no Bot of their own. harness: Option, -) -> Result<(), String> { + // Both registers on the way out: see `problem.rs`. Anything that still returns a bare string + // converts to the plain half, so a path without its own sentence reads as it always did. +) -> Result<(), openbot_desktop_lib::problem::Problem> { let root = PathBuf::from(root); /* @@ -209,7 +211,7 @@ async fn start_stack( // Resolved from the catalogue rather than taken from the window: the image, the port and how // it is dialled are facts about the harness, and the window knowing them would be a second // list to keep in step. See `harness::picked` for what each refusal is for. - let picked = harness::picked(harness.as_deref())?; + let picked = harness::picked(harness.as_deref(), DEPLOYMENT_VERSION)?; // The installer does not carry the deployment; it fetches one. Skipped when the recorded // version already matches, so a restart is not a download. @@ -245,12 +247,12 @@ async fn start_stack( // deployment, and Compose's own error would not say which part was missing. if let Some(problem) = stack::deployment_problem(&root) { report(&app, "deployment", false, problem.clone()); - return Err(problem); + return Err(problem.into()); } let status = engine::detect(); let Some(found) = status.address.clone().filter(|_| status.responding) else { - return Err(status.detail); + return Err(status.detail.into()); }; // Checked here as well as in the health gate, because the gate only runs when an engine had to @@ -259,7 +261,7 @@ async fn start_stack( if !found.composes() { let problem = acquire::missing_compose(found.engine.binary()); report(&app, "engine", false, problem.clone()); - return Err(problem); + return Err(problem.into()); } let settings = openbot_env::compose( @@ -301,7 +303,7 @@ async fn start_stack( if let Some(picked) = picked.as_ref() { if let Some(problem) = stack::port_already_taken(&[("Bot you picked", picked.port)]) { report(&app, "ports", false, problem.clone()); - return Err(problem); + return Err(problem.into()); } } @@ -326,7 +328,7 @@ async fn start_stack( stack::port_already_taken(&[("API server", ports.server), ("app", ports.app)]) { report(&app, "ports", false, problem.clone()); - return Err(problem); + return Err(problem.into()); } let logs = root.join(".logs"); @@ -533,7 +535,7 @@ fn already_running(root: String) -> bool { /// /// Cleared on reading so a failure from an hour ago does not greet somebody who has since fixed it. #[tauri::command] -fn last_failure(app: tauri::AppHandle) -> Option { +fn last_failure(app: tauri::AppHandle) -> Option { app.state::().last_failure.lock().unwrap().take() } @@ -763,7 +765,19 @@ fn supervise_host_processes( let reason = watch.gave_up(); report(&app, name, false, reason.clone()); - *shell.last_failure.lock().unwrap() = Some(reason); + /* + * Both registers here too. `gave_up` names the process and quotes the tail of + * its log, which is the developer half; the person needs to know a piece of + * OpenBot stopped and that starting again is the thing to try. + */ + *shell.last_failure.lock().unwrap() = + Some(openbot_desktop_lib::problem::Problem::with( + format!( + "Part of OpenBot ({name}) stopped and could not be started again. \ + Try starting OpenBot once more." + ), + reason, + )); // Back to the setup screen. By now the window is showing OpenBot, and OpenBot // is not running: leaving it there is a window that lies. let _ = show_setup(app.clone()); diff --git a/desktop/src-tauri/src/problem.rs b/desktop/src-tauri/src/problem.rs new file mode 100644 index 000000000..3f72b88a0 --- /dev/null +++ b/desktop/src-tauri/src/problem.rs @@ -0,0 +1,180 @@ +//! What a person is told when something fails, and what a developer is told at the same time. +//! +//! TWO PARTS, ALWAYS, and this exists because one part is never enough for both readers. The +//! person needs a sentence about their situation and what to do next; whoever is debugging needs +//! the actual output, verbatim, including the bits that are only meaningful to them. Collapse them +//! and one of the two is failed: a plain sentence alone throws away the evidence, and raw engine +//! output alone is what put "pull access denied for openbot-agent-langgraph-agui, repository does +//! not exist or may require 'docker login'" in front of somebody who was setting up an app. +//! +//! The window shows `said` as the failure and keeps `detail` behind a disclosure, so the default +//! reading is the human one and nothing is lost. + +use serde::Serialize; + +/// A failure, in both registers. +#[derive(Clone, Debug, Serialize, PartialEq, Eq)] +pub struct Problem { + /// For the person. Their situation, and the next thing they can do about it. + pub said: String, + /// For whoever is debugging. Verbatim, and never shown as the headline. + /// + /// `None` where the plain sentence IS the whole truth — a refusal this deployment decided, with + /// no underlying output behind it. + pub detail: Option, +} + +impl Problem { + /// A refusal this deployment made itself, where there is nothing underneath to show. + pub fn plain(said: impl Into) -> Self { + Self { + said: said.into(), + detail: None, + } + } + + /// A sentence for the person, with the real output kept beside it. + pub fn with(said: impl Into, detail: impl Into) -> Self { + let detail = detail.into(); + Self { + said: said.into(), + detail: (!detail.trim().is_empty()).then_some(detail), + } + } +} + +/* + * Every error that is still a bare string becomes a plain problem. + * + * So `?` keeps working on the paths that have not been given a sentence yet, and those read exactly + * as they did before rather than losing their message during the conversion. What it does NOT do is + * let a raw engine dump masquerade as a sentence: `said_about` below is what turns one of those into + * both halves, and the call sites that produce engine output use it. + */ +impl From for Problem { + fn from(said: String) -> Self { + Self::plain(said) + } +} + +impl From<&str> for Problem { + fn from(said: &str) -> Self { + Self::plain(said) + } +} + +/** +A sentence for engine output, chosen by what the output actually says. + +Pure and tested, because these are the failures a first run hits and the sentence is the only part +the person reads. Anything unrecognised keeps a general sentence rather than a guess: being vague is +recoverable, and being confidently wrong about somebody's machine is not. +*/ +pub fn said_about(output: &str) -> String { + let lower = output.to_lowercase(); + + // The one this was written for. A pull that is refused reads as a permissions problem, and is + // almost always an image this release did not publish. + if lower.contains("pull access denied") + || lower.contains("repository does not exist") + || lower.contains("manifest unknown") + || lower.contains("not found: manifest") + { + return "OpenBot could not download one of the parts it needs. That version may not have \ + been published yet. Check for an OpenBot update, and try again." + .into(); + } + if lower.contains("port is already allocated") || lower.contains("address already in use") { + return "Something else on this computer is using a port OpenBot needs. Close it, or \ + restart the computer, and try again." + .into(); + } + if lower.contains("no space left") { + return "This computer has run out of disk space, so OpenBot could not finish. Free some \ + space and try again." + .into(); + } + if lower.contains("cannot connect") + || lower.contains("is the docker daemon running") + || lower.contains("connection refused") + { + return "OpenBot cannot reach the container engine. Start Docker or Podman, wait for it to \ + finish starting, and try again." + .into(); + } + if lower.contains("timeout") || lower.contains("timed out") { + return "That took too long and stopped. It is usually a slow or interrupted connection, \ + so trying again often works." + .into(); + } + if lower.contains("unauthorized") || lower.contains("permission denied") { + return "OpenBot was refused permission for something it needed. The details below say \ + what, and are worth sending to whoever set this up." + .into(); + } + + "Something went wrong while setting OpenBot up. The details below are worth sending to \ + whoever set this up." + .into() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The failure this whole file exists for. + #[test] + fn a_refused_pull_reads_as_a_missing_release_not_a_login_problem() { + let raw = + "Error response from daemon: pull access denied for openbot-agent-langgraph-agui, \ + repository does not exist or may require 'docker login'"; + let said = said_about(raw); + assert!(said.contains("could not download"), "{said}"); + // The person is never told to run `docker login`, which is not a thing they have. + assert!(!said.to_lowercase().contains("docker login"), "{said}"); + assert!(!said.contains("openbot-agent"), "{said}"); + } + + #[test] + fn a_taken_port_says_so_in_words_somebody_can_act_on() { + let said = said_about("Bind for 0.0.0.0:4202 failed: port is already allocated"); + assert!(said.contains("port OpenBot needs"), "{said}"); + } + + #[test] + fn an_engine_that_is_not_running_says_to_start_it() { + let said = + said_about("Cannot connect to the Docker daemon at unix:///var/run/docker.sock."); + assert!(said.contains("Start Docker or Podman"), "{said}"); + } + + /// Unrecognised output keeps a general sentence. Guessing at somebody's machine is worse than + /// admitting the detail is where the answer is. + #[test] + fn something_unrecognised_stays_general_rather_than_guessing() { + let said = said_about("frobnicator exploded (0x8007)"); + assert!(said.contains("Something went wrong"), "{said}"); + } + + /// Both halves, and the detail is never empty-but-present. + #[test] + fn a_problem_carries_both_registers() { + let both = Problem::with("Plain thing.", "raw output"); + assert_eq!(both.said, "Plain thing."); + assert_eq!(both.detail.as_deref(), Some("raw output")); + + let blank = Problem::with("Plain thing.", " "); + assert_eq!(blank.detail, None, "whitespace is not a detail"); + + let refusal = Problem::plain("We will not do that."); + assert_eq!(refusal.detail, None); + } + + /// A bare string still converts, so paths without a sentence yet read as they always did. + #[test] + fn a_bare_string_becomes_a_plain_problem() { + let problem: Problem = "bun was not found".to_string().into(); + assert_eq!(problem.said, "bun was not found"); + assert_eq!(problem.detail, None); + } +} diff --git a/desktop/src-tauri/src/stack.rs b/desktop/src-tauri/src/stack.rs index a6232490a..972ef7917 100644 --- a/desktop/src-tauri/src/stack.rs +++ b/desktop/src-tauri/src/stack.rs @@ -105,7 +105,7 @@ fn compose_command(engine: &Address, root: &Path) -> Command { /// `--no-build` is the point of the whole published-images job: a desktop install has no toolchain, /// and without it Compose quietly starts compiling Chromium. Failing loudly on a missing image is /// the better answer, because it names a pull that did not happen. -pub fn up(engine: &Address, root: &Path, harness: bool) -> Result<(), String> { +pub fn up(engine: &Address, root: &Path, harness: bool) -> Result<(), crate::problem::Problem> { /* * The picked harness rides in on its profile. * @@ -132,7 +132,13 @@ pub fn up(engine: &Address, root: &Path, harness: bool) -> Result<(), String> { if output.status.success() { return Ok(()); } - Err(command_said(&output.stderr)) + // Both registers: the sentence is chosen from what the engine said, and what it said is kept + // beside it rather than shown as the headline. See `problem.rs`. + let raw = command_said(&output.stderr); + Err(crate::problem::Problem::with( + crate::problem::said_about(&raw), + raw, + )) } /// Apply migrations, once, to completion. @@ -140,7 +146,7 @@ pub fn up(engine: &Address, root: &Path, harness: bool) -> Result<(), String> { /// A release step rather than a start step, for the reason `server/Dockerfile` gives: two replicas /// starting together would race, and a failed migration should stop the start rather than leave a /// half-migrated database serving. -pub fn migrate(engine: &Address, root: &Path) -> Result<(), String> { +pub fn migrate(engine: &Address, root: &Path) -> Result<(), crate::problem::Problem> { // No `--no-build` here: `compose run` does not take it, and passing it fails on the flag rather // than on anything to do with migrations. Building is prevented the other way, by // `IMAGE_PULL_POLICY=missing` in the environment, which makes the service pull instead. @@ -152,7 +158,13 @@ pub fn migrate(engine: &Address, root: &Path) -> Result<(), String> { if output.status.success() { return Ok(()); } - Err(command_said(&output.stderr)) + // Both registers: the sentence is chosen from what the engine said, and what it said is kept + // beside it rather than shown as the headline. See `problem.rs`. + let raw = command_said(&output.stderr); + Err(crate::problem::Problem::with( + crate::problem::said_about(&raw), + raw, + )) } /// The label the supervisor stamps on every container it creates. diff --git a/desktop/src/App.tsx b/desktop/src/App.tsx index 610ea8cbc..63433dffa 100644 --- a/desktop/src/App.tsx +++ b/desktop/src/App.tsx @@ -20,6 +20,17 @@ type Blocker = type Progress = { step: string; ok: boolean; detail: string }; +/** What a failed command returns: a sentence for the person, and the real output beside it. */ +type Problem = { said: string; detail?: string | null }; + +/** Anything thrown, as a problem. A bare string keeps working and reads as it always did. */ +function asProblem(thrown: unknown): Problem { + if (thrown && typeof thrown === "object" && "said" in thrown) { + return thrown as Problem; + } + return { said: String(thrown) }; +} + /** * One screen, four states: something is in the way, nothing is set up yet, it is working, it is * running. A wizard with more screens than states is a wizard that asks twice. @@ -53,7 +64,15 @@ export function App() { const [steps, setSteps] = useState([]); const [busy, setBusy] = useState(false); const [running, setRunning] = useState(false); - const [failure, setFailure] = useState(""); + /* + * A failure, in both registers. + * + * `said` is what a person reads and `detail` is the real output, kept behind a disclosure. One + * string could not serve both: the plain sentence alone throws away the evidence, and the raw + * engine output alone is how "pull access denied ... may require 'docker login'" ended up as the + * headline on a setup screen. See `problem.rs`. + */ + const [failure, setFailure] = useState(null); useEffect(() => { invoke("detect_engine") @@ -106,7 +125,7 @@ export function App() { // Why the stack stopped, if it did while this screen was not loaded. The supervisor gives up // and sends the window back here, and without this the person arrives at a setup screen with // no indication that anything happened. - invoke("last_failure") + invoke("last_failure") .then((found) => { if (found) setFailure(found); }) @@ -131,7 +150,7 @@ export function App() { async function start() { setBusy(true); - setFailure(""); + setFailure(null); setSteps([]); try { await invoke("prepare_engine"); @@ -153,9 +172,11 @@ export function App() { // // Said out loud when it does not happen. Swallowed, the window sits on the setup screen // looking like the start failed, while every step on it is ticked. - await invoke("show_openbot").catch((error) => setFailure(String(error))); + await invoke("show_openbot").catch((error) => + setFailure(asProblem(error)), + ); } catch (error) { - setFailure(String(error)); + setFailure(asProblem(error)); } finally { setBusy(false); invoke("detect_engine") @@ -170,7 +191,7 @@ export function App() { await invoke("stop_stack", { root }); setRunning(false); } catch (error) { - setFailure(String(error)); + setFailure(asProblem(error)); } finally { setBusy(false); } @@ -331,7 +352,15 @@ export function App() { {failure && (

That did not finish

-

{failure}

+

{failure.said}

+ {/* The real output, kept but not the headline. Whoever is debugging opens this; the + person reading the sentence above never has to. */} + {failure.detail && ( +
+ Technical details +
{failure.detail}
+
+ )}
)} diff --git a/desktop/src/styles.css b/desktop/src/styles.css index 3ec238006..a43eaff69 100644 --- a/desktop/src/styles.css +++ b/desktop/src/styles.css @@ -562,3 +562,30 @@ details > summary:hover { font-weight: 600; letter-spacing: -0.02em; } + +/* The developer half of a failure: present, and never the headline. */ +.detail-of { + border: 0; + padding: 0.6rem 0 0; + margin: 0; +} + +.detail-of > summary { + font-size: 0.78rem; +} + +.detail-of pre { + margin: 0.5rem 0 0; + padding: 0.6rem 0.7rem; + background: var(--sunk); + border-radius: 8px; + font-size: 0.72rem; + line-height: 1.45; + white-space: pre-wrap; + word-break: break-word; + max-height: 9rem; + overflow-y: auto; + color: var(--ink-soft); + /* Monospace here is for machine output, which is what it is for. */ + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; +} diff --git a/server/src/agents/runtime-agents.ts b/server/src/agents/runtime-agents.ts index 2e152ed32..9a2eb817f 100644 --- a/server/src/agents/runtime-agents.ts +++ b/server/src/agents/runtime-agents.ts @@ -23,7 +23,7 @@ export function createRuntimeAgentLoader( /** Resolves a customer agent's key at load time. Absent means no agent can carry one. */ vault?: { reader: CredentialSecretReader; encryptionKey: string }, /** Secret for the deployment-managed Bot. Never sent to customer-owned endpoints. */ - managedAgent?: { endpoint: URL; token: string }, + managedAgent?: { endpoint: URL; token: string; alsoRun?: URL }, ) { return async (actor: AgentActor): Promise => { const [active, tombstones] = await Promise.all([ @@ -47,15 +47,32 @@ export function createRuntimeAgentLoader( }); if (headers) agent.headers = headers; } - if ( - agent.type === "remote_ag_ui" && - managedAgent && - agent.endpoint === managedAgent.endpoint.toString() - ) { - agent.headers = { - ...agent.headers, - "x-openbot-agent-token": managedAgent.token, - }; + /* + * Every endpoint this deployment runs gets the token, not just the first one. + * + * Matching a single endpoint left the harness picked during setup without it: registered, + * addressable, routed to, and answering `401 unauthorised` to everything. Its container is + * this deployment's own, started on a port this deployment chose with this token in its + * environment, so it is the same relationship the Bot in the box has. + */ + if (agent.type === "remote_ag_ui" && managedAgent) { + /* + * Compared without a trailing slash, because `URL` adds one and a stored address does not + * have to. `new URL("http://127.0.0.1:4206").toString()` is `".../4206/"`, and the row for + * that Bot says `".../4206"`, so an exact match silently fails and the Bot answers 401. + * The endpoint with a path — the Bot in the box — matched only because a path suppresses + * the slash, which is why this went unnoticed until a second endpoint existed. + */ + const same = (url: string) => url.replace(/\/+$/, ""); + const ours = [managedAgent.endpoint, managedAgent.alsoRun] + .filter((url): url is URL => url !== undefined) + .some((url) => same(agent.endpoint) === same(url.toString())); + if (ours) { + agent.headers = { + ...agent.headers, + "x-openbot-agent-token": managedAgent.token, + }; + } } registered.set(agent.id, agent); } diff --git a/server/src/config.ts b/server/src/config.ts index 9d7fc0066..2a55517e6 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -115,8 +115,15 @@ export function configuredAuthProviders( export type ManagedAgentConfig = { endpoint: URL; - /** Secret sent only to the managed Bot endpoint. Never stored in an agent row. */ + /** Secret sent only to endpoints this deployment runs. Never stored in an agent row. */ token: string; + /** + * The harness picked during setup, when there is one. + * + * Also an endpoint this deployment runs: its container was started by this deployment, on a port + * it chose, holding this token. It gets the same header for the same reason. + */ + alsoRun?: URL; }; /** @@ -441,7 +448,17 @@ function managedAgentConfig( if (!endpoint || !token) { return undefined; } - return { endpoint, token }; + /* + * The harness somebody picked during setup is also an endpoint this deployment runs. + * + * It is a container this deployment started, on a port this deployment chose, holding the token + * this deployment generated — the same relationship the Bot in the box has. It was not getting + * the token because that was attached by matching one endpoint exactly, so the picked Bot was + * registered, addressable, routed to, and answered every call with 401. Only visible by asking it + * something in the window. + */ + const alsoRun = optionalHttpUrl(environment, "PICKED_HARNESS_URL"); + return { endpoint, token, ...(alsoRun ? { alsoRun } : {}) }; } function oauthClient( diff --git a/server/tests/mastra-roster.test.ts b/server/tests/mastra-roster.test.ts index f58fe3a0e..1d91d67f4 100644 --- a/server/tests/mastra-roster.test.ts +++ b/server/tests/mastra-roster.test.ts @@ -78,3 +78,34 @@ describe("duplicating a Mastra Bot", () => { }); }); }); + +describe("which endpoints get this deployment's token", () => { + test("the harness picked during setup gets it, not only the Bot in the box", async () => { + /* + * The must-not case, and it was live: the picked harness was registered, addressable and + * routed to, and answered `401 unauthorised` to everything, because the token was attached by + * matching one endpoint exactly. Its container is this deployment's own, so it is the same + * relationship the Bot in the box has. + */ + const managedAgent = { + endpoint: new URL("http://127.0.0.1:4201/ag-ui"), + token: "the-deployment-token", + alsoRun: new URL("http://127.0.0.1:4206"), + }; + // Trailing slashes are the trap: `URL` adds one, a stored address need not have one, and an + // exact match then fails silently and the Bot answers 401. + const same = (url: string) => url.replace(/\/+$/, ""); + const ourEndpoints = [managedAgent.endpoint, managedAgent.alsoRun] + .filter((url): url is URL => url !== undefined) + .map((url) => same(url.toString())); + + expect(ourEndpoints).toContain(same("http://127.0.0.1:4206")); + expect(ourEndpoints).toContain(same("http://127.0.0.1:4201/ag-ui")); + // And the stored row, which has no trailing slash, matches the URL that grew one. + expect(ourEndpoints).toContain(same("http://127.0.0.1:4206/")); + // Somebody else's address is still somebody else's. + expect(ourEndpoints).not.toContain( + same("https://someone-else.example/ag-ui"), + ); + }); +}); From 7a91f7beaf9ce5abd55666c10a2c8165753c5087 Mon Sep 17 00:00:00 2001 From: David McKay Date: Mon, 7 Sep 2026 13:41:59 -0700 Subject: [PATCH 22/46] Sign in to Intelligence from the window, not from a terminal The last screen asked for an "Intelligence project key", and the only way to produce one was `npx copilotkit login` followed by `copilotkit project select`. Two commands, a terminal and a package manager, for somebody whose whole relationship with this product is a window their IT department sent them. The audience rule calls that a defect, and it was the largest one left. The flow is the CLI's own, done here instead: a loopback callback on 127.0.0.1 and an ephemeral port, the browser sent to the hosted sign-in page, the token that comes back exchanged for a session, the session exchanged for a product credential, and the person's projects listed with it. Read out of the published CLI rather than invented. The endpoints, the parameter names and the order belong to whoever changes them, and guessing at somebody else's auth is how this breaks quietly six months from now. The state is checked before the token is used for anything. Anything on this machine can reach a loopback port, so without that a page in any tab could finish a sign-in nobody asked for. The project list is read tolerantly and that is deliberate: it is somebody else's API, the response has been a bare array and a wrapped object at different times, and a setup screen showing nothing because a wrapper changed is worse than one showing a list. --- desktop/src-tauri/Cargo.toml | 2 +- desktop/src-tauri/src/intelligence.rs | 385 ++++++++++++++++++++++++++ desktop/src-tauri/src/lib.rs | 1 + 3 files changed, 387 insertions(+), 1 deletion(-) create mode 100644 desktop/src-tauri/src/intelligence.rs diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 7e5e890b5..80e6131e7 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -23,7 +23,7 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" rand = "0.9" base64 = "0.22" -reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "blocking"] } +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "blocking", "json"] } flate2 = "1" tar = "0.4" tauri-plugin-single-instance = "2.0.0-rc.5" diff --git a/desktop/src-tauri/src/intelligence.rs b/desktop/src-tauri/src/intelligence.rs new file mode 100644 index 000000000..fa1dfb5c8 --- /dev/null +++ b/desktop/src-tauri/src/intelligence.rs @@ -0,0 +1,385 @@ +//! Signing in to CopilotKit Intelligence, so nobody is sent to a terminal for a key. +//! +//! THE LAST DEVELOPER-SHAPED ASK IN SETUP. Before this, the final screen wanted an "Intelligence +//! project key", and the only way to produce one was `npx copilotkit login` followed by +//! `copilotkit project select`. That is two commands, a terminal and a package manager, for +//! somebody whose entire relationship with this product is a window their IT department sent them. +//! The audience rule says any step that amounts to "go and get something and come back" is a +//! defect, and that was the largest one left. +//! +//! The flow is the CLI's own, done here instead: a loopback callback, an exchange, and a key this +//! deployment provisions for the project the person chose. Reading it out of the CLI rather than +//! inventing it is deliberate — the endpoints, the parameter names and the order all belong to +//! whoever changes them, and guessing at somebody else's auth is how this breaks silently later. + +use std::io::{BufRead, BufReader, Write}; +use std::net::{SocketAddr, TcpListener, TcpStream}; +use std::time::{Duration, Instant}; + +use serde::Deserialize; + +/// Where the sign-in page and the CLI API live. +const OPS_FRONTEND: &str = "https://dashboard.operations.copilotkit.ai"; +const OPS_API: &str = "https://api.operations.copilotkit.ai"; +/// Where projects and their keys live. +const PRODUCT_API: &str = "https://api.intelligence.copilotkit.ai"; + +/// How long somebody gets to finish signing in. +const PATIENCE: Duration = Duration::from_secs(600); + +/// A project somebody can put OpenBot in. +#[derive(Clone, Debug, serde::Serialize, Deserialize, PartialEq, Eq)] +pub struct Project { + pub id: String, + pub name: String, +} + +/** +The callback the browser is sent back to. + +`127.0.0.1` and an ephemeral port, which is what the CLI does: the port is whatever the operating +system had free, so nothing has to be reserved and two sign-ins cannot collide. Never `localhost`, +for the reason the rest of this tree does not use it either. +*/ +pub struct SigningInToIntelligence { + listener: TcpListener, + state: String, + port: u16, +} + +/// What the browser hands back, pulled out of the callback line. +/// +/// Pure so the parsing is testable without a browser: this is a `GET /callback?...` request line, +/// and the two things that matter are in its query. +pub fn callback_values(request_line: &str) -> Option<(String, String)> { + let path = request_line.split_whitespace().nth(1)?; + let query = path.split_once('?')?.1; + let mut state = None; + let mut token = None; + for pair in query.split('&') { + let (key, value) = pair.split_once('=')?; + let value = percent_decode(value); + match key { + "state" => state = Some(value), + "clerkToken" => token = Some(value), + _ => {} + } + } + Some((state?, token?)) +} + +/// Enough percent-decoding for a token and a state, neither of which contains anything exotic. +fn percent_decode(value: &str) -> String { + let bytes = value.replace('+', " "); + let bytes = bytes.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' && i + 2 < bytes.len() { + if let Ok(byte) = u8::from_str_radix(&value[i + 1..i + 3], 16) { + out.push(byte); + i += 3; + continue; + } + } + out.push(bytes[i]); + i += 1; + } + String::from_utf8_lossy(&out).into_owned() +} + +impl SigningInToIntelligence { + /// Open the callback and return the address a browser has to visit. + pub fn begin() -> Result<(Self, String), String> { + let listener = TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0))) + .map_err(|error| format!("A sign-in could not be started: {error}"))?; + let port = listener + .local_addr() + .map_err(|error| format!("A sign-in could not be started: {error}"))? + .port(); + // Random, and checked when the browser comes back: without it any page could complete + // somebody else's sign-in by hitting this port. + let state: String = { + use rand::Rng; + let mut rng = rand::rng(); + (0..32) + .map(|_| format!("{:x}", rng.random_range(0..16))) + .collect() + }; + let callback = format!("http://127.0.0.1:{port}/callback"); + let url = format!( + "{OPS_FRONTEND}/cli-auth?callback={}&state={state}", + urlencode(&callback) + ); + Ok(( + Self { + listener, + state, + port, + }, + url, + )) + } + + /// The port the callback is listening on, for anything that needs to say so. + pub fn port(&self) -> u16 { + self.port + } + + /// Wait for the browser, then turn what it brings into a project key. + pub fn finish(self) -> Result<(String, Vec), String> { + let token = self.wait_for_token()?; + let session = exchange(&token)?; + let product = product_credential(&session)?; + let projects = list_projects(&product)?; + Ok((product, projects)) + } + + fn wait_for_token(&self) -> Result { + self.listener + .set_nonblocking(true) + .map_err(|error| format!("The sign-in could not be watched: {error}"))?; + let began = Instant::now(); + while began.elapsed() < PATIENCE { + match self.listener.accept() { + Ok((stream, _)) => { + if let Some(token) = self.read_callback(stream)? { + return Ok(token); + } + } + Err(ref error) if error.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(200)); + } + Err(error) => return Err(format!("The sign-in could not be read: {error}")), + } + } + Err("That sign-in was not finished in time. Start it again.".into()) + } + + fn read_callback(&self, mut stream: TcpStream) -> Result, String> { + stream + .set_nonblocking(false) + .map_err(|error| format!("The sign-in could not be read: {error}"))?; + let mut line = String::new(); + BufReader::new( + stream + .try_clone() + .map_err(|error| format!("The sign-in could not be read: {error}"))?, + ) + .read_line(&mut line) + .map_err(|error| format!("The sign-in could not be read: {error}"))?; + + let Some((state, token)) = callback_values(&line) else { + reply(&mut stream, "Waiting for the sign-in to finish."); + return Ok(None); + }; + /* + * The state is checked before anything is done with the token. + * + * Anything on this machine can reach a loopback port, so without this a page in any tab + * could complete a sign-in that nobody asked for. + */ + if state != self.state { + reply(&mut stream, "That sign-in did not match. Start it again."); + return Err("That sign-in did not match the one this window started.".into()); + } + reply( + &mut stream, + "Signed in. You can close this tab and go back to OpenBot.", + ); + Ok(Some(token)) + } +} + +/// A small page, so the browser does not sit on a blank tab. +fn reply(stream: &mut TcpStream, said: &str) { + let body = format!( + "OpenBot\ + \ +

{said}

" + ); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + let _ = stream.write_all(response.as_bytes()); + let _ = stream.flush(); +} + +fn urlencode(value: &str) -> String { + value + .chars() + .map(|c| match c { + 'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' => c.to_string(), + other => format!("%{:02X}", other as u32), + }) + .collect() +} + +fn client() -> Result { + reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(30)) + .build() + .map_err(|error| format!("The sign-in could not reach CopilotKit: {error}")) +} + +#[derive(Deserialize)] +struct Session { + token: String, +} + +fn exchange(clerk_token: &str) -> Result { + let response = client()? + .post(format!("{OPS_API}/api/cli/auth/session")) + .json(&serde_json::json!({ "clerkToken": clerk_token })) + .send() + .map_err(|error| format!("The sign-in could not be completed: {error}"))?; + if !response.status().is_success() { + return Err("CopilotKit refused that sign-in. Try again.".into()); + } + let session: Session = response + .json() + .map_err(|error| format!("That sign-in returned something unexpected: {error}"))?; + Ok(session.token) +} + +#[derive(Deserialize)] +struct ProductCredential { + token: String, +} + +#[derive(Deserialize)] +struct ProductCredentialResponse { + #[serde(rename = "productCredential")] + product_credential: ProductCredential, +} + +fn product_credential(session: &str) -> Result { + let response = client()? + .post(format!("{OPS_API}/api/cli/auth/product-credential")) + .bearer_auth(session) + .send() + .map_err(|error| format!("The sign-in could not be completed: {error}"))?; + if !response.status().is_success() { + return Err("CopilotKit would not issue a credential for this account.".into()); + } + let payload: ProductCredentialResponse = response + .json() + .map_err(|error| format!("That sign-in returned something unexpected: {error}"))?; + Ok(payload.product_credential.token) +} + +fn list_projects(product: &str) -> Result, String> { + let response = client()? + .get(format!("{PRODUCT_API}/api/projects")) + .bearer_auth(product) + .send() + .map_err(|error| format!("Your projects could not be listed: {error}"))?; + if !response.status().is_success() { + return Err("Your CopilotKit projects could not be listed.".into()); + } + let raw: serde_json::Value = response + .json() + .map_err(|error| format!("That list came back unreadable: {error}"))?; + Ok(projects_in(&raw)) +} + +/** +The projects in whatever shape that endpoint answers with. + +Tolerant on purpose, and pure so it can be tested against real payloads: this is somebody else's +API, the response has been a bare array and an object with a `projects` key at different times, and +a setup screen that shows nothing because a wrapper changed is worse than one that shows a list. +*/ +pub fn projects_in(raw: &serde_json::Value) -> Vec { + let rows = raw + .get("projects") + .or_else(|| raw.get("data")) + .and_then(|value| value.as_array()) + .or_else(|| raw.as_array()); + let Some(rows) = rows else { + return Vec::new(); + }; + rows.iter() + .filter_map(|row| { + let id = row.get("id")?.as_str()?.to_string(); + let name = row + .get("name") + .and_then(|value| value.as_str()) + .unwrap_or(&id) + .to_string(); + Some(Project { id, name }) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_callback_gives_up_its_state_and_token() { + let line = "GET /callback?state=abc123&clerkToken=tok_xyz HTTP/1.1"; + assert_eq!( + callback_values(line), + Some(("abc123".into(), "tok_xyz".into())) + ); + } + + /// Percent-encoded values come back decoded, since a token may carry them. + #[test] + fn an_encoded_value_is_decoded() { + let line = "GET /callback?state=a%2Db&clerkToken=x%20y HTTP/1.1"; + assert_eq!(callback_values(line), Some(("a-b".into(), "x y".into()))); + } + + /// Anything that is not the callback is ignored rather than treated as a sign-in. + #[test] + fn a_request_that_is_not_the_callback_yields_nothing() { + assert_eq!(callback_values("GET /favicon.ico HTTP/1.1"), None); + assert_eq!(callback_values("GET /callback HTTP/1.1"), None); + assert_eq!(callback_values(""), None); + } + + /// Both shapes that endpoint has answered with, because a wrapper changing should not empty + /// the screen. + #[test] + fn projects_are_read_from_either_shape() { + let wrapped = serde_json::json!({"projects": [{"id": "p1", "name": "Ledgerline"}]}); + let bare = serde_json::json!([{"id": "p1", "name": "Ledgerline"}]); + let expected = vec![Project { + id: "p1".into(), + name: "Ledgerline".into(), + }]; + assert_eq!(projects_in(&wrapped), expected); + assert_eq!(projects_in(&bare), expected); + } + + /// A project with no name is listed under its id rather than dropped. + #[test] + fn a_nameless_project_is_still_offered() { + let raw = serde_json::json!([{"id": "p2"}]); + assert_eq!( + projects_in(&raw), + vec![Project { + id: "p2".into(), + name: "p2".into() + }] + ); + } + + #[test] + fn nothing_readable_is_an_empty_list_rather_than_a_crash() { + assert!(projects_in(&serde_json::json!({"unexpected": true})).is_empty()); + } + + /// The callback is opened on a loopback address, never a name. + #[test] + fn the_callback_is_loopback_and_the_url_carries_it() { + let (signing, url) = SigningInToIntelligence::begin().expect("it did not start"); + assert!(url.starts_with(OPS_FRONTEND), "{url}"); + assert!(url.contains("127.0.0.1"), "{url}"); + assert!(!url.contains("localhost"), "{url}"); + assert!(signing.port() > 0); + } +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 31bed63c4..8d1b153d4 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -4,6 +4,7 @@ pub mod deployment; pub mod engine; pub mod env; pub mod harness; +pub mod intelligence; pub mod plan; pub mod problem; pub mod provider; From 03b1dfecabc040d04fa61520f6851e8602f6f967 Mon Sep 17 00:00:00 2001 From: David McKay Date: Mon, 7 Sep 2026 13:55:23 -0700 Subject: [PATCH 23/46] Offer the key three ways, and sign in for the one that can Managed CopilotKit is the main path and now signs in from the window: the browser goes to the hosted page, the token comes back on a loopback callback, and OpenBot provisions a key for the project the person picks. They never see a terminal. Somebody running their own Intelligence has a key this sign-in knows nothing about, so the field stays and moves down beside the addresses it belongs with. Sign in on the main path, paste on the developer one, which is the same shape the model screen already has. The third case is a company running Intelligence on its own network with OpenBot on everybody's laptop, and it is written down rather than built because it is two things rather than one. Google, Microsoft and Okta already sign people in to OpenBot itself; Intelligence is reached with a key belonging to the deployment rather than to a person. So the employee's sign-in and the deployment's key are separate problems, and the install should end up asking an employee for neither. That needs settings arriving from an administrator rather than from the person, which does not exist yet, and the desktop install currently runs as a dev actor and never signs anybody in at all. --- desktop/src-tauri/src/intelligence.rs | 77 ++++++++++++++++ desktop/src-tauri/src/main.rs | 59 ++++++++++++ desktop/src/App.tsx | 123 +++++++++++++++++++++++--- 3 files changed, 245 insertions(+), 14 deletions(-) diff --git a/desktop/src-tauri/src/intelligence.rs b/desktop/src-tauri/src/intelligence.rs index fa1dfb5c8..f06a35a6d 100644 --- a/desktop/src-tauri/src/intelligence.rs +++ b/desktop/src-tauri/src/intelligence.rs @@ -284,6 +284,63 @@ fn list_projects(product: &str) -> Result, String> { Ok(projects_in(&raw)) } +/** +Ask for a key for the project somebody chose. + +`POST /api/keys` with `project_id` and a name, which is the CLI's own call. The name says where the +key came from, because a person looking at a list of keys months later deserves to know which one +their laptop is using. +*/ +pub fn provision_key(product: &str, project_id: &str) -> Result { + let response = client()? + .post(format!("{PRODUCT_API}/api/keys")) + .bearer_auth(product) + .json(&serde_json::json!({ + "project_id": project_id, + "name": "OpenBot Desktop", + })) + .send() + .map_err(|error| format!("A key could not be created: {error}"))?; + if !response.status().is_success() { + return Err("CopilotKit would not create a key for that project.".into()); + } + let raw: serde_json::Value = response + .json() + .map_err(|error| format!("That key came back unreadable: {error}"))?; + key_in(&raw).ok_or_else(|| "That key came back without a value in it.".to_string()) +} + +/** +The key itself, out of whatever the endpoint wrapped it in. + +Tolerant for the same reason the project list is, and pure so it is testable: this is somebody +else's response shape, and a setup that fails at the last step because a field moved is the worst +possible place to be strict. +*/ +pub fn key_in(raw: &serde_json::Value) -> Option { + for at in [ + raw.get("key"), + raw.get("apiKey"), + raw.get("data"), + Some(raw), + ] { + let Some(value) = at else { continue }; + if let Some(text) = value.as_str() { + if !text.trim().is_empty() { + return Some(text.to_string()); + } + } + for field in ["key", "apiKey", "value", "token", "secret"] { + if let Some(text) = value.get(field).and_then(|v| v.as_str()) { + if !text.trim().is_empty() { + return Some(text.to_string()); + } + } + } + } + None +} + /** The projects in whatever shape that endpoint answers with. @@ -368,6 +425,26 @@ mod tests { ); } + /// The key, wherever that response decided to put it. + #[test] + fn the_key_is_found_in_the_shapes_that_endpoint_uses() { + for raw in [ + serde_json::json!({"key": "cpk-abc"}), + serde_json::json!({"apiKey": "cpk-abc"}), + serde_json::json!({"key": {"value": "cpk-abc"}}), + serde_json::json!({"data": {"key": "cpk-abc"}}), + ] { + assert_eq!(key_in(&raw).as_deref(), Some("cpk-abc"), "{raw}"); + } + } + + /// A response with no key is a failure to report, not an empty string to write into `.env`. + #[test] + fn a_response_without_a_key_yields_nothing() { + assert_eq!(key_in(&serde_json::json!({"key": ""})), None); + assert_eq!(key_in(&serde_json::json!({"unexpected": true})), None); + } + #[test] fn nothing_readable_is_an_empty_list_rather_than_a_crash() { assert!(projects_in(&serde_json::json!({"unexpected": true})).is_empty()); diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index 6bfe6c3ec..22606d21c 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -36,6 +36,11 @@ struct Shell { /// moment it is worth reading. Held here instead, and asked for on load. last_failure: Mutex>, root: Mutex>, + /// An Intelligence sign-in waiting for its loopback callback. + signing_in_to_intelligence: + Mutex>, + /// The credential that sign-in produced, held so a project can be chosen with it. + intelligence_credential: Mutex>, /// A ChatGPT sign-in waiting for the browser redirect to complete it. /// /// Held for the same reason the Claude one is: a person leaves and comes back in the middle. @@ -673,6 +678,57 @@ async fn finish_chatgpt_sign_in(app: tauri::AppHandle) -> Result .map_err(|error| format!("The sign-in did not finish: {error}"))? } +/// Start signing in to Intelligence and return the address a browser has to open. +#[tauri::command] +async fn begin_intelligence_sign_in(app: tauri::AppHandle) -> Result { + let (signing, url) = openbot_desktop_lib::intelligence::SigningInToIntelligence::begin()?; + *app.state::() + .signing_in_to_intelligence + .lock() + .unwrap() = Some(signing); + let _ = tauri_plugin_opener::OpenerExt::opener(&app).open_url(&url, None::<&str>); + Ok(url) +} + +/// Wait for that sign-in, and answer with the projects it can see. +/// +/// The credential is kept on this side rather than handed to the window: the window's business is +/// which project, and a credential it never holds is one it cannot leak into a log or a screenshot. +#[tauri::command] +async fn finish_intelligence_sign_in( + app: tauri::AppHandle, +) -> Result, String> { + let signing = app + .state::() + .signing_in_to_intelligence + .lock() + .unwrap() + .take() + .ok_or_else(|| "That sign-in is no longer running. Start it again.".to_string())?; + let (credential, projects) = tauri::async_runtime::spawn_blocking(move || signing.finish()) + .await + .map_err(|error| format!("The sign-in did not finish: {error}"))??; + *app.state::().intelligence_credential.lock().unwrap() = Some(credential); + Ok(projects) +} + +/// Create a key for the project somebody chose, and hand it back for the field. +#[tauri::command] +async fn intelligence_key_for(app: tauri::AppHandle, project: String) -> Result { + let credential = app + .state::() + .intelligence_credential + .lock() + .unwrap() + .clone() + .ok_or_else(|| "Sign in to CopilotKit first.".to_string())?; + tauri::async_runtime::spawn_blocking(move || { + openbot_desktop_lib::intelligence::provision_key(&credential, &project) + }) + .await + .map_err(|error| format!("A key could not be created: {error}"))? +} + /// The model screen's rows. Independent of the picker above, and required to stay that way: no /// harness on that list is tied to a vendor's models, so choosing one may not narrow this. #[tauri::command] @@ -907,6 +963,9 @@ fn main() { finish_claude_sign_in, begin_chatgpt_sign_in, finish_chatgpt_sign_in, + begin_intelligence_sign_in, + finish_intelligence_sign_in, + intelligence_key_for, ]) // A packaged application is not a browser tab. Left alone, WebView2 answers a right-click // with Back, Refresh, Save as and Print: Back walks the window out of OpenBot with nothing diff --git a/desktop/src/App.tsx b/desktop/src/App.tsx index 63433dffa..edf41da1c 100644 --- a/desktop/src/App.tsx +++ b/desktop/src/App.tsx @@ -52,6 +52,49 @@ export function App() { const [model, setModel] = useState(null); /** Model credentials a previous run already wrote, so the provider screen arrives filled in. */ const [alreadyHeld, setAlreadyHeld] = useState>({}); + /* + * Signing in to CopilotKit, which is how a managed deployment gets its key. + * + * The key field stays, behind the self-hosted disclosure, because somebody running their own + * Intelligence has a key this sign-in knows nothing about. David's call: sign in on the main + * path, paste on the developer one, which is the same shape as the model screen. + */ + const [projects, setProjects] = useState< + { id: string; name: string }[] | null + >(null); + const [signingIn, setSigningIn] = useState(false); + + async function signInToCopilotKit() { + setSigningIn(true); + setFailure(null); + try { + await invoke("begin_intelligence_sign_in"); + setProjects( + await invoke<{ id: string; name: string }[]>( + "finish_intelligence_sign_in", + ), + ); + } catch (error) { + setFailure(asProblem(error)); + } finally { + setSigningIn(false); + } + } + + async function useProject(id: string) { + setSigningIn(true); + setFailure(null); + try { + // The key never passes through the window until it exists: it is created for the project + // chosen here and put straight into the field this screen already had. + setApiKey(await invoke("intelligence_key_for", { project: id })); + setProjects(null); + } catch (error) { + setFailure(asProblem(error)); + } finally { + setSigningIn(false); + } + } const [step, setStep] = useState<"welcome" | "harness" | "model" | "install">( "welcome", ); @@ -280,18 +323,58 @@ export function App() { {!running && ( <> -
- - setApiKey(event.target.value)} - placeholder="the key from your Intelligence project" - autoComplete="off" - spellCheck={false} - /> -
+ {/* + Sign in on the main path; paste behind the disclosure. + + This screen used to ask for a key whose only source was two terminal commands, which is + the one thing the audience rule forbids. Somebody on managed CopilotKit now signs in and + OpenBot creates the key for the project they pick. Somebody running their own + Intelligence has a key this sign-in knows nothing about, so the field moves down there + with the addresses it belongs with. + */} + {apiKey ? ( +

Connected to CopilotKit.

+ ) : projects ? ( + <> +

Which project should OpenBot use?

+
+ Project + {projects.map((project) => ( + + ))} +
+ {projects.length === 0 && ( +

+ That account has no projects yet. Make one at copilotkit.ai, + then sign in again. +

+ )} + + ) : ( + <> +

+ OpenBot keeps your conversations in CopilotKit. Sign in and it + sets the rest up for you. +

+ + + )}
Point at your own Intelligence server -

+

These default to CopilotKit's managed service. Change them only if - you run Intelligence yourself. + you run Intelligence yourself, and paste that server's key below.

+
+ + setApiKey(event.target.value)} + placeholder="the key from your own Intelligence" + autoComplete="off" + spellCheck={false} + /> +
Date: Mon, 7 Sep 2026 14:27:39 -0700 Subject: [PATCH 24/46] Give a signed-in ChatGPT plan the Codex model, and the store rather than a token A plan token is a bearer for chatgpt.com/backend-api/codex, and langchain-openai pins that address on purpose, so it cannot be reached by pointing OPENAI_BASE_URL at it with the token as a key. The harness now picks the Codex chat model when a plan is present, so the default Bot can actually answer on a subscription. Carrying the access token alone was wrong: it expires within the hour and nothing can renew it, which would give a Bot that works in the morning and fails after lunch with an auth error nobody could account for. The sign-in now hands back the vendor's whole store, the store is written beside the .env as an owner-only file, and compose bind-mounts it read-write so the renewals the provider makes outlast the container. The .env gets a path, never the credential. The file is written even when no plan was chosen, because a bind mount with no source does not fail, it silently creates a directory in its place. --- agent-langgraph-agui/src/main.py | 37 +++++++- desktop/src-tauri/src/env.rs | 140 +++++++++++++++++++++++++++++-- desktop/src-tauri/src/main.rs | 21 +++-- desktop/src-tauri/src/plan.rs | 64 ++++++++++---- desktop/src/App.tsx | 4 +- docker-compose.yml | 12 ++- 6 files changed, 241 insertions(+), 37 deletions(-) diff --git a/agent-langgraph-agui/src/main.py b/agent-langgraph-agui/src/main.py index c977eda0a..ee0b38a55 100644 --- a/agent-langgraph-agui/src/main.py +++ b/agent-langgraph-agui/src/main.py @@ -18,9 +18,42 @@ def _model(): - """`provider:model`, which is what `init_chat_model` reads, so the provider stays a choice.""" - provider = (os.environ.get("BOT_PROVIDER") or "openai").strip() + """The model this Bot thinks with, chosen by which credential the deployment gave it. + + A SIGNED-IN CHATGPT PLAN IS NOT AN API KEY, and this is the only place that difference shows up. + A plan token is a bearer for `chatgpt.com/backend-api/codex`, and `langchain-openai` pins that + address and refuses a caller-supplied one on purpose, so pointing `OPENAI_BASE_URL` at it and + passing the token as a key does not work and is not meant to. The Codex chat model is the + supported way in, and it is selected by the presence of the token rather than by another + setting, so nothing can say "plan" while holding a key. + + Everything else keeps `provider:model`, which is what `init_chat_model` reads, so the provider + stays the person's choice. + """ model = (os.environ.get("BOT_MODEL") or "gpt-4o-mini").strip() + store = (os.environ.get("CHATGPT_AUTH_FILE") or "").strip() + if store and os.path.exists(store): + from pathlib import Path + + # Private and experimental, both deliberately. `langchain-openai` exports no public Codex + # model and warns in the module that this one is unofficial. That is a maintenance cost we + # took knowingly rather than a reason to withhold the plan, because a subscription someone + # already pays for is the whole point of offering it on the model screen. + from langchain_openai.chat_models.codex import ( + _ChatOpenAICodex, + _FileChatGPTOAuthTokenProvider, + ) + + # THE STORE FILE, NOT A BARE TOKEN. An access token expires within the hour and cannot be + # renewed; the store holds the refresh token, and this provider renews from it. A Bot given + # only the access token works until lunchtime and then reports an auth error nobody can + # explain. + return _ChatOpenAICodex( + model=model, + token_provider=_FileChatGPTOAuthTokenProvider(path=Path(store)), + ) + + provider = (os.environ.get("BOT_PROVIDER") or "openai").strip() return init_chat_model(model if ":" in model else f"{provider}:{model}") diff --git a/desktop/src-tauri/src/env.rs b/desktop/src-tauri/src/env.rs index 43cd90571..8adb75298 100644 --- a/desktop/src-tauri/src/env.rs +++ b/desktop/src-tauri/src/env.rs @@ -24,6 +24,20 @@ use rand::RngCore; use crate::engine::EngineStatus; +/** +Where a signed-in ChatGPT plan's token store lives, on this machine and inside the harness. + +Two paths for one file, joined by a bind mount `docker-compose.yml` declares. It has to be a file +rather than a setting because the harness's provider WRITES to it: when the access token expires it +renews and saves, and the mount is what makes that renewal outlast the container. + +The host file is always written, even when nobody signed in to a plan. A bind mount whose source is +missing does not fail, it silently creates a DIRECTORY at that path, and the next real sign-in then +cannot write its file. Writing an empty store costs nothing and removes the trap. +*/ +pub const CHATGPT_STORE_FILE: &str = "chatgpt-auth.json"; +pub const CHATGPT_STORE_INSIDE: &str = "/root/.langchain/chatgpt-auth.json"; + /// Ports the stack publishes. Matched to `docker-compose.yml` defaults so a person who later runs /// Compose by hand finds the deployment where the documentation says it is. pub struct Ports { @@ -108,7 +122,7 @@ pub fn compose( "OPENAI_BASE_URL", "ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN", - "CHATGPT_OAUTH_TOKEN", + "CHATGPT_AUTH_FILE", ] { env.insert(key.into(), String::new()); } @@ -133,8 +147,17 @@ pub fn compose( ModelCredential::ClaudePlan { token } => { insert_if_given(&mut env, "CLAUDE_CODE_OAUTH_TOKEN", token); } - ModelCredential::ChatGptPlan { token } => { - insert_if_given(&mut env, "CHATGPT_OAUTH_TOKEN", token); + /* + * A path, not the credential. The store itself goes to a file beside this one, because the + * harness's provider does not merely read it: it writes the renewed tokens back. Through a + * bind mount that renewal lands on this machine and survives the container; carried as an + * environment variable it would be lost on every restart, and the refresh token it replaced + * would already have been spent. + */ + ModelCredential::ChatGptPlan { store } => { + if !store.trim().is_empty() { + env.insert("CHATGPT_AUTH_FILE".into(), CHATGPT_STORE_INSIDE.into()); + } } ModelCredential::Compatible { base_url, @@ -363,10 +386,15 @@ pub enum ModelCredential { the thing the library exists to prevent, and the Codex path also shapes its requests differently, so it would not have worked anyway. - The harness picks its model class from the presence of this token. See the harness note in the + THE WHOLE STORE, NOT THE ACCESS TOKEN. The token in it lasts under an hour and nothing can + renew it; the refresh token beside it is what keeps the Bot answering tomorrow. Carrying one + field would produce a Bot that works this morning and fails this afternoon with an auth error, + which is the hardest kind of fault for somebody to report. + + The harness picks its model class from the presence of this store. See the harness note in the build doc. */ - ChatGptPlan { token: String }, + ChatGptPlan { store: String }, /// Anything that speaks the OpenAI wire format, at an address the person gave. /// /// Also where a signed-in ChatGPT plan lands, because that login yields a token and the address @@ -417,6 +445,37 @@ pub fn already_set(path: &Path, keys: &[&str]) -> BTreeMap { found } +/** +Lay down the token store a signed-in ChatGPT plan reads from, beside the `.env`. + +Always written, and see `CHATGPT_STORE_FILE` for why: an absent source turns the mount into a +directory. Answering the model screen with anything else clears it, on the same reasoning as the +keys the writer empties. A plan that was signed out of should not leave a credential on disk for a +later run to pick up. + +Not called when the screen was not answered at all, which is the one case that must not disturb what +is already there. +*/ +pub fn write_plan_store(dir: &Path, credential: &ModelCredential) -> std::io::Result<()> { + let store = match credential { + ModelCredential::ChatGptPlan { store } if !store.trim().is_empty() => store.trim(), + _ => "{}", + }; + let path = dir.join(CHATGPT_STORE_FILE); + std::fs::write(&path, format!("{store}\n"))?; + /* + * Owner-only, because this IS the credential. `.env` beside it holds keys and gets whatever + * umask the machine has; this one is not left to that, since a refresh token is a standing + * grant rather than a value somebody can rotate from a dashboard they already have open. + */ + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?; + } + Ok(()) +} + pub fn write(path: &Path, owned: &BTreeMap) -> std::io::Result<()> { let existing = std::fs::read_to_string(path).unwrap_or_default(); let mut out = String::new(); @@ -894,7 +953,7 @@ mod model_tests { &intelligence(), &Model { credential: ModelCredential::ChatGptPlan { - token: "oauth-token".into(), + store: "{\"access_token\":\"a\",\"refresh_token\":\"r\"}".into(), }, }, &engine(), @@ -903,13 +962,76 @@ mod model_tests { None, ); assert_eq!( - env.get("CHATGPT_OAUTH_TOKEN"), - Some(&"oauth-token".to_string()) + env.get("CHATGPT_AUTH_FILE"), + Some(&CHATGPT_STORE_INSIDE.to_string()) ); assert_eq!(env.get("OPENAI_API_KEY"), Some(&String::new())); assert_eq!(env.get("OPENAI_BASE_URL"), Some(&String::new())); } + /// THE CREDENTIAL ITSELF NEVER REACHES THE `.env`, only the path of the file holding it. + /// + /// Worth asserting rather than assuming: the `.env` is the file a person is most likely to open + /// or paste, and a refresh token in it is a standing grant on somebody's ChatGPT subscription. + #[test] + fn the_plan_store_is_not_written_into_the_env() { + let secret = "refresh-token-that-must-not-appear"; + let env = compose( + &intelligence(), + &Model { + credential: ModelCredential::ChatGptPlan { + store: format!("{{\"refresh_token\":\"{secret}\"}}"), + }, + }, + &engine(), + &Ports::default(), + &pinned(), + None, + ); + assert!( + !env.values().any(|value| value.contains(secret)), + "the plan's store reached the .env" + ); + } + + /// The file is laid down even with no plan, because a missing mount source becomes a directory. + #[test] + fn the_store_file_is_written_whatever_the_choice() { + let dir = std::env::temp_dir().join(format!("openbot-store-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + + write_plan_store( + &dir, + &ModelCredential::OpenAi { + api_key: "sk-x".into(), + }, + ) + .unwrap(); + let path = dir.join(CHATGPT_STORE_FILE); + assert_eq!(std::fs::read_to_string(&path).unwrap().trim(), "{}"); + + write_plan_store( + &dir, + &ModelCredential::ChatGptPlan { + store: "{\"refresh_token\":\"r\"}".into(), + }, + ) + .unwrap(); + assert!(std::fs::read_to_string(&path).unwrap().contains("\"r\"")); + + // And signing out of the plan clears it, on the same reasoning as the keys that get emptied. + write_plan_store(&dir, &ModelCredential::None).unwrap(); + assert_eq!(std::fs::read_to_string(&path).unwrap().trim(), "{}"); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(&path).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o600, "the store was readable by others"); + } + std::fs::remove_dir_all(&dir).ok(); + } + /// Switching provider does not leave the last one's key behind. /// /// Measured, not imagined: a run that signed in to a Claude plan still carried the @@ -1012,7 +1134,7 @@ mod model_tests { "OPENAI_BASE_URL", "ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN", - "CHATGPT_OAUTH_TOKEN", + "CHATGPT_AUTH_FILE", ] { assert!( !env.contains_key(key), diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index 22606d21c..3370e9591 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -164,16 +164,16 @@ impl ChosenModel { Ok(openbot_env::ModelCredential::ClaudePlan { token }) } /* - * A signed-in ChatGPT plan is not a special case: the login yields a token and the - * address to send it to, which is exactly the compatible shape. It arrives here with - * `base_url` already filled in by the sign-in, not by a person. + * The sign-in hands back the vendor's whole token store, not one token, and it travels + * in the same field the Claude plan uses. See `ModelCredential::ChatGptPlan`: the + * refresh token in there is what keeps the Bot answering past the first hour. */ ("openai", "plan") => { - let token = given(self.token); - if token.is_empty() { + let store = given(self.token); + if store.is_empty() { return Err("That ChatGPT plan was not signed in to.".into()); } - Ok(openbot_env::ModelCredential::ChatGptPlan { token }) + Ok(openbot_env::ModelCredential::ChatGptPlan { store }) } ("openai-compatible", "endpoint") => { Ok(openbot_env::ModelCredential::Compatible { @@ -269,6 +269,9 @@ async fn start_stack( return Err(problem.into()); } + // Named rather than inlined: the store file below is written from the same answer, and reading + // the model screen twice could not be relied on to give the same one. + let credential = model.into_credential()?; let settings = openbot_env::compose( &openbot_env::Intelligence { api_url, @@ -276,7 +279,7 @@ async fn start_stack( api_key, }, &openbot_env::Model { - credential: model.into_credential()?, + credential: credential.clone(), }, &status, &openbot_env::Ports::default(), @@ -285,6 +288,10 @@ async fn start_stack( ); openbot_env::write(&root.join(".env"), &settings) .map_err(|e| format!("could not write .env: {e}"))?; + // Beside the `.env` and before the containers, because compose mounts it. See + // `write_plan_store`: an absent file becomes a directory the sign-in can never write into. + openbot_env::write_plan_store(&root, &credential) + .map_err(|e| format!("could not write the sign-in file: {e}"))?; report(&app, "env", true, ".env written"); // Said before rather than after. On a machine that has never run OpenBot this pulls five diff --git a/desktop/src-tauri/src/plan.rs b/desktop/src-tauri/src/plan.rs index 0203850b8..beb9aad77 100644 --- a/desktop/src-tauri/src/plan.rs +++ b/desktop/src-tauri/src/plan.rs @@ -460,6 +460,11 @@ redirect URI as a string, and `http://localhost:1455/auth/callback` is what is r `127.0.0.1` — the same address, a different string — makes the authorize request fail with `unknown_error` before any login page is drawn. Measured, twice, before the cause was obvious. +AND WHAT IS PRINTED IS THE WHOLE STORE, NOT THE ACCESS TOKEN. The access token expires within the +hour and nothing can renew it; the store carries the refresh token beside it, which is what the +harness's provider renews from. Carrying only the token yields a Bot that answers until lunchtime +and then reports an auth failure nobody can account for. + Passed as an argument rather than a mounted file, so the app never has to write a script to disk to run one. */ @@ -505,7 +510,9 @@ from langchain_openai.chatgpt_oauth import login_chatgpt login_chatgpt(open_browser=False, port=LOOPBACK, timeout=900) raw = json.loads(Path(STORE).read_text()) -print("OPENBOT_CHATGPT_TOKEN=" + (raw.get("access_token") or raw.get("token") or ""), flush=True) +if not (raw.get("access_token") or raw.get("token")): + raise SystemExit("the sign-in finished but left no token behind") +print("OPENBOT_CHATGPT_STORE=" + json.dumps(raw, separators=(",", ":")), flush=True) "#; /** @@ -579,15 +586,16 @@ impl SigningInToChatGpt { Ok((signing, url)) } - /// Wait for the browser redirect to complete the login, and return the token. + /// Wait for the browser redirect to complete the login, and return the token store. /// /// Nothing is sent: the callback is what finishes this, so all there is to do is wait for the - /// program to say what it got. + /// program to say what it got. What comes back is the vendor's whole store, refresh token + /// included, because an access token on its own stops working within the hour. pub fn finish(mut self) -> Result { - match self.wait_for(chatgpt_token_in, PATIENCE_FOR_THE_PERSON) { - Some(token) => { + match self.wait_for(chatgpt_store_in, PATIENCE_FOR_THE_PERSON) { + Some(store) => { self.stop(); - Ok(token) + Ok(store) } None => { self.stop(); @@ -635,12 +643,14 @@ fn drain(stream: &mut R, into: std::sync::Arc> /// /// Its own line rather than scraped out of the store file, because the store shape belongs to the /// library and the line is this deployment's own contract with the program above. -pub fn chatgpt_token_in(output: &str) -> Option { +pub fn chatgpt_store_in(output: &str) -> Option { plain(output) .lines() - .filter_map(|line| line.trim().strip_prefix("OPENBOT_CHATGPT_TOKEN=")) + .filter_map(|line| line.trim().strip_prefix("OPENBOT_CHATGPT_STORE=")) .map(str::trim) - .find(|token| !token.is_empty()) + // A store is an object. Anything else is a half-read line, and writing it to the file the + // harness reads would turn a sign-in that looked fine into a Bot that cannot start. + .find(|store| store.starts_with('{') && store.ends_with('}') && store.len() > 2) .map(str::to_string) } @@ -758,14 +768,36 @@ mod tests { assert_eq!(token_in(&format!("{PLAN_TOKEN_PREFIX}01-abc")), None); } - /// The token line is this deployment's contract with the program it hands the image. + /// The store line is this deployment's contract with the program it hands the image. + #[test] + fn the_chatgpt_store_is_read_off_its_own_line() { + let output = "some chatter\nOPENBOT_CHATGPT_STORE={\"access_token\":\"a\",\"refresh_token\":\"r\"}\nmore\n"; + assert_eq!( + chatgpt_store_in(output).as_deref(), + Some("{\"access_token\":\"a\",\"refresh_token\":\"r\"}") + ); + assert_eq!(chatgpt_store_in("OPENBOT_CHATGPT_STORE=\n"), None); + assert_eq!(chatgpt_store_in("nothing here"), None); + } + + /// A truncated store is worse than none: it would be written to the file the harness reads. #[test] - fn the_chatgpt_token_is_read_off_its_own_line() { - let output = "some chatter\nOPENBOT_CHATGPT_TOKEN=abc123\nmore chatter\n"; - assert_eq!(chatgpt_token_in(output).as_deref(), Some("abc123")); - // An empty value is not a token: the store had no access token in it. - assert_eq!(chatgpt_token_in("OPENBOT_CHATGPT_TOKEN=\n"), None); - assert_eq!(chatgpt_token_in("nothing here"), None); + fn a_half_read_store_line_is_refused() { + assert_eq!(chatgpt_store_in("OPENBOT_CHATGPT_STORE={\"access_to"), None); + assert_eq!(chatgpt_store_in("OPENBOT_CHATGPT_STORE={}"), None); + } + + /// The refresh token is the point of carrying a store, so the program must print all of it. + #[test] + fn the_login_program_prints_the_whole_store() { + assert!( + CHATGPT_LOGIN.contains("json.dumps(raw"), + "the login must print the store, not one field of it" + ); + assert!( + !CHATGPT_LOGIN.contains("OPENBOT_CHATGPT_TOKEN"), + "an access token alone expires within the hour and cannot be renewed" + ); } /// The URL the vendor's login prints as its fallback. diff --git a/desktop/src/App.tsx b/desktop/src/App.tsx index edf41da1c..ce33e423f 100644 --- a/desktop/src/App.tsx +++ b/desktop/src/App.tsx @@ -81,7 +81,7 @@ export function App() { } } - async function useProject(id: string) { + async function pickProject(id: string) { setSigningIn(true); setFailure(null); try { @@ -345,7 +345,7 @@ export function App() { key={project.id} className="tile" disabled={signingIn} - onClick={() => useProject(project.id)} + onClick={() => pickProject(project.id)} > {project.name} diff --git a/docker-compose.yml b/docker-compose.yml index 0e59bec12..e781f8148 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -313,8 +313,18 @@ services: OPENAI_BASE_URL: ${OPENAI_BASE_URL:-} ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} CLAUDE_CODE_OAUTH_TOKEN: ${CLAUDE_CODE_OAUTH_TOKEN:-} - CHATGPT_OAUTH_TOKEN: ${CHATGPT_OAUTH_TOKEN:-} + # A path into the mount below, set only when a ChatGPT plan was signed in to. The file is + # there either way; this variable is what tells the harness to read it. + CHATGPT_AUTH_FILE: ${CHATGPT_AUTH_FILE:-} BOT_MODEL: ${BOT_MODEL:-gpt-5.5} + volumes: + # A bind mount, not a named volume, and read-write on purpose. The signed-in plan's access + # token expires within the hour and the harness renews it, so the renewal has to land back on + # this machine or every restart would replay a refresh token that had already been spent. + # + # The app writes this file before `compose up` for the reason bind mounts make necessary: a + # missing source is not an error, it silently becomes a directory. + - ./chatgpt-auth.json:/root/.langchain/chatgpt-auth.json # The same Bot behavior on a framework, exposed as another AG-UI endpoint and registry row. agent-langgraph: From 63eb6989ee17548a6761630a1525e0fa8b9b8ea5 Mon Sep 17 00:00:00 2001 From: David McKay Date: Mon, 7 Sep 2026 14:31:28 -0700 Subject: [PATCH 25/46] Empty the plan token this app has stopped writing A machine that ran the version before this one has a ChatGPT plan token sitting in its .env that nothing reads any more. The writer preserves lines it does not own, so it would stay there indefinitely. Clearing it costs one line and is the same reasoning the other model keys are emptied for. --- desktop/src-tauri/src/env.rs | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/desktop/src-tauri/src/env.rs b/desktop/src-tauri/src/env.rs index 8adb75298..06b30c23a 100644 --- a/desktop/src-tauri/src/env.rs +++ b/desktop/src-tauri/src/env.rs @@ -123,6 +123,13 @@ pub fn compose( "ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN", "CHATGPT_AUTH_FILE", + /* + * Retired, and cleared for exactly that reason. An earlier version put the ChatGPT + * plan's access token here; nothing reads it now, and `write` preserves what it does + * not own, so without this line a machine that ran that version would keep somebody's + * plan token in a file forever with nothing ever using it again. + */ + "CHATGPT_OAUTH_TOKEN", ] { env.insert(key.into(), String::new()); } @@ -994,6 +1001,24 @@ mod model_tests { ); } + /// A key this app has stopped using is emptied, not left holding a credential forever. + #[test] + fn the_retired_plan_token_is_cleared() { + let env = compose( + &intelligence(), + &Model { + credential: ModelCredential::OpenAi { + api_key: "sk-x".into(), + }, + }, + &engine(), + &Ports::default(), + &pinned(), + None, + ); + assert_eq!(env.get("CHATGPT_OAUTH_TOKEN"), Some(&String::new())); + } + /// The file is laid down even with no plan, because a missing mount source becomes a directory. #[test] fn the_store_file_is_written_whatever_the_choice() { From 4825ccb098a0215821c94c1cffa0dcd2ff766556 Mon Sep 17 00:00:00 2001 From: David McKay Date: Mon, 7 Sep 2026 14:38:19 -0700 Subject: [PATCH 26/46] End the install with a question the Bot has to answer Every step before this proves that something started, which is not the same as proving the choices work. A refused key, a lapsed plan or a model the account cannot use all give a stack that comes up clean and a Bot that cannot answer, and handing over at that point means somebody finds out later, inside the product, with no idea which of their answers caused it. So the wizard now ends on a question with one checkable answer, and the handover waits for it. This screen also owns the worst message in the product. Measured against a deliberately invalid key: the stream opens, says RUN_STARTED, says STEP_STARTED and then simply stops, with no error event at all, because the framework caught its own exception and logged it. The whole 401 lives in the container's log and nowhere else. So a run that produces no text is a failure here rather than an empty answer, the sentence shown is OpenBot's own and names the choice to change, and the harness's log is fetched to fill the developer half, since otherwise there would be no developer half to show. Two live tests are kept and ignored by default. The fixtures are transcriptions of a real stream, and a vendor changing the events they emit should break something. --- desktop/src-tauri/src/ask.rs | 334 +++++++++++++++++++++++++++++++++ desktop/src-tauri/src/lib.rs | 1 + desktop/src-tauri/src/main.rs | 78 ++++++++ desktop/src-tauri/src/stack.rs | 23 +++ desktop/src/App.tsx | 97 +++++++--- desktop/src/Ask.tsx | 115 ++++++++++++ desktop/src/styles.css | 28 +++ 7 files changed, 652 insertions(+), 24 deletions(-) create mode 100644 desktop/src-tauri/src/ask.rs create mode 100644 desktop/src/Ask.tsx diff --git a/desktop/src-tauri/src/ask.rs b/desktop/src-tauri/src/ask.rs new file mode 100644 index 000000000..72b850e4c --- /dev/null +++ b/desktop/src-tauri/src/ask.rs @@ -0,0 +1,334 @@ +/*! +The last screen: a real question, put to the Bot that was just set up. + +WHY THE INSTALL DOES NOT END AT "SAVED". Everything before this proves that things started, which +is not the same as proving the configuration works. A wrong key, an expired plan, a model name the +provider does not serve: all of them produce a stack that comes up clean and a Bot that cannot +answer, and the person finds out later, in the product, with no idea which of the choices they made +was the wrong one. So the wizard ends by asking a question and showing the answer. A configuration +that has not answered is not a finished install. + +AND THIS SCREEN OWNS THE WORST MESSAGE IN THE PRODUCT. Measured on a deliberately invalid key: the +stream opens, says `RUN_STARTED`, says `STEP_STARTED`, and then simply stops. No error event, no +reason, nothing to show. The whole of `OpenAIAuthenticationError: Error code: 401` went to the +container's log, which is where the framework's own handler put it. Passing that experience through +would leave somebody staring at a screen that stopped, so a run that produces no text is treated as +a failure here, the sentence is OpenBot's own, and the container's log is fetched to fill the +developer half, because otherwise there is no developer half at all. +*/ + +use std::time::Duration; + +use crate::problem::Problem; + +/// The header the server puts its token in, and the one every harness checks. One spelling, here, +/// because a second copy of it is a 401 nobody can explain. +pub const AGENT_TOKEN_HEADER: &str = "x-openbot-agent-token"; + +/// Long enough for a cold model, short enough that a hung run is not mistaken for a slow one. +const PATIENCE: Duration = Duration::from_secs(90); + +/// What the screen asks when the person has not typed anything of their own. +/// +/// A question with one checkable answer, on purpose. "Tell me about yourself" is answered +/// convincingly by a Bot whose tools are all broken, and the point of this screen is proof. +pub const SUGGESTED: &str = "What is 17 times 23?"; + +/** +Put a question to the harness and return what it said. + +Straight to the harness rather than through the server, because what this screen proves is the +credential and the Bot behind it. The server's own path is proved by the step before it, which +writes the Bot's row; adding a session and a login to this would test the parts that are already +green and hide the part that is not. +*/ +pub fn ask(endpoint: &str, token: &str, question: &str) -> Result { + let client = reqwest::blocking::Client::builder() + .timeout(PATIENCE) + .build() + .map_err(|error| { + Problem::plain(format!("This machine cannot make web requests: {error}")) + })?; + + let body = serde_json::json!({ + // New every time. The harness keeps a thread in memory, and reusing an id would ask the + // question into a conversation that already has an answer in it. + "threadId": format!("openbot-setup-{}", moment()), + "runId": format!("openbot-run-{}", moment()), + "state": {}, + "messages": [{ "id": "m1", "role": "user", "content": question }], + "tools": [], + "context": [], + "forwardedProps": {}, + }); + + let response = client + .post(endpoint) + .header("content-type", "application/json") + .header(AGENT_TOKEN_HEADER, token) + .json(&body) + .send() + .map_err(|error| { + Problem::with( + "OpenBot could not reach the Bot it just set up.", + error.to_string(), + ) + })?; + + let status = response.status(); + let text = response.text().unwrap_or_default(); + if !status.is_success() { + // 401 here is this deployment's own token, not the person's model credential, and saying + // "check your API key" would send them to fix the wrong thing. + return Err(Problem::with( + if status == reqwest::StatusCode::UNAUTHORIZED { + "The Bot refused OpenBot's own request. Stop OpenBot and start it again." + } else { + "The Bot could not answer." + }, + format!("HTTP {status}\n{text}"), + )); + } + + match answer_in(&text) { + Some(answer) => Ok(answer), + // Deliberately not a sentence here. The caller has the deployment and can fetch the log + // that holds the actual cause; see `why_nothing_came_back`. + None => Err(Problem::plain(String::new())), + } +} + +/** +The answer, out of an AG-UI stream. + +`TEXT_MESSAGE_CONTENT` carries the text a person sees, one delta per event, and everything else on +the wire is either the framework's own trace or protocol bookkeeping. Measured against a live run +rather than read off the spec: the same stream also carries the whole answer inside `RAW` events, +and collecting those instead would double every reply. +*/ +pub fn answer_in(body: &str) -> Option { + let mut answer = String::new(); + for line in body.lines() { + let Some(data) = line.trim().strip_prefix("data:") else { + continue; + }; + let Ok(event) = serde_json::from_str::(data.trim()) else { + continue; + }; + if event.get("type").and_then(|t| t.as_str()) != Some("TEXT_MESSAGE_CONTENT") { + continue; + } + if let Some(delta) = event.get("delta").and_then(|d| d.as_str()) { + answer.push_str(delta); + } + } + let answer = answer.trim().to_string(); + (!answer.is_empty()).then_some(answer) +} + +/** +Why a run produced no text, said plainly, with the log kept behind it. + +The cause is only ever in the harness's log, so this takes the log rather than the stream. The +sentences name the choice the person made that is wrong, because that is the only thing they can +act on: a key they pasted, a plan that has lapsed, a model name nobody serves. +*/ +pub fn why_nothing_came_back(log: &str) -> Problem { + let lower = log.to_lowercase(); + + // The likeliest failure in the whole product, and the reason this screen exists. + if lower.contains("authenticationerror") + || lower.contains("incorrect api key") + || lower.contains("invalid_api_key") + || lower.contains("401") + { + return Problem::with( + "That key was refused. Go back and connect the model again, either by signing in or with a different key.", + log, + ); + } + if lower.contains("insufficient_quota") || lower.contains("exceeded your current quota") { + return Problem::with( + "That account has no credit left with the model provider, so the Bot cannot answer yet.", + log, + ); + } + if lower.contains("rate limit") || lower.contains("429") { + return Problem::with( + "The model provider is asking OpenBot to slow down. Wait a minute and ask again.", + log, + ); + } + // A model name is only ever wrong on the compatible row, which is the one that asks for one. + if lower.contains("model_not_found") + || lower.contains("does not exist or you do not have access") + || lower.contains("unknown model") + { + return Problem::with( + "That account cannot use the model that was chosen. Go back and choose another one.", + log, + ); + } + if lower.contains("connection") || lower.contains("timed out") || lower.contains("timeout") { + return Problem::with( + "The Bot could not reach the model provider. Check this machine's internet connection and ask again.", + log, + ); + } + Problem::with( + "The Bot started answering and then stopped. Its own record of what happened is below.", + log, + ) +} + +/// Enough to tell two runs apart, which is all an id on a throwaway thread has to do. +fn moment() -> u128 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|since| since.as_nanos()) + .unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Captured from a live run, trimmed. The shape is the contract this screen depends on. + const REAL_STREAM: &str = concat!( + "data: {\"type\":\"RUN_STARTED\",\"threadId\":\"t1\",\"runId\":\"r1\"}\n\n", + "data: {\"type\":\"RAW\",\"event\":{\"data\":{\"chunk\":{\"content\":\"391\"}}}}\n\n", + "data: {\"type\":\"TEXT_MESSAGE_START\",\"messageId\":\"a\",\"role\":\"assistant\"}\n\n", + "data: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"a\",\"delta\":\"3\"}\n\n", + "data: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"a\",\"delta\":\"91\"}\n\n", + "data: {\"type\":\"TEXT_MESSAGE_END\",\"messageId\":\"a\"}\n\n", + ); + + #[test] + fn the_answer_is_the_deltas_joined() { + assert_eq!(answer_in(REAL_STREAM).as_deref(), Some("391")); + } + + /// The same answer rides inside `RAW` as well. Counting those would say "391391". + #[test] + fn the_framework_trace_is_not_counted_as_the_answer() { + assert_eq!(answer_in(REAL_STREAM).as_deref(), Some("391")); + assert!( + REAL_STREAM.contains("\"type\":\"RAW\""), + "the fixture must carry the trap" + ); + } + + /// Measured: a rejected key ends the stream after STEP_STARTED and says nothing at all. + #[test] + fn a_stream_that_stops_is_not_an_empty_answer() { + let stopped = concat!( + "data: {\"type\":\"RUN_STARTED\",\"threadId\":\"t1\",\"runId\":\"r1\"}\n\n", + "data: {\"type\":\"STEP_STARTED\",\"stepName\":\"answer\"}\n\n", + ); + assert_eq!(answer_in(stopped), None); + } + + #[test] + fn junk_on_the_wire_does_not_stop_the_answer_being_read() { + let messy = concat!( + "data: not json at all\n", + ": a comment\n", + "\n", + "data: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"delta\":\"fine\"}\n", + ); + assert_eq!(answer_in(messy).as_deref(), Some("fine")); + } + + /// The sentence names the choice to change, and the log is kept rather than shown as the point. + #[test] + fn a_refused_key_is_named_as_a_key_and_not_as_a_stack_trace() { + let log = "langchain_openai.chat_models.base.OpenAIAuthenticationError: Error code: 401"; + let problem = why_nothing_came_back(log); + assert!(problem.said.contains("refused"), "{}", problem.said); + assert!( + !problem.said.contains("401"), + "the sentence leaked the trace" + ); + assert_eq!(problem.detail.as_deref(), Some(log)); + } + + /// Each cause the person can act on gets its own sentence, and no two are the same. + #[test] + fn the_causes_worth_telling_apart_are_told_apart() { + let said = |log: &str| why_nothing_came_back(log).said; + let quota = said("Error code: 429 - insufficient_quota"); + let model = said("The model `gpt-9` does not exist or you do not have access to it"); + let offline = said("Connection error while reaching the provider"); + let unknown = said("Traceback (most recent call last): RuntimeError: something else"); + let all = ["a, &model, &offline, &unknown]; + for (i, one) in all.iter().enumerate() { + for other in all.iter().skip(i + 1) { + assert_ne!(one, other, "two causes share a sentence"); + } + } + } + + /// Every sentence is for the person: no jargon, and always something to do next. + #[test] + fn no_sentence_asks_anybody_to_read_a_log() { + for log in [ + "401 unauthorized", + "insufficient_quota", + "rate limit", + "model_not_found", + "connection refused", + "something nobody has seen", + ] { + let said = why_nothing_came_back(log).said; + let lower = said.to_lowercase(); + for forbidden in ["stack trace", "see the logs", "server logs", "traceback"] { + assert!(!lower.contains(forbidden), "{said}"); + } + assert!(said.ends_with('.'), "{said}"); + } + } + + /** + The whole path against a running harness, which is the only thing that proves the wire format. + + Ignored, because it needs a harness and a real model credential and neither belongs in CI. Run + it against one by hand: + + ```text + OPENBOT_ASK_ENDPOINT=http://127.0.0.1:4288/ OPENBOT_ASK_TOKEN=... \ + cargo test --lib live_harness -- --ignored --nocapture + ``` + + Kept rather than deleted after it passed: the fixtures above are transcriptions, and a vendor + who changes the events they emit breaks this and nothing else. + */ + #[test] + #[ignore = "needs a running harness and a real model credential"] + fn live_harness_answers_the_suggested_question() { + let endpoint = std::env::var("OPENBOT_ASK_ENDPOINT").expect("OPENBOT_ASK_ENDPOINT"); + let token = std::env::var("OPENBOT_ASK_TOKEN").expect("OPENBOT_ASK_TOKEN"); + let answer = ask(&endpoint, &token, SUGGESTED).expect("the harness did not answer"); + println!("the Bot said: {answer}"); + assert!( + answer.contains("391"), + "answered {answer:?}, which is not 17 x 23" + ); + } + + /// The same path with a credential the provider refuses, which is the failure this screen owns. + #[test] + #[ignore = "needs a running harness holding a deliberately invalid credential"] + fn live_harness_that_cannot_answer_produces_a_sentence_not_a_silence() { + let endpoint = std::env::var("OPENBOT_ASK_BAD_ENDPOINT").expect("OPENBOT_ASK_BAD_ENDPOINT"); + let token = std::env::var("OPENBOT_ASK_TOKEN").expect("OPENBOT_ASK_TOKEN"); + let problem = ask(&endpoint, &token, SUGGESTED).expect_err("it answered on a bad key"); + // An empty sentence is this module saying the reason is in the log, not in the stream. + assert!(problem.said.is_empty(), "got {problem:?}"); + } + + /// The default question has one right answer, which is the only reason it proves anything. + #[test] + fn the_suggested_question_is_checkable() { + assert!(SUGGESTED.contains("17") && SUGGESTED.contains("23")); + } +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 8d1b153d4..667babb88 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -1,5 +1,6 @@ //! The parts of the shell that are worth testing without a window around them. pub mod acquire; +pub mod ask; pub mod deployment; pub mod engine; pub mod env; diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index 3370e9591..8e8ac2738 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -556,6 +556,83 @@ fn default_root() -> String { stack::default_root().to_string_lossy().into_owned() } +/** +Put the wizard's last question to the Bot, and hand back what it said. + +THE DEFINITION OF DONE FOR AN INSTALL. Everything before this proves that things started; only this +proves the configuration works. See `ask` for why a run that says nothing is a failure rather than +an empty answer, and why the harness's log is fetched to fill the developer half. + +The endpoint and the token come out of the `.env` this run just wrote, not from the window. They are +facts about the deployment, and a window carrying them would be a second copy to keep in step. +*/ +#[tauri::command] +async fn ask_the_bot( + root: String, + question: String, +) -> Result { + let root = PathBuf::from(root); + let settings = openbot_env::already_set( + &root.join(".env"), + &[ + "PICKED_HARNESS_URL", + "MANAGED_AGENT_AG_UI_URL", + "MANAGED_AGENT_TOKEN", + ], + ); + // The picked harness if there is one, and the Bot that ships with OpenBot if there is not. + // Both speak AG-UI at the same address shape, so this screen does not care which it got. + let endpoint = settings + .get("PICKED_HARNESS_URL") + .filter(|url| !url.trim().is_empty()) + .or_else(|| settings.get("MANAGED_AGENT_AG_UI_URL")) + .cloned() + .unwrap_or_default(); + let token = settings + .get("MANAGED_AGENT_TOKEN") + .cloned() + .unwrap_or_default(); + if endpoint.trim().is_empty() || token.trim().is_empty() { + return Err(openbot_desktop_lib::problem::Problem::plain( + "OpenBot cannot find the Bot it just set up. Stop OpenBot and start it again.", + )); + } + + let question = if question.trim().is_empty() { + openbot_desktop_lib::ask::SUGGESTED.to_string() + } else { + question + }; + + let asked = tauri::async_runtime::spawn_blocking(move || { + match openbot_desktop_lib::ask::ask(&endpoint, &token, &question) { + Ok(answer) => Ok(answer), + // The empty sentence is `ask` saying it has no reason to give, which is the case the + // log exists for. Anything else already carries both halves. + Err(problem) if problem.said.is_empty() => Err(None), + Err(problem) => Err(Some(problem)), + } + }) + .await + .map_err(|error| { + openbot_desktop_lib::problem::Problem::plain(format!( + "The question could not be asked: {error}" + )) + })?; + + match asked { + Ok(answer) => Ok(answer), + Err(Some(problem)) => Err(problem), + Err(None) => { + let log = engine::detect() + .address + .map(|found| stack::service_log(&found, &root, "agent-harness", 40)) + .unwrap_or_default(); + Err(openbot_desktop_lib::ask::why_nothing_came_back(&log)) + } + } +} + /** What a previous run already wrote, so the wizard can arrive filled in. @@ -973,6 +1050,7 @@ fn main() { begin_intelligence_sign_in, finish_intelligence_sign_in, intelligence_key_for, + ask_the_bot, ]) // A packaged application is not a browser tab. Left alone, WebView2 answers a right-click // with Back, Refresh, Save as and Print: Back walks the window out of OpenBot with nothing diff --git a/desktop/src-tauri/src/stack.rs b/desktop/src-tauri/src/stack.rs index 972ef7917..8f4d89317 100644 --- a/desktop/src-tauri/src/stack.rs +++ b/desktop/src-tauri/src/stack.rs @@ -345,6 +345,29 @@ pub fn stop_processes_under(_root: &Path) -> usize { 0 } +/** +The tail of one service's log. + +For the case where the wire says nothing. A framework that catches its own exception and ends the +stream leaves the cause here and nowhere else, so this is not a debugging convenience: without it +the developer half of that failure would be empty. See `ask::why_nothing_came_back`. + +An engine that cannot be asked returns nothing rather than failing. This is only ever called to +explain a failure that has already happened, and a second failure on top of it helps nobody. +*/ +pub fn service_log(engine: &Address, root: &Path, service: &str, lines: u16) -> String { + compose_command(engine, root) + .args(["logs", "--tail", &lines.to_string(), service]) + .output() + .ok() + .map(|out| { + let mut text = String::from_utf8_lossy(&out.stdout).into_owned(); + text.push_str(&String::from_utf8_lossy(&out.stderr)); + text.trim().to_string() + }) + .unwrap_or_default() +} + /// Which Compose services are not running, and the last thing each said. /// /// `compose up` succeeds once it has asked for everything; a service that then exits is not its diff --git a/desktop/src/App.tsx b/desktop/src/App.tsx index ce33e423f..a5c674363 100644 --- a/desktop/src/App.tsx +++ b/desktop/src/App.tsx @@ -3,6 +3,7 @@ import { listen } from "@tauri-apps/api/event"; import { useEffect, useState } from "react"; import { DEFAULT_HARNESS, HarnessPicker } from "./HarnessPicker"; import { type ModelChoice, ProviderPicker } from "./ProviderPicker"; +import { Ask } from "./Ask"; import { Welcome } from "./Welcome"; type EngineStatus = { @@ -24,6 +25,39 @@ type Progress = { step: string; ok: boolean; detail: string }; type Problem = { said: string; detail?: string | null }; /** Anything thrown, as a problem. A bare string keeps working and reads as it always did. */ +/** + * What the last screen offers to ask, mirroring `ask::SUGGESTED`. + * + * Two copies of one sentence, and a test in `ask.rs` pins what it has to contain. The window needs + * it before it calls anything, and the Rust side needs it for the case where somebody clears the + * field, so neither can be the only one that has it. + */ +const SUGGESTED_QUESTION = "What is 17 times 23?"; + +/** + * A failure, in both registers, wherever one happens. + * + * One implementation because there is one rule: the sentence is the headline and the real output + * lives behind a disclosure. A second copy is how one screen ends up showing an engine dump as its + * title. + */ +function Failure({ problem }: { problem: Problem }) { + return ( +
+

That did not finish

+

{problem.said}

+ {/* The real output, kept but not the headline. Whoever is debugging opens this; the person + reading the sentence above never has to. */} + {problem.detail && ( +
+ Technical details +
{problem.detail}
+
+ )} +
+ ); +} + function asProblem(thrown: unknown): Problem { if (thrown && typeof thrown === "object" && "said" in thrown) { return thrown as Problem; @@ -95,9 +129,9 @@ export function App() { setSigningIn(false); } } - const [step, setStep] = useState<"welcome" | "harness" | "model" | "install">( - "welcome", - ); + const [step, setStep] = useState< + "welcome" | "harness" | "model" | "install" | "ask" + >("welcome"); const [apiUrl, setApiUrl] = useState( "https://api.intelligence.copilotkit.ai", ); @@ -211,13 +245,16 @@ export function App() { harness, }); setRunning(true); - // The window becomes OpenBot. Nobody double-clicked this to look at a status screen. - // - // Said out loud when it does not happen. Swallowed, the window sits on the setup screen - // looking like the start failed, while every step on it is ticked. - await invoke("show_openbot").catch((error) => - setFailure(asProblem(error)), - ); + /* + * One screen short of the handover, on purpose. + * + * The window used to become OpenBot here, the moment the stack was up. But up is not the + * same as working: a refused key or a lapsed plan gives a stack that starts clean and a Bot + * that cannot answer, and handing over at this point means somebody discovers that inside + * the product with no idea which of their answers caused it. So the last screen asks a + * question, and the handover waits for an answer to come back. + */ + setStep("ask"); } catch (error) { setFailure(asProblem(error)); } finally { @@ -281,6 +318,31 @@ export function App() { ); } + /* + * Shown while the stack is running, which every other screen is skipped for. This is the one + * screen that needs a running stack: it is the proof, and there is nothing to ask before there + * is something to ask. + */ + if (step === "ask") { + return ( +
+ + invoke("ask_the_bot", { root, question }) + } + onOpen={() => { + invoke("show_openbot").catch((error) => + setFailure(asProblem(error)), + ); + }} + onBack={() => setStep("model")} + /> + {failure && } +
+ ); + } + if (!running && step === "model") { return (
@@ -444,20 +506,7 @@ export function App() {
)} - {failure && ( -
-

That did not finish

-

{failure.said}

- {/* The real output, kept but not the headline. Whoever is debugging opens this; the - person reading the sentence above never has to. */} - {failure.detail && ( -
- Technical details -
{failure.detail}
-
- )} -
- )} + {failure && }
{running ? ( diff --git a/desktop/src/Ask.tsx b/desktop/src/Ask.tsx new file mode 100644 index 000000000..dc1bb83c5 --- /dev/null +++ b/desktop/src/Ask.tsx @@ -0,0 +1,115 @@ +import { useState } from "react"; + +/** + * The last screen: a question, an answer, and only then the handover. + * + * The install does not end at "saved". Every step before this proves that something started, which + * is not the same as proving the choices work: a refused key, a lapsed plan or a model the account + * cannot use all produce a stack that comes up clean and a Bot that cannot answer. Somebody would + * find that out later, inside the product, with no idea which answer was the wrong one. So the + * wizard ends by asking, and the answer on this screen is the proof. + * + * One suggested question, already filled in, with one right answer. "Tell me about yourself" is + * answered convincingly by a Bot whose model credential is fine and whose everything else is + * broken, and this screen exists to prove rather than to reassure. + */ +export function Ask({ + suggestion, + onAsk, + onOpen, + onBack, +}: { + suggestion: string; + onAsk: (question: string) => Promise; + onOpen: () => void; + onBack: () => void; +}) { + const [question, setQuestion] = useState(suggestion); + const [answer, setAnswer] = useState(null); + const [asking, setAsking] = useState(false); + const [failed, setFailed] = useState(false); + + async function ask() { + setAsking(true); + setFailed(false); + setAnswer(null); + try { + setAnswer(await onAsk(question)); + } catch { + // The sentence is shown by the screen around this one, which already renders a problem in + // both registers. Recording only that it failed keeps one failure in one place. + setFailed(true); + } finally { + setAsking(false); + } + } + + return ( +
+

Last step

+

Ask it something.

+

+ Your Bot is set up. This proves it can answer before you start using it. +

+ +
+ + setQuestion(event.target.value)} + disabled={asking} + onKeyDown={(event) => { + if (event.key === "Enter" && !asking) { + ask(); + } + }} + /> +
+ + {answer !== null && ( +
+

Your Bot said

+

{answer}

+
+ )} + +
+ {answer === null ? ( + + ) : ( + + )} + {/* + * Only after something went wrong, and it is the only way back to the answer that caused + * it. A model screen offered before the failure would be a way to change a choice that was + * working, which is how somebody breaks a finished install. + */} + {failed && ( + + )} + {answer !== null && ( + + )} +
+ +

+ Nothing here leaves this computer except the question, which goes to the + AI provider you connected. +

+
+ ); +} diff --git a/desktop/src/styles.css b/desktop/src/styles.css index a43eaff69..13053cdc6 100644 --- a/desktop/src/styles.css +++ b/desktop/src/styles.css @@ -589,3 +589,31 @@ details > summary:hover { /* Monospace here is for machine output, which is what it is for. */ font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } + +/* + * The answer on the last screen. Given weight on purpose: it is the one piece of text in the whole + * wizard that was produced rather than written, and it is the proof the install actually worked. + */ +.answer { + margin: 20px 0 4px; + padding: 16px 18px; + border-radius: 14px; + background: var(--raised); + border: 1px solid var(--hair); +} + +.answer-from { + margin: 0 0 6px; + font-size: 12px; + font-weight: 600; + letter-spacing: 0.02em; + color: var(--ink-faint); +} + +.answer-text { + margin: 0; + font-size: 17px; + line-height: 1.45; + color: var(--ink); + white-space: pre-wrap; +} From 30796966dad5acec2d678819f88de2d8b9405759 Mon Sep 17 00:00:00 2001 From: David McKay Date: Mon, 7 Sep 2026 14:44:46 -0700 Subject: [PATCH 27/46] Keep credentials in the machine's own store, not in the .env The .env is a settings file, and a settings file is something somebody opens, reads out to support or pastes into a chat. A model key, a plan token and the tokens these services prove themselves to each other with are not settings. They now go to the credential store each platform actually has: the login Keychain on macOS through security, DPAPI on Windows through ProtectedData encrypting to the signed-in user, and an owner-only file on Linux, which is said out loud rather than dressed up, because no desktop Linux install can be assumed to run a Secret Service daemon and refusing to save a credential because gnome-keyring is missing would fail more people than it protects. The value never goes on a command line on any of them. ps is readable by every process the person runs, so the Keychain and DPAPI paths both write over stdin. From the store the credentials travel to the containers and the host processes as environment. Compose resolves an interpolation from its own environment before it reads the .env, so a secret reaches exactly the services that declare it and is written down nowhere. Verified against a real deployment: a .env with no token, the token in the environment, and the container holding it. The writer also purges what it moved. Without that, every machine that ran an earlier version would keep its old plaintext copy exactly where it was and the change would have bought nothing for anybody who already had OpenBot. --- desktop/src-tauri/src/env.rs | 18 +- desktop/src-tauri/src/lib.rs | 1 + desktop/src-tauri/src/main.rs | 34 ++- desktop/src-tauri/src/stack.rs | 51 +++- desktop/src-tauri/src/vault.rs | 448 +++++++++++++++++++++++++++++++++ 5 files changed, 528 insertions(+), 24 deletions(-) create mode 100644 desktop/src-tauri/src/vault.rs diff --git a/desktop/src-tauri/src/env.rs b/desktop/src-tauri/src/env.rs index 06b30c23a..dfcbdd8a5 100644 --- a/desktop/src-tauri/src/env.rs +++ b/desktop/src-tauri/src/env.rs @@ -483,13 +483,21 @@ pub fn write_plan_store(dir: &Path, credential: &ModelCredential) -> std::io::Re Ok(()) } -pub fn write(path: &Path, owned: &BTreeMap) -> std::io::Result<()> { +pub fn write( + path: &Path, + owned: &BTreeMap, + // Keys to take out and not put back. This is how a credential leaves the file on a machine that + // ran a version which wrote it there: the settings move to the store, and without this the old + // copy would sit in the file forever, since `write` otherwise keeps every line it does not own. + purge: &BTreeMap, +) -> std::io::Result<()> { let existing = std::fs::read_to_string(path).unwrap_or_default(); let mut out = String::new(); for line in existing.lines() { let key = line.split('=').next().unwrap_or("").trim(); - if key.is_empty() || line.trim_start().starts_with('#') || !owned.contains_key(key) { + let ours = owned.contains_key(key) || purge.contains_key(key); + if key.is_empty() || line.trim_start().starts_with('#') || !ours { out.push_str(line); out.push('\n'); } @@ -730,7 +738,7 @@ mod tests { &pinned(), None, ); - write(&path, &env).unwrap(); + write(&path, &env, &BTreeMap::new()).unwrap(); let written = std::fs::read_to_string(&path).unwrap(); assert!( @@ -756,7 +764,7 @@ mod tests { &pinned(), None, ); - write(&path, &first).unwrap(); + write(&path, &first, &BTreeMap::new()).unwrap(); let second = compose( &intelligence(), &Model::default(), @@ -765,7 +773,7 @@ mod tests { &pinned(), None, ); - write(&path, &second).unwrap(); + write(&path, &second, &BTreeMap::new()).unwrap(); let written = std::fs::read_to_string(&path).unwrap(); assert_eq!( diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 667babb88..923922b77 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -12,4 +12,5 @@ pub mod provider; pub mod quiet; pub mod stack; pub mod supervise; +pub mod vault; pub mod windows; diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index 8e8ac2738..a34fc119f 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -286,13 +286,24 @@ async fn start_stack( &deployment::image_variables(&root)?, picked.as_ref(), ); - openbot_env::write(&root.join(".env"), &settings) + /* + * The credentials come out here and never reach the file. + * + * `.env` is a settings file, and a settings file is something somebody can open, read out to + * support or paste into a chat. A model key, a plan token and the tokens these services prove + * themselves to each other with are not settings. They go to this machine's own credential + * store, and travel from there to the processes that need them as environment, which is where + * a secret can live without being written down. See `vault` for what each platform gets. + */ + let (settings, secrets) = openbot_desktop_lib::vault::split(settings); + openbot_env::write(&root.join(".env"), &settings, &secrets) .map_err(|e| format!("could not write .env: {e}"))?; + openbot_desktop_lib::vault::remember_all(&secrets)?; // Beside the `.env` and before the containers, because compose mounts it. See // `write_plan_store`: an absent file becomes a directory the sign-in can never write into. openbot_env::write_plan_store(&root, &credential) .map_err(|e| format!("could not write the sign-in file: {e}"))?; - report(&app, "env", true, ".env written"); + report(&app, "env", true, "settings written, credentials stored"); // Said before rather than after. On a machine that has never run OpenBot this pulls five // images, and a person watching a button that says "Working" has no way to tell a download @@ -320,11 +331,11 @@ async fn start_stack( } // The harness is a service only when one was picked; see `stack::up`. - stack::up(&found, &root, picked.is_some())?; + stack::up(&found, &root, picked.is_some(), &secrets)?; report(&app, "services", true, "containers up"); report(&app, "migrate", true, "applying migrations"); - stack::migrate(&found, &root)?; + stack::migrate(&found, &root, &secrets)?; report(&app, "migrate", true, "migrations applied"); // `compose up` succeeds once it has asked for everything. A service that then exits is not its @@ -361,7 +372,7 @@ async fn start_stack( let mut started = Vec::new(); for process in stack::HOST_PROCESSES.iter() { - let child = stack::spawn_host_process(process, &root, &logs, &bun) + let child = stack::spawn_host_process(process, &root, &logs, &bun, &secrets) .map_err(|e| format!("could not start {}: {e}", process.name))?; started.push((process.name, child)); report(&app, process.name, true, "started"); @@ -394,7 +405,7 @@ async fn start_stack( .generation .fetch_add(1, std::sync::atomic::Ordering::SeqCst) + 1; - supervise_host_processes(app.clone(), root, logs, bun, generation); + supervise_host_processes(app.clone(), root, logs, bun, secrets, generation); outcome.inspect_err(|error| report(&app, "answering", false, error.clone()))?; report(&app, "answering", true, "the API and the app are answering"); @@ -572,7 +583,9 @@ async fn ask_the_bot( question: String, ) -> Result { let root = PathBuf::from(root); - let settings = openbot_env::already_set( + // The addresses come from the file and the token from the credential store, which is where + // this run put it. Asked for together, because one without the other cannot ask anything. + let settings = openbot_desktop_lib::vault::already_given( &root.join(".env"), &[ "PICKED_HARNESS_URL", @@ -642,7 +655,7 @@ anywhere, and only the settings the wizard asks about are read. */ #[tauri::command] fn already_configured(root: String) -> std::collections::BTreeMap { - openbot_env::already_set( + openbot_desktop_lib::vault::already_given( &PathBuf::from(root).join(".env"), &[ "INTELLIGENCE_API_KEY", @@ -849,6 +862,9 @@ fn supervise_host_processes( root: PathBuf, logs: PathBuf, bun: PathBuf, + // Carried rather than fetched again on each restart. A restart happens when something is + // already wrong, and a credential prompt at that moment is the worst time to ask for one. + secrets: stack::Secrets, generation: u64, ) { std::thread::spawn(move || { @@ -944,7 +960,7 @@ fn supervise_host_processes( else { continue; }; - match stack::spawn_host_process(process, &root, &logs, &bun) { + match stack::spawn_host_process(process, &root, &logs, &bun, &secrets) { Ok(child) => { let mut children = shell.children.lock().unwrap(); children.retain(|(held, _)| *held != name); diff --git a/desktop/src-tauri/src/stack.rs b/desktop/src-tauri/src/stack.rs index 8f4d89317..830869a30 100644 --- a/desktop/src-tauri/src/stack.rs +++ b/desktop/src-tauri/src/stack.rs @@ -94,9 +94,22 @@ pub struct StackStatus { pub detail: String, } -fn compose_command(engine: &Address, root: &Path) -> Command { +/** +The credentials a deployment needs, handed to a child process rather than left in its `.env`. + +THIS IS WHY THE FILE CAN STOP HOLDING THEM. Compose resolves `${VAR}` from its own environment +before it reads `.env`, so a secret passed here reaches exactly the containers that declare it and +is written down nowhere. The host processes take theirs the same way, alongside the `--env-file` +that still carries the settings. + +A `BTreeMap` rather than the vault directly: reading a credential store once per run and passing +what it gave is one prompt and one failure point, where reading it per command is neither. +*/ +pub type Secrets = std::collections::BTreeMap; + +fn compose_command(engine: &Address, root: &Path, secrets: &Secrets) -> Command { let mut command = engine.command(); - command.current_dir(root).args(["compose"]); + command.current_dir(root).args(["compose"]).envs(secrets); command } @@ -105,7 +118,12 @@ fn compose_command(engine: &Address, root: &Path) -> Command { /// `--no-build` is the point of the whole published-images job: a desktop install has no toolchain, /// and without it Compose quietly starts compiling Chromium. Failing loudly on a missing image is /// the better answer, because it names a pull that did not happen. -pub fn up(engine: &Address, root: &Path, harness: bool) -> Result<(), crate::problem::Problem> { +pub fn up( + engine: &Address, + root: &Path, + harness: bool, + secrets: &Secrets, +) -> Result<(), crate::problem::Problem> { /* * The picked harness rides in on its profile. * @@ -114,7 +132,7 @@ pub fn up(engine: &Address, root: &Path, harness: bool) -> Result<(), crate::pro * fails the whole `up` rather than the one service nobody asked for. The flag comes before * `up`, because `--profile` is an option of `compose` itself and not of the subcommand. */ - let mut command = compose_command(engine, root); + let mut command = compose_command(engine, root, secrets); if harness { command.args(["--profile", "harness"]); } @@ -146,11 +164,15 @@ pub fn up(engine: &Address, root: &Path, harness: bool) -> Result<(), crate::pro /// A release step rather than a start step, for the reason `server/Dockerfile` gives: two replicas /// starting together would race, and a failed migration should stop the start rather than leave a /// half-migrated database serving. -pub fn migrate(engine: &Address, root: &Path) -> Result<(), crate::problem::Problem> { +pub fn migrate( + engine: &Address, + root: &Path, + secrets: &Secrets, +) -> Result<(), crate::problem::Problem> { // No `--no-build` here: `compose run` does not take it, and passing it fails on the flag rather // than on anything to do with migrations. Building is prevented the other way, by // `IMAGE_PULL_POLICY=missing` in the environment, which makes the service pull instead. - let output = compose_command(engine, root) + let output = compose_command(engine, root, secrets) .args(["run", "--rm", "migrate"]) .output() .map_err(|error| format!("could not run migrations: {error}"))?; @@ -218,7 +240,7 @@ pub fn down(engine: &Address, root: &Path) -> Result<(), String> { // is happening. stop_computers(engine)?; - let output = compose_command(engine, root) + let output = compose_command(engine, root, &Secrets::new()) .args(["down"]) .output() .map_err(|error| format!("could not stop the stack: {error}"))?; @@ -270,6 +292,7 @@ pub fn spawn_host_process( root: &Path, logs: &Path, bun: &Path, + secrets: &Secrets, ) -> std::io::Result { std::fs::create_dir_all(logs)?; let out = std::fs::File::create(logs.join(format!("{}.log", process.name)))?; @@ -277,6 +300,14 @@ pub fn spawn_host_process( let mut command = command(bun); command.current_dir(root.join(process.cwd)); + /* + * The credentials, alongside the `--env-file` that carries the settings. + * + * They are not in that file any more, and this is where they rejoin. The environment wins over + * the file either way, so a machine still holding an older run's copy is overridden rather than + * fought with. + */ + command.envs(secrets); if process.script.is_empty() { command.args(["run", process.package_script]); } else { @@ -356,7 +387,7 @@ An engine that cannot be asked returns nothing rather than failing. This is only explain a failure that has already happened, and a second failure on top of it helps nobody. */ pub fn service_log(engine: &Address, root: &Path, service: &str, lines: u16) -> String { - compose_command(engine, root) + compose_command(engine, root, &Secrets::new()) .args(["logs", "--tail", &lines.to_string(), service]) .output() .ok() @@ -374,7 +405,7 @@ pub fn service_log(engine: &Address, root: &Path, service: &str, lines: u16) -> /// problem. Both Bots exit immediately without a model key, saying exactly that, and without this /// the window reports a healthy stack while nothing can answer a question. pub fn services_that_exited(engine: &Address, root: &Path) -> Vec<(String, String)> { - let Ok(output) = compose_command(engine, root) + let Ok(output) = compose_command(engine, root, &Secrets::new()) .args(["ps", "-a", "--format", "{{.Service}}\t{{.State}}"]) .output() else { @@ -393,7 +424,7 @@ pub fn services_that_exited(engine: &Address, root: &Path) -> Vec<(String, Strin if service.trim() == "migrate" { continue; } - let why = compose_command(engine, root) + let why = compose_command(engine, root, &Secrets::new()) .args(["logs", "--tail", "3", service.trim()]) .output() .ok() diff --git a/desktop/src-tauri/src/vault.rs b/desktop/src-tauri/src/vault.rs new file mode 100644 index 000000000..c4dc6a5fe --- /dev/null +++ b/desktop/src-tauri/src/vault.rs @@ -0,0 +1,448 @@ +/*! +Where a secret lives, which is not the `.env`. + +WHY NOT THE FILE. Everything OpenBot needs to run is settings, and settings belong in a file +somebody can read. A model key, a plan token and the generated tokens the services authenticate to +each other with are not settings: they are credentials, and a credential in a dotfile is one +`cat`, one screen-share or one support ticket away from being somewhere else. This machine has a +place for them already, so they go there and the file keeps the settings. + +WHAT EACH PLATFORM ACTUALLY GETS. + +- **macOS: the login Keychain**, through `security`, which every Mac has. One generic-password item + per setting, so a person can see and revoke them one at a time in Keychain Access. +- **Windows: DPAPI**, through PowerShell's `ProtectedData`, encrypting to the signed-in user so the + ciphertext is useless to any other account on the machine, and to anybody who copies the file off + it. +- **Linux: an owner-only file**, and said out loud rather than pretended otherwise. There is no + keystore a desktop Linux install can be assumed to have: Secret Service needs a session daemon + that a headless or minimal machine does not run, and failing to save a credential because + `gnome-keyring` is absent would be a worse product than a 0600 file. + +THE VALUE NEVER GOES ON A COMMAND LINE. `ps` is readable by every process the person runs, so both +the Keychain and DPAPI paths write over stdin. `security` documents `-w` as insecure for exactly +this reason and prompts when it is given last, and a prompt reads a pipe. +*/ + +use std::collections::BTreeMap; +// Only the two platforms that hand a value to another program need to write to a pipe, and only +// the two that keep a file need a path to keep it at. +#[cfg(any(target_os = "macos", target_os = "windows"))] +use std::io::Write; +#[cfg(not(target_os = "macos"))] +use std::path::PathBuf; + +use crate::problem::Problem; + +/// What the Keychain and the fallback file file these under. +const SERVICE: &str = "OpenBot"; + +/** +Whether a setting is a credential. + +By name, and the list is the point. A classifier that guessed from the value would be wrong in both +directions: `INTELLIGENCE_API_URL` looks like nothing and `POSTGRES_PORT` looks like nothing, while +a generated token looks exactly like a random string of settings. Anything not named here is a +setting and goes in the file where somebody can read it. +*/ +pub fn is_secret(key: &str) -> bool { + matches!( + key, + // Somebody's own credentials, pasted or signed in for. + "INTELLIGENCE_API_KEY" + | "OPENAI_API_KEY" + | "ANTHROPIC_API_KEY" + | "CLAUDE_CODE_OAUTH_TOKEN" + // Retired, and still swept up: a machine that ran an older version has one of these. + | "CHATGPT_OAUTH_TOKEN" + // Generated here, and no less a credential for it. These are what the services prove + // themselves to each other with, and what a Bot's computer is driven with. + | "MANAGED_AGENT_TOKEN" + | "AGENT_TOOL_TOKEN" + | "COMPUTER_TOKEN" + | "SUPERVISOR_TOKEN" + | "WORKER_SHARED_SECRET" + | "KEY_ENCRYPTION_KEY" + ) +} + +/// Split what a run produced into what the file may hold and what it may not. +pub fn split( + all: BTreeMap, +) -> (BTreeMap, BTreeMap) { + let mut settings = BTreeMap::new(); + let mut secrets = BTreeMap::new(); + for (key, value) in all { + if is_secret(&key) { + secrets.insert(key, value); + } else { + settings.insert(key, value); + } + } + (settings, secrets) +} + +/** +Put every secret away, and take each one out of the file it used to be written to. + +Both halves matter. Storing without clearing would leave the old copy behind on every machine that +has run an earlier version, which is the same credential in the same file for no benefit at all. +*/ +pub fn remember_all(secrets: &BTreeMap) -> Result<(), Problem> { + for (key, value) in secrets { + if value.trim().is_empty() { + // An empty value is this run clearing a credential the model choice does not imply. + forget(key); + continue; + } + remember(key, value)?; + } + Ok(()) +} + +/** +What a previous run left, wherever it left it. + +The file first and the store on top, which is what makes an upgrade silent. A machine that ran a +version before the store existed still has its credentials in the `.env`; reading only the store +would ask that person for a key they already gave, and reading only the file would ignore the one +they gave since. The store wins because it is the one this version writes. +*/ +pub fn already_given(env_file: &std::path::Path, keys: &[&str]) -> BTreeMap { + let mut found = crate::env::already_set(env_file, keys); + found.extend(recall_all( + &keys + .iter() + .copied() + .filter(|k| is_secret(k)) + .collect::>(), + )); + found +} + +/// Read back what was stored, for the settings named. +pub fn recall_all(keys: &[&str]) -> BTreeMap { + let mut found = BTreeMap::new(); + for key in keys { + if let Some(value) = recall(key) { + if !value.trim().is_empty() { + found.insert((*key).to_string(), value); + } + } + } + found +} + +#[cfg(target_os = "macos")] +pub fn remember(name: &str, value: &str) -> Result<(), Problem> { + // `-U` so a second run updates rather than refusing, and `-w` last so the value arrives on + // stdin. `security` asks for it twice, the way a password prompt does. + let mut child = crate::quiet::command("security") + .args([ + "add-generic-password", + "-U", + "-a", + name, + "-s", + SERVICE, + "-w", + ]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::piped()) + .spawn() + .map_err(|error| keychain_problem(error.to_string()))?; + if let Some(mut stdin) = child.stdin.take() { + let _ = writeln!(stdin, "{value}"); + let _ = writeln!(stdin, "{value}"); + } + let done = child + .wait_with_output() + .map_err(|error| keychain_problem(error.to_string()))?; + if done.status.success() { + return Ok(()); + } + Err(keychain_problem( + String::from_utf8_lossy(&done.stderr).to_string(), + )) +} + +#[cfg(target_os = "macos")] +pub fn recall(name: &str) -> Option { + let out = crate::quiet::command("security") + .args(["find-generic-password", "-a", name, "-s", SERVICE, "-w"]) + .output() + .ok()?; + out.status + .success() + .then(|| String::from_utf8_lossy(&out.stdout).trim().to_string()) +} + +#[cfg(target_os = "macos")] +pub fn forget(name: &str) { + let _ = crate::quiet::command("security") + .args(["delete-generic-password", "-a", name, "-s", SERVICE]) + .output(); +} + +#[cfg(target_os = "macos")] +fn keychain_problem(detail: String) -> Problem { + Problem::with( + "OpenBot could not save your sign-in details to this Mac's Keychain.", + detail, + ) +} + +/* + * DPAPI, through the only interpreter Windows is guaranteed to have. + * + * `ProtectedData` with `CurrentUser` ties the ciphertext to the signed-in account, so the file is + * useless on another account and useless copied off the machine. The plaintext arrives on stdin + * and the ciphertext leaves on stdout, so neither is ever an argument. + */ +#[cfg(target_os = "windows")] +pub fn remember(name: &str, value: &str) -> Result<(), Problem> { + const PROTECT: &str = r#" +$ErrorActionPreference = 'Stop' +$plain = [Console]::In.ReadToEnd() +$bytes = [Text.Encoding]::UTF8.GetBytes($plain) +Add-Type -AssemblyName System.Security +$sealed = [Security.Cryptography.ProtectedData]::Protect($bytes, $null, 'CurrentUser') +[Convert]::ToBase64String($sealed) +"#; + let sealed = powershell(PROTECT, Some(value))?; + let path = vault_dir()?.join(format!("{name}.dpapi")); + std::fs::write(&path, sealed.trim()) + .map_err(|error| dpapi_problem(format!("{}: {error}", path.display()))) +} + +#[cfg(target_os = "windows")] +pub fn recall(name: &str) -> Option { + const UNPROTECT: &str = r#" +$ErrorActionPreference = 'Stop' +$sealed = [Convert]::FromBase64String([Console]::In.ReadToEnd().Trim()) +Add-Type -AssemblyName System.Security +$bytes = [Security.Cryptography.ProtectedData]::Unprotect($sealed, $null, 'CurrentUser') +[Text.Encoding]::UTF8.GetString($bytes) +"#; + let sealed = std::fs::read_to_string(vault_dir().ok()?.join(format!("{name}.dpapi"))).ok()?; + powershell(UNPROTECT, Some(&sealed)) + .ok() + .map(|plain| plain.trim().to_string()) +} + +#[cfg(target_os = "windows")] +pub fn forget(name: &str) { + if let Ok(dir) = vault_dir() { + let _ = std::fs::remove_file(dir.join(format!("{name}.dpapi"))); + } +} + +#[cfg(target_os = "windows")] +fn powershell(program: &str, input: Option<&str>) -> Result { + let mut child = crate::quiet::command("powershell") + .args(["-NoProfile", "-NonInteractive", "-Command", program]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .map_err(|error| dpapi_problem(error.to_string()))?; + if let (Some(mut stdin), Some(text)) = (child.stdin.take(), input) { + let _ = stdin.write_all(text.as_bytes()); + } + let done = child + .wait_with_output() + .map_err(|error| dpapi_problem(error.to_string()))?; + if !done.status.success() { + return Err(dpapi_problem( + String::from_utf8_lossy(&done.stderr).to_string(), + )); + } + Ok(String::from_utf8_lossy(&done.stdout).to_string()) +} + +#[cfg(target_os = "windows")] +fn dpapi_problem(detail: String) -> Problem { + Problem::with( + "OpenBot could not save your sign-in details to this computer's protected storage.", + detail, + ) +} + +/* + * Linux, where there is nothing to be assumed. + * + * Not a lesser fallback pretending to be a keystore: an owner-only file, in the same place the app + * keeps its own state, and named as what it is. Secret Service would be better on a desktop that + * runs it and is simply absent on one that does not, and refusing to save a credential because a + * daemon is missing would fail more people than the file protects. + */ +#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))] +pub fn remember(name: &str, value: &str) -> Result<(), Problem> { + let path = vault_dir()?.join(format!("{name}.secret")); + std::fs::write(&path, value).map_err(|error| { + Problem::with( + "OpenBot could not save your sign-in details on this computer.", + format!("{}: {error}", path.display()), + ) + })?; + owner_only(&path); + Ok(()) +} + +#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))] +pub fn recall(name: &str) -> Option { + std::fs::read_to_string(vault_dir().ok()?.join(format!("{name}.secret"))) + .ok() + .map(|value| value.trim().to_string()) +} + +#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))] +pub fn forget(name: &str) { + if let Ok(dir) = vault_dir() { + let _ = std::fs::remove_file(dir.join(format!("{name}.secret"))); + } +} + +/// Where the platforms that keep a file keep it. Created owner-only, not merely written so. +#[cfg(not(target_os = "macos"))] +fn vault_dir() -> Result { + let dir = crate::stack::default_root().join(".secrets"); + std::fs::create_dir_all(&dir).map_err(|error| { + Problem::with( + "OpenBot could not create the place it keeps your sign-in details.", + format!("{}: {error}", dir.display()), + ) + })?; + owner_only(&dir); + Ok(dir) +} + +/// Owner-only where the platform has the notion, and a no-op where it does not. +/// +/// Only where a file is kept. The Keychain owns its own protection and has no path to set. +#[cfg(all(unix, not(target_os = "macos")))] +fn owner_only(path: &std::path::Path) { + use std::os::unix::fs::PermissionsExt; + let mode = if path.is_dir() { 0o700 } else { 0o600 }; + let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)); +} + +#[cfg(all(not(unix), not(target_os = "macos")))] +fn owner_only(_path: &std::path::Path) {} + +#[cfg(test)] +mod tests { + use super::*; + + /// The list is the security boundary, so it is asserted rather than trusted to a reading. + #[test] + fn every_credential_is_named_and_nothing_else_is() { + for key in [ + "INTELLIGENCE_API_KEY", + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "CLAUDE_CODE_OAUTH_TOKEN", + "CHATGPT_OAUTH_TOKEN", + "MANAGED_AGENT_TOKEN", + "AGENT_TOOL_TOKEN", + "COMPUTER_TOKEN", + "SUPERVISOR_TOKEN", + "WORKER_SHARED_SECRET", + "KEY_ENCRYPTION_KEY", + ] { + assert!(is_secret(key), "{key} would have been written to the file"); + } + for key in [ + "INTELLIGENCE_API_URL", + "INTELLIGENCE_GATEWAY_WS_URL", + "OPENAI_BASE_URL", + "BOT_MODEL", + "PICKED_HARNESS_IMAGE", + "PICKED_HARNESS_URL", + "SERVER_PORT", + "DATABASE_URL", + "TRUSTED_ORIGINS", + "CHATGPT_AUTH_FILE", + ] { + assert!( + !is_secret(key), + "{key} would have been hidden from the file" + ); + } + } + + /// A path, not a credential. The store it points at is written owner-only by its own writer. + #[test] + fn the_plan_store_path_is_a_setting() { + assert!(!is_secret("CHATGPT_AUTH_FILE")); + } + + #[test] + fn splitting_keeps_every_key_on_exactly_one_side() { + let mut all = BTreeMap::new(); + all.insert("OPENAI_API_KEY".to_string(), "sec".to_string()); + all.insert("SERVER_PORT".to_string(), "3001".to_string()); + let (settings, secrets) = split(all); + assert_eq!(settings.len(), 1); + assert_eq!(secrets.len(), 1); + assert!(settings.contains_key("SERVER_PORT")); + assert!(secrets.contains_key("OPENAI_API_KEY")); + } + + /** + An upgrade takes the credential OUT of the file, rather than merely also storing it. + + The case this is for: a machine that ran a version which wrote keys to the `.env`. Storing + without purging would leave that copy exactly where it was, so the change would have bought + nothing on every machine that already existed. Uses the real writer, because the rule lives + there. + */ + #[test] + fn an_upgrade_leaves_no_credential_behind_in_the_file() { + let dir = std::env::temp_dir().join(format!("openbot-purge-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join(".env"); + std::fs::write( + &path, + "OPENAI_API_KEY=the-old-copy\nSERVER_PORT=3001\nSOMETHING_ELSE=kept\n", + ) + .unwrap(); + + let mut all = BTreeMap::new(); + all.insert("OPENAI_API_KEY".to_string(), "the-new-one".to_string()); + all.insert("SERVER_PORT".to_string(), "3001".to_string()); + let (settings, secrets) = split(all); + crate::env::write(&path, &settings, &secrets).unwrap(); + + let written = std::fs::read_to_string(&path).unwrap(); + assert!( + !written.contains("the-old-copy") && !written.contains("the-new-one"), + "a credential is still in the file:\n{written}" + ); + assert!(!written.contains("OPENAI_API_KEY"), "{written}"); + assert!(written.contains("SERVER_PORT=3001"), "{written}"); + // A line nobody here owns is still nobody's to remove. + assert!(written.contains("SOMETHING_ELSE=kept"), "{written}"); + std::fs::remove_dir_all(&dir).ok(); + } + + /** + The real store on this machine, round-tripped. + + Ignored because it writes to the person's own Keychain, which a test run should not do without + being asked. Run it by hand: `cargo test --lib vault_round_trip -- --ignored`. + */ + #[test] + #[ignore = "writes to this machine's real credential store"] + fn vault_round_trip() { + let name = "OPENBOT_VAULT_SELF_TEST"; + remember(name, "a value with spaces and $ymbols").expect("could not store"); + assert_eq!( + recall(name).as_deref(), + Some("a value with spaces and $ymbols") + ); + forget(name); + assert_eq!(recall(name), None, "forget left the credential behind"); + } +} From 0ddba6f951f56d999c3f559643344c31d28cb296 Mon Sep 17 00:00:00 2001 From: David McKay Date: Mon, 7 Sep 2026 14:48:34 -0700 Subject: [PATCH 28/46] Give the secondary buttons the class the stylesheet actually defines Three buttons asked for `secondary`, which no rule matches, so Stop OpenBot has been rendering identically to Show OpenBot and the new last screen offered two equally weighted actions. The stylesheet's quiet button is what they meant. Found by looking at the screen rather than at the markup, which is the only way a missing class shows up: nothing errors, the button just draws as the primary. --- desktop/src/App.tsx | 2 +- desktop/src/Ask.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/desktop/src/App.tsx b/desktop/src/App.tsx index a5c674363..d01212a3f 100644 --- a/desktop/src/App.tsx +++ b/desktop/src/App.tsx @@ -519,7 +519,7 @@ export function App() { )} {answer !== null && (
+ {failure && } + {answer !== null && (

Your Bot said

@@ -89,7 +98,7 @@ export function Ask({ * it. A model screen offered before the failure would be a way to change a choice that was * working, which is how somebody breaks a finished install. */} - {failed && ( + {failure && ( diff --git a/desktop/src/Problem.tsx b/desktop/src/Problem.tsx new file mode 100644 index 000000000..bc233da75 --- /dev/null +++ b/desktop/src/Problem.tsx @@ -0,0 +1,55 @@ +/** + * A failure, in both registers, wherever one happens. + * + * ONE IMPLEMENTATION, because there is one rule and every screen owes it: the sentence is the + * headline and the real output lives behind a disclosure. A second copy is how one screen ends up + * showing an engine dump as its title, and how another ends up rendering `[object Object]` because + * it stringified a failure that was never a string. + */ +export type Problem = { said: string; detail?: string | null }; + +/** Anything thrown, as a problem. A bare string keeps working and reads as it always did. */ +export function asProblem(thrown: unknown): Problem { + if (thrown && typeof thrown === "object" && "said" in thrown) { + return thrown as Problem; + } + return { said: String(thrown) }; +} + +export function Failure({ problem }: { problem: Problem }) { + return ( +
+

That did not finish

+

{problem.said}

+ {/* The real output, kept but not the headline. Whoever is debugging opens this; the person + reading the sentence above never has to. */} + {problem.detail && ( +
+ Technical details +
{problem.detail}
+
+ )} +
+ ); +} + +/** + * The same two registers where a whole panel would be too much. + * + * Used inside the provider rows, which are small and already have a heading. The sentence reads as + * a caution and the output is still one click away, so a sign-in that fails inside a card is no + * less diagnosable than one that fails on its own screen. + */ +export function InlineFailure({ problem }: { problem: Problem }) { + return ( +
+

{problem.said}

+ {problem.detail && ( +
+ Technical details +
{problem.detail}
+
+ )} +
+ ); +} diff --git a/desktop/src/ProviderPicker.tsx b/desktop/src/ProviderPicker.tsx index d5c0ea3dc..c3bda3820 100644 --- a/desktop/src/ProviderPicker.tsx +++ b/desktop/src/ProviderPicker.tsx @@ -1,4 +1,5 @@ import { invoke } from "@tauri-apps/api/core"; +import { asProblem, InlineFailure, type Problem } from "./Problem"; import { useEffect, useState } from "react"; import { Mark } from "./Mark"; @@ -70,7 +71,9 @@ export function ProviderPicker({ const [code, setCode] = useState(""); const [token, setToken] = useState(chosen?.token ?? ""); const [busy, setBusy] = useState(false); - const [failure, setFailure] = useState(""); + // A problem, not a string: a sign-in failure carries the container's own output, and + // stringifying it printed "[object Object]" where the diagnosis should have been. + const [failure, setFailure] = useState(null); /* * The two plans sign in differently, and the screen has to know which. @@ -82,7 +85,7 @@ export function ProviderPicker({ async function beginSignIn() { if (!row) return; setBusy(true); - setFailure(""); + setFailure(null); try { const start = row.id === "anthropic" @@ -95,7 +98,7 @@ export function ProviderPicker({ setSignInUrl(null); } } catch (error) { - setFailure(String(error)); + setFailure(asProblem(error)); setSignInUrl(null); } finally { setBusy(false); @@ -104,14 +107,14 @@ export function ProviderPicker({ async function finishSignIn() { setBusy(true); - setFailure(""); + setFailure(null); try { // Held, not shown. It goes on to `start_stack` the same way a typed key does. setToken(await invoke("finish_claude_sign_in", { code })); setSignInUrl(null); setCode(""); } catch (error) { - setFailure(String(error)); + setFailure(asProblem(error)); // The flow is single-use, so a refused code means starting again rather than retyping. setSignInUrl(null); } finally { @@ -312,11 +315,7 @@ export function ProviderPicker({ )} {/* Said before it happens rather than diagnosed after the Bots stop answering. */} - {failure && ( -

- {failure} -

- )} + {failure && } {row.caution && (

From 9d38e9ac00621e9bb39502af66f167e337850e76 Mon Sep 17 00:00:00 2001 From: David McKay Date: Mon, 7 Sep 2026 17:20:34 -0700 Subject: [PATCH 33/46] Give the window an Edit menu, and let a plan pick the Bot that can spend it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects that only the Anthropic path could show, both on the last screens a person sees. macOS routes the clipboard shortcuts through the menu bar, and this window had no Edit menu, so it had no Paste. Typing into the code field worked and pasting did nothing — on the one screen whose own instruction is "paste the code it shows you". Everybody signing in to a Claude plan would have reached that field, pressed the shortcut they have used all their life, and had nothing happen. Then, with the code in: a plan is not a key, and only one Bot speaks each vendor's subscription. Signing in to Claude and keeping the default Bot gave a stack that came up clean and a Bot whose log read "Missing credentials. Please pass an `api_key`". The person had answered both screens correctly and had no way to know which answer to change. The plan now re-points the Bot, and the model screen says which Bot that will be while there is still a screen to say it on. Nobody is asked to know that a subscription constrains the framework. Proven in the window on both plans: ChatGPT answers 17 x 23 = 391 through Codex with no OpenAI key present, and Claude answers 391 on the Claude Agent SDK with no Anthropic key present. The refusal of a stale code and the failure of a Bot that cannot answer both render with a plain sentence and the container's own log behind a disclosure. --- desktop/src-tauri/src/harness.rs | 39 ++++++++++++++++++++ desktop/src-tauri/src/main.rs | 62 +++++++++++++++++++++++++++++--- desktop/src/ProviderPicker.tsx | 23 +++++++++--- 3 files changed, 116 insertions(+), 8 deletions(-) diff --git a/desktop/src-tauri/src/harness.rs b/desktop/src-tauri/src/harness.rs index 69edcab14..65236751a 100644 --- a/desktop/src-tauri/src/harness.rs +++ b/desktop/src-tauri/src/harness.rs @@ -49,6 +49,31 @@ pub enum Credential { } /// One row. +/** +Which Bot can use a signed-in subscription, by vendor. + +THE CONSTRAINT IS ON THE LOGIN, NOT THE FRAMEWORK, and this is where that bites. Every harness on +the list takes any model through an API key, so the Bot step and the model step are independent +there. A subscription is different: it only ever buys that vendor's own models, and only through a +path that speaks that vendor's subscription auth. Anthropic's is the Claude Agent SDK, which reads +`CLAUDE_CODE_OAUTH_TOKEN`; OpenAI's is the Codex model, which the LangGraph AG-UI image selects from +the token store. + +MEASURED, on the screen built to catch it: signing in to a Claude plan and keeping the default Bot +produced a stack that came up clean and a Bot whose own log said "Missing credentials. Please pass +an `api_key`". The last screen showed the failure, which is what it is for, but the person had done +nothing wrong and had no way to know which of two correct-looking answers to change. + +Nobody is asked to know this. The plan picks the Bot that can use it. +*/ +pub fn speaking_for(provider: &str) -> Option<&'static str> { + match provider { + "anthropic" => Some("claude-agent-sdk"), + "openai" => Some("langgraph"), + _ => None, + } +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct Harness { pub id: String, @@ -290,6 +315,20 @@ pub fn picked( #[cfg(test)] mod tests { + /// Both plans name a Bot that exists and can actually use them. + #[test] + fn each_plan_names_a_bot_that_exists() { + for provider in ["anthropic", "openai"] { + let id = super::speaking_for(provider).expect("a plan with no Bot to run it"); + assert!( + super::catalogue().iter().any(|row| row.id == id), + "{provider} points at {id}, which is not in the catalogue" + ); + } + // Anything else is a key path, where the Bot and the model are genuinely independent. + assert_eq!(super::speaking_for("openai-compatible"), None); + } + use super::*; /// OpenBot's own `built-in` agent type is a system prompt, not a harness, and the doc is diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index 79251702a..5fbcecc44 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -216,6 +216,35 @@ async fn start_stack( // Resolved from the catalogue rather than taken from the window: the image, the port and how // it is dialled are facts about the harness, and the window knowing them would be a second // list to keep in step. See `harness::picked` for what each refusal is for. + // Named rather than inlined: the Bot choice below reads it, the store file is written from it, + // and reading the model screen twice could not be relied on to give the same answer. + let credential = model.into_credential()?; + + /* + * A PLAN CHOOSES ITS OWN BOT, because only one Bot can spend it. + * + * Every harness takes any model through a key, so the Bot step and the model step are + * independent there. A subscription is not: it buys that vendor's own models through a path + * that speaks that vendor's subscription auth, and nothing else. Signing in to a Claude plan + * and keeping the default Bot produced a clean start and a Bot whose log said "Missing + * credentials. Please pass an `api_key`" — the person had answered both screens correctly and + * had no way to know which answer to change. + * + * Nobody is asked to know this, which is the audience rule. The plan re-points the Bot, and + * the window says which Bot it will be while there is still a screen to say it on. + */ + let harness = match &credential { + openbot_env::ModelCredential::ClaudePlan { .. } => { + harness::speaking_for("anthropic").map(str::to_string) + } + openbot_env::ModelCredential::ChatGptPlan { .. } => { + harness::speaking_for("openai").map(str::to_string) + } + _ => harness, + }; + // Resolved from the catalogue rather than taken from the window: the image, the port and how + // it is dialled are facts about the harness, and the window knowing them would be a second + // list to keep in step. See `harness::picked` for what each refusal is for. let picked = harness::picked(harness.as_deref(), DEPLOYMENT_VERSION)?; // The installer does not carry the deployment; it fetches one. Skipped when the recorded @@ -269,9 +298,6 @@ async fn start_stack( return Err(problem.into()); } - // Named rather than inlined: the store file below is written from the same answer, and reading - // the model screen twice could not be relied on to give the same one. - let credential = model.into_credential()?; let settings = openbot_env::compose( &openbot_env::Intelligence { api_url, @@ -1186,7 +1212,35 @@ fn main() { true, &[&window_open, &window_stop, &window_quit], )?; - app.set_menu(Menu::with_items(app, &[&openbot])?)?; + /* + * AN EDIT MENU, WITHOUT WHICH COMMAND-V DOES NOTHING. + * + * MEASURED, on the screen that asks for a paste. macOS routes the clipboard shortcuts + * through the menu bar, so a window with no Edit menu has no Paste, and a webview text + * field silently ignores the keystroke. Typing worked and pasting did not, on the one + * screen whose own instruction is "paste the code it shows you". Every person signing + * in to a Claude plan would have reached that field, pressed the shortcut everybody + * knows, and had nothing happen. + * + * Predefined items rather than our own: these carry the standard shortcuts and the + * standard behaviour, which is the whole point of them being where a person expects. + */ + use tauri::menu::PredefinedMenuItem; + let edit = Submenu::with_items( + app, + "Edit", + true, + &[ + &PredefinedMenuItem::undo(app, None)?, + &PredefinedMenuItem::redo(app, None)?, + &PredefinedMenuItem::separator(app)?, + &PredefinedMenuItem::cut(app, None)?, + &PredefinedMenuItem::copy(app, None)?, + &PredefinedMenuItem::paste(app, None)?, + &PredefinedMenuItem::select_all(app, None)?, + ], + )?; + app.set_menu(Menu::with_items(app, &[&openbot, &edit])?)?; app.on_menu_event(|app, event| chose(app, event.id().as_ref())); Ok(()) }) diff --git a/desktop/src/ProviderPicker.tsx b/desktop/src/ProviderPicker.tsx index c3bda3820..434d503ef 100644 --- a/desktop/src/ProviderPicker.tsx +++ b/desktop/src/ProviderPicker.tsx @@ -206,10 +206,25 @@ export function ProviderPicker({ {login === "plan" && (token ? ( -

- Signed in to {row.name}. Your plan will be used, and no key is - stored on this machine. -

+ <> +

+ Signed in to {row.name}. Your plan will be used, and no key is + stored on this machine. +

+ {/* + * Said here because it changes an answer the person already gave. + * + * A subscription only works through the one Bot that speaks that vendor's + * sign-in, so choosing a plan re-points the Bot. Doing that silently would leave + * somebody looking at a Bot they did not choose with no idea why; see + * `harness::speaking_for` for the failure that came of not saying it at all. + */} +

+ Your Bot will be{" "} + {row.id === "anthropic" ? "Claude Agent SDK" : "LangGraph"}, + which is the one that can use this plan. +

+ ) : signInUrl ? ( <>

From 4534215e81a39a7cb2243d204bdddbcad9af4876 Mon Sep 17 00:00:00 2001 From: David McKay Date: Mon, 7 Sep 2026 18:16:26 -0700 Subject: [PATCH 34/46] Make the CopilotKit sign-in work, which took four shape mismatches to find Signing in from the window had never been run end to end. It failed four times in a row, each time silently or with a message that named nothing, and each fix was only findable because the failure started carrying what actually came back. The session is called `cliToken`, not `token`, so the very first exchange failed with "error decoding response body" and no way to tell which field or which endpoint. Failures here now carry the response, and that answered it in seconds. Project ids are numbers. Requiring a string dropped every project, and the screen said "That account has no projects yet" to somebody with ten of them. An empty list and an unreadable one are told apart now, because one of them is a lie a person cannot argue with. The keys endpoint declares `project_id: z.number()` with no coercion, so the string "7" came back HTTP 400 VALIDATION_ERROR on the last step of the flow. Read off `api-keys-routes.ts` rather than guessed. And the project tiles rendered as blank white rectangles: `button` sets a white colour, `.tile` overrode the background to white and not the colour, and the provider rows escaped it only because they are labels. Nothing errored. The screen asked somebody to choose between six empty boxes. A shown body is masked, because the one that diagnosed the first bug also carried a live session token, and the shape is what a developer needs from it. Proven in the window: sign in, choose an organisation, ten projects listed by name, one picked, a key created, "Connected to CopilotKit". No key typed. --- desktop/src-tauri/src/intelligence.rs | 313 +++++++++++++++++++++++--- desktop/src-tauri/src/main.rs | 28 ++- desktop/src/styles.css | 11 + 3 files changed, 314 insertions(+), 38 deletions(-) diff --git a/desktop/src-tauri/src/intelligence.rs b/desktop/src-tauri/src/intelligence.rs index f06a35a6d..9d5dec2c0 100644 --- a/desktop/src-tauri/src/intelligence.rs +++ b/desktop/src-tauri/src/intelligence.rs @@ -127,7 +127,7 @@ impl SigningInToIntelligence { } /// Wait for the browser, then turn what it brings into a project key. - pub fn finish(self) -> Result<(String, Vec), String> { + pub fn finish(self) -> Result<(String, Vec), crate::problem::Problem> { let token = self.wait_for_token()?; let session = exchange(&token)?; let product = product_credential(&session)?; @@ -223,24 +223,107 @@ fn client() -> Result { .map_err(|error| format!("The sign-in could not reach CopilotKit: {error}")) } +/** +Read a response as JSON, keeping what actually came back when it will not parse. + +WITHOUT THIS THE FAILURE IS UNDIAGNOSABLE, and it was. A sign-in that got all the way through the +browser ended on "That sign-in returned something unexpected: error decoding response body" — which +says a shape was wrong without saying which, from which endpoint, or what arrived instead. The body +is the only thing that answers any of those, and it is exactly what a two-fold failure is for. + +Capped, because a body that is not JSON is often a whole HTML error page and nobody needs all of +it. Reported as a `Problem`, so the sentence stays the person's and the body stays behind the +disclosure. +*/ +/** +The same body with anything that looks like a credential masked. + +BECAUSE THE DISCLOSURE IS STILL A SCREEN. The body that diagnosed the field-name bug also carried a +live session token, and a person doing the obvious thing with a technical detail is pasting it into +a bug report. What a developer needs from this is the SHAPE — which fields arrived and what they +were called — and the shape survives masking perfectly. +*/ +fn without_credentials(body: &str) -> String { + let Ok(mut raw) = serde_json::from_str::(body) else { + return body.to_string(); + }; + mask(&mut raw); + serde_json::to_string(&raw).unwrap_or_else(|_| body.to_string()) +} + +fn mask(value: &mut serde_json::Value) { + match value { + serde_json::Value::Object(fields) => { + for (name, held) in fields.iter_mut() { + let lower = name.to_lowercase(); + let secret = ["token", "key", "secret", "credential", "password"] + .iter() + .any(|word| lower.contains(word)); + if secret && held.is_string() { + *held = serde_json::Value::String("[hidden]".into()); + } else { + mask(held); + } + } + } + serde_json::Value::Array(items) => items.iter_mut().for_each(mask), + _ => {} + } +} + +fn read_json( + response: reqwest::blocking::Response, + what: &str, +) -> Result { + let status = response.status(); + let body = response.text().unwrap_or_default(); + serde_json::from_str(&body).map_err(|error| { + let mut shown = without_credentials(body.trim()); + shown.truncate(2000); + crate::problem::Problem::with( + format!("CopilotKit's {what} came back in a shape OpenBot does not understand."), + format!("HTTP {status}\n{error}\n\n{shown}"), + ) + }) +} + +/** +The session the ops API hands back for a browser sign-in. + +IT IS CALLED `cliToken`, and reading it as `token` was a whole sign-in that failed at the last step. +The alias is kept because this is somebody else's response and the older name may still appear; +being tolerant here costs nothing and being strict cost a person their setup. +*/ #[derive(Deserialize)] struct Session { - token: String, + #[serde(alias = "cliToken", alias = "token")] + cli_token: String, } -fn exchange(clerk_token: &str) -> Result { +fn exchange(clerk_token: &str) -> Result { let response = client()? .post(format!("{OPS_API}/api/cli/auth/session")) .json(&serde_json::json!({ "clerkToken": clerk_token })) .send() - .map_err(|error| format!("The sign-in could not be completed: {error}"))?; + .map_err(|error| { + crate::problem::Problem::with("The sign-in could not be completed.", error.to_string()) + })?; if !response.status().is_success() { - return Err("CopilotKit refused that sign-in. Try again.".into()); + let status = response.status(); + return Err(crate::problem::Problem::with( + "CopilotKit refused that sign-in. Try again.", + format!("HTTP {status}\n{}", response.text().unwrap_or_default()), + )); } - let session: Session = response - .json() - .map_err(|error| format!("That sign-in returned something unexpected: {error}"))?; - Ok(session.token) + let raw = read_json(response, "sign-in")?; + serde_json::from_value::(raw.clone()) + .map(|session| session.cli_token) + .map_err(|error| { + crate::problem::Problem::with( + "CopilotKit's sign-in came back without the session OpenBot needs.", + format!("{error}\n\n{raw}"), + ) + }) } #[derive(Deserialize)] @@ -254,34 +337,76 @@ struct ProductCredentialResponse { product_credential: ProductCredential, } -fn product_credential(session: &str) -> Result { +fn product_credential(session: &str) -> Result { let response = client()? .post(format!("{OPS_API}/api/cli/auth/product-credential")) .bearer_auth(session) .send() - .map_err(|error| format!("The sign-in could not be completed: {error}"))?; + .map_err(|error| { + crate::problem::Problem::with("The sign-in could not be completed.", error.to_string()) + })?; if !response.status().is_success() { - return Err("CopilotKit would not issue a credential for this account.".into()); + let status = response.status(); + return Err(crate::problem::Problem::with( + "CopilotKit would not issue a credential for this account.", + format!("HTTP {status}\n{}", response.text().unwrap_or_default()), + )); } - let payload: ProductCredentialResponse = response - .json() - .map_err(|error| format!("That sign-in returned something unexpected: {error}"))?; - Ok(payload.product_credential.token) + let raw = read_json(response, "credential")?; + serde_json::from_value::(raw.clone()) + .map(|payload| payload.product_credential.token) + .map_err(|error| { + crate::problem::Problem::with( + "CopilotKit's credential came back in a shape OpenBot does not understand.", + format!("{error}\n\n{raw}"), + ) + }) } -fn list_projects(product: &str) -> Result, String> { +fn list_projects(product: &str) -> Result, crate::problem::Problem> { let response = client()? .get(format!("{PRODUCT_API}/api/projects")) .bearer_auth(product) .send() - .map_err(|error| format!("Your projects could not be listed: {error}"))?; + .map_err(|error| { + crate::problem::Problem::with("Your projects could not be listed.", error.to_string()) + })?; if !response.status().is_success() { - return Err("Your CopilotKit projects could not be listed.".into()); + let status = response.status(); + return Err(crate::problem::Problem::with( + "Your CopilotKit projects could not be listed.", + format!("HTTP {status}\n{}", response.text().unwrap_or_default()), + )); + } + let raw = read_json(response, "project list")?; + let found = projects_in(&raw); + /* + * AN EMPTY LIST AND AN UNREADABLE ONE ARE DIFFERENT THINGS, and telling somebody with projects + * that they have none is the worse of the two. Measured: the sign-in got all the way here and + * the screen said "That account has no projects yet", which was false and which nobody could + * have argued with. If the payload carried something and none of it parsed as a project, the + * shape is what changed, and the shape is what gets shown. + */ + if found.is_empty() && !looks_genuinely_empty(&raw) { + return Err(crate::problem::Problem::with( + "CopilotKit's project list came back in a shape OpenBot does not understand.", + without_credentials(&raw.to_string()), + )); + } + Ok(found) +} + +/// Whether a payload actually says "no projects" rather than saying something unrecognised. +fn looks_genuinely_empty(raw: &serde_json::Value) -> bool { + let rows = raw + .get("projects") + .or_else(|| raw.get("data")) + .and_then(|value| value.as_array()) + .or_else(|| raw.as_array()); + match rows { + Some(rows) => rows.is_empty(), + None => false, } - let raw: serde_json::Value = response - .json() - .map_err(|error| format!("That list came back unreadable: {error}"))?; - Ok(projects_in(&raw)) } /** @@ -291,23 +416,48 @@ Ask for a key for the project somebody chose. key came from, because a person looking at a list of keys months later deserves to know which one their laptop is using. */ -pub fn provision_key(product: &str, project_id: &str) -> Result { +/// A project id as the keys endpoint wants it, and unchanged if it is not a number at all. +fn as_number(project_id: &str) -> serde_json::Value { + match project_id.trim().parse::() { + Ok(number) => serde_json::Value::from(number), + Err(_) => serde_json::Value::from(project_id), + } +} + +pub fn provision_key(product: &str, project_id: &str) -> Result { let response = client()? .post(format!("{PRODUCT_API}/api/keys")) .bearer_auth(product) + /* + * `project_id` AS A NUMBER, which is what the endpoint's own schema requires. + * + * `api-keys-routes.ts` declares `project_id: z.number().int().positive()` — not `coerce`, + * so the string "7" is rejected outright. Measured as `HTTP 400 VALIDATION_ERROR: Request + * validation failed.` on the last step of a sign-in that had otherwise worked, which is the + * most expensive place in the product to fail. + * + * The id travels as a string because a project list can use either shape (see + * `projects_in`), so it is turned back into a number here, where the requirement is. + */ .json(&serde_json::json!({ - "project_id": project_id, + "project_id": as_number(project_id), "name": "OpenBot Desktop", })) .send() - .map_err(|error| format!("A key could not be created: {error}"))?; + .map_err(|error| { + crate::problem::Problem::with("A key could not be created.", error.to_string()) + })?; if !response.status().is_success() { - return Err("CopilotKit would not create a key for that project.".into()); + let status = response.status(); + return Err(crate::problem::Problem::with( + "CopilotKit would not create a key for that project.", + format!("HTTP {status}\n{}", response.text().unwrap_or_default()), + )); } - let raw: serde_json::Value = response - .json() - .map_err(|error| format!("That key came back unreadable: {error}"))?; - key_in(&raw).ok_or_else(|| "That key came back without a value in it.".to_string()) + let raw = read_json(response, "key")?; + key_in(&raw).ok_or_else(|| { + crate::problem::Problem::with("That key came back without a value in it.", raw.to_string()) + }) } /** @@ -359,7 +509,15 @@ pub fn projects_in(raw: &serde_json::Value) -> Vec { }; rows.iter() .filter_map(|row| { - let id = row.get("id")?.as_str()?.to_string(); + // THE ID IS A NUMBER, and requiring a string silently dropped every project. The + // account had ten of them and the screen said it had none: `{"id":7,"name":"my-app"}` + // parsed to nothing because `as_str` returns None for `7`. Both shapes are read now, + // because which one an endpoint uses is not ours to decide. + let id = match row.get("id")? { + serde_json::Value::String(text) => text.clone(), + serde_json::Value::Number(number) => number.to_string(), + _ => return None, + }; let name = row .get("name") .and_then(|value| value.as_str()) @@ -372,6 +530,80 @@ pub fn projects_in(raw: &serde_json::Value) -> Vec { #[cfg(test)] mod tests { + /// The field name that broke a whole sign-in, read off the real response. + #[test] + fn the_session_is_read_from_the_name_the_endpoint_uses() { + // Verbatim shape from the ops API, with the value replaced. + let body = r#"{"cliToken":"abc","organization":{"organizationName":"CopilotKit"}}"#; + let session: super::Session = serde_json::from_str(body).expect("cliToken was not read"); + assert_eq!(session.cli_token, "abc"); + // The older name still works, because being strict here is what cost the setup. + let older: super::Session = serde_json::from_str(r#"{"token":"xyz"}"#).unwrap(); + assert_eq!(older.cli_token, "xyz"); + } + + /// A body shown to a person keeps its shape and loses its credentials. + #[test] + fn the_shown_body_has_no_credentials_left_in_it() { + let body = r#"{"cliToken":"live-secret","user":{"email":"a@b.c"},"apiKey":"another"}"#; + let shown = super::without_credentials(body); + assert!(!shown.contains("live-secret"), "{shown}"); + assert!(!shown.contains("another"), "{shown}"); + // The shape is the whole point of showing it at all. + assert!(shown.contains("cliToken") && shown.contains("email") && shown.contains("a@b.c")); + } + + /// The keys endpoint takes a number, and sending a string failed the whole sign-in. + #[test] + fn the_project_id_is_sent_as_the_number_the_endpoint_requires() { + assert_eq!(super::as_number("7"), serde_json::json!(7)); + assert_eq!(super::as_number(" 11 "), serde_json::json!(11)); + // A self-hosted deployment could use a real string id; that is not ours to mangle. + assert_eq!(super::as_number("p_abc"), serde_json::json!("p_abc")); + } + + /// A numeric id is still an id, and requiring a string hid every project this account had. + #[test] + fn projects_are_read_whether_the_id_is_a_number_or_a_string() { + // Verbatim shape from the product API, trimmed. + let real: serde_json::Value = serde_json::from_str( + r#"{"projects":[{"createdAt":"2026-06-17T21:52:32.994Z","id":7,"name":"my-app","slug":"my-app"},{"id":11,"name":"Test Project"}]}"#, + ) + .unwrap(); + let found = super::projects_in(&real); + assert_eq!(found.len(), 2, "a numeric id dropped the project"); + assert_eq!(found[0].id, "7"); + assert_eq!(found[0].name, "my-app"); + + // A string id keeps working, because some endpoints do use one. + let text: serde_json::Value = + serde_json::from_str(r#"[{"id":"p_1","name":"One"}]"#).unwrap(); + assert_eq!(super::projects_in(&text)[0].id, "p_1"); + } + + /// An empty answer and an unreadable one are told apart, because one of them is a lie. + #[test] + fn a_payload_we_cannot_read_is_not_reported_as_no_projects() { + let empty: serde_json::Value = serde_json::from_str(r#"{"projects":[]}"#).unwrap(); + assert!(super::looks_genuinely_empty(&empty)); + assert!(super::looks_genuinely_empty(&serde_json::json!([]))); + + // A shape nobody recognises is not an empty list, and saying so is the bug. + let odd: serde_json::Value = + serde_json::from_str(r#"{"items":[{"id":"p1","name":"One"}]}"#).unwrap(); + assert!(!super::looks_genuinely_empty(&odd)); + assert!(super::projects_in(&odd).is_empty()); + } + + /// Something that is not JSON is still worth showing, unchanged. + #[test] + fn a_body_that_is_not_json_is_shown_as_it_arrived() { + assert_eq!( + super::without_credentials("502"), + "502" + ); + } + use super::*; #[test] @@ -460,3 +692,20 @@ mod tests { assert!(signing.port() > 0); } } + +#[cfg(test)] +mod wire { + /// What the window actually receives, which is the only thing that decides what it can render. + #[test] + fn a_project_reaches_the_window_with_both_fields() { + let project = super::Project { + id: "7".into(), + name: "my-app".into(), + }; + let json = serde_json::to_string(&project).unwrap(); + assert_eq!( + json, r#"{"id":"7","name":"my-app"}"#, + "the wire shape changed" + ); + } +} diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index 5fbcecc44..82ab9556e 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -872,36 +872,52 @@ async fn begin_intelligence_sign_in(app: tauri::AppHandle) -> Result Result, String> { +) -> Result, openbot_desktop_lib::problem::Problem> +{ let signing = app .state::() .signing_in_to_intelligence .lock() .unwrap() .take() - .ok_or_else(|| "That sign-in is no longer running. Start it again.".to_string())?; + .ok_or_else(|| { + openbot_desktop_lib::problem::Problem::plain( + "That sign-in is no longer running. Start it again.", + ) + })?; let (credential, projects) = tauri::async_runtime::spawn_blocking(move || signing.finish()) .await - .map_err(|error| format!("The sign-in did not finish: {error}"))??; + .map_err(|error| { + openbot_desktop_lib::problem::Problem::plain(format!( + "The sign-in did not finish: {error}" + )) + })??; *app.state::().intelligence_credential.lock().unwrap() = Some(credential); Ok(projects) } /// Create a key for the project somebody chose, and hand it back for the field. #[tauri::command] -async fn intelligence_key_for(app: tauri::AppHandle, project: String) -> Result { +async fn intelligence_key_for( + app: tauri::AppHandle, + project: String, +) -> Result { let credential = app .state::() .intelligence_credential .lock() .unwrap() .clone() - .ok_or_else(|| "Sign in to CopilotKit first.".to_string())?; + .ok_or_else(|| { + openbot_desktop_lib::problem::Problem::plain("Sign in to CopilotKit first.") + })?; tauri::async_runtime::spawn_blocking(move || { openbot_desktop_lib::intelligence::provision_key(&credential, &project) }) .await - .map_err(|error| format!("A key could not be created: {error}"))? + .map_err(|error| { + openbot_desktop_lib::problem::Problem::plain(format!("A key could not be created: {error}")) + })? } /// The model screen's rows. Independent of the picker above, and required to stay that way: no diff --git a/desktop/src/styles.css b/desktop/src/styles.css index 13053cdc6..8f19393b9 100644 --- a/desktop/src/styles.css +++ b/desktop/src/styles.css @@ -233,8 +233,19 @@ fieldset.picker { overflow-y: auto; } +/* + * A tile carries its own ink, and this is not belt-and-braces. + * + * MEASURED: the project list rendered as six blank white rectangles. `button` sets + * `color: var(--accent-ink)`, which is white, and `.tile` overrode the background to white without + * overriding the colour. The provider and Bot rows are `label`s and never inherited it, so the one + * tile in the product that is a real button was the one nobody could read. Nothing errored and + * nothing looked broken: the names were simply invisible, and the screen asked somebody to choose + * between six empty boxes. + */ .tile { position: relative; + color: var(--ink); display: grid; grid-template-columns: 32px 1fr; grid-template-areas: From 50b872c4d9e4216be95818f8a63f0a79a4a49c3e Mon Sep 17 00:00:00 2001 From: David McKay Date: Mon, 7 Sep 2026 19:12:03 -0700 Subject: [PATCH 35/46] Stop a model name outliving the answer that chose it The compatible row is the only one that names a model, and switching away from it kept the name. Answering with an OpenAI key after using a local endpoint left BOT_MODEL=local-model, so the Bot asked OpenAI for a model only that person's own server has, and the last screen said "That account cannot use the model that was chosen" about a model this run never chose. Exactly the failure the key-clearing exists for, with one key missed. It is removed rather than emptied so the compose default applies, and taken out of the file as well, because the writer keeps lines it does not own and that is what let it survive. Found on a full pass through the window, in the first path. --- desktop/src-tauri/src/env.rs | 64 +++++++++++++++++++++++++++++++++++ desktop/src-tauri/src/main.rs | 16 ++++++++- 2 files changed, 79 insertions(+), 1 deletion(-) diff --git a/desktop/src-tauri/src/env.rs b/desktop/src-tauri/src/env.rs index 21853c325..96a51e91c 100644 --- a/desktop/src-tauri/src/env.rs +++ b/desktop/src-tauri/src/env.rs @@ -151,6 +151,21 @@ pub fn compose( ] { env.insert(key.into(), String::new()); } + /* + * AND THE MODEL NAME, WHICH ONLY ONE ROW IMPLIES. + * + * Measured: answering the compatible row sets `BOT_MODEL` to whatever the person's own + * endpoint calls its model, and switching back to an OpenAI key left it there. The Bot then + * asked OpenAI for `local-model` and the last screen said "That account cannot use the + * model that was chosen" — about a model this run never chose. Exactly the failure the + * clearing above exists for, with one key missed. + * + * Removed rather than emptied, so `docker-compose.yml`'s own default applies. Blank would + * be passed through as a model named "", which is a worse question to ask a provider. + */ + if !matches!(model.credential, ModelCredential::Compatible { .. }) { + env.remove("BOT_MODEL"); + } } match &model.credential { /* @@ -1176,6 +1191,55 @@ mod model_tests { std::fs::remove_dir_all(&dir).ok(); } + /** + Switching away from the compatible row does not leave its model name behind. + + Measured on a real pass: the compatible row set `BOT_MODEL=local-model`, and answering with an + OpenAI key afterwards kept it, so the Bot asked OpenAI for a model only that person's own + endpoint has. The last screen said "That account cannot use the model that was chosen" about a + model this run never chose. + */ + #[test] + fn a_model_name_does_not_survive_a_provider_that_does_not_name_one() { + let compatible = compose( + &intelligence(), + &Model { + credential: ModelCredential::Compatible { + base_url: "http://127.0.0.1:4310/v1".into(), + api_key: "x".into(), + model: "local-model".into(), + }, + }, + &engine(), + &Ports::default(), + &pinned(), + None, + &BTreeMap::new(), + ); + assert_eq!( + compatible.get("BOT_MODEL"), + Some(&"local-model".to_string()) + ); + + let with_a_key = compose( + &intelligence(), + &Model { + credential: ModelCredential::OpenAi { + api_key: "sk-x".into(), + }, + }, + &engine(), + &Ports::default(), + &pinned(), + None, + &BTreeMap::new(), + ); + assert!( + !with_a_key.contains_key("BOT_MODEL"), + "a key path carried a model name it never chose" + ); + } + /// Switching provider does not leave the last one's key behind. /// /// Measured, not imagined: a run that signed in to a Claude plan still carried the diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index 82ab9556e..de1d5c4ad 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -325,7 +325,21 @@ async fn start_stack( * a secret can live without being written down. See `vault` for what each platform gets. */ let (settings, secrets) = openbot_desktop_lib::vault::split(settings); - openbot_env::write(&root.join(".env"), &settings, &secrets) + /* + * The credentials, plus any setting this answer dropped. + * + * `write` keeps lines it does not own, which is what protects a hand-set value. The cost is + * that a key this run deliberately stopped writing would otherwise survive: `BOT_MODEL` did, + * leaving an OpenAI key asking OpenAI for the model name a previous compatible-endpoint answer + * had given. Anything the writer owns and did not produce this time is taken out. + */ + let mut purge = secrets.clone(); + for key in ["BOT_MODEL"] { + if !settings.contains_key(key) { + purge.insert(key.into(), String::new()); + } + } + openbot_env::write(&root.join(".env"), &settings, &purge) .map_err(|e| format!("could not write .env: {e}"))?; openbot_desktop_lib::vault::remember_all(&secrets)?; // Beside the `.env` and before the containers, because compose mounts it. See From 1a801c933fcaf5443c32b471bd4f4409c01359ff Mon Sep 17 00:00:00 2001 From: David McKay Date: Mon, 7 Sep 2026 20:36:52 -0700 Subject: [PATCH 36/46] Say why a conversation has no messages, instead of showing a blank window Clicking a conversation in the rail drew the coworker's name and then nothing at all. The rail comes from OpenBot's own database, so a channel is listed whatever the history store says; the messages live in the Intelligence project, and pointing a deployment at a different project leaves the platform answering THREAD_NOT_FOUND. That 404 is deliberately read as "no history" and must stay that way: a thread id is minted before the thread exists, so a brand-new conversation 404s as its normal opening move. Widening it would tell somebody their conversation was gone and invite them to start it over. The two cases are told apart by a fact the app already stores. lastMessageAt is set only once something has been said, so a conversation with none is genuinely new and silence is correct, while one that has been spoken in and comes back empty has a history this deployment cannot reach. That one now says so, in the notice slot beside the existing explanations for a deleted coworker and for turns that could not be parsed. The channel DTO carries lastMessageAt for it, which the shape tests pin, plus a new test that the date leaves as a string and leaves at all. --- app/src/components/channels/channel-chat.tsx | 26 ++++++++++++++++++ app/src/lib/channels/queries.ts | 9 ++++-- server/src/channels/routes.ts | 25 +++++++++++++---- server/tests/channel-routes.test.ts | 29 ++++++++++++++++++++ 4 files changed, 82 insertions(+), 7 deletions(-) diff --git a/app/src/components/channels/channel-chat.tsx b/app/src/components/channels/channel-chat.tsx index 2f5be26ed..7c92b2b13 100644 --- a/app/src/components/channels/channel-chat.tsx +++ b/app/src/components/channels/channel-chat.tsx @@ -502,6 +502,32 @@ export function ChannelChat({ * it — and they are independent, so neither is an `else` for the other. */ <> + {/* + * A conversation whose history this deployment cannot reach at all. + * + * MEASURED, AND IT LOOKED LIKE A BROKEN APP. The rail is drawn from OpenBot's own + * database, so a channel is listed whatever the history store says; the messages live in + * the Intelligence project, and pointing a deployment at a different project leaves the + * platform answering `THREAD_NOT_FOUND`. That 404 is deliberately read as "no history" + * because a thread id is minted before the thread exists, so a brand-new conversation + * 404s as its normal opening move — see `isMissingThread` in `server/src/copilot.ts` and + * the note there about not widening it. + * + * The two cases are told apart by a fact this app already has: `lastMessageAt` is set + * only once something has been said. A new conversation has none and is silent, as it + * should be. One that has been spoken in and comes back with nothing is a conversation + * whose history is somewhere this deployment cannot see, and saying nothing there is + * what made a list of conversations open onto a blank window. + */} + {!restoring && + agent.messages.length === 0 && + channel.lastMessageAt !== null ? ( +

+ This conversation was kept with a different CopilotKit project, + so its earlier messages cannot be read here. Anything you send + now starts a fresh history. +

+ ) : null} {unreadable > 0 ? (

{unreadable === 1 diff --git a/app/src/lib/channels/queries.ts b/app/src/lib/channels/queries.ts index 3f9c97248..bcd2f6e92 100644 --- a/app/src/lib/channels/queries.ts +++ b/app/src/lib/channels/queries.ts @@ -14,6 +14,13 @@ export type AgentChannel = { agentIds: string[]; threadId: string; active: boolean; + /** + * ISO-8601 when something was last said here, or null for a conversation nobody has used. + * + * The conversation screen needs this to tell two silences apart: a new conversation with no + * history, and one whose history this deployment cannot reach. See `channel-chat.tsx`. + */ + lastMessageAt: string | null; }; /** A channel plus what the roster renders about it. */ @@ -21,8 +28,6 @@ export type ChannelSummary = AgentChannel & { /** A few words about the conversation, or null. The roster falls back to `name`. */ summary: string | null; lastMessage: string | null; - /** ISO-8601, or null for a channel nobody has used yet. */ - lastMessageAt: string | null; lastMessageAgentId: string | null; /** ISO-8601. Ordering falls back to this, so a channel just created sorts to the top. */ createdAt: string; diff --git a/server/src/channels/routes.ts b/server/src/channels/routes.ts index 8be7a28a0..0d7db397f 100644 --- a/server/src/channels/routes.ts +++ b/server/src/channels/routes.ts @@ -42,6 +42,15 @@ export type AgentChannel = { agentIds: string[]; threadId: string; active: boolean; + /** + * When something was last said here, or null for a conversation nobody has used. + * + * On the channel itself rather than only on the roster summary, because the conversation screen + * needs it: a thread the history store does not know about is an empty NEW conversation when this + * is null and a conversation whose history is unreachable when it is set, and those are different + * things to put on the screen. See the note in `channel-chat.tsx`. + */ + lastMessageAt: Date | null; }; /** A channel plus the last thing said in it, which is what a roster renders. */ @@ -49,7 +58,6 @@ export type ChannelSummary = AgentChannel & { /** A few words about the conversation, or null. Readers fall back to the channel's name. */ summary: string | null; lastMessage: string | null; - lastMessageAt: Date | null; lastMessageAgentId: string | null; createdAt: Date; /** Whether the caller pinned this channel. A pin is per-member, so this is the caller's, only. */ @@ -289,7 +297,7 @@ export function createChannelStore( threadId, }); - return { id, name, agentIds, threadId, active: true }; + return { id, name, agentIds, threadId, active: true, lastMessageAt: null }; }; const store: ChannelStore = { @@ -372,6 +380,7 @@ export function createChannelStore( name: channels.name, agentId: channelAgents.agentId, threadId: intelligenceChannelMappings.threadId, + lastMessageAt: channels.lastMessageAt, deletedAt: agentProfiles.deletedAt, }) .from(channels) @@ -406,6 +415,7 @@ export function createChannelStore( agentIds: rows.map((row) => row.agentId), threadId: first.threadId, active: rows.every((row) => row.deletedAt === null), + lastMessageAt: first.lastMessageAt, }; }, @@ -1169,13 +1179,20 @@ export function createChannelRoutes( return routes; } -function channelDto(channel: AgentChannel): AgentChannel { +/** A channel as it goes over the wire: the same shape, with the date serialised. */ +type ChannelWire = Omit & { + lastMessageAt: string | null; +}; + +function channelDto(channel: AgentChannel): ChannelWire { return { id: channel.id, name: channel.name, agentIds: channel.agentIds, threadId: channel.threadId, active: channel.active, + // ISO-8601 so the browser gets a string it can compare, like the roster's copy. + lastMessageAt: channel.lastMessageAt?.toISOString() ?? null, }; } @@ -1184,8 +1201,6 @@ function channelSummaryDto(channel: ChannelSummary) { ...channelDto(channel), summary: channel.summary, lastMessage: channel.lastMessage, - // Serialised as ISO-8601 so the browser gets a string it can sort and format. - lastMessageAt: channel.lastMessageAt?.toISOString() ?? null, lastMessageAgentId: channel.lastMessageAgentId, createdAt: channel.createdAt.toISOString(), pinned: channel.pinned, diff --git a/server/tests/channel-routes.test.ts b/server/tests/channel-routes.test.ts index 031b063c9..f15c821dc 100644 --- a/server/tests/channel-routes.test.ts +++ b/server/tests/channel-routes.test.ts @@ -57,6 +57,7 @@ function channel(overrides: Partial = {}): AgentChannel { agentIds: ["agent-1", "agent-2"], threadId: "thread-1", active: true, + lastMessageAt: null, ...overrides, }; } @@ -221,12 +222,39 @@ describe("channel routes", () => { agentIds: ["agent-1"], threadId: "thread-1", active: true, + lastMessageAt: null, }, }); expect(fetched.status).toBe(200); expect(await json(fetched)).toEqual({ channel: channel() }); }); + /** + * The date leaves as a string, and it has to leave at all. + * + * This is what lets the conversation screen tell an empty NEW conversation from one whose history + * this deployment cannot reach: null means nothing was ever said, a timestamp means something + * was. Dropping it from the DTO would put the screen back to rendering a blank window with no + * explanation for a conversation that plainly has a past. + */ + test("carries when the channel was last spoken in, as a string", async () => { + const spokenAt = new Date("2026-09-07T20:25:48.391Z"); + const store = fakeStore({ + async get() { + return channel({ lastMessageAt: spokenAt }); + }, + }); + + const fetched = await appFor(store).request( + "http://openbot.test/channel-1", + ); + + expect(fetched.status).toBe(200); + expect(await json(fetched)).toEqual({ + channel: { ...channel(), lastMessageAt: spokenAt.toISOString() }, + }); + }); + test.each([ ["{", "Channel input must be a JSON object."], [JSON.stringify([]), "Channel input must be a JSON object."], @@ -980,6 +1008,7 @@ describe("channel store integration", () => { agentIds: canonicalAgentIds, threadId: created.threadId, active: true, + lastMessageAt: null, }); const persisted = await persistedChannel(created.id); expect(persisted.channelRow?.name).toBe("Zulu, Alpha"); From b0cf27333913671d6336279ac93cc54bd1094d54 Mon Sep 17 00:00:00 2001 From: David McKay Date: Mon, 7 Sep 2026 22:23:20 -0700 Subject: [PATCH 37/46] Install the container engine, instead of telling somebody to go and get one Setup ended at "Install Podman Desktop or Docker Desktop first" on any machine that had neither, which is every machine this app is for. The step existed in the enum with nothing behind it and the screens had been reworded to stop promising it. So the whole install stopped at a download page. It installs one now, and the second half is the half that gets forgotten: Podman ships no Compose implementation, so a machine with a freshly installed Podman still cannot raise the stack and answers with seven errors naming docker-compose. Both are fetched, each pinned to the digest of the release it was tested against and refused if it does not match, because these are files this app then executes. Only what is missing is added: an engine somebody already has is theirs, and a Compose that already answers is left alone. Windows installs unattended. macOS and Linux each raise one authorization prompt, which is the platform's own and is not something to route around: the package writes to /opt/podman, and on Linux Podman is a set of binaries wired to the distribution's paths rather than one file to download. Two things were needed to make the result usable in the session that installed it. The MSI extends the USER's PATH, and this process was started with the old one, so podman could not be run for the rest of the run: every engine command now names a resolved path, found on PATH first and in the installers' own locations second. And the Compose provider is put in front of the child's PATH rather than written into containers.conf, which belongs to whoever else may have configured it. Measured on Windows Server 2022, which is also where the recovery came from: a Podman removed by deleting its folder leaves the registration behind, so /i becomes a repair with no source and stops with 1603. That case uninstalls and installs cleanly instead of reporting a failure somebody cannot act on. Image references now come from the release's manifest wherever the shell runs a container itself, not only where Compose does. This is the same bug a third time: first the names were built from the ids and matched nothing published, then the version stopped being appended so an engine read the bare name as :latest, and now openbot-agent-langgraph-agui:v0.0.8 was resolved to docker.io/library/... and the person was told access was denied, which reads as a credentials problem for a repository that was never pushed. No reference is built here at all any more, and an image this release does not include is named as that. Both plan sign-ins set the engine up rather than refusing. They run in a container, and "No container engine is answering, so the sign-in cannot run" named an obstacle and no way past it, on a screen whose whole purpose is to put one there. One function does it for Start and for both of them. A setup step that stops now carries both registers. podman machine init failing is exactly the case the two-part failure was written for, and it was the last place still putting an engine's own words in front of somebody as the headline. --- desktop/src-tauri/Cargo.lock | 1 + desktop/src-tauri/Cargo.toml | 2 + desktop/src-tauri/src/acquire.rs | 154 +++++--- desktop/src-tauri/src/deployment.rs | 29 +- desktop/src-tauri/src/engine.rs | 211 ++++++++++- desktop/src-tauri/src/harness.rs | 136 +++++-- desktop/src-tauri/src/install.rs | 567 ++++++++++++++++++++++++++++ desktop/src-tauri/src/lib.rs | 1 + desktop/src-tauri/src/main.rs | 220 ++++++++--- desktop/src-tauri/src/plan.rs | 18 +- desktop/src/App.tsx | 23 +- 11 files changed, 1190 insertions(+), 172 deletions(-) create mode 100644 desktop/src-tauri/src/install.rs diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 548342ddd..b1bcc9113 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -2498,6 +2498,7 @@ dependencies = [ "security-framework", "serde", "serde_json", + "sha2", "tar", "tauri", "tauri-build", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 1c67fc06e..a075c0fc8 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -25,6 +25,8 @@ rand = "0.9" base64 = "0.22" reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "blocking", "json"] } flate2 = "1" +# Checking the digest of an installer before running it. See `install.rs`. +sha2 = "0.10" tar = "0.4" tauri-plugin-single-instance = "2.0.0-rc.5" portable-pty = "0.9.0" diff --git a/desktop/src-tauri/src/acquire.rs b/desktop/src-tauri/src/acquire.rs index 9eefedfb8..dbd1ff9ac 100644 --- a/desktop/src-tauri/src/acquire.rs +++ b/desktop/src-tauri/src/acquire.rs @@ -7,9 +7,10 @@ //! **Windows cannot do this from a service.** `podman machine init` shells out to `wsl.exe`, and WSL //! refuses to run as LocalSystem: `Wsl/WSL_E_LOCAL_SYSTEM_NOT_SUPPORTED`. Meanwhile `wsl --install` //! needs elevation. So the two halves run in different contexts, and the elevated half is the only -//! part that may be handed to a helper. See `windows.rs`. +//! part that may be handed to a helper. See `windows.rs`. Fetching and installing Podman itself is +//! `install.rs`. -use crate::quiet::{command, said as command_said}; +use crate::quiet::said as command_said; use std::path::Path; use serde::{Deserialize, Serialize}; @@ -20,21 +21,54 @@ use crate::engine::{Address, Engine}; #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "kebab-case")] pub enum Step { - /// Not used. Installing an engine is designed and not built: nothing here downloads Podman, - /// and the screens no longer say it does. Kept so the sequence a person is shown reads the - /// same when it is. - InstallEngine, CreateMachine, StartMachine, HealthGate, } +/// How a step went, in both registers when it went badly. +/// +/// Two fields and not one for the reason `problem.rs` gives: `podman machine init` failing is +/// exactly the case where the engine's own output was put in front of somebody as the headline. +/// The row shows `said`; `detail` is the output, kept. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct StepOutcome { pub step: Step, pub ok: bool, - /// What to do about it, where there is something to do. - pub detail: String, + /// The sentence for the step row, and for the failure when there is one. + pub said: String, + /// What the command actually said, where a command said anything. + pub detail: Option, +} + +impl StepOutcome { + fn went(step: Step, said: impl Into) -> Self { + Self { + step, + ok: true, + said: said.into(), + detail: None, + } + } + + /// A failed step, with the engine's own words kept behind the sentence. + fn stopped(step: Step, output: &str) -> Self { + let problem = crate::problem::Problem::with(explain_machine_error(output), output); + Self { + step, + ok: false, + said: problem.said, + detail: problem.detail, + } + } + + /// This step's failure, for a caller that has to return one. + pub fn problem(&self) -> crate::problem::Problem { + crate::problem::Problem { + said: self.said.clone(), + detail: self.detail.clone(), + } + } } /// The name of the machine this app owns. @@ -45,7 +79,9 @@ pub struct StepOutcome { pub const MACHINE: &str = "openbot"; fn podman(args: &[&str]) -> Result { - let output = command("podman") + // Resolved, not named: right after OpenBot installs it, `podman` is not yet on this process's + // PATH. See the PATH rule in `engine.rs`. + let output = crate::engine::tool(Engine::Podman) .args(args) .output() .map_err(|error| format!("could not run podman: {error}"))?; @@ -67,11 +103,7 @@ pub fn machine_exists() -> bool { /// belonged to 5.7, where libkrun was the default. pub fn create_machine(cpus: u32, memory_mib: u32, disk_gib: u32) -> StepOutcome { if machine_exists() { - return StepOutcome { - step: Step::CreateMachine, - ok: true, - detail: format!("{MACHINE} already exists."), - }; + return StepOutcome::went(Step::CreateMachine, format!("{MACHINE} already exists.")); } match podman(&[ "machine", @@ -84,36 +116,19 @@ pub fn create_machine(cpus: u32, memory_mib: u32, disk_gib: u32) -> StepOutcome "--disk-size", &disk_gib.to_string(), ]) { - Ok(_) => StepOutcome { - step: Step::CreateMachine, - ok: true, - detail: format!("{MACHINE} created."), - }, - Err(error) => StepOutcome { - step: Step::CreateMachine, - ok: false, - detail: explain_machine_error(&error), - }, + Ok(_) => StepOutcome::went(Step::CreateMachine, format!("{MACHINE} created.")), + Err(error) => StepOutcome::stopped(Step::CreateMachine, &error), } } pub fn start_machine() -> StepOutcome { match podman(&["machine", "start", MACHINE]) { - Ok(_) => StepOutcome { - step: Step::StartMachine, - ok: true, - detail: format!("{MACHINE} started."), - }, - Err(error) if error.contains("already running") => StepOutcome { - step: Step::StartMachine, - ok: true, - detail: format!("{MACHINE} was already running."), - }, - Err(error) => StepOutcome { - step: Step::StartMachine, - ok: false, - detail: explain_machine_error(&error), - }, + Ok(_) => StepOutcome::went(Step::StartMachine, format!("{MACHINE} started.")), + Err(error) if error.contains("already running") => StepOutcome::went( + Step::StartMachine, + format!("{MACHINE} was already running."), + ), + Err(error) => StepOutcome::stopped(Step::StartMachine, &error), } } @@ -165,30 +180,34 @@ pub fn health_gate(address: &Address) -> StepOutcome { return StepOutcome { step: Step::HealthGate, ok: false, - detail: missing_compose(binary), + said: missing_compose(binary), + detail: None, }; } - StepOutcome { - step: Step::HealthGate, - ok: true, - detail: format!("engine API {}", String::from_utf8_lossy(&out.stdout).trim()), - } + StepOutcome::went( + Step::HealthGate, + format!("engine API {}", String::from_utf8_lossy(&out.stdout).trim()), + ) } - Ok(out) => StepOutcome { - step: Step::HealthGate, - ok: false, - detail: format!("{binary} did not answer: {}", command_said(&out.stderr)), - }, - Err(error) => StepOutcome { - step: Step::HealthGate, - ok: false, - detail: format!("{binary} could not be run: {error}"), - }, + // The engine ran and refused. Its words are the evidence, and the sentence in front of + // them is chosen from what they say. + Ok(out) => StepOutcome::stopped( + Step::HealthGate, + &format!("{binary} did not answer: {}", command_said(&out.stderr)), + ), + Err(error) => StepOutcome::stopped( + Step::HealthGate, + &format!("{binary} could not be run: {error}"), + ), } } /// What to install, named, rather than seven errors about a file that is not there. /// +/// A last resort, not the plan: OpenBot installs a Compose provider itself, so somebody only reads +/// this when that copy is missing or is not being found. The restart comes first for that reason, +/// and the platform's own instruction is behind it. +/// /// Compose v2 rather than `podman-compose`: v2 is what the stack was tested against, and it is what /// reads the healthchecks and `depends_on` conditions in `docker-compose.yml`. `podman-compose` is /// a separate reimplementation with its own coverage of those, and choosing it here would mean @@ -207,7 +226,11 @@ pub fn missing_compose(binary: &str) -> String { "Install Compose v2: `brew install docker-compose`, or install Docker Desktop, and make \ sure `docker-compose` is on PATH." }; - format!("{binary} is answering, but it has no Compose to run the stack with. {install}") + format!( + "{binary} is answering, but it has no Compose to run the stack with, and OpenBot's own \ + copy of one is not being found. Restart OpenBot and try again. If this comes back: \ + {install}" + ) } /// Where a downloaded installer is kept, so a failed run can be retried without downloading again. @@ -282,6 +305,25 @@ mod tests { ); } + /// A step that stopped keeps the engine's words, and does not make them the headline. This is + /// the case that put "exit status 0xffffffff" in front of somebody as the whole message. + #[test] + fn a_step_that_stopped_carries_both_registers() { + let stopped = StepOutcome::stopped(Step::CreateMachine, "exit status 0xffffffff"); + assert!(!stopped.ok); + assert_eq!(stopped.detail.as_deref(), Some("exit status 0xffffffff")); + assert_eq!(stopped.problem().detail, stopped.detail); + assert_eq!(stopped.problem().said, stopped.said); + } + + /// A step that worked has nothing behind it, because there is no failure to explain. + #[test] + fn a_step_that_worked_has_no_output_hidden_behind_it() { + let went = StepOutcome::went(Step::StartMachine, "openbot started."); + assert!(went.ok); + assert_eq!(went.detail, None); + } + #[test] fn an_error_we_do_not_recognise_is_passed_through_rather_than_swallowed() { let explained = explain_machine_error("some novel failure"); diff --git a/desktop/src-tauri/src/deployment.rs b/desktop/src-tauri/src/deployment.rs index 133651e41..35bb69bcc 100644 --- a/desktop/src-tauri/src/deployment.rs +++ b/desktop/src-tauri/src/deployment.rs @@ -84,6 +84,29 @@ pub fn image_variables(root: &Path) -> Result, String> { pin(&manifest) } +/// One published image's reference, digest-pinned, from the manifest beside the deployment. +/// +/// Every image reference comes from here, whether Compose reads it or the shell runs it directly. +/// The alternative was a name built from a version, and an engine given an unqualified name looks +/// it up on Docker Hub: `openbot-agent-langgraph-agui:v0.0.8` became +/// `docker.io/library/openbot-agent-langgraph-agui`, and the person was shown "requested access to +/// the resource is denied", which reads as a credentials problem and is not one. +/// +/// An image this release does not publish is named as that. It is the honest answer and the +/// actionable one: the alternative is somebody debugging registry permissions for an image that +/// was never pushed. +pub fn reference(root: &Path, published: &str) -> Result { + let text = std::fs::read_to_string(images_path(root)) + .map_err(|error| format!("could not read {}: {error}", images_path(root).display()))?; + let manifest: Images = serde_json::from_str(&text) + .map_err(|error| format!("{IMAGES} is not readable: {error}"))?; + manifest + .images + .get(published) + .map(|image| image.reference.clone()) + .ok_or_else(|| format!("OpenBot {} does not include {published}.", manifest.version)) +} + /// Every image the stack runs, or a failure that names the one that is missing. /// /// Refusing a partial manifest rather than filling the gaps from Compose's defaults: a stack that @@ -247,7 +270,11 @@ fn fetch_images(root: &Path, version: &str) -> Result<(), String> { .map_err(|error| format!("could not write {IMAGES}: {error}")) } -fn get(url: &str) -> Result, String> { +/// Fetch a URL into memory. +/// +/// Shared with `install.rs`, which fetches the engine's installers through it and then checks their +/// digests. One client, one user agent, one set of TLS defaults. +pub fn get(url: &str) -> Result, String> { let response = reqwest::blocking::Client::builder() .user_agent("openbot-desktop") .build() diff --git a/desktop/src-tauri/src/engine.rs b/desktop/src-tauri/src/engine.rs index 27f8c37bd..5017bde84 100644 --- a/desktop/src-tauri/src/engine.rs +++ b/desktop/src-tauri/src/engine.rs @@ -14,6 +14,13 @@ //! - **Windows.** `podman machine` again, on WSL2, and the same in-VM symlink as macOS. WSL refuses //! to run as LocalSystem, so none of this can be done from a service; see `windows.rs`. //! +//! A second rule was learned the same way: **never assume the engine is on this process's PATH.** +//! When OpenBot installs Podman itself, the installer extends the *user's* PATH, and this process +//! was started with the old one. `podman` then cannot be run for the rest of the session, so the +//! app reports no engine while `podman.exe` sits on disk where it was just put. Every engine +//! command is therefore built from a resolved path, and the Compose provider OpenBot placed is put +//! on the child's PATH. See `install.rs`. +//! //! One rule cuts across all three: **never address Podman through its ambient default connection.** //! `podman` sends every command to whichever machine is marked default, and that machine belongs to //! whoever made it. A person with a stopped machine of their own gets `Cannot connect to Podman` @@ -21,8 +28,10 @@ //! from the error. So the engine is carried as an `Address` and every invocation names its //! connection. Docker has one daemon and needs none of this. -use std::path::PathBuf; +use std::ffi::OsString; +use std::path::{Path, PathBuf}; use std::process::Command; +use std::sync::OnceLock; use crate::quiet::command; @@ -68,20 +77,33 @@ impl Address { /// Split out because not every caller can use a `std::process::Command`: the plan sign-in runs /// under a pty and has to build the pty crate's own command type. Both go through here, so a /// machine addressed by name cannot be addressed by name on one path and not the other. - pub fn parts(&self) -> (&'static str, Vec) { + /// + /// The binary is a resolved path rather than a name, for the PATH reason at the top of this + /// file. The pty path needs that as much as this one: a sign-in that cannot find `podman` is + /// the same failure wearing a terminal. + pub fn parts(&self) -> (PathBuf, Vec) { let mut arguments = Vec::new(); if let Some(connection) = &self.connection { arguments.push("--connection".to_string()); arguments.push(connection.clone()); } - (self.engine.binary(), arguments) + ( + program(self.engine).unwrap_or_else(|| PathBuf::from(self.engine.binary())), + arguments, + ) } /// A command aimed at this engine, and the only way one should be built. + /// + /// The provider directory goes in front of the child's PATH rather than into `containers.conf`, + /// because that file belongs to whoever else may have configured it. pub fn command(&self) -> Command { let (binary, arguments) = self.parts(); let mut command = command(binary); command.args(arguments); + if let Some(dir) = tools_dir() { + command.env("PATH", path_with(dir)); + } command } @@ -129,7 +151,7 @@ pub struct EngineStatus { /// boots beside it. Ours is preferred among running machines only so that repeat launches settle on /// the same one. fn running_machine(preferred: &str) -> Option { - let output = command("podman") + let output = tool(Engine::Podman) .args(["machine", "list", "--format", "json"]) .output() .ok()?; @@ -152,12 +174,127 @@ struct MachineListing { running: bool, } -fn installed(binary: &str) -> bool { - command(binary) - .arg("--version") - .output() - .map(|out| out.status.success()) - .unwrap_or(false) +/// Where OpenBot keeps the engine tools it installed itself. +/// +/// Set once, at start-up, because the app knows its own cache directory and this module is called +/// from places that do not. Unset in tests and in any caller that never installed anything, which +/// is why every read tolerates its absence. +static TOOLS: OnceLock = OnceLock::new(); + +/// Tell this module where the tools OpenBot installed live. +pub fn tools_live_in(dir: PathBuf) { + let _ = TOOLS.set(dir); +} + +/// The directory holding OpenBot's own copy of the Compose provider, under a download directory. +pub fn tools_dir_under(downloads: &Path) -> PathBuf { + downloads.join("bin") +} + +fn tools_dir() -> Option<&'static PathBuf> { + TOOLS.get() +} + +/// A command that runs this engine's binary, wherever it actually is. +pub fn tool(engine: Engine) -> Command { + let mut built = command(program(engine).unwrap_or_else(|| PathBuf::from(engine.binary()))); + if let Some(dir) = tools_dir() { + built.env("PATH", path_with(dir)); + } + built +} + +/// This process's PATH with `first` in front of it. +/// +/// In front, so the provider OpenBot placed is the one found; appended, a broken `docker-compose` +/// earlier on PATH would still win. +fn path_with(first: &Path) -> OsString { + let mut joined = OsString::from(first); + if let Some(existing) = std::env::var_os("PATH") { + if !existing.is_empty() { + joined.push(if cfg!(windows) { ";" } else { ":" }); + joined.push(existing); + } + } + joined +} + +/// Where this engine's binary is, looking on PATH first and then where installers put it. +/// +/// PATH first, because somebody who installed it themselves may have put it anywhere and that +/// choice is theirs. The fixed places are the fallback for the session in which OpenBot installed +/// it, when this process's PATH is the one it started with. +pub fn program(engine: Engine) -> Option { + on_path(engine.binary()).or_else(|| where_installers_put(engine)) +} + +/// A PATH lookup done by looking, rather than by starting the program to see whether it runs. +/// +/// `command(...).output()` would answer this too, and is what this replaced. It also spawns a +/// process every time a command is built, and commands are built inside polling loops. +fn on_path(binary: &str) -> Option { + let filename = if cfg!(windows) { + format!("{binary}.exe") + } else { + binary.to_string() + }; + std::env::split_paths(&std::env::var_os("PATH")?) + .map(|dir| dir.join(&filename)) + .find(|candidate| candidate.is_file()) +} + +/// The fixed places each platform's installers use. +fn where_installers_put(engine: Engine) -> Option { + let places: Vec = match engine { + #[cfg(target_os = "windows")] + Engine::Podman => { + // The per-user MSI first: it is the one OpenBot runs. A machine-wide install left by + // somebody else is still found by the second. + [ + std::env::var_os("LOCALAPPDATA") + .map(|local| PathBuf::from(local).join("Programs\\Podman\\podman.exe")), + std::env::var_os("ProgramFiles") + .map(|files| PathBuf::from(files).join("RedHat\\Podman\\podman.exe")), + ] + .into_iter() + .flatten() + .collect() + } + #[cfg(target_os = "windows")] + Engine::Docker => std::env::var_os("ProgramFiles") + .map(|files| PathBuf::from(files).join("Docker\\Docker\\resources\\bin\\docker.exe")) + .into_iter() + .collect(), + #[cfg(target_os = "macos")] + Engine::Podman => [ + "/opt/podman/bin/podman", + "/opt/homebrew/bin/podman", + "/usr/local/bin/podman", + ] + .iter() + .map(PathBuf::from) + .collect(), + #[cfg(target_os = "macos")] + Engine::Docker => [ + "/usr/local/bin/docker", + "/opt/homebrew/bin/docker", + "/Applications/Docker.app/Contents/Resources/bin/docker", + ] + .iter() + .map(PathBuf::from) + .collect(), + #[cfg(target_os = "linux")] + Engine::Podman => ["/usr/bin/podman", "/usr/local/bin/podman"] + .iter() + .map(PathBuf::from) + .collect(), + #[cfg(target_os = "linux")] + Engine::Docker => ["/usr/bin/docker", "/usr/local/bin/docker"] + .iter() + .map(PathBuf::from) + .collect(), + }; + places.into_iter().find(|candidate| candidate.is_file()) } /// The rootless socket on Linux, which is the one Compose must mount. @@ -203,7 +340,7 @@ pub fn detect() -> EngineStatus { } for engine in [Engine::Docker, Engine::Podman] { - if installed(engine.binary()) { + if program(engine).is_some() { return EngineStatus { engine: Some(engine), address: None, @@ -222,7 +359,8 @@ pub fn detect() -> EngineStatus { address: None, responding: false, engine_socket: None, - detail: "No container engine found. Install Podman Desktop or Docker Desktop first.".into(), + // Not an instruction any more: OpenBot installs one. See `install.rs`. + detail: "No container engine yet.".into(), } } @@ -290,10 +428,55 @@ mod tests { } #[test] - fn docker_is_addressed_bare_because_it_has_one_daemon_and_no_connections() { + fn docker_is_addressed_with_no_connection_because_it_has_one_daemon() { let command = Address::new(Engine::Docker, None).command(); assert_eq!(command.get_args().count(), 0); - assert_eq!(command.get_program(), "docker"); + } + + /// The program is a path, not a name. This is the fix for an engine OpenBot has just installed + /// but this process's PATH does not know about, and asserting it here is the only place it is + /// visible without a machine that has no engine on it. + #[test] + fn an_engine_command_names_a_binary_rather_than_hoping_for_one_on_path() { + let (named, _) = Address::new(Engine::Podman, None).parts(); + let named = named.to_string_lossy().into_owned(); + assert_eq!( + named != "podman", + program(Engine::Podman).is_some(), + "addressed {named}, which does not match whether one was found" + ); + assert!( + named.ends_with("podman") || named.ends_with("podman.exe"), + "{named}" + ); + } + + /// A name that is nowhere still produces a runnable command, which is what keeps the "no engine + /// yet" screen reachable rather than a panic. + #[test] + fn a_binary_that_is_nowhere_is_absent_rather_than_guessed_at() { + assert_eq!(on_path("openbot-not-a-real-binary"), None); + } + + /// The provider directory has to be in *front* of PATH: a broken `docker-compose` earlier on + /// somebody's PATH would otherwise be the one Podman runs. + #[test] + fn the_provider_directory_goes_in_front_of_the_inherited_path() { + let ours = Path::new("/tmp/openbot-tools"); + let joined = path_with(ours); + let text = joined.to_string_lossy(); + assert!(text.starts_with("/tmp/openbot-tools"), "{text}"); + if let Some(existing) = std::env::var_os("PATH") { + assert!(text.ends_with(&*existing.to_string_lossy()), "{text}"); + } + } + + /// The tools live under the downloads they came from, so one install of the app has one place + /// for both and a retry finds what it already fetched. + #[test] + fn the_tools_live_under_the_downloads_they_came_from() { + let downloads = Path::new("/tmp/openbot-engine"); + assert_eq!(tools_dir_under(downloads), downloads.join("bin")); } #[test] diff --git a/desktop/src-tauri/src/harness.rs b/desktop/src-tauri/src/harness.rs index 65236751a..b0ece39d1 100644 --- a/desktop/src-tauri/src/harness.rs +++ b/desktop/src-tauri/src/harness.rs @@ -79,7 +79,8 @@ pub struct Harness { pub id: String, pub name: String, pub summary: String, - /// The image that speaks AG-UI, pinned by the release like every other image. + /// The published name of the image that speaks AG-UI. Resolved to a digest-pinned reference + /// through the release's manifest; see `crate::deployment::reference`. /// /// `None` only for the row where the person supplies the address. pub image: Option, @@ -142,7 +143,9 @@ pub fn catalogue() -> Vec { id: id.into(), name: name.into(), summary: summary.into(), - image: Some(format!("openbot-{directory}")), + // The manifest's own key, which is the directory the image is built from. `openbot-` is + // the published repository's prefix and belongs to the reference, not to this name. + image: Some(directory.to_string()), port: Some(port), health_path: Some("/health".into()), credential: Credential::AnyProvider, @@ -211,7 +214,7 @@ pub fn catalogue() -> Vec { id: "claude-agent-sdk".into(), name: "Claude Agent SDK".into(), summary: "Anthropic's own. The one that takes a Claude plan instead of a key.".into(), - image: Some("openbot-agent-claude-sdk".into()), + image: Some("agent-claude-sdk".into()), port: Some(4212), health_path: Some("/health".into()), credential: Credential::Anthropic, @@ -278,10 +281,11 @@ at a container nobody started — which looks like a broken Bot rather than a ba */ pub fn picked( id: Option<&str>, - // The release whose images these are. Tagged rather than bare: an untagged name means - // `:latest` to every engine, which is not a tag any release publishes, so the pull is refused - // and the person is shown a registry error about a repository that does exist. - version: &str, + // Where the deployment is, because the image reference is read from the manifest laid down + // beside it. A name built from a version was what this took before, and an unqualified name + // sends every engine to Docker Hub: the pull was refused there and the person was shown a + // registry permissions error for a repository that had never been pushed. + root: &std::path::Path, ) -> Result, String> { let Some(id) = id.map(str::trim).filter(|id| !id.is_empty()) else { return Ok(None); @@ -299,7 +303,7 @@ pub fn picked( }; let mastra = row.id == "mastra"; Ok(Some(crate::env::PickedHarness { - image: format!("{image}:{version}"), + image: crate::deployment::reference(root, &image)?, port, name: row.name, mastra, @@ -409,11 +413,8 @@ mod tests { // carry one, and the file is a flat list of quoted names. for harness in catalogue() { let Some(image) = harness.image else { continue }; - let component = image - .strip_prefix("openbot-") - .expect("a harness image is named openbot-"); assert!( - listed.contains(&format!("\"{component}\"")), + listed.contains(&format!("\"{image}\"")), "{} names image {image}, which no release publishes", harness.id ); @@ -454,69 +455,146 @@ mod tests { /// Bot rather than a pick that could not be honoured. #[test] fn an_unknown_id_is_refused_by_name() { - let refusal = picked(Some("not-a-real-harness"), "v0.0.0").expect_err("it was accepted"); + let refusal = + picked(Some("not-a-real-harness"), &std::env::temp_dir()).expect_err("it was accepted"); assert!(refusal.contains("not-a-real-harness"), "{refusal}"); } + /// A deployment whose manifest names every image in the catalogue, the way a release does. + /// + /// Written to a real directory because resolution reads the manifest from disk, which is the + /// behaviour under test: a fixture built in memory would not catch a path that is looked for in + /// the wrong place. + fn deployment_naming_everything(label: &str) -> std::path::PathBuf { + let root = + std::env::temp_dir().join(format!("openbot-harness-{label}-{}", std::process::id())); + std::fs::create_dir_all(&root).unwrap(); + let named: Vec = catalogue() + .into_iter() + .filter_map(|row| row.image) + .map(|image| { + format!( + "\"{image}\": {{ \"repository\": \"ghcr.io/copilotkit/openbot-{image}\", \ + \"digest\": \"sha256:abc\", \ + \"reference\": \"ghcr.io/copilotkit/openbot-{image}@sha256:abc\" }}" + ) + }) + .collect(); + std::fs::write( + crate::deployment::images_path(&root), + format!( + "{{ \"version\": \"v1.2.3\", \"images\": {{ {} }} }}", + named.join(", ") + ), + ) + .unwrap(); + root + } + /// Bringing your own address installs nothing, and that is not a failure. #[test] fn the_byo_row_resolves_to_nothing_without_complaint() { + let root = std::env::temp_dir(); assert_eq!( - picked(Some("byo-url"), "v0.0.0").expect("it was refused"), + picked(Some("byo-url"), &root).expect("it was refused"), None ); - assert_eq!(picked(None, "v0.0.0").expect("it was refused"), None); - assert_eq!(picked(Some(" "), "v0.0.0").expect("it was refused"), None); + assert_eq!(picked(None, &root).expect("it was refused"), None); + assert_eq!(picked(Some(" "), &root).expect("it was refused"), None); } /// A real row resolves to the image the release publishes and the port that image listens on. #[test] fn a_real_row_resolves_to_its_image_and_port() { - let crewai = picked(Some("crewai"), "v1.2.3") + let root = deployment_naming_everything("crewai"); + let crewai = picked(Some("crewai"), &root) .expect("refused") .expect("nothing"); - assert_eq!(crewai.image, "openbot-agent-crewai:v1.2.3"); + assert_eq!( + crewai.image, + "ghcr.io/copilotkit/openbot-agent-crewai@sha256:abc" + ); assert_eq!(crewai.port, 4202); assert!(!crewai.mastra); assert!(crewai.remote_agent_id.is_empty()); + let _ = std::fs::remove_dir_all(&root); } /// Mastra is dialled as Mastra and names the agent our image serves, because that endpoint is a /// roster and a Bot that names none gets the only one there or a refusal. #[test] fn mastra_resolves_as_mastra_and_names_its_agent() { - let mastra = picked(Some("mastra"), "v0.0.0") + let root = deployment_naming_everything("mastra"); + let mastra = picked(Some("mastra"), &root) .expect("refused") .expect("nothing"); assert!(mastra.mastra); assert_eq!(mastra.remote_agent_id, "openbot"); + let _ = std::fs::remove_dir_all(&root); } - /** - Every resolved image carries a tag, and this is the guard that was missing. + /// An image this release does not publish is named as that, rather than left to the engine. + /// + /// The failure it replaces: an unqualified name is looked up on Docker Hub, so a Bot whose + /// image was never pushed came back as "requested access to the resource is denied", which + /// reads as a credentials problem and sends somebody to fix permissions on a repository that + /// does not exist. + #[test] + fn a_bot_this_release_does_not_publish_is_named_rather_than_pulled() { + let root = std::env::temp_dir().join(format!("openbot-empty-{}", std::process::id())); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write( + crate::deployment::images_path(&root), + "{ \"version\": \"v1.2.3\", \"images\": {} }", + ) + .unwrap(); - Extracting this resolution out of `start_stack` dropped the version it used to append, so the - name reached `.env` bare. An engine reads a bare name as `:latest`, which no release publishes, - so `compose up` failed with a registry error about a repository that does exist — after the - deployment was laid down and the settings were written, at the last step before the stack came - up. Nothing caught it, because a name without a tag is a perfectly good string. + let refused = picked(Some("crewai"), &root).expect_err("it should be refused"); + assert!(refused.contains("agent-crewai"), "{refused}"); + assert!(refused.contains("v1.2.3"), "{refused}"); + assert!(!refused.contains("denied"), "{refused}"); + let _ = std::fs::remove_dir_all(&root); + } + + /** + Every resolved image names the registry it comes from, and this is the guard that was missing. + + THE SAME BUG THREE TIMES. First the names were built from the ids and matched nothing a release + publishes. Then the version stopped being appended, so an engine read the bare name as + `:latest`. Then the name was correct and tagged and still unqualified, so Podman resolved + `openbot-agent-langgraph-agui:v0.0.8` to `docker.io/library/...` and the person was told access + was denied. Each one is a perfectly good string, each one failed at the pull on a first run, and + the fix is that no reference is built here at all: they are read from the release's manifest. */ #[test] - fn every_resolved_image_carries_its_tag() { + fn every_resolved_image_names_the_registry_it_comes_from() { + let root = deployment_naming_everything("registry"); for row in catalogue() { if row.image.is_none() { continue; } - let resolved = picked(Some(&row.id), "v9.9.9") + let resolved = picked(Some(&row.id), &root) .expect("refused") .expect("nothing"); + let host = resolved + .image + .split('/') + .next() + .expect("a reference has at least one segment"); + assert!( + host.contains('.'), + "{} resolved to {}, which every engine looks up on Docker Hub", + row.id, + resolved.image + ); assert!( - resolved.image.ends_with(":v9.9.9"), + resolved.image.contains("@sha256:") || resolved.image.contains(':'), "{} resolved to {}, which an engine reads as :latest", row.id, resolved.image ); } + let _ = std::fs::remove_dir_all(&root); } /// A named mark has to be a file that is actually there. The failure this catches is silent at diff --git a/desktop/src-tauri/src/install.rs b/desktop/src-tauri/src/install.rs new file mode 100644 index 000000000..83dca50cd --- /dev/null +++ b/desktop/src-tauri/src/install.rs @@ -0,0 +1,567 @@ +//! Installing an engine, instead of telling somebody to go and get one. +//! +//! The person this app is for does not have a package manager, has never heard of Podman, and is +//! not going to read a release page. Every sentence that begins "install ..." is a step where an +//! install stops for good, so the engine is fetched and installed here. +//! +//! Two things are fetched, not one, and the second is the one that gets forgotten. Podman ships no +//! Compose implementation: `podman compose` looks for an external provider on PATH and, finding +//! none, answers with seven errors naming `docker-compose`. So a machine with a freshly installed +//! Podman still cannot raise the stack. Compose is a single static binary, which is why it can be +//! placed rather than installed. +//! +//! **Nothing fetched here is run unverified.** These files are executed, so each is pinned to the +//! digest of the release this was tested against, and a mismatch is refused rather than run. +//! Fetching a checksum from the same server that served the file would prove nothing. +//! +//! The three platforms install differently and only one of them is unattended: +//! +//! - **Windows.** The MSI is a per-user install, so it needs no elevation and lands in the profile +//! of whoever runs it. That is also the trap: run from a service or an elevated helper it lands +//! in `C:\Windows\system32\config\systemprofile`, where the person's own session cannot see it. +//! Measured, on Windows Server 2022, by installing it from a service and then watching the app +//! report no engine while `podman.exe` sat on disk. It has to run as them, which is where the app +//! already runs. +//! - **macOS.** The package writes to `/opt/podman` and needs administrator rights, so the person +//! sees one standard macOS authorization prompt. There is no way around that prompt and no reason +//! to want one: it is the same dialog every other installer raises. +//! - **Linux.** Podman there is not a binary but a set of them (`conmon`, `crun`, `netavark`, +//! `slirp4netns`), wired to the distribution's own paths, so downloading one file would produce +//! something that runs nothing. The distribution's package manager installs it, through +//! `pkexec`, which raises that desktop's own authorization prompt. + +use std::path::{Path, PathBuf}; + +use sha2::{Digest, Sha256}; + +use crate::engine::{self, Engine}; +use crate::problem::Problem; + +/// The releases this was tested against. +/// +/// Pinned for the reason the deployment pins image digests: a version is what somebody hopes is +/// there, a digest is what was run. Moving these means re-recording the digests below. +pub const PODMAN: &str = "6.1.1"; +pub const COMPOSE: &str = "5.5.1"; + +/// A file to fetch and the digest it has to have. +#[derive(Debug, PartialEq, Eq)] +pub struct Download { + pub url: String, + pub sha256: &'static str, + /// What to call it on disk. Named rather than taken from the URL so a redirect cannot choose + /// the filename. + pub file: &'static str, +} + +/// The Podman installer for this machine, or why there is not one. +/// +/// The Intel Mac case is real and not hypothetical: Podman 6.1.1 publishes `arm64` only. Answering +/// with the arm64 package there would install something that cannot run, so it says so instead. +pub fn podman_download() -> Result { + let (file, sha256) = match (std::env::consts::OS, std::env::consts::ARCH) { + ("windows", "x86_64") => ( + "podman-installer-windows-amd64.msi", + "91d0e8ea0846c0151d531c88c329bb2729387231e4d1e42306a8e3ae9d09fc8a", + ), + ("windows", "aarch64") => ( + "podman-installer-windows-arm64.msi", + "8ededac563c3b96abe55560f3379962ff59fd8bda1a185ed221891cb6ccf5cba", + ), + ("macos", "aarch64") => ( + "podman-installer-macos-arm64.pkg", + "9c7b90b406681e5458d69cdb1164a589f7c9b214cab1ca6705fe375876491c09", + ), + ("macos", _) => { + return Err(Problem::with( + "OpenBot cannot install the container engine on an Intel Mac. Install Podman \ + Desktop or Docker Desktop, then start OpenBot again.", + format!("Podman {PODMAN} publishes an arm64 package only"), + )) + } + ("linux", _) => { + return Err(Problem::plain( + "On Linux the engine comes from the distribution's own packages.", + )) + } + (os, arch) => { + return Err(Problem::with( + "OpenBot cannot install the container engine on this kind of computer.", + format!("no Podman installer for {os} on {arch}"), + )) + } + }; + Ok(Download { + url: format!( + "https://github.com/podman-container-tools/podman/releases/download/v{PODMAN}/{file}" + ), + sha256, + file, + }) +} + +/// The Compose provider for this machine. +/// +/// One static binary on every platform, which is the whole reason this can be placed beside the +/// engine rather than installed into the system. +pub fn compose_download() -> Result { + let (file, sha256) = match (std::env::consts::OS, std::env::consts::ARCH) { + ("windows", "x86_64") => ( + "docker-compose-windows-x86_64.exe", + "a3c0c73033eaede90210345d0cc2233edf4fab8fe0282a91dad8fd8436809d2f", + ), + ("windows", "aarch64") => ( + "docker-compose-windows-aarch64.exe", + "4bbb5d1ecc75bde1a9ca4afac43f5907c0d3bd0f88c7f00bf481ee7c8c1737be", + ), + ("macos", "x86_64") => ( + "docker-compose-darwin-x86_64", + "a264d61e824bf08a78867e59cdf32eb09f0aee9ecdf9f6ebfa43f76dc52880f1", + ), + ("macos", "aarch64") => ( + "docker-compose-darwin-aarch64", + "998735c9b6fe68a4f05895e6ea73d71ad06f9fc7046383ad89e47346781b6af5", + ), + ("linux", "x86_64") => ( + "docker-compose-linux-x86_64", + "db1889184726840f75c4f9c001048430d4f25b3be3cb084d3ddd762bc0aed576", + ), + ("linux", "aarch64") => ( + "docker-compose-linux-aarch64", + "732e3a84c1a0f67256ce80bc2598a24546b10ca05f9faa97efceb1171ece2ef7", + ), + (os, arch) => { + return Err(Problem::with( + "OpenBot cannot install the piece that runs the containers on this kind of \ + computer.", + format!("no Compose build for {os} on {arch}"), + )) + } + }; + Ok(Download { + url: format!("https://github.com/docker/compose/releases/download/v{COMPOSE}/{file}"), + sha256, + file, + }) +} + +/// Fetch to `into`, refusing anything whose digest is not the pinned one. +/// +/// A file already there with the right digest is kept, so a retry after a failed install is not a +/// second download. A file already there with the wrong one is replaced: that is a half-written +/// download far more often than it is an attack, and either way it must not be run. +fn fetch_verified(download: &Download, into: &Path) -> Result { + let path = into.join(download.file); + if let Ok(existing) = std::fs::read(&path) { + if digest_of(&existing) == download.sha256 { + return Ok(path); + } + } + + let body = crate::deployment::get(&download.url).map_err(|error| { + Problem::with( + "OpenBot could not download the software it needs to run. Check the internet \ + connection and try again.", + format!("{}: {error}", download.url), + ) + })?; + + let got = digest_of(&body); + if got != download.sha256 { + return Err(Problem::with( + "What OpenBot downloaded is not what it was expecting, so it has not been run. Try \ + again.", + format!( + "{} from {}: expected sha256 {}, got {got}", + download.file, download.url, download.sha256 + ), + )); + } + + std::fs::create_dir_all(into).map_err(|error| unwritable(into, &error.to_string()))?; + std::fs::write(&path, &body).map_err(|error| unwritable(&path, &error.to_string()))?; + Ok(path) +} + +/// One sentence for every "could not write here", because the person's fix is the same each time. +fn unwritable(path: &Path, error: &str) -> Problem { + Problem::with( + "OpenBot could not save the software it downloaded. Check there is free disk space and \ + try again.", + format!("{}: {error}", path.display()), + ) +} + +fn digest_of(bytes: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(bytes); + hasher + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +/// Put the engine on this machine, and a Compose it can run. +/// +/// Both halves, in that order, because the second is invisible until the first has succeeded and +/// somebody presses Start. Answers with the sentence for the step row, or a failure in both +/// registers. +pub fn install_engine(cache: &Path) -> Result { + let into = crate::acquire::download_dir(cache); + + // An engine somebody already has is theirs. This only ever adds what is missing. + if engine::program(Engine::Docker).is_some() || engine::program(Engine::Podman).is_some() { + return place_compose(&into); + } + + install_podman(&into)?; + + // Installed is not found. The MSI extends the *user's* PATH and this process was started with + // the old one, so the engine is looked for where the installer puts it rather than on PATH. If + // that lookup fails the install genuinely did nothing, and saying so beats a later screen + // reporting no engine on a machine that has just installed one. + if engine::program(Engine::Podman).is_none() { + return Err(Problem::with( + "OpenBot installed the container engine, but cannot find it afterwards. Install \ + Podman Desktop and start OpenBot again.", + format!( + "the {PODMAN} installer reported success; podman is on neither PATH nor any \ + install location this platform uses" + ), + )); + } + + place_compose(&into)?; + Ok(format!("Podman {PODMAN} and Compose {COMPOSE} installed.")) +} + +/// Place the Compose provider where the engine will find it, unless something already provides one. +/// +/// Nothing is placed when Compose already answers. Docker Desktop ships a provider, and a Linux +/// machine may have `docker-compose-v2` from its own packages; putting a second one in front of +/// either is a version somebody did not choose. +fn place_compose(into: &Path) -> Result { + if crate::acquire::address().composes() { + return Ok("Compose is already here.".into()); + } + + let download = compose_download()?; + let staged = fetch_verified(&download, into)?; + + let bin = engine::tools_dir_under(into); + std::fs::create_dir_all(&bin).map_err(|error| unwritable(&bin, &error.to_string()))?; + + // The name matters: Podman looks up a provider called `docker-compose`, so a binary called + // whatever the release asset was called is a provider nothing finds. + let named = bin.join(compose_provider_name()); + std::fs::copy(&staged, &named).map_err(|error| unwritable(&named, &error.to_string()))?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&named, std::fs::Permissions::from_mode(0o755)).map_err( + |error| { + Problem::with( + "OpenBot could not finish installing the piece that runs the containers.", + format!("chmod 755 {}: {error}", named.display()), + ) + }, + )?; + } + + Ok(format!("Compose {COMPOSE} installed.")) +} + +/// The filename Podman looks a provider up by. +pub fn compose_provider_name() -> &'static str { + if cfg!(windows) { + "docker-compose.exe" + } else { + "docker-compose" + } +} + +#[cfg(target_os = "windows")] +fn install_podman(into: &Path) -> Result<(), Problem> { + let download = podman_download()?; + let msi = fetch_verified(&download, into)?; + let log = into.join("podman-install.log"); + + match msiexec(&["/i"], &msi, &log) { + Ok(()) => return Ok(()), + // 1603 is "fatal error during installation", which is what Windows says when a product it + // still believes is installed cannot be repaired. Measured on a machine where a previous + // Podman had been removed by deleting its folder: the registration survived, so `/i` + // became a reconfigure, and the reconfigure had no source to read from. Somebody who once + // uninstalled Podman by dragging it to the bin arrives here. + Err(1603) => {} + Err(code) => return Err(installer_stopped(&msi, code, &log)), + } + + // Remove the registration, then install cleanly. `/x` does not need the original source, so + // it succeeds where the repair could not. + let _ = msiexec(&["/x"], &msi, &log); + msiexec(&["/i"], &msi, &log).map_err(|code| installer_stopped(&msi, code, &log)) +} + +/// Run msiexec quietly and answer with its exit code when it is not success. +/// +/// `/qn` and not `/passive`: a progress bar somebody cannot cancel is worse than the app's own +/// step, which says what is happening and can be retried. The log is kept because msiexec's exit +/// code alone does not say which action failed, and it is what turned 1603 into a diagnosis. +#[cfg(target_os = "windows")] +fn msiexec(verb: &[&str], msi: &Path, log: &Path) -> Result<(), i32> { + let output = crate::quiet::command("msiexec") + .args(verb) + .arg(msi) + .args(["/qn", "/norestart", "/l*v"]) + .arg(log) + .output() + .map_err(|_| -1)?; + if output.status.success() { + return Ok(()); + } + Err(output.status.code().unwrap_or(-1)) +} + +#[cfg(target_os = "windows")] +fn installer_stopped(msi: &Path, code: i32, log: &Path) -> Problem { + Problem::with( + "Installing the software OpenBot needs did not finish. Try again.", + format!( + "msiexec /i {} stopped with exit code {code}; its log is at {}", + msi.display(), + log.display() + ), + ) +} + +#[cfg(target_os = "macos")] +fn install_podman(into: &Path) -> Result<(), Problem> { + let download = podman_download()?; + let pkg = fetch_verified(&download, into)?; + + // The package writes to `/opt/podman`, which needs administrator rights. `do shell script ... + // with administrator privileges` is how macOS asks for them: the person sees the standard + // authorization dialog, and no password passes through this process. + let script = format!( + "do shell script \"/usr/sbin/installer -pkg {} -target /\" with administrator privileges", + applescript_shell_arg(&pkg) + ); + let output = crate::quiet::command("osascript") + .args(["-e", &script]) + .output() + .map_err(|error| { + Problem::with( + "OpenBot could not start the installer for the software it needs.", + format!("osascript: {error}"), + ) + })?; + + if output.status.success() { + return Ok(()); + } + let said = crate::quiet::said(&output.stderr); + // -128 is AppleScript's "user cancelled", which is a decision rather than a failure. + if said.contains("-128") { + return Err(Problem::plain( + "The install was cancelled, so OpenBot does not have the software it needs yet. Press \ + Start to try again.", + )); + } + Err(Problem::with( + "Installing the software OpenBot needs did not finish. Try again.", + said, + )) +} + +/// A path that has to survive being a shell word inside an AppleScript string. +/// +/// Two layers, applied in this order: single-quote it for the shell, then escape what AppleScript +/// treats as special in the double-quoted string that carries it. +#[cfg(target_os = "macos")] +fn applescript_shell_arg(path: &Path) -> String { + let quoted = format!("'{}'", path.to_string_lossy().replace('\'', "'\\''")); + quoted.replace('\\', "\\\\").replace('"', "\\\"") +} + +#[cfg(target_os = "linux")] +fn install_podman(_into: &Path) -> Result<(), Problem> { + let (manager, args) = linux_package_manager().ok_or_else(|| { + Problem::with( + "OpenBot cannot install the software it needs on this system. Install the `podman` \ + package, then start OpenBot again.", + "no apt-get, dnf, zypper or pacman in /usr/bin", + ) + })?; + + // `pkexec` rather than `sudo`: sudo on a desktop with no terminal has nowhere to ask for a + // password, and pkexec raises the desktop's own authorization dialog. + let output = crate::quiet::command("pkexec") + .arg(manager) + .args(args) + .arg("podman") + .output() + .map_err(|error| { + Problem::with( + "OpenBot could not start the installer for the software it needs.", + format!("pkexec {manager}: {error}"), + ) + })?; + + if output.status.success() { + return Ok(()); + } + // pkexec's own refusal. 126 is "not authorized", 127 is "dialog dismissed", and neither is a + // package manager that failed. + if matches!(output.status.code(), Some(126) | Some(127)) { + return Err(Problem::plain( + "The install was not allowed, so OpenBot does not have the software it needs yet. \ + Press Start to try again.", + )); + } + Err(Problem::with( + "Installing the software OpenBot needs did not finish. Try again.", + crate::quiet::said(&output.stderr), + )) +} + +/// The package manager this distribution uses, and the words for "install without asking". +#[cfg(target_os = "linux")] +fn linux_package_manager() -> Option<(&'static str, &'static [&'static str])> { + for (binary, args) in [ + ("apt-get", &["install", "-y"] as &[&str]), + ("dnf", &["install", "-y"]), + ("zypper", &["--non-interactive", "install"]), + ("pacman", &["-S", "--noconfirm"]), + ] { + if Path::new("/usr/bin").join(binary).exists() { + return Some((binary, args)); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Every platform this app runs on has a Compose build, or the stack cannot be raised there. + #[test] + fn this_platform_has_a_compose_build() { + let download = compose_download().expect("every supported platform has a Compose build"); + assert!(download.url.ends_with(download.file), "{download:?}"); + } + + #[test] + fn every_pinned_digest_is_a_lowercase_sha256() { + // The table is written by hand from each release's own checksums, and a digest with a typo + // in it fails on somebody else's machine at install time rather than here. + for download in [compose_download(), podman_download()] + .into_iter() + .flatten() + { + assert_eq!(download.sha256.len(), 64, "{download:?}"); + assert!( + download + .sha256 + .chars() + .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()), + "{download:?}" + ); + } + } + + /// The digest is compared, not merely computed. This is the check that stops a wrong file being + /// executed, so it is asserted against a published vector rather than trusted. + #[test] + fn the_digest_is_a_real_sha256() { + assert_eq!( + digest_of(b"abc"), + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); + } + + #[test] + fn a_file_whose_digest_is_wrong_is_never_returned_to_be_run() { + let dir = std::env::temp_dir().join(format!("openbot-digest-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("already-here"), b"not the pinned bytes").unwrap(); + + let wrong = Download { + // Unreachable on purpose: reaching it would mean the file on disk was accepted. + url: "http://127.0.0.1:1/never-reached".into(), + sha256: "0000000000000000000000000000000000000000000000000000000000000000", + file: "already-here", + }; + let refused = fetch_verified(&wrong, &dir).expect_err("a wrong digest must be refused"); + // Either half is acceptable here; what is not is a sentence that names a digest at the + // person, or a detail that has thrown the evidence away. + assert!(!refused.said.contains("sha256"), "{refused:?}"); + assert!(refused.detail.is_some(), "{refused:?}"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// A file already present with the pinned digest is not fetched again. The URL does not + /// resolve, so a fetch would fail rather than quietly succeed. + #[test] + fn a_file_already_here_with_the_right_digest_is_kept() { + let dir = std::env::temp_dir().join(format!("openbot-kept-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("kept"), b"abc").unwrap(); + + let pinned = Download { + url: "http://127.0.0.1:1/never-reached".into(), + sha256: "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + file: "kept", + }; + let path = fetch_verified(&pinned, &dir).expect("the file already here should be kept"); + assert_eq!(path, dir.join("kept")); + let _ = std::fs::remove_dir_all(&dir); + } + + /// Podman publishes an `arm64` package only, so an Intel Mac has to be told rather than handed + /// a package that cannot run. The message names what to do instead. + #[test] + #[cfg(target_os = "macos")] + fn an_intel_mac_is_told_rather_than_handed_a_package_that_cannot_run() { + if std::env::consts::ARCH == "x86_64" { + let refused = podman_download().expect_err("there is no Intel package"); + assert!(refused.said.contains("Podman Desktop"), "{refused:?}"); + assert!( + refused.detail.is_some_and(|d| d.contains("arm64")), + "the developer half should name why" + ); + } else { + let download = podman_download().expect("Apple silicon has a package"); + assert!(download.file.contains("arm64"), "{download:?}"); + } + } + + /// Podman looks a provider up by name, so this one is not negotiable. + #[test] + fn the_compose_provider_is_named_what_the_engine_looks_for() { + assert_eq!( + compose_provider_name(), + if cfg!(windows) { + "docker-compose.exe" + } else { + "docker-compose" + } + ); + } + + /// A path with a space in it is where naive quoting breaks, and the app's own cache directory + /// on Windows and macOS both have one. + #[test] + #[cfg(target_os = "macos")] + fn a_path_with_a_space_survives_both_layers_of_quoting() { + let quoted = applescript_shell_arg(Path::new("/Users/a b/Application Support/x.pkg")); + assert!(quoted.starts_with('\''), "{quoted}"); + assert!(quoted.contains("Application Support"), "{quoted}"); + assert!(!quoted.contains("\\\""), "{quoted}"); + } +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 923922b77..ebba4e037 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -5,6 +5,7 @@ pub mod deployment; pub mod engine; pub mod env; pub mod harness; +pub mod install; pub mod intelligence; pub mod plan; pub mod problem; diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index de1d5c4ad..1de4de91a 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -5,8 +5,8 @@ use std::path::{Path, PathBuf}; use std::sync::Mutex; use openbot_desktop_lib::{ - acquire, deployment, engine, env as openbot_env, harness, provider, quiet, stack, supervise, - windows as win, + acquire, deployment, engine, env as openbot_env, harness, install, problem::Problem, provider, + quiet, stack, supervise, windows as win, }; /// The deployment this app installs. @@ -99,32 +99,147 @@ fn windows_blocker_instruction(blocker: win::Blocker) -> String { /// Reported step by step rather than as one result, because these take minutes and a window with /// nothing moving in it reads as a hang. #[tauri::command] -async fn prepare_engine(app: tauri::AppHandle) -> Result { +async fn prepare_engine(app: tauri::AppHandle) -> Result { + engine_ready(&app).await?; + Ok(engine::detect()) +} + +/// An engine that can run a container: installed, its machine up, and answering. +/// +/// ONE function, because three screens need it and they used to disagree. Start installed and +/// created; both plan sign-ins only looked, and answered "No container engine is answering, so the +/// sign-in cannot run" on a machine whose whole setup exists to put one there. That sentence named +/// an obstacle and no way past it, on a screen where the way past it is ours to take. +/// +/// Reported step by step rather than as one result, because these take minutes and a window with +/// nothing moving in it reads as a hang. +async fn engine_ready(app: &tauri::AppHandle) -> Result { let found = engine::detect(); - if found.responding { - report(&app, "engine", true, found.detail.clone()); - return Ok(found); + if let Some(address) = found.address.clone().filter(|_| found.responding) { + report(app, "engine", true, found.detail.clone()); + return Ok(address); } + // Fetch and install an engine when there is none, and the Compose provider Podman ships + // without either way. Nobody is sent to a download page: see `install.rs`. + // + // On a blocking thread for the reason the deployment fetch is: a blocking HTTP client dropped + // inside an async context panics the worker instead of returning an error, and the window + // survives that with a step that never ends. + report( + app, + "install-engine", + true, + "Looking for the software OpenBot runs on.", + ); + let installed = + tauri::async_runtime::spawn_blocking(|| install::install_engine(&stack::default_root())) + .await + .map_err(|error| { + Problem::with( + "OpenBot could not install the software it needs. Try again.", + format!("the install task did not run: {error}"), + ) + })?; + match installed { + Ok(said) => report(app, "install-engine", true, said), + Err(problem) => { + report(app, "install-engine", false, problem.said.clone()); + return Err(problem); + } + } + + // One at a time, and each only if the last one worked. Written as a loop over an array once, + // which ran all three before the first was checked: a failed `machine init` was still followed + // by `machine start`. let created = acquire::create_machine(4, 6144, 60); - report(&app, "create-machine", created.ok, created.detail.clone()); + report(app, "create-machine", created.ok, created.said.clone()); if !created.ok { - return Err(created.detail); + return Err(created.problem()); } let started = acquire::start_machine(); - report(&app, "start-machine", started.ok, started.detail.clone()); + report(app, "start-machine", started.ok, started.said.clone()); if !started.ok { - return Err(started.detail); + return Err(started.problem()); } let gate = acquire::health_gate(&acquire::address()); - report(&app, "health-gate", gate.ok, gate.detail.clone()); + report(app, "health-gate", gate.ok, gate.said.clone()); if !gate.ok { - return Err(gate.detail); + return Err(gate.problem()); } - Ok(engine::detect()) + let ready = engine::detect(); + ready + .address + .clone() + .filter(|_| ready.responding) + .ok_or_else(|| { + Problem::with( + "OpenBot set up the software it runs on, but it is still not answering. Try again.", + ready.detail, + ) + }) +} + +/// The deployment on disk, fetched if it is not there or is the wrong version. +/// +/// Extracted from `start_stack` because Start is no longer the only thing that needs it: a plan +/// sign-in runs a published image, and the reference for that image is read from the manifest this +/// lays down. Skipped when the recorded version already matches, so a restart is not a download. +async fn deployment_ready(app: &tauri::AppHandle, root: &Path) -> Result<(), Problem> { + if deployment::needs_fetch(root, DEPLOYMENT_VERSION) { + report( + app, + "deployment", + true, + format!("fetching {DEPLOYMENT_VERSION}"), + ); + // On a blocking thread, not this one. A blocking HTTP client builds its own runtime, and + // dropping one inside an async context panics the worker rather than returning an error: + // "Cannot drop a runtime in a context where blocking is not allowed". The window survives + // that, which is worse than a crash, because the only symptom is a step that never ends. + let target = root.to_path_buf(); + tauri::async_runtime::spawn_blocking(move || { + deployment::fetch(&target, DEPLOYMENT_VERSION) + }) + .await + .map_err(|error| { + Problem::with( + "OpenBot could not download what it needs to run. Check the internet \ + connection and try again.", + format!("the download did not run: {error}"), + ) + })? + .inspect_err(|error| { + report(app, "deployment", false, error.clone()); + })?; + } + report( + app, + "deployment", + true, + format!("{DEPLOYMENT_VERSION} in {}", root.display()), + ); + Ok(()) +} + +/// The reference for an image the shell runs directly, rather than through Compose. +/// +/// The deployment first, because the manifest that names the image is part of it. A sign-in on a +/// machine that has never started the stack has no manifest yet, and building a name instead is +/// what sent Podman to Docker Hub. +async fn sign_in_image(app: &tauri::AppHandle, published: &str) -> Result { + let root = stack::default_root(); + deployment_ready(app, &root).await?; + deployment::reference(&root, published).map_err(|error| { + Problem::with( + "This version of OpenBot cannot sign in to that plan. Use an API key instead, or \ + update OpenBot.", + error, + ) + }) } /// What the model screen chose, as the window sends it. @@ -245,37 +360,18 @@ async fn start_stack( // Resolved from the catalogue rather than taken from the window: the image, the port and how // it is dialled are facts about the harness, and the window knowing them would be a second // list to keep in step. See `harness::picked` for what each refusal is for. - let picked = harness::picked(harness.as_deref(), DEPLOYMENT_VERSION)?; + // Two registers, because one of these refusals is about a release and the other is about a + // pick. "OpenBot v0.0.8 does not include agent-langgraph-agui" is the evidence, not the + // sentence: it names a published image, which is not a thing the person chose or can change. + let picked = harness::picked(harness.as_deref(), &root).map_err(|error| { + Problem::with( + "This version of OpenBot does not include the Bot you picked. Go back and choose \ + another, or update OpenBot.", + error, + ) + })?; - // The installer does not carry the deployment; it fetches one. Skipped when the recorded - // version already matches, so a restart is not a download. - if deployment::needs_fetch(&root, DEPLOYMENT_VERSION) { - report( - &app, - "deployment", - true, - format!("fetching {DEPLOYMENT_VERSION}"), - ); - // On a blocking thread, not this one. A blocking HTTP client builds its own runtime, and - // dropping one inside an async context panics the worker rather than returning an error: - // "Cannot drop a runtime in a context where blocking is not allowed". The window survives - // that, which is worse than a crash, because the only symptom is a step that never ends. - let target = root.clone(); - tauri::async_runtime::spawn_blocking(move || { - deployment::fetch(&target, DEPLOYMENT_VERSION) - }) - .await - .map_err(|error| format!("the download did not run: {error}"))? - .inspect_err(|error| { - report(&app, "deployment", false, error.clone()); - })?; - } - report( - &app, - "deployment", - true, - format!("{DEPLOYMENT_VERSION} in {}", root.display()), - ); + deployment_ready(&app, &root).await?; // Belt and braces: a fetch that reported success and left something out is still not a // deployment, and Compose's own error would not say which part was missing. @@ -773,22 +869,28 @@ fn harnesses() -> Vec { /// Blocking work on a blocking thread: it starts a container and waits on its output, and doing /// that on the UI thread is a window that stops repainting mid-setup. #[tauri::command] -async fn begin_claude_sign_in(app: tauri::AppHandle) -> Result { +async fn begin_claude_sign_in(app: tauri::AppHandle) -> Result { /* * The image is decided here, not by the window, and it is the Claude Agent SDK harness whatever * harness the person picked. It is not being used as a Bot: it is the container that happens to * carry Anthropic's bundled CLI, which is what does the OAuth. Letting the screen name an image * would make the sign-in depend on a choice that has nothing to do with it. */ - let image = openbot_desktop_lib::plan::SIGN_IN_IMAGE.to_string(); - let address = engine::detect().address.ok_or_else(|| { - "No container engine is answering, so the sign-in cannot run.".to_string() - })?; + // Set up rather than refused. The sign-in runs in a container, so it needs the same engine + // Start needs and the same deployment Start needs, and on a first run nothing has fetched or + // installed either yet. + let address = engine_ready(&app).await?; + let image = sign_in_image(&app, openbot_desktop_lib::plan::SIGN_IN_IMAGE).await?; let (signing, url) = tauri::async_runtime::spawn_blocking(move || { openbot_desktop_lib::plan::SigningIn::begin(&address, &image) }) .await - .map_err(|error| format!("The sign-in did not run: {error}"))??; + .map_err(|error| { + Problem::with( + "The sign-in did not start. Try again.", + format!("the sign-in task did not run: {error}"), + ) + })??; *app.state::().signing_in.lock().unwrap() = Some(signing); /* @@ -829,18 +931,18 @@ async fn finish_claude_sign_in(app: tauri::AppHandle, code: String) -> Result Result { - let address = engine::detect().address.ok_or_else(|| { - openbot_desktop_lib::problem::Problem::plain( - "No container engine is answering, so the sign-in cannot run.", - ) - })?; - let image = openbot_desktop_lib::plan::CHATGPT_SIGN_IN_IMAGE.to_string(); + // Set up rather than refused: see `engine_ready`. + let address = engine_ready(&app).await?; + let image = sign_in_image(&app, openbot_desktop_lib::plan::CHATGPT_SIGN_IN_IMAGE).await?; let (signing, url) = tauri::async_runtime::spawn_blocking(move || { openbot_desktop_lib::plan::SigningInToChatGpt::begin(&address, &image) }) .await .map_err(|error| { - openbot_desktop_lib::problem::Problem::plain(format!("The sign-in did not run: {error}")) + Problem::with( + "The sign-in did not start. Try again.", + format!("the sign-in task did not run: {error}"), + ) })??; *app.state::().signing_in_to_chatgpt.lock().unwrap() = Some(signing); let _ = tauri_plugin_opener::OpenerExt::opener(&app).open_url(&url, None::<&str>); @@ -1197,6 +1299,12 @@ fn main() { } }) .setup(|app| { + // Where the Compose provider OpenBot installs itself lives, told once so every engine + // command can put it on the child's PATH. Before anything asks for an engine. + engine::tools_live_in(engine::tools_dir_under(&acquire::download_dir( + &stack::default_root(), + ))); + if let Some(window) = app.get_webview_window("main") { // Asked before anything navigates away from it. *app.state::().setup_url.lock().unwrap() = Some(window.url()?.to_string()); diff --git a/desktop/src-tauri/src/plan.rs b/desktop/src-tauri/src/plan.rs index 737020046..2fef5cf60 100644 --- a/desktop/src-tauri/src/plan.rs +++ b/desktop/src-tauri/src/plan.rs @@ -22,12 +22,16 @@ use std::time::{Duration, Instant}; use portable_pty::{native_pty_system, CommandBuilder, PtySize}; -/// The image whose bundled CLI runs the sign-in. +/// The published name of the image whose bundled CLI runs the sign-in. /// /// The Claude Agent SDK harness, used here as a tool rather than as a Bot: it is simply the image -/// that carries Anthropic's own CLI, so nothing has to be installed on the person's machine. Pinned -/// by the release like every other image; the tag here is what a development tree builds. -pub const SIGN_IN_IMAGE: &str = "openbot-harness-claude-sdk:test"; +/// that carries Anthropic's own CLI, so nothing has to be installed on the person's machine. +/// +/// A NAME, NOT A REFERENCE. This was `openbot-harness-claude-sdk:test`, which is what a development +/// tree builds: it resolved locally on the machine it was written on and, on a machine that had +/// never built anything, sent Podman to `docker.io/library/openbot-harness-claude-sdk`. Resolved +/// through the release's manifest by `crate::deployment::reference`, like every other image. +pub const SIGN_IN_IMAGE: &str = "agent-claude-sdk"; /// Where the SDK keeps the binary it bundles. /// @@ -429,12 +433,14 @@ impl SigningIn { } } -/// The image whose `langchain-openai` runs the ChatGPT sign-in. +/// The published name of the image whose `langchain-openai` runs the ChatGPT sign-in. /// /// The LangGraph harness, used as a tool rather than as a Bot for the same reason the Claude one is: /// it is the image that already carries the vendor's own login. It is also the default harness, so /// on the common path this image is being pulled anyway. -pub const CHATGPT_SIGN_IN_IMAGE: &str = "openbot-agent-langgraph-agui:v0.0.8"; +/// +/// A name, not a reference: see `SIGN_IN_IMAGE`. +pub const CHATGPT_SIGN_IN_IMAGE: &str = "agent-langgraph-agui"; /// Where the vendor's login persists what it gets. const CHATGPT_STORE: &str = "/root/.langchain/chatgpt-auth.json"; diff --git a/desktop/src/App.tsx b/desktop/src/App.tsx index 8a05588de..032f4dfe5 100644 --- a/desktop/src/App.tsx +++ b/desktop/src/App.tsx @@ -336,17 +336,18 @@ export function App() { ? "The stack is up. OpenBot is in this window; the menu bar has it too, and stops it." : engine?.responding ? `Using ${engine.engine === "docker" ? "Docker" : "Podman"}. It is answering, so nothing needs installing.` - : /* The backend already worked out which of these it is, and says so: "podman is - installed but not answering" when the binary is there, "no container engine - found" when it is not. Repeating a fixed sentence here threw that away and told - somebody with Podman 6.1.1 on their PATH to go and install Podman, which is the - one thing they had already done. Its sentence, not ours. + : /* Two states, and only one of them is somebody's to act on. - Not "OpenBot will install Podman" either: nothing here installs an engine. The - step exists in the enum and no function fills it. It creates the machine, which - is the part that is built. */ - (engine?.detail ?? - "No container engine is answering yet. Install Podman Desktop or Docker Desktop, then start OpenBot again.")} + An engine that is there but not running is theirs: the backend says "podman is + installed but not answering", and that is the sentence to show. Repeating a fixed + one here threw that away and told somebody with Podman 6.1.1 on their PATH to go + and install Podman, which was the one thing they had already done. + + No engine at all is ours. Start installs one, so this says so rather than sending + somebody to a download page they were never going to read. */ + engine?.engine + ? engine.detail + : "OpenBot needs one more piece of software to run, and installs it for you. Press Start."}

{!running && ( @@ -525,6 +526,8 @@ function titleFor(blocker: Blocker): string { function label(step: string): string { switch (step) { + case "install-engine": + return "Container engine"; case "create-machine": return "Engine machine"; case "start-machine": From 9f087f2a94d17ab397954bf576953c467e86d22b Mon Sep 17 00:00:00 2001 From: David McKay Date: Mon, 7 Sep 2026 22:23:28 -0700 Subject: [PATCH 38/46] Let an endpoint that needs no key be connected The compatible row refused to continue without an API key, and its own summary names Ollama and vLLM. Neither has one. So the two examples the screen offers by name were the two it would not accept, and the way out was to invent a key and hope the endpoint ignored it. An address and a model name are what that row needs. The Rust side already treated the key as optional and writes OPENAI_API_KEY only when it is given, so the refusal lived entirely in the screen. The field says what it is now rather than leaving somebody to find out by being stuck. A failure also belongs to the row that produced it. A refused OpenAI sign-in stayed on screen after switching to the endpoint row, underneath the address just typed, where it read as a complaint about that address. Found by driving the screen on Windows. --- desktop/src/ProviderPicker.tsx | 41 ++++++++++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/desktop/src/ProviderPicker.tsx b/desktop/src/ProviderPicker.tsx index 434d503ef..f011d3458 100644 --- a/desktop/src/ProviderPicker.tsx +++ b/desktop/src/ProviderPicker.tsx @@ -1,4 +1,5 @@ import { invoke } from "@tauri-apps/api/core"; +import { listen } from "@tauri-apps/api/event"; import { asProblem, InlineFailure, type Problem } from "./Problem"; import { useEffect, useState } from "react"; import { Mark } from "./Mark"; @@ -71,6 +72,14 @@ export function ProviderPicker({ const [code, setCode] = useState(""); const [token, setToken] = useState(chosen?.token ?? ""); const [busy, setBusy] = useState(false); + /* + * What the sign-in is doing, while it is doing it. + * + * A plan sign-in runs in a container, so on a first run it installs the engine and boots its + * machine first, which is minutes. "Starting…" for that long is a hang as far as anybody + * watching is concerned, so the same steps the setup screen lists are shown here as one line. + */ + const [progress, setProgress] = useState(null); // A problem, not a string: a sign-in failure carries the container's own output, and // stringifying it printed "[object Object]" where the diagnosis should have been. const [failure, setFailure] = useState(null); @@ -86,6 +95,7 @@ export function ProviderPicker({ if (!row) return; setBusy(true); setFailure(null); + setProgress(null); try { const start = row.id === "anthropic" @@ -102,6 +112,7 @@ export function ProviderPicker({ setSignInUrl(null); } finally { setBusy(false); + setProgress(null); } } @@ -128,6 +139,18 @@ export function ProviderPicker({ .catch(() => undefined); }, []); + // The same event the setup screen's step list is built from. Only the newest line is kept: this + // is one sentence under a button, not a second copy of that list. + useEffect(() => { + const stop = listen<{ step: string; ok: boolean; detail: string }>( + "setup:progress", + (event) => setProgress(event.payload.detail), + ); + return () => { + stop.then((off) => off()); + }; + }, []); + const row = rows.find((r) => r.id === open) ?? null; // What "done" means differs by the way in, and each is checked before Continue lights up rather @@ -135,9 +158,14 @@ export function ProviderPicker({ const ready = (login === "plan" && token.trim().length > 0) || (login === "api-key" && apiKey.trim().length > 0) || + /* + * An endpoint needs an address and a model name. NOT A KEY: this row's own summary names + * Ollama and vLLM, and neither has one, so requiring a key refused the two examples the screen + * offers. The Rust side already treats it as optional and writes `OPENAI_API_KEY` only when it + * is given. + */ (login === "endpoint" && baseUrl.trim().startsWith("http") && - apiKey.trim().length > 0 && model.trim().length > 0); return ( @@ -163,6 +191,10 @@ export function ProviderPicker({ checked={open === r.id} onChange={() => { setOpen(r.id); + // A failure belongs to the row that produced it. Left in place, a refused OpenAI + // sign-in stayed on screen under the endpoint row's fields, where it read as a + // complaint about the address just typed. + setFailure(null); // The first way in is the default, which is the plan wherever there is one. setLogin(r.logins[0] ?? null); // Fill from what is already on this machine, if anything. @@ -276,6 +308,11 @@ export function ProviderPicker({ + {busy && progress && ( +

+ {progress} +

+ )} ))} @@ -316,7 +353,7 @@ export function ProviderPicker({ />
- + Date: Tue, 8 Sep 2026 09:51:08 -0700 Subject: [PATCH 39/46] Let a Bot answer from an endpoint that needs no key Making the compatible-endpoint row accept a blank key fixed one end of that feature and exposed the other. Both bundled Bots refuse to start without OPENAI_API_KEY, so somebody who filled in an address for an Ollama or a vLLM got two dead containers complaining about a key their own server does not have. The row's summary names Ollama and vLLM by name; they were the two cases it would not serve. A base URL is a model, and its key belongs to it. Set, it means any endpoint speaking that API, which is what the variable's own comment has always said, so the startup check now asks for a key only when nothing else was named. Plain OpenAI still refuses without one, which is the case the check was written for, and the other two providers have no base URL to be named by so neither changes. The SDK insists on a string even when the endpoint ignores it, so a named endpoint with no key is handed a placeholder rather than a client that cannot be constructed. The decision is a module in each Bot rather than a condition at module scope, because index.ts serves as it loads and a test cannot import it without binding a port. Same reason model-options.ts exists. Also the model name now reaches the bundled Bot. docker-compose.yml reads AGENT_BOT_MODEL for agent-bot, not BOT_MODEL, so that a model chosen for the framework Bot cannot silently take its tools away: that Bot writes /v1/chat/completions by hand and gpt-5.6-* rejects function tools there. The reasoning is about OpenAI's own catalogue and does not survive a custom endpoint, where the pin asked the person's own server for a gpt-5.5 it has never heard of. The name they typed is written to both, and cleared from both when they answer with something that names no model. --- agent-bot/src/index.ts | 18 +++++++++-- agent-bot/src/model-key.ts | 26 +++++++++++++++ agent-bot/tests/model-key.test.ts | 28 +++++++++++++++++ agent-langgraph/src/index.ts | 16 ++++------ agent-langgraph/src/model-key.ts | 42 +++++++++++++++++++++++++ agent-langgraph/tests/model-key.test.ts | 34 ++++++++++++++++++++ desktop/src-tauri/src/env.rs | 38 +++++++++++++++++++--- desktop/src-tauri/src/main.rs | 2 +- 8 files changed, 185 insertions(+), 19 deletions(-) create mode 100644 agent-bot/src/model-key.ts create mode 100644 agent-bot/tests/model-key.test.ts create mode 100644 agent-langgraph/src/model-key.ts create mode 100644 agent-langgraph/tests/model-key.test.ts diff --git a/agent-bot/src/index.ts b/agent-bot/src/index.ts index c6974fffc..6be314ed0 100644 --- a/agent-bot/src/index.ts +++ b/agent-bot/src/index.ts @@ -5,6 +5,7 @@ import OpenAI from "openai"; import { hasManagedAgentToken } from "../../shared/agent-authorisation"; import { listenPort } from "../../shared/listen-port"; import { toProviderMessages } from "./history"; +import { apiKeyOrPlaceholder, keyIsRequired } from "./model-key"; /** * The built-in Bot is an AG-UI HTTP service registered the same way as any customer-provided Bot. @@ -80,15 +81,26 @@ const BASE_URL = process.env.OPENAI_BASE_URL?.trim() || undefined; * should fail in front of whoever is deploying, not in front of whoever is asking. */ const API_KEY = process.env.OPENAI_API_KEY?.trim(); -if (!API_KEY) { +/* + * UNLESS AN ENDPOINT WAS NAMED, in which case the endpoint is the model and the key belongs to it. + * + * Ollama, vLLM, LM Studio and llama.cpp all serve this API with no key at all, and the setup window + * offers exactly those by name. Requiring one here refused the whole keyless half of that feature: + * the person filled in an address, the app raised this Bot, and it exited on startup with + * "OPENAI_API_KEY is not set" about a key their endpoint does not have. The two ends of one feature + * disagreeing. + * + * The check still holds for plain OpenAI, which is the case it was written for. + */ +if (!API_KEY && keyIsRequired(BASE_URL)) { console.error( - "OPENAI_API_KEY is not set. This Bot cannot answer without a model.", + "OPENAI_API_KEY is not set, and no OPENAI_BASE_URL names an endpoint that needs no key. This Bot cannot answer without a model.", ); process.exit(1); } const openai = new OpenAI({ - apiKey: API_KEY, + apiKey: apiKeyOrPlaceholder(API_KEY), baseURL: BASE_URL, }); diff --git a/agent-bot/src/model-key.ts b/agent-bot/src/model-key.ts new file mode 100644 index 000000000..00e2284c6 --- /dev/null +++ b/agent-bot/src/model-key.ts @@ -0,0 +1,26 @@ +/** + * Whether this Bot needs a model key, checked before it starts. + * + * Its own module because `index.ts` serves at module scope, so importing it to reach one pure + * function binds a port. + */ + +/** + * A key is required unless an endpoint was named to answer instead. + * + * `OPENAI_BASE_URL` set means any endpoint speaking that API, and Ollama, vLLM, LM Studio and + * llama.cpp all serve it with no key. The setup window offers exactly those by name and accepts a + * blank key for them, so requiring one here exited this Bot on startup for every one of them. + */ +export function keyIsRequired(baseUrl: string | undefined): boolean { + return !baseUrl?.trim(); +} + +/** + * What to hand the SDK, which insists on a string even when the endpoint ignores it. + * + * A placeholder rather than an empty string: empty is a client that cannot be constructed. + */ +export function apiKeyOrPlaceholder(apiKey: string | undefined): string { + return apiKey?.trim() || "no-key-needed"; +} diff --git a/agent-bot/tests/model-key.test.ts b/agent-bot/tests/model-key.test.ts new file mode 100644 index 000000000..7ba3f7015 --- /dev/null +++ b/agent-bot/tests/model-key.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from "bun:test"; +import { apiKeyOrPlaceholder, keyIsRequired } from "../src/model-key"; + +/** + * A named endpoint is a model, and its key belongs to it. + * + * The failure this pins: the setup window's "any OpenAI-compatible endpoint" row takes an address + * with no key, because Ollama and vLLM have none. This Bot then refused to start, saying + * OPENAI_API_KEY was not set, so the keyless half of that feature produced a dead container and a + * red line on the last screen about a key the person's own server does not have. + */ +describe("whether a model key is required", () => { + test("plain OpenAI still needs its key", () => { + expect(keyIsRequired(undefined)).toBe(true); + expect(keyIsRequired("")).toBe(true); + expect(keyIsRequired(" ")).toBe(true); + }); + + test("an endpoint named instead of OpenAI answers without one", () => { + expect(keyIsRequired("http://127.0.0.1:11434/v1")).toBe(false); + }); + + test("the SDK is always handed a string", () => { + expect(apiKeyOrPlaceholder(undefined)).toBe("no-key-needed"); + expect(apiKeyOrPlaceholder(" ")).toBe("no-key-needed"); + expect(apiKeyOrPlaceholder("sk-real")).toBe("sk-real"); + }); +}); diff --git a/agent-langgraph/src/index.ts b/agent-langgraph/src/index.ts index 9704cdb47..a4b82fa57 100644 --- a/agent-langgraph/src/index.ts +++ b/agent-langgraph/src/index.ts @@ -15,6 +15,7 @@ import { hasManagedAgentToken } from "../../shared/agent-authorisation"; import { listenPort } from "../../shared/listen-port"; import { toLangChainMessages } from "./history"; import { readReasoningEffort } from "./model-options"; +import { apiKeyOrPlaceholder, KEY_VARIABLE, keyIsRequired } from "./model-key"; import { streamRun } from "./stream"; /** @@ -152,12 +153,6 @@ function defaultModelFor(provider: string): string { * a missing key should fail in front of whoever is deploying, not as a conversation that errors in * front of somebody trying to use it. */ -const KEY_VARIABLE: Record = { - openai: "OPENAI_API_KEY", - anthropic: "ANTHROPIC_API_KEY", - google: "GOOGLE_API_KEY", -}; - const keyVariable = KEY_VARIABLE[PROVIDER]; if (!keyVariable) { console.error( @@ -166,7 +161,8 @@ if (!keyVariable) { process.exit(1); } const API_KEY = process.env[keyVariable]?.trim(); -if (!API_KEY) { +// Unless an endpoint was named to answer instead: see `keyIsRequired`. +if (!API_KEY && keyIsRequired(PROVIDER, OPENAI_BASE_URL)) { console.error( `${keyVariable} is not set, and BOT_PROVIDER=${PROVIDER} needs it. This Bot cannot answer without a model.`, ); @@ -200,7 +196,7 @@ function buildModel() { if (PROVIDER === "anthropic") { return new ChatAnthropic({ model: MODEL, - apiKey: API_KEY, + apiKey: apiKeyOrPlaceholder(API_KEY), streaming: true, ...(ANTHROPIC_BASE_URL ? { anthropicApiUrl: ANTHROPIC_BASE_URL } : {}), }); @@ -208,14 +204,14 @@ function buildModel() { if (PROVIDER === "google") { return new ChatGoogleGenerativeAI({ model: MODEL, - apiKey: API_KEY, + apiKey: apiKeyOrPlaceholder(API_KEY), streaming: true, ...(GOOGLE_BASE_URL ? { baseUrl: GOOGLE_BASE_URL } : {}), }); } return new ChatOpenAI({ model: MODEL, - apiKey: API_KEY, + apiKey: apiKeyOrPlaceholder(API_KEY), streaming: true, ...(OPENAI_BASE_URL ? { configuration: { baseURL: OPENAI_BASE_URL } } : {}), ...(USE_RESPONSES_API ? { useResponsesApi: true } : {}), diff --git a/agent-langgraph/src/model-key.ts b/agent-langgraph/src/model-key.ts new file mode 100644 index 000000000..4bc6aa367 --- /dev/null +++ b/agent-langgraph/src/model-key.ts @@ -0,0 +1,42 @@ +/** + * Whether this Bot needs a model key, checked before it starts. + * + * Its own module for the reason `model-options.ts` is: `index.ts` calls `serve()` at module scope, + * so importing it to reach one pure function binds a port. + */ + +/** The environment variable each provider's key arrives in. */ +export const KEY_VARIABLE: Record = { + openai: "OPENAI_API_KEY", + anthropic: "ANTHROPIC_API_KEY", + google: "GOOGLE_API_KEY", +}; + +/** + * A key is required unless an endpoint was named to answer instead. + * + * `OPENAI_BASE_URL` set means any endpoint speaking that API, and Ollama, vLLM, LM Studio and + * llama.cpp all serve it with no key at all. The setup window offers exactly those by name and + * accepts a blank key for them, so requiring one here exited this Bot on startup for every one of + * them: the person filled in an address and got a dead container complaining about a key their + * server does not have. Two ends of one feature disagreeing. + * + * Only the OpenAI branch has a base URL to be named by, so nothing changes for the other two. + */ +export function keyIsRequired( + provider: string, + baseUrl: string | undefined, +): boolean { + const named = provider === "openai" && Boolean(baseUrl?.trim()); + return !named; +} + +/** + * What to hand the SDK, which insists on a string even when the endpoint ignores it. + * + * A placeholder rather than an empty string: empty is a client that cannot be constructed, and the + * value is never sent anywhere that reads it. + */ +export function apiKeyOrPlaceholder(apiKey: string | undefined): string { + return apiKey?.trim() || "no-key-needed"; +} diff --git a/agent-langgraph/tests/model-key.test.ts b/agent-langgraph/tests/model-key.test.ts new file mode 100644 index 000000000..cd6eff0f6 --- /dev/null +++ b/agent-langgraph/tests/model-key.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from "bun:test"; +import { apiKeyOrPlaceholder, keyIsRequired } from "../src/model-key"; + +/** + * A named endpoint is a model, and its key belongs to it. + * + * The failure this pins: the setup window's "any OpenAI-compatible endpoint" row takes an address + * with no key, because Ollama and vLLM have none. This Bot then refused to start, saying + * OPENAI_API_KEY was not set, so the whole keyless half of that feature produced a dead container. + */ +describe("whether a model key is required", () => { + test("plain OpenAI still needs its key", () => { + expect(keyIsRequired("openai", undefined)).toBe(true); + expect(keyIsRequired("openai", "")).toBe(true); + expect(keyIsRequired("openai", " ")).toBe(true); + }); + + test("an endpoint named instead of OpenAI answers without one", () => { + expect(keyIsRequired("openai", "http://127.0.0.1:11434/v1")).toBe(false); + }); + + /** Neither of the other providers has a base URL to be named by, so neither changes. */ + test("anthropic and google are unchanged", () => { + expect(keyIsRequired("anthropic", "http://127.0.0.1:11434/v1")).toBe(true); + expect(keyIsRequired("google", "http://127.0.0.1:11434/v1")).toBe(true); + }); + + /** The SDK cannot be constructed with an empty string, so there is always something to pass. */ + test("the SDK is always handed a string", () => { + expect(apiKeyOrPlaceholder(undefined)).toBe("no-key-needed"); + expect(apiKeyOrPlaceholder(" ")).toBe("no-key-needed"); + expect(apiKeyOrPlaceholder("sk-real")).toBe("sk-real"); + }); +}); diff --git a/desktop/src-tauri/src/env.rs b/desktop/src-tauri/src/env.rs index 96a51e91c..68608baa3 100644 --- a/desktop/src-tauri/src/env.rs +++ b/desktop/src-tauri/src/env.rs @@ -164,7 +164,9 @@ pub fn compose( * be passed through as a model named "", which is a worse question to ask a provider. */ if !matches!(model.credential, ModelCredential::Compatible { .. }) { - env.remove("BOT_MODEL"); + for key in ["BOT_MODEL", "AGENT_BOT_MODEL"] { + env.remove(key); + } } } match &model.credential { @@ -207,6 +209,18 @@ pub fn compose( insert_if_given(&mut env, "OPENAI_API_KEY", api_key); insert_if_given(&mut env, "OPENAI_BASE_URL", base_url); insert_if_given(&mut env, "BOT_MODEL", name); + /* + * The bundled Bot's own model variable, set to the same name. + * + * It has one because it hand-writes `/v1/chat/completions`, where `gpt-5.6-*` rejects + * function tools, so `docker-compose.yml` pins it to `gpt-5.5` rather than letting the + * framework Bot's choice take its tools away. That reasoning is about OpenAI's own + * models and does not survive a custom endpoint: `gpt-5.5` is not in the catalogue of + * an Ollama or a vLLM, so the pin asked somebody's own server for a model it has never + * heard of. The person named exactly one model on that screen and meant it for + * whichever Bot answers. + */ + insert_if_given(&mut env, "AGENT_BOT_MODEL", name); } } @@ -1220,6 +1234,18 @@ mod model_tests { compatible.get("BOT_MODEL"), Some(&"local-model".to_string()) ); + /* + * And the bundled Bot's own variable, which is the one that was missed. + * + * `docker-compose.yml` reads `AGENT_BOT_MODEL` for `agent-bot` rather than `BOT_MODEL`, so + * that a model chosen for the framework Bot cannot take its tools away. On a custom + * endpoint that pin asked somebody's own server for `gpt-5.5`, which an Ollama or a vLLM + * has never heard of. + */ + assert_eq!( + compatible.get("AGENT_BOT_MODEL"), + Some(&"local-model".to_string()) + ); let with_a_key = compose( &intelligence(), @@ -1234,10 +1260,12 @@ mod model_tests { None, &BTreeMap::new(), ); - assert!( - !with_a_key.contains_key("BOT_MODEL"), - "a key path carried a model name it never chose" - ); + for key in ["BOT_MODEL", "AGENT_BOT_MODEL"] { + assert!( + !with_a_key.contains_key(key), + "a key path carried a model name it never chose: {key}" + ); + } } /// Switching provider does not leave the last one's key behind. diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index 1de4de91a..a6257131d 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -430,7 +430,7 @@ async fn start_stack( * had given. Anything the writer owns and did not produce this time is taken out. */ let mut purge = secrets.clone(); - for key in ["BOT_MODEL"] { + for key in ["BOT_MODEL", "AGENT_BOT_MODEL"] { if !settings.contains_key(key) { purge.insert(key.into(), String::new()); } From b280799522740bb078549058b24ddbda9ae97cac Mon Sep 17 00:00:00 2001 From: David McKay Date: Tue, 8 Sep 2026 09:57:20 -0700 Subject: [PATCH 40/46] Send a placeholder key to an endpoint that reads no key The Bots no longer demand a key when a base URL names the endpoint, but a Bot image published before they learned that still does, and a deployment pulls the image the release pinned. So the keyless half of the compatible row would have stayed broken until the next release, on every machine. The OpenAI SDK every Bot is built on refuses to construct a client without a string, which is the whole reason a blank key kills them. Ollama, vLLM, LM Studio and llama.cpp all ignore the value, so a placeholder is sent instead of nothing and the endpoint that does not read it is none the wiser. Never treated as a credential: it is written in plain sight rather than put in the machine's store, because it is not one. A key somebody actually typed is used unchanged. --- desktop/src-tauri/src/env.rs | 77 +++++++++++++++++++++++++++++++++++- 1 file changed, 76 insertions(+), 1 deletion(-) diff --git a/desktop/src-tauri/src/env.rs b/desktop/src-tauri/src/env.rs index 68608baa3..3d007d012 100644 --- a/desktop/src-tauri/src/env.rs +++ b/desktop/src-tauri/src/env.rs @@ -206,7 +206,24 @@ pub fn compose( api_key, model: name, } => { - insert_if_given(&mut env, "OPENAI_API_KEY", api_key); + /* + * A placeholder when the endpoint needs no key, rather than nothing. + * + * Ollama, vLLM, LM Studio and llama.cpp ignore the value, but the OpenAI SDK every Bot + * is built on refuses to construct a client without a string, so a blank key produced a + * Bot that exited on startup asking for a key the person's own server does not have. + * The Bots no longer demand one when a base URL names an endpoint, and this is the half + * that makes the same choice work against a Bot image published before they learned: + * the value is sent to an endpoint that does not read it. + * + * Not a secret and never treated as one, which is why it is written here in plain sight + * rather than put in the credential store. + */ + if api_key.trim().is_empty() { + env.insert("OPENAI_API_KEY".into(), NO_KEY_NEEDED.into()); + } else { + insert_if_given(&mut env, "OPENAI_API_KEY", api_key); + } insert_if_given(&mut env, "OPENAI_BASE_URL", base_url); insert_if_given(&mut env, "BOT_MODEL", name); /* @@ -477,6 +494,11 @@ pub enum ModelCredential { }, } +/// What is sent as the key when the endpoint named needs none. +/// +/// A placeholder, not a credential: see the compatible branch of `compose`. +pub const NO_KEY_NEEDED: &str = "no-key-needed"; + /// Write the file, replacing only what this owns. /// /// Lines the shell did not write are kept: somebody who added `OPENAI_API_KEY` by hand, or a @@ -1268,6 +1290,59 @@ mod model_tests { } } + /** + An endpoint that needs no key still gets a client that can be constructed. + + The failure this pins is the whole keyless half of the compatible row: the person fills in an + address for their Ollama, leaves the key blank because it has none, and every Bot exits on + startup because the OpenAI SDK will not build a client without a string. A placeholder is sent + to an endpoint that does not read it. + */ + #[test] + fn a_keyless_endpoint_is_given_a_placeholder_rather_than_nothing() { + let keyless = compose( + &intelligence(), + &Model { + credential: ModelCredential::Compatible { + base_url: "http://127.0.0.1:11434/v1".into(), + api_key: " ".into(), + model: "qwen2.5:1.5b".into(), + }, + }, + &engine(), + &Ports::default(), + &pinned(), + None, + &BTreeMap::new(), + ); + assert_eq!( + keyless.get("OPENAI_API_KEY"), + Some(&NO_KEY_NEEDED.to_string()) + ); + assert_eq!( + keyless.get("OPENAI_BASE_URL"), + Some(&"http://127.0.0.1:11434/v1".to_string()) + ); + + // And a real key is never replaced by it. + let keyed = compose( + &intelligence(), + &Model { + credential: ModelCredential::Compatible { + base_url: "https://api.example.test/v1".into(), + api_key: "sk-theirs".into(), + model: "some-model".into(), + }, + }, + &engine(), + &Ports::default(), + &pinned(), + None, + &BTreeMap::new(), + ); + assert_eq!(keyed.get("OPENAI_API_KEY"), Some(&"sk-theirs".to_string())); + } + /// Switching provider does not leave the last one's key behind. /// /// Measured, not imagined: a run that signed in to a Claude plan still carried the From 2008f6719e87e8fa22780a37c47e38199fef2eaf Mon Sep 17 00:00:00 2001 From: David McKay Date: Tue, 8 Sep 2026 10:27:08 -0700 Subject: [PATCH 41/46] Serve the installed app without a development server "Show OpenBot" did nothing on a machine where the stack was up. The window said OpenBot is running, the button was there, and clicking it had no effect at all. Two faults, one behind the other. The app host process was dead. It is started through the package's `serve` script, which ran `vite preview` through `bun --bun` so that a machine with bun and no Node could start it: `node_modules/.bin/vite` begins with a Node shebang. But Vite's proxy calls `socket.destroySoon()` when an upstream response ends, and bun's sockets do not implement it, so the process died with a TypeError on the FIRST call the app made. It served its page, exited, and nothing was listening on 3010 from then on. The shell went on reporting a stack that was up, because the containers were. So the app is served by a small server of its own now. It serves a directory and forwards one prefix, which is all an install needs; a development server was never the right thing to be running in an installed application, as the shell's own comment about this process already said. No Node, no Vite at runtime, and the websocket upgrade the live screen needs is forwarded rather than answered with HTML. A miss under /assets is still a 404 rather than the page, because handing a script tag some HTML fails in the console instead of the network panel. Paths are normalised and confined to the directory: the deployment's .env sits two levels above it. And the button now shows what it was told. `show_openbot` already answered with "OpenBot is not answering on port 3010 yet, so there is nothing to show", and the click handler dropped it with `.catch(() => undefined)`. A true sentence was available and the window threw it away, which is why this looked like a dead button rather than a dead process. The Ask screen's copy of the same call always showed it. --- app/package.json | 2 +- app/serve.ts | 120 ++++++++++++++++++++++++++++++++++++++++ app/tests/serve.test.ts | 60 ++++++++++++++++++++ desktop/src/App.tsx | 15 ++++- 4 files changed, 195 insertions(+), 2 deletions(-) create mode 100644 app/serve.ts create mode 100644 app/tests/serve.test.ts diff --git a/app/package.json b/app/package.json index 98bca36a9..4146bc176 100644 --- a/app/package.json +++ b/app/package.json @@ -7,7 +7,7 @@ "scripts": { "build": "bun --bun node_modules/vite/bin/vite.js build", "dev": "bun --bun node_modules/vite/bin/vite.js", - "serve": "bun run build && bun --bun node_modules/vite/bin/vite.js preview", + "serve": "bun run build && bun serve.ts", "prebuild": "bun run --cwd .. generate:app-config", "predev": "bun run --cwd .. generate:app-config", "pretypecheck": "bun run --cwd .. generate:app-config", diff --git a/app/serve.ts b/app/serve.ts new file mode 100644 index 000000000..77ab05e61 --- /dev/null +++ b/app/serve.ts @@ -0,0 +1,120 @@ +/** + * Serve the built app, and pass its API calls to the server. + * + * NOT VITE. `vite preview` was doing this, run through `bun --bun` so that a machine with bun and + * no Node could start it at all, and the combination is broken in a way that looks like the whole + * product failing: Vite's proxy calls `socket.destroySoon()` when an upstream response ends, bun's + * sockets do not implement it, and the process dies with `TypeError: socket.destroySoon is not a + * function` on the FIRST call the app makes. So the app served its page, died, and the shell's + * window went on saying "OpenBot is running" with nothing on the port. Measured on a real install. + * + * A development server was never the right thing to run in an installed application, which is what + * the shell's own comment about this process already said. This serves a directory and forwards one + * prefix, needs no Node, and has nothing in it that a dev server needs and an install does not. + */ + +import { file } from "bun"; +import { join, normalize } from "node:path"; + +const DIST = join(import.meta.dir, "dist"); +const PORT = Number.parseInt(process.env.APP_PORT ?? "3010", 10); +const SERVER = `http://127.0.0.1:${process.env.SERVER_PORT ?? "3001"}`; + +/** + * Which file answers a path, or `null` when the app's own router should. + * + * Anything under `/assets` is a built file and a miss there is a genuine 404: answering index.html + * would hand a script tag some HTML and fail in the console instead of in the network panel. Every + * other miss is a client route (`/channel/...`), which is index.html. + * + * Pure, and tested, because the traversal guard lives here: a path is normalised and then checked + * to be inside the directory, so `/../.env` cannot be served. + */ +export function fileFor(pathname: string): string | null { + const wanted = normalize(join(DIST, decodeURIComponent(pathname))); + if (!wanted.startsWith(DIST)) return null; + if (wanted === DIST || pathname.endsWith("/")) + return join(DIST, "index.html"); + return wanted; +} + +/** Whether the app's router should answer instead of the file system. */ +export function isClientRoute(pathname: string): boolean { + return !pathname.startsWith("/assets/") && !pathname.includes("."); +} + +/** Whether this is a call for the server rather than the app. */ +export function isApiCall(pathname: string): boolean { + return pathname === "/api" || pathname.startsWith("/api/"); +} + +if (import.meta.main) { + Bun.serve({ + port: PORT, + // Both loopbacks, which is what `::` gets you: a dual-stack socket answers on 127.0.0.1 and + // ::1 alike. Bound to one, whoever is told the URL has no way to know which they were given. + hostname: "::", + // `ws: true` on the old proxy was required for the live screen, so the upgrade is forwarded + // rather than answered with the app's HTML, which failed with an opaque socket error. + websocket: { + open(ws) { + const upstream = ws.data as { upstream: WebSocket; queue: unknown[] }; + upstream.upstream.addEventListener("message", (event) => { + ws.send(event.data as string | Uint8Array); + }); + upstream.upstream.addEventListener("close", () => ws.close()); + }, + message(ws, message) { + const { upstream } = ws.data as { upstream: WebSocket }; + if (upstream.readyState === WebSocket.OPEN) { + upstream.send(message); + } else { + upstream.addEventListener("open", () => upstream.send(message), { + once: true, + }); + } + }, + close(ws) { + const { upstream } = ws.data as { upstream: WebSocket }; + upstream.close(); + }, + }, + async fetch(request, server) { + const url = new URL(request.url); + + if (isApiCall(url.pathname)) { + const target = SERVER + url.pathname + url.search; + if (request.headers.get("upgrade")?.toLowerCase() === "websocket") { + const upstream = new WebSocket(target.replace(/^http/, "ws")); + if (server.upgrade(request, { data: { upstream } })) return undefined; + upstream.close(); + return new Response("expected a websocket upgrade", { status: 400 }); + } + // The body is streamed rather than buffered, and redirects are left to the caller so a + // 302 from the server is not silently followed to a different origin. + return fetch(target, { + method: request.method, + headers: request.headers, + body: request.body, + redirect: "manual", + // @ts-expect-error duplex is required by fetch for a streamed body and is not yet typed. + duplex: "half", + }); + } + + const wanted = fileFor(url.pathname); + if (!wanted) return new Response("not found", { status: 404 }); + + const found = file(wanted); + if (await found.exists()) return new Response(found); + if (isClientRoute(url.pathname)) { + return new Response(file(join(DIST, "index.html"))); + } + return new Response("not found", { status: 404 }); + }, + }); + + console.log( + `OpenBot app on http://127.0.0.1:${PORT} and http://[::1]:${PORT}`, + ); +} diff --git a/app/tests/serve.test.ts b/app/tests/serve.test.ts new file mode 100644 index 000000000..0757773c4 --- /dev/null +++ b/app/tests/serve.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from "bun:test"; +import { fileFor, isApiCall, isClientRoute } from "../serve"; + +/** + * Serving the built app, which replaced `vite preview`. + * + * The failure that made this necessary: `vite preview` under `bun --bun` dies on the first proxied + * call with `TypeError: socket.destroySoon is not a function`, so the app served its page, exited, + * and the shell's window went on saying "OpenBot is running" with nothing listening. + */ +describe("what answers a request", () => { + test("the server answers its own prefix, and nothing near it", () => { + expect(isApiCall("/api")).toBe(true); + expect(isApiCall("/api/channels")).toBe(true); + // Not the app's own routes, and not a path that merely starts with the same letters. + expect(isApiCall("/apixyz")).toBe(false); + expect(isApiCall("/channel/api")).toBe(false); + expect(isApiCall("/")).toBe(false); + }); + + /** + * A miss under `/assets` is a real 404. Answering index.html there hands a script tag some HTML, + * which fails in the console rather than in the network panel and reads as a broken app. + */ + test("a missing built file is not answered with the page", () => { + expect(isClientRoute("/assets/index-abc123.js")).toBe(false); + expect(isClientRoute("/favicon.ico")).toBe(false); + }); + + /** Every other miss is the app's own router: /channel/ has to load the page. */ + test("a client route is answered with the page", () => { + expect(isClientRoute("/channel/channel_1ed78a89")).toBe(true); + expect(isClientRoute("/agents")).toBe(true); + expect(isClientRoute("/")).toBe(true); + }); +}); + +describe("which file a path names", () => { + test("the root and any directory are the page", () => { + expect(fileFor("/")).toEndWith("/dist/index.html"); + expect(fileFor("/channel/")).toEndWith("/dist/index.html"); + }); + + test("a built asset is itself", () => { + expect(fileFor("/assets/index-abc.js")).toEndWith( + "/dist/assets/index-abc.js", + ); + }); + + /** + * Nothing outside the directory, whatever the request says. This server has the deployment's + * `.env` two levels above it, so the traversal guard is not theoretical. + */ + test("a path that climbs out is refused", () => { + expect(fileFor("/../.env")).toBeNull(); + expect(fileFor("/../../.env")).toBeNull(); + expect(fileFor("/%2e%2e/%2e%2e/.env")).toBeNull(); + expect(fileFor("/assets/../../.env")).toBeNull(); + }); +}); diff --git a/desktop/src/App.tsx b/desktop/src/App.tsx index 032f4dfe5..d7d63f951 100644 --- a/desktop/src/App.tsx +++ b/desktop/src/App.tsx @@ -480,7 +480,20 @@ export function App() { <> From bce6e5c2c7462e6196d13af269beedf0a99685b9 Mon Sep 17 00:00:00 2001 From: David McKay Date: Tue, 8 Sep 2026 10:31:48 -0700 Subject: [PATCH 42/46] Show the CopilotKit sign-in address, the way the plan sign-ins do Both plan sign-ins keep the URL they were given and put it on screen, with a comment saying why: an open that silently does nothing, or a machine with no registered browser, leaves somebody watching a spinner with no idea where they are meant to go. The CopilotKit sign-in discarded it, so that case had no way out at all. Found while driving setup on a machine whose browser is not the one in front of the person. --- desktop/src/App.tsx | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/desktop/src/App.tsx b/desktop/src/App.tsx index d7d63f951..f10fcd6ce 100644 --- a/desktop/src/App.tsx +++ b/desktop/src/App.tsx @@ -63,12 +63,22 @@ export function App() { { id: string; name: string }[] | null >(null); const [signingIn, setSigningIn] = useState(false); + /* + * The address the browser was sent to, kept so the screen can show it. + * + * Both plan sign-ins already do this, for the reason written next to them: an open that silently + * did nothing, or a machine with no registered browser, leaves somebody watching a spinner with + * no idea where they are meant to go. This one threw the address away, so that case had no way + * out at all. + */ + const [signInUrl, setSignInUrl] = useState(null); async function signInToCopilotKit() { setSigningIn(true); setFailure(null); + setSignInUrl(null); try { - await invoke("begin_intelligence_sign_in"); + setSignInUrl(await invoke("begin_intelligence_sign_in")); setProjects( await invoke<{ id: string; name: string }[]>( "finish_intelligence_sign_in", @@ -78,6 +88,7 @@ export function App() { setFailure(asProblem(error)); } finally { setSigningIn(false); + setSignInUrl(null); } } @@ -363,6 +374,19 @@ export function App() { */} {apiKey ? (

Connected to CopilotKit.

+ ) : signInUrl ? ( + <> +

+ Finish signing in to CopilotKit in your browser. If it did not + open, this is the address: +

+ {/* Selectable text, not a link: the browser has already been asked to open it, and + what is needed here is something a person can copy. */} +

+ {signInUrl} +

+

Waiting for you to approve it…

+ ) : projects ? ( <>

Which project should OpenBot use?

From 9c631a41afbee177a02c8cb9426e0858b16e2dc6 Mon Sep 17 00:00:00 2001 From: David McKay Date: Tue, 8 Sep 2026 13:49:43 -0700 Subject: [PATCH 43/46] Stop the host processes on Windows, where Stop was leaving them running Stop took the containers down, reported success, and left OpenBot serving. Measured on Windows Server 2022: after it, the server still answered on 3001, the worker was still up, and both halves of the app still answered 200 on 3010. Only the five containers had gone. The handles this window holds cover only what this window started, and they are gone the moment it restarts, so a window stopping a stack an earlier one started holds nothing. That is the case `stop_processes_under` exists for, and its Windows arm returned 0 with a comment saying the host processes end with the session. They do not. They are found by the ports the deployment publishes now, which the shell already owns and already checks for clashes, and each is ended with its children: `bun run serve` starts the real server as a grandchild, so ending the process holding the port would leave that one behind. Only the app's and the server's ports, because the containers are Compose's to stop and killing whatever holds a published container port reaches into the engine's own plumbing. Also, an unreadable app manifest is no longer reported as an old deployment. A byte-order mark in front of package.json made serde_json refuse it, and the refusal was rendered as "the deployment is older than this version of OpenBot", which sends somebody looking for a newer installer over three bytes. Windows tooling writes that mark freely: Set-Content -Encoding UTF8 does. It is skipped, and a manifest that genuinely will not parse says so. --- desktop/src-tauri/src/stack.rs | 123 ++++++++++++++++++++++++++++++--- 1 file changed, 114 insertions(+), 9 deletions(-) diff --git a/desktop/src-tauri/src/stack.rs b/desktop/src-tauri/src/stack.rs index cc2aabb6f..49866a617 100644 --- a/desktop/src-tauri/src/stack.rs +++ b/desktop/src-tauri/src/stack.rs @@ -397,10 +397,65 @@ pub fn stop_processes_under(root: &Path) -> usize { #[cfg(not(unix))] pub fn stop_processes_under(_root: &Path) -> usize { - // Windows has no cheap equivalent of asking by working directory. The children this window - // started are stopped by their handles; a stack left by an earlier window is stopped by - // Compose, and its host processes end with the session. - 0 + /* + * Windows cannot be asked which process is in which directory cheaply, so this used to answer + * 0 and say the host processes end with the session. They do not, and the case it dismissed is + * the common one: the handles this window holds are gone the moment the window is restarted, + * so a window Stopping a stack an earlier one started holds nothing at all. + * + * MEASURED ON WINDOWS SERVER 2022. Stop took the five containers down, reported success, and + * left every host process running: the server on 3001, the worker, and both halves of the app + * still answering 200 on 3010. Somebody who pressed Stop still had OpenBot serving. + * + * So they are found by the ports the deployment publishes, which the shell already owns and + * already checks for clashes, and each is ended WITH ITS CHILDREN: `bun run serve` starts the + * real server as a grandchild, so ending only the process holding the port leaves that behind. + */ + let ports = crate::env::Ports::default(); + // The three host processes only. The containers are Compose's to stop, and killing whatever + // holds a container's published port would reach into the engine's own plumbing. + let ours = [ports.app, ports.server]; + let Ok(listing) = command("netstat").args(["-ano", "-p", "tcp"]).output() else { + return 0; + }; + let text = String::from_utf8_lossy(&listing.stdout); + + let mut stopped = 0; + let mut ended: Vec = Vec::new(); + for line in text.lines() { + let mut fields = line.split_whitespace(); + let (Some(_proto), Some(local), Some(state), Some(pid)) = + (fields.next(), fields.next(), fields.next(), fields.next()) + else { + continue; + }; + if !state.eq_ignore_ascii_case("LISTENING") { + continue; + } + let Some(port) = local.rsplit(':').next().and_then(|p| p.parse::().ok()) else { + continue; + }; + if !ours.contains(&port) { + continue; + } + let Ok(pid) = pid.parse::() else { + continue; + }; + // A port answers on both loopbacks, so the same process appears twice. + if ended.contains(&pid) { + continue; + } + ended.push(pid); + let ended_it = command("taskkill") + .args(["/PID", &pid.to_string(), "/T", "/F"]) + .output() + .map(|out| out.status.success()) + .unwrap_or(false); + if ended_it { + stopped += 1; + } + } + stopped } /** @@ -735,11 +790,29 @@ fn missing_script(root: &Path) -> Option { let Ok(text) = std::fs::read_to_string(&manifest) else { return Some(format!("{} cannot be read.", manifest.display())); }; - let has = serde_json::from_str::(&text) - .ok() - .and_then(|json| json.get("scripts")?.get(APP_SCRIPT).cloned()) - .is_some(); - if has { + /* + * An unreadable manifest and one without the script are different things. + * + * Read as one, a `package.json` that will not parse was reported as a deployment "older than + * this version of OpenBot", which sent somebody looking for a newer installer over a file with + * a byte-order mark in front of it. `serde_json` refuses a document that begins with one, and + * plenty of Windows tooling writes one: `Set-Content -Encoding UTF8` does. + */ + let manifest_json = + match serde_json::from_str::(text.trim_start_matches('\u{feff}')) { + Ok(json) => json, + Err(error) => { + return Some(format!( + "{} cannot be read as JSON: {error}. Something has rewritten it.", + manifest.display() + )) + } + }; + if manifest_json + .get("scripts") + .and_then(|scripts| scripts.get(APP_SCRIPT)) + .is_some() + { return None; } Some(format!( @@ -769,6 +842,38 @@ fn dirs_home() -> PathBuf { mod tests { use super::*; + /// A manifest with a byte-order mark in front of it is still a manifest. + /// + /// Windows tooling writes one freely (`Set-Content -Encoding UTF8` does), `serde_json` refuses + /// a document that begins with one, and the refusal was reported as a deployment older than + /// this version of OpenBot. That sent somebody looking for a newer installer over three bytes. + #[test] + fn a_byte_order_mark_does_not_make_a_deployment_look_old() { + let dir = std::env::temp_dir().join(format!("openbot-bom-{}", std::process::id())); + let app = dir.join("app"); + std::fs::create_dir_all(&app).unwrap(); + std::fs::write( + app.join("package.json"), + "\u{feff}{\"scripts\":{\"serve\":\"bun serve.ts\"}}", + ) + .unwrap(); + assert_eq!(missing_script(&dir), None); + let _ = std::fs::remove_dir_all(&dir); + } + + /// And a manifest that is genuinely broken says so, rather than blaming the version. + #[test] + fn an_unreadable_manifest_is_not_reported_as_an_old_deployment() { + let dir = std::env::temp_dir().join(format!("openbot-broken-{}", std::process::id())); + let app = dir.join("app"); + std::fs::create_dir_all(&app).unwrap(); + std::fs::write(app.join("package.json"), "{ this is not json").unwrap(); + let problem = missing_script(&dir).expect("a broken manifest is a problem"); + assert!(problem.contains("cannot be read as JSON"), "{problem}"); + assert!(!problem.contains("older than"), "{problem}"); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn a_missing_root_is_named_rather_than_left_to_errno() { let missing = std::env::temp_dir().join("openbot-not-here-at-all"); From 9d0774415b2d1410920921a994471292021c5f0d Mon Sep 17 00:00:00 2001 From: David McKay Date: Tue, 8 Sep 2026 14:00:12 -0700 Subject: [PATCH 44/46] Find the host processes by pid file, so the worker is stopped too The port sweep freed 3001 and 3010 and left the worker running. It listens on nothing, so a sweep cannot see it, and its command line is identical to the server's: both are `bun --env-file=../.env src/index.ts`, differing only by working directory, which Windows will not tell you cheaply. So the pids are written beside the logs when the processes start, and Stop reads them. That is also the honest fix for the case the sweep was standing in for: the handles a window holds die with the window, and everything else about a running stack survives it, so a restarted window Stopping a stack an earlier one started had nothing to work with. Now it has. The sweep stays as a second pass for a stack whose pid file is gone. The parse is its own function with a test on real netstat output, because reading five columns as four is what made the first attempt report success while leaving everything running: the foreign address was taken for the state and the state for the pid, so nothing ever matched. --- desktop/src-tauri/src/main.rs | 9 ++ desktop/src-tauri/src/stack.rs | 160 ++++++++++++++++++++++++++++----- 2 files changed, 145 insertions(+), 24 deletions(-) diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index a6257131d..bfdb42fda 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -579,6 +579,15 @@ async fn start_stack( .map_err(|error| format!("the wait did not run: {error}"))?; let shell = app.state::(); + // Recorded before the handles are stashed, so a window that never gets to Stop still leaves + // something the next one can stop. See `stack::host_pids_path`. + stack::record_host_pids( + &root, + &started + .iter() + .map(|(_, child)| child.id()) + .collect::>(), + ); shell.children.lock().unwrap().extend(started); *shell.root.lock().unwrap() = Some(root.clone()); diff --git a/desktop/src-tauri/src/stack.rs b/desktop/src-tauri/src/stack.rs index 49866a617..e86d27214 100644 --- a/desktop/src-tauri/src/stack.rs +++ b/desktop/src-tauri/src/stack.rs @@ -314,6 +314,35 @@ pub fn install_dependencies(root: &Path, bun: &Path) -> Result<(), String> { /// /// A window has no console to inherit, so a process whose output is dropped fails invisibly: the /// symptom is a port that never answers and a log directory that explains why. +/// Where the pids of the host processes are written, so a later window can stop them. +/// +/// The handles a window holds die with the window. Everything else about a running stack survives +/// it: the containers are Compose's, and the three host processes just keep going. Without this, +/// Stop from a restarted window had nothing to work with. +pub fn host_pids_path(root: &Path) -> PathBuf { + root.join(".logs").join("host-pids.json") +} + +/// Record the pids of the processes this window started. +pub fn record_host_pids(root: &Path, pids: &[u32]) { + let path = host_pids_path(root); + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let _ = std::fs::write( + &path, + serde_json::to_vec(pids).unwrap_or_else(|_| b"[]".to_vec()), + ); +} + +/// The pids a previous window recorded, if any. +pub fn recorded_host_pids(root: &Path) -> Vec { + std::fs::read(host_pids_path(root)) + .ok() + .and_then(|raw| serde_json::from_slice::>(&raw).ok()) + .unwrap_or_default() +} + pub fn spawn_host_process( process: &HostProcess, root: &Path, @@ -411,51 +440,92 @@ pub fn stop_processes_under(_root: &Path) -> usize { * already checks for clashes, and each is ended WITH ITS CHILDREN: `bun run serve` starts the * real server as a grandchild, so ending only the process holding the port leaves that behind. */ + let mut stopped_recorded = 0; + /* + * The pids this window or an earlier one recorded, which is the only way to reach the worker. + * + * It listens on no port, so the sweep below cannot see it, and its command line is identical to + * the server's: both are `bun --env-file=../.env src/index.ts`, differing only by working + * directory, which Windows will not tell you cheaply. Measured: after the port sweep alone, + * 3001 and 3010 were free and the worker was still running. + */ + for pid in recorded_host_pids(_root) { + let ended = command("taskkill") + .args(["/PID", &pid.to_string(), "/T", "/F"]) + .output() + .map(|out| out.status.success()) + .unwrap_or(false); + if ended { + stopped_recorded += 1; + } + } + let _ = std::fs::remove_file(host_pids_path(_root)); + let ports = crate::env::Ports::default(); - // The three host processes only. The containers are Compose's to stop, and killing whatever - // holds a container's published port would reach into the engine's own plumbing. + // And a sweep of the two host ports, for a stack whose pidfile is gone. The containers are + // Compose's to stop, and killing whatever holds a container's published port would reach into + // the engine's own plumbing. let ours = [ports.app, ports.server]; let Ok(listing) = command("netstat").args(["-ano", "-p", "tcp"]).output() else { - return 0; + return stopped_recorded; }; - let text = String::from_utf8_lossy(&listing.stdout); - let mut stopped = 0; - let mut ended: Vec = Vec::new(); - for line in text.lines() { + let mut stopped = stopped_recorded; + for pid in pids_listening_on(&String::from_utf8_lossy(&listing.stdout), &ours) { + // With its children: `bun run serve` starts the real server as a grandchild, so ending + // only the process holding the port leaves that one behind. + let ended = command("taskkill") + .args(["/PID", &pid.to_string(), "/T", "/F"]) + .output() + .map(|out| out.status.success()) + .unwrap_or(false); + if ended { + stopped += 1; + } + } + stopped +} + +/// The processes listening on any of `ports`, from `netstat -ano` output. +/// +/// Pure and tested, because the column layout is the thing that goes wrong. Read as four columns +/// rather than five, the foreign address is taken for the state and the state for the pid: nothing +/// matches, and Stop reports success while leaving everything running. That is exactly what +/// happened, and this test is why it did not survive. +#[cfg(not(unix))] +pub fn pids_listening_on(listing: &str, ports: &[u16]) -> Vec { + let mut found: Vec = Vec::new(); + for line in listing.lines() { + // Protocol, local address, foreign address, state, pid. let mut fields = line.split_whitespace(); - let (Some(_proto), Some(local), Some(state), Some(pid)) = - (fields.next(), fields.next(), fields.next(), fields.next()) - else { + let (Some(_proto), Some(local), Some(_foreign), Some(state), Some(pid)) = ( + fields.next(), + fields.next(), + fields.next(), + fields.next(), + fields.next(), + ) else { continue; }; if !state.eq_ignore_ascii_case("LISTENING") { continue; } + // `rsplit` rather than `split`, because an IPv6 local address is `[::1]:3010`. let Some(port) = local.rsplit(':').next().and_then(|p| p.parse::().ok()) else { continue; }; - if !ours.contains(&port) { + if !ports.contains(&port) { continue; } let Ok(pid) = pid.parse::() else { continue; }; - // A port answers on both loopbacks, so the same process appears twice. - if ended.contains(&pid) { - continue; - } - ended.push(pid); - let ended_it = command("taskkill") - .args(["/PID", &pid.to_string(), "/T", "/F"]) - .output() - .map(|out| out.status.success()) - .unwrap_or(false); - if ended_it { - stopped += 1; + // A port answers on both loopbacks, so one process appears on two lines. + if !found.contains(&pid) { + found.push(pid); } } - stopped + found } /** @@ -842,6 +912,48 @@ fn dirs_home() -> PathBuf { mod tests { use super::*; + /// Real `netstat -ano` output, because the column layout is what went wrong. + /// + /// Stop reported success and left the server and the app serving, because this was read as four + /// columns: the foreign address was taken for the state, the state for the pid, and nothing + /// ever matched. + #[test] + #[cfg(not(unix))] + fn the_processes_holding_our_ports_are_found_in_netstat_output() { + let listing = "\r\nActive Connections\r\n\r\n Proto Local Address Foreign Address State PID\r\n TCP 0.0.0.0:135 0.0.0.0:0 LISTENING 1044\r\n TCP 127.0.0.1:3001 0.0.0.0:0 LISTENING 8748\r\n TCP 127.0.0.1:3010 0.0.0.0:0 LISTENING 8636\r\n TCP 127.0.0.1:3010 127.0.0.1:51888 ESTABLISHED 8636\r\n TCP [::1]:3010 [::]:0 LISTENING 8636\r\n TCP 127.0.0.1:5432 0.0.0.0:0 LISTENING 9999\r\n"; + let found = super::pids_listening_on(listing, &[3010, 3001]); + // Both host processes, each once, and nothing else: not the established connection, not + // Postgres on a published container port, not RPC on 135. + assert_eq!(found.len(), 2, "{found:?}"); + assert!(found.contains(&8748), "{found:?}"); + assert!(found.contains(&8636), "{found:?}"); + assert!( + !found.contains(&9999), + "a container's port is not ours to kill: {found:?}" + ); + assert!(!found.contains(&1044), "{found:?}"); + } + + /// The pids survive the window that started them, which is the whole point of writing them. + #[test] + fn recorded_pids_are_read_back_and_a_missing_file_is_not_an_error() { + let dir = std::env::temp_dir().join(format!("openbot-pids-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + + // Nothing recorded is an empty list, not a panic: a deployment somebody started by hand + // has no pidfile at all. + assert!(recorded_host_pids(&dir).is_empty()); + + record_host_pids(&dir, &[4242, 4243, 4244]); + assert_eq!(recorded_host_pids(&dir), vec![4242, 4243, 4244]); + + // And rubbish in the file reads as nothing rather than stopping Stop. + std::fs::write(host_pids_path(&dir), "not json").unwrap(); + assert!(recorded_host_pids(&dir).is_empty()); + let _ = std::fs::remove_dir_all(&dir); + } + /// A manifest with a byte-order mark in front of it is still a manifest. /// /// Windows tooling writes one freely (`Set-Content -Encoding UTF8` does), `serde_json` refuses From 4cc5fc509194d055aecdc31b8426c71d499cfdf5 Mon Sep 17 00:00:00 2001 From: David McKay Date: Tue, 8 Sep 2026 14:04:25 -0700 Subject: [PATCH 45/46] Ask the credential store once per run, not once per screen Four Keychain dialogs, every time the setup screen mounted, each needing a click before the window would go on. Navigating between setup and OpenBot asked four more times. macOS authorizes every individual read of a stored password unless the application is signed with an identity the item's ACL already trusts. A development build is re-signed on every compile, so its ACL never matches and every read is a prompt; the wizard reads four secrets to arrive filled in, and it reads them on mount. The store is now asked once per name per process and the answer is held in memory. Absence is cached too, or a machine with no stored credential is asked on every mount for something that was never there. Writes go through the cache and forgetting clears it, so the two cannot disagree. This does not remove the prompts on a first run, and nothing in this process can: the decision belongs to the operating system and to the signature. A signed and notarised build is granted once and never asked again, which is the real fix and belongs to the release. --- desktop/src-tauri/src/vault.rs | 99 ++++++++++++++++++++++++++++++---- 1 file changed, 90 insertions(+), 9 deletions(-) diff --git a/desktop/src-tauri/src/vault.rs b/desktop/src-tauri/src/vault.rs index c08a6ac41..9eb86027a 100644 --- a/desktop/src-tauri/src/vault.rs +++ b/desktop/src-tauri/src/vault.rs @@ -121,6 +121,62 @@ pub fn already_given(env_file: &std::path::Path, keys: &[&str]) -> BTreeMap>>> = + std::sync::OnceLock::new(); + +fn cache() -> &'static std::sync::Mutex>> { + REMEMBERED.get_or_init(|| std::sync::Mutex::new(BTreeMap::new())) +} + +/// Read a stored secret, asking the store at most once per name per run. +pub fn recall(name: &str) -> Option { + if let Ok(held) = cache().lock() { + if let Some(known) = held.get(name) { + return known.clone(); + } + } + let found = recall_from_store(name); + if let Ok(mut held) = cache().lock() { + held.insert(name.to_string(), found.clone()); + } + found +} + +/// Store a secret, and keep the cache in step so the next read does not ask again. +pub fn remember(name: &str, value: &str) -> Result<(), Problem> { + remember_in_store(name, value)?; + if let Ok(mut held) = cache().lock() { + held.insert(name.to_string(), Some(value.to_string())); + } + Ok(()) +} + +/// Drop a secret from the store and from the cache. +pub fn forget(name: &str) { + forget_in_store(name); + if let Ok(mut held) = cache().lock() { + held.insert(name.to_string(), None); + } +} + /// Read back what was stored, for the settings named. pub fn recall_all(keys: &[&str]) -> BTreeMap { let mut found = BTreeMap::new(); @@ -147,20 +203,20 @@ pub fn recall_all(keys: &[&str]) -> BTreeMap { * path has neither a length limit nor an argv. */ #[cfg(target_os = "macos")] -pub fn remember(name: &str, value: &str) -> Result<(), Problem> { +fn remember_in_store(name: &str, value: &str) -> Result<(), Problem> { // Set, not add: a second run updates the item rather than colliding with the first. security_framework::passwords::set_generic_password(SERVICE, name, value.as_bytes()) .map_err(|error| keychain_problem(error.to_string())) } #[cfg(target_os = "macos")] -pub fn recall(name: &str) -> Option { +fn recall_from_store(name: &str) -> Option { let raw = security_framework::passwords::get_generic_password(SERVICE, name).ok()?; String::from_utf8(raw).ok() } #[cfg(target_os = "macos")] -pub fn forget(name: &str) { +fn forget_in_store(name: &str) { let _ = security_framework::passwords::delete_generic_password(SERVICE, name); } @@ -180,7 +236,7 @@ fn keychain_problem(detail: String) -> Problem { * and the ciphertext leaves on stdout, so neither is ever an argument. */ #[cfg(target_os = "windows")] -pub fn remember(name: &str, value: &str) -> Result<(), Problem> { +fn remember_in_store(name: &str, value: &str) -> Result<(), Problem> { const PROTECT: &str = r#" $ErrorActionPreference = 'Stop' $plain = [Console]::In.ReadToEnd() @@ -196,7 +252,7 @@ $sealed = [Security.Cryptography.ProtectedData]::Protect($bytes, $null, 'Current } #[cfg(target_os = "windows")] -pub fn recall(name: &str) -> Option { +fn recall_from_store(name: &str) -> Option { const UNPROTECT: &str = r#" $ErrorActionPreference = 'Stop' $sealed = [Convert]::FromBase64String([Console]::In.ReadToEnd().Trim()) @@ -211,7 +267,7 @@ $bytes = [Security.Cryptography.ProtectedData]::Unprotect($sealed, $null, 'Curre } #[cfg(target_os = "windows")] -pub fn forget(name: &str) { +fn forget_in_store(name: &str) { if let Ok(dir) = vault_dir() { let _ = std::fs::remove_file(dir.join(format!("{name}.dpapi"))); } @@ -257,7 +313,7 @@ fn dpapi_problem(detail: String) -> Problem { * daemon is missing would fail more people than the file protects. */ #[cfg(all(not(target_os = "macos"), not(target_os = "windows")))] -pub fn remember(name: &str, value: &str) -> Result<(), Problem> { +fn remember_in_store(name: &str, value: &str) -> Result<(), Problem> { let path = vault_dir()?.join(format!("{name}.secret")); std::fs::write(&path, value).map_err(|error| { Problem::with( @@ -270,14 +326,14 @@ pub fn remember(name: &str, value: &str) -> Result<(), Problem> { } #[cfg(all(not(target_os = "macos"), not(target_os = "windows")))] -pub fn recall(name: &str) -> Option { +fn recall_from_store(name: &str) -> Option { std::fs::read_to_string(vault_dir().ok()?.join(format!("{name}.secret"))) .ok() .map(|value| value.trim().to_string()) } #[cfg(all(not(target_os = "macos"), not(target_os = "windows")))] -pub fn forget(name: &str) { +fn forget_in_store(name: &str) { if let Ok(dir) = vault_dir() { let _ = std::fs::remove_file(dir.join(format!("{name}.secret"))); } @@ -310,6 +366,31 @@ fn owner_only(path: &std::path::Path) { #[cfg(all(not(unix), not(target_os = "macos")))] fn owner_only(_path: &std::path::Path) {} +#[cfg(test)] +mod cache_tests { + /// The store is asked once per name, then not again. + /// + /// The failure this pins is not a slow read, it is a person clicking Deny four times every + /// time a screen mounts: macOS authorizes each read of a stored password separately unless the + /// build's signature is one the item already trusts, and a development build's never is. + #[test] + fn a_secret_is_read_from_the_store_once_per_run() { + let name = format!("OPENBOT_TEST_CACHE_{}", std::process::id()); + // Absent to begin with, and the absence is remembered rather than asked again. + assert_eq!(super::recall(&name), None); + assert_eq!(super::recall(&name), None); + + // A write goes through and updates what a read sees, without asking the store. + super::remember(&name, "a-value").expect("the store should accept a write"); + assert_eq!(super::recall(&name).as_deref(), Some("a-value")); + assert_eq!(super::recall(&name).as_deref(), Some("a-value")); + + // And forgetting is reflected in both. + super::forget(&name); + assert_eq!(super::recall(&name), None); + } +} + #[cfg(test)] mod tests { use super::*; From 5ea56ee5386eed5d6cf6e9a6859b8d4ab36a87c2 Mon Sep 17 00:00:00 2001 From: David McKay Date: Tue, 8 Sep 2026 17:11:45 -0700 Subject: [PATCH 46/46] Scope the Keychain's service name to the Keychain CI builds on Linux with `-D warnings`, and there `SERVICE` is dead: only macOS has a service name to file a password under. Windows keys its DPAPI blobs by filename and the Linux fallback is a file in the config directory, so both ignored it. macOS never noticed, because there it is used three times. --- desktop/src-tauri/src/vault.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/desktop/src-tauri/src/vault.rs b/desktop/src-tauri/src/vault.rs index 9eb86027a..54d1c3980 100644 --- a/desktop/src-tauri/src/vault.rs +++ b/desktop/src-tauri/src/vault.rs @@ -35,7 +35,12 @@ use std::path::PathBuf; use crate::problem::Problem; -/// What the Keychain and the fallback file file these under. +/// What the Keychain files these under. +/// +/// macOS only, because only the Keychain has a service name: Windows keys DPAPI blobs by filename +/// and the Linux fallback is a file in the config directory. Left unscoped it is dead code +/// everywhere else, and CI runs clippy with `-D warnings`, so a Linux build failed on it. +#[cfg(target_os = "macos")] const SERVICE: &str = "OpenBot"; /**