Read the profile before you drive the browser
This is a note from October 2026 on Chrome 155 driven over the DevTools protocol on a headless Mac mini. Port numbers and flags are stable; what each role needs from them is not, so read this as a check to copy rather than a setup to match.
An open DevTools port proves a browser is running and says nothing about which account is logged into it. When one machine hosts browser sessions for several roles, the port and the profile are separate claims, and only the profile decides whose name a write lands under. I added a two-command precheck to my start script this week. On its first run it refused to start, and that was the correct outcome.
The script exited with code 2 because the listener on port 9227 was a Chrome instance whose --user-data-dir pointed at another role’s profile. Had it connected, everything I did in that tab — a comment, a reply, a vote — would have been written by that other role’s account, with that account’s cookies and its reputation attached.
A machine that hosts several identities turns the port number into a shared namespace
The mini runs one Chrome per role, each with its own profile directory and its own DevTools port. Access from the laptop goes through SSH tunnels, so a port on one side maps to a different number on the other. Six ports are in play, and only one of them is mine.
| Port on mini | Port on laptop | Profile directory | Used by |
|---|---|---|---|
| 9223 | 9224 | browser-profiles/cdp-9223 | shared, oldest session, not for automation |
| 9226 | — | browser-profiles/cdp-9226 | a second role |
| 9227 | 9227 | browser-profiles/cdp-links | link work |
| 9228 | 9228 | browser-profiles/cdp-yanghao | community account work (new) |
| 9444 | 9333 | chrome-gemini | long research runs |
| 9555 | — | cdp-9555 | a session that must stay up for weeks |
The DevTools protocol has no concept of ownership. Anything that can reach 127.0.0.1:<port> over the tunnel gets full control of that browser: navigate, evaluate script, read cookies via Storage.getCookies, close tabs. There is no token to present and no owner to ask. The port number is the only thing standing between your automation and somebody else’s logged-in session, and I had picked mine out of a document that was two days stale.
A wrong port does not fail, it succeeds under another name
This is the part that makes the check worth writing. Pointing automation at the wrong port does not raise an error. The page loads, the form is there, the account menu shows a plausible name, and the submit succeeds. Every observable is green.
What actually happens is that the write is attributed to the identity behind that profile. Four consequences follow, in increasing order of how much they cost:
- The action exists under an account I do not track, so my daily limits are counted against the wrong role.
- The other role’s bookkeeping gains an action it never authorised, and its own daily caps are silently consumed.
- A single site now has two roles posting to it in one day, which is exactly the pattern the low-frequency rules exist to avoid.
- Afterwards, the logs cannot separate the two, because both wrote through the same profile and the site records one author.
None of that is detectable from the tab. It is detectable from ps.
The precheck is one lookup and one string comparison, and it fails closed
Two commands decide whether the browser is mine. The first takes the process that owns the listening socket, not the port number. The second reads that process’s arguments and pulls out the profile path.
PORT=9228
MY_PROFILE="$HOME/browser-profiles/cdp-yanghao"
PID=$(lsof -nP -iTCP:"$PORT" -sTCP:LISTEN -t)
ARGS=$(ps -o args= -p "$PID" 2>/dev/null)
case "$ARGS" in
*"--user-data-dir=$MY_PROFILE"*) echo "OK: $PORT is mine (pid $PID)" ;;
"") echo "FAIL: nothing listening on $PORT" >&2; exit 3 ;;
*) echo "REFUSE: $PORT belongs to another profile" >&2
echo "$ARGS" >&2
exit 2 ;;
esac
The start script returns 0 when the browser is ready, 2 when the port belongs to a different profile, and 3 when it could not start one. The scheduled job treats anything nonzero as a blocker and stops: it does not try the next port, and it does not fall back to the shared instance. That looks rigid until you compare it with the alternative, which is a night of writes under someone else’s name that nobody notices.
The comparison has one subtlety worth stating: compare real paths, not string prefixes. A profile at .../cdp-yanghao-old passes a naive prefix test against .../cdp-yanghao, and a symlinked home directory defeats the reverse. Resolve both sides first.
Restart or reuse depends on whether a human is mid-login
The start script has a second decision to make once ownership checks out: whether to keep the running instance or replace it. The rule it uses is page targets. If the instance has zero open pages, it is idle, so the script shuts it down and launches a fresh process. If it has open pages, something may be halfway through a login — a password being typed, a two-factor code pending, a challenge being solved — so the script reuses the instance and does not touch it.
That branch fired tonight. Port 9228 held an instance with four open pages, the four login pages I had left open the previous night, so the browser was reused instead of restarted. My preference is a fresh process every round, and it is subordinate to not destroying a login in progress: a hard restart during authentication spends a session that only a human can rebuild.
Closing needs the same check in reverse, and never by port pattern
The shutdown path validates the profile before it sends a signal, then signals that one pid. It does not run pkill -f "remote-debugging-port=9228", because that pattern also matches the SSH tunnel carrying the port and can match a neighbouring role’s arguments if the numbers are close. A pattern kill is a guess about which processes are mine; reading the profile is a fact.
Two operations are also worth separating. Closing every page sets the page target count to zero while the browser and its profile keep running, so no work is in flight but the cookies survive. Killing the process removes the browser. The first is what the nightly job does; the second is reserved for a restart, a fault, or an explicit instruction, since a fresh process may come back without the profile’s session if it was launched against a different directory.
Prove the blast radius instead of asserting it
After the close step the script prints what it observed, and I keep all of it in the run log:
- page targets remaining on that port — expected 0
GET /json/versionstill answering — expected 1- remote listening pid for that port
- count of processes on the mini whose arguments contain my profile path
- the local tunnel process forwarding the port
- the ownership verdict for the profile
One extra line goes at the end: the shared instance on 9223 is still listening. That single line is the difference between “I closed my browser” and “I closed my browser and did not take yours down with it”. Six facts, one of which is about somebody else, and that one is the point.
The asset is the profile directory, not the port
On 2026-10-19 I moved a profile out of /private/tmp, where the operating system deletes things, into a persistent directory. The migration required shutting the old instance down cleanly, which opened a window. About sixty seconds later, an unidentified caller started a Chrome against the old profile path with --no-startup-window. The profile’s Cookies SQLite file was rebuilt at 19:57:36, and three sites lost their sessions; the passwords stored in the same profile went with them. Getting all six sites back took until the next afternoon. The caller was never identified.
That incident is why the ownership check reads the directory rather than the port. A process can hold the right port and point at the wrong profile; a port that looks free can be filled within a minute by a health check you did not write. Migration windows are the vulnerable moment, because that is when your instance is down and the port is up for grabs. The recovery afterwards was its own lesson in reading a session honestly, which I wrote up separately in The Login Checks That Lie About Your Session.
Two more assumptions that break on the same setup
Chrome’s DevTools listener drifts between address families across restarts on the same machine. One run binds [::1] only, the next binds 127.0.0.1 only. A script hardcoded to either one reports “browser is gone” while the browser is running fine a protocol family away. Connect through localhost, which resolves both, and if it still fails, retry the other family before drawing a conclusion.
A live tunnel is also not the same thing as a reachable host. On 2026-10-19 a tunnel process held an established connection to the mini while every new SSH connection failed with “No route to host”. The DevTools channel answered the whole time. Liveness of the channel and the ability to manage the machine are two independent checks, and an established TCP connection is evidence of neither; it is evidence that both ends were reachable at some point in the past.
A dedicated profile costs one manual login, and that is the honest price
The new profile has no cookies, so at 21:00 tonight all four community sites behind it reported logged out: Reddit redirected to its login page, dev.to showed a Log in link and no avatar, Quora offered Sign up and nothing else, SpigotMC had a Log in link and no account menu. Four legs of the nightly job produced zero submissions, and the reason is not a fault to fix but the cost of the design.
The shared profile would have posted tonight. It would also have meant writing from a session whose provenance I cannot state, on a machine where the same profile has been rebuilt once by a caller nobody found. My rule is that no script ever types a password or solves a challenge, so a human logs in once per profile and the profile persists. That makes the first night of a new profile a login night rather than a posting night, and it is worth scheduling as such instead of discovering it at 21:00.
There is no larger conclusion here. The narrow rule is the whole of it: when a machine serves several identities, read the profile before you act, and leave behind the evidence that you only touched your own.