@yhong91/cpac 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  CPAC 将 Codex 和 Claude Code 接入远端 CLIProxyAPI(CPA):
4
4
 
5
- - **Codex**:注入模型目录和 provider,并可逐字节恢复原配置。
5
+ - **Codex**:注入模型目录和 loopback 网关,保留内置 `openai` provider,并可逐字节恢复原配置。
6
6
  - **Claude Code**:通过临时环境变量启动,不修改 Claude Code 配置。
7
7
 
8
8
  实现基于 TypeScript + Node.js,运行时零依赖。
@@ -110,7 +110,7 @@ ANTHROPIC_AUTH_TOKEN=<CPA_API_KEY>
110
110
 
111
111
  同时清除可能覆盖网关选择的 `ANTHROPIC_API_KEY` 和 Claude Code 云 provider 环境变量。它不会修改 `~/.claude/`,并原样返回 Claude Code 的退出码。
112
112
 
113
- > CPA 服务端必须支持 Claude Code 使用的 Anthropic Messages API(`/v1/messages`)。CPAC 不负责协议转换,也不启动本地代理。
113
+ > CPA 服务端必须支持 Claude Code 使用的 Anthropic Messages API(`/v1/messages`)。Claude 路径直接连接远端 CPA;CPAC 不负责协议转换。
114
114
 
115
115
  ### Codex
116
116
 
@@ -126,15 +126,45 @@ cpac inject
126
126
  cpac status
127
127
  ```
128
128
 
129
+ `status` 列出所有支持的 agent 及当前状态:
130
+
131
+ ```text
132
+ codex: injected proxy=http://127.0.0.1:10101/v1 sha256=...
133
+ claude: ready
134
+ pi: ready
135
+ ```
136
+
137
+ - **codex**:`native`(未注入)、`injected`(已注入,附代理地址和配置摘要)、`injected (loopback proxy stopped)` 或 `injected (config missing)`
138
+ - **claude**:`ready`(密钥已配置)或 `not configured`
139
+ - **pi**:`ready`(密钥已配置)或 `not configured`
140
+
129
141
  恢复注入前的原配置:
130
142
 
131
143
  ```bash
132
144
  cpac restore
133
145
  ```
134
146
 
135
- `inject` `/v1/models?client_version=1` 获取 Codex rich catalog,将根级 `model_provider`、`model_catalog_json` 指向 CPA,并追加 `[model_providers.cpac_cpa]`。其他用户 TOML 内容会保留;若已有同名 provider 且没有 CPAC state,CPAC 会拒绝接管。
147
+ `inject` 从远端 `/v1/models?client_version=1` 获取 Codex rich catalog,并启动只监听 `127.0.0.1` 的轻量转发代理。Codex 配置保留内置 `openai` provider,只写入根级 `model_catalog_json` 和:
148
+
149
+ ```toml
150
+ openai_base_url = "http://127.0.0.1:10101/v1"
151
+ ```
152
+
153
+ Codex App/CLI 发给本地代理的 ChatGPT bearer 不会转发到 CPA;代理会改用 `CPA_API_KEY`,并将请求和流式响应原样转发到远端 CPA。这保留了 Codex App 的原生 `openai` provider 身份、历史和账号相关界面。已有用户自定义根级 `openai_base_url` 或非 `openai` 的活动 `model_provider` 时,CPAC 会拒绝覆盖。
136
154
 
137
- 第一次注入时,原文件按原始 bytes 备份到 `state_dir/config.toml.backup`。重复注入不会覆盖首次备份,并会保留 CPAC 管理字段之外的用户编辑。`restore` 按原 mode 逐字节恢复首次备份;若原文件不存在,则删除注入创建的 `config.toml`。
155
+ Codex App 的长驻 `app-server` 可能缓存旧目录。`inject` 会删除 `models_cache.json`;如果选择器仍未更新,请重启 Codex App。
156
+
157
+ 第一次注入时,原文件按原始 bytes 备份到 `state_dir/config.toml.backup`。重复注入不会覆盖首次备份,并会保留 CPAC 管理字段之外的用户编辑。`restore` 按原 mode 逐字节恢复首次备份、关闭 CPAC loopback 代理;若原文件不存在,则删除注入创建的 `config.toml`。
158
+
159
+ loopback 代理是 detached 用户进程,不安装系统服务。机器重启或进程意外退出后,`cpac status` 会报告 `loopback proxy stopped`;重新执行 `cpac inject` 即可恢复。
160
+
161
+ 需要由现有进程管理器监督时,可使用前台入口:
162
+
163
+ ```bash
164
+ cpac proxy
165
+ ```
166
+
167
+ 它复用当前注入记录的端口和实例身份;没有活动注入时会拒绝启动。
138
168
 
139
169
  默认路径:
140
170
 
@@ -171,6 +201,7 @@ CPA_BASE_URL='https://cpa.example.com' cpac claude
171
201
  "cpa_url": "https://cpa.example.com",
172
202
  "api_key_env": "CPA_API_KEY",
173
203
  "codex_config": "~/.codex/config.toml",
204
+ "codex_proxy_port": 10101,
174
205
  "state_dir": "~/.cpac"
175
206
  }
176
207
  ```
@@ -180,6 +211,7 @@ CPA_BASE_URL='https://cpa.example.com' cpac claude
180
211
  - `cpa_url`:可选,绝对 `http(s)` URL,可带或不带 `/v1`
