Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 25 additions & 4 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,37 @@ PORT=3321
HTTPS_PORT=3443
# CODEXMOBILE_PUBLIC_URL=https://<your-device>.<your-tailnet>.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://<your-device>.<your-tailnet>.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\<you>\.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\<you>\AppData\Local\OpenAI\Codex\bin\codex.exe

# Optional Feishu/Lark docs integration
# Keep secrets server-side only. CodexMobile uses the official lark-cli
Expand Down Expand Up @@ -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
Expand Down
26 changes: 21 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ CodexMobile 和 Remodex 这类项目都在解决“移动端使用 Codex”的
- 前台 toast:任务完成、任务失败、需要用户输入、Git 进度都会提示。
- Web Push:在平台和浏览器支持的 HTTPS PWA 环境中,可接收后台完成通知。
- 连接恢复卡片:断开、重连、需配对、同步中、桌面端不可用时,会给出重试、同步、重新配对、查看状态等入口。
- 配对使用一次性配对码和设备 token,适合单用户私有网络使用。
- 配对使用局域网一次性请求、控制台配对码和 HttpOnly 设备 Cookie,适合单用户私有网络使用。

### 本机工具能力

Expand All @@ -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
Expand Down Expand Up @@ -186,7 +186,7 @@ http://127.0.0.1:3321
http://<电脑的私网 IP>:3321
```

第一次进入需要输入服务启动时打印的 6 位配对码。配对成功后,浏览器会保存设备 token,后续不需要每次重新输入。
第一次进入会先从同一局域网请求一次性配对码,电脑控制台会显示短时有效的配对码。配对成功后,浏览器使用 HttpOnly Cookie 保存设备会话,后续不需要每次重新输入。

## PWA、HTTPS 和完成通知

Expand Down Expand Up @@ -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` 兼容入口会打开同一个设置项。

## 配置

复制示例配置:
Expand All @@ -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 子进程读取
Expand Down Expand Up @@ -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`
Expand Down Expand Up @@ -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,再点击“开启完成通知”。

Expand Down
20 changes: 10 additions & 10 deletions client/src/api.js
Original file line number Diff line number Diff line change
@@ -1,33 +1,34 @@
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 || {})
};

let response;
try {
response = await fetch(path, {
...fetchOptions,
credentials: 'same-origin',
headers,
signal: fetchOptions.signal || controller?.signal,
body:
Expand All @@ -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'
Expand Down Expand Up @@ -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`;
}
25 changes: 19 additions & 6 deletions client/src/app-state.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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' } });
Expand Down Expand Up @@ -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'
);
});
Loading