diff --git a/desktop/src/lib/desktopRuntime.test.ts b/desktop/src/lib/desktopRuntime.test.ts
index 26b9d28b..5bc48852 100644
--- a/desktop/src/lib/desktopRuntime.test.ts
+++ b/desktop/src/lib/desktopRuntime.test.ts
@@ -156,6 +156,33 @@ describe('desktopRuntime browser H5 bootstrap', () => {
expect(globalThis.fetch).toHaveBeenCalledTimes(1)
})
+ it('still starts when the desktop host cannot resolve the local access token', async () => {
+ // The token only raises what the shell may do — loopback is trusted without
+ // it — so losing it must degrade rather than block startup.
+ const serverUrl = 'http://127.0.0.1:59232'
+ const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ window.desktopHost = {
+ ...browserHost,
+ kind: 'electron',
+ isDesktop: true,
+ runtime: {
+ getServerUrl: vi.fn().mockResolvedValue(serverUrl),
+ getLocalAccessToken: vi.fn().mockRejectedValue(new Error('ipc channel missing')),
+ },
+ }
+ globalThis.fetch = vi.fn().mockResolvedValue(
+ healthOkResponse(),
+ ) as typeof fetch
+
+ await expect(initializeDesktopServerUrl()).resolves.toBe(serverUrl)
+
+ expect(clientMocks.setBaseUrl).toHaveBeenLastCalledWith(serverUrl)
+ expect(clientMocks.setAuthToken).toHaveBeenLastCalledWith(null)
+ expect(consoleWarn).toHaveBeenCalled()
+
+ consoleWarn.mockRestore()
+ })
+
it('classifies browser H5 runtime using the desktop host boundary', () => {
expect(isBrowserH5Runtime()).toBe(true)
expect(isDesktopRuntime()).toBe(false)
diff --git a/desktop/src/lib/desktopRuntime.ts b/desktop/src/lib/desktopRuntime.ts
index 84ed337e..0ef0175e 100644
--- a/desktop/src/lib/desktopRuntime.ts
+++ b/desktop/src/lib/desktopRuntime.ts
@@ -165,7 +165,12 @@ export async function initializeDesktopServerUrl() {
try {
const [serverUrl, localAccessToken] = await Promise.all([
host.runtime.getServerUrl(),
- host.runtime.getLocalAccessToken(),
+ // The process token only *raises* what the shell may do; loopback is
+ // trusted without it. Losing it must not take the whole app down with it.
+ host.runtime.getLocalAccessToken().catch((error) => {
+ console.warn('[desktop] local access token unavailable, continuing on loopback trust', error)
+ return null
+ }),
])
setBaseUrl(serverUrl)
setAuthToken(localAccessToken)
diff --git a/src/server/__tests__/h5-access-auth.test.ts b/src/server/__tests__/h5-access-auth.test.ts
index 6c16b6b8..268ebc62 100644
--- a/src/server/__tests__/h5-access-auth.test.ts
+++ b/src/server/__tests__/h5-access-auth.test.ts
@@ -332,12 +332,20 @@ describe('remote H5 auth and CORS integration', () => {
}
})
- test('rejects a tokenless loopback proxy hop when desktop local auth is configured', async () => {
+ test('keeps tokenless loopback capabilities working when desktop local auth is configured', async () => {
+ // The desktop shell injects a process token, but the browser windows it
+ // opens (OAuth success pages, `/preview-fs` links) and local scripts cannot
+ // carry it. Loopback must stay trusted on its own.
process.env.CC_HAHA_LOCAL_ACCESS_TOKEN = 'desktop-local-secret'
await restartRemoteServer()
- const proxyShapedResponse = await fetch(`${baseUrl}/api/status`)
- expect(proxyShapedResponse.status).toBe(403)
+ const tokenlessResponse = await fetch(`${baseUrl}/api/status`)
+ expect(tokenlessResponse.status).toBe(200)
+ await expect(tokenlessResponse.json()).resolves.toMatchObject({ status: 'ok' })
+
+ const oauthSuccessResponse = await fetch(`${baseUrl}/api/haha-grok-oauth/success`)
+ expect(oauthSuccessResponse.status).toBe(200)
+ expect(oauthSuccessResponse.headers.get('Content-Type')).toContain('text/html')
const desktopResponse = await fetch(`${baseUrl}/api/status`, {
headers: { Authorization: 'Bearer desktop-local-secret' },
@@ -346,6 +354,51 @@ describe('remote H5 auth and CORS integration', () => {
await expect(desktopResponse.json()).resolves.toMatchObject({ status: 'ok' })
})
+ test('still requires the desktop process token for the H5 control plane', async () => {
+ process.env.CC_HAHA_LOCAL_ACCESS_TOKEN = 'desktop-local-secret'
+ await restartRemoteServer()
+
+ // Another browser or script on the same machine must not be able to publish
+ // the user's sessions to the network.
+ const tokenlessRead = await fetch(`${baseUrl}/api/h5-access`)
+ expect(tokenlessRead.status).toBe(403)
+
+ const tokenlessEnable = await fetch(`${baseUrl}/api/h5-access/enable`, { method: 'POST' })
+ expect(tokenlessEnable.status).toBe(403)
+
+ const desktopEnable = await fetch(`${baseUrl}/api/h5-access/enable`, {
+ method: 'POST',
+ headers: { Authorization: 'Bearer desktop-local-secret' },
+ })
+ expect(desktopEnable.status).toBe(200)
+ await expect(desktopEnable.json()).resolves.toHaveProperty('token')
+ })
+
+ test('does not extend tokenless loopback trust to cross-site subresource loads', async () => {
+ process.env.CC_HAHA_LOCAL_ACCESS_TOKEN = 'desktop-local-secret'
+ await restartRemoteServer()
+
+ // A malicious page embedding `
`
+ // sends no Origin, so Fetch Metadata is what marks it as not-a-navigation.
+ const subresourceResponse = await fetch(`${baseUrl}/api/status`, {
+ headers: {
+ 'Sec-Fetch-Site': 'cross-site',
+ 'Sec-Fetch-Mode': 'no-cors',
+ 'Sec-Fetch-Dest': 'image',
+ },
+ })
+ expect(subresourceResponse.status).toBe(403)
+
+ const navigationResponse = await fetch(`${baseUrl}/api/status`, {
+ headers: {
+ 'Sec-Fetch-Site': 'cross-site',
+ 'Sec-Fetch-Mode': 'navigate',
+ 'Sec-Fetch-Dest': 'document',
+ },
+ })
+ expect(navigationResponse.status).toBe(200)
+ })
+
test('enforces the pet bearer capability allowlist before API routing', async () => {
process.env.CC_HAHA_LOCAL_ACCESS_TOKEN = 'desktop-local-secret'
process.env.CC_HAHA_PET_ACCESS_TOKEN = 'pet-capability-secret'
@@ -478,8 +531,10 @@ describe('remote H5 auth and CORS integration', () => {
body: JSON.stringify({ model: 'test', max_tokens: 8, messages: [] }),
})
+ // Loopback reaches the proxy with or without the process token; only the
+ // provider configuration decides the outcome.
const disabledTokenlessResponse = await requestProxy(false)
- expect(disabledTokenlessResponse.status).toBe(403)
+ expect(disabledTokenlessResponse.status).toBe(400)
const disabledAuthorizedResponse = await requestProxy(true)
expect(disabledAuthorizedResponse.status).toBe(400)
@@ -489,8 +544,11 @@ describe('remote H5 auth and CORS integration', () => {
await new H5AccessService().enable()
+ // Remote browsers on this same route still need the H5 token — covered by
+ // 'requires H5 token for remote browser proxy requests when H5 access is
+ // enabled'.
const enabledTokenlessResponse = await requestProxy(false)
- expect(enabledTokenlessResponse.status).toBe(401)
+ expect(enabledTokenlessResponse.status).toBe(400)
const enabledAuthorizedResponse = await requestProxy(true)
expect(enabledAuthorizedResponse.status).toBe(400)
diff --git a/src/server/__tests__/h5-access-policy.test.ts b/src/server/__tests__/h5-access-policy.test.ts
index 030655d8..3919a01d 100644
--- a/src/server/__tests__/h5-access-policy.test.ts
+++ b/src/server/__tests__/h5-access-policy.test.ts
@@ -2,6 +2,7 @@ import { describe, expect, test } from 'bun:test'
import {
classifyH5Request,
isLoopbackHost,
+ requiresLocalAccessCredential,
shouldBlockDisabledH5Access,
shouldRequireH5Token,
} from '../h5AccessPolicy.js'
@@ -193,9 +194,7 @@ describe('h5AccessPolicy', () => {
}
})
- test('requires the configured local credential even when a proxy perfectly mimics loopback', () => {
- const request = req('http://127.0.0.1:3456/api/h5-access')
- const url = new URL(request.url)
+ test('requires the configured local credential to reach the H5 control plane', () => {
const unauthorizedContext = {
clientAddress: '127.0.0.1',
localAccessTokenConfigured: true,
@@ -206,15 +205,72 @@ describe('h5AccessPolicy', () => {
localAccessAuthorized: true,
}
- expect(classifyH5Request(request, url, unauthorizedContext)).toBe('h5-browser')
- expect(shouldBlockDisabledH5Access({
- request,
- url,
- h5Enabled: false,
- explicitAuthRequired: false,
- context: unauthorizedContext,
- })).toBe(true)
- expect(classifyH5Request(request, url, authorizedContext)).toBe('local-trusted')
+ for (const pathname of ['/api/h5-access', '/api/h5-access/enable']) {
+ expect(requiresLocalAccessCredential(pathname, unauthorizedContext)).toBe(true)
+ expect(requiresLocalAccessCredential(pathname, authorizedContext)).toBe(false)
+ }
+
+ // Verifying a token the phone already holds is not a control-plane change,
+ // and an unmanaged (tokenless) server must not lock itself out either.
+ expect(requiresLocalAccessCredential('/api/h5-access/verify', unauthorizedContext)).toBe(false)
+ expect(requiresLocalAccessCredential('/api/h5-access', { clientAddress: '127.0.0.1' })).toBe(false)
+ })
+
+ test('keeps ordinary loopback capabilities usable without the desktop process token', () => {
+ // The desktop shell injects a process token, but the OAuth success page the
+ // system browser opens, a `/preview-fs` link and plain `curl` can never
+ // carry it. Gating loopback behind that token 401'd all of them.
+ const desktopContext = {
+ clientAddress: '127.0.0.1',
+ localAccessTokenConfigured: true,
+ localAccessAuthorized: false,
+ }
+
+ for (const pathname of [
+ '/api/haha-grok-oauth/success',
+ '/api/sessions',
+ '/preview-fs/session-1/index.html',
+ '/ws/session-1',
+ ]) {
+ const request = req(`http://127.0.0.1:3456${pathname}`)
+ const url = new URL(request.url)
+
+ expect(classifyH5Request(request, url, desktopContext)).toBe('local-trusted')
+ expect(shouldRequireH5Token({ request, url, h5Enabled: true, context: desktopContext })).toBe(false)
+ expect(shouldBlockDisabledH5Access({
+ request,
+ url,
+ h5Enabled: false,
+ explicitAuthRequired: false,
+ context: desktopContext,
+ })).toBe(false)
+ }
+ })
+
+ test('does not extend loopback trust to cross-site subresource loads', () => {
+ // `
` from a malicious page reaches
+ // us without an Origin header; only Fetch Metadata separates it from a real
+ // local navigation.
+ const request = req('http://127.0.0.1:3456/api/sessions', {
+ headers: {
+ 'Sec-Fetch-Site': 'cross-site',
+ 'Sec-Fetch-Mode': 'no-cors',
+ 'Sec-Fetch-Dest': 'image',
+ },
+ })
+ expect(classifyH5Request(request, new URL(request.url), localContext)).toBe('h5-browser')
+
+ for (const headers of [
+ // The OAuth provider redirecting the browser back to us.
+ { 'Sec-Fetch-Site': 'cross-site', 'Sec-Fetch-Mode': 'navigate', 'Sec-Fetch-Dest': 'document' },
+ // The user opening the URL from the address bar or `shell.open`.
+ { 'Sec-Fetch-Site': 'none', 'Sec-Fetch-Mode': 'navigate', 'Sec-Fetch-Dest': 'document' },
+ // The local H5 shell calling its own API.
+ { 'Sec-Fetch-Site': 'same-origin', 'Sec-Fetch-Mode': 'cors', 'Sec-Fetch-Dest': 'empty' },
+ ]) {
+ const allowed = req('http://127.0.0.1:3456/api/sessions', { headers })
+ expect(classifyH5Request(allowed, new URL(allowed.url), localContext)).toBe('local-trusted')
+ }
})
test('does not grant internal SDK trust to a request carrying proxy traces', () => {
diff --git a/src/server/h5AccessPolicy.ts b/src/server/h5AccessPolicy.ts
index fd9340f1..20737f9c 100644
--- a/src/server/h5AccessPolicy.ts
+++ b/src/server/h5AccessPolicy.ts
@@ -59,8 +59,29 @@ function isLoopbackBrowserOrigin(origin: string): boolean {
return isLoopbackHost(parsed.hostname)
}
-function isLocalDesktopOrNavigationOrigin(origin: string | null): boolean {
- if (!origin) return true
+/**
+ * A cross-site subresource load (`
`, `