fix frontend css issue

This commit is contained in:
daniel wang 2026-09-09 16:59:32 +00:00
parent 7f12c5f944
commit 0e56438f1c
51 changed files with 1842 additions and 676 deletions

View File

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

View File

@ -2,6 +2,46 @@
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
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,6 +1899,7 @@ dependencies = [
"fastembed",
"futures-util",
"hex",
"hmac",
"http",
"lazyboy-contracts",
"lazyboy-control",

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

@ -70,3 +70,6 @@ export function Upload({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>;
}
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,12 +1,15 @@
export class ApiError extends Error {
status:number;
constructor(status:number,message:string){super(message);this.name="ApiError";this.status=status}
/** Stable machine code from the API (`username_taken`, `unauthenticated`). */
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>{
const response=await fetch(path,{...options,headers:{"content-type":"application/json",...options.headers}});
// The login cookie is first-party and HttpOnly, so every call has to carry it.
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;
try{body=text?JSON.parse(text):null}catch{body={message:text}}
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)}
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:"")}
return body as T;
}

View File

@ -18,6 +18,15 @@
.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}
.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.hidden-part{display:none}
.computer-status-row{display:flex;align-items:center;gap:8px;color:var(--muted)}
@ -76,4 +85,12 @@
.computer-overlay{top:var(--visible-top,0px);bottom:auto;height:var(--visible-height,100dvh)}
.computer-overlay>header{flex-shrink:0}
.overlay-error{position:static;transform:none;flex:none;margin:0 8px 8px}
@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}}
@media(max-width:700px){
.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,7 +333,6 @@ export const en: { [K in keyof typeof zhTW]: string } = {
apiKey: "API key",
apiKeyPlaceholder: "sk-…",
apiKeyStored: "A key is saved. Leave blank to keep it.",
usingEnvKey: "Using environment variable {name}.",
clearApiKey: "Clear saved key",
modelId: "Model",
modelIdPlaceholder: "For example: qwen2.5 or llama3.1",
@ -353,6 +352,10 @@ 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.",
helpComputerTitle: "Computer",
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",
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",
@ -395,11 +398,32 @@ 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.",
feedbackPlaceholder: "For example: in group chats I want to pick who speaks first…",
copyContent: "Copy",
loginTitle: "Sign in to LazyBoy",
loginDescription: "Enter the shared access token configured on the server.",
loginTitle: "Sign in",
loginDescription: "Use the account you registered on this server. Your agents, chats, and computers stay yours alone.",
accessToken: "Access token",
verifying: "Checking…",
verifying: "Working…",
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",
url: "URL",
stdio: "stdio",
@ -536,6 +560,8 @@ export const en: { [K in keyof typeof zhTW]: string } = {
errorTitleInterrupted: "Interrupted",
errorTitleToolTimeout: "Action timed out",
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",
errorTitleModelUnknown: "Model not found",
errorTitleModelTimeout: "Model took too long",

View File

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

View File

@ -482,6 +482,34 @@
.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}
@ -579,23 +607,44 @@
.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%;place-items:center;padding:88px 20px;background:radial-gradient(circle at 50% 20%,#18201e 0,#080809 55%)}
.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-dialog{display:grid;justify-items:stretch}
.login-dialog{width:min(320px,100%);display:grid;justify-items:stretch;gap:14px}
.login-dialog>.avatar{justify-self:center}
.login-dialog>.avatar{justify-self:center;margin:0 0 10px}
.login-dialog h1,.login-dialog p{text-align:center}
.login-dialog h1{margin:0 0 8px;text-align:center;font-size:20px;font-weight:650;letter-spacing:.01em}
.login-dialog p{margin-top:-8px;color:var(--muted)}
.login-dialog label{display:grid;gap:6px;color:var(--muted);font-size:13px}
.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-lang{position:absolute;top:24px;right:24px;display:flex;align-items:center;gap:6px}
.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 button{height:32px;padding:0 12px;border:1px solid var(--border);border-radius:9px;background:transparent;color:var(--muted);cursor:pointer}
.login-switch:hover{color:var(--ink)}
.login-lang button.picked{border-color:var(--accent);color:var(--ink);background:rgba(62,197,168,.08)}
.login-switch:disabled{opacity:.35;cursor:not-allowed}
.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}}

View File

@ -4,14 +4,26 @@
@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(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}}
@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:1050px){
.panel-open-btn{display:inline-flex}
.app-shell.right-collapsed{grid-template-columns:250px minmax(0,1fr)}
.app-shell.right-collapsed .side-card{display:none}
.app-shell.right-open{grid-template-columns:250px minmax(0,1fr)}
.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-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{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){.computer-hud .avatar.blobatar,.computer-hud-label{animation:none!important}.computer-hud-label{color:var(--muted);background:none}}

View File

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

View File

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

View File

@ -29,7 +29,6 @@ export function VoiceSettingsDialog({ close }: { close: () => void }) {
.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)
?? (provider === "openai"
? [{ id: "gpt-realtime", name: "GPT Realtime" }]
@ -94,8 +93,8 @@ export function VoiceSettingsDialog({ close }: { close: () => void }) {
<legend>{t("voiceProvider")}</legend>
<div className="provider-grid">
{(settings?.providers || [
{ id: "xai" as const, name: t("providerXai"), envKeyName: "XAI_API_KEY" },
{ id: "openai" as const, name: t("providerOpenai"), envKeyName: "OPENAI_API_KEY" },
{ id: "xai" as const, name: t("providerXai") },
{ id: "openai" as const, name: t("providerOpenai") },
]).map((item) => (
<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}
@ -130,7 +129,6 @@ 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")} />
</label>
{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}
</> : null}
{error ? <div className="pane-error">{error}</div> : null}

View File

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

498
crates/api/src/accounts.rs Normal file
View File

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

View File

@ -1,114 +1,52 @@
//! 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::http::{HeaderMap, HeaderValue, StatusCode, header};
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use axum::{Json, Router};
use serde::Deserialize;
use rand::RngCore;
use serde_json::json;
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;
const COOKIE_NAME: &str = "lazyboy_session";
pub(crate) 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)]
pub struct AuthConfig {
token: Option<String>,
sessions: Arc<Mutex<HashMap<String, Instant>>>,
secure_cookie: bool,
pub guard: LoginGuard,
}
impl AuthConfig {
pub fn from_env() -> Self {
let token = std::env::var("LAZYBOY_APP_TOKEN")
.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"))
.unwrap_or(false);
Self {
token,
sessions: Arc::new(Mutex::new(HashMap::new())),
secure_cookie,
secure_cookie: std::env::var("LAZYBOY_SECURE_COOKIE")
.map(|value| matches!(value.as_str(), "1" | "true" | "yes"))
.unwrap_or(false),
guard: LoginGuard::default(),
}
}
pub fn enabled(&self) -> bool {
self.token.is_some()
}
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())));
}
/// Set `Secure` on the cookie. Needed when the app is served over HTTPS.
pub fn secure_cookie(&self) -> bool {
self.secure_cookie
}
}
fn constant_time_eq(left: &[u8], right: &[u8]) -> bool {
pub(crate) fn constant_time_eq(left: &[u8], right: &[u8]) -> bool {
if left.len() != right.len() {
return false;
}
@ -119,7 +57,7 @@ fn constant_time_eq(left: &[u8], right: &[u8]) -> bool {
difference == 0
}
fn cookie_value<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
pub(crate) fn cookie_value<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
headers
.get(header::COOKIE)?
.to_str()
@ -129,96 +67,80 @@ fn cookie_value<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
.find_map(|(key, value)| (key == name).then_some(value))
}
#[derive(Deserialize)]
struct LoginInput {
token: String,
pub(crate) fn session_token(headers: &HeaderMap) -> Option<String> {
let token = cookie_value(headers, COOKIE_NAME)?;
(!token.is_empty()).then(|| token.to_string())
}
pub fn public_router(state: AppState) -> Router {
Router::new()
.route(
"/api/session",
axum::routing::get(session).post(login).delete(logout),
)
.with_state(state)
/// 32 random bytes, hex encoded. Long enough that the cookie itself is a
/// 256-bit secret even though only its digest is stored.
pub(crate) fn new_session_token() -> String {
let mut bytes = [0u8; 32];
rand::thread_rng().fill_bytes(&mut bytes);
hex::encode(bytes)
}
async fn session(State(state): State<AppState>, headers: HeaderMap) -> Json<serde_json::Value> {
Json(json!({
"authenticated": state.auth.valid_session(&headers),
"required": state.auth.enabled()
}))
pub(crate) fn token_digest(token: &str) -> String {
hex::encode(Sha256::digest(token.as_bytes()))
}
async fn login(
/// Who this request belongs to, or `None` when the cookie is missing, expired,
/// or no longer backed by an account.
pub(crate) async fn session_actor(state: &AppState, headers: &HeaderMap) -> Option<Actor> {
let token = session_token(headers)?;
state
.db
.session_actor(&token_digest(&token))
.await
.ok()
.flatten()
}
/// The name behind a valid cookie, so the browser can show who is signed in.
pub(crate) async fn session_username(state: &AppState, headers: &HeaderMap) -> Option<String> {
let token = session_token(headers)?;
state
.db
.session_username(&token_digest(&token))
.await
.ok()
.flatten()
}
async fn unauthorized() -> Response {
(
StatusCode::UNAUTHORIZED,
Json(json!({"message": "請先登入", "code": "unauthenticated"})),
)
.into_response()
}
/// Every private route runs behind this: resolve the cookie, publish the actor
/// to the handler, and answer 401 when there is nobody to resolve.
pub async fn require_session(
State(state): State<AppState>,
Json(input): Json<LoginInput>,
) -> Result<Response, (StatusCode, Json<serde_json::Value>)> {
if !state.auth.valid_token(&input.token) {
return Err((
StatusCode::UNAUTHORIZED,
Json(json!({"message": "存取 token 不正確"})),
));
}
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)
}
async fn logout(State(state): State<AppState>, headers: HeaderMap) -> Response {
state.auth.revoke_session(&headers);
let mut response = Json(json!({"ok": true})).into_response();
response.headers_mut().insert(
header::SET_COOKIE,
HeaderValue::from_static("lazyboy_session=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0"),
);
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,
Json(json!({"message": "請先登入"})),
)
.into_response()
}
}
#[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"));
mut request: Request,
next: Next,
) -> Response {
match session_actor(&state, request.headers()).await {
Some(actor) => {
request.extensions_mut().insert(actor);
next.run(request).await
}
None => unauthorized().await,
}
}
/// SameSite cookies do not replace Origin checks (including WebSockets).
pub async fn browser_boundary(
State(state): State<AppState>,
State(_state): State<AppState>,
request: Request,
next: Next,
) -> Response {
if !allowed_browser_request(request.headers(), state.auth.enabled()) {
if let Err(rejection) = check_browser_request(request.headers()) {
return (
StatusCode::FORBIDDEN,
Json(json!({"message":"cross-origin request rejected"})),
Json(json!({"message": rejection.message()})),
)
.into_response();
}
@ -244,90 +166,214 @@ pub async fn browser_boundary(
response
}
fn allowed_browser_request(headers: &HeaderMap, authenticated_mode: bool) -> bool {
/// Why a browser request was turned away. The two cases need different fixes:
/// 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") {
return false;
return Err(BrowserRejection::CrossOrigin);
}
let Some(host) = headers.get(header::HOST).and_then(|v| v.to_str().ok()) else {
return false;
return Err(BrowserRejection::InvalidHost);
};
let Ok(destination) = reqwest::Url::parse(&format!("http://{host}")) else {
return false;
};
if !authenticated_mode
&& !matches!(
destination.host_str(),
Some("localhost" | "127.0.0.1" | "[::1]")
)
{
return false;
if !served_at_an_allowed_host(host, &listed_hosts()) {
return Err(BrowserRejection::InvalidHost);
}
if let Some(origin) = headers.get(header::ORIGIN) {
let Ok(origin) = origin
let Some(origin) = origin
.to_str()
.ok()
.and_then(|s| reqwest::Url::parse(s).ok())
.ok_or(())
else {
return false;
return Err(BrowserRejection::CrossOrigin);
};
let Ok(expected) = reqwest::Url::parse(&format!("{}://{host}", origin.scheme())) else {
return false;
return Err(BrowserRejection::CrossOrigin);
};
if !matches!(origin.scheme(), "http" | "https") || origin.origin() != expected.origin() {
return false;
return Err(BrowserRejection::CrossOrigin);
}
}
true
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 {
return false;
};
let Some(hostname) = destination.host_str() else {
return false;
};
// `Url` keeps the brackets around an IPv6 host; `IpAddr` only reads them off.
let address = hostname.trim_matches(|ch| ch == '[' || ch == ']');
if hostname == "localhost" || address.parse::<std::net::IpAddr>().is_ok() {
return true;
}
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);
}
}
#[cfg(test)]
mod origin_tests {
use super::*;
#[test]
fn blocks_cross_origin_and_dns_rebinding() {
fn blocks_cross_origin_requests() {
let mut headers = HeaderMap::new();
headers.insert(header::HOST, "localhost:3101".parse().unwrap());
assert!(allowed_browser_request(&headers, false));
assert!(allowed_browser_request(&headers));
headers.insert(header::ORIGIN, "https://evil.example".parse().unwrap());
assert!(!allowed_browser_request(&headers, true));
assert!(!allowed_browser_request(&headers));
headers.insert(header::ORIGIN, "http://localhost:3101".parse().unwrap());
assert!(allowed_browser_request(&headers, true));
headers.remove(header::ORIGIN);
assert!(allowed_browser_request(&headers));
headers.insert("sec-fetch-site", "cross-site".parse().unwrap());
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());
assert!(!allowed_browser_request(&headers, false));
assert_eq!(
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)]
mod session_tests {
mod allowed_host_tests {
use super::*;
#[test]
fn sessions_are_distinct_expire_and_are_revoked_on_logout() {
let config = AuthConfig {
token: Some("secret".into()),
sessions: Arc::new(Mutex::new(HashMap::new())),
secure_cookie: true,
};
let first = config.session_cookie().unwrap();
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));
fn a_domain_the_operator_listed_is_served() {
let listed = listed_hosts_from("lazyboy.local, box.example");
assert!(served_at_an_allowed_host("box.example:3101", &listed));
assert!(served_at_an_allowed_host("BOX.example", &listed));
assert!(!served_at_an_allowed_host("other.example", &listed));
assert!(served_at_an_allowed_host("127.0.0.1:3101", &[]));
assert!(served_at_an_allowed_host("[::1]:3101", &[]));
assert!(!served_at_an_allowed_host("not a host", &listed));
}
}

View File

@ -1,8 +1,14 @@
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 lazyboy_contracts::{
Bot, BrowserProfileMode, ComputerMode, ComputerState, ControlHolder, RunStatus, SandboxKind,
computer_home_key, computer_scope_key,
};
use serde_json::json;
use sqlx::{FromRow, PgPool};
use uuid::Uuid;
@ -11,12 +17,41 @@ pub struct Db {
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)]
pub struct Actor {
pub user_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)]
#[allow(dead_code)]
pub struct ComputerRow {
@ -103,28 +138,198 @@ pub struct SpaceRow {
}
impl Db {
pub async fn ensure_local_actor(&self) -> Result<Actor, sqlx::Error> {
let user_id = "local-user";
let space_id = "local-space";
sqlx::query("INSERT INTO users (id, name) VALUES ($1, $2) ON CONFLICT (id) DO NOTHING")
.bind(user_id)
.bind("Local")
.execute(&self.pool)
.await?;
/// Register a person.
///
/// The first account on an upgraded install claims the workspace that
/// already exists (it belonged to the shared-token install), so nobody
/// 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)
.fetch_optional(&self.pool)
.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(
"INSERT INTO spaces (id, user_id, name, is_default, default_model_provider, default_model_id)
VALUES ($1, $2, $3, TRUE, 'xai', 'grok-4.6')
ON CONFLICT (id) DO NOTHING",
)
.bind(space_id)
.bind(&space_id)
.bind(user_id)
.bind("Home")
.bind("My workspace")
.execute(&self.pool)
.await?;
Ok(Actor {
user_id: user_id.into(),
space_id: space_id.into(),
})
Ok(self.default_space(user_id).await?.unwrap_or(space_id))
}
/// 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> {
@ -505,3 +710,74 @@ pub fn parse_run_status(status: &str) -> Option<RunStatus> {
_ => 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,3 +1,4 @@
mod accounts;
mod attachments;
mod auth;
mod computer;
@ -40,21 +41,21 @@ async fn main() {
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::from_default_env().add_directive("info".parse().unwrap()))
.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")
.unwrap_or_else(|_| "postgres://lazyboy:lazyboy@127.0.0.1:5434/lazyboy".into());
let state = AppState::connect(&database_url).await.expect("database");
let actor = state.bootstrap().await.expect("bootstrap");
let accounts = state
.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();
tokio::spawn(async move {
mcp_state.mcp.reconnect_all(mcp_state.pool(), &actor).await;
mcp_state.mcp.reconnect_everyone(&mcp_state).await;
});
let retention_state = state.clone();
@ -77,9 +78,12 @@ async fn main() {
let bind = std::env::var("API_BIND").unwrap_or_else(|_| "127.0.0.1:3101".into());
let addr: SocketAddr = bind.parse().expect("API_BIND");
if !addr.ip().is_loopback() && !state.auth.strong_enough_for_network() {
panic!(
"LAZYBOY_APP_TOKEN must be set to at least 32 characters when API_BIND is not loopback"
if !addr.ip().is_loopback() {
// Serving outside this machine is normal — that is how a second person
// signs in — but they must reach us by an IP or a listed hostname, and
// every page behind login.
tracing::info!(
"listening on {addr}; sign-in is required, and a hostname other than an IP needs LAZYBOY_ALLOWED_HOSTS"
);
}
@ -89,7 +93,7 @@ async fn main() {
"/api/health",
axum::routing::get(|| async { axum::Json(serde_json::json!({"ok": true})) }),
)
.merge(auth::public_router(state.clone()))
.merge(accounts::router(state.clone()))
.merge(routes::router(state.clone()))
.layer(DefaultBodyLimit::max(28 * 1024 * 1024))
.layer(axum::middleware::from_fn_with_state(

View File

@ -187,6 +187,21 @@ impl McpHub {
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) {
let rows = match load_rows(pool, actor).await {
Ok(rows) => rows,
@ -488,13 +503,6 @@ async fn rollback_server(state: &AppState, id: &str) {
.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 {
let live = state.mcp.inner.lock().await;
if !row.enabled {
@ -507,8 +515,10 @@ async fn present(state: &AppState, _actor: &Actor, mut row: McpRow) -> McpServer
row.into_server("disconnected".into(), error, Vec::new())
}
async fn list_servers(State(state): State<AppState>) -> Result<Json<Vec<McpServer>>, ApiError> {
let actor = actor(&state).await?;
async fn list_servers(
actor: Actor,
State(state): State<AppState>,
) -> Result<Json<Vec<McpServer>>, ApiError> {
let rows = load_rows(state.pool(), &actor)
.await
.map_err(|error| internal(error.to_string()))?;
@ -538,10 +548,11 @@ fn validate_input(input: &UpsertMcpServerInput) -> Result<(), ApiError> {
async fn create_server(
State(state): State<AppState>,
actor: Actor,
Json(input): Json<UpsertMcpServerInput>,
) -> Result<(StatusCode, Json<McpServer>), ApiError> {
validate_input(&input)?;
let actor = actor(&state).await?;
let id = Uuid::new_v4().to_string();
let name = input.name.trim().chars().take(80).collect::<String>();
sqlx::query(
@ -605,9 +616,9 @@ async fn create_server(
async fn get_server(
State(state): State<AppState>,
actor: Actor,
Path(id): Path<String>,
) -> Result<Json<McpServer>, ApiError> {
let actor = actor(&state).await?;
let row = load_row(state.pool(), &actor, &id)
.await
.map_err(|error| internal(error.to_string()))?
@ -617,10 +628,10 @@ async fn get_server(
async fn update_server(
State(state): State<AppState>,
actor: Actor,
Path(id): Path<String>,
Json(input): Json<PatchMcpServerInput>,
) -> Result<Json<McpServer>, ApiError> {
let actor = actor(&state).await?;
let current = load_row(state.pool(), &actor, &id)
.await
.map_err(|error| internal(error.to_string()))?
@ -714,9 +725,9 @@ async fn update_server(
async fn delete_server(
State(state): State<AppState>,
actor: Actor,
Path(id): Path<String>,
) -> Result<StatusCode, ApiError> {
let actor = actor(&state).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")
.bind(&id)
@ -733,9 +744,9 @@ async fn delete_server(
async fn reconnect_server(
State(state): State<AppState>,
actor: Actor,
Path(id): Path<String>,
) -> Result<Json<McpServer>, ApiError> {
let actor = actor(&state).await?;
let mut row = load_row(state.pool(), &actor, &id)
.await
.map_err(|error| internal(error.to_string()))?

View File

@ -718,25 +718,29 @@ pub fn router() -> Router<AppState> {
)
}
async fn scoped_actor(state: &AppState, bot_id: &str) -> Result<Actor, StatusCode> {
let actor = state
.bootstrap()
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
/// An id in a URL is not permission. Anything addressed by `bot_id` checks
/// that the agent lives in the caller's own workspace first, so guessing an id
/// cannot reach another person's memory, vault, or schedule.
async fn assert_bot_is_mine(
state: &AppState,
actor: &Actor,
bot_id: &str,
) -> Result<(), StatusCode> {
state
.db
.get_bot(&actor, bot_id)
.get_bot(actor, bot_id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::NOT_FOUND)?;
Ok(actor)
Ok(())
}
async fn memory_status(
State(state): State<AppState>,
actor: Actor,
Path(bot_id): Path<String>,
) -> Result<Json<Value>, StatusCode> {
let actor = scoped_actor(&state, &bot_id).await?;
assert_bot_is_mine(&state, &actor, &bot_id).await?;
let (stored, indexed): (i64, i64) = sqlx::query_as(
"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",
@ -757,9 +761,10 @@ async fn memory_status(
async fn list_memories(
State(state): State<AppState>,
actor: Actor,
Path(bot_id): Path<String>,
) -> Result<Json<Vec<MemoryItem>>, StatusCode> {
let actor = scoped_actor(&state, &bot_id).await?;
assert_bot_is_mine(&state, &actor, &bot_id).await?;
state
.memory
.list(state.pool(), &actor, &bot_id)
@ -770,10 +775,13 @@ async fn list_memories(
async fn create_memory(
State(state): State<AppState>,
actor: Actor,
Path(bot_id): Path<String>,
Json(input): Json<CreateMemoryInput>,
) -> Result<Json<MemoryItem>, (StatusCode, Json<Value>)> {
let actor = scoped_actor(&state, &bot_id).await.map_err(api_status)?;
assert_bot_is_mine(&state, &actor, &bot_id)
.await
.map_err(api_status)?;
state
.memory
.remember(state.pool(), &actor, &bot_id, input)
@ -784,10 +792,13 @@ async fn create_memory(
async fn update_memory(
State(state): State<AppState>,
actor: Actor,
Path((bot_id, memory_id)): Path<(String, Uuid)>,
Json(input): Json<UpdateMemoryInput>,
) -> Result<Json<MemoryItem>, (StatusCode, Json<Value>)> {
let actor = scoped_actor(&state, &bot_id).await.map_err(api_status)?;
assert_bot_is_mine(&state, &actor, &bot_id)
.await
.map_err(api_status)?;
state
.memory
.update(state.pool(), &actor, &bot_id, memory_id, input)
@ -799,9 +810,12 @@ async fn update_memory(
async fn delete_memory(
State(state): State<AppState>,
actor: Actor,
Path((bot_id, memory_id)): Path<(String, Uuid)>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
let actor = scoped_actor(&state, &bot_id).await.map_err(api_status)?;
assert_bot_is_mine(&state, &actor, &bot_id)
.await
.map_err(api_status)?;
if !state
.memory
.forget(state.pool(), &actor, &bot_id, memory_id)
@ -815,9 +829,12 @@ async fn delete_memory(
async fn clear_memories(
State(state): State<AppState>,
actor: Actor,
Path(bot_id): Path<String>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
let actor = scoped_actor(&state, &bot_id).await.map_err(api_status)?;
assert_bot_is_mine(&state, &actor, &bot_id)
.await
.map_err(api_status)?;
let deleted = state
.memory
.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.
async fn memory_usage(
State(state): State<AppState>,
actor: Actor,
Path((run_id, activity_id)): Path<(String, i64)>,
) -> Result<Json<Value>, ApiError> {
let actor = state.bootstrap().await.map_err(internal)?;
memory_usage_items(state.pool(), &actor, &run_id, activity_id).await
}
@ -191,6 +191,30 @@ pub fn classify_run_error(error: &str) -> RunFailure {
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(&[
"invalid api key",
"incorrect api key",
@ -289,7 +313,9 @@ pub fn classify_run_error(error: &str) -> RunFailure {
true,
);
}
if text.contains("lease") {
// "run lease was lost" / "run lease lost": the bare substring would also
// match "please" in a provider's error text.
if has(&["run lease", "lease was lost", "lease lost", "lease expired"]) {
return RunFailure::new(
"lease_lost",
"這份工作被另一邊接手過,我手上這份已經失效。",
@ -342,24 +368,15 @@ struct RunRow {
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.
/// `after` pages forward; the first read returns the newest window, oldest
/// first, so the UI renders it and then tails from the last id.
async fn activity(
State(state): State<AppState>,
actor: Actor,
Path(id): Path<String>,
Query(query): Query<ActivityQuery>,
) -> Result<Json<Value>, ApiError> {
let actor = actor(&state).await?;
let run = sqlx::query_as::<_, RunRow>(
"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
@ -429,9 +446,9 @@ async fn activity(
/// it continues where it stopped instead of starting the task over.
async fn retry(
State(state): State<AppState>,
actor: Actor,
Path(id): Path<String>,
) -> Result<Json<Value>, ApiError> {
let actor = actor(&state).await?;
let exists: Option<String> =
sqlx::query_scalar("SELECT status FROM runs WHERE id=$1 AND space_id=$2 AND user_id=$3")
.bind(&id)
@ -522,6 +539,39 @@ mod tests {
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]
fn the_specific_timeouts_win_over_the_generic_one() {
assert_eq!(

View File

@ -30,13 +30,6 @@ pub fn router() -> Router<AppState> {
.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 {
tracing::error!("rooms: {message}");
(
@ -115,8 +108,10 @@ async fn room_from_id(
}))
}
async fn list_rooms(State(state): State<AppState>) -> Result<Json<Vec<Room>>, ApiError> {
let actor = actor(&state).await?;
async fn list_rooms(
actor: Actor,
State(state): State<AppState>,
) -> Result<Json<Vec<Room>>, ApiError> {
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",
)
@ -139,9 +134,9 @@ async fn list_rooms(State(state): State<AppState>) -> Result<Json<Vec<Room>>, Ap
async fn create_room(
State(state): State<AppState>,
actor: Actor,
Json(input): Json<CreateRoomInput>,
) -> Result<(StatusCode, Json<Room>), ApiError> {
let actor = actor(&state).await?;
let name = input.name.trim();
let mut seen = HashSet::new();
let member_ids: Vec<String> = input
@ -241,9 +236,9 @@ async fn create_room(
async fn get_room(
State(state): State<AppState>,
actor: Actor,
Path(id): Path<String>,
) -> Result<Json<Room>, ApiError> {
let actor = actor(&state).await?;
room_from_id(&state, &actor, &id)
.await
.map_err(|error| internal(error.to_string()))?
@ -258,9 +253,9 @@ async fn get_room(
async fn delete_room(
State(state): State<AppState>,
actor: Actor,
Path(id): Path<String>,
) -> 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")
.bind(&id)
.bind(&actor.space_id)
@ -281,10 +276,10 @@ async fn delete_room(
/// is reached by one tap on a member — there is no settings page to open.
async fn update_room(
State(state): State<AppState>,
actor: Actor,
Path(id): Path<String>,
Json(input): Json<UpdateRoomInput>,
) -> Result<Json<Room>, ApiError> {
let actor = actor(&state).await?;
let current = room_from_id(&state, &actor, &id)
.await
.map_err(|error| internal(error.to_string()))?
@ -332,9 +327,9 @@ async fn update_room(
async fn list_sessions(
State(state): State<AppState>,
actor: Actor,
Path(id): Path<String>,
) -> Result<Json<Vec<Session>>, ApiError> {
let actor = actor(&state).await?;
if room_from_id(&state, &actor, &id)
.await
.map_err(|error| internal(error.to_string()))?
@ -363,10 +358,10 @@ async fn list_sessions(
async fn create_session(
State(state): State<AppState>,
actor: Actor,
Path(id): Path<String>,
Json(input): Json<CreateSessionInput>,
) -> Result<(StatusCode, Json<Session>), ApiError> {
let actor = actor(&state).await?;
let Some(room) = room_from_id(&state, &actor, &id)
.await
.map_err(|error| internal(error.to_string()))?
@ -403,9 +398,9 @@ async fn create_session(
async fn room_status(
State(state): State<AppState>,
actor: Actor,
Path(id): Path<String>,
) -> Result<Json<RoomStatus>, ApiError> {
let actor = actor(&state).await?;
if room_from_id(&state, &actor, &id)
.await
.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_layer(middleware::from_fn_with_state(
state.clone(),
crate::auth::require_auth,
crate::auth::require_session,
))
.with_state(state)
}
@ -58,15 +58,10 @@ async fn file_skills(State(state): State<AppState>) -> Json<Vec<crate::file_skil
Json(crate::file_skills::list(&state.data_dir))
}
async fn actor(state: &AppState) -> Result<Actor, StatusCode> {
state
.bootstrap()
.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?;
async fn list_bots(
actor: Actor,
State(state): State<AppState>,
) -> Result<Json<Vec<Bot>>, StatusCode> {
let rows = state
.db
.list_bots(&actor)
@ -102,9 +97,9 @@ async fn list_bots(State(state): State<AppState>) -> Result<Json<Vec<Bot>>, Stat
async fn create_bot(
State(state): State<AppState>,
actor: Actor,
Json(input): Json<CreateBotInput>,
) -> Result<Json<Bot>, StatusCode> {
let actor = actor(&state).await?;
if input.name.trim().is_empty() {
return Err(StatusCode::BAD_REQUEST);
}
@ -128,9 +123,9 @@ async fn create_bot(
async fn get_bot(
State(state): State<AppState>,
actor: Actor,
Path(id): Path<String>,
) -> Result<Json<Value>, StatusCode> {
let actor = actor(&state).await?;
let bot = state
.db
.get_bot(&actor, &id)
@ -185,12 +180,10 @@ struct InboxInput {
async fn update_inbox(
State(state): State<AppState>,
actor: Actor,
Path(id): Path<String>,
Json(input): Json<InboxInput>,
) -> 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() {
"read" => "UPDATE bots SET last_read_at=now() WHERE id=$1 AND space_id=$2 AND user_id=$3",
"unread" => {
@ -230,12 +223,10 @@ async fn update_inbox(
async fn update_bot(
State(state): State<AppState>,
actor: Actor,
Path(id): Path<String>,
Json(input): Json<UpdateBotInput>,
) -> 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 color_ok = input.avatar_color.len() == 7
&& input.avatar_color.starts_with('#')
@ -324,11 +315,9 @@ async fn update_bot(
async fn delete_bot(
State(state): State<AppState>,
actor: Actor,
Path(id): Path<String>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
let actor = actor(&state)
.await
.map_err(|status| (status, Json(json!({ "message": "actor" }))))?;
let bot = state
.db
.get_bot(&actor, &id)
@ -409,11 +398,9 @@ async fn delete_bot(
async fn delete_environment(
State(state): State<AppState>,
actor: Actor,
Path(id): Path<String>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
let actor = actor(&state)
.await
.map_err(|status| (status, Json(json!({ "message": "actor" }))))?;
if id != actor.space_id {
return Err((
StatusCode::NOT_FOUND,
@ -497,10 +484,10 @@ struct SendBody {
async fn send_message(
State(state): State<AppState>,
actor: Actor,
Path(id): Path<String>,
Json(body): Json<SendBody>,
) -> Result<Json<Value>, StatusCode> {
let actor = actor(&state).await?;
let _ = state
.db
.get_bot(&actor, &id)
@ -531,9 +518,9 @@ async fn send_message(
async fn list_messages(
State(state): State<AppState>,
actor: Actor,
Path(id): Path<String>,
) -> Result<Json<Value>, StatusCode> {
let actor = actor(&state).await?;
let _ = state
.db
.get_bot(&actor, &id)
@ -552,9 +539,9 @@ async fn list_messages(
async fn stop_task(
State(state): State<AppState>,
actor: Actor,
Path(id): Path<String>,
) -> Result<Json<Value>, StatusCode> {
let actor = actor(&state).await?;
let _ = state
.db
.get_bot(&actor, &id)
@ -569,9 +556,9 @@ async fn stop_task(
async fn computer_status(
State(state): State<AppState>,
actor: Actor,
Path(id): Path<String>,
) -> Result<Json<Value>, StatusCode> {
let actor = actor(&state).await?;
computer::current_status(&state, &actor, &id)
.await
.map(|status| Json(serde_json::to_value(status).unwrap()))
@ -580,9 +567,9 @@ async fn computer_status(
async fn boot(
State(state): State<AppState>,
actor: Actor,
Path(id): Path<String>,
) -> Result<Json<Value>, StatusCode> {
let actor = actor(&state).await?;
computer::boot(&state, &actor, &id)
.await
.map(|status| Json(serde_json::to_value(status).unwrap()))
@ -598,9 +585,9 @@ async fn boot(
async fn restart(
State(state): State<AppState>,
actor: Actor,
Path(id): Path<String>,
) -> Result<Json<Value>, StatusCode> {
let actor = actor(&state).await?;
computer::restart(&state, &actor, &id)
.await
.map(|status| Json(serde_json::to_value(status).unwrap()))
@ -612,11 +599,9 @@ async fn restart(
async fn stop(
State(state): State<AppState>,
actor: Actor,
Path(id): Path<String>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
let actor = actor(&state)
.await
.map_err(|status| (status, Json(json!({"message":"無法取得工作區"}))))?;
computer::stop(&state, &actor, &id)
.await
.map(|status| Json(serde_json::to_value(status).unwrap()))
@ -625,9 +610,9 @@ async fn stop(
async fn screen_url(
State(state): State<AppState>,
actor: Actor,
Path(id): Path<String>,
) -> Result<Json<Value>, StatusCode> {
let actor = actor(&state).await?;
let bot = state
.db
.get_bot(&actor, &id)
@ -670,11 +655,9 @@ async fn screen_url(
async fn takeover(
State(state): State<AppState>,
actor: Actor,
Path(id): Path<String>,
) -> 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 {
Ok((lease_id, expires_at)) => Ok(Json(
json!({ "leaseId": lease_id, "expiresAt": expires_at }),
@ -685,9 +668,9 @@ async fn takeover(
async fn release(
State(state): State<AppState>,
actor: Actor,
Path(id): Path<String>,
) -> Result<Json<Value>, StatusCode> {
let actor = actor(&state).await?;
computer::release(&state, &actor, &id)
.await
.map(|_| Json(json!({ "ok": true })))
@ -696,9 +679,9 @@ async fn release(
async fn heartbeat(
State(state): State<AppState>,
actor: Actor,
Path(id): Path<String>,
) -> Result<Json<Value>, StatusCode> {
let actor = actor(&state).await?;
computer::heartbeat(&state, &actor, &id)
.await
.map(|_| Json(json!({ "ok": true })))
@ -716,10 +699,10 @@ struct InputBody {
async fn input(
State(state): State<AppState>,
actor: Actor,
Path(id): Path<String>,
Json(body): Json<InputBody>,
) -> Result<Json<Value>, StatusCode> {
let actor = actor(&state).await?;
let bot = state
.db
.get_bot(&actor, &id)

View File

@ -12,8 +12,7 @@ use std::time::Duration;
use lazyboy_contracts::ModelProvider;
use lazyboy_harness::{
CredentialChain, DynModel, ResolveModelRequest, connect_model, credential_from_env,
resolve_backend,
CredentialChain, DynModel, ResolveModelRequest, connect_model, resolve_backend,
};
use rig_core::completion::message::{AssistantContent, Message, UserContent};
@ -338,7 +337,7 @@ async fn pick(
thread_id: &str,
text: &str,
) -> Vec<String> {
let model = match router_model(state, actor).await {
let model = match router_model(state, actor, thread_id).await {
Ok(model) => model,
Err(error) => {
tracing::warn!("room router unavailable: {error}");
@ -433,7 +432,11 @@ async fn prompt_for(
/// The picker runs on the workspace model unless `LAZYBOY_ROUTER_MODEL` names a
/// cheaper one; leaving it unset is a valid and common state.
async fn router_model(state: &AppState, actor: &Actor) -> Result<DynModel, String> {
async fn router_model(
state: &AppState,
actor: &Actor,
thread_id: &str,
) -> Result<DynModel, String> {
let space = state
.db
.get_space(actor)
@ -455,11 +458,10 @@ async fn router_model(state: &AppState, actor: &Actor) -> Result<DynModel, Strin
credentials: CredentialChain {
bot: None,
space: space.default_model_api_key.clone(),
env: credential_from_env(provider),
},
})
.map_err(|error| error.to_string())?;
connect_model(&backend).map_err(|error| error.to_string())
connect_model(&backend, thread_id).map_err(|error| error.to_string())
}
/// 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())?
.ok_or_else(|| "computer not found".to_string())?;
let (model, vision) = bot_model(state, actor, &bot).await?;
let (model, vision) = bot_model(state, actor, &bot, thread_id).await?;
let skills = crate::skills::saved_skills(state.pool(), bot_id).await;
let ctx = Arc::new(ToolCtx {
@ -1594,12 +1594,15 @@ async fn execute_run(
Ok(())
}
/// Resolve the model a bot runs on (bot override → workspace default → env
/// credentials). Returns the connected model and whether it accepts images.
/// Resolve the model a bot runs on (bot override → workspace default).
/// `session` names the conversation the calls belong to; providers such as
/// OpenCode Go require it on every request. Returns the connected model and
/// whether it accepts images.
pub(crate) async fn bot_model(
state: &AppState,
actor: &Actor,
bot: &crate::db::BotRow,
session: &str,
) -> Result<(DynModel, bool), String> {
let space = state
.db
@ -1626,11 +1629,10 @@ pub(crate) async fn bot_model(
credentials: CredentialChain {
bot: None,
space: space.default_model_api_key.clone(),
env: lazyboy_harness::credential_from_env(provider),
},
})
.map_err(|error| error.to_string())?;
let model = connect_model(&backend).map_err(|error| error.to_string())?;
let model = connect_model(&backend, session).map_err(|error| error.to_string())?;
Ok((model, backend.capabilities.vision))
}

View File

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

View File

@ -6,25 +6,37 @@ use futures_util::{SinkExt, StreamExt};
use tokio_tungstenite::tungstenite::Message as WsMessage;
use crate::computer;
use crate::db::Actor;
use crate::state::AppState;
pub async fn view_root(
State(state): State<AppState>,
Path(bot_id): Path<String>,
actor: Actor,
req: Request,
) -> Response {
proxy(state, bot_id, String::new(), req).await
proxy(state, actor, bot_id, String::new(), req).await
}
pub async fn view_path(
State(state): State<AppState>,
Path((bot_id, rest)): Path<(String, String)>,
actor: Actor,
req: Request,
) -> Response {
proxy(state, bot_id, rest, req).await
proxy(state, actor, bot_id, rest, req).await
}
async fn proxy(state: AppState, bot_id: String, rest: String, req: Request) -> Response {
/// A desktop is streamed to its owner only. The actor comes from the login
/// 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
.headers()
.get(header::UPGRADE)
@ -44,7 +56,7 @@ async fn proxy(state: AppState, bot_id: String, rest: String, req: Request) -> R
return StatusCode::NOT_FOUND.into_response();
}
let ensure = upgrade || is_viewer_page(&rest) || rest.contains("websockify");
let (host, port) = match upstream_target(&state, &bot_id, ensure).await {
let (host, port) = match upstream_target(&state, &actor, &bot_id, ensure).await {
Ok(target) => target,
Err(status) => return status.into_response(),
};
@ -88,16 +100,13 @@ async fn viewer_page() -> Response {
async fn upstream_target(
state: &AppState,
actor: &Actor,
bot_id: &str,
ensure: bool,
) -> Result<(String, u16), StatusCode> {
let actor = state
.bootstrap()
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let bot = state
.db
.get_bot(&actor, bot_id)
.get_bot(actor, bot_id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::NOT_FOUND)?;
@ -109,7 +118,7 @@ async fn upstream_target(
.ok_or(StatusCode::NOT_FOUND)?;
let computer_ref = computer::computer_ref(&computer).ok_or(StatusCode::NOT_FOUND)?;
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,
Err(_) => state
.db
@ -129,7 +138,7 @@ async fn upstream_target(
.connect_screen(
&computer_ref,
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
.map_err(|_| StatusCode::BAD_GATEWAY)?;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -27,19 +27,10 @@ struct PreparedCall {
pub async fn call_ws(
State(state): State<AppState>,
actor: Actor,
Path(session_id): Path<String>,
ws: WebSocketUpgrade,
) -> 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 {
Ok(prep) => {
let Some(lease) = state.calls.try_begin(&prep.bot_id, &prep.call_id) else {

View File

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

View File

@ -24,16 +24,6 @@ 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 {
!matches!(self, Self::OpenaiCompatible)
}

View File

@ -19,14 +19,6 @@ 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 {
!matches!(self, Self::Scripted)
}

View File

@ -7,8 +7,8 @@ use thiserror::Error;
pub enum ModelError {
#[error("unsupported_provider:{provider}")]
UnsupportedProvider { provider: String },
#[error("missing credential for {provider} ({env_key})")]
MissingCredential { provider: String, env_key: String },
#[error("missing credential for {provider}: add the API key in the workspace settings")]
MissingCredential { provider: String },
#[error("missing base URL for {provider}")]
MissingBaseUrl { provider: String },
#[error("unknown model provider: {0}")]
@ -17,11 +17,14 @@ pub enum ModelError {
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)]
pub struct CredentialChain {
pub bot: Option<String>,
pub space: Option<String>,
pub env: Option<String>,
}
impl CredentialChain {
@ -30,7 +33,6 @@ impl CredentialChain {
.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()))
}
}
@ -67,7 +69,6 @@ pub fn resolve_backend(request: ResolveModelRequest) -> Result<ResolvedBackend,
None if request.provider.requires_api_key() => {
return Err(ModelError::MissingCredential {
provider: request.provider.as_str().to_string(),
env_key: request.provider.env_key_name().to_string(),
});
}
None => String::new(),
@ -127,7 +128,35 @@ fn uses_responses_api(provider: ModelProvider, model_id: &str) -> bool {
&& (id.starts_with("gpt-") || id.starts_with("grok-") || id.starts_with("muse-"))
}
pub fn connect_model(backend: &ResolvedBackend) -> Result<DynModel, ModelError> {
/// Headers OpenCode Go asks every client for: a self-identifying user agent
/// 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 {
ModelProvider::Xai => {
let client = xai::Client::new(&backend.api_key)
@ -140,10 +169,12 @@ pub fn connect_model(backend: &ResolvedBackend) -> Result<DynModel, ModelError>
} else {
backend.api_key.as_str()
};
let headers = provider_headers(backend.provider, session);
if uses_responses_api(backend.provider, &backend.model_id) {
let client = openai::Client::builder()
.api_key(key.to_string())
.base_url(&backend.base_url)
.http_headers(headers)
.build()
.map_err(|error| ModelError::ProviderClient(error.to_string()))?;
Ok(DynModel::OpenAiResponses(
@ -153,6 +184,7 @@ pub fn connect_model(backend: &ResolvedBackend) -> Result<DynModel, ModelError>
let client = openai::CompletionsClient::builder()
.api_key(key.to_string())
.base_url(&backend.base_url)
.http_headers(headers)
.build()
.map_err(|error| ModelError::ProviderClient(error.to_string()))?;
Ok(DynModel::OpenAi(client.completion_model(&backend.model_id)))
@ -164,12 +196,6 @@ pub fn connect_model(backend: &ResolvedBackend) -> Result<DynModel, ModelError>
}
}
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.
pub fn connect_xai(
backend: &ResolvedBackend,
@ -195,8 +221,7 @@ mod tests {
base_url: None,
credentials: CredentialChain {
bot: None,
space: None,
env: key.map(str::to_string),
space: key.map(str::to_string),
},
}
}
@ -212,7 +237,7 @@ mod tests {
}
#[test]
fn credentials_prefer_bot_then_space_then_env() {
fn credentials_prefer_the_bot_key_over_the_workspace_key() {
let backend = resolve_backend(ResolveModelRequest {
provider: ModelProvider::Xai,
model_id: Some("grok-4.6".into()),
@ -220,7 +245,6 @@ mod tests {
credentials: CredentialChain {
bot: Some("bot-key".into()),
space: Some("space-key".into()),
env: Some("env-key".into()),
},
})
.unwrap();
@ -246,8 +270,7 @@ mod tests {
base_url: None,
credentials: CredentialChain {
bot: None,
space: None,
env: Some("sk-test".into()),
space: Some("sk-test".into()),
},
})
.unwrap_err();
@ -274,14 +297,29 @@ mod tests {
base_url: None,
credentials: CredentialChain {
bot: None,
space: None,
env: Some("go-key".into()),
space: Some("go-key".into()),
},
})
.unwrap();
assert_eq!(backend.model_id, "glm-5.1");
assert_eq!(backend.base_url, "https://opencode.ai/zen/go/v1");
assert!(connect_model(&backend).is_ok());
assert!(connect_model(&backend, "thread-1").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]
@ -307,13 +345,12 @@ mod tests {
credentials: CredentialChain {
bot: None,
space: None,
env: None,
},
})
.unwrap();
assert_eq!(backend.api_key, "");
assert_eq!(backend.base_url, "http://127.0.0.1:8000/v1");
assert!(connect_model(&backend).is_ok());
assert!(connect_model(&backend, "thread-1").is_ok());
}
#[test]
@ -325,7 +362,6 @@ mod tests {
credentials: CredentialChain {
bot: None,
space: None,
env: None,
},
})
.unwrap_err();

View File

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

View File

@ -82,16 +82,13 @@ services:
DATABASE_URL: postgres://lazyboy:${POSTGRES_PASSWORD:-lazyboy}@postgres:5432/lazyboy
SANDBOX_SUPERVISOR_URL: http://supervisor:7091
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_SECURE_COOKIE: ${LAZYBOY_SECURE_COOKIE:-false}
LAZYBOY_ALLOWED_HOSTS: ${LAZYBOY_ALLOWED_HOSTS:-}
SANDBOX_PROVIDER: docker
DATA_DIR: /data
HOST_DATA_DIR: ${LAZYBOY_HOST_DATA_DIR:-${PWD}/data}
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_MODEL_CACHE: /data/fastembed
LAZYBOY_MEMORY_TOP_K: ${LAZYBOY_MEMORY_TOP_K:-8}

View File

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

View File

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

View File

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

View File

@ -18,7 +18,6 @@ if [[ -f "$root/.env" ]]; then
fi
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}"
: "${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_PROVIDER="${SANDBOX_PROVIDER:-docker}"
export DATA_DIR="${DATA_DIR:-$root/data}"
@ -26,5 +25,5 @@ export API_BIND="${API_BIND:-0.0.0.0:3101}"
export LAZYBOY_WEB_DIR="$root/apps/web"
mkdir -p "$DATA_DIR"
echo "start supervisor in another terminal with the same .env: cargo run -p lazyboy-supervisor"
echo "then: cargo run -p lazyboy-api"
echo "then: cargo run -p lazyboy-api (register the first account in the browser)"
echo "listening on 0.0.0.0:3101"

View File

@ -7,7 +7,7 @@ path=Path('.env')
if path.exists():
print('.env already exists; existing credentials were preserved.')
else:
values={key:secrets.token_hex(32) for key in ('LAZYBOY_APP_TOKEN','SANDBOX_SUPERVISOR_TOKEN','LAZYBOY_VAULT_KEY','POSTGRES_PASSWORD')}
values={key:secrets.token_hex(32) for key in ('SANDBOX_SUPERVISOR_TOKEN','LAZYBOY_VAULT_KEY','POSTGRES_PASSWORD')}
text=Path('.env.example').read_text()
lines=[]
for line in text.splitlines():
@ -18,4 +18,5 @@ else:
with path.open('x') as out:
path.chmod(0o600)
out.write('\n'.join(lines)+'\n')
print('Created .env with independent app, supervisor, vault and database secrets. Set your model API key next.')
print('Created .env with independent supervisor, vault and database secrets.')
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);
const {formatElapsed,shortDuration,errorActions,errorTitle,trailText}=monitorBox.exports;
const FAILURE_CODES=['interrupted','tool_timeout','model_key','model_quota','model_unknown','model_timeout','network','computer_gone','lease_lost','unknown'];
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'];
test('run timings stay on one glanceable line',()=>{
assert.equal(formatElapsed(0),'0:00');
@ -368,6 +368,21 @@ test('trail lines read as sentences with the detail a stuck run needs',()=>{
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',()=>{
const app=fs.readFileSync('apps/web/src/App.tsx','utf8');
assert.match(app,/function MessageContextMenu/);
@ -391,6 +406,7 @@ test('the bubble reads the run activity endpoint the API actually mounts',()=>{
assert.match(app,/errorActions\(chip\.code\)/);
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=\{member\.id===computer\.botId\?computer\.busyRunId:null\}><Avatar/);
assert.match(app,/<RunProbe runId=\{computer\.busyRunId\|\|computer\.waitingRunId\}>/);
assert.doesNotMatch(app,/message-run-details/);
assert.match(app,/className="thinking-row"/);