diff --git a/AGENT-VM.md b/AGENT-VM.md index 0d00461..1efdac5 100644 --- a/AGENT-VM.md +++ b/AGENT-VM.md @@ -36,6 +36,9 @@ project's original absolute path**, which exists in the guest. Then **Settings → Models** to configure DeepSeek, if you haven't already. Existing credentials/settings are reused. No API key is required merely to boot the UI. No model inference runs locally: the VM's RAM/CPUs are for tools/builds, not model weights. +The VM is **headless by default**: use DSH's Web UI from your host browser; no +VM desktop or display forwarding is started. Browser automation uses headless +Firefox inside the VM, as described below. The template input `path:/etc/nix/agent-vm` is a local bootstrap. For collaboration or guest-side evaluation of the flake, replace it with your accessible Git remote, @@ -65,6 +68,9 @@ Missing directories are created. Existing DSH home must be user-owned and privat `chmod 700 ~/.dsh` (or your actual `DSH_HOME`); existing credentials must be mode 600 as DSH requires. The launcher never reads the credentials into Nix, copies them to the store, or replaces your settings/skills with generated content. +The one bundled `playwright-firefox` skill is seeded into the actual shared skills +directory only if its `SKILL.md` is absent. It is a normal writable file (0600), +not a store symlink; existing files and user edits are never overwritten. Root inside the guest is **not host root**. QEMU/9p writes as the launching host user, so newly created project files belong to you. The whole DSH directory is @@ -108,7 +114,8 @@ memory overhead beyond guest RAM. The standard guest toolkit includes Git/LFS, ripgrep, fd, Python, Node/npm/pnpm, jq/yq, curl/wget, common archive tools, Make, pkg-config, ShellCheck, and ordinary -Unix inspection tools. Project versions/tools belong in `project.nix`. +Unix inspection tools, plus the official **Playwright CLI and patched Firefox**. +Project versions/tools belong in `project.nix`. This deliberately shares **packages and non-secret environment variables**, not an arbitrary shell's internals. Existing `shellHook`, `inputsFrom`, cross-compilation @@ -117,6 +124,57 @@ converted into NixOS configuration. Factor tools/env into the shared attrset; configure required guest services through `modules`. Do not put credentials in `project.env`. Build outputs/package definitions can use the same `pkgs` input. +## Firefox automation and subagent sessions + +DSH discovers the bundled [playwright-firefox skill](agent-vm/skills/playwright-firefox/SKILL.md) +from `/root/.agents/skills/playwright-firefox/SKILL.md`, which is the live host +`${DSH_AGENTS_HOME:-~/.agents}/skills/playwright-firefox/SKILL.md`. No changes to +`settings.yaml`, credentials, plugins or external MCP configuration are needed. +Ask DSH/subagents to use **playwright-firefox** for browser tasks. + +The `playwright-cli` executable uses the official CLI shipped in Playwright core +(`playwright-core cli`), with its matching Nixpkgs Firefox and rendering fonts. +It defaults to **Firefox, headless, nonpersistent profiles**. This avoids an +independently updated npm CLI demanding a different browser revision, or trying +to automate ordinary Firefox without Playwright's Juggler support. CLI, patched +browser and dependencies advance together with the consuming flake's Nixpkgs +input; no handwritten Playwright version/hash or runtime browser download. + +Each subagent must use its own unique name and artifact directory. For example, +inside the guest, from the same project cwd throughout: + +```sh +# Parent-assigned examples: give each task a different suffix. +PLAYWRIGHT_MCP_OUTPUT_DIR="$PWD/.playwright-cli/login-a1b2c3d4" \ + playwright-cli -s=login-a1b2c3d4 open http://127.0.0.1:3000 --browser=firefox +PLAYWRIGHT_MCP_OUTPUT_DIR="$PWD/.playwright-cli/search-e5f6a7b8" \ + playwright-cli -s=search-e5f6a7b8 open http://127.0.0.1:3000 --browser=firefox +playwright-cli -s=login-a1b2c3d4 snapshot +playwright-cli -s=search-e5f6a7b8 eval 'document.title' +playwright-cli list +playwright-cli -s=login-a1b2c3d4 close # leaves the other subagent alone +playwright-cli -s=search-e5f6a7b8 close +``` + +Cookies, DOM, tabs and storage are independent between named sessions. This is +**not a security boundary** between root-running subagents. The skill requires +explicit `-s=` on every browser command, unique per-task output paths, modest +parallelism (normally two browsers with 4 GiB RAM), and scoped cleanup. It forbids +`close-all`, `kill-all`, global process killing and accidental use of the default +session. Profiles are ephemeral unless persistence is explicitly requested; +screenshots/traces/auth-state exports can contain secrets. New templates ignore +`.playwright-cli/`; add that ignore rule to existing projects too. + +The application server must run **inside the VM** for guest `127.0.0.1` URLs. +Headed operation is opt-in (`open --headed`) and needs a separately configured +guest display; the default VM provides none and never mounts your host desktop. +Do not import your normal browser profile or install another browser/CLI with npm. + +After updating the helper input, stop/restart the project VM to get the new tools +and initial skill. Existing skill edits deliberately survive restarts and updates; +merge later changes from the repository's skill file yourself rather than silently +replacing local instructions. This does not restart an already-running VM. + ## Networking: bind address versus VM interface DSH intentionally rejects `dsh web --host 0.0.0.0`. We don't patch around its @@ -238,8 +296,9 @@ blanket-approving everything. The built-in MCP bridge can also connect to popula projects such as [Context7](https://github.com/upstash/context7) and [Playwright MCP](https://github.com/microsoft/playwright-mcp); those are separate MCP services, not evidence that a particular DSH adapter is widely deployed. -Playwright needs its browsers/dependencies inside the guest. No browser sessions, -external MCP credentials or arbitrary host skill symlink targets are imported. +The bundled Firefox **CLI + skill** above does not need Playwright MCP; use the +separate server only if you explicitly need that interface. No host browser +sessions, external MCP credentials or arbitrary host skill symlink targets are imported. ## Sandbox boundary and limitations @@ -274,12 +333,19 @@ external MCP credentials or arbitrary host skill symlink targets are imported. ```sh nix flake check path:/etc/nix/agent-vm --no-build nix build path:/etc/nix/agent-vm#checks.x86_64-linux.config \ - path:/etc/nix/agent-vm#checks.x86_64-linux.shell --no-link -# Actual offline microVM boot/mount/SSH test, with a fake harness (no API calls): + path:/etc/nix/agent-vm#checks.x86_64-linux.shell \ + path:/etc/nix/agent-vm#checks.x86_64-linux.playwright --no-link +# Actual offline microVM boot/mount/SSH/browser test, with a fake harness (no API calls): nix build path:/etc/nix/agent-vm#checks.x86_64-linux.boot --no-link ``` The boot test requires KVM and nested user namespaces in the Nix build sandbox. +The Playwright check launches two real Firefox instances concurrently against a +local HTTP fixture, checks headless/default browser selection, separate cookies, +DOM and localStorage, screenshots, close-one/keep-one behavior and profile expiry. +The boot check runs it inside the guest too, and checks writable skill seeding, +host ownership and preservation of a user edit across a harness restart. No live +websites, real credentials or model calls are used. A real `dsh@latest` Web startup additionally requires network access and is not silently replaced by the fake harness in normal usage. @@ -289,6 +355,8 @@ DSH Web launch with disposable config. The live check returned **401** without a cookie, **200** after the token exchange, and **403** for an untrusted Host; DSH stayed running without restarts. No real API credentials, model requests, community plugins, TAP network provisioning, or host activation were involved. +The Firefox two-session regression also passed natively and in the real headless +microVM, including screenshot generation and writable, non-clobbering skill seeding. Research used the **new official wiki**, plus upstream sources: @@ -304,5 +372,8 @@ Research used the **new official wiki**, plus upstream sources: [skill paths](https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/skill/skill-filesystem/README.md), [browser auth](https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/client/connection/README.md), [safety notice](https://github.com/deepseek-ai/deepseek-harness/blob/master/SAFETY.md). +- [Official Playwright CLI](https://github.com/microsoft/playwright-cli), its + [session-management reference](https://github.com/microsoft/playwright-cli/blob/main/skills/playwright-cli/references/session-management.md), + and the CLI/core implementation shipped in the locked Nixpkgs Playwright package. - [Awesome DSH plugins](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin) plus the individual repositories above; no third-party plugin was installed. diff --git a/README.md b/README.md index 5fbfbfc..4214634 100644 --- a/README.md +++ b/README.md @@ -122,7 +122,10 @@ git credential-cache exit The official `dsh@latest` runs as root in a rootless microVM, with the current project and your standard DSH home/shared skills mounted **read-write**. Its host Web listener defaults to localhost; RAM, vCPUs, IPs and optional TAP -networking are configured through Nix modules. No host service is activated. +networking are configured through Nix modules. The VM is headless by default and +includes **Playwright CLI + matching Firefox**, with a writable, once-seeded +`playwright-firefox` skill for isolated named sessions across subagents. No host +service is activated. ### Local Tor client diff --git a/agent-vm/boot-test.sh b/agent-vm/boot-test.sh index c8f29be..7064fc3 100644 --- a/agent-vm/boot-test.sh +++ b/agent-vm/boot-test.sh @@ -2,6 +2,7 @@ # Offline test of the real microvm.nix runner, mounts, SSH and host-side sandbox. set -euo pipefail launcher=$1 +browser_test=${2:-} tmp=$(mktemp -d) export HOME=$tmp/home DSH_HOME=$tmp/dsh DSH_AGENTS_HOME=$tmp/agents XDG_STATE_HOME=$tmp/state mkdir -p "$HOME" "$tmp/project" "$DSH_HOME/skills" "$DSH_AGENTS_HOME/skills" @@ -45,11 +46,23 @@ fi [[ $(< "$DSH_HOME/credentials-test") == creds && $(< "$DSH_HOME/skills/test.md") == skill ]] [[ $(< "$DSH_AGENTS_HOME/skills/test.md") == shared ]] [[ $(stat -c %u changed) == "$(id -u)" ]] -"$launcher" ssh 'command -v rg python3 git; findmnt /workspace; findmnt /root/.dsh' +"$launcher" ssh 'command -v rg python3 git playwright-cli; findmnt /workspace; findmnt /root/.dsh' +"$launcher" ssh 'systemctl start agent.service; test ! -e /tmp/.X11-unix/X0' +skill="$DSH_AGENTS_HOME/skills/playwright-firefox/SKILL.md" +grep -q '^name: playwright-firefox$' "$skill" +[[ ! -L $skill && -w $skill && $(stat -c %a "$skill") == 600 ]] +[[ $(stat -c %u "$skill") == "$(id -u)" ]] +printf '\nuser customization\n' >> "$skill" +"$launcher" ssh 'systemctl restart agent.service' +grep -q '^user customization$' "$skill" +if [[ -n $browser_test ]]; then + cp "$browser_test" ./playwright-test.sh + "$launcher" ssh 'bash ./playwright-test.sh' +fi # A second start must fail without disrupting the existing VM. if "$launcher" run >/dev/null 2>&1; then echo 'Duplicate launch succeeded' >&2; exit 1; fi "$launcher" stop wait "$pid" trap - EXIT rm -rf "$tmp" -echo 'PASS: microVM boot, root SSH, shared toolchain, RW cwd/config/creds/skills, host ownership, symlink isolation, duplicate lock, shutdown' +echo 'PASS: microVM boot, root SSH, shared toolchain, RW cwd/config/creds/skills, writable non-clobbering skill seed, headless browser CLI, host ownership, symlink isolation, duplicate lock, shutdown' diff --git a/agent-vm/flake.nix b/agent-vm/flake.nix index c065c70..f8e7366 100644 --- a/agent-vm/flake.nix +++ b/agent-vm/flake.nix @@ -31,7 +31,10 @@ lib.mkAgentVM = import ./lib.nix { inherit nixpkgs microvm; }; nixosModules.agent = ./module.nix; nixosConfigurations.agent = example.nixos; - packages.${system}.default = example.package; + packages.${system} = { + default = example.package; + playwright-cli = import ./playwright.nix { inherit pkgs; }; + }; apps.${system}.default = example.app; formatter.${system} = pkgs.nixfmt; checks.${system} = import ./tests.nix { inherit inputs pkgs example; }; diff --git a/agent-vm/module.nix b/agent-vm/module.nix index 0281843..c8a5243 100644 --- a/agent-vm/module.nix +++ b/agent-vm/module.nix @@ -9,6 +9,7 @@ let cfg = config.agentVM; net = cfg.network; ipv4 = types.strMatching "[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+"; + playwrightCli = import ./playwright.nix { inherit pkgs; }; dshNode = pkgs.writeShellScript "dsh-node" '' # Cordis HMR requires this Node flag; npm's published dsh shebang omits it. exec node --expose-internals "$(command -v dsh)" "$@" @@ -274,6 +275,7 @@ in programs.nix-ld.enable = true; # Upstream npm native executables, guest only. environment.systemPackages = [ cfg.package + playwrightCli ] ++ cfg.packages ++ (with pkgs; [ @@ -336,6 +338,14 @@ in mkdir -p -- "$workdir" mountpoint -q -- "$workdir" || mount --bind /workspace "$workdir" git config --global --replace-all safe.directory "$workdir" + # Seed this new skill into the actual RW shared home once. Existing + # skills/user edits stay untouched, and the new file is writable, not + # a Nix-store symlink. GNU cp's no-overwrite creation also handles races + # between project VMs starting with the same shared skills directory. + mkdir -p /root/.agents/skills/playwright-firefox + cp --update=none --no-preserve=mode \ + ${./skills/playwright-firefox/SKILL.md} \ + /root/.agents/skills/playwright-firefox/SKILL.md ''; serviceConfig = { User = "root"; diff --git a/agent-vm/playwright-test.sh b/agent-vm/playwright-test.sh new file mode 100644 index 0000000..c344cc4 --- /dev/null +++ b/agent-vm/playwright-test.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# Offline, real Firefox/CLI regression: private HOME, local HTTP, two sessions. +set -euo pipefail +umask 077 +work=$(mktemp -d) +export HOME="$work/home" XDG_CACHE_HOME="$work/cache" +unset DISPLAY WAYLAND_DISPLAY PLAYWRIGHT_CLI_SESSION PLAYWRIGHT_MCP_BROWSER PLAYWRIGHT_MCP_HEADLESS +mkdir -p "$HOME" "$work/project/artifacts/test-a" "$work/project/artifacts/test-b" +cd "$work/project" +server='' +cleanup() { + status=$? + if (( status )); then grep -h . "$work"/*.log || true; fi + for session in test-a test-b; do + timeout 15 playwright-cli -s="$session" close >/dev/null 2>&1 || true + done + if [[ -n $server ]]; then kill "$server" 2>/dev/null || true; wait "$server" 2>/dev/null || true; fi + rm -rf "$work" + return "$status" +} +trap cleanup EXIT +node - "$work/port" <<'JS' & +const http = require('http'); +const fs = require('fs'); +http.createServer((req, res) => { + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.end(`