fix: keep playwright-cli on PATH across node versions - #14
Conversation
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/<active>/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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C2a5dErY6bxw9DHG96DA7R
|
|
||
| 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 |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
There was a problem hiding this comment.
Correct, and reproduced. With node reachable only through nvm, the plain symlink dies exactly as described:
$ sudo -n /usr/local/bin/playwright-cli --version
/usr/bin/env: 'node': No such file or directory
Fixed in 4258f05. /usr/local/bin/playwright-cli is now a wrapper instead of a symlink. It fills in only what the caller is missing, so a normal shell keeps running the CLI on whichever node version it has active — which is the property this PR exists to protect:
if ! command -v node >/dev/null 2>&1; then
PATH="${NVM_DIR:-/usr/local/share/nvm}/current/bin:${PATH}"
export PATH
fiOne thing your report didn't mention that bites the same callers: sudo resets the environment, so it drops PLAYWRIGHT_BROWSERS_PATH from containerEnv too, and the browsers aren't where Playwright looks by default. The wrapper defaults that as well, again only when unset.
Added the sudo playwright-cli --version check you asked for. Verified it fails against the previous commit and passes against this one.
Generated by Claude Code
There was a problem hiding this comment.
Right, and this one is my inconsistency rather than an edge case: I reasoned "sudo drops containerEnv" and then restored only one of the two variables containerEnv sets. Fixed in c6f0b02 — the wrapper now defaults PLAYWRIGHT_MCP_BROWSER alongside PLAYWRIGHT_BROWSERS_PATH, each still deferring to a caller-provided value:
--- empty env (sudo/cron simulation) ---
PLAYWRIGHT_MCP_BROWSER=chromium
PLAYWRIGHT_BROWSERS_PATH=/usr/local/share/ms-playwright
--- caller sets its own browser ---
PLAYWRIGHT_MCP_BROWSER=firefox
PLAYWRIGHT_BROWSERS_PATH=/custom
Your point about the test is the more useful half: --version never looks for a browser, so it could not have caught this. runs_under_sudo now opens and closes a session with no --browser flag, which is exactly the check that caught the same failure for ordinary shells in eee7eb1.
For the record, launching as root is safe here — playwright-core appends --no-sandbox unless chromiumSandbox: true is set explicitly, so the sudo path does not need special handling.
Generated by Claude Code
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C2a5dErY6bxw9DHG96DA7R
|
@greptileai @softlight Please re-review — both scores above are pinned to
Both are now covered by checks that were confirmed to fail on Generated by Claude Code |
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C2a5dErY6bxw9DHG96DA7R
|
@greptileai @softlight Re-review please — head is now Since the last scores (both pinned to
Six checks now cover the feature, each confirmed to fail on the commit that introduced the defect it guards: placement outside Worth a specific look: everything found so far has been a distinct consequence of putting the CLI on a privileged path, so the Generated by Claude Code |
| # ordinary shell still runs the CLI on whichever version it already has active. | ||
| cat >/usr/local/bin/playwright-cli <<EOF | ||
| #!/bin/sh | ||
| if ! command -v node >/dev/null 2>&1; then |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
There was a problem hiding this comment.
Not taking this one, because the wrapper it applies to is gone as of 6c5f537 — but the finding is what pushed me to look at the wrapper properly, so it did its job.
Your analysis of the guard is correct: command -v node finding a node says nothing about it being new enough, and the package needs 18+. Adding the version check would have fixed that. The reason I removed the wrapper instead is that Greptile flagged two P1s on the same lines, and those are not fixable: the node feature does chown "${USERNAME}:nvm" "${NVM_DIR}", so the remote user owns the node binaries. A wrapper that prepends $NVM_DIR/current/bin for sudo has root running a user-replaceable interpreter no matter which version it selects — the guard would have made the wrapper more correct and left it unsafe.
sudo support was mine, not the bug this branch fixes, and it never worked before the branch. So /usr/local/bin/playwright-cli is a plain symlink again and the docs say sudo is unsupported. Both of your earlier findings stand and remain fixed.
Generated by Claude Code
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C2a5dErY6bxw9DHG96DA7R
|
@greptileai @softlight Re-review please — head is The change since your last pass is a removal, not a patch. Every finding after the first was on the
What remains is the original fix, +22 lines in Greptile: your first finding (user-writable install prefix) is still fixed and still covered; it no longer depends on Generated by Claude Code |
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C2a5dErY6bxw9DHG96DA7R
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C2a5dErY6bxw9DHG96DA7R
|
@greptileai @softlight Final re-review — head is Since the last pass, no behavior changed. Two housekeeping commits:
The change under review is The Generated by Claude Code |
| ln -sfn "${INSTALL_PATH}/bin/playwright-cli" /usr/local/bin/playwright-cli | ||
|
|
||
| chown -R "${owner}" "${BROWSERS_PATH}" | ||
| chmod -R a+rX "${INSTALL_PATH}" "${BROWSERS_PATH}" |
There was a problem hiding this comment.
When rebuilding an image whose prefix was chowned to the remote user by the earlier implementation, chmod -R a+rX preserves that ownership and its existing write permissions while /usr/local/bin/playwright-cli continues to resolve into the writable tree, allowing a later privileged invocation to execute user-controlled launcher or module code. How this was verified: The current script excludes INSTALL_PATH from chown, and its additive chmod does not clear inherited write bits.
There was a problem hiding this comment.
Taking the fix in f8098a8, though not at P1 — the mechanism is right and the severity is not.
The mechanism reproduces exactly as you describe. chmod -R a+rX only adds bits, so an existing prefix keeps its owner and its write permissions:
BEFORE: remote user CAN write the launcher
=== old line: chmod -R a+rX only ===
STILL WRITABLE
=== new lines ===
AFTER: reclaimed, not writable by remote user
still readable/executable by remote user
On severity: no published image can be in that state. No released version of this feature creates /usr/local/share/playwright-cli — main still installs into nvm's global root — and the workflow only publishes on push to main, so the implementation that chowned this prefix exists solely on unmerged commits of this PR. Reaching it means having built an image from a mid-review commit.
Worth fixing anyway, for a reason narrower than the one you gave: the script was getting root ownership by accident, because npm happened to create the directory as root, and not_writable_by_remote_user passed for that incidental reason rather than an enforced one. It now chowns to root:root and clears group/other write, so the install establishes the property instead of inheriting it.
One limit I'll state rather than paper over: the feature test builds from a clean base every time, so no check exercises the rebuild-over-existing-prefix path. The chown makes the outcome unconditional, but the test suite is not what demonstrates that.
Generated by Claude Code
There was a problem hiding this comment.
The fix is correct and complete. chown -R root:root "${INSTALL_PATH}" unconditionally reclaims ownership before chmod -R a+rX,go-w clears write bits for group/other — so the property is established by the script regardless of prior state, not inherited.
Your severity analysis holds: the vulnerable state (prefix chowned to remote user) can only exist in images built from unmerged mid-review commits, not from any published version of the feature. The fix is still the right call for the reason you named — the previous pass/fail of not_writable_by_remote_user was incidental, and now the script enforces what the test was checking.
Resolved from my side.
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C2a5dErY6bxw9DHG96DA7R
Softlight Overview
UX Score: 5/5
Softlight reviewed this change and found no issues.
To run Softlight again, click here or comment @softlight.
npm install --globalwas the wrong home for the CLI. The node feature installs node through nvm, and nvm's global root is one directory per node version — the package landed in$NVM_DIR/versions/node/<active>/lib/node_moduleswith its shim in the matchingbin, and only$NVM_DIR/current/binis onPATH.nvm installandnvm useboth repointcurrent, so the CLI goes missing for the whole container, not just the shell that switched —currentis a symlink on disk, so anything inheriting the feature'scontainerEnvPATHloses it too.Reproduced against a real nvm install (node 22 → 20):
Fix
Install under a dedicated prefix,
/usr/local/share/playwright-cli, and symlink it to/usr/local/bin/playwright-cli. One copy, reachable from every node version — and fromsudo, whosesecure_pathcovers/usr/local/binbut never nvm.@playwright/clideclaresnode >=18, so it runs on whatever version is active.Dropping the write into nvm's tree also lets the
chownof the global root go; it was only there to undo root-owned files the install left in the remote user'snode_modules.Tests
The previous fix in this feature landed because every existing check passed against a broken image, so both new checks were verified to fail against the old install and pass against the new one:
outside_nvm— the resolved binary is not under$NVM_DIR.survives_node_switch— installs a second node version, switches to it, and runs the CLI.outside nvmsurvives node switchpre-commitpasses on the changed files. Feature version bumped to1.2.0; theplaywright:1pin in the devcontainer repo picks it up with no change there.Generated by Claude Code
Note
Medium Risk
Changes how and where the CLI is installed and owned, which can affect PATH resolution and upgrades for existing feature users. Regression tests cover the main failure mode.
Overview
Fixes
playwright-clidisappearing fromPATHafternvm use/nvm install.Installs
@playwright/cliunder a dedicated prefix (/usr/local/share/playwright-cli) and symlinks it to/usr/local/bin/playwright-cli, instead of a plainnpm install --globalinto nvm’s per-version global root. One copy stays reachable across node switches and viasudo.Adds regression checks that the binary lives outside
$NVM_DIRand still runs after switching to node 20. Documents the behavior and bumps the feature to1.2.0.Reviewed by Cursor Bugbot for commit a64c85d. Configure here.
Greptile Summary
The PR moves playwright-cli out of nvm's version-specific global root so it remains available after Node version switches.
Confidence Score: 3/5
The PR is not yet safe to merge because an upgrade from the earlier user-owned shared prefix can leave the privileged CLI target writable by the remote user.
The reply shown as “Reply from :” claims that dropping INSTALL_PATH from chown fixed the issue, but current code neither restores root ownership nor clears inherited write permissions, so an existing prefix created by the earlier revision remains a concrete counterexample.
Files Needing Attention: src/playwright/install.sh and test/playwright/ubuntu.sh
Important Files Changed
Reviews (5): Last reviewed commit: "chore: drop the explanatory comments fro..." | Re-trigger Greptile