diff --git a/Coder-Desktop/Coder-Desktop/SessionTokenLink.swift b/Coder-Desktop/Coder-Desktop/SessionTokenLink.swift new file mode 100644 index 00000000..7c35b995 --- /dev/null +++ b/Coder-Desktop/Coder-Desktop/SessionTokenLink.swift @@ -0,0 +1,113 @@ +import Foundation + +/// Why an app link that needs a session token isn't given one. +enum SessionTokenBlockReason: Equatable { + case untrustedURL + case wrongDeployment(link: String, signedIn: URL) + + var description: String { + switch self { + case .untrustedURL: + "This app's URL isn't trusted to sign you in to Coder. Ask your template administrator to fix the " + + "app's url in the template's coder_app configuration." + case let .wrongDeployment(link, signedIn): + "This app signs in to \(link), but Coder Desktop is signed in to \(signedIn.absoluteString)." + } + } +} + +/// Decides whether an external app link may receive the user's session token, +/// using the same rules as the Coder dashboard. +enum SessionTokenLink { + static let placeholder = "$SESSION_TOKEN" + + private struct Handler { + /// Scheme and authority, matched case-insensitively. + let base: String + /// Matched exactly, up to where the params start. + let path: String + var firstParam = "" + } + + private static let handlers: [Handler] = [ + "vscode", "vscode-insiders", "windsurf", "devin", "cursor", "kiro", "positron", "antigravity", + "antigravity-ide", + ].flatMap { scheme in + ["/open?", "/openDevContainer?"].map { path in Handler(base: "\(scheme)://coder.coder-remote", path: path) } + } + [ + Handler(base: "jetbrains://gateway", path: "/coder?"), + Handler(base: "jetbrains://gateway", path: "/com.coder.toolbox?"), + // Gateway also handles non-Coder providers, such as `type=ssh`. + Handler(base: "jetbrains-gateway://connect", path: "#", firstParam: "type=coder&"), + ] + + static func isKnownHandler(_ appURL: String) -> Bool { + handler(for: appURL) != nil + } + + /// Returns nil if the link has no placeholder, or its only `url` is + /// `signedInURL` or `dashboardURL`. + static func blockReason(_ appURL: String, signedInURL: URL, dashboardURL: URL?) -> SessionTokenBlockReason? { + guard appURL.contains(placeholder) else { return nil } + guard appURL.components(separatedBy: placeholder).count == 2, + let handler = handler(for: appURL) + else { return .untrustedURL } + + // The placeholder is replaced raw, so it must be the raw `token` value. + // Gateway doesn't decode names, IDEs decode values twice, and VS Code + // ignores params after a `#`. + let params = String(appURL.dropFirst(handler.base.count + handler.path.count)) + let entries = params.components(separatedBy: "&") + guard entries.contains("token=\(placeholder)"), + entries.allSatisfy({ entry in !entry.prefix { char in char != "=" }.contains("%") }), + !params.contains("#"), + let decoded = params.removingPercentEncoding, + !decoded.unicodeScalars.contains("%"), + let url = urlParam(params), + urlParam(decoded) == url + else { return .untrustedURL } + + let trusted = [signedInURL, dashboardURL].compactMap(\.self) + return trusted.contains { trustedURL in isSameOrigin(url, trustedURL) } + ? nil + : .wrongDeployment(link: url, signedIn: signedInURL) + } + + /// Only printable ASCII links match, so parsers can't drop a tab, LF or CR + /// and `lowercased()` can't fold a Kelvin sign into `k`. + private static func handler(for appURL: String) -> Handler? { + guard appURL.unicodeScalars.allSatisfy({ scalar in (0x20 ..< 0x7F).contains(scalar.value) }) else { + return nil + } + return handlers.first { handler in + appURL.prefix(handler.base.count).lowercased() == handler.base + && appURL.dropFirst(handler.base.count).hasPrefix(handler.path + handler.firstParam) + } + } + + /// Returns `url` if the names are unique and it's a bare http(s) origin, + /// matched raw since parsing normalizes `/.` and drops an empty `?`. + private static func urlParam(_ query: String) -> String? { + // Split on scalars like `URLSearchParams`, since `&` followed by a + // combining mark is one `Character`. + let params = query.unicodeScalars.split(separator: "&").map { entry in + entry.split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false).map { part in + let plain = String(part).replacingOccurrences(of: "+", with: " ", options: .literal) + return plain.removingPercentEncoding ?? plain + } + } + guard Set(params.map { param in param[0] }).count == params.count, + let url = params.first(where: { param in param[0] == "url" && param.count == 2 })?[1], + url.range(of: #"^https?://[^/\\?#@\s]+/?\z"#, options: [.regularExpression, .caseInsensitive]) != nil + else { return nil } + return url + } + + private static func isSameOrigin(_ link: String, _ url: URL) -> Bool { + guard let origin = URL(string: link) else { return false } + let port = { (target: URL) in target.port ?? (target.scheme?.lowercased() == "https" ? 443 : 80) } + return origin.scheme?.lowercased() == url.scheme?.lowercased() + && origin.host?.lowercased() == url.host?.lowercased() + && port(origin) == port(url) + } +} diff --git a/Coder-Desktop/Coder-Desktop/State.swift b/Coder-Desktop/Coder-Desktop/State.swift index 3807273d..bb7c0094 100644 --- a/Coder-Desktop/Coder-Desktop/State.swift +++ b/Coder-Desktop/Coder-Desktop/State.swift @@ -79,6 +79,16 @@ class AppState: ObservableObject { } } + /// Lowercase schemes of app links the user chose to open without confirmation. + @Published var trustedAppSchemes: Set = Set( + UserDefaults.standard.stringArray(forKey: Keys.trustedAppSchemes) ?? [] + ) { + didSet { + guard persistent else { return } + UserDefaults.standard.set(Array(trustedAppSchemes), forKey: Keys.trustedAppSchemes) + } + } + func tunnelProviderProtocol() -> NETunnelProviderProtocol? { if !hasSession { return nil } let proto = NETunnelProviderProtocol() @@ -171,6 +181,10 @@ class AppState: ObservableObject { } } + /// The deployment's access URL from build info. Not saved, since an admin + /// can change it. + @Published var dashboardURL: URL? + private var refreshTask: Task? public func refreshDeploymentConfig() async { // Client is non-nil if there's a sesssion @@ -191,12 +205,18 @@ class AppState: ObservableObject { } hostnameSuffix = await refreshTask?.value ?? Self.defaultHostnameSuffix + do { + dashboardURL = try await client.buildInfo().dashboard_url + } catch { + logger.error("failed to get build info: \(error)") + } } } public func clearSession() { hasSession = false sessionToken = nil + dashboardURL = nil refreshTask?.cancel() client = nil reconfigure() @@ -228,6 +248,7 @@ class AppState: ObservableObject { static let dangerousDisableCoderSignatureValidation = "DangerousDisableCoderSignatureValidation" static let skipHiddenIconAlert = "SkipHiddenIconAlert" + static let trustedAppSchemes = "TrustedAppSchemes" } } diff --git a/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift b/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift index c598889d..f3b5522f 100644 --- a/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift +++ b/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift @@ -189,7 +189,12 @@ struct MenuItemView: View { .flatMap(\.self) .first(where: { $0.id == agent.id }) { - apps = agentToApps(logger, wsAgent, agent.primaryHost, baseAccessURL, sessionToken) + let session = CoderSession( + baseAccessURL: baseAccessURL, + dashboardURL: state.dashboardURL, + sessionToken: sessionToken + ) + apps = agentToApps(logger, wsAgent, agent.primaryHost, session) } else { logger.error("Could not find agent '\(agent.id)' in workspace '\(item.wsName)' resources") } diff --git a/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceAppIcon.swift b/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceAppIcon.swift index 94104d27..eb20889d 100644 --- a/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceAppIcon.swift +++ b/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceAppIcon.swift @@ -6,73 +6,109 @@ import SwiftUI struct WorkspaceAppIcon: View { let app: WorkspaceApp @Environment(\.openURL) private var openURL + @Environment(\.dismiss) private var dismiss + @EnvironmentObject private var state: AppState @State var isHovering: Bool = false @State var isPressed = false var body: some View { - Group { - Group { - WebImage( - url: app.icon, - context: [.imageThumbnailPixelSize: Theme.Size.appIconSize] - ) { $0 } - placeholder: { - if app.icon != nil { - CircularProgressView(value: nil, strokeWidth: 2, diameter: 10) - } else { - Image(systemName: "questionmark").frame( - width: Theme.Size.appIconWidth, - height: Theme.Size.appIconHeight - ) - } - }.frame( + WebImage( + url: app.icon, + context: [.imageThumbnailPixelSize: Theme.Size.appIconSize] + ) { $0 } + placeholder: { + if app.icon != nil { + CircularProgressView(value: nil, strokeWidth: 2, diameter: 10) + } else { + Image(systemName: "questionmark").frame( width: Theme.Size.appIconWidth, height: Theme.Size.appIconHeight ) - }.padding(6) - } - .background(isHovering ? Color.accentColor.opacity(0.8) : .clear) - .clipShape(RoundedRectangle(cornerRadius: 8)) - .onHover { hovering in isHovering = hovering } - .simultaneousGesture( - DragGesture(minimumDistance: 0) - .onChanged { _ in - withAnimation(.easeInOut(duration: 0.1)) { - isPressed = true - } } - .onEnded { _ in - withAnimation(.easeInOut(duration: 0.1)) { - isPressed = false + }.frame( + width: Theme.Size.appIconWidth, + height: Theme.Size.appIconHeight + ).padding(6) + .background(isHovering ? Color.accentColor.opacity(0.8) : .clear) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .onHover { hovering in isHovering = hovering && app.blockedReason == nil } + .simultaneousGesture( + DragGesture(minimumDistance: 0) + .onChanged { _ in + withAnimation(.easeInOut(duration: 0.1)) { + isPressed = true + } } - openURL(app.url) - } - ).help(app.displayName) + .onEnded { _ in + withAnimation(.easeInOut(duration: 0.1)) { + isPressed = false + } + guard app.blockedReason == nil else { return } + let scheme = app.url.scheme?.lowercased() ?? "" + if app.confirmBeforeOpening, !state.trustedAppSchemes.contains(scheme) { + confirmOpen(scheme: scheme) + } else { + openURL(app.url) + } + } + ) + .opacity(app.blockedReason == nil ? 1 : 0.4) + .help(app.blockedReason.map { reason in "\(app.displayName): \(reason.description)" } ?? app.displayName) + } + + /// `.alert` from SwiftUI doesn't play nice when the calling view is in the + /// menu bar. + private func confirmOpen(scheme: String) { + let alert = NSAlert() + alert.messageText = "Open \(app.displayName)?" + alert.informativeText = "This opens a \(scheme): link in another app on your computer." + alert.alertStyle = .informational + let dontAskAgain = NSButton(checkboxWithTitle: "Don't ask again for \(scheme): links", target: nil, action: nil) + alert.accessoryView = dontAskAgain + alert.addButton(withTitle: "Open") + alert.addButton(withTitle: "Cancel") + dismiss() + if alert.runModal() == .alertFirstButtonReturn { + if dontAskAgain.state == .on { + state.trustedAppSchemes.insert(scheme) + } + openURL(app.url) + } } } +/// The signed-in deployment that app links are built for. +struct CoderSession { + let baseAccessURL: URL + /// The deployment's access URL from build info, if it has loaded. + let dashboardURL: URL? + let sessionToken: String +} + struct WorkspaceApp { let slug: String let displayName: String let url: URL let icon: URL? + /// Set when the link needs a session token but isn't trusted with one. + let blockedReason: SessionTokenBlockReason? + let confirmBeforeOpening: Bool var id: String { slug } - private static let magicTokenString = "$SESSION_TOKEN" - init(slug: String, displayName: String, url: URL, icon: URL?) { self.slug = slug self.displayName = displayName self.url = url self.icon = icon + blockedReason = nil + confirmBeforeOpening = false } init( _ original: CoderSDK.WorkspaceApp, - iconBaseURL: URL, - sessionToken: String + session: CoderSession ) throws(WorkspaceAppError) { slug = original.slug // Same behaviour as the web UI @@ -96,23 +132,33 @@ struct WorkspaceApp { throw .isWebApp } - let newUrlString = originalUrl.absoluteString.replacingOccurrences( - of: Self.magicTokenString, - with: sessionToken + confirmBeforeOpening = !SessionTokenLink.isKnownHandler(originalUrl.absoluteString) + blockedReason = SessionTokenLink.blockReason( + originalUrl.absoluteString, + signedInURL: session.baseAccessURL, + dashboardURL: session.dashboardURL ) - guard let newUrl = URL(string: newUrlString) else { - throw .invalidURL + if blockedReason == nil { + let newUrlString = originalUrl.absoluteString.replacingOccurrences( + of: SessionTokenLink.placeholder, + with: session.sessionToken + ) + guard let newUrl = URL(string: newUrlString) else { + throw .invalidURL + } + url = newUrl + } else { + url = originalUrl } - url = newUrl var icon = original.icon if let originalIcon = original.icon, var components = URLComponents(url: originalIcon, resolvingAgainstBaseURL: false) { if components.host == nil { - components.port = iconBaseURL.port - components.scheme = iconBaseURL.scheme - components.host = iconBaseURL.host(percentEncoded: false) + components.port = session.baseAccessURL.port + components.scheme = session.baseAccessURL.scheme + components.host = session.baseAccessURL.host(percentEncoded: false) } if let newIconURL = components.url { @@ -149,12 +195,17 @@ func agentToApps( _ logger: Logger, _ agent: CoderSDK.WorkspaceAgent, _ host: String, - _ baseAccessURL: URL, - _ sessionToken: String + _ session: CoderSession ) -> [WorkspaceApp] { let workspaceApps = agent.apps.compactMap { app in + var app = app + // Dev container apps never get a token. coderd removes the placeholder + // since coder/coder#31262, but older servers still send it. + if agent.parent_id != nil, let url = app.url { + app.url = URL(string: url.absoluteString.replacingOccurrences(of: SessionTokenLink.placeholder, with: "")) + } do throws(WorkspaceAppError) { - return try WorkspaceApp(app, iconBaseURL: baseAccessURL, sessionToken: sessionToken) + return try WorkspaceApp(app, session: session) } catch { logger.warning("Skipping WorkspaceApp '\(app.slug)' for \(host): \(error.localizedDescription)") return nil @@ -166,13 +217,13 @@ func agentToApps( case .vscode: return vscodeDisplayApp( hostname: host, - baseIconURL: baseAccessURL, + baseIconURL: session.baseAccessURL, path: agent.expanded_directory ) case .vscode_insiders: return vscodeInsidersDisplayApp( hostname: host, - baseIconURL: baseAccessURL, + baseIconURL: session.baseAccessURL, path: agent.expanded_directory ) default: @@ -191,7 +242,7 @@ func vscodeDisplayApp(hostname: String, baseIconURL: URL, path: String? = nil) - // slugs as SwiftUI IDs slug: "-vscode", displayName: "VS Code Desktop", - url: URL(string: "vscode://vscode-remote/ssh-remote+\(hostname)/\(path ?? "")")!, + url: vscodeRemoteURL(scheme: "vscode", hostname: hostname, path: path), icon: icon ) } @@ -201,7 +252,17 @@ func vscodeInsidersDisplayApp(hostname: String, baseIconURL: URL, path: String? return WorkspaceApp( slug: "-vscode-insiders", displayName: "VS Code Insiders Desktop", - url: URL(string: "vscode-insiders://vscode-remote/ssh-remote+\(hostname)/\(path ?? "")")!, + url: vscodeRemoteURL(scheme: "vscode-insiders", hostname: hostname, path: path), icon: icon ) } + +/// The path setter percent-encodes the host and directory, so they can't add a +/// query or fragment. +private func vscodeRemoteURL(scheme: String, hostname: String, path: String?) -> URL { + var components = URLComponents() + components.scheme = scheme + components.host = "vscode-remote" + components.path = "/ssh-remote+\(hostname)/\(path ?? "")" + return components.url! +} diff --git a/Coder-Desktop/Coder-DesktopTests/SessionTokenLinkTests.swift b/Coder-Desktop/Coder-DesktopTests/SessionTokenLinkTests.swift new file mode 100644 index 00000000..f1fd2557 --- /dev/null +++ b/Coder-Desktop/Coder-DesktopTests/SessionTokenLinkTests.swift @@ -0,0 +1,103 @@ +@testable import Coder_Desktop +import Foundation +import Testing + +struct SessionTokenLinkTests { + static let signedIn = URL(string: "https://coder.example.com/")! + static let dashboard = URL(string: "https://dashboard.example/")! + + struct LinkCase: CustomStringConvertible { + let description: String + let url: String + let expected: SessionTokenBlockReason? + } + + /// A link with `url` set to `origin` and the placeholder as `token`. + static func link(_ prefix: String, origin: String = "https://coder.example.com", extra: String = "") -> String { + "\(prefix)url=\(origin)&token=\(SessionTokenLink.placeholder)\(extra)" + } + + static func trusted(_ description: String, _ url: String) -> LinkCase { + LinkCase(description: description, url: url, expected: nil) + } + + static func untrusted(_ description: String, _ url: String) -> LinkCase { + LinkCase(description: description, url: url, expected: .untrustedURL) + } + + static func wrongDeployment(_ description: String, origin: String) -> LinkCase { + LinkCase( + description: description, + url: link("vscode://coder.coder-remote/open?", origin: origin), + expected: .wrongDeployment(link: origin, signedIn: signedIn) + ) + } + + static let vscode = "vscode://coder.coder-remote/open?" + static let gateway = "jetbrains-gateway://connect#type=coder&" + + @Test("Block reasons", arguments: [ + trusted("no placeholder", "someapp://anything?x=1"), + trusted("VS Code", link("vscode://coder.coder-remote/open?owner=a&")), + trusted("dev container handler", link("cursor://coder.coder-remote/openDevContainer?")), + trusted("uppercase scheme and authority", link("VSCode://Coder.Coder-Remote/open?")), + trusted("Gateway plugin", link("jetbrains://gateway/coder?")), + trusted("Toolbox", link("jetbrains://gateway/com.coder.toolbox?")), + trusted("Gateway fragment", link(gateway)), + trusted("trailing slash", link(vscode, origin: "https://coder.example.com/")), + trusted("uppercase host", link(vscode, origin: "https://CODER.example.com")), + trusted("default port", link(vscode, origin: "https://coder.example.com:443")), + trusted("encoded url value", link(vscode, origin: "https%3A%2F%2Fcoder.example.com")), + trusted("dashboard URL", link(vscode, origin: "https://dashboard.example")), + + untrusted("unknown extension", link("vscode://other.ext/open?")), + untrusted("unknown scheme", link("coder://open?")), + untrusted("opaque handler", link("vscode:coder.coder-remote/open?")), + untrusted("userinfo in handler", link("vscode://x@coder.coder-remote/open?")), + untrusted("dot segments in handler", link("vscode://coder.coder-remote/open/../open?")), + untrusted("VS Code params in fragment", link("vscode://coder.coder-remote/open#")), + untrusted("Gateway params in query", link("jetbrains-gateway://connect?")), + untrusted("non-Coder Gateway provider", link("jetbrains-gateway://connect#type=ssh&")), + untrusted("tab in a param name", link(gateway, extra: "&ur\tl=https://other.example")), + untrusted("second placeholder", link(gateway, extra: "&x=$SESSION_TOKEN")), + untrusted("params after a fragment", link("\(vscode)x=1#&")), + untrusted( + "placeholder outside the raw token entry", + "\(vscode)url=https://coder.example.com&token=%24SESSION_TOKEN&y=$SESSION_TOKEN" + ), + untrusted("encoded url name", "\(vscode)%75rl=https://coder.example.com&token=$SESSION_TOKEN"), + untrusted("invalid percent encoding", link(gateway, extra: "&x=%FF")), + untrusted("double-encoded url param", link(gateway, extra: "&x=%2526url%253Dhttps://other.example")), + untrusted("duplicate url", link(gateway, extra: "&url=https://other.example")), + untrusted("duplicate Gateway type", link(gateway, extra: "&type=ssh")), + untrusted("url hidden by a combining mark", link(gateway, extra: "&x=%26url=%CC%81https://other.example")), + untrusted("url smuggled in an encoded value", link(gateway, extra: "&x=a%26url%3Dhttps://other.example")), + untrusted("no url", "\(vscode)token=$SESSION_TOKEN"), + untrusted("url isn't a URL", link(vscode, origin: "not-a-url")), + untrusted("url has a path", link(vscode, origin: "https://coder.example.com/@victim/ws.main/apps/x/")), + untrusted("url has userinfo", link(vscode, origin: "https://user@coder.example.com")), + untrusted("url has a dot segment", link(vscode, origin: "https://coder.example.com/.")), + untrusted("url has an empty query", link(vscode, origin: "https://coder.example.com?")), + + wrongDeployment("another host", origin: "https://other.example"), + wrongDeployment("another port", origin: "https://coder.example.com:1"), + wrongDeployment("http instead of https", origin: "http://coder.example.com"), + ]) + func blockReason(_ testCase: LinkCase) { + #expect( + SessionTokenLink.blockReason(testCase.url, signedInURL: Self.signedIn, dashboardURL: Self.dashboard) + == testCase.expected + ) + } + + @Test(arguments: [ + ("vscode://coder.coder-remote/open?owner=a", true), + ("jetbrains-gateway://connect#type=coder&workspace=b", true), + ("jetbrains-gateway://connect#type=ssh&host=other.example", false), + ("\u{212A}iro://coder.coder-remote/open?", false), + ("someapp://anything?x=1", false), + ]) + func isKnownHandler(url: String, expected: Bool) { + #expect(SessionTokenLink.isKnownHandler(url) == expected) + } +} diff --git a/Coder-Desktop/Coder-DesktopTests/WorkspaceAppTests.swift b/Coder-Desktop/Coder-DesktopTests/WorkspaceAppTests.swift index d0aead16..599d4a4e 100644 --- a/Coder-Desktop/Coder-DesktopTests/WorkspaceAppTests.swift +++ b/Coder-Desktop/Coder-DesktopTests/WorkspaceAppTests.swift @@ -10,6 +10,9 @@ struct WorkspaceAppTests { let baseAccessURL = URL(string: "https://coder.example.com")! let sessionToken = "test-session-token" let host = "test-workspace.coder.test" + var session: CoderSession { + CoderSession(baseAccessURL: baseAccessURL, dashboardURL: nil, sessionToken: sessionToken) + } @Test func testCreateWorkspaceApp_Success() throws { @@ -27,10 +30,10 @@ struct WorkspaceAppTests { let workspaceApp = try WorkspaceApp( sdkApp, - iconBaseURL: baseAccessURL, - sessionToken: sessionToken + session: session ) + #expect(workspaceApp.confirmBeforeOpening) #expect(workspaceApp.slug == "test-app") #expect(workspaceApp.displayName == "Test App") #expect(workspaceApp.url.absoluteString == "vscode://myworkspace.coder/foo") @@ -39,27 +42,29 @@ struct WorkspaceAppTests { @Test func testCreateWorkspaceApp_SessionTokenReplacement() throws { - let sdkApp = CoderSDK.WorkspaceApp( + let sdkApp = try CoderSDK.WorkspaceApp( id: UUID(), - url: URL(string: "vscode://myworkspace.coder/foo?token=$SESSION_TOKEN")!, + url: #require(URL(string: "vscode://coder.coder-remote/open?url=https://coder.example.com&token=$SESSION_TOKEN")), external: true, slug: "token-app", display_name: "Token App", command: nil, - icon: URL(string: "/icon/test-app.svg")!, + icon: #require(URL(string: "/icon/test-app.svg")), subdomain: false, subdomain_name: nil ) let workspaceApp = try WorkspaceApp( sdkApp, - iconBaseURL: baseAccessURL, - sessionToken: sessionToken + session: session ) #expect( - workspaceApp.url.absoluteString == "vscode://myworkspace.coder/foo?token=test-session-token" + workspaceApp.url.absoluteString == + "vscode://coder.coder-remote/open?url=https://coder.example.com&token=test-session-token" ) + #expect(workspaceApp.blockedReason == nil) + #expect(!workspaceApp.confirmBeforeOpening) } @Test @@ -79,8 +84,7 @@ struct WorkspaceAppTests { #expect(throws: WorkspaceAppError.missingURL) { try WorkspaceApp( sdkApp, - iconBaseURL: baseAccessURL, - sessionToken: sessionToken + session: session ) } } @@ -102,8 +106,7 @@ struct WorkspaceAppTests { #expect(throws: WorkspaceAppError.isCommandApp) { try WorkspaceApp( sdkApp, - iconBaseURL: baseAccessURL, - sessionToken: sessionToken + session: session ) } } @@ -112,9 +115,10 @@ struct WorkspaceAppTests { func testDisplayApps_VSCode() throws { let agent = createMockAgent(displayApps: [.vscode, .web_terminal, .ssh_helper, .port_forwarding_helper]) - let apps = agentToApps(logger, agent, host, baseAccessURL, sessionToken) + let apps = agentToApps(logger, agent, host, session) #expect(apps.count == 1) + #expect(!apps[0].confirmBeforeOpening) #expect(apps[0].slug == "-vscode") #expect(apps[0].displayName == "VS Code Desktop") #expect(apps[0].url.absoluteString == "vscode://vscode-remote/ssh-remote+test-workspace.coder.test//home/user") @@ -132,7 +136,7 @@ struct WorkspaceAppTests { ] ) - let apps = agentToApps(logger, agent, host, baseAccessURL, sessionToken) + let apps = agentToApps(logger, agent, host, session) #expect(apps.count == 1) #expect(apps[0].slug == "-vscode-insiders") @@ -162,8 +166,7 @@ struct WorkspaceAppTests { #expect(throws: WorkspaceAppError.isWebApp) { try WorkspaceApp( sdkApp, - iconBaseURL: baseAccessURL, - sessionToken: sessionToken + session: session ) } } @@ -220,7 +223,7 @@ struct WorkspaceAppTests { ) let agent = createMockAgent(apps: [sdkApp1, sdkApp2, sdkApp3, sdkApp4], displayApps: [.vscode]) - let apps = agentToApps(logger, agent, host, baseAccessURL, sessionToken) + let apps = agentToApps(logger, agent, host, session) #expect(apps.count == 3) let appSlugs = apps.map(\.slug) @@ -236,8 +239,71 @@ struct WorkspaceAppTests { CoderSDK.WorkspaceAgent( id: UUID(), expanded_directory: "/home/user", + parent_id: nil, apps: apps, display_apps: displayApps ) } } + +extension WorkspaceAppTests { + private func externalApp(_ url: String) throws -> CoderSDK.WorkspaceApp { + try CoderSDK.WorkspaceApp( + id: UUID(), url: #require(URL(string: url)), external: true, slug: "app", display_name: "App", + command: nil, icon: nil, subdomain: false, subdomain_name: nil + ) + } + + @Test + func createWorkspaceApp_SessionTokenBlocked() throws { + let sdkApp = try externalApp("vscode://coder.coder-remote/open?url=https://other.example&token=$SESSION_TOKEN") + + let app = try WorkspaceApp(sdkApp, session: session) + + #expect(app.url.absoluteString.contains(SessionTokenLink.placeholder)) + #expect(app.blockedReason == .wrongDeployment(link: "https://other.example", signedIn: baseAccessURL)) + } + + @Test + func createWorkspaceApp_TrustsDashboardURL() throws { + let sdkApp = try externalApp("vscode://coder.coder-remote/open?url=https://dogfood.example&token=$SESSION_TOKEN") + let session = try CoderSession( + baseAccessURL: baseAccessURL, + dashboardURL: #require(URL(string: "https://dogfood.example/")), + sessionToken: sessionToken + ) + + let app = try WorkspaceApp(sdkApp, session: session) + + #expect(app.url.absoluteString.hasSuffix("&token=\(sessionToken)")) + #expect(app.blockedReason == nil) + } + + @Test + func agentToApps_SubAgentGetsNoToken() throws { + // The trust check doesn't apply, since there's no token to protect. + let url = "vscode://coder.coder-remote/openDevContainer?url=https://other.example&token=" + let agent = try CoderSDK.WorkspaceAgent( + id: UUID(), expanded_directory: nil, parent_id: UUID(), + apps: [externalApp(url + "$SESSION_TOKEN")], display_apps: [] + ) + + let apps = agentToApps(logger, agent, host, session) + + #expect(apps.map(\.url.absoluteString) == [url]) + #expect(apps.map(\.blockedReason) == [nil]) + } + + @Test + func displayApps_VSCodeEscapesPath() { + let agent = CoderSDK.WorkspaceAgent( + id: UUID(), expanded_directory: "/home/a b?x=1#y", parent_id: nil, apps: [], display_apps: [.vscode] + ) + + let apps = agentToApps(logger, agent, host, session) + + #expect(apps.map(\.url.absoluteString) == [ + "vscode://vscode-remote/ssh-remote+test-workspace.coder.test//home/a%20b%3Fx=1%23y", + ]) + } +} diff --git a/Coder-Desktop/CoderSDK/Deployment.swift b/Coder-Desktop/CoderSDK/Deployment.swift index b88029f1..e5178842 100644 --- a/Coder-Desktop/CoderSDK/Deployment.swift +++ b/Coder-Desktop/CoderSDK/Deployment.swift @@ -12,6 +12,12 @@ public extension Client { public struct BuildInfoResponse: Codable, Equatable, Sendable { public let version: String + public let dashboard_url: URL? + + public init(version: String, dashboard_url: URL? = nil) { + self.version = version + self.dashboard_url = dashboard_url + } // `version` in the form `[0-9]+.[0-9]+.[0-9]+` public var semver: String? { diff --git a/Coder-Desktop/CoderSDK/Workspace.swift b/Coder-Desktop/CoderSDK/Workspace.swift index e70820da..e6655f1f 100644 --- a/Coder-Desktop/CoderSDK/Workspace.swift +++ b/Coder-Desktop/CoderSDK/Workspace.swift @@ -43,12 +43,21 @@ public struct WorkspaceResource: Codable, Identifiable, Sendable { public struct WorkspaceAgent: Codable, Identifiable, Sendable { public let id: UUID public let expanded_directory: String? // `omitempty` + /// Set for sub-agents, such as dev containers. + public let parent_id: UUID? public let apps: [WorkspaceApp] public let display_apps: [DisplayApp] - public init(id: UUID, expanded_directory: String?, apps: [WorkspaceApp], display_apps: [DisplayApp]) { + public init( + id: UUID, + expanded_directory: String?, + parent_id: UUID?, + apps: [WorkspaceApp], + display_apps: [DisplayApp] + ) { self.id = id self.expanded_directory = expanded_directory + self.parent_id = parent_id self.apps = apps self.display_apps = display_apps }