Skip to content
Merged
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ http://127.0.0.1:7420

默认安装最新正式 Release,并校验 Release 包的 SHA-256;只有参与源码预览时才应显式使用 `-UseBranchArchive`。

安装完成后,电脑本机打开 `http://127.0.0.1:7420/local-setup` 查看访问密码;该页面不允许通过公网域名访问
安装完成后,电脑本机打开 `http://127.0.0.1:7420/local-setup`,用手机扫描二维码打开临时地址,再输入页面显示的访问密码。二维码只在本机生成且只包含地址,不包含密码;配对页不允许通过公网域名访问

`-JsonOutput` 的 stdout 固定为单行 JSON,构建进度和诊断写入 stderr。成功结果包含 `schemaVersion`、`operation`、`version`、`started`、`healthReady`、本机/公网地址和结构化告警;失败结果返回 `BOOTSTRAP_FAILED` 与失败阶段,不输出密码、Cookie 或 Token。bootstrap 还会读取归档内的 `release-capabilities.json`,拒绝把新版参数交给不支持它们的旧 Release。

Expand Down
1 change: 1 addition & 0 deletions docs/changelog.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

- 长时稳定性浸泡现在持续验证本机事件回放结构与 `latestSeq` 单调性,并在配置公网地址时逐样本确认未登录 Codex API 始终返回 HTTP 401;JSON 报告新增回放失败、鉴权失败和事件序号倒退汇总,避免只看 `/health` 而漏掉消息接收链路或远程鉴权回归。
- 设置中的“手机访问”前置到基础设置之后,新人无需先滚过套餐余量和权限控制即可生成、复制、打开或停止临时地址;功能、安全验证和视觉样式保持不变。
- 仅限本机的手机配对页新增本地生成二维码,手机相机可直接打开已验证的临时地址;二维码不包含访问密码、不调用第三方二维码服务,配对页继续对公网请求返回 404。

## 2.5.5 - 2026-07-25

Expand Down
10 changes: 5 additions & 5 deletions docs/new-user-install-review-20260725.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

从没有 CX-Codex 运行环境的 Windows 状态出发,按 README 的一行命令已经能够完成安装、构建、启动、免费临时公网访问、密码登录、发送消息和接收回复。

首次源码预览安装最终成功用时 153.5 秒。正式 2.5.4 的公开资产、五进程无警告卸载和无安装状态重装均已通过;首次升级时 Cloudflare 临时地址一度完全不可达,手动再次开启约 20 秒即成功,2.5.5 候选已只对这种 `HTTP unreachable` 自动重试一次。当前仍不能称为真正“傻瓜式”,主要差距变为:下载阶段缺少连续进度、首次工作区选择和二维码配对仍不够明确
首次源码预览安装最终成功用时 153.5 秒。正式 2.5.4 的公开资产、五进程无警告卸载和无安装状态重装均已通过;首次升级时 Cloudflare 临时地址一度完全不可达,手动再次开启约 20 秒即成功,2.5.5 已只对这种 `HTTP unreachable` 自动重试一次。当前 `main` 还补上了仅限本机的二维码配对页。项目仍不能称为真正“傻瓜式”,主要差距变为:下载阶段缺少连续进度、首次工作区选择不够明确,以及手机扫码后仍需手动输入密码

## 测试边界

Expand Down Expand Up @@ -34,8 +34,8 @@
```

2. 等待源码下载、依赖安装、前端/CLI 构建和 cloudflared 下载。
3. 本机打开 `http://127.0.0.1:7420/local-setup`,只在本机查看访问密码
4. 手机打开安装结果中的临时 HTTPS 地址并输入密码
3. 本机打开 `http://127.0.0.1:7420/local-setup`,用手机相机扫描本机生成的二维码
4. 手机打开临时 HTTPS 地址并输入配对页显示的密码;二维码本身不包含密码
5. 浏览器中新建或选择工作区,本次选择 `CodexWorkspace`。
6. 发送一条测试消息并等待回复。
7. 不再需要手机入口时,在设置的“手机访问”卡片中停止临时地址;退出 CX-Codex 后地址也会失效。
Expand Down Expand Up @@ -100,7 +100,7 @@

