Compare commits
3 Commits
feat/chat_
...
main
| Author | SHA1 | Date |
|---|---|---|
|
|
0f6e33dd76 | |
|
|
0e56438f1c | |
|
|
7f12c5f944 |
13
.env.example
13
.env.example
|
|
@ -1,15 +1,16 @@
|
||||||
XAI_API_KEY=
|
# 登入改為帳號密碼(在網頁上註冊),不再需要共享 token。
|
||||||
OPENCODE_GO_API_KEY=
|
# API 金鑰也不再讀環境變數:註冊後到「設定 → 模型」貼上,沒有金鑰就不能跑。
|
||||||
OPENAI_API_KEY=
|
|
||||||
# Generate independent values with: openssl rand -hex 32
|
# Generate independent values with: openssl rand -hex 32
|
||||||
LAZYBOY_APP_TOKEN=
|
|
||||||
SANDBOX_SUPERVISOR_TOKEN=
|
SANDBOX_SUPERVISOR_TOKEN=
|
||||||
# Keep this key stable when rotating the app login token.
|
# 這個金鑰要穩定輪替:換掉它會讀不到已加密的密碼庫。
|
||||||
LAZYBOY_VAULT_KEY=
|
LAZYBOY_VAULT_KEY=
|
||||||
POSTGRES_PASSWORD=lazyboy
|
POSTGRES_PASSWORD=lazyboy
|
||||||
# 127.0.0.1 = 只有本機;0.0.0.0 = 開放區網(需 LAZYBOY_APP_TOKEN >= 32 字元);
|
# 127.0.0.1 = 只有本機;0.0.0.0 = 開放區網,讓別人用他自己的帳號登入;
|
||||||
# 也可填單一網卡的 IP,把監聽限制在那個介面。
|
# 也可填單一網卡的 IP,把監聽限制在那個介面。
|
||||||
LAZYBOY_BIND_IP=127.0.0.1
|
LAZYBOY_BIND_IP=127.0.0.1
|
||||||
|
# 只有清單上的網域名稱會被接受(DNS rebinding 防護)。用 IP 或 localhost 連進來
|
||||||
|
# 不需要填;用例如 lazyboy.local 這種位名時才加在這裡,逗號分隔。
|
||||||
|
LAZYBOY_ALLOWED_HOSTS=
|
||||||
SANDBOX_PROVIDER=docker
|
SANDBOX_PROVIDER=docker
|
||||||
DATABASE_URL=postgres://lazyboy:lazyboy@127.0.0.1:5434/lazyboy
|
DATABASE_URL=postgres://lazyboy:lazyboy@127.0.0.1:5434/lazyboy
|
||||||
DATA_DIR=./data
|
DATA_DIR=./data
|
||||||
|
|
|
||||||
40
CHANGELOG.md
40
CHANGELOG.md
|
|
@ -2,6 +2,46 @@
|
||||||
|
|
||||||
All notable changes to LazyBoy are documented here.
|
All notable changes to LazyBoy are documented here.
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
Sign in with your own account. The shared install token is gone, and so are the
|
||||||
|
model keys in the environment.
|
||||||
|
|
||||||
|
- **Register and log in** with a username and password in the app. Passwords are
|
||||||
|
stored as PBKDF2-HMAC-SHA256 (120,000 rounds, per-password salt) and sessions
|
||||||
|
live in the database as token digests in an `HttpOnly`, `SameSite=Strict`
|
||||||
|
cookie. Repeated failures cool a username down for five minutes.
|
||||||
|
- **One workspace per account.** Agents, chats, runs, schedules, credential
|
||||||
|
vaults, and model keys belong to the account that created them; a second
|
||||||
|
account on the same install sees none of it. Work created before this release
|
||||||
|
is adopted by the first account that registers.
|
||||||
|
- **Model keys come from Settings → Models only.** `LAZYBOY_APP_TOKEN`,
|
||||||
|
`XAI_API_KEY`, `OPENCODE_GO_API_KEY`, and `OPENAI_API_KEY` are no longer read.
|
||||||
|
A workspace without a key does not quietly run on someone else's: the run
|
||||||
|
fails immediately and points at the setting to fix.
|
||||||
|
- **`LAZYBOY_ALLOWED_HOSTS`** lists the domain names an install may be addressed
|
||||||
|
by (DNS rebinding guard). IPs and `localhost` keep working with no config.
|
||||||
|
- Removed: the token login screen, `LAZYBOY_APP_TOKEN`, and the rule that a
|
||||||
|
non-loopback bind required a 32-character token. `SANDBOX_SUPERVISOR_TOKEN`
|
||||||
|
and `LAZYBOY_VAULT_KEY` are still required in `.env`.
|
||||||
|
|
||||||
|
### 繁體中文
|
||||||
|
|
||||||
|
改成自己的帳號登入:共享的安裝 token 拿掉了,環境變數裡的模型金鑰也拿掉了。
|
||||||
|
|
||||||
|
- **在 App 裡註冊與登入**:密碼以 PBKDF2-HMAC-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
|
## [v0.1.0-alpha] - 2026-09-09
|
||||||
|
|
||||||
First public alpha. Self-hosted AI agent workspace: each agent gets an isolated Linux desktop, you watch it live, and you can take over at any time.
|
First public alpha. Self-hosted AI agent workspace: each agent gets an isolated Linux desktop, you watch it live, and you can take over at any time.
|
||||||
|
|
|
||||||
|
|
@ -1899,6 +1899,7 @@ dependencies = [
|
||||||
"fastembed",
|
"fastembed",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"hex",
|
"hex",
|
||||||
|
"hmac",
|
||||||
"http",
|
"http",
|
||||||
"lazyboy-contracts",
|
"lazyboy-contracts",
|
||||||
"lazyboy-control",
|
"lazyboy-control",
|
||||||
|
|
|
||||||
5
Makefile
5
Makefile
|
|
@ -60,8 +60,7 @@ help: ## Show this help
|
||||||
@echo " make web Build the frontend in $(WEB_DIR) (needs node/npm)"
|
@echo " make web Build the frontend in $(WEB_DIR) (needs node/npm)"
|
||||||
@echo " make clean cargo clean"
|
@echo " make clean cargo clean"
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo " After 'make up': open http://127.0.0.1:3101 and sign in with LAZYBOY_APP_TOKEN."
|
@echo " After 'make up': open http://127.0.0.1:3101, register an account, and add a model API key in 設定 → 模型."
|
||||||
@echo " Set XAI_API_KEY in .env for chat."
|
|
||||||
|
|
||||||
# --- Environment -----------------------------------------------------------
|
# --- Environment -----------------------------------------------------------
|
||||||
|
|
||||||
|
|
@ -79,7 +78,7 @@ up: env ## Build every image and start the whole stack in Docker
|
||||||
@$(MAKE) --no-print-directory prepare-screen-network
|
@$(MAKE) --no-print-directory prepare-screen-network
|
||||||
$(COMPOSE) up -d --build
|
$(COMPOSE) up -d --build
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo "stack launched. open http://127.0.0.1:3101 and sign in with LAZYBOY_APP_TOKEN."
|
@echo "stack launched. open http://127.0.0.1:3101 and register your account."
|
||||||
$(COMPOSE) ps
|
$(COMPOSE) ps
|
||||||
|
|
||||||
# Compose owns `lazyboy_screen` (internal, labeled). A leftover from host-dev or
|
# Compose owns `lazyboy_screen` (internal, labeled). A leftover from host-dev or
|
||||||
|
|
|
||||||
11
README.md
11
README.md
|
|
@ -18,10 +18,11 @@ A self-hosted AI agent workspace. Assign tasks in text or voice, watch the deskt
|
||||||
|
|
||||||
LazyBoy gives each agent its own Linux desktop in Docker — browser, terminal, and files. You can run several agents, put them in a group, turn a demonstration into a skill, and schedule it to run again.
|
LazyBoy gives each agent its own Linux desktop in Docker — browser, terminal, and files. You can run several agents, put them in a group, turn a demonstration into a skill, and schedule it to run again.
|
||||||
|
|
||||||
This is an early `v0.1.0-alpha` release with desktop and phone browser UIs. You bring your own model API key.
|
This is an early `v0.1.0-alpha` release with desktop and phone browser UIs. Every person registers their own account, and model API keys live in the app's settings rather than in the environment.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
|
- **One account per person**: register a username and password, and the whole workspace — agents, chats, credentials, model keys — belongs to that account alone.
|
||||||
- **A lasting workspace**: each agent has its own chats, run history, and optional long-term memory.
|
- **A lasting workspace**: each agent has its own chats, run history, and optional long-term memory.
|
||||||
- **A real computer**: open pages, use the terminal, organize files, drive the GUI — and watch it live.
|
- **A real computer**: open pages, use the terminal, organize files, drive the GUI — and watch it live.
|
||||||
- **Attachments in chat**: send files or images along with the message; the agent can open them on its own desktop, and inbox copies expire on their own.
|
- **Attachments in chat**: send files or images along with the message; the agent can open them on its own desktop, and inbox copies expire on their own.
|
||||||
|
|
@ -36,7 +37,7 @@ This is an early `v0.1.0-alpha` release with desktop and phone browser UIs. You
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
You need Docker and Compose, Git, Make, Python 3, and an API key for a supported model. On macOS use Docker Desktop or OrbStack; on Linux use Docker Engine.
|
You need Docker and Compose, Git, Make, and Python 3, plus an API key for a supported model — you paste that key into the app after you register, not into `.env`. On macOS use Docker Desktop or OrbStack; on Linux use Docker Engine.
|
||||||
|
|
||||||
From the repo root:
|
From the repo root:
|
||||||
|
|
||||||
|
|
@ -44,14 +45,14 @@ From the repo root:
|
||||||
make env
|
make env
|
||||||
```
|
```
|
||||||
|
|
||||||
Edit the generated `.env` and set one of `XAI_API_KEY`, `OPENCODE_GO_API_KEY`, or `OPENAI_API_KEY`. The init tool creates the login and service secrets; running it again keeps existing values.
|
The generated `.env` holds service secrets only (supervisor, credential vault, database); the init tool fills them in and keeps existing values when run again. Nothing about signing in or model access is configured here.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make up
|
make up
|
||||||
make health
|
make health
|
||||||
```
|
```
|
||||||
|
|
||||||
Open **[http://127.0.0.1:3101](http://127.0.0.1:3101)**, sign in with `LAZYBOY_APP_TOKEN` from `.env`, create an agent, and pick a model.
|
Open **[http://127.0.0.1:3101](http://127.0.0.1:3101)**, register an account with any username and password you like, paste your model API key under **Settings → Models**, then create an agent and pick a model. Without a key nothing runs — the app tells you where to add one.
|
||||||
|
|
||||||
The first run builds the API and Linux desktop images from source and takes a while. The build toolchain lives in the containers, so a full Docker deploy does not need Rust or Node.js on the host.
|
The first run builds the API and Linux desktop images from source and takes a while. The build toolchain lives in the containers, so a full Docker deploy does not need Rust or Node.js on the host.
|
||||||
|
|
||||||
|
|
@ -71,7 +72,7 @@ For resource limits, environment variables, HTTPS, and in-container sudo, see [O
|
||||||
|
|
||||||
## On a phone
|
## On a phone
|
||||||
|
|
||||||
Desktop and phone share the same web UI. The API listens on `127.0.0.1` by default, so set `LAZYBOY_BIND_IP=0.0.0.0` (or one network card's address) in `.env` and recreate the api container before a phone on your network can reach it; off-loopback the login token has to be at least 32 characters. Then open that address in the phone browser — `127.0.0.1` there is the phone itself and will not reach another machine.
|
Desktop and phone share the same web UI. The API listens on `127.0.0.1` by default, so set `LAZYBOY_BIND_IP=0.0.0.0` (or one network card's address) in `.env` and recreate the api container before a phone on your network can reach it. Then open that address in the phone browser — `127.0.0.1` there is the phone itself and will not reach another machine — and register there too: two people on one install each see only their own agents. Reaching the app by a domain name instead of an IP or `localhost` additionally requires listing it in `LAZYBOY_ALLOWED_HOSTS`.
|
||||||
|
|
||||||
Tap outside the chat sidebar to collapse it. On the remote desktop you can switch between tap-to-click and trackpad, and use the toolbar for keyboard, right-click, or drag. Put the service behind HTTPS before you expose it; see [Operations](./docs/operations.md#安全模型).
|
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,10 +18,11 @@
|
||||||
|
|
||||||
LazyBoy 讓 Agent 在 Docker 裡使用自己的 Linux 桌面,操作瀏覽器、終端與檔案。你可以建立不同的 Agent、在群組中協作,把示範整理成技能,再安排定時執行。
|
LazyBoy 讓 Agent 在 Docker 裡使用自己的 Linux 桌面,操作瀏覽器、終端與檔案。你可以建立不同的 Agent、在群組中協作,把示範整理成技能,再安排定時執行。
|
||||||
|
|
||||||
目前為早期版本 `v0.1.0-alpha`,提供桌面與手機瀏覽器介面;模型金鑰由你自行設定。
|
目前為早期版本 `v0.1.0-alpha`,提供桌面與手機瀏覽器介面;每個人各自註冊帳號,模型金鑰在 App 的設定裡填寫,不放在環境變數。
|
||||||
|
|
||||||
## 功能
|
## 功能
|
||||||
|
|
||||||
|
- **一人一帳號**:用帳號密碼註冊後,Agent、對話、憑證與模型金鑰都只屬於那個帳號,彼此不會混用。
|
||||||
- **持續的工作空間**:每個 Agent 有自己的對話、工作紀錄與可設定的長期記憶。
|
- **持續的工作空間**:每個 Agent 有自己的對話、工作紀錄與可設定的長期記憶。
|
||||||
- **真的能操作電腦**:開網頁、使用終端、整理檔案、操作圖形介面,過程可即時觀看。
|
- **真的能操作電腦**:開網頁、使用終端、整理檔案、操作圖形介面,過程可即時觀看。
|
||||||
- **附件直接丟進對話**:訊息可帶檔案或圖片,Agent 能在自己的桌面開啟,放在收件匣的複本會自動到期清除。
|
- **附件直接丟進對話**:訊息可帶檔案或圖片,Agent 能在自己的桌面開啟,放在收件匣的複本會自動到期清除。
|
||||||
|
|
@ -36,7 +37,7 @@ LazyBoy 讓 Agent 在 Docker 裡使用自己的 Linux 桌面,操作瀏覽器
|
||||||
|
|
||||||
## 快速開始
|
## 快速開始
|
||||||
|
|
||||||
需要 Docker 與 Compose、Git、Make、Python 3,以及一組支援的模型 API 金鑰。macOS 可使用 Docker Desktop 或 OrbStack;Linux 可使用 Docker Engine。
|
需要 Docker 與 Compose、Git、Make、Python 3,以及一組支援的模型 API 金鑰——金鑰註冊後在 App 裡貼上,不寫進 `.env`。macOS 可使用 Docker Desktop 或 OrbStack;Linux 可使用 Docker Engine。
|
||||||
|
|
||||||
取得本專案原始碼後,在專案根目錄執行:
|
取得本專案原始碼後,在專案根目錄執行:
|
||||||
|
|
||||||
|
|
@ -44,14 +45,14 @@ LazyBoy 讓 Agent 在 Docker 裡使用自己的 Linux 桌面,操作瀏覽器
|
||||||
make env
|
make env
|
||||||
```
|
```
|
||||||
|
|
||||||
編輯產生的 `.env`,填入 `XAI_API_KEY`、`OPENCODE_GO_API_KEY` 或 `OPENAI_API_KEY` 其中之一。初始化工具會產生登入與服務所需的金鑰,重新執行時保留既有設定。
|
產生的 `.env` 只有服務內部使用的秘密(supervisor、憑證庫、資料庫);初始化工具會產生這些值,重複執行時保留既有設定。登入與模型金鑰都不需要在這裡設定。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make up
|
make up
|
||||||
make health
|
make health
|
||||||
```
|
```
|
||||||
|
|
||||||
開啟 **[http://127.0.0.1:3101](http://127.0.0.1:3101)**,用 `.env` 裡的 `LAZYBOY_APP_TOKEN` 登入,建立 Agent 並選擇模型。
|
開啟 **[http://127.0.0.1:3101](http://127.0.0.1:3101)**,自行註冊一組帳號密碼,到「**設定 → 模型**」貼上模型 API 金鑰,再建立 Agent 並選擇模型。沒有金鑰就不會執行任何工作,App 會告訴你要去哪裡補。
|
||||||
|
|
||||||
第一次會從原始碼建置 API 與 Linux 桌面映像,所需時間較長。容器內已包含建置環境,完整 Docker 部署不需要在主機安裝 Rust 或 Node.js。
|
第一次會從原始碼建置 API 與 Linux 桌面映像,所需時間較長。容器內已包含建置環境,完整 Docker 部署不需要在主機安裝 Rust 或 Node.js。
|
||||||
|
|
||||||
|
|
@ -71,7 +72,7 @@ make down # 停止服務,保留 PostgreSQL 資料
|
||||||
|
|
||||||
## 在手機上使用
|
## 在手機上使用
|
||||||
|
|
||||||
手機與桌面使用同一個 Web 介面。API 預設只綁 `127.0.0.1`,請在 `.env` 設定 `LAZYBOY_BIND_IP=0.0.0.0`(或指定網卡位址)並重建 api 容器,區網裡的手機才連得到;綁非 loopback 時登入 token 需至少 32 字元。接著用手機瀏覽器開啟該位址——手機上的 `127.0.0.1` 只代表手機本身,不能拿來連另一台電腦。
|
手機與桌面使用同一個 Web 介面。API 預設只綁 `127.0.0.1`,請在 `.env` 設定 `LAZYBOY_BIND_IP=0.0.0.0`(或指定網卡位址)並重建 api 容器,區網裡的手機才連得到。接著用手機瀏覽器開啟該位址——手機上的 `127.0.0.1` 只代表手機本身,不能拿來連另一台電腦——並在手機上也註冊一個帳號:同一套安裝裡,每個人只會看到自己的 Agent。若要用網域名稱(而不是 IP 或 `localhost`)連線,還要把該名稱加進 `LAZYBOY_ALLOWED_HOSTS`。
|
||||||
|
|
||||||
聊天側欄可點外側空白處收合。操作遠端桌面時,可切換直接點選與觸控板模式,使用工具列叫出鍵盤、按右鍵或拖曳。對外提供服務時請設定 HTTPS,詳見 [部署指南](./docs/operations.md#安全模型)。
|
聊天側欄可點外側空白處收合。操作遠端桌面時,可切換直接點選與觸控板模式,使用工具列叫出鍵盤、按右鍵或拖曳。對外提供服務時請設定 HTTPS,詳見 [部署指南](./docs/operations.md#安全模型)。
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -70,3 +70,6 @@ export function Upload({className,size=16}:IconProps){
|
||||||
export function Reply({className,size=16}:IconProps){
|
export function Reply({className,size=16}:IconProps){
|
||||||
return <svg className={["animated-icon",className].filter(Boolean).join(" ")} width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg>;
|
return <svg className={["animated-icon",className].filter(Boolean).join(" ")} width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg>;
|
||||||
}
|
}
|
||||||
|
export function MeetingLayout({className,size=16}:IconProps){
|
||||||
|
return <svg className={["animated-icon",className].filter(Boolean).join(" ")} width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><rect x="2" y="4" width="14" height="16" rx="2"/><rect x="18" y="4" width="4" height="16" rx="1.2"/></svg>;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,15 @@
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
status:number;
|
status:number;
|
||||||
constructor(status:number,message:string){super(message);this.name="ApiError";this.status=status}
|
/** Stable machine code from the API (`username_taken`, `unauthenticated`). */
|
||||||
|
code:string;
|
||||||
|
constructor(status:number,message:string,code:string=""){super(message);this.name="ApiError";this.status=status;this.code=code}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function api<T>(path:string, options:RequestInit={}):Promise<T>{
|
export async function api<T>(path:string, options:RequestInit={}):Promise<T>{
|
||||||
const response=await fetch(path,{...options,headers:{"content-type":"application/json",...options.headers}});
|
// The login cookie is first-party and HttpOnly, so every call has to carry it.
|
||||||
|
const response=await fetch(path,{credentials:"same-origin",...options,headers:{"content-type":"application/json",...options.headers}});
|
||||||
const text=await response.text(); let body:unknown=null;
|
const text=await response.text(); let body:unknown=null;
|
||||||
try{body=text?JSON.parse(text):null}catch{body={message:text}}
|
try{body=text?JSON.parse(text):null}catch{body={message:text}}
|
||||||
if(!response.ok){const message=typeof body==="object"&&body&&"message" in body?String((body as {message:unknown}).message):`${response.status} ${response.statusText}`;throw new ApiError(response.status,message)}
|
if(!response.ok){const fields=(typeof body==="object"&&body?body:{}) as {message?:unknown;code?:unknown};const message=typeof fields.message==="string"&&fields.message?fields.message:`${response.status} ${response.statusText}`;throw new ApiError(response.status,message,typeof fields.code==="string"?fields.code:"")}
|
||||||
return body as T;
|
return body as T;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,15 @@
|
||||||
.overlay-screen>.desktop-frame,.overlay-screen>.empty-computer{width:min(100%,1440px);height:100%;border:1px solid var(--border);border-radius:14px;overflow:hidden;background:var(--main)}
|
.overlay-screen>.desktop-frame,.overlay-screen>.empty-computer{width:min(100%,1440px);height:100%;border:1px solid var(--border);border-radius:14px;overflow:hidden;background:var(--main)}
|
||||||
.overlay-error{position:absolute;top:74px;left:50%;transform:translateX(-50%);background:var(--error-bg);color:var(--error-fg);padding:9px 15px;border-radius:9px}
|
.overlay-error{position:absolute;top:74px;left:50%;transform:translateX(-50%);background:var(--error-bg);color:var(--error-fg);padding:9px 15px;border-radius:9px}
|
||||||
.computer-toggle{background:var(--surface)}
|
.computer-toggle{background:var(--surface)}
|
||||||
|
.meeting-stage{display:flex;flex-direction:column;min-width:0;min-height:0;background:var(--panel);border-right:1px solid var(--hairline)}
|
||||||
|
.meeting-stage.is-hidden{display:none}
|
||||||
|
.meeting-stage-head{display:flex;align-items:center;gap:6px;height:72px;flex:0 0 72px;padding:0 10px 0 12px;border-bottom:1px solid var(--hairline)}
|
||||||
|
.meeting-stage .side-card-body{display:flex;flex-direction:column;min-height:0;flex:1;padding:14px 16px 16px;overflow:hidden}
|
||||||
|
.meeting-stage.is-meeting .meeting-extras{display:none}
|
||||||
|
.meeting-stage.is-meeting .computer-part{overflow:hidden}
|
||||||
|
.meeting-stage.is-meeting .computer-part>.preview{flex:1 1 auto;flex-shrink:1;width:100%;height:auto;min-height:0;max-height:none;aspect-ratio:auto}
|
||||||
|
.meeting-stage.is-meeting .computer-part .preview .desktop-frame,
|
||||||
|
.meeting-stage.is-meeting .computer-part .preview .empty-computer{height:100%;aspect-ratio:auto;border-radius:12px;overflow:hidden}
|
||||||
.computer-part{display:flex;flex-direction:column;min-height:0;flex:1;gap:10px;overflow:auto}
|
.computer-part{display:flex;flex-direction:column;min-height:0;flex:1;gap:10px;overflow:auto}
|
||||||
.computer-part.hidden-part{display:none}
|
.computer-part.hidden-part{display:none}
|
||||||
.computer-status-row{display:flex;align-items:center;gap:8px;color:var(--muted)}
|
.computer-status-row{display:flex;align-items:center;gap:8px;color:var(--muted)}
|
||||||
|
|
@ -76,4 +85,12 @@
|
||||||
.computer-overlay{top:var(--visible-top,0px);bottom:auto;height:var(--visible-height,100dvh)}
|
.computer-overlay{top:var(--visible-top,0px);bottom:auto;height:var(--visible-height,100dvh)}
|
||||||
.computer-overlay>header{flex-shrink:0}
|
.computer-overlay>header{flex-shrink:0}
|
||||||
.overlay-error{position:static;transform:none;flex:none;margin:0 8px 8px}
|
.overlay-error{position:static;transform:none;flex:none;margin:0 8px 8px}
|
||||||
@media(max-width:700px){.overlay-screen{padding:4px}.computer-overlay>header{min-height:44px;gap:4px}.computer-overlay>header>div{gap:5px}.computer-overlay>header strong{font-size:12px}}
|
@media(max-width:700px){
|
||||||
|
.meeting-stage{display:none!important}
|
||||||
|
.computer-overlay{position:fixed!important;inset:0!important;top:0!important;left:0!important;right:0!important;bottom:0!important;width:100%!important;height:100dvh!important;height:100%!important;z-index:200}
|
||||||
|
.computer-overlay>header{height:auto;min-height:48px;gap:4px;padding:calc(6px + env(safe-area-inset-top)) 10px 6px}
|
||||||
|
.computer-overlay>header>div{gap:5px;flex-wrap:wrap}
|
||||||
|
.computer-overlay>header strong{font-size:12px}
|
||||||
|
.overlay-screen{padding:0}
|
||||||
|
.overlay-screen .overlay-desktop,.overlay-screen .overlay-desktop>.desktop-frame,.overlay-screen .overlay-desktop>.empty-computer{width:100%;height:100%;border:0;border-radius:0}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -333,7 +333,6 @@ export const en: { [K in keyof typeof zhTW]: string } = {
|
||||||
apiKey: "API key",
|
apiKey: "API key",
|
||||||
apiKeyPlaceholder: "sk-…",
|
apiKeyPlaceholder: "sk-…",
|
||||||
apiKeyStored: "A key is saved. Leave blank to keep it.",
|
apiKeyStored: "A key is saved. Leave blank to keep it.",
|
||||||
usingEnvKey: "Using environment variable {name}.",
|
|
||||||
clearApiKey: "Clear saved key",
|
clearApiKey: "Clear saved key",
|
||||||
modelId: "Model",
|
modelId: "Model",
|
||||||
modelIdPlaceholder: "For example: qwen2.5 or llama3.1",
|
modelIdPlaceholder: "For example: qwen2.5 or llama3.1",
|
||||||
|
|
@ -353,6 +352,10 @@ export const en: { [K in keyof typeof zhTW]: string } = {
|
||||||
helpGroups: "+ → New group, pick at least two. @ a name and that agent answers; unaddressed messages go to the host or the best fit.",
|
helpGroups: "+ → New group, pick at least two. @ a name and that agent answers; unaddressed messages go to the host or the best fit.",
|
||||||
helpComputerTitle: "Computer",
|
helpComputerTitle: "Computer",
|
||||||
helpComputer: "The Computer tab on the right is this agent’s desktop. Start it, take over mouse and keyboard, or let it drive.",
|
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",
|
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.",
|
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",
|
helpMcpTitle: "MCP plugins",
|
||||||
|
|
@ -395,11 +398,32 @@ export const en: { [K in keyof typeof zhTW]: string } = {
|
||||||
feedbackDescription: "Write a problem, idea, or request. This is a local workspace, so the text is copied to the clipboard for you to paste into an issue or message.",
|
feedbackDescription: "Write a problem, idea, or request. This is a local workspace, so the text is copied to the clipboard for you to paste into an issue or message.",
|
||||||
feedbackPlaceholder: "For example: in group chats I want to pick who speaks first…",
|
feedbackPlaceholder: "For example: in group chats I want to pick who speaks first…",
|
||||||
copyContent: "Copy",
|
copyContent: "Copy",
|
||||||
loginTitle: "Sign in to LazyBoy",
|
loginTitle: "Sign in",
|
||||||
loginDescription: "Enter the shared access token configured on the server.",
|
loginDescription: "Use the account you registered on this server. Your agents, chats, and computers stay yours alone.",
|
||||||
accessToken: "Access token",
|
accessToken: "Access token",
|
||||||
verifying: "Checking…",
|
verifying: "Working…",
|
||||||
login: "Sign in",
|
login: "Sign in",
|
||||||
|
registerTitle: "Create account",
|
||||||
|
registerDescription: "Each account owns its workspace: nobody else sees your agents, and you never see theirs.",
|
||||||
|
register: "Create account",
|
||||||
|
registerFailed: "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",
|
agentComputer: "Agent computer",
|
||||||
url: "URL",
|
url: "URL",
|
||||||
stdio: "stdio",
|
stdio: "stdio",
|
||||||
|
|
@ -536,6 +560,8 @@ export const en: { [K in keyof typeof zhTW]: string } = {
|
||||||
errorTitleInterrupted: "Interrupted",
|
errorTitleInterrupted: "Interrupted",
|
||||||
errorTitleToolTimeout: "Action timed out",
|
errorTitleToolTimeout: "Action timed out",
|
||||||
errorTitleModelKey: "Model API key rejected",
|
errorTitleModelKey: "Model API key rejected",
|
||||||
|
errorTitleModelKeyMissing: "No API key set",
|
||||||
|
errorTitleModelOptIn: "Model needs an opt-in on OpenCode",
|
||||||
errorTitleModelQuota: "Rate limited or out of quota",
|
errorTitleModelQuota: "Rate limited or out of quota",
|
||||||
errorTitleModelUnknown: "Model not found",
|
errorTitleModelUnknown: "Model not found",
|
||||||
errorTitleModelTimeout: "Model took too long",
|
errorTitleModelTimeout: "Model took too long",
|
||||||
|
|
|
||||||
|
|
@ -110,7 +110,6 @@ export const zhTW = {
|
||||||
providerOpencodeGoHint: "OpenCode Go 訂閱。金鑰從 opencode.ai/auth 取得。",
|
providerOpencodeGoHint: "OpenCode Go 訂閱。金鑰從 opencode.ai/auth 取得。",
|
||||||
providerOpenaiCompatibleHint: "自架 vLLM、Ollama、LiteLLM 或其他 OpenAI 相容端點。",
|
providerOpenaiCompatibleHint: "自架 vLLM、Ollama、LiteLLM 或其他 OpenAI 相容端點。",
|
||||||
apiKey: "API 金鑰", apiKeyPlaceholder: "sk-…", apiKeyStored: "已儲存金鑰,留空表示沿用。",
|
apiKey: "API 金鑰", apiKeyPlaceholder: "sk-…", apiKeyStored: "已儲存金鑰,留空表示沿用。",
|
||||||
usingEnvKey: "目前會用環境變數 {name}。",
|
|
||||||
clearApiKey: "清除已存金鑰",
|
clearApiKey: "清除已存金鑰",
|
||||||
modelId: "模型", modelIdPlaceholder: "例如:qwen2.5 或 llama3.1",
|
modelId: "模型", modelIdPlaceholder: "例如:qwen2.5 或 llama3.1",
|
||||||
reloadModels: "重新載入模型列表",
|
reloadModels: "重新載入模型列表",
|
||||||
|
|
@ -120,7 +119,10 @@ export const zhTW = {
|
||||||
aboutDescription: "本機多 Agent 工作區。每個機器人有自己的 Linux 電腦,也可以拉進群組一起討論,並接入 MCP 工具。",
|
aboutDescription: "本機多 Agent 工作區。每個機器人有自己的 Linux 電腦,也可以拉進群組一起討論,並接入 MCP 工具。",
|
||||||
apiStatus: "API 狀態:{status}", statusNormal: "正常", statusUnavailable: "無法連線", statusChecking: "檢查中…",
|
apiStatus: "API 狀態:{status}", statusNormal: "正常", statusUnavailable: "無法連線", statusChecking: "檢查中…",
|
||||||
helpBotsTitle: "機器人", helpBots: "左上角 + 新增機器人。點左側列進入對話。右鍵可以釘選、隱藏或刪除。", helpGroupsTitle: "群組", helpGroups: "+ → 新增群組,選至少兩位。@誰就由誰回;沒點名時,主持人或最合適的 Agent 會接手。",
|
helpBotsTitle: "機器人", helpBots: "左上角 + 新增機器人。點左側列進入對話。右鍵可以釘選、隱藏或刪除。", helpGroupsTitle: "群組", helpGroups: "+ → 新增群組,選至少兩位。@誰就由誰回;沒點名時,主持人或最合適的 Agent 會接手。",
|
||||||
helpComputerTitle: "電腦", helpComputer: "右側「電腦」是這個 Agent 的獨立桌面。可以啟動、接管滑鼠鍵盤,或讓它自己操作。", helpMemoryTitle: "記憶", helpMemory: "清除對話不會刪長期記憶。可以叫 Agent 記住,或在右側「記憶」手動新增。",
|
helpComputerTitle: "電腦", helpComputer: "右側「電腦」是這個 Agent 的獨立桌面。可以啟動、接管滑鼠鍵盤,或讓它自己操作。",
|
||||||
|
meetingMode: "會議模式", exitMeetingMode: "結束會議模式",
|
||||||
|
helpMeetingTitle: "會議模式", helpMeeting: "電腦版標題列可開啟會議模式:螢幕放大放中間,對話移到旁邊,像分享畫面時邊看邊聊。手機沒有這個模式。",
|
||||||
|
helpMemoryTitle: "記憶", helpMemory: "清除對話不會刪長期記憶。可以叫 Agent 記住,或在右側「記憶」手動新增。",
|
||||||
helpMcpTitle: "MCP 外掛", helpMcp: "左下「外掛程式」接入 MCP server。連上的工具會顯示在畫面上,對話時 Agent 可以使用。",
|
helpMcpTitle: "MCP 外掛", helpMcp: "左下「外掛程式」接入 MCP server。連上的工具會顯示在畫面上,對話時 Agent 可以使用。",
|
||||||
helpSkillsTitle: "技能", helpSkills: "+ → 教它一項任務,示範一次就會整理成技能。示範結束後可以匯出 JSON,或把別人的技能檔匯入,換一個機器人也適用。",
|
helpSkillsTitle: "技能", helpSkills: "+ → 教它一項任務,示範一次就會整理成技能。示範結束後可以匯出 JSON,或把別人的技能檔匯入,換一個機器人也適用。",
|
||||||
helpAttachTitle: "附件", helpAttach: "+ → 附加檔案。圖片這則訊息就會給模型看,不會存進對話紀錄。若機器人電腦要打開原檔,會暫放 inbox/,兩小時後自動刪,避免把磁碟塞滿。",
|
helpAttachTitle: "附件", helpAttach: "+ → 附加檔案。圖片這則訊息就會給模型看,不會存進對話紀錄。若機器人電腦要打開原檔,會暫放 inbox/,兩小時後自動刪,避免把磁碟塞滿。",
|
||||||
|
|
@ -138,7 +140,15 @@ export const zhTW = {
|
||||||
providerOpenaiVoiceHint: "OpenAI Realtime。同一套通話介面,之後可切換。",
|
providerOpenaiVoiceHint: "OpenAI Realtime。同一套通話介面,之後可切換。",
|
||||||
voiceReusesTextKey: "目前會重用文字模型已存的 xAI 金鑰。",
|
voiceReusesTextKey: "目前會重用文字模型已存的 xAI 金鑰。",
|
||||||
feedbackDescription: "寫下問題、想法或想要的功能。這是本機工作區,內容會複製到剪貼簿,方便你貼到 issue 或訊息裡。", feedbackPlaceholder: "例如:群組對話希望可以指定誰先發言…", copyContent: "複製內容",
|
feedbackDescription: "寫下問題、想法或想要的功能。這是本機工作區,內容會複製到剪貼簿,方便你貼到 issue 或訊息裡。", feedbackPlaceholder: "例如:群組對話希望可以指定誰先發言…", copyContent: "複製內容",
|
||||||
loginTitle: "登入 LazyBoy", loginDescription: "輸入伺服器設定的共享存取 token。", accessToken: "存取 token", verifying: "驗證中…", login: "登入",
|
loginTitle: "登入", loginDescription: "用你在這台伺服器註冊的帳號登入。每個人的 agent、對話與電腦都只有你自己看得到。", accessToken: "存取 token", verifying: "處理中…", login: "登入",
|
||||||
|
registerTitle: "建立帳號", registerDescription: "註冊後你會擁有自己的工作區,別人看不到你的 agent,你也不會看到他們的。", register: "建立帳號", registerFailed: "註冊失敗",
|
||||||
|
switchToRegister: "還沒有帳號?建立一個", switchToLogin: "已經有帳號了?返回登入",
|
||||||
|
loginUsername: "帳號", loginPassword: "密碼",
|
||||||
|
loginInvalidCredentials: "帳號或密碼不正確。", loginUsernameTaken: "這個帳號已經有人用了。", loginTooManyAttempts: "嘗試次數太多,請 5 分鐘後再試。", loginFieldsRequired: "請輸入帳號與密碼。",
|
||||||
|
loginUsernameTooShort: "帳號至少 3 個字元。", loginUsernameTooLong: "帳號最多 32 個字元。", loginUsernameCharacters: "帳號只能用字母、數字與 . _ -。", loginUsernameEdges: "帳號必須以字母或數字開頭與結尾。",
|
||||||
|
loginPasswordTooShort: "密碼至少 8 個字元。", loginPasswordTooLong: "密碼太長了。", loginPasswordCharacters: "密碼含有無法使用的字元。",
|
||||||
|
modelKeyNotSet: "這個工作區還沒有 API 金鑰,貼上金鑰後 agent 才能開始工作。",
|
||||||
|
modelKeyNeededForProvider: "換了供應商,原本存的金鑰不能沿用;請貼上這個供應商的金鑰,否則儲存後 agent 會無法開始。",
|
||||||
agentComputer: "Agent 電腦", url: "URL", stdio: "stdio", http: "HTTP", sse: "SSE",
|
agentComputer: "Agent 電腦", url: "URL", stdio: "stdio", http: "HTTP", sse: "SSE",
|
||||||
argumentsPlaceholder: "-y @modelcontextprotocol/server-github", environmentVariablesPlaceholder: "GITHUB_TOKEN=…",
|
argumentsPlaceholder: "-y @modelcontextprotocol/server-github", environmentVariablesPlaceholder: "GITHUB_TOKEN=…",
|
||||||
urlPlaceholder: "https://mcp.example.com/mcp", headersPlaceholder: "Authorization=Bearer …",
|
urlPlaceholder: "https://mcp.example.com/mcp", headersPlaceholder: "Authorization=Bearer …",
|
||||||
|
|
@ -225,7 +235,7 @@ export const zhTW = {
|
||||||
monitorStatusPaused: "要你接手",
|
monitorStatusPaused: "要你接手",
|
||||||
errorTitleInterrupted: "被打斷",
|
errorTitleInterrupted: "被打斷",
|
||||||
errorTitleToolTimeout: "動作逾時",
|
errorTitleToolTimeout: "動作逾時",
|
||||||
errorTitleModelKey: "模型金鑰不被接受",
|
errorTitleModelKey: "模型金鑰不被接受", errorTitleModelKeyMissing: "尚未設定 API 金鑰", errorTitleModelOptIn: "模型需要先在 OpenCode 開啟",
|
||||||
errorTitleModelQuota: "限流或額度不足",
|
errorTitleModelQuota: "限流或額度不足",
|
||||||
errorTitleModelUnknown: "模型名稱找不到",
|
errorTitleModelUnknown: "模型名稱找不到",
|
||||||
errorTitleModelTimeout: "模型太久沒回話",
|
errorTitleModelTimeout: "模型太久沒回話",
|
||||||
|
|
|
||||||
|
|
@ -482,6 +482,34 @@
|
||||||
|
|
||||||
.app-shell.right-open{grid-template-columns:clamp(220px,18vw,280px) minmax(0,1fr) clamp(320px,30vw,440px)}
|
.app-shell.right-open{grid-template-columns:clamp(220px,18vw,280px) minmax(0,1fr) clamp(320px,30vw,440px)}
|
||||||
|
|
||||||
|
.app-shell.meeting-mode,.app-shell.meeting-mode.right-open,.app-shell.meeting-mode.right-collapsed{
|
||||||
|
grid-template-columns:clamp(200px,16vw,260px) minmax(0,1fr) minmax(300px,380px);
|
||||||
|
grid-template-areas:"nav stage chat";
|
||||||
|
}
|
||||||
|
.app-shell.meeting-mode .sidebar{grid-area:nav}
|
||||||
|
.app-shell.meeting-mode .meeting-stage{grid-area:stage;border-right:0}
|
||||||
|
.app-shell.meeting-mode .chat-panel{grid-area:chat;--chat-gutter:14px;--chat-col:100%;border-left:1px solid var(--hairline);min-width:0}
|
||||||
|
.app-shell.meeting-mode .chat-panel .topbar{
|
||||||
|
display:flex;flex-wrap:wrap;align-items:center;gap:6px 8px;
|
||||||
|
height:auto;min-height:0;flex:0 0 auto;padding:8px 10px;
|
||||||
|
}
|
||||||
|
.app-shell.meeting-mode .chat-panel .topbar>.grow{display:none}
|
||||||
|
.app-shell.meeting-mode .chat-panel .topbar>strong{min-width:0;flex:1 1 80px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
|
.app-shell.meeting-mode .chat-panel .topbar>.host-chip-wrap{flex:0 1 auto;max-width:100%}
|
||||||
|
.app-shell.meeting-mode .host-chip{max-width:96px}
|
||||||
|
.app-shell.meeting-mode .session-picker{margin-left:0;flex:0 1 auto}
|
||||||
|
.app-shell.meeting-mode .session-current{max-width:min(100%,140px)}
|
||||||
|
.app-shell.meeting-mode .chat-panel .topbar>.top-tools{
|
||||||
|
flex:1 0 100%;width:100%;margin:0;justify-content:space-between;
|
||||||
|
box-sizing:border-box;padding:3px;overflow:hidden;
|
||||||
|
}
|
||||||
|
.app-shell.meeting-mode .top-tool-button{width:32px;height:32px;flex:0 0 32px}
|
||||||
|
.app-shell.meeting-mode .call-entry{flex:0 0 auto}
|
||||||
|
.app-shell.meeting-mode .messages{padding-left:var(--chat-gutter);padding-right:var(--chat-gutter);min-width:0}
|
||||||
|
.app-shell.meeting-mode .composer-dock{padding-left:var(--chat-gutter);padding-right:var(--chat-gutter)}
|
||||||
|
.app-shell.meeting-mode .side-card-backdrop{display:block;position:fixed;inset:0;z-index:44;background:rgba(0,0,0,.5);grid-area:stage}
|
||||||
|
.app-shell.meeting-mode .side-card{position:fixed;z-index:45;inset:0 0 0 auto;width:min(420px,100vw);grid-area:stage;box-shadow:-20px 0 60px #000}
|
||||||
|
|
||||||
|
|
||||||
.app-shell.right-collapsed{grid-template-columns:clamp(220px,18vw,280px) minmax(0,1fr) 52px}
|
.app-shell.right-collapsed{grid-template-columns:clamp(220px,18vw,280px) minmax(0,1fr) 52px}
|
||||||
|
|
||||||
|
|
@ -579,23 +607,44 @@
|
||||||
.memory-clear-confirm{display:flex;align-items:center;gap:8px;margin-right:auto;color:var(--muted);font-size:13px}
|
.memory-clear-confirm{display:flex;align-items:center;gap:8px;margin-right:auto;color:var(--muted);font-size:13px}
|
||||||
|
|
||||||
|
|
||||||
.login-screen{position:relative;display:grid;min-height:100%;place-items:center;padding:88px 20px;background:radial-gradient(circle at 50% 20%,#18201e 0,#080809 55%)}
|
.login-screen{position:relative;display:grid;min-height:100%;min-height:100dvh;place-items:center;padding:72px 24px 64px;background:radial-gradient(circle at 50% 18%,#16241f 0,#080809 58%)}
|
||||||
|
|
||||||
.login-dialog{display:grid;justify-items:stretch}
|
.login-dialog{width:min(320px,100%);display:grid;justify-items:stretch;gap:14px}
|
||||||
|
|
||||||
.login-dialog>.avatar{justify-self:center}
|
.login-dialog>.avatar{justify-self:center;margin:0 0 10px}
|
||||||
|
|
||||||
.login-dialog h1,.login-dialog p{text-align:center}
|
.login-dialog h1{margin:0 0 8px;text-align:center;font-size:20px;font-weight:650;letter-spacing:.01em}
|
||||||
|
|
||||||
.login-dialog p{margin-top:-8px;color:var(--muted)}
|
.login-dialog label{display:grid;gap:6px;color:var(--muted);font-size:13px}
|
||||||
|
|
||||||
|
.login-dialog input{height:44px;border:1px solid var(--border);border-radius:10px;background:var(--input);color:var(--ink);padding:0 12px;outline:0}
|
||||||
|
|
||||||
|
.login-dialog input:focus{border-color:var(--focus)}
|
||||||
|
|
||||||
|
.login-dialog .primary{height:44px;margin-top:4px;justify-content:center}
|
||||||
|
|
||||||
.login-error{color:var(--danger-soft);font-size:13px}
|
.login-error{color:var(--danger-soft);font-size:13px}
|
||||||
|
|
||||||
.login-lang{position:absolute;top:24px;right:24px;display:flex;align-items:center;gap:6px}
|
.login-switch{min-height:0;justify-self:center;margin-top:2px;padding:8px;border:0;border-radius:8px;background:transparent;color:var(--muted);font-size:13px;cursor:pointer}
|
||||||
|
|
||||||
.login-lang button{height:32px;padding:0 12px;border:1px solid var(--border);border-radius:9px;background:transparent;color:var(--muted);cursor:pointer}
|
.login-switch:hover{color:var(--ink)}
|
||||||
|
|
||||||
.login-lang button.picked{border-color:var(--accent);color:var(--ink);background:rgba(62,197,168,.08)}
|
.login-switch:disabled{opacity:.35;cursor:not-allowed}
|
||||||
|
|
||||||
|
.login-lang{position:absolute;top:20px;right:20px;display:flex;align-items:center;gap:4px}
|
||||||
|
|
||||||
|
.login-lang button{height:auto;padding:6px 8px;border:0;border-radius:8px;background:transparent;color:var(--faint);cursor:pointer;font-size:13px}
|
||||||
|
|
||||||
|
.login-lang button:hover{color:var(--muted)}
|
||||||
|
|
||||||
|
.login-lang button.picked{color:var(--ink);font-weight:600}
|
||||||
|
|
||||||
|
@media(max-width:700px){
|
||||||
|
.login-screen{padding:56px 20px 40px}
|
||||||
|
.login-dialog{width:min(360px,100%)}
|
||||||
|
.login-lang{top:12px;right:8px}
|
||||||
|
.login-lang button{min-height:44px;padding:8px 10px}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@keyframes working-shimmer{to{background-position:-220% 0}}
|
@keyframes working-shimmer{to{background-position:-220% 0}}
|
||||||
|
|
|
||||||
|
|
@ -4,14 +4,26 @@
|
||||||
@media(max-width:1050px){.app-shell{grid-template-columns:250px minmax(0,1fr)}}
|
@media(max-width:1050px){.app-shell{grid-template-columns:250px minmax(0,1fr)}}
|
||||||
@media(max-width:700px){.chat-panel{--chat-gutter:18px}.app-shell{display:block}.message{width:100%}.day-divider{width:100%}.message>span,.message>.message-body,.message-stack{max-width:90%}.composer-dock{padding:20px var(--chat-gutter) 16px}.composer{width:var(--chat-col);min-height:60px}.composer-files{padding:2px 4px 8px 8px}.composer-reply{margin:2px 0 4px;padding-left:12px}.file-card{max-width:100%;height:52px}.file-card-remove{flex-basis:32px;width:32px;height:32px}.message.with-files .msg-attachments{max-width:100%}.messages{padding-bottom:16px}.context-menu{width:min(220px,calc(100vw - 24px))}.context-menu button{height:44px}}
|
@media(max-width:700px){.chat-panel{--chat-gutter:18px}.app-shell{display:block}.message{width:100%}.day-divider{width:100%}.message>span,.message>.message-body,.message-stack{max-width:90%}.composer-dock{padding:20px var(--chat-gutter) 16px}.composer{width:var(--chat-col);min-height:60px}.composer-files{padding:2px 4px 8px 8px}.composer-reply{margin:2px 0 4px;padding-left:12px}.file-card{max-width:100%;height:52px}.file-card-remove{flex-basis:32px;width:32px;height:32px}.message.with-files .msg-attachments{max-width:100%}.messages{padding-bottom:16px}.context-menu{width:min(220px,calc(100vw - 24px))}.context-menu button{height:44px}}
|
||||||
@media(prefers-reduced-motion:reduce){.avatar.blobatar.thinking:before,.avatar.blobatar.thinking:after,.thinking-dots i{animation:none!important}}
|
@media(prefers-reduced-motion:reduce){.avatar.blobatar.thinking:before,.avatar.blobatar.thinking:after,.thinking-dots i{animation:none!important}}
|
||||||
@media(max-width:1200px){.app-shell.right-open{grid-template-columns:230px minmax(0,1fr) 340px}.app-shell.right-collapsed{grid-template-columns:230px minmax(0,1fr) 52px}}
|
@media(max-width:1200px){.app-shell.right-open{grid-template-columns:230px minmax(0,1fr) 340px}.app-shell.right-collapsed{grid-template-columns:230px minmax(0,1fr) 52px}.app-shell.meeting-mode,.app-shell.meeting-mode.right-open,.app-shell.meeting-mode.right-collapsed{grid-template-columns:200px minmax(0,1fr) 320px}}
|
||||||
@media(max-width:1050px){
|
@media(max-width:1050px){
|
||||||
.panel-open-btn{display:inline-flex}
|
.panel-open-btn{display:inline-flex}
|
||||||
.app-shell.right-collapsed{grid-template-columns:250px minmax(0,1fr)}
|
.app-shell.right-collapsed{grid-template-columns:250px minmax(0,1fr)}
|
||||||
.app-shell.right-collapsed .side-card{display:none}
|
.app-shell.right-collapsed .side-card{display:none}
|
||||||
.app-shell.right-open{grid-template-columns:250px minmax(0,1fr)}
|
.app-shell.right-open{grid-template-columns:250px minmax(0,1fr)}
|
||||||
.app-shell.right-open .side-card-backdrop{display:block;position:fixed;inset:0;z-index:44;background:rgba(0,0,0,.5)}
|
.app-shell.right-open .side-card-backdrop,.app-shell.stage-open:not(.meeting-mode) .side-card-backdrop{display:block;position:fixed;inset:0;z-index:44;background:rgba(0,0,0,.5)}
|
||||||
.app-shell.right-open .side-card{position:fixed;z-index:45;inset:0 0 0 auto;width:min(420px,100vw);box-shadow:-20px 0 60px #000}
|
.app-shell.right-open .side-card{position:fixed;z-index:45;inset:0 0 0 auto;width:min(420px,100vw);box-shadow:-20px 0 60px #000}
|
||||||
|
.app-shell:not(.meeting-mode).stage-open .meeting-stage:not(.is-hidden){position:fixed;z-index:45;inset:0 0 0 auto;width:min(420px,100vw);box-shadow:-20px 0 60px #000}
|
||||||
|
.app-shell.meeting-mode,.app-shell.meeting-mode.right-open,.app-shell.meeting-mode.right-collapsed{
|
||||||
|
display:grid;grid-template-columns:minmax(0,1fr) minmax(280px,340px);grid-template-areas:"stage chat";
|
||||||
|
}
|
||||||
|
.app-shell.meeting-mode .sidebar{position:fixed;z-index:40;inset:0 auto 0 0;width:min(300px,88vw);transform:translateX(-105%);transition:.2s;box-shadow:20px 0 60px #000}
|
||||||
|
.app-shell.meeting-mode .sidebar.open{transform:none}
|
||||||
|
.app-shell.meeting-mode .mobile-menu{display:inline-flex}
|
||||||
|
}
|
||||||
|
@media(max-width:700px){
|
||||||
|
.meeting-toggle{display:none!important}
|
||||||
|
.meeting-stage{display:none!important}
|
||||||
|
.app-shell.meeting-mode,.app-shell.meeting-mode.right-open,.app-shell.meeting-mode.right-collapsed{display:block;grid-template-areas:none;grid-template-columns:none}
|
||||||
}
|
}
|
||||||
@media(prefers-reduced-motion:reduce){.working-label{animation:none;color:var(--muted);background:none}}
|
@media(prefers-reduced-motion:reduce){.working-label{animation:none;color:var(--muted);background:none}}
|
||||||
@media(prefers-reduced-motion:reduce){.computer-hud .avatar.blobatar,.computer-hud-label{animation:none!important}.computer-hud-label{color:var(--muted);background:none}}
|
@media(prefers-reduced-motion:reduce){.computer-hud .avatar.blobatar,.computer-hud-label{animation:none!important}.computer-hud-label{color:var(--muted);background:none}}
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,8 @@ const ACTIONS: Record<string, RunActionId[]> = {
|
||||||
interrupted: ["screen", "retry"],
|
interrupted: ["screen", "retry"],
|
||||||
tool_timeout: ["screen", "retry"],
|
tool_timeout: ["screen", "retry"],
|
||||||
model_key: ["settings", "retry"],
|
model_key: ["settings", "retry"],
|
||||||
|
model_key_missing: ["settings", "retry"],
|
||||||
|
model_opt_in: ["settings", "retry"],
|
||||||
model_unknown: ["settings", "retry"],
|
model_unknown: ["settings", "retry"],
|
||||||
model_quota: ["retry", "settings"],
|
model_quota: ["retry", "settings"],
|
||||||
model_timeout: ["retry", "settings"],
|
model_timeout: ["retry", "settings"],
|
||||||
|
|
@ -38,6 +40,8 @@ const TITLES: Record<string, MessageKey> = {
|
||||||
interrupted: "errorTitleInterrupted",
|
interrupted: "errorTitleInterrupted",
|
||||||
tool_timeout: "errorTitleToolTimeout",
|
tool_timeout: "errorTitleToolTimeout",
|
||||||
model_key: "errorTitleModelKey",
|
model_key: "errorTitleModelKey",
|
||||||
|
model_key_missing: "errorTitleModelKeyMissing",
|
||||||
|
model_opt_in: "errorTitleModelOptIn",
|
||||||
model_quota: "errorTitleModelQuota",
|
model_quota: "errorTitleModelQuota",
|
||||||
model_unknown: "errorTitleModelUnknown",
|
model_unknown: "errorTitleModelUnknown",
|
||||||
model_timeout: "errorTitleModelTimeout",
|
model_timeout: "errorTitleModelTimeout",
|
||||||
|
|
|
||||||
|
|
@ -38,15 +38,13 @@ export interface VoiceSettings {
|
||||||
ready: boolean;
|
ready: boolean;
|
||||||
missing?: string | null;
|
missing?: string | null;
|
||||||
apiKeySet: boolean;
|
apiKeySet: boolean;
|
||||||
envKeySet: boolean;
|
|
||||||
envKeyName: string;
|
|
||||||
reusesTextKey: boolean;
|
reusesTextKey: boolean;
|
||||||
providers: { id: VoiceProviderId; name: string; envKeyName: string }[];
|
providers: { id: VoiceProviderId; name: string }[];
|
||||||
models: { id: string; name: string }[];
|
models: { id: string; name: string }[];
|
||||||
voices: { id: string; name: string }[];
|
voices: { id: string; name: string }[];
|
||||||
}
|
}
|
||||||
export interface WorkspaceProvider { id:ModelProviderId; name:string; needsBaseUrl:boolean; needsKey:boolean; defaultBaseUrl:string|null; defaultModel:string|null }
|
export interface WorkspaceProvider { id:ModelProviderId; name:string; needsBaseUrl:boolean; needsKey:boolean; defaultBaseUrl:string|null; defaultModel:string|null }
|
||||||
export interface WorkspaceModel { id:string; name:string }
|
export interface WorkspaceModel { id:string; name:string }
|
||||||
export interface WorkspaceSettings { provider:ModelProviderId; modelId:string; baseUrl:string; apiKeySet:boolean; envKeySet:boolean; envKeyName:string; providers:WorkspaceProvider[]; models:WorkspaceModel[] }
|
export interface WorkspaceSettings { provider:ModelProviderId; modelId:string; baseUrl:string; apiKeySet:boolean; providers:WorkspaceProvider[]; models:WorkspaceModel[] }
|
||||||
|
|
||||||
export interface MemoryStatus { globallyEnabled:boolean; embeddingStatus:"disabled"|"loading"|"ready"|"busy"|"unavailable"; storedCount:number; indexedCount:number }
|
export interface MemoryStatus { globallyEnabled:boolean; embeddingStatus:"disabled"|"loading"|"ready"|"busy"|"unavailable"; storedCount:number; indexedCount:number }
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,6 @@ export function VoiceSettingsDialog({ close }: { close: () => void }) {
|
||||||
.catch((err) => setError(err instanceof Error ? err.message : t("loadFailed")));
|
.catch((err) => setError(err instanceof Error ? err.message : t("loadFailed")));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const current = settings?.providers.find((item) => item.id === provider);
|
|
||||||
const models = (settings?.provider === provider ? settings.models : null)
|
const models = (settings?.provider === provider ? settings.models : null)
|
||||||
?? (provider === "openai"
|
?? (provider === "openai"
|
||||||
? [{ id: "gpt-realtime", name: "GPT Realtime" }]
|
? [{ id: "gpt-realtime", name: "GPT Realtime" }]
|
||||||
|
|
@ -94,8 +93,8 @@ export function VoiceSettingsDialog({ close }: { close: () => void }) {
|
||||||
<legend>{t("voiceProvider")}</legend>
|
<legend>{t("voiceProvider")}</legend>
|
||||||
<div className="provider-grid">
|
<div className="provider-grid">
|
||||||
{(settings?.providers || [
|
{(settings?.providers || [
|
||||||
{ id: "xai" as const, name: t("providerXai"), envKeyName: "XAI_API_KEY" },
|
{ id: "xai" as const, name: t("providerXai") },
|
||||||
{ id: "openai" as const, name: t("providerOpenai"), envKeyName: "OPENAI_API_KEY" },
|
{ id: "openai" as const, name: t("providerOpenai") },
|
||||||
]).map((item) => (
|
]).map((item) => (
|
||||||
<button type="button" key={item.id} className={provider === item.id ? "picked" : ""} onClick={() => pickProvider(item.id)}>
|
<button type="button" key={item.id} className={provider === item.id ? "picked" : ""} onClick={() => pickProvider(item.id)}>
|
||||||
{item.id === "xai" ? t("providerXai") : item.id === "openai" ? t("providerOpenai") : item.name}
|
{item.id === "xai" ? t("providerXai") : item.id === "openai" ? t("providerOpenai") : item.name}
|
||||||
|
|
@ -130,7 +129,6 @@ export function VoiceSettingsDialog({ close }: { close: () => void }) {
|
||||||
<input type="password" autoComplete="off" value={apiKey} onChange={(event) => { setApiKey(event.target.value); setClearKey(false); }} placeholder={settings?.apiKeySet ? t("apiKeyStored") : t("apiKeyPlaceholder")} />
|
<input type="password" autoComplete="off" value={apiKey} onChange={(event) => { setApiKey(event.target.value); setClearKey(false); }} placeholder={settings?.apiKeySet ? t("apiKeyStored") : t("apiKeyPlaceholder")} />
|
||||||
</label>
|
</label>
|
||||||
{settings?.reusesTextKey && !apiKey && !clearKey ? <p className="dialog-lead">{t("voiceReusesTextKey")}</p> : null}
|
{settings?.reusesTextKey && !apiKey && !clearKey ? <p className="dialog-lead">{t("voiceReusesTextKey")}</p> : null}
|
||||||
{settings?.envKeySet && !apiKey && !clearKey ? <p className="dialog-lead">{t("usingEnvKey", { name: current?.envKeyName || settings.envKeyName })}</p> : null}
|
|
||||||
{settings?.apiKeySet ? <label className="memory-toggle"><input type="checkbox" checked={clearKey} onChange={(event) => setClearKey(event.target.checked)} /> {t("clearApiKey")}</label> : null}
|
{settings?.apiKeySet ? <label className="memory-toggle"><input type="checkbox" checked={clearKey} onChange={(event) => setClearKey(event.target.checked)} /> {t("clearApiKey")}</label> : null}
|
||||||
</> : null}
|
</> : null}
|
||||||
{error ? <div className="pane-error">{error}</div> : null}
|
{error ? <div className="pane-error">{error}</div> : null}
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ tower-http.workspace = true
|
||||||
rig-core.workspace = true
|
rig-core.workspace = true
|
||||||
base64.workspace = true
|
base64.workspace = true
|
||||||
sha2.workspace = true
|
sha2.workspace = true
|
||||||
|
hmac.workspace = true
|
||||||
hex.workspace = true
|
hex.workspace = true
|
||||||
reqwest.workspace = true
|
reqwest.workspace = true
|
||||||
tokio-tungstenite.workspace = true
|
tokio-tungstenite.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,498 @@
|
||||||
|
//! Accounts: a username and a password are the only way into LazyBoy.
|
||||||
|
//!
|
||||||
|
//! Opening a workspace used to mean knowing one shared token from `.env`, and
|
||||||
|
//! everyone who knew it saw the same agents and the same desktops. Here a
|
||||||
|
//! person registers, owns a workspace of their own, and the cookie they get
|
||||||
|
//! resolves to exactly one actor on every later request — which is what keeps
|
||||||
|
//! one person's agents out of another person's list.
|
||||||
|
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use axum::extract::State;
|
||||||
|
use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
|
use axum::routing::{get, post};
|
||||||
|
use axum::{Json, Router};
|
||||||
|
use hmac::{Hmac, Mac};
|
||||||
|
use rand::RngCore;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::json;
|
||||||
|
use sha2::Sha256;
|
||||||
|
|
||||||
|
use crate::auth::{self, COOKIE_NAME};
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
/// PBKDF2-HMAC-SHA256 rounds. High enough that cracking a stolen password
|
||||||
|
/// database is expensive, low enough that signing in never feels stuck. The
|
||||||
|
/// number is stored with every hash, so raising it later leaves old accounts
|
||||||
|
/// working and only affects newly set passwords.
|
||||||
|
const PBKDF2_ROUNDS: u32 = 120_000;
|
||||||
|
/// A hostile or corrupt row must not turn one login into a CPU denial of
|
||||||
|
/// service; anything above this is rejected rather than computed.
|
||||||
|
const MAX_PBKDF2_ROUNDS: u32 = 2_000_000;
|
||||||
|
const SALT_BYTES: usize = 16;
|
||||||
|
const KEY_BYTES: usize = 32;
|
||||||
|
const HASH_PREFIX: &str = "pbkdf2-sha256";
|
||||||
|
|
||||||
|
pub const MIN_PASSWORD_CHARS: usize = 8;
|
||||||
|
pub const MAX_PASSWORD_CHARS: usize = 256;
|
||||||
|
const MIN_USERNAME_CHARS: usize = 3;
|
||||||
|
const MAX_USERNAME_CHARS: usize = 32;
|
||||||
|
/// A login stays valid for a week, and survives an API restart: sessions live
|
||||||
|
/// in the database, not in process memory.
|
||||||
|
const SESSION_DAYS: i64 = 7;
|
||||||
|
/// Failed sign-ins per username before a cool-down, and how long it lasts.
|
||||||
|
/// Passwords are hashed on every attempt, so a throttled name is also how one
|
||||||
|
/// account cannot be used to burn the whole server.
|
||||||
|
const MAX_FAILED_LOGINS: u32 = 8;
|
||||||
|
const LOGIN_COOLDOWN: Duration = Duration::from_secs(300);
|
||||||
|
|
||||||
|
fn hmac_sha256(key: &[u8], data: &[u8]) -> [u8; KEY_BYTES] {
|
||||||
|
let mut mac = <Hmac<Sha256> as Mac>::new_from_slice(key).expect("HMAC accepts any key length");
|
||||||
|
mac.update(data);
|
||||||
|
let digest = mac.finalize().into_bytes();
|
||||||
|
let mut out = [0u8; KEY_BYTES];
|
||||||
|
out.copy_from_slice(&digest);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PBKDF2 with the single-block PRF, which is all a 256-bit key needs.
|
||||||
|
fn pbkdf2_sha256(password: &[u8], salt: &[u8], rounds: u32) -> [u8; KEY_BYTES] {
|
||||||
|
let mut previous = hmac_sha256(password, &[salt, &1u32.to_be_bytes()].concat());
|
||||||
|
let mut derived = previous;
|
||||||
|
for _ in 1..rounds {
|
||||||
|
previous = hmac_sha256(password, &previous);
|
||||||
|
for (byte, round) in derived.iter_mut().zip(previous) {
|
||||||
|
*byte ^= round;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
derived
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `pbkdf2-sha256$rounds$salt$hash`, self-describing so the format can move.
|
||||||
|
pub fn hash_password(password: &str) -> String {
|
||||||
|
let mut salt = [0u8; SALT_BYTES];
|
||||||
|
rand::thread_rng().fill_bytes(&mut salt);
|
||||||
|
let key = pbkdf2_sha256(password.as_bytes(), &salt, PBKDF2_ROUNDS);
|
||||||
|
format!(
|
||||||
|
"{HASH_PREFIX}${PBKDF2_ROUNDS}${}${}",
|
||||||
|
hex::encode(salt),
|
||||||
|
hex::encode(key)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_password(stored: &str) -> Option<(u32, Vec<u8>, Vec<u8>)> {
|
||||||
|
let (algorithm, rest) = stored.split_once('$')?;
|
||||||
|
if algorithm != HASH_PREFIX {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut parts = rest.split('$');
|
||||||
|
let rounds: u32 = parts.next()?.parse().ok()?;
|
||||||
|
if rounds == 0 || rounds > MAX_PBKDF2_ROUNDS {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let salt = hex::decode(parts.next()?).ok()?;
|
||||||
|
let key = hex::decode(parts.next()?).ok()?;
|
||||||
|
if salt.is_empty() || key.is_empty() || parts.next().is_some() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some((rounds, salt, key))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Constant-time check against the stored hash. An unparseable or unknown-format
|
||||||
|
/// hash is simply "not this password" — never an open door.
|
||||||
|
pub fn verify_password(password: &str, stored: &str) -> bool {
|
||||||
|
let Some((rounds, salt, expected)) = parse_password(stored) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let key = pbkdf2_sha256(password.as_bytes(), &salt, rounds);
|
||||||
|
auth::constant_time_eq(&key, &expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Usernames are addresses, so they are stored in one spelling.
|
||||||
|
pub fn normalize_username(raw: &str) -> String {
|
||||||
|
raw.trim().to_lowercase()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The rules that keep a name typeable, comparable, and unambiguous in a URL
|
||||||
|
/// or an `@mention`: letters and numbers (any language) plus `.` `_` `-`,
|
||||||
|
/// bracketed by word characters.
|
||||||
|
fn username_problem(username: &str) -> Option<&'static str> {
|
||||||
|
if username.chars().count() < MIN_USERNAME_CHARS {
|
||||||
|
return Some("username_too_short");
|
||||||
|
}
|
||||||
|
if username.chars().count() > MAX_USERNAME_CHARS {
|
||||||
|
return Some("username_too_long");
|
||||||
|
}
|
||||||
|
let first = username.chars().next().unwrap_or(' ');
|
||||||
|
let last = username.chars().last().unwrap_or(' ');
|
||||||
|
if !first.is_alphanumeric() || !last.is_alphanumeric() {
|
||||||
|
return Some("username_must_start_with_letter");
|
||||||
|
}
|
||||||
|
if username
|
||||||
|
.chars()
|
||||||
|
.any(|ch| !(ch.is_alphanumeric() || matches!(ch, '.' | '_' | '-')))
|
||||||
|
{
|
||||||
|
return Some("username_has_invalid_characters");
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn password_problem(password: &str) -> Option<&'static str> {
|
||||||
|
let count = password.chars().count();
|
||||||
|
if count < MIN_PASSWORD_CHARS {
|
||||||
|
return Some("password_too_short");
|
||||||
|
}
|
||||||
|
if count > MAX_PASSWORD_CHARS {
|
||||||
|
return Some("password_too_long");
|
||||||
|
}
|
||||||
|
if password.chars().any(char::is_control) {
|
||||||
|
return Some("password_has_invalid_characters");
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sliding cool-down per username. In memory on purpose: it exists to make
|
||||||
|
/// online guessing slow, not to survive a restart.
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
pub struct LoginGuard {
|
||||||
|
failures: Arc<Mutex<Vec<(String, u32, Instant)>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LoginGuard {
|
||||||
|
fn locked(&self, key: &str) -> bool {
|
||||||
|
let now = Instant::now();
|
||||||
|
let failures = self
|
||||||
|
.failures
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|error| error.into_inner());
|
||||||
|
failures
|
||||||
|
.iter()
|
||||||
|
.any(|(name, count, until)| name == key && *count >= MAX_FAILED_LOGINS && *until > now)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record_failure(&self, key: &str) {
|
||||||
|
let mut failures = self
|
||||||
|
.failures
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|error| error.into_inner());
|
||||||
|
failures.retain(|(_, _, until)| *until > Instant::now());
|
||||||
|
let until = Instant::now() + LOGIN_COOLDOWN;
|
||||||
|
match failures.iter_mut().find(|(name, _, _)| name == key) {
|
||||||
|
Some(entry) => {
|
||||||
|
entry.1 += 1;
|
||||||
|
entry.2 = until;
|
||||||
|
}
|
||||||
|
None => failures.push((key.to_string(), 1, until)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clear(&self, key: &str) {
|
||||||
|
let mut failures = self
|
||||||
|
.failures
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|error| error.into_inner());
|
||||||
|
failures.retain(|(name, _, _)| name != key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct CredentialsInput {
|
||||||
|
#[serde(default)]
|
||||||
|
username: String,
|
||||||
|
#[serde(default)]
|
||||||
|
password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct SignedIn {
|
||||||
|
ok: bool,
|
||||||
|
username: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn router(state: AppState) -> Router {
|
||||||
|
Router::new()
|
||||||
|
.route("/api/auth/session", get(current_session).delete(logout))
|
||||||
|
.route("/api/auth/register", post(register))
|
||||||
|
.route("/api/auth/login", post(login))
|
||||||
|
.with_state(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The single source of truth for "am I signed in": the login screen and every
|
||||||
|
/// 401 that reaches the browser ends up here.
|
||||||
|
pub async fn current_session(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> Json<serde_json::Value> {
|
||||||
|
let signed_in = auth::session_username(&state, &headers).await;
|
||||||
|
Json(json!({
|
||||||
|
"authenticated": signed_in.is_some(),
|
||||||
|
"required": true,
|
||||||
|
"username": signed_in,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn register(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(input): Json<CredentialsInput>,
|
||||||
|
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
|
||||||
|
let username = normalize_username(&input.username);
|
||||||
|
if let Some(code) = username_problem(&username).or_else(|| password_problem(&input.password)) {
|
||||||
|
return Err(bad_request(code));
|
||||||
|
}
|
||||||
|
let hash = hash_password(&input.password);
|
||||||
|
let user_id = match state.db.create_account(&username, &hash).await {
|
||||||
|
Ok(user_id) => user_id,
|
||||||
|
Err(error) if is_username_taken(&error) => {
|
||||||
|
return Err(bad_request("username_taken"));
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
tracing::warn!("register failed: {error}");
|
||||||
|
return Err(server_error());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// A new account owns its workspace from the first second; nothing about it
|
||||||
|
// is shared with the person who registered before them.
|
||||||
|
if let Err(error) = state.db.ensure_default_space(&user_id).await {
|
||||||
|
tracing::warn!("workspace creation failed: {error}");
|
||||||
|
return Err(server_error());
|
||||||
|
}
|
||||||
|
tracing::info!("registered account {username}");
|
||||||
|
signed_in_response(&state, &user_id, &username).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn login(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(input): Json<CredentialsInput>,
|
||||||
|
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
|
||||||
|
let username = normalize_username(&input.username);
|
||||||
|
if username.is_empty() || input.password.is_empty() {
|
||||||
|
return Err(bad_request("fields_required"));
|
||||||
|
}
|
||||||
|
if state.auth.guard.locked(&username) {
|
||||||
|
return Err((
|
||||||
|
StatusCode::TOO_MANY_REQUESTS,
|
||||||
|
Json(json!({"message": "嘗試次數太多,請 5 分鐘後再試", "code": "too_many_attempts"})),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let account = state
|
||||||
|
.db
|
||||||
|
.account_by_username(&username)
|
||||||
|
.await
|
||||||
|
.map_err(|error| {
|
||||||
|
tracing::warn!("login lookup failed: {error}");
|
||||||
|
server_error()
|
||||||
|
})?;
|
||||||
|
// Hash even when the name is unknown so that a missing account and a wrong
|
||||||
|
// password cost the same and answer the same way.
|
||||||
|
let stored = account
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|account| account.password_hash.clone());
|
||||||
|
let verified = stored
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|hash| verify_password(&input.password, hash));
|
||||||
|
let Some(account) = account.filter(|_| verified) else {
|
||||||
|
state.auth.guard.record_failure(&username);
|
||||||
|
return Err((
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
Json(json!({"message": "帳號或密碼不正確", "code": "invalid_credentials"})),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
state.auth.guard.clear(&username);
|
||||||
|
let user_id = account.user_id;
|
||||||
|
let username = account.username;
|
||||||
|
if let Err(error) = state.db.ensure_default_space(&user_id).await {
|
||||||
|
tracing::warn!("workspace lookup failed: {error}");
|
||||||
|
return Err(server_error());
|
||||||
|
}
|
||||||
|
signed_in_response(&state, &user_id, &username).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn signed_in_response(
|
||||||
|
state: &AppState,
|
||||||
|
user_id: &str,
|
||||||
|
username: &str,
|
||||||
|
) -> Result<Response, (StatusCode, Json<serde_json::Value>)> {
|
||||||
|
let token = auth::new_session_token();
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.create_session(user_id, &auth::token_digest(&token), SESSION_DAYS * 86_400)
|
||||||
|
.await
|
||||||
|
.map_err(|error| {
|
||||||
|
tracing::warn!("session creation failed: {error}");
|
||||||
|
server_error()
|
||||||
|
})?;
|
||||||
|
let mut response = Json(SignedIn {
|
||||||
|
ok: true,
|
||||||
|
username: username.to_string(),
|
||||||
|
})
|
||||||
|
.into_response();
|
||||||
|
let cookie = format!(
|
||||||
|
"{COOKIE_NAME}={token}; Path=/; HttpOnly; SameSite=Strict; Max-Age={}{}",
|
||||||
|
SESSION_DAYS * 86_400,
|
||||||
|
if state.auth.secure_cookie() {
|
||||||
|
"; Secure"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
);
|
||||||
|
response.headers_mut().insert(
|
||||||
|
header::SET_COOKIE,
|
||||||
|
HeaderValue::from_str(&cookie).map_err(|_| server_error())?,
|
||||||
|
);
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn logout(State(state): State<AppState>, headers: HeaderMap) -> impl IntoResponse {
|
||||||
|
if let Some(token) = auth::session_token(&headers)
|
||||||
|
&& let Err(error) = state.db.delete_session(&auth::token_digest(&token)).await
|
||||||
|
{
|
||||||
|
tracing::warn!("session deletion failed: {error}");
|
||||||
|
}
|
||||||
|
let mut response = Json(json!({"ok": true})).into_response();
|
||||||
|
response.headers_mut().insert(
|
||||||
|
header::SET_COOKIE,
|
||||||
|
HeaderValue::from_static(auth::EXPIRED_COOKIE),
|
||||||
|
);
|
||||||
|
response
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_username_taken(error: &sqlx::Error) -> bool {
|
||||||
|
error
|
||||||
|
.as_database_error()
|
||||||
|
.and_then(|error| error.code().map(|code| code == "23505"))
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bad_request(code: &'static str) -> (StatusCode, Json<serde_json::Value>) {
|
||||||
|
(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
Json(json!({"message": message_for(code), "code": code})),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn server_error() -> (StatusCode, Json<serde_json::Value>) {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(json!({"message": "伺服器錯誤,請稍後再試"})),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn message_for(code: &str) -> &'static str {
|
||||||
|
match code {
|
||||||
|
"username_too_short" => "帳號至少 3 個字元",
|
||||||
|
"username_too_long" => "帳號最多 32 個字元",
|
||||||
|
"username_must_start_with_letter" => "帳號必須以字母或數字開頭結尾",
|
||||||
|
"username_has_invalid_characters" => "帳號只能使用字母、數字與 . _ -",
|
||||||
|
"username_taken" => "這個帳號已經有人用了",
|
||||||
|
"password_too_short" => "密碼至少 8 個字元",
|
||||||
|
"password_too_long" => "密碼太長了",
|
||||||
|
"password_has_invalid_characters" => "密碼含有無法使用的字元",
|
||||||
|
_ => "請輸入帳號與密碼",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod password_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_hash_verifies_its_own_password_and_nothing_else() {
|
||||||
|
let hash = hash_password("correct horse battery staple");
|
||||||
|
assert!(verify_password("correct horse battery staple", &hash));
|
||||||
|
assert!(!verify_password("Correct horse battery staple", &hash));
|
||||||
|
assert!(!verify_password("", &hash));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_same_password_hashes_differently_each_time() {
|
||||||
|
assert_ne!(
|
||||||
|
hash_password("same password"),
|
||||||
|
hash_password("same password")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_foreign_or_malformed_hash_never_opens_the_door() {
|
||||||
|
for stored in [
|
||||||
|
"",
|
||||||
|
"plain-text-password",
|
||||||
|
"bcrypt$10$abc",
|
||||||
|
"pbkdf2-sha256$not-a-number$00$00",
|
||||||
|
"pbkdf2-sha256$0$00$00",
|
||||||
|
"pbkdf2-sha256$999999999$00$00",
|
||||||
|
"pbkdf2-sha256$1000$00",
|
||||||
|
] {
|
||||||
|
assert!(!verify_password("anything", stored), "{stored}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_stored_hash_round_trips_its_rounds_salt_and_key() {
|
||||||
|
let hash = hash_password("round trip password");
|
||||||
|
let (rounds, salt, key) = parse_password(&hash).expect("a hash we wrote must parse");
|
||||||
|
assert_eq!(rounds, PBKDF2_ROUNDS);
|
||||||
|
assert_eq!(salt.len(), SALT_BYTES);
|
||||||
|
assert_eq!(key.len(), KEY_BYTES);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod validation_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn usernames_are_trimmed_and_lowercased() {
|
||||||
|
assert_eq!(normalize_username(" WorkBench "), "workbench");
|
||||||
|
assert_eq!(normalize_username("王小明"), "王小明");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn username_rules_accept_plain_names_and_reject_addresses() {
|
||||||
|
for good in ["ana", "ana.lin", "lazy_boy", "王小明", "a12"] {
|
||||||
|
assert_eq!(username_problem(good), None, "{good}");
|
||||||
|
}
|
||||||
|
for bad in [
|
||||||
|
"an",
|
||||||
|
".ana",
|
||||||
|
"ana.",
|
||||||
|
"ana lin",
|
||||||
|
"ana@example.com",
|
||||||
|
"ana\u{1}",
|
||||||
|
&"x".repeat(MAX_USERNAME_CHARS + 1),
|
||||||
|
] {
|
||||||
|
assert!(username_problem(bad).is_some(), "{bad}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn passwords_need_a_minimum_length() {
|
||||||
|
assert!(password_problem("1234567").is_some());
|
||||||
|
assert_eq!(password_problem("12345678"), None);
|
||||||
|
assert!(password_problem(&"x".repeat(MAX_PASSWORD_CHARS + 1)).is_some());
|
||||||
|
assert!(password_problem("pass\nword").is_some());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod guard_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_locked_username_stays_locked_until_it_is_cleared() {
|
||||||
|
let guard = LoginGuard::default();
|
||||||
|
for _ in 0..MAX_FAILED_LOGINS - 1 {
|
||||||
|
guard.record_failure("ana");
|
||||||
|
}
|
||||||
|
assert!(!guard.locked("ana"), "one attempt is still left");
|
||||||
|
guard.record_failure("ana");
|
||||||
|
assert!(guard.locked("ana"));
|
||||||
|
assert!(
|
||||||
|
!guard.locked("bob"),
|
||||||
|
"a cool-down never spills to another account"
|
||||||
|
);
|
||||||
|
guard.clear("ana");
|
||||||
|
assert!(!guard.locked("ana"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -530,7 +530,10 @@ mod tests {
|
||||||
fn reply_blocks_are_not_counted_as_files() {
|
fn reply_blocks_are_not_counted_as_files() {
|
||||||
let reply = json!({"kind":"reply","name":"阿狗","body":"在這等你"});
|
let reply = json!({"kind":"reply","name":"阿狗","body":"在這等你"});
|
||||||
let notes = extra_prompt_notes(&[reply], 0);
|
let notes = extra_prompt_notes(&[reply], 0);
|
||||||
assert_eq!(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());
|
assert!(extra_prompt_notes(&[json!({"kind":"login","site":"x"})], 0).is_empty());
|
||||||
let mixed = extra_prompt_notes(&[json!({"kind":"reply","name":"小美","body":"hi"})], 2);
|
let mixed = extra_prompt_notes(&[json!({"kind":"reply","name":"小美","body":"hi"})], 2);
|
||||||
assert!(mixed[0].contains("小美"));
|
assert!(mixed[0].contains("小美"));
|
||||||
|
|
|
||||||
|
|
@ -1,114 +1,52 @@
|
||||||
|
//! Session plumbing: the login cookie, who it belongs to, and the browser
|
||||||
|
//! boundary that keeps other sites out.
|
||||||
|
//!
|
||||||
|
//! The cookie holds a random value; the database holds only its digest, so a
|
||||||
|
//! stolen backup of `app_sessions` cannot be replayed as a login. Resolving a
|
||||||
|
//! cookie yields one [`Actor`] — one person and one workspace — which every
|
||||||
|
//! handler then uses as its only lens on the data.
|
||||||
|
|
||||||
|
use axum::Json;
|
||||||
use axum::extract::{Request, State};
|
use axum::extract::{Request, State};
|
||||||
use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
|
use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
|
||||||
use axum::middleware::Next;
|
use axum::middleware::Next;
|
||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
use axum::{Json, Router};
|
use rand::RngCore;
|
||||||
use serde::Deserialize;
|
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use std::{
|
|
||||||
collections::HashMap,
|
|
||||||
sync::{Arc, Mutex},
|
|
||||||
time::{Duration, Instant},
|
|
||||||
};
|
|
||||||
|
|
||||||
|
use crate::accounts::LoginGuard;
|
||||||
|
use crate::db::Actor;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
const COOKIE_NAME: &str = "lazyboy_session";
|
pub(crate) const COOKIE_NAME: &str = "lazyboy_session";
|
||||||
|
/// Clears the login cookie on sign-out.
|
||||||
|
pub(crate) const EXPIRED_COOKIE: &str =
|
||||||
|
"lazyboy_session=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0";
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct AuthConfig {
|
pub struct AuthConfig {
|
||||||
token: Option<String>,
|
|
||||||
sessions: Arc<Mutex<HashMap<String, Instant>>>,
|
|
||||||
secure_cookie: bool,
|
secure_cookie: bool,
|
||||||
|
pub guard: LoginGuard,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AuthConfig {
|
impl AuthConfig {
|
||||||
pub fn from_env() -> Self {
|
pub fn from_env() -> Self {
|
||||||
let token = std::env::var("LAZYBOY_APP_TOKEN")
|
|
||||||
.ok()
|
|
||||||
.filter(|value| !value.trim().is_empty());
|
|
||||||
let secure_cookie = std::env::var("LAZYBOY_SECURE_COOKIE")
|
|
||||||
.map(|value| matches!(value.as_str(), "1" | "true" | "yes"))
|
|
||||||
.unwrap_or(false);
|
|
||||||
Self {
|
Self {
|
||||||
token,
|
secure_cookie: std::env::var("LAZYBOY_SECURE_COOKIE")
|
||||||
sessions: Arc::new(Mutex::new(HashMap::new())),
|
.map(|value| matches!(value.as_str(), "1" | "true" | "yes"))
|
||||||
secure_cookie,
|
.unwrap_or(false),
|
||||||
|
guard: LoginGuard::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn enabled(&self) -> bool {
|
/// Set `Secure` on the cookie. Needed when the app is served over HTTPS.
|
||||||
self.token.is_some()
|
pub fn secure_cookie(&self) -> bool {
|
||||||
}
|
self.secure_cookie
|
||||||
|
|
||||||
pub fn strong_enough_for_network(&self) -> bool {
|
|
||||||
self.token
|
|
||||||
.as_ref()
|
|
||||||
.map(|token| token.len() >= 32 && token != "dev-token")
|
|
||||||
.unwrap_or(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn valid_token(&self, supplied: &str) -> bool {
|
|
||||||
self.token
|
|
||||||
.as_ref()
|
|
||||||
.map(|expected| constant_time_eq(expected.as_bytes(), supplied.as_bytes()))
|
|
||||||
.unwrap_or(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn valid_session(&self, headers: &HeaderMap) -> bool {
|
|
||||||
if !self.enabled() {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
let Some(value) = cookie_value(headers, COOKIE_NAME) else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
let key = hex::encode(Sha256::digest(value.as_bytes()));
|
|
||||||
self.sessions
|
|
||||||
.lock()
|
|
||||||
.unwrap()
|
|
||||||
.get(&key)
|
|
||||||
.is_some_and(|expires| *expires > Instant::now())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn session_cookie(&self) -> Option<String> {
|
|
||||||
if !self.enabled() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let value = format!(
|
|
||||||
"{}{}",
|
|
||||||
uuid::Uuid::new_v4().simple(),
|
|
||||||
uuid::Uuid::new_v4().simple()
|
|
||||||
);
|
|
||||||
let key = hex::encode(Sha256::digest(value.as_bytes()));
|
|
||||||
let mut sessions = self.sessions.lock().unwrap();
|
|
||||||
sessions.retain(|_, expires| *expires > Instant::now());
|
|
||||||
if sessions.len() >= 4096
|
|
||||||
&& let Some(oldest) = sessions
|
|
||||||
.iter()
|
|
||||||
.min_by_key(|(_, t)| **t)
|
|
||||||
.map(|(k, _)| k.clone())
|
|
||||||
{
|
|
||||||
sessions.remove(&oldest);
|
|
||||||
}
|
|
||||||
sessions.insert(key, Instant::now() + Duration::from_secs(604800));
|
|
||||||
Some(format!(
|
|
||||||
"{COOKIE_NAME}={value}; Path=/; HttpOnly; SameSite=Strict; Max-Age=604800{}",
|
|
||||||
if self.secure_cookie { "; Secure" } else { "" }
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn revoke_session(&self, headers: &HeaderMap) {
|
|
||||||
if let Some(value) = cookie_value(headers, COOKIE_NAME) {
|
|
||||||
self.sessions
|
|
||||||
.lock()
|
|
||||||
.unwrap()
|
|
||||||
.remove(&hex::encode(Sha256::digest(value.as_bytes())));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn constant_time_eq(left: &[u8], right: &[u8]) -> bool {
|
pub(crate) fn constant_time_eq(left: &[u8], right: &[u8]) -> bool {
|
||||||
if left.len() != right.len() {
|
if left.len() != right.len() {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -119,7 +57,7 @@ fn constant_time_eq(left: &[u8], right: &[u8]) -> bool {
|
||||||
difference == 0
|
difference == 0
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cookie_value<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
|
pub(crate) fn cookie_value<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
|
||||||
headers
|
headers
|
||||||
.get(header::COOKIE)?
|
.get(header::COOKIE)?
|
||||||
.to_str()
|
.to_str()
|
||||||
|
|
@ -129,96 +67,80 @@ fn cookie_value<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
|
||||||
.find_map(|(key, value)| (key == name).then_some(value))
|
.find_map(|(key, value)| (key == name).then_some(value))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
pub(crate) fn session_token(headers: &HeaderMap) -> Option<String> {
|
||||||
struct LoginInput {
|
let token = cookie_value(headers, COOKIE_NAME)?;
|
||||||
token: String,
|
(!token.is_empty()).then(|| token.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn public_router(state: AppState) -> Router {
|
/// 32 random bytes, hex encoded. Long enough that the cookie itself is a
|
||||||
Router::new()
|
/// 256-bit secret even though only its digest is stored.
|
||||||
.route(
|
pub(crate) fn new_session_token() -> String {
|
||||||
"/api/session",
|
let mut bytes = [0u8; 32];
|
||||||
axum::routing::get(session).post(login).delete(logout),
|
rand::thread_rng().fill_bytes(&mut bytes);
|
||||||
)
|
hex::encode(bytes)
|
||||||
.with_state(state)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn session(State(state): State<AppState>, headers: HeaderMap) -> Json<serde_json::Value> {
|
pub(crate) fn token_digest(token: &str) -> String {
|
||||||
Json(json!({
|
hex::encode(Sha256::digest(token.as_bytes()))
|
||||||
"authenticated": state.auth.valid_session(&headers),
|
|
||||||
"required": state.auth.enabled()
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn login(
|
/// Who this request belongs to, or `None` when the cookie is missing, expired,
|
||||||
|
/// or no longer backed by an account.
|
||||||
|
pub(crate) async fn session_actor(state: &AppState, headers: &HeaderMap) -> Option<Actor> {
|
||||||
|
let token = session_token(headers)?;
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.session_actor(&token_digest(&token))
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The name behind a valid cookie, so the browser can show who is signed in.
|
||||||
|
pub(crate) async fn session_username(state: &AppState, headers: &HeaderMap) -> Option<String> {
|
||||||
|
let token = session_token(headers)?;
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.session_username(&token_digest(&token))
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn unauthorized() -> Response {
|
||||||
|
(
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
Json(json!({"message": "請先登入", "code": "unauthenticated"})),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every private route runs behind this: resolve the cookie, publish the actor
|
||||||
|
/// to the handler, and answer 401 when there is nobody to resolve.
|
||||||
|
pub async fn require_session(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Json(input): Json<LoginInput>,
|
mut request: Request,
|
||||||
) -> Result<Response, (StatusCode, Json<serde_json::Value>)> {
|
next: Next,
|
||||||
if !state.auth.valid_token(&input.token) {
|
) -> Response {
|
||||||
return Err((
|
match session_actor(&state, request.headers()).await {
|
||||||
StatusCode::UNAUTHORIZED,
|
Some(actor) => {
|
||||||
Json(json!({"message": "存取 token 不正確"})),
|
request.extensions_mut().insert(actor);
|
||||||
));
|
next.run(request).await
|
||||||
}
|
}
|
||||||
let mut response = Json(json!({"ok": true})).into_response();
|
None => unauthorized().await,
|
||||||
if let Some(cookie) = state.auth.session_cookie() {
|
|
||||||
response.headers_mut().insert(
|
|
||||||
header::SET_COOKIE,
|
|
||||||
HeaderValue::from_str(&cookie).map_err(|_| {
|
|
||||||
(
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
Json(json!({"message": "無法建立 session"})),
|
|
||||||
)
|
|
||||||
})?,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Ok(response)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn logout(State(state): State<AppState>, headers: HeaderMap) -> Response {
|
|
||||||
state.auth.revoke_session(&headers);
|
|
||||||
let mut response = Json(json!({"ok": true})).into_response();
|
|
||||||
response.headers_mut().insert(
|
|
||||||
header::SET_COOKIE,
|
|
||||||
HeaderValue::from_static("lazyboy_session=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0"),
|
|
||||||
);
|
|
||||||
response
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn require_auth(State(state): State<AppState>, request: Request, next: Next) -> Response {
|
|
||||||
if state.auth.valid_session(request.headers()) {
|
|
||||||
next.run(request).await
|
|
||||||
} else {
|
|
||||||
(
|
|
||||||
StatusCode::UNAUTHORIZED,
|
|
||||||
Json(json!({"message": "請先登入"})),
|
|
||||||
)
|
|
||||||
.into_response()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::constant_time_eq;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn token_comparison_requires_exact_value() {
|
|
||||||
assert!(constant_time_eq(b"correct", b"correct"));
|
|
||||||
assert!(!constant_time_eq(b"correct", b"wrong"));
|
|
||||||
assert!(!constant_time_eq(b"correct", b"correct-longer"));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// SameSite cookies do not replace Origin checks (including WebSockets).
|
/// SameSite cookies do not replace Origin checks (including WebSockets).
|
||||||
pub async fn browser_boundary(
|
pub async fn browser_boundary(
|
||||||
State(state): State<AppState>,
|
State(_state): State<AppState>,
|
||||||
request: Request,
|
request: Request,
|
||||||
next: Next,
|
next: Next,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
if !allowed_browser_request(request.headers(), state.auth.enabled()) {
|
if let Err(rejection) = check_browser_request(request.headers()) {
|
||||||
return (
|
return (
|
||||||
StatusCode::FORBIDDEN,
|
StatusCode::FORBIDDEN,
|
||||||
Json(json!({"message":"cross-origin request rejected"})),
|
Json(json!({"message": rejection.message()})),
|
||||||
)
|
)
|
||||||
.into_response();
|
.into_response();
|
||||||
}
|
}
|
||||||
|
|
@ -244,90 +166,214 @@ pub async fn browser_boundary(
|
||||||
response
|
response
|
||||||
}
|
}
|
||||||
|
|
||||||
fn allowed_browser_request(headers: &HeaderMap, authenticated_mode: bool) -> bool {
|
/// Why a browser request was turned away. The two cases need different fixes:
|
||||||
|
/// an unlisted host is an operator setting (`LAZYBOY_ALLOWED_HOSTS`), a
|
||||||
|
/// mismatched origin is another site trying to reach in.
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
enum BrowserRejection {
|
||||||
|
InvalidHost,
|
||||||
|
CrossOrigin,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BrowserRejection {
|
||||||
|
fn message(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::InvalidHost => "invalid host",
|
||||||
|
Self::CrossOrigin => "cross-origin request rejected",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_browser_request(headers: &HeaderMap) -> Result<(), BrowserRejection> {
|
||||||
if headers.get("sec-fetch-site").and_then(|v| v.to_str().ok()) == Some("cross-site") {
|
if headers.get("sec-fetch-site").and_then(|v| v.to_str().ok()) == Some("cross-site") {
|
||||||
return false;
|
return Err(BrowserRejection::CrossOrigin);
|
||||||
}
|
}
|
||||||
let Some(host) = headers.get(header::HOST).and_then(|v| v.to_str().ok()) else {
|
let Some(host) = headers.get(header::HOST).and_then(|v| v.to_str().ok()) else {
|
||||||
return false;
|
return Err(BrowserRejection::InvalidHost);
|
||||||
};
|
};
|
||||||
let Ok(destination) = reqwest::Url::parse(&format!("http://{host}")) else {
|
if !served_at_an_allowed_host(host, &listed_hosts()) {
|
||||||
return false;
|
return Err(BrowserRejection::InvalidHost);
|
||||||
};
|
|
||||||
if !authenticated_mode
|
|
||||||
&& !matches!(
|
|
||||||
destination.host_str(),
|
|
||||||
Some("localhost" | "127.0.0.1" | "[::1]")
|
|
||||||
)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
if let Some(origin) = headers.get(header::ORIGIN) {
|
if let Some(origin) = headers.get(header::ORIGIN) {
|
||||||
let Ok(origin) = origin
|
let Some(origin) = origin
|
||||||
.to_str()
|
.to_str()
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|s| reqwest::Url::parse(s).ok())
|
.and_then(|s| reqwest::Url::parse(s).ok())
|
||||||
.ok_or(())
|
|
||||||
else {
|
else {
|
||||||
return false;
|
return Err(BrowserRejection::CrossOrigin);
|
||||||
};
|
};
|
||||||
let Ok(expected) = reqwest::Url::parse(&format!("{}://{host}", origin.scheme())) else {
|
let Ok(expected) = reqwest::Url::parse(&format!("{}://{host}", origin.scheme())) else {
|
||||||
return false;
|
return Err(BrowserRejection::CrossOrigin);
|
||||||
};
|
};
|
||||||
if !matches!(origin.scheme(), "http" | "https") || origin.origin() != expected.origin() {
|
if !matches!(origin.scheme(), "http" | "https") || origin.origin() != expected.origin() {
|
||||||
return false;
|
return Err(BrowserRejection::CrossOrigin);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
true
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
fn allowed_browser_request(headers: &HeaderMap) -> bool {
|
||||||
|
check_browser_request(headers).is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn listed_hosts_from(value: &str) -> Vec<String> {
|
||||||
|
value
|
||||||
|
.split(',')
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|host| !host.is_empty())
|
||||||
|
.map(|host| host.to_ascii_lowercase())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which hostnames this install may be addressed by.
|
||||||
|
///
|
||||||
|
/// Loopback and plain IP addresses are always fine — that is how a self-hosted
|
||||||
|
/// LazyBoy is normally opened, from this machine or from another one on the
|
||||||
|
/// same network. A *domain name* is only believed when the operator listed it,
|
||||||
|
/// because a domain an attacker controls can be pointed at this machine (DNS
|
||||||
|
/// rebinding) and would otherwise read a signed-in session.
|
||||||
|
fn served_at_an_allowed_host(host: &str, listed: &[String]) -> bool {
|
||||||
|
let Ok(destination) = reqwest::Url::parse(&format!("http://{host}")) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let Some(hostname) = destination.host_str() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
// `Url` keeps the brackets around an IPv6 host; `IpAddr` only reads them off.
|
||||||
|
let address = hostname.trim_matches(|ch| ch == '[' || ch == ']');
|
||||||
|
if hostname == "localhost" || address.parse::<std::net::IpAddr>().is_ok() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
listed
|
||||||
|
.iter()
|
||||||
|
.any(|allowed| allowed.eq_ignore_ascii_case(hostname))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `LAZYBOY_ALLOWED_HOSTS=lazyboy.local, box.example`
|
||||||
|
fn listed_hosts() -> Vec<String> {
|
||||||
|
std::env::var("LAZYBOY_ALLOWED_HOSTS")
|
||||||
|
.ok()
|
||||||
|
.map(|hosts| listed_hosts_from(&hosts))
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::constant_time_eq;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn token_comparison_requires_exact_value() {
|
||||||
|
assert!(constant_time_eq(b"correct", b"correct"));
|
||||||
|
assert!(!constant_time_eq(b"correct", b"wrong"));
|
||||||
|
assert!(!constant_time_eq(b"correct", b"correct-longer"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod session_cookie_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_tokens_are_unique_and_only_the_digest_is_stored() {
|
||||||
|
let first = new_session_token();
|
||||||
|
let second = new_session_token();
|
||||||
|
assert_ne!(first, second);
|
||||||
|
assert_eq!(first.len(), 64);
|
||||||
|
assert_ne!(token_digest(&first), first);
|
||||||
|
assert_eq!(token_digest(&first), token_digest(&first));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_login_cookie_is_read_by_name_only() {
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(
|
||||||
|
header::COOKIE,
|
||||||
|
format!("other=1; {COOKIE_NAME}=abc123; third=2")
|
||||||
|
.parse()
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
assert_eq!(session_token(&headers).as_deref(), Some("abc123"));
|
||||||
|
headers.insert(header::COOKIE, "other=1".parse().unwrap());
|
||||||
|
assert_eq!(session_token(&headers), None);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod origin_tests {
|
mod origin_tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn blocks_cross_origin_and_dns_rebinding() {
|
fn blocks_cross_origin_requests() {
|
||||||
let mut headers = HeaderMap::new();
|
let mut headers = HeaderMap::new();
|
||||||
headers.insert(header::HOST, "localhost:3101".parse().unwrap());
|
headers.insert(header::HOST, "localhost:3101".parse().unwrap());
|
||||||
assert!(allowed_browser_request(&headers, false));
|
assert!(allowed_browser_request(&headers));
|
||||||
headers.insert(header::ORIGIN, "https://evil.example".parse().unwrap());
|
headers.insert(header::ORIGIN, "https://evil.example".parse().unwrap());
|
||||||
assert!(!allowed_browser_request(&headers, true));
|
assert!(!allowed_browser_request(&headers));
|
||||||
headers.insert(header::ORIGIN, "http://localhost:3101".parse().unwrap());
|
headers.insert(header::ORIGIN, "http://localhost:3101".parse().unwrap());
|
||||||
assert!(allowed_browser_request(&headers, true));
|
assert!(allowed_browser_request(&headers));
|
||||||
headers.remove(header::ORIGIN);
|
headers.insert("sec-fetch-site", "cross-site".parse().unwrap());
|
||||||
|
assert!(!allowed_browser_request(&headers));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unlisted_host_and_foreign_origin_are_told_apart() {
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
headers.insert(header::HOST, "rebinding.example".parse().unwrap());
|
headers.insert(header::HOST, "rebinding.example".parse().unwrap());
|
||||||
assert!(!allowed_browser_request(&headers, false));
|
assert_eq!(
|
||||||
|
check_browser_request(&headers),
|
||||||
|
Err(BrowserRejection::InvalidHost)
|
||||||
|
);
|
||||||
|
headers.insert(header::HOST, "localhost:3101".parse().unwrap());
|
||||||
|
headers.insert(header::ORIGIN, "https://evil.example".parse().unwrap());
|
||||||
|
assert_eq!(
|
||||||
|
check_browser_request(&headers),
|
||||||
|
Err(BrowserRejection::CrossOrigin)
|
||||||
|
);
|
||||||
|
assert_eq!(BrowserRejection::InvalidHost.message(), "invalid host");
|
||||||
|
assert_eq!(
|
||||||
|
BrowserRejection::CrossOrigin.message(),
|
||||||
|
"cross-origin request rejected"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unlisted_domain_cannot_rebind_to_this_machine() {
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(header::HOST, "rebinding.example".parse().unwrap());
|
||||||
|
assert!(!allowed_browser_request(&headers));
|
||||||
|
// A matching Origin is not enough either: the host itself must be known.
|
||||||
|
headers.insert(header::ORIGIN, "http://rebinding.example".parse().unwrap());
|
||||||
|
assert!(!allowed_browser_request(&headers));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn loopback_and_ip_hosts_are_served_from_any_machine_on_the_network() {
|
||||||
|
for host in [
|
||||||
|
"127.0.0.1:3101",
|
||||||
|
"[::1]:3101",
|
||||||
|
"192.168.1.20:3101",
|
||||||
|
"localhost:3101",
|
||||||
|
] {
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(header::HOST, host.parse().unwrap());
|
||||||
|
assert!(allowed_browser_request(&headers), "{host}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod session_tests {
|
mod allowed_host_tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn sessions_are_distinct_expire_and_are_revoked_on_logout() {
|
fn a_domain_the_operator_listed_is_served() {
|
||||||
let config = AuthConfig {
|
let listed = listed_hosts_from("lazyboy.local, box.example");
|
||||||
token: Some("secret".into()),
|
assert!(served_at_an_allowed_host("box.example:3101", &listed));
|
||||||
sessions: Arc::new(Mutex::new(HashMap::new())),
|
assert!(served_at_an_allowed_host("BOX.example", &listed));
|
||||||
secure_cookie: true,
|
assert!(!served_at_an_allowed_host("other.example", &listed));
|
||||||
};
|
assert!(served_at_an_allowed_host("127.0.0.1:3101", &[]));
|
||||||
let first = config.session_cookie().unwrap();
|
assert!(served_at_an_allowed_host("[::1]:3101", &[]));
|
||||||
let second = config.session_cookie().unwrap();
|
assert!(!served_at_an_allowed_host("not a host", &listed));
|
||||||
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,8 +1,14 @@
|
||||||
|
use axum::Json;
|
||||||
|
use axum::extract::FromRequestParts;
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::http::request::Parts;
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use lazyboy_contracts::{
|
use lazyboy_contracts::{
|
||||||
Bot, BrowserProfileMode, ComputerMode, ComputerState, ControlHolder, RunStatus, SandboxKind,
|
Bot, BrowserProfileMode, ComputerMode, ComputerState, ControlHolder, RunStatus, SandboxKind,
|
||||||
computer_home_key, computer_scope_key,
|
computer_home_key, computer_scope_key,
|
||||||
};
|
};
|
||||||
|
use serde_json::json;
|
||||||
use sqlx::{FromRow, PgPool};
|
use sqlx::{FromRow, PgPool};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
|
@ -11,12 +17,41 @@ pub struct Db {
|
||||||
pub pool: PgPool,
|
pub pool: PgPool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Who a request is acting as: one person and the workspace they own. Every
|
||||||
|
/// query for an agent, chat, run, or desktop is filtered by both, so two
|
||||||
|
/// people registered on the same server can never see — or collide with —
|
||||||
|
/// each other's work.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Actor {
|
pub struct Actor {
|
||||||
pub user_id: String,
|
pub user_id: String,
|
||||||
pub space_id: String,
|
pub space_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Handlers ask for `actor: Actor` and axum fills it from the session the auth
|
||||||
|
/// middleware resolved, so no handler can accidentally run as somebody else.
|
||||||
|
impl<S: Sync> FromRequestParts<S> for Actor {
|
||||||
|
type Rejection = Response;
|
||||||
|
|
||||||
|
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
|
||||||
|
parts.extensions.get::<Actor>().cloned().ok_or_else(|| {
|
||||||
|
(
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
Json(json!({"message": "請先登入", "code": "unauthenticated"})),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A registered person. `password_hash` is `None` for a legacy account that
|
||||||
|
/// predates logins and still has to be claimed.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Account {
|
||||||
|
pub user_id: String,
|
||||||
|
pub username: String,
|
||||||
|
pub password_hash: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, FromRow)]
|
#[derive(Debug, Clone, FromRow)]
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub struct ComputerRow {
|
pub struct ComputerRow {
|
||||||
|
|
@ -103,28 +138,198 @@ pub struct SpaceRow {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Db {
|
impl Db {
|
||||||
pub async fn ensure_local_actor(&self) -> Result<Actor, sqlx::Error> {
|
/// Register a person.
|
||||||
let user_id = "local-user";
|
///
|
||||||
let space_id = "local-space";
|
/// The first account on an upgraded install claims the workspace that
|
||||||
sqlx::query("INSERT INTO users (id, name) VALUES ($1, $2) ON CONFLICT (id) DO NOTHING")
|
/// already exists (it belonged to the shared-token install), so nobody
|
||||||
.bind(user_id)
|
/// loses their agents, chats, or desktops; from the second account on, each
|
||||||
.bind("Local")
|
/// person starts in a workspace of their own.
|
||||||
.execute(&self.pool)
|
pub async fn create_account(
|
||||||
.await?;
|
&self,
|
||||||
|
username: &str,
|
||||||
|
password_hash: &str,
|
||||||
|
) -> Result<String, sqlx::Error> {
|
||||||
|
let mut tx = self.pool.begin().await?;
|
||||||
|
let claimable: Option<(String,)> = sqlx::query_as(
|
||||||
|
"SELECT id FROM users
|
||||||
|
WHERE password_hash IS NULL
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM users WHERE password_hash IS NOT NULL)
|
||||||
|
ORDER BY created_at, id
|
||||||
|
LIMIT 1
|
||||||
|
FOR UPDATE",
|
||||||
|
)
|
||||||
|
.fetch_optional(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
let user_id = match claimable {
|
||||||
|
Some((user_id,)) => {
|
||||||
|
// Guarded so two simultaneous first registrations cannot both
|
||||||
|
// take the same legacy row: the loser finds no row to change.
|
||||||
|
let updated = sqlx::query(
|
||||||
|
"UPDATE users
|
||||||
|
SET username = $2, password_hash = $3
|
||||||
|
WHERE id = $1
|
||||||
|
AND password_hash IS NULL
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM users WHERE password_hash IS NOT NULL)",
|
||||||
|
)
|
||||||
|
.bind(&user_id)
|
||||||
|
.bind(username)
|
||||||
|
.bind(password_hash)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
if updated.rows_affected() != 1 {
|
||||||
|
return Err(sqlx::Error::RowNotFound);
|
||||||
|
}
|
||||||
|
user_id
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
let user_id = Uuid::new_v4().simple().to_string();
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO users (id, name, username, password_hash) VALUES ($1, $2, $2, $3)",
|
||||||
|
)
|
||||||
|
.bind(&user_id)
|
||||||
|
.bind(username)
|
||||||
|
.bind(password_hash)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
user_id
|
||||||
|
}
|
||||||
|
};
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(user_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn account_by_username(
|
||||||
|
&self,
|
||||||
|
username: &str,
|
||||||
|
) -> Result<Option<Account>, sqlx::Error> {
|
||||||
|
let found: Option<(String, String, Option<String>)> = sqlx::query_as(
|
||||||
|
"SELECT id, username, password_hash FROM users WHERE lower(username) = $1",
|
||||||
|
)
|
||||||
|
.bind(username)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(found.map(|(user_id, username, password_hash)| Account {
|
||||||
|
user_id,
|
||||||
|
username,
|
||||||
|
password_hash,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The workspace a person owns. An account without one is treated as
|
||||||
|
/// signed out rather than handed somebody else's.
|
||||||
|
pub async fn default_space(&self, user_id: &str) -> Result<Option<String>, sqlx::Error> {
|
||||||
|
let space: Option<(String,)> = sqlx::query_as(
|
||||||
|
"SELECT id FROM spaces WHERE user_id = $1 ORDER BY is_default DESC, created_at LIMIT 1",
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(space.map(|(id,)| id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create the workspace on first use, then return it. Provider and model
|
||||||
|
/// defaults are empty of credentials on purpose: a key belongs in the
|
||||||
|
/// workspace settings, and without one nothing runs.
|
||||||
|
pub async fn ensure_default_space(&self, user_id: &str) -> Result<String, sqlx::Error> {
|
||||||
|
if let Some(space) = self.default_space(user_id).await? {
|
||||||
|
return Ok(space);
|
||||||
|
}
|
||||||
|
let space_id = format!("space-{user_id}");
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO spaces (id, user_id, name, is_default, default_model_provider, default_model_id)
|
"INSERT INTO spaces (id, user_id, name, is_default, default_model_provider, default_model_id)
|
||||||
VALUES ($1, $2, $3, TRUE, 'xai', 'grok-4.6')
|
VALUES ($1, $2, $3, TRUE, 'xai', 'grok-4.6')
|
||||||
ON CONFLICT (id) DO NOTHING",
|
ON CONFLICT (id) DO NOTHING",
|
||||||
)
|
)
|
||||||
.bind(space_id)
|
.bind(&space_id)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
.bind("Home")
|
.bind("My workspace")
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(Actor {
|
Ok(self.default_space(user_id).await?.unwrap_or(space_id))
|
||||||
user_id: user_id.into(),
|
}
|
||||||
space_id: space_id.into(),
|
|
||||||
})
|
/// Every account that can sign in, for background work that must run per
|
||||||
|
/// person instead of once for the whole server.
|
||||||
|
pub async fn actors(&self) -> Result<Vec<Actor>, sqlx::Error> {
|
||||||
|
let rows: Vec<(String, Option<String>)> = sqlx::query_as(
|
||||||
|
"SELECT u.id,
|
||||||
|
(SELECT s.id FROM spaces s
|
||||||
|
WHERE s.user_id = u.id
|
||||||
|
ORDER BY s.is_default DESC, s.created_at LIMIT 1) AS space_id
|
||||||
|
FROM users u
|
||||||
|
WHERE u.password_hash IS NOT NULL",
|
||||||
|
)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
let mut actors = Vec::new();
|
||||||
|
for (user_id, space_id) in rows {
|
||||||
|
// An account with no workspace cannot act on anything, so it is
|
||||||
|
// skipped rather than given a guessed space.
|
||||||
|
if let Some(space_id) = space_id {
|
||||||
|
actors.push(Actor { user_id, space_id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(actors)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_session(
|
||||||
|
&self,
|
||||||
|
user_id: &str,
|
||||||
|
token_hash: &str,
|
||||||
|
ttl_seconds: i64,
|
||||||
|
) -> Result<(), sqlx::Error> {
|
||||||
|
let mut tx = self.pool.begin().await?;
|
||||||
|
sqlx::query("DELETE FROM app_sessions WHERE expires_at <= now()")
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO app_sessions (token_hash, user_id, expires_at)
|
||||||
|
VALUES ($1, $2, now() + make_interval(secs => $3::double precision))",
|
||||||
|
)
|
||||||
|
.bind(token_hash)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(ttl_seconds as f64)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
tx.commit().await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn session_actor(&self, token_hash: &str) -> Result<Option<Actor>, sqlx::Error> {
|
||||||
|
let row: Option<(String, Option<String>)> = sqlx::query_as(
|
||||||
|
"SELECT s.user_id,
|
||||||
|
(SELECT x.id FROM spaces x
|
||||||
|
WHERE x.user_id = s.user_id
|
||||||
|
ORDER BY x.is_default DESC, x.created_at LIMIT 1) AS space_id
|
||||||
|
FROM app_sessions s
|
||||||
|
WHERE s.token_hash = $1 AND s.expires_at > now()",
|
||||||
|
)
|
||||||
|
.bind(token_hash)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
let Some((user_id, Some(space_id))) = row else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
Ok(Some(Actor { user_id, space_id }))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn session_username(&self, token_hash: &str) -> Result<Option<String>, sqlx::Error> {
|
||||||
|
let row: Option<(Option<String>,)> = sqlx::query_as(
|
||||||
|
"SELECT u.username FROM users u
|
||||||
|
JOIN app_sessions s ON s.user_id = u.id
|
||||||
|
WHERE s.token_hash = $1 AND s.expires_at > now()",
|
||||||
|
)
|
||||||
|
.bind(token_hash)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row.and_then(|(username,)| username))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_session(&self, token_hash: &str) -> Result<(), sqlx::Error> {
|
||||||
|
sqlx::query("DELETE FROM app_sessions WHERE token_hash = $1")
|
||||||
|
.bind(token_hash)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_space(&self, actor: &Actor) -> Result<Option<SpaceRow>, sqlx::Error> {
|
pub async fn get_space(&self, actor: &Actor) -> Result<Option<SpaceRow>, sqlx::Error> {
|
||||||
|
|
@ -505,3 +710,74 @@ pub fn parse_run_status(status: &str) -> Option<RunStatus> {
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{Actor, Db};
|
||||||
|
|
||||||
|
/// One install, many people: an account may only reach the workspace it owns.
|
||||||
|
/// This replaced a shared token that opened everything, so scoping is the
|
||||||
|
/// whole security model here and it is worth a database test.
|
||||||
|
#[sqlx::test(migrations = "../../migrations")]
|
||||||
|
async fn each_account_only_reaches_its_own_workspace(pool: sqlx::PgPool) {
|
||||||
|
// An install from before accounts existed: one row that cannot sign in,
|
||||||
|
// owning every agent.
|
||||||
|
sqlx::query("INSERT INTO users (id, name) VALUES ('local-user', 'Local')")
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let db = Db { pool: pool.clone() };
|
||||||
|
|
||||||
|
let alice = db.create_account("alice", "hash-a").await.unwrap();
|
||||||
|
assert_eq!(alice, "local-user", "the first account adopts the old data");
|
||||||
|
let alice_space = db.ensure_default_space(&alice).await.unwrap();
|
||||||
|
|
||||||
|
let bob = db.create_account("bob", "hash-b").await.unwrap();
|
||||||
|
let bob_space = db.ensure_default_space(&bob).await.unwrap();
|
||||||
|
assert_ne!(alice_space, bob_space, "nobody shares a workspace");
|
||||||
|
|
||||||
|
// A username is an address: one spelling, so `ALICE` is not a second door.
|
||||||
|
assert!(
|
||||||
|
db.create_account("ALICE", "hash-c").await.is_err(),
|
||||||
|
"a differently cased name must not open the same account"
|
||||||
|
);
|
||||||
|
|
||||||
|
sqlx::query("INSERT INTO bots (id, space_id, user_id, name) VALUES ('bot-a', $1, $2, 'A')")
|
||||||
|
.bind(&alice_space)
|
||||||
|
.bind(&alice)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let alice_actor = Actor {
|
||||||
|
user_id: alice.clone(),
|
||||||
|
space_id: alice_space,
|
||||||
|
};
|
||||||
|
let bob_actor = Actor {
|
||||||
|
user_id: bob.clone(),
|
||||||
|
space_id: bob_space,
|
||||||
|
};
|
||||||
|
assert!(db.get_bot(&alice_actor, "bot-a").await.unwrap().is_some());
|
||||||
|
assert!(
|
||||||
|
db.get_bot(&bob_actor, "bot-a").await.unwrap().is_none(),
|
||||||
|
"another account's agent is not even visible by id"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Background work iterates people, so each actor must carry only their
|
||||||
|
// own space.
|
||||||
|
let mut actors = db.actors().await.unwrap();
|
||||||
|
let scoped: Vec<(String, String)> = actors
|
||||||
|
.drain(..)
|
||||||
|
.map(|actor| (actor.user_id, actor.space_id))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
scoped.len(),
|
||||||
|
2,
|
||||||
|
"the account that still cannot sign in is left out"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
scoped.contains(&(alice, alice_actor.space_id)),
|
||||||
|
"each account is handed exactly its own workspace"
|
||||||
|
);
|
||||||
|
assert!(scoped.contains(&(bob, bob_actor.space_id)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
mod accounts;
|
||||||
mod attachments;
|
mod attachments;
|
||||||
mod auth;
|
mod auth;
|
||||||
mod computer;
|
mod computer;
|
||||||
|
|
@ -40,21 +41,21 @@ async fn main() {
|
||||||
tracing_subscriber::fmt()
|
tracing_subscriber::fmt()
|
||||||
.with_env_filter(EnvFilter::from_default_env().add_directive("info".parse().unwrap()))
|
.with_env_filter(EnvFilter::from_default_env().add_directive("info".parse().unwrap()))
|
||||||
.init();
|
.init();
|
||||||
if std::env::var("XAI_API_KEY")
|
|
||||||
.ok()
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
.is_none()
|
|
||||||
{
|
|
||||||
tracing::warn!("XAI_API_KEY is not set; chat will fail until it is");
|
|
||||||
}
|
|
||||||
|
|
||||||
let database_url = std::env::var("DATABASE_URL")
|
let database_url = std::env::var("DATABASE_URL")
|
||||||
.unwrap_or_else(|_| "postgres://lazyboy:lazyboy@127.0.0.1:5434/lazyboy".into());
|
.unwrap_or_else(|_| "postgres://lazyboy:lazyboy@127.0.0.1:5434/lazyboy".into());
|
||||||
let state = AppState::connect(&database_url).await.expect("database");
|
let state = AppState::connect(&database_url).await.expect("database");
|
||||||
let actor = state.bootstrap().await.expect("bootstrap");
|
let accounts = state
|
||||||
|
.db
|
||||||
|
.actors()
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|error| panic!("accounts: {error}"));
|
||||||
|
if accounts.is_empty() {
|
||||||
|
tracing::warn!("nobody can sign in yet — open the app and register the first account");
|
||||||
|
}
|
||||||
let mcp_state = state.clone();
|
let mcp_state = state.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
mcp_state.mcp.reconnect_all(mcp_state.pool(), &actor).await;
|
mcp_state.mcp.reconnect_everyone(&mcp_state).await;
|
||||||
});
|
});
|
||||||
|
|
||||||
let retention_state = state.clone();
|
let retention_state = state.clone();
|
||||||
|
|
@ -77,9 +78,12 @@ async fn main() {
|
||||||
|
|
||||||
let bind = std::env::var("API_BIND").unwrap_or_else(|_| "127.0.0.1:3101".into());
|
let bind = std::env::var("API_BIND").unwrap_or_else(|_| "127.0.0.1:3101".into());
|
||||||
let addr: SocketAddr = bind.parse().expect("API_BIND");
|
let addr: SocketAddr = bind.parse().expect("API_BIND");
|
||||||
if !addr.ip().is_loopback() && !state.auth.strong_enough_for_network() {
|
if !addr.ip().is_loopback() {
|
||||||
panic!(
|
// Serving outside this machine is normal — that is how a second person
|
||||||
"LAZYBOY_APP_TOKEN must be set to at least 32 characters when API_BIND is not loopback"
|
// signs in — but they must reach us by an IP or a listed hostname, and
|
||||||
|
// every page behind login.
|
||||||
|
tracing::info!(
|
||||||
|
"listening on {addr}; sign-in is required, and a hostname other than an IP needs LAZYBOY_ALLOWED_HOSTS"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -89,7 +93,7 @@ async fn main() {
|
||||||
"/api/health",
|
"/api/health",
|
||||||
axum::routing::get(|| async { axum::Json(serde_json::json!({"ok": true})) }),
|
axum::routing::get(|| async { axum::Json(serde_json::json!({"ok": true})) }),
|
||||||
)
|
)
|
||||||
.merge(auth::public_router(state.clone()))
|
.merge(accounts::router(state.clone()))
|
||||||
.merge(routes::router(state.clone()))
|
.merge(routes::router(state.clone()))
|
||||||
.layer(DefaultBodyLimit::max(28 * 1024 * 1024))
|
.layer(DefaultBodyLimit::max(28 * 1024 * 1024))
|
||||||
.layer(axum::middleware::from_fn_with_state(
|
.layer(axum::middleware::from_fn_with_state(
|
||||||
|
|
|
||||||
|
|
@ -187,6 +187,21 @@ impl McpHub {
|
||||||
Ok(tools)
|
Ok(tools)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reconnect every account's own servers at startup: MCP connections
|
||||||
|
/// belong to the person who added them, not to the process.
|
||||||
|
pub async fn reconnect_everyone(&self, state: &AppState) {
|
||||||
|
let actors = match state.db.actors().await {
|
||||||
|
Ok(actors) => actors,
|
||||||
|
Err(error) => {
|
||||||
|
tracing::warn!("account lookup failed: {error}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for actor in actors {
|
||||||
|
self.reconnect_all(state.pool(), &actor).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn reconnect_all(&self, pool: &sqlx::PgPool, actor: &Actor) {
|
pub async fn reconnect_all(&self, pool: &sqlx::PgPool, actor: &Actor) {
|
||||||
let rows = match load_rows(pool, actor).await {
|
let rows = match load_rows(pool, actor).await {
|
||||||
Ok(rows) => rows,
|
Ok(rows) => rows,
|
||||||
|
|
@ -488,13 +503,6 @@ async fn rollback_server(state: &AppState, id: &str) {
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn actor(state: &AppState) -> Result<Actor, ApiError> {
|
|
||||||
state
|
|
||||||
.bootstrap()
|
|
||||||
.await
|
|
||||||
.map_err(|error| internal(error.to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn present(state: &AppState, _actor: &Actor, mut row: McpRow) -> McpServer {
|
async fn present(state: &AppState, _actor: &Actor, mut row: McpRow) -> McpServer {
|
||||||
let live = state.mcp.inner.lock().await;
|
let live = state.mcp.inner.lock().await;
|
||||||
if !row.enabled {
|
if !row.enabled {
|
||||||
|
|
@ -507,8 +515,10 @@ async fn present(state: &AppState, _actor: &Actor, mut row: McpRow) -> McpServer
|
||||||
row.into_server("disconnected".into(), error, Vec::new())
|
row.into_server("disconnected".into(), error, Vec::new())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_servers(State(state): State<AppState>) -> Result<Json<Vec<McpServer>>, ApiError> {
|
async fn list_servers(
|
||||||
let actor = actor(&state).await?;
|
actor: Actor,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> Result<Json<Vec<McpServer>>, ApiError> {
|
||||||
let rows = load_rows(state.pool(), &actor)
|
let rows = load_rows(state.pool(), &actor)
|
||||||
.await
|
.await
|
||||||
.map_err(|error| internal(error.to_string()))?;
|
.map_err(|error| internal(error.to_string()))?;
|
||||||
|
|
@ -538,10 +548,11 @@ fn validate_input(input: &UpsertMcpServerInput) -> Result<(), ApiError> {
|
||||||
|
|
||||||
async fn create_server(
|
async fn create_server(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
actor: Actor,
|
||||||
Json(input): Json<UpsertMcpServerInput>,
|
Json(input): Json<UpsertMcpServerInput>,
|
||||||
) -> Result<(StatusCode, Json<McpServer>), ApiError> {
|
) -> Result<(StatusCode, Json<McpServer>), ApiError> {
|
||||||
validate_input(&input)?;
|
validate_input(&input)?;
|
||||||
let actor = actor(&state).await?;
|
|
||||||
let id = Uuid::new_v4().to_string();
|
let id = Uuid::new_v4().to_string();
|
||||||
let name = input.name.trim().chars().take(80).collect::<String>();
|
let name = input.name.trim().chars().take(80).collect::<String>();
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
|
|
@ -605,9 +616,9 @@ async fn create_server(
|
||||||
|
|
||||||
async fn get_server(
|
async fn get_server(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
actor: Actor,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> Result<Json<McpServer>, ApiError> {
|
) -> Result<Json<McpServer>, ApiError> {
|
||||||
let actor = actor(&state).await?;
|
|
||||||
let row = load_row(state.pool(), &actor, &id)
|
let row = load_row(state.pool(), &actor, &id)
|
||||||
.await
|
.await
|
||||||
.map_err(|error| internal(error.to_string()))?
|
.map_err(|error| internal(error.to_string()))?
|
||||||
|
|
@ -617,10 +628,10 @@ async fn get_server(
|
||||||
|
|
||||||
async fn update_server(
|
async fn update_server(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
actor: Actor,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
Json(input): Json<PatchMcpServerInput>,
|
Json(input): Json<PatchMcpServerInput>,
|
||||||
) -> Result<Json<McpServer>, ApiError> {
|
) -> Result<Json<McpServer>, ApiError> {
|
||||||
let actor = actor(&state).await?;
|
|
||||||
let current = load_row(state.pool(), &actor, &id)
|
let current = load_row(state.pool(), &actor, &id)
|
||||||
.await
|
.await
|
||||||
.map_err(|error| internal(error.to_string()))?
|
.map_err(|error| internal(error.to_string()))?
|
||||||
|
|
@ -714,9 +725,9 @@ async fn update_server(
|
||||||
|
|
||||||
async fn delete_server(
|
async fn delete_server(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
actor: Actor,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> Result<StatusCode, ApiError> {
|
) -> Result<StatusCode, ApiError> {
|
||||||
let actor = actor(&state).await?;
|
|
||||||
state.mcp.forget(&id).await;
|
state.mcp.forget(&id).await;
|
||||||
let deleted = sqlx::query("DELETE FROM mcp_servers WHERE id=$1 AND space_id=$2 AND user_id=$3")
|
let deleted = sqlx::query("DELETE FROM mcp_servers WHERE id=$1 AND space_id=$2 AND user_id=$3")
|
||||||
.bind(&id)
|
.bind(&id)
|
||||||
|
|
@ -733,9 +744,9 @@ async fn delete_server(
|
||||||
|
|
||||||
async fn reconnect_server(
|
async fn reconnect_server(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
actor: Actor,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> Result<Json<McpServer>, ApiError> {
|
) -> Result<Json<McpServer>, ApiError> {
|
||||||
let actor = actor(&state).await?;
|
|
||||||
let mut row = load_row(state.pool(), &actor, &id)
|
let mut row = load_row(state.pool(), &actor, &id)
|
||||||
.await
|
.await
|
||||||
.map_err(|error| internal(error.to_string()))?
|
.map_err(|error| internal(error.to_string()))?
|
||||||
|
|
|
||||||
|
|
@ -718,25 +718,29 @@ pub fn router() -> Router<AppState> {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn scoped_actor(state: &AppState, bot_id: &str) -> Result<Actor, StatusCode> {
|
/// An id in a URL is not permission. Anything addressed by `bot_id` checks
|
||||||
let actor = state
|
/// that the agent lives in the caller's own workspace first, so guessing an id
|
||||||
.bootstrap()
|
/// cannot reach another person's memory, vault, or schedule.
|
||||||
.await
|
async fn assert_bot_is_mine(
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
state: &AppState,
|
||||||
|
actor: &Actor,
|
||||||
|
bot_id: &str,
|
||||||
|
) -> Result<(), StatusCode> {
|
||||||
state
|
state
|
||||||
.db
|
.db
|
||||||
.get_bot(&actor, bot_id)
|
.get_bot(actor, bot_id)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||||
.ok_or(StatusCode::NOT_FOUND)?;
|
.ok_or(StatusCode::NOT_FOUND)?;
|
||||||
Ok(actor)
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn memory_status(
|
async fn memory_status(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
actor: Actor,
|
||||||
Path(bot_id): Path<String>,
|
Path(bot_id): Path<String>,
|
||||||
) -> Result<Json<Value>, StatusCode> {
|
) -> Result<Json<Value>, StatusCode> {
|
||||||
let actor = scoped_actor(&state, &bot_id).await?;
|
assert_bot_is_mine(&state, &actor, &bot_id).await?;
|
||||||
let (stored, indexed): (i64, i64) = sqlx::query_as(
|
let (stored, indexed): (i64, i64) = sqlx::query_as(
|
||||||
"SELECT count(*),count(*) FILTER (WHERE embedding IS NOT NULL AND embedding_model=$4) FROM memory_items
|
"SELECT count(*),count(*) FILTER (WHERE embedding IS NOT NULL AND embedding_model=$4) FROM memory_items
|
||||||
WHERE space_id=$1 AND user_id=$2 AND bot_id=$3 AND deleted_at IS NULL",
|
WHERE space_id=$1 AND user_id=$2 AND bot_id=$3 AND deleted_at IS NULL",
|
||||||
|
|
@ -757,9 +761,10 @@ async fn memory_status(
|
||||||
|
|
||||||
async fn list_memories(
|
async fn list_memories(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
actor: Actor,
|
||||||
Path(bot_id): Path<String>,
|
Path(bot_id): Path<String>,
|
||||||
) -> Result<Json<Vec<MemoryItem>>, StatusCode> {
|
) -> Result<Json<Vec<MemoryItem>>, StatusCode> {
|
||||||
let actor = scoped_actor(&state, &bot_id).await?;
|
assert_bot_is_mine(&state, &actor, &bot_id).await?;
|
||||||
state
|
state
|
||||||
.memory
|
.memory
|
||||||
.list(state.pool(), &actor, &bot_id)
|
.list(state.pool(), &actor, &bot_id)
|
||||||
|
|
@ -770,10 +775,13 @@ async fn list_memories(
|
||||||
|
|
||||||
async fn create_memory(
|
async fn create_memory(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
actor: Actor,
|
||||||
Path(bot_id): Path<String>,
|
Path(bot_id): Path<String>,
|
||||||
Json(input): Json<CreateMemoryInput>,
|
Json(input): Json<CreateMemoryInput>,
|
||||||
) -> Result<Json<MemoryItem>, (StatusCode, Json<Value>)> {
|
) -> Result<Json<MemoryItem>, (StatusCode, Json<Value>)> {
|
||||||
let actor = scoped_actor(&state, &bot_id).await.map_err(api_status)?;
|
assert_bot_is_mine(&state, &actor, &bot_id)
|
||||||
|
.await
|
||||||
|
.map_err(api_status)?;
|
||||||
state
|
state
|
||||||
.memory
|
.memory
|
||||||
.remember(state.pool(), &actor, &bot_id, input)
|
.remember(state.pool(), &actor, &bot_id, input)
|
||||||
|
|
@ -784,10 +792,13 @@ async fn create_memory(
|
||||||
|
|
||||||
async fn update_memory(
|
async fn update_memory(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
actor: Actor,
|
||||||
Path((bot_id, memory_id)): Path<(String, Uuid)>,
|
Path((bot_id, memory_id)): Path<(String, Uuid)>,
|
||||||
Json(input): Json<UpdateMemoryInput>,
|
Json(input): Json<UpdateMemoryInput>,
|
||||||
) -> Result<Json<MemoryItem>, (StatusCode, Json<Value>)> {
|
) -> Result<Json<MemoryItem>, (StatusCode, Json<Value>)> {
|
||||||
let actor = scoped_actor(&state, &bot_id).await.map_err(api_status)?;
|
assert_bot_is_mine(&state, &actor, &bot_id)
|
||||||
|
.await
|
||||||
|
.map_err(api_status)?;
|
||||||
state
|
state
|
||||||
.memory
|
.memory
|
||||||
.update(state.pool(), &actor, &bot_id, memory_id, input)
|
.update(state.pool(), &actor, &bot_id, memory_id, input)
|
||||||
|
|
@ -799,9 +810,12 @@ async fn update_memory(
|
||||||
|
|
||||||
async fn delete_memory(
|
async fn delete_memory(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
actor: Actor,
|
||||||
Path((bot_id, memory_id)): Path<(String, Uuid)>,
|
Path((bot_id, memory_id)): Path<(String, Uuid)>,
|
||||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||||
let actor = scoped_actor(&state, &bot_id).await.map_err(api_status)?;
|
assert_bot_is_mine(&state, &actor, &bot_id)
|
||||||
|
.await
|
||||||
|
.map_err(api_status)?;
|
||||||
if !state
|
if !state
|
||||||
.memory
|
.memory
|
||||||
.forget(state.pool(), &actor, &bot_id, memory_id)
|
.forget(state.pool(), &actor, &bot_id, memory_id)
|
||||||
|
|
@ -815,9 +829,12 @@ async fn delete_memory(
|
||||||
|
|
||||||
async fn clear_memories(
|
async fn clear_memories(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
actor: Actor,
|
||||||
Path(bot_id): Path<String>,
|
Path(bot_id): Path<String>,
|
||||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||||
let actor = scoped_actor(&state, &bot_id).await.map_err(api_status)?;
|
assert_bot_is_mine(&state, &actor, &bot_id)
|
||||||
|
.await
|
||||||
|
.map_err(api_status)?;
|
||||||
let deleted = state
|
let deleted = state
|
||||||
.memory
|
.memory
|
||||||
.clear(state.pool(), &actor, &bot_id)
|
.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.
|
/// and revisions removed by retention remain unavailable, even in old runs.
|
||||||
async fn memory_usage(
|
async fn memory_usage(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
actor: Actor,
|
||||||
Path((run_id, activity_id)): Path<(String, i64)>,
|
Path((run_id, activity_id)): Path<(String, i64)>,
|
||||||
) -> Result<Json<Value>, ApiError> {
|
) -> Result<Json<Value>, ApiError> {
|
||||||
let actor = state.bootstrap().await.map_err(internal)?;
|
|
||||||
memory_usage_items(state.pool(), &actor, &run_id, activity_id).await
|
memory_usage_items(state.pool(), &actor, &run_id, activity_id).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -191,6 +191,30 @@ pub fn classify_run_error(error: &str) -> RunFailure {
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// Nothing was ever configured. This is not "the key is wrong": the fix is
|
||||||
|
// pasting a key for the first time, and saying so beats retrying forever.
|
||||||
|
if has(&["missing credential", "no api key", "api key is not set"]) {
|
||||||
|
return RunFailure::new(
|
||||||
|
"model_key_missing",
|
||||||
|
"你還沒給這個工作區 API 金鑰,所以我無法開始。",
|
||||||
|
"到「設定 → 模型」貼上金鑰,存好後按重試。",
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// OpenCode Go answers 403 for models that need a data-sharing opt-in on
|
||||||
|
// opencode.ai. The key is fine; re-pasting it would change nothing.
|
||||||
|
if has(&["datapolicyerror", "requires explicit opt in"]) {
|
||||||
|
return RunFailure::new(
|
||||||
|
"model_opt_in",
|
||||||
|
"OpenCode 說這個模型要先在 opencode.ai 同意資料使用政策才能用。",
|
||||||
|
"到 opencode.ai 工作區的 Go 頁面開啟這個模型,或到「設定 → 模型」換一個模型,再按重試。",
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
if has(&[
|
if has(&[
|
||||||
"invalid api key",
|
"invalid api key",
|
||||||
"incorrect api key",
|
"incorrect api key",
|
||||||
|
|
@ -289,7 +313,9 @@ pub fn classify_run_error(error: &str) -> RunFailure {
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if text.contains("lease") {
|
// "run lease was lost" / "run lease lost": the bare substring would also
|
||||||
|
// match "please" in a provider's error text.
|
||||||
|
if has(&["run lease", "lease was lost", "lease lost", "lease expired"]) {
|
||||||
return RunFailure::new(
|
return RunFailure::new(
|
||||||
"lease_lost",
|
"lease_lost",
|
||||||
"這份工作被另一邊接手過,我手上這份已經失效。",
|
"這份工作被另一邊接手過,我手上這份已經失效。",
|
||||||
|
|
@ -342,24 +368,15 @@ struct RunRow {
|
||||||
completed_at: Option<DateTime<Utc>>,
|
completed_at: Option<DateTime<Utc>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn actor(state: &AppState) -> Result<Actor, ApiError> {
|
|
||||||
state.bootstrap().await.map_err(|error| {
|
|
||||||
(
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
Json(json!({"message": error.to_string()})),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The bubble's whole payload: where the run stands right now plus its trail.
|
/// The bubble's whole payload: where the run stands right now plus its trail.
|
||||||
/// `after` pages forward; the first read returns the newest window, oldest
|
/// `after` pages forward; the first read returns the newest window, oldest
|
||||||
/// first, so the UI renders it and then tails from the last id.
|
/// first, so the UI renders it and then tails from the last id.
|
||||||
async fn activity(
|
async fn activity(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
actor: Actor,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
Query(query): Query<ActivityQuery>,
|
Query(query): Query<ActivityQuery>,
|
||||||
) -> Result<Json<Value>, ApiError> {
|
) -> Result<Json<Value>, ApiError> {
|
||||||
let actor = actor(&state).await?;
|
|
||||||
let run = sqlx::query_as::<_, RunRow>(
|
let run = sqlx::query_as::<_, RunRow>(
|
||||||
"SELECT status, checkpoint->>'step' AS step, (checkpoint->>'turn')::bigint AS turn,
|
"SELECT status, checkpoint->>'step' AS step, (checkpoint->>'turn')::bigint AS turn,
|
||||||
(checkpoint->>'turnLimit')::bigint AS turn_limit, error, (checkpoint->>'stepAt')::timestamptz AS step_at, started_at, completed_at
|
(checkpoint->>'turnLimit')::bigint AS turn_limit, error, (checkpoint->>'stepAt')::timestamptz AS step_at, started_at, completed_at
|
||||||
|
|
@ -429,9 +446,9 @@ async fn activity(
|
||||||
/// it continues where it stopped instead of starting the task over.
|
/// it continues where it stopped instead of starting the task over.
|
||||||
async fn retry(
|
async fn retry(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
actor: Actor,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> Result<Json<Value>, ApiError> {
|
) -> Result<Json<Value>, ApiError> {
|
||||||
let actor = actor(&state).await?;
|
|
||||||
let exists: Option<String> =
|
let exists: Option<String> =
|
||||||
sqlx::query_scalar("SELECT status FROM runs WHERE id=$1 AND space_id=$2 AND user_id=$3")
|
sqlx::query_scalar("SELECT status FROM runs WHERE id=$1 AND space_id=$2 AND user_id=$3")
|
||||||
.bind(&id)
|
.bind(&id)
|
||||||
|
|
@ -522,6 +539,39 @@ mod tests {
|
||||||
assert_eq!(code("HTTP status 404 Not Found"), "model_unknown");
|
assert_eq!(code("HTTP status 404 Not Found"), "model_unknown");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_opencode_data_policy_refusal_is_not_a_bad_key() {
|
||||||
|
let failure = classify_run_error(
|
||||||
|
"ProviderResponseError: status 403 Forbidden: {\"type\":\"error\",\"error\":{\"type\":\"DataPolicyError\",\"message\":\"This model collects data used to improve its quality and requires explicit opt in: https://opencode.ai/workspace/wrk_x/go\"}}",
|
||||||
|
);
|
||||||
|
assert_eq!(failure.code, "model_opt_in");
|
||||||
|
assert!(failure.retryable, "retry works once the opt-in is on");
|
||||||
|
assert!(failure.needs_settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn please_in_a_provider_message_is_not_a_lost_lease() {
|
||||||
|
assert_eq!(code("run lease was lost before completion"), "lease_lost");
|
||||||
|
assert_eq!(code("run lease lost before tool dispatch"), "lease_lost");
|
||||||
|
assert_ne!(
|
||||||
|
code("status 400 Bad Request: Request is missing x-opencode-session. Please see https://opencode.ai/docs/go/"),
|
||||||
|
"lease_lost"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_missing_key_is_not_reported_as_a_wrong_key() {
|
||||||
|
let failure = classify_run_error(
|
||||||
|
"missing credential for xai: add the API key in the workspace settings",
|
||||||
|
);
|
||||||
|
assert_eq!(failure.code, "model_key_missing");
|
||||||
|
assert!(!failure.retryable, "retrying without a key cannot help");
|
||||||
|
assert!(failure.needs_settings);
|
||||||
|
// A key the provider rejected still points at the same screen, but it
|
||||||
|
// is a different problem, and the wording matters.
|
||||||
|
assert_eq!(code("Incorrect API key provided"), "model_key");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_specific_timeouts_win_over_the_generic_one() {
|
fn the_specific_timeouts_win_over_the_generic_one() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|
|
||||||
|
|
@ -30,13 +30,6 @@ pub fn router() -> Router<AppState> {
|
||||||
.route("/api/rooms/{id}/status", get(room_status))
|
.route("/api/rooms/{id}/status", get(room_status))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn actor(state: &AppState) -> Result<Actor, ApiError> {
|
|
||||||
state
|
|
||||||
.bootstrap()
|
|
||||||
.await
|
|
||||||
.map_err(|error| internal(error.to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn internal(message: String) -> ApiError {
|
fn internal(message: String) -> ApiError {
|
||||||
tracing::error!("rooms: {message}");
|
tracing::error!("rooms: {message}");
|
||||||
(
|
(
|
||||||
|
|
@ -115,8 +108,10 @@ async fn room_from_id(
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_rooms(State(state): State<AppState>) -> Result<Json<Vec<Room>>, ApiError> {
|
async fn list_rooms(
|
||||||
let actor = actor(&state).await?;
|
actor: Actor,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> Result<Json<Vec<Room>>, ApiError> {
|
||||||
let ids: Vec<String> = sqlx::query_scalar(
|
let ids: Vec<String> = sqlx::query_scalar(
|
||||||
"SELECT id FROM rooms WHERE space_id=$1 AND user_id=$2 ORDER BY updated_at DESC, created_at DESC",
|
"SELECT id FROM rooms WHERE space_id=$1 AND user_id=$2 ORDER BY updated_at DESC, created_at DESC",
|
||||||
)
|
)
|
||||||
|
|
@ -139,9 +134,9 @@ async fn list_rooms(State(state): State<AppState>) -> Result<Json<Vec<Room>>, Ap
|
||||||
|
|
||||||
async fn create_room(
|
async fn create_room(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
actor: Actor,
|
||||||
Json(input): Json<CreateRoomInput>,
|
Json(input): Json<CreateRoomInput>,
|
||||||
) -> Result<(StatusCode, Json<Room>), ApiError> {
|
) -> Result<(StatusCode, Json<Room>), ApiError> {
|
||||||
let actor = actor(&state).await?;
|
|
||||||
let name = input.name.trim();
|
let name = input.name.trim();
|
||||||
let mut seen = HashSet::new();
|
let mut seen = HashSet::new();
|
||||||
let member_ids: Vec<String> = input
|
let member_ids: Vec<String> = input
|
||||||
|
|
@ -241,9 +236,9 @@ async fn create_room(
|
||||||
|
|
||||||
async fn get_room(
|
async fn get_room(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
actor: Actor,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> Result<Json<Room>, ApiError> {
|
) -> Result<Json<Room>, ApiError> {
|
||||||
let actor = actor(&state).await?;
|
|
||||||
room_from_id(&state, &actor, &id)
|
room_from_id(&state, &actor, &id)
|
||||||
.await
|
.await
|
||||||
.map_err(|error| internal(error.to_string()))?
|
.map_err(|error| internal(error.to_string()))?
|
||||||
|
|
@ -258,9 +253,9 @@ async fn get_room(
|
||||||
|
|
||||||
async fn delete_room(
|
async fn delete_room(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
actor: Actor,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> Result<StatusCode, ApiError> {
|
) -> Result<StatusCode, ApiError> {
|
||||||
let actor = actor(&state).await?;
|
|
||||||
let deleted = sqlx::query("DELETE FROM rooms WHERE id=$1 AND space_id=$2 AND user_id=$3")
|
let deleted = sqlx::query("DELETE FROM rooms WHERE id=$1 AND space_id=$2 AND user_id=$3")
|
||||||
.bind(&id)
|
.bind(&id)
|
||||||
.bind(&actor.space_id)
|
.bind(&actor.space_id)
|
||||||
|
|
@ -281,10 +276,10 @@ async fn delete_room(
|
||||||
/// is reached by one tap on a member — there is no settings page to open.
|
/// is reached by one tap on a member — there is no settings page to open.
|
||||||
async fn update_room(
|
async fn update_room(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
actor: Actor,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
Json(input): Json<UpdateRoomInput>,
|
Json(input): Json<UpdateRoomInput>,
|
||||||
) -> Result<Json<Room>, ApiError> {
|
) -> Result<Json<Room>, ApiError> {
|
||||||
let actor = actor(&state).await?;
|
|
||||||
let current = room_from_id(&state, &actor, &id)
|
let current = room_from_id(&state, &actor, &id)
|
||||||
.await
|
.await
|
||||||
.map_err(|error| internal(error.to_string()))?
|
.map_err(|error| internal(error.to_string()))?
|
||||||
|
|
@ -332,9 +327,9 @@ async fn update_room(
|
||||||
|
|
||||||
async fn list_sessions(
|
async fn list_sessions(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
actor: Actor,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> Result<Json<Vec<Session>>, ApiError> {
|
) -> Result<Json<Vec<Session>>, ApiError> {
|
||||||
let actor = actor(&state).await?;
|
|
||||||
if room_from_id(&state, &actor, &id)
|
if room_from_id(&state, &actor, &id)
|
||||||
.await
|
.await
|
||||||
.map_err(|error| internal(error.to_string()))?
|
.map_err(|error| internal(error.to_string()))?
|
||||||
|
|
@ -363,10 +358,10 @@ async fn list_sessions(
|
||||||
|
|
||||||
async fn create_session(
|
async fn create_session(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
actor: Actor,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
Json(input): Json<CreateSessionInput>,
|
Json(input): Json<CreateSessionInput>,
|
||||||
) -> Result<(StatusCode, Json<Session>), ApiError> {
|
) -> Result<(StatusCode, Json<Session>), ApiError> {
|
||||||
let actor = actor(&state).await?;
|
|
||||||
let Some(room) = room_from_id(&state, &actor, &id)
|
let Some(room) = room_from_id(&state, &actor, &id)
|
||||||
.await
|
.await
|
||||||
.map_err(|error| internal(error.to_string()))?
|
.map_err(|error| internal(error.to_string()))?
|
||||||
|
|
@ -403,9 +398,9 @@ async fn create_session(
|
||||||
|
|
||||||
async fn room_status(
|
async fn room_status(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
actor: Actor,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> Result<Json<RoomStatus>, ApiError> {
|
) -> Result<Json<RoomStatus>, ApiError> {
|
||||||
let actor = actor(&state).await?;
|
|
||||||
if room_from_id(&state, &actor, &id)
|
if room_from_id(&state, &actor, &id)
|
||||||
.await
|
.await
|
||||||
.map_err(|error| internal(error.to_string()))?
|
.map_err(|error| internal(error.to_string()))?
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,7 @@ pub fn router(state: AppState) -> Router {
|
||||||
.route("/view/{id}/{*rest}", any(crate::screen_proxy::view_path))
|
.route("/view/{id}/{*rest}", any(crate::screen_proxy::view_path))
|
||||||
.route_layer(middleware::from_fn_with_state(
|
.route_layer(middleware::from_fn_with_state(
|
||||||
state.clone(),
|
state.clone(),
|
||||||
crate::auth::require_auth,
|
crate::auth::require_session,
|
||||||
))
|
))
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
}
|
}
|
||||||
|
|
@ -58,15 +58,10 @@ async fn file_skills(State(state): State<AppState>) -> Json<Vec<crate::file_skil
|
||||||
Json(crate::file_skills::list(&state.data_dir))
|
Json(crate::file_skills::list(&state.data_dir))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn actor(state: &AppState) -> Result<Actor, StatusCode> {
|
async fn list_bots(
|
||||||
state
|
actor: Actor,
|
||||||
.bootstrap()
|
State(state): State<AppState>,
|
||||||
.await
|
) -> Result<Json<Vec<Bot>>, StatusCode> {
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn list_bots(State(state): State<AppState>) -> Result<Json<Vec<Bot>>, StatusCode> {
|
|
||||||
let actor = actor(&state).await?;
|
|
||||||
let rows = state
|
let rows = state
|
||||||
.db
|
.db
|
||||||
.list_bots(&actor)
|
.list_bots(&actor)
|
||||||
|
|
@ -102,9 +97,9 @@ async fn list_bots(State(state): State<AppState>) -> Result<Json<Vec<Bot>>, Stat
|
||||||
|
|
||||||
async fn create_bot(
|
async fn create_bot(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
actor: Actor,
|
||||||
Json(input): Json<CreateBotInput>,
|
Json(input): Json<CreateBotInput>,
|
||||||
) -> Result<Json<Bot>, StatusCode> {
|
) -> Result<Json<Bot>, StatusCode> {
|
||||||
let actor = actor(&state).await?;
|
|
||||||
if input.name.trim().is_empty() {
|
if input.name.trim().is_empty() {
|
||||||
return Err(StatusCode::BAD_REQUEST);
|
return Err(StatusCode::BAD_REQUEST);
|
||||||
}
|
}
|
||||||
|
|
@ -128,9 +123,9 @@ async fn create_bot(
|
||||||
|
|
||||||
async fn get_bot(
|
async fn get_bot(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
actor: Actor,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> Result<Json<Value>, StatusCode> {
|
) -> Result<Json<Value>, StatusCode> {
|
||||||
let actor = actor(&state).await?;
|
|
||||||
let bot = state
|
let bot = state
|
||||||
.db
|
.db
|
||||||
.get_bot(&actor, &id)
|
.get_bot(&actor, &id)
|
||||||
|
|
@ -185,12 +180,10 @@ struct InboxInput {
|
||||||
|
|
||||||
async fn update_inbox(
|
async fn update_inbox(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
actor: Actor,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
Json(input): Json<InboxInput>,
|
Json(input): Json<InboxInput>,
|
||||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||||
let actor = actor(&state)
|
|
||||||
.await
|
|
||||||
.map_err(|status| (status, Json(json!({"message":"actor"}))))?;
|
|
||||||
let query = match input.action.as_str() {
|
let query = match input.action.as_str() {
|
||||||
"read" => "UPDATE bots SET last_read_at=now() WHERE id=$1 AND space_id=$2 AND user_id=$3",
|
"read" => "UPDATE bots SET last_read_at=now() WHERE id=$1 AND space_id=$2 AND user_id=$3",
|
||||||
"unread" => {
|
"unread" => {
|
||||||
|
|
@ -230,12 +223,10 @@ async fn update_inbox(
|
||||||
|
|
||||||
async fn update_bot(
|
async fn update_bot(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
actor: Actor,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
Json(input): Json<UpdateBotInput>,
|
Json(input): Json<UpdateBotInput>,
|
||||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||||
let actor = actor(&state)
|
|
||||||
.await
|
|
||||||
.map_err(|status| (status, Json(json!({"message":"actor"}))))?;
|
|
||||||
let name = input.name.trim();
|
let name = input.name.trim();
|
||||||
let color_ok = input.avatar_color.len() == 7
|
let color_ok = input.avatar_color.len() == 7
|
||||||
&& input.avatar_color.starts_with('#')
|
&& input.avatar_color.starts_with('#')
|
||||||
|
|
@ -324,11 +315,9 @@ async fn update_bot(
|
||||||
|
|
||||||
async fn delete_bot(
|
async fn delete_bot(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
actor: Actor,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||||
let actor = actor(&state)
|
|
||||||
.await
|
|
||||||
.map_err(|status| (status, Json(json!({ "message": "actor" }))))?;
|
|
||||||
let bot = state
|
let bot = state
|
||||||
.db
|
.db
|
||||||
.get_bot(&actor, &id)
|
.get_bot(&actor, &id)
|
||||||
|
|
@ -409,11 +398,9 @@ async fn delete_bot(
|
||||||
|
|
||||||
async fn delete_environment(
|
async fn delete_environment(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
actor: Actor,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||||
let actor = actor(&state)
|
|
||||||
.await
|
|
||||||
.map_err(|status| (status, Json(json!({ "message": "actor" }))))?;
|
|
||||||
if id != actor.space_id {
|
if id != actor.space_id {
|
||||||
return Err((
|
return Err((
|
||||||
StatusCode::NOT_FOUND,
|
StatusCode::NOT_FOUND,
|
||||||
|
|
@ -497,10 +484,10 @@ struct SendBody {
|
||||||
|
|
||||||
async fn send_message(
|
async fn send_message(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
actor: Actor,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
Json(body): Json<SendBody>,
|
Json(body): Json<SendBody>,
|
||||||
) -> Result<Json<Value>, StatusCode> {
|
) -> Result<Json<Value>, StatusCode> {
|
||||||
let actor = actor(&state).await?;
|
|
||||||
let _ = state
|
let _ = state
|
||||||
.db
|
.db
|
||||||
.get_bot(&actor, &id)
|
.get_bot(&actor, &id)
|
||||||
|
|
@ -531,9 +518,9 @@ async fn send_message(
|
||||||
|
|
||||||
async fn list_messages(
|
async fn list_messages(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
actor: Actor,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> Result<Json<Value>, StatusCode> {
|
) -> Result<Json<Value>, StatusCode> {
|
||||||
let actor = actor(&state).await?;
|
|
||||||
let _ = state
|
let _ = state
|
||||||
.db
|
.db
|
||||||
.get_bot(&actor, &id)
|
.get_bot(&actor, &id)
|
||||||
|
|
@ -552,9 +539,9 @@ async fn list_messages(
|
||||||
|
|
||||||
async fn stop_task(
|
async fn stop_task(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
actor: Actor,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> Result<Json<Value>, StatusCode> {
|
) -> Result<Json<Value>, StatusCode> {
|
||||||
let actor = actor(&state).await?;
|
|
||||||
let _ = state
|
let _ = state
|
||||||
.db
|
.db
|
||||||
.get_bot(&actor, &id)
|
.get_bot(&actor, &id)
|
||||||
|
|
@ -569,9 +556,9 @@ async fn stop_task(
|
||||||
|
|
||||||
async fn computer_status(
|
async fn computer_status(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
actor: Actor,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> Result<Json<Value>, StatusCode> {
|
) -> Result<Json<Value>, StatusCode> {
|
||||||
let actor = actor(&state).await?;
|
|
||||||
computer::current_status(&state, &actor, &id)
|
computer::current_status(&state, &actor, &id)
|
||||||
.await
|
.await
|
||||||
.map(|status| Json(serde_json::to_value(status).unwrap()))
|
.map(|status| Json(serde_json::to_value(status).unwrap()))
|
||||||
|
|
@ -580,9 +567,9 @@ async fn computer_status(
|
||||||
|
|
||||||
async fn boot(
|
async fn boot(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
actor: Actor,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> Result<Json<Value>, StatusCode> {
|
) -> Result<Json<Value>, StatusCode> {
|
||||||
let actor = actor(&state).await?;
|
|
||||||
computer::boot(&state, &actor, &id)
|
computer::boot(&state, &actor, &id)
|
||||||
.await
|
.await
|
||||||
.map(|status| Json(serde_json::to_value(status).unwrap()))
|
.map(|status| Json(serde_json::to_value(status).unwrap()))
|
||||||
|
|
@ -598,9 +585,9 @@ async fn boot(
|
||||||
|
|
||||||
async fn restart(
|
async fn restart(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
actor: Actor,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> Result<Json<Value>, StatusCode> {
|
) -> Result<Json<Value>, StatusCode> {
|
||||||
let actor = actor(&state).await?;
|
|
||||||
computer::restart(&state, &actor, &id)
|
computer::restart(&state, &actor, &id)
|
||||||
.await
|
.await
|
||||||
.map(|status| Json(serde_json::to_value(status).unwrap()))
|
.map(|status| Json(serde_json::to_value(status).unwrap()))
|
||||||
|
|
@ -612,11 +599,9 @@ async fn restart(
|
||||||
|
|
||||||
async fn stop(
|
async fn stop(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
actor: Actor,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||||
let actor = actor(&state)
|
|
||||||
.await
|
|
||||||
.map_err(|status| (status, Json(json!({"message":"無法取得工作區"}))))?;
|
|
||||||
computer::stop(&state, &actor, &id)
|
computer::stop(&state, &actor, &id)
|
||||||
.await
|
.await
|
||||||
.map(|status| Json(serde_json::to_value(status).unwrap()))
|
.map(|status| Json(serde_json::to_value(status).unwrap()))
|
||||||
|
|
@ -625,9 +610,9 @@ async fn stop(
|
||||||
|
|
||||||
async fn screen_url(
|
async fn screen_url(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
actor: Actor,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> Result<Json<Value>, StatusCode> {
|
) -> Result<Json<Value>, StatusCode> {
|
||||||
let actor = actor(&state).await?;
|
|
||||||
let bot = state
|
let bot = state
|
||||||
.db
|
.db
|
||||||
.get_bot(&actor, &id)
|
.get_bot(&actor, &id)
|
||||||
|
|
@ -670,11 +655,9 @@ async fn screen_url(
|
||||||
|
|
||||||
async fn takeover(
|
async fn takeover(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
actor: Actor,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||||
let actor = actor(&state)
|
|
||||||
.await
|
|
||||||
.map_err(|status| (status, Json(json!({"message": "actor"}))))?;
|
|
||||||
match computer::takeover(&state, &actor, &id).await {
|
match computer::takeover(&state, &actor, &id).await {
|
||||||
Ok((lease_id, expires_at)) => Ok(Json(
|
Ok((lease_id, expires_at)) => Ok(Json(
|
||||||
json!({ "leaseId": lease_id, "expiresAt": expires_at }),
|
json!({ "leaseId": lease_id, "expiresAt": expires_at }),
|
||||||
|
|
@ -685,9 +668,9 @@ async fn takeover(
|
||||||
|
|
||||||
async fn release(
|
async fn release(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
actor: Actor,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> Result<Json<Value>, StatusCode> {
|
) -> Result<Json<Value>, StatusCode> {
|
||||||
let actor = actor(&state).await?;
|
|
||||||
computer::release(&state, &actor, &id)
|
computer::release(&state, &actor, &id)
|
||||||
.await
|
.await
|
||||||
.map(|_| Json(json!({ "ok": true })))
|
.map(|_| Json(json!({ "ok": true })))
|
||||||
|
|
@ -696,9 +679,9 @@ async fn release(
|
||||||
|
|
||||||
async fn heartbeat(
|
async fn heartbeat(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
actor: Actor,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> Result<Json<Value>, StatusCode> {
|
) -> Result<Json<Value>, StatusCode> {
|
||||||
let actor = actor(&state).await?;
|
|
||||||
computer::heartbeat(&state, &actor, &id)
|
computer::heartbeat(&state, &actor, &id)
|
||||||
.await
|
.await
|
||||||
.map(|_| Json(json!({ "ok": true })))
|
.map(|_| Json(json!({ "ok": true })))
|
||||||
|
|
@ -716,10 +699,10 @@ struct InputBody {
|
||||||
|
|
||||||
async fn input(
|
async fn input(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
actor: Actor,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
Json(body): Json<InputBody>,
|
Json(body): Json<InputBody>,
|
||||||
) -> Result<Json<Value>, StatusCode> {
|
) -> Result<Json<Value>, StatusCode> {
|
||||||
let actor = actor(&state).await?;
|
|
||||||
let bot = state
|
let bot = state
|
||||||
.db
|
.db
|
||||||
.get_bot(&actor, &id)
|
.get_bot(&actor, &id)
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,7 @@ use std::time::Duration;
|
||||||
|
|
||||||
use lazyboy_contracts::ModelProvider;
|
use lazyboy_contracts::ModelProvider;
|
||||||
use lazyboy_harness::{
|
use lazyboy_harness::{
|
||||||
CredentialChain, DynModel, ResolveModelRequest, connect_model, credential_from_env,
|
CredentialChain, DynModel, ResolveModelRequest, connect_model, resolve_backend,
|
||||||
resolve_backend,
|
|
||||||
};
|
};
|
||||||
use rig_core::completion::message::{AssistantContent, Message, UserContent};
|
use rig_core::completion::message::{AssistantContent, Message, UserContent};
|
||||||
|
|
||||||
|
|
@ -338,7 +337,7 @@ async fn pick(
|
||||||
thread_id: &str,
|
thread_id: &str,
|
||||||
text: &str,
|
text: &str,
|
||||||
) -> Vec<String> {
|
) -> Vec<String> {
|
||||||
let model = match router_model(state, actor).await {
|
let model = match router_model(state, actor, thread_id).await {
|
||||||
Ok(model) => model,
|
Ok(model) => model,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
tracing::warn!("room router unavailable: {error}");
|
tracing::warn!("room router unavailable: {error}");
|
||||||
|
|
@ -433,7 +432,11 @@ async fn prompt_for(
|
||||||
|
|
||||||
/// The picker runs on the workspace model unless `LAZYBOY_ROUTER_MODEL` names a
|
/// The picker runs on the workspace model unless `LAZYBOY_ROUTER_MODEL` names a
|
||||||
/// cheaper one; leaving it unset is a valid and common state.
|
/// cheaper one; leaving it unset is a valid and common state.
|
||||||
async fn router_model(state: &AppState, actor: &Actor) -> Result<DynModel, String> {
|
async fn router_model(
|
||||||
|
state: &AppState,
|
||||||
|
actor: &Actor,
|
||||||
|
thread_id: &str,
|
||||||
|
) -> Result<DynModel, String> {
|
||||||
let space = state
|
let space = state
|
||||||
.db
|
.db
|
||||||
.get_space(actor)
|
.get_space(actor)
|
||||||
|
|
@ -455,11 +458,10 @@ async fn router_model(state: &AppState, actor: &Actor) -> Result<DynModel, Strin
|
||||||
credentials: CredentialChain {
|
credentials: CredentialChain {
|
||||||
bot: None,
|
bot: None,
|
||||||
space: space.default_model_api_key.clone(),
|
space: space.default_model_api_key.clone(),
|
||||||
env: credential_from_env(provider),
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.map_err(|error| error.to_string())?;
|
.map_err(|error| error.to_string())?;
|
||||||
connect_model(&backend).map_err(|error| error.to_string())
|
connect_model(&backend, thread_id).map_err(|error| error.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The single hand-off a bot may pass on: a member it names that is neither
|
/// 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())?
|
.map_err(|error| error.to_string())?
|
||||||
.ok_or_else(|| "computer not found".to_string())?;
|
.ok_or_else(|| "computer not found".to_string())?;
|
||||||
|
|
||||||
let (model, vision) = bot_model(state, actor, &bot).await?;
|
let (model, vision) = bot_model(state, actor, &bot, thread_id).await?;
|
||||||
let skills = crate::skills::saved_skills(state.pool(), bot_id).await;
|
let skills = crate::skills::saved_skills(state.pool(), bot_id).await;
|
||||||
|
|
||||||
let ctx = Arc::new(ToolCtx {
|
let ctx = Arc::new(ToolCtx {
|
||||||
|
|
@ -1594,12 +1594,15 @@ async fn execute_run(
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve the model a bot runs on (bot override → workspace default → env
|
/// Resolve the model a bot runs on (bot override → workspace default).
|
||||||
/// credentials). Returns the connected model and whether it accepts images.
|
/// `session` names the conversation the calls belong to; providers such as
|
||||||
|
/// OpenCode Go require it on every request. Returns the connected model and
|
||||||
|
/// whether it accepts images.
|
||||||
pub(crate) async fn bot_model(
|
pub(crate) async fn bot_model(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
actor: &Actor,
|
actor: &Actor,
|
||||||
bot: &crate::db::BotRow,
|
bot: &crate::db::BotRow,
|
||||||
|
session: &str,
|
||||||
) -> Result<(DynModel, bool), String> {
|
) -> Result<(DynModel, bool), String> {
|
||||||
let space = state
|
let space = state
|
||||||
.db
|
.db
|
||||||
|
|
@ -1626,11 +1629,10 @@ pub(crate) async fn bot_model(
|
||||||
credentials: CredentialChain {
|
credentials: CredentialChain {
|
||||||
bot: None,
|
bot: None,
|
||||||
space: space.default_model_api_key.clone(),
|
space: space.default_model_api_key.clone(),
|
||||||
env: lazyboy_harness::credential_from_env(provider),
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.map_err(|error| error.to_string())?;
|
.map_err(|error| error.to_string())?;
|
||||||
let model = connect_model(&backend).map_err(|error| error.to_string())?;
|
let model = connect_model(&backend, session).map_err(|error| error.to_string())?;
|
||||||
Ok((model, backend.capabilities.vision))
|
Ok((model, backend.capabilities.vision))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -77,25 +77,29 @@ pub fn router() -> Router<AppState> {
|
||||||
.route("/api/schedules/{id}/run", post(run_now_http))
|
.route("/api/schedules/{id}/run", post(run_now_http))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn scoped_actor(state: &AppState, bot_id: &str) -> Result<Actor, StatusCode> {
|
/// An id in a URL is not permission. Anything addressed by `bot_id` checks
|
||||||
let actor = state
|
/// that the agent lives in the caller's own workspace first, so guessing an id
|
||||||
.bootstrap()
|
/// cannot reach another person's memory, vault, or schedule.
|
||||||
.await
|
async fn assert_bot_is_mine(
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
state: &AppState,
|
||||||
|
actor: &Actor,
|
||||||
|
bot_id: &str,
|
||||||
|
) -> Result<(), StatusCode> {
|
||||||
state
|
state
|
||||||
.db
|
.db
|
||||||
.get_bot(&actor, bot_id)
|
.get_bot(actor, bot_id)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||||
.ok_or(StatusCode::NOT_FOUND)?;
|
.ok_or(StatusCode::NOT_FOUND)?;
|
||||||
Ok(actor)
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_http(
|
async fn list_http(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
actor: Actor,
|
||||||
Path(bot_id): Path<String>,
|
Path(bot_id): Path<String>,
|
||||||
) -> Result<Json<Vec<Value>>, StatusCode> {
|
) -> Result<Json<Vec<Value>>, StatusCode> {
|
||||||
let actor = scoped_actor(&state, &bot_id).await?;
|
assert_bot_is_mine(&state, &actor, &bot_id).await?;
|
||||||
let rows = list(&state, &actor, &bot_id)
|
let rows = list(&state, &actor, &bot_id)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
@ -104,10 +108,11 @@ async fn list_http(
|
||||||
|
|
||||||
async fn create_http(
|
async fn create_http(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
actor: Actor,
|
||||||
Path(bot_id): Path<String>,
|
Path(bot_id): Path<String>,
|
||||||
Json(input): Json<CreateSchedule>,
|
Json(input): Json<CreateSchedule>,
|
||||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||||
let actor = scoped_actor(&state, &bot_id)
|
assert_bot_is_mine(&state, &actor, &bot_id)
|
||||||
.await
|
.await
|
||||||
.map_err(|status| (status, Json(json!({"message":"bot not found"}))))?;
|
.map_err(|status| (status, Json(json!({"message":"bot not found"}))))?;
|
||||||
create(&state, &actor, &bot_id, input)
|
create(&state, &actor, &bot_id, input)
|
||||||
|
|
@ -118,15 +123,10 @@ async fn create_http(
|
||||||
|
|
||||||
async fn update_http(
|
async fn update_http(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
actor: Actor,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
Json(input): Json<UpdateSchedule>,
|
Json(input): Json<UpdateSchedule>,
|
||||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||||
let actor = state.bootstrap().await.map_err(|_| {
|
|
||||||
(
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
Json(json!({"message":"actor"})),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
update(&state, &actor, &id, input)
|
update(&state, &actor, &id, input)
|
||||||
.await
|
.await
|
||||||
.map_err(|error| (StatusCode::BAD_REQUEST, Json(json!({"message": error}))))?
|
.map_err(|error| (StatusCode::BAD_REQUEST, Json(json!({"message": error}))))?
|
||||||
|
|
@ -139,12 +139,9 @@ async fn update_http(
|
||||||
|
|
||||||
async fn delete_http(
|
async fn delete_http(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
actor: Actor,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> Result<StatusCode, StatusCode> {
|
) -> Result<StatusCode, StatusCode> {
|
||||||
let actor = state
|
|
||||||
.bootstrap()
|
|
||||||
.await
|
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
|
||||||
let deleted = sqlx::query("DELETE FROM schedules WHERE id=$1 AND space_id=$2 AND user_id=$3")
|
let deleted = sqlx::query("DELETE FROM schedules WHERE id=$1 AND space_id=$2 AND user_id=$3")
|
||||||
.bind(&id)
|
.bind(&id)
|
||||||
.bind(&actor.space_id)
|
.bind(&actor.space_id)
|
||||||
|
|
@ -160,14 +157,9 @@ async fn delete_http(
|
||||||
|
|
||||||
async fn run_now_http(
|
async fn run_now_http(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
actor: Actor,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||||
let actor = state.bootstrap().await.map_err(|_| {
|
|
||||||
(
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
Json(json!({"message":"actor"})),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
let row = get_row(&state, &actor, &id)
|
let row = get_row(&state, &actor, &id)
|
||||||
.await
|
.await
|
||||||
.map_err(|error| (StatusCode::BAD_REQUEST, Json(json!({"message": error}))))?
|
.map_err(|error| (StatusCode::BAD_REQUEST, Json(json!({"message": error}))))?
|
||||||
|
|
|
||||||
|
|
@ -6,25 +6,37 @@ use futures_util::{SinkExt, StreamExt};
|
||||||
use tokio_tungstenite::tungstenite::Message as WsMessage;
|
use tokio_tungstenite::tungstenite::Message as WsMessage;
|
||||||
|
|
||||||
use crate::computer;
|
use crate::computer;
|
||||||
|
use crate::db::Actor;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
pub async fn view_root(
|
pub async fn view_root(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(bot_id): Path<String>,
|
Path(bot_id): Path<String>,
|
||||||
|
actor: Actor,
|
||||||
req: Request,
|
req: Request,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
proxy(state, bot_id, String::new(), req).await
|
proxy(state, actor, bot_id, String::new(), req).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn view_path(
|
pub async fn view_path(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path((bot_id, rest)): Path<(String, String)>,
|
Path((bot_id, rest)): Path<(String, String)>,
|
||||||
|
actor: Actor,
|
||||||
req: Request,
|
req: Request,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
proxy(state, bot_id, rest, req).await
|
proxy(state, actor, bot_id, rest, req).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn proxy(state: AppState, bot_id: String, rest: String, req: Request) -> Response {
|
/// A desktop is streamed to its owner only. The actor comes from the login
|
||||||
|
/// cookie before the request is upgraded, so another person holding the same
|
||||||
|
/// `/view/:id` URL is turned away instead of watching somebody's screen.
|
||||||
|
async fn proxy(
|
||||||
|
state: AppState,
|
||||||
|
actor: Actor,
|
||||||
|
bot_id: String,
|
||||||
|
rest: String,
|
||||||
|
req: Request,
|
||||||
|
) -> Response {
|
||||||
let upgrade = req
|
let upgrade = req
|
||||||
.headers()
|
.headers()
|
||||||
.get(header::UPGRADE)
|
.get(header::UPGRADE)
|
||||||
|
|
@ -44,7 +56,7 @@ async fn proxy(state: AppState, bot_id: String, rest: String, req: Request) -> R
|
||||||
return StatusCode::NOT_FOUND.into_response();
|
return StatusCode::NOT_FOUND.into_response();
|
||||||
}
|
}
|
||||||
let ensure = upgrade || is_viewer_page(&rest) || rest.contains("websockify");
|
let ensure = upgrade || is_viewer_page(&rest) || rest.contains("websockify");
|
||||||
let (host, port) = match upstream_target(&state, &bot_id, ensure).await {
|
let (host, port) = match upstream_target(&state, &actor, &bot_id, ensure).await {
|
||||||
Ok(target) => target,
|
Ok(target) => target,
|
||||||
Err(status) => return status.into_response(),
|
Err(status) => return status.into_response(),
|
||||||
};
|
};
|
||||||
|
|
@ -88,16 +100,13 @@ async fn viewer_page() -> Response {
|
||||||
|
|
||||||
async fn upstream_target(
|
async fn upstream_target(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
|
actor: &Actor,
|
||||||
bot_id: &str,
|
bot_id: &str,
|
||||||
ensure: bool,
|
ensure: bool,
|
||||||
) -> Result<(String, u16), StatusCode> {
|
) -> Result<(String, u16), StatusCode> {
|
||||||
let actor = state
|
|
||||||
.bootstrap()
|
|
||||||
.await
|
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
|
||||||
let bot = state
|
let bot = state
|
||||||
.db
|
.db
|
||||||
.get_bot(&actor, bot_id)
|
.get_bot(actor, bot_id)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||||
.ok_or(StatusCode::NOT_FOUND)?;
|
.ok_or(StatusCode::NOT_FOUND)?;
|
||||||
|
|
@ -109,7 +118,7 @@ async fn upstream_target(
|
||||||
.ok_or(StatusCode::NOT_FOUND)?;
|
.ok_or(StatusCode::NOT_FOUND)?;
|
||||||
let computer_ref = computer::computer_ref(&computer).ok_or(StatusCode::NOT_FOUND)?;
|
let computer_ref = computer::computer_ref(&computer).ok_or(StatusCode::NOT_FOUND)?;
|
||||||
let screen = if ensure {
|
let screen = if ensure {
|
||||||
match computer::ensure_bot_screen(state, &actor, bot_id, &computer, None).await {
|
match computer::ensure_bot_screen(state, actor, bot_id, &computer, None).await {
|
||||||
Ok(bound) => bound.row,
|
Ok(bound) => bound.row,
|
||||||
Err(_) => state
|
Err(_) => state
|
||||||
.db
|
.db
|
||||||
|
|
@ -129,7 +138,7 @@ async fn upstream_target(
|
||||||
.connect_screen(
|
.connect_screen(
|
||||||
&computer_ref,
|
&computer_ref,
|
||||||
computer::user_can_interact(&computer, screen.as_ref(), bot_id),
|
computer::user_can_interact(&computer, screen.as_ref(), bot_id),
|
||||||
&computer::adapter_context_for(&actor, bot_id, "view", screen.as_ref(), None),
|
&computer::adapter_context_for(actor, bot_id, "view", screen.as_ref(), None),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::BAD_GATEWAY)?;
|
.map_err(|_| StatusCode::BAD_GATEWAY)?;
|
||||||
|
|
|
||||||
|
|
@ -43,13 +43,6 @@ pub fn router() -> Router<AppState> {
|
||||||
.route("/api/sessions/{id}/stop", post(stop_session))
|
.route("/api/sessions/{id}/stop", post(stop_session))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn actor(state: &AppState) -> Result<Actor, ApiError> {
|
|
||||||
state
|
|
||||||
.bootstrap()
|
|
||||||
.await
|
|
||||||
.map_err(|error| internal(error.to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn internal(message: String) -> ApiError {
|
fn internal(message: String) -> ApiError {
|
||||||
tracing::error!("sessions: {message}");
|
tracing::error!("sessions: {message}");
|
||||||
(
|
(
|
||||||
|
|
@ -86,9 +79,9 @@ pub(crate) fn session_from_row(
|
||||||
|
|
||||||
async fn list_sessions(
|
async fn list_sessions(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
actor: Actor,
|
||||||
Path(bot_id): Path<String>,
|
Path(bot_id): Path<String>,
|
||||||
) -> Result<Json<Vec<Session>>, ApiError> {
|
) -> Result<Json<Vec<Session>>, ApiError> {
|
||||||
let actor = actor(&state).await?;
|
|
||||||
let exists: Option<i32> =
|
let exists: Option<i32> =
|
||||||
sqlx::query_scalar("SELECT 1 FROM bots WHERE id=$1 AND space_id=$2 AND user_id=$3")
|
sqlx::query_scalar("SELECT 1 FROM bots WHERE id=$1 AND space_id=$2 AND user_id=$3")
|
||||||
.bind(&bot_id)
|
.bind(&bot_id)
|
||||||
|
|
@ -121,10 +114,10 @@ async fn list_sessions(
|
||||||
|
|
||||||
async fn create_session(
|
async fn create_session(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
actor: Actor,
|
||||||
Path(bot_id): Path<String>,
|
Path(bot_id): Path<String>,
|
||||||
Json(input): Json<CreateSessionInput>,
|
Json(input): Json<CreateSessionInput>,
|
||||||
) -> Result<(StatusCode, Json<Session>), ApiError> {
|
) -> Result<(StatusCode, Json<Session>), ApiError> {
|
||||||
let actor = actor(&state).await?;
|
|
||||||
let exists: Option<i32> =
|
let exists: Option<i32> =
|
||||||
sqlx::query_scalar("SELECT 1 FROM bots WHERE id=$1 AND space_id=$2 AND user_id=$3")
|
sqlx::query_scalar("SELECT 1 FROM bots WHERE id=$1 AND space_id=$2 AND user_id=$3")
|
||||||
.bind(&bot_id)
|
.bind(&bot_id)
|
||||||
|
|
@ -159,9 +152,9 @@ async fn create_session(
|
||||||
|
|
||||||
async fn get_session(
|
async fn get_session(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
actor: Actor,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> Result<Json<Session>, ApiError> {
|
) -> Result<Json<Session>, ApiError> {
|
||||||
let actor = actor(&state).await?;
|
|
||||||
let row = scoped_session_row(&state, &actor, &id)
|
let row = scoped_session_row(&state, &actor, &id)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
|
|
@ -175,10 +168,10 @@ async fn get_session(
|
||||||
|
|
||||||
async fn update_session(
|
async fn update_session(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
actor: Actor,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
Json(input): Json<UpdateSessionInput>,
|
Json(input): Json<UpdateSessionInput>,
|
||||||
) -> Result<Json<Session>, ApiError> {
|
) -> Result<Json<Session>, ApiError> {
|
||||||
let actor = actor(&state).await?;
|
|
||||||
if input
|
if input
|
||||||
.status
|
.status
|
||||||
.as_deref()
|
.as_deref()
|
||||||
|
|
@ -216,9 +209,9 @@ async fn update_session(
|
||||||
|
|
||||||
async fn delete_session(
|
async fn delete_session(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
actor: Actor,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> Result<StatusCode, ApiError> {
|
) -> Result<StatusCode, ApiError> {
|
||||||
let actor = actor(&state).await?;
|
|
||||||
if scoped_session_row(&state, &actor, &id).await?.is_none() {
|
if scoped_session_row(&state, &actor, &id).await?.is_none() {
|
||||||
return Err((
|
return Err((
|
||||||
StatusCode::NOT_FOUND,
|
StatusCode::NOT_FOUND,
|
||||||
|
|
@ -272,17 +265,17 @@ async fn delete_session(
|
||||||
|
|
||||||
async fn list_messages(
|
async fn list_messages(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
actor: Actor,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> Result<Json<Vec<SessionMessage>>, ApiError> {
|
) -> Result<Json<Vec<SessionMessage>>, ApiError> {
|
||||||
let actor = actor(&state).await?;
|
|
||||||
Ok(Json(messages_for_session(&state, &actor, &id).await?))
|
Ok(Json(messages_for_session(&state, &actor, &id).await?))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn clear_messages(
|
async fn clear_messages(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
actor: Actor,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> Result<Json<Value>, ApiError> {
|
) -> Result<Json<Value>, ApiError> {
|
||||||
let actor = actor(&state).await?;
|
|
||||||
if scoped_session_row(&state, &actor, &id).await?.is_none() {
|
if scoped_session_row(&state, &actor, &id).await?.is_none() {
|
||||||
return Err((
|
return Err((
|
||||||
StatusCode::NOT_FOUND,
|
StatusCode::NOT_FOUND,
|
||||||
|
|
@ -321,10 +314,10 @@ async fn clear_messages(
|
||||||
|
|
||||||
async fn send_message(
|
async fn send_message(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
actor: Actor,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
Json(input): Json<SendSessionMessageInput>,
|
Json(input): Json<SendSessionMessageInput>,
|
||||||
) -> Result<(StatusCode, Json<Value>), ApiError> {
|
) -> Result<(StatusCode, Json<Value>), ApiError> {
|
||||||
let actor = actor(&state).await?;
|
|
||||||
if input.text.trim().is_empty() && input.attachments.is_empty() {
|
if input.text.trim().is_empty() && input.attachments.is_empty() {
|
||||||
return Err((
|
return Err((
|
||||||
StatusCode::BAD_REQUEST,
|
StatusCode::BAD_REQUEST,
|
||||||
|
|
@ -356,9 +349,9 @@ async fn send_message(
|
||||||
|
|
||||||
async fn stop_session(
|
async fn stop_session(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
actor: Actor,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> Result<Json<Value>, ApiError> {
|
) -> Result<Json<Value>, ApiError> {
|
||||||
let actor = actor(&state).await?;
|
|
||||||
if scoped_session_row(&state, &actor, &id).await?.is_none() {
|
if scoped_session_row(&state, &actor, &id).await?.is_none() {
|
||||||
return Err((
|
return Err((
|
||||||
StatusCode::NOT_FOUND,
|
StatusCode::NOT_FOUND,
|
||||||
|
|
@ -371,10 +364,10 @@ async fn stop_session(
|
||||||
|
|
||||||
async fn events(
|
async fn events(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
actor: Actor,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
) -> Result<Sse<impl futures_util::Stream<Item = Result<Event, Infallible>>>, ApiError> {
|
) -> Result<Sse<impl futures_util::Stream<Item = Result<Event, Infallible>>>, ApiError> {
|
||||||
let actor = actor(&state).await?;
|
|
||||||
if scoped_session_row(&state, &actor, &id).await?.is_none() {
|
if scoped_session_row(&state, &actor, &id).await?.is_none() {
|
||||||
return Err((
|
return Err((
|
||||||
StatusCode::NOT_FOUND,
|
StatusCode::NOT_FOUND,
|
||||||
|
|
|
||||||
|
|
@ -121,13 +121,6 @@ const COLUMNS: &str = "id, bot_id, thread_id, name, goal, status, playbook, reco
|
||||||
const SELECT: &str = "SELECT id, bot_id, thread_id, name, goal, status, playbook, recording, error,
|
const SELECT: &str = "SELECT id, bot_id, thread_id, name, goal, status, playbook, recording, error,
|
||||||
started_at, expires_at, stopped_at, created_at, updated_at FROM taught_skills";
|
started_at, expires_at, stopped_at, created_at, updated_at FROM taught_skills";
|
||||||
|
|
||||||
async fn actor(state: &AppState) -> Result<Actor, ApiError> {
|
|
||||||
state
|
|
||||||
.bootstrap()
|
|
||||||
.await
|
|
||||||
.map_err(|error| internal(error.to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn internal(message: String) -> ApiError {
|
fn internal(message: String) -> ApiError {
|
||||||
tracing::error!("skills: {message}");
|
tracing::error!("skills: {message}");
|
||||||
(
|
(
|
||||||
|
|
@ -183,9 +176,9 @@ pub async fn recording_skill(pool: &PgPool, bot_id: &str) -> Option<SkillRow> {
|
||||||
|
|
||||||
async fn list_skills(
|
async fn list_skills(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
actor: Actor,
|
||||||
Path(bot_id): Path<String>,
|
Path(bot_id): Path<String>,
|
||||||
) -> Result<Json<Vec<Skill>>, ApiError> {
|
) -> Result<Json<Vec<Skill>>, ApiError> {
|
||||||
let actor = actor(&state).await?;
|
|
||||||
let rows = sqlx::query_as::<_, SkillRow>(&format!(
|
let rows = sqlx::query_as::<_, SkillRow>(&format!(
|
||||||
"{SELECT} WHERE bot_id = $1 AND space_id = $2 AND user_id = $3
|
"{SELECT} WHERE bot_id = $1 AND space_id = $2 AND user_id = $3
|
||||||
ORDER BY CASE status WHEN 'recording' THEN 0 WHEN 'drafting' THEN 1 WHEN 'draft' THEN 2 ELSE 3 END,
|
ORDER BY CASE status WHEN 'recording' THEN 0 WHEN 'drafting' THEN 1 WHEN 'draft' THEN 2 ELSE 3 END,
|
||||||
|
|
@ -207,10 +200,10 @@ struct StartBody {
|
||||||
|
|
||||||
async fn start_skill(
|
async fn start_skill(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
actor: Actor,
|
||||||
Path(bot_id): Path<String>,
|
Path(bot_id): Path<String>,
|
||||||
Json(body): Json<StartBody>,
|
Json(body): Json<StartBody>,
|
||||||
) -> Result<Json<Skill>, ApiError> {
|
) -> Result<Json<Skill>, ApiError> {
|
||||||
let actor = actor(&state).await?;
|
|
||||||
let goal = body.goal.trim().to_string();
|
let goal = body.goal.trim().to_string();
|
||||||
if goal.is_empty() {
|
if goal.is_empty() {
|
||||||
return Err(bad_request("goal required"));
|
return Err(bad_request("goal required"));
|
||||||
|
|
@ -319,9 +312,9 @@ async fn start_skill(
|
||||||
|
|
||||||
async fn stop_skill(
|
async fn stop_skill(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
actor: Actor,
|
||||||
Path(bot_id): Path<String>,
|
Path(bot_id): Path<String>,
|
||||||
) -> Result<Json<Skill>, ApiError> {
|
) -> Result<Json<Skill>, ApiError> {
|
||||||
let actor = actor(&state).await?;
|
|
||||||
let row = recording_skill(state.pool(), &bot_id)
|
let row = recording_skill(state.pool(), &bot_id)
|
||||||
.await
|
.await
|
||||||
.ok_or_else(|| bad_request("not recording"))?;
|
.ok_or_else(|| bad_request("not recording"))?;
|
||||||
|
|
@ -350,9 +343,9 @@ async fn stop_skill(
|
||||||
|
|
||||||
async fn cancel_skill(
|
async fn cancel_skill(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
actor: Actor,
|
||||||
Path(bot_id): Path<String>,
|
Path(bot_id): Path<String>,
|
||||||
) -> Result<Json<Value>, ApiError> {
|
) -> Result<Json<Value>, ApiError> {
|
||||||
let actor = actor(&state).await?;
|
|
||||||
let Some(row) = recording_skill(state.pool(), &bot_id).await else {
|
let Some(row) = recording_skill(state.pool(), &bot_id).await else {
|
||||||
return Ok(Json(json!({ "ok": true })));
|
return Ok(Json(json!({ "ok": true })));
|
||||||
};
|
};
|
||||||
|
|
@ -385,10 +378,10 @@ struct UpdateBody {
|
||||||
|
|
||||||
async fn update_skill(
|
async fn update_skill(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
actor: Actor,
|
||||||
Path(skill_id): Path<String>,
|
Path(skill_id): Path<String>,
|
||||||
Json(body): Json<UpdateBody>,
|
Json(body): Json<UpdateBody>,
|
||||||
) -> Result<Json<Skill>, ApiError> {
|
) -> Result<Json<Skill>, ApiError> {
|
||||||
let actor = actor(&state).await?;
|
|
||||||
let row = load_skill(&state, &actor, &skill_id).await?;
|
let row = load_skill(&state, &actor, &skill_id).await?;
|
||||||
if !matches!(row.status.as_str(), "draft" | "saved") {
|
if !matches!(row.status.as_str(), "draft" | "saved") {
|
||||||
return Err(conflict("skill is not ready yet"));
|
return Err(conflict("skill is not ready yet"));
|
||||||
|
|
@ -430,9 +423,9 @@ async fn update_skill(
|
||||||
|
|
||||||
async fn delete_skill(
|
async fn delete_skill(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
actor: Actor,
|
||||||
Path(skill_id): Path<String>,
|
Path(skill_id): Path<String>,
|
||||||
) -> Result<Json<Value>, ApiError> {
|
) -> Result<Json<Value>, ApiError> {
|
||||||
let actor = actor(&state).await?;
|
|
||||||
let row = load_skill(&state, &actor, &skill_id).await?;
|
let row = load_skill(&state, &actor, &skill_id).await?;
|
||||||
if row.status == "recording" {
|
if row.status == "recording" {
|
||||||
return Err(conflict("stop or cancel the recording first"));
|
return Err(conflict("stop or cancel the recording first"));
|
||||||
|
|
@ -448,9 +441,9 @@ async fn delete_skill(
|
||||||
|
|
||||||
async fn test_skill(
|
async fn test_skill(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
actor: Actor,
|
||||||
Path(skill_id): Path<String>,
|
Path(skill_id): Path<String>,
|
||||||
) -> Result<Json<Value>, ApiError> {
|
) -> Result<Json<Value>, ApiError> {
|
||||||
let actor = actor(&state).await?;
|
|
||||||
let row = load_skill(&state, &actor, &skill_id).await?;
|
let row = load_skill(&state, &actor, &skill_id).await?;
|
||||||
if !matches!(row.status.as_str(), "draft" | "saved") {
|
if !matches!(row.status.as_str(), "draft" | "saved") {
|
||||||
return Err(conflict("skill is not ready yet"));
|
return Err(conflict("skill is not ready yet"));
|
||||||
|
|
@ -661,9 +654,9 @@ fn export_filename_ascii(name: &str) -> String {
|
||||||
|
|
||||||
async fn export_skill(
|
async fn export_skill(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
actor: Actor,
|
||||||
Path(skill_id): Path<String>,
|
Path(skill_id): Path<String>,
|
||||||
) -> Result<(HeaderMap, Json<Value>), ApiError> {
|
) -> Result<(HeaderMap, Json<Value>), ApiError> {
|
||||||
let actor = actor(&state).await?;
|
|
||||||
let row = load_skill(&state, &actor, &skill_id).await?;
|
let row = load_skill(&state, &actor, &skill_id).await?;
|
||||||
if !matches!(row.status.as_str(), "draft" | "saved") {
|
if !matches!(row.status.as_str(), "draft" | "saved") {
|
||||||
return Err(conflict("skill is not ready yet"));
|
return Err(conflict("skill is not ready yet"));
|
||||||
|
|
@ -692,10 +685,10 @@ async fn export_skill(
|
||||||
|
|
||||||
async fn import_skill(
|
async fn import_skill(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
actor: Actor,
|
||||||
Path(bot_id): Path<String>,
|
Path(bot_id): Path<String>,
|
||||||
Json(body): Json<Value>,
|
Json(body): Json<Value>,
|
||||||
) -> Result<Json<Skill>, ApiError> {
|
) -> Result<Json<Skill>, ApiError> {
|
||||||
let actor = actor(&state).await?;
|
|
||||||
state
|
state
|
||||||
.db
|
.db
|
||||||
.get_bot(&actor, &bot_id)
|
.get_bot(&actor, &bot_id)
|
||||||
|
|
@ -1019,7 +1012,19 @@ async fn finalize(
|
||||||
|
|
||||||
let bot = state.db.get_bot(actor, &row.bot_id).await.ok().flatten();
|
let bot = state.db.get_bot(actor, &row.bot_id).await.ok().flatten();
|
||||||
let distilled = match bot {
|
let distilled = match bot {
|
||||||
Some(bot) => distill(state, actor, &bot, &row.goal, &events, &frames, &dir).await,
|
Some(bot) => {
|
||||||
|
distill(
|
||||||
|
state,
|
||||||
|
actor,
|
||||||
|
&bot,
|
||||||
|
&row.id,
|
||||||
|
&row.goal,
|
||||||
|
&events,
|
||||||
|
&frames,
|
||||||
|
&dir,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
None => Err("bot not found".to_string()),
|
None => Err("bot not found".to_string()),
|
||||||
};
|
};
|
||||||
let (playbook, error) = match distilled {
|
let (playbook, error) = match distilled {
|
||||||
|
|
@ -1266,12 +1271,13 @@ async fn distill(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
actor: &Actor,
|
actor: &Actor,
|
||||||
bot: &crate::db::BotRow,
|
bot: &crate::db::BotRow,
|
||||||
|
skill_id: &str,
|
||||||
goal: &str,
|
goal: &str,
|
||||||
events: &[Value],
|
events: &[Value],
|
||||||
frames: &[Value],
|
frames: &[Value],
|
||||||
dir: &std::path::Path,
|
dir: &std::path::Path,
|
||||||
) -> Result<Value, String> {
|
) -> Result<Value, String> {
|
||||||
let (model, vision) = crate::runs::bot_model(state, actor, bot).await?;
|
let (model, vision) = crate::runs::bot_model(state, actor, bot, skill_id).await?;
|
||||||
let t0 = events
|
let t0 = events
|
||||||
.iter()
|
.iter()
|
||||||
.chain(frames.iter())
|
.chain(frames.iter())
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ use sqlx::PgPool;
|
||||||
use sqlx::postgres::PgPoolOptions;
|
use sqlx::postgres::PgPoolOptions;
|
||||||
|
|
||||||
use crate::auth::AuthConfig;
|
use crate::auth::AuthConfig;
|
||||||
use crate::db::{Actor, Db};
|
use crate::db::Db;
|
||||||
use crate::mcp::McpHub;
|
use crate::mcp::McpHub;
|
||||||
use crate::memory::MemoryService;
|
use crate::memory::MemoryService;
|
||||||
|
|
||||||
|
|
@ -176,10 +176,6 @@ impl AppState {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn bootstrap(&self) -> Result<Actor, sqlx::Error> {
|
|
||||||
self.db.ensure_local_actor().await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn pool(&self) -> &PgPool {
|
pub fn pool(&self) -> &PgPool {
|
||||||
&self.db.pool
|
&self.db.pool
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -55,25 +55,29 @@ pub fn router() -> Router<AppState> {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn scoped_actor(state: &AppState, bot_id: &str) -> Result<Actor, StatusCode> {
|
/// An id in a URL is not permission. Anything addressed by `bot_id` checks
|
||||||
let actor = state
|
/// that the agent lives in the caller's own workspace first, so guessing an id
|
||||||
.bootstrap()
|
/// cannot reach another person's memory, vault, or schedule.
|
||||||
.await
|
async fn assert_bot_is_mine(
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
state: &AppState,
|
||||||
|
actor: &Actor,
|
||||||
|
bot_id: &str,
|
||||||
|
) -> Result<(), StatusCode> {
|
||||||
state
|
state
|
||||||
.db
|
.db
|
||||||
.get_bot(&actor, bot_id)
|
.get_bot(actor, bot_id)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||||
.ok_or(StatusCode::NOT_FOUND)?;
|
.ok_or(StatusCode::NOT_FOUND)?;
|
||||||
Ok(actor)
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_accounts(
|
async fn list_accounts(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
actor: Actor,
|
||||||
Path(bot_id): Path<String>,
|
Path(bot_id): Path<String>,
|
||||||
) -> Result<Json<Vec<VaultAccount>>, StatusCode> {
|
) -> Result<Json<Vec<VaultAccount>>, StatusCode> {
|
||||||
let actor = scoped_actor(&state, &bot_id).await?;
|
assert_bot_is_mine(&state, &actor, &bot_id).await?;
|
||||||
list(&state, &actor, &bot_id)
|
list(&state, &actor, &bot_id)
|
||||||
.await
|
.await
|
||||||
.map(Json)
|
.map(Json)
|
||||||
|
|
@ -82,10 +86,11 @@ async fn list_accounts(
|
||||||
|
|
||||||
async fn create_account(
|
async fn create_account(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
actor: Actor,
|
||||||
Path(bot_id): Path<String>,
|
Path(bot_id): Path<String>,
|
||||||
Json(input): Json<UpsertAccount>,
|
Json(input): Json<UpsertAccount>,
|
||||||
) -> Result<Json<VaultAccount>, (StatusCode, Json<Value>)> {
|
) -> Result<Json<VaultAccount>, (StatusCode, Json<Value>)> {
|
||||||
let actor = scoped_actor(&state, &bot_id)
|
assert_bot_is_mine(&state, &actor, &bot_id)
|
||||||
.await
|
.await
|
||||||
.map_err(|status| (status, Json(json!({"message":"bot not found"}))))?;
|
.map_err(|status| (status, Json(json!({"message":"bot not found"}))))?;
|
||||||
insert(&state, &actor, &bot_id, input)
|
insert(&state, &actor, &bot_id, input)
|
||||||
|
|
@ -96,10 +101,11 @@ async fn create_account(
|
||||||
|
|
||||||
async fn update_account(
|
async fn update_account(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
actor: Actor,
|
||||||
Path((bot_id, account_id)): Path<(String, String)>,
|
Path((bot_id, account_id)): Path<(String, String)>,
|
||||||
Json(input): Json<UpsertAccount>,
|
Json(input): Json<UpsertAccount>,
|
||||||
) -> Result<Json<VaultAccount>, (StatusCode, Json<Value>)> {
|
) -> Result<Json<VaultAccount>, (StatusCode, Json<Value>)> {
|
||||||
let actor = scoped_actor(&state, &bot_id)
|
assert_bot_is_mine(&state, &actor, &bot_id)
|
||||||
.await
|
.await
|
||||||
.map_err(|status| (status, Json(json!({"message":"bot not found"}))))?;
|
.map_err(|status| (status, Json(json!({"message":"bot not found"}))))?;
|
||||||
update_row(&state, &actor, &bot_id, &account_id, input)
|
update_row(&state, &actor, &bot_id, &account_id, input)
|
||||||
|
|
@ -114,9 +120,10 @@ async fn update_account(
|
||||||
|
|
||||||
async fn delete_account(
|
async fn delete_account(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
actor: Actor,
|
||||||
Path((bot_id, account_id)): Path<(String, String)>,
|
Path((bot_id, account_id)): Path<(String, String)>,
|
||||||
) -> Result<StatusCode, StatusCode> {
|
) -> Result<StatusCode, StatusCode> {
|
||||||
let actor = scoped_actor(&state, &bot_id).await?;
|
assert_bot_is_mine(&state, &actor, &bot_id).await?;
|
||||||
let deleted = sqlx::query(
|
let deleted = sqlx::query(
|
||||||
"DELETE FROM vault_accounts
|
"DELETE FROM vault_accounts
|
||||||
WHERE id=$1 AND bot_id=$2 AND space_id=$3 AND user_id=$4",
|
WHERE id=$1 AND bot_id=$2 AND space_id=$3 AND user_id=$4",
|
||||||
|
|
@ -328,17 +335,13 @@ fn normalize_host(host: &str, site: &str) -> String {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn vault_key() -> Result<[u8; 32], String> {
|
fn vault_key() -> Result<[u8; 32], String> {
|
||||||
|
// The vault key is its own secret. It used to fall back to the old shared
|
||||||
|
// login token, which quietly mixed "who can sign in" with "what can be
|
||||||
|
// decrypted"; saved passwords stay locked until it is set on purpose.
|
||||||
let material = std::env::var("LAZYBOY_VAULT_KEY")
|
let material = std::env::var("LAZYBOY_VAULT_KEY")
|
||||||
.ok()
|
.ok()
|
||||||
.filter(|value| !value.is_empty())
|
.filter(|value| !value.is_empty())
|
||||||
.or_else(|| {
|
.ok_or_else(|| "set LAZYBOY_VAULT_KEY to encrypt saved passwords".to_string())?;
|
||||||
std::env::var("LAZYBOY_APP_TOKEN")
|
|
||||||
.ok()
|
|
||||||
.filter(|v| !v.is_empty())
|
|
||||||
})
|
|
||||||
.ok_or_else(|| {
|
|
||||||
"set LAZYBOY_VAULT_KEY or LAZYBOY_APP_TOKEN to encrypt saved passwords".to_string()
|
|
||||||
})?;
|
|
||||||
let digest = Sha256::digest(material.as_bytes());
|
let digest = Sha256::digest(material.as_bytes());
|
||||||
let mut key = [0u8; 32];
|
let mut key = [0u8; 32];
|
||||||
key.copy_from_slice(&digest);
|
key.copy_from_slice(&digest);
|
||||||
|
|
|
||||||
|
|
@ -22,19 +22,15 @@ pub fn router() -> Router<AppState> {
|
||||||
.route("/api/sessions/{id}/call", get(voice_call::call_ws))
|
.route("/api/sessions/{id}/call", get(voice_call::call_ws))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn actor(state: &AppState) -> Result<Actor, StatusCode> {
|
/// A voice key comes from settings and nowhere else: the key saved for voice,
|
||||||
state
|
/// or the text key when voice uses the same provider. An unset key stays unset
|
||||||
.bootstrap()
|
/// so a call fails with "go to 設定 → 語音" instead of borrowing a key that
|
||||||
.await
|
/// happens to live in the server environment.
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn voice_credential_chain(
|
pub fn voice_credential_chain(
|
||||||
provider: VoiceProvider,
|
provider: VoiceProvider,
|
||||||
voice_api_key: Option<&str>,
|
voice_api_key: Option<&str>,
|
||||||
text_provider: &str,
|
text_provider: &str,
|
||||||
text_api_key: Option<&str>,
|
text_api_key: Option<&str>,
|
||||||
env_key: Option<&str>,
|
|
||||||
) -> CredentialChain {
|
) -> CredentialChain {
|
||||||
let nonempty = |value: Option<&str>| {
|
let nonempty = |value: Option<&str>| {
|
||||||
value
|
value
|
||||||
|
|
@ -49,11 +45,7 @@ pub fn voice_credential_chain(
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
CredentialChain {
|
CredentialChain { bot: None, space }
|
||||||
bot: None,
|
|
||||||
space,
|
|
||||||
env: nonempty(env_key),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn voice_instructions(bot_name: &str, bot_instructions: &str) -> String {
|
pub fn voice_instructions(bot_name: &str, bot_instructions: &str) -> String {
|
||||||
|
|
@ -92,15 +84,11 @@ fn settings_json(space: &SpaceRow) -> Value {
|
||||||
} else {
|
} else {
|
||||||
requested
|
requested
|
||||||
};
|
};
|
||||||
let env_key = std::env::var(provider.env_key_name())
|
|
||||||
.ok()
|
|
||||||
.filter(|value| !value.is_empty());
|
|
||||||
let credentials = voice_credential_chain(
|
let credentials = voice_credential_chain(
|
||||||
provider,
|
provider,
|
||||||
space.voice_api_key.as_deref(),
|
space.voice_api_key.as_deref(),
|
||||||
&space.default_model_provider,
|
&space.default_model_provider,
|
||||||
space.default_model_api_key.as_deref(),
|
space.default_model_api_key.as_deref(),
|
||||||
env_key.as_deref(),
|
|
||||||
);
|
);
|
||||||
let resolved = resolve_voice(ResolveVoiceRequest {
|
let resolved = resolve_voice(ResolveVoiceRequest {
|
||||||
provider,
|
provider,
|
||||||
|
|
@ -134,8 +122,6 @@ fn settings_json(space: &SpaceRow) -> Value {
|
||||||
"ready": space.voice_enabled && ready,
|
"ready": space.voice_enabled && ready,
|
||||||
"missing": missing,
|
"missing": missing,
|
||||||
"apiKeySet": space.voice_api_key.as_deref().is_some_and(|value| !value.is_empty()),
|
"apiKeySet": space.voice_api_key.as_deref().is_some_and(|value| !value.is_empty()),
|
||||||
"envKeySet": env_key.is_some(),
|
|
||||||
"envKeyName": provider.env_key_name(),
|
|
||||||
"reusesTextKey": provider.as_str() == space.default_model_provider
|
"reusesTextKey": provider.as_str() == space.default_model_provider
|
||||||
&& space.voice_api_key.as_deref().is_none_or(|value| value.is_empty())
|
&& space.voice_api_key.as_deref().is_none_or(|value| value.is_empty())
|
||||||
&& space.default_model_api_key.as_deref().is_some_and(|value| !value.is_empty()),
|
&& space.default_model_api_key.as_deref().is_some_and(|value| !value.is_empty()),
|
||||||
|
|
@ -146,7 +132,6 @@ fn settings_json(space: &SpaceRow) -> Value {
|
||||||
VoiceProvider::Openai => "OpenAI",
|
VoiceProvider::Openai => "OpenAI",
|
||||||
VoiceProvider::Scripted => "Scripted",
|
VoiceProvider::Scripted => "Scripted",
|
||||||
},
|
},
|
||||||
"envKeyName": item.env_key_name(),
|
|
||||||
})).collect::<Vec<_>>(),
|
})).collect::<Vec<_>>(),
|
||||||
"models": catalog.models.iter().map(|item| json!({"id": item.id, "name": item.name})).collect::<Vec<_>>(),
|
"models": catalog.models.iter().map(|item| json!({"id": item.id, "name": item.name})).collect::<Vec<_>>(),
|
||||||
"voices": catalog.voices.iter().map(|item| json!({"id": item.id, "name": item.name})).collect::<Vec<_>>(),
|
"voices": catalog.voices.iter().map(|item| json!({"id": item.id, "name": item.name})).collect::<Vec<_>>(),
|
||||||
|
|
@ -154,17 +139,25 @@ fn settings_json(space: &SpaceRow) -> Value {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_settings(State(state): State<AppState>) -> Result<Json<Value>, StatusCode> {
|
/// Reading the settings twice is cheaper than keeping two shapes in sync, so
|
||||||
let actor = actor(&state).await?;
|
/// a save answers with the same body a GET returns.
|
||||||
|
async fn settings_payload(state: &AppState, actor: &Actor) -> Result<Json<Value>, StatusCode> {
|
||||||
let space = state
|
let space = state
|
||||||
.db
|
.db
|
||||||
.get_space(&actor)
|
.get_space(actor)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||||
.ok_or(StatusCode::NOT_FOUND)?;
|
.ok_or(StatusCode::NOT_FOUND)?;
|
||||||
Ok(Json(settings_json(&space)))
|
Ok(Json(settings_json(&space)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_settings(
|
||||||
|
actor: Actor,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> Result<Json<Value>, StatusCode> {
|
||||||
|
settings_payload(&state, &actor).await
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct UpdateVoiceSettings {
|
struct UpdateVoiceSettings {
|
||||||
|
|
@ -181,9 +174,9 @@ struct UpdateVoiceSettings {
|
||||||
|
|
||||||
async fn update_settings(
|
async fn update_settings(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
actor: Actor,
|
||||||
Json(input): Json<UpdateVoiceSettings>,
|
Json(input): Json<UpdateVoiceSettings>,
|
||||||
) -> Result<Json<Value>, StatusCode> {
|
) -> Result<Json<Value>, StatusCode> {
|
||||||
let actor = actor(&state).await?;
|
|
||||||
let provider: VoiceProvider = input
|
let provider: VoiceProvider = input
|
||||||
.provider
|
.provider
|
||||||
.parse()
|
.parse()
|
||||||
|
|
@ -225,7 +218,7 @@ async fn update_settings(
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
get_settings(State(state)).await
|
settings_payload(&state, &actor).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn resolve_space_voice(
|
pub fn resolve_space_voice(
|
||||||
|
|
@ -245,15 +238,11 @@ pub fn resolve_space_voice(
|
||||||
} else {
|
} else {
|
||||||
requested
|
requested
|
||||||
};
|
};
|
||||||
let env_key = std::env::var(provider.env_key_name())
|
|
||||||
.ok()
|
|
||||||
.filter(|value| !value.is_empty());
|
|
||||||
let credentials = voice_credential_chain(
|
let credentials = voice_credential_chain(
|
||||||
provider,
|
provider,
|
||||||
space.voice_api_key.as_deref(),
|
space.voice_api_key.as_deref(),
|
||||||
&space.default_model_provider,
|
&space.default_model_provider,
|
||||||
space.default_model_api_key.as_deref(),
|
space.default_model_api_key.as_deref(),
|
||||||
env_key.as_deref(),
|
|
||||||
);
|
);
|
||||||
let resolved = resolve_voice(ResolveVoiceRequest {
|
let resolved = resolve_voice(ResolveVoiceRequest {
|
||||||
provider,
|
provider,
|
||||||
|
|
@ -271,30 +260,19 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn voice_key_reuses_matching_text_provider_and_does_not_cross_providers() {
|
fn voice_key_reuses_matching_text_provider_and_does_not_cross_providers() {
|
||||||
let reused = voice_credential_chain(
|
let reused = voice_credential_chain(VoiceProvider::Xai, None, "xai", Some("text-key"));
|
||||||
VoiceProvider::Xai,
|
|
||||||
None,
|
|
||||||
"xai",
|
|
||||||
Some("text-key"),
|
|
||||||
Some("env-key"),
|
|
||||||
);
|
|
||||||
assert_eq!(reused.resolve(), Some("text-key"));
|
assert_eq!(reused.resolve(), Some("text-key"));
|
||||||
|
|
||||||
let env_only = voice_credential_chain(
|
// Another provider's text key is not a voice key, and nothing else is
|
||||||
VoiceProvider::Xai,
|
// reached for: no key means the call is refused, not silently guessed.
|
||||||
None,
|
let none = voice_credential_chain(VoiceProvider::Xai, None, "opencode-go", Some("go-key"));
|
||||||
"opencode-go",
|
assert_eq!(none.resolve(), None);
|
||||||
Some("go-key"),
|
|
||||||
Some("env-xai"),
|
|
||||||
);
|
|
||||||
assert_eq!(env_only.resolve(), Some("env-xai"));
|
|
||||||
|
|
||||||
let dedicated = voice_credential_chain(
|
let dedicated = voice_credential_chain(
|
||||||
VoiceProvider::Openai,
|
VoiceProvider::Openai,
|
||||||
Some("voice-openai"),
|
Some("voice-openai"),
|
||||||
"xai",
|
"xai",
|
||||||
Some("text-xai"),
|
Some("text-xai"),
|
||||||
Some("env-openai"),
|
|
||||||
);
|
);
|
||||||
assert_eq!(dedicated.resolve(), Some("voice-openai"));
|
assert_eq!(dedicated.resolve(), Some("voice-openai"));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,19 +27,10 @@ struct PreparedCall {
|
||||||
|
|
||||||
pub async fn call_ws(
|
pub async fn call_ws(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
actor: Actor,
|
||||||
Path(session_id): Path<String>,
|
Path(session_id): Path<String>,
|
||||||
ws: WebSocketUpgrade,
|
ws: WebSocketUpgrade,
|
||||||
) -> axum::response::Response {
|
) -> axum::response::Response {
|
||||||
let actor = match state.bootstrap().await {
|
|
||||||
Ok(actor) => actor,
|
|
||||||
Err(_) => {
|
|
||||||
return (
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
Json(json!({"message":"internal error"})),
|
|
||||||
)
|
|
||||||
.into_response();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
match prepare_call(&state, &actor, &session_id).await {
|
match prepare_call(&state, &actor, &session_id).await {
|
||||||
Ok(prep) => {
|
Ok(prep) => {
|
||||||
let Some(lease) = state.calls.try_begin(&prep.bot_id, &prep.call_id) else {
|
let Some(lease) = state.calls.try_begin(&prep.bot_id, &prep.call_id) else {
|
||||||
|
|
|
||||||
|
|
@ -54,18 +54,13 @@ fn provider_info(provider: ModelProvider) -> ProviderInfo {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn actor(state: &AppState) -> Result<Actor, StatusCode> {
|
/// The model settings for one workspace. `apiKeySet` is the whole story now:
|
||||||
state
|
/// a key exists only if this person pasted one here, and while it is false no
|
||||||
.bootstrap()
|
/// agent can run.
|
||||||
.await
|
async fn settings_payload(state: &AppState, actor: &Actor) -> Result<Json<Value>, StatusCode> {
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_settings(State(state): State<AppState>) -> Result<Json<Value>, StatusCode> {
|
|
||||||
let actor = actor(&state).await?;
|
|
||||||
let space = state
|
let space = state
|
||||||
.db
|
.db
|
||||||
.get_space(&actor)
|
.get_space(actor)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||||
.ok_or(StatusCode::NOT_FOUND)?;
|
.ok_or(StatusCode::NOT_FOUND)?;
|
||||||
|
|
@ -73,17 +68,11 @@ async fn get_settings(State(state): State<AppState>) -> Result<Json<Value>, Stat
|
||||||
.default_model_provider
|
.default_model_provider
|
||||||
.parse::<ModelProvider>()
|
.parse::<ModelProvider>()
|
||||||
.unwrap_or(ModelProvider::Xai);
|
.unwrap_or(ModelProvider::Xai);
|
||||||
let env_key_set = std::env::var(provider.env_key_name())
|
|
||||||
.ok()
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
.is_some();
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
"provider": provider.as_str(),
|
"provider": provider.as_str(),
|
||||||
"modelId": space.default_model_id,
|
"modelId": space.default_model_id,
|
||||||
"baseUrl": space.default_model_base_url.unwrap_or_default(),
|
"baseUrl": space.default_model_base_url.unwrap_or_default(),
|
||||||
"apiKeySet": space.default_model_api_key.as_deref().is_some_and(|value| !value.is_empty()),
|
"apiKeySet": space.default_model_api_key.as_deref().is_some_and(|value| !value.is_empty()),
|
||||||
"envKeySet": env_key_set,
|
|
||||||
"envKeyName": provider.env_key_name(),
|
|
||||||
"providers": ModelProvider::selectable().iter().copied().map(provider_info).collect::<Vec<_>>(),
|
"providers": ModelProvider::selectable().iter().copied().map(provider_info).collect::<Vec<_>>(),
|
||||||
"models": catalog_models(provider).iter().map(|(id, name)| ModelChoice { id: (*id).into(), name: (*name).into() }).collect::<Vec<_>>(),
|
"models": catalog_models(provider).iter().map(|(id, name)| ModelChoice { id: (*id).into(), name: (*name).into() }).collect::<Vec<_>>(),
|
||||||
})))
|
})))
|
||||||
|
|
@ -104,9 +93,9 @@ struct UpdateSettings {
|
||||||
|
|
||||||
async fn update_settings(
|
async fn update_settings(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
actor: Actor,
|
||||||
Json(input): Json<UpdateSettings>,
|
Json(input): Json<UpdateSettings>,
|
||||||
) -> Result<Json<Value>, StatusCode> {
|
) -> Result<Json<Value>, StatusCode> {
|
||||||
let actor = actor(&state).await?;
|
|
||||||
let provider = input
|
let provider = input
|
||||||
.provider
|
.provider
|
||||||
.parse::<ModelProvider>()
|
.parse::<ModelProvider>()
|
||||||
|
|
@ -139,9 +128,9 @@ async fn update_settings(
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
.filter(|value| !value.is_empty());
|
.filter(|value| !value.is_empty());
|
||||||
// A stored key belongs to the provider it was entered for. Carrying it
|
// A stored key belongs to the provider it was entered for. Carrying one
|
||||||
// over to a new provider shadows the env key and every run fails with
|
// over to a different provider only guarantees an "Incorrect API key"
|
||||||
// "Incorrect API key", so drop it unless a new one is supplied.
|
// error, so it is dropped unless a new key is supplied.
|
||||||
let api_key = if input.clear_api_key || (provider_changed && supplied.is_none()) {
|
let api_key = if input.clear_api_key || (provider_changed && supplied.is_none()) {
|
||||||
Some(None)
|
Some(None)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -152,7 +141,14 @@ async fn update_settings(
|
||||||
.update_workspace_model(&actor, provider.as_str(), model_id, base_url, api_key)
|
.update_workspace_model(&actor, provider.as_str(), model_id, base_url, api_key)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
get_settings(State(state)).await
|
settings_payload(&state, &actor).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_settings(
|
||||||
|
actor: Actor,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> Result<Json<Value>, StatusCode> {
|
||||||
|
settings_payload(&state, &actor).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
|
|
@ -165,9 +161,9 @@ struct ModelsQuery {
|
||||||
|
|
||||||
async fn list_models(
|
async fn list_models(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
actor: Actor,
|
||||||
Query(query): Query<ModelsQuery>,
|
Query(query): Query<ModelsQuery>,
|
||||||
) -> Result<Json<Value>, StatusCode> {
|
) -> Result<Json<Value>, StatusCode> {
|
||||||
let actor = actor(&state).await?;
|
|
||||||
let provider = query
|
let provider = query
|
||||||
.provider
|
.provider
|
||||||
.parse::<ModelProvider>()
|
.parse::<ModelProvider>()
|
||||||
|
|
@ -180,9 +176,6 @@ async fn list_models(
|
||||||
name: (*name).into(),
|
name: (*name).into(),
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
let env_key = std::env::var(provider.env_key_name())
|
|
||||||
.ok()
|
|
||||||
.filter(|value| !value.is_empty());
|
|
||||||
let live = fetch_remote_models(
|
let live = fetch_remote_models(
|
||||||
provider,
|
provider,
|
||||||
query.base_url.as_deref().or(space
|
query.base_url.as_deref().or(space
|
||||||
|
|
@ -190,8 +183,7 @@ async fn list_models(
|
||||||
.and_then(|row| row.default_model_base_url.as_deref())),
|
.and_then(|row| row.default_model_base_url.as_deref())),
|
||||||
space
|
space
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|row| row.default_model_api_key.as_deref())
|
.and_then(|row| row.default_model_api_key.as_deref()),
|
||||||
.or(env_key.as_deref()),
|
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
|
||||||
|
|
@ -24,16 +24,6 @@ impl ModelProvider {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn env_key_name(self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
Self::Xai => "XAI_API_KEY",
|
|
||||||
Self::OpencodeGo => "OPENCODE_GO_API_KEY",
|
|
||||||
Self::OpenaiCompatible | Self::Openai => "OPENAI_API_KEY",
|
|
||||||
Self::Anthropic => "ANTHROPIC_API_KEY",
|
|
||||||
Self::Openrouter => "OPENROUTER_API_KEY",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn requires_api_key(self) -> bool {
|
pub fn requires_api_key(self) -> bool {
|
||||||
!matches!(self, Self::OpenaiCompatible)
|
!matches!(self, Self::OpenaiCompatible)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,14 +19,6 @@ impl VoiceProvider {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn env_key_name(self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
Self::Xai => "XAI_API_KEY",
|
|
||||||
Self::Openai => "OPENAI_API_KEY",
|
|
||||||
Self::Scripted => "",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn requires_api_key(self) -> bool {
|
pub fn requires_api_key(self) -> bool {
|
||||||
!matches!(self, Self::Scripted)
|
!matches!(self, Self::Scripted)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,8 @@ use thiserror::Error;
|
||||||
pub enum ModelError {
|
pub enum ModelError {
|
||||||
#[error("unsupported_provider:{provider}")]
|
#[error("unsupported_provider:{provider}")]
|
||||||
UnsupportedProvider { provider: String },
|
UnsupportedProvider { provider: String },
|
||||||
#[error("missing credential for {provider} ({env_key})")]
|
#[error("missing credential for {provider}: add the API key in the workspace settings")]
|
||||||
MissingCredential { provider: String, env_key: String },
|
MissingCredential { provider: String },
|
||||||
#[error("missing base URL for {provider}")]
|
#[error("missing base URL for {provider}")]
|
||||||
MissingBaseUrl { provider: String },
|
MissingBaseUrl { provider: String },
|
||||||
#[error("unknown model provider: {0}")]
|
#[error("unknown model provider: {0}")]
|
||||||
|
|
@ -17,11 +17,14 @@ pub enum ModelError {
|
||||||
ProviderClient(String),
|
ProviderClient(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Keys a run may use. Both levels come from inside LazyBoy — a bot's own
|
||||||
|
/// vault entry, then the workspace setting — and nothing is read from the
|
||||||
|
/// process environment, so an unset key is a clear "configure it" error rather
|
||||||
|
/// than a mystery key somebody exported in a shell.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct CredentialChain {
|
pub struct CredentialChain {
|
||||||
pub bot: Option<String>,
|
pub bot: Option<String>,
|
||||||
pub space: Option<String>,
|
pub space: Option<String>,
|
||||||
pub env: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CredentialChain {
|
impl CredentialChain {
|
||||||
|
|
@ -30,7 +33,6 @@ impl CredentialChain {
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.filter(|value| !value.is_empty())
|
.filter(|value| !value.is_empty())
|
||||||
.or(self.space.as_deref().filter(|value| !value.is_empty()))
|
.or(self.space.as_deref().filter(|value| !value.is_empty()))
|
||||||
.or(self.env.as_deref().filter(|value| !value.is_empty()))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -67,7 +69,6 @@ pub fn resolve_backend(request: ResolveModelRequest) -> Result<ResolvedBackend,
|
||||||
None if request.provider.requires_api_key() => {
|
None if request.provider.requires_api_key() => {
|
||||||
return Err(ModelError::MissingCredential {
|
return Err(ModelError::MissingCredential {
|
||||||
provider: request.provider.as_str().to_string(),
|
provider: request.provider.as_str().to_string(),
|
||||||
env_key: request.provider.env_key_name().to_string(),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
None => String::new(),
|
None => String::new(),
|
||||||
|
|
@ -127,7 +128,35 @@ fn uses_responses_api(provider: ModelProvider, model_id: &str) -> bool {
|
||||||
&& (id.starts_with("gpt-") || id.starts_with("grok-") || id.starts_with("muse-"))
|
&& (id.starts_with("gpt-") || id.starts_with("grok-") || id.starts_with("muse-"))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn connect_model(backend: &ResolvedBackend) -> Result<DynModel, ModelError> {
|
/// Headers OpenCode Go asks every client for: a self-identifying user agent
|
||||||
|
/// and a stable per-conversation id so it can route to the same backend and
|
||||||
|
/// keep the prompt cache warm. Requests without them are refused
|
||||||
|
/// (`MissingSessionID`). Harmless for a plain OpenAI-compatible server, so the
|
||||||
|
/// same headers go on both.
|
||||||
|
pub fn provider_headers(provider: ModelProvider, session: &str) -> http::HeaderMap {
|
||||||
|
let mut headers = http::HeaderMap::new();
|
||||||
|
if !matches!(
|
||||||
|
provider,
|
||||||
|
ModelProvider::OpencodeGo | ModelProvider::OpenaiCompatible
|
||||||
|
) {
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
headers.insert(
|
||||||
|
http::header::USER_AGENT,
|
||||||
|
http::HeaderValue::from_static(concat!("lazyboy/", env!("CARGO_PKG_VERSION"))),
|
||||||
|
);
|
||||||
|
let session = session.trim();
|
||||||
|
if !session.is_empty()
|
||||||
|
&& let Ok(value) = http::HeaderValue::from_str(session)
|
||||||
|
{
|
||||||
|
headers.insert(http::HeaderName::from_static("x-opencode-session"), value);
|
||||||
|
}
|
||||||
|
headers
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `session` is the conversation the model is being used for (thread, room,
|
||||||
|
/// or bot id); it only has to be stable across the requests of one exchange.
|
||||||
|
pub fn connect_model(backend: &ResolvedBackend, session: &str) -> Result<DynModel, ModelError> {
|
||||||
match backend.provider {
|
match backend.provider {
|
||||||
ModelProvider::Xai => {
|
ModelProvider::Xai => {
|
||||||
let client = xai::Client::new(&backend.api_key)
|
let client = xai::Client::new(&backend.api_key)
|
||||||
|
|
@ -140,10 +169,12 @@ pub fn connect_model(backend: &ResolvedBackend) -> Result<DynModel, ModelError>
|
||||||
} else {
|
} else {
|
||||||
backend.api_key.as_str()
|
backend.api_key.as_str()
|
||||||
};
|
};
|
||||||
|
let headers = provider_headers(backend.provider, session);
|
||||||
if uses_responses_api(backend.provider, &backend.model_id) {
|
if uses_responses_api(backend.provider, &backend.model_id) {
|
||||||
let client = openai::Client::builder()
|
let client = openai::Client::builder()
|
||||||
.api_key(key.to_string())
|
.api_key(key.to_string())
|
||||||
.base_url(&backend.base_url)
|
.base_url(&backend.base_url)
|
||||||
|
.http_headers(headers)
|
||||||
.build()
|
.build()
|
||||||
.map_err(|error| ModelError::ProviderClient(error.to_string()))?;
|
.map_err(|error| ModelError::ProviderClient(error.to_string()))?;
|
||||||
Ok(DynModel::OpenAiResponses(
|
Ok(DynModel::OpenAiResponses(
|
||||||
|
|
@ -153,6 +184,7 @@ pub fn connect_model(backend: &ResolvedBackend) -> Result<DynModel, ModelError>
|
||||||
let client = openai::CompletionsClient::builder()
|
let client = openai::CompletionsClient::builder()
|
||||||
.api_key(key.to_string())
|
.api_key(key.to_string())
|
||||||
.base_url(&backend.base_url)
|
.base_url(&backend.base_url)
|
||||||
|
.http_headers(headers)
|
||||||
.build()
|
.build()
|
||||||
.map_err(|error| ModelError::ProviderClient(error.to_string()))?;
|
.map_err(|error| ModelError::ProviderClient(error.to_string()))?;
|
||||||
Ok(DynModel::OpenAi(client.completion_model(&backend.model_id)))
|
Ok(DynModel::OpenAi(client.completion_model(&backend.model_id)))
|
||||||
|
|
@ -164,12 +196,6 @@ pub fn connect_model(backend: &ResolvedBackend) -> Result<DynModel, ModelError>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn credential_from_env(provider: ModelProvider) -> Option<String> {
|
|
||||||
std::env::var(provider.env_key_name())
|
|
||||||
.ok()
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Prove the xAI Rig client can be constructed from a resolved backend.
|
/// Prove the xAI Rig client can be constructed from a resolved backend.
|
||||||
pub fn connect_xai(
|
pub fn connect_xai(
|
||||||
backend: &ResolvedBackend,
|
backend: &ResolvedBackend,
|
||||||
|
|
@ -195,8 +221,7 @@ mod tests {
|
||||||
base_url: None,
|
base_url: None,
|
||||||
credentials: CredentialChain {
|
credentials: CredentialChain {
|
||||||
bot: None,
|
bot: None,
|
||||||
space: None,
|
space: key.map(str::to_string),
|
||||||
env: key.map(str::to_string),
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -212,7 +237,7 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn credentials_prefer_bot_then_space_then_env() {
|
fn credentials_prefer_the_bot_key_over_the_workspace_key() {
|
||||||
let backend = resolve_backend(ResolveModelRequest {
|
let backend = resolve_backend(ResolveModelRequest {
|
||||||
provider: ModelProvider::Xai,
|
provider: ModelProvider::Xai,
|
||||||
model_id: Some("grok-4.6".into()),
|
model_id: Some("grok-4.6".into()),
|
||||||
|
|
@ -220,7 +245,6 @@ mod tests {
|
||||||
credentials: CredentialChain {
|
credentials: CredentialChain {
|
||||||
bot: Some("bot-key".into()),
|
bot: Some("bot-key".into()),
|
||||||
space: Some("space-key".into()),
|
space: Some("space-key".into()),
|
||||||
env: Some("env-key".into()),
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
@ -246,8 +270,7 @@ mod tests {
|
||||||
base_url: None,
|
base_url: None,
|
||||||
credentials: CredentialChain {
|
credentials: CredentialChain {
|
||||||
bot: None,
|
bot: None,
|
||||||
space: None,
|
space: Some("sk-test".into()),
|
||||||
env: Some("sk-test".into()),
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
|
|
@ -274,14 +297,29 @@ mod tests {
|
||||||
base_url: None,
|
base_url: None,
|
||||||
credentials: CredentialChain {
|
credentials: CredentialChain {
|
||||||
bot: None,
|
bot: None,
|
||||||
space: None,
|
space: Some("go-key".into()),
|
||||||
env: Some("go-key".into()),
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(backend.model_id, "glm-5.1");
|
assert_eq!(backend.model_id, "glm-5.1");
|
||||||
assert_eq!(backend.base_url, "https://opencode.ai/zen/go/v1");
|
assert_eq!(backend.base_url, "https://opencode.ai/zen/go/v1");
|
||||||
assert!(connect_model(&backend).is_ok());
|
assert!(connect_model(&backend, "thread-1").is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn opencode_go_identifies_the_client_and_the_conversation() {
|
||||||
|
let headers = provider_headers(ModelProvider::OpencodeGo, "thread-42");
|
||||||
|
assert_eq!(headers["x-opencode-session"], "thread-42");
|
||||||
|
assert!(
|
||||||
|
headers[http::header::USER_AGENT]
|
||||||
|
.to_str()
|
||||||
|
.unwrap()
|
||||||
|
.starts_with("lazyboy/")
|
||||||
|
);
|
||||||
|
// No conversation id is better than a made-up or malformed one.
|
||||||
|
assert!(!provider_headers(ModelProvider::OpencodeGo, " ").contains_key("x-opencode-session"));
|
||||||
|
assert!(!provider_headers(ModelProvider::OpencodeGo, "bad\nvalue").contains_key("x-opencode-session"));
|
||||||
|
assert!(provider_headers(ModelProvider::Xai, "thread-42").is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -307,13 +345,12 @@ mod tests {
|
||||||
credentials: CredentialChain {
|
credentials: CredentialChain {
|
||||||
bot: None,
|
bot: None,
|
||||||
space: None,
|
space: None,
|
||||||
env: None,
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(backend.api_key, "");
|
assert_eq!(backend.api_key, "");
|
||||||
assert_eq!(backend.base_url, "http://127.0.0.1:8000/v1");
|
assert_eq!(backend.base_url, "http://127.0.0.1:8000/v1");
|
||||||
assert!(connect_model(&backend).is_ok());
|
assert!(connect_model(&backend, "thread-1").is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -325,7 +362,6 @@ mod tests {
|
||||||
credentials: CredentialChain {
|
credentials: CredentialChain {
|
||||||
bot: None,
|
bot: None,
|
||||||
space: None,
|
space: None,
|
||||||
env: None,
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,8 @@ use crate::{CredentialChain, ModelError};
|
||||||
pub enum VoiceError {
|
pub enum VoiceError {
|
||||||
#[error("{0}")]
|
#[error("{0}")]
|
||||||
Message(String),
|
Message(String),
|
||||||
#[error("missing credential for {provider} ({env_key})")]
|
#[error("missing credential for {provider}: add the API key in the workspace settings")]
|
||||||
MissingCredential { provider: String, env_key: String },
|
MissingCredential { provider: String },
|
||||||
#[error("unknown voice provider: {0}")]
|
#[error("unknown voice provider: {0}")]
|
||||||
UnknownProvider(String),
|
UnknownProvider(String),
|
||||||
}
|
}
|
||||||
|
|
@ -27,9 +27,7 @@ pub enum VoiceError {
|
||||||
impl From<ModelError> for VoiceError {
|
impl From<ModelError> for VoiceError {
|
||||||
fn from(error: ModelError) -> Self {
|
fn from(error: ModelError) -> Self {
|
||||||
match error {
|
match error {
|
||||||
ModelError::MissingCredential { provider, env_key } => {
|
ModelError::MissingCredential { provider } => Self::MissingCredential { provider },
|
||||||
Self::MissingCredential { provider, env_key }
|
|
||||||
}
|
|
||||||
other => Self::Message(other.to_string()),
|
other => Self::Message(other.to_string()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -90,7 +88,6 @@ pub fn resolve_voice(request: ResolveVoiceRequest) -> Result<ResolvedVoice, Voic
|
||||||
None if request.provider.requires_api_key() => {
|
None if request.provider.requires_api_key() => {
|
||||||
return Err(VoiceError::MissingCredential {
|
return Err(VoiceError::MissingCredential {
|
||||||
provider: request.provider.as_str().to_string(),
|
provider: request.provider.as_str().to_string(),
|
||||||
env_key: request.provider.env_key_name().to_string(),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
None => String::new(),
|
None => String::new(),
|
||||||
|
|
@ -667,7 +664,6 @@ mod tests {
|
||||||
credentials: CredentialChain {
|
credentials: CredentialChain {
|
||||||
bot: None,
|
bot: None,
|
||||||
space: None,
|
space: None,
|
||||||
env: None,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
assert!(matches!(missing, Err(VoiceError::MissingCredential { .. })));
|
assert!(matches!(missing, Err(VoiceError::MissingCredential { .. })));
|
||||||
|
|
@ -679,7 +675,6 @@ mod tests {
|
||||||
credentials: CredentialChain {
|
credentials: CredentialChain {
|
||||||
bot: None,
|
bot: None,
|
||||||
space: Some("sk".into()),
|
space: Some("sk".into()),
|
||||||
env: None,
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
|
||||||
|
|
@ -82,16 +82,13 @@ services:
|
||||||
DATABASE_URL: postgres://lazyboy:${POSTGRES_PASSWORD:-lazyboy}@postgres:5432/lazyboy
|
DATABASE_URL: postgres://lazyboy:${POSTGRES_PASSWORD:-lazyboy}@postgres:5432/lazyboy
|
||||||
SANDBOX_SUPERVISOR_URL: http://supervisor:7091
|
SANDBOX_SUPERVISOR_URL: http://supervisor:7091
|
||||||
SANDBOX_SUPERVISOR_TOKEN: ${SANDBOX_SUPERVISOR_TOKEN:?Set SANDBOX_SUPERVISOR_TOKEN in .env}
|
SANDBOX_SUPERVISOR_TOKEN: ${SANDBOX_SUPERVISOR_TOKEN:?Set SANDBOX_SUPERVISOR_TOKEN in .env}
|
||||||
LAZYBOY_APP_TOKEN: ${LAZYBOY_APP_TOKEN:?Set LAZYBOY_APP_TOKEN in .env}
|
|
||||||
LAZYBOY_VAULT_KEY: ${LAZYBOY_VAULT_KEY:-}
|
LAZYBOY_VAULT_KEY: ${LAZYBOY_VAULT_KEY:-}
|
||||||
LAZYBOY_SECURE_COOKIE: ${LAZYBOY_SECURE_COOKIE:-false}
|
LAZYBOY_SECURE_COOKIE: ${LAZYBOY_SECURE_COOKIE:-false}
|
||||||
|
LAZYBOY_ALLOWED_HOSTS: ${LAZYBOY_ALLOWED_HOSTS:-}
|
||||||
SANDBOX_PROVIDER: docker
|
SANDBOX_PROVIDER: docker
|
||||||
DATA_DIR: /data
|
DATA_DIR: /data
|
||||||
HOST_DATA_DIR: ${LAZYBOY_HOST_DATA_DIR:-${PWD}/data}
|
HOST_DATA_DIR: ${LAZYBOY_HOST_DATA_DIR:-${PWD}/data}
|
||||||
API_BIND: 0.0.0.0:3100
|
API_BIND: 0.0.0.0:3100
|
||||||
XAI_API_KEY: ${XAI_API_KEY:-}
|
|
||||||
OPENCODE_GO_API_KEY: ${OPENCODE_GO_API_KEY:-}
|
|
||||||
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
|
||||||
LAZYBOY_MEMORY_ENABLED: ${LAZYBOY_MEMORY_ENABLED:-true}
|
LAZYBOY_MEMORY_ENABLED: ${LAZYBOY_MEMORY_ENABLED:-true}
|
||||||
LAZYBOY_MEMORY_MODEL_CACHE: /data/fastembed
|
LAZYBOY_MEMORY_MODEL_CACHE: /data/fastembed
|
||||||
LAZYBOY_MEMORY_TOP_K: ${LAZYBOY_MEMORY_TOP_K:-8}
|
LAZYBOY_MEMORY_TOP_K: ${LAZYBOY_MEMORY_TOP_K:-8}
|
||||||
|
|
|
||||||
|
|
@ -111,7 +111,7 @@ builder 並註冊 binfmt;主機本身的架構會跳過註冊,否則在特
|
||||||
(`CHROME=` 指定,否則依序找 PATH 上的 chromium/Chrome 與 Playwright 下載的 chrome)。
|
(`CHROME=` 指定,否則依序找 PATH 上的 chromium/Chrome 與 Playwright 下載的 chrome)。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. 對運行中的 stack 擷取真實畫面(登入 token 讀 .env,可用 LB_TOKEN/LB_URL 覆寫)
|
# 1. 對運行中的 stack 擷取真實畫面(帳號密碼用 LB_USER/LB_PASS,帳號不存在時自動註冊;LB_URL 指定 stack)
|
||||||
# 群組視圖的右欄是機器人自己的瀏覽器,進圖前先用 --hide 遮掉,再補一張中立的上去;
|
# 群組視圖的右欄是機器人自己的瀏覽器,進圖前先用 --hide 遮掉,再補一張中立的上去;
|
||||||
# --anchor 會印出被遮區塊在圖上的位置,hero.html 的圖層座標就是從這裡來的。
|
# --anchor 會印出被遮區塊在圖上的位置,hero.html 的圖層座標就是從這裡來的。
|
||||||
node scripts/capture-hero.mjs --out docs/hero/agent.png
|
node scripts/capture-hero.mjs --out docs/hero/agent.png
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,13 @@
|
||||||
|
|
||||||
目前程式碼包含下列防護:
|
目前程式碼包含下列防護:
|
||||||
|
|
||||||
- API session token 與 Supervisor token 分離。
|
- 登入是帳號密碼:資料庫只存 PBKDF2-HMAC-SHA256 派生值(120,000 輪,每組密碼各自的 salt),不存明文;登入 session token 與 Supervisor token 分離,資料庫裡也只存 session 的雜湊。
|
||||||
|
- 帳號互相隔離:Agent、對話、排程、憑證庫與模型金鑰都以帳號自己的 workspace 為界,A 讀不到也改不到 B 的資料。
|
||||||
|
- `Host` 檢查抵禦 DNS rebinding:IP 與 `localhost` 直接接受,其他網域名稱一定要列在 `LAZYBOY_ALLOWED_HOSTS`。
|
||||||
- Supervisor 只在內部 Compose network,並使用 `no-new-privileges`、唯讀 root filesystem 與 capability drop。
|
- Supervisor 只在內部 Compose network,並使用 `no-new-privileges`、唯讀 root filesystem 與 capability drop。
|
||||||
- 每台電腦有 CPU、RAM、PID 上限;預設 2 CPU、2 GB、2048 PID。
|
- 每台電腦有 CPU、RAM、PID 上限;預設 2 CPU、2 GB、2048 PID。
|
||||||
- API 預設只綁定 `127.0.0.1:3101`。
|
- API 預設只綁定 `127.0.0.1:3101`。
|
||||||
- 憑證以獨立 `LAZYBOY_VAULT_KEY` 加密,輪替登入 token 時不應更換此 key。
|
- 憑證以獨立 `LAZYBOY_VAULT_KEY` 加密,改帳號密碼不影響此 key;輪替 `LAZYBOY_VAULT_KEY` 會讀不到已加密的密碼庫。
|
||||||
- 已保存登入只接受 HTTPS、精確或合法子網域匹配,不對相似惡意網域填入。
|
- 已保存登入只接受 HTTPS、精確或合法子網域匹配,不對相似惡意網域填入。
|
||||||
- Markdown 連結限制為 HTTP(S)、`mailto:`、`tel:` 與頁內錨點。
|
- Markdown 連結限制為 HTTP(S)、`mailto:`、`tel:` 與頁內錨點。
|
||||||
- 日誌有大小與檔案數上限;診斷資料有可設定的保留週期。
|
- 日誌有大小與檔案數上限;診斷資料有可設定的保留週期。
|
||||||
|
|
@ -19,9 +21,10 @@
|
||||||
|
|
||||||
1. 對區網或網際網路開放前,先放在 HTTPS reverse proxy 後方,並設定 `LAZYBOY_SECURE_COOKIE=true`。
|
1. 對區網或網際網路開放前,先放在 HTTPS reverse proxy 後方,並設定 `LAZYBOY_SECURE_COOKIE=true`。
|
||||||
2. 不要把 Supervisor `:7091` 對外發布,也不要將 Docker socket 掛進 Agent 電腦。
|
2. 不要把 Supervisor `:7091` 對外發布,也不要將 Docker socket 掛進 Agent 電腦。
|
||||||
3. `LAZYBOY_APP_TOKEN`、`SANDBOX_SUPERVISOR_TOKEN`、`LAZYBOY_VAULT_KEY` 必須使用不同的高熵值。
|
3. `SANDBOX_SUPERVISOR_TOKEN` 與 `LAZYBOY_VAULT_KEY` 必須使用不同的高熵值。
|
||||||
4. 模型仍可能看見任務所需的網頁內容與截圖;密碼、token 與高敏感資料不要放進提示詞。
|
4. 模型仍可能看見任務所需的網頁內容與截圖;密碼、token 與高敏感資料不要放進提示詞。
|
||||||
5. 簡單的 Cloudflare 連線驗證可嘗試一次正常點擊;未通過、其他 CAPTCHA 與 2FA 交由使用者接管。
|
5. 簡單的 Cloudflare 連線驗證可嘗試一次正常點擊;未通過、其他 CAPTCHA 與 2FA 交由使用者接管。
|
||||||
|
6. 模型 API 金鑰只吃各 workspace 在「設定 → 模型」裡貼上的值,不再讀環境變數;沒設定金鑰的 workspace 跑任務會立刻失敗,並在畫面告訴你要去哪裡補。
|
||||||
|
|
||||||
## 硬體與資源
|
## 硬體與資源
|
||||||
|
|
||||||
|
|
@ -43,10 +46,7 @@
|
||||||
|
|
||||||
| 變數 | 用途 | 預設 |
|
| 變數 | 用途 | 預設 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `XAI_API_KEY` | xAI 模型 | 空 |
|
| `LAZYBOY_ALLOWED_HOSTS` | 允許以網域名稱連入的主機名(逗號分隔);IP 與 `localhost` 不需要列 | 空 |
|
||||||
| `OPENCODE_GO_API_KEY` | OpenCode Go 模型 | 空 |
|
|
||||||
| `OPENAI_API_KEY` | OpenAI 相容端點 | 空 |
|
|
||||||
| `LAZYBOY_APP_TOKEN` | Web 登入 token | 必填 |
|
|
||||||
| `SANDBOX_SUPERVISOR_TOKEN` | API ↔ Supervisor 驗證 | 必填 |
|
| `SANDBOX_SUPERVISOR_TOKEN` | API ↔ Supervisor 驗證 | 必填 |
|
||||||
| `LAZYBOY_VAULT_KEY` | 憑證庫加密 key | 必填且必須保持穩定 |
|
| `LAZYBOY_VAULT_KEY` | 憑證庫加密 key | 必填且必須保持穩定 |
|
||||||
| `LAZYBOY_BIND_IP` | 主機監聽位址 | `127.0.0.1` |
|
| `LAZYBOY_BIND_IP` | 主機監聽位址 | `127.0.0.1` |
|
||||||
|
|
@ -73,7 +73,7 @@ LAZYBOY_BIND_IP=10.0.33.1 # 只開放指定網卡
|
||||||
docker compose up -d api
|
docker compose up -d api
|
||||||
```
|
```
|
||||||
|
|
||||||
- 綁非 loopback 時 `LAZYBOY_APP_TOKEN` 必須至少 32 字元,否則 api 拒絕啟動。
|
- 每台裝置各自註冊、各自登入;`Host` 為 IP 或 `localhost` 時可直接使用,改用網域名稱進入時要把名稱加進 `LAZYBOY_ALLOWED_HOSTS`,否則會被 `403 invalid host` 擋下。
|
||||||
- Origin 檢查比對 `Origin` 與 `Host`:直接開 `http://<主機IP>:3101` 可正常使用,從其他網域嵌入會被 `403 cross-origin request rejected` 擋下。
|
- Origin 檢查比對 `Origin` 與 `Host`:直接開 `http://<主機IP>:3101` 可正常使用,從其他網域嵌入會被 `403 cross-origin request rejected` 擋下。
|
||||||
- 純 HTTP 下 session cookie 以明碼走區網;長期或跨網際網路使用請放到 HTTPS reverse proxy 後面,並設定 `LAZYBOY_SECURE_COOKIE=true`。
|
- 純 HTTP 下 session cookie 以明碼走區網;長期或跨網際網路使用請放到 HTTPS reverse proxy 後面,並設定 `LAZYBOY_SECURE_COOKIE=true`。
|
||||||
- 暫時性跨網存取建議維持 `127.0.0.1` 綁定改用隧道:`ssh -L 3101:127.0.0.1:3101 <host>`。
|
- 暫時性跨網存取建議維持 `127.0.0.1` 綁定改用隧道:`ssh -L 3101:127.0.0.1:3101 <host>`。
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
-- Accounts: every person signs in with their own username and password, and
|
||||||
|
-- the agents they make belong to them alone.
|
||||||
|
--
|
||||||
|
-- Before this, one shared token from `.env` opened the whole install, and one
|
||||||
|
-- hard-coded local user owned every agent. Login is now per person, so a
|
||||||
|
-- session resolves to exactly one user and one workspace, and the shared token
|
||||||
|
-- is gone. `password_hash IS NULL` marks an account that cannot sign in yet.
|
||||||
|
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN IF NOT EXISTS username TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS password_hash TEXT;
|
||||||
|
|
||||||
|
-- Usernames are addresses: one spelling, matched case-insensitively.
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS users_username_key
|
||||||
|
ON users (lower(username))
|
||||||
|
WHERE username IS NOT NULL;
|
||||||
|
|
||||||
|
-- Server-side sessions. Only the digest of the cookie value is stored, so a
|
||||||
|
-- database leak cannot be replayed as a login, and an API restart no longer
|
||||||
|
-- logs everyone out.
|
||||||
|
CREATE TABLE IF NOT EXISTS app_sessions (
|
||||||
|
token_hash TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS app_sessions_user_idx ON app_sessions (user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS app_sessions_expiry_idx ON app_sessions (expires_at);
|
||||||
|
|
@ -25,7 +25,8 @@
|
||||||
//
|
//
|
||||||
// Environment:
|
// Environment:
|
||||||
// LB_URL stack to photograph (default http://127.0.0.1:3101)
|
// LB_URL stack to photograph (default http://127.0.0.1:3101)
|
||||||
// LB_TOKEN login token (default: LAZYBOY_APP_TOKEN from .env)
|
// LB_USER account to sign in as (default: hero)
|
||||||
|
// LB_PASS that account's password (default below; registered on demand)
|
||||||
// CHROME browser binary (default: discovered, see findChrome)
|
// CHROME browser binary (default: discovered, see findChrome)
|
||||||
import { spawn } from 'node:child_process';
|
import { spawn } from 'node:child_process';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
|
|
@ -80,14 +81,32 @@ function findChrome() {
|
||||||
throw new Error('no Chromium found; set CHROME=/path/to/chrome');
|
throw new Error('no Chromium found; set CHROME=/path/to/chrome');
|
||||||
}
|
}
|
||||||
|
|
||||||
function readToken() {
|
// Screenshots are taken against a throwaway stack, so the script is allowed to
|
||||||
if (process.env.LB_TOKEN) return process.env.LB_TOKEN;
|
// create the account it photographs; LB_USER/LB_PASS override both halves.
|
||||||
const file = path.join(root, '.env');
|
function credentials() {
|
||||||
const match = fs.existsSync(file) ? fs.readFileSync(file, 'utf8').match(/^LAZYBOY_APP_TOKEN=(.+)$/m) : null;
|
return {
|
||||||
if (!match) throw new Error('no login token; set LB_TOKEN or create .env with make env');
|
username: (process.env.LB_USER || 'hero').trim().toLowerCase(),
|
||||||
return match[1].trim();
|
password: process.env.LB_PASS || 'lazyboy-hero-2026',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Evaluated inside the page so the session cookie lands in the browser that
|
||||||
|
// takes the screenshot. An unknown account is registered instead of failing,
|
||||||
|
// which keeps a freshly started stack photographable without setup.
|
||||||
|
const SIGN_IN_HELPER = String.raw`async function signIn({ username, password }) {
|
||||||
|
const call = async (path) => {
|
||||||
|
const response = await fetch(path, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'same-origin',
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
return response.status;
|
||||||
|
};
|
||||||
|
const status = await call('/api/auth/login');
|
||||||
|
return status === 401 ? call('/api/auth/register') : status;
|
||||||
|
}`;
|
||||||
|
|
||||||
async function waitForVersion(port) {
|
async function waitForVersion(port) {
|
||||||
for (let attempt = 0; attempt < 60; attempt += 1) {
|
for (let attempt = 0; attempt < 60; attempt += 1) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -207,7 +226,7 @@ try {
|
||||||
const status = await connection.send(
|
const status = await connection.send(
|
||||||
'Runtime.evaluate',
|
'Runtime.evaluate',
|
||||||
{
|
{
|
||||||
expression: `fetch('/api/session',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token:${JSON.stringify(readToken())}})}).then(r=>r.status)`,
|
expression: `${SIGN_IN_HELPER}\nsignIn(${JSON.stringify(credentials())})`,
|
||||||
awaitPromise: true,
|
awaitPromise: true,
|
||||||
returnByValue: true,
|
returnByValue: true,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@ if [[ -f "$root/.env" ]]; then
|
||||||
fi
|
fi
|
||||||
export DATABASE_URL="${DATABASE_URL:-postgres://lazyboy:lazyboy@127.0.0.1:5434/lazyboy}"
|
export DATABASE_URL="${DATABASE_URL:-postgres://lazyboy:lazyboy@127.0.0.1:5434/lazyboy}"
|
||||||
: "${SANDBOX_SUPERVISOR_TOKEN:?Set a random SANDBOX_SUPERVISOR_TOKEN of at least 32 characters in .env}"
|
: "${SANDBOX_SUPERVISOR_TOKEN:?Set a random SANDBOX_SUPERVISOR_TOKEN of at least 32 characters in .env}"
|
||||||
: "${LAZYBOY_APP_TOKEN:?Set a random LAZYBOY_APP_TOKEN of at least 32 characters in .env}"
|
|
||||||
export SANDBOX_SUPERVISOR_URL="${SANDBOX_SUPERVISOR_URL:-http://127.0.0.1:7091}"
|
export SANDBOX_SUPERVISOR_URL="${SANDBOX_SUPERVISOR_URL:-http://127.0.0.1:7091}"
|
||||||
export SANDBOX_PROVIDER="${SANDBOX_PROVIDER:-docker}"
|
export SANDBOX_PROVIDER="${SANDBOX_PROVIDER:-docker}"
|
||||||
export DATA_DIR="${DATA_DIR:-$root/data}"
|
export DATA_DIR="${DATA_DIR:-$root/data}"
|
||||||
|
|
@ -26,5 +25,5 @@ export API_BIND="${API_BIND:-0.0.0.0:3101}"
|
||||||
export LAZYBOY_WEB_DIR="$root/apps/web"
|
export LAZYBOY_WEB_DIR="$root/apps/web"
|
||||||
mkdir -p "$DATA_DIR"
|
mkdir -p "$DATA_DIR"
|
||||||
echo "start supervisor in another terminal with the same .env: cargo run -p lazyboy-supervisor"
|
echo "start supervisor in another terminal with the same .env: cargo run -p lazyboy-supervisor"
|
||||||
echo "then: cargo run -p lazyboy-api"
|
echo "then: cargo run -p lazyboy-api (register the first account in the browser)"
|
||||||
echo "listening on 0.0.0.0:3101"
|
echo "listening on 0.0.0.0:3101"
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ path=Path('.env')
|
||||||
if path.exists():
|
if path.exists():
|
||||||
print('.env already exists; existing credentials were preserved.')
|
print('.env already exists; existing credentials were preserved.')
|
||||||
else:
|
else:
|
||||||
values={key:secrets.token_hex(32) for key in ('LAZYBOY_APP_TOKEN','SANDBOX_SUPERVISOR_TOKEN','LAZYBOY_VAULT_KEY','POSTGRES_PASSWORD')}
|
values={key:secrets.token_hex(32) for key in ('SANDBOX_SUPERVISOR_TOKEN','LAZYBOY_VAULT_KEY','POSTGRES_PASSWORD')}
|
||||||
text=Path('.env.example').read_text()
|
text=Path('.env.example').read_text()
|
||||||
lines=[]
|
lines=[]
|
||||||
for line in text.splitlines():
|
for line in text.splitlines():
|
||||||
|
|
@ -18,4 +18,5 @@ else:
|
||||||
with path.open('x') as out:
|
with path.open('x') as out:
|
||||||
path.chmod(0o600)
|
path.chmod(0o600)
|
||||||
out.write('\n'.join(lines)+'\n')
|
out.write('\n'.join(lines)+'\n')
|
||||||
print('Created .env with independent app, supervisor, vault and database secrets. Set your model API key next.')
|
print('Created .env with independent supervisor, vault and database secrets.')
|
||||||
|
print('Sign-in is now a username and password: open the app and register the first account, then paste your model API key in 設定 → 模型.')
|
||||||
|
|
|
||||||
|
|
@ -305,7 +305,7 @@ const monitorBox={exports:{},require:name=>{
|
||||||
}};
|
}};
|
||||||
vm.runInNewContext(monitorJs,monitorBox);
|
vm.runInNewContext(monitorJs,monitorBox);
|
||||||
const {formatElapsed,shortDuration,errorActions,errorTitle,trailText}=monitorBox.exports;
|
const {formatElapsed,shortDuration,errorActions,errorTitle,trailText}=monitorBox.exports;
|
||||||
const FAILURE_CODES=['interrupted','tool_timeout','model_key','model_quota','model_unknown','model_timeout','network','computer_gone','lease_lost','unknown'];
|
const FAILURE_CODES=['interrupted','tool_timeout','model_key','model_key_missing','model_opt_in','model_quota','model_unknown','model_timeout','network','computer_gone','lease_lost','unknown'];
|
||||||
|
|
||||||
test('run timings stay on one glanceable line',()=>{
|
test('run timings stay on one glanceable line',()=>{
|
||||||
assert.equal(formatElapsed(0),'0:00');
|
assert.equal(formatElapsed(0),'0:00');
|
||||||
|
|
@ -368,6 +368,21 @@ test('trail lines read as sentences with the detail a stuck run needs',()=>{
|
||||||
assert.match(trailText({id:9,kind:'notice',createdAt:'',text:'這輪不需要電腦'}),/這輪不需要電腦/);
|
assert.match(trailText({id:9,kind:'notice',createdAt:'',text:'這輪不需要電腦'}),/這輪不需要電腦/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('meeting mode is a desktop layout switch with a toggle in the title bar',()=>{
|
||||||
|
const app=fs.readFileSync('apps/web/src/App.tsx','utf8');
|
||||||
|
assert.match(app,/meeting-mode/);
|
||||||
|
assert.match(app,/meeting-toggle/);
|
||||||
|
assert.match(app,/function toggleMeeting/);
|
||||||
|
assert.match(app,/className=\{`meeting-stage/);
|
||||||
|
const css=fs.readFileSync('apps/web/src/responsive.css','utf8');
|
||||||
|
assert.match(css,/meeting-toggle\{display:none!important\}/);
|
||||||
|
assert.match(css,/\.meeting-stage\{display:none!important\}/);
|
||||||
|
assert.match(app,/part==="computer"&&isPhoneLayout\(\)/);
|
||||||
|
assert.match(app,/createPortal/);
|
||||||
|
const computerCss=fs.readFileSync('apps/web/src/computer.css','utf8');
|
||||||
|
assert.match(computerCss,/\.meeting-stage\{display:none!important\}/);
|
||||||
|
});
|
||||||
|
|
||||||
test('chat messages use a copy-or-reply menu instead of inline run details',()=>{
|
test('chat messages use a copy-or-reply menu instead of inline run details',()=>{
|
||||||
const app=fs.readFileSync('apps/web/src/App.tsx','utf8');
|
const app=fs.readFileSync('apps/web/src/App.tsx','utf8');
|
||||||
assert.match(app,/function MessageContextMenu/);
|
assert.match(app,/function MessageContextMenu/);
|
||||||
|
|
@ -391,6 +406,7 @@ test('the bubble reads the run activity endpoint the API actually mounts',()=>{
|
||||||
assert.match(app,/errorActions\(chip\.code\)/);
|
assert.match(app,/errorActions\(chip\.code\)/);
|
||||||
assert.match(app,/`\/api\/runs\/\$\{runId\}\/retry`,\{method:"POST",body:"\{\}"\}/);
|
assert.match(app,/`\/api\/runs\/\$\{runId\}\/retry`,\{method:"POST",body:"\{\}"\}/);
|
||||||
assert.match(app,/<RunProbe runId=\{chip\.runId\|\|null\} align="end" label=\{t\("errorDetails"\)\}/);
|
assert.match(app,/<RunProbe runId=\{chip\.runId\|\|null\} align="end" label=\{t\("errorDetails"\)\}/);
|
||||||
|
assert.match(app,/<RunProbe runId=\{member\.id===computer\.botId\?computer\.busyRunId:null\}><Avatar/);
|
||||||
assert.match(app,/<RunProbe runId=\{computer\.busyRunId\|\|computer\.waitingRunId\}>/);
|
assert.match(app,/<RunProbe runId=\{computer\.busyRunId\|\|computer\.waitingRunId\}>/);
|
||||||
assert.doesNotMatch(app,/message-run-details/);
|
assert.doesNotMatch(app,/message-run-details/);
|
||||||
assert.match(app,/className="thinking-row"/);
|
assert.match(app,/className="thinking-row"/);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue