diff --git a/app/src-tauri/tauri.conf.json b/app/src-tauri/tauri.conf.json index 4bbf0350a..5ac7fb9c5 100644 --- a/app/src-tauri/tauri.conf.json +++ b/app/src-tauri/tauri.conf.json @@ -23,7 +23,7 @@ } ], "security": { - "csp": "default-src 'self' 'unsafe-inline' data: blob: https: wss: ipc: http://ipc.localhost http://127.0.0.1:* http://localhost:*; img-src 'self' data: blob: https:; connect-src 'self' ipc: http://ipc.localhost http://127.0.0.1:* http://localhost:* ws://127.0.0.1:* ws://localhost:* https: wss: data: blob:; frame-src 'self' https: data: blob:" + "csp": "default-src 'self' 'unsafe-inline' data: blob: https: wss: ipc: http://ipc.localhost http://127.0.0.1:* http://localhost:*; img-src 'self' data: blob: https:; connect-src 'self' ipc: http://ipc.localhost http://127.0.0.1:* http://localhost:* http: ws://127.0.0.1:* ws://localhost:* ws: https: wss: data: blob:; frame-src 'self' https: data: blob:" }, "macOSPrivateApi": true }, diff --git a/app/src/components/BootCheckGate/BootCheckGate.tsx b/app/src/components/BootCheckGate/BootCheckGate.tsx index e1d62e34d..895c12fad 100644 --- a/app/src/components/BootCheckGate/BootCheckGate.tsx +++ b/app/src/components/BootCheckGate/BootCheckGate.tsx @@ -24,6 +24,7 @@ import { useAppDispatch, useAppSelector } from '../../store/hooks'; import { clearStoredCoreMode, clearStoredCoreToken, + isLocalOrPrivateNetworkHost, storeCoreMode, storeCoreToken, storeRpcUrl, @@ -116,6 +117,12 @@ function ModePicker({ onConfirm }: PickerProps) { setUrlError('URL must start with http:// or https://'); return null; } + if (parsed.protocol === 'http:' && !isLocalOrPrivateNetworkHost(parsed.hostname)) { + setUrlError( + 'HTTP core URLs are only allowed for localhost or private network hosts. Use HTTPS for public hosts.' + ); + return null; + } } catch { setUrlError('Please enter a valid URL (e.g. https://core.example.com/rpc)'); return null; diff --git a/app/src/components/BootCheckGate/__tests__/BootCheckGate.test.tsx b/app/src/components/BootCheckGate/__tests__/BootCheckGate.test.tsx index bc1dda7f1..dc906204f 100644 --- a/app/src/components/BootCheckGate/__tests__/BootCheckGate.test.tsx +++ b/app/src/components/BootCheckGate/__tests__/BootCheckGate.test.tsx @@ -39,14 +39,17 @@ vi.mock('../../../services/coreRpcClient', () => ({ testCoreRpcConnection: (...args: unknown[]) => mockTestCoreRpcConnection(...args), })); -vi.mock('../../../utils/configPersistence', () => ({ - storeRpcUrl: vi.fn(), - storeCoreToken: vi.fn(), - clearStoredCoreToken: vi.fn(), - storeCoreMode: vi.fn(), - clearStoredCoreMode: vi.fn(), - isValidRpcUrl: vi.fn().mockReturnValue(true), -})); +vi.mock('../../../utils/configPersistence', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + storeRpcUrl: vi.fn(), + storeCoreToken: vi.fn(), + clearStoredCoreToken: vi.fn(), + storeCoreMode: vi.fn(), + clearStoredCoreMode: vi.fn(), + }; +}); // --------------------------------------------------------------------------- // Store factory @@ -157,6 +160,43 @@ describe('BootCheckGate — picker (unset mode)', () => { expect(screen.getByText(/Please enter the core auth token/i)).toBeInTheDocument(); }); + it('accepts a Tailscale HTTP core URL in cloud mode', async () => { + mockRunBootCheck.mockResolvedValue({ kind: 'match' }); + + renderGate(); + fireEvent.click(screen.getByText('Cloud')); + fireEvent.change(screen.getByPlaceholderText(/https:\/\/core\.example\.com/), { + target: { value: 'http://100.116.244.64:7788/rpc' }, + }); + fireEvent.change(screen.getByPlaceholderText(/Bearer token/i), { + target: { value: 'tok-1234' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Continue' })); + + await waitFor(() => { + expect(screen.getByTestId('app-content')).toBeInTheDocument(); + }); + expect(mockRunBootCheck).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'cloud', + url: 'http://100.116.244.64:7788/rpc', + token: 'tok-1234', + }), + expect.any(Object) + ); + }); + + it('rejects public HTTP cloud URLs', () => { + renderGate(); + + fireEvent.click(screen.getByText('Cloud')); + const urlInput = screen.getByPlaceholderText(/https:\/\/core\.example\.com/); + fireEvent.change(urlInput, { target: { value: 'http://core.example.com/rpc' } }); + fireEvent.click(screen.getByRole('button', { name: 'Continue' })); + + expect(screen.getByText(/HTTP core URLs are only allowed/)).toBeInTheDocument(); + }); + it('clears the token error as soon as the user types into the token field', () => { renderGate(); diff --git a/app/src/utils/__tests__/configPersistence.test.ts b/app/src/utils/__tests__/configPersistence.test.ts index 7e159da7b..4fe8f58da 100644 --- a/app/src/utils/__tests__/configPersistence.test.ts +++ b/app/src/utils/__tests__/configPersistence.test.ts @@ -12,6 +12,8 @@ import { getStoredCoreMode, getStoredCoreToken, getStoredRpcUrl, + isAllowedCloudRpcUrl, + isLocalOrPrivateNetworkHost, isValidRpcUrl, normalizeRpcUrl, peekStoredRpcUrl, @@ -205,6 +207,56 @@ describe('configPersistence', () => { }); }); + describe('isLocalOrPrivateNetworkHost', () => { + it('allows localhost and loopback addresses', () => { + expect(isLocalOrPrivateNetworkHost('localhost')).toBe(true); + expect(isLocalOrPrivateNetworkHost('app.localhost')).toBe(true); + expect(isLocalOrPrivateNetworkHost('127.0.0.1')).toBe(true); + expect(isLocalOrPrivateNetworkHost('::1')).toBe(true); + }); + + it('allows RFC1918, link-local, and Tailscale/CGNAT IPv4 addresses', () => { + expect(isLocalOrPrivateNetworkHost('10.0.0.8')).toBe(true); + expect(isLocalOrPrivateNetworkHost('172.16.0.1')).toBe(true); + expect(isLocalOrPrivateNetworkHost('172.31.255.255')).toBe(true); + expect(isLocalOrPrivateNetworkHost('192.168.1.100')).toBe(true); + expect(isLocalOrPrivateNetworkHost('169.254.10.20')).toBe(true); + expect(isLocalOrPrivateNetworkHost('100.64.0.1')).toBe(true); + expect(isLocalOrPrivateNetworkHost('100.116.244.64')).toBe(true); + expect(isLocalOrPrivateNetworkHost('100.127.255.254')).toBe(true); + }); + + it('allows private IPv6 ranges', () => { + expect(isLocalOrPrivateNetworkHost('fc00::1')).toBe(true); + expect(isLocalOrPrivateNetworkHost('fd12:3456::1')).toBe(true); + expect(isLocalOrPrivateNetworkHost('fe80::1')).toBe(true); + }); + + it('rejects public hosts and invalid IPv4 addresses', () => { + expect(isLocalOrPrivateNetworkHost('example.com')).toBe(false); + expect(isLocalOrPrivateNetworkHost('8.8.8.8')).toBe(false); + expect(isLocalOrPrivateNetworkHost('100.128.0.1')).toBe(false); + expect(isLocalOrPrivateNetworkHost('256.1.1.1')).toBe(false); + }); + }); + + describe('isAllowedCloudRpcUrl', () => { + it('allows HTTPS cloud URLs on public hosts', () => { + expect(isAllowedCloudRpcUrl('https://core.example.com/rpc')).toBe(true); + }); + + it('allows HTTP only for local and private-network core URLs', () => { + expect(isAllowedCloudRpcUrl('http://127.0.0.1:7788/rpc')).toBe(true); + expect(isAllowedCloudRpcUrl('http://192.168.1.100:7788/rpc')).toBe(true); + expect(isAllowedCloudRpcUrl('http://100.116.244.64:7788/rpc')).toBe(true); + }); + + it('rejects public HTTP cloud URLs', () => { + expect(isAllowedCloudRpcUrl('http://core.example.com/rpc')).toBe(false); + expect(isAllowedCloudRpcUrl('http://8.8.8.8:7788/rpc')).toBe(false); + }); + }); + describe('normalizeRpcUrl — edge cases', () => { it('does not add /rpc suffix when missing (normalizeRpcUrl only strips, not appends)', () => { expect(normalizeRpcUrl('http://127.0.0.1:7788')).toBe('http://127.0.0.1:7788'); diff --git a/app/src/utils/configPersistence.ts b/app/src/utils/configPersistence.ts index 2feaf1564..4a222bc8e 100644 --- a/app/src/utils/configPersistence.ts +++ b/app/src/utils/configPersistence.ts @@ -124,6 +124,54 @@ export function isValidRpcUrl(url: string): boolean { } } +/** + * Return true when `hostname` is local or private-network address space. + * + * This intentionally includes Tailscale/CGNAT (`100.64.0.0/10`): self-hosted + * cores often run on tailnets where the transport is already encrypted and + * the HTTP service is not exposed to the public internet. + */ +export function isLocalOrPrivateNetworkHost(hostname: string): boolean { + const host = hostname + .trim() + .replace(/^\[(.*)\]$/, '$1') + .toLowerCase(); + if (!host) return false; + if (host === 'localhost' || host.endsWith('.localhost')) return true; + if (host === '::1') return true; + if (host.startsWith('fe80:')) return true; + if (/^f[cd][0-9a-f]{2}:/i.test(host)) return true; + + const match = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); + if (!match) return false; + + const octets = match.slice(1).map(Number); + if (octets.some(octet => octet < 0 || octet > 255)) return false; + + const [a, b] = octets; + return ( + a === 10 || + a === 127 || + (a === 172 && b >= 16 && b <= 31) || + (a === 192 && b === 168) || + (a === 169 && b === 254) || + (a === 100 && b >= 64 && b <= 127) + ); +} + +/** + * Cloud cores may use HTTPS on any host. Plain HTTP is accepted only for + * localhost/private networks, including tailnets, to avoid encouraging + * bearer-token transport over public plaintext links. + */ +export function isAllowedCloudRpcUrl(url: string): boolean { + if (!isValidRpcUrl(url)) return false; + + const parsed = new URL(url.trim()); + if (parsed.protocol === 'https:') return true; + return parsed.protocol === 'http:' && isLocalOrPrivateNetworkHost(parsed.hostname); +} + /** * Normalize an RPC URL by trimming whitespace and trailing slashes. * diff --git a/gitbooks/features/cloud-deploy.md b/gitbooks/features/cloud-deploy.md index 56b607ba0..7c2d455ae 100644 --- a/gitbooks/features/cloud-deploy.md +++ b/gitbooks/features/cloud-deploy.md @@ -20,9 +20,12 @@ This guide covers three deploy paths, easiest first: 3. [Any VPS via Docker Compose](#3-any-vps-via-docker-compose) What gets deployed in every path: a single container running -`openhuman-core serve` on port `7788`, behind the provider's TLS. The desktop -app already knows how to talk to a remote core, set -`OPENHUMAN_CORE_RPC_URL=https://your-host/rpc` and `OPENHUMAN_CORE_TOKEN=...` +`openhuman-core serve` on port `7788`. Public hosts should sit behind the +provider's TLS, for example `https://core.example.com/rpc`. Private-only hosts +on localhost, RFC1918 networks, or tailnets such as Tailscale can use +plain HTTP, for example `http://100.x.x.x:7788/rpc`, when the core is not +reachable from the public internet. The desktop app already knows how to talk +to a remote core; set `OPENHUMAN_CORE_RPC_URL` and `OPENHUMAN_CORE_TOKEN=...` in `app/.env.local` and launch. --- @@ -337,8 +340,17 @@ OPENHUMAN_CORE_RPC_URL=https://core.example.com/rpc OPENHUMAN_CORE_TOKEN= ``` +For a private tailnet-only VM with no public IP, use the tailnet URL instead: + +```bash +OPENHUMAN_CORE_RUN_MODE=external +OPENHUMAN_CORE_RPC_URL=http://100.x.x.x:7788/rpc +OPENHUMAN_CORE_TOKEN= +``` + Restart the desktop app. The provider chain in `App.tsx` will route all RPC -calls to the remote core; nothing else changes. +calls to the remote core; nothing else changes. Public `http://` hosts are +rejected by the app picker; use HTTPS for any publicly reachable core. --- diff --git a/src/openhuman/memory/tree/ingest.rs b/src/openhuman/memory/tree/ingest.rs index 594bfebbb..562e3a42e 100644 --- a/src/openhuman/memory/tree/ingest.rs +++ b/src/openhuman/memory/tree/ingest.rs @@ -368,7 +368,7 @@ fn markdown_body_preview(md: &str) -> String { if len <= BODY_PREVIEW_MAX_BYTES { md.to_string() } else { - let start = md.ceil_char_boundary(len - BODY_PREVIEW_MAX_BYTES); + let start = crate::openhuman::util::ceil_char_boundary(md, len - BODY_PREVIEW_MAX_BYTES); md[start..].to_string() } } diff --git a/src/openhuman/tools/impl/browser/screenshot.rs b/src/openhuman/tools/impl/browser/screenshot.rs index 5bd38cc1b..2f73825dd 100644 --- a/src/openhuman/tools/impl/browser/screenshot.rs +++ b/src/openhuman/tools/impl/browser/screenshot.rs @@ -152,7 +152,10 @@ impl ScreenshotTool { let size = bytes.len(); let mut encoded = base64::engine::general_purpose::STANDARD.encode(&bytes); let truncated = if encoded.len() > MAX_BASE64_BYTES { - encoded.truncate(encoded.floor_char_boundary(MAX_BASE64_BYTES)); + encoded.truncate(crate::openhuman::util::floor_char_boundary( + &encoded, + MAX_BASE64_BYTES, + )); true } else { false diff --git a/src/openhuman/tools/impl/system/node_exec.rs b/src/openhuman/tools/impl/system/node_exec.rs index de1e8b70f..6c0565853 100644 --- a/src/openhuman/tools/impl/system/node_exec.rs +++ b/src/openhuman/tools/impl/system/node_exec.rs @@ -223,11 +223,17 @@ impl Tool for NodeExecTool { let mut stderr = String::from_utf8_lossy(&output.stderr).to_string(); if stdout.len() > MAX_OUTPUT_BYTES { - stdout.truncate(stdout.floor_char_boundary(MAX_OUTPUT_BYTES)); + stdout.truncate(crate::openhuman::util::floor_char_boundary( + &stdout, + MAX_OUTPUT_BYTES, + )); stdout.push_str("\n... [stdout truncated at 1MB]"); } if stderr.len() > MAX_OUTPUT_BYTES { - stderr.truncate(stderr.floor_char_boundary(MAX_OUTPUT_BYTES)); + stderr.truncate(crate::openhuman::util::floor_char_boundary( + &stderr, + MAX_OUTPUT_BYTES, + )); stderr.push_str("\n... [stderr truncated at 1MB]"); } diff --git a/src/openhuman/tools/impl/system/npm_exec.rs b/src/openhuman/tools/impl/system/npm_exec.rs index 2a703f174..4e5e9bda9 100644 --- a/src/openhuman/tools/impl/system/npm_exec.rs +++ b/src/openhuman/tools/impl/system/npm_exec.rs @@ -235,11 +235,17 @@ impl Tool for NpmExecTool { let mut stderr = String::from_utf8_lossy(&output.stderr).to_string(); if stdout.len() > MAX_OUTPUT_BYTES { - stdout.truncate(stdout.floor_char_boundary(MAX_OUTPUT_BYTES)); + stdout.truncate(crate::openhuman::util::floor_char_boundary( + &stdout, + MAX_OUTPUT_BYTES, + )); stdout.push_str("\n... [stdout truncated at 1MB]"); } if stderr.len() > MAX_OUTPUT_BYTES { - stderr.truncate(stderr.floor_char_boundary(MAX_OUTPUT_BYTES)); + stderr.truncate(crate::openhuman::util::floor_char_boundary( + &stderr, + MAX_OUTPUT_BYTES, + )); stderr.push_str("\n... [stderr truncated at 1MB]"); } diff --git a/src/openhuman/tools/impl/system/shell.rs b/src/openhuman/tools/impl/system/shell.rs index 2aa2be663..1c0ed9afb 100644 --- a/src/openhuman/tools/impl/system/shell.rs +++ b/src/openhuman/tools/impl/system/shell.rs @@ -175,11 +175,17 @@ impl Tool for ShellTool { // Truncate output to prevent OOM if stdout.len() > MAX_OUTPUT_BYTES { - stdout.truncate(stdout.floor_char_boundary(MAX_OUTPUT_BYTES)); + stdout.truncate(crate::openhuman::util::floor_char_boundary( + &stdout, + MAX_OUTPUT_BYTES, + )); stdout.push_str("\n... [output truncated at 1MB]"); } if stderr.len() > MAX_OUTPUT_BYTES { - stderr.truncate(stderr.floor_char_boundary(MAX_OUTPUT_BYTES)); + stderr.truncate(crate::openhuman::util::floor_char_boundary( + &stderr, + MAX_OUTPUT_BYTES, + )); stderr.push_str("\n... [stderr truncated at 1MB]"); } diff --git a/src/openhuman/tree_summarizer/engine.rs b/src/openhuman/tree_summarizer/engine.rs index b271fdf2d..8398604e5 100644 --- a/src/openhuman/tree_summarizer/engine.rs +++ b/src/openhuman/tree_summarizer/engine.rs @@ -408,7 +408,8 @@ async fn summarize_to_limit( char_limit ); // Truncate at a char boundary - let truncated = &response[..response.floor_char_boundary(char_limit)]; + let truncated = + &response[..crate::openhuman::util::floor_char_boundary(&response, char_limit)]; truncated.to_string() } else { response diff --git a/src/openhuman/util.rs b/src/openhuman/util.rs index 313b982a9..d7e32103f 100644 --- a/src/openhuman/util.rs +++ b/src/openhuman/util.rs @@ -79,6 +79,18 @@ pub fn floor_char_boundary(s: &str, index: usize) -> usize { end } +/// Round a byte index UP to the nearest UTF-8 character boundary. +pub fn ceil_char_boundary(s: &str, index: usize) -> usize { + if index >= s.len() { + return s.len(); + } + let mut start = index; + while start < s.len() && !s.is_char_boundary(start) { + start += 1; + } + start +} + /// Utility enum for handling optional values. pub enum MaybeSet { Set(T), @@ -215,6 +227,19 @@ mod tests { assert_eq!(floor_char_boundary(s, 100), 6); } + #[test] + fn test_ceil_char_boundary() { + let s = "A🦀C"; + assert_eq!(ceil_char_boundary(s, 0), 0); + assert_eq!(ceil_char_boundary(s, 1), 1); // After 'A' + assert_eq!(ceil_char_boundary(s, 2), 5); // Mid-🦀 + assert_eq!(ceil_char_boundary(s, 3), 5); // Mid-🦀 + assert_eq!(ceil_char_boundary(s, 4), 5); // Mid-🦀 + assert_eq!(ceil_char_boundary(s, 5), 5); // After '🦀' + assert_eq!(ceil_char_boundary(s, 6), 6); // After 'C' + assert_eq!(ceil_char_boundary(s, 100), 6); + } + #[test] fn test_truncate_with_suffix() { let s = "Hello World";