mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-19 13:33:35 +08:00
H5 远程访问的三处不稳定来源修复,让手机出门在外也能稳定连接、长任务不丢。 #767 令牌与端口固定: - 令牌明文持久化到 cc-haha/settings.json,重启后二维码/令牌随时可查、可复制; enable 复用现有令牌、disable 保留令牌,仅 regenerate 才轮换。手改 token 字段 即自定义固定令牌。完整令牌只经 local-trusted 面返回,远端 403。 - 新增可选固定端口 fixedPort,并在未配置时复用上次端口(desktop-server-state.json sticky,Electron/Tauri 双壳共享),反向代理/手机书签跨重启不失效;占用时回退随机。 #764 断连不杀正在运行的 CLI: - 客户端断开时若该会话仍在跑一轮任务,不再 30s 后强杀子进程,而是等任务跑完; 手机锁屏/切后台时长任务在后台跑完,重连即见结果。 - 空闲清理超时改为可配 disconnectGraceSeconds(H5 访问设置页,默认 30s), 经 disconnectGraceConfig 同步缓存供 close 处理读取。 server / Electron / Tauri / React 四层 + 五语言 i18n + 配套单测全部覆盖。
1033 lines
33 KiB
TypeScript
1033 lines
33 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
|
import * as fs from 'node:fs/promises'
|
|
import { createServer } from 'node:net'
|
|
import * as os from 'node:os'
|
|
import * as path from 'node:path'
|
|
import { startServer } from '../index.js'
|
|
import { H5AccessService } from '../services/h5AccessService.js'
|
|
import { ProviderService } from '../services/providerService.js'
|
|
|
|
let server: ReturnType<typeof Bun.serve> | undefined
|
|
let baseUrl = ''
|
|
let wsBaseUrl = ''
|
|
let lanBaseUrl = ''
|
|
let lanWsBaseUrl = ''
|
|
let tmpDir = ''
|
|
let originalConfigDir: string | undefined
|
|
let originalAnthropicApiKey: string | undefined
|
|
let originalH5DistDir: string | undefined
|
|
let originalClaudeAppRoot: string | undefined
|
|
let originalServerAuthRequired: string | undefined
|
|
let originalServerPort = 3456
|
|
const PHONE_ORIGIN = 'https://phone.example'
|
|
|
|
async function waitForServer(url: string): Promise<void> {
|
|
for (let attempt = 0; attempt < 50; attempt += 1) {
|
|
try {
|
|
const response = await fetch(url)
|
|
if (response.ok) {
|
|
return
|
|
}
|
|
} catch {}
|
|
|
|
await Bun.sleep(50)
|
|
}
|
|
|
|
throw new Error(`Timed out waiting for server at ${url}`)
|
|
}
|
|
|
|
async function availablePort(): Promise<number> {
|
|
return await new Promise((resolve, reject) => {
|
|
const probe = createServer()
|
|
probe.once('error', reject)
|
|
probe.listen(0, '0.0.0.0', () => {
|
|
const address = probe.address()
|
|
if (!address || typeof address === 'string') {
|
|
probe.close(() => reject(new Error('Failed to allocate an H5 access test port')))
|
|
return
|
|
}
|
|
const port = address.port
|
|
probe.close(() => resolve(port))
|
|
})
|
|
})
|
|
}
|
|
|
|
function resolvePrivateLanBaseUrl(port: number): string | null {
|
|
for (const entries of Object.values(os.networkInterfaces())) {
|
|
for (const entry of entries ?? []) {
|
|
if (entry.family !== 'IPv4' || entry.internal) {
|
|
continue
|
|
}
|
|
|
|
if (
|
|
entry.address.startsWith('10.') ||
|
|
entry.address.startsWith('192.168.') ||
|
|
/^172\.(1[6-9]|2\d|3[0-1])\./.test(entry.address)
|
|
) {
|
|
return `http://${entry.address}:${port}`
|
|
}
|
|
}
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
async function startRemoteServer(options: { authRequired?: boolean } = {}): Promise<void> {
|
|
if (options.authRequired) {
|
|
process.env.SERVER_AUTH_REQUIRED = '1'
|
|
} else {
|
|
delete process.env.SERVER_AUTH_REQUIRED
|
|
}
|
|
|
|
const port = await availablePort()
|
|
server = startServer(port, '0.0.0.0')
|
|
baseUrl = `http://127.0.0.1:${port}`
|
|
wsBaseUrl = `ws://127.0.0.1:${port}`
|
|
lanBaseUrl = resolvePrivateLanBaseUrl(port) ?? ''
|
|
lanWsBaseUrl = lanBaseUrl.replace(/^http/, 'ws')
|
|
await waitForServer(`${baseUrl}/health`)
|
|
}
|
|
|
|
async function restartRemoteServer(options: { authRequired?: boolean } = {}): Promise<void> {
|
|
server?.stop(true)
|
|
server = undefined
|
|
await startRemoteServer(options)
|
|
}
|
|
|
|
function makeUpgradeHeaders(origin?: string): HeadersInit {
|
|
return {
|
|
Connection: 'Upgrade',
|
|
Upgrade: 'websocket',
|
|
...(origin ? { Origin: origin } : {}),
|
|
}
|
|
}
|
|
|
|
function spoofedLoopbackHeaders(port: string): Record<string, string> {
|
|
return {
|
|
Host: `127.0.0.1:${port}`,
|
|
Origin: 'http://127.0.0.1:5179',
|
|
}
|
|
}
|
|
|
|
function localFileUrl(base: string, absPath: string): string {
|
|
const normalized = absPath.replace(/\\/g, '/')
|
|
const rooted = normalized.startsWith('/') ? normalized : `/${normalized}`
|
|
const encoded = rooted
|
|
.split('/')
|
|
.map((segment) => encodeURIComponent(segment))
|
|
.join('/')
|
|
return `${base}/local-file${encoded}`
|
|
}
|
|
|
|
async function enableH5Access(options: {
|
|
allowedOrigins?: string[]
|
|
publicBaseUrl?: string | null
|
|
} = {}): Promise<string> {
|
|
const service = new H5AccessService()
|
|
if (options.allowedOrigins || options.publicBaseUrl !== undefined) {
|
|
await service.updateSettings({
|
|
allowedOrigins: options.allowedOrigins,
|
|
publicBaseUrl: options.publicBaseUrl,
|
|
})
|
|
}
|
|
const { token } = await service.enable()
|
|
if (options.allowedOrigins || options.publicBaseUrl !== undefined) {
|
|
await service.updateSettings({
|
|
allowedOrigins: options.allowedOrigins,
|
|
publicBaseUrl: options.publicBaseUrl,
|
|
})
|
|
}
|
|
return token
|
|
}
|
|
|
|
function expectWebSocketOpen(url: string): Promise<void> {
|
|
return new Promise((resolve, reject) => {
|
|
const ws = new WebSocket(url)
|
|
const timeout = setTimeout(() => {
|
|
ws.close()
|
|
reject(new Error(`Timed out opening websocket: ${url}`))
|
|
}, 5000)
|
|
|
|
ws.addEventListener('open', () => {
|
|
clearTimeout(timeout)
|
|
ws.close()
|
|
resolve()
|
|
})
|
|
|
|
ws.addEventListener('error', () => {
|
|
clearTimeout(timeout)
|
|
reject(new Error(`WebSocket failed to open: ${url}`))
|
|
})
|
|
})
|
|
}
|
|
|
|
function expectWebSocketUpgradeThenClose(url: string): Promise<void> {
|
|
return new Promise((resolve, reject) => {
|
|
const ws = new WebSocket(url)
|
|
let opened = false
|
|
const timeout = setTimeout(() => {
|
|
ws.close()
|
|
reject(new Error(`Timed out waiting for websocket close: ${url}`))
|
|
}, 5000)
|
|
|
|
ws.addEventListener('open', () => {
|
|
opened = true
|
|
})
|
|
|
|
ws.addEventListener('close', () => {
|
|
clearTimeout(timeout)
|
|
if (opened) {
|
|
resolve()
|
|
} else {
|
|
reject(new Error(`WebSocket closed before upgrade completed: ${url}`))
|
|
}
|
|
})
|
|
|
|
ws.addEventListener('error', () => {
|
|
clearTimeout(timeout)
|
|
reject(new Error(`WebSocket failed before upgrade completed: ${url}`))
|
|
})
|
|
})
|
|
}
|
|
|
|
const settingsSurfaceEndpoints = [
|
|
{ path: '/api/mcp', expected: { servers: [] } },
|
|
{ path: '/api/plugins', expected: { plugins: [] } },
|
|
{ path: '/api/agents', expectedKey: 'activeAgents' },
|
|
] as const
|
|
|
|
beforeEach(async () => {
|
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'h5-access-auth-test-'))
|
|
originalConfigDir = process.env.CLAUDE_CONFIG_DIR
|
|
originalAnthropicApiKey = process.env.ANTHROPIC_API_KEY
|
|
originalH5DistDir = process.env.CLAUDE_H5_DIST_DIR
|
|
originalClaudeAppRoot = process.env.CLAUDE_APP_ROOT
|
|
originalServerAuthRequired = process.env.SERVER_AUTH_REQUIRED
|
|
originalServerPort = ProviderService.getServerPort()
|
|
process.env.CLAUDE_CONFIG_DIR = tmpDir
|
|
const h5DistDir = path.join(tmpDir, 'dist')
|
|
process.env.CLAUDE_H5_DIST_DIR = h5DistDir
|
|
delete process.env.ANTHROPIC_API_KEY
|
|
await fs.mkdir(path.join(h5DistDir, 'assets'), { recursive: true })
|
|
await fs.writeFile(
|
|
path.join(h5DistDir, 'index.html'),
|
|
'<!doctype html><html><head><script type="module" src="/assets/app.js"></script></head><body>H5 Shell</body></html>',
|
|
'utf-8',
|
|
)
|
|
await fs.writeFile(path.join(h5DistDir, 'assets/app.js'), 'window.__h5 = true', 'utf-8')
|
|
await startRemoteServer()
|
|
})
|
|
|
|
afterEach(async () => {
|
|
server?.stop(true)
|
|
server = undefined
|
|
ProviderService.setServerPort(originalServerPort)
|
|
|
|
if (originalConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR
|
|
else process.env.CLAUDE_CONFIG_DIR = originalConfigDir
|
|
|
|
if (originalAnthropicApiKey === undefined) delete process.env.ANTHROPIC_API_KEY
|
|
else process.env.ANTHROPIC_API_KEY = originalAnthropicApiKey
|
|
if (originalH5DistDir === undefined) delete process.env.CLAUDE_H5_DIST_DIR
|
|
else process.env.CLAUDE_H5_DIST_DIR = originalH5DistDir
|
|
if (originalClaudeAppRoot === undefined) delete process.env.CLAUDE_APP_ROOT
|
|
else process.env.CLAUDE_APP_ROOT = originalClaudeAppRoot
|
|
if (originalServerAuthRequired === undefined) delete process.env.SERVER_AUTH_REQUIRED
|
|
else process.env.SERVER_AUTH_REQUIRED = originalServerAuthRequired
|
|
|
|
await fs.rm(tmpDir, { recursive: true, force: true })
|
|
})
|
|
|
|
describe('remote H5 auth and CORS integration', () => {
|
|
test('serves the packaged H5 shell and static assets from the remote server', async () => {
|
|
const shellResponse = await fetch(`${baseUrl}/`)
|
|
expect(shellResponse.status).toBe(200)
|
|
expect(shellResponse.headers.get('Content-Type')).toContain('text/html')
|
|
await expect(shellResponse.text()).resolves.toContain('H5 Shell')
|
|
|
|
const assetResponse = await fetch(`${baseUrl}/assets/app.js`)
|
|
expect(assetResponse.status).toBe(200)
|
|
expect(assetResponse.headers.get('Cache-Control')).toContain('immutable')
|
|
await expect(assetResponse.text()).resolves.toContain('window.__h5')
|
|
})
|
|
|
|
test('finds legacy packaged H5 resources under Resources/_up_/dist', async () => {
|
|
const appRoot = path.join(tmpDir, 'Fake.app', 'Contents', 'MacOS')
|
|
const mappedDistDir = path.join(tmpDir, 'Fake.app', 'Contents', 'Resources', '_up_', 'dist')
|
|
delete process.env.CLAUDE_H5_DIST_DIR
|
|
process.env.CLAUDE_APP_ROOT = appRoot
|
|
|
|
await fs.mkdir(mappedDistDir, { recursive: true })
|
|
await fs.writeFile(path.join(mappedDistDir, 'index.html'), 'Mapped H5 Shell', 'utf-8')
|
|
|
|
const response = await fetch(`${baseUrl}/`)
|
|
|
|
expect(response.status).toBe(200)
|
|
await expect(response.text()).resolves.toContain('Mapped H5 Shell')
|
|
})
|
|
|
|
test('finds Electron packaged H5 resources from app.asar.unpacked when the sidecar points at app.asar', async () => {
|
|
const asarDistDir = path.join(tmpDir, 'Fake.app', 'Contents', 'Resources', 'app.asar', 'dist')
|
|
const unpackedDistDir = path.join(tmpDir, 'Fake.app', 'Contents', 'Resources', 'app.asar.unpacked', 'dist')
|
|
process.env.CLAUDE_H5_DIST_DIR = asarDistDir
|
|
|
|
await fs.mkdir(unpackedDistDir, { recursive: true })
|
|
await fs.writeFile(path.join(unpackedDistDir, 'index.html'), 'Electron H5 Shell', 'utf-8')
|
|
|
|
const response = await fetch(`${baseUrl}/`)
|
|
|
|
expect(response.status).toBe(200)
|
|
await expect(response.text()).resolves.toContain('Electron H5 Shell')
|
|
})
|
|
|
|
test('allows /api/status by default without H5 token or Anthropic key', async () => {
|
|
const response = await fetch(`${baseUrl}/api/status`)
|
|
|
|
expect(response.status).toBe(200)
|
|
await expect(response.json()).resolves.toMatchObject({
|
|
status: 'ok',
|
|
})
|
|
})
|
|
|
|
test('allows loopback browser capability requests while H5 access is disabled', async () => {
|
|
for (const origin of ['http://127.0.0.1:2024', 'http://localhost:5179', 'http://[::1]:5173']) {
|
|
const response = await fetch(`${baseUrl}/api/status`, {
|
|
headers: { Origin: origin },
|
|
})
|
|
|
|
expect(response.status).toBe(200)
|
|
await expect(response.json()).resolves.toMatchObject({
|
|
status: 'ok',
|
|
})
|
|
}
|
|
})
|
|
|
|
test('does not keep retired Tauri origins trusted after Electron replacement', async () => {
|
|
const response = await fetch(`${baseUrl}/api/status`, {
|
|
headers: {
|
|
Origin: 'http://tauri.localhost',
|
|
},
|
|
})
|
|
|
|
expect(response.status).toBe(403)
|
|
await expect(response.json()).resolves.toMatchObject({
|
|
error: 'Forbidden',
|
|
})
|
|
})
|
|
|
|
test('blocks remote browser capability requests while H5 access is disabled', async () => {
|
|
const apiResponse = await fetch(`${baseUrl}/api/status`, {
|
|
headers: {
|
|
Origin: PHONE_ORIGIN,
|
|
},
|
|
})
|
|
expect(apiResponse.status).toBe(403)
|
|
await expect(apiResponse.json()).resolves.toMatchObject({
|
|
error: 'Forbidden',
|
|
})
|
|
|
|
const proxyResponse = await fetch(`${baseUrl}/proxy/openai/v1/chat/completions`, {
|
|
method: 'POST',
|
|
headers: {
|
|
Origin: PHONE_ORIGIN,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({ model: 'test', messages: [] }),
|
|
})
|
|
expect(proxyResponse.status).toBe(403)
|
|
|
|
const wsResponse = await fetch(`${baseUrl}/ws/h5-auth-test`, {
|
|
headers: makeUpgradeHeaders(PHONE_ORIGIN),
|
|
})
|
|
expect(wsResponse.status).toBe(403)
|
|
})
|
|
|
|
test('blocks remote browser local-file and preview-fs requests while H5 access is disabled', async () => {
|
|
const localFileResponse = await fetch(localFileUrl(baseUrl, path.join(tmpDir, 'dist', 'index.html')), {
|
|
headers: {
|
|
Origin: PHONE_ORIGIN,
|
|
},
|
|
})
|
|
expect(localFileResponse.status).toBe(403)
|
|
|
|
const previewResponse = await fetch(`${baseUrl}/preview-fs/h5-auth-test/index.html`, {
|
|
headers: {
|
|
Origin: PHONE_ORIGIN,
|
|
},
|
|
})
|
|
expect(previewResponse.status).toBe(403)
|
|
})
|
|
|
|
test('allows loopback browser local-file and preview-fs requests through the H5 gate while H5 access is disabled', async () => {
|
|
const loopbackBrowserOrigin = 'http://localhost:5173'
|
|
const localFileResponse = await fetch(localFileUrl(baseUrl, path.join(process.cwd(), 'package.json')), {
|
|
headers: {
|
|
Origin: loopbackBrowserOrigin,
|
|
},
|
|
})
|
|
expect(localFileResponse.status).toBe(200)
|
|
|
|
const previewResponse = await fetch(`${baseUrl}/preview-fs/h5-auth-test/index.html`, {
|
|
headers: {
|
|
Origin: loopbackBrowserOrigin,
|
|
},
|
|
})
|
|
expect(previewResponse.status).not.toBe(401)
|
|
expect(previewResponse.status).not.toBe(403)
|
|
})
|
|
|
|
test('blocks remote browser SDK requests while H5 access is disabled', async () => {
|
|
const response = await fetch(`${baseUrl}/sdk/h5-auth-test`, {
|
|
headers: makeUpgradeHeaders(PHONE_ORIGIN),
|
|
})
|
|
|
|
expect(response.status).toBe(403)
|
|
await expect(response.json()).resolves.toMatchObject({
|
|
error: 'Forbidden',
|
|
})
|
|
})
|
|
|
|
test('blocks remote preflight requests to capability routes while H5 access is disabled', async () => {
|
|
const response = await fetch(`${baseUrl}/api/status`, {
|
|
method: 'OPTIONS',
|
|
headers: {
|
|
Origin: PHONE_ORIGIN,
|
|
'Access-Control-Request-Method': 'GET',
|
|
},
|
|
})
|
|
|
|
expect(response.status).toBe(403)
|
|
expect(response.headers.get('Access-Control-Allow-Origin')).toBeNull()
|
|
})
|
|
|
|
test('blocks same-origin LAN capability requests while H5 access is disabled when a LAN interface is available', async () => {
|
|
if (!lanBaseUrl) {
|
|
return
|
|
}
|
|
|
|
const apiResponse = await fetch(`${lanBaseUrl}/api/status`)
|
|
|
|
expect(apiResponse.status).toBe(403)
|
|
await expect(apiResponse.json()).resolves.toMatchObject({
|
|
error: 'Forbidden',
|
|
message: 'H5 access is disabled. Enable H5 access from the local desktop app first.',
|
|
})
|
|
|
|
const proxyResponse = await fetch(`${lanBaseUrl}/proxy/v1/messages`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({ model: 'test', messages: [] }),
|
|
})
|
|
expect(proxyResponse.status).toBe(403)
|
|
await expect(proxyResponse.json()).resolves.toMatchObject({
|
|
error: 'Forbidden',
|
|
message: 'H5 access is disabled. Enable H5 access from the local desktop app first.',
|
|
})
|
|
|
|
const wsResponse = await fetch(`${lanBaseUrl}/ws/h5-auth-test`, {
|
|
headers: makeUpgradeHeaders(),
|
|
})
|
|
expect(wsResponse.status).toBe(403)
|
|
await expect(wsResponse.json()).resolves.toMatchObject({
|
|
error: 'Forbidden',
|
|
message: 'H5 access is disabled. Enable H5 access from the local desktop app first.',
|
|
})
|
|
})
|
|
|
|
test('does not trust spoofed localhost Host and Origin headers from LAN clients while H5 access is disabled', async () => {
|
|
if (!lanBaseUrl) {
|
|
return
|
|
}
|
|
|
|
const spoofedHeaders = spoofedLoopbackHeaders(new URL(lanBaseUrl).port)
|
|
|
|
const apiResponse = await fetch(`${lanBaseUrl}/api/status`, {
|
|
headers: spoofedHeaders,
|
|
})
|
|
if (apiResponse.status === 200) {
|
|
// Some local stacks route a request to the machine's own LAN IP as a
|
|
// loopback peer. In that case this test cannot simulate a distinct LAN
|
|
// client; the policy-level spoof regression still covers that boundary.
|
|
return
|
|
}
|
|
expect(apiResponse.status).toBe(403)
|
|
|
|
const proxyResponse = await fetch(`${lanBaseUrl}/proxy/v1/messages`, {
|
|
method: 'POST',
|
|
headers: {
|
|
...spoofedHeaders,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({ model: 'test', messages: [] }),
|
|
})
|
|
expect(proxyResponse.status).toBe(403)
|
|
|
|
const wsResponse = await fetch(`${lanBaseUrl}/ws/h5-auth-test`, {
|
|
headers: {
|
|
...makeUpgradeHeaders(spoofedHeaders.Origin),
|
|
Host: spoofedHeaders.Host,
|
|
},
|
|
})
|
|
expect(wsResponse.status).toBe(403)
|
|
|
|
const controlResponse = await fetch(`${lanBaseUrl}/api/h5-access/enable`, {
|
|
method: 'POST',
|
|
headers: spoofedHeaders,
|
|
})
|
|
expect(controlResponse.status).toBe(403)
|
|
})
|
|
|
|
test('keeps local loopback SDK requests tokenless while H5 access is disabled', async () => {
|
|
await expectWebSocketUpgradeThenClose(`${wsBaseUrl}/sdk/h5-auth-test`)
|
|
})
|
|
|
|
test('keeps local loopback adapter requests tokenless while H5 access is disabled', async () => {
|
|
const response = await fetch(`${baseUrl}/api/adapters`)
|
|
|
|
expect(response.status).toBe(200)
|
|
await expect(response.json()).resolves.toEqual({})
|
|
})
|
|
|
|
test('keeps local loopback settings surface requests tokenless while H5 access is disabled', async () => {
|
|
for (const endpoint of settingsSurfaceEndpoints) {
|
|
const response = await fetch(`${baseUrl}${endpoint.path}`)
|
|
|
|
expect(response.status).toBe(200)
|
|
}
|
|
})
|
|
|
|
test('lets explicitly authenticated deployments use remote capability routes while H5 access is disabled', async () => {
|
|
await restartRemoteServer({ authRequired: true })
|
|
process.env.ANTHROPIC_API_KEY = 'test-server-key'
|
|
|
|
const missingResponse = await fetch(`${baseUrl}/api/status`, {
|
|
headers: {
|
|
Origin: PHONE_ORIGIN,
|
|
},
|
|
})
|
|
expect(missingResponse.status).toBe(401)
|
|
|
|
const validResponse = await fetch(`${baseUrl}/api/status`, {
|
|
headers: {
|
|
Origin: PHONE_ORIGIN,
|
|
Authorization: 'Bearer test-server-key',
|
|
},
|
|
})
|
|
expect(validResponse.status).toBe(200)
|
|
})
|
|
|
|
test('keeps /api/status open by default even when a stale bearer token is sent', async () => {
|
|
await enableH5Access()
|
|
|
|
const response = await fetch(`${baseUrl}/api/status`, {
|
|
headers: {
|
|
Authorization: 'Bearer wrong-token',
|
|
},
|
|
})
|
|
|
|
expect(response.status).toBe(200)
|
|
await expect(response.json()).resolves.toMatchObject({
|
|
status: 'ok',
|
|
})
|
|
})
|
|
|
|
test('allows /api/status with a bearer token while default auth is open', async () => {
|
|
const token = await enableH5Access()
|
|
|
|
const response = await fetch(`${baseUrl}/api/status`, {
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
},
|
|
})
|
|
|
|
expect(response.status).toBe(200)
|
|
await expect(response.json()).resolves.toMatchObject({
|
|
status: 'ok',
|
|
})
|
|
})
|
|
|
|
test('rejects arbitrary CORS origins when H5 access is enabled', async () => {
|
|
await enableH5Access({
|
|
allowedOrigins: ['https://allowed.example.com'],
|
|
})
|
|
|
|
const response = await fetch(`${baseUrl}/api/status`, {
|
|
method: 'OPTIONS',
|
|
headers: {
|
|
...makeUpgradeHeaders('https://blocked.example.com'),
|
|
'Access-Control-Request-Method': 'GET',
|
|
},
|
|
})
|
|
|
|
expect(response.status).toBe(403)
|
|
})
|
|
|
|
test('blocks remote browsers from enabling H5 access before the local desktop opts in', async () => {
|
|
const response = await fetch(`${baseUrl}/api/h5-access/enable`, {
|
|
method: 'POST',
|
|
headers: {
|
|
Origin: PHONE_ORIGIN,
|
|
},
|
|
})
|
|
|
|
expect(response.status).toBe(403)
|
|
await expect(response.json()).resolves.toMatchObject({
|
|
error: 'Forbidden',
|
|
})
|
|
})
|
|
|
|
test('keeps the recoverable token readable for loopback but blocked for remote browsers', async () => {
|
|
const h5AccessService = new H5AccessService()
|
|
const { token } = await h5AccessService.enable()
|
|
|
|
// Local desktop (loopback, no foreign Origin) can recover the full token
|
|
// at any time — this is what keeps the QR code alive across restarts.
|
|
const localResponse = await fetch(`${baseUrl}/api/h5-access`)
|
|
expect(localResponse.status).toBe(200)
|
|
const localPayload = await localResponse.json() as { settings: { token: string | null } }
|
|
expect(localPayload.settings.token).toBe(token)
|
|
|
|
// A remote H5 browser must never reach the settings surface, even with
|
|
// the valid token: the control plane stays local-only.
|
|
const remoteResponse = await fetch(`${baseUrl}/api/h5-access`, {
|
|
headers: {
|
|
Origin: PHONE_ORIGIN,
|
|
Authorization: `Bearer ${token}`,
|
|
},
|
|
})
|
|
expect(remoteResponse.status).toBe(403)
|
|
|
|
await h5AccessService.disable()
|
|
})
|
|
|
|
test('blocks remote preflight requests to the local H5 access control plane', async () => {
|
|
const response = await fetch(`${baseUrl}/api/h5-access/enable`, {
|
|
method: 'OPTIONS',
|
|
headers: {
|
|
Origin: PHONE_ORIGIN,
|
|
'Access-Control-Request-Method': 'POST',
|
|
},
|
|
})
|
|
|
|
expect(response.status).toBe(403)
|
|
expect(response.headers.get('Access-Control-Allow-Origin')).toBeNull()
|
|
})
|
|
|
|
test('blocks authenticated remote browsers from changing H5 access settings under explicit server auth', async () => {
|
|
await restartRemoteServer({ authRequired: true })
|
|
process.env.ANTHROPIC_API_KEY = 'test-server-key'
|
|
|
|
const response = await fetch(`${baseUrl}/api/h5-access/enable`, {
|
|
method: 'POST',
|
|
headers: {
|
|
Origin: PHONE_ORIGIN,
|
|
Authorization: 'Bearer test-server-key',
|
|
},
|
|
})
|
|
|
|
expect(response.status).toBe(403)
|
|
expect(response.headers.get('Access-Control-Allow-Origin')).toBeNull()
|
|
})
|
|
|
|
test('allows local desktop H5 access settings under explicit server auth with a valid bearer', async () => {
|
|
await restartRemoteServer({ authRequired: true })
|
|
process.env.ANTHROPIC_API_KEY = 'test-server-key'
|
|
|
|
const response = await fetch(`${baseUrl}/api/h5-access/enable`, {
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: 'Bearer test-server-key',
|
|
},
|
|
})
|
|
|
|
expect(response.status).toBe(200)
|
|
await expect(response.json()).resolves.toHaveProperty('token')
|
|
})
|
|
|
|
test('allows H5 browser requests from the configured public base URL origin', async () => {
|
|
const token = await enableH5Access({
|
|
publicBaseUrl: `${PHONE_ORIGIN}/h5`,
|
|
})
|
|
|
|
const response = await fetch(`${baseUrl}/api/status`, {
|
|
headers: {
|
|
Origin: PHONE_ORIGIN,
|
|
Authorization: `Bearer ${token}`,
|
|
},
|
|
})
|
|
|
|
expect(response.status).toBe(200)
|
|
expect(response.headers.get('Access-Control-Allow-Origin')).toBe(PHONE_ORIGIN)
|
|
})
|
|
|
|
test('allows configured CORS origins and includes Vary: Origin', async () => {
|
|
const token = await enableH5Access({
|
|
allowedOrigins: ['https://allowed.example.com'],
|
|
})
|
|
|
|
const response = await fetch(`${baseUrl}/api/status`, {
|
|
method: 'OPTIONS',
|
|
headers: {
|
|
Origin: 'https://allowed.example.com',
|
|
Authorization: `Bearer ${token}`,
|
|
'Access-Control-Request-Method': 'GET',
|
|
},
|
|
})
|
|
|
|
expect(response.status).toBe(204)
|
|
expect(response.headers.get('Access-Control-Allow-Origin')).toBe(
|
|
'https://allowed.example.com',
|
|
)
|
|
expect(response.headers.get('Vary')).toBe('Origin')
|
|
})
|
|
|
|
test('opens websocket upgrades without H5 token by default', async () => {
|
|
await expectWebSocketOpen(`${wsBaseUrl}/ws/h5-auth-test`)
|
|
})
|
|
|
|
test('requires H5 token for remote browser REST requests when H5 access is enabled', async () => {
|
|
const token = await enableH5Access({
|
|
allowedOrigins: [PHONE_ORIGIN],
|
|
})
|
|
|
|
const missingTokenResponse = await fetch(`${baseUrl}/api/status`, {
|
|
headers: {
|
|
Origin: PHONE_ORIGIN,
|
|
},
|
|
})
|
|
expect(missingTokenResponse.status).toBe(401)
|
|
|
|
const validTokenResponse = await fetch(`${baseUrl}/api/status`, {
|
|
headers: {
|
|
Origin: PHONE_ORIGIN,
|
|
Authorization: `Bearer ${token}`,
|
|
},
|
|
})
|
|
expect(validTokenResponse.status).toBe(200)
|
|
})
|
|
|
|
test('requires H5 token for remote browser settings surface requests when H5 access is enabled', async () => {
|
|
const token = await enableH5Access({
|
|
allowedOrigins: [PHONE_ORIGIN],
|
|
})
|
|
|
|
for (const endpoint of settingsSurfaceEndpoints) {
|
|
const missingTokenResponse = await fetch(`${baseUrl}${endpoint.path}`, {
|
|
headers: {
|
|
Origin: PHONE_ORIGIN,
|
|
},
|
|
})
|
|
expect(missingTokenResponse.status).toBe(401)
|
|
|
|
const wrongTokenResponse = await fetch(`${baseUrl}${endpoint.path}`, {
|
|
headers: {
|
|
Origin: PHONE_ORIGIN,
|
|
Authorization: 'Bearer wrong-token',
|
|
},
|
|
})
|
|
expect(wrongTokenResponse.status).toBe(401)
|
|
|
|
const validTokenResponse = await fetch(`${baseUrl}${endpoint.path}`, {
|
|
headers: {
|
|
Origin: PHONE_ORIGIN,
|
|
Authorization: `Bearer ${token}`,
|
|
},
|
|
})
|
|
expect(validTokenResponse.status).toBe(200)
|
|
expect(validTokenResponse.headers.get('Access-Control-Allow-Origin')).toBe(PHONE_ORIGIN)
|
|
const body = await validTokenResponse.json()
|
|
if ('expected' in endpoint) {
|
|
expect(body).toMatchObject(endpoint.expected)
|
|
} else {
|
|
expect(body).toHaveProperty(endpoint.expectedKey)
|
|
}
|
|
}
|
|
})
|
|
|
|
test('requires H5 token for remote browser local-file and preview-fs requests when H5 access is enabled', async () => {
|
|
const token = await enableH5Access({
|
|
allowedOrigins: [PHONE_ORIGIN],
|
|
})
|
|
const localFile = localFileUrl(baseUrl, path.join(process.cwd(), 'package.json'))
|
|
|
|
const missingLocalFileToken = await fetch(localFile, {
|
|
headers: {
|
|
Origin: PHONE_ORIGIN,
|
|
},
|
|
})
|
|
expect(missingLocalFileToken.status).toBe(401)
|
|
|
|
const wrongLocalFileToken = await fetch(localFile, {
|
|
headers: {
|
|
Origin: PHONE_ORIGIN,
|
|
Authorization: 'Bearer wrong-token',
|
|
},
|
|
})
|
|
expect(wrongLocalFileToken.status).toBe(401)
|
|
|
|
const validLocalFileToken = await fetch(localFile, {
|
|
headers: {
|
|
Origin: PHONE_ORIGIN,
|
|
Authorization: `Bearer ${token}`,
|
|
},
|
|
})
|
|
expect(validLocalFileToken.status).toBe(200)
|
|
await expect(validLocalFileToken.text()).resolves.toContain('"name"')
|
|
|
|
const missingPreviewToken = await fetch(`${baseUrl}/preview-fs/h5-auth-test/index.html`, {
|
|
headers: {
|
|
Origin: PHONE_ORIGIN,
|
|
},
|
|
})
|
|
expect(missingPreviewToken.status).toBe(401)
|
|
})
|
|
|
|
test('keeps loopback browser local-file and preview-fs requests tokenless when H5 access is enabled', async () => {
|
|
const loopbackBrowserOrigin = 'http://localhost:5173'
|
|
await enableH5Access()
|
|
const localFile = localFileUrl(baseUrl, path.join(process.cwd(), 'package.json'))
|
|
|
|
const localFileResponse = await fetch(localFile, {
|
|
headers: {
|
|
Origin: loopbackBrowserOrigin,
|
|
},
|
|
})
|
|
expect(localFileResponse.status).toBe(200)
|
|
await expect(localFileResponse.text()).resolves.toContain('"name"')
|
|
|
|
const previewResponse = await fetch(`${baseUrl}/preview-fs/h5-auth-test/index.html`, {
|
|
headers: {
|
|
Origin: loopbackBrowserOrigin,
|
|
},
|
|
})
|
|
expect(previewResponse.status).not.toBe(401)
|
|
expect(previewResponse.status).not.toBe(403)
|
|
})
|
|
|
|
test('does not allow the server API key to replace the H5 token for remote browser requests', async () => {
|
|
process.env.ANTHROPIC_API_KEY = 'test-server-key'
|
|
await enableH5Access({
|
|
allowedOrigins: [PHONE_ORIGIN],
|
|
})
|
|
|
|
const apiResponse = await fetch(`${baseUrl}/api/status`, {
|
|
headers: {
|
|
Origin: PHONE_ORIGIN,
|
|
Authorization: 'Bearer test-server-key',
|
|
},
|
|
})
|
|
expect(apiResponse.status).toBe(401)
|
|
await expect(apiResponse.json()).resolves.toMatchObject({
|
|
message: 'Invalid H5 access token',
|
|
})
|
|
|
|
const proxyResponse = await fetch(`${baseUrl}/proxy/v1/messages`, {
|
|
method: 'POST',
|
|
headers: {
|
|
Origin: PHONE_ORIGIN,
|
|
Authorization: 'Bearer test-server-key',
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({ model: 'test', messages: [] }),
|
|
})
|
|
expect(proxyResponse.status).toBe(401)
|
|
|
|
const wsResponse = await fetch(`${baseUrl}/ws/h5-auth-test`, {
|
|
headers: {
|
|
...makeUpgradeHeaders(PHONE_ORIGIN),
|
|
Authorization: 'Bearer test-server-key',
|
|
},
|
|
})
|
|
expect(wsResponse.status).toBe(401)
|
|
})
|
|
|
|
test('requires H5 token for remote browser proxy requests when H5 access is enabled', async () => {
|
|
const token = await enableH5Access({
|
|
allowedOrigins: [PHONE_ORIGIN],
|
|
})
|
|
|
|
const missingTokenResponse = await fetch(`${baseUrl}/proxy/v1/messages`, {
|
|
method: 'POST',
|
|
headers: {
|
|
Origin: PHONE_ORIGIN,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({ model: 'test', messages: [] }),
|
|
})
|
|
expect(missingTokenResponse.status).toBe(401)
|
|
|
|
const validTokenResponse = await fetch(`${baseUrl}/proxy/v1/messages`, {
|
|
method: 'POST',
|
|
headers: {
|
|
Origin: PHONE_ORIGIN,
|
|
Authorization: `Bearer ${token}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({ model: 'test', messages: [] }),
|
|
})
|
|
expect(validTokenResponse.status).toBe(400)
|
|
expect(validTokenResponse.headers.get('Access-Control-Allow-Origin')).toBe(PHONE_ORIGIN)
|
|
await expect(validTokenResponse.json()).resolves.toMatchObject({
|
|
error: {
|
|
type: 'invalid_request_error',
|
|
},
|
|
})
|
|
})
|
|
|
|
test('does not keep retired Tauri loopback REST requests tokenless when H5 access is enabled', async () => {
|
|
await enableH5Access()
|
|
|
|
const response = await fetch(`${baseUrl}/api/status`, {
|
|
headers: {
|
|
Origin: 'http://tauri.localhost',
|
|
},
|
|
})
|
|
|
|
expect(response.status).toBe(403)
|
|
})
|
|
|
|
test('keeps local loopback websocket and SDK requests tokenless when H5 access is enabled', async () => {
|
|
await enableH5Access()
|
|
|
|
await expectWebSocketOpen(`${wsBaseUrl}/ws/h5-auth-test`)
|
|
await expectWebSocketUpgradeThenClose(`${wsBaseUrl}/sdk/h5-auth-test`)
|
|
})
|
|
|
|
test('keeps local loopback adapter requests tokenless when H5 access is enabled', async () => {
|
|
await enableH5Access()
|
|
|
|
const response = await fetch(`${baseUrl}/api/adapters`)
|
|
|
|
expect(response.status).toBe(200)
|
|
await expect(response.json()).resolves.toEqual({})
|
|
})
|
|
|
|
test('keeps local loopback settings surface requests tokenless when H5 access is enabled', async () => {
|
|
await enableH5Access()
|
|
|
|
for (const endpoint of settingsSurfaceEndpoints) {
|
|
const response = await fetch(`${baseUrl}${endpoint.path}`)
|
|
|
|
expect(response.status).toBe(200)
|
|
}
|
|
})
|
|
|
|
test('keeps local loopback local-file navigations tokenless when H5 access is enabled', async () => {
|
|
await enableH5Access()
|
|
|
|
const response = await fetch(localFileUrl(baseUrl, path.join(process.cwd(), 'package.json')))
|
|
|
|
expect(response.status).toBe(200)
|
|
await expect(response.text()).resolves.toContain('"name"')
|
|
})
|
|
|
|
test('blocks adapter requests from non-local browser origins when H5 access is enabled', async () => {
|
|
await enableH5Access()
|
|
|
|
const response = await fetch(`${baseUrl}/api/adapters`, {
|
|
headers: {
|
|
Origin: PHONE_ORIGIN,
|
|
},
|
|
})
|
|
|
|
expect(response.status).toBe(403)
|
|
})
|
|
|
|
test('blocks settings surface requests from untrusted browser origins when H5 access is enabled', async () => {
|
|
await enableH5Access()
|
|
|
|
for (const endpoint of settingsSurfaceEndpoints) {
|
|
const response = await fetch(`${baseUrl}${endpoint.path}`, {
|
|
headers: {
|
|
Origin: PHONE_ORIGIN,
|
|
},
|
|
})
|
|
|
|
expect(response.status).toBe(403)
|
|
}
|
|
})
|
|
|
|
test('requires H5 token for remote browser websocket requests when H5 access is enabled', async () => {
|
|
const token = await enableH5Access({
|
|
allowedOrigins: [PHONE_ORIGIN],
|
|
})
|
|
|
|
const missingTokenResponse = await fetch(`${baseUrl}/ws/h5-auth-test`, {
|
|
headers: makeUpgradeHeaders(PHONE_ORIGIN),
|
|
})
|
|
expect(missingTokenResponse.status).toBe(401)
|
|
|
|
const validTokenResponse = await fetch(`${baseUrl}/ws/h5-auth-test?token=${token}`, {
|
|
headers: makeUpgradeHeaders(PHONE_ORIGIN),
|
|
})
|
|
expect(validTokenResponse.status).toBe(400)
|
|
await expect(validTokenResponse.text()).resolves.toBe('WebSocket upgrade failed')
|
|
})
|
|
|
|
test('requires H5 token for remote browser SDK requests when H5 access is enabled', async () => {
|
|
const token = await enableH5Access({
|
|
allowedOrigins: [PHONE_ORIGIN],
|
|
})
|
|
|
|
const missingTokenResponse = await fetch(`${baseUrl}/sdk/h5-auth-test`, {
|
|
headers: makeUpgradeHeaders(PHONE_ORIGIN),
|
|
})
|
|
expect(missingTokenResponse.status).toBe(403)
|
|
|
|
const validTokenResponse = await fetch(`${baseUrl}/sdk/h5-auth-test?token=${token}`, {
|
|
headers: makeUpgradeHeaders(PHONE_ORIGIN),
|
|
})
|
|
expect(validTokenResponse.status).toBe(403)
|
|
})
|
|
|
|
test('blocks remote browser SDK requests even under explicit server auth', async () => {
|
|
await restartRemoteServer({ authRequired: true })
|
|
process.env.ANTHROPIC_API_KEY = 'test-server-key'
|
|
|
|
const response = await fetch(`${baseUrl}/sdk/h5-auth-test`, {
|
|
headers: {
|
|
...makeUpgradeHeaders(PHONE_ORIGIN),
|
|
Authorization: 'Bearer test-server-key',
|
|
},
|
|
})
|
|
|
|
expect(response.status).toBe(403)
|
|
})
|
|
|
|
test('honors explicit auth opt-in for REST and websocket requests', async () => {
|
|
await restartRemoteServer({ authRequired: true })
|
|
const token = await enableH5Access()
|
|
|
|
const missingStatusResponse = await fetch(`${baseUrl}/api/status`)
|
|
expect(missingStatusResponse.status).toBe(401)
|
|
|
|
const wrongStatusResponse = await fetch(`${baseUrl}/api/status`, {
|
|
headers: {
|
|
Authorization: 'Bearer wrong-token',
|
|
},
|
|
})
|
|
expect(wrongStatusResponse.status).toBe(401)
|
|
|
|
const validStatusResponse = await fetch(`${baseUrl}/api/status`, {
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
},
|
|
})
|
|
expect(validStatusResponse.status).toBe(200)
|
|
|
|
const missingTokenResponse = await fetch(`${baseUrl}/ws/h5-auth-test`, {
|
|
headers: makeUpgradeHeaders(),
|
|
})
|
|
expect(missingTokenResponse.status).toBe(401)
|
|
|
|
const wrongTokenResponse = await fetch(`${baseUrl}/ws/h5-auth-test?token=wrong-token`, {
|
|
headers: makeUpgradeHeaders(),
|
|
})
|
|
expect(wrongTokenResponse.status).toBe(401)
|
|
|
|
await expectWebSocketOpen(`${wsBaseUrl}/ws/h5-auth-test?token=${token}`)
|
|
})
|
|
})
|