@yhong91/cpac 0.1.27 → 0.1.28

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**:注入模型目录和 loopback 网关,保留内置 `openai` provider,并可逐字节恢复原配置。
5
+ - **Codex**:注入模型目录和 loopback 网关,保留内置 `openai` provider。无用户改动时逐字节恢复原配置;注入期间改过文件则只撤掉 CPAC 字段。
6
6
  - **Claude Code**:通过临时环境变量启动,不修改 Claude Code 配置。
7
7
 
8
8
  实现基于 TypeScript + Node.js,运行时零依赖。
@@ -41,7 +41,7 @@ cpac install --all # 注入所有检测到的目标(codex/
41
41
  cpac install --target codex # 只注入 Codex(默认开启 multi-agent v2)
42
42
  cpac install --target codex --v2_models # 先多选 spawn_agent 模型再注入
43
43
  cpac install --target codex --max_context # 注入时把模型上下文提升到实测上限(opt-in,仅限 codex)
44
- cpac restore --target codex # 字节级恢复原配置
44
+ cpac restore --target codex # 撤掉注入;注入期间的用户改动会留下
45
45
 
46
46
  # 4b. 临时接管(退出无痕,不写任何配置文件)
47
47
  cpac claude # Claude Code 走 CPA
@@ -248,7 +248,7 @@ Codex App 的长驻 `app-server` 可能缓存旧目录。`inject` 会删除 `mod
248
248
 
249
249
  注入命令可选 `--max_context`(`cpac inject --max_context` 或 `cpac install --target codex --max_context`,默认不带):把 catalog 中 `max_context_window` 高于 `context_window` 的模型提升到上限,并把 `auto_compact_token_limit` 设为上限的 90%。Codex 把 `context_window` 当输入预算而非展示标签,上游默认保留保守运营值(如 GPT-5.6 家族 272k),带上该参数后可用到实测上限(约 921k,opencodex 实测);90% 压缩线确保在硬上限前触发 auto-compact。不带参数注入的仍是原本上下文的目录。该参数仅限 codex,与其他 target 组合使用会直接报错。
250
250
 
251
- 第一次注入时,原文件按原始 bytes 备份到 `state_dir/config.toml.backup`。重复注入不会覆盖首次备份,并会保留 CPAC 管理字段之外的用户编辑。`restore` 按原 mode 逐字节恢复首次备份、关闭 CPAC loopback 代理;若原文件不存在,则删除注入创建的 `config.toml`。
251
+ 第一次注入时,原文件按原始 bytes 备份到 `state_dir/config.toml.backup`。重复注入不会覆盖首次备份,并会保留 CPAC 管理字段之外的用户编辑。`restore`:若 `config.toml` 相对首次注入未改,按原 mode 逐字节还原备份;若用户在注入期间改过文件,只撤掉 CPAC 写入的 catalog、url 和标记。原先没有 `config.toml` 且撤掉后为空则删除文件。关闭 CPAC loopback 代理。
252
252
 
253
253
  loopback 代理是 detached 用户进程,不安装系统服务。机器重启或进程意外退出后,`cpac status` 会报告 `loopback proxy stopped`;重新执行 `cpac inject` 即可恢复。
254
254
 
package/dist/agents.js CHANGED
@@ -3,7 +3,7 @@ import { existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { dirname, join } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
- import { fetchCatalog, pickSpawnModels, proxyFingerprint, readState, saveSpawnModels, stateBytes, stateProxy, } from "./config.js";
6
+ import { fetchCatalog, injectedOwnership, pickSpawnModels, proxyFingerprint, readState, saveSpawnModels, stateBytes, stateProxy, } from "./config.js";
7
7
  import { proxyIsHealthy, startProxyProcess, stopProxyProcess, } from "./proxy.js";
8
8
  import { MANAGED_MARKER, inject, restore } from "./targets/codex.js";
9
9
  import { grokConfigPath, grokHome, installGrokConfig, isGrokConfigInstalled, uninstallGrokConfig, } from "./targets/grok.js";
@@ -76,13 +76,16 @@ export async function runTargetLauncher(config, targetId, args, executable = tar
76
76
  if (["kimi", "grok"].includes(targetId)) {
77
77
  const state = readState(config.state_dir);
78
78
  const proxy = state ? stateProxy(state) : null;
79
- if (!proxy || !(await proxyIsHealthy(proxy))) {
79
+ const apiKey = await resolveApiKey(config.api_key_env);
80
+ const fingerprint = proxyFingerprint(config, apiKey);
81
+ const reuse = proxy !== null &&
82
+ state?.proxy_fingerprint === fingerprint &&
83
+ (await proxyIsHealthy(proxy));
84
+ if (!reuse) {
80
85
  if (proxy)
81
86
  await stopProxyProcess(proxy);
82
- const apiKey = await resolveApiKey(config.api_key_env);
83
87
  const started = await startProxyProcess(config, apiKey);
84
88
  try {
85
- const fingerprint = proxyFingerprint(config, apiKey);
86
89
  const catalog = await fetchCatalog(config.cpa_url, apiKey);
87
90
  const catalogPath = join(config.state_dir, "codex-models.json");
88
91
  mkdirSync(config.state_dir, { recursive: true, mode: 0o700 });
@@ -90,7 +93,7 @@ export async function runTargetLauncher(config, targetId, args, executable = tar
90
93
  const originalMode = existsSync(config.codex_config)
91
94
  ? statSync(config.codex_config).mode & 0o7777
92
95
  : 0o600;
93
- atomicWrite(join(config.state_dir, "state.json"), stateBytes(config, state ? state.config_existed : false, state ? state.config_mode : originalMode, started, fingerprint));
96
+ atomicWrite(join(config.state_dir, "state.json"), stateBytes(config, state ? state.config_existed : false, state ? state.config_mode : originalMode, started, fingerprint, injectedOwnership(state)));
94
97
  }
95
98
  catch (error) {
96
99
  await stopProxyProcess(started);
package/dist/config.js CHANGED
@@ -31,6 +31,9 @@ const STATE_KEYS = new Set([
31
31
  "proxy_fingerprint",
32
32
  "proxy_pid",
33
33
  "proxy_port",
34
+ "injected_config_hash",
35
+ "injected_openai_base_url",
36
+ "injected_catalog_path",
34
37
  ]);
35
38
  export const STATE_FILES = [
36
39
  "state.json",
@@ -388,6 +391,21 @@ export function readState(stateDir) {
388
391
  !/^[a-f0-9]{64}$/.test(value.proxy_fingerprint))) {
389
392
  throw new CPACError("invalid loopback proxy fingerprint");
390
393
  }
394
+ if (value.injected_config_hash !== undefined &&
395
+ (typeof value.injected_config_hash !== "string" ||
396
+ !/^[a-f0-9]{64}$/.test(value.injected_config_hash))) {
397
+ throw new CPACError("invalid injected_config_hash in state");
398
+ }
399
+ if (value.injected_openai_base_url !== undefined &&
400
+ (typeof value.injected_openai_base_url !== "string" ||
401
+ !value.injected_openai_base_url.trim())) {
402
+ throw new CPACError("invalid injected_openai_base_url in state");
403
+ }
404
+ if (value.injected_catalog_path !== undefined &&
405
+ (typeof value.injected_catalog_path !== "string" ||
406
+ !value.injected_catalog_path.trim())) {
407
+ throw new CPACError("invalid injected_catalog_path in state");
408
+ }
391
409
  return value;
392
410
  }
393
411
  export function stateProxy(state) {
@@ -395,7 +413,21 @@ export function stateProxy(state) {
395
413
  ? { id: state.proxy_id, pid: state.proxy_pid, port: state.proxy_port }
396
414
  : null;
397
415
  }
398
- export function stateBytes(config, existed, mode, proxy, proxyFingerprint) {
416
+ export function injectedOwnership(state) {
417
+ if (!state?.injected_config_hash ||
418
+ !state.injected_openai_base_url ||
419
+ !state.injected_catalog_path)
420
+ return undefined;
421
+ return {
422
+ hash: state.injected_config_hash,
423
+ openaiBaseUrl: state.injected_openai_base_url,
424
+ catalogPath: state.injected_catalog_path,
425
+ };
426
+ }
427
+ export function sha256Hex(bytes) {
428
+ return createHash("sha256").update(bytes).digest("hex");
429
+ }
430
+ export function stateBytes(config, existed, mode, proxy, proxyFingerprint, injected) {
399
431
  return Buffer.from(`${JSON.stringify({
400
432
  config_path: config.codex_config,
401
433
  config_existed: existed,
@@ -404,6 +436,13 @@ export function stateBytes(config, existed, mode, proxy, proxyFingerprint) {
404
436
  proxy_fingerprint: proxyFingerprint,
405
437
  proxy_pid: proxy.pid,
406
438
  proxy_port: proxy.port,
439
+ ...(injected
440
+ ? {
441
+ injected_config_hash: injected.hash,
442
+ injected_openai_base_url: injected.openaiBaseUrl,
443
+ injected_catalog_path: injected.catalogPath,
444
+ }
445
+ : {}),
407
446
  }, null, 2)}\n`);
408
447
  }
409
448
  export function originalBytes(stateDir, state, expectedPath) {
package/dist/proxy.js CHANGED
@@ -5,7 +5,7 @@ import { createServer, request as httpRequest, } from "node:http";
5
5
  import { request as httpsRequest } from "node:https";
6
6
  import { join } from "node:path";
7
7
  import { fileURLToPath } from "node:url";
8
- import { apiBase, originalBytes, proxyFingerprint, readState, stateBytes, stateProxy, } from "./config.js";
8
+ import { apiBase, injectedOwnership, originalBytes, proxyFingerprint, readState, stateBytes, stateProxy, } from "./config.js";
9
9
  import { CPACError, atomicWrite, objectValue, resolveApiKey } from "./util.js";
10
10
  const HOP_BY_HOP_HEADERS = new Set([
11
11
  "connection",
@@ -322,7 +322,7 @@ export async function runProxy(config) {
322
322
  }
323
323
  const proxy = await createLoopbackProxy(config.cpa_url, apiKey, recorded.id, recorded.port);
324
324
  try {
325
- 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)));
325
+ 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), injectedOwnership(state)));
326
326
  }
327
327
  catch (error) {
328
328
  proxy.server.closeAllConnections?.();
@@ -1,6 +1,6 @@
1
1
  import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
- import { PROVIDER, STATE_FILES, cleanupStateFiles, fetchCatalog, liftContextWindows, originalBytes, proxyFingerprint, readState, reorderCatalog, stateBytes, stateProxy, } from "../config.js";
3
+ import { PROVIDER, STATE_FILES, cleanupStateFiles, fetchCatalog, injectedOwnership, liftContextWindows, originalBytes, proxyFingerprint, readState, reorderCatalog, sha256Hex, stateBytes, stateProxy, } from "../config.js";
4
4
  import { proxyIsHealthy, startProxyProcess, stopProxyProcess, } from "../proxy.js";
5
5
  import { CPACError, atomicWrite, dominantEol, tomlString } from "../util.js";
6
6
  const MODEL_PROVIDER_KEY = /^\s*(?:model_provider|"model_provider"|'model_provider')\s*=/;
@@ -8,6 +8,37 @@ const MODEL_CATALOG_KEY = /^\s*(?:model_catalog_json|"model_catalog_json"|'model
8
8
  const OPENAI_BASE_URL_KEY = /^\s*(?:openai_base_url|"openai_base_url"|'openai_base_url')\s*=/;
9
9
  const ROOT_KEY = new RegExp(`(?:${MODEL_PROVIDER_KEY.source}|${MODEL_CATALOG_KEY.source}|${OPENAI_BASE_URL_KEY.source})`);
10
10
  export const MANAGED_MARKER = "# CPAC managed; run CPAC 'restore' to restore the original file.";
11
+ function tomlQuotedValue(line) {
12
+ const eq = line.indexOf("=");
13
+ if (eq === -1)
14
+ return undefined;
15
+ const rest = line.slice(eq + 1).trimStart();
16
+ if (rest.startsWith('"')) {
17
+ const match = /^(?:"(?:\\.|[^"\\])*")/.exec(rest);
18
+ if (!match)
19
+ return undefined;
20
+ try {
21
+ return JSON.parse(match[0]);
22
+ }
23
+ catch {
24
+ return undefined;
25
+ }
26
+ }
27
+ if (rest.startsWith("'")) {
28
+ const end = rest.indexOf("'", 1);
29
+ return end === -1 ? undefined : rest.slice(1, end);
30
+ }
31
+ return undefined;
32
+ }
33
+ function ownedRootLine(line, owned) {
34
+ if (!owned)
35
+ return false;
36
+ const value = tomlQuotedValue(line);
37
+ if (value === undefined)
38
+ return false;
39
+ return ((MODEL_CATALOG_KEY.test(line) && value === owned.catalogPath) ||
40
+ (OPENAI_BASE_URL_KEY.test(line) && value === owned.openaiBaseUrl));
41
+ }
11
42
  function providerPatterns(provider = PROVIDER) {
12
43
  const escaped = provider.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
13
44
  const modelProviders = `(?:model_providers|"model_providers"|'model_providers')`;
@@ -41,7 +72,7 @@ export function hasProviderTable(content, provider = PROVIDER) {
41
72
  }
42
73
  return false;
43
74
  }
44
- function stripManagedConfig(content) {
75
+ function stripManagedConfig(content, owned) {
45
76
  let text;
46
77
  try {
47
78
  text = new TextDecoder("utf-8", { fatal: true }).decode(content);
@@ -75,6 +106,8 @@ function stripManagedConfig(content) {
75
106
  managedRootKeys -= 1;
76
107
  continue;
77
108
  }
109
+ if (inRoot && ownedRootLine(line, owned))
110
+ continue;
78
111
  kept.push(line);
79
112
  }
80
113
  const output = kept.join("\n");
@@ -225,7 +258,7 @@ export async function inject(config, v2Off = false, maxContext = false) {
225
258
  const backup = originalBytes(config.state_dir, state, config.codex_config);
226
259
  try {
227
260
  original = existsSync(config.codex_config)
228
- ? stripManagedConfig(readFileSync(config.codex_config))
261
+ ? stripManagedConfig(readFileSync(config.codex_config), injectedOwnership(state))
229
262
  : backup;
230
263
  }
231
264
  catch (error) {
@@ -293,6 +326,11 @@ export async function inject(config, v2Off = false, maxContext = false) {
293
326
  await stopProxyProcess(proxy);
294
327
  throw error;
295
328
  }
329
+ const ownership = {
330
+ hash: state?.injected_config_hash ?? sha256Hex(injected),
331
+ openaiBaseUrl: `http://127.0.0.1:${proxy.port}/v1`,
332
+ catalogPath,
333
+ };
296
334
  mkdirSync(config.state_dir, { recursive: true, mode: 0o700 });
