Skip to content

fix: keep playwright-cli on PATH across node versions - #14

Merged
ashwin153 merged 7 commits into
mainfrom
claude/playwright-cli-devcontainer-install-ufrxv9
Aug 12, 2026
Merged

fix: keep playwright-cli on PATH across node versions#14
ashwin153 merged 7 commits into
mainfrom
claude/playwright-cli-devcontainer-install-ufrxv9

Conversation

@ashwin153

@ashwin153 ashwin153 commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

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 --global was 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_modules with its shim in the matching bin, and only $NVM_DIR/current/bin is on PATH.

nvm install and nvm use both repoint current, so the CLI goes missing for the whole container, not just the shell that switched — current is a symlink on disk, so anything inheriting the feature's containerEnv PATH loses it too.

Reproduced against a real nvm install (node 22 → 20):

$ nvm use 20
Now using node v20.20.2
$ playwright-cli --version
bash: playwright-cli: command not found

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 from sudo, whose secure_path covers /usr/local/bin but never nvm. @playwright/cli declares node >=18, so it runs on whatever version is active.

Dropping the write into nvm's tree also lets the chown of the global root go; it was only there to undo root-owned files the install left in the remote user's node_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.
old install new install
outside nvm FAIL PASS
survives node switch FAIL PASS

pre-commit passes on the changed files. Feature version bumped to 1.2.0; the playwright:1 pin 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-cli disappearing from PATH after nvm use / nvm install.

Installs @playwright/cli under a dedicated prefix (/usr/local/share/playwright-cli) and symlinks it to /usr/local/bin/playwright-cli, instead of a plain npm install --global into nvm’s per-version global root. One copy stays reachable across node switches and via sudo.

Adds regression checks that the binary lives outside $NVM_DIR and still runs after switching to node 20. Documents the behavior and bumps the feature to 1.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.

  • Installs the CLI beneath /usr/local/share/playwright-cli and links it from /usr/local/bin.
  • Adds placement, Node-switch, and remote-user writability regression checks.
  • Documents Node-version behavior and unsupported sudo usage, updates the Node test feature to major version 2, and bumps the feature version to 1.2.0.

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

Filename Overview
src/playwright/install.sh Moves the CLI to a shared prefix, but the ownership repair leaves prefixes made user-writable by an earlier PR revision writable across upgrades.
test/playwright/ubuntu.sh Adds useful regression coverage, although its writability check does not cover writable directories or the executed module tree.
src/playwright/README.md Documents the shared installation, Node-switch behavior, ownership intent, and unsupported sudo execution.
src/playwright/NOTES.md Mirrors the detailed operational rationale and security boundary documented in the README.
test/playwright/scenarios.json Updates the scenario to exercise the Node feature major version used by consumers.
src/playwright/devcontainer-feature.json Bumps the feature version to 1.2.0 for the changed installation behavior.

Reviews (5): Last reviewed commit: "chore: drop the explanatory comments fro..." | Re-trigger Greptile

Greptile also left 1 inline comment on this PR.

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
Comment thread src/playwright/install.sh

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
fi

One 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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread src/playwright/install.sh Outdated
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

Copy link
Copy Markdown
Contributor Author

@greptileai @softlight Please re-review — both scores above are pinned to a64c85d, and the findings behind them were fixed in 4258f05.

  • Softlight, UX 4/5 — "Running the CLI with sudo fails before it starts". /usr/local/bin/playwright-cli is now a wrapper, not a symlink; it resolves node (and PLAYWRIGHT_BROWSERS_PATH, which sudo also drops with containerEnv) only when the caller has neither.
  • Greptile, Confidence 3/5 — the privileged-path target being user-writable. The chown of the install prefix is gone; it stays root:root with a+rX.

Both are now covered by checks that were confirmed to fail on a64c85d: runs_under_sudo and not_writable_by_remote_user. CI is green, with all four new checks passing in a real container.


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

Copy link
Copy Markdown
Contributor Author

@greptileai @softlight Re-review please — head is now c6f0b02, CI green.

Since the last scores (both pinned to a64c85d):

  • 4258f05 — wrapper resolves node for sudo/cron; install prefix stays root:root (Greptile's P1).
  • c6f0b02 — wrapper also restores PLAYWRIGHT_MCP_BROWSER, the second variable containerEnv sets, so a browser command from sudo/cron uses the bundled Chromium instead of the uninstalled chrome channel (Softlight's second finding).

Six checks now cover the feature, each confirmed to fail on the commit that introduced the defect it guards: placement outside $NVM_DIR, survival across a node version switch, running under sudo, opening a browser under sudo with no --browser flag, and the install prefix being read-only to the remote user.

Worth a specific look: everything found so far has been a distinct consequence of putting the CLI on a privileged path, so the sudo/cron paths are where I'd expect anything remaining to be.


Generated by Claude Code

Comment thread src/playwright/install.sh Outdated
# 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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread src/playwright/install.sh Outdated
Comment thread src/playwright/install.sh Outdated
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

Copy link
Copy Markdown
Contributor Author

@greptileai @softlight Re-review please — head is 6c5f537, CI green.

The change since your last pass is a removal, not a patch. Every finding after the first was on the sudo wrapper I had added, so the wrapper is gone:

  • Both P1s — a wrapper that prepends $NVM_DIR/current/bin cannot be made safe here. The node feature runs chown "${USERNAME}:nvm" "${NVM_DIR}", so the remote user owns the node binaries; sudo would be root executing a user-replaceable interpreter regardless of which version it picked. Same for the Chromium tree, which is chowned to that user.
  • The node-version guard would have made the wrapper more correct while leaving both P1s open, so it is declined rather than applied.

/usr/local/bin/playwright-cli is a plain symlink again and the docs state sudo is unsupported — which is what it was before this branch, so no regression.

What remains is the original fix, +22 lines in install.sh: @playwright/cli installs under /usr/local/share/playwright-cli instead of nvm's per-version global root, stays root:root with a+rX, and survives nvm use / nvm install. Three checks cover it — placement outside $NVM_DIR, survival across a node version switch, and the install being read-only to the remote user.

Greptile: your first finding (user-writable install prefix) is still fixed and still covered; it no longer depends on sudo to matter, since one install now backs every node version.


Generated by Claude Code

claude added 2 commits August 12, 2026 03:36
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

Copy link
Copy Markdown
Contributor Author

@greptileai @softlight Final re-review — head is 15fd1fe, CI green.

Since the last pass, no behavior changed. Two housekeeping commits:

  • 7429f5b — the scenario pinned the node feature to major 1 while the feature's docs and the consuming devcontainer both ask for major 2, so the suite was not exercising the version anyone ships. Now on node:2, and the version-switch check passes against it.
  • 15fd1fe — removed the explanatory comments I had added to install.sh and ubuntu.sh. Neither file carried comments before this branch; the rationale lives in NOTES.md and the commit messages.

The change under review is install.sh +11 −5: @playwright/cli installs under /usr/local/share/playwright-cli rather than nvm's per-version global root, is symlinked to /usr/local/bin/playwright-cli, and stays root:root with a+rX. Three checks cover it — placement outside $NVM_DIR, survival across nvm use, and the install being read-only to the remote user.

The sudo wrapper and everything it dragged in are gone; sudo is documented as unsupported, matching the behavior before this branch.


Generated by Claude Code

Comment thread src/playwright/install.sh Outdated
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}"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Inherited writable CLI prefix

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-climain 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
@ashwin153
ashwin153 enabled auto-merge (squash) August 12, 2026 03:53
@ashwin153
ashwin153 merged commit 81f5072 into main Aug 12, 2026
2 checks passed
@ashwin153
ashwin153 deleted the claude/playwright-cli-devcontainer-install-ufrxv9 branch August 12, 2026 03:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants