verifier-gui
GitHub用于驱动 Silo 桌面应用进行运行时验证,通过 RPC 桥接执行命令、评估 DOM、截图及管理工作区。严格在沙盒环境中操作以隔离真实数据,支持启动或附加到已运行实例,捕获 GUI 证据供 verify skill 判定。
触发场景
安装
npx skills add silo-code/silo --skill verifier-gui -g -y
SKILL.md
Frontmatter
{
"name": "verifier-gui",
"tools": "Bash, Read",
"description": "Launch (or attach to) the Silo dev app and drive it for runtime verification through the dev automation RPC bridge — exec commands, eval DOM, capture screenshots, and create\/activate\/delete workspaces, terminals, editors. This is the repo's GUI evidence-capture handle for the `verify` skill. Use when verifying a change by running the real app and observing it. Always works inside a throwaway sandbox workspace, never the user's real workspaces."
}
Silo GUI Verifier
The handle the verify skill looks for: how to get the running Silo app under
control and capture evidence from it. Silo is a Tauri desktop app; its surface is
pixels + a dev-only RPC bridge. This skill drives that bridge.
It does not judge. It launches, drives, captures. The verdict is verify's.
Golden rule 1: verify in a sandbox workspace, never the user's
The app may be the user's live session with real workspaces and terminals. Do
all verification in a workspace you create from a temp dir, and delete it when
done. Never openTerminal/deleteWorkspace/openFile against an existing
workspace — you'd pollute or destroy real state. Create → activate → verify →
delete. This also makes destructive paths (workspace delete, session kill) safe
to exercise.
Golden rule 2: one turn, not one op per turn
The wall-clock cost here is agent turns, not the RPC bridge — each bridge call
is ~milliseconds on localhost, but every separate Bash tool call is a full model
round-trip (seconds). So issue a whole drive + capture sequence as a single
Bash call. The silo() helper is just curl; bash variables (WS_ID,
WS_DIR) persist within one invocation, so create → activate → drive →
screenshot → decode all belong in one block (see §2). A 6-step flow then costs
2 turns, not 7.
Only split into a separate turn when you genuinely must:
- The final
Read /tmp/silo.png—Readis its own tool, so capture-in-one-turn then read-in-the-next is the floor (2 turns). - Branching on an observed result — if the next op depends on what you saw (a count, a tab list, a pass/fail), end the block, read the output, then decide. A fixed setup sequence has no such dependency — never split it.
Echo any state you'll need next turn (e.g. echo "WS_ID=$WS_ID") — bash vars die
at the end of the Bash call.
1. Get the app up (attach or launch)
The bridge listens on 127.0.0.1:7878 (dev builds only — app:dev is built
--features automation). Define the request helper first — the contract is
strict: header X-Silo-Automation: 1 and a loopback Host, POST /, body
{"op", "args"}.
silo(){ curl -s -m30 -X POST http://127.0.0.1:7878/ \
-H 'X-Silo-Automation: 1' -H 'Content-Type: application/json' \
--data "$1"; }
Attach if it's already running, else launch:
if [ "$(silo '{"op":"ping"}')" = '{"ok":true,"result":"pong"}' ]; then
echo "attached to running dev app"
else
pnpm dev >/tmp/silo-appdev.log 2>&1 & # first run compiles Rust — slow
for i in $(seq 1 120); do # poll up to ~4 min
sleep 2
[ "$(silo '{"op":"ping"}' 2>/dev/null)" = '{"ok":true,"result":"pong"}' ] && break
done
fi
app:dev runs under the isolated "Silo Dev" identity (separate app data), so
launching never touches the user's real Silo install — but if you attached to
an already-running instance, the sandbox rule above still applies.
If you attached (ping succeeded on the first try), verify it's actually serving
your code before doing anything else. A pong only proves some dev app is
listening on 7878 — it can just as easily be a stale instance from a different
checkout, a different branch, or a session someone else started hours ago, with
none of the changes you're here to verify. Confirm the process identity, not just
liveness:
ps aux | grep "target/debug/silo\b" | grep -v grep
# expect the binary path to start with YOUR working directory, e.g.:
# /path/to/your/repo/apps/desktop/src-tauri/target/debug/silo
# a path pointing anywhere else means you're about to verify the wrong code —
# stop and either ask the user to close it or launch your own (a port conflict
# will make that obvious rather than silently reusing the wrong instance)
This check is cheap (one ps) against the cost of it going wrong: driving and
debugging against the wrong checkout produces confident-looking PASS/FAIL results
for code you didn't touch, and any oddity you hit sends you chasing a phantom bug
in your own change instead of noticing the mismatch.
2. Drive & capture — one block, one turn
Per golden rule 2, do the whole sandbox setup, drive steps, and screenshot in a
single Bash call. Bash variables persist within the invocation, so the
workspace id flows from one op to the next with no agent round-trip. End the block
with the screenshot + decode; the only follow-up turn is Read /tmp/silo.png.
# ── ONE Bash call = ONE turn ──────────────────────────────────────────────
WS_DIR=$(mktemp -d /tmp/silo-verify.XXXXXX)
WS_ID=$(silo "{\"op\":\"openWorkspace\",\"args\":{\"folder\":\"$WS_DIR\",\"name\":\"verify-sandbox\"}}" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["result"]["id"])')
silo "{\"op\":\"activateWorkspace\",\"args\":{\"id\":\"$WS_ID\"}}"
# ── drive steps (add as many as the check needs) ──
silo '{"op":"exec","args":{"command":"core.newTerminal"}}'
# silo "{\"op\":\"openFile\",\"args\":{\"path\":\"$WS_DIR/README.md\"}}"
# silo '{"op":"eval","args":{"expr":"document.querySelectorAll(\".xterm\").length"}}'
# ── capture as the LAST step in the same block ──
silo '{"op":"screenshot"}' > /tmp/shot.json
python3 -c "import json,base64;d=json.load(open('/tmp/shot.json'));r=d['result'];open('/tmp/silo.png','wb').write(base64.b64decode(r['png_base64']));print('shot',r['width'],r['height'])"
echo "WS_ID=$WS_ID" # surface state needed for next-turn cleanup
Next turn: Read /tmp/silo.png. The capture can be slow (a few seconds),
especially the first call — keep the timeout ≥30s and retry once if it returns
empty. No OS permission setup is required.
A single-folder workspace also means core.newTerminal won't pop the folder
picker (which automation can't click) — it resolves the lone folder directly.
Reading evidence without a screenshot — for structure/counts, an inline eval
in the same block is cheaper than a picture: document.querySelectorAll('.xterm').length
(terminal count), [...document.querySelectorAll('.dv-tab')].map(t=>t.textContent)
(open tabs), document.body.innerText.includes('Session ended') (spawn failure).
WebGL caveat: terminals render to a canvas (WebGL addon) — .xterm has
no DOM text, so to read what a shell printed you need a screenshot, not
textContent.
Op catalog
exec runs a registered command (the real ctx path); eval runs JS in the
webview global scope (note: app modules like store are not in scope — use
the dedicated ops for state). The full, authoritative list is the switch (op)
in apps/desktop/src/automation/bridge.ts — read it when in doubt; this table
mirrors it. eval is the escape hatch, but for Monaco state prefer the dedicated
editor ops below — monaco is not a page global, so eval can't reach it.
Core / liveness
| Op | Args | Returns / use |
|---|---|---|
ping |
— | pong — liveness |
exec |
command |
run a command id (menu/keybinding dispatch) → {ran} |
eval |
expr |
evaluate JS in the page; awaits a returned promise |
screenshot |
— | host-side window capture → {png_base64,width,height} |
Workspaces / panels (sandbox only — never point at real workspaces)
| Op | Args | Returns / use |
|---|---|---|
listWorkspaces |
— | {active, workspaces[{id,name,folder}]} |
openWorkspace |
folder,name |
create + activate (use a temp dir) → {id} |
activateWorkspace |
id |
switch active → {active} |
addFolder |
id,folder |
attach a folder, bypassing the native OS picker ("Add Folder…" in Workspace Properties) → {extraFolders} |
closeWorkspace |
id |
close (sets closedAt, doesn't remove) → {closed,active} |
deleteWorkspace |
id |
reap terminals + remove → {deleted,active} |
splitActivePanel |
position? (l/r/top/bottom) |
split center group → {groups} |
activatePanel |
panelId |
focus a dock panel → {activated} |
showSidePanel |
id |
expand slot + activate its tab → {shown,slot} |
Editors / terminals
| Op | Args | Returns / use |
|---|---|---|
openFile |
path |
open an editor tab → {editorId,panelId} |
openDiff |
path,providerId,args?,title?,preview? |
open a diff tab via a content provider → {diffId,panelId} |
listEditors |
workspaceId? |
{previewEditorId, editors[{id,filePath,title,isPreview,mode,providerId}]} |
openTerminal |
cwd? |
ctx.terminals.create → {terminalId,panelId} |
sendText |
terminalId,text,addNewline? |
write to a terminal's PTY (force-spawns if never mounted) → {sent:true} |
listTerminals |
workspaceId? |
{terminals[{id,title,sessionId,kind}]} |
Monaco introspection / drive (authoritative — straight from Monaco's registry; uri matches by substring of the model URI)
| Op | Args | Returns / use |
|---|---|---|
monacoEditors |
— | live editors [{uri,hasTextFocus,valueLength,valueTail}] |
editorsDetail |
— | per-editor focus + container-visibility ground truth (focus-handoff debugging) |
focusLog |
clear? |
Monaco focus-event timeline ({clear:true} resets it) |
editorContent |
uri |
read a model's current text → {uri,value} | null |
editorOptions |
uri |
resolved Monaco config (font/tab/wrap/minimap/readOnly/…) for that editor |
setEditorValue |
uri,value |
model.setValue → fires onChange, i.e. the real edit→dirty→save/backup path, no OS focus needed → {uri,valueLength} | null |
Output logs (read what the app or extensions have logged)
| Op | Args | Returns / use |
|---|---|---|
outputLogs |
channel?,level?,search?,limit? (all opt.) |
{channel,displayName,totalCount,entries[{timestamp,level,message,data?}],channels[{key,displayName}]} |
channeldefaults to the first registered channel. Discover all channels via thechannelsfield in any response.level:"debug"/"info"/"warn"/"error"/"all"(default"all").search: case-insensitive substring onmessage.limit: most-recent N entries (default 200; ring buffer holds 5 000 per channel).
# All recent logs (first channel, up to 200)
silo '{"op":"outputLogs"}'
# Errors only from notifications channel
silo '{"op":"outputLogs","args":{"channel":"silo:notifications","level":"error","limit":50}}'
Theme / process / introspection
| Op | Args | Returns / use |
|---|---|---|
themeState |
— | {activeId, presets[], customThemes[]} |
setTheme |
id |
switch active theme → {activeId} |
processExec |
command,args?,cwd? |
one-shot ctx.process.exec → {stdout,stderr,code} |
contextKeys |
— | host context-keys snapshot (activeEditorId/activeViewerId/…) |
activeElement |
— | describe what holds DOM focus |
To dirty an editor without keyboard focus (e.g. verifying save / dirty
indicator / hot-exit backups): openFile, then setEditorValue with the file's
basename as uri and new value — this drives the real onChange. Read it back
with editorContent, or screenshot for the dirty dot.
The typed client src/automation/client.ts (SiloAutomation) wraps these if you
prefer TS over curl.
3. Clean up (always)
silo "{\"op\":\"deleteWorkspace\",\"args\":{\"id\":\"$WS_ID\"}}" # reaps its terminals/panels
rm -rf "$WS_DIR"
If you launched the app yourself, you may leave it running (next verify attaches)
or kill the backgrounded pnpm dev — but never kill an instance you
attached to (it's the user's).
Gotchas (learned the hard way)
- Header quoting:
-H 'X-Silo-Automation: 1'— an unquoted/space-mangled header gets a403 {"error":"forbidden"}. execvsopenTerminal:exec("core.newTerminal")drives the realctx.terminals.createpath; theopenTerminalop is a lower-level test setup that calls record APIs directly — preferexecwhen verifying thectxpath.- Focus-sensitive checks (asserting a
<textarea>isdocument.activeElement) only pass while the window is frontmost; an agent session can't hold focus, so gate them onSiloAutomation.foreground()and SKIP otherwise — don't FAIL. - Code freshness: confirm the running app is the code under test, not just that
a dev app answers
ping— see the identity check in §1. An attached instance can be a different checkout entirely (wrong repo clone, wrong branch), not just stale HMR. evalhas a hard 5-second reply timeout independent of curl's-m. The bridge's Rust side (REPLY_TIMEOUTinautomation.rs) gives up waiting on the webview after 5s and returns{"ok":false,"error":"timed out waiting for webview reply"}— but the JS keeps running in the page regardless, since nothing on the page side knows the host stopped listening. A driver script that doesawait sleep(15000)internally will report a timeout error to you while still fully executing moments later — actions you think failed actually land, which is deeply confusing to debug from the outside. Never put multi-second waits inside anevalpayload. Instead: fire one fast action (a.click()returns essentially instantly),sleepin bash between calls, then a second fastevalto read the result:
This also composes with golden rule 2 — each fire/sleep/read trio is still cheapsilo '{"op":"eval","args":{"expr":"document.querySelector(\"button\").click(); \"clicked\""}}' sleep 2 # bash sleep, not JS sleep — the RPC call itself stays fast silo '{"op":"eval","args":{"expr":"document.querySelector(\".result\").textContent"}}'curlcalls inside oneBashblock, just no longer racing the 5s limit.listWorkspacesreturns every real workspace on the machine, closed ones included — when picking one to "switch away to" (e.g. to try forcing a remount), don't just grab the first non-sandbox id from the list. A closed workspace hasclosedAtset;activateWorkspaceon one reopens it (clearsclosedAt), silently undoing a deliberate close the user made, possibly weeks ago. CheckclosedAtfirst, or better, target a workspace you already know is open. If you do this by mistake,closeWorkspace(notdeleteWorkspace) restores it.- Bash variables never survive across separate
Bashtool calls — each is a fresh shell. If a workspace/terminal id from one block is needed in a later one, hardcode the literal id string (from what you echoed) at the top of the new block; don't reference$WS_IDand assume it's still set. An unset variable silently expands to"", so e.g.activateWorkspacewith an empty id fails quietly rather than erroring loudly — easy to misread as "it worked" when it didn't do what you intended. - Switching workspaces away and back does not remount a backgrounded
terminal panel — Silo's entire premise is keeping background work alive,
so a terminal that's merely not-visible stays mounted and never re-triggers
its attach/reattach effect. There's no bridge op to force just one tab to
remount (no terminal-close-tab op, only whole-workspace
closeWorkspace/deleteWorkspace). To test something that only happens on that a real panel mount (e.g. reattach-after-daemon-death), a full app restart is required — which the golden-rule-1 sandbox discipline doesn't license doing on an attached (not self-launched) instance. Report the limitation rather than reaching foreval-driven DOM clicks on the user's live session to work around it. - In a dev build (
target/debug/silo), the PTY session daemon is not a separately-named process — it's the samesilobinary self-exec'd with--session-host <id> <folder> <cols> <rows> -- <shell>, not a distinctpty-hostbinary (that may differ in a release build). To kill just one session's daemon for a death-transition test, match on--session-host silo-<first 8 hex chars of the terminal's sessionId>and its folder — never a barepkill -f pty-host-style pattern, which would hit every real session-host process on the machine, not just the sandbox one.
版本历史
- 95dfc72 当前 2026-08-27 21:33


