Repository navigation
Conversation
Open Dashboard marked the tunnel up as soon as ssh.exe started, then put the shared token in the browser URL. Wait for the same listener ownership check StartOwnedAsync uses, and do not build that URL when the wait fails. - Add EnsureSettingsOwnedForwardReadyAsync for the settings-owned forward - Stop the forward and show the existing tunnel toast when ownership fails - Leave direct dashboard opens unchanged when SSH is off Signed-off-by: Sebastien Tardif <SebTardif@ncf.ca>
|
🦞👀 Pull request received. I will update this pull request when review starts. ClawSweeper review completeClawSweeper finished reviewing this revision. The review result is being finalized. |
|
Codex review: blocked before merge. Reviewed October 6, 2026, 4:21 AM ET / 08:21 UTC (Revision 32). ClawSweeper reviewWhat this changesThe PR binds Dashboard browser launches to a verified SSH destination, protects the local port across tunnel teardown, and adds launch-failure handling and regression coverage. Merge readiness⛔ Blocked before merge - 3 items remain The updated current-head evidence resolves the prior real-behavior-proof blocker. This work remains necessary on main, but its restriction on switching saved SSH gateways that share a local port still needs an explicit compatibility decision. Priority: P1 Review scores
Verification
How this fits togetherDashboard opening turns saved gateway credentials and SSH settings into a browser URL. The SSH service and loopback relay determine which remote gateway can receive subsequent browser traffic. flowchart TD
A[Saved gateway and credentials] --> B[Dashboard launch preparation]
B --> C[Verify SSH destination and process]
C --> D[Protected loopback relay]
D --> E[Owned SSH forward]
E --> F[Remote gateway]
C --> G[Browser launch]
G --> D
B --> H[Visible failure and retry]
Decision needed
Why: Retaining the port prevents delayed credential disclosure, but the resulting profile restriction is intentional and explicitly unapproved; code review cannot choose that user-facing tradeoff. Before merge
Agent review detailsSecurityNone. Review metrics
Merge-risk optionsMaintainer options:
Technical reviewBest possible solution: Keep destination-bound browser protection while preserving saved-profile switching, or provide an explicitly approved distinct-port contract with visible recovery for existing profiles. Do we have a high-confidence way to reproduce the issue? Yes, source provides a high-confidence race path: main starts SSH synchronously and launches the credential URL without carrying verified listener ownership through browser handoff. This read-only review did not execute a current-main failure. Is this the best way to solve the issue? Unclear overall: the ownership-bound relay addresses the credential race, but permanently restricting reuse of a configured local port requires an explicit upgrade-safety choice. AGENTS.md: found and applied where relevant. Codex review notes: model internal, reasoning medium; reviewed against 4895ed50e39e. LabelsLabel changes:
Label justifications:
EvidenceWhat I checked:
Likely related people:
Rank-up movesOptional improvements that raise the rating; they are not merge blockers.
Rating scale
Overall follows the weaker of proof and patch quality. Workflow
HistoryReview history (31 earlier review cycles; latest 8 shown)
|
|
Global triage: HOLD_FOR_AUTHOR. Take confidence 10%; recommendation confidence 97%; effort medium; risk high. Two handoff races block this exact head:
Please carry a generation-bound ownership lease through browser launch, prevent replacement during that handoff, and guard failure-state updates by the captured process/generation. Add overlapping-attempt and replacement-before-launch regressions. Run The E2E setup refusals appear shared/baseline; the UI lane timed out and remains unresolved. CI Gate is derivative, so the required current-head proof is not green. |
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0f6d0b3f-48b9-489e-a9dd-a736c63d485b
Resolve the dashboard credential and endpoint from one immutable gateway snapshot, preserve newer tunnel state when an older ownership wait fails, and revalidate the exact tunnel generation at browser handoff. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0f6d0b3f-48b9-489e-a9dd-a736c63d485b
|
@clawsweeper re-review Current head 6649c8b fixes the endpoint snapshot mismatch, preserves newer tunnel state, and holds a generation-bound owned-listener lease through browser handoff. Full local build plus Shared, Tray, and Connection suites pass. The PR body now includes current-head isolated WinUI/MCP/browser proof and the explicit live-SSH blocker. |
|
🦞👀 Re-review progress:
|
Restore PR openclaw#1514 to SebTardif's original four-file patch after the maintainer repair exceeded the bounded review scope. This reverts commit 6649c8b. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0f6d0b3f-48b9-489e-a9dd-a736c63d485b
The listener check returned before the shared-token URL was handed to the browser, and an older wait wrote Failed onto a replacement tunnel. Dashboard launch now keeps the generation from the ready check and refuses to start the browser if that generation is no longer the settings-owned forward. A wait that has already lost the process does not publish failure. Tests: EnsureSettingsOwnedForwardReadyAsync 2 passed. Dashboard contract tests 2 passed. ./build.ps1 exit 0. Shared 4107 passed, 32 skipped. Tray 3116 passed. Signed-off-by: Sebastien Tardif <SebTardif@ncf.ca>
A manager-owned tunnel was rejected even when it was the verified forward. The failure flag was written after the ownership lock was released, so an older wait could mark a replacement failed. Open Dashboard now accepts a settings or manager owner, requires the dashboard URL to use that local port, and keeps the process alive until the browser launch returns. Tests: EnsureSettingsOwnedForwardReadyAsync 2 passed. Dashboard contract test passed. ./build.ps1 exit 0. Shared 4107 passed, 32 skipped. Tray 3116 passed. Signed-off-by: Sebastien Tardif <SebTardif@ncf.ca>
A stop during the browser handoff used to return and let a later start publish a new process over the old listener. A different tunnel spec now fails while that handoff is active, and the old process stays the tracked one. Ending the handoff no longer stops the forward, so the port stays up after the browser is launched. Tests: SshTunnel 67 passed. ./build.ps1 exit 0. Shared 4107 passed, 32 skipped. Signed-off-by: Sebastien Tardif <SebTardif@ncf.ca>
…f lease A handoff could make StopLocked return while the caller still replaced the tunnel, and StopIfOwnedAsync reported that deferred stop as complete. Signed-off-by: Sebastien Tardif <SebTardif@ncf.ca>
…f ends A stop or reset during a browser handoff was dropped, so the forward stayed up after the launch and a reset could report NotConfigured while the process was still running. Signed-off-by: Sebastien Tardif <SebTardif@ncf.ca>
…e browser connects Open Dashboard waited for one SSH forward, then read the active gateway again. A different gateway on the same local port could send its token through the forward that had just been checked. The launch now keeps one gateway identity, tunnel destination, and token snapshot through that wait and the browser handoff. A token rotation on that same gateway and destination still opens. Process.Start returns before the browser connects. A stop recorded during launch used to run as soon as the handoff method finished, which freed the port while the browser still held the token URL. A stop that is already waiting skips the launch and completes. A stop that arrives during launch keeps the listener until an established loopback connection uses the forward, or until ssh exits. If that connection does not show up, the listener stays up. Signed-off-by: Sebastien Tardif <SebTardif@ncf.ca>
Core and CLI failed only WaitForOperatorHandshakeAsync_ReturnsTrue_WhenStateEventFires. The wait returned false after 934 ms. That test is not in this change, and it passed locally in 14 ms. The SSH handoff tests in the same job passed. Signed-off-by: Sebastien Tardif <SebTardif@ncf.ca>
The dashboard snapshot read the active gateway again after resolving the token, so a switch could pair one gateway's token with another's identity. The browser lease and a deferred stop are now decided under one lock. An established loopback connection releases that stop only when it is new for this handoff and, for an accepted socket, owned by the SSH process. Validation: ./build.ps1 exit 0. Shared 4107 passed, 32 skipped. Tray 3116 passed. Connection SSH and TCP snapshot tests passed 40. Signed-off-by: Sebastien Tardif <SebTardif@ncf.ca>
Another tab in the same browser can open a new connection to the forward. That is not this dashboard navigation, so the handoff stays while that SSH process is alive. The watcher starts even when Windows returns no browser process, and it releases the lease once that SSH process has exited. Validation: .\build.ps1 succeeded. Shared tests 4224 passed, 33 skipped. Tray tests 3808 passed. Connection handoff tests 42 passed. Signed-off-by: Sebastien Tardif <SebTardif@ncf.ca>
…forward Disconnect, Restart, and shutdown cancel an open dashboard handoff and then stop the SSH process. Another browser connection still does not release the hold. A saved remote gateway URL is opened through the verified local forward, and the saved profile is left unchanged. Validation: .\build.ps1 succeeded. Shared tests 4224 passed, 33 skipped. Tray tests 3808 passed. Connection handoff tests 36 passed. Snapshot tests 7 passed. Signed-off-by: Sebastien Tardif <SebTardif@ncf.ca>
Stop before the browser is opened cancels that launch and then stops SSH. Stop after the URL was handed to the browser leaves the forward in place until a second stop, or until that browser process exits. The local forward URL keeps the saved mount path, query, and fragment. Validation: .\build.ps1 succeeded. Shared tests 4224 passed, 33 skipped. Tray tests 3808 passed. Connection handoff and dashboard URL tests 34 passed. Signed-off-by: Sebastien Tardif <SebTardif@ncf.ca>
Signed-off-by: Sebastien Tardif <SebTardif@ncf.ca>
…ell exit Repeated Stop leaves a submitted dashboard hold in place. A shell process that exits after handing the URL to an existing browser does not release it. Dispose, StopAsync, and Restart stop SSH and keep that local port occupied until the next SSH start binds it. Validation: .\build.ps1 succeeded. Shared tests 4224 passed, 33 skipped. Tray tests 3808 passed. Connection handoff tests passed, including repeated Stop, activation-process exit, and disposal. Signed-off-by: Sebastien Tardif <SebTardif@ncf.ca>
Signed-off-by: Sebastien Tardif <SebTardif@ncf.ca>
A port held after dashboard teardown is our listener, so startup does not reject it as a foreign process. The guard stays bound. Replacement SSH listens on a backend port, and the guard forwards only to that process. Validation: .\build.ps1 succeeded. Shared tests 4224 passed, 33 skipped. Tray tests 3808 passed. Connection handoff and port-guard tests 38 passed. Signed-off-by: Sebastien Tardif <SebTardif@ncf.ca>
Signed-off-by: Sebastien Tardif <SebTardif@ncf.ca>
This app binds the browser port before SSH starts and keeps it when SSH stops. A replacement SSH process listens on a backend port, and traffic is forwarded only when that backend is still the SSH process for the original gateway. A different host or remote port cannot reuse the hold. Browser-proxy forwarding still uses the saved local port. An unexpected SSH exit keeps the hold instead of dropping the navigation lease. Validation: .\build.ps1 succeeded. Shared tests 4224 passed, 33 skipped. Tray tests 3808 passed. Focused handoff and port-guard tests 40 passed. Signed-off-by: Sebastien Tardif <SebTardif@ncf.ca>
Hold the browser port only while a submitted or opening dashboard navigation needs it. An unexpected SSH exit keeps that port, records the user, host, remote port, and SSH port, and drops the lease so recovery can listen on a backend port. Copy bytes only after the accepted loopback socket belongs to that SSH process and its start time still matches. Validate the browser-proxy port on the saved local port. Project the dashboard URL in GatewayDashboardLauncher. Validation: ./build.ps1 exit 0. Shared 4225 passed, 33 skipped. Tray 3808 passed. Focused handoff tests 46 passed. Signed-off-by: Sebastien Tardif <SebTardif@ncf.ca>
Claim the public port before a dashboard SSH process listens there. Stop and Reset end that SSH process after the navigation is submitted, and they leave the port bound. Recovery still drops the launch lease so the same destination can restart on a backend port. Validate the browser-proxy local port as a normal port, including 65534 and 65535. Validation: ./build.ps1 exit 0. Shared 4226 passed, 33 skipped. Tray 3808 passed. Focused handoff tests 46 passed. Signed-off-by: Sebastien Tardif <SebTardif@ncf.ca>
Dashboard startup keeps a manager-owned tunnel on that owner when it moves SSH behind the public port. The port guard binds 127.0.0.1 and ::1, and guarded clients use 127.0.0.1. A guard acquired for a start that fails before handoff is released. Validation: ./build.ps1 exit 0. Shared 4226 passed, 33 skipped. Tray 3808 passed. Focused connection tests 54 passed. Signed-off-by: Sebastien Tardif <SebTardif@ncf.ca>
|
Global triage: HOLD_FOR_AUTHOR. Take confidence 20%; recommendation confidence 98%; effort medium; risk high. Reviewed exact head Author: investigate the 1 failing check(s), establish whether each failure is branch-owned, and publish a green exact-head CI Gate before reconsideration. This is a triage classification, not a merge action. No branch changes were made. |
What Problem This Solves
Open Dashboard could send a gateway token through an SSH forward that belonged to a different gateway, and it could drop that forward before the browser connected.
User Impact
The browser opens only when the gateway identity and SSH destination still match the ones checked before the wait. If a stop is already waiting before the browser is opened, the dashboard does not open and the stop finishes.
A saved gateway URL that names a remote host is left in settings. GatewayDashboardLauncher builds the browser URL. That URL keeps the saved mount path, query, and fragment, and uses the verified local forward for the host and port.
Before that URL is handed to the browser, this app binds both 127.0.0.1 and ::1 on the public port. SSH listens on a backend port. Guarded gateway clients use 127.0.0.1. If the tunnel already belongs to the connection manager, the replacement stays on that owner. Stop and Reset after the navigation is submitted end the SSH process and leave the public port occupied. Recovery drops the launch lease and can start SSH again on a backend port for the same user, host, remote port, and SSH port. A guard created for a start that fails before the browser opens is released.
Browser traffic is copied only when the accepted loopback socket belongs to that SSH process and the process start time still matches. Browser-proxy forwarding uses the saved local port. Local proxy ports 65534 and 65535 remain valid. The remote gateway port still has to leave room for the proxy's
+ 2.A failed browser open shows the existing Connection notification and retry dialog. Retry keeps the requested path. A successful open dismisses that notification. The launch error is not written to the log, because the message can contain the token URL.
Switching to another saved SSH gateway that uses the same local port, while an older dashboard navigation is still protected, is still a maintainer decision.
Why This Change Was Made
The ownership wait returns the generation, local port, and SSH process id that passed listener verification.
The public port has to be owned on every loopback address the client can select, before SSH exits and before the browser receives the URL. Manager-owned tunnels keep that owner. A failed start does not leave the new bind behind.
Dashboard URL construction and launch failure handling stay in GatewayDashboardLauncher.
Evidence
Current head
2a20af4a0d672d61fec4ba95d88d49a7c5bc119e.SshTunnelService.EnsureSettingsOwnedForwardReadyAsyncstarted realssh.exetoroot@127.0.0.1port 22, with BatchMode and no password. The guest HTTP server listened on127.0.0.1:19048. The owned forward was local port 19049, ssh pid 7580.GET /ocwn-1514?token=not-a-gateway-tokenreturned HTTP 200. A browser opened that same URL. The guest log recordedtoken=truefor those requests and did not store the query value. One later guest line wastoken=false, from a browser fetch that did not include the query.A second call for remote port 19047 on the same local port returned
owned=false. It did not replace the forward. Binding another listener on 19049 failed while the guard held it. The next request then failed withHttpRequestException. Calling ensure again for the original destination returnedowned=truewith a new ssh pid 43132, and the same GET returned HTTP 200.A later run on the same head kept ssh pid 45824 as the recorded owner and pointed the backend port at a different process listening on
127.0.0.1:19046. The public GET failed. That other process accepted the TCP connection and read 0 bytes. The guest log did not record that request. Recovery then used ssh pid 36624 and the GET returned HTTP 200. The guest log for the whole run was twotoken=truelines, the allowed call and the recovered call.The same head then covered a delayed navigation across explicit Stop and Reset.
EnsureSettingsOwnedForwardReadyAsyncused realssh.exetoroot@127.0.0.1port 22, local port 19149, remote port 19148. The first owned forward was ssh pid 48196, backend port 61404.GET /ocwn-1514?token=not-a-gateway-tokenreturned HTTP 200. The guest log was onetoken=trueline. Navigation was submitted on that handoff, thenStopran. Status was Stopped, the public port stayed held, and a second bind of 19149 threwSocketException. The delayed GET failed withHttpRequestException. A foreign listener on backend port 61404 accepted 0 connections and read 0 bytes. The guest log did not gain a line. Ensure for the same destination then used ssh pid 39768, backend port 61409, and the GET returned HTTP 200. The guest log gained a secondtoken=trueline.ResetNotConfiguredused the same sequence. Navigation was submitted on ssh pid 39768. After reset, status was NotConfigured, port 19149 stayed held, and a second bind threwSocketException. The delayed GET failed. A foreign listener on backend port 61409 accepted 0 connections and read 0 bytes. The guest log stayed at two lines. Ensure for the same destination then used ssh pid 35880, backend port 61414, and the GET returned HTTP 200. The guest log gained a thirdtoken=trueline.The retained-port behavior is unchanged. A local port Dashboard has reserved stays tied to the original user, host, remote port, and SSH port. A different destination is not accepted on that port. Whether saved gateways must use distinct local ports is still a maintainer decision.
Unit coverage on this head, from the earlier closeout:
Unit coverage on this head:
Change Type
Scope
winnodeRequired proof pools
windows-winui-interactive: isolated Debug tray built from this head. Settings hadEnableMcpServertrue,EnableNodeModefalse, andUseSshTunneltrue with an empty user and host, so setup did not block and the tunnel settings were incomplete.openclaw://dashboardwas forwarded on the isolated pipe. The log recordedSSH tunnel is enabled but settings are incomplete. The Connection page showed a dialog titled Dashboard, with the text "Dashboard could not open. Check the verified gateway connection, then try again." Retry and Close are both fully inside the window. Invoking Retry removed that button, and the same dialog returned about 200 ms later. The incomplete-tunnel warning was logged again.Validation
Head
2a20af4a0d672d61fec4ba95d88d49a7c5bc119e../build.ps1: exit 0Real behavior proof
C:\Users\sebta\wt-wn-1514, head2a20af4a0d672d61fec4ba95d88d49a7c5bc119e. Guest SSH is Ubuntu-24.04 on127.0.0.1:22. The HTTP proof server was127.0.0.1:19048inside that guest.SshTunnelService.EnsureSettingsOwnedForwardReadyAsyncforroot@127.0.0.1, local port 19049, remote port 19048. Then an HTTP GET and a browser open ofhttp://127.0.0.1:19049/ocwn-1514?token=not-a-gateway-token. Then the same call with remote port 19047. Then ensure again for remote port 19048. On this same head, the delayed case used local port 19149 and remote port 19148: submit the navigation, callStop, send the GET, then ensure the original destination. Repeat that withResetNotConfigured. The isolated tray then receivedopenclaw://dashboard.owned=false. A second bind of 19049 was denied. Recovery used ssh pid 43132 and returned HTTP 200. On the next run, ssh pid 45824 stayed the recorded owner while a different process listened on the backend port. That process read 0 bytes and the guest did not see the request. Recovery used ssh pid 36624 and returned HTTP 200. After Stop, ssh pid 48196 was gone, the delayed GET failed, and the foreign listener on backend port 61404 read 0 bytes. Recovery ssh pid 39768 returned HTTP 200. After Reset, the foreign listener on backend port 61409 read 0 bytes. Recovery ssh pid 35880 returned HTTP 200. The isolated tray dialog is the screenshot above. Retry left and then returned.Security Impact
NoYesNoNoNoYes, explain the risk and mitigation: The shared token is appended only when the gateway identity and SSH destination still match the snapshot checked before the wait. This app binds 127.0.0.1 and ::1 before the URL is issued and keeps that binding after SSH stops. Guarded clients use 127.0.0.1. Forwarding happens only when the accepted loopback socket belongs to the SSH process for the original user, host, remote port, and SSH port. Launch failures are not logged, because the exception text can contain the token URL.Compatibility and Migration
YesNoNoReview Conversations