From a62ec20ff6a7b7ffdf21b9ed4f3fcd4848106764 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 1 Jul 2026 19:11:05 +0300 Subject: [PATCH] fix: enable connection link generation for desktop app --- .../remote-instances/RemoteInstancesPage.tsx | 54 ++++++++++++++++++- .../web/server/lib/opencode/core-routes.js | 33 +++++++++++- .../server/lib/opencode/core-routes.test.js | 30 +++++++++++ 3 files changed, 113 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/components/sections/remote-instances/RemoteInstancesPage.tsx b/packages/ui/src/components/sections/remote-instances/RemoteInstancesPage.tsx index 5bbeea9a..5e38a383 100644 --- a/packages/ui/src/components/sections/remote-instances/RemoteInstancesPage.tsx +++ b/packages/ui/src/components/sections/remote-instances/RemoteInstancesPage.tsx @@ -46,7 +46,8 @@ import { resolveDesktopHostUrl, type DesktopHost, } from '@/lib/desktopHosts'; -import { isDesktopShell } from '@/lib/desktop'; +import { getDesktopLanAddress, isDesktopLocalOriginActive, isDesktopShell } from '@/lib/desktop'; +import { runtimeFetch } from '@/lib/runtime-fetch'; import { getRuntimeApiBaseUrl, switchRuntimeEndpoint } from '@/lib/runtime-switch'; const randomPort = (): number => { @@ -229,6 +230,55 @@ const readRequestHeaderDrafts = (headers: Record | undefined): H return Object.entries(headers || {}).map(([name, value]) => createHeaderDraft(name, value)); }; +const getRuntimePort = (): number | null => { + if (typeof window === 'undefined') { + return null; + } + + const runtimeApiBaseUrl = getRuntimeApiBaseUrl(); + const portSource = runtimeApiBaseUrl || window.location.href; + try { + const port = Number(new URL(portSource).port || window.location.port); + return Number.isFinite(port) && port > 0 ? port : null; + } catch { + const port = Number(window.location.port); + return Number.isFinite(port) && port > 0 ? port : null; + } +}; + +const resolvePairingServerUrl = async (): Promise => { + const fallback = normalizeHostUrl(getRuntimeApiBaseUrl()) || window.location.origin; + if (!isDesktopShell() || !isDesktopLocalOriginActive()) { + return fallback; + } + + let response: Response; + try { + response = await runtimeFetch('/api/config/settings', { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + } catch { + return fallback; + } + if (!response.ok) return fallback; + + const settings = (await response.json().catch(() => null)) as null | { + desktopLanAccessActive?: unknown; + }; + if (settings?.desktopLanAccessActive !== true) { + return fallback; + } + + const address = await getDesktopLanAddress(); + const port = getRuntimePort(); + if (!address || !port) { + return fallback; + } + + return `http://${address}:${port}`; +}; + const navigateToUrl = (rawUrl: string): void => { const target = rawUrl.trim(); if (!target) { @@ -549,7 +599,7 @@ export const RemoteInstancesPage: React.FC = () => { if (!clientAuth) return; setRemoteClientError(null); try { - const serverUrl = normalizeHostUrl(getRuntimeApiBaseUrl()) || window.location.origin; + const serverUrl = await resolvePairingServerUrl(); const result = await clientAuth.createClient({ label: remoteClientLabel.trim() || 'Paired client' }); const payload = buildClientConnectionPayload({ serverUrl, token: result.token, label: remoteClientLabel || 'OpenChamber' }); const encoded = encodeClientConnectionPayload(payload); diff --git a/packages/web/server/lib/opencode/core-routes.js b/packages/web/server/lib/opencode/core-routes.js index 5b4fae2c..697fe528 100644 --- a/packages/web/server/lib/opencode/core-routes.js +++ b/packages/web/server/lib/opencode/core-routes.js @@ -396,6 +396,35 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => { } }; + const runWithClientCreateAuth = async (req, res, next, handler) => { + try { + if (typeof uiAuthController.resolveAuthContext === 'function') { + const context = await uiAuthController.resolveAuthContext(req, res, { + allowClientAuth: true, + allowUrlToken: false, + }); + if (context?.type === 'session') { + await handler(context); + return; + } + if (context?.type === 'client') { + const client = await clientRecordFromAuthContext(context); + if (client?.clientKind === 'desktop-local') { + await handler({ ...context, client }); + return; + } + return res.status(403).json({ error: 'Client tokens cannot create remote clients' }); + } + } + + await runWithUiAuth(req, res, next, async () => { + await handler({ type: 'session' }); + }, { sessionOnly: true }); + } catch (error) { + next(error); + } + }; + const clientIdFromAuthContext = (context) => { const raw = context?.client?.id || context?.clientId; return typeof raw === 'string' && raw.length > 0 ? raw : null; @@ -567,7 +596,7 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => { }); app.post('/api/client-auth/clients', express.json({ limit: '64kb' }), async (req, res, next) => { - await runWithUiAuth(req, res, next, async () => { + await runWithClientCreateAuth(req, res, next, async () => { const result = await remoteClientAuthRuntime.createClient({ label: req.body?.label, clientKind: req.body?.clientKind, @@ -575,7 +604,7 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => { }); res.setHeader('Cache-Control', 'no-store'); res.status(201).json(result); - }, { sessionOnly: true }); + }); }); app.delete('/api/client-auth/clients/:id', async (req, res, next) => { diff --git a/packages/web/server/lib/opencode/core-routes.test.js b/packages/web/server/lib/opencode/core-routes.test.js index c95ebbb6..384f7f03 100644 --- a/packages/web/server/lib/opencode/core-routes.test.js +++ b/packages/web/server/lib/opencode/core-routes.test.js @@ -399,6 +399,36 @@ describe('client auth routes', () => { expect(revoked.body.client.id).toBe(current.body.client.id); }); + it('allows only the local desktop client token to create remote client tokens', async () => { + const app = express(); + let authContext = { type: 'session' }; + const dependencies = createDependencies({ + resolveAuthContext: async () => authContext, + }); + registerAuthAndAccessRoutes(app, dependencies); + + const desktop = await request(app) + .post('/api/client-auth/clients') + .send({ label: 'OpenChamber Desktop', clientKind: 'desktop-local' }); + const remote = await request(app) + .post('/api/client-auth/clients') + .send({ label: 'Phone' }); + + authContext = { type: 'client', clientId: remote.body.client.id, client: remote.body.client }; + const denied = await request(app) + .post('/api/client-auth/clients') + .send({ label: 'Another phone' }); + expect(denied.status).toBe(403); + expect(denied.body.error).toBe('Client tokens cannot create remote clients'); + + authContext = { type: 'client', clientId: desktop.body.client.id, client: desktop.body.client }; + const created = await request(app) + .post('/api/client-auth/clients') + .send({ label: 'Mobile' }); + expect(created.status).toBe(201); + expect(created.body.client.label).toBe('Mobile'); + }); + it('requires UI-session auth for passkey registration management routes', async () => { const app = express(); const dependencies = createDependencies();