From 2eff7d81cf0e0c4f6945b19ad56d714ee2a07b03 Mon Sep 17 00:00:00 2001 From: phoenixray2000 Date: Mon, 11 May 2026 18:58:01 +0800 Subject: [PATCH 1/2] Harden CodexMobile pairing and mobile security --- .env.example | 29 +- README.md | 26 +- client/src/api.js | 20 +- client/src/app-state.test.mjs | 25 +- client/src/app/App.jsx | 87 +- client/src/app/FilePreviewApp.jsx | 15 +- client/src/app/PairingScreen.jsx | 91 +- client/src/app/defaults.js | 10 +- client/src/app/session-utils.js | 12 +- client/src/app/useAppWebSocket.js | 18 +- client/src/app/useTurnSubmission.js | 6 +- client/src/app/useViewportSizing.js | 19 +- client/src/chat/ActivityMessage.jsx | 4 + client/src/chat/MarkdownContent.jsx | 3 +- client/src/composer/Composer.jsx | 11 +- client/src/composer/attachment-preview.js | 6 +- .../src/composer/attachment-preview.test.mjs | 2 +- client/src/composer/composer-options.js | 15 +- client/src/composer/composer-options.test.mjs | 19 + client/src/pairing-flow.js | 65 + client/src/pairing-flow.test.mjs | 63 + client/src/panels/Drawer.jsx | 157 +- client/src/panels/TopBar.jsx | 12 +- client/src/security-devices.js | 32 + client/src/security-devices.test.mjs | 37 + client/src/styles/activity.css | 11 + client/src/styles/base.css | 14 + client/src/styles/panels-drawer.css | 221 +- client/src/styles/theme.css | 38 + .../2026-05-08-desktop-ipc-state-sync.md | 2 +- ...-05-10-codexmobile-merge-security-fixes.md | 1292 +++++++++ ...0-codexmobile-public-security-hardening.md | 2367 +++++++++++++++++ scripts/bundle-secret-scan.mjs | 54 + scripts/bundle-secret-scan.test.mjs | 29 + scripts/smoke.mjs | 58 +- scripts/start-asr.mjs | 45 +- scripts/start-asr.test.mjs | 11 + server/auth.js | 558 +++- server/auth.test.mjs | 525 +++- server/chat-delivery.js | 54 +- server/chat-request-prep.js | 3 +- server/chat-request-prep.test.mjs | 18 +- server/chat-routes.js | 13 +- server/chat-service.js | 13 +- server/chat-service.test.mjs | 80 +- server/codex-app-server.js | 63 +- server/codex-app-server.test.mjs | 14 +- server/codex-quota.js | 10 +- server/codex-runner.js | 11 +- server/desktop-ipc-client.test.mjs | 26 +- server/git-service.test.mjs | 6 +- server/index.js | 404 ++- server/integration.test.mjs | 391 +++ server/permission-policy.js | 68 + server/permission-policy.test.mjs | 83 + server/request-security.js | 115 + server/request-security.test.mjs | 120 + server/route-handlers.test.mjs | 22 + server/security-options.js | 141 + server/security-options.test.mjs | 104 + server/server-options.js | 109 + server/server-options.test.mjs | 24 + server/session-index-builder.test.mjs | 7 +- server/static-service.js | 14 +- server/static-service.test.mjs | 60 +- server/upload-service.js | 98 +- server/upload-service.test.mjs | 58 +- 67 files changed, 7766 insertions(+), 372 deletions(-) create mode 100644 client/src/pairing-flow.js create mode 100644 client/src/pairing-flow.test.mjs create mode 100644 client/src/security-devices.js create mode 100644 client/src/security-devices.test.mjs create mode 100644 docs/superpowers/plans/2026-05-10-codexmobile-merge-security-fixes.md create mode 100644 docs/superpowers/plans/2026-05-10-codexmobile-public-security-hardening.md create mode 100644 scripts/bundle-secret-scan.mjs create mode 100644 scripts/bundle-secret-scan.test.mjs create mode 100644 scripts/start-asr.test.mjs create mode 100644 server/integration.test.mjs create mode 100644 server/permission-policy.js create mode 100644 server/permission-policy.test.mjs create mode 100644 server/request-security.js create mode 100644 server/request-security.test.mjs create mode 100644 server/security-options.js create mode 100644 server/security-options.test.mjs create mode 100644 server/server-options.js create mode 100644 server/server-options.test.mjs diff --git a/.env.example b/.env.example index ac17dae..ecc4d97 100644 --- a/.env.example +++ b/.env.example @@ -7,19 +7,37 @@ PORT=3321 HTTPS_PORT=3443 # CODEXMOBILE_PUBLIC_URL=https://..ts.net:3443/ +# Public exposure profile. Enable only when using HTTPS through CodexMobile or +# a trusted reverse proxy. +# CODEXMOBILE_PUBLIC_ACCESS=1 +# CODEXMOBILE_PUBLIC_URL=https://codex.example.com/ +# CODEXMOBILE_ALLOWED_ORIGINS=https://codex.example.com +# Trust forwarded headers only from these proxy IPs/CIDRs. Leave empty for +# direct router forwarding. +# CODEXMOBILE_TRUSTED_PROXIES=127.0.0.1 +# Optional extra private CIDRs, e.g. VPN ranges not covered by defaults. +# CODEXMOBILE_PRIVATE_CIDRS=100.64.0.0/10 +# CODEXMOBILE_ALLOW_REMOTE_PAIRING=0 +# CODEXMOBILE_ENABLE_DANGER_FULL_ACCESS=0 +# CODEXMOBILE_PAIRING_CODE_LENGTH=10 +# CODEXMOBILE_PAIRING_CODE_TTL_MS=600000 +# CODEXMOBILE_PAIRING_REQUEST_COOLDOWN_MS=30000 +# CODEXMOBILE_PAIRING_MAX_FAILURES=5 +# CODEXMOBILE_PAIRING_LOCK_MS=900000 +# CODEXMOBILE_TOKEN_TTL_MS=7776000000 + # Optional Web Push VAPID subject. Defaults to CODEXMOBILE_PUBLIC_URL, then # mailto:codexmobile@localhost. iOS background notifications require an HTTPS # Home Screen PWA. # CODEXMOBILE_PUSH_SUBJECT=https://..ts.net/ -# Pairing -# Optional fixed six-digit pairing code. Leave empty to generate one at startup. -# CODEXMOBILE_PAIRING_CODE=123456 - # Codex local data # Defaults to ~/.codex. # CODEX_HOME=C:\Users\\.codex # CODEXMOBILE_HOME=C:\path\to\CodexMobile\.codexmobile\state +# Optional explicit Codex Desktop binary path for Windows services or shells +# whose PATH does not expose the codex command. +# CODEXMOBILE_CODEX_BINARY=C:\Users\\AppData\Local\OpenAI\Codex\bin\codex.exe # Optional Feishu/Lark docs integration # Keep secrets server-side only. CodexMobile uses the official lark-cli @@ -62,6 +80,9 @@ HTTPS_PORT=3443 CODEXMOBILE_LOCAL_TRANSCRIBE_BASE_URL=http://127.0.0.1:8000/v1 CODEXMOBILE_TRANSCRIBE_MODEL=iic/SenseVoiceSmall CODEXMOBILE_ASR_DEVICE=cpu +# Local SenseVoice ASR binds to localhost by default. +# Use 0.0.0.0 only if another trusted machine must call ASR directly. +# CODEXMOBILE_ASR_HOST=127.0.0.1 # CODEXMOBILE_ASR_PORT=8000 # CODEXMOBILE_ASR_REBUILD=1 # CODEXMOBILE_ASR_RECREATE=1 diff --git a/README.md b/README.md index b2f1d97..08776ad 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,7 @@ CodexMobile 和 Remodex 这类项目都在解决“移动端使用 Codex”的 - 前台 toast:任务完成、任务失败、需要用户输入、Git 进度都会提示。 - Web Push:在平台和浏览器支持的 HTTPS PWA 环境中,可接收后台完成通知。 - 连接恢复卡片:断开、重连、需配对、同步中、桌面端不可用时,会给出重试、同步、重新配对、查看状态等入口。 -- 配对使用一次性配对码和设备 token,适合单用户私有网络使用。 +- 配对使用局域网一次性请求、控制台配对码和 HttpOnly 设备 Cookie,适合单用户私有网络使用。 ### 本机工具能力 @@ -142,7 +142,7 @@ Mobile browser / PWA | 推荐 Tailscale Serve;局域网 HTTP 可用,但部分平台不能触发后台通知 v CodexMobile Node.js bridge - |-- Auth: pairing code + trusted device token + |-- Auth: LAN pairing request + HttpOnly trusted-device cookie |-- Codex data: ~/.codex/config.toml, ~/.codex/sessions, local mobile sessions |-- Desktop sync: Codex Desktop IPC read / steer / archive integration |-- Chat service: send, queue, steer, interrupt, file mentions, selected skills @@ -186,7 +186,7 @@ http://127.0.0.1:3321 http://<电脑的私网 IP>:3321 ``` -第一次进入需要输入服务启动时打印的 6 位配对码。配对成功后,浏览器会保存设备 token,后续不需要每次重新输入。 +第一次进入会先从同一局域网请求一次性配对码,电脑控制台会显示短时有效的配对码。配对成功后,浏览器使用 HttpOnly Cookie 保存设备会话,后续不需要每次重新输入。 ## PWA、HTTPS 和完成通知 @@ -229,6 +229,21 @@ $env:HTTPS_ROOT_CA_PATH="C:\path\to\root-ca.cer" npm run start:env ``` +## 公网端口转发安全部署 + +CodexMobile 可以放在家用/办公路由器后面使用公网端口转发,但必须按下面边界部署: + +- 只转发 CodexMobile HTTPS 端口,或只转发可信反向代理的 HTTPS 端口。 +- 不要转发 ASR、CLIProxyAPI、OpenAI-compatible provider、模型服务、Docker 容器端口。 +- 首次绑定手机时,把手机连接到电脑所在的同一 Wi-Fi / 局域网。 +- 未绑定设备打开页面不会自动打印配对码;只有在配对页点击“请求配对码”后,控制台才会显示一次性配对码。 +- 保持 `CODEXMOBILE_ALLOW_REMOTE_PAIRING=0`,外网未绑定设备只能看到绑定说明,不能创建配对请求。 +- 保持 `CODEXMOBILE_ENABLE_DANGER_FULL_ACCESS=0`,公网设备不能打开完全访问。 +- 如果使用反向代理,不要使用布尔型 trust proxy;只配置 `CODEXMOBILE_TRUSTED_PROXIES=<代理 IP 或 CIDR>`,并确保代理清洗外部传入的 `X-Forwarded-*`。 +- 公网模式下 HTTP 监听只用于本机健康检查;对外转发只使用 HTTPS。 +- 不要把 `.codexmobile/state` 放进 OneDrive、Dropbox、网盘同步目录或公开备份。 +- 手机丢失或微信环境不可信时,在侧边栏设置里的“授权设备”撤销对应设备;`/security` 兼容入口会打开同一个设置项。 + ## 配置 复制示例配置: @@ -244,9 +259,9 @@ npm run start:env - `PORT`:HTTP 端口,默认 `3321` - `HTTPS_PORT`:HTTPS 端口,默认 `3443` - `CODEXMOBILE_PUBLIC_URL`:移动设备访问用的公开私网地址 -- `CODEXMOBILE_PAIRING_CODE`:可选固定 6 位配对码;不设置则启动时随机生成 - `CODEX_HOME`:Codex 配置目录,默认 `~/.codex` - `CODEXMOBILE_HOME`:CodexMobile 本地状态目录,默认 `.codexmobile/state` +- `CODEXMOBILE_PAIRING_REQUEST_COOLDOWN_MS`:同一设备连续申请配对码的冷却时间,默认 `30000` - `CODEXMOBILE_PUSH_SUBJECT`:可选 Web Push VAPID subject;默认使用 `CODEXMOBILE_PUBLIC_URL`,再回退到 `mailto:codexmobile@localhost` - `CODEXMOBILE_FEISHU_APP_ID` / `CODEXMOBILE_FEISHU_APP_SECRET`:可选飞书应用凭证,用于 `lark-cli` 文档集成 - `LARK_APP_ID` / `LARK_APP_SECRET`:可选飞书凭证别名,供 `lark-cli` 和 Codex 子进程读取 @@ -300,6 +315,7 @@ npm run build 主要路由从 `server/index.js` 挂载,具体实现已拆到 `server/*-routes.js`、`server/*-service.js` 等模块: - `GET /api/status` +- `POST /api/pair/request` - `POST /api/pair` - `POST /api/sync` - `GET /api/projects` @@ -336,7 +352,7 @@ npm run build 2. 电脑启动 CodexMobile。 3. 日常聊天可使用 Tailscale IP 或局域网 IP。 4. 如果要 iOS 后台通知,启用 Tailscale Serve 并使用 HTTPS 地址。 -5. 第一次访问时输入配对码。 +5. 第一次访问时点击“请求配对码”,再输入电脑控制台显示的一次性配对码。 6. 在移动设备浏览器中添加到主屏或保存为 PWA。 7. 从主屏 PWA 打开 CodexMobile;如果平台支持 Web Push,再点击“开启完成通知”。 diff --git a/client/src/api.js b/client/src/api.js index a055aad..39e54ff 100644 --- a/client/src/api.js +++ b/client/src/api.js @@ -1,26 +1,26 @@ -const TOKEN_KEY = 'codexmobile.deviceToken'; +const LEGACY_TOKEN_KEY = 'codexmobile.deviceToken'; export function getToken() { - return localStorage.getItem(TOKEN_KEY) || ''; + return ''; } export function setToken(token) { - localStorage.setItem(TOKEN_KEY, token); + if (token) { + localStorage.removeItem(LEGACY_TOKEN_KEY); + } } export function clearToken() { - localStorage.removeItem(TOKEN_KEY); + localStorage.removeItem(LEGACY_TOKEN_KEY); } export async function apiFetch(path, options = {}) { const { timeoutMs: rawTimeoutMs, ...fetchOptions } = options; - const token = getToken(); const timeoutMs = Number(rawTimeoutMs || 0); const controller = timeoutMs > 0 ? new AbortController() : null; const timeout = controller ? globalThis.setTimeout(() => controller.abort(), timeoutMs) : null; const headers = { ...(fetchOptions.body instanceof FormData ? {} : { 'content-type': 'application/json' }), - ...(token ? { authorization: `Bearer ${token}` } : {}), ...(fetchOptions.headers || {}) }; @@ -28,6 +28,7 @@ export async function apiFetch(path, options = {}) { try { response = await fetch(path, { ...fetchOptions, + credentials: 'same-origin', headers, signal: fetchOptions.signal || controller?.signal, body: @@ -54,21 +55,21 @@ export async function apiFetch(path, options = {}) { const error = new Error(data.error || `Request failed: ${response.status}`); error.status = response.status; error.code = data.code || null; + error.retryAfterSeconds = data.retryAfterSeconds || null; throw error; } return data; } export async function apiBlobFetch(path, options = {}) { - const token = getToken(); const headers = { ...(options.body instanceof FormData ? {} : { 'content-type': 'application/json' }), - ...(token ? { authorization: `Bearer ${token}` } : {}), ...(options.headers || {}) }; const response = await fetch(path, { ...options, + credentials: 'same-origin', headers, body: options.body && !(options.body instanceof FormData) && typeof options.body !== 'string' @@ -97,7 +98,6 @@ export async function apiBlobFetch(path, options = {}) { } export function websocketUrl() { - const token = encodeURIComponent(getToken()); const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - return `${protocol}//${window.location.host}/ws?token=${token}`; + return `${protocol}//${window.location.host}/ws`; } diff --git a/client/src/app-state.test.mjs b/client/src/app-state.test.mjs index 8627f67..e39a377 100644 --- a/client/src/app-state.test.mjs +++ b/client/src/app-state.test.mjs @@ -20,7 +20,8 @@ import { titleFromFirstMessage } from './app/session-utils.js'; import { completeMessagesForTurnCompletion, runtimeKeysForPayload } from './app/useTurnRuntime.js'; -import { viewportSizingMetrics } from './app/useViewportSizing.js'; +import { isRevokedWebSocketClose } from './app/useAppWebSocket.js'; +import { shouldResetWindowScroll, viewportSizingMetrics } from './app/useViewportSizing.js'; test('appReducer updates ui state with direct and functional values', () => { const initial = createInitialUiState({ storage: { getItem: () => 'light' } }); @@ -397,16 +398,28 @@ test('viewportSizingMetrics exposes keyboard inset from visual viewport', () => assert.equal(metrics.height, 520); }); -test('localFileApiPath can include token for direct browser navigation', () => { +test('isRevokedWebSocketClose detects device revocation close events', () => { + assert.equal(isRevokedWebSocketClose({ code: 1008, reason: 'revoked' }), true); + assert.equal(isRevokedWebSocketClose({ code: 1008, reason: { toString: () => 'revoked' } }), true); + assert.equal(isRevokedWebSocketClose({ code: 1006, reason: '' }), false); +}); + +test('localFileApiPath uses cookie-authenticated local file URLs', () => { assert.equal( - localFileApiPath('/Users/demo/report.md', 'secret token'), - '/api/local-file?path=%2FUsers%2Fdemo%2Freport.md&token=secret%20token' + localFileApiPath('/Users/demo/report.md'), + '/api/local-file?path=%2FUsers%2Fdemo%2Freport.md' ); }); +test('shouldResetWindowScroll does not force login pages back to the header', () => { + assert.equal(shouldResetWindowScroll({ enabled: false, scrollX: 0, scrollY: 240 }), false); + assert.equal(shouldResetWindowScroll({ enabled: true, scrollX: 0, scrollY: 240 }), true); + assert.equal(shouldResetWindowScroll({ enabled: true, scrollX: 0, scrollY: 0 }), false); +}); + test('localFilePreviewPath routes local files through the mobile preview page', () => { assert.equal( - localFilePreviewPath('/Users/demo/report.md', 'secret token'), - '/preview/file?path=%2FUsers%2Fdemo%2Freport.md&token=secret+token' + localFilePreviewPath('/Users/demo/report.md'), + '/preview/file?path=%2FUsers%2Fdemo%2Freport.md' ); }); diff --git a/client/src/app/App.jsx b/client/src/app/App.jsx index 4879dd7..bf66253 100644 --- a/client/src/app/App.jsx +++ b/client/src/app/App.jsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react'; -import { apiFetch, getToken } from '../api.js'; +import { apiFetch } from '../api.js'; import { DEFAULT_PERMISSION_MODE } from '../composer/Composer.jsx'; -import { DEFAULT_MODEL_SPEED, normalizeModelSpeed } from '../composer/composer-options.js'; +import { DEFAULT_MODEL_SPEED, normalizeModelSpeed, permissionModeForSecurity } from '../composer/composer-options.js'; import { useComposerSelections } from '../composer/useComposerSelections.js'; import { useQueueDrafts } from '../composer/useQueueDrafts.js'; import { connectionRecoveryState } from '../connection-recovery.js'; @@ -42,7 +42,8 @@ const MODEL_SPEED_KEY = 'codexmobile.modelSpeed'; export default function App() { const [status, setStatus] = useState(DEFAULT_STATUS); const [contextStatus, setContextStatus] = useState(() => normalizeContextStatus(DEFAULT_STATUS.context)); - const [authenticated, setAuthenticated] = useState(Boolean(getToken())); + const [authenticated, setAuthenticated] = useState(false); + const [authChecked, setAuthChecked] = useState(false); const [uiState, dispatchUi] = useReducer(appReducer, undefined, () => createInitialUiState()); const setDrawerOpen = useCallback((value) => dispatchUi({ type: 'ui/drawerOpen', value }), []); const setPreviewImage = useCallback((value) => dispatchUi({ type: 'ui/previewImage', value }), []); @@ -52,6 +53,7 @@ export default function App() { const setGitPanel = useCallback((value) => dispatchUi({ type: 'ui/gitPanel', value }), []); const setTheme = useCallback((value) => dispatchUi({ type: 'ui/theme', value }), []); const { drawerOpen, previewImage, docsOpen, docsBusy, docsError, gitPanel, theme } = uiState; + const [drawerRequestedView, setDrawerRequestedView] = useState('main'); const { toasts, notificationSupported, @@ -101,7 +103,7 @@ export default function App() { const [runningById, setRunningById] = useState({}); const [threadRuntimeById, setThreadRuntimeById] = useState({}); const [syncing, setSyncing] = useState(false); - const [connectionState, setConnectionState] = useState(() => (getToken() ? 'connecting' : 'disconnected')); + const [connectionState, setConnectionState] = useState('disconnected'); const wsRef = useRef(null); const selectedProjectRef = useRef(null); const selectedSessionRef = useRef(null); @@ -134,7 +136,7 @@ export default function App() { setSelectedSkillPaths }); - useViewportSizing(composerRef); + useViewportSizing(composerRef, { enabled: authenticated }); const selectedRuntime = selectedRunKeys(selectedSession) .map((key) => threadRuntimeById[key]) @@ -165,6 +167,13 @@ export default function App() { loadQueueDrafts(selectedSession).catch(() => null); }, [selectedSession?.id]); + useEffect(() => { + const normalizedPermissionMode = permissionModeForSecurity(permissionMode, status.security || DEFAULT_STATUS.security); + if (normalizedPermissionMode !== permissionMode) { + setPermissionMode(normalizedPermissionMode); + } + }, [permissionMode, status.security?.dangerFullAccessEnabled]); + useEffect(() => { setThreadRuntimeById((current) => { const next = reconcileThreadRuntimeWithSessions(current, sessionsByProject); @@ -267,6 +276,13 @@ export default function App() { }; }, [theme]); + useEffect(() => { + document.documentElement.dataset.codexRoute = !authChecked || !authenticated ? 'pairing' : 'app'; + return () => { + delete document.documentElement.dataset.codexRoute; + }; + }, [authChecked, authenticated]); + useEffect(() => { if (selectedReasoningEffort) { localStorage.setItem('codexmobile.reasoningEffort', selectedReasoningEffort); @@ -301,6 +317,35 @@ export default function App() { } }, [selectedModel, selectedReasoningEffort, status.model, status.reasoningEffort]); + const openDrawerMain = useCallback(() => { + setDrawerRequestedView('main'); + setDrawerOpen(true); + }, [setDrawerOpen]); + + const openSecuritySettings = useCallback(() => { + setDrawerRequestedView('settings'); + setDrawerOpen(true); + }, [setDrawerOpen]); + + useEffect(() => { + if (!authChecked || !authenticated || window.location.pathname !== '/security') { + return; + } + openSecuritySettings(); + window.history.replaceState({}, '', '/'); + }, [authChecked, authenticated, openSecuritySettings]); + + const closeDrawer = useCallback(() => { + setDrawerOpen(false); + }, [setDrawerOpen]); + + const handleSecurityLoggedOut = useCallback(() => { + setAuthenticated(false); + setConnectionState('disconnected'); + setDrawerRequestedView('main'); + setDrawerOpen(false); + }, [setDrawerOpen]); + const { loadStatus, loadSessions, @@ -328,7 +373,7 @@ export default function App() { return; } bootstrapStartedRef.current = true; - bootstrap(); + bootstrap().finally(() => setAuthChecked(true)); }, [bootstrap]); useEffect(() => { @@ -421,7 +466,8 @@ export default function App() { setProjects, setSelectedProject, setExpandedProjectIds, - loadSessions + loadSessions, + onAuthenticationRevoked: handleSecurityLoggedOut }); const { @@ -489,7 +535,8 @@ export default function App() { markSessionCompleteNotice, markTurnCompleted, scheduleTurnRefresh, - loadQueueDrafts + loadQueueDrafts, + onPermissionModeRejected: () => setPermissionMode(DEFAULT_PERMISSION_MODE) }); async function handleGitAction(action) { @@ -532,8 +579,20 @@ export default function App() { }); const topBarRuntime = selectedRuntime || (selectedRunning ? { status: 'running' } : null); + if (!authChecked) { + return ( +
+
+ +
+

CodexMobile

+

正在确认当前设备授权...

+
+ ); + } + if (!authenticated) { - return ; + return ; } const sessionLoading = Boolean(sessionLoadingId && selectedSession?.id === sessionLoadingId); @@ -547,8 +606,9 @@ export default function App() { connectionState, desktopBridge: status.desktopBridge, selectedRuntime: topBarRuntime, - onMenu: () => setDrawerOpen(true), + onMenu: openDrawerMain, onOpenDocs: () => setDocsOpen(true), + onOpenSecurity: openSecuritySettings, onGitAction: handleGitAction, notificationSupported, notificationEnabled, @@ -592,7 +652,8 @@ export default function App() { }; const drawerProps = { open: drawerOpen, - onClose: () => setDrawerOpen(false), + onClose: closeDrawer, + requestedView: drawerRequestedView, projects, selectedProject, selectedSession, @@ -610,7 +671,8 @@ export default function App() { onSync: handleSync, syncing, theme, - setTheme + setTheme, + onLoggedOut: handleSecurityLoggedOut }; const chatProps = { messages, @@ -657,6 +719,7 @@ export default function App() { contextStatus: visibleContextStatus, runStatus: composerRunStatusForShell, desktopBridge: status.desktopBridge, + security: status.security || DEFAULT_STATUS.security, queueDrafts, onRestoreQueueDraft: restoreQueueDraft, onRemoveQueueDraft: removeQueueDraft, diff --git a/client/src/app/FilePreviewApp.jsx b/client/src/app/FilePreviewApp.jsx index e9e9cbe..82bea58 100644 --- a/client/src/app/FilePreviewApp.jsx +++ b/client/src/app/FilePreviewApp.jsx @@ -1,6 +1,6 @@ import { ArrowLeft, Check, Code2, Copy, ExternalLink, FileText, Minus, Plus, RefreshCw, Save, Share2, X } from 'lucide-react'; import { useEffect, useMemo, useState } from 'react'; -import { apiFetch, getToken } from '../api.js'; +import { apiFetch } from '../api.js'; import { MarkdownContent } from '../chat/MarkdownContent.jsx'; import { copyTextToClipboard } from '../utils/clipboard.js'; import { THEME_KEY } from './AppState.js'; @@ -46,8 +46,7 @@ function cleanMimeType(value, fallback = 'application/octet-stream') { export default function FilePreviewApp() { const params = useMemo(() => new URLSearchParams(window.location.search), []); const filePath = params.get('path') || ''; - const urlToken = params.get('token') || ''; - const rawFileUrl = localFileApiPath(filePath, urlToken || getToken()); + const rawFileUrl = localFileApiPath(filePath); const [mode, setMode] = useState('rendered'); const [fontScale, setFontScale] = useState(1); const [copied, setCopied] = useState(false); @@ -107,9 +106,7 @@ export default function FilePreviewApp() { } setState({ loading: true, error: '', text: '', objectUrl: '', pdfData: null, contentType: '', mtimeMs: 0, editable: false }); try { - const response = await fetch(localFileApiPath(filePath, urlToken || getToken()), { - headers: getToken() ? { authorization: `Bearer ${getToken()}` } : {} - }); + const response = await fetch(localFileApiPath(filePath), { credentials: 'same-origin' }); if (!response.ok) { const text = await response.text(); let message = `Request failed: ${response.status}`; @@ -165,7 +162,7 @@ export default function FilePreviewApp() { URL.revokeObjectURL(objectUrl); } }; - }, [filePath, urlToken]); + }, [filePath]); const kind = previewKind(filePath, state.contentType); const title = fileNameFromPath(filePath); @@ -197,9 +194,7 @@ export default function FilePreviewApp() { const response = await fetch(state.objectUrl); return response.blob(); } - const response = await fetch(rawFileUrl, { - headers: getToken() ? { authorization: `Bearer ${getToken()}` } : {} - }); + const response = await fetch(rawFileUrl, { credentials: 'same-origin' }); if (!response.ok) { throw new Error(`Request failed: ${response.status}`); } diff --git a/client/src/app/PairingScreen.jsx b/client/src/app/PairingScreen.jsx index 4af28af..5b41eaf 100644 --- a/client/src/app/PairingScreen.jsx +++ b/client/src/app/PairingScreen.jsx @@ -1,25 +1,81 @@ import { Check, Loader2, Monitor } from 'lucide-react'; -import { useState } from 'react'; -import { apiFetch, setToken } from '../api.js'; +import { useEffect, useState } from 'react'; +import { apiFetch } from '../api.js'; +import { + PAIRING_REQUEST_COOLDOWN_MS, + normalizePairingCode, + pairingRequestCooldownSeconds, + pairingPromptText, + pairingRequestDisabled, + pairingRequestLabel, + pairingSubmitDisabled +} from '../pairing-flow.js'; -export default function PairingScreen({ onPaired }) { +export default function PairingScreen({ onPaired, canPair = true }) { const [code, setCode] = useState(''); const [error, setError] = useState(''); + const [requestId, setRequestId] = useState(''); + const [codeLength, setCodeLength] = useState(10); + const [requesting, setRequesting] = useState(false); const [pairing, setPairing] = useState(false); + const [cooldownUntil, setCooldownUntil] = useState(0); + const [cooldownNow, setCooldownNow] = useState(() => Date.now()); + const cooldownSeconds = pairingRequestCooldownSeconds(cooldownUntil, cooldownNow); + + useEffect(() => { + if (cooldownSeconds <= 0) { + return undefined; + } + const timer = window.setInterval(() => setCooldownNow(Date.now()), 500); + return () => window.clearInterval(timer); + }, [cooldownSeconds]); + + async function requestPairingCode() { + setRequesting(true); + setError(''); + try { + const result = await apiFetch('/api/pair/request', { + method: 'POST', + body: { + deviceName: navigator.platform || 'iPhone' + } + }); + setRequestId(result.requestId || ''); + setCodeLength(Number(result.codeLength || 10)); + setCode(''); + const serverCooldownSeconds = Number(result.requestCooldownSeconds); + const cooldownMs = Number.isFinite(serverCooldownSeconds) && serverCooldownSeconds > 0 + ? serverCooldownSeconds * 1000 + : PAIRING_REQUEST_COOLDOWN_MS; + const now = Date.now(); + setCooldownNow(now); + setCooldownUntil(now + cooldownMs); + } catch (err) { + const retryAfterSeconds = Number(err.retryAfterSeconds || 0); + if (retryAfterSeconds > 0) { + const now = Date.now(); + setCooldownNow(now); + setCooldownUntil(now + retryAfterSeconds * 1000); + } + setError(err.message); + } finally { + setRequesting(false); + } + } async function handlePair(event) { event.preventDefault(); setPairing(true); setError(''); try { - const result = await apiFetch('/api/pair', { + await apiFetch('/api/pair', { method: 'POST', body: { + requestId, code, deviceName: navigator.platform || 'iPhone' } }); - setToken(result.token); onPaired(); } catch (err) { setError(err.message); @@ -42,16 +98,29 @@ export default function PairingScreen({ onPaired }) { 完整执行过程 私有网络访问 -

输入电脑端启动日志里的 6 位配对码。

+

+ {pairingPromptText({ requesting, requestId, codeLength, canPair })} +

+
setCode(event.target.value.replace(/\D/g, '').slice(0, 6))} + disabled={pairing} + onChange={(event) => setCode(normalizePairingCode(event.target.value, codeLength))} /> - diff --git a/client/src/app/defaults.js b/client/src/app/defaults.js index 8aa48c2..556705f 100644 --- a/client/src/app/defaults.js +++ b/client/src/app/defaults.js @@ -49,7 +49,15 @@ export const DEFAULT_STATUS = { reason: '' } }, - auth: { authenticated: false } + auth: { authenticated: false }, + security: { + publicAccess: false, + dangerFullAccessEnabled: false, + httpsEnabled: false, + pairing: { + lanOnly: true + } + } }; export const DEFAULT_REASONING_EFFORT = 'xhigh'; diff --git a/client/src/app/session-utils.js b/client/src/app/session-utils.js index bc1a63f..3f24d9e 100644 --- a/client/src/app/session-utils.js +++ b/client/src/app/session-utils.js @@ -165,23 +165,17 @@ export function localImageApiPath(value) { return `/api/local-image?path=${encodeURIComponent(normalized)}`; } -export function localFileApiPath(value, token = '') { +export function localFileApiPath(value) { const raw = String(value || '').trim(); const normalized = /%[0-9a-f]{2}/i.test(raw) ? safeDecodeUriComponent(raw) : raw; - const tokenValue = String(token || '').trim(); - const tokenParam = tokenValue ? `&token=${encodeURIComponent(tokenValue)}` : ''; - return `/api/local-file?path=${encodeURIComponent(normalized)}${tokenParam}`; + return `/api/local-file?path=${encodeURIComponent(normalized)}`; } -export function localFilePreviewPath(value, token = '') { +export function localFilePreviewPath(value) { const raw = String(value || '').trim(); const normalized = /%[0-9a-f]{2}/i.test(raw) ? safeDecodeUriComponent(raw) : raw; const params = new URLSearchParams(); params.set('path', normalized); - const tokenValue = String(token || '').trim(); - if (tokenValue) { - params.set('token', tokenValue); - } return `/preview/file?${params.toString()}`; } diff --git a/client/src/app/useAppWebSocket.js b/client/src/app/useAppWebSocket.js index a7c603a..5f5b778 100644 --- a/client/src/app/useAppWebSocket.js +++ b/client/src/app/useAppWebSocket.js @@ -1,4 +1,4 @@ -import { apiFetch, getToken, websocketUrl } from '../api.js'; +import { apiFetch, websocketUrl } from '../api.js'; import { applySessionRenameToProjectSessions } from '../session-live-refresh.js'; @@ -60,6 +60,10 @@ export function shouldRefreshCurrentSessionAfterReconnect(session = null) { return Boolean(sessionId && !sessionId.startsWith('draft-')); } +export function isRevokedWebSocketClose(event = {}) { + return Number(event?.code) === 1008 && String(event?.reason || '') === 'revoked'; +} + export function useAppWebSocket({ useEffect, authenticated, @@ -87,10 +91,11 @@ export function useAppWebSocket({ setProjects, setSelectedProject, setExpandedProjectIds, - loadSessions + loadSessions, + onAuthenticationRevoked }) { useEffect(() => { - if (!authenticated || !getToken()) { + if (!authenticated) { setConnectionState('disconnected'); return undefined; } @@ -120,8 +125,13 @@ export function useAppWebSocket({ wsRef.current = ws; ws.onopen = () => setConnectionState('connecting'); - ws.onclose = () => { + ws.onclose = (event) => { setConnectionState('disconnected'); + if (isRevokedWebSocketClose(event)) { + stopped = true; + onAuthenticationRevoked?.(); + return; + } if (!stopped) { reconnectingAfterDrop = true; reconnectTimer = window.setTimeout(connect, 1200); diff --git a/client/src/app/useTurnSubmission.js b/client/src/app/useTurnSubmission.js index 3e0c544..fbb2625 100644 --- a/client/src/app/useTurnSubmission.js +++ b/client/src/app/useTurnSubmission.js @@ -66,7 +66,8 @@ export function useTurnSubmission({ markSessionCompleteNotice, markTurnCompleted, scheduleTurnRefresh, - loadQueueDrafts + loadQueueDrafts, + onPermissionModeRejected = () => {} }) { function applyTurnSession(turn, optimisticSessionId, projectId, previousSessionId) { const realSessionId = realSessionIdFromTurn(turn); @@ -374,6 +375,9 @@ export function useTurnSubmission({ }; } catch (error) { clearRun({ turnId, sessionId: optimisticSessionId, previousSessionId: draftSessionId || outgoingSessionId }); + if (error?.code === 'CODEXMOBILE_DANGER_FULL_ACCESS_DISABLED') { + onPermissionModeRejected(); + } if (clearComposer) { setAttachments(selectedAttachments); setFileMentions(selectedFileMentions); diff --git a/client/src/app/useViewportSizing.js b/client/src/app/useViewportSizing.js index 20a3cab..12447b7 100644 --- a/client/src/app/useViewportSizing.js +++ b/client/src/app/useViewportSizing.js @@ -22,9 +22,22 @@ export function viewportSizingMetrics({ }; } -export function useViewportSizing(composerRef) { +export function shouldResetWindowScroll({ enabled = true, scrollX = 0, scrollY = 0 } = {}) { + return Boolean(enabled) && (Boolean(scrollX) || Boolean(scrollY)); +} + +export function useViewportSizing(composerRef, { enabled = true } = {}) { useEffect(() => { const root = document.documentElement; + if (!enabled) { + root.style.removeProperty('--app-height'); + root.style.removeProperty('--app-width'); + root.style.removeProperty('--composer-height'); + root.style.removeProperty('--keyboard-inset'); + delete root.dataset.keyboard; + return undefined; + } + let frame = 0; let observeFrame = 0; let composerObserver = null; @@ -56,7 +69,7 @@ export function useViewportSizing(composerRef) { root.style.setProperty('--composer-height', `${composerHeight}px`); } root.dataset.keyboard = keyboardOpen ? 'open' : 'closed'; - if (window.scrollX || window.scrollY) { + if (shouldResetWindowScroll({ enabled, scrollX: window.scrollX, scrollY: window.scrollY })) { window.scrollTo(0, 0); } }); @@ -91,5 +104,5 @@ export function useViewportSizing(composerRef) { root.style.removeProperty('--keyboard-inset'); delete root.dataset.keyboard; }; - }, [composerRef]); + }, [composerRef, enabled]); } diff --git a/client/src/chat/ActivityMessage.jsx b/client/src/chat/ActivityMessage.jsx index b1ca8de..970428e 100644 --- a/client/src/chat/ActivityMessage.jsx +++ b/client/src/chat/ActivityMessage.jsx @@ -25,6 +25,7 @@ export function ActivityMessage({ message, now = Date.now(), onImplementPlan }) const visibleSteps = activities.filter((activity) => isVisibleActivityStep(activity, message.status)); const { timeRange, timeline, fileSummary } = projectActivityView(visibleSteps, { running }); const hasProcess = timeline.length > 0 || Boolean(fileSummary); + const failureDetail = failed ? String(message.detail || '').trim() : ''; const [open, setOpen] = useState(() => pendingPlanImplementation || activityCardShouldOpen({ running, hasProcess })); const startedAt = message.startedAt || timeRange.startedAt || message.timestamp; const endedAt = running ? now : message.completedAt || timeRange.endedAt || message.timestamp || now; @@ -55,6 +56,9 @@ export function ActivityMessage({ message, now = Date.now(), onImplementPlan }) onImplementPlan={onImplementPlan} /> ) : null} + {failureDetail && !hasProcess ? ( +
{failureDetail}
+ ) : null} ); diff --git a/client/src/chat/MarkdownContent.jsx b/client/src/chat/MarkdownContent.jsx index b1bc8f3..3337ded 100644 --- a/client/src/chat/MarkdownContent.jsx +++ b/client/src/chat/MarkdownContent.jsx @@ -3,7 +3,6 @@ import { useEffect, useRef, useState } from 'react'; import ReactMarkdown, { defaultUrlTransform } from 'react-markdown'; import remarkBreaks from 'remark-breaks'; import remarkGfm from 'remark-gfm'; -import { getToken } from '../api.js'; import { isLocalFileSource, isLocalImageSource, localFilePreviewPath } from '../app/session-utils.js'; import { copyTextToClipboard } from '../utils/clipboard.js'; import { GeneratedImage } from './ImagePreview.jsx'; @@ -157,7 +156,7 @@ function normalizeInlineHref(value) { return ''; } if (isLocalFileSource(raw)) { - return localFilePreviewPath(raw, getToken()); + return localFilePreviewPath(raw); } if (/^https?:\/\//i.test(raw) || /^mailto:/i.test(raw) || raw.startsWith('/') || raw.startsWith('#')) { return raw; diff --git a/client/src/composer/Composer.jsx b/client/src/composer/Composer.jsx index 7547fbe..65c8b3e 100644 --- a/client/src/composer/Composer.jsx +++ b/client/src/composer/Composer.jsx @@ -1,13 +1,13 @@ import { ArrowUp, Bot, Check, ChevronDown, FileText, Image, Loader2, MessageSquare, MessageSquarePlus, Paperclip, Plus, Search, Shield, Square, Terminal, Trash2, Zap } from 'lucide-react'; import { useEffect, useMemo, useRef, useState } from 'react'; -import { apiFetch, getToken } from '../api.js'; +import { apiFetch } from '../api.js'; import { detectComposerToken, filteredSlashCommands, replaceComposerToken } from '../composer-shortcuts.js'; import { composerSendState } from '../send-state.js'; import { isDraftSession } from '../app/session-utils.js'; import { attachmentPreviewUrl, isImageAttachment } from './attachment-preview.js'; import { filesFromClipboardData } from './paste-files.js'; import { ContextStatusButton, ContextStatusDetails } from './ContextStatus.jsx'; -import { DEFAULT_PERMISSION_MODE, MODEL_SPEED_OPTIONS, PERMISSION_OPTIONS, REASONING_OPTIONS, formatBytes, modelSpeedLabel, normalizeModelSpeed, permissionLabel, reasoningLabel, selectedSkillSummary, shortModelName } from './composer-options.js'; +import { DEFAULT_PERMISSION_MODE, MODEL_SPEED_OPTIONS, REASONING_OPTIONS, formatBytes, modelSpeedLabel, normalizeModelSpeed, permissionLabel, permissionOptionsForSecurity, reasoningLabel, selectedSkillSummary, shortModelName } from './composer-options.js'; export { DEFAULT_PERMISSION_MODE } from './composer-options.js'; @@ -44,6 +44,7 @@ export function Composer({ contextStatus, runStatus, desktopBridge, + security, queueDrafts, onRestoreQueueDraft, onRemoveQueueDraft, @@ -57,7 +58,6 @@ export function Composer({ const [cursorPosition, setCursorPosition] = useState(0); const [fileSearch, setFileSearch] = useState({ query: '', loading: false, results: [] }); const selectedFileMentions = Array.isArray(fileMentions) ? fileMentions : []; - const deviceToken = getToken(); const hasInput = input.trim().length > 0 || attachments.length > 0 || selectedFileMentions.length > 0; const modelList = models?.length ? models : [{ value: selectedModel || 'gpt-5.5', label: selectedModel || 'gpt-5.5' }]; const selectedModelLabel = modelList.find((model) => model.value === selectedModel)?.label || selectedModel || 'gpt-5.5'; @@ -65,6 +65,7 @@ export function Composer({ const skillList = Array.isArray(skills) ? skills : []; const selectedSkillSet = new Set(Array.isArray(selectedSkillPaths) ? selectedSkillPaths : []); const selectedSkills = skillList.filter((skill) => selectedSkillSet.has(skill.path)); + const permissionOptions = permissionOptionsForSecurity(security); const composerToken = useMemo( () => detectComposerToken(input, cursorPosition || input.length), [input, cursorPosition] @@ -271,7 +272,7 @@ export function Composer({ ) : null} {openMenu === 'permission' ? (
- {PERMISSION_OPTIONS.map((option) => ( + {permissionOptions.map((option) => ( +
+
+
+ 有效设备 + {counts.active} +
+
+ 撤销记录 + {counts.revoked} +
+
+ + {error ?
{error}
: null} +
+ {loading ?
正在读取设备...
: null} + {!loading && !sortedDevices.length ?
暂无授权设备
: null} + {!loading ? sortedDevices.map((device) => { + const state = deviceState(device); + const canRevoke = !device.revokedAt; + return ( +
+ +
+
+ {deviceDisplayName(device)} + {state.label} +
+

{deviceAccessText(device)}

+ {device.lastUserAgent || device.userAgent || '未记录浏览器信息'} +
+ +
+ ); + }) : null} +
+ + + ); +} + export function Drawer({ open, onClose, + requestedView = 'main', projects, selectedProject, selectedSession, @@ -92,7 +232,8 @@ export function Drawer({ onSync, syncing, theme, - setTheme + setTheme, + onLoggedOut }) { const [drawerView, setDrawerView] = useState('main'); const [subagentExpandedById, setSubagentExpandedById] = useState({}); @@ -118,11 +259,14 @@ export function Drawer({ const projectChoices = orderedProjects.filter((project) => !project.projectless); useEffect(() => { - if (!open) { - setThreadActionMenu(null); - setNewConversationOpen(false); + if (open) { + setDrawerView(requestedView || 'main'); + return; } - }, [open]); + setDrawerView('main'); + setThreadActionMenu(null); + setNewConversationOpen(false); + }, [open, requestedView]); function startNewConversation(project, event) { event?.preventDefault(); @@ -233,6 +377,7 @@ export function Drawer({ + diff --git a/client/src/panels/TopBar.jsx b/client/src/panels/TopBar.jsx index 109ac89..0772ff1 100644 --- a/client/src/panels/TopBar.jsx +++ b/client/src/panels/TopBar.jsx @@ -1,4 +1,4 @@ -import { Bell, Check, Copy, FileText, GitBranch, GitCommitHorizontal, Menu, MoreHorizontal, RefreshCw, UploadCloud, Wifi } from 'lucide-react'; +import { Bell, Check, Copy, FileText, GitBranch, GitCommitHorizontal, Menu, MoreHorizontal, RefreshCw, Shield, UploadCloud, Wifi } from 'lucide-react'; import { useEffect, useRef, useState } from 'react'; import { copyTextToClipboard } from '../utils/clipboard.js'; import { isDraftSession } from '../app/session-utils.js'; @@ -15,6 +15,7 @@ export function TopBar({ selectedRuntime, onMenu, onOpenDocs, + onOpenSecurity, onGitAction, notificationSupported, notificationEnabled, @@ -75,6 +76,11 @@ export function TopBar({ onOpenDocs?.(); } + function handleOpenSecurity() { + setMenuOpen(false); + onOpenSecurity?.(); + } + function handleEnableNotifications() { setMenuOpen(false); onEnableNotifications?.(); @@ -121,6 +127,10 @@ export function TopBar({ {notificationEnabled ? '完成通知已开启' : '开启完成通知'} +
diff --git a/client/src/security-devices.js b/client/src/security-devices.js new file mode 100644 index 0000000..1be78a5 --- /dev/null +++ b/client/src/security-devices.js @@ -0,0 +1,32 @@ +export function deviceState(device) { + if (device?.revokedAt) { + return { label: '已撤销', className: 'is-revoked' }; + } + if (device?.current) { + return { label: '当前设备', className: 'is-current' }; + } + return { label: '已授权', className: 'is-active' }; +} + +export function sortDevices(devices) { + return [...(Array.isArray(devices) ? devices : [])].sort((left, right) => { + const leftRank = left?.revokedAt ? 2 : left?.current ? 0 : 1; + const rightRank = right?.revokedAt ? 2 : right?.current ? 0 : 1; + if (leftRank !== rightRank) { + return leftRank - rightRank; + } + return String(right?.lastSeenAt || right?.createdAt || '').localeCompare(String(left?.lastSeenAt || left?.createdAt || '')); + }); +} + +export function deviceDisplayName(device) { + return String(device?.name || '').trim() || '未命名设备'; +} + +export function deviceCounts(devices) { + const values = Array.isArray(devices) ? devices : []; + return { + active: values.filter((device) => !device?.revokedAt).length, + revoked: values.filter((device) => device?.revokedAt).length + }; +} diff --git a/client/src/security-devices.test.mjs b/client/src/security-devices.test.mjs new file mode 100644 index 0000000..82e61d7 --- /dev/null +++ b/client/src/security-devices.test.mjs @@ -0,0 +1,37 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { deviceCounts, deviceDisplayName, deviceState, sortDevices } from './security-devices.js'; + +test('deviceState labels current, active, and revoked devices', () => { + assert.deepEqual(deviceState({ current: true }), { label: '当前设备', className: 'is-current' }); + assert.deepEqual(deviceState({ current: false }), { label: '已授权', className: 'is-active' }); + assert.deepEqual(deviceState({ current: true, revokedAt: '2026-01-01T00:00:00.000Z' }), { + label: '已撤销', + className: 'is-revoked' + }); +}); + +test('sortDevices keeps current first and revoked last', () => { + const sorted = sortDevices([ + { id: 'old', createdAt: '2026-01-01T00:00:00.000Z' }, + { id: 'revoked', revokedAt: '2026-01-03T00:00:00.000Z', createdAt: '2026-01-03T00:00:00.000Z' }, + { id: 'current', current: true, createdAt: '2026-01-02T00:00:00.000Z' }, + { id: 'new', createdAt: '2026-01-04T00:00:00.000Z' } + ]); + + assert.deepEqual(sorted.map((device) => device.id), ['current', 'new', 'old', 'revoked']); +}); + +test('deviceDisplayName falls back for blank names', () => { + assert.equal(deviceDisplayName({ name: ' Ray iPhone ' }), 'Ray iPhone'); + assert.equal(deviceDisplayName({ name: ' ' }), '未命名设备'); +}); + +test('deviceCounts separates active and revoked devices', () => { + assert.deepEqual(deviceCounts([ + { id: 'current', current: true }, + { id: 'active' }, + { id: 'revoked', revokedAt: '2026-01-01T00:00:00.000Z' } + ]), { active: 2, revoked: 1 }); +}); diff --git a/client/src/styles/activity.css b/client/src/styles/activity.css index 368a896..a91c7de 100644 --- a/client/src/styles/activity.css +++ b/client/src/styles/activity.css @@ -55,6 +55,17 @@ backdrop-filter: blur(14px) saturate(1.08); } +.activity-failure-detail { + max-width: min(100%, 736px); + margin-top: 6px; + padding: 0 10px 4px; + color: var(--danger); + font-size: 12px; + line-height: 1.55; + overflow-wrap: anywhere; + white-space: pre-wrap; +} + .activity-text { margin: 0; color: var(--text); diff --git a/client/src/styles/base.css b/client/src/styles/base.css index eec891d..e505546 100644 --- a/client/src/styles/base.css +++ b/client/src/styles/base.css @@ -63,6 +63,20 @@ body, overscroll-behavior: none; } +html[data-codex-route="pairing"], +html[data-codex-route="pairing"] body, +html[data-codex-route="pairing"] #root { + height: auto; + min-height: 100%; + overflow: auto; + overscroll-behavior: auto; +} + +html[data-codex-route="pairing"] body { + position: static; + touch-action: auto; +} + body { position: fixed; inset: 0; diff --git a/client/src/styles/panels-drawer.css b/client/src/styles/panels-drawer.css index 266ce76..5c6b899 100644 --- a/client/src/styles/panels-drawer.css +++ b/client/src/styles/panels-drawer.css @@ -184,8 +184,12 @@ .settings-view { flex: 1 1 auto; min-height: 0; + display: flex; + flex-direction: column; + gap: 18px; overflow: auto; - padding-top: 8px; + padding: 8px 0 calc(env(safe-area-inset-bottom, 0px) + 10px); + overscroll-behavior: contain; } .settings-group { @@ -251,6 +255,221 @@ box-shadow: 0 1px 4px rgba(20, 24, 31, 0.12); } +.settings-security-card { + display: grid; + gap: 10px; + padding: 12px; + border-radius: 8px; + background: #f5f7f4; +} + +[data-theme="dark"] .settings-security-card { + background: #101012; +} + +.settings-security-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + min-width: 0; +} + +.settings-security-head > div, +.settings-security-head button, +.settings-logout-button, +.settings-device-row button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; +} + +.settings-security-head > div { + min-width: 0; + color: var(--text); + font-size: 14px; + font-weight: 650; +} + +.settings-security-head button, +.settings-logout-button, +.settings-device-row button { + min-height: 34px; + border: 1px solid var(--line); + border-radius: 8px; + color: var(--text); + background: var(--panel); + font-size: 12px; + font-weight: 700; +} + +.settings-security-head button { + flex: 0 0 auto; + padding: 0 10px; +} + +.settings-security-head button:disabled, +.settings-logout-button:disabled, +.settings-device-row button:disabled { + opacity: 0.45; +} + +.settings-security-summary { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.settings-security-summary > div { + min-width: 0; + display: grid; + gap: 3px; + padding: 10px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel); +} + +.settings-security-summary span { + color: var(--muted); + font-size: 12px; +} + +.settings-security-summary strong { + color: var(--text); + font-size: 22px; + line-height: 26px; +} + +.settings-logout-button { + width: 100%; + color: var(--danger); + background: rgba(194, 65, 58, 0.08); +} + +.settings-security-error { + padding: 9px 10px; + border-radius: 8px; + color: var(--danger); + background: rgba(194, 65, 58, 0.08); + font-size: 13px; + line-height: 18px; +} + +.settings-device-list { + display: flex; + flex-direction: column; + gap: 8px; + min-width: 0; +} + +.settings-device-empty { + display: grid; + place-items: center; + min-height: 86px; + color: var(--muted); + border: 1px dashed var(--line); + border-radius: 8px; + font-size: 13px; +} + +.settings-device-row { + min-width: 0; + display: grid; + grid-template-columns: 34px minmax(0, 1fr) 34px; + align-items: start; + gap: 9px; + padding: 10px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel); +} + +.settings-device-row.is-revoked { + opacity: 0.68; +} + +.settings-device-icon { + display: grid; + place-items: center; + width: 34px; + height: 34px; + border-radius: 8px; + color: #fff; + background: #1f2937; +} + +[data-theme="dark"] .settings-device-icon { + color: #171717; + background: #f4f4f5; +} + +.settings-device-main { + min-width: 0; + display: grid; + gap: 3px; +} + +.settings-device-main > div { + min-width: 0; + display: flex; + align-items: center; + gap: 7px; +} + +.settings-device-main strong, +.settings-device-main p, +.settings-device-main small { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.settings-device-main strong { + color: var(--text); + font-size: 13px; + line-height: 18px; +} + +.settings-device-main p, +.settings-device-main small { + margin: 0; + color: var(--muted); + font-size: 12px; + line-height: 17px; +} + +.settings-device-state { + flex: 0 0 auto; + padding: 2px 7px; + border-radius: 999px; + color: #174eb8; + background: rgba(23, 78, 184, 0.1); + font-size: 11px; + line-height: 15px; + font-weight: 700; +} + +.settings-device-state.is-current { + color: #0f6b3d; + background: rgba(15, 107, 61, 0.12); +} + +.settings-device-state.is-revoked { + color: var(--muted); + background: rgba(125, 125, 118, 0.12); +} + +.settings-device-row button { + width: 34px; + min-height: 34px; + padding: 0; + color: var(--danger); + background: rgba(194, 65, 58, 0.08); +} + .drawer-action, .project-row, .setting-row, diff --git a/client/src/styles/theme.css b/client/src/styles/theme.css index 80c6c1f..6e4e3ce 100644 --- a/client/src/styles/theme.css +++ b/client/src/styles/theme.css @@ -188,6 +188,14 @@ margin-bottom: 20px; } +.pairing-loading-dot { + width: 12px; + height: 12px; + border-radius: 50%; + background: #fff; + animation: pulse 900ms ease-in-out infinite; +} + .pairing-screen h1 { margin: 0; font-size: 30px; @@ -237,6 +245,24 @@ font-size: 14px; } +.pairing-request-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + height: 42px; + margin-bottom: 12px; + padding: 0 14px; + border: 1px solid var(--line); + border-radius: 10px; + color: var(--text); + background: var(--panel); +} + +.pairing-request-button:disabled { + opacity: 0.48; +} + .pairing-form { display: flex; gap: 10px; @@ -293,6 +319,18 @@ animation: spin 800ms linear infinite; } +@keyframes pulse { + 0%, 100% { + opacity: 0.35; + transform: scale(0.82); + } + + 50% { + opacity: 1; + transform: scale(1); + } +} + @keyframes spin { to { transform: rotate(360deg); diff --git a/docs/superpowers/plans/2026-05-08-desktop-ipc-state-sync.md b/docs/superpowers/plans/2026-05-08-desktop-ipc-state-sync.md index 6bfa162..74bec85 100644 --- a/docs/superpowers/plans/2026-05-08-desktop-ipc-state-sync.md +++ b/docs/superpowers/plans/2026-05-08-desktop-ipc-state-sync.md @@ -546,7 +546,7 @@ Start: CODEXMOBILE_HOME="$(mktemp -d /tmp/codexmobile-auth-XXXXXX)" \ PORT=3344 \ HTTPS_PORT=3454 \ -CODEXMOBILE_PAIRING_CODE=123456 \ +CODEXMOBILE_PAIRING_CODE_LENGTH=10 \ CODEXMOBILE_SYNC_RESPONSE_TIMEOUT_MS=20000 \ npm start ``` diff --git a/docs/superpowers/plans/2026-05-10-codexmobile-merge-security-fixes.md b/docs/superpowers/plans/2026-05-10-codexmobile-merge-security-fixes.md new file mode 100644 index 0000000..1d3305f --- /dev/null +++ b/docs/superpowers/plans/2026-05-10-codexmobile-merge-security-fixes.md @@ -0,0 +1,1292 @@ +# CodexMobile Merge Security Fixes Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Fix the remaining merge-blocking public-exposure security issues and document the lower-priority follow-up hardening tasks. + +**Architecture:** Keep the server as the single security boundary: allowed origins come only from configuration plus explicit localhost defaults, authentication uses `HttpOnly` cookies with `SameSite=Strict`, and public mode avoids exposing the HTTP listener on non-loopback when HTTPS is available. Tests should exercise the real HTTP/WS contracts where a pure unit test would miss the deployment behavior. + +**Tech Stack:** Node.js 22 built-in test runner, `ws`, Vite/React client, PowerShell startup scripts. + +--- + +## File Structure + +- `server/index.js`: remove Host-derived origin trust, add fixed local origin defaults, use static `securityOptions` for HTTP and WebSocket origin checks, and start HTTPS before deciding the HTTP bind address. +- `server/server-options.js`: expose the small pure HTTP bind decision helper used by `server/index.js`. +- `server/server-options.test.mjs`: assert public HTTPS mode binds HTTP to loopback only after HTTPS has actually started. +- `server/request-security.js`: change auth cookie SameSite policy to `Strict`; optionally extend `rejectSuspiciousFetchSite` for protected GET endpoints. +- `server/request-security.test.mjs`: assert `SameSite=Strict` and cross-site protected GET rejection behavior. +- `server/integration.test.mjs`: spawn a real temporary server for Host/Origin rejection and the Phase C WebSocket revocation contract test. +- `scripts/start-all.ps1`: rename parameter `$Host` to `$BindHost` to avoid colliding with PowerShell's built-in `$Host`. +- `client/src/api.js`: keep legacy localStorage cleanup only as an explicitly named migration cleanup helper. +- `server/auth.js`: split pairing code logging so the full pairing code only goes to direct stdout; Phase C adds rotation-race mitigation and atomic state writes. +- `server/auth.test.mjs`: add regression tests for pairing-code logging, token rotation race, and atomic writes. +- `server/upload-service.js`: Phase C adds minimal magic-byte sniffing and MIME normalization. +- `server/upload-service.test.mjs`: Phase C asserts mismatched upload MIME is downgraded to `application/octet-stream`. +- `README.md` / `.env.example`: update only if behavior or operator guidance changes. + +--- + +## Phase A: Merge-Blocking Fixes + +### Task 1: Remove Host-Origin Auto Allowlist (C1) + +**Files:** +- Modify: `server/index.js` +- Create: `server/integration.test.mjs` + +- [ ] **Step 1: Write the failing integration test** + +Create `server/integration.test.mjs` with this initial content: + +```js +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import fs from 'node:fs/promises'; +import http from 'node:http'; +import net from 'node:net'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT_DIR = path.resolve(__dirname, '..'); + +async function freePort() { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + const { port } = server.address(); + server.close(() => resolve(port)); + }); + }); +} + +function waitForOutput(lines, matcher, timeoutMs = 5000) { + return new Promise((resolve, reject) => { + const existing = lines.find((line) => matcher.test(line)); + if (existing) { + resolve(existing); + return; + } + const timer = setTimeout(() => { + reject(new Error(`Timed out waiting for ${matcher}`)); + }, timeoutMs); + lines.waiters.push((line) => { + if (matcher.test(line)) { + clearTimeout(timer); + resolve(line); + return true; + } + return false; + }); + }); +} + +function pushOutputLine(lines, line) { + lines.push(line); + lines.waiters = lines.waiters.filter((waiter) => !waiter(line)); +} + +async function startServer(t, extraEnv = {}) { + const port = await freePort(); + const httpsPort = await freePort(); + const home = await fs.mkdtemp(path.join(os.tmpdir(), 'codexmobile-integration-')); + const stdout = []; + stdout.waiters = []; + const stderr = []; + stderr.waiters = []; + const child = spawn(process.execPath, [ + 'server/index.js', + '--host', + '127.0.0.1', + '--port', + String(port), + '--https-port', + String(httpsPort) + ], { + cwd: ROOT_DIR, + env: { + ...process.env, + CODEXMOBILE_HOME: home, + CODEXMOBILE_PUBLIC_ACCESS: '0', + HTTPS_PFX_PATH: path.join(home, 'missing-server.pfx'), + ...extraEnv + }, + stdio: ['ignore', 'pipe', 'pipe'] + }); + + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', (chunk) => { + for (const line of String(chunk).split(/\r?\n/).filter(Boolean)) { + pushOutputLine(stdout, line); + } + }); + child.stderr.on('data', (chunk) => { + for (const line of String(chunk).split(/\r?\n/).filter(Boolean)) { + pushOutputLine(stderr, line); + } + }); + + t.after(() => { + child.kill(); + }); + + await waitForOutput(stdout, /CodexMobile listening on http:\/\/127\.0\.0\.1:/); + return { port, httpsPort, home, stdout, stderr, child }; +} + +function httpRequest({ port, method = 'GET', path: requestPath = '/', headers = {}, body = '' }) { + return new Promise((resolve, reject) => { + const req = http.request({ + host: '127.0.0.1', + port, + method, + path: requestPath, + headers: { + 'content-length': Buffer.byteLength(body), + ...headers + } + }, (res) => { + const chunks = []; + res.on('data', (chunk) => chunks.push(chunk)); + res.on('end', () => { + resolve({ + statusCode: res.statusCode, + headers: res.headers, + body: Buffer.concat(chunks).toString('utf8') + }); + }); + }); + req.once('error', reject); + req.end(body); + }); +} + +test('state-changing requests do not trust matching Host and Origin automatically', async (t) => { + const { port } = await startServer(t); + const response = await httpRequest({ + port, + method: 'POST', + path: '/api/chat/send', + headers: { + host: 'evil.com', + origin: 'https://evil.com', + 'content-type': 'application/json' + }, + body: '{}' + }); + + assert.equal(response.statusCode, 403); + assert.match(response.body, /Cross-origin request rejected/); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: + +```powershell +node --test server/integration.test.mjs +``` + +Expected: FAIL because the server currently derives `https://evil.com` from the request `Host` header and accepts the matching `Origin`. + +- [ ] **Step 3: Add fixed local allowed origins** + +In `server/index.js`, replace: + +```js +const securityOptions = readSecurityOptions(); +``` + +with: + +```js +function withLocalAllowedOrigins(options) { + return { + ...options, + allowedOrigins: [ + ...new Set([ + ...(options.allowedOrigins || []), + `http://127.0.0.1:${PORT}`, + `http://localhost:${PORT}`, + `https://127.0.0.1:${HTTPS_PORT}` + ]) + ] + }; +} + +const securityOptions = withLocalAllowedOrigins(readSecurityOptions()); +``` + +- [ ] **Step 4: Delete request-derived origin helpers** + +In `server/index.js`, delete these functions entirely: + +```js +function requestHostOrigin(req) { + const host = String(req.headers.host || '').trim(); + if (!host) { + return ''; + } + const protocol = isRequestTransportSecure(req, securityOptions) ? 'https' : 'http'; + return `${protocol}://${host}`; +} + +function requestSecurityOptions(req) { + const origin = requestHostOrigin(req); + return { + ...securityOptions, + allowedOrigins: [...new Set([...(securityOptions.allowedOrigins || []), origin].filter(Boolean))] + }; +} +``` + +- [ ] **Step 5: Use static security options for HTTP origin rejection** + +In `requestHandler`, replace: + +```js +const originRejection = rejectUnsafeOrigin(req, requestSecurityOptions(req)); +``` + +with: + +```js +const originRejection = rejectUnsafeOrigin(req, securityOptions); +``` + +- [ ] **Step 6: Use static security options for WebSocket origin rejection** + +Add `sameOriginAllowed` to the `server/security-options.js` import list in `server/index.js`: + +```js + requestMayUsePublicHttp, + sameOriginAllowed +} from './security-options.js'; +``` + +Then replace this check: + +```js +if (origin && !requestSecurityOptions(req).allowedOrigins.includes(origin)) { +``` + +with: + +```js +if (!sameOriginAllowed(origin, securityOptions)) { +``` + +- [ ] **Step 7: Run the integration test** + +Run: + +```powershell +node --test server/integration.test.mjs +``` + +Expected: PASS. + +- [ ] **Step 8: Commit** + +Run: + +```powershell +git add server/index.js server/integration.test.mjs +git commit -m "fix: stop trusting Host-derived origins" +``` + +--- + +### Task 2: Set Auth Cookie SameSite=Strict (C2) + +**Files:** +- Modify: `server/request-security.js` +- Modify: `server/request-security.test.mjs` + +- [ ] **Step 1: Update the cookie test first** + +In `server/request-security.test.mjs`, update the existing `buildAuthCookie` assertion so it expects `SameSite=Strict`: + +```js +test('buildAuthCookie sets browser security attributes', () => { + const cookie = buildAuthCookie('abc', { secure: true, maxAgeSeconds: 60 }); + assert.match(cookie, /codexmobile_token=abc/); + assert.match(cookie, /HttpOnly/); + assert.match(cookie, /SameSite=Strict/); + assert.match(cookie, /Secure/); + assert.match(cookie, /Max-Age=60/); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: + +```powershell +node --test server/request-security.test.mjs +``` + +Expected: FAIL because the cookie currently contains `SameSite=Lax`. + +- [ ] **Step 3: Change the cookie attribute** + +In `server/request-security.js`, replace: + +```js +'SameSite=Lax' +``` + +with: + +```js +'SameSite=Strict' +``` + +- [ ] **Step 4: Add optional protected GET fetch-site defense** + +In `server/request-security.js`, replace `rejectSuspiciousFetchSite` with: + +```js +export function rejectSuspiciousFetchSite(req, { protectSafeMethod = false } = {}) { + const method = String(req.method || 'GET').toUpperCase(); + if (['GET', 'HEAD', 'OPTIONS'].includes(method) && !protectSafeMethod) { + return null; + } + const fetchSite = String(req.headers['sec-fetch-site'] || '').toLowerCase(); + if (!fetchSite || fetchSite === 'same-origin' || fetchSite === 'none') { + return null; + } + return { statusCode: 403, error: 'Cross-site request rejected' }; +} +``` + +Then in `server/index.js`, replace: + +```js +const fetchSiteRejection = rejectSuspiciousFetchSite(req); +``` + +with: + +```js +const fetchSiteRejection = rejectSuspiciousFetchSite(req, { + protectSafeMethod: url.pathname.startsWith('/api/') +}); +``` + +This keeps normal top-level SPA/static navigation working while rejecting cross-site GET probes against API endpoints when browsers send `Sec-Fetch-Site: cross-site`. + +- [ ] **Step 5: Add the protected GET test** + +Append this test to `server/request-security.test.mjs`: + +```js +test('rejectSuspiciousFetchSite can protect API GET requests', () => { + const allowed = rejectSuspiciousFetchSite({ + method: 'GET', + headers: { 'sec-fetch-site': 'same-origin' } + }, { protectSafeMethod: true }); + assert.equal(allowed, null); + + const rejected = rejectSuspiciousFetchSite({ + method: 'GET', + headers: { 'sec-fetch-site': 'cross-site' } + }, { protectSafeMethod: true }); + assert.deepEqual(rejected, { statusCode: 403, error: 'Cross-site request rejected' }); +}); +``` + +- [ ] **Step 6: Run the security request tests** + +Run: + +```powershell +node --test server/request-security.test.mjs +``` + +Expected: PASS. + +- [ ] **Step 7: Commit** + +Run: + +```powershell +git add server/request-security.js server/request-security.test.mjs server/index.js +git commit -m "fix: use strict same-site auth cookies" +``` + +--- + +### Task 3: Bind HTTP to Loopback in Public HTTPS Mode (H1) + +**Files:** +- Modify: `server/index.js` +- Modify: `server/server-options.js` +- Create: `server/server-options.test.mjs` + +- [ ] **Step 1: Add the bind decision test** + +Create `server/server-options.test.mjs`: + +```js +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { resolveHttpListenHost } from './server-options.js'; + +test('resolveHttpListenHost binds public-mode HTTP to loopback only after HTTPS starts', () => { + assert.equal(resolveHttpListenHost({ + publicAccess: true, + httpsStarted: true, + host: '0.0.0.0' + }), '127.0.0.1'); + + assert.equal(resolveHttpListenHost({ + publicAccess: true, + httpsStarted: false, + host: '0.0.0.0' + }), '0.0.0.0'); + + assert.equal(resolveHttpListenHost({ + publicAccess: false, + httpsStarted: true, + host: '0.0.0.0' + }), '0.0.0.0'); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: + +```powershell +node --test server/server-options.test.mjs +``` + +Expected: FAIL because `resolveHttpListenHost` is not exported yet. + +- [ ] **Step 3: Add the pure bind helper** + +In `server/server-options.js`, add this before `serverOptionsHelp`: + +```js +export function resolveHttpListenHost({ publicAccess = false, httpsStarted = false, host = '0.0.0.0' } = {}) { + return publicAccess && httpsStarted ? '127.0.0.1' : host; +} +``` + +- [ ] **Step 4: Add a listen promise helper** + +In `server/index.js`, add this function before `main()`: + +```js +function listen(serverToStart, port, host) { + return new Promise((resolve, reject) => { + const onError = (error) => { + serverToStart.off('listening', onListening); + reject(error); + }; + const onListening = () => { + serverToStart.off('error', onError); + resolve(); + }; + serverToStart.once('error', onError); + serverToStart.once('listening', onListening); + serverToStart.listen(port, host); + }); +} +``` + +- [ ] **Step 5: Start HTTPS before deciding HTTP bind address** + +Add `resolveHttpListenHost` to the `server/server-options.js` import list in `server/index.js`: + +```js +import { readServerOptions, resolveHttpListenHost, serverOptionsHelp } from './server-options.js'; +``` + +In `main()`, replace the current `server.listen(...)` block and HTTPS `try/catch` block with: + +```js + let httpsStarted = false; + + try { + const pfx = await fs.readFile(HTTPS_PFX_PATH); + const httpsServer = https.createServer({ pfx, passphrase: HTTPS_PFX_PASSPHRASE }, requestHandler); + httpsServer.on('upgrade', handleUpgrade); + await listen(httpsServer, HTTPS_PORT, HOST); + httpsStarted = true; + console.log(`CodexMobile HTTPS listening on https://${HOST}:${HTTPS_PORT}`); + if (PUBLIC_URL) { + console.log(`Public/private URL: ${PUBLIC_URL}`); + } else { + console.log(`Use Tailscale HTTPS: https://..ts.net:${HTTPS_PORT}/`); + } + } catch (error) { + if (error.code === 'ENOENT') { + console.log(`CodexMobile HTTPS disabled: certificate not found at ${HTTPS_PFX_PATH}`); + } else { + console.warn(`[server] Failed to start HTTPS listener: ${error.message}`); + } + } + + const httpHost = resolveHttpListenHost({ + publicAccess: securityOptions.publicAccess, + httpsStarted, + host: HOST + }); + await listen(server, PORT, httpHost); + console.log(`CodexMobile listening on http://${httpHost}:${PORT}`); + console.log(`Pairing: open CodexMobile from the same LAN, then click "请求配对码" to print a one-time console code (${auth.trustedDevices} trusted device(s)).`); + console.log(`Use Tailscale and open http://:${PORT} on iPhone.`); +``` + +The important implementation detail is that `httpsStarted` is set only after `await listen(...)` resolves. Setting it inside an async `listen` callback and then immediately deciding the HTTP host would race. + +- [ ] **Step 6: Run tests** + +Run: + +```powershell +node --check server/index.js +node --test server/server-options.test.mjs +``` + +Expected: syntax check passes; bind decision tests pass. + +- [ ] **Step 7: Manual acceptance check** + +With a valid `HTTPS_PFX_PATH`: + +```powershell +$env:CODEXMOBILE_PUBLIC_ACCESS='1' +npm start -- --host 0.0.0.0 --port 3321 --https-port 3443 +curl.exe http://127.0.0.1:3321/api/status +``` + +Expected: local curl works. From another machine or public port scan, TCP/3321 is not reachable because HTTP is bound to `127.0.0.1`. + +- [ ] **Step 8: Commit** + +Run: + +```powershell +git add server/index.js server/server-options.js server/server-options.test.mjs +git commit -m "fix: bind public-mode http to loopback" +``` + +--- + +## Phase B: Same-Batch Small Fixes + +### Task 4: Rename start-all.ps1 Host Parameter (H3) + +**Files:** +- Modify: `scripts/start-all.ps1` + +- [ ] **Step 1: Rename the parameter** + +In `scripts/start-all.ps1`, replace: + +```powershell +[string]$Host +``` + +with: + +```powershell +[string]$BindHost +``` + +- [ ] **Step 2: Update all references** + +Replace: + +```powershell +if ($Host) { + $serverArgs += '--host' + $serverArgs += $Host +} +``` + +with: + +```powershell +if ($BindHost) { + $serverArgs += '--host' + $serverArgs += $BindHost +} +``` + +- [ ] **Step 3: Syntax-check the script** + +Run: + +```powershell +powershell.exe -NoProfile -Command "$null = [scriptblock]::Create((Get-Content -LiteralPath 'scripts/start-all.ps1' -Encoding UTF8 -Raw)); 'ok'" +``` + +Expected: prints `ok`. + +- [ ] **Step 4: Commit** + +Run: + +```powershell +git add scripts/start-all.ps1 +git commit -m "fix: avoid powershell host parameter collision" +``` + +--- + +### Task 5: Mark localStorage Token Cleanup as Legacy (L3) + +**Files:** +- Modify: `client/src/api.js` + +- [ ] **Step 1: Confirm current references** + +Run: + +```powershell +rg --encoding utf-8 -n 'clearToken|TOKEN_KEY|codexmobile.deviceToken' client +``` + +Expected: `clearToken` is still imported and called from `client/src/App.jsx`, so keep the cleanup helper. + +- [ ] **Step 2: Rename the constant and add a migration comment** + +In `client/src/api.js`, replace: + +```js +const TOKEN_KEY = 'codexmobile.deviceToken'; + +export function clearToken() { + localStorage.removeItem(TOKEN_KEY); +} +``` + +with: + +```js +const LEGACY_TOKEN_KEY = 'codexmobile.deviceToken'; + +export function clearToken() { + // Clear old pre-cookie localStorage auth state left by previous builds. + localStorage.removeItem(LEGACY_TOKEN_KEY); +} +``` + +- [ ] **Step 3: Run client build** + +Run: + +```powershell +npm.cmd run build +``` + +Expected: Vite build succeeds. + +- [ ] **Step 4: Commit** + +Run: + +```powershell +git add client/src/api.js client/dist +git commit -m "chore: mark legacy token cleanup" +``` + +--- + +### Task 6: Keep Pairing Code Out of File-Style Logs (M4) + +**Files:** +- Modify: `server/auth.js` +- Modify: `server/auth.test.mjs` + +- [ ] **Step 1: Add the logger regression test** + +Append this test to `server/auth.test.mjs`: + +```js +test('default pairing logger writes full code only to stdout', async () => { + const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'codexmobile-auth-log-')); + const stdoutWrites = []; + const consoleLogs = []; + const originalStdoutWrite = process.stdout.write; + const originalConsoleLog = console.log; + + process.stdout.write = function patchedStdoutWrite(chunk, encoding, callback) { + stdoutWrites.push(String(chunk)); + if (typeof encoding === 'function') { + encoding(); + } else if (typeof callback === 'function') { + callback(); + } + return true; + }; + console.log = (...args) => { + consoleLogs.push(args.map(String).join(' ')); + }; + + try { + const auth = createAuthController({ dataDir }); + await auth.initializeAuth(); + const result = await auth.startPairingRequest({ + deviceName: 'iPhone / WeChat', + userAgent: 'WeChat', + remoteAddress: '192.168.1.23', + securityOptions: readSecurityOptions() + }); + + assert.equal(result.ok, true); + assert.ok(stdoutWrites.some((line) => line.includes(`code=${result.code}`))); + assert.equal(consoleLogs.some((line) => line.includes(result.code)), false); + assert.ok(consoleLogs.some((line) => line.includes(`request=${result.requestId}`))); + } finally { + process.stdout.write = originalStdoutWrite; + console.log = originalConsoleLog; + } +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: + +```powershell +node --test server/auth.test.mjs +``` + +Expected: FAIL because the default logger currently writes the full pairing code through `console.log`. + +- [ ] **Step 3: Split the default logger** + +In `server/auth.js`, replace the `logPairingCode` default in `createAuthController`: + +```js +logPairingCode = (entry) => console.log(`[pairing] request=${entry.requestId} device=${entry.deviceName} remote=${entry.remoteAddress} code=${entry.code} expiresAt=${entry.expiresAt}`) +``` + +with: + +```js +logPairingCode = (entry) => { + process.stdout.write(`[pairing] code=${entry.code}\n`); + console.log(`[pairing] request=${entry.requestId} device=${entry.deviceName} remote=${entry.remoteAddress} expiresAt=${entry.expiresAt}`); +} +``` + +- [ ] **Step 4: Run auth tests** + +Run: + +```powershell +node --test server/auth.test.mjs +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +Run: + +```powershell +git add server/auth.js server/auth.test.mjs +git commit -m "fix: keep pairing codes out of structured logs" +``` + +--- + +## Phase C: Follow-Up PR Tasks + +### Task 7: Avoid Token Rotation Stampedes (M1) + +**Files:** +- Modify: `server/auth.js` +- Modify: `server/auth.test.mjs` + +- [ ] **Step 1: Add a rotation reuse test** + +Append this test to `server/auth.test.mjs`: + +```js +test('verifyToken reuses a very recent replacement token instead of rotating twice', async () => { + const t = await tempAuth(); + const paired = await t.auth.completePairingRequest(await (async () => { + const requested = await t.auth.startPairingRequest({ + deviceName: 'iPhone / WeChat', + userAgent: 'WeChat', + remoteAddress: '192.168.1.23', + securityOptions: t.security() + }); + return { + requestId: requested.requestId, + code: requested.code, + remoteAddress: '192.168.1.23', + securityOptions: t.security() + }; + })()); + + t.advance(46 * 24 * 60 * 60 * 1000); + const first = await t.auth.verifyToken(paired.token, { + remoteAddress: '192.168.1.23', + userAgent: 'WeChat', + securityOptions: t.security() + }); + const second = await t.auth.verifyToken(paired.token, { + remoteAddress: '192.168.1.23', + userAgent: 'WeChat', + securityOptions: t.security() + }); + + assert.equal(first.ok, true); + assert.ok(first.replacementToken); + assert.equal(second.ok, true); + assert.equal(second.replacementToken, null); + assert.equal(second.tokenHash, first.tokenHash); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: + +```powershell +node --test server/auth.test.mjs +``` + +Expected: FAIL because the second old-token verification currently returns the old token hash or rotates independently. + +- [ ] **Step 3: Add recent replacement reuse** + +In `server/auth.js`, inside `verifyToken`, immediately before the current rotation `if`, add: + +```js + const recentReplacement = deviceTokenRecords(device).find((record) => { + if (record.hash === tokenHash || record.supersededAt) { + return false; + } + const replacementCreatedMs = Date.parse(record.createdAt || ''); + return Number.isFinite(replacementCreatedMs) && nowMs - replacementCreatedMs < 1000; + }); + if (rotate && tokenRecord.supersededAt && recentReplacement) { + activeTokenHash = recentReplacement.hash; + } +``` + +Then change the rotation condition from: + +```js +if (rotate && !tokenRecord.supersededAt && ageMs > securityOptions.tokenTtlMs / 2) { +``` + +to: + +```js +if (rotate && !recentReplacement && !tokenRecord.supersededAt && ageMs > securityOptions.tokenTtlMs / 2) { +``` + +- [ ] **Step 4: Run auth tests** + +Run: + +```powershell +node --test server/auth.test.mjs +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +Run: + +```powershell +git add server/auth.js server/auth.test.mjs +git commit -m "fix: avoid duplicate token rotation" +``` + +--- + +### Task 8: Normalize Uploaded MIME by Magic Bytes (M3) + +**Files:** +- Modify: `server/upload-service.js` +- Modify: `server/upload-service.test.mjs` + +- [ ] **Step 1: Add MIME mismatch tests** + +Append this test to `server/upload-service.test.mjs`: + +```js +test('parseMultipartFile downgrades mismatched file mime type', () => { + const boundary = 'codex-boundary'; + const body = Buffer.concat([ + Buffer.from(`--${boundary}\r\n`), + Buffer.from('content-disposition: form-data; name="file"; filename="fake.png"\r\n'), + Buffer.from('content-type: image/png\r\n\r\n'), + Buffer.from('%PDF-1.7\n'), + Buffer.from(`\r\n--${boundary}--\r\n`) + ]); + + const file = parseMultipartFile(body, `multipart/form-data; boundary=${boundary}`); + assert.equal(file.mimeType, 'application/octet-stream'); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: + +```powershell +node --test server/upload-service.test.mjs +``` + +Expected: FAIL because the declared MIME type is currently trusted. + +- [ ] **Step 3: Add minimal sniffing helpers** + +In `server/upload-service.js`, add these functions after `classifyUpload`: + +```js +export function sniffMimeType(data) { + const bytes = Buffer.isBuffer(data) ? data : Buffer.from(data || []); + if (bytes.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) { + return 'image/png'; + } + if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) { + return 'image/jpeg'; + } + if (bytes.subarray(0, 6).toString('ascii') === 'GIF87a' || bytes.subarray(0, 6).toString('ascii') === 'GIF89a') { + return 'image/gif'; + } + if (bytes.subarray(0, 4).toString('ascii') === '%PDF') { + return 'application/pdf'; + } + if (bytes.subarray(0, 4).toString('ascii') === 'RIFF' && bytes.subarray(8, 12).toString('ascii') === 'WEBP') { + return 'image/webp'; + } + if (bytes.subarray(4, 8).toString('ascii') === 'ftyp') { + return 'video/mp4'; + } + return ''; +} + +export function normalizeUploadMimeType(declaredMimeType, data) { + const declared = String(declaredMimeType || 'application/octet-stream').toLowerCase(); + const sniffed = sniffMimeType(data); + if (!sniffed || declared === sniffed) { + return declared; + } + return 'application/octet-stream'; +} +``` + +- [ ] **Step 4: Use the normalized MIME** + +In `parseMultipartFile`, replace: + +```js +return { + fileName: sanitizeFileName(fileName), + mimeType, + data: buffer.slice(headerEnd + 4, contentEnd) +}; +``` + +with: + +```js +const data = buffer.slice(headerEnd + 4, contentEnd); +return { + fileName: sanitizeFileName(fileName), + mimeType: normalizeUploadMimeType(mimeType, data), + data +}; +``` + +- [ ] **Step 5: Run upload tests** + +Run: + +```powershell +node --test server/upload-service.test.mjs +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +Run: + +```powershell +git add server/upload-service.js server/upload-service.test.mjs +git commit -m "fix: normalize uploaded file mime types" +``` + +--- + +### Task 9: Make Auth State Writes Atomic (L2) + +**Files:** +- Modify: `server/auth.js` +- Modify: `server/auth.test.mjs` + +- [ ] **Step 1: Add a state write assertion** + +Append this test to `server/auth.test.mjs`: + +```js +test('auth state writes leave a valid json state file', async () => { + const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'codexmobile-auth-atomic-')); + const auth = createAuthController({ dataDir }); + await auth.initializeAuth(); + const files = await fs.readdir(dataDir); + assert.ok(files.includes('auth-state.json')); + assert.equal(files.includes('auth-state.json.tmp'), false); + const parsed = JSON.parse(await fs.readFile(path.join(dataDir, 'auth-state.json'), 'utf8')); + assert.deepEqual(parsed, { devices: [] }); +}); +``` + +- [ ] **Step 2: Implement atomic rename** + +In `server/auth.js`, replace `writeState()` with: + +```js + async function writeState() { + await ensurePrivateStatePath(dataDir); + const tmpFile = `${stateFile}.tmp`; + await fs.writeFile(tmpFile, JSON.stringify(authState, null, 2), { encoding: 'utf8', mode: 0o600 }); + if (process.platform !== 'win32') { + await fs.chmod(tmpFile, 0o600).catch(() => {}); + } + await fs.rename(tmpFile, stateFile); + if (process.platform !== 'win32') { + await fs.chmod(stateFile, 0o600).catch(() => {}); + } + } +``` + +- [ ] **Step 3: Run auth tests** + +Run: + +```powershell +node --test server/auth.test.mjs +``` + +Expected: PASS. + +- [ ] **Step 4: Commit** + +Run: + +```powershell +git add server/auth.js server/auth.test.mjs +git commit -m "fix: write auth state atomically" +``` + +--- + +### Task 10: Add End-to-End G1/G2 Contract Test + +**Files:** +- Modify: `server/integration.test.mjs` + +- [ ] **Step 1: Add WebSocket dependency imports** + +At the top of `server/integration.test.mjs`, add: + +```js +import WebSocket from 'ws'; +``` + +- [ ] **Step 2: Add JSON helper and pairing-code waiter** + +Append these helpers: + +```js +async function jsonRequest({ port, method = 'GET', path: requestPath, headers = {}, body = {} }) { + const payload = JSON.stringify(body); + const response = await httpRequest({ + port, + method, + path: requestPath, + headers: { + 'content-type': 'application/json', + ...headers + }, + body: payload + }); + return { + ...response, + json: response.body ? JSON.parse(response.body) : {} + }; +} + +async function waitForPairingCode(stdout) { + const line = await waitForOutput(stdout, /\[pairing\] code=/); + return line.match(/code=([A-Z2-9]+)/)?.[1] || ''; +} +``` + +- [ ] **Step 3: Add the WebSocket revoke test** + +Append this test: + +```js +test('websocket uses cookie auth and closes after current device revoke', async (t) => { + const { port, stdout } = await startServer(t); + const origin = `http://127.0.0.1:${port}`; + + const requested = await jsonRequest({ + port, + method: 'POST', + path: '/api/pair/request', + headers: { origin }, + body: { deviceName: 'iPhone / WeChat' } + }); + assert.equal(requested.statusCode, 200); + + const code = await waitForPairingCode(stdout); + assert.match(code, /^[A-Z2-9]+$/); + + const paired = await jsonRequest({ + port, + method: 'POST', + path: '/api/pair', + headers: { origin }, + body: { requestId: requested.json.requestId, code } + }); + assert.equal(paired.statusCode, 200); + const cookie = paired.headers['set-cookie']?.[0] || ''; + assert.match(cookie, /codexmobile_token=/); + + const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`, { + headers: { cookie, origin } + }); + t.after(() => ws.close()); + await new Promise((resolve, reject) => { + ws.once('open', resolve); + ws.once('error', reject); + }); + + const devices = await jsonRequest({ + port, + method: 'GET', + path: '/api/devices', + headers: { cookie } + }); + const current = devices.json.devices.find((device) => device.current); + assert.ok(current); + + const closed = new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('WebSocket was not closed after revoke')), 1000); + ws.once('close', (codeValue, reason) => { + clearTimeout(timer); + resolve({ code: codeValue, reason: reason.toString() }); + }); + }); + + const revoked = await jsonRequest({ + port, + method: 'POST', + path: `/api/devices/${encodeURIComponent(current.id)}/revoke`, + headers: { cookie, origin }, + body: {} + }); + assert.equal(revoked.statusCode, 200); + + const close = await closed; + assert.equal(close.code, 1008); + assert.equal(close.reason, 'revoked'); +}); +``` + +- [ ] **Step 4: Run integration tests** + +Run: + +```powershell +node --test server/integration.test.mjs +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +Run: + +```powershell +git add server/integration.test.mjs +git commit -m "test: cover websocket auth revocation" +``` + +--- + +## Final Verification + +- [ ] **Run full tests** + +```powershell +node --test +``` + +Expected: all tests pass. If sandbox blocks writes under `D:\tmp`, rerun externally because `server/chat-service.test.mjs` creates projectless temporary directories there. + +- [ ] **Run production build** + +```powershell +npm.cmd run build +``` + +Expected: Vite build succeeds and updates `client/dist`. + +- [ ] **Scan bundle for accidental secrets** + +```powershell +rg --encoding utf-8 -n -i 'sk-[a-z0-9_-]{16,}|api[_-]?key|secret[_-]?(key|token|value)' client/dist +``` + +Expected: exit code 1 with no matches. + +- [ ] **Scan for removed token-in-URL patterns** + +```powershell +rg --encoding utf-8 -n '\?token=|ws\?token|extractBearerToken|x-codexmobile-token' server client README.md .env.example +``` + +Expected: exit code 1 with no matches. + +- [ ] **Manual cookie check** + +Start the app, pair a device, and inspect the `/api/pair` response in DevTools. + +Expected: `Set-Cookie` contains `HttpOnly; SameSite=Strict`; on HTTPS it also contains `Secure`. + +- [ ] **Manual public-mode bind check** + +With a valid HTTPS PFX: + +```powershell +$env:CODEXMOBILE_PUBLIC_ACCESS='1' +npm start -- --host 0.0.0.0 --port 3321 --https-port 3443 +curl.exe http://127.0.0.1:3321/api/status +``` + +Expected: local HTTP status works. Public/router scan for TCP/3321 does not show an open listener. + +--- + +## Self-Review Notes + +- Spec coverage: C1 is covered by Task 1; C2 by Task 2; H1 by Task 3; H3 by Task 4; L3 by Task 5; M4 by Task 6; M1/M3/L2/G1+G2 follow-up items by Tasks 7-10. +- Scope split: Tasks 1-6 are the merge-before-main batch. Tasks 7-10 are intentionally grouped as follow-up PR work. +- Type consistency: new helper names are `withLocalAllowedOrigins`, `listen`, `rejectSuspiciousFetchSite(req, { protectSafeMethod })`, `LEGACY_TOKEN_KEY`, `sniffMimeType`, and `normalizeUploadMimeType`. diff --git a/docs/superpowers/plans/2026-05-10-codexmobile-public-security-hardening.md b/docs/superpowers/plans/2026-05-10-codexmobile-public-security-hardening.md new file mode 100644 index 0000000..d21e963 --- /dev/null +++ b/docs/superpowers/plans/2026-05-10-codexmobile-public-security-hardening.md @@ -0,0 +1,2367 @@ +# CodexMobile Public Security Hardening Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Harden CodexMobile for a real public-router-port-forward deployment while preserving the product shape: a mobile React shell around local Codex, with first device binding allowed only from the LAN. + +**Architecture:** CodexMobile is not an iframe around an official Codex page. It is a self-written React PWA in `client/src/App.jsx` served by `server/static-service.js`, backed by Node API routes and WebSocket upgrades in `server/index.js`, Codex session readers in `server/codex-data.js`, Desktop IPC/background execution in `server/chat-service.js` and `server/codex-runner.js`. The hardening adds explicit public-access policy, trusted-proxy CIDR handling, LAN-only client-initiated console-code pairing, cookie-only WebSocket authentication, active socket revocation, authenticated device management, server-side permission enforcement, upload/session/local-image access boundaries, and deployment smoke checks. + +**Tech Stack:** Node.js ESM HTTP/HTTPS server, React 18 + Vite PWA, Node built-in test runner, local JSON state under `.codexmobile/state`. + +--- + +## Product And Security Constraints + +- Deployment target: local Windows machine, exposed through a public router port forward. +- Primary remote client: WeChat built-in browser opening the public URL. +- Default unauthenticated behavior: show only an authorization/binding gate; do not expose projects, sessions, active run ids, local file paths, or high-risk capability flags beyond coarse safe status. +- Binding behavior: unauthenticated client starts a binding request; if and only if the request comes from LAN/private IP, the server prints a short-lived high-entropy code to the local console; the user reads the console code and types it on the phone. +- Computer-side interaction: no local Web UI confirmation button is required. Physical console visibility is the local-presence check. +- Public binding: disabled by default. `CODEXMOBILE_ALLOW_REMOTE_PAIRING=1` is an explicit emergency override and still keeps rate limits and short TTL. +- Public transport: non-private remote access must use HTTPS directly or a trusted HTTPS reverse proxy. +- Trusted proxy handling: never blindly trust `X-Forwarded-*`; only accept forwarded headers when `req.socket.remoteAddress` is in `CODEXMOBILE_TRUSTED_PROXIES`. +- WebSocket posture: use HttpOnly cookie auth for `/ws` and `/ws/realtime`, reject cross-origin upgrades, and actively close already-open sockets when a device is revoked or logs out. +- Codex shell: after authentication, default route remains the existing CodexMobile shell. Device management is a lightweight `/security` authenticated view or shield-panel entry, not a new product home page. +- Dangerous execution: client requests must never be able to enable `danger-full-access`; server env must opt in with `CODEXMOBILE_ENABLE_DANGER_FULL_ACCESS=1`. +- Unauthenticated status: response fields must be an explicit whitelist; do not expose model names, projects, cwd, sessions, desktop bridge details, active run identifiers, or local paths. +- State storage: `.codexmobile/state` must be owner-only where the platform supports it, and must not be placed in OneDrive/Dropbox/sync directories. +- Existing uncommitted port-argument work must be preserved. +- No PRD file was found; update `README.md` and `.env.example`. + +## Interaction Model + +```text +Unauthenticated public/WAN device + -> PairingGate: "首次绑定必须在同一局域网完成" + -> no /api/pair/request is created + +Unauthenticated LAN device + -> PairingGate waits for the user to click "请求配对码" + -> PairingGate then calls POST /api/pair/request + -> server prints console code once + -> phone enters code + -> POST /api/pair completes binding + -> HttpOnly auth cookie is set + -> enters existing Codex shell + +Authenticated device + -> / opens existing Codex shell + -> shield icon or /security opens a minimal security panel + -> user can view/revoke devices or logout current device +``` + +## File Structure + +- Create `server/security-options.js`: env parsing, LAN/private IP and private CIDR detection, trusted proxy CIDR handling, origin/public URL policy, HTTPS/public access checks. +- Create `server/security-options.test.mjs`: policy parsing, private IP/CGNAT/private CIDR, trusted proxy spoofing, origin, and transport tests. +- Create `server/request-security.js`: HSTS/CSP/security headers, cookie helpers, Origin/Sec-Fetch guard, auth cookie extraction helpers. +- Create `server/request-security.test.mjs`: cookie/header/origin tests. +- Modify `server/auth.js`: refactor into an injectable auth controller while keeping existing exported functions; add pending pairing requests, console-code generation, LAN-only binding, IP-level request rate limits, constant-time code comparison, token expiry/rotation, active WebSocket registry, device revoke/list, owner-only state writes. +- Create `server/auth.test.mjs`: pairing request, code completion, expiry, rate limit, token verify, revoke tests. +- Modify `server/index.js`: use security options, reject unsafe public HTTP, reduce public-mode HTTP listener exposure, add `/api/pair/request`, update `/api/pair`, add `/api/devices`, add `/api/logout`, add `/api/security/posture`, redact unauthenticated status by whitelist, store authenticated device on request, harden WebSocket upgrade. +- Create `client/src/pairing-flow.js`: pure helpers for pairing UI state and code normalization. +- Create `client/src/pairing-flow.test.mjs`: UI-state and code-normalization tests. +- Modify `client/src/App.jsx`: upgrade existing `PairingScreen`, add LAN/public unauthenticated states, add `/security` authenticated view and shield entry. +- Create `client/src/security-panel.js`: pure helpers for security panel route and device formatting. +- Create `client/src/security-panel.test.mjs`: route/format tests. +- Modify `client/src/api.js`: prefer HttpOnly cookie auth; keep localStorage bearer only as legacy fallback; add `credentials: 'same-origin'`. +- Create `server/permission-policy.js`: server-approved permission mapping. +- Create `server/permission-policy.test.mjs`: downgrade and opt-in tests. +- Modify `server/codex-runner.js`: use server-side permission policy for background Codex. +- Modify `server/chat-service.js`: default permissions to safe mode; use server-side policy for Desktop IPC/background paths; resolve attachments through whitelist. +- Modify `server/upload-service.js`: resolve and validate uploaded attachment references. +- Modify `server/upload-service.test.mjs`: reject arbitrary local paths and accept saved uploads only. +- Create `server/session-access.js`: readable-session and public-active-run helpers. +- Create `server/session-access.test.mjs`: hidden session and status-redaction tests. +- Create `server/status-policy.js`: explicit unauthenticated `/api/status` whitelist. +- Create `server/status-policy.test.mjs`: public status field-set tests. +- Modify `server/static-service.js`: restrict `/api/local-image` to approved generated/upload image roots. +- Modify `server/static-service.test.mjs`: reject arbitrary absolute images; accept generated/upload images. +- Modify `scripts/start-asr.mjs`: default Docker publish host to `127.0.0.1`. +- Create `scripts/start-asr.test.mjs`: Docker publish helper tests. +- Modify `scripts/smoke.mjs`: print security posture and fail unsafe public HTTP. +- Add bundle secret scan to verification: scan `client/dist` for obvious API key patterns after build. +- Modify `.env.example`: document public security env vars. +- Modify `README.md`: add public port-forward deployment and LAN-only binding checklist. + +--- + +## Task 1: Security Options And Public Access Policy + +**Files:** +- Create: `server/security-options.js` +- Create: `server/security-options.test.mjs` + +- [ ] **Step 1: Write the failing tests** + +Create `server/security-options.test.mjs`: + +```js +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + clientRemoteAddress, + envFlag, + isPrivateRemoteAddress, + isRequestTransportSecure, + isTrustedProxy, + readSecurityOptions, + sameOriginAllowed +} from './security-options.js'; + +test('envFlag only enables explicit true-like values', () => { + assert.equal(envFlag({ A: '1' }, 'A'), true); + assert.equal(envFlag({ A: 'true' }, 'A'), true); + assert.equal(envFlag({ A: 'yes' }, 'A'), true); + assert.equal(envFlag({ A: 'on' }, 'A'), true); + assert.equal(envFlag({ A: '0' }, 'A'), false); + assert.equal(envFlag({ A: 'false' }, 'A'), false); + assert.equal(envFlag({}, 'A'), false); +}); + +test('isPrivateRemoteAddress recognizes loopback and private networks', () => { + assert.equal(isPrivateRemoteAddress('127.0.0.1'), true); + assert.equal(isPrivateRemoteAddress('::1'), true); + assert.equal(isPrivateRemoteAddress('::ffff:192.168.1.20'), true); + assert.equal(isPrivateRemoteAddress('100.64.1.2'), true); + assert.equal(isPrivateRemoteAddress('100.127.255.254'), true); + assert.equal(isPrivateRemoteAddress('100.128.0.1'), false); + assert.equal(isPrivateRemoteAddress('10.12.0.8'), true); + assert.equal(isPrivateRemoteAddress('172.16.0.9'), true); + assert.equal(isPrivateRemoteAddress('172.31.255.9'), true); + assert.equal(isPrivateRemoteAddress('172.32.0.9'), false); + assert.equal(isPrivateRemoteAddress('203.0.113.9'), false); +}); + +test('CODEXMOBILE_PRIVATE_CIDRS extends LAN detection explicitly', () => { + const options = readSecurityOptions({ CODEXMOBILE_PRIVATE_CIDRS: '198.51.100.0/24' }); + assert.equal(isPrivateRemoteAddress('198.51.100.7', options), true); + assert.equal(isPrivateRemoteAddress('198.51.101.7', options), false); +}); + +test('readSecurityOptions defaults to safe private deployment values', () => { + const options = readSecurityOptions({}); + assert.equal(options.publicAccess, false); + assert.equal(options.allowRemotePairing, false); + assert.equal(options.dangerFullAccessEnabled, false); + assert.deepEqual(options.trustedProxyCidrs, []); + assert.equal(options.pairingCodeLength, 10); + assert.equal(options.pairingCodeTtlMs, 600000); + assert.deepEqual(options.allowedOrigins, []); +}); + +test('sameOriginAllowed accepts configured public URL and extra origins', () => { + const options = readSecurityOptions({ + CODEXMOBILE_PUBLIC_URL: 'https://codex.example.com/mobile', + CODEXMOBILE_ALLOWED_ORIGINS: 'https://extra.example.com' + }); + assert.equal(sameOriginAllowed('https://codex.example.com', options), true); + assert.equal(sameOriginAllowed('https://extra.example.com', options), true); + assert.equal(sameOriginAllowed('https://evil.example.com', options), false); +}); + +test('clientRemoteAddress ignores forwarded headers unless socket peer is a trusted proxy', () => { + const req = { + socket: { remoteAddress: '203.0.113.20' }, + headers: { 'x-forwarded-for': '192.168.1.8, 203.0.113.1' } + }; + assert.equal(clientRemoteAddress(req, readSecurityOptions({})), '203.0.113.20'); + assert.equal(clientRemoteAddress(req, readSecurityOptions({ CODEXMOBILE_TRUSTED_PROXIES: '127.0.0.1' })), '203.0.113.20'); + + const proxied = { + socket: { remoteAddress: '127.0.0.1' }, + headers: { 'x-forwarded-for': '198.51.100.22, 127.0.0.1' } + }; + assert.equal(isTrustedProxy('127.0.0.1', readSecurityOptions({ CODEXMOBILE_TRUSTED_PROXIES: '127.0.0.1' })), true); + assert.equal(clientRemoteAddress(proxied, readSecurityOptions({ CODEXMOBILE_TRUSTED_PROXIES: '127.0.0.1' })), '198.51.100.22'); +}); + +test('isRequestTransportSecure accepts forwarded https only from trusted proxies', () => { + assert.equal(isRequestTransportSecure({ socket: { encrypted: true }, headers: {} }, readSecurityOptions({})), true); + assert.equal( + isRequestTransportSecure({ socket: { remoteAddress: '203.0.113.20' }, headers: { 'x-forwarded-proto': 'https' } }, readSecurityOptions({ CODEXMOBILE_TRUSTED_PROXIES: '127.0.0.1' })), + false + ); + assert.equal( + isRequestTransportSecure( + { socket: { remoteAddress: '127.0.0.1' }, headers: { 'x-forwarded-proto': 'https' } }, + readSecurityOptions({ CODEXMOBILE_TRUSTED_PROXIES: '127.0.0.1' }) + ), + true + ); +}); +``` + +- [ ] **Step 2: Run the failing test** + +Run: + +```powershell +node --test server/security-options.test.mjs +``` + +Expected: fails because `server/security-options.js` does not exist. + +- [ ] **Step 3: Implement `server/security-options.js`** + +Create the module with this public contract: + +```js +import net from 'node:net'; + +export function envFlag(env, key) { + return ['1', 'true', 'yes', 'on'].includes(String(env[key] || '').trim().toLowerCase()); +} + +export function readIntEnv(env, key, fallback) { + const value = Number(env[key]); + return Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback; +} + +export function normalizeRemoteAddress(value) { + const raw = String(value || '').trim(); + return raw.startsWith('::ffff:') ? raw.slice(7) : raw; +} + +export function ipv4ToNumber(value) { + const parts = String(value || '').split('.').map(Number); + if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return null; + return ((parts[0] << 24) >>> 0) + (parts[1] << 16) + (parts[2] << 8) + parts[3]; +} + +export function cidrMatches(address, cidr) { + const [base, prefixText] = String(cidr || '').split('/'); + const prefix = Number(prefixText); + const addressNumber = ipv4ToNumber(normalizeRemoteAddress(address)); + const baseNumber = ipv4ToNumber(normalizeRemoteAddress(base)); + if (addressNumber === null || baseNumber === null || !Number.isInteger(prefix) || prefix < 0 || prefix > 32) return false; + const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0; + return (addressNumber & mask) === (baseNumber & mask); +} + +export function addressInCidrs(address, cidrs = []) { + return cidrs.some((cidr) => cidrMatches(address, cidr)); +} + +export function isPrivateRemoteAddress(value, options = {}) { + const address = normalizeRemoteAddress(value); + const lower = address.toLowerCase(); + if (addressInCidrs(address, options.privateCidrs || [])) return true; + if (address === 'localhost' || address === '127.0.0.1' || address === '::1') return true; + if (lower.startsWith('fe80:') || lower.startsWith('fc') || lower.startsWith('fd')) return true; + if (net.isIP(address) !== 4) return false; + const [a, b] = address.split('.').map(Number); + return a === 10 || + (a === 192 && b === 168) || + (a === 172 && b >= 16 && b <= 31) || + (a === 169 && b === 254) || + (a === 100 && b >= 64 && b <= 127); +} + +export function parseOrigins(value) { + return String(value || '') + .split(',') + .map((item) => item.trim()) + .filter(Boolean) + .map((item) => { + try { + return new URL(item).origin; + } catch { + return ''; + } + }) + .filter(Boolean); +} + +export function readSecurityOptions(env = process.env) { + const publicUrl = String(env.CODEXMOBILE_PUBLIC_URL || '').trim(); + const publicOrigin = publicUrl ? new URL(publicUrl).origin : ''; + const allowedOrigins = [...new Set([publicOrigin, ...parseOrigins(env.CODEXMOBILE_ALLOWED_ORIGINS)].filter(Boolean))]; + return { + publicAccess: envFlag(env, 'CODEXMOBILE_PUBLIC_ACCESS'), + publicUrl, + publicOrigin, + allowedOrigins, + trustedProxyCidrs: String(env.CODEXMOBILE_TRUSTED_PROXIES || '').split(',').map((item) => item.trim()).filter(Boolean), + privateCidrs: String(env.CODEXMOBILE_PRIVATE_CIDRS || '').split(',').map((item) => item.trim()).filter(Boolean), + allowRemotePairing: envFlag(env, 'CODEXMOBILE_ALLOW_REMOTE_PAIRING'), + dangerFullAccessEnabled: envFlag(env, 'CODEXMOBILE_ENABLE_DANGER_FULL_ACCESS'), + pairingCodeLength: readIntEnv(env, 'CODEXMOBILE_PAIRING_CODE_LENGTH', 10), + pairingCodeTtlMs: readIntEnv(env, 'CODEXMOBILE_PAIRING_CODE_TTL_MS', 10 * 60 * 1000), + pairingMaxFailures: readIntEnv(env, 'CODEXMOBILE_PAIRING_MAX_FAILURES', 5), + pairingWindowMs: readIntEnv(env, 'CODEXMOBILE_PAIRING_WINDOW_MS', 10 * 60 * 1000), + pairingLockMs: readIntEnv(env, 'CODEXMOBILE_PAIRING_LOCK_MS', 15 * 60 * 1000), + tokenTtlMs: readIntEnv(env, 'CODEXMOBILE_TOKEN_TTL_MS', 90 * 24 * 60 * 60 * 1000) + }; +} + +export function sameOriginAllowed(origin, options) { + const value = String(origin || '').trim(); + return !value || options.allowedOrigins.includes(value); +} + +export function clientRemoteAddress(req, options) { + if (isTrustedProxy(req.socket?.remoteAddress || '', options)) { + const forwardedFor = String(req.headers['x-forwarded-for'] || '').split(',')[0].trim(); + if (forwardedFor) return normalizeRemoteAddress(forwardedFor); + } + return normalizeRemoteAddress(req.socket?.remoteAddress || ''); +} + +export function isTrustedProxy(address, options) { + const normalized = normalizeRemoteAddress(address); + return options.trustedProxyCidrs?.some((cidr) => { + if (!cidr.includes('/')) return normalizeRemoteAddress(cidr) === normalized; + return cidrMatches(normalized, cidr); + }) || false; +} + +export function isRequestTransportSecure(req, options) { + if (req.socket?.encrypted) return true; + if (!isTrustedProxy(req.socket?.remoteAddress || '', options)) return false; + return String(req.headers['x-forwarded-proto'] || '').split(',')[0].trim().toLowerCase() === 'https'; +} + +export function requestMayUsePublicHttp(req, options) { + const remote = clientRemoteAddress(req, options); + return !options.publicAccess || isPrivateRemoteAddress(remote, options) || isRequestTransportSecure(req, options); +} +``` + +- [ ] **Step 4: Verify** + +Run: + +```powershell +node --test server/security-options.test.mjs +``` + +Expected: all tests pass. + +- [ ] **Step 5: Commit** + +```powershell +git add server/security-options.js server/security-options.test.mjs +git commit -m "security: add public access policy options" +``` + +--- + +## Task 2: Request Security, Cookies, And Origin Guard + +**Files:** +- Create: `server/request-security.js` +- Create: `server/request-security.test.mjs` +- Modify: `server/index.js` + +- [ ] **Step 1: Write request-security tests** + +Create `server/request-security.test.mjs`: + +```js +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + buildAuthCookie, + clearAuthCookie, + extractCookieToken, + parseCookies, + rejectSuspiciousFetchSite, + rejectUnsafeOrigin, + setSecurityHeaders +} from './request-security.js'; + +test('parseCookies parses multiple cookies', () => { + assert.deepEqual(parseCookies('a=1; codexmobile_token=abc.def; theme=dark'), { + a: '1', + codexmobile_token: 'abc.def', + theme: 'dark' + }); +}); + +test('buildAuthCookie sets browser security attributes', () => { + const cookie = buildAuthCookie('token-value', { secure: true, maxAgeSeconds: 60 }); + assert.match(cookie, /codexmobile_token=token-value/); + assert.match(cookie, /HttpOnly/); + assert.match(cookie, /Secure/); + assert.match(cookie, /SameSite=Lax/); + assert.match(cookie, /Max-Age=60/); +}); + +test('clearAuthCookie expires the cookie', () => { + assert.match(clearAuthCookie({ secure: false }), /Max-Age=0/); +}); + +test('extractCookieToken reads the auth cookie only', () => { + assert.equal(extractCookieToken({ headers: { cookie: 'x=1; codexmobile_token=abc' } }), 'abc'); +}); + +test('rejectUnsafeOrigin rejects cross-origin state changes', () => { + const result = rejectUnsafeOrigin({ + method: 'POST', + headers: { origin: 'https://evil.example.com' } + }, { + allowedOrigins: ['https://codex.example.com'] + }); + assert.equal(result.statusCode, 403); +}); + +test('rejectSuspiciousFetchSite blocks cross-site state changes', () => { + const result = rejectSuspiciousFetchSite({ + method: 'POST', + headers: { 'sec-fetch-site': 'cross-site' } + }); + assert.equal(result.statusCode, 403); +}); + +test('setSecurityHeaders sets CSP and HSTS on secure requests', () => { + const headers = {}; + const res = { setHeader: (key, value) => { headers[key.toLowerCase()] = value; } }; + setSecurityHeaders(res, { secure: true, cspReportOnly: false }); + assert.match(headers['strict-transport-security'], /max-age=15552000/); + assert.match(headers['content-security-policy'], /default-src 'self'/); + assert.match(headers['content-security-policy'], /frame-ancestors 'none'/); +}); + +test('setSecurityHeaders can run CSP in report-only mode', () => { + const headers = {}; + const res = { setHeader: (key, value) => { headers[key.toLowerCase()] = value; } }; + setSecurityHeaders(res, { secure: false, cspReportOnly: true }); + assert.equal(headers['strict-transport-security'], undefined); + assert.match(headers['content-security-policy-report-only'], /default-src 'self'/); +}); +``` + +- [ ] **Step 2: Run the failing tests** + +Run: + +```powershell +node --test server/request-security.test.mjs +``` + +Expected: fails because `server/request-security.js` does not exist. + +- [ ] **Step 3: Implement `server/request-security.js`** + +Create: + +```js +const AUTH_COOKIE = 'codexmobile_token'; + +export function parseCookies(header = '') { + const result = {}; + for (const part of String(header || '').split(';')) { + const index = part.indexOf('='); + if (index < 0) continue; + const key = part.slice(0, index).trim(); + const value = part.slice(index + 1).trim(); + if (key) result[key] = decodeURIComponent(value); + } + return result; +} + +export function extractCookieToken(req) { + return parseCookies(req.headers?.cookie || '')[AUTH_COOKIE] || ''; +} + +export function buildAuthCookie(token, { secure = false, maxAgeSeconds } = {}) { + const parts = [`${AUTH_COOKIE}=${encodeURIComponent(token)}`, 'Path=/', 'HttpOnly', 'SameSite=Lax']; + if (secure) parts.push('Secure'); + if (Number.isFinite(maxAgeSeconds)) parts.push(`Max-Age=${Math.max(0, Math.floor(maxAgeSeconds))}`); + return parts.join('; '); +} + +export function clearAuthCookie({ secure = false } = {}) { + return buildAuthCookie('', { secure, maxAgeSeconds: 0 }); +} + +export function contentSecurityPolicy() { + return [ + "default-src 'self'", + "script-src 'self' 'unsafe-inline'", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data: blob:", + "connect-src 'self' https: wss:", + "font-src 'self' data:", + "frame-ancestors 'none'", + "base-uri 'self'", + "form-action 'self'" + ].join('; '); +} + +export function setSecurityHeaders(res, { secure = false, cspReportOnly = false } = {}) { + res.setHeader('x-content-type-options', 'nosniff'); + res.setHeader('referrer-policy', 'no-referrer'); + res.setHeader('x-frame-options', 'DENY'); + res.setHeader(cspReportOnly ? 'content-security-policy-report-only' : 'content-security-policy', contentSecurityPolicy()); + res.setHeader('permissions-policy', 'camera=(), geolocation=(), microphone=(self)'); + if (secure) { + res.setHeader('strict-transport-security', 'max-age=15552000; includeSubDomains'); + } +} + +export function rejectUnsafeOrigin(req, options) { + const method = String(req.method || 'GET').toUpperCase(); + if (['GET', 'HEAD', 'OPTIONS'].includes(method)) return null; + const origin = String(req.headers.origin || '').trim(); + if (!origin || options.allowedOrigins.includes(origin)) return null; + return { statusCode: 403, error: 'Cross-origin request rejected' }; +} + +export function rejectSuspiciousFetchSite(req) { + const method = String(req.method || 'GET').toUpperCase(); + if (['GET', 'HEAD', 'OPTIONS'].includes(method)) return null; + const fetchSite = String(req.headers['sec-fetch-site'] || '').toLowerCase(); + if (!fetchSite || fetchSite === 'same-origin' || fetchSite === 'same-site' || fetchSite === 'none') return null; + return { statusCode: 403, error: 'Cross-site request rejected' }; +} +``` + +- [ ] **Step 4: Wire it into `server/index.js`** + +At the start of the request handler: + +```js +setSecurityHeaders(res, { + secure: isRequestTransportSecure(req, securityOptions), + cspReportOnly: process.env.CODEXMOBILE_CSP_REPORT_ONLY === '1' +}); +if (!requestMayUsePublicHttp(req, securityOptions)) { + sendJson(res, 403, { error: 'Public access requires HTTPS' }); + return; +} +const fetchSiteRejection = rejectSuspiciousFetchSite(req); +if (fetchSiteRejection) { + sendJson(res, fetchSiteRejection.statusCode, { error: fetchSiteRejection.error }); + return; +} +const originRejection = rejectUnsafeOrigin(req, securityOptions); +if (originRejection) { + sendJson(res, originRejection.statusCode, { error: originRejection.error }); + return; +} +``` + +Update token extraction so cookies are preferred and the old bearer header remains a migration fallback: + +```js +function requestToken(req) { + return extractCookieToken(req) || extractBearerToken(req); +} +``` + +- [ ] **Step 5: Verify** + +Run: + +```powershell +node --test server/request-security.test.mjs +node --check server/index.js +``` + +Expected: tests and syntax check pass. + +- [ ] **Step 6: Commit** + +```powershell +git add server/request-security.js server/request-security.test.mjs server/index.js +git commit -m "security: add request guards and auth cookies" +``` + +--- + +## Task 3: Auth Controller, Pairing Requests, And Device Tokens + +**Files:** +- Modify: `server/auth.js` +- Create: `server/auth.test.mjs` + +- [ ] **Step 1: Write auth controller tests** + +Create `server/auth.test.mjs`: + +```js +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { createAuthController } from './auth.js'; +import { readSecurityOptions } from './security-options.js'; + +async function tempAuth() { + const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'codexmobile-auth-')); + let nowMs = Date.parse('2026-05-10T00:00:00.000Z'); + const logs = []; + const auth = createAuthController({ + dataDir, + now: () => nowMs, + logPairingCode: (entry) => logs.push(entry) + }); + await auth.initializeAuth(); + return { + auth, + logs, + advance(ms) { + nowMs += ms; + }, + security(overrides = {}) { + return readSecurityOptions({ ...overrides }); + } + }; +} + +test('LAN pairing request creates one console-visible code and stores only a hash', async () => { + const t = await tempAuth(); + const result = await t.auth.startPairingRequest({ + deviceName: 'iPhone / WeChat', + userAgent: 'WeChat', + remoteAddress: '192.168.1.23', + securityOptions: t.security() + }); + assert.equal(result.ok, true); + assert.match(result.requestId, /^[0-9a-f-]{36}$/); + assert.match(result.code, /^[A-Z2-9]{10}$/); + assert.equal(t.logs.length, 1); + assert.equal(t.logs[0].code, result.code); + assert.equal(t.auth.getPendingPairingRequest(result.requestId).code, undefined); + assert.match(t.auth.getPendingPairingRequest(result.requestId).codeHash, /^[a-f0-9]{64}$/); +}); + +test('WAN pairing request is rejected by default', async () => { + const t = await tempAuth(); + const result = await t.auth.startPairingRequest({ + deviceName: 'Remote iPhone', + userAgent: 'WeChat', + remoteAddress: '203.0.113.9', + securityOptions: t.security() + }); + assert.equal(result.ok, false); + assert.equal(result.statusCode, 403); +}); + +test('pairing request creation is rate limited per remote before a code is printed', async () => { + const t = await tempAuth(); + for (let i = 0; i < 5; i += 1) { + const result = await t.auth.startPairingRequest({ + deviceName: `iPhone ${i}`, + userAgent: 'WeChat', + remoteAddress: '192.168.1.23', + securityOptions: t.security() + }); + assert.equal(result.ok, true); + } + const blocked = await t.auth.startPairingRequest({ + deviceName: 'iPhone overflow', + userAgent: 'WeChat', + remoteAddress: '192.168.1.23', + securityOptions: t.security() + }); + assert.equal(blocked.ok, false); + assert.equal(blocked.statusCode, 429); + assert.equal(t.logs.length, 5); +}); + +test('pairing completion requires same request, same remote, valid code, and unused request', async () => { + const t = await tempAuth(); + const request = await t.auth.startPairingRequest({ + deviceName: 'iPhone / WeChat', + userAgent: 'WeChat', + remoteAddress: '192.168.1.23', + securityOptions: t.security() + }); + const wrongRemote = await t.auth.completePairingRequest({ + requestId: request.requestId, + code: request.code, + remoteAddress: '192.168.1.24', + securityOptions: t.security() + }); + assert.equal(wrongRemote.ok, false); + assert.equal(wrongRemote.statusCode, 403); + + const paired = await t.auth.completePairingRequest({ + requestId: request.requestId, + code: request.code, + remoteAddress: '192.168.1.23', + securityOptions: t.security() + }); + assert.equal(paired.ok, true); + assert.match(paired.token, /^[A-Za-z0-9_-]+$/); + assert.equal(paired.device.name, 'iPhone / WeChat'); + + const reused = await t.auth.completePairingRequest({ + requestId: request.requestId, + code: request.code, + remoteAddress: '192.168.1.23', + securityOptions: t.security() + }); + assert.equal(reused.ok, false); + assert.equal(reused.statusCode, 404); +}); + +test('expired pairing request cannot complete', async () => { + const t = await tempAuth(); + const request = await t.auth.startPairingRequest({ + deviceName: 'iPhone', + userAgent: 'WeChat', + remoteAddress: '192.168.1.23', + securityOptions: t.security() + }); + t.advance(10 * 60 * 1000 + 1); + const result = await t.auth.completePairingRequest({ + requestId: request.requestId, + code: request.code, + remoteAddress: '192.168.1.23', + securityOptions: t.security() + }); + assert.equal(result.ok, false); + assert.equal(result.statusCode, 410); +}); + +test('wrong codes are rate limited', async () => { + const t = await tempAuth(); + const request = await t.auth.startPairingRequest({ + deviceName: 'iPhone', + userAgent: 'WeChat', + remoteAddress: '192.168.1.23', + securityOptions: t.security() + }); + for (let i = 0; i < 5; i += 1) { + const result = await t.auth.completePairingRequest({ + requestId: request.requestId, + code: 'AAAAAAAAAA', + remoteAddress: '192.168.1.23', + securityOptions: t.security() + }); + assert.equal(result.ok, false); + } + const locked = await t.auth.completePairingRequest({ + requestId: request.requestId, + code: request.code, + remoteAddress: '192.168.1.23', + securityOptions: t.security() + }); + assert.equal(locked.ok, false); + assert.equal(locked.statusCode, 429); +}); + +test('token verifies, expires, and can be revoked', async () => { + const t = await tempAuth(); + const request = await t.auth.startPairingRequest({ + deviceName: 'iPhone', + userAgent: 'WeChat', + remoteAddress: '192.168.1.23', + securityOptions: t.security() + }); + const paired = await t.auth.completePairingRequest({ + requestId: request.requestId, + code: request.code, + remoteAddress: '192.168.1.23', + securityOptions: t.security() + }); + const verified = await t.auth.verifyToken(paired.token, { + remoteAddress: '198.51.100.7', + userAgent: 'WeChat', + securityOptions: t.security() + }); + assert.equal(verified.ok, true); + assert.equal(verified.device.id, paired.device.id); + + const revoked = await t.auth.revokeDevice(paired.device.id); + assert.equal(revoked.ok, true); + const afterRevoke = await t.auth.verifyToken(paired.token, { + remoteAddress: '198.51.100.7', + userAgent: 'WeChat', + securityOptions: t.security() + }); + assert.equal(afterRevoke.ok, false); +}); + +test('verifyToken rotates old tokens after half of ttl has elapsed', async () => { + const t = await tempAuth(); + const request = await t.auth.startPairingRequest({ + deviceName: 'iPhone', + userAgent: 'WeChat', + remoteAddress: '192.168.1.23', + securityOptions: t.security() + }); + const paired = await t.auth.completePairingRequest({ + requestId: request.requestId, + code: request.code, + remoteAddress: '192.168.1.23', + securityOptions: t.security({ CODEXMOBILE_TOKEN_TTL_MS: String(100 * 1000) }) + }); + t.advance(51 * 1000); + const verified = await t.auth.verifyToken(paired.token, { + remoteAddress: '198.51.100.7', + userAgent: 'WeChat', + securityOptions: t.security({ CODEXMOBILE_TOKEN_TTL_MS: String(100 * 1000) }) + }); + assert.equal(verified.ok, true); + assert.match(verified.replacementToken, /^[A-Za-z0-9_-]+$/); + assert.notEqual(verified.replacementToken, paired.token); +}); +``` + +- [ ] **Step 2: Run failing tests** + +Run: + +```powershell +node --test server/auth.test.mjs +``` + +Expected: fails because `createAuthController`, `startPairingRequest`, and related methods do not exist. + +- [ ] **Step 3: Refactor `server/auth.js`** + +Implement `createAuthController({ dataDir, now, logPairingCode })` and keep existing top-level exports delegating to the default controller: + +```js +const DEFAULT_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; + +export function createAuthController({ + dataDir = DATA_DIR, + now = () => Date.now(), + logPairingCode = (entry) => console.log(`[pairing] request=${entry.requestId} device=${entry.deviceName} remote=${entry.remoteAddress} code=${entry.code} expiresAt=${entry.expiresAt}`) +} = {}) { + // Controller owns authState, pendingPairingRequests, request buckets, failure buckets, and active socket registrations. +} +``` + +Required methods: + +```js +initializeAuth() +startPairingRequest({ deviceName, userAgent, remoteAddress, securityOptions }) +completePairingRequest({ requestId, code, remoteAddress, securityOptions }) +verifyToken(token, { remoteAddress, userAgent, securityOptions }) +revokeDevice(deviceId) +revokeToken(token) +registerSocket(tokenHash, socket) +unregisterSocket(tokenHash, socket) +listDevices({ currentToken } = {}) +getTrustedDeviceCount() +getPendingPairingRequest(requestId) +``` + +Required state rules: + +- Store device tokens only as SHA-256 hashes. +- Store pending pairing codes only as SHA-256 hashes. +- Compare submitted pairing code hashes with `crypto.timingSafeEqual()`. +- Generate raw codes with `crypto.randomInt()` over `DEFAULT_CODE_ALPHABET`. +- Pending request contains `requestId`, `codeHash`, `deviceName`, `userAgent`, `remoteAddress`, `createdAt`, `expiresAt`, `failedAttempts`. +- `startPairingRequest()` uses `pairingRequestsByRemote: Map` before generating or printing a code. Default: at most 5 requests per 10 minutes, lock for 15 minutes. +- Completion requires same normalized remote address as request creation unless `CODEXMOBILE_ALLOW_REMOTE_PAIRING=1`. +- Successful completion deletes the pending request. +- Expired completion deletes the pending request and returns `{ ok: false, statusCode: 410, error: 'Pairing code expired' }`. +- Token devices contain `id`, `name`, `tokenHash`, `createdAt`, `expiresAt`, `revokedAt`, `supersededAt`, `userAgent`, `lastUserAgent`, `lastSeenAt`, `lastRemoteAddress`. +- `verifyToken()` returns `{ ok: true, device, tokenHash, replacementToken: null }` normally. +- If token age is greater than 50% of `tokenTtlMs`, `verifyToken()` creates a replacement token, appends its hash to the same device record, marks the old token hash `supersededAt`, and returns `{ ok: true, device, tokenHash: newHash, replacementToken }`. +- Old superseded token hashes remain accepted for only 5 minutes to avoid breaking concurrent requests, then verify false. +- `revokeDevice()` and `revokeToken()` close active sockets registered for the revoked token hashes with `socket.close(1008, 'revoked')`. +- State directory creation uses owner-only permissions where possible: POSIX `mode: 0o700` for directories and `mode: 0o600` for state files; Windows attempts `icacls /inheritance:r /grant:r :(OI)(CI)F` and logs a warning if unavailable. + +- [ ] **Step 4: Verify** + +Run: + +```powershell +node --test server/auth.test.mjs +node --check server/auth.js +``` + +Expected: tests and syntax check pass. + +- [ ] **Step 5: Commit** + +```powershell +git add server/auth.js server/auth.test.mjs +git commit -m "security: add lan-only pairing auth controller" +``` + +--- + +## Task 4: Pairing API And Binding Page + +**Files:** +- Create: `client/src/pairing-flow.js` +- Create: `client/src/pairing-flow.test.mjs` +- Modify: `server/index.js` +- Modify: `client/src/App.jsx` + +- [ ] **Step 1: Write pairing UI helper tests** + +Create `client/src/pairing-flow.test.mjs`: + +```js +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + normalizePairingCode, + pairingGateState, + secondsUntilExpiry +} from './pairing-flow.js'; + +test('normalizePairingCode uppercases and removes separators', () => { + assert.equal(normalizePairingCode(' k7m4-q9 xr2p '), 'K7M4Q9XR2P'); + assert.equal(normalizePairingCode('abc<>123'), 'ABC123'); +}); + +test('pairingGateState blocks non-LAN unauthenticated devices', () => { + assert.equal(pairingGateState({ + authenticated: false, + canPair: false, + pendingRequest: null, + error: '' + }), 'remote-blocked'); +}); + +test('pairingGateState shows code entry when LAN request exists', () => { + assert.equal(pairingGateState({ + authenticated: false, + canPair: true, + pendingRequest: { requestId: 'r1', expiresAt: '2026-05-10T00:10:00.000Z' }, + error: '' + }), 'enter-code'); +}); + +test('secondsUntilExpiry never returns negative values', () => { + assert.equal(secondsUntilExpiry('2026-05-10T00:00:05.000Z', Date.parse('2026-05-10T00:00:00.000Z')), 5); + assert.equal(secondsUntilExpiry('2026-05-10T00:00:00.000Z', Date.parse('2026-05-10T00:00:05.000Z')), 0); +}); +``` + +- [ ] **Step 2: Run failing tests** + +Run: + +```powershell +node --test client/src/pairing-flow.test.mjs +``` + +Expected: fails because `client/src/pairing-flow.js` does not exist. + +- [ ] **Step 3: Implement pairing helpers** + +Create `client/src/pairing-flow.js`: + +```js +export function normalizePairingCode(value) { + return String(value || '').toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 16); +} + +export function secondsUntilExpiry(expiresAt, nowMs = Date.now()) { + const expiryMs = Date.parse(expiresAt || ''); + if (!Number.isFinite(expiryMs)) return 0; + return Math.max(0, Math.ceil((expiryMs - nowMs) / 1000)); +} + +export function pairingGateState({ authenticated, canPair, pendingRequest, error }) { + if (authenticated) return 'authenticated'; + if (error) return 'error'; + if (!canPair) return 'remote-blocked'; + if (pendingRequest?.requestId) return 'enter-code'; + return 'requesting'; +} +``` + +- [ ] **Step 4: Add API routes to `server/index.js`** + +In unauthenticated route handling, add `POST /api/pair/request` before `requireAuth`: + +```js +if (method === 'POST' && pathname === '/api/pair/request') { + const body = await readBody(req); + const requested = await startPairingRequest({ + deviceName: body.deviceName || 'iPhone', + userAgent: req.headers['user-agent'], + remoteAddress: remoteAddress(req), + securityOptions + }); + if (!requested.ok) { + sendJson(res, requested.statusCode, { error: requested.error, retryAfterSeconds: requested.retryAfterSeconds || null }); + return; + } + sendJson(res, 200, { + requestId: requested.requestId, + expiresAt: requested.expiresAt, + codeLength: requested.codeLength + }); + return; +} +``` + +Update `POST /api/pair`: + +```js +const paired = await completePairingRequest({ + requestId: body.requestId, + code: body.code, + remoteAddress: remoteAddress(req), + securityOptions +}); +if (!paired.ok) { + sendJson(res, paired.statusCode, { error: paired.error, retryAfterSeconds: paired.retryAfterSeconds || null }); + return; +} +res.setHeader('set-cookie', buildAuthCookie(paired.token, { + secure: isRequestTransportSecure(req, securityOptions), + maxAgeSeconds: Math.floor(securityOptions.tokenTtlMs / 1000) +})); +sendJson(res, 200, { device: paired.device, token: paired.token }); +``` + +Expose unauthenticated safe pairing status from `/api/status`: + +```js +auth: { + required: true, + authenticated, + trustedDevices: authenticated ? getTrustedDeviceCount() : 0, + canPair: isPrivateRemoteAddress(remoteAddress(req)) || securityOptions.allowRemotePairing +} +``` + +- [ ] **Step 5: Replace current `PairingScreen` behavior in `client/src/App.jsx`** + +Keep `PairingScreen` as the unauthenticated gate. Change it to: + +- Load `/api/status` from the parent `bootstrap` as today. +- If `status.auth?.canPair` is false, show: + +```text +不能在外网完成绑定 +首次绑定必须在电脑所在的同一 Wi-Fi / 局域网内完成。 +已绑定设备仍可从外网访问。 +``` + +- If `canPair` is true, show a "请求配对码" button. Only call `/api/pair/request` after the user clicks it: + +```js +const result = await apiFetch('/api/pair/request', { + method: 'POST', + body: { deviceName: navigator.platform || 'iPhone' } +}); +setPendingRequest(result); +``` + +- Show: + +```text +请查看电脑端 CodexMobile 控制台输出的配对码。 +设备:iPhone / WeChat +有效期:09:58 +控制台配对码 +[__________] +``` + +- Submit: + +```js +await apiFetch('/api/pair', { + method: 'POST', + body: { + requestId: pendingRequest.requestId, + code + } +}); +``` + +- After success, call `onPaired()` and let the app enter the existing Codex shell. + +- [ ] **Step 6: Verify** + +Run: + +```powershell +node --test client/src/pairing-flow.test.mjs server/auth.test.mjs server/security-options.test.mjs server/request-security.test.mjs +npm.cmd run build +``` + +Expected: tests pass and the React build succeeds. + +- [ ] **Step 7: Commit** + +```powershell +git add client/src/pairing-flow.js client/src/pairing-flow.test.mjs server/index.js client/src/App.jsx +git commit -m "security: add console-code binding flow" +``` + +--- + +## Task 5: Device Management API And Authenticated Security Panel + +**Files:** +- Create: `client/src/security-panel.js` +- Create: `client/src/security-panel.test.mjs` +- Modify: `server/index.js` +- Modify: `client/src/App.jsx` +- Modify: `client/src/api.js` + +- [ ] **Step 1: Write security panel helper tests** + +Create `client/src/security-panel.test.mjs`: + +```js +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + formatDeviceLabel, + formatLastSeen, + isSecurityRoute, + securitySummaryRows +} from './security-panel.js'; + +test('isSecurityRoute recognizes only the security view', () => { + assert.equal(isSecurityRoute('/security'), true); + assert.equal(isSecurityRoute('/security/'), true); + assert.equal(isSecurityRoute('/'), false); +}); + +test('formatDeviceLabel prefers stored device name and falls back to user agent', () => { + assert.equal(formatDeviceLabel({ name: 'iPhone / WeChat', userAgent: 'Mozilla' }), 'iPhone / WeChat'); + assert.equal(formatDeviceLabel({ name: '', userAgent: 'Mobile Safari' }), 'Mobile Safari'); + assert.equal(formatDeviceLabel({}), '未知设备'); +}); + +test('formatLastSeen handles current and old devices', () => { + assert.equal(formatLastSeen('2026-05-10T00:00:00.000Z', Date.parse('2026-05-10T00:00:30.000Z')), '刚刚'); + assert.equal(formatLastSeen('2026-05-09T00:00:00.000Z', Date.parse('2026-05-10T00:00:00.000Z')), '1 天前'); +}); + +test('securitySummaryRows renders safe status labels', () => { + assert.deepEqual(securitySummaryRows({ + publicAccess: true, + pairing: { lanOnly: true }, + dangerFullAccessEnabled: false, + httpsEnabled: true + }), [ + ['公网访问', '已启用'], + ['首次绑定', '仅局域网'], + ['完全访问', '已关闭'], + ['HTTPS', '已启用'] + ]); +}); +``` + +- [ ] **Step 2: Run failing tests** + +Run: + +```powershell +node --test client/src/security-panel.test.mjs +``` + +Expected: fails because `client/src/security-panel.js` does not exist. + +- [ ] **Step 3: Implement security panel helpers** + +Create `client/src/security-panel.js`: + +```js +export function isSecurityRoute(pathname = window.location.pathname) { + return String(pathname || '').replace(/\/+$/, '') === '/security'; +} + +export function formatDeviceLabel(device = {}) { + return String(device.name || device.userAgent || '未知设备').trim() || '未知设备'; +} + +export function formatLastSeen(value, nowMs = Date.now()) { + const seenMs = Date.parse(value || ''); + if (!Number.isFinite(seenMs)) return '从未'; + const seconds = Math.max(0, Math.floor((nowMs - seenMs) / 1000)); + if (seconds < 60) return '刚刚'; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes} 分钟前`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours} 小时前`; + return `${Math.floor(hours / 24)} 天前`; +} + +export function securitySummaryRows(security = {}) { + return [ + ['公网访问', security.publicAccess ? '已启用' : '未启用'], + ['首次绑定', security.pairing?.lanOnly === false ? '允许远程' : '仅局域网'], + ['完全访问', security.dangerFullAccessEnabled ? '已启用' : '已关闭'], + ['HTTPS', security.httpsEnabled ? '已启用' : '未启用'] + ]; +} +``` + +- [ ] **Step 4: Add authenticated device routes in `server/index.js`** + +After `requireAuth`, add: + +```js +if (method === 'GET' && pathname === '/api/devices') { + sendJson(res, 200, { + currentDeviceId: req.auth.device.id, + devices: listDevices({ currentToken: requestToken(req) }), + security: publicSecurityStatus(req, true) + }); + return; +} + +if (method === 'POST' && pathname === '/api/logout') { + await revokeToken(requestToken(req)); + res.setHeader('set-cookie', clearAuthCookie({ secure: isRequestTransportSecure(req, securityOptions) })); + sendJson(res, 200, { success: true }); + return; +} + +if (method === 'DELETE' && parts.length === 3 && parts[0] === 'api' && parts[1] === 'devices') { + const result = await revokeDevice(decodeURIComponent(parts[2])); + if (!result.ok) { + sendJson(res, 404, { error: 'Device not found' }); + return; + } + if (result.deviceId === req.auth.device.id) { + res.setHeader('set-cookie', clearAuthCookie({ secure: isRequestTransportSecure(req, securityOptions) })); + } + sendJson(res, 200, { success: true }); + return; +} +``` + +Update authentication helpers so `requireAuth()` sets `req.auth = { device }` after `verifyToken()` returns `{ ok: true, device }`. + +- [ ] **Step 5: Add the authenticated security panel in `client/src/App.jsx`** + +Add a lightweight view, not a new home page: + +- `TopBar` receives `onOpenSecurity`. +- `TopBar` renders a shield icon button with `aria-label="安全与设备"`. +- Clicking it uses `window.history.pushState(null, '', '/security')` and sets route state. +- If authenticated and `isSecurityRoute(routePath)`, render `SecurityPanel` instead of the chat shell. +- `SecurityPanel` loads `/api/devices`, displays `securitySummaryRows(data.security)`, lists devices, and calls: + - `DELETE /api/devices/:id` for revoke. + - `POST /api/logout` for current-device logout. +- A back button returns to `/` and restores the existing Codex shell. + +Required text: + +```text +安全 +公网访问:已启用 / 未启用 +首次绑定:仅局域网 +完全访问:已关闭 / 已启用 +HTTPS:已启用 / 未启用 +已绑定设备 +当前设备 +退出登录 +撤销 +返回 Codex +``` + +- [ ] **Step 6: Prefer HttpOnly cookies in `client/src/api.js`** + +Change both `apiFetch()` and `apiBlobFetch()` to include credentials: + +```js +const response = await fetch(path, { + ...options, + credentials: 'same-origin', + headers, + body: + options.body && !(options.body instanceof FormData) && typeof options.body !== 'string' + ? JSON.stringify(options.body) + : options.body +}); +``` + +Keep existing `Authorization` bearer fallback only for HTTP requests when `localStorage` still has the legacy token. Update `websocketUrl()` and `realtimeVoiceWebsocketUrl()` so they never put a token in the URL; WebSocket authentication must use the HttpOnly cookie sent by the browser during upgrade: + +```js +return base; +``` + +- [ ] **Step 7: Verify** + +Run: + +```powershell +node --test client/src/security-panel.test.mjs server/auth.test.mjs +npm.cmd run build +``` + +Expected: tests pass and the React build succeeds. + +- [ ] **Step 8: Commit** + +```powershell +git add client/src/security-panel.js client/src/security-panel.test.mjs server/index.js client/src/App.jsx client/src/api.js +git commit -m "security: add authenticated device management" +``` + +--- + +## Task 6: Server-Side Permission Policy + +**Files:** +- Create: `server/permission-policy.js` +- Create: `server/permission-policy.test.mjs` +- Modify: `server/codex-runner.js` +- Modify: `server/chat-service.js` +- Modify: `server/index.js` +- Modify: `client/src/App.jsx` + +- [ ] **Step 1: Write permission policy tests** + +Create `server/permission-policy.test.mjs`: + +```js +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { resolvePermissionMode } from './permission-policy.js'; + +test('bypassPermissions is downgraded unless explicitly enabled', () => { + assert.deepEqual(resolvePermissionMode('bypassPermissions', { dangerFullAccessEnabled: false }), { + permissionMode: 'default', + sandboxMode: 'workspace-write', + approvalPolicy: 'never', + desktopSandboxPolicy: { type: 'workspaceWrite', networkAccess: false }, + downgraded: true + }); +}); + +test('bypassPermissions maps to danger-full-access only when enabled', () => { + assert.deepEqual(resolvePermissionMode('bypassPermissions', { dangerFullAccessEnabled: true }), { + permissionMode: 'bypassPermissions', + sandboxMode: 'danger-full-access', + approvalPolicy: 'never', + desktopSandboxPolicy: { type: 'dangerFullAccess' }, + downgraded: false + }); +}); + +test('default and acceptEdits remain workspace-write', () => { + assert.equal(resolvePermissionMode('', { dangerFullAccessEnabled: false }).permissionMode, 'default'); + assert.equal(resolvePermissionMode('acceptEdits', { dangerFullAccessEnabled: false }).sandboxMode, 'workspace-write'); +}); +``` + +- [ ] **Step 2: Run failing tests** + +Run: + +```powershell +node --test server/permission-policy.test.mjs +``` + +Expected: fails because module does not exist. + +- [ ] **Step 3: Implement policy and backend wiring** + +Create `server/permission-policy.js`: + +```js +export function resolvePermissionMode(permissionMode, securityOptions = {}) { + const requested = String(permissionMode || 'default'); + if (requested === 'bypassPermissions') { + if (securityOptions.dangerFullAccessEnabled) { + return { + permissionMode: 'bypassPermissions', + sandboxMode: 'danger-full-access', + approvalPolicy: 'never', + desktopSandboxPolicy: { type: 'dangerFullAccess' }, + downgraded: false + }; + } + return { + permissionMode: 'default', + sandboxMode: 'workspace-write', + approvalPolicy: 'never', + desktopSandboxPolicy: { type: 'workspaceWrite', networkAccess: false }, + downgraded: true + }; + } + return { + permissionMode: requested === 'acceptEdits' ? 'acceptEdits' : 'default', + sandboxMode: 'workspace-write', + approvalPolicy: 'never', + desktopSandboxPolicy: { type: 'workspaceWrite', networkAccess: false }, + downgraded: false + }; +} +``` + +Wire it: + +- `server/codex-runner.js`: replace local `mapPermissionMode()` with `resolvePermissionMode(permissionMode, securityOptions)`. +- `server/chat-service.js`: inject `securityOptions`, default `permissionMode` to `default`, and use `desktopSandboxPolicy` in `sendViaDesktopIpc()`. +- `server/index.js`: pass `securityOptions` into `createChatService()` and `runCodexTurn()`. + +- [ ] **Step 4: Update frontend permission UI** + +In `client/src/App.jsx`: + +- Set `const DEFAULT_PERMISSION_MODE = 'default';`. +- Hide or disable `完全访问` unless `status.security?.dangerFullAccessEnabled` is true. +- If the stored or current permission mode is `bypassPermissions` while disabled, reset to `default`. + +- [ ] **Step 5: Verify** + +Run: + +```powershell +node --test server/permission-policy.test.mjs server/chat-service.test.mjs server/codex-runner-status.test.mjs +npm.cmd run build +``` + +Expected: all tests and build pass. + +- [ ] **Step 6: Commit** + +```powershell +git add server/permission-policy.js server/permission-policy.test.mjs server/codex-runner.js server/chat-service.js server/index.js client/src/App.jsx +git commit -m "security: disable danger full access by default" +``` + +--- + +## Task 7: Uploaded Attachment Whitelist + +**Files:** +- Modify: `server/upload-service.js` +- Modify: `server/upload-service.test.mjs` +- Modify: `server/chat-service.js` +- Modify: `server/index.js` +- Modify: `server/codex-native-images.test.js` + +- [ ] **Step 1: Replace upload tests with whitelist tests** + +Update `server/upload-service.test.mjs` to include: + +```js +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import crypto from 'node:crypto'; +``` + +Add tests: + +```js +test('resolveUploadedAttachments rejects client supplied absolute paths outside upload root', async () => { + const uploadRoot = path.join(os.tmpdir(), 'codexmobile-uploads'); + await assert.rejects( + () => resolveUploadedAttachments([ + { id: 'not-uploaded', name: 'secret.txt', path: 'C:\\Users\\Ray\\.codex\\auth.json', kind: 'file' } + ], { uploadRoot }), + /Invalid attachment/ + ); +}); + +test('resolveUploadedAttachments accepts files saved below upload root with matching id prefix', async () => { + const uploadRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codexmobile-uploads-')); + const id = crypto.randomUUID(); + const dir = path.join(uploadRoot, '2026-05-10'); + const filePath = path.join(dir, `${id}-photo.png`); + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile(filePath, Buffer.from('image')); + + const [attachment] = await resolveUploadedAttachments([ + { id, name: 'photo.png', path: filePath, kind: 'image', mimeType: 'image/png' } + ], { uploadRoot }); + + assert.equal(attachment.path, filePath); + assert.equal(attachment.kind, 'image'); + assert.equal(attachment.size, 5); +}); +``` + +- [ ] **Step 2: Run failing tests** + +Run: + +```powershell +node --test server/upload-service.test.mjs +``` + +Expected: fails because `resolveUploadedAttachments` does not exist and old expectations still trust arbitrary paths. + +- [ ] **Step 3: Implement resolver** + +In `server/upload-service.js`, add: + +```js +export async function resolveUploadedAttachments(value, { uploadRoot }) { + const attachments = normalizeAttachments(value); + const root = path.resolve(uploadRoot); + const resolved = []; + for (const attachment of attachments) { + const filePath = path.resolve(String(attachment.path || '')); + const relative = path.relative(root, filePath); + const insideRoot = relative && !relative.startsWith('..') && !path.isAbsolute(relative); + const fileBase = path.basename(filePath); + const idMatchesName = attachment.id && fileBase.startsWith(`${attachment.id}-`); + if (!insideRoot || !idMatchesName) { + const error = new Error('Invalid attachment'); + error.statusCode = 400; + throw error; + } + const stat = await fs.stat(filePath); + if (!stat.isFile()) { + const error = new Error('Invalid attachment'); + error.statusCode = 400; + throw error; + } + resolved.push({ ...attachment, path: filePath, size: attachment.size || stat.size }); + } + return resolved; +} +``` + +- [ ] **Step 4: Wire chat service** + +In `server/index.js`, pass `uploadRoot: UPLOAD_ROOT` into `createChatService()`. In `server/chat-service.js`, replace: + +```js +const attachments = normalizeAttachments(body.attachments); +``` + +with: + +```js +const attachments = await resolveUploadedAttachments(body.attachments, { uploadRoot }); +``` + +- [ ] **Step 5: Verify** + +Run: + +```powershell +node --test server/upload-service.test.mjs server/chat-service.test.mjs server/codex-native-images.test.js +npm.cmd run build +``` + +Expected: arbitrary paths are rejected, uploaded files still work, and build succeeds. + +- [ ] **Step 6: Commit** + +```powershell +git add server/upload-service.js server/upload-service.test.mjs server/chat-service.js server/index.js server/codex-native-images.test.js +git commit -m "security: restrict attachments to uploaded files" +``` + +--- + +## Task 8: Session, Active Run, And Local Image Access Boundaries + +**Files:** +- Create: `server/session-access.js` +- Create: `server/session-access.test.mjs` +- Create: `server/status-policy.js` +- Create: `server/status-policy.test.mjs` +- Modify: `server/index.js` +- Modify: `server/static-service.js` +- Modify: `server/static-service.test.mjs` + +- [ ] **Step 1: Write session-access tests** + +Create `server/session-access.test.mjs`: + +```js +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { canReadSessionId, publicActiveRuns } from './session-access.js'; + +test('canReadSessionId allows visible listed sessions', () => { + assert.equal(canReadSessionId('s1', { + getSession: (id) => id === 's1' ? { id: 's1' } : null, + activeRuns: [] + }), true); +}); + +test('canReadSessionId rejects unknown hidden sessions', () => { + assert.equal(canReadSessionId('hidden', { + getSession: () => null, + activeRuns: [] + }), false); +}); + +test('canReadSessionId allows active mobile-created runs', () => { + assert.equal(canReadSessionId('draft-1', { + getSession: () => null, + activeRuns: [{ sessionId: 'draft-1' }] + }), true); +}); + +test('publicActiveRuns hides identifiers from unauthenticated status', () => { + assert.deepEqual(publicActiveRuns(false, [{ turnId: 't1', sessionId: 's1' }]), { count: 1, items: [] }); + assert.deepEqual(publicActiveRuns(true, [{ turnId: 't1', sessionId: 's1' }]), { count: 1, items: [{ turnId: 't1', sessionId: 's1' }] }); +}); +``` + +Create `server/status-policy.test.mjs`: + +```js +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { publicStatusForAuthState } from './status-policy.js'; + +test('unauthenticated status exposes only auth security and version', () => { + const status = publicStatusForAuthState(false, { + auth: { required: true, authenticated: false, canPair: false, trustedDevices: 4 }, + security: { publicAccess: true, dangerFullAccessEnabled: false }, + version: '0.1.0', + model: 'gpt-5.5', + projects: [{ id: 'secret' }], + activeRuns: [{ sessionId: 's1' }], + desktopBridge: { connected: true }, + cwd: 'D:\\Git\\secret' + }); + assert.deepEqual(Object.keys(status).sort(), ['auth', 'security', 'version']); + assert.equal(status.auth.trustedDevices, 0); + assert.equal(status.security.publicAccess, true); +}); + +test('authenticated status passes through full status object', () => { + const source = { auth: { authenticated: true }, model: 'gpt-5.5', activeRuns: [] }; + assert.deepEqual(publicStatusForAuthState(true, source), source); +}); +``` + +- [ ] **Step 2: Run failing session tests** + +Run: + +```powershell +node --test server/session-access.test.mjs server/status-policy.test.mjs +``` + +Expected: fails because `server/session-access.js` and `server/status-policy.js` do not exist. + +- [ ] **Step 3: Implement session-access module** + +Create `server/session-access.js`: + +```js +export function canReadSessionId(sessionId, { getSession, activeRuns = [] }) { + const id = String(sessionId || '').trim(); + if (!id) return false; + if (getSession(id)) return true; + return activeRuns.some((run) => run.sessionId === id || run.previousSessionId === id || run.draftSessionId === id); +} + +export function publicActiveRuns(authenticated, runs = []) { + const items = authenticated ? runs : []; + return { count: runs.length, items }; +} +``` + +- [ ] **Step 4: Implement public status whitelist** + +Create `server/status-policy.js`: + +```js +const PUBLIC_STATUS_FIELDS = ['auth', 'security', 'version']; + +export function publicStatusForAuthState(authenticated, status) { + if (authenticated) return status; + return { + auth: { + required: true, + authenticated: false, + trustedDevices: 0, + canPair: Boolean(status.auth?.canPair) + }, + security: status.security || {}, + version: status.version || '0.1.0' + }; +} + +export function publicStatusFields() { + return [...PUBLIC_STATUS_FIELDS]; +} +``` + +- [ ] **Step 5: Wire status and session checks** + +In `server/index.js`: + +- Build the full status object internally, then return `publicStatusForAuthState(authenticated, fullStatus)`. +- For unauthenticated `/api/status`, do not include `model`, `models`, `projects`, `sessions`, `cwd`, `desktopBridge`, `activeRuns`, `activeRunCount`, `skills`, provider config, local paths, or active turn ids. +- Before `readSessionMessages(sessionId, ...)`, call `canReadSessionId(sessionId, { getSession, activeRuns: [...getActiveRuns(), ...chatService.getActiveImageRuns()] })`. +- Before `hideSessionMessage(sessionId, messageId)`, apply the same check. +- Return `404` for rejected session ids. + +- [ ] **Step 6: Restrict `/api/local-image`** + +Update `server/static-service.js` so `sendLocalImage()` accepts roots: + +```js +export async function sendLocalImage(req, res, url, { + mimeTypes = DEFAULT_MIME_TYPES, + allowedRoots = [] +} = {}) { + const roots = allowedRoots.map((root) => path.resolve(root)); + function isAllowed(filePath) { + return roots.some((root) => { + const relative = path.relative(root, filePath); + return relative && !relative.startsWith('..') && !path.isAbsolute(relative); + }); + } +} +``` + +In `server/index.js`, call it with generated/upload roots only: + +```js +await staticService.sendLocalImage(req, res, url, { + allowedRoots: [GENERATED_ROOT, UPLOAD_ROOT] +}); +``` + +Update `server/static-service.test.mjs` with: + +```js +test('sendLocalImage rejects absolute image outside allowed roots', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'codexmobile-static-image-')); + const allowedRoot = path.join(root, 'allowed'); + const blockedRoot = path.join(root, 'blocked'); + const blockedImage = path.join(blockedRoot, 'secret.png'); + await fs.mkdir(allowedRoot, { recursive: true }); + await fs.mkdir(blockedRoot, { recursive: true }); + await fs.writeFile(blockedImage, Buffer.from([137, 80, 78, 71])); + + const service = createStaticService({ + clientDist: allowedRoot, + generatedRoot: allowedRoot, + httpsRootCaPath: path.join(root, 'root.cer') + }); + const response = res(); + await service.sendLocalImage( + req(), + response, + new URL(`http://local/api/local-image?path=${encodeURIComponent(blockedImage)}`), + { allowedRoots: [allowedRoot] } + ); + + assert.equal(response.statusCode, 403); +}); + +test('sendLocalImage serves images below allowed roots', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'codexmobile-static-image-')); + const allowedRoot = path.join(root, 'allowed'); + const image = path.join(allowedRoot, 'image.png'); + await fs.mkdir(allowedRoot, { recursive: true }); + await fs.writeFile(image, Buffer.from([137, 80, 78, 71])); + + const service = createStaticService({ + clientDist: allowedRoot, + generatedRoot: allowedRoot, + httpsRootCaPath: path.join(root, 'root.cer') + }); + const response = res(); + await service.sendLocalImage( + req(), + response, + new URL(`http://local/api/local-image?path=${encodeURIComponent(image)}`), + { allowedRoots: [allowedRoot] } + ); + + assert.equal(response.statusCode, 200); + assert.equal(response.headers['content-type'], 'image/png'); +}); +``` + +- [ ] **Step 7: Verify** + +Run: + +```powershell +node --test server/session-access.test.mjs server/status-policy.test.mjs server/static-service.test.mjs +node --check server/index.js server/static-service.js +``` + +Expected: tests and syntax checks pass. + +- [ ] **Step 8: Commit** + +```powershell +git add server/session-access.js server/session-access.test.mjs server/status-policy.js server/status-policy.test.mjs server/index.js server/static-service.js server/static-service.test.mjs +git commit -m "security: constrain session and local image access" +``` + +--- + +## Task 9: Local-Only ASR + +**Files:** +- Modify: `scripts/start-asr.mjs` +- Create: `scripts/start-asr.test.mjs` +- Modify: `.env.example` +- Modify: `README.md` + +- [ ] **Step 1: Write ASR publish tests** + +Create `scripts/start-asr.test.mjs`: + +```js +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { buildPublishArg } from './start-asr.mjs'; + +test('buildPublishArg defaults to localhost binding', () => { + assert.equal(buildPublishArg({ host: '127.0.0.1', port: '8000' }), '127.0.0.1:8000:8000'); +}); + +test('buildPublishArg keeps explicit exposure visible', () => { + assert.equal(buildPublishArg({ host: '0.0.0.0', port: '9000' }), '0.0.0.0:9000:8000'); +}); +``` + +- [ ] **Step 2: Run failing tests** + +Run: + +```powershell +node --test scripts/start-asr.test.mjs +``` + +Expected: fails because `buildPublishArg` is not exported. + +- [ ] **Step 3: Extract helper and change Docker args** + +In `scripts/start-asr.mjs`, export: + +```js +export function buildPublishArg({ host = '127.0.0.1', port = '8000' } = {}) { + return `${host}:${port}:8000`; +} +``` + +Set: + +```js +const host = process.env.CODEXMOBILE_ASR_HOST || '127.0.0.1'; +``` + +Use: + +```js +'--publish', +buildPublishArg({ host, port }), +``` + +- [ ] **Step 4: Document ASR binding** + +Add to `.env.example`: + +```dotenv +# Local SenseVoice ASR binds to localhost by default. +# Use 0.0.0.0 only if another trusted machine must call ASR directly. +# CODEXMOBILE_ASR_HOST=127.0.0.1 +``` + +Add to `README.md`: the public router should forward only the CodexMobile HTTPS/reverse-proxy port, never ASR, CLIProxyAPI, provider API, or model service ports. + +- [ ] **Step 5: Verify** + +Run: + +```powershell +node --test scripts/start-asr.test.mjs +node --check scripts/start-asr.mjs +``` + +Expected: tests and syntax check pass. + +- [ ] **Step 6: Commit** + +```powershell +git add scripts/start-asr.mjs scripts/start-asr.test.mjs .env.example README.md +git commit -m "security: bind local ASR to localhost by default" +``` + +--- + +## Task 10: Public Deployment Docs And Smoke Checks + +**Files:** +- Modify: `.env.example` +- Modify: `README.md` +- Modify: `server/index.js` +- Modify: `scripts/start-server.mjs` +- Modify: `scripts/start-all.ps1` +- Modify: `scripts/smoke.mjs` + +- [ ] **Step 1: Add public deployment env template** + +Add to `.env.example`: + +```dotenv +# Public exposure profile. Enable only when using HTTPS through CodexMobile or a trusted reverse proxy. +# CODEXMOBILE_PUBLIC_ACCESS=1 +# CODEXMOBILE_PUBLIC_URL=https://codex.example.com/ +# CODEXMOBILE_ALLOWED_ORIGINS=https://codex.example.com +# Trust forwarded headers only from these proxy IPs/CIDRs. Leave empty for direct router forwarding. +# CODEXMOBILE_TRUSTED_PROXIES=127.0.0.1 +# Optional extra private CIDRs, e.g. VPN ranges not covered by defaults. +# CODEXMOBILE_PRIVATE_CIDRS=100.64.0.0/10 +# CODEXMOBILE_ALLOW_REMOTE_PAIRING=0 +# CODEXMOBILE_ENABLE_DANGER_FULL_ACCESS=0 +# CODEXMOBILE_PAIRING_CODE_LENGTH=10 +# CODEXMOBILE_PAIRING_CODE_TTL_MS=600000 +# CODEXMOBILE_PAIRING_MAX_FAILURES=5 +# CODEXMOBILE_PAIRING_LOCK_MS=900000 +# CODEXMOBILE_TOKEN_TTL_MS=7776000000 +``` + +- [ ] **Step 2: Add README section** + +Add a section named `公网端口转发安全部署` with this checklist: + +```markdown +## 公网端口转发安全部署 + +CodexMobile 可以放在家用/办公路由器后面使用公网端口转发,但必须按下面边界部署: + +- 只转发 CodexMobile HTTPS 端口,或只转发可信反向代理的 HTTPS 端口。 +- 不要转发 ASR、CLIProxyAPI、OpenAI-compatible provider、模型服务、Docker 容器端口。 +- 首次绑定手机时,把手机连接到电脑所在的同一 Wi-Fi / 局域网。 +- 保持 `CODEXMOBILE_ALLOW_REMOTE_PAIRING=0`,外网未绑定设备只能看到绑定说明,不能创建配对请求。 +- 保持 `CODEXMOBILE_ENABLE_DANGER_FULL_ACCESS=0`,公网设备不能打开完全访问。 +- 如果使用反向代理,不要使用布尔型 trust proxy;只配置 `CODEXMOBILE_TRUSTED_PROXIES=<代理 IP 或 CIDR>`,并确保代理清洗外部传入的 `X-Forwarded-*`。 +- 公网模式下 HTTP 监听只用于本机健康检查;对外转发只使用 HTTPS。 +- 不要把 `.codexmobile/state` 放进 OneDrive、Dropbox、网盘同步目录或公开备份。 +- 手机丢失或微信环境不可信时,访问 `/security` 撤销对应设备。 +``` + +- [ ] **Step 3: Reduce public-mode HTTP listener exposure** + +In `server/index.js`, compute the HTTP listen host: + +```js +function httpListenHost() { + if (!securityOptions.publicAccess) return HOST; + return process.env.CODEXMOBILE_PUBLIC_HTTP_HOST || '127.0.0.1'; +} +``` + +Use it for the HTTP server: + +```js +const httpHost = httpListenHost(); +server.listen(PORT, httpHost, () => { + console.log(`CodexMobile listening on http://${httpHost}:${PORT}`); +}); +``` + +Keep HTTPS on `HOST` / `HTTPS_PORT`. In `scripts/start-server.mjs` and `scripts/start-all.ps1`, document and forward `CODEXMOBILE_PUBLIC_HTTP_HOST` only as an advanced override. Do not default public mode to `0.0.0.0` for HTTP. + +- [ ] **Step 4: Add `/api/security/posture` for smoke and operations** + +In `server/index.js`, add a public low-detail endpoint: + +```js +if (method === 'GET' && pathname === '/api/security/posture') { + sendJson(res, 200, { + publicAccess: securityOptions.publicAccess, + trustedProxyCidrs: securityOptions.trustedProxyCidrs, + dangerFullAccessEnabled: securityOptions.dangerFullAccessEnabled, + httpsActive: isRequestTransportSecure(req, securityOptions), + hstsEnabled: isRequestTransportSecure(req, securityOptions), + httpListenHost: httpListenHost(), + httpsPort: HTTPS_PORT + }); + return; +} +``` + +Do not include device counts, model names, project paths, active sessions, IP history, or token metadata. + +- [ ] **Step 5: Extend smoke checks** + +In `scripts/smoke.mjs`, after reading `/api/status`, print: + +```js +console.log(`publicAccess=${Boolean(data.security?.publicAccess)}`); +console.log(`dangerFullAccessEnabled=${Boolean(data.security?.dangerFullAccessEnabled)}`); +console.log(`authenticated=${Boolean(data.auth?.authenticated)}`); +console.log(`trustedDevices=${Number(data.auth?.trustedDevices || 0)}`); +``` + +If public mode is enabled and URL is plain HTTP to a non-localhost host, fail: + +```js +const parsed = new URL(url); +const localHost = ['127.0.0.1', 'localhost', '::1'].includes(parsed.hostname); +if (process.env.CODEXMOBILE_PUBLIC_ACCESS === '1' && parsed.protocol === 'http:' && !localHost) { + throw new Error('Public access mode requires HTTPS or a trusted HTTPS reverse proxy.'); +} +``` + +Then read `/api/security/posture` and print: + +```js +const posture = await fetchJson(new URL('/api/security/posture', url).toString()); +console.log(`httpsActive=${Boolean(posture.httpsActive)}`); +console.log(`hstsEnabled=${Boolean(posture.hstsEnabled)}`); +console.log(`httpListenHost=${posture.httpListenHost || ''}`); +``` + +- [ ] **Step 6: Verify** + +Run: + +```powershell +node --check server/index.js scripts/start-server.mjs +node --check scripts/smoke.mjs +npm.cmd run build +``` + +Expected: syntax check and build pass. + +- [ ] **Step 7: Commit** + +```powershell +git add .env.example README.md server/index.js scripts/start-server.mjs scripts/start-all.ps1 scripts/smoke.mjs +git commit -m "docs: add public deployment security checklist" +``` + +--- + +## Task 11: WebSocket Cookie Auth, Origin Guard, And Revocation + +**Files:** +- Create: `server/websocket-security.js` +- Create: `server/websocket-security.test.mjs` +- Modify: `server/index.js` +- Modify: `server/auth.js` +- Modify: `client/src/api.js` + +- [ ] **Step 1: Write WebSocket security helper tests** + +Create `server/websocket-security.test.mjs`: + +```js +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + websocketOriginAllowed, + websocketToken +} from './websocket-security.js'; + +test('websocketToken reads cookie and ignores query token', () => { + const token = websocketToken({ + url: '/ws?token=query-token', + headers: { cookie: 'codexmobile_token=cookie-token' } + }); + assert.equal(token, 'cookie-token'); +}); + +test('websocketToken does not accept URL query tokens', () => { + const token = websocketToken({ + url: '/ws?token=query-token', + headers: {} + }); + assert.equal(token, ''); +}); + +test('websocketOriginAllowed rejects cross-origin upgrades', () => { + assert.equal(websocketOriginAllowed({ + headers: { origin: 'https://evil.example.com' } + }, { + allowedOrigins: ['https://codex.example.com'] + }), false); + assert.equal(websocketOriginAllowed({ + headers: { origin: 'https://codex.example.com' } + }, { + allowedOrigins: ['https://codex.example.com'] + }), true); +}); + +test('websocketOriginAllowed allows missing Origin for native clients and local tools', () => { + assert.equal(websocketOriginAllowed({ headers: {} }, { allowedOrigins: ['https://codex.example.com'] }), true); +}); +``` + +- [ ] **Step 2: Run failing tests** + +Run: + +```powershell +node --test server/websocket-security.test.mjs +``` + +Expected: fails because `server/websocket-security.js` does not exist. + +- [ ] **Step 3: Implement `server/websocket-security.js`** + +Create: + +```js +import { extractCookieToken } from './request-security.js'; +import { sameOriginAllowed } from './security-options.js'; + +export function websocketToken(req) { + return extractCookieToken(req); +} + +export function websocketOriginAllowed(req, securityOptions) { + const origin = String(req.headers?.origin || '').trim(); + return sameOriginAllowed(origin, securityOptions); +} +``` + +- [ ] **Step 4: Harden `client/src/api.js` WebSocket URLs** + +Replace both WebSocket URL builders with cookie-only URLs: + +```js +export function websocketUrl() { + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + return `${protocol}//${window.location.host}/ws`; +} + +export function realtimeVoiceWebsocketUrl() { + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + return `${protocol}//${window.location.host}/ws/realtime`; +} +``` + +Do not append `?token=` even when a legacy localStorage token exists. Legacy bearer fallback is HTTP-only and should not be preserved for WebSockets. + +- [ ] **Step 5: Harden `server/index.js` upgrade handler** + +Replace the current query-token logic: + +```js +const token = url.searchParams.get('token') || ''; +const ok = await verifyToken(token, { remoteAddress: remoteAddress(req) }); +``` + +with: + +```js +if (!websocketOriginAllowed(req, securityOptions)) { + socket.write('HTTP/1.1 403 Forbidden\r\n\r\n'); + socket.destroy(); + return; +} + +const token = websocketToken(req); +const verified = await verifyToken(token, { + remoteAddress: remoteAddress(req), + userAgent: req.headers['user-agent'], + securityOptions +}); +if (!verified.ok) { + socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n'); + socket.destroy(); + return; +} +``` + +After `handleUpgrade` succeeds, register sockets against the verified token hash: + +```js +function trackSocket(ws, tokenHash) { + registerSocket(tokenHash, ws); + ws.on('close', () => unregisterSocket(tokenHash, ws)); +} + +if (url.pathname === '/ws/realtime') { + realtimeWss.handleUpgrade(req, socket, head, (ws) => { + trackSocket(ws, verified.tokenHash); + startVoiceRealtimeProxy(ws, { remoteAddress: remoteAddress(req) }); + }); + return; +} + +wss.handleUpgrade(req, socket, head, async (ws) => { + trackSocket(ws, verified.tokenHash); + sockets.add(ws); + ws.on('close', () => sockets.delete(ws)); + ws.send(JSON.stringify({ type: 'connected', status: await publicStatus(true, req) })); +}); +``` + +If `verifyToken()` returns a `replacementToken` during an upgrade, do not send it over WebSocket. Let the next HTTP request rotate the cookie. This avoids reintroducing token material into WS messages. + +- [ ] **Step 6: Ensure revoke/logout closes active sockets** + +In `server/auth.js`, `registerSocket(tokenHash, socket)` stores: + +```js +const socketsByTokenHash = new Map(); + +function registerSocket(tokenHash, socket) { + if (!socketsByTokenHash.has(tokenHash)) socketsByTokenHash.set(tokenHash, new Set()); + socketsByTokenHash.get(tokenHash).add(socket); +} + +function unregisterSocket(tokenHash, socket) { + const set = socketsByTokenHash.get(tokenHash); + if (!set) return; + set.delete(socket); + if (!set.size) socketsByTokenHash.delete(tokenHash); +} + +function closeSocketsForTokenHash(tokenHash) { + const set = socketsByTokenHash.get(tokenHash); + if (!set) return; + for (const socket of set) socket.close(1008, 'revoked'); + socketsByTokenHash.delete(tokenHash); +} +``` + +Call `closeSocketsForTokenHash()` from `revokeDevice()` for every token hash belonging to that device and from `revokeToken()` for the current token hash. + +- [ ] **Step 7: Verify** + +Run: + +```powershell +node --test server/websocket-security.test.mjs server/auth.test.mjs +node --check server/index.js client/src/api.js +npm.cmd run build +``` + +Expected: WebSocket helper tests pass, auth tests pass, syntax checks pass, and build succeeds. + +- [ ] **Step 8: Commit** + +```powershell +git add server/websocket-security.js server/websocket-security.test.mjs server/index.js server/auth.js client/src/api.js +git commit -m "security: harden websocket authentication" +``` + +--- + +## Task 12: End-To-End Verification + +**Files:** +- No source changes in this task. + +- [ ] **Step 1: Run focused security tests** + +```powershell +node --test server/security-options.test.mjs server/request-security.test.mjs server/auth.test.mjs server/websocket-security.test.mjs server/permission-policy.test.mjs server/upload-service.test.mjs server/session-access.test.mjs server/static-service.test.mjs scripts/start-asr.test.mjs +``` + +Expected: all tests pass. + +- [ ] **Step 2: Run affected existing tests** + +```powershell +node --test server/chat-service.test.mjs server/codex-runner-status.test.mjs server/codex-native-images.test.js client/src/pairing-flow.test.mjs client/src/security-panel.test.mjs client/src/send-state.test.mjs +``` + +Expected: all tests pass. + +- [ ] **Step 3: Build production frontend** + +```powershell +npm.cmd run build +``` + +Expected: Vite build succeeds. + +- [ ] **Step 4: Scan built frontend for obvious secret leaks** + +Run after build: + +```powershell +rg --encoding utf-8 -n -i "sk-[a-z0-9_-]{16,}|api[_-]?key|secret|bearer\\s+[a-z0-9._-]{16,}" client\\dist +``` + +Expected: no matches. If the scan finds expected static text in docs or labels, document the exact benign match before continuing. + +- [ ] **Step 5: Manual LAN binding check** + +Start: + +```powershell +npm start -- --host 0.0.0.0 --port 33321 +``` + +From a phone on the same Wi-Fi: + +- Open `http://:33321`. +- Confirm the binding page requests a code. +- Confirm the server console prints one code. +- Enter the code. +- Confirm the app enters the existing Codex shell. + +- [ ] **Step 6: Manual public-mode denial check** + +Start: + +```powershell +$env:CODEXMOBILE_PUBLIC_ACCESS='1' +$env:CODEXMOBILE_PUBLIC_URL='https://codex.example.com/' +$env:CODEXMOBILE_ALLOW_REMOTE_PAIRING='0' +$env:CODEXMOBILE_ENABLE_DANGER_FULL_ACCESS='0' +npm start -- --host 0.0.0.0 --port 33321 +``` + +From a non-private simulated request or unit test path: + +- `POST /api/pair/request` returns `403`. +- `POST /api/pair` returns `403` or `404`. +- Unauthenticated `/api/status` has no active run identifiers. +- Unauthenticated `/api/status` field set is exactly `auth`, `security`, and `version`. +- `security.dangerFullAccessEnabled` is false. + +- [ ] **Step 7: Manual WebSocket security check** + +After binding: + +- Open the app and confirm `/ws` connects without a `?token=` query string in DevTools Network. +- Logout from `/security`; the old app tab WebSocket closes within 1 second with close code `1008`. +- Send an upgrade request with `Origin: https://evil.example.com`; the server rejects it with `403` or destroys the socket. + +- [ ] **Step 8: Manual headers and proxy-spoof checks** + +Run: + +```powershell +curl.exe -I https://your-host/ +curl.exe -i -H "X-Forwarded-For: 192.168.1.1" http://your-public-host/api/pair/request +``` + +Expected: + +- HTTPS response includes `strict-transport-security`. +- Public HTTP response is rejected. +- Spoofed `X-Forwarded-For` does not make a WAN request eligible for pairing unless the socket peer is listed in `CODEXMOBILE_TRUSTED_PROXIES`. + +- [ ] **Step 9: Manual WeChat cookie check** + +On a real phone: + +- Open the public URL in WeChat after binding and refresh; the session remains authenticated. +- Open the same URL in Safari/Chrome; it is a separate browser context and should require its own binding. + +- [ ] **Step 10: Manual security panel check** + +After binding: + +- Open `/security`. +- Confirm current device is marked. +- Confirm revoking another device removes it from the list. +- Confirm current-device logout clears auth and returns to the binding gate. +- Confirm `/` still opens the Codex shell, not a new management home page. + +- [ ] **Step 11: Final status** + +```powershell +git status --short --branch +``` + +Expected: clean working tree after commits, except intentionally untracked local runtime files. + +--- + +## Self-Review + +- Spec coverage: the plan covers public router forwarding, WeChat browser use, LAN-only first binding, console-code binding without computer-side UI, existing React shell integration, WebSocket cookie auth/origin checks/revocation, authenticated device management, trusted proxy CIDRs, CGNAT/Tailscale private addressing, HSTS/CSP, unauthenticated status whitelisting, token rotation, owner-only auth state storage, HTTPS/public HTTP listener boundaries, dangerous permission gating, upload/session/local-image access, ASR exposure, docs, bundle secret scanning, and smoke checks. +- Placeholder scan: the plan contains concrete file paths, test contents, API payloads, UI text, commands, and expected results. It avoids unresolved markers. +- Type consistency: `securityOptions`, `clientRemoteAddress`, `isTrustedProxy`, `isRequestTransportSecure`, `startPairingRequest`, `completePairingRequest`, `registerSocket`, `unregisterSocket`, `websocketToken`, `websocketOriginAllowed`, `resolvePermissionMode`, `resolveUploadedAttachments`, `canReadSessionId`, `publicStatusForAuthState`, `pairingGateState`, and `securitySummaryRows` are defined before use. diff --git a/scripts/bundle-secret-scan.mjs b/scripts/bundle-secret-scan.mjs new file mode 100644 index 0000000..d28e993 --- /dev/null +++ b/scripts/bundle-secret-scan.mjs @@ -0,0 +1,54 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +const SECRET_PATTERN = /sk-[a-z0-9_-]{16,}|api[_-]?key|secret[_-]?(key|token|value)/i; +const TEXT_EXTENSIONS = new Set(['.html', '.js', '.mjs', '.css', '.json', '.webmanifest', '.txt', '.map']); + +async function walkFiles(rootDir) { + let entries = []; + try { + entries = await fs.readdir(rootDir, { withFileTypes: true }); + } catch (error) { + if (error.code === 'ENOENT') { + return []; + } + throw error; + } + + const files = []; + for (const entry of entries) { + const fullPath = path.join(rootDir, entry.name); + if (entry.isDirectory()) { + files.push(...await walkFiles(fullPath)); + } else if (entry.isFile()) { + files.push(fullPath); + } + } + return files; +} + +export async function findSecretLikeMatches(rootDir, { maxMatches = 20 } = {}) { + const files = await walkFiles(rootDir); + const matches = []; + for (const file of files) { + if (!TEXT_EXTENSIONS.has(path.extname(file).toLowerCase())) { + continue; + } + const content = await fs.readFile(file, 'utf8').catch(() => ''); + const lines = content.split(/\r?\n/); + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]; + if (SECRET_PATTERN.test(line)) { + matches.push({ + file, + line: index + 1, + text: line.slice(0, 240) + }); + if (matches.length >= maxMatches) { + return matches; + } + } + } + } + return matches; +} diff --git a/scripts/bundle-secret-scan.test.mjs b/scripts/bundle-secret-scan.test.mjs new file mode 100644 index 0000000..301664b --- /dev/null +++ b/scripts/bundle-secret-scan.test.mjs @@ -0,0 +1,29 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { findSecretLikeMatches } from './bundle-secret-scan.mjs'; + +test('findSecretLikeMatches returns no matches for normal bundle text', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'codexmobile-bundle-clean-')); + await fs.writeFile(path.join(root, 'app.js'), 'console.log("CodexMobile");', 'utf8'); + try { + assert.deepEqual(await findSecretLikeMatches(root), []); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +}); + +test('findSecretLikeMatches reports obvious key-like bundle text', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'codexmobile-bundle-secret-')); + await fs.writeFile(path.join(root, 'app.js'), 'const value = "sk-abc1234567890abcdef";', 'utf8'); + try { + const matches = await findSecretLikeMatches(root); + assert.equal(matches.length, 1); + assert.equal(matches[0].file.endsWith('app.js'), true); + assert.match(matches[0].text, /sk-/); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +}); diff --git a/scripts/smoke.mjs b/scripts/smoke.mjs index 7b74aef..e733244 100644 --- a/scripts/smoke.mjs +++ b/scripts/smoke.mjs @@ -1,15 +1,59 @@ +import path from 'node:path'; +import { findSecretLikeMatches } from './bundle-secret-scan.mjs'; + const url = process.env.CODEXMOBILE_URL || 'http://127.0.0.1:3321/api/status'; +const distDir = path.resolve(import.meta.dirname, '..', 'client', 'dist'); + +async function fetchJson(targetUrl) { + const response = await fetch(targetUrl); + const text = await response.text(); + let data = {}; + try { + data = text ? JSON.parse(text) : {}; + } catch { + data = { raw: text }; + } + if (!response.ok) { + const error = new Error(`Request failed: ${response.status}`); + error.response = data; + throw error; + } + return data; +} try { - const response = await fetch(url); - const data = await response.json(); - if (!response.ok || !data.connected) { - console.error('Smoke failed:', response.status, data); - process.exit(1); + const parsed = new URL(url); + const localHost = ['127.0.0.1', 'localhost', '::1'].includes(parsed.hostname); + if (process.env.CODEXMOBILE_PUBLIC_ACCESS === '1' && parsed.protocol === 'http:' && !localHost) { + throw new Error('Public access mode requires HTTPS or a trusted HTTPS reverse proxy.'); + } + + const data = await fetchJson(url); + console.log(`publicAccess=${Boolean(data.security?.publicAccess)}`); + console.log(`dangerFullAccessEnabled=${Boolean(data.security?.dangerFullAccessEnabled)}`); + console.log(`authenticated=${Boolean(data.auth?.authenticated)}`); + console.log(`trustedDevices=${Number(data.auth?.trustedDevices || 0)}`); + if (data.auth?.authenticated) { + console.log(`Smoke ok: ${data.hostName} ${data.provider}/${data.model} synced=${data.syncedAt}`); + } else { + console.log(`Smoke ok: unauthenticated status version=${data.version || ''}`); + } + + const posture = await fetchJson(new URL('/api/security/posture', url).toString()); + console.log(`httpsActive=${Boolean(posture.httpsActive)}`); + console.log(`hstsEnabled=${Boolean(posture.hstsEnabled)}`); + console.log(`httpListenHost=${posture.httpListenHost || ''}`); + + const secretMatches = await findSecretLikeMatches(distDir); + if (secretMatches.length) { + const first = secretMatches[0]; + throw new Error(`Built frontend contains secret-like text: ${first.file}:${first.line}`); } - console.log(`Smoke ok: ${data.hostName} ${data.provider}/${data.model} synced=${data.syncedAt}`); + console.log('bundleSecretScan=clean'); } catch (error) { console.error(`Smoke failed: ${error.message}`); + if (error.response) { + console.error(error.response); + } process.exit(1); } - diff --git a/scripts/start-asr.mjs b/scripts/start-asr.mjs index 519e91b..95e690c 100644 --- a/scripts/start-asr.mjs +++ b/scripts/start-asr.mjs @@ -1,6 +1,7 @@ import { spawnSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; +import { pathToFileURL } from 'node:url'; const root = path.resolve(import.meta.dirname, '..'); const serviceDir = path.join(root, 'asr-service'); @@ -9,6 +10,7 @@ const containerName = process.env.CODEXMOBILE_ASR_CONTAINER || 'codexmobile-sens const legacyContainerName = process.env.CODEXMOBILE_ASR_LEGACY_CONTAINER || 'codexmobile-asr'; const image = process.env.CODEXMOBILE_ASR_IMAGE || 'codexmobile-sensevoice-asr:latest'; const port = process.env.CODEXMOBILE_ASR_PORT || '8000'; +const host = process.env.CODEXMOBILE_ASR_HOST || '127.0.0.1'; const model = process.env.CODEXMOBILE_TRANSCRIBE_MODEL || 'iic/SenseVoiceSmall'; const device = process.env.CODEXMOBILE_ASR_DEVICE || 'cpu'; const healthTimeoutMs = Number(process.env.CODEXMOBILE_ASR_HEALTH_TIMEOUT_MS || 60000); @@ -16,7 +18,9 @@ const buildTimeoutMs = Number(process.env.CODEXMOBILE_ASR_BUILD_TIMEOUT_MS || 20 const rebuild = ['1', 'true', 'yes', 'on'].includes(String(process.env.CODEXMOBILE_ASR_REBUILD || '').toLowerCase()); const recreate = ['1', 'true', 'yes', 'on'].includes(String(process.env.CODEXMOBILE_ASR_RECREATE || '').toLowerCase()); -fs.mkdirSync(cacheDir, { recursive: true }); +export function buildPublishArg({ host = '127.0.0.1', port = '8000' } = {}) { + return `${host}:${port}:8000`; +} function run(command, args, options = {}) { return spawnSync(command, args, { @@ -131,7 +135,7 @@ function startContainer() { '--restart', 'unless-stopped', '--publish', - `${port}:8000`, + buildPublishArg({ host, port }), '--volume', `${cacheDir.replace(/\\/g, '/')}:/models`, '--env', @@ -186,20 +190,27 @@ async function waitForHealth() { return { ready: false, health: lastHealth }; } -requireDocker(); -buildImageIfNeeded(); -startContainer(); - -console.log(`SenseVoice ASR container started: ${containerName}`); -console.log(`Endpoint: http://127.0.0.1:${port}/v1/audio/transcriptions`); -console.log(`Model: ${model}`); -console.log(`Cache: ${cacheDir}`); +export async function main() { + fs.mkdirSync(cacheDir, { recursive: true }); + requireDocker(); + buildImageIfNeeded(); + startContainer(); + + console.log(`SenseVoice ASR container started: ${containerName}`); + console.log(`Endpoint: http://127.0.0.1:${port}/v1/audio/transcriptions`); + console.log(`Model: ${model}`); + console.log(`Cache: ${cacheDir}`); + + const health = await waitForHealth(); + if (health.ready) { + console.log('SenseVoice ASR is ready.'); + } else { + console.log('SenseVoice ASR is starting or downloading the model. This can take several minutes the first time.'); + console.log(`Check later: curl http://127.0.0.1:${port}/health`); + console.log(`Logs: docker logs -f ${containerName}`); + } +} -const health = await waitForHealth(); -if (health.ready) { - console.log('SenseVoice ASR is ready.'); -} else { - console.log('SenseVoice ASR is starting or downloading the model. This can take several minutes the first time.'); - console.log(`Check later: curl http://127.0.0.1:${port}/health`); - console.log(`Logs: docker logs -f ${containerName}`); +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + await main(); } diff --git a/scripts/start-asr.test.mjs b/scripts/start-asr.test.mjs new file mode 100644 index 0000000..ec64e5b --- /dev/null +++ b/scripts/start-asr.test.mjs @@ -0,0 +1,11 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { buildPublishArg } from './start-asr.mjs'; + +test('buildPublishArg defaults to localhost binding', () => { + assert.equal(buildPublishArg({ host: '127.0.0.1', port: '8000' }), '127.0.0.1:8000:8000'); +}); + +test('buildPublishArg keeps explicit exposure visible', () => { + assert.equal(buildPublishArg({ host: '0.0.0.0', port: '9000' }), '0.0.0.0:9000:8000'); +}); diff --git a/server/auth.js b/server/auth.js index 31634ee..3ba3414 100644 --- a/server/auth.js +++ b/server/auth.js @@ -1,146 +1,514 @@ import crypto from 'node:crypto'; import fs from 'node:fs/promises'; import path from 'node:path'; +import { isPrivateRemoteAddress, normalizeRemoteAddress, readSecurityOptions } from './security-options.js'; export const DATA_DIR = process.env.CODEXMOBILE_HOME || path.join(process.cwd(), '.codexmobile', 'state'); -const STATE_FILE = path.join(DATA_DIR, 'auth-state.json'); -const FIXED_PAIRING_CODE_FILE = path.join(DATA_DIR, 'pairing-code.txt'); -const PAIRING_CODE_PATTERN = /^\d{6}$/; +const STATE_FILE_NAME = 'auth-state.json'; +const DEFAULT_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; +const SUPERSEDED_TOKEN_GRACE_MS = 5 * 60 * 1000; -let authState = null; -let fixedPairingCode = false; -let pairingCode = createPairingCode(); - -function createPairingCode() { - return String(crypto.randomInt(100000, 999999)); +function iso(nowMs) { + return new Date(nowMs).toISOString(); } function hashToken(token) { - return crypto.createHash('sha256').update(token).digest('hex'); + return crypto.createHash('sha256').update(String(token || '')).digest('hex'); +} + +function timingSafeHexEqual(a, b) { + const left = Buffer.from(String(a || ''), 'hex'); + const right = Buffer.from(String(b || ''), 'hex'); + if (left.length !== right.length || !left.length) { + return false; + } + return crypto.timingSafeEqual(left, right); +} + +function createPairingCode(length = 10) { + let value = ''; + for (let i = 0; i < length; i += 1) { + value += DEFAULT_CODE_ALPHABET[crypto.randomInt(0, DEFAULT_CODE_ALPHABET.length)]; + } + return value; } -async function readState() { +function createToken() { + return crypto.randomBytes(32).toString('base64url'); +} + +async function readJson(filePath) { try { - const raw = await fs.readFile(STATE_FILE, 'utf8'); + const raw = await fs.readFile(filePath, 'utf8'); const parsed = JSON.parse(raw); - return { - devices: Array.isArray(parsed.devices) ? parsed.devices : [] - }; + return parsed && typeof parsed === 'object' ? parsed : {}; } catch (error) { if (error.code !== 'ENOENT') { console.warn('[auth] Failed to read auth state, starting fresh:', error.message); } - return { devices: [] }; + return {}; } } -async function writeState() { - await fs.mkdir(DATA_DIR, { recursive: true }); - await fs.writeFile(STATE_FILE, JSON.stringify(authState, null, 2), 'utf8'); +function publicDevice(device, currentTokenHash = '') { + return { + id: device.id, + name: device.name, + createdAt: device.createdAt, + expiresAt: device.expiresAt || null, + revokedAt: device.revokedAt || null, + userAgent: device.userAgent || null, + lastUserAgent: device.lastUserAgent || null, + lastSeenAt: device.lastSeenAt || null, + lastRemoteAddress: device.lastRemoteAddress || null, + current: Boolean(currentTokenHash && deviceTokenRecords(device).some((record) => record.hash === currentTokenHash)) + }; +} + +function deviceTokenRecords(device) { + if (Array.isArray(device.tokens) && device.tokens.length) { + return device.tokens; + } + if (device.tokenHash) { + return [{ + hash: device.tokenHash, + createdAt: device.createdAt, + expiresAt: device.expiresAt || null, + supersededAt: device.supersededAt || null + }]; + } + return []; +} + +function retryAfterSeconds(lockedUntil, nowMs) { + return Math.max(1, Math.ceil((lockedUntil - nowMs) / 1000)); +} + +function consumeBucket(map, key, { maxFailures, windowMs, lockMs }, nowMs) { + const bucket = map.get(key) || { count: 0, windowStart: nowMs, lockedUntil: 0 }; + if (bucket.lockedUntil && bucket.lockedUntil > nowMs) { + return { ok: false, retryAfterSeconds: retryAfterSeconds(bucket.lockedUntil, nowMs) }; + } + if (nowMs - bucket.windowStart > windowMs) { + bucket.count = 0; + bucket.windowStart = nowMs; + bucket.lockedUntil = 0; + } + bucket.count += 1; + if (bucket.count > maxFailures) { + bucket.lockedUntil = nowMs + lockMs; + map.set(key, bucket); + return { ok: false, retryAfterSeconds: retryAfterSeconds(bucket.lockedUntil, nowMs) }; + } + map.set(key, bucket); + return { ok: true }; +} + +async function ensurePrivateStatePath(dataDir) { + await fs.mkdir(dataDir, { recursive: true, mode: 0o700 }); + if (process.platform !== 'win32') { + await fs.chmod(dataDir, 0o700).catch(() => {}); + } } -async function readFixedPairingCode() { - const envCode = String(process.env.CODEXMOBILE_PAIRING_CODE || '').trim(); - if (envCode) { - if (PAIRING_CODE_PATTERN.test(envCode)) { - return envCode; +export function createAuthController({ + dataDir = DATA_DIR, + now = () => Date.now(), + logPairingCode = (entry) => { + if (process.stdout.isTTY) { + process.stdout.write(`[pairing] code=${entry.code}\n`); + } else { + console.log('[pairing] code hidden because stdout is not an interactive terminal'); } - console.warn('[auth] Ignoring CODEXMOBILE_PAIRING_CODE because it is not a 6 digit code.'); + console.log(`[pairing] request=${entry.requestId} device=${entry.deviceName} remote=${entry.remoteAddress} expiresAt=${entry.expiresAt}`); } +} = {}) { + const stateFile = path.join(dataDir, STATE_FILE_NAME); + const pendingPairingRequests = new Map(); + const pairingRequestsByRemote = new Map(); + const pairingCooldownsByRemote = new Map(); + const pairingFailuresByRemote = new Map(); + const socketsByTokenHash = new Map(); + let authState = { devices: [] }; + let stateWriteChain = Promise.resolve(); - try { - const fileCode = (await fs.readFile(FIXED_PAIRING_CODE_FILE, 'utf8')).trim(); - if (PAIRING_CODE_PATTERN.test(fileCode)) { - return fileCode; + async function writeStateSnapshot(snapshot) { + await ensurePrivateStatePath(dataDir); + const tmpFile = `${stateFile}.tmp-${process.pid}-${Date.now()}-${crypto.randomUUID()}`; + try { + await fs.writeFile(tmpFile, snapshot, { encoding: 'utf8', mode: 0o600 }); + if (process.platform !== 'win32') { + await fs.chmod(tmpFile, 0o600).catch(() => {}); + } + await fs.rename(tmpFile, stateFile); + if (process.platform !== 'win32') { + await fs.chmod(stateFile, 0o600).catch(() => {}); + } + } catch (error) { + await fs.unlink(tmpFile).catch(() => {}); + throw error; } - console.warn(`[auth] Ignoring ${FIXED_PAIRING_CODE_FILE} because it is not a 6 digit code.`); - } catch (error) { - if (error.code !== 'ENOENT') { - console.warn('[auth] Failed to read fixed pairing code:', error.message); + } + + async function writeState() { + const snapshot = JSON.stringify(authState, null, 2); + const write = stateWriteChain.then(() => writeStateSnapshot(snapshot)); + stateWriteChain = write.catch(() => {}); + await write; + } + + function addDevice({ token, deviceName, userAgent, remoteAddress, securityOptions }) { + const nowMs = now(); + const createdAt = iso(nowMs); + const expiresAt = iso(nowMs + securityOptions.tokenTtlMs); + const tokenHash = hashToken(token); + const device = { + id: crypto.randomUUID(), + name: deviceName || 'iPhone', + tokenHash, + tokens: [{ + hash: tokenHash, + createdAt, + expiresAt, + supersededAt: null + }], + createdAt, + expiresAt, + revokedAt: null, + userAgent: userAgent || null, + lastUserAgent: userAgent || null, + lastSeenAt: createdAt, + lastRemoteAddress: remoteAddress || null + }; + authState.devices.push(device); + return { tokenHash, device }; + } + + function registerSocket(tokenHash, socket) { + if (!tokenHash || !socket) { + return; + } + if (!socketsByTokenHash.has(tokenHash)) { + socketsByTokenHash.set(tokenHash, new Set()); } + socketsByTokenHash.get(tokenHash).add(socket); } - return null; -} + function unregisterSocket(tokenHash, socket) { + const set = socketsByTokenHash.get(tokenHash); + if (!set) { + return; + } + set.delete(socket); + if (!set.size) { + socketsByTokenHash.delete(tokenHash); + } + } -export async function initializeAuth() { - authState = await readState(); - const configuredPairingCode = await readFixedPairingCode(); - if (configuredPairingCode) { - pairingCode = configuredPairingCode; - fixedPairingCode = true; + function closeSocketsForTokenHash(tokenHash) { + const set = socketsByTokenHash.get(tokenHash); + if (!set) { + return; + } + for (const socket of set) { + if (typeof socket.close === 'function') { + socket.close(1008, 'revoked'); + } + } + socketsByTokenHash.delete(tokenHash); } - await writeState(); - return { pairingCode, fixedPairingCode, trustedDevices: authState.devices.length }; -} -export function getPairingCode() { - return pairingCode; -} + async function initializeAuth() { + const parsed = await readJson(stateFile); + authState = { + devices: Array.isArray(parsed.devices) ? parsed.devices : [] + }; + for (const device of authState.devices) { + if (!Array.isArray(device.tokens) && device.tokenHash) { + device.tokens = deviceTokenRecords(device); + } + } + await writeState(); + return { trustedDevices: authState.devices.length }; + } -export function getTrustedDeviceCount() { - return authState?.devices?.length || 0; -} + async function startPairingRequest({ deviceName, userAgent, remoteAddress, securityOptions = readSecurityOptions() }) { + const normalizedRemote = normalizeRemoteAddress(remoteAddress); + if (!isPrivateRemoteAddress(normalizedRemote, securityOptions) && !securityOptions.allowRemotePairing) { + return { ok: false, statusCode: 403, error: 'Pairing is only allowed from the local network' }; + } -export function extractBearerToken(req, url = null) { - const header = req.headers.authorization || ''; - if (header.toLowerCase().startsWith('bearer ')) { - return header.slice(7).trim(); + const nowMs = now(); + const cooldownUntil = pairingCooldownsByRemote.get(normalizedRemote) || 0; + if (cooldownUntil > nowMs) { + return { + ok: false, + statusCode: 429, + error: 'Pairing request cooldown', + retryAfterSeconds: retryAfterSeconds(cooldownUntil, nowMs) + }; + } + + const requestBucket = consumeBucket(pairingRequestsByRemote, normalizedRemote, { + maxFailures: securityOptions.pairingMaxFailures, + windowMs: securityOptions.pairingWindowMs, + lockMs: securityOptions.pairingLockMs + }, nowMs); + if (!requestBucket.ok) { + return { + ok: false, + statusCode: 429, + error: 'Too many pairing requests', + retryAfterSeconds: requestBucket.retryAfterSeconds + }; + } + + const code = createPairingCode(securityOptions.pairingCodeLength); + const requestId = crypto.randomUUID(); + const createdAt = iso(nowMs); + const expiresAt = iso(nowMs + securityOptions.pairingCodeTtlMs); + const request = { + requestId, + codeHash: hashToken(code), + deviceName: deviceName || 'iPhone', + userAgent: userAgent || null, + remoteAddress: normalizedRemote, + createdAt, + expiresAt, + failedAttempts: 0 + }; + pendingPairingRequests.set(requestId, request); + const requestCooldownMs = Math.max(0, Number(securityOptions.pairingRequestCooldownMs) || 0); + if (requestCooldownMs > 0) { + pairingCooldownsByRemote.set(normalizedRemote, nowMs + requestCooldownMs); + } + logPairingCode({ ...request, code, codeLength: code.length }); + return { + ok: true, + requestId, + code, + codeLength: code.length, + deviceName: request.deviceName, + remoteAddress: request.remoteAddress, + expiresAt, + requestCooldownSeconds: requestCooldownMs > 0 ? Math.ceil(requestCooldownMs / 1000) : 0 + }; } - const fallback = req.headers['x-codexmobile-token']; - if (typeof fallback === 'string' && fallback.trim()) { - return fallback.trim(); + async function completePairingRequest({ requestId, code, remoteAddress, securityOptions = readSecurityOptions() }) { + const normalizedRemote = normalizeRemoteAddress(remoteAddress); + const request = pendingPairingRequests.get(String(requestId || '')); + if (!request) { + return { ok: false, statusCode: 404, error: 'Pairing request not found' }; + } + if (request.remoteAddress !== normalizedRemote && !securityOptions.allowRemotePairing) { + return { ok: false, statusCode: 403, error: 'Pairing is only allowed from the local network' }; + } + + const nowMs = now(); + if (Date.parse(request.expiresAt) <= nowMs) { + pendingPairingRequests.delete(request.requestId); + return { ok: false, statusCode: 410, error: 'Pairing code expired' }; + } + + const failureBucket = consumeBucket(pairingFailuresByRemote, normalizedRemote, { + maxFailures: securityOptions.pairingMaxFailures, + windowMs: securityOptions.pairingWindowMs, + lockMs: securityOptions.pairingLockMs + }, nowMs); + if (!failureBucket.ok) { + pendingPairingRequests.delete(request.requestId); + return { + ok: false, + statusCode: 429, + error: 'Too many pairing attempts', + retryAfterSeconds: failureBucket.retryAfterSeconds + }; + } + + if (!timingSafeHexEqual(hashToken(String(code || '').trim().toUpperCase()), request.codeHash)) { + request.failedAttempts += 1; + return { ok: false, statusCode: 403, error: 'Invalid pairing code' }; + } + + const token = createToken(); + const { device } = addDevice({ + token, + deviceName: request.deviceName, + userAgent: request.userAgent, + remoteAddress: normalizedRemote, + securityOptions + }); + pendingPairingRequests.delete(request.requestId); + pairingFailuresByRemote.delete(normalizedRemote); + await writeState(); + return { ok: true, token, device: publicDevice(device) }; } - return url?.searchParams?.get('token')?.trim() || ''; -} -export async function verifyToken(token, metadata = {}) { - if (!token || !authState) { - return false; + async function verifyToken(token, { remoteAddress, userAgent, securityOptions = readSecurityOptions(), rotate = true } = {}) { + if (!token || !authState) { + return { ok: false }; + } + const tokenHash = hashToken(token); + const nowMs = now(); + for (const device of authState.devices) { + if (device.revokedAt) { + continue; + } + const tokenRecord = deviceTokenRecords(device).find((record) => record.hash === tokenHash); + if (!tokenRecord) { + continue; + } + if (tokenRecord.expiresAt && Date.parse(tokenRecord.expiresAt) <= nowMs) { + return { ok: false }; + } + if (tokenRecord.supersededAt && nowMs - Date.parse(tokenRecord.supersededAt) > SUPERSEDED_TOKEN_GRACE_MS) { + return { ok: false }; + } + + device.lastSeenAt = iso(nowMs); + device.lastRemoteAddress = remoteAddress || device.lastRemoteAddress || null; + device.lastUserAgent = userAgent || device.lastUserAgent || null; + + let replacementToken = null; + let activeTokenHash = tokenHash; + const createdMs = Date.parse(tokenRecord.createdAt || device.createdAt || iso(nowMs)); + const ageMs = Number.isFinite(createdMs) ? nowMs - createdMs : 0; + if (tokenRecord.supersededAt) { + const activeRecord = deviceTokenRecords(device) + .filter((record) => { + if (record.hash === tokenHash || record.supersededAt) { + return false; + } + return !record.expiresAt || Date.parse(record.expiresAt) > nowMs; + }) + .sort((left, right) => Date.parse(right.createdAt || '') - Date.parse(left.createdAt || ''))[0]; + if (activeRecord) { + activeTokenHash = activeRecord.hash; + } + } + if (rotate && !tokenRecord.supersededAt && ageMs > securityOptions.tokenTtlMs / 2) { + replacementToken = createToken(); + activeTokenHash = hashToken(replacementToken); + tokenRecord.supersededAt = iso(nowMs); + if (!Array.isArray(device.tokens)) { + device.tokens = deviceTokenRecords(device); + } + device.tokens.push({ + hash: activeTokenHash, + createdAt: iso(nowMs), + expiresAt: iso(nowMs + securityOptions.tokenTtlMs), + supersededAt: null + }); + device.tokenHash = activeTokenHash; + device.expiresAt = iso(nowMs + securityOptions.tokenTtlMs); + } + await writeState(); + return { + ok: true, + device: publicDevice(device, activeTokenHash), + tokenHash: activeTokenHash, + replacementToken + }; + } + return { ok: false }; } - const tokenHash = hashToken(token); - const device = authState.devices.find((entry) => entry.tokenHash === tokenHash); - if (!device) { - return false; + async function revokeDevice(deviceId) { + const device = authState.devices.find((entry) => entry.id === deviceId); + if (!device) { + return { ok: false }; + } + device.revokedAt = iso(now()); + for (const record of deviceTokenRecords(device)) { + closeSocketsForTokenHash(record.hash); + } + await writeState(); + return { ok: true, deviceId: device.id }; } - device.lastSeenAt = new Date().toISOString(); - device.lastRemoteAddress = metadata.remoteAddress || device.lastRemoteAddress || null; - await writeState(); - return true; -} + async function revokeToken(token) { + const tokenHash = hashToken(token); + for (const device of authState.devices) { + if (deviceTokenRecords(device).some((record) => record.hash === tokenHash)) { + device.revokedAt = iso(now()); + for (const record of deviceTokenRecords(device)) { + closeSocketsForTokenHash(record.hash); + } + await writeState(); + return { ok: true, deviceId: device.id }; + } + } + return { ok: false }; + } -export async function pairDevice({ code, deviceName, userAgent, remoteAddress }) { - if (!code || String(code).trim() !== pairingCode) { - return null; + function listDevices({ currentToken } = {}) { + const currentTokenHash = currentToken ? hashToken(currentToken) : ''; + return authState.devices.map((device) => publicDevice(device, currentTokenHash)); } - const token = crypto.randomBytes(32).toString('base64url'); - const now = new Date().toISOString(); - const device = { - id: crypto.randomUUID(), - name: deviceName || 'iPhone', - tokenHash: hashToken(token), - createdAt: now, - lastSeenAt: now, - userAgent: userAgent || null, - lastRemoteAddress: remoteAddress || null - }; + function getTrustedDeviceCount() { + return authState.devices.filter((device) => !device.revokedAt).length; + } - authState.devices.push(device); - if (!fixedPairingCode) { - pairingCode = createPairingCode(); + function getPendingPairingRequest(requestId) { + const request = pendingPairingRequests.get(requestId); + return request ? { ...request } : null; } - await writeState(); return { - token, - device: { - id: device.id, - name: device.name, - createdAt: device.createdAt - } + initializeAuth, + startPairingRequest, + completePairingRequest, + verifyToken, + revokeDevice, + revokeToken, + registerSocket, + unregisterSocket, + listDevices, + getTrustedDeviceCount, + getPendingPairingRequest }; } + +const defaultAuth = createAuthController(); + +export async function initializeAuth() { + return defaultAuth.initializeAuth(); +} + +export function getTrustedDeviceCount() { + return defaultAuth.getTrustedDeviceCount(); +} + +export async function verifyToken(token, metadata = {}) { + return defaultAuth.verifyToken(token, metadata); +} + +export async function startPairingRequest(params) { + return defaultAuth.startPairingRequest(params); +} + +export async function completePairingRequest(params) { + return defaultAuth.completePairingRequest(params); +} + +export async function revokeDevice(deviceId) { + return defaultAuth.revokeDevice(deviceId); +} + +export async function revokeToken(token) { + return defaultAuth.revokeToken(token); +} + +export function registerSocket(tokenHash, socket) { + return defaultAuth.registerSocket(tokenHash, socket); +} + +export function unregisterSocket(tokenHash, socket) { + return defaultAuth.unregisterSocket(tokenHash, socket); +} + +export function listDevices(options) { + return defaultAuth.listDevices(options); +} diff --git a/server/auth.test.mjs b/server/auth.test.mjs index ac5ff73..5f34f35 100644 --- a/server/auth.test.mjs +++ b/server/auth.test.mjs @@ -1,14 +1,523 @@ -import test from 'node:test'; import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { createAuthController } from './auth.js'; +import { readSecurityOptions } from './security-options.js'; + +async function tempAuth() { + const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'codexmobile-auth-')); + let nowMs = Date.parse('2026-05-10T00:00:00.000Z'); + const logs = []; + const auth = createAuthController({ + dataDir, + now: () => nowMs, + logPairingCode: (entry) => logs.push(entry) + }); + await auth.initializeAuth(); + return { + auth, + logs, + advance(ms) { + nowMs += ms; + }, + security(overrides = {}) { + return readSecurityOptions({ ...overrides }); + } + }; +} + +function setStdoutIsTTY(value) { + const descriptor = Object.getOwnPropertyDescriptor(process.stdout, 'isTTY'); + Object.defineProperty(process.stdout, 'isTTY', { + configurable: true, + value + }); + return () => { + if (descriptor) { + Object.defineProperty(process.stdout, 'isTTY', descriptor); + } else { + delete process.stdout.isTTY; + } + }; +} + +test('LAN pairing request creates one console-visible code and stores only a hash', async () => { + const t = await tempAuth(); + const result = await t.auth.startPairingRequest({ + deviceName: 'iPhone / WeChat', + userAgent: 'WeChat', + remoteAddress: '192.168.1.23', + securityOptions: t.security() + }); + assert.equal(result.ok, true); + assert.match(result.requestId, /^[0-9a-f-]{36}$/); + assert.match(result.code, /^[A-Z2-9]{10}$/); + assert.equal(t.logs.length, 1); + assert.equal(t.logs[0].code, result.code); + assert.equal(t.auth.getPendingPairingRequest(result.requestId).code, undefined); + assert.match(t.auth.getPendingPairingRequest(result.requestId).codeHash, /^[a-f0-9]{64}$/); +}); + +test('default pairing logger writes code only to stdout and keeps structured log redacted', async () => { + const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'codexmobile-auth-default-log-')); + const auth = createAuthController({ + dataDir, + now: () => Date.parse('2026-05-10T00:00:00.000Z') + }); + const stdoutWrites = []; + const consoleLogs = []; + const originalStdoutWrite = process.stdout.write; + const originalConsoleLog = console.log; + const restoreStdoutIsTTY = setStdoutIsTTY(true); + process.stdout.write = (chunk, ...args) => { + stdoutWrites.push(String(chunk)); + if (typeof args.at(-1) === 'function') { + args.at(-1)(); + } + return true; + }; + console.log = (...args) => { + consoleLogs.push(args.map((entry) => String(entry)).join(' ')); + }; + try { + await auth.initializeAuth(); + const result = await auth.startPairingRequest({ + deviceName: 'iPhone / WeChat', + userAgent: 'WeChat', + remoteAddress: '192.168.1.23', + securityOptions: readSecurityOptions() + }); + + assert.equal(result.ok, true); + assert.match(stdoutWrites.join(''), new RegExp(`\\[pairing\\] code=${result.code}\\n`)); + assert.equal(consoleLogs.some((line) => line.includes(result.code)), false); + assert.equal(consoleLogs.some((line) => line.includes(result.requestId)), true); + } finally { + restoreStdoutIsTTY(); + process.stdout.write = originalStdoutWrite; + console.log = originalConsoleLog; + } +}); + +test('default pairing logger hides code when stdout is redirected', async () => { + const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'codexmobile-auth-background-log-')); + const auth = createAuthController({ + dataDir, + now: () => Date.parse('2026-05-10T00:00:00.000Z') + }); + const stdoutWrites = []; + const consoleLogs = []; + const originalStdoutWrite = process.stdout.write; + const originalConsoleLog = console.log; + const restoreStdoutIsTTY = setStdoutIsTTY(false); + process.stdout.write = (chunk, ...args) => { + stdoutWrites.push(String(chunk)); + if (typeof args.at(-1) === 'function') { + args.at(-1)(); + } + return true; + }; + console.log = (...args) => { + consoleLogs.push(args.map((entry) => String(entry)).join(' ')); + }; + try { + await auth.initializeAuth(); + const result = await auth.startPairingRequest({ + deviceName: 'iPhone / WeChat', + userAgent: 'WeChat', + remoteAddress: '192.168.1.23', + securityOptions: readSecurityOptions() + }); + + assert.equal(result.ok, true); + assert.equal(stdoutWrites.some((line) => line.includes(result.code)), false); + assert.equal(consoleLogs.some((line) => line.includes(result.code)), false); + assert.equal(consoleLogs.some((line) => line.includes('code hidden')), true); + assert.equal(consoleLogs.some((line) => line.includes(result.requestId)), true); + } finally { + restoreStdoutIsTTY(); + process.stdout.write = originalStdoutWrite; + console.log = originalConsoleLog; + } +}); -import { extractBearerToken } from './auth.js'; +test('WAN pairing request is rejected by default', async () => { + const t = await tempAuth(); + const result = await t.auth.startPairingRequest({ + deviceName: 'Remote iPhone', + userAgent: 'WeChat', + remoteAddress: '203.0.113.9', + securityOptions: t.security() + }); + assert.equal(result.ok, false); + assert.equal(result.statusCode, 403); +}); + +test('pairing request creation is rate limited per remote before a code is printed', async () => { + const t = await tempAuth(); + for (let i = 0; i < 5; i += 1) { + const result = await t.auth.startPairingRequest({ + deviceName: `iPhone ${i}`, + userAgent: 'WeChat', + remoteAddress: '192.168.1.23', + securityOptions: t.security() + }); + assert.equal(result.ok, true); + t.advance(30 * 1000); + } + const blocked = await t.auth.startPairingRequest({ + deviceName: 'iPhone overflow', + userAgent: 'WeChat', + remoteAddress: '192.168.1.23', + securityOptions: t.security() + }); + assert.equal(blocked.ok, false); + assert.equal(blocked.statusCode, 429); + assert.equal(t.logs.length, 5); +}); + +test('pairing request creation has a per-remote cooldown before another code is printed', async () => { + const t = await tempAuth(); + const first = await t.auth.startPairingRequest({ + deviceName: 'iPhone', + userAgent: 'WeChat', + remoteAddress: '192.168.1.23', + securityOptions: t.security({ CODEXMOBILE_PAIRING_REQUEST_COOLDOWN_MS: '30000' }) + }); + assert.equal(first.ok, true); + + const blocked = await t.auth.startPairingRequest({ + deviceName: 'iPhone again', + userAgent: 'WeChat', + remoteAddress: '192.168.1.23', + securityOptions: t.security({ CODEXMOBILE_PAIRING_REQUEST_COOLDOWN_MS: '30000' }) + }); + assert.equal(blocked.ok, false); + assert.equal(blocked.statusCode, 429); + assert.equal(blocked.error, 'Pairing request cooldown'); + assert.equal(blocked.retryAfterSeconds, 30); + assert.equal(t.logs.length, 1); + + t.advance(30 * 1000); + const second = await t.auth.startPairingRequest({ + deviceName: 'iPhone later', + userAgent: 'WeChat', + remoteAddress: '192.168.1.23', + securityOptions: t.security({ CODEXMOBILE_PAIRING_REQUEST_COOLDOWN_MS: '30000' }) + }); + assert.equal(second.ok, true); + assert.equal(t.logs.length, 2); +}); + +test('pairing completion requires same request, same remote, valid code, and unused request', async () => { + const t = await tempAuth(); + const request = await t.auth.startPairingRequest({ + deviceName: 'iPhone / WeChat', + userAgent: 'WeChat', + remoteAddress: '192.168.1.23', + securityOptions: t.security() + }); + const wrongRemote = await t.auth.completePairingRequest({ + requestId: request.requestId, + code: request.code, + remoteAddress: '192.168.1.24', + securityOptions: t.security() + }); + assert.equal(wrongRemote.ok, false); + assert.equal(wrongRemote.statusCode, 403); + + const paired = await t.auth.completePairingRequest({ + requestId: request.requestId, + code: request.code, + remoteAddress: '192.168.1.23', + securityOptions: t.security() + }); + assert.equal(paired.ok, true); + assert.match(paired.token, /^[A-Za-z0-9_-]+$/); + assert.equal(paired.device.name, 'iPhone / WeChat'); -test('extractBearerToken falls back to token query parameter for image tags', () => { - const url = new URL('http://127.0.0.1:3321/api/local-image?path=%2Ftmp%2Fa.png&token=query-token'); - assert.equal(extractBearerToken({ headers: {} }, url), 'query-token'); + const reused = await t.auth.completePairingRequest({ + requestId: request.requestId, + code: request.code, + remoteAddress: '192.168.1.23', + securityOptions: t.security() + }); + assert.equal(reused.ok, false); + assert.equal(reused.statusCode, 404); }); -test('extractBearerToken prefers authorization header over query token', () => { - const url = new URL('http://127.0.0.1:3321/api/local-image?token=query-token'); - assert.equal(extractBearerToken({ headers: { authorization: 'Bearer header-token' } }, url), 'header-token'); +test('expired pairing request cannot complete', async () => { + const t = await tempAuth(); + const request = await t.auth.startPairingRequest({ + deviceName: 'iPhone', + userAgent: 'WeChat', + remoteAddress: '192.168.1.23', + securityOptions: t.security() + }); + t.advance(10 * 60 * 1000 + 1); + const result = await t.auth.completePairingRequest({ + requestId: request.requestId, + code: request.code, + remoteAddress: '192.168.1.23', + securityOptions: t.security() + }); + assert.equal(result.ok, false); + assert.equal(result.statusCode, 410); +}); + +test('wrong codes are rate limited', async () => { + const t = await tempAuth(); + const request = await t.auth.startPairingRequest({ + deviceName: 'iPhone', + userAgent: 'WeChat', + remoteAddress: '192.168.1.23', + securityOptions: t.security() + }); + for (let i = 0; i < 5; i += 1) { + const result = await t.auth.completePairingRequest({ + requestId: request.requestId, + code: 'AAAAAAAAAA', + remoteAddress: '192.168.1.23', + securityOptions: t.security() + }); + assert.equal(result.ok, false); + } + const locked = await t.auth.completePairingRequest({ + requestId: request.requestId, + code: request.code, + remoteAddress: '192.168.1.23', + securityOptions: t.security() + }); + assert.equal(locked.ok, false); + assert.equal(locked.statusCode, 429); +}); + +test('token verifies, expires, and can be revoked', async () => { + const t = await tempAuth(); + const request = await t.auth.startPairingRequest({ + deviceName: 'iPhone', + userAgent: 'WeChat', + remoteAddress: '192.168.1.23', + securityOptions: t.security() + }); + const paired = await t.auth.completePairingRequest({ + requestId: request.requestId, + code: request.code, + remoteAddress: '192.168.1.23', + securityOptions: t.security() + }); + const verified = await t.auth.verifyToken(paired.token, { + remoteAddress: '198.51.100.7', + userAgent: 'WeChat', + securityOptions: t.security() + }); + assert.equal(verified.ok, true); + assert.equal(verified.device.id, paired.device.id); + + const revoked = await t.auth.revokeDevice(paired.device.id); + assert.equal(revoked.ok, true); + const afterRevoke = await t.auth.verifyToken(paired.token, { + remoteAddress: '198.51.100.7', + userAgent: 'WeChat', + securityOptions: t.security() + }); + assert.equal(afterRevoke.ok, false); +}); + +test('verifyToken rotates old tokens after half of ttl has elapsed', async () => { + const t = await tempAuth(); + const request = await t.auth.startPairingRequest({ + deviceName: 'iPhone', + userAgent: 'WeChat', + remoteAddress: '192.168.1.23', + securityOptions: t.security() + }); + const paired = await t.auth.completePairingRequest({ + requestId: request.requestId, + code: request.code, + remoteAddress: '192.168.1.23', + securityOptions: t.security({ CODEXMOBILE_TOKEN_TTL_MS: String(100 * 1000) }) + }); + t.advance(51 * 1000); + const verified = await t.auth.verifyToken(paired.token, { + remoteAddress: '198.51.100.7', + userAgent: 'WeChat', + securityOptions: t.security({ CODEXMOBILE_TOKEN_TTL_MS: String(100 * 1000) }) + }); + assert.equal(verified.ok, true); + assert.match(verified.replacementToken, /^[A-Za-z0-9_-]+$/); + assert.notEqual(verified.replacementToken, paired.token); +}); + +test('verifyToken maps superseded grace tokens to the active token hash', async () => { + const t = await tempAuth(); + const request = await t.auth.startPairingRequest({ + deviceName: 'iPhone', + userAgent: 'WeChat', + remoteAddress: '192.168.1.23', + securityOptions: t.security() + }); + const paired = await t.auth.completePairingRequest({ + requestId: request.requestId, + code: request.code, + remoteAddress: '192.168.1.23', + securityOptions: t.security({ CODEXMOBILE_TOKEN_TTL_MS: String(100 * 1000) }) + }); + t.advance(51 * 1000); + const rotated = await t.auth.verifyToken(paired.token, { + remoteAddress: '198.51.100.7', + userAgent: 'WeChat', + securityOptions: t.security({ CODEXMOBILE_TOKEN_TTL_MS: String(100 * 1000) }) + }); + assert.equal(rotated.ok, true); + assert.match(rotated.replacementToken, /^[A-Za-z0-9_-]+$/); + + const grace = await t.auth.verifyToken(paired.token, { + remoteAddress: '198.51.100.7', + userAgent: 'WeChat', + securityOptions: t.security({ CODEXMOBILE_TOKEN_TTL_MS: String(100 * 1000) }) + }); + assert.equal(grace.ok, true); + assert.equal(grace.replacementToken, null); + assert.equal(grace.tokenHash, rotated.tokenHash); + assert.equal(grace.device.current, true); + + const websocketGrace = await t.auth.verifyToken(paired.token, { + remoteAddress: '198.51.100.7', + userAgent: 'WeChat', + rotate: false, + securityOptions: t.security({ CODEXMOBILE_TOKEN_TTL_MS: String(100 * 1000) }) + }); + assert.equal(websocketGrace.ok, true); + assert.equal(websocketGrace.replacementToken, null); + assert.equal(websocketGrace.tokenHash, rotated.tokenHash); +}); + +test('verifyToken can skip rotation for websocket upgrades', async () => { + const t = await tempAuth(); + const request = await t.auth.startPairingRequest({ + deviceName: 'iPhone', + userAgent: 'WeChat', + remoteAddress: '192.168.1.23', + securityOptions: t.security() + }); + const paired = await t.auth.completePairingRequest({ + requestId: request.requestId, + code: request.code, + remoteAddress: '192.168.1.23', + securityOptions: t.security({ CODEXMOBILE_TOKEN_TTL_MS: String(100 * 1000) }) + }); + t.advance(51 * 1000); + const verified = await t.auth.verifyToken(paired.token, { + remoteAddress: '198.51.100.7', + userAgent: 'WeChat', + rotate: false, + securityOptions: t.security({ CODEXMOBILE_TOKEN_TTL_MS: String(100 * 1000) }) + }); + assert.equal(verified.ok, true); + assert.equal(verified.replacementToken, null); +}); + +test('revokeToken closes sockets registered to all tokens for the same device', async () => { + const t = await tempAuth(); + const request = await t.auth.startPairingRequest({ + deviceName: 'iPhone', + userAgent: 'WeChat', + remoteAddress: '192.168.1.23', + securityOptions: t.security() + }); + const paired = await t.auth.completePairingRequest({ + requestId: request.requestId, + code: request.code, + remoteAddress: '192.168.1.23', + securityOptions: t.security({ CODEXMOBILE_TOKEN_TTL_MS: String(100 * 1000) }) + }); + t.advance(51 * 1000); + const rotated = await t.auth.verifyToken(paired.token, { + remoteAddress: '198.51.100.7', + userAgent: 'WeChat', + securityOptions: t.security({ CODEXMOBILE_TOKEN_TTL_MS: String(100 * 1000) }) + }); + const oldSocket = { closed: false, close() { this.closed = true; } }; + const newSocket = { closed: false, close() { this.closed = true; } }; + const oldVerification = await t.auth.verifyToken(paired.token, { + remoteAddress: '198.51.100.7', + userAgent: 'WeChat', + rotate: false, + securityOptions: t.security({ CODEXMOBILE_TOKEN_TTL_MS: String(100 * 1000) }) + }); + const newVerification = await t.auth.verifyToken(rotated.replacementToken, { + remoteAddress: '198.51.100.7', + userAgent: 'WeChat', + rotate: false, + securityOptions: t.security({ CODEXMOBILE_TOKEN_TTL_MS: String(100 * 1000) }) + }); + t.auth.registerSocket(oldVerification.tokenHash, oldSocket); + t.auth.registerSocket(newVerification.tokenHash, newSocket); + + const revoked = await t.auth.revokeToken(paired.token); + + assert.equal(revoked.ok, true); + assert.equal(oldSocket.closed, true); + assert.equal(newSocket.closed, true); +}); + +test('auth state writes through a temporary file before replacing state file', async (t) => { + const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'codexmobile-auth-atomic-')); + const writes = []; + const originalWriteFile = fs.writeFile; + t.mock.method(fs, 'writeFile', async (filePath, ...args) => { + writes.push(path.basename(String(filePath))); + return originalWriteFile(filePath, ...args); + }); + + const auth = createAuthController({ dataDir }); + await auth.initializeAuth(); + + assert.ok(writes.some((name) => name.startsWith('auth-state.json.tmp-'))); + assert.equal(writes.includes('auth-state.json'), false); + assert.equal((await fs.readdir(dataDir)).some((name) => name.startsWith('auth-state.json.tmp-')), false); + assert.deepEqual(JSON.parse(await fs.readFile(path.join(dataDir, 'auth-state.json'), 'utf8')), { + devices: [] + }); +}); + +test('concurrent token verification serializes auth state writes', async (t) => { + const authContext = await tempAuth(); + const request = await authContext.auth.startPairingRequest({ + deviceName: 'iPhone', + userAgent: 'Mobile Safari', + remoteAddress: '192.168.1.23', + securityOptions: authContext.security() + }); + const paired = await authContext.auth.completePairingRequest({ + requestId: request.requestId, + code: request.code, + remoteAddress: '192.168.1.23', + securityOptions: authContext.security() + }); + + let barrierEnabled = false; + const originalWriteFile = fs.writeFile; + t.mock.method(fs, 'writeFile', async (filePath, ...args) => { + await originalWriteFile(filePath, ...args); + if (barrierEnabled && path.basename(String(filePath)).startsWith('auth-state.json.tmp')) { + await new Promise((resolve) => setTimeout(resolve, 20)); + } + }); + + barrierEnabled = true; + const results = await Promise.allSettled(Array.from({ length: 8 }, () => + authContext.auth.verifyToken(paired.token, { + remoteAddress: '192.168.1.23', + userAgent: 'Mobile Safari', + securityOptions: authContext.security(), + rotate: false + }) + )); + + assert.deepEqual(results.map((result) => result.status), Array(8).fill('fulfilled')); + assert.deepEqual(results.map((result) => result.value.ok), Array(8).fill(true)); }); diff --git a/server/chat-delivery.js b/server/chat-delivery.js index 2b78183..b6b7b9d 100644 --- a/server/chat-delivery.js +++ b/server/chat-delivery.js @@ -1,4 +1,5 @@ import { buildCodexTurnInput } from './codex-native-images.js'; +import { desktopTurnPermissionsForPermissionMode } from './permission-policy.js'; export async function assertDesktopBridgeAvailable(getDesktopBridgeStatus) { const bridge = getDesktopBridgeStatus ? await getDesktopBridgeStatus({ force: true }) : null; @@ -86,6 +87,20 @@ function userMessageMetadataForSendMode(sendMode = 'start') { : {}; } +function desktopCollaborationModeForTurn(collaborationMode, { model = null, reasoningEffort = null } = {}) { + if (collaborationMode?.mode) { + return collaborationMode; + } + return { + mode: 'default', + settings: { + model: String(model || '').trim(), + reasoning_effort: reasoningEffort || null, + developer_instructions: null + } + }; +} + async function syncDesktopFollowerCollaborationMode({ selectedSessionId, collaborationMode, @@ -94,7 +109,7 @@ async function syncDesktopFollowerCollaborationMode({ if (!setDesktopFollowerCollaborationMode) { return; } - await setDesktopFollowerCollaborationMode(selectedSessionId, collaborationMode || null); + await setDesktopFollowerCollaborationMode(selectedSessionId, collaborationMode); } export async function sendViaDesktopIpc({ @@ -135,20 +150,29 @@ export async function sendViaDesktopIpc({ }); const now = new Date().toISOString(); const lastSession = getSession(selectedSessionId); + const cwd = lastSession?.cwd || project.path || null; + const desktopPermissions = desktopTurnPermissionsForPermissionMode(permissionMode, { + dangerFullAccessEnabled: true, + writableRoots: cwd ? [cwd] : [], + networkAccess: true + }); + const desktopCollaborationMode = desktopCollaborationModeForTurn(collaborationMode, { + model, + reasoningEffort + }); const baseTurnStartParams = { input, - cwd: lastSession?.cwd || project.path || null, - approvalPolicy: 'never', - approvalsReviewer: 'user', - sandboxPolicy: permissionMode === 'bypassPermissions' - ? { type: 'dangerFullAccess' } - : { type: 'workspaceWrite', networkAccess: false }, + cwd, + ...desktopPermissions, model: model || null, effort: reasoningEffort || null, serviceTier: serviceTier || null, - collaborationMode: collaborationMode || null, + collaborationMode: desktopCollaborationMode, attachments: [] }; + console.info( + `[desktop-ipc] start-turn prepare session=${selectedSessionId} mode=${permissionMode || 'default'} approval=${baseTurnStartParams.approvalPolicy || ''} reviewer=${baseTurnStartParams.approvalsReviewer || ''} sandbox=${JSON.stringify(baseTurnStartParams.sandboxPolicy || null)}` + ); async function attemptDesktopFollowerTurn() { if (sendMode === 'steer') { @@ -157,7 +181,7 @@ export async function sendViaDesktopIpc({ } await syncDesktopFollowerCollaborationMode({ selectedSessionId, - collaborationMode, + collaborationMode: desktopCollaborationMode, setDesktopFollowerCollaborationMode }); result = await steerDesktopFollowerTurn(selectedSessionId, { @@ -165,10 +189,10 @@ export async function sendViaDesktopIpc({ attachments: [], restoreMessage: { text: codexMessage, - cwd: lastSession?.cwd || project.path || null, + cwd, context: { workspaceRoots: project.path ? [project.path] : [], - collaborationMode: collaborationMode || null + collaborationMode: desktopCollaborationMode }, responsesapiClientMetadata: null } @@ -182,10 +206,13 @@ export async function sendViaDesktopIpc({ } await syncDesktopFollowerCollaborationMode({ selectedSessionId, - collaborationMode, + collaborationMode: desktopCollaborationMode, setDesktopFollowerCollaborationMode }); result = await startDesktopFollowerTurn(selectedSessionId, baseTurnStartParams); + console.info( + `[desktop-ipc] start-turn accepted session=${selectedSessionId} mode=${permissionMode || 'default'} turn=${result?.result?.turn?.id || result?.turn?.id || ''}` + ); } return result; } @@ -208,6 +235,9 @@ export async function sendViaDesktopIpc({ } } } catch (error) { + console.warn( + `[desktop-ipc] start-turn failed session=${selectedSessionId} mode=${permissionMode || 'default'} error=${error?.message || error}` + ); if (isDesktopThreadOwnerUnavailable(error)) { throw desktopIpcUnavailableError(error?.message || undefined); } diff --git a/server/chat-request-prep.js b/server/chat-request-prep.js index d5b1b4c..855a00d 100644 --- a/server/chat-request-prep.js +++ b/server/chat-request-prep.js @@ -109,9 +109,10 @@ export function prepareChatRequest(body = {}, { getSession = () => null, config = {}, defaultReasoningEffort = 'xhigh', + uploadRoot = '', createTurnId = crypto.randomUUID } = {}) { - const attachments = normalizeAttachments(body.attachments); + const attachments = normalizeAttachments(body.attachments, { uploadRoot }); const fileMentions = normalizeFileMentions(body.fileMentions); const message = String(body.message || '').trim(); if (!message && !attachments.length) { diff --git a/server/chat-request-prep.test.mjs b/server/chat-request-prep.test.mjs index ab26d9c..78c37d4 100644 --- a/server/chat-request-prep.test.mjs +++ b/server/chat-request-prep.test.mjs @@ -7,6 +7,8 @@ import { } from './chat-request-prep.js'; test('prepareChatRequest normalizes skills, plan mode, attachments, and file mentions', () => { + const imagePath = path.resolve('/tmp/screen shot.png'); + const notesPath = path.resolve('/tmp/notes.md'); const prepared = prepareChatRequest({ projectId: 'project-1', sessionId: 'thread-1', @@ -14,8 +16,8 @@ test('prepareChatRequest normalizes skills, plan mode, attachments, and file men sendMode: 'queue', message: '看这里', attachments: [ - { id: 'img-1', name: '截图.png', path: '/tmp/screen shot.png', mimeType: 'image/png', kind: 'image' }, - { id: 'file-1', name: 'notes.md', path: '/tmp/notes.md', mimeType: 'text/markdown', kind: 'file' } + { id: 'img-1', name: '截图.png', path: imagePath, mimeType: 'image/png', kind: 'image' }, + { id: 'file-1', name: 'notes.md', path: notesPath, mimeType: 'text/markdown', kind: 'file' } ], fileMentions: [ { name: 'App.jsx', path: '/repo/client/src/App.jsx' }, @@ -66,10 +68,11 @@ test('prepareChatRequest normalizes skills, plan mode, attachments, and file men developer_instructions: '只先规划' } }); - assert.match(prepared.visibleMessage, /!\[截图\.png\]\(<\/tmp\/screen shot\.png>\)/); + assert.match(prepared.visibleMessage, /!\[截图\.png\]\(/); + assert.match(prepared.visibleMessage, /screen shot\.png/); assert.match(prepared.codexMessage, /附件路径:/); - assert.match(prepared.codexMessage, /截图\.png \(\/tmp\/screen shot\.png\)/); - assert.match(prepared.codexMessage, /notes\.md \(\/tmp\/notes\.md\)/); + assert.doesNotMatch(prepared.codexMessage, /截图\.png/); + assert.ok(prepared.codexMessage.includes(`notes.md (${notesPath})`)); assert.match(prepared.codexMessage, /引用文件路径:/); assert.match(prepared.codexMessage, /App\.jsx \(\/repo\/client\/src\/App\.jsx\)/); assert.doesNotMatch(prepared.codexMessage, /App duplicate/); @@ -145,8 +148,9 @@ test('prepareChatRequest rejects empty text without attachments', () => { test('projectlessThreadWorkingDirectory creates dated slug directories', async () => { const mkdirCalls = []; + const projectlessRoot = path.resolve('/tmp/codex-projectless'); const cwd = await projectlessThreadWorkingDirectory( - { path: '/tmp/codex-projectless' }, + { path: projectlessRoot }, 'Hello world!', { date: new Date('2026-05-08T03:04:05.000Z'), @@ -155,6 +159,6 @@ test('projectlessThreadWorkingDirectory creates dated slug directories', async ( } ); - assert.equal(cwd, path.join('/tmp/codex-projectless', '2026-05-08', 'hello-world-21i3v9')); + assert.equal(cwd, path.join(projectlessRoot, '2026-05-08', 'hello-world-21i3v9')); assert.deepEqual(mkdirCalls, [{ dir: cwd, options: { recursive: true } }]); }); diff --git a/server/chat-routes.js b/server/chat-routes.js index caea6ce..cf8bb47 100644 --- a/server/chat-routes.js +++ b/server/chat-routes.js @@ -1,5 +1,12 @@ import { readBody, sendJson } from './http-utils.js'; +function errorPayload(error, fallback) { + return { + error: error.message || fallback, + ...(error.code ? { code: error.code } : {}) + }; +} + export function createChatRouteHandler({ chatService, remoteAddress = () => '' @@ -51,7 +58,7 @@ export function createChatRouteHandler({ const result = await chatService.steerQueuedDraft(body); sendJson(res, result ? 202 : 404, result || { error: 'Queued draft not found' }); } catch (error) { - sendJson(res, error.statusCode || 500, { error: error.message || 'Failed to steer queued draft' }); + sendJson(res, error.statusCode || 500, errorPayload(error, 'Failed to steer queued draft')); } return true; } @@ -62,7 +69,7 @@ export function createChatRouteHandler({ const result = await chatService.sendChat(body, { remoteAddress: remoteAddress(req) }); sendJson(res, 202, result); } catch (error) { - sendJson(res, error.statusCode || 500, { error: error.message || 'Failed to send chat' }); + sendJson(res, error.statusCode || 500, errorPayload(error, 'Failed to send chat')); } return true; } @@ -73,7 +80,7 @@ export function createChatRouteHandler({ const aborted = await chatService.abortChat(body, { remoteAddress: remoteAddress(req) }); sendJson(res, aborted ? 200 : 404, { aborted }); } catch (error) { - sendJson(res, error.statusCode || 500, { error: error.message || 'Failed to abort chat' }); + sendJson(res, error.statusCode || 500, errorPayload(error, 'Failed to abort chat')); } return true; } diff --git a/server/chat-service.js b/server/chat-service.js index 3bc9e47..7410c93 100644 --- a/server/chat-service.js +++ b/server/chat-service.js @@ -17,6 +17,7 @@ import { import { createChatImageHandler } from './chat-image-handler.js'; import { createChatAutoNamer } from './chat-auto-title.js'; import { createDesktopTurnMonitor } from './desktop-turn-monitor.js'; +import { normalizePermissionMode } from './permission-policy.js'; export { normalizeSelectedSkills } from './chat-request-prep.js'; @@ -48,6 +49,8 @@ export function createChatService({ registerProjectlessThread = registerProjectlessThreadInCodexState, registerMobileSession = registerMobileSessionInIndex, rememberLiveSession = () => null, + uploadRoot = '', + dangerFullAccessEnabled = false, desktopOwnerRetryDelays = [250, 700, 1500] }) { const chatQueue = createChatQueue(); @@ -211,7 +214,8 @@ export function createChatService({ const prepared = prepareChatRequest(body, { getSession, config, - defaultReasoningEffort + defaultReasoningEffort, + uploadRoot }); const { attachments, @@ -229,6 +233,7 @@ export function createChatService({ visibleMessage, codexMessage } = prepared; + const permissionModeForTurn = normalizePermissionMode(body.permissionMode, { dangerFullAccessEnabled }); let selectedSessionId = prepared.selectedSessionId; let conversationSessionId = prepared.conversationSessionId; let bridge = await assertDesktopBridgeAvailable(getDesktopBridgeStatus); @@ -269,7 +274,7 @@ export function createChatService({ model: modelForTurn, reasoningEffort: reasoningEffortForTurn, serviceTier: serviceTierForTurn, - permissionMode: body.permissionMode || 'bypassPermissions', + permissionMode: permissionModeForTurn, collaborationMode }, { forceQueued: true, autoStart: false }); return { @@ -302,7 +307,7 @@ export function createChatService({ model: modelForTurn, reasoningEffort: reasoningEffortForTurn, serviceTier: serviceTierForTurn, - permissionMode: body.permissionMode || 'bypassPermissions', + permissionMode: permissionModeForTurn, collaborationMode, getSession, rememberTurn, @@ -456,7 +461,7 @@ export function createChatService({ model: modelForTurn, reasoningEffort: reasoningEffortForTurn, serviceTier: serviceTierForTurn, - permissionMode: body.permissionMode || 'bypassPermissions', + permissionMode: permissionModeForTurn, collaborationMode }); diff --git a/server/chat-service.test.mjs b/server/chat-service.test.mjs index 5c93770..64388c6 100644 --- a/server/chat-service.test.mjs +++ b/server/chat-service.test.mjs @@ -1,4 +1,6 @@ import assert from 'node:assert/strict'; +import os from 'node:os'; +import path from 'node:path'; import test from 'node:test'; import { createChatService } from './chat-service.js'; @@ -165,6 +167,50 @@ test('sendChat sends existing desktop-ipc threads through the desktop follower b assert.equal(started.params.input[0].text, '从手机发到桌面已有线程'); }); +test('sendChat sends explicit desktop sandbox policy when switching permission modes', async () => { + const started = []; + const { service } = makeChatService({ + dangerFullAccessEnabled: true, + getDesktopBridgeStatus: async () => ({ + strict: true, + connected: true, + mode: 'desktop-ipc', + reason: null, + capabilities: { sendToOpenDesktopThread: true, createThread: false } + }), + startDesktopFollowerTurn: async (conversationId, params) => { + started.push({ conversationId, params }); + return { result: { turn: { id: `desktop-turn-${started.length}` } } }; + } + }); + + await service.sendChat({ + projectId: 'project-1', + sessionId: 'thread-1', + message: '完整模式', + permissionMode: 'bypassPermissions' + }); + await service.sendChat({ + projectId: 'project-1', + sessionId: 'thread-1', + message: '切回自动接受编辑', + permissionMode: 'acceptEdits' + }); + + assert.deepEqual(started[0].params.sandboxPolicy, { type: 'dangerFullAccess' }); + assert.equal(started[0].params.approvalPolicy, 'never'); + assert.equal(started[0].params.approvalsReviewer, 'user'); + assert.equal(started[1].params.approvalPolicy, 'never'); + assert.equal(started[1].params.approvalsReviewer, 'user'); + assert.deepEqual(started[1].params.sandboxPolicy, { + type: 'workspaceWrite', + writableRoots: ['/tmp/project'], + networkAccess: true, + excludeTmpdirEnvVar: false, + excludeSlashTmp: false + }); +}); + test('sendChat starts server-side desktop IPC monitoring after desktop handoff', async () => { const { service, broadcasts } = makeChatService({ getDesktopBridgeStatus: async () => ({ @@ -447,9 +493,16 @@ test('sendChat clears desktop collaboration mode for normal follow-up turns', as assert.equal(result.delivery, 'started'); assert.deepEqual(collaborationUpdate, { conversationId: 'thread-1', - collaborationMode: null + collaborationMode: { + mode: 'default', + settings: { + model: 'gpt-5.5', + reasoning_effort: 'xhigh', + developer_instructions: null + } + } }); - assert.equal(started.params.collaborationMode, null); + assert.deepEqual(started.params.collaborationMode, collaborationUpdate.collaborationMode); }); test('sendChat falls back to headless local when an existing desktop-ipc thread has no owner', async () => { @@ -692,6 +745,8 @@ test('sendChat reuses a background-created thread alias for later desktop-ipc se }); test('sendChat registers new projectless background threads for mobile and desktop lists', async () => { + const projectlessRoot = path.join(os.tmpdir(), 'codex-projectless'); + const lunchPath = path.join(os.tmpdir(), 'lunch.png'); let runPayload = null; let desktopRegistration = null; let mobileRegistration = null; @@ -699,7 +754,7 @@ test('sendChat registers new projectless background threads for mobile and deskt getProject: () => ({ id: '__codexmobile_projectless__', name: '普通对话', - path: '/tmp/codex-projectless', + path: projectlessRoot, projectless: true }), getDesktopBridgeStatus: async () => ({ @@ -745,23 +800,32 @@ test('sendChat registers new projectless background threads for mobile and deskt clientTurnId: 'client-turn', message: '你好呀', attachments: [ - { id: 'img-1', name: '午餐.png', path: '/tmp/lunch.png', mimeType: 'image/png', kind: 'image' } + { id: 'img-1', name: '午餐.png', path: lunchPath, mimeType: 'image/png', kind: 'image' } ] }); await flushQueuedWork(); assert.equal(result.accepted, true); assert.equal(runPayload.draftSessionId, 'draft-projectless-1'); - assert.match(runPayload.message, /图片: 午餐\.png \(\/tmp\/lunch\.png\)/); - assert.match(runPayload.projectPath, /\/tmp\/codex-projectless\/\d{4}-\d{2}-\d{2}\/mobile-chat-/); + assert.equal(runPayload.message, '你好呀'); + assert.deepEqual(runPayload.attachments, [{ + id: 'img-1', + name: '午餐.png', + size: 0, + mimeType: 'image/png', + path: lunchPath, + kind: 'image' + }]); + assert.match(path.relative(projectlessRoot, runPayload.projectPath).replace(/\\/g, '/'), /^\d{4}-\d{2}-\d{2}\/mobile-chat-/); assert.deepEqual(desktopRegistration, { threadId: 'projectless-thread-1', - workspaceRoot: '/tmp/codex-projectless' + workspaceRoot: projectlessRoot }); assert.equal(mobileRegistration.id, 'projectless-thread-1'); assert.equal(mobileRegistration.projectless, true); assert.equal(mobileRegistration.summary, '你好呀'); - assert.match(mobileRegistration.messages[0].content, /!\[午餐\.png\]\(\/tmp\/lunch\.png\)/); + assert.match(mobileRegistration.messages[0].content, /!\[午餐\.png\]\(/); + assert.match(mobileRegistration.messages[0].content, /lunch\.png/); }); test('sendChat remembers a started background thread path before broadcasting it', async () => { diff --git a/server/codex-app-server.js b/server/codex-app-server.js index 77dfd10..5e5751e 100644 --- a/server/codex-app-server.js +++ b/server/codex-app-server.js @@ -16,16 +16,69 @@ const BRIDGE_STATUS_CACHE_MS = 2500; let bridgeStatusCache = null; -function resolveCodexBinary() { +function uniqueValues(values) { + const seen = new Set(); + const result = []; + for (const value of values) { + const item = String(value || '').trim(); + const key = item.toLowerCase(); + if (!item || seen.has(key)) { + continue; + } + seen.add(key); + result.push(item); + } + return result; +} + +function pathApiForPlatform(platform) { + return platform === 'win32' ? path.win32 : path.posix; +} + +function windowsCodexCandidates(env, pathApi) { + const localAppData = env.LOCALAPPDATA || (env.USERPROFILE ? pathApi.join(env.USERPROFILE, 'AppData', 'Local') : ''); + const appData = env.APPDATA || (env.USERPROFILE ? pathApi.join(env.USERPROFILE, 'AppData', 'Roaming') : ''); + return [ + localAppData ? pathApi.join(localAppData, 'OpenAI', 'Codex', 'bin', 'codex.exe') : '', + appData ? pathApi.join(appData, 'npm', 'codex.exe') : '', + appData ? pathApi.join(appData, 'npm', 'codex.cmd') : '' + ]; +} + +function pathEnvironmentCandidates(command, env, platform, pathApi) { + const pathValue = env.Path || env.PATH || env.path || ''; + const pathDirs = String(pathValue).split(platform === 'win32' ? ';' : ':').filter(Boolean); + if (!pathDirs.length) { + return []; + } + const extensions = platform === 'win32' + ? uniqueValues(['.exe', '.cmd', '.bat', ...(env.PATHEXT || '').split(';'), '']) + : ['']; + return pathDirs.flatMap((dir) => extensions.map((ext) => pathApi.join(dir, `${command}${ext}`))); +} + +export function resolveCodexBinary({ + env = process.env, + platform = process.platform, + existsSync = fsSync.existsSync +} = {}) { + const pathApi = pathApiForPlatform(platform); + const explicit = [ + env.CODEXMOBILE_CODEX_BINARY, + env.CODEX_BINARY + ]; + const platformCandidates = platform === 'win32' + ? windowsCodexCandidates(env, pathApi) + : [DEFAULT_CODEX_APP_BINARY]; const candidates = [ - process.env.CODEXMOBILE_CODEX_BINARY, - process.env.CODEX_BINARY, - DEFAULT_CODEX_APP_BINARY, + ...explicit, + ...platformCandidates, + ...pathEnvironmentCandidates('codex', env, platform, pathApi), 'codex' ].filter(Boolean); for (const candidate of candidates) { - if (candidate === 'codex' || fsSync.existsSync(candidate)) { + if (candidate === 'codex' || existsSync(candidate)) { return candidate; } } diff --git a/server/codex-app-server.test.mjs b/server/codex-app-server.test.mjs index 18b780e..7d93657 100644 --- a/server/codex-app-server.test.mjs +++ b/server/codex-app-server.test.mjs @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; +import path from 'node:path'; import test from 'node:test'; -import { resolveAppServerTransport } from './codex-app-server.js'; +import { resolveAppServerTransport, resolveCodexBinary } from './codex-app-server.js'; test('resolveAppServerTransport is strict and unavailable without a desktop socket', () => { const transport = resolveAppServerTransport({ @@ -34,3 +35,14 @@ test('resolveAppServerTransport can use a headless local fallback when explicitl assert.equal(transport.mode, 'headless-local'); assert.match(transport.reason, /后台 Codex/); }); + +test('resolveCodexBinary prefers the Windows desktop Codex binary when present', () => { + const localAppData = path.win32.join('C:\\Users', 'Ray', 'AppData', 'Local'); + const desktopBinary = path.win32.join(localAppData, 'OpenAI', 'Codex', 'bin', 'codex.exe'); + + assert.equal(resolveCodexBinary({ + env: { LOCALAPPDATA: localAppData }, + platform: 'win32', + existsSync: (candidate) => candidate === desktopBinary + }), desktopBinary); +}); diff --git a/server/codex-quota.js b/server/codex-quota.js index fc2ec5d..af2abf4 100644 --- a/server/codex-quota.js +++ b/server/codex-quota.js @@ -16,7 +16,6 @@ const CODEX_USAGE_URL = 'https://chatgpt.com/backend-api/wham/usage'; const REQUEST_TIMEOUT_MS = Number(process.env.CODEXMOBILE_QUOTA_REQUEST_TIMEOUT_MS || 8_000); const MANAGEMENT_TIMEOUT_MS = Number(process.env.CODEXMOBILE_QUOTA_MANAGEMENT_TIMEOUT_MS || 2_500); const STALE_QUOTA_TTL_MS = Number(process.env.CODEXMOBILE_QUOTA_STALE_TTL_MS || 30 * 60_000); -const FIXED_PAIRING_CODE_FILE = path.join(process.cwd(), '.codexmobile', 'state', 'pairing-code.txt'); let lastSuccessfulQuota = null; let cachedQuotaProxyUrl = null; let quotaProxyResolved = false; @@ -128,19 +127,14 @@ async function resolveManagementKey() { for (const value of [ process.env.CODEXMOBILE_CLIPROXY_MANAGEMENT_KEY, process.env.CLIPROXYAPI_MANAGEMENT_KEY, - process.env.MANAGEMENT_PASSWORD, - process.env.CODEXMOBILE_PAIRING_CODE + process.env.MANAGEMENT_PASSWORD ]) { const trimmed = String(value || '').trim(); if (trimmed) { return trimmed; } } - try { - return (await fs.readFile(FIXED_PAIRING_CODE_FILE, 'utf8')).trim(); - } catch { - return ''; - } + return ''; } function maskAccount(value) { diff --git a/server/codex-runner.js b/server/codex-runner.js index 0d673b5..7deab52 100644 --- a/server/codex-runner.js +++ b/server/codex-runner.js @@ -6,6 +6,7 @@ import { createCodexAppServerClient, defaultServerRequestResult } from './codex- import { buildCodexTurnInput, imageMarkdownFromCodexImageGeneration } from './codex-native-images.js'; import { buildCodexLarkCliContext } from './lark-cli.js'; import { detectFeishuSkillKeys } from './feishu-skills.js'; +import { codexSandboxForPermissionMode } from './permission-policy.js'; const activeRuns = new Map(); const NON_ASCII_PATH_PATTERN = /[^\u0000-\u007F]/; @@ -81,13 +82,9 @@ async function ensureAsciiWorkingDirectory(projectPath) { } function mapPermissionMode(permissionMode) { - if (permissionMode === 'bypassPermissions') { - return { sandboxMode: 'danger-full-access', approvalPolicy: 'never' }; - } - if (permissionMode === 'acceptEdits') { - return { sandboxMode: 'workspace-write', approvalPolicy: 'never' }; - } - return { sandboxMode: 'workspace-write', approvalPolicy: 'never' }; + return codexSandboxForPermissionMode(permissionMode, { + dangerFullAccessEnabled: process.env.CODEXMOBILE_ENABLE_DANGER_FULL_ACCESS === '1' + }); } function normalizeReasoningEffort(reasoningEffort) { diff --git a/server/desktop-ipc-client.test.mjs b/server/desktop-ipc-client.test.mjs index b7fc406..b54f1f8 100644 --- a/server/desktop-ipc-client.test.mjs +++ b/server/desktop-ipc-client.test.mjs @@ -8,6 +8,22 @@ import * as desktopIpc from './desktop-ipc-client.js'; const { DesktopIpcClient, desktopIpcMethodVersion } = desktopIpc; +async function makeIpcSocketPathForTest() { + if (process.platform === 'win32') { + const suffix = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`; + return { + socketPath: `\\\\.\\pipe\\codexmobile-ipc-test-${suffix}`, + cleanup: async () => {} + }; + } + + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'codexmobile-ipc-test-')); + return { + socketPath: path.join(dir, 'ipc.sock'), + cleanup: async () => fs.rm(dir, { recursive: true, force: true }) + }; +} + test('desktop follower IPC methods use the current desktop protocol version', () => { assert.equal(desktopIpcMethodVersion('initialize'), 0); assert.equal(desktopIpcMethodVersion('thread-archived'), 2); @@ -45,8 +61,7 @@ function readFrame(socket) { } test('sendBroadcast writes desktop IPC broadcast frames', async () => { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'codexmobile-ipc-test-')); - const socketPath = path.join(dir, 'ipc.sock'); + const { socketPath, cleanup } = await makeIpcSocketPathForTest(); const server = net.createServer(); await new Promise((resolve) => server.listen(socketPath, resolve)); @@ -83,14 +98,13 @@ test('sendBroadcast writes desktop IPC broadcast frames', async () => { client.close(); server.close(); - await fs.rm(dir, { recursive: true, force: true }); + await cleanup(); }); test('broadcastDesktopThreadTitleUpdated writes desktop title update broadcast frames', async () => { assert.equal(typeof desktopIpc.broadcastDesktopThreadTitleUpdated, 'function'); - const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'codexmobile-ipc-test-')); - const socketPath = path.join(dir, 'ipc.sock'); + const { socketPath, cleanup } = await makeIpcSocketPathForTest(); const server = net.createServer(); await new Promise((resolve) => server.listen(socketPath, resolve)); @@ -124,5 +138,5 @@ test('broadcastDesktopThreadTitleUpdated writes desktop title update broadcast f }); server.close(); - await fs.rm(dir, { recursive: true, force: true }); + await cleanup(); }); diff --git a/server/git-service.test.mjs b/server/git-service.test.mjs index a173207..481913b 100644 --- a/server/git-service.test.mjs +++ b/server/git-service.test.mjs @@ -1,4 +1,5 @@ import assert from 'node:assert/strict'; +import path from 'node:path'; import test from 'node:test'; import { createGitService, @@ -213,12 +214,13 @@ test('git service creates a linked worktree from a codex branch name', async () branchName: 'mobile panel', baseBranch: 'main' }); + const expectedWorktreePath = path.join(path.dirname('/repo'), 'repo-mobile-panel'); assert.equal( - calls.includes('worktree add -b codex/mobile-panel /repo-mobile-panel main'), + calls.includes(`worktree add -b codex/mobile-panel ${expectedWorktreePath} main`), true ); assert.equal(result.branch, 'codex/mobile-panel'); - assert.equal(result.worktreePath, '/repo-mobile-panel'); + assert.equal(result.worktreePath, expectedWorktreePath); }); test('git service generates a copyable PR draft', async () => { diff --git a/server/index.js b/server/index.js index 6f6e656..4f1997c 100644 --- a/server/index.js +++ b/server/index.js @@ -5,11 +5,15 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { WebSocketServer } from 'ws'; import { - extractBearerToken, - getPairingCode, getTrustedDeviceCount, initializeAuth, - pairDevice, + listDevices, + registerSocket, + completePairingRequest, + revokeDevice, + revokeToken, + startPairingRequest, + unregisterSocket, verifyToken } from './auth.js'; import { @@ -56,21 +60,53 @@ import { createChatService } from './chat-service.js'; import { readBody, sendJson } from './http-utils.js'; import { createPushService } from './push-service.js'; import { createStaticService } from './static-service.js'; +import { readServerOptions, resolveHttpListenHost, serverOptionsHelp } from './server-options.js'; +import { + clientRemoteAddress, + isPrivateRemoteAddress, + isRequestTransportSecure, + readSecurityOptions, + requestMayUsePublicHttp, + sameOriginAllowed +} from './security-options.js'; +import { + buildAuthCookie, + clearAuthCookie, + extractRequestToken, + rejectSuspiciousFetchSite, + rejectUnsafeOrigin, + setSecurityHeaders +} from './request-security.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ROOT_DIR = path.resolve(__dirname, '..'); const CLIENT_DIST = path.join(ROOT_DIR, 'client', 'dist'); const UPLOAD_ROOT = path.join(ROOT_DIR, '.codexmobile', 'uploads'); +const DESKTOP_IMAGE_ROOT = path.join(ROOT_DIR, '.codexmobile', 'desktop-images'); const IMAGE_PROMPT_STATE = path.join(ROOT_DIR, '.codexmobile', 'state', 'image-prompts.json'); const FEISHU_AUTH_STATE = path.join(ROOT_DIR, '.codexmobile', 'state', 'feishu-auth.json'); const PUSH_STATE = path.join(ROOT_DIR, '.codexmobile', 'state', 'push-notifications.json'); -const PORT = Number(process.env.PORT || 3321); -const HOST = process.env.HOST || '0.0.0.0'; -const HTTPS_PORT = Number(process.env.HTTPS_PORT || 3443); +let serverOptions = null; +try { + serverOptions = readServerOptions(); +} catch (error) { + console.error(`[server] ${error.message}`); + console.error(serverOptionsHelp()); + process.exit(1); +} +if (serverOptions.help) { + console.log(serverOptionsHelp()); + process.exit(0); +} +const PORT = serverOptions.port; +const HOST = serverOptions.host; +const HTTPS_PORT = serverOptions.httpsPort; +let actualHttpHost = HOST; const HTTPS_PFX_PATH = process.env.HTTPS_PFX_PATH || path.join(ROOT_DIR, '.codexmobile', 'tls', 'server.pfx'); const HTTPS_ROOT_CA_PATH = process.env.HTTPS_ROOT_CA_PATH || path.join(ROOT_DIR, '.codexmobile', 'tls', 'codexmobile-root-ca.cer'); const HTTPS_PFX_PASSPHRASE = process.env.HTTPS_PFX_PASSPHRASE || 'codexmobile-local-https'; const PUBLIC_URL = process.env.CODEXMOBILE_PUBLIC_URL || ''; +const APP_VERSION = process.env.npm_package_version || '1.2.0'; const FEISHU_APP_ID = String(process.env.CODEXMOBILE_FEISHU_APP_ID || '').trim(); const FEISHU_APP_SECRET = String(process.env.CODEXMOBILE_FEISHU_APP_SECRET || '').trim(); const FEISHU_REDIRECT_URI = String(process.env.CODEXMOBILE_FEISHU_REDIRECT_URI || '').trim(); @@ -80,12 +116,15 @@ const MAX_UPLOAD_BYTES = 50 * 1024 * 1024; const MAX_VOICE_BYTES = 10 * 1024 * 1024; const DEFAULT_REASONING_EFFORT = 'xhigh'; const SYNC_RESPONSE_TIMEOUT_MS = Math.max(1000, Number(process.env.CODEXMOBILE_SYNC_RESPONSE_TIMEOUT_MS) || 12_000); +const securityOptions = withLocalAllowedOrigins(readSecurityOptions()); let syncRefreshPromise = null; const sockets = new Set(); const staticService = createStaticService({ clientDist: CLIENT_DIST, generatedRoot: GENERATED_ROOT, + desktopImageRoot: DESKTOP_IMAGE_ROOT, + uploadRoot: UPLOAD_ROOT, httpsRootCaPath: HTTPS_ROOT_CA_PATH }); const gitService = createGitService({ getProject }); @@ -108,6 +147,36 @@ const feishuIntegration = createFeishuIntegration({ }); let statusConfigFallback = null; +function listen(serverToStart, port, host) { + return new Promise((resolve, reject) => { + const onError = (error) => { + serverToStart.off('listening', onListening); + reject(error); + }; + const onListening = () => { + serverToStart.off('error', onError); + resolve(); + }; + serverToStart.once('error', onError); + serverToStart.once('listening', onListening); + serverToStart.listen(port, host); + }); +} + +function closeServer(serverToClose) { + return new Promise((resolve) => { + if (!serverToClose) { + resolve(); + return; + } + try { + serverToClose.close(() => resolve()); + } catch { + resolve(); + } + }); +} + async function getStatusConfigFallback() { if (!statusConfigFallback) { statusConfigFallback = readCodexConfig().catch((error) => { @@ -123,23 +192,79 @@ function fallbackModels(config) { return [{ value: model, label: model }]; } +function withLocalAllowedOrigins(options) { + const localOrigins = [ + `http://127.0.0.1:${PORT}`, + `http://localhost:${PORT}`, + `https://127.0.0.1:${HTTPS_PORT}`, + `https://localhost:${HTTPS_PORT}` + ]; + return { + ...options, + allowedOrigins: [...new Set([...(options.allowedOrigins || []), ...localOrigins].filter(Boolean))] + }; +} + function requestOrigin(req) { - const forwardedProto = String(req.headers['x-forwarded-proto'] || '').split(',')[0].trim(); - const proto = forwardedProto || (req.socket.encrypted ? 'https' : 'http'); - const host = req.headers['x-forwarded-host'] || req.headers.host || `127.0.0.1:${PORT}`; + const proto = isRequestTransportSecure(req, securityOptions) ? 'https' : 'http'; + const host = req.headers.host || `127.0.0.1:${PORT}`; return `${proto}://${String(host).split(',')[0].trim()}`; } +function requestHostname(req) { + const host = String(req.headers.host || '').split(',')[0].trim(); + if (!host) { + return ''; + } + try { + return new URL(`http://${host}`).hostname; + } catch { + return host.replace(/^\[/, '').replace(/\]$/, '').split(':')[0]; + } +} + +function securityOptionsForRequest(req) { + const allowedOrigins = new Set(securityOptions.allowedOrigins || []); + if (isPrivateRemoteAddress(requestHostname(req), securityOptions)) { + allowedOrigins.add(requestOrigin(req)); + } + return { + ...securityOptions, + allowedOrigins: [...allowedOrigins] + }; +} + function remoteAddress(req) { - return req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.socket.remoteAddress || ''; + return clientRemoteAddress(req, securityOptions); } -async function isAuthenticated(req, url = null) { - return verifyToken(extractBearerToken(req, url), { remoteAddress: remoteAddress(req) }); +function requestToken(req) { + return extractRequestToken(req, { allowBearer: securityOptions.legacyBearerEnabled }); } -async function requireAuth(req, res, pathname = '', url = null) { - if (await isAuthenticated(req, url)) { +async function authenticateRequest(req, res = null, { rotate = true } = {}) { + const result = await verifyToken(requestToken(req), { + remoteAddress: remoteAddress(req), + userAgent: req.headers['user-agent'], + securityOptions, + rotate + }); + if (res && result?.ok === true && result.replacementToken) { + res.setHeader('set-cookie', buildAuthCookie(result.replacementToken, { + secure: isRequestTransportSecure(req, securityOptions), + maxAgeSeconds: Math.floor(securityOptions.tokenTtlMs / 1000) + })); + } + return result; +} + +async function isAuthenticated(req, res = null) { + const result = await authenticateRequest(req, res); + return result === true || result?.ok === true; +} + +async function requireAuth(req, res, pathname = '') { + if (await isAuthenticated(req, res)) { return true; } if ((req.method || 'GET') !== 'GET') { @@ -186,7 +311,9 @@ const chatService = createChatService({ isImageRequest, useLegacyImageGenerator, maybeAutoNameSession, - rememberLiveSession + rememberLiveSession, + uploadRoot: UPLOAD_ROOT, + dangerFullAccessEnabled: securityOptions.dangerFullAccessEnabled }); const handleNotificationApi = createNotificationRouteHandler({ pushService, @@ -263,7 +390,49 @@ async function refreshCodexCacheForSyncResponse() { return result; } -async function publicStatus(authenticated) { +function publicSecurityStatus(req = null) { + return { + publicAccess: securityOptions.publicAccess, + dangerFullAccessEnabled: securityOptions.dangerFullAccessEnabled, + httpsEnabled: req ? isRequestTransportSecure(req, securityOptions) : false, + pairing: { + lanOnly: !securityOptions.allowRemotePairing + } + }; +} + +function securityPosture(req = null) { + const secure = req ? isRequestTransportSecure(req, securityOptions) : false; + return { + publicAccess: securityOptions.publicAccess, + dangerFullAccessEnabled: securityOptions.dangerFullAccessEnabled, + httpsActive: secure, + hstsEnabled: secure, + cspReportOnly: process.env.CODEXMOBILE_CSP_REPORT_ONLY === '1', + trustedProxyCount: securityOptions.trustedProxyCidrs.length, + privateCidrsConfigured: securityOptions.privateCidrs.length, + remotePairingAllowed: securityOptions.allowRemotePairing, + httpListenHost: actualHttpHost, + httpsPort: HTTPS_PORT + }; +} + +function canPairFromRequest(req) { + return isPrivateRemoteAddress(remoteAddress(req), securityOptions) || securityOptions.allowRemotePairing; +} + +async function publicStatus(authenticated, req = null) { + const auth = { + required: true, + authenticated, + trustedDevices: authenticated ? getTrustedDeviceCount() : 0, + canPair: req ? canPairFromRequest(req) : false + }; + const security = publicSecurityStatus(req); + if (!authenticated) { + return { auth, security, version: APP_VERSION }; + } + const snapshot = getCacheSnapshot(); const config = snapshot.config || await getStatusConfigFallback() || {}; const desktopBridge = await getDesktopBridgeStatus(); @@ -285,11 +454,9 @@ async function publicStatus(authenticated) { docs: await feishuIntegration.publicDocsStatus(authenticated), syncedAt: snapshot.syncedAt, activeRuns: [...getActiveRuns(), ...chatService.getActiveDesktopIpcRuns(), ...chatService.getActiveImageRuns()], - auth: { - required: true, - authenticated, - trustedDevices: getTrustedDeviceCount() - } + security, + auth, + version: APP_VERSION }; } @@ -298,23 +465,64 @@ async function handleApi(req, res, url) { const pathname = url.pathname; if (method === 'GET' && pathname === '/api/status') { - sendJson(res, 200, await publicStatus(await isAuthenticated(req, url))); + const authResult = await authenticateRequest(req, res); + sendJson(res, 200, await publicStatus(authResult === true || authResult?.ok === true, req)); + return; + } + + if (method === 'GET' && pathname === '/api/security/posture') { + sendJson(res, 200, securityPosture(req)); + return; + } + + if (method === 'POST' && pathname === '/api/pair/request') { + const body = await readBody(req); + const requested = await startPairingRequest({ + deviceName: body.deviceName || 'iPhone', + userAgent: req.headers['user-agent'], + remoteAddress: remoteAddress(req), + securityOptions + }); + if (!requested.ok) { + sendJson(res, requested.statusCode, { + error: requested.error, + retryAfterSeconds: requested.retryAfterSeconds || null + }); + return; + } + sendJson(res, 200, { + requestId: requested.requestId, + expiresAt: requested.expiresAt, + codeLength: requested.codeLength, + requestCooldownSeconds: requested.requestCooldownSeconds || 0 + }); return; } if (method === 'POST' && pathname === '/api/pair') { const body = await readBody(req); - const paired = await pairDevice({ + if (!body.requestId) { + sendJson(res, 400, { error: 'Pairing request is required' }); + return; + } + const paired = await completePairingRequest({ + requestId: body.requestId, code: body.code, - deviceName: body.deviceName, - userAgent: req.headers['user-agent'], - remoteAddress: remoteAddress(req) + remoteAddress: remoteAddress(req), + securityOptions }); - if (!paired) { - sendJson(res, 403, { error: 'Invalid pairing code' }); + if (!paired || paired.ok === false) { + sendJson(res, paired?.statusCode || 403, { + error: paired?.error || 'Invalid pairing code', + retryAfterSeconds: paired?.retryAfterSeconds || null + }); return; } - sendJson(res, 200, paired); + res.setHeader('set-cookie', buildAuthCookie(paired.token, { + secure: isRequestTransportSecure(req, securityOptions), + maxAgeSeconds: Math.floor(securityOptions.tokenTtlMs / 1000) + })); + sendJson(res, 200, { device: paired.device }); return; } @@ -323,7 +531,45 @@ async function handleApi(req, res, url) { return; } - if (!(await requireAuth(req, res, pathname, url))) { + if (!(await requireAuth(req, res, pathname))) { + return; + } + + if (method === 'GET' && pathname === '/api/devices') { + sendJson(res, 200, { devices: listDevices({ currentToken: requestToken(req) }) }); + return; + } + + if (method === 'POST' && pathname === '/api/logout') { + const token = requestToken(req); + if (token) { + await revokeToken(token); + } + res.setHeader('set-cookie', clearAuthCookie({ secure: isRequestTransportSecure(req, securityOptions) })); + sendJson(res, 200, { success: true }); + return; + } + + const parts = pathname.split('/').filter(Boolean); + + if (method === 'POST' && parts.length === 4 && parts[0] === 'api' && parts[1] === 'devices' && parts[3] === 'revoke') { + const token = requestToken(req); + const deviceId = decodeURIComponent(parts[2]); + const devicesBefore = listDevices({ currentToken: token }); + const currentRevoked = devicesBefore.some((device) => device.id === deviceId && device.current); + const revoked = await revokeDevice(deviceId); + if (!revoked.ok) { + sendJson(res, 404, { error: 'Device not found' }); + return; + } + if (currentRevoked) { + res.setHeader('set-cookie', clearAuthCookie({ secure: isRequestTransportSecure(req, securityOptions) })); + } + sendJson(res, 200, { + success: true, + currentRevoked, + devices: currentRevoked ? [] : listDevices({ currentToken: token }) + }); return; } @@ -381,6 +627,26 @@ async function handleApi(req, res, url) { async function requestHandler(req, res) { const url = new URL(req.url || '/', `http://${req.headers.host || `127.0.0.1:${PORT}`}`); try { + setSecurityHeaders(res, { + secure: isRequestTransportSecure(req, securityOptions), + cspReportOnly: process.env.CODEXMOBILE_CSP_REPORT_ONLY === '1' + }); + if (!requestMayUsePublicHttp(req, securityOptions)) { + sendJson(res, 403, { error: 'Public access requires HTTPS' }); + return; + } + const fetchSiteRejection = rejectSuspiciousFetchSite(req, { + protectSafeMethod: url.pathname.startsWith('/api/') + }); + if (fetchSiteRejection) { + sendJson(res, fetchSiteRejection.statusCode, { error: fetchSiteRejection.error }); + return; + } + const originRejection = rejectUnsafeOrigin(req, securityOptionsForRequest(req)); + if (originRejection) { + sendJson(res, originRejection.statusCode, { error: originRejection.error }); + return; + } if (url.pathname.startsWith('/api/')) { await handleApi(req, res, url); return; @@ -408,59 +674,97 @@ async function main() { return; } - const token = url.searchParams.get('token') || ''; - const ok = await verifyToken(token, { remoteAddress: remoteAddress(req) }); - if (!ok) { + if (!requestMayUsePublicHttp(req, securityOptions)) { + socket.write('HTTP/1.1 403 Forbidden\r\n\r\n'); + socket.destroy(); + return; + } + const origin = String(req.headers.origin || '').trim(); + if (!sameOriginAllowed(origin, securityOptionsForRequest(req))) { + socket.write('HTTP/1.1 403 Forbidden\r\n\r\n'); + socket.destroy(); + return; + } + + const authResult = await verifyToken(requestToken(req), { + remoteAddress: remoteAddress(req), + userAgent: req.headers['user-agent'], + securityOptions, + rotate: false + }); + if (!(authResult === true || authResult?.ok === true)) { socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n'); socket.destroy(); return; } + const tokenHash = authResult?.tokenHash || ''; if (url.pathname === '/ws/realtime') { realtimeWss.handleUpgrade(req, socket, head, (ws) => { + registerSocket(tokenHash, ws); + ws.on('close', () => unregisterSocket(tokenHash, ws)); startVoiceRealtimeProxy(ws, { remoteAddress: remoteAddress(req) }); }); return; } wss.handleUpgrade(req, socket, head, async (ws) => { + registerSocket(tokenHash, ws); sockets.add(ws); - ws.on('close', () => sockets.delete(ws)); - ws.send(JSON.stringify({ type: 'connected', status: await publicStatus(true) })); + ws.on('close', () => { + unregisterSocket(tokenHash, ws); + sockets.delete(ws); + }); + ws.send(JSON.stringify({ type: 'connected', status: await publicStatus(true, req) })); }); }; server.on('upgrade', handleUpgrade); - server.listen(PORT, HOST, () => { - console.log(`CodexMobile listening on http://${HOST}:${PORT}`); - console.log(`Pairing code: ${getPairingCode()} (${auth.trustedDevices} trusted device(s)${auth.fixedPairingCode ? ', fixed' : ''})`); - console.log('Use Tailscale and open http://:3321 on iPhone.'); - }); - refreshCodexCache().catch((error) => { console.warn('[server] Initial sync failed:', error.message); }); + let httpsStarted = false; + let httpsServer = null; try { const pfx = await fs.readFile(HTTPS_PFX_PATH); - const httpsServer = https.createServer({ pfx, passphrase: HTTPS_PFX_PASSPHRASE }, requestHandler); + httpsServer = https.createServer({ pfx, passphrase: HTTPS_PFX_PASSPHRASE }, requestHandler); httpsServer.on('upgrade', handleUpgrade); - httpsServer.listen(HTTPS_PORT, HOST, () => { - console.log(`CodexMobile HTTPS listening on https://${HOST}:${HTTPS_PORT}`); - if (PUBLIC_URL) { - console.log(`Public/private URL: ${PUBLIC_URL}`); - } else { - console.log(`Use Tailscale HTTPS: https://..ts.net:${HTTPS_PORT}/`); - } - }); + await listen(httpsServer, HTTPS_PORT, HOST); + httpsStarted = true; + console.log(`CodexMobile HTTPS listening on https://${HOST}:${HTTPS_PORT}`); + if (PUBLIC_URL) { + console.log(`Public/private URL: ${PUBLIC_URL}`); + } else { + console.log(`Use Tailscale HTTPS: https://..ts.net:${HTTPS_PORT}/`); + } } catch (error) { if (error.code === 'ENOENT') { console.log(`CodexMobile HTTPS disabled: certificate not found at ${HTTPS_PFX_PATH}`); } else { console.warn(`[server] Failed to start HTTPS listener: ${error.message}`); } + httpsServer = null; + } + + const httpHost = resolveHttpListenHost({ + publicAccess: securityOptions.publicAccess, + httpsStarted, + host: HOST + }); + try { + await listen(server, PORT, httpHost); + actualHttpHost = httpHost; + } catch (error) { + if (httpsStarted && httpsServer) { + await closeServer(httpsServer); + } + throw error; } + console.log(`CodexMobile listening on http://${httpHost}:${PORT}`); + console.log(`Pairing: open CodexMobile from the same LAN, then click "请求配对码" to print a one-time console code (${auth.trustedDevices} trusted device(s)).`); + console.log(`Use Tailscale and open http://:${PORT} on iPhone.`); } main().catch((error) => { diff --git a/server/integration.test.mjs b/server/integration.test.mjs new file mode 100644 index 0000000..c0a6551 --- /dev/null +++ b/server/integration.test.mjs @@ -0,0 +1,391 @@ +import assert from 'node:assert/strict'; +import crypto from 'node:crypto'; +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import fs from 'node:fs/promises'; +import http from 'node:http'; +import net from 'node:net'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import WebSocket from 'ws'; + +async function getFreePort() { + return await new Promise((resolve, reject) => { + const server = net.createServer(); + server.on('error', reject); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + const port = address && typeof address === 'object' ? address.port : 0; + server.close(() => resolve(port)); + }); + }); +} + +function waitForOutput(child, pattern) { + return new Promise((resolve, reject) => { + let output = ''; + const timer = setTimeout(() => { + cleanup(); + reject(new Error(`Timed out waiting for server output: ${pattern}`)); + }, 15_000); + + const onData = (chunk) => { + output += chunk.toString('utf8'); + if (output.includes(pattern)) { + cleanup(); + resolve(output); + } + }; + const onExit = (code, signal) => { + cleanup(); + reject(new Error(`Server exited before ready: code=${code} signal=${signal} output=${output}`)); + }; + const cleanup = () => { + clearTimeout(timer); + child.stdout.off('data', onData); + child.stderr.off('data', onData); + child.off('exit', onExit); + }; + + child.stdout.on('data', onData); + child.stderr.on('data', onData); + child.on('exit', onExit); + }); +} + +async function stopChild(child) { + if (child.exitCode !== null || child.signalCode !== null) { + return; + } + child.kill(); + await Promise.race([ + once(child, 'close'), + once(child, 'exit'), + new Promise((resolve) => setTimeout(resolve, 1000)) + ]); +} + +function requestJson({ port, method = 'POST', path = '/api/chat/send', headers, body = '' }) { + return new Promise((resolve, reject) => { + const req = http.request({ + host: '127.0.0.1', + port, + path, + method, + headers + }, (res) => { + let responseBody = ''; + res.setEncoding('utf8'); + res.on('data', (chunk) => { + responseBody += chunk; + }); + res.on('end', () => { + let json = {}; + try { + json = responseBody ? JSON.parse(responseBody) : {}; + } catch { + json = {}; + } + resolve({ statusCode: res.statusCode, headers: res.headers, body: responseBody, json }); + }); + }); + req.on('error', reject); + req.end(body); + }); +} + +function hashToken(token) { + return crypto.createHash('sha256').update(token).digest('hex'); +} + +test('chat POST rejects Host-derived cross-origin requests that are not configured', async (t) => { + const port = await getFreePort(); + const httpsPort = await getFreePort(); + const home = path.join(os.tmpdir(), `codexmobile-integration-${process.pid}-${Date.now()}`); + const missingPfx = path.join(home, 'missing-server.pfx'); + const child = spawn(process.execPath, [ + 'server/index.js', + '--host', + '127.0.0.1', + '--port', + String(port), + '--https-port', + String(httpsPort) + ], { + cwd: path.resolve(import.meta.dirname, '..'), + env: { + ...process.env, + CODEXMOBILE_HOME: home, + CODEXMOBILE_PUBLIC_ACCESS: '0', + HTTPS_PFX_PATH: missingPfx + }, + stdio: ['ignore', 'pipe', 'pipe'] + }); + t.after(async () => { + await stopChild(child); + await fs.rm(home, { recursive: true, force: true }); + }); + + await waitForOutput(child, `CodexMobile listening on http://127.0.0.1:${port}`); + + for (const origin of ['http://evil.com', 'https://evil.com']) { + const response = await requestJson({ + port, + headers: { + host: 'evil.com', + origin, + 'content-type': 'application/json' + }, + body: '{}' + }); + + assert.equal(response.statusCode, 403, origin); + assert.match(response.body, /Cross-origin request rejected/, origin); + } +}); + +test('pair request allows local HTTPS localhost origin', async (t) => { + const port = await getFreePort(); + const httpsPort = await getFreePort(); + const home = path.join(os.tmpdir(), `codexmobile-integration-${process.pid}-${Date.now()}-localhost`); + const missingPfx = path.join(home, 'missing-server.pfx'); + const child = spawn(process.execPath, [ + 'server/index.js', + '--host', + '127.0.0.1', + '--port', + String(port), + '--https-port', + String(httpsPort) + ], { + cwd: path.resolve(import.meta.dirname, '..'), + env: { + ...process.env, + CODEXMOBILE_HOME: home, + CODEXMOBILE_PUBLIC_ACCESS: '0', + HTTPS_PFX_PATH: missingPfx + }, + stdio: ['ignore', 'pipe', 'pipe'] + }); + let output = ''; + const captureOutput = (chunk) => { + output += chunk.toString('utf8'); + }; + child.stdout.on('data', captureOutput); + child.stderr.on('data', captureOutput); + + t.after(async () => { + child.stdout.off('data', captureOutput); + child.stderr.off('data', captureOutput); + await stopChild(child); + await fs.rm(home, { recursive: true, force: true }); + }); + + await waitForOutput(child, `CodexMobile listening on http://127.0.0.1:${port}`); + + const response = await requestJson({ + port, + path: '/api/pair/request', + headers: { + host: `127.0.0.1:${port}`, + origin: `https://localhost:${httpsPort}`, + 'content-type': 'application/json' + }, + body: JSON.stringify({ deviceName: 'Integration Test' }) + }); + + assert.equal(response.statusCode, 200); + assert.match(output, /\[pairing\] code hidden because stdout is not an interactive terminal/); + assert.doesNotMatch(output, /\[pairing\] code=[A-Z2-9]{10}/); +}); + +test('pair request and websocket allow same private host origin', async (t) => { + const port = await getFreePort(); + const httpsPort = await getFreePort(); + const home = path.join(os.tmpdir(), `codexmobile-integration-${process.pid}-${Date.now()}-private-origin`); + const missingPfx = path.join(home, 'missing-server.pfx'); + const token = 'private-origin-token'; + const tokenHash = hashToken(token); + const now = new Date().toISOString(); + await fs.mkdir(home, { recursive: true }); + await fs.writeFile(path.join(home, 'auth-state.json'), JSON.stringify({ + devices: [{ + id: 'private-origin-device', + name: 'Private Origin Device', + tokenHash, + tokens: [{ + hash: tokenHash, + createdAt: now, + expiresAt: '2999-01-01T00:00:00.000Z', + supersededAt: null + }], + createdAt: now, + expiresAt: '2999-01-01T00:00:00.000Z', + revokedAt: null, + lastSeenAt: now + }] + }, null, 2), 'utf8'); + + const child = spawn(process.execPath, [ + 'server/index.js', + '--host', + '127.0.0.1', + '--port', + String(port), + '--https-port', + String(httpsPort) + ], { + cwd: path.resolve(import.meta.dirname, '..'), + env: { + ...process.env, + CODEXMOBILE_HOME: home, + CODEXMOBILE_PUBLIC_ACCESS: '0', + HTTPS_PFX_PATH: missingPfx + }, + stdio: ['ignore', 'pipe', 'pipe'] + }); + t.after(async () => { + await stopChild(child); + await fs.rm(home, { recursive: true, force: true }); + }); + + await waitForOutput(child, `CodexMobile listening on http://127.0.0.1:${port}`); + const privateHost = `192.168.1.50:${port}`; + const privateOrigin = `http://${privateHost}`; + + const pairResponse = await requestJson({ + port, + path: '/api/pair/request', + headers: { + host: privateHost, + origin: privateOrigin, + 'content-type': 'application/json' + }, + body: JSON.stringify({ deviceName: 'Private Host Test' }) + }); + assert.equal(pairResponse.statusCode, 200); + + const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`, { + headers: { + host: privateHost, + origin: privateOrigin, + cookie: `codexmobile_token=${token}` + } + }); + t.after(() => ws.close()); + + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('Private host websocket did not open')), 1000); + ws.once('open', () => { + clearTimeout(timer); + resolve(); + }); + ws.once('error', (error) => { + clearTimeout(timer); + reject(error); + }); + ws.once('unexpected-response', (_request, response) => { + clearTimeout(timer); + reject(new Error(`Unexpected websocket response: ${response.statusCode}`)); + }); + }); +}); + +test('websocket uses cookie auth and closes after current device revoke', async (t) => { + const port = await getFreePort(); + const httpsPort = await getFreePort(); + const home = path.join(os.tmpdir(), `codexmobile-integration-${process.pid}-${Date.now()}-ws`); + const missingPfx = path.join(home, 'missing-server.pfx'); + const token = 'integration-cookie-token'; + const tokenHash = hashToken(token); + const now = new Date().toISOString(); + await fs.mkdir(home, { recursive: true }); + await fs.writeFile(path.join(home, 'auth-state.json'), JSON.stringify({ + devices: [{ + id: 'integration-device', + name: 'Integration Device', + tokenHash, + tokens: [{ + hash: tokenHash, + createdAt: now, + expiresAt: '2999-01-01T00:00:00.000Z', + supersededAt: null + }], + createdAt: now, + expiresAt: '2999-01-01T00:00:00.000Z', + revokedAt: null, + lastSeenAt: now + }] + }, null, 2), 'utf8'); + + const child = spawn(process.execPath, [ + 'server/index.js', + '--host', + '127.0.0.1', + '--port', + String(port), + '--https-port', + String(httpsPort) + ], { + cwd: path.resolve(import.meta.dirname, '..'), + env: { + ...process.env, + CODEXMOBILE_HOME: home, + CODEXMOBILE_PUBLIC_ACCESS: '0', + HTTPS_PFX_PATH: missingPfx + }, + stdio: ['ignore', 'pipe', 'pipe'] + }); + t.after(async () => { + await stopChild(child); + await fs.rm(home, { recursive: true, force: true }); + }); + + await waitForOutput(child, `CodexMobile listening on http://127.0.0.1:${port}`); + const origin = `http://127.0.0.1:${port}`; + const cookie = `codexmobile_token=${token}`; + const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`, { + headers: { cookie, origin } + }); + t.after(() => ws.close()); + + await new Promise((resolve, reject) => { + ws.once('open', resolve); + ws.once('error', reject); + }); + + const devices = await requestJson({ + port, + method: 'GET', + path: '/api/devices', + headers: { cookie } + }); + assert.equal(devices.statusCode, 200); + assert.equal(devices.json.devices?.[0]?.current, true); + + const closed = new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('WebSocket was not closed after revoke')), 1000); + ws.once('close', (code, reason) => { + clearTimeout(timer); + resolve({ code, reason: reason.toString() }); + }); + }); + + const revoked = await requestJson({ + port, + method: 'POST', + path: '/api/devices/integration-device/revoke', + headers: { + cookie, + origin, + 'content-type': 'application/json' + }, + body: '{}' + }); + assert.equal(revoked.statusCode, 200); + + const close = await closed; + assert.equal(close.code, 1008); + assert.equal(close.reason, 'revoked'); +}); diff --git a/server/permission-policy.js b/server/permission-policy.js new file mode 100644 index 0000000..a7d74a5 --- /dev/null +++ b/server/permission-policy.js @@ -0,0 +1,68 @@ +export function dangerFullAccessDisabledError() { + const error = new Error('danger-full-access is disabled on this server'); + error.statusCode = 403; + error.code = 'CODEXMOBILE_DANGER_FULL_ACCESS_DISABLED'; + return error; +} + +export function normalizePermissionMode(permissionMode, { dangerFullAccessEnabled = false } = {}) { + const value = String(permissionMode || '').trim(); + if (value === 'bypassPermissions') { + if (!dangerFullAccessEnabled) { + throw dangerFullAccessDisabledError(); + } + return 'bypassPermissions'; + } + if (value === 'acceptEdits') { + return 'acceptEdits'; + } + return 'default'; +} + +export function codexSandboxForPermissionMode(permissionMode, options = {}) { + const normalized = normalizePermissionMode(permissionMode, options); + if (normalized === 'bypassPermissions') { + return { sandboxMode: 'danger-full-access', approvalPolicy: 'never' }; + } + return { sandboxMode: 'workspace-write', approvalPolicy: 'never' }; +} + +export function desktopSandboxPolicyForPermissionMode(permissionMode, options = {}) { + const normalized = normalizePermissionMode(permissionMode, options); + if (normalized === 'bypassPermissions') { + return { type: 'dangerFullAccess' }; + } + const writableRoots = Array.isArray(options.writableRoots) + ? [...new Set(options.writableRoots.filter(Boolean).map((entry) => String(entry)))] + : []; + return { + type: 'workspaceWrite', + writableRoots, + networkAccess: options.networkAccess !== false, + excludeTmpdirEnvVar: false, + excludeSlashTmp: false + }; +} + +export function desktopTurnPermissionsForPermissionMode(permissionMode, options = {}) { + const normalized = normalizePermissionMode(permissionMode, options); + if (normalized === 'bypassPermissions') { + return { + approvalPolicy: 'never', + approvalsReviewer: 'user', + sandboxPolicy: { type: 'dangerFullAccess' } + }; + } + if (normalized === 'acceptEdits') { + return { + approvalPolicy: 'never', + approvalsReviewer: 'user', + sandboxPolicy: desktopSandboxPolicyForPermissionMode(normalized, options) + }; + } + return { + approvalPolicy: 'on-request', + approvalsReviewer: 'guardian_subagent', + sandboxPolicy: desktopSandboxPolicyForPermissionMode(normalized, options) + }; +} diff --git a/server/permission-policy.test.mjs b/server/permission-policy.test.mjs new file mode 100644 index 0000000..1845e51 --- /dev/null +++ b/server/permission-policy.test.mjs @@ -0,0 +1,83 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + codexSandboxForPermissionMode, + desktopSandboxPolicyForPermissionMode, + desktopTurnPermissionsForPermissionMode, + normalizePermissionMode +} from './permission-policy.js'; + +test('bypassPermissions is rejected unless danger full access is explicitly enabled', () => { + assert.throws( + () => normalizePermissionMode('bypassPermissions'), + /danger-full-access is disabled/ + ); + assert.equal(normalizePermissionMode('bypassPermissions', { dangerFullAccessEnabled: true }), 'bypassPermissions'); +}); + +test('unknown permission modes fall back to workspace-write defaults', () => { + assert.equal(normalizePermissionMode('unknown'), 'default'); + assert.deepEqual(codexSandboxForPermissionMode('unknown'), { + sandboxMode: 'workspace-write', + approvalPolicy: 'never' + }); +}); + +test('desktop sandbox policy explicitly switches between workspace-write and danger full access', () => { + const workspacePolicy = { + type: 'workspaceWrite', + writableRoots: ['D:\\Git\\CodexMobile'], + networkAccess: true, + excludeTmpdirEnvVar: false, + excludeSlashTmp: false + }; + assert.deepEqual(desktopSandboxPolicyForPermissionMode('acceptEdits', { + writableRoots: ['D:\\Git\\CodexMobile'], + networkAccess: true + }), workspacePolicy); + assert.deepEqual(desktopSandboxPolicyForPermissionMode('default', { + writableRoots: ['D:\\Git\\CodexMobile'], + networkAccess: true + }), workspacePolicy); + assert.deepEqual(desktopSandboxPolicyForPermissionMode('bypassPermissions', { dangerFullAccessEnabled: true }), { + type: 'dangerFullAccess' + }); +}); + +test('desktop turn permissions match Codex Desktop non-full and full access modes', () => { + assert.deepEqual(desktopTurnPermissionsForPermissionMode('acceptEdits', { + writableRoots: ['D:\\Git\\CodexMobile'] + }), { + approvalPolicy: 'never', + approvalsReviewer: 'user', + sandboxPolicy: { + type: 'workspaceWrite', + writableRoots: ['D:\\Git\\CodexMobile'], + networkAccess: true, + excludeTmpdirEnvVar: false, + excludeSlashTmp: false + } + }); + assert.deepEqual(desktopTurnPermissionsForPermissionMode('default', { + writableRoots: ['D:\\Git\\CodexMobile'] + }), { + approvalPolicy: 'on-request', + approvalsReviewer: 'guardian_subagent', + sandboxPolicy: { + type: 'workspaceWrite', + writableRoots: ['D:\\Git\\CodexMobile'], + networkAccess: true, + excludeTmpdirEnvVar: false, + excludeSlashTmp: false + } + }); + assert.deepEqual(desktopTurnPermissionsForPermissionMode('bypassPermissions', { + dangerFullAccessEnabled: true, + writableRoots: ['D:\\Git\\CodexMobile'] + }), { + approvalPolicy: 'never', + approvalsReviewer: 'user', + sandboxPolicy: { type: 'dangerFullAccess' } + }); +}); diff --git a/server/request-security.js b/server/request-security.js new file mode 100644 index 0000000..5989738 --- /dev/null +++ b/server/request-security.js @@ -0,0 +1,115 @@ +const AUTH_COOKIE = 'codexmobile_token'; + +function safeDecodeCookieValue(value) { + try { + return decodeURIComponent(value); + } catch { + return null; + } +} + +export function parseCookies(header = '') { + const result = {}; + for (const part of String(header || '').split(';')) { + const index = part.indexOf('='); + if (index < 0) { + continue; + } + const key = part.slice(0, index).trim(); + const value = part.slice(index + 1).trim(); + if (key) { + const decoded = safeDecodeCookieValue(value); + if (decoded !== null) { + result[key] = decoded; + } + } + } + return result; +} + +export function extractCookieToken(req) { + return parseCookies(req.headers?.cookie || '')[AUTH_COOKIE] || ''; +} + +export function extractRequestToken(req, { allowBearer = false } = {}) { + const cookieToken = extractCookieToken(req); + if (cookieToken || !allowBearer) { + return cookieToken; + } + const header = String(req.headers?.authorization || '').trim(); + const match = header.match(/^Bearer\s+(.+)$/i); + return match ? match[1].trim() : ''; +} + +export function buildAuthCookie(token, { secure = false, maxAgeSeconds } = {}) { + const parts = [ + `${AUTH_COOKIE}=${encodeURIComponent(token)}`, + 'Path=/', + 'HttpOnly', + 'SameSite=Strict' + ]; + if (secure) { + parts.push('Secure'); + } + if (Number.isFinite(maxAgeSeconds)) { + parts.push(`Max-Age=${Math.max(0, Math.floor(maxAgeSeconds))}`); + } + return parts.join('; '); +} + +export function clearAuthCookie({ secure = false } = {}) { + return buildAuthCookie('', { secure, maxAgeSeconds: 0 }); +} + +export function contentSecurityPolicy() { + return [ + "default-src 'self'", + "script-src 'self' 'unsafe-inline'", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data: blob:", + "connect-src 'self' https: wss:", + "font-src 'self' data:", + "frame-ancestors 'none'", + "base-uri 'self'", + "form-action 'self'" + ].join('; '); +} + +export function setSecurityHeaders(res, { secure = false, cspReportOnly = false } = {}) { + res.setHeader('x-content-type-options', 'nosniff'); + res.setHeader('referrer-policy', 'no-referrer'); + res.setHeader('x-frame-options', 'DENY'); + res.setHeader(cspReportOnly ? 'content-security-policy-report-only' : 'content-security-policy', contentSecurityPolicy()); + res.setHeader('permissions-policy', 'camera=(), geolocation=(), microphone=(self)'); + if (secure) { + res.setHeader('strict-transport-security', 'max-age=15552000; includeSubDomains'); + } +} + +export function rejectUnsafeOrigin(req, options) { + const method = String(req.method || 'GET').toUpperCase(); + if (['GET', 'HEAD', 'OPTIONS'].includes(method)) { + return null; + } + const origin = String(req.headers.origin || '').trim(); + if (!origin || options.allowedOrigins.includes(origin)) { + return null; + } + return { statusCode: 403, error: 'Cross-origin request rejected' }; +} + +export function rejectSuspiciousFetchSite(req, { protectSafeMethod = false } = {}) { + const method = String(req.method || 'GET').toUpperCase(); + const isSafeMethod = ['GET', 'HEAD', 'OPTIONS'].includes(method); + if (isSafeMethod && !protectSafeMethod) { + return null; + } + const fetchSite = String(req.headers['sec-fetch-site'] || '').toLowerCase(); + const allowedFetchSites = isSafeMethod + ? ['', 'same-origin', 'none'] + : ['', 'same-origin', 'same-site', 'none']; + if (allowedFetchSites.includes(fetchSite)) { + return null; + } + return { statusCode: 403, error: 'Cross-site request rejected' }; +} diff --git a/server/request-security.test.mjs b/server/request-security.test.mjs new file mode 100644 index 0000000..046b5df --- /dev/null +++ b/server/request-security.test.mjs @@ -0,0 +1,120 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + buildAuthCookie, + clearAuthCookie, + extractCookieToken, + extractRequestToken, + parseCookies, + rejectSuspiciousFetchSite, + rejectUnsafeOrigin, + setSecurityHeaders +} from './request-security.js'; + +test('parseCookies parses multiple cookies', () => { + assert.deepEqual(parseCookies('a=1; codexmobile_token=abc.def; theme=dark'), { + a: '1', + codexmobile_token: 'abc.def', + theme: 'dark' + }); +}); + +test('parseCookies ignores malformed percent-encoded cookie values', () => { + assert.deepEqual(parseCookies('bad=%E0%A4%A; codexmobile_token=abc'), { + codexmobile_token: 'abc' + }); +}); + +test('extractRequestToken ignores Bearer tokens unless explicitly enabled', () => { + const req = { + headers: { + cookie: 'codexmobile_token=cookie-token', + authorization: 'Bearer bearer-token' + } + }; + assert.equal(extractRequestToken(req), 'cookie-token'); + assert.equal(extractRequestToken({ headers: { authorization: 'Bearer bearer-token' } }), ''); + assert.equal(extractRequestToken({ headers: { authorization: 'Bearer bearer-token' } }, { allowBearer: true }), 'bearer-token'); +}); + +test('buildAuthCookie sets browser security attributes', () => { + const cookie = buildAuthCookie('token-value', { secure: true, maxAgeSeconds: 60 }); + assert.match(cookie, /codexmobile_token=token-value/); + assert.match(cookie, /HttpOnly/); + assert.match(cookie, /Secure/); + assert.match(cookie, /SameSite=Strict/); + assert.match(cookie, /Max-Age=60/); +}); + +test('clearAuthCookie expires the cookie', () => { + assert.match(clearAuthCookie({ secure: false }), /Max-Age=0/); +}); + +test('extractCookieToken reads the auth cookie only', () => { + assert.equal(extractCookieToken({ headers: { cookie: 'x=1; codexmobile_token=abc' } }), 'abc'); +}); + +test('rejectUnsafeOrigin rejects cross-origin state changes', () => { + const result = rejectUnsafeOrigin({ + method: 'POST', + headers: { origin: 'https://evil.example.com' } + }, { + allowedOrigins: ['https://codex.example.com'] + }); + assert.equal(result.statusCode, 403); +}); + +test('rejectSuspiciousFetchSite blocks cross-site state changes', () => { + const result = rejectSuspiciousFetchSite({ + method: 'POST', + headers: { 'sec-fetch-site': 'cross-site' } + }); + assert.equal(result.statusCode, 403); +}); + +test('rejectSuspiciousFetchSite can protect safe API methods', () => { + assert.equal(rejectSuspiciousFetchSite({ + method: 'GET', + headers: { 'sec-fetch-site': 'cross-site' } + }), null); + + const crossSiteResult = rejectSuspiciousFetchSite({ + method: 'GET', + headers: { 'sec-fetch-site': 'cross-site' } + }, { protectSafeMethod: true }); + assert.deepEqual(crossSiteResult, { + statusCode: 403, + error: 'Cross-site request rejected' + }); + + const sameSiteResult = rejectSuspiciousFetchSite({ + method: 'GET', + headers: { 'sec-fetch-site': 'same-site' } + }, { protectSafeMethod: true }); + assert.deepEqual(sameSiteResult, { + statusCode: 403, + error: 'Cross-site request rejected' + }); + + assert.equal(rejectSuspiciousFetchSite({ + method: 'GET', + headers: { 'sec-fetch-site': 'same-origin' } + }, { protectSafeMethod: true }), null); +}); + +test('setSecurityHeaders sets CSP and HSTS on secure requests', () => { + const headers = {}; + const res = { setHeader: (key, value) => { headers[key.toLowerCase()] = value; } }; + setSecurityHeaders(res, { secure: true, cspReportOnly: false }); + assert.match(headers['strict-transport-security'], /max-age=15552000/); + assert.match(headers['content-security-policy'], /default-src 'self'/); + assert.match(headers['content-security-policy'], /frame-ancestors 'none'/); +}); + +test('setSecurityHeaders can run CSP in report-only mode', () => { + const headers = {}; + const res = { setHeader: (key, value) => { headers[key.toLowerCase()] = value; } }; + setSecurityHeaders(res, { secure: false, cspReportOnly: true }); + assert.equal(headers['strict-transport-security'], undefined); + assert.match(headers['content-security-policy-report-only'], /default-src 'self'/); +}); diff --git a/server/route-handlers.test.mjs b/server/route-handlers.test.mjs index 86456d5..9f528e9 100644 --- a/server/route-handlers.test.mjs +++ b/server/route-handlers.test.mjs @@ -107,6 +107,28 @@ test('chat route handler routes send and abort through chat service', async () = assert.deepEqual(calls.map((call) => call.name), ['send', 'abort']); }); +test('chat route handler preserves structured send errors', async () => { + const handler = createChatRouteHandler({ + chatService: { + async sendChat() { + const error = new Error('danger-full-access is disabled on this server'); + error.statusCode = 403; + error.code = 'CODEXMOBILE_DANGER_FULL_ACCESS_DISABLED'; + throw error; + } + } + }); + + const sendReq = createRequest('POST', { projectId: 'project-1', message: 'hi', permissionMode: 'bypassPermissions' }); + const sendRes = createResponse(); + assert.equal(await callWithBody(handler, sendReq, sendRes, new URL('http://local/api/chat/send')), true); + assert.equal(sendRes.statusCode, 403); + assert.deepEqual(JSON.parse(sendRes.body), { + error: 'danger-full-access is disabled on this server', + code: 'CODEXMOBILE_DANGER_FULL_ACCESS_DISABLED' + }); +}); + test('file route handler searches project files and preserves project not found response', async () => { const handler = createFileRouteHandler({ getProject(projectId) { diff --git a/server/security-options.js b/server/security-options.js new file mode 100644 index 0000000..1dfff61 --- /dev/null +++ b/server/security-options.js @@ -0,0 +1,141 @@ +import net from 'node:net'; + +export function envFlag(env, key) { + return ['1', 'true', 'yes', 'on'].includes(String(env[key] || '').trim().toLowerCase()); +} + +export function readIntEnv(env, key, fallback) { + const value = Number(env[key]); + return Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback; +} + +export function normalizeRemoteAddress(value) { + const raw = String(value || '').trim(); + return raw.startsWith('::ffff:') ? raw.slice(7) : raw; +} + +export function ipv4ToNumber(value) { + const parts = String(value || '').split('.').map(Number); + if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) { + return null; + } + return ((parts[0] << 24) >>> 0) + (parts[1] << 16) + (parts[2] << 8) + parts[3]; +} + +export function cidrMatches(address, cidr) { + const [base, prefixText] = String(cidr || '').split('/'); + const prefix = Number(prefixText); + const addressNumber = ipv4ToNumber(normalizeRemoteAddress(address)); + const baseNumber = ipv4ToNumber(normalizeRemoteAddress(base)); + if (addressNumber === null || baseNumber === null || !Number.isInteger(prefix) || prefix < 0 || prefix > 32) { + return false; + } + const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0; + return (addressNumber & mask) === (baseNumber & mask); +} + +export function addressInCidrs(address, cidrs = []) { + return cidrs.some((cidr) => cidrMatches(address, cidr)); +} + +export function isPrivateRemoteAddress(value, options = {}) { + const address = normalizeRemoteAddress(value); + const lower = address.toLowerCase(); + if (addressInCidrs(address, options.privateCidrs || [])) { + return true; + } + if (address === 'localhost' || address === '127.0.0.1' || address === '::1') { + return true; + } + if (lower.startsWith('fe80:') || lower.startsWith('fc') || lower.startsWith('fd')) { + return true; + } + if (net.isIP(address) !== 4) { + return false; + } + const [a, b] = address.split('.').map(Number); + return a === 10 || + (a === 192 && b === 168) || + (a === 172 && b >= 16 && b <= 31) || + (a === 169 && b === 254) || + (a === 100 && b >= 64 && b <= 127); +} + +export function parseOrigins(value) { + return String(value || '') + .split(',') + .map((item) => item.trim()) + .filter(Boolean) + .map((item) => { + try { + return new URL(item).origin; + } catch { + return ''; + } + }) + .filter(Boolean); +} + +export function readSecurityOptions(env = process.env) { + const publicUrl = String(env.CODEXMOBILE_PUBLIC_URL || '').trim(); + const publicOrigin = publicUrl ? new URL(publicUrl).origin : ''; + const allowedOrigins = [...new Set([publicOrigin, ...parseOrigins(env.CODEXMOBILE_ALLOWED_ORIGINS)].filter(Boolean))]; + return { + publicAccess: envFlag(env, 'CODEXMOBILE_PUBLIC_ACCESS'), + publicUrl, + publicOrigin, + allowedOrigins, + trustedProxyCidrs: String(env.CODEXMOBILE_TRUSTED_PROXIES || '').split(',').map((item) => item.trim()).filter(Boolean), + privateCidrs: String(env.CODEXMOBILE_PRIVATE_CIDRS || '').split(',').map((item) => item.trim()).filter(Boolean), + allowRemotePairing: envFlag(env, 'CODEXMOBILE_ALLOW_REMOTE_PAIRING'), + legacyBearerEnabled: envFlag(env, 'CODEXMOBILE_ALLOW_LEGACY_BEARER'), + dangerFullAccessEnabled: envFlag(env, 'CODEXMOBILE_ENABLE_DANGER_FULL_ACCESS'), + pairingCodeLength: readIntEnv(env, 'CODEXMOBILE_PAIRING_CODE_LENGTH', 10), + pairingCodeTtlMs: readIntEnv(env, 'CODEXMOBILE_PAIRING_CODE_TTL_MS', 10 * 60 * 1000), + pairingRequestCooldownMs: readIntEnv(env, 'CODEXMOBILE_PAIRING_REQUEST_COOLDOWN_MS', 30 * 1000), + pairingMaxFailures: readIntEnv(env, 'CODEXMOBILE_PAIRING_MAX_FAILURES', 5), + pairingWindowMs: readIntEnv(env, 'CODEXMOBILE_PAIRING_WINDOW_MS', 10 * 60 * 1000), + pairingLockMs: readIntEnv(env, 'CODEXMOBILE_PAIRING_LOCK_MS', 15 * 60 * 1000), + tokenTtlMs: readIntEnv(env, 'CODEXMOBILE_TOKEN_TTL_MS', 90 * 24 * 60 * 60 * 1000) + }; +} + +export function sameOriginAllowed(origin, options) { + const value = String(origin || '').trim(); + return !value || (options.allowedOrigins || []).includes(value); +} + +export function isTrustedProxy(address, options) { + const normalized = normalizeRemoteAddress(address); + return options.trustedProxyCidrs?.some((cidr) => { + if (!cidr.includes('/')) { + return normalizeRemoteAddress(cidr) === normalized; + } + return cidrMatches(normalized, cidr); + }) || false; +} + +export function clientRemoteAddress(req, options) { + if (isTrustedProxy(req.socket?.remoteAddress || '', options)) { + const forwardedFor = String(req.headers['x-forwarded-for'] || '').split(',')[0].trim(); + if (forwardedFor) { + return normalizeRemoteAddress(forwardedFor); + } + } + return normalizeRemoteAddress(req.socket?.remoteAddress || ''); +} + +export function isRequestTransportSecure(req, options) { + if (req.socket?.encrypted) { + return true; + } + if (!isTrustedProxy(req.socket?.remoteAddress || '', options)) { + return false; + } + return String(req.headers['x-forwarded-proto'] || '').split(',')[0].trim().toLowerCase() === 'https'; +} + +export function requestMayUsePublicHttp(req, options) { + const remote = clientRemoteAddress(req, options); + return !options.publicAccess || isPrivateRemoteAddress(remote, options) || isRequestTransportSecure(req, options); +} diff --git a/server/security-options.test.mjs b/server/security-options.test.mjs new file mode 100644 index 0000000..f68cc0c --- /dev/null +++ b/server/security-options.test.mjs @@ -0,0 +1,104 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + clientRemoteAddress, + envFlag, + isPrivateRemoteAddress, + isRequestTransportSecure, + isTrustedProxy, + readSecurityOptions, + sameOriginAllowed +} from './security-options.js'; + +test('envFlag only enables explicit true-like values', () => { + assert.equal(envFlag({ A: '1' }, 'A'), true); + assert.equal(envFlag({ A: 'true' }, 'A'), true); + assert.equal(envFlag({ A: 'yes' }, 'A'), true); + assert.equal(envFlag({ A: 'on' }, 'A'), true); + assert.equal(envFlag({ A: '0' }, 'A'), false); + assert.equal(envFlag({ A: 'false' }, 'A'), false); + assert.equal(envFlag({}, 'A'), false); +}); + +test('isPrivateRemoteAddress recognizes loopback and private networks', () => { + assert.equal(isPrivateRemoteAddress('127.0.0.1'), true); + assert.equal(isPrivateRemoteAddress('::1'), true); + assert.equal(isPrivateRemoteAddress('::ffff:192.168.1.20'), true); + assert.equal(isPrivateRemoteAddress('100.64.1.2'), true); + assert.equal(isPrivateRemoteAddress('100.127.255.254'), true); + assert.equal(isPrivateRemoteAddress('100.128.0.1'), false); + assert.equal(isPrivateRemoteAddress('10.12.0.8'), true); + assert.equal(isPrivateRemoteAddress('172.16.0.9'), true); + assert.equal(isPrivateRemoteAddress('172.31.255.9'), true); + assert.equal(isPrivateRemoteAddress('172.32.0.9'), false); + assert.equal(isPrivateRemoteAddress('203.0.113.9'), false); +}); + +test('CODEXMOBILE_PRIVATE_CIDRS extends LAN detection explicitly', () => { + const options = readSecurityOptions({ CODEXMOBILE_PRIVATE_CIDRS: '198.51.100.0/24' }); + assert.equal(isPrivateRemoteAddress('198.51.100.7', options), true); + assert.equal(isPrivateRemoteAddress('198.51.101.7', options), false); +}); + +test('readSecurityOptions defaults to safe private deployment values', () => { + const options = readSecurityOptions({}); + assert.equal(options.publicAccess, false); + assert.equal(options.allowRemotePairing, false); + assert.equal(options.dangerFullAccessEnabled, false); + assert.equal(options.legacyBearerEnabled, false); + assert.deepEqual(options.trustedProxyCidrs, []); + assert.equal(options.pairingCodeLength, 10); + assert.equal(options.pairingCodeTtlMs, 600000); + assert.equal(options.pairingRequestCooldownMs, 30000); + assert.deepEqual(options.allowedOrigins, []); +}); + +test('CODEXMOBILE_PAIRING_REQUEST_COOLDOWN_MS configures pairing cooldown', () => { + assert.equal(readSecurityOptions({ CODEXMOBILE_PAIRING_REQUEST_COOLDOWN_MS: '45000' }).pairingRequestCooldownMs, 45000); +}); + +test('legacy Bearer auth requires an explicit migration flag', () => { + assert.equal(readSecurityOptions({}).legacyBearerEnabled, false); + assert.equal(readSecurityOptions({ CODEXMOBILE_ALLOW_LEGACY_BEARER: '1' }).legacyBearerEnabled, true); +}); + +test('sameOriginAllowed accepts configured public URL and extra origins', () => { + const options = readSecurityOptions({ + CODEXMOBILE_PUBLIC_URL: 'https://codex.example.com/mobile', + CODEXMOBILE_ALLOWED_ORIGINS: 'https://extra.example.com' + }); + assert.equal(sameOriginAllowed('https://codex.example.com', options), true); + assert.equal(sameOriginAllowed('https://extra.example.com', options), true); + assert.equal(sameOriginAllowed('https://evil.example.com', options), false); +}); + +test('clientRemoteAddress ignores forwarded headers unless socket peer is a trusted proxy', () => { + const req = { + socket: { remoteAddress: '203.0.113.20' }, + headers: { 'x-forwarded-for': '192.168.1.8, 203.0.113.1' } + }; + assert.equal(clientRemoteAddress(req, readSecurityOptions({})), '203.0.113.20'); + assert.equal(clientRemoteAddress(req, readSecurityOptions({ CODEXMOBILE_TRUSTED_PROXIES: '127.0.0.1' })), '203.0.113.20'); + + const proxied = { + socket: { remoteAddress: '127.0.0.1' }, + headers: { 'x-forwarded-for': '198.51.100.22, 127.0.0.1' } + }; + assert.equal(isTrustedProxy('127.0.0.1', readSecurityOptions({ CODEXMOBILE_TRUSTED_PROXIES: '127.0.0.1' })), true); + assert.equal(clientRemoteAddress(proxied, readSecurityOptions({ CODEXMOBILE_TRUSTED_PROXIES: '127.0.0.1' })), '198.51.100.22'); +}); + +test('isRequestTransportSecure accepts forwarded https only from trusted proxies', () => { + assert.equal(isRequestTransportSecure({ socket: { encrypted: true }, headers: {} }, readSecurityOptions({})), true); + assert.equal( + isRequestTransportSecure({ socket: { remoteAddress: '203.0.113.20' }, headers: { 'x-forwarded-proto': 'https' } }, readSecurityOptions({ CODEXMOBILE_TRUSTED_PROXIES: '127.0.0.1' })), + false + ); + assert.equal( + isRequestTransportSecure( + { socket: { remoteAddress: '127.0.0.1' }, headers: { 'x-forwarded-proto': 'https' } }, + readSecurityOptions({ CODEXMOBILE_TRUSTED_PROXIES: '127.0.0.1' }) + ), + true + ); +}); diff --git a/server/server-options.js b/server/server-options.js new file mode 100644 index 0000000..bb0e0c9 --- /dev/null +++ b/server/server-options.js @@ -0,0 +1,109 @@ +const DEFAULT_HOST = '0.0.0.0'; +const DEFAULT_PORT = 3321; +const DEFAULT_HTTPS_PORT = 3443; + +const ARG_ALIASES = new Map([ + ['-p', 'port'], + ['--port', 'port'], + ['--http-port', 'port'], + ['--host', 'host'], + ['--https-port', 'httpsPort'] +]); + +function parseArgValue(arg, nextArg) { + const eqIndex = arg.indexOf('='); + if (eqIndex >= 0) { + return { + rawFlag: arg.slice(0, eqIndex), + value: arg.slice(eqIndex + 1), + consumedNext: false + }; + } + return { + rawFlag: arg, + value: nextArg, + consumedNext: true + }; +} + +export function parseServerCliArgs(argv = process.argv.slice(2)) { + const options = {}; + const unknown = []; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (!arg || arg === '--') { + continue; + } + if (arg === '--help' || arg === '-h') { + options.help = true; + continue; + } + if (!arg.startsWith('-')) { + unknown.push(arg); + continue; + } + + const { rawFlag, value, consumedNext } = parseArgValue(arg, argv[index + 1]); + const key = ARG_ALIASES.get(rawFlag); + if (!key) { + unknown.push(arg); + continue; + } + if (!value || String(value).startsWith('-')) { + throw new Error(`${rawFlag} requires a value`); + } + options[key] = String(value).trim(); + if (consumedNext) { + index += 1; + } + } + + return { options, unknown }; +} + +function parsePort(value, fallback, label) { + const raw = value === undefined || value === null || value === '' ? fallback : value; + const port = Number(raw); + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error(`${label} must be an integer from 1 to 65535`); + } + return port; +} + +export function readServerOptions({ + argv = process.argv.slice(2), + env = process.env, + allowUnknown = false +} = {}) { + const { options, unknown } = parseServerCliArgs(argv); + if (!allowUnknown && unknown.length) { + throw new Error(`Unknown server option: ${unknown[0]}`); + } + + return { + host: options.host || env.HOST || DEFAULT_HOST, + port: parsePort(options.port || env.CODEXMOBILE_PORT || env.PORT, DEFAULT_PORT, 'HTTP port'), + httpsPort: parsePort( + options.httpsPort || env.CODEXMOBILE_HTTPS_PORT || env.HTTPS_PORT, + DEFAULT_HTTPS_PORT, + 'HTTPS port' + ), + help: Boolean(options.help) + }; +} + +export function resolveHttpListenHost({ publicAccess = false, httpsStarted = false, host = DEFAULT_HOST } = {}) { + return publicAccess && httpsStarted ? '127.0.0.1' : host; +} + +export function serverOptionsHelp(command = 'npm start --') { + return [ + 'Usage:', + ` ${command} [--port ] [--https-port ] [--host
]`, + '', + 'Examples:', + ` ${command} --port 33321`, + ` ${command} --host 127.0.0.1 --port 33321 --https-port 33443` + ].join('\n'); +} diff --git a/server/server-options.test.mjs b/server/server-options.test.mjs new file mode 100644 index 0000000..8cb6d77 --- /dev/null +++ b/server/server-options.test.mjs @@ -0,0 +1,24 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { resolveHttpListenHost } from './server-options.js'; + +test('resolveHttpListenHost keeps public HTTP local after HTTPS starts', () => { + assert.equal( + resolveHttpListenHost({ publicAccess: true, httpsStarted: true, host: '0.0.0.0' }), + '127.0.0.1' + ); +}); + +test('resolveHttpListenHost keeps configured host when HTTPS did not start', () => { + assert.equal( + resolveHttpListenHost({ publicAccess: true, httpsStarted: false, host: '0.0.0.0' }), + '0.0.0.0' + ); +}); + +test('resolveHttpListenHost keeps configured host outside public access mode', () => { + assert.equal( + resolveHttpListenHost({ publicAccess: false, httpsStarted: true, host: '0.0.0.0' }), + '0.0.0.0' + ); +}); diff --git a/server/session-index-builder.test.mjs b/server/session-index-builder.test.mjs index e99dc87..da9fb87 100644 --- a/server/session-index-builder.test.mjs +++ b/server/session-index-builder.test.mjs @@ -1,4 +1,5 @@ import assert from 'node:assert/strict'; +import path from 'node:path'; import test from 'node:test'; import { PROJECTLESS_PROJECT_ID, @@ -7,9 +8,9 @@ import { } from './session-index-builder.js'; test('session index builder preserves project ordering, projectless sessions, hidden filtering, and child counts', async () => { - const projectA = '/tmp/codexmobile-project-a'; - const projectB = '/tmp/codexmobile-project-b'; - const projectlessRoot = '/tmp/codexmobile-projectless'; + const projectA = path.resolve('/tmp/codexmobile-project-a'); + const projectB = path.resolve('/tmp/codexmobile-project-b'); + const projectlessRoot = path.resolve('/tmp/codexmobile-projectless'); const projectAId = projectIdFor(projectA); const contextReads = []; diff --git a/server/static-service.js b/server/static-service.js index 676332f..c31f3be 100644 --- a/server/static-service.js +++ b/server/static-service.js @@ -3,6 +3,7 @@ import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { sendJson, sendStaticContent, staticCacheControl } from './http-utils.js'; +import { isPathInsideRoot } from './upload-service.js'; export const DEFAULT_MIME_TYPES = new Map([ ['.html', 'text/html; charset=utf-8'], @@ -143,7 +144,8 @@ function backupFileName(filePath) { } export async function sendLocalImage(req, res, url, { - mimeTypes = DEFAULT_MIME_TYPES + mimeTypes = DEFAULT_MIME_TYPES, + allowedRoots = [] } = {}) { const requestedPath = resolveLocalImagePath(url.searchParams.get('path')); const decodedPath = /%[0-9a-f]{2}/i.test(requestedPath) ? resolveLocalImagePath(safeDecodeLocalPath(requestedPath)) : ''; @@ -158,6 +160,9 @@ export async function sendLocalImage(req, res, url, { continue; } const filePath = path.resolve(candidate); + if (Array.isArray(allowedRoots) && allowedRoots.length && !allowedRoots.some((root) => isPathInsideRoot(filePath, root))) { + continue; + } const ext = path.extname(filePath).toLowerCase(); const contentType = mimeTypes.get(ext) || ''; if (!contentType.startsWith('image/')) { @@ -293,7 +298,9 @@ export async function serveFileFromRoot(req, res, rootDir, requestedPath, cacheC export function createStaticService({ clientDist, generatedRoot, + desktopImageRoot = '', httpsRootCaPath, + uploadRoot = '', mimeTypes = DEFAULT_MIME_TYPES }) { async function serveStatic(req, res, url) { @@ -367,7 +374,10 @@ export function createStaticService({ } async function sendLocalImageFromRequest(req, res, url) { - await sendLocalImage(req, res, url, { mimeTypes }); + await sendLocalImage(req, res, url, { + mimeTypes, + allowedRoots: [generatedRoot, uploadRoot, desktopImageRoot].filter(Boolean) + }); } async function sendLocalFileFromRequest(req, res, url) { diff --git a/server/static-service.test.mjs b/server/static-service.test.mjs index 2ce2f4e..9c06bf2 100644 --- a/server/static-service.test.mjs +++ b/server/static-service.test.mjs @@ -28,20 +28,31 @@ async function withTempService(fn) { const root = await fs.mkdtemp(path.join(os.tmpdir(), 'codexmobile-static-')); const clientDist = path.join(root, 'dist'); const generatedRoot = path.join(root, 'generated'); + const uploadRoot = path.join(root, 'uploads'); + const desktopImageRoot = path.join(root, 'desktop-images'); const certPath = path.join(root, 'tls', 'root.cer'); await fs.mkdir(clientDist, { recursive: true }); await fs.mkdir(generatedRoot, { recursive: true }); + await fs.mkdir(uploadRoot, { recursive: true }); + await fs.mkdir(desktopImageRoot, { recursive: true }); await fs.mkdir(path.dirname(certPath), { recursive: true }); await fs.writeFile(path.join(clientDist, 'index.html'), '

CodexMobile

'); await fs.writeFile(path.join(clientDist, 'worker.mjs'), 'export default null;'); await fs.writeFile(path.join(generatedRoot, 'image.png'), Buffer.from([137, 80, 78, 71])); + await fs.writeFile(path.join(uploadRoot, 'upload.png'), Buffer.from([137, 80, 78, 71])); + await fs.writeFile(path.join(desktopImageRoot, 'desktop.png'), Buffer.from([137, 80, 78, 71])); await fs.writeFile(path.join(root, 'report.md'), '# Report'); await fs.writeFile(path.join(root, 'brief.pdf'), Buffer.from('%PDF-1.7')); await fs.writeFile(path.join(root, '甘肃临夏萌宠乐园丨政府汇报项目前置简介.md'), '# 中文文件名'); await fs.writeFile(path.join(root, 'secret.txt'), 'secret'); await fs.writeFile(certPath, 'cert'); try { - await fn(createStaticService({ clientDist, generatedRoot, httpsRootCaPath: certPath }), root); + await fn(createStaticService({ clientDist, generatedRoot, uploadRoot, desktopImageRoot, httpsRootCaPath: certPath }), { + root, + generatedRoot, + uploadRoot, + desktopImageRoot + }); } finally { await fs.rm(root, { recursive: true, force: true }); } @@ -90,7 +101,7 @@ test('serveStatic returns generated files from the generated root', async () => }); test('sendLocalFile serves markdown files inline from absolute paths', async () => { - await withTempService(async (service, root) => { + await withTempService(async (service, { root }) => { const filePath = path.join(root, 'report.md'); const response = res(); await service.sendLocalFile(req(), response, new URL(`http://local/api/local-file?path=${encodeURIComponent(filePath)}`)); @@ -102,7 +113,7 @@ test('sendLocalFile serves markdown files inline from absolute paths', async () }); test('sendLocalFile serves pdf files with pdf content type', async () => { - await withTempService(async (service, root) => { + await withTempService(async (service, { root }) => { const filePath = path.join(root, 'brief.pdf'); const response = res(); await service.sendLocalFile(req(), response, new URL(`http://local/api/local-file?path=${encodeURIComponent(filePath)}`)); @@ -114,7 +125,7 @@ test('sendLocalFile serves pdf files with pdf content type', async () => { }); test('sendLocalFile tolerates Codex style line suffixes on file links', async () => { - await withTempService(async (service, root) => { + await withTempService(async (service, { root }) => { const filePath = `${path.join(root, 'report.md')}:12`; const response = res(); await service.sendLocalFile(req(), response, new URL(`http://local/api/local-file?path=${encodeURIComponent(filePath)}`)); @@ -126,7 +137,7 @@ test('sendLocalFile tolerates Codex style line suffixes on file links', async () }); test('sendLocalFile encodes non-ascii filenames in content-disposition', async () => { - await withTempService(async (service, root) => { + await withTempService(async (service, { root }) => { const filePath = path.join(root, '甘肃临夏萌宠乐园丨政府汇报项目前置简介.md'); const response = res(); await service.sendLocalFile(req(), response, new URL(`http://local/api/local-file?path=${encodeURIComponent(filePath)}`)); @@ -139,7 +150,7 @@ test('sendLocalFile encodes non-ascii filenames in content-disposition', async ( }); test('writeLocalFile saves editable text files with conflict protection and backup', async () => { - await withTempService(async (service, root) => { + await withTempService(async (service, { root }) => { const filePath = path.join(root, 'report.md'); const initialStat = await fs.stat(filePath); const saveResponse = res(); @@ -169,3 +180,40 @@ test('writeLocalFile saves editable text files with conflict protection and back assert.equal(await fs.readFile(filePath, 'utf8'), '# Updated'); }); }); + +test('sendLocalImage only serves generated, uploaded, or desktop images', async () => { + await withTempService(async (service, paths) => { + const generatedResponse = res(); + await service.sendLocalImage( + req(), + generatedResponse, + new URL(`http://local/api/local-image?path=${encodeURIComponent(path.join(paths.generatedRoot, 'image.png'))}`) + ); + assert.equal(generatedResponse.statusCode, 200); + assert.equal(generatedResponse.headers['content-type'], 'image/png'); + + const uploadResponse = res(); + await service.sendLocalImage( + req(), + uploadResponse, + new URL(`http://local/api/local-image?path=${encodeURIComponent(path.join(paths.uploadRoot, 'upload.png'))}`) + ); + assert.equal(uploadResponse.statusCode, 200); + + const desktopImageResponse = res(); + await service.sendLocalImage( + req(), + desktopImageResponse, + new URL(`http://local/api/local-image?path=${encodeURIComponent(path.join(paths.desktopImageRoot, 'desktop.png'))}`) + ); + assert.equal(desktopImageResponse.statusCode, 200); + + const blockedResponse = res(); + await service.sendLocalImage( + req(), + blockedResponse, + new URL(`http://local/api/local-image?path=${encodeURIComponent(path.join(paths.root, 'secret.txt'))}`) + ); + assert.equal(blockedResponse.statusCode, 404); + }); +}); diff --git a/server/upload-service.js b/server/upload-service.js index d001c91..438bb96 100644 --- a/server/upload-service.js +++ b/server/upload-service.js @@ -13,8 +13,45 @@ export function sanitizeFileName(fileName) { return baseName || 'upload.bin'; } -export function classifyUpload(mimeType) { - return String(mimeType || '').startsWith('image/') ? 'image' : 'file'; +function hasImageExtension(value) { + return /\.(?:png|jpe?g|gif|webp|bmp|heic|heif)$/i.test(String(value || '').split(/[?#]/)[0]); +} + +export function classifyUpload(mimeType, fileName = '') { + return String(mimeType || '').toLowerCase().startsWith('image/') || hasImageExtension(fileName) ? 'image' : 'file'; +} + +export function sniffMimeType(data) { + const bytes = Buffer.isBuffer(data) ? data : Buffer.from(data || []); + if (bytes.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) { + return 'image/png'; + } + if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) { + return 'image/jpeg'; + } + const gifHeader = bytes.subarray(0, 6).toString('ascii'); + if (gifHeader === 'GIF87a' || gifHeader === 'GIF89a') { + return 'image/gif'; + } + if (bytes.subarray(0, 4).toString('ascii') === 'RIFF' && bytes.subarray(8, 12).toString('ascii') === 'WEBP') { + return 'image/webp'; + } + if (bytes.subarray(0, 4).toString('ascii') === '%PDF') { + return 'application/pdf'; + } + if (bytes.subarray(4, 8).toString('ascii') === 'ftyp') { + return 'video/mp4'; + } + return ''; +} + +export function normalizeUploadMimeType(declaredMimeType, data) { + const declared = String(declaredMimeType || 'application/octet-stream').toLowerCase(); + const sniffed = sniffMimeType(data); + if (!sniffed || declared === sniffed) { + return declared; + } + return 'application/octet-stream'; } export function parseMultipartFile(buffer, contentType, fieldName = 'file') { @@ -58,10 +95,11 @@ export function parseMultipartFile(buffer, contentType, fieldName = 'file') { if (buffer[contentEnd - 2] === 13 && buffer[contentEnd - 1] === 10) { contentEnd -= 2; } + const data = buffer.slice(headerEnd + 4, contentEnd); return { fileName: sanitizeFileName(fileName), - mimeType, - data: buffer.slice(headerEnd + 4, contentEnd) + mimeType: normalizeUploadMimeType(mimeType, data), + data }; } @@ -137,32 +175,56 @@ export async function saveUpload(req, { size: part.data.length, mimeType: part.mimeType, path: filePath, - kind: classifyUpload(part.mimeType) + kind: classifyUpload(part.mimeType, part.fileName) }; } -export function normalizeAttachments(value) { +export function isPathInsideRoot(filePath, rootPath) { + if (!filePath || !rootPath) { + return false; + } + const resolvedRoot = path.resolve(rootPath); + const resolvedFile = path.resolve(filePath); + const compareRoot = process.platform === 'win32' ? resolvedRoot.toLowerCase() : resolvedRoot; + const compareFile = process.platform === 'win32' ? resolvedFile.toLowerCase() : resolvedFile; + const relative = path.relative(compareRoot, compareFile); + return relative === '' || (relative && !relative.startsWith('..') && !path.isAbsolute(relative)); +} + +export function normalizeAttachments(value, { uploadRoot = '' } = {}) { if (!Array.isArray(value)) { return []; } return value - .filter((item) => item && typeof item.path === 'string' && item.path.trim()) - .map((item) => ({ - id: String(item.id || ''), - name: String(item.name || path.basename(item.path)), - size: Number(item.size) || 0, - mimeType: String(item.mimeType || ''), - path: String(item.path), - kind: item.kind === 'image' ? 'image' : 'file' - })); + .map((item) => { + if (!item || typeof item.path !== 'string' || !item.path.trim()) { + return null; + } + const attachmentPath = path.resolve(String(item.path)); + if (uploadRoot && !isPathInsideRoot(attachmentPath, uploadRoot)) { + return null; + } + return { + id: String(item.id || ''), + name: String(item.name || path.basename(attachmentPath)), + size: Number(item.size) || 0, + mimeType: String(item.mimeType || ''), + path: attachmentPath, + kind: item.kind === 'image' ? 'image' : classifyUpload(item.mimeType, item.name || attachmentPath) + }; + }) + .filter(Boolean); } export function markdownImageDestination(value) { - const raw = String(value || '').trim(); + let raw = String(value || '').trim(); if (!raw) { return ''; } - if (/[\s<>()]/.test(raw)) { + if (/^[A-Za-z]:[\\/]/.test(raw)) { + raw = raw.replace(/\\/g, '/'); + } + if (/[\s<>()\\]/.test(raw) || /^[A-Za-z]:/.test(raw)) { return `<${raw.replace(/>/g, '%3E')}>`; } return raw; @@ -193,7 +255,7 @@ export function withAttachmentReferences(message, attachments) { return message; } - const fileLines = attachments.map((attachment) => { + const fileLines = attachments.filter((attachment) => attachment.kind !== 'image').map((attachment) => { const type = attachment.kind === 'image' ? '图片' : '文件'; return `- ${type}: ${attachment.name} (${attachment.path})`; }); diff --git a/server/upload-service.test.mjs b/server/upload-service.test.mjs index 08fcdd8..b116857 100644 --- a/server/upload-service.test.mjs +++ b/server/upload-service.test.mjs @@ -1,6 +1,9 @@ import assert from 'node:assert/strict'; +import path from 'node:path'; import test from 'node:test'; import { + classifyUpload, + isPathInsideRoot, normalizeFileMentions, normalizeAttachments, parseMultipartFile, @@ -35,10 +38,44 @@ test('parseMultipartFile extracts and sanitizes an uploaded file', () => { assert.equal(file.data.toString('utf8'), 'hello'); }); +test('parseMultipartFile downgrades mismatched file mime type', () => { + const boundary = 'codex-boundary'; + const body = multipartBody({ + boundary, + fileName: 'fake.png', + mimeType: 'image/png', + data: '%PDF-1.7\n' + }); + + const file = parseMultipartFile(body, `multipart/form-data; boundary=${boundary}`); + + assert.equal(file.mimeType, 'application/octet-stream'); +}); + +test('classifyUpload treats image extensions as images when mobile browsers omit image MIME', () => { + assert.equal(classifyUpload('application/octet-stream', 'photo.PNG'), 'image'); + assert.equal(classifyUpload('', 'scan.jpeg'), 'image'); + assert.equal(classifyUpload('application/pdf', 'brief.pdf'), 'file'); +}); + +test('normalizeAttachments recovers image kind from MIME or file extension', () => { + const uploadRoot = path.resolve('/tmp/codexmobile/uploads'); + const pngPath = path.join(uploadRoot, '2026-05-10', 'photo.png'); + const heicPath = path.join(uploadRoot, '2026-05-10', 'photo.heic'); + const attachments = normalizeAttachments([ + { name: 'photo.png', path: pngPath, kind: 'file', mimeType: 'application/octet-stream' }, + { name: 'photo.heic', path: heicPath, kind: 'file', mimeType: 'image/heic' } + ], { uploadRoot }); + + assert.deepEqual(attachments.map((attachment) => attachment.kind), ['image', 'image']); +}); + test('normalizeAttachments keeps valid paths and splits image/file references', () => { + const imagePath = path.resolve('/tmp/a image.png'); + const filePath = path.resolve('/tmp/brief.pdf'); const attachments = normalizeAttachments([ - { id: 1, name: '图[片].png', path: '/tmp/a image.png', kind: 'image', mimeType: 'image/png' }, - { name: 'brief.pdf', path: '/tmp/brief.pdf', kind: 'file', mimeType: 'application/pdf' }, + { id: 1, name: '图[片].png', path: imagePath, kind: 'image', mimeType: 'image/png' }, + { name: 'brief.pdf', path: filePath, kind: 'file', mimeType: 'application/pdf' }, { name: 'missing-path' } ]); @@ -47,14 +84,27 @@ test('normalizeAttachments keeps valid paths and splits image/file references', assert.equal(attachments[1].kind, 'file'); assert.equal( withImageAttachmentPreviews('看图', attachments), - '看图\n\n![图片.png]()' + `看图\n\n![图片.png](<${imagePath.replace(/\\/g, '/')}>)` ); assert.equal( withAttachmentReferences('看文件', attachments), - '看文件\n\n附件路径:\n- 图片: 图[片].png (/tmp/a image.png)\n- 文件: brief.pdf (/tmp/brief.pdf)' + `看文件\n\n附件路径:\n- 文件: brief.pdf (${filePath})` ); }); +test('normalizeAttachments drops client supplied paths outside the upload root', () => { + const uploadRoot = path.resolve('/tmp/codexmobile/uploads'); + const inside = path.join(uploadRoot, '2026-05-10', 'image.png'); + const attachments = normalizeAttachments([ + { name: 'image.png', path: inside, kind: 'image', mimeType: 'image/png' }, + { name: 'secret.txt', path: path.resolve('/tmp/secret.txt'), kind: 'file' } + ], { uploadRoot }); + + assert.equal(isPathInsideRoot(inside, uploadRoot), true); + assert.equal(isPathInsideRoot(path.resolve('/tmp/secret.txt'), uploadRoot), false); + assert.deepEqual(attachments.map((attachment) => attachment.name), ['image.png']); +}); + test('file mention references dedupe paths and append to the model message', () => { const mentions = normalizeFileMentions([ { name: 'App.jsx', path: '/repo/client/src/App.jsx' }, From a8c55f7eb3acd00758df1c6170a7cb23faad2b01 Mon Sep 17 00:00:00 2001 From: phoenixray2000 Date: Tue, 12 May 2026 10:48:30 +0800 Subject: [PATCH 2/2] Fix full access env parsing and Lark config prep --- server/codex-runner-status.test.mjs | 29 ++++++- server/codex-runner.js | 7 +- server/lark-cli.js | 101 +++++++++++++++++++----- server/lark-cli.test.mjs | 116 ++++++++++++++++++++++++++++ 4 files changed, 231 insertions(+), 22 deletions(-) create mode 100644 server/lark-cli.test.mjs diff --git a/server/codex-runner-status.test.mjs b/server/codex-runner-status.test.mjs index 6de8a50..7ba352f 100644 --- a/server/codex-runner-status.test.mjs +++ b/server/codex-runner-status.test.mjs @@ -1,6 +1,10 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { shouldCompleteTurnFromAppServerItem, statusLabel } from './codex-runner.js'; +import { + headlessCodexSandboxForPermissionMode, + shouldCompleteTurnFromAppServerItem, + statusLabel +} from './codex-runner.js'; test('statusLabel uses mobile-friendly command labels', () => { assert.equal(statusLabel('command_execution', 'running'), '正在处理本地任务'); @@ -43,3 +47,26 @@ test('completed final assistant item can finish a headless turn without turn com false ); }); + +test('headless full-access env parsing matches server security options', async () => { + for (const value of ['1', 'true', 'yes', 'on']) { + assert.deepEqual( + headlessCodexSandboxForPermissionMode('bypassPermissions', { + CODEXMOBILE_ENABLE_DANGER_FULL_ACCESS: value + }), + { sandboxMode: 'danger-full-access', approvalPolicy: 'never' }, + value + ); + } + + assert.throws( + () => headlessCodexSandboxForPermissionMode('bypassPermissions', { + CODEXMOBILE_ENABLE_DANGER_FULL_ACCESS: '0' + }), + /danger-full-access is disabled/ + ); + assert.throws( + () => headlessCodexSandboxForPermissionMode('bypassPermissions', {}), + /danger-full-access is disabled/ + ); +}); diff --git a/server/codex-runner.js b/server/codex-runner.js index 7deab52..7b948ca 100644 --- a/server/codex-runner.js +++ b/server/codex-runner.js @@ -7,6 +7,7 @@ import { buildCodexTurnInput, imageMarkdownFromCodexImageGeneration } from './co import { buildCodexLarkCliContext } from './lark-cli.js'; import { detectFeishuSkillKeys } from './feishu-skills.js'; import { codexSandboxForPermissionMode } from './permission-policy.js'; +import { envFlag } from './security-options.js'; const activeRuns = new Map(); const NON_ASCII_PATH_PATTERN = /[^\u0000-\u007F]/; @@ -81,9 +82,9 @@ async function ensureAsciiWorkingDirectory(projectPath) { return aliasPath; } -function mapPermissionMode(permissionMode) { +export function headlessCodexSandboxForPermissionMode(permissionMode, env = process.env) { return codexSandboxForPermissionMode(permissionMode, { - dangerFullAccessEnabled: process.env.CODEXMOBILE_ENABLE_DANGER_FULL_ACCESS === '1' + dangerFullAccessEnabled: envFlag(env, 'CODEXMOBILE_ENABLE_DANGER_FULL_ACCESS') }); } @@ -767,7 +768,7 @@ function abortError() { export async function runCodexTurn({ sessionId, draftSessionId, projectPath, message, attachments = [], selectedSkills = [], model, reasoningEffort, serviceTier, permissionMode, collaborationMode = null, turnId: providedTurnId }, emit) { const workingDirectory = await ensureAsciiWorkingDirectory(projectPath); - const { sandboxMode, approvalPolicy } = mapPermissionMode(permissionMode); + const { sandboxMode, approvalPolicy } = headlessCodexSandboxForPermissionMode(permissionMode); const feishuSkillKeys = detectFeishuSkillKeys(message); const normalizedReasoningEffort = normalizeReasoningEffort(reasoningEffort); const normalizedServiceTier = normalizeServiceTier(serviceTier); diff --git a/server/lark-cli.js b/server/lark-cli.js index c6718de..f9f0db8 100644 --- a/server/lark-cli.js +++ b/server/lark-cli.js @@ -75,6 +75,8 @@ let statusCache = { at: 0, value: null }; let authRun = null; let larkCliCommandPath = ''; let agentConfigPreparedAt = 0; +const AGENT_LARK_PROFILE = 'openclaw'; +const LARK_RUNTIME_DIR_NAMES = new Set(['locks', 'cache', 'logs']); async function pathExists(candidate) { try { @@ -150,31 +152,94 @@ export function larkCliEnvironment(baseEnv = process.env) { return env; } -async function ensureAgentLarkConfigDir() { - const sourceRoot = path.join(os.homedir(), '.lark-cli'); - const sourceProfile = path.join(sourceRoot, 'openclaw'); - const targetRoot = path.join(ROOT_DIR, '.codexmobile', 'lark-cli-agent'); - const targetProfile = path.join(targetRoot, 'openclaw'); - const now = Date.now(); +function isPortableLarkConfigFile(source, { excludedNames = [] } = {}) { + const name = path.basename(source).toLowerCase(); + return !LARK_RUNTIME_DIR_NAMES.has(name) && !excludedNames.includes(name); +} - if (now - agentConfigPreparedAt < 5000) { - return targetRoot; +async function ensureRuntimeDirs(root) { + await Promise.all( + [...LARK_RUNTIME_DIR_NAMES].map((name) => fs.mkdir(path.join(root, name), { recursive: true })) + ); +} + +async function hasPortableLarkConfig(root, options = {}) { + if (!(await pathExists(root))) { + return false; } + const entries = await fs.readdir(root, { withFileTypes: true }); + for (const entry of entries) { + const source = path.join(root, entry.name); + if (!isPortableLarkConfigFile(source, options)) { + continue; + } + if (entry.isDirectory()) { + if (await hasPortableLarkConfig(source, options)) { + return true; + } + continue; + } + return true; + } + return false; +} - await fs.mkdir(targetProfile, { recursive: true }); - await fs.cp(sourceProfile, targetProfile, { +async function resetAgentLarkConfigDir(targetRoot) { + await fs.rm(targetRoot, { recursive: true, force: true }); +} + +async function copyPortableLarkConfig(source, target, options = {}) { + await fs.mkdir(target, { recursive: true }); + await fs.cp(source, target, { recursive: true, force: true, - filter: (source) => { - const name = path.basename(source).toLowerCase(); - return !['locks', 'cache', 'logs'].includes(name); - } + filter: (source) => isPortableLarkConfigFile(source, options) }); +} + +export async function prepareAgentLarkConfigDir({ + sourceRoot = path.join(os.homedir(), '.lark-cli'), + targetRoot = path.join(ROOT_DIR, '.codexmobile', 'lark-cli-agent'), + profileName = AGENT_LARK_PROFILE +} = {}) { + const sourceProfile = path.join(sourceRoot, profileName); + const targetProfile = path.join(targetRoot, profileName); + const excludedProfileNames = [String(profileName || '').toLowerCase()].filter(Boolean); + const profileHasConfig = await hasPortableLarkConfig(sourceProfile); + + if (profileHasConfig) { + await resetAgentLarkConfigDir(targetRoot); + await copyPortableLarkConfig(sourceProfile, targetProfile); + await ensureRuntimeDirs(targetProfile); + return targetRoot; + } + + if (!(await pathExists(sourceRoot))) { + throw new Error(`lark-cli 配置目录不存在:${sourceRoot}`); + } + if (!(await hasPortableLarkConfig(sourceRoot, { excludedNames: excludedProfileNames }))) { + throw new Error(`lark-cli 配置目录没有可用配置:${sourceRoot}`); + } + + await resetAgentLarkConfigDir(targetRoot); + await copyPortableLarkConfig(sourceRoot, targetRoot, { excludedNames: excludedProfileNames }); + await copyPortableLarkConfig(sourceRoot, targetProfile, { excludedNames: excludedProfileNames }); await Promise.all([ - fs.mkdir(path.join(targetProfile, 'locks'), { recursive: true }), - fs.mkdir(path.join(targetProfile, 'cache'), { recursive: true }), - fs.mkdir(path.join(targetProfile, 'logs'), { recursive: true }) + ensureRuntimeDirs(targetRoot), + ensureRuntimeDirs(targetProfile) ]); + return targetRoot; +} + +async function ensureAgentLarkConfigDir() { + const now = Date.now(); + const targetRoot = path.join(ROOT_DIR, '.codexmobile', 'lark-cli-agent'); + + if (now - agentConfigPreparedAt < 5000) { + return targetRoot; + } + + await prepareAgentLarkConfigDir({ targetRoot }); agentConfigPreparedAt = now; return targetRoot; } @@ -748,7 +813,7 @@ export async function buildCodexLarkCliContext(message = '') { const configRoot = await ensureAgentLarkConfigDir(); const realCli = await resolveLarkCliCommand(); env.LARKSUITE_CLI_CONFIG_DIR = configRoot; - env.LARKSUITE_CLI_LOG_DIR = path.join(configRoot, 'openclaw', 'logs'); + env.LARKSUITE_CLI_LOG_DIR = path.join(configRoot, AGENT_LARK_PROFILE, 'logs'); env.LARKSUITE_CLI_NO_UPDATE_NOTIFIER = '1'; if (realCli && realCli !== LARK_CLI) { const guardDir = await ensureLarkCliGuardDir(); diff --git a/server/lark-cli.test.mjs b/server/lark-cli.test.mjs new file mode 100644 index 0000000..100d528 --- /dev/null +++ b/server/lark-cli.test.mjs @@ -0,0 +1,116 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { prepareAgentLarkConfigDir } from './lark-cli.js'; + +async function exists(filePath) { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} + +async function makeTempRoot() { + return await fs.mkdtemp(path.join(os.tmpdir(), 'codexmobile-lark-config-')); +} + +test('prepareAgentLarkConfigDir supports flat lark-cli config directories', async () => { + const root = await makeTempRoot(); + const sourceRoot = path.join(root, 'source'); + const targetRoot = path.join(root, 'target'); + + await fs.mkdir(path.join(sourceRoot, 'cache'), { recursive: true }); + await fs.mkdir(path.join(sourceRoot, 'locks'), { recursive: true }); + await fs.mkdir(path.join(sourceRoot, 'logs'), { recursive: true }); + await fs.writeFile(path.join(sourceRoot, 'config.json'), '{"layout":"flat"}\n', 'utf8'); + await fs.writeFile(path.join(sourceRoot, 'skills.stamp'), 'ok\n', 'utf8'); + await fs.writeFile(path.join(sourceRoot, 'cache', 'skip.txt'), 'cached\n', 'utf8'); + await fs.writeFile(path.join(sourceRoot, 'logs', 'skip.txt'), 'logged\n', 'utf8'); + + const configRoot = await prepareAgentLarkConfigDir({ sourceRoot, targetRoot }); + + assert.equal(configRoot, targetRoot); + assert.equal(await fs.readFile(path.join(targetRoot, 'config.json'), 'utf8'), '{"layout":"flat"}\n'); + assert.equal(await fs.readFile(path.join(targetRoot, 'openclaw', 'config.json'), 'utf8'), '{"layout":"flat"}\n'); + assert.equal(await exists(path.join(targetRoot, 'cache')), true); + assert.equal(await exists(path.join(targetRoot, 'openclaw', 'cache')), true); + assert.equal(await exists(path.join(targetRoot, 'cache', 'skip.txt')), false); + assert.equal(await exists(path.join(targetRoot, 'openclaw', 'logs', 'skip.txt')), false); +}); + +test('prepareAgentLarkConfigDir keeps profile layout when openclaw exists', async () => { + const root = await makeTempRoot(); + const sourceRoot = path.join(root, 'source'); + const targetRoot = path.join(root, 'target'); + const sourceProfile = path.join(sourceRoot, 'openclaw'); + + await fs.mkdir(path.join(sourceProfile, 'cache'), { recursive: true }); + await fs.writeFile(path.join(sourceRoot, 'config.json'), '{"layout":"root"}\n', 'utf8'); + await fs.writeFile(path.join(sourceProfile, 'config.json'), '{"layout":"profile"}\n', 'utf8'); + await fs.writeFile(path.join(sourceProfile, 'cache', 'skip.txt'), 'cached\n', 'utf8'); + + const configRoot = await prepareAgentLarkConfigDir({ sourceRoot, targetRoot }); + + assert.equal(configRoot, targetRoot); + assert.equal(await fs.readFile(path.join(targetRoot, 'openclaw', 'config.json'), 'utf8'), '{"layout":"profile"}\n'); + assert.equal(await exists(path.join(targetRoot, 'config.json')), false); + assert.equal(await exists(path.join(targetRoot, 'openclaw', 'cache')), true); + assert.equal(await exists(path.join(targetRoot, 'openclaw', 'cache', 'skip.txt')), false); +}); + +test('prepareAgentLarkConfigDir resets stale target content between layouts', async () => { + const root = await makeTempRoot(); + const flatSource = path.join(root, 'flat-source'); + const profileSource = path.join(root, 'profile-source'); + const targetRoot = path.join(root, 'target'); + + await fs.mkdir(flatSource, { recursive: true }); + await fs.writeFile(path.join(flatSource, 'config.json'), '{"layout":"flat"}\n', 'utf8'); + await prepareAgentLarkConfigDir({ sourceRoot: flatSource, targetRoot }); + await fs.writeFile(path.join(targetRoot, 'obsolete.json'), '{"stale":true}\n', 'utf8'); + await fs.writeFile(path.join(targetRoot, 'cache', 'stale.txt'), 'stale cache\n', 'utf8'); + await fs.writeFile(path.join(targetRoot, 'openclaw', 'logs', 'stale.txt'), 'stale log\n', 'utf8'); + + await fs.mkdir(path.join(profileSource, 'openclaw'), { recursive: true }); + await fs.writeFile(path.join(profileSource, 'openclaw', 'config.json'), '{"layout":"profile"}\n', 'utf8'); + + await prepareAgentLarkConfigDir({ sourceRoot: profileSource, targetRoot }); + + assert.equal(await exists(path.join(targetRoot, 'config.json')), false); + assert.equal(await exists(path.join(targetRoot, 'obsolete.json')), false); + assert.equal(await exists(path.join(targetRoot, 'cache', 'stale.txt')), false); + assert.equal(await exists(path.join(targetRoot, 'openclaw', 'logs', 'stale.txt')), false); + assert.equal(await fs.readFile(path.join(targetRoot, 'openclaw', 'config.json'), 'utf8'), '{"layout":"profile"}\n'); +}); + +test('prepareAgentLarkConfigDir ignores runtime-only openclaw when flat config exists', async () => { + const root = await makeTempRoot(); + const sourceRoot = path.join(root, 'source'); + const targetRoot = path.join(root, 'target'); + + await fs.mkdir(path.join(sourceRoot, 'openclaw', 'logs'), { recursive: true }); + await fs.writeFile(path.join(sourceRoot, 'config.json'), '{"layout":"flat"}\n', 'utf8'); + await fs.writeFile(path.join(sourceRoot, 'openclaw', 'logs', 'skip.txt'), 'logged\n', 'utf8'); + + await prepareAgentLarkConfigDir({ sourceRoot, targetRoot }); + + assert.equal(await fs.readFile(path.join(targetRoot, 'config.json'), 'utf8'), '{"layout":"flat"}\n'); + assert.equal(await fs.readFile(path.join(targetRoot, 'openclaw', 'config.json'), 'utf8'), '{"layout":"flat"}\n'); + assert.equal(await exists(path.join(targetRoot, 'openclaw', 'openclaw')), false); + assert.equal(await exists(path.join(targetRoot, 'openclaw', 'logs', 'skip.txt')), false); +}); + +test('prepareAgentLarkConfigDir reports missing lark-cli config explicitly', async () => { + const root = await makeTempRoot(); + const sourceRoot = path.join(root, 'missing'); + const targetRoot = path.join(root, 'target'); + + await assert.rejects( + prepareAgentLarkConfigDir({ sourceRoot, targetRoot }), + /lark-cli 配置目录不存在/ + ); +});