Compare commits

..

No commits in common. "main" and "feat/chat_team_mode" have entirely different histories.

51 changed files with 682 additions and 1848 deletions

View File

@ -1,16 +1,15 @@
# 登入改為帳號密碼(在網頁上註冊),不再需要共享 token。 XAI_API_KEY=
# API 金鑰也不再讀環境變數:註冊後到「設定 → 模型」貼上,沒有金鑰就不能跑。 OPENCODE_GO_API_KEY=
OPENAI_API_KEY=
# Generate independent values with: openssl rand -hex 32 # Generate independent values with: openssl rand -hex 32
LAZYBOY_APP_TOKEN=
SANDBOX_SUPERVISOR_TOKEN= SANDBOX_SUPERVISOR_TOKEN=
# 這個金鑰要穩定輪替:換掉它會讀不到已加密的密碼庫。 # Keep this key stable when rotating the app login token.
LAZYBOY_VAULT_KEY= LAZYBOY_VAULT_KEY=
POSTGRES_PASSWORD=lazyboy POSTGRES_PASSWORD=lazyboy
# 127.0.0.1 = 只有本機0.0.0.0 = 開放區網,讓別人用他自己的帳號登入 # 127.0.0.1 = 只有本機0.0.0.0 = 開放區網(需 LAZYBOY_APP_TOKEN >= 32 字元)
# 也可填單一網卡的 IP把監聽限制在那個介面。 # 也可填單一網卡的 IP把監聽限制在那個介面。
LAZYBOY_BIND_IP=127.0.0.1 LAZYBOY_BIND_IP=127.0.0.1
# 只有清單上的網域名稱會被接受DNS rebinding 防護)。用 IP 或 localhost 連進來
# 不需要填;用例如 lazyboy.local 這種位名時才加在這裡,逗號分隔。
LAZYBOY_ALLOWED_HOSTS=
SANDBOX_PROVIDER=docker SANDBOX_PROVIDER=docker
DATABASE_URL=postgres://lazyboy:lazyboy@127.0.0.1:5434/lazyboy DATABASE_URL=postgres://lazyboy:lazyboy@127.0.0.1:5434/lazyboy
DATA_DIR=./data DATA_DIR=./data

View File

@ -2,46 +2,6 @@
All notable changes to LazyBoy are documented here. All notable changes to LazyBoy are documented here.
## [Unreleased]
Sign in with your own account. The shared install token is gone, and so are the
model keys in the environment.
- **Register and log in** with a username and password in the app. Passwords are
stored as PBKDF2-HMAC-SHA256 (120,000 rounds, per-password salt) and sessions
live in the database as token digests in an `HttpOnly`, `SameSite=Strict`
cookie. Repeated failures cool a username down for five minutes.
- **One workspace per account.** Agents, chats, runs, schedules, credential
vaults, and model keys belong to the account that created them; a second
account on the same install sees none of it. Work created before this release
is adopted by the first account that registers.
- **Model keys come from Settings → Models only.** `LAZYBOY_APP_TOKEN`,
`XAI_API_KEY`, `OPENCODE_GO_API_KEY`, and `OPENAI_API_KEY` are no longer read.
A workspace without a key does not quietly run on someone else's: the run
fails immediately and points at the setting to fix.
- **`LAZYBOY_ALLOWED_HOSTS`** lists the domain names an install may be addressed
by (DNS rebinding guard). IPs and `localhost` keep working with no config.
- Removed: the token login screen, `LAZYBOY_APP_TOKEN`, and the rule that a
non-loopback bind required a 32-character token. `SANDBOX_SUPERVISOR_TOKEN`
and `LAZYBOY_VAULT_KEY` are still required in `.env`.
### 繁體中文
改成自己的帳號登入:共享的安裝 token 拿掉了,環境變數裡的模型金鑰也拿掉了。
- **在 App 裡註冊與登入**:密碼以 PBKDF2-HMAC-SHA256120,000 輪、每組密碼各自的
salt存放session 在資料庫只存雜湊cookie 是 `HttpOnly` + `SameSite=Strict`
同一帳號連續失敗會冷卻五分鐘。
- **一個帳號一個工作區**Agent、對話、執行紀錄、排程、憑證庫與模型金鑰都只屬於
建立它的帳號,同一套安裝的第二個帳號看不到任何資料;舊資料由第一個註冊的帳號接手。
- **模型金鑰只吃「設定 → 模型」**:不再讀 `LAZYBOY_APP_TOKEN`、`XAI_API_KEY`、
`OPENCODE_GO_API_KEY`、`OPENAI_API_KEY`。沒設金鑰的工作區不會偷偷用別人的金鑰,
任務會立刻失敗並指出該去哪裡補。
- **`LAZYBOY_ALLOWED_HOSTS`**要改用網域名稱連進來時列在這裡DNS rebinding 防護);
用 IP 或 `localhost` 不用設定。
- 移除token 登入畫面、`LAZYBOY_APP_TOKEN`,以及「綁非 loopback 需 32 字元 token」
的規則。`.env` 仍需要 `SANDBOX_SUPERVISOR_TOKEN``LAZYBOY_VAULT_KEY`
## [v0.1.0-alpha] - 2026-09-09 ## [v0.1.0-alpha] - 2026-09-09
First public alpha. Self-hosted AI agent workspace: each agent gets an isolated Linux desktop, you watch it live, and you can take over at any time. First public alpha. Self-hosted AI agent workspace: each agent gets an isolated Linux desktop, you watch it live, and you can take over at any time.

1
Cargo.lock generated
View File

