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
107 changes: 107 additions & 0 deletions .github/scripts/electron-smoke.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
param()

$ErrorActionPreference = "Stop"

$LogBase = if ($env:SMOKE_LOG) { $env:SMOKE_LOG } else { [System.IO.Path]::GetTempFileName() }
$StdoutLog = "$LogBase.out"
$StderrLog = "$LogBase.err"
$TimeoutSecs = if ($env:SMOKE_TIMEOUT) { [int]$env:SMOKE_TIMEOUT } else { 90 }
$Process = $null

function Get-SmokeLog {
$parts = @()
if (Test-Path $StdoutLog) { $parts += Get-Content -Raw -Path $StdoutLog }
if (Test-Path $StderrLog) { $parts += Get-Content -Raw -Path $StderrLog }
return ($parts -join "`n")
}

function Fail-Smoke {
param([string]$Message)
Write-Host "::error::$Message"
Write-Host "--- smoke log ---"
$text = Get-SmokeLog
if ($text) { Write-Host $text } else { Write-Host "(no log captured)" }
exit 1
}

try {
if (-not $env:RELEASE_DIR) {
Fail-Smoke "RELEASE_DIR not set"
}

if (-not (Test-Path -Path $env:RELEASE_DIR -PathType Container)) {
Fail-Smoke "RELEASE_DIR=$env:RELEASE_DIR does not exist"
}

$UnpackedDir = Join-Path $env:RELEASE_DIR "win-unpacked"
if (-not (Test-Path -Path $UnpackedDir -PathType Container)) {
Fail-Smoke "Windows unpacked directory not found under $UnpackedDir"
}

$Binary = Get-ChildItem -Path $UnpackedDir -Filter "*.exe" -File |
Sort-Object Name |
Select-Object -First 1
if (-not $Binary) {
Fail-Smoke "Windows exe not found under $UnpackedDir"
}

Write-Host "Launching: $($Binary.FullName) --no-sandbox"
Write-Host "Log: $LogBase"
Write-Host "Timeout: ${TimeoutSecs}s"

$Process = Start-Process `
-FilePath $Binary.FullName `
-ArgumentList "--no-sandbox" `
-RedirectStandardOutput $StdoutLog `
-RedirectStandardError $StderrLog `
-PassThru

$Deadline = (Get-Date).AddSeconds($TimeoutSecs)
$Port = $null
while ((Get-Date) -lt $Deadline) {
$text = Get-SmokeLog
$match = [regex]::Matches($text, "Server started on port (\d+)") | Select-Object -Last 1
if ($match) {
$Port = $match.Groups[1].Value
break
}

if ($Process.HasExited) {
Fail-Smoke "App process exited before starting the server (pid=$($Process.Id))"
}

Start-Sleep -Seconds 1
}

if (-not $Port) {
Fail-Smoke "App did not log 'Server started on port N' within ${TimeoutSecs}s"
}

Write-Host "App reports server on port $Port"

$HealthOk = $false
for ($i = 1; $i -le 30; $i++) {
try {
$Response = Invoke-WebRequest -Uri "http://127.0.0.1:$Port/health" -UseBasicParsing -TimeoutSec 5
if ([int]$Response.StatusCode -ge 200 -and [int]$Response.StatusCode -lt 300) {
$HealthOk = $true
if ($i -gt 1) { Write-Host "Health probe succeeded after $i attempt(s)" }
break
}
} catch {
Start-Sleep -Seconds 1
}
}

if (-not $HealthOk) {
Fail-Smoke "Health probe failed: GET http://127.0.0.1:$Port/health (gave up after 30s)"
}

Write-Host "Smoke OK on Windows"
} finally {
if ($Process -and -not $Process.HasExited) {
Write-Host "Stopping app (pid=$($Process.Id))"
Stop-Process -Id $Process.Id -Force -ErrorAction SilentlyContinue
}
Get-Process "Codex Proxy" -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
}
1 change: 1 addition & 0 deletions .github/workflows/promote-dev-to-master.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ jobs:
else
echo "ok=false" >> "$GITHUB_OUTPUT"
echo "::warning::master has commits not in dev — manual rebase needed (likely a hotfix landed directly on master)"
exit 1
fi

- name: Check soak (>= 24h since latest dev commit)
Expand Down
10 changes: 10 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ jobs:

- name: Clean stale release assets
continue-on-error: true
shell: bash
run: |
TAG="${{ inputs.tag || github.ref_name }}"
ASSETS=$(gh release view "$TAG" --json assets -q '.assets[].name' 2>/dev/null || echo "")
Expand Down Expand Up @@ -134,7 +135,16 @@ jobs:
# Electron renderer can spawn a window in a headless runner.
sudo apt-get install -y libfuse2 xvfb

- name: Smoke test packaged binary (win)
if: matrix.platform == 'win'
shell: pwsh
env:
RELEASE_DIR: packages/electron/release
SMOKE_LOG: ${{ runner.temp }}/electron-smoke.log
run: ./.github/scripts/electron-smoke.ps1

- name: Smoke test packaged binary (${{ matrix.platform }}${{ matrix.arch && format('-{0}', matrix.arch) || '' }})
if: matrix.platform != 'win'
shell: bash
env:
RELEASE_DIR: packages/electron/release
Expand Down
14 changes: 14 additions & 0 deletions CHANGELOG.md

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ RUN npm ci && npm run build
# ── Stage 2: Application ────────────────────────────────────────────
FROM node:20-slim

# The checked-in default is loopback-only for local source installs. Containers
# need to listen on all interfaces inside the network namespace so published
# ports and Docker health checks can reach the service.
ENV CODEX_PROXY_HOST=0.0.0.0

# curl: needed by full-update.ts
# unzip: needed by full-update.ts to extract Codex.app
# gosu: needed by entrypoint to drop from root to node user
Expand Down
25 changes: 19 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ curl http://localhost:8080/v1/chat/completions \
- **多账号轮换** — `least_used`(最少使用优先)、`round_robin`(轮询)、`sticky`(粘性)三种策略
- **Plan Routing** — 不同 plan(free/plus/team/business)的账号自动路由到各自支持的模型
- **Token 自动续期** — JWT 到期前自动刷新,指数退避重试
- **配额被动采集** — 从上游响应头和 WebSocket rate limit 事件更新账号额度;`quota.refresh_interval_minutes` 仅控制用量快照记录,`0` 表示关闭快照定时器
- **配额采集** — 默认从上游响应头和 WebSocket rate limit 事件被动更新账号额度;用户手动查询单账号额度时会调用 `/backend-api/wham/usage`,并把 `remaining_percent = 100 - used_percent` 写入缓存
- **封禁检测** — 上游 403 自动标记 banned;401 token 吊销自动过期并切换账号
- **API Key Provider 池** — 支持通过 Dashboard 管理第三方 API Key、模型列表、导入导出和启停状态。
- **Web 控制面板** — 账号管理、用量统计、批量操作,中英双语;远程访问需 Dashboard 登录门
Expand Down Expand Up @@ -572,14 +572,16 @@ model:

### 局域网访问

源码/容器默认配置监听 `::`(IPv6 unspecified,通常也覆盖本机访问);Electron 启动时会传入 `127.0.0.1`,除非 `data/local.yaml` 显式覆盖。建议需要仅本机访问时写入:
源码默认配置仅监听 `127.0.0.1`;Electron 也会传入 `127.0.0.1`,除非 `data/local.yaml` 显式覆盖。Docker 镜像会通过 `CODEX_PROXY_HOST=0.0.0.0` 在容器内监听所有接口,`docker-compose.yml` 默认仍只把宿主机端口绑定到 `127.0.0.1`。

需要仅本机访问时写入:

```yaml
server:
host: "127.0.0.1"
```

如需局域网内其他设备访问,在 `data/local.yaml` 中添加:
如需局域网内其他设备访问,在 `data/local.yaml` 中添加,并把 `docker-compose.yml` 的端口映射从 `127.0.0.1:${PORT:-8080}:8080` 改成 `${PORT:-8080}:8080`

```yaml
server:
Expand Down Expand Up @@ -711,6 +713,7 @@ curl -N http://localhost:8080/official-agent/threads/{threadId}/turns \
| 环境变量 | 覆盖配置 |
|---------|---------|
| `PORT` | `server.port` |
| `CODEX_PROXY_HOST` | `server.host`(仅当 `data/local.yaml` 未显式设置 `server.host` 时生效) |
| `CODEX_PLATFORM` | `client.platform` |
| `CODEX_ARCH` | `client.arch` |
| `HTTPS_PROXY` | `tls.proxy_url` |
Expand Down Expand Up @@ -745,10 +748,10 @@ curl -N http://localhost:8080/official-agent/threads/{threadId}/turns \
| 端点 | 方法 | 说明 |
|------|------|------|
| `/auth/login` | GET | OAuth 登录入口 |
| `/auth/accounts` | GET | 账号列表(`?quota=true` / `?quota=fresh`) |
| `/auth/accounts` | GET | 账号列表(含缓存额度) |
| `/auth/accounts` | POST | 添加单个账号(token 或 refreshToken) |
| `/auth/accounts/import` | POST | 批量导入账号 |
| `/auth/accounts/export` | GET | 导出账号(`?format=minimal` 精简格式) |
| `/auth/accounts/import` | POST | 批量导入账号(JSON / `text/plain` token 行) |
| `/auth/accounts/export` | GET | 导出账号(`?format=full|minimal|cockpit_tools|sub2api|cpa`) |
| `/auth/accounts/batch-delete` | POST | 批量删除账号 |
| `/auth/accounts/batch-status` | POST | 批量修改账号状态 |
| `/auth/accounts/health-check` | POST | 批量检测账号可用性 |
Expand Down Expand Up @@ -782,6 +785,10 @@ curl -s http://localhost:8080/auth/accounts/export \
curl -s "http://localhost:8080/auth/accounts/export?format=minimal" \
-H "Authorization: Bearer your-api-key" > backup-minimal.json

# 导出第三方兼容格式
curl -s "http://localhost:8080/auth/accounts/export?format=sub2api" \
-H "Authorization: Bearer your-api-key" > sub2api-accounts.json

# 批量导入(支持 token、refreshToken,或两者同时传)
curl -X POST http://localhost:8080/auth/accounts/import \
-H "Content-Type: application/json" \
Expand All @@ -795,6 +802,12 @@ curl -X POST http://localhost:8080/auth/accounts/import \
}'
# 返回: { "added": 2, "updated": 1, "failed": 0, "errors": [] }

