have main issue

This commit is contained in:
daniel wang 2026-09-16 14:00:19 +00:00
parent 8e8174e8bf
commit 358de0f621
26 changed files with 923 additions and 79 deletions

View File

@ -61,9 +61,9 @@ See [team setup and behavior](docs/TEAM.md) for commands, budgets, privacy bound
| Explicit local files | `external_list_dir`, `external_read_file`, `external_edit_file`, `external_write_file`, `external_grep` (regex), `external_glob`, `external_search_files` (literal) | | Explicit local files | `external_list_dir`, `external_read_file`, `external_edit_file`, `external_write_file`, `external_grep` (regex), `external_glob`, `external_search_files` (literal) |
| Web | `web_fetch` (direct anonymous HTTP GET, HTML → text, short cache), `web_search` (remote search service); no browser login; Docker browser for login-gated pages | | Web | `web_fetch` (direct anonymous HTTP GET, HTML → text, short cache), `web_search` (remote search service); no browser login; Docker browser for login-gated pages |
| Background | `spawn_subagent` (returns immediately; `kind=computerUse` for desktop GUI), `check_subagent`, `message_subagent`, `stop_subagent`. Subagents and external command completion revive the turn; observe detached box commands with `await_shell`. | | Background | `spawn_subagent` (returns immediately; `kind=computerUse` for desktop GUI), `check_subagent`, `message_subagent`, `stop_subagent`. Subagents and external command completion revive the turn; observe detached box commands with `await_shell`. |
| Desktop GUI | Parent: `screenshot` (read-only). Clicks: `spawn_subagent kind=computerUse`, which gets `computer` (screenshot/click/move/drag/type/key/scroll/wait via xdotool on `DISPLAY=:1`). One computerUse per agent computer. Passwords still `request_box_help`. | | Desktop GUI | Parent: `screenshot` (read-only). Clicks: `spawn_subagent kind=computerUse`, which gets `computer` (screenshot/snapshot/click/move/drag/type/key/scroll/wait via xdotool on `DISPLAY=:1`; `snapshot` lists on-screen elements with centre coordinates from the AT-SPI accessibility tree). One computerUse per agent computer. Passwords still `request_box_help`. |
| MCP | `get_mcp_tools`, `call_mcp_tool`, `get_mcp_server_status`, `add_mcp_server`, `remove_mcp_server`. Config: `~/.lazyboy/mcp.json` (or `LAZYBOY_MCP_CONFIG`). Prefer a connector over the browser for that service. | | MCP | `get_mcp_tools`, `call_mcp_tool`, `get_mcp_server_status`, `add_mcp_server`, `remove_mcp_server`. Config: `~/.lazyboy/mcp.json` (or `LAZYBOY_MCP_CONFIG`). Prefer a connector over the browser for that service. |
| My computer (box) | Docker Linux desktop (XFCE + **Chromium** + 終端機 + xdotool). Named agents each get their own container; opening an agent starts it and the UI confirms ready/error. Session CLI keeps `lazyboy-box`. Default tools: `shell`, `read`, `await_shell`, `screenshot`. Profile: `/home/box/chrome-profile` on that seat. Viewer`lazyboy computer` or the agent overlay. | | My computer (box) | Docker Linux desktop (TigerVNC + minimal XFCE in lazyBoy's Arc-Dark/粉圓 look, zh-TW/Asia/Taipei, **Chromium** + 終端機 + xdotool + AT-SPI). Named agents each get their own container; opening an agent starts it and the UI confirms ready/error. Session CLI keeps `lazyboy-box`. Default tools: `shell`, `read`, `await_shell`, `screenshot`. Profile: `/home/box/chrome-profile` on that seat. Viewer`lazyboy computer` or the agent overlay. |
| Browser observation | `browser_navigate`, `browser_snapshot`, `browser_read_page`, `browser_tabs` | | Browser observation | `browser_navigate`, `browser_snapshot`, `browser_read_page`, `browser_tabs` |
| Browser actions | `browser_click`, `browser_type`, `browser_press`, `browser_select`, `browser_scroll`, `browser_wait` | | Browser actions | `browser_click`, `browser_type`, `browser_press`, `browser_select`, `browser_scroll`, `browser_wait` |
| Browser file exchange | `browser_upload`, `browser_download` | | Browser file exchange | `browser_upload`, `browser_download` |

View File

@ -1,26 +1,81 @@
# LazyBoy's computer: a real Linux desktop the human (noVNC) and the agent
# (xdotool + AT-SPI + CDP) share. Looks like lazyBoy's computer image:
# Arc-Dark + Papirus + jf open 粉圓, Traditional Chinese, Taipei time.
# One TigerVNC X server replaces Xvfb + x11vnc; no XFCE session manager.
FROM debian:bookworm-slim FROM debian:bookworm-slim
ENV DEBIAN_FRONTEND=noninteractive ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \ # dpkg never unpacks docs/man/foreign locales; zh_TW catalogs are kept so the
xvfb x11-utils x11-xserver-utils x11vnc novnc websockify autocutsel xclip imagemagick xdotool \ # XFCE menus are in Traditional Chinese.
dbus-x11 xfce4-session xfce4-panel xfwm4 xfdesktop4 xfce4-settings \ RUN printf '%s\n' \
xfce4-terminal thunar \ 'path-exclude=/usr/share/doc/*' \
fonts-noto-core fonts-noto-cjk \ 'path-include=/usr/share/doc/*/copyright' \
'path-exclude=/usr/share/man/*' \
'path-exclude=/usr/share/info/*' \
'path-exclude=/usr/share/gtk-doc/*' \
'path-exclude=/usr/share/backgrounds/*' \
'path-exclude=/usr/share/locale/*' \
'path-include=/usr/share/locale/locale.alias' \
'path-include=/usr/share/locale/zh_TW/*' \
'path-include=/usr/share/locale/zh/*' \
> /etc/dpkg/dpkg.cfg.d/zz-lazyboy-slim \
&& apt-get update && apt-get install -y --no-install-recommends \
tigervnc-standalone-server tigervnc-tools x11-utils x11-xserver-utils \
novnc websockify xclip imagemagick xdotool \
dbus-x11 xfconf xfce4-panel xfwm4 xfdesktop4 xfce4-settings xfce4-terminal \
arc-theme papirus-icon-theme adwaita-icon-theme gnome-themes-extra librsvg2-common \
at-spi2-core libatk-adaptor python3-gi gir1.2-atspi-2.0 gir1.2-gtk-3.0 \
dconf-gsettings-backend gsettings-desktop-schemas libglib2.0-bin \
fonts-dejavu-core fonts-liberation fonts-noto-cjk fonts-noto-color-emoji \
locales tzdata \
python3 curl ca-certificates git bash tini procps \ python3 curl ca-certificates git bash tini procps \
chromium \ chromium \
&& echo "zh_TW.UTF-8 UTF-8" >> /etc/locale.gen \
&& echo "en_US.UTF-8 UTF-8" >> /etc/locale.gen \
&& locale-gen \
&& ln -sf /usr/share/zoneinfo/Asia/Taipei /etc/localtime \
&& echo "Asia/Taipei" > /etc/timezone \
&& rm -rf /usr/share/icons/ePapirus /usr/share/icons/ePapirus-Dark /usr/share/icons/Papirus-Light \
&& test -e /usr/share/icons/Papirus-Dark/48x48/apps/web-browser.svg \
&& test -d /usr/share/themes/Arc-Dark/xfwm4 \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# jf open 粉圓 (OFL) as the desktop UI font, pinned like lazyBoy.
RUN mkdir -p /usr/share/fonts/truetype/huninn \
&& curl -fsSL https://github.com/justfont/open-huninn-font/releases/download/v2.1/jf-openhuninn-2.1.ttf \
-o /usr/share/fonts/truetype/huninn/jf-openhuninn-2.1.ttf \
&& echo '9d5bf4932d31fe94c18cd8cfddc98bc1b14ce10f4e354c682179db290a99c825 /usr/share/fonts/truetype/huninn/jf-openhuninn-2.1.ttf' | sha256sum -c \
&& fc-cache -f
COPY --from=node:22-bookworm-slim /usr/local/ /usr/local/ COPY --from=node:22-bookworm-slim /usr/local/ /usr/local/
COPY playwright/ /opt/lazyboy/playwright/ COPY playwright/ /opt/lazyboy/playwright/
RUN cd /opt/lazyboy/playwright && PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 npm install --omit=dev RUN cd /opt/lazyboy/playwright && PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 npm install --omit=dev
RUN mkdir -p /workspace /home/box /usr/share/lazyboy \ RUN mkdir -p /workspace /home/box /usr/share/lazyboy/xfce-skel /etc/xdg/xfce4 /usr/share/xfce4/helpers /etc/gtk-3.0 /etc/fonts/conf.d /usr/share/licenses/huninn \
&& chmod 1777 /workspace && chmod 1777 /workspace
COPY start-desktop.sh stop-browser.py stop-browser.mjs box-chrome /usr/local/bin/ COPY --chmod=755 start-desktop.sh stop-browser.py stop-browser.mjs box-chrome box-terminal box-a11y /usr/local/bin/
COPY welcome.html /usr/share/lazyboy/welcome.html COPY --chmod=644 welcome.html /usr/share/lazyboy/welcome.html
COPY autostart/ /etc/xdg/autostart/ COPY --chmod=644 xfce/wallpaper.svg /usr/share/lazyboy/wallpaper.svg
COPY desktop/ /usr/share/lazyboy/desktop/ COPY --chmod=644 xfce/xfce4-panel.xml xfce/xfwm4.xml xfce/xfce4-desktop.xml /usr/share/lazyboy/xfce-skel/
RUN chmod +x /usr/local/bin/start-desktop.sh /usr/local/bin/box-chrome COPY --chmod=644 xfce/helpers.rc /etc/xdg/xfce4/helpers.rc
ENV DISPLAY=:1 COPY --chmod=644 xfce/helper-browser.desktop /usr/share/xfce4/helpers/lazyboy-browser.desktop
COPY --chmod=644 xfce/helper-terminal.desktop /usr/share/xfce4/helpers/lazyboy-terminal.desktop
COPY --chmod=644 xfce/browser.desktop /usr/share/applications/lazyboy-browser.desktop
COPY --chmod=644 xfce/terminal.desktop /usr/share/applications/lazyboy-terminal.desktop
COPY --chmod=644 fonts.conf /etc/fonts/conf.d/99-lazyboy-cjk.conf
COPY --chmod=644 gtk3-settings.ini /etc/gtk-3.0/settings.ini
COPY --chmod=644 licenses/huninn-OFL.txt /usr/share/licenses/huninn/OFL.txt
# Hide the stock launchers so the menu shows only 瀏覽器 / 終端機 (box-chrome
# is the only supported way to start Chromium: same profile + CDP port).
RUN for f in chromium xfce4-terminal debian-xterm xterm uxterm; do \
if [ -f "/usr/share/applications/${f}.desktop" ]; then \
printf '\nNoDisplay=true\nHidden=true\n' >> "/usr/share/applications/${f}.desktop"; \
fi; \
done \
&& test -f /usr/share/locale/zh_TW/LC_MESSAGES/xfce4-panel.mo
# Traditional Chinese locale, Taipei time and AT-SPI on for every process in
# the box, including `docker exec` launches.
ENV DISPLAY=:1 \
LANG=zh_TW.UTF-8 LC_ALL=zh_TW.UTF-8 LANGUAGE=zh_TW:zh:en TZ=Asia/Taipei \
GTK_MODULES=atk-bridge GTK_A11Y=atspi GNOME_ACCESSIBILITY=1 NO_AT_BRIDGE=0
WORKDIR /workspace WORKDIR /workspace
EXPOSE 6080 EXPOSE 6080
LABEL lazyboy.box.revision=computer-use-5 LABEL lazyboy.box.revision=computer-use-7
ENTRYPOINT ["/usr/bin/tini", "--"] ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["/usr/local/bin/start-desktop.sh"] CMD ["/usr/local/bin/start-desktop.sh"]

View File

@ -1,5 +0,0 @@
[Desktop Entry]
Type=Application
Name=Browser
Exec=box-chrome /usr/share/lazyboy/welcome.html
X-XFCE-Autostart-Enabled=true

View File

@ -1,5 +0,0 @@
[Desktop Entry]
Type=Application
Name=Terminal
Exec=xfce4-terminal --title=終端機 --working-directory=/workspace
X-XFCE-Autostart-Enabled=true

258
box/box-a11y Executable file
View File

@ -0,0 +1,258 @@
#!/usr/bin/env python3
"""Accessibility snapshot of the box desktop (AT-SPI).
Prints one line per interactive/visible element with its role, name and the
screen-pixel centre the computer tool can click:
[12] button "登入" @ (640,412) 96x36 win="蝦皮購物 - Chromium"
Usage: box-a11y [--json] [--limit N] [--budget SECONDS]
Exit 0 with an empty list when nothing is exposed; exit 2 when AT-SPI itself is
unreachable (the desktop or registry is not up).
"""
import argparse
import json
import os
import sys
import time
import warnings
warnings.filterwarnings("ignore", category=DeprecationWarning)
# Always listed when on screen, even without a name (the agent can still
# target them by position/value).
INTERACTIVE = {
"push button", "toggle button", "radio button", "check box", "link",
"menu item", "check menu item", "radio menu item", "tab", "page tab",
"list item", "tree item", "combo box", "spin button", "slider",
"entry", "password text",
}
# Listed only when they carry a name or text (headings, labels, cells...).
NAMED = {
"heading", "label", "static", "text", "image", "table cell",
"column header", "row header", "icon", "menu", "tool tip", "alert",
"dialog", "frame",
}
ROLE_SHORT = {
"push button": "button", "toggle button": "toggle", "radio button": "radio",
"check box": "checkbox", "menu item": "menuitem", "check menu item": "menuitem",
"radio menu item": "menuitem", "page tab": "tab", "list item": "listitem",
"tree item": "treeitem", "combo box": "combobox", "spin button": "spinner",
"password text": "password", "entry": "textbox", "text": "text",
"static": "text", "document frame": "document", "table cell": "cell",
"column header": "header", "row header": "header", "internal frame": "frame",
}
def load_dbus_env():
if os.environ.get("DBUS_SESSION_BUS_ADDRESS"):
return
try:
with open("/tmp/lazyboy/dbus.env", encoding="utf-8") as fh:
for line in fh:
key, _, value = line.strip().partition("=")
if key == "DBUS_SESSION_BUS_ADDRESS" and value:
os.environ[key] = value
except OSError:
pass
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--json", action="store_true")
ap.add_argument("--limit", type=int, default=300)
ap.add_argument("--budget", type=float, default=6.0)
ap.add_argument("--max-depth", type=int, default=40)
args = ap.parse_args()
load_dbus_env()
os.environ.setdefault("DISPLAY", ":1")
try:
import gi
gi.require_version("Atspi", "2.0")
from gi.repository import Atspi
except Exception as exc: # noqa: BLE001
print(f"AT-SPI bindings unavailable: {exc}", file=sys.stderr)
return 2
try:
Atspi.init()
desktop = Atspi.get_desktop(0)
app_count = desktop.get_child_count()
except Exception as exc: # noqa: BLE001
print(f"AT-SPI registry unreachable: {exc}", file=sys.stderr)
return 2
screen_w = int(os.environ.get("LAZYBOY_SCREEN_W", "0") or 0)
screen_h = int(os.environ.get("LAZYBOY_SCREEN_H", "0") or 0)
deadline = time.monotonic() + args.budget
out = []
truncated = False
seen_apps = []
def showing(node):
try:
st = node.get_state_set()
return st.contains(Atspi.StateType.SHOWING) and st.contains(Atspi.StateType.VISIBLE)
except Exception: # noqa: BLE001
return False
def extents(node):
try:
comp = node.get_component() if hasattr(node, "get_component") else node.get_component_iface()
if comp is None:
return None
r = comp.get_extents(Atspi.CoordType.SCREEN)
return int(r.x), int(r.y), int(r.width), int(r.height)
except Exception: # noqa: BLE001
return None
def onscreen(box):
x, y, w, h = box
if w <= 0 or h <= 0:
return False
if screen_w and (x >= screen_w or x + w <= 0):
return False
if screen_h and (y >= screen_h or y + h <= 0):
return False
return True
def walk(node, depth, win_title):
nonlocal truncated
if truncated or time.monotonic() > deadline:
truncated = True
return
if depth > args.max_depth or len(out) >= args.limit:
truncated = truncated or len(out) >= args.limit
return
try:
role = node.get_role_name()
except Exception: # noqa: BLE001
return
if depth > 1 and not showing(node):
return
box = extents(node)
if depth > 1 and (box is None or not onscreen(box)):
return
name = ""
try:
name = (node.get_name() or "").strip()
except Exception: # noqa: BLE001
pass
if role in ("frame", "dialog", "window") and name:
win_title = name
try:
st = node.get_state_set()
focused = st.contains(Atspi.StateType.FOCUSED)
editable = st.contains(Atspi.StateType.EDITABLE)
checked = st.contains(Atspi.StateType.CHECKED)
selected = st.contains(Atspi.StateType.SELECTED)
disabled = not st.contains(Atspi.StateType.ENABLED)
except Exception: # noqa: BLE001
focused = editable = checked = selected = disabled = False
value = ""
if role in ("entry", "password text", "text", "spin button", "slider") or editable:
try:
txt = node.get_text() if hasattr(node, "get_text") else node.get_text_iface()
if txt is not None and role != "password text":
value = (txt.get_text(0, min(txt.get_character_count(), 80)) or "").strip()
except Exception: # noqa: BLE001
pass
printable = depth > 1 and box is not None and (
role in INTERACTIVE
or (role in NAMED and (name or value))
or (editable and role == "text")
)
if printable:
x, y, w, h = box
out.append({
"role": ROLE_SHORT.get(role, role.replace(" ", "")),
"name": name[:120],
"value": value[:80],
"x": x, "y": y, "w": w, "h": h,
"cx": x + w // 2, "cy": y + h // 2,
"focused": focused, "checked": checked, "selected": selected,
"disabled": disabled, "editable": editable,
"window": win_title,
})
try:
count = node.get_child_count()
except Exception: # noqa: BLE001
return
for i in range(min(count, 2000)):
if truncated:
return
try:
child = node.get_child_at_index(i)
except Exception: # noqa: BLE001
continue
if child is None:
continue
walk(child, depth + 1, win_title)
for i in range(app_count):
try:
app = desktop.get_child_at_index(i)
if app is None:
continue
app_name = app.get_name() or ""
except Exception: # noqa: BLE001
continue
if app_name in ("xfce4-panel", "wrapper-2.0", "xfdesktop", "xfwm4", "box-a11y", "python3"):
# The desktop shell is visible in the screenshot already; keep the
# list for the windows the agent actually works in.
continue
seen_apps.append(app_name)
walk(app, 0, app_name)
# Chromium mirrors some nodes (omnibox rows, nested list items); one entry
# per role/name/box is enough for targeting. Then top-to-bottom,
# left-to-right reading order.
unique = {}
for e in out:
unique.setdefault((e["role"], e["name"], e["cx"], e["cy"], e["w"], e["h"]), e)
window_order = {}
for e in unique.values():
window_order.setdefault(e["window"], len(window_order))
out = sorted(unique.values(), key=lambda e: (window_order[e["window"]], e["cy"] // 12, e["cx"]))
for i, e in enumerate(out):
e["id"] = i
if args.json:
json.dump({"apps": seen_apps, "elements": out, "truncated": truncated}, sys.stdout, ensure_ascii=False)
print()
return 0
lines = []
current_window = None
for e in out:
if len(window_order) > 1 and e["window"] != current_window:
current_window = e["window"]
lines.append(f"## {current_window}")
flags = []
if e["focused"]:
flags.append("focused")
if e["checked"]:
flags.append("checked")
if e["selected"]:
flags.append("selected")
if e["disabled"]:
flags.append("disabled")
label = f'"{e["name"]}"' if e["name"] else ""
if e["value"]:
label += f' value="{e["value"]}"'
parts = [f"[{e['id']}]", e["role"]]
if label:
parts.append(label)
parts.append(f"@ ({e['cx']},{e['cy']}) {e['w']}x{e['h']}")
if flags:
parts.append("(" + ", ".join(flags) + ")")
lines.append(" ".join(parts))
if not lines:
lines.append("(no accessible elements exposed; apps: " + ", ".join(seen_apps) + ")")
if truncated:
lines.append(f"... truncated at {len(out)} elements; scroll or focus a window and snapshot again")
print("\n".join(lines))
return 0
if __name__ == "__main__":
sys.exit(main())

54
box/box-chrome Normal file → Executable file
View File

@ -3,6 +3,25 @@
# attach to the browser the human sees (Firefox profiles cannot). # attach to the browser the human sees (Firefox profiles cannot).
set -euo pipefail set -euo pipefail
export DISPLAY="${DISPLAY:-:1}" export DISPLAY="${DISPLAY:-:1}"
# Chromium reads HOME for its config/crash-report dirs; keep them on the
# persisted /home/box volume rather than in /root.
export HOME=/home/box
export XDG_CONFIG_HOME=/home/box/.config
export LANG="${LANG:-zh_TW.UTF-8}"
export LC_ALL="${LC_ALL:-zh_TW.UTF-8}"
export LANGUAGE="${LANGUAGE:-zh_TW:zh:en}"
export TZ="${TZ:-Asia/Taipei}"
export GTK_MODULES="${GTK_MODULES:-atk-bridge}"
export GTK_A11Y="${GTK_A11Y:-atspi}"
export GNOME_ACCESSIBILITY="${GNOME_ACCESSIBILITY:-1}"
export NO_AT_BRIDGE=0
# Join the desktop's session bus so Chromium registers with the same AT-SPI
# registry that box-a11y reads (launches over `docker exec` start without it).
if [[ -z "${DBUS_SESSION_BUS_ADDRESS:-}" && -r /tmp/lazyboy/dbus.env ]]; then
# shellcheck disable=SC1091
source /tmp/lazyboy/dbus.env
export DBUS_SESSION_BUS_ADDRESS
fi
PROFILE=/home/box/chrome-profile PROFILE=/home/box/chrome-profile
CDP_PORT=9222 CDP_PORT=9222
LAUNCH_LOCK=/tmp/.box-chrome-launch.lock LAUNCH_LOCK=/tmp/.box-chrome-launch.lock
@ -31,7 +50,11 @@ flock -w 30 9 || true
if profile_in_use; then if profile_in_use; then
flock -u 9 flock -u 9
exec 9>&- exec 9>&-
exec chromium --no-sandbox --user-data-dir="$PROFILE" "$@" # This invocation only forwards its URLs to the running instance, but
# Chromium treats *this* command line as the startup command line when it
# decides whether to show the "unsupported flag: --no-sandbox" infobar in
# the new tab, so --test-type must be present here too.
exec chromium --no-sandbox --test-type --user-data-dir="$PROFILE" "$@"
fi fi
# The profile lives on a volume that outlives the container. Chromium's # The profile lives on a volume that outlives the container. Chromium's
@ -42,16 +65,33 @@ fi
# stale by definition. # stale by definition.
rm -f "$PROFILE/SingletonLock" "$PROFILE/SingletonSocket" "$PROFILE/SingletonCookie" rm -f "$PROFILE/SingletonLock" "$PROFILE/SingletonSocket" "$PROFILE/SingletonCookie"
# Flags mirror lazyBoy's lazyboy-browser (the desktop that passes Shopee's
# checks): Traditional Chinese UI + Accept-Language, maximized normal window,
# no translate/crash bubbles. Never add --kiosk or --app.
chromium \ chromium \
--no-sandbox \ --no-sandbox \
--disable-dev-shm-usage \
--disable-gpu \
--no-first-run --restore-last-session \
--no-default-browser-check \
--password-store=basic \
--user-data-dir="$PROFILE" \
--remote-debugging-address=127.0.0.1 \ --remote-debugging-address=127.0.0.1 \
--remote-debugging-port="$CDP_PORT" \ --remote-debugging-port="$CDP_PORT" \
--test-type \
--disable-gpu \
--disable-dev-shm-usage \
--disable-features=TranslateUI \
--disable-blink-features=AutomationControlled \
--no-first-run \
--no-default-browser-check \
--disable-session-crashed-bubble \
--hide-crash-restore-bubble \
--disable-infobars \
--force-renderer-accessibility \
--disable-background-networking \
--disable-component-update \
--renderer-process-limit=4 \
--password-store=basic \
--lang=zh-TW \
--accept-lang=zh-TW,zh,en-US,en \
--start-maximized \
--restore-last-session \
--user-data-dir="$PROFILE" \
"$@" 9>&- & "$@" 9>&- &
pid=$! pid=$!
trap 'kill -TERM "$pid" 2>/dev/null' TERM INT trap 'kill -TERM "$pid" 2>/dev/null' TERM INT

15
box/box-terminal Executable file
View File

@ -0,0 +1,15 @@
#!/bin/bash
# Terminal on my computer: xfce4-terminal in /workspace. Plain bash keeps the
# image small; --disable-server so each window is its own process.
export LANG="${LANG:-zh_TW.UTF-8}"
export LC_ALL="${LC_ALL:-zh_TW.UTF-8}"
export LANGUAGE="${LANGUAGE:-zh_TW:zh:en}"
export TERM="${TERM:-xterm-256color}"
export COLORTERM="${COLORTERM:-truecolor}"
cd /workspace 2>/dev/null || cd /
exec xfce4-terminal \
--disable-server \
--geometry=100x28+40+40 \
--title=終端機 \
--working-directory=/workspace \
"$@"

View File

@ -1,9 +0,0 @@
[Desktop Entry]
Version=1.0
Type=Application
Name=瀏覽器
Comment=Chrome on my computer
Exec=box-chrome
Icon=web-browser
Terminal=false
Categories=Network;

41
box/fonts.conf Normal file
View File

@ -0,0 +1,41 @@
<?xml version="1.0"?>
<!DOCTYPE fontconfig SYSTEM "fonts.dtd">
<fontconfig>
<!-- System UI: jf open 粉圓 (same as lazyBoy). Terminal: DejaVu Sans Mono + Noto Sans Mono CJK TC. -->
<match target="pattern">
<test name="family"><string>粉圓</string></test>
<edit name="family" mode="assign" binding="same"><string>jf open 粉圓 2.1</string></edit>
</match>
<match target="pattern">
<test name="family"><string>jf open 粉圓</string></test>
<edit name="family" mode="assign" binding="same"><string>jf open 粉圓 2.1</string></edit>
</match>
<match target="pattern">
<test name="family"><string>Huninn</string></test>
<edit name="family" mode="assign" binding="same"><string>jf open 粉圓 2.1</string></edit>
</match>
<match target="pattern">
<test name="family"><string>sans-serif</string></test>
<edit name="family" mode="prepend" binding="strong">
<string>jf open 粉圓 2.1</string>
</edit>
</match>
<match target="pattern">
<test name="lang" compare="contains"><string>zh</string></test>
<test name="family"><string>sans-serif</string></test>
<edit name="family" mode="prepend" binding="strong">
<string>jf open 粉圓 2.1</string>
</edit>
</match>
<match target="pattern">
<test name="family"><string>monospace</string></test>
<edit name="family" mode="prepend" binding="strong">
<string>Noto Sans Mono CJK TC</string>
</edit>
<edit name="family" mode="prepend" binding="strong">
<string>DejaVu Sans Mono</string>
</edit>
</match>
</fontconfig>

5
box/gtk3-settings.ini Normal file
View File

@ -0,0 +1,5 @@
[Settings]
gtk-font-name=jf open 粉圓 2.1 12
gtk-icon-theme-name=Papirus-Dark
gtk-theme-name=Arc-Dark
gtk-application-prefer-dark-theme=1

101
box/licenses/huninn-OFL.txt Normal file
View File

@ -0,0 +1,101 @@
[Kosugi Maru]
Copyright (c) 2010 MOTOYA CO.,LTD.
[Varela Round]
Copyright (c) 2011-2016 The Varela Round Project Authors (https://github.com/alefalefalef/Varela-Round-Hebrew/), with Reserved Font Names 'Varela' and 'Varela Round'.
[jf open huninn]
Copyright (c) 2020-2024 The jf open huninn font is redistributed by justfont Co., LTD., with Reserved Font Names 'open huninn' and 'huninn'. under SIL Open Font License. The Hanzi part of this project was derived from Kosugi Maru under Apache-2.0 (https://www.apache.org/licenses/LICENSE-2.0).
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

108
box/start-desktop.sh Normal file → Executable file
View File

@ -1,14 +1,54 @@
#!/bin/bash #!/bin/bash
# Boot the box desktop: one TigerVNC X server (framebuffer + VNC in a single
# event-driven process), a minimal XFCE (xfwm4 + xfdesktop + panel, no session
# manager), AT-SPI for element-level automation, noVNC, then Chromium.
# Never a kiosk, --app, or bare HTML shell: the human and the agent share a
# normal Linux desktop.
set -euo pipefail set -euo pipefail
export DISPLAY="${DISPLAY:-:1}" export DISPLAY="${DISPLAY:-:1}"
mkdir -p /workspace /home/box/Desktop /home/box/.config/autostart /tmp # Traditional Chinese desktop + Taipei time; everything launched from here
chmod 1777 /workspace /tmp # (panel, box-chrome, terminals) inherits these.
cp -n /usr/share/lazyboy/desktop/*.desktop /home/box/Desktop/ 2>/dev/null || true export LANG="${LANG:-zh_TW.UTF-8}"
chmod +x /home/box/Desktop/*.desktop 2>/dev/null || true export LC_ALL="${LC_ALL:-zh_TW.UTF-8}"
export LANGUAGE="${LANGUAGE:-zh_TW:zh:en}"
export TZ="${TZ:-Asia/Taipei}"
# AT-SPI: GTK apps and Chromium publish their accessibility trees so the
# agent can read element names/coordinates instead of guessing pixels.
export GTK_MODULES="${GTK_MODULES:-atk-bridge}"
export GTK_A11Y="${GTK_A11Y:-atspi}"
export GNOME_ACCESSIBILITY="${GNOME_ACCESSIBILITY:-1}"
export NO_AT_BRIDGE=0
export XDG_CURRENT_DESKTOP=XFCE
export XDG_CONFIG_HOME=/home/box/.config
export XDG_CACHE_HOME=/tmp/box-cache
export XDG_RUNTIME_DIR=/tmp/box-runtime
ROOT=/tmp/lazyboy
SCREEN_GEOMETRY="${LAZYBOY_SCREEN_GEOMETRY:-1280x720}"
mkdir -p /workspace /home/box /tmp "$ROOT" "$XDG_CACHE_HOME" "$XDG_RUNTIME_DIR" /tmp/.X11-unix
chmod 1777 /workspace /tmp /tmp/.X11-unix
chmod 700 "$XDG_RUNTIME_DIR"
rm -f "$ROOT/ready"
# XFCE configuration is re-seeded from the image on every boot so the desktop
# always looks the way the image defines it; only /home/box/chrome-profile
# (Chromium) is meant to carry state across boots.
hydrate_xfce() {
local xml="$XDG_CONFIG_HOME/xfce4/xfconf/xfce-perchannel-xml"
mkdir -p "$xml" "$XDG_CONFIG_HOME/xfce4/panel" "$XDG_CONFIG_HOME/glib-2.0/settings"
local f
for f in xfce4-panel.xml xfwm4.xml xfce4-desktop.xml; do
cp -f "/usr/share/lazyboy/xfce-skel/$f" "$xml/$f"
done
cp -f /etc/xdg/xfce4/helpers.rc "$XDG_CONFIG_HOME/xfce4/helpers.rc"
}
if [[ -z "${DBUS_SESSION_BUS_ADDRESS:-}" ]]; then if [[ -z "${DBUS_SESSION_BUS_ADDRESS:-}" ]]; then
eval "$(dbus-launch --sh-syntax)" eval "$(dbus-launch --sh-syntax)"
fi fi
# Tools started later over `docker exec` (box-a11y, box-chrome) must join this
# bus to reach the AT-SPI registry that the desktop apps registered with.
printf 'DBUS_SESSION_BUS_ADDRESS=%s\n' "$DBUS_SESSION_BUS_ADDRESS" >"$ROOT/dbus.env"
display_number="${DISPLAY#:}" display_number="${DISPLAY#:}"
display_number="${display_number%%.*}" display_number="${display_number%%.*}"
@ -29,30 +69,62 @@ if ! xdpyinfo -display "$DISPLAY" >/dev/null 2>&1; then
rm -f -- "$lock_file" "/tmp/.X11-unix/X${display_number}" rm -f -- "$lock_file" "/tmp/.X11-unix/X${display_number}"
fi fi
Xvfb "$DISPLAY" -screen 0 1280x720x24 -ac +extension GLX +render -noreset >/tmp/xvfb.log 2>&1 & # Only websockify (same container) talks to VNC; keep the passwordless port
for _ in 1 2 3 4 5 6 7 8 9 10; do # off the Docker bridge so sibling containers cannot attach.
Xtigervnc "$DISPLAY" -geometry "$SCREEN_GEOMETRY" -depth 24 \
-rfbport 5900 -localhost -SecurityTypes None -AlwaysShared -AcceptCutText -SendCutText \
-desktop LazyBoy -ac -noreset >/tmp/xvnc.log 2>&1 &
for _ in $(seq 1 30); do
if xdpyinfo -display "$DISPLAY" >/dev/null 2>&1; then if xdpyinfo -display "$DISPLAY" >/dev/null 2>&1; then
break break
fi fi
sleep 0.3 sleep 0.2
done done
if ! xdpyinfo -display "$DISPLAY" >/dev/null 2>&1; then if ! xdpyinfo -display "$DISPLAY" >/dev/null 2>&1; then
cat /tmp/xvfb.log >&2 cat /tmp/xvnc.log >&2
exit 1 exit 1
fi fi
startxfce4 >/tmp/xfce.log 2>&1 & # Accessibility bus + registry. toolkit-accessibility is what Chromium and GTK
# Only websockify (same container) talks to VNC; keep the passwordless port # consult before exposing their trees over AT-SPI.
# off the Docker bridge so sibling containers cannot attach. (libvncserver for c in /usr/libexec/at-spi-bus-launcher /usr/lib/at-spi2-core/at-spi-bus-launcher; do
# still binds [::]:5900, but the container has no routable IPv6 address.) if [[ -x "$c" ]]; then
x11vnc -display "$DISPLAY" -forever -shared -nopw -rfbport 5900 -listen localhost -xkb -repeat -noxdamage >/tmp/x11vnc.log 2>&1 & "$c" --launch-immediately >/tmp/atspi-bus.log 2>&1 &
if command -v autocutsel >/dev/null 2>&1; then break
autocutsel -fork -selection CLIPBOARD >/dev/null 2>&1 || true
autocutsel -fork -selection PRIMARY >/dev/null 2>&1 || true
fi fi
websockify --web /usr/share/novnc 6080 localhost:5900 >/tmp/novnc.log 2>&1 & done
for c in /usr/libexec/at-spi2-registryd /usr/lib/at-spi2-core/at-spi2-registryd; do
if [[ -x "$c" ]]; then
"$c" >/tmp/atspi-registry.log 2>&1 &
break
fi
done
HOME=/home/box gsettings set org.gnome.desktop.interface toolkit-accessibility true 2>/tmp/gsettings.log || true
echo "[lazyboy-box] desktop up display=$DISPLAY vnc=6080 xfce chrome terminal" >/tmp/start-desktop.log hydrate_xfce
xfconfd --daemon >/tmp/xfconfd.log 2>&1 || true
sleep 0.2
xfwm4 --compositor=off --display="$DISPLAY" --sm-client-disable >/tmp/xfwm4.log 2>&1 &
sleep 0.3
xfdesktop --disable-wm-check --sm-client-disable >/tmp/xfdesktop.log 2>&1 &
xfce4-panel --disable-wm-check --sm-client-disable >/tmp/xfce4-panel.log 2>&1 &
# Xvnc keeps the VNC clipboard in sync with the X selections through vncconfig.
if command -v vncconfig >/dev/null 2>&1; then
vncconfig -nowin -display "$DISPLAY" >/tmp/vncconfig.log 2>&1 &
fi
websockify --heartbeat=30 --web /usr/share/novnc 6080 127.0.0.1:5900 >/tmp/novnc.log 2>&1 &
# Chromium: --restore-last-session brings back the previous tabs, so only the
# very first boot of a profile gets the welcome page (otherwise it doubled up).
if [[ -d /home/box/chrome-profile/Default/Sessions ]]; then
box-chrome >/tmp/lazyboy-browser.log 2>&1 &
else
box-chrome /usr/share/lazyboy/welcome.html >/tmp/lazyboy-browser.log 2>&1 &
fi
echo "[lazyboy-box] desktop up display=$DISPLAY geometry=$SCREEN_GEOMETRY vnc=6080 xfce chrome" >/tmp/start-desktop.log
touch "$ROOT/ready"
trap 'node /usr/local/bin/stop-browser.mjs || python3 /usr/local/bin/stop-browser.py; exit 0' TERM INT trap 'node /usr/local/bin/stop-browser.mjs || python3 /usr/local/bin/stop-browser.py; exit 0' TERM INT
while true; do while true; do
sleep 3600 & sleep 3600 &

View File

@ -4,15 +4,15 @@
<meta charset="utf-8"> <meta charset="utf-8">
<title>LazyBoy 的電腦</title> <title>LazyBoy 的電腦</title>
<style> <style>
body { font-family: "Noto Sans CJK TC", "Noto Sans", sans-serif; margin: 3rem; background: #111; color: #eee; } body { font-family: "jf open 粉圓 2.1", "Noto Sans CJK TC", "Noto Sans", sans-serif; margin: 3rem; background: #2f343f; color: #d3dae3; }
h1 { font-size: 1.6rem; } h1 { font-size: 1.6rem; }
code { background: #222; padding: .1rem .4rem; } code { background: #383c4a; padding: .1rem .4rem; }
.hint { color: #9f9; } .hint { color: #5294e2; }
</style> </style>
</head> </head>
<body> <body>
<h1>這是 LazyBoy 的電腦</h1> <h1>這是 LazyBoy 的電腦</h1>
<p>登入、驗證碼、付款請在這個畫面完成。帳密只在這裡輸入,對話裡看不到。</p> <p>登入、驗證碼、付款請在這個畫面完成。帳密只在這裡輸入,對話裡看不到。</p>
<p class="hint">桌面上有「瀏覽器」和「終端機」。做完後回到聊天回覆「好了」。</p> <p class="hint">下方工作列有「瀏覽器」和「終端機」。做完後回到聊天回覆「好了」。</p>
</body> </body>
</html> </html>

11
box/xfce/browser.desktop Normal file
View File

@ -0,0 +1,11 @@
[Desktop Entry]
Version=1.0
Type=Application
Name=瀏覽器
Name[zh_TW]=瀏覽器
Comment=Chromium on my computer
Exec=/usr/local/bin/box-chrome
Icon=web-browser
Terminal=false
Categories=Network;WebBrowser;GTK;
StartupNotify=true

View File

@ -0,0 +1,11 @@
[Desktop Entry]
Version=1.0
Type=X-XFCE-Helper
Icon=web-browser
Name=瀏覽器
Name[zh_TW]=瀏覽器
StartupNotify=true
X-XFCE-Binaries=box-chrome;chromium;
X-XFCE-Category=WebBrowser
X-XFCE-Commands=/usr/local/bin/box-chrome;
X-XFCE-CommandsWithParameter=/usr/local/bin/box-chrome "%s";

View File

@ -0,0 +1,11 @@
[Desktop Entry]
Version=1.0
Type=X-XFCE-Helper
Icon=utilities-terminal
Name=終端機
Name[zh_TW]=終端機
StartupNotify=true
X-XFCE-Binaries=box-terminal;xfce4-terminal;
X-XFCE-Category=TerminalEmulator
X-XFCE-Commands=/usr/local/bin/box-terminal;
X-XFCE-CommandsWithParameter=/usr/local/bin/box-terminal -e %s;

2
box/xfce/helpers.rc Normal file
View File

@ -0,0 +1,2 @@
WebBrowser=lazyboy-browser
TerminalEmulator=lazyboy-terminal

View File

@ -2,8 +2,10 @@
Version=1.0 Version=1.0
Type=Application Type=Application
Name=終端機 Name=終端機
Name[zh_TW]=終端機
Comment=Terminal on my computer Comment=Terminal on my computer
Exec=xfce4-terminal --working-directory=/workspace Exec=/usr/local/bin/box-terminal
Icon=utilities-terminal Icon=utilities-terminal
Terminal=false Terminal=false
Categories=System; Categories=System;TerminalEmulator;GTK;
StartupNotify=true

32
box/xfce/wallpaper.svg Normal file
View File

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="1920" height="1200" viewBox="0 0 1920 1200">
<defs>
<linearGradient id="base" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#383c4a"/>
<stop offset="0.55" stop-color="#2f343f"/>
<stop offset="1" stop-color="#1b1e24"/>
</linearGradient>
<radialGradient id="glowBlue" cx="0.78" cy="0.22" r="0.55">
<stop offset="0" stop-color="#5294e2" stop-opacity="0.38"/>
<stop offset="0.5" stop-color="#5294e2" stop-opacity="0.10"/>
<stop offset="1" stop-color="#5294e2" stop-opacity="0"/>
</radialGradient>
<radialGradient id="glowTeal" cx="0.12" cy="0.88" r="0.5">
<stop offset="0" stop-color="#4dd0c4" stop-opacity="0.22"/>
<stop offset="0.6" stop-color="#4dd0c4" stop-opacity="0.05"/>
<stop offset="1" stop-color="#4dd0c4" stop-opacity="0"/>
</radialGradient>
</defs>
<rect width="1920" height="1200" fill="url(#base)"/>
<rect width="1920" height="1200" fill="url(#glowBlue)"/>
<rect width="1920" height="1200" fill="url(#glowTeal)"/>
<g fill="none" stroke="#5294e2" stroke-opacity="0.16" stroke-width="2">
<circle cx="1500" cy="260" r="220"/>
<circle cx="1500" cy="260" r="340"/>
<circle cx="1500" cy="260" r="470"/>
</g>
<g fill="none" stroke="#ffffff" stroke-opacity="0.05" stroke-width="1.5">
<path d="M0 900 C 400 780, 800 1020, 1200 880 S 1700 760, 1920 840"/>
<path d="M0 980 C 420 860, 820 1100, 1220 960 S 1720 840, 1920 920"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1,53 @@
<?xml version="1.0" encoding="UTF-8"?>
<channel name="xfce4-desktop" version="1.0">
<property name="desktop-icons" type="empty">
<property name="style" type="int" value="0"/>
<property name="file-icons" type="empty">
<property name="show-home" type="bool" value="false"/>
<property name="show-filesystem" type="bool" value="false"/>
<property name="show-trash" type="bool" value="false"/>
<property name="show-removable" type="bool" value="false"/>
</property>
</property>
<property name="desktop-menu" type="empty">
<property name="show" type="bool" value="true"/>
<property name="show-icons" type="bool" value="true"/>
</property>
<property name="windowlist-menu" type="empty">
<property name="show" type="bool" value="true"/>
<property name="show-icons" type="bool" value="true"/>
<property name="show-workspace-names" type="bool" value="false"/>
</property>
<!-- Xvfb's RANDR output is named "screen", so xfdesktop reads monitorscreen;
monitor0 covers displays that report a numbered output. -->
<property name="backdrop" type="empty">
<property name="screen0" type="empty">
<property name="monitorscreen" type="empty">
<property name="workspace0" type="empty">
<property name="color-style" type="int" value="0"/>
<property name="image-style" type="int" value="5"/>
<property name="last-image" type="string" value="/usr/share/lazyboy/wallpaper.svg"/>
<property name="rgba1" type="array">
<value type="double" value="0.184314"/>
<value type="double" value="0.203922"/>
<value type="double" value="0.247059"/>
<value type="double" value="1"/>
</property>
</property>
</property>
<property name="monitor0" type="empty">
<property name="workspace0" type="empty">
<property name="color-style" type="int" value="0"/>
<property name="image-style" type="int" value="5"/>
<property name="last-image" type="string" value="/usr/share/lazyboy/wallpaper.svg"/>
<property name="rgba1" type="array">
<value type="double" value="0.184314"/>
<value type="double" value="0.203922"/>
<value type="double" value="0.247059"/>
<value type="double" value="1"/>
</property>
</property>
</property>
</property>
</property>
</channel>

66
box/xfce/xfce4-panel.xml Normal file
View File

@ -0,0 +1,66 @@
<?xml version="1.0" encoding="UTF-8"?>
<channel name="xfce4-panel" version="1.0">
<property name="configver" type="int" value="2"/>
<property name="panels" type="array">
<value type="int" value="1"/>
<property name="dark-mode" type="bool" value="true"/>
<property name="panel-1" type="empty">
<!-- Bottom edge of the 1280x720 desktop; the box runtime reads the real
geometry at runtime so this only has to be the initial placement. -->
<property name="position" type="string" value="p=8;x=640;y=702"/>
<property name="length" type="uint" value="100"/>
<property name="position-locked" type="bool" value="true"/>
<property name="size" type="uint" value="36"/>
<property name="icon-size" type="uint" value="22"/>
<property name="background-style" type="uint" value="0"/>
<property name="autohide-behavior" type="uint" value="0"/>
<property name="mode" type="uint" value="0"/>
<property name="plugin-ids" type="array">
<value type="int" value="1"/>
<value type="int" value="6"/>
<value type="int" value="7"/>
<value type="int" value="2"/>
<value type="int" value="3"/>
<value type="int" value="4"/>
<value type="int" value="5"/>
</property>
</property>
</property>
<property name="plugins" type="empty">
<property name="plugin-1" type="string" value="applicationsmenu">
<property name="show-generic-names" type="bool" value="false"/>
<property name="show-button-icon" type="bool" value="true"/>
<property name="show-button-title" type="bool" value="true"/>
<property name="button-title" type="string" value="應用程式"/>
</property>
<property name="plugin-6" type="string" value="launcher">
<property name="items" type="array">
<value type="string" value="lazyboy-browser.desktop"/>
</property>
</property>
<property name="plugin-7" type="string" value="launcher">
<property name="items" type="array">
<value type="string" value="lazyboy-terminal.desktop"/>
</property>
</property>
<property name="plugin-2" type="string" value="tasklist">
<property name="show-handle" type="bool" value="false"/>
<property name="show-labels" type="bool" value="true"/>
<property name="flat-buttons" type="bool" value="true"/>
<property name="grouping" type="uint" value="0"/>
<property name="include-all-workspaces" type="bool" value="true"/>
</property>
<property name="plugin-3" type="string" value="separator">
<property name="expand" type="bool" value="true"/>
<property name="style" type="uint" value="0"/>
</property>
<property name="plugin-4" type="string" value="systray"/>
<property name="plugin-5" type="string" value="clock">
<property name="mode" type="uint" value="2"/>
<property name="digital-format" type="string" value="%H:%M"/>
<property name="digital-layout" type="uint" value="3"/>
<property name="digital-time-format" type="string" value="%H:%M"/>
<property name="digital-date-format" type="string" value="%m/%d"/>
</property>
</property>
</channel>

20
box/xfce/xfwm4.xml Normal file
View File

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<channel name="xfwm4" version="1.0">
<property name="general" type="empty">
<property name="use_compositing" type="bool" value="false"/>
<property name="workspace_count" type="int" value="1"/>
<property name="wrap_windows" type="bool" value="false"/>
<property name="wrap_workspaces" type="bool" value="false"/>
<property name="click_to_focus" type="bool" value="true"/>
<property name="focus_new" type="bool" value="true"/>
<property name="raise_on_click" type="bool" value="true"/>
<property name="box_move" type="bool" value="false"/>
<property name="box_resize" type="bool" value="false"/>
<property name="snap_to_border" type="bool" value="true"/>
<property name="snap_to_windows" type="bool" value="true"/>
<property name="title_font" type="string" value="jf open 粉圓 2.1 11"/>
<property name="button_layout" type="string" value="O|HMC"/>
<property name="theme" type="string" value="Arc-Dark"/>
<property name="double_click_action" type="string" value="maximize"/>
</property>
</channel>

View File

@ -21,7 +21,7 @@ const SESSION_WORKSPACE_VOLUME: &str = "grokboy-box-workspace";
const SESSION_HOME_VOLUME: &str = "grokboy-box-home"; const SESSION_HOME_VOLUME: &str = "grokboy-box-home";
const SESSION_VIEWER_PORT: u16 = 6080; const SESSION_VIEWER_PORT: u16 = 6080;
pub const SESSION_SEAT: &str = "session"; pub const SESSION_SEAT: &str = "session";
const BOX_REVISION: &str = "computer-use-5"; const BOX_REVISION: &str = "computer-use-7";
const BROWSER_PROFILE: &str = "/home/box/chrome-profile"; const BROWSER_PROFILE: &str = "/home/box/chrome-profile";
/// Per-stream cap on shell output returned to the model; the middle is elided. /// Per-stream cap on shell output returned to the model; the middle is elided.
const JOB_OUTPUT_HEAD_CHARS: usize = 16_000; const JOB_OUTPUT_HEAD_CHARS: usize = 16_000;
@ -627,19 +627,20 @@ print(json.dumps({{'path': str(p), 'offset': start+1, 'lines': len(lines), 'cont
validate_bounds(actions, screen)?; validate_bounds(actions, screen)?;
let names: Vec<&str> = actions.iter().map(ComputerAction::name).collect(); let names: Vec<&str> = actions.iter().map(ComputerAction::name).collect();
let shot = ShotFile::new(); let shot = ShotFile::new();
let mut snapshot = None;
for (index, action) in actions.iter().enumerate() { for (index, action) in actions.iter().enumerate() {
let settle = settle_before(actions, index); let settle = settle_before(actions, index);
if settle > 0 { if settle > 0 {
tokio::time::sleep(Duration::from_millis(u64::from(settle))).await; tokio::time::sleep(Duration::from_millis(u64::from(settle))).await;
} }
self.run_computer_action(action, &shot).await?; self.run_computer_action(action, &shot, screen, &mut snapshot).await?;
} }
if !actions.iter().any(ComputerAction::is_screenshot) { if !actions.iter().any(ComputerAction::is_screenshot) {
self.capture_root_png(&shot).await?; self.capture_root_png(&shot).await?;
} }
shot.fetch(self.container(), dest).await?; shot.fetch(self.container(), dest).await?;
let cursor = self.cursor_position().await.ok(); let cursor = self.cursor_position().await.ok();
Ok(json!({ let mut payload = json!({
"ok": true, "ok": true,
"actions": names, "actions": names,
"path": dest, "path": dest,
@ -647,13 +648,53 @@ print(json.dumps({{'path': str(p), 'offset': start+1, 'lines': len(lines), 'cont
"cursor_position": cursor, "cursor_position": cursor,
"screen": {"width": screen.width, "height": screen.height}, "screen": {"width": screen.width, "height": screen.height},
"instruction": "Computer action ran on my computer. Read this screenshot before the next action. A batched then sequence returns only this final screen." "instruction": "Computer action ran on my computer. Read this screenshot before the next action. A batched then sequence returns only this final screen."
})) });
if let Some(text) = snapshot {
payload["snapshot"] = json!(text);
payload["instruction"] = json!(
"Computer action ran on my computer. `snapshot` lists the on-screen elements as `[id] role \"name\" @ (x,y) WxH`; click the (x,y) centre of the element you mean. Read the screenshot too before the next action."
);
}
Ok(payload)
} }
async fn run_computer_action(&self, action: &ComputerAction, shot: &ShotFile) -> Result<()> { async fn run_computer_action(
&self,
action: &ComputerAction,
shot: &ShotFile,
screen: ScreenSize,
snapshot: &mut Option<String>,
) -> Result<()> {
for step in steps_for(action)? { for step in steps_for(action)? {
match step { match step {
ComputerStep::Screenshot => self.capture_root_png(shot).await?, ComputerStep::Screenshot => self.capture_root_png(shot).await?,
ComputerStep::A11ySnapshot => {
let width = screen.width.to_string();
let height = screen.height.to_string();
let out = self
.exec_env(
&[
("DISPLAY", ":1"),
("LAZYBOY_SCREEN_W", width.as_str()),
("LAZYBOY_SCREEN_H", height.as_str()),
],
&["box-a11y"],
)
.await?;
if out.status != 0 {
let detail = out.stderr.trim();
if detail.contains("not found") {
return Err(anyhow!(
"My computer image is missing box-a11y. Rebuild the box image and retry."
));
}
return Err(anyhow!(
"computer snapshot failed: {}",
if detail.is_empty() { out.stdout.trim() } else { detail }
));
}
*snapshot = Some(out.stdout.trim_end().to_string());
}
ComputerStep::SleepMs(0) => {} ComputerStep::SleepMs(0) => {}
ComputerStep::SleepMs(ms) => { ComputerStep::SleepMs(ms) => {
let secs = format!("{:.3}", f64::from(ms) / 1000.0); let secs = format!("{:.3}", f64::from(ms) / 1000.0);

View File

@ -1,5 +1,6 @@
//! Grok Bot Computer tool: screenshot / click / move / drag / type / key / scroll / wait. //! Grok Bot Computer tool: screenshot / snapshot / click / move / drag / type / key / scroll / wait.
//! Parent agents do not get this tool. A `computerUse` subagent drives the box via xdotool. //! Parent agents do not get this tool. A `computerUse` subagent drives the box via xdotool;
//! `snapshot` reads the AT-SPI accessibility tree (element names + pixel centres) via box-a11y.
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};
use serde_json::{json, Value}; use serde_json::{json, Value};
@ -98,6 +99,8 @@ impl ScrollDir {
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum ComputerAction { pub enum ComputerAction {
Screenshot, Screenshot,
/// Accessibility-tree listing of on-screen elements with clickable centres.
Snapshot,
Click { Click {
x: Option<i32>, x: Option<i32>,
y: Option<i32>, y: Option<i32>,
@ -133,6 +136,7 @@ impl ComputerAction {
pub fn name(&self) -> &'static str { pub fn name(&self) -> &'static str {
match self { match self {
Self::Screenshot => "screenshot", Self::Screenshot => "screenshot",
Self::Snapshot => "snapshot",
Self::Click { .. } => "click", Self::Click { .. } => "click",
Self::Move { .. } => "move", Self::Move { .. } => "move",
Self::Drag { .. } => "drag", Self::Drag { .. } => "drag",
@ -148,7 +152,7 @@ impl ComputerAction {
} }
pub fn needs_screenshot_settle(&self) -> bool { pub fn needs_screenshot_settle(&self) -> bool {
!matches!(self, Self::Screenshot | Self::Wait { .. }) !matches!(self, Self::Screenshot | Self::Snapshot | Self::Wait { .. })
} }
fn points(&self) -> Vec<(i32, i32)> { fn points(&self) -> Vec<(i32, i32)> {
@ -232,11 +236,13 @@ pub enum ComputerStep {
Xdotool(Vec<String>), Xdotool(Vec<String>),
SleepMs(u32), SleepMs(u32),
Screenshot, Screenshot,
A11ySnapshot,
} }
pub fn steps_for(action: &ComputerAction) -> Result<Vec<ComputerStep>> { pub fn steps_for(action: &ComputerAction) -> Result<Vec<ComputerStep>> {
match action { match action {
ComputerAction::Screenshot => Ok(vec![ComputerStep::Screenshot]), ComputerAction::Screenshot => Ok(vec![ComputerStep::Screenshot]),
ComputerAction::Snapshot => Ok(vec![ComputerStep::A11ySnapshot]),
ComputerAction::Wait { duration_ms } => Ok(vec![ComputerStep::SleepMs(*duration_ms)]), ComputerAction::Wait { duration_ms } => Ok(vec![ComputerStep::SleepMs(*duration_ms)]),
ComputerAction::Type { text } if text.is_empty() => Ok(vec![]), ComputerAction::Type { text } if text.is_empty() => Ok(vec![]),
ComputerAction::Type { text } => Ok(vec![ComputerStep::Xdotool(vec![ ComputerAction::Type { text } => Ok(vec![ComputerStep::Xdotool(vec![
@ -316,7 +322,8 @@ pub fn tool_definition() -> Value {
let action_fields = json!({ let action_fields = json!({
"action": { "action": {
"type": "string", "type": "string",
"enum": ["screenshot", "click", "move", "drag", "type", "key", "scroll", "wait"] "enum": ["screenshot", "snapshot", "click", "move", "drag", "type", "key", "scroll", "wait"],
"description": "snapshot lists the on-screen UI elements (role, name, value, centre x/y) from the accessibility tree; click those centres instead of estimating pixels from the image."
}, },
"x": {"type": "integer", "description": "Desktop pixel X (origin top-left)"}, "x": {"type": "integer", "description": "Desktop pixel X (origin top-left)"},
"y": {"type": "integer", "description": "Desktop pixel Y"}, "y": {"type": "integer", "description": "Desktop pixel Y"},
@ -365,7 +372,7 @@ pub fn tool_definition() -> Value {
"type": "function", "type": "function",
"function": { "function": {
"name": "computer", "name": "computer",
"description": "Drive MY computer's desktop (screenshot, click, move, drag, type, key, scroll, wait). Only a computerUse subagent may call this. See-act-verify: read the screenshot returned after this call before the next one. `then` batches up to 9 follow-ups that need no intermediate check; a screenshot is always captured at the end. Coordinates are desktop pixels. Do not type passwords; if a human is needed, stop and report so the parent can call request_box_help.", "description": "Drive MY computer's desktop (screenshot, snapshot, click, move, drag, type, key, scroll, wait). Only a computerUse subagent may call this. Start with snapshot: it returns the visible elements with exact centre coordinates (plus a screenshot), so clicks land on the element, not a guessed pixel. See-act-verify: read the screenshot returned after this call before the next one. `then` batches up to 9 follow-ups that need no intermediate check; a screenshot is always captured at the end. Coordinates are desktop pixels. Do not type passwords; if a human is needed, stop and report so the parent can call request_box_help.",
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": primary, "properties": primary,
@ -389,6 +396,7 @@ fn parse_action(args: &Value, follow_up: bool) -> Result<ComputerAction> {
} }
match name { match name {
"screenshot" => Ok(ComputerAction::Screenshot), "screenshot" => Ok(ComputerAction::Screenshot),
"snapshot" | "a11y" | "elements" => Ok(ComputerAction::Snapshot),
"click" => { "click" => {
let (x, y) = optional_point(args, "click")?; let (x, y) = optional_point(args, "click")?;
Ok(ComputerAction::Click { Ok(ComputerAction::Click {
@ -436,7 +444,7 @@ fn parse_action(args: &Value, follow_up: bool) -> Result<ComputerAction> {
duration_ms: bounded_u32(args.get("durationMs"), 1_000, 0, MAX_WAIT_MS, "durationMs")?, duration_ms: bounded_u32(args.get("durationMs"), 1_000, 0, MAX_WAIT_MS, "durationMs")?,
}), }),
other => Err(anyhow!( other => Err(anyhow!(
"unknown computer action {other}; use screenshot, click, move, drag, type, key, scroll, or wait" "unknown computer action {other}; use screenshot, snapshot, click, move, drag, type, key, scroll, or wait"
)), )),
} }
} }
@ -634,6 +642,15 @@ mod tests {
assert!(seq[2].is_screenshot()); assert!(seq[2].is_screenshot());
} }
#[test]
fn snapshot_reads_the_tree_then_screenshots_without_settling() {
let seq = parse_computer_call(&json!({"action": "snapshot"})).unwrap();
assert_eq!(seq, vec![ComputerAction::Snapshot, ComputerAction::Screenshot]);
assert_eq!(settle_before(&seq, 1), 0);
assert_eq!(steps_for(&seq[0]).unwrap(), vec![ComputerStep::A11ySnapshot]);
assert_eq!(parse_computer_call(&json!({"action": "elements"})).unwrap()[0], ComputerAction::Snapshot);
}
#[test] #[test]
fn screenshot_only_does_not_duplicate() { fn screenshot_only_does_not_duplicate() {
let seq = parse_computer_call(&json!({"action": "screenshot"})).unwrap(); let seq = parse_computer_call(&json!({"action": "screenshot"})).unwrap();

View File

@ -20,9 +20,10 @@ pub const COMPUTER_USE_SYSTEM: &str = "\
OVERRIDE: you are a computerUse subagent. You DO have the computer tool and you SHOULD click, type, scroll, and key on MY computer's desktop. The parent restriction (\"screenshot is read-only\") does not apply to you. OVERRIDE: you are a computerUse subagent. You DO have the computer tool and you SHOULD click, type, scroll, and key on MY computer's desktop. The parent restriction (\"screenshot is read-only\") does not apply to you.
## Computer ## Computer
Drive this box's desktop with computer (screenshot, click, move, drag, type, key, scroll, wait): GUI apps, file dialogs, drag, and sites that defeat page-level automation. Drive this box's desktop with computer (screenshot, snapshot, click, move, drag, type, key, scroll, wait): GUI apps, file dialogs, drag, and sites that defeat page-level automation.
- Stay inside the task you were handed. Do that step and its success criteria, then stop. If it is bigger or more ambiguous than scoped, stop and report what you found. - Stay inside the task you were handed. Do that step and its success criteria, then stop. If it is bigger or more ambiguous than scoped, stop and report what you found.
- See-act-verify: screenshot to see the real state, act, then read the one fresh screenshot returned after the entire computer call before the next one. A batched `then` sequence returns only its final screen, so batch only steps that need no intermediate check. Never fire actions blind off a remembered layout. - Start with `snapshot`, not a bare screenshot: it lists every visible element as `[id] role \"name\" @ (x,y) WxH` (buttons, links, textboxes with their current value, tabs, menus) straight from the app's accessibility tree, plus the screenshot. Click the listed (x,y) centre; only estimate pixels from the image for things the list does not show (canvas, images, custom widgets). Re-snapshot after the page changes.
- See-act-verify: snapshot/screenshot to see the real state, act, then read the one fresh screenshot returned after the entire computer call before the next one. A batched `then` sequence returns only its final screen, so batch only steps that need no intermediate check. Never fire actions blind off a remembered layout.
- If the screen is mid-load, wait and re-screenshot rather than clicking a moving target. If a click missed, study the new screenshot and re-target; never type into a field you have not just focused. - If the screen is mid-load, wait and re-screenshot rather than clicking a moving target. If a click missed, study the new screenshot and re-target; never type into a field you have not just focused.
- Before typing into a field that may already hold text, key ctrl+a then BackSpace. Open Chrome with shell `box-chrome 'https://example.com'` or `box-chrome --new-window`, then confirm with one computer screenshot. Never launch another browser. - Before typing into a field that may already hold text, key ctrl+a then BackSpace. Open Chrome with shell `box-chrome 'https://example.com'` or `box-chrome --new-window`, then confirm with one computer screenshot. Never launch another browser.
- Your desktop is display :1 the display computer screenshots and clicks and Chromium's CDP is http://127.0.0.1:9222. Those are given facts. Do not probe other displays or ports. - Your desktop is display :1 the display computer screenshots and clicks and Chromium's CDP is http://127.0.0.1:9222. Those are given facts. Do not probe other displays or ports.

View File

@ -60,6 +60,15 @@ Gateway 需要提供 JSON Connect `POST /aiserver.v1.AiService/RunWebSearch`s
本次驗證:一般 Rust 測試 120 項通過;另以實際 Docker 通過 `computer_use_click_and_screenshot_on_box`xdotool click + screenshot。Clippy 通過。Box 映像 revision `computer-use-2` 含 xdotool 與 procps`box-chrome` 以啟動鎖序列化 Chromium 冷啟動並清除跨 container 殘留的 `SingletonLock`x11vnc 只監聽 localhost。 本次驗證:一般 Rust 測試 120 項通過;另以實際 Docker 通過 `computer_use_click_and_screenshot_on_box`xdotool click + screenshot。Clippy 通過。Box 映像 revision `computer-use-2` 含 xdotool 與 procps`box-chrome` 以啟動鎖序列化 Chromium 冷啟動並清除跨 container 殘留的 `SingletonLock`x11vnc 只監聽 localhost。
Box 映像 revision `computer-use-6`:對齊 lazyBoy `image/computer` 的瀏覽器環境,避免蝦皮等站直接判定為機器人。容器與 `box-chrome``LANG/LC_ALL=zh_TW.UTF-8`、`LANGUAGE=zh_TW:zh:en`、`TZ=Asia/Taipei``locale-gen` zh_TW/en_US、`/etc/localtime` 指向台北);字型補 `fonts-liberation`、`fonts-dejavu-core`、`fonts-noto-color-emoji`Chromium 旗標改用 lazyBoy 的 `lazyboy-browser` 組合(`--lang=zh-TW --accept-lang=zh-TW,zh,en-US,en --start-maximized --test-type --disable-features=TranslateUI --disable-session-crashed-bubble --hide-crash-restore-bubble --disable-infobars`),另加 `--disable-blink-features=AutomationControlled``HOME=/home/box` 讓 Chromium 設定與 Crash Reports 留在 volume。
Box 映像 revision `computer-use-7`:桌面改成 lazyBoy `image/computer` 的樣子並瘦身、加上元件級操控。
- 顯示層:一個 `Xtigervnc :1`framebuffer + VNC 同一程序、事件驅動)取代 `Xvfb + x11vnc + autocutsel`;剪貼簿由 `vncconfig -nowin` 同步。noVNC/websockify、xdotool、`import` 截圖與 `DESKTOP_PROBE`WM、5900、6080不變解析度仍 1280x720可用 `LAZYBOY_SCREEN_GEOMETRY` 覆寫)。
- 桌面:不再跑 `startxfce4`xfce4-sessionxfsettingsdThunar自動開終端機都拿掉只起 `xfconfd + xfwm4 --compositor=off + xfdesktop + xfce4-panel`。外觀沿用 lazyBoyArc-Dark、Papirus-Dark、jf open 粉圓、底部中文面板應用程式瀏覽器終端機啟動器工作列時鐘、同一張桌布XFCE 設定每次開機從 `/usr/share/lazyboy/xfce-skel` 重灌到 `/home/box/.config`
- Chromium只在 profile 第一次啟動時開 welcome 頁(之前 `--restore-last-session` 加 URL 會出現兩個相同分頁);另加 `--force-renderer-accessibility --disable-background-networking --disable-component-update --renderer-process-limit=4`
- 元件級操控:`box-a11y`python3 + AT-SPI列出畫面上元件的角色名稱中心座標`computer` 工具新增 `snapshot` 動作(`ComputerStep::A11ySnapshot`),回傳 `snapshot` 文字並照常附截圖。computerUse 提示改為先 `snapshot` 再點列出的座標。`docker exec` 啟動的程序透過 `/tmp/lazyboy/dbus.env` 加入桌面的 session bus 才能看到同一個 AT-SPI registry`gsettings toolkit-accessibility=true`dconf 在 `/home/box/.config`)讓 Chromium 願意輸出無障礙樹。
- 量測桌面Chromium 閒置約 400 MiB原 486 MiB`box-a11y` 對一個網頁約 0.2 秒。
Box 執行期行為:截圖前會等 `SCREENSHOT_SETTLE_MS` 讓桌面重繪;每次截圖使用獨立的暫存檔,父 agent 的 `screenshot` 與 computerUse 子 agent 不會互相讀到對方的畫面。`shell` 輸出以 UTF-8 容錯讀取,超過上限時保留頭尾並在 `full_output` 給出可用 `read` 讀取的 `/tmp/gb-jobs/<id>/` 完整路徑。多個程序CLI、browser helper、測試同時初始化 box 時,以主機端 flock 序列化映像建置與 container 重建。 Box 執行期行為:截圖前會等 `SCREENSHOT_SETTLE_MS` 讓桌面重繪;每次截圖使用獨立的暫存檔,父 agent 的 `screenshot` 與 computerUse 子 agent 不會互相讀到對方的畫面。`shell` 輸出以 UTF-8 容錯讀取,超過上限時保留頭尾並在 `full_output` 給出可用 `read` 讀取的 `/tmp/gb-jobs/<id>/` 完整路徑。多個程序CLI、browser helper、測試同時初始化 box 時,以主機端 flock 序列化映像建置與 container 重建。
一般工具名 `shell``read``await_shell` 一律指向 box`box_shell``box_read``box_await` 保留為舊 box 呼叫的別名。本地檔案與命令工具全部改成 `external_*`;舊模糊名稱如 `exec_command``read_file` 會報錯不會隱含操作本地。Box 命令等待時間到後保留執行,以 `await_shell` 收取結果。關閉桌面瀏覽器不會阻止 shell/read 工作,下一次 browser 工具會重開原 profile。 一般工具名 `shell``read``await_shell` 一律指向 box`box_shell``box_read``box_await` 保留為舊 box 呼叫的別名。本地檔案與命令工具全部改成 `external_*`;舊模糊名稱如 `exec_command``read_file` 會報錯不會隱含操作本地。Box 命令等待時間到後保留執行,以 `await_shell` 收取結果。關閉桌面瀏覽器不會阻止 shell/read 工作,下一次 browser 工具會重開原 profile。