181
212
  - `api_key_env`:可选,密钥环境变量名,默认 `CPA_API_KEY`
182
213
  - `codex_config`:可选,Codex 配置路径
214
+ - `codex_proxy_port`:可选,本地 Codex 转发端口,默认 `10101`;设为 `0` 时自动选择空闲端口
183
215
  - `state_dir`:可选,CPAC state 路径
184
216
 
185
217
  使用自定义配置:
@@ -198,6 +230,7 @@ export CPAC_CONFIG=/path/to/cpac.json
198
230
  ## 安全行为
199
231
 
200
232
  - API key 不写入 CPAC JSON、Codex 配置、备份、state、catalog 或日志。
233
+ - Codex loopback 代理仅监听 `127.0.0.1`,拒绝非本地浏览器 Origin,并在内存中用 CPA key 替换入站 Authorization。
201
234
  - 引导输入不回显,写入 shell 时会正确引用特殊字符。
202
235
  - shell 启动文件和 Codex 配置均通过同目录临时文件原子替换。
203
236
  - CPA 请求失败、catalog 无效或 key 缺失时,首次注入不会修改 Codex 配置。
@@ -218,7 +251,7 @@ npm run pack:check
218
251
 
219
252
  ## npm 发布
220
253
 
221
- `.github/workflows/publish.yml` 在推送 `v*` tag 或手动触发时执行测试、类型检查、构建并发布到 npm。tag 发布会校验 tag 必须等于 `v` 加 `package.json` 的版本,例如 `v0.1.0`。
254
+ `.github/workflows/publish.yml` 在推送到 `main` 或手动触发时执行测试、类型检查、构建并发布到 npm。发布前会确认 `package.json` 中的版本尚未存在;因此每次计划发布的 push 都必须先提升版本。普通 PR 仅运行 CI。
222
255
 
223
256
  首次发布前:
224
257
 
@@ -230,8 +263,12 @@ npm run pack:check
230
263
  正常发布新版本:
231
264
 
232
265
  ```bash
233
- npm version patch # 或 minor / major
234
- git push --follow-tags
266
+ npm version patch --no-git-tag-version # 或 minor / major
267
+ git add package.json package-lock.json
268
+ git commit -m 'Release 0.1.1'
269
+ git push
235
270
  ```
236
271
 
272
+ 也可以手工修改并提交两个版本文件。push 到 `main` 后 Action 自动发布;若忘记提升版本,发布 workflow 会明确失败,不会覆盖 npm 已发布版本。
273
+
237
274
  仓库当前为 private,因此 npm Trusted Publishing 可以使用,但 npm 不会为发布包生成 provenance。
package/cpac.example.json CHANGED
@@ -1,4 +1,5 @@
1
1
  {
2
2
  "cpa_url": "https://cpa.example.com",
3
- "api_key_env": "CPA_API_KEY"
3
+ "api_key_env": "CPA_API_KEY",
4
+ "codex_proxy_port": 10101
4
5
  }
package/dist/cpac.js CHANGED
@@ -1,24 +1,43 @@
1
1
  #!/usr/bin/env node
2
2
  import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, rmdirSync, rmSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
3
3
  import { spawn } from "node:child_process";
4
- import { createHash } from "node:crypto";
4
+ import { createHash, randomBytes } from "node:crypto";
5
+ import { createServer, request as httpRequest, } from "node:http";
6
+ import { request as httpsRequest } from "node:https";
5
7
  import { homedir, tmpdir } from "node:os";
6
8
  import { dirname, isAbsolute, join, parse, relative, resolve } from "node:path";
7
- import { pathToFileURL } from "node:url";
9
+ import { fileURLToPath, pathToFileURL } from "node:url";
8
10
  import { createInterface } from "node:readline/promises";
9
11
  import { Writable } from "node:stream";
10
12
  export const PROVIDER = "cpac_cpa";
11
13
  const DEFAULT_CPA_URL = "https://cpa.vibetime.cc";
14
+ const DEFAULT_CODEX_PROXY_PORT = 10101;
12
15
  const MAX_CATALOG_BYTES = 16 * 1024 * 1024;
13
16
  const CONFIG_KEYS = new Set([
14
17
  "cpa_url",
15
18
  "api_key_env",
16
19
  "codex_config",
20
+ "codex_proxy_port",
17
21
  "state_dir",
18
22
  ]);
19
- const STATE_KEYS = new Set(["config_path", "config_existed", "config_mode"]);
23
+ const REQUIRED_STATE_KEYS = new Set([
24
+ "config_path",
25
+ "config_existed",
26
+ "config_mode",
27
+ ]);
28
+ const STATE_KEYS = new Set([
29
+ ...REQUIRED_STATE_KEYS,
30
+ "proxy_id",
31
+ "proxy_fingerprint",
32
+ "proxy_pid",
33
+ "proxy_port",
34
+ ]);
20
35
  const STATE_FILES = ["state.json", "config.toml.backup", "codex-models.json"];
21
- const ROOT_KEY = /^\s*(?:model_provider|"model_provider"|'model_provider'|model_catalog_json|"model_catalog_json"|'model_catalog_json')\s*=/;
36
+ const MODEL_PROVIDER_KEY = /^\s*(?:model_provider|"model_provider"|'model_provider')\s*=/;
37
+ const MODEL_CATALOG_KEY = /^\s*(?:model_catalog_json|"model_catalog_json"|'model_catalog_json')\s*=/;
38
+ const OPENAI_BASE_URL_KEY = /^\s*(?:openai_base_url|"openai_base_url"|'openai_base_url')\s*=/;
39
+ const ROOT_KEY = new RegExp(`(?:${MODEL_PROVIDER_KEY.source}|${MODEL_CATALOG_KEY.source}|${OPENAI_BASE_URL_KEY.source})`);
40
+ const MANAGED_MARKER = "# CPAC managed; run CPAC 'restore' to restore the original file.";
22
41
  let atomicSequence = 0;
