From b0fef5b44d1734fa70ba99fb57ca3d1722b61589 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 9 Jun 2026 03:33:12 +0000 Subject: [PATCH] feat: manage sub2api codex pool --- config/platform-infra/sub2api-codex-pool.yaml | 40 + scripts/src/help.ts | 2 +- scripts/src/platform-infra-sub2api-codex.ts | 819 +++++++++++++++++- 3 files changed, 836 insertions(+), 25 deletions(-) diff --git a/config/platform-infra/sub2api-codex-pool.yaml b/config/platform-infra/sub2api-codex-pool.yaml index 4e3b32bb..c8fc058d 100644 --- a/config/platform-infra/sub2api-codex-pool.yaml +++ b/config/platform-infra/sub2api-codex-pool.yaml @@ -4,3 +4,43 @@ pool: apiKeySecretName: sub2api-codex-pool-api-key apiKeySecretKey: API_KEY minOwnerBalanceUsd: 1000 +profiles: + entries: + - profile: default + accountName: unidesk-codex-default + configFile: config.toml.pre-sub2api + authFile: auth.json.pre-sub2api + fallbackConfigFile: config.toml + fallbackAuthFile: auth.json + - profile: HY + accountName: unidesk-codex-hy + configFile: config.toml.HY + authFile: auth.json.HY + - profile: gptclub + accountName: unidesk-codex-gptclub + configFile: config.toml.gptclub + authFile: auth.json.gptclub + - profile: pinche + accountName: unidesk-codex-pinche + configFile: config.toml.pinche + authFile: auth.json.pinche +publicExposure: + enabled: true + proxyName: platform-infra-sub2api + configMapName: sub2api-frpc-config + deploymentName: sub2api-frpc + frpcImage: fatedier/frpc:v0.68.1 + serverAddr: 74.48.78.17 + serverPort: 7000 + remotePort: 21880 + localIP: sub2api.platform-infra.svc.cluster.local + localPort: 8080 + publicBaseUrl: http://74.48.78.17:21880 + masterBaseUrl: http://127.0.0.1:21880 + masterFrps: + configPath: /opt/hwlab-frp/frps.dev.toml + containerName: hwlab-frps-dev +localCodex: + backupSuffix: pre-sub2api + providerName: OpenAI + wireApi: responses diff --git a/scripts/src/help.ts b/scripts/src/help.ts index 1d13616c..d29adaba 100644 --- a/scripts/src/help.ts +++ b/scripts/src/help.ts @@ -63,7 +63,7 @@ export function rootHelp(): unknown { { command: "hwlab nodes control-plane|git-mirror|secret --node G14 --lane v03", description: "Manage HWLAB node/lane runtime prerequisites for v0.3+ with the node identity passed as data instead of a command family." }, { command: "hwlab g14 monitor-prs | hwlab g14 control-plane status|apply|trigger-current|runtime-migration|cleanup-runs|cleanup-released-pvs | hwlab g14 git-mirror status|apply|sync|flush | hwlab g14 tools-image status|build", description: "Start the legacy G14 PR monitor, run bounded v0.2 Tekton/Argo control-plane, manual PipelineRun trigger, runtime migration, CI workspace retention, manual devops-infra git mirror/relay maintenance, or fixed HWLAB CI tools image actions; long confirmed trigger/sync/flush actions return async jobs by default." }, { command: "agentrun v01 queue|sessions|control-plane|git-mirror", description: "Use AgentRun v0.1 Queue and Sessions as the active commander entry through the official G14 CLI bridge, plus bounded Tekton/Argo and git-mirror operations." }, - { command: "platform-infra sub2api plan|apply|status|validate", description: "Deploy and validate the G14 k3s internal-only Sub2API service in the platform-infra namespace through the controlled UniDesk CLI." }, + { command: "platform-infra sub2api plan|apply|status|validate|codex-pool", description: "Deploy Sub2API in G14 platform-infra, manage the YAML-controlled Codex upstream pool, expose the unified API through FRP when needed, and configure master ~/.codex without printing API keys." }, { command: "hwlab cd audit --env dev | hwlab cd status --env dev | hwlab cd apply --env dev --dry-run", description: "Legacy D601 HWLAB DEV CD wrapper kept for explicit old-path diagnostics; current HWLAB rollout uses G14 GitOps." }, { command: "code-agent-sandbox", description: "Independent Code Agent Sandbox service skeleton for adapter, mode, and credential-boundary diagnostics." }, { command: "schedule list|get|runs|run|retry-run|delete", description: "Manage backend-core scheduled tasks and run history; schedule run supports --wait-ms N and retry-run reuses the failed run's schedule." }, diff --git a/scripts/src/platform-infra-sub2api-codex.ts b/scripts/src/platform-infra-sub2api-codex.ts index 83b4649c..a84e40da 100644 --- a/scripts/src/platform-infra-sub2api-codex.ts +++ b/scripts/src/platform-infra-sub2api-codex.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; import type { UniDeskConfig } from "./config"; @@ -28,6 +28,10 @@ interface SyncOptions extends DisclosureOptions { confirm: boolean; } +interface ConfirmOptions extends DisclosureOptions { + confirm: boolean; +} + interface CodexProfile { profile: string; accountName: string; @@ -51,19 +55,58 @@ interface CodexPoolConfig { apiKeySecretName: string; apiKeySecretKey: string; minOwnerBalanceUsd: number; + profiles: CodexPoolProfileConfig[]; + publicExposure: CodexPoolPublicExposureConfig; + localCodex: CodexPoolLocalCodexConfig; +} + +interface CodexPoolProfileConfig { + profile: string; + accountName: string | null; + configFile: string; + authFile: string; + fallbackConfigFile: string | null; + fallbackAuthFile: string | null; +} + +interface CodexPoolPublicExposureConfig { + enabled: boolean; + proxyName: string; + configMapName: string; + deploymentName: string; + frpcImage: string; + serverAddr: string; + serverPort: number; + remotePort: number; + localIP: string; + localPort: number; + publicBaseUrl: string; + masterBaseUrl: string; + masterFrps: { + configPath: string; + containerName: string; + }; +} + +interface CodexPoolLocalCodexConfig { + backupSuffix: string; + providerName: string; + wireApi: string; } export function codexPoolHelp(): unknown { const pool = readCodexPoolConfig(); return { - command: "platform-infra sub2api codex-pool plan|sync|validate", + command: "platform-infra sub2api codex-pool plan|sync|validate|expose|configure-local", output: "json", usage: [ "bun scripts/cli.ts platform-infra sub2api codex-pool plan", "bun scripts/cli.ts platform-infra sub2api codex-pool sync --confirm", "bun scripts/cli.ts platform-infra sub2api codex-pool validate [--full|--raw]", + "bun scripts/cli.ts platform-infra sub2api codex-pool expose --confirm", + "bun scripts/cli.ts platform-infra sub2api codex-pool configure-local --confirm", ], - description: "Import ~/.codex/config.toml plus config.toml.* API-key profiles into one Sub2API OpenAI pool and expose one internal API_KEY stored in a k3s Secret.", + description: "Import YAML-selected ~/.codex API-key profiles into one Sub2API OpenAI pool, expose one unified API_KEY, and optionally configure this master server's ~/.codex consumer endpoint.", target: { route: g14K3sRoute, namespace, @@ -72,6 +115,8 @@ export function codexPoolHelp(): unknown { poolGroupName: pool.groupName, poolApiKeySecretName: pool.apiKeySecretName, poolApiKeySecretKey: pool.apiKeySecretKey, + publicBaseUrl: pool.publicExposure.enabled ? pool.publicExposure.publicBaseUrl : null, + masterBaseUrl: pool.publicExposure.enabled ? pool.publicExposure.masterBaseUrl : null, secretValuesPrinted: false, }, }; @@ -82,6 +127,8 @@ export async function runCodexPoolCommand(config: UniDeskConfig, args: string[]) if (action === "plan") return codexPoolPlan(); if (action === "sync") return await codexPoolSync(config, parseSyncOptions(args.slice(1))); if (action === "validate") return await codexPoolValidate(config, parseDisclosureOptions(args.slice(1))); + if (action === "expose") return await codexPoolExpose(config, parseConfirmOptions(args.slice(1))); + if (action === "configure-local") return await codexPoolConfigureLocal(config, parseConfirmOptions(args.slice(1))); return { ok: false, error: "unsupported-platform-infra-sub2api-codex-pool-command", @@ -96,6 +143,12 @@ function parseSyncOptions(args: string[]): SyncOptions { return { ...disclosure, confirm: args.includes("--confirm") }; } +function parseConfirmOptions(args: string[]): ConfirmOptions { + validateOptions(args, new Set(["--confirm", "--full", "--raw"])); + const disclosure = parseDisclosureOptions(args.filter((arg) => arg !== "--confirm")); + return { ...disclosure, confirm: args.includes("--confirm") }; +} + function parseDisclosureOptions(args: string[]): DisclosureOptions { validateOptions(args, new Set(["--full", "--raw"])); const raw = args.includes("--raw"); @@ -118,8 +171,8 @@ function codexPoolPlan(): Record { action: "platform-infra-sub2api-codex-pool-plan", source: { directory: join(homedir(), ".codex"), - configPattern: "config.toml and config.toml.*", - authPattern: "auth.json and auth.json.*", + configPattern: "YAML-selected config files under ~/.codex", + authPattern: "YAML-selected auth files under ~/.codex", valuesPrinted: false, }, target: poolTarget(), @@ -132,6 +185,9 @@ function codexPoolPlan(): Record { accountType: "openai/apikey", grouping: `All discovered Codex profiles are bound to one Sub2API group named ${pool.groupName}.`, unifiedApiKey: `The client-facing API_KEY is controlled by k3s Secret ${namespace}/${pool.apiKeySecretName}.${pool.apiKeySecretKey}.`, + publicExposure: pool.publicExposure.enabled + ? `Master server consumers use ${pool.publicExposure.masterBaseUrl}; FRP proxy ${pool.publicExposure.proxyName} maps public ${pool.publicExposure.publicBaseUrl} to ${pool.publicExposure.localIP}:${pool.publicExposure.localPort}.` + : "Public FRP exposure is disabled by YAML.", idempotency: "sync reuses the group, account names, and k3s Secret when they already exist; credentials are updated from the current local Codex files.", configPolicy: "UniDesk-owned durable configuration remains YAML-first; local ~/.codex files and runtime Secrets are not committed.", }, @@ -222,22 +278,126 @@ async function codexPoolValidate(config: UniDeskConfig, options: DisclosureOptio }; } +async function codexPoolExpose(config: UniDeskConfig, options: ConfirmOptions): Promise> { + const pool = readCodexPoolConfig(); + if (!pool.publicExposure.enabled) { + return { + ok: true, + action: "platform-infra-sub2api-codex-pool-expose", + mode: "disabled-by-yaml", + target: poolTarget(pool), + }; + } + if (!options.confirm) { + return { + ok: true, + action: "platform-infra-sub2api-codex-pool-expose", + mode: "dry-run", + target: poolTarget(pool), + publicExposure: publicExposureSummary(pool), + next: { + confirm: "bun scripts/cli.ts platform-infra sub2api codex-pool expose --confirm", + }, + }; + } + const masterResult = await applyMasterFrpsAllowPort(pool); + const remoteResult = await capture(config, g14K3sRoute, ["script"], exposeScript(pool)); + const parsed = parseJsonOutput(remoteResult.stdout); + const publicProbe = await probePublicModels(pool, "without-api-key"); + const ok = masterResult.ok && remoteResult.exitCode === 0 && boolField(parsed, "ok", false) && publicProbe.httpStatus === 401; + if (options.raw) { + return { + ok, + action: "platform-infra-sub2api-codex-pool-expose", + masterFrps: masterResult, + remote: compactCapture(remoteResult, { full: true }), + parsed, + publicProbe, + }; + } + return { + ok, + action: "platform-infra-sub2api-codex-pool-expose", + mode: "confirmed", + publicExposure: publicExposureSummary(pool), + masterFrps: masterResult, + remote: parsed ?? compactCapture(remoteResult, { full: options.full || remoteResult.exitCode !== 0 }), + publicProbe, + next: { + configureLocal: "bun scripts/cli.ts platform-infra sub2api codex-pool configure-local --confirm", + validate: "bun scripts/cli.ts platform-infra sub2api codex-pool validate", + }, + }; +} + +async function codexPoolConfigureLocal(config: UniDeskConfig, options: ConfirmOptions): Promise> { + const pool = readCodexPoolConfig(); + const codexDir = join(homedir(), ".codex"); + const configPath = join(codexDir, "config.toml"); + const authPath = join(codexDir, "auth.json"); + const backupConfigPath = join(codexDir, `config.toml.${pool.localCodex.backupSuffix}`); + const backupAuthPath = join(codexDir, `auth.json.${pool.localCodex.backupSuffix}`); + if (!options.confirm) { + return { + ok: true, + action: "platform-infra-sub2api-codex-pool-configure-local", + mode: "dry-run", + target: { + codexDir, + configPath, + authPath, + backupConfigPath, + backupAuthPath, + baseUrl: pool.publicExposure.masterBaseUrl, + providerName: pool.localCodex.providerName, + wireApi: pool.localCodex.wireApi, + valuesPrinted: false, + }, + next: { + confirm: "bun scripts/cli.ts platform-infra sub2api codex-pool configure-local --confirm", + }, + }; + } + if (!pool.publicExposure.enabled) throw new Error(`${codexPoolConfigPath}.publicExposure.enabled must be true before configure-local`); + const keyResult = await fetchPoolApiKey(config, pool); + if (keyResult.apiKey === null) { + return { + ok: false, + action: "platform-infra-sub2api-codex-pool-configure-local", + error: keyResult.error ?? "pool API key missing", + valuesPrinted: false, + }; + } + const writeResult = writeLocalCodexConfig(pool, keyResult.apiKey); + const validateResult = await validatePublicGatewayWithKey(pool, keyResult.apiKey); + return { + ok: writeResult.ok && validateResult.ok, + action: "platform-infra-sub2api-codex-pool-configure-local", + mode: "confirmed", + local: writeResult, + validation: validateResult, + apiKey: { + secret: `${namespace}/${pool.apiKeySecretName}.${pool.apiKeySecretKey}`, + keyPreview: apiKeyPreview(keyResult.apiKey), + apiKeyFingerprint: fingerprint(keyResult.apiKey), + valuesPrinted: false, + }, + }; +} + function collectCodexProfiles(): CodexProfile[] { const codexDir = join(homedir(), ".codex"); + const pool = readCodexPoolConfig(); if (!existsSync(codexDir)) return []; - const configFiles = readdirSync(codexDir) - .filter((file) => file === "config.toml" || file.startsWith("config.toml.")) - .sort((a, b) => { - if (a === "config.toml") return -1; - if (b === "config.toml") return 1; - return a.localeCompare(b); - }); const seenAccountNames = new Set(); - return configFiles.map((configFile) => { - const suffix = configFile === "config.toml" ? "" : configFile.slice("config.toml.".length); - const profile = suffix === "" ? "default" : suffix; - const accountName = uniqueAccountName(profile, seenAccountNames); - const authFile = suffix === "" ? "auth.json" : `auth.json.${suffix}`; + const configs = pool.profiles.length > 0 ? pool.profiles : discoverCodexProfileConfigs(codexDir); + return configs.map((entry) => { + const resolved = resolveProfileFiles(codexDir, entry); + const profile = entry.profile; + const accountName = entry.accountName ?? uniqueAccountName(profile, seenAccountNames); + seenAccountNames.add(accountName); + const configFile = resolved.configFile; + const authFile = resolved.authFile; const configPath = join(codexDir, configFile); const authPath = join(codexDir, authFile); const base: CodexProfile = { @@ -258,6 +418,7 @@ function collectCodexProfiles(): CodexProfile[] { }; try { + if (!existsSync(configPath)) throw new Error(`config file ${configFile} is missing`); const parsed = Bun.TOML.parse(readFileSync(configPath, "utf8")) as unknown; if (!isRecord(parsed)) throw new Error("config is not a TOML object"); const providers = parsed.model_providers; @@ -295,14 +456,40 @@ function collectCodexProfiles(): CodexProfile[] { }); } +function discoverCodexProfileConfigs(codexDir: string): CodexPoolProfileConfig[] { + return readdirSync(codexDir) + .filter((file) => file === "config.toml" || file.startsWith("config.toml.")) + .sort((a, b) => { + if (a === "config.toml") return -1; + if (b === "config.toml") return 1; + return a.localeCompare(b); + }) + .map((configFile) => { + const suffix = configFile === "config.toml" ? "" : configFile.slice("config.toml.".length); + const profile = suffix === "" ? "default" : suffix; + return { + profile, + accountName: null, + configFile, + authFile: suffix === "" ? "auth.json" : `auth.json.${suffix}`, + fallbackConfigFile: null, + fallbackAuthFile: null, + }; + }); +} + +function resolveProfileFiles(codexDir: string, profile: CodexPoolProfileConfig): { configFile: string; authFile: string } { + const configFile = existsSync(join(codexDir, profile.configFile)) || profile.fallbackConfigFile === null + ? profile.configFile + : profile.fallbackConfigFile; + const authFile = existsSync(join(codexDir, profile.authFile)) || profile.fallbackAuthFile === null + ? profile.authFile + : profile.fallbackAuthFile; + return { configFile, authFile }; +} + function readCodexPoolConfig(): CodexPoolConfig { - const defaults: CodexPoolConfig = { - groupName: defaultPoolGroupName, - apiKeyName: defaultPoolApiKeyName, - apiKeySecretName: defaultPoolApiKeySecretName, - apiKeySecretKey: defaultPoolApiKeySecretKey, - minOwnerBalanceUsd: defaultMinOwnerBalanceUsd, - }; + const defaults = defaultCodexPoolConfig(); if (!existsSync(codexPoolConfigPath)) return defaults; const parsed = Bun.YAML.parse(readFileSync(codexPoolConfigPath, "utf8")) as unknown; if (!isRecord(parsed)) throw new Error(`${codexPoolConfigPath} must contain a YAML object`); @@ -314,6 +501,9 @@ function readCodexPoolConfig(): CodexPoolConfig { apiKeySecretName: stringValue(pool.apiKeySecretName) ?? defaults.apiKeySecretName, apiKeySecretKey: stringValue(pool.apiKeySecretKey) ?? defaults.apiKeySecretKey, minOwnerBalanceUsd: numberValue(pool.minOwnerBalanceUsd) ?? defaults.minOwnerBalanceUsd, + profiles: readProfileConfig(parsed.profiles, defaults.profiles), + publicExposure: readPublicExposureConfig(parsed.publicExposure, defaults.publicExposure), + localCodex: readLocalCodexConfig(parsed.localCodex, defaults.localCodex), }; validateKubernetesName(config.groupName, "pool.groupName", false); validateKubernetesName(config.apiKeySecretName, "pool.apiKeySecretName", true); @@ -324,6 +514,133 @@ function readCodexPoolConfig(): CodexPoolConfig { return config; } +function defaultCodexPoolConfig(): CodexPoolConfig { + return { + groupName: defaultPoolGroupName, + apiKeyName: defaultPoolApiKeyName, + apiKeySecretName: defaultPoolApiKeySecretName, + apiKeySecretKey: defaultPoolApiKeySecretKey, + minOwnerBalanceUsd: defaultMinOwnerBalanceUsd, + profiles: [], + publicExposure: { + enabled: false, + proxyName: "platform-infra-sub2api", + configMapName: "sub2api-frpc-config", + deploymentName: "sub2api-frpc", + frpcImage: "fatedier/frpc:v0.68.1", + serverAddr: "74.48.78.17", + serverPort: 7000, + remotePort: 21880, + localIP: serviceDns.split(":")[0], + localPort: 8080, + publicBaseUrl: "http://74.48.78.17:21880", + masterBaseUrl: "http://127.0.0.1:21880", + masterFrps: { + configPath: "/opt/hwlab-frp/frps.dev.toml", + containerName: "hwlab-frps-dev", + }, + }, + localCodex: { + backupSuffix: "pre-sub2api", + providerName: "OpenAI", + wireApi: "responses", + }, + }; +} + +function readProfileConfig(value: unknown, defaults: CodexPoolProfileConfig[]): CodexPoolProfileConfig[] { + if (!isRecord(value)) return defaults; + const entries = value.entries; + if (!Array.isArray(entries)) throw new Error(`${codexPoolConfigPath}.profiles.entries must be a YAML array`); + return entries.map((entry, index) => { + if (!isRecord(entry)) throw new Error(`${codexPoolConfigPath}.profiles.entries[${index}] must be an object`); + const profile = stringValue(entry.profile); + const configFile = stringValue(entry.configFile); + const authFile = stringValue(entry.authFile); + if (profile === null || configFile === null || authFile === null) { + throw new Error(`${codexPoolConfigPath}.profiles.entries[${index}] requires profile, configFile, and authFile`); + } + const accountName = stringValue(entry.accountName); + if (accountName !== null) validateKubernetesName(accountName, `profiles.entries[${index}].accountName`, false); + validateCodexFileName(configFile, `profiles.entries[${index}].configFile`); + validateCodexFileName(authFile, `profiles.entries[${index}].authFile`); + const fallbackConfigFile = stringValue(entry.fallbackConfigFile); + const fallbackAuthFile = stringValue(entry.fallbackAuthFile); + if (fallbackConfigFile !== null) validateCodexFileName(fallbackConfigFile, `profiles.entries[${index}].fallbackConfigFile`); + if (fallbackAuthFile !== null) validateCodexFileName(fallbackAuthFile, `profiles.entries[${index}].fallbackAuthFile`); + return { + profile, + accountName, + configFile, + authFile, + fallbackConfigFile, + fallbackAuthFile, + }; + }); +} + +function readPublicExposureConfig(value: unknown, defaults: CodexPoolPublicExposureConfig): CodexPoolPublicExposureConfig { + if (!isRecord(value)) return defaults; + const masterFrpsValue = isRecord(value.masterFrps) ? value.masterFrps : {}; + const config: CodexPoolPublicExposureConfig = { + enabled: booleanValue(value.enabled) ?? defaults.enabled, + proxyName: stringValue(value.proxyName) ?? defaults.proxyName, + configMapName: stringValue(value.configMapName) ?? defaults.configMapName, + deploymentName: stringValue(value.deploymentName) ?? defaults.deploymentName, + frpcImage: stringValue(value.frpcImage) ?? defaults.frpcImage, + serverAddr: stringValue(value.serverAddr) ?? defaults.serverAddr, + serverPort: numberValue(value.serverPort) ?? defaults.serverPort, + remotePort: numberValue(value.remotePort) ?? defaults.remotePort, + localIP: stringValue(value.localIP) ?? defaults.localIP, + localPort: numberValue(value.localPort) ?? defaults.localPort, + publicBaseUrl: normalizeBaseUrl(stringValue(value.publicBaseUrl)) ?? defaults.publicBaseUrl, + masterBaseUrl: normalizeBaseUrl(stringValue(value.masterBaseUrl)) ?? defaults.masterBaseUrl, + masterFrps: { + configPath: stringValue(masterFrpsValue.configPath) ?? defaults.masterFrps.configPath, + containerName: stringValue(masterFrpsValue.containerName) ?? defaults.masterFrps.containerName, + }, + }; + validateKubernetesName(config.configMapName, "publicExposure.configMapName", true); + validateKubernetesName(config.deploymentName, "publicExposure.deploymentName", true); + validateProxyName(config.proxyName, "publicExposure.proxyName"); + validatePort(config.serverPort, "publicExposure.serverPort"); + validatePort(config.remotePort, "publicExposure.remotePort"); + validatePort(config.localPort, "publicExposure.localPort"); + if (!/^[A-Za-z0-9._:/-]+$/u.test(config.frpcImage)) throw new Error(`${codexPoolConfigPath}.publicExposure.frpcImage has an unsupported format`); + if (!/^[A-Za-z0-9._:-]+$/u.test(config.serverAddr)) throw new Error(`${codexPoolConfigPath}.publicExposure.serverAddr has an unsupported format`); + if (!/^[A-Za-z0-9._:-]+$/u.test(config.localIP)) throw new Error(`${codexPoolConfigPath}.publicExposure.localIP has an unsupported format`); + if (!config.masterFrps.configPath.startsWith("/")) throw new Error(`${codexPoolConfigPath}.publicExposure.masterFrps.configPath must be absolute`); + validateProxyName(config.masterFrps.containerName, "publicExposure.masterFrps.containerName"); + return config; +} + +function readLocalCodexConfig(value: unknown, defaults: CodexPoolLocalCodexConfig): CodexPoolLocalCodexConfig { + if (!isRecord(value)) return defaults; + const config = { + backupSuffix: stringValue(value.backupSuffix) ?? defaults.backupSuffix, + providerName: stringValue(value.providerName) ?? defaults.providerName, + wireApi: stringValue(value.wireApi) ?? defaults.wireApi, + }; + if (!/^[A-Za-z0-9._-]+$/u.test(config.backupSuffix)) throw new Error(`${codexPoolConfigPath}.localCodex.backupSuffix has an unsupported format`); + validateProxyName(config.providerName, "localCodex.providerName"); + validateProxyName(config.wireApi, "localCodex.wireApi"); + return config; +} + +function validateCodexFileName(value: string, key: string): void { + if (!/^(config\.toml|config\.toml\.[A-Za-z0-9._-]+|auth\.json|auth\.json\.[A-Za-z0-9._-]+)$/u.test(value)) { + throw new Error(`${codexPoolConfigPath}.${key} has an unsupported file name`); + } +} + +function validateProxyName(value: string, key: string): void { + if (!/^[A-Za-z0-9._-]+$/u.test(value)) throw new Error(`${codexPoolConfigPath}.${key} has an unsupported format`); +} + +function validatePort(value: number, key: string): void { + if (!Number.isInteger(value) || value < 1 || value > 65535) throw new Error(`${codexPoolConfigPath}.${key} must be an integer port`); +} + function readAuthAPIKey(authPath: string): { apiKey: string | null; shape: string } { if (!existsSync(authPath)) return { apiKey: null, shape: "missing" }; const parsed = JSON.parse(readFileSync(authPath, "utf8")) as unknown; @@ -385,6 +702,451 @@ function poolTarget(pool = readCodexPoolConfig()): Record { }; } +function publicExposureSummary(pool: CodexPoolConfig): Record { + return { + enabled: pool.publicExposure.enabled, + proxyName: pool.publicExposure.proxyName, + namespace, + configMapName: pool.publicExposure.configMapName, + deploymentName: pool.publicExposure.deploymentName, + frpcImage: pool.publicExposure.frpcImage, + frps: { + serverAddr: pool.publicExposure.serverAddr, + serverPort: pool.publicExposure.serverPort, + remotePort: pool.publicExposure.remotePort, + masterConfigPath: pool.publicExposure.masterFrps.configPath, + masterContainerName: pool.publicExposure.masterFrps.containerName, + }, + upstream: { + localIP: pool.publicExposure.localIP, + localPort: pool.publicExposure.localPort, + serviceDns, + }, + urls: { + publicBaseUrl: pool.publicExposure.publicBaseUrl, + masterBaseUrl: pool.publicExposure.masterBaseUrl, + }, + }; +} + +async function applyMasterFrpsAllowPort(pool: CodexPoolConfig): Promise> { + const path = pool.publicExposure.masterFrps.configPath; + const port = pool.publicExposure.remotePort; + const container = pool.publicExposure.masterFrps.containerName; + if (!existsSync(path)) { + return { ok: false, error: "master-frps-config-missing", path, valuesPrinted: false }; + } + const before = readFileSync(path, "utf8"); + const alreadyAllowed = frpsAllowPortExists(before, port); + let backupPath: string | null = null; + let action = "kept-existing"; + if (!alreadyAllowed) { + const stamp = new Date().toISOString().replace(/[-:]/gu, "").replace(/\..*$/u, "Z"); + backupPath = `${path}.bak-sub2api-${stamp}`; + copyFileSync(path, backupPath); + const next = before.replace(/\s*$/u, "") + `\n\n[[allowPorts]]\nstart = ${port}\nend = ${port}\n`; + writeFileSync(path, next, "utf8"); + chmodSync(path, statSync(backupPath).mode & 0o777); + action = "added-allow-port"; + } + const restart = alreadyAllowed ? null : Bun.spawnSync(["docker", "restart", container]); + const inspect = Bun.spawnSync(["docker", "inspect", "-f", "{{.State.Running}}", container]); + const ok = alreadyAllowed || (restart?.exitCode === 0 && inspect.exitCode === 0 && String(inspect.stdout).trim() === "true"); + return { + ok, + action, + path, + backupPath, + remotePort: port, + containerName: container, + restart: restart === null ? null : { + exitCode: restart.exitCode, + stdoutTail: Buffer.from(restart.stdout).toString("utf8").slice(-1000), + stderrTail: Buffer.from(restart.stderr).toString("utf8").slice(-1000), + }, + inspect: { + exitCode: inspect.exitCode, + running: String(inspect.stdout).trim() === "true", + stderrTail: Buffer.from(inspect.stderr).toString("utf8").slice(-1000), + }, + valuesPrinted: false, + }; +} + +function frpsAllowPortExists(toml: string, port: number): boolean { + const sections = toml.split(/(?=\[\[allowPorts\]\])/u); + return sections.some((section) => { + if (!section.includes("[[allowPorts]]")) return false; + const start = section.match(/^\s*start\s*=\s*(\d+)\s*$/mu); + const end = section.match(/^\s*end\s*=\s*(\d+)\s*$/mu); + return Number(start?.[1]) === port && Number(end?.[1]) === port; + }); +} + +function exposeScript(pool: CodexPoolConfig): string { + const manifest = frpcManifest(pool); + const encoded = Buffer.from(manifest, "utf8").toString("base64"); + return ` +set -u +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT +manifest="$tmp/sub2api-frpc.yaml" +printf '%s' '${encoded}' | base64 -d > "$manifest" +ns_out="$tmp/ns.out" +ns_err="$tmp/ns.err" +apply_out="$tmp/apply.out" +apply_err="$tmp/apply.err" +rollout_out="$tmp/rollout.out" +rollout_err="$tmp/rollout.err" +kubectl get namespace ${namespace} >"$ns_out" 2>"$ns_err" +ns_rc=$? +apply_rc=1 +rollout_rc=1 +if [ "$ns_rc" -eq 0 ]; then + kubectl apply --server-side --force-conflicts --field-manager=${fieldManager} -f "$manifest" >"$apply_out" 2>"$apply_err" + apply_rc=$? + if [ "$apply_rc" -eq 0 ]; then + kubectl -n ${namespace} rollout status deployment/${pool.publicExposure.deploymentName} --timeout=30s >"$rollout_out" 2>"$rollout_err" + rollout_rc=$? + else + printf '%s\\n' 'skipped because apply failed' >"$rollout_err" + fi +else + : >"$apply_out" + printf '%s\\n' 'skipped because namespace is missing' >"$apply_err" + : >"$rollout_out" + printf '%s\\n' 'skipped because namespace is missing' >"$rollout_err" +fi +pods_json="$tmp/pods.json" +pods_err="$tmp/pods.err" +kubectl -n ${namespace} get pods -l app.kubernetes.io/name=${pool.publicExposure.deploymentName} -o json >"$pods_json" 2>"$pods_err" +pods_rc=$? +logs_out="$tmp/logs.out" +logs_err="$tmp/logs.err" +kubectl -n ${namespace} logs deployment/${pool.publicExposure.deploymentName} --tail=80 >"$logs_out" 2>"$logs_err" +logs_rc=$? +python3 - "$tmp" "$ns_rc" "$apply_rc" "$rollout_rc" "$pods_rc" "$logs_rc" <<'PY' +import json +import os +import sys + +tmp = sys.argv[1] +ns_rc, apply_rc, rollout_rc, pods_rc, logs_rc = [int(value) for value in sys.argv[2:]] + +def text(name, limit=4000): + path = os.path.join(tmp, name) + try: + return open(path, encoding="utf-8", errors="replace").read()[-limit:] + except FileNotFoundError: + return "" + +def load_json(name): + path = os.path.join(tmp, name) + try: + return json.load(open(path, encoding="utf-8")) + except Exception: + return None + +pods_obj = load_json("pods.json") +pods = [] +if isinstance(pods_obj, dict): + for item in pods_obj.get("items") or []: + status = item.get("status") or {} + statuses = status.get("containerStatuses") or [] + pods.append({ + "name": item.get("metadata", {}).get("name"), + "phase": status.get("phase"), + "ready": all(cs.get("ready") is True for cs in statuses) if statuses else False, + "restarts": sum(int(cs.get("restartCount") or 0) for cs in statuses), + }) + +logs = text("logs.out", 4000) +payload = { + "ok": ns_rc == 0 and apply_rc == 0 and rollout_rc == 0 and pods_rc == 0 and len(pods) > 0 and all(p["ready"] for p in pods), + "namespace": "${namespace}", + "proxy": { + "name": "${pool.publicExposure.proxyName}", + "remotePort": ${JSON.stringify(pool.publicExposure.remotePort)}, + "publicBaseUrl": "${pool.publicExposure.publicBaseUrl}", + "masterBaseUrl": "${pool.publicExposure.masterBaseUrl}", + "localIP": "${pool.publicExposure.localIP}", + "localPort": ${JSON.stringify(pool.publicExposure.localPort)}, + }, + "resources": { + "configMap": "${pool.publicExposure.configMapName}", + "deployment": "${pool.publicExposure.deploymentName}", + "image": "${pool.publicExposure.frpcImage}", + }, + "steps": { + "namespace": {"exitCode": ns_rc, "stdout": text("ns.out"), "stderr": text("ns.err")}, + "apply": {"exitCode": apply_rc, "stdout": text("apply.out"), "stderr": text("apply.err")}, + "rollout": {"exitCode": rollout_rc, "stdout": text("rollout.out"), "stderr": text("rollout.err")}, + "pods": {"exitCode": pods_rc, "items": pods, "stderr": text("pods.err")}, + "logs": { + "exitCode": logs_rc, + "startProxySuccess": "start proxy success" in logs, + "stdoutTail": logs, + "stderrTail": text("logs.err"), + }, + }, +} +print(json.dumps(payload, ensure_ascii=False, indent=2)) +sys.exit(0 if payload["ok"] else 1) +PY +`; +} + +function frpcManifest(pool: CodexPoolConfig): string { + return `apiVersion: v1 +kind: ConfigMap +metadata: + name: ${pool.publicExposure.configMapName} + namespace: ${namespace} + labels: + app.kubernetes.io/name: ${pool.publicExposure.deploymentName} + app.kubernetes.io/part-of: platform-infra + app.kubernetes.io/managed-by: unidesk +data: + frpc.toml: | + serverAddr = "${pool.publicExposure.serverAddr}" + serverPort = ${pool.publicExposure.serverPort} + loginFailExit = true + + [[proxies]] + name = "${pool.publicExposure.proxyName}" + type = "tcp" + localIP = "${pool.publicExposure.localIP}" + localPort = ${pool.publicExposure.localPort} + remotePort = ${pool.publicExposure.remotePort} +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: ${pool.publicExposure.deploymentName} + namespace: ${namespace} + labels: + app.kubernetes.io/name: ${pool.publicExposure.deploymentName} + app.kubernetes.io/part-of: platform-infra + app.kubernetes.io/managed-by: unidesk +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: ${pool.publicExposure.deploymentName} + template: + metadata: + labels: + app.kubernetes.io/name: ${pool.publicExposure.deploymentName} + app.kubernetes.io/part-of: platform-infra + app.kubernetes.io/managed-by: unidesk + spec: + containers: + - name: frpc + image: ${pool.publicExposure.frpcImage} + imagePullPolicy: IfNotPresent + args: + - -c + - /etc/frp/frpc.toml + volumeMounts: + - name: config + mountPath: /etc/frp + readOnly: true + volumes: + - name: config + configMap: + name: ${pool.publicExposure.configMapName} +`; +} + +async function fetchPoolApiKey(config: UniDeskConfig, pool: CodexPoolConfig): Promise<{ apiKey: string | null; error: string | null }> { + const result = await capture(config, g14K3sRoute, ["script"], ` +set -u +kubectl -n ${namespace} get secret ${pool.apiKeySecretName} -o json +`); + if (result.exitCode !== 0) { + return { apiKey: null, error: `read pool API key secret failed: ${result.stderr.slice(-1000)}` }; + } + const parsed = parseJsonOutput(result.stdout); + const data = isRecord(parsed?.data) ? parsed.data : null; + const encoded = typeof data?.[pool.apiKeySecretKey] === "string" ? data[pool.apiKeySecretKey] : null; + if (encoded === null) return { apiKey: null, error: `${namespace}/${pool.apiKeySecretName}.${pool.apiKeySecretKey} missing` }; + try { + const apiKey = Buffer.from(encoded, "base64").toString("utf8"); + return apiKey.length > 0 ? { apiKey, error: null } : { apiKey: null, error: "decoded API key is empty" }; + } catch (error) { + return { apiKey: null, error: error instanceof Error ? error.message : String(error) }; + } +} + +function writeLocalCodexConfig(pool: CodexPoolConfig, apiKey: string): Record { + const codexDir = join(homedir(), ".codex"); + mkdirSync(codexDir, { recursive: true, mode: 0o700 }); + const configPath = join(codexDir, "config.toml"); + const authPath = join(codexDir, "auth.json"); + const backupConfigPath = join(codexDir, `config.toml.${pool.localCodex.backupSuffix}`); + const backupAuthPath = join(codexDir, `auth.json.${pool.localCodex.backupSuffix}`); + if (!existsSync(configPath)) throw new Error(`${configPath} missing`); + if (!existsSync(authPath)) throw new Error(`${authPath} missing`); + const configBackupAction = copyIfMissing(configPath, backupConfigPath); + const authBackupAction = copyIfMissing(authPath, backupAuthPath); + const currentToml = readFileSync(configPath, "utf8"); + const nextToml = updateCodexConfigToml(currentToml, pool); + writeFileSync(configPath, nextToml, { encoding: "utf8", mode: 0o600 }); + chmodSync(configPath, 0o600); + writeFileSync(authPath, `${JSON.stringify({ OPENAI_API_KEY: apiKey }, null, 2)}\n`, { encoding: "utf8", mode: 0o600 }); + chmodSync(authPath, 0o600); + return { + ok: true, + codexDir, + config: { + path: configPath, + bytes: statSync(configPath).size, + backupPath: backupConfigPath, + backupAction: configBackupAction, + backupBytes: statSync(backupConfigPath).size, + }, + auth: { + path: authPath, + bytes: statSync(authPath).size, + backupPath: backupAuthPath, + backupAction: authBackupAction, + backupBytes: statSync(backupAuthPath).size, + openaiApiKeyShape: "string", + openaiApiKeyBytes: Buffer.byteLength(apiKey, "utf8"), + openaiApiKeyFingerprint: fingerprint(apiKey), + valuesPrinted: false, + }, + provider: { + name: pool.localCodex.providerName, + baseUrl: pool.publicExposure.masterBaseUrl, + wireApi: pool.localCodex.wireApi, + }, + valuesPrinted: false, + }; +} + +function copyIfMissing(source: string, target: string): "created" | "kept-existing" { + if (existsSync(target)) return "kept-existing"; + copyFileSync(source, target); + chmodSync(target, 0o600); + return "created"; +} + +function updateCodexConfigToml(current: string, pool: CodexPoolConfig): string { + let next = upsertTopLevelTomlString(current, "model_provider", pool.localCodex.providerName); + next = upsertProviderSection(next, pool.localCodex.providerName, pool.publicExposure.masterBaseUrl, pool.localCodex.wireApi); + return next.endsWith("\n") ? next : `${next}\n`; +} + +function upsertTopLevelTomlString(text: string, key: string, value: string): string { + const lines = text.split(/\r?\n/u); + const firstSection = lines.findIndex((line) => /^\s*\[/.test(line)); + const end = firstSection === -1 ? lines.length : firstSection; + for (let index = 0; index < end; index += 1) { + if (new RegExp(`^\\s*${escapeRegExp(key)}\\s*=`).test(lines[index])) { + lines[index] = `${key} = ${tomlString(value)}`; + return lines.join("\n"); + } + } + lines.splice(0, 0, `${key} = ${tomlString(value)}`); + return lines.join("\n"); +} + +function upsertProviderSection(text: string, providerName: string, baseUrl: string, wireApi: string): string { + const header = `[model_providers.${providerName}]`; + const lines = text.split(/\r?\n/u); + const start = lines.findIndex((line) => line.trim() === header); + const canonical = [ + `name = ${tomlString(providerName)}`, + `base_url = ${tomlString(baseUrl)}`, + `wire_api = ${tomlString(wireApi)}`, + "requires_openai_auth = true", + ]; + if (start === -1) { + const needsBlank = lines.length > 0 && lines[lines.length - 1].trim() !== ""; + return [...lines, ...(needsBlank ? [""] : []), header, ...canonical].join("\n"); + } + let end = lines.length; + for (let index = start + 1; index < lines.length; index += 1) { + if (/^\s*\[/.test(lines[index])) { + end = index; + break; + } + } + const preserved = lines.slice(start + 1, end).filter((line) => { + return !/^\s*(name|base_url|wire_api|requires_openai_auth|env_key)\s*=/u.test(line); + }); + const replacement = [header, ...canonical, ...preserved.filter((line) => line.trim() !== "")]; + return [...lines.slice(0, start), ...replacement, ...lines.slice(end)].join("\n"); +} + +async function validatePublicGatewayWithKey(pool: CodexPoolConfig, apiKey: string): Promise> { + const probe = await probePublicModels(pool, "with-api-key", apiKey); + return { + ...probe, + ok: probe.ok === true, + apiKeyFingerprint: fingerprint(apiKey), + keyPreview: apiKeyPreview(apiKey), + valuesPrinted: false, + }; +} + +async function probePublicModels(pool: CodexPoolConfig, mode: "with-api-key" | "without-api-key", apiKey?: string): Promise> { + const url = `${pool.publicExposure.masterBaseUrl.replace(/\/+$/u, "")}/v1/models`; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 8000); + try { + const response = await fetch(url, { + method: "GET", + headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : undefined, + signal: controller.signal, + }); + const body = await response.text(); + let parsed: unknown = null; + try { + parsed = body.trim().length > 0 ? JSON.parse(body) : null; + } catch { + parsed = null; + } + const modelCount = isRecord(parsed) && Array.isArray(parsed.data) ? parsed.data.length : null; + return { + ok: response.ok, + mode, + method: "GET /v1/models", + baseUrl: pool.publicExposure.masterBaseUrl, + httpStatus: response.status, + modelCount, + bodyPreview: response.ok ? "" : body.slice(0, 500), + valuesPrinted: false, + }; + } catch (error) { + return { + ok: false, + mode, + method: "GET /v1/models", + baseUrl: pool.publicExposure.masterBaseUrl, + httpStatus: 0, + error: error instanceof Error ? error.message : String(error), + valuesPrinted: false, + }; + } finally { + clearTimeout(timeout); + } +} + +function apiKeyPreview(apiKey: string): string { + if (apiKey.length <= 14) return "***"; + return `${apiKey.slice(0, 10)}...${apiKey.slice(-4)}`; +} + +function tomlString(value: string): string { + return JSON.stringify(value); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); +} + function normalizeBaseUrl(value: string | null): string | null { if (value === null) return null; const trimmed = value.trim().replace(/\/+$/u, ""); @@ -411,6 +1173,15 @@ function numberValue(value: unknown): number | null { return null; } +function booleanValue(value: unknown): boolean | null { + if (typeof value === "boolean") return value; + if (typeof value === "string") { + if (value.trim() === "true") return true; + if (value.trim() === "false") return false; + } + return null; +} + function validateKubernetesName(value: string, key: string, strictDns: boolean): void { const pattern = strictDns ? /^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/u : /^[A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?$/u; if (!pattern.test(value)) throw new Error(`${codexPoolConfigPath}.${key} has an unsupported format`);