@ -1899,7 +1899,6 @@ dependencies = [
"fastembed", "fastembed",
"futures-util", "futures-util",
"hex", "hex",
"hmac",
"http", "http",
"lazyboy-contracts", "lazyboy-contracts",
"lazyboy-control", "lazyboy-control",

View File

@ -60,7 +60,8 @@ help: ## Show this help
@echo " make web Build the frontend in $(WEB_DIR) (needs node/npm)" @echo " make web Build the frontend in $(WEB_DIR) (needs node/npm)"
@echo " make clean cargo clean" @echo " make clean cargo clean"
@echo "" @echo ""
@echo " After 'make up': open http://127.0.0.1:3101, register an account, and add a model API key in 設定 → 模型." @echo " After 'make up': open http://127.0.0.1:3101 and sign in with LAZYBOY_APP_TOKEN."
@echo " Set XAI_API_KEY in .env for chat."
# --- Environment ----------------------------------------------------------- # --- Environment -----------------------------------------------------------
@ -78,7 +79,7 @@ up: env ## Build every image and start the whole stack in Docker
@$(MAKE) --no-print-directory prepare-screen-network @$(MAKE) --no-print-directory prepare-screen-network
$(COMPOSE) up -d --build $(COMPOSE) up -d --build
@echo "" @echo ""
@echo "stack launched. open http://127.0.0.1:3101 and register your account." @echo "stack launched. open http://127.0.0.1:3101 and sign in with LAZYBOY_APP_TOKEN."
$(COMPOSE) ps $(COMPOSE) ps
# Compose owns `lazyboy_screen` (internal, labeled). A leftover from host-dev or # Compose owns `lazyboy_screen` (internal, labeled). A leftover from host-dev or

View File

@ -18,11 +18,10 @@ A self-hosted AI agent workspace. Assign tasks in text or voice, watch the deskt
LazyBoy gives each agent its own Linux desktop in Docker — browser, terminal, and files. You can run several agents, put them in a group, turn a demonstration into a skill, and schedule it to run again. LazyBoy gives each agent its own Linux desktop in Docker — browser, terminal, and files. You can run several agents, put them in a group, turn a demonstration into a skill, and schedule it to run again.
This is an early `v0.1.0-alpha` release with desktop and phone browser UIs. Every person registers their own account, and model API keys live in the app's settings rather than in the environment. This is an early `v0.1.0-alpha` release with desktop and phone browser UIs. You bring your own model API key.
## Features ## Features
- **One account per person**: register a username and password, and the whole workspace — agents, chats, credentials, model keys — belongs to that account alone.
- **A lasting workspace**: each agent has its own chats, run history, and optional long-term memory. - **A lasting workspace**: each agent has its own chats, run history, and optional long-term memory.
- **A real computer**: open pages, use the terminal, organize files, drive the GUI — and watch it live. - **A real computer**: open pages, use the terminal, organize files, drive the GUI — and watch it live.
- **Attachments in chat**: send files or images along with the message; the agent can open them on its own desktop, and inbox copies expire on their own. - **Attachments in chat**: send files or images along with the message; the agent can open them on its own desktop, and inbox copies expire on their own.
@ -37,7 +36,7 @@ This is an early `v0.1.0-alpha` release with desktop and phone browser UIs. Ever
## Quick start ## Quick start
You need Docker and Compose, Git, Make, and Python 3, plus an API key for a supported model — you paste that key into the app after you register, not into `.env`. On macOS use Docker Desktop or OrbStack; on Linux use Docker Engine. You need Docker and Compose, Git, Make, Python 3, and an API key for a supported model. On macOS use Docker Desktop or OrbStack; on Linux use Docker Engine.
From the repo root: From the repo root:
@ -45,14 +44,14 @@ From the repo root:
make env make env
``` ```
The generated `.env` holds service secrets only (supervisor, credential vault, database); the init tool fills them in and keeps existing values when run again. Nothing about signing in or model access is configured here. Edit the generated `.env` and set one of `XAI_API_KEY`, `OPENCODE_GO_API_KEY`, or `OPENAI_API_KEY`. The init tool creates the login and service secrets; running it again keeps existing values.
```bash ```bash
make up make up
make health make health
``` ```
Open **[http://127.0.0.1:3101](http://127.0.0.1:3101)**, register an account with any username and password you like, paste your model API key under **Settings → Models**, then create an agent and pick a model. Without a key nothing runs — the app tells you where to add one. Open **[http://127.0.0.1:3101](http://127.0.0.1:3101)**, sign in with `LAZYBOY_APP_TOKEN` from `.env`, create an agent, and pick a model.
The first run builds the API and Linux desktop images from source and takes a while. The build toolchain lives in the containers, so a full Docker deploy does not need Rust or Node.js on the host. The first run builds the API and Linux desktop images from source and takes a while. The build toolchain lives in the containers, so a full Docker deploy does not need Rust or Node.js on the host.
@ -72,7 +71,7 @@ For resource limits, environment variables, HTTPS, and in-container sudo, see [O
## On a phone ## On a phone
Desktop and phone share the same web UI. The API listens on `127.0.0.1` by default, so set `LAZYBOY_BIND_IP=0.0.0.0` (or one network card's address) in `.env` and recreate the api container before a phone on your network can reach it. Then open that address in the phone browser — `127.0.0.1` there is the phone itself and will not reach another machine — and register there too: two people on one install each see only their own agents. Reaching the app by a domain name instead of an IP or `localhost` additionally requires listing it in `LAZYBOY_ALLOWED_HOSTS`. Desktop and phone share the same web UI. The API listens on `127.0.0.1` by default, so set `LAZYBOY_BIND_IP=0.0.0.0` (or one network card's address) in `.env` and recreate the api container before a phone on your network can reach it; off-loopback the login token has to be at least 32 characters. Then open that address in the phone browser — `127.0.0.1` there is the phone itself and will not reach another machine.
Tap outside the chat sidebar to collapse it. On the remote desktop you can switch between tap-to-click and trackpad, and use the toolbar for keyboard, right-click, or drag. Put the service behind HTTPS before you expose it; see [Operations](./docs/operations.md#安全模型). Tap outside the chat sidebar to collapse it. On the remote desktop you can switch between tap-to-click and trackpad, and use the toolbar for keyboard, right-click, or drag. Put the service behind HTTPS before you expose it; see [Operations](./docs/operations.md#安全模型).

View File

@ -18,11 +18,10 @@
LazyBoy 讓 Agent 在 Docker 裡使用自己的 Linux 桌面,操作瀏覽器、終端與檔案。你可以建立不同的 Agent、在群組中協作把示範整理成技能再安排定時執行。 LazyBoy 讓 Agent 在 Docker 裡使用自己的 Linux 桌面,操作瀏覽器、終端與檔案。你可以建立不同的 Agent、在群組中協作把示範整理成技能再安排定時執行。
目前為早期版本 `v0.1.0-alpha`,提供桌面與手機瀏覽器介面;每個人各自註冊帳號,模型金鑰在 App 的設定裡填寫,不放在環境變數 目前為早期版本 `v0.1.0-alpha`,提供桌面與手機瀏覽器介面;模型金鑰由你自行設定
## 功能 ## 功能
- **一人一帳號**用帳號密碼註冊後Agent、對話、憑證與模型金鑰都只屬於那個帳號彼此不會混用。
- **持續的工作空間**:每個 Agent 有自己的對話、工作紀錄與可設定的長期記憶。 - **持續的工作空間**:每個 Agent 有自己的對話、工作紀錄與可設定的長期記憶。
- **真的能操作電腦**:開網頁、使用終端、整理檔案、操作圖形介面,過程可即時觀看。 - **真的能操作電腦**:開網頁、使用終端、整理檔案、操作圖形介面,過程可即時觀看。
- **附件直接丟進對話**訊息可帶檔案或圖片Agent 能在自己的桌面開啟,放在收件匣的複本會自動到期清除。 - **附件直接丟進對話**訊息可帶檔案或圖片Agent 能在自己的桌面開啟,放在收件匣的複本會自動到期清除。
@ -37,7 +36,7 @@ LazyBoy 讓 Agent 在 Docker 裡使用自己的 Linux 桌面,操作瀏覽器
## 快速開始 ## 快速開始
需要 Docker 與 Compose、Git、Make、Python 3以及一組支援的模型 API 金鑰——金鑰註冊後在 App 裡貼上,不寫進 `.env`。macOS 可使用 Docker Desktop 或 OrbStackLinux 可使用 Docker Engine。 需要 Docker 與 Compose、Git、Make、Python 3以及一組支援的模型 API 金鑰。macOS 可使用 Docker Desktop 或 OrbStackLinux 可使用 Docker Engine。
取得本專案原始碼後,在專案根目錄執行: 取得本專案原始碼後,在專案根目錄執行:
@ -45,14 +44,14 @@ LazyBoy 讓 Agent 在 Docker 裡使用自己的 Linux 桌面,操作瀏覽器
make env make env
``` ```
產生的 `.env` 只有服務內部使用的秘密supervisor、憑證庫、資料庫初始化工具會產生這些值重複執行時保留既有設定。登入與模型金鑰都不需要在這裡設定。 編輯產生的 `.env`,填入 `XAI_API_KEY`、`OPENCODE_GO_API_KEY` 或 `OPENAI_API_KEY` 其中之一。初始化工具會產生登入與服務所需的金鑰,重新執行時保留既有設定。
```bash ```bash
make up make up
make health make health
``` ```
開啟 **[http://127.0.0.1:3101](http://127.0.0.1:3101)**自行註冊一組帳號密碼,到「**設定 → 模型**」貼上模型 API 金鑰,再建立 Agent 並選擇模型。沒有金鑰就不會執行任何工作App 會告訴你要去哪裡補 開啟 **[http://127.0.0.1:3101](http://127.0.0.1:3101)**`.env` 裡的 `LAZYBOY_APP_TOKEN` 登入,建立 Agent 並選擇模型
第一次會從原始碼建置 API 與 Linux 桌面映像,所需時間較長。容器內已包含建置環境,完整 Docker 部署不需要在主機安裝 Rust 或 Node.js。 第一次會從原始碼建置 API 與 Linux 桌面映像,所需時間較長。容器內已包含建置環境,完整 Docker 部署不需要在主機安裝 Rust 或 Node.js。
@ -72,7 +71,7 @@ make down # 停止服務,保留 PostgreSQL 資料
## 在手機上使用 ## 在手機上使用
手機與桌面使用同一個 Web 介面。API 預設只綁 `127.0.0.1`,請在 `.env` 設定 `LAZYBOY_BIND_IP=0.0.0.0`(或指定網卡位址)並重建 api 容器,區網裡的手機才連得到。接著用手機瀏覽器開啟該位址——手機上的 `127.0.0.1` 只代表手機本身,不能拿來連另一台電腦——並在手機上也註冊一個帳號:同一套安裝裡,每個人只會看到自己的 Agent。若要用網域名稱而不是 IP 或 `localhost`)連線,還要把該名稱加進 `LAZYBOY_ALLOWED_HOSTS` 手機與桌面使用同一個 Web 介面。API 預設只綁 `127.0.0.1`,請在 `.env` 設定 `LAZYBOY_BIND_IP=0.0.0.0`(或指定網卡位址)並重建 api 容器,區網裡的手機才連得到;綁非 loopback 時登入 token 需至少 32 字元。接著用手機瀏覽器開啟該位址——手機上的 `127.0.0.1` 只代表手機本身,不能拿來連另一台電腦。
聊天側欄可點外側空白處收合。操作遠端桌面時,可切換直接點選與觸控板模式,使用工具列叫出鍵盤、按右鍵或拖曳。對外提供服務時請設定 HTTPS詳見 [部署指南](./docs/operations.md#安全模型)。 聊天側欄可點外側空白處收合。操作遠端桌面時,可切換直接點選與觸控板模式,使用工具列叫出鍵盤、按右鍵或拖曳。對外提供服務時請設定 HTTPS詳見 [部署指南](./docs/operations.md#安全模型)。

File diff suppressed because one or more lines are too long

View File

@ -70,6 +70,3 @@ export function Upload({className,size=16}:IconProps){
export function Reply({className,size=16}:IconProps){ export function Reply({className,size=16}:IconProps){
return <svg className={["animated-icon",className].filter(Boolean).join(" ")} width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg>; return <svg className={["animated-icon",className].filter(Boolean).join(" ")} width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg>;
} }
export function MeetingLayout({className,size=16}:IconProps){
return <svg className={["animated-icon",className].filter(Boolean).join(" ")} width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><rect x="2" y="4" width="14" height="16" rx="2"/><rect x="18" y="4" width="4" height="16" rx="1.2"/></svg>;
}

View File

@ -1,15 +1,12 @@
export class ApiError extends Error { export class ApiError extends Error {
status:number; status:number;
/** Stable machine code from the API (`username_taken`, `unauthenticated`). */ constructor(status:number,message:string){super(message);this.name="ApiError";this.status=status}
code:string;
constructor(status:number,message:string,code:string=""){super(message);this.name="ApiError";this.status=status;this.code=code}
} }
export async function api<T>(path:string, options:RequestInit={}):Promise<T>{ export async function api<T>(path:string, options:RequestInit={}):Promise<T>{
// The login cookie is first-party and HttpOnly, so every call has to carry it. const response=await fetch(path,{...options,headers:{"content-type":"application/json",...options.headers}});
const response=await fetch(path,{credentials:"same-origin",...options,headers:{"content-type":"application/json",...options.headers}});
const text=await response.text(); let body:unknown=null; const text=await response.text(); let body:unknown=null;
try{body=text?JSON.parse(text):null}catch{body={message:text}} try{body=text?JSON.parse(text):null}catch{body={message:text}}
if(!response.ok){const fields=(typeof body==="object"&&body?body:{}) as {message?:unknown;code?:unknown};const message=typeof fields.message==="string"&&fields.message?fields.message:`${response.status} ${response.statusText}`;throw new ApiError(response.status,message,typeof fields.code==="string"?fields.code:"")} if(!response.ok){const message=typeof body==="object"&&body&&"message" in body?String((body as {message:unknown}).message):`${response.status} ${response.statusText}`;throw new ApiError(response.status,message)}
return body as T; return body as T;
} }

View File

@ -18,15 +18,6 @@
.overlay-screen>.desktop-frame,.overlay-screen>.empty-computer{width:min(100%,1440px);height:100%;border:1px solid var(--border);border-radius:14px;overflow:hidden;background:var(--main)} .overlay-screen>.desktop-frame,.overlay-screen>.empty-computer{width:min(100%,1440px);height:100%;border:1px solid var(--border);border-radius:14px;overflow:hidden;background:var(--main)}
.overlay-error{position:absolute;top:74px;left:50%;transform:translateX(-50%);background:var(--error-bg);color:var(--error-fg);padding:9px 15px;border-radius:9px} .overlay-error{position:absolute;top:74px;left:50%;transform:translateX(-50%);background:var(--error-bg);color:var(--error-fg);padding:9px 15px;border-radius:9px}
.computer-toggle{background:var(--surface)} .computer-toggle{background:var(--surface)}
.meeting-stage{display:flex;flex-direction:column;min-width:0;min-height:0;background:var(--panel);border-right:1px solid var(--hairline)}
.meeting-stage.is-hidden{display:none}
.meeting-stage-head{display:flex;align-items:center;gap:6px;height:72px;flex:0 0 72px;padding:0 10px 0 12px;border-bottom:1px solid var(--hairline)}
.meeting-stage .side-card-body{display:flex;flex-direction:column;min-height:0;flex:1;padding:14px 16px 16px;overflow:hidden}
.meeting-stage.is-meeting .meeting-extras{display:none}
.meeting-stage.is-meeting .computer-part{overflow:hidden}
.meeting-stage.is-meeting .computer-part>.preview{flex:1 1 auto;flex-shrink:1;width:100%;height:auto;min-height:0;max-height:none;aspect-ratio:auto}
.meeting-stage.is-meeting .computer-part .preview .desktop-frame,
.meeting-stage.is-meeting .computer-part .preview .empty-computer{height:100%;aspect-ratio:auto;border-radius:12px;overflow:hidden}
.computer-part{display:flex;flex-direction:column;min-height:0;flex:1;gap:10px;overflow:auto} .computer-part{display:flex;flex-direction:column;min-height:0;flex:1;gap:10px;overflow:auto}
.computer-part.hidden-part{display:none} .computer-part.hidden-part{display:none}
.computer-status-row{display:flex;align-items:center;gap:8px;color:var(--muted)} .computer-status-row{display:flex;align-items:center;gap:8px;color:var(--muted)}
@ -85,12 +76,4 @@
.computer-overlay{top:var(--visible-top,0px);bottom:auto;height:var(--visible-height,100dvh)} .computer-overlay{top:var(--visible-top,0px);bottom:auto;height:var(--visible-height,100dvh)}
.computer-overlay>header{flex-shrink:0} .computer-overlay>header{flex-shrink:0}
.overlay-error{position:static;transform:none;flex:none;margin:0 8px 8px} .overlay-error{position:static;transform:none;flex:none;margin:0 8px 8px}
@media(max-width:700px){ @media(max-width:700px){.overlay-screen{padding:4px}.computer-overlay>header{min-height:44px;gap:4px}.computer-overlay>header>div{gap:5px}.computer-overlay>header strong{font-size:12px}}
.meeting-stage{display:none!important}
.computer-overlay{position:fixed!important;inset:0!important;top:0!important;left:0!important;right:0!important;bottom:0!important;width:100%!important;height:100dvh!important;height:100%!important;z-index:200}
.computer-overlay>header{height:auto;min-height:48px;gap:4px;padding:calc(6px + env(safe-area-inset-top)) 10px 6px}
.computer-overlay>header>div{gap:5px;flex-wrap:wrap}
.computer-overlay>header strong{font-size:12px}
.overlay-screen{padding:0}
.overlay-screen .overlay-desktop,.overlay-screen .overlay-desktop>.desktop-frame,.overlay-screen .overlay-desktop>.empty-computer{width:100%;height:100%;border:0;border-radius:0}
}

View File

@ -333,6 +333,7 @@ export const en: { [K in keyof typeof zhTW]: string } = {
apiKey: "API key", apiKey: "API key",
apiKeyPlaceholder: "sk-…", apiKeyPlaceholder: "sk-…",
apiKeyStored: "A key is saved. Leave blank to keep it.", apiKeyStored: "A key is saved. Leave blank to keep it.",
usingEnvKey: "Using environment variable {name}.",
clearApiKey: "Clear saved key", clearApiKey: "Clear saved key",
modelId: "Model", modelId: "Model",
modelIdPlaceholder: "For example: qwen2.5 or llama3.1", modelIdPlaceholder: "For example: qwen2.5 or llama3.1",
@ -352,10 +353,6 @@ export const en: { [K in keyof typeof zhTW]: string } = {
helpGroups: "+ → New group, pick at least two. @ a name and that agent answers; unaddressed messages go to the host or the best fit.", helpGroups: "+ → New group, pick at least two. @ a name and that agent answers; unaddressed messages go to the host or the best fit.",
helpComputerTitle: "Computer", helpComputerTitle: "Computer",
helpComputer: "The Computer tab on the right is this agents desktop. Start it, take over mouse and keyboard, or let it drive.", helpComputer: "The Computer tab on the right is this agents desktop. Start it, take over mouse and keyboard, or let it drive.",
meetingMode: "Meeting mode",
exitMeetingMode: "Exit meeting mode",
helpMeetingTitle: "Meeting mode",
helpMeeting: "On desktop, the title bar can put the screen in the middle and chat on the side, like a shared-screen call. Phones dont have this mode.",
helpMemoryTitle: "Memory", helpMemoryTitle: "Memory",
helpMemory: "Clearing a chat doesnt wipe long-term memory. Ask the agent to remember, or add it in Memory on the right.", helpMemory: "Clearing a chat doesnt wipe long-term memory. Ask the agent to remember, or add it in Memory on the right.",
helpMcpTitle: "MCP plugins", helpMcpTitle: "MCP plugins",
@ -398,32 +395,11 @@ export const en: { [K in keyof typeof zhTW]: string } = {
feedbackDescription: "Write a problem, idea, or request. This is a local workspace, so the text is copied to the clipboard for you to paste into an issue or message.", feedbackDescription: "Write a problem, idea, or request. This is a local workspace, so the text is copied to the clipboard for you to paste into an issue or message.",
feedbackPlaceholder: "For example: in group chats I want to pick who speaks first…", feedbackPlaceholder: "For example: in group chats I want to pick who speaks first…",
copyContent: "Copy", copyContent: "Copy",
loginTitle: "Sign in", loginTitle: "Sign in to LazyBoy",
loginDescription: "Use the account you registered on this server. Your agents, chats, and computers stay yours alone.", loginDescription: "Enter the shared access token configured on the server.",
accessToken: "Access token", accessToken: "Access token",
verifying: "Working…", verifying: "Checking…",
login: "Sign in", login: "Sign in",
registerTitle: "Create account",
registerDescription: "Each account owns its workspace: nobody else sees your agents, and you never see theirs.",
register: "Create account",
registerFailed: "Couldnt create the account",
switchToRegister: "No account yet? Create one",
switchToLogin: "Already have an account? Sign in",
loginUsername: "Username",
loginPassword: "Password",
loginInvalidCredentials: "Wrong username or password.",
loginUsernameTaken: "That username is taken.",
loginTooManyAttempts: "Too many attempts. Try again in 5 minutes.",
loginFieldsRequired: "Enter both a username and a password.",
loginUsernameTooShort: "Use at least 3 characters.",
loginUsernameTooLong: "Use at most 32 characters.",
loginUsernameCharacters: "Use letters, numbers, and . _ - only.",
loginUsernameEdges: "Start and end with a letter or a number.",
loginPasswordTooShort: "Use at least 8 characters.",
loginPasswordTooLong: "That password is too long.",
loginPasswordCharacters: "The password contains characters we cant use.",
modelKeyNotSet: "This workspace has no API key yet — paste one below before an agent can work.",
modelKeyNeededForProvider: "You switched provider, so the stored key no longer applies. Paste a key for this provider, or agents will not be able to start after saving.",
agentComputer: "Agent computer", agentComputer: "Agent computer",
url: "URL", url: "URL",
stdio: "stdio", stdio: "stdio",
@ -560,8 +536,6 @@ export const en: { [K in keyof typeof zhTW]: string } = {
errorTitleInterrupted: "Interrupted", errorTitleInterrupted: "Interrupted",
errorTitleToolTimeout: "Action timed out", errorTitleToolTimeout: "Action timed out",
errorTitleModelKey: "Model API key rejected", errorTitleModelKey: "Model API key rejected",
errorTitleModelKeyMissing: "No API key set",
errorTitleModelOptIn: "Model needs an opt-in on OpenCode",
errorTitleModelQuota: "Rate limited or out of quota", errorTitleModelQuota: "Rate limited or out of quota",
errorTitleModelUnknown: "Model not found", errorTitleModelUnknown: "Model not found",
errorTitleModelTimeout: "Model took too long", errorTitleModelTimeout: "Model took too long",

View File

@ -110,6 +110,7 @@ export const zhTW = {
providerOpencodeGoHint: "OpenCode Go 訂閱。金鑰從 opencode.ai/auth 取得。", providerOpencodeGoHint: "OpenCode Go 訂閱。金鑰從 opencode.ai/auth 取得。",
providerOpenaiCompatibleHint: "自架 vLLM、Ollama、LiteLLM 或其他 OpenAI 相容端點。", providerOpenaiCompatibleHint: "自架 vLLM、Ollama、LiteLLM 或其他 OpenAI 相容端點。",
apiKey: "API 金鑰", apiKeyPlaceholder: "sk-…", apiKeyStored: "已儲存金鑰,留空表示沿用。", apiKey: "API 金鑰", apiKeyPlaceholder: "sk-…", apiKeyStored: "已儲存金鑰,留空表示沿用。",
usingEnvKey: "目前會用環境變數 {name}。",
clearApiKey: "清除已存金鑰", clearApiKey: "清除已存金鑰",
modelId: "模型", modelIdPlaceholder: "例如qwen2.5 或 llama3.1", modelId: "模型", modelIdPlaceholder: "例如qwen2.5 或 llama3.1",
reloadModels: "重新載入模型列表", reloadModels: "重新載入模型列表",
@ -119,10 +120,7 @@ export const zhTW = {
aboutDescription: "本機多 Agent 工作區。每個機器人有自己的 Linux 電腦,也可以拉進群組一起討論,並接入 MCP 工具。", aboutDescription: "本機多 Agent 工作區。每個機器人有自己的 Linux 電腦,也可以拉進群組一起討論,並接入 MCP 工具。",
apiStatus: "API 狀態:{status}", statusNormal: "正常", statusUnavailable: "無法連線", statusChecking: "檢查中…", apiStatus: "API 狀態:{status}", statusNormal: "正常", statusUnavailable: "無法連線", statusChecking: "檢查中…",
helpBotsTitle: "機器人", helpBots: "左上角 新增機器人。點左側列進入對話。右鍵可以釘選、隱藏或刪除。", helpGroupsTitle: "群組", helpGroups: " → 新增群組,選至少兩位。@誰就由誰回;沒點名時,主持人或最合適的 Agent 會接手。", helpBotsTitle: "機器人", helpBots: "左上角 新增機器人。點左側列進入對話。右鍵可以釘選、隱藏或刪除。", helpGroupsTitle: "群組", helpGroups: " → 新增群組,選至少兩位。@誰就由誰回;沒點名時,主持人或最合適的 Agent 會接手。",
helpComputerTitle: "電腦", helpComputer: "右側「電腦」是這個 Agent 的獨立桌面。可以啟動、接管滑鼠鍵盤,或讓它自己操作。", helpComputerTitle: "電腦", helpComputer: "右側「電腦」是這個 Agent 的獨立桌面。可以啟動、接管滑鼠鍵盤,或讓它自己操作。", helpMemoryTitle: "記憶", helpMemory: "清除對話不會刪長期記憶。可以叫 Agent 記住,或在右側「記憶」手動新增。",
meetingMode: "會議模式", exitMeetingMode: "結束會議模式",
helpMeetingTitle: "會議模式", helpMeeting: "電腦版標題列可開啟會議模式:螢幕放大放中間,對話移到旁邊,像分享畫面時邊看邊聊。手機沒有這個模式。",
helpMemoryTitle: "記憶", helpMemory: "清除對話不會刪長期記憶。可以叫 Agent 記住,或在右側「記憶」手動新增。",
helpMcpTitle: "MCP 外掛", helpMcp: "左下「外掛程式」接入 MCP server。連上的工具會顯示在畫面上對話時 Agent 可以使用。", helpMcpTitle: "MCP 外掛", helpMcp: "左下「外掛程式」接入 MCP server。連上的工具會顯示在畫面上對話時 Agent 可以使用。",
helpSkillsTitle: "技能", helpSkills: " → 教它一項任務,示範一次就會整理成技能。示範結束後可以匯出 JSON或把別人的技能檔匯入換一個機器人也適用。", helpSkillsTitle: "技能", helpSkills: " → 教它一項任務,示範一次就會整理成技能。示範結束後可以匯出 JSON或把別人的技能檔匯入換一個機器人也適用。",
helpAttachTitle: "附件", helpAttach: " → 附加檔案。圖片這則訊息就會給模型看,不會存進對話紀錄。若機器人電腦要打開原檔,會暫放 inbox/,兩小時後自動刪,避免把磁碟塞滿。", helpAttachTitle: "附件", helpAttach: " → 附加檔案。圖片這則訊息就會給模型看,不會存進對話紀錄。若機器人電腦要打開原檔,會暫放 inbox/,兩小時後自動刪,避免把磁碟塞滿。",
@ -140,15 +138,7 @@ export const zhTW = {
providerOpenaiVoiceHint: "OpenAI Realtime。同一套通話介面之後可切換。", providerOpenaiVoiceHint: "OpenAI Realtime。同一套通話介面之後可切換。",
voiceReusesTextKey: "目前會重用文字模型已存的 xAI 金鑰。", voiceReusesTextKey: "目前會重用文字模型已存的 xAI 金鑰。",
feedbackDescription: "寫下問題、想法或想要的功能。這是本機工作區,內容會複製到剪貼簿,方便你貼到 issue 或訊息裡。", feedbackPlaceholder: "例如:群組對話希望可以指定誰先發言…", copyContent: "複製內容", feedbackDescription: "寫下問題、想法或想要的功能。這是本機工作區,內容會複製到剪貼簿,方便你貼到 issue 或訊息裡。", feedbackPlaceholder: "例如:群組對話希望可以指定誰先發言…", copyContent: "複製內容",
loginTitle: "登入", loginDescription: "用你在這台伺服器註冊的帳號登入。每個人的 agent、對話與電腦都只有你自己看得到。", accessToken: "存取 token", verifying: "處理中…", login: "登入", loginTitle: "登入 LazyBoy", loginDescription: "輸入伺服器設定的共享存取 token。", accessToken: "存取 token", verifying: "驗證中…", login: "登入",
registerTitle: "建立帳號", registerDescription: "註冊後你會擁有自己的工作區,別人看不到你的 agent你也不會看到他們的。", register: "建立帳號", registerFailed: "註冊失敗",
switchToRegister: "還沒有帳號?建立一個", switchToLogin: "已經有帳號了?返回登入",
loginUsername: "帳號", loginPassword: "密碼",
loginInvalidCredentials: "帳號或密碼不正確。", loginUsernameTaken: "這個帳號已經有人用了。", loginTooManyAttempts: "嘗試次數太多,請 5 分鐘後再試。", loginFieldsRequired: "請輸入帳號與密碼。",
loginUsernameTooShort: "帳號至少 3 個字元。", loginUsernameTooLong: "帳號最多 32 個字元。", loginUsernameCharacters: "帳號只能用字母、數字與 . _ -。", loginUsernameEdges: "帳號必須以字母或數字開頭與結尾。",
loginPasswordTooShort: "密碼至少 8 個字元。", loginPasswordTooLong: "密碼太長了。", loginPasswordCharacters: "密碼含有無法使用的字元。",
modelKeyNotSet: "這個工作區還沒有 API 金鑰,貼上金鑰後 agent 才能開始工作。",
modelKeyNeededForProvider: "換了供應商,原本存的金鑰不能沿用;請貼上這個供應商的金鑰,否則儲存後 agent 會無法開始。",
agentComputer: "Agent 電腦", url: "URL", stdio: "stdio", http: "HTTP", sse: "SSE", agentComputer: "Agent 電腦", url: "URL", stdio: "stdio", http: "HTTP", sse: "SSE",
argumentsPlaceholder: "-y @modelcontextprotocol/server-github", environmentVariablesPlaceholder: "GITHUB_TOKEN=…", argumentsPlaceholder: "-y @modelcontextprotocol/server-github", environmentVariablesPlaceholder: "GITHUB_TOKEN=…",
urlPlaceholder: "https://mcp.example.com/mcp", headersPlaceholder: "Authorization=Bearer …", urlPlaceholder: "https://mcp.example.com/mcp", headersPlaceholder: "Authorization=Bearer …",
@ -235,7 +225,7 @@ export const zhTW = {
monitorStatusPaused: "要你接手", monitorStatusPaused: "要你接手",
errorTitleInterrupted: "被打斷", errorTitleInterrupted: "被打斷",
errorTitleToolTimeout: "動作逾時", errorTitleToolTimeout: "動作逾時",
errorTitleModelKey: "模型金鑰不被接受", errorTitleModelKeyMissing: "尚未設定 API 金鑰", errorTitleModelOptIn: "模型需要先在 OpenCode 開啟", errorTitleModelKey: "模型金鑰不被接受",
errorTitleModelQuota: "限流或額度不足", errorTitleModelQuota: "限流或額度不足",
errorTitleModelUnknown: "模型名稱找不到", errorTitleModelUnknown: "模型名稱找不到",
errorTitleModelTimeout: "模型太久沒回話", errorTitleModelTimeout: "模型太久沒回話",

View File

@ -482,34 +482,6 @@
.app-shell.right-open{grid-template-columns:clamp(220px,18vw,280px) minmax(0,1fr) clamp(320px,30vw,440px)} .app-shell.right-open{grid-template-columns:clamp(220px,18vw,280px) minmax(0,1fr) clamp(320px,30vw,440px)}
.app-shell.meeting-mode,.app-shell.meeting-mode.right-open,.app-shell.meeting-mode.right-collapsed{
grid-template-columns:clamp(200px,16vw,260px) minmax(0,1fr) minmax(300px,380px);
grid-template-areas:"nav stage chat";
}
.app-shell.meeting-mode .sidebar{grid-area:nav}
.app-shell.meeting-mode .meeting-stage{grid-area:stage;border-right:0}
.app-shell.meeting-mode .chat-panel{grid-area:chat;--chat-gutter:14px;--chat-col:100%;border-left:1px solid var(--hairline);min-width:0}
.app-shell.meeting-mode .chat-panel .topbar{
display:flex;flex-wrap:wrap;align-items:center;gap:6px 8px;
height:auto;min-height:0;flex:0 0 auto;padding:8px 10px;
}
.app-shell.meeting-mode .chat-panel .topbar>.grow{display:none}
.app-shell.meeting-mode .chat-panel .topbar>strong{min-width:0;flex:1 1 80px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.app-shell.meeting-mode .chat-panel .topbar>.host-chip-wrap{flex:0 1 auto;max-width:100%}
.app-shell.meeting-mode .host-chip{max-width:96px}
.app-shell.meeting-mode .session-picker{margin-left:0;flex:0 1 auto}
.app-shell.meeting-mode .session-current{max-width:min(100%,140px)}
.app-shell.meeting-mode .chat-panel .topbar>.top-tools{
flex:1 0 100%;width:100%;margin:0;justify-content:space-between;
box-sizing:border-box;padding:3px;overflow:hidden;
}
.app-shell.meeting-mode .top-tool-button{width:32px;height:32px;flex:0 0 32px}
.app-shell.meeting-mode .call-entry{flex:0 0 auto}
.app-shell.meeting-mode .messages{padding-left:var(--chat-gutter);padding-right:var(--chat-gutter);min-width:0}
.app-shell.meeting-mode .composer-dock{padding-left:var(--chat-gutter);padding-right:var(--chat-gutter)}
.app-shell.meeting-mode .side-card-backdrop{display:block;position:fixed;inset:0;z-index:44;background:rgba(0,0,0,.5);grid-area:stage}
.app-shell.meeting-mode .side-card{position:fixed;z-index:45;inset:0 0 0 auto;width:min(420px,100vw);grid-area:stage;box-shadow:-20px 0 60px #000}
.app-shell.right-collapsed{grid-template-columns:clamp(220px,18vw,280px) minmax(0,1fr) 52px} .app-shell.right-collapsed{grid-template-columns:clamp(220px,18vw,280px) minmax(0,1fr) 52px}
@ -607,44 +579,23 @@
.memory-clear-confirm{display:flex;align-items:center;gap:8px;margin-right:auto;color:var(--muted);font-size:13px} .memory-clear-confirm{display:flex;align-items:center;gap:8px;margin-right:auto;color:var(--muted);font-size:13px}
.login-screen{position:relative;display:grid;min-height:100%;min-height:100dvh;place-items:center;padding:72px 24px 64px;background:radial-gradient(circle at 50% 18%,#16241f 0,#080809 58%)} .login-screen{position:relative;display:grid;min-height:100%;place-items:center;padding:88px 20px;background:radial-gradient(circle at 50% 20%,#18201e 0,#080809 55%)}
.login-dialog{width:min(320px,100%);display:grid;justify-items:stretch;gap:14px} .login-dialog{display:grid;justify-items:stretch}
.login-dialog>.avatar{justify-self:center;margin:0 0 10px} .login-dialog>.avatar{justify-self:center}
.login-dialog h1{margin:0 0 8px;text-align:center;font-size:20px;font-weight:650;letter-spacing:.01em} .login-dialog h1,.login-dialog p{text-align:center}
.login-dialog label{display:grid;gap:6px;color:var(--muted);font-size:13px} .login-dialog p{margin-top:-8px;color:var(--muted)}
.login-dialog input{height:44px;border:1px solid var(--border);border-radius:10px;background:var(--input);color:var(--ink);padding:0 12px;outline:0}
.login-dialog input:focus{border-color:var(--focus)}
.login-dialog .primary{height:44px;margin-top:4px;justify-content:center}
.login-error{color:var(--danger-soft);font-size:13px} .login-error{color:var(--danger-soft);font-size:13px}
.login-switch{min-height:0;justify-self:center;margin-top:2px;padding:8px;border:0;border-radius:8px;background:transparent;color:var(--muted);font-size:13px;cursor:pointer} .login-lang{position:absolute;top:24px;right:24px;display:flex;align-items:center;gap:6px}
.login-switch:hover{color:var(--ink)} .login-lang button{height:32px;padding:0 12px;border:1px solid var(--border);border-radius:9px;background:transparent;color:var(--muted);cursor:pointer}
.login-switch:disabled{opacity:.35;cursor:not-allowed} .login-lang button.picked{border-color:var(--accent);color:var(--ink);background:rgba(62,197,168,.08)}
.login-lang{position:absolute;top:20px;right:20px;display:flex;align-items:center;gap:4px}
.login-lang button{height:auto;padding:6px 8px;border:0;border-radius:8px;background:transparent;color:var(--faint);cursor:pointer;font-size:13px}
.login-lang button:hover{color:var(--muted)}
.login-lang button.picked{color:var(--ink);font-weight:600}
@media(max-width:700px){
.login-screen{padding:56px 20px 40px}
.login-dialog{width:min(360px,100%)}
.login-lang{top:12px;right:8px}
.login-lang button{min-height:44px;padding:8px 10px}
}
@keyframes working-shimmer{to{background-position:-220% 0}} @keyframes working-shimmer{to{background-position:-220% 0}}

View File

@ -4,26 +4,14 @@
@media(max-width:1050px){.app-shell{grid-template-columns:250px minmax(0,1fr)}} @media(max-width:1050px){.app-shell{grid-template-columns:250px minmax(0,1fr)}}
@media(max-width:700px){.chat-panel{--chat-gutter:18px}.app-shell{display:block}.message{width:100%}.day-divider{width:100%}.message>span,.message>.message-body,.message-stack{max-width:90%}.composer-dock{padding:20px var(--chat-gutter) 16px}.composer{width:var(--chat-col);min-height:60px}.composer-files{padding:2px 4px 8px 8px}.composer-reply{margin:2px 0 4px;padding-left:12px}.file-card{max-width:100%;height:52px}.file-card-remove{flex-basis:32px;width:32px;height:32px}.message.with-files .msg-attachments{max-width:100%}.messages{padding-bottom:16px}.context-menu{width:min(220px,calc(100vw - 24px))}.context-menu button{height:44px}} @media(max-width:700px){.chat-panel{--chat-gutter:18px}.app-shell{display:block}.message{width:100%}.day-divider{width:100%}.message>span,.message>.message-body,.message-stack{max-width:90%}.composer-dock{padding:20px var(--chat-gutter) 16px}.composer{width:var(--chat-col);min-height:60px}.composer-files{padding:2px 4px 8px 8px}.composer-reply{margin:2px 0 4px;padding-left:12px}.file-card{max-width:100%;height:52px}.file-card-remove{flex-basis:32px;width:32px;height:32px}.message.with-files .msg-attachments{max-width:100%}.messages{padding-bottom:16px}.context-menu{width:min(220px,calc(100vw - 24px))}.context-menu button{height:44px}}
@media(prefers-reduced-motion:reduce){.avatar.blobatar.thinking:before,.avatar.blobatar.thinking:after,.thinking-dots i{animation:none!important}} @media(prefers-reduced-motion:reduce){.avatar.blobatar.thinking:before,.avatar.blobatar.thinking:after,.thinking-dots i{animation:none!important}}
@media(max-width:1200px){.app-shell.right-open{grid-template-columns:230px minmax(0,1fr) 340px}.app-shell.right-collapsed{grid-template-columns:230px minmax(0,1fr) 52px}.app-shell.meeting-mode,.app-shell.meeting-mode.right-open,.app-shell.meeting-mode.right-collapsed{grid-template-columns:200px minmax(0,1fr) 320px}} @media(max-width:1200px){.app-shell.right-open{grid-template-columns:230px minmax(0,1fr) 340px}.app-shell.right-collapsed{grid-template-columns:230px minmax(0,1fr) 52px}}
@media(max-width:1050px){ @media(max-width:1050px){
.panel-open-btn{display:inline-flex} .panel-open-btn{display:inline-flex}
.app-shell.right-collapsed{grid-template-columns:250px minmax(0,1fr)} .app-shell.right-collapsed{grid-template-columns:250px minmax(0,1fr)}
.app-shell.right-collapsed .side-card{display:none} .app-shell.right-collapsed .side-card{display:none}
.app-shell.right-open{grid-template-columns:250px minmax(0,1fr)} .app-shell.right-open{grid-template-columns:250px minmax(0,1fr)}
.app-shell.right-open .side-card-backdrop,.app-shell.stage-open:not(.meeting-mode) .side-card-backdrop{display:block;position:fixed;inset:0;z-index:44;background:rgba(0,0,0,.5)} .app-shell.right-open .side-card-backdrop{display:block;position:fixed;inset:0;z-index:44;background:rgba(0,0,0,.5)}
.app-shell.right-open .side-card{position:fixed;z-index:45;inset:0 0 0 auto;width:min(420px,100vw);box-shadow:-20px 0 60px #000} .app-shell.right-open .side-card{position:fixed;z-index:45;inset:0 0 0 auto;width:min(420px,100vw);box-shadow:-20px 0 60px #000}
.app-shell:not(.meeting-mode).stage-open .meeting-stage:not(.is-hidden){position:fixed;z-index:45;inset:0 0 0 auto;width:min(420px,100vw);box-shadow:-20px 0 60px #000}
.app-shell.meeting-mode,.app-shell.meeting-mode.right-open,.app-shell.meeting-mode.right-collapsed{
display:grid;grid-template-columns:minmax(0,1fr) minmax(280px,340px);grid-template-areas:"stage chat";
}
.app-shell.meeting-mode .sidebar{position:fixed;z-index:40;inset:0 auto 0 0;width:min(300px,88vw);transform:translateX(-105%);transition:.2s;box-shadow:20px 0 60px #000}
.app-shell.meeting-mode .sidebar.open{transform:none}
.app-shell.meeting-mode .mobile-menu{display:inline-flex}
}
@media(max-width:700px){
.meeting-toggle{display:none!important}
.meeting-stage{display:none!important}
.app-shell.meeting-mode,.app-shell.meeting-mode.right-open,.app-shell.meeting-mode.right-collapsed{display:block;grid-template-areas:none;grid-template-columns:none}
} }
@media(prefers-reduced-motion:reduce){.working-label{animation:none;color:var(--muted);background:none}} @media(prefers-reduced-motion:reduce){.working-label{animation:none;color:var(--muted);background:none}}
@media(prefers-reduced-motion:reduce){.computer-hud .avatar.blobatar,.computer-hud-label{animation:none!important}.computer-hud-label{color:var(--muted);background:none}} @media(prefers-reduced-motion:reduce){.computer-hud .avatar.blobatar,.computer-hud-label{animation:none!important}.computer-hud-label{color:var(--muted);background:none}}

View File

@ -25,8 +25,6 @@ const ACTIONS: Record<string, RunActionId[]> = {
interrupted: ["screen", "retry"], interrupted: ["screen", "retry"],
tool_timeout: ["screen", "retry"], tool_timeout: ["screen", "retry"],
model_key: ["settings", "retry"], model_key: ["settings", "retry"],
model_key_missing: ["settings", "retry"],
model_opt_in: ["settings", "retry"],
model_unknown: ["settings", "retry"], model_unknown: ["settings", "retry"],
model_quota: ["retry", "settings"], model_quota: ["retry", "settings"],
model_timeout: ["retry", "settings"], model_timeout: ["retry", "settings"],
@ -40,8 +38,6 @@ const TITLES: Record<string, MessageKey> = {
interrupted: "errorTitleInterrupted", interrupted: "errorTitleInterrupted",
tool_timeout: "errorTitleToolTimeout", tool_timeout: "errorTitleToolTimeout",
model_key: "errorTitleModelKey", model_key: "errorTitleModelKey",
model_key_missing: "errorTitleModelKeyMissing",
model_opt_in: "errorTitleModelOptIn",
model_quota: "errorTitleModelQuota", model_quota: "errorTitleModelQuota",
model_unknown: "errorTitleModelUnknown", model_unknown: "errorTitleModelUnknown",
model_timeout: "errorTitleModelTimeout", model_timeout: "errorTitleModelTimeout",

View File

@ -38,13 +38,15 @@ export interface VoiceSettings {
ready: boolean; ready: boolean;
missing?: string | null; missing?: string | null;
apiKeySet: boolean; apiKeySet: boolean;
envKeySet: boolean;
envKeyName: string;
reusesTextKey: boolean; reusesTextKey: boolean;
providers: { id: VoiceProviderId; name: string }[]; providers: { id: VoiceProviderId; name: string; envKeyName: string }[];
models: { id: string; name: string }[]; models: { id: string; name: string }[];
voices: { id: string; name: string }[]; voices: { id: string; name: string }[];
} }
export interface WorkspaceProvider { id:ModelProviderId; name:string; needsBaseUrl:boolean; needsKey:boolean; defaultBaseUrl:string|null; defaultModel:string|null } export interface WorkspaceProvider { id:ModelProviderId; name:string; needsBaseUrl:boolean; needsKey:boolean; defaultBaseUrl:string|null; defaultModel:string|null }
export interface WorkspaceModel { id:string; name:string } export interface WorkspaceModel { id:string; name:string }
export interface WorkspaceSettings { provider:ModelProviderId; modelId:string; baseUrl:string; apiKeySet:boolean; providers:WorkspaceProvider[]; models:WorkspaceModel[] } export interface WorkspaceSettings { provider:ModelProviderId; modelId:string; baseUrl:string; apiKeySet:boolean; envKeySet:boolean; envKeyName:string; providers:WorkspaceProvider[]; models:WorkspaceModel[] }
export interface MemoryStatus { globallyEnabled:boolean; embeddingStatus:"disabled"|"loading"|"ready"|"busy"|"unavailable"; storedCount:number; indexedCount:number } export interface MemoryStatus { globallyEnabled:boolean; embeddingStatus:"disabled"|"loading"|"ready"|"busy"|"unavailable"; storedCount:number; indexedCount:number }

View File

@ -29,6 +29,7 @@ export function VoiceSettingsDialog({ close }: { close: () => void }) {
.catch((err) => setError(err instanceof Error ? err.message : t("loadFailed"))); .catch((err) => setError(err instanceof Error ? err.message : t("loadFailed")));
}, []); }, []);
const current = settings?.providers.find((item) => item.id === provider);
const models = (settings?.provider === provider ? settings.models : null) const models = (settings?.provider === provider ? settings.models : null)
?? (provider === "openai" ?? (provider === "openai"
? [{ id: "gpt-realtime", name: "GPT Realtime" }] ? [{ id: "gpt-realtime", name: "GPT Realtime" }]
@ -93,8 +94,8 @@ export function VoiceSettingsDialog({ close }: { close: () => void }) {
<legend>{t("voiceProvider")}</legend> <legend>{t("voiceProvider")}</legend>
<div className="provider-grid"> <div className="provider-grid">
{(settings?.providers || [ {(settings?.providers || [
{ id: "xai" as const, name: t("providerXai") }, { id: "xai" as const, name: t("providerXai"), envKeyName: "XAI_API_KEY" },
{ id: "openai" as const, name: t("providerOpenai") }, { id: "openai" as const, name: t("providerOpenai"), envKeyName: "OPENAI_API_KEY" },
]).map((item) => ( ]).map((item) => (
<button type="button" key={item.id} className={provider === item.id ? "picked" : ""} onClick={() => pickProvider(item.id)}> <button type="button" key={item.id} className={provider === item.id ? "picked" : ""} onClick={() => pickProvider(item.id)}>
{item.id === "xai" ? t("providerXai") : item.id === "openai" ? t("providerOpenai") : item.name} {item.id === "xai" ? t("providerXai") : item.id === "openai" ? t("providerOpenai") : item.name}
@ -129,6 +130,7 @@ export function VoiceSettingsDialog({ close }: { close: () => void }) {
<input type="password" autoComplete="off" value={apiKey} onChange={(event) => { setApiKey(event.target.value); setClearKey(false); }} placeholder={settings?.apiKeySet ? t("apiKeyStored") : t("apiKeyPlaceholder")} /> <input type="password" autoComplete="off" value={apiKey} onChange={(event) => { setApiKey(event.target.value); setClearKey(false); }} placeholder={settings?.apiKeySet ? t("apiKeyStored") : t("apiKeyPlaceholder")} />
</label> </label>
{settings?.reusesTextKey && !apiKey && !clearKey ? <p className="dialog-lead">{t("voiceReusesTextKey")}</p> : null} {settings?.reusesTextKey && !apiKey && !clearKey ? <p className="dialog-lead">{t("voiceReusesTextKey")}</p> : null}
{settings?.envKeySet && !apiKey && !clearKey ? <p className="dialog-lead">{t("usingEnvKey", { name: current?.envKeyName || settings.envKeyName })}</p> : null}
{settings?.apiKeySet ? <label className="memory-toggle"><input type="checkbox" checked={clearKey} onChange={(event) => setClearKey(event.target.checked)} /> {t("clearApiKey")}</label> : null} {settings?.apiKeySet ? <label className="memory-toggle"><input type="checkbox" checked={clearKey} onChange={(event) => setClearKey(event.target.checked)} /> {t("clearApiKey")}</label> : null}
</> : null} </> : null}
{error ? <div className="pane-error">{error}</div> : null} {error ? <div className="pane-error">{error}</div> : null}

View File

@ -24,7 +24,6 @@ tower-http.workspace = true
rig-core.workspace = true rig-core.workspace = true
base64.workspace = true base64.workspace = true
sha2.workspace = true sha2.workspace = true
hmac.workspace = true
hex.workspace = true hex.workspace = true
reqwest.workspace = true reqwest.workspace = true
tokio-tungstenite.workspace = true tokio-tungstenite.workspace = true

View File

@ -1,498 +0,0 @@
//! Accounts: a username and a password are the only way into LazyBoy.
//!
//! Opening a workspace used to mean knowing one shared token from `.env`, and
//! everyone who knew it saw the same agents and the same desktops. Here a
//! person registers, owns a workspace of their own, and the cookie they get
//! resolves to exactly one actor on every later request — which is what keeps
//! one person's agents out of another person's list.
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use axum::extract::State;
use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use axum::{Json, Router};
use hmac::{Hmac, Mac};
use rand::RngCore;
use serde::{Deserialize, Serialize};
use serde_json::json;
use sha2::Sha256;
use crate::auth::{self, COOKIE_NAME};
use crate::state::AppState;
/// PBKDF2-HMAC-SHA256 rounds. High enough that cracking a stolen password
/// database is expensive, low enough that signing in never feels stuck. The
/// number is stored with every hash, so raising it later leaves old accounts
/// working and only affects newly set passwords.
const PBKDF2_ROUNDS: u32 = 120_000;
/// A hostile or corrupt row must not turn one login into a CPU denial of
/// service; anything above this is rejected rather than computed.
const MAX_PBKDF2_ROUNDS: u32 = 2_000_000;
const SALT_BYTES: usize = 16;
const KEY_BYTES: usize = 32;
const HASH_PREFIX: &str = "pbkdf2-sha256";
pub const MIN_PASSWORD_CHARS: usize = 8;
pub const MAX_PASSWORD_CHARS: usize = 256;
const MIN_USERNAME_CHARS: usize = 3;
const MAX_USERNAME_CHARS: usize = 32;
/// A login stays valid for a week, and survives an API restart: sessions live
/// in the database, not in process memory.
const SESSION_DAYS: i64 = 7;
/// Failed sign-ins per username before a cool-down, and how long it lasts.
/// Passwords are hashed on every attempt, so a throttled name is also how one
/// account cannot be used to burn the whole server.
const MAX_FAILED_LOGINS: u32 = 8;
const LOGIN_COOLDOWN: Duration = Duration::from_secs(300);
fn hmac_sha256(key: &[u8], data: &[u8]) -> [u8; KEY_BYTES] {
let mut mac = <Hmac<Sha256> as Mac>::new_from_slice(key).expect("HMAC accepts any key length");
mac.update(data);
let digest = mac.finalize().into_bytes();
let mut out = [0u8; KEY_BYTES];
out.copy_from_slice(&digest);
out
}
/// PBKDF2 with the single-block PRF, which is all a 256-bit key needs.
fn pbkdf2_sha256(password: &[u8], salt: &[u8], rounds: u32) -> [u8; KEY_BYTES] {
let mut previous = hmac_sha256(password, &[salt, &1u32.to_be_bytes()].concat());
let mut derived = previous;
for _ in 1..rounds {
previous = hmac_sha256(password, &previous);
for (byte, round) in derived.iter_mut().zip(previous) {
*byte ^= round;
}
}
derived
}
/// `pbkdf2-sha256$rounds$salt$hash`, self-describing so the format can move.
pub fn hash_password(password: &str) -> String {
let mut salt = [0u8; SALT_BYTES];
rand::thread_rng().fill_bytes(&mut salt);
let key = pbkdf2_sha256(password.as_bytes(), &salt, PBKDF2_ROUNDS);
format!(
"{HASH_PREFIX}${PBKDF2_ROUNDS}${}${}",
hex::encode(salt),
hex::encode(key)
)
}
fn parse_password(stored: &str) -> Option<(u32, Vec<u8>, Vec<u8>)> {
let (algorithm, rest) = stored.split_once('$')?;
if algorithm != HASH_PREFIX {
return None;
}
let mut parts = rest.split('$');
let rounds: u32 = parts.next()?.parse().ok()?;
if rounds == 0 || rounds > MAX_PBKDF2_ROUNDS {
return None;
}
let salt = hex::decode(parts.next()?).ok()?;
let key = hex::decode(parts.next()?).ok()?;
if salt.is_empty() || key.is_empty() || parts.next().is_some() {
return None;
}
Some((rounds, salt, key))
}
/// Constant-time check against the stored hash. An unparseable or unknown-format
/// hash is simply "not this password" — never an open door.
pub fn verify_password(password: &str, stored: &str) -> bool {
let Some((rounds, salt, expected)) = parse_password(stored) else {
return false;
};
let key = pbkdf2_sha256(password.as_bytes(), &salt, rounds);
auth::constant_time_eq(&key, &expected)
}
/// Usernames are addresses, so they are stored in one spelling.
pub fn normalize_username(raw: &str) -> String {
raw.trim().to_lowercase()
}
/// The rules that keep a name typeable, comparable, and unambiguous in a URL
/// or an `@mention`: letters and numbers (any language) plus `.` `_` `-`,
/// bracketed by word characters.
fn username_problem(username: &str) -> Option<&'static str> {
if username.chars().count() < MIN_USERNAME_CHARS {
return Some("username_too_short");
}
if username.chars().count() > MAX_USERNAME_CHARS {
return Some("username_too_long");
}
let first = username.chars().next().unwrap_or(' ');
let last = username.chars().last().unwrap_or(' ');
if !first.is_alphanumeric() || !last.is_alphanumeric() {
return Some("username_must_start_with_letter");
}
if username
.chars()
.any(|ch| !(ch.is_alphanumeric() || matches!(ch, '.' | '_' | '-')))
{
return Some("username_has_invalid_characters");
}
None
}
fn password_problem(password: &str) -> Option<&'static str> {
let count = password.chars().count();
if count < MIN_PASSWORD_CHARS {
return Some("password_too_short");
}
if count > MAX_PASSWORD_CHARS {
return Some("password_too_long");
}
if password.chars().any(char::is_control) {
return Some("password_has_invalid_characters");
}
None
}
/// Sliding cool-down per username. In memory on purpose: it exists to make
/// online guessing slow, not to survive a restart.
#[derive(Clone, Default)]
pub struct LoginGuard {
failures: Arc<Mutex<Vec<(String, u32, Instant)>>>,
}
impl LoginGuard {
fn locked(&self, key: &str) -> bool {
let now = Instant::now();
let failures = self
.failures
.lock()
.unwrap_or_else(|error| error.into_inner());
failures
.iter()
.any(|(name, count, until)| name == key && *count >= MAX_FAILED_LOGINS && *until > now)
}
fn record_failure(&self, key: &str) {
let mut failures = self
.failures
.lock()
.unwrap_or_else(|error| error.into_inner());
failures.retain(|(_, _, until)| *until > Instant::now());
let until = Instant::now() + LOGIN_COOLDOWN;
match failures.iter_mut().find(|(name, _, _)| name == key) {
Some(entry) => {
entry.1 += 1;
entry.2 = until;
}
None => failures.push((key.to_string(), 1, until)),
}
}
fn clear(&self, key: &str) {
let mut failures = self
.failures
.lock()
.unwrap_or_else(|error| error.into_inner());
failures.retain(|(name, _, _)| name != key);
}
}
#[derive(Deserialize)]
struct CredentialsInput {
#[serde(default)]
username: String,
#[serde(default)]
password: String,
}
#[derive(Serialize)]
struct SignedIn {
ok: bool,
username: String,
}
pub fn router(state: AppState) -> Router {
Router::new()
.route("/api/auth/session", get(current_session).delete(logout))
.route("/api/auth/register", post(register))
.route("/api/auth/login", post(login))
.with_state(state)
}
/// The single source of truth for "am I signed in": the login screen and every
/// 401 that reaches the browser ends up here.
pub async fn current_session(
State(state): State<AppState>,
headers: HeaderMap,
) -> Json<serde_json::Value> {
let signed_in = auth::session_username(&state, &headers).await;
Json(json!({
"authenticated": signed_in.is_some(),
"required": true,
"username": signed_in,
}))
}
async fn register(
State(state): State<AppState>,
Json(input): Json<CredentialsInput>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
let username = normalize_username(&input.username);
if let Some(code) = username_problem(&username).or_else(|| password_problem(&input.password)) {
return Err(bad_request(code));
}
let hash = hash_password(&input.password);
let user_id = match state.db.create_account(&username, &hash).await {
Ok(user_id) => user_id,
Err(error) if is_username_taken(&error) => {
return Err(bad_request("username_taken"));
}
Err(error) => {
tracing::warn!("register failed: {error}");
return Err(server_error());
}
};
// A new account owns its workspace from the first second; nothing about it
// is shared with the person who registered before them.
if let Err(error) = state.db.ensure_default_space(&user_id).await {
tracing::warn!("workspace creation failed: {error}");
return Err(server_error());
}
tracing::info!("registered account {username}");
signed_in_response(&state, &user_id, &username).await
}
async fn login(
State(state): State<AppState>,
Json(input): Json<CredentialsInput>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
let username = normalize_username(&input.username);
if username.is_empty() || input.password.is_empty() {
return Err(bad_request("fields_required"));
}
if state.auth.guard.locked(&username) {
return Err((
StatusCode::TOO_MANY_REQUESTS,
Json(json!({"message": "嘗試次數太多,請 5 分鐘後再試", "code": "too_many_attempts"})),
));
}
let account = state
.db
.account_by_username(&username)
.await
.map_err(|error| {
tracing::warn!("login lookup failed: {error}");
server_error()
})?;
// Hash even when the name is unknown so that a missing account and a wrong
// password cost the same and answer the same way.
let stored = account
.as_ref()
.and_then(|account| account.password_hash.clone());
let verified = stored
.as_deref()
.is_some_and(|hash| verify_password(&input.password, hash));
let Some(account) = account.filter(|_| verified) else {
state.auth.guard.record_failure(&username);
return Err((
StatusCode::UNAUTHORIZED,
Json(json!({"message": "帳號或密碼不正確", "code": "invalid_credentials"})),
));
};
state.auth.guard.clear(&username);
let user_id = account.user_id;
let username = account.username;
if let Err(error) = state.db.ensure_default_space(&user_id).await {
tracing::warn!("workspace lookup failed: {error}");
return Err(server_error());
}
signed_in_response(&state, &user_id, &username).await
}
async fn signed_in_response(
state: &AppState,
user_id: &str,
username: &str,
) -> Result<Response, (StatusCode, Json<serde_json::Value>)> {
let token = auth::new_session_token();
state
.db
.create_session(user_id, &auth::token_digest(&token), SESSION_DAYS * 86_400)
.await
.map_err(|error| {
tracing::warn!("session creation failed: {error}");
server_error()
})?;
let mut response = Json(SignedIn {
ok: true,
username: username.to_string(),
})
.into_response();
let cookie = format!(
"{COOKIE_NAME}={token}; Path=/; HttpOnly; SameSite=Strict; Max-Age={}{}",
SESSION_DAYS * 86_400,
if state.auth.secure_cookie() {
"; Secure"
} else {
""
}
);
response.headers_mut().insert(
header::SET_COOKIE,
HeaderValue::from_str(&cookie).map_err(|_| server_error())?,
);
Ok(response)
}
async fn logout(State(state): State<AppState>, headers: HeaderMap) -> impl IntoResponse {
if let Some(token) = auth::session_token(&headers)
&& let Err(error) = state.db.delete_session(&auth::token_digest(&token)).await
{
tracing::warn!("session deletion failed: {error}");
}
let mut response = Json(json!({"ok": true})).into_response();
response.headers_mut().insert(
header::SET_COOKIE,
HeaderValue::from_static(auth::EXPIRED_COOKIE),
);
response
}
fn is_username_taken(error: &sqlx::Error) -> bool {
error
.as_database_error()
.and_then(|error| error.code().map(|code| code == "23505"))
.unwrap_or(false)
}
fn bad_request(code: &'static str) -> (StatusCode, Json<serde_json::Value>) {
(
StatusCode::BAD_REQUEST,
Json(json!({"message": message_for(code), "code": code})),
)
}
fn server_error() -> (StatusCode, Json<serde_json::Value>) {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"message": "伺服器錯誤,請稍後再試"})),
)
}
fn message_for(code: &str) -> &'static str {
match code {
"username_too_short" => "帳號至少 3 個字元",
"username_too_long" => "帳號最多 32 個字元",
"username_must_start_with_letter" => "帳號必須以字母或數字開頭結尾",
"username_has_invalid_characters" => "帳號只能使用字母、數字與 . _ -",
"username_taken" => "這個帳號已經有人用了",
"password_too_short" => "密碼至少 8 個字元",
"password_too_long" => "密碼太長了",
"password_has_invalid_characters" => "密碼含有無法使用的字元",
_ => "請輸入帳號與密碼",
}
}
#[cfg(test)]
mod password_tests {
use super::*;
#[test]
fn a_hash_verifies_its_own_password_and_nothing_else() {
let hash = hash_password("correct horse battery staple");
assert!(verify_password("correct horse battery staple", &hash));
assert!(!verify_password("Correct horse battery staple", &hash));
assert!(!verify_password("", &hash));
}
#[test]
fn the_same_password_hashes_differently_each_time() {
assert_ne!(
hash_password("same password"),
hash_password("same password")
);
}
#[test]
fn a_foreign_or_malformed_hash_never_opens_the_door() {
for stored in [
"",
"plain-text-password",
"bcrypt$10$abc",
"pbkdf2-sha256$not-a-number$00$00",
"pbkdf2-sha256$0$00$00",
"pbkdf2-sha256$999999999$00$00",
"pbkdf2-sha256$1000$00",
] {
assert!(!verify_password("anything", stored), "{stored}");
}
}
#[test]
fn a_stored_hash_round_trips_its_rounds_salt_and_key() {
let hash = hash_password("round trip password");
let (rounds, salt, key) = parse_password(&hash).expect("a hash we wrote must parse");
assert_eq!(rounds, PBKDF2_ROUNDS);
assert_eq!(salt.len(), SALT_BYTES);
assert_eq!(key.len(), KEY_BYTES);
}
}
#[cfg(test)]
mod validation_tests {
use super::*;
#[test]
fn usernames_are_trimmed_and_lowercased() {
assert_eq!(normalize_username(" WorkBench "), "workbench");
assert_eq!(normalize_username("王小明"), "王小明");
}
#[test]
fn username_rules_accept_plain_names_and_reject_addresses() {
for good in ["ana", "ana.lin", "lazy_boy", "王小明", "a12"] {
assert_eq!(username_problem(good), None, "{good}");
}
for bad in [
"an",
".ana",
"ana.",
"ana lin",
"ana@example.com",
"ana\u{1}",
&"x".repeat(MAX_USERNAME_CHARS + 1),
] {
assert!(username_problem(bad).is_some(), "{bad}");
}
}
#[test]
fn passwords_need_a_minimum_length() {
assert!(password_problem("1234567").is_some());
assert_eq!(password_problem("12345678"), None);
assert!(password_problem(&"x".repeat(MAX_PASSWORD_CHARS + 1)).is_some());
assert!(password_problem("pass\nword").is_some());
}
}
#[cfg(test)]
mod guard_tests {
use super::*;
#[test]
fn a_locked_username_stays_locked_until_it_is_cleared() {
let guard = LoginGuard::default();
for _ in 0..MAX_FAILED_LOGINS - 1 {
guard.record_failure("ana");
}
assert!(!guard.locked("ana"), "one attempt is still left");
guard.record_failure("ana");
assert!(guard.locked("ana"));
assert!(
!guard.locked("bob"),
"a cool-down never spills to another account"
);
guard.clear("ana");
assert!(!guard.locked("ana"));
}
}

View File

@ -530,10 +530,7 @@ mod tests {
fn reply_blocks_are_not_counted_as_files() { fn reply_blocks_are_not_counted_as_files() {
let reply = json!({"kind":"reply","name":"阿狗","body":"在這等你"}); let reply = json!({"kind":"reply","name":"阿狗","body":"在這等你"});
let notes = extra_prompt_notes(&[reply], 0); let notes = extra_prompt_notes(&[reply], 0);
assert_eq!( assert_eq!(notes, vec!["The user is replying to 阿狗: 在這等你".to_string()]);
notes,
vec!["The user is replying to 阿狗: 在這等你".to_string()]
);
assert!(extra_prompt_notes(&[json!({"kind":"login","site":"x"})], 0).is_empty()); assert!(extra_prompt_notes(&[json!({"kind":"login","site":"x"})], 0).is_empty());
let mixed = extra_prompt_notes(&[json!({"kind":"reply","name":"小美","body":"hi"})], 2); let mixed = extra_prompt_notes(&[json!({"kind":"reply","name":"小美","body":"hi"})], 2);
assert!(mixed[0].contains("小美")); assert!(mixed[0].contains("小美"));

View File

@ -1,52 +1,114 @@
//! Session plumbing: the login cookie, who it belongs to, and the browser
//! boundary that keeps other sites out.
//!
//! The cookie holds a random value; the database holds only its digest, so a
//! stolen backup of `app_sessions` cannot be replayed as a login. Resolving a
//! cookie yields one [`Actor`] — one person and one workspace — which every
//! handler then uses as its only lens on the data.
use axum::Json;
use axum::extract::{Request, State}; use axum::extract::{Request, State};
use axum::http::{HeaderMap, HeaderValue, StatusCode, header}; use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
use axum::middleware::Next; use axum::middleware::Next;
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use rand::RngCore; use axum::{Json, Router};
use serde::Deserialize;
use serde_json::json; use serde_json::json;
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use std::{
collections::HashMap,
sync::{Arc, Mutex},
time::{Duration, Instant},
};
use crate::accounts::LoginGuard;
use crate::db::Actor;
use crate::state::AppState; use crate::state::AppState;
pub(crate) const COOKIE_NAME: &str = "lazyboy_session"; const COOKIE_NAME: &str = "lazyboy_session";
/// Clears the login cookie on sign-out.
pub(crate) const EXPIRED_COOKIE: &str =
"lazyboy_session=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0";
#[derive(Clone)] #[derive(Clone)]
pub struct AuthConfig { pub struct AuthConfig {
token: Option<String>,
sessions: Arc<Mutex<HashMap<String, Instant>>>,
secure_cookie: bool, secure_cookie: bool,
pub guard: LoginGuard,
} }
impl AuthConfig { impl AuthConfig {
pub fn from_env() -> Self { pub fn from_env() -> Self {
Self { let token = std::env::var("LAZYBOY_APP_TOKEN")
secure_cookie: std::env::var("LAZYBOY_SECURE_COOKIE") .ok()
.filter(|value| !value.trim().is_empty());
let secure_cookie = std::env::var("LAZYBOY_SECURE_COOKIE")
.map(|value| matches!(value.as_str(), "1" | "true" | "yes")) .map(|value| matches!(value.as_str(), "1" | "true" | "yes"))
.unwrap_or(false), .unwrap_or(false);
guard: LoginGuard::default(), Self {
token,
sessions: Arc::new(Mutex::new(HashMap::new())),
secure_cookie,
} }
} }
/// Set `Secure` on the cookie. Needed when the app is served over HTTPS. pub fn enabled(&self) -> bool {
pub fn secure_cookie(&self) -> bool { self.token.is_some()
self.secure_cookie }
pub fn strong_enough_for_network(&self) -> bool {
self.token
.as_ref()
.map(|token| token.len() >= 32 && token != "dev-token")
.unwrap_or(false)
}
fn valid_token(&self, supplied: &str) -> bool {
self.token
.as_ref()
.map(|expected| constant_time_eq(expected.as_bytes(), supplied.as_bytes()))
.unwrap_or(true)
}
fn valid_session(&self, headers: &HeaderMap) -> bool {
if !self.enabled() {
return true;
}
let Some(value) = cookie_value(headers, COOKIE_NAME) else {
return false;
};
let key = hex::encode(Sha256::digest(value.as_bytes()));
self.sessions
.lock()
.unwrap()
.get(&key)
.is_some_and(|expires| *expires > Instant::now())
}
fn session_cookie(&self) -> Option<String> {
if !self.enabled() {
return None;
}
let value = format!(
"{}{}",
uuid::Uuid::new_v4().simple(),
uuid::Uuid::new_v4().simple()
);
let key = hex::encode(Sha256::digest(value.as_bytes()));
let mut sessions = self.sessions.lock().unwrap();
sessions.retain(|_, expires| *expires > Instant::now());
if sessions.len() >= 4096
&& let Some(oldest) = sessions
.iter()
.min_by_key(|(_, t)| **t)
.map(|(k, _)| k.clone())
{
sessions.remove(&oldest);
}
sessions.insert(key, Instant::now() + Duration::from_secs(604800));
Some(format!(
"{COOKIE_NAME}={value}; Path=/; HttpOnly; SameSite=Strict; Max-Age=604800{}",
if self.secure_cookie { "; Secure" } else { "" }
))
}
fn revoke_session(&self, headers: &HeaderMap) {
if let Some(value) = cookie_value(headers, COOKIE_NAME) {
self.sessions
.lock()
.unwrap()
.remove(&hex::encode(Sha256::digest(value.as_bytes())));
}
} }
} }
pub(crate) fn constant_time_eq(left: &[u8], right: &[u8]) -> bool { fn constant_time_eq(left: &[u8], right: &[u8]) -> bool {
if left.len() != right.len() { if left.len() != right.len() {
return false; return false;
} }
@ -57,7 +119,7 @@ pub(crate) fn constant_time_eq(left: &[u8], right: &[u8]) -> bool {
difference == 0 difference == 0
} }
pub(crate) fn cookie_value<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> { fn cookie_value<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
headers headers
.get(header::COOKIE)? .get(header::COOKIE)?
.to_str() .to_str()
@ -67,80 +129,96 @@ pub(crate) fn cookie_value<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a
.find_map(|(key, value)| (key == name).then_some(value)) .find_map(|(key, value)| (key == name).then_some(value))
} }
pub(crate) fn session_token(headers: &HeaderMap) -> Option<String> { #[derive(Deserialize)]
let token = cookie_value(headers, COOKIE_NAME)?; struct LoginInput {
(!token.is_empty()).then(|| token.to_string()) token: String,
} }
/// 32 random bytes, hex encoded. Long enough that the cookie itself is a pub fn public_router(state: AppState) -> Router {
/// 256-bit secret even though only its digest is stored. Router::new()
pub(crate) fn new_session_token() -> String { .route(
let mut bytes = [0u8; 32]; "/api/session",
rand::thread_rng().fill_bytes(&mut bytes); axum::routing::get(session).post(login).delete(logout),
hex::encode(bytes) )
.with_state(state)
} }
pub(crate) fn token_digest(token: &str) -> String { async fn session(State(state): State<AppState>, headers: HeaderMap) -> Json<serde_json::Value> {
hex::encode(Sha256::digest(token.as_bytes())) Json(json!({
"authenticated": state.auth.valid_session(&headers),
"required": state.auth.enabled()
}))
} }
/// Who this request belongs to, or `None` when the cookie is missing, expired, async fn login(
/// or no longer backed by an account. State(state): State<AppState>,
pub(crate) async fn session_actor(state: &AppState, headers: &HeaderMap) -> Option<Actor> { Json(input): Json<LoginInput>,
let token = session_token(headers)?; ) -> Result<Response, (StatusCode, Json<serde_json::Value>)> {
state if !state.auth.valid_token(&input.token) {
.db return Err((
.session_actor(&token_digest(&token)) StatusCode::UNAUTHORIZED,
.await Json(json!({"message": "存取 token 不正確"})),
.ok() ));
.flatten() }
let mut response = Json(json!({"ok": true})).into_response();
if let Some(cookie) = state.auth.session_cookie() {
response.headers_mut().insert(
header::SET_COOKIE,
HeaderValue::from_str(&cookie).map_err(|_| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"message": "無法建立 session"})),
)
})?,
);
}
Ok(response)
} }
/// The name behind a valid cookie, so the browser can show who is signed in. async fn logout(State(state): State<AppState>, headers: HeaderMap) -> Response {
pub(crate) async fn session_username(state: &AppState, headers: &HeaderMap) -> Option<String> { state.auth.revoke_session(&headers);
let token = session_token(headers)?; let mut response = Json(json!({"ok": true})).into_response();
state response.headers_mut().insert(
.db header::SET_COOKIE,
.session_username(&token_digest(&token)) HeaderValue::from_static("lazyboy_session=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0"),
.await );
.ok() response
.flatten()
} }
async fn unauthorized() -> Response { pub async fn require_auth(State(state): State<AppState>, request: Request, next: Next) -> Response {
if state.auth.valid_session(request.headers()) {
next.run(request).await
} else {
( (
StatusCode::UNAUTHORIZED, StatusCode::UNAUTHORIZED,
Json(json!({"message": "請先登入", "code": "unauthenticated"})), Json(json!({"message": "請先登入"})),
) )
.into_response() .into_response()
}
} }
/// Every private route runs behind this: resolve the cookie, publish the actor #[cfg(test)]
/// to the handler, and answer 401 when there is nobody to resolve. mod tests {
pub async fn require_session( use super::constant_time_eq;
State(state): State<AppState>,
mut request: Request, #[test]
next: Next, fn token_comparison_requires_exact_value() {
) -> Response { assert!(constant_time_eq(b"correct", b"correct"));
match session_actor(&state, request.headers()).await { assert!(!constant_time_eq(b"correct", b"wrong"));
Some(actor) => { assert!(!constant_time_eq(b"correct", b"correct-longer"));
request.extensions_mut().insert(actor);
next.run(request).await
}
None => unauthorized().await,
} }
} }
/// SameSite cookies do not replace Origin checks (including WebSockets). /// SameSite cookies do not replace Origin checks (including WebSockets).
pub async fn browser_boundary( pub async fn browser_boundary(
State(_state): State<AppState>, State(state): State<AppState>,
request: Request, request: Request,
next: Next, next: Next,
) -> Response { ) -> Response {
if let Err(rejection) = check_browser_request(request.headers()) { if !allowed_browser_request(request.headers(), state.auth.enabled()) {
return ( return (
StatusCode::FORBIDDEN, StatusCode::FORBIDDEN,
Json(json!({"message": rejection.message()})), Json(json!({"message":"cross-origin request rejected"})),
) )
.into_response(); .into_response();
} }
@ -166,214 +244,90 @@ pub async fn browser_boundary(
response response
} }
/// Why a browser request was turned away. The two cases need different fixes: fn allowed_browser_request(headers: &HeaderMap, authenticated_mode: bool) -> bool {
/// an unlisted host is an operator setting (`LAZYBOY_ALLOWED_HOSTS`), a
/// mismatched origin is another site trying to reach in.
#[derive(Debug, PartialEq, Eq)]
enum BrowserRejection {
InvalidHost,
CrossOrigin,
}
impl BrowserRejection {
fn message(self) -> &'static str {
match self {
Self::InvalidHost => "invalid host",
Self::CrossOrigin => "cross-origin request rejected",
}
}
}
fn check_browser_request(headers: &HeaderMap) -> Result<(), BrowserRejection> {
if headers.get("sec-fetch-site").and_then(|v| v.to_str().ok()) == Some("cross-site") { if headers.get("sec-fetch-site").and_then(|v| v.to_str().ok()) == Some("cross-site") {
return Err(BrowserRejection::CrossOrigin); return false;
} }
let Some(host) = headers.get(header::HOST).and_then(|v| v.to_str().ok()) else { let Some(host) = headers.get(header::HOST).and_then(|v| v.to_str().ok()) else {
return Err(BrowserRejection::InvalidHost); return false;
}; };
if !served_at_an_allowed_host(host, &listed_hosts()) {
return Err(BrowserRejection::InvalidHost);
}
if let Some(origin) = headers.get(header::ORIGIN) {
let Some(origin) = origin
.to_str()
.ok()
.and_then(|s| reqwest::Url::parse(s).ok())
else {
return Err(BrowserRejection::CrossOrigin);
};
let Ok(expected) = reqwest::Url::parse(&format!("{}://{host}", origin.scheme())) else {
return Err(BrowserRejection::CrossOrigin);
};
if !matches!(origin.scheme(), "http" | "https") || origin.origin() != expected.origin() {
return Err(BrowserRejection::CrossOrigin);
}
}
Ok(())
}
#[cfg(test)]
fn allowed_browser_request(headers: &HeaderMap) -> bool {
check_browser_request(headers).is_ok()
}
fn listed_hosts_from(value: &str) -> Vec<String> {
value
.split(',')
.map(str::trim)
.filter(|host| !host.is_empty())
.map(|host| host.to_ascii_lowercase())
.collect()
}
/// Which hostnames this install may be addressed by.
///
/// Loopback and plain IP addresses are always fine — that is how a self-hosted
/// LazyBoy is normally opened, from this machine or from another one on the
/// same network. A *domain name* is only believed when the operator listed it,
/// because a domain an attacker controls can be pointed at this machine (DNS
/// rebinding) and would otherwise read a signed-in session.
fn served_at_an_allowed_host(host: &str, listed: &[String]) -> bool {
let Ok(destination) = reqwest::Url::parse(&format!("http://{host}")) else { let Ok(destination) = reqwest::Url::parse(&format!("http://{host}")) else {
return false; return false;
}; };
let Some(hostname) = destination.host_str() else { if !authenticated_mode
&& !matches!(
destination.host_str(),
Some("localhost" | "127.0.0.1" | "[::1]")
)
{
return false;
}
if let Some(origin) = headers.get(header::ORIGIN) {
let Ok(origin) = origin
.to_str()
.ok()
.and_then(|s| reqwest::Url::parse(s).ok())
.ok_or(())
else {
return false; return false;
}; };
// `Url` keeps the brackets around an IPv6 host; `IpAddr` only reads them off. let Ok(expected) = reqwest::Url::parse(&format!("{}://{host}", origin.scheme())) else {
let address = hostname.trim_matches(|ch| ch == '[' || ch == ']'); return false;
if hostname == "localhost" || address.parse::<std::net::IpAddr>().is_ok() { };
return true; if !matches!(origin.scheme(), "http" | "https") || origin.origin() != expected.origin() {
return false;
} }
listed
.iter()
.any(|allowed| allowed.eq_ignore_ascii_case(hostname))
}
/// `LAZYBOY_ALLOWED_HOSTS=lazyboy.local, box.example`
fn listed_hosts() -> Vec<String> {
std::env::var("LAZYBOY_ALLOWED_HOSTS")
.ok()
.map(|hosts| listed_hosts_from(&hosts))
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::constant_time_eq;
#[test]
fn token_comparison_requires_exact_value() {
assert!(constant_time_eq(b"correct", b"correct"));
assert!(!constant_time_eq(b"correct", b"wrong"));
assert!(!constant_time_eq(b"correct", b"correct-longer"));
}
}
#[cfg(test)]
mod session_cookie_tests {
use super::*;
#[test]
fn session_tokens_are_unique_and_only_the_digest_is_stored() {
let first = new_session_token();
let second = new_session_token();
assert_ne!(first, second);
assert_eq!(first.len(), 64);
assert_ne!(token_digest(&first), first);
assert_eq!(token_digest(&first), token_digest(&first));
}
#[test]
fn the_login_cookie_is_read_by_name_only() {
let mut headers = HeaderMap::new();
headers.insert(
header::COOKIE,
format!("other=1; {COOKIE_NAME}=abc123; third=2")
.parse()
.unwrap(),
);
assert_eq!(session_token(&headers).as_deref(), Some("abc123"));
headers.insert(header::COOKIE, "other=1".parse().unwrap());
assert_eq!(session_token(&headers), None);
} }
true
} }
#[cfg(test)] #[cfg(test)]
mod origin_tests { mod origin_tests {
use super::*; use super::*;
#[test] #[test]
fn blocks_cross_origin_requests() { fn blocks_cross_origin_and_dns_rebinding() {
let mut headers = HeaderMap::new(); let mut headers = HeaderMap::new();
headers.insert(header::HOST, "localhost:3101".parse().unwrap()); headers.insert(header::HOST, "localhost:3101".parse().unwrap());
assert!(allowed_browser_request(&headers)); assert!(allowed_browser_request(&headers, false));
headers.insert(header::ORIGIN, "https://evil.example".parse().unwrap()); headers.insert(header::ORIGIN, "https://evil.example".parse().unwrap());
assert!(!allowed_browser_request(&headers)); assert!(!allowed_browser_request(&headers, true));
headers.insert(header::ORIGIN, "http://localhost:3101".parse().unwrap()); headers.insert(header::ORIGIN, "http://localhost:3101".parse().unwrap());
assert!(allowed_browser_request(&headers)); assert!(allowed_browser_request(&headers, true));
headers.insert("sec-fetch-site", "cross-site".parse().unwrap()); headers.remove(header::ORIGIN);
assert!(!allowed_browser_request(&headers));
}
#[test]
fn unlisted_host_and_foreign_origin_are_told_apart() {
let mut headers = HeaderMap::new();
headers.insert(header::HOST, "rebinding.example".parse().unwrap()); headers.insert(header::HOST, "rebinding.example".parse().unwrap());
assert_eq!( assert!(!allowed_browser_request(&headers, false));
check_browser_request(&headers),
Err(BrowserRejection::InvalidHost)
);
headers.insert(header::HOST, "localhost:3101".parse().unwrap());
headers.insert(header::ORIGIN, "https://evil.example".parse().unwrap());
assert_eq!(
check_browser_request(&headers),
Err(BrowserRejection::CrossOrigin)
);
assert_eq!(BrowserRejection::InvalidHost.message(), "invalid host");
assert_eq!(
BrowserRejection::CrossOrigin.message(),
"cross-origin request rejected"
);
}
#[test]
fn an_unlisted_domain_cannot_rebind_to_this_machine() {
let mut headers = HeaderMap::new();
headers.insert(header::HOST, "rebinding.example".parse().unwrap());
assert!(!allowed_browser_request(&headers));
// A matching Origin is not enough either: the host itself must be known.
headers.insert(header::ORIGIN, "http://rebinding.example".parse().unwrap());
assert!(!allowed_browser_request(&headers));
}
#[test]
fn loopback_and_ip_hosts_are_served_from_any_machine_on_the_network() {
for host in [
"127.0.0.1:3101",
"[::1]:3101",
"192.168.1.20:3101",
"localhost:3101",
] {
let mut headers = HeaderMap::new();
headers.insert(header::HOST, host.parse().unwrap());
assert!(allowed_browser_request(&headers), "{host}");
}
} }
} }
#[cfg(test)] #[cfg(test)]
mod allowed_host_tests { mod session_tests {
use super::*; use super::*;
#[test] #[test]
fn a_domain_the_operator_listed_is_served() { fn sessions_are_distinct_expire_and_are_revoked_on_logout() {
let listed = listed_hosts_from("lazyboy.local, box.example"); let config = AuthConfig {
assert!(served_at_an_allowed_host("box.example:3101", &listed)); token: Some("secret".into()),
assert!(served_at_an_allowed_host("BOX.example", &listed)); sessions: Arc::new(Mutex::new(HashMap::new())),
assert!(!served_at_an_allowed_host("other.example", &listed)); secure_cookie: true,
assert!(served_at_an_allowed_host("127.0.0.1:3101", &[])); };
assert!(served_at_an_allowed_host("[::1]:3101", &[])); let first = config.session_cookie().unwrap();
assert!(!served_at_an_allowed_host("not a host", &listed)); let second = config.session_cookie().unwrap();
assert_ne!(first, second);
let mut headers = HeaderMap::new();
headers.insert(
header::COOKIE,
first.split(';').next().unwrap().parse().unwrap(),
);
assert!(config.valid_session(&headers));
config.revoke_session(&headers);
assert!(!config.valid_session(&headers));
headers.insert(
header::COOKIE,
second.split(';').next().unwrap().parse().unwrap(),
);
assert!(config.valid_session(&headers));
for expiry in config.sessions.lock().unwrap().values_mut() {
*expiry = Instant::now() - Duration::from_secs(1);
}
assert!(!config.valid_session(&headers));
} }
} }

View File

@ -1,14 +1,8 @@
use axum::Json;
use axum::extract::FromRequestParts;
use axum::http::StatusCode;
use axum::http::request::Parts;
use axum::response::{IntoResponse, Response};
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use lazyboy_contracts::{ use lazyboy_contracts::{
Bot, BrowserProfileMode, ComputerMode, ComputerState, ControlHolder, RunStatus, SandboxKind, Bot, BrowserProfileMode, ComputerMode, ComputerState, ControlHolder, RunStatus, SandboxKind,
computer_home_key, computer_scope_key, computer_home_key, computer_scope_key,
}; };
use serde_json::json;
use sqlx::{FromRow, PgPool}; use sqlx::{FromRow, PgPool};
use uuid::Uuid; use uuid::Uuid;
@ -17,41 +11,12 @@ pub struct Db {
pub pool: PgPool, pub pool: PgPool,
} }
/// Who a request is acting as: one person and the workspace they own. Every
/// query for an agent, chat, run, or desktop is filtered by both, so two
/// people registered on the same server can never see — or collide with —
/// each other's work.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct Actor { pub struct Actor {
pub user_id: String, pub user_id: String,
pub space_id: String, pub space_id: String,
} }
/// Handlers ask for `actor: Actor` and axum fills it from the session the auth
/// middleware resolved, so no handler can accidentally run as somebody else.
impl<S: Sync> FromRequestParts<S> for Actor {
type Rejection = Response;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
parts.extensions.get::<Actor>().cloned().ok_or_else(|| {
(
StatusCode::UNAUTHORIZED,
Json(json!({"message": "請先登入", "code": "unauthenticated"})),
)
.into_response()
})
}
}
/// A registered person. `password_hash` is `None` for a legacy account that
/// predates logins and still has to be claimed.
#[derive(Debug, Clone)]
pub struct Account {
pub user_id: String,
pub username: String,
pub password_hash: Option<String>,
}
#[derive(Debug, Clone, FromRow)] #[derive(Debug, Clone, FromRow)]
#[allow(dead_code)] #[allow(dead_code)]
pub struct ComputerRow { pub struct ComputerRow {
@ -138,198 +103,28 @@ pub struct SpaceRow {
} }
impl Db { impl Db {
/// Register a person. pub async fn ensure_local_actor(&self) -> Result<Actor, sqlx::Error> {
/// let user_id = "local-user";
/// The first account on an upgraded install claims the workspace that let space_id = "local-space";
/// already exists (it belonged to the shared-token install), so nobody sqlx::query("INSERT INTO users (id, name) VALUES ($1, $2) ON CONFLICT (id) DO NOTHING")
/// loses their agents, chats, or desktops; from the second account on, each
/// person starts in a workspace of their own.
pub async fn create_account(
&self,
username: &str,
password_hash: &str,
) -> Result<String, sqlx::Error> {
let mut tx = self.pool.begin().await?;
let claimable: Option<(String,)> = sqlx::query_as(
"SELECT id FROM users
WHERE password_hash IS NULL
AND NOT EXISTS (SELECT 1 FROM users WHERE password_hash IS NOT NULL)
ORDER BY created_at, id
LIMIT 1
FOR UPDATE",
)
.fetch_optional(&mut *tx)
.await?;
let user_id = match claimable {
Some((user_id,)) => {
// Guarded so two simultaneous first registrations cannot both
// take the same legacy row: the loser finds no row to change.
let updated = sqlx::query(
"UPDATE users
SET username = $2, password_hash = $3
WHERE id = $1
AND password_hash IS NULL
AND NOT EXISTS (SELECT 1 FROM users WHERE password_hash IS NOT NULL)",
)
.bind(&user_id)
.bind(username)
.bind(password_hash)
.execute(&mut *tx)
.await?;
if updated.rows_affected() != 1 {
return Err(sqlx::Error::RowNotFound);
}
user_id
}
None => {
let user_id = Uuid::new_v4().simple().to_string();
sqlx::query(
"INSERT INTO users (id, name, username, password_hash) VALUES ($1, $2, $2, $3)",
)
.bind(&user_id)
.bind(username)
.bind(password_hash)
.execute(&mut *tx)
.await?;
user_id
}
};
tx.commit().await?;
Ok(user_id)
}
pub async fn account_by_username(
&self,
username: &str,
) -> Result<Option<Account>, sqlx::Error> {
let found: Option<(String, String, Option<String>)> = sqlx::query_as(
"SELECT id, username, password_hash FROM users WHERE lower(username) = $1",
)
.bind(username)
.fetch_optional(&self.pool)
.await?;
Ok(found.map(|(user_id, username, password_hash)| Account {
user_id,
username,
password_hash,
}))
}
/// The workspace a person owns. An account without one is treated as
/// signed out rather than handed somebody else's.
pub async fn default_space(&self, user_id: &str) -> Result<Option<String>, sqlx::Error> {
let space: Option<(String,)> = sqlx::query_as(
"SELECT id FROM spaces WHERE user_id = $1 ORDER BY is_default DESC, created_at LIMIT 1",
)
.bind(user_id) .bind(user_id)
.fetch_optional(&self.pool) .bind("Local")
.execute(&self.pool)
.await?; .await?;
Ok(space.map(|(id,)| id))
}
/// Create the workspace on first use, then return it. Provider and model
/// defaults are empty of credentials on purpose: a key belongs in the
/// workspace settings, and without one nothing runs.
pub async fn ensure_default_space(&self, user_id: &str) -> Result<String, sqlx::Error> {
if let Some(space) = self.default_space(user_id).await? {
return Ok(space);
}
let space_id = format!("space-{user_id}");
sqlx::query( sqlx::query(
"INSERT INTO spaces (id, user_id, name, is_default, default_model_provider, default_model_id) "INSERT INTO spaces (id, user_id, name, is_default, default_model_provider, default_model_id)
VALUES ($1, $2, $3, TRUE, 'xai', 'grok-4.6') VALUES ($1, $2, $3, TRUE, 'xai', 'grok-4.6')
ON CONFLICT (id) DO NOTHING", ON CONFLICT (id) DO NOTHING",
) )
.bind(&space_id) .bind(space_id)
.bind(user_id) .bind(user_id)
.bind("My workspace") .bind("Home")
.execute(&self.pool) .execute(&self.pool)
.await?; .await?;
Ok(self.default_space(user_id).await?.unwrap_or(space_id)) Ok(Actor {
} user_id: user_id.into(),
space_id: space_id.into(),
/// Every account that can sign in, for background work that must run per })
/// person instead of once for the whole server.
pub async fn actors(&self) -> Result<Vec<Actor>, sqlx::Error> {
let rows: Vec<(String, Option<String>)> = sqlx::query_as(
"SELECT u.id,
(SELECT s.id FROM spaces s
WHERE s.user_id = u.id
ORDER BY s.is_default DESC, s.created_at LIMIT 1) AS space_id
FROM users u
WHERE u.password_hash IS NOT NULL",
)
.fetch_all(&self.pool)
.await?;
let mut actors = Vec::new();
for (user_id, space_id) in rows {
// An account with no workspace cannot act on anything, so it is
// skipped rather than given a guessed space.
if let Some(space_id) = space_id {
actors.push(Actor { user_id, space_id });
}
}
Ok(actors)
}
pub async fn create_session(
&self,
user_id: &str,
token_hash: &str,
ttl_seconds: i64,
) -> Result<(), sqlx::Error> {
let mut tx = self.pool.begin().await?;
sqlx::query("DELETE FROM app_sessions WHERE expires_at <= now()")
.execute(&mut *tx)
.await?;
sqlx::query(
"INSERT INTO app_sessions (token_hash, user_id, expires_at)
VALUES ($1, $2, now() + make_interval(secs => $3::double precision))",
)
.bind(token_hash)
.bind(user_id)
.bind(ttl_seconds as f64)
.execute(&mut *tx)
.await?;
tx.commit().await
}
pub async fn session_actor(&self, token_hash: &str) -> Result<Option<Actor>, sqlx::Error> {
let row: Option<(String, Option<String>)> = sqlx::query_as(
"SELECT s.user_id,
(SELECT x.id FROM spaces x
WHERE x.user_id = s.user_id
ORDER BY x.is_default DESC, x.created_at LIMIT 1) AS space_id
FROM app_sessions s
WHERE s.token_hash = $1 AND s.expires_at > now()",
)
.bind(token_hash)
.fetch_optional(&self.pool)
.await?;
let Some((user_id, Some(space_id))) = row else {
return Ok(None);
};
Ok(Some(Actor { user_id, space_id }))
}
pub async fn session_username(&self, token_hash: &str) -> Result<Option<String>, sqlx::Error> {
let row: Option<(Option<String>,)> = sqlx::query_as(
"SELECT u.username FROM users u
JOIN app_sessions s ON s.user_id = u.id
WHERE s.token_hash = $1 AND s.expires_at > now()",
)
.bind(token_hash)
.fetch_optional(&self.pool)
.await?;
Ok(row.and_then(|(username,)| username))
}
pub async fn delete_session(&self, token_hash: &str) -> Result<(), sqlx::Error> {
sqlx::query("DELETE FROM app_sessions WHERE token_hash = $1")
.bind(token_hash)
.execute(&self.pool)
.await?;
Ok(())
} }
pub async fn get_space(&self, actor: &Actor) -> Result<Option<SpaceRow>, sqlx::Error> { pub async fn get_space(&self, actor: &Actor) -> Result<Option<SpaceRow>, sqlx::Error> {
@ -710,74 +505,3 @@ pub fn parse_run_status(status: &str) -> Option<RunStatus> {
_ => None, _ => None,
} }
} }
#[cfg(test)]
mod tests {
use super::{Actor, Db};
/// One install, many people: an account may only reach the workspace it owns.
/// This replaced a shared token that opened everything, so scoping is the
/// whole security model here and it is worth a database test.
#[sqlx::test(migrations = "../../migrations")]
async fn each_account_only_reaches_its_own_workspace(pool: sqlx::PgPool) {
// An install from before accounts existed: one row that cannot sign in,
// owning every agent.
sqlx::query("INSERT INTO users (id, name) VALUES ('local-user', 'Local')")
.execute(&pool)
.await
.unwrap();
let db = Db { pool: pool.clone() };
let alice = db.create_account("alice", "hash-a").await.unwrap();
assert_eq!(alice, "local-user", "the first account adopts the old data");
let alice_space = db.ensure_default_space(&alice).await.unwrap();
let bob = db.create_account("bob", "hash-b").await.unwrap();
let bob_space = db.ensure_default_space(&bob).await.unwrap();
assert_ne!(alice_space, bob_space, "nobody shares a workspace");
// A username is an address: one spelling, so `ALICE` is not a second door.
assert!(
db.create_account("ALICE", "hash-c").await.is_err(),
"a differently cased name must not open the same account"
);
sqlx::query("INSERT INTO bots (id, space_id, user_id, name) VALUES ('bot-a', $1, $2, 'A')")
.bind(&alice_space)
.bind(&alice)
.execute(&pool)
.await
.unwrap();
let alice_actor = Actor {
user_id: alice.clone(),
space_id: alice_space,
};
let bob_actor = Actor {
user_id: bob.clone(),
space_id: bob_space,
};
assert!(db.get_bot(&alice_actor, "bot-a").await.unwrap().is_some());
assert!(
db.get_bot(&bob_actor, "bot-a").await.unwrap().is_none(),
"another account's agent is not even visible by id"
);
// Background work iterates people, so each actor must carry only their
// own space.
let mut actors = db.actors().await.unwrap();
let scoped: Vec<(String, String)> = actors
.drain(..)
.map(|actor| (actor.user_id, actor.space_id))
.collect();
assert_eq!(
scoped.len(),
2,
"the account that still cannot sign in is left out"
);
assert!(
scoped.contains(&(alice, alice_actor.space_id)),
"each account is handed exactly its own workspace"
);
assert!(scoped.contains(&(bob, bob_actor.space_id)));
}
}

View File

@ -1,4 +1,3 @@
mod accounts;
mod attachments; mod attachments;
mod auth; mod auth;
mod computer; mod computer;
@ -41,21 +40,21 @@ async fn main() {
tracing_subscriber::fmt() tracing_subscriber::fmt()
.with_env_filter(EnvFilter::from_default_env().add_directive("info".parse().unwrap())) .with_env_filter(EnvFilter::from_default_env().add_directive("info".parse().unwrap()))
.init(); .init();
if std::env::var("XAI_API_KEY")
.ok()
.filter(|value| !value.is_empty())
.is_none()
{
tracing::warn!("XAI_API_KEY is not set; chat will fail until it is");
}
let database_url = std::env::var("DATABASE_URL") let database_url = std::env::var("DATABASE_URL")
.unwrap_or_else(|_| "postgres://lazyboy:lazyboy@127.0.0.1:5434/lazyboy".into()); .unwrap_or_else(|_| "postgres://lazyboy:lazyboy@127.0.0.1:5434/lazyboy".into());
let state = AppState::connect(&database_url).await.expect("database"); let state = AppState::connect(&database_url).await.expect("database");
let accounts = state let actor = state.bootstrap().await.expect("bootstrap");
.db
.actors()
.await
.unwrap_or_else(|error| panic!("accounts: {error}"));
if accounts.is_empty() {
tracing::warn!("nobody can sign in yet — open the app and register the first account");
}
let mcp_state = state.clone(); let mcp_state = state.clone();
tokio::spawn(async move { tokio::spawn(async move {
mcp_state.mcp.reconnect_everyone(&mcp_state).await; mcp_state.mcp.reconnect_all(mcp_state.pool(), &actor).await;
}); });
let retention_state = state.clone(); let retention_state = state.clone();
@ -78,12 +77,9 @@ async fn main() {
let bind = std::env::var("API_BIND").unwrap_or_else(|_| "127.0.0.1:3101".into()); let bind = std::env::var("API_BIND").unwrap_or_else(|_| "127.0.0.1:3101".into());
let addr: SocketAddr = bind.parse().expect("API_BIND"); let addr: SocketAddr = bind.parse().expect("API_BIND");
if !addr.ip().is_loopback() { if !addr.ip().is_loopback() && !state.auth.strong_enough_for_network() {
// Serving outside this machine is normal — that is how a second person panic!(
// signs in — but they must reach us by an IP or a listed hostname, and "LAZYBOY_APP_TOKEN must be set to at least 32 characters when API_BIND is not loopback"
// every page behind login.
tracing::info!(
"listening on {addr}; sign-in is required, and a hostname other than an IP needs LAZYBOY_ALLOWED_HOSTS"
); );
} }
@ -93,7 +89,7 @@ async fn main() {
"/api/health", "/api/health",
axum::routing::get(|| async { axum::Json(serde_json::json!({"ok": true})) }), axum::routing::get(|| async { axum::Json(serde_json::json!({"ok": true})) }),
) )
.merge(accounts::router(state.clone())) .merge(auth::public_router(state.clone()))
.merge(routes::router(state.clone())) .merge(routes::router(state.clone()))
.layer(DefaultBodyLimit::max(28 * 1024 * 1024)) .layer(DefaultBodyLimit::max(28 * 1024 * 1024))
.layer(axum::middleware::from_fn_with_state( .layer(axum::middleware::from_fn_with_state(

View File

@ -187,21 +187,6 @@ impl McpHub {
Ok(tools) Ok(tools)
} }
/// Reconnect every account's own servers at startup: MCP connections
/// belong to the person who added them, not to the process.
pub async fn reconnect_everyone(&self, state: &AppState) {
let actors = match state.db.actors().await {
Ok(actors) => actors,
Err(error) => {
tracing::warn!("account lookup failed: {error}");
return;
}
};
for actor in actors {
self.reconnect_all(state.pool(), &actor).await;
}
}
pub async fn reconnect_all(&self, pool: &sqlx::PgPool, actor: &Actor) { pub async fn reconnect_all(&self, pool: &sqlx::PgPool, actor: &Actor) {
let rows = match load_rows(pool, actor).await { let rows = match load_rows(pool, actor).await {
Ok(rows) => rows, Ok(rows) => rows,
@ -503,6 +488,13 @@ async fn rollback_server(state: &AppState, id: &str) {
.await; .await;
} }
async fn actor(state: &AppState) -> Result<Actor, ApiError> {
state
.bootstrap()
.await
.map_err(|error| internal(error.to_string()))
}
async fn present(state: &AppState, _actor: &Actor, mut row: McpRow) -> McpServer { async fn present(state: &AppState, _actor: &Actor, mut row: McpRow) -> McpServer {
let live = state.mcp.inner.lock().await; let live = state.mcp.inner.lock().await;
if !row.enabled { if !row.enabled {
@ -515,10 +507,8 @@ async fn present(state: &AppState, _actor: &Actor, mut row: McpRow) -> McpServer
row.into_server("disconnected".into(), error, Vec::new()) row.into_server("disconnected".into(), error, Vec::new())
} }
async fn list_servers( async fn list_servers(State(state): State<AppState>) -> Result<Json<Vec<McpServer>>, ApiError> {
actor: Actor, let actor = actor(&state).await?;
State(state): State<AppState>,
) -> Result<Json<Vec<McpServer>>, ApiError> {
let rows = load_rows(state.pool(), &actor) let rows = load_rows(state.pool(), &actor)
.await .await
.map_err(|error| internal(error.to_string()))?; .map_err(|error| internal(error.to_string()))?;
@ -548,11 +538,10 @@ fn validate_input(input: &UpsertMcpServerInput) -> Result<(), ApiError> {
async fn create_server( async fn create_server(
State(state): State<AppState>, State(state): State<AppState>,
actor: Actor,
Json(input): Json<UpsertMcpServerInput>, Json(input): Json<UpsertMcpServerInput>,
) -> Result<(StatusCode, Json<McpServer>), ApiError> { ) -> Result<(StatusCode, Json<McpServer>), ApiError> {
validate_input(&input)?; validate_input(&input)?;
let actor = actor(&state).await?;
let id = Uuid::new_v4().to_string(); let id = Uuid::new_v4().to_string();
let name = input.name.trim().chars().take(80).collect::<String>(); let name = input.name.trim().chars().take(80).collect::<String>();
sqlx::query( sqlx::query(
@ -616,9 +605,9 @@ async fn create_server(
async fn get_server( async fn get_server(
State(state): State<AppState>, State(state): State<AppState>,
actor: Actor,
Path(id): Path<String>, Path(id): Path<String>,
) -> Result<Json<McpServer>, ApiError> { ) -> Result<Json<McpServer>, ApiError> {
let actor = actor(&state).await?;
let row = load_row(state.pool(), &actor, &id) let row = load_row(state.pool(), &actor, &id)
.await .await
.map_err(|error| internal(error.to_string()))? .map_err(|error| internal(error.to_string()))?
@ -628,10 +617,10 @@ async fn get_server(
async fn update_server( async fn update_server(
State(state): State<AppState>, State(state): State<AppState>,
actor: Actor,
Path(id): Path<String>, Path(id): Path<String>,
Json(input): Json<PatchMcpServerInput>, Json(input): Json<PatchMcpServerInput>,
) -> Result<Json<McpServer>, ApiError> { ) -> Result<Json<McpServer>, ApiError> {
let actor = actor(&state).await?;
let current = load_row(state.pool(), &actor, &id) let current = load_row(state.pool(), &actor, &id)
.await .await
.map_err(|error| internal(error.to_string()))? .map_err(|error| internal(error.to_string()))?
@ -725,9 +714,9 @@ async fn update_server(
async fn delete_server( async fn delete_server(
State(state): State<AppState>, State(state): State<AppState>,
actor: Actor,
Path(id): Path<String>, Path(id): Path<String>,
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
let actor = actor(&state).await?;
state.mcp.forget(&id).await; state.mcp.forget(&id).await;
let deleted = sqlx::query("DELETE FROM mcp_servers WHERE id=$1 AND space_id=$2 AND user_id=$3") let deleted = sqlx::query("DELETE FROM mcp_servers WHERE id=$1 AND space_id=$2 AND user_id=$3")
.bind(&id) .bind(&id)
@ -744,9 +733,9 @@ async fn delete_server(
async fn reconnect_server( async fn reconnect_server(
State(state): State<AppState>, State(state): State<AppState>,
actor: Actor,
Path(id): Path<String>, Path(id): Path<String>,
) -> Result<Json<McpServer>, ApiError> { ) -> Result<Json<McpServer>, ApiError> {
let actor = actor(&state).await?;
let mut row = load_row(state.pool(), &actor, &id) let mut row = load_row(state.pool(), &actor, &id)
.await .await
.map_err(|error| internal(error.to_string()))? .map_err(|error| internal(error.to_string()))?

View File

@ -718,29 +718,25 @@ pub fn router() -> Router<AppState> {
) )
} }
/// An id in a URL is not permission. Anything addressed by `bot_id` checks async fn scoped_actor(state: &AppState, bot_id: &str) -> Result<Actor, StatusCode> {
/// that the agent lives in the caller's own workspace first, so guessing an id let actor = state
/// cannot reach another person's memory, vault, or schedule. .bootstrap()
async fn assert_bot_is_mine( .await
state: &AppState, .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
actor: &Actor,
bot_id: &str,
) -> Result<(), StatusCode> {
state state
.db .db
.get_bot(actor, bot_id) .get_bot(&actor, bot_id)
.await .await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::NOT_FOUND)?; .ok_or(StatusCode::NOT_FOUND)?;
Ok(()) Ok(actor)
} }
async fn memory_status( async fn memory_status(
State(state): State<AppState>, State(state): State<AppState>,
actor: Actor,
Path(bot_id): Path<String>, Path(bot_id): Path<String>,
) -> Result<Json<Value>, StatusCode> { ) -> Result<Json<Value>, StatusCode> {
assert_bot_is_mine(&state, &actor, &bot_id).await?; let actor = scoped_actor(&state, &bot_id).await?;
let (stored, indexed): (i64, i64) = sqlx::query_as( let (stored, indexed): (i64, i64) = sqlx::query_as(
"SELECT count(*),count(*) FILTER (WHERE embedding IS NOT NULL AND embedding_model=$4) FROM memory_items "SELECT count(*),count(*) FILTER (WHERE embedding IS NOT NULL AND embedding_model=$4) FROM memory_items
WHERE space_id=$1 AND user_id=$2 AND bot_id=$3 AND deleted_at IS NULL", WHERE space_id=$1 AND user_id=$2 AND bot_id=$3 AND deleted_at IS NULL",
@ -761,10 +757,9 @@ async fn memory_status(
async fn list_memories( async fn list_memories(
State(state): State<AppState>, State(state): State<AppState>,
actor: Actor,
Path(bot_id): Path<String>, Path(bot_id): Path<String>,
) -> Result<Json<Vec<MemoryItem>>, StatusCode> { ) -> Result<Json<Vec<MemoryItem>>, StatusCode> {
assert_bot_is_mine(&state, &actor, &bot_id).await?; let actor = scoped_actor(&state, &bot_id).await?;
state state
.memory .memory
.list(state.pool(), &actor, &bot_id) .list(state.pool(), &actor, &bot_id)
@ -775,13 +770,10 @@ async fn list_memories(
async fn create_memory( async fn create_memory(
State(state): State<AppState>, State(state): State<AppState>,
actor: Actor,
Path(bot_id): Path<String>, Path(bot_id): Path<String>,
Json(input): Json<CreateMemoryInput>, Json(input): Json<CreateMemoryInput>,
) -> Result<Json<MemoryItem>, (StatusCode, Json<Value>)> { ) -> Result<Json<MemoryItem>, (StatusCode, Json<Value>)> {
assert_bot_is_mine(&state, &actor, &bot_id) let actor = scoped_actor(&state, &bot_id).await.map_err(api_status)?;
.await
.map_err(api_status)?;
state state
.memory .memory
.remember(state.pool(), &actor, &bot_id, input) .remember(state.pool(), &actor, &bot_id, input)
@ -792,13 +784,10 @@ async fn create_memory(
async fn update_memory( async fn update_memory(
State(state): State<AppState>, State(state): State<AppState>,
actor: Actor,
Path((bot_id, memory_id)): Path<(String, Uuid)>, Path((bot_id, memory_id)): Path<(String, Uuid)>,
Json(input): Json<UpdateMemoryInput>, Json(input): Json<UpdateMemoryInput>,
) -> Result<Json<MemoryItem>, (StatusCode, Json<Value>)> { ) -> Result<Json<MemoryItem>, (StatusCode, Json<Value>)> {
assert_bot_is_mine(&state, &actor, &bot_id) let actor = scoped_actor(&state, &bot_id).await.map_err(api_status)?;
.await
.map_err(api_status)?;
state state
.memory .memory
.update(state.pool(), &actor, &bot_id, memory_id, input) .update(state.pool(), &actor, &bot_id, memory_id, input)
@ -810,12 +799,9 @@ async fn update_memory(
async fn delete_memory( async fn delete_memory(
State(state): State<AppState>, State(state): State<AppState>,
actor: Actor,
Path((bot_id, memory_id)): Path<(String, Uuid)>, Path((bot_id, memory_id)): Path<(String, Uuid)>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> { ) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
assert_bot_is_mine(&state, &actor, &bot_id) let actor = scoped_actor(&state, &bot_id).await.map_err(api_status)?;
.await
.map_err(api_status)?;
if !state if !state
.memory .memory
.forget(state.pool(), &actor, &bot_id, memory_id) .forget(state.pool(), &actor, &bot_id, memory_id)
@ -829,12 +815,9 @@ async fn delete_memory(
async fn clear_memories( async fn clear_memories(
State(state): State<AppState>, State(state): State<AppState>,
actor: Actor,
Path(bot_id): Path<String>, Path(bot_id): Path<String>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> { ) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
assert_bot_is_mine(&state, &actor, &bot_id) let actor = scoped_actor(&state, &bot_id).await.map_err(api_status)?;
.await
.map_err(api_status)?;
let deleted = state let deleted = state
.memory .memory
.clear(state.pool(), &actor, &bot_id) .clear(state.pool(), &actor, &bot_id)

View File

@ -29,9 +29,9 @@ pub fn router() -> Router<AppState> {
/// and revisions removed by retention remain unavailable, even in old runs. /// and revisions removed by retention remain unavailable, even in old runs.
async fn memory_usage( async fn memory_usage(
State(state): State<AppState>, State(state): State<AppState>,
actor: Actor,
Path((run_id, activity_id)): Path<(String, i64)>, Path((run_id, activity_id)): Path<(String, i64)>,
) -> Result<Json<Value>, ApiError> { ) -> Result<Json<Value>, ApiError> {
let actor = state.bootstrap().await.map_err(internal)?;
memory_usage_items(state.pool(), &actor, &run_id, activity_id).await memory_usage_items(state.pool(), &actor, &run_id, activity_id).await
} }
@ -191,30 +191,6 @@ pub fn classify_run_error(error: &str) -> RunFailure {
false, false,
); );
} }
// Nothing was ever configured. This is not "the key is wrong": the fix is
// pasting a key for the first time, and saying so beats retrying forever.
if has(&["missing credential", "no api key", "api key is not set"]) {
return RunFailure::new(
"model_key_missing",
"你還沒給這個工作區 API 金鑰,所以我無法開始。",
"到「設定 → 模型」貼上金鑰,存好後按重試。",
false,
true,
false,
);
}
// OpenCode Go answers 403 for models that need a data-sharing opt-in on
// opencode.ai. The key is fine; re-pasting it would change nothing.
if has(&["datapolicyerror", "requires explicit opt in"]) {
return RunFailure::new(
"model_opt_in",
"OpenCode 說這個模型要先在 opencode.ai 同意資料使用政策才能用。",
"到 opencode.ai 工作區的 Go 頁面開啟這個模型,或到「設定 → 模型」換一個模型,再按重試。",
true,
true,
false,
);
}
if has(&[ if has(&[
"invalid api key", "invalid api key",
"incorrect api key", "incorrect api key",
@ -313,9 +289,7 @@ pub fn classify_run_error(error: &str) -> RunFailure {
true, true,
); );
} }
// "run lease was lost" / "run lease lost": the bare substring would also if text.contains("lease") {
// match "please" in a provider's error text.
if has(&["run lease", "lease was lost", "lease lost", "lease expired"]) {
return RunFailure::new( return RunFailure::new(
"lease_lost", "lease_lost",
"這份工作被另一邊接手過,我手上這份已經失效。", "這份工作被另一邊接手過,我手上這份已經失效。",
@ -368,15 +342,24 @@ struct RunRow {
completed_at: Option<DateTime<Utc>>, completed_at: Option<DateTime<Utc>>,
} }
async fn actor(state: &AppState) -> Result<Actor, ApiError> {
state.bootstrap().await.map_err(|error| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"message": error.to_string()})),
)
})
}
/// The bubble's whole payload: where the run stands right now plus its trail. /// The bubble's whole payload: where the run stands right now plus its trail.
/// `after` pages forward; the first read returns the newest window, oldest /// `after` pages forward; the first read returns the newest window, oldest
/// first, so the UI renders it and then tails from the last id. /// first, so the UI renders it and then tails from the last id.
async fn activity( async fn activity(
State(state): State<AppState>, State(state): State<AppState>,
actor: Actor,
Path(id): Path<String>, Path(id): Path<String>,
Query(query): Query<ActivityQuery>, Query(query): Query<ActivityQuery>,
) -> Result<Json<Value>, ApiError> { ) -> Result<Json<Value>, ApiError> {
let actor = actor(&state).await?;
let run = sqlx::query_as::<_, RunRow>( let run = sqlx::query_as::<_, RunRow>(
"SELECT status, checkpoint->>'step' AS step, (checkpoint->>'turn')::bigint AS turn, "SELECT status, checkpoint->>'step' AS step, (checkpoint->>'turn')::bigint AS turn,
(checkpoint->>'turnLimit')::bigint AS turn_limit, error, (checkpoint->>'stepAt')::timestamptz AS step_at, started_at, completed_at (checkpoint->>'turnLimit')::bigint AS turn_limit, error, (checkpoint->>'stepAt')::timestamptz AS step_at, started_at, completed_at
@ -446,9 +429,9 @@ async fn activity(
/// it continues where it stopped instead of starting the task over. /// it continues where it stopped instead of starting the task over.
async fn retry( async fn retry(
State(state): State<AppState>, State(state): State<AppState>,
actor: Actor,
Path(id): Path<String>, Path(id): Path<String>,
) -> Result<Json<Value>, ApiError> { ) -> Result<Json<Value>, ApiError> {
let actor = actor(&state).await?;
let exists: Option<String> = let exists: Option<String> =
sqlx::query_scalar("SELECT status FROM runs WHERE id=$1 AND space_id=$2 AND user_id=$3") sqlx::query_scalar("SELECT status FROM runs WHERE id=$1 AND space_id=$2 AND user_id=$3")
.bind(&id) .bind(&id)
@ -539,39 +522,6 @@ mod tests {
assert_eq!(code("HTTP status 404 Not Found"), "model_unknown"); assert_eq!(code("HTTP status 404 Not Found"), "model_unknown");
} }
#[test]
fn an_opencode_data_policy_refusal_is_not_a_bad_key() {
let failure = classify_run_error(
"ProviderResponseError: status 403 Forbidden: {\"type\":\"error\",\"error\":{\"type\":\"DataPolicyError\",\"message\":\"This model collects data used to improve its quality and requires explicit opt in: https://opencode.ai/workspace/wrk_x/go\"}}",
);
assert_eq!(failure.code, "model_opt_in");
assert!(failure.retryable, "retry works once the opt-in is on");
assert!(failure.needs_settings);
}
#[test]
fn please_in_a_provider_message_is_not_a_lost_lease() {
assert_eq!(code("run lease was lost before completion"), "lease_lost");
assert_eq!(code("run lease lost before tool dispatch"), "lease_lost");
assert_ne!(
code("status 400 Bad Request: Request is missing x-opencode-session. Please see https://opencode.ai/docs/go/"),
"lease_lost"
);
}
#[test]
fn a_missing_key_is_not_reported_as_a_wrong_key() {
let failure = classify_run_error(
"missing credential for xai: add the API key in the workspace settings",
);
assert_eq!(failure.code, "model_key_missing");
assert!(!failure.retryable, "retrying without a key cannot help");
assert!(failure.needs_settings);
// A key the provider rejected still points at the same screen, but it
// is a different problem, and the wording matters.
assert_eq!(code("Incorrect API key provided"), "model_key");
}
#[test] #[test]
fn the_specific_timeouts_win_over_the_generic_one() { fn the_specific_timeouts_win_over_the_generic_one() {
assert_eq!( assert_eq!(

View File

@ -30,6 +30,13 @@ pub fn router() -> Router<AppState> {
.route("/api/rooms/{id}/status", get(room_status)) .route("/api/rooms/{id}/status", get(room_status))
} }
async fn actor(state: &AppState) -> Result<Actor, ApiError> {
state
.bootstrap()
.await
.map_err(|error| internal(error.to_string()))
}
fn internal(message: String) -> ApiError { fn internal(message: String) -> ApiError {
tracing::error!("rooms: {message}"); tracing::error!("rooms: {message}");
( (
@ -108,10 +115,8 @@ async fn room_from_id(
})) }))
} }
async fn list_rooms( async fn list_rooms(State(state): State<AppState>) -> Result<Json<Vec<Room>>, ApiError> {
actor: Actor, let actor = actor(&state).await?;
State(state): State<AppState>,
) -> Result<Json<Vec<Room>>, ApiError> {
let ids: Vec<String> = sqlx::query_scalar( let ids: Vec<String> = sqlx::query_scalar(
"SELECT id FROM rooms WHERE space_id=$1 AND user_id=$2 ORDER BY updated_at DESC, created_at DESC", "SELECT id FROM rooms WHERE space_id=$1 AND user_id=$2 ORDER BY updated_at DESC, created_at DESC",
) )
@ -134,9 +139,9 @@ async fn list_rooms(
async fn create_room( async fn create_room(
State(state): State<AppState>, State(state): State<AppState>,
actor: Actor,
Json(input): Json<CreateRoomInput>, Json(input): Json<CreateRoomInput>,
) -> Result<(StatusCode, Json<Room>), ApiError> { ) -> Result<(StatusCode, Json<Room>), ApiError> {
let actor = actor(&state).await?;
let name = input.name.trim(); let name = input.name.trim();
let mut seen = HashSet::new(); let mut seen = HashSet::new();
let member_ids: Vec<String> = input let member_ids: Vec<String> = input
@ -236,9 +241,9 @@ async fn create_room(
async fn get_room( async fn get_room(
State(state): State<AppState>, State(state): State<AppState>,
actor: Actor,
Path(id): Path<String>, Path(id): Path<String>,
) -> Result<Json<Room>, ApiError> { ) -> Result<Json<Room>, ApiError> {
let actor = actor(&state).await?;
room_from_id(&state, &actor, &id) room_from_id(&state, &actor, &id)
.await .await
.map_err(|error| internal(error.to_string()))? .map_err(|error| internal(error.to_string()))?
@ -253,9 +258,9 @@ async fn get_room(
async fn delete_room( async fn delete_room(
State(state): State<AppState>, State(state): State<AppState>,
actor: Actor,
Path(id): Path<String>, Path(id): Path<String>,
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
let actor = actor(&state).await?;
let deleted = sqlx::query("DELETE FROM rooms WHERE id=$1 AND space_id=$2 AND user_id=$3") let deleted = sqlx::query("DELETE FROM rooms WHERE id=$1 AND space_id=$2 AND user_id=$3")
.bind(&id) .bind(&id)
.bind(&actor.space_id) .bind(&actor.space_id)
@ -276,10 +281,10 @@ async fn delete_room(
/// is reached by one tap on a member — there is no settings page to open. /// is reached by one tap on a member — there is no settings page to open.
async fn update_room( async fn update_room(
State(state): State<AppState>, State(state): State<AppState>,
actor: Actor,
Path(id): Path<String>, Path(id): Path<String>,
Json(input): Json<UpdateRoomInput>, Json(input): Json<UpdateRoomInput>,
) -> Result<Json<Room>, ApiError> { ) -> Result<Json<Room>, ApiError> {
let actor = actor(&state).await?;
let current = room_from_id(&state, &actor, &id) let current = room_from_id(&state, &actor, &id)
.await .await
.map_err(|error| internal(error.to_string()))? .map_err(|error| internal(error.to_string()))?
@ -327,9 +332,9 @@ async fn update_room(
async fn list_sessions( async fn list_sessions(
State(state): State<AppState>, State(state): State<AppState>,
actor: Actor,
Path(id): Path<String>, Path(id): Path<String>,
) -> Result<Json<Vec<Session>>, ApiError> { ) -> Result<Json<Vec<Session>>, ApiError> {
let actor = actor(&state).await?;
if room_from_id(&state, &actor, &id) if room_from_id(&state, &actor, &id)
.await .await
.map_err(|error| internal(error.to_string()))? .map_err(|error| internal(error.to_string()))?
@ -358,10 +363,10 @@ async fn list_sessions(
async fn create_session( async fn create_session(
State(state): State<AppState>, State(state): State<AppState>,
actor: Actor,
Path(id): Path<String>, Path(id): Path<String>,
Json(input): Json<CreateSessionInput>, Json(input): Json<CreateSessionInput>,
) -> Result<(StatusCode, Json<Session>), ApiError> { ) -> Result<(StatusCode, Json<Session>), ApiError> {
let actor = actor(&state).await?;
let Some(room) = room_from_id(&state, &actor, &id) let Some(room) = room_from_id(&state, &actor, &id)
.await .await
.map_err(|error| internal(error.to_string()))? .map_err(|error| internal(error.to_string()))?
@ -398,9 +403,9 @@ async fn create_session(
async fn room_status( async fn room_status(
State(state): State<AppState>, State(state): State<AppState>,
actor: Actor,
Path(id): Path<String>, Path(id): Path<String>,
) -> Result<Json<RoomStatus>, ApiError> { ) -> Result<Json<RoomStatus>, ApiError> {
let actor = actor(&state).await?;
if room_from_id(&state, &actor, &id) if room_from_id(&state, &actor, &id)
.await .await
.map_err(|error| internal(error.to_string()))? .map_err(|error| internal(error.to_string()))?

View File

@ -49,7 +49,7 @@ pub fn router(state: AppState) -> Router {
.route("/view/{id}/{*rest}", any(crate::screen_proxy::view_path)) .route("/view/{id}/{*rest}", any(crate::screen_proxy::view_path))
.route_layer(middleware::from_fn_with_state( .route_layer(middleware::from_fn_with_state(
state.clone(), state.clone(),
crate::auth::require_session, crate::auth::require_auth,
)) ))
.with_state(state) .with_state(state)
} }
@ -58,10 +58,15 @@ async fn file_skills(State(state): State<AppState>) -> Json<Vec<crate::file_skil
Json(crate::file_skills::list(&state.data_dir)) Json(crate::file_skills::list(&state.data_dir))
} }
async fn list_bots( async fn actor(state: &AppState) -> Result<Actor, StatusCode> {
actor: Actor, state
State(state): State<AppState>, .bootstrap()
) -> Result<Json<Vec<Bot>>, StatusCode> { .await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
}
async fn list_bots(State(state): State<AppState>) -> Result<Json<Vec<Bot>>, StatusCode> {
let actor = actor(&state).await?;
let rows = state let rows = state
.db .db
.list_bots(&actor) .list_bots(&actor)
@ -97,9 +102,9 @@ async fn list_bots(
async fn create_bot( async fn create_bot(
State(state): State<AppState>, State(state): State<AppState>,
actor: Actor,
Json(input): Json<CreateBotInput>, Json(input): Json<CreateBotInput>,
) -> Result<Json<Bot>, StatusCode> { ) -> Result<Json<Bot>, StatusCode> {
let actor = actor(&state).await?;
if input.name.trim().is_empty() { if input.name.trim().is_empty() {
return Err(StatusCode::BAD_REQUEST); return Err(StatusCode::BAD_REQUEST);
} }
@ -123,9 +128,9 @@ async fn create_bot(
async fn get_bot( async fn get_bot(
State(state): State<AppState>, State(state): State<AppState>,
actor: Actor,
Path(id): Path<String>, Path(id): Path<String>,
) -> Result<Json<Value>, StatusCode> { ) -> Result<Json<Value>, StatusCode> {
let actor = actor(&state).await?;
let bot = state let bot = state
.db .db
.get_bot(&actor, &id) .get_bot(&actor, &id)
@ -180,10 +185,12 @@ struct InboxInput {
async fn update_inbox( async fn update_inbox(
State(state): State<AppState>, State(state): State<AppState>,
actor: Actor,
Path(id): Path<String>, Path(id): Path<String>,
Json(input): Json<InboxInput>, Json(input): Json<InboxInput>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> { ) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
let actor = actor(&state)
.await
.map_err(|status| (status, Json(json!({"message":"actor"}))))?;
let query = match input.action.as_str() { let query = match input.action.as_str() {
"read" => "UPDATE bots SET last_read_at=now() WHERE id=$1 AND space_id=$2 AND user_id=$3", "read" => "UPDATE bots SET last_read_at=now() WHERE id=$1 AND space_id=$2 AND user_id=$3",
"unread" => { "unread" => {
@ -223,10 +230,12 @@ async fn update_inbox(
async fn update_bot( async fn update_bot(
State(state): State<AppState>, State(state): State<AppState>,
actor: Actor,
Path(id): Path<String>, Path(id): Path<String>,
Json(input): Json<UpdateBotInput>, Json(input): Json<UpdateBotInput>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> { ) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
let actor = actor(&state)
.await
.map_err(|status| (status, Json(json!({"message":"actor"}))))?;
let name = input.name.trim(); let name = input.name.trim();
let color_ok = input.avatar_color.len() == 7 let color_ok = input.avatar_color.len() == 7
&& input.avatar_color.starts_with('#') && input.avatar_color.starts_with('#')
@ -315,9 +324,11 @@ async fn update_bot(
async fn delete_bot( async fn delete_bot(
State(state): State<AppState>, State(state): State<AppState>,
actor: Actor,
Path(id): Path<String>, Path(id): Path<String>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> { ) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
let actor = actor(&state)
.await
.map_err(|status| (status, Json(json!({ "message": "actor" }))))?;
let bot = state let bot = state
.db .db
.get_bot(&actor, &id) .get_bot(&actor, &id)
@ -398,9 +409,11 @@ async fn delete_bot(
async fn delete_environment( async fn delete_environment(
State(state): State<AppState>, State(state): State<AppState>,
actor: Actor,
Path(id): Path<String>, Path(id): Path<String>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> { ) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
let actor = actor(&state)
.await
.map_err(|status| (status, Json(json!({ "message": "actor" }))))?;
if id != actor.space_id { if id != actor.space_id {
return Err(( return Err((
StatusCode::NOT_FOUND, StatusCode::NOT_FOUND,
@ -484,10 +497,10 @@ struct SendBody {
async fn send_message( async fn send_message(
State(state): State<AppState>, State(state): State<AppState>,
actor: Actor,
Path(id): Path<String>, Path(id): Path<String>,
Json(body): Json<SendBody>, Json(body): Json<SendBody>,
) -> Result<Json<Value>, StatusCode> { ) -> Result<Json<Value>, StatusCode> {
let actor = actor(&state).await?;
let _ = state let _ = state
.db .db
.get_bot(&actor, &id) .get_bot(&actor, &id)
@ -518,9 +531,9 @@ async fn send_message(
async fn list_messages( async fn list_messages(
State(state): State<AppState>, State(state): State<AppState>,
actor: Actor,
Path(id): Path<String>, Path(id): Path<String>,
) -> Result<Json<Value>, StatusCode> { ) -> Result<Json<Value>, StatusCode> {
let actor = actor(&state).await?;
let _ = state let _ = state
.db .db
.get_bot(&actor, &id) .get_bot(&actor, &id)
@ -539,9 +552,9 @@ async fn list_messages(
async fn stop_task( async fn stop_task(
State(state): State<AppState>, State(state): State<AppState>,
actor: Actor,
Path(id): Path<String>, Path(id): Path<String>,
) -> Result<Json<Value>, StatusCode> { ) -> Result<Json<Value>, StatusCode> {
let actor = actor(&state).await?;
let _ = state let _ = state
.db .db
.get_bot(&actor, &id) .get_bot(&actor, &id)
@ -556,9 +569,9 @@ async fn stop_task(
async fn computer_status( async fn computer_status(
State(state): State<AppState>, State(state): State<AppState>,
actor: Actor,
Path(id): Path<String>, Path(id): Path<String>,
) -> Result<Json<Value>, StatusCode> { ) -> Result<Json<Value>, StatusCode> {
let actor = actor(&state).await?;
computer::current_status(&state, &actor, &id) computer::current_status(&state, &actor, &id)
.await .await
.map(|status| Json(serde_json::to_value(status).unwrap())) .map(|status| Json(serde_json::to_value(status).unwrap()))
@ -567,9 +580,9 @@ async fn computer_status(
async fn boot( async fn boot(
State(state): State<AppState>, State(state): State<AppState>,
actor: Actor,
Path(id): Path<String>, Path(id): Path<String>,
) -> Result<Json<Value>, StatusCode> { ) -> Result<Json<Value>, StatusCode> {
let actor = actor(&state).await?;
computer::boot(&state, &actor, &id) computer::boot(&state, &actor, &id)
.await .await
.map(|status| Json(serde_json::to_value(status).unwrap())) .map(|status| Json(serde_json::to_value(status).unwrap()))
@ -585,9 +598,9 @@ async fn boot(
async fn restart( async fn restart(
State(state): State<AppState>, State(state): State<AppState>,
actor: Actor,
Path(id): Path<String>, Path(id): Path<String>,
) -> Result<Json<Value>, StatusCode> { ) -> Result<Json<Value>, StatusCode> {
let actor = actor(&state).await?;
computer::restart(&state, &actor, &id) computer::restart(&state, &actor, &id)
.await .await
.map(|status| Json(serde_json::to_value(status).unwrap())) .map(|status| Json(serde_json::to_value(status).unwrap()))
@ -599,9 +612,11 @@ async fn restart(
async fn stop( async fn stop(
State(state): State<AppState>, State(state): State<AppState>,
actor: Actor,
Path(id): Path<String>, Path(id): Path<String>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> { ) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
let actor = actor(&state)
.await
.map_err(|status| (status, Json(json!({"message":"無法取得工作區"}))))?;
computer::stop(&state, &actor, &id) computer::stop(&state, &actor, &id)
.await .await
.map(|status| Json(serde_json::to_value(status).unwrap())) .map(|status| Json(serde_json::to_value(status).unwrap()))
@ -610,9 +625,9 @@ async fn stop(
async fn screen_url( async fn screen_url(
State(state): State<AppState>, State(state): State<AppState>,
actor: Actor,
Path(id): Path<String>, Path(id): Path<String>,
) -> Result<Json<Value>, StatusCode> { ) -> Result<Json<Value>, StatusCode> {
let actor = actor(&state).await?;
let bot = state let bot = state
.db .db
.get_bot(&actor, &id) .get_bot(&actor, &id)
@ -655,9 +670,11 @@ async fn screen_url(
async fn takeover( async fn takeover(
State(state): State<AppState>, State(state): State<AppState>,
actor: Actor,
Path(id): Path<String>, Path(id): Path<String>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> { ) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
let actor = actor(&state)
.await
.map_err(|status| (status, Json(json!({"message": "actor"}))))?;
match computer::takeover(&state, &actor, &id).await { match computer::takeover(&state, &actor, &id).await {
Ok((lease_id, expires_at)) => Ok(Json( Ok((lease_id, expires_at)) => Ok(Json(
json!({ "leaseId": lease_id, "expiresAt": expires_at }), json!({ "leaseId": lease_id, "expiresAt": expires_at }),
@ -668,9 +685,9 @@ async fn takeover(
async fn release( async fn release(
State(state): State<AppState>, State(state): State<AppState>,
actor: Actor,
Path(id): Path<String>, Path(id): Path<String>,
) -> Result<Json<Value>, StatusCode> { ) -> Result<Json<Value>, StatusCode> {
let actor = actor(&state).await?;
computer::release(&state, &actor, &id) computer::release(&state, &actor, &id)
.await .await
.map(|_| Json(json!({ "ok": true }))) .map(|_| Json(json!({ "ok": true })))
@ -679,9 +696,9 @@ async fn release(
async fn heartbeat( async fn heartbeat(
State(state): State<AppState>, State(state): State<AppState>,
actor: Actor,
Path(id): Path<String>, Path(id): Path<String>,
) -> Result<Json<Value>, StatusCode> { ) -> Result<Json<Value>, StatusCode> {
let actor = actor(&state).await?;
computer::heartbeat(&state, &actor, &id) computer::heartbeat(&state, &actor, &id)
.await .await
.map(|_| Json(json!({ "ok": true }))) .map(|_| Json(json!({ "ok": true })))
@ -699,10 +716,10 @@ struct InputBody {
async fn input( async fn input(
State(state): State<AppState>, State(state): State<AppState>,
actor: Actor,
Path(id): Path<String>, Path(id): Path<String>,
Json(body): Json<InputBody>, Json(body): Json<InputBody>,
) -> Result<Json<Value>, StatusCode> { ) -> Result<Json<Value>, StatusCode> {
let actor = actor(&state).await?;
let bot = state let bot = state
.db .db
.get_bot(&actor, &id) .get_bot(&actor, &id)

View File

@ -12,7 +12,8 @@ use std::time::Duration;
use lazyboy_contracts::ModelProvider; use lazyboy_contracts::ModelProvider;
use lazyboy_harness::{ use lazyboy_harness::{
CredentialChain, DynModel, ResolveModelRequest, connect_model, resolve_backend, CredentialChain, DynModel, ResolveModelRequest, connect_model, credential_from_env,
resolve_backend,
}; };
use rig_core::completion::message::{AssistantContent, Message, UserContent}; use rig_core::completion::message::{AssistantContent, Message, UserContent};
@ -337,7 +338,7 @@ async fn pick(
thread_id: &str, thread_id: &str,
text: &str, text: &str,
) -> Vec<String> { ) -> Vec<String> {
let model = match router_model(state, actor, thread_id).await { let model = match router_model(state, actor).await {
Ok(model) => model, Ok(model) => model,
Err(error) => { Err(error) => {
tracing::warn!("room router unavailable: {error}"); tracing::warn!("room router unavailable: {error}");
@ -432,11 +433,7 @@ async fn prompt_for(
/// The picker runs on the workspace model unless `LAZYBOY_ROUTER_MODEL` names a /// The picker runs on the workspace model unless `LAZYBOY_ROUTER_MODEL` names a
/// cheaper one; leaving it unset is a valid and common state. /// cheaper one; leaving it unset is a valid and common state.
async fn router_model( async fn router_model(state: &AppState, actor: &Actor) -> Result<DynModel, String> {
state: &AppState,
actor: &Actor,
thread_id: &str,
) -> Result<DynModel, String> {
let space = state let space = state
.db .db
.get_space(actor) .get_space(actor)
@ -458,10 +455,11 @@ async fn router_model(
credentials: CredentialChain { credentials: CredentialChain {
bot: None, bot: None,
space: space.default_model_api_key.clone(), space: space.default_model_api_key.clone(),
env: credential_from_env(provider),
}, },
}) })
.map_err(|error| error.to_string())?; .map_err(|error| error.to_string())?;
connect_model(&backend, thread_id).map_err(|error| error.to_string()) connect_model(&backend).map_err(|error| error.to_string())
} }
/// The single hand-off a bot may pass on: a member it names that is neither /// The single hand-off a bot may pass on: a member it names that is neither

View File

@ -510,7 +510,7 @@ async fn execute_run(
.map_err(|error| error.to_string())? .map_err(|error| error.to_string())?
.ok_or_else(|| "computer not found".to_string())?; .ok_or_else(|| "computer not found".to_string())?;
let (model, vision) = bot_model(state, actor, &bot, thread_id).await?; let (model, vision) = bot_model(state, actor, &bot).await?;
let skills = crate::skills::saved_skills(state.pool(), bot_id).await; let skills = crate::skills::saved_skills(state.pool(), bot_id).await;
let ctx = Arc::new(ToolCtx { let ctx = Arc::new(ToolCtx {
@ -1594,15 +1594,12 @@ async fn execute_run(
Ok(()) Ok(())
} }
/// Resolve the model a bot runs on (bot override → workspace default). /// Resolve the model a bot runs on (bot override → workspace default → env
/// `session` names the conversation the calls belong to; providers such as /// credentials). Returns the connected model and whether it accepts images.
/// OpenCode Go require it on every request. Returns the connected model and
/// whether it accepts images.
pub(crate) async fn bot_model( pub(crate) async fn bot_model(
state: &AppState, state: &AppState,
actor: &Actor, actor: &Actor,
bot: &crate::db::BotRow, bot: &crate::db::BotRow,
session: &str,
) -> Result<(DynModel, bool), String> { ) -> Result<(DynModel, bool), String> {
let space = state let space = state
.db .db
@ -1629,10 +1626,11 @@ pub(crate) async fn bot_model(
credentials: CredentialChain { credentials: CredentialChain {
bot: None, bot: None,
space: space.default_model_api_key.clone(), space: space.default_model_api_key.clone(),
env: lazyboy_harness::credential_from_env(provider),
}, },
}) })
.map_err(|error| error.to_string())?; .map_err(|error| error.to_string())?;
let model = connect_model(&backend, session).map_err(|error| error.to_string())?; let model = connect_model(&backend).map_err(|error| error.to_string())?;
Ok((model, backend.capabilities.vision)) Ok((model, backend.capabilities.vision))
} }

View File

@ -77,29 +77,25 @@ pub fn router() -> Router<AppState> {
.route("/api/schedules/{id}/run", post(run_now_http)) .route("/api/schedules/{id}/run", post(run_now_http))
} }
/// An id in a URL is not permission. Anything addressed by `bot_id` checks async fn scoped_actor(state: &AppState, bot_id: &str) -> Result<Actor, StatusCode> {
/// that the agent lives in the caller's own workspace first, so guessing an id let actor = state
/// cannot reach another person's memory, vault, or schedule. .bootstrap()
async fn assert_bot_is_mine( .await
state: &AppState, .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
actor: &Actor,
bot_id: &str,
) -> Result<(), StatusCode> {
state state
.db .db
.get_bot(actor, bot_id) .get_bot(&actor, bot_id)
.await .await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::NOT_FOUND)?; .ok_or(StatusCode::NOT_FOUND)?;
Ok(()) Ok(actor)
} }
async fn list_http( async fn list_http(
State(state): State<AppState>, State(state): State<AppState>,
actor: Actor,
Path(bot_id): Path<String>, Path(bot_id): Path<String>,
) -> Result<Json<Vec<Value>>, StatusCode> { ) -> Result<Json<Vec<Value>>, StatusCode> {
assert_bot_is_mine(&state, &actor, &bot_id).await?; let actor = scoped_actor(&state, &bot_id).await?;
let rows = list(&state, &actor, &bot_id) let rows = list(&state, &actor, &bot_id)
.await .await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
@ -108,11 +104,10 @@ async fn list_http(
async fn create_http( async fn create_http(
State(state): State<AppState>, State(state): State<AppState>,
actor: Actor,
Path(bot_id): Path<String>, Path(bot_id): Path<String>,
Json(input): Json<CreateSchedule>, Json(input): Json<CreateSchedule>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> { ) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
assert_bot_is_mine(&state, &actor, &bot_id) let actor = scoped_actor(&state, &bot_id)
.await .await
.map_err(|status| (status, Json(json!({"message":"bot not found"}))))?; .map_err(|status| (status, Json(json!({"message":"bot not found"}))))?;
create(&state, &actor, &bot_id, input) create(&state, &actor, &bot_id, input)
@ -123,10 +118,15 @@ async fn create_http(
async fn update_http( async fn update_http(
State(state): State<AppState>, State(state): State<AppState>,
actor: Actor,
Path(id): Path<String>, Path(id): Path<String>,
Json(input): Json<UpdateSchedule>, Json(input): Json<UpdateSchedule>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> { ) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
let actor = state.bootstrap().await.map_err(|_| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"message":"actor"})),
)
})?;
update(&state, &actor, &id, input) update(&state, &actor, &id, input)
.await .await
.map_err(|error| (StatusCode::BAD_REQUEST, Json(json!({"message": error}))))? .map_err(|error| (StatusCode::BAD_REQUEST, Json(json!({"message": error}))))?
@ -139,9 +139,12 @@ async fn update_http(
async fn delete_http( async fn delete_http(
State(state): State<AppState>, State(state): State<AppState>,
actor: Actor,
Path(id): Path<String>, Path(id): Path<String>,
) -> Result<StatusCode, StatusCode> { ) -> Result<StatusCode, StatusCode> {
let actor = state
.bootstrap()
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let deleted = sqlx::query("DELETE FROM schedules WHERE id=$1 AND space_id=$2 AND user_id=$3") let deleted = sqlx::query("DELETE FROM schedules WHERE id=$1 AND space_id=$2 AND user_id=$3")
.bind(&id) .bind(&id)
.bind(&actor.space_id) .bind(&actor.space_id)
@ -157,9 +160,14 @@ async fn delete_http(
async fn run_now_http( async fn run_now_http(
State(state): State<AppState>, State(state): State<AppState>,
actor: Actor,
Path(id): Path<String>, Path(id): Path<String>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> { ) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
let actor = state.bootstrap().await.map_err(|_| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"message":"actor"})),
)
})?;
let row = get_row(&state, &actor, &id) let row = get_row(&state, &actor, &id)
.await .await
.map_err(|error| (StatusCode::BAD_REQUEST, Json(json!({"message": error}))))? .map_err(|error| (StatusCode::BAD_REQUEST, Json(json!({"message": error}))))?

View File

@ -6,37 +6,25 @@ use futures_util::{SinkExt, StreamExt};
use tokio_tungstenite::tungstenite::Message as WsMessage; use tokio_tungstenite::tungstenite::Message as WsMessage;
use crate::computer; use crate::computer;
use crate::db::Actor;
use crate::state::AppState; use crate::state::AppState;
pub async fn view_root( pub async fn view_root(
State(state): State<AppState>, State(state): State<AppState>,
Path(bot_id): Path<String>, Path(bot_id): Path<String>,
actor: Actor,
req: Request, req: Request,
) -> Response { ) -> Response {
proxy(state, actor, bot_id, String::new(), req).await proxy(state, bot_id, String::new(), req).await
} }
pub async fn view_path( pub async fn view_path(
State(state): State<AppState>, State(state): State<AppState>,
Path((bot_id, rest)): Path<(String, String)>, Path((bot_id, rest)): Path<(String, String)>,
actor: Actor,
req: Request, req: Request,
) -> Response { ) -> Response {
proxy(state, actor, bot_id, rest, req).await proxy(state, bot_id, rest, req).await
} }
/// A desktop is streamed to its owner only. The actor comes from the login async fn proxy(state: AppState, bot_id: String, rest: String, req: Request) -> Response {
/// cookie before the request is upgraded, so another person holding the same
/// `/view/:id` URL is turned away instead of watching somebody's screen.
async fn proxy(
state: AppState,
actor: Actor,
bot_id: String,
rest: String,
req: Request,
) -> Response {
let upgrade = req let upgrade = req
.headers() .headers()
.get(header::UPGRADE) .get(header::UPGRADE)
@ -56,7 +44,7 @@ async fn proxy(
return StatusCode::NOT_FOUND.into_response(); return StatusCode::NOT_FOUND.into_response();
} }
let ensure = upgrade || is_viewer_page(&rest) || rest.contains("websockify"); let ensure = upgrade || is_viewer_page(&rest) || rest.contains("websockify");
let (host, port) = match upstream_target(&state, &actor, &bot_id, ensure).await { let (host, port) = match upstream_target(&state, &bot_id, ensure).await {
Ok(target) => target, Ok(target) => target,
Err(status) => return status.into_response(), Err(status) => return status.into_response(),
}; };
@ -100,13 +88,16 @@ async fn viewer_page() -> Response {
async fn upstream_target( async fn upstream_target(
state: &AppState, state: &AppState,
actor: &Actor,
bot_id: &str, bot_id: &str,
ensure: bool, ensure: bool,
) -> Result<(String, u16), StatusCode> { ) -> Result<(String, u16), StatusCode> {
let actor = state
.bootstrap()
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let bot = state let bot = state
.db .db
.get_bot(actor, bot_id) .get_bot(&actor, bot_id)
.await .await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::NOT_FOUND)?; .ok_or(StatusCode::NOT_FOUND)?;
@ -118,7 +109,7 @@ async fn upstream_target(
.ok_or(StatusCode::NOT_FOUND)?; .ok_or(StatusCode::NOT_FOUND)?;
let computer_ref = computer::computer_ref(&computer).ok_or(StatusCode::NOT_FOUND)?; let computer_ref = computer::computer_ref(&computer).ok_or(StatusCode::NOT_FOUND)?;
let screen = if ensure { let screen = if ensure {
match computer::ensure_bot_screen(state, actor, bot_id, &computer, None).await { match computer::ensure_bot_screen(state, &actor, bot_id, &computer, None).await {
Ok(bound) => bound.row, Ok(bound) => bound.row,
Err(_) => state Err(_) => state
.db .db
@ -138,7 +129,7 @@ async fn upstream_target(
.connect_screen( .connect_screen(
&computer_ref, &computer_ref,
computer::user_can_interact(&computer, screen.as_ref(), bot_id), computer::user_can_interact(&computer, screen.as_ref(), bot_id),
&computer::adapter_context_for(actor, bot_id, "view", screen.as_ref(), None), &computer::adapter_context_for(&actor, bot_id, "view", screen.as_ref(), None),
) )
.await .await
.map_err(|_| StatusCode::BAD_GATEWAY)?; .map_err(|_| StatusCode::BAD_GATEWAY)?;

View File

@ -43,6 +43,13 @@ pub fn router() -> Router<AppState> {
.route("/api/sessions/{id}/stop", post(stop_session)) .route("/api/sessions/{id}/stop", post(stop_session))
} }
async fn actor(state: &AppState) -> Result<Actor, ApiError> {
state
.bootstrap()
.await
.map_err(|error| internal(error.to_string()))
}
fn internal(message: String) -> ApiError { fn internal(message: String) -> ApiError {
tracing::error!("sessions: {message}"); tracing::error!("sessions: {message}");
( (
@ -79,9 +86,9 @@ pub(crate) fn session_from_row(
async fn list_sessions( async fn list_sessions(
State(state): State<AppState>, State(state): State<AppState>,
actor: Actor,
Path(bot_id): Path<String>, Path(bot_id): Path<String>,
) -> Result<Json<Vec<Session>>, ApiError> { ) -> Result<Json<Vec<Session>>, ApiError> {
let actor = actor(&state).await?;
let exists: Option<i32> = let exists: Option<i32> =
sqlx::query_scalar("SELECT 1 FROM bots WHERE id=$1 AND space_id=$2 AND user_id=$3") sqlx::query_scalar("SELECT 1 FROM bots WHERE id=$1 AND space_id=$2 AND user_id=$3")
.bind(&bot_id) .bind(&bot_id)
@ -114,10 +121,10 @@ async fn list_sessions(
async fn create_session( async fn create_session(
State(state): State<AppState>, State(state): State<AppState>,
actor: Actor,
Path(bot_id): Path<String>, Path(bot_id): Path<String>,
Json(input): Json<CreateSessionInput>, Json(input): Json<CreateSessionInput>,
) -> Result<(StatusCode, Json<Session>), ApiError> { ) -> Result<(StatusCode, Json<Session>), ApiError> {
let actor = actor(&state).await?;
let exists: Option<i32> = let exists: Option<i32> =
sqlx::query_scalar("SELECT 1 FROM bots WHERE id=$1 AND space_id=$2 AND user_id=$3") sqlx::query_scalar("SELECT 1 FROM bots WHERE id=$1 AND space_id=$2 AND user_id=$3")
.bind(&bot_id) .bind(&bot_id)
@ -152,9 +159,9 @@ async fn create_session(
async fn get_session( async fn get_session(
State(state): State<AppState>, State(state): State<AppState>,
actor: Actor,
Path(id): Path<String>, Path(id): Path<String>,
) -> Result<Json<Session>, ApiError> { ) -> Result<Json<Session>, ApiError> {
let actor = actor(&state).await?;
let row = scoped_session_row(&state, &actor, &id) let row = scoped_session_row(&state, &actor, &id)
.await? .await?
.ok_or_else(|| { .ok_or_else(|| {
@ -168,10 +175,10 @@ async fn get_session(
async fn update_session( async fn update_session(
State(state): State<AppState>, State(state): State<AppState>,
actor: Actor,
Path(id): Path<String>, Path(id): Path<String>,
Json(input): Json<UpdateSessionInput>, Json(input): Json<UpdateSessionInput>,
) -> Result<Json<Session>, ApiError> { ) -> Result<Json<Session>, ApiError> {
let actor = actor(&state).await?;
if input if input
.status .status
.as_deref() .as_deref()
@ -209,9 +216,9 @@ async fn update_session(
async fn delete_session( async fn delete_session(
State(state): State<AppState>, State(state): State<AppState>,
actor: Actor,
Path(id): Path<String>, Path(id): Path<String>,
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
let actor = actor(&state).await?;
if scoped_session_row(&state, &actor, &id).await?.is_none() { if scoped_session_row(&state, &actor, &id).await?.is_none() {
return Err(( return Err((
StatusCode::NOT_FOUND, StatusCode::NOT_FOUND,
@ -265,17 +272,17 @@ async fn delete_session(
async fn list_messages( async fn list_messages(
State(state): State<AppState>, State(state): State<AppState>,
actor: Actor,
Path(id): Path<String>, Path(id): Path<String>,
) -> Result<Json<Vec<SessionMessage>>, ApiError> { ) -> Result<Json<Vec<SessionMessage>>, ApiError> {
let actor = actor(&state).await?;
Ok(Json(messages_for_session(&state, &actor, &id).await?)) Ok(Json(messages_for_session(&state, &actor, &id).await?))
} }
async fn clear_messages( async fn clear_messages(
State(state): State<AppState>, State(state): State<AppState>,
actor: Actor,
Path(id): Path<String>, Path(id): Path<String>,
) -> Result<Json<Value>, ApiError> { ) -> Result<Json<Value>, ApiError> {
let actor = actor(&state).await?;
if scoped_session_row(&state, &actor, &id).await?.is_none() { if scoped_session_row(&state, &actor, &id).await?.is_none() {
return Err(( return Err((
StatusCode::NOT_FOUND, StatusCode::NOT_FOUND,
@ -314,10 +321,10 @@ async fn clear_messages(
async fn send_message( async fn send_message(
State(state): State<AppState>, State(state): State<AppState>,
actor: Actor,
Path(id): Path<String>, Path(id): Path<String>,
Json(input): Json<SendSessionMessageInput>, Json(input): Json<SendSessionMessageInput>,
) -> Result<(StatusCode, Json<Value>), ApiError> { ) -> Result<(StatusCode, Json<Value>), ApiError> {
let actor = actor(&state).await?;
if input.text.trim().is_empty() && input.attachments.is_empty() { if input.text.trim().is_empty() && input.attachments.is_empty() {
return Err(( return Err((
StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST,
@ -349,9 +356,9 @@ async fn send_message(
async fn stop_session( async fn stop_session(
State(state): State<AppState>, State(state): State<AppState>,
actor: Actor,
Path(id): Path<String>, Path(id): Path<String>,
) -> Result<Json<Value>, ApiError> { ) -> Result<Json<Value>, ApiError> {
let actor = actor(&state).await?;
if scoped_session_row(&state, &actor, &id).await?.is_none() { if scoped_session_row(&state, &actor, &id).await?.is_none() {
return Err(( return Err((
StatusCode::NOT_FOUND, StatusCode::NOT_FOUND,
@ -364,10 +371,10 @@ async fn stop_session(
async fn events( async fn events(
State(state): State<AppState>, State(state): State<AppState>,
actor: Actor,
Path(id): Path<String>, Path(id): Path<String>,
headers: HeaderMap, headers: HeaderMap,
) -> Result<Sse<impl futures_util::Stream<Item = Result<Event, Infallible>>>, ApiError> { ) -> Result<Sse<impl futures_util::Stream<Item = Result<Event, Infallible>>>, ApiError> {
let actor = actor(&state).await?;
if scoped_session_row(&state, &actor, &id).await?.is_none() { if scoped_session_row(&state, &actor, &id).await?.is_none() {
return Err(( return Err((
StatusCode::NOT_FOUND, StatusCode::NOT_FOUND,

View File

@ -121,6 +121,13 @@ const COLUMNS: &str = "id, bot_id, thread_id, name, goal, status, playbook, reco
const SELECT: &str = "SELECT id, bot_id, thread_id, name, goal, status, playbook, recording, error, const SELECT: &str = "SELECT id, bot_id, thread_id, name, goal, status, playbook, recording, error,
started_at, expires_at, stopped_at, created_at, updated_at FROM taught_skills"; started_at, expires_at, stopped_at, created_at, updated_at FROM taught_skills";
async fn actor(state: &AppState) -> Result<Actor, ApiError> {
state
.bootstrap()
.await
.map_err(|error| internal(error.to_string()))
}
fn internal(message: String) -> ApiError { fn internal(message: String) -> ApiError {
tracing::error!("skills: {message}"); tracing::error!("skills: {message}");
( (
@ -176,9 +183,9 @@ pub async fn recording_skill(pool: &PgPool, bot_id: &str) -> Option<SkillRow> {
async fn list_skills( async fn list_skills(
State(state): State<AppState>, State(state): State<AppState>,
actor: Actor,
Path(bot_id): Path<String>, Path(bot_id): Path<String>,
) -> Result<Json<Vec<Skill>>, ApiError> { ) -> Result<Json<Vec<Skill>>, ApiError> {
let actor = actor(&state).await?;
let rows = sqlx::query_as::<_, SkillRow>(&format!( let rows = sqlx::query_as::<_, SkillRow>(&format!(
"{SELECT} WHERE bot_id = $1 AND space_id = $2 AND user_id = $3 "{SELECT} WHERE bot_id = $1 AND space_id = $2 AND user_id = $3
ORDER BY CASE status WHEN 'recording' THEN 0 WHEN 'drafting' THEN 1 WHEN 'draft' THEN 2 ELSE 3 END, ORDER BY CASE status WHEN 'recording' THEN 0 WHEN 'drafting' THEN 1 WHEN 'draft' THEN 2 ELSE 3 END,
@ -200,10 +207,10 @@ struct StartBody {
async fn start_skill( async fn start_skill(
State(state): State<AppState>, State(state): State<AppState>,
actor: Actor,
Path(bot_id): Path<String>, Path(bot_id): Path<String>,
Json(body): Json<StartBody>, Json(body): Json<StartBody>,
) -> Result<Json<Skill>, ApiError> { ) -> Result<Json<Skill>, ApiError> {
let actor = actor(&state).await?;
let goal = body.goal.trim().to_string(); let goal = body.goal.trim().to_string();
if goal.is_empty() { if goal.is_empty() {
return Err(bad_request("goal required")); return Err(bad_request("goal required"));
@ -312,9 +319,9 @@ async fn start_skill(
async fn stop_skill( async fn stop_skill(
State(state): State<AppState>, State(state): State<AppState>,
actor: Actor,
Path(bot_id): Path<String>, Path(bot_id): Path<String>,
) -> Result<Json<Skill>, ApiError> { ) -> Result<Json<Skill>, ApiError> {
let actor = actor(&state).await?;
let row = recording_skill(state.pool(), &bot_id) let row = recording_skill(state.pool(), &bot_id)
.await .await
.ok_or_else(|| bad_request("not recording"))?; .ok_or_else(|| bad_request("not recording"))?;
@ -343,9 +350,9 @@ async fn stop_skill(
async fn cancel_skill( async fn cancel_skill(
State(state): State<AppState>, State(state): State<AppState>,
actor: Actor,
Path(bot_id): Path<String>, Path(bot_id): Path<String>,
) -> Result<Json<Value>, ApiError> { ) -> Result<Json<Value>, ApiError> {
let actor = actor(&state).await?;
let Some(row) = recording_skill(state.pool(), &bot_id).await else { let Some(row) = recording_skill(state.pool(), &bot_id).await else {
return Ok(Json(json!({ "ok": true }))); return Ok(Json(json!({ "ok": true })));
}; };
@ -378,10 +385,10 @@ struct UpdateBody {
async fn update_skill( async fn update_skill(
State(state): State<AppState>, State(state): State<AppState>,
actor: Actor,
Path(skill_id): Path<String>, Path(skill_id): Path<String>,
Json(body): Json<UpdateBody>, Json(body): Json<UpdateBody>,
) -> Result<Json<Skill>, ApiError> { ) -> Result<Json<Skill>, ApiError> {
let actor = actor(&state).await?;
let row = load_skill(&state, &actor, &skill_id).await?; let row = load_skill(&state, &actor, &skill_id).await?;
if !matches!(row.status.as_str(), "draft" | "saved") { if !matches!(row.status.as_str(), "draft" | "saved") {
return Err(conflict("skill is not ready yet")); return Err(conflict("skill is not ready yet"));
@ -423,9 +430,9 @@ async fn update_skill(
async fn delete_skill( async fn delete_skill(
State(state): State<AppState>, State(state): State<AppState>,
actor: Actor,
Path(skill_id): Path<String>, Path(skill_id): Path<String>,
) -> Result<Json<Value>, ApiError> { ) -> Result<Json<Value>, ApiError> {
let actor = actor(&state).await?;
let row = load_skill(&state, &actor, &skill_id).await?; let row = load_skill(&state, &actor, &skill_id).await?;
if row.status == "recording" { if row.status == "recording" {
return Err(conflict("stop or cancel the recording first")); return Err(conflict("stop or cancel the recording first"));
@ -441,9 +448,9 @@ async fn delete_skill(
async fn test_skill( async fn test_skill(
State(state): State<AppState>, State(state): State<AppState>,
actor: Actor,
Path(skill_id): Path<String>, Path(skill_id): Path<String>,
) -> Result<Json<Value>, ApiError> { ) -> Result<Json<Value>, ApiError> {
let actor = actor(&state).await?;
let row = load_skill(&state, &actor, &skill_id).await?; let row = load_skill(&state, &actor, &skill_id).await?;
if !matches!(row.status.as_str(), "draft" | "saved") { if !matches!(row.status.as_str(), "draft" | "saved") {
return Err(conflict("skill is not ready yet")); return Err(conflict("skill is not ready yet"));
@ -654,9 +661,9 @@ fn export_filename_ascii(name: &str) -> String {
async fn export_skill( async fn export_skill(
State(state): State<AppState>, State(state): State<AppState>,
actor: Actor,
Path(skill_id): Path<String>, Path(skill_id): Path<String>,
) -> Result<(HeaderMap, Json<Value>), ApiError> { ) -> Result<(HeaderMap, Json<Value>), ApiError> {
let actor = actor(&state).await?;
let row = load_skill(&state, &actor, &skill_id).await?; let row = load_skill(&state, &actor, &skill_id).await?;
if !matches!(row.status.as_str(), "draft" | "saved") { if !matches!(row.status.as_str(), "draft" | "saved") {
return Err(conflict("skill is not ready yet")); return Err(conflict("skill is not ready yet"));
@ -685,10 +692,10 @@ async fn export_skill(
async fn import_skill( async fn import_skill(
State(state): State<AppState>, State(state): State<AppState>,
actor: Actor,
Path(bot_id): Path<String>, Path(bot_id): Path<String>,
Json(body): Json<Value>, Json(body): Json<Value>,
) -> Result<Json<Skill>, ApiError> { ) -> Result<Json<Skill>, ApiError> {
let actor = actor(&state).await?;
state state
.db .db
.get_bot(&actor, &bot_id) .get_bot(&actor, &bot_id)
@ -1012,19 +1019,7 @@ async fn finalize(
let bot = state.db.get_bot(actor, &row.bot_id).await.ok().flatten(); let bot = state.db.get_bot(actor, &row.bot_id).await.ok().flatten();
let distilled = match bot { let distilled = match bot {
Some(bot) => { Some(bot) => distill(state, actor, &bot, &row.goal, &events, &frames, &dir).await,
distill(
state,
actor,
&bot,
&row.id,
&row.goal,
&events,
&frames,
&dir,
)
.await
}
None => Err("bot not found".to_string()), None => Err("bot not found".to_string()),
}; };
let (playbook, error) = match distilled { let (playbook, error) = match distilled {
@ -1271,13 +1266,12 @@ async fn distill(
state: &AppState, state: &AppState,
actor: &Actor, actor: &Actor,
bot: &crate::db::BotRow, bot: &crate::db::BotRow,
skill_id: &str,
goal: &str, goal: &str,
events: &[Value], events: &[Value],
frames: &[Value], frames: &[Value],
dir: &std::path::Path, dir: &std::path::Path,
) -> Result<Value, String> { ) -> Result<Value, String> {
let (model, vision) = crate::runs::bot_model(state, actor, bot, skill_id).await?; let (model, vision) = crate::runs::bot_model(state, actor, bot).await?;
let t0 = events let t0 = events
.iter() .iter()
.chain(frames.iter()) .chain(frames.iter())

View File

@ -7,7 +7,7 @@ use sqlx::PgPool;
use sqlx::postgres::PgPoolOptions; use sqlx::postgres::PgPoolOptions;
use crate::auth::AuthConfig; use crate::auth::AuthConfig;
use crate::db::Db; use crate::db::{Actor, Db};
use crate::mcp::McpHub; use crate::mcp::McpHub;
use crate::memory::MemoryService; use crate::memory::MemoryService;
@ -176,6 +176,10 @@ impl AppState {
}) })
} }
pub async fn bootstrap(&self) -> Result<Actor, sqlx::Error> {
self.db.ensure_local_actor().await
}
pub fn pool(&self) -> &PgPool { pub fn pool(&self) -> &PgPool {
&self.db.pool &self.db.pool
} }

View File

@ -55,29 +55,25 @@ pub fn router() -> Router<AppState> {
) )
} }
/// An id in a URL is not permission. Anything addressed by `bot_id` checks async fn scoped_actor(state: &AppState, bot_id: &str) -> Result<Actor, StatusCode> {
/// that the agent lives in the caller's own workspace first, so guessing an id let actor = state
/// cannot reach another person's memory, vault, or schedule. .bootstrap()
async fn assert_bot_is_mine( .await
state: &AppState, .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
actor: &Actor,
bot_id: &str,
) -> Result<(), StatusCode> {
state state
.db .db
.get_bot(actor, bot_id) .get_bot(&actor, bot_id)
.await .await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::NOT_FOUND)?; .ok_or(StatusCode::NOT_FOUND)?;
Ok(()) Ok(actor)
} }
async fn list_accounts( async fn list_accounts(
State(state): State<AppState>, State(state): State<AppState>,
actor: Actor,
Path(bot_id): Path<String>, Path(bot_id): Path<String>,
) -> Result<Json<Vec<VaultAccount>>, StatusCode> { ) -> Result<Json<Vec<VaultAccount>>, StatusCode> {
assert_bot_is_mine(&state, &actor, &bot_id).await?; let actor = scoped_actor(&state, &bot_id).await?;
list(&state, &actor, &bot_id) list(&state, &actor, &bot_id)
.await .await
.map(Json) .map(Json)
@ -86,11 +82,10 @@ async fn list_accounts(
async fn create_account( async fn create_account(
State(state): State<AppState>, State(state): State<AppState>,
actor: Actor,
Path(bot_id): Path<String>, Path(bot_id): Path<String>,
Json(input): Json<UpsertAccount>, Json(input): Json<UpsertAccount>,
) -> Result<Json<VaultAccount>, (StatusCode, Json<Value>)> { ) -> Result<Json<VaultAccount>, (StatusCode, Json<Value>)> {
assert_bot_is_mine(&state, &actor, &bot_id) let actor = scoped_actor(&state, &bot_id)
.await .await
.map_err(|status| (status, Json(json!({"message":"bot not found"}))))?; .map_err(|status| (status, Json(json!({"message":"bot not found"}))))?;
insert(&state, &actor, &bot_id, input) insert(&state, &actor, &bot_id, input)
@ -101,11 +96,10 @@ async fn create_account(
async fn update_account( async fn update_account(
State(state): State<AppState>, State(state): State<AppState>,
actor: Actor,
Path((bot_id, account_id)): Path<(String, String)>, Path((bot_id, account_id)): Path<(String, String)>,
Json(input): Json<UpsertAccount>, Json(input): Json<UpsertAccount>,
) -> Result<Json<VaultAccount>, (StatusCode, Json<Value>)> { ) -> Result<Json<VaultAccount>, (StatusCode, Json<Value>)> {
assert_bot_is_mine(&state, &actor, &bot_id) let actor = scoped_actor(&state, &bot_id)
.await .await
.map_err(|status| (status, Json(json!({"message":"bot not found"}))))?; .map_err(|status| (status, Json(json!({"message":"bot not found"}))))?;
update_row(&state, &actor, &bot_id, &account_id, input) update_row(&state, &actor, &bot_id, &account_id, input)
@ -120,10 +114,9 @@ async fn update_account(
async fn delete_account( async fn delete_account(
State(state): State<AppState>, State(state): State<AppState>,
actor: Actor,
Path((bot_id, account_id)): Path<(String, String)>, Path((bot_id, account_id)): Path<(String, String)>,
) -> Result<StatusCode, StatusCode> { ) -> Result<StatusCode, StatusCode> {
assert_bot_is_mine(&state, &actor, &bot_id).await?; let actor = scoped_actor(&state, &bot_id).await?;
let deleted = sqlx::query( let deleted = sqlx::query(
"DELETE FROM vault_accounts "DELETE FROM vault_accounts
WHERE id=$1 AND bot_id=$2 AND space_id=$3 AND user_id=$4", WHERE id=$1 AND bot_id=$2 AND space_id=$3 AND user_id=$4",
@ -335,13 +328,17 @@ fn normalize_host(host: &str, site: &str) -> String {
} }
fn vault_key() -> Result<[u8; 32], String> { fn vault_key() -> Result<[u8; 32], String> {
// The vault key is its own secret. It used to fall back to the old shared
// login token, which quietly mixed "who can sign in" with "what can be
// decrypted"; saved passwords stay locked until it is set on purpose.
let material = std::env::var("LAZYBOY_VAULT_KEY") let material = std::env::var("LAZYBOY_VAULT_KEY")
.ok() .ok()
.filter(|value| !value.is_empty()) .filter(|value| !value.is_empty())
.ok_or_else(|| "set LAZYBOY_VAULT_KEY to encrypt saved passwords".to_string())?; .or_else(|| {
std::env::var("LAZYBOY_APP_TOKEN")
.ok()
.filter(|v| !v.is_empty())
})
.ok_or_else(|| {
"set LAZYBOY_VAULT_KEY or LAZYBOY_APP_TOKEN to encrypt saved passwords".to_string()
})?;
let digest = Sha256::digest(material.as_bytes()); let digest = Sha256::digest(material.as_bytes());
let mut key = [0u8; 32]; let mut key = [0u8; 32];
key.copy_from_slice(&digest); key.copy_from_slice(&digest);

View File

@ -22,15 +22,19 @@ pub fn router() -> Router<AppState> {
.route("/api/sessions/{id}/call", get(voice_call::call_ws)) .route("/api/sessions/{id}/call", get(voice_call::call_ws))
} }
/// A voice key comes from settings and nowhere else: the key saved for voice, async fn actor(state: &AppState) -> Result<Actor, StatusCode> {
/// or the text key when voice uses the same provider. An unset key stays unset state
/// so a call fails with "go to 設定 → 語音" instead of borrowing a key that .bootstrap()
/// happens to live in the server environment. .await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
}
pub fn voice_credential_chain( pub fn voice_credential_chain(
provider: VoiceProvider, provider: VoiceProvider,
voice_api_key: Option<&str>, voice_api_key: Option<&str>,
text_provider: &str, text_provider: &str,
text_api_key: Option<&str>, text_api_key: Option<&str>,
env_key: Option<&str>,
) -> CredentialChain { ) -> CredentialChain {
let nonempty = |value: Option<&str>| { let nonempty = |value: Option<&str>| {
value value
@ -45,7 +49,11 @@ pub fn voice_credential_chain(
None None
} }
}); });
CredentialChain { bot: None, space } CredentialChain {
bot: None,
space,
env: nonempty(env_key),
}
} }
pub fn voice_instructions(bot_name: &str, bot_instructions: &str) -> String { pub fn voice_instructions(bot_name: &str, bot_instructions: &str) -> String {
@ -84,11 +92,15 @@ fn settings_json(space: &SpaceRow) -> Value {
} else { } else {
requested requested
}; };
let env_key = std::env::var(provider.env_key_name())
.ok()
.filter(|value| !value.is_empty());
let credentials = voice_credential_chain( let credentials = voice_credential_chain(
provider, provider,
space.voice_api_key.as_deref(), space.voice_api_key.as_deref(),
&space.default_model_provider, &space.default_model_provider,
space.default_model_api_key.as_deref(), space.default_model_api_key.as_deref(),
env_key.as_deref(),
); );
let resolved = resolve_voice(ResolveVoiceRequest { let resolved = resolve_voice(ResolveVoiceRequest {
provider, provider,
@ -122,6 +134,8 @@ fn settings_json(space: &SpaceRow) -> Value {
"ready": space.voice_enabled && ready, "ready": space.voice_enabled && ready,
"missing": missing, "missing": missing,
"apiKeySet": space.voice_api_key.as_deref().is_some_and(|value| !value.is_empty()), "apiKeySet": space.voice_api_key.as_deref().is_some_and(|value| !value.is_empty()),
"envKeySet": env_key.is_some(),
"envKeyName": provider.env_key_name(),
"reusesTextKey": provider.as_str() == space.default_model_provider "reusesTextKey": provider.as_str() == space.default_model_provider
&& space.voice_api_key.as_deref().is_none_or(|value| value.is_empty()) && space.voice_api_key.as_deref().is_none_or(|value| value.is_empty())
&& space.default_model_api_key.as_deref().is_some_and(|value| !value.is_empty()), && space.default_model_api_key.as_deref().is_some_and(|value| !value.is_empty()),
@ -132,6 +146,7 @@ fn settings_json(space: &SpaceRow) -> Value {
VoiceProvider::Openai => "OpenAI", VoiceProvider::Openai => "OpenAI",
VoiceProvider::Scripted => "Scripted", VoiceProvider::Scripted => "Scripted",
}, },
"envKeyName": item.env_key_name(),
})).collect::<Vec<_>>(), })).collect::<Vec<_>>(),
"models": catalog.models.iter().map(|item| json!({"id": item.id, "name": item.name})).collect::<Vec<_>>(), "models": catalog.models.iter().map(|item| json!({"id": item.id, "name": item.name})).collect::<Vec<_>>(),
"voices": catalog.voices.iter().map(|item| json!({"id": item.id, "name": item.name})).collect::<Vec<_>>(), "voices": catalog.voices.iter().map(|item| json!({"id": item.id, "name": item.name})).collect::<Vec<_>>(),
@ -139,25 +154,17 @@ fn settings_json(space: &SpaceRow) -> Value {
}) })
} }
/// Reading the settings twice is cheaper than keeping two shapes in sync, so async fn get_settings(State(state): State<AppState>) -> Result<Json<Value>, StatusCode> {
/// a save answers with the same body a GET returns. let actor = actor(&state).await?;
async fn settings_payload(state: &AppState, actor: &Actor) -> Result<Json<Value>, StatusCode> {
let space = state let space = state
.db .db
.get_space(actor) .get_space(&actor)
.await .await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::NOT_FOUND)?; .ok_or(StatusCode::NOT_FOUND)?;
Ok(Json(settings_json(&space))) Ok(Json(settings_json(&space)))
} }
async fn get_settings(
actor: Actor,
State(state): State<AppState>,
) -> Result<Json<Value>, StatusCode> {
settings_payload(&state, &actor).await
}
#[derive(Deserialize)] #[derive(Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct UpdateVoiceSettings { struct UpdateVoiceSettings {
@ -174,9 +181,9 @@ struct UpdateVoiceSettings {
async fn update_settings( async fn update_settings(
State(state): State<AppState>, State(state): State<AppState>,
actor: Actor,
Json(input): Json<UpdateVoiceSettings>, Json(input): Json<UpdateVoiceSettings>,
) -> Result<Json<Value>, StatusCode> { ) -> Result<Json<Value>, StatusCode> {
let actor = actor(&state).await?;
let provider: VoiceProvider = input let provider: VoiceProvider = input
.provider .provider
.parse() .parse()
@ -218,7 +225,7 @@ async fn update_settings(
) )
.await .await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
settings_payload(&state, &actor).await get_settings(State(state)).await
} }
pub fn resolve_space_voice( pub fn resolve_space_voice(
@ -238,11 +245,15 @@ pub fn resolve_space_voice(
} else { } else {
requested requested
}; };
let env_key = std::env::var(provider.env_key_name())
.ok()
.filter(|value| !value.is_empty());
let credentials = voice_credential_chain( let credentials = voice_credential_chain(
provider, provider,
space.voice_api_key.as_deref(), space.voice_api_key.as_deref(),
&space.default_model_provider, &space.default_model_provider,
space.default_model_api_key.as_deref(), space.default_model_api_key.as_deref(),
env_key.as_deref(),
); );
let resolved = resolve_voice(ResolveVoiceRequest { let resolved = resolve_voice(ResolveVoiceRequest {
provider, provider,
@ -260,19 +271,30 @@ mod tests {
#[test] #[test]
fn voice_key_reuses_matching_text_provider_and_does_not_cross_providers() { fn voice_key_reuses_matching_text_provider_and_does_not_cross_providers() {
let reused = voice_credential_chain(VoiceProvider::Xai, None, "xai", Some("text-key")); let reused = voice_credential_chain(
VoiceProvider::Xai,
None,
"xai",
Some("text-key"),
Some("env-key"),
);
assert_eq!(reused.resolve(), Some("text-key")); assert_eq!(reused.resolve(), Some("text-key"));
// Another provider's text key is not a voice key, and nothing else is let env_only = voice_credential_chain(
// reached for: no key means the call is refused, not silently guessed. VoiceProvider::Xai,
let none = voice_credential_chain(VoiceProvider::Xai, None, "opencode-go", Some("go-key")); None,
assert_eq!(none.resolve(), None); "opencode-go",
Some("go-key"),
Some("env-xai"),
);
assert_eq!(env_only.resolve(), Some("env-xai"));
let dedicated = voice_credential_chain( let dedicated = voice_credential_chain(
VoiceProvider::Openai, VoiceProvider::Openai,
Some("voice-openai"), Some("voice-openai"),
"xai", "xai",
Some("text-xai"), Some("text-xai"),
Some("env-openai"),
); );
assert_eq!(dedicated.resolve(), Some("voice-openai")); assert_eq!(dedicated.resolve(), Some("voice-openai"));
} }

View File

@ -27,10 +27,19 @@ struct PreparedCall {
pub async fn call_ws( pub async fn call_ws(
State(state): State<AppState>, State(state): State<AppState>,
actor: Actor,
Path(session_id): Path<String>, Path(session_id): Path<String>,
ws: WebSocketUpgrade, ws: WebSocketUpgrade,
) -> axum::response::Response { ) -> axum::response::Response {
let actor = match state.bootstrap().await {
Ok(actor) => actor,
Err(_) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"message":"internal error"})),
)
.into_response();
}
};
match prepare_call(&state, &actor, &session_id).await { match prepare_call(&state, &actor, &session_id).await {
Ok(prep) => { Ok(prep) => {
let Some(lease) = state.calls.try_begin(&prep.bot_id, &prep.call_id) else { let Some(lease) = state.calls.try_begin(&prep.bot_id, &prep.call_id) else {

View File

@ -54,13 +54,18 @@ fn provider_info(provider: ModelProvider) -> ProviderInfo {
} }
} }
/// The model settings for one workspace. `apiKeySet` is the whole story now: async fn actor(state: &AppState) -> Result<Actor, StatusCode> {
/// a key exists only if this person pasted one here, and while it is false no state
/// agent can run. .bootstrap()
async fn settings_payload(state: &AppState, actor: &Actor) -> Result<Json<Value>, StatusCode> { .await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
}
async fn get_settings(State(state): State<AppState>) -> Result<Json<Value>, StatusCode> {
let actor = actor(&state).await?;
let space = state let space = state
.db .db
.get_space(actor) .get_space(&actor)
.await .await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::NOT_FOUND)?; .ok_or(StatusCode::NOT_FOUND)?;
@ -68,11 +73,17 @@ async fn settings_payload(state: &AppState, actor: &Actor) -> Result<Json<Value>
.default_model_provider .default_model_provider
.parse::<ModelProvider>() .parse::<ModelProvider>()
.unwrap_or(ModelProvider::Xai); .unwrap_or(ModelProvider::Xai);
let env_key_set = std::env::var(provider.env_key_name())
.ok()
.filter(|value| !value.is_empty())
.is_some();
Ok(Json(json!({ Ok(Json(json!({
"provider": provider.as_str(), "provider": provider.as_str(),
"modelId": space.default_model_id, "modelId": space.default_model_id,
"baseUrl": space.default_model_base_url.unwrap_or_default(), "baseUrl": space.default_model_base_url.unwrap_or_default(),
"apiKeySet": space.default_model_api_key.as_deref().is_some_and(|value| !value.is_empty()), "apiKeySet": space.default_model_api_key.as_deref().is_some_and(|value| !value.is_empty()),
"envKeySet": env_key_set,
"envKeyName": provider.env_key_name(),
"providers": ModelProvider::selectable().iter().copied().map(provider_info).collect::<Vec<_>>(), "providers": ModelProvider::selectable().iter().copied().map(provider_info).collect::<Vec<_>>(),
"models": catalog_models(provider).iter().map(|(id, name)| ModelChoice { id: (*id).into(), name: (*name).into() }).collect::<Vec<_>>(), "models": catalog_models(provider).iter().map(|(id, name)| ModelChoice { id: (*id).into(), name: (*name).into() }).collect::<Vec<_>>(),
}))) })))
@ -93,9 +104,9 @@ struct UpdateSettings {
async fn update_settings( async fn update_settings(
State(state): State<AppState>, State(state): State<AppState>,
actor: Actor,
Json(input): Json<UpdateSettings>, Json(input): Json<UpdateSettings>,
) -> Result<Json<Value>, StatusCode> { ) -> Result<Json<Value>, StatusCode> {
let actor = actor(&state).await?;
let provider = input let provider = input
.provider .provider
.parse::<ModelProvider>() .parse::<ModelProvider>()
@ -128,9 +139,9 @@ async fn update_settings(
.as_deref() .as_deref()
.map(str::trim) .map(str::trim)
.filter(|value| !value.is_empty()); .filter(|value| !value.is_empty());
// A stored key belongs to the provider it was entered for. Carrying one // A stored key belongs to the provider it was entered for. Carrying it
// over to a different provider only guarantees an "Incorrect API key" // over to a new provider shadows the env key and every run fails with
// error, so it is dropped unless a new key is supplied. // "Incorrect API key", so drop it unless a new one is supplied.
let api_key = if input.clear_api_key || (provider_changed && supplied.is_none()) { let api_key = if input.clear_api_key || (provider_changed && supplied.is_none()) {
Some(None) Some(None)
} else { } else {
@ -141,14 +152,7 @@ async fn update_settings(
.update_workspace_model(&actor, provider.as_str(), model_id, base_url, api_key) .update_workspace_model(&actor, provider.as_str(), model_id, base_url, api_key)
.await .await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
settings_payload(&state, &actor).await get_settings(State(state)).await
}
async fn get_settings(
actor: Actor,
State(state): State<AppState>,
) -> Result<Json<Value>, StatusCode> {
settings_payload(&state, &actor).await
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@ -161,9 +165,9 @@ struct ModelsQuery {
async fn list_models( async fn list_models(
State(state): State<AppState>, State(state): State<AppState>,
actor: Actor,
Query(query): Query<ModelsQuery>, Query(query): Query<ModelsQuery>,
) -> Result<Json<Value>, StatusCode> { ) -> Result<Json<Value>, StatusCode> {
let actor = actor(&state).await?;
let provider = query let provider = query
.provider .provider
.parse::<ModelProvider>() .parse::<ModelProvider>()
@ -176,6 +180,9 @@ async fn list_models(
name: (*name).into(), name: (*name).into(),
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let env_key = std::env::var(provider.env_key_name())
.ok()
.filter(|value| !value.is_empty());
let live = fetch_remote_models( let live = fetch_remote_models(
provider, provider,
query.base_url.as_deref().or(space query.base_url.as_deref().or(space
@ -183,7 +190,8 @@ async fn list_models(
.and_then(|row| row.default_model_base_url.as_deref())), .and_then(|row| row.default_model_base_url.as_deref())),
space space
.as_ref() .as_ref()
.and_then(|row| row.default_model_api_key.as_deref()), .and_then(|row| row.default_model_api_key.as_deref())
.or(env_key.as_deref()),
) )
.await .await
.unwrap_or_default(); .unwrap_or_default();

View File

@ -24,6 +24,16 @@ impl ModelProvider {
} }
} }
pub fn env_key_name(self) -> &'static str {
match self {
Self::Xai => "XAI_API_KEY",
Self::OpencodeGo => "OPENCODE_GO_API_KEY",
Self::OpenaiCompatible | Self::Openai => "OPENAI_API_KEY",
Self::Anthropic => "ANTHROPIC_API_KEY",
Self::Openrouter => "OPENROUTER_API_KEY",
}
}
pub fn requires_api_key(self) -> bool { pub fn requires_api_key(self) -> bool {
!matches!(self, Self::OpenaiCompatible) !matches!(self, Self::OpenaiCompatible)
} }

View File

@ -19,6 +19,14 @@ impl VoiceProvider {
} }
} }
pub fn env_key_name(self) -> &'static str {
match self {
Self::Xai => "XAI_API_KEY",
Self::Openai => "OPENAI_API_KEY",
Self::Scripted => "",
}
}
pub fn requires_api_key(self) -> bool { pub fn requires_api_key(self) -> bool {
!matches!(self, Self::Scripted) !matches!(self, Self::Scripted)
} }

View File

@ -7,8 +7,8 @@ use thiserror::Error;
pub enum ModelError { pub enum ModelError {
#[error("unsupported_provider:{provider}")] #[error("unsupported_provider:{provider}")]
UnsupportedProvider { provider: String }, UnsupportedProvider { provider: String },
#[error("missing credential for {provider}: add the API key in the workspace settings")] #[error("missing credential for {provider} ({env_key})")]
MissingCredential { provider: String }, MissingCredential { provider: String, env_key: String },
#[error("missing base URL for {provider}")] #[error("missing base URL for {provider}")]
MissingBaseUrl { provider: String }, MissingBaseUrl { provider: String },
#[error("unknown model provider: {0}")] #[error("unknown model provider: {0}")]
@ -17,14 +17,11 @@ pub enum ModelError {
ProviderClient(String), ProviderClient(String),
} }
/// Keys a run may use. Both levels come from inside LazyBoy — a bot's own
/// vault entry, then the workspace setting — and nothing is read from the
/// process environment, so an unset key is a clear "configure it" error rather
/// than a mystery key somebody exported in a shell.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct CredentialChain { pub struct CredentialChain {
pub bot: Option<String>, pub bot: Option<String>,
pub space: Option<String>, pub space: Option<String>,
pub env: Option<String>,
} }
impl CredentialChain { impl CredentialChain {
@ -33,6 +30,7 @@ impl CredentialChain {
.as_deref() .as_deref()
.filter(|value| !value.is_empty()) .filter(|value| !value.is_empty())
.or(self.space.as_deref().filter(|value| !value.is_empty())) .or(self.space.as_deref().filter(|value| !value.is_empty()))
.or(self.env.as_deref().filter(|value| !value.is_empty()))
} }
} }
@ -69,6 +67,7 @@ pub fn resolve_backend(request: ResolveModelRequest) -> Result<ResolvedBackend,
None if request.provider.requires_api_key() => { None if request.provider.requires_api_key() => {
return Err(ModelError::MissingCredential { return Err(ModelError::MissingCredential {
provider: request.provider.as_str().to_string(), provider: request.provider.as_str().to_string(),
env_key: request.provider.env_key_name().to_string(),
}); });
} }
None => String::new(), None => String::new(),
@ -128,35 +127,7 @@ fn uses_responses_api(provider: ModelProvider, model_id: &str) -> bool {
&& (id.starts_with("gpt-") || id.starts_with("grok-") || id.starts_with("muse-")) && (id.starts_with("gpt-") || id.starts_with("grok-") || id.starts_with("muse-"))
} }
/// Headers OpenCode Go asks every client for: a self-identifying user agent pub fn connect_model(backend: &ResolvedBackend) -> Result<DynModel, ModelError> {
/// and a stable per-conversation id so it can route to the same backend and
/// keep the prompt cache warm. Requests without them are refused
/// (`MissingSessionID`). Harmless for a plain OpenAI-compatible server, so the
/// same headers go on both.
pub fn provider_headers(provider: ModelProvider, session: &str) -> http::HeaderMap {
let mut headers = http::HeaderMap::new();
if !matches!(
provider,
ModelProvider::OpencodeGo | ModelProvider::OpenaiCompatible
) {
return headers;
}
headers.insert(
http::header::USER_AGENT,
http::HeaderValue::from_static(concat!("lazyboy/", env!("CARGO_PKG_VERSION"))),
);
let session = session.trim();
if !session.is_empty()
&& let Ok(value) = http::HeaderValue::from_str(session)
{
headers.insert(http::HeaderName::from_static("x-opencode-session"), value);
}
headers
}
/// `session` is the conversation the model is being used for (thread, room,
/// or bot id); it only has to be stable across the requests of one exchange.
pub fn connect_model(backend: &ResolvedBackend, session: &str) -> Result<DynModel, ModelError> {
match backend.provider { match backend.provider {
ModelProvider::Xai => { ModelProvider::Xai => {
let client = xai::Client::new(&backend.api_key) let client = xai::Client::new(&backend.api_key)
@ -169,12 +140,10 @@ pub fn connect_model(backend: &ResolvedBackend, session: &str) -> Result<DynMode
} else { } else {
backend.api_key.as_str() backend.api_key.as_str()
}; };
let headers = provider_headers(backend.provider, session);
if uses_responses_api(backend.provider, &backend.model_id) { if uses_responses_api(backend.provider, &backend.model_id) {
let client = openai::Client::builder() let client = openai::Client::builder()
.api_key(key.to_string()) .api_key(key.to_string())
.base_url(&backend.base_url) .base_url(&backend.base_url)
.http_headers(headers)
.build() .build()
.map_err(|error| ModelError::ProviderClient(error.to_string()))?; .map_err(|error| ModelError::ProviderClient(error.to_string()))?;
Ok(DynModel::OpenAiResponses( Ok(DynModel::OpenAiResponses(
@ -184,7 +153,6 @@ pub fn connect_model(backend: &ResolvedBackend, session: &str) -> Result<DynMode
let client = openai::CompletionsClient::builder() let client = openai::CompletionsClient::builder()
.api_key(key.to_string()) .api_key(key.to_string())
.base_url(&backend.base_url) .base_url(&backend.base_url)
.http_headers(headers)
.build() .build()
.map_err(|error| ModelError::ProviderClient(error.to_string()))?; .map_err(|error| ModelError::ProviderClient(error.to_string()))?;
Ok(DynModel::OpenAi(client.completion_model(&backend.model_id))) Ok(DynModel::OpenAi(client.completion_model(&backend.model_id)))
@ -196,6 +164,12 @@ pub fn connect_model(backend: &ResolvedBackend, session: &str) -> Result<DynMode
} }
} }
pub fn credential_from_env(provider: ModelProvider) -> Option<String> {
std::env::var(provider.env_key_name())
.ok()
.filter(|value| !value.is_empty())
}
/// Prove the xAI Rig client can be constructed from a resolved backend. /// Prove the xAI Rig client can be constructed from a resolved backend.
pub fn connect_xai( pub fn connect_xai(
backend: &ResolvedBackend, backend: &ResolvedBackend,
@ -221,7 +195,8 @@ mod tests {
base_url: None, base_url: None,
credentials: CredentialChain { credentials: CredentialChain {
bot: None, bot: None,
space: key.map(str::to_string), space: None,
env: key.map(str::to_string),
}, },
} }
} }
@ -237,7 +212,7 @@ mod tests {
} }
#[test] #[test]
fn credentials_prefer_the_bot_key_over_the_workspace_key() { fn credentials_prefer_bot_then_space_then_env() {
let backend = resolve_backend(ResolveModelRequest { let backend = resolve_backend(ResolveModelRequest {
provider: ModelProvider::Xai, provider: ModelProvider::Xai,
model_id: Some("grok-4.6".into()), model_id: Some("grok-4.6".into()),
@ -245,6 +220,7 @@ mod tests {
credentials: CredentialChain { credentials: CredentialChain {
bot: Some("bot-key".into()), bot: Some("bot-key".into()),
space: Some("space-key".into()), space: Some("space-key".into()),
env: Some("env-key".into()),
}, },
}) })
.unwrap(); .unwrap();
@ -270,7 +246,8 @@ mod tests {
base_url: None, base_url: None,
credentials: CredentialChain { credentials: CredentialChain {
bot: None, bot: None,
space: Some("sk-test".into()), space: None,
env: Some("sk-test".into()),
}, },
}) })
.unwrap_err(); .unwrap_err();
@ -297,29 +274,14 @@ mod tests {
base_url: None, base_url: None,
credentials: CredentialChain { credentials: CredentialChain {
bot: None, bot: None,
space: Some("go-key".into()), space: None,
env: Some("go-key".into()),
}, },
}) })
.unwrap(); .unwrap();
assert_eq!(backend.model_id, "glm-5.1"); assert_eq!(backend.model_id, "glm-5.1");
assert_eq!(backend.base_url, "https://opencode.ai/zen/go/v1"); assert_eq!(backend.base_url, "https://opencode.ai/zen/go/v1");
assert!(connect_model(&backend, "thread-1").is_ok()); assert!(connect_model(&backend).is_ok());
}
#[test]
fn opencode_go_identifies_the_client_and_the_conversation() {
let headers = provider_headers(ModelProvider::OpencodeGo, "thread-42");
assert_eq!(headers["x-opencode-session"], "thread-42");
assert!(
headers[http::header::USER_AGENT]
.to_str()
.unwrap()
.starts_with("lazyboy/")
);
// No conversation id is better than a made-up or malformed one.
assert!(!provider_headers(ModelProvider::OpencodeGo, " ").contains_key("x-opencode-session"));
assert!(!provider_headers(ModelProvider::OpencodeGo, "bad\nvalue").contains_key("x-opencode-session"));
assert!(provider_headers(ModelProvider::Xai, "thread-42").is_empty());
} }
#[test] #[test]
@ -345,12 +307,13 @@ mod tests {
credentials: CredentialChain { credentials: CredentialChain {
bot: None, bot: None,
space: None, space: None,
env: None,
}, },
}) })
.unwrap(); .unwrap();
assert_eq!(backend.api_key, ""); assert_eq!(backend.api_key, "");
assert_eq!(backend.base_url, "http://127.0.0.1:8000/v1"); assert_eq!(backend.base_url, "http://127.0.0.1:8000/v1");
assert!(connect_model(&backend, "thread-1").is_ok()); assert!(connect_model(&backend).is_ok());
} }
#[test] #[test]
@ -362,6 +325,7 @@ mod tests {
credentials: CredentialChain { credentials: CredentialChain {
bot: None, bot: None,
space: None, space: None,
env: None,
}, },
}) })
.unwrap_err(); .unwrap_err();

View File

@ -18,8 +18,8 @@ use crate::{CredentialChain, ModelError};
pub enum VoiceError { pub enum VoiceError {
#[error("{0}")] #[error("{0}")]
Message(String), Message(String),
#[error("missing credential for {provider}: add the API key in the workspace settings")] #[error("missing credential for {provider} ({env_key})")]
MissingCredential { provider: String }, MissingCredential { provider: String, env_key: String },
#[error("unknown voice provider: {0}")] #[error("unknown voice provider: {0}")]
UnknownProvider(String), UnknownProvider(String),
} }
@ -27,7 +27,9 @@ pub enum VoiceError {
impl From<ModelError> for VoiceError { impl From<ModelError> for VoiceError {
fn from(error: ModelError) -> Self { fn from(error: ModelError) -> Self {
match error { match error {
ModelError::MissingCredential { provider } => Self::MissingCredential { provider }, ModelError::MissingCredential { provider, env_key } => {
Self::MissingCredential { provider, env_key }
}
other => Self::Message(other.to_string()), other => Self::Message(other.to_string()),
} }
} }
@ -88,6 +90,7 @@ pub fn resolve_voice(request: ResolveVoiceRequest) -> Result<ResolvedVoice, Voic
None if request.provider.requires_api_key() => { None if request.provider.requires_api_key() => {
return Err(VoiceError::MissingCredential { return Err(VoiceError::MissingCredential {
provider: request.provider.as_str().to_string(), provider: request.provider.as_str().to_string(),
env_key: request.provider.env_key_name().to_string(),
}); });
} }
None => String::new(), None => String::new(),
@ -664,6 +667,7 @@ mod tests {
credentials: CredentialChain { credentials: CredentialChain {
bot: None, bot: None,
space: None, space: None,
env: None,
}, },
}); });
assert!(matches!(missing, Err(VoiceError::MissingCredential { .. }))); assert!(matches!(missing, Err(VoiceError::MissingCredential { .. })));
@ -675,6 +679,7 @@ mod tests {
credentials: CredentialChain { credentials: CredentialChain {
bot: None, bot: None,
space: Some("sk".into()), space: Some("sk".into()),
env: None,
}, },
}) })
.unwrap(); .unwrap();

View File

@ -82,13 +82,16 @@ services:
DATABASE_URL: postgres://lazyboy:${POSTGRES_PASSWORD:-lazyboy}@postgres:5432/lazyboy DATABASE_URL: postgres://lazyboy:${POSTGRES_PASSWORD:-lazyboy}@postgres:5432/lazyboy
SANDBOX_SUPERVISOR_URL: http://supervisor:7091 SANDBOX_SUPERVISOR_URL: http://supervisor:7091
SANDBOX_SUPERVISOR_TOKEN: ${SANDBOX_SUPERVISOR_TOKEN:?Set SANDBOX_SUPERVISOR_TOKEN in .env} SANDBOX_SUPERVISOR_TOKEN: ${SANDBOX_SUPERVISOR_TOKEN:?Set SANDBOX_SUPERVISOR_TOKEN in .env}
LAZYBOY_APP_TOKEN: ${LAZYBOY_APP_TOKEN:?Set LAZYBOY_APP_TOKEN in .env}
LAZYBOY_VAULT_KEY: ${LAZYBOY_VAULT_KEY:-} LAZYBOY_VAULT_KEY: ${LAZYBOY_VAULT_KEY:-}
LAZYBOY_SECURE_COOKIE: ${LAZYBOY_SECURE_COOKIE:-false} LAZYBOY_SECURE_COOKIE: ${LAZYBOY_SECURE_COOKIE:-false}
LAZYBOY_ALLOWED_HOSTS: ${LAZYBOY_ALLOWED_HOSTS:-}
SANDBOX_PROVIDER: docker SANDBOX_PROVIDER: docker
DATA_DIR: /data DATA_DIR: /data
HOST_DATA_DIR: ${LAZYBOY_HOST_DATA_DIR:-${PWD}/data} HOST_DATA_DIR: ${LAZYBOY_HOST_DATA_DIR:-${PWD}/data}
API_BIND: 0.0.0.0:3100 API_BIND: 0.0.0.0:3100
XAI_API_KEY: ${XAI_API_KEY:-}
OPENCODE_GO_API_KEY: ${OPENCODE_GO_API_KEY:-}
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
LAZYBOY_MEMORY_ENABLED: ${LAZYBOY_MEMORY_ENABLED:-true} LAZYBOY_MEMORY_ENABLED: ${LAZYBOY_MEMORY_ENABLED:-true}
LAZYBOY_MEMORY_MODEL_CACHE: /data/fastembed LAZYBOY_MEMORY_MODEL_CACHE: /data/fastembed
LAZYBOY_MEMORY_TOP_K: ${LAZYBOY_MEMORY_TOP_K:-8} LAZYBOY_MEMORY_TOP_K: ${LAZYBOY_MEMORY_TOP_K:-8}

View File

@ -111,7 +111,7 @@ builder 並註冊 binfmt主機本身的架構會跳過註冊否則在特
`CHROME=` 指定,否則依序找 PATH 上的 chromiumChrome 與 Playwright 下載的 chrome `CHROME=` 指定,否則依序找 PATH 上的 chromiumChrome 與 Playwright 下載的 chrome
```bash ```bash
# 1. 對運行中的 stack 擷取真實畫面(帳號密碼用 LB_USERLB_PASS帳號不存在時自動註冊LB_URL 指定 stack # 1. 對運行中的 stack 擷取真實畫面(登入 token 讀 .env可用 LB_TOKENLB_URL 覆寫
# 群組視圖的右欄是機器人自己的瀏覽器,進圖前先用 --hide 遮掉,再補一張中立的上去; # 群組視圖的右欄是機器人自己的瀏覽器,進圖前先用 --hide 遮掉,再補一張中立的上去;
# --anchor 會印出被遮區塊在圖上的位置hero.html 的圖層座標就是從這裡來的。 # --anchor 會印出被遮區塊在圖上的位置hero.html 的圖層座標就是從這裡來的。
node scripts/capture-hero.mjs --out docs/hero/agent.png node scripts/capture-hero.mjs --out docs/hero/agent.png

View File

@ -6,13 +6,11 @@
目前程式碼包含下列防護: 目前程式碼包含下列防護:
- 登入是帳號密碼:資料庫只存 PBKDF2-HMAC-SHA256 派生值120,000 輪,每組密碼各自的 salt不存明文登入 session token 與 Supervisor token 分離,資料庫裡也只存 session 的雜湊。 - API session token 與 Supervisor token 分離。
- 帳號互相隔離Agent、對話、排程、憑證庫與模型金鑰都以帳號自己的 workspace 為界A 讀不到也改不到 B 的資料。
- `Host` 檢查抵禦 DNS rebindingIP 與 `localhost` 直接接受,其他網域名稱一定要列在 `LAZYBOY_ALLOWED_HOSTS`
- Supervisor 只在內部 Compose network並使用 `no-new-privileges`、唯讀 root filesystem 與 capability drop。 - Supervisor 只在內部 Compose network並使用 `no-new-privileges`、唯讀 root filesystem 與 capability drop。
- 每台電腦有 CPU、RAM、PID 上限;預設 2 CPU、2 GB、2048 PID。 - 每台電腦有 CPU、RAM、PID 上限;預設 2 CPU、2 GB、2048 PID。
- API 預設只綁定 `127.0.0.1:3101` - API 預設只綁定 `127.0.0.1:3101`
- 憑證以獨立 `LAZYBOY_VAULT_KEY` 加密,改帳號密碼不影響此 key輪替 `LAZYBOY_VAULT_KEY` 會讀不到已加密的密碼庫 - 憑證以獨立 `LAZYBOY_VAULT_KEY` 加密,輪替登入 token 時不應更換此 key
- 已保存登入只接受 HTTPS、精確或合法子網域匹配不對相似惡意網域填入。 - 已保存登入只接受 HTTPS、精確或合法子網域匹配不對相似惡意網域填入。
- Markdown 連結限制為 HTTP(S)、`mailto:`、`tel:` 與頁內錨點。 - Markdown 連結限制為 HTTP(S)、`mailto:`、`tel:` 與頁內錨點。
- 日誌有大小與檔案數上限;診斷資料有可設定的保留週期。 - 日誌有大小與檔案數上限;診斷資料有可設定的保留週期。
@ -21,10 +19,9 @@
1. 對區網或網際網路開放前,先放在 HTTPS reverse proxy 後方,並設定 `LAZYBOY_SECURE_COOKIE=true` 1. 對區網或網際網路開放前,先放在 HTTPS reverse proxy 後方,並設定 `LAZYBOY_SECURE_COOKIE=true`
2. 不要把 Supervisor `:7091` 對外發布,也不要將 Docker socket 掛進 Agent 電腦。 2. 不要把 Supervisor `:7091` 對外發布,也不要將 Docker socket 掛進 Agent 電腦。
3. `SANDBOX_SUPERVISOR_TOKEN` 與 `LAZYBOY_VAULT_KEY` 必須使用不同的高熵值。 3. `LAZYBOY_APP_TOKEN`、`SANDBOX_SUPERVISOR_TOKEN`、`LAZYBOY_VAULT_KEY` 必須使用不同的高熵值。
4. 模型仍可能看見任務所需的網頁內容與截圖密碼、token 與高敏感資料不要放進提示詞。 4. 模型仍可能看見任務所需的網頁內容與截圖密碼、token 與高敏感資料不要放進提示詞。
5. 簡單的 Cloudflare 連線驗證可嘗試一次正常點擊;未通過、其他 CAPTCHA 與 2FA 交由使用者接管。 5. 簡單的 Cloudflare 連線驗證可嘗試一次正常點擊;未通過、其他 CAPTCHA 與 2FA 交由使用者接管。
6. 模型 API 金鑰只吃各 workspace 在「設定 → 模型」裡貼上的值,不再讀環境變數;沒設定金鑰的 workspace 跑任務會立刻失敗,並在畫面告訴你要去哪裡補。
## 硬體與資源 ## 硬體與資源
@ -46,7 +43,10 @@
| 變數 | 用途 | 預設 | | 變數 | 用途 | 預設 |
| --- | --- | --- | | --- | --- | --- |
| `LAZYBOY_ALLOWED_HOSTS` | 允許以網域名稱連入的主機名逗號分隔IP 與 `localhost` 不需要列 | 空 | | `XAI_API_KEY` | xAI 模型 | 空 |
| `OPENCODE_GO_API_KEY` | OpenCode Go 模型 | 空 |
| `OPENAI_API_KEY` | OpenAI 相容端點 | 空 |
| `LAZYBOY_APP_TOKEN` | Web 登入 token | 必填 |
| `SANDBOX_SUPERVISOR_TOKEN` | API ↔ Supervisor 驗證 | 必填 | | `SANDBOX_SUPERVISOR_TOKEN` | API ↔ Supervisor 驗證 | 必填 |
| `LAZYBOY_VAULT_KEY` | 憑證庫加密 key | 必填且必須保持穩定 | | `LAZYBOY_VAULT_KEY` | 憑證庫加密 key | 必填且必須保持穩定 |
| `LAZYBOY_BIND_IP` | 主機監聽位址 | `127.0.0.1` | | `LAZYBOY_BIND_IP` | 主機監聽位址 | `127.0.0.1` |
@ -73,7 +73,7 @@ LAZYBOY_BIND_IP=10.0.33.1 # 只開放指定網卡
docker compose up -d api docker compose up -d api
``` ```
- 每台裝置各自註冊、各自登入;`Host` 為 IP 或 `localhost` 時可直接使用,改用網域名稱進入時要把名稱加進 `LAZYBOY_ALLOWED_HOSTS`,否則會被 `403 invalid host` 擋下 - 綁非 loopback 時 `LAZYBOY_APP_TOKEN` 必須至少 32 字元,否則 api 拒絕啟動
- Origin 檢查比對 `Origin``Host`:直接開 `http://<主機IP>:3101` 可正常使用,從其他網域嵌入會被 `403 cross-origin request rejected` 擋下。 - Origin 檢查比對 `Origin``Host`:直接開 `http://<主機IP>:3101` 可正常使用,從其他網域嵌入會被 `403 cross-origin request rejected` 擋下。
- 純 HTTP 下 session cookie 以明碼走區網;長期或跨網際網路使用請放到 HTTPS reverse proxy 後面,並設定 `LAZYBOY_SECURE_COOKIE=true` - 純 HTTP 下 session cookie 以明碼走區網;長期或跨網際網路使用請放到 HTTPS reverse proxy 後面,並設定 `LAZYBOY_SECURE_COOKIE=true`
- 暫時性跨網存取建議維持 `127.0.0.1` 綁定改用隧道:`ssh -L 3101:127.0.0.1:3101 <host>`。 - 暫時性跨網存取建議維持 `127.0.0.1` 綁定改用隧道:`ssh -L 3101:127.0.0.1:3101 <host>`。

View File

@ -1,29 +0,0 @@
-- Accounts: every person signs in with their own username and password, and
-- the agents they make belong to them alone.
--
-- Before this, one shared token from `.env` opened the whole install, and one
-- hard-coded local user owned every agent. Login is now per person, so a
-- session resolves to exactly one user and one workspace, and the shared token
-- is gone. `password_hash IS NULL` marks an account that cannot sign in yet.
ALTER TABLE users
ADD COLUMN IF NOT EXISTS username TEXT,
ADD COLUMN IF NOT EXISTS password_hash TEXT;
-- Usernames are addresses: one spelling, matched case-insensitively.
CREATE UNIQUE INDEX IF NOT EXISTS users_username_key
ON users (lower(username))
WHERE username IS NOT NULL;
-- Server-side sessions. Only the digest of the cookie value is stored, so a
-- database leak cannot be replayed as a login, and an API restart no longer
-- logs everyone out.
CREATE TABLE IF NOT EXISTS app_sessions (
token_hash TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS app_sessions_user_idx ON app_sessions (user_id);
CREATE INDEX IF NOT EXISTS app_sessions_expiry_idx ON app_sessions (expires_at);

View File

@ -25,8 +25,7 @@
// //
// Environment: // Environment:
// LB_URL stack to photograph (default http://127.0.0.1:3101) // LB_URL stack to photograph (default http://127.0.0.1:3101)
// LB_USER account to sign in as (default: hero) // LB_TOKEN login token (default: LAZYBOY_APP_TOKEN from .env)
// LB_PASS that account's password (default below; registered on demand)
// CHROME browser binary (default: discovered, see findChrome) // CHROME browser binary (default: discovered, see findChrome)
import { spawn } from 'node:child_process'; import { spawn } from 'node:child_process';
import fs from 'node:fs'; import fs from 'node:fs';
@ -81,32 +80,14 @@ function findChrome() {
throw new Error('no Chromium found; set CHROME=/path/to/chrome'); throw new Error('no Chromium found; set CHROME=/path/to/chrome');
} }
// Screenshots are taken against a throwaway stack, so the script is allowed to function readToken() {
// create the account it photographs; LB_USER/LB_PASS override both halves. if (process.env.LB_TOKEN) return process.env.LB_TOKEN;
function credentials() { const file = path.join(root, '.env');
return { const match = fs.existsSync(file) ? fs.readFileSync(file, 'utf8').match(/^LAZYBOY_APP_TOKEN=(.+)$/m) : null;
username: (process.env.LB_USER || 'hero').trim().toLowerCase(), if (!match) throw new Error('no login token; set LB_TOKEN or create .env with make env');
password: process.env.LB_PASS || 'lazyboy-hero-2026', return match[1].trim();
};
} }
// Evaluated inside the page so the session cookie lands in the browser that
// takes the screenshot. An unknown account is registered instead of failing,
// which keeps a freshly started stack photographable without setup.
const SIGN_IN_HELPER = String.raw`async function signIn({ username, password }) {
const call = async (path) => {
const response = await fetch(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ username, password }),
});
return response.status;
};
const status = await call('/api/auth/login');
return status === 401 ? call('/api/auth/register') : status;
}`;
async function waitForVersion(port) { async function waitForVersion(port) {
for (let attempt = 0; attempt < 60; attempt += 1) { for (let attempt = 0; attempt < 60; attempt += 1) {
try { try {
@ -226,7 +207,7 @@ try {
const status = await connection.send( const status = await connection.send(
'Runtime.evaluate', 'Runtime.evaluate',
{ {
expression: `${SIGN_IN_HELPER}\nsignIn(${JSON.stringify(credentials())})`, expression: `fetch('/api/session',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token:${JSON.stringify(readToken())}})}).then(r=>r.status)`,
awaitPromise: true, awaitPromise: true,
returnByValue: true, returnByValue: true,
}, },

View File

@ -18,6 +18,7 @@ if [[ -f "$root/.env" ]]; then
fi fi
export DATABASE_URL="${DATABASE_URL:-postgres://lazyboy:lazyboy@127.0.0.1:5434/lazyboy}" export DATABASE_URL="${DATABASE_URL:-postgres://lazyboy:lazyboy@127.0.0.1:5434/lazyboy}"
: "${SANDBOX_SUPERVISOR_TOKEN:?Set a random SANDBOX_SUPERVISOR_TOKEN of at least 32 characters in .env}" : "${SANDBOX_SUPERVISOR_TOKEN:?Set a random SANDBOX_SUPERVISOR_TOKEN of at least 32 characters in .env}"
: "${LAZYBOY_APP_TOKEN:?Set a random LAZYBOY_APP_TOKEN of at least 32 characters in .env}"
export SANDBOX_SUPERVISOR_URL="${SANDBOX_SUPERVISOR_URL:-http://127.0.0.1:7091}" export SANDBOX_SUPERVISOR_URL="${SANDBOX_SUPERVISOR_URL:-http://127.0.0.1:7091}"
export SANDBOX_PROVIDER="${SANDBOX_PROVIDER:-docker}" export SANDBOX_PROVIDER="${SANDBOX_PROVIDER:-docker}"
export DATA_DIR="${DATA_DIR:-$root/data}" export DATA_DIR="${DATA_DIR:-$root/data}"
@ -25,5 +26,5 @@ export API_BIND="${API_BIND:-0.0.0.0:3101}"
export LAZYBOY_WEB_DIR="$root/apps/web" export LAZYBOY_WEB_DIR="$root/apps/web"
mkdir -p "$DATA_DIR" mkdir -p "$DATA_DIR"
echo "start supervisor in another terminal with the same .env: cargo run -p lazyboy-supervisor" echo "start supervisor in another terminal with the same .env: cargo run -p lazyboy-supervisor"
echo "then: cargo run -p lazyboy-api (register the first account in the browser)" echo "then: cargo run -p lazyboy-api"
echo "listening on 0.0.0.0:3101" echo "listening on 0.0.0.0:3101"

View File

@ -7,7 +7,7 @@ path=Path('.env')
if path.exists(): if path.exists():
print('.env already exists; existing credentials were preserved.') print('.env already exists; existing credentials were preserved.')
else: else:
values={key:secrets.token_hex(32) for key in ('SANDBOX_SUPERVISOR_TOKEN','LAZYBOY_VAULT_KEY','POSTGRES_PASSWORD')} values={key:secrets.token_hex(32) for key in ('LAZYBOY_APP_TOKEN','SANDBOX_SUPERVISOR_TOKEN','LAZYBOY_VAULT_KEY','POSTGRES_PASSWORD')}
text=Path('.env.example').read_text() text=Path('.env.example').read_text()
lines=[] lines=[]
for line in text.splitlines(): for line in text.splitlines():
@ -18,5 +18,4 @@ else:
with path.open('x') as out: with path.open('x') as out:
path.chmod(0o600) path.chmod(0o600)
out.write('\n'.join(lines)+'\n') out.write('\n'.join(lines)+'\n')
print('Created .env with independent supervisor, vault and database secrets.') print('Created .env with independent app, supervisor, vault and database secrets. Set your model API key next.')
print('Sign-in is now a username and password: open the app and register the first account, then paste your model API key in 設定 → 模型.')

View File

@ -305,7 +305,7 @@ const monitorBox={exports:{},require:name=>{
}}; }};
vm.runInNewContext(monitorJs,monitorBox); vm.runInNewContext(monitorJs,monitorBox);
const {formatElapsed,shortDuration,errorActions,errorTitle,trailText}=monitorBox.exports; const {formatElapsed,shortDuration,errorActions,errorTitle,trailText}=monitorBox.exports;
const FAILURE_CODES=['interrupted','tool_timeout','model_key','model_key_missing','model_opt_in','model_quota','model_unknown','model_timeout','network','computer_gone','lease_lost','unknown']; const FAILURE_CODES=['interrupted','tool_timeout','model_key','model_quota','model_unknown','model_timeout','network','computer_gone','lease_lost','unknown'];
test('run timings stay on one glanceable line',()=>{ test('run timings stay on one glanceable line',()=>{
assert.equal(formatElapsed(0),'0:00'); assert.equal(formatElapsed(0),'0:00');
@ -368,21 +368,6 @@ test('trail lines read as sentences with the detail a stuck run needs',()=>{
assert.match(trailText({id:9,kind:'notice',createdAt:'',text:'這輪不需要電腦'}),/這輪不需要電腦/); assert.match(trailText({id:9,kind:'notice',createdAt:'',text:'這輪不需要電腦'}),/這輪不需要電腦/);
}); });
test('meeting mode is a desktop layout switch with a toggle in the title bar',()=>{
const app=fs.readFileSync('apps/web/src/App.tsx','utf8');
assert.match(app,/meeting-mode/);
assert.match(app,/meeting-toggle/);
assert.match(app,/function toggleMeeting/);
assert.match(app,/className=\{`meeting-stage/);
const css=fs.readFileSync('apps/web/src/responsive.css','utf8');
assert.match(css,/meeting-toggle\{display:none!important\}/);
assert.match(css,/\.meeting-stage\{display:none!important\}/);
assert.match(app,/part==="computer"&&isPhoneLayout\(\)/);
assert.match(app,/createPortal/);
const computerCss=fs.readFileSync('apps/web/src/computer.css','utf8');
assert.match(computerCss,/\.meeting-stage\{display:none!important\}/);
});
test('chat messages use a copy-or-reply menu instead of inline run details',()=>{ test('chat messages use a copy-or-reply menu instead of inline run details',()=>{
const app=fs.readFileSync('apps/web/src/App.tsx','utf8'); const app=fs.readFileSync('apps/web/src/App.tsx','utf8');
assert.match(app,/function MessageContextMenu/); assert.match(app,/function MessageContextMenu/);
@ -406,7 +391,6 @@ test('the bubble reads the run activity endpoint the API actually mounts',()=>{
assert.match(app,/errorActions\(chip\.code\)/); assert.match(app,/errorActions\(chip\.code\)/);
assert.match(app,/`\/api\/runs\/\$\{runId\}\/retry`,\{method:"POST",body:"\{\}"\}/); assert.match(app,/`\/api\/runs\/\$\{runId\}\/retry`,\{method:"POST",body:"\{\}"\}/);
assert.match(app,/<RunProbe runId=\{chip\.runId\|\|null\} align="end" label=\{t\("errorDetails"\)\}/); assert.match(app,/<RunProbe runId=\{chip\.runId\|\|null\} align="end" label=\{t\("errorDetails"\)\}/);
assert.match(app,/<RunProbe runId=\{member\.id===computer\.botId\?computer\.busyRunId:null\}><Avatar/);
assert.match(app,/<RunProbe runId=\{computer\.busyRunId\|\|computer\.waitingRunId\}>/); assert.match(app,/<RunProbe runId=\{computer\.busyRunId\|\|computer\.waitingRunId\}>/);
assert.doesNotMatch(app,/message-run-details/); assert.doesNotMatch(app,/message-run-details/);
assert.match(app,/className="thinking-row"/); assert.match(app,/className="thinking-row"/);