Compare commits
No commits in common. "main" and "feat/chat_team_mode" have entirely different histories.
main
...
feat/chat_
13
.env.example
13
.env.example
|
|
@ -1,16 +1,15 @@
|
|||
# 登入改為帳號密碼(在網頁上註冊),不再需要共享 token。
|
||||
# API 金鑰也不再讀環境變數:註冊後到「設定 → 模型」貼上,沒有金鑰就不能跑。
|
||||
XAI_API_KEY=
|
||||
OPENCODE_GO_API_KEY=
|
||||
OPENAI_API_KEY=
|
||||
# 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 = 開放區網,讓別人用他自己的帳號登入;
|
||||
# 127.0.0.1 = 只有本機;0.0.0.0 = 開放區網(需 LAZYBOY_APP_TOKEN >= 32 字元);
|
||||
# 也可填單一網卡的 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
|
||||
|
|
|
|||
40
CHANGELOG.md
40
CHANGELOG.md
|
|
@ -2,46 +2,6 @@
|
|||
|
||||
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-SHA256(120,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.
|
||||
|
|
|
|||
|
|
@ -1899,7 +1899,6 @@ dependencies = [
|
|||
"fastembed",
|
||||
"futures-util",
|
||||
"hex",
|
||||
"hmac",
|
||||
"http",
|
||||
"lazyboy-contracts",
|
||||
"lazyboy-control",
|
||||
|
|
|
|||
5
Makefile
5
Makefile
|
|
@ -60,7 +60,8 @@ 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, register an account, and add a model API key in 設定 → 模型."
|
||||
@echo " After 'make up': open http://127.0.0.1:3101 and sign in with LAZYBOY_APP_TOKEN."
|
||||
@echo " Set XAI_API_KEY in .env for chat."
|
||||
|
||||
# --- Environment -----------------------------------------------------------
|
||||
|
||||
|
|
@ -78,7 +79,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 register your account."
|
||||
@echo "stack launched. open http://127.0.0.1:3101 and sign in with LAZYBOY_APP_TOKEN."
|
||||
$(COMPOSE) ps
|
||||
|
||||
# Compose owns `lazyboy_screen` (internal, labeled). A leftover from host-dev or
|
||||
|
|
|
|||
11
README.md
11
README.md
|
|
@ -18,11 +18,10 @@ A self-hosted AI agent workspace. Assign tasks in text or voice, watch the deskt
|
|||
|
||||
LazyBoy gives each agent its own Linux desktop in Docker — browser, terminal, and files. You can run several agents, put them in a group, turn a demonstration into a skill, and schedule it to run again.
|
||||
|
||||
This is an early `v0.1.0-alpha` release with desktop and phone browser UIs. Every person registers their own account, and model API keys live in the app's settings rather than in the environment.
|
||||
This is an early `v0.1.0-alpha` release with desktop and phone browser UIs. You bring your own model API key.
|
||||
|
||||
## Features
|
||||
|
||||
- **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.
|
||||
|
|
@ -37,7 +36,7 @@ This is an early `v0.1.0-alpha` release with desktop and phone browser UIs. Ever
|
|||
|
||||
## Quick start
|
||||
|
||||
You need Docker and Compose, Git, Make, and Python 3, plus an API key for a supported model — you paste that key into the app after you register, not into `.env`. On macOS use Docker Desktop or OrbStack; on Linux use Docker Engine.
|
||||
You need Docker and Compose, Git, Make, Python 3, and an API key for a supported model. On macOS use Docker Desktop or OrbStack; on Linux use Docker Engine.
|
||||
|
||||
From the repo root:
|
||||
|
||||
|
|
@ -45,14 +44,14 @@ From the repo root:
|
|||
make env
|
||||
```
|
||||
|
||||
The generated `.env` holds service secrets only (supervisor, credential vault, database); the init tool fills them in and keeps existing values when run again. Nothing about signing in or model access is configured here.
|
||||
Edit the generated `.env` and set one of `XAI_API_KEY`, `OPENCODE_GO_API_KEY`, or `OPENAI_API_KEY`. The init tool creates the login and service secrets; running it again keeps existing values.
|
||||
|
||||
```bash
|
||||
make up
|
||||
make health
|
||||
```
|
||||
|
||||
Open **[http://127.0.0.1:3101](http://127.0.0.1:3101)**, register an account with any username and password you like, paste your model API key under **Settings → Models**, then create an agent and pick a model. Without a key nothing runs — the app tells you where to add one.
|
||||
Open **[http://127.0.0.1:3101](http://127.0.0.1:3101)**, sign in with `LAZYBOY_APP_TOKEN` from `.env`, create an agent, and pick a model.
|
||||
|
||||
The first run builds the API and Linux desktop images from source and takes a while. The build toolchain lives in the containers, so a full Docker deploy does not need Rust or Node.js on the host.
|
||||
|
||||
|
|
@ -72,7 +71,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. Then open that address in the phone browser — `127.0.0.1` there is the phone itself and will not reach another machine — and register there too: two people on one install each see only their own agents. Reaching the app by a domain name instead of an IP or `localhost` additionally requires listing it in `LAZYBOY_ALLOWED_HOSTS`.
|
||||
Desktop and phone share the same web UI. The API listens on `127.0.0.1` by default, so set `LAZYBOY_BIND_IP=0.0.0.0` (or one network card's address) in `.env` and recreate the api container before a phone on your network can reach it; off-loopback the login token has to be at least 32 characters. Then open that address in the phone browser — `127.0.0.1` there is the phone itself and will not reach another machine.
|
||||
|
||||
Tap outside the chat sidebar to collapse it. On the remote desktop you can switch between tap-to-click and trackpad, and use the toolbar for keyboard, right-click, or drag. Put the service behind HTTPS before you expose it; see [Operations](./docs/operations.md#安全模型).
|
||||
|
||||
|
|
|
|||
|
|
@ -18,11 +18,10 @@
|
|||
|
||||
LazyBoy 讓 Agent 在 Docker 裡使用自己的 Linux 桌面,操作瀏覽器、終端與檔案。你可以建立不同的 Agent、在群組中協作,把示範整理成技能,再安排定時執行。
|
||||
|
||||
目前為早期版本 `v0.1.0-alpha`,提供桌面與手機瀏覽器介面;每個人各自註冊帳號,模型金鑰在 App 的設定裡填寫,不放在環境變數。
|
||||
目前為早期版本 `v0.1.0-alpha`,提供桌面與手機瀏覽器介面;模型金鑰由你自行設定。
|
||||
|
||||
## 功能
|
||||
|
||||
- **一人一帳號**:用帳號密碼註冊後,Agent、對話、憑證與模型金鑰都只屬於那個帳號,彼此不會混用。
|
||||
- **持續的工作空間**:每個 Agent 有自己的對話、工作紀錄與可設定的長期記憶。
|
||||
- **真的能操作電腦**:開網頁、使用終端、整理檔案、操作圖形介面,過程可即時觀看。
|
||||
- **附件直接丟進對話**:訊息可帶檔案或圖片,Agent 能在自己的桌面開啟,放在收件匣的複本會自動到期清除。
|
||||
|
|
@ -37,7 +36,7 @@ LazyBoy 讓 Agent 在 Docker 裡使用自己的 Linux 桌面,操作瀏覽器
|
|||
|
||||
## 快速開始
|
||||
|
||||
需要 Docker 與 Compose、Git、Make、Python 3,以及一組支援的模型 API 金鑰——金鑰註冊後在 App 裡貼上,不寫進 `.env`。macOS 可使用 Docker Desktop 或 OrbStack;Linux 可使用 Docker Engine。
|
||||
需要 Docker 與 Compose、Git、Make、Python 3,以及一組支援的模型 API 金鑰。macOS 可使用 Docker Desktop 或 OrbStack;Linux 可使用 Docker Engine。
|
||||
|
||||
取得本專案原始碼後,在專案根目錄執行:
|
||||
|
||||
|
|
@ -45,14 +44,14 @@ LazyBoy 讓 Agent 在 Docker 裡使用自己的 Linux 桌面,操作瀏覽器
|
|||
make env
|
||||
```
|
||||
|
||||
產生的 `.env` 只有服務內部使用的秘密(supervisor、憑證庫、資料庫);初始化工具會產生這些值,重複執行時保留既有設定。登入與模型金鑰都不需要在這裡設定。
|
||||
編輯產生的 `.env`,填入 `XAI_API_KEY`、`OPENCODE_GO_API_KEY` 或 `OPENAI_API_KEY` 其中之一。初始化工具會產生登入與服務所需的金鑰,重新執行時保留既有設定。
|
||||
|
||||
```bash
|
||||
make up
|
||||
make health
|
||||
```
|
||||
|
||||
開啟 **[http://127.0.0.1:3101](http://127.0.0.1:3101)**,自行註冊一組帳號密碼,到「**設定 → 模型**」貼上模型 API 金鑰,再建立 Agent 並選擇模型。沒有金鑰就不會執行任何工作,App 會告訴你要去哪裡補。
|
||||
開啟 **[http://127.0.0.1:3101](http://127.0.0.1:3101)**,用 `.env` 裡的 `LAZYBOY_APP_TOKEN` 登入,建立 Agent 並選擇模型。
|
||||
|
||||
第一次會從原始碼建置 API 與 Linux 桌面映像,所需時間較長。容器內已包含建置環境,完整 Docker 部署不需要在主機安裝 Rust 或 Node.js。
|
||||
|
||||
|
|
@ -72,7 +71,7 @@ make down # 停止服務,保留 PostgreSQL 資料
|
|||
|
||||
## 在手機上使用
|
||||
|
||||
手機與桌面使用同一個 Web 介面。API 預設只綁 `127.0.0.1`,請在 `.env` 設定 `LAZYBOY_BIND_IP=0.0.0.0`(或指定網卡位址)並重建 api 容器,區網裡的手機才連得到。接著用手機瀏覽器開啟該位址——手機上的 `127.0.0.1` 只代表手機本身,不能拿來連另一台電腦——並在手機上也註冊一個帳號:同一套安裝裡,每個人只會看到自己的 Agent。若要用網域名稱(而不是 IP 或 `localhost`)連線,還要把該名稱加進 `LAZYBOY_ALLOWED_HOSTS`。
|
||||
手機與桌面使用同一個 Web 介面。API 預設只綁 `127.0.0.1`,請在 `.env` 設定 `LAZYBOY_BIND_IP=0.0.0.0`(或指定網卡位址)並重建 api 容器,區網裡的手機才連得到;綁非 loopback 時登入 token 需至少 32 字元。接著用手機瀏覽器開啟該位址——手機上的 `127.0.0.1` 只代表手機本身,不能拿來連另一台電腦。
|
||||
|
||||
聊天側欄可點外側空白處收合。操作遠端桌面時,可切換直接點選與觸控板模式,使用工具列叫出鍵盤、按右鍵或拖曳。對外提供服務時請設定 HTTPS,詳見 [部署指南](./docs/operations.md#安全模型)。
|
||||
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -70,6 +70,3 @@ 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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,12 @@
|
|||
export class ApiError extends Error {
|
||||
status:number;
|
||||
/** 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}
|
||||
constructor(status:number,message:string){super(message);this.name="ApiError";this.status=status}
|
||||
}
|
||||
|
||||
export async function api<T>(path:string, options:RequestInit={}):Promise<T>{
|
||||
// The login cookie is first-party and HttpOnly, so every call has to carry it.
|
||||
const response=await fetch(path,{credentials:"same-origin",...options,headers:{"content-type":"application/json",...options.headers}});
|
||||
const response=await fetch(path,{...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 fields=(typeof body==="object"&&body?body:{}) as {message?:unknown;code?:unknown};const message=typeof fields.message==="string"&&fields.message?fields.message:`${response.status} ${response.statusText}`;throw new ApiError(response.status,message,typeof fields.code==="string"?fields.code:"")}
|
||||
if(!response.ok){const message=typeof body==="object"&&body&&"message" in body?String((body as {message:unknown}).message):`${response.status} ${response.statusText}`;throw new ApiError(response.status,message)}
|
||||
return body as T;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,15 +18,6 @@
|
|||
.overlay-screen>.desktop-frame,.overlay-screen>.empty-computer{width:min(100%,1440px);height:100%;border:1px solid var(--border);border-radius:14px;overflow:hidden;background:var(--main)}
|
||||
.overlay-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)}
|
||||
|
|
@ -85,12 +76,4 @@
|
|||
.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){
|
||||
.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}
|
||||
}
|
||||
@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}}
|
||||
|
|
|
|||
|
|
@ -333,6 +333,7 @@ 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",
|
||||
|
|
@ -352,10 +353,6 @@ export const en: { [K in keyof typeof zhTW]: string } = {
|
|||
helpGroups: "+ → New group, pick at least two. @ a name and that agent answers; unaddressed messages go to the host or the best fit.",
|
||||
helpComputerTitle: "Computer",
|
||||
helpComputer: "The Computer tab on the right is this agent’s 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 don’t have this mode.",
|
||||
helpMemoryTitle: "Memory",
|
||||
helpMemory: "Clearing a chat doesn’t wipe long-term memory. Ask the agent to remember, or add it in Memory on the right.",
|
||||
helpMcpTitle: "MCP plugins",
|
||||
|
|
@ -398,32 +395,11 @@ export const en: { [K in keyof typeof zhTW]: string } = {
|
|||
feedbackDescription: "Write a problem, idea, or request. This is a local workspace, so the text is copied to the clipboard for you to paste into an issue or message.",
|
||||
feedbackPlaceholder: "For example: in group chats I want to pick who speaks first…",
|
||||
copyContent: "Copy",
|
||||
loginTitle: "Sign in",
|
||||
loginDescription: "Use the account you registered on this server. Your agents, chats, and computers stay yours alone.",
|
||||
loginTitle: "Sign in to LazyBoy",
|
||||
loginDescription: "Enter the shared access token configured on the server.",
|
||||
accessToken: "Access token",
|
||||
verifying: "Working…",
|
||||
verifying: "Checking…",
|
||||
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: "Couldn’t 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 can’t 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",
|
||||
|
|
@ -560,8 +536,6 @@ 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",
|
||||
|
|
|
|||
|
|
@ -110,6 +110,7 @@ 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: "重新載入模型列表",
|
||||
|
|
@ -119,10 +120,7 @@ export const zhTW = {
|
|||
aboutDescription: "本機多 Agent 工作區。每個機器人有自己的 Linux 電腦,也可以拉進群組一起討論,並接入 MCP 工具。",
|
||||
apiStatus: "API 狀態:{status}", statusNormal: "正常", statusUnavailable: "無法連線", statusChecking: "檢查中…",
|
||||
helpBotsTitle: "機器人", helpBots: "左上角 + 新增機器人。點左側列進入對話。右鍵可以釘選、隱藏或刪除。", helpGroupsTitle: "群組", helpGroups: "+ → 新增群組,選至少兩位。@誰就由誰回;沒點名時,主持人或最合適的 Agent 會接手。",
|
||||
helpComputerTitle: "電腦", helpComputer: "右側「電腦」是這個 Agent 的獨立桌面。可以啟動、接管滑鼠鍵盤,或讓它自己操作。",
|
||||
meetingMode: "會議模式", exitMeetingMode: "結束會議模式",
|
||||
helpMeetingTitle: "會議模式", helpMeeting: "電腦版標題列可開啟會議模式:螢幕放大放中間,對話移到旁邊,像分享畫面時邊看邊聊。手機沒有這個模式。",
|
||||
helpMemoryTitle: "記憶", helpMemory: "清除對話不會刪長期記憶。可以叫 Agent 記住,或在右側「記憶」手動新增。",
|
||||
helpComputerTitle: "電腦", helpComputer: "右側「電腦」是這個 Agent 的獨立桌面。可以啟動、接管滑鼠鍵盤,或讓它自己操作。", helpMemoryTitle: "記憶", helpMemory: "清除對話不會刪長期記憶。可以叫 Agent 記住,或在右側「記憶」手動新增。",
|
||||
helpMcpTitle: "MCP 外掛", helpMcp: "左下「外掛程式」接入 MCP server。連上的工具會顯示在畫面上,對話時 Agent 可以使用。",
|
||||
helpSkillsTitle: "技能", helpSkills: "+ → 教它一項任務,示範一次就會整理成技能。示範結束後可以匯出 JSON,或把別人的技能檔匯入,換一個機器人也適用。",
|
||||
helpAttachTitle: "附件", helpAttach: "+ → 附加檔案。圖片這則訊息就會給模型看,不會存進對話紀錄。若機器人電腦要打開原檔,會暫放 inbox/,兩小時後自動刪,避免把磁碟塞滿。",
|
||||
|
|
@ -140,15 +138,7 @@ export const zhTW = {
|
|||
providerOpenaiVoiceHint: "OpenAI Realtime。同一套通話介面,之後可切換。",
|
||||
voiceReusesTextKey: "目前會重用文字模型已存的 xAI 金鑰。",
|
||||
feedbackDescription: "寫下問題、想法或想要的功能。這是本機工作區,內容會複製到剪貼簿,方便你貼到 issue 或訊息裡。", feedbackPlaceholder: "例如:群組對話希望可以指定誰先發言…", copyContent: "複製內容",
|
||||
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 會無法開始。",
|
||||
loginTitle: "登入 LazyBoy", loginDescription: "輸入伺服器設定的共享存取 token。", accessToken: "存取 token", verifying: "驗證中…", login: "登入",
|
||||
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 …",
|
||||
|
|
@ -235,7 +225,7 @@ export const zhTW = {
|
|||
monitorStatusPaused: "要你接手",
|
||||
errorTitleInterrupted: "被打斷",
|
||||
errorTitleToolTimeout: "動作逾時",
|
||||
errorTitleModelKey: "模型金鑰不被接受", errorTitleModelKeyMissing: "尚未設定 API 金鑰", errorTitleModelOptIn: "模型需要先在 OpenCode 開啟",
|
||||
errorTitleModelKey: "模型金鑰不被接受",
|
||||
errorTitleModelQuota: "限流或額度不足",
|
||||
errorTitleModelUnknown: "模型名稱找不到",
|
||||
errorTitleModelTimeout: "模型太久沒回話",
|
||||
|
|
|
|||
|
|
@ -482,34 +482,6 @@
|
|||
|
||||
.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}
|
||||
|
||||
|
|
@ -607,44 +579,23 @@
|
|||
.memory-clear-confirm{display:flex;align-items:center;gap:8px;margin-right:auto;color:var(--muted);font-size:13px}
|
||||
|
||||
|
||||
.login-screen{position:relative;display:grid;min-height:100%;min-height:100dvh;place-items:center;padding:72px 24px 64px;background:radial-gradient(circle at 50% 18%,#16241f 0,#080809 58%)}
|
||||
.login-screen{position:relative;display:grid;min-height:100%;place-items:center;padding:88px 20px;background:radial-gradient(circle at 50% 20%,#18201e 0,#080809 55%)}
|
||||
|
||||
.login-dialog{width:min(320px,100%);display:grid;justify-items:stretch;gap:14px}
|
||||
.login-dialog{display:grid;justify-items:stretch}
|
||||
|
||||
.login-dialog>.avatar{justify-self:center;margin:0 0 10px}
|
||||
.login-dialog>.avatar{justify-self:center}
|
||||
|
||||
.login-dialog h1{margin:0 0 8px;text-align:center;font-size:20px;font-weight:650;letter-spacing:.01em}
|
||||
.login-dialog h1,.login-dialog p{text-align:center}
|
||||
|
||||
.login-dialog label{display:grid;gap:6px;color:var(--muted);font-size:13px}
|
||||
|
||||
.login-dialog 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-dialog p{margin-top:-8px;color:var(--muted)}
|
||||
|
||||
.login-error{color:var(--danger-soft);font-size:13px}
|
||||
|
||||
.login-switch{min-height:0;justify-self:center;margin-top:2px;padding:8px;border:0;border-radius:8px;background:transparent;color:var(--muted);font-size:13px;cursor:pointer}
|
||||
.login-lang{position:absolute;top:24px;right:24px;display:flex;align-items:center;gap:6px}
|
||||
|
||||
.login-switch:hover{color:var(--ink)}
|
||||
.login-lang button{height:32px;padding:0 12px;border:1px solid var(--border);border-radius:9px;background:transparent;color:var(--muted);cursor:pointer}
|
||||
|
||||
.login-switch:disabled{opacity:.35;cursor:not-allowed}
|
||||
|
||||
.login-lang{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}
|
||||
}
|
||||
.login-lang button.picked{border-color:var(--accent);color:var(--ink);background:rgba(62,197,168,.08)}
|
||||
|
||||
|
||||
@keyframes working-shimmer{to{background-position:-220% 0}}
|
||||
|
|
|
|||
|
|
@ -4,26 +4,14 @@
|
|||
@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}.app-shell.meeting-mode,.app-shell.meeting-mode.right-open,.app-shell.meeting-mode.right-collapsed{grid-template-columns:200px minmax(0,1fr) 320px}}
|
||||
@media(max-width:1200px){.app-shell.right-open{grid-template-columns:230px minmax(0,1fr) 340px}.app-shell.right-collapsed{grid-template-columns:230px minmax(0,1fr) 52px}}
|
||||
@media(max-width:1050px){
|
||||
.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,.app-shell.stage-open:not(.meeting-mode) .side-card-backdrop{display:block;position:fixed;inset:0;z-index:44;background:rgba(0,0,0,.5)}
|
||||
.app-shell.right-open .side-card-backdrop{display:block;position:fixed;inset:0;z-index:44;background:rgba(0,0,0,.5)}
|
||||
.app-shell.right-open .side-card{position:fixed;z-index:45;inset:0 0 0 auto;width:min(420px,100vw);box-shadow:-20px 0 60px #000}
|
||||
.app-shell: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}}
|
||||
|
|
|
|||
|
|
@ -25,8 +25,6 @@ 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"],
|
||||
|
|
@ -40,8 +38,6 @@ 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",
|
||||
|
|
|
|||
|
|
@ -38,13 +38,15 @@ export interface VoiceSettings {
|
|||
ready: boolean;
|
||||
missing?: string | null;
|
||||
apiKeySet: boolean;
|
||||
envKeySet: boolean;
|
||||
envKeyName: string;
|
||||
reusesTextKey: boolean;
|
||||
providers: { id: VoiceProviderId; name: string }[];
|
||||
providers: { id: VoiceProviderId; name: string; envKeyName: 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; providers:WorkspaceProvider[]; models:WorkspaceModel[] }
|
||||
export interface WorkspaceSettings { provider:ModelProviderId; modelId:string; baseUrl:string; apiKeySet:boolean; envKeySet:boolean; envKeyName:string; providers:WorkspaceProvider[]; models:WorkspaceModel[] }
|
||||
|
||||
export interface MemoryStatus { globallyEnabled:boolean; embeddingStatus:"disabled"|"loading"|"ready"|"busy"|"unavailable"; storedCount:number; indexedCount:number }
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ 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" }]
|
||||
|
|
@ -93,8 +94,8 @@ export function VoiceSettingsDialog({ close }: { close: () => void }) {
|
|||
<legend>{t("voiceProvider")}</legend>
|
||||
<div className="provider-grid">
|
||||
{(settings?.providers || [
|
||||
{ id: "xai" as const, name: t("providerXai") },
|
||||
{ id: "openai" as const, name: t("providerOpenai") },
|
||||
{ id: "xai" as const, name: t("providerXai"), envKeyName: "XAI_API_KEY" },
|
||||
{ id: "openai" as const, name: t("providerOpenai"), envKeyName: "OPENAI_API_KEY" },
|
||||
]).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}
|
||||
|
|
@ -129,6 +130,7 @@ export function VoiceSettingsDialog({ close }: { close: () => void }) {
|
|||
<input type="password" autoComplete="off" value={apiKey} onChange={(event) => { setApiKey(event.target.value); setClearKey(false); }} placeholder={settings?.apiKeySet ? t("apiKeyStored") : t("apiKeyPlaceholder")} />
|
||||
</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}
|
||||
|
|
|
|||
|
|
@ -24,7 +24,6 @@ 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
|
||||
|
|
|
|||
|
|
@ -1,498 +0,0 @@
|
|||
//! Accounts: a username and a password are the only way into LazyBoy.
|
||||
//!
|
||||
//! Opening a workspace used to mean knowing one shared token from `.env`, and
|
||||
//! everyone who knew it saw the same agents and the same desktops. Here a
|
||||
//! person registers, owns a workspace of their own, and the cookie they get
|
||||
//! resolves to exactly one actor on every later request — which is what keeps
|
||||
//! one person's agents out of another person's list.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use axum::extract::State;
|
||||
use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::{get, post};
|
||||
use axum::{Json, Router};
|
||||
use hmac::{Hmac, Mac};
|
||||
use rand::RngCore;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use sha2::Sha256;
|
||||
|
||||
use crate::auth::{self, COOKIE_NAME};
|
||||
use crate::state::AppState;
|
||||
|
||||
/// PBKDF2-HMAC-SHA256 rounds. High enough that cracking a stolen password
|
||||
/// database is expensive, low enough that signing in never feels stuck. The
|
||||
/// number is stored with every hash, so raising it later leaves old accounts
|
||||
/// working and only affects newly set passwords.
|
||||
const PBKDF2_ROUNDS: u32 = 120_000;
|
||||
/// A hostile or corrupt row must not turn one login into a CPU denial of
|
||||
/// service; anything above this is rejected rather than computed.
|
||||
const MAX_PBKDF2_ROUNDS: u32 = 2_000_000;
|
||||
const SALT_BYTES: usize = 16;
|
||||
const KEY_BYTES: usize = 32;
|
||||
const HASH_PREFIX: &str = "pbkdf2-sha256";
|
||||
|
||||
pub const MIN_PASSWORD_CHARS: usize = 8;
|
||||
pub const MAX_PASSWORD_CHARS: usize = 256;
|
||||
const MIN_USERNAME_CHARS: usize = 3;
|
||||
const MAX_USERNAME_CHARS: usize = 32;
|
||||
/// A login stays valid for a week, and survives an API restart: sessions live
|
||||
/// in the database, not in process memory.
|
||||
const SESSION_DAYS: i64 = 7;
|
||||
/// Failed sign-ins per username before a cool-down, and how long it lasts.
|
||||
/// Passwords are hashed on every attempt, so a throttled name is also how one
|
||||
/// account cannot be used to burn the whole server.
|
||||
const MAX_FAILED_LOGINS: u32 = 8;
|
||||
const LOGIN_COOLDOWN: Duration = Duration::from_secs(300);
|
||||
|
||||
fn hmac_sha256(key: &[u8], data: &[u8]) -> [u8; KEY_BYTES] {
|
||||
let mut mac = <Hmac<Sha256> as Mac>::new_from_slice(key).expect("HMAC accepts any key length");
|
||||
mac.update(data);
|
||||
let digest = mac.finalize().into_bytes();
|
||||
let mut out = [0u8; KEY_BYTES];
|
||||
out.copy_from_slice(&digest);
|
||||
out
|
||||
}
|
||||
|
||||
/// PBKDF2 with the single-block PRF, which is all a 256-bit key needs.
|
||||
fn pbkdf2_sha256(password: &[u8], salt: &[u8], rounds: u32) -> [u8; KEY_BYTES] {
|
||||
let mut previous = hmac_sha256(password, &[salt, &1u32.to_be_bytes()].concat());
|
||||
let mut derived = previous;
|
||||
for _ in 1..rounds {
|
||||
previous = hmac_sha256(password, &previous);
|
||||
for (byte, round) in derived.iter_mut().zip(previous) {
|
||||
*byte ^= round;
|
||||
}
|
||||
}
|
||||
derived
|
||||
}
|
||||
|
||||
/// `pbkdf2-sha256$rounds$salt$hash`, self-describing so the format can move.
|
||||
pub fn hash_password(password: &str) -> String {
|
||||
let mut salt = [0u8; SALT_BYTES];
|
||||
rand::thread_rng().fill_bytes(&mut salt);
|
||||
let key = pbkdf2_sha256(password.as_bytes(), &salt, PBKDF2_ROUNDS);
|
||||
format!(
|
||||
"{HASH_PREFIX}${PBKDF2_ROUNDS}${}${}",
|
||||
hex::encode(salt),
|
||||
hex::encode(key)
|
||||
)
|
||||
}
|
||||
|
||||
fn parse_password(stored: &str) -> Option<(u32, Vec<u8>, Vec<u8>)> {
|
||||
let (algorithm, rest) = stored.split_once('$')?;
|
||||
if algorithm != HASH_PREFIX {
|
||||
return None;
|
||||
}
|
||||
let mut parts = rest.split('$');
|
||||
let rounds: u32 = parts.next()?.parse().ok()?;
|
||||
if rounds == 0 || rounds > MAX_PBKDF2_ROUNDS {
|
||||
return None;
|
||||
}
|
||||
let salt = hex::decode(parts.next()?).ok()?;
|
||||
let key = hex::decode(parts.next()?).ok()?;
|
||||
if salt.is_empty() || key.is_empty() || parts.next().is_some() {
|
||||
return None;
|
||||
}
|
||||
Some((rounds, salt, key))
|
||||
}
|
||||
|
||||
/// Constant-time check against the stored hash. An unparseable or unknown-format
|
||||
/// hash is simply "not this password" — never an open door.
|
||||
pub fn verify_password(password: &str, stored: &str) -> bool {
|
||||
let Some((rounds, salt, expected)) = parse_password(stored) else {
|
||||
return false;
|
||||
};
|
||||
let key = pbkdf2_sha256(password.as_bytes(), &salt, rounds);
|
||||
auth::constant_time_eq(&key, &expected)
|
||||
}
|
||||
|
||||
/// Usernames are addresses, so they are stored in one spelling.
|
||||
pub fn normalize_username(raw: &str) -> String {
|
||||
raw.trim().to_lowercase()
|
||||
}
|
||||
|
||||
/// The rules that keep a name typeable, comparable, and unambiguous in a URL
|
||||
/// or an `@mention`: letters and numbers (any language) plus `.` `_` `-`,
|
||||
/// bracketed by word characters.
|
||||
fn username_problem(username: &str) -> Option<&'static str> {
|
||||
if username.chars().count() < MIN_USERNAME_CHARS {
|
||||
return Some("username_too_short");
|
||||
}
|
||||
if username.chars().count() > MAX_USERNAME_CHARS {
|
||||
return Some("username_too_long");
|
||||
}
|
||||
let first = username.chars().next().unwrap_or(' ');
|
||||
let last = username.chars().last().unwrap_or(' ');
|
||||
if !first.is_alphanumeric() || !last.is_alphanumeric() {
|
||||
return Some("username_must_start_with_letter");
|
||||
}
|
||||
if username
|
||||
.chars()
|
||||
.any(|ch| !(ch.is_alphanumeric() || matches!(ch, '.' | '_' | '-')))
|
||||
{
|
||||
return Some("username_has_invalid_characters");
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn password_problem(password: &str) -> Option<&'static str> {
|
||||
let count = password.chars().count();
|
||||
if count < MIN_PASSWORD_CHARS {
|
||||
return Some("password_too_short");
|
||||
}
|
||||
if count > MAX_PASSWORD_CHARS {
|
||||
return Some("password_too_long");
|
||||
}
|
||||
if password.chars().any(char::is_control) {
|
||||
return Some("password_has_invalid_characters");
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Sliding cool-down per username. In memory on purpose: it exists to make
|
||||
/// online guessing slow, not to survive a restart.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct LoginGuard {
|
||||
failures: Arc<Mutex<Vec<(String, u32, Instant)>>>,
|
||||
}
|
||||
|
||||
impl LoginGuard {
|
||||
fn locked(&self, key: &str) -> bool {
|
||||
let now = Instant::now();
|
||||
let failures = self
|
||||
.failures
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner());
|
||||
failures
|
||||
.iter()
|
||||
.any(|(name, count, until)| name == key && *count >= MAX_FAILED_LOGINS && *until > now)
|
||||
}
|
||||
|
||||
fn record_failure(&self, key: &str) {
|
||||
let mut failures = self
|
||||
.failures
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner());
|
||||
failures.retain(|(_, _, until)| *until > Instant::now());
|
||||
let until = Instant::now() + LOGIN_COOLDOWN;
|
||||
match failures.iter_mut().find(|(name, _, _)| name == key) {
|
||||
Some(entry) => {
|
||||
entry.1 += 1;
|
||||
entry.2 = until;
|
||||
}
|
||||
None => failures.push((key.to_string(), 1, until)),
|
||||
}
|
||||
}
|
||||
|
||||
fn clear(&self, key: &str) {
|
||||
let mut failures = self
|
||||
.failures
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner());
|
||||
failures.retain(|(name, _, _)| name != key);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CredentialsInput {
|
||||
#[serde(default)]
|
||||
username: String,
|
||||
#[serde(default)]
|
||||
password: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct SignedIn {
|
||||
ok: bool,
|
||||
username: String,
|
||||
}
|
||||
|
||||
pub fn router(state: AppState) -> Router {
|
||||
Router::new()
|
||||
.route("/api/auth/session", get(current_session).delete(logout))
|
||||
.route("/api/auth/register", post(register))
|
||||
.route("/api/auth/login", post(login))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
/// The single source of truth for "am I signed in": the login screen and every
|
||||
/// 401 that reaches the browser ends up here.
|
||||
pub async fn current_session(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> Json<serde_json::Value> {
|
||||
let signed_in = auth::session_username(&state, &headers).await;
|
||||
Json(json!({
|
||||
"authenticated": signed_in.is_some(),
|
||||
"required": true,
|
||||
"username": signed_in,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn register(
|
||||
State(state): State<AppState>,
|
||||
Json(input): Json<CredentialsInput>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
|
||||
let username = normalize_username(&input.username);
|
||||
if let Some(code) = username_problem(&username).or_else(|| password_problem(&input.password)) {
|
||||
return Err(bad_request(code));
|
||||
}
|
||||
let hash = hash_password(&input.password);
|
||||
let user_id = match state.db.create_account(&username, &hash).await {
|
||||
Ok(user_id) => user_id,
|
||||
Err(error) if is_username_taken(&error) => {
|
||||
return Err(bad_request("username_taken"));
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!("register failed: {error}");
|
||||
return Err(server_error());
|
||||
}
|
||||
};
|
||||
// A new account owns its workspace from the first second; nothing about it
|
||||
// is shared with the person who registered before them.
|
||||
if let Err(error) = state.db.ensure_default_space(&user_id).await {
|
||||
tracing::warn!("workspace creation failed: {error}");
|
||||
return Err(server_error());
|
||||
}
|
||||
tracing::info!("registered account {username}");
|
||||
signed_in_response(&state, &user_id, &username).await
|
||||
}
|
||||
|
||||
async fn login(
|
||||
State(state): State<AppState>,
|
||||
Json(input): Json<CredentialsInput>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
|
||||
let username = normalize_username(&input.username);
|
||||
if username.is_empty() || input.password.is_empty() {
|
||||
return Err(bad_request("fields_required"));
|
||||
}
|
||||
if state.auth.guard.locked(&username) {
|
||||
return Err((
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
Json(json!({"message": "嘗試次數太多,請 5 分鐘後再試", "code": "too_many_attempts"})),
|
||||
));
|
||||
}
|
||||
let account = state
|
||||
.db
|
||||
.account_by_username(&username)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
tracing::warn!("login lookup failed: {error}");
|
||||
server_error()
|
||||
})?;
|
||||
// Hash even when the name is unknown so that a missing account and a wrong
|
||||
// password cost the same and answer the same way.
|
||||
let stored = account
|
||||
.as_ref()
|
||||
.and_then(|account| account.password_hash.clone());
|
||||
let verified = stored
|
||||
.as_deref()
|
||||
.is_some_and(|hash| verify_password(&input.password, hash));
|
||||
let Some(account) = account.filter(|_| verified) else {
|
||||
state.auth.guard.record_failure(&username);
|
||||
return Err((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({"message": "帳號或密碼不正確", "code": "invalid_credentials"})),
|
||||
));
|
||||
};
|
||||
state.auth.guard.clear(&username);
|
||||
let user_id = account.user_id;
|
||||
let username = account.username;
|
||||
if let Err(error) = state.db.ensure_default_space(&user_id).await {
|
||||
tracing::warn!("workspace lookup failed: {error}");
|
||||
return Err(server_error());
|
||||
}
|
||||
signed_in_response(&state, &user_id, &username).await
|
||||
}
|
||||
|
||||
async fn signed_in_response(
|
||||
state: &AppState,
|
||||
user_id: &str,
|
||||
username: &str,
|
||||
) -> Result<Response, (StatusCode, Json<serde_json::Value>)> {
|
||||
let token = auth::new_session_token();
|
||||
state
|
||||
.db
|
||||
.create_session(user_id, &auth::token_digest(&token), SESSION_DAYS * 86_400)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
tracing::warn!("session creation failed: {error}");
|
||||
server_error()
|
||||
})?;
|
||||
let mut response = Json(SignedIn {
|
||||
ok: true,
|
||||
username: username.to_string(),
|
||||
})
|
||||
.into_response();
|
||||
let cookie = format!(
|
||||
"{COOKIE_NAME}={token}; Path=/; HttpOnly; SameSite=Strict; Max-Age={}{}",
|
||||
SESSION_DAYS * 86_400,
|
||||
if state.auth.secure_cookie() {
|
||||
"; Secure"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
);
|
||||
response.headers_mut().insert(
|
||||
header::SET_COOKIE,
|
||||
HeaderValue::from_str(&cookie).map_err(|_| server_error())?,
|
||||
);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
async fn logout(State(state): State<AppState>, headers: HeaderMap) -> impl IntoResponse {
|
||||
if let Some(token) = auth::session_token(&headers)
|
||||
&& let Err(error) = state.db.delete_session(&auth::token_digest(&token)).await
|
||||
{
|
||||
tracing::warn!("session deletion failed: {error}");
|
||||
}
|
||||
let mut response = Json(json!({"ok": true})).into_response();
|
||||
response.headers_mut().insert(
|
||||
header::SET_COOKIE,
|
||||
HeaderValue::from_static(auth::EXPIRED_COOKIE),
|
||||
);
|
||||
response
|
||||
}
|
||||
|
||||
fn is_username_taken(error: &sqlx::Error) -> bool {
|
||||
error
|
||||
.as_database_error()
|
||||
.and_then(|error| error.code().map(|code| code == "23505"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn bad_request(code: &'static str) -> (StatusCode, Json<serde_json::Value>) {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"message": message_for(code), "code": code})),
|
||||
)
|
||||
}
|
||||
|
||||
fn server_error() -> (StatusCode, Json<serde_json::Value>) {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"message": "伺服器錯誤,請稍後再試"})),
|
||||
)
|
||||
}
|
||||
|
||||
fn message_for(code: &str) -> &'static str {
|
||||
match code {
|
||||
"username_too_short" => "帳號至少 3 個字元",
|
||||
"username_too_long" => "帳號最多 32 個字元",
|
||||
"username_must_start_with_letter" => "帳號必須以字母或數字開頭結尾",
|
||||
"username_has_invalid_characters" => "帳號只能使用字母、數字與 . _ -",
|
||||
"username_taken" => "這個帳號已經有人用了",
|
||||
"password_too_short" => "密碼至少 8 個字元",
|
||||
"password_too_long" => "密碼太長了",
|
||||
"password_has_invalid_characters" => "密碼含有無法使用的字元",
|
||||
_ => "請輸入帳號與密碼",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod password_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_hash_verifies_its_own_password_and_nothing_else() {
|
||||
let hash = hash_password("correct horse battery staple");
|
||||
assert!(verify_password("correct horse battery staple", &hash));
|
||||
assert!(!verify_password("Correct horse battery staple", &hash));
|
||||
assert!(!verify_password("", &hash));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_same_password_hashes_differently_each_time() {
|
||||
assert_ne!(
|
||||
hash_password("same password"),
|
||||
hash_password("same password")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_foreign_or_malformed_hash_never_opens_the_door() {
|
||||
for stored in [
|
||||
"",
|
||||
"plain-text-password",
|
||||
"bcrypt$10$abc",
|
||||
"pbkdf2-sha256$not-a-number$00$00",
|
||||
"pbkdf2-sha256$0$00$00",
|
||||
"pbkdf2-sha256$999999999$00$00",
|
||||
"pbkdf2-sha256$1000$00",
|
||||
] {
|
||||
assert!(!verify_password("anything", stored), "{stored}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stored_hash_round_trips_its_rounds_salt_and_key() {
|
||||
let hash = hash_password("round trip password");
|
||||
let (rounds, salt, key) = parse_password(&hash).expect("a hash we wrote must parse");
|
||||
assert_eq!(rounds, PBKDF2_ROUNDS);
|
||||
assert_eq!(salt.len(), SALT_BYTES);
|
||||
assert_eq!(key.len(), KEY_BYTES);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod validation_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn usernames_are_trimmed_and_lowercased() {
|
||||
assert_eq!(normalize_username(" WorkBench "), "workbench");
|
||||
assert_eq!(normalize_username("王小明"), "王小明");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn username_rules_accept_plain_names_and_reject_addresses() {
|
||||
for good in ["ana", "ana.lin", "lazy_boy", "王小明", "a12"] {
|
||||
assert_eq!(username_problem(good), None, "{good}");
|
||||
}
|
||||
for bad in [
|
||||
"an",
|
||||
".ana",
|
||||
"ana.",
|
||||
"ana lin",
|
||||
"ana@example.com",
|
||||
"ana\u{1}",
|
||||
&"x".repeat(MAX_USERNAME_CHARS + 1),
|
||||
] {
|
||||
assert!(username_problem(bad).is_some(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn passwords_need_a_minimum_length() {
|
||||
assert!(password_problem("1234567").is_some());
|
||||
assert_eq!(password_problem("12345678"), None);
|
||||
assert!(password_problem(&"x".repeat(MAX_PASSWORD_CHARS + 1)).is_some());
|
||||
assert!(password_problem("pass\nword").is_some());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod guard_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_locked_username_stays_locked_until_it_is_cleared() {
|
||||
let guard = LoginGuard::default();
|
||||
for _ in 0..MAX_FAILED_LOGINS - 1 {
|
||||
guard.record_failure("ana");
|
||||
}
|
||||
assert!(!guard.locked("ana"), "one attempt is still left");
|
||||
guard.record_failure("ana");
|
||||
assert!(guard.locked("ana"));
|
||||
assert!(
|
||||
!guard.locked("bob"),
|
||||
"a cool-down never spills to another account"
|
||||
);
|
||||
guard.clear("ana");
|
||||
assert!(!guard.locked("ana"));
|
||||
}
|
||||
}
|
||||
|
|
@ -530,10 +530,7 @@ 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("小美"));
|
||||
|
|
|
|||
|
|
@ -1,52 +1,114 @@
|
|||
//! Session plumbing: the login cookie, who it belongs to, and the browser
|
||||
//! boundary that keeps other sites out.
|
||||
//!
|
||||
//! The cookie holds a random value; the database holds only its digest, so a
|
||||
//! stolen backup of `app_sessions` cannot be replayed as a login. Resolving a
|
||||
//! cookie yields one [`Actor`] — one person and one workspace — which every
|
||||
//! handler then uses as its only lens on the data.
|
||||
|
||||
use axum::Json;
|
||||
use axum::extract::{Request, State};
|
||||
use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
|
||||
use axum::middleware::Next;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use rand::RngCore;
|
||||
use axum::{Json, Router};
|
||||
use serde::Deserialize;
|
||||
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;
|
||||
|
||||
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";
|
||||
const COOKIE_NAME: &str = "lazyboy_session";
|
||||
|
||||
#[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 {
|
||||
Self {
|
||||
secure_cookie: std::env::var("LAZYBOY_SECURE_COOKIE")
|
||||
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),
|
||||
guard: LoginGuard::default(),
|
||||
.unwrap_or(false);
|
||||
Self {
|
||||
token,
|
||||
sessions: Arc::new(Mutex::new(HashMap::new())),
|
||||
secure_cookie,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set `Secure` on the cookie. Needed when the app is served over HTTPS.
|
||||
pub fn secure_cookie(&self) -> bool {
|
||||
self.secure_cookie
|
||||
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())));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn constant_time_eq(left: &[u8], right: &[u8]) -> bool {
|
||||
fn constant_time_eq(left: &[u8], right: &[u8]) -> bool {
|
||||
if left.len() != right.len() {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -57,7 +119,7 @@ pub(crate) fn constant_time_eq(left: &[u8], right: &[u8]) -> bool {
|
|||
difference == 0
|
||||
}
|
||||
|
||||
pub(crate) fn cookie_value<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
|
||||
fn cookie_value<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
|
||||
headers
|
||||
.get(header::COOKIE)?
|
||||
.to_str()
|
||||
|
|
@ -67,80 +129,96 @@ pub(crate) fn cookie_value<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a
|
|||
.find_map(|(key, value)| (key == name).then_some(value))
|
||||
}
|
||||
|
||||
pub(crate) fn session_token(headers: &HeaderMap) -> Option<String> {
|
||||
let token = cookie_value(headers, COOKIE_NAME)?;
|
||||
(!token.is_empty()).then(|| token.to_string())
|
||||
#[derive(Deserialize)]
|
||||
struct LoginInput {
|
||||
token: String,
|
||||
}
|
||||
|
||||
/// 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)
|
||||
pub fn public_router(state: AppState) -> Router {
|
||||
Router::new()
|
||||
.route(
|
||||
"/api/session",
|
||||
axum::routing::get(session).post(login).delete(logout),
|
||||
)
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
pub(crate) fn token_digest(token: &str) -> String {
|
||||
hex::encode(Sha256::digest(token.as_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()
|
||||
}))
|
||||
}
|
||||
|
||||
/// 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()
|
||||
async fn login(
|
||||
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)
|
||||
}
|
||||
|
||||
/// 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 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
|
||||
}
|
||||
|
||||
async fn unauthorized() -> Response {
|
||||
pub async fn require_auth(State(state): State<AppState>, request: Request, next: Next) -> Response {
|
||||
if state.auth.valid_session(request.headers()) {
|
||||
next.run(request).await
|
||||
} else {
|
||||
(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({"message": "請先登入", "code": "unauthenticated"})),
|
||||
Json(json!({"message": "請先登入"})),
|
||||
)
|
||||
.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>,
|
||||
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,
|
||||
|
||||
#[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"));
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 let Err(rejection) = check_browser_request(request.headers()) {
|
||||
if !allowed_browser_request(request.headers(), state.auth.enabled()) {
|
||||
return (
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(json!({"message": rejection.message()})),
|
||||
Json(json!({"message":"cross-origin request rejected"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
|
@ -166,214 +244,90 @@ pub async fn browser_boundary(
|
|||
response
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
fn allowed_browser_request(headers: &HeaderMap, authenticated_mode: bool) -> bool {
|
||||
if headers.get("sec-fetch-site").and_then(|v| v.to_str().ok()) == Some("cross-site") {
|
||||
return Err(BrowserRejection::CrossOrigin);
|
||||
return false;
|
||||
}
|
||||
let Some(host) = headers.get(header::HOST).and_then(|v| v.to_str().ok()) else {
|
||||
return Err(BrowserRejection::InvalidHost);
|
||||
return false;
|
||||
};
|
||||
if !served_at_an_allowed_host(host, &listed_hosts()) {
|
||||
return Err(BrowserRejection::InvalidHost);
|
||||
}
|
||||
if let Some(origin) = headers.get(header::ORIGIN) {
|
||||
let Some(origin) = origin
|
||||
.to_str()
|
||||
.ok()
|
||||
.and_then(|s| reqwest::Url::parse(s).ok())
|
||||
else {
|
||||
return Err(BrowserRejection::CrossOrigin);
|
||||
};
|
||||
let Ok(expected) = reqwest::Url::parse(&format!("{}://{host}", origin.scheme())) else {
|
||||
return Err(BrowserRejection::CrossOrigin);
|
||||
};
|
||||
if !matches!(origin.scheme(), "http" | "https") || origin.origin() != expected.origin() {
|
||||
return Err(BrowserRejection::CrossOrigin);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn allowed_browser_request(headers: &HeaderMap) -> bool {
|
||||
check_browser_request(headers).is_ok()
|
||||
}
|
||||
|
||||
fn listed_hosts_from(value: &str) -> Vec<String> {
|
||||
value
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|host| !host.is_empty())
|
||||
.map(|host| host.to_ascii_lowercase())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Which hostnames this install may be addressed by.
|
||||
///
|
||||
/// Loopback and plain IP addresses are always fine — that is how a self-hosted
|
||||
/// LazyBoy is normally opened, from this machine or from another one on the
|
||||
/// same network. A *domain name* is only believed when the operator listed it,
|
||||
/// because a domain an attacker controls can be pointed at this machine (DNS
|
||||
/// rebinding) and would otherwise read a signed-in session.
|
||||
fn served_at_an_allowed_host(host: &str, listed: &[String]) -> bool {
|
||||
let Ok(destination) = reqwest::Url::parse(&format!("http://{host}")) else {
|
||||
return false;
|
||||
};
|
||||
let Some(hostname) = destination.host_str() else {
|
||||
if !authenticated_mode
|
||||
&& !matches!(
|
||||
destination.host_str(),
|
||||
Some("localhost" | "127.0.0.1" | "[::1]")
|
||||
)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if let Some(origin) = headers.get(header::ORIGIN) {
|
||||
let Ok(origin) = origin
|
||||
.to_str()
|
||||
.ok()
|
||||
.and_then(|s| reqwest::Url::parse(s).ok())
|
||||
.ok_or(())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
// `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"));
|
||||
let Ok(expected) = reqwest::Url::parse(&format!("{}://{host}", origin.scheme())) else {
|
||||
return false;
|
||||
};
|
||||
if !matches!(origin.scheme(), "http" | "https") || origin.origin() != expected.origin() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod session_cookie_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn session_tokens_are_unique_and_only_the_digest_is_stored() {
|
||||
let first = new_session_token();
|
||||
let second = new_session_token();
|
||||
assert_ne!(first, second);
|
||||
assert_eq!(first.len(), 64);
|
||||
assert_ne!(token_digest(&first), first);
|
||||
assert_eq!(token_digest(&first), token_digest(&first));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_login_cookie_is_read_by_name_only() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
header::COOKIE,
|
||||
format!("other=1; {COOKIE_NAME}=abc123; third=2")
|
||||
.parse()
|
||||
.unwrap(),
|
||||
);
|
||||
assert_eq!(session_token(&headers).as_deref(), Some("abc123"));
|
||||
headers.insert(header::COOKIE, "other=1".parse().unwrap());
|
||||
assert_eq!(session_token(&headers), None);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod origin_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn blocks_cross_origin_requests() {
|
||||
fn blocks_cross_origin_and_dns_rebinding() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(header::HOST, "localhost:3101".parse().unwrap());
|
||||
assert!(allowed_browser_request(&headers));
|
||||
assert!(allowed_browser_request(&headers, false));
|
||||
headers.insert(header::ORIGIN, "https://evil.example".parse().unwrap());
|
||||
assert!(!allowed_browser_request(&headers));
|
||||
assert!(!allowed_browser_request(&headers, true));
|
||||
headers.insert(header::ORIGIN, "http://localhost:3101".parse().unwrap());
|
||||
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();
|
||||
assert!(allowed_browser_request(&headers, true));
|
||||
headers.remove(header::ORIGIN);
|
||||
headers.insert(header::HOST, "rebinding.example".parse().unwrap());
|
||||
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}");
|
||||
}
|
||||
assert!(!allowed_browser_request(&headers, false));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod allowed_host_tests {
|
||||
mod session_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
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));
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,8 @@
|
|||
use axum::Json;
|
||||
use axum::extract::FromRequestParts;
|
||||
use axum::http::StatusCode;
|
||||
use axum::http::request::Parts;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use chrono::{DateTime, Utc};
|
||||
use 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;
|
||||
|
||||
|
|
@ -17,41 +11,12 @@ 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 {
|
||||
|
|
@ -138,198 +103,28 @@ pub struct SpaceRow {
|
|||
}
|
||||
|
||||
impl Db {
|
||||
/// 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",
|
||||
)
|
||||
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)
|
||||
.fetch_optional(&self.pool)
|
||||
.bind("Local")
|
||||
.execute(&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("My workspace")
|
||||
.bind("Home")
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
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(())
|
||||
Ok(Actor {
|
||||
user_id: user_id.into(),
|
||||
space_id: space_id.into(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get_space(&self, actor: &Actor) -> Result<Option<SpaceRow>, sqlx::Error> {
|
||||
|
|
@ -710,74 +505,3 @@ pub fn parse_run_status(status: &str) -> Option<RunStatus> {
|
|||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[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)));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
mod accounts;
|
||||
mod attachments;
|
||||
mod auth;
|
||||
mod computer;
|
||||
|
|
@ -41,21 +40,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 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 actor = state.bootstrap().await.expect("bootstrap");
|
||||
let mcp_state = state.clone();
|
||||
tokio::spawn(async move {
|
||||
mcp_state.mcp.reconnect_everyone(&mcp_state).await;
|
||||
mcp_state.mcp.reconnect_all(mcp_state.pool(), &actor).await;
|
||||
});
|
||||
|
||||
let retention_state = state.clone();
|
||||
|
|
@ -78,12 +77,9 @@ 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() {
|
||||
// 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"
|
||||
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"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -93,7 +89,7 @@ async fn main() {
|
|||
"/api/health",
|
||||
axum::routing::get(|| async { axum::Json(serde_json::json!({"ok": true})) }),
|
||||
)
|
||||
.merge(accounts::router(state.clone()))
|
||||
.merge(auth::public_router(state.clone()))
|
||||
.merge(routes::router(state.clone()))
|
||||
.layer(DefaultBodyLimit::max(28 * 1024 * 1024))
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
|
|
|
|||
|
|
@ -187,21 +187,6 @@ 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,
|
||||
|
|
@ -503,6 +488,13 @@ 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 {
|
||||
|
|
@ -515,10 +507,8 @@ async fn present(state: &AppState, _actor: &Actor, mut row: McpRow) -> McpServer
|
|||
row.into_server("disconnected".into(), error, Vec::new())
|
||||
}
|
||||
|
||||
async fn list_servers(
|
||||
actor: Actor,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Vec<McpServer>>, ApiError> {
|
||||
async fn list_servers(State(state): State<AppState>) -> Result<Json<Vec<McpServer>>, ApiError> {
|
||||
let actor = actor(&state).await?;
|
||||
let rows = load_rows(state.pool(), &actor)
|
||||
.await
|
||||
.map_err(|error| internal(error.to_string()))?;
|
||||
|
|
@ -548,11 +538,10 @@ 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(
|
||||
|
|
@ -616,9 +605,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()))?
|
||||
|
|
@ -628,10 +617,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()))?
|
||||
|
|
@ -725,9 +714,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)
|
||||
|
|
@ -744,9 +733,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()))?
|
||||
|
|
|
|||
|
|
@ -718,29 +718,25 @@ pub fn router() -> Router<AppState> {
|
|||
)
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
async fn scoped_actor(state: &AppState, bot_id: &str) -> Result<Actor, StatusCode> {
|
||||
let actor = state
|
||||
.bootstrap()
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
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(())
|
||||
Ok(actor)
|
||||
}
|
||||
|
||||
async fn memory_status(
|
||||
State(state): State<AppState>,
|
||||
actor: Actor,
|
||||
Path(bot_id): Path<String>,
|
||||
) -> Result<Json<Value>, StatusCode> {
|
||||
assert_bot_is_mine(&state, &actor, &bot_id).await?;
|
||||
let actor = scoped_actor(&state, &bot_id).await?;
|
||||
let (stored, indexed): (i64, i64) = sqlx::query_as(
|
||||
"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",
|
||||
|
|
@ -761,10 +757,9 @@ async fn memory_status(
|
|||
|
||||
async fn list_memories(
|
||||
State(state): State<AppState>,
|
||||
actor: Actor,
|
||||
Path(bot_id): Path<String>,
|
||||
) -> Result<Json<Vec<MemoryItem>>, StatusCode> {
|
||||
assert_bot_is_mine(&state, &actor, &bot_id).await?;
|
||||
let actor = scoped_actor(&state, &bot_id).await?;
|
||||
state
|
||||
.memory
|
||||
.list(state.pool(), &actor, &bot_id)
|
||||
|
|
@ -775,13 +770,10 @@ 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>)> {
|
||||
assert_bot_is_mine(&state, &actor, &bot_id)
|
||||
.await
|
||||
.map_err(api_status)?;
|
||||
let actor = scoped_actor(&state, &bot_id).await.map_err(api_status)?;
|
||||
state
|
||||
.memory
|
||||
.remember(state.pool(), &actor, &bot_id, input)
|
||||
|
|
@ -792,13 +784,10 @@ 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>)> {
|
||||
assert_bot_is_mine(&state, &actor, &bot_id)
|
||||
.await
|
||||
.map_err(api_status)?;
|
||||
let actor = scoped_actor(&state, &bot_id).await.map_err(api_status)?;
|
||||
state
|
||||
.memory
|
||||
.update(state.pool(), &actor, &bot_id, memory_id, input)
|
||||
|
|
@ -810,12 +799,9 @@ 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>)> {
|
||||
assert_bot_is_mine(&state, &actor, &bot_id)
|
||||
.await
|
||||
.map_err(api_status)?;
|
||||
let actor = scoped_actor(&state, &bot_id).await.map_err(api_status)?;
|
||||
if !state
|
||||
.memory
|
||||
.forget(state.pool(), &actor, &bot_id, memory_id)
|
||||
|
|
@ -829,12 +815,9 @@ 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>)> {
|
||||
assert_bot_is_mine(&state, &actor, &bot_id)
|
||||
.await
|
||||
.map_err(api_status)?;
|
||||
let actor = scoped_actor(&state, &bot_id).await.map_err(api_status)?;
|
||||
let deleted = state
|
||||
.memory
|
||||
.clear(state.pool(), &actor, &bot_id)
|
||||
|
|
|
|||
|
|
@ -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,30 +191,6 @@ 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",
|
||||
|
|
@ -313,9 +289,7 @@ pub fn classify_run_error(error: &str) -> RunFailure {
|
|||
true,
|
||||
);
|
||||
}
|
||||
// "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"]) {
|
||||
if text.contains("lease") {
|
||||
return RunFailure::new(
|
||||
"lease_lost",
|
||||
"這份工作被另一邊接手過,我手上這份已經失效。",
|
||||
|
|
@ -368,15 +342,24 @@ 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
|
||||
|
|
@ -446,9 +429,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)
|
||||
|
|
@ -539,39 +522,6 @@ 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!(
|
||||
|
|
|
|||
|
|
@ -30,6 +30,13 @@ 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}");
|
||||
(
|
||||
|
|
@ -108,10 +115,8 @@ async fn room_from_id(
|
|||
}))
|
||||
}
|
||||
|
||||
async fn list_rooms(
|
||||
actor: Actor,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Vec<Room>>, ApiError> {
|
||||
async fn list_rooms(State(state): State<AppState>) -> Result<Json<Vec<Room>>, ApiError> {
|
||||
let actor = actor(&state).await?;
|
||||
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",
|
||||
)
|
||||
|
|
@ -134,9 +139,9 @@ async fn list_rooms(
|
|||
|
||||
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
|
||||
|
|
@ -236,9 +241,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()))?
|
||||
|
|
@ -253,9 +258,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)
|
||||
|
|
@ -276,10 +281,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()))?
|
||||
|
|
@ -327,9 +332,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()))?
|
||||
|
|
@ -358,10 +363,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()))?
|
||||
|
|
@ -398,9 +403,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()))?
|
||||
|
|
|
|||
|
|
@ -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_session,
|
||||
crate::auth::require_auth,
|
||||
))
|
||||
.with_state(state)
|
||||
}
|
||||
|
|
@ -58,10 +58,15 @@ async fn file_skills(State(state): State<AppState>) -> Json<Vec<crate::file_skil
|
|||
Json(crate::file_skills::list(&state.data_dir))
|
||||
}
|
||||
|
||||
async fn list_bots(
|
||||
actor: Actor,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Vec<Bot>>, StatusCode> {
|
||||
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?;
|
||||
let rows = state
|
||||
.db
|
||||
.list_bots(&actor)
|
||||
|
|
@ -97,9 +102,9 @@ async fn list_bots(
|
|||
|
||||
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);
|
||||
}
|
||||
|
|
@ -123,9 +128,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)
|
||||
|
|
@ -180,10 +185,12 @@ 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" => {
|
||||
|
|
@ -223,10 +230,12 @@ 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('#')
|
||||
|
|
@ -315,9 +324,11 @@ 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)
|
||||
|
|
@ -398,9 +409,11 @@ 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,
|
||||
|
|
@ -484,10 +497,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)
|
||||
|
|
@ -518,9 +531,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)
|
||||
|
|
@ -539,9 +552,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)
|
||||
|
|
@ -556,9 +569,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()))
|
||||
|
|
@ -567,9 +580,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()))
|
||||
|
|
@ -585,9 +598,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()))
|
||||
|
|
@ -599,9 +612,11 @@ 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()))
|
||||
|
|
@ -610,9 +625,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)
|
||||
|
|
@ -655,9 +670,11 @@ 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 }),
|
||||
|
|
@ -668,9 +685,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 })))
|
||||
|
|
@ -679,9 +696,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 })))
|
||||
|
|
@ -699,10 +716,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)
|
||||
|
|
|
|||
|
|
@ -12,7 +12,8 @@ use std::time::Duration;
|
|||
|
||||
use lazyboy_contracts::ModelProvider;
|
||||
use lazyboy_harness::{
|
||||
CredentialChain, DynModel, ResolveModelRequest, connect_model, resolve_backend,
|
||||
CredentialChain, DynModel, ResolveModelRequest, connect_model, credential_from_env,
|
||||
resolve_backend,
|
||||
};
|
||||
use rig_core::completion::message::{AssistantContent, Message, UserContent};
|
||||
|
||||
|
|
@ -337,7 +338,7 @@ async fn pick(
|
|||
thread_id: &str,
|
||||
text: &str,
|
||||
) -> Vec<String> {
|
||||
let model = match router_model(state, actor, thread_id).await {
|
||||
let model = match router_model(state, actor).await {
|
||||
Ok(model) => model,
|
||||
Err(error) => {
|
||||
tracing::warn!("room router unavailable: {error}");
|
||||
|
|
@ -432,11 +433,7 @@ async fn prompt_for(
|
|||
|
||||
/// The picker runs on the workspace model unless `LAZYBOY_ROUTER_MODEL` names a
|
||||
/// cheaper one; leaving it unset is a valid and common state.
|
||||
async fn router_model(
|
||||
state: &AppState,
|
||||
actor: &Actor,
|
||||
thread_id: &str,
|
||||
) -> Result<DynModel, String> {
|
||||
async fn router_model(state: &AppState, actor: &Actor) -> Result<DynModel, String> {
|
||||
let space = state
|
||||
.db
|
||||
.get_space(actor)
|
||||
|
|
@ -458,10 +455,11 @@ async fn router_model(
|
|||
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, thread_id).map_err(|error| error.to_string())
|
||||
connect_model(&backend).map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
/// The single hand-off a bot may pass on: a member it names that is neither
|
||||
|
|
|
|||
|
|
@ -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, thread_id).await?;
|
||||
let (model, vision) = bot_model(state, actor, &bot).await?;
|
||||
let skills = crate::skills::saved_skills(state.pool(), bot_id).await;
|
||||
|
||||
let ctx = Arc::new(ToolCtx {
|
||||
|
|
@ -1594,15 +1594,12 @@ async fn execute_run(
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// Resolve the model a bot runs on (bot override → workspace default → env
|
||||
/// credentials). 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
|
||||
|
|
@ -1629,10 +1626,11 @@ 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, session).map_err(|error| error.to_string())?;
|
||||
let model = connect_model(&backend).map_err(|error| error.to_string())?;
|
||||
Ok((model, backend.capabilities.vision))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -77,29 +77,25 @@ pub fn router() -> Router<AppState> {
|
|||
.route("/api/schedules/{id}/run", post(run_now_http))
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
async fn scoped_actor(state: &AppState, bot_id: &str) -> Result<Actor, StatusCode> {
|
||||
let actor = state
|
||||
.bootstrap()
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
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(())
|
||||
Ok(actor)
|
||||
}
|
||||
|
||||
async fn list_http(
|
||||
State(state): State<AppState>,
|
||||
actor: Actor,
|
||||
Path(bot_id): Path<String>,
|
||||
) -> Result<Json<Vec<Value>>, StatusCode> {
|
||||
assert_bot_is_mine(&state, &actor, &bot_id).await?;
|
||||
let actor = scoped_actor(&state, &bot_id).await?;
|
||||
let rows = list(&state, &actor, &bot_id)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
|
@ -108,11 +104,10 @@ 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>)> {
|
||||
assert_bot_is_mine(&state, &actor, &bot_id)
|
||||
let actor = scoped_actor(&state, &bot_id)
|
||||
.await
|
||||
.map_err(|status| (status, Json(json!({"message":"bot not found"}))))?;
|
||||
create(&state, &actor, &bot_id, input)
|
||||
|
|
@ -123,10 +118,15 @@ 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,9 +139,12 @@ 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)
|
||||
|
|
@ -157,9 +160,14 @@ 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}))))?
|
||||
|
|
|
|||
|
|
@ -6,37 +6,25 @@ 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, actor, bot_id, String::new(), req).await
|
||||
proxy(state, 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, actor, bot_id, rest, req).await
|
||||
proxy(state, bot_id, rest, req).await
|
||||
}
|
||||
|
||||
/// A desktop is streamed to its owner only. The actor comes from the login
|
||||
/// 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 {
|
||||
async fn proxy(state: AppState, bot_id: String, rest: String, req: Request) -> Response {
|
||||
let upgrade = req
|
||||
.headers()
|
||||
.get(header::UPGRADE)
|
||||
|
|
@ -56,7 +44,7 @@ async fn proxy(
|
|||
return StatusCode::NOT_FOUND.into_response();
|
||||
}
|
||||
let ensure = upgrade || is_viewer_page(&rest) || rest.contains("websockify");
|
||||
let (host, port) = match upstream_target(&state, &actor, &bot_id, ensure).await {
|
||||
let (host, port) = match upstream_target(&state, &bot_id, ensure).await {
|
||||
Ok(target) => target,
|
||||
Err(status) => return status.into_response(),
|
||||
};
|
||||
|
|
@ -100,13 +88,16 @@ 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)?;
|
||||
|
|
@ -118,7 +109,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
|
||||
|
|
@ -138,7 +129,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)?;
|
||||
|
|
|
|||
|
|
@ -43,6 +43,13 @@ 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}");
|
||||
(
|
||||
|
|
@ -79,9 +86,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)
|
||||
|
|
@ -114,10 +121,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)
|
||||
|
|
@ -152,9 +159,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(|| {
|
||||
|
|
@ -168,10 +175,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()
|
||||
|
|
@ -209,9 +216,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,
|
||||
|
|
@ -265,17 +272,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,
|
||||
|
|
@ -314,10 +321,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,
|
||||
|
|
@ -349,9 +356,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,
|
||||
|
|
@ -364,10 +371,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,
|
||||
|
|
|
|||
|
|
@ -121,6 +121,13 @@ const COLUMNS: &str = "id, bot_id, thread_id, name, goal, status, playbook, reco
|
|||
const SELECT: &str = "SELECT id, bot_id, thread_id, name, goal, status, playbook, recording, error,
|
||||
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}");
|
||||
(
|
||||
|
|
@ -176,9 +183,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,
|
||||
|
|
@ -200,10 +207,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"));
|
||||
|
|
@ -312,9 +319,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"))?;
|
||||
|
|
@ -343,9 +350,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 })));
|
||||
};
|
||||
|
|
@ -378,10 +385,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"));
|
||||
|
|
@ -423,9 +430,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"));
|
||||
|
|
@ -441,9 +448,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"));
|
||||
|
|
@ -654,9 +661,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"));
|
||||
|
|
@ -685,10 +692,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)
|
||||
|
|
@ -1012,19 +1019,7 @@ 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.id,
|
||||
&row.goal,
|
||||
&events,
|
||||
&frames,
|
||||
&dir,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Some(bot) => distill(state, actor, &bot, &row.goal, &events, &frames, &dir).await,
|
||||
None => Err("bot not found".to_string()),
|
||||
};
|
||||
let (playbook, error) = match distilled {
|
||||
|
|
@ -1271,13 +1266,12 @@ 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, skill_id).await?;
|
||||
let (model, vision) = crate::runs::bot_model(state, actor, bot).await?;
|
||||
let t0 = events
|
||||
.iter()
|
||||
.chain(frames.iter())
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ use sqlx::PgPool;
|
|||
use sqlx::postgres::PgPoolOptions;
|
||||
|
||||
use crate::auth::AuthConfig;
|
||||
use crate::db::Db;
|
||||
use crate::db::{Actor, Db};
|
||||
use crate::mcp::McpHub;
|
||||
use crate::memory::MemoryService;
|
||||
|
||||
|
|
@ -176,6 +176,10 @@ impl AppState {
|
|||
})
|
||||
}
|
||||
|
||||
pub async fn bootstrap(&self) -> Result<Actor, sqlx::Error> {
|
||||
self.db.ensure_local_actor().await
|
||||
}
|
||||
|
||||
pub fn pool(&self) -> &PgPool {
|
||||
&self.db.pool
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,29 +55,25 @@ pub fn router() -> Router<AppState> {
|
|||
)
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
async fn scoped_actor(state: &AppState, bot_id: &str) -> Result<Actor, StatusCode> {
|
||||
let actor = state
|
||||
.bootstrap()
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
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(())
|
||||
Ok(actor)
|
||||
}
|
||||
|
||||
async fn list_accounts(
|
||||
State(state): State<AppState>,
|
||||
actor: Actor,
|
||||
Path(bot_id): Path<String>,
|
||||
) -> Result<Json<Vec<VaultAccount>>, StatusCode> {
|
||||
assert_bot_is_mine(&state, &actor, &bot_id).await?;
|
||||
let actor = scoped_actor(&state, &bot_id).await?;
|
||||
list(&state, &actor, &bot_id)
|
||||
.await
|
||||
.map(Json)
|
||||
|
|
@ -86,11 +82,10 @@ 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>)> {
|
||||
assert_bot_is_mine(&state, &actor, &bot_id)
|
||||
let actor = scoped_actor(&state, &bot_id)
|
||||
.await
|
||||
.map_err(|status| (status, Json(json!({"message":"bot not found"}))))?;
|
||||
insert(&state, &actor, &bot_id, input)
|
||||
|
|
@ -101,11 +96,10 @@ 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>)> {
|
||||
assert_bot_is_mine(&state, &actor, &bot_id)
|
||||
let actor = scoped_actor(&state, &bot_id)
|
||||
.await
|
||||
.map_err(|status| (status, Json(json!({"message":"bot not found"}))))?;
|
||||
update_row(&state, &actor, &bot_id, &account_id, input)
|
||||
|
|
@ -120,10 +114,9 @@ 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> {
|
||||
assert_bot_is_mine(&state, &actor, &bot_id).await?;
|
||||
let actor = scoped_actor(&state, &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",
|
||||
|
|
@ -335,13 +328,17 @@ 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())
|
||||
.ok_or_else(|| "set LAZYBOY_VAULT_KEY to encrypt saved passwords".to_string())?;
|
||||
.or_else(|| {
|
||||
std::env::var("LAZYBOY_APP_TOKEN")
|
||||
.ok()
|
||||
.filter(|v| !v.is_empty())
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
"set LAZYBOY_VAULT_KEY or LAZYBOY_APP_TOKEN to encrypt saved passwords".to_string()
|
||||
})?;
|
||||
let digest = Sha256::digest(material.as_bytes());
|
||||
let mut key = [0u8; 32];
|
||||
key.copy_from_slice(&digest);
|
||||
|
|
|
|||
|
|
@ -22,15 +22,19 @@ pub fn router() -> Router<AppState> {
|
|||
.route("/api/sessions/{id}/call", get(voice_call::call_ws))
|
||||
}
|
||||
|
||||
/// 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.
|
||||
async fn actor(state: &AppState) -> Result<Actor, StatusCode> {
|
||||
state
|
||||
.bootstrap()
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
|
||||
}
|
||||
|
||||
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
|
||||
|
|
@ -45,7 +49,11 @@ pub fn voice_credential_chain(
|
|||
None
|
||||
}
|
||||
});
|
||||
CredentialChain { bot: None, space }
|
||||
CredentialChain {
|
||||
bot: None,
|
||||
space,
|
||||
env: nonempty(env_key),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn voice_instructions(bot_name: &str, bot_instructions: &str) -> String {
|
||||
|
|
@ -84,11 +92,15 @@ 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,
|
||||
|
|
@ -122,6 +134,8 @@ 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()),
|
||||
|
|
@ -132,6 +146,7 @@ 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<_>>(),
|
||||
|
|
@ -139,25 +154,17 @@ fn settings_json(space: &SpaceRow) -> Value {
|
|||
})
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
async fn get_settings(State(state): State<AppState>) -> Result<Json<Value>, StatusCode> {
|
||||
let actor = actor(&state).await?;
|
||||
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 {
|
||||
|
|
@ -174,9 +181,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()
|
||||
|
|
@ -218,7 +225,7 @@ async fn update_settings(
|
|||
)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
settings_payload(&state, &actor).await
|
||||
get_settings(State(state)).await
|
||||
}
|
||||
|
||||
pub fn resolve_space_voice(
|
||||
|
|
@ -238,11 +245,15 @@ 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,
|
||||
|
|
@ -260,19 +271,30 @@ 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"));
|
||||
let reused = voice_credential_chain(
|
||||
VoiceProvider::Xai,
|
||||
None,
|
||||
"xai",
|
||||
Some("text-key"),
|
||||
Some("env-key"),
|
||||
);
|
||||
assert_eq!(reused.resolve(), Some("text-key"));
|
||||
|
||||
// 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 env_only = voice_credential_chain(
|
||||
VoiceProvider::Xai,
|
||||
None,
|
||||
"opencode-go",
|
||||
Some("go-key"),
|
||||
Some("env-xai"),
|
||||
);
|
||||
assert_eq!(env_only.resolve(), Some("env-xai"));
|
||||
|
||||
let dedicated = voice_credential_chain(
|
||||
VoiceProvider::Openai,
|
||||
Some("voice-openai"),
|
||||
"xai",
|
||||
Some("text-xai"),
|
||||
Some("env-openai"),
|
||||
);
|
||||
assert_eq!(dedicated.resolve(), Some("voice-openai"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,10 +27,19 @@ 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 {
|
||||
|
|
|
|||
|
|
@ -54,13 +54,18 @@ fn provider_info(provider: ModelProvider) -> ProviderInfo {
|
|||
}
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
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?;
|
||||
let space = state
|
||||
.db
|
||||
.get_space(actor)
|
||||
.get_space(&actor)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||
.ok_or(StatusCode::NOT_FOUND)?;
|
||||
|
|
@ -68,11 +73,17 @@ async fn settings_payload(state: &AppState, actor: &Actor) -> Result<Json<Value>
|
|||
.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<_>>(),
|
||||
})))
|
||||
|
|
@ -93,9 +104,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>()
|
||||
|
|
@ -128,9 +139,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 one
|
||||
// over to a different provider only guarantees an "Incorrect API key"
|
||||
// error, so it is dropped unless a new key is supplied.
|
||||
// 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.
|
||||
let api_key = if input.clear_api_key || (provider_changed && supplied.is_none()) {
|
||||
Some(None)
|
||||
} else {
|
||||
|
|
@ -141,14 +152,7 @@ async fn update_settings(
|
|||
.update_workspace_model(&actor, provider.as_str(), model_id, base_url, api_key)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
settings_payload(&state, &actor).await
|
||||
}
|
||||
|
||||
async fn get_settings(
|
||||
actor: Actor,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Value>, StatusCode> {
|
||||
settings_payload(&state, &actor).await
|
||||
get_settings(State(state)).await
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
|
|
@ -161,9 +165,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>()
|
||||
|
|
@ -176,6 +180,9 @@ 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
|
||||
|
|
@ -183,7 +190,8 @@ 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()),
|
||||
.and_then(|row| row.default_model_api_key.as_deref())
|
||||
.or(env_key.as_deref()),
|
||||
)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
|
|
|||
|
|
@ -24,6 +24,16 @@ impl ModelProvider {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn env_key_name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Xai => "XAI_API_KEY",
|
||||
Self::OpencodeGo => "OPENCODE_GO_API_KEY",
|
||||
Self::OpenaiCompatible | Self::Openai => "OPENAI_API_KEY",
|
||||
Self::Anthropic => "ANTHROPIC_API_KEY",
|
||||
Self::Openrouter => "OPENROUTER_API_KEY",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn requires_api_key(self) -> bool {
|
||||
!matches!(self, Self::OpenaiCompatible)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,14 @@ impl VoiceProvider {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn env_key_name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Xai => "XAI_API_KEY",
|
||||
Self::Openai => "OPENAI_API_KEY",
|
||||
Self::Scripted => "",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn requires_api_key(self) -> bool {
|
||||
!matches!(self, Self::Scripted)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@ use thiserror::Error;
|
|||
pub enum ModelError {
|
||||
#[error("unsupported_provider:{provider}")]
|
||||
UnsupportedProvider { provider: String },
|
||||
#[error("missing credential for {provider}: add the API key in the workspace settings")]
|
||||
MissingCredential { provider: String },
|
||||
#[error("missing credential for {provider} ({env_key})")]
|
||||
MissingCredential { provider: String, env_key: String },
|
||||
#[error("missing base URL for {provider}")]
|
||||
MissingBaseUrl { provider: String },
|
||||
#[error("unknown model provider: {0}")]
|
||||
|
|
@ -17,14 +17,11 @@ 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 {
|
||||
|
|
@ -33,6 +30,7 @@ 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()))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -69,6 +67,7 @@ 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(),
|
||||
|
|
@ -128,35 +127,7 @@ fn uses_responses_api(provider: ModelProvider, model_id: &str) -> bool {
|
|||
&& (id.starts_with("gpt-") || id.starts_with("grok-") || id.starts_with("muse-"))
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
pub fn connect_model(backend: &ResolvedBackend) -> Result<DynModel, ModelError> {
|
||||
match backend.provider {
|
||||
ModelProvider::Xai => {
|
||||
let client = xai::Client::new(&backend.api_key)
|
||||
|
|
@ -169,12 +140,10 @@ pub fn connect_model(backend: &ResolvedBackend, session: &str) -> Result<DynMode
|
|||
} 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(
|
||||
|
|
@ -184,7 +153,6 @@ pub fn connect_model(backend: &ResolvedBackend, session: &str) -> Result<DynMode
|
|||
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)))
|
||||
|
|
@ -196,6 +164,12 @@ pub fn connect_model(backend: &ResolvedBackend, session: &str) -> Result<DynMode
|
|||
}
|
||||
}
|
||||
|
||||
pub fn credential_from_env(provider: ModelProvider) -> Option<String> {
|
||||
std::env::var(provider.env_key_name())
|
||||
.ok()
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
/// Prove the xAI Rig client can be constructed from a resolved backend.
|
||||
pub fn connect_xai(
|
||||
backend: &ResolvedBackend,
|
||||
|
|
@ -221,7 +195,8 @@ mod tests {
|
|||
base_url: None,
|
||||
credentials: CredentialChain {
|
||||
bot: None,
|
||||
space: key.map(str::to_string),
|
||||
space: None,
|
||||
env: key.map(str::to_string),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -237,7 +212,7 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn credentials_prefer_the_bot_key_over_the_workspace_key() {
|
||||
fn credentials_prefer_bot_then_space_then_env() {
|
||||
let backend = resolve_backend(ResolveModelRequest {
|
||||
provider: ModelProvider::Xai,
|
||||
model_id: Some("grok-4.6".into()),
|
||||
|
|
@ -245,6 +220,7 @@ mod tests {
|
|||
credentials: CredentialChain {
|
||||
bot: Some("bot-key".into()),
|
||||
space: Some("space-key".into()),
|
||||
env: Some("env-key".into()),
|
||||
},
|
||||
})
|
||||
.unwrap();
|
||||
|
|
@ -270,7 +246,8 @@ mod tests {
|
|||
base_url: None,
|
||||
credentials: CredentialChain {
|
||||
bot: None,
|
||||
space: Some("sk-test".into()),
|
||||
space: None,
|
||||
env: Some("sk-test".into()),
|
||||
},
|
||||
})
|
||||
.unwrap_err();
|
||||
|
|
@ -297,29 +274,14 @@ mod tests {
|
|||
base_url: None,
|
||||
credentials: CredentialChain {
|
||||
bot: None,
|
||||
space: Some("go-key".into()),
|
||||
space: None,
|
||||
env: 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, "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());
|
||||
assert!(connect_model(&backend).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -345,12 +307,13 @@ 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, "thread-1").is_ok());
|
||||
assert!(connect_model(&backend).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -362,6 +325,7 @@ mod tests {
|
|||
credentials: CredentialChain {
|
||||
bot: None,
|
||||
space: None,
|
||||
env: None,
|
||||
},
|
||||
})
|
||||
.unwrap_err();
|
||||
|
|
|
|||
|
|
@ -18,8 +18,8 @@ use crate::{CredentialChain, ModelError};
|
|||
pub enum VoiceError {
|
||||
#[error("{0}")]
|
||||
Message(String),
|
||||
#[error("missing credential for {provider}: add the API key in the workspace settings")]
|
||||
MissingCredential { provider: String },
|
||||
#[error("missing credential for {provider} ({env_key})")]
|
||||
MissingCredential { provider: String, env_key: String },
|
||||
#[error("unknown voice provider: {0}")]
|
||||
UnknownProvider(String),
|
||||
}
|
||||
|
|
@ -27,7 +27,9 @@ pub enum VoiceError {
|
|||
impl From<ModelError> for VoiceError {
|
||||
fn from(error: ModelError) -> Self {
|
||||
match error {
|
||||
ModelError::MissingCredential { provider } => Self::MissingCredential { provider },
|
||||
ModelError::MissingCredential { provider, env_key } => {
|
||||
Self::MissingCredential { provider, env_key }
|
||||
}
|
||||
other => Self::Message(other.to_string()),
|
||||
}
|
||||
}
|
||||
|
|
@ -88,6 +90,7 @@ pub fn resolve_voice(request: ResolveVoiceRequest) -> Result<ResolvedVoice, Voic
|
|||
None if request.provider.requires_api_key() => {
|
||||
return Err(VoiceError::MissingCredential {
|
||||
provider: request.provider.as_str().to_string(),
|
||||
env_key: request.provider.env_key_name().to_string(),
|
||||
});
|
||||
}
|
||||
None => String::new(),
|
||||
|
|
@ -664,6 +667,7 @@ mod tests {
|
|||
credentials: CredentialChain {
|
||||
bot: None,
|
||||
space: None,
|
||||
env: None,
|
||||
},
|
||||
});
|
||||
assert!(matches!(missing, Err(VoiceError::MissingCredential { .. })));
|
||||
|
|
@ -675,6 +679,7 @@ mod tests {
|
|||
credentials: CredentialChain {
|
||||
bot: None,
|
||||
space: Some("sk".into()),
|
||||
env: None,
|
||||
},
|
||||
})
|
||||
.unwrap();
|
||||
|
|
|
|||
|
|
@ -82,13 +82,16 @@ 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}
|
||||
|
|
|
|||
|
|
@ -111,7 +111,7 @@ builder 並註冊 binfmt;主機本身的架構會跳過註冊,否則在特
|
|||
(`CHROME=` 指定,否則依序找 PATH 上的 chromium/Chrome 與 Playwright 下載的 chrome)。
|
||||
|
||||
```bash
|
||||
# 1. 對運行中的 stack 擷取真實畫面(帳號密碼用 LB_USER/LB_PASS,帳號不存在時自動註冊;LB_URL 指定 stack)
|
||||
# 1. 對運行中的 stack 擷取真實畫面(登入 token 讀 .env,可用 LB_TOKEN/LB_URL 覆寫)
|
||||
# 群組視圖的右欄是機器人自己的瀏覽器,進圖前先用 --hide 遮掉,再補一張中立的上去;
|
||||
# --anchor 會印出被遮區塊在圖上的位置,hero.html 的圖層座標就是從這裡來的。
|
||||
node scripts/capture-hero.mjs --out docs/hero/agent.png
|
||||
|
|
|
|||
|
|
@ -6,13 +6,11 @@
|
|||
|
||||
目前程式碼包含下列防護:
|
||||
|
||||
- 登入是帳號密碼:資料庫只存 PBKDF2-HMAC-SHA256 派生值(120,000 輪,每組密碼各自的 salt),不存明文;登入 session token 與 Supervisor token 分離,資料庫裡也只存 session 的雜湊。
|
||||
- 帳號互相隔離:Agent、對話、排程、憑證庫與模型金鑰都以帳號自己的 workspace 為界,A 讀不到也改不到 B 的資料。
|
||||
- `Host` 檢查抵禦 DNS rebinding:IP 與 `localhost` 直接接受,其他網域名稱一定要列在 `LAZYBOY_ALLOWED_HOSTS`。
|
||||
- API session token 與 Supervisor token 分離。
|
||||
- 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` 加密,改帳號密碼不影響此 key;輪替 `LAZYBOY_VAULT_KEY` 會讀不到已加密的密碼庫。
|
||||
- 憑證以獨立 `LAZYBOY_VAULT_KEY` 加密,輪替登入 token 時不應更換此 key。
|
||||
- 已保存登入只接受 HTTPS、精確或合法子網域匹配,不對相似惡意網域填入。
|
||||
- Markdown 連結限制為 HTTP(S)、`mailto:`、`tel:` 與頁內錨點。
|
||||
- 日誌有大小與檔案數上限;診斷資料有可設定的保留週期。
|
||||
|
|
@ -21,10 +19,9 @@
|
|||
|
||||
1. 對區網或網際網路開放前,先放在 HTTPS reverse proxy 後方,並設定 `LAZYBOY_SECURE_COOKIE=true`。
|
||||
2. 不要把 Supervisor `:7091` 對外發布,也不要將 Docker socket 掛進 Agent 電腦。
|
||||
3. `SANDBOX_SUPERVISOR_TOKEN` 與 `LAZYBOY_VAULT_KEY` 必須使用不同的高熵值。
|
||||
3. `LAZYBOY_APP_TOKEN`、`SANDBOX_SUPERVISOR_TOKEN`、`LAZYBOY_VAULT_KEY` 必須使用不同的高熵值。
|
||||
4. 模型仍可能看見任務所需的網頁內容與截圖;密碼、token 與高敏感資料不要放進提示詞。
|
||||
5. 簡單的 Cloudflare 連線驗證可嘗試一次正常點擊;未通過、其他 CAPTCHA 與 2FA 交由使用者接管。
|
||||
6. 模型 API 金鑰只吃各 workspace 在「設定 → 模型」裡貼上的值,不再讀環境變數;沒設定金鑰的 workspace 跑任務會立刻失敗,並在畫面告訴你要去哪裡補。
|
||||
|
||||
## 硬體與資源
|
||||
|
||||
|
|
@ -46,7 +43,10 @@
|
|||
|
||||
| 變數 | 用途 | 預設 |
|
||||
| --- | --- | --- |
|
||||
| `LAZYBOY_ALLOWED_HOSTS` | 允許以網域名稱連入的主機名(逗號分隔);IP 與 `localhost` 不需要列 | 空 |
|
||||
| `XAI_API_KEY` | xAI 模型 | 空 |
|
||||
| `OPENCODE_GO_API_KEY` | OpenCode Go 模型 | 空 |
|
||||
| `OPENAI_API_KEY` | OpenAI 相容端點 | 空 |
|
||||
| `LAZYBOY_APP_TOKEN` | Web 登入 token | 必填 |
|
||||
| `SANDBOX_SUPERVISOR_TOKEN` | API ↔ Supervisor 驗證 | 必填 |
|
||||
| `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
|
||||
```
|
||||
|
||||
- 每台裝置各自註冊、各自登入;`Host` 為 IP 或 `localhost` 時可直接使用,改用網域名稱進入時要把名稱加進 `LAZYBOY_ALLOWED_HOSTS`,否則會被 `403 invalid host` 擋下。
|
||||
- 綁非 loopback 時 `LAZYBOY_APP_TOKEN` 必須至少 32 字元,否則 api 拒絕啟動。
|
||||
- Origin 檢查比對 `Origin` 與 `Host`:直接開 `http://<主機IP>:3101` 可正常使用,從其他網域嵌入會被 `403 cross-origin request rejected` 擋下。
|
||||
- 純 HTTP 下 session cookie 以明碼走區網;長期或跨網際網路使用請放到 HTTPS reverse proxy 後面,並設定 `LAZYBOY_SECURE_COOKIE=true`。
|
||||
- 暫時性跨網存取建議維持 `127.0.0.1` 綁定改用隧道:`ssh -L 3101:127.0.0.1:3101 <host>`。
|
||||
|
|
|
|||
|
|
@ -1,29 +0,0 @@
|
|||
-- Accounts: every person signs in with their own username and password, and
|
||||
-- the agents they make belong to them alone.
|
||||
--
|
||||
-- Before this, one shared token from `.env` opened the whole install, and one
|
||||
-- hard-coded local user owned every agent. Login is now per person, so a
|
||||
-- session resolves to exactly one user and one workspace, and the shared token
|
||||
-- is gone. `password_hash IS NULL` marks an account that cannot sign in yet.
|
||||
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS username TEXT,
|
||||
ADD COLUMN IF NOT EXISTS password_hash TEXT;
|
||||
|
||||
-- Usernames are addresses: one spelling, matched case-insensitively.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS users_username_key
|
||||
ON users (lower(username))
|
||||
WHERE username IS NOT NULL;
|
||||
|
||||
-- Server-side sessions. Only the digest of the cookie value is stored, so a
|
||||
-- database leak cannot be replayed as a login, and an API restart no longer
|
||||
-- logs everyone out.
|
||||
CREATE TABLE IF NOT EXISTS app_sessions (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
expires_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS app_sessions_user_idx ON app_sessions (user_id);
|
||||
CREATE INDEX IF NOT EXISTS app_sessions_expiry_idx ON app_sessions (expires_at);
|
||||
|
|
@ -25,8 +25,7 @@
|
|||
//
|
||||
// Environment:
|
||||
// LB_URL stack to photograph (default http://127.0.0.1:3101)
|
||||
// LB_USER account to sign in as (default: hero)
|
||||
// LB_PASS that account's password (default below; registered on demand)
|
||||
// LB_TOKEN login token (default: LAZYBOY_APP_TOKEN from .env)
|
||||
// CHROME browser binary (default: discovered, see findChrome)
|
||||
import { spawn } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
|
|
@ -81,32 +80,14 @@ function findChrome() {
|
|||
throw new Error('no Chromium found; set CHROME=/path/to/chrome');
|
||||
}
|
||||
|
||||
// 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',
|
||||
};
|
||||
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();
|
||||
}
|
||||
|
||||
// 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 {
|
||||
|
|
@ -226,7 +207,7 @@ try {
|
|||
const status = await connection.send(
|
||||
'Runtime.evaluate',
|
||||
{
|
||||
expression: `${SIGN_IN_HELPER}\nsignIn(${JSON.stringify(credentials())})`,
|
||||
expression: `fetch('/api/session',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token:${JSON.stringify(readToken())}})}).then(r=>r.status)`,
|
||||
awaitPromise: true,
|
||||
returnByValue: true,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ 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}"
|
||||
|
|
@ -25,5 +26,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 (register the first account in the browser)"
|
||||
echo "then: cargo run -p lazyboy-api"
|
||||
echo "listening on 0.0.0.0:3101"
|
||||
|
|
|
|||
|
|
@ -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 ('SANDBOX_SUPERVISOR_TOKEN','LAZYBOY_VAULT_KEY','POSTGRES_PASSWORD')}
|
||||
values={key:secrets.token_hex(32) for key in ('LAZYBOY_APP_TOKEN','SANDBOX_SUPERVISOR_TOKEN','LAZYBOY_VAULT_KEY','POSTGRES_PASSWORD')}
|
||||
text=Path('.env.example').read_text()
|
||||
lines=[]
|
||||
for line in text.splitlines():
|
||||
|
|
@ -18,5 +18,4 @@ else:
|
|||
with path.open('x') as out:
|
||||
path.chmod(0o600)
|
||||
out.write('\n'.join(lines)+'\n')
|
||||
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 設定 → 模型.')
|
||||
print('Created .env with independent app, supervisor, vault and database secrets. Set your model API key next.')
|
||||
|
|
|
|||
|
|
@ -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_key_missing','model_opt_in','model_quota','model_unknown','model_timeout','network','computer_gone','lease_lost','unknown'];
|
||||
const FAILURE_CODES=['interrupted','tool_timeout','model_key','model_quota','model_unknown','model_timeout','network','computer_gone','lease_lost','unknown'];
|
||||
|
||||
test('run timings stay on one glanceable line',()=>{
|
||||
assert.equal(formatElapsed(0),'0:00');
|
||||
|
|
@ -368,21 +368,6 @@ test('trail lines read as sentences with the detail a stuck run needs',()=>{
|
|||
assert.match(trailText({id:9,kind:'notice',createdAt:'',text:'這輪不需要電腦'}),/這輪不需要電腦/);
|
||||
});
|
||||
|
||||
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/);
|
||||
|
|
@ -406,7 +391,6 @@ test('the bubble reads the run activity endpoint the API actually mounts',()=>{
|
|||
assert.match(app,/errorActions\(chip\.code\)/);
|
||||
assert.match(app,/`\/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"/);
|
||||
|
|
|
|||
Loading…
Reference in New Issue