Skip to content

Feat/sdk 7250 capture wdio conf - #147

Merged
rahulpsq merged 15 commits into
sdk_v9_pre_prod_14augfrom
feat/sdk-7250-capture-wdio-conf
Aug 14, 2026
Merged

Feat/sdk 7250 capture wdio conf#147
rahulpsq merged 15 commits into
sdk_v9_pre_prod_14augfrom
feat/sdk-7250-capture-wdio-conf

Conversation

@rahulpsq

Copy link
Copy Markdown
Contributor

What is this about?

Related Jira task/s

Release (mandatory for every PR — required for the ready-for-review label)

Version bump: (required — tick exactly one)

  • minor (backwards-compatible feature)
  • patch (bug fix or other small change)

Release notes type: (optional)

  • New Feature
  • Bug Fix
  • Other Improvement

Release notes (customer-facing): (optional but encouraged)

Release notes (internal): (required — engineer-facing; what actually changed / why)

Checklist

  • Ready to review
  • Has it been tested locally?

PR Validations

Run Tests: Comment RUN_TESTS to trigger sanity tests.

AakashHotchandani and others added 15 commits August 11, 2026 17:33
The archive uploaded at onComplete carried only our own two debug logs, so
triaging an App-A11y no-scan report meant asking the customer how they had
configured the service. It now also carries a credential-redacted copy of
their wdio config, the local config files it imports, and package.json.

WebdriverIO keeps the config path in ConfigParser's private #configFilePath
(v8 and v9 alike) and no service can reach it, so configCapture.ts resolves it
through a ladder of fallbacks: the `config-path` key yargs leaves behind from
`run <configPath>`, the raw argv positional, rootDir, cwd, and finally a single
unambiguous *.conf.* in either directory. Resolved once in onPrepare and
published on the environment so the upload path never re-derives it from cwd --
that re-derivation is the bug SDK-5993 fixed in the Node SDK.

Opt out with `disableAutoCaptureLogs: true` or
BROWSERSTACK_DISABLE_AUTO_CAPTURE_LOGS=true. The flag is mirrored onto the
environment because the detached cleanup rescue calls uploadLogs with no
options -- and since opting out leaves logsUploaded false, that rescue is armed
on exactly the runs that opted out.

Also fixes two latent archive bugs this made reachable: the staging directory is
now per-run (the fixed tmpdir()/logs.tar names let concurrent runs clobber and
unlink each other's archives) and archive entry names are de-duplicated (two
captured files sharing a basename silently overwrote each other).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The config-capture line names only the config files, so regression automation had no way
to assert that package.json and the service log actually made it into the tarball -- it
could only infer it. Emit the complete entry list at debug level right before the archive
is written, which is the one place the whole manifest is known.

Consumed by BStackAutomation's SDK-7250 coverage
(common_helper.assert_wdio_auto_capture_archive_contains).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…, dedup, path

Review findings, all verified against a real uploaded bundle before and after.

1. Redaction missed compound secret keys. The whole-word pass rejects the letter before
   `Secret`/`Token`/`Key`, so `clientSecret` / `refreshToken` / `privateKey` survived it --
   and so did snake_case `client_secret`, which the review did not mention. Added a second
   pass anchored on the SUFFIX. It is deliberately case-sensitive: requiring a capitalised
   suffix (camelCase) or an explicit `_` (snake_case) is what separates `privateKey` from
   `hotkey` and `client_secret` from `keyword`, so the leak closes without the false
   positives a bare /key|token|secret/ pass would produce.

2. package.json was the one capture path that skipped redaction. It now goes through
   redactSensitiveContent like the configs -- `scripts` routinely embed tokens
   (`--token=ghp_...`). Dependency and version lines are unaffected by the scrub.

3. The resolved config path was logged absolute into a log file that is itself uploaded,
   leaking the OS username. Now logged cwd-relative; `path.relative` still yields
   `../../shared/wdio.conf.ts` for a config outside cwd, so the monorepo diagnostic survives.

4. Basename de-duplication existed twice with different loop bounds. Extracted
   `dedupeEntryName` and used it in both places, which also removes the bounded-loop
   fallthrough in the configCapture copy that could have returned an already-taken name.

The architecture comment (file I/O in the thin service layer) needs no change and is
answered in-thread: the user's wdio.conf lives on the service host, not anywhere the binary
can read, and this is co-located with the pre-existing log-upload I/O.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…URLs

