Merge H5 access stability fixes (#767, #764)

This commit is contained in:
程序员阿江(Relakkes) 2026-06-12 16:37:09 +08:00
commit 90a50eca1c
25 changed files with 1340 additions and 70 deletions

View File

@ -6,14 +6,16 @@ import {
killSidecar,
mergeProxyEnv,
POWERSHELL_PATH_OVERRIDE_ENV,
preferredServerPorts,
proxyUrlFromElectronProxyRules,
pushStartupLog,
reserveLocalPort,
reserveServerPort,
SERVER_BIND_HOST,
SERVER_CONTROL_HOST,
spawnSidecar,
waitForServer,
windowsPowerShellOverride,
writeLastServerPort,
type SidecarChild,
} from './sidecarManager'
import { readDesktopTerminalConfig, resolveDesktopTerminalShell } from './terminal'
@ -76,7 +78,9 @@ export class ElectronServerRuntime {
}
private async startServerOnce(): Promise<string> {
const port = await reserveLocalPort(SERVER_BIND_HOST)
// Prefer the configured fixed port, then the previous run's port, so
// phone bookmarks / QR codes / reverse proxies survive restarts (#767).
const port = await reserveServerPort(SERVER_BIND_HOST, preferredServerPorts())
const url = `http://${SERVER_CONTROL_HOST}:${port}`
const logs: string[] = []
const env = await this.resolveSidecarBaseEnv()
@ -92,6 +96,7 @@ export class ElectronServerRuntime {
const child = spawnSidecar(plan)
this.captureLogs(child, 'claude-server', logs)
await waitForServer(SERVER_CONTROL_HOST, port)
writeLastServerPort(port)
this.server = { url, child }
this.startupError = null
await this.startAdaptersSidecars(url)

View File

@ -1,6 +1,7 @@
import { describe, expect, it, vi } from 'vitest'
import net from 'node:net'
import path from 'node:path'
import { mkdtempSync, rmSync } from 'node:fs'
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import {
buildSidecarEnv,
@ -9,11 +10,18 @@ import {
httpToWebSocketUrl,
killSidecar,
mergeProxyEnv,
parseH5FixedPort,
preferredServerPorts,
proxyUrlFromElectronProxyRules,
pushStartupLog,
readH5FixedPort,
readLastServerPort,
reserveLocalPort,
reserveServerPort,
resolveHostTriple,
spawnSidecar,
windowsPowerShellOverride,
writeLastServerPort,
type SidecarChild,
} from './sidecarManager'
@ -191,4 +199,67 @@ describe('Electron sidecar manager', () => {
expect(windowsPowerShellOverride('pwsh', 'darwin')).toBeNull()
expect(windowsPowerShellOverride('powershell.exe', 'linux')).toBeNull()
})
it('parses only in-range integer h5Access.fixedPort values', () => {
expect(parseH5FixedPort('{"h5Access":{"fixedPort":28670}}')).toBe(28670)
expect(parseH5FixedPort('{"h5Access":{"fixedPort":80}}')).toBeNull()
expect(parseH5FixedPort('{"h5Access":{"fixedPort":70000}}')).toBeNull()
expect(parseH5FixedPort('{"h5Access":{"fixedPort":"3456"}}')).toBeNull()
expect(parseH5FixedPort('{"h5Access":{"fixedPort":null}}')).toBeNull()
expect(parseH5FixedPort('{"h5Access":{}}')).toBeNull()
expect(parseH5FixedPort('{}')).toBeNull()
expect(parseH5FixedPort('not json')).toBeNull()
})
it('persists and prioritizes preferred server ports from the config dir', () => {
const configDir = mkdtempSync(path.join(tmpdir(), 'cchh-server-state-'))
const env = { CLAUDE_CONFIG_DIR: configDir } as NodeJS.ProcessEnv
try {
// Nothing stored yet: no preferred ports.
expect(preferredServerPorts(env)).toEqual([])
// Sticky port from the previous run.
writeLastServerPort(50123, env)
expect(readLastServerPort(env)).toBe(50123)
expect(preferredServerPorts(env)).toEqual([50123])
// An explicit fixed port wins over the sticky port.
mkdirSync(path.join(configDir, 'cc-haha'), { recursive: true })
writeFileSync(
path.join(configDir, 'cc-haha', 'settings.json'),
JSON.stringify({ h5Access: { fixedPort: 28670 } }),
'utf-8',
)
expect(readH5FixedPort(env)).toBe(28670)
expect(preferredServerPorts(env)).toEqual([28670, 50123])
// Identical fixed and sticky ports are not duplicated.
writeLastServerPort(28670, env)
expect(preferredServerPorts(env)).toEqual([28670])
} finally {
rmSync(configDir, { recursive: true, force: true })
}
})
it('reserves a free preferred port and falls back when it is taken', async () => {
// Reserve a random free port, verify preference picks it while free.
const freePort = await reserveLocalPort('127.0.0.1')
await expect(reserveServerPort('127.0.0.1', [freePort])).resolves.toBe(freePort)
// Occupy it and verify the fallback hands out a different port.
const blocker = net.createServer()
await new Promise<void>((resolve, reject) => {
blocker.once('error', reject)
blocker.listen(freePort, '127.0.0.1', () => resolve())
})
try {
const fallback = await reserveServerPort('127.0.0.1', [freePort])
expect(fallback).not.toBe(freePort)
} finally {
await new Promise<void>(resolve => blocker.close(() => resolve()))
}
// Invalid entries are skipped without throwing.
await expect(reserveServerPort('127.0.0.1', [0, -1, 1.5, 70000])).resolves.toBeGreaterThan(0)
})
})

View File

@ -1,12 +1,19 @@
import { spawn, spawnSync, type ChildProcessByStdio } from 'node:child_process'
import { mkdirSync, existsSync } from 'node:fs'
import { mkdirSync, existsSync, readFileSync, writeFileSync } from 'node:fs'
import type { Readable } from 'node:stream'
import net from 'node:net'
import os from 'node:os'
import path from 'node:path'
export const SERVER_BIND_HOST = '0.0.0.0'
export const SERVER_CONTROL_HOST = '127.0.0.1'
export const SERVER_STARTUP_LOG_LIMIT = 80
// Shared with the Tauri shell (src-tauri/src/lib.rs) so both desktop builds
// reuse the same sticky port across restarts (issue #767).
export const SERVER_STATE_FILE = 'desktop-server-state.json'
// Mirrors the server-side fixedPort range (h5AccessService MIN/MAX_FIXED_PORT).
const MIN_FIXED_PORT = 1024
const MAX_FIXED_PORT = 65535
export type SidecarChild = ChildProcessByStdio<null, Readable, Readable>
@ -66,6 +73,95 @@ export async function reserveLocalPort(bindHost = SERVER_BIND_HOST): Promise<num
})
}
function canBindPort(bindHost: string, port: number): Promise<boolean> {
return new Promise(resolve => {
const server = net.createServer()
server.once('error', () => resolve(false))
server.listen(port, bindHost, () => {
server.close(() => resolve(true))
})
})
}
/**
* Try the preferred ports in order (h5Access.fixedPort first, then the port
* used by the previous run) and fall back to an OS-assigned random port when
* all of them are taken, so the app always starts.
*/
export async function reserveServerPort(
bindHost: string,
preferred: number[],
): Promise<number> {
for (const port of preferred) {
if (!Number.isInteger(port) || port <= 0 || port > 65535) continue
if (await canBindPort(bindHost, port)) return port
console.error(`[desktop] preferred server port ${port} unavailable`)
}
return await reserveLocalPort(bindHost)
}
export function claudeConfigDir(env: NodeJS.ProcessEnv = process.env): string {
return env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
}
/** Parse h5Access.fixedPort out of cc-haha/settings.json contents. */
export function parseH5FixedPort(contents: string): number | null {
let value: unknown
try {
value = JSON.parse(contents)
} catch {
return null
}
if (!value || typeof value !== 'object') return null
const h5Access = (value as Record<string, unknown>).h5Access
if (!h5Access || typeof h5Access !== 'object') return null
const port = (h5Access as Record<string, unknown>).fixedPort
if (typeof port !== 'number' || !Number.isInteger(port)) return null
return port >= MIN_FIXED_PORT && port <= MAX_FIXED_PORT ? port : null
}
export function readH5FixedPort(env: NodeJS.ProcessEnv = process.env): number | null {
try {
const settingsPath = path.join(claudeConfigDir(env), 'cc-haha', 'settings.json')
return parseH5FixedPort(readFileSync(settingsPath, 'utf-8'))
} catch {
return null
}
}
export function readLastServerPort(env: NodeJS.ProcessEnv = process.env): number | null {
try {
const statePath = path.join(claudeConfigDir(env), SERVER_STATE_FILE)
const state: unknown = JSON.parse(readFileSync(statePath, 'utf-8'))
if (!state || typeof state !== 'object') return null
const port = (state as Record<string, unknown>).lastPort
if (typeof port !== 'number' || !Number.isInteger(port)) return null
return port > 0 && port <= 65535 ? port : null
} catch {
return null
}
}
export function writeLastServerPort(port: number, env: NodeJS.ProcessEnv = process.env): void {
try {
const dir = claudeConfigDir(env)
mkdirSync(dir, { recursive: true })
writeFileSync(path.join(dir, SERVER_STATE_FILE), `${JSON.stringify({ lastPort: port }, null, 2)}\n`, 'utf-8')
} catch (error) {
console.error('[desktop] failed to persist server state', error)
}
}
/** Preferred ports for the next server start: explicit fixed port first, then the sticky last-used port. */
export function preferredServerPorts(env: NodeJS.ProcessEnv = process.env): number[] {
const ports: number[] = []
const fixedPort = readH5FixedPort(env)
if (fixedPort !== null) ports.push(fixedPort)
const lastPort = readLastServerPort(env)
if (lastPort !== null && !ports.includes(lastPort)) ports.push(lastPort)
return ports
}
export async function waitForServer(host: string, port: number, timeoutMs = 10_000): Promise<void> {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {

View File

@ -229,6 +229,7 @@ const TRAY_QUIT_ID: &str = "tray_quit";
const WINDOW_STATE_FILE: &str = "window-state.json";
const TERMINAL_CONFIG_FILE: &str = "terminal-config.json";
const APP_MODE_FILE: &str = "app-mode.json";
const SERVER_STATE_FILE: &str = "desktop-server-state.json";
const MIN_WINDOW_WIDTH: u32 = 960;
const MIN_WINDOW_HEIGHT: u32 = 640;
const MIN_VISIBLE_PIXELS: i64 = 64;
@ -406,6 +407,15 @@ struct StoredWindowState {
maximized: bool,
}
/// 上一次 server sidecar 实际监听的端口。重启时优先复用同一端口,
/// 这样手机书签 / 二维码 / 反向代理的 upstream 不会因为重启而失效
/// issue #767。端口被占用时才回退到随机端口。
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
struct StoredServerState {
#[serde(rename = "lastPort")]
last_port: u16,
}
/// 与 ServerState 平级的 adapter 子进程状态。
///
/// adapter sidecarclaude-sidecar adapters --telegram 等)的生命周期
@ -741,6 +751,69 @@ fn resolve_portable_state_path() -> Option<PathBuf> {
.map(|dir| PathBuf::from(&dir).join(WINDOW_STATE_FILE))
}
fn server_state_path() -> Option<PathBuf> {
// Lives next to cc-haha/settings.json (CLAUDE_CONFIG_DIR or ~/.claude) so
// the Tauri and Electron shells share the same sticky port across builds.
claude_config_dir().map(|dir| dir.join(SERVER_STATE_FILE))
}
fn read_stored_server_state() -> Option<StoredServerState> {
let path = server_state_path()?;
let data = match fs::read_to_string(&path) {
Ok(data) => data,
Err(err) if err.kind() == ErrorKind::NotFound => return None,
Err(err) => {
eprintln!(
"[desktop] failed to read server state {}: {err}",
path.display()
);
return None;
}
};
match serde_json::from_str::<StoredServerState>(&data) {
Ok(state) => Some(state),
Err(err) => {
eprintln!(
"[desktop] failed to parse server state {}: {err}",
path.display()
);
None
}
}
}
fn write_stored_server_state(state: &StoredServerState) {
let Some(path) = server_state_path() else {
return;
};
if let Some(parent) = path.parent() {
if let Err(err) = fs::create_dir_all(parent) {
eprintln!(
"[desktop] failed to create server state directory {}: {err}",
parent.display()
);
return;
}
}
let data = match serde_json::to_string_pretty(state) {
Ok(data) => data,
Err(err) => {
eprintln!("[desktop] failed to serialize server state: {err}");
return;
}
};
if let Err(err) = fs::write(&path, data) {
eprintln!(
"[desktop] failed to write server state {}: {err}",
path.display()
);
}
}
fn read_stored_window_state(app: &AppHandle) -> Option<StoredWindowState> {
let path = window_state_path(app)?;
let data = match fs::read_to_string(&path) {
@ -1370,6 +1443,24 @@ fn desktop_terminal_settings_path() -> Option<PathBuf> {
claude_config_dir().map(|path| path.join("settings.json"))
}
/// 解析 cc-haha/settings.json 里的 h5Access.fixedPort。范围必须与
/// 服务端 h5AccessService 的 MIN/MAX_FIXED_PORT 一致1024..=65535
fn parse_h5_fixed_port(contents: &str) -> Option<u16> {
let value: serde_json::Value = serde_json::from_str(contents).ok()?;
let port = value.get("h5Access")?.get("fixedPort")?.as_u64()?;
if (1024..=65535).contains(&port) {
u16::try_from(port).ok()
} else {
None
}
}
fn read_h5_fixed_port() -> Option<u16> {
let path = claude_config_dir()?.join("cc-haha").join("settings.json");
let contents = fs::read_to_string(path).ok()?;
parse_h5_fixed_port(&contents)
}
fn read_desktop_terminal_config() -> Option<DesktopTerminalConfig> {
let path = desktop_terminal_settings_path()?;
let contents = fs::read_to_string(path).ok()?;
@ -1526,6 +1617,23 @@ fn reserve_local_port(bind_host: &str) -> Result<u16, String> {
Ok(port)
}
/// 按优先级尝试给定端口h5Access.fixedPort > 上次使用的端口),
/// 全部被占用时回退到 OS 随机分配。保证 app 总能启动。
fn reserve_local_port_with_preference(bind_host: &str, preferred: &[u16]) -> Result<u16, String> {
for &port in preferred {
match TcpListener::bind(format!("{bind_host}:{port}")) {
Ok(listener) => {
drop(listener);
return Ok(port);
}
Err(err) => {
eprintln!("[desktop] preferred server port {port} unavailable: {err}");
}
}
}
reserve_local_port(bind_host)
}
fn wait_for_server(url_host: &str, port: u16) -> Result<(), String> {
let addr: SocketAddr = format!("{url_host}:{port}")
.parse()
@ -1619,7 +1727,16 @@ fn resolve_h5_dist_dir(app: &AppHandle, app_root: &Path) -> PathBuf {
fn start_server_sidecar(app: &AppHandle) -> Result<ServerRuntime, String> {
let bind_host = SERVER_BIND_HOST;
let control_host = SERVER_CONTROL_HOST;
let port = reserve_local_port(bind_host)?;
let mut preferred_ports: Vec<u16> = Vec::new();
if let Some(port) = read_h5_fixed_port() {
preferred_ports.push(port);
}
if let Some(state) = read_stored_server_state() {
if !preferred_ports.contains(&state.last_port) {
preferred_ports.push(state.last_port);
}
}
let port = reserve_local_port_with_preference(bind_host, &preferred_ports)?;
let url = format!("http://{control_host}:{port}");
let app_root = resolve_app_root(app)?;
let app_root_arg = app_root.to_string_lossy().to_string();
@ -1707,6 +1824,8 @@ fn start_server_sidecar(app: &AppHandle) -> Result<ServerRuntime, String> {
return Err(format_server_startup_error(&err, &startup_logs));
}
write_stored_server_state(&StoredServerState { last_port: port });
Ok(ServerRuntime { url, child })
}
@ -1968,11 +2087,13 @@ mod tests {
use super::{
decode_terminal_output, default_utf8_locale, dir_has_portable_data, ensure_utf8_locale,
has_meaningful_intersection, is_persistable_window_state, normalize_terminal_bash_path,
parse_env_block, resolve_agent_powershell_path_override, resolve_desktop_terminal_shell,
parse_env_block, parse_h5_fixed_port, reserve_local_port_with_preference,
resolve_agent_powershell_path_override, resolve_desktop_terminal_shell,
resolve_terminal_cwd, run_notification_bridge, select_h5_dist_dir, DesktopTerminalConfig,
StoredWindowState, TerminalHostPlatform, SERVER_BIND_HOST, SERVER_CONTROL_HOST,
StoredServerState, StoredWindowState, TerminalHostPlatform, SERVER_BIND_HOST,
SERVER_CONTROL_HOST,
};
use std::{collections::HashMap, fs};
use std::{collections::HashMap, fs, net::TcpListener};
#[test]
fn window_state_rejects_too_small_sizes() {
@ -2279,6 +2400,66 @@ mod tests {
assert_eq!(SERVER_CONTROL_HOST, "127.0.0.1");
}
#[test]
fn h5_fixed_port_parses_only_valid_in_range_values() {
assert_eq!(
parse_h5_fixed_port(r#"{"h5Access":{"fixedPort":28670}}"#),
Some(28670)
);
// Out of range, wrong type, missing, or null all fall back to None.
assert_eq!(parse_h5_fixed_port(r#"{"h5Access":{"fixedPort":80}}"#), None);
assert_eq!(
parse_h5_fixed_port(r#"{"h5Access":{"fixedPort":70000}}"#),
None
);
assert_eq!(
parse_h5_fixed_port(r#"{"h5Access":{"fixedPort":"3456"}}"#),
None
);
assert_eq!(
parse_h5_fixed_port(r#"{"h5Access":{"fixedPort":null}}"#),
None
);
assert_eq!(parse_h5_fixed_port(r#"{"h5Access":{}}"#), None);
assert_eq!(parse_h5_fixed_port("{}"), None);
assert_eq!(parse_h5_fixed_port("not json"), None);
}
#[test]
fn preferred_port_is_used_when_free_and_skipped_when_taken() {
// Find a port that is currently free, then verify preference picks it.
let probe = TcpListener::bind("127.0.0.1:0").expect("probe bind");
let free_port = probe.local_addr().expect("probe addr").port();
drop(probe);
let reserved = reserve_local_port_with_preference("127.0.0.1", &[free_port])
.expect("reserve preferred");
assert_eq!(reserved, free_port);
// Occupy a port and verify preference falls back to a random one.
let occupied = TcpListener::bind("127.0.0.1:0").expect("occupy bind");
let occupied_port = occupied.local_addr().expect("occupied addr").port();
let fallback = reserve_local_port_with_preference("127.0.0.1", &[occupied_port])
.expect("reserve fallback");
assert_ne!(fallback, occupied_port);
drop(occupied);
// Empty preference list behaves like the plain random reservation.
assert!(reserve_local_port_with_preference("127.0.0.1", &[]).is_ok());
}
#[test]
fn stored_server_state_round_trips_camel_case_json() {
let state = StoredServerState { last_port: 28670 };
let json = serde_json::to_string(&state).expect("serialize");
assert_eq!(json, r#"{"lastPort":28670}"#);
assert_eq!(
serde_json::from_str::<StoredServerState>(&json).expect("parse"),
state
);
}
#[test]
fn h5_dist_dir_prefers_tauri_parent_resource_mapping() {
let root = std::env::temp_dir().join(format!("cchh-h5-dist-test-{}", std::process::id()));

View File

@ -221,9 +221,12 @@ describe('Settings > General tab', () => {
},
h5Access: {
enabled: false,
token: null,
tokenPreview: null,
allowedOrigins: [],
publicBaseUrl: null,
fixedPort: null,
disconnectGraceSeconds: null,
},
h5AccessDiagnostics: null,
h5AccessError: null,
@ -305,18 +308,20 @@ describe('Settings > General tab', () => {
h5Access: {
...current,
enabled: true,
token: 'h5_default_generated_token',
tokenPreview: 'h5_default_generated_token'.slice(0, 8),
},
})
return 'h5_default_generated_token'
}),
disableH5Access: vi.fn().mockImplementation(async () => {
// Mirrors the server: disabling keeps the stored token so a later
// re-enable restores access for already-paired phones.
const current = useSettingsStore.getState().h5Access
useSettingsStore.setState({
h5Access: {
...current,
enabled: false,
tokenPreview: null,
},
})
}),
@ -326,6 +331,7 @@ describe('Settings > General tab', () => {
h5Access: {
...current,
enabled: true,
token: 'h5_default_regenerated_token',
tokenPreview: 'h5_default_regenerated_token'.slice(0, 8),
},
})
@ -913,9 +919,12 @@ describe('Settings > General tab', () => {
useSettingsStore.setState({
h5Access: {
enabled: false,
token: null,
tokenPreview: null,
allowedOrigins: [],
publicBaseUrl: 'http://192.168.0.102:3456',
fixedPort: null,
disconnectGraceSeconds: null,
},
})
render(<Settings />)
@ -940,9 +949,12 @@ describe('Settings > General tab', () => {
useSettingsStore.setState({
h5Access: {
enabled: false,
token: null,
tokenPreview: null,
allowedOrigins: [],
publicBaseUrl: 'http://192.168.0.102:3456',
fixedPort: null,
disconnectGraceSeconds: null,
},
})
render(<Settings />)
@ -973,9 +985,12 @@ describe('Settings > General tab', () => {
useSettingsStore.setState({
h5Access: {
enabled: true,
token: null,
tokenPreview: 'h5oldtok',
allowedOrigins: [],
publicBaseUrl: 'http://192.168.0.102:3456',
fixedPort: null,
disconnectGraceSeconds: null,
},
})
render(<Settings />)
@ -995,13 +1010,184 @@ describe('Settings > General tab', () => {
expect(await within(section).findByAltText('H5 access QR code')).toBeInTheDocument()
})
it('renders the QR code and token from persisted settings without any action (issue #767)', async () => {
useSettingsStore.setState({
h5Access: {
enabled: true,
token: 'h5_persisted_token',
tokenPreview: 'h5_pers...oken',
allowedOrigins: [],
publicBaseUrl: 'http://192.168.0.102:3456',
fixedPort: null,
disconnectGraceSeconds: null,
},
})
render(<Settings />)
fireEvent.click(screen.getByText('H5 Access'))
const section = screen.getByRole('region', { name: 'H5 Access' })
// No enable/regenerate click this session: everything comes from the
// persisted token, so a desktop restart no longer loses the QR code.
expect(await within(section).findByAltText('H5 access QR code')).toBeInTheDocument()
expect(within(section).getByText('http://192.168.0.102:3456/?serverUrl=http%3A%2F%2F192.168.0.102%3A3456&h5Token=h5_persisted_token')).toBeInTheDocument()
fireEvent.click(within(section).getByRole('button', { name: 'Show token' }))
expect(within(section).getByText('h5_persisted_token')).toBeInTheDocument()
})
it('saves a fixed port together with the host', async () => {
useSettingsStore.setState({
h5Access: {
enabled: true,
token: 'h5_persisted_token',
tokenPreview: 'h5_pers...oken',
allowedOrigins: [],
publicBaseUrl: 'http://192.168.0.102:54064',
fixedPort: null,
disconnectGraceSeconds: null,
},
})
render(<Settings />)
fireEvent.click(screen.getByText('H5 Access'))
const section = screen.getByRole('region', { name: 'H5 Access' })
fireEvent.change(within(section).getByLabelText('Fixed port'), {
target: { value: '28670' },
})
await act(async () => {
fireEvent.click(within(section).getByRole('button', { name: 'Save H5 settings' }))
})
expect(useSettingsStore.getState().updateH5AccessSettings).toHaveBeenCalledWith({
publicBaseUrl: 'http://192.168.0.102:54064',
fixedPort: 28670,
disconnectGraceSeconds: null,
})
})
it('rejects an out-of-range fixed port before saving', () => {
useSettingsStore.setState({
h5Access: {
enabled: true,
token: 'h5_persisted_token',
tokenPreview: 'h5_pers...oken',
allowedOrigins: [],
publicBaseUrl: 'http://192.168.0.102:54064',
fixedPort: null,
disconnectGraceSeconds: null,
},
})
render(<Settings />)
fireEvent.click(screen.getByText('H5 Access'))
const section = screen.getByRole('region', { name: 'H5 Access' })
fireEvent.change(within(section).getByLabelText('Fixed port'), {
target: { value: '99' },
})
expect(within(section).getByText('Port must be an integer between 1024 and 65535.')).toBeInTheDocument()
expect(within(section).getByRole('button', { name: 'Save H5 settings' })).toBeDisabled()
expect(useSettingsStore.getState().updateH5AccessSettings).not.toHaveBeenCalled()
})
it('saves a custom disconnect grace period (issue #764)', async () => {
useSettingsStore.setState({
h5Access: {
enabled: true,
token: 'h5_persisted_token',
tokenPreview: 'h5_pers...oken',
allowedOrigins: [],
publicBaseUrl: 'http://192.168.0.102:54064',
fixedPort: null,
disconnectGraceSeconds: null,
},
})
render(<Settings />)
fireEvent.click(screen.getByText('H5 Access'))
const section = screen.getByRole('region', { name: 'H5 Access' })
fireEvent.change(within(section).getByLabelText('Disconnect grace (sec)'), {
target: { value: '600' },
})
await act(async () => {
fireEvent.click(within(section).getByRole('button', { name: 'Save H5 settings' }))
})
expect(useSettingsStore.getState().updateH5AccessSettings).toHaveBeenCalledWith({
publicBaseUrl: 'http://192.168.0.102:54064',
fixedPort: null,
disconnectGraceSeconds: 600,
})
})
it('rejects an out-of-range disconnect grace period before saving', () => {
useSettingsStore.setState({
h5Access: {
enabled: true,
token: 'h5_persisted_token',
tokenPreview: 'h5_pers...oken',
allowedOrigins: [],
publicBaseUrl: 'http://192.168.0.102:54064',
fixedPort: null,
disconnectGraceSeconds: null,
},
})
render(<Settings />)
fireEvent.click(screen.getByText('H5 Access'))
const section = screen.getByRole('region', { name: 'H5 Access' })
fireEvent.change(within(section).getByLabelText('Disconnect grace (sec)'), {
target: { value: '2' },
})
expect(within(section).getByText('Must be an integer between 5 and 86400 seconds.')).toBeInTheDocument()
expect(within(section).getByRole('button', { name: 'Save H5 settings' })).toBeDisabled()
expect(useSettingsStore.getState().updateH5AccessSettings).not.toHaveBeenCalled()
})
it('shows a restart note while the saved fixed port is not active yet', () => {
useSettingsStore.setState({
h5Access: {
enabled: true,
token: 'h5_persisted_token',
tokenPreview: 'h5_pers...oken',
allowedOrigins: [],
publicBaseUrl: 'http://192.168.0.102:54064',
fixedPort: 28670,
disconnectGraceSeconds: null,
},
h5AccessDiagnostics: {
storedHostStaleness: 'ok',
storedPublicBaseUrl: 'http://192.168.0.102:54064',
effectivePublicBaseUrl: 'http://192.168.0.102:54064',
suggestedHost: null,
localInterfaceHosts: ['192.168.0.102'],
activePort: 54064,
},
})
render(<Settings />)
fireEvent.click(screen.getByText('H5 Access'))
const section = screen.getByRole('region', { name: 'H5 Access' })
const note = within(section).getByTestId('h5-access-fixed-port-restart-note')
expect(note.textContent).toContain('28670')
expect(note.textContent).toContain('54064')
})
it('shows the generated H5 token as a fallback when requested', async () => {
useSettingsStore.setState({
h5Access: {
enabled: false,
token: null,
tokenPreview: null,
allowedOrigins: [],
publicBaseUrl: 'http://192.168.0.102:3456',
fixedPort: null,
disconnectGraceSeconds: null,
},
})
render(<Settings />)
@ -1023,9 +1209,12 @@ describe('Settings > General tab', () => {
useSettingsStore.setState({
h5Access: {
enabled: true,
token: null,
tokenPreview: 'h5url123',
allowedOrigins: ['https://phone.example'],
publicBaseUrl: 'https://phone.example/app',
fixedPort: null,
disconnectGraceSeconds: null,
},
})
render(<Settings />)
@ -1059,9 +1248,12 @@ describe('Settings > General tab', () => {
useSettingsStore.setState({
h5Access: {
enabled: true,
token: null,
tokenPreview: 'h5a1b2c3',
allowedOrigins: [],
publicBaseUrl: 'http://172.20.16.1:54064',
fixedPort: null,
disconnectGraceSeconds: null,
},
})
render(<Settings />)
@ -1080,6 +1272,8 @@ describe('Settings > General tab', () => {
expect(useSettingsStore.getState().updateH5AccessSettings).toHaveBeenCalledWith({
publicBaseUrl: 'http://192.168.1.100:54064',
fixedPort: null,
disconnectGraceSeconds: null,
})
})
@ -1087,9 +1281,12 @@ describe('Settings > General tab', () => {
useSettingsStore.setState({
h5Access: {
enabled: false,
token: null,
tokenPreview: 'h5a1b2c3',
allowedOrigins: ['https://old.example'],
publicBaseUrl: null,
fixedPort: null,
disconnectGraceSeconds: null,
},
})
render(<Settings />)
@ -1107,6 +1304,8 @@ describe('Settings > General tab', () => {
expect(useSettingsStore.getState().updateH5AccessSettings).toHaveBeenCalledWith({
publicBaseUrl: 'https://phone.example/app',
fixedPort: null,
disconnectGraceSeconds: null,
})
})
@ -1114,9 +1313,12 @@ describe('Settings > General tab', () => {
useSettingsStore.setState({
h5Access: {
enabled: true,
token: null,
tokenPreview: 'h5a1b2c3',
allowedOrigins: [],
publicBaseUrl: 'http://192.168.1.207:55379',
fixedPort: null,
disconnectGraceSeconds: null,
},
h5AccessDiagnostics: {
storedHostStaleness: 'unreachable',
@ -1148,9 +1350,12 @@ describe('Settings > General tab', () => {
useSettingsStore.setState({
h5Access: {
enabled: true,
token: null,
tokenPreview: 'h5a1b2c3',
allowedOrigins: [],
publicBaseUrl: 'https://h5.mydomain.com',
fixedPort: null,
disconnectGraceSeconds: null,
},
h5AccessDiagnostics: {
storedHostStaleness: 'proxy',
@ -1172,9 +1377,12 @@ describe('Settings > General tab', () => {
useSettingsStore.setState({
h5Access: {
enabled: true,
token: null,
tokenPreview: 'h5a1b2c3',
allowedOrigins: [],
publicBaseUrl: 'http://192.168.0.105:55379',
fixedPort: null,
disconnectGraceSeconds: null,
},
h5AccessDiagnostics: {
storedHostStaleness: 'ok',

View File

@ -33,6 +33,8 @@ export const h5AccessApi = {
update(input: {
allowedOrigins?: string[]
publicBaseUrl?: string | null
fixedPort?: number | null
disconnectGraceSeconds?: number | null
}) {
return api.put<H5AccessStatus>('/api/h5-access', input)
},

View File

@ -1025,11 +1025,20 @@ export const en = {
'settings.general.h5AccessUrlCopied': 'H5 URL copied.',
'settings.general.h5AccessLaunchUrlCopied': 'QR link copied.',
'settings.general.h5AccessHideToken': 'Hide token',
'settings.general.h5AccessTokenNotAvailable': 'Regenerate to show a new token.',
'settings.general.h5AccessTokenNotAvailable': 'Tokens from older versions cannot be recovered. Regenerate once and the token will be kept permanently and stay viewable.',
'settings.general.h5AccessPublicHost': 'Access host / IP',
'settings.general.h5AccessPublicHostPlaceholder': '192.168.1.100',
'settings.general.h5AccessCurrentPort': 'Current port',
'settings.general.h5AccessCurrentPortUnknown': 'Auto',
'settings.general.h5AccessFixedPort': 'Fixed port',
'settings.general.h5AccessFixedPortPlaceholder': 'Auto',
'settings.general.h5AccessFixedPortInvalid': 'Port must be an integer between 1024 and 65535.',
'settings.general.h5AccessFixedPortHint': 'Without a fixed port the previous port is reused automatically; pin one for reverse proxies and other setups that need a stable port. Port changes apply after restarting the app.',
'settings.general.h5AccessFixedPortRestartNote': 'Fixed port {fixedPort} takes effect after restarting the app; currently still running on {activePort}.',
'settings.general.h5AccessDisconnectGrace': 'Disconnect grace (sec)',
'settings.general.h5AccessDisconnectGracePlaceholder': '30',
'settings.general.h5AccessDisconnectGraceInvalid': 'Must be an integer between 5 and 86400 seconds.',
'settings.general.h5AccessDisconnectGraceHint': 'When a phone locks its screen or backgrounds the app and disconnects, a running task is never interrupted — it finishes in the background and the result shows on reconnect. The CLI is only stopped after this idle period once the task is done and no client is attached (default 30s). Raise it for remote use on the go, e.g. 600.',
'settings.general.h5AccessPublicUrl': 'Public URL',
'settings.general.h5AccessPublicUrlPlaceholder': 'https://chat.example.com',
'settings.general.h5AccessAllowedOrigins': 'Allowed origins',

View File

@ -1027,11 +1027,20 @@ export const jp: Record<TranslationKey, string> = {
'settings.general.h5AccessUrlCopied': 'H5 URL をコピーしました。',
'settings.general.h5AccessLaunchUrlCopied': 'QR リンクをコピーしました。',
'settings.general.h5AccessHideToken': 'トークンを非表示',
'settings.general.h5AccessTokenNotAvailable': '新しいトークンを表示するには再生成してください。',
'settings.general.h5AccessTokenNotAvailable': '旧バージョンのトークンは復元できません。一度再生成すると、トークンは永続化され、いつでも確認できます。',
'settings.general.h5AccessPublicHost': 'アクセスホスト / IP',
'settings.general.h5AccessPublicHostPlaceholder': '192.168.1.100',
'settings.general.h5AccessCurrentPort': '現在のポート',
'settings.general.h5AccessCurrentPortUnknown': '自動',
'settings.general.h5AccessFixedPort': '固定ポート',
'settings.general.h5AccessFixedPortPlaceholder': '自動',
'settings.general.h5AccessFixedPortInvalid': 'ポートは 1024〜65535 の整数で指定してください。',
'settings.general.h5AccessFixedPortHint': '固定ポート未設定の場合は前回のポートを自動的に再利用します。リバースプロキシなど安定したポートが必要な場合は固定を推奨します。変更はアプリ再起動後に反映されます。',
'settings.general.h5AccessFixedPortRestartNote': '固定ポート {fixedPort} はアプリ再起動後に有効になります。現在は {activePort} で稼働中です。',
'settings.general.h5AccessDisconnectGrace': '切断後の保持(秒)',
'settings.general.h5AccessDisconnectGracePlaceholder': '30',
'settings.general.h5AccessDisconnectGraceInvalid': '5〜86400 の整数(秒)で指定してください。',
'settings.general.h5AccessDisconnectGraceHint': 'スマホの画面ロックやバックグラウンド移行で切断されても、実行中のタスクは中断されずバックグラウンドで完走し、再接続時に結果を確認できます。タスクが終了し誰も接続していない場合のみ、この時間が経過した後に CLI を停止します(デフォルト 30 秒)。外出先でのリモート操作では大きめに、例えば 600 に設定できます。',
'settings.general.h5AccessPublicUrl': '公開 URL',
'settings.general.h5AccessPublicUrlPlaceholder': 'https://chat.example.com',
'settings.general.h5AccessAllowedOrigins': '許可するオリジン',

View File

@ -1027,11 +1027,20 @@ export const kr: Record<TranslationKey, string> = {
'settings.general.h5AccessUrlCopied': 'H5 URL을 복사했습니다.',
'settings.general.h5AccessLaunchUrlCopied': 'QR 링크를 복사했습니다.',
'settings.general.h5AccessHideToken': '토큰 숨기기',
'settings.general.h5AccessTokenNotAvailable': '새 토큰을 표시하려면 다시 생성하세요.',
'settings.general.h5AccessTokenNotAvailable': '이전 버전의 토큰은 복구할 수 없습니다. 한 번 재생성하면 토큰이 영구 보존되어 언제든지 확인할 수 있습니다.',
'settings.general.h5AccessPublicHost': '액세스 호스트 / IP',
'settings.general.h5AccessPublicHostPlaceholder': '192.168.1.100',
'settings.general.h5AccessCurrentPort': '현재 포트',
'settings.general.h5AccessCurrentPortUnknown': '자동',
'settings.general.h5AccessFixedPort': '고정 포트',
'settings.general.h5AccessFixedPortPlaceholder': '자동',
'settings.general.h5AccessFixedPortInvalid': '포트는 1024~65535 사이의 정수여야 합니다.',
'settings.general.h5AccessFixedPortHint': '고정 포트를 설정하지 않으면 이전 포트를 자동으로 재사용합니다. 리버스 프록시 등 안정적인 포트가 필요한 경우 고정을 권장합니다. 변경 사항은 앱 재시작 후 적용됩니다.',
'settings.general.h5AccessFixedPortRestartNote': '고정 포트 {fixedPort}은(는) 앱 재시작 후 적용됩니다. 현재는 {activePort}에서 실행 중입니다.',
'settings.general.h5AccessDisconnectGrace': '연결 끊김 유지(초)',
'settings.general.h5AccessDisconnectGracePlaceholder': '30',
'settings.general.h5AccessDisconnectGraceInvalid': '5~86400 사이의 정수(초)여야 합니다.',
'settings.general.h5AccessDisconnectGraceHint': '휴대폰 화면 잠금이나 백그라운드 전환으로 연결이 끊겨도 실행 중인 작업은 중단되지 않고 백그라운드에서 완료되며, 재연결 시 결과를 볼 수 있습니다. 작업이 끝나고 연결된 클라이언트가 없을 때만 이 시간이 지난 후 CLI를 중지합니다(기본 30초). 외출 중 원격 조작 시 600처럼 크게 설정할 수 있습니다.',
'settings.general.h5AccessPublicUrl': '공개 URL',
'settings.general.h5AccessPublicUrlPlaceholder': 'https://chat.example.com',
'settings.general.h5AccessAllowedOrigins': '허용된 출처',

View File

@ -1027,11 +1027,20 @@ export const zh: Record<TranslationKey, string> = {
'settings.general.h5AccessUrlCopied': 'H5 連結已複製。',
'settings.general.h5AccessLaunchUrlCopied': '掃碼連結已複製。',
'settings.general.h5AccessHideToken': '隱藏令牌',
'settings.general.h5AccessTokenNotAvailable': '重新生成後可顯示新的令牌。',
'settings.general.h5AccessTokenNotAvailable': '舊版本的令牌無法找回。重新生成一次後,令牌將長期保存,可隨時查看。',
'settings.general.h5AccessPublicHost': '訪問主機 / IP',
'settings.general.h5AccessPublicHostPlaceholder': '192.168.1.100',
'settings.general.h5AccessCurrentPort': '當前埠',
'settings.general.h5AccessCurrentPortUnknown': '自動',
'settings.general.h5AccessFixedPort': '固定埠',
'settings.general.h5AccessFixedPortPlaceholder': '自動',
'settings.general.h5AccessFixedPortInvalid': '埠需為 1024-65535 之間的整數。',
'settings.general.h5AccessFixedPortHint': '不設固定埠時會自動沿用上次的埠;反向代理等需要穩定埠的場景建議固定。修改埠後重啟應用程式生效。',
'settings.general.h5AccessFixedPortRestartNote': '固定埠 {fixedPort} 將在重啟應用程式後生效,目前仍執行在 {activePort}。',
'settings.general.h5AccessDisconnectGrace': '斷線保活(秒)',
'settings.general.h5AccessDisconnectGracePlaceholder': '30',
'settings.general.h5AccessDisconnectGraceInvalid': '需為 5-86400 之間的整數秒。',
'settings.general.h5AccessDisconnectGraceHint': '手機鎖屏或切後台導致斷線後,正在執行的任務不會被中斷,會在後台跑完、重連即可看到結果;只有任務空閒且無人連線時才在此時長後停止 CLI預設 30 秒)。外出遠端操作可調大,例如 600。',
'settings.general.h5AccessPublicUrl': '公開訪問 URL',
'settings.general.h5AccessPublicUrlPlaceholder': 'https://chat.example.com',
'settings.general.h5AccessAllowedOrigins': '允許的來源',

View File

@ -1027,11 +1027,20 @@ export const zh: Record<TranslationKey, string> = {
'settings.general.h5AccessUrlCopied': 'H5 链接已复制。',
'settings.general.h5AccessLaunchUrlCopied': '扫码链接已复制。',
'settings.general.h5AccessHideToken': '隐藏令牌',
'settings.general.h5AccessTokenNotAvailable': '重新生成后可显示新的令牌。',
'settings.general.h5AccessTokenNotAvailable': '旧版本的令牌无法找回。重新生成一次后,令牌将长期保存,可随时查看。',
'settings.general.h5AccessPublicHost': '访问主机 / IP',
'settings.general.h5AccessPublicHostPlaceholder': '192.168.1.100',
'settings.general.h5AccessCurrentPort': '当前端口',
'settings.general.h5AccessCurrentPortUnknown': '自动',
'settings.general.h5AccessFixedPort': '固定端口',
'settings.general.h5AccessFixedPortPlaceholder': '自动',
'settings.general.h5AccessFixedPortInvalid': '端口需为 1024-65535 之间的整数。',
'settings.general.h5AccessFixedPortHint': '不设固定端口时会自动复用上次的端口;反向代理等需要稳定端口的场景建议固定。修改端口后重启应用生效。',
'settings.general.h5AccessFixedPortRestartNote': '固定端口 {fixedPort} 将在重启应用后生效,当前仍运行在 {activePort}。',
'settings.general.h5AccessDisconnectGrace': '断连保活(秒)',
'settings.general.h5AccessDisconnectGracePlaceholder': '30',
'settings.general.h5AccessDisconnectGraceInvalid': '需为 5-86400 之间的整数秒。',
'settings.general.h5AccessDisconnectGraceHint': '手机锁屏或切后台导致断连后,正在执行的任务不会被打断,会在后台跑完、重连即可看到结果;只有任务空闲且无人连接时才在此时长后停止 CLI默认 30 秒)。出门远程操作可调大,例如 600。',
'settings.general.h5AccessPublicUrl': '公开访问 URL',
'settings.general.h5AccessPublicUrlPlaceholder': 'https://chat.example.com',
'settings.general.h5AccessAllowedOrigins': '允许的来源',

View File

@ -123,6 +123,25 @@ function extractH5AccessPort(baseUrl: string | null): string | null {
}
}
// Mirrors the server-side fixedPort range (h5AccessService MIN/MAX_FIXED_PORT).
function parseH5FixedPortDraft(draft: string): number | null | 'invalid' {
const trimmed = draft.trim()
if (!trimmed) return null
if (!/^\d{1,5}$/.test(trimmed)) return 'invalid'
const port = Number(trimmed)
return port >= 1024 && port <= 65535 ? port : 'invalid'
}
// Mirrors the server-side disconnect grace range (h5AccessService
// MIN/MAX_DISCONNECT_GRACE_SECONDS). Empty = use the built-in 30s default.
function parseH5GraceDraft(draft: string): number | null | 'invalid' {
const trimmed = draft.trim()
if (!trimmed) return null
if (!/^\d{1,5}$/.test(trimmed)) return 'invalid'
const seconds = Number(trimmed)
return seconds >= 5 && seconds <= 86400 ? seconds : 'invalid'
}
function buildH5PublicBaseUrlFromHostDraft(draft: string, currentBaseUrl: string | null): string | null {
const trimmed = draft.trim()
if (!trimmed) return null
@ -2777,27 +2796,44 @@ function H5AccessSettings() {
const t = useTranslation()
const addToast = useUIStore((s) => s.addToast)
const [h5PublicBaseUrlDraft, setH5PublicBaseUrlDraft] = useState(extractH5AccessAddressDraft(h5Access.publicBaseUrl))
const [h5GeneratedToken, setH5GeneratedToken] = useState<string | null>(null)
const [h5FixedPortDraft, setH5FixedPortDraft] = useState(h5Access.fixedPort != null ? String(h5Access.fixedPort) : '')
const [h5GraceDraft, setH5GraceDraft] = useState(h5Access.disconnectGraceSeconds != null ? String(h5Access.disconnectGraceSeconds) : '')
const [h5TokenVisible, setH5TokenVisible] = useState(false)
const [h5EnableConfirmOpen, setH5EnableConfirmOpen] = useState(false)
const [h5QrDataUrl, setH5QrDataUrl] = useState<string | null>(null)
const [h5ActionRunning, setH5ActionRunning] = useState(false)
const h5AccessUrl = h5Access.publicBaseUrl
// The token is persisted server-side, so the QR code and copy actions stay
// available across desktop restarts (issue #767).
const h5Token = h5Access.token
const h5LaunchUrl = useMemo(
() => buildH5LaunchUrl(h5AccessUrl, h5GeneratedToken),
[h5AccessUrl, h5GeneratedToken],
() => buildH5LaunchUrl(h5AccessUrl, h5Token),
[h5AccessUrl, h5Token],
)
const h5AccessPort = extractH5AccessPort(h5AccessUrl)
const h5ActivePort = h5AccessDiagnostics?.activePort != null
? String(h5AccessDiagnostics.activePort)
: extractH5AccessPort(h5AccessUrl)
const h5NextPublicBaseUrl = buildH5PublicBaseUrlFromHostDraft(h5PublicBaseUrlDraft, h5Access.publicBaseUrl)
const h5AccessDirty = h5NextPublicBaseUrl !== (h5Access.publicBaseUrl ?? null)
const h5NextFixedPort = parseH5FixedPortDraft(h5FixedPortDraft)
const h5FixedPortInvalid = h5NextFixedPort === 'invalid'
const h5NextGrace = parseH5GraceDraft(h5GraceDraft)
const h5GraceInvalid = h5NextGrace === 'invalid'
const h5AccessDirty = h5NextPublicBaseUrl !== (h5Access.publicBaseUrl ?? null) ||
(!h5FixedPortInvalid && h5NextFixedPort !== h5Access.fixedPort) ||
(!h5GraceInvalid && h5NextGrace !== h5Access.disconnectGraceSeconds)
const h5FixedPortPendingRestart = h5Access.fixedPort != null &&
h5ActivePort != null &&
String(h5Access.fixedPort) !== h5ActivePort
useEffect(() => {
setH5PublicBaseUrlDraft(extractH5AccessAddressDraft(h5Access.publicBaseUrl))
setH5FixedPortDraft(h5Access.fixedPort != null ? String(h5Access.fixedPort) : '')
setH5GraceDraft(h5Access.disconnectGraceSeconds != null ? String(h5Access.disconnectGraceSeconds) : '')
}, [h5Access])
useEffect(() => {
let cancelled = false
if (!h5Access.enabled || !h5LaunchUrl || !h5GeneratedToken) {
if (!h5Access.enabled || !h5LaunchUrl || !h5Token) {
setH5QrDataUrl(null)
return () => {
cancelled = true
@ -2815,7 +2851,7 @@ function H5AccessSettings() {
return () => {
cancelled = true
}
}, [h5Access.enabled, h5LaunchUrl, h5GeneratedToken])
}, [h5Access.enabled, h5LaunchUrl, h5Token])
const runH5Action = async (action: () => Promise<void>) => {
setH5ActionRunning(true)
@ -2829,9 +2865,12 @@ function H5AccessSettings() {
}
const handleH5SettingsSave = async () => {
if (h5FixedPortInvalid || h5GraceInvalid) return
await runH5Action(async () => {
await updateH5AccessSettings({
publicBaseUrl: h5NextPublicBaseUrl,
fixedPort: h5NextFixedPort,
disconnectGraceSeconds: h5NextGrace,
})
})
}
@ -2867,8 +2906,7 @@ function H5AccessSettings() {
const handleH5EnableConfirm = async () => {
await runH5Action(async () => {
const token = await enableH5Access()
setH5GeneratedToken(token)
await enableH5Access()
setH5TokenVisible(false)
setH5EnableConfirmOpen(false)
})
@ -2877,15 +2915,13 @@ function H5AccessSettings() {
const handleH5Disable = async () => {
await runH5Action(async () => {
await disableH5Access()
setH5GeneratedToken(null)
setH5TokenVisible(false)
})
}
const handleH5Regenerate = async () => {
await runH5Action(async () => {
const token = await regenerateH5AccessToken()
setH5GeneratedToken(token)
await regenerateH5AccessToken()
setH5TokenVisible(false)
})
}
@ -2992,7 +3028,7 @@ function H5AccessSettings() {
) : null}
<div className="mt-4 grid grid-cols-1 gap-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-[minmax(0,1fr)_9rem]">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-[minmax(0,1fr)_9rem_9rem]">
<Input
id="h5-access-public-url"
label={t('settings.general.h5AccessPublicHost')}
@ -3000,28 +3036,64 @@ function H5AccessSettings() {
placeholder={t('settings.general.h5AccessPublicHostPlaceholder')}
onChange={(event) => setH5PublicBaseUrlDraft(event.target.value)}
/>
<Input
id="h5-access-fixed-port"
label={t('settings.general.h5AccessFixedPort')}
value={h5FixedPortDraft}
placeholder={t('settings.general.h5AccessFixedPortPlaceholder')}
inputMode="numeric"
error={h5FixedPortInvalid ? t('settings.general.h5AccessFixedPortInvalid') : undefined}
onChange={(event) => setH5FixedPortDraft(event.target.value)}
/>
<Input
id="h5-access-current-port"
label={t('settings.general.h5AccessCurrentPort')}
value={h5AccessPort ?? t('settings.general.h5AccessCurrentPortUnknown')}
value={h5ActivePort ?? t('settings.general.h5AccessCurrentPortUnknown')}
readOnly
className="text-[var(--color-text-tertiary)]"
/>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-[9rem_minmax(0,1fr)] sm:items-start">
<Input
id="h5-access-disconnect-grace"
label={t('settings.general.h5AccessDisconnectGrace')}
value={h5GraceDraft}
placeholder={t('settings.general.h5AccessDisconnectGracePlaceholder')}
inputMode="numeric"
error={h5GraceInvalid ? t('settings.general.h5AccessDisconnectGraceInvalid') : undefined}
onChange={(event) => setH5GraceDraft(event.target.value)}
/>
<p className="text-xs leading-5 text-[var(--color-text-tertiary)] sm:pt-7">
{t('settings.general.h5AccessDisconnectGraceHint')}
</p>
</div>
<div className="flex items-center justify-between gap-3">
<p className="text-xs text-[var(--color-text-tertiary)]">
{t('settings.general.h5AccessOpenHint')}
{' '}
{t('settings.general.h5AccessFixedPortHint')}
</p>
<Button
size="sm"
variant="secondary"
onClick={() => void handleH5SettingsSave()}
disabled={!h5AccessDirty || h5ActionRunning}
disabled={!h5AccessDirty || h5FixedPortInvalid || h5GraceInvalid || h5ActionRunning}
aria-label={t('settings.general.h5AccessSave')}
>
{t('settings.general.h5AccessSave')}
</Button>
</div>
{h5FixedPortPendingRestart && (
<div
data-testid="h5-access-fixed-port-restart-note"
className="rounded-lg border border-[var(--color-warning)]/40 bg-[var(--color-warning)]/10 px-3 py-2 text-xs leading-5 text-[var(--color-text-primary)]"
>
{t('settings.general.h5AccessFixedPortRestartNote', {
fixedPort: String(h5Access.fixedPort),
activePort: h5ActivePort ?? '',
})}
</div>
)}
</div>
{h5AccessUrl && (
@ -3073,7 +3145,7 @@ function H5AccessSettings() {
{t('settings.general.h5AccessQrTitle')}
</div>
<p className="mt-1 text-xs leading-5 text-[var(--color-text-tertiary)]">
{h5GeneratedToken
{h5Token
? t('settings.general.h5AccessQrHint')
: t('settings.general.h5AccessQrRefreshHint')}
</p>
@ -3087,19 +3159,19 @@ function H5AccessSettings() {
size="sm"
variant="secondary"
icon={<Copy className="h-3.5 w-3.5" aria-hidden="true" />}
disabled={!h5LaunchUrl || !h5GeneratedToken}
disabled={!h5LaunchUrl || !h5Token}
onClick={() => void handleH5LaunchUrlCopy()}
>
{t('settings.general.h5AccessCopyLaunchUrl')}
</Button>
<Button
size="sm"
variant={h5GeneratedToken ? 'secondary' : 'primary'}
variant={h5Token ? 'secondary' : 'primary'}
icon={<RotateCw className="h-3.5 w-3.5" aria-hidden="true" />}
loading={h5ActionRunning}
onClick={() => void handleH5Regenerate()}
>
{h5GeneratedToken ? t('settings.general.h5AccessRegenerate') : t('settings.general.h5AccessGenerateToken')}
{h5Token ? t('settings.general.h5AccessRegenerate') : t('settings.general.h5AccessGenerateToken')}
</Button>
</div>
</div>
@ -3115,8 +3187,8 @@ function H5AccessSettings() {
{t('settings.general.h5AccessTokenPreview')}
</div>
<div className="mt-1 break-all text-sm text-[var(--color-text-primary)]">
{h5TokenVisible && h5GeneratedToken
? h5GeneratedToken
{h5TokenVisible && h5Token
? h5Token
: h5Access.tokenPreview || t('settings.general.h5AccessTokenNotAvailable')}
</div>
</div>
@ -3125,7 +3197,7 @@ function H5AccessSettings() {
size="sm"
variant="secondary"
icon={h5TokenVisible ? <EyeOff className="h-3.5 w-3.5" aria-hidden="true" /> : <Eye className="h-3.5 w-3.5" aria-hidden="true" />}
disabled={!h5GeneratedToken}
disabled={!h5Token}
onClick={() => setH5TokenVisible((visible) => !visible)}
>
{h5TokenVisible ? t('settings.general.h5AccessHideToken') : t('settings.general.h5AccessShowToken')}

View File

@ -1118,9 +1118,12 @@ describe('settingsStore H5 access behavior', () => {
useSettingsStore.setState({
h5Access: {
enabled: true,
token: null,
tokenPreview: 'h5_prev',
allowedOrigins: ['https://prev.example'],
publicBaseUrl: 'https://prev.example/app',
fixedPort: null,
disconnectGraceSeconds: null,
},
})
@ -1128,9 +1131,12 @@ describe('settingsStore H5 access behavior', () => {
expect(useSettingsStore.getState().h5Access).toEqual({
enabled: false,
token: null,
tokenPreview: null,
allowedOrigins: [],
publicBaseUrl: null,
fixedPort: null,
disconnectGraceSeconds: null,
})
expect(useSettingsStore.getState().h5AccessError).toBeNull()
})
@ -1168,9 +1174,12 @@ describe('settingsStore H5 access behavior', () => {
useSettingsStore.setState({
h5Access: {
enabled: true,
token: null,
tokenPreview: 'h5_prev',
allowedOrigins: ['https://prev.example'],
publicBaseUrl: 'https://prev.example/app',
fixedPort: null,
disconnectGraceSeconds: null,
},
})
@ -1178,14 +1187,20 @@ describe('settingsStore H5 access behavior', () => {
expect(useSettingsStore.getState().h5Access).toEqual({
enabled: true,
token: null,
tokenPreview: 'h5_prev',
allowedOrigins: ['https://prev.example'],
publicBaseUrl: 'https://prev.example/app',
fixedPort: null,
disconnectGraceSeconds: null,
})
expect(useSettingsStore.getState().h5AccessError).toBe('H5 unavailable')
})
it('handles H5 enable, regenerate, and disable transitions without persisting a raw token in store state', async () => {
// Since issue #767 the server persists the token and returns it inside
// settings for local-trusted callers, so the store keeps it too — that is
// what lets the QR code survive desktop restarts.
it('handles H5 enable, regenerate, and disable transitions and mirrors the persisted token', async () => {
vi.doMock('../api/settings', () => ({
settingsApi: {
getUser: vi.fn(),
@ -1210,26 +1225,35 @@ describe('settingsStore H5 access behavior', () => {
enable: vi.fn().mockResolvedValue({
settings: {
enabled: true,
token: 'raw-enable-token',
tokenPreview: 'h5_first',
allowedOrigins: [],
publicBaseUrl: null,
fixedPort: null,
disconnectGraceSeconds: null,
},
token: 'raw-enable-token',
}),
disable: vi.fn().mockResolvedValue({
settings: {
enabled: false,
tokenPreview: null,
token: 'raw-regenerated-token',
tokenPreview: 'h5_second',
allowedOrigins: [],
publicBaseUrl: null,
fixedPort: null,
disconnectGraceSeconds: null,
},
}),
regenerate: vi.fn().mockResolvedValue({
settings: {
enabled: true,
token: 'raw-regenerated-token',
tokenPreview: 'h5_second',
allowedOrigins: ['https://phone.example'],
publicBaseUrl: 'https://phone.example/app',
fixedPort: null,
disconnectGraceSeconds: null,
},
token: 'raw-regenerated-token',
}),
@ -1242,25 +1266,35 @@ describe('settingsStore H5 access behavior', () => {
await expect(useSettingsStore.getState().enableH5Access()).resolves.toBe('raw-enable-token')
expect(useSettingsStore.getState().h5Access).toEqual({
enabled: true,
token: 'raw-enable-token',
tokenPreview: 'h5_first',
allowedOrigins: [],
publicBaseUrl: null,
fixedPort: null,
disconnectGraceSeconds: null,
})
await expect(useSettingsStore.getState().regenerateH5AccessToken()).resolves.toBe('raw-regenerated-token')
expect(useSettingsStore.getState().h5Access).toEqual({
enabled: true,
token: 'raw-regenerated-token',
tokenPreview: 'h5_second',
allowedOrigins: ['https://phone.example'],
publicBaseUrl: 'https://phone.example/app',
fixedPort: null,
disconnectGraceSeconds: null,
})
// Disable keeps the token so a later re-enable restores paired devices.
await expect(useSettingsStore.getState().disableH5Access()).resolves.toBeUndefined()
expect(useSettingsStore.getState().h5Access).toEqual({
enabled: false,
tokenPreview: null,
token: 'raw-regenerated-token',
tokenPreview: 'h5_second',
allowedOrigins: [],
publicBaseUrl: null,
fixedPort: null,
disconnectGraceSeconds: null,
})
expect(useSettingsStore.getState().h5AccessError).toBeNull()
expect('h5AccessGeneratedToken' in useSettingsStore.getState()).toBe(false)

View File

@ -115,6 +115,8 @@ type SettingsStore = {
updateH5AccessSettings: (input: {
allowedOrigins?: string[]
publicBaseUrl?: string | null
fixedPort?: number | null
disconnectGraceSeconds?: number | null
}) => Promise<void>
setResponseLanguage: (language: string) => Promise<void>
fetchAppMode: () => Promise<void>
@ -128,9 +130,12 @@ type NetworkSettingsInput = Partial<Omit<NetworkSettings, 'proxy'>> & {
const DEFAULT_H5_ACCESS_SETTINGS: H5AccessSettings = {
enabled: false,
token: null,
tokenPreview: null,
allowedOrigins: [],
publicBaseUrl: null,
fixedPort: null,
disconnectGraceSeconds: null,
}
const DEFAULT_DESKTOP_TERMINAL_SETTINGS: DesktopTerminalSettings = {
@ -690,9 +695,12 @@ function normalizeDesktopTerminalSettings(
function normalizeH5AccessSettings(settings: H5AccessSettings | undefined): H5AccessSettings {
return {
enabled: settings?.enabled === true,
token: typeof settings?.token === 'string' && settings.token ? settings.token : null,
tokenPreview: settings?.tokenPreview ?? null,
allowedOrigins: Array.isArray(settings?.allowedOrigins) ? settings.allowedOrigins : [],
publicBaseUrl: settings?.publicBaseUrl ?? null,
fixedPort: typeof settings?.fixedPort === 'number' ? settings.fixedPort : null,
disconnectGraceSeconds: typeof settings?.disconnectGraceSeconds === 'number' ? settings.disconnectGraceSeconds : null,
}
}

View File

@ -63,9 +63,15 @@ export type NetworkSettings = {
export type H5AccessSettings = {
enabled: boolean
/** Full token, recoverable at any time from the desktop app. Null for pre-#767 data until the token is regenerated. */
token: string | null
tokenPreview: string | null
allowedOrigins: string[]
publicBaseUrl: string | null
/** Preferred fixed server port. Applied by the Tauri launcher on next app start. */
fixedPort: number | null
/** Idle grace period (seconds) before a disconnected, idle session's CLI is stopped. null = built-in 30s default. */
disconnectGraceSeconds: number | null
}
export type H5HostStaleness = 'ok' | 'unreachable' | 'proxy' | 'unset'
@ -76,6 +82,7 @@ export type H5AccessDiagnostics = {
effectivePublicBaseUrl: string | null
suggestedHost: string | null
localInterfaceHosts: string[]
activePort?: number
}
export type DesktopTerminalStartupShell =

View File

@ -379,11 +379,21 @@ describe('E2E: Full Flow', () => {
// 10. CORS
// =============================================
it('should block browser CORS preflight while H5 access is disabled', async () => {
// Loopback browser origins (local dev servers) are trusted without a token
// since 9238481e; only remote origins stay blocked while H5 is disabled.
it('should allow loopback browser CORS preflight while H5 access is disabled', async () => {
const res = await fetch(`${baseUrl}/api/status`, {
method: 'OPTIONS',
headers: { 'Origin': 'http://localhost:3000' },
})
expect(res.status).toBe(204)
})
it('should block remote browser CORS preflight while H5 access is disabled', async () => {
const res = await fetch(`${baseUrl}/api/status`, {
method: 'OPTIONS',
headers: { 'Origin': 'https://phone.example' },
})
expect(res.status).toBe(403)
})

View File

@ -64,9 +64,12 @@ describe('/api/h5-access', () => {
}
expect(body.settings).toEqual({
enabled: false,
token: null,
tokenPreview: null,
allowedOrigins: [],
publicBaseUrl: null,
fixedPort: null,
disconnectGraceSeconds: null,
})
expect(body.diagnostics).toBeDefined()
expect(body.diagnostics?.storedHostStaleness).toBe('unset')
@ -74,7 +77,7 @@ describe('/api/h5-access', () => {
expect(Array.isArray(body.diagnostics?.localInterfaceHosts)).toBe(true)
})
test('enable returns raw token once and status stays sanitized', async () => {
test('enable returns the token and GET keeps it recoverable', async () => {
const enableResponse = await api('POST', '/api/h5-access/enable')
expect(enableResponse.status).toBe(200)
@ -89,19 +92,21 @@ describe('/api/h5-access', () => {
expect(enablePayload.settings.enabled).toBe(true)
expect(enablePayload.token).toMatch(/^h5_/)
// GET /api/h5-access is local-trusted-only (remote callers are rejected
// upstream), so the desktop app can recover the full token at any time.
const statusResponse = await api('GET', '/api/h5-access')
expect(statusResponse.status).toBe(200)
const statusPayload = await statusResponse.json() as {
settings: {
enabled: boolean
token: string | null
tokenPreview: string | null
}
token?: string
}
expect(statusPayload.settings.enabled).toBe(true)
expect(statusPayload.settings.tokenPreview).toBe(enablePayload.settings.tokenPreview)
expect(statusPayload.token).toBeUndefined()
expect(statusPayload.settings.token).toBe(enablePayload.token)
})
test('verify accepts a good bearer token and rejects missing or bad tokens', async () => {
@ -141,24 +146,37 @@ describe('/api/h5-access', () => {
).toMatchObject({ status: 200 })
})
test('disable clears access and causes the old token to fail verification', async () => {
test('disable blocks access but keeps the token for a later re-enable', async () => {
const enableResponse = await api('POST', '/api/h5-access/enable')
const enablePayload = await enableResponse.json() as { token: string }
const enablePayload = await enableResponse.json() as {
settings: { tokenPreview: string }
token: string
}
const disableResponse = await api('POST', '/api/h5-access/disable')
expect(disableResponse.status).toBe(200)
await expect(disableResponse.json()).resolves.toEqual({
settings: {
enabled: false,
tokenPreview: null,
token: enablePayload.token,
tokenPreview: enablePayload.settings.tokenPreview,
allowedOrigins: [],
publicBaseUrl: null,
fixedPort: null,
disconnectGraceSeconds: null,
},
})
expect(
await api('POST', '/api/h5-access/verify', { bearerToken: enablePayload.token }),
).toMatchObject({ status: 401 })
const reEnableResponse = await api('POST', '/api/h5-access/enable')
const reEnablePayload = await reEnableResponse.json() as { token: string }
expect(reEnablePayload.token).toBe(enablePayload.token)
expect(
await api('POST', '/api/h5-access/verify', { bearerToken: enablePayload.token }),
).toMatchObject({ status: 200 })
})
test('PUT updates sanitized settings', async () => {
@ -166,6 +184,8 @@ describe('/api/h5-access', () => {
body: {
allowedOrigins: ['https://example.com/path'],
publicBaseUrl: 'https://public.example.com/app/',
fixedPort: 28670,
disconnectGraceSeconds: null,
},
})
@ -173,10 +193,21 @@ describe('/api/h5-access', () => {
await expect(response.json()).resolves.toEqual({
settings: {
enabled: false,
token: null,
tokenPreview: null,
allowedOrigins: ['https://example.com'],
publicBaseUrl: 'https://public.example.com/app',
fixedPort: 28670,
disconnectGraceSeconds: null,
},
})
})
test('PUT rejects out-of-range fixedPort', async () => {
const response = await api('PUT', '/api/h5-access', {
body: { fixedPort: 80 },
})
expect(response.status).toBe(400)
})
})

View File

@ -578,6 +578,30 @@ describe('remote H5 auth and CORS integration', () => {
})
})
test('keeps the recoverable token readable for loopback but blocked for remote browsers', async () => {
const h5AccessService = new H5AccessService()
const { token } = await h5AccessService.enable()
// Local desktop (loopback, no foreign Origin) can recover the full token
// at any time — this is what keeps the QR code alive across restarts.
const localResponse = await fetch(`${baseUrl}/api/h5-access`)
expect(localResponse.status).toBe(200)
const localPayload = await localResponse.json() as { settings: { token: string | null } }
expect(localPayload.settings.token).toBe(token)
// A remote H5 browser must never reach the settings surface, even with
// the valid token: the control plane stays local-only.
const remoteResponse = await fetch(`${baseUrl}/api/h5-access`, {
headers: {
Origin: PHONE_ORIGIN,
Authorization: `Bearer ${token}`,
},
})
expect(remoteResponse.status).toBe(403)
await h5AccessService.disable()
})
test('blocks remote preflight requests to the local H5 access control plane', async () => {
const response = await fetch(`${baseUrl}/api/h5-access/enable`, {
method: 'OPTIONS',

View File

@ -46,15 +46,18 @@ describe('H5AccessService', () => {
await expect(service.getSettings()).resolves.toEqual({
enabled: false,
token: null,
tokenPreview: null,
allowedOrigins: [],
publicBaseUrl: null,
fixedPort: null,
disconnectGraceSeconds: null,
})
await expect(service.validateToken('missing-token')).resolves.toBe(false)
})
test('enable generates a token and persists only hash plus preview', async () => {
test('enable persists a recoverable token alongside hash and preview', async () => {
const service = new H5AccessService()
const result = await service.enable()
@ -62,6 +65,7 @@ describe('H5AccessService', () => {
const saved = JSON.parse(raw) as {
h5Access: {
enabled: boolean
token: string
tokenHash: string
tokenPreview: string
}
@ -70,19 +74,143 @@ describe('H5AccessService', () => {
expect(result.token).toMatch(/^h5_[A-Za-z0-9_-]{43}$/)
expect(result.settings).toEqual({
enabled: true,
token: result.token,
tokenPreview: saved.h5Access.tokenPreview,
allowedOrigins: [],
publicBaseUrl: null,
fixedPort: null,
disconnectGraceSeconds: null,
})
expect(saved.h5Access.enabled).toBe(true)
// Plaintext is persisted on purpose (issue #767): the desktop app must be
// able to re-display the QR code / token after a restart.
expect(saved.h5Access.token).toBe(result.token)
expect(saved.h5Access.tokenHash).toHaveLength(64)
expect(saved.h5Access.tokenPreview).toBe(
`${result.token.slice(0, 7)}...${result.token.slice(-4)}`,
)
expect(raw).not.toContain(result.token)
expect(await service.validateToken(result.token)).toBe(true)
})
test('enable reuses the existing token instead of rotating it', async () => {
const service = new H5AccessService()
const first = await service.enable()
const second = await service.enable()
expect(second.token).toBe(first.token)
expect(await service.validateToken(first.token)).toBe(true)
})
test('disable keeps the token so re-enable restores paired devices', async () => {
const service = new H5AccessService()
const first = await service.enable()
const disabled = await service.disable()
expect(disabled.enabled).toBe(false)
expect(disabled.token).toBe(first.token)
// Disabled state rejects every token even though it is still stored.
expect(await service.validateToken(first.token)).toBe(false)
const reEnabled = await service.enable()
expect(reEnabled.token).toBe(first.token)
expect(await service.validateToken(first.token)).toBe(true)
})
test('hand-edited plaintext token becomes the source of truth', async () => {
await fs.mkdir(path.dirname(getManagedSettingsPath()), { recursive: true })
await fs.writeFile(
getManagedSettingsPath(),
JSON.stringify({
h5Access: {
enabled: true,
token: 'my-custom-pinned-token',
tokenHash: 'f'.repeat(64),
tokenPreview: 'stale-preview',
},
}),
'utf-8',
)
const service = new H5AccessService()
expect(await service.validateToken('my-custom-pinned-token')).toBe(true)
const settings = await service.getSettings()
expect(settings.enabled).toBe(true)
expect(settings.token).toBe('my-custom-pinned-token')
expect(settings.tokenPreview).toBe('my-cust...oken')
})
test('legacy hash-only settings stay enabled and validate by hash', async () => {
const service = new H5AccessService()
const result = await service.enable()
// Simulate pre-#767 data: hash + preview persisted, plaintext missing.
const saved = JSON.parse(await fs.readFile(getManagedSettingsPath(), 'utf-8')) as {
h5Access: Record<string, unknown>
}
delete saved.h5Access.token
await fs.writeFile(getManagedSettingsPath(), JSON.stringify(saved), 'utf-8')
const legacyService = new H5AccessService()
const settings = await legacyService.getSettings()
expect(settings.enabled).toBe(true)
expect(settings.token).toBeNull()
expect(settings.tokenPreview).toBe(result.settings.tokenPreview)
expect(await legacyService.validateToken(result.token)).toBe(true)
})
test('updateSettings manages fixedPort within the allowed range', async () => {
const service = new H5AccessService()
const updated = await service.updateSettings({ fixedPort: 28670 })
expect(updated.fixedPort).toBe(28670)
const saved = JSON.parse(await fs.readFile(getManagedSettingsPath(), 'utf-8')) as {
h5Access: { fixedPort: number }
}
expect(saved.h5Access.fixedPort).toBe(28670)
const cleared = await service.updateSettings({ fixedPort: null })
expect(cleared.fixedPort).toBeNull()
await expect(service.updateSettings({ fixedPort: 80 })).rejects.toMatchObject({
statusCode: 400,
})
await expect(service.updateSettings({ fixedPort: 70000 })).rejects.toMatchObject({
statusCode: 400,
})
await expect(service.updateSettings({ fixedPort: 3456.5 })).rejects.toMatchObject({
statusCode: 400,
})
})
test('updateSettings manages the disconnect grace period within range', async () => {
const service = new H5AccessService()
// Default: no stored value falls back to the built-in 30s grace.
await expect(service.getDisconnectGraceMs()).resolves.toBe(30_000)
const updated = await service.updateSettings({ disconnectGraceSeconds: 600 })
expect(updated.disconnectGraceSeconds).toBe(600)
await expect(service.getDisconnectGraceMs()).resolves.toBe(600_000)
const cleared = await service.updateSettings({ disconnectGraceSeconds: null })
expect(cleared.disconnectGraceSeconds).toBeNull()
await expect(service.getDisconnectGraceMs()).resolves.toBe(30_000)
await expect(service.updateSettings({ disconnectGraceSeconds: 1 })).rejects.toMatchObject({
statusCode: 400,
})
await expect(service.updateSettings({ disconnectGraceSeconds: 90_000 })).rejects.toMatchObject({
statusCode: 400,
})
await expect(service.updateSettings({ disconnectGraceSeconds: 12.5 })).rejects.toMatchObject({
statusCode: 400,
})
})
test('enabled public settings use the packaged app LAN URL when provided', async () => {
process.env.CLAUDE_H5_PUBLIC_BASE_URL = 'http://192.168.1.20:28670/'
process.env.CLAUDE_H5_AUTO_PUBLIC_URL = '1'
@ -243,9 +371,12 @@ describe('H5AccessService', () => {
}),
).resolves.toEqual({
enabled: false,
token: null,
tokenPreview: null,
allowedOrigins: ['https://example.com', 'http://localhost:3000'],
publicBaseUrl: 'https://public.example.com/app',
fixedPort: null,
disconnectGraceSeconds: null,
})
await expect(
@ -291,9 +422,12 @@ describe('H5AccessService', () => {
await expect(service.getSettings()).resolves.toEqual({
enabled: false,
token: null,
tokenPreview: null,
allowedOrigins: ['https://example.com'],
publicBaseUrl: 'https://public.example.com',
fixedPort: null,
disconnectGraceSeconds: null,
})
await expect(service.validateToken('anything')).resolves.toBe(false)
await expect(service.isOriginAllowed('https://example.com')).resolves.toBe(false)

View File

@ -2,12 +2,17 @@ import { afterEach, describe, expect, it, mock, spyOn } from 'bun:test'
import type { ServerWebSocket } from 'bun'
import {
__markPrewarmPendingForTests,
__markActiveTurnForTests,
__resetWebSocketHandlerStateForTests,
closeSessionConnection,
getActiveSessionIds,
handleWebSocket,
type WebSocketData,
} from '../ws/handler.js'
import {
__resetDisconnectGraceMsForTests,
__setDisconnectGraceMsForTests,
} from '../ws/disconnectGraceConfig.js'
import { conversationService } from '../services/conversationService.js'
import { computerUseApprovalService } from '../services/computerUseApprovalService.js'
@ -33,6 +38,7 @@ function makeClientSocket(sessionId: string) {
describe('WebSocket handler session isolation', () => {
afterEach(() => {
__resetWebSocketHandlerStateForTests()
__resetDisconnectGraceMsForTests()
mock.restore()
})
@ -162,4 +168,80 @@ describe('WebSocket handler session isolation', () => {
const secondMessages = second.sent.map((payload) => JSON.parse(payload))
expect(secondMessages).not.toContainEqual({ type: 'status', state: 'thinking' })
})
it('keeps a running session alive on disconnect and cleans up only after the turn finishes (issue #764)', () => {
const sessionId = `running-disconnect-${crypto.randomUUID()}`
const ws = makeClientSocket(sessionId)
const setTimeoutSpy = spyOn(globalThis, 'setTimeout')
const stopSession = spyOn(conversationService, 'stopSession').mockImplementation(() => {})
spyOn(conversationService, 'getPendingPermissionRequests').mockReturnValue([])
let turnCompleteCallback: ((cliMsg: any) => void) | null = null
spyOn(conversationService, 'onOutput').mockImplementation((_sid, cb) => {
turnCompleteCallback = cb
})
spyOn(conversationService, 'removeOutputCallback').mockImplementation(() => {})
handleWebSocket.open(ws)
__markActiveTurnForTests(sessionId)
setTimeoutSpy.mockClear()
// Last client disconnects while the turn is still running: no kill timer,
// just a turn-completion watcher.
handleWebSocket.close(ws, 1006, 'phone locked screen')
expect(setTimeoutSpy).not.toHaveBeenCalled()
expect(stopSession).not.toHaveBeenCalled()
expect(turnCompleteCallback).not.toBeNull()
// Turn finishes while still disconnected → now the idle grace timer starts.
turnCompleteCallback?.({ type: 'result', subtype: 'success' })
expect(setTimeoutSpy).toHaveBeenCalled()
// Timer body still hasn't run, so the process is not killed yet.
expect(stopSession).not.toHaveBeenCalled()
})
it('uses the configured disconnect grace period for an idle session', () => {
const sessionId = `idle-disconnect-${crypto.randomUUID()}`
const ws = makeClientSocket(sessionId)
__setDisconnectGraceMsForTests(120_000)
const setTimeoutSpy = spyOn(globalThis, 'setTimeout').mockImplementation(() => 0 as any)
spyOn(conversationService, 'getPendingPermissionRequests').mockReturnValue([])
handleWebSocket.open(ws)
setTimeoutSpy.mockClear()
handleWebSocket.close(ws, 1006, 'tab closed')
expect(setTimeoutSpy).toHaveBeenCalled()
expect(setTimeoutSpy.mock.calls[0]?.[1]).toBe(120_000)
})
it('does not start the idle timer if the client reconnects before the turn finishes', () => {
const sessionId = `reconnect-mid-turn-${crypto.randomUUID()}`
const ws = makeClientSocket(sessionId)
const reconnected = makeClientSocket(sessionId)
const setTimeoutSpy = spyOn(globalThis, 'setTimeout')
spyOn(conversationService, 'getPendingPermissionRequests').mockReturnValue([])
spyOn(conversationService, 'hasSession').mockReturnValue(true)
let turnCompleteCallback: ((cliMsg: any) => void) | null = null
spyOn(conversationService, 'onOutput').mockImplementation((_sid, cb) => {
turnCompleteCallback = cb
})
const removeOutputCallback = spyOn(conversationService, 'removeOutputCallback').mockImplementation(() => {})
handleWebSocket.open(ws)
__markActiveTurnForTests(sessionId)
handleWebSocket.close(ws, 1006, 'phone locked screen')
expect(turnCompleteCallback).not.toBeNull()
// Reconnect tears down the watcher before the turn completes.
handleWebSocket.open(reconnected)
expect(removeOutputCallback).toHaveBeenCalled()
setTimeoutSpy.mockClear()
// A late result must not schedule cleanup now that a client is back.
turnCompleteCallback?.({ type: 'result', subtype: 'success' })
expect(setTimeoutSpy).not.toHaveBeenCalled()
})
})

View File

@ -1,5 +1,6 @@
import { ApiError, errorResponse } from '../middleware/errorHandler.js'
import { H5AccessService } from '../services/h5AccessService.js'
import { refreshDisconnectGraceMs } from '../ws/disconnectGraceConfig.js'
const h5AccessService = new H5AccessService()
@ -54,7 +55,11 @@ export async function handleH5AccessApi(
const settings = await h5AccessService.updateSettings({
allowedOrigins: body.allowedOrigins as string[] | undefined,
publicBaseUrl: body.publicBaseUrl as string | null | undefined,
fixedPort: body.fixedPort as number | null | undefined,
disconnectGraceSeconds: body.disconnectGraceSeconds as number | null | undefined,
})
// Keep the synchronous disconnect-cleanup cache in step with the new value.
await refreshDisconnectGraceMs()
return Response.json({ settings })
}
throw methodNotAllowed(req.method, '/api/h5-access')

View File

@ -27,6 +27,7 @@ import { ensurePersistentStorageUpgraded } from './services/persistentStorageMig
import { handleStaticH5Request } from './staticH5.js'
import { classifyH5Request, shouldBlockDisabledH5Access, shouldRequireH5Token } from './h5AccessPolicy.js'
import { H5AccessService } from './services/h5AccessService.js'
import { refreshDisconnectGraceMs } from './ws/disconnectGraceConfig.js'
function readArgValue(flag: string): string | undefined {
const args = process.argv.slice(2)
@ -125,6 +126,9 @@ function originFromUrl(value: string | null): string | null {
export function startServer(port = PORT, host = HOST) {
enableConfigs()
// Warm the synchronous disconnect-grace cache from managed settings so the
// first client disconnect honors the configured value (issue #764).
void refreshDisconnectGraceMs()
// Don't hijack the global console / process handlers under `bun test`:
// a test that boots the server would otherwise route every test-side
// console.error/warn into the user's real diagnostics file.

View File

@ -6,9 +6,34 @@ import { ProviderService } from './providerService.js'
export type H5AccessSettings = {
enabled: boolean
/**
* Full access token. Persisted so it can be recovered at any time from the
* desktop app (issue #767: tokens used to be shown once and lost on
* restart, forcing regeneration that invalidated every connected phone).
* Only ever served to local-trusted requests the `/api/h5-access` route
* (except `verify`) is rejected for remote callers before reaching this
* service.
*/
token: string | null
tokenPreview: string | null
allowedOrigins: string[]
publicBaseUrl: string | null
/**
* Preferred fixed TCP port for the desktop server. The desktop launchers
* (Electron and Tauri) read this before spawning the sidecar so reverse
* proxies and phone bookmarks keep working across app restarts. Applied on
* next launch.
*/
fixedPort: number | null
/**
* Idle grace period (seconds) before an unobserved session's CLI subprocess
* is stopped after the last client disconnects (issue #764). A turn that is
* actively running is never interrupted regardless of this value the grace
* timer only starts once the session is idle with no clients attached, so a
* phone that locks its screen mid-task lets the task finish in the
* background and shows the result on reconnect. null = built-in 30s default.
*/
disconnectGraceSeconds: number | null
}
export type H5AccessEnableResult = {
@ -24,6 +49,8 @@ export type H5AccessDiagnostics = {
effectivePublicBaseUrl: string | null
suggestedHost: string | null
localInterfaceHosts: string[]
/** Port the running server is actually bound to. Lets the UI flag a fixedPort that has not taken effect yet. */
activePort: number
}
export type H5PublicBaseUrlClassification = 'plain-lan' | 'proxy'
@ -38,13 +65,28 @@ type StoredH5AccessSettings = H5AccessSettings & {
const DEFAULT_STORED_SETTINGS: StoredH5AccessSettings = {
enabled: false,
token: null,
tokenHash: null,
tokenPreview: null,
allowedOrigins: [],
publicBaseUrl: null,
fixedPort: null,
disconnectGraceSeconds: null,
}
const TOKEN_HASH_RE = /^[a-f0-9]{64}$/
// Tokens must survive URL query params and Authorization headers, so restrict
// to visible ASCII. The 16-char floor keeps hand-edited tokens from being
// trivially guessable while still allowing users to pin their own value.
const TOKEN_RE = /^[\x21-\x7e]{16,512}$/
const MIN_FIXED_PORT = 1024
const MAX_FIXED_PORT = 65535
// Disconnect grace bounds. Floor at 5s so a transient renderer reconnect is
// always tolerated; ceiling at 24h so a stuck phone cannot pin a subprocess
// forever. Mirrored in the desktop UI validator.
const MIN_DISCONNECT_GRACE_SECONDS = 5
const MAX_DISCONNECT_GRACE_SECONDS = 86_400
export const DEFAULT_DISCONNECT_GRACE_MS = 30_000
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value)
@ -53,8 +95,11 @@ function isRecord(value: unknown): value is Record<string, unknown> {
function toPublicSettings(settings: StoredH5AccessSettings): H5AccessSettings {
return {
enabled: settings.enabled,
token: settings.token,
tokenPreview: settings.tokenPreview,
allowedOrigins: settings.allowedOrigins,
fixedPort: settings.fixedPort,
disconnectGraceSeconds: settings.disconnectGraceSeconds,
publicBaseUrl: resolveEffectiveH5PublicBaseUrl({
enabled: settings.enabled,
storedPublicBaseUrl: settings.publicBaseUrl,
@ -101,6 +146,7 @@ function describeH5AccessDiagnostics(stored: StoredH5AccessSettings): H5AccessDi
effectivePublicBaseUrl,
suggestedHost,
localInterfaceHosts,
activePort: ProviderService.getServerPort(),
}
}
@ -479,6 +525,26 @@ function is172PrivateIPv4(address: string): boolean {
return a === 172 && b >= 16 && b <= 31
}
function normalizeFixedPort(value: unknown): number | null {
if (typeof value !== 'number' || !Number.isInteger(value)) {
return null
}
if (value < MIN_FIXED_PORT || value > MAX_FIXED_PORT) {
return null
}
return value
}
function normalizeDisconnectGraceSeconds(value: unknown): number | null {
if (typeof value !== 'number' || !Number.isInteger(value)) {
return null
}
if (value < MIN_DISCONNECT_GRACE_SECONDS || value > MAX_DISCONNECT_GRACE_SECONDS) {
return null
}
return value
}
function normalizeStoredSettings(value: unknown): StoredH5AccessSettings {
if (!isRecord(value)) {
return { ...DEFAULT_STORED_SETTINGS }
@ -507,16 +573,29 @@ function normalizeStoredSettings(value: unknown): StoredH5AccessSettings {
}
}
const tokenHash = typeof value.tokenHash === 'string' && TOKEN_HASH_RE.test(value.tokenHash)
// The plaintext token is the source of truth: hash and preview are derived
// from it, so hand-editing `token` in settings.json pins a custom token.
// The stored hash only matters for pre-#767 data that never kept plaintext.
const token = typeof value.token === 'string' && TOKEN_RE.test(value.token)
? value.token
: null
const storedTokenHash = typeof value.tokenHash === 'string' && TOKEN_HASH_RE.test(value.tokenHash)
? value.tokenHash
: null
const tokenHash = token ? hashToken(token) : storedTokenHash
const tokenPreview = token
? createTokenPreview(token)
: tokenHash && typeof value.tokenPreview === 'string' ? value.tokenPreview : null
return {
enabled: value.enabled === true && tokenHash !== null,
token,
tokenHash,
tokenPreview: tokenHash && typeof value.tokenPreview === 'string' ? value.tokenPreview : null,
tokenPreview,
allowedOrigins,
publicBaseUrl,
fixedPort: normalizeFixedPort(value.fixedPort),
disconnectGraceSeconds: normalizeDisconnectGraceSeconds(value.disconnectGraceSeconds),
}
}
@ -537,14 +616,16 @@ export class H5AccessService {
private async setToken(
managedSettings: Record<string, unknown>,
current: StoredH5AccessSettings,
{ reuseExistingToken }: { reuseExistingToken: boolean },
): Promise<{
settings: Record<string, unknown>
result: H5AccessEnableResult
}> {
const token = createToken()
const token = reuseExistingToken && current.token ? current.token : createToken()
const nextSettings: StoredH5AccessSettings = {
...current,
enabled: true,
token,
tokenHash: hashToken(token),
tokenPreview: createTokenPreview(token),
}
@ -567,19 +648,23 @@ export class H5AccessService {
}
async enable(): Promise<H5AccessEnableResult> {
// Re-enabling keeps the existing token (issue #767: a stable token means
// phones that already paired keep working). Use regenerateToken to rotate.
return this.managedSettingsService.updateSettings(async (current) => {
return this.setToken(current, normalizeStoredSettings(current.h5Access))
return this.setToken(current, normalizeStoredSettings(current.h5Access), {
reuseExistingToken: true,
})
})
}
async disable(): Promise<H5AccessSettings> {
// Keep the token so a later re-enable restores access for already-paired
// phones. While disabled, validateToken rejects everything regardless.
return this.managedSettingsService.updateSettings(async (current) => {
const h5Access = normalizeStoredSettings(current.h5Access)
const nextSettings: StoredH5AccessSettings = {
...h5Access,
enabled: false,
tokenHash: null,
tokenPreview: null,
}
return {
@ -594,13 +679,17 @@ export class H5AccessService {
async regenerateToken(): Promise<H5AccessEnableResult> {
return this.managedSettingsService.updateSettings(async (current) => {
return this.setToken(current, normalizeStoredSettings(current.h5Access))
return this.setToken(current, normalizeStoredSettings(current.h5Access), {
reuseExistingToken: false,
})
})
}
async updateSettings(input: {
allowedOrigins?: string[]
publicBaseUrl?: string | null
fixedPort?: number | null
disconnectGraceSeconds?: number | null
}): Promise<H5AccessSettings> {
return this.managedSettingsService.updateSettings(async (current) => {
const h5Access = normalizeStoredSettings(current.h5Access)
@ -617,12 +706,42 @@ export class H5AccessService {
}
}
let nextFixedPort: number | null
if (input.fixedPort === undefined) {
nextFixedPort = h5Access.fixedPort
} else if (input.fixedPort === null) {
nextFixedPort = null
} else {
nextFixedPort = normalizeFixedPort(input.fixedPort)
if (nextFixedPort === null) {
throw ApiError.badRequest(
`fixedPort must be an integer between ${MIN_FIXED_PORT} and ${MAX_FIXED_PORT}`,
)
}
}
let nextDisconnectGraceSeconds: number | null
if (input.disconnectGraceSeconds === undefined) {
nextDisconnectGraceSeconds = h5Access.disconnectGraceSeconds
} else if (input.disconnectGraceSeconds === null) {
nextDisconnectGraceSeconds = null
} else {
nextDisconnectGraceSeconds = normalizeDisconnectGraceSeconds(input.disconnectGraceSeconds)
if (nextDisconnectGraceSeconds === null) {
throw ApiError.badRequest(
`disconnectGraceSeconds must be an integer between ${MIN_DISCONNECT_GRACE_SECONDS} and ${MAX_DISCONNECT_GRACE_SECONDS}`,
)
}
}
const nextSettings: StoredH5AccessSettings = {
...h5Access,
allowedOrigins: input.allowedOrigins === undefined
? h5Access.allowedOrigins
: normalizeAllowedOrigins(input.allowedOrigins),
publicBaseUrl: nextPublicBaseUrl,
fixedPort: nextFixedPort,
disconnectGraceSeconds: nextDisconnectGraceSeconds,
}
return {
@ -640,6 +759,18 @@ export class H5AccessService {
return describeH5AccessDiagnostics(h5Access)
}
/**
* Idle grace period (ms) before an unobserved session is stopped after the
* last client disconnects. Reads the configured value, falling back to the
* built-in default. Used by the WebSocket handler's disconnect cleanup.
*/
async getDisconnectGraceMs(): Promise<number> {
const { h5Access } = await this.readStoredSettings()
return h5Access.disconnectGraceSeconds !== null
? h5Access.disconnectGraceSeconds * 1000
: DEFAULT_DISCONNECT_GRACE_MS
}
async validateToken(token: string | null | undefined): Promise<boolean> {
if (!token) {
return false

View File

@ -0,0 +1,37 @@
/**
* Cached disconnect grace period (issue #764).
*
* The WebSocket `close` handler runs synchronously, but the configured grace
* period lives in managed settings (async disk read). We cache the resolved
* value here so the hot path stays synchronous, and refresh the cache at
* server startup and whenever the H5 access settings are updated.
*/
import { H5AccessService, DEFAULT_DISCONNECT_GRACE_MS } from '../services/h5AccessService.js'
let cachedGraceMs = DEFAULT_DISCONNECT_GRACE_MS
const h5AccessService = new H5AccessService()
/** Synchronous accessor for the disconnect cleanup grace period, in ms. */
export function getDisconnectGraceMs(): number {
return cachedGraceMs
}
/** Reload the cached grace period from managed settings. Best-effort. */
export async function refreshDisconnectGraceMs(): Promise<number> {
try {
cachedGraceMs = await h5AccessService.getDisconnectGraceMs()
} catch {
// Keep the previous (or default) value on read failure.
}
return cachedGraceMs
}
/** Test hook: override the cached value directly. */
export function __setDisconnectGraceMsForTests(value: number): void {
cachedGraceMs = value
}
/** Test hook: reset to the built-in default. */
export function __resetDisconnectGraceMsForTests(): void {
cachedGraceMs = DEFAULT_DISCONNECT_GRACE_MS
}

View File

@ -34,6 +34,7 @@ import {
LOCAL_COMMAND_STDOUT_TAG,
} from '../../constants/xml.js'
import { shouldCreateWorktreeForSessionLaunch } from '../services/repositoryLaunchService.js'
import { getDisconnectGraceMs } from './disconnectGraceConfig.js'
const settingsService = new SettingsService()
const providerService = new ProviderService()
@ -53,9 +54,15 @@ const sessionSlashCommands = new Map<string, SessionSlashCommand[]>()
* Timers for delayed session cleanup after client disconnect.
* If a client reconnects before the timer fires, the timer is cancelled.
*/
const CLIENT_DISCONNECT_CLEANUP_MS = 30_000
const PENDING_PERMISSION_DISCONNECT_CLEANUP_MS = 30 * 60_000
const sessionCleanupTimers = new Map<string, ReturnType<typeof setTimeout>>()
/**
* Per-session removers for the turn-completion watcher (issue #764). When the
* last client disconnects while a turn is still running, we let the turn finish
* in the background instead of killing the CLI, then start the idle grace timer
* once the result arrives. The remover is also cleared on reconnect/cleanup.
*/
const sessionDisconnectWatchers = new Map<string, () => void>()
/**
* Track sessions where user requested stop suppress the CLI_ERROR that
@ -166,6 +173,9 @@ export const handleWebSocket = {
clearTimeout(pendingTimer)
sessionCleanupTimers.delete(sessionId)
}
// Cancel any "let the running turn finish, then clean up" watcher too —
// the session is observed again (issue #764).
cancelSessionDisconnectWatcher(sessionId)
addActiveClient(sessionId, ws)
if (prewarmPendingSessions.has(sessionId) || prewarmedSessions.has(sessionId)) {
@ -261,20 +271,17 @@ export const handleWebSocket = {
return
}
computerUseApprovalService.cancelSession(sessionId)
// No clients left. A turn that is still running must finish in the
// background (issue #764) — never kill it just because a phone locked its
// screen. Defer cleanup until the turn completes, then apply the idle
// grace period. Sessions that are already idle go straight to the timer.
if (isSessionTurnActive(sessionId)) {
console.log(`[WS] Session ${sessionId} still running after disconnect; keeping CLI alive until the turn finishes`)
watchTurnCompletionForCleanup(sessionId)
return
}
// Schedule delayed cleanup. Sessions waiting on user input need a longer
// grace period so transient renderer disconnects do not abort the prompt.
const cleanupDelayMs = getDisconnectCleanupDelayMs(sessionId)
const cleanupTimer = setTimeout(() => {
sessionCleanupTimers.delete(sessionId)
if (!hasActiveClients(sessionId)) {
console.log(`[WS] Session ${sessionId} not reconnected after ${cleanupDelayMs}ms, stopping CLI subprocess`)
conversationService.stopSession(sessionId)
cleanupSessionRuntimeState(sessionId)
}
}, cleanupDelayMs)
sessionCleanupTimers.set(sessionId, cleanupTimer)
scheduleDisconnectCleanup(sessionId)
},
drain(ws: ServerWebSocket<WebSocketData>) {
@ -1176,6 +1183,7 @@ function cleanupStreamState(sessionId: string) {
}
function cleanupSessionRuntimeState(sessionId: string) {
cancelSessionDisconnectWatcher(sessionId)
cleanupStreamState(sessionId)
sessionSlashCommands.delete(sessionId)
sessionTitleState.delete(sessionId)
@ -1894,10 +1902,78 @@ function sendError(ws: ServerWebSocket<WebSocketData>, message: string, code: st
sendMessage(ws, { type: 'error', message, code })
}
/**
* Idle disconnect cleanup delay. A session waiting on a pending permission
* keeps the long 30-minute window so a transient renderer disconnect does not
* abort a prompt the user is about to answer. Otherwise we honor the
* user-configured grace period (issue #764).
*/
function getDisconnectCleanupDelayMs(sessionId: string): number {
return conversationService.getPendingPermissionRequests(sessionId).length > 0
? PENDING_PERMISSION_DISCONNECT_CLEANUP_MS
: CLIENT_DISCONNECT_CLEANUP_MS
: getDisconnectGraceMs()
}
/**
* Whether the session is mid-turn (a user message was sent and no result has
* arrived yet). Such a turn must not be killed on disconnect.
*/
function isSessionTurnActive(sessionId: string): boolean {
return activeUserTurns.get(sessionId)?.messageSent === true
}
/**
* Start the idle grace timer for a disconnected, idle session. If no client
* reconnects before it fires, the CLI subprocess is stopped.
*/
function scheduleDisconnectCleanup(sessionId: string): void {
computerUseApprovalService.cancelSession(sessionId)
const existing = sessionCleanupTimers.get(sessionId)
if (existing) clearTimeout(existing)
const cleanupDelayMs = getDisconnectCleanupDelayMs(sessionId)
const cleanupTimer = setTimeout(() => {
sessionCleanupTimers.delete(sessionId)
if (!hasActiveClients(sessionId)) {
console.log(`[WS] Session ${sessionId} not reconnected after ${cleanupDelayMs}ms, stopping CLI subprocess`)
conversationService.stopSession(sessionId)
cleanupSessionRuntimeState(sessionId)
}
}, cleanupDelayMs)
sessionCleanupTimers.set(sessionId, cleanupTimer)
}
/**
* Keep a still-running session alive after the last client leaves, and start
* the idle grace timer only once the current turn completes (issue #764). If a
* client reconnects first, cancelSessionDisconnectWatcher() tears this down.
*/
function watchTurnCompletionForCleanup(sessionId: string): void {
cancelSessionDisconnectWatcher(sessionId)
const onComplete = (cliMsg: any) => {
if (cliMsg?.type !== 'result') return
cancelSessionDisconnectWatcher(sessionId)
// The turn finished while still unobserved — fall back to the idle timer.
if (!hasActiveClients(sessionId)) {
scheduleDisconnectCleanup(sessionId)
}
}
conversationService.onOutput(sessionId, onComplete)
sessionDisconnectWatchers.set(sessionId, () => {
conversationService.removeOutputCallback(sessionId, onComplete)
})
}
/** Remove any pending turn-completion watcher for a session. */
function cancelSessionDisconnectWatcher(sessionId: string): void {
const remove = sessionDisconnectWatchers.get(sessionId)
if (remove) {
remove()
sessionDisconnectWatchers.delete(sessionId)
}
}
function replayPendingPermissionRequests(
@ -2537,9 +2613,11 @@ export function getActiveSessionIds(): string[] {
export function __resetWebSocketHandlerStateForTests(): void {
for (const timer of sessionCleanupTimers.values()) clearTimeout(timer)
for (const timer of prewarmIdleTimers.values()) clearTimeout(timer)
for (const remove of sessionDisconnectWatchers.values()) remove()
activeSessions.clear()
clientOutputCallbacks.clear()
sessionCleanupTimers.clear()
sessionDisconnectWatchers.clear()
prewarmPendingSessions.clear()
prewarmedSessions.clear()
prewarmIdleTimers.clear()
@ -2548,3 +2626,8 @@ export function __resetWebSocketHandlerStateForTests(): void {
export function __markPrewarmPendingForTests(sessionId: string): void {
prewarmPendingSessions.add(sessionId)
}
/** Test hook: mark a session as mid-turn so disconnect keeps the CLI alive. */
export function __markActiveTurnForTests(sessionId: string): void {
activeUserTurns.set(sessionId, { messageSent: true })
}