# text/plain token 行导入(每行 access token 或 refresh token)
curl -X POST http://localhost:8080/auth/accounts/import \
-H "Content-Type: text/plain" \
-H "Authorization: Bearer your-api-key" \
--data-binary $'eyJhbGciOi...\noaistb_rt_...\n'

# 备份恢复一键操作(导出后直接导入到另一个实例)
curl -X POST http://localhost:8080/auth/accounts/import \
-H "Content-Type: application/json" \
Expand Down
2 changes: 1 addition & 1 deletion config/default.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ auth:
oauth_auth_endpoint: https://auth.openai.com/oauth/authorize
oauth_token_endpoint: https://auth.openai.com/oauth/token
server:
host: "::"
host: "127.0.0.1"
port: 8080
proxy_api_key: null
trust_proxy: false
Expand Down
5 changes: 4 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ services:
extra_hosts:
- "host.docker.internal:host-gateway"
ports:
- "${PORT:-8080}:8080"
# Loopback-only on the host by default; change to "${PORT:-8080}:8080"
# if you intentionally want LAN access.
- "127.0.0.1:${PORT:-8080}:8080"
- "1455:1455"
# Optional Ollama-compatible bridge. Enable ollama.enabled and use
# ollama.host=0.0.0.0 inside the container before uncommenting.
Expand All @@ -22,6 +24,7 @@ services:
environment:
- NODE_ENV=production
- PORT=8080
- CODEX_PROXY_HOST=0.0.0.0