297
335
  if (!stateDirExisted)
298
336
  chmodSync(config.state_dir, 0o700);
@@ -300,7 +338,7 @@ export async function inject(config, v2Off = false, maxContext = false) {
300
338
  try {
301
339
  atomicWrite(catalogPath, catalog.bytes);
302
340
  atomicWrite(config.codex_config, injected, originalMode);
303
- atomicWrite(join(config.state_dir, "state.json"), stateBytes(config, existed, originalMode, proxy, fingerprint));
341
+ atomicWrite(join(config.state_dir, "state.json"), stateBytes(config, existed, originalMode, proxy, fingerprint, ownership));
304
342
  }
305
343
  catch (error) {
306
344
  if (startedProxy)
@@ -316,7 +354,7 @@ export async function inject(config, v2Off = false, maxContext = false) {
316
354
  atomicWrite(catalogPath, catalog.bytes);
317
355
  atomicWrite(config.codex_config, injected, originalMode);
318
356
  configWritten = true;
319
- atomicWrite(join(config.state_dir, "state.json"), stateBytes(config, existed, originalMode, proxy, fingerprint));
357
+ atomicWrite(join(config.state_dir, "state.json"), stateBytes(config, existed, originalMode, proxy, fingerprint, ownership));
320
358
  }
321
359
  catch (error) {
322
360
  if (configWritten) {
@@ -361,10 +399,25 @@ export async function restore(config) {
361
399
  if (!state)
362
400
  throw new CPACError("no active CPAC injection");
363
401
  const original = originalBytes(config.state_dir, state, config.codex_config);
364
- if (state.config_existed)
365
- atomicWrite(config.codex_config, original, state.config_mode);
366
- else
367
- rmSync(config.codex_config, { force: true });
402
+ const current = existsSync(config.codex_config)
403
+ ? readFileSync(config.codex_config)
404
+ : null;
405
+ const unchanged = !state.injected_config_hash ||
406
+ (current !== null && sha256Hex(current) === state.injected_config_hash);
407
+ if (!current || unchanged) {
408
+ if (state.config_existed)
409
+ atomicWrite(config.codex_config, original, state.config_mode);
410
+ else
411
+ rmSync(config.codex_config, { force: true });
412
+ }
413
+ else {
414
+ const stripped = stripManagedConfig(current, injectedOwnership(state));
415
+ const empty = new TextDecoder("utf-8").decode(stripped).trim() === "";
416
+ if (!state.config_existed && empty)
417
+ rmSync(config.codex_config, { force: true });
418
+ else
419
+ atomicWrite(config.codex_config, stripped, state.config_mode);
420
+ }
368
421
  const proxy = stateProxy(state);
369
422
  if (proxy)
370
423
  await stopProxyProcess(proxy);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yhong91/cpac",
3
- "version": "0.1.27",
3
+ "version": "0.1.28",
4
4
  "description": "Connect Codex and Claude Code to a remote CLIProxyAPI gateway",
5
5
  "type": "module",
6
6
  "bin": {