From 4af09d4961c9a2db802ac78209da77cdc022e445 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 19 Aug 2026 03:41:29 -0700 Subject: [PATCH] feat(ui): unify focused presentation routes (#126143) * feat(ui): unify focused presentation routes /focus/ replaces unshipped standalone query links across dashboard, terminal, desktop, and native apps. Gateway-served index assets are anchored so nested documents resolve their bundles from the Control UI base path. * test(gateway): narrow emitted asset URLs Fixes check:test-types TS18048/TS2322 by dropping unmatched optional captures before comparing emitted asset URLs. * test(docs): follow centralized cloud secret guidance Fixes the stale current-main docs test after #126132 centralized GCP and Hetzner setup in docker-vm-runtime. * test(ui): retry missing locator reads The 500ms locator text read can time out while the menu label is still rendering, causing expect.poll to reject instead of using its owning 10s retry window. Treat only Playwright TimeoutError as a missing value so the outer poll retries while page-closure and arbitrary failures still surface. * test(android): capture TLS probe coroutine The TLS probe test inferred its coroutine from mutable scope children, racing unrelated child startup and teardown in CI. Capture the exact Job from inside the probe coroutine and join that owner before asserting the stale-attempt guard. * fix(gateway): preserve plugin focus routes Keep approval handling ahead of plugin dispatch, but treat focus documents as an unclaimed Control UI fallback after plugin authentication and routing. Exact and prefix plugin routes therefore retain ownership, while unclaimed reads serve the focus document and other methods return 404. * fix(ui): migrate released terminal links Preserve stable v2026.7.1 terminal query compatibility by rewriting the root/base ?view=terminal URL once to the canonical /focus/terminal path with history.replace. Keep URL parsing path-only, and leave the removed desktop and dashboard query forms as a hard cut. * test(codex): assign run-attempt tools shard Cached filtered configs caused duplicate ownership, and the test lacked a canonical full-suite owner. * test(ui): keep cloud recovery proof state-owned The recovery test should assert owner state and reload identity, while dedicated tests own transient alert visibility. * test(qa): wait for outbound bus state * fix(qa): reserve gateway ports through staging * refactor(qa): keep socket creation in gateway owner --- .../java/ai/openclaw/app/ui/DesktopScreen.kt | 20 +- .../openclaw/app/ui/TerminalSettingsScreen.kt | 18 +- .../openclaw/app/GatewayBootstrapAuthTest.kt | 20 +- .../ai/openclaw/app/ui/DesktopScreenTest.kt | 109 +++---- .../app/ui/TerminalSettingsScreenTest.kt | 26 ++ .../Sources/Desktop/DesktopHubScreen.swift | 32 ++- .../Sources/Terminal/TerminalHubScreen.swift | 8 +- .../Web/AuthenticatedControlUIWebView.swift | 10 +- apps/ios/Tests/DesktopHubScreenTests.swift | 24 +- apps/ios/Tests/TerminalHubScreenTests.swift | 6 +- docs/web/control-ui.md | 4 +- docs/web/dashboards.md | 11 +- docs/web/urls.md | 90 ++++-- extensions/qa-lab/src/gateway-child.ts | 31 +- .../src/gateway-port-reservation.test.ts | 40 +++ .../qa-lab/src/gateway-port-reservation.ts | 39 +++ .../src/harness-runtime.integration.test.ts | 18 +- .../session-url-contract/src/focus.test.ts | 208 ++++++++++++++ packages/session-url-contract/src/focus.ts | 229 +++++++++++++++ packages/session-url-contract/src/index.ts | 1 + src/docs/install-cloud-secrets.test.ts | 2 +- src/gateway/control-ui-routing.test.ts | 22 ++ src/gateway/control-ui-routing.ts | 9 + src/gateway/control-ui.http.test.ts | 110 +++++-- src/gateway/control-ui.ts | 22 +- src/gateway/server-http.ts | 41 +-- src/gateway/server.plugin-http-auth.test.ts | 183 ++++++++++++ ...n-codex-app-server-attempt-extra.config.ts | 2 +- ...n-codex-app-server-attempt-light.config.ts | 2 +- ...extension-codex-app-server-tools.config.ts | 2 +- ui/src/app-navigation.test.ts | 3 + ui/src/app-route-paths.ts | 5 + ui/src/app/app-root.ts | 271 +++++++++++++++--- ui/src/app/app-shell-view.ts | 1 + ui/src/app/bootstrap.test.ts | 158 +++++++++- ui/src/app/bootstrap.ts | 67 +++-- ui/src/app/dashboard-document-mode.ts | 21 -- ui/src/app/desktop-document-mode.test.ts | 112 -------- ui/src/app/desktop-document-mode.ts | 40 --- ui/src/app/startup-settings.ts | 22 ++ ui/src/app/terminal-document-mode.test.ts | 20 -- ui/src/app/terminal-document-mode.ts | 23 -- .../desktop/desktop-document-inventory.ts | 2 +- .../desktop/desktop-focus-window.ts | 14 + .../components/desktop/desktop-panel-view.ts | 10 + ui/src/components/desktop/desktop-panel.ts | 4 + ui/src/components/desktop/desktop-source.ts | 3 +- .../terminal-panel-accessibility.test.ts | 10 +- .../terminal/terminal-panel-upload.ts | 4 +- ui/src/components/terminal/terminal-panel.ts | 9 +- ui/src/e2e/dashboard-fullscreen.e2e.test.ts | 130 +++++++-- ui/src/e2e/desktop-document-mode.e2e.test.ts | 101 ++++--- ui/src/e2e/desktop-panel.e2e.test.ts | 19 ++ ...new-session-page.cloud-startup.e2e.test.ts | 3 - ui/src/e2e/new-session-page.test-support.ts | 18 +- ui/src/e2e/terminal-embedded.e2e.test.ts | 25 +- ui/src/e2e/terminal-upload.e2e.test.ts | 2 +- ui/src/i18n/locales/en.ts | 8 +- .../pages/chat/chat-pane-embedded-panels.ts | 19 +- ui/src/pages/chat/route-loader-context.ts | 6 + ui/src/pages/chat/route-loader-short-cache.ts | 2 +- .../chat/route-loader-short-list-fallback.ts | 2 +- .../pages/chat/route-loader-short-resolve.ts | 2 +- ui/src/pages/chat/route-loader.ts | 2 +- ui/src/pages/dashboards/view.test.ts | 65 +++-- ui/src/pages/dashboards/view.ts | 11 +- 66 files changed, 1918 insertions(+), 635 deletions(-) create mode 100644 apps/android/app/src/test/java/ai/openclaw/app/ui/TerminalSettingsScreenTest.kt create mode 100644 extensions/qa-lab/src/gateway-port-reservation.test.ts create mode 100644 extensions/qa-lab/src/gateway-port-reservation.ts create mode 100644 packages/session-url-contract/src/focus.test.ts create mode 100644 packages/session-url-contract/src/focus.ts delete mode 100644 ui/src/app/dashboard-document-mode.ts delete mode 100644 ui/src/app/desktop-document-mode.test.ts delete mode 100644 ui/src/app/desktop-document-mode.ts delete mode 100644 ui/src/app/terminal-document-mode.test.ts delete mode 100644 ui/src/app/terminal-document-mode.ts create mode 100644 ui/src/components/desktop/desktop-focus-window.ts create mode 100644 ui/src/pages/chat/route-loader-context.ts diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/DesktopScreen.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/DesktopScreen.kt index 0662a71e9abb..461ede29f504 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/DesktopScreen.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/DesktopScreen.kt @@ -103,22 +103,26 @@ internal fun DesktopScreen( } } -/** Builds the desktop document route; credentials stay in ControlUiWebView's startup script. */ +/** Builds the desktop focus route; credentials stay in ControlUiWebView's startup script. */ internal fun desktopUrl( baseUrl: String, source: String? = null, session: String? = null, ): String { - val baseUri = baseUrl.trimEnd('/').toUri() - val routePath = "${baseUri.encodedPath.orEmpty().trimEnd('/')}/" + val normalizedSource = source?.trim()?.takeIf(String::isNotEmpty) + val normalizedSession = session?.trim()?.takeIf(String::isNotEmpty) val builder = - baseUri + baseUrl + .trimEnd('/') + .toUri() .buildUpon() - .encodedPath(routePath) .clearQuery() .fragment(null) - .appendQueryParameter("view", "desktop") - source?.let { builder.appendQueryParameter("source", it) } - session?.let { builder.appendQueryParameter("session", it) } + .appendPath("focus") + .appendPath("desktop") + when { + normalizedSource != null -> builder.appendPath("source").appendPath(normalizedSource) + normalizedSession != null -> builder.appendPath("session").appendPath(normalizedSession) + } return builder.build().toString() } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/TerminalSettingsScreen.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/TerminalSettingsScreen.kt index 5282e63df9c8..85e73afe4ded 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/TerminalSettingsScreen.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/TerminalSettingsScreen.kt @@ -27,10 +27,11 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.core.net.toUri /** * Full-height terminal surface: embeds the gateway-served terminal-only - * Control UI document (`/?view=terminal`, the same ghostty-web surface the + * Control UI focus document (`/focus/terminal`, the same ghostty-web surface the * desktop Control UI uses) for the currently connected gateway. */ @Composable @@ -61,7 +62,7 @@ internal fun TerminalSettingsScreen( key(page) { ControlUiWebView( page = page, - url = "${page.baseUrl}/?view=terminal", + url = terminalUrl(page.baseUrl), modifier = Modifier.fillMaxSize(), ) } @@ -75,3 +76,16 @@ internal fun TerminalSettingsScreen( } } } + +/** Builds the terminal focus route without putting gateway credentials in the URL. */ +internal fun terminalUrl(baseUrl: String): String = + baseUrl + .trimEnd('/') + .toUri() + .buildUpon() + .clearQuery() + .fragment(null) + .appendPath("focus") + .appendPath("terminal") + .build() + .toString() diff --git a/apps/android/app/src/test/java/ai/openclaw/app/GatewayBootstrapAuthTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/GatewayBootstrapAuthTest.kt index 428547a03367..84ffc3c282b5 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/GatewayBootstrapAuthTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/GatewayBootstrapAuthTest.kt @@ -30,6 +30,7 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.async import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.runBlocking @@ -438,37 +439,26 @@ class GatewayBootstrapAuthTest { fun connect_ignoresStaleTlsProbeAfterDisconnect() = runBlocking { val fingerprint = "aa".repeat(32) - val probeStarted = CompletableDeferred() + val probeJob = CompletableDeferred() val probeResult = CompletableDeferred() val (_, prefs, runtime) = gatewayFixture { _, _ -> - probeStarted.complete(Unit) + probeJob.complete(checkNotNull(currentCoroutineContext()[Job])) probeResult.await() } val endpoint = GatewayEndpoint.manual(host = "gateway.example", port = 18789) prefs.saveGatewayTlsFingerprint(endpoint.stableId, fingerprint) - val runtimeScope = readField(runtime, "scope") - val existingJobs = - runtimeScope.coroutineContext[Job] - ?.children - ?.toSet() - .orEmpty() runtime.connect( endpoint, auth(token = "shared-token"), ) - probeStarted.await() - val probeJob = - runtimeScope.coroutineContext[Job] - ?.children - ?.singleOrNull { it !in existingJobs } - ?: error("Expected one TLS probe job") + val tlsProbeJob = probeJob.await() runtime.disconnect() probeResult.complete(GatewayTlsProbeResult(fingerprintSha256 = fingerprint)) // Join the owning coroutine so assertions run after its stale-attempt guard. - probeJob.join() + tlsProbeJob.join() assertNull(runtime.pendingGatewayTrust.value) assertNull(desiredBootstrapToken(runtime, "nodeSession")) diff --git a/apps/android/app/src/test/java/ai/openclaw/app/ui/DesktopScreenTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/ui/DesktopScreenTest.kt index 799d66380c22..3b25131acfab 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/ui/DesktopScreenTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/ui/DesktopScreenTest.kt @@ -1,7 +1,6 @@ package ai.openclaw.app.ui import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner @@ -11,56 +10,70 @@ import org.robolectric.annotation.Config @Config(sdk = [34]) class DesktopScreenTest { @Test - fun desktopUrlUsesDocumentModeWithoutSource() { - val url = desktopUrl(baseUrl = "https://gateway.example.com:8443/openclaw/") - - assertEquals("https://gateway.example.com:8443/openclaw/?view=desktop", url) - assertFalse(url.contains("token=")) - assertFalse(url.contains("password=")) - } - - @Test - fun desktopUrlEncodesProvidedSource() { - val url = - desktopUrl( - baseUrl = "https://gateway.example.com:8443", - source = "environment:Mac Studio/QA & demo", + fun desktopUrlBuildsCanonicalFocusPaths() { + val cases = + listOf( + DesktopUrlCase( + name = "root base", + baseUrl = "https://gateway.example.com:8443", + expected = "https://gateway.example.com:8443/focus/desktop", + ), + DesktopUrlCase( + name = "configured base path", + baseUrl = "https://gateway.example.com:8443/openclaw/", + expected = "https://gateway.example.com:8443/openclaw/focus/desktop", + ), + DesktopUrlCase( + name = "encoded source", + baseUrl = "https://gateway.example.com:8443", + source = "environment:Mac Studio/QA & demo", + expected = + "https://gateway.example.com:8443/focus/desktop/source/environment%3AMac%20Studio%2FQA%20%26%20demo", + ), + DesktopUrlCase( + name = "encoded session under configured base path", + baseUrl = "https://gateway.example.com:8443/openclaw/", + session = "agent:main:mobile session", + expected = + "https://gateway.example.com:8443/openclaw/focus/desktop/session/agent%3Amain%3Amobile%20session", + ), + DesktopUrlCase( + name = "source wins over session", + baseUrl = "https://gateway.example.com:8443", + source = "node:worker-1", + session = "agent:main:mobile", + expected = "https://gateway.example.com:8443/focus/desktop/source/node%3Aworker-1", + ), + DesktopUrlCase( + name = "empty source falls through to session", + baseUrl = "https://gateway.example.com:8443", + source = " ", + session = "agent:main:mobile", + expected = "https://gateway.example.com:8443/focus/desktop/session/agent%3Amain%3Amobile", + ), + DesktopUrlCase( + name = "empty values are omitted", + baseUrl = "https://gateway.example.com:8443/openclaw/", + source = " ", + session = "\n", + expected = "https://gateway.example.com:8443/openclaw/focus/desktop", + ), ) - assertEquals( - "https://gateway.example.com:8443/?view=desktop&source=environment%3AMac%20Studio%2FQA%20%26%20demo", - url, - ) - assertFalse(url.contains("token=")) - assertFalse(url.contains("password=")) - } - - @Test - fun desktopUrlEncodesProvidedSession() { - val url = - desktopUrl( - baseUrl = "https://gateway.example.com:8443/openclaw/", - session = "agent:main:mobile session", + cases.forEach { case -> + assertEquals( + case.name, + case.expected, + desktopUrl(baseUrl = case.baseUrl, source = case.source, session = case.session), ) - - assertEquals( - "https://gateway.example.com:8443/openclaw/?view=desktop&session=agent%3Amain%3Amobile%20session", - url, - ) + } } - @Test - fun desktopUrlRetainsExplicitSourceAlongsideSession() { - val url = - desktopUrl( - baseUrl = "https://gateway.example.com:8443", - source = "node:worker-1", - session = "agent:main:mobile", - ) - - assertEquals( - "https://gateway.example.com:8443/?view=desktop&source=node%3Aworker-1&session=agent%3Amain%3Amobile", - url, - ) - } + private data class DesktopUrlCase( + val name: String, + val baseUrl: String, + val source: String? = null, + val session: String? = null, + val expected: String, + ) } diff --git a/apps/android/app/src/test/java/ai/openclaw/app/ui/TerminalSettingsScreenTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/ui/TerminalSettingsScreenTest.kt new file mode 100644 index 000000000000..ea19eaa95f19 --- /dev/null +++ b/apps/android/app/src/test/java/ai/openclaw/app/ui/TerminalSettingsScreenTest.kt @@ -0,0 +1,26 @@ +package ai.openclaw.app.ui + +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class TerminalSettingsScreenTest { + @Test + fun terminalUrlBuildsCanonicalFocusPath() { + val cases = + listOf( + "https://gateway.example.com:8443" to + "https://gateway.example.com:8443/focus/terminal", + "https://gateway.example.com:8443/openclaw/" to + "https://gateway.example.com:8443/openclaw/focus/terminal", + ) + + cases.forEach { (baseUrl, expected) -> + assertEquals(baseUrl, expected, terminalUrl(baseUrl)) + } + } +} diff --git a/apps/ios/Sources/Desktop/DesktopHubScreen.swift b/apps/ios/Sources/Desktop/DesktopHubScreen.swift index bb2eca1787d1..e5295bc126d6 100644 --- a/apps/ios/Sources/Desktop/DesktopHubScreen.swift +++ b/apps/ios/Sources/Desktop/DesktopHubScreen.swift @@ -100,17 +100,11 @@ struct DesktopHubScreen: View { source: String?, session: String? = nil) -> URL? { - var queryItems = [URLQueryItem(name: "view", value: "desktop")] - if let source = self.normalizedSource(source) { - queryItems.append(URLQueryItem(name: "source", value: source)) - } - if let session = self.normalizedSource(session) { - queryItems.append(URLQueryItem(name: "session", value: session)) - } + guard let path = self.desktopPath(source: source, session: session) else { return nil } return AuthenticatedControlUI.pageURL( config: config, - path: "/", - queryItems: queryItems) + path: path, + queryItems: []) } static func desktopAuthUserScript( @@ -147,13 +141,25 @@ struct DesktopHubScreen: View { hasher.combine(AuthenticatedControlUI.webContentIdentity( config: config, storedOperatorToken: storedOperatorToken)) - hasher.combine(self.normalizedSource(source)) - hasher.combine(self.normalizedSource(session)) + hasher.combine(self.desktopPath(source: source, session: session)) return hasher.finalize() } - private static func normalizedSource(_ source: String?) -> String? { - let trimmed = source?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + private static func desktopPath(source: String?, session: String?) -> String? { + let basePath = "/focus/desktop" + if let source = self.normalizedValue(source) { + guard let encoded = AuthenticatedControlUI.percentEncodedPathSegment(source) else { return nil } + return "\(basePath)/source/\(encoded)" + } + if let session = self.normalizedValue(session) { + guard let encoded = AuthenticatedControlUI.percentEncodedPathSegment(session) else { return nil } + return "\(basePath)/session/\(encoded)" + } + return basePath + } + + private static func normalizedValue(_ value: String?) -> String? { + let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" return trimmed.isEmpty ? nil : trimmed } } diff --git a/apps/ios/Sources/Terminal/TerminalHubScreen.swift b/apps/ios/Sources/Terminal/TerminalHubScreen.swift index 5195752f6ca8..c108e964f4c1 100644 --- a/apps/ios/Sources/Terminal/TerminalHubScreen.swift +++ b/apps/ios/Sources/Terminal/TerminalHubScreen.swift @@ -2,7 +2,7 @@ import OpenClawKit import SwiftUI /// Control-hub Terminal destination: embeds the gateway-served terminal page -/// (`/?view=terminal`, the ghostty-web surface shared with the Control UI) in a +/// (`/focus/terminal`, the ghostty-web surface shared with the Control UI) in a /// WKWebView, authenticated with the stored gateway credentials. struct TerminalHubScreen: View { @Environment(NodeAppModel.self) private var appModel @@ -87,14 +87,14 @@ struct TerminalHubScreen: View { } /// Derives the terminal page URL from the active gateway connection: the - /// WS endpoint flips to HTTP(S) and only `view=terminal` rides in the URL. + /// WS endpoint flips to HTTP(S) and the configured Control UI base path is preserved. /// Credentials never enter the URL — they are injected as a document-start /// user script (see `terminalAuthUserScript`), matching the macOS Dashboard. static func terminalURL(config: GatewayConnectConfig?) -> URL? { AuthenticatedControlUI.pageURL( config: config, - path: "/", - queryItems: [URLQueryItem(name: "view", value: "terminal")]) + path: "/focus/terminal", + queryItems: []) } /// Origin-gated document-start script that hands the gateway credentials to diff --git a/apps/ios/Sources/Web/AuthenticatedControlUIWebView.swift b/apps/ios/Sources/Web/AuthenticatedControlUIWebView.swift index 4ab690e1181a..23f3d9af76e6 100644 --- a/apps/ios/Sources/Web/AuthenticatedControlUIWebView.swift +++ b/apps/ios/Sources/Web/AuthenticatedControlUIWebView.swift @@ -7,6 +7,8 @@ import WebKit enum AuthenticatedControlUI { private static let queryComponentAllowed = CharacterSet( charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~") + private static let pathSegmentAllowed = CharacterSet( + charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~!'()*") static func pageURL( config: GatewayConnectConfig?, @@ -33,10 +35,16 @@ enum AuthenticatedControlUI { return "\(name)=\(encodedValue)" } guard encodedItems.count == queryItems.count else { return nil } - components.percentEncodedQuery = encodedItems.joined(separator: "&") + components.percentEncodedQuery = encodedItems.isEmpty + ? nil + : encodedItems.joined(separator: "&") return components.url } + static func percentEncodedPathSegment(_ value: String) -> String? { + value.addingPercentEncoding(withAllowedCharacters: self.pathSegmentAllowed) + } + /// Origin-gated document-start script for the Control UI native-auth contract. static func authUserScript( config: GatewayConnectConfig?, diff --git a/apps/ios/Tests/DesktopHubScreenTests.swift b/apps/ios/Tests/DesktopHubScreenTests.swift index 9dfde13862e0..04ec327a3db1 100644 --- a/apps/ios/Tests/DesktopHubScreenTests.swift +++ b/apps/ios/Tests/DesktopHubScreenTests.swift @@ -30,13 +30,13 @@ struct DesktopHubScreenTests { @Test func `standalone desktop URL uses document mode without credentials`() throws { let config = try Self.makeConfig( - url: #require(URL(string: "wss://gateway.example.com:8443/openclaw")), + url: #require(URL(string: "wss://gateway.example.com:8443/openclaw/")), token: "secret-token", password: "secret-password") let url = DesktopHubScreen.desktopURL(config: config, source: nil, session: nil) - #expect(url?.absoluteString == "https://gateway.example.com:8443/openclaw/?view=desktop") + #expect(url?.absoluteString == "https://gateway.example.com:8443/openclaw/focus/desktop") #expect(url?.absoluteString.contains("secret-token") == false) #expect(url?.absoluteString.contains("secret-password") == false) } @@ -49,29 +49,33 @@ struct DesktopHubScreenTests { let url = DesktopHubScreen.desktopURL( config: config, source: nil, - session: "agent:main:mobile session") + session: "agent:main/mobile session") - #expect(url?.absoluteString == "http://192.168.1.10:18789/?view=desktop&session=agent%3Amain%3Amobile%20session") + #expect( + url?.absoluteString == + "http://192.168.1.10:18789/focus/desktop/session/agent%3Amain%2Fmobile%20session") #expect(url?.absoluteString.contains("secret-token") == false) } - @Test func `explicit desktop source is retained alongside the session`() throws { + @Test func `explicit desktop source wins over the session`() throws { let config = try Self.makeConfig(url: #require(URL(string: "wss://gateway.example.com"))) let url = DesktopHubScreen.desktopURL( config: config, - source: "node:worker-1", + source: "node:worker-1/primary?mode=qa", session: "agent:main:mobile") - #expect(url?.absoluteString == "https://gateway.example.com/?view=desktop&source=node%3Aworker-1&session=agent%3Amain%3Amobile") + #expect( + url?.absoluteString == + "https://gateway.example.com/focus/desktop/source/node%3Aworker-1%2Fprimary%3Fmode%3Dqa") } - @Test func `empty desktop source is omitted`() throws { + @Test func `empty desktop source and session are normalized away`() throws { let config = try Self.makeConfig(url: #require(URL(string: "wss://gateway.example.com"))) let url = DesktopHubScreen.desktopURL(config: config, source: " ", session: " ") - #expect(url?.absoluteString == "https://gateway.example.com/?view=desktop") + #expect(url?.absoluteString == "https://gateway.example.com/focus/desktop") } @Test func `desktop auth script carries credentials outside the URL`() throws { @@ -83,7 +87,7 @@ struct DesktopHubScreenTests { let url = DesktopHubScreen.desktopURL(config: config, source: "gateway") let script = DesktopHubScreen.desktopAuthUserScript(config: config, source: "gateway") - #expect(url?.absoluteString == "https://gateway.example.com/?view=desktop&source=gateway") + #expect(url?.absoluteString == "https://gateway.example.com/focus/desktop/source/gateway") #expect(url?.absoluteString.contains("secret-token") == false) #expect(url?.absoluteString.contains("secret-password") == false) #expect(script?.contains("__OPENCLAW_NATIVE_CONTROL_AUTH__") == true) diff --git a/apps/ios/Tests/TerminalHubScreenTests.swift b/apps/ios/Tests/TerminalHubScreenTests.swift index 0f9a37b53b71..96e7c3f91478 100644 --- a/apps/ios/Tests/TerminalHubScreenTests.swift +++ b/apps/ios/Tests/TerminalHubScreenTests.swift @@ -37,12 +37,12 @@ struct TerminalHubScreenTests { @Test func `terminal URL flips scheme and preserves the Control UI base path`() throws { let config = try Self.makeConfig( - url: #require(URL(string: "wss://gateway.example.com:8443/openclaw")), + url: #require(URL(string: "wss://gateway.example.com:8443/openclaw/")), token: "secret-token") let url = TerminalHubScreen.terminalURL(config: config) - #expect(url?.absoluteString == "https://gateway.example.com:8443/openclaw/?view=terminal") + #expect(url?.absoluteString == "https://gateway.example.com:8443/openclaw/focus/terminal") // Credentials must never ride in the page URL; they travel via the // document-start auth user script instead. #expect(url?.absoluteString.contains("secret-token") == false) @@ -53,7 +53,7 @@ struct TerminalHubScreenTests { let url = TerminalHubScreen.terminalURL(config: config) - #expect(url?.absoluteString == "http://192.168.1.10:18789/?view=terminal") + #expect(url?.absoluteString == "http://192.168.1.10:18789/focus/terminal") } @Test func `auth user script carries credentials gated to the page origin`() throws { diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index 07764f4f3325..b8e20e89d21b 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -441,14 +441,14 @@ Codex and Claude Code sessions discovered in the sessions sidebar can open in th Eligibility is per session and per host. Gateway-local sessions start the provider-owned resume command on the Gateway host. Paired-node sessions start an allowlisted provider command on the owning node and relay only that PTY's output, input, and resize events; this does not expose a general node shell or accept browser-supplied commands. File uploads use the separate, size-bounded `terminal.upload` node command and remain bound to the already-open terminal session. Approve the node pairing upgrade when that command first appears. Nodes that do not advertise the matching terminal-resume command, including embedded worker bridges without duplex streaming, keep the viewer available and show terminal opening as unavailable; older nodes can still run a terminal but cannot receive dragged files. -Standalone operator sessions, including the full-screen terminal document, are connection-owned. A page reload, laptop sleep, or network blip detaches one on the Gateway instead of killing it, and the same browser tab reattaches on reconnect with recent output replayed. Detached connection-owned sessions are killed after `gateway.terminal.detachedSessionTimeoutSeconds` (default 300 seconds; `0` restores kill-on-disconnect). Attaching one of these sessions remains tmux-style take-over. +Standalone operator sessions, including the terminal focus presentation, are connection-owned. A page reload, laptop sleep, or network blip detaches one on the Gateway instead of killing it, and the same browser tab reattaches on reconnect with recent output replayed. Detached connection-owned sessions are killed after `gateway.terminal.detachedSessionTimeoutSeconds` (default 300 seconds; `0` restores kill-on-disconnect). Attaching one of these sessions remains tmux-style take-over. Conversation-owned sessions, whether opened by the agent tool or from that Chat session's Terminal panel, are not bound to a browser connection. `terminal.attach` adds each browser as a viewer without taking ownership, and closing a viewer tab detaches only that browser. Conversation-owned PTYs remain until the agent closes them, their process exits, policy disables them, or the Gateway shuts down. PTYs opened by a detached task close automatically when that task succeeds, fails, times out, is cancelled, or is lost. `terminal.list` marks each entry as connection- or agent-owned. All Gateway terminal PTYs are process-local. A Gateway restart ends them; the PTY sessions and their scrollback are not recovered after the new process starts. -The terminal is also available as a [full-screen terminal document](/web/urls#special-documents-and-startup-modes). The iOS and Android apps embed this page in their Terminal screens, reusing the stored gateway credentials; availability follows the same `gateway.terminal.enabled` and `operator.admin` gate, and the page shows a notice when the connected Gateway does not offer the terminal. +The terminal is also available as a [focus presentation](/web/urls#focus-presentation-routes). The iOS and Android apps embed this page in their Terminal screens, reusing the stored gateway credentials; availability follows the same `gateway.terminal.enabled` and `operator.admin` gate, and the page shows a notice when the connected Gateway does not offer the terminal. Focus presentation removes the application chrome; it does not invoke browser fullscreen. ## Browser panel diff --git a/docs/web/dashboards.md b/docs/web/dashboards.md index 5724b5a671a7..77b4a4df6a6f 100644 --- a/docs/web/dashboards.md +++ b/docs/web/dashboards.md @@ -25,11 +25,12 @@ thread's `/dashboard//` URL. An open Dashboards page updates as threads are renamed, archived, deleted, or switched between Chat and Dashboard, including after a Gateway reconnect. -Use **Open full-screen dashboard** on a row to open its board as a standalone -browser document with no sidebar, top bar, or chat. The close button returns to -the previous page. Inside a session, use the fullscreen button beside the -Chat / Split / Dashboard switch to enter or leave browser fullscreen while the -board is visible. +Use **Open dashboard in focus mode** on a row to open its board as a standalone +browser document at `/focus/dashboard//`, with no sidebar, +top bar, or chat. This focus presentation does not invoke browser fullscreen; +the close button returns to the previous page. Inside a session, use the +fullscreen button beside the Chat / Split / Dashboard switch to enter or leave +browser fullscreen while the board is visible. The Chat or Dashboard face preference is stored server-side per thread. It therefore follows you when you connect to the same gateway from another device. diff --git a/docs/web/urls.md b/docs/web/urls.md index 19744c815513..52c2a911eb7d 100644 --- a/docs/web/urls.md +++ b/docs/web/urls.md @@ -1,9 +1,9 @@ --- -summary: "Control UI URL routes, stable session-link grammar, and connection handoff parameters" +summary: "Control UI routes, focus presentations, stable session links, and connection handoff parameters" read_when: - You need to bookmark or share a Control UI session - You are adding or changing a Control UI route - - You need a terminal, approval, onboarding, or remote Gateway URL + - You need a terminal, desktop, approval, onboarding, or remote Gateway URL title: "Control UI URLs" --- @@ -123,10 +123,70 @@ own `?session=` parameter because that parameter expands a row; it is not a session deep link. The one-shot composer value `?draft=` remains supported on chat and dashboard session paths. -This canonical-link restriction applies to application routes. The standalone -dashboard document described below intentionally uses -`/?view=dashboard&session=` because it is a special document, not a -session route. +## Focus presentation routes + +A focus route renders one supported content surface without the normal Control +UI application chrome. Focus presentation is separate from browser fullscreen: +opening a focus route does not invoke the browser Fullscreen API. + +Insert `/focus` immediately after the configured Control UI base path. Removing +it returns the corresponding normal route when one exists: + +```text +/dashboard/roboclaw/the-daily-claw-6d7c9ccb +/focus/dashboard/roboclaw/the-daily-claw-6d7c9ccb + +/openclaw/dashboard/roboclaw/the-daily-claw-6d7c9ccb +/openclaw/focus/dashboard/roboclaw/the-daily-claw-6d7c9ccb +``` + +Dashboard focus routes use the complete canonical `/dashboard` grammar above: + +```text +/focus/dashboard/ +/focus/dashboard// +``` + +The Control UI removes the focus modifier before passing the dashboard route to +the canonical session resolver. Canonical address replacement and ambiguity +candidate links preserve `/focus`. Missing, ambiguous, and unavailable sessions +remain visible, and the dashboard is not read until the session resolves to a +canonical key. + +The other focus targets are: + +```text +/focus/terminal + +/focus/desktop +/focus/desktop/source/ +/focus/desktop/session/ +/focus/desktop/control +/focus/desktop/control/source/ +/focus/desktop/control/session/ +``` + +Encode desktop source and exact-session-key values with `encodeURIComponent` so +each occupies one path segment. Empty source and session values are omitted. If +a native caller supplies both non-empty values, the source form wins. The +optional `control` segment requests initial control; it does not grant control +or authorize the connection. + +The focus target and desktop identity or options are path-only. Credentials do +not belong in these URLs. Each target keeps the startup, authentication, +permission, and capability checks of its normal or embedded surface. In +particular, the terminal still requires `gateway.terminal.enabled` and an +`operator.admin` connection. + +Stable releases previously emitted `/?view=terminal`. The Control UI accepts +that form only at the application root (or `/?view=terminal`) and +immediately replaces it in browser history with `/focus/terminal` under the +same base path, removing the legacy `view` parameter. New links must use +`/focus/terminal`. The query form is not recognized on other application +paths, and the removed desktop and dashboard query forms are not accepted. + +`/focus` and unsupported `/focus/*` targets show an error without the ordinary +application shell. They do not open a normal application route. ## Route table @@ -200,24 +260,22 @@ Agent selection and its `overview|files|tools|skills|channels|cron|memory` panels use paths. Older links with `?agent=` are replaced once with the agent path while keeping other query parameters and the fragment. -## Special documents and startup modes +## Other special documents and startup modes These Gateway-served documents sit outside the application route table: - `/?onboarding=1` opens the first-run onboarding presentation. -- `/terminal` opens the user-facing full-screen terminal. With a base path, use - `/terminal`. -- `/?view=terminal` opens the same terminal-only document in the WebView/embed - form used by the mobile apps. Terminal availability in either form still - requires `gateway.terminal.enabled` and `operator.admin`. -- `/?view=dashboard&session=` opens that session's interactive - dashboard full-window without application or chat chrome. The document stays - connected to live board updates and shows a visible empty state when the - session or board is unavailable. - `/approve/` opens a standalone approval document. With a base path, use `/approve/`. The id identifies an approval but never authorizes it; normal Gateway authentication still applies. +Registered exact and prefix plugin HTTP routes can own `/focus` and +`/focus/*`. After plugin authentication and dispatch decline a request, the +Gateway uses those paths as the Control UI focus fallback: unclaimed `GET` and +`HEAD` requests serve the Control UI document, while other methods return +`404`. Every unclaimed method returns `404` when Control UI serving is +disabled. Lookalikes such as `/focused` are not part of the focus fallback. + The approval namespace is reserved ahead of plugin HTTP routes for all HTTP methods. When Control UI serving is disabled, it returns `404` instead of falling through to a plugin route. diff --git a/extensions/qa-lab/src/gateway-child.ts b/extensions/qa-lab/src/gateway-child.ts index b632f7f06d6c..32bedc3352e2 100644 --- a/extensions/qa-lab/src/gateway-child.ts +++ b/extensions/qa-lab/src/gateway-child.ts @@ -51,6 +51,7 @@ import { waitForQaGatewayRestartBoundary, } from "./gateway-child-readiness.js"; import { redactQaGatewayDebugText } from "./gateway-log-redaction.js"; +import { reserveQaGatewayPort } from "./gateway-port-reservation.js"; import { createQaGatewayProcessBoundaryController, type QaGatewayVerifiedProcessIdentity, @@ -102,21 +103,6 @@ function createQaGatewayEmptyTransport() { } satisfies Pick; } -async function getFreePort() { - return await new Promise((resolve, reject) => { - const server = net.createServer(); - server.once("error", (error) => reject(error)); - server.listen(0, "127.0.0.1", () => { - const address = server.address(); - if (!address || typeof address === "string") { - reject(new Error("failed to allocate port")); - return; - } - server.close((error) => (error ? reject(error) : resolve(address.port))); - }); - }); -} - function appendQaGatewayTempRoot(details: string, tempRoot: string) { return details.includes(tempRoot) ? details @@ -261,6 +247,7 @@ export async function startQaGatewayChild(params: { ReturnType > | null = null; let rpcClient: Awaited> | null = null; + let gatewayPortReservation: Awaited> | null = null; let stagedBundledPluginsRoot: string | null = null; const tempRoot = await fs.mkdtemp(path.join(tempParentDir, "openclaw-qa-suite-")); // The startup owner must release its temp root even when launcher or staging @@ -514,7 +501,8 @@ export async function startQaGatewayChild(params: { }; for (let attempt = 1; attempt <= QA_GATEWAY_CHILD_STARTUP_MAX_ATTEMPTS; attempt += 1) { if (!reuseStartupLaunchState) { - gatewayPort = await getFreePort(); + gatewayPortReservation = await reserveQaGatewayPort(() => net.createServer()); + gatewayPort = gatewayPortReservation.port; baseUrl = `http://127.0.0.1:${gatewayPort}`; wsUrl = `ws://127.0.0.1:${gatewayPort}`; cfg = await buildStagedGatewayConfig(gatewayPort); @@ -603,6 +591,10 @@ export async function startQaGatewayChild(params: { reuseStartupLaunchState = false; const attemptLogMark = output.mark(); + // Hold the selected port through plugin/config staging so parallel QA workers + // cannot satisfy readiness against one another. Release only for the child bind. + await gatewayPortReservation?.release(); + gatewayPortReservation = null; const spawnedAttempt = await spawnGatewayProcess(env); const attemptChild = spawnedAttempt.child; child = attemptChild; @@ -1027,6 +1019,13 @@ export async function startQaGatewayChild(params: { }; } catch (error) { const cleanupErrors: unknown[] = []; + if (gatewayPortReservation) { + try { + await gatewayPortReservation.release(); + } catch (cleanupError) { + cleanupErrors.push(cleanupError); + } + } await rpcClient?.stop().catch(() => {}); let processStopped = child === null; if (child) { diff --git a/extensions/qa-lab/src/gateway-port-reservation.test.ts b/extensions/qa-lab/src/gateway-port-reservation.test.ts new file mode 100644 index 000000000000..28e5b2dc002a --- /dev/null +++ b/extensions/qa-lab/src/gateway-port-reservation.test.ts @@ -0,0 +1,40 @@ +// Qa Lab tests cover Gateway port reservation behavior. +import net from "node:net"; +import { afterEach, describe, expect, it } from "vitest"; +import { reserveQaGatewayPort } from "./gateway-port-reservation.js"; + +const servers = new Set(); + +afterEach(async () => { + await Promise.all( + [...servers].map( + (server) => + new Promise((resolve) => { + server.close(() => resolve()); + }), + ), + ); + servers.clear(); +}); + +async function bindReservedPort(port: number) { + const server = net.createServer(); + servers.add(server); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(port, "127.0.0.1", resolve); + }); + return server; +} + +describe("reserveQaGatewayPort", () => { + it("keeps the selected port unavailable until release", async () => { + const reservation = await reserveQaGatewayPort(() => net.createServer()); + + await expect(bindReservedPort(reservation.port)).rejects.toMatchObject({ code: "EADDRINUSE" }); + await reservation.release(); + + await expect(bindReservedPort(reservation.port)).resolves.toBeInstanceOf(net.Server); + await expect(reservation.release()).resolves.toBeUndefined(); + }); +}); diff --git a/extensions/qa-lab/src/gateway-port-reservation.ts b/extensions/qa-lab/src/gateway-port-reservation.ts new file mode 100644 index 000000000000..de7e3cdc6d4d --- /dev/null +++ b/extensions/qa-lab/src/gateway-port-reservation.ts @@ -0,0 +1,39 @@ +// Qa Lab plugin module reserves Gateway ports across pre-spawn setup. +type QaGatewayPortServer = { + once(event: "error", listener: (error: Error) => void): void; + off(event: "error", listener: (error: Error) => void): void; + listen(port: number, host: string, listener: () => void): void; + address(): { port: number } | string | null; + close(callback?: (error?: Error) => void): void; +}; + +export async function reserveQaGatewayPort(createServer: () => QaGatewayPortServer) { + const server = createServer(); + const port = await new Promise((resolve, reject) => { + const handleError = (error: Error) => { + server.close(() => {}); + reject(error); + }; + server.once("error", handleError); + server.listen(0, "127.0.0.1", () => { + server.off("error", handleError); + const address = server.address(); + if (!address || typeof address === "string") { + server.close(); + reject(new Error("failed to reserve gateway port")); + return; + } + resolve(address.port); + }); + }); + let releasePromise: Promise | undefined; + return { + port, + release() { + releasePromise ??= new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + return releasePromise; + }, + }; +} diff --git a/extensions/qa-lab/src/harness-runtime.integration.test.ts b/extensions/qa-lab/src/harness-runtime.integration.test.ts index 0f87923213de..0e2ce9690869 100644 --- a/extensions/qa-lab/src/harness-runtime.integration.test.ts +++ b/extensions/qa-lab/src/harness-runtime.integration.test.ts @@ -5,7 +5,7 @@ import { resetPluginRuntimeStateForTest, setActivePluginRegistry, } from "openclaw/plugin-sdk/plugin-test-runtime"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; import { startQaBusServer } from "./bus-server.js"; import { createQaBusState } from "./bus-state.js"; import { createQaRunnerRuntime } from "./harness-runtime.js"; @@ -80,19 +80,17 @@ describe("QA runner runtime integration", () => { text: "ping", }); - await Promise.race([ - vi.waitFor( - () => { - expect(harness.state.getSnapshot().messages).toContainEqual( - expect.objectContaining({ direction: "outbound", text: "qa-echo: ping" }), - ); - }, - { interval: 25, timeout: 2_000 }, - ), + const outbound = await Promise.race([ + harness.state.waitFor({ + kind: "message-text", + direction: "outbound", + textIncludes: "qa-echo: ping", + }), harness.gatewayTask.then(() => { throw new Error("QA Channel gateway stopped before delivering the turn"); }), ]); + expect(outbound).toMatchObject({ direction: "outbound", text: "qa-echo: ping" }); } finally { await harness.stop(); } diff --git a/packages/session-url-contract/src/focus.test.ts b/packages/session-url-contract/src/focus.test.ts new file mode 100644 index 000000000000..0ec9d0cccc70 --- /dev/null +++ b/packages/session-url-contract/src/focus.test.ts @@ -0,0 +1,208 @@ +import { describe, expect, it } from "vitest"; +import { + buildControlUiFocusPath, + inferControlUiFocusBasePath, + parseControlUiFocusLocation, +} from "./focus.js"; + +describe("Control UI focus locations", () => { + it.each([ + ["dashboard main", "/focus/dashboard/roboclaw", undefined, "/dashboard/roboclaw"], + [ + "dashboard short reference", + "/focus/dashboard/roboclaw/the-daily-claw-6d7c9ccb", + undefined, + "/dashboard/roboclaw/the-daily-claw-6d7c9ccb", + ], + [ + "dashboard literal key", + "/focus/dashboard/roboclaw/~key/12345678", + undefined, + "/dashboard/roboclaw/~key/12345678", + ], + [ + "base-path dashboard", + "/openclaw/focus/dashboard/roboclaw/the-daily-claw-6d7c9ccb/", + "/openclaw", + "/openclaw/dashboard/roboclaw/the-daily-claw-6d7c9ccb", + ], + ])("parses %s through the underlying dashboard route", (_name, pathname, basePath, routePath) => { + expect(parseControlUiFocusLocation(pathname, basePath)).toEqual({ + status: "valid", + basePath: basePath ?? "", + target: { + kind: "dashboard", + route: { pathname: routePath, search: "", hash: "" }, + }, + }); + }); + + it.each([ + ["terminal", "/focus/terminal", { kind: "terminal" }], + ["desktop", "/focus/desktop/", { kind: "desktop", control: false, selector: null }], + [ + "desktop source", + "/focus/desktop/source/environment%3AMac%20Studio%2FQA%20%26%20demo", + { + kind: "desktop", + control: false, + selector: { kind: "source", value: "environment:Mac Studio/QA & demo" }, + }, + ], + [ + "desktop session", + "/focus/desktop/session/agent%3Amain%3Amobile%20session", + { + kind: "desktop", + control: false, + selector: { kind: "session", value: "agent:main:mobile session" }, + }, + ], + [ + "controlled desktop", + "/focus/desktop/control", + { kind: "desktop", control: true, selector: null }, + ], + [ + "controlled source", + "/focus/desktop/control/source/node%3Aworker-1", + { + kind: "desktop", + control: true, + selector: { kind: "source", value: "node:worker-1" }, + }, + ], + [ + "controlled session", + "/focus/desktop/control/session/agent%3Amain%3Amobile", + { + kind: "desktop", + control: true, + selector: { kind: "session", value: "agent:main:mobile" }, + }, + ], + ] as const)("parses %s", (_name, pathname, target) => { + expect(parseControlUiFocusLocation(pathname, "")).toEqual({ + status: "valid", + basePath: "", + target, + }); + }); + + it.each([ + "/focus", + "/focus/unknown", + "/focus/terminal/extra", + "/focus/desktop/source", + "/focus/desktop/session/%", + "/focus/desktop/control/unknown/value", + "/focus/dashboard", + ])("rejects malformed or unsupported target %s", (pathname) => { + expect(parseControlUiFocusLocation(pathname, "")).toEqual({ + status: "unsupported", + basePath: "", + }); + }); + + it.each([ + "/?view=dashboard&session=agent%3Amain%3Awork", + "/?view=terminal", + "/?view=desktop", + "/terminal", + "/desktop", + "/focused/terminal", + ])("does not parse query aliases or lookalike location %s", (pathname) => { + expect(parseControlUiFocusLocation(pathname, "")).toBeNull(); + }); + + it("infers focus-aware base paths without overriding an explicit base", () => { + expect(inferControlUiFocusBasePath("/focus/terminal")).toBe(""); + expect(inferControlUiFocusBasePath("/openclaw/focus/desktop")).toBe("/openclaw"); + expect(inferControlUiFocusBasePath("/company/focus/focus/terminal")).toBe("/company/focus"); + expect(inferControlUiFocusBasePath("/focused/terminal")).toBeNull(); + expect(parseControlUiFocusLocation("/openclaw/focus/terminal", "/other")).toBeNull(); + }); + + it("passes dashboard search and hash through to the canonical route loader", () => { + expect( + parseControlUiFocusLocation({ + pathname: "/focus/dashboard/main", + search: "?catalog=beam&host=gateway&thread=one", + hash: "#pane", + }), + ).toEqual({ + status: "valid", + basePath: "", + target: { + kind: "dashboard", + route: { + pathname: "/dashboard/main", + search: "?catalog=beam&host=gateway&thread=one", + hash: "#pane", + }, + }, + }); + }); +}); + +describe("buildControlUiFocusPath", () => { + it.each([ + [ + "dashboard", + { kind: "dashboard", path: "/dashboard/roboclaw/the-daily-claw-6d7c9ccb" }, + "", + "/focus/dashboard/roboclaw/the-daily-claw-6d7c9ccb", + ], + [ + "base-path dashboard with suffix", + { kind: "dashboard", path: "/openclaw/dashboard/roboclaw/main?catalog=beam#pane" }, + "/openclaw/", + "/openclaw/focus/dashboard/roboclaw/main?catalog=beam#pane", + ], + ["terminal", { kind: "terminal" }, "/openclaw", "/openclaw/focus/terminal"], + ["desktop", { kind: "desktop" }, "", "/focus/desktop"], + [ + "desktop source", + { kind: "desktop", source: "environment:Mac Studio/QA & demo" }, + "", + "/focus/desktop/source/environment%3AMac%20Studio%2FQA%20%26%20demo", + ], + [ + "desktop session", + { kind: "desktop", session: "agent:main:mobile session" }, + "", + "/focus/desktop/session/agent%3Amain%3Amobile%20session", + ], + [ + "controlled source wins", + { + kind: "desktop", + control: true, + source: "node:worker-1", + session: "agent:main:mobile", + }, + "", + "/focus/desktop/control/source/node%3Aworker-1", + ], + [ + "controlled session", + { kind: "desktop", control: true, session: "agent:main:mobile" }, + "", + "/focus/desktop/control/session/agent%3Amain%3Amobile", + ], + [ + "empty values", + { kind: "desktop", source: " ", session: "" }, + "/openclaw", + "/openclaw/focus/desktop", + ], + ] as const)("builds %s", (_name, target, basePath, expected) => { + expect(buildControlUiFocusPath(target, basePath)).toBe(expected); + }); + + it("rejects a dashboard route outside the configured base path", () => { + expect( + buildControlUiFocusPath({ kind: "dashboard", path: "/dashboard/roboclaw/main" }, "/openclaw"), + ).toBeNull(); + }); +}); diff --git a/packages/session-url-contract/src/focus.ts b/packages/session-url-contract/src/focus.ts new file mode 100644 index 000000000000..f1aee28d9087 --- /dev/null +++ b/packages/session-url-contract/src/focus.ts @@ -0,0 +1,229 @@ +import { normalizeControlUiBasePath } from "./grammar.js"; + +const FOCUS_SEGMENT = "/focus"; + +type ControlUiFocusDashboardTarget = { + kind: "dashboard"; + /** Existing canonical dashboard route, including any search or hash suffix. */ + path: string; +}; + +type ControlUiFocusDesktopBuildTarget = { + kind: "desktop"; + control?: boolean; + source?: string | null; + session?: string | null; +}; + +export type ControlUiFocusBuildTarget = + | ControlUiFocusDashboardTarget + | { kind: "terminal" } + | ControlUiFocusDesktopBuildTarget; + +export type ControlUiFocusTarget = + | { + kind: "dashboard"; + route: { pathname: string; search: string; hash: string }; + } + | { kind: "terminal" } + | { + kind: "desktop"; + control: boolean; + selector: { kind: "source" | "session"; value: string } | null; + }; + +export type ControlUiFocusLocation = + | { status: "valid"; basePath: string; target: ControlUiFocusTarget } + | { status: "unsupported"; basePath: string }; + +type ControlUiFocusLocationInput = string | { pathname: string; search?: string; hash?: string }; + +function normalizePathname(pathname: string): string { + const trimmed = pathname.trim(); + const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`; + return withSlash.length > 1 ? withSlash.replace(/\/+$/u, "") : withSlash; +} + +function splitPathSuffix(value: string): { pathname: string; suffix: string } { + const queryIndex = value.indexOf("?"); + const hashIndex = value.indexOf("#"); + const suffixIndex = [queryIndex, hashIndex] + .filter((index) => index >= 0) + .reduce((first, index) => Math.min(first, index), value.length); + return { pathname: value.slice(0, suffixIndex), suffix: value.slice(suffixIndex) }; +} + +function nonEmptyValue(value: string | null | undefined): string | null { + return value && value.trim() ? value : null; +} + +function decodeFocusValue(segment: string): { ok: true; value: string | null } | { ok: false } { + try { + return { ok: true, value: nonEmptyValue(decodeURIComponent(segment)) }; + } catch { + return { ok: false }; + } +} + +export function inferControlUiFocusBasePath(pathname: string): string | null { + const normalizedPath = normalizePathname(pathname); + const segments = normalizedPath.split("/").filter(Boolean); + const focusIndexes = segments.flatMap((segment, index) => + segment === FOCUS_SEGMENT.slice(1) ? [index] : [], + ); + if (focusIndexes.length === 0) { + return null; + } + const supportsSuffix = (index: number): boolean => { + const rest = segments.slice(index + 1); + if (rest[0] === "terminal") { + return rest.length === 1; + } + if (rest[0] === "dashboard") { + return rest.length >= 2; + } + if (rest[0] !== "desktop") { + return false; + } + const selectorIndex = rest[1] === "control" ? 2 : 1; + return ( + rest.length === selectorIndex || + (rest.length === selectorIndex + 2 && + (rest[selectorIndex] === "source" || rest[selectorIndex] === "session")) + ); + }; + let focusIndex = focusIndexes.at(-1) ?? 0; + for (let index = focusIndexes.length - 1; index >= 0; index -= 1) { + const candidate = focusIndexes[index]; + if (candidate !== undefined && supportsSuffix(candidate)) { + focusIndex = candidate; + break; + } + } + return normalizeControlUiBasePath(segments.slice(0, focusIndex).join("/")); +} + +export function isControlUiFocusPath(pathname: string, basePath = ""): boolean { + const normalizedPath = normalizePathname(pathname); + const root = `${normalizeControlUiBasePath(basePath)}${FOCUS_SEGMENT}`; + return normalizedPath === root || normalizedPath.startsWith(`${root}/`); +} + +export function buildControlUiFocusPath( + target: Exclude, + basePath?: string, +): string; +export function buildControlUiFocusPath( + target: ControlUiFocusDashboardTarget, + basePath?: string, +): string | null; +export function buildControlUiFocusPath( + target: ControlUiFocusBuildTarget, + basePath?: string, +): string | null; +export function buildControlUiFocusPath( + target: ControlUiFocusBuildTarget, + basePath = "", +): string | null { + const base = normalizeControlUiBasePath(basePath); + const root = `${base}${FOCUS_SEGMENT}`; + if (target.kind === "terminal") { + return `${root}/terminal`; + } + if (target.kind === "desktop") { + const control = target.control === true ? "/control" : ""; + const source = nonEmptyValue(target.source); + const session = nonEmptyValue(target.session); + const selector = source + ? `/source/${encodeURIComponent(source)}` + : session + ? `/session/${encodeURIComponent(session)}` + : ""; + return `${root}/desktop${control}${selector}`; + } + const { pathname, suffix } = splitPathSuffix(target.path); + const normalizedPath = normalizePathname(pathname); + const dashboardRoot = `${base}/dashboard/`; + if (!normalizedPath.startsWith(dashboardRoot)) { + return null; + } + return `${root}${normalizedPath.slice(base.length)}${suffix}`; +} + +export function parseControlUiFocusLocation( + input: ControlUiFocusLocationInput, + basePath?: string, +): ControlUiFocusLocation | null { + const pathname = typeof input === "string" ? input : input.pathname; + const search = typeof input === "string" ? "" : (input.search ?? ""); + const hash = typeof input === "string" ? "" : (input.hash ?? ""); + const normalizedPath = normalizePathname(pathname); + const resolvedBasePath = + basePath === undefined + ? inferControlUiFocusBasePath(normalizedPath) + : normalizeControlUiBasePath(basePath); + if (resolvedBasePath === null || !isControlUiFocusPath(normalizedPath, resolvedBasePath)) { + return null; + } + const root = `${resolvedBasePath}${FOCUS_SEGMENT}`; + const rest = normalizedPath.slice(root.length + 1); + if (rest === "terminal") { + return { status: "valid", basePath: resolvedBasePath, target: { kind: "terminal" } }; + } + if (rest.startsWith("dashboard/") && rest.length > "dashboard/".length) { + return { + status: "valid", + basePath: resolvedBasePath, + target: { + kind: "dashboard", + route: { pathname: `${resolvedBasePath}/${rest}`, search, hash }, + }, + }; + } + + const segments = rest.split("/"); + if (segments[0] !== "desktop") { + return { status: "unsupported", basePath: resolvedBasePath }; + } + let index = 1; + const control = segments[index] === "control"; + if (control) { + index += 1; + } + if (segments.length === index) { + return { + status: "valid", + basePath: resolvedBasePath, + target: { kind: "desktop", control, selector: null }, + }; + } + const selectorKind = segments[index]; + const encodedValue = segments[index + 1]; + if ( + segments.length !== index + 2 || + (selectorKind !== "source" && selectorKind !== "session") || + encodedValue === undefined + ) { + return { status: "unsupported", basePath: resolvedBasePath }; + } + const decoded = decodeFocusValue(encodedValue); + if (!decoded.ok) { + return { status: "unsupported", basePath: resolvedBasePath }; + } + if (!decoded.value) { + return { + status: "valid", + basePath: resolvedBasePath, + target: { kind: "desktop", control, selector: null }, + }; + } + return { + status: "valid", + basePath: resolvedBasePath, + target: { + kind: "desktop", + control, + selector: { kind: selectorKind, value: decoded.value }, + }, + }; +} diff --git a/packages/session-url-contract/src/index.ts b/packages/session-url-contract/src/index.ts index dac38ae7de8b..ce68b50f04bb 100644 --- a/packages/session-url-contract/src/index.ts +++ b/packages/session-url-contract/src/index.ts @@ -8,6 +8,7 @@ import { } from "./grammar.js"; export { normalizeControlUiBasePath }; +export * from "./focus.js"; // Control UI session URL grammar shared by browser and plugin consumers. export type ControlUiSessionNamespace = "chat" | "dashboard"; diff --git a/src/docs/install-cloud-secrets.test.ts b/src/docs/install-cloud-secrets.test.ts index 10d21c652ee0..90279cc42063 100644 --- a/src/docs/install-cloud-secrets.test.ts +++ b/src/docs/install-cloud-secrets.test.ts @@ -25,7 +25,7 @@ async function readInstallDocs(): Promise { - it("does not publish a copy-paste gateway token placeholder", async () => { + it("keeps cloud install secret guidance safe and centralized", async () => { for (const { docName, markdown } of await readInstallDocs()) { for (const token of KNOWN_WEAK_GATEWAY_TOKEN_PLACEHOLDERS) { expect(markdown, docName).not.toContain(`OPENCLAW_GATEWAY_TOKEN=${token}`); diff --git a/src/gateway/control-ui-routing.test.ts b/src/gateway/control-ui-routing.test.ts index b33250887f82..fd38161e676c 100644 --- a/src/gateway/control-ui-routing.test.ts +++ b/src/gateway/control-ui-routing.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it } from "vitest"; import { classifyControlUiRequest, isControlUiApprovalDocumentPath, + isControlUiFocusDocumentPath, isControlUiPluginManagerRequest, } from "./control-ui-routing.js"; @@ -44,6 +45,27 @@ describe("isControlUiApprovalDocumentPath", () => { }); }); +describe("isControlUiFocusDocumentPath", () => { + it.each([ + { basePath: "", pathname: "/focus" }, + { basePath: "", pathname: "/focus/" }, + { basePath: "", pathname: "/focus/dashboard/roboclaw/the-daily-claw-6d7c9ccb" }, + { basePath: "", pathname: "/focus/not-supported" }, + { basePath: "/openclaw", pathname: "/openclaw/focus/desktop/control" }, + ])("classifies $pathname", ({ basePath, pathname }) => { + expect(isControlUiFocusDocumentPath({ basePath, pathname })).toBe(true); + }); + + it.each([ + { basePath: "", pathname: "/focused" }, + { basePath: "", pathname: "/focused/terminal" }, + { basePath: "/openclaw", pathname: "/focus/terminal" }, + { basePath: "/openclaw", pathname: "/openclaw/focused" }, + ])("does not classify $pathname", ({ basePath, pathname }) => { + expect(isControlUiFocusDocumentPath({ basePath, pathname })).toBe(false); + }); +}); + describe("Control UI SPA fallback Accept routing", () => { it.each([ { diff --git a/src/gateway/control-ui-routing.ts b/src/gateway/control-ui-routing.ts index 218649f16570..2ade69101f46 100644 --- a/src/gateway/control-ui-routing.ts +++ b/src/gateway/control-ui-routing.ts @@ -1,4 +1,5 @@ // Control UI route classifier for base-path and root-mounted SPA serving. +import { isControlUiFocusPath } from "@openclaw/session-url-contract"; import { acceptsControlUiHtmlResponse, isReadHttpMethod } from "./control-ui-http-utils.js"; import { classifyGatewayProbePath, @@ -45,6 +46,14 @@ export function isControlUiApprovalDocumentPath(params: { return encodedId.length > 0 && !encodedId.includes("/"); } +/** Focused presentation namespace used only after plugin routing declines it. */ +export function isControlUiFocusDocumentPath(params: { + basePath: string; + pathname: string; +}): boolean { + return isControlUiFocusPath(params.pathname, params.basePath); +} + /** Classify an HTTP request as Control UI serving, redirect, 404, or non-Control-UI. */ export function classifyControlUiRequest(params: { basePath: string; diff --git a/src/gateway/control-ui.http.test.ts b/src/gateway/control-ui.http.test.ts index efa1bc304b22..312614f59a90 100644 --- a/src/gateway/control-ui.http.test.ts +++ b/src/gateway/control-ui.http.test.ts @@ -1561,33 +1561,56 @@ describe("handleControlUiHttpRequest", () => { it.each([ { - name: "root-mounted nested routes", + name: "root-mounted focus routes", + requestPath: "/focus/dashboard/roboclaw/session-ref", + basePath: undefined, + expectedPrefix: "", + }, + { + name: "base-mounted focus routes", + requestPath: "/openclaw/focus/desktop/control", + basePath: "/openclaw", + expectedPrefix: "/openclaw", + }, + { + name: "root-mounted ordinary deep routes", requestPath: "/settings/approvals", basePath: undefined, expectedPrefix: "", }, { - name: "base-mounted nested routes", + name: "base-mounted ordinary deep routes", requestPath: "/openclaw/settings/approvals", basePath: "/openclaw", expectedPrefix: "/openclaw", }, ])( - "anchors Vite-relative public asset hrefs for $name", + "anchors Vite-relative asset references for $name", async ({ requestPath, basePath, expectedPrefix }) => { - const assets = [ + const emittedAssets = [ + ["index.js", "index-js\n", "application/javascript; charset=utf-8"], + ["runtime.js", "runtime-js\n", "application/javascript; charset=utf-8"], + ["index.css", "index-css\n", "text/css; charset=utf-8"], + ] as const; + const publicAssets = [ "favicon.svg", "favicon-32.png", "apple-touch-icon.png", "manifest.webmanifest", ]; - const html = `${assets + const html = `${publicAssets .map((asset) => ``) - .join("")}\n`; + .join( + "", + )}\n`; await withControlUiRoot({ indexHtml: html, fn: async (tmp) => { + await fs.mkdir(path.join(tmp, "assets")); + for (const [asset, content] of emittedAssets) { + await fs.writeFile(path.join(tmp, "assets", asset), content); + } const { res, end } = makeMockHttpResponse(); const handled = await handleControlUiHttpRequest( { @@ -1604,10 +1627,36 @@ describe("handleControlUiHttpRequest", () => { expect(handled).toBe(true); const body = String(end.mock.calls[0]?.[0] ?? ""); - for (const asset of assets) { + for (const asset of publicAssets) { expect(body).toContain(`href="${expectedPrefix}/${asset}"`); expect(body).not.toContain(`href="./${asset}"`); } + expect(body).toContain(`src="${expectedPrefix}/assets/index.js"`); + expect(body).toContain(`href="${expectedPrefix}/assets/runtime.js"`); + expect(body).toContain(`href="${expectedPrefix}/assets/index.css"`); + expect(body).not.toContain('="./assets/'); + expect(body).not.toContain(`${requestPath}/assets/`); + + const emittedAssetUrls = Array.from( + body.matchAll(/(?:src|href)="([^" ]*\/assets\/[^" ]+)"/g), + ).flatMap((match) => (match[1] ? [match[1]] : [])); + expect(new Set(emittedAssetUrls)).toEqual( + new Set(emittedAssets.map(([asset]) => `${expectedPrefix}/assets/${asset}`)), + ); + for (const url of emittedAssetUrls) { + const emittedAsset = emittedAssets.find(([asset]) => url.endsWith(`/${asset}`)); + expect(emittedAsset).toBeDefined(); + const [, content, contentType] = emittedAsset!; + const response = await runControlUiRequest({ + url, + method: "GET", + rootPath: tmp, + basePath, + }); + expect(response.handled).toBe(true); + expect(responseBody(response.end)).toBe(content); + expect(response.setHeader).toHaveBeenCalledWith("Content-Type", contentType); + } }, }); }, @@ -3505,28 +3554,38 @@ describe("handleControlUiHttpRequest", () => { it.each([ { - name: "root-mounted", + name: "root-mounted approval", basePath: undefined, url: "/approve/Approval%3AMobile%2F%E6%9D%B1%E4%BA%AC%20100%25%20%F0%9F%A6%9E", }, { - name: "configured-base-path", + name: "configured-base-path approval", basePath: "/openclaw", url: "/openclaw/approve/Approval%3AMobile%2F%E6%9D%B1%E4%BA%AC%20100%25%20%F0%9F%A6%9E", }, { - name: "asset-like-id", + name: "asset-like approval id", basePath: undefined, url: "/approve/plugin%3Arequest.json", }, { - name: "configured-base-asset-like-id", + name: "configured-base asset-like approval id", basePath: "/openclaw", url: "/openclaw/approve/plugin%3Arequest.js", }, - ])("serves $name approval deep links through the SPA fallback", async ({ basePath, url }) => { + { + name: "root-mounted focus path", + basePath: undefined, + url: "/focus/dashboard/roboclaw/session.json", + }, + { + name: "configured-base focus path", + basePath: "/openclaw", + url: "/openclaw/focus/desktop/control/session/agent%3Amain%3Amain", + }, + ])("serves $name through the standalone document", async ({ basePath, url }) => { await withControlUiRoot({ - indexHtml: "approval-spa\n", + indexHtml: "standalone-spa\n", fn: async (tmp) => { for (const method of ["GET", "HEAD"] as const) { const { res, end, handled } = await runControlUiRequest({ @@ -3541,7 +3600,7 @@ describe("handleControlUiHttpRequest", () => { if (method === "HEAD") { expect(firstEndCallLength(end)).toBe(0); } else { - expect(responseBody(end)).toContain("approval-spa"); + expect(responseBody(end)).toContain("standalone-spa"); if (basePath) { expect(responseBody(end)).toContain('data-openclaw-control-ui-base-path="/openclaw"'); } @@ -3553,21 +3612,31 @@ describe("handleControlUiHttpRequest", () => { it.each([ { - name: "root-mounted", + name: "root-mounted approval", basePath: undefined, url: "/approve/Approval%3AMobile%2F%E6%9D%B1%E4%BA%AC%20100%25%20%F0%9F%A6%9E", }, { - name: "configured-base-path", + name: "configured-base-path approval", basePath: "/openclaw", url: "/openclaw/approve/Approval%3AMobile%2F%E6%9D%B1%E4%BA%AC%20100%25%20%F0%9F%A6%9E", }, { - name: "asset-like-id", + name: "asset-like approval id", basePath: undefined, url: "/approve/plugin%3Arequest.json", }, - ])("declines POST to $name approval deep links at the UI module", async ({ basePath, url }) => { + { + name: "root-mounted focus path", + basePath: undefined, + url: "/focus/terminal", + }, + { + name: "configured-base focus path", + basePath: "/openclaw", + url: "/openclaw/focus/desktop", + }, + ])("declines POST to $name at the UI module", async ({ basePath, url }) => { await withControlUiRoot({ fn: async (tmp) => { const { handled, end } = await runControlUiRequest({ @@ -3577,9 +3646,8 @@ describe("handleControlUiHttpRequest", () => { basePath, }); - // The UI module only serves reads; the gateway's approval-document - // stage (server-http.ts) owns the terminal 404 for write methods, so - // these requests never reach plugin HTTP handlers in production. + // The UI module serves reads only. The gateway router decides whether a + // write is reserved approval traffic or an unclaimed focus fallback. expect(handled).toBe(false); expect(end).not.toHaveBeenCalled(); }, diff --git a/src/gateway/control-ui.ts b/src/gateway/control-ui.ts index 9ad7ddc2091b..b63d8d9ac300 100644 --- a/src/gateway/control-ui.ts +++ b/src/gateway/control-ui.ts @@ -57,7 +57,11 @@ import { respondNotFound as respondControlUiNotFound, respondPlainText, } from "./control-ui-http-utils.js"; -import { classifyControlUiRequest, isControlUiApprovalDocumentPath } from "./control-ui-routing.js"; +import { + classifyControlUiRequest, + isControlUiApprovalDocumentPath, + isControlUiFocusDocumentPath, +} from "./control-ui-routing.js"; import { buildControlUiAvatarUrl, CONTROL_UI_AVATAR_PREFIX, @@ -122,10 +126,12 @@ const CONTROL_UI_ROOT_PUBLIC_ASSETS = new Set([ "sw.js", ]); -/** Anchors bundled public assets before deep-linked documents begin preloading. */ -function rewriteControlUiIndexHtmlPublicAssetHrefs(html: string, basePath: string): string { +/** Anchors bundled assets before deep-linked documents begin preloading. */ +function rewriteControlUiIndexHtmlAssetHrefs(html: string, basePath: string): string { const normalized = normalizeControlUiBasePath(basePath); - let next = html; + let next = html + .replaceAll('src="./assets/', `src="${normalized}/assets/`) + .replaceAll('href="./assets/', `href="${normalized}/assets/`); for (const asset of CONTROL_UI_ROOT_PUBLIC_ASSETS) { const assetHref = `href="${normalized}/${asset}"`; // Vite's portable ./ base emits relative hrefs, which the browser starts @@ -666,7 +672,7 @@ async function serveResolvedIndexHtml( allowWasm?: boolean, ) { const normalizedBasePath = normalizeControlUiBasePath(basePath); - const withBasePath = rewriteControlUiIndexHtmlPublicAssetHrefs(body, normalizedBasePath); + const withBasePath = rewriteControlUiIndexHtmlAssetHrefs(body, normalizedBasePath); const basePathAttribute = normalizedBasePath ? ` ${CONTROL_UI_BASE_PATH_ATTRIBUTE}="${escapeHtmlAttribute(normalizedBasePath)}"` : ""; @@ -956,7 +962,9 @@ export async function handleControlUiHttpRequest( const uiPath = basePath && pathname.startsWith(`${basePath}/`) ? pathname.slice(basePath.length) : pathname; - const approvalDocument = isControlUiApprovalDocumentPath({ basePath, pathname }); + const standaloneDocument = + isControlUiApprovalDocumentPath({ basePath, pathname }) || + isControlUiFocusDocumentPath({ basePath, pathname }); const rel = (() => { if (uiPath === ROOT_PREFIX) { return ""; @@ -973,7 +981,7 @@ export async function handleControlUiHttpRequest( } return uiPath.slice(1); })(); - const requested = approvalDocument + const requested = standaloneDocument ? "index.html" : rel && !rel.endsWith("/") ? rel diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index f4d9891e96b7..75f15082012c 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -31,6 +31,7 @@ import { import { respondNotFound, respondPlainText } from "./control-ui-http-utils.js"; import { isControlUiApprovalDocumentPath, + isControlUiFocusDocumentPath, isControlUiPluginManagerRequest, } from "./control-ui-routing.js"; import type { ControlUiRootState } from "./control-ui.js"; @@ -325,6 +326,17 @@ export function createGatewayHttpServer(opts: { agentId: resolveAssistantIdentity({ cfg: configSnapshot }).agentId, root: controlUiRoot, }); + const handleStandaloneControlUiRequest = async () => { + if (!controlUiEnabled) { + respondNotFound(res); + return true; + } + if (await handleControlUiRequest()) { + return true; + } + respondNotFound(res); + return true; + }; const requestStages: GatewayHttpRequestStage[] = [ { run: () => @@ -460,24 +472,15 @@ export function createGatewayHttpServer(opts: { config: openAiChatCompletionsConfig, }), ); - addRequestStage( - isControlUiApprovalDocumentPath({ - basePath: controlUiBasePath, - pathname: scopedRequestPath, - }), - async () => { - if (!controlUiEnabled) { - respondNotFound(res); - return true; - } - const handled = await handleControlUiRequest(); - if (handled) { - return true; - } - respondNotFound(res); - return true; - }, - ); + const approvalDocument = isControlUiApprovalDocumentPath({ + basePath: controlUiBasePath, + pathname: scopedRequestPath, + }); + const focusDocument = isControlUiFocusDocumentPath({ + basePath: controlUiBasePath, + pathname: scopedRequestPath, + }); + addRequestStage(approvalDocument, handleStandaloneControlUiRequest); addRequestStage(Boolean(nodeCapability), async () => { const { authorizePluginNodeCapabilityRequest } = await getPluginNodeCapabilityAuthModule(); const ok = await authorizePluginNodeCapabilityRequest({ @@ -581,6 +584,8 @@ export function createGatewayHttpServer(opts: { ); } + addRequestStage(focusDocument, handleStandaloneControlUiRequest); + addRequestStage( scopedRequestPath.startsWith("/api/chat/media/outgoing/") || (controlUiRouteBasePath.length > 0 && diff --git a/src/gateway/server.plugin-http-auth.test.ts b/src/gateway/server.plugin-http-auth.test.ts index 7a2f7f7159b4..4a9c2c23cb70 100644 --- a/src/gateway/server.plugin-http-auth.test.ts +++ b/src/gateway/server.plugin-http-auth.test.ts @@ -163,6 +163,42 @@ function createRuntimeScopeRecorderHandler(params: { }); } +function createPublicPluginRouteHandler(params: { + path: string; + match: "exact" | "prefix"; + method: string; + responseBody: string; +}) { + const routeHandler = vi.fn(async (req: IncomingMessage, res: ServerResponse) => { + if (req.method !== params.method) { + return false; + } + res.statusCode = 200; + res.end(params.responseBody); + return true; + }); + return { + routeHandler, + handlePluginRequest: createGatewayPluginRequestHandler({ + registry: createGatewayTestRegistry({ + httpRoutes: [ + { + pluginId: "focus-owner", + source: "focus-owner", + path: params.path, + auth: "plugin", + match: params.match, + handler: routeHandler, + }, + ], + }), + log: { warn: vi.fn() } as unknown as Parameters< + typeof createGatewayPluginRequestHandler + >[0]["log"], + }), + }; +} + async function expectPluginRequestOk( server: Parameters[0], request: Parameters[0], @@ -754,6 +790,153 @@ describe("gateway plugin HTTP auth boundary", () => { }); }); + test.each([ + { + label: "root-mounted exact GET", + basePath: "", + routePath: "/focus/terminal", + match: "exact" as const, + requestPath: "/focus/terminal", + method: "GET", + }, + { + label: "root-mounted prefix POST", + basePath: "", + routePath: "/focus", + match: "prefix" as const, + requestPath: "/focus/desktop/control", + method: "POST", + }, + { + label: "base-path-mounted prefix PUT", + basePath: "/openclaw", + routePath: "/openclaw/focus", + match: "prefix" as const, + requestPath: "/openclaw/focus/dashboard/roboclaw/session-ref", + method: "PUT", + }, + ])( + "lets a registered $label route own focus requests", + async ({ basePath, routePath, match, requestPath, method }) => { + const { handlePluginRequest, routeHandler } = createPublicPluginRouteHandler({ + path: routePath, + match, + method, + responseBody: "plugin-owned-focus", + }); + + await withGatewayServer({ + prefix: "openclaw-plugin-http-focus-ownership-test-", + resolvedAuth: AUTH_NONE, + overrides: { + controlUiEnabled: true, + controlUiBasePath: basePath, + controlUiRoot: { kind: "missing" }, + handlePluginRequest, + }, + run: async (server) => { + const response = await sendRequest(server, { path: requestPath, method }); + expect(response.res.statusCode).toBe(200); + expect(response.getBody()).toBe("plugin-owned-focus"); + expect(routeHandler).toHaveBeenCalledOnce(); + }, + }); + }, + ); + + test.each([ + { + label: "root-mounted", + basePath: "", + rootPath: "/focus", + descendantPath: "/focus/desktop/control", + lookalikePath: "/focused", + }, + { + label: "base-path-mounted", + basePath: "/openclaw", + rootPath: "/openclaw/focus", + descendantPath: "/openclaw/focus/dashboard/roboclaw/session-ref", + lookalikePath: "/openclaw/focused", + }, + ])( + "uses focus as the $label unclaimed fallback without reserving lookalikes", + async ({ basePath, rootPath, descendantPath, lookalikePath }) => { + const { handlePluginRequest, routeHandler } = createPublicPluginRouteHandler({ + path: lookalikePath, + match: "exact", + method: "GET", + responseBody: "plugin-lookalike", + }); + + await withGatewayServer({ + prefix: "openclaw-plugin-http-focus-fallback-test-", + resolvedAuth: AUTH_NONE, + overrides: { + controlUiEnabled: true, + controlUiBasePath: basePath, + controlUiRoot: { kind: "missing" }, + handlePluginRequest, + }, + run: async (server) => { + const get = await sendRequest(server, { path: rootPath }); + expect(get.res.statusCode).toBe(503); + expect(get.getBody()).toContain("Control UI assets not found"); + + const head = await sendRequest(server, { path: descendantPath, method: "HEAD" }); + expect(head.res.statusCode).toBe(503); + + for (const method of ["POST", "PUT"] as const) { + const write = await sendRequest(server, { path: descendantPath, method }); + expect(write.res.statusCode, method).toBe(404); + expect(write.getBody(), method).toBe("Not Found"); + } + + const lookalike = await sendRequest(server, { path: lookalikePath }); + expect(lookalike.res.statusCode).toBe(200); + expect(lookalike.getBody()).toBe("plugin-lookalike"); + expect(routeHandler).toHaveBeenCalledOnce(); + }, + }); + }, + ); + + test.each([ + { label: "root-mounted", basePath: "", path: "/focus/terminal" }, + { + label: "base-path-mounted", + basePath: "/openclaw", + path: "/openclaw/focus/desktop", + }, + ])( + "returns 404 for an unclaimed $label focus request when control ui serving is disabled", + async ({ basePath, path }) => { + const { handlePluginRequest, routeHandler } = createPublicPluginRouteHandler({ + path: `${basePath}/unrelated`, + match: "exact", + method: "GET", + responseBody: "unrelated", + }); + + await withPluginGatewayServer({ + prefix: "openclaw-plugin-http-disabled-focus-fallback-test-", + resolvedAuth: AUTH_NONE, + overrides: { + controlUiEnabled: false, + controlUiBasePath: basePath, + handlePluginRequest, + }, + run: async (server) => { + const response = await sendRequest(server, { path }); + + expect(response.res.statusCode).toBe(404); + expect(response.getBody()).toBe("Not Found"); + expect(routeHandler).not.toHaveBeenCalled(); + }, + }); + }, + ); + test("passes POST webhook routes through root-mounted control ui to plugins", async () => { const handlePluginRequest = vi.fn(async (req: IncomingMessage, res: ServerResponse) => { const pathname = new URL(req.url ?? "/", "http://localhost").pathname; diff --git a/test/vitest/vitest.extension-codex-app-server-attempt-extra.config.ts b/test/vitest/vitest.extension-codex-app-server-attempt-extra.config.ts index 8faa5484a578..1551278641c6 100644 --- a/test/vitest/vitest.extension-codex-app-server-attempt-extra.config.ts +++ b/test/vitest/vitest.extension-codex-app-server-attempt-extra.config.ts @@ -1,7 +1,7 @@ // Vitest extension codex app server attempt extra config wires the extension codex app server attempt extra test shard. import { createScopedVitestConfig } from "./vitest.scoped-config.ts"; -function createExtensionCodexAppServerAttemptExtraVitestConfig( +export function createExtensionCodexAppServerAttemptExtraVitestConfig( env: Record = process.env, ) { return createScopedVitestConfig( diff --git a/test/vitest/vitest.extension-codex-app-server-attempt-light.config.ts b/test/vitest/vitest.extension-codex-app-server-attempt-light.config.ts index c26bf1769cef..82d2ef8d8828 100644 --- a/test/vitest/vitest.extension-codex-app-server-attempt-light.config.ts +++ b/test/vitest/vitest.extension-codex-app-server-attempt-light.config.ts @@ -1,7 +1,7 @@ // Vitest extension codex app server attempt light config wires the extension codex app server attempt light test shard. import { createScopedVitestConfig } from "./vitest.scoped-config.ts"; -function createExtensionCodexAppServerAttemptLightVitestConfig( +export function createExtensionCodexAppServerAttemptLightVitestConfig( env: Record = process.env, ) { return createScopedVitestConfig( diff --git a/test/vitest/vitest.extension-codex-app-server-tools.config.ts b/test/vitest/vitest.extension-codex-app-server-tools.config.ts index fda4a152d8d2..b6f1f706beef 100644 --- a/test/vitest/vitest.extension-codex-app-server-tools.config.ts +++ b/test/vitest/vitest.extension-codex-app-server-tools.config.ts @@ -1,7 +1,7 @@ // Vitest extension codex app server tools config wires the extension codex app server tools test shard. import { createScopedVitestConfig } from "./vitest.scoped-config.ts"; -function createExtensionCodexAppServerToolsVitestConfig( +export function createExtensionCodexAppServerToolsVitestConfig( env: Record = process.env, ) { return createScopedVitestConfig( diff --git a/ui/src/app-navigation.test.ts b/ui/src/app-navigation.test.ts index 8d3c390c3bd3..e50f80d85685 100644 --- a/ui/src/app-navigation.test.ts +++ b/ui/src/app-navigation.test.ts @@ -494,6 +494,9 @@ describe("inferBasePathFromPathname", () => { // Real mount directories that merely contain a route-suffix keep working. expect(inferBasePathFromPathname("/ui/config")).toBe("/ui"); expect(inferBasePathFromPathname("/ui/settings/appearance")).toBe("/ui"); + expect(inferBasePathFromPathname("/focus/terminal")).toBe(""); + expect(inferBasePathFromPathname("/openclaw/focus/dashboard/main")).toBe("/openclaw"); + expect(inferBasePathFromPathname("/company/focus/focus/terminal")).toBe("/company/focus"); }); }); diff --git a/ui/src/app-route-paths.ts b/ui/src/app-route-paths.ts index 426fec26c105..ec90a8be9527 100644 --- a/ui/src/app-route-paths.ts +++ b/ui/src/app-route-paths.ts @@ -1,3 +1,4 @@ +import { inferControlUiFocusBasePath } from "@openclaw/session-url-contract"; import { normalizeRouteBasePath, normalizeRoutePath } from "@openclaw/uirouter"; import type { RouteLocation } from "@openclaw/uirouter"; import { isValidWorkboardBoardId } from "@openclaw/workboard-contract"; @@ -310,6 +311,10 @@ function isRouteOwnedBasePath(basePath: string): boolean { } export function inferBasePathFromPathname(pathname: string): string { + const focusBasePath = inferControlUiFocusBasePath(pathname); + if (focusBasePath !== null) { + return focusBasePath; + } const isMountRoot = pathname.trim().endsWith("/"); const normalizedPath = normalizePath(pathname); if (normalizedPath.toLowerCase().endsWith("/index.html")) { diff --git a/ui/src/app/app-root.ts b/ui/src/app/app-root.ts index 0415773207ff..c2f76b76d132 100644 --- a/ui/src/app/app-root.ts +++ b/ui/src/app/app-root.ts @@ -1,4 +1,6 @@ import { ContextProvider } from "@lit/context"; +import { buildControlUiFocusPath, type ControlUiFocusTarget } from "@openclaw/session-url-contract"; +import type { RouteLocation, RouteNotFound } from "@openclaw/uirouter"; import { html, nothing } from "lit"; import { state } from "lit/decorators.js"; import type { GatewayBrowserClient } from "../api/gateway.ts"; @@ -9,16 +11,15 @@ import "../components/login-gate.ts"; import "../components/openclaw-mascot.ts"; import { installNativeTitleGuard } from "../components/tooltip.ts"; import { t } from "../i18n/index.ts"; +import { formatUiError } from "../lib/format-error.ts"; import { normalizeAgentId } from "../lib/sessions/session-key.ts"; import { isTerminalAvailable } from "../lib/terminal-availability.ts"; import { OpenClawLightDomElement } from "../lit/openclaw-element.ts"; import { SubscriptionsController } from "../lit/subscriptions-controller.ts"; +import type { ChatRouteData } from "../pages/chat/route-loader.ts"; import { isDesktopPanelAvailable } from "./app-shell-chrome.ts"; import { bootstrapApplication, type ApplicationRuntime } from "./bootstrap.ts"; -import { resolveControlUiBasePath } from "./browser.ts"; import { applicationContext, type ApplicationContext } from "./context.ts"; -import { dashboardDocumentSession, isDashboardOnlyView } from "./dashboard-document-mode.ts"; -import { desktopDocumentOptions, isDesktopOnlyView } from "./desktop-document-mode.ts"; import { APPROVAL_PAGE_ELEMENT, DASHBOARD_DOCUMENT_ELEMENT, @@ -29,7 +30,21 @@ import { } from "./lazy-custom-element.ts"; import { resolveOnboardingMode } from "./onboarding-mode.ts"; import { controlUiPublicAssetPath } from "./public-assets.ts"; -import { isTerminalOnlyView } from "./terminal-document-mode.ts"; + +type FocusDashboardRouteState = + | { kind: "loading" } + | { kind: "not-found" } + | { kind: "error"; message: string } + | { kind: "ambiguous"; data: Extract } + | { kind: "session"; data: Extract }; + +function routeLocationHref(location: RouteLocation): string { + return `${location.pathname}${location.search}${location.hash}`; +} + +function isRouteNotFound(result: ChatRouteData | RouteNotFound): result is RouteNotFound { + return "type" in result && result.type === "notFound"; +} export function resolveTerminalThemeMode(): "dark" | "light" { return document.documentElement.dataset.themeMode === "light" ? "light" : "dark"; @@ -79,18 +94,8 @@ export class OpenClawApp extends OpenClawLightDomElement { @state() private loginShowGatewayPassword = false; @state() private pendingGatewayUrl: string | null = null; @state() private onboarding = resolveOnboardingMode(globalThis.location?.search ?? ""); + @state() private focusDashboardRoute: FocusDashboardRouteState = { kind: "loading" }; - private readonly terminalOnly = isTerminalOnlyView( - globalThis.location, - resolveControlUiBasePath(globalThis.location?.pathname ?? "/"), - ); - private readonly desktopOnly = isDesktopOnlyView( - globalThis.location, - resolveControlUiBasePath(globalThis.location?.pathname ?? "/"), - ); - private readonly dashboardOnly = isDashboardOnlyView(globalThis.location); - private readonly dashboardSession = dashboardDocumentSession(globalThis.location); - private readonly desktopOptions = desktopDocumentOptions(globalThis.location); private runtime: ApplicationRuntime | undefined; private readonly contextProvider = new ContextProvider(this, { context: applicationContext, @@ -98,11 +103,21 @@ export class OpenClawApp extends OpenClawLightDomElement { private readonly subscriptions = new SubscriptionsController(this); private loginGatewaySource: ApplicationContext["gateway"] | null = null; private loginConnectionClient: GatewayBrowserClient | null = null; + private focusDashboardAbort: AbortController | null = null; private get context(): ApplicationContext | undefined { return this.runtime?.context; } + private get focusTarget(): ControlUiFocusTarget | null { + const focus = this.runtime?.focusLocation; + return focus?.status === "valid" ? focus.target : null; + } + + private get terminalOnly(): boolean { + return this.focusTarget?.kind === "terminal"; + } + constructor() { super(); this.subscriptions @@ -128,13 +143,14 @@ export class OpenClawApp extends OpenClawLightDomElement { void import("../components/session-progress-hovercard-registration.ts"); this.resetLoginSensitivePresentation(); this.runtime = bootstrapApplication(); - if (this.terminalOnly) { + const focusTarget = this.focusTarget; + if (focusTarget?.kind === "terminal") { preloadOptionalElement(this, TERMINAL_PANEL_ELEMENT); } - if (this.desktopOnly) { + if (focusTarget?.kind === "desktop") { preloadOptionalElement(this, DESKTOP_PANEL_ELEMENT); } - if (this.dashboardOnly) { + if (focusTarget?.kind === "dashboard") { preloadOptionalElement(this, DASHBOARD_DOCUMENT_ELEMENT); } if (this.runtime.documentMode?.kind === "approval") { @@ -149,14 +165,19 @@ export class OpenClawApp extends OpenClawLightDomElement { // The runtime is created after controller hostConnected hooks run. Ensure // their lazy source getters bind on both the initial mount and reconnect. this.requestUpdate(); - void this.runtime.start().catch((error: unknown) => { - console.error("[openclaw] application start failed", error); - }); + void this.runtime + .start() + .then(() => this.resolveFocusDashboard()) + .catch((error: unknown) => { + console.error("[openclaw] application start failed", error); + }); } override disconnectedCallback() { // Stop reactive subscriptions before disposing their application sources. this.subscriptions.clear(); + this.focusDashboardAbort?.abort(); + this.focusDashboardAbort = null; this.runtime?.stop(); this.runtime = undefined; this.loginGatewaySource = null; @@ -216,6 +237,159 @@ export class OpenClawApp extends OpenClawLightDomElement { } } + private renderFocusEscape(label: string) { + return html``; + } + + private replaceFocusDashboardLocation(location: RouteLocation, source: RouteLocation): void { + const basePath = this.context?.basePath ?? ""; + const expected = buildControlUiFocusPath( + { kind: "dashboard", path: routeLocationHref(source) }, + basePath, + ); + const replacement = buildControlUiFocusPath( + { kind: "dashboard", path: routeLocationHref(location) }, + basePath, + ); + const current = `${globalThis.location.pathname}${globalThis.location.search}${globalThis.location.hash}`; + if (!expected || !replacement || current !== expected || replacement === current) { + return; + } + globalThis.history.replaceState(globalThis.history.state, "", replacement); + } + + private async resolveFocusDashboard(): Promise { + const target = this.focusTarget; + const context = this.context; + if (target?.kind !== "dashboard" || !context) { + return; + } + this.focusDashboardAbort?.abort(); + const controller = new AbortController(); + this.focusDashboardAbort = controller; + this.focusDashboardRoute = { kind: "loading" }; + const location = target.route; + try { + const { loadChatRoute } = await import("../pages/chat/route-loader.ts"); + const result = await loadChatRoute(context, location, "dashboard", controller.signal); + if (controller.signal.aborted || this.focusDashboardAbort !== controller) { + return; + } + if (isRouteNotFound(result)) { + this.focusDashboardRoute = { kind: "not-found" }; + return; + } + if (result.kind === "ambiguous") { + this.focusDashboardRoute = { + kind: "ambiguous", + data: { + ...result, + candidates: result.candidates.map((candidate) => ({ + ...candidate, + href: + buildControlUiFocusPath( + { kind: "dashboard", path: candidate.href }, + context.basePath, + ) ?? candidate.href, + })), + }, + }; + return; + } + this.focusDashboardRoute = { kind: "session", data: result }; + if (result.canonicalLocation && result.canonicalLocationSource) { + this.replaceFocusDashboardLocation( + result.canonicalLocation, + result.canonicalLocationSource, + ); + } + const canonicalLocationSource = result.canonicalLocationSource; + if (result.canonicalLocationReady && canonicalLocationSource) { + void result.canonicalLocationReady.then((canonicalLocation) => { + if ( + canonicalLocation && + !controller.signal.aborted && + this.focusDashboardAbort === controller + ) { + this.replaceFocusDashboardLocation(canonicalLocation, canonicalLocationSource); + } + }); + } + } catch (error) { + if (!controller.signal.aborted && this.focusDashboardAbort === controller) { + this.focusDashboardRoute = { kind: "error", message: formatUiError(error) }; + } + } + } + + private renderFocusDashboard( + gatewaySnapshot: ApplicationContext["gateway"]["snapshot"], + gatewayConnected: boolean, + gatewayStartupStatus: string | undefined, + ) { + const route = this.focusDashboardRoute; + if (route.kind === "loading") { + return renderConnectingSplash(gatewayStartupStatus); + } + if (route.kind === "not-found") { + return html`
+
+ ${t("dashboardDocument.notFound")} + ${this.renderFocusEscape(t("dashboardDocument.close"))} +
+
`; + } + if (route.kind === "error") { + return html`
+ +
`; + } + if (route.kind === "ambiguous") { + return html`
+
+

${t("chat.sessionRoute.chooseTitle")}

+

+ ${route.data.candidates.length > 1 + ? t("chat.sessionRoute.multipleMatches", { shortId: route.data.shortId }) + : t("chat.sessionRoute.additionalMatches")} +

+ ${route.data.candidates.map( + (candidate) => html`

+ ${candidate.displayName}
+ ${candidate.agentId} · ${candidate.idPrefix} +

`, + )} + ${route.data.truncated + ? html`

${t("chat.sessionRoute.additionalMatches")}

` + : nothing} + ${this.renderFocusEscape(t("dashboardDocument.close"))} +
+
`; + } + return html` + this.closeDocument(this.context?.basePath ?? "")} + > + ${!gatewayConnected && gatewaySnapshot.lastError === null + ? renderConnectingSplash(gatewayStartupStatus) + : nothing} + ${!isOptionalElementDefined(DASHBOARD_DOCUMENT_ELEMENT) && gatewayConnected + ? renderConnectingSplash(gatewayStartupStatus) + : nothing} + `; + } + override render() { const context = this.context; const runtime = this.runtime; @@ -243,9 +417,18 @@ export class OpenClawApp extends OpenClawLightDomElement { > ` : nothing; - // Full-screen terminals own the whole document. Keep the generic login gate + if (runtime.focusLocation?.status === "unsupported") { + return html`
+
+ ${t("focus.unsupported")} + ${this.renderFocusEscape(t("common.back"))} +
+
`; + } + const focusTarget = this.focusTarget; + // Focused terminals own the whole document. Keep the generic login gate // out of this path or a connecting native session exposes Web UI chrome. - if (this.terminalOnly) { + if (focusTarget?.kind === "terminal") { const terminalAvailable = isTerminalAvailable( gatewaySnapshot, context.config.current.terminalEnabled ?? false, @@ -269,23 +452,30 @@ export class OpenClawApp extends OpenClawLightDomElement { ? renderConnectingSplash(gatewayStartupStatus) : nothing} ${!terminalAvailable && (gatewayConnected || gatewaySnapshot.lastError) - ? html`
${t("terminal.unavailable")}
` + ? html`
+
+ ${t("terminal.unavailable")} + ${this.renderFocusEscape(t("common.back"))} +
+
` : nothing} `; } // Desktop documents share the panel's connection owner but none of its // dock or shell chrome. Native clients can therefore load this route as a // standalone, mobile-shaped surface without changing the observe contract. - if (this.desktopOnly) { + if (focusTarget?.kind === "desktop") { const desktopAvailable = isDesktopPanelAvailable(gatewaySnapshot); + const source = focusTarget.selector?.kind === "source" ? focusTarget.selector.value : null; + const session = focusTarget.selector?.kind === "session" ? focusTarget.selector.value : null; return html` this.closeDocument(context.basePath)} > ${!gatewayConnected && gatewaySnapshot.lastError === null @@ -295,26 +485,17 @@ export class OpenClawApp extends OpenClawLightDomElement { ? renderConnectingSplash(gatewayStartupStatus) : nothing} ${!desktopAvailable && (gatewayConnected || gatewaySnapshot.lastError) - ? html`
${t("desktop.unavailable")}
` + ? html`
+
+ ${t("desktop.unavailable")} + ${this.renderFocusEscape(t("common.back"))} +
+
` : nothing} `; } - // Dashboard documents reuse the live board provider and widget bridge while - // keeping the application shell, transcript, and navigation chrome unmounted. - if (this.dashboardOnly) { - return html` - this.closeDocument(context.basePath)} - > - ${!gatewayConnected && gatewaySnapshot.lastError === null - ? renderConnectingSplash(gatewayStartupStatus) - : nothing} - ${!isOptionalElementDefined(DASHBOARD_DOCUMENT_ELEMENT) && gatewayConnected - ? renderConnectingSplash(gatewayStartupStatus) - : nothing} - `; + if (focusTarget?.kind === "dashboard") { + return this.renderFocusDashboard(gatewaySnapshot, gatewayConnected, gatewayStartupStatus); } // In the normal Control UI document, the Gateway lifecycle owns unresolved // first-connect state across every auth mode. Failures publish lastError diff --git a/ui/src/app/app-shell-view.ts b/ui/src/app/app-shell-view.ts index 9d99fdda3c76..02baed9f7f36 100644 --- a/ui/src/app/app-shell-view.ts +++ b/ui/src/app/app-shell-view.ts @@ -682,6 +682,7 @@ export function renderApplicationShell(host: ShellViewHost) { .client=${gatewayConnected ? gatewaySnapshot.client : null} .available=${desktopPanelAvailable} .suppressed=${settingsTakeover} + .basePath=${context.basePath} > `} () { return { promise, resolve }; } +describe("normalizeLegacyTerminalViewLocation", () => { + it.each([ + { + location: { pathname: "/", search: "?view=terminal&keep=yes", hash: "#pane" }, + basePath: "", + expected: { pathname: "/focus/terminal", search: "?keep=yes", hash: "#pane" }, + }, + { + location: { + pathname: "/openclaw/", + search: "?keep=yes&view=terminal", + hash: "#pane", + }, + basePath: "/openclaw", + expected: { + pathname: "/openclaw/focus/terminal", + search: "?keep=yes", + hash: "#pane", + }, + }, + ])("normalizes the released terminal query at $basePath", ({ location, basePath, expected }) => { + expect(normalizeLegacyTerminalViewLocation(location, basePath)).toEqual(expected); + }); + + it.each([ + { pathname: "/", search: "?view=desktop", hash: "" }, + { pathname: "/", search: "?view=dashboard", hash: "" }, + { pathname: "/settings/appearance", search: "?view=terminal", hash: "" }, + ])("does not normalize an unsupported legacy location $pathname$search", (location) => { + expect(normalizeLegacyTerminalViewLocation(location, "")).toBe(location); + }); +}); + describe("normalizeInitialApplicationLocation", () => { it("routes an opaque persisted key without aborting bootstrap", () => { expect( @@ -521,17 +555,22 @@ describe("normalizeInitialApplicationLocation", () => { } }); - it("keeps the terminal document route outside the application router", async () => { + it("keeps the focused terminal route outside the application router", async () => { const previousSettings = loadSettings(); const previousUrl = window.location.href; - window.history.replaceState({}, "", "/terminal"); + window.history.replaceState({}, "", "/focus/terminal"); const runtime = bootstrapApplication({ sessionPathBuilderReady: Promise.resolve() }); const routerStart = vi.spyOn(runtime.router, "start"); try { await runtime.start(); - expect(window.location.pathname).toBe("/terminal"); + expect(window.location.pathname).toBe("/focus/terminal"); + expect(runtime.focusLocation).toEqual({ + status: "valid", + basePath: "", + target: { kind: "terminal" }, + }); expect(routerStart).not.toHaveBeenCalled(); } finally { runtime.stop(); @@ -540,6 +579,119 @@ describe("normalizeInitialApplicationLocation", () => { } }); + it.each([ + { + initialUrl: "/?view=terminal&keep=yes#pane", + expectedUrl: "/focus/terminal?keep=yes#pane", + basePath: "", + }, + { + initialUrl: "/openclaw/?view=terminal&keep=yes#pane", + expectedUrl: "/openclaw/focus/terminal?keep=yes#pane", + basePath: "/openclaw", + }, + ])( + "rewrites the released terminal query at the $basePath application boundary", + async ({ initialUrl, expectedUrl, basePath }) => { + const previousSettings = loadSettings(); + const previousUrl = window.location.href; + window.history.replaceState({}, "", initialUrl); + const replaceState = vi.spyOn(window.history, "replaceState"); + const runtime = bootstrapApplication({ sessionPathBuilderReady: Promise.resolve() }); + const routerStart = vi.spyOn(runtime.router, "start"); + + try { + expect(`${window.location.pathname}${window.location.search}${window.location.hash}`).toBe( + expectedUrl, + ); + expect(runtime.focusLocation).toEqual({ + status: "valid", + basePath, + target: { kind: "terminal" }, + }); + + await runtime.start(); + + expect(routerStart).not.toHaveBeenCalled(); + expect(replaceState).toHaveBeenCalledTimes(1); + } finally { + runtime.stop(); + replaceState.mockRestore(); + window.history.replaceState({}, "", previousUrl); + saveSettings(previousSettings); + } + }, + ); + + it.each(["desktop", "dashboard"])( + "does not recognize the removed %s query presentation", + (view) => { + const previousSettings = loadSettings(); + const previousUrl = window.location.href; + const initialUrl = `/?view=${view}&keep=yes#pane`; + window.history.replaceState({}, "", initialUrl); + const replaceState = vi.spyOn(window.history, "replaceState"); + const runtime = bootstrapApplication({ sessionPathBuilderReady: Promise.resolve() }); + + try { + expect(runtime.focusLocation).toBeNull(); + expect(`${window.location.pathname}${window.location.search}${window.location.hash}`).toBe( + initialUrl, + ); + expect(replaceState).not.toHaveBeenCalled(); + } finally { + runtime.stop(); + replaceState.mockRestore(); + window.history.replaceState({}, "", previousUrl); + saveSettings(previousSettings); + } + }, + ); + + it("strips startup credentials before rewriting the released terminal query", () => { + const previousSettings = loadSettings(); + const previousUrl = window.location.href; + window.history.replaceState({}, "", "/?view=terminal#token=startup-token&pane=1"); + const replaceState = vi.spyOn(window.history, "replaceState"); + const runtime = bootstrapApplication({ sessionPathBuilderReady: Promise.resolve() }); + + try { + expect(replaceState.mock.calls.map((call) => call[2])).toEqual([ + "/?view=terminal#pane=1", + "/focus/terminal#pane=1", + ]); + expect(runtime.focusLocation).toEqual({ + status: "valid", + basePath: "", + target: { kind: "terminal" }, + }); + } finally { + runtime.stop(); + replaceState.mockRestore(); + window.history.replaceState({}, "", previousUrl); + saveSettings(previousSettings); + } + }); + + it("does not recognize the terminal query outside the application root", () => { + const previousSettings = loadSettings(); + const previousUrl = window.location.href; + const initialUrl = "/settings/appearance?view=terminal&keep=yes#pane"; + window.history.replaceState({}, "", initialUrl); + const runtime = bootstrapApplication({ sessionPathBuilderReady: Promise.resolve() }); + + try { + expect(runtime.focusLocation).toBeNull(); + expect(`${window.location.pathname}${window.location.search}${window.location.hash}`).toBe( + initialUrl, + ); + } finally { + runtime.stop(); + window.history.replaceState({}, "", previousUrl); + saveSettings(previousSettings); + } + }); + it("keeps the latest navigation requested before router start", async () => { const previousSettings = loadSettings(); const previousUrl = window.location.href; diff --git a/ui/src/app/bootstrap.ts b/ui/src/app/bootstrap.ts index 82e8e24aed0c..8652e787ae60 100644 --- a/ui/src/app/bootstrap.ts +++ b/ui/src/app/bootstrap.ts @@ -1,3 +1,7 @@ +import { + parseControlUiFocusLocation, + type ControlUiFocusLocation, +} from "@openclaw/session-url-contract"; import type { RouteLocation } from "@openclaw/uirouter"; import type { GatewayBrowserClient } from "../api/gateway.ts"; import { sessionRouteNamespaceFromPath } from "../app-route-paths.ts"; @@ -39,8 +43,6 @@ import type { ApplicationThemeServerSelection, } from "./context.ts"; import { syncCustomThemeStyleTag } from "./custom-theme.ts"; -import { isDashboardOnlyView } from "./dashboard-document-mode.ts"; -import { isDesktopDocumentPath, isDesktopOnlyView } from "./desktop-document-mode.ts"; import { createApplicationGateway } from "./gateway-store.ts"; import { createInitialUserMessageHandoff } from "./initial-user-message-handoff.ts"; import { createNativeChatDrafts } from "./native-bridge.ts"; @@ -58,8 +60,10 @@ import { } from "./settings.ts"; import { createSkillWorkshopRevisionAdmissions } from "./skill-workshop-revision-admissions.ts"; import { createStartupLifecycle, type StartupStep } from "./startup-lifecycle.ts"; -import { resolveApplicationStartupSettings } from "./startup-settings.ts"; -import { isTerminalDocumentPath, isTerminalOnlyView } from "./terminal-document-mode.ts"; +import { + normalizeLegacyTerminalViewLocation, + resolveApplicationStartupSettings, +} from "./startup-settings.ts"; import { startThemeTransition } from "./theme-transition.ts"; import { resolveTheme, type ThemeMode } from "./theme.ts"; import { createWebPushCapability } from "./web-push.ts"; @@ -222,6 +226,7 @@ export type ApplicationRuntime = { readonly context: ApplicationContext; readonly router: ApplicationRouter; readonly documentMode: ApprovalDocumentMode | null; + readonly focusLocation: ControlUiFocusLocation | null; readonly pendingGatewayConnection: { readonly gatewayUrl: string; readonly token: string; @@ -271,27 +276,26 @@ export function bootstrapApplication( saveSettings(startup.settings); } } - const basePath = resolveControlUiBasePath( - startup.location.pathname || globalThis.location?.pathname || "/", + const applicationLocation = normalizeLegacyTerminalViewLocation( + startup.location, + initialBasePath, ); - const dashboardDocument = isDashboardOnlyView(startup.location); - const standaloneDocument = - isTerminalDocumentPath(startup.location.pathname, basePath) || - isDesktopDocumentPath(startup.location.pathname, basePath) || - dashboardDocument; + if (applicationLocation !== startup.location) { + history.replace(applicationLocation); + } + const basePath = resolveControlUiBasePath( + applicationLocation.pathname || globalThis.location?.pathname || "/", + ); + const focusLocation = parseControlUiFocusLocation(applicationLocation, basePath); const firstRunDefaultLanding = - documentMode === null && isDefaultChatLanding(startup.location, basePath, routeIdFromPath); - // A `?view=` document mode still lands on the chat path, so it counts as the default landing - // for routing, but it is an explicit destination that renders its own surface. Redirecting it - // into model setup strands native app webviews on a blank page, so only gate the redirect. - const firstRunRedirectEnabled = - firstRunDefaultLanding && - !isTerminalOnlyView(startup.location, basePath) && - !isDesktopOnlyView(startup.location, basePath) && - !dashboardDocument; + documentMode === null && + focusLocation === null && + isDefaultChatLanding(applicationLocation, basePath, routeIdFromPath); + const firstRunRedirectEnabled = firstRunDefaultLanding; const sessionPathBuilderReady = dependencies.sessionPathBuilderReady ?? - (documentMode || dashboardDocument + (documentMode || + (focusLocation?.status === "valid" && focusLocation.target.kind !== "dashboard") ? Promise.resolve() : import("@openclaw/session-url-contract").then((contract) => { setSessionPathBuilder(contract.buildControlUiSessionPath); @@ -313,23 +317,23 @@ export function bootstrapApplication( ); const agents = createAgentCapability(gateway); const startupLifecycle = createStartupLifecycle(); - const startupRouteId = routeIdFromPath(startup.location.pathname, basePath); + const startupRouteId = routeIdFromPath(applicationLocation.pathname, basePath); const releasedSessionQuery = (startupRouteId === "chat" || startupRouteId === "dashboard") && - sessionRouteNamespaceFromPath(startup.location.pathname, basePath) === null && - new URLSearchParams(startup.location.search).has("session"); + sessionRouteNamespaceFromPath(applicationLocation.pathname, basePath) === null && + new URLSearchParams(applicationLocation.search).has("session"); const deferInitialLocationUntilGateway = documentMode === null && !releasedSessionQuery && firstRunDefaultLanding && !parseAgentSessionKey(settings.sessionKey); const initialLocationReady = ( - documentMode || dashboardDocument - ? Promise.resolve(startup.location) + documentMode || focusLocation + ? Promise.resolve(applicationLocation) : Promise.all([sessionPathBuilderReady, import("./bootstrap-location.ts")]).then( ([, location]) => location.resolveInitialApplicationLocation({ - location: startup.location, + location: applicationLocation, basePath, sessionKey: settings.sessionKey, gateway, @@ -341,7 +345,7 @@ export function bootstrapApplication( // stop() aborts an eager unscoped-session lookup even when start() returns // at the lazy-chunk guard, so consume that teardown-only rejection here. if (startupLifecycle.signal.aborted) { - return startup.location; + return applicationLocation; } throw error; }); @@ -391,9 +395,9 @@ export function bootstrapApplication( const chatAttachmentHandoff = createChatAttachmentHandoff(); applyThemePresentation(settings); const router = createApplicationRouter(); - // Standalone terminal, desktop, and dashboard documents render before the - // shell; starting the page router would rewrite them to an application route. - const startsApplicationRouter = documentMode === null && !standaloneDocument; + // Focus documents render before the shell; starting the application router + // would rewrite their reserved presentation route into an ordinary page. + const startsApplicationRouter = documentMode === null && focusLocation === null; let routerStarted = false; // Pre-start navigations are invisible to history; retain the latest request so // router.start() cannot resolve the stale browser URL over the user's route. @@ -532,6 +536,7 @@ export function bootstrapApplication( context, router, documentMode, + focusLocation, get pendingGatewayConnection() { return pendingGatewayConnection; }, diff --git a/ui/src/app/dashboard-document-mode.ts b/ui/src/app/dashboard-document-mode.ts deleted file mode 100644 index 5d1a0c42c514..000000000000 --- a/ui/src/app/dashboard-document-mode.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { normalizeRouteBasePath } from "@openclaw/uirouter"; - -type DashboardDocumentLocation = Pick; - -export function isDashboardOnlyView( - location: DashboardDocumentLocation | undefined = globalThis.location, -): boolean { - return new URLSearchParams(location?.search ?? "").get("view") === "dashboard"; -} - -export function dashboardDocumentSession( - location: DashboardDocumentLocation | undefined = globalThis.location, -): string | null { - return new URLSearchParams(location?.search ?? "").get("session"); -} - -export function dashboardDocumentHref(basePath: string, sessionRef: string): string { - const path = normalizeRouteBasePath(basePath) || "/"; - const search = new URLSearchParams({ view: "dashboard", session: sessionRef }); - return `${path}?${search}`; -} diff --git a/ui/src/app/desktop-document-mode.test.ts b/ui/src/app/desktop-document-mode.test.ts deleted file mode 100644 index b1114d5f87a4..000000000000 --- a/ui/src/app/desktop-document-mode.test.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { GatewaySessionRow } from "../api/types.ts"; -import { resolveDesktopDocumentTarget } from "../components/desktop/desktop-source.ts"; -import { - dashboardDocumentHref, - dashboardDocumentSession, - isDashboardOnlyView, -} from "./dashboard-document-mode.ts"; -import { desktopDocumentOptions } from "./desktop-document-mode.ts"; - -describe("dashboard document mode", () => { - it("parses the dashboard session reference", () => { - const location = { - search: "?view=dashboard&session=agent%3Amain%3Awork", - }; - - expect(isDashboardOnlyView(location)).toBe(true); - expect(dashboardDocumentSession(location)).toBe("agent:main:work"); - }); - - it("keeps a missing session visible to the document empty state", () => { - const location = { search: "?view=dashboard" }; - - expect(isDashboardOnlyView(location)).toBe(true); - expect(dashboardDocumentSession(location)).toBeNull(); - }); - - it("does not treat an ordinary route as a dashboard document", () => { - expect(isDashboardOnlyView({ search: "" })).toBe(false); - }); - - it("builds an encoded base-path-aware document URL", () => { - expect(dashboardDocumentHref("/openclaw/", "agent:main:work item")).toBe( - "/openclaw?view=dashboard&session=agent%3Amain%3Awork+item", - ); - }); -}); - -describe("desktop document mode", () => { - it("parses desktop source, session, and control options", () => { - expect( - desktopDocumentOptions({ - search: "?view=desktop&source=gateway&session=agent%3Amain%3Awork&control=1", - }), - ).toEqual({ - source: "gateway", - session: "agent:main:work", - control: true, - }); - }); - - it("prefers an explicit source over the session placement", () => { - const session = { - key: "agent:main:work", - kind: "direct", - updatedAt: 1, - execNode: "workstation", - } satisfies GatewaySessionRow; - - expect( - resolveDesktopDocumentTarget( - { source: "gateway", session: session.key, control: false }, - session, - ), - ).toBe("gateway"); - }); - - it.each([ - [ - "cloud placement", - { - key: "agent:main:cloud", - kind: "direct", - updatedAt: 1, - placement: { state: "active", environmentId: "worker:cloud-1" }, - } as GatewaySessionRow, - "worker:cloud-1", - ], - [ - "execution node", - { - key: "agent:main:node", - kind: "direct", - updatedAt: 1, - execNode: "workstation", - } satisfies GatewaySessionRow, - "node:workstation", - ], - [ - "gateway fallback", - { - key: "agent:main:gateway", - kind: "direct", - updatedAt: 1, - } satisfies GatewaySessionRow, - "gateway", - ], - ])("resolves a session's %s through the chat placement owner", (_label, session, expected) => { - expect( - resolveDesktopDocumentTarget({ source: null, session: session.key, control: false }, session), - ).toBe(expected); - }); - - it("returns no target for an unknown session", () => { - expect( - resolveDesktopDocumentTarget( - { source: null, session: "agent:main:missing", control: false }, - undefined, - ), - ).toBeNull(); - }); -}); diff --git a/ui/src/app/desktop-document-mode.ts b/ui/src/app/desktop-document-mode.ts deleted file mode 100644 index 9c816f3ebefb..000000000000 --- a/ui/src/app/desktop-document-mode.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { normalizeRouteBasePath, normalizeRoutePath } from "@openclaw/uirouter"; - -const DESKTOP_DOCUMENT_PATH = "/desktop"; - -type DesktopDocumentLocation = Pick; - -export type DesktopDocumentOptions = { - source: string | null; - session: string | null; - control: boolean; -}; - -function desktopDocumentPath(basePath = ""): string { - return `${normalizeRouteBasePath(basePath)}${DESKTOP_DOCUMENT_PATH}`; -} - -export function isDesktopDocumentPath(pathname: string, basePath: string): boolean { - return normalizeRoutePath(pathname) === desktopDocumentPath(basePath); -} - -export function isDesktopOnlyView( - location: DesktopDocumentLocation | undefined = globalThis.location, - basePath = "", -): boolean { - return ( - new URLSearchParams(location?.search ?? "").get("view") === "desktop" || - isDesktopDocumentPath(location?.pathname ?? "/", basePath) - ); -} - -export function desktopDocumentOptions( - location: Pick | undefined = globalThis.location, -): DesktopDocumentOptions { - const search = new URLSearchParams(location?.search ?? ""); - return { - source: search.get("source"), - session: search.get("session"), - control: search.get("control") === "1", - }; -} diff --git a/ui/src/app/startup-settings.ts b/ui/src/app/startup-settings.ts index 73e62c5e4966..55d4a6fa3b87 100644 --- a/ui/src/app/startup-settings.ts +++ b/ui/src/app/startup-settings.ts @@ -1,4 +1,5 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +import { buildControlUiFocusPath } from "@openclaw/session-url-contract"; // Control UI startup settings resolve native auth handoff and URL parameters. import { CONTROL_UI_BOOTSTRAP_PROFILE_FRAGMENT_PARAM, @@ -38,6 +39,27 @@ declare global { } } +export function normalizeLegacyTerminalViewLocation( + location: ApplicationStartupLocation, + basePath: string, +): ApplicationStartupLocation { + const applicationRoot = basePath ? `${basePath}/` : "/"; + if (location.pathname !== applicationRoot) { + return location; + } + const searchParams = new URLSearchParams(location.search); + if (searchParams.get("view") !== "terminal") { + return location; + } + searchParams.delete("view"); + const search = searchParams.toString(); + return { + pathname: buildControlUiFocusPath({ kind: "terminal" }, basePath), + search: search ? `?${search}` : "", + hash: location.hash, + }; +} + export function resolveApplicationStartupSettings( initialSettings: UiSettings, location: ApplicationStartupLocation, diff --git a/ui/src/app/terminal-document-mode.test.ts b/ui/src/app/terminal-document-mode.test.ts deleted file mode 100644 index 66a8f429c419..000000000000 --- a/ui/src/app/terminal-document-mode.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { isTerminalOnlyView, terminalDocumentPath } from "./terminal-document-mode.ts"; - -describe("terminal document mode", () => { - it.each([ - ["the root route", { pathname: "/terminal", search: "" }, ""], - ["a base-mounted route", { pathname: "/openclaw/terminal", search: "" }, "/openclaw"], - ["the embedded query form", { pathname: "/", search: "?view=terminal" }, ""], - ])("recognizes %s", (_label, location, basePath) => { - expect(isTerminalOnlyView(location, basePath)).toBe(true); - }); - - it("does not treat an ordinary route as a terminal document", () => { - expect(isTerminalOnlyView({ pathname: "/chat", search: "" }, "")).toBe(false); - }); - - it("builds a base-path-aware user-facing route", () => { - expect(terminalDocumentPath("/openclaw/")).toBe("/openclaw/terminal"); - }); -}); diff --git a/ui/src/app/terminal-document-mode.ts b/ui/src/app/terminal-document-mode.ts deleted file mode 100644 index 48f72319fd77..000000000000 --- a/ui/src/app/terminal-document-mode.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { normalizeRouteBasePath, normalizeRoutePath } from "@openclaw/uirouter"; - -const TERMINAL_DOCUMENT_PATH = "/terminal"; - -type TerminalDocumentLocation = Pick; - -export function terminalDocumentPath(basePath = ""): string { - return `${normalizeRouteBasePath(basePath)}${TERMINAL_DOCUMENT_PATH}`; -} - -export function isTerminalDocumentPath(pathname: string, basePath: string): boolean { - return normalizeRoutePath(pathname) === terminalDocumentPath(basePath); -} - -export function isTerminalOnlyView( - location: TerminalDocumentLocation | undefined = globalThis.location, - basePath = "", -): boolean { - return ( - new URLSearchParams(location?.search ?? "").get("view") === "terminal" || - isTerminalDocumentPath(location?.pathname ?? "/", basePath) - ); -} diff --git a/ui/src/components/desktop/desktop-document-inventory.ts b/ui/src/components/desktop/desktop-document-inventory.ts index 8bc1c1a3f73a..e1cdba3ec1ff 100644 --- a/ui/src/components/desktop/desktop-document-inventory.ts +++ b/ui/src/components/desktop/desktop-document-inventory.ts @@ -23,7 +23,7 @@ export async function resolveDesktopDocumentInventoryTarget(options: { } catch {} } const requestedSource = resolveDesktopDocumentTarget( - { source: options.source, session: options.sessionKey, control: false }, + { source: options.source, session: options.sessionKey }, session, ); return requestedSource !== null && diff --git a/ui/src/components/desktop/desktop-focus-window.ts b/ui/src/components/desktop/desktop-focus-window.ts new file mode 100644 index 000000000000..a9be17c2cefc --- /dev/null +++ b/ui/src/components/desktop/desktop-focus-window.ts @@ -0,0 +1,14 @@ +import { buildControlUiFocusPath } from "@openclaw/session-url-contract"; +import { openExternalUrlSafe } from "../../lib/open-external-url.ts"; + +export function desktopFocusPath( + basePath: string, + source?: string | null, + control = false, +): string { + return buildControlUiFocusPath({ kind: "desktop", source, control }, basePath); +} + +export function openDesktopFocus(basePath: string, source?: string | null, control = false): void { + openExternalUrlSafe(desktopFocusPath(basePath, source, control)); +} diff --git a/ui/src/components/desktop/desktop-panel-view.ts b/ui/src/components/desktop/desktop-panel-view.ts index 8dd8c66a5860..eb14e52e0367 100644 --- a/ui/src/components/desktop/desktop-panel-view.ts +++ b/ui/src/components/desktop/desktop-panel-view.ts @@ -11,6 +11,7 @@ export function renderDesktopPanelHeader(options: { fullscreenControl: TemplateResult; onClose: () => void; onDock: (dock: "bottom" | "right") => void; + onOpenWindow: () => void; }) { return html`
@@ -34,6 +35,15 @@ export function renderDesktopPanelHeader(options: { > ${icons.panelRightOpen} + ${options.fullscreenControl}