Second review round. All three gaps reproduced against the head regex first, then verified
closed on a real uploaded bundle.

1. SCREAMING_SNAKE_CASE bypassed the scrub entirely. The snake branch listed lowercase
   suffixes only and the compound pass carried no `i` flag, while the whole-word pass rejects
   any token preceded by `_`. So `CLIENT_SECRET`, `AWS_SECRET_ACCESS_KEY`, `GITHUB_TOKEN` and
   `REFRESH_TOKEN` were all untouched -- the dominant convention for secrets in config and env
   files. The snake branch is now matched case-insensitively, which is safe precisely because
   it requires an explicit `_` before the suffix: `HOTKEY`, `KEYWORD` and `my_secretary` still
   fall out. camelCase stays case-sensitive for the same reason as before.

2. A multi-line PEM leaked its key bytes. The line naming `privateKey` was scrubbed but the
   base64 body carries no key name, and every pass was line-anchored. Added a block-level pass
   that collapses `-----BEGIN ...-----` through `-----END ...-----` as a unit.

3. Basic-auth credentials leaked from any URL that was not `proxyUrl`. Added a userinfo
   rewrite so `https://admin:pass@host` becomes `https://[REDACTED]@host` for any scheme. A
   port-bearing URL with no userinfo (`https://example.com:8080/path`) is left alone.

Block-level passes run before the line-anchored ones, since the latter can only ever see the
single line that carries the key name.

Also qualified the user-facing claim, which is the honest description now that the residual is
known: the changeset and the `disableAutoCaptureLogs` doc say values under known credential
keys are removed on a best-effort basis, and that a secret under an unrecognised name can
still be included.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…fo and open PEMs

Third review round.

1. ReDoS. Measured: URL_USERINFO_REGEX is quadratic -- 12.5k chars 97ms, 25k 382ms,
   50k 1543ms, 100k 6144ms, 4x per doubling, because both userinfo halves scan forward for
   an `@` that never arrives. redactSensitiveContent runs synchronously inside uploadLogs,
   so a captured config carrying one long unbroken run (a base64/data: URI, a minified line)
   would block the event loop for minutes and stall exit. Bounded the quantifiers: 100k drops
   6144ms -> 20ms, 400k -> 83ms, linear. Added a linearity guard test.

   The same report also called the two compound identifier passes quadratic. That did NOT
   reproduce: 0-1ms at every size I could construct, including the suffix literal present with
   no assignment, many suffix occurrences on one line, a 105k base64 data: URI, and the
   report's own stated input (100k word chars + trailing colon) at 1ms rather than 6155ms.
   The required literal suffix bounds the backtracking. Bounded them at 64 chars anyway --
   real config keys are far shorter, so it costs nothing and hardens a case I could not build.

2. Single-token URL userinfo leaked: the pattern required `user:pass@`, so
   `https://ghp_xxx@github.com` -- the shape CI git remotes and npm registry auth use -- was
   untouched. Password half is now optional.

3. An unterminated PEM (BEGIN with no END) leaked its body, since the block pass needs the END
   marker and the body lines carry no key name. Added a bounded pass matching BEGIN plus the
   run of base64-only lines that follows.

Two bugs in my own round-3 fixes, both caught by testing rather than review:

- The first cut of the unterminated-PEM pass ate ordinary lines. Letters are valid base64, so
  it matched `nextOption` out of `nextOption: 1`. The body run must now be at least 20
  characters AND end at a non-base64 character.
- Live-bundle verification then showed PEM_BLOCK_REGEX spanning from an unterminated BEGIN
  through to a LATER, unrelated block's END marker, replacing every line in between and
  silently destroying unrelated config. The body is now tempered so it cannot cross a second
  BEGIN, and bounded so the scan stays linear.

Verified on a real uploaded bundle: all ten planted leak vectors absent from the archived
config, all six triage markers still readable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The changeset bot regenerates .changeset/pr-128.md from the PR body, which still carried the
unqualified 'with credentials removed'. Updated the PR body release note as well so the two
agree and the qualification survives the next regeneration.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…sure notice

Fourth review round. Five findings, all reproduced before fixing.

