@yhong91/cpac 0.1.25 → 0.1.27

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/dist/config.js ADDED
@@ -0,0 +1,440 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, readFileSync, rmdirSync, rmSync } from "node:fs";
3
+ import { homedir, tmpdir } from "node:os";
4
+ import { isAbsolute, join, parse, relative, resolve } from "node:path";
5
+ import { CPACError, atomicWrite, checkboxPicker, expandUserPath, objectValue, } from "./util.js";
6
+ export const PROVIDER = "cpac_cpa";
7
+ export const DEFAULT_CPA_URL = "http://124.223.178.52:8317";
8
+ export const DEFAULT_CODEX_PROXY_PORT = 10101;
9
+ const MAX_CATALOG_BYTES = 16 * 1024 * 1024;
10
+ const CONFIG_KEYS = new Set([
11
+ "cpa_url",
12
+ "api_key_env",
13
+ "codex_config",
14
+ "codex_proxy_port",
15
+ "state_dir",
16
+ "claude_models",
17
+ "spawn_models",
18
+ ]);
19
+ // Codex advertises only the first 5 picker-visible catalog models as
20
+ // spawn_agent overrides (codex-rs MAX_SPAWN_AGENT_MODEL_OVERRIDES).
21
+ const MAX_SPAWN_MODELS = 5;
22
+ export const CLAUDE_SLOT_KEYS = ["opus", "sonnet", "haiku"];
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
+ ]);
35
+ export const STATE_FILES = [
36
+ "state.json",
37
+ "config.toml.backup",
38
+ "codex-models.json",
39
+ ];
40
+ export function defaultConfigPath() {
41
+ return resolve(expandUserPath(process.env.CPAC_CONFIG || "~/.config/cpac/config.json"));
42
+ }
43
+ function normalizeCpaUrl(value) {
44
+ if (typeof value !== "string" || !value.trim()) {
45
+ throw new CPACError("cpa_url must be a non-empty string");
46
+ }
47
+ let parsed;
48
+ try {
49
+ parsed = new URL(value);
50
+ }
51
+ catch {
52
+ throw new CPACError("cpa_url must be an absolute http(s) URL");
53
+ }
54
+ if (!["http:", "https:"].includes(parsed.protocol) ||
55
+ !parsed.hostname ||
56
+ parsed.username ||
57
+ parsed.password ||
58
+ parsed.search ||
59
+ parsed.hash) {
60
+ throw new CPACError("cpa_url must be an absolute http(s) URL without credentials, query, or fragment");
61
+ }
62
+ return value.trim().replace(/\/+$/, "");
63
+ }
64
+ export function loadConfig(path, useDefaultsIfMissing = false) {
65
+ let value;
66
+ try {
67
+ value = JSON.parse(readFileSync(path, "utf8"));
68
+ }
69
+ catch (error) {
70
+ if (error.code === "ENOENT" &&
71
+ useDefaultsIfMissing) {
72
+ value = {};
73
+ }
74
+ else if (error.code === "ENOENT") {
75
+ throw new CPACError(`config not found: ${path}`);
76
+ }
77
+ else {
78
+ throw new CPACError(`cannot read config: ${error instanceof Error ? error.message : String(error)}`);
79
+ }
80
+ }
81
+ if (!objectValue(value))
82
+ throw new CPACError("config must be a JSON object");
83
+ const unknown = Object.keys(value)
84
+ .filter((key) => !CONFIG_KEYS.has(key))
85
+ .sort();
86
+ if (unknown.length)
87
+ throw new CPACError(`unknown config keys: ${unknown.join(", ")}`);
88
+ const cpaUrl = normalizeCpaUrl(value.cpa_url ?? process.env.CPA_BASE_URL?.trim() ?? DEFAULT_CPA_URL);
89
+ const apiKeyEnv = value.api_key_env ?? "CPA_API_KEY";
90
+ if (typeof apiKeyEnv !== "string" ||
91
+ !/^[A-Za-z_][A-Za-z0-9_]*$/.test(apiKeyEnv)) {
92
+ throw new CPACError("api_key_env must be an environment variable name");
93
+ }
94
+ if (value.codex_config !== undefined &&
95
+ (typeof value.codex_config !== "string" || !value.codex_config.trim())) {
96
+ throw new CPACError("codex_config must be a non-empty string");
97
+ }
98
+ if (value.state_dir !== undefined &&
99
+ (typeof value.state_dir !== "string" || !value.state_dir.trim())) {
100
+ throw new CPACError("state_dir must be a non-empty string");
101
+ }
102
+ const codexProxyPort = value.codex_proxy_port ?? DEFAULT_CODEX_PROXY_PORT;
103
+ if (!Number.isInteger(codexProxyPort) ||
104
+ codexProxyPort < 0 ||
105
+ codexProxyPort > 65535) {
106
+ throw new CPACError("codex_proxy_port must be an integer from 0 to 65535");
107
+ }
108
+ const claudeModelsRaw = value.claude_models;
109
+ let claudeModels;
110
+ if (claudeModelsRaw !== undefined) {
111
+ if (!objectValue(claudeModelsRaw))
112
+ throw new CPACError("claude_models must be an object");
113
+ const unknownSlots = Object.keys(claudeModelsRaw)
114
+ // "classifier" was a shipped slot; its env var turned out unread by
115
+ // Claude Code, so the key is dropped on load instead of erroring.
116
+ .filter((key) => key !== "classifier" && !CLAUDE_SLOT_KEYS.includes(key))
117
+ .sort();
118
+ if (unknownSlots.length)
119
+ throw new CPACError(`unknown claude_models slots: ${unknownSlots.join(", ")}`);
120
+ claudeModels = {};
121
+ for (const slot of CLAUDE_SLOT_KEYS) {
122
+ const model = claudeModelsRaw[slot];
123
+ if (model === undefined)
124
+ continue;
125
+ if (typeof model !== "string" || !model.trim())
126
+ throw new CPACError(`claude_models.${slot} must be a non-empty string`);
127
+ claudeModels[slot] = model.trim();
128
+ }
129
+ }
130
+ const spawnModelsRaw = value.spawn_models;
131
+ let spawnModels;
132
+ if (spawnModelsRaw !== undefined) {
133
+ if (!Array.isArray(spawnModelsRaw))
134
+ throw new CPACError("spawn_models must be an array of model slugs");
135
+ if (spawnModelsRaw.length > MAX_SPAWN_MODELS)
136
+ throw new CPACError(`spawn_models accepts at most ${MAX_SPAWN_MODELS} models`);
137
+ spawnModels = spawnModelsRaw.map((model, index) => {
138
+ if (typeof model !== "string" || !model.trim())
139
+ throw new CPACError(`spawn_models[${index}] must be a non-empty string`);
140
+ return model.trim();
141
+ });
142
+ }
143
+ const codexHome = expandUserPath(process.env.CODEX_HOME?.trim() || join(homedir(), ".codex"));
144
+ const codexConfig = resolve(expandUserPath(value.codex_config || join(codexHome, "config.toml")));
145
+ const stateDir = resolve(expandUserPath(value.state_dir || join(homedir(), ".cpac")));
146
+ const stateToConfig = relative(stateDir, codexConfig);
147
+ if ([parse(stateDir).root, resolve(homedir()), resolve(tmpdir())].includes(stateDir) ||
148
+ stateToConfig === "" ||
149
+ (!stateToConfig.startsWith("..") && !isAbsolute(stateToConfig))) {
150
+ throw new CPACError("state_dir must not be a filesystem root, home/temp directory, or contain codex_config");
151
+ }
152
+ return {
153
+ cpa_url: cpaUrl,
154
+ api_key_env: apiKeyEnv,
155
+ codex_config: codexConfig,
156
+ codex_proxy_port: codexProxyPort,
157
+ state_dir: stateDir,
158
+ claude_models: claudeModels,
159
+ spawn_models: spawnModels,
160
+ };
161
+ }
162
+ export function apiBase(cpaUrl) {
163
+ try {
164
+ const url = new URL(cpaUrl);
165
+ return url.pathname.replace(/\/+$/, "").endsWith("/v1")
166
+ ? cpaUrl
167
+ : `${cpaUrl}/v1`;
168
+ }
169
+ catch {
170
+ throw new CPACError("cpa_url must be an absolute http(s) URL");
171
+ }
172
+ }
173
+ export async function fetchCatalog(cpaUrl, apiKey) {
174
+ let response;
175
+ try {
176
+ response = await fetch(`${apiBase(cpaUrl)}/models?client_version=1`, {
177
+ headers: {
178
+ Authorization: `Bearer ${apiKey}`,
179
+ Accept: "application/json",
180
+ },
181
+ signal: AbortSignal.timeout(20_000),
182
+ });
183
+ }
184
+ catch {
185
+ throw new CPACError("CPA catalog request failed");
186
+ }
187
+ if (!response.ok)
188
+ throw new CPACError(`CPA catalog request failed: HTTP ${response.status}`);
189
+ const declaredLength = response.headers.get("content-length");
190
+ if (declaredLength &&
191
+ /^\d+$/.test(declaredLength) &&
192
+ Number(declaredLength) > MAX_CATALOG_BYTES) {
193
+ await response.body?.cancel();
194
+ throw new CPACError("CPA catalog exceeds 16 MiB");
195
+ }
196
+ const chunks = [];
197
+ let length = 0;
198
+ const reader = response.body?.getReader();
199
+ if (reader) {
200
+ while (true) {
201
+ let result;
202
+ try {
203
+ result = await reader.read();
204
+ }
205
+ catch {
206
+ throw new CPACError("CPA catalog request failed");
207
+ }
208
+ if (result.done)
209
+ break;
210
+ length += result.value.byteLength;
211
+ if (length > MAX_CATALOG_BYTES) {
212
+ await reader.cancel();
213
+ throw new CPACError("CPA catalog exceeds 16 MiB");
214
+ }
215
+ chunks.push(result.value);
216
+ }
217
+ }
218
+ const body = Buffer.concat(chunks, length);
219
+ let document;
220
+ try {
221
+ document = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(body));
222
+ }
223
+ catch {
224
+ throw new CPACError("CPA catalog is not valid JSON");
225
+ }
226
+ const models = objectValue(document) ? document.models : undefined;
227
+ if (!Array.isArray(models) || models.length === 0) {
228
+ throw new CPACError("CPA rich catalog must contain a non-empty models array");
229
+ }
230
+ if (models.some((model) => !objectValue(model) || typeof model.slug !== "string" || !model.slug.trim())) {
231
+ throw new CPACError("CPA catalog contains a model without a slug");
232
+ }
233
+ return {
234
+ bytes: Buffer.from(`${JSON.stringify(document, null, 2)}\n`),
235
+ modelCount: models.length,
236
+ };
237
+ }
238
+ // Codex treats a catalog model's context_window as its input budget, not a
239
+ // display label; upstream keeps it a conservative operating cap while
240
+ // max_context_window holds the real ceiling (gpt-5.6: 272k vs ~921k, measured
241
+ // by opencodex). Opt-in lift: raise context_window to max_context_window and
242
+ // set auto_compact_token_limit at 90% (Codex's own convention), keeping
243
+ // compaction ahead of the hard ceiling.
244
+ export function liftContextWindows(bytes) {
245
+ let document;
246
+ try {
247
+ document = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
248
+ }
249
+ catch {
250
+ throw new CPACError("invalid CPA catalog");
251
+ }
252
+ for (const model of document.models) {
253
+ const maxWindow = typeof model.max_context_window === "number" && model.max_context_window > 0
254
+ ? Math.floor(model.max_context_window)
255
+ : 0;
256
+ const window = typeof model.context_window === "number"
257
+ ? Math.floor(model.context_window)
258
+ : 0;
259
+ if (maxWindow <= window)
260
+ continue;
261
+ model.context_window = maxWindow;
262
+ model.auto_compact_token_limit = Math.floor(maxWindow * 0.9);
263
+ }
264
+ return Buffer.from(`${JSON.stringify(document, null, 2)}\n`);
265
+ }
266
+ // Move `order` slugs to the front of the rich catalog (stable for the rest),
267
+ // so the Codex client's first-5 spawn_agent advertisement picks them.
268
+ export function reorderCatalog(bytes, order) {
269
+ let document;
270
+ try {
271
+ document = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
272
+ }
273
+ catch {
274
+ throw new CPACError("invalid CPA catalog");
275
+ }
276
+ const rank = new Map(order.map((slug, index) => [slug, index]));
277
+ const models = document.models.map((model, index) => ({ model, index }));
278
+ models.sort((left, right) => {
279
+ const leftRank = rank.get(String(left.model.slug));
280
+ const rightRank = rank.get(String(right.model.slug));
281
+ if (leftRank === undefined && rightRank === undefined)
282
+ return left.index - right.index;
283
+ if (leftRank === undefined)
284
+ return 1;
285
+ if (rightRank === undefined)
286
+ return -1;
287
+ return leftRank - rightRank;
288
+ });
289
+ document.models = models.map((entry) => entry.model);
290
+ return Buffer.from(`${JSON.stringify(document, null, 2)}\n`);
291
+ }
292
+ export function saveSpawnModels(configPath, models) {
293
+ let document = {};
294
+ if (existsSync(configPath)) {
295
+ let parsed;
296
+ try {
297
+ parsed = JSON.parse(readFileSync(configPath, "utf8"));
298
+ }
299
+ catch {
300
+ throw new CPACError("config must be a valid JSON object");
301
+ }
302
+ if (!objectValue(parsed))
303
+ throw new CPACError("config must be a JSON object");
304
+ document = parsed;
305
+ }
306
+ document.spawn_models = models;
307
+ atomicWrite(configPath, Buffer.from(`${JSON.stringify(document, null, 2)}\n`));
308
+ }
309
+ // Arrow-key checkbox picker on raw stdin; resolves with the checked labels in
310
+ export async function pickSpawnModels(config) {
311
+ const apiKey = process.env[config.api_key_env]?.trim();
312
+ if (!apiKey)
313
+ throw new CPACError(`environment variable ${config.api_key_env} is not set`);
314
+ const catalog = await fetchCatalog(config.cpa_url, apiKey);
315
+ let document;
316
+ try {
317
+ document = JSON.parse(new TextDecoder().decode(catalog.bytes));
318
+ }
319
+ catch {
320
+ throw new CPACError("invalid CPA catalog");
321
+ }
322
+ const slugs = document.models.map((model) => model.slug);
323
+ const picked = await checkboxPicker("Select spawn_agent models", slugs, MAX_SPAWN_MODELS);
324
+ if (picked.length === 0)
325
+ throw new CPACError("no models selected");
326
+ return picked;
327
+ }
328
+ export function catalogModelRows(document) {
329
+ if (!objectValue(document))
330
+ return undefined;
331
+ const source = Array.isArray(document.models)
332
+ ? document.models
333
+ : Array.isArray(document.data)
334
+ ? document.data
335
+ : undefined;
336
+ if (!source)
337
+ return undefined;
338
+ return source.filter(objectValue);
339
+ }
340
+ export function catalogModelId(model) {
341
+ if (typeof model.slug === "string" && model.slug.trim())
342
+ return model.slug.trim();
343
+ if (typeof model.id === "string" && model.id.trim())
344
+ return model.id.trim();
345
+ return undefined;
346
+ }
347
+ export function readState(stateDir) {
348
+ const path = join(stateDir, "state.json");
349
+ if (!existsSync(path))
350
+ return null;
351
+ let value;
352
+ try {
353
+ value = JSON.parse(readFileSync(path, "utf8"));
354
+ }
355
+ catch (error) {
356
+ throw new CPACError(`cannot read state: ${error instanceof Error ? error.message : String(error)}`);
357
+ }
358
+ if (!objectValue(value) ||
359
+ Object.keys(value).some((key) => !STATE_KEYS.has(key)) ||
360
+ [...REQUIRED_STATE_KEYS].some((key) => !(key in value))) {
361
+ throw new CPACError("invalid state file");
362
+ }
363
+ if (typeof value.config_path !== "string" || !isAbsolute(value.config_path)) {
364
+ throw new CPACError("invalid config path in state");
365
+ }
366
+ if (typeof value.config_existed !== "boolean")
367
+ throw new CPACError("invalid config_existed value in state");
368
+ if (!Number.isInteger(value.config_mode) ||
369
+ value.config_mode < 0 ||
370
+ value.config_mode > 0o7777) {
371
+ throw new CPACError("invalid config_mode value in state");
372
+ }
373
+ const proxyKeys = ["proxy_id", "proxy_pid", "proxy_port"];
374
+ const proxyKeyCount = proxyKeys.filter((key) => key in value).length;
375
+ if (proxyKeyCount !== 0 &&
376
+ (proxyKeyCount !== proxyKeys.length ||
377
+ typeof value.proxy_id !== "string" ||
378
+ !/^[a-f0-9]{48}$/.test(value.proxy_id) ||
379
+ !Number.isInteger(value.proxy_pid) ||
380
+ value.proxy_pid <= 0 ||
381
+ !Number.isInteger(value.proxy_port) ||
382
+ value.proxy_port <= 0 ||
383
+ value.proxy_port > 65535)) {
384
+ throw new CPACError("invalid loopback proxy state");
385
+ }
386
+ if (value.proxy_fingerprint !== undefined &&
387
+ (typeof value.proxy_fingerprint !== "string" ||
388
+ !/^[a-f0-9]{64}$/.test(value.proxy_fingerprint))) {
389
+ throw new CPACError("invalid loopback proxy fingerprint");
390
+ }
391
+ return value;
392
+ }
393
+ export function stateProxy(state) {
394
+ return state.proxy_id && state.proxy_pid && state.proxy_port
395
+ ? { id: state.proxy_id, pid: state.proxy_pid, port: state.proxy_port }
396
+ : null;
397
+ }
398
+ export function stateBytes(config, existed, mode, proxy, proxyFingerprint) {
399
+ return Buffer.from(`${JSON.stringify({
400
+ config_path: config.codex_config,
401
+ config_existed: existed,
402
+ config_mode: mode,
403
+ proxy_id: proxy.id,
404
+ proxy_fingerprint: proxyFingerprint,
405
+ proxy_pid: proxy.pid,
406
+ proxy_port: proxy.port,
407
+ }, null, 2)}\n`);
408
+ }
409
+ export function originalBytes(stateDir, state, expectedPath) {
410
+ if (state.config_path !== expectedPath) {
411
+ throw new CPACError(`active injection belongs to ${state.config_path}; restore it first`);
412
+ }
413
+ if (!state.config_existed)
414
+ return new Uint8Array();
415
+ try {
416
+ return readFileSync(join(stateDir, "config.toml.backup"));
417
+ }
418
+ catch (error) {
419
+ throw new CPACError(`cannot read original config backup: ${error instanceof Error ? error.message : String(error)}`);
420
+ }
421
+ }
422
+ export function cleanupStateFiles(stateDir) {
423
+ for (const name of STATE_FILES)
424
+ rmSync(join(stateDir, name), { force: true });
425
+ try {
426
+ rmdirSync(stateDir);
427
+ }
428
+ catch (error) {
429
+ const code = error.code;
430
+ if (code !== "ENOENT" && code !== "ENOTEMPTY" && code !== "EEXIST")
431
+ throw error;
432
+ }
433
+ }
434
+ export function proxyFingerprint(config, apiKey) {
435
+ return createHash("sha256")
436
+ .update(config.cpa_url)
437
+ .update("\0")
438
+ .update(apiKey)
439
+ .digest("hex");
440
+ }