fix frontend css issue

This commit is contained in:
daniel wang 2026-09-10 14:42:17 +00:00
parent 79952b088b
commit 80fe9a8ef1
75 changed files with 9063 additions and 278 deletions

View File

@ -67,3 +67,14 @@ LAZYBOY_DB_WARN_MB=1024
# Docker 網路名稱API 與 Agent 電腦的 noVNC 透過它相通(容器內用,不對外)。 # Docker 網路名稱API 與 Agent 電腦的 noVNC 透過它相通(容器內用,不對外)。
# 同時跑多組 LazyBoy 時改這個名字避免相撞。 # 同時跑多組 LazyBoy 時改這個名字避免相撞。
LAZYBOY_SCREEN_NETWORK=lazyboy_screen LAZYBOY_SCREEN_NETWORK=lazyboy_screen
# Executor path version (not a ComputerMode). legacy = GUI-first; hybrid = native tools.
LAZYBOY_EXECUTION_PROFILE=hybrid
# xvfb_x11vnc is the default. tigervnc_xvnc is a candidate backend (PR-09); keep rollback.
LAZYBOY_DISPLAY_BACKEND=xvfb_x11vnc
LAZYBOY_BROWSER_BACKEND=cua
LAZYBOY_TOOL_AUDIT_REQUIRED=true
LAZYBOY_NATIVE_JOB_CONCURRENCY=2
LAZYBOY_SERVICE_READ_CONCURRENCY=4
LAZYBOY_TOOL_INSTALL_ENABLED=true
LAZYBOY_TOOL_INSTALL_REQUIRE_APPROVAL=true

3
Cargo.lock generated
View File

@ -1905,6 +1905,7 @@ dependencies = [
"lazyboy-control", "lazyboy-control",
"lazyboy-harness", "lazyboy-harness",
"lazyboy-sandbox", "lazyboy-sandbox",
"ort",
"rand 0.8.8", "rand 0.8.8",
"reqwest 0.12.28", "reqwest 0.12.28",
"rig-core", "rig-core",
@ -1942,6 +1943,7 @@ dependencies = [
"hex", "hex",
"image", "image",
"lazyboy-contracts", "lazyboy-contracts",
"regex",
"serde", "serde",
"serde_json", "serde_json",
"sha2", "sha2",
@ -1991,6 +1993,7 @@ dependencies = [
"lazyboy-control", "lazyboy-control",
"reqwest 0.12.28", "reqwest 0.12.28",
"serde_json", "serde_json",
"tokio",
] ]
[[package]] [[package]]

View File

@ -57,6 +57,7 @@ help: ## Show this help
@echo " make lint The Rust gate: clippy with -D warnings" @echo " make lint The Rust gate: clippy with -D warnings"
@echo " make audit cargo deny: RustSec advisories, licenses, sources" @echo " make audit cargo deny: RustSec advisories, licenses, sources"
@echo " make test cargo test --workspace (DB tests need: make postgres)" @echo " make test cargo test --workspace (DB tests need: make postgres)"
@echo " make test-agent-computer Screenshot / locator / native-file contract tests"
@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 ""
@ -192,6 +193,72 @@ audit: ## Supply-chain check (RustSec advisories, licenses, dependency sources)
test: ## Run the test suite test: ## Run the test suite
cargo test --workspace cargo test --workspace
test-agent-computer: ## Agent-computer contract tests (screenshot, locators, native files, jobs, connectors)
cargo test --workspace --lib -- \
should_deliver_observation_image \
classify_browser_locator \
native_file_payload \
file_bytes_prefer_base64 \
file_list_refuses \
fake_sandbox_keeps_invalid_utf8 \
pixel_actions_need_vision \
typed_browser_errors \
chat_tools_do_not_need_the_desktop \
plan_batch_modify \
classify_graph_item \
validate_manifest \
zip_slip \
pausing_agent \
same_operation \
journal_failure \
cancel_stops \
gmail_with_grant \
policy_denied \
slot_zero \
wait_counts \
canary_is_stripped \
stale_hash \
team_computers_share \
short_command_returns \
form_fill_and_computer_mcp \
form_locator_resolves \
begin_then_complete \
running_count_and_quota \
operation_id_is_not_part \
team_packages_live \
catalog_stdio_is_not_an_api_child \
native_exec_does_not_need_desktop \
revoke_drops_computer_mcp \
update_pins_old_version \
t59_keeps_ac \
native_tools_do_not_boot \
mcp_rs_does_not_spawn \
agent_computer_migration_does_not_unique \
native_exec_and_files_do_not_call_ensure_screen \
update_switches_the_single_ready_binding \
start_sh_skips_desktop \
background_exec_runs_on_the_computer \
computer_background_launch \
background_launch_keeps_posix_quoted \
exec_background_does_not_spawn \
reap_background_job_failed \
command_result_json_keeps \
supervisor_exec_returns_full
cargo test -p lazyboy-api -- \
native_file_payload \
pixel_actions_need_vision \
typed_browser_errors \
native_exec_is_in_the_schema \
chat_tools_do_not_need_the_desktop \
form_fill_and_computer_mcp \
form_locator_resolves \
operation_id_is_not_part \
team_packages_live \
mcp_rs_does_not_spawn \
native_tools_do_not_boot \
agent_computer_migration_does_not_unique \
exec_background_does_not_spawn
web: ## Build the frontend (needs node/npm) web: ## Build the frontend (needs node/npm)
cd $(WEB_DIR) && npm install && npm run build cd $(WEB_DIR) && npm install && npm run build

View File

@ -22,7 +22,7 @@ import { clockTime, dayLabel, sameDay } from "./chat-time";
import { HANDOFF_MS, VEIL_FADE_MS, handoffRemaining, keepScreenUrl, nextVeil, viewOnlyFor, viewerPath, type Veil } from "./handoff"; import { HANDOFF_MS, VEIL_FADE_MS, handoffRemaining, keepScreenUrl, nextVeil, viewOnlyFor, viewerPath, type Veil } from "./handoff";
import { Avatar, AvatarLookProvider, AvatarStack, BLOBATAR_BACKGROUNDS, BLOBATAR_EXPRESSIONS, BLOBATAR_SHAPES, DEFAULT_LOOK, persistBlobatarShape, readAvatarLooks, resolveBlobatarShape, writeAvatarLook, type AvatarBackground, type AvatarExpression, type AvatarLook } from "./avatar"; import { Avatar, AvatarLookProvider, AvatarStack, BLOBATAR_BACKGROUNDS, BLOBATAR_EXPRESSIONS, BLOBATAR_SHAPES, DEFAULT_LOOK, persistBlobatarShape, readAvatarLooks, resolveBlobatarShape, writeAvatarLook, type AvatarBackground, type AvatarExpression, type AvatarLook } from "./avatar";
import { dateLocale, getLocale, listJoin, setLocale, t, useLocale, type MessageKey } from "./i18n"; import { dateLocale, getLocale, listJoin, setLocale, t, useLocale, type MessageKey } from "./i18n";
import type { AvatarShape, Bot, ComputerMode, ComputerStatus, FileSkill, McpCatalogEntry, McpServer, McpTransport, MemoryItem, MemoryStatus, Message, MessageFile, Playbook, PlaybookInput, PlaybookStep, Room, RoomMember, Session, TaughtSkill, VoiceSettings } from "./types"; import type { AvatarShape, Bot, ComputerMode, ComputerStatus, ComputerToolBinding, FileSkill, McpCatalogEntry, McpServer, McpTransport, MemoryItem, MemoryStatus, Message, MessageFile, Playbook, PlaybookInput, PlaybookStep, Room, RoomMember, Session, TaughtSkill, VoiceSettings } from "./types";
import { ChatMarkdown, MentionText, copyText } from "./markdown"; import { ChatMarkdown, MentionText, copyText } from "./markdown";
import { RunProbe, errorActions, errorTitle } from "./run-monitor"; import { RunProbe, errorActions, errorTitle } from "./run-monitor";
import { ScheduleEditor, ScheduleList, cronFromPreset, defaultCronPreset, presetFromCron, scheduleWhen, type CronPreset, type ScheduleItem } from "./schedule"; import { ScheduleEditor, ScheduleList, cronFromPreset, defaultCronPreset, presetFromCron, scheduleWhen, type CronPreset, type ScheduleItem } from "./schedule";
@ -680,7 +680,7 @@ export function App(){
<label className="memory-target">{t("memoryOwner")}{activeRoom?<select value={memoryBot.id} onChange={e=>setMemoryTargets(current=>({...current,[activeRoom.id]:e.target.value}))}>{activeRoom.members.map(member=><option key={member.id} value={member.id}>{member.name}</option>)}</select>:<strong>{memoryBot.name}</strong>}</label> <label className="memory-target">{t("memoryOwner")}{activeRoom?<select value={memoryBot.id} onChange={e=>setMemoryTargets(current=>({...current,[activeRoom.id]:e.target.value}))}>{activeRoom.members.map(member=><option key={member.id} value={member.id}>{member.name}</option>)}</select>:<strong>{memoryBot.name}</strong>}</label>
<MemoryPane key={memoryBot.id} bot={memoryBot} changed={loadBots}/> <MemoryPane key={memoryBot.id} bot={memoryBot} changed={loadBots}/>
</>} </>}
{rightPart==="plugins"&&<McpPane servers={mcpServers} reload={loadMcp}/>} {rightPart==="plugins"&&<McpPane bot={paneBot??undefined} servers={mcpServers} reload={loadMcp}/>}
{rightPart==="settings"&&active&&<BotSettingsPane bot={active} look={looks[active.id]||DEFAULT_LOOK} onLook={look=>{writeAvatarLook(active.id,look);setLooks(readAvatarLooks())}} saved={loadBots} onDelete={()=>setDeleteOpen(true)}/>} {rightPart==="settings"&&active&&<BotSettingsPane bot={active} look={looks[active.id]||DEFAULT_LOOK} onLook={look=>{writeAvatarLook(active.id,look);setLooks(readAvatarLooks())}} saved={loadBots} onDelete={()=>setDeleteOpen(true)}/>}
</div> </div>
</> </>
@ -847,11 +847,12 @@ function MemoryPane({bot,changed}:{bot:Bot;changed:()=>Promise<void>}){
} }
function parsePairs(text:string){const out:Record<string,string>={};for(const line of text.split(/\n+/)){const trimmed=line.trim();if(!trimmed)continue;const cut=trimmed.indexOf("=");if(cut<=0)continue;out[trimmed.slice(0,cut).trim()]=trimmed.slice(cut+1)}return out} function parsePairs(text:string){const out:Record<string,string>={};for(const line of text.split(/\n+/)){const trimmed=line.trim();if(!trimmed)continue;const cut=trimmed.indexOf("=");if(cut<=0)continue;out[trimmed.slice(0,cut).trim()]=trimmed.slice(cut+1)}return out}
function McpPane({servers,reload}:{servers:McpServer[];reload:()=>Promise<void>}){ function McpPane({bot,servers,reload}:{bot?:Bot;servers:McpServer[];reload:()=>Promise<void>}){
const[picker,setPicker]=useState(false);const[busy,setBusy]=useState(false);const[error,setError]=useState("");const[openId,setOpenId]=useState<string|null>(null); const[picker,setPicker]=useState(false);const[busy,setBusy]=useState(false);const[error,setError]=useState("");const[openId,setOpenId]=useState<string|null>(null);
async function run(work:()=>Promise<unknown>){setBusy(true);setError("");try{await work();await reload()}catch(e){setError(e instanceof Error?localizeError(e.message):t("operationFailed"))}finally{setBusy(false)}} async function run(work:()=>Promise<unknown>){setBusy(true);setError("");try{await work();await reload()}catch(e){setError(e instanceof Error?localizeError(e.message):t("operationFailed"))}finally{setBusy(false)}}
return <div className="mcp-pane"> return <div className="mcp-pane">
<p className="memory-help">{t("mcpHelp")}</p> <p className="memory-help">{t("mcpHelp")}</p>
<ComputerToolsPane bot={bot}/>
<button type="button" className="primary mcp-choose" disabled={busy} onClick={()=>setPicker(true)}><Plug/>{t("chooseMcp")}</button> <button type="button" className="primary mcp-choose" disabled={busy} onClick={()=>setPicker(true)}><Plug/>{t("chooseMcp")}</button>
{picker&&<McpPickerDialog added={servers.map(server=>server.name)} close={()=>setPicker(false)} connected={async()=>{setPicker(false);await reload()}}/>} {picker&&<McpPickerDialog added={servers.map(server=>server.name)} close={()=>setPicker(false)} connected={async()=>{setPicker(false);await reload()}}/>}
<div className="mcp-list">{servers.length===0?<p>{t("noMcp")}</p>:servers.map(server=><div className={`mcp-card ${server.status}`} key={server.id}> <div className="mcp-list">{servers.length===0?<p>{t("noMcp")}</p>:servers.map(server=><div className={`mcp-card ${server.status}`} key={server.id}>
@ -873,6 +874,46 @@ function McpPane({servers,reload}:{servers:McpServer[];reload:()=>Promise<void>}
</div> </div>
} }
function ComputerToolsPane({bot}:{bot?:Bot}){
const[tools,setTools]=useState<ComputerToolBinding[]>([]);
const[busy,setBusy]=useState(false);
const[error,setError]=useState("");
const[echoText,setEchoText]=useState("hello");
const[echoOut,setEchoOut]=useState("");
const load=useCallback(async()=>{
if(!bot){setTools([]);return}
const result=await api<{tools:ComputerToolBinding[]}>(`/api/bots/${bot.id}/tools`).catch(()=>({tools:[] as ComputerToolBinding[]}));
setTools(result.tools||[]);
},[bot]);
useEffect(()=>{void load()},[load]);
async function run(work:()=>Promise<unknown>){setBusy(true);setError("");try{await work();await load()}catch(e){setError(e instanceof Error?localizeError(e.message):t("operationFailed"))}finally{setBusy(false)}}
return <div className="mcp-computer">
<strong className="mcp-computer-title">{t("computerToolsTitle")}</strong>
<p className="memory-help">{t("computerToolsHelp")}</p>
{!bot?<p className="memory-help">{t("chooseBotForTools")}</p>:<>
<div className="mcp-echo-row">
<button type="button" className="outline mcp-choose" disabled={busy} onClick={()=>void run(()=>api(`/api/bots/${bot.id}/tools`,{method:"POST",body:JSON.stringify({packageId:"lazyboy.example.echo",version:"0.0.1"})}))}>{t("installSampleEcho")}</button>
<button type="button" className="outline" disabled={busy} onClick={()=>void run(()=>api(`/api/bots/${bot.id}/tools`,{method:"POST",body:JSON.stringify({packageId:"lazyboy.example.echo",version:"0.0.2"})}))}>{t("updateSampleEcho")}</button>
</div>
<div className="mcp-list">{tools.length===0?<p>{t("noComputerTools")}</p>:tools.map(tool=><div className={`mcp-card ${tool.ready?"connected":""}`} key={tool.bindingId}>
<div className="mcp-card-head">
<i className={`mcp-dot ${tool.ready?"connected":"disabled"}`}/><strong>{tool.packageId}</strong>
<small>{tool.ready?t("computerToolsReady"):tool.bindingStatus==="revoked"?t("computerToolsRevoked"):tool.bindingStatus} · {t("toolOnComputer")}</small>
</div>
{tool.ready&&<div className="mcp-echo-row">
<input value={echoText} onChange={e=>setEchoText(e.target.value)} placeholder={t("echoPlaceholder")} disabled={busy}/>
<button type="button" className="outline" disabled={busy||!echoText.trim()} onClick={()=>void run(async()=>{const result=await api<{stdout?:string;stderr?:string;exitCode?:number}>(`/api/bots/${bot.id}/tools/call`,{method:"POST",body:JSON.stringify({text:echoText,packageId:tool.packageId})});setEchoOut(result.stdout||result.stderr||JSON.stringify(result))})}>{t("callSampleEcho")}</button>
<button type="button" className="outline" disabled={busy} onClick={()=>void run(()=>api(`/api/bots/${bot.id}/tools/${tool.bindingId}/rollback`,{method:"POST",body:"{}"}))}>{t("rollbackPackage")}</button>
<button type="button" className="danger-ghost" disabled={busy} onClick={()=>void run(()=>api(`/api/bots/${bot.id}/tools/${tool.bindingId}/revoke`,{method:"POST",body:"{}"}))}>{t("revokeBinding")}</button>
<button type="button" className="danger-ghost" disabled={busy} onClick={()=>void run(()=>api(`/api/bots/${bot.id}/tools/${tool.bindingId}/remove`,{method:"POST",body:"{}"}))}>{t("removePackage")}</button>
</div>}
</div>)}</div>
{echoOut&&<pre className="mcp-echo-out">{t("echoCallResult")}: {echoOut}</pre>}
</>}
{error&&<div className="pane-error">{error}</div>}
</div>
}
function McpPickerDialog({added,close,connected}:{added:string[];close:()=>void;connected:()=>Promise<void>}){ function McpPickerDialog({added,close,connected}:{added:string[];close:()=>void;connected:()=>Promise<void>}){
const[query,setQuery]=useState("");const[custom,setCustom]=useState(false); const[query,setQuery]=useState("");const[custom,setCustom]=useState(false);
const[items,setItems]=useState<McpCatalogEntry[]>([]);const[loading,setLoading]=useState(true); const[items,setItems]=useState<McpCatalogEntry[]>([]);const[loading,setLoading]=useState(true);

View File

@ -248,7 +248,22 @@ export const en: { [K in keyof typeof zhTW]: string } = {
clearAllMemoryConfirm: "Clear all memory?", clearAllMemoryConfirm: "Clear all memory?",
clearMemoryConfirm: "Clear", clearMemoryConfirm: "Clear",
clearAll: "Clear all", clearAll: "Clear all",
mcpHelp: "Press “Choose MCP” to connect from the catalog. If a key is required, fill it in first. Local stdio servers start inside the API container.", mcpHelp: "Catalog HTTP/SSE MCP is a remote workspace connection. Local stdio is not started in the API process — reviewed packages run on the assigned Computer.",
computerToolsTitle: "Computer packages",
computerToolsHelp: "Reviewed packages are written onto this Computer. Team mode stores them under shared/tools. The sample echo server is the one installable package today.",
installSampleEcho: "Install echo sample",
updateSampleEcho: "Update to 0.0.2",
rollbackPackage: "Roll back version",
removePackage: "Remove package",
noComputerTools: "No packages on this Computer yet.",
toolOnComputer: "Runs on the assigned Computer",
revokeBinding: "Revoke binding",
callSampleEcho: "Call echo",
echoPlaceholder: "Text to echo",
chooseBotForTools: "Select a bot to install packages on its Computer.",
computerToolsReady: "Ready",
computerToolsRevoked: "Revoked",
echoCallResult: "Result",
mcpNamePlaceholder: "For example: github", mcpNamePlaceholder: "For example: github",
mcpConnectFailed: "Couldnt connect. Check the key and try again.", mcpConnectFailed: "Couldnt connect. Check the key and try again.",
command: "Command", command: "Command",
@ -359,7 +374,7 @@ export const en: { [K in keyof typeof zhTW]: string } = {
helpMemoryTitle: "Memory", helpMemoryTitle: "Memory",
helpMemory: "Clearing a chat doesnt wipe long-term memory. Ask the agent to remember, or add it in Memory on the right.", helpMemory: "Clearing a chat doesnt wipe long-term memory. Ask the agent to remember, or add it in Memory on the right.",
helpMcpTitle: "MCP plugins", helpMcpTitle: "MCP plugins",
helpMcp: "Plugins in the lower left connect MCP servers. Their tools show up on screen and the agent can use them in chat.", helpMcp: "Plugins in the lower left connect catalog MCP servers (workspace-level). The same pane can install a reviewed package onto this Computer; that process runs in the container.",
helpSkillsTitle: "Skills", helpSkillsTitle: "Skills",
helpSkills: "+ → Teach a task, demonstrate once, and it becomes a skill. Export JSON after, or import someone elses file onto another bot.", helpSkills: "+ → Teach a task, demonstrate once, and it becomes a skill. Export JSON after, or import someone elses file onto another bot.",
helpAttachTitle: "Attachments", helpAttachTitle: "Attachments",

View File

@ -88,7 +88,13 @@ export const zhTW = {
memoryHelp: "清除對話不會刪這些。只存明確偏好或事實,不要存密碼。", enableLongTermMemory: "啟用長期記憶", addMemory: "新增記憶", memoryPlaceholder: "只儲存明確偏好或事實;密碼與 token 會被拒絕。", memoryHelp: "清除對話不會刪這些。只存明確偏好或事實,不要存密碼。", enableLongTermMemory: "啟用長期記憶", addMemory: "新增記憶", memoryPlaceholder: "只儲存明確偏好或事實;密碼與 token 會被拒絕。",
memorySearch: "搜尋", filterMemory: "過濾記憶", noMemory: "還沒有長期記憶。對話裡講過的偏好,可以叫 Agent 記住,或你在這裡新增。", noMatchingMemory: "沒有符合的記憶。", memorySearch: "搜尋", filterMemory: "過濾記憶", noMemory: "還沒有長期記憶。對話裡講過的偏好,可以叫 Agent 記住,或你在這裡新增。", noMatchingMemory: "沒有符合的記憶。",
save: "儲存", edit: "編輯", clearAllMemoryConfirm: "確定清除全部記憶?", clearMemoryConfirm: "確定清除", clearAll: "全部清除", save: "儲存", edit: "編輯", clearAllMemoryConfirm: "確定清除全部記憶?", clearMemoryConfirm: "確定清除", clearAll: "全部清除",
mcpHelp: "點「選擇 MCP」從市集接入。標了需要金鑰的先填 token 才能連。本機 stdio 會在 API 容器裡啟動。", mcpNamePlaceholder: "例如github", mcpConnectFailed: "接入失敗,請檢查金鑰或稍後重試。", mcpHelp: "市集 HTTP/SSE MCP 是遠端工作區連線。本機 stdio 不會在 API 行程啟動;審核過的套件在指派的 Computer 裡執行。", mcpNamePlaceholder: "例如github", mcpConnectFailed: "接入失敗,請檢查金鑰或稍後重試。",
computerToolsTitle: "Computer 套件", computerToolsHelp: "審核過的套件寫進這台 Computer。Team 模式放在 shared/tools。目前可裝範例 echo。",
installSampleEcho: "安裝 echo 範例", updateSampleEcho: "更新到 0.0.2", rollbackPackage: "回滾版本", removePackage: "移除套件",
noComputerTools: "這台 Computer 還沒有安裝套件。",
toolOnComputer: "在指派的 Computer 執行", revokeBinding: "撤銷綁定", callSampleEcho: "呼叫 echo",
echoPlaceholder: "要回傳的文字", chooseBotForTools: "選一個機器人後,才能在它的 Computer 上安裝套件。",
computerToolsReady: "可用", computerToolsRevoked: "已撤銷", echoCallResult: "回傳",
command: "Command", commandPlaceholder: "npx 或 uvx 或完整路徑", arguments: "參數", environmentVariables: "環境變數 KEY=value", headers: "Headers KEY=value", connecting: "連線中…", connectMcp: "接入 MCP", command: "Command", commandPlaceholder: "npx 或 uvx 或完整路徑", arguments: "參數", environmentVariables: "環境變數 KEY=value", headers: "Headers KEY=value", connecting: "連線中…", connectMcp: "接入 MCP",
chooseMcp: "選擇 MCP", mcpPickerTitle: "選擇 MCP", mcpPickerLead: "從精選或官方市集挑一個,點下去接入。所有 Agent 都能用它的工具。", chooseMcp: "選擇 MCP", mcpPickerTitle: "選擇 MCP", mcpPickerLead: "從精選或官方市集挑一個,點下去接入。所有 Agent 都能用它的工具。",
searchMcp: "搜尋 MCP", mcpRemote: "遠端", mcpLocal: "本機", mcpAdded: "已接入", mcpNeedsKey: "需要金鑰", searchMcp: "搜尋 MCP", mcpRemote: "遠端", mcpLocal: "本機", mcpAdded: "已接入", mcpNeedsKey: "需要金鑰",
@ -123,7 +129,7 @@ export const zhTW = {
meetingMode: "會議模式", exitMeetingMode: "結束會議模式", meetingMode: "會議模式", exitMeetingMode: "結束會議模式",
helpMeetingTitle: "會議模式", helpMeeting: "電腦版標題列可開啟會議模式:螢幕放大放中間,對話移到旁邊,像分享畫面時邊看邊聊。手機沒有這個模式。", helpMeetingTitle: "會議模式", helpMeeting: "電腦版標題列可開啟會議模式:螢幕放大放中間,對話移到旁邊,像分享畫面時邊看邊聊。手機沒有這個模式。",
helpMemoryTitle: "記憶", helpMemory: "清除對話不會刪長期記憶。可以叫 Agent 記住,或在右側「記憶」手動新增。", helpMemoryTitle: "記憶", helpMemory: "清除對話不會刪長期記憶。可以叫 Agent 記住,或在右側「記憶」手動新增。",
helpMcpTitle: "MCP 外掛", helpMcp: "左下「外掛程式」接入 MCP server。連上的工具會顯示在畫面上對話時 Agent 可以使用。", helpMcpTitle: "MCP 外掛", helpMcp: "左下「外掛程式」接入市集 MCP工作區連線。同一頁也可把審核過的套件裝到這台 Computer 上,行程在容器裡跑。",
helpSkillsTitle: "技能", helpSkills: " → 教它一項任務,示範一次就會整理成技能。示範結束後可以匯出 JSON或把別人的技能檔匯入換一個機器人也適用。", helpSkillsTitle: "技能", helpSkills: " → 教它一項任務,示範一次就會整理成技能。示範結束後可以匯出 JSON或把別人的技能檔匯入換一個機器人也適用。",
helpAttachTitle: "附件", helpAttach: " → 附加檔案。圖片這則訊息就會給模型看,不會存進對話紀錄。若機器人電腦要打開原檔,會暫放 inbox/,兩小時後自動刪,避免把磁碟塞滿。", helpAttachTitle: "附件", helpAttach: " → 附加檔案。圖片這則訊息就會給模型看,不會存進對話紀錄。若機器人電腦要打開原檔,會暫放 inbox/,兩小時後自動刪,避免把磁碟塞滿。",
helpShortcutsTitle: "快捷鍵", helpShortcuts: "Enter 送出Shift+Enter 換行。正在回覆時送出鈕會變成停止。通話中空白鍵插話Esc 掛斷。", helpShortcutsTitle: "快捷鍵", helpShortcuts: "Enter 送出Shift+Enter 換行。正在回覆時送出鈕會變成停止。通話中空白鍵插話Esc 掛斷。",

View File

@ -313,6 +313,12 @@
.mcp-choose{display:inline-flex;align-items:center;gap:8px;width:fit-content} .mcp-choose{display:inline-flex;align-items:center;gap:8px;width:fit-content}
.mcp-computer{display:grid;gap:10px;padding-bottom:12px;border-bottom:1px solid var(--border)}
.mcp-computer-title{font-size:13px}
.mcp-echo-row{display:flex;flex-wrap:wrap;gap:6px;padding:0 12px 12px;align-items:center}
.mcp-echo-row input{flex:1;min-width:8rem;height:32px;padding:0 10px;border:1px solid var(--border);border-radius:8px;background:transparent;color:inherit;font:inherit}
.mcp-echo-out{margin:0;padding:0 12px 12px;font-size:12px;color:var(--muted);white-space:pre-wrap;word-break:break-word}
.mcp-choose svg{width:16px;height:16px} .mcp-choose svg{width:16px;height:16px}

View File

@ -72,6 +72,12 @@ export function formatElapsed(ms: number): string {
} }
/** 340ms / 6.4s / 1:05 — durations inside the trail stay glanceable. */ /** 340ms / 6.4s / 1:05 — durations inside the trail stay glanceable. */
/** Operation ids are UUIDs; the first block is enough to match a ledger row by eye. */
function shortId(id: string): string {
const head = id.split("-")[0] ?? id;
return head.length > 12 ? `${head.slice(0, 12)}` : head;
}
export function shortDuration(ms?: number | null): string { export function shortDuration(ms?: number | null): string {
if (typeof ms !== "number" || !Number.isFinite(ms) || ms < 0) return ""; if (typeof ms !== "number" || !Number.isFinite(ms) || ms < 0) return "";
if (ms < 1000) return `${Math.round(ms)}ms`; if (ms < 1000) return `${Math.round(ms)}ms`;
@ -85,6 +91,8 @@ function kindLabel(kind: string): string {
if (kind === "tool") return t("monitorKindTool"); if (kind === "tool") return t("monitorKindTool");
if (kind === "retry") return t("monitorKindRetry"); if (kind === "retry") return t("monitorKindRetry");
if (kind === "notice") return t("monitorKindNotice"); if (kind === "notice") return t("monitorKindNotice");
if (kind === "job") return t("monitorKindTool");
if (kind === "verify") return t("monitorKindRun");
return t("monitorKindRun"); return t("monitorKindRun");
} }
@ -118,6 +126,9 @@ export function trailText(entry: RunActivityEntry): string {
const parts = [entry.step || entry.name || t("monitorKindTool")]; const parts = [entry.step || entry.name || t("monitorKindTool")];
const status = statusLabel(entry.status); const status = statusLabel(entry.status);
if (status) parts.push(status); if (status) parts.push(status);
if (entry.errorCode) parts.push(entry.errorCode);
if (entry.jobId) parts.push(entry.jobId);
if (entry.operationId) parts.push(`op ${shortId(entry.operationId)}`);
const time = shortDuration(entry.elapsedMs); const time = shortDuration(entry.elapsedMs);
if (time) parts.push(time); if (time) parts.push(time);
const detail = entry.snippet ? `${entry.snippet}` : ""; const detail = entry.snippet ? `${entry.snippet}` : "";

View File

@ -12,7 +12,7 @@ export interface Room { id:string; name:string; members:RoomMember[]; hostBotId?
export interface ComputerStatus { botId:string; mode:ComputerMode; state:ComputerState; sharedInput?:boolean; controlHolder:"none"|"bot"|"user"; takeoverRequested:boolean; busyBotName:string|null; busySessionId:string|null; busyRunId:string|null; busyStep?:string|null; usingComputer?:boolean; waitingRunId?:string|null; waitingSessionId?:string|null; queuedRuns?:number; display:string|null; profileMode:string; screenAvailable:boolean } export interface ComputerStatus { botId:string; mode:ComputerMode; state:ComputerState; sharedInput?:boolean; controlHolder:"none"|"bot"|"user"; takeoverRequested:boolean; busyBotName:string|null; busySessionId:string|null; busyRunId:string|null; busyStep?:string|null; usingComputer?:boolean; waitingRunId?:string|null; waitingSessionId?:string|null; queuedRuns?:number; display:string|null; profileMode:string; screenAvailable:boolean }
/** One line of the live trail a run writes while it works. */ /** One line of the live trail a run writes while it works. */
export type RunActivityKind = "run"|"model"|"tool"|"retry"|"notice"|"memory"; export type RunActivityKind = "run"|"model"|"tool"|"retry"|"notice"|"memory";
export interface RunActivityEntry { memories?:{id:string;revision:number}[]; enabled?:boolean; botId?:string; id:number; kind:RunActivityKind; createdAt:string; turn?:number|null; event?:string|null; task?:string|null; reason?:string|null; turns?:number|null; limit?:number|null; error?:string|null; name?:string|null; step?:string|null; status?:string|null; elapsedMs?:number|null; toolCalls?:number|null; text?:string|null; snippet?:string|null; attempt?:number|null; gaveUp?:boolean|null } export interface RunActivityEntry { memories?:{id:string;revision:number}[]; enabled?:boolean; botId?:string; id:number; kind:RunActivityKind; createdAt:string; turn?:number|null; event?:string|null; task?:string|null; reason?:string|null; turns?:number|null; limit?:number|null; error?:string|null; errorCode?:string|null; jobId?:string|null; operationId?:string|null; name?:string|null; step?:string|null; status?:string|null; elapsedMs?:number|null; toolCalls?:number|null; text?:string|null; snippet?:string|null; attempt?:number|null; gaveUp?:boolean|null }
export interface RunActivityError { code:string; headline:string; action?:string; raw:string } export interface RunActivityError { code:string; headline:string; action?:string; raw:string }
export interface RunActivity { runId:string; status:string; turn:number|null; turnLimit:number|null; step:string|null; stepAt?:string|null; elapsedMs:number|null; error:RunActivityError|null; activity:RunActivityEntry[] } export interface RunActivity { runId:string; status:string; turn:number|null; turnLimit:number|null; step:string|null; stepAt?:string|null; elapsedMs:number|null; error:RunActivityError|null; activity:RunActivityEntry[] }
@ -28,6 +28,7 @@ export interface McpTool { name:string; exposedName:string; description:string }
export interface McpServer { id:string; name:string; transport:McpTransport; command:string|null; args:string[]; env:Record<string,string>; url:string|null; headers:Record<string,string>; enabled:boolean; status:"connected"|"disconnected"|"disabled"; error:string|null; tools:McpTool[]; createdAt:string; updatedAt:string } export interface McpServer { id:string; name:string; transport:McpTransport; command:string|null; args:string[]; env:Record<string,string>; url:string|null; headers:Record<string,string>; enabled:boolean; status:"connected"|"disconnected"|"disabled"; error:string|null; tools:McpTool[]; createdAt:string; updatedAt:string }
export interface McpSecretField { name:string; required:boolean; secret:boolean; hint:string } export interface McpSecretField { name:string; required:boolean; secret:boolean; hint:string }
export interface McpCatalogEntry { id:string; title:string; description:string; transport:McpTransport; command:string|null; args:string[]; url:string|null; envKeys:McpSecretField[]; headerKeys:McpSecretField[]; source:"featured"|"registry"; remote:boolean } export interface McpCatalogEntry { id:string; title:string; description:string; transport:McpTransport; command:string|null; args:string[]; url:string|null; envKeys:McpSecretField[]; headerKeys:McpSecretField[]; source:"featured"|"registry"; remote:boolean }
export interface ComputerToolBinding { bindingId:string; packageId:string; version:string; sha256:string; installStatus:string; bindingStatus:string; ready:boolean; executionLocation?:string }
export type ModelProviderId = "xai" | "opencode-go" | "openai-compatible"; export type ModelProviderId = "xai" | "opencode-go" | "openai-compatible";
export type VoiceProviderId = "xai" | "openai" | "scripted"; export type VoiceProviderId = "xai" | "openai" | "scripted";
export interface VoiceSettings { export interface VoiceSettings {

View File

@ -31,6 +31,10 @@ tokio-tungstenite.workspace = true
futures-util = "0.3" futures-util = "0.3"
dotenvy = "0.15" dotenvy = "0.15"
fastembed = { version = "6.0.2", default-features = false, features = ["hf-hub-rustls-tls", "ort-load-dynamic"] } fastembed = { version = "6.0.2", default-features = false, features = ["hf-hub-rustls-tls", "ort-load-dynamic"] }
# Same ort fastembed pins. Used only to pre-load the ONNX Runtime dylib through a
# fallible path: ort's lazy loader `expect`s while holding its global lock, and the
# poisoned lock aborts the process in ort's exit hook.
ort = { version = "=2.0.0-rc.13", default-features = false, features = ["load-dynamic"] }
rmcp = { version = "3.2", default-features = false, features = ["client", "transport-child-process", "transport-streamable-http-client-reqwest"] } rmcp = { version = "3.2", default-features = false, features = ["client", "transport-child-process", "transport-streamable-http-client-reqwest"] }
http = "1" http = "1"
aes-gcm = "0.10" aes-gcm = "0.10"

182
crates/api/src/artifacts.rs Normal file
View File

@ -0,0 +1,182 @@
//! Artifact catalog: bytes live on the Computer; the API only stores
//! references and streams a download.
use axum::extract::{Path, State};
use axum::http::{HeaderMap, HeaderValue, StatusCode};
use axum::routing::get;
use axum::{Json, Router};
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use uuid::Uuid;
use crate::db::Actor;
use crate::state::AppState;
use crate::tools::ToolCtx;
type ApiError = (StatusCode, Json<Value>);
pub fn router() -> Router<AppState> {
Router::new()
.route("/api/bots/{id}/artifacts", get(list_artifacts))
.route(
"/api/bots/{id}/artifacts/{artifact_id}",
get(download_artifact),
)
}
pub async fn record_file(
ctx: &ToolCtx,
relative_path: &str,
bytes: &[u8],
operation_id: Option<&str>,
) {
let Some(computer_id): Option<String> =
sqlx::query_scalar("SELECT computer_id FROM bots WHERE id=$1")
.bind(&ctx.bot_id)
.fetch_optional(&ctx.pool)
.await
.ok()
.flatten()
.flatten()
else {
return;
};
let digest = {
use sha2::{Digest, Sha256};
hex::encode(Sha256::digest(bytes))
};
let _ = sqlx::query(
"INSERT INTO computer_artifacts (id, computer_id, bot_id, relative_path, sha256, size, operation_id)
VALUES ($1,$2,$3,$4,$5,$6,$7)",
)
.bind(Uuid::new_v4().to_string())
.bind(computer_id)
.bind(&ctx.bot_id)
.bind(relative_path)
.bind(digest)
.bind(bytes.len() as i64)
.bind(operation_id)
.execute(&ctx.pool)
.await;
}
async fn list_artifacts(
State(state): State<AppState>,
actor: Actor,
Path(bot_id): Path<String>,
) -> Result<Json<Value>, ApiError> {
type ArtifactRow = (
String,
String,
String,
i64,
Option<String>,
chrono::DateTime<chrono::Utc>,
);
let rows: Vec<ArtifactRow> = sqlx::query_as(
"SELECT a.id, a.relative_path, a.sha256, a.size, a.operation_id, a.created_at
FROM computer_artifacts a
JOIN bots b ON b.id=a.bot_id
WHERE a.bot_id=$1 AND b.space_id=$2 AND b.user_id=$3
ORDER BY a.created_at DESC LIMIT 100",
)
.bind(&bot_id)
.bind(&actor.space_id)
.bind(&actor.user_id)
.fetch_all(state.pool())
.await
.map_err(internal)?;
Ok(Json(json!({
"artifacts": rows.into_iter().map(|(id, path, sha, size, op, at)| json!({
"id": id,
"path": path,
"sha256": sha,
"size": size,
"operationId": op,
"createdAt": at,
})).collect::<Vec<_>>()
})))
}
async fn download_artifact(
State(state): State<AppState>,
actor: Actor,
Path((bot_id, artifact_id)): Path<(String, String)>,
) -> Result<(HeaderMap, Vec<u8>), ApiError> {
let row: Option<(String, String, String)> = sqlx::query_as(
"SELECT a.relative_path, a.sha256, a.computer_id
FROM computer_artifacts a
JOIN bots b ON b.id=a.bot_id
WHERE a.id=$1 AND a.bot_id=$2 AND b.space_id=$3 AND b.user_id=$4",
)
.bind(&artifact_id)
.bind(&bot_id)
.bind(&actor.space_id)
.bind(&actor.user_id)
.fetch_optional(state.pool())
.await
.map_err(internal)?;
let Some((relative, sha, computer_id)) = row else {
return Err((
StatusCode::NOT_FOUND,
Json(json!({"message":"artifact not found"})),
));
};
let computer = state
.db
.get_computer(&computer_id)
.await
.map_err(internal)?
.ok_or((
StatusCode::NOT_FOUND,
Json(json!({"message":"computer not found"})),
))?;
let Some(computer_ref) = crate::computer::computer_ref(&computer) else {
return Err((
StatusCode::CONFLICT,
Json(json!({"message":"computer is not running"})),
));
};
let bytes = state
.sandbox
.read_file(
&computer_ref,
&relative,
&crate::computer::adapter_context(&actor, &bot_id, "artifact"),
)
.await
.map_err(|error| {
(
StatusCode::BAD_GATEWAY,
Json(json!({"message": error.to_string()})),
)
})?;
let actual = hex::encode(Sha256::digest(&bytes));
if actual != sha {
return Err((
StatusCode::CONFLICT,
Json(json!({"message":"artifact bytes changed on the Computer"})),
));
}
let mut headers = HeaderMap::new();
headers.insert(
axum::http::header::CONTENT_TYPE,
HeaderValue::from_static("application/octet-stream"),
);
headers.insert(
axum::http::header::CONTENT_DISPOSITION,
HeaderValue::from_str(&format!(
"attachment; filename=\"{}\"",
relative.rsplit('/').next().unwrap_or("artifact")
))
.unwrap_or_else(|_| HeaderValue::from_static("attachment")),
);
Ok((headers, bytes))
}
fn internal<E: std::fmt::Display>(error: E) -> ApiError {
tracing::error!("artifacts: {error}");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"message":"internal error"})),
)
}

View File

@ -8,9 +8,10 @@ use lazyboy_contracts::{
}; };
use lazyboy_control::{ use lazyboy_control::{
AdapterContext, CommandRequest, EnsureScreenRequest, ProvisionRequest, admit_gui, AdapterContext, CommandRequest, EnsureScreenRequest, ProvisionRequest, admit_gui,
admit_new_screen, browser_profile_path, execution_blocks_user_takeover, profile_lock_key, admit_new_screen, attach_display_for_tool, browser_profile_path,
screen_layout, team_bot_workspace_directory, execution_blocks_user_takeover, profile_lock_key, screen_layout, team_bot_workspace_directory,
}; };
use serde_json::json;
use uuid::Uuid; use uuid::Uuid;
use crate::db::{ use crate::db::{
@ -165,6 +166,7 @@ pub async fn refresh_cursor_color(
cwd: None, cwd: None,
timeout_ms: Some(5_000), timeout_ms: Some(5_000),
stdin: None, stdin: None,
..CommandRequest::default()
}, },
&adapter_context_for(actor, bot_id, "cursor-color", Some(&screen), None), &adapter_context_for(actor, bot_id, "cursor-color", Some(&screen), None),
) )
@ -402,6 +404,7 @@ async fn probe_computer_container(
cwd: None, cwd: None,
timeout_ms: Some(5_000), timeout_ms: Some(5_000),
stdin: None, stdin: None,
..CommandRequest::default()
}, },
&adapter_context(actor, bot_id, "probe"), &adapter_context(actor, bot_id, "probe"),
) )
@ -544,6 +547,16 @@ pub async fn take_profile_lock(
} }
pub async fn boot(state: &AppState, actor: &Actor, bot_id: &str) -> Result<ComputerStatus, String> { pub async fn boot(state: &AppState, actor: &Actor, bot_id: &str) -> Result<ComputerStatus, String> {
boot_for(state, actor, bot_id, true).await
}
/// `need_gui=false` provisions the Runner only: no `ensure_screen`, no viewer.
pub async fn boot_for(
state: &AppState,
actor: &Actor,
bot_id: &str,
need_gui: bool,
) -> Result<ComputerStatus, String> {
let bot = state let bot = state
.db .db
.get_bot(actor, bot_id) .get_bot(actor, bot_id)
@ -562,11 +575,17 @@ pub async fn boot(state: &AppState, actor: &Actor, bot_id: &str) -> Result<Compu
.ok_or_else(|| "computer not found".to_string())?; .ok_or_else(|| "computer not found".to_string())?;
if computer.state == "running" && computer.provider_ref.is_some() { if computer.state == "running" && computer.provider_ref.is_some() {
if guest_has_screens(state, actor, bot_id, &computer).await { if guest_has_screens(state, actor, bot_id, &computer).await {
let screen = ensure_bot_screen(state, actor, bot_id, &computer, None) let screen = if attach_display_for_tool(need_gui) {
ensure_bot_screen(state, actor, bot_id, &computer, None)
.await .await
.ok() .ok()
.and_then(|bound| bound.row); .and_then(|bound| bound.row)
} else {
None
};
if attach_display_for_tool(need_gui) {
restore_computer_screens(state, actor, &computer, bot_id).await; restore_computer_screens(state, actor, &computer, bot_id).await;
}
return Ok(status_from(bot_id, &computer, screen.as_ref(), None)); return Ok(status_from(bot_id, &computer, screen.as_ref(), None));
} }
if let Some(computer_ref) = computer_ref(&computer) { if let Some(computer_ref) = computer_ref(&computer) {
@ -575,15 +594,16 @@ pub async fn boot(state: &AppState, actor: &Actor, bot_id: &str) -> Result<Compu
let _ = state.sandbox.destroy(&computer_ref, &ctx).await; let _ = state.sandbox.destroy(&computer_ref, &ctx).await;
} }
sqlx::query( sqlx::query(
"UPDATE computers SET state = 'stopped', provider_ref = NULL, updated_at = now() WHERE id = $1", "UPDATE computers SET state = 'stopped', provider_ref = NULL, generation = generation + 1, updated_at = now() WHERE id = $1",
) )
.bind(&computer_id) .bind(&computer_id)
.execute(state.pool()) .execute(state.pool())
.await .await
.map_err(|error| error.to_string())?; .map_err(|error| error.to_string())?;
interrupt_computer_jobs(state, &computer_id).await;
} }
if computer.state == "suspended" && computer.provider_ref.is_some() { if computer.state == "suspended" && computer.provider_ref.is_some() {
match resume_paused(state, actor, bot_id, &computer).await { match resume_paused(state, actor, bot_id, &computer, need_gui).await {
Ok(status) => return Ok(status), Ok(status) => return Ok(status),
Err(error) => { Err(error) => {
tracing::warn!("computer {computer_id} resume failed: {error}"); tracing::warn!("computer {computer_id} resume failed: {error}");
@ -625,6 +645,7 @@ pub async fn boot(state: &AppState, actor: &Actor, bot_id: &str) -> Result<Compu
home_key: computer.home_key.clone(), home_key: computer.home_key.clone(),
home_path: home.to_string_lossy().into_owned(), home_path: home.to_string_lossy().into_owned(),
provider_ref: computer.provider_ref.clone(), provider_ref: computer.provider_ref.clone(),
runner_only: !need_gui,
}, },
&ctx, &ctx,
), ),
@ -661,6 +682,7 @@ pub async fn boot(state: &AppState, actor: &Actor, bot_id: &str) -> Result<Compu
cwd: None, cwd: None,
timeout_ms: Some(10_000), timeout_ms: Some(10_000),
stdin: None, stdin: None,
..CommandRequest::default()
}, },
&ctx, &ctx,
), ),
@ -675,13 +697,17 @@ pub async fn boot(state: &AppState, actor: &Actor, bot_id: &str) -> Result<Compu
tracing::warn!("computer workspace setup failed for {bot_id}: {error}"); tracing::warn!("computer workspace setup failed for {bot_id}: {error}");
} }
} }
// Record the display backend the container was actually started with, so
// health reports what runs rather than the column default.
let running = sqlx::query( let running = sqlx::query(
"UPDATE computers SET state = 'running', provider_ref = $2, kind = $3, updated_at = now() "UPDATE computers SET state = 'running', provider_ref = $2, kind = $3,
display_backend = $4, updated_at = now()
WHERE id = $1 AND state = 'booting'", WHERE id = $1 AND state = 'booting'",
) )
.bind(&computer_id) .bind(&computer_id)
.bind(&provisioned.provider_ref) .bind(&provisioned.provider_ref)
.bind(provisioned.kind.as_str()) .bind(provisioned.kind.as_str())
.bind(lazyboy_control::DisplayBackend::from_env().as_str())
.execute(state.pool()) .execute(state.pool())
.await .await
.map_err(|error| error.to_string())?; .map_err(|error| error.to_string())?;
@ -694,11 +720,17 @@ pub async fn boot(state: &AppState, actor: &Actor, bot_id: &str) -> Result<Compu
.await .await
.map_err(|error| error.to_string())? .map_err(|error| error.to_string())?
.unwrap(); .unwrap();
let screen = ensure_bot_screen(state, actor, bot_id, &computer, None) let screen = if attach_display_for_tool(need_gui) {
ensure_bot_screen(state, actor, bot_id, &computer, None)
.await .await
.ok() .ok()
.and_then(|bound| bound.row); .and_then(|bound| bound.row)
} else {
None
};
if attach_display_for_tool(need_gui) {
restore_computer_screens(state, actor, &computer, bot_id).await; restore_computer_screens(state, actor, &computer, bot_id).await;
}
Ok(status_from(bot_id, &computer, screen.as_ref(), None)) Ok(status_from(bot_id, &computer, screen.as_ref(), None))
} }
@ -707,6 +739,7 @@ async fn resume_paused(
actor: &Actor, actor: &Actor,
bot_id: &str, bot_id: &str,
computer: &ComputerRow, computer: &ComputerRow,
need_gui: bool,
) -> Result<ComputerStatus, String> { ) -> Result<ComputerStatus, String> {
let ctx = adapter_context(actor, bot_id, "resume"); let ctx = adapter_context(actor, bot_id, "resume");
let home = home_path(&state.data_dir, &computer.home_key); let home = home_path(&state.data_dir, &computer.home_key);
@ -717,6 +750,7 @@ async fn resume_paused(
home_key: computer.home_key.clone(), home_key: computer.home_key.clone(),
home_path: home.to_string_lossy().into_owned(), home_path: home.to_string_lossy().into_owned(),
provider_ref: computer.provider_ref.clone(), provider_ref: computer.provider_ref.clone(),
runner_only: !need_gui,
}, },
&ctx, &ctx,
), ),
@ -745,10 +779,14 @@ async fn resume_paused(
.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())?;
if current.state == "running" { if current.state == "running" {
let screen = ensure_bot_screen(state, actor, bot_id, &current, None) let screen = if attach_display_for_tool(need_gui) {
ensure_bot_screen(state, actor, bot_id, &current, None)
.await .await
.ok() .ok()
.and_then(|bound| bound.row); .and_then(|bound| bound.row)
} else {
None
};
return Ok(status_from(bot_id, &current, screen.as_ref(), None)); return Ok(status_from(bot_id, &current, screen.as_ref(), None));
} }
return Err("computer resume was superseded".into()); return Err("computer resume was superseded".into());
@ -759,11 +797,17 @@ async fn resume_paused(
.await .await
.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 screen = ensure_bot_screen(state, actor, bot_id, &computer, None) let screen = if attach_display_for_tool(need_gui) {
ensure_bot_screen(state, actor, bot_id, &computer, None)
.await .await
.ok() .ok()
.and_then(|bound| bound.row); .and_then(|bound| bound.row)
} else {
None
};
if attach_display_for_tool(need_gui) {
restore_computer_screens(state, actor, &computer, bot_id).await; restore_computer_screens(state, actor, &computer, bot_id).await;
}
Ok(status_from(bot_id, &computer, screen.as_ref(), None)) Ok(status_from(bot_id, &computer, screen.as_ref(), None))
} }
@ -888,13 +932,14 @@ pub async fn restart(
"UPDATE computers SET state = 'stopped', provider_ref = NULL, control_holder = 'none', "UPDATE computers SET state = 'stopped', provider_ref = NULL, control_holder = 'none',
control_lease_id = NULL, control_lease_expires_at = NULL, control_bot_id = NULL, control_lease_id = NULL, control_lease_expires_at = NULL, control_bot_id = NULL,
control_run_id = NULL, execution_bot_id = NULL, execution_run_id = NULL, control_run_id = NULL, execution_bot_id = NULL, execution_run_id = NULL,
execution_lease_expires_at = NULL, updated_at = now() execution_lease_expires_at = NULL, generation = generation + 1, updated_at = now()
WHERE id = $1", WHERE id = $1",
) )
.bind(&computer_id) .bind(&computer_id)
.execute(state.pool()) .execute(state.pool())
.await .await
.map_err(|error| error.to_string())?; .map_err(|error| error.to_string())?;
interrupt_computer_jobs(state, &computer_id).await;
boot(state, actor, bot_id).await boot(state, actor, bot_id).await
} }
@ -1091,13 +1136,65 @@ pub async fn idle_loop(state: AppState) {
} }
} }
/// `computer_jobs.status` is only written when a tool asks the Computer, so a
/// background job nobody polled would keep the Computer "busy" forever. Before
/// the idle reaper trusts those rows, ask the Computer whether they still run.
async fn refresh_running_jobs(state: &AppState, computer: &ComputerRow) {
let Some(computer_ref) = computer_ref(computer) else {
return;
};
let ids: Vec<String> = sqlx::query_scalar(
"SELECT id FROM computer_jobs WHERE computer_id=$1 AND status='running'
ORDER BY updated_at LIMIT 16",
)
.bind(&computer.id)
.fetch_all(state.pool())
.await
.unwrap_or_default();
for id in ids {
let outcome = state
.sandbox
.execute(
&computer_ref,
CommandRequest {
argv: Vec::new(),
timeout_ms: Some(5_000),
job_id: Some(id.clone()),
job_op: Some("status".into()),
..CommandRequest::default()
},
&idle_adapter(computer, "job-status"),
)
.await;
let status = match outcome {
Ok(result) => match result.status {
Some(status) if status != "running" => status,
_ => continue,
},
Err(error) if error.to_string().contains("unknown job") => "interrupted".into(),
Err(_) => continue,
};
let _ = sqlx::query(
"UPDATE computer_jobs SET status=$2, updated_at=now() WHERE id=$1 AND status='running'",
)
.bind(&id)
.bind(&status)
.execute(state.pool())
.await;
}
}
async fn computer_has_active_work(state: &AppState, computer_id: &str) -> bool { async fn computer_has_active_work(state: &AppState, computer_id: &str) -> bool {
let active: Result<Option<(i64,)>, _> = sqlx::query_as( // Row presence only: `SELECT 1` is INT4 and decoding it as i64 used to fail
// on every call, which silently turned this guard into "never busy".
let active = sqlx::query(
"SELECT 1 FROM runs WHERE status IN ('queued','leased','running','waiting_input','waiting_takeover') "SELECT 1 FROM runs WHERE status IN ('queued','leased','running','waiting_input','waiting_takeover')
AND bot_id IN (SELECT id FROM bots WHERE computer_id = $1) AND bot_id IN (SELECT id FROM bots WHERE computer_id = $1)
UNION ALL UNION ALL
SELECT 1 FROM taught_skills WHERE status IN ('recording','drafting') SELECT 1 FROM taught_skills WHERE status IN ('recording','drafting')
AND bot_id IN (SELECT id FROM bots WHERE computer_id = $1) AND bot_id IN (SELECT id FROM bots WHERE computer_id = $1)
UNION ALL
SELECT 1 FROM computer_jobs WHERE computer_id = $1 AND status IN ('running','accepted')
LIMIT 1", LIMIT 1",
) )
.bind(computer_id) .bind(computer_id)
@ -1121,7 +1218,7 @@ async fn pause_idle_computers(state: &AppState) {
"SELECT id, space_id, user_id, scope, scope_key, home_key, home_revision, kind, provider_ref, state, "SELECT id, space_id, user_id, scope, scope_key, home_key, home_revision, kind, provider_ref, state,
control_holder, control_lease_id, control_lease_expires_at, control_bot_id, control_run_id, control_holder, control_lease_id, control_lease_expires_at, control_bot_id, control_run_id,
execution_run_id, execution_bot_id, execution_lease_expires_at, execution_fence, execution_run_id, execution_bot_id, execution_lease_expires_at, execution_fence,
browser_profile_mode browser_profile_mode, generation
FROM computers WHERE state = 'running' AND updated_at < $1", FROM computers WHERE state = 'running' AND updated_at < $1",
) )
.bind(cutoff) .bind(cutoff)
@ -1129,6 +1226,7 @@ async fn pause_idle_computers(state: &AppState) {
.await; .await;
let Ok(rows) = rows else { return }; let Ok(rows) = rows else { return };
for computer in rows { for computer in rows {
refresh_running_jobs(state, &computer).await;
if computer_has_active_work(state, &computer.id).await { if computer_has_active_work(state, &computer.id).await {
continue; continue;
} }
@ -1157,7 +1255,7 @@ async fn stop_parked_computers(state: &AppState) {
"SELECT id, space_id, user_id, scope, scope_key, home_key, home_revision, kind, provider_ref, state, "SELECT id, space_id, user_id, scope, scope_key, home_key, home_revision, kind, provider_ref, state,
control_holder, control_lease_id, control_lease_expires_at, control_bot_id, control_run_id, control_holder, control_lease_id, control_lease_expires_at, control_bot_id, control_run_id,
execution_run_id, execution_bot_id, execution_lease_expires_at, execution_fence, execution_run_id, execution_bot_id, execution_lease_expires_at, execution_fence,
browser_profile_mode browser_profile_mode, generation
FROM computers WHERE state = 'suspended' AND updated_at < $1", FROM computers WHERE state = 'suspended' AND updated_at < $1",
) )
.bind(cutoff) .bind(cutoff)
@ -1184,6 +1282,101 @@ async fn stop_parked_computers(state: &AppState) {
} }
} }
async fn interrupt_computer_jobs(state: &AppState, computer_id: &str) {
crate::job_store::interrupt_computer(computer_id);
let _ = sqlx::query(
"UPDATE computer_jobs SET status='interrupted', updated_at=now()
WHERE computer_id=$1 AND status IN ('running','accepted')",
)
.bind(computer_id)
.execute(state.pool())
.await;
}
pub async fn component_health(
state: &AppState,
actor: &Actor,
bot_id: &str,
) -> Result<serde_json::Value, String> {
let bot = state
.db
.get_bot(actor, bot_id)
.await
.map_err(|error| error.to_string())?
.ok_or_else(|| "bot not found".to_string())?;
let computer_id = bot
.computer_id
.ok_or_else(|| "bot has no computer".to_string())?;
let computer = state
.db
.get_computer(&computer_id)
.await
.map_err(|error| error.to_string())?
.ok_or_else(|| "computer not found".to_string())?;
let display_backend: String =
sqlx::query_scalar("SELECT display_backend FROM computers WHERE id=$1")
.bind(&computer.id)
.fetch_optional(state.pool())
.await
.ok()
.flatten()
.unwrap_or_else(|| {
std::env::var("LAZYBOY_DISPLAY_BACKEND").unwrap_or_else(|_| "xvfb_x11vnc".into())
});
let jobs_running: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM computer_jobs WHERE computer_id=$1 AND status='running'",
)
.bind(&computer.id)
.fetch_one(state.pool())
.await
.unwrap_or(0);
let jobs_quota = lazyboy_control::native_job_concurrency() as i64;
let sandbox = if computer.state != "running" || computer.provider_ref.is_none() {
json!({"name":"sandbox","ok":true,"detail":"computer not running"})
} else {
match probe_computer_container(state, actor, bot_id, &computer).await {
ContainerProbe::Alive => json!({"name":"sandbox","ok":true,"detail":"alive"}),
ContainerProbe::Missing => {
json!({"name":"sandbox","ok":false,"detail":"container missing"})
}
ContainerProbe::Unknown => json!({"name":"sandbox","ok":false,"detail":"probe failed"}),
}
};
let journal_ok = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM computer_operations")
.fetch_one(state.pool())
.await
.is_ok();
let components = vec![
json!({"name":"database","ok":true,"detail":"reachable"}),
sandbox,
json!({"name":"display","ok":true,"detail":display_backend}),
json!({
"name":"jobs",
"ok": jobs_running <= jobs_quota,
"detail": format!("{jobs_running}/{jobs_quota} running")
}),
json!({
"name":"operations",
"ok": journal_ok,
"detail": if journal_ok { "available" } else { "unreachable" }
}),
];
let ok = components
.iter()
.all(|item| item["ok"].as_bool() == Some(true));
Ok(json!({
"computerId": computer.id,
"botId": bot_id,
"state": computer.state,
"generation": computer.generation,
"displayBackend": display_backend,
"jobsRunning": jobs_running,
"jobsQuota": jobs_quota,
"ok": ok,
"components": components,
}))
}
pub fn computer_ref(computer: &ComputerRow) -> Option<lazyboy_control::ComputerRef> { pub fn computer_ref(computer: &ComputerRow) -> Option<lazyboy_control::ComputerRef> {
let provider_ref = computer.provider_ref.clone()?; let provider_ref = computer.provider_ref.clone()?;
Some(lazyboy_control::ComputerRef { Some(lazyboy_control::ComputerRef {
@ -1315,6 +1508,7 @@ mod shared_input_tests {
execution_lease_expires_at: None, execution_lease_expires_at: None,
execution_fence: 1, execution_fence: 1,
browser_profile_mode: "per-bot".into(), browser_profile_mode: "per-bot".into(),
generation: 1,
}; };
let screen = ScreenRow { let screen = ScreenRow {
id: "screen".into(), id: "screen".into(),
@ -1363,3 +1557,125 @@ mod shared_input_tests {
assert!(!user_can_interact(&computer, Some(&screen), "bot")); assert!(!user_can_interact(&computer, Some(&screen), "bot"));
} }
} }
#[cfg(test)]
mod idle_job_tests {
use super::*;
fn app(pool: sqlx::PgPool) -> AppState {
AppState {
db: crate::db::Db { pool },
sandbox: std::sync::Arc::new(lazyboy_sandbox::FakeSandbox::new()),
data_dir: String::new(),
auth: crate::auth::AuthConfig::from_env(),
memory: crate::memory::MemoryService::from_env(),
mcp: crate::mcp::McpHub::new(),
calls: crate::state::CallRegistry::default(),
wakes: crate::state::WakeBus::default(),
}
}
async fn job_status(pool: &sqlx::PgPool, id: &str) -> String {
sqlx::query_scalar("SELECT status FROM computer_jobs WHERE id=$1")
.bind(id)
.fetch_one(pool)
.await
.unwrap()
}
/// A background job that finished (or vanished with its supervisor) while
/// nobody polled it must not keep the Computer busy forever.
#[sqlx::test(migrations = "../../migrations")]
async fn idle_reaper_reconciles_unpolled_jobs_with_the_computer(pool: sqlx::PgPool) {
sqlx::query("INSERT INTO users(id,name) VALUES ('u','test')")
.execute(&pool)
.await
.unwrap();
sqlx::query("INSERT INTO spaces(id,user_id,name) VALUES ('s','u','test')")
.execute(&pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO computers(id,space_id,user_id,scope,scope_key,home_key,provider_ref,state)
VALUES ('c','s','u','team','team:s','home','container','running')",
)
.execute(&pool)
.await
.unwrap();
let state = app(pool.clone());
let computer: ComputerRow = sqlx::query_as(
"SELECT id, space_id, user_id, scope, scope_key, home_key, home_revision, kind, provider_ref, state,
control_holder, control_lease_id, control_lease_expires_at, control_bot_id, control_run_id,
execution_run_id, execution_bot_id, execution_lease_expires_at, execution_fence,
browser_profile_mode, generation
FROM computers WHERE id='c'",
)
.fetch_one(&pool)
.await
.unwrap();
let computer_ref = computer_ref(&computer).unwrap();
let adapter = idle_adapter(&computer, "test");
// Three jobs the DB believes are running: one really is, one finished
// unobserved, one the Computer has never heard of (supervisor restart).
for (id, argv) in [("job-live", "/bin/sleep"), ("job-done", "/bin/true")] {
state
.sandbox
.execute(
&computer_ref,
CommandRequest {
argv: vec![argv.into(), "30".into()],
background: true,
job_id: Some(id.into()),
..CommandRequest::default()
},
&adapter,
)
.await
.unwrap();
}
for id in ["job-live", "job-done", "job-lost"] {
sqlx::query(
"INSERT INTO computer_jobs(id,computer_id,bot_id,status) VALUES ($1,'c','b','running')",
)
.bind(id)
.execute(&pool)
.await
.unwrap();
}
assert!(computer_has_active_work(&state, "c").await);
refresh_running_jobs(&state, &computer).await;
assert_eq!(job_status(&pool, "job-live").await, "running");
assert_eq!(job_status(&pool, "job-done").await, "succeeded");
assert_eq!(job_status(&pool, "job-lost").await, "interrupted");
assert!(computer_has_active_work(&state, "c").await);
sqlx::query("DELETE FROM computer_jobs WHERE id='job-live'")
.execute(&pool)
.await
.unwrap();
assert!(!computer_has_active_work(&state, "c").await);
// A live run on a bot bound to this Computer is work too (this guard
// used to be dead: `SELECT 1` never decoded).
sqlx::query(
"INSERT INTO bots(id,space_id,user_id,name,computer_id) VALUES ('b','s','u','bot','c')",
)
.execute(&pool)
.await
.unwrap();
sqlx::query("INSERT INTO threads(id,space_id,user_id,bot_id) VALUES ('t','s','u','b')")
.execute(&pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO runs(id,space_id,user_id,bot_id,thread_id,status,trigger,prompt)
VALUES ('r','s','u','b','t','running','message','go')",
)
.execute(&pool)
.await
.unwrap();
assert!(computer_has_active_work(&state, "c").await);
}
}

View File

@ -75,6 +75,7 @@ pub struct ComputerRow {
pub execution_lease_expires_at: Option<DateTime<Utc>>, pub execution_lease_expires_at: Option<DateTime<Utc>>,
pub execution_fence: i32, pub execution_fence: i32,
pub browser_profile_mode: String, pub browser_profile_mode: String,
pub generation: i32,
} }
#[derive(Debug, Clone, FromRow)] #[derive(Debug, Clone, FromRow)]
@ -578,7 +579,7 @@ impl Db {
"SELECT id, space_id, user_id, scope, scope_key, home_key, home_revision, kind, provider_ref, state, "SELECT id, space_id, user_id, scope, scope_key, home_key, home_revision, kind, provider_ref, state,
control_holder, control_lease_id, control_lease_expires_at, control_bot_id, control_run_id, control_holder, control_lease_id, control_lease_expires_at, control_bot_id, control_run_id,
execution_run_id, execution_bot_id, execution_lease_expires_at, execution_fence, execution_run_id, execution_bot_id, execution_lease_expires_at, execution_fence,
browser_profile_mode browser_profile_mode, generation
FROM computers WHERE id = $1", FROM computers WHERE id = $1",
) )
.bind(computer_id) .bind(computer_id)
@ -734,7 +735,7 @@ async fn ensure_computer(
"SELECT id, space_id, user_id, scope, scope_key, home_key, home_revision, kind, provider_ref, state, "SELECT id, space_id, user_id, scope, scope_key, home_key, home_revision, kind, provider_ref, state,
control_holder, control_lease_id, control_lease_expires_at, control_bot_id, control_run_id, control_holder, control_lease_id, control_lease_expires_at, control_bot_id, control_run_id,
execution_run_id, execution_bot_id, execution_lease_expires_at, execution_fence, execution_run_id, execution_bot_id, execution_lease_expires_at, execution_fence,
browser_profile_mode browser_profile_mode, generation
FROM computers WHERE scope_key = $1", FROM computers WHERE scope_key = $1",
) )
.bind(scope_key) .bind(scope_key)

View File

@ -0,0 +1,10 @@
//! Process-local Runner jobs. Shared so Computer recreate can interrupt
//! leftovers without a tools ↔ computer module cycle.
use lazyboy_control::JobSupervisor;
pub static JOBS: std::sync::LazyLock<JobSupervisor> =
std::sync::LazyLock::new(JobSupervisor::default);
pub fn interrupt_computer(computer_id: &str) {
JOBS.interrupt_computer(computer_id);
}

View File

@ -1,14 +1,17 @@
mod accounts; mod accounts;
mod artifacts;
mod attachments; mod attachments;
mod auth; mod auth;
mod computer; mod computer;
mod context_fit; mod context_fit;
mod db; mod db;
mod file_skills; mod file_skills;
mod job_store;
mod mcp; mod mcp;
mod mcp_catalog; mod mcp_catalog;
mod memory; mod memory;
mod monitor; mod monitor;
mod operations;
mod retention; mod retention;
mod rooms; mod rooms;
mod routes; mod routes;
@ -19,6 +22,7 @@ mod screen_proxy;
mod sessions; mod sessions;
mod skills; mod skills;
mod state; mod state;
mod tool_install;
mod tools; mod tools;
mod vault; mod vault;
mod voice; mod voice;

View File

@ -12,11 +12,9 @@ use rig_core::completion::ToolDefinition;
use rmcp::model::{CallToolRequestParams, ClientInfo, Tool}; use rmcp::model::{CallToolRequestParams, ClientInfo, Tool};
use rmcp::service::RunningService; use rmcp::service::RunningService;
use rmcp::transport::StreamableHttpClientTransport; use rmcp::transport::StreamableHttpClientTransport;
use rmcp::transport::child_process::TokioChildProcess;
use rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig; use rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig;
use rmcp::{RoleClient, ServiceExt}; use rmcp::{RoleClient, ServiceExt};
use serde_json::{Map, Value, json}; use serde_json::{Map, Value, json};
use tokio::process::Command;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use uuid::Uuid; use uuid::Uuid;
@ -34,7 +32,9 @@ pub struct McpHub {
} }
struct Live { struct Live {
client: LiveClient, space_id: String,
user_id: String,
client: std::sync::Arc<LiveClient>,
tools: Vec<McpTool>, tools: Vec<McpTool>,
defs: Vec<ToolDefinition>, defs: Vec<ToolDefinition>,
} }
@ -74,18 +74,38 @@ impl McpHub {
.collect() .collect()
} }
pub async fn definitions(&self) -> Vec<ToolDefinition> { pub async fn definitions_for(&self, actor: &Actor) -> Vec<ToolDefinition> {
let live = self.inner.lock().await; let live = self.inner.lock().await;
live.values().flat_map(|entry| entry.defs.clone()).collect() live.values()
.filter(|entry| entry.space_id == actor.space_id && entry.user_id == actor.user_id)
.flat_map(|entry| entry.defs.clone())
.collect()
} }
pub async fn call(&self, exposed: &str, args: &Value) -> Result<String, String> { pub async fn call_for(
&self,
actor: Option<&Actor>,
exposed: &str,
args: &Value,
) -> Result<String, String> {
let found = {
let live = self.inner.lock().await; let live = self.inner.lock().await;
for entry in live.values() { live.values().find_map(|entry| {
for tool in &entry.tools { if let Some(actor) = actor
if tool.exposed_name != exposed { && (entry.space_id != actor.space_id || entry.user_id != actor.user_id)
continue; {
return None;
} }
entry
.tools
.iter()
.find(|tool| tool.exposed_name == exposed)
.map(|tool| (entry.client.clone(), tool.name.clone()))
})
};
let Some((client, name)) = found else {
return Err(format!("unknown MCP tool {exposed}"));
};
let arguments = match args { let arguments = match args {
Value::Object(map) => map.clone(), Value::Object(map) => map.clone(),
Value::Null => Map::new(), Value::Null => Map::new(),
@ -95,17 +115,12 @@ impl McpHub {
map map
} }
}; };
let params = let params = CallToolRequestParams::new(name).with_arguments(arguments);
CallToolRequestParams::new(tool.name.clone()).with_arguments(arguments); let result = client
let result = entry
.client
.call_tool(params) .call_tool(params)
.await .await
.map_err(|error| error.to_string())?; .map_err(|error| error.to_string())?;
return serde_json::to_string_pretty(&result).map_err(|error| error.to_string()); serde_json::to_string_pretty(&result).map_err(|error| error.to_string())
}
}
Err(format!("unknown MCP tool {exposed}"))
} }
pub async fn disconnect(&self, id: &str) { pub async fn disconnect(&self, id: &str) {
@ -178,7 +193,9 @@ impl McpHub {
self.inner.lock().await.insert( self.inner.lock().await.insert(
row.id.clone(), row.id.clone(),
Live { Live {
client, space_id: row.space_id.clone(),
user_id: row.user_id.clone(),
client: std::sync::Arc::new(client),
tools: tools.clone(), tools: tools.clone(),
defs, defs,
}, },
@ -262,43 +279,8 @@ fn exposed_name_for(slug: &str, tool: &str) -> String {
async fn connect_client(row: &McpRow) -> Result<LiveClient, String> { async fn connect_client(row: &McpRow) -> Result<LiveClient, String> {
match row.transport.as_str() { match row.transport.as_str() {
"stdio" => { "stdio" => {
let command = row debug_assert!(!lazyboy_control::api_host_may_spawn_stdio());
.command Err(lazyboy_control::catalog_stdio_api_error().into())
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| "stdio 需要 command".to_string())?;
let mut cmd = Command::new(command);
cmd.env_clear();
for key in [
"PATH",
"HOME",
"LANG",
"LC_ALL",
"TMPDIR",
"PYTHONPATH",
"NODE_EXTRA_CA_CERTS",
] {
if let Some(value) = std::env::var_os(key) {
cmd.env(key, value);
}
}
cmd.kill_on_drop(true);
cmd.args(&row.args);
cmd.stdin(std::process::Stdio::piped());
cmd.stdout(std::process::Stdio::piped());
cmd.stderr(std::process::Stdio::piped());
for (key, value) in &row.env {
if let Some(text) = value.as_str() {
cmd.env(key, text);
}
}
let transport = TokioChildProcess::new(cmd)
.map_err(|error| humanize_mcp_error(Some(command), &error.to_string()))?;
ClientInfo::default()
.serve(transport)
.await
.map_err(|error| humanize_mcp_error(Some(command), &error.to_string()))
} }
"http" | "sse" => { "http" | "sse" => {
let url = row let url = row
@ -359,6 +341,8 @@ fn humanize_mcp_error(command: Option<&str>, error: &str) -> String {
#[derive(Clone)] #[derive(Clone)]
pub struct McpRow { pub struct McpRow {
pub id: String, pub id: String,
pub space_id: String,
pub user_id: String,
pub name: String, pub name: String,
pub transport: String, pub transport: String,
pub command: Option<String>, pub command: Option<String>,
@ -410,6 +394,8 @@ type RowTuple = (
fn row_from(tuple: RowTuple) -> McpRow { fn row_from(tuple: RowTuple) -> McpRow {
McpRow { McpRow {
id: tuple.0, id: tuple.0,
space_id: String::new(),
user_id: String::new(),
name: tuple.1, name: tuple.1,
transport: tuple.2, transport: tuple.2,
command: tuple.3, command: tuple.3,
@ -449,7 +435,15 @@ async fn load_rows(pool: &sqlx::PgPool, actor: &Actor) -> Result<Vec<McpRow>, sq
.bind(&actor.user_id) .bind(&actor.user_id)
.fetch_all(pool) .fetch_all(pool)
.await?; .await?;
Ok(rows.into_iter().map(row_from).collect()) Ok(rows
.into_iter()
.map(|tuple| {
let mut row = row_from(tuple);
row.space_id = actor.space_id.clone();
row.user_id = actor.user_id.clone();
row
})
.collect())
} }
async fn load_row( async fn load_row(
@ -466,7 +460,12 @@ async fn load_row(
.bind(&actor.user_id) .bind(&actor.user_id)
.fetch_optional(pool) .fetch_optional(pool)
.await?; .await?;
Ok(row.map(row_from)) Ok(row.map(|tuple| {
let mut row = row_from(tuple);
row.space_id = actor.space_id.clone();
row.user_id = actor.user_id.clone();
row
}))
} }
pub fn router() -> Router<AppState> { pub fn router() -> Router<AppState> {
@ -533,7 +532,13 @@ fn validate_input(input: &UpsertMcpServerInput) -> Result<(), ApiError> {
match input.transport.as_str() { match input.transport.as_str() {
"stdio" => { "stdio" => {
if input.command.as_deref().unwrap_or("").trim().is_empty() { if input.command.as_deref().unwrap_or("").trim().is_empty() {
return Err(bad("stdio 需要 command例如 npx 或 uvx")); return Err(bad("stdio 需要 command例如已審查的 Computer 套件路徑"));
}
if lazyboy_control::refuse_unpinned_npx(
input.command.as_deref().unwrap_or(""),
&input.args,
) {
return Err(bad(lazyboy_control::catalog_stdio_api_error()));
} }
} }
"http" | "sse" => { "http" | "sse" => {
@ -592,7 +597,15 @@ async fn create_server(
} }
.to_string(); .to_string();
let mut tools = Vec::new(); let mut tools = Vec::new();
if row.enabled { let mut error = None;
if row.enabled && row.transport == "stdio" {
error = Some(lazyboy_control::catalog_stdio_api_error().to_string());
let _ = sqlx::query("UPDATE mcp_servers SET last_error=$2 WHERE id=$1")
.bind(&id)
.bind(error.as_deref())
.execute(state.pool())
.await;
} else if row.enabled {
match tokio::time::timeout(CONNECT_TIMEOUT, state.mcp.connect_row(&row)).await { match tokio::time::timeout(CONNECT_TIMEOUT, state.mcp.connect_row(&row)).await {
Ok(Ok(connected)) => { Ok(Ok(connected)) => {
status = "connected".into(); status = "connected".into();
@ -610,7 +623,7 @@ async fn create_server(
} }
Ok(( Ok((
StatusCode::CREATED, StatusCode::CREATED,
Json(row.into_server(status, None, tools)), Json(row.into_server(status, error, tools)),
)) ))
} }
@ -776,3 +789,22 @@ async fn reconnect_server(
} }
} }
} }
#[cfg(test)]
mod tests {
#[test]
fn mcp_rs_does_not_spawn_stdio_on_the_api_host() {
let src = include_str!("mcp.rs");
let child = format!("{}{}{}", "use rmcp::", "transport::", "child_process");
let command = format!("{}{}", "use tokio::process::", "Command");
assert!(
!src.contains(&child),
"catalog stdio must not be a child of the API process"
);
assert!(!src.contains(&command));
assert_eq!(
lazyboy_control::mcp_execution_location("stdio"),
lazyboy_control::McpExecutionLocation::AssignedComputer
);
}
}

View File

@ -43,6 +43,35 @@ impl ModelState {
} }
} }
/// Load the ONNX Runtime shared library before fastembed touches `ort`.
///
/// `ort`'s lazy loader panics (`expect`) inside its global environment mutex
/// when the dylib is missing. `catch_unwind` around fastembed would swallow that
/// panic but leave the mutex poisoned, and ort's `.fini_array` hook then panics
/// again at process exit, turning a clean shutdown into SIGABRT. `init_from`
/// walks the same `ORT_DYLIB_PATH` / default-name resolution but returns `Err`.
fn preload_onnx_runtime() -> Result<(), String> {
static LOADED: std::sync::OnceLock<Result<(), String>> = std::sync::OnceLock::new();
LOADED
.get_or_init(|| {
let path = match std::env::var("ORT_DYLIB_PATH") {
Ok(value) if !value.is_empty() => value,
#[cfg(target_os = "macos")]
_ => "libonnxruntime.dylib".to_string(),
#[cfg(not(target_os = "macos"))]
_ => "libonnxruntime.so".to_string(),
};
load_onnx_runtime(std::path::Path::new(&path))
})
.clone()
}
fn load_onnx_runtime(path: &std::path::Path) -> Result<(), String> {
ort::init_from(path)
.map(|_builder| ())
.map_err(|error| format!("ONNX Runtime unavailable: {error}"))
}
#[derive(Debug, Clone, Serialize, FromRow)] #[derive(Debug, Clone, Serialize, FromRow)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct MemoryItem { pub struct MemoryItem {
@ -208,6 +237,7 @@ impl MemoryService {
} }
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
if !matches!(*state, ModelState::Ready(_)) { if !matches!(*state, ModelState::Ready(_)) {
preload_onnx_runtime()?;
let options = TextInitOptions::new(EmbeddingModel::ParaphraseMLMiniLML12V2) let options = TextInitOptions::new(EmbeddingModel::ParaphraseMLMiniLML12V2)
.with_cache_dir(cache_dir) .with_cache_dir(cache_dir)
.with_show_download_progress(false); .with_show_download_progress(false);
@ -865,6 +895,15 @@ mod tests {
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use uuid::Uuid; use uuid::Uuid;
#[test]
fn a_missing_onnx_runtime_is_an_error_not_a_panic() {
// Going through ort's lazy loader here would `expect` while holding its
// global lock and abort the whole test binary at exit.
let missing = std::env::temp_dir().join(format!("no-ort-{}.so", Uuid::new_v4()));
let error = super::load_onnx_runtime(&missing).unwrap_err();
assert!(error.starts_with("ONNX Runtime unavailable"), "{error}");
}
#[tokio::test] #[tokio::test]
#[ignore = "requires the downloaded embedding model and ONNX runtime"] #[ignore = "requires the downloaded embedding model and ONNX runtime"]
async fn memory_model_recovers_after_cache_failure() { async fn memory_model_recovers_after_cache_failure() {
@ -1207,6 +1246,11 @@ mod tests {
); );
let mut recall_service = service.clone(); let mut recall_service = service.clone();
recall_service.enabled = true; recall_service.enabled = true;
// Model the "embedding backend is down" case explicitly instead of relying
// on a missing ONNX runtime to fail (that path is environment-dependent).
recall_service.model = Arc::new(Mutex::new(ModelState::Unavailable {
retry_at: std::time::Instant::now() + std::time::Duration::from_secs(3600),
}));
let actor = Actor { let actor = Actor {
user_id: "u".into(), user_id: "u".into(),
space_id: "s".into(), space_id: "s".into(),

View File

@ -87,15 +87,31 @@ async fn memory_usage_items(
/// Append one line to the run's trail. Diagnostics never fail a run: a write /// Append one line to the run's trail. Diagnostics never fail a run: a write
/// that cannot land is logged and dropped. /// that cannot land is logged and dropped.
pub async fn record(state: &AppState, run_id: &str, kind: &str, payload: Value) { pub async fn record(state: &AppState, run_id: &str, kind: &str, payload: Value) {
let result = sqlx::query("INSERT INTO run_activity (run_id,kind,payload) VALUES ($1,$2,$3)") record_pool(state.pool(), run_id, kind, payload).await;
}
pub async fn record_pool(pool: &sqlx::PgPool, run_id: &str, kind: &str, payload: Value) {
if let Err(error) = try_record_pool(pool, run_id, kind, payload).await {
tracing::warn!(run_id, kind, "failed to record run activity: {error}");
}
}
/// Same as [`record_pool`] but lets the caller decide what a failed write
/// means (the outbox must not mark an event delivered on failure).
pub async fn try_record_pool(
pool: &sqlx::PgPool,
run_id: &str,
kind: &str,
mut payload: Value,
) -> Result<(), sqlx::Error> {
lazyboy_control::redact_json(&mut payload);
sqlx::query("INSERT INTO run_activity (run_id,kind,payload) VALUES ($1,$2,$3)")
.bind(run_id) .bind(run_id)
.bind(kind) .bind(kind)
.bind(clamp_strings(&payload, MAX_STRING_CHARS)) .bind(clamp_strings(&payload, MAX_STRING_CHARS))
.execute(state.pool()) .execute(pool)
.await; .await
if let Err(error) = result { .map(|_| ())
tracing::warn!(run_id, kind, "failed to record run activity: {error}");
}
} }
/// One line of text for the trail: single line, bounded, never an image. /// One line of text for the trail: single line, bounded, never an image.

View File

@ -0,0 +1,473 @@
//! Durable operation ledger and outbox. Mutations are not started if the
//! journal insert fails. The same operation_id returns the stored result;
//! a different payload hash is rejected.
//!
//! Postgres is the only source of truth here. Ledger rows are keyed per bot
//! (`{bot_id}:{operation_id}`): an operation id is chosen by the model, so two
//! bots picking the same string must never see each other's results.
use serde_json::{Value, json};
use sqlx::PgPool;
use uuid::Uuid;
use lazyboy_control::{OperationLedger, redact_json, redact_secret_patterns};
use crate::tools::{ToolCtx, ToolOutcome};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Begin {
Proceed,
Replay {
text: String,
error_code: Option<String>,
pause: bool,
},
/// Same id and payload, but the earlier attempt never recorded a result
/// (crash, timeout, halt). Its effect is unknown, so it is not re-run.
InProgress,
PayloadMismatch,
JournalUnavailable,
}
pub fn payload_hash(name: &str, args: &Value) -> String {
let mut args = args.clone();
if let Some(object) = args.as_object_mut() {
object.remove("operation_id");
object.remove("operationId");
}
OperationLedger::payload_hash(format!("{name}:{args}").as_bytes())
}
pub fn mutating_tool(name: &str, args: &Value) -> bool {
match name {
"computer_act" | "shell" | "write_file" | "launch_app" | "open_path"
| "create_schedule" | "cancel_schedule" | "remember" | "forget_memory"
| "use_saved_login" | "request_takeover" | "form_fill" | "computer_mcp" => true,
// Asking about a job changes nothing; starting or cancelling one does.
"exec" => !matches!(args.get("action").and_then(Value::as_str), Some("status")),
"browser" => matches!(
args.get("action").and_then(Value::as_str),
Some("click") | Some("type") | Some("navigate") | Some("press")
),
_ => false,
}
}
/// Ledger row id. Namespaced per bot so a model-chosen id cannot collide with
/// (or replay) another bot's operation.
pub fn ledger_key(bot_id: &str, operation_id: &str) -> String {
format!("{bot_id}:{operation_id}")
}
pub async fn begin(ctx: &ToolCtx, name: &str, args: &Value, operation_id: &str) -> Begin {
if !mutating_tool(name, args) {
return Begin::Proceed;
}
let hash = payload_hash(name, args);
// A DB error here is a journal failure, not "this bot has no Computer":
// tools that need no Computer (memory, schedules) are journaled with NULL.
let computer_id = match bot_computer_id(ctx).await {
Ok(id) => id,
Err(_) => return Begin::JournalUnavailable,
};
persist_begin(
&ctx.pool,
&ledger_key(&ctx.bot_id, operation_id),
computer_id.as_deref(),
&ctx.bot_id,
&ctx.run_id,
&hash,
)
.await
.unwrap_or(Begin::JournalUnavailable)
}
async fn persist_begin(
pool: &PgPool,
key: &str,
computer_id: Option<&str>,
bot_id: &str,
run_id: &str,
hash: &str,
) -> Result<Begin, sqlx::Error> {
let existing: Option<(String, Option<String>, String)> = sqlx::query_as(
"SELECT payload_hash, result, status FROM computer_operations WHERE id=$1 AND bot_id=$2",
)
.bind(key)
.bind(bot_id)
.fetch_optional(pool)
.await?;
if let Some((stored_hash, result, status)) = existing {
if stored_hash != hash {
return Ok(Begin::PayloadMismatch);
}
if status != "accepted"
&& let Some(text) = result.filter(|value| !value.is_empty())
{
return Ok(replay_from_stored(&text));
}
return Ok(Begin::InProgress);
}
sqlx::query(
"INSERT INTO computer_operations (id, computer_id, bot_id, run_id, payload_hash, status)
VALUES ($1,$2,$3,$4,$5,'accepted')",
)
.bind(key)
.bind(computer_id)
.bind(bot_id)
.bind(run_id)
.bind(hash)
.execute(pool)
.await?;
Ok(Begin::Proceed)
}
fn replay_from_stored(stored: &str) -> Begin {
if let Ok(value) = serde_json::from_str::<Value>(stored) {
Begin::Replay {
text: value
.get("text")
.and_then(Value::as_str)
.unwrap_or(stored)
.to_string(),
error_code: value
.get("errorCode")
.and_then(Value::as_str)
.map(str::to_string),
pause: value.get("pause").and_then(Value::as_bool).unwrap_or(false),
}
} else {
Begin::Replay {
text: stored.to_string(),
error_code: None,
pause: false,
}
}
}
/// Transport-level failures (`exec failed: …`, `write_file failed: …`) are
/// reported as prose by the native tools; storing them as `succeeded` would
/// replay the failure forever instead of letting the same operation retry.
fn outcome_status(outcome: &ToolOutcome) -> &'static str {
if outcome.error_code.is_some() {
return "failed";
}
let head: String = outcome.text.chars().take(80).collect();
if head.contains(" failed: ") {
return "failed";
}
"succeeded"
}
pub async fn finish(
ctx: &ToolCtx,
operation_id: &str,
name: &str,
args: &Value,
outcome: &ToolOutcome,
) {
if !mutating_tool(name, args) {
return;
}
let key = ledger_key(&ctx.bot_id, operation_id);
let status = outcome_status(outcome);
let text = redact_secret_patterns(&outcome.text);
// Transport failures leave no durable result: the same operation id may be
// retried, and `persist_begin` will let it proceed again.
let stored = if status == "failed" && outcome.error_code.is_none() {
None
} else {
Some(
json!({
"text": text,
"errorCode": outcome.error_code,
"pause": outcome.pause,
})
.to_string(),
)
};
let row_status = if stored.is_some() {
status
} else {
"retryable"
};
if stored.is_some() {
let _ = sqlx::query(
"UPDATE computer_operations SET status=$3, result=$4
WHERE id=$1 AND bot_id=$2 AND status='accepted'",
)
.bind(&key)
.bind(&ctx.bot_id)
.bind(row_status)
.bind(&stored)
.execute(&ctx.pool)
.await;
} else {
// Drop the accepted row so a retry with the same id is not treated as
// in progress.
let _ = sqlx::query(
"DELETE FROM computer_operations WHERE id=$1 AND bot_id=$2 AND status='accepted'",
)
.bind(&key)
.bind(&ctx.bot_id)
.execute(&ctx.pool)
.await;
}
let event_key = format!("tool:{key}");
let mut payload = json!({
"runId": ctx.run_id,
"botId": ctx.bot_id,
"name": name,
"operationId": operation_id,
"errorCode": outcome.error_code,
"snippet": crate::monitor::snippet(&text, 200),
});
redact_json(&mut payload);
let _ = sqlx::query(
"INSERT INTO operation_outbox (id, event_key, payload) VALUES ($1,$2,$3)
ON CONFLICT (event_key) DO NOTHING",
)
.bind(Uuid::new_v4().to_string())
.bind(&event_key)
.bind(&payload)
.execute(&ctx.pool)
.await;
}
pub async fn flush_outbox(pool: &PgPool) {
let rows: Vec<(String, String, Value)> = sqlx::query_as(
"SELECT id, event_key, payload FROM operation_outbox
WHERE delivered=false AND created_at < now() - interval '2 seconds'
ORDER BY created_at LIMIT 50",
)
.fetch_all(pool)
.await
.unwrap_or_default();
for (id, _key, payload) in rows {
if let Some(run_id) = payload.get("runId").and_then(Value::as_str) {
let operation_id = payload
.get("operationId")
.and_then(Value::as_str)
.unwrap_or("");
// Row presence only; do not decode `SELECT 1` (INT4) into i64 — that
// errored on every row and re-recorded each operation.
let exists = sqlx::query(
"SELECT 1 FROM run_activity
WHERE run_id=$1 AND kind='tool' AND payload->>'operationId'=$2
LIMIT 1",
)
.bind(run_id)
.bind(operation_id)
.fetch_optional(pool)
.await;
match exists {
Ok(Some(_)) => {}
Ok(None) => {
// At-least-once: only a successful write may mark the row
// delivered; otherwise it stays for the next pass.
if crate::monitor::try_record_pool(pool, run_id, "tool", payload.clone())
.await
.is_err()
{
continue;
}
}
// Cannot tell whether it was delivered: leave the row for the
// next pass instead of risking a duplicate or a lost event.
Err(_) => continue,
}
}
let _ = sqlx::query("UPDATE operation_outbox SET delivered=true WHERE id=$1")
.bind(id)
.execute(pool)
.await;
}
}
async fn bot_computer_id(ctx: &ToolCtx) -> Result<Option<String>, sqlx::Error> {
let row: Option<Option<String>> =
sqlx::query_scalar("SELECT computer_id FROM bots WHERE id=$1")
.bind(&ctx.bot_id)
.fetch_optional(&ctx.pool)
.await?;
Ok(row.flatten())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn same_payload_hashes_equal() {
let args = json!({"path":"a.txt"});
assert_eq!(
payload_hash("write_file", &args),
payload_hash("write_file", &args)
);
assert_ne!(
payload_hash("write_file", &args),
payload_hash("write_file", &json!({"path":"b.txt"}))
);
}
#[test]
fn operation_id_is_not_part_of_the_payload_hash() {
let a = json!({"path":"a.txt","operationId":"op-1"});
let b = json!({"path":"a.txt","operation_id":"op-2"});
assert_eq!(
payload_hash("write_file", &a),
payload_hash("write_file", &b)
);
}
/// T17: the outbox only fills the gap; an operation already in the
/// activity trail must not be recorded a second time.
#[sqlx::test(migrations = "../../migrations")]
async fn outbox_flush_does_not_duplicate_recorded_operations(pool: sqlx::PgPool) {
for sql in [
"INSERT INTO users(id,name) VALUES ('u','test')",
"INSERT INTO spaces(id,user_id,name) VALUES ('s','u','test')",
"INSERT INTO bots(id,space_id,user_id,name) VALUES ('b','s','u','bot')",
"INSERT INTO threads(id,space_id,user_id,bot_id) VALUES ('t','s','u','b')",
"INSERT INTO runs(id,space_id,user_id,bot_id,thread_id,status,trigger,prompt)
VALUES ('r','s','u','b','t','running','message','go')",
"INSERT INTO run_activity(run_id,kind,payload)
VALUES ('r','tool','{\"operationId\":\"op-seen\",\"tool\":\"write_file\"}')",
] {
sqlx::query(sql).execute(&pool).await.unwrap();
}
for (key, op) in [("k1", "op-seen"), ("k2", "op-missed"), ("k3", "op-fresh")] {
// k3 is brand new: the direct activity write may still be on its
// way, so the flush must leave it alone this pass.
let age = if key == "k3" {
"0 seconds"
} else {
"10 seconds"
};
sqlx::query(&format!(
"INSERT INTO operation_outbox(id,event_key,payload,created_at)
VALUES ($1,$1,$2,now() - interval '{age}')"
))
.bind(key)
.bind(json!({"runId":"r","operationId":op,"tool":"write_file"}))
.execute(&pool)
.await
.unwrap();
}
flush_outbox(&pool).await;
let count = |op: &'static str| {
let pool = pool.clone();
async move {
sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) FROM run_activity WHERE run_id='r' AND payload->>'operationId'=$1",
)
.bind(op)
.fetch_one(&pool)
.await
.unwrap()
}
};
assert_eq!(count("op-seen").await, 1, "already recorded: no duplicate");
assert_eq!(count("op-missed").await, 1, "missing one is back-filled");
assert_eq!(count("op-fresh").await, 0, "too new to judge: not touched");
let pending: Vec<String> = sqlx::query_scalar(
"SELECT event_key FROM operation_outbox WHERE delivered=false ORDER BY event_key",
)
.fetch_all(&pool)
.await
.unwrap();
assert_eq!(pending, vec!["k3".to_string()]);
}
/// One bot's operation id must not replay another bot's result, and an
/// operation that never finished is not silently run a second time.
#[sqlx::test(migrations = "../../migrations")]
async fn ledger_is_per_bot_and_does_not_rerun_in_flight_work(pool: sqlx::PgPool) {
for sql in [
"INSERT INTO users(id,name) VALUES ('u','test')",
"INSERT INTO spaces(id,user_id,name) VALUES ('s','u','test')",
] {
sqlx::query(sql).execute(&pool).await.unwrap();
}
// Tools that need no Computer are journaled with a NULL computer_id.
let hash = payload_hash("remember", &json!({"content":"x"}));
let first = persist_begin(&pool, &ledger_key("a", "op-1"), None, "a", "r", &hash)
.await
.unwrap();
assert_eq!(first, Begin::Proceed);
// Same id, same payload, no result yet: unknown effect, do not re-run.
let again = persist_begin(&pool, &ledger_key("a", "op-1"), None, "a", "r", &hash)
.await
.unwrap();
assert_eq!(again, Begin::InProgress);
// Bot b choosing the same operation id is a different operation.
let other = persist_begin(&pool, &ledger_key("b", "op-1"), None, "b", "r", &hash)
.await
.unwrap();
assert_eq!(other, Begin::Proceed);
sqlx::query(
"UPDATE computer_operations SET status='succeeded',
result='{\"text\":\"done\",\"pause\":true}' WHERE id=$1",
)
.bind(ledger_key("a", "op-1"))
.execute(&pool)
.await
.unwrap();
let replay = persist_begin(&pool, &ledger_key("a", "op-1"), None, "a", "r", &hash)
.await
.unwrap();
assert_eq!(
replay,
Begin::Replay {
text: "done".into(),
error_code: None,
pause: true,
}
);
let other_hash = payload_hash("remember", &json!({"content":"y"}));
let mismatch = persist_begin(&pool, &ledger_key("a", "op-1"), None, "a", "r", &other_hash)
.await
.unwrap();
assert_eq!(mismatch, Begin::PayloadMismatch);
}
#[test]
fn form_fill_and_computer_mcp_are_mutations() {
assert!(mutating_tool("form_fill", &json!({})));
assert!(mutating_tool("computer_mcp", &json!({})));
assert!(!mutating_tool("list_files", &json!({})));
assert!(mutating_tool("browser", &json!({"action":"type"})));
assert!(!mutating_tool("browser", &json!({"action":"snapshot"})));
assert!(mutating_tool("exec", &json!({"argv":["ls"]})));
assert!(mutating_tool(
"exec",
&json!({"action":"cancel","jobId":"j"})
));
assert!(!mutating_tool(
"exec",
&json!({"action":"status","jobId":"j"})
));
}
#[test]
fn transport_failures_are_not_stored_as_success() {
let failed = ToolOutcome {
text: "write_file failed: connection reset".into(),
image: None,
pause: false,
blocks: Vec::new(),
error_code: None,
};
assert_eq!(outcome_status(&failed), "failed");
let ok = ToolOutcome {
text: "{\"ok\":true}".into(),
image: None,
pause: false,
blocks: Vec::new(),
error_code: None,
};
assert_eq!(outcome_status(&ok), "succeeded");
}
}

View File

@ -50,6 +50,16 @@ pub async fn retention_loop(state: AppState) {
days("LAZYBOY_MEMORY_HISTORY_RETENTION_DAYS", 90), days("LAZYBOY_MEMORY_HISTORY_RETENTION_DAYS", 90),
), ),
("leases", include_str!("retention/leases.sql"), 7), ("leases", include_str!("retention/leases.sql"), 7),
(
"operation_outbox",
include_str!("retention/operation_outbox.sql"),
days("LAZYBOY_RUN_ACTIVITY_RETENTION_DAYS", 7),
),
(
"computer_operations",
include_str!("retention/computer_operations.sql"),
days("LAZYBOY_RUN_RETENTION_DAYS", 90),
),
( (
"profile_locks", "profile_locks",
include_str!("retention/profile_locks.sql"), include_str!("retention/profile_locks.sql"),

View File

@ -0,0 +1,4 @@
DELETE FROM computer_operations WHERE id IN (
SELECT id FROM computer_operations WHERE status <> 'accepted' AND created_at < now() - make_interval(days => $1)
ORDER BY created_at LIMIT $2 FOR UPDATE SKIP LOCKED
)

View File

@ -0,0 +1,4 @@
DELETE FROM operation_outbox WHERE id IN (
SELECT id FROM operation_outbox WHERE delivered AND created_at < now() - make_interval(days => $1)
ORDER BY created_at LIMIT $2 FOR UPDATE SKIP LOCKED
)

View File

@ -24,6 +24,8 @@ pub fn router(state: AppState) -> Router {
.route("/api/file-skills", get(file_skills)) .route("/api/file-skills", get(file_skills))
.merge(crate::vault::router()) .merge(crate::vault::router())
.merge(crate::schedules::router()) .merge(crate::schedules::router())
.merge(crate::artifacts::router())
.merge(crate::tool_install::router())
.route("/api/bots", get(list_bots).post(create_bot)) .route("/api/bots", get(list_bots).post(create_bot))
.route( .route(
"/api/bots/{id}", "/api/bots/{id}",
@ -44,6 +46,7 @@ pub fn router(state: AppState) -> Router {
.route("/api/computer/{id}/takeover", post(takeover)) .route("/api/computer/{id}/takeover", post(takeover))
.route("/api/computer/{id}/release", post(release)) .route("/api/computer/{id}/release", post(release))
.route("/api/computer/{id}/heartbeat", post(heartbeat)) .route("/api/computer/{id}/heartbeat", post(heartbeat))
.route("/api/computer/{id}/health", get(computer_health))
.route("/api/computer/{id}/input", post(input)) .route("/api/computer/{id}/input", post(input))
.route("/view/{id}/", any(crate::screen_proxy::view_root)) .route("/view/{id}/", any(crate::screen_proxy::view_root))
.route("/view/{id}/{*rest}", any(crate::screen_proxy::view_path)) .route("/view/{id}/{*rest}", any(crate::screen_proxy::view_path))
@ -370,6 +373,21 @@ async fn delete_bot(
.execute(&mut *tx) .execute(&mut *tx)
.await .await
.map_err(internal_error)?; .map_err(internal_error)?;
// Agent-computer rows keyed by bot_id have no FK to bots; without this they
// would outlive the bot as orphans (and a Team Computer would keep
// counting a deleted bot's jobs against its quota).
for table in [
"tool_bindings",
"computer_jobs",
"computer_operations",
"computer_artifacts",
] {
sqlx::query(&format!("DELETE FROM {table} WHERE bot_id = $1"))
.bind(&id)
.execute(&mut *tx)
.await
.map_err(internal_error)?;
}
sqlx::query("DELETE FROM bots WHERE id = $1 AND space_id = $2 AND user_id = $3") sqlx::query("DELETE FROM bots WHERE id = $1 AND space_id = $2 AND user_id = $3")
.bind(&id) .bind(&id)
.bind(&actor.space_id) .bind(&actor.space_id)
@ -411,7 +429,7 @@ async fn delete_environment(
"SELECT id, space_id, user_id, scope, scope_key, home_key, home_revision, kind, provider_ref, state, "SELECT id, space_id, user_id, scope, scope_key, home_key, home_revision, kind, provider_ref, state,
control_holder, control_lease_id, control_lease_expires_at, control_bot_id, control_run_id, control_holder, control_lease_id, control_lease_expires_at, control_bot_id, control_run_id,
execution_run_id, execution_bot_id, execution_lease_expires_at, execution_fence, execution_run_id, execution_bot_id, execution_lease_expires_at, execution_fence,
browser_profile_mode FROM computers WHERE space_id = $1 AND user_id = $2", browser_profile_mode, generation FROM computers WHERE space_id = $1 AND user_id = $2",
) )
.bind(&id) .bind(&id)
.bind(&actor.user_id) .bind(&actor.user_id)
@ -688,6 +706,17 @@ async fn heartbeat(
.map_err(|_| StatusCode::BAD_REQUEST) .map_err(|_| StatusCode::BAD_REQUEST)
} }
async fn computer_health(
State(state): State<AppState>,
actor: Actor,
Path(id): Path<String>,
) -> Result<Json<Value>, StatusCode> {
computer::component_health(&state, &actor, &id)
.await
.map(Json)
.map_err(|_| StatusCode::NOT_FOUND)
}
#[derive(Deserialize)] #[derive(Deserialize)]
struct InputBody { struct InputBody {
kind: String, kind: String,

View File

@ -38,21 +38,21 @@ Be brief and friendly. If they ask who you are, say you can chat and also do wor
const SYSTEM: &str = "You are this bot's assistant. You have a Linux desktop you can use, but most conversation does not need it. const SYSTEM: &str = "You are this bot's assistant. You have a Linux desktop you can use, but most conversation does not need it.
Reply in text no tools for greetings, small talk, questions you can answer from knowledge, planning, or explaining. Do not call computer_observe, computer_act, browser, launch_app, open_path, wait, list_files, or shell just to check the screen or because a desktop exists. A hello does not need a screenshot or a file listing. Reply in text no tools for greetings, small talk, questions you can answer from knowledge, planning, or explaining. Do not call computer_observe, computer_act, browser, launch_app, open_path, wait, list_files, exec, or shell just to check the screen or because a desktop exists. A hello does not need a screenshot or a file listing.
Use tools only when the user wants something done on the computer: open a site, click through a UI, run a command, read/write workspace files, or follow a taught skill. Route directly by task: Use tools only when the user wants something done on the computer: open a site, click through a UI, run a command, read/write workspace files, or follow a taught skill. Route directly by task:
1) Website, video, search, email, or anything in Chromium: use browser first. Navigate directly, then snapshot/click/type/press by element id. Do not use shell/curl to inspect a web page. For canvas or controls the browser tool cannot operate, call computer_observe and use Cua computer_act coordinates from that fresh screenshot. 1) Website, video, search, or anything in Chromium: use browser first. Navigate directly, then snapshot/click/type/press by element id. Do not use shell/curl to inspect a web page. For canvas or controls the browser tool cannot operate, call computer_observe and use Cua computer_act coordinates from that fresh screenshot. Gmail/Outlook with an authorized connector use that API never fall back to the browser after POLICY_DENIED.
2) Workspace files or commands: use list_files, read_file, write_file, or shell. 2) Workspace files or commands: use list_files, read_file, write_file, or exec. Use shell only when the user asked to watch the visible GUI terminal.
3) Connected services: use an MCP tool when it directly matches the task. 3) Connected services: use an MCP tool when it directly matches the task. Packages installed on this Computer (computer_mcp) run inside the assigned container, not the API. Long web forms: prefer form_fill with locators from the latest snapshot; it stops on the first failed field.
4) Opening a local file or non-browser app: use open_path or launch_app. 4) Opening a local file or non-browser app: use open_path or launch_app.
5) Native GUI with no DOM (dialogs, file manager, XFCE): use computer_act by element id. Those ids are AT-SPI controls, not window boxes. 5) Native GUI with no DOM (dialogs, file manager, XFCE): use computer_act by element id. Those ids are AT-SPI controls, not window boxes.
The shell is a visible Cua-controlled terminal on the shared VNC screen. The same session keeps its directory, exports and background jobs. Results are screenshots, not hidden stdout: inspect the prompt to decide whether a command finished. A timeout does not stop the job. Omit command to inspect it again, use keys \"C-c\" to interrupt, and never type a second command while busy. File tools also work through this visible terminal; read_file supports start_line and lines, and you can scroll to inspect longer output. Clicking, typing, and browsing need a vision model; a text-only model can still read computer_observe as an element tree. exec returns real stdout/stderr/exit from the bound Computer. Prefer argv. The visible `shell` tool types into a Cua-controlled terminal on the shared VNC screen and returns screenshots use it for TUI collaboration, not ordinary commands. A timeout on either tool does not prove the process stopped. File tools are native (text, hash, no screenshot); read_file supports start_line and lines, and binary files are not decoded as UTF-8. Pixel clicks, the visible GUI terminal, and opening desktop apps need a vision model. A text-only model can still read computer_observe as an element tree, drive Chromium through browser snapshot/click/type by element id, and use exec/list_files/read_file/write_file.
When you ARE using the desktop: the human can interact with the same live screen while you work; this does not pause your task. Prefer browser/native element actions over moving the shared pointer. If the screen changes unexpectedly, observe again and continue from the current state; do not undo human changes or replay an uncertain click. Request human assistance only when the task needs it. Only the latest screenshot you received is current; they may have interacted since. Call computer_observe before coordinate clicks, after navigation, when the outcome is uncertain, and before describing what is on screen. Never guess the screen state from files, history or memory. Never kill or restart the browser, display, or desktop processes; if the browser tool reports it is unavailable, use computer_observe / computer_act on the existing window instead. When you ARE using the desktop: the human can interact with the same live screen while you work; this does not pause your task. Prefer browser/native element actions over moving the shared pointer. If the screen changes unexpectedly, observe again and continue from the current state; do not undo human changes or replay an uncertain click. Request human assistance only when the task needs it. Only the latest screenshot you received is current; they may have interacted since. Call computer_observe before coordinate clicks, after navigation, when the outcome is uncertain, and before describing what is on screen. Never guess the screen state from files, history or memory. Never kill or restart the browser, display, or desktop processes; if the browser tool reports it is unavailable, use computer_observe / computer_act on the existing window instead.
When you use the browser tool: When you use the browser tool:
- snapshot first; click {\"action\":\"click\",\"element\":N}; type {\"action\":\"type\",\"element\":N,\"text\":\"...\"}; open a URL with navigate. - snapshot first; click {\"action\":\"click\",\"element\":N}; type {\"action\":\"type\",\"element\":N,\"text\":\"...\"}; open a URL with navigate. CSS selectors are not supported (SELECTOR_UNSUPPORTED); use the numbered snapshot ref.
- Yellow numbered marks on the screenshot match the element list. Click the number, not guessed pixels. - Yellow numbered marks on the screenshot match the element list. Click the number, not guessed pixels.
- Elements tagged [below viewport ...] / [above viewport ...] are outside the visible area but still clickable by id; the click scrolls to them. Do not scroll manually just to reach them. - Elements tagged [below viewport ...] / [above viewport ...] are outside the visible area but still clickable by id; the click scrolls to them. Do not scroll manually just to reach them.
- A control is disabled only when its entry says [disabled]. Never claim a button is disabled, counting down or loading unless the element list or the screenshot shows that. - A control is disabled only when its entry says [disabled]. Never claim a button is disabled, counting down or loading unless the element list or the screenshot shows that.
@ -335,6 +335,12 @@ type RetryCandidateRow = (String, String, String, String, String, String);
pub async fn worker_loop(state: AppState) { pub async fn worker_loop(state: AppState) {
let inflight = Arc::new(tokio::sync::Semaphore::new(16)); let inflight = Arc::new(tokio::sync::Semaphore::new(16));
let lease_owner = format!("api-{}", Uuid::new_v4()); let lease_owner = format!("api-{}", Uuid::new_v4());
let _ = sqlx::query(
"UPDATE computer_jobs SET status='interrupted', updated_at=now()
WHERE status IN ('running','accepted')",
)
.execute(state.pool())
.await;
// Without a knock the loop sits on its 200 ms timer before it can see a run // Without a knock the loop sits on its 200 ms timer before it can see a run
// that was queued a moment ago; going straight to the claim query is what // that was queued a moment ago; going straight to the claim query is what
// makes the thinking indicator follow the message instead of the timer. // makes the thinking indicator follow the message instead of the timer.
@ -344,6 +350,7 @@ pub async fn worker_loop(state: AppState) {
_ = wakes.wait_any() => {} _ = wakes.wait_any() => {}
_ = tokio::time::sleep(Duration::from_millis(200)) => {} _ = tokio::time::sleep(Duration::from_millis(200)) => {}
} }
crate::operations::flush_outbox(state.pool()).await;
let interrupted: Vec<(String, String, String)> = sqlx::query_as( let interrupted: Vec<(String, String, String)> = sqlx::query_as(
"WITH doomed AS ( "WITH doomed AS (
UPDATE runs SET status='failed',error=$1,completed_at=now(), UPDATE runs SET status='failed',error=$1,completed_at=now(),
@ -510,7 +517,7 @@ async fn execute_run(
.map_err(|error| error.to_string())? .map_err(|error| error.to_string())?
.ok_or_else(|| "computer not found".to_string())?; .ok_or_else(|| "computer not found".to_string())?;
let (model, vision) = bot_model(state, actor, &bot, thread_id).await?; let (model, vision, model_id) = 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 {
@ -526,9 +533,15 @@ async fn execute_run(
mode: parse_mode(&computer.scope), mode: parse_mode(&computer.scope),
bot_id: bot_id.to_string(), bot_id: bot_id.to_string(),
vision, vision,
model_id,
gui_block: std::sync::Mutex::new(None), gui_block: std::sync::Mutex::new(None),
previous_frame: std::sync::Mutex::new(None), previous_frame: std::sync::Mutex::new(None),
previous_signature: std::sync::Mutex::new(None), previous_signature: std::sync::Mutex::new(None),
delivered_frame: std::sync::Mutex::new(None),
delivered_signature: std::sync::Mutex::new(None),
delivered_model_id: std::sync::Mutex::new(None),
force_image: std::sync::Mutex::new(false),
pending_image_delivery: std::sync::Mutex::new(None),
elements: std::sync::Mutex::new(Vec::new()), elements: std::sync::Mutex::new(Vec::new()),
miss_streak: std::sync::Mutex::new(0), miss_streak: std::sync::Mutex::new(0),
last_click_key: std::sync::Mutex::new(None), last_click_key: std::sync::Mutex::new(None),
@ -542,10 +555,26 @@ async fn execute_run(
run_id: run_id.to_string(), run_id: run_id.to_string(),
memory_enabled: bot.memory_enabled && state.memory.globally_enabled(), memory_enabled: bot.memory_enabled && state.memory.globally_enabled(),
mcp: state.mcp.clone(), mcp: state.mcp.clone(),
last_operation_id: std::sync::Mutex::new(None),
}); });
let mut defs = tool_definitions(ctx.memory_enabled); let mut defs = tool_definitions(ctx.memory_enabled);
let mcp_defs = state.mcp.definitions().await; let computer_mcp_bound = crate::tool_install::package_is_bound(
state.pool(),
actor,
bot_id,
crate::tool_install::ECHO_ID,
)
.await
.unwrap_or(false);
let mcp_defs = state.mcp.definitions_for(actor).await;
let granted: Vec<String> = mcp_defs.iter().map(|tool| tool.name.clone()).collect();
let allowed = lazyboy_control::filter_run_tool_names(
defs.iter().map(|tool| tool.name.as_str()),
computer_mcp_bound,
&granted,
);
defs.retain(|tool| allowed.iter().any(|name| name == &tool.name));
if !mcp_defs.is_empty() { if !mcp_defs.is_empty() {
defs.extend(mcp_defs); defs.extend(mcp_defs);
} }
@ -761,12 +790,14 @@ async fn execute_run(
prepare_run_computer(state, actor, bot_id, run_id, &ctx, true).await?; prepare_run_computer(state, actor, bot_id, run_id, &ctx, true).await?;
} }
if resume_after_takeover && ctx.gui_block.lock().unwrap().is_none() { if resume_after_takeover && ctx.gui_block.lock().unwrap().is_none() {
ctx.request_force_image();
let outcome = dispatch(&ctx, "computer_observe", &json!({})).await; let outcome = dispatch(&ctx, "computer_observe", &json!({})).await;
first.push(UserContent::text(outcome.text)); first.push(UserContent::text(outcome.text));
if let Some(image) = outcome.image { if let Some(image) = outcome.image {
screenshot_bytes += image.len() as u64; screenshot_bytes += image.len() as u64;
screenshots += 1; screenshots += 1;
first.extend(screenshot_parts(image)); first.extend(screenshot_parts(image));
ctx.commit_image_delivery();
} }
} }
let mut pending = Message::User { content: first }; let mut pending = Message::User { content: first };
@ -785,6 +816,8 @@ async fn execute_run(
content.push(UserContent::text("Resumed after a completed tool batch. Do not repeat completed actions. Observe current browser/desktop before any new mutation; prior element references may be stale.")); content.push(UserContent::text("Resumed after a completed tool batch. Do not repeat completed actions. Observe current browser/desktop before any new mutation; prior element references may be stale."));
} }
pending = next; pending = next;
// Checkpoints strip images, so the model no longer holds the last frame.
ctx.request_force_image();
} }
let mut final_text = String::new(); let mut final_text = String::new();
@ -1044,6 +1077,9 @@ async fn execute_run(
defs_chars, defs_chars,
None, None,
); );
if !history.iter().any(has_screenshot) && !has_screenshot(&pending) {
ctx.request_force_image();
}
if fit.compacted { if fit.compacted {
tracing::info!( tracing::info!(
run_id, run_id,
@ -1227,6 +1263,7 @@ async fn execute_run(
screenshot_bytes += image.len() as u64; screenshot_bytes += image.len() as u64;
screenshots += 1; screenshots += 1;
content.extend(screenshot_parts(image)); content.extend(screenshot_parts(image));
ctx.commit_image_delivery();
} }
} }
pending = Message::User { content }; pending = Message::User { content };
@ -1310,6 +1347,8 @@ async fn execute_run(
| "wait" | "wait"
| "use_saved_login" | "use_saved_login"
| "request_takeover" | "request_takeover"
| "shell"
| "form_fill"
); );
did_work = true; did_work = true;
let step = describe_step(&name, &call.function.arguments); let step = describe_step(&name, &call.function.arguments);
@ -1350,6 +1389,7 @@ async fn execute_run(
image: None, image: None,
pause: false, pause: false,
blocks: Vec::new(), blocks: Vec::new(),
error_code: None,
} }
} }
} }
@ -1365,6 +1405,11 @@ async fn execute_run(
"tool call" "tool call"
); );
let status = tool_status(tool_timed_out, outcome.pause, &outcome.text); let status = tool_status(tool_timed_out, outcome.pause, &outcome.text);
// Background jobs answer with JSON carrying jobId; surface it so the
// monitor can show which job a status/cancel line refers to.
let job_id = serde_json::from_str::<Value>(&outcome.text)
.ok()
.and_then(|value| value.get("jobId")?.as_str().map(str::to_string));
crate::monitor::record( crate::monitor::record(
state, state,
run_id, run_id,
@ -1374,6 +1419,9 @@ async fn execute_run(
"name": name.clone(), "name": name.clone(),
"step": step.clone(), "step": step.clone(),
"status": status, "status": status,
"errorCode": outcome.error_code,
"jobId": job_id,
"operationId": ctx.last_operation_id.lock().unwrap().clone(),
"elapsedMs": tool_started.elapsed().as_millis() as u64, "elapsedMs": tool_started.elapsed().as_millis() as u64,
"snippet": crate::monitor::snippet(&outcome.text, 200), "snippet": crate::monitor::snippet(&outcome.text, 200),
}), }),
@ -1498,6 +1546,7 @@ async fn execute_run(
screenshot_bytes += png.len() as u64; screenshot_bytes += png.len() as u64;
screenshots += 1; screenshots += 1;
results.extend(screenshot_parts(png)); results.extend(screenshot_parts(png));
ctx.commit_image_delivery();
} }
pending = Message::User { content: results }; pending = Message::User { content: results };
save_harness_checkpoint( save_harness_checkpoint(
@ -1651,7 +1700,7 @@ pub(crate) async fn bot_model(
actor: &Actor, actor: &Actor,
bot: &crate::db::BotRow, bot: &crate::db::BotRow,
session: &str, session: &str,
) -> Result<(DynModel, bool), String> { ) -> Result<(DynModel, bool, String), String> {
let space = state let space = state
.db .db
.get_space(actor) .get_space(actor)
@ -1685,7 +1734,7 @@ pub(crate) async fn bot_model(
}) })
.map_err(|error| error.to_string())?; .map_err(|error| error.to_string())?;
let model = connect_model(&backend, session).map_err(|error| error.to_string())?; let model = connect_model(&backend, session).map_err(|error| error.to_string())?;
Ok((model, backend.capabilities.vision)) Ok((model, backend.capabilities.vision, backend.model_id))
} }
pub(crate) async fn complete_once( pub(crate) async fn complete_once(
@ -3100,6 +3149,7 @@ fn tool_needs_sandbox(name: &str) -> bool {
matches!( matches!(
name, name,
"shell" "shell"
| "exec"
| "list_files" | "list_files"
| "read_file" | "read_file"
| "write_file" | "write_file"
@ -3112,6 +3162,8 @@ fn tool_needs_sandbox(name: &str) -> bool {
| "wait" | "wait"
| "use_saved_login" | "use_saved_login"
| "request_takeover" | "request_takeover"
| "form_fill"
| "computer_mcp"
) )
} }
@ -3119,9 +3171,6 @@ fn tool_needs_gui(name: &str) -> bool {
matches!( matches!(
name, name,
"shell" "shell"
| "list_files"
| "read_file"
| "write_file"
| "computer_observe" | "computer_observe"
| "computer_act" | "computer_act"
| "browser" | "browser"
@ -3131,6 +3180,7 @@ fn tool_needs_gui(name: &str) -> bool {
| "wait" | "wait"
| "use_saved_login" | "use_saved_login"
| "request_takeover" | "request_takeover"
| "form_fill"
) )
} }
@ -3163,7 +3213,7 @@ async fn prepare_run_computer(
}; };
set_run_step(state, run_id, step).await; set_run_step(state, run_id, step).await;
} }
computer::boot(state, actor, bot_id).await?; computer::boot_for(state, actor, bot_id, need_gui).await?;
let computer = state let computer = state
.db .db
.get_computer(bot.computer_id.as_deref().unwrap_or("")) .get_computer(bot.computer_id.as_deref().unwrap_or(""))
@ -3173,6 +3223,7 @@ async fn prepare_run_computer(
let computer_ref = computer::computer_ref(&computer) let computer_ref = computer::computer_ref(&computer)
.ok_or_else(|| "computer is not running".to_string())?; .ok_or_else(|| "computer is not running".to_string())?;
*ctx.computer.lock().unwrap() = Some(computer_ref); *ctx.computer.lock().unwrap() = Some(computer_ref);
ctx.context.lock().unwrap().computer_generation = Some(computer.generation);
} }
if !need_gui || ctx.adapter().display.is_some() { if !need_gui || ctx.adapter().display.is_some() {
return Ok(()); return Ok(());
@ -3213,6 +3264,7 @@ async fn prepare_run_computer(
*ctx.gui_block.lock().unwrap() = gui_block; *ctx.gui_block.lock().unwrap() = gui_block;
*ctx.context.lock().unwrap() = *ctx.context.lock().unwrap() =
adapter_context_for(actor, bot_id, "run", screen.as_ref(), Some(run_id)); adapter_context_for(actor, bot_id, "run", screen.as_ref(), Some(run_id));
ctx.context.lock().unwrap().computer_generation = Some(computer.generation);
Ok(()) Ok(())
} }
@ -3283,6 +3335,20 @@ fn describe_step(name: &str, args: &Value) -> String {
.trim() .trim()
.to_string() .to_string()
} }
"exec" => {
if let Some(argv) = args.get("argv").and_then(Value::as_array) {
let line = argv
.iter()
.filter_map(Value::as_str)
.take(6)
.collect::<Vec<_>>()
.join(" ");
short(Some(&line), 60)
} else {
short(get("command"), 60)
}
}
"list_files" | "read_file" | "write_file" | "list_dir" => short(get("path"), 40),
"shell" => { "shell" => {
// The terminal does four different things; the feed says which. // The terminal does four different things; the feed says which.
let session = get("session").filter(|name| !name.trim().is_empty() && *name != "main"); let session = get("session").filter(|name| !name.trim().is_empty() && *name != "main");
@ -3312,7 +3378,6 @@ fn describe_step(name: &str, args: &Value) -> String {
.trim() .trim()
.to_string(), .to_string(),
"launch_app" | "open_path" => short(get("app").or(get("path")), 40), "launch_app" | "open_path" => short(get("app").or(get("path")), 40),
"read_file" | "write_file" | "list_dir" => short(get("path"), 40),
"use_skill" => format!("讀取技能 {}", short(get("name"), 30)), "use_skill" => format!("讀取技能 {}", short(get("name"), 30)),
"use_saved_login" => "填入已存帳號".into(), "use_saved_login" => "填入已存帳號".into(),
"list_accounts" => "列出已存帳號".into(), "list_accounts" => "列出已存帳號".into(),
@ -3320,6 +3385,14 @@ fn describe_step(name: &str, args: &Value) -> String {
"list_schedules" => "列出排程".into(), "list_schedules" => "列出排程".into(),
"cancel_schedule" => "取消排程".into(), "cancel_schedule" => "取消排程".into(),
"request_takeover" => short(get("site").or(get("reason")), 40), "request_takeover" => short(get("site").or(get("reason")), 40),
"form_fill" => format!(
"{} fields",
args.get("fields")
.and_then(Value::as_array)
.map(|fields| fields.len())
.unwrap_or(0)
),
"computer_mcp" => short(get("packageId").or(get("text")), 40),
_ => String::new(), _ => String::new(),
}; };
if detail.is_empty() { if detail.is_empty() {
@ -3443,7 +3516,8 @@ fn action_changes_state(name: &str, args: &Value) -> bool {
match name { match name {
"computer_act" | "shell" | "write_file" | "launch_app" | "open_path" "computer_act" | "shell" | "write_file" | "launch_app" | "open_path"
| "create_schedule" | "cancel_schedule" | "remember" | "forget_memory" | "create_schedule" | "cancel_schedule" | "remember" | "forget_memory"
| "use_saved_login" | "request_takeover" => true, | "use_saved_login" | "request_takeover" | "form_fill" | "computer_mcp" => true,
"exec" => !matches!(args.get("action").and_then(Value::as_str), Some("status")),
"browser" => matches!( "browser" => matches!(
args.get("action").and_then(Value::as_str), args.get("action").and_then(Value::as_str),
Some("click") | Some("type") | Some("navigate") | Some("press") Some("click") | Some("type") | Some("navigate") | Some("press")
@ -3612,10 +3686,41 @@ mod tests {
assert!(!tool_needs_sandbox("recall_memory")); assert!(!tool_needs_sandbox("recall_memory"));
assert!(!tool_needs_sandbox("use_skill")); assert!(!tool_needs_sandbox("use_skill"));
assert!(tool_needs_sandbox("shell")); assert!(tool_needs_sandbox("shell"));
assert!(tool_needs_sandbox("exec"));
assert!(tool_needs_sandbox("list_files"));
assert!(tool_needs_gui("computer_observe")); assert!(tool_needs_gui("computer_observe"));
assert!(tool_needs_gui("browser")); assert!(tool_needs_gui("browser"));
assert!(tool_needs_gui("shell")); assert!(tool_needs_gui("shell"));
assert!(tool_needs_gui("list_files")); assert!(!tool_needs_gui("list_files"));
assert!(!tool_needs_gui("read_file"));
assert!(!tool_needs_gui("write_file"));
assert!(!tool_needs_gui("exec"));
assert!(tool_needs_sandbox("form_fill"));
assert!(tool_needs_gui("form_fill"));
assert!(tool_needs_sandbox("computer_mcp"));
assert!(!tool_needs_gui("computer_mcp"));
}
#[test]
fn native_tools_do_not_boot_the_desktop() {
for name in [
"exec",
"list_files",
"read_file",
"write_file",
"computer_mcp",
] {
assert!(tool_needs_sandbox(name), "{name}");
assert!(!tool_needs_gui(name), "{name}");
}
assert!(!lazyboy_control::viewer_blocks_native());
assert!(!lazyboy_control::desktop_required_for_native());
assert!(lazyboy_control::native_work_allowed(
lazyboy_control::ComponentReadiness {
runner: true,
..lazyboy_control::ComponentReadiness::default()
}
));
} }
#[test] #[test]

View File

@ -1270,7 +1270,7 @@ async fn distill(
frames: &[Value], frames: &[Value],
dir: &std::path::Path, dir: &std::path::Path,
) -> Result<Value, String> { ) -> Result<Value, String> {
let (model, vision) = crate::runs::bot_model(state, actor, bot, skill_id).await?; let (model, vision, _) = crate::runs::bot_model(state, actor, bot, skill_id).await?;
let t0 = events let t0 = events
.iter() .iter()
.chain(frames.iter()) .chain(frames.iter())

View File

@ -0,0 +1,786 @@
//! Install a reviewed local package onto the bound Computer and bind it to a bot.
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::routing::{get, post};
use axum::{Json, Router};
use lazyboy_contracts::ComputerMode;
use lazyboy_control::{
AdapterContext, CommandRequest, ComputerRef, ReadyBinding, ReadyBindingChange, RollbackChange,
SandboxProvider, ToolManifest, artifact_digest, package_gc_allowed, pin_running_jobs,
plan_version_switch, previous_version, rollback_one_ready_binding, upsert_one_ready_binding,
validate_manifest,
};
use serde::Deserialize;
use serde_json::{Value, json};
use sqlx::PgPool;
use uuid::Uuid;
use crate::computer::{self, adapter_context};
use crate::db::{Actor, parse_mode};
use crate::state::AppState;
const ECHO_SOURCE: &str = include_str!("../../../scripts/sample-mcp/echo_server.py");
pub const ECHO_ID: &str = "lazyboy.example.echo";
pub const ECHO_VERSION: &str = "0.0.1";
pub const ECHO_VERSION_NEXT: &str = "0.0.2";
type ApiError = (StatusCode, Json<Value>);
pub fn router() -> Router<AppState> {
Router::new()
.route("/api/bots/{id}/tools", get(list_tools).post(install_sample))
.route("/api/bots/{id}/tools/call", post(call_installed))
.route(
"/api/bots/{id}/tools/{binding_id}/revoke",
post(revoke_binding),
)
.route(
"/api/bots/{id}/tools/{binding_id}/rollback",
post(rollback_binding),
)
.route(
"/api/bots/{id}/tools/{binding_id}/remove",
post(remove_binding),
)
}
#[derive(Deserialize)]
struct InstallBody {
#[serde(default, rename = "packageId", alias = "package_id")]
pub package_id: Option<String>,
#[serde(default)]
pub version: Option<String>,
}
/// Immutable packages live on the Computer, not in the bot workspace folder.
pub fn echo_relative_path(mode: ComputerMode, version: &str) -> String {
match mode {
ComputerMode::Team => format!("shared/tools/{ECHO_ID}/{version}/echo_server.py"),
ComputerMode::Dedicated => format!("tools/{ECHO_ID}/{version}/echo_server.py"),
}
}
fn echo_version(requested: Option<&str>) -> Result<&str, ApiError> {
match requested.unwrap_or(ECHO_VERSION) {
ECHO_VERSION => Ok(ECHO_VERSION),
ECHO_VERSION_NEXT => Ok(ECHO_VERSION_NEXT),
other => Err((
StatusCode::BAD_REQUEST,
Json(json!({"message": format!("unsupported echo version {other}")})),
)),
}
}
pub fn tool_install_enabled() -> bool {
std::env::var("LAZYBOY_TOOL_INSTALL_ENABLED")
.map(|value| value != "false" && value != "0")
.unwrap_or(true)
}
async fn list_tools(
State(state): State<AppState>,
actor: Actor,
Path(bot_id): Path<String>,
) -> Result<Json<Value>, ApiError> {
let rows: Vec<(String, String, String, String, String, String)> = sqlx::query_as(
"SELECT b.id, p.package_id, p.version, p.sha256, p.status, b.status
FROM tool_bindings b
JOIN tool_packages p ON p.id=b.package_row_id
JOIN bots bot ON bot.id=b.bot_id
WHERE b.bot_id=$1 AND bot.space_id=$2 AND bot.user_id=$3
ORDER BY b.created_at",
)
.bind(&bot_id)
.bind(&actor.space_id)
.bind(&actor.user_id)
.fetch_all(state.pool())
.await
.map_err(internal)?;
Ok(Json(json!({
"tools": rows.into_iter().map(|(id, pkg, ver, sha, install, bind)| json!({
"bindingId": id,
"packageId": pkg,
"version": ver,
"sha256": sha,
"installStatus": install,
"bindingStatus": bind,
"ready": install == "installed" && bind == "ready",
"executionLocation": "assigned_computer",
})).collect::<Vec<_>>()
})))
}
async fn install_sample(
State(state): State<AppState>,
actor: Actor,
Path(bot_id): Path<String>,
Json(body): Json<InstallBody>,
) -> Result<Json<Value>, ApiError> {
if !tool_install_enabled() {
return Err((
StatusCode::FORBIDDEN,
Json(json!({"message":"tool install is disabled"})),
));
}
let package_id = body.package_id.unwrap_or_else(|| ECHO_ID.into());
if package_id != ECHO_ID {
return Err((
StatusCode::BAD_REQUEST,
Json(
json!({"message":"only lazyboy.example.echo is installable without a reviewed artifact"}),
),
));
}
let version = echo_version(body.version.as_deref())?.to_string();
let digest = artifact_digest(ECHO_SOURCE.as_bytes());
let manifest = ToolManifest {
id: ECHO_ID.into(),
version: version.clone(),
sha256: digest.clone(),
entrypoint: vec!["python3".into(), "./echo_server.py".into()],
share_immutable_package: true,
};
validate_manifest(&manifest).map_err(|error| {
(
StatusCode::BAD_REQUEST,
Json(json!({"message": format!("{error:?}")})),
)
})?;
let bot = state
.db
.get_bot(&actor, &bot_id)
.await
.map_err(internal)?
.ok_or((
StatusCode::NOT_FOUND,
Json(json!({"message":"bot not found"})),
))?;
computer::boot_for(&state, &actor, &bot_id, false)
.await
.map_err(|error| (StatusCode::BAD_GATEWAY, Json(json!({"message": error}))))?;
let computer = state
.db
.get_computer(bot.computer_id.as_deref().unwrap_or(""))
.await
.map_err(internal)?
.ok_or((
StatusCode::NOT_FOUND,
Json(json!({"message":"computer not found"})),
))?;
let computer_ref = computer::computer_ref(&computer).ok_or((
StatusCode::CONFLICT,
Json(json!({"message":"computer is not running"})),
))?;
let mode = parse_mode(&computer.scope);
let relative = echo_relative_path(mode, &version);
state
.sandbox
.write_file(
&computer_ref,
&relative,
ECHO_SOURCE.as_bytes(),
&adapter_context(&actor, &bot_id, "tool-install"),
)
.await
.map_err(|error| {
(
StatusCode::BAD_GATEWAY,
Json(json!({"message": error.to_string()})),
)
})?;
let package_row = Uuid::new_v4().to_string();
sqlx::query(
"INSERT INTO tool_packages (id, computer_id, package_id, version, sha256, status)
VALUES ($1,$2,$3,$4,$5,'installed')
ON CONFLICT (computer_id, package_id, version) DO UPDATE SET status='installed', sha256=EXCLUDED.sha256",
)
.bind(&package_row)
.bind(&computer.id)
.bind(ECHO_ID)
.bind(&version)
.bind(&digest)
.execute(state.pool())
.await
.map_err(internal)?;
let row_id: String = sqlx::query_scalar(
"SELECT id FROM tool_packages WHERE computer_id=$1 AND package_id=$2 AND version=$3",
)
.bind(&computer.id)
.bind(ECHO_ID)
.bind(&version)
.fetch_one(state.pool())
.await
.map_err(internal)?;
let existing: Option<(String, String, String)> = sqlx::query_as(
"SELECT b.id, b.package_row_id, p.version FROM tool_bindings b
JOIN tool_packages p ON p.id=b.package_row_id
WHERE b.bot_id=$1 AND p.package_id=$2 AND b.status='ready'
ORDER BY b.created_at DESC LIMIT 1",
)
.bind(&bot_id)
.bind(ECHO_ID)
.fetch_optional(state.pool())
.await
.map_err(internal)?;
let jobs: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM computer_jobs WHERE computer_id=$1 AND status='running'",
)
.bind(&computer.id)
.fetch_one(state.pool())
.await
.unwrap_or(0);
let current_version = existing
.as_ref()
.map(|row| row.2.as_str())
.unwrap_or(&version);
let plan = plan_version_switch(current_version, &version, jobs.max(0) as usize);
// Only this bot's jobs were started with the version being replaced; other
// bots sharing the Computer keep their own bindings.
if let Some(pin) = pin_running_jobs(current_version, &version, jobs.max(0) as usize) {
let _ = sqlx::query(
"UPDATE computer_jobs SET pin_version=$2
WHERE computer_id=$1 AND bot_id=$3 AND status='running' AND pin_version IS NULL",
)
.bind(&computer.id)
.bind(&pin)
.bind(&bot_id)
.execute(state.pool())
.await;
}
let ready = existing
.as_ref()
.map(|(id, package_row_id, ver)| ReadyBinding {
id: id.clone(),
package_row_id: package_row_id.clone(),
version: ver.clone(),
});
let change = upsert_one_ready_binding(
ready.as_ref(),
&row_id,
&version,
&Uuid::new_v4().to_string(),
);
let binding_id = match &change {
ReadyBindingChange::Insert {
id, package_row_id, ..
} => {
// (package_row_id, bot_id) is unique: reinstalling after a revoke
// revives the old row instead of failing on the constraint.
let binding_id: String = sqlx::query_scalar(
"INSERT INTO tool_bindings (id, package_row_id, bot_id, status)
VALUES ($1,$2,$3,'ready')
ON CONFLICT (package_row_id, bot_id) DO UPDATE SET status='ready'
RETURNING id",
)
.bind(id)
.bind(package_row_id)
.bind(&bot_id)
.fetch_one(state.pool())
.await
.map_err(internal)?;
binding_id
}
ReadyBindingChange::Switch {
id, package_row_id, ..
} => {
switch_binding(state.pool(), id, package_row_id, &bot_id)
.await
.map_err(internal)?;
id.clone()
}
ReadyBindingChange::Keep { id } => id.clone(),
};
Ok(Json(json!({
"packageId": ECHO_ID,
"version": version,
"bindingId": binding_id,
"sha256": digest,
"path": relative,
"installStatus": "installed",
"bindingStatus": "ready",
"jobPin": plan.job_pin,
"newRuns": plan.new_runs,
"executionLocation": "assigned_computer",
})))
}
/// Point a ready binding at another package row. A revoked leftover for the
/// same (package_row, bot) would trip the unique index, so it is dropped first.
async fn switch_binding(
pool: &PgPool,
binding_id: &str,
package_row_id: &str,
bot_id: &str,
) -> Result<(), sqlx::Error> {
let mut tx = pool.begin().await?;
sqlx::query(
"DELETE FROM tool_bindings
WHERE package_row_id=$1 AND bot_id=$2 AND status<>'ready' AND id<>$3",
)
.bind(package_row_id)
.bind(bot_id)
.bind(binding_id)
.execute(&mut *tx)
.await?;
sqlx::query("UPDATE tool_bindings SET package_row_id=$2, status='ready' WHERE id=$1")
.bind(binding_id)
.bind(package_row_id)
.execute(&mut *tx)
.await?;
tx.commit().await
}
#[derive(Deserialize)]
struct CallBody {
pub text: String,
#[serde(default, rename = "packageId")]
pub package_id: Option<String>,
}
async fn call_installed(
State(state): State<AppState>,
actor: Actor,
Path(bot_id): Path<String>,
Json(body): Json<CallBody>,
) -> Result<Json<Value>, ApiError> {
let package_id = body.package_id.as_deref().unwrap_or(ECHO_ID);
if package_id != ECHO_ID {
return Err((
StatusCode::BAD_REQUEST,
Json(json!({"message":"only lazyboy.example.echo can be called via this fixture"})),
));
}
if !package_is_bound(state.pool(), &actor, &bot_id, package_id)
.await
.map_err(internal)?
{
return Err((
StatusCode::FORBIDDEN,
Json(json!({"message":"package not bound to this agent"})),
));
}
let bot = state
.db
.get_bot(&actor, &bot_id)
.await
.map_err(internal)?
.ok_or((
StatusCode::NOT_FOUND,
Json(json!({"message":"bot not found"})),
))?;
let computer = state
.db
.get_computer(bot.computer_id.as_deref().unwrap_or(""))
.await
.map_err(internal)?
.ok_or((
StatusCode::NOT_FOUND,
Json(json!({"message":"computer not found"})),
))?;
let computer_ref = computer::computer_ref(&computer).ok_or((
StatusCode::CONFLICT,
Json(json!({"message":"computer is not running"})),
))?;
let bound = bound_echo_relative(state.pool(), &actor, &bot_id, parse_mode(&computer.scope))
.await
.map_err(internal)?
.ok_or((
StatusCode::FORBIDDEN,
Json(json!({"message":"package not bound to this agent"})),
))?;
let result = exec_echo(
state.sandbox.as_ref(),
&computer_ref,
&adapter_context(&actor, &bot_id, "computer-mcp"),
&bound,
&body.text,
)
.await
.map_err(|error| (StatusCode::BAD_GATEWAY, Json(json!({"message": error}))))?;
Ok(Json(json!({
"exitCode": result.code,
"stdout": result.stdout,
"stderr": result.stderr,
"executionLocation": "assigned_computer",
})))
}
/// The bound package as it must exist on the Computer: where it lives and the
/// digest the installer recorded for it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BoundPackage {
pub relative: String,
pub sha256: String,
}
pub async fn bound_echo_relative(
pool: &PgPool,
actor: &Actor,
bot_id: &str,
mode: ComputerMode,
) -> Result<Option<BoundPackage>, sqlx::Error> {
let row: Option<(String, String)> = sqlx::query_as(
"SELECT p.version, p.sha256 FROM tool_bindings b
JOIN tool_packages p ON p.id=b.package_row_id
JOIN bots bot ON bot.id=b.bot_id
WHERE b.bot_id=$1 AND bot.space_id=$2 AND bot.user_id=$3
AND p.package_id=$4 AND b.status='ready' AND p.status='installed'
ORDER BY b.created_at DESC LIMIT 1",
)
.bind(bot_id)
.bind(&actor.space_id)
.bind(&actor.user_id)
.bind(ECHO_ID)
.fetch_optional(pool)
.await?;
Ok(row.map(|(version, sha256)| BoundPackage {
relative: echo_relative_path(mode, &version),
sha256,
}))
}
/// Run the package only if the file on the Computer still hashes to what was
/// installed. The check and the run are one command so nothing can swap the
/// file in between; the digest and path travel as argv, never interpolated.
pub fn verified_run_argv(absolute_path: &str, sha256: &str) -> Vec<String> {
vec![
"bash".into(),
"-c".into(),
r#"printf '%s %s\n' "$1" "$2" | sha256sum -c --status || { echo "package digest mismatch: $2" >&2; exit 97; }; exec python3 "$2""#.into(),
"verify".into(),
sha256.into(),
absolute_path.into(),
]
}
pub async fn package_is_bound(
pool: &PgPool,
actor: &Actor,
bot_id: &str,
package_id: &str,
) -> Result<bool, sqlx::Error> {
let bound: Option<(String,)> = sqlx::query_as(
"SELECT p.id FROM tool_bindings b
JOIN tool_packages p ON p.id=b.package_row_id
JOIN bots bot ON bot.id=b.bot_id
WHERE b.bot_id=$1 AND bot.space_id=$2 AND bot.user_id=$3
AND p.package_id=$4 AND b.status='ready' AND p.status='installed'
LIMIT 1",
)
.bind(bot_id)
.bind(&actor.space_id)
.bind(&actor.user_id)
.bind(package_id)
.fetch_optional(pool)
.await?;
Ok(bound.is_some())
}
pub async fn exec_echo(
sandbox: &dyn SandboxProvider,
computer_ref: &ComputerRef,
adapter: &AdapterContext,
bound: &BoundPackage,
text: &str,
) -> Result<lazyboy_control::CommandResult, String> {
let rpc = json!({
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {"arguments": {"text": text}},
});
sandbox
.execute(
computer_ref,
CommandRequest {
argv: verified_run_argv(
&format!("/home/lazyboy/{}", bound.relative),
&bound.sha256,
),
cwd: None,
timeout_ms: Some(10_000),
stdin: Some(format!("{rpc}\n")),
..CommandRequest::default()
},
adapter,
)
.await
.map_err(|error| error.to_string())
}
async fn revoke_binding(
State(state): State<AppState>,
actor: Actor,
Path((bot_id, binding_id)): Path<(String, String)>,
) -> Result<Json<Value>, ApiError> {
let done = sqlx::query(
"UPDATE tool_bindings b SET status='revoked'
FROM bots bot
WHERE b.id=$1 AND b.bot_id=$2 AND bot.id=b.bot_id
AND bot.space_id=$3 AND bot.user_id=$4",
)
.bind(&binding_id)
.bind(&bot_id)
.bind(&actor.space_id)
.bind(&actor.user_id)
.execute(state.pool())
.await
.map_err(internal)?;
if done.rows_affected() == 0 {
return Err((
StatusCode::NOT_FOUND,
Json(json!({"message":"binding not found"})),
));
}
Ok(Json(json!({"ok": true})))
}
async fn rollback_binding(
State(state): State<AppState>,
actor: Actor,
Path((bot_id, binding_id)): Path<(String, String)>,
) -> Result<Json<Value>, ApiError> {
let row: Option<(String, String, String, String)> = sqlx::query_as(
"SELECT p.computer_id, p.package_id, p.version, b.status
FROM tool_bindings b
JOIN tool_packages p ON p.id=b.package_row_id
JOIN bots bot ON bot.id=b.bot_id
WHERE b.id=$1 AND b.bot_id=$2 AND bot.space_id=$3 AND bot.user_id=$4",
)
.bind(&binding_id)
.bind(&bot_id)
.bind(&actor.space_id)
.bind(&actor.user_id)
.fetch_optional(state.pool())
.await
.map_err(internal)?;
let Some((computer_id, package_id, current, binding_status)) = row else {
return Err((
StatusCode::NOT_FOUND,
Json(json!({"message":"binding not found"})),
));
};
// Rolling back a revoked binding would silently re-enable the package.
if binding_status != "ready" {
return Err((
StatusCode::CONFLICT,
Json(json!({"message": format!("binding is {binding_status}; install again instead")})),
));
}
let versions: Vec<String> = sqlx::query_scalar(
"SELECT version FROM tool_packages
WHERE computer_id=$1 AND package_id=$2 AND status='installed'
ORDER BY created_at",
)
.bind(&computer_id)
.bind(&package_id)
.fetch_all(state.pool())
.await
.map_err(internal)?;
let refs: Vec<&str> = versions.iter().map(String::as_str).collect();
let Some(prev) = previous_version(&refs, &current) else {
return Err((
StatusCode::CONFLICT,
Json(json!({"message":"no previous version to rollback"})),
));
};
let prev_row: String = sqlx::query_scalar(
"SELECT id FROM tool_packages WHERE computer_id=$1 AND package_id=$2 AND version=$3",
)
.bind(&computer_id)
.bind(&package_id)
.bind(prev)
.fetch_one(state.pool())
.await
.map_err(internal)?;
let leftover: Option<(String,)> = sqlx::query_as(
"SELECT id FROM tool_bindings
WHERE package_row_id=$1 AND bot_id=$2 AND status='ready' AND id<>$3",
)
.bind(&prev_row)
.bind(&bot_id)
.bind(&binding_id)
.fetch_optional(state.pool())
.await
.map_err(internal)?;
let current_binding = ReadyBinding {
id: binding_id.clone(),
package_row_id: String::new(),
version: current.clone(),
};
match rollback_one_ready_binding(
&current_binding,
leftover.is_some(),
Some(&prev_row),
Some(prev),
) {
RollbackChange::NoPrevious => {
return Err((
StatusCode::CONFLICT,
Json(json!({"message":"no previous version to rollback"})),
));
}
RollbackChange::Conflict => {
sqlx::query(
"UPDATE tool_bindings SET status='revoked'
WHERE package_row_id=$1 AND bot_id=$2 AND status='ready' AND id<>$3",
)
.bind(&prev_row)
.bind(&bot_id)
.bind(&binding_id)
.execute(state.pool())
.await
.map_err(internal)?;
switch_binding(state.pool(), &binding_id, &prev_row, &bot_id)
.await
.map_err(internal)?;
}
RollbackChange::Switch {
id, package_row_id, ..
} => {
switch_binding(state.pool(), &id, &package_row_id, &bot_id)
.await
.map_err(internal)?;
}
}
Ok(Json(json!({
"ok": true,
"version": prev,
"previous": current,
"executionLocation": "assigned_computer",
})))
}
async fn remove_binding(
State(state): State<AppState>,
actor: Actor,
Path((bot_id, binding_id)): Path<(String, String)>,
) -> Result<Json<Value>, ApiError> {
let row: Option<(String, String)> = sqlx::query_as(
"SELECT p.id, p.computer_id
FROM tool_bindings b
JOIN tool_packages p ON p.id=b.package_row_id
JOIN bots bot ON bot.id=b.bot_id
WHERE b.id=$1 AND b.bot_id=$2 AND bot.space_id=$3 AND bot.user_id=$4",
)
.bind(&binding_id)
.bind(&bot_id)
.bind(&actor.space_id)
.bind(&actor.user_id)
.fetch_optional(state.pool())
.await
.map_err(internal)?;
let Some((package_row, computer_id)) = row else {
return Err((
StatusCode::NOT_FOUND,
Json(json!({"message":"binding not found"})),
));
};
sqlx::query(
"UPDATE tool_bindings b SET status='revoked'
FROM bots bot
WHERE b.id=$1 AND b.bot_id=$2 AND bot.id=b.bot_id
AND bot.space_id=$3 AND bot.user_id=$4",
)
.bind(&binding_id)
.bind(&bot_id)
.bind(&actor.space_id)
.bind(&actor.user_id)
.execute(state.pool())
.await
.map_err(internal)?;
let ready: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM tool_bindings WHERE package_row_id=$1 AND status='ready'",
)
.bind(&package_row)
.fetch_one(state.pool())
.await
.unwrap_or(0);
let jobs: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM computer_jobs WHERE computer_id=$1 AND status='running'",
)
.bind(&computer_id)
.fetch_one(state.pool())
.await
.unwrap_or(0);
let removed = package_gc_allowed(ready.max(0) as usize, jobs.max(0) as usize);
if removed {
sqlx::query("UPDATE tool_packages SET status='removed' WHERE id=$1")
.bind(&package_row)
.execute(state.pool())
.await
.map_err(internal)?;
}
Ok(Json(json!({
"ok": true,
"packageRemoved": removed,
"executionLocation": "assigned_computer",
})))
}
fn internal<E: std::fmt::Display>(error: E) -> ApiError {
tracing::error!("tool install: {error}");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"message":"internal error"})),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn team_packages_live_on_the_shared_tree() {
assert_eq!(
echo_relative_path(ComputerMode::Team, ECHO_VERSION),
"shared/tools/lazyboy.example.echo/0.0.1/echo_server.py"
);
assert_eq!(
echo_relative_path(ComputerMode::Dedicated, ECHO_VERSION_NEXT),
"tools/lazyboy.example.echo/0.0.2/echo_server.py"
);
}
/// A package whose bytes no longer match the installed digest must not run.
#[test]
fn tampered_package_is_refused_before_it_runs() {
let dir = std::env::temp_dir().join(format!("lazyboy-verify-{}", Uuid::new_v4()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("echo_server.py");
std::fs::write(&path, "print('ok')\n").unwrap();
let path = path.to_string_lossy().into_owned();
let good = artifact_digest(b"print('ok')\n");
let run = |digest: &str| {
let argv = verified_run_argv(&path, digest);
std::process::Command::new(&argv[0])
.args(&argv[1..])
.output()
.unwrap()
};
let ok = run(&good);
assert_eq!(ok.status.code(), Some(0), "{ok:?}");
assert_eq!(String::from_utf8_lossy(&ok.stdout).trim(), "ok");
let bad = run(&artifact_digest(b"something else"));
assert_eq!(bad.status.code(), Some(97), "{bad:?}");
assert!(bad.stdout.is_empty(), "tampered package must not execute");
assert!(String::from_utf8_lossy(&bad.stderr).contains("digest mismatch"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn agent_computer_migration_does_not_unique_bot_computer_id() {
let sql = include_str!("../../../migrations/022_agent_computer.sql");
assert!(sql.contains("do NOT add UNIQUE(bots.computer_id)"));
let statements: String = sql
.lines()
.filter(|line| !line.trim_start().starts_with("--"))
.collect::<Vec<_>>()
.join("\n")
.to_ascii_lowercase();
assert!(!statements.contains("unique (bots.computer_id)"));
assert!(!statements.contains("unique(bots.computer_id)"));
}
}

File diff suppressed because it is too large Load Diff

View File

@ -112,6 +112,10 @@ pub struct UiElement {
pub kind: Option<String>, pub kind: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub role: Option<String>, pub role: Option<String>,
/// True when the snapshot marked the control disabled. Clicks wait, then
/// return TARGET_DISABLED instead of guessing that time will fix it.
#[serde(default)]
pub disabled: bool,
} }
impl UiElement { impl UiElement {

View File

@ -220,6 +220,18 @@ pub const PROFILE_LOCKED: &str =
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn team_computers_share_one_scope_across_bots() {
assert_eq!(
computer_scope_key(ComputerMode::Team, "space-1", Some("bot-a")).unwrap(),
computer_scope_key(ComputerMode::Team, "space-1", Some("bot-b")).unwrap()
);
assert_ne!(
computer_scope_key(ComputerMode::Dedicated, "space-1", Some("bot-a")).unwrap(),
computer_scope_key(ComputerMode::Dedicated, "space-1", Some("bot-b")).unwrap()
);
}
#[test] #[test]
fn team_and_dedicated_keys_differ() { fn team_and_dedicated_keys_differ() {
assert_eq!( assert_eq!(

View File

@ -6,6 +6,7 @@ mod model;
mod room; mod room;
mod run; mod run;
mod session; mod session;
mod tool;
mod voice; mod voice;
pub use action::*; pub use action::*;
@ -16,6 +17,7 @@ pub use model::*;
pub use room::*; pub use room::*;
pub use run::*; pub use run::*;
pub use session::*; pub use session::*;
pub use tool::*;
pub use voice::*; pub use voice::*;
pub type Id = String; pub type Id = String;

View File

@ -0,0 +1,141 @@
use serde::{Deserialize, Serialize};
/// Transport / operation / task are three different success layers.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ToolStatus {
Accepted,
Running,
Succeeded,
Failed,
Cancelled,
TimedOut,
NeedsAuth,
NeedsApproval,
NeedsHuman,
PolicyDenied,
Unknown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ToolEffect {
None,
Confirmed,
Partial,
Unknown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ExecutionPlane {
Computer,
Control,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ToolExecution {
pub computer_id: String,
#[serde(default)]
pub computer_generation: i32,
pub executor: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub job_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ToolResult {
pub schema_version: u32,
pub operation_id: String,
pub status: ToolStatus,
pub effect: ToolEffect,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub execution: Option<ToolExecution>,
#[serde(default)]
pub data: serde_json::Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error_code: Option<String>,
}
impl ToolResult {
pub fn failed(operation_id: impl Into<String>, code: &str, error: impl Into<String>) -> Self {
Self {
schema_version: 1,
operation_id: operation_id.into(),
status: ToolStatus::Failed,
effect: ToolEffect::Unknown,
execution: None,
data: serde_json::Value::Null,
error: Some(error.into()),
error_code: Some(code.into()),
}
}
pub fn succeeded(operation_id: impl Into<String>, data: serde_json::Value) -> Self {
Self {
schema_version: 1,
operation_id: operation_id.into(),
status: ToolStatus::Succeeded,
effect: ToolEffect::Confirmed,
execution: None,
data,
error: None,
error_code: None,
}
}
/// HTTP 200 / exit 0 is not task completion.
pub fn task_complete(&self) -> bool {
false
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PauseScope {
Agent,
Display,
Computer,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RouteKind {
NativeApi,
NativeExec,
NativeFiles,
Dom,
Accessibility,
Vision,
Human,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CompletionLayer {
Transport,
OperationEffect,
TaskSuccess,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_successful_tool_is_not_a_finished_task() {
let result = ToolResult::succeeded("op-1", serde_json::json!({"exitCode": 0}));
assert_eq!(result.status, ToolStatus::Succeeded);
assert!(!result.task_complete());
}
#[test]
fn pause_scopes_are_distinct() {
assert_ne!(PauseScope::Agent, PauseScope::Computer);
assert_ne!(PauseScope::Display, PauseScope::Agent);
}
}

View File

@ -19,6 +19,7 @@ image.workspace = true
tokio.workspace = true tokio.workspace = true
tracing.workspace = true tracing.workspace = true
base64.workspace = true base64.workspace = true
regex = "1"
[lints] [lints]
workspace = true workspace = true

View File

@ -76,6 +76,7 @@ mod tests {
kind: Some("a11y".into()), kind: Some("a11y".into()),
selector: Some(format!("0/{id}")), selector: Some(format!("0/{id}")),
role: Some("push button".into()), role: Some("push button".into()),
..UiElement::default()
} }
} }

View File

@ -697,6 +697,7 @@ mod tests {
y: 20, y: 20,
w: 80, w: 80,
h: 24, h: 24,
..UiElement::default()
} }
} }

View File

@ -35,6 +35,16 @@ pub struct BrowserPage {
/// Seconds the click waited for a disabled control to become enabled. /// Seconds the click waited for a disabled control to become enabled.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub waited_seconds: Option<f64>, pub waited_seconds: Option<f64>,
/// Typed tool error: SELECTOR_UNSUPPORTED, TARGET_STALE, TARGET_DISABLED,
/// TARGET_NOT_FOUND. Distinct from a free-text `error` so recovery can
/// branch without parsing English.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error_code: Option<String>,
#[serde(default)] #[serde(default)]
pub elements: Vec<UiElement>, pub elements: Vec<UiElement>,
} }
pub const ERROR_SELECTOR_UNSUPPORTED: &str = "SELECTOR_UNSUPPORTED";
pub const ERROR_TARGET_STALE: &str = "TARGET_STALE";
pub const ERROR_TARGET_DISABLED: &str = "TARGET_DISABLED";
pub const ERROR_TARGET_NOT_FOUND: &str = "TARGET_NOT_FOUND";

View File

@ -0,0 +1,87 @@
use lazyboy_contracts::RouteKind;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RouteDecision {
pub kind: RouteKind,
pub rationale: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RouteInputs {
pub has_native_api: bool,
pub policy_denied: bool,
pub account_mismatch: bool,
pub local_files: bool,
pub needs_pixels: bool,
pub has_dom: bool,
}
/// Capability-aware preference. Policy denial is never a reason to try a
/// slower/weaker path that would bypass the grant.
pub fn choose_route(inputs: RouteInputs) -> Result<RouteDecision, &'static str> {
if inputs.policy_denied || inputs.account_mismatch {
return Err("POLICY_DENIED");
}
if inputs.has_native_api {
return Ok(RouteDecision {
kind: RouteKind::NativeApi,
rationale: "authorized structured API is available".into(),
});
}
if inputs.local_files {
return Ok(RouteDecision {
kind: RouteKind::NativeFiles,
rationale: "task files are on the bound Computer".into(),
});
}
if inputs.has_dom && !inputs.needs_pixels {
return Ok(RouteDecision {
kind: RouteKind::Dom,
rationale: "page has semantic targets".into(),
});
}
if inputs.needs_pixels {
return Ok(RouteDecision {
kind: RouteKind::Vision,
rationale: "pixel-only control; requires a fresh screenshot".into(),
});
}
Ok(RouteDecision {
kind: RouteKind::NativeExec,
rationale: "fall through to computer-local exec".into(),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn gmail_with_grant_does_not_use_the_browser() {
let decision = choose_route(RouteInputs {
has_native_api: true,
policy_denied: false,
account_mismatch: false,
local_files: false,
needs_pixels: false,
has_dom: true,
})
.unwrap();
assert_eq!(decision.kind, RouteKind::NativeApi);
}
#[test]
fn policy_denied_does_not_fall_back_to_ui() {
assert_eq!(
choose_route(RouteInputs {
has_native_api: false,
policy_denied: true,
account_mismatch: false,
local_files: false,
needs_pixels: false,
has_dom: true,
}),
Err("POLICY_DENIED")
);
}
}

View File

@ -51,6 +51,10 @@ pub enum ControlError {
TargetNotFound, TargetNotFound,
#[error("stale UI reference; take a fresh observation")] #[error("stale UI reference; take a fresh observation")]
StaleReference, StaleReference,
#[error("selector type is not supported by this browser backend")]
SelectorUnsupported,
#[error("target is disabled")]
TargetDisabled,
#[error("permission denied")] #[error("permission denied")]
PermissionDenied, PermissionDenied,
/// Fail closed, in the same words the tool-layer timeout uses /// Fail closed, in the same words the tool-layer timeout uses
@ -80,6 +84,8 @@ impl ControlError {
self, self,
Self::TargetNotFound Self::TargetNotFound
| Self::StaleReference | Self::StaleReference
| Self::SelectorUnsupported
| Self::TargetDisabled
| Self::Unsupported | Self::Unsupported
| Self::InvalidAction(_) | Self::InvalidAction(_)
| Self::PermissionDenied | Self::PermissionDenied

View File

@ -79,6 +79,7 @@ pub fn page_from_semantic(value: &Value) -> BrowserPage {
text: outline.to_string(), text: outline.to_string(),
restarted: false, restarted: false,
waited_seconds: None, waited_seconds: None,
error_code: None,
elements, elements,
} }
} }
@ -99,6 +100,16 @@ fn element_from_ref(id: u32, item: &Value) -> Option<UiElement> {
.and_then(Value::as_str) .and_then(Value::as_str)
.filter(|role| !role.is_empty()) .filter(|role| !role.is_empty())
.map(str::to_string); .map(str::to_string);
let disabled = item
.get("disabled")
.and_then(Value::as_bool)
.unwrap_or(false)
|| item
.get("states")
.and_then(Value::as_array)
.into_iter()
.flatten()
.any(|state| state.as_str() == Some("disabled"));
let visibility = item let visibility = item
.get("visibility") .get("visibility")
.and_then(Value::as_str) .and_then(Value::as_str)
@ -131,9 +142,71 @@ fn element_from_ref(id: u32, item: &Value) -> Option<UiElement> {
selector: Some(selector), selector: Some(selector),
kind: Some("dom".into()), kind: Some("dom".into()),
role, role,
disabled,
}) })
} }
/// How the model addressed a page control. Cua's semantic backend understands
/// snapshot refs (`p1:0`) and element ids from the last snapshot. CSS is
/// advertised in older schemas but is not implemented — callers must get a
/// typed SELECTOR_UNSUPPORTED rather than a fuzzy title match.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BrowserLocatorKind {
SnapshotRef,
ElementId,
Css,
RoleName,
}
pub fn classify_browser_locator(selector: &str) -> BrowserLocatorKind {
let selector = selector.trim();
if is_cua_ref(selector) {
BrowserLocatorKind::SnapshotRef
} else if selector.parse::<u32>().is_ok() {
BrowserLocatorKind::ElementId
} else if looks_like_css(selector) {
BrowserLocatorKind::Css
} else {
BrowserLocatorKind::RoleName
}
}
fn looks_like_css(selector: &str) -> bool {
let trimmed = selector.trim();
trimmed.starts_with('#')
|| trimmed.starts_with('.')
|| trimmed.contains('[')
|| trimmed.contains('>')
|| trimmed.contains('+')
|| trimmed.contains('~')
|| (trimmed.contains('.') && !trimmed.contains(' '))
}
fn locator_miss_code(kind: BrowserLocatorKind) -> &'static str {
match kind {
BrowserLocatorKind::SnapshotRef | BrowserLocatorKind::ElementId => {
crate::ERROR_TARGET_STALE
}
BrowserLocatorKind::Css => crate::ERROR_SELECTOR_UNSUPPORTED,
BrowserLocatorKind::RoleName => crate::ERROR_TARGET_NOT_FOUND,
}
}
fn locator_miss_message(kind: BrowserLocatorKind) -> String {
let code = locator_miss_code(kind);
match kind {
BrowserLocatorKind::Css => format!(
"{code}: this backend accepts snapshot refs (p1:0), element ids, or an exact element title, not CSS selectors"
),
BrowserLocatorKind::RoleName => format!(
"{code}: no element has exactly that title (or several do). Use the element id or snapshot ref from the element list in this result"
),
BrowserLocatorKind::SnapshotRef | BrowserLocatorKind::ElementId => format!(
"{code}: element gone: the page changed and ids were renumbered. Use the fresh element list in this result."
),
}
}
fn number(value: &Value, key: &str) -> Option<u32> { fn number(value: &Value, key: &str) -> Option<u32> {
value value
.get(key) .get(key)
@ -366,6 +439,13 @@ pub async fn run(
sleep(Duration::from_millis(ms)).await; sleep(Duration::from_millis(ms)).await;
snapshot(client, display, &attached).await snapshot(client, display, &attached).await
} }
"click" => {
let mut current = attached;
let page = click(client, display, &mut current, request).await;
*bind = Some(current);
page
}
"type" => type_into(client, display, &attached, request).await,
"navigate" => { "navigate" => {
let url = request.url.as_deref().unwrap_or(""); let url = request.url.as_deref().unwrap_or("");
if !allowed_navigate_url(url) { if !allowed_navigate_url(url) {
@ -387,8 +467,6 @@ pub async fn run(
.await?; .await?;
snapshot_when_document_ready(client, display, &attached).await snapshot_when_document_ready(client, display, &attached).await
} }
"click" => click(client, display, &attached, request).await,
"type" => type_into(client, display, &attached, request).await,
"press" => { "press" => {
let key = map_press_key(request.key.as_deref().unwrap_or("return")); let key = map_press_key(request.key.as_deref().unwrap_or("return"));
client client
@ -415,29 +493,52 @@ pub async fn run(
async fn click( async fn click(
client: &CuaClient, client: &CuaClient,
display: &str, display: &str,
bind: &BrowserBind, bind: &mut BrowserBind,
request: &BrowserRequest, request: &BrowserRequest,
) -> Result<BrowserPage, ControlError> { ) -> Result<BrowserPage, ControlError> {
let selector = request let selector = request
.selector .selector
.as_deref() .as_deref()
.ok_or_else(|| ControlError::InvalidAction("browser click needs a selector".into()))?; .ok_or_else(|| ControlError::InvalidAction("browser click needs a selector".into()))?;
// Exact, unique titles always win (a link named "v1.2 Release" is not a
// CSS selector). Only when nothing matches does the shape of the locator
// decide which error the model gets.
let kind = classify_browser_locator(selector);
let wait_ms = request.wait_ms.unwrap_or(45_000).min(120_000);
let started = Instant::now();
loop {
if bind.page.is_none() {
let page = snapshot(client, display, bind).await?;
bind.page = Some(page);
}
let (r#ref, disabled) = {
let page = bind.page.as_ref().ok_or(ControlError::StaleReference)?; let page = bind.page.as_ref().ok_or(ControlError::StaleReference)?;
let Some(r#ref) = find_ref(page, selector) else { match find_ref(page, selector) {
Some(r#ref) => {
let disabled = page
.elements
.iter()
.find(|element| element.selector.as_deref() == Some(r#ref))
.is_some_and(|element| element.disabled);
(r#ref.to_string(), disabled)
}
None => {
let code = locator_miss_code(kind);
let page = page.clone();
return Ok(BrowserPage { return Ok(BrowserPage {
ok: false, ok: false,
error: Some( error: Some(locator_miss_message(kind)),
"element gone: the page changed and ids were renumbered. Use the fresh element list in this result." error_code: Some(code.into()),
.into(), url: page.url,
), title: page.title,
url: page.url.clone(), text: page.text,
title: page.title.clone(), elements: page.elements,
text: page.text.clone(),
elements: page.elements.clone(),
..BrowserPage::default() ..BrowserPage::default()
}); });
}
}
}; };
let r#ref = r#ref.to_string(); if !disabled {
client client
.call( .call(
display, display,
@ -451,7 +552,34 @@ async fn click(
&[], &[],
) )
.await?; .await?;
snapshot(client, display, bind).await let mut page = snapshot(client, display, bind).await?;
let waited = started.elapsed().as_secs_f64();
if waited >= 1.0 {
page.waited_seconds = Some(waited);
}
bind.page = Some(page.clone());
return Ok(page);
}
if started.elapsed() >= Duration::from_millis(wait_ms) {
let page = bind.page.clone().unwrap_or_default();
return Ok(BrowserPage {
ok: false,
error: Some(
"TARGET_DISABLED: the control stayed disabled until the wait deadline; no click was sent".into(),
),
error_code: Some(crate::ERROR_TARGET_DISABLED.into()),
waited_seconds: Some(started.elapsed().as_secs_f64()),
url: page.url,
title: page.title,
text: page.text,
elements: page.elements,
..BrowserPage::default()
});
}
sleep(Duration::from_millis(200)).await;
let page = snapshot(client, display, bind).await?;
bind.page = Some(page);
}
} }
async fn snapshot_when_document_ready( async fn snapshot_when_document_ready(
@ -512,9 +640,12 @@ async fn type_into(
if let Some(selector) = request.selector.as_deref() { if let Some(selector) = request.selector.as_deref() {
let page = bind.page.as_ref().ok_or(ControlError::StaleReference)?; let page = bind.page.as_ref().ok_or(ControlError::StaleReference)?;
let Some(r#ref) = find_ref(page, selector) else { let Some(r#ref) = find_ref(page, selector) else {
let kind = classify_browser_locator(selector);
let code = locator_miss_code(kind);
return Ok(BrowserPage { return Ok(BrowserPage {
ok: false, ok: false,
error: Some("target field is unavailable; no text inserted".into()), error: Some(format!("{}; no text inserted", locator_miss_message(kind))),
error_code: Some(code.into()),
url: page.url.clone(), url: page.url.clone(),
title: page.title.clone(), title: page.title.clone(),
text: page.text.clone(), text: page.text.clone(),
@ -656,6 +787,62 @@ mod tests {
assert!(!is_cua_ref("p:1")); assert!(!is_cua_ref("p:1"));
} }
#[test]
fn classify_browser_locator_does_not_treat_css_as_a_title() {
assert_eq!(
classify_browser_locator("p1:1"),
BrowserLocatorKind::SnapshotRef
);
assert_eq!(
classify_browser_locator("12"),
BrowserLocatorKind::ElementId
);
assert_eq!(classify_browser_locator("#submit"), BrowserLocatorKind::Css);
assert_eq!(
classify_browser_locator("button.primary"),
BrowserLocatorKind::Css
);
assert_eq!(
classify_browser_locator("Smoke Entry"),
BrowserLocatorKind::RoleName
);
assert_eq!(
locator_miss_code(BrowserLocatorKind::Css),
crate::ERROR_SELECTOR_UNSUPPORTED
);
assert_eq!(
locator_miss_code(BrowserLocatorKind::SnapshotRef),
crate::ERROR_TARGET_STALE
);
}
/// A title that happens to contain `.` or `+` is still a title when an
/// element carries it exactly; the CSS refusal only applies on a miss.
#[test]
fn exact_title_with_css_shaped_characters_still_resolves() {
let raw = json!({
"status": "ok",
"page": { "title": "t", "url": "http://x" },
"refs": [
{ "name": "v1.2 Release", "ref": "p1:1", "role": "link", "visibility": "in_viewport" },
{ "name": "C++ Guide", "ref": "p1:2", "role": "link", "visibility": "in_viewport" }
]
});
let page = page_from_semantic(&raw);
assert_eq!(
classify_browser_locator("v1.2 Release"),
BrowserLocatorKind::RoleName
);
assert_eq!(
classify_browser_locator("C++ Guide"),
BrowserLocatorKind::Css
);
assert_eq!(find_ref(&page, "v1.2 Release"), Some("p1:1"));
assert_eq!(find_ref(&page, "C++ Guide"), Some("p1:2"));
assert_eq!(find_ref(&page, "button.primary"), None);
assert!(locator_miss_message(BrowserLocatorKind::Css).contains("SELECTOR_UNSUPPORTED"));
}
#[test] #[test]
fn semantic_snapshot_becomes_browser_page() { fn semantic_snapshot_becomes_browser_page() {
let raw = json!({ let raw = json!({
@ -684,6 +871,18 @@ mod tests {
assert_eq!(find_ref(&page, "Smoke Entry"), Some("p1:2")); assert_eq!(find_ref(&page, "Smoke Entry"), Some("p1:2"));
} }
#[test]
fn semantic_snapshot_marks_disabled_controls() {
let raw = json!({
"status": "ok",
"refs": [
{ "name": "Next", "ref": "p1:1", "role": "button", "disabled": true, "visibility": "in_viewport" }
]
});
let page = page_from_semantic(&raw);
assert!(page.elements[0].disabled);
}
#[test] #[test]
fn navigation_snapshot_is_ready_once_a_url_is_present() { fn navigation_snapshot_is_ready_once_a_url_is_present() {
let mut page = BrowserPage { let mut page = BrowserPage {

View File

@ -29,6 +29,7 @@ use crate::{
}; };
use client::{action_verdict, first_array_of_objects}; use client::{action_verdict, first_array_of_objects};
pub use browser::{BrowserLocatorKind, classify_browser_locator, is_cua_ref};
pub use client::CuaClient; pub use client::CuaClient;
/// Last resort when neither the screenshot nor the driver reports a mode. /// Last resort when neither the screenshot nor the driver reports a mode.
@ -547,6 +548,7 @@ impl CuaController {
selector: None, selector: None,
kind: Some("window".into()), kind: Some("window".into()),
role: None, role: None,
disabled: false,
}) })
.collect::<Vec<_>>(), .collect::<Vec<_>>(),
); );

View File

@ -101,6 +101,7 @@ pub(super) async fn observe(
selector: Some(selector.clone()), selector: Some(selector.clone()),
kind: Some("a11y".into()), kind: Some("a11y".into()),
role: Some(role.into()), role: Some(role.into()),
disabled: false,
}); });
observed.targets.insert( observed.targets.insert(
selector, selector,

View File

@ -0,0 +1,110 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DisplayBackend {
XvfbX11vnc,
TigerVncXvnc,
}
impl DisplayBackend {
pub const ENV: &'static str = "LAZYBOY_DISPLAY_BACKEND";
pub fn from_env() -> Self {
Self::parse(&std::env::var(Self::ENV).unwrap_or_default())
}
pub fn parse(value: &str) -> Self {
match value.trim() {
"tigervnc_xvnc" => Self::TigerVncXvnc,
_ => Self::XvfbX11vnc,
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::XvfbX11vnc => "xvfb_x11vnc",
Self::TigerVncXvnc => "tigervnc_xvnc",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DisplaySlot {
pub slot: u32,
pub display_number: u32,
pub rfb_port: u16,
pub view_port: u16,
}
/// Slot 0 is DISPLAY :1 / RFB 5900 / web 6080. Do not use Xvnc's default
/// 5900+display-number mapping, which would shift every existing port.
pub fn slot_endpoints(slot: u32) -> DisplaySlot {
DisplaySlot {
slot,
display_number: slot + 1,
rfb_port: 5900 + slot as u16,
view_port: 6080 + slot as u16,
}
}
pub fn display_name(slot: u32) -> String {
format!(":{}", slot_endpoints(slot).display_number)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum X11Access {
/// Intra-container X clients (Cua, AT-SPI, x11vnc) currently rely on
/// `Xvfb -ac`. T59 still holds: the public VNC/noVNC path is the
/// authenticated screen proxy, not a naked 5900. Shipping Xauthority
/// requires giving every in-image X client the cookie; deleting `-ac`
/// alone would break those clients.
KeepAcUntilXauthority,
}
pub fn x11_access_control() -> X11Access {
X11Access::KeepAcUntilXauthority
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn slot_zero_keeps_the_existing_port_map() {
let slot = slot_endpoints(0);
assert_eq!(slot.display_number, 1);
assert_eq!(slot.rfb_port, 5900);
assert_eq!(slot.view_port, 6080);
assert_eq!(display_name(0), ":1");
}
#[test]
fn slot_one_is_display_two_rfb_5901() {
let slot = slot_endpoints(1);
assert_eq!(slot.display_number, 2);
assert_eq!(slot.rfb_port, 5901);
assert_eq!(slot.view_port, 6081);
}
#[test]
fn t59_keeps_ac_until_xauthority_is_wired() {
assert_eq!(x11_access_control(), X11Access::KeepAcUntilXauthority);
let script = include_str!("../../../image/computer/lazyboy-screen");
assert!(
script.contains(" -ac "),
"removing -ac without Xauthority breaks Cua/AT-SPI inside the Computer"
);
assert!(
!script.contains("xauth generate"),
"do not claim Xauthority is wired while -ac remains"
);
}
#[test]
fn default_backend_stays_xvfb_until_tigervnc_is_chosen() {
assert_eq!(DisplayBackend::parse(""), DisplayBackend::XvfbX11vnc);
assert_eq!(
DisplayBackend::parse("tigervnc_xvnc"),
DisplayBackend::TigerVncXvnc
);
assert_eq!(DisplayBackend::XvfbX11vnc.as_str(), "xvfb_x11vnc");
}
}

View File

@ -0,0 +1,48 @@
use sha2::{Digest, Sha256};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FileCasError {
Conflict { expected: String, actual: String },
}
pub fn content_hash(bytes: &[u8]) -> String {
hex::encode(Sha256::digest(bytes))
}
/// Optimistic write: if the caller read `expected` and the file has moved,
/// refuse rather than overwrite.
pub fn check_expected_hash(
current: Option<&[u8]>,
expected: Option<&str>,
) -> Result<(), FileCasError> {
let Some(expected) = expected.filter(|value| !value.is_empty()) else {
return Ok(());
};
let actual = current.map(content_hash).unwrap_or_default();
if actual != expected {
return Err(FileCasError::Conflict {
expected: expected.to_string(),
actual,
});
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn matching_hash_allows_the_write() {
let bytes = b"hello";
let hash = content_hash(bytes);
assert!(check_expected_hash(Some(bytes), Some(&hash)).is_ok());
}
#[test]
fn stale_hash_is_a_conflict_not_an_overwrite() {
let hash = content_hash(b"old");
let error = check_expected_hash(Some(b"new"), Some(&hash)).unwrap_err();
assert!(matches!(error, FileCasError::Conflict { .. }));
}
}

View File

@ -0,0 +1,94 @@
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FormField {
pub name: String,
pub value: String,
pub locator: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FormStepResult {
pub field: String,
pub ok: bool,
pub evidence: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FormMacroResult {
pub ok: bool,
pub steps: Vec<FormStepResult>,
}
/// Bounded form fill: each field is re-located and checked. Failure stops the
/// rest of the macro instead of blasting stale ids.
pub fn run_form_macro(
fields: &[FormField],
locate: impl Fn(&str) -> Option<String>,
fill: impl Fn(&str, &str) -> Result<String, String>,
submit_ready: impl Fn() -> bool,
) -> FormMacroResult {
let mut steps = Vec::new();
for field in fields.iter().take(20) {
let Some(current) = locate(&field.locator) else {
steps.push(FormStepResult {
field: field.name.clone(),
ok: false,
evidence: "TARGET_STALE".into(),
});
return FormMacroResult { ok: false, steps };
};
match fill(&current, &field.value) {
Ok(evidence) => steps.push(FormStepResult {
field: field.name.clone(),
ok: true,
evidence,
}),
Err(error) => {
steps.push(FormStepResult {
field: field.name.clone(),
ok: false,
evidence: error,
});
return FormMacroResult { ok: false, steps };
}
}
}
if !submit_ready() {
steps.push(FormStepResult {
field: "submit".into(),
ok: false,
evidence: "precondition failed".into(),
});
return FormMacroResult { ok: false, steps };
}
FormMacroResult { ok: true, steps }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_missing_field_stops_the_macro() {
let fields = vec![
FormField {
name: "a".into(),
value: "1".into(),
locator: "p1:1".into(),
},
FormField {
name: "b".into(),
value: "2".into(),
locator: "p1:2".into(),
},
];
let result = run_form_macro(
&fields,
|locator| (locator == "p1:1").then(|| locator.to_string()),
|_, value| Ok(value.to_string()),
|| true,
);
assert!(!result.ok);
assert_eq!(result.steps.len(), 2);
assert!(!result.steps[1].ok);
}
}

236
crates/control/src/gmail.rs Normal file
View File

@ -0,0 +1,236 @@
const GMAIL_BATCH_MODIFY_MAX: usize = 1000;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GmailMessage {
pub id: String,
pub labels: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LabelDelta {
pub add: Vec<String>,
pub remove: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ModifyBatch {
pub ids: Vec<String>,
pub delta: LabelDelta,
}
/// Group messages that share the same add/remove set. Each batch is at most
/// 1000 ids, matching users.messages.batchModify.
pub fn plan_batch_modify(
messages: &[GmailMessage],
delta_for: impl Fn(&GmailMessage) -> LabelDelta,
) -> Vec<ModifyBatch> {
let mut groups: Vec<(LabelDelta, Vec<String>)> = Vec::new();
for message in messages {
let delta = delta_for(message);
if let Some((_, ids)) = groups.iter_mut().find(|(existing, _)| existing == &delta) {
ids.push(message.id.clone());
} else {
groups.push((delta, vec![message.id.clone()]));
}
}
let mut batches = Vec::new();
for (delta, ids) in groups {
for chunk in ids.chunks(GMAIL_BATCH_MODIFY_MAX) {
batches.push(ModifyBatch {
ids: chunk.to_vec(),
delta: delta.clone(),
});
}
}
batches
}
pub fn apply_delta(labels: &[String], delta: &LabelDelta) -> Vec<String> {
let mut next: Vec<String> = labels
.iter()
.filter(|label| !delta.remove.contains(label))
.cloned()
.collect();
for label in &delta.add {
if !next.contains(label) {
next.push(label.clone());
}
}
next
}
/// Inverse of a plan, computed against the labels each message had *before*
/// the plan ran. Only what this plan actually changed is reverted: a label the
/// message already carried is not stripped, one it never had is not re-added,
/// and labels a human added in the meantime are left alone.
pub fn undo_plan(before: &[GmailMessage], batches: &[ModifyBatch]) -> Vec<ModifyBatch> {
let mut deltas: Vec<(String, LabelDelta)> = Vec::new();
for batch in batches {
for id in &batch.ids {
let Some(message) = before.iter().find(|message| &message.id == id) else {
continue;
};
let inverse = LabelDelta {
add: batch
.delta
.remove
.iter()
.filter(|label| message.labels.contains(label))
.cloned()
.collect(),
remove: batch
.delta
.add
.iter()
.filter(|label| !message.labels.contains(label))
.cloned()
.collect(),
};
if inverse.add.is_empty() && inverse.remove.is_empty() {
continue;
}
deltas.push((id.clone(), inverse));
}
}
let messages: Vec<GmailMessage> = deltas
.iter()
.map(|(id, _)| GmailMessage {
id: id.clone(),
labels: Vec::new(),
})
.collect();
plan_batch_modify(&messages, |message| {
deltas
.iter()
.find(|(id, _)| id == &message.id)
.map(|(_, delta)| delta.clone())
.unwrap_or(LabelDelta {
add: Vec::new(),
remove: Vec::new(),
})
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VerifyStatus {
Matched,
Conflict,
Missing,
}
pub fn verify_labels(actual: &[String], expected: &[String]) -> VerifyStatus {
let mut left = actual.to_vec();
let mut right = expected.to_vec();
left.sort();
right.sort();
if left == right {
VerifyStatus::Matched
} else {
VerifyStatus::Conflict
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn batches_split_on_delta_and_the_1000_cap() {
let messages: Vec<_> = (0..1005)
.map(|index| GmailMessage {
id: format!("m{index}"),
labels: vec!["INBOX".into()],
})
.collect();
let batches = plan_batch_modify(&messages, |_| LabelDelta {
add: vec!["work".into()],
remove: vec![],
});
assert_eq!(batches.len(), 2);
assert_eq!(batches[0].ids.len(), 1000);
assert_eq!(batches[1].ids.len(), 5);
}
#[test]
fn undo_only_reverts_this_plans_delta() {
let after = apply_delta(
&["INBOX".into(), "work".into(), "human".into()],
&LabelDelta {
add: vec![],
remove: vec!["work".into()],
},
);
assert!(after.contains(&"human".into()));
assert!(!after.contains(&"work".into()));
}
#[test]
fn undo_plan_reverts_only_what_the_plan_changed() {
let before = vec![
// Already had "work": the plan's add was a no-op here, so undo must
// not remove it.
GmailMessage {
id: "m1".into(),
labels: vec!["INBOX".into(), "work".into()],
},
// Gained "work" and lost "INBOX": both get reverted.
GmailMessage {
id: "m2".into(),
labels: vec!["INBOX".into()],
},
// Never had "INBOX": the plan's remove was a no-op, nothing to re-add.
GmailMessage {
id: "m3".into(),
labels: vec![],
},
];
let plan = plan_batch_modify(&before, |_| LabelDelta {
add: vec!["work".into()],
remove: vec!["INBOX".into()],
});
let undo = undo_plan(&before, &plan);
let delta_for = |id: &str| {
undo.iter()
.find(|batch| batch.ids.iter().any(|item| item == id))
.map(|batch| batch.delta.clone())
};
assert_eq!(
delta_for("m1"),
Some(LabelDelta {
add: vec!["INBOX".into()],
remove: vec![],
})
);
assert_eq!(
delta_for("m2"),
Some(LabelDelta {
add: vec!["INBOX".into()],
remove: vec!["work".into()],
})
);
assert_eq!(
delta_for("m3"),
Some(LabelDelta {
add: vec![],
remove: vec!["work".into()],
})
);
// Applying plan then undo lands back where each message started.
for message in &before {
let after_plan = apply_delta(
&message.labels,
&LabelDelta {
add: vec!["work".into()],
remove: vec!["INBOX".into()],
},
);
let restored = apply_delta(&after_plan, &delta_for(&message.id).unwrap());
assert_eq!(
verify_labels(&restored, &message.labels),
VerifyStatus::Matched,
"{}",
message.id
);
}
}
}

729
crates/control/src/jobs.rs Normal file
View File

@ -0,0 +1,729 @@
use std::collections::HashMap;
use std::process::Stdio;
use std::sync::Mutex;
use std::time::{Duration, Instant};
use tokio::process::Command;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum JobStatus {
Running,
Succeeded,
Failed,
Cancelled,
Interrupted,
}
#[derive(Debug, Clone)]
pub struct JobSnapshot {
pub id: String,
pub status: JobStatus,
pub pid: Option<u32>,
pub stdout: String,
pub stderr: String,
pub exit_code: Option<i32>,
pub computer_generation: i32,
pub computer_id: Option<String>,
}
struct LiveJob {
snapshot: JobSnapshot,
child: Option<tokio::process::Child>,
pgid: Option<i32>,
started: Instant,
}
pub struct JobSupervisor {
inner: Mutex<HashMap<String, LiveJob>>,
}
impl Default for JobSupervisor {
fn default() -> Self {
Self {
inner: Mutex::new(HashMap::new()),
}
}
}
impl JobSupervisor {
pub fn get(&self, id: &str) -> Option<JobSnapshot> {
self.inner
.lock()
.unwrap()
.get(id)
.map(|job| job.snapshot.clone())
}
pub fn running_count(&self) -> usize {
self.inner
.lock()
.unwrap()
.values()
.filter(|job| job.snapshot.status == JobStatus::Running)
.count()
}
pub fn running_count_for(&self, computer_id: &str) -> usize {
self.inner
.lock()
.unwrap()
.values()
.filter(|job| {
job.snapshot.status == JobStatus::Running
&& job.snapshot.computer_id.as_deref() == Some(computer_id)
})
.count()
}
/// After a container recreate, leftover job ids must not look alive.
pub fn interrupt_generation(&self, generation: i32) {
let mut map = self.inner.lock().unwrap();
for job in map.values_mut() {
if job.snapshot.computer_generation != generation
&& job.snapshot.status == JobStatus::Running
{
job.snapshot.status = JobStatus::Interrupted;
job.child = None;
}
}
}
pub fn interrupt_computer(&self, computer_id: &str) {
let mut map = self.inner.lock().unwrap();
for job in map.values_mut() {
if job.snapshot.computer_id.as_deref() == Some(computer_id)
&& job.snapshot.status == JobStatus::Running
{
job.snapshot.status = JobStatus::Interrupted;
job.child = None;
}
}
}
pub async fn start(
&self,
id: &str,
argv: &[String],
generation: i32,
computer_id: Option<String>,
) -> Result<JobSnapshot, String> {
if argv.is_empty() {
return Err("argv required".into());
}
let mut command = Command::new(&argv[0]);
if argv.len() > 1 {
command.args(&argv[1..]);
}
command.stdout(Stdio::piped()).stderr(Stdio::piped());
#[cfg(unix)]
{
command.process_group(0);
}
let child = command.spawn().map_err(|error| error.to_string())?;
let pid = child.id();
let snapshot = JobSnapshot {
id: id.to_string(),
status: JobStatus::Running,
pid,
stdout: String::new(),
stderr: String::new(),
exit_code: None,
computer_generation: generation,
computer_id,
};
self.inner.lock().unwrap().insert(
id.to_string(),
LiveJob {
snapshot: snapshot.clone(),
child: Some(child),
pgid: pid.map(|pid| pid as i32),
started: Instant::now(),
},
);
Ok(snapshot)
}
pub async fn wait(&self, id: &str, timeout: Duration) -> Result<JobSnapshot, String> {
let started = Instant::now();
loop {
self.poll(id).await?;
let snapshot = self.get(id).ok_or_else(|| "unknown job".to_string())?;
if snapshot.status != JobStatus::Running {
return Ok(snapshot);
}
if started.elapsed() >= timeout {
return Ok(snapshot);
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
}
pub async fn poll(&self, id: &str) -> Result<JobSnapshot, String> {
let mut child = {
let mut map = self.inner.lock().unwrap();
let job = map.get_mut(id).ok_or_else(|| "unknown job".to_string())?;
job.child.take()
};
if let Some(mut live) = child.take() {
match live.try_wait() {
Ok(Some(status)) => {
let stdout = child_output(&mut live, true).await;
let stderr = child_output(&mut live, false).await;
let mut map = self.inner.lock().unwrap();
if let Some(job) = map.get_mut(id) {
job.snapshot.status = if status.success() {
JobStatus::Succeeded
} else {
JobStatus::Failed
};
job.snapshot.exit_code = status.code();
job.snapshot.stdout = stdout;
job.snapshot.stderr = stderr;
job.child = None;
return Ok(job.snapshot.clone());
}
}
Ok(None) => {
let mut map = self.inner.lock().unwrap();
if let Some(job) = map.get_mut(id) {
job.child = Some(live);
return Ok(job.snapshot.clone());
}
}
Err(error) => return Err(error.to_string()),
}
}
self.get(id).ok_or_else(|| "unknown job".to_string())
}
/// TERM the process group, then KILL after grace. Never claims cancel until
/// the tree is actually gone.
pub async fn cancel(&self, id: &str) -> Result<JobSnapshot, String> {
let (pid, pgid) = {
let map = self.inner.lock().unwrap();
let job = map.get(id).ok_or_else(|| "unknown job".to_string())?;
(job.snapshot.pid, job.pgid)
};
if let Some(pgid) = pgid {
let _ = Command::new("kill")
.args(["-TERM", "--", &format!("-{pgid}")])
.status()
.await;
tokio::time::sleep(Duration::from_millis(50)).await;
let still = self.poll(id).await?;
if still.status == JobStatus::Running {
let _ = Command::new("kill")
.args(["-KILL", "--", &format!("-{pgid}")])
.status()
.await;
}
} else if let Some(pid) = pid {
let _ = Command::new("kill")
.args(["-TERM", &pid.to_string()])
.status()
.await;
}
let _ = pid;
let mut snapshot = self.poll(id).await?;
if snapshot.status == JobStatus::Running {
// Process did not exit in time: be honest rather than claim cancelled.
return Ok(snapshot);
}
if snapshot.status != JobStatus::Succeeded {
snapshot.status = JobStatus::Cancelled;
if let Some(job) = self.inner.lock().unwrap().get_mut(id) {
job.snapshot.status = JobStatus::Cancelled;
}
}
Ok(self.get(id).unwrap_or(snapshot))
}
pub fn age(&self, id: &str) -> Option<Duration> {
self.inner
.lock()
.unwrap()
.get(id)
.map(|job| job.started.elapsed())
}
}
/// `LAZYBOY_NATIVE_JOB_CONCURRENCY`, default 2, hard-capped.
pub fn native_job_concurrency() -> usize {
std::env::var("LAZYBOY_NATIVE_JOB_CONCURRENCY")
.ok()
.and_then(|value| value.parse().ok())
.unwrap_or(2)
.clamp(1, 32)
}
pub fn job_quota_exceeded(running: usize, limit: usize) -> bool {
running >= limit
}
/// Tracker for jobs that already run on the Computer. Does not spawn host processes.
#[derive(Default)]
pub struct ComputerJobTable {
inner: Mutex<HashMap<String, JobSnapshot>>,
}
impl ComputerJobTable {
pub fn insert(&self, snapshot: JobSnapshot) {
self.inner
.lock()
.unwrap()
.insert(snapshot.id.clone(), snapshot);
}
pub fn get(&self, id: &str) -> Option<JobSnapshot> {
self.inner.lock().unwrap().get(id).cloned()
}
pub fn update(&self, id: &str, mutate: impl FnOnce(&mut JobSnapshot)) -> Option<JobSnapshot> {
let mut map = self.inner.lock().unwrap();
let job = map.get_mut(id)?;
mutate(job);
Some(job.clone())
}
pub fn running_count(&self) -> usize {
self.inner
.lock()
.unwrap()
.values()
.filter(|job| job.status == JobStatus::Running)
.count()
}
}
pub fn sanitize_job_id(id: &str) -> String {
let cleaned: String = id
.chars()
.filter(|ch| ch.is_ascii_alphanumeric() || *ch == '-' || *ch == '_')
.take(80)
.collect();
if cleaned.is_empty() {
"job".into()
} else {
cleaned
}
}
pub fn posix_shell_join(argv: &[String]) -> String {
argv.iter()
.map(|arg| {
if arg
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b"-_./:@+".contains(&b))
{
arg.clone()
} else {
format!("'{}'", arg.replace('\'', "'\\''"))
}
})
.collect::<Vec<_>>()
.join(" ")
}
/// Launch argv inside the Computer so the process is not a child of the API.
/// The wrapper writes `$?` to `/tmp/lazyboy/jobs/{id}/exit` when the job exits
/// and prints the pid of the `setsid` session leader (the `sh` wrapper), which
/// is also the process-group id used by [`computer_cancel_script`].
///
/// The joined command is a separate argv element (`$1`), not interpolated into
/// `sh -c '…'`. Nested single quotes from `posix_shell_join` must survive.
///
/// `mkdir` is a separate statement on purpose: `a && b &` backgrounds the whole
/// list in a subshell and `$!` would then be that subshell, not the job. The
/// wrapper also records its own `$$` so the reported pid is right even when
/// `setsid` had to fork.
pub fn computer_background_launch(argv: &[String], job_id: &str) -> Vec<String> {
let id = sanitize_job_id(job_id);
let dir = format!("/tmp/lazyboy/jobs/{id}");
let cmd = posix_shell_join(argv);
let script = format!(
r#"mkdir -p {dir} || exit 1
rm -f {dir}/pid {dir}/exit
setsid nohup sh -c 'printf %s "$$" >{dir}/pid; eval "$1"; echo $? > {dir}/exit' _ "$1" >{dir}/out 2>{dir}/err </dev/null &
launcher=$!
for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25; do
[ -s {dir}/pid ] && break
kill -0 "$launcher" 2>/dev/null || break
sleep 0.02
done
if [ -s {dir}/pid ]; then cat {dir}/pid; else echo "$launcher"; fi"#
);
vec!["bash".into(), "-c".into(), script, "bash".into(), cmd]
}
/// Shell run inside the Computer to stop a background job started by
/// [`computer_background_launch`]. The wrapper is a `setsid` session leader, so
/// `-pid` addresses its whole process group. Exits 0 only when nothing in the
/// group is left; exit 1 means the tree survived TERM and KILL and the caller
/// must keep reporting the job as running.
pub fn computer_cancel_script(pid: u32) -> String {
format!(
"kill -TERM -- -{pid} 2>/dev/null || kill -TERM {pid} 2>/dev/null; \
for _ in 1 2 3 4 5 6 7 8 9 10; do kill -0 -- -{pid} 2>/dev/null || kill -0 {pid} 2>/dev/null || exit 0; sleep 0.1; done; \
kill -KILL -- -{pid} 2>/dev/null || kill -KILL {pid} 2>/dev/null; sleep 0.1; \
if kill -0 -- -{pid} 2>/dev/null || kill -0 {pid} 2>/dev/null; then exit 1; fi; exit 0"
)
}
/// Liveness probe for a Computer-side job. `kill -0` alone is not enough: a
/// wrapper whose parent never reaped it is a zombie that still answers, so the
/// process state is checked too. Exit 0 = something in the group is genuinely
/// running.
pub fn computer_alive_script(pid: u32) -> String {
format!(
"for p in $(pgrep -g {pid} 2>/dev/null) {pid}; do \
s=$(awk '/^State:/ {{print $2}}' /proc/$p/status 2>/dev/null); \
case \"$s\" in ''|Z|X) ;; *) exit 0;; esac; \
done; exit 1"
)
}
/// Reap a Computer-side background job from pid liveness and the exit file.
/// Missing or unreadable `$?` is Failed/1, never Succeeded/0.
pub fn reap_background_job(pid_alive: bool, exit_file: Option<&str>) -> (JobStatus, i32) {
if pid_alive {
return (JobStatus::Running, 0);
}
let trimmed = exit_file.map(str::trim).filter(|text| !text.is_empty());
match trimmed {
Some("0") => (JobStatus::Succeeded, 0),
Some(raw) if raw.eq_ignore_ascii_case("false") => (JobStatus::Failed, 1),
Some(raw) => {
let code = raw.parse::<i32>().unwrap_or(1);
if code == 0 {
(JobStatus::Succeeded, 0)
} else {
(JobStatus::Failed, code)
}
}
None => (JobStatus::Failed, 1),
}
}
pub fn job_snapshot_result(job: &JobSnapshot) -> crate::CommandResult {
crate::CommandResult {
stdout: job.stdout.clone(),
stderr: job.stderr.clone(),
code: job.exit_code.unwrap_or(0),
job_id: Some(job.id.clone()),
signal: None,
status: Some(format!("{:?}", job.status).to_ascii_lowercase()),
}
}
/// True when this process has a direct child whose cmdline contains `needle`.
pub fn current_process_has_child_cmd(needle: &str) -> bool {
let self_pid = std::process::id();
let Ok(entries) = std::fs::read_dir("/proc") else {
return false;
};
for entry in entries.flatten() {
let Ok(pid) = entry.file_name().to_string_lossy().parse::<u32>() else {
continue;
};
let status = std::fs::read_to_string(format!("/proc/{pid}/status")).unwrap_or_default();
let ppid = status.lines().find_map(|line| {
line.strip_prefix("PPid:")
.and_then(|rest| rest.trim().parse::<u32>().ok())
});
if ppid != Some(self_pid) {
continue;
}
let cmd = std::fs::read(format!("/proc/{pid}/cmdline")).unwrap_or_default();
let cmd: Vec<u8> = cmd
.iter()
.map(|byte| if *byte == 0 { b' ' } else { *byte })
.collect();
if String::from_utf8_lossy(&cmd).contains(needle) {
return true;
}
}
false
}
async fn child_output(child: &mut tokio::process::Child, stdout: bool) -> String {
use tokio::io::AsyncReadExt;
let mut buf = String::new();
if stdout {
if let Some(pipe) = child.stdout.as_mut() {
let mut bytes = Vec::new();
let _ = pipe.read_to_end(&mut bytes).await;
buf = String::from_utf8_lossy(&bytes).into_owned();
}
} else if let Some(pipe) = child.stderr.as_mut() {
let mut bytes = Vec::new();
let _ = pipe.read_to_end(&mut bytes).await;
buf = String::from_utf8_lossy(&bytes).into_owned();
}
buf
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn short_command_returns_real_exit_status() {
let jobs = JobSupervisor::default();
jobs.start("j1", &["/bin/true".into()], 1, None)
.await
.unwrap();
let done = jobs.wait("j1", Duration::from_secs(2)).await.unwrap();
assert_eq!(done.status, JobStatus::Succeeded);
assert_eq!(done.exit_code, Some(0));
}
#[tokio::test]
async fn cancel_stops_a_sleeping_process_tree() {
let jobs = JobSupervisor::default();
jobs.start(
"j2",
&["/bin/sh".into(), "-c".into(), "sleep 30".into()],
1,
None,
)
.await
.unwrap();
let cancelled = jobs.cancel("j2").await.unwrap();
assert_ne!(cancelled.status, JobStatus::Running);
assert!(matches!(
cancelled.status,
JobStatus::Cancelled | JobStatus::Failed | JobStatus::Succeeded
));
}
#[test]
fn old_generation_jobs_are_interrupted_not_alive() {
let jobs = JobSupervisor::default();
jobs.inner.lock().unwrap().insert(
"old".into(),
LiveJob {
snapshot: JobSnapshot {
id: "old".into(),
status: JobStatus::Running,
pid: Some(1),
stdout: String::new(),
stderr: String::new(),
exit_code: None,
computer_generation: 1,
computer_id: Some("comp-a".into()),
},
child: None,
pgid: None,
started: Instant::now(),
},
);
jobs.interrupt_generation(2);
assert_eq!(jobs.get("old").unwrap().status, JobStatus::Interrupted);
}
#[test]
fn computer_background_launch_runs_inside_the_computer() {
let argv = computer_background_launch(&["/bin/sleep".into(), "30".into()], "job-abc");
assert_eq!(argv[0], "bash");
let script = &argv[2];
assert!(script.contains("/tmp/lazyboy/jobs/job-abc"));
assert!(script.contains("/tmp/lazyboy/jobs/job-abc/exit"));
assert!(script.contains("echo $?"));
assert!(script.contains("eval \"$1\""));
assert!(script.contains("setsid"));
assert!(!script.contains("Command::new"));
assert!(argv.iter().any(|part| part.contains("/bin/sleep 30")));
}
#[test]
fn background_launch_keeps_posix_quoted_arguments() {
let args = vec!["echo".into(), "hello world".into()];
let quoted = posix_shell_join(&args);
assert_eq!(quoted, "echo 'hello world'");
let launch = computer_background_launch(&args, "job-q");
assert!(
launch.iter().any(|part| part == &quoted),
"quoted argv must be a separate argument, not spliced into sh -c '...'"
);
let script = &launch[2];
assert!(
!script.contains("sh -c 'echo "),
"must not wrap posix_shell_join output in another layer of single quotes"
);
assert!(script.contains("eval \"$1\""));
let ran = std::process::Command::new("sh")
.args(["-c", "eval \"$1\"", "_", &quoted])
.output()
.unwrap();
assert!(ran.status.success());
assert_eq!(String::from_utf8_lossy(&ran.stdout).trim(), "hello world");
}
#[test]
fn computer_cancel_script_kills_the_whole_process_tree() {
// Same launcher the supervisor uses inside the Computer, run locally.
let job_id = format!("cancel-{}", std::process::id());
let launch = computer_background_launch(
&["/bin/sh".into(), "-c".into(), "sleep 300; sleep 300".into()],
&job_id,
);
let launched = std::process::Command::new(&launch[0])
.args(&launch[1..])
.output()
.unwrap();
assert!(launched.status.success(), "{launched:?}");
let pid: u32 = String::from_utf8_lossy(&launched.stdout)
.trim()
.lines()
.last()
.unwrap()
.trim()
.parse()
.unwrap();
std::thread::sleep(Duration::from_millis(200));
let find_sleep = || {
std::process::Command::new("pgrep")
.args(["-g", &pid.to_string(), "-x", "sleep"])
.output()
.map(|out| out.status.success())
.unwrap_or(false)
};
assert!(
find_sleep(),
"a sleep must be running in the job's process group"
);
let alive = |pid: u32| {
std::process::Command::new("bash")
.args(["-c", &computer_alive_script(pid)])
.status()
.map(|status| status.success())
.unwrap_or(false)
};
assert!(alive(pid), "running job must probe alive");
let cancelled = std::process::Command::new("bash")
.args(["-c", &computer_cancel_script(pid)])
.output()
.unwrap();
assert!(cancelled.status.success(), "{cancelled:?}");
assert!(
!find_sleep(),
"the child sleep must be gone, not just the sh wrapper"
);
assert!(!alive(pid), "cancelled job must not probe alive");
let _ = std::fs::remove_dir_all(format!("/tmp/lazyboy/jobs/{job_id}"));
}
/// A finished wrapper nobody reaped is a zombie: `kill -0` still succeeds,
/// but the job is over and must not be reported as running.
#[test]
fn computer_alive_script_treats_a_zombie_as_dead() {
// A child that exits at once while its parent (this sh) keeps living
// without wait(1)-ing leaves a zombie whose pid we can probe.
let mut parent = std::process::Command::new("sh")
.args(["-c", "sh -c 'exit 0' & echo $!; exec sleep 30"])
.stdout(std::process::Stdio::piped())
.spawn()
.unwrap();
let mut stdout = std::io::BufReader::new(parent.stdout.take().unwrap());
let mut line = String::new();
std::io::BufRead::read_line(&mut stdout, &mut line).unwrap();
let zombie: u32 = line.trim().parse().unwrap();
std::thread::sleep(Duration::from_millis(200));
let kill0 = std::process::Command::new("kill")
.args(["-0", &zombie.to_string()])
.status()
.unwrap()
.success();
let alive = std::process::Command::new("bash")
.args(["-c", &computer_alive_script(zombie)])
.status()
.unwrap()
.success();
let _ = parent.kill();
let _ = parent.wait();
assert!(kill0, "precondition: the zombie still answers kill -0");
assert!(!alive, "a zombie must count as finished");
}
#[test]
fn reap_background_job_failed_exit_is_not_success() {
assert_eq!(reap_background_job(true, None), (JobStatus::Running, 0));
assert_eq!(
reap_background_job(false, Some("0")),
(JobStatus::Succeeded, 0)
);
assert_eq!(
reap_background_job(false, Some("1")),
(JobStatus::Failed, 1)
);
assert_eq!(
reap_background_job(false, Some("false")),
(JobStatus::Failed, 1)
);
assert_eq!(reap_background_job(false, None), (JobStatus::Failed, 1));
assert_eq!(
reap_background_job(false, Some(" ")),
(JobStatus::Failed, 1)
);
}
#[test]
fn command_result_json_keeps_job_id_status_and_code() {
let result = crate::CommandResult {
stdout: String::new(),
stderr: String::new(),
code: 1,
job_id: Some("job-1".into()),
signal: None,
status: Some("failed".into()),
};
let value = serde_json::to_value(&result).unwrap();
let back: crate::CommandResult = serde_json::from_value(value).unwrap();
assert_eq!(back.job_id.as_deref(), Some("job-1"));
assert_eq!(back.status.as_deref(), Some("failed"));
assert_eq!(back.code, 1);
}
#[test]
fn supervisor_exec_returns_full_command_result() {
let src = include_str!("../../../crates/supervisor/src/main.rs");
assert!(
!src.contains("\"stdout\": result.stdout"),
"exec HTTP must serialize CommandResult, not a stdout/stderr/code subset"
);
assert!(src.contains("Ok(Json(result))"));
}
#[test]
fn running_count_and_quota_are_per_computer() {
let jobs = JobSupervisor::default();
jobs.inner.lock().unwrap().insert(
"a".into(),
LiveJob {
snapshot: JobSnapshot {
id: "a".into(),
status: JobStatus::Running,
pid: None,
stdout: String::new(),
stderr: String::new(),
exit_code: None,
computer_generation: 1,
computer_id: Some("comp-a".into()),
},
child: None,
pgid: None,
started: Instant::now(),
},
);
assert_eq!(jobs.running_count(), 1);
assert_eq!(jobs.running_count_for("comp-a"), 1);
assert_eq!(jobs.running_count_for("comp-b"), 0);
assert!(job_quota_exceeded(2, 2));
assert!(!job_quota_exceeded(1, 2));
jobs.interrupt_computer("comp-a");
assert_eq!(jobs.get("a").unwrap().status, JobStatus::Interrupted);
assert_eq!(jobs.running_count(), 0);
}
}

View File

@ -1,27 +1,60 @@
mod a11y; mod a11y;
mod actions; mod actions;
mod browser_page; mod browser_page;
mod capability;
mod controller; mod controller;
mod cua; mod cua;
mod display_backend;
mod file_cas;
mod form;
mod gmail;
mod jobs;
mod lease; mod lease;
mod mcp_policy;
mod observe; mod observe;
mod operation;
mod outlook;
mod overlay; mod overlay;
mod path; mod path;
mod pause;
mod readiness;
mod sandbox; mod sandbox;
mod screen; mod screen;
mod secrets;
mod takeover; mod takeover;
mod tool_manager;
mod verifier;
mod wait;
mod x11; mod x11;
pub use a11y::*; pub use a11y::*;
pub use actions::*; pub use actions::*;
pub use browser_page::*; pub use browser_page::*;
pub use capability::*;
pub use controller::*; pub use controller::*;
pub use cua::{CuaClient, CuaController, TranslatedAction, translate_action}; pub use cua::{
BrowserLocatorKind, CuaClient, CuaController, TranslatedAction, classify_browser_locator,
is_cua_ref, translate_action,
};
pub use display_backend::*;
pub use file_cas::*;
pub use form::*;
pub use gmail::*;
pub use jobs::*;
pub use lease::*; pub use lease::*;
pub use mcp_policy::*;
pub use observe::*; pub use observe::*;
pub use operation::*;
pub use outlook::*;
pub use overlay::*; pub use overlay::*;
pub use path::*; pub use path::*;
pub use pause::*;
pub use readiness::*;
pub use sandbox::*; pub use sandbox::*;
pub use screen::*; pub use screen::*;
pub use secrets::*;
pub use takeover::*; pub use takeover::*;
pub use tool_manager::*;
pub use verifier::*;
pub use wait::*;
pub use x11::*; pub use x11::*;

View File

@ -0,0 +1,92 @@
//! Where MCP runtimes execute. Catalog stdio is never a child of the API.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum McpExecutionLocation {
AssignedComputer,
RemoteHttp,
}
pub fn mcp_execution_location(transport: &str) -> McpExecutionLocation {
match transport {
"http" | "sse" => McpExecutionLocation::RemoteHttp,
_ => McpExecutionLocation::AssignedComputer,
}
}
/// The API/Supervisor process must not `spawn` a catalog stdio MCP child.
pub fn api_host_may_spawn_stdio() -> bool {
false
}
pub fn catalog_stdio_api_error() -> &'static str {
"stdio MCP runs on the assigned Computer, not the API process. Install a reviewed package onto the Computer (Tool Manager). Unpinned npx -y latest is refused."
}
/// `npx -y pkg` without a version pin is not an install record.
pub fn refuse_unpinned_npx(command: &str, args: &[String]) -> bool {
let name = command.rsplit('/').next().unwrap_or(command);
if name != "npx" && name != "pnpx" {
return false;
}
let yes = args.iter().any(|arg| arg == "-y" || arg == "--yes");
let pinned = args.iter().any(|arg| package_is_pinned(arg));
yes && !pinned
}
/// `pkg@1.2.3` and `@scope/pkg@1.2.3` are pinned; `@scope/pkg` is not — the
/// leading `@` is the scope, not a version separator.
fn package_is_pinned(arg: &str) -> bool {
let name = arg.strip_prefix('@').unwrap_or(arg);
name.contains('@')
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn catalog_stdio_is_not_an_api_child() {
assert!(!api_host_may_spawn_stdio());
assert_eq!(
mcp_execution_location("stdio"),
McpExecutionLocation::AssignedComputer
);
assert_eq!(
mcp_execution_location("http"),
McpExecutionLocation::RemoteHttp
);
assert_eq!(
mcp_execution_location("sse"),
McpExecutionLocation::RemoteHttp
);
assert!(catalog_stdio_api_error().contains("assigned Computer"));
}
#[test]
fn unpinned_npx_yes_is_refused() {
assert!(refuse_unpinned_npx(
"npx",
&["-y".into(), "some-mcp".into()]
));
assert!(!refuse_unpinned_npx(
"npx",
&["-y".into(), "some-mcp@1.2.3".into()]
));
assert!(!refuse_unpinned_npx("python3", &["echo_server.py".into()]));
// Scoped packages: the leading `@` is not a version pin.
assert!(refuse_unpinned_npx(
"npx",
&[
"-y".into(),
"@modelcontextprotocol/server-filesystem".into()
]
));
assert!(!refuse_unpinned_npx(
"npx",
&[
"-y".into(),
"@modelcontextprotocol/server-filesystem@0.6.2".into()
]
));
}
}

View File

@ -111,7 +111,11 @@ pub fn format_ui_element_lines(elements: &[UiElement], max: usize) -> String {
} else { } else {
"" ""
}; };
format!("[{}] {kind} \"{title}{ellipsis}\" @ {x},{y}", element.id) let disabled = if element.disabled { " [disabled]" } else { "" };
format!(
"[{}] {kind} \"{title}{ellipsis}\"{disabled} @ {x},{y}",
element.id
)
}) })
.collect(); .collect();
if let Some(dropped) = elements.len().checked_sub(max) { if let Some(dropped) = elements.len().checked_sub(max) {
@ -200,6 +204,43 @@ pub fn screen_change_between(
} }
} }
/// Whether a captured frame should be attached to the *model request*.
///
/// Delivery is independent of capture: a background observe may update the
/// last captured frame without the model having seen it. Text-only models
/// never receive an image. Vision models get a frame when nothing has been
/// delivered yet, the bytes differ from the last delivered frame, the vision
/// model identity changed, history compression dropped the previous picture,
/// or the caller forced delivery (takeover resume).
pub fn should_deliver_observation_image(
vision: bool,
force_delivery: bool,
current_model_id: Option<&str>,
delivered_frame: Option<&str>,
delivered_signature: Option<&[u8]>,
delivered_model_id: Option<&str>,
observation: &ComputerObservation,
) -> bool {
if !vision {
return false;
}
if force_delivery {
return true;
}
if vision_model_changed(current_model_id, delivered_model_id) {
return true;
}
screen_change_between(delivered_frame, delivered_signature, observation)
!= ScreenChange::Identical
}
fn vision_model_changed(current: Option<&str>, delivered: Option<&str>) -> bool {
match (current, delivered) {
(Some(current), Some(delivered)) => current != delivered,
_ => false,
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -338,6 +379,117 @@ mod tests {
); );
} }
#[test]
fn should_deliver_observation_image_follows_the_vision_truth_table() {
fn png(paint: impl Fn(u32, u32) -> Rgb<u8>) -> Vec<u8> {
let img = RgbImage::from_fn(320, 180, paint);
let mut out = Vec::new();
PngEncoder::new(&mut out)
.write_image(img.as_raw(), 320, 180, ExtendedColorType::Rgb8)
.unwrap();
out
}
let plain = png(|_, _| Rgb([240, 240, 240]));
let clock = png(|x, y| {
if x < 6 && y < 6 {
Rgb([0, 0, 0])
} else {
Rgb([240, 240, 240])
}
});
let dialog = png(|x, y| {
if x < 160 && y < 90 {
Rgb([20, 20, 20])
} else {
Rgb([240, 240, 240])
}
});
let same = observation_from_png(plain.clone(), 320, 180, None, None);
let tick = observation_from_png(clock, 320, 180, None, None);
let covered = observation_from_png(dialog, 320, 180, None, None);
let signature = frame_signature(&plain);
let frame = same.frame_id.as_str();
let sig = signature.as_deref();
// Text-only models never receive an image, even on a first capture.
assert!(!should_deliver_observation_image(
false, false, None, None, None, None, &same
));
assert!(!should_deliver_observation_image(
false,
true,
Some("vision-model"),
None,
None,
None,
&covered
));
// First visual capture (nothing delivered yet) always attaches.
assert!(should_deliver_observation_image(
true,
false,
Some("m1"),
None,
None,
None,
&same
));
// Identical to the last *delivered* frame is skipped.
assert!(!should_deliver_observation_image(
true,
false,
Some("m1"),
Some(frame),
sig,
Some("m1"),
&same
));
// Similar and Changed still attach: the model may need to look.
assert!(should_deliver_observation_image(
true,
false,
Some("m1"),
Some(frame),
sig,
Some("m1"),
&tick
));
assert!(should_deliver_observation_image(
true,
false,
Some("m1"),
Some(frame),
sig,
Some("m1"),
&covered
));
// Force delivery (takeover / history compression) even when identical.
assert!(should_deliver_observation_image(
true,
true,
Some("m1"),
Some(frame),
sig,
Some("m1"),
&same
));
// Switching vision model identity re-delivers the current frame.
assert!(should_deliver_observation_image(
true,
false,
Some("m2"),
Some(frame),
sig,
Some("m1"),
&same
));
}
#[test] #[test]
fn element_lines_cap_the_count_and_long_labels() { fn element_lines_cap_the_count_and_long_labels() {
let elements: Vec<UiElement> = (1..=5) let elements: Vec<UiElement> = (1..=5)

View File

@ -0,0 +1,173 @@
use std::collections::HashMap;
use std::sync::Mutex;
use sha2::{Digest, Sha256};
/// Durable-enough in-process operation ledger. Same operation_id + payload
/// hash returns the original result. Same id with a different payload is
/// rejected. Journal failure (lock poisoned / quota) refuses new mutations.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OperationRecord {
pub operation_id: String,
pub payload_hash: String,
pub status: String,
pub result: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum OperationError {
PayloadMismatch,
JournalUnavailable,
}
pub struct OperationLedger {
inner: Mutex<HashMap<String, OperationRecord>>,
fail_journal: Mutex<bool>,
}
impl Default for OperationLedger {
fn default() -> Self {
Self {
inner: Mutex::new(HashMap::new()),
fail_journal: Mutex::new(false),
}
}
}
impl OperationLedger {
pub fn payload_hash(payload: &[u8]) -> String {
hex::encode(Sha256::digest(payload))
}
pub fn set_journal_available(&self, available: bool) {
*self.fail_journal.lock().unwrap() = !available;
}
pub fn accept(
&self,
operation_id: &str,
payload: &[u8],
result: &str,
) -> Result<OperationRecord, OperationError> {
let record = self.begin(operation_id, payload)?;
if !record.result.is_empty() {
return Ok(record);
}
self.complete(operation_id, payload, result, "succeeded")
}
/// Insert an accepted row. A finished result is returned as-is (replay).
pub fn begin(
&self,
operation_id: &str,
payload: &[u8],
) -> Result<OperationRecord, OperationError> {
if *self.fail_journal.lock().unwrap() {
return Err(OperationError::JournalUnavailable);
}
let hash = Self::payload_hash(payload);
let mut map = self.inner.lock().unwrap();
if let Some(existing) = map.get(operation_id) {
if existing.payload_hash != hash {
return Err(OperationError::PayloadMismatch);
}
return Ok(existing.clone());
}
let record = OperationRecord {
operation_id: operation_id.to_string(),
payload_hash: hash,
status: "accepted".into(),
result: String::new(),
};
map.insert(operation_id.to_string(), record.clone());
Ok(record)
}
/// Store the result of an accepted operation. A completed row is not overwritten.
pub fn complete(
&self,
operation_id: &str,
payload: &[u8],
result: &str,
status: &str,
) -> Result<OperationRecord, OperationError> {
if *self.fail_journal.lock().unwrap() {
return Err(OperationError::JournalUnavailable);
}
let hash = Self::payload_hash(payload);
let mut map = self.inner.lock().unwrap();
if let Some(existing) = map.get_mut(operation_id) {
if existing.payload_hash != hash {
return Err(OperationError::PayloadMismatch);
}
if !existing.result.is_empty() {
return Ok(existing.clone());
}
existing.result = result.to_string();
existing.status = status.to_string();
return Ok(existing.clone());
}
let record = OperationRecord {
operation_id: operation_id.to_string(),
payload_hash: hash,
status: status.into(),
result: result.to_string(),
};
map.insert(operation_id.to_string(), record.clone());
Ok(record)
}
pub fn get(&self, operation_id: &str) -> Option<OperationRecord> {
self.inner.lock().unwrap().get(operation_id).cloned()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn same_operation_is_not_rerun() {
let ledger = OperationLedger::default();
let first = ledger.accept("op-1", b"{\"a\":1}", "ok").unwrap();
let second = ledger.accept("op-1", b"{\"a\":1}", "different").unwrap();
assert_eq!(first.result, "ok");
assert_eq!(second.result, "ok");
}
#[test]
fn same_id_different_payload_is_rejected() {
let ledger = OperationLedger::default();
ledger.accept("op-1", b"a", "ok").unwrap();
assert_eq!(
ledger.accept("op-1", b"b", "ok"),
Err(OperationError::PayloadMismatch)
);
}
#[test]
fn journal_failure_refuses_new_mutations() {
let ledger = OperationLedger::default();
ledger.set_journal_available(false);
assert_eq!(
ledger.accept("op-2", b"x", "ok"),
Err(OperationError::JournalUnavailable)
);
}
#[test]
fn begin_then_complete_does_not_keep_an_empty_result() {
let ledger = OperationLedger::default();
let started = ledger.begin("op-3", b"payload").unwrap();
assert_eq!(started.status, "accepted");
assert!(started.result.is_empty());
let done = ledger
.complete("op-3", b"payload", "wrote", "succeeded")
.unwrap();
assert_eq!(done.result, "wrote");
let replay = ledger
.complete("op-3", b"payload", "other", "succeeded")
.unwrap();
assert_eq!(replay.result, "wrote");
}
}

View File

@ -0,0 +1,94 @@
pub const GRAPH_BATCH_MAX: usize = 20;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GraphSubResult {
pub id: String,
pub status: u16,
pub retry_after: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GraphItemOutcome {
Ok,
Retry { after_ms: u64 },
NeedsAuth,
Failed(u16),
}
/// HTTP 200 on the batch envelope is not success. Each sub-request is judged
/// on its own status. 429 honours Retry-After; 401 stops mutation.
pub fn classify_graph_item(item: &GraphSubResult) -> GraphItemOutcome {
match item.status {
200..=299 => GraphItemOutcome::Ok,
401 | 403 => GraphItemOutcome::NeedsAuth,
429 => GraphItemOutcome::Retry {
after_ms: item.retry_after.unwrap_or(1) * 1000,
},
other => GraphItemOutcome::Failed(other),
}
}
pub fn chunk_graph_batch<T>(items: &[T]) -> Vec<&[T]> {
items.chunks(GRAPH_BATCH_MAX).collect()
}
/// Merge categories without wiping ones this plan did not mention.
pub fn merge_categories(current: &[String], add: &[String], remove: &[String]) -> Vec<String> {
let mut next: Vec<String> = current
.iter()
.filter(|item| !remove.contains(item))
.cloned()
.collect();
for item in add {
if !next.contains(item) {
next.push(item.clone());
}
}
next
}
pub fn mail_readwrite_allows_send() -> bool {
false
}
pub fn mailbox_settings_required_for_master_categories() -> bool {
true
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn batch_http_200_still_classifies_child_429() {
let item = GraphSubResult {
id: "1".into(),
status: 429,
retry_after: Some(2),
};
assert_eq!(
classify_graph_item(&item),
GraphItemOutcome::Retry { after_ms: 2000 }
);
}
#[test]
fn graph_batches_are_at_most_twenty() {
let items: Vec<u32> = (0..41).collect();
let chunks = chunk_graph_batch(&items);
assert_eq!(chunks.len(), 3);
assert!(chunks.iter().all(|chunk| chunk.len() <= 20));
}
#[test]
fn categories_merge_does_not_clobber_unrelated_labels() {
let merged = merge_categories(
&["keep".into(), "old".into()],
&["new".into()],
&["old".into()],
);
assert_eq!(merged, vec!["keep".to_string(), "new".to_string()]);
assert!(!mail_readwrite_allows_send());
assert!(mailbox_settings_required_for_master_categories());
}
}

View File

@ -0,0 +1,66 @@
use lazyboy_contracts::PauseScope;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct ControlEpochs {
pub computer: u64,
pub agent: u64,
pub display: u64,
}
impl ControlEpochs {
pub fn bump(&mut self, scope: PauseScope) {
match scope {
PauseScope::Computer => {
self.computer = self.computer.saturating_add(1);
self.agent = self.agent.saturating_add(1);
self.display = self.display.saturating_add(1);
}
PauseScope::Agent => {
self.agent = self.agent.saturating_add(1);
}
PauseScope::Display => {
self.display = self.display.saturating_add(1);
}
}
}
/// A token from before the bump is stale for that scope.
pub fn allows(&self, scope: PauseScope, token: ControlEpochs) -> bool {
match scope {
PauseScope::Computer => token.computer == self.computer,
PauseScope::Agent => token.computer == self.computer && token.agent == self.agent,
PauseScope::Display => token.computer == self.computer && token.display == self.display,
}
}
}
/// Pausing agent A must not freeze unrelated work of agent B on the same Team
/// computer. Computer-wide pause is explicit.
pub fn freeze_peer_on_agent_pause() -> bool {
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pausing_agent_a_does_not_invalidate_b_display() {
let mut epochs = ControlEpochs::default();
let b_before = epochs;
epochs.bump(PauseScope::Agent);
assert!(epochs.allows(PauseScope::Display, b_before));
assert!(!epochs.allows(PauseScope::Agent, b_before));
assert!(!freeze_peer_on_agent_pause());
}
#[test]
fn computer_pause_invalidates_every_scope() {
let mut epochs = ControlEpochs::default();
let before = epochs;
epochs.bump(PauseScope::Computer);
assert!(!epochs.allows(PauseScope::Agent, before));
assert!(!epochs.allows(PauseScope::Display, before));
assert!(!epochs.allows(PauseScope::Computer, before));
}
}

View File

@ -0,0 +1,88 @@
//! Layered readiness: runner / browser / desktop / viewer are independent.
use crate::{
AdapterContext, ComputerRef, EnsureScreenRequest, EnsureScreenResult, SandboxError,
SandboxProvider,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct ComponentReadiness {
pub runner: bool,
pub browser: bool,
pub desktop: bool,
pub viewer: bool,
}
/// Native exec, files, and Computer-local MCP need the Runner, not XFCE or noVNC.
pub fn native_work_allowed(ready: ComponentReadiness) -> bool {
ready.runner
}
pub fn viewer_blocks_native() -> bool {
false
}
pub fn desktop_required_for_native() -> bool {
false
}
/// Production gate: native tools must not attach a display/viewer.
pub fn attach_display_for_tool(need_gui: bool) -> bool {
need_gui
}
/// Call `ensure_screen` only when the tool actually needs a display.
pub async fn maybe_ensure_screen(
sandbox: &dyn SandboxProvider,
computer: &ComputerRef,
need_gui: bool,
request: EnsureScreenRequest,
context: &AdapterContext,
) -> Result<Option<EnsureScreenResult>, SandboxError> {
if !attach_display_for_tool(need_gui) {
return Ok(None);
}
Ok(Some(
sandbox.ensure_screen(computer, request, context).await?,
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn native_exec_does_not_need_desktop_or_viewer() {
let ready = ComponentReadiness {
runner: true,
browser: false,
desktop: false,
viewer: false,
};
assert!(native_work_allowed(ready));
assert!(!viewer_blocks_native());
assert!(!desktop_required_for_native());
assert!(!attach_display_for_tool(false));
assert!(attach_display_for_tool(true));
}
#[test]
fn start_sh_skips_desktop_when_runner_only() {
let script = include_str!("../../../image/computer/start.sh");
assert!(script.contains("LAZYBOY_RUNNER_ONLY"));
assert!(script.contains("exec sleep infinity"));
assert!(
script.contains("lazyboy-screen boot-primary"),
"GUI boot still starts the desktop"
);
}
#[test]
fn viewer_not_ready_does_not_block_native() {
assert!(native_work_allowed(ComponentReadiness {
runner: true,
..ComponentReadiness::default()
}));
assert!(!native_work_allowed(ComponentReadiness::default()));
}
}

View File

@ -18,6 +18,10 @@ pub struct AdapterContext {
pub display: Option<String>, pub display: Option<String>,
#[serde(default)] #[serde(default)]
pub profile_path: Option<String>, pub profile_path: Option<String>,
#[serde(default)]
pub computer_generation: Option<i32>,
#[serde(default)]
pub control_epoch: Option<i64>,
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@ -29,11 +33,15 @@ pub struct ComputerRef {
pub fresh: bool, pub fresh: bool,
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct ProvisionRequest { pub struct ProvisionRequest {
pub home_key: String, pub home_key: String,
pub home_path: String, pub home_path: String,
pub provider_ref: Option<String>, pub provider_ref: Option<String>,
/// When true the container stays up for exec/files without XFCE/noVNC.
/// GUI later starts via `ensure_screen` / `lazyboy-screen ensure`.
#[serde(default)]
pub runner_only: bool,
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
@ -45,13 +53,30 @@ pub struct CommandRequest {
/// Used so fill-login never puts a password on the argv of `ps`. /// Used so fill-login never puts a password on the argv of `ps`.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub stdin: Option<String>, pub stdin: Option<String>,
/// When true the Computer keeps the process as a job and returns job_id.
#[serde(default)]
pub background: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub operation_id: Option<String>,
/// Existing Computer-side job for `job_op` status/cancel, or the id to assign on start.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub job_id: Option<String>,
/// `"status"` or `"cancel"` for an existing Computer-side job.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub job_op: Option<String>,
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct CommandResult { pub struct CommandResult {
pub stdout: String, pub stdout: String,
pub stderr: String, pub stderr: String,
pub code: i32, pub code: i32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub job_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub signal: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub status: Option<String>,
} }
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]

View File

@ -0,0 +1,129 @@
const SECRET_KEYS: &[&str] = &[
"password",
"passwd",
"secret",
"token",
"api_key",
"apikey",
"authorization",
"access_token",
"refresh_token",
"private_key",
];
/// Redact secret-shaped values before they reach traces, checkpoints, or UI.
pub fn redact_text(text: &str, canaries: &[&str]) -> String {
let mut out = text.to_string();
for canary in canaries {
if !canary.is_empty() {
out = out.replace(canary, "[redacted]");
}
}
out
}
/// Pattern-based redaction for free text that is persisted (ledger results,
/// activity snippets). Catches the common shapes — `Authorization: Bearer …`,
/// `password=…`/`token: …` assignments, and well-known API key prefixes —
/// without needing to know the secret values in advance. Not a guarantee.
pub fn redact_secret_patterns(text: &str) -> String {
static PATTERNS: std::sync::OnceLock<Vec<regex::Regex>> = std::sync::OnceLock::new();
let patterns = PATTERNS.get_or_init(|| {
[
r"(?i)\bbearer\s+[A-Za-z0-9\-._~+/]+=*",
r"(?i)\b(?:password|passwd|pwd|secret|token|api[_-]?key|apikey|access[_-]?token|refresh[_-]?token|client[_-]?secret|private[_-]?key|authorization)\b\s*[:=]\s*\S+",
r#"(?i)"(?:password|passwd|secret|token|api_key|apikey|access_token|refresh_token|client_secret|private_key|authorization)"\s*:\s*"[^"]*""#,
r"\b(?:sk|rk|pk)-[A-Za-z0-9\-_]{16,}\b",
r"\bgh[pousr]_[A-Za-z0-9]{20,}\b",
r"\bAKIA[0-9A-Z]{16}\b",
r"\bxox[abprs]-[A-Za-z0-9\-]{10,}\b",
r"\bAIza[0-9A-Za-z\-_]{30,}\b",
r"-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----",
]
.iter()
.map(|pattern| regex::Regex::new(pattern).expect("static regex"))
.collect()
});
let mut out = text.to_string();
for pattern in patterns {
out = pattern.replace_all(&out, "[redacted]").into_owned();
}
out
}
pub fn looks_like_secret_key(key: &str) -> bool {
let lower = key.to_ascii_lowercase();
SECRET_KEYS
.iter()
.any(|secret| lower == *secret || lower.ends_with(&format!("_{secret}")))
}
pub fn redact_json(value: &mut serde_json::Value) {
match value {
serde_json::Value::Object(map) => {
let keys: Vec<String> = map.keys().cloned().collect();
for key in keys {
if looks_like_secret_key(&key) {
map.insert(key, serde_json::Value::String("[redacted]".into()));
} else if let Some(child) = map.get_mut(&key) {
redact_json(child);
}
}
}
serde_json::Value::Array(items) => {
for item in items {
redact_json(item);
}
}
_ => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn canary_is_stripped_from_success_and_error_text() {
let secret = "super-secret-token-value";
assert!(!redact_text(&format!("ok {secret}"), &[secret]).contains(secret));
assert!(!redact_text(&format!("error: {secret}"), &[secret]).contains(secret));
assert!(redact_text(&format!("error: {secret}"), &[secret]).contains("[redacted]"));
}
#[test]
fn secret_shaped_text_is_redacted_without_knowing_the_value() {
let text = concat!(
"curl -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.abc.def' ",
"https://x/ password=hunter2 token: abc123 ",
"OPENAI=sk-abcdefghijklmnopqrstuvwxyz0123456789 ",
"ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123 AKIAABCDEFGHIJKLMNOP ",
"{\"access_token\":\"zzz\"} plain words stay"
);
let out = redact_secret_patterns(text);
for secret in [
"eyJhbGciOiJIUzI1NiJ9",
"hunter2",
"abc123",
"sk-abcdefghijklmnopqrstuvwxyz0123456789",
"ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123",
"AKIAABCDEFGHIJKLMNOP",
"zzz",
] {
assert!(!out.contains(secret), "{secret} leaked: {out}");
}
assert!(out.contains("plain words stay"));
assert!(out.contains("https://x/"));
assert_eq!(redact_secret_patterns("exit 0\nls -la"), "exit 0\nls -la");
}
#[test]
fn json_secret_keys_are_redacted() {
let mut value = json!({"password":"hunter2","nested":{"access_token":"abc"},"ok":true});
redact_json(&mut value);
assert_eq!(value["password"], "[redacted]");
assert_eq!(value["nested"]["access_token"], "[redacted]");
assert_eq!(value["ok"], true);
}
}

View File

@ -0,0 +1,389 @@
use sha2::{Digest, Sha256};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum InstallState {
PendingApproval,
Installing,
Installed,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum InstanceState {
Starting,
Connected,
AuthRequired,
Authenticated,
Ready,
Degraded,
Disabled,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ToolManifest {
pub id: String,
pub version: String,
pub sha256: String,
pub entrypoint: Vec<String>,
pub share_immutable_package: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ManifestError {
MissingDigest,
PathEscape,
EmptyEntrypoint,
}
pub fn validate_manifest(manifest: &ToolManifest) -> Result<(), ManifestError> {
// A digest is what gets verified before every run, so a placeholder such as
// "compute-at-install" must be rejected here, not trusted later.
let digest = manifest.sha256.trim();
if digest.len() != 64 || !digest.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(ManifestError::MissingDigest);
}
if manifest.entrypoint.is_empty() {
return Err(ManifestError::EmptyEntrypoint);
}
for part in &manifest.entrypoint {
if part.contains("..") {
return Err(ManifestError::PathEscape);
}
}
Ok(())
}
pub fn artifact_digest(bytes: &[u8]) -> String {
hex::encode(Sha256::digest(bytes))
}
pub fn install_dir(root: &Path, id: &str, version: &str) -> PathBuf {
root.join("tools").join(id).join(version)
}
/// Reject archive members that escape the destination (zip-slip).
pub fn safe_member_path(destination: &Path, member: &str) -> Result<PathBuf, ManifestError> {
let cleaned = member.replace('\\', "/");
if cleaned.split('/').any(|part| part == ".." || part == ".") {
return Err(ManifestError::PathEscape);
}
let path = destination.join(cleaned);
if !path.starts_with(destination) {
return Err(ManifestError::PathEscape);
}
Ok(path)
}
pub fn installed_is_not_ready(install: InstallState, instance: InstanceState) -> bool {
!matches!(
(install, instance),
(InstallState::Installed, InstanceState::Ready)
)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VersionPlan {
pub job_pin: String,
pub new_runs: String,
}
/// Active jobs keep the old bytes; new runs may use a verified new version.
/// Never requires restarting the whole Computer.
pub fn plan_version_switch(current: &str, incoming: &str, active_jobs: usize) -> VersionPlan {
if current == incoming {
return VersionPlan {
job_pin: current.into(),
new_runs: current.into(),
};
}
if active_jobs > 0 {
VersionPlan {
job_pin: current.into(),
new_runs: incoming.into(),
}
} else {
VersionPlan {
job_pin: incoming.into(),
new_runs: incoming.into(),
}
}
}
pub fn previous_version<'a>(installed: &'a [&str], current: &str) -> Option<&'a str> {
let pos = installed.iter().position(|version| *version == current)?;
if pos == 0 {
None
} else {
Some(installed[pos - 1])
}
}
pub fn package_gc_allowed(remaining_ready_bindings: usize, active_job_pins: usize) -> bool {
remaining_ready_bindings == 0 && active_job_pins == 0
}
/// Revoke drops Computer-local MCP from the next run schema immediately.
/// At most one `ready` binding per (bot, package_id). Version changes switch
/// that row instead of inserting a second ready binding (UNIQUE is
/// package_row_id+bot_id, so a second insert would survive and break rollback).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReadyBinding {
pub id: String,
pub package_row_id: String,
pub version: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ReadyBindingChange {
Insert {
id: String,
package_row_id: String,
version: String,
},
Switch {
id: String,
package_row_id: String,
version: String,
},
Keep {
id: String,
},
}
pub fn upsert_one_ready_binding(
existing: Option<&ReadyBinding>,
incoming_row_id: &str,
incoming_version: &str,
new_id: &str,
) -> ReadyBindingChange {
match existing {
None => ReadyBindingChange::Insert {
id: new_id.into(),
package_row_id: incoming_row_id.into(),
version: incoming_version.into(),
},
Some(current) if current.package_row_id == incoming_row_id => ReadyBindingChange::Keep {
id: current.id.clone(),
},
Some(current) => ReadyBindingChange::Switch {
id: current.id.clone(),
package_row_id: incoming_row_id.into(),
version: incoming_version.into(),
},
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RollbackChange {
Switch {
id: String,
package_row_id: String,
version: String,
},
NoPrevious,
/// A leftover ready row already occupies the previous package_row_id.
Conflict,
}
pub fn rollback_one_ready_binding(
current: &ReadyBinding,
leftover_on_previous: bool,
previous_row_id: Option<&str>,
previous_version: Option<&str>,
) -> RollbackChange {
let (Some(row_id), Some(version)) = (previous_row_id, previous_version) else {
return RollbackChange::NoPrevious;
};
if leftover_on_previous {
return RollbackChange::Conflict;
}
RollbackChange::Switch {
id: current.id.clone(),
package_row_id: row_id.into(),
version: version.into(),
}
}
pub fn pin_running_jobs(
current_version: &str,
incoming_version: &str,
active_jobs: usize,
) -> Option<String> {
let plan = plan_version_switch(current_version, incoming_version, active_jobs);
if plan.job_pin != plan.new_runs {
Some(plan.job_pin)
} else {
None
}
}
pub fn package_version_for_call<'a>(
ready_version: &'a str,
job_pin: Option<&'a str>,
existing_job: bool,
) -> &'a str {
if existing_job {
job_pin.unwrap_or(ready_version)
} else {
ready_version
}
}
pub fn filter_run_tool_names<'a>(
names: impl IntoIterator<Item = &'a str>,
computer_mcp_bound: bool,
granted_mcp: &[String],
) -> Vec<String> {
names
.into_iter()
.filter(|name| {
if *name == "computer_mcp" {
computer_mcp_bound
} else if name.starts_with("mcp_") {
granted_mcp.iter().any(|granted| granted == name)
} else {
true
}
})
.map(str::to_string)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> ToolManifest {
ToolManifest {
id: "lazyboy.example.echo".into(),
version: "0.0.1".into(),
sha256: artifact_digest(b"echo"),
entrypoint: vec!["./echo-adapter".into()],
share_immutable_package: true,
}
}
#[test]
fn empty_digest_is_rejected() {
let mut manifest = sample();
manifest.sha256.clear();
assert_eq!(
validate_manifest(&manifest),
Err(ManifestError::MissingDigest)
);
manifest.sha256 = "compute-at-install".into();
assert_eq!(
validate_manifest(&manifest),
Err(ManifestError::MissingDigest)
);
assert_eq!(validate_manifest(&sample()), Ok(()));
}
#[test]
fn zip_slip_is_rejected() {
let dest = Path::new("/tmp/pkg");
assert_eq!(
safe_member_path(dest, "../etc/passwd"),
Err(ManifestError::PathEscape)
);
assert!(safe_member_path(dest, "bin/echo").is_ok());
}
#[test]
fn installed_is_not_the_same_as_authorized() {
assert!(installed_is_not_ready(
InstallState::Installed,
InstanceState::AuthRequired
));
assert!(!installed_is_not_ready(
InstallState::Installed,
InstanceState::Ready
));
}
#[test]
fn update_pins_old_version_while_jobs_run() {
let plan = plan_version_switch("0.0.1", "0.0.2", 1);
assert_eq!(plan.job_pin, "0.0.1");
assert_eq!(plan.new_runs, "0.0.2");
let idle = plan_version_switch("0.0.1", "0.0.2", 0);
assert_eq!(idle.job_pin, "0.0.2");
assert_eq!(idle.new_runs, "0.0.2");
}
#[test]
fn rollback_selects_the_previous_side_by_side_version() {
let installed = ["0.0.1", "0.0.2"];
assert_eq!(previous_version(&installed, "0.0.2"), Some("0.0.1"));
assert_eq!(previous_version(&installed, "0.0.1"), None);
assert!(package_gc_allowed(0, 0));
assert!(!package_gc_allowed(1, 0));
}
#[test]
fn update_switches_the_single_ready_binding_then_rollback() {
let first = upsert_one_ready_binding(None, "row-1", "0.0.1", "bind-a");
assert_eq!(
first,
ReadyBindingChange::Insert {
id: "bind-a".into(),
package_row_id: "row-1".into(),
version: "0.0.1".into(),
}
);
let ready = ReadyBinding {
id: "bind-a".into(),
package_row_id: "row-1".into(),
version: "0.0.1".into(),
};
let second = upsert_one_ready_binding(Some(&ready), "row-2", "0.0.2", "bind-b");
assert_eq!(
second,
ReadyBindingChange::Switch {
id: "bind-a".into(),
package_row_id: "row-2".into(),
version: "0.0.2".into(),
}
);
let on_new = ReadyBinding {
id: "bind-a".into(),
package_row_id: "row-2".into(),
version: "0.0.2".into(),
};
assert_eq!(
rollback_one_ready_binding(&on_new, false, Some("row-1"), Some("0.0.1")),
RollbackChange::Switch {
id: "bind-a".into(),
package_row_id: "row-1".into(),
version: "0.0.1".into(),
}
);
assert_eq!(
rollback_one_ready_binding(&on_new, true, Some("row-1"), Some("0.0.1")),
RollbackChange::Conflict
);
assert_eq!(pin_running_jobs("0.0.1", "0.0.2", 2), Some("0.0.1".into()));
assert_eq!(pin_running_jobs("0.0.1", "0.0.2", 0), None);
assert_eq!(
package_version_for_call("0.0.2", Some("0.0.1"), true),
"0.0.1"
);
assert_eq!(
package_version_for_call("0.0.2", Some("0.0.1"), false),
"0.0.2"
);
}
#[test]
fn revoke_drops_computer_mcp_from_the_next_schema() {
let before = filter_run_tool_names(
["exec", "computer_mcp", "mcp_x_echo"],
true,
&["mcp_x_echo".into()],
);
assert!(before.contains(&"computer_mcp".into()));
assert!(before.contains(&"mcp_x_echo".into()));
let after = filter_run_tool_names(["exec", "computer_mcp", "mcp_x_echo"], false, &[]);
assert_eq!(after, vec!["exec".to_string()]);
}
}

View File

@ -0,0 +1,62 @@
use lazyboy_contracts::CompletionLayer;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Verification {
pub layer: CompletionLayer,
pub ok: bool,
pub evidence: String,
}
pub fn transport_ok(http_ok: bool) -> Verification {
Verification {
layer: CompletionLayer::Transport,
ok: http_ok,
evidence: if http_ok {
"response received".into()
} else {
"no valid response".into()
},
}
}
pub fn operation_effect(confirmed: bool, evidence: impl Into<String>) -> Verification {
Verification {
layer: CompletionLayer::OperationEffect,
ok: confirmed,
evidence: evidence.into(),
}
}
pub fn task_success(all_required: bool, evidence: impl Into<String>) -> Verification {
Verification {
layer: CompletionLayer::TaskSuccess,
ok: all_required,
evidence: evidence.into(),
}
}
/// Wait / poll heartbeats keep the worker alive but do not count as task progress.
pub fn wait_counts_as_progress() -> bool {
false
}
pub fn clock_or_cursor_is_progress() -> bool {
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn exit_zero_is_not_task_success() {
let transport = transport_ok(true);
let effect = operation_effect(true, "exit 0");
let task = task_success(false, "output hash missing");
assert!(transport.ok);
assert!(effect.ok);
assert!(!task.ok);
assert!(!wait_counts_as_progress());
assert!(!clock_or_cursor_is_progress());
}
}

View File

@ -0,0 +1,42 @@
use std::time::{Duration, Instant};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WaitOutcome {
Satisfied,
Timeout,
DisabledUnknown,
}
/// Wait for a predicate with a deadline. Unknown disabled reasons do not spin
/// forever — they surface as DisabledUnknown so the caller can re-observe.
pub fn wait_until<F>(deadline: Duration, mut ready: F) -> WaitOutcome
where
F: FnMut() -> Result<bool, &'static str>,
{
let start = Instant::now();
loop {
match ready() {
Ok(true) => return WaitOutcome::Satisfied,
Ok(false) if start.elapsed() >= deadline => return WaitOutcome::Timeout,
Ok(false) => std::thread::sleep(Duration::from_millis(5)),
Err(_) => return WaitOutcome::DisabledUnknown,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn deadline_stops_an_unready_condition() {
let outcome = wait_until(Duration::from_millis(20), || Ok(false));
assert_eq!(outcome, WaitOutcome::Timeout);
}
#[test]
fn unknown_disabled_does_not_spin() {
let outcome = wait_until(Duration::from_secs(5), || Err("unknown"));
assert_eq!(outcome, WaitOutcome::DisabledUnknown);
}
}

View File

@ -68,6 +68,10 @@ pub fn parse_ui_elements(raw: &str) -> Vec<lazyboy_contracts::UiElement> {
.and_then(serde_json::Value::as_str) .and_then(serde_json::Value::as_str)
.filter(|value| !value.is_empty()) .filter(|value| !value.is_empty())
.map(str::to_string), .map(str::to_string),
disabled: item
.get("disabled")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false),
}) })
}) })
.collect() .collect()

View File

@ -56,9 +56,15 @@ pub const STALE_HALT: u32 = 150;
/// Coaching has to stay rare enough that the model actually reads it. /// Coaching has to stay rare enough that the model actually reads it.
pub const MAX_REFLECTIONS: u32 = 8; pub const MAX_REFLECTIONS: u32 = 8;
/// A run that repeats itself is not a run that waits or polls, so these tools /// Running a command again can legitimately produce something new (a build
/// reset the no-progress clock even when their arguments repeat. /// step, a poll that returns different output), so these tools reset the
const PROGRESS_WHEN_REPEATED: [&str; 2] = ["shell", "wait"]; /// no-progress clock even when their arguments repeat.
const PROGRESS_WHEN_REPEATED: [&str; 2] = ["shell", "exec"];
/// Waiting proves the worker is alive, not that the task moved. It keeps a
/// polling run from being halted as a loop, but the stale coaching still fires
/// so the model says what it is waiting for.
const HEARTBEAT_TOOLS: [&str; 1] = ["wait"];
fn parse_u64(value: Option<String>) -> Option<u64> { fn parse_u64(value: Option<String>) -> Option<u64> {
value?.trim().parse::<u64>().ok().filter(|value| *value > 0) value?.trim().parse::<u64>().ok().filter(|value| *value > 0)
@ -218,6 +224,7 @@ pub struct LoopGuard {
/// are handled by the nudge limit, not by this guard. /// are handled by the nudge limit, not by this guard.
acted: bool, acted: bool,
last_progress_turn: u32, last_progress_turn: u32,
last_heartbeat_turn: u32,
last_stale_reflect_turn: u32, last_stale_reflect_turn: u32,
checkpoint_turn: u32, checkpoint_turn: u32,
soft_wall_spoken: bool, soft_wall_spoken: bool,
@ -244,6 +251,7 @@ impl LoopGuard {
watched_since: watched_from, watched_since: watched_from,
acted: false, acted: false,
last_progress_turn: watched_from, last_progress_turn: watched_from,
last_heartbeat_turn: watched_from,
last_stale_reflect_turn: watched_from, last_stale_reflect_turn: watched_from,
checkpoint_turn: 0, checkpoint_turn: 0,
soft_wall_spoken: false, soft_wall_spoken: false,
@ -347,7 +355,9 @@ impl LoopGuard {
self.failures.remove(&key); self.failures.remove(&key);
self.warned_failure = false; self.warned_failure = false;
self.acted = true; self.acted = true;
if seen == 1 || PROGRESS_WHEN_REPEATED.contains(&action.name) { if HEARTBEAT_TOOLS.contains(&action.name) {
self.last_heartbeat_turn = action.turn;
} else if seen == 1 || PROGRESS_WHEN_REPEATED.contains(&action.name) {
self.last_progress_turn = action.turn; self.last_progress_turn = action.turn;
} }
@ -389,7 +399,10 @@ impl LoopGuard {
if since < STALE_REFLECT { if since < STALE_REFLECT {
return None; return None;
} }
if since >= STALE_HALT { // A run that keeps waiting is alive, not spinning: coach it, never halt it.
let since_alive =
turns.saturating_sub(self.last_progress_turn.max(self.last_heartbeat_turn));
if since_alive >= STALE_HALT {
return Some(Verdict::Halt { return Some(Verdict::Halt {
reason: StopReason::LoopDetected, reason: StopReason::LoopDetected,
note: format!( note: format!(
@ -624,6 +637,7 @@ mod tests {
fn polling_a_build_or_a_queue_is_not_treated_as_spinning() { fn polling_a_build_or_a_queue_is_not_treated_as_spinning() {
let mut guard = LoopGuard::new(RunPolicy::default()); let mut guard = LoopGuard::new(RunPolicy::default());
let args = json!({"ms": 30000}); let args = json!({"ms": 30000});
let mut stale_coaching = 0;
for turn in 1..=160 { for turn in 1..=160 {
assert_eq!( assert_eq!(
guard.on_action(&act("wait", &args, turn, true, false)), guard.on_action(&act("wait", &args, turn, true, false)),
@ -632,12 +646,23 @@ mod tests {
); );
// A checkpoint question is fine; what a polling run must never get // A checkpoint question is fine; what a polling run must never get
// is a halt. // is a halt.
let verdict = guard.on_turn(turn, Duration::from_secs(30));
assert!( assert!(
!guard.on_turn(turn, Duration::from_secs(30)).is_halt(), !verdict.is_halt(),
"a polling run was halted at turn {turn}" "a polling run was halted at turn {turn}"
); );
if matches!(&verdict, Verdict::Reflect(text) if text.contains("nothing new has succeeded"))
{
stale_coaching += 1;
} }
} }
// Waiting is a heartbeat, not progress: the run is kept alive but is
// still asked to say what it is waiting for.
assert!(
stale_coaching >= 1,
"waiting must not count as progress; the stale coaching never fired"
);
}
#[test] #[test]
fn re_observing_the_same_screen_eventually_stops_asking() { fn re_observing_the_same_screen_eventually_stops_asking() {

View File

@ -14,5 +14,8 @@ reqwest.workspace = true
serde_json.workspace = true serde_json.workspace = true
base64.workspace = true base64.workspace = true
[dev-dependencies]
tokio.workspace = true
[lints] [lints]
workspace = true workspace = true

View File

@ -101,6 +101,7 @@ impl SandboxProvider for DockerSandbox {
"homeKey": request.home_key, "homeKey": request.home_key,
"homePath": request.home_path, "homePath": request.home_path,
"spaceId": context.space_id, "spaceId": context.space_id,
"runnerOnly": request.runner_only,
})) }))
.send() .send()
.await .await
@ -397,23 +398,18 @@ impl SandboxProvider for DockerSandbox {
.send() .send()
.await .await
.map_err(|error| SandboxError::message(error.to_string()))?; .map_err(|error| SandboxError::message(error.to_string()))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(SandboxError::message(format!(
"list files failed: {status} {body}"
)));
}
let body: Value = response let body: Value = response
.json() .json()
.await .await
.map_err(|error| SandboxError::message(error.to_string()))?; .map_err(|error| SandboxError::message(error.to_string()))?;
let Some(items) = body.as_array() else { file_entries_from_json(&body)
return Ok(Vec::new());
};
Ok(items
.iter()
.filter_map(|item| {
Some(FileEntry {
path: item.get("path")?.as_str()?.to_string(),
kind: item.get("kind")?.as_str()?.to_string(),
size: item.get("size")?.as_u64().unwrap_or(0),
})
})
.collect())
} }
async fn read_file( async fn read_file(
@ -430,16 +426,18 @@ impl SandboxProvider for DockerSandbox {
.send() .send()
.await .await
.map_err(|error| SandboxError::message(error.to_string()))?; .map_err(|error| SandboxError::message(error.to_string()))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(SandboxError::message(format!(
"read failed: {status} {body}"
)));
}
let body: Value = response let body: Value = response
.json() .json()
.await .await
.map_err(|error| SandboxError::message(error.to_string()))?; .map_err(|error| SandboxError::message(error.to_string()))?;
Ok(body file_bytes_from_json(&body)
.get("content")
.and_then(Value::as_str)
.unwrap_or_default()
.as_bytes()
.to_vec())
} }
async fn write_file( async fn write_file(
@ -455,7 +453,7 @@ impl SandboxProvider for DockerSandbox {
.headers(self.headers(context)) .headers(self.headers(context))
.json(&serde_json::json!({ .json(&serde_json::json!({
"path": path, "path": path,
"content": String::from_utf8_lossy(content), "contentBase64": base64::engine::general_purpose::STANDARD.encode(content),
})) }))
.send() .send()
.await .await
@ -576,9 +574,42 @@ fn decode_observation(body: &Value) -> Result<ComputerObservation, SandboxError>
Ok(observation) Ok(observation)
} }
fn file_entries_from_json(body: &Value) -> Result<Vec<FileEntry>, SandboxError> {
let Some(items) = body.as_array() else {
return Err(SandboxError::message(
"list files failed: response was not a JSON array",
));
};
Ok(items
.iter()
.filter_map(|item| {
Some(FileEntry {
path: item.get("path")?.as_str()?.to_string(),
kind: item.get("kind")?.as_str()?.to_string(),
size: item.get("size")?.as_u64().unwrap_or(0),
})
})
.collect())
}
fn file_bytes_from_json(body: &Value) -> Result<Vec<u8>, SandboxError> {
if let Some(encoded) = body.get("contentBase64").and_then(Value::as_str) {
return base64::engine::general_purpose::STANDARD
.decode(encoded)
.map_err(|error| {
SandboxError::message(format!("read failed: invalid base64: {error}"))
});
}
if let Some(text) = body.get("content").and_then(Value::as_str) {
return Ok(text.as_bytes().to_vec());
}
Err(SandboxError::message("read failed: missing file content"))
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::decode_observation; use super::decode_observation;
use base64::Engine;
use lazyboy_contracts::{DEFAULT_SCREEN_HEIGHT, DEFAULT_SCREEN_WIDTH}; use lazyboy_contracts::{DEFAULT_SCREEN_HEIGHT, DEFAULT_SCREEN_WIDTH};
use serde_json::json; use serde_json::json;
@ -601,4 +632,28 @@ mod tests {
assert_eq!(observation.width, DEFAULT_SCREEN_WIDTH); assert_eq!(observation.width, DEFAULT_SCREEN_WIDTH);
assert_eq!(observation.height, DEFAULT_SCREEN_HEIGHT); assert_eq!(observation.height, DEFAULT_SCREEN_HEIGHT);
} }
#[test]
fn file_bytes_prefer_base64_and_refuse_empty_success() {
use super::file_bytes_from_json;
let raw = [0xff, 0xfe, 0x00];
let encoded = base64::engine::general_purpose::STANDARD.encode(raw);
let body = json!({ "contentBase64": encoded, "content": "not-the-bytes" });
assert_eq!(file_bytes_from_json(&body).unwrap(), raw);
assert_eq!(
file_bytes_from_json(&json!({ "content": "hello" })).unwrap(),
b"hello"
);
assert!(file_bytes_from_json(&json!({})).is_err());
}
#[test]
fn file_list_refuses_a_non_array() {
use super::file_entries_from_json;
assert!(file_entries_from_json(&json!({"error":"nope"})).is_err());
let entries =
file_entries_from_json(&json!([{"path":"a.txt","kind":"file","size":4}])).unwrap();
assert_eq!(entries[0].path, "a.txt");
assert_eq!(entries[0].size, 4);
}
} }

View File

@ -5,8 +5,10 @@ use async_trait::async_trait;
use lazyboy_contracts::{ComputerObservation, SandboxKind}; use lazyboy_contracts::{ComputerObservation, SandboxKind};
use lazyboy_control::{ use lazyboy_control::{
ActionRequest, ActionResult, AdapterContext, BrowserPage, BrowserRequest, CommandRequest, ActionRequest, ActionResult, AdapterContext, BrowserPage, BrowserRequest, CommandRequest,
CommandResult, ComputerRef, FileEntry, ProvisionRequest, RecordingRequest, RecordingResult, CommandResult, ComputerJobTable, ComputerRef, EnsureScreenRequest, EnsureScreenResult,
RecordingSession, SandboxError, SandboxProvider, ScreenSession, observation_from_png, FileEntry, JobSnapshot, JobStatus, ProvisionRequest, RecordingRequest, RecordingResult,
RecordingSession, SandboxError, SandboxProvider, ScreenSession, job_snapshot_result,
observation_from_png, screen_layout,
}; };
const EMPTY_PNG: &[u8] = &[ const EMPTY_PNG: &[u8] = &[
@ -20,12 +22,23 @@ const EMPTY_PNG: &[u8] = &[
#[derive(Default)] #[derive(Default)]
pub struct FakeSandbox { pub struct FakeSandbox {
files: Mutex<HashMap<String, HashMap<String, Vec<u8>>>>, files: Mutex<HashMap<String, HashMap<String, Vec<u8>>>>,
ensure_screen_calls: Mutex<u32>,
jobs: ComputerJobTable,
host_spawns: Mutex<u32>,
} }
impl FakeSandbox { impl FakeSandbox {
pub fn new() -> Self { pub fn new() -> Self {
Self::default() Self::default()
} }
pub fn ensure_screen_calls(&self) -> u32 {
*self.ensure_screen_calls.lock().unwrap()
}
pub fn host_spawn_count(&self) -> u32 {
*self.host_spawns.lock().unwrap()
}
} }
#[async_trait] #[async_trait]
@ -49,17 +62,111 @@ impl SandboxProvider for FakeSandbox {
}) })
} }
async fn ensure_screen(
&self,
_computer: &ComputerRef,
request: EnsureScreenRequest,
_context: &AdapterContext,
) -> Result<EnsureScreenResult, SandboxError> {
*self.ensure_screen_calls.lock().unwrap() += 1;
let layout = screen_layout(request.slot)
.map_err(|error| SandboxError::message(error.to_string()))?;
Ok(EnsureScreenResult {
slot: layout.slot,
display: layout.display,
view_port: layout.view_port,
})
}
async fn execute( async fn execute(
&self, &self,
computer: &ComputerRef, computer: &ComputerRef,
request: CommandRequest, request: CommandRequest,
_context: &AdapterContext, _context: &AdapterContext,
) -> Result<CommandResult, SandboxError> { ) -> Result<CommandResult, SandboxError> {
let _ = computer;
if let Some(op) = request.job_op.as_deref() {
let id = request
.job_id
.as_deref()
.ok_or_else(|| SandboxError::message("job_id required"))?;
match op {
"status" => {
let job = self
.jobs
.get(id)
.ok_or_else(|| SandboxError::message("unknown job"))?;
return Ok(job_snapshot_result(&job));
}
"cancel" => {
let job = self
.jobs
.update(id, |job| {
if job.status == JobStatus::Running {
job.status = JobStatus::Cancelled;
job.exit_code = Some(143);
}
})
.ok_or_else(|| SandboxError::message("unknown job"))?;
return Ok(job_snapshot_result(&job));
}
other => {
return Err(SandboxError::message(format!("unknown job_op {other}")));
}
}
}
if request.background {
let id = request
.job_id
.clone()
.or(request.operation_id.clone())
.unwrap_or_else(|| "job".into());
let short = matches!(
request.argv.first().map(String::as_str),
Some("/bin/true") | Some("true")
);
let failed = matches!(
request.argv.first().map(String::as_str),
Some("/bin/false") | Some("false")
);
let snapshot = JobSnapshot {
id: id.clone(),
status: if short {
JobStatus::Succeeded
} else if failed {
JobStatus::Failed
} else {
JobStatus::Running
},
pid: None,
stdout: String::new(),
stderr: String::new(),
exit_code: if short {
Some(0)
} else if failed {
Some(1)
} else {
None
},
computer_generation: 1,
computer_id: Some(computer.id.clone()),
};
self.jobs.insert(snapshot.clone());
return Ok(CommandResult {
stdout: String::new(),
stderr: String::new(),
code: 0,
job_id: Some(id),
signal: None,
status: Some("running".into()),
});
}
if request.argv.first().map(String::as_str) == Some("mkdir") { if request.argv.first().map(String::as_str) == Some("mkdir") {
return Ok(CommandResult { return Ok(CommandResult {
stdout: String::new(), stdout: String::new(),
stderr: String::new(), stderr: String::new(),
code: 0, code: 0,
..CommandResult::default()
}); });
} }
if request.argv.first().map(String::as_str) == Some("touch") if request.argv.first().map(String::as_str) == Some("touch")
@ -72,10 +179,16 @@ impl SandboxProvider for FakeSandbox {
.or_default() .or_default()
.insert(path.clone(), Vec::new()); .insert(path.clone(), Vec::new());
} }
let code = match request.argv.first().map(String::as_str) {
Some("/bin/true") | Some("true") => 0,
Some("/bin/false") | Some("false") => 1,
_ => 0,
};
Ok(CommandResult { Ok(CommandResult {
stdout: request.argv.join(" "), stdout: request.argv.join(" "),
stderr: String::new(), stderr: String::new(),
code: 0, code,
..CommandResult::default()
}) })
} }
@ -247,3 +360,216 @@ impl SandboxProvider for FakeSandbox {
Ok(()) Ok(())
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use lazyboy_control::maybe_ensure_screen;
#[tokio::test]
async fn fake_sandbox_keeps_invalid_utf8_bytes() {
let sandbox = FakeSandbox::new();
let context = AdapterContext::default();
let computer = sandbox
.provision(
ProvisionRequest {
home_key: "home".into(),
home_path: "/tmp/home".into(),
..ProvisionRequest::default()
},
&context,
)
.await
.unwrap();
let raw = vec![0xff, 0xfe, 0x00, b'A'];
sandbox
.write_file(&computer, "bin.dat", &raw, &context)
.await
.unwrap();
let got = sandbox
.read_file(&computer, "bin.dat", &context)
.await
.unwrap();
assert_eq!(got, raw);
let listed = sandbox.list_files(&computer, "", &context).await.unwrap();
assert_eq!(listed[0].size, 4);
}
#[tokio::test]
async fn native_exec_and_files_do_not_call_ensure_screen() {
let sandbox = FakeSandbox::new();
let context = AdapterContext::default();
let computer = sandbox
.provision(
ProvisionRequest {
home_key: "home".into(),
home_path: "/tmp/home".into(),
provider_ref: None,
runner_only: true,
},
&context,
)
.await
.unwrap();
let attached = maybe_ensure_screen(
&sandbox,
&computer,
false,
EnsureScreenRequest {
slot: 0,
profile_path: String::new(),
bot_id: "bot".into(),
bot_name: String::new(),
bot_color: String::new(),
},
&context,
)
.await
.unwrap();
assert!(attached.is_none());
assert_eq!(sandbox.ensure_screen_calls(), 0);
sandbox
.write_file(&computer, "notes.txt", b"hello", &context)
.await
.unwrap();
assert_eq!(
sandbox
.read_file(&computer, "notes.txt", &context)
.await
.unwrap(),
b"hello"
);
let listed = sandbox
.list_files(&computer, "notes.txt", &context)
.await
.unwrap();
assert_eq!(listed[0].size, 5);
let ran = sandbox
.execute(
&computer,
CommandRequest {
argv: vec!["/bin/true".into()],
..CommandRequest::default()
},
&context,
)
.await
.unwrap();
assert_eq!(ran.code, 0);
assert_eq!(sandbox.ensure_screen_calls(), 0);
let gui = maybe_ensure_screen(
&sandbox,
&computer,
true,
EnsureScreenRequest {
slot: 0,
profile_path: String::new(),
bot_id: "bot".into(),
bot_name: String::new(),
bot_color: String::new(),
},
&context,
)
.await
.unwrap();
assert!(gui.is_some());
assert_eq!(sandbox.ensure_screen_calls(), 1);
}
#[tokio::test]
async fn background_exec_runs_on_the_computer_not_the_api_host() {
use lazyboy_control::current_process_has_child_cmd;
let sandbox = FakeSandbox::new();
let context = AdapterContext::default();
let computer = sandbox
.provision(
ProvisionRequest {
home_key: "home".into(),
home_path: "/tmp/home".into(),
..ProvisionRequest::default()
},
&context,
)
.await
.unwrap();
let short = sandbox
.execute(
&computer,
CommandRequest {
argv: vec!["/bin/true".into()],
background: true,
job_id: Some("job-true".into()),
..CommandRequest::default()
},
&context,
)
.await
.unwrap();
assert_eq!(short.job_id.as_deref(), Some("job-true"));
let status = sandbox
.execute(
&computer,
CommandRequest {
job_id: Some("job-true".into()),
job_op: Some("status".into()),
..CommandRequest::default()
},
&context,
)
.await
.unwrap();
assert_eq!(status.code, 0);
assert_eq!(status.job_id.as_deref(), Some("job-true"));
let failed = sandbox
.execute(
&computer,
CommandRequest {
argv: vec!["/bin/false".into()],
..CommandRequest::default()
},
&context,
)
.await
.unwrap();
assert_eq!(failed.code, 1);
let started = sandbox
.execute(
&computer,
CommandRequest {
argv: vec!["/bin/sleep".into(), "30".into()],
background: true,
job_id: Some("job-sleep".into()),
..CommandRequest::default()
},
&context,
)
.await
.unwrap();
assert_eq!(started.job_id.as_deref(), Some("job-sleep"));
assert_eq!(sandbox.host_spawn_count(), 0);
assert!(
!current_process_has_child_cmd("sleep 30"),
"background exec must not spawn a child of the API/test process"
);
let cancelled = sandbox
.execute(
&computer,
CommandRequest {
job_id: Some("job-sleep".into()),
job_op: Some("cancel".into()),
..CommandRequest::default()
},
&context,
)
.await
.unwrap();
assert_eq!(cancelled.job_id.as_deref(), Some("job-sleep"));
assert_eq!(sandbox.host_spawn_count(), 0);
}
}

View File

@ -13,9 +13,10 @@ use bollard::models::{EndpointSettings, HostConfig, HostConfigLogConfig, PortBin
use bollard::network::{ConnectNetworkOptions, CreateNetworkOptions}; use bollard::network::{ConnectNetworkOptions, CreateNetworkOptions};
use futures_util::StreamExt; use futures_util::StreamExt;
use lazyboy_control::{ use lazyboy_control::{
ActionRequest, BrowserRequest, CommandRequest, CommandResult, EnsureScreenRequest, ActionRequest, BrowserRequest, CommandRequest, CommandResult, ComputerJobTable,
EnsureScreenResult, HOME, RecordingRequest, ScreenTarget, TEAM_SCREEN_LIMIT, normalize_display, EnsureScreenRequest, EnsureScreenResult, HOME, JobSnapshot, JobStatus, RecordingRequest,
normalize_workspace_path, screen_layout, ScreenTarget, TEAM_SCREEN_LIMIT, computer_background_launch, job_snapshot_result,
normalize_display, normalize_workspace_path, sanitize_job_id, screen_layout,
}; };
use tokio::time::{Duration, sleep}; use tokio::time::{Duration, sleep};
@ -25,6 +26,7 @@ pub struct DockerHost {
docker: Docker, docker: Docker,
image: String, image: String,
control_token: String, control_token: String,
jobs: ComputerJobTable,
} }
pub struct Provisioned { pub struct Provisioned {
@ -58,6 +60,7 @@ impl DockerHost {
docker, docker,
image, image,
control_token, control_token,
jobs: ComputerJobTable::default(),
}) })
} }
@ -66,6 +69,7 @@ impl DockerHost {
home_key: &str, home_key: &str,
home_path: &str, home_path: &str,
space_id: &str, space_id: &str,
runner_only: bool,
) -> Result<Provisioned, String> { ) -> Result<Provisioned, String> {
let data_dir = std::env::var("DATA_DIR").unwrap_or_else(|_| "./data".into()); let data_dir = std::env::var("DATA_DIR").unwrap_or_else(|_| "./data".into());
if home_key.is_empty() if home_key.is_empty()
@ -168,6 +172,12 @@ impl DockerHost {
privileged: Some(false), privileged: Some(false),
shm_size: Some(512 * 1024 * 1024), shm_size: Some(512 * 1024 * 1024),
network_mode: Some(network), network_mode: Some(network),
// PID 1 is `sleep infinity` in runner-only mode and start.sh
// otherwise; neither reaps orphans. Background jobs are launched
// with setsid so they reparent to PID 1 when their wrapper exits
// and would stay zombies (kill -0 keeps succeeding, the job looks
// running forever). docker-init reaps them.
init: Some(true),
..Default::default() ..Default::default()
}; };
@ -181,6 +191,10 @@ impl DockerHost {
env: Some(vec![ env: Some(vec![
"DISPLAY=:1".into(), "DISPLAY=:1".into(),
format!("HOME={HOME}"), format!("HOME={HOME}"),
format!(
"LAZYBOY_RUNNER_ONLY={}",
if runner_only { "1" } else { "0" }
),
format!( format!(
"LAZYBOY_CONTROL_TOKEN={}", "LAZYBOY_CONTROL_TOKEN={}",
scoped_control_token(&self.control_token, home_key) scoped_control_token(&self.control_token, home_key)
@ -265,6 +279,11 @@ impl DockerHost {
request: CommandRequest, request: CommandRequest,
target: &ScreenTarget, target: &ScreenTarget,
) -> Result<CommandResult, String> { ) -> Result<CommandResult, String> {
if let Some(op) = request.job_op.as_deref() {
return self
.job_follow_up(id, request.job_id.as_deref(), op, target)
.await;
}
let cwd = match request.cwd { let cwd = match request.cwd {
Some(cwd) if PathBuf::from(&cwd).is_absolute() => cwd, Some(cwd) if PathBuf::from(&cwd).is_absolute() => cwd,
Some(cwd) => { Some(cwd) => {
@ -277,12 +296,17 @@ impl DockerHost {
} }
None => HOME.to_string(), None => HOME.to_string(),
}; };
let timeout_ms = request.timeout_ms.unwrap_or(30_000).clamp(100, 120_000);
let argv = if request.argv.is_empty() { let argv = if request.argv.is_empty() {
vec!["/bin/echo".into(), "ready".into()] vec!["/bin/echo".into(), "ready".into()]
} else { } else {
request.argv request.argv
}; };
if request.background {
return self
.start_background_job(id, argv, &cwd, request.job_id.as_deref(), target)
.await;
}
let timeout_ms = request.timeout_ms.unwrap_or(30_000).clamp(100, 120_000);
let mut bounded = vec![ let mut bounded = vec![
"timeout".into(), "timeout".into(),
"--signal=TERM".into(), "--signal=TERM".into(),
@ -294,6 +318,184 @@ impl DockerHost {
.await .await
} }
async fn start_background_job(
&self,
container: &str,
argv: Vec<String>,
cwd: &str,
job_id: Option<&str>,
target: &ScreenTarget,
) -> Result<CommandResult, String> {
let job_id = sanitize_job_id(job_id.unwrap_or("job"));
let launch = computer_background_launch(&argv, &job_id);
let launched = self
.exec_raw_cmd(container, &launch, Some(cwd), target, None)
.await?;
if launched.code != 0 {
return Err(launched.stderr);
}
// Without a pid the job could never be reaped or cancelled and would be
// reported running forever; refuse rather than track a ghost.
let pid = launched
.stdout
.trim()
.lines()
.last()
.and_then(|line| line.trim().parse::<u32>().ok())
.ok_or_else(|| {
format!(
"background launch did not report a pid (stdout={:?}, stderr={:?})",
launched.stdout.trim(),
launched.stderr.trim()
)
})?;
self.jobs.insert(JobSnapshot {
id: job_id.clone(),
status: JobStatus::Running,
pid: Some(pid),
stdout: String::new(),
stderr: String::new(),
exit_code: None,
computer_generation: 1,
computer_id: Some(container.to_string()),
});
Ok(CommandResult {
stdout: String::new(),
stderr: String::new(),
code: 0,
job_id: Some(job_id),
signal: None,
status: Some("running".into()),
})
}
async fn job_follow_up(
&self,
container: &str,
job_id: Option<&str>,
op: &str,
target: &ScreenTarget,
) -> Result<CommandResult, String> {
let job_id = job_id.ok_or_else(|| "job_id required".to_string())?;
let Some(job) = self.jobs.get(job_id) else {
return Err("unknown job".into());
};
// Job ids are only meaningful on the Computer that started them; a pid
// from another container must never be signalled here.
if job.computer_id.as_deref() != Some(container) {
return Err("unknown job".into());
}
match op {
"status" => {
let mut job = job;
if let Some(pid) = job.pid
&& job.status == JobStatus::Running
{
// The exit file is written by the wrapper after the command
// returns, so it settles the question even when the wrapper
// is a zombie that `kill -0` still counts as alive.
let exit = self.read_job_file(container, job_id, "exit", target).await;
let exit = exit.trim().to_string();
let alive = if exit.is_empty() {
self.exec_raw_cmd(
container,
&[
"bash".into(),
"-c".into(),
lazyboy_control::computer_alive_script(pid),
],
None,
target,
None,
)
.await?
.code
== 0
} else {
false
};
if !alive {
let out = self.read_job_file(container, job_id, "out", target).await;
let err = self.read_job_file(container, job_id, "err", target).await;
let (status, code) = lazyboy_control::reap_background_job(
false,
if exit.is_empty() {
None
} else {
Some(exit.as_str())
},
);
job = self
.jobs
.update(job_id, |snap| {
snap.status = status;
snap.exit_code = Some(code);
snap.stdout = out;
snap.stderr = err;
})
.unwrap_or(job);
}
}
Ok(job_snapshot_result(&job))
}
"cancel" => {
// The launcher ran the wrapper under `setsid`, so its pid is also
// the process-group id: signalling `-pid` reaches the whole tree,
// not just the `sh -c` wrapper (T13). TERM first, a short grace,
// then KILL; only claim cancelled once nothing in the group answers.
let mut tree_gone = job.pid.is_none();
if let Some(pid) = job.pid {
let script = lazyboy_control::computer_cancel_script(pid);
let outcome = self
.exec_raw_cmd(
container,
&["bash".into(), "-c".into(), script],
None,
target,
None,
)
.await;
tree_gone = matches!(outcome, Ok(ref result) if result.code == 0);
}
let job = self
.jobs
.update(job_id, |snap| {
if snap.status == JobStatus::Running && tree_gone {
snap.status = JobStatus::Cancelled;
snap.exit_code = Some(143);
}
})
.ok_or_else(|| "unknown job".to_string())?;
Ok(job_snapshot_result(&job))
}
other => Err(format!("unknown job_op {other}")),
}
}
async fn read_job_file(
&self,
container: &str,
job_id: &str,
which: &str,
target: &ScreenTarget,
) -> String {
let id = sanitize_job_id(job_id);
let result = self
.exec_raw_cmd(
container,
&[
"bash".into(),
"-lc".into(),
format!("cat /tmp/lazyboy/jobs/{id}/{which} 2>/dev/null || true"),
],
None,
target,
None,
)
.await;
result.map(|r| r.stdout).unwrap_or_default()
}
pub async fn observe_payload( pub async fn observe_payload(
&self, &self,
id: &str, id: &str,
@ -786,6 +988,7 @@ PY"#,
stdout: String::from_utf8_lossy(&stdout).into_owned(), stdout: String::from_utf8_lossy(&stdout).into_owned(),
stderr: String::from_utf8_lossy(&stderr).into_owned(), stderr: String::from_utf8_lossy(&stderr).into_owned(),
code, code,
..CommandResult::default()
}) })
} }

View File

@ -6,10 +6,11 @@ use axum::extract::{Path, State};
use axum::http::{HeaderMap, StatusCode}; use axum::http::{HeaderMap, StatusCode};
use axum::routing::{delete, get, post}; use axum::routing::{delete, get, post};
use axum::{Json, Router}; use axum::{Json, Router};
use base64::Engine;
use docker::DockerHost; use docker::DockerHost;
use lazyboy_control::{ use lazyboy_control::{
ActionRequest, BrowserRequest, CommandRequest, EnsureScreenRequest, HOME, RecordingRequest, ActionRequest, BrowserRequest, CommandRequest, CommandResult, EnsureScreenRequest, HOME,
ScreenTarget, normalize_workspace_path, RecordingRequest, ScreenTarget, normalize_workspace_path,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tracing_subscriber::EnvFilter; use tracing_subscriber::EnvFilter;
@ -28,6 +29,8 @@ struct ProvisionBody {
home_path: String, home_path: String,
#[serde(rename = "spaceId")] #[serde(rename = "spaceId")]
space_id: String, space_id: String,
#[serde(rename = "runnerOnly", default)]
runner_only: bool,
} }
#[tokio::main] #[tokio::main]
@ -135,7 +138,12 @@ async fn provision(
require_token(&headers, &app.token)?; require_token(&headers, &app.token)?;
let created = app let created = app
.docker .docker
.provision(&body.home_key, &body.home_path, &body.space_id) .provision(
&body.home_key,
&body.home_path,
&body.space_id,
body.runner_only,
)
.await .await
.map_err(|error| { .map_err(|error| {
tracing::error!("provision: {error}"); tracing::error!("provision: {error}");
@ -153,7 +161,7 @@ async fn exec(
headers: HeaderMap, headers: HeaderMap,
Path(id): Path<String>, Path(id): Path<String>,
Json(body): Json<CommandRequest>, Json(body): Json<CommandRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> { ) -> Result<Json<CommandResult>, (StatusCode, String)> {
require_token(&headers, &app.token).map_err(|status| (status, String::new()))?; require_token(&headers, &app.token).map_err(|status| (status, String::new()))?;
let result = app let result = app
.docker .docker
@ -163,11 +171,7 @@ async fn exec(
tracing::error!("exec: {error}"); tracing::error!("exec: {error}");
(StatusCode::INTERNAL_SERVER_ERROR, error) (StatusCode::INTERNAL_SERVER_ERROR, error)
})?; })?;
Ok(Json(serde_json::json!({ Ok(Json(result))
"stdout": result.stdout,
"stderr": result.stderr,
"code": result.code,
})))
} }
async fn observe( async fn observe(
@ -373,6 +377,17 @@ async fn list_files(
struct FileBody { struct FileBody {
path: String, path: String,
content: Option<String>, content: Option<String>,
#[serde(rename = "contentBase64")]
content_base64: Option<String>,
}
fn file_body_bytes(body: &FileBody) -> Result<Vec<u8>, StatusCode> {
if let Some(encoded) = body.content_base64.as_deref() {
return base64::engine::general_purpose::STANDARD
.decode(encoded)
.map_err(|_| StatusCode::BAD_REQUEST);
}
Ok(body.content.clone().unwrap_or_default().into_bytes())
} }
async fn write_file( async fn write_file(
@ -383,8 +398,9 @@ async fn write_file(
) -> Result<StatusCode, StatusCode> { ) -> Result<StatusCode, StatusCode> {
require_token(&headers, &app.token)?; require_token(&headers, &app.token)?;
let relative = normalize_workspace_path(&body.path).map_err(|_| StatusCode::BAD_REQUEST)?; let relative = normalize_workspace_path(&body.path).map_err(|_| StatusCode::BAD_REQUEST)?;
let bytes = file_body_bytes(&body)?;
app.docker app.docker
.write_file(&id, &relative, body.content.unwrap_or_default().as_bytes()) .write_file(&id, &relative, &bytes)
.await .await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
@ -405,7 +421,8 @@ async fn read_file(
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(serde_json::json!({ Ok(Json(serde_json::json!({
"path": relative, "path": relative,
"content": String::from_utf8_lossy(&bytes), "encoding": if std::str::from_utf8(&bytes).is_ok() { "utf-8" } else { "binary" },
"contentBase64": base64::engine::general_purpose::STANDARD.encode(&bytes),
}))) })))
} }

View File

@ -0,0 +1,361 @@
# Agent Computer progress (V3)
基準 commit計劃`e6afa324530e19922909d4692c28fb005cc05a7a`
本機開始 HEAD`79952b088b394e36d6c3db3952165f1c3fd59961`
未覆蓋使用者未提交修改(`docs/plan/` 仍未追蹤)。
驗證2026-09-10 複查見文末「複查Bug 修正」):`cargo fmt --check`、`cargo clippy --workspace --all-targets -D warnings`、`cargo test --workspace`(含 sqlx全綠、`make test-agent-computer`、`apps/web npm run build` 全部 exit 0。
sqlx 測試用拋棄式 `pgvector/pgvector:pg16``127.0.0.1:5434``docker run --rm --name lazyboy-test-pg …`),不重建正在服務的 `lazyboy-postgres-1`
真實 OAuth主信箱TigerVNC GUI fixture`BLOCKED_EXTERNAL`。
sample MCP 在真實 Computer 容器 `lb-team-space-*``python3 echo_server.py` JSON-RPC 回 `from-computer`。外掛 UI 已 typecheck無瀏覽器工具未點擊。
---
## PR-00 / PR-01
狀態IMPLEMENTED見前次。截圖交付、semantic/vision、原生 fs/exec 短路徑。
---
## PR-02雙模式身份
狀態IMPLEMENTED資料契約idle沒有強制遷移
- `computers.generation`destroy/recreate `generation + 1`(舊 job 不可裝活)。
- Team 多 bot 同 `computer_id``computer_scope_key(Team, space, bot-a) == bot-b`。Dedicated 互斥 scope。
- **沒有**對 `bots.computer_id` 加 UNIQUE。
- `AdapterContext.computer_generation` / `control_epoch`
- Pause scopes`PauseScope::{Agent,Display,Computer}`;停 A 不 bump B 的 display epoch。
- idle reaper 把 `computer_jobs` running 算進活性。
回滾migration 022 + generation SELECT/UPDATE。
---
## PR-03ledger / 脫敏 / activity
狀態IMPLEMENTEDPostgres 為 mutation 來源;本環境未跑 sqlx 整合測試)
- `dispatch`mutating tool 先 `begin`journal insert 失敗則 `JOURNAL_UNAVAILABLE`,不開做。
- 同 `operationId` + payload hash replay 已存結果;不同 hash → `PAYLOAD_MISMATCH`
- `operationId` 不進入 payload hash。
- `finish``computer_operations` + `operation_outbox``worker_loop` `flush_outbox` 只補尚未出現在 `run_activity``operationId`
- 結果與 outbox payload 經過 `redact_text` / `redact_json`
---
## PR-04jobs / cancel / CAS
狀態IMPLEMENTEDRunner 在 API 行程內container recreate 以 generation 中斷)
- `JobSupervisor``process_group(0)`cancel TERM→KILL process group。
- `exec``action=run|status|cancel`、`background` 回 `jobId`
- `write_file.expectedHash``CONFLICT`,不覆蓋。
- 測試:`/bin/true` 真 exit`sleep 30` 可 cancel。
- API 重啟:`computer_jobs` running/accepted → `interrupted`(不假裝收回 Docker PID
- `LAZYBOY_NATIVE_JOB_CONCURRENCY`(預設 2超額 → `QUOTA_EXCEEDED`
- Artifact`write_file` 成功寫 `computer_artifacts``GET /api/bots/{id}/artifacts` 列表;下載從 Computer `read_file` 串回sha256 不符 → `CONFLICT`
O04 PTY `terminal.*`DEFERRED見文末。跨重啟回收仍在跑的容器 PID標 interrupted不是 resume。
---
## PR-05Tool Manager / MCP
狀態IMPLEMENTEDstdio 不在 API spawnsample 在 Computer無第三方商店
- Manifest空 digest / zip-slip / 空 entrypoint 拒絕。
- 安裝≠Ready`AuthRequired` ≠ 可用)。
- `scripts/sample-mcp/echo_server.py` + `manifest.yaml`(本機範例,不是虛構 npm
- REST`GET/POST /api/bots/{id}/tools`、`POST .../tools/call`、`POST .../revoke`。
- 安裝把 echo 寫進 ComputerDedicated `tools/...`Team `shared/tools/...``python3` 在該容器執行 JSON-RPC。
- Agent 工具 `computer_mcp` 走同一條路徑;未 bind → `NOT_BOUND`
- 外掛頁「Computer 套件」可安裝/呼叫/撤銷 echo市集 MCP 文案不再假裝 stdio 都在 Computer。
- MCP `call_for` 先複製 client 再 await`definitions_for(actor)` 不把別人的工具塞進這次 run。
未做任意第三方套件商店、OAuth broker。`/api/mcp-servers` stdio **不再** `spawn` 子行程,只存 metadata 並指向 Computer Tool Manager。HTTP/SSE 仍為遠端 client。真實 Microsoft/Google OAuth`BLOCKED_EXTERNAL`。
---
## PR-06路由 / verifier / wait
狀態IMPLEMENTED決策層
- `choose_route`:有授權 API 不走 browser`POLICY_DENIED` 不繞路。
- `CompletionLayer`transport ≠ effect ≠ task`wait_counts_as_progress() == false`。
- policy`wait` 不再刷新無進展時鐘;`exec` 可。
- `wait_until` 有 deadline未知 disabled 不空轉。
- system promptGmail/Outlook 有 connector 走 API。
---
## PR-07form / takeover scopes
狀態IMPLEMENTEDform 工具已註冊lease/pause 見 PR-02
- `run_form_macro` 失敗即停、逐步 evidence。
- Display writer lease 原本就有pause scope 見 PR-02。
- Agent 工具 `form_fill`:用最新 snapshot locatorelement id`run_form_macro` 先定位,第一個失敗就停;可選 `submit` click。
---
## PR-08A Gmail fixture
狀態IMPLEMENTED純函式 fixture不碰真信箱
- batchModify 同 delta 分組、1000 cap。
- undo 只撤本 plan 的 delta。
- 真實帳號:`BLOCKED_EXTERNAL`。
---
## PR-08B Outlook
狀態IMPLEMENTEDGraph 語意 fixture非官方 npm
- `$batch` 最多 20子項 429/401 分開。
- categories merge不覆蓋無關分類。
- `Mail.ReadWrite` 不自動允許寄信。
- 真實 Graph OAuth`BLOCKED_EXTERNAL`。
---
## PR-09 TigerVNC 候選
狀態IMPLEMENTEDopt-in預設仍 Xvfb+x11vnc
- `DisplayBackend`slot 0 = `:1` / 5900 / 6080T57
- `lazyboy-screen``LAZYBOY_DISPLAY_BACKEND=tigervnc_xvnc`
- Dockerfile 加入 `tigervnc-standalone-server`,失敗則 script fallback Xvfb。
- Cua/中文/a11y GUI 對照:`BLOCKED_EXTERNAL`**未改 default**。
---
## PR-10 設定 / 文件
狀態IMPLEMENTED
`.env.example``LAZYBOY_EXECUTION_PROFILE`、`LAZYBOY_DISPLAY_BACKEND`、`LAZYBOY_BROWSER_BACKEND`、concurrency、tool install flags。
`migrations/022_agent_computer.sql`
---
## O01O48
| ID | 狀態 |
|---|---|
| O01 | IMPLEMENTED |
| O02 | IMPLEMENTED短 exec長 job 見 O04 |
| O03 | IMPLEMENTED + CAS |
| O04 | PARTIALComputer 內 background exec + cancelPTY `terminal.*` DEFERRED |
| O05 | IMPLEMENTEDform macro + `form_fill` 工具) |
| O06 | IMPLEMENTEDstdio 不在 API spawnHTTP/SSE 遠端echo 在 Computer |
| O07 | DEFERRED未量測 docker exec 開銷) |
| O08 | IMPLEMENTED`boot_for(need_gui=false)` 不 ensure_screen`LAZYBOY_RUNNER_ONLY` 不 boot XFCE |
| O09 | IMPLEMENTEDrouter |
| O10 | IMPLEMENTEDrevoke 從下次 schema 拿掉 computer_mcpstdio 不再進 defs |
| O11 | 保留現況 |
| O12 | IMPLEMENTED |
| O13 | DEFERRED |
| O14 | IMPLEMENTEDPR-01 |
| O15 | 保留現況(既有 profile bind |
| O16 | PARTIALverifier 層 + wait 不算進度) |
| O17 | 實驗opt-in |
| O18 | DEFERRED |
| O19 | 保留現況 |
| O20 | 保留現況 |
| O21 | 保留現況 |
| O22O24 | DEFERRED |
| O25 | IMPLEMENTED保留 |
| O26 | PARTIAL既有 screen/profile lease |
| O27 | IMPLEMENTEDepoch 函式) |
| O28 | PARTIALidle 看 runs/skills/jobs2026-09-10 修正守門原本永遠回 false並在判斷前 reconcile job 狀態) |
| O29 | IMPLEMENTEDpackage/binding 分離;共用 bytes 不共享帳號) |
| O30 | IMPLEMENTED`LAZYBOY_NATIVE_JOB_CONCURRENCY`,預設 2配額滿時先向 Computer reconcile 未 poll 的 job不會永久卡死 |
| O31 | DEFERRED |
| O32 | DEFERREDT59保留 `-ac` 直到 Xauthority 接到 Cua/AT-SPIVNC 仍走認證 proxy |
| O33 | IMPLEMENTEDsample MCP 安裝 REST + UI |
| O34 | IMPLEMENTEDdigestzip-slipunpinned npx 拒絕) |
| O35 | IMPLEMENTEDinstalled ≠ readyAuthRequired 不可用) |
| O36 | PARTIAL熱啟用 bindings無獨立 schema cache 服務) |
| O37 | IMPLEMENTED0.0.2 旁置、job pin 舊版、rollbackremove |
| O38 | BLOCKED_EXTERNAL |
| O39O40 | IMPLEMENTED fixture真實帳號 BLOCKED_EXTERNAL |
| O41 | IMPLEMENTEDdispatch 寫 operations + outboxflush 補送) |
| O42 | IMPLEMENTEDerrorCode/jobId/operationId |
| O43 | IMPLEMENTED列表 + 下載;磁碟 hash 不符 CONFLICT |
| O44 | PARTIALverifier 層) |
| O45 | IMPLEMENTEDCAS + operation id |
| O46 | IMPLEMENTED`GET /api/computer/{id}/health`dbsandboxdisplayjobsoperations |
| O47 | DEFERRED |
| O48 | DEFERRED無固定環境雙模式 P95不寫未測倍數 |
---
## T01T64
| ID | 狀態 | 證據/理由 |
|---|---|---|
| T01 | IMPLEMENTED | `should_deliver_observation_image` |
| T02 | IMPLEMENTED | 換模型takeover 強制交付(同真值表) |
| T03 | IMPLEMENTED | `pixel_actions_need_vision`text-only 無圖 |
| T04 | IMPLEMENTED | `typed_browser_errors` |
| T05 | IMPLEMENTED | Team/Dedicated path + echo 路徑測試 |
| T06 | PARTIAL | generation 拒絕舊 job未覆蓋所有偽造 grant 變體 |
| T07 | 保留現況 | task 無 Docker socket未加新 sentinel fixture |
| T08 | IMPLEMENTED | zip-slip`..` path 拒絕 |
| T09 | IMPLEMENTED | invalid UTF-8 binary payload |
| T10 | PARTIAL | 工具錯誤非空成功;磁碟滿未測 |
| T11 | IMPLEMENTED | `stale_hash` CAS CONFLICT |
| T12 | IMPLEMENTED | Supervisor `/exec` 回完整 `CommandResult`reap 讀 `exit` 檔,`false`/缺檔為 Failed/1 |
| T13 | IMPLEMENTED | Computer-side job_op cancel 走 `computer_cancel_script`TERM→KILL 整個 process group驗證後才標 cancelled2026-09-10 修正 pid 錯誤status 缺欄位當 TRANSPORT不發明 running |
| T14 | DEFERRED | API 重啟標 interrupted不 resume 容器 PID |
| T15 | IMPLEMENTED | generation interrupt |
| T16 | IMPLEMENTED | operation_id hashmismatch2026-09-10 第二輪key 改 per-bot、in-flight 同 id 回 `UNKNOWN_EFFECT`、傳輸失敗不存成 succeededsqlx 測試 `ledger_is_per_bot_and_does_not_rerun_in_flight_work` |
| T17 | IMPLEMENTED | outbox flush 補送且不重複sqlx 測試 `outbox_flush_does_not_duplicate_recorded_operations`2026-09-10 修正 `SELECT 1` 解碼 bug |
| T18 | IMPLEMENTED | `journal_failure` 拒 mutation |
| T19 | IMPLEMENTED | actor-scoped MCPstdio 不在 API 持有 client |
| T20 | PARTIAL | NOT_BOUNDrevoke無真實 token revoke |
| T21 | IMPLEMENTED | `canary_is_stripped`2026-09-10 第二輪ledgersnippet 實際套用 `redact_secret_patterns`(之前傳空 canary 等於沒脫敏) |
| T22 | 保留現況 | 既有 prompt 邊界;無新 injection corpus |
| T23 | PARTIAL | 同 display lease 既有Cua+worker 未雙開 fixture |
| T24 | PARTIAL | pause/takeoverHTTP in-flight barrier 未單獨測 |
| T25 | IMPLEMENTED | wait 不算 milestone |
| T26 | IMPLEMENTED | Gmail batchModify fixture |
| T27 | IMPLEMENTED | fixture 429/partial真實帳號 BLOCKED_EXTERNAL |
| T28 | IMPLEMENTED | undo 只撤本 plan |
| T29 | IMPLEMENTED | 無強制拆 Teammigration 不 UNIQUE computer_id |
| T30 | IMPLEMENTED | `boot_for(need_gui=false)``ensure_screen``maybe_ensure_screen` + FakeSandbox native write/read/exec`start.sh` runner-only 不 boot XFCE |
| T31 | PARTIAL | job 配額;無壓力 bench |
| T32 | DEFERRED | 無郵件分類品質集 |
| T33 | PARTIAL | broker 不進 generic env無任意 exec 讀 socket fixture |
| T34 | IMPLEMENTED | artifact 從 Computer 下載 |
| T35 | PARTIAL | CASoperation replayresume 不重播未全測 |
| T36 | PARTIAL | mutating tools 走 ledgerMCP stdio 不再是旁路 spawn |
| T37 | IMPLEMENTED | `team_computers_share`migration 註解+測試 |
| T38 | IMPLEMENTED | Team `bots/<id>/` 路徑 |
| T39 | IMPLEMENTED | shared/ + CAS |
| T40 | PARTIAL | 多 slot 契約;無雙 writer GUI fixture |
| T41 | PARTIAL | display writer lease 既有 |
| T42 | IMPLEMENTED | pause scope 停 A 不 bump B |
| T43 | PARTIAL | idle 看 jobs整機 pause 未單測 recreate 全集 |
| T44 | 保留現況 | 既有 ensure-screen single-flight |
| T45 | IMPLEMENTED | Dedicated 刪 botTeam Computer 存活 |
| T46 | IMPLEMENTED | echo 安裝/呼叫 `executionLocation=assigned_computer` |
| T47 | IMPLEMENTED | binding per bot套件 per computer |
| T48 | IMPLEMENTED | digestzip-slipunpinned npx |
| T49 | IMPLEMENTED | `plan_version_switch` job pin |
| T50 | IMPLEMENTED | revokeremoverevoke 掉 schema |
| T51 | IMPLEMENTED | Outlook fixture非官方 npm |
| T52 | IMPLEMENTED | Graph 子項 429/401 |
| T53 | PARTIAL | fixture 分頁語意;真實 delta BLOCKED_EXTERNAL |
| T54 | IMPLEMENTED | categories merge 不盲覆蓋 |
| T55 | BLOCKED_EXTERNAL | 無 Cua/中文/a11y GUI |
| T56 | PARTIAL | generation bumpbackend 熱切未做 |
| T57 | IMPLEMENTED | slot 0 = :1/5900/6080 |
| T58 | 保留現況 | 既有 noVNC無新 DPI fixture |
| T59 | DEFERRED | 保留 `-ac`VNC 走認證 proxyXauthority 未接到 Cua |
| T60 | IMPLEMENTED | 同 T30native 路徑不 attach viewerGUI 之後才 `lazyboy-screen ensure` |
| T61 | IMPLEMENTED | 未審查 env tokenunpinned npx 拒絕 |
| T62 | 保留現況 | 同 UID 不宣稱強隔離 |
| T63 | PARTIAL | job 配額renderer OOM 未測 |
| T64 | PARTIAL | 套件路徑可重建;舊 job 不裝活 |
## 2026-09-10 複查Bug 修正
本輪先跑計劃第 16 節的完整 gate再修實際發現的 bug未新增功能。
驗證命令與結果(本機,拋棄式 Postgres `pgvector/pgvector:pg16``127.0.0.1:5434`
- `cargo fmt --all -- --check`:原本有 diff`artifacts.rs`、`computer.rs` 等新檔未格式化)→ 已 `cargo fmt --all`,現在 exit 0。
- `cargo clippy --workspace --all-targets -- -D warnings`exit 0。
- `DATABASE_URL=… cargo test --workspace`**全部通過**api 157 passed / 3 ignoredcontrol 150harness 37contracts 7sandbox 7supervisor 2。前次紀錄的「sqlx 未連」現在已實跑14 個 sqlx 測試dbmemorymonitorrouting::fan_out全綠。
- `make test-agent-computer`exit 0。
- `apps/web``npm run build``tsc --noEmit` + vite通過。
修正:
1. **`lazyboy-api` 測試 binary 結束時 SIGABRT**`ort` `Mutex poisoned``release_env_on_exit` 二次 panic。根因`memory.rs` 用 `catch_unwind` 包住 `TextEmbedding::try_new`,但 `ort` 找不到 `libonnxruntime.so` 時是在持有全域鎖的情況下 `expect` panic鎖被 poison 後程序退出時 `.fini_array` hook 再 panic → abort。正式環境沒設 `ORT_DYLIB_PATH`API 正常關機也會變 exit 134。修法`memory.rs` 新增 `preload_onnx_runtime()`,先走 `ort::init_from(path)`(回 `Result`)預載 dylib失敗即標 `Unavailable` 不再進 fastembed`crates/api/Cargo.toml` 直接依賴同版 `ort ="=2.0.0-rc.13"`lock 無新增 crate。測試 `database_enforces_agent_scope_and_queries_do_not_leak` 改成顯式 `ModelState::Unavailable`,不再靠環境缺 ONNX 來觸發;新增 `a_missing_onnx_runtime_is_an_error_not_a_panic`。反向驗證:`ORT_DYLIB_PATH=/nonexistent cargo test -- --ignored memory_model_recovers_after_cache_failure` 只因無模型 fail**不再 abort**。
2. **Computer 內 background job 的 pid 是錯的T13 process-tree cancel 實際上沒生效)**。`computer_background_launch` 原本是 `mkdir -p … && setsid nohup sh … & echo $!``&` 作用在整個 `a && b` list`$!` 是 bash 子 shell 的 pid不是 `setsid` 後的 session leader。後果`status` 只是碰巧能用(子 shell 會 wait`cancel` 只殺子 shell真正的 `sh`→子程序變孤兒繼續跑,之後 `status` 讀不到 exit 檔還會誤報 `failed`。修法:`mkdir` 獨立一行wrapper 先 `printf %s "$$" > …/pid`launcher 等 pid 檔再輸出(`setsid` 若 fork 也正確)。新增 `computer_cancel_script(pid)``kill -TERM -- -pgid` → 最多 1s 等待 → `kill -KILL -- -pgid` → 再驗證exit 0 才算整棵樹已消失。`supervisor/docker.rs` cancel 改用它,且只有樹確實消失才把 job 標 `cancelled`(否則維持 `running`,不假稱取消)。測試:`computer_cancel_script_kills_the_whole_process_tree`(本機真起 `sh -c 'sleep 300; sleep 300'`,用 `pgrep -g` 驗證子 `sleep` 一起死);另在真實 Team 容器 `lb-team-space-c7bace…` 內手動跑 launchercancelpid=pgid=sid`tree gone`。
3. **supervisor `job_follow_up` 沒檢查 job 屬於哪個容器**:拿到別的 Computer 的 jobId 時會在自己容器對同號 pid 送 `kill`。現在 `job.computer_id != container` 一律回 `unknown job`
4. **`Makefile` `help` 目標壞掉**:新增的 `test-agent-computer` 說明被塞進同一個 `@echo "…` 造成跨行未閉合字串,`make help` 回 `Unterminated quoted string` Error 2。已拆成兩行 `@echo`
5. **idle reaper 的「有工作就不暫停」守門一直是死的HEAD 既有 bug**`computer_has_active_work` 把 `SELECT 1`INT4解成 `(i64,)`sqlx 回 `ColumnDecode mismatched types`,被 `matches!(Ok(Some(_)))` 吞成 false → 只要 `computers.updated_at` 十分鐘沒動,正在跑 run 的 Computer 也會被 suspend。改成 `sqlx::query(...).fetch_optional` 只看有沒有列。sqlx 測試 `idle_reaper_reconciles_unpolled_jobs_with_the_computer` 同時覆蓋 run 與 job 兩種活性。
6. **`flush_outbox` 去重失效T17 宣稱的「不重做」實際沒生效)**:同樣的 `SELECT 1`→`i64` 解碼錯誤讓 `exists` 永遠 None每筆 outbox 都再寫一次 `run_activity`Activity 出現重複 tool 事件。已改為列存在檢查;查詢出錯時保留 outbox 列到下一輪,不猜。新增 sqlx 測試 `outbox_flush_does_not_duplicate_recorded_operations`
7. **背景 job 配額會永久卡死**`computer_jobs.status` 只在 agent 呼叫 `exec status` 時更新;兩個跑完但沒人 poll 的 job 會讓該 Computer 之後永遠 `QUOTA_EXCEEDED`idle reaper 也永遠以為有工作。修法:`tools.rs` 配額看起來滿時先 `refresh_running_jobs`(向 Computer 問最舊 16 筆 running 的真實狀態supervisor 回 `unknown job``interrupted`)再重算;`computer.rs` 的 `pause_idle_computers` 在判斷活性前做同樣的 reconcilesuspended 容器無法問,`stop_parked_computers` 不做)。
未修、已知:
- host 端 `JobSupervisor::start``crates/control/src/jobs.rs`)用 piped stdout 但只在子程序結束後 `read_to_end`;輸出超過 pipe buffer~64 KiB的子程序會永久阻塞。目前正式路徑不用它背景 job 全在 Computer 內,`exec_background_does_not_spawn_on_the_api` 有守門),僅測試使用;若之後要在 API 行程內跑 job 必須先改成邊跑邊 drain。
- supervisor 的 `ComputerJobTable` 在記憶體supervisor 重啟後舊 jobId 回 `unknown job`API 側已把 `computer_jobs` 標 interrupted屬同一限制T14 仍 DEFERRED
回滾:還原 `crates/api/src/memory.rs`、`crates/api/Cargo.toml`ort 直接依賴)、`crates/api/src/tools.rs`refresh_running_jobs、`crates/api/src/computer.rs`、`crates/api/src/operations.rs`、`crates/control/src/jobs.rs`、`crates/supervisor/src/docker.rs`、`Makefile` 本節改動即可;無 migration 變動。
## 2026-09-10 複查(第二輪):三路平行 code review 後的修正
三個獨立 reviewledger/outbox/artifactsjobs/tool-manager/install雙模式/pause/前端)共回報 40 餘項重複、已在上一節修掉的略過。以下每項都有測試或反向驗證。gate`cargo fmt --check`、`clippy -D warnings`、`cargo test --workspace`api 162control 154harness 37、`tsc --noEmit` 全綠。
**LedgerT15T17`operations.rs` 重寫)**
1. **operationId 全域命名空間 → 跨 bot replay拒絕**ledger key 改為 `{bot_id}:{operation_id}`,查詢加 `bot_id` 條件。Bot B 選到 Bot A 用過的 id不會拿到 A 的結果,也不會被 `PAYLOAD_MISMATCH` 擋。
2. **記憶體 `LEDGER` 拆掉**:與 DB 兩份狀態不一致(一邊 accepted 一邊沒列)、無上限增長(連非 mutating tool 都建列。Postgres 是唯一真相;`OperationLedger` 只留 control crate 單元測試用。
3. **`bot_computer_id` 查詢失敗被當「沒有 Computer → Proceed」**:現在 DB 錯誤 = `JOURNAL_UNAVAILABLE`;不需要 Computer 的 mutationrememberschedule`computer_id = NULL` 入帳migration 023 放寬 NOT NULL
4. **同 id 同 payload 的 `accepted` 列直接 Proceed → 崩潰後重試會做第二次**:新增 `Begin::InProgress` → 工具回 `UNKNOWN_EFFECT`,要求先讀回狀態、換新 id 重試。
5. **`finish` 對非 mutating tool 也寫 DBoutbox**:提早 return。`exec status` 改為非 mutating`exec run/cancel` 仍是)。
6. **`"xxx failed: …"` 的傳輸失敗被存成 `succeeded` 並永遠 replay**`outcome_status` 辨識這類文字 → 刪除 accepted 列讓同 id 可重試;不寫入結果。
7. **replay 丟掉 `pause`**`Begin::Replay` 帶回 `pause`
8. **outbox 寫 `run_activity` 失敗仍標 delivered**:新增 `monitor::try_record_pool`,只有成功才標;且只處理 `created_at` 早於 2 秒的列,避免與 `runs.rs` 的直接寫入賽跑而重複。
9. **`redact_text(&text, &[])` 是 no-op**:新增 `redact_secret_patterns`Bearer、`password=``token:`、`sk-``ghp_``AKIA``xox``AIza`、PEM 私鑰、JSON secret keyledger 結果與 activity snippet 都經過它。control crate 新增 `regex` 直接依賴lock 已有)。
10. retention 新增 `operation_outbox`delivered7 天)與 `computer_operations`(非 accepted90 天)。
**JobsT13T14**
11. **runner-only 容器 PID 1 是 `sleep infinity`,不收屍**setsid 後的 wrapper 結束會變 zombie`kill -0` 仍成功 → job 永遠 `running`。修:`HostConfig.init = true`docker-init 收屍);`status` 改先讀 exit 檔、再用 `computer_alive_script`(看 `/proc/*/status` StateZ/X 視為死)。測試 `computer_alive_script_treats_a_zombie_as_dead` 本機造真 zombie 驗證。
12. **launcher 沒吐出 pid 時仍登記 jobpid None→ 永遠 running、不能 cancel**:現在直接回錯誤,不登記幽靈 job。
13. **`exec status/cancel` 不看歸屬與世代**:新增 `job_ownership`:別的 bot 的 job → `UNKNOWN_JOB``generation` 小於目前 Computer 世代或已 `interrupted` → 直接回 interrupted不去問新容器`persist_job` 的 upsert 不再把 `interrupted` 蓋回 `running`
14. **`pin_running_jobs` 把同 Computer 上其他 bot 的 job 也 pin 了**:加 `bot_id` 條件。
**Tool ManagerT18T21**
15. **revoke 後重裝撞 `UNIQUE(package_row_id, bot_id)` → 500**`ON CONFLICT … DO UPDATE SET status='ready' RETURNING id`;切版本走 `switch_binding`(同 tx 內先清掉同 (package_row, bot) 的非 ready 殘留)。
16. **rollback 會把 `revoked` binding 復活**:非 `ready` → 409。
17. **執行前不驗 digest**`verified_run_argv` 在同一條命令內 `sha256sum -c` 通過才 `exec python3`;不符 exit 97 → `PACKAGE_TAMPERED`。測試 `tampered_package_is_refused_before_it_runs` 真改檔驗證。
18. **`validate_manifest` 只檢查非空**:現要求 64 hex`compute-at-install` 佔位會被拒。
19. `InstallBody` 只吃 `package_id`、前端送 `packageId`serde `rename` + `alias` 兩者皆收。
20. `refuse_unpinned_npx``@scope/pkg``@` 當版本 pin修為去掉 scope 前綴再判斷。
**其他**
21. **`form_fill` 靜默截斷**>20 欄或欄位缺 namelocator → `INVALID_ARGUMENT`,不再填半張表回報成功。
22. **`looks_like_css` 誤判 `v1.2 Release``C++ Guide` 這種標題為 CSS**clicktype 先做精確唯一標題比對;找不到才依 locator 形狀決定 `SELECTOR_UNSUPPORTED``TARGET_NOT_FOUND``TARGET_STALE`,並各給對應說明。
23. **`computers.display_backend` 從未寫入**boot 轉 running 時寫入 `DisplayBackend::from_env()`health 不再永遠回欄位預設值。
24. **`delete_bot` 不清 `tool_bindings``computer_jobs``computer_operations``computer_artifacts`**:同 tx 一起刪Team Computer 的配額不再被已刪 bot 佔用)。
25. **`wait``PROGRESS_WHEN_REPEATED`與本文件「wait 不刷新無進展時鐘」相反**:改成 heartbeat 語意 — `wait` 只更新 `last_heartbeat_turn`(不會被當 loop halt但 stale 教練訊息照常觸發;`polling_a_build_or_a_queue_is_not_treated_as_spinning` 同時鎖住兩半契約。
26. **Gmail fixture 沒有真的 undo**:新增 `undo_plan(before, batches)`,只還原本次計畫實際改動的 label原本就有的不拔、原本沒有的不補、人工新加的不動測試做 plan→undo 往返驗證。
27. 前端:`RunActivityEntry.operationId` 補型別;`runs.rs` 從工具回傳 JSON 抽 `jobId` 寫進 activitymonitor 原有的 `entry.jobId` 顯示終於有值;並顯示 `op <短 id>`
**Migration**:新增 `migrations/023_agent_computer_fixes.sql``computer_operations.computer_id` DROP NOT NULL、移除多餘 `computer_operations_id_hash`、outbox pending 部分索引、`computer_operations(bot_id, created_at)` 索引)。不改 022已可能套用過
**review 提到但本輪不動、記錄在此**
- Pause scopes`resume_after_pause``route_operation` 有純函式與測試但沒接進 runs 主迴圈takeover 實際只有 GUI 全暫停,沒有 scope 級細分T22T24 應視為 PARTIAL不是 DONE
- `write_file``bash -lc` 字串插值路徑HEAD 既有);`normalize_workspace_path` 有限制字元,本輪未改成 argv。
- supervisor `ComputerJobTable` 仍在記憶體T14 DEFERRED 不變)。
回滾:還原本節列出的檔案,並 `DELETE FROM _sqlx_migrations WHERE version=23``ALTER TABLE computer_operations ALTER COLUMN computer_id SET NOT NULL`(若已無 NULL 列)。
## 誠實未完成
P0/P1 可在本環境落地的已接上。下面不是「做完」:
1. 真實 Gmail/Outlook OAuth主信箱`BLOCKED_EXTERNAL`。
2. TigerVNC 當預設 + Cua/中文/a11y GUI`BLOCKED_EXTERNAL`;預設仍 Xvfb+x11vnc。
3. Postgres sqlx 整合測試此環境未連。
4. **O04 PTY `terminal.*`DEFERRED** — 非互動走 `exec`+jobs+process-tree cancelTUI 協作仍用 GUI `shell`。不假裝有 PTY。
5. 跨重啟回收容器內仍在跑的 PID標 interrupted不是 resume。
6. 任意第三方套件商店、完整 OAuth broker沒有憑證。
7. O48 無未測速度倍數。
8. 外掛 UI 未在瀏覽器點過(無瀏覽器工具);已 `tsc --noEmit`
雙模式Team/Dedicated 契約不變;**沒有** UNIQUE `bots.computer_id`。stdio MCP **不是** API 子行程。
回滾:還原本輪 crate / migration 022 / lazyboy-screen `-ac` 註解 / sample-mcp / 前端 plugins+i18n。不要 force push。

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,34 @@
# LazyBoy coding agent 啟動指令 — V3
本版取代舊版啟動指令。與 `LAZYBOY_AGENT_COMPUTER_IMPLEMENTATION_PLAN_V3.md` 一起使用。
## 可直接貼給 coding agent
請閱讀隨附 V3 主計劃,直接修改目前 LazyBoy repository不要只重新產生計劃。
**產品必須同時保留共用主機 Team Computer 與私人主機 Dedicated Computer。** 共用模式允許多個 agent 綁定同一 Computer沿用 per-agent workspace/display/profile 與 shared/;私人模式才是排他 Computer。不得把所有 agent 強制改成獨立容器,不刪 Team 模式、不強制遷移、不把 shared 降級成 GUI-only。
所有任務命令、檔案處理、MCP 的本地 runtime 與連接器請求在 agent 當前獲准的 Computer 內執行。工具不必在桌面演出但有結構化紀錄、真實程序狀態與結果驗證。API/Supervisor 只管理與傳輸,不執行 agent 任意主機任務。
先做:
1. 查 HEAD、git status、AGENTS.md、README、Cargo/前端 scripts保護使用者未提交修改。基準 commit 為 e6afa324530e19922909d4692c28fb005cc05a7a但以當前程式核對。
2. 完成 PR-00 baseline/regression先修 PR-01 截圖與工具契約;重用 SandboxProvider 已有 execute/list/read/write不從換模型或全新 harness 開始。
3. 按主文件第 15 節 DAG 實作PR-04 必須能交付兩模式的 native fast path。PR-05 要能真的安裝一個 sample MCP。Gmail/Outlook 分 PR-08A/BTigerVNC 做 PR-09 候選測試,不與 core exec 改動綁死。
4. 逐條處理 O01O48給出採用、保留現況、實驗、延期或阻礙及理由P2/P3 不要求全部替換。T01T64 的實測與缺項要誠實標記。
必要規則:
- shared 多 bot→同 computer_id 合法;每 bot 一個 active assignment。不要對所有 assignments.computer_id 無條件加 UNIQUE。
- 同一 display/profile 的 GUI mutation 排他;不同 display、受控且無衝突的 jobs 可並行。任意無界 shell 不能靠模型填 read_only 假裝安全。
- 接管 A、接管某 display、暫停整台 Computer 分清楚。不要為了暫停 A 而 freeze 仍有 B 工作的 Team 容器。
- 共享程式套件不等於共享 Outlook/Gmail 帳號package installation、agent binding、credential connection 分離。
- 同 UID 共用 arbitrary shell 不是強機密隔離;保護 broker/core keys對第三方工具清楚標示權限與信任邊界不能只用資料夾和 prompt 擋。
- 外部 stdio MCP、CLI、審核後 HTTP MCP/Graph adapter 走通用 Tool Manager。安裝/啟動/認證/授權/健康狀態分開;包版本與 hash 固定,安全解壓,支援熱啟用、更新、回滾、撤銷、移除。
- Outlook 建立本機 Graph adapter 的可安裝範例,不假造官方 npm package。Mail.ReadWrite 與 MailboxSettings.ReadWrite 不混用Graph batch 最多 20 子請求,逐項判斷 429/401 等結果。寄信、刪信、日曆另行核准。
- TigerVNC 以 Xvnc 取代 Xvfb+x11vnc 是候選 Ax0vncserver 只替換匯出層。先保留 noVNC/websockify/Cua/XFCE維持既有 slot/port/profile 對應與中文/a11y測試後才改 default保留 rollback。
- 截圖/模型/工具/觀看串流分開量測;既有 compositor 已 off 不當成新成果。不用弱畫質換假提速。
- tool 200/exit 0 不等於 task completed未知副作用先 read-back。job_id、process-tree cancel、byte-safe files、CAS/conflict、idempotency/outbox、secret redaction 都要有測試。
- 沒有 OAuth、GUI 或真實模型時,完成程式與 disposable/local fixtures外部項標 BLOCKED_EXTERNAL。不能把 mock 成功當真實整合通過。
每段更新 docs/agent-computer-progress.md狀態、實際 diff、Oxx/Txx、測試命令與結果、未測原因、benchmark 樣本、雙模式影響、回滾、下一個可執行步驟。不刪測試掩蓋問題、不索取明文密碼、不動真實主信箱、不刪舊 shared 資料、不自動正式部署或 force push。
現在先完成 PR-00/01 的實際修改與驗證,再依依賴推進。暫停時留下精確 checkpoint不把未完成全案說成完成。

View File

@ -0,0 +1,86 @@
# LazyBoy V348 項優化與採用判斷
本清單是 V3 主計劃第 18 節的獨立副本;產品契約、來源與實作細節以主計劃為準。共用/私人都保留;不是要求把所有候選工具都裝上。
## 18. 逐項改善清單48 項,不等於 48 項全部替換
P0正確性產品邊界P1核心交付P2量測後採用P3可選延後。以下是本案的待辦與候選沒有實測提速倍數。每項在進度檔有 disposition、證據和回滾已存在的優化要標「保留」不要再次算成新成果。
### A. 原生執行與工作流程
| ID | 項目 | 優先級 | 具體改動 | 要改善的問題 | 驗收/限制 |
|---|---|---|---|---|---|
| O01 | 修正新圖交付 | P0必做 | 修反轉條件,分 captured/delivered接管換模型強制交付 | 無圖硬做造成的錯誤與重試 | 真值表+模型 request fixture[R1] |
| O02 | 原生 exec | P1必做 | 命令在綁定 Computer 執行,直接取 stdout/stderr/exit | 消除終端機打字與讀圖 | 兩模式 text-only 命令 0 screenshot |
| O03 | 原生 files | P1必做 | 重用並強化 SandboxProvider不透過 sed 畫面讀檔 | 減少回合/避免 binary 毀損 | range、Unicode、binary、shared 路徑;[R2R4] |
| O04 | 持續 jobs 與 PTY | P1必做 | 短命令直接返回,長作業由 Runner 管理、事件通知/真取消 | 不請 LLM 反覆看是否做完 | API 重啟可追 jobprocess-tree cancel |
| O05 | 批次程序form macro | P1必做 | 已知步驟在受控 executor 內完成,有 pre/post checks | 不逐列/逐 click 呼叫模型 | 每個子動作有 trace失敗即停 |
| O06 | 套件/連線常駐 | P1必做 | 暖 MCP instance、HTTP pooling、版本化 cache | 降低啟動與重複連線成本 | warm/cold 分測;不共享私人 env |
| O07 | 穩定 Runner transport | P2量測 | 優先現有安全 transport高 overhead 才改持續 UDS/HTTP 通道 | 減少每次 docker exec 的啟動成本 | 實測 RPC 分解;不能公開 root socket |
| O08 | readiness 分層 | P1必做 | Runner、browser、desktop、viewer 各自 ready按需啟動 | 讀 CSV 不先等整個桌面 | no viewer/no desktop 的 native fixture |
### B. 模型與瀏覽器
| ID | 項目 | 優先級 | 具體改動 | 要改善的問題 | 驗收/限制 |
|---|---|---|---|---|---|
| O09 | 按能力路由 | P1必做 | 原生/已授權 API 優先DOM 次之,必要時視覺 | 避免 GmailOutlook 走慢 UI | route rationale權限拒絕不繞過 |
| O10 | 工具 schema 按需載入 | P1必做 | 僅帶此 run 可用能力版本grant cache | 縮短 context、避免選錯工具 | revoke 後立即失效;記 schema tokens |
| O11 | 簡單任務不加 planner | P1必做 | 用既有一輪決策,複雜任務才 TaskPlan | 避免多 agentplanner 套娃 | 記模型回合、TTFT、正確完成 |
| O12 | 條件等待 | P1必做 | wait_untiljob events 有 deadline替代固定 sleep | 消除空等與毫無進度輪詢 | 未知 disabled 原因不無限等 |
| O13 | DOM snapshot 限量與增量 | P2量測 | 聚焦相關區域revision 與失效 refs 完整處理 | 減少重傳巨大 page text | 錯 revision 回 full不能漏關鍵元素 |
| O14 | agent 圖片和 viewer 串流分離 | P1必做 | 高可讀截圖按需取,不把 VNC frame 全塞模型 | 減少模型圖片成本且維持辨識 | 縮放映射force image小字 fixture |
| O15 | 既有 browser/session 穩定 attach | P1必做 | profile/tab/frame 明確綁定,禁止亂開替代瀏覽器 | 減少重登入與 stale reference | 同 profile 不雙開;重啟正確失效 |
| O16 | 驗證、有限恢復與模型升級 | P1必做 | 進度看 milestone兩次同義錯誤換策略難題才升級模型 | 停止二十分鐘空转 | 未知副作用先讀回;無固定提速保證 |
### C. 顯示與遠端桌面
| ID | 項目 | 優先級 | 具體改動 | 要改善的問題 | 驗收/限制 |
|---|---|---|---|---|---|
| O17 | Xvfb+x11vnc → TigerVNC Xvnc | P2必做候選測試 | 一個 backend 同時提供 X server 與 VNC保留 rollback | 可能減少顯示匯出層與維護負擔 | Cuaa11y中文字多 displayCPU/latency[E5] |
| O18 | Xvfb+x0vncserver 備選 | P3保留替代 | 只換 VNC 匯出層,不替換 X server | Xvnc 相容性卡住時較小改動 | 不能說已移除 Xvfb[E6] |
| O19 | 檢查 -noxdamage | P2對照 | 現有 x11vnc 關閉 XDamage測啟用是否有重繪瑕疵 | 判斷掃描負擔能否降低 | 靜態/捲動/遮擋/影片測試;不可直接刪旗標;[R9] |
| O20 | noVNC/websockify 與畫質檔 | P2調校 | 先保留viewer FPS/壓縮可調,無 viewer 減少服務負載 | 改善觀看/頻寬,不假稱模型更快 | 文字清晰、輸入延遲、resize、proxy auth[E7] |
| O21 | 精簡桌面而非先換 OS | P2調校 | 保留 XFCE/a11y已 compositor=off評估不必要 autostart | 降低多 slot CPU/RAM | 不重做既有優化;不破壞 DBus/a11y[R9] |
| O22 | Selkies 替代 viewer | P3可選 | 有高幀率/音訊需求才測;目前有 WebSocket 與選用 WebRTC | 影音觀看可能較適合 | 硬體 encoder 實測、網路與前端成本;[E8] |
| O23 | Xpra 替代 viewer | P3可選 | 需要單視窗發布session forwarding 時測 | 另一路 remote app 體驗 | 非 noVNC drop-in額外 client/輸入整合;[E9] |
| O24 | Wayland全面換桌面 | P3延後 | 保留 X11 driver 路徑,未有需求不全面搬遷 | 避免同時引入 capture/input 相容性變動 | 獨立 ADR/fixtures不是目前速度主解法 |
### D. 共用/私人與資源協調
| ID | 項目 | 優先級 | 具體改動 | 要改善的問題 | 驗收/限制 |
|---|---|---|---|---|---|
| O25 | 雙模式正式保留 | P0必做 | shared 多 bot→同 Computerprivate 排他;不強制遷移 | 符合產品且避免破壞資料 | assignment/member/slot/重建測試 |
| O26 | 按 display檔案account 鎖 | P1必做 | 同資源排他、不同 display 與受控 job 並行 | 避免共用主機整機串行 | Cua/DOM/human 同鎖canonical shared key |
| O27 | scope-aware pause | P1必做 | 接管 A、接管 display、暫停整機分開 | B 不被 A 的無關操作凍結 | 真 barrierfencingHTTP unknown 誠實顯示 |
| O28 | 共享 lifecyclesingle-flight | P1必做 | 啟動、刪除、idle reaper 看全部 member/jobs | 避免重複建立與誤停他人工作 | member refcount、job/viewer 活性聚合 |
| O29 | 程式共享與帳號授權分離 | P0必做 | 共享唯讀 packageper-agent runtime/bindings/grants | 省重複安裝、不共享私人帳號 | 未知程式不能靠 annotations 保證安全 |
| O30 | 公平排程與總配額 | P1必做 | 每 agentComputer 的 CPU/RAM/jobs/requests 有界 | 抑制單 agent 拖慢所有人 | 壓力/OOM/慢 MCP不無界並行 |
| O31 | Chromium /dev/shm 與程序回收 | P2量測 | 調 per-Computer shm、init/reaper、renderer 健康 | 減少可避免的崩潰與孤兒程序 | 不能盲開 ipc=host/SYS_ADMIN/no-sandbox[E10] |
| O32 | X11/DBus/profile 權限與 session | P1必做 | per-slot identity評估 Xauthority系統安裝有管理鎖 | 避免串台與共用 session 風險 | 同 UID 非強隔離Xvnc :1 port 對應測試 |
### E. 可安裝外部工具Outlook
| ID | 項目 | 優先級 | 具體改動 | 要改善的問題 | 驗收/限制 |
|---|---|---|---|---|---|
| O33 | Tool Manager 安裝入口 | P1必做 | catalog、manifest受控 package URL、本地上傳三種入口 | 新增工具不改核心 dispatch | 實際 sample MCP 安裝,不只是設定畫面 |
| O34 | 來源版本與完整性 | P1必做 | pin digest/version審核 hooks安全解壓與 quota | 更新可重現、防無界安裝 | 拒絕未審核 URL、路徑逃逸、錯 digest |
| O35 | 安裝/啟動/認證/授權分開 | P1必做 | 每階段顯示狀態、failure reason 和 scope | 不再「裝好了卻不能用」 | installed ≠ connected ≠ authenticated ≠ authorized |
| O36 | 熱啟用與 registry 更新 | P1必做 | health/schema 驗證原子登錄能力grant 撤銷失效 | 不為加工具重啟整個 LazyBoy | 正在執行 run 的能力版本與安全點更新 |
| O37 | 版本更新/回滾/移除 | P1必做 | 新版本旁置active job pin 舊版bindings/reference GC | 共用主機其他 agent 不受破壞 | 新增權限重新核准;共享套件引用仍存活 |
| O38 | OAuthtoken broker | P1必做 | Microsoft/Google 正規授權scoped token、refresh single-flight | 減少重登入與憑證混用 | MFA/admin policy 正常接管secret canary[E11] |
| O39 | Outlook Graph 外掛 | P1必做驗收 | 讀信→categories preview/apply/verify寄信另授權 | 驗證架構不只 Gmail 特判 | 個人/工作帳號對應 scopes[E12E14] |
| O40 | Outlook 批次/增量同步 | P1/P2 | Graph 20 子請求/逐項狀態;需要時 folder delta | 減少網路回合及每次全信箱掃描 | 429/Retry-After/分頁/ImmutableId[E15E18] |
### F. 正確性、紀錄與可維護性
| ID | 項目 | 優先級 | 具體改動 | 要改善的問題 | 驗收/限制 |
|---|---|---|---|---|---|
| O41 | operation journal/outbox | P0/P1 | 副作用前記 intent斷線 durable 補送與去重 | 避免掉紀錄與重做工作 | journal 故障不開始 mutation |
| O42 | 活動紀錄串流而非桌面演出 | P1必做 | 工具與 job 的結構化事件、實際耗時/結果 | 人可追查、不逼每步打字 | noVNC 關閉仍可看進度 |
| O43 | artifact 存 Computer脫敏 | P1必做 | 全文输出在 ComputerAPI 存摘要/reference限量保留 | 避免 DB/context 被圖片/日誌塞滿 | secret/大檔/retention/串流授權測試 |
| O44 | Task 驗證與郵件分類品質 | P1必做 | API 200 不等於任務完成;測 precision/coverage/abstention | 速度與正確性一起看 | read-back真實 fixture、失敗也計入 |
| O45 | 衝突與冪等/未知效果 | P1必做 | 同 operation 不重做same-file CAS未知 API 先回讀 | 降低重送/覆蓋/重複外部副作用 | 不假稱通用 exactly-once無損 undo |
| O46 | 部件健康與版本相容矩陣 | P1/P2 | Runner/display/Cua/browser/plugin 個別 probe/recover | 失敗不用重啟整台 Computer | 不影響其他成員pin 上游版本 |
| O47 | 已驗證 skill流程重用 | P2後續 | 將常用流程保存語意步驟與 checks環境變更可失效 | 降低每次從零探索 | 不保存密碼舊座標profile/版本變更重驗 |
| O48 | 端到端viewer 雙 benchmark | P1必做 | 分模型、工具、snapshot、queue、viewer固定環境兩模式 | 找真瓶頸,不憑工具名字換底層 | 成功率/median/P95/CPU/RAM/可讀性;一項一改 |

View File

@ -88,6 +88,7 @@ RUN --mount=type=cache,id=lazyboy-apt-cache-$TARGETARCH,target=/var/cache/apt,sh
websockify \ websockify \
x11-utils \ x11-utils \
x11vnc \ x11vnc \
tigervnc-standalone-server \
xdg-utils \ xdg-utils \
xfce4-panel \ xfce4-panel \
xfce4-settings \ xfce4-settings \

View File

@ -101,6 +101,10 @@ wait_port() {
return 1 return 1
} }
display_backend() {
printf '%s' "${LAZYBOY_DISPLAY_BACKEND:-xvfb_x11vnc}"
}
start_xvfb() { start_xvfb() {
local display="$1" local display="$1"
local number="$2" local number="$2"
@ -109,11 +113,42 @@ start_xvfb() {
return 0 return 0
fi fi
rm -f "/tmp/.X${number}-lock" "/tmp/.X11-unix/X${number}" rm -f "/tmp/.X${number}-lock" "/tmp/.X11-unix/X${number}"
# T59: keep -ac until Xauthority is given to Cua/AT-SPI/x11vnc in-image.
# Public VNC is still the authenticated screen proxy, not a naked 5900.
Xvfb "$display" -screen 0 1280x800x24 -ac +extension RANDR +render -noreset >>"${log}-xvfb.log" 2>&1 9>&- & Xvfb "$display" -screen 0 1280x800x24 -ac +extension RANDR +render -noreset >>"${log}-xvfb.log" 2>&1 9>&- &
echo $! > "${log}-xvfb.pid" echo $! > "${log}-xvfb.pid"
wait_display "$display" wait_display "$display"
} }
# Candidate A: TigerVNC Xvnc provides X + RFB in one process. rfbport is
# explicit so slot 0 stays 5900 even though display is :1.
start_xvnc() {
local display="$1"
local number="$2"
local rfb_port="$3"
local log="$4"
if xdpyinfo -display "$display" >/dev/null 2>&1; then
return 0
fi
local xvnc=""
for c in Xvnc Xtigervnc; do
if command -v "$c" >/dev/null 2>&1; then
xvnc="$c"
break
fi
done
if [[ -z "$xvnc" ]]; then
echo "TigerVNC Xvnc is not installed; falling back to Xvfb" >&2
start_xvfb "$display" "$number" "$log"
return
fi
rm -f "/tmp/.X${number}-lock" "/tmp/.X11-unix/X${number}"
"$xvnc" "$display" -geometry 1280x800 -depth 24 -rfbport "$rfb_port" -localhost \
-SecurityTypes None >>"${log}-xvnc.log" 2>&1 9>&- &
echo $! > "${log}-xvfb.pid"
wait_display "$display"
}
alive_pidfile() { alive_pidfile() {
local file="$1" local file="$1"
[[ -f "$file" ]] || return 1 [[ -f "$file" ]] || return 1
@ -368,13 +403,31 @@ ensure_slot() {
"$slot" "$display" "$view_port" "$vnc_port" "$slot" "$display" "$view_port" "$vnc_port"
exit 0 exit 0
fi fi
if [[ "$(display_backend)" == "tigervnc_xvnc" ]]; then
start_xvnc "$display" "$number" "$vnc_port" "$log" || {
echo "Xvnc failed on ${display}" >&2
cat "${log}-xvnc.log" >&2 || true
exit 1
}
else
start_xvfb "$display" "$number" "$log" || { start_xvfb "$display" "$number" "$log" || {
echo "Xvfb failed on ${display}" >&2 echo "Xvfb failed on ${display}" >&2
cat "${log}-xvfb.log" >&2 || true cat "${log}-xvfb.log" >&2 || true
exit 1 exit 1
} }
fi
start_desktop "$display" "$xfce_home" "$log" start_desktop "$display" "$xfce_home" "$log"
if [[ "$(display_backend)" != "tigervnc_xvnc" ]] || ! port_open "$vnc_port"; then
start_vnc "$display" "$vnc_port" "$view_port" "$log" || exit 1 start_vnc "$display" "$vnc_port" "$view_port" "$log" || exit 1
else
if ! port_open "$view_port"; then
local novnc=/usr/share/novnc
websockify --heartbeat=30 --web="$novnc" "0.0.0.0:${view_port}" "127.0.0.1:${vnc_port}" \
>>"${log}-novnc.log" 2>&1 9>&- &
fi
wait_port "$vnc_port"
wait_port "$view_port"
fi
start_cua_driver "$display" "$log" || exit 1 start_cua_driver "$display" "$log" || exit 1
start_xterm "$display" "$log" start_xterm "$display" "$log"
if [[ -n "$profile" ]]; then if [[ -n "$profile" ]]; then

View File

@ -29,6 +29,13 @@ if command -v dbus-launch >/dev/null 2>&1; then
eval "$(dbus-launch --sh-syntax)" eval "$(dbus-launch --sh-syntax)"
fi fi
# Runner-only: native exec/files/MCP without XFCE/noVNC. GUI later uses
# `lazyboy-screen ensure` (T30/T60).
if [[ "${LAZYBOY_RUNNER_ONLY:-0}" == "1" ]]; then
touch /tmp/lazyboy/ready
exec sleep infinity
fi
lazyboy-screen boot-primary || exit 1 lazyboy-screen boot-primary || exit 1
touch /tmp/lazyboy/ready touch /tmp/lazyboy/ready
pid="" pid=""

View File

@ -0,0 +1,84 @@
-- Dual-mode identity, operations, jobs, artifacts, tool packages, grants.
-- shared computers may have many bots; do NOT add UNIQUE(bots.computer_id).
ALTER TABLE computers
ADD COLUMN IF NOT EXISTS generation INTEGER NOT NULL DEFAULT 1,
ADD COLUMN IF NOT EXISTS computer_epoch BIGINT NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS display_backend TEXT NOT NULL DEFAULT 'xvfb_x11vnc';
CREATE TABLE IF NOT EXISTS computer_operations (
id TEXT PRIMARY KEY,
computer_id TEXT NOT NULL REFERENCES computers (id) ON DELETE CASCADE,
bot_id TEXT NOT NULL,
run_id TEXT,
payload_hash TEXT NOT NULL,
status TEXT NOT NULL,
result TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX IF NOT EXISTS computer_operations_id_hash
ON computer_operations (id, payload_hash);
CREATE TABLE IF NOT EXISTS computer_jobs (
id TEXT PRIMARY KEY,
computer_id TEXT NOT NULL REFERENCES computers (id) ON DELETE CASCADE,
generation INTEGER NOT NULL DEFAULT 1,
bot_id TEXT NOT NULL,
status TEXT NOT NULL,
argv JSONB NOT NULL DEFAULT '[]'::jsonb,
pin_version TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
ALTER TABLE computer_jobs ADD COLUMN IF NOT EXISTS pin_version TEXT;
CREATE TABLE IF NOT EXISTS computer_artifacts (
id TEXT PRIMARY KEY,
computer_id TEXT NOT NULL REFERENCES computers (id) ON DELETE CASCADE,
bot_id TEXT NOT NULL,
relative_path TEXT NOT NULL,
sha256 TEXT NOT NULL,
size BIGINT NOT NULL DEFAULT 0,
operation_id TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS tool_packages (
id TEXT PRIMARY KEY,
computer_id TEXT NOT NULL REFERENCES computers (id) ON DELETE CASCADE,
package_id TEXT NOT NULL,
version TEXT NOT NULL,
sha256 TEXT NOT NULL,
status TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (computer_id, package_id, version)
);
CREATE TABLE IF NOT EXISTS tool_bindings (
id TEXT PRIMARY KEY,
package_row_id TEXT NOT NULL REFERENCES tool_packages (id) ON DELETE CASCADE,
bot_id TEXT NOT NULL,
account_id TEXT,
status TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (package_row_id, bot_id)
);
CREATE TABLE IF NOT EXISTS tool_grants (
id TEXT PRIMARY KEY,
bot_id TEXT NOT NULL REFERENCES bots (id) ON DELETE CASCADE,
capability TEXT NOT NULL,
account_ref TEXT,
expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS operation_outbox (
id TEXT PRIMARY KEY,
event_key TEXT NOT NULL UNIQUE,
payload JSONB NOT NULL,
delivered BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

View File

@ -0,0 +1,17 @@
-- Ledger fixes.
-- * Mutating tools that need no Computer (memory, schedules) are journaled
-- too, so computer_id must be nullable.
-- * The outbox is polled every 200 ms; give the undelivered scan an index and
-- let retention drop delivered rows.
-- * `computer_operations.id` is now `{bot_id}:{operation_id}`; the extra
-- (id, payload_hash) unique index never added anything over the PK.
ALTER TABLE computer_operations ALTER COLUMN computer_id DROP NOT NULL;
DROP INDEX IF EXISTS computer_operations_id_hash;
CREATE INDEX IF NOT EXISTS operation_outbox_pending_idx
ON operation_outbox (created_at) WHERE NOT delivered;
CREATE INDEX IF NOT EXISTS computer_operations_bot_idx
ON computer_operations (bot_id, created_at);

View File

@ -0,0 +1,54 @@
#!/usr/bin/env python3
"""Minimal stdio MCP-ish echo server for LazyBoy Tool Manager fixtures.
Speaks a tiny JSON-RPC subset: initialize, tools/list, tools/call.
Not a substitute for a full MCP SDK; used to prove install bind call.
"""
import json
import sys
def reply(message_id, result):
sys.stdout.write(json.dumps({"jsonrpc": "2.0", "id": message_id, "result": result}) + "\n")
sys.stdout.flush()
def main():
for line in sys.stdin:
line = line.strip()
if not line:
continue
request = json.loads(line)
method = request.get("method")
message_id = request.get("id")
if method == "initialize":
reply(message_id, {"protocolVersion": "2025-11-25", "serverInfo": {"name": "lazyboy-echo"}})
elif method == "tools/list":
reply(
message_id,
{
"tools": [
{
"name": "echo",
"description": "Echo text from the bound Computer",
"inputSchema": {
"type": "object",
"properties": {"text": {"type": "string"}},
"required": ["text"],
},
}
]
},
)
elif method == "tools/call":
args = (request.get("params") or {}).get("arguments") or {}
text = args.get("text", "")
reply(message_id, {"content": [{"type": "text", "text": text}]})
elif method == "notifications/initialized":
continue
else:
reply(message_id, {"error": f"unknown method {method}"})
if __name__ == "__main__":
main()

View File

@ -0,0 +1,23 @@
manifest_version: 1
id: lazyboy.example.echo
version: 0.0.1
source:
kind: reviewed_local_artifact
artifact_ref: ./echo_server.py
sha256: compute-at-install
runtime:
kind: stdio_mcp
execution_location: assigned_computer
entrypoint: [python3, ./echo_server.py]
state_scope: agent_binding
installation:
supported_modes: [shared, dedicated]
share_immutable_package: true
permissions:
filesystem: [own_workspace]
shared_paths: []
network_services: []
needs_host_access: false
needs_root: false
capabilities:
- echo.text