Repository navigation
fix(remote): device state: capability race, session without a device id, logout while running - #780
Conversation
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Advanced Run ID: 📒 Files selected for processing (2)
Included review availability: This review used your included allowance. Your plan provides up to 8 included reviews per hour; 3 remain after this review. 📝 WalkthroughWalkthroughThe device now coordinates configuration saves with logout removal and ignores persisted sessions without a device ID. RemoteChannel serializes capability writes and applies timeouts to device-row writes. Tests cover logout races, session loading, and write timeouts. ChangesPersisted session handling
Remote device-row writes
Priority: ➖ Normal Estimated code review effort: 3 (Moderate) | ~25 minutes Change: Bug fix Sequence Diagram(s)sequenceDiagram
participant RemoteChannel
participant DeviceRowAPI
participant Presence
RemoteChannel->>DeviceRowAPI: Queue capability write with abort signal
DeviceRowAPI-->>RemoteChannel: Confirm write or return error
RemoteChannel->>Presence: Continue publication after queued write
Presence->>RemoteChannel: Request capability update
Suggested reviewers: Merge Risk: 🔵 Low · up to The change makes logout stick while a device is running and keeps device-row writes from blocking presence publishing. One reported limitation remains: a timed-out status or capability write can still land late and briefly reverse newer state. Owners should be aware of it, but it does not block merge. Security Architecture ReviewSecurity architecture risk: 🔵 Low · up to The changes strengthen normal logout and credential-save coordination without establishing broader command authority. Remaining uncertainty concerns delayed remote updates and exceptional credential-cleanup paths, with effects concentrated on the affected device. Retained concerns
Security review detailsSecurity Blast Radius
Trust Boundaries and Controls
Resilience and Maintainability Implications
Hardening Proposals
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
f3cc76f to
e9516cf
Compare
e9516cf to
746b60e
Compare
746b60e to
eef33ed
Compare
9009512 to
b7431f8
Compare
b7431f8 to
a16968c
Compare
a16968c to
704e467
Compare
704e467 to
b16c11f
Compare
…hdrawal A device could be shown online while the server refused every call to it as not broadcast-capable (#697's symptom), until its channel rejoined again or the process restarted. After three failed recreates the device withdraws its transport_broadcast_v1 flag, but waits only 5 s for that write; a slower write still lands later. If realtime-js rejoined the channel in the meantime, presence was re-published and setTransportCapable(true) compared with the cached value, which still read true, so it wrote nothing and presence counted as tracked. The withdrawal then landed: the row lost the flag while the health check (which repairs only untracked presence) and the heartbeat (which keeps writing 'online') both took the device for reachable. Capability writes now go through a queue, as the status writes already do, and the duplicate check compares with the last queued value, not the last landed one. A re-publish during a slow withdrawal is written after it. test-remote-transport.js "a re-publish during a slow capability withdrawal still leaves the flag on the row" fails with the commit before's remote-channel.ts ("Capability writes landed: withdrawn") and passes here; the rest of test-remote-transport.js, test-remote-channel-reconnect.js and the other remote-channel tests still pass (Windows). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
remote-device-restart.js started, stopped and waited on real device processes with functions of its own. They move unchanged to test/helpers/remote-device.js (startDevice, waitFor, stopHard, stopGracefully, tail, STARTED, and the device.json path and writer), so the next test can start a device the same way instead of copying them. No behavior change: remote-device-restart.js passes as before. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A device.json holding a session but no device id made every start fail with
"Device not found: undefined" and exit 1, without ever asking to log in
again; under systemd's Restart=always that repeats on every restart, and only
deleting the file by hand ends it. loadPersistedConfig() returned the session
whatever the id, start() skips the revoked-device check when there is no id,
saves the same shape back, and registerDevice() then looks a device up with
no id. How such a file comes about (an older build, a hand edit, a server
answer without the id) is not known.
A session without a device id now counts as not logged in: the device goes to
its normal authorization, as with no session at all.
test-remote-device-session-without-id.js (a real device process against the
local stand-in) fails with the commit before's device.ts ("Device not found:
undefined" (exit 1), device-code flow requests: 0) and passes here;
test-remote-token-rotation-persisted.js and test-remote-device-env.js still
pass (Windows).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`remote --logout` deletes ~/.desktop-commander-device/device.json, and the README says that removes the saved credentials (local only: the server keeps the device's authorization). A device still running wrote the file back at its next token refresh, since every rotation is saved, or at shutdown. The logout did not stick: the next start reconnected without asking to log in. Once this run has saved device.json and later finds it removed by someone else, it saves nothing more for the rest of the run, with one log line: "🔓 Saved Remote MCP device credentials were removed (remote --logout): this run keeps its session in memory and won't save it again". It keeps running on its in-memory session; the next start asks to log in. The device removing a revoked device's config itself is not taken for a logout. remote-device-restart.js "a logout while the device runs sticks: device.json is not written back" (a real device, the real `remote --logout`, the local stand-in) fails with the commit before's device.ts: device.json is back after the next token refresh or the stop, and the next start does not ask to log in. It passes here; the other cases still pass. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
remote --logout run while the device was starting, after it loaded device.json and before its first save (session restore and device lookup, seconds with retries), did not stick: that save wrote device.json back, and the next start restored the session instead of asking to log in. The device took a missing device.json for a logout only once it had saved the file itself. It now does so from the moment it loaded the file too; removing it itself (a revoked device) still doesn't count. test-remote-device-logout.js runs a real device against the stand-in, which now can hold device lookups, and the real remote --logout during the hold. It fails on the commit before (the device logs "Config saved to" and the file is back) and passes here. test-remote-token-rotation-persisted.js and test-remote-device-session-without-id.js still pass (Windows). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A running device checked that device.json was still there, then read its session (getSession() can take a lock or a refresh over the network) and created the folder, then wrote. remote --logout removing the file in between was undone: the save wrote the credentials back. The save now reads the session first, and checks and writes under a cross-process lock on device.json (proper-lockfile, as config-manager.ts uses for config.json). remote --logout removes the file under the same lock, so the two happen one after the other. A home without the config folder still logs out as before. test-remote-device-logout.js: with the commit before's code, remote --logout removes device.json while a save holds the lock, and a logout during the save's getSession() is written over. Both pass here, and so do the start case, test-remote-token-rotation-persisted.js and test-remote-device-session-without-id.js (Windows). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A capability withdrawal sent down a half-open connection was answered only when fetch gave up (its 300 s headers timeout). The capability queue waited behind it, and so did the presence re-publish after the channel came back. Presence didn't count as tracked, and the health check, which skips while a publish is in progress, didn't retry it: the device stayed unreachable for up to 5 minutes. A status write nothing answers held presence the same way through the status queue (as on main). Capability and status writes to the device's row are now aborted after DEVICE_ROW_WRITE_TIMEOUT_MS (10 s), and count as failed. A capability write that failed or was aborted may still land, so what the row holds counts as unknown: the next write of either value goes through instead of being skipped as redundant. test-remote-transport.js: "a capability withdrawal nothing answers cannot hold back the re-publish" and "a status write nothing answers cannot keep presence from being published again" fail on the commit before (still waiting after 2 s) and pass here. The fake clients in three remote tests accept .abortSignal(). All 13 test-remote-*.js files pass (Windows). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ce that was logged out remote --logout is local: the running device keeps its session and only stops saving it. Its save checked that device.json still existed, so a device.json a new login wrote after the logout passed the check, and the old device's next save (a token rotation, or its shutdown) wrote its credentials over it: the next start restored the old device. The device now keeps device.json's text as it last loaded or saved it, and under the device.json lock saves only while the file still holds that text. A file that is gone (the logout) or holds other text (a login after it) stops its saves, and the device says which. test-remote-device-logout.js adds the case; it fails on the commit before. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
b16c11f to
9cd90e5
Compare
Stack #818 · 18/20 · base:
fix/search-memory· next:codex/tool-call-profilingRefs #697, #633.
Four bugs in the remote device's state, all on
mainas well. A device could be shown online while the server refused every call to it (#697's symptom), adevice.jsonwith a session but no device id made every start fail,remote --logoutdidn't stick while the device ran, and a status write that was never answered kept the device from publishing presence again. Capability writes now go through a queue, a session without a device id counts as not logged in, after a logout the running device saves nothing more, and status and capability writes have a time limit.What this fixes
device.jsonwith a session but no device id made every start fail with "Device not found: undefined".Restart=alwaysit repeated on every restart.remote --logoutdidn't stick while the device ran: the device wrotedevice.jsonback at its next token refresh, when it stopped, at its first save if the logout came while it was starting, or in the middle of a save; and it wrote over thedevice.jsonof a login made after the logout.device.jsonstill holds what it last loaded or saved); the next start asks to log in. A save's check and write and the logout's removal take turns under a lock ondevice.json.Where to look
src/remote-device/remote-channel.tssetTransportCapable(),writeTransportCapable(): capability writes queue like status writes. Before, a withdrawal that landed late (after 3 failed recreates, the channel waited only 5 s for it) overrode a re-publish that had been skipped as a duplicate. The capability write andsetOnlineStatus()are each aborted afterDEVICE_ROW_WRITE_TIMEOUT_MS(10 s), so nothing waits behind one that is never answered. A capability write that fails leaves the row's value unknown, so the next one is not skipped.src/remote-device/device.tsloadPersistedConfig(): a saved session without a device id is ignored.src/remote-device/device.tswritePersistedConfig(),clearPersistedConfig(): once this run findsdevice.jsonremoved or replaced by someone else after loading or saving it (its text is no longer what this run last loaded or saved), it saves nothing more; the device's own removal of a revoked device's config isn't taken for a logout. The check and the write run underlockRemoteDeviceConfig()(proper-lockfile, asconfig-manager.tsuses for config.json), after the session is read.src/remote-device/device.tsremoveRemoteDeviceConfig(), used bysrc/npm-scripts/remote.ts:remote --logoutremovesdevice.jsonunder the same lock.test/test-remote-transport.js(a re-publish during a slow withdrawal; a capability or status write nothing answers),test/test-remote-device-session-without-id.js(a real device process, against the local stand-in),test/test-remote-device-logout.js(a logout while the device starts, and while it saves; a login after the logout),test/integration/remote-device-restart.js(a logout while the device runs).test/helpers/remote-device.js: the device-process helpers moved here unchanged, plusrunLogout(), which runs the realremote --logout.test/helpers/remote-stand-in.jsholdDeviceLookups(): holds a starting device between loadingdevice.jsonand its first save.How to verify
Answers that change
None: tool answers and descriptions are unchanged. After a logout, the running device prints one new line in its own terminal: "🔓 Saved Remote MCP device credentials were removed (remote --logout): this run keeps its session in memory and won't save it again", or, when a login after the logout has saved its own
device.json, "🔓 Saved Remote MCP device credentials were replaced (a login after remote --logout): this run keeps its session in memory and won't save it again". A row write that is aborted logs its failure there, as other failed writes do ("Failed to update device status: TimeoutError: …"). If another process holds thedevice.jsonlock for longer than about 10 s (a stuck process; a save holds it for milliseconds),remote --logoutends with exit 1: "❌ Failed to remove saved Remote MCP credentials: Lock file is already being held".Commits and test results
70ff698ee0b4f3test/helpers(no behavior change).4f87de87c5a9444a27ddcece6506device.json).aa018079cd90e5main(c774c3b): Windows 11 / Node 24.18: unit 168/168, integration 4/4, repros 19/19. macOS 26.6.2 / Node 24.15: unit 168/168, integration 4/4, repros 19/19. Checks skipped for the platform, missing rights or a missing tool: 7 on Windows, 10 on macOS.7c5a944fails on the commit before and passes on its own commit, on Windows 11 / Node 24.18 and macOS 26.6.2 / Node 24.15.remote-device-restart.jsat7c5a944: Windows passes, the logout case included and the SIGTERM case skipped (58.5 s); macOS 6/6 cases (81.0 s).4f87de8and7c5a944, 10 of the 12test-remote-*.jsfiles they had beforemaingained fix(remote): stop device auth polling on denied/expired codes #783'stest-remote-device-auth-poll.jswere run, and pass on both OSes.dist/: each of4a27ddc,ece6506andaa01807has its tests fail with the product code before it and pass on it. Ataa01807the 13test-remote-*.jsfiles it had beforemaingained fix(remote): stop device auth polling on denied/expired codes #783's test pass 13/13, andremote-device-restart.jspasses, logout case included (5 cases, the SIGTERM case skipped as on Windows before; 49.1 s). The real Supabase client aborts a PATCH that is never answered at the set bound (checked with the bound at 300 ms: both writes return failed after 315 ms). On macOS 26.6.2, with the product code from7c5a944, the 3 logout and 2 transport cases fail; ataa01807the same 13test-remote-*.jsfiles pass 13/13 andremote-device-restart.jspasses 6/6.9cd90e5on Windows 11 / Node 24.18, cleandist/: the new logout case fails on the commit before and passes at it, andremote-device-restart.jspasses, logout case included (the SIGTERM case skipped, 43.5 s). With the product code from7c5a944, 4 logout cases and 2 transport cases fail. In the full suites at the top, all 14test-remote-*.jsfiles pass on Windows and macOS (macOS: logout 4/4).device.jsonwithout a device id arises (older builds, a hand edit, or a server answer).remote --logoutfrom an older version doesn't take the lock, so it can still land in the middle of a running device's save.main; ordering them needs a row version on the server's device row.last_seenwrite has no bound of its own either. Presence and the write queues don't wait for it; only the next heartbeat does.Stack #818: #781 makes the tests run on Windows and macOS; #770–#768 fix what that exposed; #773–#779 fix the issues listed in each; #780 fixes the remote device's state; #794 reports targeted Broadcast receipts.
🤖 Generated with Claude Code