23
42
  export class CPACError extends Error {
24
43
  }
@@ -94,6 +113,12 @@ export function loadConfig(path, useDefaultsIfMissing = false) {
94
113
  (typeof value.state_dir !== "string" || !value.state_dir.trim())) {
95
114
  throw new CPACError("state_dir must be a non-empty string");
96
115
  }
116
+ const codexProxyPort = value.codex_proxy_port ?? DEFAULT_CODEX_PROXY_PORT;
117
+ if (!Number.isInteger(codexProxyPort) ||
118
+ codexProxyPort < 0 ||
119
+ codexProxyPort > 65535) {
120
+ throw new CPACError("codex_proxy_port must be an integer from 0 to 65535");
121
+ }
97
122
  const codexHome = expandUserPath(process.env.CODEX_HOME?.trim() || join(homedir(), ".codex"));
98
123
  const codexConfig = resolve(expandUserPath(value.codex_config ||
99
124
  join(codexHome, "config.toml")));
@@ -108,6 +133,7 @@ export function loadConfig(path, useDefaultsIfMissing = false) {
108
133
  cpa_url: cpaUrl,
109
134
  api_key_env: apiKeyEnv,
110
135
  codex_config: codexConfig,
136
+ codex_proxy_port: codexProxyPort,
111
137
  state_dir: stateDir,
112
138
  };
113
139
  }
