mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-29 16:03:34 +08:00
Merge branch 'main' into fix(adapters)-shorten-Windows-paths-in-tool-summaries
This commit is contained in:
commit
d0d09dbe05
@ -41,6 +41,18 @@ describe('AdapterHttpClient', () => {
|
|||||||
expect(body.workDir).toBe('/path/to/project')
|
expect(body.workDir).toBe('/path/to/project')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('authenticates requests with the desktop local access token', async () => {
|
||||||
|
client = new AdapterHttpClient('ws://127.0.0.1:3456', {
|
||||||
|
localAccessToken: 'adapter-secret',
|
||||||
|
})
|
||||||
|
globalThis.fetch = mock(() => Promise.resolve(Response.json({ projects: [] }))) as any
|
||||||
|
|
||||||
|
await client.listRecentProjects()
|
||||||
|
|
||||||
|
const init = (globalThis.fetch as any).mock.calls[0][1] as RequestInit
|
||||||
|
expect(new Headers(init.headers).get('Authorization')).toBe('Bearer adapter-secret')
|
||||||
|
})
|
||||||
|
|
||||||
it('listRecentProjects calls GET /api/sessions/recent-projects', async () => {
|
it('listRecentProjects calls GET /api/sessions/recent-projects', async () => {
|
||||||
const mockProjects = [
|
const mockProjects = [
|
||||||
{ projectName: 'my-app', realPath: '/home/user/my-app', sessionCount: 3 },
|
{ projectName: 'my-app', realPath: '/home/user/my-app', sessionCount: 3 },
|
||||||
|
|||||||
@ -115,6 +115,22 @@ describe('WsBridge: handler serialization', () => {
|
|||||||
return connections[0]!
|
return connections[0]!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
it('authenticates websocket connections with the desktop local access token', async () => {
|
||||||
|
let requestUrl = ''
|
||||||
|
server.once('connection', (_ws, request) => {
|
||||||
|
requestUrl = request.url || ''
|
||||||
|
})
|
||||||
|
const bridge = new WsBridge(serverUrl, 'test', 'adapter secret/with spaces')
|
||||||
|
|
||||||
|
bridge.connectSession('chat-auth', 'sess-auth')
|
||||||
|
expect(await bridge.waitForOpen('chat-auth')).toBe(true)
|
||||||
|
await waitForServerConnection()
|
||||||
|
|
||||||
|
expect(new URL(requestUrl, serverUrl).searchParams.get('token'))
|
||||||
|
.toBe('adapter secret/with spaces')
|
||||||
|
bridge.destroy()
|
||||||
|
})
|
||||||
|
|
||||||
it('processes handler calls in strict FIFO order per chatId', async () => {
|
it('processes handler calls in strict FIFO order per chatId', async () => {
|
||||||
const bridge = new WsBridge(serverUrl, 'test')
|
const bridge = new WsBridge(serverUrl, 'test')
|
||||||
const events: string[] = []
|
const events: string[] = []
|
||||||
|
|||||||
@ -73,10 +73,14 @@ export type SkillSummary = {
|
|||||||
export class AdapterHttpClient {
|
export class AdapterHttpClient {
|
||||||
readonly httpBaseUrl: string
|
readonly httpBaseUrl: string
|
||||||
private readonly allowedProjectRoots: string[]
|
private readonly allowedProjectRoots: string[]
|
||||||
|
private readonly localAccessToken: string | null
|
||||||
/** Default timeout for HTTP requests (30 seconds) */
|
/** Default timeout for HTTP requests (30 seconds) */
|
||||||
private static readonly DEFAULT_TIMEOUT_MS = 30_000
|
private static readonly DEFAULT_TIMEOUT_MS = 30_000
|
||||||
|
|
||||||
constructor(wsUrl: string, options?: { allowedProjectRoots?: string[] }) {
|
constructor(
|
||||||
|
wsUrl: string,
|
||||||
|
options?: { allowedProjectRoots?: string[], localAccessToken?: string },
|
||||||
|
) {
|
||||||
this.httpBaseUrl = wsUrl
|
this.httpBaseUrl = wsUrl
|
||||||
.replace(/^ws:/, 'http:')
|
.replace(/^ws:/, 'http:')
|
||||||
.replace(/^wss:/, 'https:')
|
.replace(/^wss:/, 'https:')
|
||||||
@ -84,6 +88,17 @@ export class AdapterHttpClient {
|
|||||||
this.allowedProjectRoots = (options?.allowedProjectRoots ?? [])
|
this.allowedProjectRoots = (options?.allowedProjectRoots ?? [])
|
||||||
.map(resolveExistingProjectPath)
|
.map(resolveExistingProjectPath)
|
||||||
.filter((value): value is string => Boolean(value))
|
.filter((value): value is string => Boolean(value))
|
||||||
|
this.localAccessToken = options?.localAccessToken?.trim() ||
|
||||||
|
process.env.CC_HAHA_LOCAL_ACCESS_TOKEN?.trim() ||
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
private request(pathname: string, init: RequestInit = {}): Promise<Response> {
|
||||||
|
const headers = new Headers(init.headers)
|
||||||
|
if (this.localAccessToken) {
|
||||||
|
headers.set('Authorization', `Bearer ${this.localAccessToken}`)
|
||||||
|
}
|
||||||
|
return fetch(`${this.httpBaseUrl}${pathname}`, { ...init, headers })
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Create an AbortController with timeout */
|
/** Create an AbortController with timeout */
|
||||||
@ -99,7 +114,7 @@ export class AdapterHttpClient {
|
|||||||
async createSession(workDir: string): Promise<string> {
|
async createSession(workDir: string): Promise<string> {
|
||||||
const { controller, timer } = this.createTimeoutController()
|
const { controller, timer } = this.createTimeoutController()
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${this.httpBaseUrl}/api/sessions`, {
|
const res = await this.request('/api/sessions', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ workDir }),
|
body: JSON.stringify({ workDir }),
|
||||||
@ -119,7 +134,7 @@ export class AdapterHttpClient {
|
|||||||
async sessionExists(sessionId: string): Promise<boolean> {
|
async sessionExists(sessionId: string): Promise<boolean> {
|
||||||
const { controller, timer } = this.createTimeoutController()
|
const { controller, timer } = this.createTimeoutController()
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${this.httpBaseUrl}/api/sessions/${encodeURIComponent(sessionId)}`, {
|
const res = await this.request(`/api/sessions/${encodeURIComponent(sessionId)}`, {
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
})
|
})
|
||||||
if (res.status === 404) return false
|
if (res.status === 404) return false
|
||||||
@ -136,7 +151,7 @@ export class AdapterHttpClient {
|
|||||||
async listRecentProjects(): Promise<RecentProject[]> {
|
async listRecentProjects(): Promise<RecentProject[]> {
|
||||||
const { controller, timer } = this.createTimeoutController()
|
const { controller, timer } = this.createTimeoutController()
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${this.httpBaseUrl}/api/sessions/recent-projects`, {
|
const res = await this.request('/api/sessions/recent-projects', {
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@ -202,7 +217,7 @@ export class AdapterHttpClient {
|
|||||||
async getGitInfo(sessionId: string): Promise<GitInfo> {
|
async getGitInfo(sessionId: string): Promise<GitInfo> {
|
||||||
const { controller, timer } = this.createTimeoutController()
|
const { controller, timer } = this.createTimeoutController()
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${this.httpBaseUrl}/api/sessions/${encodeURIComponent(sessionId)}/git-info`, {
|
const res = await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/git-info`, {
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@ -218,7 +233,7 @@ export class AdapterHttpClient {
|
|||||||
async getTasksForSession(sessionId: string): Promise<SessionTask[]> {
|
async getTasksForSession(sessionId: string): Promise<SessionTask[]> {
|
||||||
const { controller, timer } = this.createTimeoutController()
|
const { controller, timer } = this.createTimeoutController()
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${this.httpBaseUrl}/api/tasks/lists/${encodeURIComponent(sessionId)}`, {
|
const res = await this.request(`/api/tasks/lists/${encodeURIComponent(sessionId)}`, {
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@ -246,7 +261,7 @@ export class AdapterHttpClient {
|
|||||||
|
|
||||||
const { controller, timer } = this.createTimeoutController()
|
const { controller, timer } = this.createTimeoutController()
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${this.httpBaseUrl}/api/sessions${suffix}`, {
|
const res = await this.request(`/api/sessions${suffix}`, {
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@ -262,7 +277,7 @@ export class AdapterHttpClient {
|
|||||||
async listProviders(): Promise<{ providers: ProviderSummary[]; activeId: string | null }> {
|
async listProviders(): Promise<{ providers: ProviderSummary[]; activeId: string | null }> {
|
||||||
const { controller, timer } = this.createTimeoutController()
|
const { controller, timer } = this.createTimeoutController()
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${this.httpBaseUrl}/api/providers`, {
|
const res = await this.request('/api/providers', {
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@ -286,7 +301,7 @@ export class AdapterHttpClient {
|
|||||||
async listModels(): Promise<{ models: ModelSummary[]; provider: { id: string; name: string } | null }> {
|
async listModels(): Promise<{ models: ModelSummary[]; provider: { id: string; name: string } | null }> {
|
||||||
const { controller, timer } = this.createTimeoutController()
|
const { controller, timer } = this.createTimeoutController()
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${this.httpBaseUrl}/api/models`, {
|
const res = await this.request('/api/models', {
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@ -302,7 +317,7 @@ export class AdapterHttpClient {
|
|||||||
async getCurrentModel(): Promise<{ model: ModelSummary }> {
|
async getCurrentModel(): Promise<{ model: ModelSummary }> {
|
||||||
const { controller, timer } = this.createTimeoutController()
|
const { controller, timer } = this.createTimeoutController()
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${this.httpBaseUrl}/api/models/current`, {
|
const res = await this.request('/api/models/current', {
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@ -323,7 +338,7 @@ export class AdapterHttpClient {
|
|||||||
const params = new URLSearchParams({ cwd })
|
const params = new URLSearchParams({ cwd })
|
||||||
const { controller, timer } = this.createTimeoutController()
|
const { controller, timer } = this.createTimeoutController()
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${this.httpBaseUrl}/api/skills?${params.toString()}`, {
|
const res = await this.request(`/api/skills?${params.toString()}`, {
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@ -339,7 +354,7 @@ export class AdapterHttpClient {
|
|||||||
private async postJson(pathname: string): Promise<void> {
|
private async postJson(pathname: string): Promise<void> {
|
||||||
const { controller, timer } = this.createTimeoutController()
|
const { controller, timer } = this.createTimeoutController()
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${this.httpBaseUrl}${pathname}`, {
|
const res = await this.request(pathname, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
})
|
})
|
||||||
@ -355,7 +370,7 @@ export class AdapterHttpClient {
|
|||||||
private async putJson(pathname: string, body: Record<string, unknown>): Promise<void> {
|
private async putJson(pathname: string, body: Record<string, unknown>): Promise<void> {
|
||||||
const { controller, timer } = this.createTimeoutController()
|
const { controller, timer } = this.createTimeoutController()
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${this.httpBaseUrl}${pathname}`, {
|
const res = await this.request(pathname, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
|
|||||||
@ -50,12 +50,18 @@ export class WsBridge {
|
|||||||
private handlerChains = new Map<string, Promise<void>>()
|
private handlerChains = new Map<string, Promise<void>>()
|
||||||
private serverUrl: string
|
private serverUrl: string
|
||||||
private platform: string
|
private platform: string
|
||||||
|
private localAccessToken: string | null
|
||||||
private heartbeatTimer: ReturnType<typeof setInterval> | null = null
|
private heartbeatTimer: ReturnType<typeof setInterval> | null = null
|
||||||
private destroyed = false
|
private destroyed = false
|
||||||
|
|
||||||
constructor(serverUrl: string, platform: string) {
|
constructor(
|
||||||
|
serverUrl: string,
|
||||||
|
platform: string,
|
||||||
|
localAccessToken = process.env.CC_HAHA_LOCAL_ACCESS_TOKEN,
|
||||||
|
) {
|
||||||
this.serverUrl = serverUrl.replace(/\/$/, '')
|
this.serverUrl = serverUrl.replace(/\/$/, '')
|
||||||
this.platform = platform
|
this.platform = platform
|
||||||
|
this.localAccessToken = localAccessToken?.trim() || null
|
||||||
this.startHeartbeat()
|
this.startHeartbeat()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -162,7 +168,10 @@ export class WsBridge {
|
|||||||
// ------- internal -------
|
// ------- internal -------
|
||||||
|
|
||||||
private connect(chatId: string, sessionId: string): void {
|
private connect(chatId: string, sessionId: string): void {
|
||||||
const url = `${this.serverUrl}/ws/${sessionId}`
|
const url = new URL(`${this.serverUrl}/ws/${sessionId}`)
|
||||||
|
if (this.localAccessToken) {
|
||||||
|
url.searchParams.set('token', this.localAccessToken)
|
||||||
|
}
|
||||||
const ws = new WebSocket(url)
|
const ws = new WebSocket(url)
|
||||||
|
|
||||||
// Cancel any pending reconnect timer for this chatId
|
// Cancel any pending reconnect timer for this chatId
|
||||||
|
|||||||
@ -72,6 +72,7 @@ const updateCheckOptions: Validator = value => {
|
|||||||
export const ELECTRON_IPC_VALIDATORS = {
|
export const ELECTRON_IPC_VALIDATORS = {
|
||||||
[ELECTRON_IPC_CHANNELS.appGetVersion]: noPayload,
|
[ELECTRON_IPC_CHANNELS.appGetVersion]: noPayload,
|
||||||
[ELECTRON_IPC_CHANNELS.runtimeGetServerUrl]: noPayload,
|
[ELECTRON_IPC_CHANNELS.runtimeGetServerUrl]: noPayload,
|
||||||
|
[ELECTRON_IPC_CHANNELS.runtimeGetLocalAccessToken]: noPayload,
|
||||||
[ELECTRON_IPC_CHANNELS.commandInvoke]: commandInvoke,
|
[ELECTRON_IPC_CHANNELS.commandInvoke]: commandInvoke,
|
||||||
[ELECTRON_IPC_CHANNELS.clipboardReadText]: noPayload,
|
[ELECTRON_IPC_CHANNELS.clipboardReadText]: noPayload,
|
||||||
[ELECTRON_IPC_CHANNELS.clipboardWriteText]: stringPayload,
|
[ELECTRON_IPC_CHANNELS.clipboardWriteText]: stringPayload,
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
export const ELECTRON_IPC_CHANNELS = {
|
export const ELECTRON_IPC_CHANNELS = {
|
||||||
appGetVersion: 'desktop:app:get-version',
|
appGetVersion: 'desktop:app:get-version',
|
||||||
runtimeGetServerUrl: 'desktop:runtime:get-server-url',
|
runtimeGetServerUrl: 'desktop:runtime:get-server-url',
|
||||||
|
runtimeGetLocalAccessToken: 'desktop:runtime:get-local-access-token',
|
||||||
commandInvoke: 'desktop:command:invoke',
|
commandInvoke: 'desktop:command:invoke',
|
||||||
clipboardReadText: 'desktop:clipboard:read-text',
|
clipboardReadText: 'desktop:clipboard:read-text',
|
||||||
clipboardWriteText: 'desktop:clipboard:write-text',
|
clipboardWriteText: 'desktop:clipboard:write-text',
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
import { existsSync, readFileSync } from 'node:fs'
|
import { existsSync, readFileSync } from 'node:fs'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
import {
|
import {
|
||||||
|
configureLocalServerRequestAuth,
|
||||||
configurePreviewSessionPermissions,
|
configurePreviewSessionPermissions,
|
||||||
PREVIEW_SESSION_PARTITION,
|
createPreviewSessionPartition,
|
||||||
} from './services/previewSession'
|
} from './services/previewSession'
|
||||||
|
|
||||||
const desktopRoot = existsSync(path.resolve(process.cwd(), 'electron', 'main.ts'))
|
const desktopRoot = existsSync(path.resolve(process.cwd(), 'electron', 'main.ts'))
|
||||||
@ -12,16 +13,21 @@ const desktopRoot = existsSync(path.resolve(process.cwd(), 'electron', 'main.ts'
|
|||||||
const mainSource = readFileSync(path.join(desktopRoot, 'electron', 'main.ts'), 'utf8')
|
const mainSource = readFileSync(path.join(desktopRoot, 'electron', 'main.ts'), 'utf8')
|
||||||
|
|
||||||
describe('Electron preview security boundary', () => {
|
describe('Electron preview security boundary', () => {
|
||||||
it('uses a dedicated in-memory session partition for remote previews', () => {
|
it('uses a fresh in-memory session partition for every remote preview', () => {
|
||||||
expect(PREVIEW_SESSION_PARTITION).toBe('cc-haha-preview')
|
const firstPartition = createPreviewSessionPartition()
|
||||||
expect(PREVIEW_SESSION_PARTITION.startsWith('persist:')).toBe(false)
|
const secondPartition = createPreviewSessionPartition()
|
||||||
expect(mainSource).toContain('partition: PREVIEW_SESSION_PARTITION')
|
|
||||||
|
expect(firstPartition.startsWith('cc-haha-preview-')).toBe(true)
|
||||||
|
expect(firstPartition.startsWith('persist:')).toBe(false)
|
||||||
|
expect(secondPartition).not.toBe(firstPartition)
|
||||||
|
expect(mainSource).toContain('partition: createPreviewSessionPartition()')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('denies preview permission checks and requests by default', () => {
|
it('denies preview permission checks and requests by default', () => {
|
||||||
const handlers: {
|
const handlers: {
|
||||||
check?: (...args: unknown[]) => boolean
|
check?: (...args: unknown[]) => boolean
|
||||||
request?: (...args: unknown[]) => void
|
request?: (...args: unknown[]) => void
|
||||||
|
beforeSendHeaders?: (...args: unknown[]) => void
|
||||||
} = {}
|
} = {}
|
||||||
const session = {
|
const session = {
|
||||||
setPermissionCheckHandler(handler: (...args: unknown[]) => boolean) {
|
setPermissionCheckHandler(handler: (...args: unknown[]) => boolean) {
|
||||||
@ -30,13 +36,42 @@ describe('Electron preview security boundary', () => {
|
|||||||
setPermissionRequestHandler(handler: (...args: unknown[]) => void) {
|
setPermissionRequestHandler(handler: (...args: unknown[]) => void) {
|
||||||
handlers.request = handler
|
handlers.request = handler
|
||||||
},
|
},
|
||||||
|
webRequest: {
|
||||||
|
onBeforeSendHeaders(handler: (...args: unknown[]) => void) {
|
||||||
|
handlers.beforeSendHeaders = handler
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
configurePreviewSessionPermissions(session as never)
|
configurePreviewSessionPermissions(session as never)
|
||||||
|
configureLocalServerRequestAuth(session.webRequest as never, () => ({
|
||||||
|
serverUrl: 'http://127.0.0.1:49321',
|
||||||
|
token: 'preview-local-token',
|
||||||
|
}))
|
||||||
|
|
||||||
expect(handlers.check?.()).toBe(false)
|
expect(handlers.check?.()).toBe(false)
|
||||||
const callback = (allowed: boolean) => expect(allowed).toBe(false)
|
const callback = (allowed: boolean) => expect(allowed).toBe(false)
|
||||||
handlers.request?.(null, 'media', callback)
|
handlers.request?.(null, 'media', callback)
|
||||||
expect(mainSource).toContain('configurePreviewSessionPermissions(view.webContents.session)')
|
expect(mainSource).toContain('configurePreviewSessionPermissions(view.webContents.session)')
|
||||||
|
expect(mainSource).toContain('mainWindow.webContents.session.webRequest')
|
||||||
|
|
||||||
|
const localCallback = vi.fn()
|
||||||
|
handlers.beforeSendHeaders?.({
|
||||||
|
url: 'http://127.0.0.1:49321/preview-fs/session/index.css',
|
||||||
|
requestHeaders: { Accept: 'text/css' },
|
||||||
|
}, localCallback)
|
||||||
|
expect(localCallback).toHaveBeenCalledWith({
|
||||||
|
requestHeaders: {
|
||||||
|
Accept: 'text/css',
|
||||||
|
Authorization: 'Bearer preview-local-token',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const remoteCallback = vi.fn()
|
||||||
|
handlers.beforeSendHeaders?.({
|
||||||
|
url: 'https://example.com/app.js',
|
||||||
|
requestHeaders: { Accept: '*/*' },
|
||||||
|
}, remoteCallback)
|
||||||
|
expect(remoteCallback).toHaveBeenCalledWith({ requestHeaders: { Accept: '*/*' } })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -20,8 +20,10 @@ import { createUpdateSmokeUpdaterFromEnv } from './services/updateSmoke'
|
|||||||
import { ElectronTerminalService, type TerminalSpawnInput } from './services/terminal'
|
import { ElectronTerminalService, type TerminalSpawnInput } from './services/terminal'
|
||||||
import { ElectronPreviewService, type PreviewBounds } from './services/preview'
|
import { ElectronPreviewService, type PreviewBounds } from './services/preview'
|
||||||
import {
|
import {
|
||||||
|
configureLocalServerRequestAuth,
|
||||||
configurePreviewSessionPermissions,
|
configurePreviewSessionPermissions,
|
||||||
PREVIEW_SESSION_PARTITION,
|
createPreviewSessionPartition,
|
||||||
|
type PreviewLocalAccess,
|
||||||
} from './services/previewSession'
|
} from './services/previewSession'
|
||||||
import {
|
import {
|
||||||
applyStartupPortableMode,
|
applyStartupPortableMode,
|
||||||
@ -150,6 +152,14 @@ function getServerRuntime() {
|
|||||||
return serverRuntime
|
return serverRuntime
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveLocalServerAccess(): PreviewLocalAccess | null {
|
||||||
|
const runtime = getServerRuntime()
|
||||||
|
const serverUrl = runtime.getActiveServerUrl()
|
||||||
|
return serverUrl
|
||||||
|
? { serverUrl, token: runtime.getLocalAccessToken() }
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
|
||||||
function getUpdaterService() {
|
function getUpdaterService() {
|
||||||
const smokeUpdater = createUpdateSmokeUpdaterFromEnv(process.env)
|
const smokeUpdater = createUpdateSmokeUpdaterFromEnv(process.env)
|
||||||
updaterService ??= new ElectronUpdaterService(smokeUpdater ?? autoUpdater, {
|
updaterService ??= new ElectronUpdaterService(smokeUpdater ?? autoUpdater, {
|
||||||
@ -189,13 +199,17 @@ function getPreviewService() {
|
|||||||
const view = new WebContentsView({
|
const view = new WebContentsView({
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
preload: previewPreloadPath(),
|
preload: previewPreloadPath(),
|
||||||
partition: PREVIEW_SESSION_PARTITION,
|
partition: createPreviewSessionPartition(),
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
nodeIntegration: false,
|
nodeIntegration: false,
|
||||||
sandbox: true,
|
sandbox: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
configurePreviewSessionPermissions(view.webContents.session)
|
configurePreviewSessionPermissions(view.webContents.session)
|
||||||
|
configureLocalServerRequestAuth(
|
||||||
|
view.webContents.session.webRequest,
|
||||||
|
resolveLocalServerAccess,
|
||||||
|
)
|
||||||
installPreviewNavigationGuards(view.webContents, { openExternal: openExternalUrl })
|
installPreviewNavigationGuards(view.webContents, { openExternal: openExternalUrl })
|
||||||
return view
|
return view
|
||||||
},
|
},
|
||||||
@ -262,6 +276,10 @@ function registerIpcHandlers() {
|
|||||||
})
|
})
|
||||||
registerHandler(ELECTRON_IPC_CHANNELS.appGetVersion, () => app.getVersion())
|
registerHandler(ELECTRON_IPC_CHANNELS.appGetVersion, () => app.getVersion())
|
||||||
registerHandler(ELECTRON_IPC_CHANNELS.runtimeGetServerUrl, () => getServerRuntime().getServerUrl())
|
registerHandler(ELECTRON_IPC_CHANNELS.runtimeGetServerUrl, () => getServerRuntime().getServerUrl())
|
||||||
|
registerHandler(
|
||||||
|
ELECTRON_IPC_CHANNELS.runtimeGetLocalAccessToken,
|
||||||
|
() => getServerRuntime().getLocalAccessToken(),
|
||||||
|
)
|
||||||
registerHandler(ELECTRON_IPC_CHANNELS.commandInvoke, (_event, payload) => handleCommandInvoke(payload))
|
registerHandler(ELECTRON_IPC_CHANNELS.commandInvoke, (_event, payload) => handleCommandInvoke(payload))
|
||||||
registerHandler(ELECTRON_IPC_CHANNELS.clipboardReadText, () => clipboard.readText())
|
registerHandler(ELECTRON_IPC_CHANNELS.clipboardReadText, () => clipboard.readText())
|
||||||
registerHandler(ELECTRON_IPC_CHANNELS.clipboardWriteText, (_event, payload) => clipboard.writeText(String(payload)))
|
registerHandler(ELECTRON_IPC_CHANNELS.clipboardWriteText, (_event, payload) => clipboard.writeText(String(payload)))
|
||||||
@ -363,6 +381,10 @@ async function createMainWindow() {
|
|||||||
sandbox: true,
|
sandbox: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
configureLocalServerRequestAuth(
|
||||||
|
mainWindow.webContents.session.webRequest,
|
||||||
|
resolveLocalServerAccess,
|
||||||
|
)
|
||||||
|
|
||||||
installMainWindowNavigationGuards(mainWindow.webContents, { openExternal: openExternalUrl })
|
installMainWindowNavigationGuards(mainWindow.webContents, { openExternal: openExternalUrl })
|
||||||
installPreviewCleanupOnRendererNavigation(mainWindow.webContents, () => {
|
installPreviewCleanupOnRendererNavigation(mainWindow.webContents, () => {
|
||||||
|
|||||||
@ -1,6 +1,44 @@
|
|||||||
|
import { randomUUID } from 'node:crypto'
|
||||||
import type { Session } from 'electron'
|
import type { Session } from 'electron'
|
||||||
|
|
||||||
export const PREVIEW_SESSION_PARTITION = 'cc-haha-preview'
|
const PREVIEW_SESSION_PARTITION_PREFIX = 'cc-haha-preview-'
|
||||||
|
|
||||||
|
export function createPreviewSessionPartition(): string {
|
||||||
|
return `${PREVIEW_SESSION_PARTITION_PREFIX}${randomUUID()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PreviewLocalAccess = {
|
||||||
|
serverUrl: string
|
||||||
|
token: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameOrigin(left: string, right: string): boolean {
|
||||||
|
try {
|
||||||
|
return new URL(left).origin === new URL(right).origin
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function configureLocalServerRequestAuth(
|
||||||
|
webRequest: Pick<Session['webRequest'], 'onBeforeSendHeaders'>,
|
||||||
|
resolveLocalAccess: () => PreviewLocalAccess | null,
|
||||||
|
): void {
|
||||||
|
webRequest.onBeforeSendHeaders((details, callback) => {
|
||||||
|
const localAccess = resolveLocalAccess()
|
||||||
|
if (!localAccess || !sameOrigin(details.url, localAccess.serverUrl)) {
|
||||||
|
callback({ requestHeaders: details.requestHeaders })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
callback({
|
||||||
|
requestHeaders: {
|
||||||
|
...details.requestHeaders,
|
||||||
|
Authorization: `Bearer ${localAccess.token}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export function configurePreviewSessionPermissions(
|
export function configurePreviewSessionPermissions(
|
||||||
session: Pick<Session, 'setPermissionCheckHandler' | 'setPermissionRequestHandler'>,
|
session: Pick<Session, 'setPermissionCheckHandler' | 'setPermissionRequestHandler'>,
|
||||||
|
|||||||
@ -125,6 +125,21 @@ describe('ElectronServerRuntime', () => {
|
|||||||
.not.toBe(path.join(homedir(), '.claude'))
|
.not.toBe(path.join(homedir(), '.claude'))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('shares one unguessable local access token with server, adapters, and renderer', async () => {
|
||||||
|
const runtime = createRuntime()
|
||||||
|
|
||||||
|
await runtime.startServer()
|
||||||
|
|
||||||
|
const token = runtime.getLocalAccessToken()
|
||||||
|
expect(token.length).toBeGreaterThanOrEqual(32)
|
||||||
|
expect(sidecarMocks.serverPlans[0]!.env.CC_HAHA_LOCAL_ACCESS_TOKEN).toBe(token)
|
||||||
|
for (const adapter of sidecarMocks.spawnSidecar.mock.calls
|
||||||
|
.map(([plan]) => plan)
|
||||||
|
.filter(plan => plan.args[0] === 'adapters')) {
|
||||||
|
expect(adapter.env.CC_HAHA_LOCAL_ACCESS_TOKEN).toBe(token)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
it('persists a server startup failure through the sanitized host-log boundary', async () => {
|
it('persists a server startup failure through the sanitized host-log boundary', async () => {
|
||||||
sidecarMocks.spawnError = new Error('spawn failed')
|
sidecarMocks.spawnError = new Error('spawn failed')
|
||||||
const runtime = createRuntime({
|
const runtime = createRuntime({
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
|
import { randomBytes } from 'node:crypto'
|
||||||
import {
|
import {
|
||||||
appendHostDiagnostic,
|
appendHostDiagnostic,
|
||||||
createAdapterPlan,
|
createAdapterPlan,
|
||||||
@ -97,6 +98,7 @@ export class ElectronServerRuntime {
|
|||||||
private readonly baseEnv: NodeJS.ProcessEnv
|
private readonly baseEnv: NodeJS.ProcessEnv
|
||||||
private readonly deps: ServerRuntimeDeps
|
private readonly deps: ServerRuntimeDeps
|
||||||
private readonly resolveSystemProxy?: (url: string) => Promise<string>
|
private readonly resolveSystemProxy?: (url: string) => Promise<string>
|
||||||
|
private readonly localAccessToken = randomBytes(32).toString('base64url')
|
||||||
private sidecarEnvPromise: Promise<NodeJS.ProcessEnv> | null = null
|
private sidecarEnvPromise: Promise<NodeJS.ProcessEnv> | null = null
|
||||||
private server: ActiveServer | null = null
|
private server: ActiveServer | null = null
|
||||||
private adapters: SidecarChild[] = []
|
private adapters: SidecarChild[] = []
|
||||||
@ -136,6 +138,14 @@ export class ElectronServerRuntime {
|
|||||||
return await this.startServer()
|
return await this.startServer()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getLocalAccessToken(): string {
|
||||||
|
return this.localAccessToken
|
||||||
|
}
|
||||||
|
|
||||||
|
getActiveServerUrl(): string | null {
|
||||||
|
return this.server?.url ?? null
|
||||||
|
}
|
||||||
|
|
||||||
restartAdaptersSidecars(): Promise<void> {
|
restartAdaptersSidecars(): Promise<void> {
|
||||||
if (this.adapterRestartPromise) return this.adapterRestartPromise
|
if (this.adapterRestartPromise) return this.adapterRestartPromise
|
||||||
const operation = this.restartAdaptersSidecarsOnce()
|
const operation = this.restartAdaptersSidecarsOnce()
|
||||||
@ -183,7 +193,7 @@ export class ElectronServerRuntime {
|
|||||||
const url = `http://${SERVER_CONTROL_HOST}:${port}`
|
const url = `http://${SERVER_CONTROL_HOST}:${port}`
|
||||||
const logs: string[] = []
|
const logs: string[] = []
|
||||||
let startState: ServerStartState | null = null
|
let startState: ServerStartState | null = null
|
||||||
const env = await this.resolveSidecarBaseEnv()
|
const env = this.withLocalAccessToken(await this.resolveSidecarBaseEnv())
|
||||||
const plan = createServerPlan({
|
const plan = createServerPlan({
|
||||||
desktopRoot: this.desktopRoot,
|
desktopRoot: this.desktopRoot,
|
||||||
appRoot: this.appRoot,
|
appRoot: this.appRoot,
|
||||||
@ -245,7 +255,7 @@ export class ElectronServerRuntime {
|
|||||||
startState?: ServerStartState,
|
startState?: ServerStartState,
|
||||||
activeServer?: ActiveServer,
|
activeServer?: ActiveServer,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const env = await this.resolveSidecarBaseEnv()
|
const env = this.withLocalAccessToken(await this.resolveSidecarBaseEnv())
|
||||||
const isCurrentGeneration = () => {
|
const isCurrentGeneration = () => {
|
||||||
if (startState?.failure) return false
|
if (startState?.failure) return false
|
||||||
if (activeServer && this.server !== activeServer) return false
|
if (activeServer && this.server !== activeServer) return false
|
||||||
@ -293,6 +303,13 @@ export class ElectronServerRuntime {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private withLocalAccessToken(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||||
|
return {
|
||||||
|
...env,
|
||||||
|
CC_HAHA_LOCAL_ACCESS_TOKEN: this.localAccessToken,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private removeOwnedAdapters(owned: SidecarChild[] | undefined, removed: SidecarChild[]) {
|
private removeOwnedAdapters(owned: SidecarChild[] | undefined, removed: SidecarChild[]) {
|
||||||
if (!owned?.length || !removed.length) return
|
if (!owned?.length || !removed.length) return
|
||||||
const removedSet = new Set(removed)
|
const removedSet = new Set(removed)
|
||||||
|
|||||||
@ -43,6 +43,9 @@ export const browserHost: DesktopHost = {
|
|||||||
async getServerUrl() {
|
async getServerUrl() {
|
||||||
unsupported('Resolving the bundled server URL')
|
unsupported('Resolving the bundled server URL')
|
||||||
},
|
},
|
||||||
|
async getLocalAccessToken() {
|
||||||
|
unsupported('Resolving the bundled server access token')
|
||||||
|
},
|
||||||
},
|
},
|
||||||
app: {
|
app: {
|
||||||
async getVersion() {
|
async getVersion() {
|
||||||
|
|||||||
@ -23,6 +23,7 @@ describe('desktop host contract', () => {
|
|||||||
|
|
||||||
it('rejects desktop-only browser calls with actionable errors', async () => {
|
it('rejects desktop-only browser calls with actionable errors', async () => {
|
||||||
await expect(browserHost.runtime.getServerUrl()).rejects.toThrow('desktop app runtime')
|
await expect(browserHost.runtime.getServerUrl()).rejects.toThrow('desktop app runtime')
|
||||||
|
await expect(browserHost.runtime.getLocalAccessToken()).rejects.toThrow('desktop app runtime')
|
||||||
await expect(browserHost.dialogs.open({ directory: true })).rejects.toThrow('desktop app runtime')
|
await expect(browserHost.dialogs.open({ directory: true })).rejects.toThrow('desktop app runtime')
|
||||||
await expect(browserHost.shell.openPath('/tmp/report.md')).rejects.toThrow('desktop app runtime')
|
await expect(browserHost.shell.openPath('/tmp/report.md')).rejects.toThrow('desktop app runtime')
|
||||||
await expect(browserHost.terminal.spawn({ cwd: '/tmp', cols: 80, rows: 24 })).rejects.toThrow(
|
await expect(browserHost.terminal.spawn({ cwd: '/tmp', cols: 80, rows: 24 })).rejects.toThrow(
|
||||||
|
|||||||
@ -75,6 +75,7 @@ export function createElectronHost(bridge: ElectronHostBridge): DesktopHost {
|
|||||||
},
|
},
|
||||||
runtime: {
|
runtime: {
|
||||||
getServerUrl: () => invoke(ELECTRON_IPC_CHANNELS.runtimeGetServerUrl),
|
getServerUrl: () => invoke(ELECTRON_IPC_CHANNELS.runtimeGetServerUrl),
|
||||||
|
getLocalAccessToken: () => invoke(ELECTRON_IPC_CHANNELS.runtimeGetLocalAccessToken),
|
||||||
},
|
},
|
||||||
app: {
|
app: {
|
||||||
getVersion: () => invoke(ELECTRON_IPC_CHANNELS.appGetVersion),
|
getVersion: () => invoke(ELECTRON_IPC_CHANNELS.appGetVersion),
|
||||||
|
|||||||
@ -142,6 +142,7 @@ export type DesktopHost = {
|
|||||||
capabilities: DesktopHostCapabilities
|
capabilities: DesktopHostCapabilities
|
||||||
runtime: {
|
runtime: {
|
||||||
getServerUrl(): Promise<string>
|
getServerUrl(): Promise<string>
|
||||||
|
getLocalAccessToken(): Promise<string | null>
|
||||||
}
|
}
|
||||||
app: {
|
app: {
|
||||||
getVersion(): Promise<string>
|
getVersion(): Promise<string>
|
||||||
|
|||||||
@ -138,6 +138,7 @@ describe('desktopRuntime browser H5 bootstrap', () => {
|
|||||||
isDesktop: true,
|
isDesktop: true,
|
||||||
runtime: {
|
runtime: {
|
||||||
getServerUrl: vi.fn().mockResolvedValue(serverUrl),
|
getServerUrl: vi.fn().mockResolvedValue(serverUrl),
|
||||||
|
getLocalAccessToken: vi.fn().mockResolvedValue('desktop-local-token'),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
globalThis.fetch = vi.fn().mockResolvedValue(
|
globalThis.fetch = vi.fn().mockResolvedValue(
|
||||||
@ -148,7 +149,7 @@ describe('desktopRuntime browser H5 bootstrap', () => {
|
|||||||
|
|
||||||
expect(window.desktopHost.runtime.getServerUrl).toHaveBeenCalledTimes(1)
|
expect(window.desktopHost.runtime.getServerUrl).toHaveBeenCalledTimes(1)
|
||||||
expect(clientMocks.setBaseUrl).toHaveBeenLastCalledWith(serverUrl)
|
expect(clientMocks.setBaseUrl).toHaveBeenLastCalledWith(serverUrl)
|
||||||
expect(clientMocks.setAuthToken).toHaveBeenLastCalledWith(null)
|
expect(clientMocks.setAuthToken).toHaveBeenLastCalledWith('desktop-local-token')
|
||||||
expect(globalThis.fetch).toHaveBeenCalledWith(`${serverUrl}/health`, {
|
expect(globalThis.fetch).toHaveBeenCalledWith(`${serverUrl}/health`, {
|
||||||
cache: 'no-store',
|
cache: 'no-store',
|
||||||
})
|
})
|
||||||
@ -178,6 +179,7 @@ describe('desktopRuntime browser H5 bootstrap', () => {
|
|||||||
isDesktop: true,
|
isDesktop: true,
|
||||||
runtime: {
|
runtime: {
|
||||||
getServerUrl: vi.fn().mockRejectedValue(error),
|
getServerUrl: vi.fn().mockRejectedValue(error),
|
||||||
|
getLocalAccessToken: vi.fn().mockResolvedValue('desktop-local-token'),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -163,9 +163,12 @@ export async function initializeDesktopServerUrl() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const serverUrl = await host.runtime.getServerUrl()
|
const [serverUrl, localAccessToken] = await Promise.all([
|
||||||
|
host.runtime.getServerUrl(),
|
||||||
|
host.runtime.getLocalAccessToken(),
|
||||||
|
])
|
||||||
setBaseUrl(serverUrl)
|
setBaseUrl(serverUrl)
|
||||||
setAuthToken(null)
|
setAuthToken(localAccessToken)
|
||||||
await waitForHealth(serverUrl)
|
await waitForHealth(serverUrl)
|
||||||
markDesktopServerReady()
|
markDesktopServerReady()
|
||||||
return serverUrl
|
return serverUrl
|
||||||
|
|||||||
@ -484,6 +484,7 @@ describe('updateStore', () => {
|
|||||||
updates: true,
|
updates: true,
|
||||||
},
|
},
|
||||||
runtime: {
|
runtime: {
|
||||||
|
...browserHost.runtime,
|
||||||
getServerUrl,
|
getServerUrl,
|
||||||
},
|
},
|
||||||
updates: {
|
updates: {
|
||||||
|
|||||||
@ -21,6 +21,7 @@ let originalAnthropicApiKey: string | undefined
|
|||||||
let originalH5DistDir: string | undefined
|
let originalH5DistDir: string | undefined
|
||||||
let originalClaudeAppRoot: string | undefined
|
let originalClaudeAppRoot: string | undefined
|
||||||
let originalServerAuthRequired: string | undefined
|
let originalServerAuthRequired: string | undefined
|
||||||
|
let originalLocalAccessToken: string | undefined
|
||||||
let originalServerPort = 3456
|
let originalServerPort = 3456
|
||||||
const PHONE_ORIGIN = 'https://phone.example'
|
const PHONE_ORIGIN = 'https://phone.example'
|
||||||
|
|
||||||
@ -192,11 +193,13 @@ beforeEach(async () => {
|
|||||||
originalH5DistDir = process.env.CLAUDE_H5_DIST_DIR
|
originalH5DistDir = process.env.CLAUDE_H5_DIST_DIR
|
||||||
originalClaudeAppRoot = process.env.CLAUDE_APP_ROOT
|
originalClaudeAppRoot = process.env.CLAUDE_APP_ROOT
|
||||||
originalServerAuthRequired = process.env.SERVER_AUTH_REQUIRED
|
originalServerAuthRequired = process.env.SERVER_AUTH_REQUIRED
|
||||||
|
originalLocalAccessToken = process.env.CC_HAHA_LOCAL_ACCESS_TOKEN
|
||||||
originalServerPort = ProviderService.getServerPort()
|
originalServerPort = ProviderService.getServerPort()
|
||||||
process.env.CLAUDE_CONFIG_DIR = tmpDir
|
process.env.CLAUDE_CONFIG_DIR = tmpDir
|
||||||
const h5DistDir = path.join(tmpDir, 'dist')
|
const h5DistDir = path.join(tmpDir, 'dist')
|
||||||
process.env.CLAUDE_H5_DIST_DIR = h5DistDir
|
process.env.CLAUDE_H5_DIST_DIR = h5DistDir
|
||||||
delete process.env.ANTHROPIC_API_KEY
|
delete process.env.ANTHROPIC_API_KEY
|
||||||
|
delete process.env.CC_HAHA_LOCAL_ACCESS_TOKEN
|
||||||
await fs.mkdir(path.join(h5DistDir, 'assets'), { recursive: true })
|
await fs.mkdir(path.join(h5DistDir, 'assets'), { recursive: true })
|
||||||
await fs.writeFile(
|
await fs.writeFile(
|
||||||
path.join(h5DistDir, 'index.html'),
|
path.join(h5DistDir, 'index.html'),
|
||||||
@ -224,6 +227,8 @@ afterEach(async () => {
|
|||||||
else process.env.CLAUDE_APP_ROOT = originalClaudeAppRoot
|
else process.env.CLAUDE_APP_ROOT = originalClaudeAppRoot
|
||||||
if (originalServerAuthRequired === undefined) delete process.env.SERVER_AUTH_REQUIRED
|
if (originalServerAuthRequired === undefined) delete process.env.SERVER_AUTH_REQUIRED
|
||||||
else process.env.SERVER_AUTH_REQUIRED = originalServerAuthRequired
|
else process.env.SERVER_AUTH_REQUIRED = originalServerAuthRequired
|
||||||
|
if (originalLocalAccessToken === undefined) delete process.env.CC_HAHA_LOCAL_ACCESS_TOKEN
|
||||||
|
else process.env.CC_HAHA_LOCAL_ACCESS_TOKEN = originalLocalAccessToken
|
||||||
|
|
||||||
await fs.rm(tmpDir, { recursive: true, force: true })
|
await fs.rm(tmpDir, { recursive: true, force: true })
|
||||||
})
|
})
|
||||||
@ -292,6 +297,20 @@ describe('remote H5 auth and CORS integration', () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('rejects a tokenless loopback proxy hop when desktop local auth is configured', async () => {
|
||||||
|
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 desktopResponse = await fetch(`${baseUrl}/api/status`, {
|
||||||
|
headers: { Authorization: 'Bearer desktop-local-secret' },
|
||||||
|
})
|
||||||
|
expect(desktopResponse.status).toBe(200)
|
||||||
|
await expect(desktopResponse.json()).resolves.toMatchObject({ status: 'ok' })
|
||||||
|
})
|
||||||
|
|
||||||
test('does not keep retired Tauri origins trusted after Electron replacement', async () => {
|
test('does not keep retired Tauri origins trusted after Electron replacement', async () => {
|
||||||
const response = await fetch(`${baseUrl}/api/status`, {
|
const response = await fetch(`${baseUrl}/api/status`, {
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
@ -57,6 +57,18 @@ describe('h5AccessPolicy', () => {
|
|||||||
expect(shouldRequireH5Token({ request, url: new URL(request.url), h5Enabled: true, context: remoteContext })).toBe(false)
|
expect(shouldRequireH5Token({ request, url: new URL(request.url), h5Enabled: true, context: remoteContext })).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('accepts an SDK route only after its session token is authorized', () => {
|
||||||
|
const request = req('http://127.0.0.1:3456/sdk/session-1?token=sdk-secret')
|
||||||
|
const configuredContext = {
|
||||||
|
clientAddress: '127.0.0.1',
|
||||||
|
localAccessTokenConfigured: true,
|
||||||
|
localAccessAuthorized: false,
|
||||||
|
internalSdkAuthorized: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(classifyH5Request(request, new URL(request.url), configuredContext)).toBe('internal-sdk')
|
||||||
|
})
|
||||||
|
|
||||||
test('keeps adapter API routes tokenless for local integrations', () => {
|
test('keeps adapter API routes tokenless for local integrations', () => {
|
||||||
const request = req('http://127.0.0.1:3456/api/adapters')
|
const request = req('http://127.0.0.1:3456/api/adapters')
|
||||||
expect(classifyH5Request(request, new URL(request.url), localContext)).toBe('local-trusted')
|
expect(classifyH5Request(request, new URL(request.url), localContext)).toBe('local-trusted')
|
||||||
@ -181,6 +193,30 @@ 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)
|
||||||
|
const unauthorizedContext = {
|
||||||
|
clientAddress: '127.0.0.1',
|
||||||
|
localAccessTokenConfigured: true,
|
||||||
|
localAccessAuthorized: false,
|
||||||
|
}
|
||||||
|
const authorizedContext = {
|
||||||
|
...unauthorizedContext,
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
|
||||||
test('does not grant internal SDK trust to a request carrying proxy traces', () => {
|
test('does not grant internal SDK trust to a request carrying proxy traces', () => {
|
||||||
const request = req('http://127.0.0.1:3456/sdk/session-1', {
|
const request = req('http://127.0.0.1:3456/sdk/session-1', {
|
||||||
headers: { 'X-Forwarded-For': '203.0.113.9' },
|
headers: { 'X-Forwarded-For': '203.0.113.9' },
|
||||||
|
|||||||
36
src/server/__tests__/local-access-auth.test.ts
Normal file
36
src/server/__tests__/local-access-auth.test.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import { afterEach, describe, expect, test } from 'bun:test'
|
||||||
|
import {
|
||||||
|
hasConfiguredLocalAccessToken,
|
||||||
|
isLocalAccessAuthorized,
|
||||||
|
LOCAL_ACCESS_TOKEN_ENV,
|
||||||
|
} from '../localAccessAuth.js'
|
||||||
|
|
||||||
|
const originalToken = process.env[LOCAL_ACCESS_TOKEN_ENV]
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (originalToken === undefined) {
|
||||||
|
delete process.env[LOCAL_ACCESS_TOKEN_ENV]
|
||||||
|
} else {
|
||||||
|
process.env[LOCAL_ACCESS_TOKEN_ENV] = originalToken
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('localAccessAuth', () => {
|
||||||
|
test('accepts only the configured process token', () => {
|
||||||
|
process.env[LOCAL_ACCESS_TOKEN_ENV] = 'desktop-secret'
|
||||||
|
|
||||||
|
expect(hasConfiguredLocalAccessToken()).toBe(true)
|
||||||
|
expect(isLocalAccessAuthorized(new Request('http://127.0.0.1:3456/api/status', {
|
||||||
|
headers: { Authorization: 'Bearer desktop-secret' },
|
||||||
|
}))).toBe(true)
|
||||||
|
expect(isLocalAccessAuthorized(new Request('http://127.0.0.1:3456/api/status'), 'desktop-secret')).toBe(true)
|
||||||
|
expect(isLocalAccessAuthorized(new Request('http://127.0.0.1:3456/api/status'), 'wrong')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('stays disabled when the process token is absent', () => {
|
||||||
|
delete process.env[LOCAL_ACCESS_TOKEN_ENV]
|
||||||
|
|
||||||
|
expect(hasConfiguredLocalAccessToken()).toBe(false)
|
||||||
|
expect(isLocalAccessAuthorized(new Request('http://127.0.0.1:3456/api/status'), 'anything')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -1,6 +1,9 @@
|
|||||||
export type H5RequestKind = 'local-trusted' | 'internal-sdk' | 'h5-browser'
|
export type H5RequestKind = 'local-trusted' | 'internal-sdk' | 'h5-browser'
|
||||||
export type H5RequestContext = {
|
export type H5RequestContext = {
|
||||||
clientAddress: string | null
|
clientAddress: string | null
|
||||||
|
localAccessTokenConfigured?: boolean
|
||||||
|
localAccessAuthorized?: boolean
|
||||||
|
internalSdkAuthorized?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const LOCAL_DESKTOP_ORIGINS = new Set(['file://'])
|
const LOCAL_DESKTOP_ORIGINS = new Set(['file://'])
|
||||||
@ -71,6 +74,10 @@ function isLocalTrustedRequest(
|
|||||||
context: H5RequestContext,
|
context: H5RequestContext,
|
||||||
origin: string | null,
|
origin: string | null,
|
||||||
): boolean {
|
): boolean {
|
||||||
|
if (context.localAccessTokenConfigured) {
|
||||||
|
return context.localAccessAuthorized === true
|
||||||
|
}
|
||||||
|
|
||||||
const clientAddress = context.clientAddress
|
const clientAddress = context.clientAddress
|
||||||
if (!clientAddress) return false
|
if (!clientAddress) return false
|
||||||
if (hasProxyTraceHeaders(request.headers)) return false
|
if (hasProxyTraceHeaders(request.headers)) return false
|
||||||
@ -96,7 +103,7 @@ export function classifyH5Request(
|
|||||||
return localTrusted ? 'local-trusted' : 'h5-browser'
|
return localTrusted ? 'local-trusted' : 'h5-browser'
|
||||||
}
|
}
|
||||||
|
|
||||||
if (url.pathname.startsWith('/sdk/') && localTrusted) {
|
if (url.pathname.startsWith('/sdk/') && (localTrusted || context.internalSdkAuthorized)) {
|
||||||
return 'internal-sdk'
|
return 'internal-sdk'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -28,6 +28,10 @@ import { handleStaticH5Request } from './staticH5.js'
|
|||||||
import { classifyH5Request, shouldBlockDisabledH5Access, shouldRequireH5Token } from './h5AccessPolicy.js'
|
import { classifyH5Request, shouldBlockDisabledH5Access, shouldRequireH5Token } from './h5AccessPolicy.js'
|
||||||
import { H5AccessService } from './services/h5AccessService.js'
|
import { H5AccessService } from './services/h5AccessService.js'
|
||||||
import { refreshDisconnectGraceMs } from './ws/disconnectGraceConfig.js'
|
import { refreshDisconnectGraceMs } from './ws/disconnectGraceConfig.js'
|
||||||
|
import {
|
||||||
|
hasConfiguredLocalAccessToken,
|
||||||
|
isLocalAccessAuthorized,
|
||||||
|
} from './localAccessAuth.js'
|
||||||
|
|
||||||
function readArgValue(flag: string): string | undefined {
|
function readArgValue(flag: string): string | undefined {
|
||||||
const args = process.argv.slice(2)
|
const args = process.argv.slice(2)
|
||||||
@ -164,7 +168,19 @@ export function startServer(port = PORT, host = HOST) {
|
|||||||
const url = new URL(req.url)
|
const url = new URL(req.url)
|
||||||
const origin = req.headers.get('Origin')
|
const origin = req.headers.get('Origin')
|
||||||
const clientAddress = server.requestIP(req)?.address ?? null
|
const clientAddress = server.requestIP(req)?.address ?? null
|
||||||
const h5RequestContext = { clientAddress }
|
const localTokenOverride = url.searchParams.get('localToken') ?? url.searchParams.get('token')
|
||||||
|
const sdkSessionId = url.pathname.startsWith('/sdk/')
|
||||||
|
? url.pathname.split('/').pop() || ''
|
||||||
|
: ''
|
||||||
|
const sdkToken = url.searchParams.get('token')
|
||||||
|
const h5RequestContext = {
|
||||||
|
clientAddress,
|
||||||
|
localAccessTokenConfigured: hasConfiguredLocalAccessToken(),
|
||||||
|
localAccessAuthorized: isLocalAccessAuthorized(req, localTokenOverride),
|
||||||
|
internalSdkAuthorized: Boolean(
|
||||||
|
sdkSessionId && sdkToken && conversationService.authorizeSdkConnection(sdkSessionId, sdkToken),
|
||||||
|
),
|
||||||
|
}
|
||||||
const h5Settings = await h5AccessService.getSettings()
|
const h5Settings = await h5AccessService.getSettings()
|
||||||
const h5PublicOrigin = originFromUrl(h5Settings.publicBaseUrl)
|
const h5PublicOrigin = originFromUrl(h5Settings.publicBaseUrl)
|
||||||
const cors = await resolveCors(origin, url.origin, {
|
const cors = await resolveCors(origin, url.origin, {
|
||||||
|
|||||||
37
src/server/localAccessAuth.ts
Normal file
37
src/server/localAccessAuth.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import { timingSafeEqual } from 'node:crypto'
|
||||||
|
|
||||||
|
export const LOCAL_ACCESS_TOKEN_ENV = 'CC_HAHA_LOCAL_ACCESS_TOKEN'
|
||||||
|
|
||||||
|
function configuredLocalAccessToken(): string | null {
|
||||||
|
const token = process.env[LOCAL_ACCESS_TOKEN_ENV]?.trim()
|
||||||
|
return token || null
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokensEqual(actual: string, expected: string): boolean {
|
||||||
|
const actualBuffer = Buffer.from(actual)
|
||||||
|
const expectedBuffer = Buffer.from(expected)
|
||||||
|
return actualBuffer.length === expectedBuffer.length &&
|
||||||
|
timingSafeEqual(actualBuffer, expectedBuffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
function bearerToken(request: Request): string | null {
|
||||||
|
const authorization = request.headers.get('Authorization')
|
||||||
|
if (!authorization) return null
|
||||||
|
const [scheme, token] = authorization.split(' ')
|
||||||
|
return scheme === 'Bearer' && token ? token : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasConfiguredLocalAccessToken(): boolean {
|
||||||
|
return configuredLocalAccessToken() !== null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLocalAccessAuthorized(
|
||||||
|
request: Request,
|
||||||
|
tokenOverride?: string | null,
|
||||||
|
): boolean {
|
||||||
|
const expected = configuredLocalAccessToken()
|
||||||
|
if (!expected) return false
|
||||||
|
|
||||||
|
const candidate = tokenOverride ?? bearerToken(request)
|
||||||
|
return candidate ? tokensEqual(candidate, expected) : false
|
||||||
|
}
|
||||||
@ -6,6 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { H5AccessService } from '../services/h5AccessService.js'
|
import { H5AccessService } from '../services/h5AccessService.js'
|
||||||
|
import { isLocalAccessAuthorized } from '../localAccessAuth.js'
|
||||||
|
|
||||||
type AuthResult = { valid: boolean; error?: string }
|
type AuthResult = { valid: boolean; error?: string }
|
||||||
|
|
||||||
@ -48,6 +49,10 @@ export async function validateRequestAuth(
|
|||||||
req: Request,
|
req: Request,
|
||||||
tokenOverride?: string | null,
|
tokenOverride?: string | null,
|
||||||
): Promise<AuthResult> {
|
): Promise<AuthResult> {
|
||||||
|
if (isLocalAccessAuthorized(req, tokenOverride)) {
|
||||||
|
return { valid: true }
|
||||||
|
}
|
||||||
|
|
||||||
const anthropicAuth = validateAuth(req)
|
const anthropicAuth = validateAuth(req)
|
||||||
if (anthropicAuth.valid) {
|
if (anthropicAuth.valid) {
|
||||||
return anthropicAuth
|
return anthropicAuth
|
||||||
|
|||||||
@ -59,6 +59,15 @@ import { getDisconnectGraceMs } from './disconnectGraceConfig.js'
|
|||||||
const settingsService = new SettingsService()
|
const settingsService = new SettingsService()
|
||||||
const providerService = new ProviderService()
|
const providerService = new ProviderService()
|
||||||
|
|
||||||
|
function buildSdkWebSocketUrl(
|
||||||
|
ws: ServerWebSocket<WebSocketData>,
|
||||||
|
sessionId: string,
|
||||||
|
): string {
|
||||||
|
const url = new URL(`ws://${ws.data.serverHost}:${ws.data.serverPort}/sdk/${sessionId}`)
|
||||||
|
url.searchParams.set('token', crypto.randomUUID())
|
||||||
|
return url.toString()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cache slash commands from CLI init messages, keyed by sessionId.
|
* Cache slash commands from CLI init messages, keyed by sessionId.
|
||||||
*/
|
*/
|
||||||
@ -931,9 +940,7 @@ async function restartSessionWithPermissionMode(
|
|||||||
...await getRuntimeSettings(sessionId),
|
...await getRuntimeSettings(sessionId),
|
||||||
permissionMode: mode,
|
permissionMode: mode,
|
||||||
}
|
}
|
||||||
const sdkUrl =
|
const sdkUrl = buildSdkWebSocketUrl(ws, sessionId)
|
||||||
`ws://${ws.data.serverHost}:${ws.data.serverPort}/sdk/${sessionId}` +
|
|
||||||
`?token=${encodeURIComponent(crypto.randomUUID())}`
|
|
||||||
await conversationService.startSession(sessionId, workDir, sdkUrl, runtimeSettings)
|
await conversationService.startSession(sessionId, workDir, sdkUrl, runtimeSettings)
|
||||||
|
|
||||||
await commitConfirmedPermissionMode(sessionId, mode, workDir)
|
await commitConfirmedPermissionMode(sessionId, mode, workDir)
|
||||||
@ -1020,9 +1027,7 @@ async function restartSessionWithRuntimeConfig(
|
|||||||
conversationService.stopSession(sessionId)
|
conversationService.stopSession(sessionId)
|
||||||
|
|
||||||
const runtimeSettings = await getRuntimeSettings(sessionId)
|
const runtimeSettings = await getRuntimeSettings(sessionId)
|
||||||
const sdkUrl =
|
const sdkUrl = buildSdkWebSocketUrl(ws, sessionId)
|
||||||
`ws://${ws.data.serverHost}:${ws.data.serverPort}/sdk/${sessionId}` +
|
|
||||||
`?token=${encodeURIComponent(crypto.randomUUID())}`
|
|
||||||
await conversationService.startSession(sessionId, workDir, sdkUrl, runtimeSettings)
|
await conversationService.startSession(sessionId, workDir, sdkUrl, runtimeSettings)
|
||||||
|
|
||||||
sendMessage(ws, { type: 'status', state: 'idle' })
|
sendMessage(ws, { type: 'status', state: 'idle' })
|
||||||
@ -1576,9 +1581,7 @@ async function ensureCliSessionStarted(
|
|||||||
const startupSettings = reason === 'prewarm_session'
|
const startupSettings = reason === 'prewarm_session'
|
||||||
? { ...runtimeSettings, resumeInterruptedTurn: false }
|
? { ...runtimeSettings, resumeInterruptedTurn: false }
|
||||||
: runtimeSettings
|
: runtimeSettings
|
||||||
const sdkUrl =
|
const sdkUrl = buildSdkWebSocketUrl(ws, sessionId)
|
||||||
`ws://${ws.data.serverHost}:${ws.data.serverPort}/sdk/${sessionId}` +
|
|
||||||
`?token=${encodeURIComponent(crypto.randomUUID())}`
|
|
||||||
await sendRepositoryStartupStatus(ws, sessionId, reason)
|
await sendRepositoryStartupStatus(ws, sessionId, reason)
|
||||||
console.log(`[WS] Starting CLI for ${sessionId} due to ${reason}`)
|
console.log(`[WS] Starting CLI for ${sessionId} due to ${reason}`)
|
||||||
await conversationService.startSession(sessionId, workDir, sdkUrl, startupSettings)
|
await conversationService.startSession(sessionId, workDir, sdkUrl, startupSettings)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user