From b8b0e7c62e64afd84878e8ba3ac09eb8642dbd8f Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 2 Nov 2026 23:33:52 +0000 Subject: [PATCH 01/51] =?UTF-8?q?=F0=9F=A4=96=20feat:=20add=20verify-xum?= =?UTF-8?q?=20skill=20that=20drives=20the=20real=20Xum=20web=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A project-local verification skill: verify-xum.sh launches one mock-AI make dev-server-sandbox per checkout (env -i, setsid process group, seeded project and workspace), checks it with doctor, waits with idle and send-ready, captures evidence that survives cleanup, and a feature map covers workspaces, chat, review, command palette and settings. --- .xum/skills/verify-xum/SKILL.md | 161 +++++++++ .xum/skills/verify-xum/features/README.md | 67 ++++ .xum/skills/verify-xum/features/chat.md | 62 ++++ .../verify-xum/features/command-palette.md | 48 +++ .xum/skills/verify-xum/features/review.md | 61 ++++ .xum/skills/verify-xum/features/settings.md | 59 ++++ .xum/skills/verify-xum/features/workspaces.md | 100 ++++++ .xum/skills/verify-xum/scripts/verify-xum.sh | 323 ++++++++++++++++++ 8 files changed, 881 insertions(+) create mode 100644 .xum/skills/verify-xum/SKILL.md create mode 100644 .xum/skills/verify-xum/features/README.md create mode 100644 .xum/skills/verify-xum/features/chat.md create mode 100644 .xum/skills/verify-xum/features/command-palette.md create mode 100644 .xum/skills/verify-xum/features/review.md create mode 100644 .xum/skills/verify-xum/features/settings.md create mode 100644 .xum/skills/verify-xum/features/workspaces.md create mode 100755 .xum/skills/verify-xum/scripts/verify-xum.sh diff --git a/.xum/skills/verify-xum/SKILL.md b/.xum/skills/verify-xum/SKILL.md new file mode 100644 index 00000000000..39b4a3b0c9e --- /dev/null +++ b/.xum/skills/verify-xum/SKILL.md @@ -0,0 +1,161 @@ +--- +name: verify-xum +description: Drives the real Xum web UI (a `make dev-server-sandbox` instance with the mock AI, isolated XUM_ROOT and free ports) through agent-browser to prove a change works the way a user sees it, with screenshots, ARIA snapshots and on-disk side effects as evidence. Use to verify a UI or backend change in this checkout end to end, to reproduce a reported Xum bug in the browser, or before claiming a Xum feature works. +--- + +# Verify Xum in the browser + +This skill starts one disposable Xum dev server from this checkout, drives its web UI with +`agent-browser`, and keeps the evidence after teardown. The mock AI (`XUM_MOCK_AI=1`) answers every +chat turn, so a run makes no model calls and costs nothing. + +The feature map in [features/README.md](features/README.md) has one recipe per user-facing feature. +Pick the recipe for the behavior you changed and drive every entry point it lists. + +Surfaces: + +- Primary: the web UI that `make dev-server` serves (Vite on a free port, backend `xum server` on + another). Source edits hot-reload into the running instance. +- Electron: the same renderer in the desktop shell. This skill does not launch or prove it. See + the [dev-desktop-sandbox](../dev-desktop-sandbox/SKILL.md) and [electron](../electron/SKILL.md) + skills. +- CLI: `xum api` against the sandbox (see Drive). Headless `xum run` needs no server. Give it + its own two roots under the run's scratch folder, which cleanup deletes: + + ```bash + XUM_ROOT="$VERIFY_XUM_RUN/scratch/cli-root" \ + XUM_RUN_SESSION_ROOT="$VERIFY_XUM_RUN/scratch/cli-sessions" XUM_MOCK_AI=1 \ + bun src/cli/index.ts run --dir "$VERIFY_XUM_PROJECT" --json "[mock:list-languages] hi" + ``` + +- Model-driven explorers and story tests run only in the bug-bash sandbox. See the + [bug-bash](../bug-bash/SKILL.md) skill. + +## Launch + +Bun on PATH must be the version in `package.json` `packageManager`. Launch refuses another Bun. + +```bash +.xum/skills/verify-xum/scripts/verify-xum.sh launch +``` + +Launch runs `make dev-server-sandbox` with `--clean-providers --clean-projects`, the mock AI and an +`env -i` allowlist, in its own process group. It returns when the app is usable, in about 15 s when +`dist/` is already built. It gives up after `VERIFY_XUM_READY_TIMEOUT` seconds (default 300). +Ready means all of these hold: + +- the backend that nodemon restarts after the first watch build owns `server.lock` and answers + `/health`, +- Vite serves the app, +- the seed is done: onboarding marked seen, a fake Anthropic provider on a dead loopback port (the + composer refuses to send without a provider), the trusted project `demo-app` (a throwaway git + repo) and the workspace `Verify playground` with its worktree set up. + +It prints the run's `export` lines and the URL of the seeded workspace. A failed launch prints the +server log tail and cleans up. Launch refuses while another verify-xum server runs from this +checkout: two dev servers in one checkout share `dist/` and `node_modules/.vite`. + +## Doctor + +Run it first whenever anything looks off. It changes nothing. + +```bash +.xum/skills/verify-xum/scripts/verify-xum.sh doctor +``` + +Every line must read `ok`. It checks that the process group lives, the checkout lock names it, the +backend pid in the sandbox `server.lock` and the app port belong to the group, `/health` answers, +`/version` reports the launch commit, and the backend env has `XUM_MOCK_AI=1`. Exit 1 means do not +drive this instance: run cleanup, then launch again. + +## Drive + +Every bash call starts by loading the run. This sets `AGENT_BROWSER_SESSION` (a session name only +this run uses) and the `VERIFY_XUM_*` paths and URLs: + +```bash +eval "$(.xum/skills/verify-xum/scripts/verify-xum.sh env)" +agent-browser open "$VERIFY_XUM_URL/workspace/$VERIFY_XUM_WORKSPACE_ID" +.xum/skills/verify-xum/scripts/verify-xum.sh idle +agent-browser snapshot -i +agent-browser find role button click --name "Open settings" --exact +``` + +- Prefer `find role --name "" --exact` and the names in the + feature map over `@eN` refs, which change with every snapshot. Without `--exact`, `--name` + matches substrings and can act on a different element. +- `idle` waits until the open transcript is neither loading nor streaming (`aria-busy="false"` on + the `Conversation transcript` log) and no CSS transition runs. A click while the right sidebar + slides open lands where the target was and is lost. It needs an open workspace. +- `send-ready` waits until the composer's `Send message` button is enabled. Fill the message, run + `send-ready`, then send. The workspace composer keeps Send disabled until the transcript has + caught up, and the new-workspace composer until the branch list has loaded and the draft is + saved. Enter pressed before that refuses the send. +- After an action that opens a view, wait for the view's text (`agent-browser wait --text ""`) + before the next key press. The URL changes before the view mounts, and an Escape sent too early + leaves a dialog open that hides the rest of the page from `find`. +- `snapshot -i` lists only interactive elements. Use a full `snapshot` to read text, headings and + states such as `INTERRUPTED`. +- Mock replies are `Mock response: `. `[mock:long-stream]` in a message streams for + about 20 s, long enough to press Stop. Markers such as `[mock:error:api]` and + `[mock:list-languages]` are in `src/node/services/mock/mockAiRouter.ts`. Mock tool calls are + canned and change no files. +- The CLI drives the same server. Unset the live server's variables, or the CLI talks to the Xum + you run in: + + ```bash + env -u XUM_SERVER_URL -u XUM_SERVER_AUTH_TOKEN XUM_ROOT="$VERIFY_XUM_ROOT" \ + node "$VERIFY_XUM_CHECKOUT/dist/cli/api.mjs" workspace list + ``` + +## Evidence + +Evidence goes to `$VERIFY_XUM_EVIDENCE`, which is `$VERIFY_XUM_HOME//evidence`. +`VERIFY_XUM_HOME` defaults to `${XUM_SCRATCH_DIR:-${TMPDIR:-/tmp}}/verify-xum`. Cleanup never +deletes the evidence or the server log next to it (`/server.log`). + +```bash +.xum/skills/verify-xum/scripts/verify-xum.sh capture chat/reply +``` + +`capture /` writes `.png`, `.aria.txt` (full ARIA snapshot) and +`.url.txt` under `evidence//`. Pass absolute paths to any other `agent-browser` +output: it resolves relative paths against its daemon's directory. + +Proof standards: + +- Drive the path a user takes. No test-only routes, no state written behind the UI's back except + the seed, and edits to the worktree that stand in for the user's own editor. +- Capture the action and the resulting state, not only the final screen. +- Check side effects next to what is visible: rows in + `$VERIFY_XUM_ROOT/sessions//chat.jsonl`, entries in `$VERIFY_XUM_ROOT/config.json`, + git state in the worktree, or the CLI view above. +- Report an entry point you could not drive with the command and the unmet precondition. Do not + count it as verified through another path. + +## Cleanup + +```bash +.xum/skills/verify-xum/scripts/verify-xum.sh cleanup +``` + +Cleanup closes this run's browser session, stops the process group that launch started (SIGTERM, +then SIGKILL after 20 s), deletes the sandbox XUM_ROOT and the demo repo, and prints the evidence +path and file count. It kills nothing it did not start. Run it after every run, failed ones too. +`doctor`, `env`, `capture`, `idle`, `send-ready` and `cleanup` take an optional run directory for +a run other than the newest. + +## Gotchas + +- The bash tool's environment carries the live Xum's `PORT`, auth token and provider keys. Launch + drops them with `env -i`. Never start a server or CLI by hand without the same care. +- The first open of a lazily loaded panel on a cold Vite cache (Review, for one) reloads the page: + `server.log` shows `optimized dependencies changed. reloading`. Repeat the step after the reload. +- A page that stays on `Loading Xum` under host load recovers with `agent-browser reload`. +- agent-browser's headless Chromium matches `(hover: none)`, so Tailwind's `hover:` and + `group-hover:` variants never apply. Controls that appear on hover (a sidebar row's archive + icon) ignore clicks there. Focus them (`agent-browser focus '[aria-label=""]'`) and press + Enter, which is the keyboard user's path. +- `agent-browser` sessions are host-global. Use only the run's `AGENT_BROWSER_SESSION`. +- HOME is the real one, so git inside the app uses your global git config. Xum writes its own + state only under the sandbox XUM_ROOT. diff --git a/.xum/skills/verify-xum/features/README.md b/.xum/skills/verify-xum/features/README.md new file mode 100644 index 00000000000..c37bd738c8b --- /dev/null +++ b/.xum/skills/verify-xum/features/README.md @@ -0,0 +1,67 @@ +# Xum verification map + +One recipe per user-facing feature of the Xum web UI. Read this index, then drive the matching +feature file with the `verify-xum` harness. Update the file in the same change as the feature. + +## Baseline preconditions + +- `verify-xum.sh launch` started the run, and `verify-xum.sh doctor` prints only `ok` lines. +- The seed holds the trusted project `demo-app` (one commit on `main`: `README.md`, `src/math.ts`) + and the workspace `Verify playground` (branch `verify-playground`, id `$VERIFY_XUM_WORKSPACE_ID`, + worktree `$VERIFY_XUM_WORKTREE`) with an empty transcript. +- Onboarding is marked seen, tutorials are off, and a fake Anthropic provider is configured, so + the composer sends. The mock AI answers every turn. + +## Driving conventions + +- Start each bash call with `eval "$(.xum/skills/verify-xum/scripts/verify-xum.sh env)"`. The + recipes write the helper as `verify-xum.sh`. +- Open the app with `agent-browser open "$VERIFY_XUM_URL/workspace/$VERIFY_XUM_WORKSPACE_ID"`, + run `verify-xum.sh idle`, and expand the project in the sidebar with + `agent-browser find role button click --name "Expand project demo-app" --exact`. A new browser + session starts with the project collapsed, and the recipes choose workspace rows by name. The + expand command fails when the project is already expanded. That failure is harmless. +- Send a message in three steps: fill `Message`, run `verify-xum.sh send-ready`, then press Enter + or choose `Send message`. +- Use accessible names with `agent-browser find role --name "" --exact`. + Names are exact strings from the UI. `find text "" click --exact` is the fallback for rows + without a role. +- `find` does not wait. After a step that navigates or opens a view, the recipe waits for the next + target first, with `agent-browser wait '[aria-label=""]'` or + `agent-browser wait --text ""`. Keep those waits when you adapt a recipe. +- Run recipes in the order of this index when you drive more than one. A recipe that changes a + setting restores it at its end. +- Shortcuts are the Linux bindings that agent-browser presses. `src/browser/utils/ui/keybinds.ts` + defines them, with their macOS variants. + +## Proof and skip reporting + +- Capture with `verify-xum.sh capture /`. The feature part of the name is the file + name below. +- Capture the state after each action that the recipe marks as proof, not only the last screen. +- Check the side effect the recipe names (`chat.jsonl`, `config.json`, git state, or the CLI) + and save its output next to the captures, as `$VERIFY_XUM_EVIDENCE//.txt`. +- Report an entry point you could not drive with the command you ran and the unmet precondition. + +## Feature entry contract + +Each feature file starts with an H1 title and one paragraph on the user-visible behavior. Then it +has exactly four H2 sections in this order: + +1. `Sub-features` lists short IDs with one line each. +2. `How to get to it (user POV)` lists every entry point a user has. +3. `Driving it with agent-browser` starts with `Preconditions:` and pairs each user action with + the exact command and the observable result. +4. `Gotchas` lists traps that waste or invalidate a run. + +## Features + +- [Workspaces](./workspaces.md) covers creating a workspace from the project composer, opening it + from the sidebar, archiving and restoring it, and the CLI view of the list. +- [Chat](./chat.md) covers sending a message, the streamed reply, Stop, and the transcript after a + reload. +- [Review](./review.md) covers the Review tab's diff of uncommitted worktree changes, marking a + hunk as read, and the sidebar's keyboard toggle. +- [Command palette](./command-palette.md) covers opening the palette, switching workspaces, and + running a command. +- [Settings](./settings.md) covers opening Settings, changing a preference, and its persistence. diff --git a/.xum/skills/verify-xum/features/chat.md b/.xum/skills/verify-xum/features/chat.md new file mode 100644 index 00000000000..3c5b29a7b47 --- /dev/null +++ b/.xum/skills/verify-xum/features/chat.md @@ -0,0 +1,62 @@ +# Chat + +The user types a message in a workspace's composer and sends it. The reply streams into the +`Conversation transcript` log, the user can stop a reply that is still streaming, and the +transcript is the same after a reload. + +## Sub-features + +- `chat-send` sends a message and shows the user row and the reply. +- `chat-stop` stops a streaming reply and marks it interrupted. +- `chat-persist` shows the same transcript after a page reload, from `chat.jsonl`. + +## How to get to it (user POV) + +- Type in the `Message` textbox of an open workspace and press Enter, or choose `Send message`. +- Choose `Stop streaming` while a reply streams. +- Reload the page on `/workspace/`. + +## Driving it with agent-browser + +Preconditions: + +- Baseline state. The app is open on `Verify playground` with an empty transcript. +- `verify-xum.sh idle` returned. + +- **Send with Enter.** Run + `agent-browser find role textbox fill "Hello from verify-xum" --name "Message" --exact`, + `verify-xum.sh send-ready`, `agent-browser press Enter`, + `agent-browser wait --text "Mock response: Hello from verify-xum"` and `verify-xum.sh idle`. The + transcript shows the user row `Hello from verify-xum` and the reply + `Mock response: Hello from verify-xum`. Capture `chat/reply`. +- **Send with the button.** Fill `Second message`, run `verify-xum.sh send-ready`, then run + `agent-browser find role button click --name "Send message" --exact` and + `agent-browser wait --text "Mock response: Second message"`. +- **Check history.** Save the last four rows of the workspace history: + + ```bash + tail -n 4 "$VERIFY_XUM_ROOT/sessions/$VERIFY_XUM_WORKSPACE_ID/chat.jsonl" >"$VERIFY_XUM_EVIDENCE/chat/history.txt" + ``` + + The file has two `"role":"user"` rows and two `"role":"assistant"` rows with the reply texts. +- **Stop a stream.** Fill `[mock:long-stream] tell a long story`, run `verify-xum.sh send-ready` + and press Enter. Run `agent-browser wait '[aria-label="Stop streaming"]'`, then wait for a few + chunks with + `agent-browser wait --fn "document.body.innerText.split('Streaming response').length > 40"`. + Run `agent-browser click '[aria-label="Stop streaming"]'` and `verify-xum.sh idle`. The reply + keeps its partial text, an `INTERRUPTED` divider follows it, and the button + `Continue interrupted response` appears. Capture `chat/stopped`. +- **Reload.** Run `agent-browser reload` and `verify-xum.sh idle`. The transcript still shows both + replies, the partial reply and `INTERRUPTED`. Capture `chat/reloaded`. + +## Gotchas + +- Enter pressed before `send-ready` returns is refused with the alert + `Transcript is still loading — wait until it is up to date before sending or editing.` The draft + stays in the box, and the alert stays until you choose `Dismiss`. +- `verify-xum.sh idle` returns at once before a stream starts. Wait for the reply text or the + `Stop streaming` button first, then for idle. +- The mock writes partial text to disk every 100 ms. A Stop in the first 100 ms of a stream can + leave `INTERRUPTED` with no text after a reload. Wait for chunks before you stop. +- A model picked in the composer stays in memory until a send. A reload before the send shows the + workspace's saved model again (`src/browser/utils/modelChange.ts`). diff --git a/.xum/skills/verify-xum/features/command-palette.md b/.xum/skills/verify-xum/features/command-palette.md new file mode 100644 index 00000000000..b3aa772d9e5 --- /dev/null +++ b/.xum/skills/verify-xum/features/command-palette.md @@ -0,0 +1,48 @@ +# Command palette + +The command palette is a search box over workspaces and commands. Without a prefix it lists +workspaces to switch to. With `>` it lists commands, such as `Change Model…` and `Open Settings`. + +## Sub-features + +- `palette-open` opens the palette with each shortcut and closes it with Escape. +- `palette-switch` switches to another workspace by typing part of its title. +- `palette-command` runs a command from `>` mode. + +## How to get to it (user POV) + +- Press Ctrl+Shift+P (`OPEN_COMMAND_PALETTE`) for workspace search. +- Press F4 (`OPEN_COMMAND_PALETTE_ACTIONS`) for command mode, which prefills `>`. + +## Driving it with agent-browser + +Preconditions: + +- Baseline state plus a second workspace `release-notes` (create it with the + [workspaces](./workspaces.md) recipe, or skip `palette-switch` and report it). +- The app is open on `Verify playground`, and `verify-xum.sh idle` returned. + +- **Open and close.** Run `agent-browser press Control+Shift+P` and + `agent-browser wait '[aria-label="Command palette"]'`. The combobox `Command palette` is + expanded, with the listbox `Suggestions`. Run `agent-browser press Escape`. The combobox is + gone. +- **Switch workspace.** Press Ctrl+Shift+P, wait for the palette as above, then run + `agent-browser keyboard type "release"`. `Suggestions` lists the option `release-notes`. Run + `agent-browser press Enter`. The URL is `/workspace/`. Capture + `command-palette/switched`. +- **Command mode.** Run `agent-browser press F4` and + `agent-browser wait '[aria-label="Command palette"]'`. The combobox value is `>`, and + `Suggestions` lists commands such as `Open Settings` and `Change Model…`. +- **Run a command.** Run `agent-browser keyboard type "Change Model"`, + `agent-browser press Enter` and `agent-browser wait --text "Show all models…"`. The model picker + opens with the textbox `Search [provider:model-name]`. Run + `agent-browser find text "Sonnet 5.5" click --exact`. The composer's combobox reads + `Model: Sonnet 5.5`. Capture `command-palette/model`. + +## Gotchas + +- Type into the palette with `agent-browser keyboard type`. The combobox has focus as soon as it + opens. +- Workspace options show the title only. A leading `•` marks the open workspace. +- The model pick stays in memory until a send (see [chat](./chat.md) Gotchas). Do not reload + between `Change Model…` and the check. diff --git a/.xum/skills/verify-xum/features/review.md b/.xum/skills/verify-xum/features/review.md new file mode 100644 index 00000000000..08122bc524f --- /dev/null +++ b/.xum/skills/verify-xum/features/review.md @@ -0,0 +1,61 @@ +# Review + +The Review tab in the right sidebar shows the workspace's diff against its base branch, one hunk +per change. The user includes uncommitted edits, reads the diff, and marks a hunk as read. The tab +label counts read hunks against all hunks. + +## Sub-features + +- `review-open` opens the Review tab from the right sidebar. +- `review-uncommitted` shows uncommitted worktree edits once `Uncommitted:` is checked. +- `review-read` marks a hunk as read and updates the `Review /` count. +- `review-toggle` hides and shows the right sidebar with the keyboard and keeps the open tab. + +## How to get to it (user POV) + +- Choose `Expand right sidebar`, then choose `Review` under `Open a tool`. +- Press Ctrl+Shift+B to collapse the right sidebar and to open it again on its last tab. + +## Driving it with agent-browser + +Preconditions: + +- Baseline state. The worktree `$VERIFY_XUM_WORKTREE` has no changes. +- The app is open on `Verify playground`, and the right sidebar is collapsed. + +- **Edit a file.** Change the worktree the way an editor would: + + ```bash + printf 'export function sub(a: number, b: number): number {\n return a - b;\n}\n' >>"$VERIFY_XUM_WORKTREE/src/math.ts" + ``` + + `git -C "$VERIFY_XUM_WORKTREE" status --short` prints ` M src/math.ts`. +- **Open the sidebar.** Run + `agent-browser find role button click --name "Expand right sidebar" --exact`, + `agent-browser wait --text "Open a tool"` and `verify-xum.sh idle`. The heading `Open a tool` + lists a `Review` button. +- **Open Review.** Run `agent-browser find role button click --name "Review" --exact` and + `agent-browser wait --text "Uncommitted:"`. The tab `Review Close Review` is selected, and the + panel reads `No changes found` with `Uncommitted:` unchecked. +- **Include uncommitted edits.** Run + `agent-browser find role checkbox check --name "Uncommitted:" --exact` and + `agent-browser wait --text "math.ts"`. The file tree lists `src` and `math.ts` with `+3`, the + hunk shows the three added lines of `sub`, and the tab reads `Review 0/1`. Capture + `review/diff`. +- **Mark as read.** Run + `agent-browser find role button click --name "Mark as read (M)" --exact`. The tab reads `Review 1/1`, and the hunk reads `Hunk marked as read`. + `$VERIFY_XUM_ROOT/sessions/$VERIFY_XUM_WORKSPACE_ID/review-state.json` has + `"isRead": true` for that hunk within a second. Capture `review/read`. +- **Keyboard toggle.** Run `agent-browser press Control+Shift+B`. The sidebar collapses, and the + button `Expand right sidebar` is back. Run `agent-browser press Control+Shift+B` again. The + button reads `Collapse right sidebar`, and the tab `Review 1/1 Close Review` is selected. +- **Restore.** Run `git -C "$VERIFY_XUM_WORKTREE" checkout -- src/math.ts`. + +## Gotchas + +- `find role ... --name` matches substrings without `--exact`. The hunk itself is a button whose + name starts with `Mark as read (M)`, so the call without `--exact` selects the hunk and marks + nothing. +- On a cold Vite cache the first open of Review reloads the page (see SKILL.md Gotchas). Open it + again after the reload. +- A click on `Review` while the sidebar still slides open is lost. Keep the `idle` call before it. diff --git a/.xum/skills/verify-xum/features/settings.md b/.xum/skills/verify-xum/features/settings.md new file mode 100644 index 00000000000..9fba6e142aa --- /dev/null +++ b/.xum/skills/verify-xum/features/settings.md @@ -0,0 +1,59 @@ +# Settings + +Settings is a dialog over the app with one section per area (General, Providers, Models, and +more). Each section has its own route under `/settings/`. A change applies at once and persists in +the server's `config.json`. + +## Sub-features + +- `settings-open` opens the dialog from the sidebar button, the palette, and its route. +- `settings-toggle` changes the `Flat chat list` switch and shows its effect on the sidebar. +- `settings-persist` keeps the change across a reload and in `config.json`. + +## How to get to it (user POV) + +- Choose `Open settings` (the gear in the sidebar header). +- Press F4, type `Open Settings`, and press Enter. +- Open `$VERIFY_XUM_URL/settings/general`. + +## Driving it with agent-browser + +Preconditions: + +- Baseline state. `Flat chat list` is off. +- The app is open on `Verify playground`. + +- **Open from the button.** Run + `agent-browser find role button click --name "Open settings" --exact` and + `agent-browser wait --text "Choose your preferred theme"`. The URL is `/settings/general`, and a + full `snapshot` shows the dialog `Settings` with the section buttons `General`, `Providers` and + `Models`. Run `agent-browser press Escape`. The URL is back on the + workspace. +- **Open from the palette.** Run `agent-browser press F4`, + `agent-browser wait '[aria-label="Command palette"]'`, + `agent-browser keyboard type "Open Settings"`, `agent-browser press Enter` and + `agent-browser wait --text "Choose your preferred theme"`. The URL is `/settings/general`. +- **Change a setting.** Run + `agent-browser find role switch click --name "Toggle flat chat list" --exact`. The switch is + `checked=true`, and `$VERIFY_XUM_ROOT/config.json` has `"sidebarFlatMode": true` under + `userPreferences.ui`. Capture `settings/flat-on`. +- **See the effect.** Run `agent-browser press Escape` and `agent-browser wait --text "RECENT"`. + The sidebar has a `RECENT` group, and the workspace row is + `Select workspace Verify playground (demo-app)`. +- **Reload.** Run `agent-browser reload`, + `agent-browser open "$VERIFY_XUM_URL/settings/general"` and + `agent-browser wait --text "Choose your preferred theme"`. The switch `Toggle flat chat list` is + still `checked=true`. Capture `settings/persisted`. +- **Restore.** Run + `agent-browser find role switch click --name "Toggle flat chat list" --exact` and + `agent-browser press Escape`. `config.json` has `"sidebarFlatMode": false`. + +## Gotchas + +- `snapshot -i` lists only the dialog's buttons and switches. Use a full `snapshot` to read the + section text. +- Flat mode renames sidebar rows to `Select workspace (<project>)`. Restore it before you + drive another recipe. +- Read where a control saves before you name its side effect. `Flat chat list` calls + `updateUserPreferences` (`src/browser/features/Settings/Sections/GeneralSection.tsx`), and other + controls there call other routes. diff --git a/.xum/skills/verify-xum/features/workspaces.md b/.xum/skills/verify-xum/features/workspaces.md new file mode 100644 index 00000000000..0ddaa6b2cd6 --- /dev/null +++ b/.xum/skills/verify-xum/features/workspaces.md @@ -0,0 +1,100 @@ +# Workspaces + +A workspace is one chat with its own git worktree and branch of a project. The user creates one +from the project composer by naming it and sending a first message, opens it from the sidebar, +archives it to hide it, and restores it from the project's archived list. + +## Sub-features + +- `ws-create` creates a named workspace, its worktree and branch, and sends the first message. +- `ws-open` opens a workspace from its sidebar row. +- `ws-archive` hides a workspace from the sidebar and records the archive time. +- `ws-restore` brings an archived workspace back from the project page. +- `ws-cli` lists the same workspaces through `xum api workspace list`. + +## How to get to it (user POV) + +- Choose `New chat in demo-app` on the project's sidebar row, or press Ctrl+N. +- Choose a `Select workspace <title>` row in the sidebar. +- Hover a sidebar row and choose its `Archive workspace <title>` icon, or tab to the icon and + press Enter, or press Ctrl+Shift+Backspace in the open workspace. +- Choose the project row (`Create workspace in demo-app`), then `Expand archived workspaces` and + `Restore workspace <title>`. +- Run `xum api workspace list` against the server. + +## Driving it with agent-browser + +Preconditions: + +- Baseline state. No workspace is named `release-notes` or `scratch-notes`. +- The app is open on `Verify playground` with the project expanded (see the + [driving conventions](./README.md#driving-conventions)), and `verify-xum.sh idle` returned. + +- **Open the composer.** Choose `New chat in demo-app`. Run + `agent-browser find role button click --name "New chat in demo-app" --exact` and + `agent-browser wait '[aria-label="Workspace name"]'`. The URL is + `$VERIFY_XUM_URL/project?project=demo-app-<hash>&draft=<id>`, and the page has the textboxes + `Workspace name` and `Message`. +- **Create.** Name the workspace and send. Run + `agent-browser find role textbox fill "release-notes" --name "Workspace name" --exact`, + `agent-browser find role textbox fill "Draft the release notes" --name "Message" --exact`, + `verify-xum.sh send-ready`, + `agent-browser find role button click --name "Send message" --exact` and + `agent-browser wait --text "Mock response: Draft the release notes"`. The URL changes to + `/workspace/<new id>`, and the sidebar has a `Select workspace release-notes` row. Capture + `workspaces/created`. +- **Check the worktree.** Run `git -C "$VERIFY_XUM_PROJECT" worktree list`. It lists + `$VERIFY_XUM_ROOT/src/demo-app/release-notes` on branch `release-notes`. +- **Keyboard create.** Press Ctrl+N. Run `agent-browser press Control+n` and + `agent-browser wait '[aria-label="Workspace name"]'`. The URL changes to + `/project?project=demo-app-<hash>&draft=<id>`. Create `scratch-notes` the same way as above, + with the message `Collect scratch notes`. +- **Open from the sidebar.** Run + `agent-browser find role button click --name "Select workspace release-notes" --exact`. The URL + is `/workspace/<release-notes id>`. Run + `agent-browser find role button click --name "Select workspace Verify playground" --exact`. The + URL is `/workspace/$VERIFY_XUM_WORKSPACE_ID`. +- **Archive from the row.** Focus the row's archive icon and press Enter. Run + `agent-browser wait '[aria-label="Archive workspace release-notes"]'`, + `agent-browser focus '[aria-label="Archive workspace release-notes"]'` and + `agent-browser press Enter`. The `Select workspace release-notes` row disappears, and in + `$VERIFY_XUM_ROOT/config.json` the `release-notes` entry gets an `archivedAt` time. Capture + `workspaces/archived`. +- **Archive with the keyboard.** Open `scratch-notes` from its row, run `verify-xum.sh idle`, and + press Ctrl+Shift+Backspace. Run `agent-browser press Control+Shift+Backspace`. The + `Select workspace scratch-notes` row disappears, and its `config.json` entry gets an + `archivedAt` time. +- **Restore.** Open the project page and the archived list. Run + `agent-browser find role button click --name "Create workspace in demo-app" --exact`, + `agent-browser wait '[aria-label="Expand archived workspaces"]'`, + `agent-browser find role button click --name "Expand archived workspaces" --exact` and + `agent-browser wait '[aria-label="Restore workspace release-notes"]'`. The region + `Archived workspaces` lists `release-notes` and `scratch-notes`. Run + `agent-browser find role button click --name "Restore workspace release-notes" --exact`. The + app opens `/workspace/<release-notes id>`, the sidebar row is back, and `config.json` gets an + `unarchivedAt` time for it. Capture `workspaces/restored`. +- **CLI view.** List the workspaces through the CLI: + + ```bash + env -u XUM_SERVER_URL -u XUM_SERVER_AUTH_TOKEN XUM_ROOT="$VERIFY_XUM_ROOT" \ + node "$VERIFY_XUM_CHECKOUT/dist/cli/api.mjs" workspace list >"$VERIFY_XUM_EVIDENCE/workspaces/cli-list.txt" + ``` + + The file has the titles `Verify playground` and `release-notes`. + +## Gotchas + +- The project row's accessible name is `Create workspace in demo-app`, but choosing it opens the + project page. It creates nothing. +- Right after a create, the next new-workspace composer keeps `Send message` disabled for a few + seconds until its branch list has loaded and its draft is saved. `verify-xum.sh send-ready` + waits for it. +- With `Workspace name` empty, Xum names the workspace with random words (`curvy-mangos-pull`). + Fill the name so rows and branches have known names. +- In agent-browser's Chromium, `document.hasFocus()` is false, so a reply that finishes while you + watch does not mark the workspace read. When you leave it, its row shows an unread status dot, + and that dot takes the archive icon's place. Open the workspace once more, as the recipe does, + before you archive it from its row. +- A click on the archive icon selects the row instead in agent-browser's Chromium. The icon takes + clicks only on row hover, and that browser matches `(hover: none)`, so Tailwind's `group-hover` + never applies (see SKILL.md Gotchas). Focus the icon, as above. diff --git a/.xum/skills/verify-xum/scripts/verify-xum.sh b/.xum/skills/verify-xum/scripts/verify-xum.sh new file mode 100755 index 00000000000..025e7730c39 --- /dev/null +++ b/.xum/skills/verify-xum/scripts/verify-xum.sh @@ -0,0 +1,323 @@ +#!/usr/bin/env bash +# One disposable, mock-AI Xum dev server per checkout, for verification runs (see ../SKILL.md). +# +# verify-xum.sh launch start and seed a sandbox, print its state +# verify-xum.sh doctor [RUN] read-only health check of a run +# verify-xum.sh env [RUN] print the run's `export` lines (eval them) +# verify-xum.sh capture NAME [RUN] screenshot + ARIA snapshot + URL into the run's evidence +# verify-xum.sh idle [RUN] wait until the open transcript is quiet and no CSS transition runs +# verify-xum.sh send-ready [RUN] wait until the composer's Send button is enabled +# verify-xum.sh cleanup [RUN] stop the run's processes, delete its XUM_ROOT, keep evidence +# +# RUN defaults to the newest launch: $VERIFY_XUM_HOME/current. +set -euo pipefail + +SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +CHECKOUT=$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel) +VERIFY_XUM_HOME=${VERIFY_XUM_HOME:-${XUM_SCRATCH_DIR:-${TMPDIR:-/tmp}}/verify-xum} +# Per-worktree path inside the git dir: two dev servers in one checkout share dist/ and +# node_modules/.vite and corrupt each other, so launch refuses while this names a live group. +LOCK=$(git -C "$CHECKOUT" rev-parse --path-format=absolute --git-path verify-xum.lock) +READY_TIMEOUT=${VERIFY_XUM_READY_TIMEOUT:-300} + +die() { + echo "verify-xum: $*" >&2 + exit 1 +} + +need() { + local tool + for tool in "$@"; do + command -v "$tool" >/dev/null || die "missing tool: $tool" + done +} + +group_alive() { + kill -0 -- "-$1" 2>/dev/null +} + +load_run() { + local run=${1:-$VERIFY_XUM_HOME/current} + [[ -f $run/state.env ]] || die "no run at $run (run: verify-xum.sh launch)" + # shellcheck source=/dev/null + source "$run/state.env" +} + +api() { + curl -fsS -X POST "$VERIFY_XUM_BACKEND/api/$1" -H 'content-type: application/json' -d "$2" +} + +json_string() { + node -p 'JSON.stringify(process.argv[1])' "$1" +} + +# Prints field $2 (a dotted path) of the JSON file $1, or nothing. +json_field() { + node -e ' + const fs = require("fs"); + try { + let v = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); + for (const k of process.argv[2].split(".")) v = v?.[k]; + if (v !== undefined && v !== null) console.log(String(v)); + } catch {}' "$1" "$2" +} + +# The backend is ready once the server that nodemon restarted after the first watch build +# (dist/.main-build-complete, written in ms) owns server.lock and answers /health. +backend_ready() { + node -e ' + const fs = require("fs"); + const [lock, stamp, t0] = process.argv.slice(1); + try { + const built = Number(fs.readFileSync(stamp, "utf8")); + const started = Date.parse(JSON.parse(fs.readFileSync(lock, "utf8")).startedAt); + process.exit(built >= Number(t0) * 1000 && started > built ? 0 : 1); + } catch { + process.exit(1); + }' "$VERIFY_XUM_ROOT/server.lock" "$CHECKOUT/dist/.main-build-complete" "$1" \ + && curl -fsS -o /dev/null "$VERIFY_XUM_BACKEND/health" +} + +write_state() { + local name + : >"$VERIFY_XUM_RUN/state.env" + for name in VERIFY_XUM_RUN VERIFY_XUM_CHECKOUT VERIFY_XUM_COMMIT VERIFY_XUM_PGID VERIFY_XUM_ROOT \ + VERIFY_XUM_BACKEND VERIFY_XUM_URL VERIFY_XUM_PROJECT VERIFY_XUM_WORKSPACE_ID \ + VERIFY_XUM_WORKTREE VERIFY_XUM_EVIDENCE AGENT_BROWSER_SESSION; do + printf 'export %s=%q\n' "$name" "${!name-}" >>"$VERIFY_XUM_RUN/state.env" + done +} + +check_bun() { + local want have + want=$(node -p 'require(process.argv[1]).packageManager.split("@")[1]' "$CHECKOUT/package.json") + have=$(bun --version) + [[ $have == "$want" ]] \ + || die "bun $have is first on PATH but package.json pins $want. Put Bun $want first on PATH (mise: PATH=\"\$(mise where bun@$want)/bin:\$PATH\")." +} + +create_demo_repo() { + local repo=$1 + mkdir -p "$repo/src" + printf '# Demo app\n\nA throwaway project for verify-xum.\n' >"$repo/README.md" + printf 'export function add(a: number, b: number): number {\n return a + b;\n}\n' >"$repo/src/math.ts" + git -C "$repo" init -q -b main + git -C "$repo" add . + git -C "$repo" -c user.name=verify-xum -c user.email=verify-xum@example.test \ + -c commit.gpgsign=false commit -qm init +} + +seed() { + local created status_file status i + api splashScreens/markSplashScreenViewed '{"splashId":"onboarding-wizard-v1"}' >/dev/null + # The composer refuses to send without a configured provider. The mock never calls it, + # and port 9 on loopback keeps any stray call on the machine. + api providers/setProviderConfig '{"provider":"anthropic","keyPath":["apiKey"],"value":"sk-ant-verify-xum-fake"}' >/dev/null + api providers/setProviderConfig '{"provider":"anthropic","keyPath":["baseUrl"],"value":"http://127.0.0.1:9/v1"}' >/dev/null + create_demo_repo "$VERIFY_XUM_PROJECT" + api projects/create "{\"projectPath\":$(json_string "$VERIFY_XUM_PROJECT")}" >/dev/null + api projects/setTrust "{\"projectPath\":$(json_string "$VERIFY_XUM_PROJECT"),\"trusted\":true}" >/dev/null + created="$VERIFY_XUM_RUN/scratch/workspace-create.json" + api workspace/create "{\"projectPath\":$(json_string "$VERIFY_XUM_PROJECT"),\"branchName\":\"verify-playground\",\"trunkBranch\":\"main\",\"title\":\"Verify playground\"}" >"$created" + VERIFY_XUM_WORKSPACE_ID=$(json_field "$created" metadata.id) + [[ -n $VERIFY_XUM_WORKSPACE_ID ]] || die "workspace/create returned no id: $(head -c 400 "$created")" + status_file="$VERIFY_XUM_ROOT/sessions/$VERIFY_XUM_WORKSPACE_ID/init-status.json" + for ((i = 0; i < 120; i++)); do + status=$(json_field "$status_file" status) + [[ $status == success ]] && break + [[ $status == error ]] && die "workspace setup failed, see $status_file" + sleep 0.5 + done + [[ $status == success ]] || die "workspace setup did not finish in 60 s" + VERIFY_XUM_WORKTREE="$VERIFY_XUM_ROOT/src/$(basename "$VERIFY_XUM_PROJECT")/verify-playground" + [[ -d $VERIFY_XUM_WORKTREE ]] || die "no worktree at $VERIFY_XUM_WORKTREE" +} + +launch_failed() { + echo "verify-xum: launch failed. Server log tail ($VERIFY_XUM_RUN/server.log):" >&2 + tail -n 30 "$VERIFY_XUM_RUN/server.log" >&2 || true + cmd_cleanup "$VERIFY_XUM_RUN" >&2 || true +} + +cmd_launch() { + local old run_id t0 i backend_port vite_port log pidfile + need git curl node bun make setsid ss agent-browser + check_bun + if [[ -f $LOCK ]]; then + old=$(cat "$LOCK") + if group_alive "$old"; then + die "a verify-xum server already runs from this checkout (process group $old). Run verify-xum.sh cleanup first." + fi + rm -f "$LOCK" + fi + + run_id="$(date -u +%Y%m%d-%H%M%S)-$$" + VERIFY_XUM_RUN="$VERIFY_XUM_HOME/$run_id" + VERIFY_XUM_EVIDENCE="$VERIFY_XUM_RUN/evidence" + VERIFY_XUM_PROJECT="$VERIFY_XUM_RUN/scratch/demo-app" + VERIFY_XUM_CHECKOUT=$CHECKOUT + VERIFY_XUM_COMMIT=$(git -C "$CHECKOUT" rev-parse HEAD) + AGENT_BROWSER_SESSION="verify-xum-$run_id" + mkdir -p "$VERIFY_XUM_EVIDENCE" "$VERIFY_XUM_RUN/scratch" + log="$VERIFY_XUM_RUN/server.log" + pidfile="$VERIFY_XUM_RUN/scratch/pgid" + t0=$(date +%s) + + # env -i: the agent's env carries the live Xum's PORT, auth token, proxy URIs and provider + # keys. setsid: the server outlives this shell, and its process group is what cleanup kills. + # The sh wrapper records that group: setsid forks when its caller already leads a group. + # shellcheck disable=SC2016 # $$ and $@ belong to the inner sh. + setsid sh -c 'echo "$$" >"$1"; shift; exec "$@"' sh "$pidfile" \ + env -i PATH="$PATH" HOME="$HOME" USER="${USER:-}" LANG="${LANG:-C.UTF-8}" TERM=dumb \ + TMPDIR="${TMPDIR:-/tmp}" XUM_MOCK_AI=1 XUM_DISABLE_TELEMETRY=1 \ + make -C "$CHECKOUT" dev-server-sandbox DEV_SERVER_SANDBOX_ARGS="--clean-providers --clean-projects" \ + >"$log" 2>&1 </dev/null & + for ((i = 0; i < 50; i++)); do + [[ -s $pidfile ]] && break + sleep 0.1 + done + VERIFY_XUM_PGID=$(cat "$pidfile") + echo "$VERIFY_XUM_PGID" >"$LOCK" + write_state + ln -sfn "$VERIFY_XUM_RUN" "$VERIFY_XUM_HOME/current" + trap launch_failed EXIT + + for ((i = 0; i < 120; i++)); do + VERIFY_XUM_ROOT=$(sed -n 's/^ XUM_ROOT: *//p' "$log") + backend_port=$(sed -n 's|^ Backend: *http://127.0.0.1:||p' "$log") + vite_port=$(sed -n 's|^ Frontend: *http://localhost:||p' "$log") + [[ -n $VERIFY_XUM_ROOT && -n $backend_port && -n $vite_port ]] && break + group_alive "$VERIFY_XUM_PGID" || die "make dev-server-sandbox exited during startup" + sleep 0.5 + done + [[ -n $vite_port ]] || die "make dev-server-sandbox printed no XUM_ROOT and ports in 60 s" + VERIFY_XUM_BACKEND="http://127.0.0.1:$backend_port" + VERIFY_XUM_URL="http://127.0.0.1:$vite_port" + write_state + + for ((i = 0; i < READY_TIMEOUT; i++)); do + backend_ready "$t0" && curl -fsS -o /dev/null "$VERIFY_XUM_URL/" && break + group_alive "$VERIFY_XUM_PGID" || die "the dev server exited before it was ready" + sleep 1 + done + ((i < READY_TIMEOUT)) || die "the dev server was not ready in $READY_TIMEOUT s" + + seed + write_state + trap - EXIT + cat "$VERIFY_XUM_RUN/state.env" + echo "ready: $VERIFY_XUM_URL/workspace/$VERIFY_XUM_WORKSPACE_ID" +} + +serves_vite_ui() { + curl -fsS "$1/" 2>/dev/null | grep -q '/@vite/client' +} + +pid_in_group() { + [[ -n $1 && $(ps -o pgid= -p "$1" 2>/dev/null | tr -d ' ') == "$2" ]] +} + +cmd_doctor() { + load_run "${1:-}" + local failed=0 backend_pid version vite_port vite_pid + check() { + local label=$1 + shift + if "$@"; then + echo "ok $label" + else + echo "FAIL $label" + failed=1 + fi + } + + check "process group $VERIFY_XUM_PGID is alive" group_alive "$VERIFY_XUM_PGID" + check "checkout lock $LOCK names this run" test "$(cat "$LOCK" 2>/dev/null)" = "$VERIFY_XUM_PGID" + backend_pid=$(json_field "$VERIFY_XUM_ROOT/server.lock" pid) + check "backend pid ${backend_pid:-none} (server.lock in $VERIFY_XUM_ROOT) belongs to this run" \ + pid_in_group "$backend_pid" "$VERIFY_XUM_PGID" + check "server.lock names $VERIFY_XUM_BACKEND" \ + test "$(json_field "$VERIFY_XUM_ROOT/server.lock" baseUrl)" = "$VERIFY_XUM_BACKEND" + check "GET $VERIFY_XUM_BACKEND/health answers" curl -fsS -o /dev/null "$VERIFY_XUM_BACKEND/health" + curl -fsS "$VERIFY_XUM_BACKEND/version" -o "$VERIFY_XUM_RUN/scratch/version.json" 2>/dev/null || true + version=$(json_field "$VERIFY_XUM_RUN/scratch/version.json" git_commit) + check "backend build ${version:-unknown} is the launch commit $VERIFY_XUM_COMMIT" \ + test -n "$version" -a "${VERIFY_XUM_COMMIT#"$version"}" != "$VERIFY_XUM_COMMIT" + if [[ $(git -C "$VERIFY_XUM_CHECKOUT" rev-parse HEAD) != "$VERIFY_XUM_COMMIT" ]]; then + echo "note HEAD moved since launch. Source edits hot-reload, /version keeps the launch commit." + fi + check "backend env has XUM_MOCK_AI=1" grep -qszx 'XUM_MOCK_AI=1' "/proc/${backend_pid:-0}/environ" + vite_port=${VERIFY_XUM_URL##*:} + vite_pid=$(ss -Hltnp "sport = :$vite_port" 2>/dev/null | sed -n 's/.*pid=\([0-9]*\).*/\1/p' | head -n 1) + check "app port $vite_port is owned by this run (pid ${vite_pid:-none})" pid_in_group "$vite_pid" "$VERIFY_XUM_PGID" + check "$VERIFY_XUM_URL serves the Vite dev UI" serves_vite_ui "$VERIFY_XUM_URL" + return "$failed" +} + +cmd_env() { + load_run "${1:-}" + cat "$VERIFY_XUM_RUN/state.env" +} + +cmd_capture() { + local name=${1:-} base + [[ $name =~ ^[a-z0-9][a-z0-9._-]*(/[a-z0-9][a-z0-9._-]*)*$ ]] || die "capture NAME must look like <feature>/<step>, lowercase" + load_run "${2:-}" + base="$VERIFY_XUM_EVIDENCE/$name" + mkdir -p "$(dirname "$base")" + agent-browser --session "$AGENT_BROWSER_SESSION" get url >"$base.url.txt" + agent-browser --session "$AGENT_BROWSER_SESSION" snapshot >"$base.aria.txt" + agent-browser --session "$AGENT_BROWSER_SESSION" screenshot "$base.png" >/dev/null + echo "$base.png $base.aria.txt $base.url.txt" +} + +# A click during a CSS transition (the right sidebar slides open in about 250 ms) can land where +# the target was, so idle also waits for running transitions to end. +cmd_idle() { + load_run "${1:-}" + agent-browser --session "$AGENT_BROWSER_SESSION" wait --fn \ + "document.querySelector('[role=log][aria-label=\"Conversation transcript\"]')?.getAttribute('aria-busy') === 'false' && !document.getAnimations().some((a) => a instanceof CSSTransition)" >/dev/null +} + +cmd_send_ready() { + load_run "${1:-}" + agent-browser --session "$AGENT_BROWSER_SESSION" wait --fn \ + "document.querySelector('[aria-label=\"Send message\"]')?.disabled === false" >/dev/null +} + +cmd_cleanup() { + load_run "${1:-}" + local i + # `close` on a session that does not exist starts a browser first. + if agent-browser session list 2>/dev/null | grep -qF "$AGENT_BROWSER_SESSION"; then + agent-browser --session "$AGENT_BROWSER_SESSION" close >/dev/null 2>&1 || true + fi + if group_alive "$VERIFY_XUM_PGID"; then + kill -TERM -- "-$VERIFY_XUM_PGID" 2>/dev/null || true + for ((i = 0; i < 20; i++)); do + group_alive "$VERIFY_XUM_PGID" || break + sleep 1 + done + group_alive "$VERIFY_XUM_PGID" && kill -KILL -- "-$VERIFY_XUM_PGID" 2>/dev/null + fi + # make dev-server-sandbox deletes its own root on a clean exit. Only a root it created + # (mux-dev-server-*) is removed here. + if [[ -n $VERIFY_XUM_ROOT && -d $VERIFY_XUM_ROOT && $(basename "$VERIFY_XUM_ROOT") == mux-dev-server-* ]]; then + rm -rf "$VERIFY_XUM_ROOT" + fi + rm -rf "$VERIFY_XUM_RUN/scratch" + [[ $(cat "$LOCK" 2>/dev/null) == "$VERIFY_XUM_PGID" ]] && rm -f "$LOCK" + [[ $(readlink "$VERIFY_XUM_HOME/current" 2>/dev/null) == "$VERIFY_XUM_RUN" ]] && rm -f "$VERIFY_XUM_HOME/current" + echo "stopped group $VERIFY_XUM_PGID; evidence kept in $VERIFY_XUM_EVIDENCE ($(find "$VERIFY_XUM_EVIDENCE" -type f | wc -l | tr -d ' ') files) and $VERIFY_XUM_RUN/server.log" +} + +case ${1:-} in + launch) cmd_launch ;; + doctor) cmd_doctor "${2:-}" ;; + env) cmd_env "${2:-}" ;; + capture) cmd_capture "${2:-}" "${3:-}" ;; + idle) cmd_idle "${2:-}" ;; + send-ready) cmd_send_ready "${2:-}" ;; + cleanup) cmd_cleanup "${2:-}" ;; + *) die "usage: verify-xum.sh launch | doctor [RUN] | env [RUN] | capture NAME [RUN] | idle [RUN] | send-ready [RUN] | cleanup [RUN]" ;; +esac From f3358aafd103fc6de26cebf4f84b968fe0c45061 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski <tk@coder.com> Date: Mon, 2 Nov 2026 23:56:15 +0000 Subject: [PATCH 02/51] =?UTF-8?q?=F0=9F=A4=96=20fix:=20verify-xum=20idle?= =?UTF-8?q?=20exits=203=20on=20a=20lazy=20chunk=20that=20failed=20to=20loa?= =?UTF-8?q?d?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .xum/skills/verify-xum/SKILL.md | 12 ++++++++--- .xum/skills/verify-xum/features/README.md | 2 ++ .xum/skills/verify-xum/scripts/verify-xum.sh | 22 +++++++++++++++----- 3 files changed, 28 insertions(+), 8 deletions(-) diff --git a/.xum/skills/verify-xum/SKILL.md b/.xum/skills/verify-xum/SKILL.md index 39b4a3b0c9e..4d9bf7a600a 100644 --- a/.xum/skills/verify-xum/SKILL.md +++ b/.xum/skills/verify-xum/SKILL.md @@ -84,9 +84,10 @@ agent-browser find role button click --name "Open settings" --exact - Prefer `find role <role> <action> --name "<accessible name>" --exact` and the names in the feature map over `@eN` refs, which change with every snapshot. Without `--exact`, `--name` matches substrings and can act on a different element. -- `idle` waits until the open transcript is neither loading nor streaming (`aria-busy="false"` on - the `Conversation transcript` log) and no CSS transition runs. A click while the right sidebar - slides open lands where the target was and is lost. It needs an open workspace. +- `idle` waits until the page settles: no CSS transition runs, and on a `/workspace/` page the + `Conversation transcript` log is neither loading nor streaming (`aria-busy="false"`). A click + while the right sidebar slides open lands where the target was and is lost. `idle` exits 3 + when the page shows `<name> failed to load.` (see Gotchas). - `send-ready` waits until the composer's `Send message` button is enabled. Fill the message, run `send-ready`, then send. The workspace composer keeps Send disabled until the transcript has caught up, and the new-workspace composer until the branch list has loaded and the draft is @@ -151,6 +152,11 @@ a run other than the newest. drops them with `env -i`. Never start a server or CLI by hand without the same care. - The first open of a lazily loaded panel on a cold Vite cache (Review, for one) reloads the page: `server.log` shows `optimized dependencies changed. reloading`. Repeat the step after the reload. +- Now and then a lazily loaded module request from agent-browser to Vite never completes. The + page or panel then shows `<name> failed to load.` with a `Reload` button, and the console logs + `Failed to fetch dynamically imported module`. curl gets the same module at once, so it is not + a product failure. `verify-xum.sh idle` exits 3 on it. Run `agent-browser reload`, then repeat + the step, and note the reload in your report. - A page that stays on `Loading Xum` under host load recovers with `agent-browser reload`. - agent-browser's headless Chromium matches `(hover: none)`, so Tailwind's `hover:` and `group-hover:` variants never apply. Controls that appear on hover (a sidebar row's archive diff --git a/.xum/skills/verify-xum/features/README.md b/.xum/skills/verify-xum/features/README.md index c37bd738c8b..46851cc60ae 100644 --- a/.xum/skills/verify-xum/features/README.md +++ b/.xum/skills/verify-xum/features/README.md @@ -29,6 +29,8 @@ feature file with the `verify-xum` harness. Update the file in the same change a - `find` does not wait. After a step that navigates or opens a view, the recipe waits for the next target first, with `agent-browser wait '[aria-label="<name>"]'` or `agent-browser wait --text "<text>"`. Keep those waits when you adapt a recipe. +- When a wait times out, run `verify-xum.sh idle`. Exit 3 means a module failed to load (SKILL.md + Gotchas): run `agent-browser reload`, repeat the step, and note the reload with the evidence. - Run recipes in the order of this index when you drive more than one. A recipe that changes a setting restores it at its end. - Shortcuts are the Linux bindings that agent-browser presses. `src/browser/utils/ui/keybinds.ts` diff --git a/.xum/skills/verify-xum/scripts/verify-xum.sh b/.xum/skills/verify-xum/scripts/verify-xum.sh index 025e7730c39..3d8f06daaf0 100755 --- a/.xum/skills/verify-xum/scripts/verify-xum.sh +++ b/.xum/skills/verify-xum/scripts/verify-xum.sh @@ -5,7 +5,7 @@ # verify-xum.sh doctor [RUN] read-only health check of a run # verify-xum.sh env [RUN] print the run's `export` lines (eval them) # verify-xum.sh capture NAME [RUN] screenshot + ARIA snapshot + URL into the run's evidence -# verify-xum.sh idle [RUN] wait until the open transcript is quiet and no CSS transition runs +# verify-xum.sh idle [RUN] wait until the page settles, exit 3 if a chunk failed to load # verify-xum.sh send-ready [RUN] wait until the composer's Send button is enabled # verify-xum.sh cleanup [RUN] stop the run's processes, delete its XUM_ROOT, keep evidence # @@ -271,12 +271,24 @@ cmd_capture() { echo "$base.png $base.aria.txt $base.url.txt" } -# A click during a CSS transition (the right sidebar slides open in about 250 ms) can land where -# the target was, so idle also waits for running transitions to end. +# Settled: no lazy chunk failed to load, no CSS transition runs (a click while the right sidebar +# slides open lands where the target was), and on a workspace page the transcript is neither +# loading nor streaming. A failed chunk exits 3: the page needs a reload. +IDLE_FAILED_JS="[...document.querySelectorAll('[role=\"alert\"]')].some((e) => e.textContent.includes(' failed to load.'))" +IDLE_JS="(() => { + if ($IDLE_FAILED_JS) return true; + const log = document.querySelector('[role=\"log\"][aria-label=\"Conversation transcript\"]'); + if (location.pathname.startsWith('/workspace/') && log?.getAttribute('aria-busy') !== 'false') return false; + return !document.getAnimations().some((a) => a instanceof CSSTransition); +})()" + cmd_idle() { load_run "${1:-}" - agent-browser --session "$AGENT_BROWSER_SESSION" wait --fn \ - "document.querySelector('[role=log][aria-label=\"Conversation transcript\"]')?.getAttribute('aria-busy') === 'false' && !document.getAnimations().some((a) => a instanceof CSSTransition)" >/dev/null + agent-browser --session "$AGENT_BROWSER_SESSION" wait --fn "$IDLE_JS" >/dev/null + if [[ $(agent-browser --session "$AGENT_BROWSER_SESSION" eval "$IDLE_FAILED_JS") == true ]]; then + echo "verify-xum: the page shows '<name> failed to load.': a module fetch from Vite failed. Run agent-browser reload, then repeat the step." >&2 + exit 3 + fi } cmd_send_ready() { From dc61c42f4ea2d46fb8f49413bdaaf230452755d0 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski <tk@coder.com> Date: Mon, 2 Nov 2026 23:56:27 +0000 Subject: [PATCH 03/51] =?UTF-8?q?=F0=9F=A4=96=20docs:=20wrap=20a=20review?= =?UTF-8?q?=20recipe=20line?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .xum/skills/verify-xum/features/review.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.xum/skills/verify-xum/features/review.md b/.xum/skills/verify-xum/features/review.md index 08122bc524f..d81d605a723 100644 --- a/.xum/skills/verify-xum/features/review.md +++ b/.xum/skills/verify-xum/features/review.md @@ -43,7 +43,8 @@ Preconditions: hunk shows the three added lines of `sub`, and the tab reads `Review 0/1`. Capture `review/diff`. - **Mark as read.** Run - `agent-browser find role button click --name "Mark as read (M)" --exact`. The tab reads `Review 1/1`, and the hunk reads `Hunk marked as read`. + `agent-browser find role button click --name "Mark as read (M)" --exact`. The tab reads + `Review 1/1`, and the hunk reads `Hunk marked as read`. `$VERIFY_XUM_ROOT/sessions/$VERIFY_XUM_WORKSPACE_ID/review-state.json` has `"isRead": true` for that hunk within a second. Capture `review/read`. - **Keyboard toggle.** Run `agent-browser press Control+Shift+B`. The sidebar collapses, and the From 2c35da673661cd67d94eecf2e322ef616d61cdfd Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski <tk@coder.com> Date: Mon, 2 Nov 2026 23:58:34 +0000 Subject: [PATCH 04/51] =?UTF-8?q?=F0=9F=A4=96=20refactor:=20strip=20narrat?= =?UTF-8?q?ion=20comments=20from=20verify-xum.sh?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Thomas Kosiewski <tk@coder.com> --- .xum/skills/verify-xum/scripts/verify-xum.sh | 21 ++++---------------- 1 file changed, 4 insertions(+), 17 deletions(-) diff --git a/.xum/skills/verify-xum/scripts/verify-xum.sh b/.xum/skills/verify-xum/scripts/verify-xum.sh index 3d8f06daaf0..d6e95a5b07e 100755 --- a/.xum/skills/verify-xum/scripts/verify-xum.sh +++ b/.xum/skills/verify-xum/scripts/verify-xum.sh @@ -15,8 +15,6 @@ set -euo pipefail SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) CHECKOUT=$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel) VERIFY_XUM_HOME=${VERIFY_XUM_HOME:-${XUM_SCRATCH_DIR:-${TMPDIR:-/tmp}}/verify-xum} -# Per-worktree path inside the git dir: two dev servers in one checkout share dist/ and -# node_modules/.vite and corrupt each other, so launch refuses while this names a live group. LOCK=$(git -C "$CHECKOUT" rev-parse --path-format=absolute --git-path verify-xum.lock) READY_TIMEOUT=${VERIFY_XUM_READY_TIMEOUT:-300} @@ -51,7 +49,6 @@ json_string() { node -p 'JSON.stringify(process.argv[1])' "$1" } -# Prints field $2 (a dotted path) of the JSON file $1, or nothing. json_field() { node -e ' const fs = require("fs"); @@ -62,8 +59,6 @@ json_field() { } catch {}' "$1" "$2" } -# The backend is ready once the server that nodemon restarted after the first watch build -# (dist/.main-build-complete, written in ms) owns server.lock and answers /health. backend_ready() { node -e ' const fs = require("fs"); @@ -110,8 +105,6 @@ create_demo_repo() { seed() { local created status_file status i api splashScreens/markSplashScreenViewed '{"splashId":"onboarding-wizard-v1"}' >/dev/null - # The composer refuses to send without a configured provider. The mock never calls it, - # and port 9 on loopback keeps any stray call on the machine. api providers/setProviderConfig '{"provider":"anthropic","keyPath":["apiKey"],"value":"sk-ant-verify-xum-fake"}' >/dev/null api providers/setProviderConfig '{"provider":"anthropic","keyPath":["baseUrl"],"value":"http://127.0.0.1:9/v1"}' >/dev/null create_demo_repo "$VERIFY_XUM_PROJECT" @@ -163,10 +156,9 @@ cmd_launch() { pidfile="$VERIFY_XUM_RUN/scratch/pgid" t0=$(date +%s) - # env -i: the agent's env carries the live Xum's PORT, auth token, proxy URIs and provider - # keys. setsid: the server outlives this shell, and its process group is what cleanup kills. - # The sh wrapper records that group: setsid forks when its caller already leads a group. - # shellcheck disable=SC2016 # $$ and $@ belong to the inner sh. + # setsid(1) forks when its caller already leads a process group, so $! can miss the new + # group. The inner sh records its own pid, which is the new group id. + # shellcheck disable=SC2016 setsid sh -c 'echo "$$" >"$1"; shift; exec "$@"' sh "$pidfile" \ env -i PATH="$PATH" HOME="$HOME" USER="${USER:-}" LANG="${LANG:-C.UTF-8}" TERM=dumb \ TMPDIR="${TMPDIR:-/tmp}" XUM_MOCK_AI=1 XUM_DISABLE_TELEMETRY=1 \ @@ -271,9 +263,6 @@ cmd_capture() { echo "$base.png $base.aria.txt $base.url.txt" } -# Settled: no lazy chunk failed to load, no CSS transition runs (a click while the right sidebar -# slides open lands where the target was), and on a workspace page the transcript is neither -# loading nor streaming. A failed chunk exits 3: the page needs a reload. IDLE_FAILED_JS="[...document.querySelectorAll('[role=\"alert\"]')].some((e) => e.textContent.includes(' failed to load.'))" IDLE_JS="(() => { if ($IDLE_FAILED_JS) return true; @@ -300,7 +289,7 @@ cmd_send_ready() { cmd_cleanup() { load_run "${1:-}" local i - # `close` on a session that does not exist starts a browser first. + # agent-browser `close` on a session that does not exist starts that session first. if agent-browser session list 2>/dev/null | grep -qF "$AGENT_BROWSER_SESSION"; then agent-browser --session "$AGENT_BROWSER_SESSION" close >/dev/null 2>&1 || true fi @@ -312,8 +301,6 @@ cmd_cleanup() { done group_alive "$VERIFY_XUM_PGID" && kill -KILL -- "-$VERIFY_XUM_PGID" 2>/dev/null fi - # make dev-server-sandbox deletes its own root on a clean exit. Only a root it created - # (mux-dev-server-*) is removed here. if [[ -n $VERIFY_XUM_ROOT && -d $VERIFY_XUM_ROOT && $(basename "$VERIFY_XUM_ROOT") == mux-dev-server-* ]]; then rm -rf "$VERIFY_XUM_ROOT" fi From 0a6cc03759ddd2e0ab8c92cf67080d693dad31cd Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski <tk@coder.com> Date: Mon, 2 Nov 2026 23:59:59 +0000 Subject: [PATCH 05/51] =?UTF-8?q?=F0=9F=A4=96=20refactor:=20name=20the=20v?= =?UTF-8?q?erify-xum=20helpers=20that=20lost=20their=20comments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .xum/skills/verify-xum/scripts/verify-xum.sh | 69 ++++++++++++-------- 1 file changed, 40 insertions(+), 29 deletions(-) diff --git a/.xum/skills/verify-xum/scripts/verify-xum.sh b/.xum/skills/verify-xum/scripts/verify-xum.sh index d6e95a5b07e..39d8d0dbf59 100755 --- a/.xum/skills/verify-xum/scripts/verify-xum.sh +++ b/.xum/skills/verify-xum/scripts/verify-xum.sh @@ -15,8 +15,9 @@ set -euo pipefail SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) CHECKOUT=$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel) VERIFY_XUM_HOME=${VERIFY_XUM_HOME:-${XUM_SCRATCH_DIR:-${TMPDIR:-/tmp}}/verify-xum} -LOCK=$(git -C "$CHECKOUT" rev-parse --path-format=absolute --git-path verify-xum.lock) +ONE_SERVER_PER_CHECKOUT_LOCK=$(git -C "$CHECKOUT" rev-parse --path-format=absolute --git-path verify-xum.lock) READY_TIMEOUT=${VERIFY_XUM_READY_TIMEOUT:-300} +DISCARD_BASE_URL=http://127.0.0.1:9/v1 die() { echo "verify-xum: $*" >&2 @@ -49,7 +50,7 @@ json_string() { node -p 'JSON.stringify(process.argv[1])' "$1" } -json_field() { +json_field_or_empty() { node -e ' const fs = require("fs"); try { @@ -62,11 +63,13 @@ json_field() { backend_ready() { node -e ' const fs = require("fs"); - const [lock, stamp, t0] = process.argv.slice(1); + const [serverLock, buildStamp, launchSeconds] = process.argv.slice(1); try { - const built = Number(fs.readFileSync(stamp, "utf8")); - const started = Date.parse(JSON.parse(fs.readFileSync(lock, "utf8")).startedAt); - process.exit(built >= Number(t0) * 1000 && started > built ? 0 : 1); + const buildStampMs = Number(fs.readFileSync(buildStamp, "utf8")); + const serverStartedMs = Date.parse(JSON.parse(fs.readFileSync(serverLock, "utf8")).startedAt); + const builtSinceLaunch = buildStampMs >= Number(launchSeconds) * 1000; + const serverRestartedAfterBuild = serverStartedMs > buildStampMs; + process.exit(builtSinceLaunch && serverRestartedAfterBuild ? 0 : 1); } catch { process.exit(1); }' "$VERIFY_XUM_ROOT/server.lock" "$CHECKOUT/dist/.main-build-complete" "$1" \ @@ -102,21 +105,25 @@ create_demo_repo() { -c commit.gpgsign=false commit -qm init } +seed_unreachable_fake_provider() { + api providers/setProviderConfig '{"provider":"anthropic","keyPath":["apiKey"],"value":"sk-ant-verify-xum-fake"}' >/dev/null + api providers/setProviderConfig "{\"provider\":\"anthropic\",\"keyPath\":[\"baseUrl\"],\"value\":\"$DISCARD_BASE_URL\"}" >/dev/null +} + seed() { local created status_file status i api splashScreens/markSplashScreenViewed '{"splashId":"onboarding-wizard-v1"}' >/dev/null - api providers/setProviderConfig '{"provider":"anthropic","keyPath":["apiKey"],"value":"sk-ant-verify-xum-fake"}' >/dev/null - api providers/setProviderConfig '{"provider":"anthropic","keyPath":["baseUrl"],"value":"http://127.0.0.1:9/v1"}' >/dev/null + seed_unreachable_fake_provider create_demo_repo "$VERIFY_XUM_PROJECT" api projects/create "{\"projectPath\":$(json_string "$VERIFY_XUM_PROJECT")}" >/dev/null api projects/setTrust "{\"projectPath\":$(json_string "$VERIFY_XUM_PROJECT"),\"trusted\":true}" >/dev/null created="$VERIFY_XUM_RUN/scratch/workspace-create.json" api workspace/create "{\"projectPath\":$(json_string "$VERIFY_XUM_PROJECT"),\"branchName\":\"verify-playground\",\"trunkBranch\":\"main\",\"title\":\"Verify playground\"}" >"$created" - VERIFY_XUM_WORKSPACE_ID=$(json_field "$created" metadata.id) + VERIFY_XUM_WORKSPACE_ID=$(json_field_or_empty "$created" metadata.id) [[ -n $VERIFY_XUM_WORKSPACE_ID ]] || die "workspace/create returned no id: $(head -c 400 "$created")" status_file="$VERIFY_XUM_ROOT/sessions/$VERIFY_XUM_WORKSPACE_ID/init-status.json" for ((i = 0; i < 120; i++)); do - status=$(json_field "$status_file" status) + status=$(json_field_or_empty "$status_file" status) [[ $status == success ]] && break [[ $status == error ]] && die "workspace setup failed, see $status_file" sleep 0.5 @@ -136,12 +143,12 @@ cmd_launch() { local old run_id t0 i backend_port vite_port log pidfile need git curl node bun make setsid ss agent-browser check_bun - if [[ -f $LOCK ]]; then - old=$(cat "$LOCK") + if [[ -f $ONE_SERVER_PER_CHECKOUT_LOCK ]]; then + old=$(cat "$ONE_SERVER_PER_CHECKOUT_LOCK") if group_alive "$old"; then - die "a verify-xum server already runs from this checkout (process group $old). Run verify-xum.sh cleanup first." + die "a verify-xum server already runs from this checkout (process group $old), and two would share dist/ and node_modules/.vite. Run verify-xum.sh cleanup first." fi - rm -f "$LOCK" + rm -f "$ONE_SERVER_PER_CHECKOUT_LOCK" fi run_id="$(date -u +%Y%m%d-%H%M%S)-$$" @@ -156,12 +163,12 @@ cmd_launch() { pidfile="$VERIFY_XUM_RUN/scratch/pgid" t0=$(date +%s) + local env_without_live_xum=(env -i PATH="$PATH" HOME="$HOME" USER="${USER:-}" LANG="${LANG:-C.UTF-8}" + TERM=dumb TMPDIR="${TMPDIR:-/tmp}" XUM_MOCK_AI=1 XUM_DISABLE_TELEMETRY=1) # setsid(1) forks when its caller already leads a process group, so $! can miss the new # group. The inner sh records its own pid, which is the new group id. # shellcheck disable=SC2016 - setsid sh -c 'echo "$$" >"$1"; shift; exec "$@"' sh "$pidfile" \ - env -i PATH="$PATH" HOME="$HOME" USER="${USER:-}" LANG="${LANG:-C.UTF-8}" TERM=dumb \ - TMPDIR="${TMPDIR:-/tmp}" XUM_MOCK_AI=1 XUM_DISABLE_TELEMETRY=1 \ + setsid sh -c 'echo "$$" >"$1"; shift; exec "$@"' sh "$pidfile" "${env_without_live_xum[@]}" \ make -C "$CHECKOUT" dev-server-sandbox DEV_SERVER_SANDBOX_ARGS="--clean-providers --clean-projects" \ >"$log" 2>&1 </dev/null & for ((i = 0; i < 50; i++)); do @@ -169,7 +176,7 @@ cmd_launch() { sleep 0.1 done VERIFY_XUM_PGID=$(cat "$pidfile") - echo "$VERIFY_XUM_PGID" >"$LOCK" + echo "$VERIFY_XUM_PGID" >"$ONE_SERVER_PER_CHECKOUT_LOCK" write_state ln -sfn "$VERIFY_XUM_RUN" "$VERIFY_XUM_HOME/current" trap launch_failed EXIT @@ -224,15 +231,15 @@ cmd_doctor() { } check "process group $VERIFY_XUM_PGID is alive" group_alive "$VERIFY_XUM_PGID" - check "checkout lock $LOCK names this run" test "$(cat "$LOCK" 2>/dev/null)" = "$VERIFY_XUM_PGID" - backend_pid=$(json_field "$VERIFY_XUM_ROOT/server.lock" pid) + check "checkout lock $ONE_SERVER_PER_CHECKOUT_LOCK names this run" test "$(cat "$ONE_SERVER_PER_CHECKOUT_LOCK" 2>/dev/null)" = "$VERIFY_XUM_PGID" + backend_pid=$(json_field_or_empty "$VERIFY_XUM_ROOT/server.lock" pid) check "backend pid ${backend_pid:-none} (server.lock in $VERIFY_XUM_ROOT) belongs to this run" \ pid_in_group "$backend_pid" "$VERIFY_XUM_PGID" check "server.lock names $VERIFY_XUM_BACKEND" \ - test "$(json_field "$VERIFY_XUM_ROOT/server.lock" baseUrl)" = "$VERIFY_XUM_BACKEND" + test "$(json_field_or_empty "$VERIFY_XUM_ROOT/server.lock" baseUrl)" = "$VERIFY_XUM_BACKEND" check "GET $VERIFY_XUM_BACKEND/health answers" curl -fsS -o /dev/null "$VERIFY_XUM_BACKEND/health" curl -fsS "$VERIFY_XUM_BACKEND/version" -o "$VERIFY_XUM_RUN/scratch/version.json" 2>/dev/null || true - version=$(json_field "$VERIFY_XUM_RUN/scratch/version.json" git_commit) + version=$(json_field_or_empty "$VERIFY_XUM_RUN/scratch/version.json" git_commit) check "backend build ${version:-unknown} is the launch commit $VERIFY_XUM_COMMIT" \ test -n "$version" -a "${VERIFY_XUM_COMMIT#"$version"}" != "$VERIFY_XUM_COMMIT" if [[ $(git -C "$VERIFY_XUM_CHECKOUT" rev-parse HEAD) != "$VERIFY_XUM_COMMIT" ]]; then @@ -263,9 +270,9 @@ cmd_capture() { echo "$base.png $base.aria.txt $base.url.txt" } -IDLE_FAILED_JS="[...document.querySelectorAll('[role=\"alert\"]')].some((e) => e.textContent.includes(' failed to load.'))" -IDLE_JS="(() => { - if ($IDLE_FAILED_JS) return true; +CHUNK_LOAD_FAILED_JS="[...document.querySelectorAll('[role=\"alert\"]')].some((e) => e.textContent.includes(' failed to load.'))" +SETTLED_OR_CHUNK_FAILED_JS="(() => { + if ($CHUNK_LOAD_FAILED_JS) return true; const log = document.querySelector('[role=\"log\"][aria-label=\"Conversation transcript\"]'); if (location.pathname.startsWith('/workspace/') && log?.getAttribute('aria-busy') !== 'false') return false; return !document.getAnimations().some((a) => a instanceof CSSTransition); @@ -273,8 +280,8 @@ IDLE_JS="(() => { cmd_idle() { load_run "${1:-}" - agent-browser --session "$AGENT_BROWSER_SESSION" wait --fn "$IDLE_JS" >/dev/null - if [[ $(agent-browser --session "$AGENT_BROWSER_SESSION" eval "$IDLE_FAILED_JS") == true ]]; then + agent-browser --session "$AGENT_BROWSER_SESSION" wait --fn "$SETTLED_OR_CHUNK_FAILED_JS" >/dev/null + if [[ $(agent-browser --session "$AGENT_BROWSER_SESSION" eval "$CHUNK_LOAD_FAILED_JS") == true ]]; then echo "verify-xum: the page shows '<name> failed to load.': a module fetch from Vite failed. Run agent-browser reload, then repeat the step." >&2 exit 3 fi @@ -286,6 +293,10 @@ cmd_send_ready() { "document.querySelector('[aria-label=\"Send message\"]')?.disabled === false" >/dev/null } +is_dev_server_sandbox_root() { + [[ -n $1 && -d $1 && $(basename "$1") == mux-dev-server-* ]] +} + cmd_cleanup() { load_run "${1:-}" local i @@ -301,11 +312,11 @@ cmd_cleanup() { done group_alive "$VERIFY_XUM_PGID" && kill -KILL -- "-$VERIFY_XUM_PGID" 2>/dev/null fi - if [[ -n $VERIFY_XUM_ROOT && -d $VERIFY_XUM_ROOT && $(basename "$VERIFY_XUM_ROOT") == mux-dev-server-* ]]; then + if is_dev_server_sandbox_root "$VERIFY_XUM_ROOT"; then rm -rf "$VERIFY_XUM_ROOT" fi rm -rf "$VERIFY_XUM_RUN/scratch" - [[ $(cat "$LOCK" 2>/dev/null) == "$VERIFY_XUM_PGID" ]] && rm -f "$LOCK" + [[ $(cat "$ONE_SERVER_PER_CHECKOUT_LOCK" 2>/dev/null) == "$VERIFY_XUM_PGID" ]] && rm -f "$ONE_SERVER_PER_CHECKOUT_LOCK" [[ $(readlink "$VERIFY_XUM_HOME/current" 2>/dev/null) == "$VERIFY_XUM_RUN" ]] && rm -f "$VERIFY_XUM_HOME/current" echo "stopped group $VERIFY_XUM_PGID; evidence kept in $VERIFY_XUM_EVIDENCE ($(find "$VERIFY_XUM_EVIDENCE" -type f | wc -l | tr -d ' ') files) and $VERIFY_XUM_RUN/server.log" } From ae3d23714abfee8b1ed1d3546fdd69703993047c Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski <tk@coder.com> Date: Tue, 3 Nov 2026 00:02:54 +0000 Subject: [PATCH 06/51] =?UTF-8?q?=F0=9F=A4=96=20fix:=20keep=20verify-xum?= =?UTF-8?q?=20doctor=20output=20to=20its=20ok/FAIL=20lines?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .xum/skills/verify-xum/scripts/verify-xum.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.xum/skills/verify-xum/scripts/verify-xum.sh b/.xum/skills/verify-xum/scripts/verify-xum.sh index 39d8d0dbf59..6b7f71225cc 100755 --- a/.xum/skills/verify-xum/scripts/verify-xum.sh +++ b/.xum/skills/verify-xum/scripts/verify-xum.sh @@ -237,7 +237,7 @@ cmd_doctor() { pid_in_group "$backend_pid" "$VERIFY_XUM_PGID" check "server.lock names $VERIFY_XUM_BACKEND" \ test "$(json_field_or_empty "$VERIFY_XUM_ROOT/server.lock" baseUrl)" = "$VERIFY_XUM_BACKEND" - check "GET $VERIFY_XUM_BACKEND/health answers" curl -fsS -o /dev/null "$VERIFY_XUM_BACKEND/health" + check "GET $VERIFY_XUM_BACKEND/health answers" curl -fs -o /dev/null "$VERIFY_XUM_BACKEND/health" curl -fsS "$VERIFY_XUM_BACKEND/version" -o "$VERIFY_XUM_RUN/scratch/version.json" 2>/dev/null || true version=$(json_field_or_empty "$VERIFY_XUM_RUN/scratch/version.json" git_commit) check "backend build ${version:-unknown} is the launch commit $VERIFY_XUM_COMMIT" \ From 8601eba987a5cdb0f2f14468e00f4a8d039fe881 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski <tk@coder.com> Date: Tue, 3 Nov 2026 00:18:41 +0000 Subject: [PATCH 07/51] =?UTF-8?q?=F0=9F=A4=96=20fix:=20address=20verify-xu?= =?UTF-8?q?m=20review:=20per-checkout=20run=20link,=20atomic=20lock,=20bou?= =?UTF-8?q?nded=20probes,=20group=20identity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The newest-run link lives in the checkout's git dir next to the lock, so two checkouts that share VERIFY_XUM_HOME never load each other's run. - launch takes the checkout lock under flock, so two launches at once cannot both start. - Every curl has --max-time, and the readiness loop uses a wall-clock deadline. - doctor deletes the previous version.json before it asks /version again. - cleanup signals the group only while a process in it has VERIFY_XUM_SERVER_OF=<run id>. - SKILL.md says a canned todo_write still writes its TODO list. --- .xum/skills/verify-xum/SKILL.md | 19 +++-- .xum/skills/verify-xum/scripts/verify-xum.sh | 88 +++++++++++++------- 2 files changed, 68 insertions(+), 39 deletions(-) diff --git a/.xum/skills/verify-xum/SKILL.md b/.xum/skills/verify-xum/SKILL.md index 4d9bf7a600a..93e4be5c2c1 100644 --- a/.xum/skills/verify-xum/SKILL.md +++ b/.xum/skills/verify-xum/SKILL.md @@ -63,10 +63,11 @@ Run it first whenever anything looks off. It changes nothing. .xum/skills/verify-xum/scripts/verify-xum.sh doctor ``` -Every line must read `ok`. It checks that the process group lives, the checkout lock names it, the -backend pid in the sandbox `server.lock` and the app port belong to the group, `/health` answers, -`/version` reports the launch commit, and the backend env has `XUM_MOCK_AI=1`. Exit 1 means do not -drive this instance: run cleanup, then launch again. +Every line must read `ok`. It checks that the process group lives and carries the run's id +(`VERIFY_XUM_SERVER_OF` in its env), the checkout lock names it, the backend pid in the sandbox +`server.lock` and the app port belong to the group, `/health` answers, `/version` reports the +launch commit, and the backend env has `XUM_MOCK_AI=1`. Exit 1 means do not drive this instance: +run cleanup, then launch again. ## Drive @@ -100,7 +101,8 @@ agent-browser find role button click --name "Open settings" --exact - Mock replies are `Mock response: <your text>`. `[mock:long-stream]` in a message streams for about 20 s, long enough to press Stop. Markers such as `[mock:error:api]` and `[mock:list-languages]` are in `src/node/services/mock/mockAiRouter.ts`. Mock tool calls are - canned and change no files. + canned and leave the worktree alone. A canned `todo_write` (`[mock:todo-nudge]`) still writes + its TODO list to the workspace's session folder. - The CLI drives the same server. Unset the live server's variables, or the CLI talks to the Xum you run in: @@ -142,9 +144,10 @@ Proof standards: Cleanup closes this run's browser session, stops the process group that launch started (SIGTERM, then SIGKILL after 20 s), deletes the sandbox XUM_ROOT and the demo repo, and prints the evidence -path and file count. It kills nothing it did not start. Run it after every run, failed ones too. -`doctor`, `env`, `capture`, `idle`, `send-ready` and `cleanup` take an optional run directory for -a run other than the newest. +path and file count. It signals the group only while one of its processes carries the run's id, +so it kills nothing it did not start. Run it after every run, failed ones too. `doctor`, `env`, +`capture`, `idle`, `send-ready` and `cleanup` take an optional run directory. Without one they use +the newest launch from this checkout. ## Gotchas diff --git a/.xum/skills/verify-xum/scripts/verify-xum.sh b/.xum/skills/verify-xum/scripts/verify-xum.sh index 6b7f71225cc..9a6a91a5d44 100755 --- a/.xum/skills/verify-xum/scripts/verify-xum.sh +++ b/.xum/skills/verify-xum/scripts/verify-xum.sh @@ -9,13 +9,15 @@ # verify-xum.sh send-ready [RUN] wait until the composer's Send button is enabled # verify-xum.sh cleanup [RUN] stop the run's processes, delete its XUM_ROOT, keep evidence # -# RUN defaults to the newest launch: $VERIFY_XUM_HOME/current. +# RUN defaults to the newest launch from this checkout. set -euo pipefail SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) CHECKOUT=$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel) VERIFY_XUM_HOME=${VERIFY_XUM_HOME:-${XUM_SCRATCH_DIR:-${TMPDIR:-/tmp}}/verify-xum} -ONE_SERVER_PER_CHECKOUT_LOCK=$(git -C "$CHECKOUT" rev-parse --path-format=absolute --git-path verify-xum.lock) +CHECKOUT_GIT_DIR=$(git -C "$CHECKOUT" rev-parse --path-format=absolute --git-dir) +ONE_SERVER_PER_CHECKOUT_LOCK="$CHECKOUT_GIT_DIR/verify-xum.lock" +CURRENT_RUN_LINK="$CHECKOUT_GIT_DIR/verify-xum.current" READY_TIMEOUT=${VERIFY_XUM_READY_TIMEOUT:-300} DISCARD_BASE_URL=http://127.0.0.1:9/v1 @@ -35,15 +37,39 @@ group_alive() { kill -0 -- "-$1" 2>/dev/null } +# A process group belongs to this run only while one of its processes has +# VERIFY_XUM_SERVER_OF=<run id> in its env, so a group id reused after the run died is left alone. +# state.env never exports that name, so processes an agent starts after `eval "$(verify-xum.sh env)"` +# do not carry it. +group_is_ours() { + local pid + for pid in $(ps -o pid= -g "$1" 2>/dev/null); do + grep -qszx "VERIFY_XUM_SERVER_OF=$2" "/proc/$pid/environ" && return 0 + done + return 1 +} + +take_checkout_lock() { + local holder + exec 9>"$ONE_SERVER_PER_CHECKOUT_LOCK.guard" + flock -w 30 9 || die "another verify-xum launch holds $ONE_SERVER_PER_CHECKOUT_LOCK.guard" + holder=$(cat "$ONE_SERVER_PER_CHECKOUT_LOCK" 2>/dev/null || true) + if [[ -n $holder ]] && { kill -0 "$holder" 2>/dev/null || group_alive "$holder"; }; then + die "a verify-xum server already runs from this checkout (process $holder), and two would share dist/ and node_modules/.vite. Run verify-xum.sh cleanup first." + fi + echo "$$" >"$ONE_SERVER_PER_CHECKOUT_LOCK" + exec 9>&- +} + load_run() { - local run=${1:-$VERIFY_XUM_HOME/current} + local run=${1:-$CURRENT_RUN_LINK} [[ -f $run/state.env ]] || die "no run at $run (run: verify-xum.sh launch)" # shellcheck source=/dev/null source "$run/state.env" } api() { - curl -fsS -X POST "$VERIFY_XUM_BACKEND/api/$1" -H 'content-type: application/json' -d "$2" + curl -fsS --max-time 60 -X POST "$VERIFY_XUM_BACKEND/api/$1" -H 'content-type: application/json' -d "$2" } json_string() { @@ -73,13 +99,13 @@ backend_ready() { } catch { process.exit(1); }' "$VERIFY_XUM_ROOT/server.lock" "$CHECKOUT/dist/.main-build-complete" "$1" \ - && curl -fsS -o /dev/null "$VERIFY_XUM_BACKEND/health" + && curl -fs --max-time 5 -o /dev/null "$VERIFY_XUM_BACKEND/health" } write_state() { local name : >"$VERIFY_XUM_RUN/state.env" - for name in VERIFY_XUM_RUN VERIFY_XUM_CHECKOUT VERIFY_XUM_COMMIT VERIFY_XUM_PGID VERIFY_XUM_ROOT \ + for name in VERIFY_XUM_RUN_ID VERIFY_XUM_RUN VERIFY_XUM_CHECKOUT VERIFY_XUM_COMMIT VERIFY_XUM_PGID VERIFY_XUM_ROOT \ VERIFY_XUM_BACKEND VERIFY_XUM_URL VERIFY_XUM_PROJECT VERIFY_XUM_WORKSPACE_ID \ VERIFY_XUM_WORKTREE VERIFY_XUM_EVIDENCE AGENT_BROWSER_SESSION; do printf 'export %s=%q\n' "$name" "${!name-}" >>"$VERIFY_XUM_RUN/state.env" @@ -140,31 +166,27 @@ launch_failed() { } cmd_launch() { - local old run_id t0 i backend_port vite_port log pidfile - need git curl node bun make setsid ss agent-browser + local t0 deadline i backend_port vite_port log pidfile + need git curl node bun make setsid flock ss agent-browser check_bun - if [[ -f $ONE_SERVER_PER_CHECKOUT_LOCK ]]; then - old=$(cat "$ONE_SERVER_PER_CHECKOUT_LOCK") - if group_alive "$old"; then - die "a verify-xum server already runs from this checkout (process group $old), and two would share dist/ and node_modules/.vite. Run verify-xum.sh cleanup first." - fi - rm -f "$ONE_SERVER_PER_CHECKOUT_LOCK" - fi + take_checkout_lock - run_id="$(date -u +%Y%m%d-%H%M%S)-$$" - VERIFY_XUM_RUN="$VERIFY_XUM_HOME/$run_id" + VERIFY_XUM_RUN_ID="$(date -u +%Y%m%d-%H%M%S)-$$" + VERIFY_XUM_RUN="$VERIFY_XUM_HOME/$VERIFY_XUM_RUN_ID" VERIFY_XUM_EVIDENCE="$VERIFY_XUM_RUN/evidence" VERIFY_XUM_PROJECT="$VERIFY_XUM_RUN/scratch/demo-app" VERIFY_XUM_CHECKOUT=$CHECKOUT VERIFY_XUM_COMMIT=$(git -C "$CHECKOUT" rev-parse HEAD) - AGENT_BROWSER_SESSION="verify-xum-$run_id" + AGENT_BROWSER_SESSION="verify-xum-$VERIFY_XUM_RUN_ID" mkdir -p "$VERIFY_XUM_EVIDENCE" "$VERIFY_XUM_RUN/scratch" log="$VERIFY_XUM_RUN/server.log" pidfile="$VERIFY_XUM_RUN/scratch/pgid" t0=$(date +%s) + deadline=$((SECONDS + READY_TIMEOUT)) local env_without_live_xum=(env -i PATH="$PATH" HOME="$HOME" USER="${USER:-}" LANG="${LANG:-C.UTF-8}" - TERM=dumb TMPDIR="${TMPDIR:-/tmp}" XUM_MOCK_AI=1 XUM_DISABLE_TELEMETRY=1) + TERM=dumb TMPDIR="${TMPDIR:-/tmp}" XUM_MOCK_AI=1 XUM_DISABLE_TELEMETRY=1 + VERIFY_XUM_SERVER_OF="$VERIFY_XUM_RUN_ID") # setsid(1) forks when its caller already leads a process group, so $! can miss the new # group. The inner sh records its own pid, which is the new group id. # shellcheck disable=SC2016 @@ -178,7 +200,7 @@ cmd_launch() { VERIFY_XUM_PGID=$(cat "$pidfile") echo "$VERIFY_XUM_PGID" >"$ONE_SERVER_PER_CHECKOUT_LOCK" write_state - ln -sfn "$VERIFY_XUM_RUN" "$VERIFY_XUM_HOME/current" + ln -sfn "$VERIFY_XUM_RUN" "$CURRENT_RUN_LINK" trap launch_failed EXIT for ((i = 0; i < 120; i++)); do @@ -194,12 +216,11 @@ cmd_launch() { VERIFY_XUM_URL="http://127.0.0.1:$vite_port" write_state - for ((i = 0; i < READY_TIMEOUT; i++)); do - backend_ready "$t0" && curl -fsS -o /dev/null "$VERIFY_XUM_URL/" && break + until backend_ready "$t0" && curl -fs --max-time 5 -o /dev/null "$VERIFY_XUM_URL/"; do + ((SECONDS < deadline)) || die "the dev server was not ready in $READY_TIMEOUT s" group_alive "$VERIFY_XUM_PGID" || die "the dev server exited before it was ready" sleep 1 done - ((i < READY_TIMEOUT)) || die "the dev server was not ready in $READY_TIMEOUT s" seed write_state @@ -209,7 +230,7 @@ cmd_launch() { } serves_vite_ui() { - curl -fsS "$1/" 2>/dev/null | grep -q '/@vite/client' + curl -fs --max-time 5 "$1/" 2>/dev/null | grep -q '/@vite/client' } pid_in_group() { @@ -230,15 +251,16 @@ cmd_doctor() { fi } - check "process group $VERIFY_XUM_PGID is alive" group_alive "$VERIFY_XUM_PGID" + check "process group $VERIFY_XUM_PGID is alive and carries this run's id" group_is_ours "$VERIFY_XUM_PGID" "$VERIFY_XUM_RUN_ID" check "checkout lock $ONE_SERVER_PER_CHECKOUT_LOCK names this run" test "$(cat "$ONE_SERVER_PER_CHECKOUT_LOCK" 2>/dev/null)" = "$VERIFY_XUM_PGID" backend_pid=$(json_field_or_empty "$VERIFY_XUM_ROOT/server.lock" pid) check "backend pid ${backend_pid:-none} (server.lock in $VERIFY_XUM_ROOT) belongs to this run" \ pid_in_group "$backend_pid" "$VERIFY_XUM_PGID" check "server.lock names $VERIFY_XUM_BACKEND" \ test "$(json_field_or_empty "$VERIFY_XUM_ROOT/server.lock" baseUrl)" = "$VERIFY_XUM_BACKEND" - check "GET $VERIFY_XUM_BACKEND/health answers" curl -fs -o /dev/null "$VERIFY_XUM_BACKEND/health" - curl -fsS "$VERIFY_XUM_BACKEND/version" -o "$VERIFY_XUM_RUN/scratch/version.json" 2>/dev/null || true + check "GET $VERIFY_XUM_BACKEND/health answers" curl -fs --max-time 5 -o /dev/null "$VERIFY_XUM_BACKEND/health" + rm -f "$VERIFY_XUM_RUN/scratch/version.json" + curl -fs --max-time 5 "$VERIFY_XUM_BACKEND/version" -o "$VERIFY_XUM_RUN/scratch/version.json" || true version=$(json_field_or_empty "$VERIFY_XUM_RUN/scratch/version.json" git_commit) check "backend build ${version:-unknown} is the launch commit $VERIFY_XUM_COMMIT" \ test -n "$version" -a "${VERIFY_XUM_COMMIT#"$version"}" != "$VERIFY_XUM_COMMIT" @@ -304,21 +326,25 @@ cmd_cleanup() { if agent-browser session list 2>/dev/null | grep -qF "$AGENT_BROWSER_SESSION"; then agent-browser --session "$AGENT_BROWSER_SESSION" close >/dev/null 2>&1 || true fi - if group_alive "$VERIFY_XUM_PGID"; then + if group_is_ours "$VERIFY_XUM_PGID" "$VERIFY_XUM_RUN_ID"; then kill -TERM -- "-$VERIFY_XUM_PGID" 2>/dev/null || true for ((i = 0; i < 20; i++)); do group_alive "$VERIFY_XUM_PGID" || break sleep 1 done - group_alive "$VERIFY_XUM_PGID" && kill -KILL -- "-$VERIFY_XUM_PGID" 2>/dev/null + if group_is_ours "$VERIFY_XUM_PGID" "$VERIFY_XUM_RUN_ID"; then + kill -KILL -- "-$VERIFY_XUM_PGID" 2>/dev/null || true + fi + elif group_alive "$VERIFY_XUM_PGID"; then + echo "verify-xum: process group $VERIFY_XUM_PGID no longer carries this run's id. Not signaling it." >&2 fi if is_dev_server_sandbox_root "$VERIFY_XUM_ROOT"; then rm -rf "$VERIFY_XUM_ROOT" fi rm -rf "$VERIFY_XUM_RUN/scratch" [[ $(cat "$ONE_SERVER_PER_CHECKOUT_LOCK" 2>/dev/null) == "$VERIFY_XUM_PGID" ]] && rm -f "$ONE_SERVER_PER_CHECKOUT_LOCK" - [[ $(readlink "$VERIFY_XUM_HOME/current" 2>/dev/null) == "$VERIFY_XUM_RUN" ]] && rm -f "$VERIFY_XUM_HOME/current" - echo "stopped group $VERIFY_XUM_PGID; evidence kept in $VERIFY_XUM_EVIDENCE ($(find "$VERIFY_XUM_EVIDENCE" -type f | wc -l | tr -d ' ') files) and $VERIFY_XUM_RUN/server.log" + [[ $(readlink "$CURRENT_RUN_LINK" 2>/dev/null) == "$VERIFY_XUM_RUN" ]] && rm -f "$CURRENT_RUN_LINK" + echo "cleaned up run $VERIFY_XUM_RUN_ID; evidence kept in $VERIFY_XUM_EVIDENCE ($(find "$VERIFY_XUM_EVIDENCE" -type f | wc -l | tr -d ' ') files) and $VERIFY_XUM_RUN/server.log" } case ${1:-} in From f8fb2fe5bb33cf3efa6fea78577dfda3aa25c67b Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski <tk@coder.com> Date: Tue, 3 Nov 2026 00:37:50 +0000 Subject: [PATCH 08/51] =?UTF-8?q?=F0=9F=A4=96=20fix:=20wait=20for=20the=20?= =?UTF-8?q?selected=20palette=20option,=20stop=20on=20a=20failed=20wait,?= =?UTF-8?q?=20bound=20every=20launch=20phase?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - settings.md and command-palette.md wait for the option to be selected before Enter. The first palette open after a page load shows a loading input that drops Enter (LazyCommandPalette.tsx:37/60), so an Enter typed right after the text was lost. - settings.md waits until no modal dialog is open after Escape. The palette shortcuts do nothing while one is (isDialogOpen(), keybinds.ts:291, App.tsx:1133). - README and SKILL.md: run a step's commands as one && chain, so a failed wait stops it instead of typing into the composer. - env also puts the helper on PATH, so the recipes' bare verify-xum.sh resolves. - launch checks its deadline during port discovery, the readiness loop, every seed API call and the workspace-setup wait. --- .xum/skills/verify-xum/SKILL.md | 5 ++- .xum/skills/verify-xum/features/README.md | 7 ++-- .../verify-xum/features/command-palette.md | 14 ++++++-- .xum/skills/verify-xum/features/settings.md | 17 +++++++--- .xum/skills/verify-xum/scripts/verify-xum.sh | 32 ++++++++++++------- 5 files changed, 53 insertions(+), 22 deletions(-) diff --git a/.xum/skills/verify-xum/SKILL.md b/.xum/skills/verify-xum/SKILL.md index 93e4be5c2c1..fcc6025f1ee 100644 --- a/.xum/skills/verify-xum/SKILL.md +++ b/.xum/skills/verify-xum/SKILL.md @@ -41,7 +41,8 @@ Bun on PATH must be the version in `package.json` `packageManager`. Launch refus Launch runs `make dev-server-sandbox` with `--clean-providers --clean-projects`, the mock AI and an `env -i` allowlist, in its own process group. It returns when the app is usable, in about 15 s when -`dist/` is already built. It gives up after `VERIFY_XUM_READY_TIMEOUT` seconds (default 300). +`dist/` is already built. It gives up when the whole launch, seed included, takes longer than +`VERIFY_XUM_READY_TIMEOUT` seconds (default 300). Ready means all of these hold: - the backend that nodemon restarts after the first watch build owns `server.lock` and answers @@ -96,6 +97,8 @@ agent-browser find role button click --name "Open settings" --exact - After an action that opens a view, wait for the view's text (`agent-browser wait --text "<text>"`) before the next key press. The URL changes before the view mounts, and an Escape sent too early leaves a dialog open that hides the rest of the page from `find`. +- Chain a wait and the key presses after it with `&&`. A failed wait then stops the chain, and no + keys go to whatever has focus instead. - `snapshot -i` lists only interactive elements. Use a full `snapshot` to read text, headings and states such as `INTERRUPTED`. - Mock replies are `Mock response: <your text>`. `[mock:long-stream]` in a message streams for diff --git a/.xum/skills/verify-xum/features/README.md b/.xum/skills/verify-xum/features/README.md index 46851cc60ae..fe486cb48ed 100644 --- a/.xum/skills/verify-xum/features/README.md +++ b/.xum/skills/verify-xum/features/README.md @@ -14,8 +14,11 @@ feature file with the `verify-xum` harness. Update the file in the same change a ## Driving conventions -- Start each bash call with `eval "$(.xum/skills/verify-xum/scripts/verify-xum.sh env)"`. The - recipes write the helper as `verify-xum.sh`. +- Start each bash call with `eval "$(.xum/skills/verify-xum/scripts/verify-xum.sh env)"`. It also + puts the helper on `PATH`, so the recipes call it as `verify-xum.sh`. +- Run each step's commands as one `&&` chain, or under `set -e`, so the first failed `wait` stops + the step. Keys pressed after a failed wait go to whatever has focus. In one run a palette query + went into the composer, and the Enter after it sent that text as a chat message. - Open the app with `agent-browser open "$VERIFY_XUM_URL/workspace/$VERIFY_XUM_WORKSPACE_ID"`, run `verify-xum.sh idle`, and expand the project in the sidebar with `agent-browser find role button click --name "Expand project demo-app" --exact`. A new browser diff --git a/.xum/skills/verify-xum/features/command-palette.md b/.xum/skills/verify-xum/features/command-palette.md index b3aa772d9e5..66b354edb37 100644 --- a/.xum/skills/verify-xum/features/command-palette.md +++ b/.xum/skills/verify-xum/features/command-palette.md @@ -27,13 +27,15 @@ Preconditions: expanded, with the listbox `Suggestions`. Run `agent-browser press Escape`. The combobox is gone. - **Switch workspace.** Press Ctrl+Shift+P, wait for the palette as above, then run - `agent-browser keyboard type "release"`. `Suggestions` lists the option `release-notes`. Run - `agent-browser press Enter`. The URL is `/workspace/<release-notes id>`. Capture - `command-palette/switched`. + `agent-browser keyboard type "release"` and + `agent-browser wait '[role="option"][aria-selected="true"][aria-label="release-notes"]'`. + `Suggestions` lists the option `release-notes`, selected. Run `agent-browser press Enter`. The URL + is `/workspace/<release-notes id>`. Capture `command-palette/switched`. - **Command mode.** Run `agent-browser press F4` and `agent-browser wait '[aria-label="Command palette"]'`. The combobox value is `>`, and `Suggestions` lists commands such as `Open Settings` and `Change Model…`. - **Run a command.** Run `agent-browser keyboard type "Change Model"`, + `agent-browser wait '[role="option"][aria-selected="true"][aria-label="Change Model…"]'`, `agent-browser press Enter` and `agent-browser wait --text "Show all models…"`. The model picker opens with the textbox `Search [provider:model-name]`. Run `agent-browser find text "Sonnet 5.5" click --exact`. The composer's combobox reads @@ -41,6 +43,12 @@ Preconditions: ## Gotchas +- Wait for the option you want to be selected before you press Enter. The first palette open after + a page load shows a plain input while the palette's code loads (`LoadingPaletteInput` at + `src/browser/components/CommandPalette/LazyCommandPalette.tsx:37`). It has the same label + `Command palette` and keeps typed text, but it ignores Enter (line 60). agent-browser types and + presses Enter in a few milliseconds, before the code arrives. A person who pastes a query and + presses Enter on a slow connection loses the Enter the same way. - Type into the palette with `agent-browser keyboard type`. The combobox has focus as soon as it opens. - Workspace options show the title only. A leading `•` marks the open workspace. diff --git a/.xum/skills/verify-xum/features/settings.md b/.xum/skills/verify-xum/features/settings.md index 9fba6e142aa..7acc8b8b043 100644 --- a/.xum/skills/verify-xum/features/settings.md +++ b/.xum/skills/verify-xum/features/settings.md @@ -27,12 +27,15 @@ Preconditions: `agent-browser find role button click --name "Open settings" --exact` and `agent-browser wait --text "Choose your preferred theme"`. The URL is `/settings/general`, and a full `snapshot` shows the dialog `Settings` with the section buttons `General`, `Providers` and - `Models`. Run `agent-browser press Escape`. The URL is back on the - workspace. + `Models`. Run `agent-browser press Escape` and + `agent-browser wait --fn '!document.querySelector("[data-modal-dialog-overlay][data-state=open], [role=dialog][aria-modal=true]")'`. + The URL is back on the workspace. - **Open from the palette.** Run `agent-browser press F4`, `agent-browser wait '[aria-label="Command palette"]'`, - `agent-browser keyboard type "Open Settings"`, `agent-browser press Enter` and - `agent-browser wait --text "Choose your preferred theme"`. The URL is `/settings/general`. + `agent-browser keyboard type "Open Settings"`, + `agent-browser wait '[role="option"][aria-selected="true"][aria-label="Open Settings"]'`, + `agent-browser press Enter` and `agent-browser wait --text "Choose your preferred theme"`. The + URL is `/settings/general`. - **Change a setting.** Run `agent-browser find role switch click --name "Toggle flat chat list" --exact`. The switch is `checked=true`, and `$VERIFY_XUM_ROOT/config.json` has `"sidebarFlatMode": true` under @@ -50,6 +53,12 @@ Preconditions: ## Gotchas +- The palette shortcuts do nothing while a modal dialog is open, and the Settings dialog stays in + the page for a moment after Escape (`isDialogOpen()` at `src/browser/utils/ui/keybinds.ts:291`, + checked by the shortcut handler at `src/browser/App.tsx:1133`). The wait after Escape uses the same + selectors. Without it, F4 is lost and the typed text goes to the composer. +- The palette steps wait for the selected option before Enter. See + [command-palette](./command-palette.md) Gotchas. - `snapshot -i` lists only the dialog's buttons and switches. Use a full `snapshot` to read the section text. - Flat mode renames sidebar rows to `Select workspace <title> (<project>)`. Restore it before you diff --git a/.xum/skills/verify-xum/scripts/verify-xum.sh b/.xum/skills/verify-xum/scripts/verify-xum.sh index 9a6a91a5d44..d65877fe49e 100755 --- a/.xum/skills/verify-xum/scripts/verify-xum.sh +++ b/.xum/skills/verify-xum/scripts/verify-xum.sh @@ -3,7 +3,7 @@ # # verify-xum.sh launch start and seed a sandbox, print its state # verify-xum.sh doctor [RUN] read-only health check of a run -# verify-xum.sh env [RUN] print the run's `export` lines (eval them) +# verify-xum.sh env [RUN] print the run's `export` lines and put this script on PATH (eval them) # verify-xum.sh capture NAME [RUN] screenshot + ARIA snapshot + URL into the run's evidence # verify-xum.sh idle [RUN] wait until the page settles, exit 3 if a chunk failed to load # verify-xum.sh send-ready [RUN] wait until the composer's Send button is enabled @@ -68,8 +68,15 @@ load_run() { source "$run/state.env" } +LAUNCH_DEADLINE= +before_launch_deadline() { + ((SECONDS < LAUNCH_DEADLINE)) || die "launch did not finish in $READY_TIMEOUT s" +} + api() { - curl -fsS --max-time 60 -X POST "$VERIFY_XUM_BACKEND/api/$1" -H 'content-type: application/json' -d "$2" + before_launch_deadline + curl -fsS --max-time $((LAUNCH_DEADLINE - SECONDS)) -X POST "$VERIFY_XUM_BACKEND/api/$1" \ + -H 'content-type: application/json' -d "$2" } json_string() { @@ -137,7 +144,7 @@ seed_unreachable_fake_provider() { } seed() { - local created status_file status i + local created status_file status api splashScreens/markSplashScreenViewed '{"splashId":"onboarding-wizard-v1"}' >/dev/null seed_unreachable_fake_provider create_demo_repo "$VERIFY_XUM_PROJECT" @@ -148,13 +155,11 @@ seed() { VERIFY_XUM_WORKSPACE_ID=$(json_field_or_empty "$created" metadata.id) [[ -n $VERIFY_XUM_WORKSPACE_ID ]] || die "workspace/create returned no id: $(head -c 400 "$created")" status_file="$VERIFY_XUM_ROOT/sessions/$VERIFY_XUM_WORKSPACE_ID/init-status.json" - for ((i = 0; i < 120; i++)); do - status=$(json_field_or_empty "$status_file" status) - [[ $status == success ]] && break + while status=$(json_field_or_empty "$status_file" status); [[ $status != success ]]; do [[ $status == error ]] && die "workspace setup failed, see $status_file" + before_launch_deadline sleep 0.5 done - [[ $status == success ]] || die "workspace setup did not finish in 60 s" VERIFY_XUM_WORKTREE="$VERIFY_XUM_ROOT/src/$(basename "$VERIFY_XUM_PROJECT")/verify-playground" [[ -d $VERIFY_XUM_WORKTREE ]] || die "no worktree at $VERIFY_XUM_WORKTREE" } @@ -166,7 +171,7 @@ launch_failed() { } cmd_launch() { - local t0 deadline i backend_port vite_port log pidfile + local t0 i backend_port vite_port log pidfile need git curl node bun make setsid flock ss agent-browser check_bun take_checkout_lock @@ -182,7 +187,7 @@ cmd_launch() { log="$VERIFY_XUM_RUN/server.log" pidfile="$VERIFY_XUM_RUN/scratch/pgid" t0=$(date +%s) - deadline=$((SECONDS + READY_TIMEOUT)) + LAUNCH_DEADLINE=$((SECONDS + READY_TIMEOUT)) local env_without_live_xum=(env -i PATH="$PATH" HOME="$HOME" USER="${USER:-}" LANG="${LANG:-C.UTF-8}" TERM=dumb TMPDIR="${TMPDIR:-/tmp}" XUM_MOCK_AI=1 XUM_DISABLE_TELEMETRY=1 @@ -203,21 +208,21 @@ cmd_launch() { ln -sfn "$VERIFY_XUM_RUN" "$CURRENT_RUN_LINK" trap launch_failed EXIT - for ((i = 0; i < 120; i++)); do + while true; do VERIFY_XUM_ROOT=$(sed -n 's/^ XUM_ROOT: *//p' "$log") backend_port=$(sed -n 's|^ Backend: *http://127.0.0.1:||p' "$log") vite_port=$(sed -n 's|^ Frontend: *http://localhost:||p' "$log") [[ -n $VERIFY_XUM_ROOT && -n $backend_port && -n $vite_port ]] && break group_alive "$VERIFY_XUM_PGID" || die "make dev-server-sandbox exited during startup" + before_launch_deadline sleep 0.5 done - [[ -n $vite_port ]] || die "make dev-server-sandbox printed no XUM_ROOT and ports in 60 s" VERIFY_XUM_BACKEND="http://127.0.0.1:$backend_port" VERIFY_XUM_URL="http://127.0.0.1:$vite_port" write_state until backend_ready "$t0" && curl -fs --max-time 5 -o /dev/null "$VERIFY_XUM_URL/"; do - ((SECONDS < deadline)) || die "the dev server was not ready in $READY_TIMEOUT s" + before_launch_deadline group_alive "$VERIFY_XUM_PGID" || die "the dev server exited before it was ready" sleep 1 done @@ -278,6 +283,9 @@ cmd_doctor() { cmd_env() { load_run "${1:-}" cat "$VERIFY_XUM_RUN/state.env" + # The feature recipes call the helper by its bare name. $PATH expands when the caller evals. + # shellcheck disable=SC2016 + printf 'export PATH=%q:"$PATH"\n' "$SCRIPT_DIR" } cmd_capture() { From d9fce8d516762f4eac3996c988ddbe2e2f6989f4 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski <tk@coder.com> Date: Tue, 3 Nov 2026 00:46:49 +0000 Subject: [PATCH 09/51] =?UTF-8?q?=F0=9F=A4=96=20fix:=20scope=20verify-xum?= =?UTF-8?q?=20runs=20to=20their=20checkout,=20read-only=20doctor,=20rerunn?= =?UTF-8?q?able=20review=20recipe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - load_run refuses a run that another checkout launched, so cleanup and doctor never act on it. - cleanup signals a group only while its live leader is still the launch's make dev-server-sandbox, and blanks the group id in state.env after the stop. - doctor reads /version without writing a file. SKILL.md says every check line reads ok. - review.md Restore unmarks the hunk, unchecks Uncommitted and closes the Review tab, so the recipe runs again in the same run. - settings.md drives Ctrl+, too. chat.md and workspaces.md create the evidence folder first. - SKILL.md: Linux only, the xum run snippet runs under env -i with a fake key (it refuses without one, and the old snippet used the caller's real keys), two more proof standards. - workspaces.md records that document.hasFocus() was true on agent-browser 0.33.2. --- .xum/skills/verify-xum/SKILL.md | 28 +++++++++++++------ .xum/skills/verify-xum/features/README.md | 3 +- .xum/skills/verify-xum/features/chat.md | 1 + .xum/skills/verify-xum/features/review.md | 18 ++++++++++-- .xum/skills/verify-xum/features/settings.md | 6 +++- .xum/skills/verify-xum/features/workspaces.md | 11 +++++--- .xum/skills/verify-xum/scripts/verify-xum.sh | 23 ++++++++++----- 7 files changed, 67 insertions(+), 23 deletions(-) diff --git a/.xum/skills/verify-xum/SKILL.md b/.xum/skills/verify-xum/SKILL.md index fcc6025f1ee..f8d71b52581 100644 --- a/.xum/skills/verify-xum/SKILL.md +++ b/.xum/skills/verify-xum/SKILL.md @@ -19,12 +19,16 @@ Surfaces: - Electron: the same renderer in the desktop shell. This skill does not launch or prove it. See the [dev-desktop-sandbox](../dev-desktop-sandbox/SKILL.md) and [electron](../electron/SKILL.md) skills. -- CLI: `xum api` against the sandbox (see Drive). Headless `xum run` needs no server. Give it - its own two roots under the run's scratch folder, which cleanup deletes: +- CLI: `xum api` against the sandbox (see Drive). Headless `xum run` needs no server. Run it + under `env -i` with its own two roots under the run's scratch folder, which cleanup deletes. It + refuses to start without a provider key, so pass a fake one: the mock answers, and your real + keys stay out. ```bash - XUM_ROOT="$VERIFY_XUM_RUN/scratch/cli-root" \ - XUM_RUN_SESSION_ROOT="$VERIFY_XUM_RUN/scratch/cli-sessions" XUM_MOCK_AI=1 \ + env -i PATH="$PATH" HOME="$HOME" TMPDIR="${TMPDIR:-/tmp}" XUM_MOCK_AI=1 XUM_DISABLE_TELEMETRY=1 \ + ANTHROPIC_API_KEY=sk-ant-verify-xum-fake \ + XUM_ROOT="$VERIFY_XUM_RUN/scratch/cli-root" \ + XUM_RUN_SESSION_ROOT="$VERIFY_XUM_RUN/scratch/cli-sessions" \ bun src/cli/index.ts run --dir "$VERIFY_XUM_PROJECT" --json "[mock:list-languages] hi" ``` @@ -33,7 +37,8 @@ Surfaces: ## Launch -Bun on PATH must be the version in `package.json` `packageManager`. Launch refuses another Bun. +The script runs on Linux only: it needs `setsid`, `flock`, `ss` and `/proc`. Bun on PATH must be +the version in `package.json` `packageManager`. Launch refuses another Bun. ```bash .xum/skills/verify-xum/scripts/verify-xum.sh launch @@ -64,7 +69,7 @@ Run it first whenever anything looks off. It changes nothing. .xum/skills/verify-xum/scripts/verify-xum.sh doctor ``` -Every line must read `ok`. It checks that the process group lives and carries the run's id +Every check line must read `ok`. A `note` line only informs. It checks that the process group lives and carries the run's id (`VERIFY_XUM_SERVER_OF` in its env), the checkout lock names it, the backend pid in the sandbox `server.lock` and the app port belong to the group, `/health` answers, `/version` reports the launch commit, and the backend env has `XUM_MOCK_AI=1`. Exit 1 means do not drive this instance: @@ -132,6 +137,11 @@ Proof standards: - Drive the path a user takes. No test-only routes, no state written behind the UI's back except the seed, and edits to the worktree that stand in for the user's own editor. +- Mock only at a production boundary. The mock AI stands in for the model provider. Do not stub + Xum's own code, routes or stores to make a step pass. +- Name what the mock skips. It never calls a provider, and its tool calls are canned and leave + the worktree alone. A mock run does not prove a change to provider requests or tool execution: + report that part as not verified, or prove it at that boundary. - Capture the action and the resulting state, not only the final screen. - Check side effects next to what is visible: rows in `$VERIFY_XUM_ROOT/sessions/<workspace id>/chat.jsonl`, entries in `$VERIFY_XUM_ROOT/config.json`, @@ -147,8 +157,10 @@ Proof standards: Cleanup closes this run's browser session, stops the process group that launch started (SIGTERM, then SIGKILL after 20 s), deletes the sandbox XUM_ROOT and the demo repo, and prints the evidence -path and file count. It signals the group only while one of its processes carries the run's id, -so it kills nothing it did not start. Run it after every run, failed ones too. `doctor`, `env`, +path and file count. It refuses a run that another checkout launched. It signals the group only +while one of its processes carries the run's id and a live group leader is still the launch's +`make dev-server-sandbox`, so it kills nothing it did not start. Then it blanks the group id in +the run's `state.env`. Run it after every run, failed ones too. `doctor`, `env`, `capture`, `idle`, `send-ready` and `cleanup` take an optional run directory. Without one they use the newest launch from this checkout. diff --git a/.xum/skills/verify-xum/features/README.md b/.xum/skills/verify-xum/features/README.md index fe486cb48ed..90f743f20c9 100644 --- a/.xum/skills/verify-xum/features/README.md +++ b/.xum/skills/verify-xum/features/README.md @@ -45,7 +45,8 @@ feature file with the `verify-xum` harness. Update the file in the same change a name below. - Capture the state after each action that the recipe marks as proof, not only the last screen. - Check the side effect the recipe names (`chat.jsonl`, `config.json`, git state, or the CLI) - and save its output next to the captures, as `$VERIFY_XUM_EVIDENCE/<feature>/<step>.txt`. + and save its output next to the captures, as `$VERIFY_XUM_EVIDENCE/<feature>/<step>.txt`. Run + `mkdir -p "$VERIFY_XUM_EVIDENCE/<feature>"` first: only `capture` creates that folder. - Report an entry point you could not drive with the command you ran and the unmet precondition. ## Feature entry contract diff --git a/.xum/skills/verify-xum/features/chat.md b/.xum/skills/verify-xum/features/chat.md index 3c5b29a7b47..37eab309d15 100644 --- a/.xum/skills/verify-xum/features/chat.md +++ b/.xum/skills/verify-xum/features/chat.md @@ -35,6 +35,7 @@ Preconditions: - **Check history.** Save the last four rows of the workspace history: ```bash + mkdir -p "$VERIFY_XUM_EVIDENCE/chat" tail -n 4 "$VERIFY_XUM_ROOT/sessions/$VERIFY_XUM_WORKSPACE_ID/chat.jsonl" >"$VERIFY_XUM_EVIDENCE/chat/history.txt" ``` diff --git a/.xum/skills/verify-xum/features/review.md b/.xum/skills/verify-xum/features/review.md index d81d605a723..7f3de2e8e9b 100644 --- a/.xum/skills/verify-xum/features/review.md +++ b/.xum/skills/verify-xum/features/review.md @@ -21,7 +21,9 @@ label counts read hunks against all hunks. Preconditions: - Baseline state. The worktree `$VERIFY_XUM_WORKTREE` has no changes. -- The app is open on `Verify playground`, and the right sidebar is collapsed. +- The app is open on `Verify playground`, the right sidebar is collapsed with no tab open, and + `Uncommitted:` is unchecked (`userPreferences.review.includeUncommitted` is not `true` in + `config.json`). The recipe's Restore step leaves this state. - **Edit a file.** Change the worktree the way an editor would: @@ -50,7 +52,14 @@ Preconditions: - **Keyboard toggle.** Run `agent-browser press Control+Shift+B`. The sidebar collapses, and the button `Expand right sidebar` is back. Run `agent-browser press Control+Shift+B` again. The button reads `Collapse right sidebar`, and the tab `Review 1/1 Close Review` is selected. -- **Restore.** Run `git -C "$VERIFY_XUM_WORKTREE" checkout -- src/math.ts`. +- **Restore.** Run `agent-browser find role button click --name "Mark as read (M)" --exact`. The + tab reads `Review 0/1`, and `review-state.json` has no `"isRead": true`. Run + `agent-browser find role checkbox click --name "Uncommitted:" --exact`. The box is + `checked=false`, and `config.json` has `"includeUncommitted": false` under + `userPreferences.review`. Run `git -C "$VERIFY_XUM_WORKTREE" checkout -- src/math.ts`, + `agent-browser find role button click --name "Close Review" --exact` and + `agent-browser wait '[aria-label="Expand right sidebar"]'`. Closing the last tab collapses the + sidebar. ## Gotchas @@ -60,3 +69,8 @@ Preconditions: - On a cold Vite cache the first open of Review reloads the page (see SKILL.md Gotchas). Open it again after the reload. - A click on `Review` while the sidebar still slides open is lost. Keep the `idle` call before it. +- Skip a Restore step and a rerun in the same run fails. A hunk's id comes from its content, so + the same edit comes back already read. With `Uncommitted:` already checked, the panel did not + show a new edit within 25 s, until `Refresh diff`. With the Review tab open, `Expand right + sidebar` opens that tab instead of `Open a tool`. +- `find` has no `uncheck` action. `find role checkbox click` toggles the box. diff --git a/.xum/skills/verify-xum/features/settings.md b/.xum/skills/verify-xum/features/settings.md index 7acc8b8b043..6e184cc180a 100644 --- a/.xum/skills/verify-xum/features/settings.md +++ b/.xum/skills/verify-xum/features/settings.md @@ -6,13 +6,14 @@ the server's `config.json`. ## Sub-features -- `settings-open` opens the dialog from the sidebar button, the palette, and its route. +- `settings-open` opens the dialog from the sidebar button, its shortcut, the palette, and its route. - `settings-toggle` changes the `Flat chat list` switch and shows its effect on the sidebar. - `settings-persist` keeps the change across a reload and in `config.json`. ## How to get to it (user POV) - Choose `Open settings` (the gear in the sidebar header). +- Press Ctrl+, (`OPEN_SETTINGS`). - Press F4, type `Open Settings`, and press Enter. - Open `$VERIFY_XUM_URL/settings/general`. @@ -30,6 +31,9 @@ Preconditions: `Models`. Run `agent-browser press Escape` and `agent-browser wait --fn '!document.querySelector("[data-modal-dialog-overlay][data-state=open], [role=dialog][aria-modal=true]")'`. The URL is back on the workspace. +- **Open with the keyboard.** Run `agent-browser press Control+,` and + `agent-browser wait --text "Choose your preferred theme"`. The URL is `/settings/general`. Close + it with Escape and the same wait as above. - **Open from the palette.** Run `agent-browser press F4`, `agent-browser wait '[aria-label="Command palette"]'`, `agent-browser keyboard type "Open Settings"`, diff --git a/.xum/skills/verify-xum/features/workspaces.md b/.xum/skills/verify-xum/features/workspaces.md index 0ddaa6b2cd6..d7b948749a3 100644 --- a/.xum/skills/verify-xum/features/workspaces.md +++ b/.xum/skills/verify-xum/features/workspaces.md @@ -76,6 +76,7 @@ Preconditions: - **CLI view.** List the workspaces through the CLI: ```bash + mkdir -p "$VERIFY_XUM_EVIDENCE/workspaces" env -u XUM_SERVER_URL -u XUM_SERVER_AUTH_TOKEN XUM_ROOT="$VERIFY_XUM_ROOT" \ node "$VERIFY_XUM_CHECKOUT/dist/cli/api.mjs" workspace list >"$VERIFY_XUM_EVIDENCE/workspaces/cli-list.txt" ``` @@ -91,10 +92,12 @@ Preconditions: waits for it. - With `Workspace name` empty, Xum names the workspace with random words (`curvy-mangos-pull`). Fill the name so rows and branches have known names. -- In agent-browser's Chromium, `document.hasFocus()` is false, so a reply that finishes while you - watch does not mark the workspace read. When you leave it, its row shows an unread status dot, - and that dot takes the archive icon's place. Open the workspace once more, as the recipe does, - before you archive it from its row. +- Xum marks a reply read only while `document.hasFocus()` is true (`src/browser/App.tsx:1386`). + An earlier run saw it false in agent-browser's Chromium: a reply that finished while the + workspace was open left an unread status dot on its row, and the dot took the archive icon's + place. On agent-browser 0.33.2 (2026-11-03) `agent-browser eval 'document.hasFocus()'` printed + `true`, and a workspace watched until its reply kept its archive icon. If it prints `false`, + open the workspace once more before you archive it from its row, as the recipe does. - A click on the archive icon selects the row instead in agent-browser's Chromium. The icon takes clicks only on row hover, and that browser matches `(hover: none)`, so Tailwind's `group-hover` never applies (see SKILL.md Gotchas). Focus the icon, as above. diff --git a/.xum/skills/verify-xum/scripts/verify-xum.sh b/.xum/skills/verify-xum/scripts/verify-xum.sh index d65877fe49e..48f55c3171f 100755 --- a/.xum/skills/verify-xum/scripts/verify-xum.sh +++ b/.xum/skills/verify-xum/scripts/verify-xum.sh @@ -34,15 +34,22 @@ need() { } group_alive() { - kill -0 -- "-$1" 2>/dev/null + [[ -n $1 ]] && kill -0 -- "-$1" 2>/dev/null } # A process group belongs to this run only while one of its processes has # VERIFY_XUM_SERVER_OF=<run id> in its env, so a group id reused after the run died is left alone. # state.env never exports that name, so processes an agent starts after `eval "$(verify-xum.sh env)"` -# do not carry it. +# do not carry it. A live leader must also still be the `make dev-server-sandbox` that launch +# started. Linux reuses no pid while it names a process group, so a group whose leader exited +# keeps its id and stays ours. group_is_ours() { - local pid + local pid leader_cmdline + [[ -n $1 ]] || return 1 + if [[ -e /proc/$1 ]]; then + leader_cmdline=$(tr '\0' ' ' 2>/dev/null <"/proc/$1/cmdline" || true) + [[ $leader_cmdline == *dev-server-sandbox* ]] || return 1 + fi for pid in $(ps -o pid= -g "$1" 2>/dev/null); do grep -qszx "VERIFY_XUM_SERVER_OF=$2" "/proc/$pid/environ" && return 0 done @@ -66,6 +73,8 @@ load_run() { [[ -f $run/state.env ]] || die "no run at $run (run: verify-xum.sh launch)" # shellcheck source=/dev/null source "$run/state.env" + [[ $VERIFY_XUM_CHECKOUT == "$CHECKOUT" ]] \ + || die "run $run belongs to the checkout $VERIFY_XUM_CHECKOUT. Use that checkout's verify-xum.sh." } LAUNCH_DEADLINE= @@ -264,9 +273,7 @@ cmd_doctor() { check "server.lock names $VERIFY_XUM_BACKEND" \ test "$(json_field_or_empty "$VERIFY_XUM_ROOT/server.lock" baseUrl)" = "$VERIFY_XUM_BACKEND" check "GET $VERIFY_XUM_BACKEND/health answers" curl -fs --max-time 5 -o /dev/null "$VERIFY_XUM_BACKEND/health" - rm -f "$VERIFY_XUM_RUN/scratch/version.json" - curl -fs --max-time 5 "$VERIFY_XUM_BACKEND/version" -o "$VERIFY_XUM_RUN/scratch/version.json" || true - version=$(json_field_or_empty "$VERIFY_XUM_RUN/scratch/version.json" git_commit) + version=$(curl -fs --max-time 5 "$VERIFY_XUM_BACKEND/version" 2>/dev/null | json_field_or_empty /dev/stdin git_commit || true) check "backend build ${version:-unknown} is the launch commit $VERIFY_XUM_COMMIT" \ test -n "$version" -a "${VERIFY_XUM_COMMIT#"$version"}" != "$VERIFY_XUM_COMMIT" if [[ $(git -C "$VERIFY_XUM_CHECKOUT" rev-parse HEAD) != "$VERIFY_XUM_COMMIT" ]]; then @@ -344,7 +351,7 @@ cmd_cleanup() { kill -KILL -- "-$VERIFY_XUM_PGID" 2>/dev/null || true fi elif group_alive "$VERIFY_XUM_PGID"; then - echo "verify-xum: process group $VERIFY_XUM_PGID no longer carries this run's id. Not signaling it." >&2 + echo "verify-xum: process group $VERIFY_XUM_PGID is not this run's dev server. Not signaling it." >&2 fi if is_dev_server_sandbox_root "$VERIFY_XUM_ROOT"; then rm -rf "$VERIFY_XUM_ROOT" @@ -352,6 +359,8 @@ cmd_cleanup() { rm -rf "$VERIFY_XUM_RUN/scratch" [[ $(cat "$ONE_SERVER_PER_CHECKOUT_LOCK" 2>/dev/null) == "$VERIFY_XUM_PGID" ]] && rm -f "$ONE_SERVER_PER_CHECKOUT_LOCK" [[ $(readlink "$CURRENT_RUN_LINK" 2>/dev/null) == "$VERIFY_XUM_RUN" ]] && rm -f "$CURRENT_RUN_LINK" + VERIFY_XUM_PGID= + write_state echo "cleaned up run $VERIFY_XUM_RUN_ID; evidence kept in $VERIFY_XUM_EVIDENCE ($(find "$VERIFY_XUM_EVIDENCE" -type f | wc -l | tr -d ' ') files) and $VERIFY_XUM_RUN/server.log" } From 2ddacc6f52dedb97dbc4839014a72b3d7e1f2ba6 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski <tk@coder.com> Date: Tue, 3 Nov 2026 00:47:14 +0000 Subject: [PATCH 10/51] =?UTF-8?q?=F0=9F=A4=96=20docs:=20prettier-format=20?= =?UTF-8?q?the=20chat=20and=20review=20recipes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .xum/skills/verify-xum/features/chat.md | 1 + .xum/skills/verify-xum/features/review.md | 5 +++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.xum/skills/verify-xum/features/chat.md b/.xum/skills/verify-xum/features/chat.md index 37eab309d15..008c4b9ece5 100644 --- a/.xum/skills/verify-xum/features/chat.md +++ b/.xum/skills/verify-xum/features/chat.md @@ -40,6 +40,7 @@ Preconditions: ``` The file has two `"role":"user"` rows and two `"role":"assistant"` rows with the reply texts. + - **Stop a stream.** Fill `[mock:long-stream] tell a long story`, run `verify-xum.sh send-ready` and press Enter. Run `agent-browser wait '[aria-label="Stop streaming"]'`, then wait for a few chunks with diff --git a/.xum/skills/verify-xum/features/review.md b/.xum/skills/verify-xum/features/review.md index 7f3de2e8e9b..abeb401dd80 100644 --- a/.xum/skills/verify-xum/features/review.md +++ b/.xum/skills/verify-xum/features/review.md @@ -32,6 +32,7 @@ Preconditions: ``` `git -C "$VERIFY_XUM_WORKTREE" status --short` prints ` M src/math.ts`. + - **Open the sidebar.** Run `agent-browser find role button click --name "Expand right sidebar" --exact`, `agent-browser wait --text "Open a tool"` and `verify-xum.sh idle`. The heading `Open a tool` @@ -71,6 +72,6 @@ Preconditions: - A click on `Review` while the sidebar still slides open is lost. Keep the `idle` call before it. - Skip a Restore step and a rerun in the same run fails. A hunk's id comes from its content, so the same edit comes back already read. With `Uncommitted:` already checked, the panel did not - show a new edit within 25 s, until `Refresh diff`. With the Review tab open, `Expand right - sidebar` opens that tab instead of `Open a tool`. + show a new edit within 25 s, until `Refresh diff`. With the Review tab open, + `Expand right sidebar` opens that tab instead of `Open a tool`. - `find` has no `uncheck` action. `find role checkbox click` toggles the box. From baa50401f9664dee04f3d68af91174153c88bbfb Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski <tk@coder.com> Date: Tue, 3 Nov 2026 00:56:35 +0000 Subject: [PATCH 11/51] =?UTF-8?q?=F0=9F=A4=96=20fix:=20run=20the=20api=20C?= =?UTF-8?q?LI=20under=20env=20-i,=20make=20a=20relative=20VERIFY=5FXUM=5FH?= =?UTF-8?q?OME=20absolute?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The live Xum's server URL and token are also in the env as legacy MUX_* names, which the CLI reads, so env -u of the XUM_* names alone did not keep them out. A relative VERIFY_XUM_HOME made the run link in the git dir point at a path relative to the git dir. --- .xum/skills/verify-xum/SKILL.md | 7 ++++--- .xum/skills/verify-xum/features/workspaces.md | 2 +- .xum/skills/verify-xum/scripts/verify-xum.sh | 2 ++ 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/.xum/skills/verify-xum/SKILL.md b/.xum/skills/verify-xum/SKILL.md index f8d71b52581..86d07944e0a 100644 --- a/.xum/skills/verify-xum/SKILL.md +++ b/.xum/skills/verify-xum/SKILL.md @@ -111,11 +111,12 @@ agent-browser find role button click --name "Open settings" --exact `[mock:list-languages]` are in `src/node/services/mock/mockAiRouter.ts`. Mock tool calls are canned and leave the worktree alone. A canned `todo_write` (`[mock:todo-nudge]`) still writes its TODO list to the workspace's session folder. -- The CLI drives the same server. Unset the live server's variables, or the CLI talks to the Xum - you run in: +- The CLI drives the same server. Run it under `env -i`: the live Xum's server URL and auth + token are in your env under both `XUM_*` and legacy `MUX_*` names, and the CLI reads either. + With them gone it finds the sandbox through `XUM_ROOT`: ```bash - env -u XUM_SERVER_URL -u XUM_SERVER_AUTH_TOKEN XUM_ROOT="$VERIFY_XUM_ROOT" \ + env -i PATH="$PATH" HOME="$HOME" XUM_ROOT="$VERIFY_XUM_ROOT" \ node "$VERIFY_XUM_CHECKOUT/dist/cli/api.mjs" workspace list ``` diff --git a/.xum/skills/verify-xum/features/workspaces.md b/.xum/skills/verify-xum/features/workspaces.md index d7b948749a3..41a0eb20a25 100644 --- a/.xum/skills/verify-xum/features/workspaces.md +++ b/.xum/skills/verify-xum/features/workspaces.md @@ -77,7 +77,7 @@ Preconditions: ```bash mkdir -p "$VERIFY_XUM_EVIDENCE/workspaces" - env -u XUM_SERVER_URL -u XUM_SERVER_AUTH_TOKEN XUM_ROOT="$VERIFY_XUM_ROOT" \ + env -i PATH="$PATH" HOME="$HOME" XUM_ROOT="$VERIFY_XUM_ROOT" \ node "$VERIFY_XUM_CHECKOUT/dist/cli/api.mjs" workspace list >"$VERIFY_XUM_EVIDENCE/workspaces/cli-list.txt" ``` diff --git a/.xum/skills/verify-xum/scripts/verify-xum.sh b/.xum/skills/verify-xum/scripts/verify-xum.sh index 48f55c3171f..72952adf13f 100755 --- a/.xum/skills/verify-xum/scripts/verify-xum.sh +++ b/.xum/skills/verify-xum/scripts/verify-xum.sh @@ -15,6 +15,8 @@ set -euo pipefail SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) CHECKOUT=$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel) VERIFY_XUM_HOME=${VERIFY_XUM_HOME:-${XUM_SCRATCH_DIR:-${TMPDIR:-/tmp}}/verify-xum} +# The run link in the git dir must point at an absolute path. +[[ $VERIFY_XUM_HOME == /* ]] || VERIFY_XUM_HOME="$PWD/$VERIFY_XUM_HOME" CHECKOUT_GIT_DIR=$(git -C "$CHECKOUT" rev-parse --path-format=absolute --git-dir) ONE_SERVER_PER_CHECKOUT_LOCK="$CHECKOUT_GIT_DIR/verify-xum.lock" CURRENT_RUN_LINK="$CHECKOUT_GIT_DIR/verify-xum.current" From 90251e3cbf9b7d98f900b168fe2f7dd8d66695a8 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski <tk@coder.com> Date: Tue, 3 Nov 2026 01:04:59 +0000 Subject: [PATCH 12/51] =?UTF-8?q?=F0=9F=A4=96=20fix:=20wait=20for=20each?= =?UTF-8?q?=20create's=20stream=20to=20end,=20treat=20a=20zombie=20leader?= =?UTF-8?q?=20as=20exited,=20end=20recipes=20on=20the=20workspace?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - workspaces.md runs idle after each create's reply. The reply text shows before the stream ends, and archiving a streaming workspace opens a confirm dialog instead. - cleanup treats a zombie group leader (empty cmdline) like an exited one, so it still stops the group's live members. - review.md says to repeat from Open the sidebar after a cold-cache page reload. - settings.md Restore re-opens the workspace: Escape from a Settings URL lands on /project. --- .xum/skills/verify-xum/features/review.md | 3 ++- .xum/skills/verify-xum/features/settings.md | 5 ++++- .xum/skills/verify-xum/features/workspaces.md | 11 +++++++---- .xum/skills/verify-xum/scripts/verify-xum.sh | 4 ++-- 4 files changed, 15 insertions(+), 8 deletions(-) diff --git a/.xum/skills/verify-xum/features/review.md b/.xum/skills/verify-xum/features/review.md index abeb401dd80..2a0f03ab0ce 100644 --- a/.xum/skills/verify-xum/features/review.md +++ b/.xum/skills/verify-xum/features/review.md @@ -39,7 +39,8 @@ Preconditions: lists a `Review` button. - **Open Review.** Run `agent-browser find role button click --name "Review" --exact` and `agent-browser wait --text "Uncommitted:"`. The tab `Review Close Review` is selected, and the - panel reads `No changes found` with `Uncommitted:` unchecked. + panel reads `No changes found` with `Uncommitted:` unchecked. If the page reloads here instead + (a cold Vite cache, see Gotchas), run `verify-xum.sh idle` and repeat from **Open the sidebar**. - **Include uncommitted edits.** Run `agent-browser find role checkbox check --name "Uncommitted:" --exact` and `agent-browser wait --text "math.ts"`. The file tree lists `src` and `math.ts` with `+3`, the diff --git a/.xum/skills/verify-xum/features/settings.md b/.xum/skills/verify-xum/features/settings.md index 6e184cc180a..6f4e5c039c5 100644 --- a/.xum/skills/verify-xum/features/settings.md +++ b/.xum/skills/verify-xum/features/settings.md @@ -53,7 +53,10 @@ Preconditions: still `checked=true`. Capture `settings/persisted`. - **Restore.** Run `agent-browser find role switch click --name "Toggle flat chat list" --exact` and - `agent-browser press Escape`. `config.json` has `"sidebarFlatMode": false`. + `agent-browser press Escape`. `config.json` has `"sidebarFlatMode": false`. The page opened + Settings by its URL, so Escape lands on `/project?...`. Run + `agent-browser open "$VERIFY_XUM_URL/workspace/$VERIFY_XUM_WORKSPACE_ID"` and + `verify-xum.sh idle` to be back on `Verify playground`. ## Gotchas diff --git a/.xum/skills/verify-xum/features/workspaces.md b/.xum/skills/verify-xum/features/workspaces.md index 41a0eb20a25..52f26d3d417 100644 --- a/.xum/skills/verify-xum/features/workspaces.md +++ b/.xum/skills/verify-xum/features/workspaces.md @@ -40,15 +40,15 @@ Preconditions: `agent-browser find role textbox fill "Draft the release notes" --name "Message" --exact`, `verify-xum.sh send-ready`, `agent-browser find role button click --name "Send message" --exact` and - `agent-browser wait --text "Mock response: Draft the release notes"`. The URL changes to - `/workspace/<new id>`, and the sidebar has a `Select workspace release-notes` row. Capture - `workspaces/created`. + `agent-browser wait --text "Mock response: Draft the release notes"` and `verify-xum.sh idle`. + The URL changes to `/workspace/<new id>`, and the sidebar has a `Select workspace release-notes` + row. Capture `workspaces/created`. - **Check the worktree.** Run `git -C "$VERIFY_XUM_PROJECT" worktree list`. It lists `$VERIFY_XUM_ROOT/src/demo-app/release-notes` on branch `release-notes`. - **Keyboard create.** Press Ctrl+N. Run `agent-browser press Control+n` and `agent-browser wait '[aria-label="Workspace name"]'`. The URL changes to `/project?project=demo-app-<hash>&draft=<id>`. Create `scratch-notes` the same way as above, - with the message `Collect scratch notes`. + with the message `Collect scratch notes`, and run `verify-xum.sh idle` after its reply. - **Open from the sidebar.** Run `agent-browser find role button click --name "Select workspace release-notes" --exact`. The URL is `/workspace/<release-notes id>`. Run @@ -85,6 +85,9 @@ Preconditions: ## Gotchas +- The reply text shows before its stream ends. Run `verify-xum.sh idle` after it. Archiving a + workspace that still streams opens the dialog `Archive "<title>" while streaming?`, and the + row stays. - The project row's accessible name is `Create workspace in demo-app`, but choosing it opens the project page. It creates nothing. - Right after a create, the next new-workspace composer keeps `Send message` disabled for a few diff --git a/.xum/skills/verify-xum/scripts/verify-xum.sh b/.xum/skills/verify-xum/scripts/verify-xum.sh index 72952adf13f..7bbd386b21f 100755 --- a/.xum/skills/verify-xum/scripts/verify-xum.sh +++ b/.xum/skills/verify-xum/scripts/verify-xum.sh @@ -44,11 +44,11 @@ group_alive() { # state.env never exports that name, so processes an agent starts after `eval "$(verify-xum.sh env)"` # do not carry it. A live leader must also still be the `make dev-server-sandbox` that launch # started. Linux reuses no pid while it names a process group, so a group whose leader exited -# keeps its id and stays ours. +# keeps its id and stays ours. A zombie leader has an empty cmdline and counts as exited. group_is_ours() { local pid leader_cmdline [[ -n $1 ]] || return 1 - if [[ -e /proc/$1 ]]; then + if [[ -e /proc/$1 && $(ps -o stat= -p "$1" 2>/dev/null) != Z* ]]; then leader_cmdline=$(tr '\0' ' ' 2>/dev/null <"/proc/$1/cmdline" || true) [[ $leader_cmdline == *dev-server-sandbox* ]] || return 1 fi From 6d27855043cab598e2907bb47a7277911b2d95e1 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski <tk@coder.com> Date: Tue, 3 Nov 2026 01:14:48 +0000 Subject: [PATCH 13/51] =?UTF-8?q?=F0=9F=A4=96=20fix:=20open=20the=20seeded?= =?UTF-8?q?=20workspace=20at=20each=20recipe's=20start,=20make=20a=20relat?= =?UTF-8?q?ive=20TMPDIR=20absolute?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README: start every recipe whose preconditions name Verify playground by opening it. The workspaces and command-palette recipes end on release-notes, and the recipes read their evidence from $VERIFY_XUM_WORKSPACE_ID. - make -C runs the sandbox from the checkout, so a relative TMPDIR resolved there while the script resolved the logged XUM_ROOT against the caller's directory. --- .xum/skills/verify-xum/features/README.md | 5 ++++- .xum/skills/verify-xum/scripts/verify-xum.sh | 2 ++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.xum/skills/verify-xum/features/README.md b/.xum/skills/verify-xum/features/README.md index 90f743f20c9..63f82a5e4b2 100644 --- a/.xum/skills/verify-xum/features/README.md +++ b/.xum/skills/verify-xum/features/README.md @@ -19,7 +19,10 @@ feature file with the `verify-xum` harness. Update the file in the same change a - Run each step's commands as one `&&` chain, or under `set -e`, so the first failed `wait` stops the step. Keys pressed after a failed wait go to whatever has focus. In one run a palette query went into the composer, and the Enter after it sent that text as a chat message. -- Open the app with `agent-browser open "$VERIFY_XUM_URL/workspace/$VERIFY_XUM_WORKSPACE_ID"`, +- Start every recipe whose preconditions name `Verify playground` by opening it, even after + another recipe: the workspaces and command-palette recipes end on `release-notes`, and the + recipes read evidence from `$VERIFY_XUM_WORKSPACE_ID`. Open the app with + `agent-browser open "$VERIFY_XUM_URL/workspace/$VERIFY_XUM_WORKSPACE_ID"`, run `verify-xum.sh idle`, and expand the project in the sidebar with `agent-browser find role button click --name "Expand project demo-app" --exact`. A new browser session starts with the project collapsed, and the recipes choose workspace rows by name. The diff --git a/.xum/skills/verify-xum/scripts/verify-xum.sh b/.xum/skills/verify-xum/scripts/verify-xum.sh index 7bbd386b21f..51a39687c6c 100755 --- a/.xum/skills/verify-xum/scripts/verify-xum.sh +++ b/.xum/skills/verify-xum/scripts/verify-xum.sh @@ -14,6 +14,8 @@ set -euo pipefail SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) CHECKOUT=$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel) +# make -C runs the sandbox from the checkout, so a relative TMPDIR would resolve there. +[[ ${TMPDIR:-/tmp} == /* ]] || TMPDIR="$PWD/$TMPDIR" VERIFY_XUM_HOME=${VERIFY_XUM_HOME:-${XUM_SCRATCH_DIR:-${TMPDIR:-/tmp}}/verify-xum} # The run link in the git dir must point at an absolute path. [[ $VERIFY_XUM_HOME == /* ]] || VERIFY_XUM_HOME="$PWD/$VERIFY_XUM_HOME" From d4c7f314be9879960708ce473e4ea2d88e12db74 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski <tk@coder.com> Date: Tue, 3 Nov 2026 02:39:16 +0000 Subject: [PATCH 14/51] =?UTF-8?q?=F0=9F=A4=96=20fix:=20hold=20the=20checko?= =?UTF-8?q?ut=20lock=20in=20the=20server's=20processes,=20wait=20for=20the?= =?UTF-8?q?=20saved=20chat=20reply?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - launch keeps the flock fd open, and the server's make and dev-server-sandbox.ts inherit it, so the lock stays held while the server runs at whatever point the launcher dies. A second launch is refused at once. launch saves state, the run link and its cleanup trap before the spawn, and cleanup reads the group id and XUM_ROOT from the pidfile and server log when a killed launch never saved them. - chat.md waits until chat.jsonl has the second reply before it reads the history. The transcript shows the reply before the server saves it at stream end. --- .xum/skills/verify-xum/SKILL.md | 5 +++-- .xum/skills/verify-xum/features/chat.md | 8 +++++-- .xum/skills/verify-xum/scripts/verify-xum.sh | 23 +++++++++++--------- 3 files changed, 22 insertions(+), 14 deletions(-) diff --git a/.xum/skills/verify-xum/SKILL.md b/.xum/skills/verify-xum/SKILL.md index 86d07944e0a..690ee6d1194 100644 --- a/.xum/skills/verify-xum/SKILL.md +++ b/.xum/skills/verify-xum/SKILL.md @@ -58,8 +58,9 @@ Ready means all of these hold: repo) and the workspace `Verify playground` with its worktree set up. It prints the run's `export` lines and the URL of the seeded workspace. A failed launch prints the -server log tail and cleans up. Launch refuses while another verify-xum server runs from this -checkout: two dev servers in one checkout share `dist/` and `node_modules/.vite`. +server log tail and cleans up. Launch refuses while another verify-xum launch or server runs from +this checkout: two dev servers in one checkout share `dist/` and `node_modules/.vite`. The server's +processes hold that lock, so it holds even when the launcher was killed partway. ## Doctor diff --git a/.xum/skills/verify-xum/features/chat.md b/.xum/skills/verify-xum/features/chat.md index 008c4b9ece5..64d5e6f2a3e 100644 --- a/.xum/skills/verify-xum/features/chat.md +++ b/.xum/skills/verify-xum/features/chat.md @@ -32,11 +32,15 @@ Preconditions: - **Send with the button.** Fill `Second message`, run `verify-xum.sh send-ready`, then run `agent-browser find role button click --name "Send message" --exact` and `agent-browser wait --text "Mock response: Second message"`. -- **Check history.** Save the last four rows of the workspace history: +- **Check history.** The transcript shows a reply before the server writes it to `chat.jsonl` at + the end of the stream. Wait for the saved reply, then save the last four rows of the workspace + history: ```bash + chat_jsonl="$VERIFY_XUM_ROOT/sessions/$VERIFY_XUM_WORKSPACE_ID/chat.jsonl" + timeout 10 sh -c 'until grep -qF "Mock response: Second message" "$1"; do sleep 0.2; done' sh "$chat_jsonl" mkdir -p "$VERIFY_XUM_EVIDENCE/chat" - tail -n 4 "$VERIFY_XUM_ROOT/sessions/$VERIFY_XUM_WORKSPACE_ID/chat.jsonl" >"$VERIFY_XUM_EVIDENCE/chat/history.txt" + tail -n 4 "$chat_jsonl" >"$VERIFY_XUM_EVIDENCE/chat/history.txt" ``` The file has two `"role":"user"` rows and two `"role":"assistant"` rows with the reply texts. diff --git a/.xum/skills/verify-xum/scripts/verify-xum.sh b/.xum/skills/verify-xum/scripts/verify-xum.sh index 51a39687c6c..a7ac7c5e734 100755 --- a/.xum/skills/verify-xum/scripts/verify-xum.sh +++ b/.xum/skills/verify-xum/scripts/verify-xum.sh @@ -60,16 +60,14 @@ group_is_ours() { return 1 } +# The kernel holds this flock while any process keeps fd 9 open: the launcher, and the server's +# `make` and `dev-server-sandbox.ts`, which inherit it at the spawn and live as long as the server. +# So the lock is held while a server from this checkout runs, at whatever point the launcher +# dies. The lock file only names the group, for doctor. take_checkout_lock() { - local holder exec 9>"$ONE_SERVER_PER_CHECKOUT_LOCK.guard" - flock -w 30 9 || die "another verify-xum launch holds $ONE_SERVER_PER_CHECKOUT_LOCK.guard" - holder=$(cat "$ONE_SERVER_PER_CHECKOUT_LOCK" 2>/dev/null || true) - if [[ -n $holder ]] && { kill -0 "$holder" 2>/dev/null || group_alive "$holder"; }; then - die "a verify-xum server already runs from this checkout (process $holder), and two would share dist/ and node_modules/.vite. Run verify-xum.sh cleanup first." - fi - echo "$$" >"$ONE_SERVER_PER_CHECKOUT_LOCK" - exec 9>&- + flock -n 9 \ + || die "a verify-xum launch or server from this checkout holds $ONE_SERVER_PER_CHECKOUT_LOCK.guard, and two would share dist/ and node_modules/.vite. Run verify-xum.sh cleanup first. If it stays held, lsof that file to find the holder." } load_run() { @@ -205,6 +203,10 @@ cmd_launch() { local env_without_live_xum=(env -i PATH="$PATH" HOME="$HOME" USER="${USER:-}" LANG="${LANG:-C.UTF-8}" TERM=dumb TMPDIR="${TMPDIR:-/tmp}" XUM_MOCK_AI=1 XUM_DISABLE_TELEMETRY=1 VERIFY_XUM_SERVER_OF="$VERIFY_XUM_RUN_ID") + # Saved before the spawn, so cleanup finds a launch killed at any point after it. + write_state + ln -sfn "$VERIFY_XUM_RUN" "$CURRENT_RUN_LINK" + trap launch_failed EXIT # setsid(1) forks when its caller already leads a process group, so $! can miss the new # group. The inner sh records its own pid, which is the new group id. # shellcheck disable=SC2016 @@ -218,8 +220,6 @@ cmd_launch() { VERIFY_XUM_PGID=$(cat "$pidfile") echo "$VERIFY_XUM_PGID" >"$ONE_SERVER_PER_CHECKOUT_LOCK" write_state - ln -sfn "$VERIFY_XUM_RUN" "$CURRENT_RUN_LINK" - trap launch_failed EXIT while true; do VERIFY_XUM_ROOT=$(sed -n 's/^ XUM_ROOT: *//p' "$log") @@ -341,6 +341,9 @@ is_dev_server_sandbox_root() { cmd_cleanup() { load_run "${1:-}" local i + # A launch killed before it saved these left them only in the pidfile and the server log. + [[ -n $VERIFY_XUM_PGID ]] || VERIFY_XUM_PGID=$(cat "$VERIFY_XUM_RUN/scratch/pgid" 2>/dev/null || true) + [[ -n $VERIFY_XUM_ROOT ]] || VERIFY_XUM_ROOT=$(sed -n 's/^ XUM_ROOT: *//p' "$VERIFY_XUM_RUN/server.log" 2>/dev/null || true) # agent-browser `close` on a session that does not exist starts that session first. if agent-browser session list 2>/dev/null | grep -qF "$AGENT_BROWSER_SESSION"; then agent-browser --session "$AGENT_BROWSER_SESSION" close >/dev/null 2>&1 || true From 34bdfc5703818c9fa3fb91a2b2ae67e61b21ecf9 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski <tk@coder.com> Date: Tue, 3 Nov 2026 09:16:39 +0000 Subject: [PATCH 15/51] =?UTF-8?q?=F0=9F=A4=96=20fix:=20guard=20launch=20on?= =?UTF-8?q?=20the=20newest=20run's=20process=20group,=20not=20on=20an=20in?= =?UTF-8?q?herited=20lock?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - launch refuses while the process group recorded for this checkout's newest run is alive, checked with the same leader-cmdline and run-id test that cleanup uses. The group's leader appends its id to the run's state.env before it starts make, so the guard and cleanup find it at whatever point the launcher dies. The flock now only serializes the check and the spawn, and the server does not inherit it. - Removed the inherited lock fd, the separate pidfile and lock file, and cleanup's pidfile and server-log fallbacks. state.env is written atomically. - doctor checks that the checkout's newest-run link names the run. - review.md: after a cold-cache reload the sidebar and Review tab stay open, so the recovery continues at Include uncommitted edits. - command-palette.md: #7897 is on main, so the loading input keeps an early Enter. The wait for the selected option stays. --- .xum/skills/verify-xum/SKILL.md | 22 ++++--- .../verify-xum/features/command-palette.md | 9 +-- .xum/skills/verify-xum/features/review.md | 8 ++- .xum/skills/verify-xum/scripts/verify-xum.sh | 59 ++++++++++--------- 4 files changed, 53 insertions(+), 45 deletions(-) diff --git a/.xum/skills/verify-xum/SKILL.md b/.xum/skills/verify-xum/SKILL.md index 690ee6d1194..9b3a2842ee9 100644 --- a/.xum/skills/verify-xum/SKILL.md +++ b/.xum/skills/verify-xum/SKILL.md @@ -58,9 +58,11 @@ Ready means all of these hold: repo) and the workspace `Verify playground` with its worktree set up. It prints the run's `export` lines and the URL of the seeded workspace. A failed launch prints the -server log tail and cleans up. Launch refuses while another verify-xum launch or server runs from -this checkout: two dev servers in one checkout share `dist/` and `node_modules/.vite`. The server's -processes hold that lock, so it holds even when the launcher was killed partway. +server log tail and cleans up. Launch refuses while the process group of this checkout's newest +run is alive, by the same identity check as cleanup: two dev servers in one checkout share `dist/` +and `node_modules/.vite`. The group's first process writes the group id into the run's +`state.env` before it starts the server, so the check and cleanup find the group even when the +launcher was killed partway. ## Doctor @@ -70,11 +72,11 @@ Run it first whenever anything looks off. It changes nothing. .xum/skills/verify-xum/scripts/verify-xum.sh doctor ``` -Every check line must read `ok`. A `note` line only informs. It checks that the process group lives and carries the run's id -(`VERIFY_XUM_SERVER_OF` in its env), the checkout lock names it, the backend pid in the sandbox -`server.lock` and the app port belong to the group, `/health` answers, `/version` reports the -launch commit, and the backend env has `XUM_MOCK_AI=1`. Exit 1 means do not drive this instance: -run cleanup, then launch again. +Every check line must read `ok`. A `note` line only informs. It checks that the process group +lives and carries the run's id (`VERIFY_XUM_SERVER_OF` in its env), the checkout's newest-run link +names the run, the backend pid in the sandbox `server.lock` and the app port belong to the group, +`/health` answers, `/version` reports the launch commit, and the backend env has `XUM_MOCK_AI=1`. +Exit 1 means do not drive this instance: run cleanup, then launch again. ## Drive @@ -162,7 +164,9 @@ then SIGKILL after 20 s), deletes the sandbox XUM_ROOT and the demo repo, and pr path and file count. It refuses a run that another checkout launched. It signals the group only while one of its processes carries the run's id and a live group leader is still the launch's `make dev-server-sandbox`, so it kills nothing it did not start. Then it blanks the group id in -the run's `state.env`. Run it after every run, failed ones too. `doctor`, `env`, +the run's `state.env`. A launch killed with SIGKILL before it printed `ready` can leave its +`mux-dev-server-*` folder in TMPDIR: cleanup stops the group, but the run never recorded that +folder. Run cleanup after every run, failed ones too. `doctor`, `env`, `capture`, `idle`, `send-ready` and `cleanup` take an optional run directory. Without one they use the newest launch from this checkout. diff --git a/.xum/skills/verify-xum/features/command-palette.md b/.xum/skills/verify-xum/features/command-palette.md index 66b354edb37..4675e5fb19a 100644 --- a/.xum/skills/verify-xum/features/command-palette.md +++ b/.xum/skills/verify-xum/features/command-palette.md @@ -45,10 +45,11 @@ Preconditions: - Wait for the option you want to be selected before you press Enter. The first palette open after a page load shows a plain input while the palette's code loads (`LoadingPaletteInput` at - `src/browser/components/CommandPalette/LazyCommandPalette.tsx:37`). It has the same label - `Command palette` and keeps typed text, but it ignores Enter (line 60). agent-browser types and - presses Enter in a few milliseconds, before the code arrives. A person who pastes a query and - presses Enter on a slow connection loses the Enter the same way. + `src/browser/components/CommandPalette/LazyCommandPalette.tsx:38`), with the same label + `Command palette`. agent-browser types and presses Enter in a few milliseconds, before the code + arrives. Since #7897 that input keeps the Enter and runs it on the first option once the code + loads. Before it, the Enter was lost. The wait proves that the option Enter runs is the one the + recipe expects. - Type into the palette with `agent-browser keyboard type`. The combobox has focus as soon as it opens. - Workspace options show the title only. A leading `•` marks the open workspace. diff --git a/.xum/skills/verify-xum/features/review.md b/.xum/skills/verify-xum/features/review.md index 2a0f03ab0ce..917914c560d 100644 --- a/.xum/skills/verify-xum/features/review.md +++ b/.xum/skills/verify-xum/features/review.md @@ -40,7 +40,9 @@ Preconditions: - **Open Review.** Run `agent-browser find role button click --name "Review" --exact` and `agent-browser wait --text "Uncommitted:"`. The tab `Review Close Review` is selected, and the panel reads `No changes found` with `Uncommitted:` unchecked. If the page reloads here instead - (a cold Vite cache, see Gotchas), run `verify-xum.sh idle` and repeat from **Open the sidebar**. + (a cold Vite cache, see Gotchas), the sidebar and the Review tab stay open. Run + `verify-xum.sh idle` and `agent-browser wait --text "Uncommitted:"`, then continue at + **Include uncommitted edits**. - **Include uncommitted edits.** Run `agent-browser find role checkbox check --name "Uncommitted:" --exact` and `agent-browser wait --text "math.ts"`. The file tree lists `src` and `math.ts` with `+3`, the @@ -68,8 +70,8 @@ Preconditions: - `find role ... --name` matches substrings without `--exact`. The hunk itself is a button whose name starts with `Mark as read (M)`, so the call without `--exact` selects the hunk and marks nothing. -- On a cold Vite cache the first open of Review reloads the page (see SKILL.md Gotchas). Open it - again after the reload. +- On a cold Vite cache the first open of Review reloads the page (see SKILL.md Gotchas). The + sidebar and the Review tab stay open after the reload, so the recipe goes on from the open tab. - A click on `Review` while the sidebar still slides open is lost. Keep the `idle` call before it. - Skip a Restore step and a rerun in the same run fails. A hunk's id comes from its content, so the same edit comes back already read. With `Uncommitted:` already checked, the panel did not diff --git a/.xum/skills/verify-xum/scripts/verify-xum.sh b/.xum/skills/verify-xum/scripts/verify-xum.sh index a7ac7c5e734..8afd46ce19c 100755 --- a/.xum/skills/verify-xum/scripts/verify-xum.sh +++ b/.xum/skills/verify-xum/scripts/verify-xum.sh @@ -20,7 +20,7 @@ VERIFY_XUM_HOME=${VERIFY_XUM_HOME:-${XUM_SCRATCH_DIR:-${TMPDIR:-/tmp}}/verify-xu # The run link in the git dir must point at an absolute path. [[ $VERIFY_XUM_HOME == /* ]] || VERIFY_XUM_HOME="$PWD/$VERIFY_XUM_HOME" CHECKOUT_GIT_DIR=$(git -C "$CHECKOUT" rev-parse --path-format=absolute --git-dir) -ONE_SERVER_PER_CHECKOUT_LOCK="$CHECKOUT_GIT_DIR/verify-xum.lock" +LAUNCH_GUARD="$CHECKOUT_GIT_DIR/verify-xum.lock.guard" CURRENT_RUN_LINK="$CHECKOUT_GIT_DIR/verify-xum.current" READY_TIMEOUT=${VERIFY_XUM_READY_TIMEOUT:-300} DISCARD_BASE_URL=http://127.0.0.1:9/v1 @@ -60,14 +60,19 @@ group_is_ours() { return 1 } -# The kernel holds this flock while any process keeps fd 9 open: the launcher, and the server's -# `make` and `dev-server-sandbox.ts`, which inherit it at the spawn and live as long as the server. -# So the lock is held while a server from this checkout runs, at whatever point the launcher -# dies. The lock file only names the group, for doctor. -take_checkout_lock() { - exec 9>"$ONE_SERVER_PER_CHECKOUT_LOCK.guard" - flock -n 9 \ - || die "a verify-xum launch or server from this checkout holds $ONE_SERVER_PER_CHECKOUT_LOCK.guard, and two would share dist/ and node_modules/.vite. Run verify-xum.sh cleanup first. If it stays held, lsof that file to find the holder." +# One server per checkout: two would share dist/ and node_modules/.vite. The guard is the newest +# run's process group, which records its own id in that run's state.env before it starts the +# server, so the guard holds at whatever point the launcher dies. The flock only keeps two +# launches from passing the check at once. The server does not inherit it. +refuse_while_checkout_server_runs() { + exec 9>"$LAUNCH_GUARD" + flock -w 30 9 || die "another verify-xum launch from this checkout holds $LAUNCH_GUARD" + [[ -f $CURRENT_RUN_LINK/state.env ]] || return 0 + ( + # shellcheck source=/dev/null + source "$CURRENT_RUN_LINK/state.env" + ! group_is_ours "$VERIFY_XUM_PGID" "$VERIFY_XUM_RUN_ID" + ) || die "the verify-xum run $(readlink "$CURRENT_RUN_LINK") still runs from this checkout. Run verify-xum.sh cleanup first." } load_run() { @@ -122,12 +127,12 @@ backend_ready() { write_state() { local name - : >"$VERIFY_XUM_RUN/state.env" for name in VERIFY_XUM_RUN_ID VERIFY_XUM_RUN VERIFY_XUM_CHECKOUT VERIFY_XUM_COMMIT VERIFY_XUM_PGID VERIFY_XUM_ROOT \ VERIFY_XUM_BACKEND VERIFY_XUM_URL VERIFY_XUM_PROJECT VERIFY_XUM_WORKSPACE_ID \ VERIFY_XUM_WORKTREE VERIFY_XUM_EVIDENCE AGENT_BROWSER_SESSION; do - printf 'export %s=%q\n' "$name" "${!name-}" >>"$VERIFY_XUM_RUN/state.env" - done + printf 'export %s=%q\n' "$name" "${!name-}" + done >"$VERIFY_XUM_RUN/state.env.tmp" + mv -f "$VERIFY_XUM_RUN/state.env.tmp" "$VERIFY_XUM_RUN/state.env" } check_bun() { @@ -182,10 +187,10 @@ launch_failed() { } cmd_launch() { - local t0 i backend_port vite_port log pidfile + local t0 backend_port vite_port log need git curl node bun make setsid flock ss agent-browser check_bun - take_checkout_lock + refuse_while_checkout_server_runs VERIFY_XUM_RUN_ID="$(date -u +%Y%m%d-%H%M%S)-$$" VERIFY_XUM_RUN="$VERIFY_XUM_HOME/$VERIFY_XUM_RUN_ID" @@ -196,29 +201,29 @@ cmd_launch() { AGENT_BROWSER_SESSION="verify-xum-$VERIFY_XUM_RUN_ID" mkdir -p "$VERIFY_XUM_EVIDENCE" "$VERIFY_XUM_RUN/scratch" log="$VERIFY_XUM_RUN/server.log" - pidfile="$VERIFY_XUM_RUN/scratch/pgid" t0=$(date +%s) LAUNCH_DEADLINE=$((SECONDS + READY_TIMEOUT)) local env_without_live_xum=(env -i PATH="$PATH" HOME="$HOME" USER="${USER:-}" LANG="${LANG:-C.UTF-8}" TERM=dumb TMPDIR="${TMPDIR:-/tmp}" XUM_MOCK_AI=1 XUM_DISABLE_TELEMETRY=1 VERIFY_XUM_SERVER_OF="$VERIFY_XUM_RUN_ID") - # Saved before the spawn, so cleanup finds a launch killed at any point after it. write_state ln -sfn "$VERIFY_XUM_RUN" "$CURRENT_RUN_LINK" trap launch_failed EXIT - # setsid(1) forks when its caller already leads a process group, so $! can miss the new - # group. The inner sh records its own pid, which is the new group id. + # The new group's leader appends its pid, which is the group id, to state.env before it starts + # make. From then on the launch guard and cleanup find the group, whenever the launcher dies. + # (setsid(1) forks when its caller already leads a process group, so $! can miss the group.) # shellcheck disable=SC2016 - setsid sh -c 'echo "$$" >"$1"; shift; exec "$@"' sh "$pidfile" "${env_without_live_xum[@]}" \ + setsid sh -c 'printf "export VERIFY_XUM_PGID=%s\n" "$$" >>"$1"; shift; exec "$@"' sh \ + "$VERIFY_XUM_RUN/state.env" "${env_without_live_xum[@]}" \ make -C "$CHECKOUT" dev-server-sandbox DEV_SERVER_SANDBOX_ARGS="--clean-providers --clean-projects" \ - >"$log" 2>&1 </dev/null & - for ((i = 0; i < 50; i++)); do - [[ -s $pidfile ]] && break + >"$log" 2>&1 </dev/null 9>&- & + until VERIFY_XUM_PGID=$(sed -n 's/^export VERIFY_XUM_PGID=\([0-9][0-9]*\)$/\1/p' "$VERIFY_XUM_RUN/state.env") + [[ -n $VERIFY_XUM_PGID ]]; do + before_launch_deadline sleep 0.1 done - VERIFY_XUM_PGID=$(cat "$pidfile") - echo "$VERIFY_XUM_PGID" >"$ONE_SERVER_PER_CHECKOUT_LOCK" + exec 9>&- write_state while true; do @@ -270,7 +275,7 @@ cmd_doctor() { } check "process group $VERIFY_XUM_PGID is alive and carries this run's id" group_is_ours "$VERIFY_XUM_PGID" "$VERIFY_XUM_RUN_ID" - check "checkout lock $ONE_SERVER_PER_CHECKOUT_LOCK names this run" test "$(cat "$ONE_SERVER_PER_CHECKOUT_LOCK" 2>/dev/null)" = "$VERIFY_XUM_PGID" + check "this checkout's newest run ($CURRENT_RUN_LINK) is this run" test "$(readlink "$CURRENT_RUN_LINK" 2>/dev/null)" = "$VERIFY_XUM_RUN" backend_pid=$(json_field_or_empty "$VERIFY_XUM_ROOT/server.lock" pid) check "backend pid ${backend_pid:-none} (server.lock in $VERIFY_XUM_ROOT) belongs to this run" \ pid_in_group "$backend_pid" "$VERIFY_XUM_PGID" @@ -341,9 +346,6 @@ is_dev_server_sandbox_root() { cmd_cleanup() { load_run "${1:-}" local i - # A launch killed before it saved these left them only in the pidfile and the server log. - [[ -n $VERIFY_XUM_PGID ]] || VERIFY_XUM_PGID=$(cat "$VERIFY_XUM_RUN/scratch/pgid" 2>/dev/null || true) - [[ -n $VERIFY_XUM_ROOT ]] || VERIFY_XUM_ROOT=$(sed -n 's/^ XUM_ROOT: *//p' "$VERIFY_XUM_RUN/server.log" 2>/dev/null || true) # agent-browser `close` on a session that does not exist starts that session first. if agent-browser session list 2>/dev/null | grep -qF "$AGENT_BROWSER_SESSION"; then agent-browser --session "$AGENT_BROWSER_SESSION" close >/dev/null 2>&1 || true @@ -364,7 +366,6 @@ cmd_cleanup() { rm -rf "$VERIFY_XUM_ROOT" fi rm -rf "$VERIFY_XUM_RUN/scratch" - [[ $(cat "$ONE_SERVER_PER_CHECKOUT_LOCK" 2>/dev/null) == "$VERIFY_XUM_PGID" ]] && rm -f "$ONE_SERVER_PER_CHECKOUT_LOCK" [[ $(readlink "$CURRENT_RUN_LINK" 2>/dev/null) == "$VERIFY_XUM_RUN" ]] && rm -f "$CURRENT_RUN_LINK" VERIFY_XUM_PGID= write_state From a450f19ed00fd80f137063d9455b0e9850310609 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski <tk@coder.com> Date: Tue, 3 Nov 2026 09:18:37 +0000 Subject: [PATCH 16/51] =?UTF-8?q?=F0=9F=A4=96=20docs:=20drop=20the=20SIGKI?= =?UTF-8?q?LL=20leftover-folder=20note:=20dev-server-sandbox.ts=20deletes?= =?UTF-8?q?=20its=20root=20when=20the=20group=20stops?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .xum/skills/verify-xum/SKILL.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.xum/skills/verify-xum/SKILL.md b/.xum/skills/verify-xum/SKILL.md index 9b3a2842ee9..93dd9934909 100644 --- a/.xum/skills/verify-xum/SKILL.md +++ b/.xum/skills/verify-xum/SKILL.md @@ -164,9 +164,7 @@ then SIGKILL after 20 s), deletes the sandbox XUM_ROOT and the demo repo, and pr path and file count. It refuses a run that another checkout launched. It signals the group only while one of its processes carries the run's id and a live group leader is still the launch's `make dev-server-sandbox`, so it kills nothing it did not start. Then it blanks the group id in -the run's `state.env`. A launch killed with SIGKILL before it printed `ready` can leave its -`mux-dev-server-*` folder in TMPDIR: cleanup stops the group, but the run never recorded that -folder. Run cleanup after every run, failed ones too. `doctor`, `env`, +the run's `state.env`. Run it after every run, failed ones too. `doctor`, `env`, `capture`, `idle`, `send-ready` and `cleanup` take an optional run directory. Without one they use the newest launch from this checkout. From a7882a7ef040afa969fae5c764fc8a8119e4cf27 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski <tk@coder.com> Date: Tue, 3 Nov 2026 10:24:40 +0000 Subject: [PATCH 17/51] =?UTF-8?q?=F0=9F=A4=96=20fix:=20clear=20inherited?= =?UTF-8?q?=20run=20state=20in=20launch,=20bound=20probes=20by=20the=20dea?= =?UTF-8?q?dline,=20wait=20for=20the=20settings=20write?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - launch empties VERIFY_XUM_PGID, ROOT, BACKEND, URL, WORKSPACE_ID and WORKTREE before its first state write, and reads only the last recorded group id. A shell that had evaluated `env` for an earlier run exported its PGID, so the new state held two ids and the launch failed with an orphan group. - Each readiness probe checks the deadline first and uses the time left as curl's limit. - settings.md waits until config.json holds the new value before it captures or reloads, and again in Restore. - SKILL.md states the one leftover-folder limit, says that a panel stays open across a cold-cache reload, and command-palette.md says that the first open shows a textbox until the palette's code loads. --- .xum/skills/verify-xum/SKILL.md | 11 +++++++++-- .../verify-xum/features/command-palette.md | 3 ++- .xum/skills/verify-xum/features/settings.md | 16 ++++++++++++---- .xum/skills/verify-xum/scripts/verify-xum.sh | 15 ++++++++++++--- 4 files changed, 35 insertions(+), 10 deletions(-) diff --git a/.xum/skills/verify-xum/SKILL.md b/.xum/skills/verify-xum/SKILL.md index 93dd9934909..ff45527b99e 100644 --- a/.xum/skills/verify-xum/SKILL.md +++ b/.xum/skills/verify-xum/SKILL.md @@ -164,7 +164,12 @@ then SIGKILL after 20 s), deletes the sandbox XUM_ROOT and the demo repo, and pr path and file count. It refuses a run that another checkout launched. It signals the group only while one of its processes carries the run's id and a live group leader is still the launch's `make dev-server-sandbox`, so it kills nothing it did not start. Then it blanks the group id in -the run's `state.env`. Run it after every run, failed ones too. `doctor`, `env`, +the run's `state.env`. Run it after every run, failed ones too. + +Limit: if the launcher dies in the moment after the sandbox logs its `XUM_ROOT` and before launch +records it (up to 0.5 s), and a server process then ignores SIGTERM, cleanup must SIGKILL the +group. `dev-server-sandbox.ts` then cannot delete its `mux-dev-server-*` folder, and the run never +recorded it. Delete that folder in TMPDIR by hand. `doctor`, `env`, `capture`, `idle`, `send-ready` and `cleanup` take an optional run directory. Without one they use the newest launch from this checkout. @@ -173,7 +178,9 @@ the newest launch from this checkout. - The bash tool's environment carries the live Xum's `PORT`, auth token and provider keys. Launch drops them with `env -i`. Never start a server or CLI by hand without the same care. - The first open of a lazily loaded panel on a cold Vite cache (Review, for one) reloads the page: - `server.log` shows `optimized dependencies changed. reloading`. Repeat the step after the reload. + `server.log` shows `optimized dependencies changed. reloading`. The panel and its tab stay open + across the reload. Run `idle`, wait for the panel's text, and continue from the open tab (see + the Review recipe). - Now and then a lazily loaded module request from agent-browser to Vite never completes. The page or panel then shows `<name> failed to load.` with a `Reload` button, and the console logs `Failed to fetch dynamically imported module`. curl gets the same module at once, so it is not diff --git a/.xum/skills/verify-xum/features/command-palette.md b/.xum/skills/verify-xum/features/command-palette.md index 4675e5fb19a..c6a0bfdd05a 100644 --- a/.xum/skills/verify-xum/features/command-palette.md +++ b/.xum/skills/verify-xum/features/command-palette.md @@ -46,7 +46,8 @@ Preconditions: - Wait for the option you want to be selected before you press Enter. The first palette open after a page load shows a plain input while the palette's code loads (`LoadingPaletteInput` at `src/browser/components/CommandPalette/LazyCommandPalette.tsx:38`), with the same label - `Command palette`. agent-browser types and presses Enter in a few milliseconds, before the code + `Command palette`. A snapshot shows it as `textbox "Command palette"` until the code loads, then + as `combobox "Command palette"`. agent-browser types and presses Enter in a few milliseconds, before the code arrives. Since #7897 that input keeps the Enter and runs it on the first option once the code loads. Before it, the Enter was lost. The wait proves that the option Enter runs is the one the recipe expects. diff --git a/.xum/skills/verify-xum/features/settings.md b/.xum/skills/verify-xum/features/settings.md index 6f4e5c039c5..fd3eac91415 100644 --- a/.xum/skills/verify-xum/features/settings.md +++ b/.xum/skills/verify-xum/features/settings.md @@ -42,8 +42,15 @@ Preconditions: URL is `/settings/general`. - **Change a setting.** Run `agent-browser find role switch click --name "Toggle flat chat list" --exact`. The switch is - `checked=true`, and `$VERIFY_XUM_ROOT/config.json` has `"sidebarFlatMode": true` under - `userPreferences.ui`. Capture `settings/flat-on`. + `checked=true` at once, but the server writes `config.json` a moment later. Wait for the write + before you capture or reload: + + ```bash + timeout 10 sh -c 'until grep -qF "\"sidebarFlatMode\": true" "$1"; do sleep 0.2; done' sh "$VERIFY_XUM_ROOT/config.json" + ``` + + `$VERIFY_XUM_ROOT/config.json` has `"sidebarFlatMode": true` under `userPreferences.ui`. Capture + `settings/flat-on`. - **See the effect.** Run `agent-browser press Escape` and `agent-browser wait --text "RECENT"`. The sidebar has a `RECENT` group, and the workspace row is `Select workspace Verify playground (demo-app)`. @@ -52,8 +59,9 @@ Preconditions: `agent-browser wait --text "Choose your preferred theme"`. The switch `Toggle flat chat list` is still `checked=true`. Capture `settings/persisted`. - **Restore.** Run - `agent-browser find role switch click --name "Toggle flat chat list" --exact` and - `agent-browser press Escape`. `config.json` has `"sidebarFlatMode": false`. The page opened + `agent-browser find role switch click --name "Toggle flat chat list" --exact`, wait until + `config.json` has `"sidebarFlatMode": false` (the same `timeout 10` loop with `false`), and run + `agent-browser press Escape`. The page opened Settings by its URL, so Escape lands on `/project?...`. Run `agent-browser open "$VERIFY_XUM_URL/workspace/$VERIFY_XUM_WORKSPACE_ID"` and `verify-xum.sh idle` to be back on `Verify playground`. diff --git a/.xum/skills/verify-xum/scripts/verify-xum.sh b/.xum/skills/verify-xum/scripts/verify-xum.sh index 8afd46ce19c..86b6a9fa2ee 100755 --- a/.xum/skills/verify-xum/scripts/verify-xum.sh +++ b/.xum/skills/verify-xum/scripts/verify-xum.sh @@ -89,6 +89,12 @@ before_launch_deadline() { ((SECONDS < LAUNCH_DEADLINE)) || die "launch did not finish in $READY_TIMEOUT s" } +# A readiness probe that gives up at the launch deadline. +probe_before_launch_deadline() { + before_launch_deadline + curl -fs --max-time $((LAUNCH_DEADLINE - SECONDS)) -o /dev/null "$1" +} + api() { before_launch_deadline curl -fsS --max-time $((LAUNCH_DEADLINE - SECONDS)) -X POST "$VERIFY_XUM_BACKEND/api/$1" \ @@ -122,7 +128,7 @@ backend_ready() { } catch { process.exit(1); }' "$VERIFY_XUM_ROOT/server.lock" "$CHECKOUT/dist/.main-build-complete" "$1" \ - && curl -fs --max-time 5 -o /dev/null "$VERIFY_XUM_BACKEND/health" + && probe_before_launch_deadline "$VERIFY_XUM_BACKEND/health" } write_state() { @@ -207,6 +213,9 @@ cmd_launch() { local env_without_live_xum=(env -i PATH="$PATH" HOME="$HOME" USER="${USER:-}" LANG="${LANG:-C.UTF-8}" TERM=dumb TMPDIR="${TMPDIR:-/tmp}" XUM_MOCK_AI=1 XUM_DISABLE_TELEMETRY=1 VERIFY_XUM_SERVER_OF="$VERIFY_XUM_RUN_ID") + # A shell that ran `eval "$(verify-xum.sh env)"` still exports the previous run's values. + VERIFY_XUM_PGID='' VERIFY_XUM_ROOT='' VERIFY_XUM_BACKEND='' VERIFY_XUM_URL='' + VERIFY_XUM_WORKSPACE_ID='' VERIFY_XUM_WORKTREE='' write_state ln -sfn "$VERIFY_XUM_RUN" "$CURRENT_RUN_LINK" trap launch_failed EXIT @@ -218,7 +227,7 @@ cmd_launch() { "$VERIFY_XUM_RUN/state.env" "${env_without_live_xum[@]}" \ make -C "$CHECKOUT" dev-server-sandbox DEV_SERVER_SANDBOX_ARGS="--clean-providers --clean-projects" \ >"$log" 2>&1 </dev/null 9>&- & - until VERIFY_XUM_PGID=$(sed -n 's/^export VERIFY_XUM_PGID=\([0-9][0-9]*\)$/\1/p' "$VERIFY_XUM_RUN/state.env") + until VERIFY_XUM_PGID=$(sed -n 's/^export VERIFY_XUM_PGID=\([0-9][0-9]*\)$/\1/p' "$VERIFY_XUM_RUN/state.env" | tail -n 1) [[ -n $VERIFY_XUM_PGID ]]; do before_launch_deadline sleep 0.1 @@ -239,7 +248,7 @@ cmd_launch() { VERIFY_XUM_URL="http://127.0.0.1:$vite_port" write_state - until backend_ready "$t0" && curl -fs --max-time 5 -o /dev/null "$VERIFY_XUM_URL/"; do + until backend_ready "$t0" && probe_before_launch_deadline "$VERIFY_XUM_URL/"; do before_launch_deadline group_alive "$VERIFY_XUM_PGID" || die "the dev server exited before it was ready" sleep 1 From b74b7f14213977f078d762ecf2f677008e4cca6c Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski <tk@coder.com> Date: Tue, 3 Nov 2026 10:43:23 +0000 Subject: [PATCH 18/51] =?UTF-8?q?=F0=9F=A4=96=20docs:=20prettier-format=20?= =?UTF-8?q?the=20settings=20recipe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .xum/skills/verify-xum/features/settings.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.xum/skills/verify-xum/features/settings.md b/.xum/skills/verify-xum/features/settings.md index fd3eac91415..ac46c5d28e9 100644 --- a/.xum/skills/verify-xum/features/settings.md +++ b/.xum/skills/verify-xum/features/settings.md @@ -51,6 +51,7 @@ Preconditions: `$VERIFY_XUM_ROOT/config.json` has `"sidebarFlatMode": true` under `userPreferences.ui`. Capture `settings/flat-on`. + - **See the effect.** Run `agent-browser press Escape` and `agent-browser wait --text "RECENT"`. The sidebar has a `RECENT` group, and the workspace row is `Select workspace Verify playground (demo-app)`. From 6e3e29fabe08e8dba8dbb608f539533d80b5babe Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski <tk@coder.com> Date: Tue, 3 Nov 2026 10:51:09 +0000 Subject: [PATCH 19/51] =?UTF-8?q?=F0=9F=A4=96=20fix:=20stop=20doctor's=20V?= =?UTF-8?q?ite=20check=20and=20cleanup's=20session=20check=20from=20failin?= =?UTF-8?q?g=20on=20SIGPIPE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under pipefail, `curl | grep -q` fails when grep exits at the match while curl still writes: doctor printed "FAIL ... serves the Vite dev UI" on 2 of 40 runs against a healthy server, and 0 of 40 after this change. Both checks now match the captured output instead. --- .xum/skills/verify-xum/scripts/verify-xum.sh | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.xum/skills/verify-xum/scripts/verify-xum.sh b/.xum/skills/verify-xum/scripts/verify-xum.sh index 86b6a9fa2ee..296a84126cf 100755 --- a/.xum/skills/verify-xum/scripts/verify-xum.sh +++ b/.xum/skills/verify-xum/scripts/verify-xum.sh @@ -262,7 +262,8 @@ cmd_launch() { } serves_vite_ui() { - curl -fs --max-time 5 "$1/" 2>/dev/null | grep -q '/@vite/client' + # Not `curl | grep -q`: grep exits at the match, and under pipefail curl's SIGPIPE fails the check. + [[ $(curl -fs --max-time 5 "$1/" 2>/dev/null) == *"/@vite/client"* ]] } pid_in_group() { @@ -356,7 +357,7 @@ cmd_cleanup() { load_run "${1:-}" local i # agent-browser `close` on a session that does not exist starts that session first. - if agent-browser session list 2>/dev/null | grep -qF "$AGENT_BROWSER_SESSION"; then + if [[ $(agent-browser session list 2>/dev/null) == *"$AGENT_BROWSER_SESSION"* ]]; then agent-browser --session "$AGENT_BROWSER_SESSION" close >/dev/null 2>&1 || true fi if group_is_ours "$VERIFY_XUM_PGID" "$VERIFY_XUM_RUN_ID"; then From a928bd93335cb06220564d317a565cf72e77191d Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski <tk@coder.com> Date: Tue, 3 Nov 2026 16:07:33 +0000 Subject: [PATCH 20/51] =?UTF-8?q?=F0=9F=A4=96=20fix:=20round-8=20verify-xu?= =?UTF-8?q?m=20findings,=20auth-token=20support,=20launch=20crash=20and=20?= =?UTF-8?q?config=20guards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - review.md Restore waits until config.json holds "includeUncommitted": false. - The demo seed commit runs with core.hooksPath=/dev/null, so the developer's git hooks stay out. - workspaces.md waits for the archive and restore results before it captures. - Without XUM_SCRATCH_DIR, VERIFY_XUM_HOME defaults to ${TMPDIR:-/tmp}/verify-xum-<uid>. - The README expand step clicks only when the project is collapsed, so it is safe under &&. - Launch refuses a sandbox that copied a config.json (it copies none with both --clean flags), so every run starts from default preferences. - When the backend records an auth token in server.lock (#7904), api() and doctor send it to curl on stdin, and `env` exports VERIFY_XUM_OPEN_QUERY (?token=...) for the first browser open without printing the token. Without a token nothing changes. - Launch stops at once when the backend crashes after its rebuild, clamps curl's --max-time to at least 1 s, and the docs no longer claim the guard holds in the instant before the group id is written. The Cleanup run-directory sentence sits above the Limit paragraph again. --- .xum/skills/verify-xum/SKILL.md | 27 +++++--- .xum/skills/verify-xum/features/README.md | 18 +++-- .xum/skills/verify-xum/features/review.md | 9 ++- .xum/skills/verify-xum/features/settings.md | 4 +- .xum/skills/verify-xum/features/workspaces.md | 14 ++-- .xum/skills/verify-xum/scripts/verify-xum.sh | 68 ++++++++++++++++--- 6 files changed, 107 insertions(+), 33 deletions(-) diff --git a/.xum/skills/verify-xum/SKILL.md b/.xum/skills/verify-xum/SKILL.md index ff45527b99e..e875a974822 100644 --- a/.xum/skills/verify-xum/SKILL.md +++ b/.xum/skills/verify-xum/SKILL.md @@ -61,8 +61,10 @@ It prints the run's `export` lines and the URL of the seeded workspace. A failed server log tail and cleans up. Launch refuses while the process group of this checkout's newest run is alive, by the same identity check as cleanup: two dev servers in one checkout share `dist/` and `node_modules/.vite`. The group's first process writes the group id into the run's -`state.env` before it starts the server, so the check and cleanup find the group even when the -launcher was killed partway. +`state.env` before it starts the server. From that moment, right after the spawn, the check and +cleanup find the group even when the launcher was killed. Launch also stops at once when the +sandbox copied a `config.json` (the recipes assume default preferences) and when the backend +crashes after its rebuild (nodemon then waits for a file change that never comes). ## Doctor @@ -76,16 +78,18 @@ Every check line must read `ok`. A `note` line only informs. It checks that the lives and carries the run's id (`VERIFY_XUM_SERVER_OF` in its env), the checkout's newest-run link names the run, the backend pid in the sandbox `server.lock` and the app port belong to the group, `/health` answers, `/version` reports the launch commit, and the backend env has `XUM_MOCK_AI=1`. +When the backend has an auth token, doctor and launch read it from `server.lock` and send it to +curl on stdin, never in argv. Exit 1 means do not drive this instance: run cleanup, then launch again. ## Drive Every bash call starts by loading the run. This sets `AGENT_BROWSER_SESSION` (a session name only -this run uses) and the `VERIFY_XUM_*` paths and URLs: +this run uses), the `VERIFY_XUM_*` paths and URLs, and `VERIFY_XUM_OPEN_QUERY`: ```bash eval "$(.xum/skills/verify-xum/scripts/verify-xum.sh env)" -agent-browser open "$VERIFY_XUM_URL/workspace/$VERIFY_XUM_WORKSPACE_ID" +agent-browser open "$VERIFY_XUM_URL/workspace/$VERIFY_XUM_WORKSPACE_ID$VERIFY_XUM_OPEN_QUERY" .xum/skills/verify-xum/scripts/verify-xum.sh idle agent-browser snapshot -i agent-browser find role button click --name "Open settings" --exact @@ -94,6 +98,10 @@ agent-browser find role button click --name "Open settings" --exact - Prefer `find role <role> <action> --name "<accessible name>" --exact` and the names in the feature map over `@eN` refs, which change with every snapshot. Without `--exact`, `--name` matches substrings and can act on a different element. +- `VERIFY_XUM_OPEN_QUERY` is `?token=<token>` when the backend has an auth token (`make dev-server` + with #7904), and empty otherwise. Put it on the first `open` of a browser session: the app + keeps the token and strips it from the address bar. CAUTION: that URL puts the token in the + `agent-browser` command line, which every local account can read while it runs. - `idle` waits until the page settles: no CSS transition runs, and on a `/workspace/` page the `Conversation transcript` log is neither loading nor streaming (`aria-busy="false"`). A click while the right sidebar slides open lands where the target was and is lost. `idle` exits 3 @@ -126,7 +134,8 @@ agent-browser find role button click --name "Open settings" --exact ## Evidence Evidence goes to `$VERIFY_XUM_EVIDENCE`, which is `$VERIFY_XUM_HOME/<run>/evidence`. -`VERIFY_XUM_HOME` defaults to `${XUM_SCRATCH_DIR:-${TMPDIR:-/tmp}}/verify-xum`. Cleanup never +`VERIFY_XUM_HOME` defaults to `$XUM_SCRATCH_DIR/verify-xum`, or without `XUM_SCRATCH_DIR` to +`${TMPDIR:-/tmp}/verify-xum-<uid>`. Cleanup never deletes the evidence or the server log next to it (`<run>/server.log`). ```bash @@ -164,14 +173,14 @@ then SIGKILL after 20 s), deletes the sandbox XUM_ROOT and the demo repo, and pr path and file count. It refuses a run that another checkout launched. It signals the group only while one of its processes carries the run's id and a live group leader is still the launch's `make dev-server-sandbox`, so it kills nothing it did not start. Then it blanks the group id in -the run's `state.env`. Run it after every run, failed ones too. +the run's `state.env`. Run it after every run, failed ones too. `doctor`, `env`, `capture`, +`idle`, `send-ready` and `cleanup` take an optional run directory. Without one they use the newest +launch from this checkout. Limit: if the launcher dies in the moment after the sandbox logs its `XUM_ROOT` and before launch records it (up to 0.5 s), and a server process then ignores SIGTERM, cleanup must SIGKILL the group. `dev-server-sandbox.ts` then cannot delete its `mux-dev-server-*` folder, and the run never -recorded it. Delete that folder in TMPDIR by hand. `doctor`, `env`, -`capture`, `idle`, `send-ready` and `cleanup` take an optional run directory. Without one they use -the newest launch from this checkout. +recorded it. Delete that folder in TMPDIR by hand. ## Gotchas diff --git a/.xum/skills/verify-xum/features/README.md b/.xum/skills/verify-xum/features/README.md index 63f82a5e4b2..45d16195530 100644 --- a/.xum/skills/verify-xum/features/README.md +++ b/.xum/skills/verify-xum/features/README.md @@ -22,11 +22,19 @@ feature file with the `verify-xum` harness. Update the file in the same change a - Start every recipe whose preconditions name `Verify playground` by opening it, even after another recipe: the workspaces and command-palette recipes end on `release-notes`, and the recipes read evidence from `$VERIFY_XUM_WORKSPACE_ID`. Open the app with - `agent-browser open "$VERIFY_XUM_URL/workspace/$VERIFY_XUM_WORKSPACE_ID"`, - run `verify-xum.sh idle`, and expand the project in the sidebar with - `agent-browser find role button click --name "Expand project demo-app" --exact`. A new browser - session starts with the project collapsed, and the recipes choose workspace rows by name. The - expand command fails when the project is already expanded. That failure is harmless. + `agent-browser open "$VERIFY_XUM_URL/workspace/$VERIFY_XUM_WORKSPACE_ID$VERIFY_XUM_OPEN_QUERY"` + and run `verify-xum.sh idle`. A new browser session starts with the project collapsed, and the + recipes choose workspace rows by name, so expand it when it is collapsed. This form fails only + when the button is there and the click fails, so it is safe in an `&&` chain: + + ```bash + [[ $(agent-browser eval '!!document.querySelector("[aria-label=\"Expand project demo-app\"]")') != true ]] \ + || agent-browser find role button click --name "Expand project demo-app" --exact + ``` + +- `$VERIFY_XUM_OPEN_QUERY` (SKILL.md Drive) holds the backend's auth token when it has one. Add + it to the first `open` of a browser session, as above. Later opens in that session do not need + it. - Send a message in three steps: fill `Message`, run `verify-xum.sh send-ready`, then press Enter or choose `Send message`. - Use accessible names with `agent-browser find role <role> <action> --name "<name>" --exact`. diff --git a/.xum/skills/verify-xum/features/review.md b/.xum/skills/verify-xum/features/review.md index 917914c560d..b7b58b43e86 100644 --- a/.xum/skills/verify-xum/features/review.md +++ b/.xum/skills/verify-xum/features/review.md @@ -59,8 +59,13 @@ Preconditions: - **Restore.** Run `agent-browser find role button click --name "Mark as read (M)" --exact`. The tab reads `Review 0/1`, and `review-state.json` has no `"isRead": true`. Run `agent-browser find role checkbox click --name "Uncommitted:" --exact`. The box is - `checked=false`, and `config.json` has `"includeUncommitted": false` under - `userPreferences.review`. Run `git -C "$VERIFY_XUM_WORKTREE" checkout -- src/math.ts`, + `checked=false` at once. Wait until the server has written it: + + ```bash + timeout 10 sh -c 'until grep -qF "\"includeUncommitted\": false" "$1"; do sleep 0.2; done' sh "$VERIFY_XUM_ROOT/config.json" + ``` + + `config.json` has `"includeUncommitted": false` under `userPreferences.review`. Run `git -C "$VERIFY_XUM_WORKTREE" checkout -- src/math.ts`, `agent-browser find role button click --name "Close Review" --exact` and `agent-browser wait '[aria-label="Expand right sidebar"]'`. Closing the last tab collapses the sidebar. diff --git a/.xum/skills/verify-xum/features/settings.md b/.xum/skills/verify-xum/features/settings.md index ac46c5d28e9..855d6ef2c72 100644 --- a/.xum/skills/verify-xum/features/settings.md +++ b/.xum/skills/verify-xum/features/settings.md @@ -56,7 +56,7 @@ Preconditions: The sidebar has a `RECENT` group, and the workspace row is `Select workspace Verify playground (demo-app)`. - **Reload.** Run `agent-browser reload`, - `agent-browser open "$VERIFY_XUM_URL/settings/general"` and + `agent-browser open "$VERIFY_XUM_URL/settings/general$VERIFY_XUM_OPEN_QUERY"` and `agent-browser wait --text "Choose your preferred theme"`. The switch `Toggle flat chat list` is still `checked=true`. Capture `settings/persisted`. - **Restore.** Run @@ -64,7 +64,7 @@ Preconditions: `config.json` has `"sidebarFlatMode": false` (the same `timeout 10` loop with `false`), and run `agent-browser press Escape`. The page opened Settings by its URL, so Escape lands on `/project?...`. Run - `agent-browser open "$VERIFY_XUM_URL/workspace/$VERIFY_XUM_WORKSPACE_ID"` and + `agent-browser open "$VERIFY_XUM_URL/workspace/$VERIFY_XUM_WORKSPACE_ID$VERIFY_XUM_OPEN_QUERY"` and `verify-xum.sh idle` to be back on `Verify playground`. ## Gotchas diff --git a/.xum/skills/verify-xum/features/workspaces.md b/.xum/skills/verify-xum/features/workspaces.md index 52f26d3d417..ecbd6c7613e 100644 --- a/.xum/skills/verify-xum/features/workspaces.md +++ b/.xum/skills/verify-xum/features/workspaces.md @@ -57,9 +57,11 @@ Preconditions: - **Archive from the row.** Focus the row's archive icon and press Enter. Run `agent-browser wait '[aria-label="Archive workspace release-notes"]'`, `agent-browser focus '[aria-label="Archive workspace release-notes"]'` and - `agent-browser press Enter`. The `Select workspace release-notes` row disappears, and in - `$VERIFY_XUM_ROOT/config.json` the `release-notes` entry gets an `archivedAt` time. Capture - `workspaces/archived`. + `agent-browser press Enter`. The archive request is still running when Enter returns. Wait for + its result with + `agent-browser wait --fn '!document.querySelector("[aria-label=\"Select workspace release-notes\"]")'`. + The `Select workspace release-notes` row is gone, and in `$VERIFY_XUM_ROOT/config.json` the + `release-notes` entry has an `archivedAt` time. Capture `workspaces/archived`. - **Archive with the keyboard.** Open `scratch-notes` from its row, run `verify-xum.sh idle`, and press Ctrl+Shift+Backspace. Run `agent-browser press Control+Shift+Backspace`. The `Select workspace scratch-notes` row disappears, and its `config.json` entry gets an @@ -70,8 +72,10 @@ Preconditions: `agent-browser find role button click --name "Expand archived workspaces" --exact` and `agent-browser wait '[aria-label="Restore workspace release-notes"]'`. The region `Archived workspaces` lists `release-notes` and `scratch-notes`. Run - `agent-browser find role button click --name "Restore workspace release-notes" --exact`. The - app opens `/workspace/<release-notes id>`, the sidebar row is back, and `config.json` gets an + `agent-browser find role button click --name "Restore workspace release-notes" --exact`, then + wait for the result with `agent-browser wait --url "**/workspace/<release-notes id>"` and + `agent-browser wait '[aria-label="Select workspace release-notes"]'`. The app is on + `/workspace/<release-notes id>`, the sidebar row is back, and `config.json` has an `unarchivedAt` time for it. Capture `workspaces/restored`. - **CLI view.** List the workspaces through the CLI: diff --git a/.xum/skills/verify-xum/scripts/verify-xum.sh b/.xum/skills/verify-xum/scripts/verify-xum.sh index 296a84126cf..05ab1cbd7cf 100755 --- a/.xum/skills/verify-xum/scripts/verify-xum.sh +++ b/.xum/skills/verify-xum/scripts/verify-xum.sh @@ -4,6 +4,7 @@ # verify-xum.sh launch start and seed a sandbox, print its state # verify-xum.sh doctor [RUN] read-only health check of a run # verify-xum.sh env [RUN] print the run's `export` lines and put this script on PATH (eval them) +# verify-xum.sh open-query [RUN] print `?token=<token>` when the backend has one (env uses it) # verify-xum.sh capture NAME [RUN] screenshot + ARIA snapshot + URL into the run's evidence # verify-xum.sh idle [RUN] wait until the page settles, exit 3 if a chunk failed to load # verify-xum.sh send-ready [RUN] wait until the composer's Send button is enabled @@ -16,7 +17,9 @@ SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) CHECKOUT=$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel) # make -C runs the sandbox from the checkout, so a relative TMPDIR would resolve there. [[ ${TMPDIR:-/tmp} == /* ]] || TMPDIR="$PWD/$TMPDIR" -VERIFY_XUM_HOME=${VERIFY_XUM_HOME:-${XUM_SCRATCH_DIR:-${TMPDIR:-/tmp}}/verify-xum} +# Without XUM_SCRATCH_DIR the default is under the shared TMPDIR, so it carries the user id. +VERIFY_XUM_HOME=${VERIFY_XUM_HOME:-${XUM_SCRATCH_DIR:+$XUM_SCRATCH_DIR/verify-xum}} +VERIFY_XUM_HOME=${VERIFY_XUM_HOME:-${TMPDIR:-/tmp}/verify-xum-$(id -u)} # The run link in the git dir must point at an absolute path. [[ $VERIFY_XUM_HOME == /* ]] || VERIFY_XUM_HOME="$PWD/$VERIFY_XUM_HOME" CHECKOUT_GIT_DIR=$(git -C "$CHECKOUT" rev-parse --path-format=absolute --git-dir) @@ -62,8 +65,9 @@ group_is_ours() { # One server per checkout: two would share dist/ and node_modules/.vite. The guard is the newest # run's process group, which records its own id in that run's state.env before it starts the -# server, so the guard holds at whatever point the launcher dies. The flock only keeps two -# launches from passing the check at once. The server does not inherit it. +# server. Once that id is written, a moment after the spawn, the guard holds wherever the launcher +# dies. A launcher killed inside that moment can leave a group that the guard does not see. The +# flock only keeps two launches from passing the check at once. The server does not inherit it. refuse_while_checkout_server_runs() { exec 9>"$LAUNCH_GUARD" flock -w 30 9 || die "another verify-xum launch from this checkout holds $LAUNCH_GUARD" @@ -89,15 +93,34 @@ before_launch_deadline() { ((SECONDS < LAUNCH_DEADLINE)) || die "launch did not finish in $READY_TIMEOUT s" } +# Seconds left before the launch deadline, at least 1: curl reads `--max-time 0` as no limit. +launch_seconds_left() { + local left=$((LAUNCH_DEADLINE - SECONDS)) + ((left > 0)) || left=1 + echo "$left" +} + # A readiness probe that gives up at the launch deadline. probe_before_launch_deadline() { before_launch_deadline - curl -fs --max-time $((LAUNCH_DEADLINE - SECONDS)) -o /dev/null "$1" + curl -fs --max-time "$(launch_seconds_left)" -o /dev/null "$1" +} + +# A backend started with auth (#7904's `make dev-server`) records its token in server.lock. The +# header goes to curl on stdin, so the token stays out of every process's argv. +backend_curl() { + local token + token=$(json_field_or_empty "$VERIFY_XUM_ROOT/server.lock" token) + if [[ -n $token ]]; then + printf 'Authorization: Bearer %s\n' "$token" | curl -H @- "$@" + else + curl "$@" + fi } api() { before_launch_deadline - curl -fsS --max-time $((LAUNCH_DEADLINE - SECONDS)) -X POST "$VERIFY_XUM_BACKEND/api/$1" \ + backend_curl -fsS --max-time "$(launch_seconds_left)" -X POST "$VERIFY_XUM_BACKEND/api/$1" \ -H 'content-type: application/json' -d "$2" } @@ -157,7 +180,7 @@ create_demo_repo() { git -C "$repo" init -q -b main git -C "$repo" add . git -C "$repo" -c user.name=verify-xum -c user.email=verify-xum@example.test \ - -c commit.gpgsign=false commit -qm init + -c commit.gpgsign=false -c core.hooksPath=/dev/null commit -qm init } seed_unreachable_fake_provider() { @@ -186,6 +209,16 @@ seed() { [[ -d $VERIFY_XUM_WORKTREE ]] || die "no worktree at $VERIFY_XUM_WORKTREE" } +# nodemon waits for a file change after the backend crashes, and none comes during a launch. A +# crash before the first rebuild is expected (a stale dist/): the rebuild restarts it. +backend_crashed_after_rebuild() { + sed 's/\x1b\[[0-9;]*m//g' "$1" | awk ' + /\[nodemon\] starting `.*dist\/cli\/index\.js server/ && prefix == "" { prefix = $1 } + prefix != "" && $1 == prefix && /\[nodemon\] restarting due to changes/ { rebuilt = 1; crashed = 0 } + prefix != "" && $1 == prefix && /\[nodemon\] app crashed/ { crashed = rebuilt } + END { exit !crashed }' +} + launch_failed() { echo "verify-xum: launch failed. Server log tail ($VERIFY_XUM_RUN/server.log):" >&2 tail -n 30 "$VERIFY_XUM_RUN/server.log" >&2 || true @@ -220,7 +253,7 @@ cmd_launch() { ln -sfn "$VERIFY_XUM_RUN" "$CURRENT_RUN_LINK" trap launch_failed EXIT # The new group's leader appends its pid, which is the group id, to state.env before it starts - # make. From then on the launch guard and cleanup find the group, whenever the launcher dies. + # make. From then on the launch guard and cleanup find the group, wherever the launcher dies. # (setsid(1) forks when its caller already leads a process group, so $! can miss the group.) # shellcheck disable=SC2016 setsid sh -c 'printf "export VERIFY_XUM_PGID=%s\n" "$$" >>"$1"; shift; exec "$@"' sh \ @@ -244,6 +277,8 @@ cmd_launch() { before_launch_deadline sleep 0.5 done + # The recipes assume default preferences. With both --clean flags the sandbox copies no config. + grep -q '^ Seed config: *(none)$' "$log" || die "the sandbox copied a config.json, so the run would not start from default preferences" VERIFY_XUM_BACKEND="http://127.0.0.1:$backend_port" VERIFY_XUM_URL="http://127.0.0.1:$vite_port" write_state @@ -251,6 +286,7 @@ cmd_launch() { until backend_ready "$t0" && probe_before_launch_deadline "$VERIFY_XUM_URL/"; do before_launch_deadline group_alive "$VERIFY_XUM_PGID" || die "the dev server exited before it was ready" + backend_crashed_after_rebuild "$log" && die "the backend crashed after its rebuild (nodemon waits for a file change)" sleep 1 done @@ -291,8 +327,8 @@ cmd_doctor() { pid_in_group "$backend_pid" "$VERIFY_XUM_PGID" check "server.lock names $VERIFY_XUM_BACKEND" \ test "$(json_field_or_empty "$VERIFY_XUM_ROOT/server.lock" baseUrl)" = "$VERIFY_XUM_BACKEND" - check "GET $VERIFY_XUM_BACKEND/health answers" curl -fs --max-time 5 -o /dev/null "$VERIFY_XUM_BACKEND/health" - version=$(curl -fs --max-time 5 "$VERIFY_XUM_BACKEND/version" 2>/dev/null | json_field_or_empty /dev/stdin git_commit || true) + check "GET $VERIFY_XUM_BACKEND/health answers" backend_curl -fs --max-time 5 -o /dev/null "$VERIFY_XUM_BACKEND/health" + version=$(backend_curl -fs --max-time 5 "$VERIFY_XUM_BACKEND/version" 2>/dev/null | json_field_or_empty /dev/stdin git_commit || true) check "backend build ${version:-unknown} is the launch commit $VERIFY_XUM_COMMIT" \ test -n "$version" -a "${VERIFY_XUM_COMMIT#"$version"}" != "$VERIFY_XUM_COMMIT" if [[ $(git -C "$VERIFY_XUM_CHECKOUT" rev-parse HEAD) != "$VERIFY_XUM_COMMIT" ]]; then @@ -312,6 +348,17 @@ cmd_env() { # The feature recipes call the helper by its bare name. $PATH expands when the caller evals. # shellcheck disable=SC2016 printf 'export PATH=%q:"$PATH"\n' "$SCRIPT_DIR" + # Read when the caller evals, so the token never shows in this output. + # shellcheck disable=SC2016 + printf 'export VERIFY_XUM_OPEN_QUERY="$(%q open-query %q)"\n' "$SCRIPT_DIR/verify-xum.sh" "$VERIFY_XUM_RUN" +} + +# `?token=<token>` for the first page a browser opens when the backend has a token, else nothing. +cmd_open_query() { + load_run "${1:-}" + local token + token=$(json_field_or_empty "$VERIFY_XUM_ROOT/server.lock" token) + [[ -z $token ]] || printf '?token=%s' "$(node -p 'encodeURIComponent(process.argv[1])' "$token")" } cmd_capture() { @@ -386,9 +433,10 @@ case ${1:-} in launch) cmd_launch ;; doctor) cmd_doctor "${2:-}" ;; env) cmd_env "${2:-}" ;; + open-query) cmd_open_query "${2:-}" ;; capture) cmd_capture "${2:-}" "${3:-}" ;; idle) cmd_idle "${2:-}" ;; send-ready) cmd_send_ready "${2:-}" ;; cleanup) cmd_cleanup "${2:-}" ;; - *) die "usage: verify-xum.sh launch | doctor [RUN] | env [RUN] | capture NAME [RUN] | idle [RUN] | send-ready [RUN] | cleanup [RUN]" ;; + *) die "usage: verify-xum.sh launch | doctor [RUN] | env [RUN] | open-query [RUN] | capture NAME [RUN] | idle [RUN] | send-ready [RUN] | cleanup [RUN]" ;; esac From f9c41d1386a9720cfa79af63e80ec223b9d568c6 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski <tk@coder.com> Date: Tue, 3 Nov 2026 17:09:22 +0000 Subject: [PATCH 21/51] =?UTF-8?q?=F0=9F=A4=96=20fix:=20keep=20verify-xum?= =?UTF-8?q?=20runs=20in=20a=20private=20home,=20read=20the=20token=20on=20?= =?UTF-8?q?stdin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The run home never defaults to a shared TMPDIR: $XUM_SCRATCH_DIR/verify-xum, else $XDG_RUNTIME_DIR/verify-xum, else $HOME/.cache/verify-xum. Launch creates it and each run folder with mode 0700. - Before any command sources a run's state.env, it checks that the home and the run folder are real folders (only the newest-run link is followed), owned by this user and not writable by group or others, and refuses otherwise. A folder another account created first could otherwise swap state.env and run commands as you. - open-query passes the token to node on stdin, not in argv. - review.md runs idle before Mark as read, the README expand snippet is one { ...; } group, and SKILL.md says VERIFY_XUM_OPEN_QUERY stays in the shell's environment. --- .xum/skills/verify-xum/SKILL.md | 11 ++++-- .xum/skills/verify-xum/features/README.md | 4 +- .xum/skills/verify-xum/features/review.md | 2 +- .xum/skills/verify-xum/scripts/verify-xum.sh | 39 ++++++++++++++++++-- 4 files changed, 46 insertions(+), 10 deletions(-) diff --git a/.xum/skills/verify-xum/SKILL.md b/.xum/skills/verify-xum/SKILL.md index e875a974822..012b7a1bdbf 100644 --- a/.xum/skills/verify-xum/SKILL.md +++ b/.xum/skills/verify-xum/SKILL.md @@ -101,7 +101,9 @@ agent-browser find role button click --name "Open settings" --exact - `VERIFY_XUM_OPEN_QUERY` is `?token=<token>` when the backend has an auth token (`make dev-server` with #7904), and empty otherwise. Put it on the first `open` of a browser session: the app keeps the token and strips it from the address bar. CAUTION: that URL puts the token in the - `agent-browser` command line, which every local account can read while it runs. + `agent-browser` command line, which every local account can read while it runs. The variable + itself sits in your shell's environment after the `eval`, so every process you start from that + shell inherits the token. - `idle` waits until the page settles: no CSS transition runs, and on a `/workspace/` page the `Conversation transcript` log is neither loading nor streaming (`aria-busy="false"`). A click while the right sidebar slides open lands where the target was and is lost. `idle` exits 3 @@ -134,8 +136,11 @@ agent-browser find role button click --name "Open settings" --exact ## Evidence Evidence goes to `$VERIFY_XUM_EVIDENCE`, which is `$VERIFY_XUM_HOME/<run>/evidence`. -`VERIFY_XUM_HOME` defaults to `$XUM_SCRATCH_DIR/verify-xum`, or without `XUM_SCRATCH_DIR` to -`${TMPDIR:-/tmp}/verify-xum-<uid>`. Cleanup never +`VERIFY_XUM_HOME` defaults to `$XUM_SCRATCH_DIR/verify-xum`, else `$XDG_RUNTIME_DIR/verify-xum`, +else `$HOME/.cache/verify-xum`, and never to a shared TMPDIR. Launch creates it with mode 0700. +Every command that reads a run's `state.env` first checks that the home and the run folder are +real folders (no symlinks), owned by you and not writable by group or others, and refuses +otherwise: the script sources that file. Cleanup never deletes the evidence or the server log next to it (`<run>/server.log`). ```bash diff --git a/.xum/skills/verify-xum/features/README.md b/.xum/skills/verify-xum/features/README.md index 45d16195530..b6af58237bf 100644 --- a/.xum/skills/verify-xum/features/README.md +++ b/.xum/skills/verify-xum/features/README.md @@ -28,8 +28,8 @@ feature file with the `verify-xum` harness. Update the file in the same change a when the button is there and the click fails, so it is safe in an `&&` chain: ```bash - [[ $(agent-browser eval '!!document.querySelector("[aria-label=\"Expand project demo-app\"]")') != true ]] \ - || agent-browser find role button click --name "Expand project demo-app" --exact + { [[ $(agent-browser eval '!!document.querySelector("[aria-label=\"Expand project demo-app\"]")') != true ]] \ + || agent-browser find role button click --name "Expand project demo-app" --exact; } ``` - `$VERIFY_XUM_OPEN_QUERY` (SKILL.md Drive) holds the backend's auth token when it has one. Add diff --git a/.xum/skills/verify-xum/features/review.md b/.xum/skills/verify-xum/features/review.md index b7b58b43e86..d549b9f1436 100644 --- a/.xum/skills/verify-xum/features/review.md +++ b/.xum/skills/verify-xum/features/review.md @@ -48,7 +48,7 @@ Preconditions: `agent-browser wait --text "math.ts"`. The file tree lists `src` and `math.ts` with `+3`, the hunk shows the three added lines of `sub`, and the tab reads `Review 0/1`. Capture `review/diff`. -- **Mark as read.** Run +- **Mark as read.** Run `verify-xum.sh idle`, then `agent-browser find role button click --name "Mark as read (M)" --exact`. The tab reads `Review 1/1`, and the hunk reads `Hunk marked as read`. `$VERIFY_XUM_ROOT/sessions/$VERIFY_XUM_WORKSPACE_ID/review-state.json` has diff --git a/.xum/skills/verify-xum/scripts/verify-xum.sh b/.xum/skills/verify-xum/scripts/verify-xum.sh index 05ab1cbd7cf..a56e3d90942 100755 --- a/.xum/skills/verify-xum/scripts/verify-xum.sh +++ b/.xum/skills/verify-xum/scripts/verify-xum.sh @@ -17,9 +17,11 @@ SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) CHECKOUT=$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel) # make -C runs the sandbox from the checkout, so a relative TMPDIR would resolve there. [[ ${TMPDIR:-/tmp} == /* ]] || TMPDIR="$PWD/$TMPDIR" -# Without XUM_SCRATCH_DIR the default is under the shared TMPDIR, so it carries the user id. +# Never a shared TMPDIR: another local account could create the folder first and swap a run's +# state.env, which this script sources (see require_private_dir). VERIFY_XUM_HOME=${VERIFY_XUM_HOME:-${XUM_SCRATCH_DIR:+$XUM_SCRATCH_DIR/verify-xum}} -VERIFY_XUM_HOME=${VERIFY_XUM_HOME:-${TMPDIR:-/tmp}/verify-xum-$(id -u)} +VERIFY_XUM_HOME=${VERIFY_XUM_HOME:-${XDG_RUNTIME_DIR:+$XDG_RUNTIME_DIR/verify-xum}} +VERIFY_XUM_HOME=${VERIFY_XUM_HOME:-$HOME/.cache/verify-xum} # The run link in the git dir must point at an absolute path. [[ $VERIFY_XUM_HOME == /* ]] || VERIFY_XUM_HOME="$PWD/$VERIFY_XUM_HOME" CHECKOUT_GIT_DIR=$(git -C "$CHECKOUT" rev-parse --path-format=absolute --git-dir) @@ -63,6 +65,26 @@ group_is_ours() { return 1 } +# A run's state.env is sourced, so the run folder and the home above it must be real folders (no +# symlinks), owned by this user and writable by nobody else. +require_private_dir() { + local dir=$1 mode + [[ -d $dir && ! -L $dir ]] || die "$dir is not a real folder" + [[ $(stat -c %u "$dir") == "$(id -u)" ]] || die "$dir is not owned by user $(id -u)" + mode=$(stat -c %a "$dir") + (((8#$mode & 8#022) == 0)) || die "$dir is writable by group or others (mode $mode)" +} + +# The run folder of a run argument, or behind the newest-run link (the one symlink allowed), +# checked before its state.env is read. +private_run_dir() { + local run=${1%/} + [[ $run == "$CURRENT_RUN_LINK" ]] && run=$(readlink "$run") + require_private_dir "$(dirname "$run")" + require_private_dir "$run" + echo "$run" +} + # One server per checkout: two would share dist/ and node_modules/.vite. The guard is the newest # run's process group, which records its own id in that run's state.env before it starts the # server. Once that id is written, a moment after the spawn, the guard holds wherever the launcher @@ -72,9 +94,11 @@ refuse_while_checkout_server_runs() { exec 9>"$LAUNCH_GUARD" flock -w 30 9 || die "another verify-xum launch from this checkout holds $LAUNCH_GUARD" [[ -f $CURRENT_RUN_LINK/state.env ]] || return 0 + local run + run=$(private_run_dir "$CURRENT_RUN_LINK") || exit 1 ( # shellcheck source=/dev/null - source "$CURRENT_RUN_LINK/state.env" + source "$run/state.env" ! group_is_ours "$VERIFY_XUM_PGID" "$VERIFY_XUM_RUN_ID" ) || die "the verify-xum run $(readlink "$CURRENT_RUN_LINK") still runs from this checkout. Run verify-xum.sh cleanup first." } @@ -82,6 +106,7 @@ refuse_while_checkout_server_runs() { load_run() { local run=${1:-$CURRENT_RUN_LINK} [[ -f $run/state.env ]] || die "no run at $run (run: verify-xum.sh launch)" + run=$(private_run_dir "$run") || exit 1 # shellcheck source=/dev/null source "$run/state.env" [[ $VERIFY_XUM_CHECKOUT == "$CHECKOUT" ]] \ @@ -238,6 +263,12 @@ cmd_launch() { VERIFY_XUM_CHECKOUT=$CHECKOUT VERIFY_XUM_COMMIT=$(git -C "$CHECKOUT" rev-parse HEAD) AGENT_BROWSER_SESSION="verify-xum-$VERIFY_XUM_RUN_ID" + if [[ ! -e $VERIFY_XUM_HOME ]]; then + mkdir -p "$(dirname "$VERIFY_XUM_HOME")" + mkdir -m 700 "$VERIFY_XUM_HOME" + fi + require_private_dir "$VERIFY_XUM_HOME" + mkdir -m 700 "$VERIFY_XUM_RUN" mkdir -p "$VERIFY_XUM_EVIDENCE" "$VERIFY_XUM_RUN/scratch" log="$VERIFY_XUM_RUN/server.log" t0=$(date +%s) @@ -358,7 +389,7 @@ cmd_open_query() { load_run "${1:-}" local token token=$(json_field_or_empty "$VERIFY_XUM_ROOT/server.lock" token) - [[ -z $token ]] || printf '?token=%s' "$(node -p 'encodeURIComponent(process.argv[1])' "$token")" + [[ -z $token ]] || printf '?token=%s' "$(printf %s "$token" | node -p 'encodeURIComponent(require("fs").readFileSync(0, "utf8"))')" } cmd_capture() { From 1f518fb63cdf97adccd230a279a6d14bf435b046 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski <tk@coder.com> Date: Tue, 3 Nov 2026 18:44:52 +0000 Subject: [PATCH 22/51] =?UTF-8?q?=F0=9F=A4=96=20fix:=20open=20verify-xum?= =?UTF-8?q?=20pages=20without=20the=20token=20in=20any=20argv,=20wait=20fo?= =?UTF-8?q?r=20navigations=20and=20writes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - verify-xum.sh open <path> replaces `agent-browser open "...$VERIFY_XUM_OPEN_QUERY"`. node builds the URL from server.lock and pipes [["open", url]] to `agent-browser batch` on stdin, so the token is in no process's command line or environment. VERIFY_XUM_OPEN_QUERY and open-query are gone. A failed open prints agent-browser's output with the token redacted. - command-palette.md waits for the destination URL after the palette switch, and workspaces.md after each sidebar row click. - workspaces.md waits for the keyboard archive to remove the row, and review.md Restore waits until review-state.json has no "isRead": true. - Launch records its start in milliseconds, so a build stamp from earlier in the same second is stale. --- .xum/skills/verify-xum/SKILL.md | 16 +++---- .xum/skills/verify-xum/features/README.md | 8 ++-- .../verify-xum/features/command-palette.md | 6 ++- .xum/skills/verify-xum/features/review.md | 9 +++- .xum/skills/verify-xum/features/settings.md | 4 +- .xum/skills/verify-xum/features/workspaces.md | 16 ++++--- .xum/skills/verify-xum/scripts/verify-xum.sh | 45 ++++++++++++------- 7 files changed, 65 insertions(+), 39 deletions(-) diff --git a/.xum/skills/verify-xum/SKILL.md b/.xum/skills/verify-xum/SKILL.md index 012b7a1bdbf..7c04f90b47d 100644 --- a/.xum/skills/verify-xum/SKILL.md +++ b/.xum/skills/verify-xum/SKILL.md @@ -85,11 +85,11 @@ Exit 1 means do not drive this instance: run cleanup, then launch again. ## Drive Every bash call starts by loading the run. This sets `AGENT_BROWSER_SESSION` (a session name only -this run uses), the `VERIFY_XUM_*` paths and URLs, and `VERIFY_XUM_OPEN_QUERY`: +this run uses) and the `VERIFY_XUM_*` paths and URLs: ```bash eval "$(.xum/skills/verify-xum/scripts/verify-xum.sh env)" -agent-browser open "$VERIFY_XUM_URL/workspace/$VERIFY_XUM_WORKSPACE_ID$VERIFY_XUM_OPEN_QUERY" +.xum/skills/verify-xum/scripts/verify-xum.sh open "/workspace/$VERIFY_XUM_WORKSPACE_ID" .xum/skills/verify-xum/scripts/verify-xum.sh idle agent-browser snapshot -i agent-browser find role button click --name "Open settings" --exact @@ -98,12 +98,12 @@ agent-browser find role button click --name "Open settings" --exact - Prefer `find role <role> <action> --name "<accessible name>" --exact` and the names in the feature map over `@eN` refs, which change with every snapshot. Without `--exact`, `--name` matches substrings and can act on a different element. -- `VERIFY_XUM_OPEN_QUERY` is `?token=<token>` when the backend has an auth token (`make dev-server` - with #7904), and empty otherwise. Put it on the first `open` of a browser session: the app - keeps the token and strips it from the address bar. CAUTION: that URL puts the token in the - `agent-browser` command line, which every local account can read while it runs. The variable - itself sits in your shell's environment after the `eval`, so every process you start from that - shell inherits the token. +- Open pages with `verify-xum.sh open <path>`, not `agent-browser open`. `make dev-server` starts + the backend with an auth token, and the first page of a browser session needs it as + `?token=<token>` (the app keeps it and strips it from the address bar). `open` builds that URL in + node from `server.lock` and hands it to `agent-browser batch` on stdin, so the token is in no + command line or environment. CAUTION: never put the token in an `agent-browser` argument. Every + local account can read a process's command line while it runs. - `idle` waits until the page settles: no CSS transition runs, and on a `/workspace/` page the `Conversation transcript` log is neither loading nor streaming (`aria-busy="false"`). A click while the right sidebar slides open lands where the target was and is lost. `idle` exits 3 diff --git a/.xum/skills/verify-xum/features/README.md b/.xum/skills/verify-xum/features/README.md index b6af58237bf..e2f78c46c87 100644 --- a/.xum/skills/verify-xum/features/README.md +++ b/.xum/skills/verify-xum/features/README.md @@ -22,8 +22,7 @@ feature file with the `verify-xum` harness. Update the file in the same change a - Start every recipe whose preconditions name `Verify playground` by opening it, even after another recipe: the workspaces and command-palette recipes end on `release-notes`, and the recipes read evidence from `$VERIFY_XUM_WORKSPACE_ID`. Open the app with - `agent-browser open "$VERIFY_XUM_URL/workspace/$VERIFY_XUM_WORKSPACE_ID$VERIFY_XUM_OPEN_QUERY"` - and run `verify-xum.sh idle`. A new browser session starts with the project collapsed, and the + `verify-xum.sh open "/workspace/$VERIFY_XUM_WORKSPACE_ID"` and run `verify-xum.sh idle`. A new browser session starts with the project collapsed, and the recipes choose workspace rows by name, so expand it when it is collapsed. This form fails only when the button is there and the click fails, so it is safe in an `&&` chain: @@ -32,9 +31,8 @@ feature file with the `verify-xum` harness. Update the file in the same change a || agent-browser find role button click --name "Expand project demo-app" --exact; } ``` -- `$VERIFY_XUM_OPEN_QUERY` (SKILL.md Drive) holds the backend's auth token when it has one. Add - it to the first `open` of a browser session, as above. Later opens in that session do not need - it. +- Open pages with `verify-xum.sh open <path>`. It adds the backend's auth token without putting + it on any command line (SKILL.md Drive). - Send a message in three steps: fill `Message`, run `verify-xum.sh send-ready`, then press Enter or choose `Send message`. - Use accessible names with `agent-browser find role <role> <action> --name "<name>" --exact`. diff --git a/.xum/skills/verify-xum/features/command-palette.md b/.xum/skills/verify-xum/features/command-palette.md index c6a0bfdd05a..fcdf49ffc6e 100644 --- a/.xum/skills/verify-xum/features/command-palette.md +++ b/.xum/skills/verify-xum/features/command-palette.md @@ -29,8 +29,10 @@ Preconditions: - **Switch workspace.** Press Ctrl+Shift+P, wait for the palette as above, then run `agent-browser keyboard type "release"` and `agent-browser wait '[role="option"][aria-selected="true"][aria-label="release-notes"]'`. - `Suggestions` lists the option `release-notes`, selected. Run `agent-browser press Enter`. The URL - is `/workspace/<release-notes id>`. Capture `command-palette/switched`. + `Suggestions` lists the option `release-notes`, selected. Run `agent-browser press Enter` and + `agent-browser wait --url "**/workspace/<release-notes id>"`: the palette closes first and + navigates a moment later. The URL is `/workspace/<release-notes id>`. Capture + `command-palette/switched`. - **Command mode.** Run `agent-browser press F4` and `agent-browser wait '[aria-label="Command palette"]'`. The combobox value is `>`, and `Suggestions` lists commands such as `Open Settings` and `Change Model…`. diff --git a/.xum/skills/verify-xum/features/review.md b/.xum/skills/verify-xum/features/review.md index d549b9f1436..6d45460633a 100644 --- a/.xum/skills/verify-xum/features/review.md +++ b/.xum/skills/verify-xum/features/review.md @@ -57,7 +57,14 @@ Preconditions: button `Expand right sidebar` is back. Run `agent-browser press Control+Shift+B` again. The button reads `Collapse right sidebar`, and the tab `Review 1/1 Close Review` is selected. - **Restore.** Run `agent-browser find role button click --name "Mark as read (M)" --exact`. The - tab reads `Review 0/1`, and `review-state.json` has no `"isRead": true`. Run + tab reads `Review 0/1` at once, and the app writes `review-state.json` a moment later. Wait for + it: + + ```bash + timeout 10 sh -c 'while grep -qF "\"isRead\": true" "$1"; do sleep 0.2; done' sh "$VERIFY_XUM_ROOT/sessions/$VERIFY_XUM_WORKSPACE_ID/review-state.json" + ``` + + `review-state.json` has no `"isRead": true`. Run `agent-browser find role checkbox click --name "Uncommitted:" --exact`. The box is `checked=false` at once. Wait until the server has written it: diff --git a/.xum/skills/verify-xum/features/settings.md b/.xum/skills/verify-xum/features/settings.md index 855d6ef2c72..72b8b634138 100644 --- a/.xum/skills/verify-xum/features/settings.md +++ b/.xum/skills/verify-xum/features/settings.md @@ -56,7 +56,7 @@ Preconditions: The sidebar has a `RECENT` group, and the workspace row is `Select workspace Verify playground (demo-app)`. - **Reload.** Run `agent-browser reload`, - `agent-browser open "$VERIFY_XUM_URL/settings/general$VERIFY_XUM_OPEN_QUERY"` and + `verify-xum.sh open /settings/general` and `agent-browser wait --text "Choose your preferred theme"`. The switch `Toggle flat chat list` is still `checked=true`. Capture `settings/persisted`. - **Restore.** Run @@ -64,7 +64,7 @@ Preconditions: `config.json` has `"sidebarFlatMode": false` (the same `timeout 10` loop with `false`), and run `agent-browser press Escape`. The page opened Settings by its URL, so Escape lands on `/project?...`. Run - `agent-browser open "$VERIFY_XUM_URL/workspace/$VERIFY_XUM_WORKSPACE_ID$VERIFY_XUM_OPEN_QUERY"` and + `verify-xum.sh open "/workspace/$VERIFY_XUM_WORKSPACE_ID"` and `verify-xum.sh idle` to be back on `Verify playground`. ## Gotchas diff --git a/.xum/skills/verify-xum/features/workspaces.md b/.xum/skills/verify-xum/features/workspaces.md index ecbd6c7613e..f9df26e3221 100644 --- a/.xum/skills/verify-xum/features/workspaces.md +++ b/.xum/skills/verify-xum/features/workspaces.md @@ -50,10 +50,12 @@ Preconditions: `/project?project=demo-app-<hash>&draft=<id>`. Create `scratch-notes` the same way as above, with the message `Collect scratch notes`, and run `verify-xum.sh idle` after its reply. - **Open from the sidebar.** Run - `agent-browser find role button click --name "Select workspace release-notes" --exact`. The URL - is `/workspace/<release-notes id>`. Run - `agent-browser find role button click --name "Select workspace Verify playground" --exact`. The - URL is `/workspace/$VERIFY_XUM_WORKSPACE_ID`. + `agent-browser find role button click --name "Select workspace release-notes" --exact` and + `agent-browser wait --url "**/workspace/<release-notes id>"`. The URL is + `/workspace/<release-notes id>`. Run + `agent-browser find role button click --name "Select workspace Verify playground" --exact` and + `agent-browser wait --url "**/workspace/$VERIFY_XUM_WORKSPACE_ID"`. The URL is + `/workspace/$VERIFY_XUM_WORKSPACE_ID`. - **Archive from the row.** Focus the row's archive icon and press Enter. Run `agent-browser wait '[aria-label="Archive workspace release-notes"]'`, `agent-browser focus '[aria-label="Archive workspace release-notes"]'` and @@ -63,8 +65,10 @@ Preconditions: The `Select workspace release-notes` row is gone, and in `$VERIFY_XUM_ROOT/config.json` the `release-notes` entry has an `archivedAt` time. Capture `workspaces/archived`. - **Archive with the keyboard.** Open `scratch-notes` from its row, run `verify-xum.sh idle`, and - press Ctrl+Shift+Backspace. Run `agent-browser press Control+Shift+Backspace`. The - `Select workspace scratch-notes` row disappears, and its `config.json` entry gets an + press Ctrl+Shift+Backspace. Run `agent-browser press Control+Shift+Backspace` and wait for the + archive with + `agent-browser wait --fn '!document.querySelector("[aria-label=\"Select workspace scratch-notes\"]")'`. + The `Select workspace scratch-notes` row is gone, and its `config.json` entry has an `archivedAt` time. - **Restore.** Open the project page and the archived list. Run `agent-browser find role button click --name "Create workspace in demo-app" --exact`, diff --git a/.xum/skills/verify-xum/scripts/verify-xum.sh b/.xum/skills/verify-xum/scripts/verify-xum.sh index a56e3d90942..b56ea206f96 100755 --- a/.xum/skills/verify-xum/scripts/verify-xum.sh +++ b/.xum/skills/verify-xum/scripts/verify-xum.sh @@ -4,7 +4,7 @@ # verify-xum.sh launch start and seed a sandbox, print its state # verify-xum.sh doctor [RUN] read-only health check of a run # verify-xum.sh env [RUN] print the run's `export` lines and put this script on PATH (eval them) -# verify-xum.sh open-query [RUN] print `?token=<token>` when the backend has one (env uses it) +# verify-xum.sh open PATH [RUN] open PATH of the run's UI in its browser session, token in no argv # verify-xum.sh capture NAME [RUN] screenshot + ARIA snapshot + URL into the run's evidence # verify-xum.sh idle [RUN] wait until the page settles, exit 3 if a chunk failed to load # verify-xum.sh send-ready [RUN] wait until the composer's Send button is enabled @@ -166,11 +166,11 @@ json_field_or_empty() { backend_ready() { node -e ' const fs = require("fs"); - const [serverLock, buildStamp, launchSeconds] = process.argv.slice(1); + const [serverLock, buildStamp, launchMs] = process.argv.slice(1); try { const buildStampMs = Number(fs.readFileSync(buildStamp, "utf8")); const serverStartedMs = Date.parse(JSON.parse(fs.readFileSync(serverLock, "utf8")).startedAt); - const builtSinceLaunch = buildStampMs >= Number(launchSeconds) * 1000; + const builtSinceLaunch = buildStampMs >= Number(launchMs); const serverRestartedAfterBuild = serverStartedMs > buildStampMs; process.exit(builtSinceLaunch && serverRestartedAfterBuild ? 0 : 1); } catch { @@ -271,7 +271,8 @@ cmd_launch() { mkdir -m 700 "$VERIFY_XUM_RUN" mkdir -p "$VERIFY_XUM_EVIDENCE" "$VERIFY_XUM_RUN/scratch" log="$VERIFY_XUM_RUN/server.log" - t0=$(date +%s) + # Milliseconds: a stamp from earlier in the launch's own second is a stale build. + t0=$(date +%s%3N) LAUNCH_DEADLINE=$((SECONDS + READY_TIMEOUT)) local env_without_live_xum=(env -i PATH="$PATH" HOME="$HOME" USER="${USER:-}" LANG="${LANG:-C.UTF-8}" @@ -379,17 +380,31 @@ cmd_env() { # The feature recipes call the helper by its bare name. $PATH expands when the caller evals. # shellcheck disable=SC2016 printf 'export PATH=%q:"$PATH"\n' "$SCRIPT_DIR" - # Read when the caller evals, so the token never shows in this output. - # shellcheck disable=SC2016 - printf 'export VERIFY_XUM_OPEN_QUERY="$(%q open-query %q)"\n' "$SCRIPT_DIR/verify-xum.sh" "$VERIFY_XUM_RUN" } -# `?token=<token>` for the first page a browser opens when the backend has a token, else nothing. -cmd_open_query() { - load_run "${1:-}" - local token - token=$(json_field_or_empty "$VERIFY_XUM_ROOT/server.lock" token) - [[ -z $token ]] || printf '?token=%s' "$(printf %s "$token" | node -p 'encodeURIComponent(require("fs").readFileSync(0, "utf8"))')" +# Opens PATH of the run's UI in the run's browser session. When the backend has an auth token, the +# URL carries `?token=` (the app keeps the token and strips it from the address bar). node reads +# the token from server.lock and pipes the command to `agent-browser batch` on stdin, so the token +# is in no process's argv or environment: every local account can read argv. +cmd_open() { + local path=${1:-} out + [[ $path == /* ]] || die "open PATH must start with / (for example /workspace/<id>)" + load_run "${2:-}" + # shellcheck disable=SC2001 # sed's character class; bash patterns have none + out=$(node -e ' + const fs = require("fs"); + const [serverLock, base, path] = process.argv.slice(1); + let token = ""; + try { + token = JSON.parse(fs.readFileSync(serverLock, "utf8")).token ?? ""; + } catch {} + const url = new URL(path, base); + if (token) url.searchParams.set("token", token); + process.stdout.write(JSON.stringify([["open", url.href]]));' \ + "$VERIFY_XUM_ROOT/server.lock" "$VERIFY_XUM_URL" "$path" \ + | agent-browser --session "$AGENT_BROWSER_SESSION" batch --bail 2>&1) \ + || die "agent-browser could not open $path: $(sed 's/token=[^&[:space:]]*/token=<redacted>/g' <<<"$out")" + # The success output repeats the URL with the token: it is not printed. } cmd_capture() { @@ -464,10 +479,10 @@ case ${1:-} in launch) cmd_launch ;; doctor) cmd_doctor "${2:-}" ;; env) cmd_env "${2:-}" ;; - open-query) cmd_open_query "${2:-}" ;; + open) cmd_open "${2:-}" "${3:-}" ;; capture) cmd_capture "${2:-}" "${3:-}" ;; idle) cmd_idle "${2:-}" ;; send-ready) cmd_send_ready "${2:-}" ;; cleanup) cmd_cleanup "${2:-}" ;; - *) die "usage: verify-xum.sh launch | doctor [RUN] | env [RUN] | open-query [RUN] | capture NAME [RUN] | idle [RUN] | send-ready [RUN] | cleanup [RUN]" ;; + *) die "usage: verify-xum.sh launch | doctor [RUN] | env [RUN] | open PATH [RUN] | capture NAME [RUN] | idle [RUN] | send-ready [RUN] | cleanup [RUN]" ;; esac From 2c1a71ff7b66e59d00f8a09066a87808a25591b2 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski <tk@coder.com> Date: Tue, 3 Nov 2026 19:02:05 +0000 Subject: [PATCH 23/51] =?UTF-8?q?=F0=9F=A4=96=20fix:=20verify-xum=20open?= =?UTF-8?q?=20refuses=20a=20path=20that=20leaves=20the=20run's=20origin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A path like //host, /\host or /<tab>/host resolves to another origin, and open would have sent the auth token there. Also rewrap the features README line that the open change made too long. --- _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ --- .xum/skills/verify-xum/features/README.md | 7 ++++--- .xum/skills/verify-xum/scripts/verify-xum.sh | 5 +++++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/.xum/skills/verify-xum/features/README.md b/.xum/skills/verify-xum/features/README.md index e2f78c46c87..36109e2eba1 100644 --- a/.xum/skills/verify-xum/features/README.md +++ b/.xum/skills/verify-xum/features/README.md @@ -22,9 +22,10 @@ feature file with the `verify-xum` harness. Update the file in the same change a - Start every recipe whose preconditions name `Verify playground` by opening it, even after another recipe: the workspaces and command-palette recipes end on `release-notes`, and the recipes read evidence from `$VERIFY_XUM_WORKSPACE_ID`. Open the app with - `verify-xum.sh open "/workspace/$VERIFY_XUM_WORKSPACE_ID"` and run `verify-xum.sh idle`. A new browser session starts with the project collapsed, and the - recipes choose workspace rows by name, so expand it when it is collapsed. This form fails only - when the button is there and the click fails, so it is safe in an `&&` chain: + `verify-xum.sh open "/workspace/$VERIFY_XUM_WORKSPACE_ID"` and run `verify-xum.sh idle`. A new + browser session starts with the project collapsed, and the recipes choose workspace rows by + name, so expand it when it is collapsed. This form fails only when the button is there and the + click fails, so it is safe in an `&&` chain: ```bash { [[ $(agent-browser eval '!!document.querySelector("[aria-label=\"Expand project demo-app\"]")') != true ]] \ diff --git a/.xum/skills/verify-xum/scripts/verify-xum.sh b/.xum/skills/verify-xum/scripts/verify-xum.sh index b56ea206f96..2269ce51b9c 100755 --- a/.xum/skills/verify-xum/scripts/verify-xum.sh +++ b/.xum/skills/verify-xum/scripts/verify-xum.sh @@ -399,6 +399,11 @@ cmd_open() { token = JSON.parse(fs.readFileSync(serverLock, "utf8")).token ?? ""; } catch {} const url = new URL(path, base); + // A PATH like //host or /\host resolves to another origin: never send the token there. + if (url.origin !== new URL(base).origin) { + process.stderr.write("verify-xum: open PATH must stay on " + base + "\n"); + process.exit(1); + } if (token) url.searchParams.set("token", token); process.stdout.write(JSON.stringify([["open", url.href]]));' \ "$VERIFY_XUM_ROOT/server.lock" "$VERIFY_XUM_URL" "$path" \ From 6eca3f2d0be0f1baa947069e862a8a40283bab4f Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski <tk@coder.com> Date: Tue, 3 Nov 2026 19:46:34 +0000 Subject: [PATCH 24/51] =?UTF-8?q?=F0=9F=A4=96=20fix:=20verify-xum=20review?= =?UTF-8?q?=20restore=20waits=20for=20the=20sidebar=20to=20settle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The restore click came right after Ctrl+Shift+B opened the sidebar again. A click during that slide is lost, and one no-token proof run kept the tab at Review 1/1. Run idle first, as Mark as read does. --- _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ --- .xum/skills/verify-xum/features/review.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.xum/skills/verify-xum/features/review.md b/.xum/skills/verify-xum/features/review.md index 6d45460633a..1c1c86b6cfa 100644 --- a/.xum/skills/verify-xum/features/review.md +++ b/.xum/skills/verify-xum/features/review.md @@ -56,8 +56,9 @@ Preconditions: - **Keyboard toggle.** Run `agent-browser press Control+Shift+B`. The sidebar collapses, and the button `Expand right sidebar` is back. Run `agent-browser press Control+Shift+B` again. The button reads `Collapse right sidebar`, and the tab `Review 1/1 Close Review` is selected. -- **Restore.** Run `agent-browser find role button click --name "Mark as read (M)" --exact`. The - tab reads `Review 0/1` at once, and the app writes `review-state.json` a moment later. Wait for +- **Restore.** The sidebar slid open again: run `verify-xum.sh idle`, then + `agent-browser find role button click --name "Mark as read (M)" --exact`. The tab reads + `Review 0/1` at once, and the app writes `review-state.json` a moment later. Wait for it: ```bash From eef9d3acd89d65d5c4edf0fd8222f907ecefdaad Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski <tk@coder.com> Date: Tue, 3 Nov 2026 20:16:34 +0000 Subject: [PATCH 25/51] =?UTF-8?q?=F0=9F=A4=96=20fix:=20verify-xum=20launch?= =?UTF-8?q?=20keeps=20its=20guard=20until=20the=20new=20group=20is=20visib?= =?UTF-8?q?le?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The launch deadline starts before the guard, and flock waits only for the time left, so a short VERIFY_XUM_READY_TIMEOUT bounds that wait. - The spawned group holds the launch flock until it has written its PGID, and carries VERIFY_XUM_SERVER_OF from its start. A launcher killed before that record no longer lets a second launch start a second server in the same checkout. - Parents of a new run home are created with umask 077. - New helper: verify-xum.sh workspace-field NAME FIELD [RUN] prints a field of a workspace's config.json entry. --- _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ --- .xum/skills/verify-xum/SKILL.md | 9 ++-- .xum/skills/verify-xum/scripts/verify-xum.sh | 45 ++++++++++++++++---- 2 files changed, 41 insertions(+), 13 deletions(-) diff --git a/.xum/skills/verify-xum/SKILL.md b/.xum/skills/verify-xum/SKILL.md index 7c04f90b47d..152d8f1414e 100644 --- a/.xum/skills/verify-xum/SKILL.md +++ b/.xum/skills/verify-xum/SKILL.md @@ -62,7 +62,8 @@ server log tail and cleans up. Launch refuses while the process group of this ch run is alive, by the same identity check as cleanup: two dev servers in one checkout share `dist/` and `node_modules/.vite`. The group's first process writes the group id into the run's `state.env` before it starts the server. From that moment, right after the spawn, the check and -cleanup find the group even when the launcher was killed. Launch also stops at once when the +cleanup find the group even when the launcher was killed. Until then the group holds the launch +lock, so a second launch waits for it. Launch also stops at once when the sandbox copied a `config.json` (the recipes assume default preferences) and when the backend crashes after its rebuild (nodemon then waits for a file change that never comes). @@ -178,9 +179,9 @@ then SIGKILL after 20 s), deletes the sandbox XUM_ROOT and the demo repo, and pr path and file count. It refuses a run that another checkout launched. It signals the group only while one of its processes carries the run's id and a live group leader is still the launch's `make dev-server-sandbox`, so it kills nothing it did not start. Then it blanks the group id in -the run's `state.env`. Run it after every run, failed ones too. `doctor`, `env`, `capture`, -`idle`, `send-ready` and `cleanup` take an optional run directory. Without one they use the newest -launch from this checkout. +the run's `state.env`. Run it after every run, failed ones too. `doctor`, `env`, `open`, +`workspace-field`, `capture`, `idle`, `send-ready` and `cleanup` take an optional run directory +after their other arguments. Without one they use the newest launch from this checkout. Limit: if the launcher dies in the moment after the sandbox logs its `XUM_ROOT` and before launch records it (up to 0.5 s), and a server process then ignores SIGTERM, cleanup must SIGKILL the diff --git a/.xum/skills/verify-xum/scripts/verify-xum.sh b/.xum/skills/verify-xum/scripts/verify-xum.sh index 2269ce51b9c..301247fffda 100755 --- a/.xum/skills/verify-xum/scripts/verify-xum.sh +++ b/.xum/skills/verify-xum/scripts/verify-xum.sh @@ -5,6 +5,7 @@ # verify-xum.sh doctor [RUN] read-only health check of a run # verify-xum.sh env [RUN] print the run's `export` lines and put this script on PATH (eval them) # verify-xum.sh open PATH [RUN] open PATH of the run's UI in its browser session, token in no argv +# verify-xum.sh workspace-field NAME FIELD [RUN] print FIELD of workspace NAME from config.json # verify-xum.sh capture NAME [RUN] screenshot + ARIA snapshot + URL into the run's evidence # verify-xum.sh idle [RUN] wait until the page settles, exit 3 if a chunk failed to load # verify-xum.sh send-ready [RUN] wait until the composer's Send button is enabled @@ -87,12 +88,13 @@ private_run_dir() { # One server per checkout: two would share dist/ and node_modules/.vite. The guard is the newest # run's process group, which records its own id in that run's state.env before it starts the -# server. Once that id is written, a moment after the spawn, the guard holds wherever the launcher -# dies. A launcher killed inside that moment can leave a group that the guard does not see. The -# flock only keeps two launches from passing the check at once. The server does not inherit it. +# server. The flock keeps two launches from passing the check at once, and it also covers the +# moment between the spawn and that record: the new group holds the flock until it has written +# its id, wherever the launcher dies. The server does not inherit it. refuse_while_checkout_server_runs() { exec 9>"$LAUNCH_GUARD" - flock -w 30 9 || die "another verify-xum launch from this checkout holds $LAUNCH_GUARD" + # Part of the launch deadline: a short VERIFY_XUM_READY_TIMEOUT also bounds this wait. + flock -w "$(launch_seconds_left)" 9 || die "another verify-xum launch from this checkout holds $LAUNCH_GUARD" [[ -f $CURRENT_RUN_LINK/state.env ]] || return 0 local run run=$(private_run_dir "$CURRENT_RUN_LINK") || exit 1 @@ -254,6 +256,7 @@ cmd_launch() { local t0 backend_port vite_port log need git curl node bun make setsid flock ss agent-browser check_bun + LAUNCH_DEADLINE=$((SECONDS + READY_TIMEOUT)) refuse_while_checkout_server_runs VERIFY_XUM_RUN_ID="$(date -u +%Y%m%d-%H%M%S)-$$" @@ -264,7 +267,8 @@ cmd_launch() { VERIFY_XUM_COMMIT=$(git -C "$CHECKOUT" rev-parse HEAD) AGENT_BROWSER_SESSION="verify-xum-$VERIFY_XUM_RUN_ID" if [[ ! -e $VERIFY_XUM_HOME ]]; then - mkdir -p "$(dirname "$VERIFY_XUM_HOME")" + # Parents this creates are private too, whatever the caller's umask. + (umask 077 && mkdir -p "$(dirname "$VERIFY_XUM_HOME")") mkdir -m 700 "$VERIFY_XUM_HOME" fi require_private_dir "$VERIFY_XUM_HOME" @@ -273,7 +277,6 @@ cmd_launch() { log="$VERIFY_XUM_RUN/server.log" # Milliseconds: a stamp from earlier in the launch's own second is a stale build. t0=$(date +%s%3N) - LAUNCH_DEADLINE=$((SECONDS + READY_TIMEOUT)) local env_without_live_xum=(env -i PATH="$PATH" HOME="$HOME" USER="${USER:-}" LANG="${LANG:-C.UTF-8}" TERM=dumb TMPDIR="${TMPDIR:-/tmp}" XUM_MOCK_AI=1 XUM_DISABLE_TELEMETRY=1 @@ -286,12 +289,14 @@ cmd_launch() { trap launch_failed EXIT # The new group's leader appends its pid, which is the group id, to state.env before it starts # make. From then on the launch guard and cleanup find the group, wherever the launcher dies. + # The leader carries VERIFY_XUM_SERVER_OF from its start, so group_is_ours matches it as soon + # as the id is written. It holds the launch flock (fd 9) until then and closes it before make. # (setsid(1) forks when its caller already leads a process group, so $! can miss the group.) # shellcheck disable=SC2016 - setsid sh -c 'printf "export VERIFY_XUM_PGID=%s\n" "$$" >>"$1"; shift; exec "$@"' sh \ + VERIFY_XUM_SERVER_OF="$VERIFY_XUM_RUN_ID" setsid sh -c 'printf "export VERIFY_XUM_PGID=%s\n" "$$" >>"$1"; exec 9>&-; shift; exec "$@"' sh \ "$VERIFY_XUM_RUN/state.env" "${env_without_live_xum[@]}" \ make -C "$CHECKOUT" dev-server-sandbox DEV_SERVER_SANDBOX_ARGS="--clean-providers --clean-projects" \ - >"$log" 2>&1 </dev/null 9>&- & + >"$log" 2>&1 </dev/null & until VERIFY_XUM_PGID=$(sed -n 's/^export VERIFY_XUM_PGID=\([0-9][0-9]*\)$/\1/p' "$VERIFY_XUM_RUN/state.env" | tail -n 1) [[ -n $VERIFY_XUM_PGID ]]; do before_launch_deadline @@ -412,6 +417,27 @@ cmd_open() { # The success output repeats the URL with the token: it is not printed. } +# Prints FIELD (id, archivedAt, ...) of the workspace named NAME in the run's config.json, or +# nothing when that field is unset. Exits 1 unless exactly one workspace has that name. The +# recipes read ids with it: the app makes them up at create time. +cmd_workspace_field() { + local name=${1:-} field=${2:-} + [[ -n $name && -n $field ]] || die "usage: verify-xum.sh workspace-field NAME FIELD [RUN]" + load_run "${3:-}" + node -e ' + const fs = require("fs"); + const [configPath, name, field] = process.argv.slice(1); + const config = JSON.parse(fs.readFileSync(configPath, "utf8")); + const matches = config.projects.flatMap(([, project]) => project.workspaces ?? []).filter((w) => w.name === name); + if (matches.length !== 1) { + process.stderr.write("verify-xum: " + matches.length + " workspaces are named " + name + " in " + configPath + "\n"); + process.exit(1); + } + const value = matches[0][field]; + if (value !== undefined && value !== null) console.log(String(value));' \ + "$VERIFY_XUM_ROOT/config.json" "$name" "$field" +} + cmd_capture() { local name=${1:-} base [[ $name =~ ^[a-z0-9][a-z0-9._-]*(/[a-z0-9][a-z0-9._-]*)*$ ]] || die "capture NAME must look like <feature>/<step>, lowercase" @@ -485,9 +511,10 @@ case ${1:-} in doctor) cmd_doctor "${2:-}" ;; env) cmd_env "${2:-}" ;; open) cmd_open "${2:-}" "${3:-}" ;; + workspace-field) cmd_workspace_field "${2:-}" "${3:-}" "${4:-}" ;; capture) cmd_capture "${2:-}" "${3:-}" ;; idle) cmd_idle "${2:-}" ;; send-ready) cmd_send_ready "${2:-}" ;; cleanup) cmd_cleanup "${2:-}" ;; - *) die "usage: verify-xum.sh launch | doctor [RUN] | env [RUN] | open PATH [RUN] | capture NAME [RUN] | idle [RUN] | send-ready [RUN] | cleanup [RUN]" ;; + *) die "usage: verify-xum.sh launch | doctor [RUN] | env [RUN] | open PATH [RUN] | workspace-field NAME FIELD [RUN] | capture NAME [RUN] | idle [RUN] | send-ready [RUN] | cleanup [RUN]" ;; esac From 71411b95bc66e2aaba3db6a62fe6c163d5cdfc40 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski <tk@coder.com> Date: Tue, 3 Nov 2026 20:16:35 +0000 Subject: [PATCH 26/51] =?UTF-8?q?=F0=9F=A4=96=20fix:=20verify-xum=20recipe?= =?UTF-8?q?s=20read=20workspace=20ids=20and=20wait=20for=20slow=20writes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The workspace and command palette waits used a literal <release-notes id>. They read the id with verify-xum.sh workspace-field release-notes id. - The archive steps wait until config.json has the archivedAt time. - The palette's first open waits until Suggestions lists an option. - Mark as read waits for the hunk's button, which shows late after a reload. --- _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ --- .xum/skills/verify-xum/features/README.md | 4 +++ .../verify-xum/features/command-palette.md | 18 +++++++----- .xum/skills/verify-xum/features/review.md | 7 +++-- .xum/skills/verify-xum/features/workspaces.md | 28 +++++++++++++------ 4 files changed, 38 insertions(+), 19 deletions(-) diff --git a/.xum/skills/verify-xum/features/README.md b/.xum/skills/verify-xum/features/README.md index 36109e2eba1..f27dd13b5b6 100644 --- a/.xum/skills/verify-xum/features/README.md +++ b/.xum/skills/verify-xum/features/README.md @@ -34,6 +34,10 @@ feature file with the `verify-xum` harness. Update the file in the same change a - Open pages with `verify-xum.sh open <path>`. It adds the backend's auth token without putting it on any command line (SKILL.md Drive). +- Read a workspace's id, or another field of its `config.json` entry, with + `verify-xum.sh workspace-field <name> <field>`, for example + `RN_ID=$(verify-xum.sh workspace-field release-notes id)`. The app makes up ids at create time, + and a shell variable is gone in the next bash call, so read it in each call that needs it. - Send a message in three steps: fill `Message`, run `verify-xum.sh send-ready`, then press Enter or choose `Send message`. - Use accessible names with `agent-browser find role <role> <action> --name "<name>" --exact`. diff --git a/.xum/skills/verify-xum/features/command-palette.md b/.xum/skills/verify-xum/features/command-palette.md index fcdf49ffc6e..80592a98c09 100644 --- a/.xum/skills/verify-xum/features/command-palette.md +++ b/.xum/skills/verify-xum/features/command-palette.md @@ -22,16 +22,20 @@ Preconditions: [workspaces](./workspaces.md) recipe, or skip `palette-switch` and report it). - The app is open on `Verify playground`, and `verify-xum.sh idle` returned. -- **Open and close.** Run `agent-browser press Control+Shift+P` and - `agent-browser wait '[aria-label="Command palette"]'`. The combobox `Command palette` is - expanded, with the listbox `Suggestions`. Run `agent-browser press Escape`. The combobox is - gone. -- **Switch workspace.** Press Ctrl+Shift+P, wait for the palette as above, then run +- **Open and close.** Run `agent-browser press Control+Shift+P`, + `agent-browser wait '[aria-label="Command palette"]'` and + `agent-browser wait '[role="listbox"][aria-label="Suggestions"] [role="option"]'`: on the first + open of a page the list fills a moment after the combobox shows. The combobox + `Command palette` is expanded, with the listbox `Suggestions`. Run `agent-browser press Escape`. + The combobox is gone. +- **Switch workspace.** Read the id with `RN_ID=$(verify-xum.sh workspace-field release-notes id)` + (see [workspaces](./workspaces.md) **Open from the sidebar**). Press Ctrl+Shift+P, wait for the + palette as above, then run `agent-browser keyboard type "release"` and `agent-browser wait '[role="option"][aria-selected="true"][aria-label="release-notes"]'`. `Suggestions` lists the option `release-notes`, selected. Run `agent-browser press Enter` and - `agent-browser wait --url "**/workspace/<release-notes id>"`: the palette closes first and - navigates a moment later. The URL is `/workspace/<release-notes id>`. Capture + `agent-browser wait --url "**/workspace/$RN_ID"`: the palette closes first and navigates a + moment later. The URL is `/workspace/$RN_ID`. Capture `command-palette/switched`. - **Command mode.** Run `agent-browser press F4` and `agent-browser wait '[aria-label="Command palette"]'`. The combobox value is `>`, and diff --git a/.xum/skills/verify-xum/features/review.md b/.xum/skills/verify-xum/features/review.md index 1c1c86b6cfa..ad26089bc26 100644 --- a/.xum/skills/verify-xum/features/review.md +++ b/.xum/skills/verify-xum/features/review.md @@ -48,9 +48,10 @@ Preconditions: `agent-browser wait --text "math.ts"`. The file tree lists `src` and `math.ts` with `+3`, the hunk shows the three added lines of `sub`, and the tab reads `Review 0/1`. Capture `review/diff`. -- **Mark as read.** Run `verify-xum.sh idle`, then - `agent-browser find role button click --name "Mark as read (M)" --exact`. The tab reads - `Review 1/1`, and the hunk reads `Hunk marked as read`. +- **Mark as read.** Run `verify-xum.sh idle` and + `agent-browser wait '[aria-label="Mark as read (M)"]'` (after a reload the hunk's button shows + late), then `agent-browser find role button click --name "Mark as read (M)" --exact`. The tab + reads `Review 1/1`, and the hunk reads `Hunk marked as read`. `$VERIFY_XUM_ROOT/sessions/$VERIFY_XUM_WORKSPACE_ID/review-state.json` has `"isRead": true` for that hunk within a second. Capture `review/read`. - **Keyboard toggle.** Run `agent-browser press Control+Shift+B`. The sidebar collapses, and the diff --git a/.xum/skills/verify-xum/features/workspaces.md b/.xum/skills/verify-xum/features/workspaces.md index f9df26e3221..bd26ec0fdd8 100644 --- a/.xum/skills/verify-xum/features/workspaces.md +++ b/.xum/skills/verify-xum/features/workspaces.md @@ -49,10 +49,11 @@ Preconditions: `agent-browser wait '[aria-label="Workspace name"]'`. The URL changes to `/project?project=demo-app-<hash>&draft=<id>`. Create `scratch-notes` the same way as above, with the message `Collect scratch notes`, and run `verify-xum.sh idle` after its reply. -- **Open from the sidebar.** Run +- **Open from the sidebar.** The app made up the workspace id at create time. Read it from + `config.json` in each bash call that needs it: + `RN_ID=$(verify-xum.sh workspace-field release-notes id)`. Run `agent-browser find role button click --name "Select workspace release-notes" --exact` and - `agent-browser wait --url "**/workspace/<release-notes id>"`. The URL is - `/workspace/<release-notes id>`. Run + `agent-browser wait --url "**/workspace/$RN_ID"`. The URL is `/workspace/$RN_ID`. Run `agent-browser find role button click --name "Select workspace Verify playground" --exact` and `agent-browser wait --url "**/workspace/$VERIFY_XUM_WORKSPACE_ID"`. The URL is `/workspace/$VERIFY_XUM_WORKSPACE_ID`. @@ -62,14 +63,22 @@ Preconditions: `agent-browser press Enter`. The archive request is still running when Enter returns. Wait for its result with `agent-browser wait --fn '!document.querySelector("[aria-label=\"Select workspace release-notes\"]")'`. - The `Select workspace release-notes` row is gone, and in `$VERIFY_XUM_ROOT/config.json` the - `release-notes` entry has an `archivedAt` time. Capture `workspaces/archived`. + The `Select workspace release-notes` row is gone. Wait until the server has written the + archive: + + ```bash + timeout 10 sh -c 'until [ -n "$(verify-xum.sh workspace-field release-notes archivedAt)" ]; do sleep 0.2; done' + ``` + + In `$VERIFY_XUM_ROOT/config.json` the `release-notes` entry has an `archivedAt` time. Capture + `workspaces/archived`. + - **Archive with the keyboard.** Open `scratch-notes` from its row, run `verify-xum.sh idle`, and press Ctrl+Shift+Backspace. Run `agent-browser press Control+Shift+Backspace` and wait for the archive with `agent-browser wait --fn '!document.querySelector("[aria-label=\"Select workspace scratch-notes\"]")'`. - The `Select workspace scratch-notes` row is gone, and its `config.json` entry has an - `archivedAt` time. + The `Select workspace scratch-notes` row is gone. Wait for its `archivedAt` time with the same + `timeout 10` loop for `scratch-notes`. - **Restore.** Open the project page and the archived list. Run `agent-browser find role button click --name "Create workspace in demo-app" --exact`, `agent-browser wait '[aria-label="Expand archived workspaces"]'`, @@ -77,9 +86,10 @@ Preconditions: `agent-browser wait '[aria-label="Restore workspace release-notes"]'`. The region `Archived workspaces` lists `release-notes` and `scratch-notes`. Run `agent-browser find role button click --name "Restore workspace release-notes" --exact`, then - wait for the result with `agent-browser wait --url "**/workspace/<release-notes id>"` and + wait for the result with `agent-browser wait --url "**/workspace/$RN_ID"` (`RN_ID` as in + **Open from the sidebar**) and `agent-browser wait '[aria-label="Select workspace release-notes"]'`. The app is on - `/workspace/<release-notes id>`, the sidebar row is back, and `config.json` has an + `/workspace/$RN_ID`, the sidebar row is back, and `config.json` has an `unarchivedAt` time for it. Capture `workspaces/restored`. - **CLI view.** List the workspaces through the CLI: From 76a7bcd4e682fe0da6d44b03dd9ff73ac929c196 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski <tk@coder.com> Date: Tue, 3 Nov 2026 20:34:06 +0000 Subject: [PATCH 27/51] =?UTF-8?q?=F0=9F=A4=96=20fix:=20verify-xum=20clears?= =?UTF-8?q?=20git's=20repository=20variables=20before=20it=20touches=20the?= =?UTF-8?q?=20demo=20repo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An exported GIT_INDEX_FILE, GIT_DIR or GIT_CONFIG_* made the demo repo's git commands act on the caller's repository: git add . replaced its index. The script now unsets the variables of git rev-parse --local-env-vars plus the discovery and config ones, and the env output unsets them too, so the recipes' git commands act on the demo repo. --- _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ --- .xum/skills/verify-xum/SKILL.md | 4 +++- .xum/skills/verify-xum/scripts/verify-xum.sh | 21 +++++++++++++++++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/.xum/skills/verify-xum/SKILL.md b/.xum/skills/verify-xum/SKILL.md index 152d8f1414e..4d7301727d1 100644 --- a/.xum/skills/verify-xum/SKILL.md +++ b/.xum/skills/verify-xum/SKILL.md @@ -86,7 +86,9 @@ Exit 1 means do not drive this instance: run cleanup, then launch again. ## Drive Every bash call starts by loading the run. This sets `AGENT_BROWSER_SESSION` (a session name only -this run uses) and the `VERIFY_XUM_*` paths and URLs: +this run uses) and the `VERIFY_XUM_*` paths and URLs. It also unsets the Git variables that point +git at another repository (`GIT_DIR`, `GIT_INDEX_FILE` and the rest of +`git rev-parse --local-env-vars`), so the recipes' git commands act on the demo repo: ```bash eval "$(.xum/skills/verify-xum/scripts/verify-xum.sh env)" diff --git a/.xum/skills/verify-xum/scripts/verify-xum.sh b/.xum/skills/verify-xum/scripts/verify-xum.sh index 301247fffda..5f0cc228097 100755 --- a/.xum/skills/verify-xum/scripts/verify-xum.sh +++ b/.xum/skills/verify-xum/scripts/verify-xum.sh @@ -3,7 +3,8 @@ # # verify-xum.sh launch start and seed a sandbox, print its state # verify-xum.sh doctor [RUN] read-only health check of a run -# verify-xum.sh env [RUN] print the run's `export` lines and put this script on PATH (eval them) +# verify-xum.sh env [RUN] print the run's `export` lines, put this script on PATH, clear git's +# repo variables (eval them) # verify-xum.sh open PATH [RUN] open PATH of the run's UI in its browser session, token in no argv # verify-xum.sh workspace-field NAME FIELD [RUN] print FIELD of workspace NAME from config.json # verify-xum.sh capture NAME [RUN] screenshot + ARIA snapshot + URL into the run's evidence @@ -14,6 +15,23 @@ # RUN defaults to the newest launch from this checkout. set -euo pipefail +# Git variables that point git at another repository (a hook's GIT_INDEX_FILE, an exported +# GIT_DIR, config from GIT_CONFIG_*) would make the checkout lookups and the demo repo's +# `git -C` commands below act on that repository: `git add .` would replace its index. This +# script clears them, and the `env` output clears them for the recipes' git commands too. The +# list is `git rev-parse --local-env-vars` plus the discovery and config variables. +# shellcheck disable=SC2046,SC2086 # lists of variable names +{ + GIT_REPO_ENV="GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_OBJECT_DIRECTORY + GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_COMMON_DIR GIT_IMPLICIT_WORK_TREE GIT_GRAFT_FILE + GIT_NO_REPLACE_OBJECTS GIT_REPLACE_REF_BASE GIT_SHALLOW_FILE GIT_PREFIX GIT_NAMESPACE + GIT_CEILING_DIRECTORIES GIT_DISCOVERY_ACROSS_FILESYSTEM ${!GIT_CONFIG*}" + unset $GIT_REPO_ENV + # Names a newer git adds to its own list. + GIT_REPO_ENV=$(printf '%s\n' $GIT_REPO_ENV $(git rev-parse --local-env-vars) | sort -u | tr '\n' ' ') + unset $GIT_REPO_ENV +} + SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) CHECKOUT=$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel) # make -C runs the sandbox from the checkout, so a relative TMPDIR would resolve there. @@ -385,6 +403,7 @@ cmd_env() { # The feature recipes call the helper by its bare name. $PATH expands when the caller evals. # shellcheck disable=SC2016 printf 'export PATH=%q:"$PATH"\n' "$SCRIPT_DIR" + echo "unset $GIT_REPO_ENV" } # Opens PATH of the run's UI in the run's browser session. When the backend has an auth token, the From 43b508877b202be4235e8143cbc06fbd14a609e7 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski <tk@coder.com> Date: Tue, 3 Nov 2026 20:34:18 +0000 Subject: [PATCH 28/51] =?UTF-8?q?=F0=9F=A4=96=20fix:=20verify-xum=20checks?= =?UTF-8?q?=20every=20folder=20above=20the=20run=20home=20before=20it=20so?= =?UTF-8?q?urces=20state.env?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Every folder above the home must be owned by root or this user and writable by nobody else unless sticky (like /tmp), as ssh's StrictModes checks. Otherwise another account could rename the checked home away and put its own state.env in its place. - The home is resolved to its physical path once, and state.env is read from that path, so no symlink above the home is followed again. - Trailing slashes are all stripped: a symlinked run folder given as run// passed the symlink check. - Launch checks the folders above a new home before it creates it. --- _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ --- .xum/skills/verify-xum/scripts/verify-xum.sh | 55 +++++++++++++++++--- 1 file changed, 47 insertions(+), 8 deletions(-) diff --git a/.xum/skills/verify-xum/scripts/verify-xum.sh b/.xum/skills/verify-xum/scripts/verify-xum.sh index 5f0cc228097..67844115302 100755 --- a/.xum/skills/verify-xum/scripts/verify-xum.sh +++ b/.xum/skills/verify-xum/scripts/verify-xum.sh @@ -94,12 +94,49 @@ require_private_dir() { (((8#$mode & 8#022) == 0)) || die "$dir is writable by group or others (mode $mode)" } +# Every folder above a run home must be owned by root or this user and writable by nobody else, +# unless it is sticky like /tmp (as ssh's StrictModes checks): another account could otherwise +# rename the checked home away and put its own state.env in its place. Takes a physical path. +require_safe_ancestors() { + local dir=$1 owner mode + while [[ $dir != / ]]; do + dir=$(dirname "$dir") + owner=$(stat -c %u "$dir") + mode=$(stat -c %a "$dir") + [[ $owner == 0 || $owner == "$(id -u)" ]] || die "$dir, above the run home, is owned by user $owner" + (((8#$mode & 8#022) == 0 || (8#$mode & 8#1000) != 0)) \ + || die "$dir, above the run home, is writable by group or others and not sticky (mode $mode)" + done +} + +# A path without its trailing slashes: `-L dir/` follows the link, so `link//` would pass as a +# real folder. +without_trailing_slashes() { + local path=$1 + while [[ $path == ?*/ ]]; do path=${path%/}; done + echo "$path" +} + +# The physical path of a run home, after checking it and every folder above it. Later reads use +# that path, so no symlink above the home is followed again. +checked_home() { + local home + home=$(without_trailing_slashes "$1") + [[ -d $home && ! -L $home ]] || die "$home is not a real folder" + home=$(cd -P -- "$home" && pwd -P) + require_private_dir "$home" + require_safe_ancestors "$home" + echo "$home" +} + # The run folder of a run argument, or behind the newest-run link (the one symlink allowed), # checked before its state.env is read. private_run_dir() { - local run=${1%/} + local run home + run=$(without_trailing_slashes "$1") [[ $run == "$CURRENT_RUN_LINK" ]] && run=$(readlink "$run") - require_private_dir "$(dirname "$run")" + home=$(checked_home "$(dirname "$run")") || exit 1 + run="$home/$(basename "$run")" require_private_dir "$run" echo "$run" } @@ -277,6 +314,14 @@ cmd_launch() { LAUNCH_DEADLINE=$((SECONDS + READY_TIMEOUT)) refuse_while_checkout_server_runs + if [[ ! -e $VERIFY_XUM_HOME ]]; then + # Parents this creates are private too, whatever the caller's umask. + (umask 077 && mkdir -p "$(dirname "$VERIFY_XUM_HOME")") + # Check the folders above before the home is made in them (the home itself does not exist yet). + require_safe_ancestors "$(cd -P -- "$(dirname "$VERIFY_XUM_HOME")" && pwd -P)/home" + mkdir -m 700 "$VERIFY_XUM_HOME" + fi + VERIFY_XUM_HOME=$(checked_home "$VERIFY_XUM_HOME") || exit 1 VERIFY_XUM_RUN_ID="$(date -u +%Y%m%d-%H%M%S)-$$" VERIFY_XUM_RUN="$VERIFY_XUM_HOME/$VERIFY_XUM_RUN_ID" VERIFY_XUM_EVIDENCE="$VERIFY_XUM_RUN/evidence" @@ -284,12 +329,6 @@ cmd_launch() { VERIFY_XUM_CHECKOUT=$CHECKOUT VERIFY_XUM_COMMIT=$(git -C "$CHECKOUT" rev-parse HEAD) AGENT_BROWSER_SESSION="verify-xum-$VERIFY_XUM_RUN_ID" - if [[ ! -e $VERIFY_XUM_HOME ]]; then - # Parents this creates are private too, whatever the caller's umask. - (umask 077 && mkdir -p "$(dirname "$VERIFY_XUM_HOME")") - mkdir -m 700 "$VERIFY_XUM_HOME" - fi - require_private_dir "$VERIFY_XUM_HOME" mkdir -m 700 "$VERIFY_XUM_RUN" mkdir -p "$VERIFY_XUM_EVIDENCE" "$VERIFY_XUM_RUN/scratch" log="$VERIFY_XUM_RUN/server.log" From 73f97cd33364a6e24acf50b79a8cf6d4735dfd75 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski <tk@coder.com> Date: Tue, 3 Nov 2026 20:34:19 +0000 Subject: [PATCH 29/51] =?UTF-8?q?=F0=9F=A4=96=20fix:=20verify-xum=20open?= =?UTF-8?q?=20refuses=20//-normalized=20paths=20before=20agent-browser=20r?= =?UTF-8?q?uns?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A path that normalizes to //... (/..//evil.example, /.%2e//evil.example) stays on the run's origin, but the app cannot strip ?token= from that address, so the token stayed in it. open now refuses it. - The check runs before agent-browser, so a refused open prints one line instead of a second agent-browser error. - SKILL.md says the backend always has an auth token: launch runs make under env -i, which drops XUM_DEV_SERVER_NO_AUTH. --- _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ --- .xum/skills/verify-xum/SKILL.md | 6 ++++-- .xum/skills/verify-xum/scripts/verify-xum.sh | 15 ++++++++++----- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/.xum/skills/verify-xum/SKILL.md b/.xum/skills/verify-xum/SKILL.md index 4d7301727d1..98c6261f017 100644 --- a/.xum/skills/verify-xum/SKILL.md +++ b/.xum/skills/verify-xum/SKILL.md @@ -79,8 +79,10 @@ Every check line must read `ok`. A `note` line only informs. It checks that the lives and carries the run's id (`VERIFY_XUM_SERVER_OF` in its env), the checkout's newest-run link names the run, the backend pid in the sandbox `server.lock` and the app port belong to the group, `/health` answers, `/version` reports the launch commit, and the backend env has `XUM_MOCK_AI=1`. -When the backend has an auth token, doctor and launch read it from `server.lock` and send it to -curl on stdin, never in argv. +The backend always has an auth token: launch runs make under `env -i`, which drops +`XUM_DEV_SERVER_NO_AUTH`. (A checkout from before #7904 starts it without one, and the skill works +the same.) Doctor and launch read the token from `server.lock` and send it to curl on stdin, never +in argv. Exit 1 means do not drive this instance: run cleanup, then launch again. ## Drive diff --git a/.xum/skills/verify-xum/scripts/verify-xum.sh b/.xum/skills/verify-xum/scripts/verify-xum.sh index 67844115302..3444b850bdc 100755 --- a/.xum/skills/verify-xum/scripts/verify-xum.sh +++ b/.xum/skills/verify-xum/scripts/verify-xum.sh @@ -453,6 +453,16 @@ cmd_open() { local path=${1:-} out [[ $path == /* ]] || die "open PATH must start with / (for example /workspace/<id>)" load_run "${2:-}" + # Checked before agent-browser runs. A PATH like //host, /\host or /<tab>/host resolves to + # another origin: never send the token there. One that normalizes to //... (/..//host) stays on + # the origin, but the app cannot strip ?token= from such an address, so the token stays in it. + node -e ' + const [base, path] = process.argv.slice(1); + const url = new URL(path, base); + if (url.origin !== new URL(base).origin || url.pathname.startsWith("//")) { + process.stderr.write("verify-xum: open PATH must stay on " + base + " and must not start with // once normalized\n"); + process.exit(1); + }' "$VERIFY_XUM_URL" "$path" || exit 1 # shellcheck disable=SC2001 # sed's character class; bash patterns have none out=$(node -e ' const fs = require("fs"); @@ -462,11 +472,6 @@ cmd_open() { token = JSON.parse(fs.readFileSync(serverLock, "utf8")).token ?? ""; } catch {} const url = new URL(path, base); - // A PATH like //host or /\host resolves to another origin: never send the token there. - if (url.origin !== new URL(base).origin) { - process.stderr.write("verify-xum: open PATH must stay on " + base + "\n"); - process.exit(1); - } if (token) url.searchParams.set("token", token); process.stdout.write(JSON.stringify([["open", url.href]]));' \ "$VERIFY_XUM_ROOT/server.lock" "$VERIFY_XUM_URL" "$path" \ From 876479ce9997268ded3f4052518f24114b15b078 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski <tk@coder.com> Date: Tue, 3 Nov 2026 21:10:45 +0000 Subject: [PATCH 30/51] =?UTF-8?q?=F0=9F=A4=96=20fix:=20verify-xum=20gives?= =?UTF-8?q?=20the=20server=20tree=20a=20private=20TMPDIR=20and=20ignores?= =?UTF-8?q?=20CDPATH?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The sandbox makes its XUM_ROOT in TMPDIR, and the backend writes server.lock, with the token, into it. A shared TMPDIR let another account swap that folder before the backend wrote. The server tree's TMPDIR is now <run>/tmp (mode 0700), and cleanup deletes it, so an XUM_ROOT the launch never recorded is deleted too. - With CDPATH set, the cd that finds the script folder could print a folder name into SCRIPT_DIR. The script unsets CDPATH first. --- _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ --- .xum/skills/verify-xum/SKILL.md | 10 +++------- .xum/skills/verify-xum/scripts/verify-xum.sh | 14 ++++++++++---- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/.xum/skills/verify-xum/SKILL.md b/.xum/skills/verify-xum/SKILL.md index 98c6261f017..f4b16e23545 100644 --- a/.xum/skills/verify-xum/SKILL.md +++ b/.xum/skills/verify-xum/SKILL.md @@ -25,7 +25,7 @@ Surfaces: keys stay out. ```bash - env -i PATH="$PATH" HOME="$HOME" TMPDIR="${TMPDIR:-/tmp}" XUM_MOCK_AI=1 XUM_DISABLE_TELEMETRY=1 \ + env -i PATH="$PATH" HOME="$HOME" TMPDIR="$VERIFY_XUM_RUN/tmp" XUM_MOCK_AI=1 XUM_DISABLE_TELEMETRY=1 \ ANTHROPIC_API_KEY=sk-ant-verify-xum-fake \ XUM_ROOT="$VERIFY_XUM_RUN/scratch/cli-root" \ XUM_RUN_SESSION_ROOT="$VERIFY_XUM_RUN/scratch/cli-sessions" \ @@ -179,7 +179,8 @@ Proof standards: ``` Cleanup closes this run's browser session, stops the process group that launch started (SIGTERM, -then SIGKILL after 20 s), deletes the sandbox XUM_ROOT and the demo repo, and prints the evidence +then SIGKILL after 20 s), deletes the sandbox XUM_ROOT, the run's `tmp` folder (the server tree's +TMPDIR, where the sandbox makes its XUM_ROOT) and the demo repo, and prints the evidence path and file count. It refuses a run that another checkout launched. It signals the group only while one of its processes carries the run's id and a live group leader is still the launch's `make dev-server-sandbox`, so it kills nothing it did not start. Then it blanks the group id in @@ -187,11 +188,6 @@ the run's `state.env`. Run it after every run, failed ones too. `doctor`, `env`, `workspace-field`, `capture`, `idle`, `send-ready` and `cleanup` take an optional run directory after their other arguments. Without one they use the newest launch from this checkout. -Limit: if the launcher dies in the moment after the sandbox logs its `XUM_ROOT` and before launch -records it (up to 0.5 s), and a server process then ignores SIGTERM, cleanup must SIGKILL the -group. `dev-server-sandbox.ts` then cannot delete its `mux-dev-server-*` folder, and the run never -recorded it. Delete that folder in TMPDIR by hand. - ## Gotchas - The bash tool's environment carries the live Xum's `PORT`, auth token and provider keys. Launch diff --git a/.xum/skills/verify-xum/scripts/verify-xum.sh b/.xum/skills/verify-xum/scripts/verify-xum.sh index 3444b850bdc..a60a7dbc44a 100755 --- a/.xum/skills/verify-xum/scripts/verify-xum.sh +++ b/.xum/skills/verify-xum/scripts/verify-xum.sh @@ -32,10 +32,11 @@ set -euo pipefail unset $GIT_REPO_ENV } +# With CDPATH set, `cd` to a relative folder can pick another folder and print its name, which +# the command substitutions below would capture. +unset CDPATH SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) CHECKOUT=$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel) -# make -C runs the sandbox from the checkout, so a relative TMPDIR would resolve there. -[[ ${TMPDIR:-/tmp} == /* ]] || TMPDIR="$PWD/$TMPDIR" # Never a shared TMPDIR: another local account could create the folder first and swap a run's # state.env, which this script sources (see require_private_dir). VERIFY_XUM_HOME=${VERIFY_XUM_HOME:-${XUM_SCRATCH_DIR:+$XUM_SCRATCH_DIR/verify-xum}} @@ -331,12 +332,17 @@ cmd_launch() { AGENT_BROWSER_SESSION="verify-xum-$VERIFY_XUM_RUN_ID" mkdir -m 700 "$VERIFY_XUM_RUN" mkdir -p "$VERIFY_XUM_EVIDENCE" "$VERIFY_XUM_RUN/scratch" + # The server tree's TMPDIR is a folder of this run, never the caller's: the sandbox makes its + # XUM_ROOT there, and the backend writes server.lock with the token into it. In a shared TMPDIR + # another account could swap that folder before the backend writes. + mkdir -m 700 "$VERIFY_XUM_RUN/tmp" + require_private_dir "$VERIFY_XUM_RUN/tmp" log="$VERIFY_XUM_RUN/server.log" # Milliseconds: a stamp from earlier in the launch's own second is a stale build. t0=$(date +%s%3N) local env_without_live_xum=(env -i PATH="$PATH" HOME="$HOME" USER="${USER:-}" LANG="${LANG:-C.UTF-8}" - TERM=dumb TMPDIR="${TMPDIR:-/tmp}" XUM_MOCK_AI=1 XUM_DISABLE_TELEMETRY=1 + TERM=dumb TMPDIR="$VERIFY_XUM_RUN/tmp" XUM_MOCK_AI=1 XUM_DISABLE_TELEMETRY=1 VERIFY_XUM_SERVER_OF="$VERIFY_XUM_RUN_ID") # A shell that ran `eval "$(verify-xum.sh env)"` still exports the previous run's values. VERIFY_XUM_PGID='' VERIFY_XUM_ROOT='' VERIFY_XUM_BACKEND='' VERIFY_XUM_URL='' @@ -562,7 +568,7 @@ cmd_cleanup() { if is_dev_server_sandbox_root "$VERIFY_XUM_ROOT"; then rm -rf "$VERIFY_XUM_ROOT" fi - rm -rf "$VERIFY_XUM_RUN/scratch" + rm -rf "$VERIFY_XUM_RUN/scratch" "$VERIFY_XUM_RUN/tmp" [[ $(readlink "$CURRENT_RUN_LINK" 2>/dev/null) == "$VERIFY_XUM_RUN" ]] && rm -f "$CURRENT_RUN_LINK" VERIFY_XUM_PGID= write_state From 3781fb5a9073acf4e1e08cfb3ea36ec43ba41527 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski <tk@coder.com> Date: Tue, 3 Nov 2026 21:10:48 +0000 Subject: [PATCH 31/51] =?UTF-8?q?=F0=9F=A4=96=20fix:=20verify-xum=20failur?= =?UTF-8?q?e=20cleanup=20keeps=20the=20run=20link=20until=20the=20new=20gr?= =?UTF-8?q?oup=20records=20its=20id?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A SIGINT or SIGTERM after the spawn and before the new group wrote its PGID ran the cleanup with no PGID: it dropped the newest-run link while the server went on starting, and the next launch started a second one. The cleanup now takes the launch flock anew, which the group holds until it has written its id or died (10 s at most), and then reads the id. --- _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ --- .xum/skills/verify-xum/scripts/verify-xum.sh | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.xum/skills/verify-xum/scripts/verify-xum.sh b/.xum/skills/verify-xum/scripts/verify-xum.sh index a60a7dbc44a..61f4abbf289 100755 --- a/.xum/skills/verify-xum/scripts/verify-xum.sh +++ b/.xum/skills/verify-xum/scripts/verify-xum.sh @@ -305,6 +305,18 @@ backend_crashed_after_rebuild() { launch_failed() { echo "verify-xum: launch failed. Server log tail ($VERIFY_XUM_RUN/server.log):" >&2 tail -n 30 "$VERIFY_XUM_RUN/server.log" >&2 || true + if [[ -z $VERIFY_XUM_PGID ]]; then + # A signal can stop the launcher after the spawn and before the new group records its id. + # That group holds the launch flock until it has written the id or died, so take the flock + # anew and let cleanup read the id. Without it cleanup would drop the newest-run link while + # the server starts, and the next launch would start a second one. + exec 9>&- + exec 9>"$LAUNCH_GUARD" + if ! flock -w 10 9; then + echo "verify-xum: the new process group did not record its id in 10 s. The newest-run link stays: run verify-xum.sh cleanup." >&2 + return 0 + fi + fi cmd_cleanup "$VERIFY_XUM_RUN" >&2 || true } From e127ca966f7f1fe899393c95a2bc25658d30bd6d Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski <tk@coder.com> Date: Tue, 3 Nov 2026 21:10:49 +0000 Subject: [PATCH 32/51] =?UTF-8?q?=F0=9F=A4=96=20fix:=20verify-xum=20review?= =?UTF-8?q?=20waits=20for=20the=20read=20marker=20before=20it=20captures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app writes review-state.json about 300 ms after Mark as read. The recipe now waits up to 10 s for "isRead": true before the capture and the sidebar toggle. --- _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ --- .xum/skills/verify-xum/features/review.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/.xum/skills/verify-xum/features/review.md b/.xum/skills/verify-xum/features/review.md index ad26089bc26..43543ab9f45 100644 --- a/.xum/skills/verify-xum/features/review.md +++ b/.xum/skills/verify-xum/features/review.md @@ -52,8 +52,15 @@ Preconditions: `agent-browser wait '[aria-label="Mark as read (M)"]'` (after a reload the hunk's button shows late), then `agent-browser find role button click --name "Mark as read (M)" --exact`. The tab reads `Review 1/1`, and the hunk reads `Hunk marked as read`. - `$VERIFY_XUM_ROOT/sessions/$VERIFY_XUM_WORKSPACE_ID/review-state.json` has - `"isRead": true` for that hunk within a second. Capture `review/read`. + The app writes `review-state.json` a moment later. Wait for it: + + ```bash + timeout 10 sh -c 'until grep -qsF "\"isRead\": true" "$1"; do sleep 0.2; done' sh "$VERIFY_XUM_ROOT/sessions/$VERIFY_XUM_WORKSPACE_ID/review-state.json" + ``` + + `$VERIFY_XUM_ROOT/sessions/$VERIFY_XUM_WORKSPACE_ID/review-state.json` has `"isRead": true` for + that hunk. Capture `review/read`. + - **Keyboard toggle.** Run `agent-browser press Control+Shift+B`. The sidebar collapses, and the button `Expand right sidebar` is back. Run `agent-browser press Control+Shift+B` again. The button reads `Collapse right sidebar`, and the tab `Review 1/1 Close Review` is selected. From 85b734b1fab6610aedf719ac7ed76fc9a1c54e37 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski <tk@coder.com> Date: Tue, 3 Nov 2026 21:45:04 +0000 Subject: [PATCH 33/51] =?UTF-8?q?=F0=9F=A4=96=20fix:=20verify-xum=20opens?= =?UTF-8?q?=20its=20launch=20guard=20without=20following=20a=20symlink?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guard lives in the checkout's git dir, which another account can write in a shared repository. `exec 9>` followed a symlink planted there and truncated its target. The guard is now created with O_EXCL (noclobber), opened read only, and refused unless it is a regular file of this user whose inode matches the open fd. --- _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ --- .xum/skills/verify-xum/scripts/verify-xum.sh | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/.xum/skills/verify-xum/scripts/verify-xum.sh b/.xum/skills/verify-xum/scripts/verify-xum.sh index 61f4abbf289..01f1577bbdd 100755 --- a/.xum/skills/verify-xum/scripts/verify-xum.sh +++ b/.xum/skills/verify-xum/scripts/verify-xum.sh @@ -142,13 +142,26 @@ private_run_dir() { echo "$run" } +# Opens the launch guard on fd 9 for flock. It sits in the checkout's git dir, which another +# account can write in a shared repository: a symlink planted there made `9>` truncate its target. +# The guard is created with O_EXCL (noclobber), which never follows a symlink, and opened read +# only, which can neither create nor truncate anything (flock needs no write access). Then fd 9 +# must be the regular file of this user that the path names. +open_launch_guard() { + [[ -e $LAUNCH_GUARD || -L $LAUNCH_GUARD ]] || (set -C && : >"$LAUNCH_GUARD") 2>/dev/null || true + [[ -f $LAUNCH_GUARD && ! -L $LAUNCH_GUARD ]] || die "$LAUNCH_GUARD is not a regular file (a symlink?). Remove it." + exec 9<"$LAUNCH_GUARD" + [[ $(stat -L -c '%d:%i:%u' /proc/self/fd/9) == "$(stat -c '%d:%i' -- "$LAUNCH_GUARD"):$(id -u)" ]] \ + || die "$LAUNCH_GUARD is not owned by user $(id -u), or it changed while it was opened. Remove it." +} + # One server per checkout: two would share dist/ and node_modules/.vite. The guard is the newest # run's process group, which records its own id in that run's state.env before it starts the # server. The flock keeps two launches from passing the check at once, and it also covers the # moment between the spawn and that record: the new group holds the flock until it has written # its id, wherever the launcher dies. The server does not inherit it. refuse_while_checkout_server_runs() { - exec 9>"$LAUNCH_GUARD" + open_launch_guard # Part of the launch deadline: a short VERIFY_XUM_READY_TIMEOUT also bounds this wait. flock -w "$(launch_seconds_left)" 9 || die "another verify-xum launch from this checkout holds $LAUNCH_GUARD" [[ -f $CURRENT_RUN_LINK/state.env ]] || return 0 @@ -311,7 +324,7 @@ launch_failed() { # anew and let cleanup read the id. Without it cleanup would drop the newest-run link while # the server starts, and the next launch would start a second one. exec 9>&- - exec 9>"$LAUNCH_GUARD" + open_launch_guard if ! flock -w 10 9; then echo "verify-xum: the new process group did not record its id in 10 s. The newest-run link stays: run verify-xum.sh cleanup." >&2 return 0 From 8287bb54f613c9a978da7398695f9548b7763b86 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski <tk@coder.com> Date: Tue, 3 Nov 2026 21:45:05 +0000 Subject: [PATCH 34/51] =?UTF-8?q?=F0=9F=A4=96=20fix:=20verify-xum=20keyboa?= =?UTF-8?q?rd=20archive=20waits=20until=20scratch-notes=20is=20open?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shortcut archives whichever workspace is open, and the recipe gave no command to open scratch-notes or wait for it. It now clicks the row and waits for that workspace's URL before the shortcut. --- _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ --- .xum/skills/verify-xum/features/workspaces.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.xum/skills/verify-xum/features/workspaces.md b/.xum/skills/verify-xum/features/workspaces.md index bd26ec0fdd8..26356f57ccd 100644 --- a/.xum/skills/verify-xum/features/workspaces.md +++ b/.xum/skills/verify-xum/features/workspaces.md @@ -73,9 +73,12 @@ Preconditions: In `$VERIFY_XUM_ROOT/config.json` the `release-notes` entry has an `archivedAt` time. Capture `workspaces/archived`. -- **Archive with the keyboard.** Open `scratch-notes` from its row, run `verify-xum.sh idle`, and - press Ctrl+Shift+Backspace. Run `agent-browser press Control+Shift+Backspace` and wait for the - archive with +- **Archive with the keyboard.** Open `scratch-notes` from its row and wait until the app is on + it: the shortcut archives whichever workspace is open. Run + `agent-browser find role button click --name "Select workspace scratch-notes" --exact`, + `agent-browser wait --url "**/workspace/$(verify-xum.sh workspace-field scratch-notes id)"` and + `verify-xum.sh idle`. Then press Ctrl+Shift+Backspace. Run + `agent-browser press Control+Shift+Backspace` and wait for the archive with `agent-browser wait --fn '!document.querySelector("[aria-label=\"Select workspace scratch-notes\"]")'`. The `Select workspace scratch-notes` row is gone. Wait for its `archivedAt` time with the same `timeout 10` loop for `scratch-notes`. From ca008e34d850b0ca744c0985c32e6a9d0826be62 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski <tk@coder.com> Date: Tue, 3 Nov 2026 22:07:20 +0000 Subject: [PATCH 35/51] =?UTF-8?q?=F0=9F=A4=96=20fix:=20verify-xum=20seeds?= =?UTF-8?q?=20the=20demo=20repo=20without=20templates,=20attributes=20or?= =?UTF-8?q?=20filters?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Launch keeps the real HOME, so global git config applied to the seed: an init.templateDir copied info/attributes, and a required clean filter from it or from core.attributesFile failed or hung git add. The seed now runs git init --template=, every command with core.attributesFile=/dev/null, core.hooksPath=/dev/null, core.fsmonitor=false and GIT_ATTR_NOSYSTEM=1, and under timeout with the time left before the launch deadline. --- _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ --- .xum/skills/verify-xum/scripts/verify-xum.sh | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/.xum/skills/verify-xum/scripts/verify-xum.sh b/.xum/skills/verify-xum/scripts/verify-xum.sh index 01f1577bbdd..b8c526f4586 100755 --- a/.xum/skills/verify-xum/scripts/verify-xum.sh +++ b/.xum/skills/verify-xum/scripts/verify-xum.sh @@ -268,15 +268,25 @@ check_bun() { || die "bun $have is first on PATH but package.json pins $want. Put Bun $want first on PATH (mise: PATH=\"\$(mise where bun@$want)/bin:\$PATH\")." } +# The seed's git commands. The real HOME stays (see SKILL.md), so your global git config applies: +# an init.templateDir, a core.attributesFile or the system attributes could run a clean filter on +# `git add`, and a hook or an fsmonitor could run on any command. None of them runs here, and each +# command stops at the launch deadline. +demo_git() { + GIT_ATTR_NOSYSTEM=1 timeout "$(launch_seconds_left)" git -c core.attributesFile=/dev/null \ + -c core.hooksPath=/dev/null -c core.fsmonitor=false "$@" +} + create_demo_repo() { local repo=$1 mkdir -p "$repo/src" printf '# Demo app\n\nA throwaway project for verify-xum.\n' >"$repo/README.md" printf 'export function add(a: number, b: number): number {\n return a + b;\n}\n' >"$repo/src/math.ts" - git -C "$repo" init -q -b main - git -C "$repo" add . - git -C "$repo" -c user.name=verify-xum -c user.email=verify-xum@example.test \ - -c commit.gpgsign=false -c core.hooksPath=/dev/null commit -qm init + # --template= copies no template: no hooks and no info/attributes. + demo_git -C "$repo" init -q -b main --template= + demo_git -C "$repo" add . + demo_git -C "$repo" -c user.name=verify-xum -c user.email=verify-xum@example.test \ + -c commit.gpgsign=false commit -qm init } seed_unreachable_fake_provider() { From aae7b618962c9feee4c8756c95bc012d325e5af7 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski <tk@coder.com> Date: Tue, 3 Nov 2026 22:07:21 +0000 Subject: [PATCH 36/51] =?UTF-8?q?=F0=9F=A4=96=20fix:=20verify-xum=20change?= =?UTF-8?q?s=20the=20newest-run=20link=20only=20while=20it=20holds=20the?= =?UTF-8?q?=20launch=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - One rule, stated at the helpers: every read-compare-write or unlink of verify-xum.current, in launch, cleanup and the failure cleanup, runs while this process holds the launch guard. Cleanup could otherwise unlink a link that an overlapping launch had just pointed at its own run, and the next launch then started a second server. - The guard is created with mode 0600 whatever the umask, and an older 0644 guard is set to 0600: another account that can read it can flock it and block every launch. - Launch checks the folders above a new run home before it creates any of them, so a refused launch creates no folder. --- _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ --- .xum/skills/verify-xum/scripts/verify-xum.sh | 81 ++++++++++++++------ 1 file changed, 59 insertions(+), 22 deletions(-) diff --git a/.xum/skills/verify-xum/scripts/verify-xum.sh b/.xum/skills/verify-xum/scripts/verify-xum.sh index b8c526f4586..6a1b071b843 100755 --- a/.xum/skills/verify-xum/scripts/verify-xum.sh +++ b/.xum/skills/verify-xum/scripts/verify-xum.sh @@ -146,13 +146,49 @@ private_run_dir() { # account can write in a shared repository: a symlink planted there made `9>` truncate its target. # The guard is created with O_EXCL (noclobber), which never follows a symlink, and opened read # only, which can neither create nor truncate anything (flock needs no write access). Then fd 9 -# must be the regular file of this user that the path names. +# must be the regular file of this user that the path names. Its mode is 0600: another account +# that can read it can flock it and block every launch. A guard from an older version is 0644, +# so the mode is set on the open file too. open_launch_guard() { - [[ -e $LAUNCH_GUARD || -L $LAUNCH_GUARD ]] || (set -C && : >"$LAUNCH_GUARD") 2>/dev/null || true + [[ -e $LAUNCH_GUARD || -L $LAUNCH_GUARD ]] || (umask 077 && set -C && : >"$LAUNCH_GUARD") 2>/dev/null || true [[ -f $LAUNCH_GUARD && ! -L $LAUNCH_GUARD ]] || die "$LAUNCH_GUARD is not a regular file (a symlink?). Remove it." exec 9<"$LAUNCH_GUARD" [[ $(stat -L -c '%d:%i:%u' /proc/self/fd/9) == "$(stat -c '%d:%i' -- "$LAUNCH_GUARD"):$(id -u)" ]] \ || die "$LAUNCH_GUARD is not owned by user $(id -u), or it changed while it was opened. Remove it." + chmod 600 /proc/self/fd/9 +} + +# The rule for the newest-run link: every read-compare-write or unlink of verify-xum.current, in +# launch, cleanup and the failure cleanup, runs while this process holds the launch guard's flock +# on fd 9. Only the two helpers below change the link. Launch holds the guard from its stale-run +# check until the new group has recorded its id, so no cleanup can drop the link of a launch in +# that window, and no launch can replace the link between a cleanup's compare and its unlink. +LAUNCH_GUARD_HELD= +hold_launch_guard() { # hold_launch_guard <seconds to wait> + [[ -z $LAUNCH_GUARD_HELD ]] || return 0 + open_launch_guard + flock -w "$1" 9 || return 1 + LAUNCH_GUARD_HELD=1 +} + +release_launch_guard() { + exec 9>&- + LAUNCH_GUARD_HELD= +} + +point_current_run_link_at() { # point_current_run_link_at <run> + [[ -n $LAUNCH_GUARD_HELD ]] || die "internal error: the newest-run link changed without the launch guard" + ln -sfn "$1" "$CURRENT_RUN_LINK" +} + +drop_current_run_link_if() { # drop_current_run_link_if <run>: unlink it when it names <run> + if ! hold_launch_guard 60; then + echo "verify-xum: a launch from this checkout held $LAUNCH_GUARD for 60 s. Left the newest-run link." >&2 + return 0 + fi + if [[ $(readlink "$CURRENT_RUN_LINK" 2>/dev/null) == "$1" ]]; then + rm -f "$CURRENT_RUN_LINK" + fi } # One server per checkout: two would share dist/ and node_modules/.vite. The guard is the newest @@ -161,9 +197,8 @@ open_launch_guard() { # moment between the spawn and that record: the new group holds the flock until it has written # its id, wherever the launcher dies. The server does not inherit it. refuse_while_checkout_server_runs() { - open_launch_guard # Part of the launch deadline: a short VERIFY_XUM_READY_TIMEOUT also bounds this wait. - flock -w "$(launch_seconds_left)" 9 || die "another verify-xum launch from this checkout holds $LAUNCH_GUARD" + hold_launch_guard "$(launch_seconds_left)" || die "another verify-xum launch from this checkout holds $LAUNCH_GUARD" [[ -f $CURRENT_RUN_LINK/state.env ]] || return 0 local run run=$(private_run_dir "$CURRENT_RUN_LINK") || exit 1 @@ -328,33 +363,35 @@ backend_crashed_after_rebuild() { launch_failed() { echo "verify-xum: launch failed. Server log tail ($VERIFY_XUM_RUN/server.log):" >&2 tail -n 30 "$VERIFY_XUM_RUN/server.log" >&2 || true - if [[ -z $VERIFY_XUM_PGID ]]; then - # A signal can stop the launcher after the spawn and before the new group records its id. - # That group holds the launch flock until it has written the id or died, so take the flock - # anew and let cleanup read the id. Without it cleanup would drop the newest-run link while - # the server starts, and the next launch would start a second one. - exec 9>&- - open_launch_guard - if ! flock -w 10 9; then - echo "verify-xum: the new process group did not record its id in 10 s. The newest-run link stays: run verify-xum.sh cleanup." >&2 - return 0 - fi + # Take the guard anew (the link rule above). A signal can stop the launcher after the spawn and + # before the new group records its id. That group shares this launcher's guard fd and holds + # the flock until it has written the id or died, so cleanup then reads the id. Without the + # wait, cleanup would drop the newest-run link while the server starts, and the next launch + # would start a second one. + release_launch_guard + if ! hold_launch_guard 10; then + echo "verify-xum: could not take $LAUNCH_GUARD in 10 s (the new group has not recorded its id). The newest-run link stays: run verify-xum.sh cleanup." >&2 + return 0 fi cmd_cleanup "$VERIFY_XUM_RUN" >&2 || true } cmd_launch() { - local t0 backend_port vite_port log + local t0 backend_port vite_port log existing need git curl node bun make setsid flock ss agent-browser check_bun LAUNCH_DEADLINE=$((SECONDS + READY_TIMEOUT)) refuse_while_checkout_server_runs if [[ ! -e $VERIFY_XUM_HOME ]]; then - # Parents this creates are private too, whatever the caller's umask. + # Check the nearest existing folder above the home, and every folder above it, before any + # folder is made: a refused launch creates nothing. The folders it then makes are private, + # whatever the caller's umask. + existing=$(dirname "$VERIFY_XUM_HOME") + while [[ ! -e $existing ]]; do existing=$(dirname "$existing"); done + [[ -d $existing ]] || die "$existing, above the run home, is not a folder" + require_safe_ancestors "$(cd -P -- "$existing" && pwd -P)/home" (umask 077 && mkdir -p "$(dirname "$VERIFY_XUM_HOME")") - # Check the folders above before the home is made in them (the home itself does not exist yet). - require_safe_ancestors "$(cd -P -- "$(dirname "$VERIFY_XUM_HOME")" && pwd -P)/home" mkdir -m 700 "$VERIFY_XUM_HOME" fi VERIFY_XUM_HOME=$(checked_home "$VERIFY_XUM_HOME") || exit 1 @@ -383,7 +420,7 @@ cmd_launch() { VERIFY_XUM_PGID='' VERIFY_XUM_ROOT='' VERIFY_XUM_BACKEND='' VERIFY_XUM_URL='' VERIFY_XUM_WORKSPACE_ID='' VERIFY_XUM_WORKTREE='' write_state - ln -sfn "$VERIFY_XUM_RUN" "$CURRENT_RUN_LINK" + point_current_run_link_at "$VERIFY_XUM_RUN" trap launch_failed EXIT # The new group's leader appends its pid, which is the group id, to state.env before it starts # make. From then on the launch guard and cleanup find the group, wherever the launcher dies. @@ -400,7 +437,7 @@ cmd_launch() { before_launch_deadline sleep 0.1 done - exec 9>&- + release_launch_guard write_state while true; do @@ -604,7 +641,7 @@ cmd_cleanup() { rm -rf "$VERIFY_XUM_ROOT" fi rm -rf "$VERIFY_XUM_RUN/scratch" "$VERIFY_XUM_RUN/tmp" - [[ $(readlink "$CURRENT_RUN_LINK" 2>/dev/null) == "$VERIFY_XUM_RUN" ]] && rm -f "$CURRENT_RUN_LINK" + drop_current_run_link_if "$VERIFY_XUM_RUN" VERIFY_XUM_PGID= write_state echo "cleaned up run $VERIFY_XUM_RUN_ID; evidence kept in $VERIFY_XUM_EVIDENCE ($(find "$VERIFY_XUM_EVIDENCE" -type f | wc -l | tr -d ' ') files) and $VERIFY_XUM_RUN/server.log" From 6698c9066d8784e70542dd27318766200254fe93 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski <tk@coder.com> Date: Tue, 3 Nov 2026 22:07:22 +0000 Subject: [PATCH 37/51] =?UTF-8?q?=F0=9F=A4=96=20fix:=20verify-xum=20recipe?= =?UTF-8?q?s=20read=20workspace=20ids=20in=20the=20same=20call=20as=20thei?= =?UTF-8?q?r=20waits?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Restore and palette Switch waits used $RN_ID from an earlier bash call, where it no longer exists. They read the id with verify-xum.sh workspace-field in the wait itself. --- _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ --- .xum/skills/verify-xum/features/command-palette.md | 10 ++++------ .xum/skills/verify-xum/features/workspaces.md | 8 ++++---- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/.xum/skills/verify-xum/features/command-palette.md b/.xum/skills/verify-xum/features/command-palette.md index 80592a98c09..826c2e0c6c3 100644 --- a/.xum/skills/verify-xum/features/command-palette.md +++ b/.xum/skills/verify-xum/features/command-palette.md @@ -28,15 +28,13 @@ Preconditions: open of a page the list fills a moment after the combobox shows. The combobox `Command palette` is expanded, with the listbox `Suggestions`. Run `agent-browser press Escape`. The combobox is gone. -- **Switch workspace.** Read the id with `RN_ID=$(verify-xum.sh workspace-field release-notes id)` - (see [workspaces](./workspaces.md) **Open from the sidebar**). Press Ctrl+Shift+P, wait for the - palette as above, then run +- **Switch workspace.** Press Ctrl+Shift+P, wait for the palette as above, then run `agent-browser keyboard type "release"` and `agent-browser wait '[role="option"][aria-selected="true"][aria-label="release-notes"]'`. `Suggestions` lists the option `release-notes`, selected. Run `agent-browser press Enter` and - `agent-browser wait --url "**/workspace/$RN_ID"`: the palette closes first and navigates a - moment later. The URL is `/workspace/$RN_ID`. Capture - `command-palette/switched`. + `agent-browser wait --url "**/workspace/$(verify-xum.sh workspace-field release-notes id)"`: + the palette closes first and navigates a moment later. The URL is the `release-notes` + workspace's. Capture `command-palette/switched`. - **Command mode.** Run `agent-browser press F4` and `agent-browser wait '[aria-label="Command palette"]'`. The combobox value is `>`, and `Suggestions` lists commands such as `Open Settings` and `Change Model…`. diff --git a/.xum/skills/verify-xum/features/workspaces.md b/.xum/skills/verify-xum/features/workspaces.md index 26356f57ccd..6a680073fa5 100644 --- a/.xum/skills/verify-xum/features/workspaces.md +++ b/.xum/skills/verify-xum/features/workspaces.md @@ -89,10 +89,10 @@ Preconditions: `agent-browser wait '[aria-label="Restore workspace release-notes"]'`. The region `Archived workspaces` lists `release-notes` and `scratch-notes`. Run `agent-browser find role button click --name "Restore workspace release-notes" --exact`, then - wait for the result with `agent-browser wait --url "**/workspace/$RN_ID"` (`RN_ID` as in - **Open from the sidebar**) and - `agent-browser wait '[aria-label="Select workspace release-notes"]'`. The app is on - `/workspace/$RN_ID`, the sidebar row is back, and `config.json` has an + wait for the result in the same bash call with + `agent-browser wait --url "**/workspace/$(verify-xum.sh workspace-field release-notes id)"` and + `agent-browser wait '[aria-label="Select workspace release-notes"]'`. The app is on the + `release-notes` workspace URL, the sidebar row is back, and `config.json` has an `unarchivedAt` time for it. Capture `workspaces/restored`. - **CLI view.** List the workspaces through the CLI: From aa6813789efa66a5305aabaa5e4ea8fb6d534965 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski <tk@coder.com> Date: Tue, 3 Nov 2026 22:07:23 +0000 Subject: [PATCH 38/51] =?UTF-8?q?=F0=9F=A4=96=20fix:=20verify-xum=20proof?= =?UTF-8?q?=20standards=20require=20a=20phone-width=20pass?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A UI change must also be checked at 375 px: set the viewport, repeat the changed recipe's captures, check that nothing runs past the right edge, then restore the viewport. --- _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ --- .xum/skills/verify-xum/SKILL.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.xum/skills/verify-xum/SKILL.md b/.xum/skills/verify-xum/SKILL.md index f4b16e23545..d5715c335e3 100644 --- a/.xum/skills/verify-xum/SKILL.md +++ b/.xum/skills/verify-xum/SKILL.md @@ -166,6 +166,12 @@ Proof standards: the worktree alone. A mock run does not prove a change to provider requests or tool execution: report that part as not verified, or prove it at that boundary. - Capture the action and the resulting state, not only the final screen. +- Check a UI change at phone width too. Run `agent-browser set viewport 375 812` and + `verify-xum.sh idle`, repeat the changed recipe's steps and captures with names that end in + `-375`, and look for clipped text, content past the right edge + (`agent-browser eval 'document.documentElement.scrollWidth <= innerWidth'` prints `true`) and + controls that cover each other. Run `agent-browser set viewport 1280 720` afterwards. A change + with no UI needs no phone pass: say so. - Check side effects next to what is visible: rows in `$VERIFY_XUM_ROOT/sessions/<workspace id>/chat.jsonl`, entries in `$VERIFY_XUM_ROOT/config.json`, git state in the worktree, or the CLI view above. From 5caa4030d64a20f1bb8c1889b9d8bd811c13a701 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski <tk@coder.com> Date: Tue, 3 Nov 2026 23:15:37 +0000 Subject: [PATCH 39/51] =?UTF-8?q?=F0=9F=A4=96=20fix:=20verify-xum=20keeps?= =?UTF-8?q?=20the=20auth=20token=20out=20of=20the=20evidence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the app fails before it strips ?token= (a renderer error, a chunk that does not load), the browser stays on a URL with the token, and capture saved that URL into the evidence, which is meant to be shared. capture now passes the URL and the snapshot through redact_token, which replaces the token (raw and URL-encoded) and any token= value with <redacted>. Cleanup does the same in every text file of the evidence and in the server log, before it deletes server.lock. --- _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ --- .xum/skills/verify-xum/SKILL.md | 6 ++- .xum/skills/verify-xum/scripts/verify-xum.sh | 45 +++++++++++++++++++- 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/.xum/skills/verify-xum/SKILL.md b/.xum/skills/verify-xum/SKILL.md index f4b16e23545..dfdcef53a38 100644 --- a/.xum/skills/verify-xum/SKILL.md +++ b/.xum/skills/verify-xum/SKILL.md @@ -146,7 +146,11 @@ else `$HOME/.cache/verify-xum`, and never to a shared TMPDIR. Launch creates it Every command that reads a run's `state.env` first checks that the home and the run folder are real folders (no symlinks), owned by you and not writable by group or others, and refuses otherwise: the script sources that file. Cleanup never -deletes the evidence or the server log next to it (`<run>/server.log`). +deletes the evidence or the server log next to it (`<run>/server.log`). Evidence is meant to be +shared: `capture` replaces the backend's auth token (and any `token=` URL value) with +`<redacted>` in the URL and snapshot it writes, and cleanup does the same in every text file of +the evidence and in the server log. A screenshot is not redacted, so do not capture a page that +shows the token. ```bash .xum/skills/verify-xum/scripts/verify-xum.sh capture chat/reply diff --git a/.xum/skills/verify-xum/scripts/verify-xum.sh b/.xum/skills/verify-xum/scripts/verify-xum.sh index 01f1577bbdd..691d4fe8ca1 100755 --- a/.xum/skills/verify-xum/scripts/verify-xum.sh +++ b/.xum/skills/verify-xum/scripts/verify-xum.sh @@ -532,14 +532,53 @@ cmd_workspace_field() { "$VERIFY_XUM_ROOT/config.json" "$name" "$field" } +# Evidence is meant to be shared, so no text in it may keep the backend's token: the page keeps +# ?token= in its URL when the app fails before it strips it. This replaces the token (raw, and +# URL-encoded as a path part and as a form value) and the value of any token= query parameter +# with <redacted>. With no PATH it filters stdin to stdout. With PATHs it rewrites each text file +# under them in place, skipping binary files and symlinks. The token comes from server.lock, +# never from argv. +redact_token() { # redact_token [PATH...] + # shellcheck disable=SC2016 # $1 is the regex group, for node + node -e ' + const fs = require("fs"); + const path = require("path"); + let token = ""; + try { + token = JSON.parse(fs.readFileSync(process.argv[1], "utf8")).token ?? ""; + } catch {} + const forms = [...new Set([token, encodeURIComponent(token), new URLSearchParams({ t: token }).toString().slice(2)])].filter(Boolean); + const redact = (text) => { + for (const form of forms) text = text.split(form).join("<redacted>"); + return text.replace(/([?&]token=)(?!<redacted>)[^&#\s"\x27<>]*/g, "$1<redacted>"); + }; + const filesUnder = (p) => { + const st = fs.lstatSync(p); + if (st.isDirectory()) return fs.readdirSync(p).flatMap((name) => filesUnder(path.join(p, name))); + return st.isFile() ? [p] : []; + }; + const paths = process.argv.slice(2); + if (paths.length === 0) { + process.stdout.write(redact(fs.readFileSync(0, "utf8"))); + process.exit(0); + } + for (const file of paths.filter((p) => fs.existsSync(p)).flatMap(filesUnder)) { + const data = fs.readFileSync(file); + if (data.subarray(0, 8192).includes(0)) continue; + const text = data.toString("utf8"); + const clean = redact(text); + if (clean !== text) fs.writeFileSync(file, clean); + }' "$VERIFY_XUM_ROOT/server.lock" "$@" +} + cmd_capture() { local name=${1:-} base [[ $name =~ ^[a-z0-9][a-z0-9._-]*(/[a-z0-9][a-z0-9._-]*)*$ ]] || die "capture NAME must look like <feature>/<step>, lowercase" load_run "${2:-}" base="$VERIFY_XUM_EVIDENCE/$name" mkdir -p "$(dirname "$base")" - agent-browser --session "$AGENT_BROWSER_SESSION" get url >"$base.url.txt" - agent-browser --session "$AGENT_BROWSER_SESSION" snapshot >"$base.aria.txt" + agent-browser --session "$AGENT_BROWSER_SESSION" get url | redact_token >"$base.url.txt" + agent-browser --session "$AGENT_BROWSER_SESSION" snapshot | redact_token >"$base.aria.txt" agent-browser --session "$AGENT_BROWSER_SESSION" screenshot "$base.png" >/dev/null echo "$base.png $base.aria.txt $base.url.txt" } @@ -590,6 +629,8 @@ cmd_cleanup() { elif group_alive "$VERIFY_XUM_PGID"; then echo "verify-xum: process group $VERIFY_XUM_PGID is not this run's dev server. Not signaling it." >&2 fi + # Before the root and its server.lock go: text you saved by hand is evidence too. + redact_token "$VERIFY_XUM_EVIDENCE" "$VERIFY_XUM_RUN/server.log" || true if is_dev_server_sandbox_root "$VERIFY_XUM_ROOT"; then rm -rf "$VERIFY_XUM_ROOT" fi From 663215c734bad15ad417b507df19ea09d2796328 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski <tk@coder.com> Date: Tue, 3 Nov 2026 23:25:42 +0000 Subject: [PATCH 40/51] =?UTF-8?q?=F0=9F=A4=96=20fix:=20verify-xum=20opens?= =?UTF-8?q?=20its=20launch=20guard=20without=20a=20blocking=20FIFO=20race?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pathname check and the open were separate steps, so a FIFO swapped in between made the read-only open block before the timed flock. A node helper now opens the guard with O_NOFOLLOW and O_NONBLOCK (O_EXCL and mode 0600 when it is absent), checks the open file with fstat and sets its mode to 0600. The shell opens the same file through /proc/<helper pid>/fd/<fd>, so it never opens the pathname itself. --- _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ --- .xum/skills/verify-xum/scripts/verify-xum.sh | 60 ++++++++++++++++---- 1 file changed, 48 insertions(+), 12 deletions(-) diff --git a/.xum/skills/verify-xum/scripts/verify-xum.sh b/.xum/skills/verify-xum/scripts/verify-xum.sh index 4602806e12d..992206425d4 100755 --- a/.xum/skills/verify-xum/scripts/verify-xum.sh +++ b/.xum/skills/verify-xum/scripts/verify-xum.sh @@ -143,19 +143,55 @@ private_run_dir() { } # Opens the launch guard on fd 9 for flock. It sits in the checkout's git dir, which another -# account can write in a shared repository: a symlink planted there made `9>` truncate its target. -# The guard is created with O_EXCL (noclobber), which never follows a symlink, and opened read -# only, which can neither create nor truncate anything (flock needs no write access). Then fd 9 -# must be the regular file of this user that the path names. Its mode is 0600: another account -# that can read it can flock it and block every launch. A guard from an older version is 0644, -# so the mode is set on the open file too. +# account can write in a shared repository, so the shell never opens that pathname itself: a +# symlink planted there made `9>` truncate its target, and a FIFO swapped in after a check makes +# a plain open block before the timed flock. A node helper opens it with O_NOFOLLOW and +# O_NONBLOCK (creating it with O_EXCL when it is absent), checks the open file with fstat (a +# regular file of this user), sets its mode to 0600 (another account that can read it can flock +# it and block every launch, and a guard from an older version is 0644), and keeps it open while +# the shell opens the same file through /proc/<helper pid>/fd/<fd>. Read only: flock needs no +# write access. open_launch_guard() { - [[ -e $LAUNCH_GUARD || -L $LAUNCH_GUARD ]] || (umask 077 && set -C && : >"$LAUNCH_GUARD") 2>/dev/null || true - [[ -f $LAUNCH_GUARD && ! -L $LAUNCH_GUARD ]] || die "$LAUNCH_GUARD is not a regular file (a symlink?). Remove it." - exec 9<"$LAUNCH_GUARD" - [[ $(stat -L -c '%d:%i:%u' /proc/self/fd/9) == "$(stat -c '%d:%i' -- "$LAUNCH_GUARD"):$(id -u)" ]] \ - || die "$LAUNCH_GUARD is not owned by user $(id -u), or it changed while it was opened. Remove it." - chmod 600 /proc/self/fd/9 + local line pid fd id from_helper to_helper helper + # shellcheck disable=SC2016 # node code + coproc GUARD_OPENER { + node -e ' + const fs = require("fs"); + const { O_RDONLY, O_CREAT, O_EXCL, O_NOFOLLOW, O_NONBLOCK } = fs.constants; + const fail = (why) => { + process.stdout.write("error " + why + "\n"); + process.exit(1); + }; + let fd; + try { + fd = fs.openSync(process.argv[1], O_RDONLY | O_CREAT | O_EXCL | O_NOFOLLOW, 0o600); + } catch (error) { + if (error.code !== "EEXIST") fail(error.code); + try { + fd = fs.openSync(process.argv[1], O_RDONLY | O_NOFOLLOW | O_NONBLOCK); + } catch (error) { + fail(error.code === "ELOOP" ? "is a symlink" : error.code); + } + } + const st = fs.fstatSync(fd); + if (!st.isFile()) fail("is not a regular file"); + if (st.uid !== process.getuid()) fail("is not owned by user " + process.getuid()); + fs.fchmodSync(fd, 0o600); + process.stdout.write("ok " + process.pid + " " + fd + " " + st.dev + ":" + st.ino + "\n"); + fs.readFileSync(0);' "$LAUNCH_GUARD" 2>&1 + } + # Bash drops the coproc's names once it ends, so keep the fds and pid. + from_helper=${GUARD_OPENER[0]} to_helper=${GUARD_OPENER[1]} helper=$GUARD_OPENER_PID + IFS= read -r line <&"$from_helper" || line="error the guard helper ended early" + if [[ $line == "ok "* ]]; then + read -r _ pid fd id <<<"$line" + exec 9<"/proc/$pid/fd/$fd" + fi + # EOF on its stdin ends the helper. + eval "exec $to_helper>&- $from_helper<&-" + wait "$helper" 2>/dev/null || true + [[ $line == "ok "* ]] || die "$LAUNCH_GUARD ${line#error }. Remove it." + [[ $(stat -L -c '%d:%i' /proc/self/fd/9) == "$id" ]] || die "$LAUNCH_GUARD changed while it was opened. Remove it." } # The rule for the newest-run link: every read-compare-write or unlink of verify-xum.current, in From fcfed1dbd82644d11f4cdadadec0b07ab08f2b4e Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski <tk@coder.com> Date: Tue, 3 Nov 2026 23:36:07 +0000 Subject: [PATCH 41/51] =?UTF-8?q?=F0=9F=A4=96=20fix:=20verify-xum=20cleanu?= =?UTF-8?q?p=20redacts=20with=20the=20token=20it=20read=20before=20stoppin?= =?UTF-8?q?g=20the=20server?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stopping the group makes the backend delete server.lock within about a second, so cleanup's redaction loaded an empty token and left a bare token in the evidence. Cleanup now reads the token first, and the redactor gets it on fd 3. The token= pattern also matches only a non-empty value, so an empty ?token= and the Makefile banner's ?token=<token> stay as they are. --- _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ --- .xum/skills/verify-xum/scripts/verify-xum.sh | 25 ++++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/.xum/skills/verify-xum/scripts/verify-xum.sh b/.xum/skills/verify-xum/scripts/verify-xum.sh index 691d4fe8ca1..76da0a9fd29 100755 --- a/.xum/skills/verify-xum/scripts/verify-xum.sh +++ b/.xum/skills/verify-xum/scripts/verify-xum.sh @@ -536,28 +536,31 @@ cmd_workspace_field() { # ?token= in its URL when the app fails before it strips it. This replaces the token (raw, and # URL-encoded as a path part and as a form value) and the value of any token= query parameter # with <redacted>. With no PATH it filters stdin to stdout. With PATHs it rewrites each text file -# under them in place, skipping binary files and symlinks. The token comes from server.lock, -# never from argv. +# under them in place, skipping binary files and symlinks. The token is $REDACT_TOKEN when set +# (cleanup reads it before the sandbox deletes server.lock), else server.lock's. node gets it on +# fd 3, never in argv. +REDACT_TOKEN= redact_token() { # redact_token [PATH...] + local token=$REDACT_TOKEN + [[ -n $token ]] || token=$(json_field_or_empty "$VERIFY_XUM_ROOT/server.lock" token) # shellcheck disable=SC2016 # $1 is the regex group, for node node -e ' const fs = require("fs"); const path = require("path"); - let token = ""; - try { - token = JSON.parse(fs.readFileSync(process.argv[1], "utf8")).token ?? ""; - } catch {} + const token = fs.readFileSync(3, "utf8"); const forms = [...new Set([token, encodeURIComponent(token), new URLSearchParams({ t: token }).toString().slice(2)])].filter(Boolean); const redact = (text) => { for (const form of forms) text = text.split(form).join("<redacted>"); - return text.replace(/([?&]token=)(?!<redacted>)[^&#\s"\x27<>]*/g, "$1<redacted>"); + // A value of at least one character: an empty ?token= and a placeholder such as + // ?token=<token> (the Makefile banner in server.log) stay as they are. + return text.replace(/([?&]token=)[^&#\s"\x27<>]+/g, "$1<redacted>"); }; const filesUnder = (p) => { const st = fs.lstatSync(p); if (st.isDirectory()) return fs.readdirSync(p).flatMap((name) => filesUnder(path.join(p, name))); return st.isFile() ? [p] : []; }; - const paths = process.argv.slice(2); + const paths = process.argv.slice(1); if (paths.length === 0) { process.stdout.write(redact(fs.readFileSync(0, "utf8"))); process.exit(0); @@ -568,7 +571,7 @@ redact_token() { # redact_token [PATH...] const text = data.toString("utf8"); const clean = redact(text); if (clean !== text) fs.writeFileSync(file, clean); - }' "$VERIFY_XUM_ROOT/server.lock" "$@" + }' "$@" 3< <(printf %s "$token") } cmd_capture() { @@ -613,6 +616,8 @@ is_dev_server_sandbox_root() { cmd_cleanup() { load_run "${1:-}" local i + # Read the token while server.lock exists: stopping the group makes the sandbox delete its root. + REDACT_TOKEN=$(json_field_or_empty "$VERIFY_XUM_ROOT/server.lock" token) # agent-browser `close` on a session that does not exist starts that session first. if [[ $(agent-browser session list 2>/dev/null) == *"$AGENT_BROWSER_SESSION"* ]]; then agent-browser --session "$AGENT_BROWSER_SESSION" close >/dev/null 2>&1 || true @@ -629,7 +634,7 @@ cmd_cleanup() { elif group_alive "$VERIFY_XUM_PGID"; then echo "verify-xum: process group $VERIFY_XUM_PGID is not this run's dev server. Not signaling it." >&2 fi - # Before the root and its server.lock go: text you saved by hand is evidence too. + # Text you saved by hand is evidence too. redact_token "$VERIFY_XUM_EVIDENCE" "$VERIFY_XUM_RUN/server.log" || true if is_dev_server_sandbox_root "$VERIFY_XUM_ROOT"; then rm -rf "$VERIFY_XUM_ROOT" From 7e5711c86e835dc7105d1e9e22d2d5fd6fd24228 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski <tk@coder.com> Date: Tue, 3 Nov 2026 23:37:03 +0000 Subject: [PATCH 42/51] =?UTF-8?q?Revert=20"=F0=9F=A4=96=20fix:=20verify-xu?= =?UTF-8?q?m=20opens=20its=20launch=20guard=20without=20a=20blocking=20FIF?= =?UTF-8?q?O=20race"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 663215c734bad15ad417b507df19ea09d2796328. --- .xum/skills/verify-xum/scripts/verify-xum.sh | 60 ++++---------------- 1 file changed, 12 insertions(+), 48 deletions(-) diff --git a/.xum/skills/verify-xum/scripts/verify-xum.sh b/.xum/skills/verify-xum/scripts/verify-xum.sh index 04727fee315..4917396daf5 100755 --- a/.xum/skills/verify-xum/scripts/verify-xum.sh +++ b/.xum/skills/verify-xum/scripts/verify-xum.sh @@ -143,55 +143,19 @@ private_run_dir() { } # Opens the launch guard on fd 9 for flock. It sits in the checkout's git dir, which another -# account can write in a shared repository, so the shell never opens that pathname itself: a -# symlink planted there made `9>` truncate its target, and a FIFO swapped in after a check makes -# a plain open block before the timed flock. A node helper opens it with O_NOFOLLOW and -# O_NONBLOCK (creating it with O_EXCL when it is absent), checks the open file with fstat (a -# regular file of this user), sets its mode to 0600 (another account that can read it can flock -# it and block every launch, and a guard from an older version is 0644), and keeps it open while -# the shell opens the same file through /proc/<helper pid>/fd/<fd>. Read only: flock needs no -# write access. +# account can write in a shared repository: a symlink planted there made `9>` truncate its target. +# The guard is created with O_EXCL (noclobber), which never follows a symlink, and opened read +# only, which can neither create nor truncate anything (flock needs no write access). Then fd 9 +# must be the regular file of this user that the path names. Its mode is 0600: another account +# that can read it can flock it and block every launch. A guard from an older version is 0644, +# so the mode is set on the open file too. open_launch_guard() { - local line pid fd id from_helper to_helper helper - # shellcheck disable=SC2016 # node code - coproc GUARD_OPENER { - node -e ' - const fs = require("fs"); - const { O_RDONLY, O_CREAT, O_EXCL, O_NOFOLLOW, O_NONBLOCK } = fs.constants; - const fail = (why) => { - process.stdout.write("error " + why + "\n"); - process.exit(1); - }; - let fd; - try { - fd = fs.openSync(process.argv[1], O_RDONLY | O_CREAT | O_EXCL | O_NOFOLLOW, 0o600); - } catch (error) { - if (error.code !== "EEXIST") fail(error.code); - try { - fd = fs.openSync(process.argv[1], O_RDONLY | O_NOFOLLOW | O_NONBLOCK); - } catch (error) { - fail(error.code === "ELOOP" ? "is a symlink" : error.code); - } - } - const st = fs.fstatSync(fd); - if (!st.isFile()) fail("is not a regular file"); - if (st.uid !== process.getuid()) fail("is not owned by user " + process.getuid()); - fs.fchmodSync(fd, 0o600); - process.stdout.write("ok " + process.pid + " " + fd + " " + st.dev + ":" + st.ino + "\n"); - fs.readFileSync(0);' "$LAUNCH_GUARD" 2>&1 - } - # Bash drops the coproc's names once it ends, so keep the fds and pid. - from_helper=${GUARD_OPENER[0]} to_helper=${GUARD_OPENER[1]} helper=$GUARD_OPENER_PID - IFS= read -r line <&"$from_helper" || line="error the guard helper ended early" - if [[ $line == "ok "* ]]; then - read -r _ pid fd id <<<"$line" - exec 9<"/proc/$pid/fd/$fd" - fi - # EOF on its stdin ends the helper. - eval "exec $to_helper>&- $from_helper<&-" - wait "$helper" 2>/dev/null || true - [[ $line == "ok "* ]] || die "$LAUNCH_GUARD ${line#error }. Remove it." - [[ $(stat -L -c '%d:%i' /proc/self/fd/9) == "$id" ]] || die "$LAUNCH_GUARD changed while it was opened. Remove it." + [[ -e $LAUNCH_GUARD || -L $LAUNCH_GUARD ]] || (umask 077 && set -C && : >"$LAUNCH_GUARD") 2>/dev/null || true + [[ -f $LAUNCH_GUARD && ! -L $LAUNCH_GUARD ]] || die "$LAUNCH_GUARD is not a regular file (a symlink?). Remove it." + exec 9<"$LAUNCH_GUARD" + [[ $(stat -L -c '%d:%i:%u' /proc/self/fd/9) == "$(stat -c '%d:%i' -- "$LAUNCH_GUARD"):$(id -u)" ]] \ + || die "$LAUNCH_GUARD is not owned by user $(id -u), or it changed while it was opened. Remove it." + chmod 600 /proc/self/fd/9 } # The rule for the newest-run link: every read-compare-write or unlink of verify-xum.current, in From 7f7c2b5d1015f40e976050e63abdc26e9228fae9 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski <tk@coder.com> Date: Wed, 4 Nov 2026 00:01:16 +0000 Subject: [PATCH 43/51] =?UTF-8?q?=F0=9F=A4=96=20fix:=20verify-xum=20refuse?= =?UTF-8?q?s=20a=20git=20dir=20that=20another=20account=20can=20write?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The launch guard and the newest-run link live in the checkout's git dir. Point fixes for a peer-writable git dir (a symlink guard, a FIFO guard, a folder at the run link) kept coming, so every command now refuses a git dir, or a worktree's common git dir, that is not a real folder of this user or that group or others can write, the rule the run home's folders follow. The checks on the guard's open fd stay. The node FIFO helper is reverted: no other account can put a FIFO there. --- _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ --- .xum/skills/verify-xum/SKILL.md | 7 +++++-- .xum/skills/verify-xum/scripts/verify-xum.sh | 19 ++++++++++++++++--- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/.xum/skills/verify-xum/SKILL.md b/.xum/skills/verify-xum/SKILL.md index ae97411afa8..41613d4fd25 100644 --- a/.xum/skills/verify-xum/SKILL.md +++ b/.xum/skills/verify-xum/SKILL.md @@ -37,8 +37,11 @@ Surfaces: ## Launch -The script runs on Linux only: it needs `setsid`, `flock`, `ss` and `/proc`. Bun on PATH must be -the version in `package.json` `packageManager`. Launch refuses another Bun. +The script runs on Linux only: it needs `setsid`, `flock`, `ss` and `/proc`. Every command +refuses a checkout whose git dir (or, for a worktree, its common git dir) is not yours or is +writable by group or others: the launch lock and the newest-run link live there. Use a checkout +of your own, not a shared repository. Bun on PATH must be the version in `package.json` +`packageManager`. Launch refuses another Bun. ```bash .xum/skills/verify-xum/scripts/verify-xum.sh launch diff --git a/.xum/skills/verify-xum/scripts/verify-xum.sh b/.xum/skills/verify-xum/scripts/verify-xum.sh index 4917396daf5..4a1c6eac52b 100755 --- a/.xum/skills/verify-xum/scripts/verify-xum.sh +++ b/.xum/skills/verify-xum/scripts/verify-xum.sh @@ -142,9 +142,9 @@ private_run_dir() { echo "$run" } -# Opens the launch guard on fd 9 for flock. It sits in the checkout's git dir, which another -# account can write in a shared repository: a symlink planted there made `9>` truncate its target. -# The guard is created with O_EXCL (noclobber), which never follows a symlink, and opened read +# Opens the launch guard on fd 9 for flock. It sits in the checkout's git dir, which no other +# account can write (require_private_git_dirs). These checks are a second line: a symlink there +# made `9>` truncate its target. The guard is created with O_EXCL (noclobber), which never follows a symlink, and opened read # only, which can neither create nor truncate anything (flock needs no write access). Then fd 9 # must be the regular file of this user that the path names. Its mode is 0600: another account # that can read it can flock it and block every launch. A guard from an older version is 0644, @@ -693,6 +693,19 @@ cmd_cleanup() { echo "cleaned up run $VERIFY_XUM_RUN_ID; evidence kept in $VERIFY_XUM_EVIDENCE ($(find "$VERIFY_XUM_EVIDENCE" -type f | wc -l | tr -d ' ') files) and $VERIFY_XUM_RUN/server.log" } +# The newest-run link and the launch guard live in the checkout's git dir. Another account that +# could write there could swap either between a check and its use (a symlink, a FIFO, a folder), +# so every command refuses a git dir, or a worktree's common git dir, that is not a real folder +# of this user or that group or others can write: the rule for the run home's own folders. The +# checks on the guard's open fd stay as a second line. +require_private_git_dirs() { + local dir + for dir in "$CHECKOUT_GIT_DIR" "$(git -C "$CHECKOUT" rev-parse --path-format=absolute --git-common-dir)"; do + require_private_dir "$(cd -P -- "$dir" && pwd -P)" + done +} +require_private_git_dirs + case ${1:-} in launch) cmd_launch ;; doctor) cmd_doctor "${2:-}" ;; From ed843b41edeb825840f16df642b4375aa9e3a39c Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski <tk@coder.com> Date: Wed, 4 Nov 2026 01:10:39 +0000 Subject: [PATCH 44/51] =?UTF-8?q?=F0=9F=A4=96=20fix:=20verify-xum=20turns?= =?UTF-8?q?=20off=20agent-browser's=20stream=20before=20the=20token=20reac?= =?UTF-8?q?hes=20the=20browser?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - agent-browser 0.33 serves each session's screen and input as a WebSocket on 127.0.0.1 that any local account can connect to, and a new daemon starts it on its own. open now runs stream disable every time, refuses to open while stream status reports it on, and closes the session if it comes on during the open. stream disable also drops clients that connected before. - SKILL.md says to run the skill only where you are the only local account: Chrome's DevTools port on 127.0.0.1 asks for no credentials, and agent-browser cannot turn it off. - Launch keeps a copy of the token in the private run folder (0600), so capture and cleanup can redact while server.lock is gone (a backend restart). Cleanup deletes the copy last. --- _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ --- .xum/skills/verify-xum/SKILL.md | 10 +++++ .xum/skills/verify-xum/scripts/verify-xum.sh | 40 ++++++++++++++++++-- 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/.xum/skills/verify-xum/SKILL.md b/.xum/skills/verify-xum/SKILL.md index dfdcef53a38..59f744c088f 100644 --- a/.xum/skills/verify-xum/SKILL.md +++ b/.xum/skills/verify-xum/SKILL.md @@ -109,6 +109,16 @@ agent-browser find role button click --name "Open settings" --exact node from `server.lock` and hands it to `agent-browser batch` on stdin, so the token is in no command line or environment. CAUTION: never put the token in an `agent-browser` argument. Every local account can read a process's command line while it runs. +- CAUTION: Run this skill only on a host where you are the only local account. agent-browser + starts Chrome with a DevTools port on `127.0.0.1` (`--remote-debugging-port=0`), which asks for + no credentials and which agent-browser cannot turn off. While the signed-in app is open, any + local account that finds that port can control the browser, read the page and take the stored + token. +- `open` turns off agent-browser's live stream for the session first, every time, and refuses to + open the page while it is on. agent-browser 0.33 serves each session's screen and input as a + WebSocket on `127.0.0.1` that any local account can connect to, starts it with each new + daemon, and has no setting to keep it off. CAUTION: never run `agent-browser stream enable` in + a run's session. Another account could then watch and drive the signed-in app. - `idle` waits until the page settles: no CSS transition runs, and on a `/workspace/` page the `Conversation transcript` log is neither loading nor streaming (`aria-busy="false"`). A click while the right sidebar slides open lands where the target was and is lost. `idle` exits 3 diff --git a/.xum/skills/verify-xum/scripts/verify-xum.sh b/.xum/skills/verify-xum/scripts/verify-xum.sh index 76da0a9fd29..d143ecd359a 100755 --- a/.xum/skills/verify-xum/scripts/verify-xum.sh +++ b/.xum/skills/verify-xum/scripts/verify-xum.sh @@ -334,7 +334,7 @@ launch_failed() { } cmd_launch() { - local t0 backend_port vite_port log + local t0 backend_port vite_port log token need git curl node bun make setsid flock ss agent-browser check_bun LAUNCH_DEADLINE=$((SECONDS + READY_TIMEOUT)) @@ -414,6 +414,10 @@ cmd_launch() { backend_crashed_after_rebuild "$log" && die "the backend crashed after its rebuild (nodemon waits for a file change)" sleep 1 done + # Keep the token for redaction (run_token) while server.lock is gone. The run folder is 0700, + # and the file is 0600. + token=$(json_field_or_empty "$VERIFY_XUM_ROOT/server.lock" token) + [[ -z $token ]] || (umask 077 && printf '%s' "$token" >"$VERIFY_XUM_RUN/token") seed write_state @@ -494,6 +498,13 @@ cmd_open() { process.stderr.write("verify-xum: open PATH must stay on " + base + " and must not start with // once normalized\n"); process.exit(1); }' "$VERIFY_XUM_URL" "$path" || exit 1 + # agent-browser serves each session's screen and input as a WebSocket on 127.0.0.1 that every + # local account can connect to, and a new daemon starts it on its own (no setting keeps it + # off). Turn it off before the token reaches the browser, on every open because a restarted + # daemon starts it again. `stream disable` also drops connected clients, and starts the daemon + # when none runs: the browser it launches later keeps the stream off. + agent-browser --session "$AGENT_BROWSER_SESSION" stream disable >/dev/null 2>&1 || true + stream_is_off || die "agent-browser's stream for session $AGENT_BROWSER_SESSION is still on. Not opening the page with the token." # shellcheck disable=SC2001 # sed's character class; bash patterns have none out=$(node -e ' const fs = require("fs"); @@ -509,6 +520,16 @@ cmd_open() { | agent-browser --session "$AGENT_BROWSER_SESSION" batch --bail 2>&1) \ || die "agent-browser could not open $path: $(sed 's/token=[^&[:space:]]*/token=<redacted>/g' <<<"$out")" # The success output repeats the URL with the token: it is not printed. + if ! stream_is_off; then + agent-browser --session "$AGENT_BROWSER_SESSION" close >/dev/null 2>&1 || true + die "agent-browser's stream for session $AGENT_BROWSER_SESSION came on during the open. Closed the session." + fi +} + +# True when the session's agent-browser stream (see cmd_open) is off. +stream_is_off() { + [[ $(agent-browser --session "$AGENT_BROWSER_SESSION" stream status --json 2>/dev/null \ + | node -e 'try { console.log(JSON.parse(require("fs").readFileSync(0, "utf8")).data.enabled === false) } catch { console.log(false) }') == true ]] } # Prints FIELD (id, archivedAt, ...) of the workspace named NAME in the run's config.json, or @@ -532,6 +553,15 @@ cmd_workspace_field() { "$VERIFY_XUM_ROOT/config.json" "$name" "$field" } +# The backend's token: server.lock's, else the copy launch keeps in the private run folder. A +# restarting backend deletes server.lock for a moment, and a stopped one for good. +run_token() { + local token + token=$(json_field_or_empty "$VERIFY_XUM_ROOT/server.lock" token) + [[ -n $token || ! -f $VERIFY_XUM_RUN/token ]] || token=$(<"$VERIFY_XUM_RUN/token") + printf '%s' "$token" +} + # Evidence is meant to be shared, so no text in it may keep the backend's token: the page keeps # ?token= in its URL when the app fails before it strips it. This replaces the token (raw, and # URL-encoded as a path part and as a form value) and the value of any token= query parameter @@ -542,7 +572,7 @@ cmd_workspace_field() { REDACT_TOKEN= redact_token() { # redact_token [PATH...] local token=$REDACT_TOKEN - [[ -n $token ]] || token=$(json_field_or_empty "$VERIFY_XUM_ROOT/server.lock" token) + [[ -n $token ]] || token=$(run_token) # shellcheck disable=SC2016 # $1 is the regex group, for node node -e ' const fs = require("fs"); @@ -616,8 +646,8 @@ is_dev_server_sandbox_root() { cmd_cleanup() { load_run "${1:-}" local i - # Read the token while server.lock exists: stopping the group makes the sandbox delete its root. - REDACT_TOKEN=$(json_field_or_empty "$VERIFY_XUM_ROOT/server.lock" token) + # Read the token before the group stops: the backend then deletes server.lock. + REDACT_TOKEN=$(run_token) # agent-browser `close` on a session that does not exist starts that session first. if [[ $(agent-browser session list 2>/dev/null) == *"$AGENT_BROWSER_SESSION"* ]]; then agent-browser --session "$AGENT_BROWSER_SESSION" close >/dev/null 2>&1 || true @@ -643,6 +673,8 @@ cmd_cleanup() { [[ $(readlink "$CURRENT_RUN_LINK" 2>/dev/null) == "$VERIFY_XUM_RUN" ]] && rm -f "$CURRENT_RUN_LINK" VERIFY_XUM_PGID= write_state + # Last: the redaction above used it. + rm -f "$VERIFY_XUM_RUN/token" echo "cleaned up run $VERIFY_XUM_RUN_ID; evidence kept in $VERIFY_XUM_EVIDENCE ($(find "$VERIFY_XUM_EVIDENCE" -type f | wc -l | tr -d ' ') files) and $VERIFY_XUM_RUN/server.log" } From 017261906ff5a8b229d288e12c59459a18a0249d Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski <tk@coder.com> Date: Wed, 4 Nov 2026 01:43:20 +0000 Subject: [PATCH 45/51] =?UTF-8?q?=F0=9F=A4=96=20fix:=20verify-xum=20open?= =?UTF-8?q?=20falls=20back=20to=20the=20run's=20token=20copy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit open read the token from server.lock only, so an open during a backend restart, while server.lock is gone, reached the app without it and ended at the token prompt. It now takes the token from run_token (server.lock, else the run's 0600 copy) and hands it to node on fd 3. --- _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ --- .xum/skills/verify-xum/scripts/verify-xum.sh | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/.xum/skills/verify-xum/scripts/verify-xum.sh b/.xum/skills/verify-xum/scripts/verify-xum.sh index 6944c48fd56..3f1ae84aa4e 100755 --- a/.xum/skills/verify-xum/scripts/verify-xum.sh +++ b/.xum/skills/verify-xum/scripts/verify-xum.sh @@ -532,7 +532,7 @@ cmd_env() { # the token from server.lock and pipes the command to `agent-browser batch` on stdin, so the token # is in no process's argv or environment: every local account can read argv. cmd_open() { - local path=${1:-} out + local path=${1:-} out token [[ $path == /* ]] || die "open PATH must start with / (for example /workspace/<id>)" load_run "${2:-}" # Checked before agent-browser runs. A PATH like //host, /\host or /<tab>/host resolves to @@ -552,18 +552,18 @@ cmd_open() { # when none runs: the browser it launches later keeps the stream off. agent-browser --session "$AGENT_BROWSER_SESSION" stream disable >/dev/null 2>&1 || true stream_is_off || die "agent-browser's stream for session $AGENT_BROWSER_SESSION is still on. Not opening the page with the token." + # server.lock's token, else the run's copy: a restarting backend deletes server.lock for a + # moment. node gets it on fd 3, never in argv. + token=$(run_token) # shellcheck disable=SC2001 # sed's character class; bash patterns have none out=$(node -e ' const fs = require("fs"); - const [serverLock, base, path] = process.argv.slice(1); - let token = ""; - try { - token = JSON.parse(fs.readFileSync(serverLock, "utf8")).token ?? ""; - } catch {} + const [base, path] = process.argv.slice(1); + const token = fs.readFileSync(3, "utf8"); const url = new URL(path, base); if (token) url.searchParams.set("token", token); process.stdout.write(JSON.stringify([["open", url.href]]));' \ - "$VERIFY_XUM_ROOT/server.lock" "$VERIFY_XUM_URL" "$path" \ + "$VERIFY_XUM_URL" "$path" 3< <(printf %s "$token") \ | agent-browser --session "$AGENT_BROWSER_SESSION" batch --bail 2>&1) \ || die "agent-browser could not open $path: $(sed 's/token=[^&[:space:]]*/token=<redacted>/g' <<<"$out")" # The success output repeats the URL with the token: it is not printed. From 8269446fe5a432836b9edacc65eb3ec6a7f57836 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski <tk@coder.com> Date: Wed, 4 Nov 2026 01:43:22 +0000 Subject: [PATCH 46/51] =?UTF-8?q?=F0=9F=A4=96=20fix:=20verify-xum=20review?= =?UTF-8?q?=20toggle=20waits=20for=20each=20sidebar=20state?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The recipe pressed Ctrl+Shift+B twice and checked only the end state, so a shortcut that did nothing passed. It now waits for Expand right sidebar after the first press and Collapse right sidebar after the second. --- _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ --- .xum/skills/verify-xum/features/review.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.xum/skills/verify-xum/features/review.md b/.xum/skills/verify-xum/features/review.md index 43543ab9f45..052807f4d77 100644 --- a/.xum/skills/verify-xum/features/review.md +++ b/.xum/skills/verify-xum/features/review.md @@ -61,9 +61,11 @@ Preconditions: `$VERIFY_XUM_ROOT/sessions/$VERIFY_XUM_WORKSPACE_ID/review-state.json` has `"isRead": true` for that hunk. Capture `review/read`. -- **Keyboard toggle.** Run `agent-browser press Control+Shift+B`. The sidebar collapses, and the - button `Expand right sidebar` is back. Run `agent-browser press Control+Shift+B` again. The - button reads `Collapse right sidebar`, and the tab `Review 1/1 Close Review` is selected. +- **Keyboard toggle.** Run `agent-browser press Control+Shift+B` and + `agent-browser wait '[aria-label="Expand right sidebar"]'`. The sidebar collapses, and the + button `Expand right sidebar` is back. Run `agent-browser press Control+Shift+B` again and + `agent-browser wait '[aria-label="Collapse right sidebar"]'`. The button reads + `Collapse right sidebar`, and the tab `Review 1/1 Close Review` is selected. - **Restore.** The sidebar slid open again: run `verify-xum.sh idle`, then `agent-browser find role button click --name "Mark as read (M)" --exact`. The tab reads `Review 0/1` at once, and the app writes `review-state.json` a moment later. Wait for From f02d0b04fa5408239e0f02a662e3fb5618d737f2 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski <tk@coder.com> Date: Wed, 4 Nov 2026 07:46:24 +0000 Subject: [PATCH 47/51] =?UTF-8?q?=F0=9F=A4=96=20fix:=20keep=20verify-xum?= =?UTF-8?q?=20run=20homes=20outside=20Vite's=20file=20root?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A run home inside the checkout publishes the token cache and evidence through Vite. File modes do not help because Vite runs as the same user. Launch now refuses that home before creating any folder. Every command that loads a run checks it too, including a run an older version left there. The check resolves symlinks and .. physically and covers both the current checkout and the main worktree of a linked checkout. --- _Generated with `xum` • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh` • Cost: `$153.98`_ <!-- mux-attribution: model=anthropic:claude-opus-5-5 thinking=xhigh costs=153.98 --> --- .xum/skills/verify-xum/SKILL.md | 3 +++ .xum/skills/verify-xum/scripts/verify-xum.sh | 22 +++++++++++++++++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/.xum/skills/verify-xum/SKILL.md b/.xum/skills/verify-xum/SKILL.md index 59f744c088f..01537f34a13 100644 --- a/.xum/skills/verify-xum/SKILL.md +++ b/.xum/skills/verify-xum/SKILL.md @@ -153,6 +153,9 @@ agent-browser find role button click --name "Open settings" --exact Evidence goes to `$VERIFY_XUM_EVIDENCE`, which is `$VERIFY_XUM_HOME/<run>/evidence`. `VERIFY_XUM_HOME` defaults to `$XUM_SCRATCH_DIR/verify-xum`, else `$XDG_RUNTIME_DIR/verify-xum`, else `$HOME/.cache/verify-xum`, and never to a shared TMPDIR. Launch creates it with mode 0700. +Launch and every command that reads a run refuse a home inside the checkout (or inside the main +worktree of a linked checkout), symlinks and `..` resolved: Vite serves every file there to +anyone who reaches its port, the run's token copy and evidence included. Every command that reads a run's `state.env` first checks that the home and the run folder are real folders (no symlinks), owned by you and not writable by group or others, and refuses otherwise: the script sources that file. Cleanup never diff --git a/.xum/skills/verify-xum/scripts/verify-xum.sh b/.xum/skills/verify-xum/scripts/verify-xum.sh index d143ecd359a..f29cc3c5b3f 100755 --- a/.xum/skills/verify-xum/scripts/verify-xum.sh +++ b/.xum/skills/verify-xum/scripts/verify-xum.sh @@ -118,6 +118,23 @@ without_trailing_slashes() { echo "$path" } +# Vite serves every file under the checkout to whoever reaches its port (vite.config.ts denies only +# a few names), so a run home there would publish the run's token copy, its evidence and its +# sandbox. Refuses a home whose physical path (symlinks and .. resolved, even for parts that do +# not exist yet) is inside the checkout, or inside the main worktree of a linked one. +require_home_outside_checkout() { # require_home_outside_checkout <home> + local home top common + home=$(realpath -m -- "$1") + common=$(git -C "$CHECKOUT" rev-parse --path-format=absolute --git-common-dir) + for top in "$CHECKOUT" "$([[ $(basename "$common") == .git ]] && dirname "$common")"; do + [[ -n $top ]] || continue + top=$(realpath -m -- "$top") + if [[ $top == / || $home == "$top" || $home == "$top"/* ]]; then + die "the run home $home is inside the checkout $top, where Vite serves every file to anyone who reaches its port (the run's token too). Set VERIFY_XUM_HOME to a folder outside it." + fi + done +} + # The physical path of a run home, after checking it and every folder above it. Later reads use # that path, so no symlink above the home is followed again. checked_home() { @@ -125,6 +142,7 @@ checked_home() { home=$(without_trailing_slashes "$1") [[ -d $home && ! -L $home ]] || die "$home is not a real folder" home=$(cd -P -- "$home" && pwd -P) + require_home_outside_checkout "$home" require_private_dir "$home" require_safe_ancestors "$home" echo "$home" @@ -335,11 +353,13 @@ launch_failed() { cmd_launch() { local t0 backend_port vite_port log token - need git curl node bun make setsid flock ss agent-browser + need git curl node bun make setsid flock ss realpath agent-browser check_bun LAUNCH_DEADLINE=$((SECONDS + READY_TIMEOUT)) refuse_while_checkout_server_runs + # Before any folder is made: a refused home creates nothing. + require_home_outside_checkout "$VERIFY_XUM_HOME" if [[ ! -e $VERIFY_XUM_HOME ]]; then # Parents this creates are private too, whatever the caller's umask. (umask 077 && mkdir -p "$(dirname "$VERIFY_XUM_HOME")") From 83da137e2be87a32e67736105fbc997cd03c2a18 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski <tk@coder.com> Date: Wed, 4 Nov 2026 08:06:25 +0000 Subject: [PATCH 48/51] =?UTF-8?q?=F0=9F=A4=96=20fix:=20keep=20verify-xum?= =?UTF-8?q?=20run=20homes=20outside=20Vite's=20workspace=20root?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vite allows its detected workspace root (server.fs.allow defaults to searchForWorkspaceRoot(root)), and that root can be a parent of the checkout, for example a folder whose package.json declares workspaces. A run home under that parent passed the checkout check, and Vite served <run>/token to an unauthenticated request. Every home check now asks the checkout's own vite for that root (searchForWorkspaceRoot of the physical checkout, as vite started there computes it) and refuses a home inside it, in addition to the checkout and the main worktree. When vite cannot answer, the check refuses. --- _Generated with `xum` • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh` • Cost: `$166.57`_ <!-- mux-attribution: model=anthropic:claude-opus-5-5 thinking=xhigh costs=166.57 --> --- .xum/skills/verify-xum/SKILL.md | 8 +++--- .xum/skills/verify-xum/scripts/verify-xum.sh | 26 +++++++++++++++++--- 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/.xum/skills/verify-xum/SKILL.md b/.xum/skills/verify-xum/SKILL.md index 01537f34a13..ecb28936a2e 100644 --- a/.xum/skills/verify-xum/SKILL.md +++ b/.xum/skills/verify-xum/SKILL.md @@ -153,9 +153,11 @@ agent-browser find role button click --name "Open settings" --exact Evidence goes to `$VERIFY_XUM_EVIDENCE`, which is `$VERIFY_XUM_HOME/<run>/evidence`. `VERIFY_XUM_HOME` defaults to `$XUM_SCRATCH_DIR/verify-xum`, else `$XDG_RUNTIME_DIR/verify-xum`, else `$HOME/.cache/verify-xum`, and never to a shared TMPDIR. Launch creates it with mode 0700. -Launch and every command that reads a run refuse a home inside the checkout (or inside the main -worktree of a linked checkout), symlinks and `..` resolved: Vite serves every file there to -anyone who reaches its port, the run's token copy and evidence included. +Launch and every command that reads a run refuse a home inside the checkout, inside the main +worktree of a linked checkout, or inside the workspace root that the checkout's Vite reports +(`searchForWorkspaceRoot`, which can be a parent folder), symlinks and `..` resolved: Vite serves +every file there to anyone who reaches its port, the run's token copy and evidence included. +They also refuse when that Vite cannot report its root (run `bun install`). Every command that reads a run's `state.env` first checks that the home and the run folder are real folders (no symlinks), owned by you and not writable by group or others, and refuses otherwise: the script sources that file. Cleanup never diff --git a/.xum/skills/verify-xum/scripts/verify-xum.sh b/.xum/skills/verify-xum/scripts/verify-xum.sh index f29cc3c5b3f..4dce1ad48ed 100755 --- a/.xum/skills/verify-xum/scripts/verify-xum.sh +++ b/.xum/skills/verify-xum/scripts/verify-xum.sh @@ -118,19 +118,37 @@ without_trailing_slashes() { echo "$path" } +# The folder Vite allows by default (server.fs.allow): the workspace root it finds at or above the +# checkout, which can be a parent folder (one whose package.json declares workspaces, for example). +# The checkout's own vite computes it from the physical checkout, as `vite` started there does, so +# this check cannot drift from Vite's rules. Fails closed: without an answer, no home is safe. +vite_workspace_root() { + local root + root=$(cd -P -- "$CHECKOUT" && node --input-type=module -e ' + import { createRequire } from "node:module"; + import { pathToFileURL } from "node:url"; + const vite = createRequire(process.cwd() + "/package.json").resolve("vite"); + console.log((await import(pathToFileURL(vite).href)).searchForWorkspaceRoot(process.cwd())); + ') && [[ $root == /* && $root != *$'\n'* ]] \ + || die "the checkout's vite did not report its workspace root, so the run home cannot be checked against the folders Vite serves. Run \`bun install\` in $CHECKOUT." + echo "$root" +} + # Vite serves every file under the checkout to whoever reaches its port (vite.config.ts denies only # a few names), so a run home there would publish the run's token copy, its evidence and its # sandbox. Refuses a home whose physical path (symlinks and .. resolved, even for parts that do -# not exist yet) is inside the checkout, or inside the main worktree of a linked one. +# not exist yet) is inside the checkout, inside the main worktree of a linked one, or inside +# Vite's workspace root. require_home_outside_checkout() { # require_home_outside_checkout <home> - local home top common + local home top common vite_root home=$(realpath -m -- "$1") common=$(git -C "$CHECKOUT" rev-parse --path-format=absolute --git-common-dir) - for top in "$CHECKOUT" "$([[ $(basename "$common") == .git ]] && dirname "$common")"; do + vite_root=$(vite_workspace_root) || exit 1 + for top in "$CHECKOUT" "$([[ $(basename "$common") == .git ]] && dirname "$common")" "$vite_root"; do [[ -n $top ]] || continue top=$(realpath -m -- "$top") if [[ $top == / || $home == "$top" || $home == "$top"/* ]]; then - die "the run home $home is inside the checkout $top, where Vite serves every file to anyone who reaches its port (the run's token too). Set VERIFY_XUM_HOME to a folder outside it." + die "the run home $home is inside $top (the checkout, its main worktree or Vite's workspace root), where Vite serves every file to anyone who reaches its port (the run's token too). Set VERIFY_XUM_HOME to a folder outside it." fi done } From b4a36ba934d604117ef89f35a7c317ef2830d6cc Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski <tk@coder.com> Date: Wed, 4 Nov 2026 08:31:26 +0000 Subject: [PATCH 49/51] =?UTF-8?q?=F0=9F=A4=96=20fix:=20verify-xum=20starts?= =?UTF-8?q?=20make=20only=20after=20the=20server=20group's=20id=20is=20rec?= =?UTF-8?q?orded?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new group's leader appended its id to state.env and then closed the launch flock and ran make even when that append failed (read-only file, full disk). The server then ran with no recorded group: launch_failed could not stop it, cleanup dropped the newest-run link, and the next launch could start a second server in the same checkout. The leader now exits when the append fails, before it closes the flock or starts make. The command-palette recipe now waits until the palette is gone after Escape, so the next shortcut cannot close a palette that is still open. --- _Generated with `xum` • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh` • Cost: `$169.60`_ <!-- mux-attribution: model=anthropic:claude-opus-5-5 thinking=xhigh costs=169.60 --> --- .xum/skills/verify-xum/features/command-palette.md | 6 ++++-- .xum/skills/verify-xum/scripts/verify-xum.sh | 5 ++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/.xum/skills/verify-xum/features/command-palette.md b/.xum/skills/verify-xum/features/command-palette.md index 826c2e0c6c3..e424dfedf93 100644 --- a/.xum/skills/verify-xum/features/command-palette.md +++ b/.xum/skills/verify-xum/features/command-palette.md @@ -26,8 +26,10 @@ Preconditions: `agent-browser wait '[aria-label="Command palette"]'` and `agent-browser wait '[role="listbox"][aria-label="Suggestions"] [role="option"]'`: on the first open of a page the list fills a moment after the combobox shows. The combobox - `Command palette` is expanded, with the listbox `Suggestions`. Run `agent-browser press Escape`. - The combobox is gone. + `Command palette` is expanded, with the listbox `Suggestions`. Run `agent-browser press Escape` + and `agent-browser wait --fn '!document.querySelector("[aria-label=\"Command palette\"]")'`. + The combobox is gone. The press can return before the palette closes, and the next shortcut + would then close the palette that is still open instead of opening it. - **Switch workspace.** Press Ctrl+Shift+P, wait for the palette as above, then run `agent-browser keyboard type "release"` and `agent-browser wait '[role="option"][aria-selected="true"][aria-label="release-notes"]'`. diff --git a/.xum/skills/verify-xum/scripts/verify-xum.sh b/.xum/skills/verify-xum/scripts/verify-xum.sh index f50465f4570..1363ee67e92 100755 --- a/.xum/skills/verify-xum/scripts/verify-xum.sh +++ b/.xum/skills/verify-xum/scripts/verify-xum.sh @@ -464,9 +464,12 @@ cmd_launch() { # make. From then on the launch guard and cleanup find the group, wherever the launcher dies. # The leader carries VERIFY_XUM_SERVER_OF from its start, so group_is_ours matches it as soon # as the id is written. It holds the launch flock (fd 9) until then and closes it before make. + # A failed write (read-only file, full disk) ends the leader before it closes the flock or starts + # make: a server whose group nobody recorded is one that cleanup cannot stop, and once cleanup + # drops the newest-run link, the next launch would start a second server next to it. # (setsid(1) forks when its caller already leads a process group, so $! can miss the group.) # shellcheck disable=SC2016 - VERIFY_XUM_SERVER_OF="$VERIFY_XUM_RUN_ID" setsid sh -c 'printf "export VERIFY_XUM_PGID=%s\n" "$$" >>"$1"; exec 9>&-; shift; exec "$@"' sh \ + VERIFY_XUM_SERVER_OF="$VERIFY_XUM_RUN_ID" setsid sh -c 'printf "export VERIFY_XUM_PGID=%s\n" "$$" >>"$1" || exit 1; exec 9>&- && shift && exec "$@"' sh \ "$VERIFY_XUM_RUN/state.env" "${env_without_live_xum[@]}" \ make -C "$CHECKOUT" dev-server-sandbox DEV_SERVER_SANDBOX_ARGS="--clean-providers --clean-projects" \ >"$log" 2>&1 </dev/null & From 31bf25da8f8242597820ec60511524fe95744f88 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski <tk@coder.com> Date: Wed, 4 Nov 2026 08:35:20 +0000 Subject: [PATCH 50/51] =?UTF-8?q?=F0=9F=A4=96=20fix:=20verify-xum's=20Vite?= =?UTF-8?q?=20root=20check=20uses=20an=20explicit=20if/else?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI's shellcheck (0.8.0) reports SC2015 on `A && B || die`. The check is now an if/else with the same result: vite_workspace_root prints the root only when the checkout's vite reports one absolute path, and refuses otherwise. --- _Generated with `xum` • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh` • Cost: `$171.54`_ <!-- mux-attribution: model=anthropic:claude-opus-5-5 thinking=xhigh costs=171.54 --> --- .xum/skills/verify-xum/scripts/verify-xum.sh | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/.xum/skills/verify-xum/scripts/verify-xum.sh b/.xum/skills/verify-xum/scripts/verify-xum.sh index 4dce1ad48ed..dc01c324e75 100755 --- a/.xum/skills/verify-xum/scripts/verify-xum.sh +++ b/.xum/skills/verify-xum/scripts/verify-xum.sh @@ -124,14 +124,16 @@ without_trailing_slashes() { # this check cannot drift from Vite's rules. Fails closed: without an answer, no home is safe. vite_workspace_root() { local root - root=$(cd -P -- "$CHECKOUT" && node --input-type=module -e ' + if root=$(cd -P -- "$CHECKOUT" && node --input-type=module -e ' import { createRequire } from "node:module"; import { pathToFileURL } from "node:url"; const vite = createRequire(process.cwd() + "/package.json").resolve("vite"); console.log((await import(pathToFileURL(vite).href)).searchForWorkspaceRoot(process.cwd())); - ') && [[ $root == /* && $root != *$'\n'* ]] \ - || die "the checkout's vite did not report its workspace root, so the run home cannot be checked against the folders Vite serves. Run \`bun install\` in $CHECKOUT." - echo "$root" + ') && [[ $root == /* && $root != *$'\n'* ]]; then + echo "$root" + else + die "the checkout's vite did not report its workspace root, so the run home cannot be checked against the folders Vite serves. Run \`bun install\` in $CHECKOUT." + fi } # Vite serves every file under the checkout to whoever reaches its port (vite.config.ts denies only From b48bddeeec51d52b7ea7b57df21e311144a1b5f1 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski <tk@coder.com> Date: Wed, 4 Nov 2026 10:02:51 +0000 Subject: [PATCH 51/51] =?UTF-8?q?=F0=9F=A4=96=20fix:=20verify-xum=20refuse?= =?UTF-8?q?s=20a=20folder=20at=20the=20run=20link=20and=20writable=20folde?= =?UTF-8?q?rs=20above=20the=20git=20dirs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A folder at .git/verify-xum.current made ln -sfn put the new link inside it, so the next launch did not see the live run and started a second server. Launch now refuses anything but a symlink there before it makes anything, and the link is written with ln -T. The git-dir rule checked only the git dirs themselves. A group-writable folder above a private .git let another account rename the whole git dir away. Every command now also checks the folders above both git dirs with the run home's rule (owned by root or you, not writable by group or others unless sticky). The phone-width proof standard now allows the phone layout's own path to a control, or a capture that it is absent. --- _Generated with `xum` • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh` • Cost: `$173.69`_ <!-- mux-attribution: model=anthropic:claude-opus-5-5 thinking=xhigh costs=173.69 --> --- .xum/skills/verify-xum/SKILL.md | 11 ++++--- .xum/skills/verify-xum/scripts/verify-xum.sh | 30 +++++++++++++------- 2 files changed, 26 insertions(+), 15 deletions(-) diff --git a/.xum/skills/verify-xum/SKILL.md b/.xum/skills/verify-xum/SKILL.md index f22130d5a4c..455ead811fd 100644 --- a/.xum/skills/verify-xum/SKILL.md +++ b/.xum/skills/verify-xum/SKILL.md @@ -39,8 +39,9 @@ Surfaces: The script runs on Linux only: it needs `setsid`, `flock`, `ss` and `/proc`. Every command refuses a checkout whose git dir (or, for a worktree, its common git dir) is not yours or is -writable by group or others: the launch lock and the newest-run link live there. Use a checkout -of your own, not a shared repository. Bun on PATH must be the version in `package.json` +writable by group or others, and one with a folder above that git dir that is not yours or +root's, or that group or others can write without the sticky bit: the launch lock and the +newest-run link live there. Use a checkout of your own, not a shared repository. Bun on PATH must be the version in `package.json` `packageManager`. Launch refuses another Bun. ```bash @@ -192,8 +193,10 @@ Proof standards: `verify-xum.sh idle`, repeat the changed recipe's steps and captures with names that end in `-375`, and look for clipped text, content past the right edge (`agent-browser eval 'document.documentElement.scrollWidth <= innerWidth'` prints `true`) and - controls that cover each other. Run `agent-browser set viewport 1280 720` afterwards. A change - with no UI needs no phone pass: say so. + controls that cover each other. When the phone layout reaches a step's control another way, or + does not show it, follow the phone layout's own path to it or capture that it is absent, and + say which. Run `agent-browser set viewport 1280 720` afterwards. A change with no UI needs no + phone pass: say so. - Check side effects next to what is visible: rows in `$VERIFY_XUM_ROOT/sessions/<workspace id>/chat.jsonl`, entries in `$VERIFY_XUM_ROOT/config.json`, git state in the worktree, or the CLI view above. diff --git a/.xum/skills/verify-xum/scripts/verify-xum.sh b/.xum/skills/verify-xum/scripts/verify-xum.sh index 5506e29e48d..54bca5e62c3 100755 --- a/.xum/skills/verify-xum/scripts/verify-xum.sh +++ b/.xum/skills/verify-xum/scripts/verify-xum.sh @@ -95,18 +95,18 @@ require_private_dir() { (((8#$mode & 8#022) == 0)) || die "$dir is writable by group or others (mode $mode)" } -# Every folder above a run home must be owned by root or this user and writable by nobody else, -# unless it is sticky like /tmp (as ssh's StrictModes checks): another account could otherwise -# rename the checked home away and put its own state.env in its place. Takes a physical path. -require_safe_ancestors() { - local dir=$1 owner mode +# Every folder above a run home (or a git dir) must be owned by root or this user and writable by +# nobody else, unless it is sticky like /tmp (as ssh's StrictModes checks): another account could +# otherwise rename the checked folder away and put its own in its place. Takes a physical path. +require_safe_ancestors() { # require_safe_ancestors <path> [<what it is, for messages>] + local dir=$1 what=${2:-the run home} owner mode while [[ $dir != / ]]; do dir=$(dirname "$dir") owner=$(stat -c %u "$dir") mode=$(stat -c %a "$dir") - [[ $owner == 0 || $owner == "$(id -u)" ]] || die "$dir, above the run home, is owned by user $owner" + [[ $owner == 0 || $owner == "$(id -u)" ]] || die "$dir, above $what, is owned by user $owner" (((8#$mode & 8#022) == 0 || (8#$mode & 8#1000) != 0)) \ - || die "$dir, above the run home, is writable by group or others and not sticky (mode $mode)" + || die "$dir, above $what, is writable by group or others and not sticky (mode $mode)" done } @@ -216,7 +216,8 @@ release_launch_guard() { point_current_run_link_at() { # point_current_run_link_at <run> [[ -n $LAUNCH_GUARD_HELD ]] || die "internal error: the newest-run link changed without the launch guard" - ln -sfn "$1" "$CURRENT_RUN_LINK" + # -T: a folder at the link path fails the launch instead of getting the link inside it. + ln -sfnT "$1" "$CURRENT_RUN_LINK" } drop_current_run_link_if() { # drop_current_run_link_if <run>: unlink it when it names <run> @@ -237,6 +238,10 @@ drop_current_run_link_if() { # drop_current_run_link_if <run>: unlink it when it refuse_while_checkout_server_runs() { # Part of the launch deadline: a short VERIFY_XUM_READY_TIMEOUT also bounds this wait. hold_launch_guard "$(launch_seconds_left)" || die "another verify-xum launch from this checkout holds $LAUNCH_GUARD" + # Only a symlink belongs there: with a folder in its place, the next launch would not see this + # one's run and would start a second server. + [[ ! -e $CURRENT_RUN_LINK || -L $CURRENT_RUN_LINK ]] \ + || die "$CURRENT_RUN_LINK is not a symlink (a folder?). Remove it, then launch again." [[ -f $CURRENT_RUN_LINK/state.env ]] || return 0 local run run=$(private_run_dir "$CURRENT_RUN_LINK") || exit 1 @@ -771,12 +776,15 @@ cmd_cleanup() { # The newest-run link and the launch guard live in the checkout's git dir. Another account that # could write there could swap either between a check and its use (a symlink, a FIFO, a folder), # so every command refuses a git dir, or a worktree's common git dir, that is not a real folder -# of this user or that group or others can write: the rule for the run home's own folders. The -# checks on the guard's open fd stay as a second line. +# of this user or that group or others can write: the rule for the run home and the folders +# above it. A writable folder above would let another account rename the whole git dir away and +# put its own in its place. The checks on the guard's open fd stay as a second line. require_private_git_dirs() { local dir for dir in "$CHECKOUT_GIT_DIR" "$(git -C "$CHECKOUT" rev-parse --path-format=absolute --git-common-dir)"; do - require_private_dir "$(cd -P -- "$dir" && pwd -P)" + dir=$(cd -P -- "$dir" && pwd -P) + require_private_dir "$dir" + require_safe_ancestors "$dir" "the git dir $dir" done } require_private_git_dirs