From 50248499ff980711319437264789b9954aac576f Mon Sep 17 00:00:00 2001 From: Max Schaefer Date: Wed, 11 Nov 2026 06:26:09 +0000 Subject: [PATCH 01/10] Consolidate workstation services and sandboxed browsing in dotfiles --- AGENTS.md | 2 + README.md | 10 +- home/.chezmoidata.toml | 12 ++ home/.chezmoiexternal.toml | 10 ++ home/.chezmoiignore | 6 + home/.chezmoitemplates/nono-profile | 6 +- .../sandbox/private_config.json.tmpl | 4 + .../skills/symlink_agent-browser.tmpl | 1 + .../systemd/browserless.container.tmpl | 14 +++ home/dot_config/mise/config.toml | 1 + home/dot_config/mise/tasks/services.toml | 26 +++++ .../services/browserless/compose.yaml.tmpl | 12 ++ .../onepassword-connect/compose.yaml.tmpl | 19 ++++ .../onepassword-connect-api.container.tmpl | 5 + .../onepassword-connect-sync.container.tmpl | 6 + .../onepassword-connect.pod | 5 + tests/services.test.mjs | 106 ++++++++++++++++++ 17 files changed, 241 insertions(+), 4 deletions(-) create mode 100644 home/dot_agent-browser/namespaces/sandbox/private_config.json.tmpl create mode 100644 home/dot_claude/skills/symlink_agent-browser.tmpl create mode 100644 home/dot_config/containers/systemd/browserless.container.tmpl create mode 100644 home/dot_config/mise/tasks/services.toml create mode 100644 home/dot_config/services/browserless/compose.yaml.tmpl create mode 100644 home/dot_config/services/onepassword-connect/compose.yaml.tmpl create mode 100644 home/dot_config/services/onepassword-connect/onepassword-connect-api.container.tmpl create mode 100644 home/dot_config/services/onepassword-connect/onepassword-connect-sync.container.tmpl create mode 100644 home/dot_config/services/onepassword-connect/onepassword-connect.pod create mode 100644 tests/services.test.mjs diff --git a/AGENTS.md b/AGENTS.md index 8e39494..00150d0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,6 +8,8 @@ - Every tool a CI job runs must be in its `with.tools` or declared by the repository with `project-tools: true`. - `mise use --global` and `mise upgrade --global` edit live files; reconcile those changes back into `home/`. - Linux hosts apply this source too: limit macOS-only targets with `.chezmoiignore` or `.chezmoi.os` blocks. +- Workstation services live in the mirrored source: macOS Compose stacks and Linux system Quadlet sources under `.config/services`, Linux user Quadlets under `.config/containers/systemd`. Activation is explicit through the service Mise tasks. +- Preserve Connect's runtime identity: system-level Podman units, secret and volume on Linux; the `onepassword-connect` Compose project, named volume and OrbStack hostname on macOS. Service relocation must not recreate credentials or data. - Keep application-managed hooks, caches, history, account state and generated host inventories outside this repository. - Preserve application destination paths and unrelated local state when changing source attributes or templates. - Use chezmoi's normal conflict handling for apply; do not force routine updates. diff --git a/README.md b/README.md index c8790c4..daa06a5 100644 --- a/README.md +++ b/README.md @@ -15,9 +15,8 @@ modify templates to preserve unrelated application settings and local state. MCP endpoints are defined in `home/.chezmoidata.toml`. Bifrost keys are stored in the 1Password Automation vault and referenced through `home/dot_config/fnox/config.toml`. Fnox and the 1Password CLI read vaults -through the machine's 1Password Connect server: on macOS from -[local services](https://github.com/maxexcloo/local-services), on Linux from -[Homelab](https://github.com/maxexcloo/homelab). Save its token as `token` beside +through the machine's 1Password Connect server, managed here with the +`services:connect` Mise task. Save its token as `token` beside the Connect credentials file. `chezmoi apply` writes each agent's Bifrost key into its private configuration and the `GitHub Agents` token into `~/.config/gh-agents`. Keep key values out of Git and command output. @@ -36,6 +35,7 @@ then install the global tools it declares: mise exec chezmoi@latest -- chezmoi --source "$PWD" apply "$HOME/.config/mise" mise install mise run setup +mise run services:connect mise run check chezmoi status chezmoi apply @@ -81,6 +81,10 @@ Agent logins, sessions and history stay on each machine: sign in to each agent once per machine. `CLAUDE_CONFIG_DIR` keeps Claude's state file inside `~/.claude`, because the sandbox cannot replace files directly in the home directory. +[agent-browser](https://agent-browser.dev) uses the Browserless container started +by `mise run services:browserless`. Mise installs the CLI; chezmoi configures its +skill, sandbox access and endpoint. + ## Shared Agent Guidance Clone the Agents repository beside this checkout. Chezmoi links its shared diff --git a/home/.chezmoidata.toml b/home/.chezmoidata.toml index 15514d2..2976bd2 100644 --- a/home/.chezmoidata.toml +++ b/home/.chezmoidata.toml @@ -24,3 +24,15 @@ url = "https://bifrost.excloo.com/mcp/reactive-resume" [mcp.unifi_network] url = "https://bifrost.excloo.com/mcp/unifi-network" + +[services.browserless] +concurrent = 4 +port = 9222 +queued = 4 +timeout = 3600000 +# renovate: datasource=docker depName=ghcr.io/browserless/chromium +version = "v2.62.0" + +[services.connect] +# renovate: datasource=docker depName=1password/connect-api +version = "1.8.4" diff --git a/home/.chezmoiexternal.toml b/home/.chezmoiexternal.toml index 352d2b6..b3bb296 100644 --- a/home/.chezmoiexternal.toml +++ b/home/.chezmoiexternal.toml @@ -1,3 +1,13 @@ +[".agents/skills/agent-browser/LICENSE"] +type = "file" +# renovate: datasource=github-releases depName=vercel-labs/agent-browser +url = "https://raw.githubusercontent.com/vercel-labs/agent-browser/v0.42.0/LICENSE" + +[".agents/skills/agent-browser/SKILL.md"] +type = "file" +# renovate: datasource=github-releases depName=vercel-labs/agent-browser +url = "https://raw.githubusercontent.com/vercel-labs/agent-browser/v0.42.0/skills/agent-browser/SKILL.md" + [".agents/skills/humanizer"] type = "archive" exact = true diff --git a/home/.chezmoiignore b/home/.chezmoiignore index 6341fff..7bc0b28 100644 --- a/home/.chezmoiignore +++ b/home/.chezmoiignore @@ -1,4 +1,10 @@ +{{- if ne .chezmoi.os "linux" }} +.config/containers +.config/services/onepassword-connect/*.container +.config/services/onepassword-connect/*.pod +{{- end }} {{- if ne .chezmoi.os "darwin" }} +.config/services/*/compose.yaml .gitconfig .hammerspoon .ssh diff --git a/home/.chezmoitemplates/nono-profile b/home/.chezmoitemplates/nono-profile index 0f04e7a..0c63f61 100644 --- a/home/.chezmoitemplates/nono-profile +++ b/home/.chezmoitemplates/nono-profile @@ -2,6 +2,8 @@ "extends": "nolabs-ai/{{ .agent }}", "environment": { "set_vars": { + "AGENT_BROWSER_CONFIG": "$HOME/.agent-browser/namespaces/sandbox/config.json", + "AGENT_BROWSER_SOCKET_DIR": "$HOME/.agent-browser", "GH_CONFIG_DIR": "$HOME/.config/gh-agents", "GIT_CONFIG_GLOBAL": "$HOME/.config/git/config", "TMPDIR": "$XDG_CACHE_HOME", @@ -10,13 +12,15 @@ }, "filesystem": { "allow": [ + "$HOME/.agent-browser/namespaces/sandbox", "$HOME/.config/gh-agents", "$HOME/.local/state/mise/tracked-configs", "$HOME/Library/Application Support/orca/agent-hooks", "$XDG_CACHE_HOME", "/var/tmp" ], - "read": ["$HOME/.agents", "$HOME/.orca/agent-hooks", "$HOME/Projects/agents", "/usr/libexec/git-core"] + "read": ["$HOME/.agents", "$HOME/.orca/agent-hooks", "$HOME/Projects/agents", "/usr/libexec/git-core"], + "unix_socket_subtree_bind": ["$HOME/.agent-browser/namespaces/sandbox"] }, "groups": { "include": ["mise_manager"] }, "meta": { "name": "{{ .agent }}" } diff --git a/home/dot_agent-browser/namespaces/sandbox/private_config.json.tmpl b/home/dot_agent-browser/namespaces/sandbox/private_config.json.tmpl new file mode 100644 index 0000000..42444f8 --- /dev/null +++ b/home/dot_agent-browser/namespaces/sandbox/private_config.json.tmpl @@ -0,0 +1,4 @@ +{ + "cdp": "ws://127.0.0.1:{{ .services.browserless.port }}/chromium", + "namespace": "sandbox" +} diff --git a/home/dot_claude/skills/symlink_agent-browser.tmpl b/home/dot_claude/skills/symlink_agent-browser.tmpl new file mode 100644 index 0000000..3bd7e20 --- /dev/null +++ b/home/dot_claude/skills/symlink_agent-browser.tmpl @@ -0,0 +1 @@ +{{ joinPath .chezmoi.homeDir ".agents" "skills" "agent-browser" }} diff --git a/home/dot_config/containers/systemd/browserless.container.tmpl b/home/dot_config/containers/systemd/browserless.container.tmpl new file mode 100644 index 0000000..e804fa7 --- /dev/null +++ b/home/dot_config/containers/systemd/browserless.container.tmpl @@ -0,0 +1,14 @@ +[Container] +Environment=CONCURRENT={{ .services.browserless.concurrent }} +Environment=QUEUED={{ .services.browserless.queued }} +Environment=TIMEOUT={{ .services.browserless.timeout }} +Image=ghcr.io/browserless/chromium:{{ .services.browserless.version }} +PublishPort=127.0.0.1:{{ .services.browserless.port }}:3000 +ShmSize=1g + +[Service] +Restart=on-failure +TimeoutStartSec=300 + +[Install] +WantedBy=default.target diff --git a/home/dot_config/mise/config.toml b/home/dot_config/mise/config.toml index d06d607..1a443b9 100644 --- a/home/dot_config/mise/config.toml +++ b/home/dot_config/mise/config.toml @@ -16,6 +16,7 @@ claude-code = "latest" codex = "latest" k9s = "latest" nono = "latest" +"npm:agent-browser" = "latest" opencode = "latest" pi = "latest" rclone = "latest" diff --git a/home/dot_config/mise/tasks/services.toml b/home/dot_config/mise/tasks/services.toml new file mode 100644 index 0000000..6c299c8 --- /dev/null +++ b/home/dot_config/mise/tasks/services.toml @@ -0,0 +1,26 @@ +["services:browserless"] +description = "Start Browserless" +run = ''' +{% if os() == "macos" %} +chezmoi apply "$HOME/.config/services" +docker compose --file "$HOME/.config/services/browserless/compose.yaml" up --detach +{% else %} +chezmoi apply "$HOME/.config/containers" +systemctl --user daemon-reload +systemctl --user start browserless.service +{% endif %} +''' + +["services:connect"] +description = "Start 1Password Connect" +run = ''' +chezmoi apply "$HOME/.config/services" +{% if os() == "macos" %} +docker compose --file "$HOME/.config/services/onepassword-connect/compose.yaml" up --detach +{% else %} +service_directory="$HOME/.config/services/onepassword-connect" +sudo install -D -m 0644 -t /etc/containers/systemd "$service_directory/onepassword-connect-api.container" "$service_directory/onepassword-connect-sync.container" "$service_directory/onepassword-connect.pod" +sudo systemctl daemon-reload +sudo systemctl start onepassword-connect-pod.service +{% endif %} +''' diff --git a/home/dot_config/services/browserless/compose.yaml.tmpl b/home/dot_config/services/browserless/compose.yaml.tmpl new file mode 100644 index 0000000..e692d96 --- /dev/null +++ b/home/dot_config/services/browserless/compose.yaml.tmpl @@ -0,0 +1,12 @@ +name: browserless +services: + browserless: + image: ghcr.io/browserless/chromium:{{ .services.browserless.version }} + restart: unless-stopped + shm_size: 1gb + environment: + CONCURRENT: {{ .services.browserless.concurrent }} + QUEUED: {{ .services.browserless.queued }} + TIMEOUT: {{ .services.browserless.timeout }} + ports: + - "127.0.0.1:{{ .services.browserless.port }}:3000" diff --git a/home/dot_config/services/onepassword-connect/compose.yaml.tmpl b/home/dot_config/services/onepassword-connect/compose.yaml.tmpl new file mode 100644 index 0000000..5d120df --- /dev/null +++ b/home/dot_config/services/onepassword-connect/compose.yaml.tmpl @@ -0,0 +1,19 @@ +name: onepassword-connect +services: + connect-api: + image: 1password/connect-api:{{ .services.connect.version }} + restart: unless-stopped + volumes: &connect-volumes + - data:/home/opuser/.op/data + - type: bind + read_only: true + source: ${XDG_CONFIG_HOME:-${HOME}/.config}/op/connect/1password-credentials.json + target: /home/opuser/.op/1password-credentials.json + bind: + create_host_path: false + connect-sync: + image: 1password/connect-sync:{{ .services.connect.version }} + restart: unless-stopped + volumes: *connect-volumes +volumes: + data: {} diff --git a/home/dot_config/services/onepassword-connect/onepassword-connect-api.container.tmpl b/home/dot_config/services/onepassword-connect/onepassword-connect-api.container.tmpl new file mode 100644 index 0000000..48a3da4 --- /dev/null +++ b/home/dot_config/services/onepassword-connect/onepassword-connect-api.container.tmpl @@ -0,0 +1,5 @@ +[Container] +Image=docker.io/1password/connect-api:{{ .services.connect.version }} +Pod=onepassword-connect.pod +Secret=onepassword-connect-credentials,target=/home/opuser/.op/1password-credentials.json,uid=999,mode=0400 +Volume=onepassword-connect:/home/opuser/.op/data diff --git a/home/dot_config/services/onepassword-connect/onepassword-connect-sync.container.tmpl b/home/dot_config/services/onepassword-connect/onepassword-connect-sync.container.tmpl new file mode 100644 index 0000000..7358aa7 --- /dev/null +++ b/home/dot_config/services/onepassword-connect/onepassword-connect-sync.container.tmpl @@ -0,0 +1,6 @@ +[Container] +Environment=OP_HTTP_PORT=8081 +Image=docker.io/1password/connect-sync:{{ .services.connect.version }} +Pod=onepassword-connect.pod +Secret=onepassword-connect-credentials,target=/home/opuser/.op/1password-credentials.json,uid=999,mode=0400 +Volume=onepassword-connect:/home/opuser/.op/data diff --git a/home/dot_config/services/onepassword-connect/onepassword-connect.pod b/home/dot_config/services/onepassword-connect/onepassword-connect.pod new file mode 100644 index 0000000..c21ec53 --- /dev/null +++ b/home/dot_config/services/onepassword-connect/onepassword-connect.pod @@ -0,0 +1,5 @@ +[Pod] +PublishPort=127.0.0.1:8080:8080 + +[Install] +WantedBy=multi-user.target diff --git a/tests/services.test.mjs b/tests/services.test.mjs new file mode 100644 index 0000000..5c19269 --- /dev/null +++ b/tests/services.test.mjs @@ -0,0 +1,106 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; + +const root = fileURLToPath(new URL("..", import.meta.url)); +const base = ["--source", root, "--config", "/dev/null", "--config-format", "toml"]; +const execute = (args, input = "") => + execFileSync("chezmoi", [...base, ...args], { input, encoding: "utf8" }); +const render = (path, services) => + execute([ + ...(services ? ["--override-data", JSON.stringify({ services })] : []), + "execute-template", + "--file", + `home/${path}`, + ]); +const yaml = (input) => + JSON.parse( + execute( + ["execute-template", "--with-stdin", "{{ .chezmoi.stdin | fromYaml | toJson }}"], + input, + ), + ); +const serviceRoot = "dot_config/services/onepassword-connect"; + +test("Service configuration can bootstrap into a fresh home before credential templates", () => { + const temp = mkdtempSync(join(tmpdir(), "dotfiles-services-")); + try { + for (const platform of ["linux", "darwin"]) { + const destination = join(temp, platform); + mkdirSync(join(destination, ".config"), { recursive: true }); + execFileSync( + "chezmoi", + [ + ...base, + "--destination", + destination, + "--persistent-state", + join(destination, "state.boltdb"), + "--cache", + join(temp, "cache"), + "--override-data", + JSON.stringify({ chezmoi: { os: platform } }), + "apply", + join(destination, ".config/services"), + ], + { env: { ...process.env, PATH: `${root}tests/bin:${process.env.PATH}` } }, + ); + const file = platform === "linux" ? "onepassword-connect-api.container" : "compose.yaml"; + assert.ok(existsSync(join(destination, ".config/services/onepassword-connect", file))); + } + } finally { + rmSync(temp, { recursive: true, force: true }); + } +}); + +test("Connect keeps its macOS project, named volume and read-only credential mount", () => { + const config = yaml(render(`${serviceRoot}/compose.yaml.tmpl`)); + assert.equal(config.name, "onepassword-connect"); + assert.deepEqual(config.volumes, { data: {} }); + for (const name of ["connect-api", "connect-sync"]) { + const service = config.services[name]; + assert.equal(service.restart, "unless-stopped"); + assert.equal(service.volumes[0], "data:/home/opuser/.op/data"); + assert.deepEqual(service.volumes[1], { + type: "bind", + read_only: true, + source: "${XDG_CONFIG_HOME:-${HOME}/.config}/op/connect/1password-credentials.json", + target: "/home/opuser/.op/1password-credentials.json", + bind: { create_host_path: false }, + }); + } +}); + +test("Connect keeps its existing Linux pod, secret and data volume", () => { + for (const name of ["api", "sync"]) { + const lines = render(`${serviceRoot}/onepassword-connect-${name}.container.tmpl`).split("\n"); + assert.ok(lines.includes("Pod=onepassword-connect.pod")); + assert.ok(lines.includes("Volume=onepassword-connect:/home/opuser/.op/data")); + assert.ok( + lines.includes( + "Secret=onepassword-connect-credentials,target=/home/opuser/.op/1password-credentials.json,uid=999,mode=0400", + ), + ); + } + const pod = render(`${serviceRoot}/onepassword-connect.pod`); + assert.match(pod, /^PublishPort=127\.0\.0\.1:8080:8080$/m); + assert.match(pod, /^WantedBy=multi-user\.target$/m); +}); + +test("Browserless uses the same configured port across both runtimes and the client", () => { + const services = JSON.parse(execute(["execute-template", "{{ .services | toJson }}"])); + services.browserless.port = 19333; + const compose = yaml(render("dot_config/services/browserless/compose.yaml.tmpl", services)); + assert.equal(compose.name, "browserless"); + assert.deepEqual(compose.services.browserless.ports, ["127.0.0.1:19333:3000"]); + const quadlet = render("dot_config/containers/systemd/browserless.container.tmpl", services); + assert.match(quadlet, /^PublishPort=127\.0\.0\.1:19333:3000$/m); + const client = JSON.parse( + render("dot_agent-browser/namespaces/sandbox/private_config.json.tmpl", services), + ); + assert.equal(client.cdp, "ws://127.0.0.1:19333/chromium"); +}); From 31b4bfe3b99176ca1d4cba21828feb2a63ebc3c8 Mon Sep 17 00:00:00 2001 From: Max Schaefer Date: Thu, 12 Nov 2026 01:51:08 +0000 Subject: [PATCH 02/10] Run project Playwright browsers in temporary containers --- README.md | 6 +++ home/dot_config/mise/tasks/services.toml | 16 ++++++ tests/services.test.mjs | 63 +++++++++++++++++++++++- 3 files changed, 84 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index daa06a5..c217116 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,12 @@ because the sandbox cannot replace files directly in the home directory. by `mise run services:browserless`. Mise installs the CLI; chezmoi configures its skill, sandbox access and endpoint. +For project Playwright tests, run `mise run services:playwright` from the project +root outside the agent sandbox. Set the two environment variables it prints in +the test terminal, then run the project's normal browser tests. The temporary +container supplies Chromium, Firefox and WebKit at the installed project's +Playwright version, without mounting project or home files; Ctrl-C removes it. + ## Shared Agent Guidance Clone the Agents repository beside this checkout. Chezmoi links its shared diff --git a/home/dot_config/mise/tasks/services.toml b/home/dot_config/mise/tasks/services.toml index 6c299c8..75f9db7 100644 --- a/home/dot_config/mise/tasks/services.toml +++ b/home/dot_config/mise/tasks/services.toml @@ -24,3 +24,19 @@ sudo systemctl daemon-reload sudo systemctl start onepassword-connect-pod.service {% endif %} ''' + +["services:playwright"] +description = "Start Project Playwright Browsers" +run = ''' +set -eu +version=$(node -p "require('playwright/package.json').version") +{% if os() == "macos" %} +runtime=docker +{% else %} +runtime=podman +{% endif %} +echo "export PW_TEST_CONNECT_WS_ENDPOINT=ws://127.0.0.1:$usage_port/" +echo "export PW_TEST_CONNECT_EXPOSE_NETWORK=''" +exec "$runtime" run --rm --init --user pwuser --workdir /home/pwuser --shm-size 1g --publish "127.0.0.1:$usage_port:3000" "mcr.microsoft.com/playwright:v$version-noble" npx --yes "playwright@$version" run-server --port 3000 --host 0.0.0.0 +''' +usage = 'flag "--port " default="9223" help="Loopback port for the Playwright server"' diff --git a/tests/services.test.mjs b/tests/services.test.mjs index 5c19269..6da7753 100644 --- a/tests/services.test.mjs +++ b/tests/services.test.mjs @@ -1,6 +1,14 @@ import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; -import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { test } from "node:test"; @@ -104,3 +112,56 @@ test("Browserless uses the same configured port across both runtimes and the cli ); assert.equal(client.cdp, "ws://127.0.0.1:19333/chromium"); }); + +test("Project browser server matches its installed client and exposes only loopback", () => { + const temp = mkdtempSync(join(tmpdir(), "dotfiles-playwright-")); + try { + mkdirSync(join(temp, "bin")); + symlinkSync(process.execPath, join(temp, "bin/node")); + mkdirSync(join(temp, "node_modules/playwright"), { recursive: true }); + writeFileSync( + join(temp, "node_modules/playwright/package.json"), + JSON.stringify({ version: "1.65.0" }), + ); + writeFileSync( + join(temp, ".mise.toml"), + `[task_config]\nincludes = [${JSON.stringify(join(root, "home/dot_config/mise/tasks/services.toml"))}]\n`, + ); + for (const runtime of ["docker", "podman"]) { + const path = join(temp, "bin", runtime); + writeFileSync( + path, + "#!/usr/bin/env node\nconsole.log(JSON.stringify(process.argv.slice(2)));\n", + ); + chmodSync(path, 0o755); + } + const output = execFileSync( + "mise", + ["run", "--skip-tools", "services:playwright", "--port", "19334"], + { + cwd: temp, + encoding: "utf8", + env: { + ...process.env, + MISE_CONFIG_DIR: join(temp, "config"), + MISE_GLOBAL_CONFIG_FILE: join(temp, "global.toml"), + MISE_DATA_DIR: join(temp, "data"), + MISE_CACHE_DIR: join(temp, "cache"), + MISE_TRUSTED_CONFIG_PATHS: temp, + PATH: `${join(temp, "bin")}:${process.env.PATH}`, + }, + }, + ); + assert.match(output, /PW_TEST_CONNECT_WS_ENDPOINT=ws:\/\/127\.0\.0\.1:19334\//); + assert.match(output, /PW_TEST_CONNECT_EXPOSE_NETWORK=''/); + const args = JSON.parse(output.trim().split("\n").at(-1)); + assert.equal(args[args.indexOf("--publish") + 1], "127.0.0.1:19334:3000"); + assert.ok(args.includes("mcr.microsoft.com/playwright:v1.65.0-noble")); + assert.ok(args.includes("playwright@1.65.0")); + assert.ok(args.includes("--rm")); + assert.equal(args.includes("--volume"), false); + assert.equal(args.includes("--mount"), false); + } finally { + rmSync(temp, { recursive: true, force: true }); + } +}); From dfcf4cc019c6d8dbaae06a1882af82841953df74 Mon Sep 17 00:00:00 2001 From: Max Schaefer Date: Thu, 12 Nov 2026 02:15:41 +0000 Subject: [PATCH 03/10] Separate Connect ownership from browser tooling --- AGENTS.md | 2 +- README.md | 10 --- home/.chezmoidata.toml | 8 -- home/.chezmoiexternal.toml | 10 --- home/.chezmoiignore | 1 - home/.chezmoitemplates/nono-profile | 6 +- .../sandbox/private_config.json.tmpl | 4 - .../skills/symlink_agent-browser.tmpl | 1 - .../systemd/browserless.container.tmpl | 14 --- home/dot_config/mise/config.toml | 1 - home/dot_config/mise/tasks/services.toml | 29 ------- .../services/browserless/compose.yaml.tmpl | 12 --- tests/services.test.mjs | 85 +------------------ 13 files changed, 4 insertions(+), 179 deletions(-) delete mode 100644 home/dot_agent-browser/namespaces/sandbox/private_config.json.tmpl delete mode 100644 home/dot_claude/skills/symlink_agent-browser.tmpl delete mode 100644 home/dot_config/containers/systemd/browserless.container.tmpl delete mode 100644 home/dot_config/services/browserless/compose.yaml.tmpl diff --git a/AGENTS.md b/AGENTS.md index 00150d0..2767b1c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,7 @@ - Every tool a CI job runs must be in its `with.tools` or declared by the repository with `project-tools: true`. - `mise use --global` and `mise upgrade --global` edit live files; reconcile those changes back into `home/`. - Linux hosts apply this source too: limit macOS-only targets with `.chezmoiignore` or `.chezmoi.os` blocks. -- Workstation services live in the mirrored source: macOS Compose stacks and Linux system Quadlet sources under `.config/services`, Linux user Quadlets under `.config/containers/systemd`. Activation is explicit through the service Mise tasks. +- Workstation services live in the mirrored source: macOS Compose stacks and Linux system Quadlet sources under `.config/services`. Activation is explicit through the service Mise tasks. - Preserve Connect's runtime identity: system-level Podman units, secret and volume on Linux; the `onepassword-connect` Compose project, named volume and OrbStack hostname on macOS. Service relocation must not recreate credentials or data. - Keep application-managed hooks, caches, history, account state and generated host inventories outside this repository. - Preserve application destination paths and unrelated local state when changing source attributes or templates. diff --git a/README.md b/README.md index c217116..b617b23 100644 --- a/README.md +++ b/README.md @@ -81,16 +81,6 @@ Agent logins, sessions and history stay on each machine: sign in to each agent once per machine. `CLAUDE_CONFIG_DIR` keeps Claude's state file inside `~/.claude`, because the sandbox cannot replace files directly in the home directory. -[agent-browser](https://agent-browser.dev) uses the Browserless container started -by `mise run services:browserless`. Mise installs the CLI; chezmoi configures its -skill, sandbox access and endpoint. - -For project Playwright tests, run `mise run services:playwright` from the project -root outside the agent sandbox. Set the two environment variables it prints in -the test terminal, then run the project's normal browser tests. The temporary -container supplies Chromium, Firefox and WebKit at the installed project's -Playwright version, without mounting project or home files; Ctrl-C removes it. - ## Shared Agent Guidance Clone the Agents repository beside this checkout. Chezmoi links its shared diff --git a/home/.chezmoidata.toml b/home/.chezmoidata.toml index 2976bd2..2b19424 100644 --- a/home/.chezmoidata.toml +++ b/home/.chezmoidata.toml @@ -25,14 +25,6 @@ url = "https://bifrost.excloo.com/mcp/reactive-resume" [mcp.unifi_network] url = "https://bifrost.excloo.com/mcp/unifi-network" -[services.browserless] -concurrent = 4 -port = 9222 -queued = 4 -timeout = 3600000 -# renovate: datasource=docker depName=ghcr.io/browserless/chromium -version = "v2.62.0" - [services.connect] # renovate: datasource=docker depName=1password/connect-api version = "1.8.4" diff --git a/home/.chezmoiexternal.toml b/home/.chezmoiexternal.toml index 09d0498..15bfac4 100644 --- a/home/.chezmoiexternal.toml +++ b/home/.chezmoiexternal.toml @@ -1,13 +1,3 @@ -[".agents/skills/agent-browser/LICENSE"] -type = "file" -# renovate: datasource=github-releases depName=vercel-labs/agent-browser -url = "https://raw.githubusercontent.com/vercel-labs/agent-browser/v0.42.0/LICENSE" - -[".agents/skills/agent-browser/SKILL.md"] -type = "file" -# renovate: datasource=github-releases depName=vercel-labs/agent-browser -url = "https://raw.githubusercontent.com/vercel-labs/agent-browser/v0.42.0/skills/agent-browser/SKILL.md" - [".agents/skills/humanizer"] type = "archive" exact = true diff --git a/home/.chezmoiignore b/home/.chezmoiignore index 4d543ba..23f0773 100644 --- a/home/.chezmoiignore +++ b/home/.chezmoiignore @@ -1,5 +1,4 @@ {{- if ne .chezmoi.os "linux" }} -.config/containers .config/services/onepassword-connect/*.container .config/services/onepassword-connect/*.pod {{- end }} diff --git a/home/.chezmoitemplates/nono-profile b/home/.chezmoitemplates/nono-profile index 0c63f61..0f04e7a 100644 --- a/home/.chezmoitemplates/nono-profile +++ b/home/.chezmoitemplates/nono-profile @@ -2,8 +2,6 @@ "extends": "nolabs-ai/{{ .agent }}", "environment": { "set_vars": { - "AGENT_BROWSER_CONFIG": "$HOME/.agent-browser/namespaces/sandbox/config.json", - "AGENT_BROWSER_SOCKET_DIR": "$HOME/.agent-browser", "GH_CONFIG_DIR": "$HOME/.config/gh-agents", "GIT_CONFIG_GLOBAL": "$HOME/.config/git/config", "TMPDIR": "$XDG_CACHE_HOME", @@ -12,15 +10,13 @@ }, "filesystem": { "allow": [ - "$HOME/.agent-browser/namespaces/sandbox", "$HOME/.config/gh-agents", "$HOME/.local/state/mise/tracked-configs", "$HOME/Library/Application Support/orca/agent-hooks", "$XDG_CACHE_HOME", "/var/tmp" ], - "read": ["$HOME/.agents", "$HOME/.orca/agent-hooks", "$HOME/Projects/agents", "/usr/libexec/git-core"], - "unix_socket_subtree_bind": ["$HOME/.agent-browser/namespaces/sandbox"] + "read": ["$HOME/.agents", "$HOME/.orca/agent-hooks", "$HOME/Projects/agents", "/usr/libexec/git-core"] }, "groups": { "include": ["mise_manager"] }, "meta": { "name": "{{ .agent }}" } diff --git a/home/dot_agent-browser/namespaces/sandbox/private_config.json.tmpl b/home/dot_agent-browser/namespaces/sandbox/private_config.json.tmpl deleted file mode 100644 index 42444f8..0000000 --- a/home/dot_agent-browser/namespaces/sandbox/private_config.json.tmpl +++ /dev/null @@ -1,4 +0,0 @@ -{ - "cdp": "ws://127.0.0.1:{{ .services.browserless.port }}/chromium", - "namespace": "sandbox" -} diff --git a/home/dot_claude/skills/symlink_agent-browser.tmpl b/home/dot_claude/skills/symlink_agent-browser.tmpl deleted file mode 100644 index 3bd7e20..0000000 --- a/home/dot_claude/skills/symlink_agent-browser.tmpl +++ /dev/null @@ -1 +0,0 @@ -{{ joinPath .chezmoi.homeDir ".agents" "skills" "agent-browser" }} diff --git a/home/dot_config/containers/systemd/browserless.container.tmpl b/home/dot_config/containers/systemd/browserless.container.tmpl deleted file mode 100644 index e804fa7..0000000 --- a/home/dot_config/containers/systemd/browserless.container.tmpl +++ /dev/null @@ -1,14 +0,0 @@ -[Container] -Environment=CONCURRENT={{ .services.browserless.concurrent }} -Environment=QUEUED={{ .services.browserless.queued }} -Environment=TIMEOUT={{ .services.browserless.timeout }} -Image=ghcr.io/browserless/chromium:{{ .services.browserless.version }} -PublishPort=127.0.0.1:{{ .services.browserless.port }}:3000 -ShmSize=1g - -[Service] -Restart=on-failure -TimeoutStartSec=300 - -[Install] -WantedBy=default.target diff --git a/home/dot_config/mise/config.toml b/home/dot_config/mise/config.toml index 03bd38e..5e4c467 100644 --- a/home/dot_config/mise/config.toml +++ b/home/dot_config/mise/config.toml @@ -17,7 +17,6 @@ codex = "latest" k9s = "latest" nono = "latest" "npm:@deepseek-ai/dsh" = "latest" -"npm:agent-browser" = "latest" opencode = "latest" pi = "latest" rclone = "latest" diff --git a/home/dot_config/mise/tasks/services.toml b/home/dot_config/mise/tasks/services.toml index 75f9db7..977de54 100644 --- a/home/dot_config/mise/tasks/services.toml +++ b/home/dot_config/mise/tasks/services.toml @@ -1,16 +1,3 @@ -["services:browserless"] -description = "Start Browserless" -run = ''' -{% if os() == "macos" %} -chezmoi apply "$HOME/.config/services" -docker compose --file "$HOME/.config/services/browserless/compose.yaml" up --detach -{% else %} -chezmoi apply "$HOME/.config/containers" -systemctl --user daemon-reload -systemctl --user start browserless.service -{% endif %} -''' - ["services:connect"] description = "Start 1Password Connect" run = ''' @@ -24,19 +11,3 @@ sudo systemctl daemon-reload sudo systemctl start onepassword-connect-pod.service {% endif %} ''' - -["services:playwright"] -description = "Start Project Playwright Browsers" -run = ''' -set -eu -version=$(node -p "require('playwright/package.json').version") -{% if os() == "macos" %} -runtime=docker -{% else %} -runtime=podman -{% endif %} -echo "export PW_TEST_CONNECT_WS_ENDPOINT=ws://127.0.0.1:$usage_port/" -echo "export PW_TEST_CONNECT_EXPOSE_NETWORK=''" -exec "$runtime" run --rm --init --user pwuser --workdir /home/pwuser --shm-size 1g --publish "127.0.0.1:$usage_port:3000" "mcr.microsoft.com/playwright:v$version-noble" npx --yes "playwright@$version" run-server --port 3000 --host 0.0.0.0 -''' -usage = 'flag "--port " default="9223" help="Loopback port for the Playwright server"' diff --git a/home/dot_config/services/browserless/compose.yaml.tmpl b/home/dot_config/services/browserless/compose.yaml.tmpl deleted file mode 100644 index e692d96..0000000 --- a/home/dot_config/services/browserless/compose.yaml.tmpl +++ /dev/null @@ -1,12 +0,0 @@ -name: browserless -services: - browserless: - image: ghcr.io/browserless/chromium:{{ .services.browserless.version }} - restart: unless-stopped - shm_size: 1gb - environment: - CONCURRENT: {{ .services.browserless.concurrent }} - QUEUED: {{ .services.browserless.queued }} - TIMEOUT: {{ .services.browserless.timeout }} - ports: - - "127.0.0.1:{{ .services.browserless.port }}:3000" diff --git a/tests/services.test.mjs b/tests/services.test.mjs index 6da7753..a1b8cab 100644 --- a/tests/services.test.mjs +++ b/tests/services.test.mjs @@ -1,14 +1,6 @@ import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; -import { - chmodSync, - existsSync, - mkdirSync, - mkdtempSync, - rmSync, - symlinkSync, - writeFileSync, -} from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { test } from "node:test"; @@ -18,13 +10,7 @@ const root = fileURLToPath(new URL("..", import.meta.url)); const base = ["--source", root, "--config", "/dev/null", "--config-format", "toml"]; const execute = (args, input = "") => execFileSync("chezmoi", [...base, ...args], { input, encoding: "utf8" }); -const render = (path, services) => - execute([ - ...(services ? ["--override-data", JSON.stringify({ services })] : []), - "execute-template", - "--file", - `home/${path}`, - ]); +const render = (path) => execute(["execute-template", "--file", `home/${path}`]); const yaml = (input) => JSON.parse( execute( @@ -98,70 +84,3 @@ test("Connect keeps its existing Linux pod, secret and data volume", () => { assert.match(pod, /^PublishPort=127\.0\.0\.1:8080:8080$/m); assert.match(pod, /^WantedBy=multi-user\.target$/m); }); - -test("Browserless uses the same configured port across both runtimes and the client", () => { - const services = JSON.parse(execute(["execute-template", "{{ .services | toJson }}"])); - services.browserless.port = 19333; - const compose = yaml(render("dot_config/services/browserless/compose.yaml.tmpl", services)); - assert.equal(compose.name, "browserless"); - assert.deepEqual(compose.services.browserless.ports, ["127.0.0.1:19333:3000"]); - const quadlet = render("dot_config/containers/systemd/browserless.container.tmpl", services); - assert.match(quadlet, /^PublishPort=127\.0\.0\.1:19333:3000$/m); - const client = JSON.parse( - render("dot_agent-browser/namespaces/sandbox/private_config.json.tmpl", services), - ); - assert.equal(client.cdp, "ws://127.0.0.1:19333/chromium"); -}); - -test("Project browser server matches its installed client and exposes only loopback", () => { - const temp = mkdtempSync(join(tmpdir(), "dotfiles-playwright-")); - try { - mkdirSync(join(temp, "bin")); - symlinkSync(process.execPath, join(temp, "bin/node")); - mkdirSync(join(temp, "node_modules/playwright"), { recursive: true }); - writeFileSync( - join(temp, "node_modules/playwright/package.json"), - JSON.stringify({ version: "1.65.0" }), - ); - writeFileSync( - join(temp, ".mise.toml"), - `[task_config]\nincludes = [${JSON.stringify(join(root, "home/dot_config/mise/tasks/services.toml"))}]\n`, - ); - for (const runtime of ["docker", "podman"]) { - const path = join(temp, "bin", runtime); - writeFileSync( - path, - "#!/usr/bin/env node\nconsole.log(JSON.stringify(process.argv.slice(2)));\n", - ); - chmodSync(path, 0o755); - } - const output = execFileSync( - "mise", - ["run", "--skip-tools", "services:playwright", "--port", "19334"], - { - cwd: temp, - encoding: "utf8", - env: { - ...process.env, - MISE_CONFIG_DIR: join(temp, "config"), - MISE_GLOBAL_CONFIG_FILE: join(temp, "global.toml"), - MISE_DATA_DIR: join(temp, "data"), - MISE_CACHE_DIR: join(temp, "cache"), - MISE_TRUSTED_CONFIG_PATHS: temp, - PATH: `${join(temp, "bin")}:${process.env.PATH}`, - }, - }, - ); - assert.match(output, /PW_TEST_CONNECT_WS_ENDPOINT=ws:\/\/127\.0\.0\.1:19334\//); - assert.match(output, /PW_TEST_CONNECT_EXPOSE_NETWORK=''/); - const args = JSON.parse(output.trim().split("\n").at(-1)); - assert.equal(args[args.indexOf("--publish") + 1], "127.0.0.1:19334:3000"); - assert.ok(args.includes("mcr.microsoft.com/playwright:v1.65.0-noble")); - assert.ok(args.includes("playwright@1.65.0")); - assert.ok(args.includes("--rm")); - assert.equal(args.includes("--volume"), false); - assert.equal(args.includes("--mount"), false); - } finally { - rmSync(temp, { recursive: true, force: true }); - } -}); From 0ac95f0abf0e8ea5240d4ef4be0cec3db2f4b009 Mon Sep 17 00:00:00 2001 From: Max Schaefer Date: Thu, 12 Nov 2026 02:16:15 +0000 Subject: [PATCH 04/10] Add browser tooling separately from Connect ownership --- AGENTS.md | 2 +- README.md | 10 +++ home/.chezmoidata.toml | 8 ++ home/.chezmoiexternal.toml | 10 +++ home/.chezmoiignore | 1 + home/.chezmoitemplates/nono-profile | 6 +- .../sandbox/private_config.json.tmpl | 4 + .../skills/symlink_agent-browser.tmpl | 1 + .../systemd/browserless.container.tmpl | 14 +++ home/dot_config/mise/config.toml | 1 + home/dot_config/mise/tasks/services.toml | 29 +++++++ .../services/browserless/compose.yaml.tmpl | 12 +++ tests/services.test.mjs | 85 ++++++++++++++++++- 13 files changed, 179 insertions(+), 4 deletions(-) create mode 100644 home/dot_agent-browser/namespaces/sandbox/private_config.json.tmpl create mode 100644 home/dot_claude/skills/symlink_agent-browser.tmpl create mode 100644 home/dot_config/containers/systemd/browserless.container.tmpl create mode 100644 home/dot_config/services/browserless/compose.yaml.tmpl diff --git a/AGENTS.md b/AGENTS.md index 2767b1c..00150d0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,7 @@ - Every tool a CI job runs must be in its `with.tools` or declared by the repository with `project-tools: true`. - `mise use --global` and `mise upgrade --global` edit live files; reconcile those changes back into `home/`. - Linux hosts apply this source too: limit macOS-only targets with `.chezmoiignore` or `.chezmoi.os` blocks. -- Workstation services live in the mirrored source: macOS Compose stacks and Linux system Quadlet sources under `.config/services`. Activation is explicit through the service Mise tasks. +- Workstation services live in the mirrored source: macOS Compose stacks and Linux system Quadlet sources under `.config/services`, Linux user Quadlets under `.config/containers/systemd`. Activation is explicit through the service Mise tasks. - Preserve Connect's runtime identity: system-level Podman units, secret and volume on Linux; the `onepassword-connect` Compose project, named volume and OrbStack hostname on macOS. Service relocation must not recreate credentials or data. - Keep application-managed hooks, caches, history, account state and generated host inventories outside this repository. - Preserve application destination paths and unrelated local state when changing source attributes or templates. diff --git a/README.md b/README.md index b617b23..c217116 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,16 @@ Agent logins, sessions and history stay on each machine: sign in to each agent once per machine. `CLAUDE_CONFIG_DIR` keeps Claude's state file inside `~/.claude`, because the sandbox cannot replace files directly in the home directory. +[agent-browser](https://agent-browser.dev) uses the Browserless container started +by `mise run services:browserless`. Mise installs the CLI; chezmoi configures its +skill, sandbox access and endpoint. + +For project Playwright tests, run `mise run services:playwright` from the project +root outside the agent sandbox. Set the two environment variables it prints in +the test terminal, then run the project's normal browser tests. The temporary +container supplies Chromium, Firefox and WebKit at the installed project's +Playwright version, without mounting project or home files; Ctrl-C removes it. + ## Shared Agent Guidance Clone the Agents repository beside this checkout. Chezmoi links its shared diff --git a/home/.chezmoidata.toml b/home/.chezmoidata.toml index 2b19424..2976bd2 100644 --- a/home/.chezmoidata.toml +++ b/home/.chezmoidata.toml @@ -25,6 +25,14 @@ url = "https://bifrost.excloo.com/mcp/reactive-resume" [mcp.unifi_network] url = "https://bifrost.excloo.com/mcp/unifi-network" +[services.browserless] +concurrent = 4 +port = 9222 +queued = 4 +timeout = 3600000 +# renovate: datasource=docker depName=ghcr.io/browserless/chromium +version = "v2.62.0" + [services.connect] # renovate: datasource=docker depName=1password/connect-api version = "1.8.4" diff --git a/home/.chezmoiexternal.toml b/home/.chezmoiexternal.toml index 15bfac4..09d0498 100644 --- a/home/.chezmoiexternal.toml +++ b/home/.chezmoiexternal.toml @@ -1,3 +1,13 @@ +[".agents/skills/agent-browser/LICENSE"] +type = "file" +# renovate: datasource=github-releases depName=vercel-labs/agent-browser +url = "https://raw.githubusercontent.com/vercel-labs/agent-browser/v0.42.0/LICENSE" + +[".agents/skills/agent-browser/SKILL.md"] +type = "file" +# renovate: datasource=github-releases depName=vercel-labs/agent-browser +url = "https://raw.githubusercontent.com/vercel-labs/agent-browser/v0.42.0/skills/agent-browser/SKILL.md" + [".agents/skills/humanizer"] type = "archive" exact = true diff --git a/home/.chezmoiignore b/home/.chezmoiignore index 23f0773..4d543ba 100644 --- a/home/.chezmoiignore +++ b/home/.chezmoiignore @@ -1,4 +1,5 @@ {{- if ne .chezmoi.os "linux" }} +.config/containers .config/services/onepassword-connect/*.container .config/services/onepassword-connect/*.pod {{- end }} diff --git a/home/.chezmoitemplates/nono-profile b/home/.chezmoitemplates/nono-profile index 0f04e7a..0c63f61 100644 --- a/home/.chezmoitemplates/nono-profile +++ b/home/.chezmoitemplates/nono-profile @@ -2,6 +2,8 @@ "extends": "nolabs-ai/{{ .agent }}", "environment": { "set_vars": { + "AGENT_BROWSER_CONFIG": "$HOME/.agent-browser/namespaces/sandbox/config.json", + "AGENT_BROWSER_SOCKET_DIR": "$HOME/.agent-browser", "GH_CONFIG_DIR": "$HOME/.config/gh-agents", "GIT_CONFIG_GLOBAL": "$HOME/.config/git/config", "TMPDIR": "$XDG_CACHE_HOME", @@ -10,13 +12,15 @@ }, "filesystem": { "allow": [ + "$HOME/.agent-browser/namespaces/sandbox", "$HOME/.config/gh-agents", "$HOME/.local/state/mise/tracked-configs", "$HOME/Library/Application Support/orca/agent-hooks", "$XDG_CACHE_HOME", "/var/tmp" ], - "read": ["$HOME/.agents", "$HOME/.orca/agent-hooks", "$HOME/Projects/agents", "/usr/libexec/git-core"] + "read": ["$HOME/.agents", "$HOME/.orca/agent-hooks", "$HOME/Projects/agents", "/usr/libexec/git-core"], + "unix_socket_subtree_bind": ["$HOME/.agent-browser/namespaces/sandbox"] }, "groups": { "include": ["mise_manager"] }, "meta": { "name": "{{ .agent }}" } diff --git a/home/dot_agent-browser/namespaces/sandbox/private_config.json.tmpl b/home/dot_agent-browser/namespaces/sandbox/private_config.json.tmpl new file mode 100644 index 0000000..42444f8 --- /dev/null +++ b/home/dot_agent-browser/namespaces/sandbox/private_config.json.tmpl @@ -0,0 +1,4 @@ +{ + "cdp": "ws://127.0.0.1:{{ .services.browserless.port }}/chromium", + "namespace": "sandbox" +} diff --git a/home/dot_claude/skills/symlink_agent-browser.tmpl b/home/dot_claude/skills/symlink_agent-browser.tmpl new file mode 100644 index 0000000..3bd7e20 --- /dev/null +++ b/home/dot_claude/skills/symlink_agent-browser.tmpl @@ -0,0 +1 @@ +{{ joinPath .chezmoi.homeDir ".agents" "skills" "agent-browser" }} diff --git a/home/dot_config/containers/systemd/browserless.container.tmpl b/home/dot_config/containers/systemd/browserless.container.tmpl new file mode 100644 index 0000000..e804fa7 --- /dev/null +++ b/home/dot_config/containers/systemd/browserless.container.tmpl @@ -0,0 +1,14 @@ +[Container] +Environment=CONCURRENT={{ .services.browserless.concurrent }} +Environment=QUEUED={{ .services.browserless.queued }} +Environment=TIMEOUT={{ .services.browserless.timeout }} +Image=ghcr.io/browserless/chromium:{{ .services.browserless.version }} +PublishPort=127.0.0.1:{{ .services.browserless.port }}:3000 +ShmSize=1g + +[Service] +Restart=on-failure +TimeoutStartSec=300 + +[Install] +WantedBy=default.target diff --git a/home/dot_config/mise/config.toml b/home/dot_config/mise/config.toml index 5e4c467..03bd38e 100644 --- a/home/dot_config/mise/config.toml +++ b/home/dot_config/mise/config.toml @@ -17,6 +17,7 @@ codex = "latest" k9s = "latest" nono = "latest" "npm:@deepseek-ai/dsh" = "latest" +"npm:agent-browser" = "latest" opencode = "latest" pi = "latest" rclone = "latest" diff --git a/home/dot_config/mise/tasks/services.toml b/home/dot_config/mise/tasks/services.toml index 977de54..75f9db7 100644 --- a/home/dot_config/mise/tasks/services.toml +++ b/home/dot_config/mise/tasks/services.toml @@ -1,3 +1,16 @@ +["services:browserless"] +description = "Start Browserless" +run = ''' +{% if os() == "macos" %} +chezmoi apply "$HOME/.config/services" +docker compose --file "$HOME/.config/services/browserless/compose.yaml" up --detach +{% else %} +chezmoi apply "$HOME/.config/containers" +systemctl --user daemon-reload +systemctl --user start browserless.service +{% endif %} +''' + ["services:connect"] description = "Start 1Password Connect" run = ''' @@ -11,3 +24,19 @@ sudo systemctl daemon-reload sudo systemctl start onepassword-connect-pod.service {% endif %} ''' + +["services:playwright"] +description = "Start Project Playwright Browsers" +run = ''' +set -eu +version=$(node -p "require('playwright/package.json').version") +{% if os() == "macos" %} +runtime=docker +{% else %} +runtime=podman +{% endif %} +echo "export PW_TEST_CONNECT_WS_ENDPOINT=ws://127.0.0.1:$usage_port/" +echo "export PW_TEST_CONNECT_EXPOSE_NETWORK=''" +exec "$runtime" run --rm --init --user pwuser --workdir /home/pwuser --shm-size 1g --publish "127.0.0.1:$usage_port:3000" "mcr.microsoft.com/playwright:v$version-noble" npx --yes "playwright@$version" run-server --port 3000 --host 0.0.0.0 +''' +usage = 'flag "--port " default="9223" help="Loopback port for the Playwright server"' diff --git a/home/dot_config/services/browserless/compose.yaml.tmpl b/home/dot_config/services/browserless/compose.yaml.tmpl new file mode 100644 index 0000000..e692d96 --- /dev/null +++ b/home/dot_config/services/browserless/compose.yaml.tmpl @@ -0,0 +1,12 @@ +name: browserless +services: + browserless: + image: ghcr.io/browserless/chromium:{{ .services.browserless.version }} + restart: unless-stopped + shm_size: 1gb + environment: + CONCURRENT: {{ .services.browserless.concurrent }} + QUEUED: {{ .services.browserless.queued }} + TIMEOUT: {{ .services.browserless.timeout }} + ports: + - "127.0.0.1:{{ .services.browserless.port }}:3000" diff --git a/tests/services.test.mjs b/tests/services.test.mjs index a1b8cab..6da7753 100644 --- a/tests/services.test.mjs +++ b/tests/services.test.mjs @@ -1,6 +1,14 @@ import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; -import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { test } from "node:test"; @@ -10,7 +18,13 @@ const root = fileURLToPath(new URL("..", import.meta.url)); const base = ["--source", root, "--config", "/dev/null", "--config-format", "toml"]; const execute = (args, input = "") => execFileSync("chezmoi", [...base, ...args], { input, encoding: "utf8" }); -const render = (path) => execute(["execute-template", "--file", `home/${path}`]); +const render = (path, services) => + execute([ + ...(services ? ["--override-data", JSON.stringify({ services })] : []), + "execute-template", + "--file", + `home/${path}`, + ]); const yaml = (input) => JSON.parse( execute( @@ -84,3 +98,70 @@ test("Connect keeps its existing Linux pod, secret and data volume", () => { assert.match(pod, /^PublishPort=127\.0\.0\.1:8080:8080$/m); assert.match(pod, /^WantedBy=multi-user\.target$/m); }); + +test("Browserless uses the same configured port across both runtimes and the client", () => { + const services = JSON.parse(execute(["execute-template", "{{ .services | toJson }}"])); + services.browserless.port = 19333; + const compose = yaml(render("dot_config/services/browserless/compose.yaml.tmpl", services)); + assert.equal(compose.name, "browserless"); + assert.deepEqual(compose.services.browserless.ports, ["127.0.0.1:19333:3000"]); + const quadlet = render("dot_config/containers/systemd/browserless.container.tmpl", services); + assert.match(quadlet, /^PublishPort=127\.0\.0\.1:19333:3000$/m); + const client = JSON.parse( + render("dot_agent-browser/namespaces/sandbox/private_config.json.tmpl", services), + ); + assert.equal(client.cdp, "ws://127.0.0.1:19333/chromium"); +}); + +test("Project browser server matches its installed client and exposes only loopback", () => { + const temp = mkdtempSync(join(tmpdir(), "dotfiles-playwright-")); + try { + mkdirSync(join(temp, "bin")); + symlinkSync(process.execPath, join(temp, "bin/node")); + mkdirSync(join(temp, "node_modules/playwright"), { recursive: true }); + writeFileSync( + join(temp, "node_modules/playwright/package.json"), + JSON.stringify({ version: "1.65.0" }), + ); + writeFileSync( + join(temp, ".mise.toml"), + `[task_config]\nincludes = [${JSON.stringify(join(root, "home/dot_config/mise/tasks/services.toml"))}]\n`, + ); + for (const runtime of ["docker", "podman"]) { + const path = join(temp, "bin", runtime); + writeFileSync( + path, + "#!/usr/bin/env node\nconsole.log(JSON.stringify(process.argv.slice(2)));\n", + ); + chmodSync(path, 0o755); + } + const output = execFileSync( + "mise", + ["run", "--skip-tools", "services:playwright", "--port", "19334"], + { + cwd: temp, + encoding: "utf8", + env: { + ...process.env, + MISE_CONFIG_DIR: join(temp, "config"), + MISE_GLOBAL_CONFIG_FILE: join(temp, "global.toml"), + MISE_DATA_DIR: join(temp, "data"), + MISE_CACHE_DIR: join(temp, "cache"), + MISE_TRUSTED_CONFIG_PATHS: temp, + PATH: `${join(temp, "bin")}:${process.env.PATH}`, + }, + }, + ); + assert.match(output, /PW_TEST_CONNECT_WS_ENDPOINT=ws:\/\/127\.0\.0\.1:19334\//); + assert.match(output, /PW_TEST_CONNECT_EXPOSE_NETWORK=''/); + const args = JSON.parse(output.trim().split("\n").at(-1)); + assert.equal(args[args.indexOf("--publish") + 1], "127.0.0.1:19334:3000"); + assert.ok(args.includes("mcr.microsoft.com/playwright:v1.65.0-noble")); + assert.ok(args.includes("playwright@1.65.0")); + assert.ok(args.includes("--rm")); + assert.equal(args.includes("--volume"), false); + assert.equal(args.includes("--mount"), false); + } finally { + rmSync(temp, { recursive: true, force: true }); + } +}); From f6a0ba666b94cc0acd180f2ffda3b3858a01eabb Mon Sep 17 00:00:00 2001 From: Max Schaefer Date: Thu, 12 Nov 2026 02:55:23 +0000 Subject: [PATCH 05/10] Keep Linux browser service activation explicit --- README.md | 5 +++-- .../dot_config/containers/systemd/browserless.container.tmpl | 3 --- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index c217116..fb6bf6d 100644 --- a/README.md +++ b/README.md @@ -82,8 +82,9 @@ once per machine. `CLAUDE_CONFIG_DIR` keeps Claude's state file inside `~/.claud because the sandbox cannot replace files directly in the home directory. [agent-browser](https://agent-browser.dev) uses the Browserless container started -by `mise run services:browserless`. Mise installs the CLI; chezmoi configures its -skill, sandbox access and endpoint. +by `mise run services:browserless` outside the agent sandbox when needed. Mise +installs the CLI; chezmoi configures its skill, sandbox access and endpoint. +On Linux, start it explicitly after login; applying Dotfiles does not enable it. For project Playwright tests, run `mise run services:playwright` from the project root outside the agent sandbox. Set the two environment variables it prints in diff --git a/home/dot_config/containers/systemd/browserless.container.tmpl b/home/dot_config/containers/systemd/browserless.container.tmpl index e804fa7..cad3aa9 100644 --- a/home/dot_config/containers/systemd/browserless.container.tmpl +++ b/home/dot_config/containers/systemd/browserless.container.tmpl @@ -9,6 +9,3 @@ ShmSize=1g [Service] Restart=on-failure TimeoutStartSec=300 - -[Install] -WantedBy=default.target From 4dcc55b03f5f8cc33bac5e30e06e6dade4a8f527 Mon Sep 17 00:00:00 2001 From: Max Schaefer Date: Thu, 12 Nov 2026 03:11:25 +0000 Subject: [PATCH 06/10] Use native agent-browser configuration without nono --- README.md | 8 +++++--- home/.chezmoitemplates/nono-profile | 6 +----- .../{namespaces/sandbox => }/private_config.json.tmpl | 3 +-- tests/services.test.mjs | 4 +--- 4 files changed, 8 insertions(+), 13 deletions(-) rename home/dot_agent-browser/{namespaces/sandbox => }/private_config.json.tmpl (69%) diff --git a/README.md b/README.md index fb6bf6d..310bec8 100644 --- a/README.md +++ b/README.md @@ -81,13 +81,15 @@ Agent logins, sessions and history stay on each machine: sign in to each agent once per machine. `CLAUDE_CONFIG_DIR` keeps Claude's state file inside `~/.claude`, because the sandbox cannot replace files directly in the home directory. +## Browser Tools + [agent-browser](https://agent-browser.dev) uses the Browserless container started -by `mise run services:browserless` outside the agent sandbox when needed. Mise -installs the CLI; chezmoi configures its skill, sandbox access and endpoint. +by `mise run services:browserless` when needed. Mise installs the CLI; chezmoi +configures its skill and native endpoint setting. On Linux, start it explicitly after login; applying Dotfiles does not enable it. For project Playwright tests, run `mise run services:playwright` from the project -root outside the agent sandbox. Set the two environment variables it prints in +root. Set the two environment variables it prints in the test terminal, then run the project's normal browser tests. The temporary container supplies Chromium, Firefox and WebKit at the installed project's Playwright version, without mounting project or home files; Ctrl-C removes it. diff --git a/home/.chezmoitemplates/nono-profile b/home/.chezmoitemplates/nono-profile index 0c63f61..0f04e7a 100644 --- a/home/.chezmoitemplates/nono-profile +++ b/home/.chezmoitemplates/nono-profile @@ -2,8 +2,6 @@ "extends": "nolabs-ai/{{ .agent }}", "environment": { "set_vars": { - "AGENT_BROWSER_CONFIG": "$HOME/.agent-browser/namespaces/sandbox/config.json", - "AGENT_BROWSER_SOCKET_DIR": "$HOME/.agent-browser", "GH_CONFIG_DIR": "$HOME/.config/gh-agents", "GIT_CONFIG_GLOBAL": "$HOME/.config/git/config", "TMPDIR": "$XDG_CACHE_HOME", @@ -12,15 +10,13 @@ }, "filesystem": { "allow": [ - "$HOME/.agent-browser/namespaces/sandbox", "$HOME/.config/gh-agents", "$HOME/.local/state/mise/tracked-configs", "$HOME/Library/Application Support/orca/agent-hooks", "$XDG_CACHE_HOME", "/var/tmp" ], - "read": ["$HOME/.agents", "$HOME/.orca/agent-hooks", "$HOME/Projects/agents", "/usr/libexec/git-core"], - "unix_socket_subtree_bind": ["$HOME/.agent-browser/namespaces/sandbox"] + "read": ["$HOME/.agents", "$HOME/.orca/agent-hooks", "$HOME/Projects/agents", "/usr/libexec/git-core"] }, "groups": { "include": ["mise_manager"] }, "meta": { "name": "{{ .agent }}" } diff --git a/home/dot_agent-browser/namespaces/sandbox/private_config.json.tmpl b/home/dot_agent-browser/private_config.json.tmpl similarity index 69% rename from home/dot_agent-browser/namespaces/sandbox/private_config.json.tmpl rename to home/dot_agent-browser/private_config.json.tmpl index 42444f8..615ea48 100644 --- a/home/dot_agent-browser/namespaces/sandbox/private_config.json.tmpl +++ b/home/dot_agent-browser/private_config.json.tmpl @@ -1,4 +1,3 @@ { - "cdp": "ws://127.0.0.1:{{ .services.browserless.port }}/chromium", - "namespace": "sandbox" + "cdp": "ws://127.0.0.1:{{ .services.browserless.port }}/chromium" } diff --git a/tests/services.test.mjs b/tests/services.test.mjs index 6da7753..13d2569 100644 --- a/tests/services.test.mjs +++ b/tests/services.test.mjs @@ -107,9 +107,7 @@ test("Browserless uses the same configured port across both runtimes and the cli assert.deepEqual(compose.services.browserless.ports, ["127.0.0.1:19333:3000"]); const quadlet = render("dot_config/containers/systemd/browserless.container.tmpl", services); assert.match(quadlet, /^PublishPort=127\.0\.0\.1:19333:3000$/m); - const client = JSON.parse( - render("dot_agent-browser/namespaces/sandbox/private_config.json.tmpl", services), - ); + const client = JSON.parse(render("dot_agent-browser/private_config.json.tmpl", services)); assert.equal(client.cdp, "ws://127.0.0.1:19333/chromium"); }); From c90ab096fdf7438d377ee8293ab7260a64a2a7b7 Mon Sep 17 00:00:00 2001 From: Max Schaefer Date: Thu, 12 Nov 2026 03:40:01 +0000 Subject: [PATCH 07/10] Keep browser documentation independent of Orca setup --- README.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 310bec8..a9f43f1 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,15 @@ Agent logins, sessions and history stay on each machine: sign in to each agent once per machine. `CLAUDE_CONFIG_DIR` keeps Claude's state file inside `~/.claude`, because the sandbox cannot replace files directly in the home directory. +## Shared Agent Guidance + +Clone the Agents repository beside this checkout. Chezmoi links its shared +instructions and skills into the workstation. Claude Code loads that repository +as a plugin because it does not discover the shared skill collection recursively. + +Upstream skills are installed through `home/.chezmoiexternal.toml`. Keep personal +rules in the Agents repository so upstream packages stay unmodified. + ## Browser Tools [agent-browser](https://agent-browser.dev) uses the Browserless container started @@ -94,15 +103,6 @@ the test terminal, then run the project's normal browser tests. The temporary container supplies Chromium, Firefox and WebKit at the installed project's Playwright version, without mounting project or home files; Ctrl-C removes it. -## Shared Agent Guidance - -Clone the Agents repository beside this checkout. Chezmoi links its shared -instructions and skills into the workstation. Claude Code loads that repository -as a plugin because it does not discover the shared skill collection recursively. - -Upstream skills are installed through `home/.chezmoiexternal.toml`. Keep personal -rules in the Agents repository so upstream packages stay unmodified. - ## Shared Tools & Tasks `home/dot_config/mise/conf.d/tools.toml` declares shared tools; From 43bee04bb02f34cbc7e52ce3d0d06b263d2f3f3f Mon Sep 17 00:00:00 2001 From: Max Schaefer Date: Thu, 12 Nov 2026 15:42:02 +1100 Subject: [PATCH 08/10] Use shared Compose for workstation Connect --- AGENTS.md | 4 +- README.md | 15 +++++++ home/.chezmoiignore | 5 --- home/dot_config/mise/tasks/services.toml | 6 +-- .../onepassword-connect/compose.yaml.tmpl | 25 ++++++++++++ .../onepassword-connect-api.container.tmpl | 5 --- .../onepassword-connect-sync.container.tmpl | 6 --- .../onepassword-connect.pod | 5 --- tests/services.test.mjs | 40 ++++++++++++------- 9 files changed, 68 insertions(+), 43 deletions(-) delete mode 100644 home/dot_config/services/onepassword-connect/onepassword-connect-api.container.tmpl delete mode 100644 home/dot_config/services/onepassword-connect/onepassword-connect-sync.container.tmpl delete mode 100644 home/dot_config/services/onepassword-connect/onepassword-connect.pod diff --git a/AGENTS.md b/AGENTS.md index 2767b1c..7132d4e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,8 +8,8 @@ - Every tool a CI job runs must be in its `with.tools` or declared by the repository with `project-tools: true`. - `mise use --global` and `mise upgrade --global` edit live files; reconcile those changes back into `home/`. - Linux hosts apply this source too: limit macOS-only targets with `.chezmoiignore` or `.chezmoi.os` blocks. -- Workstation services live in the mirrored source: macOS Compose stacks and Linux system Quadlet sources under `.config/services`. Activation is explicit through the service Mise tasks. -- Preserve Connect's runtime identity: system-level Podman units, secret and volume on Linux; the `onepassword-connect` Compose project, named volume and OrbStack hostname on macOS. Service relocation must not recreate credentials or data. +- Workstation Compose definitions live under `.config/services`; Homelab owns Mercury’s boot configuration. Mise service tasks start already-applied definitions. +- Preserve Connect’s existing rootful Podman secret and volume on Linux; keep the `onepassword-connect` Compose project, named volume and OrbStack hostname on macOS. A runtime migration must not recreate credentials or data. - Keep application-managed hooks, caches, history, account state and generated host inventories outside this repository. - Preserve application destination paths and unrelated local state when changing source attributes or templates. - Use chezmoi's normal conflict handling for apply; do not force routine updates. diff --git a/README.md b/README.md index 2ce6a36..54860c0 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ then install the global tools it declares: mise exec chezmoi@latest -- chezmoi --source "$PWD" apply "$HOME/.config/mise" mise install mise run setup +chezmoi apply "$HOME/.config/services" mise run services:connect mise run check chezmoi status @@ -50,6 +51,20 @@ and run the same commands. Changes to live files aren't imported automatically. `mise use --global` and `mise upgrade --global` edit the live Mise configuration, so copy those edits back into `home/`. +## Workstation Services + +Compose definitions live in `~/.config/services`. Apply them with chezmoi before +using the small `services:*` Mise tasks to start a service. macOS uses Docker +Compose through OrbStack; Mercury uses rootful `podman-compose` so Connect keeps +its existing Podman volume and secret. Homelab owns Mercury’s boot service. + +Mercury’s switch from Quadlets to Compose requires an approved handover: stop the +old Connect pod, start the Compose stack, then verify credential-backed access and +retained data before removing the old Quadlet files. The Compose definition uses +the existing volume and secret as external resources; do not recreate them or run +both owners at once. Keep the old definitions available until that verification. +The macOS project, data volume, credential mount and OrbStack endpoint stay the same. + ## Agent Launch Launch Claude Code, Codex, OpenCode and Pi directly. In Orca, reset each agent's diff --git a/home/.chezmoiignore b/home/.chezmoiignore index 23f0773..d9e2b5b 100644 --- a/home/.chezmoiignore +++ b/home/.chezmoiignore @@ -1,9 +1,4 @@ -{{- if ne .chezmoi.os "linux" }} -.config/services/onepassword-connect/*.container -.config/services/onepassword-connect/*.pod -{{- end }} {{- if ne .chezmoi.os "darwin" }} -.config/services/*/compose.yaml .gitconfig .hammerspoon .ssh diff --git a/home/dot_config/mise/tasks/services.toml b/home/dot_config/mise/tasks/services.toml index 977de54..1578b37 100644 --- a/home/dot_config/mise/tasks/services.toml +++ b/home/dot_config/mise/tasks/services.toml @@ -1,13 +1,9 @@ ["services:connect"] description = "Start 1Password Connect" run = ''' -chezmoi apply "$HOME/.config/services" {% if os() == "macos" %} docker compose --file "$HOME/.config/services/onepassword-connect/compose.yaml" up --detach {% else %} -service_directory="$HOME/.config/services/onepassword-connect" -sudo install -D -m 0644 -t /etc/containers/systemd "$service_directory/onepassword-connect-api.container" "$service_directory/onepassword-connect-sync.container" "$service_directory/onepassword-connect.pod" -sudo systemctl daemon-reload -sudo systemctl start onepassword-connect-pod.service +sudo podman-compose --file "$HOME/.config/services/onepassword-connect/compose.yaml" up --detach {% endif %} ''' diff --git a/home/dot_config/services/onepassword-connect/compose.yaml.tmpl b/home/dot_config/services/onepassword-connect/compose.yaml.tmpl index 5d120df..2cf0e6a 100644 --- a/home/dot_config/services/onepassword-connect/compose.yaml.tmpl +++ b/home/dot_config/services/onepassword-connect/compose.yaml.tmpl @@ -3,17 +3,42 @@ services: connect-api: image: 1password/connect-api:{{ .services.connect.version }} restart: unless-stopped +{{- if eq .chezmoi.os "linux" }} + ports: + - "127.0.0.1:8080:8080" + secrets: &connect-secrets + - source: onepassword-connect-credentials + target: /home/opuser/.op/1password-credentials.json + uid: "999" + mode: "0400" +{{- end }} volumes: &connect-volumes - data:/home/opuser/.op/data +{{- if ne .chezmoi.os "linux" }} - type: bind read_only: true source: ${XDG_CONFIG_HOME:-${HOME}/.config}/op/connect/1password-credentials.json target: /home/opuser/.op/1password-credentials.json bind: create_host_path: false +{{- end }} connect-sync: image: 1password/connect-sync:{{ .services.connect.version }} restart: unless-stopped +{{- if eq .chezmoi.os "linux" }} + environment: + OP_HTTP_PORT: "8081" + secrets: *connect-secrets +{{- end }} volumes: *connect-volumes volumes: +{{- if eq .chezmoi.os "linux" }} + data: + external: true + name: onepassword-connect +secrets: + onepassword-connect-credentials: + external: true +{{- else }} data: {} +{{- end }} diff --git a/home/dot_config/services/onepassword-connect/onepassword-connect-api.container.tmpl b/home/dot_config/services/onepassword-connect/onepassword-connect-api.container.tmpl deleted file mode 100644 index 48a3da4..0000000 --- a/home/dot_config/services/onepassword-connect/onepassword-connect-api.container.tmpl +++ /dev/null @@ -1,5 +0,0 @@ -[Container] -Image=docker.io/1password/connect-api:{{ .services.connect.version }} -Pod=onepassword-connect.pod -Secret=onepassword-connect-credentials,target=/home/opuser/.op/1password-credentials.json,uid=999,mode=0400 -Volume=onepassword-connect:/home/opuser/.op/data diff --git a/home/dot_config/services/onepassword-connect/onepassword-connect-sync.container.tmpl b/home/dot_config/services/onepassword-connect/onepassword-connect-sync.container.tmpl deleted file mode 100644 index 7358aa7..0000000 --- a/home/dot_config/services/onepassword-connect/onepassword-connect-sync.container.tmpl +++ /dev/null @@ -1,6 +0,0 @@ -[Container] -Environment=OP_HTTP_PORT=8081 -Image=docker.io/1password/connect-sync:{{ .services.connect.version }} -Pod=onepassword-connect.pod -Secret=onepassword-connect-credentials,target=/home/opuser/.op/1password-credentials.json,uid=999,mode=0400 -Volume=onepassword-connect:/home/opuser/.op/data diff --git a/home/dot_config/services/onepassword-connect/onepassword-connect.pod b/home/dot_config/services/onepassword-connect/onepassword-connect.pod deleted file mode 100644 index c21ec53..0000000 --- a/home/dot_config/services/onepassword-connect/onepassword-connect.pod +++ /dev/null @@ -1,5 +0,0 @@ -[Pod] -PublishPort=127.0.0.1:8080:8080 - -[Install] -WantedBy=multi-user.target diff --git a/tests/services.test.mjs b/tests/services.test.mjs index a1b8cab..82d2240 100644 --- a/tests/services.test.mjs +++ b/tests/services.test.mjs @@ -10,7 +10,14 @@ const root = fileURLToPath(new URL("..", import.meta.url)); const base = ["--source", root, "--config", "/dev/null", "--config-format", "toml"]; const execute = (args, input = "") => execFileSync("chezmoi", [...base, ...args], { input, encoding: "utf8" }); -const render = (path) => execute(["execute-template", "--file", `home/${path}`]); +const render = (path, platform = "darwin") => + execute([ + "--override-data", + JSON.stringify({ chezmoi: { os: platform } }), + "execute-template", + "--file", + `home/${path}`, + ]); const yaml = (input) => JSON.parse( execute( @@ -43,7 +50,7 @@ test("Service configuration can bootstrap into a fresh home before credential te ], { env: { ...process.env, PATH: `${root}tests/bin:${process.env.PATH}` } }, ); - const file = platform === "linux" ? "onepassword-connect-api.container" : "compose.yaml"; + const file = "compose.yaml"; assert.ok(existsSync(join(destination, ".config/services/onepassword-connect", file))); } } finally { @@ -69,18 +76,21 @@ test("Connect keeps its macOS project, named volume and read-only credential mou } }); -test("Connect keeps its existing Linux pod, secret and data volume", () => { - for (const name of ["api", "sync"]) { - const lines = render(`${serviceRoot}/onepassword-connect-${name}.container.tmpl`).split("\n"); - assert.ok(lines.includes("Pod=onepassword-connect.pod")); - assert.ok(lines.includes("Volume=onepassword-connect:/home/opuser/.op/data")); - assert.ok( - lines.includes( - "Secret=onepassword-connect-credentials,target=/home/opuser/.op/1password-credentials.json,uid=999,mode=0400", - ), - ); +test("Linux Compose reuses Connect’s rootful external volume and secret", () => { + const config = yaml(render(`${serviceRoot}/compose.yaml.tmpl`, "linux")); + assert.equal(config.name, "onepassword-connect"); + assert.deepEqual(config.volumes, { data: { external: true, name: "onepassword-connect" } }); + assert.deepEqual(config.secrets, { "onepassword-connect-credentials": { external: true } }); + assert.deepEqual(config.services["connect-api"].ports, ["127.0.0.1:8080:8080"]); + for (const name of ["connect-api", "connect-sync"]) { + assert.deepEqual(config.services[name].volumes, ["data:/home/opuser/.op/data"]); + assert.deepEqual(config.services[name].secrets, [ + { + source: "onepassword-connect-credentials", + target: "/home/opuser/.op/1password-credentials.json", + uid: "999", + mode: "0400", + }, + ]); } - const pod = render(`${serviceRoot}/onepassword-connect.pod`); - assert.match(pod, /^PublishPort=127\.0\.0\.1:8080:8080$/m); - assert.match(pod, /^WantedBy=multi-user\.target$/m); }); From e3befface52aa0500ce8d1855c300490de4c54d8 Mon Sep 17 00:00:00 2001 From: Max Schaefer Date: Thu, 12 Nov 2026 15:45:18 +1100 Subject: [PATCH 09/10] Fix platform override in service staging test --- tests/services.test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/services.test.mjs b/tests/services.test.mjs index 5084e93..a3612ad 100644 --- a/tests/services.test.mjs +++ b/tests/services.test.mjs @@ -52,7 +52,7 @@ test("Service configuration can bootstrap into a fresh home before credential te "--cache", join(temp, "cache"), "--override-data", - JSON.stringify({ chezmoi: { os: platform }, ...(services ? { services } : {}) }), + JSON.stringify({ chezmoi: { os: platform } }), "apply", join(destination, ".config/services"), ], From b5adc8a02399fcb043b520c8bae9ad02c231a8b3 Mon Sep 17 00:00:00 2001 From: Max Schaefer Date: Thu, 12 Nov 2026 16:11:43 +1100 Subject: [PATCH 10/10] Keep project Playwright setup out of workstation tools --- README.md | 6 --- home/dot_config/mise/tasks/services.toml | 16 ------ tests/services.test.mjs | 63 +----------------------- 3 files changed, 1 insertion(+), 84 deletions(-) diff --git a/README.md b/README.md index 041de11..ea5c33e 100644 --- a/README.md +++ b/README.md @@ -122,12 +122,6 @@ Both platforms use the same Compose definition. On Linux it runs under the user Podman runtime; start it explicitly when needed. Apply its configuration with chezmoi first; the start task does not apply preferences or enable boot startup. -For project Playwright tests, run `mise run services:playwright` from the project -root. Set the two environment variables it prints in -the test terminal, then run the project's normal browser tests. The temporary -container supplies Chromium, Firefox and WebKit at the installed project's -Playwright version, without mounting project or home files; Ctrl-C removes it. - ## Shared Tools & Tasks `home/dot_config/mise/conf.d/tools.toml` declares shared tools; diff --git a/home/dot_config/mise/tasks/services.toml b/home/dot_config/mise/tasks/services.toml index 4f5ffed..041fe01 100644 --- a/home/dot_config/mise/tasks/services.toml +++ b/home/dot_config/mise/tasks/services.toml @@ -17,19 +17,3 @@ docker compose --file "$HOME/.config/services/onepassword-connect/compose.yaml" sudo podman-compose --file "$HOME/.config/services/onepassword-connect/compose.yaml" up --detach {% endif %} ''' - -["services:playwright"] -description = "Start Project Playwright Browsers" -run = ''' -set -eu -version=$(node -p "require('playwright/package.json').version") -{% if os() == "macos" %} -runtime=docker -{% else %} -runtime=podman -{% endif %} -echo "export PW_TEST_CONNECT_WS_ENDPOINT=ws://127.0.0.1:$usage_port/" -echo "export PW_TEST_CONNECT_EXPOSE_NETWORK=''" -exec "$runtime" run --rm --init --user pwuser --workdir /home/pwuser --shm-size 1g --publish "127.0.0.1:$usage_port:3000" "mcr.microsoft.com/playwright:v$version-noble" npx --yes "playwright@$version" run-server --port 3000 --host 0.0.0.0 -''' -usage = 'flag "--port " default="9223" help="Loopback port for the Playwright server"' diff --git a/tests/services.test.mjs b/tests/services.test.mjs index 814eba1..e287534 100644 --- a/tests/services.test.mjs +++ b/tests/services.test.mjs @@ -1,14 +1,6 @@ import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; -import { - chmodSync, - existsSync, - mkdirSync, - mkdtempSync, - rmSync, - symlinkSync, - writeFileSync, -} from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { test } from "node:test"; @@ -120,56 +112,3 @@ test("Browserless uses the same configured port across both runtimes and the cli ); assert.equal(client.cdp, "ws://127.0.0.1:19333/chromium"); }); - -test("Project browser server matches its installed client and exposes only loopback", () => { - const temp = mkdtempSync(join(tmpdir(), "dotfiles-playwright-")); - try { - mkdirSync(join(temp, "bin")); - symlinkSync(process.execPath, join(temp, "bin/node")); - mkdirSync(join(temp, "node_modules/playwright"), { recursive: true }); - writeFileSync( - join(temp, "node_modules/playwright/package.json"), - JSON.stringify({ version: "1.65.0" }), - ); - writeFileSync( - join(temp, ".mise.toml"), - `[task_config]\nincludes = [${JSON.stringify(join(root, "home/dot_config/mise/tasks/services.toml"))}]\n`, - ); - for (const runtime of ["docker", "podman"]) { - const path = join(temp, "bin", runtime); - writeFileSync( - path, - "#!/usr/bin/env node\nconsole.log(JSON.stringify(process.argv.slice(2)));\n", - ); - chmodSync(path, 0o755); - } - const output = execFileSync( - "mise", - ["run", "--skip-tools", "services:playwright", "--port", "19334"], - { - cwd: temp, - encoding: "utf8", - env: { - ...process.env, - MISE_CONFIG_DIR: join(temp, "config"), - MISE_GLOBAL_CONFIG_FILE: join(temp, "global.toml"), - MISE_DATA_DIR: join(temp, "data"), - MISE_CACHE_DIR: join(temp, "cache"), - MISE_TRUSTED_CONFIG_PATHS: temp, - PATH: `${join(temp, "bin")}:${process.env.PATH}`, - }, - }, - ); - assert.match(output, /PW_TEST_CONNECT_WS_ENDPOINT=ws:\/\/127\.0\.0\.1:19334\//); - assert.match(output, /PW_TEST_CONNECT_EXPOSE_NETWORK=''/); - const args = JSON.parse(output.trim().split("\n").at(-1)); - assert.equal(args[args.indexOf("--publish") + 1], "127.0.0.1:19334:3000"); - assert.ok(args.includes("mcr.microsoft.com/playwright:v1.65.0-noble")); - assert.ok(args.includes("playwright@1.65.0")); - assert.ok(args.includes("--rm")); - assert.equal(args.includes("--volume"), false); - assert.equal(args.includes("--mount"), false); - } finally { - rmSync(temp, { recursive: true, force: true }); - } -});