- 临时地址会变化,无 SLA,退出进程后失效。
- 安全模式默认不创建自启动任务;重启电脑后需要重新启动并获得新地址。
- 需要先在本机打开配对页,再到手机输入地址和密码;还没有二维码配对闭环
- 仍需先在本机打开配对页并在手机输入密码;地址已可扫码打开,密码刻意不写入二维码或 URL

### P2:可维护性

Expand All @@ -121,7 +121,7 @@
### 第二阶段:首次引导

1. 安装完成自动打开本机引导页,按“Codex 登录 → 选择工作区 → 本机健康 → 手机访问”显示四步状态。
2. 配对页同时显示二维码、可复制地址、可复制密码、有效期和停止按钮
2. 部分完成:配对页已显示本地生成的二维码、地址和密码,二维码不含密码且公网无法打开配对页;后续再补有效期和停止按钮
3. 首次消息提供“发送测试消息”按钮,自动验证发送、实时进度、回复和断线恢复。
4. 明确提供“安全临时访问”和“长期固定访问”两个模式,后者引导 Tailscale 或命名 Cloudflare Tunnel。

Expand Down
31 changes: 31 additions & 0 deletions scripts/server-module-smoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -435,6 +435,10 @@ import {
CODEX_BRIDGE_SHARED_STATE_KEY,
getCodexBridgeSharedState,
} from '../src/server/codexBridgeSharedState.js'
import {
renderLocalSetupHtml,
renderPairingQrSvg,
} from '../src/server/localPairingPage.js'

const originalNow = Date.now

Expand All @@ -456,6 +460,7 @@ try {
smokeAppServerLaunch()
smokeAppServerHealth()
await smokeAuthMiddleware()
smokeLocalPairingPage()
await smokeAppServerMethodCatalog()
smokeAppServerNotificationDiagnostics()
smokeAppServerNotificationListeners()
Expand Down Expand Up @@ -1512,6 +1517,32 @@ async function smokeAuthMiddleware(): Promise<void> {
}
}

function smokeLocalPairingPage(): void {
const publicUrl = 'https://pairing.example.test/connect?a=1&b=2'
const qrSvg = renderPairingQrSvg(publicUrl)
assert.match(qrSvg, /^<svg class="pairing-qr"/u)
assert.match(qrSvg, /aria-label="手机访问地址二维码"/u)
assert.match(qrSvg, /<path d="M/u)
assert.equal(qrSvg.includes(publicUrl), false)
assert.equal(renderPairingQrSvg(' '), '')

const html = renderLocalSetupHtml({
password: 'secret<&"',
publicUrl,
})
assert.match(html, /二维码只包含手机访问地址,不包含访问密码/u)
assert.match(html, /https:\/\/pairing\.example\.test\/connect\?a=1&amp;b=2/u)
assert.match(html, /secret&lt;&amp;&quot;/u)
assert.match(html, /在电脑上测试手机地址/u)

const inactiveHtml = renderLocalSetupHtml({
password: 'secret',
publicUrl: '',
})
assert.match(inactiveHtml, /临时地址尚未生成/u)
assert.equal(inactiveHtml.includes('<svg class="pairing-qr"'), false)
}

function smokeAppServerNotificationDiagnostics(): void {
assert.equal(isKnownAppServerNotificationMethod('turn/started'), true)
assert.equal(isKnownAppServerNotificationMethod('thread/archived'), true)
Expand Down
21 changes: 18 additions & 3 deletions scripts/verify-quick-tunnel.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,14 @@ try {
throw "Isolated CX-Codex server did not become healthy."
}

$localSetupStatus = (
Invoke-WebRequest -UseBasicParsing -Uri "http://127.0.0.1:$Port/local-setup" -TimeoutSec 5
).StatusCode
$localSetupBeforeTunnel = Invoke-WebRequest `
-UseBasicParsing `
-Uri "http://127.0.0.1:$Port/local-setup" `
-TimeoutSec 5
$localSetupStatus = $localSetupBeforeTunnel.StatusCode
if ($localSetupBeforeTunnel.Content -match 'aria-label="手机访问地址二维码"') {
throw "Local pairing page rendered a QR code before the tunnel was active."
}
$remoteSetupStatus = 0
try {
Invoke-WebRequest `
Expand Down Expand Up @@ -90,8 +95,17 @@ try {
}
}

$localSetupHasQr = $false
$stopped = $true
if ($startState -and $startState.active) {
$localSetupReady = Invoke-WebRequest `
-UseBasicParsing `
-Uri "http://127.0.0.1:$Port/local-setup" `
-TimeoutSec 5
$localSetupHasQr = $localSetupReady.Content -match 'aria-label="手机访问地址二维码"'
if (-not $localSetupHasQr) {
throw "Local pairing page did not render a QR code for the active tunnel."
}
Invoke-RestMethod `
-Method Delete `
-Uri "http://127.0.0.1:$Port/codex-api/tunnel-status" `
Expand All @@ -108,6 +122,7 @@ try {
tunnelActive = [bool]$startState.active
phase = if ($startState) { [string]$startState.phase } else { "error" }
publicUrlReturned = -not [string]::IsNullOrWhiteSpace([string]$startState.publicUrl)
localSetupHasQr = $localSetupHasQr
verification = if ($startState) { $startState.verification } else { $null }
errorCode = $startErrorCode
stopped = $stopped
Expand Down
58 changes: 6 additions & 52 deletions src/server/httpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import express, { type Express } from 'express'
import { createCodexBridgeMiddleware } from './codexAppServerBridge.js'
import { createAuthSession, isLoopbackRequest } from './authMiddleware.js'
import { getQuickTunnelSnapshot } from './quickTunnel.js'
import { renderLocalSetupHtml } from './localPairingPage.js'
import { createDirectoryListingHtml, createLocalFileActionHtml, createTextEditorHtml, decodeBrowsePath, isPreviewableLocalPath, isTextEditableFile, normalizeLocalPath, toLocalFilePreviewHref } from './localBrowseUi.js'
import {
NOTIFICATION_WEBSOCKET_MAX_INBOUND_BYTES,
Expand Down Expand Up @@ -96,57 +97,6 @@ function renderFrontendMissingHtml(message: string, details?: string[]): string
].join('')
}

function escapeHtml(value: string): string {
return value
.replace(/&/gu, '&amp;')
.replace(/</gu, '&lt;')
.replace(/>/gu, '&gt;')
.replace(/"/gu, '&quot;')
.replace(/'/gu, '&#39;')
}

function renderLocalSetupHtml(password: string): string {
const tunnel = getQuickTunnelSnapshot()
const publicUrl = tunnel.active ? tunnel.publicUrl : ''
const publicLink = publicUrl
? `<a class="primary" href="${escapeHtml(publicUrl)}" target="_blank" rel="noreferrer">打开手机访问地址</a>`
: '<p class="muted">临时地址尚未生成,可在 CX-Codex 设置的“手机访问”中开启。</p>'
return `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="robots" content="noindex,nofollow">
<title>CX-Codex 本机配对</title>
<style>
body{margin:0;background:#f4f7f6;color:#17201e;font-family:system-ui,-apple-system,"Segoe UI",sans-serif}
main{max-width:560px;margin:0 auto;padding:48px 20px}
.card{border:1px solid #dbe5e2;border-radius:18px;background:#fff;padding:26px;box-shadow:0 16px 42px rgba(20,55,47,.08)}
.kicker{margin:0;color:#0f766e;font-size:12px;font-weight:700;letter-spacing:.1em;text-transform:uppercase}
h1{margin:8px 0 10px;font-size:24px}
.muted{color:#62706d;line-height:1.6}
dl{display:grid;gap:12px;margin:22px 0}dt{color:#77837f;font-size:12px}dd{margin:4px 0 0}
code{display:block;overflow-wrap:anywhere;border:1px solid #dbe5e2;border-radius:10px;background:#f7faf9;padding:12px;font-size:14px}
.primary{display:inline-flex;border-radius:10px;background:#0f766e;color:#fff;padding:11px 15px;text-decoration:none;font-weight:650}
.warning{margin-top:18px;border-left:3px solid #d97706;padding-left:12px;color:#79511d;font-size:13px;line-height:1.55}
</style>
</head>
<body>
<main><section class="card">
<p class="kicker">仅限本机</p>
<h1>CX-Codex 手机配对</h1>
<p class="muted">在手机打开临时地址后,输入下面的访问密码。密码不会写入公网链接。</p>
<dl>
<div><dt>手机访问地址</dt><dd><code>${escapeHtml(publicUrl || '尚未生成')}</code></dd></div>
<div><dt>访问密码</dt><dd><code>${escapeHtml(password || '当前未启用密码')}</code></dd></div>
</dl>
${publicLink}
<p class="warning">只在你自己的电脑上打开本页,不要截图或转发访问密码。临时地址停止后会失效。</p>
</section></main>
</body>
</html>`
}

function normalizeLocalImagePath(rawPath: string): string {
const trimmed = rawPath.trim()
if (!trimmed) return ''
Expand Down Expand Up @@ -223,9 +173,13 @@ export function createServer(options: ServerOptions = {}): ServerInstance {
res.status(404).end()
return
}
const tunnel = getQuickTunnelSnapshot()
res.setHeader('Cache-Control', 'no-store')
res.setHeader('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'")
res.status(200).type('text/html; charset=utf-8').send(renderLocalSetupHtml(options.password ?? ''))
res.status(200).type('text/html; charset=utf-8').send(renderLocalSetupHtml({
password: options.password ?? '',
publicUrl: tunnel.active ? tunnel.publicUrl : '',
}))
})

// 1. Auth middleware (if password is set)
Expand Down
125 changes: 125 additions & 0 deletions src/server/localPairingPage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { createRequire } from 'node:module'

type QrCodeInstance = {
addData: (value: string) => void
getModuleCount: () => number
isDark: (row: number, column: number) => boolean
make: () => void
}

type QrCodeConstructor = new (typeNumber: number, errorCorrectLevel: number) => QrCodeInstance

const require = createRequire(import.meta.url)
const QrCode = require('qrcode-terminal/vendor/QRCode') as QrCodeConstructor
const qrErrorCorrectLevel = require('qrcode-terminal/vendor/QRCode/QRErrorCorrectLevel') as {
M: number
}
const QR_QUIET_ZONE_MODULES = 4

function escapeHtml(value: string): string {
return value
.replace(/&/gu, '&amp;')
.replace(/</gu, '&lt;')
.replace(/>/gu, '&gt;')
.replace(/"/gu, '&quot;')
.replace(/'/gu, '&#39;')
}

export function renderPairingQrSvg(value: string): string {
const normalizedValue = value.trim()
if (!normalizedValue) return ''

const qrCode = new QrCode(-1, qrErrorCorrectLevel.M)
qrCode.addData(normalizedValue)
qrCode.make()

const moduleCount = qrCode.getModuleCount()
const viewBoxSize = moduleCount + QR_QUIET_ZONE_MODULES * 2
const darkModules: string[] = []
for (let row = 0; row < moduleCount; row += 1) {
for (let column = 0; column < moduleCount; column += 1) {
if (!qrCode.isDark(row, column)) continue
darkModules.push(
`M${String(column + QR_QUIET_ZONE_MODULES)} ${String(row + QR_QUIET_ZONE_MODULES)}h1v1h-1z`,
)
}
}

return [
`<svg class="pairing-qr" viewBox="0 0 ${String(viewBoxSize)} ${String(viewBoxSize)}"`,
' role="img" aria-label="手机访问地址二维码" shape-rendering="crispEdges"',
' xmlns="http://www.w3.org/2000/svg">',
'<title>手机访问地址二维码</title>',
`<rect width="${String(viewBoxSize)}" height="${String(viewBoxSize)}" fill="#fff"/>`,
`<path d="${darkModules.join('')}" fill="#111827"/>`,
'</svg>',
].join('')
}

export function renderLocalSetupHtml(options: {
password: string
publicUrl: string
}): string {
const publicUrl = options.publicUrl.trim()
const qrCode = renderPairingQrSvg(publicUrl)
const pairingGuide = publicUrl
? [
'<div class="pairing">',
`<div class="qr-shell">${qrCode}</div>`,
'<div>',
'<p class="step-label">手机连接</p>',
'<h2>扫描二维码打开地址</h2>',
'<ol>',
'<li>用手机相机扫描二维码。</li>',
'<li>在打开的页面输入下方访问密码。</li>',
'<li>看到聊天界面后即可开始使用。</li>',
'</ol>',
'</div>',
'</div>',
].join('')
: '<p class="muted empty">临时地址尚未生成,可在 CX-Codex 设置的“手机访问”中开启。</p>'
const publicLink = publicUrl
? `<a class="primary" href="${escapeHtml(publicUrl)}" target="_blank" rel="noreferrer">在电脑上测试手机地址</a>`
: ''

return `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="robots" content="noindex,nofollow">
<title>CX-Codex 本机配对</title>
<style>
body{margin:0;background:#f4f7f6;color:#17201e;font-family:system-ui,-apple-system,"Segoe UI",sans-serif}
main{max-width:620px;margin:0 auto;padding:40px 20px}
.card{border:1px solid #dbe5e2;border-radius:18px;background:#fff;padding:26px;box-shadow:0 16px 42px rgba(20,55,47,.08)}
.kicker,.step-label{margin:0;color:#0f766e;font-size:12px;font-weight:700;letter-spacing:.1em;text-transform:uppercase}
h1{margin:8px 0 10px;font-size:24px}h2{margin:6px 0 10px;font-size:18px}
.muted{color:#62706d;line-height:1.6}.empty{margin:22px 0}
.pairing{display:grid;grid-template-columns:184px minmax(0,1fr);align-items:center;gap:24px;margin:24px 0}
.qr-shell{border:1px solid #dbe5e2;border-radius:16px;background:#fff;padding:10px}
.pairing-qr{display:block;width:100%;height:auto}
ol{margin:0;padding-left:20px;color:#52615e;font-size:14px;line-height:1.8}
dl{display:grid;gap:12px;margin:22px 0}dt{color:#77837f;font-size:12px}dd{margin:4px 0 0}
code{display:block;overflow-wrap:anywhere;border:1px solid #dbe5e2;border-radius:10px;background:#f7faf9;padding:12px;font-size:14px}
.primary{display:inline-flex;border-radius:10px;background:#0f766e;color:#fff;padding:11px 15px;text-decoration:none;font-weight:650}
.warning{margin-top:18px;border-left:3px solid #d97706;padding-left:12px;color:#79511d;font-size:13px;line-height:1.55}
@media(max-width:520px){main{padding:18px 12px}.card{padding:20px}.pairing{grid-template-columns:1fr}.qr-shell{width:min(220px,calc(100% - 22px));margin:0 auto}}
</style>
</head>
<body>
<main><section class="card">
<p class="kicker">仅限本机</p>
<h1>CX-Codex 手机配对</h1>
<p class="muted">二维码只包含手机访问地址,不包含访问密码,也不会发送到第三方二维码服务。</p>
${pairingGuide}
<dl>
<div><dt>手机访问地址</dt><dd><code>${escapeHtml(publicUrl || '尚未生成')}</code></dd></div>
<div><dt>访问密码</dt><dd><code>${escapeHtml(options.password || '当前未启用密码')}</code></dd></div>
</dl>
${publicLink}
<p class="warning">只在你自己的电脑上打开本页,不要截图或转发访问密码。临时地址停止后会失效。</p>
</section></main>
</body>
</html>`
}
19 changes: 19 additions & 0 deletions tests.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,25 @@

This file tracks manual regression and feature verification steps.

## Local phone-pairing QR (2026-07-25)

### Expected behavior

1. Opening `http://127.0.0.1:7420/local-setup` while Quick Tunnel is ready shows a scannable QR code for the active public URL.
2. The QR code is generated locally, contains only the public URL, and never includes the CX-Codex password.
3. The page keeps `Cache-Control: no-store`, a script-blocking Content Security Policy, and HTTP 404 for requests made through a non-loopback/public host.
4. When no tunnel is active, the page shows the existing start-from-settings guidance and renders no QR SVG.
5. At 393 × 852 the card, QR code, address, password, and warning remain readable without horizontal overflow.

### Verification

- Run `npm.cmd run build:cli`.
- Run `npm.cmd run build:frontend`.
- Run `npm.cmd run verify:server-modules`.
- Run `node .\scripts\run-powershell-script.mjs .\scripts\verify-quick-tunnel.ps1`.
- Start an isolated password-protected server, enable a temporary Quick Tunnel, and open `/local-setup` at 393 × 852.
- Decode the rendered screenshot with a QR decoder and require it to match the server's active `publicUrl`; then stop the tunnel and isolated server.

## Phone-access settings priority (2026-07-25)

### Expected behavior
Expand Down