1. The capture manifest never reached the archive. The service log is snapshotted into the
   staging dir before the manifest and capture lines are written, so everything logged after
   that copy stayed only in the local file. Confirmed on a controlled single run: the archived
   log was 2 178 bytes shorter than the local one and contained neither line. Support
   downloading a bundle saw no manifest, no resolution strategy and no capture failures.
   The manifest is now its own archive entry (capture-manifest.txt) carrying the entry list,
   the strategy, the captured config names, the package.json location and any failures --
   inside the tarball by construction rather than by ordering luck.

   Worth noting this also invalidated a regression test: the BStackAutomation coverage asserts
   the manifest by reading the LOCAL log, so it passed while the archive lacked it.

2. Acronym-prefixed camelCase keys leaked: APIToken, JWTSecret, SSHKey, AWSSecret, OTPKey.
   The camel core required a lowercase char immediately before the suffix, so an uppercase
   acronym failed it, and the whole-word pass rejected `Token` for the same preceding `I`.
   That guard was never load-bearing -- the alternation is already case-sensitive, so `hotkey`
   can never match it regardless of what precedes. Relaxed the core; the full over-redaction
   corpus (hotkey, HOTKEY, keyword, monkeypatch, tokenizer, secretary, donkey) still survives.

3. No runtime disclosure. The Node SDK prints AUTOLOGCAPTURE_NOTIFICATION when auto-capture is
   active; a wdio customer got only a changeset entry and a JSDoc comment for a strictly
   broader capture. Added the equivalent info line naming what is collected and how to disable.

4. "Auto-captured 1 config file(s) via undefined: package.json" -- package.json was appended to
   the config list before the count was taken, and strategy is undefined on every failure path,
   so the line claimed success in exactly the case where no config was captured. Config files
   and package.json are now logged separately, and the strategy clause is dropped when absent.

5. Imported configs were read from disk twice: once to seed the import frontier, discarded,
   then again to archive. Beyond the wasted read the two could disagree if a file changed in
   between. collectLocalImports now returns the content it already has.

Also raised the unterminated-PEM per-line bound from 200 to 8192 chars. The bounded quantifiers
fail OPEN, so a key written unwrapped on a single line exceeded the bound, matched nothing and
shipped. The URL userinfo bound is deliberately left as-is -- see the review reply.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…harmful

It looked like a free extra signal, but `config._` is derived from the same argv the scan
above already reads, minus the scan's guard that skips a flag's value. So it can only ever
differ by taking something the scan correctly rejected.

Proven: with `wdio --spec ./a.js` and no config positional, that rung resolves the SPEC file
as the config; without it the resolver correctly falls through to wdio.conf.js. Regression
test added.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…anifest paths relative

Review round 5. Both findings valid; the first also reverses my previous commit.

1. Config resolution now mirrors the CLI: one candidate, one stem, six extensions, no search.

   Verified with real yargs and wdio's own option declarations (spec: {type:'array'},
   watch: {type:'boolean'}) that my last commit was wrong. I removed the `config._` rung after
   "proving" it would resolve a spec file, but I had hand-built `{_: ['./a.js']}` -- an input
   yargs cannot produce, because a declared array option consumes its value and leaves `_`
   EMPTY. The rung was never reachable that way.

   The inverse is real: `wdio --watch ./configs/a.conf.ts` leaves the config in `_[0]`, and the
   raw-argv scan skipped it by its own "previous token is a flag" rule. Confirmed against a
   live wdio run -- that invocation now resolves as config_positional, where at head it fell
   through to a directory guess. `config._` is the post-yargs value the CLI itself trusts;
   scanning raw argv re-implements yargs with a heuristic that cannot know which flags are
   boolean.

   Also fixes a case none of the old rungs could reach: the CLI hands Launcher the probed path
   but leaves `config-path` as the user's spelling, so a TS project legally carries
   `configs/a.conf.js` there while `configs/a.conf.ts` is on disk. Confirmed live -- wdio starts
   fine and reports the non-existent spelling. Stem-probing the CLI value resolves it
   deterministically, which is what makes the directory-scan rung unnecessary rather than
   load-bearing.

   Removed: isReadableFile-based resolveCandidate, probeConfigBasename, scanForSingleConfig,
   scanArgvForConfig and the ladder body. 347 -> 296 code lines. The `argv_positional` and
   `single_conf_scan` strategies and the `config_ambiguous` reason are gone.

   One test changed meaning rather than breaking: util.test.ts asserted no failure string on
   upload, which only held because the old directory scan was matching vitest.config.ts in the
   test cwd. It now correctly reports the soft `config_capture: config_not_found`, with success
   still true.