@@ -245,6 +271,7 @@ function stripManagedConfig(content) {
245
271
  const lines = text.replace(/\r\n/g, "\n").split("\n");
246
272
  let inRoot = true;
247
273
  let inProvider = false;
274
+ let managedRootKeys = 0;
248
275
  const kept = [];
249
276
  for (const line of lines) {
250
277
  if (inProvider && /^\s*\[/.test(line))
@@ -257,14 +284,20 @@ function stripManagedConfig(content) {
257
284
  continue;
258
285
  if (/^\s*\[/.test(line))
259
286
  inRoot = false;
260
- if (inRoot && (ROOT_KEY.test(line) || line.startsWith("# CPAC managed;")))
287
+ if (inRoot && line === MANAGED_MARKER) {
288
+ managedRootKeys = 2;
289
+ continue;
290
+ }
291
+ if (inRoot && managedRootKeys > 0 && ROOT_KEY.test(line)) {
292
+ managedRootKeys -= 1;
261
293
  continue;
294
+ }
262
295
  kept.push(line);
263
296
  }
264
297
  const output = kept.join("\n");
265
298
  return Buffer.from(eol === "\n" ? output : output.replace(/\n/g, "\r\n"));
266
299
  }
267
- export function buildCodexConfig(original, cpaUrl, apiKeyEnv, catalogPath) {
300
+ export function buildCodexConfig(original, proxyPort, catalogPath) {
268
301
  let text;
269
302
  try {
270
303
  text = new TextDecoder("utf-8", { fatal: true }).decode(original);
@@ -275,33 +308,37 @@ export function buildCodexConfig(original, cpaUrl, apiKeyEnv, catalogPath) {
275
308
  if (hasProviderTable(text)) {
276
309
  throw new CPACError(`Codex config already contains [model_providers.${PROVIDER}]`);
277
310
  }
311
+ const normalizedLines = text.replace(/\r\n/g, "\n").split("\n");
312
+ const firstTable = normalizedLines.findIndex((line) => /^\s*\[/.test(line));
313
+ const rootLines = normalizedLines.slice(0, firstTable === -1 ? undefined : firstTable);
314
+ const providerLine = rootLines.find((line) => MODEL_PROVIDER_KEY.test(line));
315
+ if (providerLine) {
316
+ const match = /=\s*["']([^"']+)["']/.exec(providerLine);
317
+ if (!match || match[1] !== "openai") {
318
+ throw new CPACError("Codex config selects an external model_provider; restore it to openai before injecting CPAC");
319
+ }
320
+ }
321
+ if (rootLines.some((line) => OPENAI_BASE_URL_KEY.test(line))) {
322
+ throw new CPACError("Codex config already contains openai_base_url; remove that user-owned override before injecting CPAC");
323
+ }
278
324
  const eol = dominantEol(text);
279
325
  const lines = text.replace(/\r\n/g, "\n").split("\n");
280
326
  let inRoot = true;
281
327
  const kept = lines.filter((line) => {
282
328
  if (/^\s*\[/.test(line))
283
329
  inRoot = false;
284
- return !(inRoot && ROOT_KEY.test(line));
330
+ return !(inRoot && MODEL_CATALOG_KEY.test(line));
285
331
  });
286
332
  let body = kept.join("\n");
287
333
  if (body && !body.endsWith("\n"))
288
334
  body += "\n";
289
335
  const root = [
290
- "# CPAC managed; run CPAC 'restore' to restore the original file.",
291
- `model_provider = ${tomlString(PROVIDER)}`,
336
+ MANAGED_MARKER,
292
337
  `model_catalog_json = ${tomlString(catalogPath)}`,
338
+ `openai_base_url = ${tomlString(`http://127.0.0.1:${proxyPort}/v1`)}`,
293
339
  "",
294
340
  ].join("\n");
295
- const provider = [
296
- "",
297
- `[model_providers.${PROVIDER}]`,
298
- 'name = "CPA"',
299
- `base_url = ${tomlString(apiBase(cpaUrl))}`,
300
- `env_key = ${tomlString(apiKeyEnv)}`,
301
- 'wire_api = "responses"',
302
- "",
303
- ].join("\n");
304
- const output = root + body + provider;
341
+ const output = root + body;
305
342
  return Buffer.from(eol === "\n" ? output : output.replace(/\n/g, "\r\n"));
306
343
  }
307
344
  export function atomicWrite(path, data, mode = 0o600) {
@@ -335,6 +372,243 @@ export function atomicWrite(path, data, mode = 0o600) {
335
372
  throw error;
336
373
  }
337
374
  }
375
+ const HOP_BY_HOP_HEADERS = new Set([
376
+ "connection",
377
+ "keep-alive",
378
+ "proxy-authenticate",
379
+ "proxy-authorization",
380
+ "proxy-connection",
381
+ "te",
382
+ "trailer",
383
+ "transfer-encoding",
384
+ "upgrade",
385
+ ]);
386
+ const CLIENT_CREDENTIAL_HEADERS = new Set([
387
+ "authorization",
388
+ "chatgpt-account-id",
389
+ "cookie",
390
+ "openai-organization",
391
+ "openai-project",
392
+ "x-api-key",
393
+ "x-opencodex-api-key",
394
+ ]);
395
+ function localBrowserOrigin(value) {
396
+ if (!value)
397
+ return true;
398
+ try {
399
+ const hostname = new URL(value).hostname.toLowerCase();
400
+ return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
401
+ }
402
+ catch {
403
+ return false;
404
+ }
405
+ }
406
+ function proxyHeaders(headers, apiKey) {
407
+ const forwarded = {};
408
+ for (const [name, value] of Object.entries(headers)) {
409
+ if (value === undefined ||
410
+ name === "host" ||
411
+ CLIENT_CREDENTIAL_HEADERS.has(name) ||
412
+ HOP_BY_HOP_HEADERS.has(name)) {
413
+ continue;
414
+ }
415
+ forwarded[name] = value;
416
+ }
417
+ forwarded.authorization = `Bearer ${apiKey}`;
418
+ return forwarded;
419
+ }
420
+ function responseHeaders(headers) {
421
+ const forwarded = {};
422
+ for (const [name, value] of Object.entries(headers)) {
423
+ if (value === undefined || HOP_BY_HOP_HEADERS.has(name))
424
+ continue;
425
+ forwarded[name] = value;
426
+ }
427
+ return forwarded;
428
+ }
429
+ function upstreamUrl(cpaUrl, requestUrl) {
430
+ const incoming = new URL(requestUrl, "http://127.0.0.1");
431
+ const suffix = incoming.pathname === "/v1"
432
+ ? ""
433
+ : incoming.pathname.startsWith("/v1/")
434
+ ? incoming.pathname.slice(3)
435
+ : incoming.pathname;
436
+ return new URL(`${apiBase(cpaUrl)}${suffix}${incoming.search}`);
437
+ }
438
+ export async function createLoopbackProxy(cpaUrl, apiKey, proxyId, port) {
439
+ const server = createServer((request, response) => {
440
+ const requestUrl = request.url ?? "/";
441
+ if (requestUrl === "/_cpac/health") {
442
+ if (request.headers["x-cpac-proxy-id"] !== proxyId) {
443
+ response.writeHead(404).end();
444
+ return;
445
+ }
446
+ response.writeHead(204).end();
447
+ return;
448
+ }
449
+ if (requestUrl === "/_cpac/shutdown" && request.method === "POST") {
450
+ if (request.headers["x-cpac-proxy-id"] !== proxyId) {
451
+ response.writeHead(404).end();
452
+ return;
453
+ }
454
+ response.writeHead(204).end();
455
+ setImmediate(() => {
456
+ server.closeAllConnections?.();
457
+ server.close();
458
+ });
459
+ return;
460
+ }
461
+ if (!localBrowserOrigin(request.headers.origin)) {
462
+ response.writeHead(403, { "content-type": "application/json" });
463
+ response.end(JSON.stringify({ error: "non-local origin rejected" }));
464
+ return;
465
+ }
466
+ let target;
467
+ try {
468
+ target = upstreamUrl(cpaUrl, requestUrl);
469
+ }
470
+ catch {
471
+ response.writeHead(400).end("invalid request URL");
472
+ return;
473
+ }
474
+ const send = target.protocol === "https:" ? httpsRequest : httpRequest;
475
+ const upstream = send(target, {
476
+ method: request.method,
477
+ headers: proxyHeaders(request.headers, apiKey),
478
+ }, (upstreamResponse) => {
479
+ response.writeHead(upstreamResponse.statusCode ?? 502, responseHeaders(upstreamResponse.headers));
480
+ upstreamResponse.pipe(response);
481
+ });
482
+ upstream.once("error", () => {
483
+ if (!response.headersSent) {
484
+ response.writeHead(502, { "content-type": "application/json" });
485
+ }
486
+ if (!response.writableEnded)
487
+ response.end(JSON.stringify({ error: "CPA upstream request failed" }));
488
+ });
489
+ request.once("aborted", () => upstream.destroy());
490
+ request.pipe(upstream);
491
+ });
492
+ server.on("upgrade", (_request, socket) => {
493
+ socket.end("HTTP/1.1 426 Upgrade Required\r\nConnection: close\r\nContent-Length: 0\r\n\r\n");
494
+ });
495
+ server.on("clientError", (_error, socket) => socket.destroy());
496
+ await new Promise((resolveListen, rejectListen) => {
497
+ server.once("error", rejectListen);
498
+ server.listen(port, "127.0.0.1", () => {
499
+ server.off("error", rejectListen);
500
+ resolveListen();
501
+ });
502
+ });
503
+ const address = server.address();
504
+ if (!address || typeof address === "string") {
505
+ server.close();
506
+ throw new CPACError("cannot determine loopback proxy port");
507
+ }
508
+ return { server, port: address.port };
509
+ }
510
+ async function runProxyChild(port) {
511
+ const cpaUrl = process.env.CPAC_PROXY_UPSTREAM;
512
+ const apiKey = process.env.CPAC_PROXY_API_KEY;
513
+ const proxyId = process.env.CPAC_PROXY_ID;
514
+ if (!cpaUrl || !apiKey || !proxyId)
515
+ return 1;
516
+ try {
517
+ const proxy = await createLoopbackProxy(cpaUrl, apiKey, proxyId, port);
518
+ process.send?.({ ready: true, port: proxy.port, pid: process.pid });
519
+ const close = () => {
520
+ proxy.server.closeAllConnections?.();
521
+ proxy.server.close();
522
+ };
523
+ process.once("SIGINT", close);
524
+ process.once("SIGTERM", close);
525
+ await new Promise((resolveClose) => proxy.server.once("close", resolveClose));
526
+ return 0;
527
+ }
528
+ catch (error) {
529
+ process.send?.({
530
+ ready: false,
531
+ error: error instanceof Error ? error.message : String(error),
532
+ });
533
+ return 1;
534
+ }
535
+ }
536
+ async function proxyIsHealthy(proxy) {
537
+ try {
538
+ const response = await fetch(`http://127.0.0.1:${proxy.port}/_cpac/health`, {
539
+ headers: { "x-cpac-proxy-id": proxy.id },
540
+ signal: AbortSignal.timeout(1_000),
541
+ });
542
+ return response.status === 204;
543
+ }
544
+ catch {
545
+ return false;
546
+ }
547
+ }
548
+ async function startProxyProcess(config, apiKey) {
549
+ const id = randomBytes(24).toString("hex");
550
+ const child = spawn(process.execPath, [fileURLToPath(import.meta.url), "_proxy", String(config.codex_proxy_port)], {
551
+ detached: true,
552
+ env: {
553
+ ...process.env,
554
+ CPAC_PROXY_API_KEY: apiKey,
555
+ CPAC_PROXY_ID: id,
556
+ CPAC_PROXY_UPSTREAM: config.cpa_url,
557
+ },
558
+ stdio: ["ignore", "ignore", "ignore", "ipc"],
559
+ });
560
+ return await new Promise((resolveStart, rejectStart) => {
561
+ let settled = false;
562
+ let timeout;
563
+ const finish = (error, value) => {
564
+ if (settled)
565
+ return;
566
+ settled = true;
567
+ clearTimeout(timeout);
568
+ child.removeAllListeners();
569
+ if (child.connected)
570
+ child.disconnect();
571
+ child.unref();
572
+ if (error)
573
+ rejectStart(error);
574
+ else
575
+ resolveStart(value);
576
+ };
577
+ timeout = setTimeout(() => {
578
+ child.kill();
579
+ finish(new CPACError("loopback proxy did not become ready"));
580
+ }, 5_000);
581
+ child.once("error", (error) => finish(new CPACError(`cannot start loopback proxy: ${error.message}`)));
582
+ child.once("exit", (code) => finish(new CPACError(`loopback proxy exited before ready (${code ?? 1})`)));
583
+ child.once("message", (message) => {
584
+ if (objectValue(message) &&
585
+ message.ready === true &&
586
+ typeof message.pid === "number" &&
587
+ typeof message.port === "number") {
588
+ finish(undefined, { id, pid: message.pid, port: message.port });
589
+ return;
590
+ }
591
+ const detail = objectValue(message) && typeof message.error === "string"
592
+ ? `: ${message.error}`
593
+ : "";
594
+ finish(new CPACError(`cannot start loopback proxy${detail}`));
595
+ });
596
+ });
597
+ }
598
+ async function stopProxyProcess(proxy) {
599
+ if (!(await proxyIsHealthy(proxy)))
600
+ return;
601
+ try {
602
+ await fetch(`http://127.0.0.1:${proxy.port}/_cpac/shutdown`, {
603
+ method: "POST",
604
+ headers: { "x-cpac-proxy-id": proxy.id },
605
+ signal: AbortSignal.timeout(1_000),
606
+ });
607
+ }
608
+ catch {
609
+ // The server may close the connection while shutting down.
610
+ }
611
+ }
338
612
  function readState(stateDir) {
339
613
  const path = join(stateDir, "state.json");
340
614
  if (!existsSync(path))
@@ -348,7 +622,7 @@ function readState(stateDir) {
348
622
  }
349
623
  if (!objectValue(value) ||
350
624
  Object.keys(value).some((key) => !STATE_KEYS.has(key)) ||
351
- Object.keys(value).length !== STATE_KEYS.size) {
625
+ [...REQUIRED_STATE_KEYS].some((key) => !(key in value))) {
352
626
  throw new CPACError("invalid state file");
353
627
  }
354
628
  if (typeof value.config_path !== "string" || !isAbsolute(value.config_path)) {
@@ -361,8 +635,42 @@ function readState(stateDir) {
361
635
  value.config_mode > 0o7777) {
362
636
  throw new CPACError("invalid config_mode value in state");
363
637
  }
638
+ const proxyKeys = ["proxy_id", "proxy_pid", "proxy_port"];
639
+ const proxyKeyCount = proxyKeys.filter((key) => key in value).length;
640
+ if (proxyKeyCount !== 0 &&
641
+ (proxyKeyCount !== proxyKeys.length ||
642
+ typeof value.proxy_id !== "string" ||
643
+ !/^[a-f0-9]{48}$/.test(value.proxy_id) ||
644
+ !Number.isInteger(value.proxy_pid) ||
645
+ value.proxy_pid <= 0 ||
646
+ !Number.isInteger(value.proxy_port) ||
647
+ value.proxy_port <= 0 ||
648
+ value.proxy_port > 65535)) {
649
+ throw new CPACError("invalid loopback proxy state");
650
+ }
651
+ if (value.proxy_fingerprint !== undefined &&
652
+ (typeof value.proxy_fingerprint !== "string" ||
653
+ !/^[a-f0-9]{64}$/.test(value.proxy_fingerprint))) {
654
+ throw new CPACError("invalid loopback proxy fingerprint");
655
+ }
364
656
  return value;
365
657
  }
658
+ function stateProxy(state) {
659
+ return state.proxy_id && state.proxy_pid && state.proxy_port
660
+ ? { id: state.proxy_id, pid: state.proxy_pid, port: state.proxy_port }
661
+ : null;
662
+ }
663
+ function stateBytes(config, existed, mode, proxy, proxyFingerprint) {
664
+ return Buffer.from(`${JSON.stringify({
665
+ config_path: config.codex_config,
666
+ config_existed: existed,
667
+ config_mode: mode,
668
+ proxy_id: proxy.id,
669
+ proxy_fingerprint: proxyFingerprint,
670
+ proxy_pid: proxy.pid,
671
+ proxy_port: proxy.port,
672
+ }, null, 2)}\n`);
673
+ }
366
674
  function originalBytes(stateDir, state, expectedPath) {
367
675
  if (state.config_path !== expectedPath) {
368
676
  throw new CPACError(`active injection belongs to ${state.config_path}; restore it first`);
@@ -388,6 +696,13 @@ function cleanupStateFiles(stateDir) {
388
696
  throw error;
389
697
  }
390
698
  }
699
+ function proxyFingerprint(config, apiKey) {
700
+ return createHash("sha256")
701
+ .update(config.cpa_url)
702
+ .update("\0")
703
+ .update(apiKey)
704
+ .digest("hex");
705
+ }
391
706
  export async function inject(config) {
392
707
  const apiKey = process.env[config.api_key_env]?.trim();
393
708
  if (!apiKey)
@@ -423,20 +738,58 @@ export async function inject(config) {
423
738
  throw new CPACError(`cannot read Codex config: ${error instanceof Error ? error.message : String(error)}`);
424
739
  }
425
740
  }
426
- const catalogPath = join(config.state_dir, "codex-models.json");
427
- const injected = buildCodexConfig(original, config.cpa_url, config.api_key_env, catalogPath);
428
741
  const catalog = await fetchCatalog(config.cpa_url, apiKey);
429
742
  const stateDirExisted = existsSync(config.state_dir);
430
743
  if (!state &&
431
744
  STATE_FILES.some((name) => existsSync(join(config.state_dir, name)))) {
432
745
  throw new CPACError("state_dir contains CPAC files without a valid state; refusing to overwrite them");
433
746
  }
747
+ const fingerprint = proxyFingerprint(config, apiKey);
748
+ const previousProxy = state ? stateProxy(state) : null;
749
+ let proxy;
750
+ let startedProxy = false;
751
+ if (previousProxy &&
752
+ state?.proxy_fingerprint === fingerprint &&
753
+ (config.codex_proxy_port === 0 ||
754
+ config.codex_proxy_port === previousProxy.port) &&
755
+ (await proxyIsHealthy(previousProxy))) {
756
+ proxy = previousProxy;
757
+ }
758
+ else {
759
+ if (previousProxy)
760
+ await stopProxyProcess(previousProxy);
761
+ try {
762
+ proxy = await startProxyProcess(config, apiKey);
763
+ startedProxy = true;
764
+ }
765
+ catch (error) {
766
+ throw new CPACError(`${error instanceof Error ? error.message : String(error)}; choose another codex_proxy_port if the port is occupied`);
767
+ }
768
+ }
769
+ const catalogPath = join(config.state_dir, "codex-models.json");
770
+ let injected;
771
+ try {
772
+ injected = buildCodexConfig(original, proxy.port, catalogPath);
773
+ }
774
+ catch (error) {
775
+ if (startedProxy)
776
+ await stopProxyProcess(proxy);
777
+ throw error;
778
+ }
434
779
  mkdirSync(config.state_dir, { recursive: true, mode: 0o700 });
435
780
  if (!stateDirExisted)
436
781
  chmodSync(config.state_dir, 0o700);
437
782
  if (state) {
438
- atomicWrite(catalogPath, catalog.bytes);
439
- atomicWrite(config.codex_config, injected, originalMode);
783
+ try {
784
+ atomicWrite(catalogPath, catalog.bytes);
785
+ atomicWrite(config.codex_config, injected, originalMode);
786
+ atomicWrite(join(config.state_dir, "state.json"), stateBytes(config, existed, originalMode, proxy, fingerprint));
787
+ }
788
+ catch (error) {
789
+ if (startedProxy)
790
+ await stopProxyProcess(proxy);
791
+ throw error;
792
+ }
440
793
  }
441
794
  else {
442
795
  let configWritten = false;
@@ -446,11 +799,7 @@ export async function inject(config) {
446
799
  atomicWrite(catalogPath, catalog.bytes);
447
800
  atomicWrite(config.codex_config, injected, originalMode);
448
801
  configWritten = true;
449
- atomicWrite(join(config.state_dir, "state.json"), Buffer.from(`${JSON.stringify({
450
- config_path: config.codex_config,
451
- config_existed: existed,
452
- config_mode: originalMode,
453
- }, null, 2)}\n`));
802
+ atomicWrite(join(config.state_dir, "state.json"), stateBytes(config, existed, originalMode, proxy, fingerprint));
454
803
  }
455
804
  catch (error) {
456
805
  if (configWritten) {
@@ -464,18 +813,33 @@ export async function inject(config) {
464
813
  throw new CPACError(`injection failed and config rollback failed; backup retained in ${config.state_dir}: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`);
465
814
  }
466
815
  }
816
+ let cleanupError;
467
817
  try {
468
818
  cleanupStateFiles(config.state_dir);
469
819
  }
470
- catch (cleanupError) {
820
+ catch (caught) {
821
+ cleanupError = caught;
822
+ }
823
+ if (startedProxy)
824
+ await stopProxyProcess(proxy);
825
+ if (cleanupError) {
471
826
  throw new CPACError(`injection failed and state cleanup failed: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`);
472
827
  }
473
828
  throw error;
474
829
  }
475
830
  }
476
- console.log(`Injected ${catalog.modelCount} CPA models into ${config.codex_config}`);
831
+ try {
832
+ rmSync(join(dirname(config.codex_config), "models_cache.json"), {
833
+ force: true,
834
+ });
835
+ }
836
+ catch {
837
+ console.warn("CPAC could not invalidate Codex models_cache.json; restart Codex App if its model list is stale.");
838
+ }
839
+ console.log(`Injected ${catalog.modelCount} CPA models into ${config.codex_config} via http://127.0.0.1:${proxy.port}/v1`);
840
+ console.log("Restart Codex App if its running app-server still shows the old model list.");
477
841
  }
478
- export function restore(config) {
842
+ export async function restore(config) {
479
843
  const state = readState(config.state_dir);
480
844
  if (!state)
481
845
  throw new CPACError("no active CPAC injection");
@@ -484,6 +848,9 @@ export function restore(config) {
484
848
  atomicWrite(config.codex_config, original, state.config_mode);
485
849
  else
486
850
  rmSync(config.codex_config, { force: true });
851
+ const proxy = stateProxy(state);
852
+ if (proxy)
853
+ await stopProxyProcess(proxy);
487
854
  try {
488
855
  cleanupStateFiles(config.state_dir);
489
856
  }
@@ -492,22 +859,39 @@ export function restore(config) {
492
859
  }
493
860
  console.log(`Restored ${config.codex_config}`);
494
861
  }
495
- export function status(config) {
862
+ export async function status(config) {
863
+ const apiKey = process.env[config.api_key_env]?.trim();
864
+ const keyConfigured = !!apiKey;
496
865
  const state = readState(config.state_dir);
497
866
  if (!state) {
498
- console.log("native");
499
- return 1;
867
+ console.log("codex: native");
500
868
  }
501
- originalBytes(config.state_dir, state, config.codex_config);
502
- if (!existsSync(config.codex_config)) {
503
- console.log(`injected (config missing: ${config.codex_config})`);
504
- return 2;
869
+ else {
870
+ originalBytes(config.state_dir, state, config.codex_config);
871
+ if (!existsSync(config.codex_config)) {
872
+ console.log(`codex: injected (config missing: ${config.codex_config})`);
873
+ }
874
+ else {
875
+ const proxy = stateProxy(state);
876
+ if (!proxy || !(await proxyIsHealthy(proxy))) {
877
+ console.log(`codex: injected (loopback proxy stopped: ${config.codex_config})`);
878
+ }
879
+ else {
880
+ const digest = createHash("sha256")
881
+ .update(readFileSync(config.codex_config))
882
+ .digest("hex")
883
+ .slice(0, 12);
884
+ console.log(`codex: injected proxy=http://127.0.0.1:${proxy.port}/v1 sha256=${digest}`);
885
+ }
886
+ }
505
887
  }
506
- const digest = createHash("sha256")
507
- .update(readFileSync(config.codex_config))
508
- .digest("hex")
509
- .slice(0, 12);
510
- console.log(`injected: ${config.codex_config} sha256=${digest}`);
888
+ console.log(`claude: ${keyConfigured ? "ready" : "not configured"}`);
889
+ console.log(`pi: ${keyConfigured ? "ready" : "not configured"}`);
890
+ if (!state)
891
+ return 1;
892
+ const proxy = stateProxy(state);
893
+ if (!proxy || !(await proxyIsHealthy(proxy)))
894
+ return 2;
511
895
  return 0;
512
896
  }
513
897
  function claudeBaseUrl(cpaUrl) {
@@ -568,7 +952,7 @@ export function saveApiKeyExport(profile, name, apiKey) {
568
952
  atomicWrite(profile, Buffer.from(content), mode);
569
953
  }
570
954
  async function guide(config) {
571
- console.log(`CPA Companion\n\nCPA: ${config.cpa_url}\nCPA_API_KEY: ${process.env[config.api_key_env]?.trim() ? "configured" : "not configured"}\n\nCommands:\n cpac claude [args...] Launch Claude Code through CPA\n cpac inject Inject CPA into Codex\n cpac status Show Codex injection status\n cpac restore Restore the original Codex config\n cpac --help Show command usage`);
955
+ console.log(`CPA Companion\n\nCPA: ${config.cpa_url}\nCPA_API_KEY: ${process.env[config.api_key_env]?.trim() ? "configured" : "not configured"}\n\nCommands:\n cpac claude [args...] Launch Claude Code through CPA\n cpac inject Inject CPA into Codex and start its loopback proxy\n cpac proxy Run the injected loopback proxy in the foreground\n cpac status Show agent support status (Codex, Claude, Pi)\n cpac restore Restore the original Codex config\n cpac --help Show command usage`);
572
956
  if (process.env[config.api_key_env]?.trim())
573
957
  return 0;
574
958
  const apiKey = await promptSecret(config.api_key_env);
@@ -600,10 +984,43 @@ export async function runClaude(config, args, executable = "claude") {
600
984
  child.once("close", (code) => resolve(code ?? 1));
601
985
  });
602
986
  }
987
+ export async function runProxy(config) {
988
+ const apiKey = await resolveApiKey(config.api_key_env);
989
+ const state = readState(config.state_dir);
990
+ if (!state)
991
+ throw new CPACError("no active CPAC injection; run cpac inject first");
992
+ originalBytes(config.state_dir, state, config.codex_config);
993
+ const recorded = stateProxy(state);
994
+ if (!recorded) {
995
+ throw new CPACError("injection has no loopback proxy state; run cpac inject to migrate it");
996
+ }
997
+ if (await proxyIsHealthy(recorded)) {
998
+ throw new CPACError(`loopback proxy is already running on 127.0.0.1:${recorded.port}`);
999
+ }
1000
+ const proxy = await createLoopbackProxy(config.cpa_url, apiKey, recorded.id, recorded.port);
1001
+ try {
1002
+ atomicWrite(join(config.state_dir, "state.json"), stateBytes(config, state.config_existed, state.config_mode, { id: recorded.id, pid: process.pid, port: proxy.port }, proxyFingerprint(config, apiKey)));
1003
+ }
1004
+ catch (error) {
1005
+ proxy.server.closeAllConnections?.();
1006
+ proxy.server.close();
1007
+ throw error;
1008
+ }
1009
+ console.log(`CPAC loopback proxy listening on http://127.0.0.1:${proxy.port}/v1`);
1010
+ const close = () => {
1011
+ proxy.server.closeAllConnections?.();
1012
+ proxy.server.close();
1013
+ };
1014
+ process.once("SIGINT", close);
1015
+ process.once("SIGTERM", close);
1016
+ await new Promise((resolveClose) => proxy.server.once("close", resolveClose));
1017
+ return 0;
1018
+ }
603
1019
  function usage() {
604
1020
  return [
605
1021
  "Usage: cpac",
606
1022
  " cpac <inject|status|restore> [--config PATH]",
1023
+ " cpac proxy [--config PATH]",
607
1024
  " cpac claude [--config PATH] [--] [claude args...]",
608
1025
  ].join("\n");
609
1026
  }
@@ -645,7 +1062,7 @@ function parseArgs(args) {
645
1062
  positional.push(args[index]);
646
1063
  }
647
1064
  if (positional.length !== 1 ||
648
- !["inject", "status", "restore"].includes(positional[0])) {
1065
+ !["inject", "status", "restore", "proxy"].includes(positional[0])) {
649
1066
  throw new CPACError(usage());
650
1067
  }
651
1068
  return {
@@ -655,6 +1072,12 @@ function parseArgs(args) {
655
1072
  }
656
1073
  export async function main(args = process.argv.slice(2)) {
657
1074
  try {
1075
+ if (args[0] === "_proxy") {
1076
+ const port = Number(args[1]);
1077
+ if (!Number.isInteger(port) || port < 0 || port > 65535)
1078
+ return 1;
1079
+ return await runProxyChild(port);
1080
+ }
658
1081
  const parsed = parseArgs(args);
659
1082
  if (!parsed)
660
1083
  return 0;
@@ -663,12 +1086,14 @@ export async function main(args = process.argv.slice(2)) {
663
1086
  return await guide(config);
664
1087
  if (parsed.command === "claude")
665
1088
  return await runClaude(config, parsed.args);
1089
+ if (parsed.command === "proxy")
1090
+ return await runProxy(config);
666
1091
  if (parsed.command === "inject")
667
1092
  await inject(config);
668
1093
  else if (parsed.command === "restore")
669
- restore(config);
1094
+ await restore(config);
670
1095
  else
671
- return status(config);
1096
+ return await status(config);
672
1097
  return 0;
673
1098
  }
674
1099
  catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yhong91/cpac",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Connect Codex and Claude Code to a remote CLIProxyAPI gateway",
5
5
  "type": "module",
6
6
  "bin": {