# -- Automatic updates (uncomment to enable) --
# watchtower:
Expand Down
61 changes: 61 additions & 0 deletions shared/account-transfer-client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import {
accountExportDownloadName,
buildAccountExportUrl,
prepareAccountImportRequest,
} from "./account-transfer-client";

function makeFile(name: string, text: string, type = "") {
return {
name,
type,
text: async () => text,
};
}

describe("account transfer browser client helpers", () => {
it("builds export URLs and download names for compatibility formats", () => {
expect(buildAccountExportUrl(["acct-1", "acct-2"], "sub2api"))
.toBe("/auth/accounts/export?ids=acct-1%2Cacct-2&format=sub2api");
expect(buildAccountExportUrl(undefined, "full")).toBe("/auth/accounts/export");
expect(accountExportDownloadName("cockpit_tools", "2026-05-18"))
.toBe("accounts-export-cockpit-tools-2026-05-18.json");
});

it("keeps JSON import payloads intact instead of forcing accounts arrays", async () => {
const request = await prepareAccountImportRequest(makeFile(
"sub2api.json",
JSON.stringify({ type: "sub2api-data", accounts: [{ credentials: { access_token: "token" } }] }),
"application/json",
));

expect(request).toEqual({
ok: true,
contentType: "application/json",
body: JSON.stringify({ type: "sub2api-data", accounts: [{ credentials: { access_token: "token" } }] }),
});
});

it("prepares text/plain token line imports", async () => {
const request = await prepareAccountImportRequest(makeFile(
"tokens.txt",
"plain.access.token\nrt_text_only\n",
"text/plain",
));

expect(request).toEqual({
ok: true,
contentType: "text/plain",
body: "plain.access.token\nrt_text_only\n",
});
});

it("rejects malformed .json files before sending them", async () => {
const request = await prepareAccountImportRequest(makeFile("broken.json", "{not json", "application/json"));

expect(request).toEqual({
ok: false,
error: "Invalid JSON file",
});
});
});
67 changes: 67 additions & 0 deletions shared/account-transfer-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
export type AccountExportFormat = "full" | "minimal" | "cockpit_tools" | "sub2api" | "cpa";