2. The capture manifest emitted an absolute project directory, reversing the decision made
   earlier in this PR to keep uploaded paths cwd-relative. relativeToCwd is now exported and
   used for both the manifest field and the debug line.

Re-ran the live invocation matrix as asked: run form -> cli_config_path, bare form ->
config_positional, no-arg -> root_dir_default, --watch form -> config_positional.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ts folder name

relativeToCwd is `path.relative(...) || path.basename(...)`, and `path.relative(cwd, cwd)` is
''. That fallback was written for FILE paths, where the result is never empty. The manifest
passes a DIRECTORY, so the common case -- a package.json at the project root -- hit the
fallback and printed the folder name:

  manifest at cwd          -> "package.json: my-e2e-project"
  project checked out in $HOME -> "package.json: jane.doe"      <- OS username, in the tarball
  manifest above cwd       -> "package.json: ../.."             <- correct

Row 2 is the same exposure the relative-path handling exists to prevent, and row 1 silently
reports a folder name where the reader expects ".".

Added relativeDirToCwd, which renders empty as "." because for a directory empty MEANS cwd, and
used it at both call sites. relativeToCwd keeps its file semantics, with a note not to pass a
directory to it. Live-verified: the debug line now reads "Auto-captured package.json from ."
with the cwd basename absent.

Also rewrote the "leaves no staging directory behind" test, which was flaky for a reason
unrelated to this review: it diffed a listing of os.tmpdir(), and vitest runs test FILES in
parallel workers where util.test.ts also calls uploadLogs, so it raced against staging dirs
another worker was creating and removing. It now spies on mkdtempSync and asserts the specific
directories that this call created are gone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The import follower resolved any relative specifier landing on a supported extension, with no
name filter. So a config doing `import { helper } from './helpers/utils.js'` had that module
captured and uploaded too -- ordinary application source, not configuration. Confirmed on a real
run before this change: the archive carried `utils.js`.

Capturing the split-config case is the point of following imports at all; shipping a customer's
application modules is not. Imports are now followed only when the resolved file is named like a
config (`*.conf.*` / `*.config.*`), which keeps `base.conf.js` and `wdio.shared.conf.ts` and
drops everything else. Verified on the same fixture, with the entry config still importing the
helper:

  Auto-captured 2 config file(s) via cli_config_path: wdio.bstack.conf.js, base.conf.js
  archive entries: bstack-wdio-service.log, sdk-cli-debug.log, wdio.bstack.conf.js,
                   base.conf.js, package.json, capture-manifest.txt

This also makes the user-facing wording true as written: the changeset, the JSDoc and the
runtime notice all say "the local config files it imports", which was inaccurate until now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…the shipped log

The manifest existed only because the capture lines never reached the archive: the
service log was copyFileSync'd into the staging dir before those lines were written,
so they lived only in the developer's local file. Rather than ship a second file,
take the reviewer's first suggested option and fix the ordering.

Ordering alone was not sufficient, which a downloaded bundle proved: BStackLogger
writes through an async fs.WriteStream, so the summary lines were still buffered
when the copy ran and the shipped log was silently truncated. Adds
BStackLogger.flushLogFile() -- a zero-length write whose callback fires after every
queued chunk reaches the fs layer, draining the buffer without ending the stream, and
bounded by a timeout so a stuck stream can never hold up the upload.

The trailing "archive entries" line stays local-only by design: it lists what actually
landed, and the log file is itself one of those entries.

Verified end-to-end on build xbpsytnxggzx6kp03mjt0j4zptx4hs9ece2kocv7 -- the downloaded
bundle carries no capture-manifest.txt, and its bstack-wdio-service.log contains the
strategy, the captured config names and the package.json origin. 0 credential hits
across every file in the bundle.

Tests: both fixes are independently mutation-checked (removing either the flush or the
reorder fails the suite), plus direct tests for flushLogFile. 1149 tests green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@rahulpsq
rahulpsq requested a review from a team as a code owner August 14, 2026 14:57
@rahulpsq
rahulpsq requested review from Bhargavi-BS and xxshubhamxx and removed request for a team August 14, 2026 14:57
@rahulpsq
rahulpsq merged commit ba6a493 into sdk_v9_pre_prod_14aug Aug 14, 2026
16 of 18 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants