From a64c85d4e40ec5c4090c43633f5b0ae33b2efa1d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 22:33:36 +0000 Subject: [PATCH 1/7] fix: keep playwright-cli on PATH across node versions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The feature ran "npm install --global", but the node feature installs node through nvm, whose global root is one directory per node version — the package landed in $NVM_DIR/versions/node//lib/node_modules and its shim in the matching bin, with only $NVM_DIR/current/bin on PATH. Any "nvm install" or "nvm use" repoints "current", so playwright-cli went missing for the whole container, not just the shell that switched: $ nvm use 20 Now using node v20.20.2 $ playwright-cli --version bash: playwright-cli: command not found It now installs under its own prefix, /usr/local/share/playwright-cli, symlinked to /usr/local/bin/playwright-cli. One copy, reachable from every node version — and from sudo, whose secure_path covers /usr/local/bin but never nvm. The package declares node >=18, so it runs on whatever version is active. Dropping the write into nvm's tree also removes the chown that was there to undo root-owned files left in the remote user's global root. Both new checks fail against the old install and pass against the new one: one asserts the resolved binary is outside $NVM_DIR, the other installs a second node version and runs the CLI under it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01C2a5dErY6bxw9DHG96DA7R --- src/playwright/NOTES.md | 14 ++++++++++++++ src/playwright/README.md | 14 ++++++++++++++ src/playwright/devcontainer-feature.json | 2 +- src/playwright/install.sh | 20 +++++++++++++------- test/playwright/ubuntu.sh | 23 +++++++++++++++++++++++ 5 files changed, 65 insertions(+), 8 deletions(-) diff --git a/src/playwright/NOTES.md b/src/playwright/NOTES.md index 56c612e..dfdb952 100644 --- a/src/playwright/NOTES.md +++ b/src/playwright/NOTES.md @@ -13,6 +13,20 @@ when it cannot find it — it never installs node for you, so your own version p Debian and Ubuntu only. Playwright installs Chromium's shared libraries with apt and has no equivalent for other package managers. +## Node versions + +The CLI is installed under its own prefix, `/usr/local/share/playwright-cli`, and symlinked to +`/usr/local/bin/playwright-cli`. It is deliberately not a plain `npm install --global`: the node +feature installs node through nvm, whose global root is one directory per node version +(`$NVM_DIR/versions/node//lib/node_modules`), with `$NVM_DIR/current/bin` on `PATH`. A +`npm install --global` there belongs to whichever version was active at build time, so `nvm install` +or `nvm use` repoints `current` and `playwright-cli` drops off `PATH` for the whole container — +not just the shell that switched. + +Switch node freely; one copy of the CLI stays reachable, and it runs on whatever node is active +(the package needs node 18 or newer). `/usr/local/bin` is also on `sudo`'s `secure_path`, so +`sudo playwright-cli` resolves too. + ## Browsers Chromium only, plus the headless shell and ffmpeg that come with it. Firefox and WebKit are not diff --git a/src/playwright/README.md b/src/playwright/README.md index 40702ca..aec4d7b 100644 --- a/src/playwright/README.md +++ b/src/playwright/README.md @@ -32,6 +32,20 @@ when it cannot find it — it never installs node for you, so your own version p Debian and Ubuntu only. Playwright installs Chromium's shared libraries with apt and has no equivalent for other package managers. +## Node versions + +The CLI is installed under its own prefix, `/usr/local/share/playwright-cli`, and symlinked to +`/usr/local/bin/playwright-cli`. It is deliberately not a plain `npm install --global`: the node +feature installs node through nvm, whose global root is one directory per node version +(`$NVM_DIR/versions/node//lib/node_modules`), with `$NVM_DIR/current/bin` on `PATH`. A +`npm install --global` there belongs to whichever version was active at build time, so `nvm install` +or `nvm use` repoints `current` and `playwright-cli` drops off `PATH` for the whole container — +not just the shell that switched. + +Switch node freely; one copy of the CLI stays reachable, and it runs on whatever node is active +(the package needs node 18 or newer). `/usr/local/bin` is also on `sudo`'s `secure_path`, so +`sudo playwright-cli` resolves too. + ## Browsers Chromium only, plus the headless shell and ffmpeg that come with it. Firefox and WebKit are not diff --git a/src/playwright/devcontainer-feature.json b/src/playwright/devcontainer-feature.json index 61f3091..8ce9019 100644 --- a/src/playwright/devcontainer-feature.json +++ b/src/playwright/devcontainer-feature.json @@ -17,5 +17,5 @@ "type": "string" } }, - "version": "1.1.0" + "version": "1.2.0" } diff --git a/src/playwright/install.sh b/src/playwright/install.sh index 4548c6e..ea9e3da 100755 --- a/src/playwright/install.sh +++ b/src/playwright/install.sh @@ -3,6 +3,7 @@ set -euo pipefail VERSION="${VERSION:-latest}" BROWSERS_PATH="/usr/local/share/ms-playwright" +INSTALL_PATH="/usr/local/share/playwright-cli" export DEBIAN_FRONTEND=noninteractive export PLAYWRIGHT_BROWSERS_PATH="${BROWSERS_PATH}" @@ -24,15 +25,18 @@ if ! command -v npm >/dev/null 2>&1; then exit 1 fi -npm_root="$(npm root -g)" -owner="$(stat -c '%u:%g' "${npm_root}")" +owner="$(stat -c '%u:%g' "$(npm root -g)")" -npm install --global "@playwright/cli@${VERSION}" +# Not "npm install --global": nvm's global root is one directory per node version, so the package and +# its bin shim would vanish from PATH the moment someone runs "nvm use" or "nvm install". Own prefix, +# symlinked into /usr/local/bin, keeps one copy that every node version — and sudo — resolves. +npm install --global --prefix "${INSTALL_PATH}" "@playwright/cli@${VERSION}" -playwright_core="$(find "${npm_root}" -maxdepth 6 -path '*/playwright-core/cli.js' -print -quit)" +node_modules="${INSTALL_PATH}/lib/node_modules" +playwright_core="$(find "${node_modules}" -maxdepth 6 -path '*/playwright-core/cli.js' -print -quit)" if [ -z "${playwright_core}" ]; then - echo "playwright-core was not found under ${npm_root}" >&2 + echo "playwright-core was not found under ${node_modules}" >&2 exit 1 fi @@ -44,5 +48,7 @@ node "${playwright_core}" install chromium rm -rf /var/lib/apt/lists/* npm cache clean --force -chown -R "${owner}" "${npm_root}" "$(npm prefix -g)/bin" "${BROWSERS_PATH}" -chmod -R a+rX "${BROWSERS_PATH}" +ln -sfn "${INSTALL_PATH}/bin/playwright-cli" /usr/local/bin/playwright-cli + +chown -R "${owner}" "${INSTALL_PATH}" "${BROWSERS_PATH}" +chmod -R a+rX "${INSTALL_PATH}" "${BROWSERS_PATH}" diff --git a/test/playwright/ubuntu.sh b/test/playwright/ubuntu.sh index 7193cc6..5eb15a8 100755 --- a/test/playwright/ubuntu.sh +++ b/test/playwright/ubuntu.sh @@ -4,10 +4,33 @@ set -e # shellcheck source=/dev/null source \ dev-container-features-test-lib + +NVM_DIR="${NVM_DIR:-/usr/local/share/nvm}" + +# An "npm install --global" under nvm lands in the active node version's directory, so it is only +# ever one "nvm use" away from disappearing. +outside_nvm() { + case "$(readlink -f "$(command -v playwright-cli)")" in + "${NVM_DIR}"/*) return 1 ;; + esac +} + +survives_node_switch() { + # shellcheck source=/dev/null + . "${NVM_DIR}/nvm.sh" && + nvm install 20 >/dev/null && + nvm use 20 >/dev/null && + test "$(node -v | cut -d. -f1)" = v20 && + playwright-cli --version +} + check 'check if playwright-cli exists' bash -c "command -v playwright-cli" check 'check if the browsers path is exported' bash -c "test \"${PLAYWRIGHT_BROWSERS_PATH:-}\" = /usr/local/share/ms-playwright" check 'check if the default browser is chromium' bash -c "test \"${PLAYWRIGHT_MCP_BROWSER:-}\" = chromium" check 'check if chromium was downloaded' bash -c "ls /usr/local/share/ms-playwright | grep -q chromium" check 'check if the browsers are readable by the remote user' bash -c "test -r /usr/local/share/ms-playwright && test -x /usr/local/share/ms-playwright" check 'check if the browser opens with no --browser flag' bash -c "PLAYWRIGHT_CLI_SESSION=featuretest playwright-cli open && PLAYWRIGHT_CLI_SESSION=featuretest playwright-cli close" +check 'check if playwright-cli lives outside the nvm version directories' outside_nvm +# Leave this last: it repoints nvm's "current" symlink for every later check in this container. +check 'check if playwright-cli survives a node version switch' survives_node_switch reportResults From 4258f055081edfd9c4c16863a7a6793be1d2b3b0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 22:44:32 +0000 Subject: [PATCH 2/7] fix: run playwright-cli through a wrapper that resolves node MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The symlink into /usr/local/bin made the CLI findable but not runnable. npm's launcher resolves node through "#!/usr/bin/env node", and the callers that most need a fixed path get a PATH with no nvm directory in it — sudo replaces it with secure_path, cron starts from almost nothing — so: $ sudo playwright-cli --version /usr/bin/env: 'node': No such file or directory /usr/local/bin/playwright-cli is now a wrapper that resolves node, and PLAYWRIGHT_BROWSERS_PATH which sudo also drops along with the rest of containerEnv. It only fills in what the caller is missing, so an ordinary shell still runs the CLI on the node version it has active. Dropping the recursive chown of the install prefix along with it. The remote user had no reason to own the package, and owning it meant being able to rewrite what sudo then executes as root. Both checks were confirmed to fail against the previous commit: the plain symlink dies on "env: 'node'" once node lives only under nvm, and the prefix was writable by the remote user. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01C2a5dErY6bxw9DHG96DA7R --- src/playwright/NOTES.md | 14 ++++++++++++-- src/playwright/README.md | 14 ++++++++++++-- src/playwright/install.sh | 23 +++++++++++++++++++++-- test/playwright/ubuntu.sh | 14 ++++++++++++++ 4 files changed, 59 insertions(+), 6 deletions(-) diff --git a/src/playwright/NOTES.md b/src/playwright/NOTES.md index dfdb952..acaf09c 100644 --- a/src/playwright/NOTES.md +++ b/src/playwright/NOTES.md @@ -24,8 +24,18 @@ or `nvm use` repoints `current` and `playwright-cli` drops off `PATH` for the wh not just the shell that switched. Switch node freely; one copy of the CLI stays reachable, and it runs on whatever node is active -(the package needs node 18 or newer). `/usr/local/bin` is also on `sudo`'s `secure_path`, so -`sudo playwright-cli` resolves too. +(the package needs node 18 or newer). + +`/usr/local/bin/playwright-cli` is a small wrapper rather than a symlink. Finding the CLI is not the +same as being able to run it: npm's launcher resolves node through `#!/usr/bin/env node`, and the +callers that most need a fixed path get a `PATH` with no nvm directory in it — `sudo` replaces it +with `secure_path`, cron starts from almost nothing, and both drop `containerEnv` on the way. The +wrapper resolves node and `PLAYWRIGHT_BROWSERS_PATH` itself when the caller has neither, so +`sudo playwright-cli` works. It defers to both when they are already set, so a normal shell still +runs on the node version it has active. + +The wrapper and the package stay root-owned and read-only to the remote user, since `sudo` runs +whatever they point at as root. ## Browsers diff --git a/src/playwright/README.md b/src/playwright/README.md index aec4d7b..5861ddb 100644 --- a/src/playwright/README.md +++ b/src/playwright/README.md @@ -43,8 +43,18 @@ or `nvm use` repoints `current` and `playwright-cli` drops off `PATH` for the wh not just the shell that switched. Switch node freely; one copy of the CLI stays reachable, and it runs on whatever node is active -(the package needs node 18 or newer). `/usr/local/bin` is also on `sudo`'s `secure_path`, so -`sudo playwright-cli` resolves too. +(the package needs node 18 or newer). + +`/usr/local/bin/playwright-cli` is a small wrapper rather than a symlink. Finding the CLI is not the +same as being able to run it: npm's launcher resolves node through `#!/usr/bin/env node`, and the +callers that most need a fixed path get a `PATH` with no nvm directory in it — `sudo` replaces it +with `secure_path`, cron starts from almost nothing, and both drop `containerEnv` on the way. The +wrapper resolves node and `PLAYWRIGHT_BROWSERS_PATH` itself when the caller has neither, so +`sudo playwright-cli` works. It defers to both when they are already set, so a normal shell still +runs on the node version it has active. + +The wrapper and the package stay root-owned and read-only to the remote user, since `sudo` runs +whatever they point at as root. ## Browsers diff --git a/src/playwright/install.sh b/src/playwright/install.sh index ea9e3da..ec7427c 100755 --- a/src/playwright/install.sh +++ b/src/playwright/install.sh @@ -48,7 +48,26 @@ node "${playwright_core}" install chromium rm -rf /var/lib/apt/lists/* npm cache clean --force -ln -sfn "${INSTALL_PATH}/bin/playwright-cli" /usr/local/bin/playwright-cli +# A symlink would be enough to find the CLI, but not to run it: npm's launcher resolves node through +# "env", and the callers that most need a stable path — sudo, cron — hand it a PATH with no nvm +# directory in it. The wrapper resolves node itself, and only when the caller has none, so an +# ordinary shell still runs the CLI on whichever version it already has active. +cat >/usr/local/bin/playwright-cli </dev/null 2>&1; then + PATH="\${NVM_DIR:-/usr/local/share/nvm}/current/bin:\${PATH}" + export PATH +fi + +# sudo and cron drop containerEnv too, and the browsers are not where playwright looks by default. +PLAYWRIGHT_BROWSERS_PATH="\${PLAYWRIGHT_BROWSERS_PATH:-${BROWSERS_PATH}}" +export PLAYWRIGHT_BROWSERS_PATH + +exec "${INSTALL_PATH}/bin/playwright-cli" "\$@" +EOF +chmod 0755 /usr/local/bin/playwright-cli -chown -R "${owner}" "${INSTALL_PATH}" "${BROWSERS_PATH}" +# Deliberately not chowned to the remote user: /usr/local/bin/playwright-cli is reachable through +# sudo, so what it executes stays root-owned and read-only to everyone else. +chown -R "${owner}" "${BROWSERS_PATH}" chmod -R a+rX "${INSTALL_PATH}" "${BROWSERS_PATH}" diff --git a/test/playwright/ubuntu.sh b/test/playwright/ubuntu.sh index 5eb15a8..ff73565 100755 --- a/test/playwright/ubuntu.sh +++ b/test/playwright/ubuntu.sh @@ -24,6 +24,18 @@ survives_node_switch() { playwright-cli --version } +# sudo replaces PATH with secure_path, which has no nvm directory in it, so this fails on a plain +# symlink to npm's launcher: the CLI resolves and then its "#!/usr/bin/env node" cannot. +runs_under_sudo() { + sudo -n playwright-cli --version +} + +# ... and what sudo runs as root must not be rewritable by the unprivileged user. +not_writable_by_remote_user() { + ! test -w /usr/local/bin/playwright-cli && + ! test -w /usr/local/share/playwright-cli/bin/playwright-cli +} + check 'check if playwright-cli exists' bash -c "command -v playwright-cli" check 'check if the browsers path is exported' bash -c "test \"${PLAYWRIGHT_BROWSERS_PATH:-}\" = /usr/local/share/ms-playwright" check 'check if the default browser is chromium' bash -c "test \"${PLAYWRIGHT_MCP_BROWSER:-}\" = chromium" @@ -31,6 +43,8 @@ check 'check if chromium was downloaded' bash -c "ls /usr/local/share/ms-playwri check 'check if the browsers are readable by the remote user' bash -c "test -r /usr/local/share/ms-playwright && test -x /usr/local/share/ms-playwright" check 'check if the browser opens with no --browser flag' bash -c "PLAYWRIGHT_CLI_SESSION=featuretest playwright-cli open && PLAYWRIGHT_CLI_SESSION=featuretest playwright-cli close" check 'check if playwright-cli lives outside the nvm version directories' outside_nvm +check 'check if playwright-cli runs under sudo' runs_under_sudo +check 'check if the cli sudo runs is read-only to the remote user' not_writable_by_remote_user # Leave this last: it repoints nvm's "current" symlink for every later check in this container. check 'check if playwright-cli survives a node version switch' survives_node_switch reportResults From c6f0b02f964eaf530ec6222af54eb9bf4c6bc8d9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 02:47:23 +0000 Subject: [PATCH 3/7] fix: default the browser channel in the playwright-cli wrapper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wrapper restored PLAYWRIGHT_BROWSERS_PATH but not PLAYWRIGHT_MCP_BROWSER, and containerEnv sets both. sudo and cron drop the pair together, so a browser command from either still reverted to the branded chrome channel and looked for /opt/google/chrome/chrome, which this feature never installs — the same failure eee7eb1 fixed for ordinary shells, reintroduced on the paths this branch added. "sudo playwright-cli --version" could not catch it, because --version never looks for a browser. The check now opens and closes a session under sudo, matching the ordinary-shell check that caught this the first time. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01C2a5dErY6bxw9DHG96DA7R --- src/playwright/NOTES.md | 7 ++++--- src/playwright/README.md | 7 ++++--- src/playwright/install.sh | 8 ++++++-- test/playwright/ubuntu.sh | 7 +++++-- 4 files changed, 19 insertions(+), 10 deletions(-) diff --git a/src/playwright/NOTES.md b/src/playwright/NOTES.md index acaf09c..d9c507d 100644 --- a/src/playwright/NOTES.md +++ b/src/playwright/NOTES.md @@ -30,9 +30,10 @@ Switch node freely; one copy of the CLI stays reachable, and it runs on whatever same as being able to run it: npm's launcher resolves node through `#!/usr/bin/env node`, and the callers that most need a fixed path get a `PATH` with no nvm directory in it — `sudo` replaces it with `secure_path`, cron starts from almost nothing, and both drop `containerEnv` on the way. The -wrapper resolves node and `PLAYWRIGHT_BROWSERS_PATH` itself when the caller has neither, so -`sudo playwright-cli` works. It defers to both when they are already set, so a normal shell still -runs on the node version it has active. +wrapper restores node, `PLAYWRIGHT_BROWSERS_PATH` and `PLAYWRIGHT_MCP_BROWSER` itself when the caller +is missing them, so `sudo playwright-cli open` finds the bundled Chromium rather than the branded +`chrome` channel. It defers to each of them when already set, so a normal shell still runs on the +node version it has active. The wrapper and the package stay root-owned and read-only to the remote user, since `sudo` runs whatever they point at as root. diff --git a/src/playwright/README.md b/src/playwright/README.md index 5861ddb..60ea10a 100644 --- a/src/playwright/README.md +++ b/src/playwright/README.md @@ -49,9 +49,10 @@ Switch node freely; one copy of the CLI stays reachable, and it runs on whatever same as being able to run it: npm's launcher resolves node through `#!/usr/bin/env node`, and the callers that most need a fixed path get a `PATH` with no nvm directory in it — `sudo` replaces it with `secure_path`, cron starts from almost nothing, and both drop `containerEnv` on the way. The -wrapper resolves node and `PLAYWRIGHT_BROWSERS_PATH` itself when the caller has neither, so -`sudo playwright-cli` works. It defers to both when they are already set, so a normal shell still -runs on the node version it has active. +wrapper restores node, `PLAYWRIGHT_BROWSERS_PATH` and `PLAYWRIGHT_MCP_BROWSER` itself when the caller +is missing them, so `sudo playwright-cli open` finds the bundled Chromium rather than the branded +`chrome` channel. It defers to each of them when already set, so a normal shell still runs on the +node version it has active. The wrapper and the package stay root-owned and read-only to the remote user, since `sudo` runs whatever they point at as root. diff --git a/src/playwright/install.sh b/src/playwright/install.sh index ec7427c..b5ee1cc 100755 --- a/src/playwright/install.sh +++ b/src/playwright/install.sh @@ -4,6 +4,7 @@ set -euo pipefail VERSION="${VERSION:-latest}" BROWSERS_PATH="/usr/local/share/ms-playwright" INSTALL_PATH="/usr/local/share/playwright-cli" +MCP_BROWSER="chromium" export DEBIAN_FRONTEND=noninteractive export PLAYWRIGHT_BROWSERS_PATH="${BROWSERS_PATH}" @@ -59,9 +60,12 @@ if ! command -v node >/dev/null 2>&1; then export PATH fi -# sudo and cron drop containerEnv too, and the browsers are not where playwright looks by default. +# Those same callers drop containerEnv, which is the only thing pointing the CLI at this image's +# browsers: without it playwright looks for them under HOME, and defaults to the branded chrome +# channel at /opt/google/chrome/chrome that nothing here installs. PLAYWRIGHT_BROWSERS_PATH="\${PLAYWRIGHT_BROWSERS_PATH:-${BROWSERS_PATH}}" -export PLAYWRIGHT_BROWSERS_PATH +PLAYWRIGHT_MCP_BROWSER="\${PLAYWRIGHT_MCP_BROWSER:-${MCP_BROWSER}}" +export PLAYWRIGHT_BROWSERS_PATH PLAYWRIGHT_MCP_BROWSER exec "${INSTALL_PATH}/bin/playwright-cli" "\$@" EOF diff --git a/test/playwright/ubuntu.sh b/test/playwright/ubuntu.sh index ff73565..b2ff635 100755 --- a/test/playwright/ubuntu.sh +++ b/test/playwright/ubuntu.sh @@ -25,9 +25,12 @@ survives_node_switch() { } # sudo replaces PATH with secure_path, which has no nvm directory in it, so this fails on a plain -# symlink to npm's launcher: the CLI resolves and then its "#!/usr/bin/env node" cannot. +# symlink to npm's launcher: the CLI resolves and then its "#!/usr/bin/env node" cannot. Opening a +# browser as well, because sudo drops containerEnv and --version alone never looks for one. runs_under_sudo() { - sudo -n playwright-cli --version + sudo -n playwright-cli --version && + sudo -n env PLAYWRIGHT_CLI_SESSION=sudotest playwright-cli open && + sudo -n env PLAYWRIGHT_CLI_SESSION=sudotest playwright-cli close } # ... and what sudo runs as root must not be rewritable by the unprivileged user. From 6c5f53730abab1363447fd8433d25a5fb88cb0d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 03:11:59 +0000 Subject: [PATCH 4/7] fix: drop the sudo wrapper from the playwright feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Supporting sudo was never part of the bug this branch fixes, and it cannot be done safely here. The node feature chowns $NVM_DIR to the remote user, so a wrapper that reaches into nvm to find node has root executing an interpreter that an unprivileged user can replace; the browsers under PLAYWRIGHT_BROWSERS_PATH are chowned to that user as well. Guarding the node version, as suggested, would leave both holes open. /usr/local/bin/playwright-cli goes back to a symlink and the docs say plainly that sudo is unsupported, which is what it was before this branch. What the branch actually fixes is unchanged: the CLI installs under its own prefix and survives "nvm use". Keeping the ownership check, which no longer depends on sudo to matter — one install now backs every node version, so no single user should be able to rewrite what the others execute. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01C2a5dErY6bxw9DHG96DA7R --- src/playwright/NOTES.md | 19 ++++++++----------- src/playwright/README.md | 19 ++++++++----------- src/playwright/install.sh | 29 ++++------------------------- test/playwright/ubuntu.sh | 14 ++------------ 4 files changed, 22 insertions(+), 59 deletions(-) diff --git a/src/playwright/NOTES.md b/src/playwright/NOTES.md index d9c507d..6426f01 100644 --- a/src/playwright/NOTES.md +++ b/src/playwright/NOTES.md @@ -26,17 +26,14 @@ not just the shell that switched. Switch node freely; one copy of the CLI stays reachable, and it runs on whatever node is active (the package needs node 18 or newer). -`/usr/local/bin/playwright-cli` is a small wrapper rather than a symlink. Finding the CLI is not the -same as being able to run it: npm's launcher resolves node through `#!/usr/bin/env node`, and the -callers that most need a fixed path get a `PATH` with no nvm directory in it — `sudo` replaces it -with `secure_path`, cron starts from almost nothing, and both drop `containerEnv` on the way. The -wrapper restores node, `PLAYWRIGHT_BROWSERS_PATH` and `PLAYWRIGHT_MCP_BROWSER` itself when the caller -is missing them, so `sudo playwright-cli open` finds the bundled Chromium rather than the branded -`chrome` channel. It defers to each of them when already set, so a normal shell still runs on the -node version it has active. - -The wrapper and the package stay root-owned and read-only to the remote user, since `sudo` runs -whatever they point at as root. +The install stays root-owned and read-only to the remote user. One copy now backs every node +version, so no single user should be able to rewrite what the others execute. + +It runs from environments that inherit `containerEnv`, which is how the CLI finds both node and its +browsers. `sudo` is not one of them — it replaces `PATH` with `secure_path` and drops `containerEnv`, +and `sudo playwright-cli` has never worked for this feature. Making it work would mean root +executing an interpreter and a browser out of trees the remote user owns, so it stays unsupported; +run the CLI as the remote user. ## Browsers diff --git a/src/playwright/README.md b/src/playwright/README.md index 60ea10a..1f40b00 100644 --- a/src/playwright/README.md +++ b/src/playwright/README.md @@ -45,17 +45,14 @@ not just the shell that switched. Switch node freely; one copy of the CLI stays reachable, and it runs on whatever node is active (the package needs node 18 or newer). -`/usr/local/bin/playwright-cli` is a small wrapper rather than a symlink. Finding the CLI is not the -same as being able to run it: npm's launcher resolves node through `#!/usr/bin/env node`, and the -callers that most need a fixed path get a `PATH` with no nvm directory in it — `sudo` replaces it -with `secure_path`, cron starts from almost nothing, and both drop `containerEnv` on the way. The -wrapper restores node, `PLAYWRIGHT_BROWSERS_PATH` and `PLAYWRIGHT_MCP_BROWSER` itself when the caller -is missing them, so `sudo playwright-cli open` finds the bundled Chromium rather than the branded -`chrome` channel. It defers to each of them when already set, so a normal shell still runs on the -node version it has active. - -The wrapper and the package stay root-owned and read-only to the remote user, since `sudo` runs -whatever they point at as root. +The install stays root-owned and read-only to the remote user. One copy now backs every node +version, so no single user should be able to rewrite what the others execute. + +It runs from environments that inherit `containerEnv`, which is how the CLI finds both node and its +browsers. `sudo` is not one of them — it replaces `PATH` with `secure_path` and drops `containerEnv`, +and `sudo playwright-cli` has never worked for this feature. Making it work would mean root +executing an interpreter and a browser out of trees the remote user owns, so it stays unsupported; +run the CLI as the remote user. ## Browsers diff --git a/src/playwright/install.sh b/src/playwright/install.sh index b5ee1cc..c6e323c 100755 --- a/src/playwright/install.sh +++ b/src/playwright/install.sh @@ -4,7 +4,6 @@ set -euo pipefail VERSION="${VERSION:-latest}" BROWSERS_PATH="/usr/local/share/ms-playwright" INSTALL_PATH="/usr/local/share/playwright-cli" -MCP_BROWSER="chromium" export DEBIAN_FRONTEND=noninteractive export PLAYWRIGHT_BROWSERS_PATH="${BROWSERS_PATH}" @@ -30,7 +29,7 @@ owner="$(stat -c '%u:%g' "$(npm root -g)")" # Not "npm install --global": nvm's global root is one directory per node version, so the package and # its bin shim would vanish from PATH the moment someone runs "nvm use" or "nvm install". Own prefix, -# symlinked into /usr/local/bin, keeps one copy that every node version — and sudo — resolves. +# symlinked into /usr/local/bin, keeps one copy that every node version resolves. npm install --global --prefix "${INSTALL_PATH}" "@playwright/cli@${VERSION}" node_modules="${INSTALL_PATH}/lib/node_modules" @@ -49,29 +48,9 @@ node "${playwright_core}" install chromium rm -rf /var/lib/apt/lists/* npm cache clean --force -# A symlink would be enough to find the CLI, but not to run it: npm's launcher resolves node through -# "env", and the callers that most need a stable path — sudo, cron — hand it a PATH with no nvm -# directory in it. The wrapper resolves node itself, and only when the caller has none, so an -# ordinary shell still runs the CLI on whichever version it already has active. -cat >/usr/local/bin/playwright-cli </dev/null 2>&1; then - PATH="\${NVM_DIR:-/usr/local/share/nvm}/current/bin:\${PATH}" - export PATH -fi - -# Those same callers drop containerEnv, which is the only thing pointing the CLI at this image's -# browsers: without it playwright looks for them under HOME, and defaults to the branded chrome -# channel at /opt/google/chrome/chrome that nothing here installs. -PLAYWRIGHT_BROWSERS_PATH="\${PLAYWRIGHT_BROWSERS_PATH:-${BROWSERS_PATH}}" -PLAYWRIGHT_MCP_BROWSER="\${PLAYWRIGHT_MCP_BROWSER:-${MCP_BROWSER}}" -export PLAYWRIGHT_BROWSERS_PATH PLAYWRIGHT_MCP_BROWSER - -exec "${INSTALL_PATH}/bin/playwright-cli" "\$@" -EOF -chmod 0755 /usr/local/bin/playwright-cli +ln -sfn "${INSTALL_PATH}/bin/playwright-cli" /usr/local/bin/playwright-cli -# Deliberately not chowned to the remote user: /usr/local/bin/playwright-cli is reachable through -# sudo, so what it executes stays root-owned and read-only to everyone else. +# The install stays root-owned: it is shared by every node version now, so no one user should be +# able to rewrite what everyone else executes. chown -R "${owner}" "${BROWSERS_PATH}" chmod -R a+rX "${INSTALL_PATH}" "${BROWSERS_PATH}" diff --git a/test/playwright/ubuntu.sh b/test/playwright/ubuntu.sh index b2ff635..827c67c 100755 --- a/test/playwright/ubuntu.sh +++ b/test/playwright/ubuntu.sh @@ -24,16 +24,7 @@ survives_node_switch() { playwright-cli --version } -# sudo replaces PATH with secure_path, which has no nvm directory in it, so this fails on a plain -# symlink to npm's launcher: the CLI resolves and then its "#!/usr/bin/env node" cannot. Opening a -# browser as well, because sudo drops containerEnv and --version alone never looks for one. -runs_under_sudo() { - sudo -n playwright-cli --version && - sudo -n env PLAYWRIGHT_CLI_SESSION=sudotest playwright-cli open && - sudo -n env PLAYWRIGHT_CLI_SESSION=sudotest playwright-cli close -} - -# ... and what sudo runs as root must not be rewritable by the unprivileged user. +# One install now backs every node version, so no single user should be able to rewrite it. not_writable_by_remote_user() { ! test -w /usr/local/bin/playwright-cli && ! test -w /usr/local/share/playwright-cli/bin/playwright-cli @@ -46,8 +37,7 @@ check 'check if chromium was downloaded' bash -c "ls /usr/local/share/ms-playwri check 'check if the browsers are readable by the remote user' bash -c "test -r /usr/local/share/ms-playwright && test -x /usr/local/share/ms-playwright" check 'check if the browser opens with no --browser flag' bash -c "PLAYWRIGHT_CLI_SESSION=featuretest playwright-cli open && PLAYWRIGHT_CLI_SESSION=featuretest playwright-cli close" check 'check if playwright-cli lives outside the nvm version directories' outside_nvm -check 'check if playwright-cli runs under sudo' runs_under_sudo -check 'check if the cli sudo runs is read-only to the remote user' not_writable_by_remote_user +check 'check if the shared install is read-only to the remote user' not_writable_by_remote_user # Leave this last: it repoints nvm's "current" symlink for every later check in this container. check 'check if playwright-cli survives a node version switch' survives_node_switch reportResults From 7429f5b3cbb69da2326616ee066e1e4ed9ba18b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 03:36:15 +0000 Subject: [PATCH 5/7] test: run the playwright scenario against node 2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scenario pinned the node feature to major 1 while the feature's own docs — and the devcontainer that consumes it — ask for major 2, so the suite was not exercising the version anyone ships. This branch turns on where the node feature puts global packages, which makes testing the wrong major worth fixing now rather than later. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01C2a5dErY6bxw9DHG96DA7R --- test/playwright/scenarios.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/playwright/scenarios.json b/test/playwright/scenarios.json index 423932e..75b9665 100644 --- a/test/playwright/scenarios.json +++ b/test/playwright/scenarios.json @@ -2,7 +2,7 @@ "ubuntu": { "features": { "ghcr.io/devcontainers/features/common-utils:2": {}, - "ghcr.io/devcontainers/features/node:1": {}, + "ghcr.io/devcontainers/features/node:2": {}, "playwright": { "version": "latest" } From 15fd1fe095c9240920e868ffc72f43c0ef6b7449 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 03:44:03 +0000 Subject: [PATCH 6/7] chore: drop the explanatory comments from the playwright scripts Neither script carried comments before this branch, and the rationale they repeated is in NOTES.md and the commit history already. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01C2a5dErY6bxw9DHG96DA7R --- src/playwright/install.sh | 5 ----- test/playwright/ubuntu.sh | 5 +---- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/src/playwright/install.sh b/src/playwright/install.sh index c6e323c..a63f757 100755 --- a/src/playwright/install.sh +++ b/src/playwright/install.sh @@ -27,9 +27,6 @@ fi owner="$(stat -c '%u:%g' "$(npm root -g)")" -# Not "npm install --global": nvm's global root is one directory per node version, so the package and -# its bin shim would vanish from PATH the moment someone runs "nvm use" or "nvm install". Own prefix, -# symlinked into /usr/local/bin, keeps one copy that every node version resolves. npm install --global --prefix "${INSTALL_PATH}" "@playwright/cli@${VERSION}" node_modules="${INSTALL_PATH}/lib/node_modules" @@ -50,7 +47,5 @@ npm cache clean --force ln -sfn "${INSTALL_PATH}/bin/playwright-cli" /usr/local/bin/playwright-cli -# The install stays root-owned: it is shared by every node version now, so no one user should be -# able to rewrite what everyone else executes. chown -R "${owner}" "${BROWSERS_PATH}" chmod -R a+rX "${INSTALL_PATH}" "${BROWSERS_PATH}" diff --git a/test/playwright/ubuntu.sh b/test/playwright/ubuntu.sh index 827c67c..728d9ff 100755 --- a/test/playwright/ubuntu.sh +++ b/test/playwright/ubuntu.sh @@ -7,8 +7,6 @@ source \ NVM_DIR="${NVM_DIR:-/usr/local/share/nvm}" -# An "npm install --global" under nvm lands in the active node version's directory, so it is only -# ever one "nvm use" away from disappearing. outside_nvm() { case "$(readlink -f "$(command -v playwright-cli)")" in "${NVM_DIR}"/*) return 1 ;; @@ -24,7 +22,6 @@ survives_node_switch() { playwright-cli --version } -# One install now backs every node version, so no single user should be able to rewrite it. not_writable_by_remote_user() { ! test -w /usr/local/bin/playwright-cli && ! test -w /usr/local/share/playwright-cli/bin/playwright-cli @@ -38,6 +35,6 @@ check 'check if the browsers are readable by the remote user' bash -c "test -r / check 'check if the browser opens with no --browser flag' bash -c "PLAYWRIGHT_CLI_SESSION=featuretest playwright-cli open && PLAYWRIGHT_CLI_SESSION=featuretest playwright-cli close" check 'check if playwright-cli lives outside the nvm version directories' outside_nvm check 'check if the shared install is read-only to the remote user' not_writable_by_remote_user -# Leave this last: it repoints nvm's "current" symlink for every later check in this container. +# Keep last: repoints nvm's "current" symlink for every later check. check 'check if playwright-cli survives a node version switch' survives_node_switch reportResults From f8098a89873bf336c44ebe6468f284cedc0c3c46 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 03:51:05 +0000 Subject: [PATCH 7/7] fix: reclaim the playwright-cli prefix instead of inheriting it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The script relied on npm creating the prefix fresh as root; "chmod -R a+rX" only adds bits, so a prefix that already existed under another owner kept its write permissions and the install stayed rewritable by that user. Chowning to root makes the property the install establishes rather than one it happens to get. Not reachable from a published image — no released version creates this directory, and the feature only publishes from main — but the invariant is worth asserting rather than assuming. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01C2a5dErY6bxw9DHG96DA7R --- src/playwright/install.sh | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/playwright/install.sh b/src/playwright/install.sh index a63f757..e30801f 100755 --- a/src/playwright/install.sh +++ b/src/playwright/install.sh @@ -47,5 +47,8 @@ npm cache clean --force ln -sfn "${INSTALL_PATH}/bin/playwright-cli" /usr/local/bin/playwright-cli +chown -R root:root "${INSTALL_PATH}" +chmod -R a+rX,go-w "${INSTALL_PATH}" + chown -R "${owner}" "${BROWSERS_PATH}" -chmod -R a+rX "${INSTALL_PATH}" "${BROWSERS_PATH}" +chmod -R a+rX "${BROWSERS_PATH}"