first commit

This commit is contained in:
王性驊 2026-09-02 00:51:19 +08:00
commit 71dd330bdb
997 changed files with 217427 additions and 0 deletions

View File

@ -0,0 +1,77 @@
---
name: composio
description: Route and complete Composio work across Composio For You and Composio Platform. Use when the user mentions Composio; wants an agent to use apps such as Gmail, Slack, GitHub, Notion, Calendar, or Linear; needs first-time setup, an SDK or MCP integration, CLI operation, migration guidance, current documentation, or help diagnosing a connection or tool call.
---
# Composio
Use this skill as a router. Identify the product and the job, load only the relevant guidance, consult canonical documentation for volatile details, and then answer or do the work the user requested.
## 1. Choose the product
Do not blend the products. They use different credentials and setup paths.
| | Composio For You | Composio Platform |
|---|---|---|
| Use when | Someone wants their own agent to use their own apps | A developer is building a product whose users connect accounts |
| Primary surface | MCP or the Composio CLI | SDK sessions inside an application |
| Credential | `ck_...` consumer key when the client requires a header | `COMPOSIO_API_KEY` project key |
| Dashboard | `dashboard.composio.dev` → For You | `dashboard.composio.dev` → Platform |
Ask one short question only when context does not establish the product:
> Is this for your own agent and accounts, or for a product where your users connect their accounts?
Treat a named personal AI client with no product code as For You. Treat an application codebase, SDK, user or tenant identity, backend, or product agent as Platform.
## 2. Choose the job
Identify the requested outcome before taking action:
- **Explain or discover:** answer a question, compare approaches, or find the current API.
- **Set up:** establish credentials, an MCP client, the CLI, or an SDK for the first time.
- **Build or change:** integrate Composio into an existing agent or application.
- **Operate:** find, connect, and run tools for a real task.
- **Debug or migrate:** diagnose a failure, update an older integration, or move from legacy direct execution or Tool Router.
Do not turn an explanation, documentation lookup, or narrow bug fix into onboarding.
## 3. Load only the relevant guidance
- For You: read [Composio For You](references/for-you.md).
- Platform: read [Composio Platform](references/platform.md).
- Provider, connection, or execution failure: also read [Errors and provider gotchas](references/errors.md).
## Complete the selected job
- For a question, fetch current documentation when needed and give the concrete answer. Do not mutate a project or force a tool call.
- For setup or integration, inspect the existing environment, preserve its architecture and identity model, make the smallest useful change, and verify it with one safe real tool call when credentials and user authorization are available.
- For an operational request, connect only the apps the task needs and execute the requested workflow.
- For debugging, get the Composio log or request ID, identify the failing boundary, fix that boundary, and retry when the user authorized execution.
## Stable rules
1. Establish the product before choosing credentials, URLs, SDKs, or commands.
2. Treat dashboard onboarding as a context, not the skill's identity. When the developer arrives with an existing `COMPOSIO_API_KEY` from Getting Started, use it and never create, rotate, replace, print, or request it in chat. Do not run `composio dev init` in that path.
3. For a general first-time Platform setup with no dashboard credential handoff, follow the current setup path in the Platform guide.
4. Never invent toolkit or tool slugs. Discover them at runtime or with the CLI.
5. Do not build a provider OAuth flow. Composio returns a Connect Link when authentication is needed.
6. Use sessions for new Platform integrations. Preserve the application's existing user identity and agent architecture.
7. Keep credentials out of source control, URLs, logs, chat, and command output.
8. Get the log or request ID before diagnosing a failed tool call.
9. Prefer the smallest configuration that completes the current job. Keep toolkit filters, tag policies, sandbox controls, custom auth, provider-specific hardening, and other advanced options out of the first path unless the request or existing code requires them.
10. Do not invent repository facts. Never claim that a file, framework, environment loader, identity field, agent path, or dependency exists until it was provided or inspected. If codebase context is unavailable, state the unknown and ask for access or one necessary detail.
## Canonical information
Use bundled references for stable decisions. For versions, provider adapters, client-specific setup, toolkit behavior, or APIs that may have changed, fetch the current Markdown source before answering or editing code:
When sources disagree, prefer the current API reference and live endpoint behavior over any page marked Legacy, and name the REST API version explicitly.
```text
https://docs.composio.dev/llms.txt
https://docs.composio.dev/docs/<page>.md
https://docs.composio.dev/toolkits/<toolkit>.md
```
Use the documentation to complete the task. Do not merely hand the user a link unless they asked for one.

View File

@ -0,0 +1,70 @@
# Errors and provider gotchas
Use this guide for failures shared by Composio For You and Composio Platform. Keep product-specific credential and client setup in the selected product guide.
## Start with evidence
Get the Composio log or request ID and inspect Dashboard Logs before changing credentials or code. Agent frameworks often wrap the underlying provider error.
When the CLI is already installed and authenticated for the applicable product, these commands can provide additional evidence:
```bash
composio dev logs tools
composio dev logs triggers
composio connections list
```
Do not install or reinitialize the CLI solely to diagnose a dashboard log that already contains the failure.
## Tool does not exist
Never guess a slug. In a Platform session, discover tools through the session meta tools. In a CLI workflow, use `composio search`, then inspect the returned tool.
For legacy manual execution, a missing tool may be a toolkit-version problem. Fetch current migration and execution docs rather than assuming the provider lacks the operation. New session integrations should use runtime discovery.
## Identify the authentication boundary
### Composio project or session 401
This happens before a provider tool call succeeds. The Platform project credential may be missing, masked, invalid, or associated with a different project.
Re-run the no-output credential checks from the Platform guide. Do not print, rotate, replace, or request the key in chat. If the developer arrived from Dashboard Getting Started, direct them back to that project's Step 1 rather than running `composio dev init`.
### Provider connected-account 401
This appears on a real tool execution after the project and session reached the provider. The selected user's provider token may be revoked, expired, or invalidated by a password, 2FA, consent, or administrator-policy change.
Keep the same project key and application user ID. Generate a fresh Connect Link for that integration, reconnect the provider account, and retry the safe call. If a link expired, request a new one.
### For You client authentication
If the MCP client itself cannot authenticate, verify the consumer endpoint, OAuth session, or `ck_...` header path from the For You guide. Do not substitute a Platform project key.
## Common provider constraints
- **Google "App is blocked":** remove unnecessary scopes or use a verified custom OAuth app.
- **Google API disabled:** enable the required provider API in the Google Cloud project that owns the custom credentials.
- **Slack 429:** managed apps share provider quota; use a custom Slack app for a dedicated bucket when needed.
- **Microsoft 403:** the tenant may require administrator consent.
- **GitHub App access:** OAuth credentials and repository installation are separate steps.
- **Payment toolkit session restriction:** treat it as a surface policy restriction, not a plan or connection failure.
## Branding and production auth
Managed auth is intended to make initial development easy. Before launch, move integrations that require the application's branding, scopes, or dedicated quotas onto its own OAuth apps.
When someone asks to remove Composio branding, identify the surface first: Connect Link page, provider consent screen, secured badge, callback domain, or success page. They have different fixes. Fetch `white-labeling-authentication.md` before proposing an implementation.
## Triggers and webhooks
Check the Composio status page and trigger logs before changing a trigger. Use current trigger documentation for event names, polling limits, and connection-state verification. Do not promise static outbound IPs; use documented webhook signature verification.
## Canonical follow-up
For provider- or toolkit-specific behavior, fetch:
```text
https://docs.composio.dev/toolkits/<toolkit>.md
```
For APIs, migrations, triggers, or compliance questions, find the current page through `https://docs.composio.dev/llms.txt`. If the problem remains unresolved, include the log ID when escalating to Composio support.

View File

@ -0,0 +1,80 @@
# Composio For You
Use this product when someone wants their own AI client or terminal agent to use their own connected apps. Route by task: answer a question, configure an MCP client, operate through the CLI, connect an app, or debug an existing setup.
## Stable product contract
- MCP endpoint: `https://connect.composio.dev/mcp`
- Consumer key when a client needs a header: `ck_...`
- Header name: `x-consumer-api-key`
- Key location: Dashboard → For You → AI Clients → select the client
The removed `mcp.composio.dev` endpoint and Platform MCP URLs are not substitutes. The `ck_...` key and Platform's `COMPOSIO_API_KEY` are not interchangeable.
## Choose MCP or CLI
Default to MCP for desktop and hosted AI clients. Use the CLI for terminal agents that can execute commands and operate tools directly.
### Know what each surface installs
These surfaces are complementary. Installing one does not automatically install the others.
| Surface | What the agent discovers |
|---|---|
| Raw Composio MCP connection | Callable tools and their schemas. MCP does not install an agent skill. |
| Public `composio` skill | This product-and-job router. Install it explicitly from `ComposioHQ/composio`. |
| Composio CLI | The `composio` command plus the separately maintained `composio-cli` skill when the CLI's agent setup flow installs it. That skill owns current command usage. |
| OpenAI/Codex plugin | The hosted Composio app plus its bundled `composio-runtime` skill, which selects between hosted tools and the local CLI. |
| Claude Code plugin | Commands and hooks. It does not bundle a skill; CLI login/setup installs `composio-cli` separately. |
An agent can therefore see Composio tools without seeing either Composio skill, and it can have more than one skill when their roles differ. Use `composio` for product selection and integration guidance, `composio-runtime` for OpenAI plugin routing, and `composio-cli` for CLI operation.
### MCP clients
Claude Desktop and ChatGPT use browser OAuth and do not need a consumer key. Header-based clients use the endpoint and header above.
Client configuration changes over time. Before giving client-specific steps, fetch the current source of truth:
```text
https://docs.composio.dev/docs/composio-connect.md
```
For any other MCP-capable client, configure HTTP transport with the endpoint and, when required, the `x-consumer-api-key` header. Keep credentials out of committed configuration.
### Terminal agents
Install and authenticate the CLI only when the task needs it:
```bash
curl -fsSL https://composio.dev/install | bash
composio login
```
For a real task:
```bash
composio search "<what the user wants>"
composio link <toolkit>
composio execute <TOOL_SLUG> -d '{...}'
```
Use `composio login --no-wait | jq` when the agent cannot open a browser. Give the returned login URL to the user and complete authentication with the returned key. Once installed, prefer the bundled `composio-cli` skill for current command and flag details.
## Connect apps when the task needs them
Do not pre-connect every app. Start the requested task. When an integration is required, Composio returns an authorization link and the connection persists for future runs.
For setup or an operational request, verify the selected path with one safe real call when authorization is available. For a question or configuration explanation, answer it without forcing execution.
## Debugging
First confirm that the client is connected to the correct MCP endpoint or that the CLI is authenticated. Then get the Composio log or request ID and read [Errors and provider gotchas](errors.md).
Common product-specific checks:
- If MCP tools do not appear, confirm the connector is enabled, clear its cache when the client supports that, and reconnect it.
- If browser OAuth repeatedly selects the wrong account, retry in a clean browser profile with one Composio account signed in.
- If an authorization link expired, request a new link rather than reusing it.
- If a connected app action returns an auth error, reconnect that app and retry without regenerating the consumer key.
Manage connections in Dashboard → For You → Connect Apps. Manage consumer keys and MCP or CLI sessions in Settings → Sessions & API Key.

View File

@ -0,0 +1,166 @@
# Composio Platform
Use this product when a developer is building an agent, application, or backend whose users connect their own accounts. Route by task instead of assuming every request is onboarding.
## Contents
1. [Choose the task](#choose-the-task)
2. [Establish project access](#establish-project-access)
3. [Integrate sessions](#integrate-sessions)
4. [Choose tools and authentication behavior](#choose-tools-and-authentication-behavior)
5. [Handle advanced product work](#handle-advanced-product-work)
6. [Verify setup when relevant](#verify-setup-when-relevant)
7. [Use canonical documentation](#use-canonical-documentation)
## Choose the task
- **Explain or discover:** answer from this guide and current documentation without changing code.
- **First-time setup:** establish project access and the smallest working SDK path.
- **Integrate or extend:** inspect the codebase and add Composio to the existing agent architecture.
- **Operate:** discover, authorize, and execute tools for the application's current user.
- **Debug or migrate:** inspect the log ID and current implementation before changing credentials or architecture.
When modifying code, first inspect and identify the language, package manager, agent or LLM framework, stable user or tenant ID, secret-loading mechanism, and smallest existing execution path where Composio tools belong. Extend that path; do not create a parallel demo agent when one already exists. Never infer filenames, framework choices, environment behavior, or identity fields that were not provided or observed.
Use progressive disclosure. The basic path is project access, the core SDK, one user-scoped session, and the existing agent's tool interface. Do not add toolkit filters, tag policies, sandbox changes, custom auth, provider adapters, or production hardening unless the request or inspected code requires them. If essential repository context is missing, give the minimum stable outline and ask only for the missing detail instead of filling a large example with placeholders.
## Establish project access
Choose exactly one credential path from context.
### Existing dashboard or repository credential
If `COMPOSIO_API_KEY` already exists, or the developer arrived from Dashboard Getting Started after copying an `ak_*` project key, use that credential from the repository's existing environment or secret mechanism.
In this path:
- Never run `composio dev init` or select another project.
- Never create, rotate, replace, print, echo, log, or request the key in chat.
- Check only whether the environment variable exists and is not visibly masked or placeholder-like.
- When it lives in a file, check that the file is ignored by source control without printing the matching line.
- Let the first SDK request validate the credential; length is not validation.
If the dashboard handoff is missing or masked, direct the developer back to Platform → project → Getting Started → Step 1. Do not silently switch to a provisioning flow.
### General first-time setup
If there is no existing project credential and no dashboard handoff, use the current first-time setup path:
```bash
curl -fsSL https://composio.dev/install | bash
composio login
composio dev init
```
`composio dev init` writes `COMPOSIO_API_KEY` and `COMPOSIO_TEST_USER_ID` to `.env.local`. Python dotenv does not load `.env.local` by default, so pass the path explicitly or move the variables through the project's normal secret mechanism. There is no bare `composio init` command.
Verification stamp: these commands were exercised against CLI 0.2.32 and 0.3.1 on 2026-08-06. If the installed version differs or behavior conflicts, check `composio dev --help` and current docs rather than forcing the stamped behavior.
### Install the SDK the codebase needs
```bash
npm install @composio/core
pip install composio
```
Add a provider adapter only when the existing framework needs one. Fetch the current provider index before naming a package:
```text
https://docs.composio.dev/docs/providers.md
```
Do not introduce another LLM framework solely to demonstrate Composio.
## Integrate sessions
A session is the runtime context for one application user. It carries identity, connections, tool scope, and sandbox configuration.
Trace the application's existing authenticated user or tenant ID and use that stable identifier. Do not add a parallel user system or share one placeholder identity across users.
TypeScript:
```typescript
import { Composio } from "@composio/core";
const composio = new Composio();
const session = await composio.create(existingUserId);
const tools = await session.tools();
```
Python:
```python
from composio import Composio
composio = Composio()
session = composio.create(user_id=existing_user_id)
tools = session.tools()
```
Both SDKs also expose `composio.sessions.create(...)`; do not teach an artificial TypeScript/Python asymmetry. The SDK reads `COMPOSIO_API_KEY` from the environment, so do not pass it inline.
For a multi-turn conversation, persist the returned session ID and resume it instead of creating a fresh session on every message. Confirm current method names against `configuring-sessions.md` before writing production code.
Pass the session tools to the repository's existing model or agent using its native tool integration. Preserve the current prompt, model, streaming, and request lifecycle unless tools require a targeted change.
## Choose tools and authentication behavior
Sessions expose a small set of meta tools by default so the agent can discover integrations and authenticate at runtime:
- `COMPOSIO_SEARCH_TOOLS`
- `COMPOSIO_GET_TOOL_SCHEMAS`
- `COMPOSIO_MULTI_EXECUTE_TOOL`
- `COMPOSIO_MANAGE_CONNECTIONS`
- `COMPOSIO_WAIT_FOR_CONNECTIONS`
- `COMPOSIO_REMOTE_WORKBENCH`
- `COMPOSIO_REMOTE_BASH_TOOL`
Keep connection management enabled for interactive agents. It returns a Connect Link when a user needs to authorize an app; do not build a provider OAuth flow.
Use the direct-tools preset only for a narrow, deterministic agent with a fixed allowlist. It removes meta tools by default. Re-enable connection management when users must authenticate in the agent, and keep or disable the sandbox deliberately. Fetch `configuring-sessions.md` for the current preset and option syntax before implementing it.
If the application has its own connect UI, use session authorization and connection-state methods and suppress in-chat connection prompts. Use managed auth initially. Create a custom auth config only for the application's OAuth branding, additional scopes, dedicated provider quotas, or self-hosted or regional requirements.
## Handle advanced product work
Do not force advanced requests through first-time setup. Route them to current documentation:
- Session scoping, account selection, callbacks, direct tools, and sandbox controls: `configuring-sessions.md`
- Custom connection UI: `manually-authenticating.md`
- Triggers and webhooks: `triggers.md` and the setting-up-triggers guides
- Custom MCP servers, tools, toolkits, or proxy execution: the `extending-sessions` guides
- Legacy direct execution, MCP servers, or Tool Router migrations: the migration and sessions guides
- White labeling and custom OAuth apps: `white-labeling-authentication.md`
"Tool Router" is the former name for sessions. Treat direct execution as a migration path, not the default for new agent integrations.
## Verify setup when relevant
For a first-time setup or integration request, success means a programmatic, safe, read-only tool call from the developer's real execution path returns an actual provider result and a non-empty Composio log ID.
Ask which integration the developer wants to try unless the application already makes the choice clear. Discover the real toolkit and tool at runtime. If the current user is not connected, return the Connect Link, wait for authorization, and retry.
A mock, Playground run, tool search, schema fetch, session creation, or Connect Link alone does not prove the integration. If the repository has no runnable agent loop, add only the smallest entrypoint compatible with its existing provider; do not require another hosted model.
After success, report the code location, identity and session mapping, integration and tool, safe result summary, log ID, and useful dashboard destinations. For an explanation, migration plan, or narrow bug fix, use that task's own completion condition instead of forcing a new tool call.
## Use canonical documentation
Fetch current Markdown before giving version-sensitive commands or editing SDK integration code:
```text
https://docs.composio.dev/llms.txt
https://docs.composio.dev/docs/<page>.md
https://docs.composio.dev/toolkits/<toolkit>.md
```
Read [Errors and provider gotchas](errors.md) for failures shared across products.
## Do not
- Do not replace a dashboard-provided credential with a general setup flow.
- Do not stop at documentation when the user asked for implementation.
- Do not guess toolkit or tool slugs.
- Do not create auth configs as a universal prerequisite.
- Do not replace the application's identity model or agent architecture.
- Do not claim an integration works before the requested proof succeeds.

25
.dockerignore Normal file
View File

@ -0,0 +1,25 @@
.git
.github
.vercel
.turbo
.cache
.DS_Store
.env
.env.*
node_modules
**/node_modules
**/dist
**/build
**/out
**/.expo
**/.astro
coverage
data
artifacts
backups
design
PRODUCT_PLAN.md
playwright-report
test-results
verify-report
*.log

123
.env.example Normal file
View File

@ -0,0 +1,123 @@
NODE_ENV=development
DATABASE_URL=postgres://rakazo:rakazo@127.0.0.1:5433/rakazo
BETTER_AUTH_SECRET=replace-with-32-plus-character-secret
BETTER_AUTH_URL=http://127.0.0.1:5173
API_URL=http://127.0.0.1:3100
# Listener address. Keep loopback unless a container or trusted reverse proxy must reach the API.
API_HOST=127.0.0.1
WEB_ORIGIN=http://127.0.0.1:5173
PUBLIC_POSTHOG_KEY=
PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
SIGNUPS_ENABLED=true
SIGNUP_ALLOWLIST=
ENCRYPTION_KEY=replace-with-64-random-hex-characters
DATA_DIR=./data
SANDBOX_SUPERVISOR_URL=http://127.0.0.1:7091
# Dedicated credentials (distinct from BETTER_AUTH_SECRET). Required for Docker sandboxes /
# screen proxy outside local development.
SANDBOX_SUPERVISOR_TOKEN=replace-with-32-plus-character-supervisor-token
SCREEN_PROXY_SECRET=replace-with-32-plus-character-screen-proxy-secret
SANDBOX_PROVIDER=docker
# Optional Docker daemon socket. Defaults to /var/run/docker.sock (Unix) or
# //./pipe/docker_engine (Windows). Set for rootless Docker, Colima, or custom paths.
# DOCKER_HOST takes precedence over DOCKER_SOCKET when both are set.
# DOCKER_SOCKET=
DAYTONA_API_KEY=
DAYTONA_API_URL=
DAYTONA_TARGET=
BOX_API_KEY=
# Optional Box API base URL; defaults to https://ascii.dev/api/box/v1.
BOX_API_URL=
AGENT_RUNTIME=pi
WAKEUP_DRIVER=graphile
# Pause or stop computers after this many idle ms. Minimum 30000.
SANDBOX_IDLE_MS=600000
SANDBOX_COMMAND_TIMEOUT_MS=300000
# Optional per-turn tool-call fuse for the Pi agent runtime. Unset, empty, or 0
# means unlimited (default). Set a positive integer to soft-stop a turn that
# exceeds the budget and still emit a final assistant message.
MAX_TOOL_CALLS_PER_TURN=
# Deployment-wide fallback model. PI_DEFAULT_PROVIDER picks which key below is used.
OPENROUTER_API_KEY=
ANTHROPIC_API_KEY=
PI_DEFAULT_PROVIDER=openrouter
# Text-only by default. Computer use (screenshots via computer_observe / computer_act)
# needs a vision-capable model — one whose catalog entry lists image input modalities
# (e.g. openai/gpt-4o). Tests use COMPUTER_E2E_MODEL for that.
PI_DEFAULT_MODEL=deepseek/deepseek-v4-flash-0731
# Optional local OpenAI-compatible models. Leave blank to disable the provider.
RAKAZO_LOCAL_MODELS=
RAKAZO_LOCAL_MODELS_URL=http://127.0.0.1:11434/v1
RAKAZO_LOCAL_CONTEXT_WINDOW=32768
RAKAZO_LOCAL_MAX_TOKENS=4096
# Allow user-connected OpenAI-compatible endpoints on public hostnames (default: private/loopback only).
RAKAZO_OPENAI_COMPAT_ALLOW_PUBLIC=
E2B_API_KEY=
COMPOSIO_API_KEY=
# Optional alternative managed connector provider.
PIPEDREAM_CLIENT_ID=
PIPEDREAM_CLIENT_SECRET=
PIPEDREAM_PROJECT_ID=
PIPEDREAM_ENVIRONMENT=development
# Optional messaging surface (Chat SDK). Each platform mounts when its full
# credential set is present; webhooks arrive at /api/v1/messaging/webhook/<provider>.
# Users link a chat app to their account from the web (Messaging settings).
# Set true to instead let unknown senders auto-provision their own chat-only
# accounts (their runs bill the deployment model key):
MESSAGING_OPEN_SIGNUP=false
# Sendblue (iMessage/SMS — all four required):
SENDBLUE_API_KEY_ID=
SENDBLUE_API_SECRET=
SENDBLUE_SIGNING_SECRET=
SENDBLUE_PHONE_NUMBER=
# Slack (both required):
SLACK_BOT_TOKEN=
SLACK_SIGNING_SECRET=
# WhatsApp Business Cloud (all four required):
WHATSAPP_ACCESS_TOKEN=
WHATSAPP_PHONE_NUMBER_ID=
WHATSAPP_APP_SECRET=
WHATSAPP_VERIFY_TOKEN=
# Telegram (both required). Inbound is webhook-only at
# /api/v1/messaging/webhook/telegram with header X-Telegram-Bot-Api-Secret-Token;
# the worker must not long-poll getUpdates.
TELEGRAM_BOT_TOKEN=
TELEGRAM_WEBHOOK_SECRET_TOKEN=
SUPERMEMORY_API_KEY=
# Optional. Defaults to https://api.supermemory.ai. Self-hosted: http://localhost:6767
SUPERMEMORY_API_URL=
# Transactional account email. Works with Resend, Amazon SES, or any SMTP server.
SMTP_URL=
EMAIL_FROM=
# Local development only: capture password-reset email and print its link to the API console.
EMAIL_EMULATOR=
VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY=
OTEL_EXPORTER_OTLP_ENDPOINT=
LOG_LEVEL=info
# Optional. GET /health returns this as `revision` so you can confirm the deployed commit.
GIT_SHA=
# --- Compose production deployments ---
# Absolute path of this checkout as the Docker *daemon* sees it. Production Compose defaults to
# /srv/rakazo on Linux; override it for any other host path so the updater bind mount resolves.
# Linux override: RAKAZO_DEPLOY_DIR=/opt/rakazo
# Docker Desktop on Windows (C:\Users\you\rakazo):
# RAKAZO_DEPLOY_DIR=/run/desktop/mnt/host/c/Users/you/rakazo
RAKAZO_DEPLOY_DIR=
# Published images (defaults match this repository). Tag `local` is built from the checkout.
RAKAZO_IMAGE=ghcr.io/elie222/rakazo/app
RAKAZO_IMAGE_TAG=local
RAKAZO_IMAGE_TAG_PREVIOUS=
RAKAZO_UPDATER_IMAGE=ghcr.io/elie222/rakazo/updater
RAKAZO_UPDATER_IMAGE_TAG=local
# Required only when starting the opt-in `updater` Compose profile; the sidecar refuses to start
# without it. Use a dedicated random value, not BETTER_AUTH_SECRET,
# SANDBOX_SUPERVISOR_TOKEN, or SCREEN_PROXY_SECRET;
# ≥32 chars in production. Leave empty when the sidecar is disabled.
RAKAZO_UPDATER_URL=
RAKAZO_UPDATER_TOKEN=
# Optional default UI locale for the web SPA (en | de | ko). Overridden by
# localStorage key rakazo.uiLocale when the user picks a language in Settings.
# VITE_DEFAULT_UI_LOCALE=en

8
.gitattributes vendored Normal file
View File

@ -0,0 +1,8 @@
* text=auto eol=lf
*.sh text eol=lf
infra/sandboxes/computer/rakazo-browser text eol=lf
infra/sandboxes/computer/fluxbox.* text eol=lf
*.png binary
*.jpg binary
*.ico binary

94
.github/ISSUE_TEMPLATE/bug.yml vendored Normal file
View File

@ -0,0 +1,94 @@
name: Bug report
description: Something is broken in Rakazo
title: "[Bug]: "
labels:
- bug
body:
- type: markdown
attributes:
value: |
For **security vulnerabilities**, email [security@rakazo.com](mailto:security@rakazo.com) — do not use this form. See [SECURITY.md](https://github.com/elie222/rakazo/blob/main/SECURITY.md).
- type: checkboxes
id: not-security
attributes:
label: Confirmation
options:
- label: This is not a security vulnerability report
required: true
- type: textarea
id: description
attributes:
label: Description
description: What went wrong? Keep it concise.
placeholder: The bot computer pane stays blank after sending a message.
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to reproduce
description: Numbered steps from a clean local setup when possible.
placeholder: |
1. `pnpm dev`
2. Create a bot and send "hello"
3. Open the computer pane
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behavior
validations:
required: true
- type: textarea
id: actual
attributes:
label: Actual behavior
validations:
required: true
- type: input
id: version
attributes:
label: Rakazo version or commit
placeholder: v0.1.0 or abc1234
validations:
required: true
- type: dropdown
id: os
attributes:
label: Operating system
options:
- macOS
- Linux
- Windows
- Other
validations:
required: true
- type: input
id: sandbox
attributes:
label: SANDBOX_PROVIDER
description: If relevant (default is `docker`).
placeholder: docker
- type: input
id: runtime
attributes:
label: AGENT_RUNTIME
description: If relevant (default is `pi`).
placeholder: pi
- type: textarea
id: logs
attributes:
label: Logs or screenshots
description: Redact secrets and tokens. Use placeholders instead of real keys.
render: shell

11
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@ -0,0 +1,11 @@
blank_issues_enabled: false
contact_links:
- name: Security vulnerabilities
url: https://github.com/elie222/rakazo/blob/main/SECURITY.md
about: Report security issues to security@rakazo.com — do not open a public issue.
- name: User support
url: mailto:support@rakazo.com
about: Questions about using Rakazo or self-hosting help.
- name: Maintainer
url: mailto:elie@rakazo.com
about: Reach the maintainer directly.

80
.github/ISSUE_TEMPLATE/self-host.yml vendored Normal file
View File

@ -0,0 +1,80 @@
name: Self-host setup
description: Cannot start Rakazo, run compose, migrate, or build the sandbox
title: "[Self-host]: "
labels:
- self-host
body:
- type: markdown
attributes:
value: |
For **security vulnerabilities**, email [security@rakazo.com](mailto:security@rakazo.com). See [SECURITY.md](https://github.com/elie222/rakazo/blob/main/SECURITY.md).
Setup steps are in [README.md](https://github.com/elie222/rakazo/blob/main/README.md). Do not paste `.env` contents or API keys.
- type: dropdown
id: step
attributes:
label: Which step fails?
options:
- Docker Compose / Postgres
- pnpm install
- pnpm db:generate or db:migrate
- pnpm sandbox:build
- pnpm dev (API, worker, web, or supervisor)
- Health check / first login
- Other
validations:
required: true
- type: textarea
id: command
attributes:
label: Command you ran
placeholder: pnpm sandbox:build
validations:
required: true
- type: textarea
id: error
attributes:
label: Error output
description: Full terminal output or log excerpt. Redact secrets.
render: shell
validations:
required: true
- type: input
id: version
attributes:
label: Rakazo version or commit
placeholder: main @ abc1234
validations:
required: true
- type: dropdown
id: os
attributes:
label: Operating system
options:
- macOS
- Linux
- Windows
- Other
validations:
required: true
- type: input
id: docker
attributes:
label: Docker version
placeholder: Docker Desktop 4.x / docker 27.x
validations:
required: true
- type: input
id: node
attributes:
label: Node.js version
placeholder: v22.x
validations:
required: true

293
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,293 @@
name: ci
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
concurrency:
group: ci-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm lint
check:
name: Typecheck
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm db:generate
env:
DATABASE_URL: postgres://rakazo:rakazo@127.0.0.1:5433/rakazo
- run: pnpm check
build:
name: Production builds
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v5
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build
env:
DATABASE_URL: postgres://rakazo:rakazo@127.0.0.1:5433/rakazo
BETTER_AUTH_SECRET: ci-build-auth-secret-at-least-32-characters
ENCRYPTION_KEY: ci-build-encryption-key-at-least-32-characters
- name: Electron smoke
run: xvfb-run --auto-servernum pnpm --filter @rakazo/desktop exec playwright test --config e2e/playwright.config.ts
test:
name: Unit tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm db:generate
env:
DATABASE_URL: postgres://rakazo:rakazo@127.0.0.1:5433/rakazo
- run: pnpm test
test-integration:
name: Postgres journeys
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v5
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm test:integration
test-e2e:
name: Web E2E
uses: ./.github/workflows/playwright.yml
with:
upload_artifacts: true
publish-mobile-update:
name: Publish compatible mobile update
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs: [lint, check, build, test, test-integration, test-e2e]
runs-on: ubuntu-latest
timeout-minutes: 20
concurrency:
group: mobile-production-update
cancel-in-progress: false
steps:
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
with:
fetch-depth: 0
persist-credentials: false
- name: Check whether the change is safe for OTA
id: ota
shell: bash
env:
BEFORE_SHA: ${{ github.event.before }}
run: |
set -euo pipefail
base="$BEFORE_SHA"
if [[ -z "$base" || "$base" =~ ^0+$ ]] || ! git cat-file -e "${base}^{commit}"; then
{
echo "### Mobile OTA skipped"
echo
echo "Could not resolve \`github.event.before\` for the full push range, so this job will not publish a partial-range update."
} >> "$GITHUB_STEP_SUMMARY"
echo "publish=false" >> "$GITHUB_OUTPUT"
exit 0
fi
mapfile -t files < <(
git diff --name-only "$base" "$GITHUB_SHA" -- \
.github/workflows/ci.yml \
apps/mobile \
package.json \
packages/chat-ui \
packages/contracts \
packages/core \
pnpm-lock.yaml
)
publish=false
blocked=false
for file in "${files[@]}"; do
case "$file" in
*.test.ts|*.test.tsx|*.d.ts)
continue
;;
esac
eligible=false
case "$file" in
apps/mobile/app/*|apps/mobile/components/*|apps/mobile/lib/*)
case "$file" in
*.ts|*.tsx) eligible=true ;;
esac
;;
packages/chat-ui/src/*)
case "$file" in
*.ts|*.tsx|*.css) eligible=true ;;
esac
;;
packages/contracts/src/*|packages/core/src/*)
case "$file" in
*.ts|*.tsx) eligible=true ;;
esac
;;
esac
if [[ "$eligible" == true ]]; then
publish=true
else
blocked=true
fi
done
if [[ "$blocked" == true ]]; then
publish=false
{
echo "### Mobile OTA skipped"
echo
echo "This revision changes native-sensitive mobile configuration, dependencies, modules, assets, or workflow files. Create new iOS and Android builds instead of publishing it to an older native runtime."
} >> "$GITHUB_STEP_SUMMARY"
elif [[ "$publish" == true ]]; then
{
echo "### Mobile OTA eligible"
echo
echo "The revision only changes JavaScript, TypeScript, or bundled CSS used by the mobile client."
} >> "$GITHUB_STEP_SUMMARY"
else
{
echo "### No mobile OTA needed"
echo
echo "This revision does not change the shipped mobile bundle."
} >> "$GITHUB_STEP_SUMMARY"
fi
echo "publish=$publish" >> "$GITHUB_OUTPUT"
- name: Require Expo automation token
if: steps.ota.outputs.publish == 'true'
env:
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
run: |
if [[ -z "$EXPO_TOKEN" ]]; then
echo "Add an EXPO_TOKEN repository secret with access to the inbox-zero/rakazo Expo project." >&2
exit 1
fi
- uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa # v4
if: steps.ota.outputs.publish == 'true'
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
if: steps.ota.outputs.publish == 'true'
with:
node-version: 24
cache: pnpm
- name: Set up Expo
if: steps.ota.outputs.publish == 'true'
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # v9
with:
eas-version: latest
packager: pnpm
token: ${{ secrets.EXPO_TOKEN }}
- name: Install dependencies
if: steps.ota.outputs.publish == 'true'
run: pnpm install --frozen-lockfile
- name: Verify mobile project
if: steps.ota.outputs.publish == 'true'
run: pnpm --filter @rakazo/mobile check
- name: Publish production update
if: steps.ota.outputs.publish == 'true'
working-directory: apps/mobile
run: |
set -euo pipefail
head="$(git ls-remote origin refs/heads/main | awk '{print $1}')"
if [[ -z "$head" ]]; then
echo "Could not resolve the current main head before publishing." >&2
exit 1
fi
if [[ "$GITHUB_SHA" != "$head" ]]; then
{
echo "### Mobile OTA skipped"
echo
echo "This run is for \`$GITHUB_SHA\`, but \`main\` is now \`$head\`. Skipping so an older revision cannot overwrite a newer production update."
} >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
eas update --platform all --channel production --environment production --message "$GITHUB_SHA" --non-interactive
deploy-production:
if: >-
github.event_name == 'push' &&
github.ref == 'refs/heads/main' &&
vars.PRODUCTION_DEPLOY_ENABLED == 'true'
needs: [lint, check, build, test, test-integration, test-e2e]
runs-on: ubuntu-latest
timeout-minutes: 30
concurrency:
group: production
cancel-in-progress: false
environment:
name: production
url: https://app.rakazo.com
steps:
- name: Configure production SSH identity
env:
SSH_PRIVATE_KEY: ${{ secrets.PRODUCTION_SSH_PRIVATE_KEY }}
SSH_KNOWN_HOSTS: ${{ secrets.PRODUCTION_SSH_KNOWN_HOSTS }}
run: |
test -n "$SSH_PRIVATE_KEY"
test -n "$SSH_KNOWN_HOSTS"
install -d -m 700 "$HOME/.ssh"
printf '%s\n' "$SSH_PRIVATE_KEY" > "$HOME/.ssh/rakazo-production"
printf '%s\n' "$SSH_KNOWN_HOSTS" > "$HOME/.ssh/known_hosts"
chmod 600 "$HOME/.ssh/rakazo-production" "$HOME/.ssh/known_hosts"
ssh-keygen -y -f "$HOME/.ssh/rakazo-production" >/dev/null
- name: Deploy successful main revision
env:
SSH_HOST: ${{ secrets.PRODUCTION_SSH_HOST }}
SSH_USER: ${{ secrets.PRODUCTION_SSH_USER }}
run: |
test -n "$SSH_HOST"
test -n "$SSH_USER"
ssh -F /dev/null \
-i "$HOME/.ssh/rakazo-production" \
-o BatchMode=yes \
-o ConnectTimeout=15 \
-o IdentitiesOnly=yes \
-o StrictHostKeyChecking=yes \
-o UserKnownHostsFile="$HOME/.ssh/known_hosts" \
"$SSH_USER@$SSH_HOST" deploy-main

View File

@ -0,0 +1,77 @@
name: nightly verification
on:
schedule:
- cron: "23 2 * * *"
workflow_dispatch:
inputs:
run_live_providers:
description: Run secret-gated OpenRouter, E2B, and Box canaries
required: false
type: boolean
default: false
permissions:
contents: read
concurrency:
group: nightly-verification
cancel-in-progress: false
jobs:
visual-web:
name: Daily web screenshots
uses: ./.github/workflows/playwright.yml
with:
publish_report: true
secrets:
S3_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY_ID }}
S3_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_ACCESS_KEY }}
topology:
name: Production topology smoke
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v5
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm test:topology
live-providers:
name: Live provider canaries
runs-on: ubuntu-latest
timeout-minutes: 30
env:
E2B_API_KEY: ${{ secrets.E2B_API_KEY }}
BOX_API_KEY: ${{ secrets.BOX_API_KEY }}
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
RUN_LIVE_PROVIDERS: ${{ github.event_name == 'schedule' || inputs.run_live_providers }}
steps:
- name: Check secret-gated canaries
id: gate
shell: bash
run: |
if [[ "$RUN_LIVE_PROVIDERS" == "true" && ( -n "$E2B_API_KEY" || -n "$BOX_API_KEY" || -n "$OPENROUTER_API_KEY" ) ]]; then
echo "enabled=true" >> "$GITHUB_OUTPUT"
else
echo "No requested live canary has a configured key; skipping safely."
echo "enabled=false" >> "$GITHUB_OUTPUT"
fi
- if: steps.gate.outputs.enabled == 'true'
uses: actions/checkout@v5
- if: steps.gate.outputs.enabled == 'true'
uses: pnpm/action-setup@v4
- if: steps.gate.outputs.enabled == 'true'
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- if: steps.gate.outputs.enabled == 'true'
run: pnpm install --frozen-lockfile
- if: steps.gate.outputs.enabled == 'true'
run: pnpm test:canary

137
.github/workflows/playwright.yml vendored Normal file
View File

@ -0,0 +1,137 @@
name: Playwright
on:
workflow_dispatch:
inputs:
sandbox_provider:
description: Sandbox provider
required: true
type: choice
options:
- fake
- e2b
- daytona
- box
default: fake
workflow_call:
inputs:
sandbox_provider:
description: Sandbox provider
required: false
type: string
default: fake
publish_report:
description: Publish this run to the persistent visual dashboard
required: false
type: boolean
default: false
upload_artifacts:
description: Retain the report and diagnostics as GitHub artifacts
required: false
type: boolean
default: false
secrets:
S3_ACCESS_KEY_ID:
description: Access key used to publish visual reports
required: false
S3_SECRET_ACCESS_KEY:
description: Secret key used to publish visual reports
required: false
E2B_API_KEY:
description: E2B API key used only when the E2B sandbox is selected
required: false
DAYTONA_API_KEY:
description: Daytona API key used only when the Daytona sandbox is selected
required: false
BOX_API_KEY:
description: Box API key used only when the Box sandbox is selected
required: false
permissions:
contents: read
jobs:
playwright:
name: Web E2E
runs-on: ubuntu-latest
timeout-minutes: 20
concurrency:
group: ${{ inputs.publish_report && 'playwright-publication' || format('playwright-{0}', github.run_id) }}
cancel-in-progress: false
steps:
- uses: actions/checkout@v5
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ hashFiles('pnpm-lock.yaml') }}
- name: Install Chromium
timeout-minutes: 5
# Skip --with-deps: GitHub ubuntu-latest already has Chromium's OS libraries,
# and apt-get against azure.archive.ubuntu.com can stall until the job timeout.
run: pnpm --filter @rakazo/web exec playwright install chromium
- name: Validate E2B credentials
if: inputs.sandbox_provider == 'e2b'
env:
E2B_API_KEY: ${{ secrets.E2B_API_KEY }}
run: test -n "$E2B_API_KEY"
- name: Validate Daytona credentials
if: inputs.sandbox_provider == 'daytona'
env:
DAYTONA_API_KEY: ${{ secrets.DAYTONA_API_KEY }}
run: test -n "$DAYTONA_API_KEY"
- name: Validate Box credentials
if: inputs.sandbox_provider == 'box'
env:
BOX_API_KEY: ${{ secrets.BOX_API_KEY }}
run: test -n "$BOX_API_KEY"
- name: Run Playwright tests
id: playwright_tests
env:
SANDBOX_TEST_PROVIDER: ${{ inputs.sandbox_provider }}
E2B_API_KEY: ${{ inputs.sandbox_provider == 'e2b' && secrets.E2B_API_KEY || '' }}
DAYTONA_API_KEY: ${{ inputs.sandbox_provider == 'daytona' && secrets.DAYTONA_API_KEY || '' }}
BOX_API_KEY: ${{ inputs.sandbox_provider == 'box' && secrets.BOX_API_KEY || '' }}
run: pnpm test:e2e -- --sandbox="$SANDBOX_TEST_PROVIDER"
- name: Record Playwright result
if: always() && inputs.upload_artifacts
env:
PLAYWRIGHT_RESULT: ${{ steps.playwright_tests.outcome }}
run: |
mkdir -p test-report/e2e
printf '%s\n' "$PLAYWRIGHT_RESULT" > test-report/e2e/outcome.txt
- name: Upload Playwright artifacts
if: always() && inputs.upload_artifacts
uses: actions/upload-artifact@v4
with:
name: playwright-artifacts-${{ github.run_id }}-${{ github.run_attempt }}
path: |
playwright-report
apps/web/test-results
test-report/e2e
retention-days: 7
if-no-files-found: warn
- name: Publish Playwright visual report
if: always() && inputs.publish_report
env:
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: ${{ vars.S3_REGION }}
AWS_EC2_METADATA_DISABLED: "true"
S3_BUCKET: ${{ vars.S3_BUCKET }}
S3_ENDPOINT: ${{ vars.S3_ENDPOINT }}
PLAYWRIGHT_PUBLIC_BASE_URL: ${{ vars.PLAYWRIGHT_PUBLIC_BASE_URL }}
PLAYWRIGHT_RESULT: ${{ steps.playwright_tests.outcome }}
PLAYWRIGHT_RUN_ATTEMPT: ${{ github.run_attempt }}
PLAYWRIGHT_RUN_ID: ${{ github.run_id }}
PLAYWRIGHT_RUN_NUMBER: ${{ github.run_number }}
PLAYWRIGHT_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
PLAYWRIGHT_SHA: ${{ github.sha }}
PLAYWRIGHT_EVENT: ${{ github.event_name }}
PLAYWRIGHT_BRANCH: ${{ github.ref_name }}
run: bash scripts/publish-playwright-report.sh

View File

@ -0,0 +1,178 @@
name: publish Playwright report
on:
workflow_run:
workflows: [ci]
types: [completed]
permissions:
actions: read
contents: read
issues: write
pull-requests: write
concurrency:
group: playwright-publication
cancel-in-progress: false
jobs:
publish:
if: >-
(github.event.workflow_run.conclusion == 'success' ||
github.event.workflow_run.conclusion == 'failure' ||
github.event.workflow_run.conclusion == 'timed_out') &&
(github.event.workflow_run.event == 'pull_request' ||
(github.event.workflow_run.event == 'push' &&
github.event.workflow_run.head_branch == 'main'))
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
# workflow_run has repository secrets. Always use trusted default-branch code here;
# never check out or execute the contributor's pull-request revision.
- uses: actions/checkout@v5
with:
ref: ${{ github.event.repository.default_branch }}
persist-credentials: false
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Read Playwright job result
id: playwright
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RUN_ID: ${{ github.event.workflow_run.id }}
shell: bash
run: |
result="$(gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${RUN_ID}/jobs?per_page=100" \
--jq '[.jobs[] | select(.name == "Web E2E / Web E2E") | .conclusion] | unique | if length == 1 then .[0] else error("expected exactly one Playwright job") end')"
case "$result" in
success|failure) ;;
timed_out) ;;
*) echo "Unexpected Playwright job conclusion: $result" >&2; exit 1 ;;
esac
echo "job_result=$result" >> "$GITHUB_OUTPUT"
if [[ "$result" == "timed_out" ]]; then result="failure"; fi
echo "result=$result" >> "$GITHUB_OUTPUT"
- name: Resolve pull request
if: github.event.workflow_run.event == 'pull_request'
id: pull_request
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
HEAD_OWNER: ${{ github.event.workflow_run.head_repository.owner.login }}
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
shell: bash
run: |
pulls="$(gh api --method GET \
"repos/${GITHUB_REPOSITORY}/pulls?state=all&head=${HEAD_OWNER}:${HEAD_BRANCH}&per_page=100")"
number="$(jq -r --arg sha "$HEAD_SHA" \
'[.[] | select(.head.sha == $sha)] | if length == 1 then .[0].number else empty end' \
<<<"$pulls")"
if [[ -z "$number" ]]; then
echo "::warning::Could not resolve a pull request for ${HEAD_OWNER}:${HEAD_BRANCH} at ${HEAD_SHA}."
fi
echo "number=$number" >> "$GITHUB_OUTPUT"
- name: Check for Playwright artifact
id: artifact
env:
ARTIFACT_NAME: playwright-artifacts-${{ github.event.workflow_run.id }}-${{ github.event.workflow_run.run_attempt }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PLAYWRIGHT_JOB_RESULT: ${{ steps.playwright.outputs.job_result }}
RUN_ID: ${{ github.event.workflow_run.id }}
shell: bash
run: |
artifacts="$(gh api --method GET \
"repos/${GITHUB_REPOSITORY}/actions/runs/${RUN_ID}/artifacts?name=${ARTIFACT_NAME}")"
count="$(jq --arg name "$ARTIFACT_NAME" \
'[.artifacts[] | select(.name == $name and .expired == false)] | length' \
<<<"$artifacts")"
if [[ "$count" == "1" ]]; then
echo "exists=true" >> "$GITHUB_OUTPUT"
elif [[ "$count" == "0" && "$PLAYWRIGHT_JOB_RESULT" == "timed_out" ]]; then
echo "::warning::The timed-out Playwright job ended before it uploaded an artifact. Publishing an empty gallery."
echo "exists=false" >> "$GITHUB_OUTPUT"
else
echo "Expected exactly one unexpired Playwright artifact; found $count." >&2
exit 1
fi
- name: Download Playwright artifacts
if: steps.artifact.outputs.exists == 'true'
uses: actions/download-artifact@v4
with:
name: playwright-artifacts-${{ github.event.workflow_run.id }}-${{ github.event.workflow_run.run_attempt }}
path: .
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}
- name: Publish trusted Playwright screenshot gallery
id: publish
env:
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: ${{ vars.S3_REGION }}
AWS_EC2_METADATA_DISABLED: "true"
S3_BUCKET: ${{ vars.S3_BUCKET }}
S3_ENDPOINT: ${{ vars.S3_ENDPOINT }}
PLAYWRIGHT_PUBLIC_BASE_URL: ${{ vars.PLAYWRIGHT_PUBLIC_BASE_URL }}
PLAYWRIGHT_RESULT: ${{ steps.playwright.outputs.result }}
PLAYWRIGHT_RUN_ATTEMPT: ${{ github.event.workflow_run.run_attempt }}
PLAYWRIGHT_RUN_ID: ${{ github.event.workflow_run.id }}
PLAYWRIGHT_RUN_NUMBER: ${{ github.event.workflow_run.run_number }}
PLAYWRIGHT_RUN_URL: ${{ github.event.workflow_run.html_url }}
PLAYWRIGHT_SHA: ${{ github.event.workflow_run.head_sha }}
PLAYWRIGHT_EVENT: ${{ github.event.workflow_run.event }}
PLAYWRIGHT_BRANCH: ${{ github.event.workflow_run.head_branch }}
PLAYWRIGHT_PR_NUMBER: ${{ steps.pull_request.outputs.number }}
PLAYWRIGHT_REPOSITORY_URL: ${{ github.server_url }}/${{ github.repository }}
PLAYWRIGHT_PUBLISH_REPORT: ${{ github.event.workflow_run.event == 'push' }}
run: bash scripts/publish-playwright-report.sh
- name: Link screenshot gallery from pull request
if: >-
github.event.workflow_run.event == 'pull_request' &&
steps.pull_request.outputs.number != '' &&
steps.publish.outputs.latest_pr_run == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PLAYWRIGHT_PUBLIC_BASE_URL: ${{ vars.PLAYWRIGHT_PUBLIC_BASE_URL }}
PR_NUMBER: ${{ steps.pull_request.outputs.number }}
RUN_URL: ${{ github.event.workflow_run.html_url }}
SHA: ${{ github.event.workflow_run.head_sha }}
shell: bash
run: |
if [[ ! "$PR_NUMBER" =~ ^[1-9][0-9]*$ ]]; then
echo "PR_NUMBER must be a positive integer." >&2
exit 1
fi
public_base_url="${PLAYWRIGHT_PUBLIC_BASE_URL%/}"
gallery_url="$public_base_url/prs/$PR_NUMBER/index.html"
review_path="$GITHUB_WORKSPACE/.tmp/playwright-dashboard/screenshots/review.json"
if [[ ! -f "$review_path" ]]; then
echo "Missing Playwright review manifest at $review_path." >&2
exit 1
fi
changed_paths_file="$(mktemp)"
gh api --paginate \
"repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?per_page=100" \
--jq '.[].filename' > "$changed_paths_file"
body="$(
PLAYWRIGHT_GALLERY_URL="$gallery_url" \
PLAYWRIGHT_DASHBOARD_URL="$public_base_url/index.html" \
PLAYWRIGHT_RUN_URL="$RUN_URL" \
PLAYWRIGHT_SHA="$SHA" \
pnpm exec tsx packages/testkit/src/cli/build-playwright-pr-screenshot-comment.ts \
"$review_path" \
"$changed_paths_file"
)"
marker="<!-- rakazo-playwright-screenshots -->"
comment_id="$(gh api --paginate --slurp \
"repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments?per_page=100" | \
jq -r --arg marker "$marker" \
'[.[][] | select(.user.login == "github-actions[bot]" and (.body | contains($marker)))] | last | .id // empty')"
payload="$(jq -n --arg body "$body" '{body: $body}')"
if [[ -n "$comment_id" ]]; then
gh api --method PATCH "repos/${GITHUB_REPOSITORY}/issues/comments/${comment_id}" --input - <<<"$payload"
else
gh api --method POST "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" --input - <<<"$payload"
fi

View File

@ -0,0 +1,153 @@
name: publish-server-image
# Release tags publish the source-addressed image used by in-app updates plus the human-readable
# semver tags. Main publishes `edge`. Pull requests build with a read-only token and an isolated
# cache, so untrusted code can validate the images without receiving package-publish authority.
on:
push:
branches: [main]
tags: ["v*"]
pull_request:
paths:
- ".dockerignore"
- ".github/workflows/publish-server-image.yml"
- "apps/**"
- "infra/compose/**"
- "infra/sandboxes/computer/**"
- "infra/updater/**"
- "packages/**"
- "package.json"
- "pnpm-lock.yaml"
- "pnpm-workspace.yaml"
workflow_dispatch:
permissions:
contents: read
env:
# docker/metadata-action otherwise truncates commit tags to seven collision-prone characters.
DOCKER_METADATA_SHORT_SHA_LENGTH: 40
concurrency:
group: publish-server-image-${{ github.ref }}
cancel-in-progress: false
jobs:
validate:
name: Validate ${{ matrix.name }} image
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
timeout-minutes: 90
permissions:
contents: read
strategy:
fail-fast: false
matrix:
include:
- name: app
dockerfile: infra/compose/Dockerfile
context: .
- name: updater
dockerfile: infra/updater/Dockerfile
context: .
- name: computer
dockerfile: infra/sandboxes/computer/Dockerfile
context: infra/sandboxes/computer
steps:
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
with:
persist-credentials: false
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
with:
images: ghcr.io/${{ github.repository }}/${{ matrix.name }}
tags: type=sha,prefix=sha-
- uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with:
context: ${{ matrix.context }}
file: ${{ matrix.dockerfile }}
push: false
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: ${{ matrix.name == 'app' && format('GIT_SHA={0}', github.sha) || '' }}
cache-from: type=gha,scope=${{ matrix.name }}
cache-to: type=gha,mode=max,scope=pr-${{ github.event.pull_request.number }}-${{ matrix.name }}
provenance: false
sbom: false
publish:
name: Publish ${{ matrix.name }} image
if: github.event_name != 'pull_request'
runs-on: ubuntu-latest
# QEMU arm64 is slow; keep everyday main→edge publishes at 90m. Multi-arch
# (v* tags + workflow_dispatch) needs the longer budget.
timeout-minutes: ${{ (github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v')) && 180 || 90 }}
permissions:
contents: read
packages: write
id-token: write
attestations: write
strategy:
fail-fast: false
matrix:
include:
# Only the updater image contains the Docker CLI; the application image stays unprivileged.
# The supervisor is not a separate published image: it runs from `app` on the internal network.
- name: app
dockerfile: infra/compose/Dockerfile
context: .
- name: updater
dockerfile: infra/updater/Dockerfile
context: .
- name: computer
dockerfile: infra/sandboxes/computer/Dockerfile
context: infra/sandboxes/computer
steps:
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
with:
persist-credentials: false
# Emulated arm64 only for releases and manual runs — not the ~10x/day main merge train.
- if: github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v')
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Log in to GHCR
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
with:
# A fork can publish only to its own namespace; no credential can redirect this value.
images: ghcr.io/${{ github.repository }}/${{ matrix.name }}
tags: |
type=semver,pattern=v{{version}}
type=semver,pattern=v{{major}}.{{minor}}
type=sha,prefix=sha-
type=raw,value=edge,enable=${{ github.ref == 'refs/heads/main' }}
# Stable releases only: a hyphen marks a prerelease (v1.0.0-rc.1) that must not move latest.
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') && !contains(github.ref, '-') }}
- id: build
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with:
context: ${{ matrix.context }}
file: ${{ matrix.dockerfile }}
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
# Main→edge stays native amd64. Tags and workflow_dispatch publish amd64+arm64.
platforms: ${{ (github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v')) && 'linux/amd64,linux/arm64' || 'linux/amd64' }}
# GET /health can report the exact source commit without a deployment-supplied override.
build-args: ${{ matrix.name == 'app' && format('GIT_SHA={0}', github.sha) || '' }}
cache-from: type=gha,scope=${{ matrix.name }}
cache-to: type=gha,mode=max,scope=${{ matrix.name }}
provenance: mode=max
sbom: true
- name: Attest the published image
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
with:
subject-name: ghcr.io/${{ github.repository }}/${{ matrix.name }}
subject-digest: ${{ steps.build.outputs.digest }}
push-to-registry: true

324
.github/workflows/release-desktop.yml vendored Normal file
View File

@ -0,0 +1,324 @@
name: release-desktop
on:
push:
tags: ["v*"]
workflow_dispatch:
permissions:
contents: read
concurrency:
# Stable releases must validate and publish serially. Per-tag concurrency would let an older
# release validate before a newer one publishes, then overwrite GitHub's "latest" pointer.
group: release-desktop-stable
cancel-in-progress: false
jobs:
validate:
name: Validate stable release
runs-on: ubuntu-24.04
outputs:
tag: ${{ steps.release.outputs.tag }}
version: ${{ steps.release.outputs.version }}
sha: ${{ steps.release.outputs.sha }}
steps:
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
with:
fetch-depth: 0
persist-credentials: false
- name: Validate tag, version, ancestry, and monotonicity
id: release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_TAG: ${{ github.ref_name }}
RELEASE_REF_TYPE: ${{ github.ref_type }}
shell: bash
run: |
set -euo pipefail
if [[ "$RELEASE_REF_TYPE" != "tag" ]]; then
echo "Desktop releases must run from a tag, including manual dispatches." >&2
exit 1
fi
if [[ ! "$RELEASE_TAG" =~ ^v([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
echo "Desktop releases use the stable vMAJOR.MINOR.PATCH channel." >&2
exit 1
fi
version="$(node -p "require('./apps/desktop/package.json').version")"
if [[ "$RELEASE_TAG" != "v$version" ]]; then
echo "Tag $RELEASE_TAG does not match desktop version $version." >&2
exit 1
fi
tag_sha="$(git rev-parse "${RELEASE_TAG}^{commit}")"
if [[ "$tag_sha" != "$GITHUB_SHA" ]]; then
echo "The workflow revision does not match the tagged commit." >&2
exit 1
fi
git fetch --no-tags origin main
if ! git merge-base --is-ancestor "$tag_sha" origin/main; then
echo "Desktop releases must point to a commit already on main." >&2
exit 1
fi
if gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${RELEASE_TAG}" >/dev/null 2>&1; then
echo "A release already exists for $RELEASE_TAG; refusing to replace it." >&2
exit 1
fi
latest_tag="$(
gh api --paginate "repos/${GITHUB_REPOSITORY}/releases?per_page=100" --jq '.[] | select(.draft == false and .prerelease == false) | .tag_name' |
sed -nE '/^v[0-9]+\.[0-9]+\.[0-9]+$/p' |
sort -V |
tail -n 1
)"
if [[ -n "$latest_tag" ]]; then
highest="$(printf '%s\n%s\n' "$latest_tag" "$RELEASE_TAG" | sort -V | tail -n 1)"
if [[ "$highest" != "$RELEASE_TAG" || "$latest_tag" == "$RELEASE_TAG" ]]; then
echo "$RELEASE_TAG must be newer than published release $latest_tag." >&2
exit 1
fi
fi
echo "tag=$RELEASE_TAG" >> "$GITHUB_OUTPUT"
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "sha=$tag_sha" >> "$GITHUB_OUTPUT"
build:
name: Build signed ${{ matrix.artifact }} artifacts
needs: validate
runs-on: ${{ matrix.os }}
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
include:
- os: macos-14
artifact: macos
- os: windows-2022
artifact: windows
- os: ubuntu-24.04
artifact: linux
steps:
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
with:
ref: ${{ needs.validate.outputs.sha }}
persist-credentials: false
- uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
- run: pnpm install --frozen-lockfile
- name: Build renderer and Electron main process
run: pnpm --filter @rakazo/web build && pnpm --filter @rakazo/desktop build
env:
RAKAZO_ALLOW_DEV_SECRETS: "1"
- name: Require macOS signing and notarization credentials
if: runner.os == 'macOS'
shell: bash
env:
CSC_LINK: ${{ secrets.DESKTOP_MAC_CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.DESKTOP_MAC_CSC_KEY_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
set -euo pipefail
test -n "$CSC_LINK"
test -n "$CSC_KEY_PASSWORD"
test -n "$APPLE_ID"
test -n "$APPLE_APP_SPECIFIC_PASSWORD"
test -n "$APPLE_TEAM_ID"
- name: Package signed and notarized universal macOS app
if: runner.os == 'macOS'
env:
CSC_LINK: ${{ secrets.DESKTOP_MAC_CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.DESKTOP_MAC_CSC_KEY_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: >-
pnpm --filter @rakazo/desktop exec electron-builder
--mac --universal --publish never -c.forceCodeSigning=true
- name: Verify macOS signature, notarization ticket, and update feed
if: runner.os == 'macOS'
shell: bash
run: |
set -euo pipefail
app="apps/desktop/out/mac-universal/Rakazo.app"
codesign --verify --deep --strict --verbose=2 "$app"
xcrun stapler validate "$app"
spctl --assess --type execute --verbose=2 "$app"
grep -Fqx "provider: github" "$app/Contents/Resources/app-update.yml"
grep -Fqx "owner: elie222" "$app/Contents/Resources/app-update.yml"
grep -Fqx "repo: rakazo" "$app/Contents/Resources/app-update.yml"
- name: Require Windows signing credentials
if: runner.os == 'Windows'
shell: bash
env:
WIN_CSC_LINK: ${{ secrets.DESKTOP_WIN_CSC_LINK }}
WIN_CSC_KEY_PASSWORD: ${{ secrets.DESKTOP_WIN_CSC_KEY_PASSWORD }}
run: |
set -euo pipefail
test -n "$WIN_CSC_LINK"
test -n "$WIN_CSC_KEY_PASSWORD"
- name: Package signed x64 Windows app
if: runner.os == 'Windows'
env:
WIN_CSC_LINK: ${{ secrets.DESKTOP_WIN_CSC_LINK }}
WIN_CSC_KEY_PASSWORD: ${{ secrets.DESKTOP_WIN_CSC_KEY_PASSWORD }}
run: >-
pnpm --filter @rakazo/desktop exec electron-builder
--win --x64 --publish never -c.forceCodeSigning=true
- name: Verify Windows Authenticode signature and publisher-bound update feed
if: runner.os == 'Windows'
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
$executables = @(
Get-ChildItem "apps/desktop/out/*.exe"
Get-ChildItem "apps/desktop/out/win-unpacked/*.exe"
)
if ($executables.Count -lt 2) { throw "Windows installer or application was not created." }
foreach ($executable in $executables) {
$signature = Get-AuthenticodeSignature $executable.FullName
if ($signature.Status -ne "Valid") {
throw "$($executable.Name) signature is $($signature.Status)."
}
}
$config = "apps/desktop/out/win-unpacked/resources/app-update.yml"
if (-not (Select-String -Path $config -Pattern '^publisherName:' -Quiet)) {
throw "Windows update config is not bound to the signing publisher."
}
$feed = Get-Content -Raw $config
foreach ($expected in @("provider: github", "owner: elie222", "repo: rakazo")) {
if ($feed -notmatch "(?m)^$([regex]::Escape($expected))\r?$") {
throw "Windows update config missing '$expected'."
}
}
- name: Package x64 Linux AppImage
if: runner.os == 'Linux'
run: >-
pnpm --filter @rakazo/desktop exec electron-builder
--linux --x64 --publish never
- name: Verify Linux update feed is pinned to the official GitHub channel
if: runner.os == 'Linux'
shell: bash
env:
RELEASE_VERSION: ${{ needs.validate.outputs.version }}
run: |
set -euo pipefail
config="apps/desktop/out/linux-unpacked/resources/app-update.yml"
if [[ ! -f "$config" ]]; then
config="$(find apps/desktop/out -name app-update.yml -print -quit)"
fi
test -n "$config"
test -f "$config"
grep -Fqx "provider: github" "$config"
grep -Fqx "owner: elie222" "$config"
grep -Fqx "repo: rakazo" "$config"
test -f apps/desktop/out/latest-linux.yml
grep -Fqx "version: $RELEASE_VERSION" apps/desktop/out/latest-linux.yml
- name: Retain installers and updater metadata
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: desktop-${{ matrix.artifact }}-${{ needs.validate.outputs.sha }}
path: |
apps/desktop/out/*.AppImage
apps/desktop/out/*.blockmap
apps/desktop/out/*.dmg
apps/desktop/out/*.exe
apps/desktop/out/latest*.yml
apps/desktop/out/*.zip
if-no-files-found: error
compression-level: 0
retention-days: 7
publish:
name: Attest and publish complete release
needs: [validate, build]
runs-on: ubuntu-24.04
timeout-minutes: 15
permissions:
actions: read
attestations: write
contents: write
id-token: write
steps:
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
with:
fetch-depth: 0
persist-credentials: false
ref: ${{ needs.validate.outputs.sha }}
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
pattern: desktop-*-${{ needs.validate.outputs.sha }}
path: release-artifacts
merge-multiple: true
- name: Verify the complete stable update feed
env:
RELEASE_SHA: ${{ needs.validate.outputs.sha }}
RELEASE_TAG: ${{ needs.validate.outputs.tag }}
RELEASE_VERSION: ${{ needs.validate.outputs.version }}
shell: bash
run: |
set -euo pipefail
git fetch --force origin "refs/tags/${RELEASE_TAG}:refs/tags/${RELEASE_TAG}"
if [[ "$(git rev-parse "${RELEASE_TAG}^{commit}")" != "$RELEASE_SHA" ]]; then
echo "The release tag moved after the build started." >&2
exit 1
fi
test -f release-artifacts/latest.yml
test -f release-artifacts/latest-mac.yml
test -f release-artifacts/latest-linux.yml
compgen -G 'release-artifacts/*.dmg' >/dev/null
compgen -G 'release-artifacts/*.zip' >/dev/null
compgen -G 'release-artifacts/*.exe' >/dev/null
compgen -G 'release-artifacts/*.AppImage' >/dev/null
feeds=(
release-artifacts/latest.yml
release-artifacts/latest-mac.yml
release-artifacts/latest-linux.yml
)
for feed in "${feeds[@]}"; do
grep -Fqx "version: $RELEASE_VERSION" "$feed"
done
(
cd release-artifacts
find . -maxdepth 1 -type f ! -name SHA256SUMS -print0 |
sort -z |
xargs -0 sha256sum > SHA256SUMS
)
- name: Attest release provenance
uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3
with:
subject-path: release-artifacts/*
- name: Create draft and upload every platform
id: create_release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_TAG: ${{ needs.validate.outputs.tag }}
run: >-
gh release create "$RELEASE_TAG" release-artifacts/*
--draft --generate-notes --title "Rakazo $RELEASE_TAG" --verify-tag
- name: Publish the completed release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_TAG: ${{ needs.validate.outputs.tag }}
run: gh release edit "$RELEASE_TAG" --draft=false --latest
- name: Remove an incomplete draft
if: failure() && steps.create_release.outcome != 'skipped'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_TAG: ${{ needs.validate.outputs.tag }}
shell: bash
run: |
if [[ "$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${RELEASE_TAG}" --jq .draft 2>/dev/null)" == "true" ]]; then
gh release delete "$RELEASE_TAG" --yes
fi

45
.gitignore vendored Normal file
View File

@ -0,0 +1,45 @@
.deepsec/
node_modules
__pycache__/
*.py[cod]
.turbo
dist
build
coverage
.playwright-report
test-results
playwright-report
*.tsbuildinfo
.env
.env.local
.env.*.local
data
apps/**/data
.tmp
.DS_Store
apps/web/dist
apps/desktop/dist
apps/desktop/out
apps/desktop/e2e/screenshots
apps/mobile/.expo
apps/mobile/dist
apps/mobile/ios
packages/db/src/generated
infra/sandboxes/supervisor/dist
*.log
.vercel
.cache
artifacts
backups
test-report
verify-report
.astro
.deploy.lock
.last-deployed-revision
design/
PRODUCT_PLAN.md
# Lingui compiled catalogs — Vite plugin compiles .po at build time
apps/web/src/locales/**/*.mjs
apps/web/src/locales/**/*.js
apps/web/scripts/msgids.json

3
.npmrc Normal file
View File

@ -0,0 +1,3 @@
engine-strict=true
strict-peer-dependencies=false
auto-install-peers=true

11
AGENTS.md Normal file
View File

@ -0,0 +1,11 @@
# AGENTS.md
- This is a public repository: assume all tracked content and diffs are public. Never commit secrets, `.env` files, private URLs, personal/customer data, or real production data; use fake placeholders. Review `git status` and the staged diff before committing, and never force-add ignored files. If private data appears, stop and alert the maintainer.
- BangSo Bot is one product across web, Electron desktop, and Expo mobile; Electron hosts the web UI. Put shared behavior, contracts, API logic, and reusable UI in packages. Keep only genuinely native navigation, storage, permissions, and interactions platform-specific. Core workflows must cover every applicable surface or degrade safely for an explicit reason.
- No hosted vendor is required to run the core product. Keep LLMs, sandboxes, memory, voice, integrations, and future external services optional and behind provider-neutral interfaces. Vendor SDKs, configuration, and translation belong only in adapters and composition roots. New providers must reuse shared contracts and deterministic offline conformance tests.
- Keep UI and copy minimal. Show advanced capability progressively and only when it becomes relevant; do not add explainer text that repeats the interface. Frontends express intent and render state; the backend owns orchestration, authorization, validation, retries, recovery, and provider translation. Give controls concise accessible names when needed.
- Keep code simple: reuse existing primitives and one source of truth, remove duplication and unused flexibility, and avoid speculative abstractions. Add an interface when it protects a real external or platform boundary, not for its own sake.
- Treat auth, secret handling, sandbox boundaries, host commands, and integrations as security-sensitive. Keep tests deterministic and offline by default.
- After creating a pull request, stay with it until CI and automated review bots have finished. Poll checks, reviews, review threads, and PR comments at roughly 60-second intervals; passing checks alone do not mean the review is complete. Address every actionable issue, push the fixes, and repeat the review cycle until no actionable feedback remains. Do not merge while review bots are still pending or review issues remain unresolved.
- For UI changes, link the CI E2E screenshot that shows the change on the PR; add the web test that opens that screen if it is missing. For native-only mobile UI that CI cannot capture, say so in the PR instead of linking an unrelated web screenshot.
- For UI work, check Beautiful UI first (https://www.beautifului.dev/, source: github.com/TurboKach/ai-native-react-components, MIT) before building anything by hand. It names the AI-native surfaces this product needs (loading/thinking states, streaming text, approval cards, tool chips, task rows, composer, tables). Hand-port from source into `apps/web/src/components/beautiful-ui/` — read the component and adapt its tokens; never `shadcn add` from the live registry (supply-chain risk). Reuse the ported primitives (`LoadingState`, `Shimmer`, `SuccessPop`, `BuiCard`, `BuiButton`) before inventing new ones.

32
CHANGELOG.md Normal file
View File

@ -0,0 +1,32 @@
# Changelog
Notable product changes in Rakazo. This is for people following the repo, not a dump of every commit. GitHub Releases still mark tagged builds.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased]
### Changed
- The phone surface became a multi-platform messaging surface built on the open-source Chat SDK. Slack, WhatsApp Business Cloud, and Telegram DMs now work alongside iMessage/SMS (Sendblue). Link a chat app to your account from Messaging settings on the web: pick a bot, send the short-lived code to the line, and that conversation reaches that bot — each app can point at a different bot. Unknown senders are ignored unless `MESSAGING_OPEN_SIGNUP=true`, which restores the old text-first auto-provisioning (and is the only mode that needs the deployment model key). Webhooks move to `/api/v1/messaging/webhook/<provider>` (the old Sendblue path still works), and each platform mounts when its env credentials are set — see `.env.example`. Group channels remain iMessage-only for now; other platforms are 1:1 until their channel semantics are mapped.
- Model picker includes Grok 4.6 (xAI) and Ox Alpha Free / GLM-5.3 (OpenCode Go).
### Added
- Voice mode: speak replies, hold-to-talk dictation, and half-duplex calls. Speech sits behind a `VoiceProvider` interface (ElevenLabs, OpenAI, Cartesia) so the product is not tied to one vendor. Keys stay on the server.
- Electron first-run: Docker (default) or this Mac. This Mac runs the bot shell as you, with working directories under your home folder. macOS does not show its own permission dialog; the consent is Rakazo's. The choice is owner-only and is refused when `SANDBOX_PROVIDER` is not `docker` (so E2B and test fakes cannot enable it).
- GitHub Copilot and SuperGrok / X Premium sign-in via Pi device-code OAuth (`openai-codex`, `github-copilot`, `xai`). Claude Pro is still omitted because Pi's Claude login uses a localhost callback that does not work from the web app.
- Spawn peer bots (each with its own thread and computer) and short-lived in-thread subagents.
- ChatGPT Plus or Pro sign-in for model access.
- Mobile: point the app at a self-hosted API origin, a native iOS inbox, and take control of the live desktop.
- Provider-neutral integrations: managed apps through Composio or Pipedream Connect, plus encrypted user-installed Treg, HTTPS MCP, and OpenAPI tool sources on web and mobile.
- Revoke for connected Composio plugins.
- Routines in plain language instead of raw cron.
### Removed
- Unused Grant folder picker in the desktop app. Bots never got a host folder that way.
## [0.1.0-beta] - 2026-08-13
Initial public beta: web, Electron, and Expo clients; Pi runtime; Docker and E2B computers; plugins; one thread, computer, memory, routines, and history per bot.

1
CLAUDE.md Normal file
View File

@ -0,0 +1 @@
@AGENTS.md

61
CONTRIBUTING.md Normal file
View File

@ -0,0 +1,61 @@
# Contributing to Rakazo
Thanks for helping improve Rakazo. Keep changes focused and testable.
## Run locally
See [README.md](README.md) for full details. Quick start from the repo root:
```bash
cp .env.example .env
# Set BETTER_AUTH_SECRET and ENCRYPTION_KEY to long random strings.
docker compose --env-file .env -f infra/compose/docker-compose.yml up postgres -d
pnpm install
pnpm db:generate
pnpm db:migrate
pnpm sandbox:build
pnpm dev
```
## Checks before you open a PR
| Command | When to run |
| --- | --- |
| `pnpm test` | Default. Units, properties, and in-process contracts. Scripted runtime, fake sandbox, in-memory wakeup — no live connector or model-provider calls. |
| `pnpm test:integration` | Postgres via Testcontainers: product journeys, authorization, executor lifecycle, Graphile / LISTEN/NOTIFY. Needs Docker. |
| `pnpm test:e2e` | Playwright against the emulated API. Needs Docker. |
| `pnpm test:topology` | Local product-path smoke: Docker computer + Graphile worker recovery. Needs Docker. Not PR CI. |
| `pnpm test:canary` | Live OpenRouter / E2B canaries. Needs keys. Not PR CI. |
| `pnpm test:computer` | Real vision model + E2B desktop. Needs keys; see README. Not PR CI. |
| `pnpm check` | TypeScript (`tsc`) across the monorepo. |
| `pnpm lint` | Biome lint and format check. |
CI runs `pnpm lint`, `pnpm check`, production builds (including Electron preload smoke), `pnpm test`, `pnpm test:integration`, and `pnpm test:e2e` on every PR.
## Secrets and configuration
- **Never** commit `.env` files or secrets.
- **Never** paste API keys, tokens, or passwords in issues or PRs.
- Use placeholders in examples (`your-openrouter-key`, etc.).
The product path is **Pi + Docker + Graphile**. Emulator settings (`AGENT_RUNTIME=scripted`, `SANDBOX_PROVIDER=fake`, `WAKEUP_DRIVER=memory`) are for tests only.
**Integrations** can use [Composio](https://composio.dev/) or Pipedream Connect as optional managed
app catalogs. Users can also install HTTPS MCP servers (including Treg) and bounded OpenAPI tool
sources. Connector tests must stay deterministic and offline. Never put connector credentials in
capability config, fixtures, logs, or snapshots; use the encrypted secret store and fake placeholders.
## Pull requests
- Keep PRs small and easy to review.
- Target the `main` branch.
- Describe what changed and **how you tested** (e.g. `pnpm test`, manual steps).
- Link related issues when applicable.
## Contact
| Address | Use for |
| --- | --- |
| [security@rakazo.com](mailto:security@rakazo.com) | Vulnerabilities only — see [SECURITY.md](SECURITY.md) |
| [support@rakazo.com](mailto:support@rakazo.com) | User and support questions |
| [elie@rakazo.com](mailto:elie@rakazo.com) | Maintainer |

178
LICENSE Normal file
View File

@ -0,0 +1,178 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction, and
distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by the
copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all other
entities that control, are controlled by, or are under common control with
that entity. For the purposes of this definition, "control" means (i) the
power, direct or indirect, to cause the direction or management of such
entity, whether by contract or otherwise, or (ii) ownership of fifty percent
(50%) or more of the outstanding shares, or (iii) beneficial ownership of
such entity.
"You" (or "Your") shall mean an individual or Legal Entity exercising
permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation source, and
configuration files.
"Object" form shall mean any form resulting from mechanical transformation or
translation of a Source form, including but not limited to compiled object
code, generated documentation, and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or Object form,
made available under the License, as indicated by a copyright notice that is
included in or attached to the work (an example is provided in the Appendix
below).
"Derivative Works" shall mean any work, whether in Source or Object form,
that is based on (or derived from) the Work and for which the editorial
revisions, annotations, elaborations, or other modifications represent, as a
whole, an original work of authorship. For the purposes of this License,
Derivative Works shall not include works that remain separable from, or
merely link (or bind by name) to the interfaces of, the Work and Derivative
Works thereof.
"Contribution" shall mean any work of authorship, including the original
version of the Work and any modifications or additions to that Work or
Derivative Works thereof, that is intentionally submitted to Licensor for
inclusion in the Work by the copyright owner or by an individual or Legal
Entity authorized to submit on behalf of the copyright owner. For the
purposes of this definition, "submitted" means any form of electronic,
verbal, or written communication sent to the Licensor or its representatives,
including but not limited to communication on electronic mailing lists,
source code control systems, and issue tracking systems that are managed by,
or on behalf of, the Licensor for the purpose of discussing and improving
the Work, but excluding communication that is conspicuously marked or
otherwise designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity on
behalf of whom a Contribution has been received by Licensor and subsequently
incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of this
License, each Contributor hereby grants to You a perpetual, worldwide,
non-exclusive, no-charge, royalty-free, irrevocable copyright license to
reproduce, prepare Derivative Works of, publicly display, publicly perform,
sublicense, and distribute the Work and such Derivative Works in Source or
Object form.
3. Grant of Patent License. Subject to the terms and conditions of this
License, each Contributor hereby grants to You a perpetual, worldwide,
non-exclusive, no-charge, royalty-free, irrevocable (except as stated in
this section) patent license to make, have made, use, offer to sell, sell,
import, and otherwise transfer the Work, where such license applies only to
those patent claims licensable by such Contributor that are necessarily
infringed by their Contribution(s) alone or by combination of their
Contribution(s) with the Work to which such Contribution(s) was submitted.
If You institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work or a
Contribution incorporated within the Work constitutes direct or contributory
patent infringement, then any patent licenses granted to You under this
License for that Work shall terminate as of the date such litigation is
filed.
4. Redistribution. You may reproduce and distribute copies of the Work or
Derivative Works thereof in any medium, with or without modifications, and
in Source or Object form, provided that You meet the following conditions:
(a) You must give any other recipients of the Work or Derivative Works a
copy of this License; and
(b) You must cause any modified files to carry prominent notices stating
that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works that You
distribute, all copyright, patent, trademark, and attribution notices from
the Source form of the Work, excluding those notices that do not pertain to
any part of the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its distribution,
then any Derivative Works that You distribute must include a readable copy
of the attribution notices contained within such NOTICE file, excluding
those notices that do not pertain to any part of the Derivative Works, in at
least one of the following places: within a NOTICE text file distributed as
part of the Derivative Works; within the Source form or documentation, if
provided along with the Derivative Works; or, within a display generated by
the Derivative Works, if and wherever such third-party notices normally
appear. The contents of the NOTICE file are for informational purposes only
and do not modify the License. You may add Your own attribution notices
within Derivative Works that You distribute, alongside or as an addendum to
the NOTICE text from the Work, provided that such additional attribution
notices cannot be construed as modifying the License.
You may add Your own copyright statement to Your modifications and may
provide additional or different license terms and conditions for use,
reproduction, or distribution of Your modifications, or for any such
Derivative Works as a whole, provided Your use, reproduction, and
distribution of the Work otherwise complies with the conditions stated in
this License.
5. Submission of Contributions. Unless You explicitly state otherwise, any
Contribution intentionally submitted for inclusion in the Work by You to the
Licensor shall be under the terms and conditions of this License, without
any additional terms or conditions. Notwithstanding the above, nothing
herein shall supersede or modify the terms of any separate license agreement
you may have executed with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor, except
as required for reasonable and customary use in describing the origin of the
Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or agreed to
in writing, Licensor provides the Work (and each Contributor provides its
Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied, including, without limitation, any
warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or
FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining
the appropriateness of using or redistributing the Work and assume any risks
associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory, whether
in tort (including negligence), contract, or otherwise, unless required by
applicable law (such as deliberate and grossly negligent acts) or agreed to
in writing, shall any Contributor be liable to You for damages, including
any direct, indirect, special, incidental, or consequential damages of any
character arising as a result of this License or out of the use or inability
to use the Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all other
commercial damages or losses), even if such Contributor has been advised of
the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing the
Work or Derivative Works thereof, You may choose to offer, and charge a fee
for, acceptance of support, warranty, indemnity, or other liability
obligations and/or rights consistent with this License. However, in
accepting such obligations, You may act only on Your own behalf and on Your
sole responsibility, not on behalf of any other Contributor, and only if
You agree to indemnify, defend, and hold each Contributor harmless for any
liability incurred by, or claims asserted against, such Contributor by
reason of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
Copyright 2026 Rakazo contributors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

200
README.md Normal file
View File

@ -0,0 +1,200 @@
# BangSo Bot
[![GitHub stars](https://img.shields.io/github/stars/elie222/rakazo?labelColor=black&style=for-the-badge&color=2563EB)](https://github.com/elie222/rakazo/stargazers)
[![Discord](https://img.shields.io/badge/Discord-Join%20the%20community-5865F2?labelColor=black&style=for-the-badge&logo=discord&logoColor=white)](https://discord.gg/RWwKa2Sn7h)
![BangSo Bot — AI teammates you actually own](./docs/readme-hero.png)
BangSo Bot is an open-source platform for running persistent AI teammates. It is available on the web,
as an Electron desktop app, and through an Expo mobile app. Bring your own model and computer
provider, or run the complete stack locally.
BangSo Bot is in beta. Learn more at [rakazo.com](https://rakazo.com).
## Features
- Persistent bots with their own conversations, memory, routines, and history
- Voice mode: speak replies, dictate, and call a bot. Bring your own ElevenLabs, OpenAI, or Cartesia key
- Shared Team Computers and isolated Private computers
- Browser, terminal, file, and graphical desktop access
- Bots that can delegate to peer bots or short-lived subagents
- Bring-your-own model credentials through Pi
- App integrations through Composio or Pipedream Connect, plus user-installed Treg, remote MCP, and OpenAPI tool sources
- Docker, E2B, Daytona, Box, and trusted local-computer support
## Demo
https://github.com/user-attachments/assets/dccdeddb-2134-4a56-8eed-b2e591736b1c
## Stack
- TypeScript
- React 19, Vite, and Tailwind CSS
- Electron and Expo
- Hono and oRPC
- PostgreSQL and Prisma
- Better Auth
- Graphile Worker
- Pi
- Docker, E2B, Daytona, and Box
- Composio, Pipedream Connect, MCP, and OpenAPI integrations
## Quick start (published images)
You need Docker Engine, the Compose plugin, curl, and OpenSSL. No clone or Node install.
```bash
mkdir -p rakazo && cd rakazo &&
curl -fsSLO https://raw.githubusercontent.com/elie222/rakazo/main/infra/compose/install-images.sh &&
bash install-images.sh
```
The installer downloads the Compose files, creates `.env` with random secrets, and starts BangSo Bot.
It preserves an existing `.env` when rerun.
Open [http://127.0.0.1:5173](http://127.0.0.1:5173), create an account, and connect a model.
Local Docker computers are on by default. Optional remote providers: `e2b`, `daytona`, or `box`
with the matching API key.
Default image tag is `edge` (main builds, `linux/amd64`). Details and tags:
[self-hosting guide](./docs/self-host.md#published-images-no-checkout).
For an agent-assisted install, use [SETUP_PROMPT.md](./SETUP_PROMPT.md).
## Local development (source checkout)
You need Node.js 22+, pnpm 9, and Docker.
```bash
git clone https://github.com/elie222/rakazo.git
cd rakazo
cp .env.example .env
```
Set `BETTER_AUTH_SECRET`, `ENCRYPTION_KEY`, and `SCREEN_PROXY_SECRET` in `.env` to independent
long random values. Docker sandboxes also need a dedicated `SANDBOX_SUPERVISOR_TOKEN`. You can
also set `OPENROUTER_API_KEY`, or connect a supported model provider during onboarding.
Managed app catalogs are optional. Set `COMPOSIO_API_KEY` for Composio, or the
`PIPEDREAM_CLIENT_ID`, `PIPEDREAM_CLIENT_SECRET`, and `PIPEDREAM_PROJECT_ID` trio for Pipedream
Connect. Users can add an HTTPS MCP server, Treg endpoint, or OpenAPI JSON document from
**Integrations** without enabling either managed catalog. Connector credentials are encrypted on the
server and are never returned by the API.
Treg is usage-metered. Self-hosters supply their own Treg token; operators embedding Treg in a
hosted product should review [Treg's integration terms](https://treg.to/integrate.md), which require
a written agreement for hosted resale.
```bash
docker compose --env-file .env -f infra/compose/docker-compose.yml up postgres -d
pnpm install
pnpm db:generate
pnpm db:migrate
pnpm sandbox:build
pnpm dev
```
After `.env` is configured, the same local startup sequence is available as:
```bash
pnpm start:local
```
The script checks Docker and required tools, starts Postgres, prepares the database, builds the
local computer image when missing, and then starts the API, worker, web app, and sandbox supervisor.
To run the complete stack in Docker and expose only the web entry point to the local network, run
`pnpm start:docker-lan`. The script detects the Mac's LAN IPv4 address and prints the URL to open
from another device. API, Postgres, worker, supervisor, and sandbox ports remain private.
Open [http://127.0.0.1:5173](http://127.0.0.1:5173), create an account, connect a model, and create
your first bot.
For deployment, provider selection, backups, and upgrades, see the
[self-hosting guide](./docs/self-host.md).
## Desktop and mobile
The Electron and Expo apps are clients of the same BangSo Bot API used by the web app.
With the development stack running, launch Electron with:
```bash
pnpm --filter @rakazo/desktop dev
```
On first run the desktop app asks whether to use the BangSo Bot stack on this computer
(`http://127.0.0.1:5173`) or connect to an existing server. Public servers must use HTTPS; HTTP is
accepted only for loopback and private LAN addresses (not link-local). The app verifies BangSo Bot's
health endpoint before saving, and later launches go straight to that instance.
Use **Change BangSo Bot Server…** in the application menu to reconnect. Closing that window without
saving returns to the previous instance. For development automation, set `RAKAZO_WEB_URL` to point
the shell somewhere else without changing the saved instance, or `RAKAZO_FORCE_SETUP=1` to run
setup again.
Mobile build and release instructions live in [docs/mobile-release.md](./docs/mobile-release.md).
## Web UI language
The web (and Electron-hosted) UI supports English, Deutsch, 한국어, Türkçe, हिन्दी,
Português (Brasil), and 繁體中文. Change it under
**Settings → Language**. The marketing homepage (`apps/www`) is available in en/de/ko via
footer language links (`/`, `/de/`, `/ko/`); other marketing pages stay English.
## Development
BangSo Bot is a TypeScript monorepo built with React, Electron, Expo, Hono, Postgres, Prisma, Graphile
Worker, and Pi.
```text
apps/ web, api, worker, desktop, mobile, and public website
packages/ domain, contracts, persistence, adapters, UI, and test tooling
infra/ local services and computer images
docs/ architecture, operations, and release guides
```
Common checks:
```bash
pnpm lint
pnpm check
pnpm test
pnpm test:integration
pnpm test:e2e
```
See [CONTRIBUTING.md](./CONTRIBUTING.md) for the development workflow and test matrix.
## Documentation
```bash
pnpm test # unit, property, and in-process contract tests
pnpm test:integration # Postgres journeys, Graphile jobs, LISTEN/NOTIFY
pnpm test:e2e # Playwright against the emulated stack
pnpm test:e2e -- --sandbox=e2b # the same deterministic suite against real E2B
pnpm test:e2e -- --sandbox=daytona # the same suite against real Daytona
pnpm test:e2e -- --sandbox=box # the same suite against real Box
pnpm test:topology # local Docker + Graphile worker recovery (needs Docker)
pnpm test:canary # live OpenRouter / E2B / Box canaries
# explicit real vision-model + real E2B desktop acceptance test:
COMPUTER_E2E_MODEL=<vision-capable-openrouter-model-id> pnpm test:computer
```
- [Self-hosting](./docs/self-host.md)
- [Computer runtime and isolation](./docs/computer-runtime.md)
- [Mobile releases](./docs/mobile-release.md)
- [Performance testing](./docs/performance.md)
## Contributing
The Playwright workflow can also be started manually with **Sandbox provider** set to `e2b`, `daytona`, or `box`.
Those options require `E2B_API_KEY`, `DAYTONA_API_KEY`, or `BOX_API_KEY`, keep the deterministic scripted agent runtime, and destroy
the provider machines after the run. The default and all automatic runs remain on `fake`.
Contributions are welcome. Please read [CONTRIBUTING.md](./CONTRIBUTING.md) before opening a pull
request. For security vulnerabilities, follow [SECURITY.md](./SECURITY.md) instead of filing a public
issue.
BangSo Bot is licensed under the [Apache License 2.0](./LICENSE).
Questions and ideas are welcome in the [BangSo Bot Discord community](https://discord.gg/RWwKa2Sn7h).

34
SECURITY.md Normal file
View File

@ -0,0 +1,34 @@
# Security
## Reporting vulnerabilities
Email **security@rakazo.com** only. Do not open public GitHub issues for security bugs.
Please include:
- Steps to reproduce
- Impact (what an attacker could do)
- Whether the issue is already public
We will acknowledge your report and work on a fix. Please do not file a public issue for unfixed vulnerabilities.
## Other contact
- General support: **support@rakazo.com**
- Maintainer: **elie@rakazo.com**
## Scope
This policy covers the Rakazo self-hosted product in **this repository**.
Out of scope:
- Third-party AI models and their APIs
- Composio, E2B, and other external services
- Operator misconfiguration (exposed secrets, open databases, weak passwords)
## Supported versions
We support security fixes on the current `main` branch and the latest release (beta).
There is no bug bounty program at this time.

137
SETUP_PROMPT.md Normal file
View File

@ -0,0 +1,137 @@
# Set up Rakazo with a coding agent
Copy one of the prompts below into a coding agent.
## Published images (no checkout)
Prefer this when the user wants a running web UI with Docker only (no Node/pnpm clone).
```text
Set up Rakazo from published GHCR images and leave the web UI running.
Work like a careful onboarding engineer: perform the setup yourself, explain only decisions or blockers, and verify the product through the UI.
Safety rules:
- Never overwrite an existing `.env`. If one exists, inspect only which keys are present (never print values), preserve it, and ask before changing existing values.
- Never print, log, commit, or paste secrets into tracked files.
- Do not kill unrelated processes or containers to free ports. Identify conflicts and ask before stopping anything.
- Treat model or integration credentials as security-sensitive.
Before making changes, ask me these concise questions:
1. Which directory should contain the Rakazo folder (or use the current directory)?
2. How should models be connected?
- Add a deployment-wide `OPENROUTER_API_KEY` to `.env`.
- Connect during Rakazo onboarding with a provider API key or with ChatGPT Plus/Pro, GitHub Copilot, or SuperGrok / X Premium.
- Defer model setup and verify infrastructure only. Make clear that bots cannot answer until a model is connected.
3. Do I want remote computers instead of local Docker? If yes, choose E2B (`E2B_API_KEY`), Daytona (`DAYTONA_API_KEY`), or Box (`BOX_API_KEY`) and set `SANDBOX_PROVIDER` accordingly. If no, keep the default `SANDBOX_PROVIDER=docker` (local computers via the in-stack supervisor).
Do not ask me to invent secrets; generate strong random values with openssl yourself.
Preflight:
- Verify Docker Engine and the Compose plugin are installed and the daemon is running.
- Check whether `127.0.0.1` ports 3100 and 5173 are available.
Setup:
1. Create the directory if needed and enter it.
2. Download and inspect this installer (do not clone the repository):
https://raw.githubusercontent.com/elie222/rakazo/main/infra/compose/install-images.sh
3. Run `bash install-images.sh --prepare-only`. It downloads the Compose and environment example
files, then creates `.env` with all required random secrets when one does not already exist.
4. Preserve existing values. Keep `SANDBOX_PROVIDER=docker` unless I chose a remote computer
provider, and add only the provider or model keys I selected.
5. Run `bash install-images.sh`. It preserves `.env`, pulls the images, and starts the stack.
6. Wait until api, web, and supervisor are healthy. Default image tag is `edge` (amd64). Do not pin `latest` unless that tag exists in GHCR.
Verification:
- Request `http://127.0.0.1:3100/health`. Require `ok: true` and `sandbox: "docker"` (or the remote provider you configured). A missing `SANDBOX_SUPERVISOR_TOKEN` is a setup failure: Compose will not start the supervisor; restore the token and recreate the stack. Do not treat `sandbox: "none"` as success for this path.
- Open `http://127.0.0.1:5173`, create a local test account with fake data, and complete first-run onboarding.
- If a model is connected, send a harmless test message. Open the Agent computer pane and confirm the Docker computer reaches `running` and renders its desktop.
When finished, report the directory path, effective Docker/Compose versions, configured options without secrets, app URL, health result, and how to stop without deleting volumes (`docker compose … down` without `-v`).
```
## Local source checkout (pnpm)
Use this for development, Docker sandboxes on the host, or Electron.
```text
Set up Rakazo locally and leave it running in a usable state.
Repository: https://github.com/elie222/rakazo.git
Work like a careful onboarding engineer: perform the setup yourself, explain only decisions or blockers, and verify the product through the UI rather than stopping after dependency installation.
Safety rules:
- Never overwrite an existing `.env`. If one exists, inspect only which keys are present (never print values), preserve it, and ask before changing existing values.
- Never print, log, commit, or paste secrets into tracked files. Confirm `.env` is ignored. Do not commit anything as part of setup.
- Do not discard local changes if the repository already exists. Inspect `git status` first.
- Do not kill unrelated processes or containers to free ports. Identify conflicts and ask before stopping anything; otherwise use a safe alternate configuration and document it.
- Treat Docker/Desktop host access and model or integration credentials as security-sensitive.
Before making changes, ask me these concise questions:
1. Should you clone into the current directory, or what parent directory should contain `rakazo`? If you are already inside a Rakazo checkout, offer to use it without recloning.
2. How should models be connected?
- Add a deployment-wide `OPENROUTER_API_KEY` to `.env`.
- Connect during Rakazo onboarding with a provider API key or with ChatGPT Plus/Pro, GitHub Copilot, or SuperGrok / X Premium.
- Defer model setup and verify infrastructure only. Make clear that bots cannot answer until a model is connected.
3. Do I want a managed app catalog? If yes, choose Composio (`COMPOSIO_API_KEY`) or Pipedream Connect (`PIPEDREAM_CLIENT_ID`, `PIPEDREAM_CLIENT_SECRET`, and `PIPEDREAM_PROJECT_ID`); otherwise leave them empty. Explain that this is optional and that users can still add Treg, HTTPS MCP, or OpenAPI sources in the app.
4. Set up the web app only (recommended), or also launch the Electron desktop shell after the web stack works?
Do not ask me to invent `BETTER_AUTH_SECRET` or `ENCRYPTION_KEY`; generate strong random local values yourself. If I choose an API key, let me enter it through an available secure secret mechanism or directly into `.env`; never echo it back. OAuth or device-code sign-in must remain under my control.
Preflight:
- Verify Git, Node.js, pnpm, Docker, and Docker Compose.
- Use Node.js 22 LTS (at least 22.12) and the repository-declared pnpm 9.15.0. Do not silently use pnpm 10 or 11: newer pnpm versions can reject this lockfile or rewrite it. Prefer Corepack; if Corepack is unavailable, use `npx --yes pnpm@9.15.0` for repo commands rather than globally installing a different version. Show the effective versions.
- Verify the Docker daemon is running.
- Check whether `127.0.0.1` ports 5433, 3100, 5173, and 7091 are available. Resolve conflicts without touching unrelated workloads.
Setup:
1. Clone the repository if needed and enter its root.
2. Read `AGENTS.md`, `README.md`, `.env.example`, and the root `package.json` before acting. Follow repository instructions if they have changed since this prompt was written.
3. If `.env` does not exist, copy `.env.example` to `.env`. Generate independent random values of at least 32 bytes for `BETTER_AUTH_SECRET` and `ENCRYPTION_KEY`. Keep local defaults for Postgres, origins, Pi, Docker, and Graphile unless the preflight found a conflict. Add only the model and managed-connector credentials I selected. Leave optional credentials blank.
4. Confirm `.env` is ignored and that no secret-bearing file is staged.
5. Start only local Postgres:
`docker compose --env-file .env -f infra/compose/docker-compose.yml up postgres -d`
6. With pnpm 9.15.0, run:
`pnpm install --frozen-lockfile`
`pnpm db:generate`
`pnpm db:migrate`
`pnpm sandbox:build`
The first sandbox build may take several minutes because it installs a graphical Linux desktop and Chromium. If a command fails, diagnose the cause; do not bypass the lockfile or approve arbitrary dependency build scripts just to make progress.
7. Start `pnpm dev` in a persistent terminal. Wait until the API, worker, web app, and sandbox supervisor are ready. Keep the process running for me.
Verification:
- Request `http://127.0.0.1:3100/health`. Require `ok: true`, `runtime: "pi"`, `sandbox: "docker"`, `jobs: "graphile"`, and `realtime: "postgres"`. Expect `composio: true` only when its key was configured and `pipedream: true` only when all Pipedream settings were configured. `revision` is `null` unless `GIT_SHA` is set.
- Open `http://127.0.0.1:5173` in a browser. If browser automation is available, use it for non-sensitive steps; otherwise give me the exact UI steps.
- Create a local test account with clearly fake data, complete first-run onboarding, and create a test bot. Do not use personal data.
- If a model is connected, send a harmless test message and confirm the bot replies. If model setup was deferred, explicitly report that the stack is healthy but a first message will fail until a provider is configured; do not call the setup fully usable without that caveat.
- Open the Agent computer pane and confirm the Docker computer reaches `running` and renders its desktop.
- Open Integrations. If neither managed catalog was configured, confirm the view still offers Treg, HTTPS MCP, and OpenAPI sources. If one was configured, verify its app catalog loads without exposing any key or client secret.
- Run `pnpm test` and `pnpm check`. Report failures with the relevant output; do not claim success if either fails.
- If I requested Electron, leave the web stack running and then launch `pnpm --filter @rakazo/desktop dev`. Verify the shell loads the same app. Let me make the Docker-versus-This-Mac choice because This Mac grants bots access under my OS account.
When finished, report:
- The absolute repository path and checked-out commit.
- Effective Node, pnpm, Docker, and Docker Compose versions.
- Which model-auth path and optional integrations are configured, without revealing secrets.
- App URL, health result, UI/message/computer verification, and test/type-check results.
- Every workaround or remaining limitation.
- How to restart the stack.
- How to stop it without deleting data. Do not use `pnpm compose:down` for a normal stop because that script includes `-v` and removes Compose volumes; use a non-destructive stop/down command without `-v` and explain it.
```

28
apps/api/package.json Normal file
View File

@ -0,0 +1,28 @@
{
"name": "@rakazo/api",
"version": "0.1.0",
"license": "Apache-2.0",
"type": "module",
"scripts": {
"dev": "node ../../scripts/dev-watch.mjs src/index.ts",
"start": "tsx src/index.ts",
"check": "tsc --noEmit -p tsconfig.json",
"test": "vitest run --root ../.. apps/api/src"
},
"dependencies": {
"@hono/node-server": "^1.19.1",
"@orpc/server": "^1.15.0",
"@rakazo/adapter-kit": "workspace:*",
"@rakazo/adapters": "workspace:*",
"@rakazo/auth": "workspace:*",
"@rakazo/contracts": "workspace:*",
"@rakazo/core": "workspace:*",
"@rakazo/db": "workspace:*",
"@rakazo/memory": "workspace:*",
"hono": "^4.9.6"
},
"devDependencies": {
"typescript": "^5.9.2",
"vitest": "^4.1.10"
}
}

View File

@ -0,0 +1,302 @@
import { ORPCError } from "@orpc/server";
import { BUILTIN_AGENT_SKILLS } from "@rakazo/adapters";
import type { Actor, AgentSkill, AgentSkillSource } from "@rakazo/contracts";
import { buildSkillMd, isSkillReadOnly, parseSkillMd, type SkillSource } from "@rakazo/core";
import { IsolationError, type PrismaClient } from "@rakazo/db";
type AgentSkillRow = {
id: string;
name: string;
description: string;
content: string;
source: string;
createdAt: Date;
updatedAt: Date;
};
function asSource(value: string): AgentSkillSource {
if (value === "builtin" || value === "plugin" || value === "user") return value;
return "user";
}
export function mapAgentSkill(row: AgentSkillRow): AgentSkill {
const source = asSource(row.source);
return {
id: row.id,
name: row.name,
description: row.description,
content: row.content,
source,
readOnly: isSkillReadOnly(source as SkillSource),
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
function builtinCatalog(): AgentSkill[] {
return BUILTIN_AGENT_SKILLS.map((skill) => ({
id: `builtin:${skill.name}`,
name: skill.name,
description: skill.description,
content: skill.content,
source: "builtin" as const,
readOnly: true,
createdAt: new Date(0).toISOString(),
updatedAt: new Date(0).toISOString(),
}));
}
export function resolveSkillContent(input: {
content?: string;
name?: string;
description?: string;
body?: string;
prior?: { content: string };
}): { name: string; description: string; content: string } {
const ensureContentLimit = (content: string): string => {
if (content.length > 100_000) {
throw new ORPCError("BAD_REQUEST", {
message: "Skill content must be at most 100000 characters.",
});
}
return content;
};
if (input.content?.trim()) {
const parsed = parseSkillMd(input.content);
if ("error" in parsed) {
throw new ORPCError("BAD_REQUEST", { message: parsed.error });
}
return {
name: parsed.name,
description: parsed.description,
content: ensureContentLimit(buildSkillMd(parsed)),
};
}
const priorParsed = input.prior ? parseSkillMd(input.prior.content) : null;
if (priorParsed && "error" in priorParsed) {
throw new ORPCError("BAD_REQUEST", { message: priorParsed.error });
}
const name = (input.name ?? priorParsed?.name ?? "").trim();
const description = (input.description ?? priorParsed?.description ?? "").trim();
const body = input.body ?? priorParsed?.body ?? "";
if (!name || !description) {
throw new ORPCError("BAD_REQUEST", {
message: "Provide content (SKILL.md) or name + description (+ optional body)",
});
}
let content: string;
try {
content = buildSkillMd({
name,
description,
body,
frontmatter: priorParsed && !("error" in priorParsed) ? priorParsed.frontmatter : undefined,
});
} catch (error) {
throw new ORPCError("BAD_REQUEST", {
message: error instanceof Error ? error.message : "Invalid skill fields.",
});
}
const validated = parseSkillMd(content);
if ("error" in validated) {
throw new ORPCError("BAD_REQUEST", { message: validated.error });
}
return {
name: validated.name,
description: validated.description,
content: ensureContentLimit(buildSkillMd(validated)),
};
}
export function createAgentSkillsService(prisma: PrismaClient) {
async function owned(actor: Actor, skillId: string) {
const row = await prisma.agentSkill.findFirst({
where: {
id: skillId,
spaceId: actor.spaceId,
userId: actor.userId,
},
});
if (!row) throw new IsolationError();
return row;
}
return {
async list(actor: Actor): Promise<Omit<AgentSkill, "content">[]> {
const rows = await prisma.agentSkill.findMany({
where: { spaceId: actor.spaceId, userId: actor.userId },
orderBy: [{ name: "asc" }, { id: "asc" }],
});
const catalog = [...builtinCatalog(), ...rows.map(mapAgentSkill)].map(
({ content: _content, ...entry }) => entry,
);
return catalog;
},
async listWithContent(actor: Actor): Promise<AgentSkill[]> {
const rows = await prisma.agentSkill.findMany({
where: { spaceId: actor.spaceId, userId: actor.userId },
orderBy: [{ name: "asc" }, { id: "asc" }],
});
return [...builtinCatalog(), ...rows.map(mapAgentSkill)];
},
async get(actor: Actor, input: { skillId?: string; name?: string }): Promise<AgentSkill> {
if (input.skillId?.startsWith("builtin:")) {
const builtin = builtinCatalog().find((skill) => skill.id === input.skillId);
if (!builtin) throw new IsolationError();
return builtin;
}
if (input.skillId) {
return mapAgentSkill(await owned(actor, input.skillId));
}
const name = input.name?.trim() ?? "";
const builtin = builtinCatalog().find(
(skill) => skill.name.toLowerCase() === name.toLowerCase(),
);
if (builtin) return builtin;
const row = await prisma.agentSkill.findFirst({
where: {
spaceId: actor.spaceId,
userId: actor.userId,
name: { equals: name, mode: "insensitive" },
},
});
if (!row) throw new IsolationError();
return mapAgentSkill(row);
},
async create(
actor: Actor,
input: { content?: string; name?: string; description?: string; body?: string },
): Promise<AgentSkill> {
const resolved = resolveSkillContent(input);
const clash = await prisma.agentSkill.findFirst({
where: {
spaceId: actor.spaceId,
userId: actor.userId,
name: { equals: resolved.name, mode: "insensitive" },
},
});
if (
clash ||
builtinCatalog().some((s) => s.name.toLowerCase() === resolved.name.toLowerCase())
) {
throw new ORPCError("CONFLICT", { message: "A skill with that name already exists." });
}
try {
const row = await prisma.agentSkill.create({
data: {
spaceId: actor.spaceId,
userId: actor.userId,
name: resolved.name,
description: resolved.description,
content: resolved.content,
source: "user",
},
});
return mapAgentSkill(row);
} catch (error) {
if (
error instanceof Error &&
"code" in error &&
(error as { code?: string }).code === "P2002"
) {
throw new ORPCError("CONFLICT", { message: "A skill with that name already exists." });
}
throw error;
}
},
async update(
actor: Actor,
input: {
skillId: string;
content?: string;
name?: string;
description?: string;
body?: string;
},
): Promise<AgentSkill> {
const existing = await owned(actor, input.skillId);
if (isSkillReadOnly(asSource(existing.source) as SkillSource)) {
throw new ORPCError("BAD_REQUEST", { message: "Builtin and plugin skills are read-only." });
}
const resolved = resolveSkillContent({ ...input, prior: existing });
if (resolved.name.toLowerCase() !== existing.name.toLowerCase()) {
const clash = await prisma.agentSkill.findFirst({
where: {
spaceId: actor.spaceId,
userId: actor.userId,
name: { equals: resolved.name, mode: "insensitive" },
NOT: { id: existing.id },
},
});
if (
clash ||
builtinCatalog().some((s) => s.name.toLowerCase() === resolved.name.toLowerCase())
) {
throw new ORPCError("CONFLICT", { message: "A skill with that name already exists." });
}
}
// Mutate only owner-scoped user rows (never builtin/plugin), even if source was tampered.
try {
const updated = await prisma.agentSkill.updateMany({
where: {
id: existing.id,
spaceId: actor.spaceId,
userId: actor.userId,
source: "user",
},
data: {
name: resolved.name,
description: resolved.description,
content: resolved.content,
},
});
if (updated.count !== 1) throw new IsolationError();
} catch (error) {
if (error instanceof IsolationError) throw error;
if (
error instanceof Error &&
"code" in error &&
(error as { code?: string }).code === "P2002"
) {
throw new ORPCError("CONFLICT", { message: "A skill with that name already exists." });
}
throw error;
}
const row = await prisma.agentSkill.findFirst({
where: {
id: existing.id,
spaceId: actor.spaceId,
userId: actor.userId,
},
});
if (!row) throw new IsolationError();
return mapAgentSkill(row);
},
async remove(actor: Actor, skillId: string): Promise<{ ok: true }> {
const existing = await owned(actor, skillId);
if (isSkillReadOnly(asSource(existing.source) as SkillSource)) {
throw new ORPCError("BAD_REQUEST", { message: "Builtin and plugin skills are read-only." });
}
const deleted = await prisma.agentSkill.deleteMany({
where: {
id: existing.id,
spaceId: actor.spaceId,
userId: actor.userId,
source: "user",
},
});
if (deleted.count !== 1) throw new IsolationError();
return { ok: true };
},
};
}
export type AgentSkillsService = ReturnType<typeof createAgentSkillsService>;

522
apps/api/src/app.ts Normal file
View File

@ -0,0 +1,522 @@
import { randomUUID } from "node:crypto";
import { rm } from "node:fs/promises";
import { ORPCError, onError } from "@orpc/server";
import { RPCHandler } from "@orpc/server/fetch";
import type {
JobPublisher,
ManagedConnectorProvider,
MessagingSurface,
RealtimeFanout,
SandboxProvider,
TransactionalEmailProvider,
} from "@rakazo/adapter-kit";
import {
applyMessagingOutboundStatus,
ChatSdkMessagingSurface,
type ComposioProvider,
type ConnectorRegistry,
createBackgroundJobHandlers,
createConnectorStack,
createJobReconciler,
createMessagingContextLoader,
createRunExecutor,
createRunSandbox,
createRunSecretWriter,
createWebProvider,
type DestinationEmulator,
destroyBot,
EmailEmulator,
EncryptedSecretStore,
ExpoPushProvider,
GraphileJobPublisher,
InMemoryJobQueue,
InMemoryRealtimeFanout,
InstalledConnectorProvider,
isComposioEnabled,
isMessagingSurfaceEnabled,
isPipedreamEnabled,
LocalAgentHomeStore,
LocalArtifactStore,
McpConnector,
McpOAuthBroker,
messagingPlatformsFromEnv,
PiAgentRuntime,
PiOAuthLogins,
PipedreamConnector,
PostgresRealtimeFanout,
pipedreamConfigFromEnv,
pushTokenPath,
type RemoteConnectorDependencies,
ScriptedAgentRuntime,
SmtpEmailProvider,
SpaceMemoryProviderResolver,
} from "@rakazo/adapters";
import { blockedAuthPaths, createAuth } from "@rakazo/auth";
import { signupPolicyFromEnv } from "@rakazo/core";
import {
createDb,
createThreadEvents,
type PrismaClient,
provisionMessagingIdentity,
requireMembership,
} from "@rakazo/db";
import { MarkdownMemoryStore } from "@rakazo/memory";
import { type Context, Hono } from "hono";
import { cors } from "hono/cors";
import { mountBotAvatarHttpRoutes } from "./bot-avatar.js";
import { type AppEnv, loadEnv } from "./env.js";
import { createMessagingInboundHandler } from "./messaging-inbound.js";
import { mountMessagingWebhookRoutes } from "./messaging-webhook.js";
import { createRouter } from "./router.js";
import { mountVoiceHttpRoutes } from "./voice.js";
import { mountWebhookHttpRoutes } from "./webhook.js";
export interface AppHandles {
app: Hono;
prisma: PrismaClient;
jobs: JobPublisher;
sandbox: SandboxProvider;
connector: DestinationEmulator;
composio?: ComposioProvider;
connectors: ConnectorRegistry;
messaging?: MessagingSurface;
email?: TransactionalEmailProvider;
executor: ReturnType<typeof createRunExecutor>;
stop: () => Promise<void>;
}
export async function createApp(
overrides: Partial<AppEnv> & {
prisma?: PrismaClient;
realtime?: RealtimeFanout;
composio?: ComposioProvider;
pipedream?: ManagedConnectorProvider;
messaging?: MessagingSurface;
email?: TransactionalEmailProvider;
remoteConnectors?: RemoteConnectorDependencies;
} = {},
): Promise<AppHandles> {
const {
prisma: prismaOverride,
realtime: realtimeOverride,
composio: composioOverride,
pipedream: pipedreamOverride,
messaging: messagingOverride,
email: emailOverride,
remoteConnectors,
...envOverrides
} = overrides;
const env = { ...loadEnv(process.env), ...envOverrides };
const created = prismaOverride
? { prisma: prismaOverride, pool: undefined }
: createDb(env.databaseUrl);
const { prisma } = created;
created.pool?.on("error", () => undefined);
const realtime =
realtimeOverride ??
(created.pool
? new PostgresRealtimeFanout({
connectionString: env.realtimeDatabaseUrl,
publisher: created.pool,
})
: new InMemoryRealtimeFanout());
const secrets = new EncryptedSecretStore(env.encryptionKey);
const events = createThreadEvents(prisma, realtime, {
runSecretWriter: createRunSecretWriter(secrets),
});
const environmentSignupPolicy = signupPolicyFromEnv(env);
const deploymentSettings = await prisma.deploymentSettings.upsert({
where: { id: "default" },
create: {
id: "default",
signupsEnabled: environmentSignupPolicy.enabled,
signupAllowlist: environmentSignupPolicy.allowlist.join(","),
signupPolicyInitialized: true,
},
update: {},
});
if (!deploymentSettings.signupPolicyInitialized) {
// Older versions created this row with schema defaults even though auth
// still enforced the environment policy. Copy that effective policy once
// so upgrades preserve behavior before Settings becomes authoritative.
await prisma.deploymentSettings.updateMany({
where: { id: "default", signupPolicyInitialized: false },
data: {
signupsEnabled: environmentSignupPolicy.enabled,
signupAllowlist: environmentSignupPolicy.allowlist.join(","),
signupPolicyInitialized: true,
},
});
}
const jobKind = env.wakeupDriver;
const inMemoryJobs = jobKind === "memory" ? new InMemoryJobQueue() : undefined;
const jobs = inMemoryJobs ?? new GraphileJobPublisher(env.databaseUrl);
const sandbox: SandboxProvider = createRunSandbox(env.sandboxProvider, {
supervisorUrl: env.sandboxSupervisorUrl,
supervisorToken: env.sandboxSupervisorToken,
e2bApiKey: env.e2bApiKey,
daytonaApiKey: env.daytonaApiKey,
daytonaApiUrl: env.daytonaApiUrl,
daytonaTarget: env.daytonaTarget,
boxApiKey: env.boxApiKey,
boxApiUrl: env.boxApiUrl,
dataDir: env.dataDir,
prisma,
});
const mcpOAuth = new McpOAuthBroker(prisma, secrets, remoteConnectors);
const memoryProviders = new SpaceMemoryProviderResolver(prisma, secrets);
const oauthLogins = new PiOAuthLogins();
const home = new LocalAgentHomeStore(env.dataDir);
const artifacts = new LocalArtifactStore(env.dataDir);
const memory = new MarkdownMemoryStore(prisma);
const mcp = new McpConnector(
prisma,
secrets,
{
stdioEnabled: env.mcpStdioEnabled,
allowedCommands: env.mcpStdioAllowedCommands,
network: remoteConnectors,
},
mcpOAuth,
);
const pipedreamConfig = pipedreamConfigFromEnv(env);
const pipedream =
pipedreamOverride ??
(isPipedreamEnabled(pipedreamConfig) ? new PipedreamConnector(pipedreamConfig) : undefined);
const messagingPlatforms = messagingPlatformsFromEnv(env);
const messaging =
messagingOverride ??
(isMessagingSurfaceEnabled(messagingPlatforms, {
deploymentModelKey: env.deploymentModelKey,
openSignup: env.messagingOpenSignup,
})
? new ChatSdkMessagingSurface(messagingPlatforms)
: undefined);
const localEmailEmulator =
!emailOverride && !env.smtpUrl && env.emailEmulator
? new EmailEmulator((message) => {
console.info(`[email-emulator] captured ${message.subject} to ${message.to}`);
})
: undefined;
if (localEmailEmulator && !isLoopbackHost(env.apiHost)) {
throw new Error("EMAIL_EMULATOR requires API_HOST to be a loopback host");
}
const email: TransactionalEmailProvider | undefined =
emailOverride ??
(env.smtpUrl
? new SmtpEmailProvider({ url: env.smtpUrl, from: env.emailFrom ?? "" })
: localEmailEmulator);
const installed = new InstalledConnectorProvider(prisma, secrets, remoteConnectors);
const stack = createConnectorStack(isComposioEnabled(env.composioApiKey), composioOverride, [
installed,
...(pipedream ? [pipedream] : []),
mcp,
]);
const connector = stack.destination;
await connector.start();
void stack.composio?.warmDirectory().catch(() => undefined);
void pipedream?.warmDirectory?.().catch(() => undefined);
const runtime =
env.agentRuntime === "scripted" ? new ScriptedAgentRuntime() : new PiAgentRuntime();
const notifications = new ExpoPushProvider(env.dataDir);
const auth = createAuth(prisma, {
secret: env.authSecret,
baseURL: env.authUrl,
webOrigin: env.webOrigin,
signupsEnabled: env.signupsEnabled,
signupAllowlist: env.signupAllowlist,
email,
onEmailError: (error) => console.error("transactional email delivery failed", error),
extraOrigins: [
"rakazo://",
"exp://",
"exp://*",
"http://localhost:8081",
"http://127.0.0.1:8081",
"http://localhost:19006",
"http://127.0.0.1:19006",
],
beforeDeleteUser: async (userId) => {
const bots = await prisma.bot.findMany({
where: { userId },
select: { id: true, spaceId: true, name: true, archivedAt: true },
});
await Promise.all(
bots.map((bot) =>
destroyBot(
{ prisma, sandbox, home, jobs, artifacts, dataDir: env.dataDir },
bot,
{
operationId: `account-delete:${userId}`,
traceId: `account-delete:${userId}`,
spaceId: bot.spaceId,
userId,
botId: bot.id,
signal: new AbortController().signal,
},
{ deleteMemories: true },
),
),
);
await rm(pushTokenPath(env.dataDir, userId), { force: true }).catch(() => undefined);
},
});
const executor = createRunExecutor({
prisma,
runtime,
sandbox,
memory,
memoryProviders,
home,
artifacts,
connector: stack.connector,
connectors: stack.connector,
listConnectedPluginSlugs: stack.composio?.listConnectedSlugs.bind(stack.composio),
secrets: [env.deploymentModelKey ?? "", env.composioApiKey ?? ""].filter(Boolean),
secretStore: secrets,
deploymentModelKey: env.deploymentModelKey,
dataDir: env.dataDir,
notifications,
jobs,
events,
messaging: messaging ? createMessagingContextLoader(prisma) : undefined,
web: createWebProvider(),
});
const jobHandlers = createBackgroundJobHandlers({
executor,
prisma,
sandbox,
home,
jobs,
events,
workerId: "api",
runtime,
secretStore: secrets,
memoryProviders,
deploymentModelKey: env.deploymentModelKey,
messaging,
});
if (inMemoryJobs) {
await inMemoryJobs.start(jobHandlers);
}
const reconciler = inMemoryJobs ? createJobReconciler({ prisma, jobs }) : undefined;
reconciler?.start();
const router = createRouter({
prisma,
events,
auth,
jobs,
sandbox,
memory,
memoryProviders,
home,
secrets,
oauthLogins,
mcpOAuth,
composio: stack.composio,
connectors: stack.connector,
remoteConnectors,
artifacts,
dataDir: env.dataDir,
messaging: {
enabled: Boolean(messaging),
providers: messaging?.platforms().map((platform) => platform.provider) ?? [],
openSignup: env.messagingOpenSignup,
},
env: {
defaultProvider: env.defaultProvider,
defaultModel: env.defaultModel,
deploymentModelKey: env.deploymentModelKey,
webOrigin: env.webOrigin,
screenProxySecret: env.screenProxySecret,
sandboxProvider: env.sandboxProvider,
gitSha: env.gitSha,
updaterUrl: env.updaterUrl,
updaterToken: env.updaterToken,
imageTag: env.imageTag,
},
});
const rpc = new RPCHandler(router, {
clientInterceptors: [onError((error, { path }) => logUnexpectedRpcError(error, path))],
});
const app = new Hono();
app.use(
"*",
cors({
origin: (origin) => {
if (!origin) return env.webOrigin;
return isTrustedOrigin(origin, env) ? origin : "";
},
credentials: true,
}),
);
app.get("/api/auth/capabilities", (c) =>
c.json({
passwordReset: Boolean(email),
resetUrl: email ? new URL("/reset-password", env.webOrigin).href : null,
}),
);
if (localEmailEmulator && env.nodeEnv === "development") {
app.get(
"/api/dev/emails",
() =>
new Response(JSON.stringify(localEmailEmulator.sent), {
headers: { "cache-control": "no-store", "content-type": "application/json" },
}),
);
}
app.on(["GET", "POST"], "/api/auth/*", async (c) => {
const path = new URL(c.req.url).pathname.replace("/api/auth", "");
if (blockedAuthPaths.some((blocked) => path.startsWith(blocked))) {
return c.json({ error: "Not available in version 1" }, 404);
}
return auth.handler(c.req.raw);
});
app.use("/rpc/*", async (c, next) => {
const session = await auth.api.getSession({ headers: sessionHeaders(c.req.raw) });
const requestedSpaceId = c.req.header("x-rakazo-space-id");
const actor = session?.user
? await requireMembership(prisma, session.user.id, requestedSpaceId).catch(() => null)
: null;
const { matched, response } = await rpc.handle(c.req.raw, {
prefix: "/rpc",
context: { actor, signal: c.req.raw.signal },
});
if (matched) return c.newResponse(response.body, response);
await next();
});
const authenticate = async (c: Context) => {
const session = await auth.api.getSession({ headers: sessionHeaders(c.req.raw) });
if (!session?.user) return null;
return requireMembership(prisma, session.user.id, c.req.header("x-rakazo-space-id")).catch(
() => null,
);
};
mountVoiceHttpRoutes(app, { prisma, secrets }, authenticate);
mountBotAvatarHttpRoutes(app, { prisma, artifacts }, authenticate);
mountWebhookHttpRoutes(app, { prisma, secrets, events, jobs });
// Messaging webhooks only exist when the surface is enabled.
if (messaging) {
const inbound = createMessagingInboundHandler({
prisma,
events,
jobs,
provision: (request, policyEnv) => provisionMessagingIdentity(prisma, request, policyEnv),
openSignup: env.messagingOpenSignup,
signupPolicy: {
signupsEnabled: env.signupsEnabled,
signupAllowlist: env.signupAllowlist,
},
typing: (threadId) => {
// Keep conversation addresses out of trace ids — those reach logs
// and telemetry, a different trust boundary than the database.
const operationId = `messaging.typing:${randomUUID()}`;
return messaging.sendTyping(threadId, {
operationId,
traceId: operationId,
spaceId: "",
userId: "",
// Cosmetic side call: the wait is bounded so a stalled vendor
// response never holds our callback chain (the Chat SDK adapter
// API cannot cancel the underlying request itself).
signal: AbortSignal.timeout(2000),
});
},
});
messaging.onInbound(async (event) => {
if (event.type === "message") await inbound(event);
else await applyMessagingOutboundStatus(prisma, event);
});
mountMessagingWebhookRoutes(app, { messaging });
}
app.get("/health", (c) =>
c.json({
ok: true,
runtime: env.agentRuntime,
sandbox: env.sandboxProvider,
composio: Boolean(stack.composio),
pipedream: Boolean(pipedream),
messaging: Boolean(messaging),
email: email?.describe().id ?? null,
jobs: jobKind,
realtime: realtime.describe().id,
revision: env.gitSha ?? null,
}),
);
return {
app,
prisma,
jobs,
sandbox,
connector,
composio: stack.composio,
connectors: stack.connector,
messaging,
email,
executor,
stop: async () => {
oauthLogins.abortAll();
await email?.drain?.();
await reconciler?.stop();
await jobs.close();
await realtime.close();
await connector.stop();
await mcp.close();
await prisma.$disconnect().catch(() => undefined);
await created.pool?.end().catch(() => undefined);
},
};
}
function isTrustedOrigin(origin: string, env: AppEnv) {
if (!origin) return true;
if (origin === env.webOrigin || origin === env.apiUrl || origin === env.authUrl) return true;
if (origin.startsWith("rakazo://") || origin.startsWith("exp://")) return true;
try {
const host = new URL(origin).hostname;
return isLoopbackHost(host);
} catch {
return false;
}
}
function isLoopbackHost(host: string): boolean {
return host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]";
}
function sessionHeaders(request: Request) {
const headers = new Headers(request.headers);
const authz = headers.get("authorization");
if (authz?.toLowerCase().startsWith("bearer ") && !headers.get("cookie")) {
headers.set("cookie", `better-auth.session_token=${authz.slice(7).trim()}`);
}
return headers;
}
/**
* An ORPCError is a decision the router made (BAD_REQUEST, UNAUTHORIZED, ...) and reaches the
* caller intact. Everything else is flattened into an opaque "Internal server error", so
* unless it is logged here the only record of what actually broke is gone.
*
* The cause chain matters as much as the message: undici and most SDKs report a bare
* "fetch failed" and keep the host and errno one level down.
*/
export function logUnexpectedRpcError(error: unknown, path: readonly string[]): void {
if (error instanceof ORPCError) return;
const where = `rpc ${path.join("/")} failed`;
if (!(error instanceof Error)) {
console.error(where, String(error));
return;
}
const chain: string[] = [];
for (let current: unknown = error; current instanceof Error && chain.length < 4; ) {
chain.push(`${current.name}: ${current.message}`);
current = current.cause;
}
console.error(where, chain.join(" <- "), error.stack ?? "");
}

337
apps/api/src/artifacts.ts Normal file
View File

@ -0,0 +1,337 @@
import { createHash } from "node:crypto";
import type { ArtifactStore } from "@rakazo/adapter-kit";
import type { Actor } from "@rakazo/contracts";
import { ATTACHMENT_MAX_COUNT } from "@rakazo/contracts";
import {
AttachmentValidationError,
decodeAttachmentBase64,
messageBlockForArtifact,
promptTextForAttachments,
validateAttachmentMimeType,
} from "@rakazo/core";
import { IsolationError, type PrismaClient } from "@rakazo/db";
function adapterContext(actor: Actor, botId: string, operationId: string) {
return {
operationId,
traceId: operationId,
spaceId: actor.spaceId,
userId: actor.userId,
botId,
signal: new AbortController().signal,
};
}
export async function createOwnedArtifact(
deps: {
prisma: PrismaClient;
artifacts: ArtifactStore;
},
actor: Actor,
input: {
botId: string;
groupId?: string;
name: string;
mimeType: string;
contentBase64: string;
},
) {
validateAttachmentMimeType(input.mimeType);
const bytes = decodeAttachmentBase64(input.contentBase64);
const context = adapterContext(actor, input.botId, `artifact-create:${input.botId}`);
const stored = await deps.artifacts.put(
{ name: input.name, mimeType: input.mimeType, bytes },
context,
);
const hash = createHash("sha256").update(bytes).digest("hex");
const row = await deps.prisma.artifact
.create({
data: {
spaceId: actor.spaceId,
userId: actor.userId,
botId: input.botId,
groupId: input.groupId,
name: input.name,
mimeType: input.mimeType,
size: bytes.byteLength,
hash,
storageKey: stored.id,
},
})
.catch(async (error) => {
await deps.artifacts.remove(stored.id, context).catch(() => undefined);
throw error;
});
return {
id: row.id,
botId: row.botId,
groupId: row.groupId,
runId: row.runId,
name: row.name,
mimeType: row.mimeType,
size: row.size,
createdAt: row.createdAt.toISOString(),
};
}
export async function copyBotAvatarImage(
deps: {
prisma: PrismaClient;
artifacts: ArtifactStore;
},
actor: Actor,
input: { sourceBotId: string; sourceArtifactId: string; targetBotId: string },
) {
const source = await getOwnedArtifact(deps, actor, {
botId: input.sourceBotId,
artifactId: input.sourceArtifactId,
});
const created = await createOwnedArtifact(deps, actor, {
botId: input.targetBotId,
name: source.name,
mimeType: source.mimeType,
contentBase64: source.contentBase64,
});
await deps.prisma.bot.update({
where: { id: input.targetBotId },
data: { avatarImageArtifactId: created.id },
});
return created;
}
export async function readBotAvatarImage(
deps: {
prisma: PrismaClient;
artifacts: ArtifactStore;
},
userId: string,
botId: string,
) {
const bot = await deps.prisma.bot.findFirst({
where: { id: botId, userId, avatarImageArtifactId: { not: null } },
select: { avatarImageArtifactId: true, spaceId: true },
});
if (!bot?.avatarImageArtifactId) return null;
const row = await deps.prisma.artifact.findFirst({
where: { id: bot.avatarImageArtifactId, userId },
});
if (!row) return null;
const bytes = await deps.artifacts.get(
row.storageKey,
adapterContext(
{ userId, spaceId: bot.spaceId, email: "", isDeploymentOwner: false },
botId,
`bot-avatar:${botId}`,
),
);
return { bytes, mimeType: row.mimeType, hash: row.hash };
}
export async function removeOwnedArtifact(
deps: {
prisma: PrismaClient;
artifacts: ArtifactStore;
},
actor: Actor,
input: { botId: string; artifactId: string },
) {
const row = await deps.prisma.artifact.findFirst({
where: {
id: input.artifactId,
botId: input.botId,
spaceId: actor.spaceId,
userId: actor.userId,
},
});
if (!row) return;
await deps.prisma.artifact.delete({ where: { id: row.id } });
await deps.artifacts
.remove(row.storageKey, adapterContext(actor, input.botId, `artifact-remove:${row.id}`))
.catch(() => undefined);
}
export async function getOwnedArtifact(
deps: {
prisma: PrismaClient;
artifacts: ArtifactStore;
},
actor: Actor,
input: { botId: string; artifactId: string },
) {
const row = await deps.prisma.artifact.findFirst({
where: {
id: input.artifactId,
botId: input.botId,
groupId: null,
spaceId: actor.spaceId,
userId: actor.userId,
},
});
if (!row) throw new IsolationError();
return readArtifact(deps.artifacts, actor, row, input.botId);
}
export async function getSpaceArtifact(
deps: {
prisma: PrismaClient;
artifacts: ArtifactStore;
},
actor: Actor,
input: { artifactId: string; groupId: string; contextBotId: string },
) {
const row = await deps.prisma.artifact.findFirst({
where: {
id: input.artifactId,
groupId: input.groupId,
spaceId: actor.spaceId,
userId: actor.userId,
},
});
if (!row) throw new IsolationError();
return readArtifact(deps.artifacts, actor, row, input.contextBotId);
}
async function readArtifact(
artifacts: ArtifactStore,
actor: Actor,
row: {
id: string;
botId: string | null;
groupId: string | null;
runId: string | null;
storageKey: string;
name: string;
mimeType: string;
size: number;
createdAt: Date;
},
contextBotId: string,
) {
const bytes = await artifacts.get(
row.storageKey,
adapterContext(actor, contextBotId, `artifact-get:${row.id}`),
);
return {
id: row.id,
botId: row.botId,
groupId: row.groupId,
runId: row.runId,
name: row.name,
mimeType: row.mimeType,
size: row.size,
createdAt: row.createdAt.toISOString(),
contentBase64: Buffer.from(bytes).toString("base64"),
};
}
type SendAttachmentRow = {
id: string;
name: string;
mimeType: string;
size: number;
storageKey: string;
};
function normalizeAttachmentIds(artifactIds: string[] | undefined) {
const ids = [...new Set(artifactIds ?? [])];
if (ids.length > ATTACHMENT_MAX_COUNT) {
throw new AttachmentValidationError(`At most ${ATTACHMENT_MAX_COUNT} attachments per message`);
}
return ids;
}
function toAttachmentResolution<T extends SendAttachmentRow>(ids: string[], rows: T[]) {
if (rows.length !== ids.length) throw new IsolationError();
const byId = new Map(rows.map((row) => [row.id, row]));
const artifacts = ids.map((id) => byId.get(id)!);
const blocks = artifacts.map((row) =>
messageBlockForArtifact({
id: row.id,
name: row.name,
mimeType: row.mimeType,
size: row.size,
}),
);
return { blocks, artifacts };
}
export async function resolveSendAttachments(
deps: { prisma: Pick<PrismaClient, "artifact"> },
actor: Actor,
botId: string,
artifactIds: string[] | undefined,
) {
const ids = normalizeAttachmentIds(artifactIds);
if (!ids.length) return toAttachmentResolution(ids, [] as SendAttachmentRow[]);
const rows = await deps.prisma.artifact.findMany({
where: {
id: { in: ids },
botId,
groupId: null,
spaceId: actor.spaceId,
userId: actor.userId,
},
});
return toAttachmentResolution(ids, rows);
}
export async function resolveGroupSendAttachments(
deps: { prisma: Pick<PrismaClient, "artifact"> },
actor: Actor,
groupId: string,
memberBotIds: string[],
artifactIds: string[] | undefined,
) {
const ids = normalizeAttachmentIds(artifactIds);
if (!ids.length) return toAttachmentResolution(ids, [] as SendAttachmentRow[]);
const rows = await deps.prisma.artifact.findMany({
where: {
id: { in: ids },
spaceId: actor.spaceId,
userId: actor.userId,
OR: [
{ groupId },
// Accept artifacts uploaded by a current member before group ownership
// was persisted. Removing that member revokes this legacy fallback.
{ groupId: null, botId: { in: memberBotIds } },
],
},
});
return toAttachmentResolution(ids, rows);
}
export function buildUserMessageBlocks(
text: string | undefined,
attachmentBlocks: ReturnType<typeof messageBlockForArtifact>[],
) {
const blocks = [];
const caption = text?.trim();
if (caption) blocks.push({ kind: "text" as const, text: caption });
blocks.push(...attachmentBlocks);
return blocks;
}
export function buildSendPrompt(
text: string | undefined,
artifacts: Array<{ name: string; mimeType: string; size: number }>,
connectorNames: string[] = [],
) {
const prompt = promptTextForAttachments(text, artifacts);
if (connectorNames.length === 0) return prompt;
const marker = "Use these connectors if relevant:";
const existing = new RegExp(`^${marker} (.*)\\.$`, "m").exec(prompt);
const names = [
...new Set([
...(existing?.[1]
?.split(",")
.map((name) => name.trim())
.filter(Boolean) ?? []),
...connectorNames.map((name) => name.trim()).filter(Boolean),
]),
];
const line = `${marker} ${names.join(", ")}.`;
if (existing) return prompt.replace(existing[0], () => line);
return prompt ? `${prompt}\n\n${line}` : line;
}

View File

@ -0,0 +1,36 @@
import { Hono } from "hono";
import { describe, expect, it, vi } from "vitest";
import { mountBotAvatarHttpRoutes } from "./bot-avatar.js";
describe("bot avatar HTTP routes", () => {
it("rejects unauthenticated reads", async () => {
const app = new Hono();
mountBotAvatarHttpRoutes(
app,
{ prisma: {} as never, artifacts: {} as never },
async () => null,
);
const response = await app.request("/api/bot-avatars/bot-1");
expect(response.status).toBe(401);
});
it("returns 404 when the bot has no custom face", async () => {
const app = new Hono();
const read = vi.fn(async () => null);
mountBotAvatarHttpRoutes(
app,
{
prisma: { bot: { findFirst: read } } as never,
artifacts: { get: vi.fn() } as never,
},
async () => ({
userId: "user-1",
spaceId: "space-1",
email: "user@rakazo.test",
isDeploymentOwner: false,
}),
);
const response = await app.request("/api/bot-avatars/bot-1");
expect(response.status).toBe(404);
});
});

View File

@ -0,0 +1,30 @@
import type { ArtifactStore } from "@rakazo/adapter-kit";
import type { Actor } from "@rakazo/contracts";
import type { PrismaClient } from "@rakazo/db";
import type { Context, Hono } from "hono";
import { readBotAvatarImage } from "./artifacts.js";
export function mountBotAvatarHttpRoutes(
app: Hono,
deps: { prisma: PrismaClient; artifacts: ArtifactStore },
authenticate: (c: Context) => Promise<Actor | null>,
) {
app.get("/api/bot-avatars/:botId", async (c) => {
const actor = await authenticate(c);
if (!actor) return c.json({ error: "Unauthorized" }, 401);
const botId = c.req.param("botId");
if (!botId) return c.json({ error: "Not found" }, 404);
const image = await readBotAvatarImage(deps, actor.userId, botId);
if (!image) return c.json({ error: "Not found" }, 404);
const inm = c.req.header("if-none-match");
const etag = `"${image.hash}"`;
if (inm === etag) return c.body(null, 304);
return new Response(new Uint8Array(image.bytes), {
headers: {
"content-type": image.mimeType,
etag,
"cache-control": "private, max-age=3600",
},
});
});
}

View File

@ -0,0 +1,118 @@
import { describe, expect, it } from "vitest";
import { executionBlocksUserTakeover, toComputerStatus } from "./computer-status.js";
describe("toComputerStatus", () => {
it("only marks control that is bound to a waiting run as a requested takeover", () => {
const computer = {
kind: "fake",
state: "running",
scope: "team",
controlHolder: "user",
controlBotId: "bot-1",
controlRunId: "run-1",
homeRevision: "revision-1",
};
expect(toComputerStatus("bot-1", computer).takeoverRequested).toBe(true);
expect(toComputerStatus("bot-1", { ...computer, controlRunId: null }).takeoverRequested).toBe(
false,
);
});
it("passes through the busy bot name when takeover is blocked", () => {
const computer = {
kind: "fake",
state: "running",
scope: "team",
controlHolder: "none",
homeRevision: "revision-1",
};
expect(toComputerStatus("bot-1", computer).busyBotName).toBeNull();
expect(toComputerStatus("bot-1", computer, "Writer").busyBotName).toBe("Writer");
});
it("hides update on desktop computers", () => {
expect(
toComputerStatus("bot-1", {
kind: "desktop",
state: "running",
scope: "team",
controlHolder: "none",
homeRevision: "r1",
}).updateAvailable,
).toBe(false);
expect(
toComputerStatus("bot-1", {
kind: "e2b",
state: "running",
scope: "team",
controlHolder: "none",
homeRevision: "r1",
}).updateAvailable,
).toBe(true);
});
});
describe("executionBlocksUserTakeover", () => {
const now = Date.parse("2026-08-25T12:00:00.000Z");
it("allows takeover when there is no execution lease", () => {
expect(
executionBlocksUserTakeover({
hasLease: false,
leaseExpiresAt: new Date(now + 60_000),
runStatus: "running",
now,
}),
).toBe(false);
});
it("allows takeover while the run is waiting for the user", () => {
expect(
executionBlocksUserTakeover({
hasLease: true,
leaseExpiresAt: new Date(now + 60_000),
runStatus: "waiting_takeover",
now,
}),
).toBe(false);
});
it("blocks takeover for an active lease or active run", () => {
expect(
executionBlocksUserTakeover({
hasLease: true,
leaseExpiresAt: new Date(now + 60_000),
runStatus: "running",
now,
}),
).toBe(true);
expect(
executionBlocksUserTakeover({
hasLease: true,
leaseExpiresAt: new Date(now - 1),
runStatus: "running",
now,
}),
).toBe(true);
expect(
executionBlocksUserTakeover({
hasLease: true,
leaseExpiresAt: new Date(now + 60_000),
runStatus: "completed",
now,
}),
).toBe(true);
});
it("allows takeover when both the lease and run are inactive", () => {
expect(
executionBlocksUserTakeover({
hasLease: true,
leaseExpiresAt: new Date(now - 1),
runStatus: "completed",
now,
}),
).toBe(false);
});
});

View File

@ -0,0 +1,89 @@
import type { ComputerStatus } from "@rakazo/contracts";
import { ACTIVE_RUN_STATUSES, computerScreenSize } from "@rakazo/core";
import type { PrismaClient } from "@rakazo/db";
/** Mirrors computer.takeover: an execution lease blocks user control unless waiting_takeover. */
export function executionBlocksUserTakeover(input: {
hasLease: boolean;
leaseExpiresAt: Date | null | undefined;
runStatus: string | null | undefined;
now?: number;
}): boolean {
if (!input.hasLease) return false;
if (input.runStatus === "waiting_takeover") return false;
const now = input.now ?? Date.now();
const leaseActive = Boolean(input.leaseExpiresAt && input.leaseExpiresAt.getTime() > now);
const runActive = Boolean(
input.runStatus && (ACTIVE_RUN_STATUSES as readonly string[]).includes(input.runStatus),
);
return leaseActive || runActive;
}
export async function resolveBusyBotName(
prisma: PrismaClient,
input: {
computerId: string | null | undefined;
botId: string;
botName: string;
},
): Promise<string | null> {
if (!input.computerId) return null;
const lease = await prisma.computerExecutionLease.findUnique({
where: { computerId_botId: { computerId: input.computerId, botId: input.botId } },
select: { expiresAt: true, runId: true },
});
if (!lease) return null;
const run = await prisma.run.findUnique({
where: { id: lease.runId },
select: { status: true },
});
return executionBlocksUserTakeover({
hasLease: true,
leaseExpiresAt: lease.expiresAt,
runStatus: run?.status,
})
? input.botName
: null;
}
export function toComputerStatus(
botId: string,
computer: {
kind: string;
state: string;
scope: string;
controlHolder: string;
controlBotId?: string | null;
controlRunId?: string | null;
homeRevision: string;
} | null,
busyBotName: string | null = null,
): ComputerStatus {
const state =
computer?.state === "suspending"
? "running"
: computer?.state === "stopped" ||
computer?.state === "booting" ||
computer?.state === "running" ||
computer?.state === "suspended" ||
computer?.state === "error"
? computer.state
: "stopped";
const screen = computerScreenSize(computer?.kind);
const kind = (computer?.kind ?? "fake") as ComputerStatus["kind"];
return {
botId,
mode: computer?.scope === "dedicated" ? "dedicated" : "team",
kind,
state,
controlHolder: (computer?.controlHolder ?? "none") as ComputerStatus["controlHolder"],
controlBotId: computer?.controlBotId ?? null,
takeoverRequested: Boolean(computer?.controlRunId),
screenAvailable: state === "running" || state === "booting",
screenWidth: screen.width,
screenHeight: screen.height,
homeRevision: computer?.homeRevision ?? null,
busyBotName,
updateAvailable: kind !== "desktop",
};
}

192
apps/api/src/env.test.ts Normal file
View File

@ -0,0 +1,192 @@
import { describe, expect, it } from "vitest";
import { loadEnv } from "./env.js";
const base = {
DATABASE_URL: "postgres://rakazo:rakazo@127.0.0.1:5433/rakazo",
NODE_ENV: "test",
};
describe("loadEnv", () => {
it("defaults the product path to Pi, Docker, and Graphile Worker", () => {
const env = loadEnv(base);
expect(env.agentRuntime).toBe("pi");
expect(env.sandboxProvider).toBe("docker");
expect(env.wakeupDriver).toBe("graphile");
expect(env.apiHost).toBe("127.0.0.1");
expect(env.nodeEnv).toBe("test");
});
it("keeps explicit emulator settings for pnpm test", () => {
const env = loadEnv({
...base,
AGENT_RUNTIME: "scripted",
SANDBOX_PROVIDER: "fake",
WAKEUP_DRIVER: "memory",
});
expect(env.agentRuntime).toBe("scripted");
expect(env.sandboxProvider).toBe("fake");
expect(env.wakeupDriver).toBe("memory");
});
it("falls back to none when a remote provider key is missing", () => {
expect(
loadEnv({
...base,
SANDBOX_PROVIDER: "e2b",
}).sandboxProvider,
).toBe("none");
expect(
loadEnv({
...base,
SANDBOX_PROVIDER: "none",
}).sandboxProvider,
).toBe("none");
expect(
loadEnv({
...base,
SANDBOX_PROVIDER: "",
}).sandboxProvider,
).toBe("none");
});
it("loads provider-specific Daytona configuration", () => {
const env = loadEnv({
...base,
SANDBOX_PROVIDER: "daytona",
DAYTONA_API_KEY: "test-daytona-key",
DAYTONA_API_URL: "https://daytona.test/api",
DAYTONA_TARGET: "test-target",
});
expect(env).toMatchObject({
sandboxProvider: "daytona",
daytonaApiKey: "test-daytona-key",
daytonaApiUrl: "https://daytona.test/api",
daytonaTarget: "test-target",
});
});
it("loads provider-specific Box configuration", () => {
const env = loadEnv({
...base,
SANDBOX_PROVIDER: "box",
BOX_API_KEY: "test-box-key",
BOX_API_URL: "https://box.test/api/v1",
});
expect(env).toMatchObject({
sandboxProvider: "box",
boxApiKey: "test-box-key",
boxApiUrl: "https://box.test/api/v1",
});
});
it("throws when production omits secrets", () => {
expect(() =>
loadEnv({
DATABASE_URL: base.DATABASE_URL,
NODE_ENV: "production",
}),
).toThrow(/BETTER_AUTH_SECRET/);
});
it("throws when production uses placeholder secrets", () => {
expect(() =>
loadEnv({
DATABASE_URL: base.DATABASE_URL,
NODE_ENV: "production",
BETTER_AUTH_SECRET: "dev-secret-change-me-please-32chars",
ENCRYPTION_KEY: "real-encryption-key-value",
SANDBOX_SUPERVISOR_TOKEN: "real-supervisor-token-with-enough-length",
SCREEN_PROXY_SECRET: "real-screen-proxy-secret-with-enough-length",
}),
).toThrow(/BETTER_AUTH_SECRET/);
});
it("loads real secrets in production", () => {
const env = loadEnv({
DATABASE_URL: base.DATABASE_URL,
NODE_ENV: "production",
BETTER_AUTH_SECRET: "prod-auth-secret-with-enough-length",
ENCRYPTION_KEY: "prod-encryption-key-with-enough-length",
SCREEN_PROXY_SECRET: "prod-screen-proxy-secret-with-enough-length",
SANDBOX_PROVIDER: "e2b",
API_HOST: "0.0.0.0",
});
expect(env.authSecret).toBe("prod-auth-secret-with-enough-length");
expect(env.encryptionKey).toBe("prod-encryption-key-with-enough-length");
expect(env.sandboxSupervisorToken).toBeUndefined();
expect(env.screenProxySecret).toBe("prod-screen-proxy-secret-with-enough-length");
expect(env.apiHost).toBe("0.0.0.0");
});
it("falls back to none in production when Docker has no supervisor token", () => {
const env = loadEnv({
DATABASE_URL: base.DATABASE_URL,
NODE_ENV: "production",
BETTER_AUTH_SECRET: "prod-auth-secret-with-enough-length",
ENCRYPTION_KEY: "prod-encryption-key-with-enough-length",
SCREEN_PROXY_SECRET: "prod-screen-proxy-secret-with-enough-length",
SANDBOX_PROVIDER: "docker",
});
expect(env.sandboxProvider).toBe("none");
expect(env.sandboxSupervisorToken).toBeUndefined();
});
it("requires a dedicated supervisor token when Docker stays selected", () => {
expect(() =>
loadEnv({
DATABASE_URL: base.DATABASE_URL,
NODE_ENV: "production",
BETTER_AUTH_SECRET: "prod-auth-secret-with-enough-length",
ENCRYPTION_KEY: "prod-encryption-key-with-enough-length",
SCREEN_PROXY_SECRET: "prod-screen-proxy-secret-with-enough-length",
SANDBOX_PROVIDER: "docker",
SANDBOX_SUPERVISOR_TOKEN: "too-short",
}),
).toThrow(/SANDBOX_SUPERVISOR_TOKEN/);
});
it("exposes a deployed git revision when GIT_SHA is set", () => {
expect(loadEnv(base).gitSha).toBeUndefined();
expect(loadEnv({ ...base, GIT_SHA: " 3c6e209 " }).gitSha).toBe("3c6e209");
expect(loadEnv({ ...base, RAKAZO_GIT_SHA: "abc1234" }).gitSha).toBe("abc1234");
});
it("loads optional updater sidecar wiring without requiring the token at boot", () => {
expect(loadEnv(base).updaterUrl).toBeUndefined();
expect(loadEnv(base).updaterToken).toBeUndefined();
const env = loadEnv({
...base,
RAKAZO_UPDATER_URL: " http://updater:7092 ",
RAKAZO_UPDATER_TOKEN: " fake-review-updater-token-000000000000 ",
});
expect(env.updaterUrl).toBe("http://updater:7092");
expect(env.updaterToken).toBe("fake-review-updater-token-000000000000");
});
it("loads SMTP configuration and keeps the email emulator out of production", () => {
expect(
loadEnv({
...base,
SMTP_URL: " smtps://user:secret@smtp.example.test:465 ",
EMAIL_FROM: " BangSo Bot <no-reply@example.test> ",
EMAIL_EMULATOR: "true",
}),
).toMatchObject({
smtpUrl: "smtps://user:secret@smtp.example.test:465",
emailFrom: "BangSo Bot <no-reply@example.test>",
emailEmulator: true,
});
expect(
loadEnv({
...base,
NODE_ENV: "production",
BETTER_AUTH_SECRET: "prod-auth-secret-with-enough-length",
ENCRYPTION_KEY: "prod-encryption-key-with-enough-length",
SCREEN_PROXY_SECRET: "prod-screen-proxy-secret-with-enough-length",
SANDBOX_PROVIDER: "none",
EMAIL_EMULATOR: "true",
}).emailEmulator,
).toBe(false);
expect(loadEnv({ ...base, NODE_ENV: "development" }).nodeEnv).toBe("development");
});
});

153
apps/api/src/env.ts Normal file
View File

@ -0,0 +1,153 @@
import { resolveDeploymentModel, resolveSandboxProvider } from "@rakazo/adapters";
import {
resolveAuthSecret,
resolveEncryptionKey,
resolveScreenProxySecret,
resolveSupervisorToken,
} from "@rakazo/core";
export { resolveSandboxProvider } from "@rakazo/adapters";
export interface AppEnv {
nodeEnv: string;
databaseUrl: string;
realtimeDatabaseUrl: string;
authSecret: string;
authUrl: string;
webOrigin: string;
apiUrl: string;
apiHost: string;
signupsEnabled: string | undefined;
signupAllowlist: string | undefined;
encryptionKey: string;
dataDir: string;
sandboxSupervisorUrl: string;
sandboxSupervisorToken: string | undefined;
screenProxySecret: string;
sandboxProvider: string;
agentRuntime: string;
deploymentModelKey: string | undefined;
e2bApiKey: string | undefined;
daytonaApiKey: string | undefined;
daytonaApiUrl: string | undefined;
daytonaTarget: string | undefined;
boxApiKey: string | undefined;
boxApiUrl: string | undefined;
composioApiKey: string | undefined;
pipedreamClientId: string | undefined;
pipedreamClientSecret: string | undefined;
pipedreamProjectId: string | undefined;
pipedreamEnvironment: "development" | "production";
sendblueApiKeyId: string | undefined;
sendblueApiSecret: string | undefined;
sendblueSigningSecret: string | undefined;
sendbluePhoneNumber: string | undefined;
smtpUrl: string | undefined;
emailFrom: string | undefined;
emailEmulator: boolean;
slackBotToken: string | undefined;
slackSigningSecret: string | undefined;
whatsappAccessToken: string | undefined;
whatsappPhoneNumberId: string | undefined;
whatsappAppSecret: string | undefined;
whatsappVerifyToken: string | undefined;
telegramBotToken: string | undefined;
telegramWebhookSecret: string | undefined;
/** Unknown chat senders auto-provision their own accounts when true. */
messagingOpenSignup: boolean;
defaultProvider: string;
defaultModel: string;
wakeupDriver: string;
mcpStdioEnabled: boolean;
mcpStdioAllowedCommands: string[];
port: number;
gitSha: string | undefined;
/** Private Compose control-network URL for the opt-in updater sidecar. */
updaterUrl: string | undefined;
/** Bearer shared with the updater; never sent to the browser. */
updaterToken: string | undefined;
/** Current application image tag; used for compose manual-upgrade command selection. */
imageTag: string | undefined;
}
export function loadEnv(source: NodeJS.ProcessEnv = process.env): AppEnv {
const authSecret = resolveAuthSecret(source);
const sandboxProvider = resolveSandboxProvider(source);
const deploymentModel = resolveDeploymentModel(source);
const updaterUrl = optional(source.RAKAZO_UPDATER_URL);
const updaterToken = optional(source.RAKAZO_UPDATER_TOKEN);
return {
nodeEnv: source.NODE_ENV ?? "",
databaseUrl: required(source, "DATABASE_URL"),
realtimeDatabaseUrl: source.REALTIME_DATABASE_URL ?? required(source, "DATABASE_URL"),
authSecret,
authUrl: source.BETTER_AUTH_URL ?? source.WEB_ORIGIN ?? "http://127.0.0.1:5173",
webOrigin: source.WEB_ORIGIN ?? "http://127.0.0.1:5173",
apiUrl: source.API_URL ?? "http://127.0.0.1:3100",
apiHost: source.API_HOST ?? "127.0.0.1",
signupsEnabled: source.SIGNUPS_ENABLED,
signupAllowlist: source.SIGNUP_ALLOWLIST,
encryptionKey: resolveEncryptionKey(source),
dataDir: source.DATA_DIR ?? "./data",
sandboxSupervisorUrl: source.SANDBOX_SUPERVISOR_URL ?? "http://127.0.0.1:7091",
sandboxSupervisorToken:
sandboxProvider === "docker" ? resolveSupervisorToken(source) : undefined,
screenProxySecret: resolveScreenProxySecret(source),
sandboxProvider,
agentRuntime: source.AGENT_RUNTIME ?? "pi",
// Provider, model and key resolve together: see resolveDeploymentModel.
deploymentModelKey: deploymentModel.key,
e2bApiKey: source.E2B_API_KEY,
daytonaApiKey: source.DAYTONA_API_KEY,
daytonaApiUrl: source.DAYTONA_API_URL,
daytonaTarget: source.DAYTONA_TARGET,
boxApiKey: source.BOX_API_KEY,
boxApiUrl: source.BOX_API_URL ?? source.BOX_BASE_URL,
composioApiKey: source.COMPOSIO_API_KEY,
pipedreamClientId: optional(source.PIPEDREAM_CLIENT_ID),
pipedreamClientSecret: optional(source.PIPEDREAM_CLIENT_SECRET),
pipedreamProjectId: optional(source.PIPEDREAM_PROJECT_ID),
pipedreamEnvironment:
source.PIPEDREAM_ENVIRONMENT === "production" ? "production" : "development",
sendblueApiKeyId: optional(source.SENDBLUE_API_KEY_ID),
sendblueApiSecret: optional(source.SENDBLUE_API_SECRET),
sendblueSigningSecret: optional(source.SENDBLUE_SIGNING_SECRET),
sendbluePhoneNumber: optional(source.SENDBLUE_PHONE_NUMBER),
smtpUrl: optional(source.SMTP_URL),
emailFrom: optional(source.EMAIL_FROM),
emailEmulator: source.EMAIL_EMULATOR === "true" && source.NODE_ENV !== "production",
slackBotToken: optional(source.SLACK_BOT_TOKEN),
slackSigningSecret: optional(source.SLACK_SIGNING_SECRET),
whatsappAccessToken: optional(source.WHATSAPP_ACCESS_TOKEN),
whatsappPhoneNumberId: optional(source.WHATSAPP_PHONE_NUMBER_ID),
whatsappAppSecret: optional(source.WHATSAPP_APP_SECRET),
whatsappVerifyToken: optional(source.WHATSAPP_VERIFY_TOKEN),
telegramBotToken: optional(source.TELEGRAM_BOT_TOKEN),
telegramWebhookSecret: optional(source.TELEGRAM_WEBHOOK_SECRET_TOKEN),
messagingOpenSignup: source.MESSAGING_OPEN_SIGNUP === "true",
defaultProvider: deploymentModel.provider,
defaultModel: deploymentModel.model,
wakeupDriver: source.WAKEUP_DRIVER ?? "graphile",
mcpStdioEnabled: source.MCP_STDIO_ENABLED === "true",
mcpStdioAllowedCommands: (source.MCP_STDIO_ALLOWED_COMMANDS ?? "")
.split(",")
.map((value) => value.trim())
.filter(Boolean),
port: Number(source.API_PORT ?? 3100),
gitSha: optional(source.GIT_SHA) ?? optional(source.RAKAZO_GIT_SHA),
updaterUrl,
updaterToken,
imageTag: optional(source.RAKAZO_IMAGE_TAG),
};
}
function required(source: NodeJS.ProcessEnv, key: string): string {
const value = source[key];
if (!value) throw new Error(`Missing ${key}`);
return value;
}
function optional(value: string | undefined): string | undefined {
const trimmed = value?.trim();
return trimmed || undefined;
}

39
apps/api/src/index.ts Normal file
View File

@ -0,0 +1,39 @@
import type { Socket } from "node:net";
import { loadRootEnv } from "@rakazo/core/node/load-root-env";
loadRootEnv();
import { serve } from "@hono/node-server";
import { createApp } from "./app.js";
import { loadEnv } from "./env.js";
const env = loadEnv();
const { app, stop } = await createApp(env);
const server = serve({ fetch: app.fetch, port: env.port, hostname: env.apiHost }, () => {
console.log(`rakazo api on http://${env.apiHost}:${env.port}`);
});
// Long-lived connections (threads.subscribe SSE streams) never end on their
// own, so server.close() alone waits forever for them. Track sockets and
// force-close any still open after a short grace period for in-flight
// requests, or every restart/shutdown hangs until something force-kills it.
const sockets = new Set<Socket>();
server.on("connection", (socket) => {
sockets.add(socket);
socket.once("close", () => sockets.delete(socket));
});
let stopping = false;
const shutdown = async () => {
if (stopping) return;
stopping = true;
const closed = new Promise<void>((resolve) => server.close(() => resolve()));
const grace = setTimeout(() => {
for (const socket of sockets) socket.destroy();
}, 2_000);
await closed;
clearTimeout(grace);
await stop();
};
process.once("SIGTERM", () => void shutdown());
process.once("SIGINT", () => void shutdown());

View File

@ -0,0 +1,36 @@
import { ORPCError } from "@orpc/server";
import { afterEach, describe, expect, it, vi } from "vitest";
import { logUnexpectedRpcError } from "./app.js";
describe("logUnexpectedRpcError", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("stays quiet for an error the router chose to return", () => {
const logError = vi.spyOn(console, "error").mockImplementation(() => undefined);
logUnexpectedRpcError(new ORPCError("BAD_REQUEST", { message: "file is too large" }), [
"computer",
"readFile",
]);
expect(logError).not.toHaveBeenCalled();
});
it("names the procedure and every cause behind an opaque failure", () => {
const logError = vi.spyOn(console, "error").mockImplementation(() => undefined);
const error = new Error("fetch failed", {
cause: Object.assign(new Error("connect ECONNREFUSED 127.0.0.1:7091"), {
code: "ECONNREFUSED",
}),
});
logUnexpectedRpcError(error, ["computer", "screenUrl"]);
const logged = logError.mock.calls[0]?.join(" ") ?? "";
expect(logged).toContain("rpc computer/screenUrl failed");
expect(logged).toContain("fetch failed");
expect(logged).toContain("connect ECONNREFUSED 127.0.0.1:7091");
});
});

View File

@ -0,0 +1,130 @@
import { describe, expect, it } from "vitest";
import { buildMcpUpdateMaterial } from "./mcp-material.js";
describe("buildMcpUpdateMaterial", () => {
it("keeps the stored blob untouched when the update carries no credential data", () => {
expect(
buildMcpUpdateMaterial(
{},
{
transport: "streamable_http",
slug: "x",
name: "x",
endpoint: "https://mcp.example.test",
headers: {},
},
),
).toEqual({ action: "keep" });
});
it("stores a merged blob when a new secret is supplied, preserving OAuth state", () => {
const existing = { secret: "old-token", oauth: { tokens: { access_token: "t" } } };
const result = buildMcpUpdateMaterial(existing, {
transport: "streamable_http",
slug: "x",
name: "x",
endpoint: "https://mcp.example.test",
headers: {},
secret: "new-token",
});
expect(result).toEqual({
action: "store",
material: { secret: "new-token", oauth: { tokens: { access_token: "t" } }, headers: {} },
});
});
it("persists env/headers even without any static secret (no silent drop)", () => {
const result = buildMcpUpdateMaterial(
{},
{
transport: "stdio",
slug: "x",
name: "x",
command: "/bin/mcp",
env: { API_KEY: "k" },
},
);
expect(result).toEqual({ action: "store", material: { env: { API_KEY: "k" } } });
});
it("clearing removes static credentials but keeps OAuth state", () => {
const existing = { secret: "token", env: { A: "b" }, oauth: { tokens: { access_token: "t" } } };
const result = buildMcpUpdateMaterial(existing, {
transport: "streamable_http",
slug: "x",
name: "x",
endpoint: "https://mcp.example.test",
headers: {},
clearCredential: true,
});
expect(result).toEqual({
action: "store",
material: { oauth: { tokens: { access_token: "t" } } },
});
});
it("clearing with no OAuth state yields an empty blob so the caller can delete the secret row", () => {
const result = buildMcpUpdateMaterial(
{ secret: "token" },
{
transport: "streamable_http",
slug: "x",
name: "x",
endpoint: "https://mcp.example.test",
headers: {},
clearCredential: true,
},
);
expect(result).toEqual({ action: "store", material: {} });
});
it("clears endpoint-bound OAuth state while preserving static credentials", () => {
const result = buildMcpUpdateMaterial(
{ secret: "token", oauth: { tokens: { access_token: "endpoint-token" } } },
{
transport: "streamable_http",
slug: "x",
name: "x",
endpoint: "https://new-mcp.example.test",
headers: {},
},
{ clearOAuth: true },
);
expect(result).toEqual({
action: "store",
material: { secret: "token", headers: {} },
});
});
it("deletes an OAuth-only blob when its endpoint changes", () => {
const result = buildMcpUpdateMaterial(
{ oauth: { tokens: { access_token: "endpoint-token" } } },
{
transport: "streamable_http",
slug: "x",
name: "x",
endpoint: "https://new-mcp.example.test",
headers: {},
},
{ clearOAuth: true },
);
expect(result).toEqual({ action: "store", material: {} });
});
it("replaces headers on update and leaves env untouched when the transport cannot express it", () => {
const result = buildMcpUpdateMaterial(
{ secret: "s", env: { OLD: "x" }, headers: { Authorization: "a" } },
{
transport: "streamable_http",
slug: "x",
name: "x",
endpoint: "https://mcp.example.test",
headers: { Authorization: "b" },
},
);
expect(result).toEqual({
action: "store",
material: { secret: "s", env: { OLD: "x" }, headers: { Authorization: "b" } },
});
});
});

View File

@ -0,0 +1,63 @@
import type { McpServerConfigInput } from "@rakazo/contracts";
/** Shape of the encrypted MCP credential blob. `oauth` holds SDK OAuth state
* (tokens, client registration, PKCE verifier) managed by McpOAuthBroker. */
export type McpSecretMaterial = {
secret?: string;
env?: Record<string, string>;
headers?: Record<string, string>;
oauth?: Record<string, unknown>;
};
export type McpMaterialUpdate =
| { action: "keep" }
| { action: "store"; material: McpSecretMaterial };
/** Compute the next encrypted credential blob for an MCP server update.
*
* - "keep": the update carries no credential data; leave the stored blob as is.
* - "store": rewrite the blob. An empty material means credentials were
* cleared entirely the caller should delete the secret row and null the
* server's secretId instead of storing an empty object.
*
* env/headers use full-replace semantics (the update payload is the complete
* set), matching the create handler. OAuth state is preserved unless the
* endpoint changed, since tokens issued for one endpoint must never be sent to
* another server. */
export function buildMcpUpdateMaterial(
existing: McpSecretMaterial,
config: McpServerConfigInput,
options: { clearOAuth?: boolean } = {},
): McpMaterialUpdate {
const material = options.clearOAuth ? { ...existing } : existing;
const clearedOAuth = options.clearOAuth === true && material.oauth !== undefined;
if (options.clearOAuth) delete material.oauth;
const clearing = config.clearCredential === true;
if (clearing) {
return { action: "store", material: material.oauth ? { oauth: material.oauth } : {} };
}
const secret = "secret" in config && config.secret ? config.secret : undefined;
const env = "env" in config ? config.env : undefined;
const headers = "headers" in config ? config.headers : undefined;
const existingHasMaterial = Boolean(
material.secret ||
(material.env && Object.keys(material.env).length > 0) ||
(material.headers && Object.keys(material.headers).length > 0) ||
material.oauth,
);
const suppliesMaterial = Boolean(
secret || (env && Object.keys(env).length > 0) || (headers && Object.keys(headers).length > 0),
);
if (!existingHasMaterial && !suppliesMaterial) {
return clearedOAuth ? { action: "store", material } : { action: "keep" };
}
return {
action: "store",
material: {
...material,
...(secret ? { secret } : {}),
...(env !== undefined ? { env } : {}),
...(headers !== undefined ? { headers } : {}),
},
};
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,530 @@
import type { JobPublisher, MessagingInboundMessage } from "@rakazo/adapter-kit";
import { messagingDeliverJob, runContinueJob } from "@rakazo/adapter-kit";
import type { MessageBlock } from "@rakazo/contracts";
import { parseMessagingCommand, sanitizeMessagingLabel } from "@rakazo/core";
import type {
MessagingIdentityRequest,
Prisma,
PrismaClient,
ProvisionedMessagingIdentity,
SignupPolicyEnv,
ThreadEvents,
} from "@rakazo/db";
import {
createThreadMessage,
normalizeMessagingLinkCode,
redeemMessagingLinkCode,
} from "@rakazo/db";
export interface MessagingInboundDeps {
prisma: PrismaClient;
events: Pick<ThreadEvents, "sendUserMessage" | "notify">;
jobs: Pick<JobPublisher, "enqueue">;
provision: (
request: MessagingIdentityRequest,
env: SignupPolicyEnv,
) => Promise<ProvisionedMessagingIdentity>;
/**
* Poke-style open line: unknown senders auto-provision their own account.
* Off by default strangers' runs would bill the deployment model key.
*/
openSignup: boolean;
signupPolicy: SignupPolicyEnv;
/**
* Best-effort "…" bubbles shown to a 1:1 sender while their run executes.
* Cosmetic only callers must catch failures; groups never get it.
*/
typing?: (threadId: string) => Promise<void>;
}
type IdentityRow = {
id: string;
provider: string;
address: string;
userId: string;
spaceId: string;
botId: string;
};
/**
* Inbound routing. 1:1 messages are messages to the sender's own bot (with
* provisioning on first contact and the YES/NO/LEAVE owner commands).
* Group messages drive channel discovery upsert channel + members, DM
* invites to linked owners, one intro when strangers are present and
* fan out to every approved member bot's own thread.
*/
export function createMessagingInboundHandler(deps: MessagingInboundDeps) {
return async (event: MessagingInboundMessage): Promise<void> => {
if (!event.isDirect) {
await handleChannelEvent(deps, event);
return;
}
await handleDirectEvent(deps, event);
};
}
async function handleDirectEvent(
deps: MessagingInboundDeps,
event: MessagingInboundMessage,
): Promise<void> {
// Inbound media arrives as a CDN URL (often expiring); no artifact
// ingestion in v1, so it rides along as text.
const text = [event.content, event.mediaUrl].filter(Boolean).join("\n");
const where = { provider_address: { provider: event.provider, address: event.from } } as const;
const existing = await deps.prisma.messagingIdentity.findUnique({ where });
if (existing) {
// Any reply — even a content-free reaction — ends the consecutive-
// outbound streak, but only real text wakes the bot. The conversation
// id is refreshed from the webhook so outbound always has a thread.
await deps.prisma.messagingIdentity.update({
where: { id: existing.id },
data: { outboundSinceInbound: 0, lastInboundAt: new Date(), dmThreadId: event.threadId },
});
if (!text) return;
// Owner commands are only parsed in the verified 1:1 conversation.
const command = parseMessagingCommand(event.content);
if (command && (await applyOwnerCommand(deps, existing, command))) return;
// A linked sender pasting a fresh code re-points this address at another
// of their bots (only their own codes apply).
if (await tryRedeemLinkCode(deps, event)) return;
} else {
// Unlinked senders: a valid link code binds this address to its issuer's
// account and bot; otherwise the line is silent unless the deployment
// explicitly runs as an open Poke-style signup line.
if (await tryRedeemLinkCode(deps, event)) return;
if (!deps.openSignup) return;
// Never provision a full account for a reaction or empty payload.
if (!text) return;
}
let ids: ProvisionedMessagingIdentity;
if (existing) {
const thread = await deps.prisma.thread.findFirst({ where: { botId: existing.botId } });
if (!thread) throw new Error(`messaging identity ${existing.id} has no thread`);
ids = {
provider: existing.provider,
address: existing.address,
userId: existing.userId,
spaceId: existing.spaceId,
botId: existing.botId,
threadId: thread.id,
created: false,
};
} else {
ids = await deps.provision(
{
provider: event.provider,
address: event.from,
dmThreadId: event.threadId,
displayName: event.fromLabel ? sanitizeMessagingLabel(event.fromLabel) : null,
},
deps.signupPolicy,
);
}
const sent = await deps.events.sendUserMessage({
spaceId: ids.spaceId,
threadId: ids.threadId,
botId: ids.botId,
userId: ids.userId,
blocks: [{ kind: "text", text }],
prompt: text,
trigger: "messaging",
clientNonce: `messaging:${event.provider}:${event.handle}`,
});
if (sent.runId) {
// Typing bubbles only make sense once a reply is actually coming. Fire
// before the enqueue so they land ahead of a fast reply, and never await:
// a stalled vendor typing call must not hold the webhook open. The bubbles
// clear on their own after a short display window or when the reply
// arrives, so long runs simply outlive them.
void deps.typing?.(event.threadId).catch((error) => {
console.error("messaging typing indicator error", error);
});
await deps.jobs.enqueue(runContinueJob(sent.runId)).catch((error) => {
console.error("messaging inbound run enqueue error", error);
});
}
}
/**
* Returns true when the message was exactly a link code that redeemed:
* the address is now bound to the issuer's chosen bot and a confirmation
* DM is on its way. An invalid or expired code falls through silently
* for linked senders it reads as a normal message, for strangers nothing
* happens (no oracle, no reply spam).
*/
async function tryRedeemLinkCode(
deps: MessagingInboundDeps,
event: MessagingInboundMessage,
): Promise<boolean> {
const code = normalizeMessagingLinkCode(event.content);
if (!code) return false;
const redeemed = await redeemMessagingLinkCode(deps.prisma, {
code,
provider: event.provider,
address: event.from,
dmThreadId: event.threadId,
});
if (!redeemed) return false;
const bot = await deps.prisma.bot.findUnique({
where: { id: redeemed.botId },
select: { name: true },
});
await enqueueConfirmation(
deps,
{ id: redeemed.identityId },
redeemed.confirmationKey,
`Linked — messages here now reach "${bot?.name ?? "your agent"}".`,
);
return true;
}
/** Returns true when the command matched a pending item and was handled. */
async function applyOwnerCommand(
deps: MessagingInboundDeps,
identity: IdentityRow,
command: "approve" | "decline" | "leave",
): Promise<boolean> {
if (command === "leave") {
const membership = await deps.prisma.messagingChannelMember.findFirst({
where: { identityId: identity.id, status: "approved" },
orderBy: { updatedAt: "desc" },
});
if (!membership) return false;
const { count } = await deps.prisma.messagingChannelMember.updateMany({
where: { id: membership.id, status: "approved" },
data: { status: "left" },
});
// State changed under us (e.g. swept out and re-invited): treat the
// text as a normal message rather than overwriting the newer state.
if (count === 0) return false;
await enqueueConfirmation(
deps,
identity,
`command:leave:${membership.id}`,
"You've left the channel. Your agent will no longer post there. The group chat itself is unchanged.",
);
return true;
}
const membership = await deps.prisma.messagingChannelMember.findFirst({
where: { identityId: identity.id, status: "invited" },
orderBy: { updatedAt: "desc" },
});
const connection = await deps.prisma.agentConnection.findFirst({
where: { targetBotId: identity.botId, status: "pending" },
orderBy: { updatedAt: "desc" },
});
// YES/NO answers whichever pending item is newest, channel invite or
// agent connection.
const target =
membership && (!connection || membership.updatedAt >= connection.updatedAt)
? ({ kind: "channel", membership } as const)
: connection
? ({ kind: "connection", connection } as const)
: null;
if (!target) return false;
const approved = command === "approve";
if (target.kind === "channel") {
const key = `command:${command}:${target.membership.id}`;
const claimed = await deps.prisma.$transaction(async (tx) => {
// The claim holds the membership row lock through commit, so the
// participant sweep can never interleave with the confirmation write.
const { count } = await tx.messagingChannelMember.updateMany({
where: { id: target.membership.id, status: "invited" },
data: { status: approved ? "approved" : "declined" },
});
// Swept out or answered elsewhere since the read: not ours to write.
if (count === 0) return false;
await writeConfirmation(
tx,
identity,
key,
approved
? "You're in. Your agent will now see and reply to that group."
: "No problem, your agent will stay out of that group.",
);
return true;
});
if (!claimed) return false;
await enqueueDeliverJob(deps);
return true;
}
const connectedKey = `command:connected:${target.connection.id}`;
const requesterIdentity = approved
? await deps.prisma.messagingIdentity.findUnique({
where: { botId: target.connection.requesterBotId },
})
: null;
const key = `command:${command}:${target.connection.id}`;
const claimed = await deps.prisma.$transaction(async (tx) => {
// The claim holds the connection row lock through commit, so a revoke
// can never interleave with the confirmation writes.
const { count } = await tx.agentConnection.updateMany({
where: { id: target.connection.id, status: "pending" },
data: { status: approved ? "approved" : "declined" },
});
// Revoked or answered elsewhere since the read: not ours to write.
if (count === 0) return false;
await writeConfirmation(
tx,
identity,
key,
approved
? "Connection approved. Your agents can now message each other."
: "Connection declined.",
);
if (requesterIdentity) {
await writeConfirmation(
tx,
requesterIdentity,
connectedKey,
"Your connection request was accepted. Your agents can now message each other.",
);
}
return true;
});
if (!claimed) return false;
await enqueueDeliverJob(deps);
return true;
}
/** Delete-then-insert inside the caller's claim transaction: the prior
* cycle's row must not suppress the new confirmation. */
async function writeConfirmation(
tx: Pick<Prisma.TransactionClient, "messagingOutbound">,
identity: { id: string },
key: string,
body: string,
): Promise<void> {
await tx.messagingOutbound.deleteMany({ where: { idempotencyKey: key } });
await tx.messagingOutbound.createMany({
data: [{ idempotencyKey: key, kind: "dm", identityId: identity.id, body }],
skipDuplicates: true,
});
}
async function enqueueDeliverJob(deps: MessagingInboundDeps): Promise<void> {
await deps.jobs.enqueue(messagingDeliverJob()).catch((error) => {
console.error("messaging confirmation enqueue error", error);
});
}
async function enqueueConfirmation(
deps: MessagingInboundDeps,
identity: { id: string },
key: string,
body: string,
): Promise<void> {
// Keys are stable per membership/connection across approval cycles; clear
// the prior cycle's row or skipDuplicates would swallow the new text.
await deps.prisma.messagingOutbound.deleteMany({ where: { idempotencyKey: key } });
await deps.prisma.messagingOutbound.createMany({
data: [{ idempotencyKey: key, kind: "dm", identityId: identity.id, body }],
skipDuplicates: true,
});
await deps.jobs.enqueue(messagingDeliverJob()).catch((error) => {
console.error("messaging confirmation enqueue error", error);
});
}
async function handleChannelEvent(
deps: MessagingInboundDeps,
event: MessagingInboundMessage,
): Promise<void> {
const channelName = event.channelName ? sanitizeMessagingLabel(event.channelName) : null;
const channel = await deps.prisma.messagingChannel.upsert({
where: { threadId: event.threadId },
create: { provider: event.provider, threadId: event.threadId, name: channelName },
update: channelName ? { name: channelName } : {},
});
const participants = [...event.participants];
if (!participants.includes(event.from)) participants.push(event.from);
let hasUnlinked = false;
for (const address of participants) {
const identity = await deps.prisma.messagingIdentity.findUnique({
where: { provider_address: { provider: event.provider, address } },
});
const member = await deps.prisma.messagingChannelMember.findUnique({
where: { channelId_address: { channelId: channel.id, address } },
});
if (member) {
// Compare against the current identity, not just null: unlinking
// deletes the identity row but leaves this FK-free column pointing at
// the dead id, so a re-link would otherwise never reattach and the
// member would sit in the channel unreachable by every lookup.
if (identity && member.identityId !== identity.id) {
await deps.prisma.messagingChannelMember.update({
where: { id: member.id },
data: { identityId: identity.id },
});
if (member.status === "invited") await inviteMember(deps, channel, identity);
}
if (member.status === "left") {
// Back in the group: restart the approval cycle.
await deps.prisma.messagingChannelMember.update({
where: { id: member.id },
data: { status: "invited" },
});
if (identity) await inviteMember(deps, channel, identity);
}
if (!identity) hasUnlinked = true;
continue;
}
// Upsert, not create: concurrent group webhooks race on the unique key.
await deps.prisma.messagingChannelMember.upsert({
where: { channelId_address: { channelId: channel.id, address } },
create: {
channelId: channel.id,
address,
identityId: identity?.id ?? null,
status: "invited",
},
update: {},
});
if (identity) await inviteMember(deps, channel, identity);
else hasUnlinked = true;
}
// Someone removed from the group must stop receiving its content.
// A webhook without a participants roster says nothing about membership —
// never sweep on partial data.
if (event.participants.length > 0) {
await deps.prisma.messagingChannelMember.updateMany({
where: {
channelId: channel.id,
address: { notIn: participants },
status: { in: ["invited", "approved"] },
},
data: { status: "left" },
});
}
if (hasUnlinked && !channel.introPostedAt) {
await deps.prisma.messagingOutbound.createMany({
data: [
{
idempotencyKey: `intro:${channel.id}`,
kind: "intro",
threadId: channel.threadId,
body: "Hi. This line hosts BangSo Bot personal agents. Some people in this group haven't messaged it yet; send any message to this line first if you want your own agent here.",
},
],
skipDuplicates: true,
});
await deps.prisma.messagingChannel.update({
where: { id: channel.id },
data: { introPostedAt: new Date() },
});
await deps.jobs.enqueue(messagingDeliverJob()).catch((error) => {
console.error("messaging intro enqueue error", error);
});
}
// Only approved owners' bots participate.
const senderMember = await deps.prisma.messagingChannelMember.findUnique({
where: { channelId_address: { channelId: channel.id, address: event.from } },
});
if (senderMember?.status !== "approved") return;
const senderIdentity = senderMember.identityId
? await deps.prisma.messagingIdentity.findUnique({ where: { id: senderMember.identityId } })
: null;
const fromLabel = senderIdentity
? await ownerFirstName(deps.prisma, senderIdentity.userId, event.from)
: event.from;
const approved = await deps.prisma.messagingChannelMember.findMany({
where: { channelId: channel.id, status: "approved", identityId: { not: null } },
});
const block: MessageBlock = {
kind: "channel_message",
provider: event.provider,
channelId: channel.id,
fromAddress: event.from,
fromLabel,
text: event.content,
hop: 0,
};
const prompt = `[Group "${channel.name ?? "group"}", ${fromLabel}]: ${event.content}`;
for (const member of approved) {
const identity = await deps.prisma.messagingIdentity.findUnique({
where: { id: member.identityId! },
});
if (!identity) continue;
const thread = await deps.prisma.thread.findFirst({ where: { botId: identity.botId } });
if (!thread) continue;
const sent = await deps.events.sendUserMessage({
spaceId: identity.spaceId,
threadId: thread.id,
botId: identity.botId,
userId: identity.userId,
blocks: [block],
prompt,
trigger: "messaging",
clientNonce: `messaging:${event.provider}:${event.handle}`,
});
if (sent.runId) {
await deps.jobs.enqueue(runContinueJob(sent.runId)).catch((error) => {
console.error("messaging channel fan-out enqueue error", error);
});
}
}
}
async function inviteMember(
deps: MessagingInboundDeps,
channel: { id: string; name: string | null },
identity: IdentityRow,
): Promise<void> {
const name = channel.name ?? "a group chat";
// A returning member restarts the approval cycle; clear the prior invite
// row or skipDuplicates would leave them with no prompt to answer.
await deps.prisma.messagingOutbound.deleteMany({
where: { idempotencyKey: `invite:${channel.id}:${identity.id}` },
});
await deps.prisma.messagingOutbound.createMany({
data: [
{
idempotencyKey: `invite:${channel.id}:${identity.id}`,
kind: "dm",
identityId: identity.id,
body: `"${name}" was linked to your BangSo Bot agent. Reply YES to let your agent join the conversation there, or NO to stay out.`,
},
],
skipDuplicates: true,
});
const thread = await deps.prisma.thread.findFirst({ where: { botId: identity.botId } });
if (thread) {
const note = await createThreadMessage(deps.prisma, {
threadId: thread.id,
role: "system",
blocks: [
{
kind: "meta",
text: `You were added to the group chat "${name}". Reply YES in this conversation to join it with your agent.`,
},
],
});
await deps.events.notify(thread.id, note.seq).catch(() => undefined);
}
await deps.jobs.enqueue(messagingDeliverJob()).catch((error) => {
console.error("messaging invite enqueue error", error);
});
}
async function ownerFirstName(
prisma: PrismaClient,
userId: string,
fallback: string,
): Promise<string> {
const user = await prisma.user.findUnique({ where: { id: userId }, select: { name: true } });
const first = user?.name.trim().split(/\s+/)[0];
return first ? sanitizeMessagingLabel(first) : fallback;
}

View File

@ -0,0 +1,93 @@
import type { MessagingSurface } from "@rakazo/adapter-kit";
import { Hono } from "hono";
import { describe, expect, it, vi } from "vitest";
import { MESSAGING_WEBHOOK_BASE_PATH, mountMessagingWebhookRoutes } from "./messaging-webhook.js";
/**
* The route is a thin pass-through: verification, parsing, and status
* handling all live inside the surface's platform adapters (covered in
* packages/adapters). These tests pin the routing contract only.
*/
function mount(respond?: (provider: string, request: Request) => Response) {
const handleWebhook = vi.fn((provider: string, request: Request) => {
if (provider !== "sendblue" && provider !== "whatsapp") return null;
return Promise.resolve(respond?.(provider, request) ?? Response.json({ ok: true }));
});
const app = new Hono();
mountMessagingWebhookRoutes(app, {
messaging: { handleWebhook } as unknown as MessagingSurface,
});
return { app, handleWebhook };
}
function post(body: string) {
return {
method: "POST",
headers: { "content-type": "application/json" },
body,
};
}
const payload = JSON.stringify({ content: "hi", is_outbound: false });
describe("messaging webhook HTTP routes", () => {
it("forwards the raw request to the surface under the :provider param", async () => {
const { app, handleWebhook } = mount();
const res = await app.request(`${MESSAGING_WEBHOOK_BASE_PATH}/sendblue`, post(payload));
expect(res.status).toBe(200);
expect(handleWebhook).toHaveBeenCalledTimes(1);
const [provider, request] = handleWebhook.mock.calls[0]! as [string, Request];
expect(provider).toBe("sendblue");
// The raw Request passes through untouched so platform adapters can
// verify signatures against the exact body bytes and headers.
expect(request.method).toBe("POST");
expect(new URL(request.url).pathname).toBe(`${MESSAGING_WEBHOOK_BASE_PATH}/sendblue`);
await expect(request.text()).resolves.toBe(payload);
});
it("returns 404 for a provider the surface does not host", async () => {
const { app, handleWebhook } = mount();
const res = await app.request(`${MESSAGING_WEBHOOK_BASE_PATH}/carrier-pigeon`, post(payload));
expect(res.status).toBe(404);
await expect(res.json()).resolves.toEqual({ error: "Unknown provider" });
expect(handleWebhook).toHaveBeenCalledWith("carrier-pigeon", expect.any(Request));
});
it("keeps the legacy phone webhook path routing to sendblue", async () => {
const { app, handleWebhook } = mount();
const res = await app.request("/api/v1/phone/webhook", post(payload));
expect(res.status).toBe(200);
expect(handleWebhook).toHaveBeenCalledWith("sendblue", expect.any(Request));
const [, request] = handleWebhook.mock.calls[0]! as [string, Request];
await expect(request.text()).resolves.toBe(payload);
});
it("returns the surface's response verbatim, including rejections", async () => {
const { app } = mount(() => new Response("signature mismatch", { status: 401 }));
const res = await app.request(`${MESSAGING_WEBHOOK_BASE_PATH}/sendblue`, post(payload));
expect(res.status).toBe(401);
await expect(res.text()).resolves.toBe("signature mismatch");
});
it("passes GET requests through for provider challenges", async () => {
// WhatsApp verifies its webhook with a GET hub.challenge handshake.
const { app, handleWebhook } = mount((_provider, request) => {
const challenge = new URL(request.url).searchParams.get("hub.challenge");
return new Response(challenge ?? "", { status: 200 });
});
const res = await app.request(
`${MESSAGING_WEBHOOK_BASE_PATH}/whatsapp?hub.mode=subscribe&hub.challenge=12345`,
{ method: "GET" },
);
expect(res.status).toBe(200);
await expect(res.text()).resolves.toBe("12345");
const [provider, request] = handleWebhook.mock.calls[0]! as [string, Request];
expect(provider).toBe("whatsapp");
expect(request.method).toBe("GET");
});
});

View File

@ -0,0 +1,26 @@
import type { MessagingSurface } from "@rakazo/adapter-kit";
import type { Hono } from "hono";
export const MESSAGING_WEBHOOK_BASE_PATH = "/api/v1/messaging/webhook";
/**
* Per-provider inbound webhooks. Verification (Slack HMAC, WhatsApp
* signatures + GET challenge, Telegram secret header, sendblue shared
* secret) happens inside the surface's platform adapters; replay safety
* comes from per-message client nonces downstream. Mounted only when the
* messaging surface is enabled.
*/
export function mountMessagingWebhookRoutes(app: Hono, deps: { messaging: MessagingSurface }) {
app.all(`${MESSAGING_WEBHOOK_BASE_PATH}/:provider`, async (c) => {
const response = deps.messaging.handleWebhook(c.req.param("provider"), c.req.raw);
if (!response) return c.json({ error: "Unknown provider" }, 404);
return response;
});
// The pre-multi-platform sendblue path; already-configured dashboards
// keep delivering without an update.
app.post("/api/v1/phone/webhook", async (c) => {
const response = deps.messaging.handleWebhook("sendblue", c.req.raw);
if (!response) return c.json({ error: "Unknown provider" }, 404);
return response;
});
}

263
apps/api/src/onboarding.ts Normal file
View File

@ -0,0 +1,263 @@
import type { ComposioProvider } from "@rakazo/adapters";
import type { Actor, MessageBlock } from "@rakazo/contracts";
import { featuredConnectorProvidersMatch } from "@rakazo/core";
import {
createThreadMessage,
IsolationError,
type PrismaClient,
type ThreadEvents,
} from "@rakazo/db";
/**
* First-run conversational onboarding, seeded deterministically into the bot's
* thread: greeting, a focus choice, and Composio app cards the user authorizes
* inline. Focus must not rename the bot. No model tokens are spent.
*/
type OnboardingDeps = {
prisma: PrismaClient;
events: ThreadEvents;
composio?: Pick<ComposioProvider, "catalog">;
};
type FocusOption = {
id: string;
letter: string;
label: string;
summary: string;
apps: string[];
};
const FOCUS_OPTIONS: FocusOption[] = [
{
id: "day",
letter: "A",
label: "Day-to-day work",
summary: "Slack, calendar, and email",
apps: ["slack", "gmail", "googlecalendar"],
},
{
id: "inbox",
letter: "B",
label: "Inbox & email",
summary: "email and calendar",
apps: ["gmail", "googlecalendar", "slack"],
},
{
id: "research",
letter: "C",
label: "Research & writing",
summary: "the web, notes, and docs",
apps: ["hackernews", "notion", "googledocs"],
},
{
id: "everything",
letter: "D",
label: "A bit of everything",
summary: "Slack, calendar, and email",
apps: ["slack", "gmail", "googlecalendar"],
},
];
const APP_DESCRIPTIONS: Record<string, string> = {
slack: "Search, read, and send messages.",
gmail: "Search, read, draft, and send email.",
googlecalendar: "Search events and schedule meetings.",
notion: "Search and edit pages and databases.",
googledocs: "Draft and edit documents.",
hackernews: "Search stories and discussions.",
};
const APP_NAMES: Record<string, string> = {
gmail: "Gmail",
googlecalendar: "Google Calendar",
googledocs: "Google Docs",
hackernews: "Hacker News",
notion: "Notion",
slack: "Slack",
};
async function requireBotThread(deps: OnboardingDeps, actor: Actor, botId: string) {
const bot = await deps.prisma.bot.findFirst({
where: { id: botId, spaceId: actor.spaceId, userId: actor.userId },
include: { thread: true },
});
if (!bot?.thread) throw new IsolationError();
return { bot, thread: bot.thread };
}
async function post(
deps: OnboardingDeps,
target: { spaceId: string; botId: string; threadId: string },
blocks: MessageBlock[],
): Promise<string> {
const message = await createThreadMessage(deps.prisma, {
threadId: target.threadId,
role: "bot",
blocks,
});
await deps.events.append({
spaceId: target.spaceId,
threadId: target.threadId,
botId: target.botId,
type: "thread.message.created",
payload: { messageId: message.id, role: "bot", blocks },
});
return message.id;
}
async function updateBlocks(
deps: OnboardingDeps,
target: { spaceId: string; botId: string; threadId: string },
messageId: string,
blocks: MessageBlock[],
): Promise<void> {
await deps.prisma.message.update({ where: { id: messageId }, data: { blocks } });
await deps.events.append({
spaceId: target.spaceId,
threadId: target.threadId,
botId: target.botId,
type: "thread.message.updated",
payload: { messageId, role: "bot", blocks },
});
}
export async function startOnboarding(
deps: OnboardingDeps,
actor: Actor,
botId: string,
): Promise<void> {
const { bot, thread } = await requireBotThread(deps, actor, botId);
const existing = await deps.prisma.message.count({ where: { threadId: thread.id } });
if (existing > 0) return;
const user = await deps.prisma.user.findUnique({
where: { id: actor.userId },
select: { name: true },
});
const firstName = (user?.name ?? "there").split(/\s+/)[0];
const target = { spaceId: actor.spaceId, botId: bot.id, threadId: thread.id };
await post(deps, target, [
{ kind: "text", text: `Hey ${firstName}. Fresh start on my side, so Ill keep this short.` },
]);
await post(deps, target, [
{
kind: "choice",
question: "What do you want me on first?",
options: FOCUS_OPTIONS.map(({ id, letter, label }) => ({ id, letter, label })),
},
]);
}
export async function chooseFocus(
deps: OnboardingDeps,
actor: Actor,
botId: string,
optionId: string,
): Promise<void> {
const option = FOCUS_OPTIONS.find((entry) => entry.id === optionId);
if (!option) throw new IsolationError();
const { bot, thread } = await requireBotThread(deps, actor, botId);
const target = { spaceId: actor.spaceId, botId: bot.id, threadId: thread.id };
const recent = await deps.prisma.message.findMany({
where: { threadId: thread.id },
orderBy: { createdAt: "asc" },
});
const pending = recent.find((message) =>
(message.blocks as MessageBlock[]).some((block) => block.kind === "choice" && !block.answerId),
);
if (!pending) return;
const blocks = (pending.blocks as MessageBlock[]).map((block) =>
block.kind === "choice" ? { ...block, answerId: option.id } : block,
);
await updateBlocks(deps, target, pending.id, blocks);
// Keep the name and title the user chose when creating the bot; the focus
// step only suggests apps, it must not rename the bot.
await post(deps, target, [
{
kind: "text",
text: `Got it. ${capitalize(option.summary)}. Ill see whats already connected so I dont make you set something up twice.`,
},
]);
const catalog = deps.composio
? await deps.composio
.catalog({
operationId: "onboarding.choose",
traceId: "onboarding.choose",
spaceId: actor.spaceId,
userId: actor.userId,
botId: bot.id,
signal: new AbortController().signal,
})
.catch(() => [])
: [];
const bySlug = new Map(catalog.map((entry) => [entry.slug.toLowerCase(), entry]));
const cards: MessageBlock[] = option.apps.map((slug) => {
const entry = bySlug.get(slug.toLowerCase());
return {
kind: "app_connect",
provider: entry?.slug ?? slug,
name: entry?.name ?? APP_NAMES[slug] ?? capitalize(slug),
description: APP_DESCRIPTIONS[slug] ?? `Connect ${entry?.name ?? slug} to your account.`,
logo: entry?.logo ?? null,
status: entry?.connected ? "connected" : "pending",
};
});
const cardNames = cards
.map((card) => (card.kind === "app_connect" ? card.name : ""))
.filter(Boolean);
const named = `${cardNames.slice(0, -1).join(", ")}${cardNames.length > 1 ? ", and " : ""}${cardNames.at(-1)}`;
await post(deps, target, [
{
kind: "text",
text: `${named} are a good place to start. Connect them here and Ill use what you already have.`,
},
]);
await post(deps, target, cards);
await post(deps, target, [
{
kind: "text",
text: `Hit those ${cards.length === 1 ? "one" : cards.length === 2 ? "two" : "three"} and Ill start pulling the picture.`,
},
]);
}
export async function markAppConnected(
deps: OnboardingDeps,
actor: Actor,
botId: string,
provider: string,
): Promise<void> {
const { bot, thread } = await requireBotThread(deps, actor, botId);
const target = { spaceId: actor.spaceId, botId: bot.id, threadId: thread.id };
const messages = await deps.prisma.message.findMany({
where: { threadId: thread.id },
select: { id: true, blocks: true },
orderBy: { createdAt: "asc" },
take: 100,
});
for (const message of messages) {
const blocks = message.blocks as MessageBlock[];
if (
!blocks.some(
(block) =>
block.kind === "app_connect" &&
featuredConnectorProvidersMatch(block.provider, provider) &&
block.status !== "connected",
)
)
continue;
const next = blocks.map((block) =>
block.kind === "app_connect" && featuredConnectorProvidersMatch(block.provider, provider)
? { ...block, status: "connected" as const }
: block,
);
await updateBlocks(deps, target, message.id, next);
}
}
function capitalize(value: string): string {
return value.length > 0 ? (value[0] ?? "").toUpperCase() + value.slice(1) : value;
}

View File

@ -0,0 +1,236 @@
import { describe, expect, it, vi } from "vitest";
import { persistMemoryProviderConfig, updateMemoryProviderDefaultScope } from "./router.js";
const actor = {
userId: "user-1",
spaceId: "ws-1",
email: "a@b.com",
isDeploymentOwner: false,
};
function makeDeps(
overrides: {
existing?: { id: string; secretId: string } | null;
upsertResult?: {
provider: string;
settings: Record<string, string>;
defaultMemoryScope: string;
updatedAt: Date;
};
updateResult?: {
provider: string;
settings: Record<string, string>;
defaultMemoryScope: string;
updatedAt: Date;
};
spaceOwner?: boolean;
memberRole?: string;
} = {},
) {
const secretCreate = vi.fn().mockResolvedValue({ id: "secret-new" });
const secretDeleteMany = vi.fn().mockResolvedValue({ count: 1 });
const findUnique = vi.fn().mockResolvedValue(overrides.existing ?? null);
const upsert = vi.fn().mockResolvedValue(
overrides.upsertResult ?? {
provider: "supermemory",
settings: { mode: "cloud", baseUrl: "https://api.supermemory.ai" },
defaultMemoryScope: "isolated",
updatedAt: new Date("2026-08-19T00:00:00.000Z"),
},
);
const update = vi.fn().mockResolvedValue(
overrides.updateResult ?? {
provider: "supermemory",
settings: { mode: "cloud", baseUrl: "https://api.supermemory.ai" },
defaultMemoryScope: "shared",
updatedAt: new Date("2026-08-20T00:00:00.000Z"),
},
);
const prisma = {
spaceMember: {
findUnique: vi
.fn()
.mockResolvedValue(
overrides.spaceOwner === false ? null : { role: overrides.memberRole ?? "owner" },
),
},
spaceMemoryConfig: { findUnique, update, upsert },
secret: { create: secretCreate, deleteMany: secretDeleteMany },
$transaction: vi.fn(),
};
prisma.$transaction.mockImplementation(async (callback: (tx: typeof prisma) => unknown) =>
callback(prisma),
);
const deps = {
prisma,
secrets: { put: vi.fn().mockResolvedValue({ id: "secret-new", ciphertext: "cipher" }) },
};
return {
deps,
secretCreate,
secretDeleteMany,
findUnique,
upsert,
update,
transaction: prisma.$transaction,
};
}
function connectionInput(mode: "cloud" | "local", baseUrl?: string) {
return {
provider: "supermemory",
settings: { mode, ...(baseUrl ? { baseUrl } : {}) },
credentials: { apiKey: "sm_test_key_12345" },
defaultMemoryScope: "isolated" as const,
};
}
describe("persistMemoryProviderConfig", () => {
it("rejects non-owners before probing or writing Space configuration", async () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const { deps, upsert } = makeDeps({ spaceOwner: false });
await expect(
persistMemoryProviderConfig(deps as never, actor, connectionInput("cloud")),
).rejects.toThrow();
expect(fetchMock).not.toHaveBeenCalled();
expect(upsert).not.toHaveBeenCalled();
vi.unstubAllGlobals();
});
it("rejects local mode without a baseUrl, without touching the database", async () => {
const { deps, upsert } = makeDeps();
await expect(
persistMemoryProviderConfig(deps as never, actor, connectionInput("local")),
).rejects.toThrow(/baseUrl/);
expect(upsert).not.toHaveBeenCalled();
});
it("rejects a non-loopback baseUrl in local mode without probing or touching the database", async () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const { deps, upsert } = makeDeps();
await expect(
persistMemoryProviderConfig(
deps as never,
actor,
connectionInput("local", "http://169.254.169.254/latest/meta-data/"),
),
).rejects.toThrow(/loopback/);
expect(fetchMock).not.toHaveBeenCalled();
expect(upsert).not.toHaveBeenCalled();
vi.unstubAllGlobals();
});
it("probes before persisting, and rejects (without writing) when the probe fails", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("", { status: 401 })));
const { deps, upsert } = makeDeps();
await expect(
persistMemoryProviderConfig(deps as never, actor, {
...connectionInput("local", "http://localhost:6767"),
credentials: { apiKey: "sm_bad_key" },
}),
).rejects.toThrow();
expect(upsert).not.toHaveBeenCalled();
vi.unstubAllGlobals();
});
it("accepts a bracketed IPv6 loopback base URL in local mode", async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response("[]", { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
const { deps, upsert } = makeDeps();
await persistMemoryProviderConfig(
deps as never,
actor,
connectionInput("local", "http://[::1]:6767"),
);
expect(fetchMock.mock.calls[0]![0]).toBe("http://[::1]:6767/v3/container-tags/list");
expect(upsert).toHaveBeenCalled();
vi.unstubAllGlobals();
});
it("connects cloud mode, defaulting the base URL, and returns the serialized config", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("[]", { status: 200 })));
const { deps, upsert, transaction } = makeDeps();
const result = await persistMemoryProviderConfig(
deps as never,
actor,
connectionInput("cloud"),
);
expect(upsert).toHaveBeenCalledWith(
expect.objectContaining({
where: { spaceId: "ws-1" },
create: expect.objectContaining({
provider: "supermemory",
settings: { mode: "cloud", baseUrl: "https://api.supermemory.ai" },
}),
}),
);
expect(result).toEqual({
provider: "supermemory",
settings: { mode: "cloud", baseUrl: "https://api.supermemory.ai" },
defaultMemoryScope: "isolated",
updatedAt: "2026-08-19T00:00:00.000Z",
});
expect(transaction).toHaveBeenCalledOnce();
vi.unstubAllGlobals();
});
it("deletes the old secret when replacing an existing config with a new key", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("[]", { status: 200 })));
const { deps, secretDeleteMany } = makeDeps({
existing: { id: "cfg-1", secretId: "secret-old" },
});
await persistMemoryProviderConfig(deps as never, actor, {
...connectionInput("cloud"),
credentials: { apiKey: "sm_new_key_12345" },
});
expect(secretDeleteMany).toHaveBeenCalledWith({ where: { id: "secret-old" } });
vi.unstubAllGlobals();
});
});
describe("updateMemoryProviderDefaultScope", () => {
it("accepts owners with additional Better Auth roles", async () => {
const { deps, update } = makeDeps({
existing: { id: "cfg-1", secretId: "secret-existing" },
memberRole: "owner,admin",
});
await updateMemoryProviderDefaultScope(deps as never, actor, "shared");
expect(update).toHaveBeenCalled();
});
it("updates only the generic scope setting and retains the provider secret", async () => {
const { deps, update, secretCreate, secretDeleteMany } = makeDeps({
existing: { id: "cfg-1", secretId: "secret-existing" },
});
const result = await updateMemoryProviderDefaultScope(deps as never, actor, "shared");
expect(update).toHaveBeenCalledWith({
where: { id: "cfg-1" },
data: { defaultMemoryScope: "shared" },
});
expect(secretCreate).not.toHaveBeenCalled();
expect(secretDeleteMany).not.toHaveBeenCalled();
expect(result.defaultMemoryScope).toBe("shared");
});
it("rejects non-owners without updating provider configuration", async () => {
const { deps, update } = makeDeps({
existing: { id: "cfg-1", secretId: "secret-existing" },
spaceOwner: false,
});
await expect(
updateMemoryProviderDefaultScope(deps as never, actor, "shared"),
).rejects.toThrow();
expect(update).not.toHaveBeenCalled();
});
});

View File

@ -0,0 +1,924 @@
import { RPCHandler } from "@orpc/server/fetch";
import type { Actor } from "@rakazo/contracts";
import type { PrismaClient } from "@rakazo/db";
import { describe, expect, it, vi } from "vitest";
import { createRouter, type RouterDeps } from "./router.js";
/** Prisma filters the handlers use when scoping memberships to the caller. */
type MembershipWhere = { id?: string; identityId?: { in: string[] } };
const identity = {
id: "mi-1",
provider: "sendblue",
address: "+15551111111",
userId: "user-1",
spaceId: "ws-1",
botId: "bot-1",
outboundSinceInbound: 0,
};
function messagingDeps(
overrides: {
enabled?: boolean;
identity?: unknown;
/** Every chat app the caller has linked; defaults to the single identity. */
identities?: Array<Record<string, unknown>>;
membership?: Record<string, unknown> | null;
memberships?: Array<Record<string, unknown>>;
connection?: Record<string, unknown> | null;
connections?: Array<Record<string, unknown>>;
} = {},
) {
const resolvedIdentity = overrides.identity === undefined ? identity : overrides.identity;
const membership =
overrides.membership === undefined
? {
id: "cm-1",
channelId: "ch-1",
address: "+15551111111",
identityId: "mi-1",
status: "invited",
channel: {
id: "ch-1",
provider: "sendblue",
name: "Family",
members: [{ id: "cm-1" }, { id: "cm-2" }],
},
}
: overrides.membership;
const connection =
overrides.connection === undefined
? {
id: "ac-1",
requesterBotId: "bot-9",
targetBotId: "bot-1",
status: "pending",
}
: overrides.connection;
// One user can hold several memberships in the same channel (one per linked
// chat app), so the mock resolves them by id the way the handlers do.
const membershipPool = (overrides.memberships ?? (membership ? [membership] : [])).map(
(row) => ({ ...row }) as Record<string, unknown>,
);
const ownedBy = (where: { identityId?: { in: string[] } } | undefined, row: unknown) =>
!where?.identityId ||
where.identityId.in.includes(String((row as { identityId?: string })?.identityId));
const visibleMemberships = (where: { identityId?: { in: string[] } } | undefined) =>
membershipPool.filter((row) => ownedBy(where, row));
const findMembership = (where: { id?: string; identityId?: { in: string[] } } | undefined) => {
const pool = visibleMemberships(where);
return where?.id ? (pool.find((row) => row.id === where.id) ?? null) : (pool[0] ?? null);
};
const defaultChannel = { id: "ch-1", provider: "sendblue", name: "Family", members: [] };
const connectionState = connection ? { ...connection } : null;
const prisma = {
messagingIdentity: {
findFirst: vi.fn(async () => resolvedIdentity),
findMany: vi.fn(
async () => overrides.identities ?? (resolvedIdentity ? [resolvedIdentity] : []),
),
update: vi.fn(async ({ data }: { data: Record<string, unknown> }) => ({
...(resolvedIdentity as Record<string, unknown>),
...data,
})),
deleteMany: vi.fn(async ({ where }: { where: { id: string } }) => ({
count: resolvedIdentity && where.id === "mi-1" ? 1 : 0,
})),
findUnique: vi.fn(async ({ where }: { where: { botId?: string } }) =>
where.botId === "bot-9"
? {
id: "mi-9",
provider: "sendblue",
address: "+15559999999",
userId: "user-9",
botId: "bot-9",
}
: resolvedIdentity,
),
},
messagingChannelMember: {
findMany: vi.fn(async ({ where }: { where?: MembershipWhere } = {}) =>
visibleMemberships(where),
),
findFirst: vi.fn(async ({ where }: { where?: MembershipWhere } = {}) =>
findMembership(where),
),
update: vi.fn(async ({ where, data }: { where?: MembershipWhere; data: unknown }) => {
const row = findMembership(where);
if (row) Object.assign(row, data as object);
return { ...row, channel: row?.channel ?? defaultChannel };
}),
updateMany: vi.fn(
async ({
where,
data,
}: {
where: MembershipWhere & { status?: string };
data: Record<string, unknown>;
}) => {
const row = findMembership(where);
if (!row) return { count: 0 };
if (where.status && row.status !== where.status) return { count: 0 };
Object.assign(row, data);
return { count: 1 };
},
),
findUniqueOrThrow: vi.fn(async ({ where }: { where?: MembershipWhere } = {}) =>
findMembership(where),
),
},
agentConnection: {
findMany: vi.fn(async () => overrides.connections ?? (connection ? [connection] : [])),
findFirst: vi.fn(
async ({
where,
}: {
where?: {
id?: string;
targetBotId?: string | { in: string[] };
status?: string;
OR?: Array<Record<string, string | { in: string[] }>>;
};
}) => {
// Handlers scope by every linked identity, so bot filters arrive
// as { in: [...] } lists.
const matches = (filter: string | { in: string[] } | undefined, value: string) =>
filter === undefined ||
(typeof filter === "string" ? filter === value : filter.in.includes(value));
if (!connection) return null;
if (where?.id && connection.id !== where.id) return null;
if (!matches(where?.targetBotId, connection.targetBotId)) return null;
if (where?.status && connection.status !== where.status) return null;
if (where?.OR) {
const involved = where.OR.some(
(cond) =>
matches(cond.requesterBotId, connection.requesterBotId) &&
matches(cond.targetBotId, connection.targetBotId) &&
(cond.requesterBotId !== undefined || cond.targetBotId !== undefined),
);
if (!involved) return null;
}
return connection;
},
),
update: vi.fn(async ({ data }: { data: unknown }) => ({
...connection,
...(data as object),
})),
updateMany: vi.fn(
async ({
where,
data,
}: {
where: { id?: string; status?: string };
data: Record<string, unknown>;
}) => {
if (!connectionState) return { count: 0 };
if (where.id && connectionState.id !== where.id) return { count: 0 };
if (where.status && connectionState.status !== where.status) return { count: 0 };
Object.assign(connectionState, data);
return { count: 1 };
},
),
findUniqueOrThrow: vi.fn(async () => connectionState),
},
bot: {
findUnique: vi.fn(async ({ where }: { where: { id: string } }) => ({
id: where.id,
name: where.id === "bot-9" ? "Helper" : "Assistant",
})),
findFirst: vi.fn(async ({ where }: { where: { id: string } }) =>
where.id.startsWith("bot-") ? { id: where.id } : null,
),
},
messagingLinkCode: {
deleteMany: vi.fn(async () => ({ count: 0 })),
create: vi.fn(async ({ data }: { data: Record<string, unknown> }) => data),
},
user: {
findUnique: vi.fn(async () => ({ id: "user-9", name: "Bob Owner" })),
},
} as unknown as PrismaClient;
const outboundRows: Array<Record<string, unknown>> = [];
const messagingOutbound = {
createMany: vi.fn(async ({ data }: { data: Array<Record<string, unknown>> }) => {
let count = 0;
for (const item of data) {
// Honors skipDuplicates against the idempotencyKey unique key.
if (outboundRows.some((row) => row.idempotencyKey === item.idempotencyKey)) continue;
outboundRows.push(item);
count += 1;
}
return { count };
}),
deleteMany: vi.fn(
async ({
where,
}: {
where: {
idempotencyKey?: string;
status?: string;
OR?: Array<{ status?: string; providerHandle?: null }>;
};
}) => {
let count = 0;
for (let i = outboundRows.length - 1; i >= 0; i -= 1) {
const row = outboundRows[i]!;
if (where.idempotencyKey && row.idempotencyKey !== where.idempotencyKey) continue;
if (where.status && row.status !== where.status) continue;
if (where.OR) {
const matches = where.OR.some((clause) => {
if (clause.status && row.status !== clause.status) return false;
if ("providerHandle" in clause && clause.providerHandle === null) {
return row.providerHandle == null;
}
return true;
});
if (!matches) continue;
}
outboundRows.splice(i, 1);
count += 1;
}
return { count };
},
),
};
(prisma as { messagingOutbound?: unknown }).messagingOutbound = messagingOutbound;
// The claim-and-confirm handlers run inside one interactive transaction;
// the mock passes the same stateful models as the tx delegate.
(prisma as { $transaction?: unknown }).$transaction = vi.fn(
async (fn: (tx: unknown) => Promise<unknown>) => fn(prisma),
);
const enqueue = vi.fn(async () => undefined);
const deps = {
prisma,
jobs: { enqueue },
env: {
defaultProvider: "fake",
defaultModel: "fake-model",
webOrigin: "http://127.0.0.1:5173",
screenProxySecret: "fake-test-secret",
sandboxProvider: "fake",
},
messaging: {
enabled: overrides.enabled ?? true,
providers: ["sendblue"],
openSignup: false,
},
dataDir: "/tmp/rakazo-router-test",
} as unknown as RouterDeps;
const actor = {
spaceId: "ws-1",
userId: "user-1",
email: "user@rakazo.test",
isDeploymentOwner: false,
} satisfies Actor;
return {
prisma,
deps,
actor,
outboundRows,
enqueue,
handler: new RPCHandler(createRouter(deps)),
};
}
async function call(handler: RPCHandler<never>, actor: Actor, path: string, body: unknown = {}) {
const { response } = await handler.handle(
new Request(`http://127.0.0.1/rpc/${path}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ json: body }),
}),
{ prefix: "/rpc", context: { actor } },
);
return response;
}
describe("messaging.status", () => {
it("reports enablement, providers, and the caller's linked identities", async () => {
const { handler, actor } = messagingDeps({ enabled: true });
const response = await call(handler, actor, "messaging/status");
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({
json: {
enabled: true,
providers: ["sendblue"],
openSignup: false,
identities: [
{
id: "mi-1",
provider: "sendblue",
address: "+15551111111",
botId: "bot-1",
botName: "Assistant",
},
],
},
});
});
it("reports no identities when the caller has not linked a chat app", async () => {
const { handler, actor } = messagingDeps({ identity: null });
const response = await call(handler, actor, "messaging/status");
await expect(response.json()).resolves.toEqual({
json: { enabled: true, providers: ["sendblue"], openSignup: false, identities: [] },
});
});
});
describe("messaging.link", () => {
it("issues a formatted single-use code for an owned unlinked bot", async () => {
const { handler, actor, prisma } = messagingDeps({ identity: null });
(prisma.messagingIdentity.findUnique as ReturnType<typeof vi.fn>).mockResolvedValue(null);
const response = await call(handler, actor, "messaging/link/start", { botId: "bot-2" });
expect(response.status).toBe(200);
const body = (await response.json()) as { json: { code: string; expiresAt: string } };
expect(body.json.code).toMatch(/^[A-Z0-9]{4}-[A-Z0-9]{4}$/);
expect(Date.parse(body.json.expiresAt)).toBeGreaterThan(Date.now());
expect(prisma.messagingLinkCode.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ userId: "user-1", botId: "bot-2" }),
}),
);
});
it("refuses to issue a code for an already-linked bot", async () => {
const { handler, actor } = messagingDeps();
// messagingIdentity.findUnique resolves an identity for bot-1 by default.
const response = await call(handler, actor, "messaging/link/start", { botId: "bot-1" });
expect(response.status).toBe(400);
});
});
describe("messaging.identities", () => {
it("re-points an identity at another owned bot", async () => {
const { handler, actor } = messagingDeps();
const response = await call(handler, actor, "messaging/identities/setBot", {
identityId: "mi-1",
botId: "bot-2",
});
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({
json: {
id: "mi-1",
provider: "sendblue",
address: "+15551111111",
botId: "bot-2",
botName: "Assistant",
},
});
});
it("unlinks only the caller's own identity", async () => {
const { handler, actor, prisma } = messagingDeps();
const response = await call(handler, actor, "messaging/identities/unlink", {
identityId: "mi-1",
});
expect(response.status).toBe(200);
expect(prisma.messagingIdentity.deleteMany).toHaveBeenCalledWith({
where: { id: "mi-1", userId: "user-1" },
});
const missing = await call(handler, actor, "messaging/identities/unlink", {
identityId: "mi-other",
});
expect(missing.status).toBe(404);
});
});
describe("messaging.channels", () => {
it("lists the caller's memberships with channel names, counting only active members", async () => {
const { handler, actor, prisma } = messagingDeps();
const response = await call(handler, actor, "messaging/channels/list");
expect(prisma.messagingChannelMember.findMany).toHaveBeenCalledWith(
expect.objectContaining({
include: expect.objectContaining({
channel: expect.objectContaining({
include: expect.objectContaining({
members: expect.objectContaining({
where: { status: { in: ["invited", "approved"] } },
}),
}),
}),
}),
}),
);
await expect(response.json()).resolves.toEqual({
json: [
{
id: "cm-1",
channelId: "ch-1",
identityId: "mi-1",
provider: "sendblue",
name: "Family",
status: "invited",
memberCount: 2,
},
],
});
});
it("approves an invited membership", async () => {
const { handler, actor, prisma } = messagingDeps();
const approved = await call(handler, actor, "messaging/channels/respond", {
membershipId: "cm-1",
accept: true,
});
expect(prisma.messagingChannelMember.updateMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: "cm-1", status: "invited" },
data: { status: "approved" },
}),
);
await expect(approved.json()).resolves.toEqual({
json: expect.objectContaining({ status: "approved" }),
});
});
it("declines an invited membership on accept=false", async () => {
const { handler, actor, prisma } = messagingDeps();
const declined = await call(handler, actor, "messaging/channels/respond", {
membershipId: "cm-1",
accept: false,
});
expect(prisma.messagingChannelMember.updateMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: "cm-1", status: "invited" },
data: { status: "declined" },
}),
);
await expect(declined.json()).resolves.toEqual({
json: expect.objectContaining({ status: "declined" }),
});
});
it("rejects respond on another user's membership or a non-invited one", async () => {
const foreign = messagingDeps({ membership: null });
const response = await call(foreign.handler, foreign.actor, "messaging/channels/respond", {
membershipId: "cm-1",
accept: true,
});
expect(response.status).toBeGreaterThanOrEqual(400);
const already = messagingDeps({
membership: {
id: "cm-1",
channelId: "ch-1",
identityId: "mi-1",
status: "approved",
channel: { id: "ch-1", provider: "sendblue", name: "Family", members: [] },
},
});
const second = await call(already.handler, already.actor, "messaging/channels/respond", {
membershipId: "cm-1",
accept: true,
});
expect(second.status).toBeGreaterThanOrEqual(400);
});
it("leaves an approved channel", async () => {
const { handler, actor, prisma } = messagingDeps({
membership: {
id: "cm-1",
channelId: "ch-1",
identityId: "mi-1",
status: "approved",
channel: { id: "ch-1", provider: "sendblue", name: "Family", members: [] },
},
});
const response = await call(handler, actor, "messaging/channels/leave", {
membershipId: "cm-1",
});
expect(prisma.messagingChannelMember.update).toHaveBeenCalledWith(
expect.objectContaining({ data: { status: "left" } }),
);
await expect(response.json()).resolves.toEqual({ json: { ok: true } });
});
it("answers the named membership when one user has two chat apps in a group", async () => {
const channel = {
id: "ch-1",
provider: "sendblue",
name: "Family",
members: [{ id: "cm-1" }, { id: "cm-2" }],
};
const { handler, actor, prisma } = messagingDeps({
identities: [identity, { ...identity, id: "mi-2", address: "+15552222222", botId: "bot-2" }],
memberships: [
// Same user, same group, one membership per linked chat app. Answering
// by channel would pick an arbitrary one and leave the other live.
{ id: "cm-work", channelId: "ch-1", identityId: "mi-1", status: "approved", channel },
{ id: "cm-home", channelId: "ch-1", identityId: "mi-2", status: "invited", channel },
],
});
const approved = await call(handler, actor, "messaging/channels/respond", {
membershipId: "cm-home",
accept: true,
});
expect(prisma.messagingChannelMember.updateMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { id: "cm-home", status: "invited" } }),
);
await expect(approved.json()).resolves.toEqual({
json: expect.objectContaining({ id: "cm-home", identityId: "mi-2", status: "approved" }),
});
const listed = await call(handler, actor, "messaging/channels/list");
const rows = (await listed.json()).json as Array<{ id: string; channelId: string }>;
expect(rows.map((row) => row.id)).toEqual(["cm-work", "cm-home"]);
expect(new Set(rows.map((row) => row.channelId))).toEqual(new Set(["ch-1"]));
});
});
describe("messaging.connections", () => {
it("lists connections with the peer label and direction", async () => {
const { handler, actor } = messagingDeps();
const response = await call(handler, actor, "messaging/connections/list");
await expect(response.json()).resolves.toEqual({
json: [
{
id: "ac-1",
peerBotName: "Helper",
peerOwnerLabel: "Bob",
status: "pending",
incoming: true,
},
],
});
});
it("hides peer identity for outgoing connections that are not approved", async () => {
const { handler, actor } = messagingDeps({
connection: { id: "ac-4", requesterBotId: "bot-1", targetBotId: "bot-9", status: "pending" },
});
const response = await call(handler, actor, "messaging/connections/list");
await expect(response.json()).resolves.toEqual({
json: [
expect.objectContaining({ peerBotName: "agent", peerOwnerLabel: "owner", incoming: false }),
],
});
});
it("approves a pending incoming connection and notifies the requester", async () => {
const { handler, actor, prisma, outboundRows, enqueue } = messagingDeps();
const response = await call(handler, actor, "messaging/connections/respond", {
connectionId: "ac-1",
accept: true,
});
expect(prisma.agentConnection.updateMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: "ac-1", status: "pending" },
data: { status: "approved" },
}),
);
await expect(response.json()).resolves.toEqual({
json: expect.objectContaining({ status: "approved" }),
});
expect(outboundRows).toEqual([
expect.objectContaining({
idempotencyKey: "command:connected:ac-1",
kind: "dm",
identityId: "mi-9",
}),
]);
expect(enqueue).toHaveBeenCalled();
});
it("rejects respond from the requester side", async () => {
const outgoing = messagingDeps({
connection: { id: "ac-2", requesterBotId: "bot-1", targetBotId: "bot-9", status: "pending" },
});
const response = await call(outgoing.handler, outgoing.actor, "messaging/connections/respond", {
connectionId: "ac-2",
accept: true,
});
expect(response.status).toBeGreaterThanOrEqual(400);
});
it("revokes an approved connection from either side", async () => {
const { handler, actor, prisma } = messagingDeps({
connection: { id: "ac-3", requesterBotId: "bot-1", targetBotId: "bot-9", status: "approved" },
});
const response = await call(handler, actor, "messaging/connections/revoke", {
connectionId: "ac-3",
});
expect(prisma.agentConnection.updateMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: "ac-3", status: "approved" },
data: { status: "revoked" },
}),
);
await expect(response.json()).resolves.toEqual({ json: { ok: true } });
});
it("cancels a pending connect invite when revoking a re-requested connection", async () => {
const { handler, actor, prisma, outboundRows } = messagingDeps({
connection: { id: "ac-4", requesterBotId: "bot-1", targetBotId: "bot-9", status: "pending" },
});
outboundRows.push({
idempotencyKey: "connect:bot-1:bot-9",
kind: "dm",
identityId: "mi-9",
body: "wants to connect",
status: "pending",
});
outboundRows.push({
idempotencyKey: "connect:bot-1:bot-9-already-sent",
kind: "dm",
status: "sent",
});
const response = await call(handler, actor, "messaging/connections/revoke", {
connectionId: "ac-4",
});
expect(prisma.messagingOutbound.deleteMany).toHaveBeenCalledWith({
where: {
idempotencyKey: "connect:bot-1:bot-9",
OR: [{ status: "pending" }, { status: "sent", providerHandle: null }],
},
});
expect(outboundRows).toEqual([
expect.objectContaining({
idempotencyKey: "connect:bot-1:bot-9-already-sent",
status: "sent",
}),
]);
await expect(response.json()).resolves.toEqual({ json: { ok: true } });
});
it("cancels a claimed-but-undelivered connect invite on revoke", async () => {
const { handler, actor, outboundRows } = messagingDeps({
connection: {
id: "ac-claimed",
requesterBotId: "bot-1",
targetBotId: "bot-9",
status: "pending",
},
});
outboundRows.push({
idempotencyKey: "connect:bot-1:bot-9",
kind: "dm",
status: "sent",
providerHandle: null,
body: "claimed by drain",
});
outboundRows.push({
idempotencyKey: "connect:bot-1:bot-9-delivered",
kind: "dm",
status: "sent",
providerHandle: "h-delivered",
});
const response = await call(handler, actor, "messaging/connections/revoke", {
connectionId: "ac-claimed",
});
expect(outboundRows).toEqual([
expect.objectContaining({
idempotencyKey: "connect:bot-1:bot-9-delivered",
providerHandle: "h-delivered",
}),
]);
await expect(response.json()).resolves.toEqual({ json: { ok: true } });
});
it("revokes status and cancels invites in one transaction", async () => {
const { handler, actor, prisma, outboundRows } = messagingDeps({
connection: { id: "ac-5", requesterBotId: "bot-1", targetBotId: "bot-9", status: "pending" },
});
outboundRows.push({
idempotencyKey: "connect:bot-1:bot-9",
kind: "dm",
status: "pending",
});
const order: string[] = [];
const connectionModel = prisma.agentConnection as unknown as {
updateMany: (args: unknown) => Promise<{ count: number }>;
};
const baseUpdateMany = connectionModel.updateMany.bind(connectionModel);
const baseDeleteMany = prisma.messagingOutbound.deleteMany;
(prisma as unknown as Record<string, unknown>).$transaction = vi.fn(
async (fn: (tx: unknown) => Promise<unknown>) => {
order.push("tx-start");
const result = await fn({
agentConnection: {
updateMany: async (args: unknown) => {
order.push("update");
return baseUpdateMany(args);
},
},
messagingOutbound: {
deleteMany: async (args: unknown) => {
order.push("delete");
return baseDeleteMany(args as never);
},
},
});
order.push("tx-end");
return result;
},
);
const response = await call(handler, actor, "messaging/connections/revoke", {
connectionId: "ac-5",
});
expect(order).toEqual(["tx-start", "update", "delete", "tx-end"]);
expect(outboundRows).toEqual([]);
await expect(response.json()).resolves.toEqual({ json: { ok: true } });
});
it("does not delete a reconnect invite created after revoke commits", async () => {
const { handler, actor, prisma, outboundRows } = messagingDeps({
connection: { id: "ac-6", requesterBotId: "bot-1", targetBotId: "bot-9", status: "pending" },
});
outboundRows.push({
idempotencyKey: "connect:bot-1:bot-9",
kind: "dm",
status: "pending",
body: "old invite",
});
const connectionModel = prisma.agentConnection as unknown as {
updateMany: (args: unknown) => Promise<{ count: number }>;
};
const baseUpdateMany = connectionModel.updateMany.bind(connectionModel);
const baseDeleteMany = prisma.messagingOutbound.deleteMany;
(prisma as unknown as Record<string, unknown>).$transaction = vi.fn(
async (fn: (tx: unknown) => Promise<unknown>) =>
fn({
agentConnection: {
updateMany: async (args: unknown) => baseUpdateMany(args),
},
messagingOutbound: {
deleteMany: async (args: unknown) => baseDeleteMany(args as never),
},
}),
);
await call(handler, actor, "messaging/connections/revoke", { connectionId: "ac-6" });
expect(outboundRows).toEqual([]);
// Reconnect after revoke has committed: its fresh invite must survive.
outboundRows.push({
idempotencyKey: "connect:bot-1:bot-9",
kind: "dm",
status: "pending",
body: "fresh reconnect invite",
});
expect(prisma.messagingOutbound.deleteMany).toHaveBeenCalledTimes(1);
expect(outboundRows).toEqual([
expect.objectContaining({
idempotencyKey: "connect:bot-1:bot-9",
body: "fresh reconnect invite",
}),
]);
});
it("does not let a stale revoke overwrite a newer pending re-request", async () => {
const { handler, actor, prisma } = messagingDeps({
connection: {
id: "ac-stale",
requesterBotId: "bot-1",
targetBotId: "bot-9",
status: "approved",
},
});
const state = { status: "approved" };
const connectionModel = prisma.agentConnection as unknown as Record<string, unknown>;
connectionModel.findFirst = vi.fn(async () => {
const snapshot = {
id: "ac-stale",
requesterBotId: "bot-1",
targetBotId: "bot-9",
status: state.status,
};
// Interleaved: a new request flips the row back to pending after the read.
state.status = "pending";
return snapshot;
});
connectionModel.updateMany = vi.fn(
async ({ where, data }: { where: { status?: string }; data: Record<string, unknown> }) => {
if (where.status && state.status !== where.status) return { count: 0 };
Object.assign(state, data);
return { count: 1 };
},
);
const response = await call(handler, actor, "messaging/connections/revoke", {
connectionId: "ac-stale",
});
expect(state.status).toBe("pending");
expect(response.status).toBeGreaterThanOrEqual(400);
});
});
describe("messaging status-write races", () => {
it("does not overwrite a concurrent revoke when responding to a connection", async () => {
const { handler, actor, prisma, outboundRows } = messagingDeps();
const state = { status: "pending" };
const connectionModel = prisma.agentConnection as unknown as Record<string, unknown>;
connectionModel.findFirst = vi.fn(async () => {
const snapshot = {
id: "ac-1",
requesterBotId: "bot-9",
targetBotId: "bot-1",
status: state.status,
};
// Interleaved: the requester revokes between the read and the write.
state.status = "revoked";
return snapshot;
});
connectionModel.update = vi.fn(async ({ data }: { data: Record<string, unknown> }) => {
Object.assign(state, data);
return state;
});
connectionModel.updateMany = vi.fn(
async ({ where, data }: { where: { status?: string }; data: Record<string, unknown> }) => {
if (where.status && state.status !== where.status) return { count: 0 };
Object.assign(state, data);
return { count: 1 };
},
);
const response = await call(handler, actor, "messaging/connections/respond", {
connectionId: "ac-1",
accept: true,
});
expect(state.status).toBe("revoked");
expect(response.status).toBeGreaterThanOrEqual(400);
expect(outboundRows).toHaveLength(0);
});
it("does not overwrite a concurrent leave when responding to a channel invite", async () => {
const { handler, actor, prisma } = messagingDeps();
const state = { status: "invited" };
const memberModel = prisma.messagingChannelMember as unknown as Record<string, unknown>;
memberModel.findFirst = vi.fn(async () => {
const snapshot = {
id: "cm-1",
channelId: "ch-1",
address: "+15551111111",
identityId: "mi-1",
status: state.status,
channel: { id: "ch-1", provider: "sendblue", name: "Family", members: [{ id: "cm-1" }] },
};
// Interleaved: the owner left (or was swept out) after the read.
state.status = "left";
return snapshot;
});
memberModel.update = vi.fn(async ({ data }: { data: Record<string, unknown> }) => {
Object.assign(state, data);
return state;
});
memberModel.updateMany = vi.fn(
async ({ where, data }: { where: { status?: string }; data: Record<string, unknown> }) => {
if (where.status && state.status !== where.status) return { count: 0 };
Object.assign(state, data);
return { count: 1 };
},
);
const response = await call(handler, actor, "messaging/channels/respond", {
membershipId: "cm-1",
accept: true,
});
expect(state.status).toBe("left");
expect(response.status).toBeGreaterThanOrEqual(400);
});
});
describe("messaging.connections.respond confirmation atomicity", () => {
it("writes the requester confirmation under the claim's transaction", async () => {
const { handler, actor, prisma, outboundRows } = messagingDeps();
// Track the transaction-scoped outbox delegate separately: a revoke can
// only be excluded from the confirmation window if the claim's row lock
// is still held when the confirmation row is written.
const txCreateMany = vi.fn(async ({ data }: { data: Array<Record<string, unknown>> }) => {
outboundRows.push(...data);
return { count: data.length };
});
(prisma as unknown as Record<string, unknown>).$transaction = vi.fn(
async (fn: (tx: unknown) => Promise<unknown>) =>
fn({
agentConnection: prisma.agentConnection,
messagingIdentity: prisma.messagingIdentity,
messagingOutbound: {
createMany: txCreateMany,
deleteMany: vi.fn(async () => ({ count: 0 })),
},
}),
);
const response = await call(handler, actor, "messaging/connections/respond", {
connectionId: "ac-1",
accept: true,
});
expect(response.status).toBe(200);
expect(txCreateMany).toHaveBeenCalledWith(
expect.objectContaining({
data: [expect.objectContaining({ idempotencyKey: "command:connected:ac-1" })],
}),
);
});
});

479
apps/api/src/router.test.ts Normal file
View File

@ -0,0 +1,479 @@
import { RPCHandler } from "@orpc/server/fetch";
import type { Actor } from "@rakazo/contracts";
import type { PrismaClient } from "@rakazo/db";
import { describe, expect, it, vi } from "vitest";
import { createRouter, type RouterDeps } from "./router.js";
describe("account preferences", () => {
function preferencesDeps(avatarStyle: string) {
const update = vi.fn().mockResolvedValue({});
const prisma = {
user: {
update,
findUniqueOrThrow: vi.fn().mockResolvedValue({
email: "user@rakazo.test",
name: "Test User",
avatarStyle,
}),
},
spaceModelPreference: { findFirst: vi.fn().mockResolvedValue(null) },
deploymentSettings: { findUnique: vi.fn().mockResolvedValue(null) },
} as unknown as PrismaClient;
const deps = {
prisma,
env: {
defaultProvider: "fake",
defaultModel: "fake-model",
webOrigin: "http://127.0.0.1:5173",
screenProxySecret: "fake-test-secret",
sandboxProvider: "fake",
},
dataDir: "/tmp/rakazo-router-test",
} as unknown as RouterDeps;
const actor = {
spaceId: "workspace-1",
userId: "user-1",
email: "user@rakazo.test",
isDeploymentOwner: true,
} satisfies Actor;
return { update, deps, actor, handler: new RPCHandler(createRouter(deps)) };
}
it("persists and returns the selected avatar style", async () => {
const { update, actor, handler } = preferencesDeps("organic");
const { response } = await handler.handle(
new Request("http://127.0.0.1/rpc/preferences/update", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ json: { avatarStyle: "organic" } }),
}),
{ prefix: "/rpc", context: { actor } },
);
expect(response.status).toBe(200);
expect(update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: { avatarStyle: "organic" },
});
await expect(response.json()).resolves.toEqual({
json: expect.objectContaining({ avatarStyle: "organic" }),
});
});
it("rejects avatar styles outside robot|organic", async () => {
const { update, actor, handler } = preferencesDeps("robot");
const { response } = await handler.handle(
new Request("http://127.0.0.1/rpc/preferences/update", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ json: { avatarStyle: "dicebear" } }),
}),
{ prefix: "/rpc", context: { actor } },
);
expect(response.status).toBeGreaterThanOrEqual(400);
expect(update).not.toHaveBeenCalled();
});
it("coerces unknown stored avatar styles to organic on me", async () => {
const { actor, handler } = preferencesDeps("custom-cdn");
const { response } = await handler.handle(
new Request("http://127.0.0.1/rpc/me", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ json: null }),
}),
{ prefix: "/rpc", context: { actor } },
);
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({
json: expect.objectContaining({ avatarStyle: "organic" }),
});
});
});
describe("thread answer delivery", () => {
it("accepts a durable answer when the immediate worker wake fails", async () => {
const answerRunInput = vi.fn().mockResolvedValue(true);
const enqueue = vi.fn().mockRejectedValue(new Error("job broker unavailable"));
const logError = vi.spyOn(console, "error").mockImplementation(() => undefined);
const prisma = {
bot: {
findFirst: vi.fn().mockResolvedValue({
id: "bot-1",
thread: { id: "thread-1" },
computer: null,
}),
},
} as unknown as PrismaClient;
const deps = {
prisma,
events: { answerRunInput },
jobs: { enqueue },
env: {
defaultProvider: "fake",
defaultModel: "fake-model",
webOrigin: "http://127.0.0.1:5173",
screenProxySecret: "fake-test-secret",
sandboxProvider: "fake",
},
dataDir: "/tmp/rakazo-router-test",
} as unknown as RouterDeps;
const actor = {
spaceId: "workspace-1",
userId: "user-1",
email: "user@rakazo.test",
isDeploymentOwner: true,
} satisfies Actor;
const handler = new RPCHandler(createRouter(deps));
const { matched, response } = await handler.handle(
new Request("http://127.0.0.1/rpc/threads/answer", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
json: {
botId: "bot-1",
runId: "run-1",
messageId: "message-1",
answer: "Paris",
},
}),
}),
{ prefix: "/rpc", context: { actor } },
);
expect(matched).toBe(true);
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({ json: { ok: true } });
expect(answerRunInput).toHaveBeenCalledWith(
expect.objectContaining({
spaceId: "workspace-1",
threadId: "thread-1",
runId: "run-1",
}),
);
expect(enqueue).toHaveBeenCalledOnce();
expect(logError).toHaveBeenCalledWith("thread answer enqueue", expect.any(Error));
logError.mockRestore();
});
});
describe("MCP server deletion", () => {
it("does not fail when a concurrent credential rotation already removed the old secret", async () => {
const deleteServer = vi.fn().mockResolvedValue({ id: "server-1" });
const deleteSecrets = vi.fn().mockResolvedValue({ count: 0 });
const prisma = {
mcpServer: {
findFirst: vi.fn().mockResolvedValue({ id: "server-1", secretId: "old-secret" }),
delete: deleteServer,
},
secret: { deleteMany: deleteSecrets },
$transaction: vi.fn((operations: Promise<unknown>[]) => Promise.all(operations)),
} as unknown as PrismaClient;
const deps = {
prisma,
env: {
defaultProvider: "fake",
defaultModel: "fake-model",
webOrigin: "http://127.0.0.1:5173",
screenProxySecret: "fake-test-secret",
sandboxProvider: "fake",
},
dataDir: "/tmp/rakazo-router-test",
} as unknown as RouterDeps;
const actor = {
spaceId: "workspace-1",
userId: "user-1",
email: "user@rakazo.test",
isDeploymentOwner: true,
} satisfies Actor;
const handler = new RPCHandler(createRouter(deps));
const { matched, response } = await handler.handle(
new Request("http://127.0.0.1/rpc/mcp/servers/remove", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ json: { id: "server-1" } }),
}),
{ prefix: "/rpc", context: { actor } },
);
expect(matched).toBe(true);
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({ json: { ok: true } });
expect(deleteServer).toHaveBeenCalledWith({ where: { id: "server-1" } });
expect(deleteSecrets).toHaveBeenCalledWith({
where: {
id: "old-secret",
spaceId: "workspace-1",
userId: "user-1",
},
});
});
});
describe("connections.complete", () => {
it("forwards an optional code to the managed connector", async () => {
const complete = vi.fn().mockResolvedValue({ connectionRef: "gmail" });
const connectionReady = vi.fn().mockResolvedValue(true);
const update = vi.fn().mockResolvedValue({
id: "conn-1",
connectorId: "composio",
provider: "gmail",
displayName: "Gmail",
status: "connected",
createdAt: new Date("2026-08-26T00:00:00.000Z"),
});
const prisma = {
connection: {
findFirst: vi.fn().mockResolvedValue({
id: "conn-1",
connectorId: "composio",
provider: "gmail",
displayName: "Gmail",
providerRef: "gmail-state",
status: "pending",
createdAt: new Date("2026-08-26T00:00:00.000Z"),
}),
update,
},
} as unknown as PrismaClient;
const deps = {
prisma,
connectors: {
managed: vi.fn(() => ({ complete, connectionReady })),
},
env: {
defaultProvider: "fake",
defaultModel: "fake-model",
webOrigin: "http://127.0.0.1:5173",
screenProxySecret: "fake-test-secret",
sandboxProvider: "fake",
},
dataDir: "/tmp/rakazo-router-test",
} as unknown as RouterDeps;
const actor = {
spaceId: "workspace-1",
userId: "user-1",
email: "user@rakazo.test",
isDeploymentOwner: true,
} satisfies Actor;
const handler = new RPCHandler(createRouter(deps));
const { matched, response } = await handler.handle(
new Request("http://127.0.0.1/rpc/connections/complete", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
json: {
connectionId: "conn-1",
code: "123456",
},
}),
}),
{ prefix: "/rpc", context: { actor } },
);
expect(matched).toBe(true);
expect(response.status).toBe(200);
expect(complete).toHaveBeenCalledWith(
{ state: "gmail-state", code: "123456" },
expect.objectContaining({ spaceId: "workspace-1", userId: "user-1" }),
);
expect(connectionReady).toHaveBeenCalled();
});
});
describe("updater owner gate", () => {
function updaterDeps() {
const prisma = {
user: {
findUniqueOrThrow: vi.fn().mockResolvedValue({
email: "user@rakazo.test",
name: "Test User",
avatarStyle: "robot",
}),
},
spaceModelPreference: { findFirst: vi.fn().mockResolvedValue(null) },
deploymentSettings: { findUnique: vi.fn().mockResolvedValue(null) },
} as unknown as PrismaClient;
const deps = {
prisma,
env: {
defaultProvider: "fake",
defaultModel: "fake-model",
webOrigin: "http://127.0.0.1:5173",
screenProxySecret: "fake-test-secret",
sandboxProvider: "fake",
gitSha: "deadbeef",
updaterUrl: undefined,
updaterToken: undefined,
},
dataDir: "/tmp/rakazo-router-test",
} as unknown as RouterDeps;
return { deps, handler: new RPCHandler(createRouter(deps)) };
}
it("forbids non-owners from updater status", async () => {
const { handler } = updaterDeps();
const actor = {
spaceId: "workspace-1",
userId: "user-2",
email: "member@rakazo.test",
isDeploymentOwner: false,
} satisfies Actor;
const { response } = await handler.handle(
new Request("http://127.0.0.1/rpc/updater/status", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ json: null }),
}),
{ prefix: "/rpc", context: { actor } },
);
expect(response.status).toBe(403);
});
it("lets the deployment owner read status without applying git", async () => {
const { handler } = updaterDeps();
const actor = {
spaceId: "workspace-1",
userId: "user-1",
email: "owner@rakazo.test",
isDeploymentOwner: true,
} satisfies Actor;
const { response } = await handler.handle(
new Request("http://127.0.0.1/rpc/updater/status", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ json: null }),
}),
{ prefix: "/rpc", context: { actor } },
);
expect(response.status).toBe(200);
const body = await response.json();
expect(body.json.supported).toBe(false);
expect(["source", "compose"]).toContain(body.json.installKind);
expect(Array.isArray(body.json.manualCommands)).toBe(true);
});
it("refuses apply when the sidecar is not configured", async () => {
const { handler } = updaterDeps();
const actor = {
spaceId: "workspace-1",
userId: "user-1",
email: "owner@rakazo.test",
isDeploymentOwner: true,
} satisfies Actor;
const { response } = await handler.handle(
new Request("http://127.0.0.1/rpc/updater/apply", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ json: {} }),
}),
{ prefix: "/rpc", context: { actor } },
);
expect(response.status).toBeGreaterThanOrEqual(400);
const body = await response.json();
const message = JSON.stringify(body);
expect(message).toMatch(/sidecar/i);
expect(message).not.toMatch(/git (fetch|merge|pull)/i);
});
});
describe("computer screen url", () => {
const actor = {
spaceId: "workspace-1",
userId: "user-1",
email: "user@rakazo.test",
isDeploymentOwner: true,
} satisfies Actor;
const computerRow = {
id: "computer-1",
kind: "e2b",
scope: "team",
state: "running",
providerRef: "sandbox-ref-1",
homeKey: "home-1",
controlHolder: "none",
controlLeaseId: null,
controlLeaseExpiresAt: null,
controlBotId: null,
controlRunId: null,
};
const callScreenUrl = async (connectScreen: () => Promise<unknown>, updateMany = vi.fn()) => {
const prisma = {
bot: {
findFirst: vi.fn().mockResolvedValue({
id: "bot-1",
thread: { id: "thread-1" },
computer: computerRow,
}),
},
computer: { updateMany },
computerExecutionLease: { findUnique: vi.fn().mockResolvedValue(null) },
} as unknown as PrismaClient;
const deps = {
prisma,
sandbox: { connectScreen },
jobs: { enqueue: vi.fn().mockResolvedValue(undefined) },
env: {
defaultProvider: "fake",
defaultModel: "fake-model",
webOrigin: "http://127.0.0.1:5173",
screenProxySecret: "fake-test-secret",
sandboxProvider: "e2b",
},
dataDir: "/tmp/rakazo-router-test",
} as unknown as RouterDeps;
const handler = new RPCHandler(createRouter(deps));
const { response } = await handler.handle(
new Request("http://127.0.0.1/rpc/computer/screenUrl", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ json: { botId: "bot-1" } }),
}),
{ prefix: "/rpc", context: { actor } },
);
return { response, updateMany };
};
it("clears the row instead of 500ing when the provider says the sandbox is gone", async () => {
const logError = vi.spyOn(console, "error").mockImplementation(() => undefined);
const { response, updateMany } = await callScreenUrl(() =>
Promise.reject(
Object.assign(new Error("Sandbox is probably not running anymore"), {
name: "SandboxNotFoundError",
}),
),
);
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({ json: { url: null } });
expect(updateMany).toHaveBeenCalledWith({
where: { id: "computer-1", providerRef: "sandbox-ref-1" },
data: { state: "stopped", providerRef: null },
});
logError.mockRestore();
});
it("keeps a transport blip an error and leaves the row alone", async () => {
const logError = vi.spyOn(console, "error").mockImplementation(() => undefined);
const { response, updateMany } = await callScreenUrl(() =>
Promise.reject(Object.assign(new Error("fetch failed"), { code: "ECONNRESET" })),
);
expect(response.status).toBe(500);
expect(updateMany).not.toHaveBeenCalled();
logError.mockRestore();
});
});

4199
apps/api/src/router.ts Normal file

File diff suppressed because it is too large Load Diff

39
apps/api/src/runs.test.ts Normal file
View File

@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import { activityNotificationsEnabled, activityPromptSnippet } from "./runs.js";
describe("run activity copy", () => {
it("presents structured agent messages instead of their internal wake prompt", () => {
expect(
activityPromptSnippet({
trigger: "bot_message",
prompt: "[bot] A message just arrived from another bot with internal routing data",
sourceBlocks: [
{
kind: "bot_message_received",
fromBotId: "maya",
fromBotName: "Maya",
text: "Please check the release workflow.",
intent: "request",
},
],
}),
).toBe("Maya asked: Please check the release workflow.");
});
it("fails closed when an agent message has no valid structured source", () => {
expect(
activityPromptSnippet({
trigger: "bot_message",
prompt: "[bot] private internal routing envelope",
sourceBlocks: [{ kind: "text", text: "not a peer message" }],
}),
).toBe("Message from another agent");
});
});
describe("run activity notification preference", () => {
it("silences only direct messages", () => {
expect(activityNotificationsEnabled(null, false)).toBe(false);
expect(activityNotificationsEnabled("group-1", false)).toBe(true);
});
});

89
apps/api/src/runs.ts Normal file
View File

@ -0,0 +1,89 @@
import { type Actor, MessageBlock, type RunActivityRow } from "@rakazo/contracts";
import { ACTIVE_RUN_STATUSES, botMessageContext } from "@rakazo/core";
import type { PrismaClient } from "@rakazo/db";
const RECENT_LIMIT = 20;
const TERMINAL_STATUSES = ["completed", "failed", "cancelled"] as const;
function promptSnippet(prompt: string, max = 120): string {
const oneLine = prompt.replace(/\s+/g, " ").trim();
if (oneLine.length <= max) return oneLine;
return `${oneLine.slice(0, max - 1)}`;
}
export function activityPromptSnippet(
input: { trigger: string; prompt: string; sourceBlocks?: unknown },
max = 120,
): string {
if (input.trigger !== "bot_message") return promptSnippet(input.prompt, max);
const parsed = MessageBlock.array().safeParse(input.sourceBlocks);
const message = parsed.success ? botMessageContext(parsed.data) : undefined;
if (!message) return "Message from another agent";
const name = message.fromBotName.trim() || "Another agent";
const label =
message.intent === "result" || message.intent === "status" || message.intent === "fyi"
? `Update from ${name}`
: `${name} asked`;
return promptSnippet(message.text.trim() ? `${label}: ${message.text}` : label, max);
}
export function activityNotificationsEnabled(
groupId: string | null,
notifyOnFinish: boolean,
): boolean {
return groupId !== null || notifyOnFinish;
}
export async function listSpaceRuns(
prisma: PrismaClient,
actor: Actor,
filter: "active" | "recent",
): Promise<RunActivityRow[]> {
const rows = await prisma.run.findMany({
where: {
spaceId: actor.spaceId,
userId: actor.userId,
bot: { archivedAt: null },
...(filter === "active"
? { status: { in: [...ACTIVE_RUN_STATUSES] } }
: { status: { in: [...TERMINAL_STATUSES] } }),
},
include: {
bot: { select: { name: true, archivedAt: true, notifyOnFinish: true } },
task: { select: { prompt: true } },
sourceMessage: { select: { blocks: true } },
thread: {
select: {
groupId: true,
group: { select: { name: true } },
},
},
},
orderBy:
filter === "active"
? [{ updatedAt: "desc" }, { id: "desc" }]
: [{ completedAt: "desc" }, { updatedAt: "desc" }, { id: "desc" }],
take: filter === "recent" ? RECENT_LIMIT : undefined,
});
return rows.map((row) => ({
runId: row.id,
botId: row.botId,
botName: row.bot.name,
groupId: row.thread.groupId,
groupName: row.thread.group?.name ?? null,
threadId: row.threadId,
status: row.status as RunActivityRow["status"],
trigger: row.trigger as RunActivityRow["trigger"],
notificationsEnabled: activityNotificationsEnabled(row.thread.groupId, row.bot.notifyOnFinish),
promptSnippet: activityPromptSnippet({
trigger: row.trigger,
prompt: row.task.prompt,
sourceBlocks: row.sourceMessage?.blocks,
}),
updatedAt: (filter === "recent" && row.completedAt
? row.completedAt
: row.updatedAt
).toISOString(),
}));
}

View File

@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { addScreenProxyCapability } from "./screen-proxy.js";
describe("screen proxy capability", () => {
it("signs loopback Docker screen URLs without changing their destination", () => {
const result = new URL(
addScreenProxyCapability(
"http://127.0.0.1:49152/embed.html?view_only=true",
"secret",
"https://app.example",
100,
),
);
expect(result.origin).toBe("https://app.example");
expect(result.pathname).toMatch(
/^\/novnc\/[\w-]+\/49152\/view\/3600100\.[\w-]{43}\/embed\.html$/,
);
expect(result.searchParams.get("view_only")).toBe("true");
});
it("does not modify managed-provider URLs", () => {
const url = "https://sandbox.example/embed.html?token=provider-token";
expect(addScreenProxyCapability(url, "secret", "https://app.example", 100)).toBe(url);
});
it("keeps external desktop secrets behind an encrypted, policy-bound capability", () => {
const result = new URL(
addScreenProxyCapability(
"https://box.example/vnc.html?token=provider-token&view_only=true",
"secret",
"https://app.example",
100,
{ proxyExternal: true },
),
);
expect(result.origin).toBe("https://app.example");
expect(result.pathname).toMatch(/^\/novnc\/remote\/view\/3600100\.[\w-]+\/vnc\.html$/);
expect(result.toString()).not.toContain("provider-token");
});
});

View File

@ -0,0 +1,58 @@
import { createCipheriv, createHash, createHmac, randomBytes } from "node:crypto";
const SCREEN_PROXY_TTL_MS = 60 * 60_000;
const SCREEN_PROXY_CIPHER = "aes-256-gcm";
const SCREEN_PROXY_REMOTE_PREFIX = "/novnc/remote";
export interface ScreenProxyOptions {
/** Keep provider desktop secrets server-side and enforce the view/control policy in the proxy. */
proxyExternal?: boolean;
}
export function addScreenProxyCapability(
url: string,
secret: string,
proxyOrigin: string,
now = Date.now(),
options: ScreenProxyOptions = {},
): string {
try {
const parsed = new URL(url);
if (options.proxyExternal && parsed.protocol === "https:" && parsed.hostname) {
const expiresAt = now + SCREEN_PROXY_TTL_MS;
const policy = parsed.searchParams.get("view_only") === "false" ? "control" : "view";
const token = sealScreenTarget(parsed.toString(), secret, policy, expiresAt);
const origin = new URL(proxyOrigin).origin;
return `${origin}${SCREEN_PROXY_REMOTE_PREFIX}/${policy}/${expiresAt}.${token}${parsed.pathname || "/"}`;
}
if (parsed.protocol !== "http:" || !parsed.hostname || !parsed.port) return url;
const expiresAt = now + SCREEN_PROXY_TTL_MS;
const target = Buffer.from(parsed.hostname).toString("base64url");
const policy = parsed.searchParams.get("view_only") === "false" ? "control" : "view";
const destination = `${parsed.pathname}${parsed.search}`;
const signature = createHmac("sha256", secret)
.update(`${parsed.hostname}:${parsed.port}:${policy}:${expiresAt}`)
.digest("base64url");
const origin = new URL(proxyOrigin).origin;
return `${origin}/novnc/${target}/${parsed.port}/${policy}/${expiresAt}.${signature}${destination}`;
} catch {
return url;
}
}
function sealScreenTarget(
url: string,
secret: string,
policy: "view" | "control",
expiresAt: number,
) {
const iv = randomBytes(12);
const cipher = createCipheriv(SCREEN_PROXY_CIPHER, screenProxyKey(secret), iv);
cipher.setAAD(Buffer.from(`${policy}:${expiresAt}`));
const ciphertext = Buffer.concat([cipher.update(url, "utf8"), cipher.final()]);
return Buffer.concat([iv, cipher.getAuthTag(), ciphertext]).toString("base64url");
}
function screenProxyKey(secret: string) {
return createHash("sha256").update(secret).digest();
}

326
apps/api/src/search.ts Normal file
View File

@ -0,0 +1,326 @@
import type { Actor, MessageBlock, SearchHit } from "@rakazo/contracts";
import { extractLinksFromText, matchesSearchQuery, snippetAroundMatch } from "@rakazo/core";
import type { Prisma, PrismaClient } from "@rakazo/db";
const SEARCH_LIMIT = 25;
/** Cap name matches so content hits (messages/files/links/routines) keep most of the budget. */
const CONVERSATION_HIT_LIMIT = 5;
export async function querySpaceSearch(
prisma: PrismaClient,
actor: Actor,
q: string,
): Promise<SearchHit[]> {
const query = q.trim();
if (!query) return [];
const conversationHits: SearchHit[] = [];
const contentHits: SearchHit[] = [];
const seen = new Set<string>();
function pushInto(bucket: SearchHit[], hit: SearchHit, limit: number) {
const key = [
hit.kind,
hit.botId ?? "",
hit.groupId ?? "",
hit.messageId ?? "",
hit.artifactId ?? "",
hit.routineId ?? "",
hit.url ?? "",
].join(":");
if (seen.has(key) || bucket.length >= limit) return;
seen.add(key);
bucket.push(hit);
}
const bots = await prisma.bot.findMany({
where: {
spaceId: actor.spaceId,
userId: actor.userId,
archivedAt: null,
OR: [
{ name: { contains: query, mode: "insensitive" } },
{ title: { contains: query, mode: "insensitive" } },
{ description: { contains: query, mode: "insensitive" } },
],
},
take: CONVERSATION_HIT_LIMIT,
});
for (const bot of bots) {
pushInto(
conversationHits,
{
kind: "conversation",
botId: bot.id,
botName: bot.name,
title: bot.name,
snippet: bot.title || bot.description || bot.name,
},
CONVERSATION_HIT_LIMIT * 2,
);
}
const groups = await prisma.chatGroup.findMany({
where: {
spaceId: actor.spaceId,
userId: actor.userId,
name: { contains: query, mode: "insensitive" },
},
take: CONVERSATION_HIT_LIMIT,
});
for (const group of groups) {
pushInto(
conversationHits,
{
kind: "conversation",
groupId: group.id,
groupName: group.name,
title: group.name,
snippet: group.name,
},
CONVERSATION_HIT_LIMIT * 2,
);
}
const contentBudget = Math.max(0, SEARCH_LIMIT - conversationHits.length);
const artifacts = await prisma.artifact.findMany({
where: {
spaceId: actor.spaceId,
userId: actor.userId,
groupId: null,
botId: { not: null },
name: { contains: query, mode: "insensitive" },
bot: { archivedAt: null },
},
include: { bot: { select: { name: true } } },
take: SEARCH_LIMIT,
});
for (const artifact of artifacts) {
if (!artifact.botId || !artifact.bot) continue;
const messageRows = await prisma.$queryRaw<Array<{ id: string; seq: number }>>`
SELECT m.id, m.seq
FROM messages m
INNER JOIN threads t ON t.id = m."threadId"
WHERE t."spaceId" = ${actor.spaceId}
AND t."userId" = ${actor.userId}
AND t."botId" = ${artifact.botId}
AND m.blocks::text ILIKE ${`%${artifact.id}%`}
ORDER BY m."createdAt" DESC
LIMIT 1
`;
const message = messageRows[0];
if (!message) continue;
pushInto(
contentHits,
{
kind: "file",
botId: artifact.botId,
botName: artifact.bot.name,
title: artifact.name,
snippet: `${artifact.mimeType} · ${artifact.size} bytes`,
artifactId: artifact.id,
messageId: message.id,
seq: message.seq,
},
contentBudget,
);
}
const groupArtifacts = await prisma.artifact.findMany({
where: {
spaceId: actor.spaceId,
userId: actor.userId,
groupId: { not: null },
name: { contains: query, mode: "insensitive" },
},
include: { group: { select: { name: true } } },
take: SEARCH_LIMIT,
});
for (const artifact of groupArtifacts) {
if (!artifact.groupId || !artifact.group) continue;
const messageRows = await prisma.$queryRaw<Array<{ id: string; seq: number }>>`
SELECT m.id, m.seq
FROM messages m
INNER JOIN threads t ON t.id = m."threadId"
WHERE t."spaceId" = ${actor.spaceId}
AND t."userId" = ${actor.userId}
AND t."groupId" = ${artifact.groupId}
AND m.blocks::text ILIKE ${`%${artifact.id}%`}
ORDER BY m."createdAt" DESC
LIMIT 1
`;
const message = messageRows[0];
if (!message) continue;
pushInto(
contentHits,
{
kind: "file",
groupId: artifact.groupId,
groupName: artifact.group.name,
title: artifact.name,
snippet: `${artifact.mimeType} · ${artifact.size} bytes`,
artifactId: artifact.id,
messageId: message.id,
seq: message.seq,
},
contentBudget,
);
}
const routines = await prisma.routine.findMany({
where: {
spaceId: actor.spaceId,
userId: actor.userId,
OR: [
{ name: { contains: query, mode: "insensitive" } },
{ prompt: { contains: query, mode: "insensitive" } },
],
bot: { archivedAt: null },
},
include: { bot: { select: { name: true } } },
take: SEARCH_LIMIT,
});
for (const routine of routines) {
pushInto(
contentHits,
{
kind: "routine",
botId: routine.botId,
botName: routine.bot.name,
title: routine.name,
snippet: snippetAroundMatch(routine.prompt, query),
routineId: routine.id,
},
contentBudget,
);
}
const pattern = `%${query}%`;
const messageRows = await prisma.$queryRaw<
Array<{
id: string;
threadId: string;
seq: number;
blocks: Prisma.JsonValue;
botId: string;
botName: string;
}>
>`
SELECT m.id, m."threadId", m.seq, m.blocks, b.id AS "botId", b.name AS "botName"
FROM messages m
INNER JOIN threads t ON t.id = m."threadId"
INNER JOIN bots b ON b.id = t."botId"
WHERE t."spaceId" = ${actor.spaceId}
AND t."userId" = ${actor.userId}
AND b."archivedAt" IS NULL
AND m.blocks::text ILIKE ${pattern}
ORDER BY m."createdAt" DESC
LIMIT ${SEARCH_LIMIT}
`;
for (const row of messageRows) {
pushMessageHits(row.blocks as MessageBlock[], {
botId: row.botId,
botName: row.botName,
messageId: row.id,
seq: row.seq,
query,
push: (hit) => pushInto(contentHits, hit, contentBudget),
});
}
const groupMessageRows = await prisma.$queryRaw<
Array<{
id: string;
threadId: string;
seq: number;
blocks: Prisma.JsonValue;
groupId: string;
groupName: string;
}>
>`
SELECT m.id, m."threadId", m.seq, m.blocks, g.id AS "groupId", g.name AS "groupName"
FROM messages m
INNER JOIN threads t ON t.id = m."threadId"
INNER JOIN chat_groups g ON g.id = t."groupId"
WHERE t."spaceId" = ${actor.spaceId}
AND t."userId" = ${actor.userId}
AND t."groupId" IS NOT NULL
AND m.blocks::text ILIKE ${pattern}
ORDER BY m."createdAt" DESC
LIMIT ${SEARCH_LIMIT}
`;
for (const row of groupMessageRows) {
pushMessageHits(row.blocks as MessageBlock[], {
groupId: row.groupId,
groupName: row.groupName,
messageId: row.id,
seq: row.seq,
query,
push: (hit) => pushInto(contentHits, hit, contentBudget),
});
}
return [...conversationHits, ...contentHits].slice(0, SEARCH_LIMIT);
}
function pushMessageHits(
blocks: MessageBlock[],
ctx: {
botId?: string;
botName?: string;
groupId?: string;
groupName?: string;
messageId: string;
seq: number;
query: string;
push: (hit: SearchHit) => void;
},
) {
// Discriminate on groupId (not name): empty group names must still target the group.
const destination = ctx.groupId
? { groupId: ctx.groupId, groupName: ctx.groupName ?? "" }
: { botId: ctx.botId!, botName: ctx.botName! };
const title = ctx.groupId ? (ctx.groupName ?? "") : (ctx.botName ?? "");
let messageHit = false;
for (const block of blocks) {
if (block.kind !== "text") continue;
const text = block.text;
if (matchesSearchQuery(ctx.query, text)) {
ctx.push({
kind: "message",
...destination,
title,
snippet: snippetAroundMatch(text, ctx.query),
messageId: ctx.messageId,
seq: ctx.seq,
});
messageHit = true;
}
for (const url of extractLinksFromText(text)) {
if (matchesSearchQuery(ctx.query, url)) {
ctx.push({
kind: "link",
...destination,
title: url,
snippet: snippetAroundMatch(text, ctx.query),
messageId: ctx.messageId,
seq: ctx.seq,
url,
});
}
}
}
if (!messageHit && matchesSearchQuery(ctx.query, JSON.stringify(blocks))) {
ctx.push({
kind: "message",
...destination,
title,
snippet: ctx.query,
messageId: ctx.messageId,
seq: ctx.seq,
});
}
}

View File

@ -0,0 +1,63 @@
import { describe, expect, it, vi } from "vitest";
import { withSerializableRetry } from "./serializable-retry.js";
function serializationConflict() {
return Object.assign(new Error("serialization conflict"), { code: "P2034" });
}
function adapterConflict(originalCode: "40001" | "40P01") {
return Object.assign(new Error("database conflict"), {
code: "P2039",
meta: { driverAdapterError: { cause: { originalCode } } },
});
}
describe("withSerializableRetry", () => {
it("retries serialization conflicts and returns the successful result", async () => {
const operation = vi
.fn<() => Promise<string>>()
.mockRejectedValueOnce(serializationConflict())
.mockRejectedValueOnce(serializationConflict())
.mockResolvedValue("ok");
await expect(withSerializableRetry(operation)).resolves.toBe("ok");
expect(operation).toHaveBeenCalledTimes(3);
});
it.each(["40001", "40P01"] as const)(
"retries driver-adapter transaction conflict %s",
async (databaseCode) => {
const operation = vi
.fn<() => Promise<string>>()
.mockRejectedValueOnce(adapterConflict(databaseCode))
.mockResolvedValue("ok");
await expect(withSerializableRetry(operation)).resolves.toBe("ok");
expect(operation).toHaveBeenCalledTimes(2);
},
);
it("rethrows non-serialization errors without retrying", async () => {
const error = new Error("unrelated failure");
const operation = vi.fn<() => Promise<never>>().mockRejectedValue(error);
await expect(withSerializableRetry(operation)).rejects.toBe(error);
expect(operation).toHaveBeenCalledTimes(1);
});
it("does not retry unrelated driver-adapter errors", async () => {
const error = Object.assign(new Error("external connector error"), { code: "P2039" });
const operation = vi.fn<() => Promise<never>>().mockRejectedValue(error);
await expect(withSerializableRetry(operation)).rejects.toBe(error);
expect(operation).toHaveBeenCalledTimes(1);
});
it("rethrows the final serialization conflict after the bounded retry limit", async () => {
const error = serializationConflict();
const operation = vi.fn<() => Promise<never>>().mockRejectedValue(error);
await expect(withSerializableRetry(operation)).rejects.toBe(error);
expect(operation).toHaveBeenCalledTimes(3);
});
});

View File

@ -0,0 +1 @@
export { withTransactionRetry as withSerializableRetry } from "@rakazo/db";

View File

@ -0,0 +1,274 @@
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
applyServerUpdate,
assertNoGitApplyPath,
checkServerUpdate,
isUpdaterConfigured,
readServerUpdateStatus,
type UpdaterProxyConfig,
UpdaterProxyError,
} from "./server-update.js";
const TOKEN = "fake-review-updater-token-000000000000";
const URL = "http://updater:7092";
const roots: string[] = [];
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
describe("server update install kind", () => {
async function tempRoot(withGit: boolean) {
const root = await mkdtemp(path.join(tmpdir(), "rakazo-update-"));
roots.push(root);
if (withGit) await mkdir(path.join(root, ".git"));
return root;
}
it("detects a source checkout when the sidecar is not wired", async () => {
const root = await tempRoot(true);
const status = await readServerUpdateStatus({
url: null,
token: null,
gitSha: "abc",
checkoutRoot: root,
fetch: vi.fn(),
});
expect(status.installKind).toBe("source");
expect(status.supported).toBe(false);
expect(status.manualCommands.some((line) => line.includes("git pull"))).toBe(true);
expect(status.manualCommands.some((line) => line.includes("migrate"))).toBe(true);
});
it("detects compose-without-sidecar when the updater URL is set but unreachable", async () => {
const root = await tempRoot(false);
const status = await readServerUpdateStatus({
url: URL,
token: TOKEN,
gitSha: "abc",
imageTag: "sha-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
checkoutRoot: root,
fetch: vi.fn(async () => {
throw new Error("ECONNREFUSED");
}),
});
expect(status.installKind).toBe("compose");
expect(status.supported).toBe(false);
expect(status.manualCommands[0]).toMatch(/docker compose .*pull api worker web/);
expect(status.manualCommands[1]).toMatch(/up -d --wait --pull never/);
});
it("shows rebuild commands for a compose install on the local image tag", async () => {
const root = await tempRoot(false);
const status = await readServerUpdateStatus({
url: URL,
token: TOKEN,
gitSha: "abc",
imageTag: "local",
checkoutRoot: root,
fetch: vi.fn(async () => {
throw new Error("ECONNREFUSED");
}),
});
expect(status.installKind).toBe("compose");
expect(status.manualCommands.some((line) => line.includes("--build"))).toBe(true);
expect(status.manualCommands.some((line) => line.includes("git pull"))).toBe(true);
expect(status.manualCommands.some((line) => /\bpull api worker web\b/.test(line))).toBe(false);
});
it("detects sidecar when health and authenticated state succeed", async () => {
const root = await tempRoot(false);
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
const href = String(input);
if (href.endsWith("/health")) {
return new Response(JSON.stringify({ ok: true }), { status: 200 });
}
if (href.endsWith("/state")) {
return new Response(
JSON.stringify({
image: "ghcr.io/elie222/rakazo/app",
currentTag: "sha-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
previousTag: "sha-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
running: false,
checkout: {
present: true,
commit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
branch: "main",
remoteUrl: "https://github.com/elie222/rakazo",
dirty: false,
dirtyPaths: [],
},
}),
{ status: 200 },
);
}
return new Response("missing", { status: 404 });
});
const status = await readServerUpdateStatus({
url: URL,
token: TOKEN,
gitSha: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
checkoutRoot: root,
fetch: fetchImpl as unknown as typeof fetch,
});
expect(status.installKind).toBe("sidecar");
expect(status.supported).toBe(true);
expect(status.canRollback).toBe(true);
expect(status.manualCommands).toEqual([]);
expect(status.imageTag).toMatch(/^sha-/);
expect(status.lastRun).toBeNull();
});
it("surfaces a finished sidecar lastRun for recreate confirmation", async () => {
const root = await tempRoot(false);
const lastRun = {
startedAt: "2026-08-27T21:00:00.000Z",
finishedAt: "2026-08-27T21:01:00.000Z",
ok: false,
fromCommit: null,
toCommit: null,
fromTag: "sha-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
toTag: "sha-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
strategy: "pull",
repoUrl: "https://github.com/elie222/rakazo",
branch: "main",
restart: "not-required",
restartAdvice: "Recreate failed; prior image restored, env pin not restored.",
error: "Recreate the stack failed.",
steps: [],
};
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
const href = String(input);
if (href.endsWith("/health")) {
return new Response(JSON.stringify({ ok: true }), { status: 200 });
}
if (href.endsWith("/state")) {
return new Response(
JSON.stringify({
image: "ghcr.io/elie222/rakazo/app",
currentTag: "sha-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
previousTag: "sha-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
running: false,
lastRun,
checkout: {
present: true,
commit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
branch: "main",
remoteUrl: "https://github.com/elie222/rakazo",
dirty: false,
dirtyPaths: [],
},
}),
{ status: 200 },
);
}
return new Response("missing", { status: 404 });
});
const status = await readServerUpdateStatus({
url: URL,
token: TOKEN,
gitSha: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
checkoutRoot: root,
fetch: fetchImpl as unknown as typeof fetch,
});
expect(status.lastRun).toMatchObject({
ok: false,
fromTag: "sha-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
toTag: "sha-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
error: "Recreate the stack failed.",
});
});
});
describe("sidecar proxy auth and no-git-apply", () => {
it("requires both URL and token before claiming the sidecar is configured", () => {
expect(isUpdaterConfigured({ url: URL, token: null, gitSha: undefined })).toBe(false);
expect(isUpdaterConfigured({ url: null, token: TOKEN, gitSha: undefined })).toBe(false);
expect(isUpdaterConfigured({ url: URL, token: TOKEN, gitSha: undefined })).toBe(true);
});
it("sends the bearer token to the sidecar and never returns it", async () => {
const authHeaders: string[] = [];
const fetchImpl = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const href = String(input);
const headers = new Headers(init?.headers);
const authorization = headers.get("authorization");
if (authorization) authHeaders.push(authorization);
if (href.endsWith("/health")) {
return new Response(JSON.stringify({ ok: true }), { status: 200 });
}
if (href.endsWith("/state")) {
return new Response(JSON.stringify({ running: false, currentTag: "local" }), {
status: 200,
});
}
if (href.endsWith("/plan")) {
return new Response(
JSON.stringify({
upToDate: false,
targetCommit: "cccccccccccccccccccccccccccccccccccccccc",
targetTag: "sha-cccccccccccccccccccccccccccccccccccccccc",
checkout: { commit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", dirty: false },
}),
{ status: 200 },
);
}
return new Response(JSON.stringify({ error: "unexpected" }), { status: 500 });
});
const root = await mkdtemp(path.join(tmpdir(), "rakazo-proxy-"));
roots.push(root);
const config: UpdaterProxyConfig = {
url: URL,
token: TOKEN,
gitSha: undefined,
checkoutRoot: root,
fetch: fetchImpl as unknown as typeof fetch,
};
const check = await checkServerUpdate(config);
expect(check.status).toBe("available");
expect(authHeaders).toContain(`Bearer ${TOKEN}`);
expect(JSON.stringify(check)).not.toContain(TOKEN);
});
it("rejects check/apply when the sidecar is off (no git apply path)", async () => {
const config: UpdaterProxyConfig = {
url: null,
token: null,
gitSha: undefined,
fetch: vi.fn(),
};
await expect(checkServerUpdate(config)).rejects.toBeInstanceOf(UpdaterProxyError);
await expect(applyServerUpdate(config)).rejects.toMatchObject({
message: expect.stringMatching(/sidecar/i),
});
expect(assertNoGitApplyPath).toBeTypeOf("function");
expect(() => assertNoGitApplyPath("source")).toThrow(/cannot apply/);
expect(() => assertNoGitApplyPath("compose")).toThrow(/cannot apply/);
expect(() => assertNoGitApplyPath("sidecar")).not.toThrow();
});
it("maps a 401 from the sidecar without exposing the token", async () => {
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
const href = String(input);
if (href.endsWith("/health")) {
return new Response(JSON.stringify({ ok: true }), { status: 200 });
}
return new Response(JSON.stringify({ error: "unauthorized" }), { status: 401 });
});
const config: UpdaterProxyConfig = {
url: URL,
token: TOKEN,
gitSha: undefined,
fetch: fetchImpl as unknown as typeof fetch,
};
await expect(applyServerUpdate(config)).rejects.toMatchObject({
message: expect.stringMatching(/credential|reachable|sidecar/i),
});
await expect(applyServerUpdate(config)).rejects.not.toMatchObject({
message: expect.stringContaining(TOKEN),
});
});
});

View File

@ -0,0 +1,353 @@
import { access } from "node:fs/promises";
import path from "node:path";
import type {
ServerUpdateCheck,
ServerUpdateRequest,
ServerUpdateRun,
ServerUpdateStatus,
} from "@rakazo/contracts";
import { ServerUpdateRunSchema } from "@rakazo/contracts";
import {
DEFAULT_UPDATE_BRANCH,
detectRestartSupervisor,
isOfficialRepoUrl,
manualUpgradeCommands,
OFFICIAL_REPO_URL,
OFFICIAL_SERVER_IMAGE,
resolveInstallKind,
restartSupervisorAdvice,
} from "@rakazo/core";
const PRODUCT_VERSION = "0.1.0";
const STATE_TIMEOUT_MS = 15_000;
const PLAN_TIMEOUT_MS = 180_000;
const APPLY_TIMEOUT_MS = 2_100_000;
export interface UpdaterProxyConfig {
url: string | null;
token: string | null;
gitSha: string | undefined;
/** Current `RAKAZO_IMAGE_TAG` when known; selects compose pull vs rebuild commands. */
imageTag?: string | null;
disabled?: boolean;
/** Override for tests; defaults to process.cwd(). */
checkoutRoot?: string;
fetch?: typeof fetch;
}
export class UpdaterProxyError extends Error {
constructor(
message: string,
readonly status: number = 400,
) {
super(message);
this.name = "UpdaterProxyError";
}
}
/** True only when the API can authenticate to the sidecar. The token never leaves this process. */
export function isUpdaterConfigured(config: UpdaterProxyConfig): boolean {
return Boolean(config.url?.trim() && config.token?.trim());
}
export async function hasGitCheckout(root: string): Promise<boolean> {
try {
await access(path.join(root, ".git"));
return true;
} catch {
return false;
}
}
async function probeSidecar(config: UpdaterProxyConfig, fetchImpl: typeof fetch): Promise<boolean> {
if (!isUpdaterConfigured(config) || !config.url || !config.token) return false;
try {
const response = await fetchImpl(new URL("/health", ensureTrailingSlash(config.url)), {
method: "GET",
signal: AbortSignal.timeout(5_000),
});
if (!response.ok) return false;
// Confirm the bearer works: /health is open, so a wrong token would still look "up".
const state = await fetchImpl(new URL("/state", ensureTrailingSlash(config.url)), {
method: "GET",
headers: { authorization: `Bearer ${config.token}` },
signal: AbortSignal.timeout(5_000),
});
return state.ok;
} catch {
return false;
}
}
function ensureTrailingSlash(url: string): string {
return url.endsWith("/") ? url : `${url}/`;
}
export async function readServerUpdateStatus(
config: UpdaterProxyConfig,
): Promise<ServerUpdateStatus> {
const fetchImpl = config.fetch ?? fetch;
const checkoutRoot = config.checkoutRoot ?? process.cwd();
const hasCheckout = await hasGitCheckout(checkoutRoot);
const urlConfigured = Boolean(config.url?.trim());
const reachable = await probeSidecar(config, fetchImpl);
const install = resolveInstallKind({
updaterUrlConfigured: urlConfigured,
updaterReachable: reachable,
hasCheckout,
disabled: config.disabled === true,
});
const supervisor = detectRestartSupervisor(process.env);
const imageTagHint = config.imageTag?.trim() || process.env.RAKAZO_IMAGE_TAG?.trim() || null;
const base: ServerUpdateStatus = {
supported: install.kind === "sidecar",
unsupportedReason: install.kind === "sidecar" ? null : install.reason,
installKind: install.kind,
manualCommands: [...manualUpgradeCommands(install.kind, { imageTag: imageTagHint })],
mode: install.mode,
strategy: null,
strategyNote: null,
version: PRODUCT_VERSION,
revision: config.gitSha ?? null,
commit: config.gitSha ?? null,
branch: null,
remoteUrl: null,
dirty: false,
dirtyPaths: [],
image: null,
imageTag: imageTagHint,
previousImageTag: null,
canRollback: false,
source: {
repoUrl: OFFICIAL_REPO_URL,
branch: DEFAULT_UPDATE_BRANCH,
official: true,
},
officialRepoUrl: OFFICIAL_REPO_URL,
restartSupervisor: supervisor.kind,
restartAdvice: restartSupervisorAdvice(supervisor),
running: false,
lastRun: null,
};
if (install.kind !== "sidecar" || !config.url || !config.token) return base;
try {
const state = await sidecarJson<{
image?: string;
imageRef?: string;
currentTag?: string;
previousTag?: string | null;
running?: boolean;
lastRun?: unknown;
checkout?: {
present?: boolean;
commit?: string | null;
branch?: string | null;
remoteUrl?: string | null;
dirty?: boolean;
dirtyPaths?: string[];
};
}>(config, fetchImpl, "GET", "/state", undefined, STATE_TIMEOUT_MS);
const remoteUrl = state.checkout?.remoteUrl ?? null;
const official = remoteUrl ? isOfficialRepoUrl(remoteUrl) : true;
const parsedLastRun = ServerUpdateRunSchema.safeParse(state.lastRun);
return {
...base,
supported: true,
unsupportedReason: null,
strategy: official ? "pull" : "build",
strategyNote: official
? "Official releases pull the published image."
: "This fork builds on the server.",
commit: state.checkout?.commit ?? base.commit,
branch: state.checkout?.branch ?? null,
remoteUrl,
dirty: state.checkout?.dirty === true,
dirtyPaths: state.checkout?.dirtyPaths ?? [],
image: state.image ?? OFFICIAL_SERVER_IMAGE,
imageTag: state.currentTag ?? null,
previousImageTag: state.previousTag ?? null,
canRollback: Boolean(state.previousTag),
source: {
repoUrl:
remoteUrl && isOfficialRepoUrl(remoteUrl)
? OFFICIAL_REPO_URL
: (remoteUrl ?? OFFICIAL_REPO_URL),
branch: state.checkout?.branch ?? DEFAULT_UPDATE_BRANCH,
official,
},
running: state.running === true,
lastRun: parsedLastRun.success ? parsedLastRun.data : null,
};
} catch (error) {
return {
...base,
supported: false,
unsupportedReason:
error instanceof Error ? error.message : "The updater sidecar did not respond.",
installKind: "compose",
mode: "unavailable",
manualCommands: [
...manualUpgradeCommands("compose", {
imageTag: imageTagHint,
}),
],
};
}
}
export async function checkServerUpdate(
config: UpdaterProxyConfig,
input: ServerUpdateRequest = {},
): Promise<ServerUpdateCheck> {
await requireSidecar(config);
const fetchImpl = config.fetch ?? fetch;
const plan = await sidecarJson<{
upToDate?: boolean;
reason?: string;
targetCommit?: string | null;
targetTag?: string | null;
checkout?: { commit?: string | null; dirty?: boolean; dirtyPaths?: string[] };
}>(config, fetchImpl, "POST", "/plan", requestBody(input), PLAN_TIMEOUT_MS);
if (plan.checkout?.dirty === true) {
return {
status: "dirty",
reason: "The deployment checkout has local changes.",
changed: plan.checkout.dirtyPaths ?? [],
commit: plan.checkout.commit ?? null,
targetCommit: plan.targetCommit ?? null,
targetTag: plan.targetTag ?? null,
behindBy: 0,
};
}
if (plan.upToDate === true) {
return {
status: "up-to-date",
reason: plan.reason ?? null,
changed: [],
commit: plan.checkout?.commit ?? null,
targetCommit: plan.targetCommit ?? null,
targetTag: plan.targetTag ?? null,
behindBy: 0,
};
}
return {
status: "available",
reason: plan.reason ?? null,
changed: [],
commit: plan.checkout?.commit ?? null,
targetCommit: plan.targetCommit ?? null,
targetTag: plan.targetTag ?? null,
behindBy:
plan.targetCommit && plan.checkout?.commit && plan.targetCommit !== plan.checkout.commit
? 1
: 0,
};
}
/**
* Proxies `/apply` to the sidecar.
*
* A successful recreate replaces this API container while the request is still open, so the
* JSON body often never reaches the browser. Clients must treat a mid-flight transport failure
* as "recreate in progress" and re-fetch `status` once the API is healthy again.
*/
export async function applyServerUpdate(
config: UpdaterProxyConfig,
input: ServerUpdateRequest = {},
): Promise<ServerUpdateRun> {
await requireSidecar(config);
const fetchImpl = config.fetch ?? fetch;
return sidecarJson<ServerUpdateRun>(
config,
fetchImpl,
"POST",
"/apply",
requestBody(input),
APPLY_TIMEOUT_MS,
);
}
/**
* Hard gate: Settings apply/check never run git (or anything else) inside the API.
* Sidecar `/rollback` remains for ops only and is not exposed on the owner RPC surface.
* Only the updater sidecar holds the Docker socket and outlives a recreate.
*/
async function requireSidecar(config: UpdaterProxyConfig): Promise<void> {
if (config.disabled === true) {
throw new UpdaterProxyError("Self-update is switched off for this deployment.");
}
if (!isUpdaterConfigured(config)) {
throw new UpdaterProxyError(
"The updater sidecar is not configured. Use the host Compose commands, or enable the updater profile.",
);
}
const fetchImpl = config.fetch ?? fetch;
if (!(await probeSidecar(config, fetchImpl))) {
throw new UpdaterProxyError(
"The updater sidecar is not reachable. Start the opt-in updater profile, or upgrade from the host.",
);
}
}
function requestBody(input: ServerUpdateRequest): Record<string, string> {
const body: Record<string, string> = {};
if (input.repoUrl?.trim()) body.repoUrl = input.repoUrl.trim();
if (input.branch?.trim()) body.branch = input.branch.trim();
return body;
}
async function sidecarJson<T>(
config: UpdaterProxyConfig,
fetchImpl: typeof fetch,
method: string,
route: string,
body: unknown | undefined,
timeoutMs: number,
): Promise<T> {
if (!config.url || !config.token) {
throw new UpdaterProxyError("The updater sidecar is not configured.");
}
let response: Response;
try {
response = await fetchImpl(new URL(route.replace(/^\//, ""), ensureTrailingSlash(config.url)), {
method,
headers: {
authorization: `Bearer ${config.token}`,
...(body === undefined ? {} : { "content-type": "application/json" }),
},
body: body === undefined ? undefined : JSON.stringify(body),
signal: AbortSignal.timeout(timeoutMs),
});
} catch (error) {
throw new UpdaterProxyError(
error instanceof Error ? error.message : "The updater sidecar did not respond.",
502,
);
}
const payload = (await response.json().catch(() => ({}))) as T & { error?: string };
if (response.status === 401) {
throw new UpdaterProxyError("The updater sidecar rejected the deployment credential.", 502);
}
if (!response.ok) {
throw new UpdaterProxyError(
typeof payload.error === "string" && payload.error
? payload.error
: `Updater sidecar returned ${response.status}.`,
response.status >= 400 && response.status < 500 ? 400 : 502,
);
}
return payload;
}
/** Exported for tests that assert the API never treats a source tree as applyable. */
export function assertNoGitApplyPath(installKind: string): void {
if (installKind === "source" || installKind === "compose") {
throw new UpdaterProxyError(
"Settings cannot apply updates without the updater sidecar. Use the documented host commands.",
);
}
}

View File

@ -0,0 +1,551 @@
import { randomUUID } from "node:crypto";
import { ORPCError } from "@orpc/server";
import type { AgentHomeStore, JobPublisher, SandboxProvider } from "@rakazo/adapter-kit";
import {
type AdapterContext,
runContinueJob,
skillTeachingExpireJob,
skillTeachingExpireJobKey,
} from "@rakazo/adapter-kit";
import {
acquireComputerExecutionLease,
appendRecordingEvent,
captureTeachingSnapshot,
completeTeachingSession,
emptyRecording,
expireTaughtSkillTeaching,
extendActiveComputerControl,
getActiveTeachingSession,
mapTaughtSkill,
observeStopSnapshot,
parsePlaybook,
parseRecording,
provisionComputer,
recordTeachingInputEvent,
releaseComputerExecutionLease,
releaseTeachingComputerControlForBot,
scheduleComputerControlExpiry,
screenLeaseIdForRun,
type TeachComputerInput,
teachingControlLeaseExpiresAt,
} from "@rakazo/adapters";
import type { Actor, MessageBlock, TaughtSkill } from "@rakazo/contracts";
import {
ACTIVE_RUN_STATUSES,
buildPlaybookFromRecording,
formatSkillRunPrompt,
type SkillPlaybook,
type TeachRecordingEvent,
teachRecordingTtlMs,
} from "@rakazo/core";
import { IsolationError, type PrismaClient, type ThreadEvents } from "@rakazo/db";
type TaughtSkillRow = {
id: string;
spaceId: string;
botId: string;
userId: string;
name: string;
goal: string;
status: string;
playbook: unknown;
recording: unknown;
startedAt: Date | null;
expiresAt: Date | null;
stoppedAt: Date | null;
createdAt: Date;
updatedAt: Date;
};
export interface TaughtSkillsDeps {
prisma: PrismaClient;
events: ThreadEvents;
jobs: JobPublisher;
sandbox: SandboxProvider;
home: AgentHomeStore;
dataDir: string;
}
function computerContext(actor: Actor, botId: string, operationId: string): AdapterContext {
return {
operationId,
traceId: operationId,
spaceId: actor.spaceId,
userId: actor.userId,
botId,
signal: new AbortController().signal,
};
}
function ownedSkillWhere(actor: Actor, skillId: string) {
return { id: skillId, spaceId: actor.spaceId, userId: actor.userId };
}
async function getOwnedSkill(
deps: TaughtSkillsDeps,
actor: Actor,
skillId: string,
): Promise<TaughtSkillRow> {
const skill = await deps.prisma.taughtSkill.findFirst({ where: ownedSkillWhere(actor, skillId) });
if (!skill) throw new IsolationError();
return skill;
}
export async function assertTeachingSendAllowed(
prisma: PrismaClient,
spaceId: string,
botId: string,
): Promise<void> {
const active = await getActiveTeachingSession(prisma, spaceId, botId);
if (active) {
throw new ORPCError("CONFLICT", { message: "Stop teaching first" });
}
}
async function cancelActiveRuns(
deps: TaughtSkillsDeps,
_actor: Actor,
botId: string,
): Promise<void> {
const activeRuns = await deps.prisma.run.findMany({
where: { botId, status: { in: [...ACTIVE_RUN_STATUSES] } },
select: { id: true },
});
await deps.prisma.run.updateMany({
where: { botId, status: { in: [...ACTIVE_RUN_STATUSES] } },
data: { status: "cancelled", completedAt: new Date() },
});
await deps.prisma.computerExecutionLease.deleteMany({ where: { botId } });
await deps.prisma.computer.updateMany({
where: { executionBotId: botId },
data: { executionRunId: null, executionBotId: null, executionLeaseExpiresAt: null },
});
await deps.prisma.event.deleteMany({
where: { type: "thread.progress", runId: { in: activeRuns.map((run) => run.id) } },
});
}
async function ensureGraphicalComputer(
deps: TaughtSkillsDeps,
actor: Actor,
bot: Awaited<ReturnType<ReturnType<typeof import("@rakazo/db").createRepos>["getBot"]>>,
) {
if (bot.computer?.kind === "desktop") {
throw new ORPCError("BAD_REQUEST", {
message: "Teaching needs a graphical sandbox computer, not a desktop host",
});
}
if (!bot.computer) throw new IsolationError();
if (bot.computer.state !== "running" || !bot.computer.providerRef) {
const ctx = computerContext(actor, bot.id, "skills.start");
const manualRunId = `teach:${randomUUID()}`;
const lease = await acquireComputerExecutionLease(deps.prisma, {
computerId: bot.computer.id,
runId: manualRunId,
botId: bot.id,
});
try {
await provisionComputer(deps, bot.computer.id, {
...ctx,
screenLeaseId: screenLeaseIdForRun(lease, manualRunId),
});
} finally {
await releaseComputerExecutionLease(deps.prisma, lease);
}
bot = await deps.prisma.bot.findUniqueOrThrow({
where: { id: bot.id },
include: { thread: true, computer: true },
});
}
if (!bot.computer?.providerRef || bot.computer.state !== "running") {
throw new ORPCError("BAD_REQUEST", { message: "Computer must be running to teach" });
}
if (bot.computer.kind === "desktop") {
throw new ORPCError("BAD_REQUEST", {
message: "Teaching needs a graphical sandbox computer, not a desktop host",
});
}
return bot;
}
async function grantTakeover(
deps: TaughtSkillsDeps,
actor: Actor,
bot: Awaited<ReturnType<ReturnType<typeof import("@rakazo/db").createRepos>["getBot"]>>,
until: Date,
): Promise<{ bot: typeof bot; leaseId: string }> {
if (!bot.computer) throw new IsolationError();
if (await extendActiveComputerControl(deps.prisma, deps.jobs, bot.computer, bot.id, until)) {
const leaseId = bot.computer.controlLeaseId;
if (!leaseId) throw new IsolationError();
return { bot, leaseId };
}
const leaseId = randomUUID();
const expiresAt = teachingControlLeaseExpiresAt(until);
const granted = await deps.prisma.computer.updateMany({
where: {
id: bot.computer.id,
state: "running",
OR: [{ controlHolder: { not: "user" } }, { controlBotId: bot.id }],
},
data: {
controlHolder: "user",
controlLeaseId: leaseId,
controlLeaseExpiresAt: expiresAt,
controlBotId: bot.id,
state: "running",
},
});
if (granted.count !== 1) {
throw new ORPCError("CONFLICT", { message: "Could not take control of the computer" });
}
await scheduleComputerControlExpiry(deps.jobs, bot.computer.id, leaseId, expiresAt);
if (bot.thread) {
await deps.events.append({
spaceId: actor.spaceId,
threadId: bot.thread.id,
botId: bot.id,
type: "computer.takeover.granted",
payload: { holder: "user", reason: "teaching" },
});
}
return { bot, leaseId };
}
async function updateSkillDraftMessage(
deps: TaughtSkillsDeps,
actor: Actor,
skill: TaughtSkillRow,
input: {
name?: string;
playbook?: SkillPlaybook;
status?: "draft" | "saved";
},
): Promise<void> {
const bot = await deps.prisma.bot.findUnique({
where: { id: skill.botId },
include: { thread: true },
});
if (!bot?.thread) return;
const messages = await deps.prisma.message.findMany({
where: { threadId: bot.thread.id, role: "bot" },
orderBy: { seq: "desc" },
take: 100,
});
for (const message of messages) {
const parsed = message.blocks as MessageBlock[];
if (!Array.isArray(parsed)) continue;
const index = parsed.findIndex(
(block) => block.kind === "skill_draft" && block.skillId === skill.id,
);
if (index === -1) continue;
const existing = parsed[index];
if (existing?.kind !== "skill_draft") continue;
const playbook = input.playbook ?? parsePlaybook(skill.playbook);
const nextBlocks: MessageBlock[] = [...parsed];
nextBlocks[index] = {
kind: "skill_draft",
skillId: skill.id,
name: input.name ?? existing.name,
goal: skill.goal,
playbook,
status: input.status ?? existing.status,
};
await deps.prisma.message.update({
where: { id: message.id },
data: { blocks: nextBlocks as never },
});
await deps.events.append({
spaceId: actor.spaceId,
threadId: bot.thread.id,
botId: bot.id,
type: "thread.message.updated",
payload: { messageId: message.id, role: "bot", blocks: nextBlocks },
});
return;
}
}
export async function expireTeachingSessionIfNeeded(deps: TaughtSkillsDeps, skillId: string) {
return expireTaughtSkillTeaching(deps, skillId);
}
export async function stopTeachingSession(
deps: TaughtSkillsDeps,
actor: Actor,
skillId: string,
): Promise<TaughtSkill> {
await getOwnedSkill(deps, actor, skillId);
await expireTeachingSessionIfNeeded(deps, skillId);
const current = await deps.prisma.taughtSkill.findUniqueOrThrow({ where: { id: skillId } });
if (current.status === "draft" || current.status === "saved") {
await releaseTeachingComputerControlForBot(
deps,
actor,
current.botId,
parseRecording(current.recording).controlLeaseId,
);
return mapTaughtSkill(current);
}
if (current.status !== "recording" && current.status !== "drafting") {
throw new ORPCError("BAD_REQUEST", { message: "Teaching session is not active" });
}
const bot = await deps.prisma.bot.findUnique({
where: { id: current.botId },
include: { thread: true, computer: true },
});
if (!bot) throw new IsolationError();
const stopSnapshot =
current.status === "recording" && bot.computer?.providerRef
? await observeStopSnapshot(deps, actor, bot)
: undefined;
const finalized = await completeTeachingSession(deps, actor, skillId, "stopped", stopSnapshot);
await deps.jobs.cancel(skillTeachingExpireJobKey(current.id));
return mapTaughtSkill(finalized);
}
export function createTaughtSkillsService(deps: TaughtSkillsDeps) {
return {
async list(actor: Actor, botId: string): Promise<TaughtSkill[]> {
const rows = await deps.prisma.taughtSkill.findMany({
where: { spaceId: actor.spaceId, botId, userId: actor.userId },
orderBy: { updatedAt: "desc" },
});
return rows.map(mapTaughtSkill);
},
async get(actor: Actor, skillId: string): Promise<TaughtSkill> {
const row = await getOwnedSkill(deps, actor, skillId);
await expireTeachingSessionIfNeeded(deps, row.id);
const current = await deps.prisma.taughtSkill.findUniqueOrThrow({ where: { id: skillId } });
return mapTaughtSkill(current);
},
async start(actor: Actor, botId: string, goal: string): Promise<TaughtSkill> {
let bot = await deps.prisma.bot.findFirst({
where: { id: botId, spaceId: actor.spaceId, userId: actor.userId },
include: { thread: true, computer: true },
});
if (!bot) throw new IsolationError();
const alreadyRecording = await deps.prisma.taughtSkill.findFirst({
where: { botId, status: "recording" },
select: { id: true },
});
if (alreadyRecording) {
throw new ORPCError("CONFLICT", { message: "A teaching session is already active" });
}
await cancelActiveRuns(deps, actor, botId);
bot = await ensureGraphicalComputer(deps, actor, bot);
const startedAt = new Date();
const expiresAt = new Date(startedAt.getTime() + teachRecordingTtlMs());
const { leaseId } = await grantTakeover(deps, actor, bot, expiresAt);
let row: TaughtSkillRow;
try {
row = await deps.prisma.$transaction(async (tx) => {
await tx.$executeRaw`SELECT id FROM bots WHERE id = ${botId} FOR UPDATE`;
const existing = await tx.taughtSkill.findFirst({
where: { botId, status: "recording" },
});
if (existing) {
throw new ORPCError("CONFLICT", { message: "A teaching session is already active" });
}
return tx.taughtSkill.create({
data: {
spaceId: actor.spaceId,
botId,
userId: actor.userId,
goal,
status: "recording",
startedAt,
expiresAt,
recording: { ...emptyRecording(), controlLeaseId: leaseId } as never,
playbook: buildPlaybookFromRecording(goal, []) as never,
},
});
});
} catch (error) {
if (error instanceof ORPCError) throw error;
if (
error instanceof Error &&
"code" in error &&
(error as { code?: string }).code === "P2002"
) {
throw new ORPCError("CONFLICT", { message: "A teaching session is already active" });
}
throw error;
}
await deps.jobs.enqueue(skillTeachingExpireJob(row.id, expiresAt));
const withSnapshot = await captureTeachingSnapshot(deps, actor, bot, row);
if (bot.thread) {
await deps.events.append({
spaceId: actor.spaceId,
threadId: bot.thread.id,
botId: bot.id,
type: "skill.teaching.started",
payload: { skillId: row.id, goal },
});
}
return mapTaughtSkill(withSnapshot);
},
async appendEvent(
actor: Actor,
skillId: string,
event: TeachRecordingEvent,
): Promise<TaughtSkill> {
await getOwnedSkill(deps, actor, skillId);
await expireTeachingSessionIfNeeded(deps, skillId);
const current = await deps.prisma.taughtSkill.findUniqueOrThrow({ where: { id: skillId } });
if (current.status !== "recording") {
throw new ORPCError("BAD_REQUEST", { message: "Teaching session is not recording" });
}
const updated = await appendRecordingEvent(deps, skillId, event, { requireRecording: true });
if (updated.status !== "recording") {
throw new ORPCError("BAD_REQUEST", { message: "Teaching session is not recording" });
}
return mapTaughtSkill(updated);
},
async snapshot(actor: Actor, skillId: string): Promise<TaughtSkill> {
await getOwnedSkill(deps, actor, skillId);
await expireTeachingSessionIfNeeded(deps, skillId);
const current = await deps.prisma.taughtSkill.findUniqueOrThrow({ where: { id: skillId } });
if (current.status !== "recording") {
throw new ORPCError("BAD_REQUEST", { message: "Teaching session is not recording" });
}
const bot = await deps.prisma.bot.findUnique({
where: { id: current.botId },
include: { computer: true },
});
if (!bot) throw new IsolationError();
const updated = await captureTeachingSnapshot(deps, actor, bot, current);
return mapTaughtSkill(updated);
},
stop: (actor: Actor, skillId: string) => stopTeachingSession(deps, actor, skillId),
async updateDraft(
actor: Actor,
skillId: string,
input: { name?: string; playbook: SkillPlaybook },
): Promise<TaughtSkill> {
const skill = await getOwnedSkill(deps, actor, skillId);
if (skill.status !== "draft" && skill.status !== "saved") {
throw new ORPCError("BAD_REQUEST", { message: "Skill is not editable yet" });
}
const row = await deps.prisma.taughtSkill.update({
where: { id: skill.id },
data: {
name: input.name ?? skill.name,
playbook: input.playbook as never,
status: skill.status === "saved" ? "saved" : "draft",
},
});
await updateSkillDraftMessage(deps, actor, row, {
name: row.name,
playbook: parsePlaybook(row.playbook),
status: row.status === "saved" ? "saved" : "draft",
});
return mapTaughtSkill(row);
},
async save(actor: Actor, skillId: string, name?: string): Promise<TaughtSkill> {
const skill = await getOwnedSkill(deps, actor, skillId);
if (skill.status !== "draft" && skill.status !== "saved") {
throw new ORPCError("BAD_REQUEST", { message: "Finish recording before saving" });
}
const row = await deps.prisma.taughtSkill.update({
where: { id: skill.id },
data: {
status: "saved",
name: name ?? (skill.name || skill.goal.slice(0, 80)),
},
});
const bot = await deps.prisma.bot.findUnique({
where: { id: row.botId },
include: { thread: true },
});
await updateSkillDraftMessage(deps, actor, row, {
name: row.name,
playbook: parsePlaybook(row.playbook),
status: "saved",
});
if (bot?.thread) {
await deps.events.append({
spaceId: actor.spaceId,
threadId: bot.thread.id,
botId: bot.id,
type: "skill.saved",
payload: { skillId: row.id, name: row.name },
});
}
return mapTaughtSkill(row);
},
async testRun(actor: Actor, skillId: string, prompt?: string): Promise<{ runId: string }> {
const skill = await getOwnedSkill(deps, actor, skillId);
if (skill.status !== "saved" && skill.status !== "draft") {
throw new ORPCError("BAD_REQUEST", { message: "Skill must be saved or drafted first" });
}
const bot = await deps.prisma.bot.findUnique({
where: { id: skill.botId },
include: { thread: true },
});
if (!bot?.thread) throw new IsolationError();
const playbook = parsePlaybook(skill.playbook);
const taskPrompt =
prompt ?? formatSkillRunPrompt(skill.name || skill.goal.slice(0, 80), playbook, true);
const task = await deps.prisma.task.create({
data: {
spaceId: actor.spaceId,
botId: bot.id,
threadId: bot.thread.id,
userId: actor.userId,
prompt: taskPrompt,
status: "queued",
},
});
const run = await deps.prisma.run.create({
data: {
spaceId: actor.spaceId,
botId: bot.id,
threadId: bot.thread.id,
taskId: task.id,
userId: actor.userId,
status: "queued",
trigger: "skill",
},
});
await deps.jobs.enqueue(runContinueJob(run.id));
return { runId: run.id };
},
async remove(actor: Actor, skillId: string): Promise<{ ok: true }> {
const skill = await getOwnedSkill(deps, actor, skillId);
if (skill.status === "recording") {
await deps.jobs.cancel(skillTeachingExpireJobKey(skill.id));
await releaseTeachingComputerControlForBot(
deps,
actor,
skill.botId,
parseRecording(skill.recording).controlLeaseId,
);
}
await deps.prisma.taughtSkill.delete({ where: { id: skill.id } });
return { ok: true as const };
},
expireTeachingSessionIfNeeded: (skillId: string) =>
expireTeachingSessionIfNeeded(deps, skillId),
async recordInput(
actor: Actor,
botId: string,
mapped: TeachComputerInput,
): Promise<"recorded" | "idle" | "stale"> {
return recordTeachingInputEvent(deps, actor, botId, mapped);
},
};
}

View File

@ -0,0 +1,515 @@
import type { PrismaClient } from "@rakazo/db";
import { describe, expect, it, vi } from "vitest";
import { isPeerRun, loadAllMessages, loadMessagePage } from "./thread-message-pages.js";
describe("thread message pages", () => {
it("caches peer-run classification for live events", async () => {
const findUnique = vi.fn(async () => ({ trigger: "bot_message" }));
const prisma = { run: { findUnique } } as unknown as PrismaClient;
const cache = new Map<string, Promise<boolean>>();
await expect(isPeerRun(prisma, "run-peer", cache)).resolves.toBe(true);
await expect(isPeerRun(prisma, "run-peer", cache)).resolves.toBe(true);
expect(findUnique).toHaveBeenCalledTimes(1);
});
it("keeps peer receipt rows when filtering peer-run output from pages", async () => {
const findMany = vi.fn(async () => [
{
id: "message-reply",
threadId: "thread-1",
seq: 3,
role: "bot",
blocks: [{ kind: "text", text: "Echoed peer reply" }],
botId: "bot-1",
replyToMessageId: null,
runId: "run-peer",
createdAt: new Date("2026-08-16T00:00:03.000Z"),
},
{
id: "message-received",
threadId: "thread-1",
seq: 2,
role: "user",
blocks: [
{
kind: "bot_message_received",
fromBotId: "bot-2",
fromBotName: "Coder",
text: "Done.",
},
],
botId: null,
replyToMessageId: null,
runId: "run-peer",
createdAt: new Date("2026-08-16T00:00:02.000Z"),
},
{
id: "message-user",
threadId: "thread-1",
seq: 1,
role: "bot",
blocks: [{ kind: "text", text: "Visible answer" }],
botId: "bot-1",
replyToMessageId: null,
runId: "run-user",
createdAt: new Date("2026-08-16T00:00:01.000Z"),
},
]);
const prisma = {
message: { findMany },
run: { findMany: vi.fn(async () => [{ id: "run-peer" }]) },
} as unknown as PrismaClient;
const page = await loadMessagePage(prisma, "thread-1", undefined, 3);
expect(page.messages.map((message) => message.id)).toEqual([
"message-user",
"message-received",
]);
});
it("filters peer-run output when its receipt is outside the loaded page", async () => {
const findMany = vi.fn(async () => [
{
id: "message-peer",
threadId: "thread-1",
seq: 2,
role: "bot",
blocks: [{ kind: "text", text: "Echoed peer reply" }],
botId: "bot-1",
replyToMessageId: null,
runId: "run-peer",
createdAt: new Date("2026-08-16T00:00:02.000Z"),
},
{
id: "message-user",
threadId: "thread-1",
seq: 1,
role: "bot",
blocks: [{ kind: "text", text: "Visible answer" }],
botId: "bot-1",
replyToMessageId: null,
runId: "run-user",
createdAt: new Date("2026-08-16T00:00:01.000Z"),
},
]);
const prisma = {
message: { findMany },
run: { findMany: vi.fn(async () => [{ id: "run-peer" }]) },
} as unknown as PrismaClient;
const page = await loadMessagePage(prisma, "thread-1", undefined, 2);
expect(page.messages.map((message) => message.id)).toEqual(["message-user"]);
});
it("omits peer around-page targets from the normal transcript", async () => {
const findMany = vi.fn(async () => [
{
id: "message-user",
threadId: "thread-1",
seq: 4,
role: "bot",
blocks: [{ kind: "text", text: "Visible answer" }],
botId: "bot-1",
replyToMessageId: null,
runId: "run-user",
createdAt: new Date("2026-08-16T00:00:04.000Z"),
},
{
id: "message-peer-activity",
threadId: "thread-1",
seq: 5,
role: "bot",
blocks: [{ kind: "steps", steps: [{ label: "Message bot", count: 1 }] }],
botId: "bot-1",
replyToMessageId: null,
runId: "run-peer",
createdAt: new Date("2026-08-16T00:00:05.000Z"),
},
{
id: "message-peer-target",
threadId: "thread-1",
seq: 6,
role: "bot",
blocks: [{ kind: "text", text: "Peer reply" }],
botId: "bot-1",
replyToMessageId: null,
runId: "run-peer",
createdAt: new Date("2026-08-16T00:00:06.000Z"),
},
]);
const count = vi.fn(async () => 1);
const runFindMany = vi.fn(async () => [{ id: "run-peer" }]);
const prisma = {
message: { findMany, count },
run: { findMany: runFindMany },
} as unknown as PrismaClient;
const page = await loadMessagePage(prisma, "thread-1", undefined, 4, {
messageId: "message-peer-target",
seq: 6,
});
expect(page.messages.map((message) => message.id)).toEqual(["message-user"]);
expect(runFindMany).toHaveBeenCalled();
});
it("keeps peer receipt around-page targets in the normal transcript page", async () => {
const findMany = vi.fn(async () => [
{
id: "message-user",
threadId: "thread-1",
seq: 4,
role: "bot",
blocks: [{ kind: "text", text: "Visible answer" }],
botId: "bot-1",
replyToMessageId: null,
runId: "run-user",
createdAt: new Date("2026-08-16T00:00:04.000Z"),
},
{
id: "message-peer-receipt",
threadId: "thread-1",
seq: 5,
role: "user",
blocks: [
{
kind: "bot_message_received",
fromBotId: "bot-2",
fromBotName: "Coder",
text: "Done.",
},
],
botId: null,
replyToMessageId: null,
runId: "run-peer",
createdAt: new Date("2026-08-16T00:00:05.000Z"),
},
{
id: "message-peer-text",
threadId: "thread-1",
seq: 6,
role: "bot",
blocks: [{ kind: "text", text: "Peer reply" }],
botId: "bot-1",
replyToMessageId: null,
runId: "run-peer",
createdAt: new Date("2026-08-16T00:00:06.000Z"),
},
]);
const count = vi.fn(async () => 0);
const prisma = {
message: { findMany, count },
run: { findMany: vi.fn(async () => [{ id: "run-peer" }]) },
} as unknown as PrismaClient;
const page = await loadMessagePage(prisma, "thread-1", undefined, 4, {
messageId: "message-peer-receipt",
seq: 5,
});
expect(page.messages.map((message) => message.id)).toEqual([
"message-user",
"message-peer-receipt",
]);
});
it("returns peer-run output for the dedicated bot messages view", async () => {
const findMany = vi.fn(async () => [
{
id: "message-peer",
threadId: "thread-1",
seq: 1,
role: "bot",
blocks: [{ kind: "text", text: "Peer reply" }],
botId: "bot-1",
replyToMessageId: null,
runId: "run-peer",
createdAt: new Date("2026-08-16T00:00:01.000Z"),
},
]);
const prisma = {
message: { findMany, count: vi.fn(async () => 0) },
} as unknown as PrismaClient;
const page = await loadMessagePage(
prisma,
"thread-1",
undefined,
2,
{ messageId: "message-peer", seq: 1 },
true,
);
expect(page.messages.map((message) => message.id)).toEqual(["message-peer"]);
});
it("scans past a page containing only peer-run output", async () => {
const row = (seq: number, runId: string) => ({
id: `message-${seq}`,
threadId: "thread-1",
seq,
role: "bot",
blocks: [{ kind: "text", text: String(seq) }],
botId: "bot-1",
replyToMessageId: null,
runId,
createdAt: new Date("2026-08-16T00:00:00.000Z"),
});
const findMany = vi
.fn()
.mockResolvedValueOnce([row(4, "run-peer"), row(3, "run-peer"), row(2, "run-peer")])
.mockResolvedValueOnce([row(1, "run-user")]);
const prisma = {
message: { findMany },
run: {
findMany: vi
.fn()
.mockResolvedValueOnce([{ id: "run-peer" }])
.mockResolvedValueOnce([]),
},
} as unknown as PrismaClient;
const page = await loadMessagePage(prisma, "thread-1", undefined, 2);
expect(page.messages.map((message) => message.id)).toEqual(["message-1"]);
expect(findMany).toHaveBeenCalledTimes(2);
});
it("scans past a receipt-only page so web can reach older user-visible rows", async () => {
const receiptRows = [
{
id: "message-receipt-b",
threadId: "thread-1",
seq: 3,
role: "user",
blocks: [
{
kind: "bot_message_received",
fromBotId: "bot-2",
fromBotName: "Coder",
text: "Done.",
},
],
botId: null,
replyToMessageId: null,
runId: "run-peer",
createdAt: new Date("2026-08-16T00:00:03.000Z"),
},
{
id: "message-receipt-a",
threadId: "thread-1",
seq: 2,
role: "user",
blocks: [
{
kind: "bot_message_sent",
toBotId: "bot-2",
toBotName: "Coder",
text: "Check this.",
},
],
botId: null,
replyToMessageId: null,
runId: "run-user",
createdAt: new Date("2026-08-16T00:00:02.000Z"),
},
{
id: "message-lookahead",
threadId: "thread-1",
seq: 1,
role: "bot",
blocks: [{ kind: "text", text: "Older visible answer" }],
botId: "bot-1",
replyToMessageId: null,
runId: "run-user",
createdAt: new Date("2026-08-16T00:00:01.000Z"),
},
];
const olderRows = [
{
id: "message-user",
threadId: "thread-1",
seq: 1,
role: "bot",
blocks: [{ kind: "text", text: "Older visible answer" }],
botId: "bot-1",
replyToMessageId: null,
runId: "run-user",
createdAt: new Date("2026-08-16T00:00:01.000Z"),
},
];
const findMany = vi.fn().mockResolvedValueOnce(receiptRows).mockResolvedValueOnce(olderRows);
const prisma = {
message: { findMany },
run: {
findMany: vi
.fn()
.mockResolvedValueOnce([{ id: "run-peer" }])
.mockResolvedValueOnce([]),
},
} as unknown as PrismaClient;
const page = await loadMessagePage(prisma, "thread-1", undefined, 2);
expect(page.messages.map((message) => message.id)).toEqual(["message-user"]);
expect(findMany).toHaveBeenCalledTimes(2);
});
it("returns a receipt-only page when the client displays peer receipts", async () => {
const receipt = (seq: number) => ({
id: `message-receipt-${seq}`,
threadId: "thread-1",
seq,
role: "user",
blocks: [
{
kind: "bot_message_received",
fromBotId: "bot-2",
fromBotName: "Coder",
text: "Done.",
},
],
botId: null,
replyToMessageId: null,
runId: "run-peer",
createdAt: new Date(`2026-08-16T00:00:0${seq}.000Z`),
});
const findMany = vi.fn(async () => [receipt(3), receipt(2), receipt(1)]);
const prisma = {
message: { findMany },
run: { findMany: vi.fn(async () => [{ id: "run-peer" }]) },
} as unknown as PrismaClient;
const page = await loadMessagePage(prisma, "thread-1", undefined, 2, undefined, false, true);
expect(page.messages.map((message) => message.id)).toEqual([
"message-receipt-2",
"message-receipt-3",
]);
expect(page.olderCursor).toBe(2);
expect(findMany).toHaveBeenCalledTimes(1);
});
it("queries before the cursor and returns an ascending bounded page", async () => {
const findMany = vi.fn(async () =>
[5, 4, 3].map((seq) => ({
id: `message-${seq}`,
threadId: "thread-1",
seq,
role: "bot",
blocks: [{ kind: "text", text: String(seq) }],
runId: null,
thumbsUp: seq === 4,
createdAt: new Date(`2026-08-16T00:00:0${seq}.000Z`),
})),
);
const prisma = { message: { findMany } } as unknown as PrismaClient;
const page = await loadMessagePage(prisma, "thread-1", 6, 2);
expect(findMany).toHaveBeenCalledWith({
where: { threadId: "thread-1", seq: { lt: 6 } },
orderBy: { seq: "desc" },
take: 3,
});
expect(page.messages.map((message) => message.seq)).toEqual([4, 5]);
expect(page.messages[0]?.thumbsUp).toBe(true);
expect(page.olderCursor).toBe(4);
});
it("ends pagination when the database returns no lookahead row", async () => {
const findMany = vi.fn(async () => [
{
id: "message-0",
threadId: "thread-1",
seq: 0,
role: "user",
blocks: [],
runId: null,
createdAt: new Date("2026-08-16T00:00:00.000Z"),
},
]);
const prisma = { message: { findMany } } as unknown as PrismaClient;
const page = await loadMessagePage(prisma, "thread-1", 1, 2);
expect(page.messages.map((message) => message.seq)).toEqual([0]);
expect(page.olderCursor).toBeNull();
});
it("loads a page around a target sequence", async () => {
const findFirst = vi.fn(async () => ({ seq: 5 }));
const findMany = vi
.fn()
.mockResolvedValueOnce([
{
id: "message-3",
threadId: "thread-1",
seq: 3,
role: "bot",
blocks: [],
runId: null,
createdAt: new Date(),
},
{
id: "message-4",
threadId: "thread-1",
seq: 4,
role: "bot",
blocks: [],
runId: null,
createdAt: new Date(),
},
{
id: "message-5",
threadId: "thread-1",
seq: 5,
role: "bot",
blocks: [],
runId: null,
createdAt: new Date(),
},
])
.mockResolvedValueOnce(1);
const count = vi.fn(async () => 1);
const prisma = {
message: { findFirst, findMany, count },
} as unknown as PrismaClient;
const page = await loadMessagePage(prisma, "thread-1", undefined, 4, { seq: 5 });
expect(page.messages.map((message) => message.seq)).toEqual([3, 4, 5]);
expect(page.olderCursor).toBe(3);
expect(findMany).toHaveBeenCalledWith({
where: { threadId: "thread-1", seq: { gte: 3, lte: 7 } },
orderBy: { seq: "asc" },
take: 4,
});
});
it("collects bounded pages into chronological export order", async () => {
const row = (seq: number) => ({
id: `message-${seq}`,
threadId: "thread-1",
seq,
role: "bot",
blocks: [],
runId: null,
createdAt: new Date("2026-08-16T00:00:00.000Z"),
});
const findMany = vi
.fn()
.mockResolvedValueOnce([row(4), row(3), row(2)])
.mockResolvedValueOnce([row(2), row(1), row(0)])
.mockResolvedValueOnce([row(0)]);
const prisma = { message: { findMany } } as unknown as PrismaClient;
const messages = await loadAllMessages(prisma, "thread-1", 2);
expect(messages.map((message) => message.seq)).toEqual([0, 1, 2, 3, 4]);
expect(findMany.mock.calls.map(([query]) => query.where.seq?.lt)).toEqual([undefined, 3, 1]);
});
});

View File

@ -0,0 +1,157 @@
import type { MessageBlock, ThreadMessage, ThreadMessagePage } from "@rakazo/contracts";
import { isPeerReceiptBlocks } from "@rakazo/core";
import type { Prisma, PrismaClient } from "@rakazo/db";
type MessageDb = PrismaClient | Prisma.TransactionClient;
export async function loadMessagePage(
prisma: MessageDb,
threadId: string,
before: number | undefined,
pageSize: number,
around?: { messageId?: string; seq?: number },
includePeerRuns = false,
includePeerReceipts = false,
): Promise<ThreadMessagePage> {
if (around) {
let targetSeq = around.seq;
if (targetSeq === undefined && around.messageId) {
const row = await prisma.message.findFirst({
where: { id: around.messageId, threadId },
select: { seq: true },
});
targetSeq = row?.seq;
}
if (targetSeq !== undefined) {
const half = Math.floor(pageSize / 2);
const minSeq = Math.max(0, targetSeq - half);
const maxSeq = targetSeq + half;
const rows = await prisma.message.findMany({
where: { threadId, seq: { gte: minSeq, lte: maxSeq } },
orderBy: { seq: "asc" },
take: pageSize,
});
const first = rows[0];
const hasOlder = first
? (await prisma.message.count({ where: { threadId, seq: { lt: first.seq } } })) > 0
: false;
// Peer text/activity stays out of the normal transcript (including the
// around target). Receipts remain via withoutPeerRunMessages; full peer
// history belongs in the bot-messages overlay (includePeerRuns).
const messages = includePeerRuns ? rows : await withoutPeerRunMessages(prisma, rows);
return {
threadId,
messages: messages.map(toThreadMessage),
olderCursor: hasOlder ? (first?.seq ?? null) : null,
};
}
}
let cursor = before;
while (true) {
const rows = await prisma.message.findMany({
where: {
threadId,
...(cursor === undefined ? {} : { seq: { lt: cursor } }),
},
orderBy: { seq: "desc" },
take: pageSize + 1,
});
const hasOlder = rows.length > pageSize;
const pageRows = rows.slice(0, pageSize).reverse();
const visibleRows = includePeerRuns ? pageRows : await withoutPeerRunMessages(prisma, pageRows);
// Web hides receipts client-side, so its receipt-only pages keep scanning.
// Mobile explicitly retains them and must receive each page for pagination.
const hasSubstantive = visibleRows.some(
(row) => !isPeerReceiptBlocks(row.blocks as MessageBlock[]),
);
if (hasSubstantive || includePeerReceipts || !hasOlder || includePeerRuns) {
return {
threadId,
messages: visibleRows.map(toThreadMessage),
olderCursor: hasOlder ? (pageRows[0]?.seq ?? null) : null,
};
}
// TODO: only rescan when a raw page is entirely peer output. Consider a run relation if
// long peer-only histories make this path hot.
cursor = pageRows[0]?.seq;
}
}
export async function loadAllMessages(
prisma: PrismaClient,
threadId: string,
pageSize: number,
): Promise<ThreadMessage[]> {
const pages: ThreadMessage[][] = [];
let before: number | undefined;
do {
const page = await loadMessagePage(prisma, threadId, before, pageSize, undefined, true);
pages.push(page.messages);
before = page.olderCursor ?? undefined;
} while (before !== undefined);
return pages.reverse().flat();
}
async function withoutPeerRunMessages<T extends { runId: string | null; blocks: Prisma.JsonValue }>(
prisma: MessageDb,
rows: T[],
): Promise<T[]> {
const runIds = [...new Set(rows.flatMap((row) => (row.runId ? [row.runId] : [])))];
if (runIds.length === 0) return rows;
const peerRuns = await prisma.run.findMany({
where: { id: { in: runIds }, trigger: "bot_message" },
select: { id: true },
});
const peerRunIds = new Set(peerRuns.map((run) => run.id));
return rows.filter((row) => {
if (!row.runId || !peerRunIds.has(row.runId)) return true;
// Keep compact sent/received receipts; clients render them as chips.
const blocks = row.blocks as MessageBlock[];
return blocks.some(
(block) => block.kind === "bot_message_sent" || block.kind === "bot_message_received",
);
});
}
export async function isPeerRun(
prisma: MessageDb,
runId: string | undefined,
cache: Map<string, Promise<boolean>>,
): Promise<boolean> {
if (!runId) return false;
let peerRun = cache.get(runId);
if (!peerRun) {
peerRun = prisma.run
.findUnique({ where: { id: runId }, select: { trigger: true } })
.then((run) => run?.trigger === "bot_message");
cache.set(runId, peerRun);
}
return peerRun;
}
function toThreadMessage(row: {
id: string;
threadId: string;
seq: number;
role: string;
blocks: Prisma.JsonValue;
botId: string | null;
replyToMessageId: string | null;
runId: string | null;
thumbsUp: boolean;
createdAt: Date;
}): ThreadMessage {
return {
id: row.id,
threadId: row.threadId,
seq: row.seq,
role: row.role as ThreadMessage["role"],
blocks: row.blocks as ThreadMessage["blocks"],
botId: row.botId ?? undefined,
replyToMessageId: row.replyToMessageId ?? undefined,
runId: row.runId ?? undefined,
thumbsUp: row.thumbsUp,
createdAt: row.createdAt.toISOString(),
};
}

View File

@ -0,0 +1,820 @@
import type { SandboxProvider } from "@rakazo/adapter-kit";
import type { Actor } from "@rakazo/contracts";
import type { PrismaClient } from "@rakazo/db";
import { describe, expect, it, vi } from "vitest";
import {
cancelSupersededQueuedRuns,
reactToThreadMessage,
stopThreadRuns,
type ThreadTarget,
threadHead,
threadSnapshot,
} from "./thread-target.js";
describe("threadHead", () => {
it("returns the durable cursor without loading a snapshot", async () => {
const findFirst = vi.fn().mockResolvedValue({ seq: 12 });
const prisma = { event: { findFirst } } as unknown as PrismaClient;
const target = { threadId: "thread-1" } as ThreadTarget;
await expect(threadHead(prisma, target)).resolves.toEqual({
threadId: "thread-1",
cursor: 12,
});
expect(findFirst).toHaveBeenCalledWith({
where: { threadId: "thread-1" },
orderBy: { seq: "desc" },
select: { seq: true },
});
});
});
describe("queued run supersession", () => {
it("only cancels queued runs started by user messages or reactions", async () => {
const tx = {
run: {
findMany: vi.fn().mockResolvedValue([{ id: "run-old", taskId: "task-old" }]),
updateMany: vi.fn(),
},
task: { updateMany: vi.fn() },
};
await cancelSupersededQueuedRuns(tx as never, {
threadId: "thread-1",
botIds: ["bot-1"],
keepRunIds: ["run-new"],
});
expect(tx.run.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
OR: [{ trigger: "user", sourceMessage: { role: "user" } }, { trigger: "reaction" }],
}),
}),
);
expect(tx.run.updateMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { id: { in: ["run-old"] } } }),
);
expect(tx.task.updateMany).toHaveBeenCalledWith({
where: { id: { in: ["task-old"] } },
data: { status: "cancelled" },
});
});
});
describe("message thumbs-up", () => {
it("wakes once on add and not on replay or removal", async () => {
let thumbsUp = false;
let busy = false;
let eventSeq = 0;
const tx = {
$queryRaw: vi.fn(async () => [
{ id: "message-1", role: "bot", blocks: [{ kind: "text", text: "Done" }], thumbsUp },
]),
message: {
update: vi.fn(async ({ data }: { data: { thumbsUp: boolean } }) => {
thumbsUp = data.thumbsUp;
return { id: "message-1" };
}),
},
run: {
findFirst: vi.fn(async () => (busy ? { id: "run-active" } : null)),
create: vi.fn().mockResolvedValue({ id: "run-1", status: "queued" }),
findUnique: vi.fn().mockResolvedValue({ status: "queued" }),
},
task: { create: vi.fn().mockResolvedValue({ id: "task-1" }) },
thread: {
update: vi.fn(async () => ({ nextEventSeq: ++eventSeq })),
},
event: {
create: vi.fn(async ({ data }: { data: Record<string, unknown> }) => ({
id: `event-${eventSeq}`,
createdAt: new Date(),
...data,
})),
},
};
const prisma = {
$transaction: vi.fn(async (callback: (client: typeof tx) => unknown) => callback(tx)),
} as unknown as PrismaClient;
const actor = { spaceId: "workspace-1", userId: "user-1" } as Actor;
const target = {
kind: "bot",
botId: "bot-1",
threadId: "thread-1",
bot: { computer: null },
} as ThreadTarget;
await expect(
reactToThreadMessage({ prisma }, actor, target, "message-1", true),
).resolves.toEqual(expect.objectContaining({ changed: true, runId: "run-1" }));
await expect(
reactToThreadMessage({ prisma }, actor, target, "message-1", true),
).resolves.toEqual(expect.objectContaining({ changed: false, runId: null }));
await expect(
reactToThreadMessage({ prisma }, actor, target, "message-1", false),
).resolves.toEqual(expect.objectContaining({ changed: true, runId: null }));
busy = true;
await expect(
reactToThreadMessage({ prisma }, actor, target, "message-1", true),
).resolves.toEqual(expect.objectContaining({ changed: true, runId: null }));
expect(tx.task.create).toHaveBeenCalledOnce();
expect(tx.run.create).toHaveBeenCalledOnce();
expect(String(tx.$queryRaw.mock.calls[0]?.[0])).toContain("SELECT id FROM threads");
expect(String(tx.$queryRaw.mock.calls[0]?.[0])).toContain("FOR UPDATE");
expect(String(tx.$queryRaw.mock.calls[1]?.[0])).toContain(
'SELECT id, "thumbsUp" FROM messages',
);
expect(String(tx.$queryRaw.mock.calls[1]?.[0])).toContain("FOR UPDATE");
expect(tx.run.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ sourceMessageId: "message-1", trigger: "reaction" }),
}),
);
expect(tx.event.create).toHaveBeenCalledTimes(3);
expect(tx.event.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
type: "thread.message.reaction",
payload: { messageId: "message-1", thumbsUp: true },
}),
}),
);
expect(thumbsUp).toBe(true);
});
});
describe("threadSnapshot", () => {
it("reloads tool-only live messages for an active run", async () => {
const run = {
id: "run-1",
botId: "bot-1",
threadId: "thread-1",
taskId: "task-1",
status: "running",
trigger: "user",
modelProvider: null,
modelId: null,
error: null,
startedAt: null,
completedAt: null,
createdAt: new Date("2026-08-23T00:00:00.000Z"),
};
const findManyEvents = vi.fn().mockResolvedValue([
{
id: "event-1",
threadId: "thread-1",
botId: "bot-1",
seq: 4,
type: "agent.tool.called",
runId: "run-1",
payload: { name: "SLACK_FIND_CHANNELS" },
createdAt: new Date("2026-08-23T00:00:00.000Z"),
},
]);
const tx = {
$queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]),
message: { findMany: vi.fn().mockResolvedValue([]) },
event: {
findFirst: vi.fn().mockResolvedValue({ seq: 4 }),
findMany: findManyEvents,
},
run: { findFirst: vi.fn().mockResolvedValue(run) },
};
const prisma = {
$transaction: vi.fn(async (callback: (client: typeof tx) => unknown) => callback(tx)),
} as unknown as PrismaClient;
const target = {
kind: "bot",
botId: "bot-1",
threadId: "thread-1",
bot: { computer: null },
} as ThreadTarget;
const snapshot = await threadSnapshot({ prisma }, target);
expect(tx.$queryRaw).toHaveBeenCalledOnce();
expect(findManyEvents).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
type: { in: ["thread.progress", "thread.subagent", "agent.tool.called"] },
}),
}),
);
expect(snapshot.messages).toEqual([
expect.objectContaining({
id: "progress:run-1",
botId: "bot-1",
blocks: [
{
kind: "steps",
steps: [{ label: "Slack find channels", count: 1 }],
},
],
}),
]);
});
it("returns the latest failed run so the client can show its error", async () => {
const run = {
id: "run-failed",
botId: "bot-1",
threadId: "thread-1",
taskId: "task-1",
status: "failed",
trigger: "user",
modelProvider: "openrouter",
modelId: "openrouter/unknown",
error: "Provider is not configured: openrouter",
startedAt: null,
completedAt: new Date("2026-08-23T00:00:01.000Z"),
createdAt: new Date("2026-08-23T00:00:00.000Z"),
};
const findManyEvents = vi.fn();
const findFirstRun = vi
.fn()
.mockResolvedValueOnce(run)
// The failure is itself the newest terminal run, so it stays visible.
.mockResolvedValueOnce({ id: run.id });
const tx = {
$queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]),
message: { findMany: vi.fn().mockResolvedValue([]) },
event: {
findFirst: vi.fn().mockResolvedValue(null),
findMany: findManyEvents,
},
run: { findFirst: findFirstRun },
};
const prisma = {
$transaction: vi.fn(async (callback: (client: typeof tx) => unknown) => callback(tx)),
} as unknown as PrismaClient;
const target = {
kind: "bot",
botId: "bot-1",
threadId: "thread-1",
bot: { computer: null },
} as ThreadTarget;
const snapshot = await threadSnapshot({ prisma }, target);
expect(findFirstRun).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
botId: "bot-1",
threadId: "thread-1",
trigger: { not: "bot_message" },
status: {
in: ["queued", "leased", "running", "waiting_input", "waiting_takeover", "failed"],
},
}),
}),
);
expect(snapshot.run).toEqual(
expect.objectContaining({
id: "run-failed",
status: "failed",
error: "Provider is not configured: openrouter",
}),
);
expect(findManyEvents).not.toHaveBeenCalled();
});
it("drops a failed run once a newer run has finished", async () => {
const failed = {
id: "run-failed",
botId: "bot-1",
threadId: "thread-1",
taskId: "task-1",
status: "failed",
trigger: "user",
modelProvider: "openrouter",
modelId: "openrouter/unknown",
error: "This operation was aborted",
startedAt: null,
completedAt: new Date("2026-08-23T00:00:01.000Z"),
createdAt: new Date("2026-08-23T00:00:00.000Z"),
};
const findFirstRun = vi
.fn()
.mockResolvedValueOnce(failed)
// The supersession probe finds a newer completed run.
.mockResolvedValueOnce({ id: "run-completed" });
const tx = {
$queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]),
message: { findMany: vi.fn().mockResolvedValue([]) },
event: {
findFirst: vi.fn().mockResolvedValue(null),
findMany: vi.fn(),
},
run: { findFirst: findFirstRun },
};
const prisma = {
$transaction: vi.fn(async (callback: (client: typeof tx) => unknown) => callback(tx)),
} as unknown as PrismaClient;
const target = {
kind: "bot",
botId: "bot-1",
threadId: "thread-1",
bot: { computer: null },
} as ThreadTarget;
const snapshot = await threadSnapshot({ prisma }, target);
expect(findFirstRun).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
where: expect.objectContaining({
trigger: { not: "bot_message" },
status: { in: ["failed", "completed", "cancelled"] },
}),
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
}),
);
expect(snapshot.run).toBeNull();
});
it("does not return a cancelled or completed run", async () => {
const findManyEvents = vi.fn();
const findFirstRun = vi.fn().mockResolvedValue(null);
const tx = {
$queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]),
message: { findMany: vi.fn().mockResolvedValue([]) },
event: {
findFirst: vi.fn().mockResolvedValue(null),
findMany: findManyEvents,
},
run: { findFirst: findFirstRun },
};
const prisma = {
$transaction: vi.fn(async (callback: (client: typeof tx) => unknown) => callback(tx)),
} as unknown as PrismaClient;
const target = {
kind: "bot",
botId: "bot-1",
threadId: "thread-1",
bot: { computer: null },
} as ThreadTarget;
const snapshot = await threadSnapshot({ prisma }, target);
expect(findFirstRun).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
status: {
in: ["queued", "leased", "running", "waiting_input", "waiting_takeover", "failed"],
},
}),
}),
);
expect(snapshot.run).toBeNull();
expect(findManyEvents).not.toHaveBeenCalled();
});
it("returns a group's latest failed run so a refresh keeps its error", async () => {
const run = {
id: "run-failed",
botId: "bot-2",
threadId: "thread-1",
taskId: "task-1",
status: "failed",
trigger: "user",
modelProvider: "openrouter",
modelId: "openrouter/unknown",
error: "member exploded",
startedAt: null,
completedAt: new Date("2026-08-23T00:00:01.000Z"),
createdAt: new Date("2026-08-23T00:00:00.000Z"),
};
const findManyRuns = groupRunFindMany({ terminals: [run] });
const snapshot = await threadSnapshot({ prisma: groupPrisma(findManyRuns) }, groupTarget());
expect(findManyRuns).toHaveBeenCalledWith(
expect.objectContaining({
where: {
threadId: "thread-1",
trigger: { not: "bot_message" },
status: { in: ["failed", "completed", "cancelled"] },
},
orderBy: [{ updatedAt: "desc" }, { id: "desc" }],
take: 50,
}),
);
expect(findManyRuns).toHaveBeenCalledWith(
expect.objectContaining({
where: {
threadId: "thread-1",
trigger: { not: "bot_message" },
status: { in: ["queued", "leased", "running", "waiting_input", "waiting_takeover"] },
},
}),
);
expect(snapshot.run).toEqual(
expect.objectContaining({ id: "run-failed", status: "failed", error: "member exploded" }),
);
expect(snapshot.activeRuns).toEqual([]);
});
it("omits peer bot_message runs from group activeRuns and displayed terminal run", async () => {
const peerActive = {
id: "run-peer-active",
botId: "bot-a",
threadId: "thread-1",
taskId: "task-peer",
status: "running",
trigger: "bot_message",
modelProvider: null,
modelId: null,
error: null,
startedAt: new Date("2026-08-23T00:00:05.000Z"),
completedAt: null,
createdAt: new Date("2026-08-23T00:00:05.000Z"),
};
const peerFailed = {
id: "run-peer-failed",
botId: "bot-b",
threadId: "thread-1",
taskId: "task-peer-fail",
status: "failed",
trigger: "bot_message",
modelProvider: null,
modelId: null,
error: "peer exploded",
startedAt: new Date("2026-08-23T00:00:01.000Z"),
completedAt: new Date("2026-08-23T00:00:02.000Z"),
createdAt: new Date("2026-08-23T00:00:01.000Z"),
};
const findManyRuns = groupRunFindMany({
active: [peerActive],
terminals: [peerFailed],
});
const snapshot = await threadSnapshot({ prisma: groupPrisma(findManyRuns) }, groupTarget());
expect(snapshot.activeRuns).toEqual([]);
expect(snapshot.run).toBeNull();
expect(findManyRuns).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
trigger: { not: "bot_message" },
status: { in: ["queued", "leased", "running", "waiting_input", "waiting_takeover"] },
}),
}),
);
expect(findManyRuns).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
trigger: { not: "bot_message" },
status: { in: ["failed", "completed", "cancelled"] },
}),
}),
);
});
it("does not revive an older group failure after a newer run completed", async () => {
const failed = {
id: "run-old-failed",
botId: "bot-2",
threadId: "thread-1",
taskId: "task-1",
status: "failed",
trigger: "user",
modelProvider: null,
modelId: null,
error: "old failure",
startedAt: null,
completedAt: new Date("2026-08-23T00:00:01.000Z"),
createdAt: new Date("2026-08-23T00:00:00.000Z"),
};
const completed = {
id: "run-newer-completed",
botId: "bot-1",
threadId: "thread-1",
taskId: "task-2",
status: "completed",
trigger: "user",
modelProvider: null,
modelId: null,
error: null,
startedAt: new Date("2026-08-23T00:00:02.000Z"),
completedAt: new Date("2026-08-23T00:00:04.000Z"),
createdAt: new Date("2026-08-23T00:00:02.000Z"),
};
const snapshot = await threadSnapshot(
{ prisma: groupPrisma(groupRunFindMany({ terminals: [completed, failed] })) },
groupTarget(),
);
expect(snapshot.run).toBeNull();
expect(snapshot.activeRuns).toEqual([]);
});
it("does not revive a failure when a newer cancelled run has null completedAt", async () => {
const failed = {
id: "run-old-failed",
botId: "bot-2",
threadId: "thread-1",
taskId: "task-1",
status: "failed",
trigger: "user",
modelProvider: null,
modelId: null,
error: "old failure",
startedAt: null,
completedAt: new Date("2026-08-23T00:00:01.000Z"),
createdAt: new Date("2026-08-23T00:00:00.000Z"),
};
const cancelled = {
id: "run-newer-cancelled",
botId: "bot-1",
threadId: "thread-1",
taskId: "task-2",
status: "cancelled",
trigger: "user",
modelProvider: null,
modelId: null,
error: null,
startedAt: new Date("2026-08-23T00:00:02.000Z"),
completedAt: null,
createdAt: new Date("2026-08-23T00:00:03.000Z"),
};
const snapshot = await threadSnapshot(
{ prisma: groupPrisma(groupRunFindMany({ terminals: [cancelled, failed] })) },
groupTarget(),
);
expect(snapshot.run).toBeNull();
});
it("prefers a timestamped terminal over an older failure with null completedAt", async () => {
const failed = {
id: "run-old-failed",
botId: "bot-2",
threadId: "thread-1",
taskId: "task-1",
status: "failed",
trigger: "user",
modelProvider: null,
modelId: null,
error: "old failure",
startedAt: null,
completedAt: null,
createdAt: new Date("2026-08-23T00:00:00.000Z"),
};
const completed = {
id: "run-completed",
botId: "bot-1",
threadId: "thread-1",
taskId: "task-2",
status: "completed",
trigger: "user",
modelProvider: null,
modelId: null,
error: null,
startedAt: new Date("2026-08-23T00:00:02.000Z"),
completedAt: new Date("2026-08-23T00:00:04.000Z"),
createdAt: new Date("2026-08-23T00:00:02.000Z"),
};
const snapshot = await threadSnapshot(
{ prisma: groupPrisma(groupRunFindMany({ terminals: [failed, completed] })) },
groupTarget(),
);
expect(snapshot.run).toBeNull();
});
it("clamps a long persisted group failure error on refresh", async () => {
const longError = "x".repeat(400);
const run = {
id: "run-failed",
botId: "bot-2",
threadId: "thread-1",
taskId: "task-1",
status: "failed",
trigger: "user",
modelProvider: "openrouter",
modelId: "openrouter/unknown",
error: longError,
startedAt: null,
completedAt: new Date("2026-08-23T00:00:01.000Z"),
createdAt: new Date("2026-08-23T00:00:00.000Z"),
};
const snapshot = await threadSnapshot(
{ prisma: groupPrisma(groupRunFindMany({ terminals: [run] })) },
groupTarget(),
);
expect(snapshot.run).toEqual(
expect.objectContaining({
id: "run-failed",
status: "failed",
error: `${"x".repeat(300)}`,
}),
);
});
it("keeps a concurrent member failure in run while another member is still active", async () => {
const active = {
id: "run-active",
botId: "bot-a",
threadId: "thread-1",
taskId: "task-a",
status: "running",
trigger: "user",
modelProvider: null,
modelId: null,
error: null,
startedAt: new Date("2026-08-23T00:00:00.000Z"),
completedAt: null,
createdAt: new Date("2026-08-23T00:00:00.000Z"),
};
const failed = {
id: "run-failed",
botId: "bot-b",
threadId: "thread-1",
taskId: "task-b",
status: "failed",
trigger: "user",
modelProvider: null,
modelId: null,
error: "member exploded",
startedAt: new Date("2026-08-23T00:00:01.000Z"),
completedAt: new Date("2026-08-23T00:00:02.000Z"),
createdAt: new Date("2026-08-23T00:00:01.000Z"),
};
const snapshot = await threadSnapshot(
{ prisma: groupPrisma(groupRunFindMany({ active: [active], terminals: [failed] })) },
groupTarget(),
);
expect(snapshot.run).toEqual(
expect.objectContaining({ id: "run-failed", status: "failed", error: "member exploded" }),
);
expect(snapshot.activeRuns).toEqual([
expect.objectContaining({ id: "run-active", status: "running" }),
]);
});
it("keeps a failure on refresh when another member starts after it", async () => {
const lateActive = {
id: "run-late",
botId: "bot-a",
threadId: "thread-1",
taskId: "task-a",
status: "running",
trigger: "user",
modelProvider: null,
modelId: null,
error: null,
startedAt: new Date("2026-08-23T00:00:03.000Z"),
completedAt: null,
createdAt: new Date("2026-08-23T00:00:03.000Z"),
};
const failed = {
id: "run-failed",
botId: "bot-b",
threadId: "thread-1",
taskId: "task-b",
status: "failed",
trigger: "user",
modelProvider: null,
modelId: null,
error: "member exploded",
startedAt: new Date("2026-08-23T00:00:01.000Z"),
completedAt: new Date("2026-08-23T00:00:02.000Z"),
createdAt: new Date("2026-08-23T00:00:01.000Z"),
};
const snapshot = await threadSnapshot(
{
prisma: groupPrisma(groupRunFindMany({ active: [lateActive], terminals: [failed] })),
},
groupTarget(),
);
expect(snapshot.run).toEqual(
expect.objectContaining({ id: "run-failed", status: "failed", error: "member exploded" }),
);
expect(snapshot.activeRuns).toEqual([
expect.objectContaining({ id: "run-late", status: "running" }),
]);
});
});
function isTerminalRunQuery(where: { status?: { in?: string[] } } | undefined) {
const statuses = where?.status?.in;
return Array.isArray(statuses) && statuses.includes("failed") && statuses.includes("completed");
}
function excludesPeerRuns(where: { trigger?: { not?: string } } | undefined) {
return where?.trigger?.not === "bot_message";
}
function groupRunFindMany(input: { active?: unknown[]; terminals?: unknown[] }) {
return vi
.fn()
.mockImplementation(
async (args: { where?: { status?: { in?: string[] }; trigger?: { not?: string } } }) => {
const rows = isTerminalRunQuery(args.where)
? (input.terminals ?? [])
: (input.active ?? []);
if (!excludesPeerRuns(args.where)) return rows;
return rows.filter((row) => (row as { trigger?: string }).trigger !== "bot_message");
},
);
}
function groupPrisma(findManyRuns: ReturnType<typeof groupRunFindMany>) {
const tx = {
$queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]),
message: { findMany: vi.fn().mockResolvedValue([]) },
event: {
findFirst: vi.fn().mockResolvedValue(null),
findMany: vi.fn().mockResolvedValue([]),
},
run: { findMany: findManyRuns },
};
return {
$transaction: vi.fn(async (callback: (client: typeof tx) => unknown) => callback(tx)),
} as unknown as PrismaClient;
}
function groupTarget() {
return {
kind: "group",
groupId: "group-1",
groupName: "Group",
members: [],
threadId: "thread-1",
} as unknown as ThreadTarget;
}
describe("stopThreadRuns", () => {
it("releases every active group member screen immediately", async () => {
const releaseScreen = vi.fn().mockResolvedValue(undefined);
const transaction = {
$queryRaw: vi.fn(),
run: {
findMany: vi.fn().mockResolvedValue([
{ id: "run-a", botId: "bot-a" },
{ id: "run-b", botId: "bot-b" },
]),
updateMany: vi.fn().mockResolvedValue({ count: 2 }),
},
steeringMessage: { deleteMany: vi.fn().mockResolvedValue({ count: 0 }) },
};
const prisma = {
$transaction: vi.fn(async (callback: (client: typeof transaction) => unknown) =>
callback(transaction),
),
computer: {
findMany: vi.fn().mockResolvedValue([
{
homeKey: "home-a",
kind: "fake",
providerRef: "computer-a",
executionBotId: "bot-a",
},
{
homeKey: "home-b",
kind: "fake",
providerRef: "computer-b",
executionBotId: "bot-b",
},
]),
updateMany: vi.fn().mockResolvedValue({ count: 2 }),
},
computerExecutionLease: { deleteMany: vi.fn().mockResolvedValue({ count: 2 }) },
event: { deleteMany: vi.fn().mockResolvedValue({ count: 0 }) },
} as unknown as PrismaClient;
const actor = {
spaceId: "workspace-1",
userId: "user-1",
} as Actor;
const target = {
kind: "group",
groupId: "group-1",
groupName: "Test group",
threadId: "thread-1",
members: [],
memberBotIds: ["bot-a", "bot-b"],
} satisfies ThreadTarget;
await stopThreadRuns(
{ prisma, sandbox: { releaseScreen } as unknown as SandboxProvider },
actor,
target,
);
expect(releaseScreen).toHaveBeenCalledTimes(2);
expect(releaseScreen).toHaveBeenCalledWith(
expect.objectContaining({ providerRef: "computer-a" }),
expect.objectContaining({ spaceId: "workspace-1", userId: "user-1", botId: "bot-a" }),
);
expect(releaseScreen).toHaveBeenCalledWith(
expect.objectContaining({ providerRef: "computer-b" }),
expect.objectContaining({ spaceId: "workspace-1", userId: "user-1", botId: "bot-b" }),
);
expect(prisma.computerExecutionLease.deleteMany).toHaveBeenCalledWith({
where: { runId: { in: ["run-a", "run-b"] } },
});
expect(prisma.computer.updateMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { executionRunId: { in: ["run-a", "run-b"] } } }),
);
});
});

View File

@ -0,0 +1,945 @@
import { type JobPublisher, runContinueJob } from "@rakazo/adapter-kit";
import { toComputerRef } from "@rakazo/adapters";
import {
type Actor,
GROUP_MEMBER_MIN,
type GroupMember,
parseAvatarShape,
type RunStatus,
type ThreadSnapshot,
} from "@rakazo/contracts";
import {
ACTIVE_RUN_STATUSES,
isActive,
projectMessages,
resolveGroupTargetBotIds,
runFailureError,
} from "@rakazo/core";
import {
appendEventInTransaction,
createGroupRepos,
createRepos,
createThreadMessageInTransaction,
IsolationError,
lockOwnedGroup,
type Prisma,
type PrismaClient,
type ThreadEvents,
touchGroupUpdatedAt,
} from "@rakazo/db";
import {
buildSendPrompt,
buildUserMessageBlocks,
resolveGroupSendAttachments,
resolveSendAttachments,
} from "./artifacts.js";
import { resolveBusyBotName, toComputerStatus } from "./computer-status.js";
import { withSerializableRetry } from "./serializable-retry.js";
import { loadMessagePage } from "./thread-message-pages.js";
export type ThreadTarget =
| {
kind: "bot";
botId: string;
threadId: string;
bot: Awaited<ReturnType<ReturnType<typeof createRepos>["getBot"]>>;
}
| {
kind: "group";
groupId: string;
threadId: string;
groupName: string;
members: GroupMember[];
memberBotIds: string[];
};
const THREAD_MESSAGE_PAGE_SIZE = 100;
const RUNS_NEEDING_CONTINUE = new Set(["queued"]);
type MentionTargetInput = string | { kind: "bot" | "group" | "routine" | "connector"; id: string };
function splitMentionTargets(mentions: MentionTargetInput[] | undefined) {
const botMentionIds = new Set<string>();
const groupMentionIds = new Set<string>();
const routineMentionIds = new Set<string>();
const connectorMentionIds = new Set<string>();
for (const mention of mentions ?? []) {
if (typeof mention === "string") {
botMentionIds.add(mention);
continue;
}
if (mention.kind === "bot") botMentionIds.add(mention.id);
if (mention.kind === "group") groupMentionIds.add(mention.id);
if (mention.kind === "routine") routineMentionIds.add(mention.id);
if (mention.kind === "connector") connectorMentionIds.add(mention.id);
}
return {
botMentionIds: [...botMentionIds],
groupMentionIds: [...groupMentionIds],
routineMentionIds: [...routineMentionIds],
connectorMentionIds: [...connectorMentionIds],
};
}
async function resolveOwnedConnectorDisplayNames(
tx: Prisma.TransactionClient,
actor: Actor,
connectionIds: string[],
) {
if (!connectionIds.length) return [];
const rows = await tx.connection.findMany({
where: {
id: { in: connectionIds },
spaceId: actor.spaceId,
userId: actor.userId,
status: "connected",
},
select: { id: true, displayName: true },
});
if (rows.length !== connectionIds.length) throw new IsolationError();
const byId = new Map(rows.map((row) => [row.id, row.displayName]));
return connectionIds.map((id) => byId.get(id) ?? "connector");
}
function sendRunClientNonce(
clientNonce: string | undefined,
messageId: string,
botId?: string,
): string | undefined {
if (!clientNonce) return undefined;
return botId ? `send:${messageId}:${botId}` : `send:${messageId}`;
}
async function enqueueRunsNeedingContinue(
jobs: JobPublisher,
runs: Array<{ id: string; status: string }>,
) {
await Promise.all(
runs
.filter((run) => RUNS_NEEDING_CONTINUE.has(run.status))
.map((run) =>
jobs.enqueue(runContinueJob(run.id)).catch((error) => {
// The queued run is durable; the reconciler repairs a missed immediate wake.
console.error("thread send enqueue", error);
}),
),
);
}
async function findSendReceipt(prisma: PrismaClient, threadId: string, clientNonce: string) {
return prisma.message.findUnique({
where: { threadId_clientNonce: { threadId, clientNonce } },
include: { sourceRuns: { orderBy: [{ createdAt: "asc" }, { id: "asc" }] } },
});
}
async function replayExistingSend(
deps: { prisma: PrismaClient; events: ThreadEvents; jobs: JobPublisher },
threadId: string,
clientNonce: string | undefined,
) {
if (!clientNonce) return null;
const message = await findSendReceipt(deps.prisma, threadId, clientNonce);
if (!message) return null;
const receiptEvent = await deps.prisma.event.findFirst({
where: {
threadId,
type: "thread.message.created",
payload: { path: ["messageId"], equals: message.id },
},
orderBy: { seq: "desc" },
select: { payload: true },
});
const receiptRunIds = sendEventRunIds(receiptEvent?.payload);
const receiptRuns = receiptRunIds.length
? await deps.prisma.run.findMany({ where: { id: { in: receiptRunIds } } })
: [];
const receiptRunById = new Map(receiptRuns.map((run) => [run.id, run]));
const orderedReceiptRuns = receiptRunIds.flatMap((id) => {
const run = receiptRunById.get(id);
return run ? [run] : [];
});
const linkedRun =
message.sourceRuns[0] ??
(message.runId ? await deps.prisma.run.findUnique({ where: { id: message.runId } }) : null);
if (!linkedRun && orderedReceiptRuns.length === 0) return null;
const runs = orderedReceiptRuns.length
? orderedReceiptRuns
: message.sourceRuns.length
? message.sourceRuns
: [linkedRun!];
await enqueueRunsNeedingContinue(deps.jobs, runs);
const latestEvent = await deps.prisma.event.findFirst({
where: { threadId },
orderBy: { seq: "desc" },
select: { seq: true },
});
if (latestEvent) {
await deps.events.notify(threadId, latestEvent.seq).catch((error) => {
// Subscribers catch up from the durable event cursor after a missed realtime wake.
console.error("thread send realtime notification", error);
});
}
return sendResult(message, runs);
}
function sendEventRunIds(payload: Prisma.JsonValue | undefined): string[] {
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return [];
const runIds = (payload as { runIds?: unknown }).runIds;
return Array.isArray(runIds) ? runIds.filter((id): id is string => typeof id === "string") : [];
}
function sendResult(message: { seq: number }, runs: Array<{ id: string; taskId: string }>) {
const first = runs[0];
if (!first) throw new IsolationError("Send did not create a run");
return {
taskId: first.taskId,
runId: first.id,
seq: message.seq,
runIds: runs.map((run) => run.id),
};
}
export async function cancelSupersededQueuedRuns(
tx: Prisma.TransactionClient,
input: { threadId: string; botIds: string[]; keepRunIds: string[] },
) {
const superseded = await tx.run.findMany({
where: {
threadId: input.threadId,
botId: { in: input.botIds },
status: "queued",
OR: [{ trigger: "user", sourceMessage: { role: "user" } }, { trigger: "reaction" }],
id: { notIn: input.keepRunIds },
},
select: { id: true, taskId: true },
});
if (superseded.length === 0) return;
const now = new Date();
await tx.run.updateMany({
where: { id: { in: superseded.map((run) => run.id) } },
data: { status: "cancelled", completedAt: now },
});
await tx.task.updateMany({
where: { id: { in: superseded.map((run) => run.taskId) } },
data: { status: "cancelled" },
});
}
async function lockAndLoadGroupMembers(
tx: Prisma.TransactionClient,
actor: Actor,
target: Extract<ThreadTarget, { kind: "group" }>,
) {
await lockOwnedGroup(tx, actor, target.groupId);
const group = await tx.chatGroup.findFirst({
where: {
id: target.groupId,
spaceId: actor.spaceId,
userId: actor.userId,
archivedAt: null,
thread: { id: target.threadId },
},
include: {
members: {
where: { bot: { archivedAt: null } },
include: {
bot: {
select: {
id: true,
name: true,
color: true,
avatarShape: true,
avatarImageArtifactId: true,
updatedAt: true,
},
},
},
orderBy: { createdAt: "asc" },
},
},
});
if (!group || group.members.length < GROUP_MEMBER_MIN) throw new IsolationError();
return group.members.map((member) => ({
botId: member.bot.id,
name: member.bot.name,
color: member.bot.color,
avatarShape: parseAvatarShape(member.bot.avatarShape),
hasAvatarImage: Boolean(member.bot.avatarImageArtifactId),
updatedAt: member.bot.updatedAt.toISOString(),
}));
}
export async function resolveThreadTarget(
prisma: PrismaClient,
actor: Actor,
input: { botId?: string; groupId?: string },
): Promise<ThreadTarget> {
const repos = createRepos(prisma);
const groupRepos = createGroupRepos(prisma);
if (input.botId) {
const bot = await repos.getBot(actor, input.botId);
if (!bot.thread) throw new IsolationError();
return {
kind: "bot",
botId: bot.id,
threadId: bot.thread.id,
bot,
};
}
if (input.groupId) {
const group = await groupRepos.getGroupTarget(actor, input.groupId);
if (!group.thread) throw new IsolationError();
const members = group.members.map((member) => ({
botId: member.bot.id,
name: member.bot.name,
color: member.bot.color,
status: member.bot.runs[0]?.status ?? "idle",
avatarShape: parseAvatarShape(member.bot.avatarShape),
hasAvatarImage: Boolean(member.bot.avatarImageArtifactId),
updatedAt: member.bot.updatedAt.toISOString(),
}));
return {
kind: "group",
groupId: group.id,
threadId: group.thread.id,
groupName: group.name,
members,
memberBotIds: members.map((member) => member.botId),
};
}
throw new IsolationError();
}
export async function threadHead(prisma: PrismaClient, target: ThreadTarget) {
const latest = await prisma.event.findFirst({
where: { threadId: target.threadId },
orderBy: { seq: "desc" },
select: { seq: true },
});
return { threadId: target.threadId, cursor: latest?.seq ?? -1 };
}
export async function threadSnapshot(
deps: { prisma: PrismaClient },
target: ThreadTarget,
): Promise<ThreadSnapshot> {
// Lock the thread row so messages, the event cursor, active runs, and live
// progress are read from one consistent commit. A torn Promise.all can
// otherwise advance the client cursor past thread.message.created while the
// ask message page still omits it — leaving waiting_input with no AskCard.
if (target.kind === "bot") {
const [busyBotName, core] = await Promise.all([
resolveBusyBotName(deps.prisma, {
computerId: target.bot.computer?.id,
botId: target.botId,
botName: target.bot.name,
}),
deps.prisma.$transaction(async (tx) => {
await tx.$queryRaw`SELECT id FROM threads WHERE id = ${target.threadId} FOR SHARE`;
const [messagePage, last, run] = await Promise.all([
loadMessagePage(tx, target.threadId, undefined, THREAD_MESSAGE_PAGE_SIZE),
tx.event.findFirst({
where: { threadId: target.threadId },
orderBy: { seq: "desc" },
select: { seq: true },
}),
tx.run.findFirst({
where: {
botId: target.botId,
threadId: target.threadId,
trigger: { not: "bot_message" },
status: { in: [...ACTIVE_RUN_STATUSES, "failed"] },
},
// The id tiebreak keeps ordering deterministic under equal
// timestamps, matching the supersession probe below.
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
}),
]);
// A failed run is only the thread's word while it is still the newest
// terminal run; otherwise a stale failure would resurface in the
// composer error strip on every load, forever. Instead of comparing
// timestamps (equal createdAt values reverse under gt/gte), ask for
// the newest terminal run under the same deterministic ordering and
// check whether it is this failure.
const newestTerminal =
run?.status === "failed"
? await tx.run.findFirst({
where: {
botId: target.botId,
threadId: target.threadId,
// Match the selection query — peer bot_message runs must not bury a user-visible failure.
trigger: { not: "bot_message" },
status: { in: ["failed", "completed", "cancelled"] },
},
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
select: { id: true },
})
: null;
const currentRun = run?.status === "failed" && newestTerminal?.id !== run.id ? null : run;
const liveEvents =
currentRun && isActive(currentRun.status as RunStatus)
? await tx.event.findMany({
where: {
threadId: target.threadId,
runId: currentRun.id,
type: { in: ["thread.progress", "thread.subagent", "agent.tool.called"] },
},
orderBy: { seq: "asc" },
})
: [];
return { messagePage, last, run: currentRun, liveEvents };
}),
]);
return {
botId: target.botId,
threadId: target.threadId,
cursor: core.last?.seq ?? -1,
messages: messagesWithLiveEvents(core.messagePage.messages, core.liveEvents),
olderCursor: core.messagePage.olderCursor,
run: core.run ? mapRun(core.run) : null,
computer: toComputerStatus(target.botId, target.bot.computer, busyBotName),
};
}
const core = await deps.prisma.$transaction(async (tx) => {
await tx.$queryRaw`SELECT id FROM threads WHERE id = ${target.threadId} FOR SHARE`;
const [messagePage, last, activeRuns, recentTerminals] = await Promise.all([
loadMessagePage(tx, target.threadId, undefined, THREAD_MESSAGE_PAGE_SIZE),
tx.event.findFirst({
where: { threadId: target.threadId },
orderBy: { seq: "desc" },
select: { seq: true },
}),
tx.run.findMany({
where: {
threadId: target.threadId,
trigger: { not: "bot_message" },
status: { in: [...ACTIVE_RUN_STATUSES] },
},
orderBy: { createdAt: "desc" },
}),
// Recently updated terminals (completion bumps updatedAt). pickLatestTerminalRun then
// ranks by completedAt ?? createdAt so null timestamps cannot revive a stale failure.
tx.run.findMany({
where: {
threadId: target.threadId,
trigger: { not: "bot_message" },
status: { in: ["failed", "completed", "cancelled"] },
},
orderBy: [{ updatedAt: "desc" }, { id: "desc" }],
take: 50,
}),
]);
const liveEvents =
activeRuns.length > 0
? await tx.event.findMany({
where: {
threadId: target.threadId,
runId: { in: activeRuns.map((run) => run.id) },
type: { in: ["thread.progress", "thread.subagent", "agent.tool.called"] },
},
orderBy: { seq: "asc" },
})
: [];
return {
messagePage,
last,
activeRuns,
terminalRun: pickLatestTerminalRun(recentTerminals),
liveEvents,
};
});
return {
groupId: target.groupId,
groupName: target.groupName,
members: target.members,
threadId: target.threadId,
cursor: core.last?.seq ?? -1,
messages: messagesWithLiveEvents(core.messagePage.messages, core.liveEvents),
olderCursor: core.messagePage.olderCursor,
// Match the live reducer: a failed latest terminal stays in run even while siblings are
// still active or start late. A newer completed/cancelled terminal clears it.
run:
core.terminalRun?.status === "failed"
? mapRun(core.terminalRun)
: core.activeRuns[0]
? mapRun(core.activeRuns[0])
: null,
activeRuns: core.activeRuns.map(mapRun),
};
}
/** Latest terminal by end time (completedAt, else createdAt), then createdAt, then id. */
function pickLatestTerminalRun<T extends { id: string; createdAt: Date; completedAt: Date | null }>(
runs: T[],
): T | null {
if (runs.length === 0) return null;
return runs.reduce((best, run) => {
const bestEnd = (best.completedAt ?? best.createdAt).getTime();
const runEnd = (run.completedAt ?? run.createdAt).getTime();
if (runEnd !== bestEnd) return runEnd > bestEnd ? run : best;
if (run.createdAt.getTime() !== best.createdAt.getTime()) {
return run.createdAt > best.createdAt ? run : best;
}
return run.id > best.id ? run : best;
});
}
function messagesWithLiveEvents(
persisted: ThreadSnapshot["messages"],
liveEvents: Parameters<typeof projectMessages>[0],
) {
const live = projectMessages(liveEvents).filter((message) => {
if (message.blocks.some((block) => block.kind === "progress" || block.kind === "steps")) {
return true;
}
if (!message.id.startsWith("subagent:")) return false;
return !persisted.some((row) =>
row.blocks.some(
(block) => block.kind === "subagent" && message.id === `subagent:${block.agentId}`,
),
);
});
return [...persisted, ...live];
}
function mapRun(run: {
id: string;
botId: string;
threadId: string;
taskId: string;
status: string;
trigger: string;
routineId: string | null;
modelProvider: string | null;
modelId: string | null;
error: string | null;
startedAt: Date | null;
completedAt: Date | null;
createdAt: Date;
}) {
return {
id: run.id,
botId: run.botId,
threadId: run.threadId,
taskId: run.taskId,
status: run.status as never,
trigger: run.trigger as never,
routineId: run.routineId ?? null,
modelProvider: run.modelProvider,
modelId: run.modelId,
// Same display clamp as live run.failed events so a huge stored error cannot bypass it.
error:
run.status === "failed"
? runFailureError({ type: "run.failed", payload: { error: run.error } })
: run.error,
startedAt: run.startedAt?.toISOString() ?? null,
completedAt: run.completedAt?.toISOString() ?? null,
createdAt: run.createdAt.toISOString(),
};
}
export async function sendThreadMessage(
deps: {
prisma: PrismaClient;
events: ThreadEvents;
jobs: JobPublisher;
},
actor: Actor,
target: ThreadTarget,
input: {
text?: string;
artifactIds?: string[];
mentions?: MentionTargetInput[];
replyToMessageId?: string;
clientNonce?: string;
},
) {
const existing = await replayExistingSend(deps, target.threadId, input.clientNonce);
if (existing) return existing;
const commit = () =>
deps.prisma.$transaction(async (tx) => {
if (input.replyToMessageId) {
const reply = await tx.message.findFirst({
where: { id: input.replyToMessageId, threadId: target.threadId },
select: { id: true },
});
if (!reply) throw new IsolationError();
}
if (target.kind === "bot") {
const mentionTargets = splitMentionTargets(input.mentions);
const { blocks: attachmentBlocks, artifacts } = await resolveSendAttachments(
{ prisma: tx },
actor,
target.botId,
input.artifactIds,
);
const connectorNames = await resolveOwnedConnectorDisplayNames(
tx,
actor,
mentionTargets.connectorMentionIds,
);
const blocks = buildUserMessageBlocks(input.text, attachmentBlocks);
const message = await createThreadMessageInTransaction(tx, {
threadId: target.threadId,
role: "user",
blocks,
replyToMessageId: input.replyToMessageId,
clientNonce: input.clientNonce,
});
const active = await tx.run.findFirst({
where: {
threadId: target.threadId,
botId: target.botId,
status: { in: [...ACTIVE_RUN_STATUSES] },
},
select: { id: true, taskId: true, status: true },
});
if (active) {
await tx.steeringMessage.create({
data: {
messageId: message.id,
botId: target.botId,
userId: actor.userId,
runId: active.id,
},
});
await tx.message.update({ where: { id: message.id }, data: { runId: active.id } });
const event = await appendEventInTransaction(tx, {
spaceId: actor.spaceId,
threadId: target.threadId,
botId: target.botId,
type: "thread.message.created",
runId: active.id,
payload: {
messageId: message.id,
role: "user",
blocks,
replyToMessageId: input.replyToMessageId,
},
});
return { message, runs: [active], eventSeq: event.seq };
}
const task = await tx.task.create({
data: {
spaceId: actor.spaceId,
botId: target.botId,
threadId: target.threadId,
userId: actor.userId,
prompt: buildSendPrompt(input.text, artifacts, connectorNames),
status: "queued",
},
});
const run = await tx.run.create({
data: {
spaceId: actor.spaceId,
botId: target.botId,
threadId: target.threadId,
taskId: task.id,
userId: actor.userId,
status: "queued",
trigger: "user",
clientNonce: sendRunClientNonce(input.clientNonce, message.id),
sourceMessageId: message.id,
},
});
await tx.message.update({ where: { id: message.id }, data: { runId: run.id } });
await cancelSupersededQueuedRuns(tx, {
threadId: target.threadId,
botIds: [target.botId],
keepRunIds: [run.id],
});
const event = await appendEventInTransaction(tx, {
spaceId: actor.spaceId,
threadId: target.threadId,
botId: target.botId,
type: "thread.message.created",
runId: run.id,
payload: {
messageId: message.id,
role: "user",
blocks,
runIds: [run.id],
replyToMessageId: input.replyToMessageId,
},
});
return { message, runs: [run], eventSeq: event.seq };
}
const members = await lockAndLoadGroupMembers(tx, actor, target);
const memberBotIds = members.map((member) => member.botId);
const mentionTargets = splitMentionTargets(input.mentions);
const targetBotIds = resolveGroupTargetBotIds({
text: input.text ?? "",
members: members.map((member) => ({ id: member.botId, name: member.name })),
explicitMentions: mentionTargets.botMentionIds,
});
const { blocks: attachmentBlocks, artifacts } = await resolveGroupSendAttachments(
{ prisma: tx },
actor,
target.groupId,
memberBotIds,
input.artifactIds,
);
const connectorNames = await resolveOwnedConnectorDisplayNames(
tx,
actor,
mentionTargets.connectorMentionIds,
);
const blocks = buildUserMessageBlocks(input.text, attachmentBlocks);
const message = await createThreadMessageInTransaction(tx, {
threadId: target.threadId,
role: "user",
blocks,
replyToMessageId: input.replyToMessageId,
clientNonce: input.clientNonce,
});
const activeRuns = await tx.run.findMany({
where: {
threadId: target.threadId,
botId: { in: targetBotIds },
status: { in: [...ACTIVE_RUN_STATUSES] },
},
select: { id: true, taskId: true, botId: true, status: true },
});
const activeByBotId = new Map(activeRuns.map((run) => [run.botId, run]));
const runs: Array<{ id: string; taskId: string; botId: string; status: string }> = [];
for (const botId of targetBotIds) {
const active = activeByBotId.get(botId);
if (active) {
await tx.steeringMessage.create({
data: { messageId: message.id, botId, userId: actor.userId, runId: active.id },
});
runs.push(active);
continue;
}
const task = await tx.task.create({
data: {
spaceId: actor.spaceId,
botId,
threadId: target.threadId,
userId: actor.userId,
prompt: buildSendPrompt(input.text, artifacts, connectorNames),
status: "queued",
},
});
const run = await tx.run.create({
data: {
spaceId: actor.spaceId,
botId,
threadId: target.threadId,
taskId: task.id,
userId: actor.userId,
status: "queued",
trigger: "user",
clientNonce: sendRunClientNonce(input.clientNonce, message.id, botId),
sourceMessageId: message.id,
},
});
runs.push(run);
}
const firstRun = runs[0];
const eventBotId = firstRun?.botId ?? targetBotIds[0];
if (!eventBotId) throw new IsolationError("Group send did not resolve a target");
if (firstRun) {
await tx.message.update({ where: { id: message.id }, data: { runId: firstRun.id } });
const createdRuns = runs.filter((run) => !activeByBotId.has(run.botId));
if (createdRuns.length) {
await cancelSupersededQueuedRuns(tx, {
threadId: target.threadId,
botIds: createdRuns.map((run) => run.botId),
keepRunIds: createdRuns.map((run) => run.id),
});
}
}
await touchGroupUpdatedAt(tx, target.groupId);
const event = await appendEventInTransaction(tx, {
spaceId: actor.spaceId,
threadId: target.threadId,
botId: eventBotId,
type: "thread.message.created",
runId: firstRun?.id ?? activeRuns[0]?.id,
payload: {
messageId: message.id,
role: "user",
blocks,
runIds: runs.map((run) => run.id),
replyToMessageId: input.replyToMessageId,
},
});
return { message, runs, eventSeq: event.seq };
});
const committed = await withSerializableRetry(commit).catch(async (error) => {
const winner = await replayExistingSend(deps, target.threadId, input.clientNonce);
if (winner) return { replay: winner } as const;
throw error;
});
if ("replay" in committed) return committed.replay;
await deps.events.notify(target.threadId, committed.eventSeq).catch((error) => {
// Subscribers catch up from the durable event cursor after a missed realtime wake.
console.error("thread send realtime notification", error);
});
await enqueueRunsNeedingContinue(deps.jobs, committed.runs);
return sendResult(committed.message, committed.runs);
}
export async function reactToThreadMessage(
deps: { prisma: PrismaClient },
actor: Actor,
target: ThreadTarget,
messageId: string,
thumbsUp: boolean,
) {
return deps.prisma.$transaction(async (tx) => {
await tx.$queryRaw`SELECT id FROM threads WHERE id = ${target.threadId} FOR UPDATE`;
const [message] = await tx.$queryRaw<
Array<{ id: string; thumbsUp: boolean }>
>`SELECT id, "thumbsUp" FROM messages WHERE id = ${messageId} AND "threadId" = ${target.threadId} FOR UPDATE`;
if (!message) throw new IsolationError();
if (message.thumbsUp === thumbsUp) {
return { changed: false, eventSeq: null, runId: null };
}
await tx.message.update({ where: { id: message.id }, data: { thumbsUp } });
const botId = target.kind === "bot" ? target.botId : target.memberBotIds[0];
if (!botId) throw new IsolationError();
let run: { id: string; status: string } | null = null;
if (thumbsUp && target.kind === "bot") {
const busy = await tx.run.findFirst({
where: { botId, status: { in: ["running", "queued", "leased"] } },
select: { id: true },
});
if (!busy) {
const task = await tx.task.create({
data: {
spaceId: actor.spaceId,
botId,
threadId: target.threadId,
userId: actor.userId,
prompt: "The user gave this message a thumbs-up.",
status: "queued",
},
});
run = await tx.run.create({
data: {
spaceId: actor.spaceId,
botId,
threadId: target.threadId,
taskId: task.id,
userId: actor.userId,
status: "queued",
trigger: "reaction",
sourceMessageId: message.id,
},
});
}
}
const event = await appendEventInTransaction(tx, {
spaceId: actor.spaceId,
threadId: target.threadId,
botId,
type: "thread.message.reaction",
payload: { messageId: message.id, thumbsUp },
runId: run?.id,
});
return { changed: true, eventSeq: event.seq, runId: run?.id ?? null };
});
}
export async function stopThreadRuns(
deps: {
prisma: PrismaClient;
sandbox: import("@rakazo/adapter-kit").SandboxProvider;
},
actor: Actor,
target: ThreadTarget,
) {
const runIds = await deps.prisma.$transaction(async (tx) => {
await tx.$queryRaw`SELECT id FROM threads WHERE id = ${target.threadId} FOR UPDATE`;
const ids = (
await tx.run.findMany({
where: {
threadId: target.threadId,
status: { in: [...ACTIVE_RUN_STATUSES] },
},
select: { id: true },
})
).map((run) => run.id);
await tx.run.updateMany({
where: { id: { in: ids }, status: { in: [...ACTIVE_RUN_STATUSES] } },
data: { status: "cancelled", completedAt: new Date() },
});
await tx.steeringMessage.deleteMany({
where: {
botId: { in: target.kind === "bot" ? [target.botId] : target.memberBotIds },
message: { threadId: target.threadId },
},
});
return ids;
});
const computers = runIds.length
? await deps.prisma.computer.findMany({
where: { executionRunId: { in: runIds } },
select: {
homeKey: true,
kind: true,
providerRef: true,
executionBotId: true,
},
})
: [];
await deps.prisma.computerExecutionLease.deleteMany({ where: { runId: { in: runIds } } });
await deps.prisma.computer.updateMany({
where: { executionRunId: { in: runIds } },
data: {
executionRunId: null,
executionBotId: null,
executionLeaseExpiresAt: null,
},
});
await Promise.all(
computers.map(async (computer) => {
if (!computer.providerRef || !computer.executionBotId) return;
await deps.sandbox
.releaseScreen?.(toComputerRef(computer), {
operationId: "stop",
traceId: "stop",
spaceId: actor.spaceId,
userId: actor.userId,
botId: computer.executionBotId,
signal: new AbortController().signal,
})
.catch(() => undefined);
}),
);
await deps.prisma.event.deleteMany({
where: {
type: "thread.progress",
runId: { in: runIds },
},
});
}
export async function setThreadUnreadState(
prisma: PrismaClient,
actor: Actor,
target: ThreadTarget,
unread: boolean,
) {
const result = await prisma.thread.updateMany({
where: {
id: target.threadId,
spaceId: actor.spaceId,
userId: actor.userId,
unread: { not: unread },
},
data: { unread },
});
if (result.count > 1) throw new IsolationError();
}

View File

@ -0,0 +1,49 @@
import { Hono } from "hono";
import { describe, expect, it } from "vitest";
import { mountVoiceHttpRoutes, toVoiceStatus, type VoiceDeps } from "./voice.js";
describe("toVoiceStatus", () => {
it("treats a saved key without a voice as configured but not ready", () => {
expect(toVoiceStatus({ provider: "elevenlabs", voiceId: "" })).toEqual({
configured: true,
ready: false,
transcribe: true,
provider: "elevenlabs",
voiceId: "",
});
});
it("is ready once a voice is chosen", () => {
expect(toVoiceStatus({ provider: "cartesia", voiceId: "katie" }).ready).toBe(true);
expect(toVoiceStatus({ provider: "cartesia", voiceId: "katie" }).transcribe).toBe(false);
});
it("is off when nothing is connected", () => {
expect(toVoiceStatus(null)).toEqual({
configured: false,
ready: false,
transcribe: false,
provider: null,
voiceId: "",
});
});
});
describe("voice HTTP routes", () => {
it("rejects unauthenticated speak and transcribe", async () => {
const app = new Hono();
mountVoiceHttpRoutes(app, {} as VoiceDeps, async () => null);
const speak = await app.request("/api/voice/speak", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ text: "hello" }),
});
const transcribe = await app.request("/api/voice/transcribe", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ audioBase64: "AAAA", mimeType: "audio/webm" }),
});
expect(speak.status).toBe(401);
expect(transcribe.status).toBe(401);
});
});

341
apps/api/src/voice.ts Normal file
View File

@ -0,0 +1,341 @@
import { ORPCError } from "@orpc/server";
import type { AdapterContext } from "@rakazo/adapter-kit";
import {
createVoiceProvider,
type EncryptedSecretStore,
isVoiceProviderId,
listVoiceCatalog,
MAX_SPEAK_CHARS,
MAX_TRANSCRIBE_BYTES,
NoVoiceConfigured,
voiceCatalogEntry,
} from "@rakazo/adapters";
import type { Actor, VoiceCredential, VoiceStatus } from "@rakazo/contracts";
import { toUtterances } from "@rakazo/core";
import {
deleteUnreferencedCredentialSecret,
findDefaultVoiceCredential,
findVoiceCredential,
IsolationError,
newestVoiceCredentialOrder,
Prisma,
type PrismaClient,
selectSpaceVoicePreference,
} from "@rakazo/db";
import type { Context, Hono } from "hono";
import { withSerializableRetry } from "./serializable-retry.js";
export interface VoiceDeps {
prisma: PrismaClient;
secrets: EncryptedSecretStore;
}
export { listVoiceCatalog };
const SPEAK_TIMEOUT_MS = 60_000;
export function voiceContext(actor: Actor, signal?: AbortSignal): AdapterContext {
return {
operationId: "voice",
traceId: "voice",
spaceId: actor.spaceId,
userId: actor.userId,
signal: signal ?? new AbortController().signal,
};
}
export function catalogEntry(provider: string) {
return voiceCatalogEntry(provider);
}
export function toVoiceStatus(cred: { provider: string; voiceId: string } | null): VoiceStatus {
const entry = cred ? catalogEntry(cred.provider) : undefined;
return {
configured: Boolean(cred),
ready: Boolean(cred?.voiceId),
transcribe: Boolean(entry?.transcribe && cred),
provider: cred?.provider ?? null,
voiceId: cred?.voiceId ?? "",
};
}
export function toVoiceCredential(row: {
id: string;
provider: string;
isDefault: boolean;
voiceId: string;
}): VoiceCredential {
return {
id: row.id,
provider: row.provider,
hasKey: true,
isDefault: row.isDefault,
voiceId: row.voiceId,
transcribe: Boolean(catalogEntry(row.provider)?.transcribe),
};
}
export async function loadDefaultVoiceCredential(deps: VoiceDeps, actor: Actor) {
return loadVoiceCredential(deps, actor);
}
export async function loadVoiceCredential(deps: VoiceDeps, actor: Actor, provider?: string) {
const cred = provider
? await findVoiceCredential(deps.prisma, actor, provider)
: await findDefaultVoiceCredential(deps.prisma, actor);
if (!cred) return null;
const secret = await deps.prisma.secret.findFirst({
where: { id: cred.secretId, userId: actor.userId, spaceId: null },
});
if (!secret) return null;
return { cred, apiKey: deps.secrets.load(secret.ciphertext, secret.id) };
}
export async function resolveVoiceTarget(
deps: VoiceDeps,
actor: Actor,
input: { botId?: string; voiceId?: string },
) {
let botVoiceId: string | null = null;
if (input.botId) {
const bot = await deps.prisma.bot.findFirst({
where: { id: input.botId, spaceId: actor.spaceId, userId: actor.userId },
select: { voiceId: true },
});
if (!bot) throw new IsolationError();
botVoiceId = bot.voiceId;
}
const loaded = await loadDefaultVoiceCredential(deps, actor);
if (!loaded) throw new NoVoiceConfigured("key");
const voiceId = input.voiceId || botVoiceId || loaded.cred.voiceId;
if (!voiceId) throw new NoVoiceConfigured("voice");
return { ...loaded, voiceId };
}
export async function persistVoiceCredential(
deps: VoiceDeps,
actor: Actor,
input: {
provider: string;
plaintext: string;
voiceId?: string;
signal?: AbortSignal;
},
): Promise<VoiceCredential> {
if (!isVoiceProviderId(input.provider)) {
throw new ORPCError("BAD_REQUEST", { message: "Unknown voice provider." });
}
const provider = createVoiceProvider(input.provider);
const verified = await provider.verify(input.plaintext, voiceContext(actor, input.signal));
if (!verified.ok) {
throw new ORPCError("BAD_REQUEST", { message: verified.message ?? "That key was rejected." });
}
let voiceId = input.voiceId?.trim() ?? "";
if (!voiceId) {
const voices = await provider.listVoices(input.plaintext, voiceContext(actor, input.signal));
voiceId = voices[0]?.id ?? "";
}
const stored = await deps.secrets.put(input.plaintext, voiceContext(actor, input.signal));
const cred = await withSerializableRetry(() =>
deps.prisma.$transaction(
async (tx) => {
const existing = await tx.userVoiceCredential.findFirst({
where: { userId: actor.userId, provider: input.provider },
orderBy: newestVoiceCredentialOrder,
});
const secret = await tx.secret.create({
data: {
id: stored.id,
userId: actor.userId,
spaceId: null,
kind: "voice",
ciphertext: stored.ciphertext,
},
});
const credential = !existing
? await tx.userVoiceCredential.create({
data: {
userId: actor.userId,
provider: input.provider,
secretId: secret.id,
},
})
: await tx.userVoiceCredential.update({
where: { id: existing.id },
data: { secretId: secret.id },
});
const previousPreference = existing
? await tx.spaceVoicePreference.findUnique({
where: {
spaceId_userId_credentialId: {
spaceId: actor.spaceId,
userId: actor.userId,
credentialId: existing.id,
},
},
})
: null;
const selectedVoiceId = voiceId || previousPreference?.voiceId || "";
await selectSpaceVoicePreference(tx, actor, credential.id, selectedVoiceId);
if (existing) {
await deleteUnreferencedCredentialSecret(tx, {
credentialKind: "voice",
credentialId: existing.id,
secretId: existing.secretId,
});
}
return { ...credential, isDefault: true, voiceId: selectedVoiceId };
},
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable },
),
);
return toVoiceCredential(cred);
}
export async function prepareVoice(
deps: VoiceDeps,
actor: Actor,
input: { text: string; voiceId?: string; botId?: string },
) {
try {
await resolveVoiceTarget(deps, actor, input);
} catch (error) {
if (error instanceof NoVoiceConfigured) {
return { ready: false, utterances: [] as string[] };
}
throw error;
}
return { ready: true, utterances: toUtterances(input.text) };
}
export async function synthesizeVoice(
deps: VoiceDeps,
actor: Actor,
input: { text: string; voiceId?: string; botId?: string; signal?: AbortSignal },
) {
const target = await resolveVoiceTarget(deps, actor, input);
const text = input.text.trim();
if (!text) throw new ORPCError("BAD_REQUEST", { message: "Nothing to speak." });
if (text.length > MAX_SPEAK_CHARS) {
throw new ORPCError("BAD_REQUEST", { message: "That utterance is too long to speak." });
}
const provider = createVoiceProvider(target.cred.provider);
return provider.synthesize(
{
text,
voiceId: target.voiceId,
apiKey: target.apiKey,
signal: input.signal,
},
voiceContext(actor, input.signal),
);
}
export async function transcribeVoice(
deps: VoiceDeps,
actor: Actor,
input: { audio: Uint8Array; mimeType: string; signal?: AbortSignal },
) {
const loaded = await loadDefaultVoiceCredential(deps, actor);
if (!loaded) throw new NoVoiceConfigured("key");
const provider = createVoiceProvider(loaded.cred.provider);
if (!provider.transcribe) {
throw new ORPCError("BAD_REQUEST", {
message: "This voice provider does not transcribe audio. Use on-device dictation instead.",
});
}
if (input.audio.byteLength === 0 || input.audio.byteLength > MAX_TRANSCRIBE_BYTES) {
throw new ORPCError("BAD_REQUEST", { message: "That recording is empty or too large." });
}
return provider.transcribe(
{
audio: input.audio,
mimeType: input.mimeType || "audio/webm",
apiKey: loaded.apiKey,
signal: input.signal,
},
voiceContext(actor, input.signal),
);
}
export function mountVoiceHttpRoutes(
app: Hono,
deps: VoiceDeps,
authenticate: (c: Context) => Promise<Actor | null>,
) {
app.post("/api/voice/speak", async (c) => {
const actor = await authenticate(c);
if (!actor) return c.json({ error: "Unauthorized" }, 401);
const body = await c.req.json().catch(() => ({}));
try {
const clip = await synthesizeVoice(deps, actor, {
text: String((body as { text?: unknown }).text ?? ""),
voiceId: optionalString((body as { voiceId?: unknown }).voiceId),
botId: optionalString((body as { botId?: unknown }).botId),
signal: AbortSignal.any(
[c.req.raw.signal, AbortSignal.timeout(SPEAK_TIMEOUT_MS)].filter(
Boolean,
) as AbortSignal[],
),
});
// Copy into a fresh ArrayBuffer-backed view: DOM-lib BodyInit rejects
// Uint8Array<ArrayBufferLike> since TS 5.7.
return new Response(new Uint8Array(clip.bytes), {
headers: {
"content-type": clip.mimeType,
"cache-control": "no-store",
},
});
} catch (error) {
return voiceHttpError(c, error);
}
});
app.post("/api/voice/transcribe", async (c) => {
const actor = await authenticate(c);
if (!actor) return c.json({ error: "Unauthorized" }, 401);
const body = await c.req.json().catch(() => ({}));
const audioBase64 = String((body as { audioBase64?: unknown }).audioBase64 ?? "");
try {
const audio = decodeAudioBase64(audioBase64);
const result = await transcribeVoice(deps, actor, {
audio,
mimeType: String((body as { mimeType?: unknown }).mimeType ?? "audio/webm"),
signal: c.req.raw.signal,
});
return c.json({ text: result.text });
} catch (error) {
return voiceHttpError(c, error);
}
});
}
function optionalString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function decodeAudioBase64(value: string): Uint8Array {
if (!value.trim()) throw new ORPCError("BAD_REQUEST", { message: "Recording is empty." });
try {
return new Uint8Array(Buffer.from(value, "base64"));
} catch {
throw new ORPCError("BAD_REQUEST", { message: "Recording is not valid audio." });
}
}
function voiceHttpError(c: Context, error: unknown) {
if (error instanceof IsolationError) {
return c.json({ error: "Resource not found" }, 404);
}
if (error instanceof NoVoiceConfigured) {
return c.json({ error: error.message }, 409);
}
if (error instanceof ORPCError) {
const code = String(error.code ?? "BAD_REQUEST");
const status =
code === "UNAUTHORIZED" ? 401 : code === "NOT_FOUND" ? 404 : code === "CONFLICT" ? 409 : 400;
return c.json({ error: error.message }, status);
}
const message = error instanceof Error ? error.message : "Voice request failed.";
return c.json({ error: message }, 502);
}

View File

@ -0,0 +1,249 @@
import { Hono } from "hono";
import { describe, expect, it, vi } from "vitest";
import {
formatWebhookPrompt,
mountWebhookHttpRoutes,
WEBHOOK_MAX_BODY_BYTES,
WEBHOOK_SECRET_KIND,
type WebhookDeps,
} from "./webhook.js";
const SECRET = "webhook-test-secret-value-32chars!!";
function createDeps(
overrides: {
bot?: {
id: string;
spaceId: string;
userId: string;
webhookSecretId: string | null;
thread: { id: string } | null;
} | null;
secret?: { ciphertext: string; kind: string; userId: string; spaceId: string } | null;
load?: (ciphertext: string) => string;
} = {},
): WebhookDeps & {
sendUserMessage: ReturnType<typeof vi.fn>;
enqueue: ReturnType<typeof vi.fn>;
} {
const bot =
overrides.bot === undefined
? {
id: "bot-1",
spaceId: "ws-1",
userId: "user-1",
webhookSecretId: "secret-1",
thread: { id: "thread-1" },
}
: overrides.bot;
const secret =
overrides.secret === undefined
? {
ciphertext: "cipher",
kind: WEBHOOK_SECRET_KIND,
userId: "user-1",
spaceId: "ws-1",
}
: overrides.secret;
const sendUserMessage = vi.fn(async () => ({
messageId: "msg-1",
runId: "run-1",
seq: 3,
}));
const enqueue = vi.fn(async () => undefined);
return {
prisma: {
bot: {
findUnique: vi.fn(async () => bot),
},
secret: {
findUnique: vi.fn(async () => secret),
},
routine: {
findMany: vi.fn(async () => []),
},
} as unknown as WebhookDeps["prisma"],
secrets: {
load: overrides.load ?? (() => SECRET),
} as unknown as WebhookDeps["secrets"],
events: { sendUserMessage },
jobs: { enqueue } as unknown as WebhookDeps["jobs"],
sendUserMessage,
enqueue,
};
}
function mount(deps: WebhookDeps) {
const app = new Hono();
mountWebhookHttpRoutes(app, deps);
return app;
}
describe("formatWebhookPrompt", () => {
it("uses payload.text when present", () => {
expect(formatWebhookPrompt({ text: " Deployment ok " })).toBe("Deployment ok");
});
it("formats json events with a fence", () => {
const prompt = formatWebhookPrompt({ event: "github.push", ref: "main" });
expect(prompt).toContain("[Inbound Event: github.push]");
expect(prompt).toContain('"ref": "main"');
});
});
describe("inbound webhook HTTP route", () => {
it("rejects missing authorization", async () => {
const deps = createDeps();
const app = mount(deps);
const res = await app.request("/api/v1/bots/bot-1/webhook", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ text: "hi" }),
});
expect(res.status).toBe(401);
expect(deps.sendUserMessage).not.toHaveBeenCalled();
});
it("rejects the wrong bearer secret", async () => {
const deps = createDeps();
const app = mount(deps);
const res = await app.request("/api/v1/bots/bot-1/webhook", {
method: "POST",
headers: {
authorization: "Bearer wrong-secret",
"content-type": "application/json",
},
body: JSON.stringify({ text: "hi" }),
});
expect(res.status).toBe(401);
expect(await res.json()).toEqual({ error: "Unauthorized" });
expect(deps.sendUserMessage).not.toHaveBeenCalled();
});
it("rejects unknown bots with the same unauthorized response", async () => {
const deps = createDeps({ bot: null });
const app = mount(deps);
const res = await app.request("/api/v1/bots/missing/webhook", {
method: "POST",
headers: {
authorization: `Bearer ${SECRET}`,
"content-type": "application/json",
},
body: JSON.stringify({ text: "hi" }),
});
expect(res.status).toBe(401);
expect(await res.json()).toEqual({ error: "Unauthorized" });
});
it("rejects bots without a configured webhook secret", async () => {
const deps = createDeps({
bot: {
id: "bot-1",
spaceId: "ws-1",
userId: "user-1",
webhookSecretId: null,
thread: { id: "thread-1" },
},
});
const app = mount(deps);
const res = await app.request("/api/v1/bots/bot-1/webhook", {
method: "POST",
headers: {
authorization: `Bearer ${SECRET}`,
"content-type": "application/json",
},
body: JSON.stringify({ text: "hi" }),
});
expect(res.status).toBe(401);
expect(deps.sendUserMessage).not.toHaveBeenCalled();
});
it("accepts a valid secret and JSON payload", async () => {
const deps = createDeps();
const app = mount(deps);
const res = await app.request("/api/v1/bots/bot-1/webhook", {
method: "POST",
headers: {
authorization: `Bearer ${SECRET}`,
"content-type": "application/json",
},
body: JSON.stringify({ event: "ci.failed", repo: "rakazo" }),
});
expect(res.status).toBe(200);
expect(await res.json()).toEqual({
ok: true,
messageId: "msg-1",
runId: "run-1",
seq: 3,
});
expect(deps.sendUserMessage).toHaveBeenCalledWith(
expect.objectContaining({
botId: "bot-1",
trigger: "webhook",
prompt: expect.stringContaining("[Inbound Event: ci.failed]"),
}),
);
expect(deps.enqueue).toHaveBeenCalled();
});
it("accepts a plain text payload", async () => {
const deps = createDeps();
const app = mount(deps);
const res = await app.request("/api/v1/bots/bot-1/webhook", {
method: "POST",
headers: {
authorization: `Bearer ${SECRET}`,
"content-type": "text/plain",
},
body: "Staging deploy finished",
});
expect(res.status).toBe(200);
expect(deps.sendUserMessage).toHaveBeenCalledWith(
expect.objectContaining({
trigger: "webhook",
prompt: "Staging deploy finished",
}),
);
});
it("hashes idempotency keys into a fixed-length clientNonce", async () => {
const { createHash } = await import("node:crypto");
const deps = createDeps();
const app = mount(deps);
const longKey = `event-${"a".repeat(240)}-unique-suffix`;
const res = await app.request("/api/v1/bots/bot-1/webhook", {
method: "POST",
headers: {
authorization: `Bearer ${SECRET}`,
"content-type": "application/json",
"idempotency-key": longKey,
},
body: JSON.stringify({ event: "ping" }),
});
expect(res.status).toBe(200);
const digest = createHash("sha256").update(longKey).digest("base64url");
expect(deps.sendUserMessage).toHaveBeenCalledWith(
expect.objectContaining({
clientNonce: `webhook:bot-1:${digest}`,
}),
);
});
it("rejects oversized payloads", async () => {
const deps = createDeps();
const app = mount(deps);
const res = await app.request("/api/v1/bots/bot-1/webhook", {
method: "POST",
headers: {
authorization: `Bearer ${SECRET}`,
"content-type": "text/plain",
"content-length": String(WEBHOOK_MAX_BODY_BYTES + 1),
},
body: "x".repeat(WEBHOOK_MAX_BODY_BYTES + 1),
});
expect(res.status).toBe(413);
expect(deps.sendUserMessage).not.toHaveBeenCalled();
});
});

202
apps/api/src/webhook.ts Normal file
View File

@ -0,0 +1,202 @@
import { createHash } from "node:crypto";
import type { JobPublisher } from "@rakazo/adapter-kit";
import { runContinueJob } from "@rakazo/adapter-kit";
import type { EncryptedSecretStore } from "@rakazo/adapters";
import { hasValidBearerToken } from "@rakazo/core";
import type { PrismaClient } from "@rakazo/db";
import type { Hono } from "hono";
export const WEBHOOK_MAX_BODY_BYTES = 64 * 1024;
export const WEBHOOK_SECRET_KIND = "webhook";
export type WebhookEvents = {
sendUserMessage(input: {
spaceId: string;
threadId: string;
botId: string;
userId: string;
blocks: Array<{ kind: "text"; text: string }>;
prompt: string;
trigger: "webhook";
clientNonce?: string;
}): Promise<{ messageId: string; runId: string | null; seq: number }>;
};
export type WebhookDeps = {
prisma: PrismaClient;
secrets: EncryptedSecretStore;
events: WebhookEvents;
jobs: JobPublisher;
};
export function formatWebhookPrompt(payload: Record<string, unknown>): string {
if (typeof payload.text === "string" && payload.text.trim()) {
return payload.text.trim();
}
const eventName = typeof payload.event === "string" ? payload.event : "webhook";
return `[Inbound Event: ${eventName}]\n\`\`\`json\n${JSON.stringify(payload, null, 2)}\n\`\`\``;
}
export function webhookPath(botId: string): string {
return `/api/v1/bots/${botId}/webhook`;
}
export async function readBoundedBody(request: Request, maxBytes: number): Promise<string | null> {
const contentLengthHeader = request.headers.get("content-length");
if (contentLengthHeader !== null) {
const contentLength = Number(contentLengthHeader);
if (!Number.isFinite(contentLength) || contentLength < 0 || contentLength > maxBytes) {
return null;
}
}
if (!request.body) return "";
const reader = request.body.getReader();
const decoder = new TextDecoder();
let bytes = 0;
let body = "";
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
bytes += value.byteLength;
if (bytes > maxBytes) {
await reader.cancel();
return null;
}
body += decoder.decode(value, { stream: true });
}
return body + decoder.decode();
} finally {
reader.releaseLock();
}
}
function parseWebhookPayload(
raw: string,
contentType: string | undefined,
): Record<string, unknown> {
const trimmed = raw.trim();
if (!trimmed) return {};
const looksJson =
contentType?.includes("application/json") || trimmed.startsWith("{") || trimmed.startsWith("[");
if (looksJson) {
try {
const parsed: unknown = JSON.parse(trimmed);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
return parsed as Record<string, unknown>;
}
return { data: parsed };
} catch {
return { text: trimmed };
}
}
return { text: trimmed };
}
export function mountWebhookHttpRoutes(app: Hono, deps: WebhookDeps) {
app.post("/api/v1/bots/:botId/webhook", async (c) => {
const unauthorized = () => c.json({ error: "Unauthorized" }, 401);
const botId = c.req.param("botId");
const authorization = c.req.header("authorization");
const bot = await deps.prisma.bot.findUnique({
where: { id: botId, archivedAt: null },
select: {
id: true,
spaceId: true,
userId: true,
webhookSecretId: true,
thread: { select: { id: true } },
},
});
// Same 401 for missing bot, missing secret, and bad bearer so bot ids are not enumerable.
if (!bot?.thread || !bot.webhookSecretId) {
return unauthorized();
}
const secret = await deps.prisma.secret.findUnique({
where: { id: bot.webhookSecretId },
select: { id: true, ciphertext: true, kind: true, userId: true, spaceId: true },
});
if (!secret || secret.kind !== WEBHOOK_SECRET_KIND) {
return unauthorized();
}
if (secret.userId !== bot.userId || secret.spaceId !== bot.spaceId) {
return unauthorized();
}
let expected: string;
try {
expected = deps.secrets.load(secret.ciphertext, secret.id);
} catch {
return unauthorized();
}
if (!hasValidBearerToken(authorization, expected)) {
return unauthorized();
}
const raw = await readBoundedBody(c.req.raw, WEBHOOK_MAX_BODY_BYTES);
if (raw === null) {
return c.json({ error: "Payload too large" }, 413);
}
const payload = parseWebhookPayload(raw, c.req.header("content-type"));
const eventPrompt = formatWebhookPrompt(payload);
const webhookRoutines = await deps.prisma.routine.findMany({
where: {
botId: bot.id,
spaceId: bot.spaceId,
active: true,
webhookEnabled: true,
},
select: { id: true, name: true, prompt: true },
orderBy: { updatedAt: "desc" },
take: 5,
});
const promptText =
webhookRoutines.length > 0
? [
...webhookRoutines.map(
(routine) => `Run routine "${routine.name}":\n${routine.prompt.trim()}`,
),
"",
"Inbound webhook payload:",
eventPrompt,
].join("\n")
: eventPrompt;
const idempotencyKey =
c.req.header("idempotency-key")?.trim() ||
c.req.header("x-idempotency-key")?.trim() ||
(typeof payload.id === "string" ? payload.id.trim() : "") ||
(typeof payload.event_id === "string" ? payload.event_id.trim() : "") ||
undefined;
const clientNonce = idempotencyKey
? `webhook:${bot.id}:${createHash("sha256").update(idempotencyKey).digest("base64url")}`
: undefined;
const sent = await deps.events.sendUserMessage({
spaceId: bot.spaceId,
threadId: bot.thread.id,
botId: bot.id,
userId: bot.userId,
blocks: [{ kind: "text", text: promptText }],
prompt: promptText,
trigger: "webhook",
clientNonce,
});
if (sent.runId) {
await deps.jobs.enqueue(runContinueJob(sent.runId)).catch((error) => {
console.error("webhook run enqueue error", error);
});
}
return c.json({ ok: true, messageId: sent.messageId, runId: sent.runId, seq: sent.seq });
});
}

9
apps/api/tsconfig.json Normal file
View File

@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"noEmit": true
},
"include": ["src"],
"exclude": ["src/**/*.test.ts"]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 722 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 880 KiB

View File

@ -0,0 +1,15 @@
import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: ".",
testMatch: "*.spec.ts",
fullyParallel: false,
workers: 1,
timeout: 30_000,
expect: { timeout: 5_000 },
reporter: "list",
use: {
screenshot: "only-on-failure",
trace: "retain-on-failure",
},
});

View File

@ -0,0 +1,541 @@
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { createServer, type Server } from "node:http";
import { tmpdir } from "node:os";
import path from "node:path";
import { type ElectronApplication, _electron as electron, expect, test } from "@playwright/test";
const APP_MARKER = "Existing BangSo Bot instance ready";
let server: Server;
let serverUrl: string;
let closedUrl: string;
let userData: string;
let app: ElectronApplication | undefined;
/** A port nothing listens on, so a connection attempt is refused rather than blocked. */
async function reserveClosedPort() {
const probe = createServer();
await new Promise<void>((resolve) => probe.listen(0, "127.0.0.1", resolve));
const address = probe.address();
if (address === null || typeof address === "string") throw new Error("probe has no port");
const { port } = address;
await new Promise<void>((resolve, reject) => {
probe.close((error) => (error ? reject(error) : resolve()));
});
return `http://127.0.0.1:${port}`;
}
test.beforeAll(async () => {
server = createServer((request, response) => {
if (request.url === "/rpc/health" && request.method === "POST") {
response.writeHead(200, { "content-type": "application/json; charset=utf-8" });
response.end(JSON.stringify({ json: { ok: true, version: "0.1.0" } }));
return;
}
response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
response.end(
`<!doctype html><html lang="en"><head><meta charset="utf-8"><title>BangSo Bot</title></head><body><main>${APP_MARKER}</main></body></html>`,
);
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (address === null || typeof address === "string") throw new Error("stub server has no port");
serverUrl = `http://127.0.0.1:${address.port}`;
closedUrl = await reserveClosedPort();
});
test.afterAll(async () => {
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
});
test.beforeEach(async () => {
userData = await mkdtemp(path.join(tmpdir(), "rakazo-desktop-e2e-"));
});
test.afterEach(async () => {
await app?.close();
app = undefined;
await rm(userData, { recursive: true, force: true });
});
function launch(extraEnv: Record<string, string> = {}) {
const env = { ...process.env, RAKAZO_PERFORMANCE_USER_DATA: userData };
// A stale RAKAZO_WEB_URL from the developer's shell would bypass setup entirely.
delete env.RAKAZO_WEB_URL;
return electron.launch({
args: ["."],
cwd: path.resolve(import.meta.dirname, ".."),
env: { ...env, ...extraEnv },
});
}
test("first run asks whether to use a local or existing instance", async () => {
app = await launch();
const setup = await app.firstWindow();
await expect(setup.getByRole("heading", { name: "Welcome to BangSo Bot" })).toBeVisible();
await expect(setup.getByText("This computer")).toBeVisible();
await expect(setup.getByText("Existing instance")).toBeVisible();
// A new instance is the default and points at the local development stack.
await expect(setup.getByRole("radio", { name: /This computer/ })).toBeChecked();
await expect(setup.locator("#local-url")).toHaveValue("http://127.0.0.1:5173");
await expect(setup.locator("#panel-existing")).toBeHidden();
await setup.screenshot({
path: path.join(import.meta.dirname, "screenshots", "01-setup-new-instance.png"),
});
});
test("connecting to an existing instance verifies, saves, and opens it", async () => {
app = await launch();
const setup = await app.firstWindow();
await setup.getByRole("radio", { name: /Existing instance/ }).check();
await expect(setup.locator("#panel-new")).toBeHidden();
await setup.locator("#server-url").fill(serverUrl);
await setup.getByRole("button", { name: "Check connection" }).click();
await expect(setup.locator("#status")).toHaveText(`BangSo Bot answered at ${serverUrl}.`);
await expect(setup.locator("#status")).toHaveAttribute("data-tone", "ok");
await setup.screenshot({
path: path.join(import.meta.dirname, "screenshots", "02-setup-existing-verified.png"),
});
const appWindow = await Promise.all([
app.waitForEvent("window"),
setup.getByRole("button", { name: "Continue" }).click(),
]).then(([window]) => window);
await expect(appWindow.getByText(APP_MARKER)).toBeVisible();
await appWindow.screenshot({
path: path.join(import.meta.dirname, "screenshots", "03-connected-instance.png"),
});
// Continue can paint the app window before setup.json finishes flushing to disk.
await expect
.poll(async () => {
try {
return JSON.parse(await readFile(path.join(userData, "setup.json"), "utf8"));
} catch {
return null;
}
})
.toEqual({ mode: "existing", serverUrl });
});
test("Continue verifies and remembers the instance so setup does not run again", async () => {
app = await launch();
const setup = await app.firstWindow();
await setup.getByRole("radio", { name: /Existing instance/ }).check();
await setup.locator("#server-url").fill(serverUrl);
const firstRun = await Promise.all([
app.waitForEvent("window"),
setup.getByRole("button", { name: "Continue" }).click(),
]).then(([window]) => window);
await expect(firstRun.getByText(APP_MARKER)).toBeVisible();
// Continue can paint the app window before setup.json finishes flushing to disk.
await expect
.poll(async () => {
try {
return JSON.parse(await readFile(path.join(userData, "setup.json"), "utf8"));
} catch {
return null;
}
})
.toEqual({ mode: "existing", serverUrl });
await app.close();
app = await launch();
const relaunched = await app.firstWindow();
await expect(relaunched.getByText(APP_MARKER)).toBeVisible();
await expect(relaunched.locator("#setup")).toHaveCount(0);
});
test("an unreachable address is reported instead of being saved", async () => {
app = await launch();
const setup = await app.firstWindow();
await setup.getByRole("radio", { name: /Existing instance/ }).check();
await setup.locator("#server-url").fill(closedUrl);
await setup.getByRole("button", { name: "Check connection" }).click();
await expect(setup.locator("#status")).toHaveAttribute("data-tone", "error");
await expect(setup.locator("#status")).toHaveText("Nothing is listening at that address yet.");
await setup.getByRole("button", { name: "Continue" }).click();
await expect(setup.locator("#status")).toHaveText("Nothing is listening at that address yet.");
await expect(async () => {
await expect(readFile(path.join(userData, "setup.json"), "utf8")).rejects.toThrow();
}).toPass();
await setup.screenshot({
path: path.join(import.meta.dirname, "screenshots", "04-setup-unreachable.png"),
});
});
test("an HTTP error document is not accepted after a healthy probe", async () => {
const broken = createServer((request, response) => {
if (request.url === "/rpc/health" && request.method === "POST") {
response.writeHead(200, { "content-type": "application/json; charset=utf-8" });
response.end(JSON.stringify({ json: { ok: true, version: "0.1.0" } }));
return;
}
response.writeHead(503, { "content-type": "text/html; charset=utf-8" });
response.end("<!doctype html><html><body><h1>Unavailable</h1></body></html>");
});
await new Promise<void>((resolve) => broken.listen(0, "127.0.0.1", resolve));
const address = broken.address();
if (address === null || typeof address === "string") throw new Error("broken server has no port");
try {
app = await launch();
const setup = await app.firstWindow();
await setup.getByRole("radio", { name: /Existing instance/ }).check();
await setup.locator("#server-url").fill(`http://127.0.0.1:${address.port}`);
await setup.getByRole("button", { name: "Continue" }).click();
await expect(setup.locator("#status")).toContainText("Could not open that server.");
await expect(async () => {
await expect(readFile(path.join(userData, "setup.json"), "utf8")).rejects.toThrow();
}).toPass();
} finally {
await new Promise<void>((resolve, reject) => {
broken.close((error) => (error ? reject(error) : resolve()));
});
}
});
test("a session-pending shell skeleton is not accepted as a ready app", async () => {
const skeletonHtml = `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>BangSo Bot</title></head>
<body><div id="root"><div data-rakazo-app-state="session-pending"><aside></aside><main><div>Opening your Space</div></main></div></div></body></html>`;
const skeleton = createServer((request, response) => {
if (request.url === "/rpc/health" && request.method === "POST") {
response.writeHead(200, { "content-type": "application/json; charset=utf-8" });
response.end(JSON.stringify({ json: { ok: true, version: "0.1.0" } }));
return;
}
response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
response.end(skeletonHtml);
});
await new Promise<void>((resolve) => skeleton.listen(0, "127.0.0.1", resolve));
const address = skeleton.address();
if (address === null || typeof address === "string")
throw new Error("skeleton server has no port");
try {
app = await launch();
const setup = await app.firstWindow();
await setup.getByRole("radio", { name: /Existing instance/ }).check();
await setup.locator("#server-url").fill(`http://127.0.0.1:${address.port}`);
await setup.getByRole("button", { name: "Continue" }).click();
await expect(setup.locator("#status")).toContainText("Could not open that server.", {
timeout: 15_000,
});
await expect(async () => {
await expect(readFile(path.join(userData, "setup.json"), "utf8")).rejects.toThrow();
}).toPass();
} finally {
await new Promise<void>((resolve, reject) => {
skeleton.close((error) => (error ? reject(error) : resolve()));
});
}
});
test("a post-session ready app mount is accepted", async () => {
const readyHtml = `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>BangSo Bot</title>
<script>performance.mark("rk:renderer:session-committed");performance.mark("rk:renderer:shell-ready");</script>
</head>
<body><div id="root"><div data-rakazo-app-state="ready"><div data-testid="shell-root" data-ready="true">Workspace</div></div></div></body></html>`;
const ready = createServer((request, response) => {
if (request.url === "/rpc/health" && request.method === "POST") {
response.writeHead(200, { "content-type": "application/json; charset=utf-8" });
response.end(JSON.stringify({ json: { ok: true, version: "0.1.0" } }));
return;
}
response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
response.end(readyHtml);
});
await new Promise<void>((resolve) => ready.listen(0, "127.0.0.1", resolve));
const address = ready.address();
if (address === null || typeof address === "string") throw new Error("ready server has no port");
try {
app = await launch();
const setup = await app.firstWindow();
await setup.getByRole("radio", { name: /Existing instance/ }).check();
await setup.locator("#server-url").fill(`http://127.0.0.1:${address.port}`);
const appWindow = await Promise.all([
app.waitForEvent("window"),
setup.getByRole("button", { name: "Continue" }).click(),
]).then(([window]) => window);
await expect(appWindow.getByTestId("shell-root")).toBeVisible();
await expect
.poll(async () => {
try {
return JSON.parse(await readFile(path.join(userData, "setup.json"), "utf8"));
} catch {
return null;
}
})
.toEqual({
mode: "existing",
serverUrl: `http://127.0.0.1:${address.port}`,
});
} finally {
await new Promise<void>((resolve, reject) => {
ready.close((error) => (error ? reject(error) : resolve()));
});
}
});
test("a shell mount before workspace bootstrap is not accepted", async () => {
const preBootstrapHtml = `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>BangSo Bot</title></head>
<body><div id="root"><div data-rakazo-app-state="ready"><div data-testid="shell-root" data-ready="false">Workspace</div></div></div></body></html>`;
const preBootstrap = createServer((request, response) => {
if (request.url === "/rpc/health" && request.method === "POST") {
response.writeHead(200, { "content-type": "application/json; charset=utf-8" });
response.end(JSON.stringify({ json: { ok: true, version: "0.1.0" } }));
return;
}
response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
response.end(preBootstrapHtml);
});
await new Promise<void>((resolve) => preBootstrap.listen(0, "127.0.0.1", resolve));
const address = preBootstrap.address();
if (address === null || typeof address === "string")
throw new Error("pre-bootstrap server has no port");
try {
app = await launch();
const setup = await app.firstWindow();
await setup.getByRole("radio", { name: /Existing instance/ }).check();
await setup.locator("#server-url").fill(`http://127.0.0.1:${address.port}`);
await setup.getByRole("button", { name: "Continue" }).click();
await expect(setup.locator("#status")).toContainText("Could not open that server.", {
timeout: 15_000,
});
await expect(async () => {
await expect(readFile(path.join(userData, "setup.json"), "utf8")).rejects.toThrow();
}).toPass();
} finally {
await new Promise<void>((resolve, reject) => {
preBootstrap.close((error) => (error ? reject(error) : resolve()));
});
}
});
test("a session-ready marker without a route surface is not accepted", async () => {
const emptyReadyHtml = `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>BangSo Bot</title></head>
<body><div id="root"><div data-rakazo-app-state="ready" class="h-full"></div></div></body></html>`;
const emptyReady = createServer((request, response) => {
if (request.url === "/rpc/health" && request.method === "POST") {
response.writeHead(200, { "content-type": "application/json; charset=utf-8" });
response.end(JSON.stringify({ json: { ok: true, version: "0.1.0" } }));
return;
}
response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
response.end(emptyReadyHtml);
});
await new Promise<void>((resolve) => emptyReady.listen(0, "127.0.0.1", resolve));
const address = emptyReady.address();
if (address === null || typeof address === "string")
throw new Error("empty-ready server has no port");
try {
app = await launch();
const setup = await app.firstWindow();
await setup.getByRole("radio", { name: /Existing instance/ }).check();
await setup.locator("#server-url").fill(`http://127.0.0.1:${address.port}`);
await setup.getByRole("button", { name: "Continue" }).click();
await expect(setup.locator("#status")).toContainText("Could not open that server.", {
timeout: 15_000,
});
await expect(async () => {
await expect(readFile(path.join(userData, "setup.json"), "utf8")).rejects.toThrow();
}).toPass();
} finally {
await new Promise<void>((resolve, reject) => {
emptyReady.close((error) => (error ? reject(error) : resolve()));
});
}
});
test("a malformed address is rejected before anything is written", async () => {
app = await launch();
const setup = await app.firstWindow();
await setup.getByRole("radio", { name: /Existing instance/ }).check();
await setup.locator("#server-url").fill("not a server");
await setup.getByRole("button", { name: "Continue" }).click();
await expect(setup.locator("#status")).toContainText("Enter a valid server address.");
await expect(async () => {
await expect(readFile(path.join(userData, "setup.json"), "utf8")).rejects.toThrow();
}).toPass();
});
test("a generic web page is not accepted as a BangSo Bot server", async () => {
const plain = createServer((_request, response) => {
response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
response.end("<!doctype html><p>not BangSo Bot</p>");
});
await new Promise<void>((resolve) => plain.listen(0, "127.0.0.1", resolve));
const address = plain.address();
if (address === null || typeof address === "string") throw new Error("plain server has no port");
try {
app = await launch();
const setup = await app.firstWindow();
await setup.getByRole("radio", { name: /Existing instance/ }).check();
await setup.locator("#server-url").fill(`http://127.0.0.1:${address.port}`);
await setup.getByRole("button", { name: "Continue" }).click();
await expect(setup.locator("#status")).toHaveText(
"That address did not respond like a BangSo Bot server.",
);
await expect(async () => {
await expect(readFile(path.join(userData, "setup.json"), "utf8")).rejects.toThrow();
}).toPass();
} finally {
await new Promise<void>((resolve, reject) => {
plain.close((error) => (error ? reject(error) : resolve()));
});
}
});
test("the setup probe refuses redirects instead of following them", async () => {
const redirect = createServer((_request, response) => {
response.writeHead(302, { location: `${serverUrl}/rpc/health` });
response.end();
});
await new Promise<void>((resolve) => redirect.listen(0, "127.0.0.1", resolve));
const address = redirect.address();
if (address === null || typeof address === "string") {
throw new Error("redirect server has no port");
}
try {
app = await launch();
const setup = await app.firstWindow();
await setup.getByRole("radio", { name: /Existing instance/ }).check();
await setup.locator("#server-url").fill(`http://127.0.0.1:${address.port}`);
await setup.getByRole("button", { name: "Continue" }).click();
// Electron's net.fetch with redirect:"manual" surfaces redirects as a
// network failure rather than an HTTP 3xx body we can classify.
await expect(setup.locator("#status")).toHaveText("Could not reach that address.");
await expect(async () => {
await expect(readFile(path.join(userData, "setup.json"), "utf8")).rejects.toThrow();
}).toPass();
} finally {
await new Promise<void>((resolve, reject) => {
redirect.close((error) => (error ? reject(error) : resolve()));
});
}
});
test("an unreachable saved server falls back to setup with a recovery message", async () => {
await writeFile(
path.join(userData, "setup.json"),
`${JSON.stringify({ mode: "existing", serverUrl: closedUrl }, null, 2)}\n`,
{ encoding: "utf8", mode: 0o600 },
);
app = await launch();
const setup = await app.firstWindow();
await expect(setup.getByRole("heading", { name: "Welcome to BangSo Bot" })).toBeVisible();
await expect(setup.getByRole("radio", { name: /Existing instance/ })).toBeChecked();
await expect(setup.locator("#server-url")).toHaveValue(closedUrl);
await expect(setup.locator("#status")).toContainText("Could not reconnect to the saved server.");
await setup.screenshot({
path: path.join(import.meta.dirname, "screenshots", "05-saved-server-recovery.png"),
});
});
test("the native application menu can reopen setup without exposing setup IPC to the server", async () => {
app = await launch({ RAKAZO_WEB_URL: serverUrl });
const appWindow = await app.firstWindow();
await expect(appWindow.getByText(APP_MARKER)).toBeVisible();
const setupPromise = app.waitForEvent("window");
await app.evaluate(({ Menu }) => {
const item = Menu.getApplicationMenu()?.getMenuItemById("change-rakazo-server");
if (!item) throw new Error("Change server menu item is missing");
item.click();
});
const setup = await setupPromise;
await expect(setup.getByRole("heading", { name: "Welcome to BangSo Bot" })).toBeVisible();
await expect(setup.locator("#status")).toBeEmpty();
// Closing setup without saving restores the connected instance.
await setup.close();
await expect(appWindow.getByText(APP_MARKER)).toBeVisible();
});
test("servers on the same host but different ports do not share login cookies", async () => {
const first = createServer((_request, response) => {
response.writeHead(200, {
"content-type": "text/html; charset=utf-8",
"set-cookie": "rakazo_session=fake-one; Path=/; SameSite=Lax",
});
response.end("<!doctype html><main>Cookie stored</main>");
});
const second = createServer((request, response) => {
response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
response.end(`<!doctype html><main>Cookies: ${request.headers.cookie ?? "none"}</main>`);
});
await Promise.all([
new Promise<void>((resolve) => first.listen(0, "127.0.0.1", resolve)),
new Promise<void>((resolve) => second.listen(0, "127.0.0.1", resolve)),
]);
const firstAddress = first.address();
const secondAddress = second.address();
if (
firstAddress === null ||
typeof firstAddress === "string" ||
secondAddress === null ||
typeof secondAddress === "string"
) {
throw new Error("cookie fixture server has no port");
}
try {
app = await launch({ RAKAZO_WEB_URL: `http://127.0.0.1:${firstAddress.port}` });
const firstWindow = await app.firstWindow();
await expect(firstWindow.getByText("Cookie stored")).toBeVisible();
await expect.poll(() => firstWindow.evaluate(() => document.cookie)).toContain("fake-one");
await app.close();
app = await launch({ RAKAZO_WEB_URL: `http://127.0.0.1:${secondAddress.port}` });
const secondWindow = await app.firstWindow();
await expect(secondWindow.getByText("Cookies: none")).toBeVisible();
} finally {
await Promise.all([
new Promise<void>((resolve, reject) =>
first.close((error) => (error ? reject(error) : resolve())),
),
new Promise<void>((resolve, reject) =>
second.close((error) => (error ? reject(error) : resolve())),
),
]);
}
});
test("setup IPC is not reachable from the connected app window", async () => {
app = await launch({ RAKAZO_WEB_URL: serverUrl });
const appWindow = await app.firstWindow();
await expect(appWindow.getByText(APP_MARKER)).toBeVisible();
const exposed = await appWindow.evaluate(() =>
Object.keys((window as typeof window & { rakazoSetup?: unknown }).rakazoSetup ?? {}),
);
expect(exposed).toEqual([]);
});

View File

@ -0,0 +1,82 @@
import path from "node:path";
import { _electron as electron, expect, test } from "@playwright/test";
import type { RakazoDesktop } from "@rakazo/contracts";
const fixture = `<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>Rakazo desktop smoke</title></head>
<body><main>Desktop fixture ready</main></body>
</html>`;
test("launches with a narrow preload bridge and an isolated renderer", async () => {
const app = await electron.launch({
args: ["."],
cwd: path.resolve(import.meta.dirname, ".."),
env: {
...process.env,
RAKAZO_WEB_URL: `data:text/html;charset=utf-8,${encodeURIComponent(fixture)}`,
},
});
try {
const page = await app.firstWindow();
await expect(page.getByText("Desktop fixture ready")).toBeVisible();
await expect(page).toHaveTitle("Rakazo desktop smoke");
const renderer = await page.evaluate(async () => {
const desktop = (window as typeof window & { rakazoDesktop?: RakazoDesktop }).rakazoDesktop;
return {
bridgeKeys: desktop ? Object.keys(desktop).sort() : [],
windowKeys: desktop ? Object.keys(desktop.window).sort() : [],
updateKeys: desktop ? Object.keys(desktop.update).sort() : [],
platform: desktop?.platform,
state: await desktop?.window.state(),
update: await desktop?.update.state(),
nodeGlobals: {
require: typeof (window as unknown as { require?: unknown }).require,
process: typeof (window as unknown as { process?: unknown }).process,
module: typeof (window as unknown as { module?: unknown }).module,
},
};
});
expect(renderer.bridgeKeys).toEqual(["oauth", "platform", "update", "window"]);
expect(renderer.windowKeys).toEqual(["close", "minimize", "state", "toggleMaximize"]);
expect(renderer.updateKeys).toEqual(["check", "download", "install", "state"]);
expect(renderer.platform).toBe(process.platform);
expect(renderer.state).toEqual({ minimized: false, maximized: false, fullScreen: false });
// An unpackaged run has no update feed, and that is reported as a state rather than an error.
expect(renderer.update).toMatchObject({ phase: "unsupported", availableVersion: null });
expect(renderer.nodeGlobals).toEqual({
require: "undefined",
process: "undefined",
module: "undefined",
});
const preferences = await app.evaluate(({ BrowserWindow }) => {
const win = BrowserWindow.getAllWindows()[0];
return {
count: BrowserWindow.getAllWindows().length,
nodeIntegration: win?.webContents.getLastWebPreferences().nodeIntegration,
contextIsolation: win?.webContents.getLastWebPreferences().contextIsolation,
sandbox: win?.webContents.getLastWebPreferences().sandbox,
state: {
minimized: win?.isMinimized(),
maximized: win?.isMaximized(),
fullScreen: win?.isFullScreen(),
},
};
});
expect(preferences).toEqual({
count: 1,
nodeIntegration: false,
contextIsolation: true,
sandbox: true,
state: renderer.state,
});
} finally {
await app.close();
}
});

View File

@ -0,0 +1,70 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { _electron as electron, expect, test } from "@playwright/test";
const styles = readFileSync(path.resolve(import.meta.dirname, "../../web/src/styles.css"), "utf8");
const fixture = `<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>Rakazo window drag</title><style>${styles}</style></head>
<body>
<main>Desktop fixture ready</main>
<header class="app-drag" id="conversation-header">
<span>Chief</span>
<button class="app-no-drag" id="bot-settings">Bot settings</button>
</header>
<output id="result"></output>
<script>
document.querySelector('#bot-settings').addEventListener('click', () => {
document.querySelector('#result').textContent = 'opened';
});
</script>
</body>
</html>`;
test("an active Electron window keeps header dragging selection-free and controls clickable", async () => {
const app = await electron.launch({
args: ["."],
cwd: path.resolve(import.meta.dirname, ".."),
env: {
...process.env,
RAKAZO_WEB_URL: `data:text/html;charset=utf-8,${encodeURIComponent(fixture)}`,
},
});
try {
const page = await app.firstWindow();
const active = await app.evaluate(({ BrowserWindow }) => {
const window = BrowserWindow.getAllWindows()[0];
window?.show();
window?.focus();
return window?.isFocused();
});
expect(active).toBe(true);
const regions = await page.evaluate(() => {
const header = document.querySelector("#conversation-header");
const settings = document.querySelector("#bot-settings");
if (!(header instanceof HTMLElement) || !(settings instanceof HTMLElement)) {
throw new Error("missing window chrome fixture");
}
const headerStyle = getComputedStyle(header);
const settingsStyle = getComputedStyle(settings);
return {
header: {
appRegion: headerStyle.getPropertyValue("-webkit-app-region"),
userSelect: headerStyle.userSelect,
},
settings: settingsStyle.getPropertyValue("-webkit-app-region"),
};
});
expect(regions).toEqual({
header: { appRegion: "drag", userSelect: "none" },
settings: "no-drag",
});
await page.getByRole("button", { name: "Bot settings" }).click();
await expect(page.locator("#result")).toHaveText("opened");
} finally {
await app.close();
}
});

78
apps/desktop/package.json Normal file
View File

@ -0,0 +1,78 @@
{
"name": "@rakazo/desktop",
"version": "0.1.0",
"license": "Apache-2.0",
"type": "module",
"main": "dist/main.js",
"scripts": {
"dev": "pnpm build && electron .",
"build": "tsc -p tsconfig.json && node scripts/copy-static.mjs",
"pack": "pnpm --filter @rakazo/web build && pnpm build && electron-builder --mac --win --linux",
"pack:dir": "pnpm --filter @rakazo/web build && pnpm build && electron-builder --dir",
"check": "tsc --noEmit -p tsconfig.json",
"test": "vitest run --root ../.. apps/desktop/src",
"test:e2e": "pnpm build && playwright test --config e2e/playwright.config.ts",
"release": "pnpm --filter @rakazo/web build && pnpm build && electron-builder --publish never"
},
"dependencies": {
"@rakazo/contracts": "workspace:*",
"electron-updater": "^6.8.9"
},
"devDependencies": {
"@playwright/test": "^1.55.0",
"electron": "^43.4.0",
"electron-builder": "^26.0.12",
"typescript": "^5.9.2",
"vitest": "^4.1.10"
},
"build": {
"appId": "dev.rakazo.desktop",
"productName": "BangSo Bot",
"directories": {
"output": "out",
"buildResources": "assets"
},
"files": [
"dist/**/*",
"package.json"
],
"extraResources": [
{
"from": "../web/dist",
"to": "web"
}
],
"extraMetadata": {
"main": "dist/main.js"
},
"mac": {
"icon": "assets/icon.icns",
"category": "public.app-category.productivity",
"target": [
"dmg",
"zip"
],
"notarize": true
},
"win": {
"icon": "assets/icon.ico",
"target": [
"nsis"
],
"verifyUpdateCodeSignature": true
},
"linux": {
"icon": "assets/icon.png",
"target": [
"AppImage"
]
},
"publish": [
{
"provider": "github",
"owner": "elie222",
"repo": "rakazo"
}
]
}
}

View File

@ -0,0 +1,15 @@
import { copyFile, mkdir } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
// tsc only emits the TypeScript sources; the preload bridges and the setup
// window's static assets have to be copied into dist alongside them.
const STATIC_FILES = ["preload.cjs", "setup-preload.cjs", "setup.html", "setup.css", "setup.js"];
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const dist = path.join(root, "dist");
await mkdir(dist, { recursive: true });
await Promise.all(
STATIC_FILES.map((file) => copyFile(path.join(root, "src", file), path.join(dist, file))),
);

View File

@ -0,0 +1,431 @@
import { describe, expect, it, vi } from "vitest";
import {
classifyUpdaterFailure,
DesktopUpdateController,
type ElectronAutoUpdater,
initialUpdateState,
MIN_CHECK_INTERVAL_MS,
reduceUpdateState,
shouldCheck,
type UpdaterEvent,
updaterSupport,
} from "./auto-update.js";
const NOW = "2026-08-22T12:00:00.000Z";
const packaged = { packaged: true, version: "0.1.0" };
const clock = { now: () => 1_000, iso: () => NOW };
function apply(events: UpdaterEvent[], env = packaged) {
return events.reduce(
(state, event) => reduceUpdateState(state, event, NOW),
initialUpdateState(env),
);
}
function fakeUpdater(overrides: Partial<ElectronAutoUpdater> = {}) {
const listeners = new Map<string, (payload: unknown) => void>();
const updater: ElectronAutoUpdater = {
autoDownload: true,
autoInstallOnAppQuit: false,
allowDowngrade: true,
allowPrerelease: true,
disableWebInstaller: false,
on: vi.fn((event, listener) => {
listeners.set(event, listener);
return updater;
}),
checkForUpdates: vi.fn(async () => undefined),
downloadUpdate: vi.fn(async () => undefined),
quitAndInstall: vi.fn(),
...overrides,
};
return {
updater,
emit(event: string, payload?: unknown) {
listeners.get(event)?.(payload);
},
};
}
function deferred() {
let resolve!: () => void;
const promise = new Promise<void>((done) => {
resolve = done;
});
return { promise, resolve };
}
describe("updaterSupport", () => {
it("only runs in an installed build that has not opted out", () => {
expect(updaterSupport(packaged).supported).toBe(true);
expect(updaterSupport({ packaged: false, version: "0.1.0" }).supported).toBe(false);
expect(updaterSupport({ ...packaged, disabled: true }).supported).toBe(false);
});
it("starts unsupported builds in a state that explains itself", () => {
const state = initialUpdateState({ packaged: false, version: "0.1.0" });
expect(state).toMatchObject({ phase: "unsupported", currentVersion: "0.1.0" });
expect(state.message).toContain("installed build");
});
});
describe("classifyUpdaterFailure", () => {
it("treats a repository with no releases as an absent feed, not a fault", () => {
for (const message of [
"HttpError: 404 Not Found",
"Cannot find latest.yml in the latest release",
"No published versions on GitHub",
]) {
expect(classifyUpdaterFailure(new Error(message)).kind, message).toBe("no-releases");
}
});
it("stays silent when the machine is simply offline", () => {
for (const message of ["getaddrinfo ENOTFOUND github.com", "net::ERR_INTERNET_DISCONNECTED"]) {
expect(classifyUpdaterFailure(new Error(message))).toEqual({
kind: "offline",
message: null,
});
}
});
it("surfaces signing failures without leaking the updater's raw URL or path", () => {
const failure = classifyUpdaterFailure(
new Error("Code signature at https://private.invalid/update.zip did not pass validation"),
);
expect(failure.kind).toBe("signature");
expect(failure.message).toContain("could not be verified");
expect(failure.message).not.toContain("private.invalid");
});
it("keeps unclassified updater detail out of the renderer bridge", () => {
const failure = classifyUpdaterFailure(new Error("disk full at /Users/example/private"));
expect(failure).toEqual({
kind: "other",
message: "The update could not be completed. Try again later.",
});
});
});
describe("reduceUpdateState", () => {
it("walks from a check to a downloaded release", () => {
const state = apply([
{ type: "check-start" },
{ type: "available", version: "0.2.0" },
{ type: "download-start" },
{ type: "progress", percent: 42.6 },
{ type: "downloaded", version: "0.2.0" },
]);
expect(state).toMatchObject({
phase: "ready",
availableVersion: "0.2.0",
percent: 100,
});
expect(state.message).toContain("Restart BangSo Bot");
});
it("clamps progress to a percentage while downloading", () => {
expect(
apply([
{ type: "available", version: "0.2.0" },
{ type: "progress", percent: -5 },
]).percent,
).toBe(0);
expect(
apply([
{ type: "available", version: "0.2.0" },
{ type: "download-start" },
{ type: "progress", percent: 250 },
]).percent,
).toBe(100);
});
it("ignores progress outside an active download", () => {
expect(apply([{ type: "progress", percent: 40 }])).toMatchObject({
phase: "idle",
percent: null,
});
expect(
apply([
{ type: "check-start" },
{ type: "failed", error: new Error("network down"), userInitiated: true },
{ type: "progress", percent: 50 },
]),
).toMatchObject({ phase: "error", percent: null });
});
it("clears an offer when a completed check finds nothing", () => {
const state = apply([
{ type: "available", version: "0.2.0" },
{ type: "check-start" },
{ type: "not-available" },
]);
expect(state).toMatchObject({ phase: "idle", availableVersion: null, checkedAt: NOW });
});
it("goes quiet on a missing feed instead of nagging every launch", () => {
const state = apply([
{ type: "check-start" },
{ type: "failed", error: new Error("HttpError: 404 Not Found"), userInitiated: false },
]);
expect(state.phase).toBe("unsupported");
expect(state.message).toContain("No desktop releases");
expect(reduceUpdateState(state, { type: "check-start" }, NOW)).toBe(state);
expect(shouldCheck(state, 10_000_000, 0)).toBe(false);
});
it("keeps a verified download when the updater emits a late failure", () => {
const ready = apply([
{ type: "downloaded", version: "0.2.0" },
{ type: "failed", error: new Error("socket hang up"), userInitiated: false },
]);
expect(ready).toMatchObject({ phase: "ready", availableVersion: "0.2.0", percent: 100 });
});
it("lets an install failure leave the ready phase", () => {
const failed = apply([
{ type: "downloaded", version: "0.2.0" },
{
type: "failed",
error: new Error("quitAndInstall failed"),
userInitiated: true,
installFailed: true,
},
]);
expect(failed).toMatchObject({
phase: "error",
availableVersion: "0.2.0",
message: "The update could not be completed. Try again later.",
});
});
it("does not let a stray check-start interrupt an in-flight download", () => {
const downloading = apply([
{ type: "download-start" },
{ type: "progress", percent: 40 },
{ type: "check-start" },
]);
expect(downloading).toMatchObject({ phase: "downloading", percent: 40 });
});
it("says nothing about being offline unless the user asked", () => {
const offline = new Error("getaddrinfo ENOTFOUND github.com");
expect(apply([{ type: "failed", error: offline, userInitiated: false }]).message).toBeNull();
expect(apply([{ type: "failed", error: offline, userInitiated: true }]).message).toContain(
"Could not reach",
);
});
it("reports other failures with a safe generic message", () => {
const state = apply([{ type: "failed", error: new Error("disk full"), userInitiated: false }]);
expect(state).toMatchObject({
phase: "error",
message: "The update could not be completed. Try again later.",
});
});
});
describe("shouldCheck", () => {
it("allows the first check and then rate-limits", () => {
const idle = initialUpdateState(packaged);
expect(shouldCheck(idle, 1_000, 0)).toBe(true);
expect(shouldCheck(idle, 1_000, 900)).toBe(false);
expect(shouldCheck(idle, MIN_CHECK_INTERVAL_MS + 1_000, 1_000)).toBe(true);
});
it("does not replace work, an available update, or a downloaded update", () => {
for (const state of [
apply([{ type: "check-start" }]),
apply([{ type: "available", version: "0.2.0" }]),
apply([{ type: "download-start" }]),
apply([{ type: "downloaded", version: "0.2.0" }]),
]) {
expect(shouldCheck(state, 10_000_000, 0)).toBe(false);
}
});
it("lets a manual check retry after an empty feed when the install supports updates", () => {
const absent = apply([
{ type: "check-start" },
{ type: "failed", error: new Error("HttpError: 404 Not Found"), userInitiated: false },
]);
expect(
shouldCheck(absent, 1_000, 1_000, {
userInitiated: true,
environmentSupportsUpdates: true,
}),
).toBe(true);
expect(
shouldCheck(absent, 1_000, 1_000, {
userInitiated: true,
environmentSupportsUpdates: false,
}),
).toBe(false);
});
});
describe("DesktopUpdateController", () => {
it("locks the updater to stable, upgrade-only, signed installer behavior", async () => {
const fake = fakeUpdater();
const controller = new DesktopUpdateController(packaged, async () => fake.updater, clock);
await controller.check(false);
expect(fake.updater).toMatchObject({
autoDownload: false,
autoInstallOnAppQuit: true,
allowPrerelease: false,
allowDowngrade: false,
disableWebInstaller: true,
});
});
it("serializes concurrent checks across the asynchronous updater load", async () => {
const check = deferred();
const fake = fakeUpdater({ checkForUpdates: vi.fn(() => check.promise) });
const controller = new DesktopUpdateController(packaged, async () => fake.updater, clock);
const first = controller.check(false);
const second = controller.check(true);
await vi.waitFor(() => expect(fake.updater.checkForUpdates).toHaveBeenCalledTimes(1));
expect(second).toBe(first);
check.resolve();
await first;
});
it("automatically downloads one verified stable update and tracks progress", async () => {
let fake: ReturnType<typeof fakeUpdater>;
fake = fakeUpdater({
checkForUpdates: vi.fn(async () => {
fake.emit("checking-for-update");
fake.emit("update-available", { version: "0.2.0" });
}),
downloadUpdate: vi.fn(async () => {
fake.emit("download-progress", { percent: 48.8 });
fake.emit("update-downloaded", { version: "0.2.0" });
}),
});
const controller = new DesktopUpdateController(packaged, async () => fake.updater, clock);
await controller.check(false);
await vi.waitFor(() => expect(fake.updater.downloadUpdate).toHaveBeenCalledTimes(1));
await vi.waitFor(() => expect(controller.state().phase).toBe("ready"));
expect(controller.state()).toMatchObject({
availableVersion: "0.2.0",
percent: 100,
});
});
it("joins manual downloads to the automatic download already in flight", async () => {
const download = deferred();
let fake: ReturnType<typeof fakeUpdater>;
fake = fakeUpdater({
checkForUpdates: vi.fn(async () => {
fake.emit("checking-for-update");
fake.emit("update-available", { version: "0.2.0" });
}),
downloadUpdate: vi.fn(() => download.promise),
});
const controller = new DesktopUpdateController(packaged, async () => fake.updater, clock);
await controller.check(false);
const first = controller.download();
const second = controller.download();
expect(second).toBe(first);
expect(fake.updater.downloadUpdate).toHaveBeenCalledTimes(1);
download.resolve();
await first;
});
it("does not treat later background failures as user-requested", async () => {
let now = 1_000;
let fake: ReturnType<typeof fakeUpdater>;
fake = fakeUpdater({
checkForUpdates: vi.fn(async () => {
fake.emit("checking-for-update");
if (vi.mocked(fake.updater.checkForUpdates).mock.calls.length === 1) {
fake.emit("update-not-available");
} else {
fake.emit("error", new Error("getaddrinfo ENOTFOUND github.com"));
}
}),
});
const controller = new DesktopUpdateController(packaged, async () => fake.updater, {
now: () => now,
iso: () => NOW,
});
await controller.check(true);
now += MIN_CHECK_INTERVAL_MS;
await controller.check(false);
expect(controller.state()).toMatchObject({ phase: "idle", message: null });
});
it("runs installation at most once", async () => {
let fake: ReturnType<typeof fakeUpdater>;
fake = fakeUpdater({
checkForUpdates: vi.fn(async () => {
fake.emit("checking-for-update");
fake.emit("update-available", { version: "0.2.0" });
}),
downloadUpdate: vi.fn(async () => {
fake.emit("update-downloaded", { version: "0.2.0" });
}),
});
const controller = new DesktopUpdateController(packaged, async () => fake.updater, clock);
await controller.check(false);
await vi.waitFor(() => expect(controller.state().phase).toBe("ready"));
await Promise.all([controller.install(), controller.install()]);
expect(fake.updater.quitAndInstall).toHaveBeenCalledTimes(1);
});
it("reports install failures instead of staying ready", async () => {
let fake: ReturnType<typeof fakeUpdater>;
fake = fakeUpdater({
checkForUpdates: vi.fn(async () => {
fake.emit("checking-for-update");
fake.emit("update-available", { version: "0.2.0" });
}),
downloadUpdate: vi.fn(async () => {
fake.emit("update-downloaded", { version: "0.2.0" });
}),
quitAndInstall: vi.fn(() => {
throw new Error("quitAndInstall failed");
}),
});
const controller = new DesktopUpdateController(packaged, async () => fake.updater, clock);
await controller.check(false);
await vi.waitFor(() => expect(controller.state().phase).toBe("ready"));
const state = await controller.install();
expect(state).toMatchObject({
phase: "error",
message: "The update could not be completed. Try again later.",
});
expect(await controller.install()).toMatchObject({ phase: "error" });
});
it("lets a manual check escape a prior empty-feed freeze", async () => {
let fake: ReturnType<typeof fakeUpdater>;
fake = fakeUpdater({
checkForUpdates: vi.fn(async () => {
fake.emit("checking-for-update");
if (vi.mocked(fake.updater.checkForUpdates).mock.calls.length === 1) {
fake.emit("error", new Error("HttpError: 404 Not Found"));
} else {
fake.emit("update-not-available");
}
}),
});
const controller = new DesktopUpdateController(packaged, async () => fake.updater, clock);
await controller.check(false);
expect(controller.state().phase).toBe("unsupported");
await controller.check(true);
expect(fake.updater.checkForUpdates).toHaveBeenCalledTimes(2);
expect(controller.state()).toMatchObject({ phase: "idle", message: null });
});
});

View File

@ -0,0 +1,409 @@
import type { DesktopUpdateState } from "@rakazo/contracts";
/** Long enough that a cold launch is never competing with the update feed for bandwidth. */
export const LAUNCH_CHECK_DELAY_MS = 8_000;
/** A connected server drives the renderer, so a manual check cannot become a request loop. */
export const MIN_CHECK_INTERVAL_MS = 60_000;
export interface UpdaterEnvironment {
packaged: boolean;
version: string;
disabled?: boolean;
}
export function updaterSupport(env: UpdaterEnvironment): { supported: boolean; reason: string } {
if (env.disabled === true) {
return { supported: false, reason: "Automatic updates are turned off for this install." };
}
if (!env.packaged) {
return { supported: false, reason: "Automatic updates only run in an installed build." };
}
return { supported: true, reason: "" };
}
export function initialUpdateState(env: UpdaterEnvironment): DesktopUpdateState {
const support = updaterSupport(env);
return {
phase: support.supported ? "idle" : "unsupported",
currentVersion: env.version,
availableVersion: null,
percent: null,
message: support.supported ? null : support.reason,
checkedAt: null,
};
}
export type UpdaterFailure =
| { kind: "no-releases"; message: string }
| { kind: "offline"; message: null }
| { kind: "signature"; message: string }
| { kind: "other"; message: string };
const NO_RELEASES = [
"404 not found",
"no published versions",
"cannot find latest.yml",
"cannot find latest-mac.yml",
"unable to find latest version",
];
const OFFLINE = [
"enotfound",
"econnrefused",
"econnreset",
"etimedout",
"eai_again",
"enetunreach",
"net::err_",
"getaddrinfo",
];
const SIGNATURE = ["code sign", "signature", "not signed", "notariz"];
/** Error text can contain local paths or URLs, so renderer-visible messages stay generic. */
export function classifyUpdaterFailure(error: unknown): UpdaterFailure {
const text = (error instanceof Error ? error.message : String(error)).trim().toLowerCase();
if (NO_RELEASES.some((needle) => text.includes(needle))) {
return {
kind: "no-releases",
message: "No desktop releases are published for this build yet.",
};
}
if (OFFLINE.some((needle) => text.includes(needle))) {
return { kind: "offline", message: null };
}
if (SIGNATURE.some((needle) => text.includes(needle))) {
return {
kind: "signature",
message: "This update could not be verified. Reinstall BangSo Bot from a trusted download.",
};
}
return {
kind: "other",
message: "The update could not be completed. Try again later.",
};
}
export type UpdaterEvent =
| { type: "check-start" }
| { type: "available"; version: string }
| { type: "not-available" }
| { type: "download-start" }
| { type: "progress"; percent: number }
| { type: "downloaded"; version: string }
| { type: "failed"; error: unknown; userInitiated: boolean; installFailed?: boolean };
export function reduceUpdateState(
state: DesktopUpdateState,
event: UpdaterEvent,
now: string,
): DesktopUpdateState {
if (state.phase === "unsupported") return state;
switch (event.type) {
case "check-start":
// A verified download in flight must not be reset by a stray updater event.
if (state.phase === "available" || state.phase === "downloading" || state.phase === "ready") {
return state;
}
return { ...state, phase: "checking", percent: null, message: null };
case "available":
if (state.phase === "downloading" || state.phase === "ready") return state;
return {
...state,
phase: "available",
availableVersion: event.version,
percent: null,
message: null,
checkedAt: now,
};
case "not-available":
if (state.phase === "downloading" || state.phase === "ready") return state;
return {
...state,
phase: "idle",
availableVersion: null,
percent: null,
message: null,
checkedAt: now,
};
case "download-start":
if (state.phase === "ready") return state;
return { ...state, phase: "downloading", percent: 0, message: null };
case "progress":
// Only advance while a download is expected; late progress after error/idle
// must not force the UI back into downloading.
if (state.phase !== "available" && state.phase !== "downloading") return state;
return {
...state,
phase: "downloading",
percent: Math.max(0, Math.min(100, Math.round(event.percent))),
};
case "downloaded":
return {
...state,
phase: "ready",
availableVersion: event.version,
percent: 100,
message: "Restart BangSo Bot to finish the update.",
};
case "failed": {
// electron-updater can emit late errors after a verified download; keep installable
// state unless this failure came from quitAndInstall itself.
if (state.phase === "ready" && event.installFailed !== true) return state;
const failure = classifyUpdaterFailure(event.error);
if (failure.kind === "no-releases" && state.phase === "checking") {
return {
...state,
phase: "unsupported",
availableVersion: null,
percent: null,
message: failure.message,
checkedAt: now,
};
}
if (failure.kind === "no-releases") {
return {
...state,
phase: "error",
percent: null,
message: "The update could not be completed. Try again later.",
checkedAt: now,
};
}
if (failure.kind === "offline" && event.installFailed !== true) {
return {
...state,
phase: "idle",
percent: null,
message: event.userInitiated ? "Could not reach the update server." : null,
checkedAt: now,
};
}
return { ...state, phase: "error", percent: null, message: failure.message, checkedAt: now };
}
}
}
export interface ShouldCheckOptions {
/** Manual checks may retry after a prior empty feed; unpackaged installs stay frozen. */
userInitiated?: boolean;
environmentSupportsUpdates?: boolean;
}
/** Checks never replace an offer that is already downloading or ready to install. */
export function shouldCheck(
state: DesktopUpdateState,
now: number,
lastCheck: number,
options: ShouldCheckOptions = {},
): boolean {
if (state.phase === "unsupported") {
return options.userInitiated === true && options.environmentSupportsUpdates === true;
}
if (state.phase !== "idle" && state.phase !== "error") return false;
return now - lastCheck >= MIN_CHECK_INTERVAL_MS || lastCheck === 0;
}
export interface ElectronAutoUpdater {
autoDownload: boolean;
autoInstallOnAppQuit: boolean;
allowDowngrade: boolean;
allowPrerelease: boolean;
disableWebInstaller: boolean;
on: (event: string, listener: (payload: unknown) => void) => unknown;
checkForUpdates: () => Promise<unknown>;
downloadUpdate: () => Promise<unknown>;
quitAndInstall: () => void;
}
interface UpdateClock {
now: () => number;
iso: () => string;
}
const systemClock: UpdateClock = {
now: Date.now,
iso: () => new Date().toISOString(),
};
function versionFrom(payload: unknown): string | null {
if (typeof payload !== "object" || payload === null || !("version" in payload)) return null;
return typeof payload.version === "string" && payload.version.trim() !== ""
? payload.version
: null;
}
function percentFrom(payload: unknown): number | null {
if (typeof payload !== "object" || payload === null || !("percent" in payload)) return null;
return typeof payload.percent === "number" && Number.isFinite(payload.percent)
? payload.percent
: null;
}
/** Owns updater configuration and serializes renderer and launch-time operations. */
export class DesktopUpdateController {
private current: DesktopUpdateState;
private lastCheck = 0;
private updaterPromise: Promise<ElectronAutoUpdater | null> | null = null;
private checkPromise: Promise<DesktopUpdateState> | null = null;
private downloadPromise: Promise<DesktopUpdateState> | null = null;
private checkWasRequested = false;
private installStarted = false;
constructor(
private readonly environment: UpdaterEnvironment,
private readonly loadUpdater: () => Promise<ElectronAutoUpdater>,
private readonly clock: UpdateClock = systemClock,
) {
this.current = initialUpdateState(environment);
}
state() {
return this.current;
}
private push(event: UpdaterEvent) {
this.current = reduceUpdateState(this.current, event, this.clock.iso());
if (event.type === "not-available" || event.type === "downloaded" || event.type === "failed") {
this.checkWasRequested = false;
}
}
private async updater(): Promise<ElectronAutoUpdater | null> {
if (!updaterSupport(this.environment).supported) return null;
if (this.updaterPromise !== null) return this.updaterPromise;
const loading = this.loadUpdater()
.then((updater) => {
updater.autoDownload = false;
updater.autoInstallOnAppQuit = true;
updater.allowPrerelease = false;
updater.allowDowngrade = false;
updater.disableWebInstaller = true;
updater.on("checking-for-update", () => this.push({ type: "check-start" }));
updater.on("update-available", (payload) => {
const version = versionFrom(payload);
if (version === null) {
this.push({
type: "failed",
error: new Error("The update feed did not include a version."),
userInitiated: this.checkWasRequested,
});
return;
}
this.push({ type: "available", version });
void this.download();
});
updater.on("update-not-available", () => this.push({ type: "not-available" }));
updater.on("download-progress", (payload) => {
const percent = percentFrom(payload);
if (percent !== null) this.push({ type: "progress", percent });
});
updater.on("update-downloaded", (payload) => {
const version = versionFrom(payload);
if (version !== null) {
this.push({ type: "downloaded", version });
} else {
this.push({
type: "failed",
error: new Error("The downloaded update did not include a version."),
userInitiated: this.checkWasRequested,
});
}
});
updater.on("error", (error) =>
this.push({ type: "failed", error, userInitiated: this.checkWasRequested }),
);
return updater;
})
.catch((error: unknown) => {
this.push({ type: "failed", error, userInitiated: this.checkWasRequested });
if (this.updaterPromise === loading) this.updaterPromise = null;
return null;
});
this.updaterPromise = loading;
return loading;
}
check(userInitiated: boolean) {
if (userInitiated) this.checkWasRequested = true;
if (this.checkPromise !== null) return this.checkPromise;
const checking = this.runCheck().finally(() => {
if (this.checkPromise === checking) this.checkPromise = null;
});
this.checkPromise = checking;
return checking;
}
private async runCheck() {
const checkOptions = {
userInitiated: this.checkWasRequested,
environmentSupportsUpdates: updaterSupport(this.environment).supported,
};
let now = this.clock.now();
if (!shouldCheck(this.current, now, this.lastCheck, checkOptions)) return this.current;
// A prior empty feed freezes automatic checks; a manual retry clears that freeze.
if (this.current.phase === "unsupported" && checkOptions.environmentSupportsUpdates) {
this.current = {
...this.current,
phase: "idle",
availableVersion: null,
percent: null,
message: null,
};
// Do not make the user wait out the launch-check interval to retry.
this.lastCheck = 0;
}
const updater = await this.updater();
if (updater === null) return this.current;
now = this.clock.now();
if (!shouldCheck(this.current, now, this.lastCheck, checkOptions)) return this.current;
this.lastCheck = now;
try {
await updater.checkForUpdates();
} catch (error) {
if (this.current.phase === "idle" || this.current.phase === "checking") {
this.push({ type: "failed", error, userInitiated: this.checkWasRequested });
}
}
return this.current;
}
download() {
if (this.downloadPromise !== null) return this.downloadPromise;
const downloading = this.runDownload().finally(() => {
if (this.downloadPromise === downloading) this.downloadPromise = null;
});
this.downloadPromise = downloading;
return downloading;
}
private async runDownload() {
if (this.current.phase !== "available") return this.current;
const updater = await this.updater();
if (updater === null || this.current.phase !== "available") return this.current;
this.push({ type: "download-start" });
try {
await updater.downloadUpdate();
} catch (error) {
if (this.state().phase === "downloading") {
this.push({ type: "failed", error, userInitiated: this.checkWasRequested });
}
}
return this.current;
}
async install() {
if (this.installStarted || this.current.phase !== "ready") return this.current;
const updater = await this.updater();
if (updater === null || this.installStarted || this.current.phase !== "ready") {
return this.current;
}
this.installStarted = true;
try {
updater.quitAndInstall();
} catch (error) {
this.installStarted = false;
this.push({ type: "failed", error, userInitiated: true, installFailed: true });
}
return this.current;
}
}

1036
apps/desktop/src/main.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import { oauthCallbackFrom } from "./oauth-callback.js";
describe("loopback OAuth callbacks", () => {
it("reads the code and state Anthropic redirects with", () => {
expect(
oauthCallbackFrom("http://localhost:53692/callback?code=ac_123&state=verifier_456"),
).toEqual({ code: "ac_123", state: "verifier_456" });
});
it("accepts the loopback addresses a provider may redirect to", () => {
expect(oauthCallbackFrom("http://127.0.0.1:53692/callback?code=ac_123")).toEqual({
code: "ac_123",
});
expect(oauthCallbackFrom("http://[::1]:53692/callback?code=ac_123")).toEqual({
code: "ac_123",
});
});
it("omits state when the provider redirects without one", () => {
expect(oauthCallbackFrom("http://localhost:53692/callback?code=ac_123")).toEqual({
code: "ac_123",
});
});
it("ignores the authorize page and other steps of the flow", () => {
expect(oauthCallbackFrom("https://claude.ai/oauth/authorize?code=true")).toBeUndefined();
expect(oauthCallbackFrom("http://localhost:53692/callback")).toBeUndefined();
expect(oauthCallbackFrom("http://localhost:5173/")).toBeUndefined();
});
it("does not treat a remote host as a loopback callback", () => {
expect(oauthCallbackFrom("https://example.com/callback?code=ac_123")).toBeUndefined();
expect(oauthCallbackFrom("https://localhost.example.com/callback?code=ac_123")).toBeUndefined();
});
it("ignores non-http schemes and unparseable targets", () => {
expect(oauthCallbackFrom("file:///callback?code=ac_123")).toBeUndefined();
expect(oauthCallbackFrom("rakazo://localhost/callback?code=ac_123")).toBeUndefined();
expect(oauthCallbackFrom("not a url")).toBeUndefined();
});
it("ignores whitespace-only codes", () => {
expect(oauthCallbackFrom("http://localhost:53692/callback?code=%20%20")).toBeUndefined();
});
it("does not capture the app renderer origin used by MCP and other in-app callbacks", () => {
expect(
oauthCallbackFrom("http://127.0.0.1:5173/mcp/oauth/callback?code=mcp_123&state=s", {
excludeOrigins: ["http://127.0.0.1:5173"],
}),
).toBeUndefined();
expect(
oauthCallbackFrom("http://localhost:53692/callback?code=ac_123", {
excludeOrigins: ["http://127.0.0.1:5173"],
}),
).toEqual({ code: "ac_123" });
});
});

View File

@ -0,0 +1,35 @@
import type { RakazoDesktopOAuthCallback } from "@rakazo/contracts";
export type OAuthCallbackFromOptions = {
/** App renderer origins — their `/callback` routes must not be treated as paste-flow codes. */
excludeOrigins?: readonly string[];
};
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
/**
* Providers that sign in through a loopback redirect Anthropic sends the
* browser to `http://localhost:53692/callback` return the authorization code
* in the redirect URL. Rakazo runs no listener on that port and asks for the
* code to be pasted instead, which the sign-in popup cannot show because an
* Electron window has no address bar. The main process still sees the
* navigation, so it reads the code from there.
*/
export function oauthCallbackFrom(
url: string,
options: OAuthCallbackFromOptions = {},
): RakazoDesktopOAuthCallback | undefined {
let target: URL;
try {
target = new URL(url);
} catch {
return undefined;
}
if (target.protocol !== "http:" && target.protocol !== "https:") return undefined;
if (!LOOPBACK_HOSTS.has(target.hostname)) return undefined;
if (options.excludeOrigins?.includes(target.origin)) return undefined;
const code = target.searchParams.get("code")?.trim();
if (!code) return undefined;
const state = target.searchParams.get("state")?.trim();
return state ? { code, state } : { code };
}

View File

@ -0,0 +1,25 @@
const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld("rakazoDesktop", {
platform: process.platform,
window: {
close: () => ipcRenderer.invoke("desktop.window.close"),
minimize: () => ipcRenderer.invoke("desktop.window.minimize"),
toggleMaximize: () => ipcRenderer.invoke("desktop.window.toggleMaximize"),
state: () => ipcRenderer.invoke("desktop.window.state"),
},
update: {
state: () => ipcRenderer.invoke("desktop.update.state"),
check: () => ipcRenderer.invoke("desktop.update.check"),
download: () => ipcRenderer.invoke("desktop.update.download"),
install: () => ipcRenderer.invoke("desktop.update.install"),
},
oauth: {
onCallback: (listener) => {
// The IpcRendererEvent stays in the preload: the renderer only sees the code.
const handler = (_event, callback) => listener(callback);
ipcRenderer.on("desktop.oauth.callback", handler);
return () => ipcRenderer.off("desktop.oauth.callback", handler);
},
},
});

View File

@ -0,0 +1,110 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import vm from "node:vm";
import type { RakazoDesktop, RakazoSetup } from "@rakazo/contracts";
import { describe, expect, it, vi } from "vitest";
function runPreload(file: string, ipc: { invoke?: unknown; on?: unknown; off?: unknown } = {}) {
const invoke =
(ipc.invoke as ReturnType<typeof vi.fn>) ?? vi.fn(async (channel: string) => ({ channel }));
const on = (ipc.on as ReturnType<typeof vi.fn>) ?? vi.fn();
const off = (ipc.off as ReturnType<typeof vi.fn>) ?? vi.fn();
const exposeInMainWorld = vi.fn();
const source = readFileSync(path.join(import.meta.dirname, file), "utf8");
vm.runInNewContext(source, {
process: { platform: "linux" },
require(moduleName: string) {
if (moduleName !== "electron") throw new Error(`Unexpected preload import: ${moduleName}`);
return { contextBridge: { exposeInMainWorld }, ipcRenderer: { invoke, on, off } };
},
});
return { invoke, on, off, exposeInMainWorld };
}
describe("desktop preload bridge", () => {
it("exposes only the platform, the four window operations, the updater, and the OAuth bridge", async () => {
const { invoke, exposeInMainWorld } = runPreload("preload.cjs");
expect(exposeInMainWorld).toHaveBeenCalledTimes(1);
const [globalName, bridge] = exposeInMainWorld.mock.calls[0] as [string, RakazoDesktop];
expect(globalName).toBe("rakazoDesktop");
expect(bridge.platform).toBe("linux");
expect(Object.keys(bridge).sort()).toEqual(["oauth", "platform", "update", "window"]);
expect(Object.keys(bridge.window).sort()).toEqual([
"close",
"minimize",
"state",
"toggleMaximize",
]);
expect(Object.keys(bridge.update).sort()).toEqual(["check", "download", "install", "state"]);
await bridge.window.close();
await bridge.window.minimize();
await bridge.window.toggleMaximize();
await bridge.window.state();
await bridge.update.state();
await bridge.update.check();
await bridge.update.download();
await bridge.update.install();
expect(invoke.mock.calls.map(([channel]) => channel)).toEqual([
"desktop.window.close",
"desktop.window.minimize",
"desktop.window.toggleMaximize",
"desktop.window.state",
"desktop.update.state",
"desktop.update.check",
"desktop.update.download",
"desktop.update.install",
]);
});
it("keeps setup off the app bridge so a connected server cannot re-point the app", () => {
const { exposeInMainWorld } = runPreload("preload.cjs");
const [, bridge] = exposeInMainWorld.mock.calls[0] as [string, Record<string, unknown>];
expect(Object.keys(bridge).sort()).toEqual(["oauth", "platform", "update", "window"]);
});
it("forwards captured codes without leaking the IPC event to the renderer", () => {
const listeners: Array<(event: unknown, callback: unknown) => void> = [];
const on = vi.fn((_channel: string, handler: (event: unknown, callback: unknown) => void) => {
listeners.push(handler);
});
const off = vi.fn();
const { exposeInMainWorld } = runPreload("preload.cjs", { on, off });
const [, bridge] = exposeInMainWorld.mock.calls[0] as [string, RakazoDesktop];
const received: unknown[] = [];
const unsubscribe = bridge.oauth.onCallback((callback) => received.push(callback));
expect(on).toHaveBeenCalledWith("desktop.oauth.callback", expect.any(Function));
listeners[0]?.({ sender: "ipc-event" }, { code: "ac_123", state: "verifier_456" });
expect(received).toEqual([{ code: "ac_123", state: "verifier_456" }]);
unsubscribe();
expect(off).toHaveBeenCalledWith("desktop.oauth.callback", expect.any(Function));
});
});
describe("setup preload bridge", () => {
it("exposes only the first-run setup operations", async () => {
const { invoke, exposeInMainWorld } = runPreload("setup-preload.cjs");
expect(exposeInMainWorld).toHaveBeenCalledTimes(1);
const [globalName, bridge] = exposeInMainWorld.mock.calls[0] as [string, RakazoSetup];
expect(globalName).toBe("rakazoSetup");
expect(Object.keys(bridge).sort()).toEqual(["quit", "save", "state", "test"]);
await bridge.state();
await bridge.test("http://127.0.0.1:5173");
await bridge.save({ mode: "new", serverUrl: "http://127.0.0.1:5173" });
await bridge.quit();
expect(invoke.mock.calls.map(([channel]) => channel)).toEqual([
"desktop.setup.state",
"desktop.setup.test",
"desktop.setup.save",
"desktop.setup.quit",
]);
});
});

View File

@ -0,0 +1,61 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
const workflow = readFileSync(
path.resolve(import.meta.dirname, "../../../.github/workflows/release-desktop.yml"),
"utf8",
);
describe("desktop release workflow", () => {
it("cannot execute contributor pull-request code with release credentials", () => {
expect(workflow).not.toMatch(/^\s*pull_request:/m);
expect(workflow).toContain("permissions:\n contents: read");
expect(workflow.match(/persist-credentials: false/g)).toHaveLength(3);
});
it("pins every third-party action to an immutable commit", () => {
const actionReferences = [...workflow.matchAll(/uses:\s+([^\s#]+)/g)].map((match) => match[1]);
expect(actionReferences.length).toBeGreaterThan(0);
for (const reference of actionReferences) {
expect(reference, reference).toMatch(/@[0-9a-f]{40}$/);
}
});
it("requires signed platform builds before a single publication job", () => {
expect(workflow).toContain("-c.forceCodeSigning=true");
expect(workflow).toContain("codesign --verify --deep --strict");
expect(workflow).toContain("Get-AuthenticodeSignature");
expect(workflow).toContain("needs: [validate, build]");
expect(workflow).toContain("attestations: write");
expect(workflow).toContain("actions/attest-build-provenance@");
expect(workflow).not.toContain("--publish always");
expect(workflow).toContain("DESKTOP_MAC_CSC_LINK");
expect(workflow).toContain("DESKTOP_WIN_CSC_LINK");
expect(workflow).not.toMatch(/secrets\.DESKTOP_CSC_(?:LINK|KEY_PASSWORD)/);
expect(workflow).not.toContain("cache: pnpm");
expect(workflow).toContain("apps/desktop/out/latest*.yml");
expect(workflow).not.toContain("apps/desktop/out/*.yml");
});
it("pins every platform update feed to the official GitHub owner and repo", () => {
expect(workflow).toContain('grep -Fqx "provider: github"');
expect(workflow).toContain('grep -Fqx "owner: elie222"');
expect(workflow).toContain('grep -Fqx "repo: rakazo"');
expect(workflow).toContain("Verify Linux update feed is pinned to the official GitHub channel");
expect(workflow).toContain("Windows update config missing");
expect(workflow).toContain("RELEASE_VERSION:");
expect(workflow).toContain('grep -Fqx "version: $RELEASE_VERSION"');
});
it("publishes only a complete stable, upgrade-only feed", () => {
expect(workflow).toContain("^v([0-9]+)\\.([0-9]+)\\.([0-9]+)$");
expect(workflow).toContain("must be newer than published release");
expect(workflow).toContain("group: release-desktop-stable");
expect(workflow).toContain("latest.yml");
expect(workflow).toContain("latest-mac.yml");
expect(workflow).toContain("latest-linux.yml");
expect(workflow).toContain("--draft --generate-notes");
expect(workflow).toContain("--draft=false --latest");
});
});

View File

@ -0,0 +1,75 @@
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
bundledRendererCandidates,
contentType,
forwardedRendererRequestInit,
immutableRendererAsset,
isRendererAssetMiss,
} from "./renderer-assets.js";
const root = path.resolve("/tmp/rakazo-renderer");
const origin = "https://app.example.com";
describe("bundled desktop renderer", () => {
it("serves static assets and falls back to index.html for app routes", () => {
expect(bundledRendererCandidates(root, `${origin}/assets/app-123.js`, origin, false)).toEqual([
path.join(root, "assets/app-123.js"),
]);
expect(bundledRendererCandidates(root, `${origin}/app/bot-1`, origin, true)).toEqual([
path.join(root, "app/bot-1"),
path.join(root, "index.html"),
]);
expect(bundledRendererCandidates(root, `${origin}/app/bot.name`, origin, true)).toEqual([
path.join(root, "app/bot.name"),
path.join(root, "index.html"),
]);
});
it("leaves APIs, screen proxying, and other origins on the network", () => {
for (const pathname of ["/api/auth/session", "/rpc/bots", "/novnc/socket"]) {
expect(bundledRendererCandidates(root, `${origin}${pathname}`, origin, false)).toBeNull();
}
expect(
bundledRendererCandidates(root, "https://example.net/assets/app-123.js", origin, false),
).toBeNull();
});
it("does not resolve traversal outside the renderer root", () => {
expect(
bundledRendererCandidates(root, `${origin}/%2e%2e%2fsecret.txt`, origin, false),
).toBeNull();
expect(bundledRendererCandidates(root, `${origin}/bad%00name.js`, origin, false)).toBeNull();
});
it("assigns cache and content metadata", () => {
expect(contentType("index.html")).toBe("text/html; charset=utf-8");
expect(contentType("app.woff2")).toBe("font/woff2");
expect(immutableRendererAsset(path.join(root, "assets/app-123.js"))).toBe(true);
expect(immutableRendererAsset(path.join(root, "index.html"))).toBe(false);
});
it("treats filesystem path misses as renderer fallbacks", () => {
for (const code of ["ENOENT", "EISDIR", "ENOTDIR"]) {
expect(isRendererAssetMiss(Object.assign(new Error(code), { code }))).toBe(true);
}
expect(isRendererAssetMiss(Object.assign(new Error("denied"), { code: "EACCES" }))).toBe(false);
expect(isRendererAssetMiss(null)).toBe(false);
});
it("forwards cookies for both app and cross-origin requests", () => {
const appOptions = forwardedRendererRequestInit(new Request(`${origin}/rpc/bots`), origin);
const oauthOptions = forwardedRendererRequestInit(
new Request("https://oauth.example.com/authorize"),
origin,
);
expect(appOptions.credentials).toBe("include");
expect(new Headers(appOptions.headers).get("origin")).toBe(origin);
expect(oauthOptions).toMatchObject({
bypassCustomProtocolHandlers: true,
credentials: "include",
});
expect(oauthOptions.headers).toBeUndefined();
});
});

View File

@ -0,0 +1,81 @@
import path from "node:path";
const PASSTHROUGH_PATHS = ["/api", "/rpc", "/novnc"];
const CONTENT_TYPES: Record<string, string> = {
".css": "text/css; charset=utf-8",
".html": "text/html; charset=utf-8",
".ico": "image/x-icon",
".js": "text/javascript; charset=utf-8",
".json": "application/json; charset=utf-8",
".png": "image/png",
".svg": "image/svg+xml",
".webmanifest": "application/manifest+json; charset=utf-8",
".woff": "font/woff",
".woff2": "font/woff2",
};
export function bundledRendererCandidates(
root: string,
requestUrl: string,
webOrigin: string,
acceptsHtml: boolean,
) {
const url = new URL(requestUrl);
if (
url.origin !== webOrigin ||
PASSTHROUGH_PATHS.some((prefix) => matchesPrefix(url.pathname, prefix))
) {
return null;
}
let pathname: string;
try {
pathname = decodeURIComponent(url.pathname);
} catch {
return null;
}
if (pathname.includes("\0")) return null;
const requested = pathname === "/" ? "index.html" : pathname.slice(1);
const candidate = safeChild(root, requested);
if (!candidate) return null;
return acceptsHtml ? [candidate, path.join(root, "index.html")] : [candidate];
}
export function contentType(file: string) {
return CONTENT_TYPES[path.extname(file).toLowerCase()] ?? "application/octet-stream";
}
export function immutableRendererAsset(file: string) {
return path.basename(path.dirname(file)) === "assets";
}
export function isRendererAssetMiss(error: unknown) {
const code =
typeof error === "object" && error !== null && "code" in error ? error.code : undefined;
return code === "ENOENT" || code === "EISDIR" || code === "ENOTDIR";
}
export function forwardedRendererRequestInit(request: Request, webOrigin: string) {
const init: RequestInit & { bypassCustomProtocolHandlers: boolean } = {
bypassCustomProtocolHandlers: true,
credentials: "include",
};
if (new URL(request.url).origin !== webOrigin) return init;
const headers = new Headers(request.headers);
headers.set("origin", webOrigin);
return { ...init, headers };
}
function matchesPrefix(pathname: string, prefix: string) {
return pathname === prefix || pathname.startsWith(`${prefix}/`);
}
function safeChild(root: string, requested: string) {
const resolvedRoot = path.resolve(root);
const resolved = path.resolve(resolvedRoot, requested);
const relative = path.relative(resolvedRoot, resolved);
if (relative.startsWith("..") || path.isAbsolute(relative)) return null;
return resolved;
}

View File

@ -0,0 +1,198 @@
import { describe, expect, it } from "vitest";
import {
DEFAULT_LOCAL_WEB_URL,
isRakazoHealth,
normalizeServerUrl,
parseSetupInput,
parseStoredSetup,
probeFailureMessage,
resolveStartupTarget,
safeExternalUrl,
serializeSetup,
servesBundledRenderer,
sessionPartitionForServerUrl,
} from "./setup-config.js";
describe("server address normalization", () => {
it("assumes http locally and https for a bare public host", () => {
expect(normalizeServerUrl("127.0.0.1:5173")).toBe("http://127.0.0.1:5173");
expect(normalizeServerUrl("localhost:5173")).toBe("http://localhost:5173");
expect(normalizeServerUrl("192.168.1.20:3100")).toBe("http://192.168.1.20:3100");
expect(normalizeServerUrl("rakazo.example.com")).toBe("https://rakazo.example.com");
});
it("keeps an explicit secure scheme and port but stores only the origin", () => {
expect(normalizeServerUrl("https://rakazo.example.com")).toBe("https://rakazo.example.com");
expect(normalizeServerUrl("https://rakazo.example.com:8443/team")).toBe(
"https://rakazo.example.com:8443",
);
});
it("trims surrounding space, trailing slashes, queries, and fragments", () => {
expect(normalizeServerUrl(" http://127.0.0.1:5173/ ")).toBe("http://127.0.0.1:5173");
expect(normalizeServerUrl("http://127.0.0.1:5173///")).toBe("http://127.0.0.1:5173");
expect(normalizeServerUrl("http://127.0.0.1:5173/?next=/bots#top")).toBe(
"http://127.0.0.1:5173",
);
});
it("rejects cleartext public servers but permits private-network development", () => {
expect(normalizeServerUrl("http://rakazo.example.com")).toBeNull();
expect(normalizeServerUrl("http://10.0.0.8:3100")).toBe("http://10.0.0.8:3100");
expect(normalizeServerUrl("http://[fd00::1]:3100")).toBe("http://[fd00::1]:3100");
});
it("rejects cleartext link-local addresses used by cloud metadata endpoints", () => {
expect(normalizeServerUrl("http://169.254.169.254")).toBeNull();
expect(normalizeServerUrl("http://169.254.1.1:80")).toBeNull();
expect(normalizeServerUrl("http://[fe80::1]:3100")).toBeNull();
// HTTPS to link-local still normalizes; the health probe must match Rakazo.
expect(normalizeServerUrl("https://169.254.169.254")).toBe("https://169.254.169.254");
});
it.each(["", " ", "not a url", "ftp://example.com", "file:///etc/passwd", "http://"])(
"rejects an address that cannot reach a Rakazo server (%s)",
(value) => {
expect(normalizeServerUrl(value)).toBeNull();
},
);
it("rejects embedded credentials rather than writing them to disk", () => {
expect(normalizeServerUrl("https://user:secret@rakazo.example.com")).toBeNull();
});
});
describe("saved setup", () => {
it("round-trips through the on-disk format", () => {
const setup = { mode: "existing", serverUrl: "https://rakazo.example.com" } as const;
expect(parseStoredSetup(serializeSetup(setup))).toEqual(setup);
});
it("normalizes the address it reads back", () => {
expect(parseStoredSetup('{"mode":"new","serverUrl":"127.0.0.1:5173/"}')).toEqual({
mode: "new",
serverUrl: "http://127.0.0.1:5173",
});
});
it.each([
["not json", "{oops"],
["a non-object", '"nope"'],
["an unknown mode", '{"mode":"other","serverUrl":"http://127.0.0.1:5173"}'],
["a missing address", '{"mode":"new"}'],
["an unusable address", '{"mode":"new","serverUrl":"ftp://example.com"}'],
])("discards %s so setup runs again", (_label, raw) => {
expect(parseStoredSetup(raw)).toBeNull();
});
it("rejects an untrusted payload that is not a setup", () => {
expect(parseSetupInput(null)).toBeNull();
expect(parseSetupInput({ mode: "new", serverUrl: 5173 })).toBeNull();
});
it("keeps the new-instance choice on this computer", () => {
expect(parseSetupInput({ mode: "new", serverUrl: "http://192.168.1.20:3100" })).toBeNull();
expect(parseSetupInput({ mode: "existing", serverUrl: "http://192.168.1.20:3100" })).toEqual({
mode: "existing",
serverUrl: "http://192.168.1.20:3100",
});
});
});
describe("startup target", () => {
const saved = { mode: "existing", serverUrl: "https://rakazo.example.com" } as const;
it("runs setup on a first launch", () => {
expect(resolveStartupTarget({})).toEqual({ kind: "setup" });
});
it("opens the saved instance on later launches", () => {
expect(resolveStartupTarget({ saved })).toEqual({
kind: "app",
url: "https://rakazo.example.com",
source: "saved",
});
});
it("lets RAKAZO_WEB_URL point the shell anywhere without touching saved setup", () => {
expect(resolveStartupTarget({ envUrl: "http://127.0.0.1:4321", saved })).toEqual({
kind: "app",
url: "http://127.0.0.1:4321",
source: "env",
});
});
it("ignores an empty RAKAZO_WEB_URL", () => {
expect(resolveStartupTarget({ envUrl: " ", saved }).kind).toBe("app");
expect(resolveStartupTarget({ envUrl: " ", saved })).toMatchObject({ source: "saved" });
});
it("re-runs setup when forced, even with saved configuration", () => {
expect(resolveStartupTarget({ saved, forceSetup: true })).toEqual({ kind: "setup" });
});
it("re-runs setup when the saved address is unusable", () => {
expect(resolveStartupTarget({ saved: { mode: "new", serverUrl: "nope://x" } })).toEqual({
kind: "setup",
});
expect(
resolveStartupTarget({
saved: { mode: "new", serverUrl: "http://192.168.1.20:3100" },
}),
).toEqual({ kind: "setup" });
});
});
describe("bundled renderer eligibility", () => {
it("stands in for http(s) origins only", () => {
expect(servesBundledRenderer(DEFAULT_LOCAL_WEB_URL)).toBe(true);
expect(servesBundledRenderer("https://rakazo.example.com")).toBe(true);
expect(servesBundledRenderer("data:text/html,<p>fixture</p>")).toBe(false);
expect(servesBundledRenderer("nonsense")).toBe(false);
});
});
describe("remote-content isolation", () => {
it("uses a stable, opaque session partition per server origin", () => {
const first = sessionPartitionForServerUrl("https://one.example.com/path");
expect(first).toBe(sessionPartitionForServerUrl("https://one.example.com/other"));
expect(first).not.toBe(sessionPartitionForServerUrl("https://one.example.com:8443"));
expect(first).toMatch(/^persist:rakazo-[a-f0-9]{24}$/);
expect(first).not.toContain("one.example.com");
expect(sessionPartitionForServerUrl("data:text/html,fixture")).toBeNull();
});
it("opens only web URLs outside Electron", () => {
expect(safeExternalUrl("https://example.com/docs")).toBe("https://example.com/docs");
expect(safeExternalUrl("mailto:person@example.com")).toBeNull();
expect(safeExternalUrl("file:///etc/passwd")).toBeNull();
});
});
describe("Rakazo health response", () => {
it("requires the public RPC health contract", () => {
expect(isRakazoHealth({ json: { ok: true, version: "0.1.0" } })).toBe(true);
expect(isRakazoHealth({ json: { ok: true } })).toBe(false);
expect(isRakazoHealth({ ok: true, version: "0.1.0" })).toBe(false);
});
});
describe("probe failures", () => {
it.each([
["TimeoutError", "Timed out reaching that address."],
["AbortError", "Timed out reaching that address."],
])("explains %s", (name, expected) => {
const error = new Error("stopped");
error.name = name;
expect(probeFailureMessage(error)).toBe(expected);
});
it.each([
["net::ERR_CONNECTION_REFUSED", "Nothing is listening at that address yet."],
["net::ERR_NAME_NOT_RESOLVED", "That host could not be found."],
["net::ERR_CERT_AUTHORITY_INVALID", "The server's HTTPS certificate was rejected."],
["something else entirely", "Could not reach that address."],
])("explains %s", (message, expected) => {
expect(probeFailureMessage(new Error(message))).toBe(expected);
});
});

View File

@ -0,0 +1,205 @@
import { createHash } from "node:crypto";
import { isIP } from "node:net";
import type { DesktopSetup } from "@rakazo/contracts";
/** Where `pnpm dev` serves the Rakazo web app on this machine. */
export const DEFAULT_LOCAL_WEB_URL = "http://127.0.0.1:5173";
export const SETUP_FILE_NAME = "setup.json";
export type StartupTarget =
| { kind: "app"; url: string; source: "env" | "saved" }
| { kind: "setup" };
const SCHEME = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//;
/**
* Accepts what a person would actually type ("localhost:5173", "rakazo.example.com")
* and returns a canonical http(s) origin, or null when the input can never
* securely address a Rakazo server.
*/
export function normalizeServerUrl(input: string): string | null {
const trimmed = input.trim();
if (trimmed === "") return null;
let url: URL;
try {
if (SCHEME.test(trimmed)) {
url = new URL(trimmed);
} else {
const candidate = new URL(`http://${trimmed}`);
url = isLocalNetworkHost(candidate.hostname) ? candidate : new URL(`https://${trimmed}`);
}
} catch {
return null;
}
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
if (url.hostname === "") return null;
// Embedded credentials would be written to disk in cleartext.
if (url.username !== "" || url.password !== "") return null;
// Public login cookies and API traffic must never cross a cleartext connection.
if (url.protocol === "http:" && !isLocalNetworkHost(url.hostname)) return null;
// Rakazo serves its renderer, RPC, and auth routes from one origin. Keeping a
// user-supplied path would make the setup probe and the loaded app disagree.
return url.origin;
}
/** Validates an untrusted value (saved file or IPC payload) into a usable setup. */
export function parseSetupInput(value: unknown): DesktopSetup | null {
if (typeof value !== "object" || value === null) return null;
const { mode, serverUrl } = value as Record<string, unknown>;
if (mode !== "new" && mode !== "existing") return null;
if (typeof serverUrl !== "string") return null;
const normalized = normalizeServerUrl(serverUrl);
if (normalized === null) return null;
if (mode === "new" && !isLoopbackHost(new URL(normalized).hostname)) return null;
return { mode, serverUrl: normalized };
}
export function parseStoredSetup(raw: string): DesktopSetup | null {
try {
return parseSetupInput(JSON.parse(raw));
} catch {
return null;
}
}
export function serializeSetup(setup: DesktopSetup): string {
return `${JSON.stringify(setup, null, 2)}\n`;
}
/**
* Decides between the first-run setup window and the app window. An explicit
* `RAKAZO_WEB_URL` still wins over saved configuration so test and performance
* harnesses can point the shell anywhere without touching a user's real setup.
*/
export function resolveStartupTarget(input: {
envUrl?: string;
saved?: DesktopSetup | null;
forceSetup?: boolean;
}): StartupTarget {
if (input.forceSetup === true) return { kind: "setup" };
const envUrl = input.envUrl?.trim();
if (envUrl !== undefined && envUrl !== "") return { kind: "app", url: envUrl, source: "env" };
if (input.saved != null) {
const saved = parseSetupInput(input.saved);
if (saved !== null) return { kind: "app", url: saved.serverUrl, source: "saved" };
}
return { kind: "setup" };
}
/** Turns a network failure into something a person can act on. */
export function probeFailureMessage(error: unknown): string {
const name = error instanceof Error ? error.name : "";
if (name === "TimeoutError" || name === "AbortError") {
return "Timed out reaching that address.";
}
const detail = error instanceof Error ? error.message : String(error);
if (detail.includes("CONNECTION_REFUSED") || detail.includes("ECONNREFUSED")) {
return "Nothing is listening at that address yet.";
}
if (detail.includes("NAME_NOT_RESOLVED") || detail.includes("ENOTFOUND")) {
return "That host could not be found.";
}
if (detail.includes("CERT_") || detail.includes("SSL")) {
return "The server's HTTPS certificate was rejected.";
}
return "Could not reach that address.";
}
/** The bundled renderer only stands in for a real http(s) origin. */
export function servesBundledRenderer(targetUrl: string): boolean {
try {
const { protocol } = new URL(targetUrl);
return protocol === "http:" || protocol === "https:";
} catch {
return false;
}
}
/** Each Rakazo origin gets its own persistent cookie and storage partition. */
export function sessionPartitionForServerUrl(targetUrl: string): string | null {
try {
const url = new URL(targetUrl);
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
const digest = createHash("sha256").update(url.origin).digest("hex").slice(0, 24);
return `persist:rakazo-${digest}`;
} catch {
return null;
}
}
/** External pages are opened by the OS, never in a privileged Electron child window. */
export function safeExternalUrl(targetUrl: string): string | null {
if (!servesBundledRenderer(targetUrl)) return null;
return new URL(targetUrl).toString();
}
export function isRakazoHealth(value: unknown): boolean {
if (typeof value !== "object" || value === null) return false;
const json = (value as { json?: unknown }).json;
return (
typeof json === "object" &&
json !== null &&
(json as { ok?: unknown }).ok === true &&
typeof (json as { version?: unknown }).version === "string"
);
}
function isLoopbackHost(hostname: string) {
const host = unbracketedHost(hostname);
if (host === "localhost" || host.endsWith(".localhost")) return true;
if (isIP(host) === 4) return host.startsWith("127.");
return isIP(host) === 6 && host === "::1";
}
/**
* Link-local addresses (IPv4 169.254/16, IPv6 fe80::/10) often host cloud
* metadata endpoints. Cleartext HTTP to them is never a legitimate Rakazo
* deploy target, so they stay out of the private-network HTTP allowlist.
*/
function isLinkLocalHost(hostname: string) {
const host = unbracketedHost(hostname);
if (isIP(host) === 4) {
const [first, second] = host.split(".").map(Number);
return first === 169 && second === 254;
}
if (isIP(host) === 6) {
const first = host.split(":", 1)[0] ?? "";
return /^fe[89ab]/.test(first);
}
return false;
}
function isLocalNetworkHost(hostname: string) {
const host = unbracketedHost(hostname);
if (isLoopbackHost(host) || host.endsWith(".local")) return true;
if (isLinkLocalHost(host)) return false;
if (isIP(host) === 4) {
const [first, second] = host.split(".").map(Number);
return (
first === 10 ||
(first === 172 && second !== undefined && second >= 16 && second <= 31) ||
(first === 192 && second === 168)
);
}
if (isIP(host) === 6) {
const first = host.split(":", 1)[0] ?? "";
// Unique-local only (fc00::/7). Link-local is rejected above.
return /^f[cd]/.test(first);
}
return false;
}
function unbracketedHost(hostname: string) {
return hostname.replace(/^\[|\]$/g, "").toLowerCase();
}

View File

@ -0,0 +1,8 @@
const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld("rakazoSetup", {
state: () => ipcRenderer.invoke("desktop.setup.state"),
test: (url) => ipcRenderer.invoke("desktop.setup.test", url),
save: (setup) => ipcRenderer.invoke("desktop.setup.save", setup),
quit: () => ipcRenderer.invoke("desktop.setup.quit"),
});

View File

@ -0,0 +1,72 @@
import { mkdtemp, readFile, rm, stat, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { clearSetup, readSetup, setupFilePath, writeSetup } from "./setup-store.js";
let userData: string;
beforeEach(async () => {
userData = await mkdtemp(path.join(tmpdir(), "rakazo-setup-"));
});
afterEach(async () => {
await rm(userData, { recursive: true, force: true });
});
describe("setup store", () => {
it("reports no setup before the first run", async () => {
await expect(readSetup(userData)).resolves.toBeNull();
});
it("keeps the chosen instance across launches", async () => {
await writeSetup(userData, { mode: "existing", serverUrl: "https://rakazo.example.com" });
await expect(readSetup(userData)).resolves.toEqual({
mode: "existing",
serverUrl: "https://rakazo.example.com",
});
});
it("clears saved setup so first-run runs again", async () => {
await writeSetup(userData, { mode: "existing", serverUrl: "https://rakazo.example.com" });
await clearSetup(userData);
await expect(readSetup(userData)).resolves.toBeNull();
});
it("creates the user data directory when it does not exist yet", async () => {
const nested = path.join(userData, "nested", "profile");
await writeSetup(nested, { mode: "new", serverUrl: "http://127.0.0.1:5173" });
await expect(readSetup(nested)).resolves.toEqual({
mode: "new",
serverUrl: "http://127.0.0.1:5173",
});
});
it.runIf(process.platform !== "win32")("keeps the saved address private", async () => {
await writeSetup(userData, { mode: "existing", serverUrl: "https://rakazo.example.com" });
const info = await stat(setupFilePath(userData));
expect(info.mode & 0o777).toBe(0o600);
});
it.runIf(process.platform !== "win32")(
"replaces a final symlink instead of overwriting its target",
async () => {
const victim = path.join(userData, "victim.txt");
await writeFile(victim, "untouched", "utf8");
await symlink(victim, setupFilePath(userData));
await writeSetup(userData, { mode: "existing", serverUrl: "https://rakazo.example.com" });
await expect(readSetup(userData)).resolves.toEqual({
mode: "existing",
serverUrl: "https://rakazo.example.com",
});
await expect(readFile(victim, "utf8")).resolves.toBe("untouched");
},
);
it("falls back to setup when the saved file is corrupt", async () => {
await writeFile(setupFilePath(userData), "{ not json", "utf8");
await expect(readSetup(userData)).resolves.toBeNull();
});
});

View File

@ -0,0 +1,45 @@
import { randomUUID } from "node:crypto";
import { mkdir, open, readFile, rename, rm } from "node:fs/promises";
import path from "node:path";
import type { DesktopSetup } from "@rakazo/contracts";
import { parseStoredSetup, SETUP_FILE_NAME, serializeSetup } from "./setup-config.js";
export function setupFilePath(userDataDir: string): string {
return path.join(userDataDir, SETUP_FILE_NAME);
}
/** Returns null when setup has not run yet, or when the saved file is unusable. */
export async function readSetup(userDataDir: string): Promise<DesktopSetup | null> {
let raw: string;
try {
raw = await readFile(setupFilePath(userDataDir), "utf8");
} catch {
return null;
}
return parseStoredSetup(raw);
}
export async function writeSetup(userDataDir: string, setup: DesktopSetup): Promise<void> {
await mkdir(userDataDir, { recursive: true });
const destination = setupFilePath(userDataDir);
const temporary = `${destination}.${process.pid}.${randomUUID()}.tmp`;
let file: Awaited<ReturnType<typeof open>> | undefined;
try {
file = await open(temporary, "wx", 0o600);
await file.writeFile(serializeSetup(setup), "utf8");
await file.sync();
await file.close();
file = undefined;
// Replacing the complete file avoids following a malicious final symlink and
// leaves either the old or new valid JSON after an interrupted write.
await rename(temporary, destination);
} finally {
await file?.close().catch(() => undefined);
await rm(temporary, { force: true }).catch(() => undefined);
}
}
/** Removes saved setup so the next launch runs first-run again. */
export async function clearSetup(userDataDir: string): Promise<void> {
await rm(setupFilePath(userDataDir), { force: true });
}

277
apps/desktop/src/setup.css Normal file
View File

@ -0,0 +1,277 @@
:root {
color-scheme: dark;
--bg: #050506;
--panel: #0f0f12;
--panel-raised: #17171c;
--border: #26262e;
--border-strong: #3a3a46;
--text: #f4f4f6;
--text-muted: #9a9aa8;
--accent: #2563eb;
--accent-hover: #3b76f2;
--danger: #f87171;
--ok: #4ade80;
}
* {
box-sizing: border-box;
}
html,
body {
height: 100%;
}
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family:
"Segoe UI", -apple-system, BlinkMacSystemFont, Inter, Roboto, Helvetica, Arial, sans-serif;
font-size: 14px;
line-height: 1.5;
display: flex;
flex-direction: column;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.titlebar {
-webkit-app-region: drag;
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
flex: none;
}
.titlebar-name {
font-size: 12px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-muted);
}
.titlebar-quit {
-webkit-app-region: no-drag;
background: none;
border: 0;
color: var(--text-muted);
font: inherit;
font-size: 12px;
cursor: pointer;
padding: 4px 8px;
border-radius: 6px;
}
.titlebar-quit:hover {
color: var(--text);
background: var(--panel-raised);
}
.shell {
flex: 1;
width: 100%;
max-width: 620px;
margin: 0 auto;
padding: 24px 28px 32px;
display: flex;
flex-direction: column;
justify-content: center;
}
.heading {
margin: 0 0 8px;
font-size: 26px;
font-weight: 600;
letter-spacing: -0.01em;
}
.subheading {
margin: 0 0 24px;
color: var(--text-muted);
}
.card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 14px;
padding: 20px;
}
.choices {
border: 0;
margin: 0 0 18px;
padding: 0;
display: grid;
gap: 10px;
}
.choice {
display: flex;
gap: 12px;
align-items: flex-start;
padding: 14px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--panel-raised);
cursor: pointer;
}
.choice:hover {
border-color: var(--border-strong);
}
.choice:has(input:checked) {
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 12%, var(--panel-raised));
}
.choice:has(input:focus-visible) {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.choice input {
margin: 2px 0 0;
accent-color: var(--accent);
flex: none;
}
.choice-body {
display: flex;
flex-direction: column;
gap: 3px;
}
.choice-title {
font-weight: 600;
}
.choice-note {
color: var(--text-muted);
font-size: 13px;
}
.panel {
margin-bottom: 4px;
}
.field-label {
display: block;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--text-muted);
margin-bottom: 6px;
}
.field {
width: 100%;
padding: 10px 12px;
border-radius: 9px;
border: 1px solid var(--border-strong);
background: #08080a;
color: var(--text);
font: inherit;
font-family: ui-monospace, SFMono-Regular, "Cascadia Mono", Menlo, monospace;
font-size: 13px;
}
.field:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
border-color: var(--accent);
}
.hint {
margin: 8px 0 0;
font-size: 12.5px;
color: var(--text-muted);
}
.hint code {
background: var(--panel-raised);
border: 1px solid var(--border);
border-radius: 5px;
padding: 1px 5px;
font-size: 12px;
}
.status {
margin: 14px 0 0;
min-height: 20px;
font-size: 13px;
color: var(--text-muted);
}
.status[data-tone="ok"] {
color: var(--ok);
}
.status[data-tone="error"] {
color: var(--danger);
}
.actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 18px;
}
.button {
font: inherit;
font-weight: 600;
padding: 9px 18px;
border-radius: 9px;
cursor: pointer;
border: 1px solid transparent;
}
.button:disabled {
opacity: 0.55;
cursor: default;
}
.button:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.button-primary {
background: var(--accent);
color: #fff;
}
.button-primary:hover:not(:disabled) {
background: var(--accent-hover);
}
.button-secondary {
background: var(--panel-raised);
color: var(--text);
border-color: var(--border-strong);
}
.button-secondary:hover:not(:disabled) {
border-color: var(--text-muted);
}
.footnote {
margin: 16px 0 0;
font-size: 12px;
color: var(--text-muted);
text-align: center;
}

View File

@ -0,0 +1,75 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; form-action 'none'; base-uri 'none'"
/>
<title>Set up BangSo Bot</title>
<link rel="stylesheet" href="setup.css" />
</head>
<body>
<header class="titlebar">
<span class="titlebar-name">BangSo Bot</span>
<button id="quit" class="titlebar-quit" type="button" title="Quit BangSo Bot">Quit</button>
</header>
<main class="shell">
<h1 class="heading">Welcome to BangSo Bot</h1>
<p class="subheading">Choose which server this app should use.</p>
<form id="setup" class="card" novalidate>
<fieldset class="choices">
<legend class="sr-only">Instance</legend>
<label class="choice" for="mode-new">
<input id="mode-new" type="radio" name="mode" value="new" checked />
<span class="choice-body">
<span class="choice-title">This computer</span>
<span class="choice-note">Local BangSo Bot stack.</span>
</span>
</label>
<label class="choice" for="mode-existing">
<input id="mode-existing" type="radio" name="mode" value="existing" />
<span class="choice-body">
<span class="choice-title">Existing instance</span>
<span class="choice-note">A server you already run.</span>
</span>
</label>
</fieldset>
<section id="panel-new" class="panel">
<label class="field-label" for="local-url">Local address</label>
<input id="local-url" class="field" type="text" spellcheck="false" autocomplete="off" />
<p class="hint">If it isnt running yet, start with <code>pnpm dev</code>.</p>
</section>
<section id="panel-existing" class="panel" hidden>
<label class="field-label" for="server-url">Server address</label>
<input
id="server-url"
class="field"
type="text"
spellcheck="false"
autocomplete="off"
placeholder="https://rakazo.example.com"
/>
<p class="hint">Same address you open in a browser.</p>
</section>
<p id="status" class="status" role="status" aria-live="polite"></p>
<div class="actions">
<button id="check" class="button button-secondary" type="button">Check connection</button>
<button id="continue" class="button button-primary" type="submit">Continue</button>
</div>
</form>
<p class="footnote">Change later from the BangSo Bot menu (⌘/Ctrl+Shift+K).</p>
</main>
<script src="setup.js"></script>
</body>
</html>

127
apps/desktop/src/setup.js Normal file
View File

@ -0,0 +1,127 @@
(() => {
const bridge = window.rakazoSetup;
const form = document.getElementById("setup");
const localUrl = document.getElementById("local-url");
const serverUrl = document.getElementById("server-url");
const panelNew = document.getElementById("panel-new");
const panelExisting = document.getElementById("panel-existing");
const status = document.getElementById("status");
const checkButton = document.getElementById("check");
const continueButton = document.getElementById("continue");
const quitButton = document.getElementById("quit");
function selectedMode() {
const checked = form.querySelector('input[name="mode"]:checked');
return checked === null ? "new" : checked.value;
}
function activeField() {
return selectedMode() === "new" ? localUrl : serverUrl;
}
function setStatus(message, tone) {
status.textContent = message;
if (tone === undefined) status.removeAttribute("data-tone");
else status.setAttribute("data-tone", tone);
}
function setBusy(busy) {
checkButton.disabled = busy;
continueButton.disabled = busy;
}
function syncPanels() {
const mode = selectedMode();
panelNew.hidden = mode !== "new";
panelExisting.hidden = mode === "new";
setStatus("");
}
async function check() {
const value = activeField().value;
if (value.trim() === "") {
setStatus("Enter a server address first.", "error");
return null;
}
setBusy(true);
setStatus("Checking…");
try {
const result = await bridge.test(value);
if (result.ok) {
activeField().value = result.url;
setStatus(`BangSo Bot answered at ${result.url}.`, "ok");
} else {
setStatus(result.error ?? "Could not reach that address.", "error");
}
return result;
} catch {
setStatus("Could not run the connection check. Try again.", "error");
return null;
} finally {
setBusy(false);
}
}
form.addEventListener("change", (event) => {
if (event.target instanceof HTMLInputElement && event.target.name === "mode") syncPanels();
});
checkButton.addEventListener("click", () => {
void check();
});
quitButton.addEventListener("click", () => {
if (bridge === undefined) {
window.close();
return;
}
void bridge.quit();
});
form.addEventListener("submit", async (event) => {
event.preventDefault();
const mode = selectedMode();
const value = activeField().value;
setBusy(true);
setStatus("Connecting…");
try {
const saved = await bridge.save({ mode, serverUrl: value });
if (!saved.ok) setStatus(saved.error ?? "Could not save that address.", "error");
} catch {
setStatus("Could not save that address. Try again.", "error");
} finally {
setBusy(false);
}
});
async function init() {
if (bridge === undefined) {
setStatus("Setup bridge unavailable.", "error");
setBusy(true);
return;
}
try {
const state = await bridge.state();
if (state === null) throw new Error("Setup is not active");
localUrl.value = state.defaultLocalUrl;
if (state.saved !== null) {
const modeInput = document.querySelector(`input[name="mode"][value="${state.saved.mode}"]`);
if (modeInput !== null) modeInput.checked = true;
if (state.saved.mode === "existing") serverUrl.value = state.saved.serverUrl;
else localUrl.value = state.saved.serverUrl;
}
syncPanels();
if (state.error) setStatus(state.error, "error");
activeField().focus();
} catch {
setStatus("Setup could not start. Quit BangSo Bot and try again.", "error");
setBusy(true);
}
}
void init();
})();

View File

@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { shouldOpenInAppPopup } from "./window-open.js";
const appOrigin = "https://rakazo.example.com";
describe("desktop child windows", () => {
it("keeps same-origin app routes in Electron", () => {
expect(shouldOpenInAppPopup(appOrigin, `${appOrigin}/mcp/oauth/callback`, "_blank")).toBe(true);
});
it("opens ordinary external links outside Electron", () => {
expect(
shouldOpenInAppPopup(appOrigin, "https://github.com/elie222/rakazo/pull/395", "_blank"),
).toBe(false);
});
it.each([
"rakazo-model-oauth",
"rakazo-mcp-oauth",
"rakazo-app-connect",
"rakazo-plugin-connect",
])("keeps the intentional %s flow in an Electron popup", (frameName) => {
expect(
shouldOpenInAppPopup(appOrigin, "https://provider.example.com/authorize", frameName),
).toBe(true);
});
it("rejects malformed URLs and non-HTTPS third-party targets", () => {
expect(shouldOpenInAppPopup(appOrigin, "not a url", "rakazo-model-oauth")).toBe(false);
expect(
shouldOpenInAppPopup(appOrigin, "http://provider.example.com", "rakazo-model-oauth"),
).toBe(false);
});
});

View File

@ -0,0 +1,23 @@
const OAUTH_POPUP_NAMES = new Set([
"rakazo-app-connect",
"rakazo-mcp-oauth",
"rakazo-model-oauth",
"rakazo-plugin-connect",
]);
export function shouldOpenInAppPopup(
appOrigin: string | null,
childUrl: string,
frameName: string,
) {
let target: URL;
try {
target = new URL(childUrl);
} catch {
return false;
}
const isHttp = target.protocol === "http:" || target.protocol === "https:";
if (appOrigin !== null && target.origin === appOrigin) return isHttp;
return target.protocol === "https:" && OAUTH_POPUP_NAMES.has(frameName);
}

View File

@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import {
browserWindowOptions,
DEFAULT_WARM_WINDOW_TTL_MS,
setupWindowOptions,
warmWindowTtlMs,
} from "./window-options.js";
describe("desktop window chrome", () => {
it("uses native traffic lights on macOS", () => {
const opts = browserWindowOptions("darwin");
expect(opts.frame).toBe(true);
expect(opts.titleBarStyle).toBe("hiddenInset");
expect(opts.trafficLightPosition).toEqual({ x: 16, y: 16 });
});
it("is frameless on Windows and Linux so in-app buttons control the window", () => {
for (const platform of ["win32", "linux"] as const) {
const opts = browserWindowOptions(platform);
expect(opts.frame).toBe(false);
expect(opts.titleBarStyle).toBeUndefined();
}
});
});
describe("setup window chrome", () => {
it("matches the app window chrome so first run looks like the product", () => {
for (const platform of ["darwin", "win32", "linux"] as const) {
const setup = setupWindowOptions(platform);
const app = browserWindowOptions(platform);
expect(setup.frame).toBe(app.frame);
expect(setup.titleBarStyle).toBe(app.titleBarStyle);
expect(setup.backgroundColor).toBe(app.backgroundColor);
}
});
it("opens smaller than the app window and stays usable when resized down", () => {
const setup = setupWindowOptions("win32");
expect(setup.width).toBeLessThan(browserWindowOptions("win32").width);
expect(setup.minWidth).toBeLessThanOrEqual(setup.width);
expect(setup.minHeight).toBeLessThanOrEqual(setup.height);
});
});
describe("warm window lifetime", () => {
it("accepts finite timer delays within Node's supported range", () => {
expect(warmWindowTtlMs("0")).toBe(0);
expect(warmWindowTtlMs("900000")).toBe(900_000);
expect(warmWindowTtlMs("2147483647")).toBe(2_147_483_647);
});
it.each([undefined, "", " ", "nope", "-1", "Infinity", "2147483648"])(
"uses the default for an invalid value (%s)",
(value) => {
expect(warmWindowTtlMs(value)).toBe(DEFAULT_WARM_WINDOW_TTL_MS);
},
);
});

View File

@ -0,0 +1,31 @@
export const DEFAULT_WARM_WINDOW_TTL_MS = 15 * 60_000;
const MAX_TIMER_DELAY_MS = 2_147_483_647;
export function warmWindowTtlMs(value: string | undefined) {
if (value === undefined || value.trim() === "") return DEFAULT_WARM_WINDOW_TTL_MS;
const parsed = Number(value);
return Number.isFinite(parsed) && parsed >= 0 && parsed <= MAX_TIMER_DELAY_MS
? parsed
: DEFAULT_WARM_WINDOW_TTL_MS;
}
function windowChrome(platform: NodeJS.Platform) {
const mac = platform === "darwin";
return {
backgroundColor: "#050506",
show: true,
autoHideMenuBar: true,
frame: mac,
titleBarStyle: mac ? ("hiddenInset" as const) : undefined,
trafficLightPosition: mac ? { x: 16, y: 16 } : undefined,
};
}
export function browserWindowOptions(platform: NodeJS.Platform) {
return { width: 1440, height: 900, ...windowChrome(platform) };
}
/** The first-run setup window is smaller and keeps the same frameless chrome. */
export function setupWindowOptions(platform: NodeJS.Platform) {
return { width: 720, height: 700, minWidth: 480, minHeight: 560, ...windowChrome(platform) };
}

Some files were not shown because too many files have changed in this diff Show More