Merge branch 'main' into fix(adapters)-shorten-Windows-paths-in-tool-summaries

This commit is contained in:
Raphaelowo 2026-07-14 02:21:18 +08:00 committed by GitHub
commit d0d09dbe05
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 392 additions and 40 deletions

View File

@ -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 },

View File

@ -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[] = []

View File

@ -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),

View File

@ -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

View File

@ -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,

View File

@ -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',

View File

@ -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: '*/*' } })
}) })
}) })

View File

@ -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, () => {

View File

@ -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'>,

View File

@ -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({

View File

@ -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)

View File

@ -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() {

View File

@ -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(

View File

@ -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),

View File

@ -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>

View File

@ -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'),
}, },
} }

View File

@ -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

View File

@ -484,6 +484,7 @@ describe('updateStore', () => {
updates: true, updates: true,
}, },
runtime: { runtime: {
...browserHost.runtime,
getServerUrl, getServerUrl,
}, },
updates: { updates: {

View File

@ -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: {

View File

@ -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' },

View 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)
})
})

View File

@ -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'
} }

View File

@ -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, {

View 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
}

View File

@ -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

View File

@ -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)