[BUG] Caller-supplied userDataDir is ignored — every session with a custom profile path shares one directory
Describe the bug
POST /v1/sessions accepts a userDataDir ("User data directory path to use for the session", sessions.schema.ts#L70), and the controller forwards it to SessionService.startSession. The value is then silently discarded: every session that supplies any userDataDir is launched against the single shared persistent profile directory instead.
The cause is operator precedence in session.service.ts#L180-L183:
const userDataDir =
options.userDataDir || options.persist === true
? path.join(dirname(fileURLToPath(import.meta.url)), "..", "..", "user-data-dir")
: env.CHROME_USER_DATA_DIR || path.join(os.tmpdir(), "steel-chrome");|| binds tighter than ?:, so this parses as:
(options.userDataDir || options.persist === true) ? <persistent dir> : <default dir>The caller's path is only ever used as a truthiness test. The three-way choice the code reads as — explicit path, else persistent, else ephemeral — is actually a two-way one, and the explicit path is never a possible result.
To reproduce
curl -sX POST localhost:3000/v1/sessions \
-H 'content-type: application/json' \
-d '{"userDataDir":"/data/profiles/tenant-a"}'The Chrome process is launched with --user-data-dir=/app/api/user-data-dir, not /data/profiles/tenant-a. Nothing is written to the requested path, and no warning is logged.
The expression can be evaluated on its own:
const persistDir = "/app/api/user-data-dir";
const current = (o) =>
o.userDataDir || o.persist === true ? persistDir : "/tmp/steel-chrome";
current({ userDataDir: "/data/profiles/tenant-a" }) // "/app/api/user-data-dir"
current({ userDataDir: "/data/profiles/tenant-b" }) // "/app/api/user-data-dir"
current({ persist: true }) // "/app/api/user-data-dir"
current({}) // "/tmp/steel-chrome"Expected behavior
userDataDirsupplied → that directory is used.- else
persist: true→ the persistent profile directory. - else →
CHROME_USER_DATA_DIRoros.tmpdir()/steel-chrome.
Why this matters
Profiles that are meant to be separate are shared. Two sessions requesting different userDataDir values both land in /app/api/user-data-dir. Whatever the first session leaves behind — cookies, Local Storage, Login Data, tokens in an authenticated profile — is the profile the second session starts from. For anyone using userDataDir to keep per-tenant or per-account profiles apart, the isolation they asked for silently does not exist, and the failure is invisible: the API returns 200 and the session works.
The guard against exactly this is defeated. isSimilarConfig compares userDataDir (validation.ts#L150-L151) so that a change of profile directory forces a relaunch rather than reuse of the running browser (cdp.service.ts#L563-L567). Because both configs collapse to the same string, that comparison can no longer see the difference, and a second session started while a first is still live can be handed the live browser instance of the first.
Sessions persist to disk when the caller did not ask them to. A plain userDataDir request with no persist flag writes a full Chrome profile into the persistent directory inside the install tree (/app/api/user-data-dir in the container image), where it survives the session and accumulates — related to the cleanup work in #324.
CHROME_USER_DATA_DIR is bypassed. The env var added in #194 is on the branch that is never taken when a userDataDir is supplied.
Versions
main@459be5b..HEAD— introduced in #210 ("feat: add persist, userDataDir option for saving profiles"), still present on currentmain- Affects every released version that exposes
userDataDir
Additional context
The precedence itself was previously reported and fixed in #300, which the author closed unmerged before review; the bug is still on main and there is no issue tracking it. I'll open a PR with the parenthesised fix plus regression tests covering all four combinations of userDataDir/persist and asserting the resolved path actually reaches the browser launch config.
Source: steel-dev/steel-browser