Repository navigation
fix(core): accept a host-supplied Maestro scope root - #2404
Conversation
Adds PERCY_MAESTRO_BS_SCOPE_ROOT. When the BrowserStack host sets it, the
value is the scope root: the relay globs {root}/**/{name}.png, the manual
walker recurses the same root, and the realpath containment check anchors
on it. Non-absolute values are ignored so a malformed injection falls back
to the composed convention. Android, iOS and self-hosted keep their
existing behaviour when the variable is unset.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
pranavz28
left a comment
There was a problem hiding this comment.
Claude Code Review (automated) — 2 inline finding(s). Full report in the PR comment below. Verdict: Failed - see PR comment.
Claude Code PR ReviewPR: #2404 • Head: 862606e • Reviewers: stack-code-reviewer SummaryAdds The mechanics are well built: the realpath + separator-prefix containment check is preserved on both the The blocking issue is what the override removes rather than what it adds: Review Table
Findings
Noted, not raised as a defect
Verification notes
Verdict: FAIL — one High finding (session isolation collapses under the override). The override mechanism itself is sound; it needs |
Keys fast-glob's dot option on the same predicate as the search pattern so the primary lookup and the walker fallback agree beneath an operator-chosen root, bounds both root-recursive globs to the walker's existing depth cap, and names the variable actually in play in the containment-failure message instead of always saying "session dir". Adds specs for a dot-prefixed directory under the override root, a name shared by two session subtrees, and a containment escape on the composed BrowserStack path. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
When the injected root contains a subdirectory named for the session, that subtree becomes the scope root, so the glob, the walker fallback and the realpath containment check all narrow to one session. Roots without such a subdirectory are used as given, which is the layout the override exists for. Adds specs for a sibling session holding a newer file of the same name, a filePath pointing into another session's subtree, and a session-named entry that is not a directory. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Matches how the surrounding code composes BrowserStack roots and keeps the new join out of the path-traversal sink set, so no additional file-level semgrep suppression is needed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
pranavz28
left a comment
There was a problem hiding this comment.
Claude Code Review (automated) — 2 inline finding(s). Full report in the PR comment below. Verdict: Passed.
| // layouts have no dot-prefixed segments, so omitting it there keeps the | ||
| // byte-identical behavior. | ||
| files = await glob(searchPattern, selfHosted ? { dot: true } : undefined); | ||
| files = await glob(searchPattern, scopedGlob ? { dot: true, deep: MAX_SEARCH_DEPTH } : undefined); |
There was a problem hiding this comment.
[Low] deep bound may sit one level short of the walker's cap, and newly bounds the self-hosted path
Two small follow-ups on the new cap — the original "unbounded glob" finding is resolved by this line.
deep: MAX_SEARCH_DEPTHand the walker'sif (depth > MAX_SEARCH_DEPTH) returnare reported not to be equivalent: testing fast-glob 3.x,deep: 15reaches one directory level less than the walker does. A file at exactly the boundary depth would then be found by the manual-walker fallback but missed by the primary glob. Flagging the provenance honestly — this is the reviewer's empirical result; I was not able to re-run the check myself.deepnow also applies to the self-hosted path, which previously had no cap. A customer whose--test-output-dirnests deeper than the cap would newly get a 404 instead of a hit.
Suggestion: use deep: MAX_SEARCH_DEPTH + 1, or add a comment recording that the two bounds intentionally differ by a level. The self-hosted cap is worth a release-note line — it is a behaviour change on an existing path, even though it aligns that path with the walker's long-standing limit.
Reviewer: stack-code-reviewer
| let overrideRoot = bsScopeRootOverride(); | ||
| if (overrideRoot) { | ||
| recursiveScope = true; | ||
| let sessionScoped = `${overrideRoot}/${sessionId}`; |
There was a problem hiding this comment.
[Low] New stat TOCTOU lacks the explanatory comment its sibling carries
This stat sits in the same shape as the self-hosted PERCY_MAESTRO_SCREENSHOT_DIR stat a few lines above, which carries an explicit comment on why the race is acceptable (realpath, not stat, is the security boundary).
Functionally this is fine: the stat only chooses which string becomes scopeRoot, and locateScreenshot still realpaths both sides before the prefix check. But a future reader has to re-derive that, and could mistake it for a security control.
Suggestion: one line mirroring the existing comment at maestro-screenshot.js:116-121.
Reviewer: stack-code-reviewer
Claude Code PR ReviewPR: #2404 • Head: 2180cb5 • Reviewers: stack-code-reviewer Continues the previous review — changes since SummaryThree commits since the last review ( Review Table
Resolved since
|
Problem
Maestro screenshots taken on BrowserStack App Automate 404 at the relay, so the Percy
build finalizes with zero snapshots and fails with
no_snapshots:App Automate has relocated its per-session artifacts to a per-device tree. On iOS the
Maestro debug output now lands at:
The relay composes its BrowserStack search path as
{tmpRoot}/{sessionId}{_test_suite},where
tmpRootcomes fromPERCY_APP_AUTOMATE_TMP_DIR. That composition cannot reach thenew layout, for two independent reasons:
<sessionId>segment — the CLI always appends one, and thehost cannot absorb
<device>/logsinto a tmp-root value that the CLI will then suffix.<device>_prefix (*_maestro_debug_*) that the newdirectory names no longer carry.
Android is unaffected: its relocation preserved the
{root}/{sessionId}_test_suite/logs/...shape below the new root, so relocating the root composes correctly there.
Change
Adds
PERCY_MAESTRO_BS_SCOPE_ROOT. When the host sets it, that value is the scope root:{root}/**/{name}.pngwith no layout assumption;Decisions
A new variable rather than reusing
PERCY_APP_AUTOMATE_TMP_DIR. That variable isdefined as a root the CLI composes onto. Overloading it to sometimes mean "the complete
root" would make its meaning depend on platform and on whether the composed path happens to
exist. The two variables answer different questions — "where does the tree start" vs. "what
exactly is the tree" — so they stay separate.
PERCY_APP_AUTOMATE_TMP_DIRkeeps workingunchanged for the Android layout and for any future relocation that only moves the root.
The override wins over the composed convention. If a host sets both, the explicit root
is the more specific statement of intent. Covered by a test.
The security boundary is relocated, never widened. The
realpath+ separator-prefixcontainment check is unchanged; it just anchors on the overridden root. A
filePathsuppliedby the SDK is still rejected unless it resolves inside that root, and a symlink placed inside
the root that points outside it is still rejected. Both are covered by tests.
Non-absolute values are ignored (fall back to the composed convention). A malformed
injection should degrade to today's behaviour, not produce a cwd-relative root. This also
disposes of
'/': trailing separators are trimmed first, so'/'becomes'', which is notabsolute and is therefore ignored — the whole filesystem can never become the scope root.
Trailing separators are trimmed so the glob pattern and the containment prefix compose
cleanly whether or not the host includes one.
No existence pre-check on the override. The self-hosted path 400s on a missing
PERCY_MAESTRO_SCREENSHOT_DIRbecause that is customer configuration and the customer canfix it. This root is host configuration; a stale value is not something the customer can
act on, so it 404s like any other missing screenshot rather than surfacing a 400 that reads
as user error.
recursiveScopeis derived inside the handler, not accepted from the request. It iscomputed from
selfHostedand the env var and passed down as a separate flag, soselfHostedkeeps its existing meaning everywhere it is already used (dot: trueon theglob, the walker fallback returning
[], and the wording of the "resolved outside" error).The override applies to Android too. The root replaces the entire convention, not just
the iOS arm — nothing about the mechanism is platform-specific, and a future Android
relocation of the same shape should not need a second change here.
The manual walker mirrors the glob. Its
acceptpredicate keeps the_maestro_debug_guard only on the conventional iOS path; with an explicit root the host has already narrowed
the tree to one session, so there is no convention left to filter on and any exact
{name}.pngmatch is taken.The recursive glob is bounded and matches the walker.
{root}/**/{name}.pngpreviouslyhad no depth limit while
manualScreenshotWalkcapped at 15, so the primary lookup and itsfallback disagreed about how far they would descend. Both now share one
MAX_SEARCH_DEPTHconstant. This is a small behaviour change for existing self-hosted users with a
--test-output-dirtree deeper than 15 levels; that depth is implausible for Maestro output,and the alternative is leaving an unbounded traversal on a root chosen by host config.
dot: trueis keyed on the search pattern, not onselfHosted. It was previously enabledonly for self-hosted, on the reasoning that BrowserStack layouts contain no dot-prefixed
segments. That reasoning does not survive this change: once an operator can point the root
anywhere, a dot-prefixed segment beneath it is possible, and fast-glob would skip what the
walker fallback would happily find. Both lookup paths now agree.
The containment-failure message names the variable actually in play. It previously read
session dirfor anything that was not self-hosted, which under the override would point anoperator at the wrong knob. All three roots now name themselves.
Session scoping under the override
sessionIdstill narrows the search when the injected root allows it. If{root}/{sessionId}is a directory, that subtree becomes the scope root — so the glob, thewalker fallback and the realpath containment check all narrow to a single session, and a
filePathpointing into a sibling session's subtree is rejected. Roots that contain no suchsubdirectory are used as given, which is the case for the per-device layout this change
exists for: it has no
sessionIdsegment for the CLI to key on, so there is nothing tonarrow to.
That leaves one residual case — a root shared by concurrent sessions that does not carry
per-session subdirectories. The precondition there is explicit: the root a host injects must
contain only what the CLI process serving that session may read. On App Automate this holds
— the host starts the CLI per session, and the root it supplies belongs to a device allocated
exclusively to that session for its duration. A host that cannot guarantee that should inject
either a per-session root or a root containing per-session subdirectories; both work.
Where the same name genuinely does appear more than once under one root, the pre-existing
rule applies unchanged: exact filename match, newest
mtimewins. Both behaviours are nowpinned by specs — one where a sibling session holds a newer file of the same name and the
narrowed lookup still returns the caller's own, and one where two session subtrees share a
root that is not narrowed.
Backwards compatibility
With
PERCY_MAESTRO_BS_SCOPE_ROOTunset, every code path is byte-identical to before:BrowserStack Android, BrowserStack iOS, the
filePathfast path, and self-hosted(
PERCY_MAESTRO_SCREENSHOT_DIR) all behave exactly as they do today. Old CLIs simply ignorethe variable.
Rollout note
This is the CLI half. It only takes effect once the App Automate host injects
PERCY_MAESTRO_BS_SCOPE_ROOTand pins a CLI release that contains this change — a hostthat injects the variable while pinned to a release without it falls back silently to the
legacy path and the 404 persists.
Tests
Fifteen specs added under
/percy/maestro-screenshot, covering: finding a screenshot theplatform convention cannot reach; the same on Android; precedence over
PERCY_APP_AUTOMATE_TMP_DIR; trailing-slash tolerance; non-absolute values falling back;filePathcontainment re-anchored on the overridden root (in-root resolves, out-of-rootrejected); a symlink inside the root that escapes it being rejected; a 404 for a
non-existent root; the trim/null semantics of the exported helper; a dot-prefixed directory
beneath the override root; a name shared by two session subtrees resolving to the newest
file; and a containment escape on the composed BrowserStack path, which had no coverage
before; narrowing to a session subtree when the root has one, including a sibling session
holding a newer same-named file and a
filePathaimed at another session's subtree; and asession-named entry that is not a directory falling back to the root.
Verified locally: the full
/percy/maestro-screenshotsuite (80 specs — 65 pre-existing plusthe 15 new) passes, and
eslintis clean on all three changed files.