export const ACCOUNT_EXPORT_FORMATS: AccountExportFormat[] = [
"full",
"minimal",
"cockpit_tools",
"sub2api",
"cpa",
];

export interface AccountImportFile {
name: string;
type?: string;
text(): Promise<string>;
}

export type PreparedAccountImportRequest =
| { ok: true; contentType: "application/json" | "text/plain"; body: string }
| { ok: false; error: string };

function isLikelyJsonFile(file: AccountImportFile): boolean {
const name = file.name.toLowerCase();
const type = file.type?.toLowerCase() ?? "";
return name.endsWith(".json") || type.includes("json");
}

function isJsonText(text: string): boolean {
const trimmed = text.trimStart();
return trimmed.startsWith("{") || trimmed.startsWith("[");
}

export function buildAccountExportUrl(
selectedIds: string[] | undefined,
format: AccountExportFormat = "full",
): string {
const params = new URLSearchParams();
if (selectedIds && selectedIds.length > 0) params.set("ids", selectedIds.join(","));
if (format !== "full") params.set("format", format);
const qs = params.toString();
return `/auth/accounts/export${qs ? `?${qs}` : ""}`;
}

export function accountExportDownloadName(
format: AccountExportFormat,
date = new Date().toISOString().slice(0, 10),
): string {
const suffix = format === "full" ? "" : `-${format.replaceAll("_", "-")}`;
return `accounts-export${suffix}-${date}.json`;
}

export async function prepareAccountImportRequest(
file: AccountImportFile,
): Promise<PreparedAccountImportRequest> {
const body = await file.text();
if (!body.trim()) return { ok: false, error: "No importable content" };

if (isLikelyJsonFile(file) || isJsonText(body)) {
try {
JSON.parse(body) as unknown;
return { ok: true, contentType: "application/json", body };
} catch {
if (isLikelyJsonFile(file)) return { ok: false, error: "Invalid JSON file" };
}
}

return { ok: true, contentType: "text/plain", body };
}
Loading
Loading