diff --git a/config/platform-infra/sub2api-codex-pool.yaml b/config/platform-infra/sub2api-codex-pool.yaml index b1f17588..2dad53c5 100644 --- a/config/platform-infra/sub2api-codex-pool.yaml +++ b/config/platform-infra/sub2api-codex-pool.yaml @@ -134,6 +134,10 @@ profiles: accountName: unidesk-codex-socap configFile: config.toml.socap authFile: auth.json.socap +manualAccounts: + protected: + - accountName: lucianepidgeon@gmail.com + reason: Manually configured in Sub2API; keep outside UniDesk-managed Codex pool and sentinel control. publicExposure: enabled: false proxyName: platform-infra-sub2api diff --git a/scripts/src/platform-infra-sub2api-codex-sentinel.ts b/scripts/src/platform-infra-sub2api-codex-sentinel.ts index 2d126deb..5c2ab510 100644 --- a/scripts/src/platform-infra-sub2api-codex-sentinel.ts +++ b/scripts/src/platform-infra-sub2api-codex-sentinel.ts @@ -59,6 +59,7 @@ export interface CodexPoolSentinelConfig { usdPer1MOutputTokens: number; }; historyLimit: number; + protectedManualAccounts?: string[]; } export interface CodexPoolSentinelImageTarget { @@ -157,6 +158,7 @@ export function defaultCodexPoolSentinelConfig(): CodexPoolSentinelConfig { usdPer1MOutputTokens: 10, }, historyLimit: 200, + protectedManualAccounts: [], }; } @@ -295,6 +297,7 @@ export function codexPoolSentinelSummary(config: CodexPoolSentinelConfig): Recor sdk: config.sdk, cadence: config.cadence, freeze: config.freeze, + protectedManualAccountCount: config.protectedManualAccounts?.length ?? 0, accounting: { mode: "record-only", pricing: config.pricing, @@ -329,6 +332,7 @@ export function renderCodexPoolSentinelManifest( cadence: config.cadence, freeze: config.freeze, pricing: config.pricing, + protectedManualAccounts: config.protectedManualAccounts ?? [], state: { configMapName: config.stateConfigMapName, historyLimit: config.historyLimit, @@ -778,6 +782,8 @@ class Sub2ApiAdmin: self.base = config["service"]["baseUrl"].rstrip("/") self.email = os.environ.get("ADMIN_EMAIL") or config["service"]["adminEmailDefault"] self.password = os.environ.get("ADMIN_PASSWORD") or "" + protected = config.get("protectedManualAccounts") if isinstance(config.get("protectedManualAccounts"), list) else [] + self.protected_manual_accounts = set(str(item) for item in protected if isinstance(item, str) and item) self.token = None self.accounts_by_name = None @@ -847,6 +853,14 @@ class Sub2ApiAdmin: return None def set_schedulable(self, account_name, schedulable): + if account_name in self.protected_manual_accounts: + return { + "accountId": None, + "previousSchedulable": None, + "schedulable": None, + "skipped": True, + "reason": "protected-manual-account", + } account = self.account(account_name) if not account or account.get("id") is None: raise RuntimeError(f"account {account_name} not found") diff --git a/scripts/src/platform-infra-sub2api-codex.ts b/scripts/src/platform-infra-sub2api-codex.ts index 2c35c498..2636e164 100644 --- a/scripts/src/platform-infra-sub2api-codex.ts +++ b/scripts/src/platform-infra-sub2api-codex.ts @@ -146,11 +146,21 @@ interface CodexPoolConfig { defaultTempUnschedulable: CodexTempUnschedulablePolicy; defaultSentinelProtect: CodexSentinelProtectPolicy; profiles: CodexPoolProfileConfig[]; + manualAccounts: CodexPoolManualAccountsConfig; publicExposure: CodexPoolPublicExposureConfig; localCodex: CodexPoolLocalCodexConfig; sentinel: CodexPoolSentinelConfig; } +interface CodexPoolManualAccountsConfig { + protected: CodexPoolManualAccountProtection[]; +} + +interface CodexPoolManualAccountProtection { + accountName: string; + reason: string | null; +} + interface CodexPoolProfileConfig { profile: string; accountName: string | null; @@ -379,7 +389,7 @@ function parseSentinelProbeOptions(args: string[]): SentinelProbeOptions { } const uniqueAccounts = [...new Set(accounts)]; if (uniqueAccounts.length === 0) throw new Error("sentinel-probe requires --account "); - for (const account of uniqueAccounts) validateKubernetesName(account, "--account", false); + for (const account of uniqueAccounts) validateSub2ApiAccountSelector(account, "--account"); const disclosure = parseDisclosureOptions(disclosureArgs); return { ...disclosure, confirm, accounts: uniqueAccounts }; } @@ -545,6 +555,12 @@ function splitAccountNames(value: string): string[] { return value.split(",").map((item) => item.trim()).filter(Boolean); } +function validateSub2ApiAccountSelector(value: string, option: string): void { + if (value.length === 0 || value.length > 256) throw new Error(`${option} must be a non-empty account name up to 256 characters`); + if (/[\r\n]/u.test(value)) throw new Error(`${option} must not contain newlines`); + if (!/^[^<>"'`\\]+$/u.test(value)) throw new Error(`${option} contains unsupported characters`); +} + function validateOptions(args: string[], booleanOptions: Set): void { for (let index = 0; index < args.length; index += 1) { const arg = args[index]!; @@ -678,6 +694,9 @@ function codexPoolPlan(options?: DisclosureOptions): Record { : `Legacy Codex-pool FRP exposure is disabled by YAML; Codex consumers for target ${runtimeTarget.id} use target-level public exposure ${consumerBaseUrl}.`, idempotency: "sync reuses the group, account names, and k3s Secret when they already exist; credentials are updated from the current local Codex files; managed accounts missing from YAML are preserved unless --prune-removed is explicitly provided.", configPolicy: "UniDesk-owned durable configuration remains YAML-first; local ~/.codex files and runtime Secrets are not committed.", + manualAccountProtection: pool.manualAccounts.protected.length === 0 + ? "No manual Sub2API accounts are protected by YAML." + : `${pool.manualAccounts.protected.length} manual Sub2API account(s) are protected from UniDesk-managed sync, prune, sentinel probe, and sentinel freeze paths.`, }, next: ok ? { sync: `bun scripts/cli.ts platform-infra sub2api codex-pool sync${targetFlag(runtimeTarget)} --confirm` } @@ -921,6 +940,18 @@ async function codexPoolSentinelReport(config: UniDeskConfig, options: SentinelR async function codexPoolSentinelProbe(config: UniDeskConfig, options: SentinelProbeOptions): Promise> { const pool = readCodexPoolConfig(); const runtimeTarget = codexPoolRuntimeTarget(options.targetId); + const protectedNames = new Set(pool.manualAccounts.protected.map((account) => account.accountName.toLowerCase())); + const protectedRequested = options.accounts.filter((account) => protectedNames.has(account.toLowerCase())); + if (protectedRequested.length > 0) { + return { + ok: false, + action: "platform-infra-sub2api-codex-pool-sentinel-probe", + error: "account-protected-manual", + protected: protectedRequested, + message: "Protected manual Sub2API accounts are not YAML-managed and must not be probed by the UniDesk sentinel.", + valuesPrinted: false, + }; + } const configuredAccounts = desiredAccountNames(pool); const missing = options.accounts.filter((account) => !configuredAccounts.includes(account)); if (missing.length > 0) { @@ -1270,6 +1301,8 @@ function readCodexPoolConfig(): CodexPoolConfig { defaultSentinelProtect, defaultAccountPriorityValue, ); + const manualAccounts = readManualAccountsConfig(parsed.manualAccounts, defaults.manualAccounts); + assertProtectedManualAccountsNotManaged(profiles, manualAccounts); const declaredAccountCapacity = desiredProfileCapacityTotal(profiles, defaultAccountCapacityValue); const minOwnerConcurrencySource = pool.minOwnerConcurrency === undefined || pool.minOwnerConcurrency === null ? "auto" : "yaml"; const minOwnerConcurrency = minOwnerConcurrencySource === "auto" @@ -1296,9 +1329,13 @@ function readCodexPoolConfig(): CodexPoolConfig { defaultTempUnschedulable, defaultSentinelProtect, profiles, + manualAccounts, publicExposure: readPublicExposureConfig(parsed.publicExposure, defaults.publicExposure), localCodex: readLocalCodexConfig(parsed.localCodex, defaults.localCodex), - sentinel: readCodexPoolSentinelConfig(parsed.sentinel, defaults.sentinel, codexPoolConfigPath), + sentinel: { + ...readCodexPoolSentinelConfig(parsed.sentinel, defaults.sentinel, codexPoolConfigPath), + protectedManualAccounts: manualAccounts.protected.map((account) => account.accountName), + }, }; validateKubernetesName(config.groupName, "pool.groupName", false); validateKubernetesName(config.apiKeySecretName, "pool.apiKeySecretName", true); @@ -1335,6 +1372,9 @@ function defaultCodexPoolConfig(): CodexPoolConfig { defaultTempUnschedulable: defaultCodexTempUnschedulablePolicy(), defaultSentinelProtect: defaultCodexSentinelProtectPolicy(), profiles: [], + manualAccounts: { + protected: [], + }, publicExposure: { enabled: false, proxyName: "platform-infra-sub2api", @@ -1449,6 +1489,58 @@ function desiredProfileCapacityTotal(profiles: CodexPoolProfileConfig[], default return profiles.reduce((total, profile) => total + (profile.capacity ?? defaultCapacity), 0); } +function readManualAccountsConfig(value: unknown, defaults: CodexPoolManualAccountsConfig): CodexPoolManualAccountsConfig { + if (value === undefined || value === null) return defaults; + if (!isRecord(value)) throw new Error(`${codexPoolConfigPath}.manualAccounts must be a YAML object`); + const protectedRaw = value.protected; + if (protectedRaw === undefined || protectedRaw === null) return { protected: [] }; + if (!Array.isArray(protectedRaw)) throw new Error(`${codexPoolConfigPath}.manualAccounts.protected must be a YAML array`); + const seen = new Set(); + const protectedAccounts = protectedRaw.map((entry, index): CodexPoolManualAccountProtection => { + const key = `manualAccounts.protected[${index}]`; + const accountName = typeof entry === "string" + ? readManualAccountName(entry, key) + : isRecord(entry) + ? readManualAccountName(entry.accountName, `${key}.accountName`) + : null; + if (accountName === null) throw new Error(`${codexPoolConfigPath}.${key} must be an account name string or object with accountName`); + const normalized = accountName.toLowerCase(); + if (seen.has(normalized)) throw new Error(`${codexPoolConfigPath}.${key}.accountName is duplicated in manualAccounts.protected`); + seen.add(normalized); + const reason = isRecord(entry) ? readManualAccountReason(entry.reason, `${key}.reason`) : null; + return { accountName, reason }; + }); + return { protected: protectedAccounts }; +} + +function readManualAccountName(value: unknown, key: string): string | null { + const text = stringValue(value)?.trim() ?? null; + if (text === null || text.length === 0) return null; + if (text.length > 256) throw new Error(`${codexPoolConfigPath}.${key} must be at most 256 characters`); + if (/[\r\n]/u.test(text)) throw new Error(`${codexPoolConfigPath}.${key} must not contain newlines`); + if (!/^[^<>"'`\\]+$/u.test(text)) throw new Error(`${codexPoolConfigPath}.${key} contains unsupported characters`); + return text; +} + +function readManualAccountReason(value: unknown, key: string): string | null { + if (value === undefined || value === null) return null; + const text = stringValue(value)?.trim() ?? null; + if (text === null || text.length === 0) return null; + if (text.length > 240) throw new Error(`${codexPoolConfigPath}.${key} must be at most 240 characters`); + if (/[\r\n]/u.test(text)) throw new Error(`${codexPoolConfigPath}.${key} must not contain newlines`); + return text; +} + +function assertProtectedManualAccountsNotManaged(profiles: CodexPoolProfileConfig[], manualAccounts: CodexPoolManualAccountsConfig): void { + const protectedNames = new Set(manualAccounts.protected.map((account) => account.accountName.toLowerCase())); + for (const [index, profile] of profiles.entries()) { + const accountName = profile.accountName; + if (accountName !== null && protectedNames.has(accountName.toLowerCase())) { + throw new Error(`${codexPoolConfigPath}.profiles.entries[${index}].accountName is listed in manualAccounts.protected; protected manual accounts must not be YAML-managed`); + } + } +} + function rejectSchedulableYamlField(value: Record, key: string): void { if (Object.prototype.hasOwnProperty.call(value, "schedulable")) { throw new Error(`${codexPoolConfigPath}.${key}.schedulable is process control, not durable YAML config; use codex-pool sync actions instead`); @@ -1928,6 +2020,11 @@ function codexPoolConfigSummary(pool: CodexPoolConfig): Record defaultTempUnschedulable: tempUnschedulableSummary(pool.defaultTempUnschedulable), defaultSentinelProtect: pool.defaultSentinelProtect, profileCount: pool.profiles.length, + manualAccounts: { + protectedCount: pool.manualAccounts.protected.length, + protected: pool.manualAccounts.protected, + controlPolicy: "manual accounts are not created, updated, pruned, probed, or frozen by UniDesk codex-pool sync/sentinel", + }, publicExposure: publicExposureSummary(pool), localCodex: pool.localCodex, sentinel: codexPoolSentinelSummary(pool.sentinel), @@ -2020,6 +2117,27 @@ function compactTempUnschedulableStatus(block: unknown): Record }; } +function compactManualAccounts(block: unknown): Record | null { + if (!isRecord(block)) return null; + const items = recordArray(block.items).map((item) => pickSummaryFields(item, [ + "accountName", + "reason", + "exists", + "accountId", + "status", + "schedulable", + "inYamlProfiles", + "runtimeMarkedUnideskManaged", + "controlPolicy", + ])); + return { + ok: block.ok, + protectedCount: block.protectedCount, + items, + valuesPrinted: false, + }; +} + function tempUnschedulableReasonSummary(value: unknown): Record { const reason = compactText(value, 180); const statusCodeMatch = reason.match(/"status_code":(\d{3})/u) ?? reason.match(/OpenAI\s+(\d{3})/u) ?? reason.match(/\((\d{3})\)/u); @@ -2736,6 +2854,7 @@ function codexPoolValidationSummary(parsed: Record | null): Rec loadFactor: compactStatusBlock(parsed.loadFactor, ["accountName", "accountId", "expectedLoadFactor", "runtimeLoadFactor", "priority", "status", "schedulable", "ok"]), webSocketsV2: compactStatusBlock(parsed.webSocketsV2, ["accountName", "accountId", "expectedMode", "runtimeMode", "runtimeEnabled", "status", "schedulable", "ok"]), tempUnschedulable: compactTempUnschedulableStatus(parsed.tempUnschedulable), + manualAccounts: compactManualAccounts(parsed.manualAccounts), sentinel: compactSentinelStatus(parsed.sentinel), runtimeCapabilities: { ok: runtimeCapabilities.ok, @@ -4031,6 +4150,7 @@ EXPECTED_ACCOUNT_CAPACITIES = ${JSON.stringify(desiredAccountCapacityMap(pool))} EXPECTED_ACCOUNT_LOAD_FACTORS = ${JSON.stringify(desiredAccountLoadFactorMap(pool))} EXPECTED_ACCOUNT_WS_MODES = json.loads(${JSON.stringify(JSON.stringify(desiredAccountWebSocketsV2ModeMap(pool)))}) EXPECTED_ACCOUNT_TEMP_UNSCHEDULABLE = json.loads(${JSON.stringify(JSON.stringify(desiredAccountTempUnschedulableMap(pool)))}) +MANUAL_ACCOUNT_PROTECTIONS = json.loads(${JSON.stringify(JSON.stringify(pool.manualAccounts.protected))}) SENTINEL_CONFIG = json.loads(${JSON.stringify(JSON.stringify(pool.sentinel))}) MODE = "${mode}" PAYLOAD_B64 = "${encodedPayload}" @@ -4338,6 +4458,44 @@ def list_accounts(token): data = ensure_success(curl_api("GET", path, bearer=token), "list accounts") return extract_items(data) +def find_account_by_name(token, name): + path = "/api/v1/admin/accounts?page=1&page_size=20&platform=openai&type=apikey&search=" + quote(str(name)) + data = ensure_success(curl_api("GET", path, bearer=token), "find account " + str(name)) + for item in extract_items(data): + if isinstance(item, dict) and item.get("name") == name: + return item + return None + +def manual_account_protection_status(token): + items = [] + desired_names = set(EXPECTED_ACCOUNT_CAPACITIES.keys()) + for protection in MANUAL_ACCOUNT_PROTECTIONS: + if not isinstance(protection, dict): + continue + name = protection.get("accountName") + if not isinstance(name, str) or not name: + continue + account = find_account_by_name(token, name) + extra = account.get("extra") if isinstance(account, dict) and isinstance(account.get("extra"), dict) else {} + items.append({ + "accountName": name, + "reason": protection.get("reason") if isinstance(protection.get("reason"), str) else None, + "exists": isinstance(account, dict), + "accountId": account.get("id") if isinstance(account, dict) else None, + "status": account.get("status") if isinstance(account, dict) else None, + "schedulable": account.get("schedulable") if isinstance(account, dict) else None, + "inYamlProfiles": name in desired_names, + "runtimeMarkedUnideskManaged": extra.get("unidesk_managed") is True, + "controlPolicy": "manual-protected: no create/update/prune/probe/freeze", + "valuesPrinted": False, + }) + return { + "ok": True, + "protectedCount": len(items), + "items": items, + "valuesPrinted": False, + } + def list_probe_accounts(token): path = "/api/v1/admin/accounts?page=1&page_size=200&platform=openai&type=apikey&search=" + quote("unidesk-probe-") data = ensure_success(curl_api("GET", path, bearer=token), "list probe accounts") @@ -5943,6 +6101,7 @@ def run_sync(): if group_id is None: raise RuntimeError("pool group id missing after ensure") existing_accounts = list_accounts(token) + manual_account_protections = manual_account_protection_status(token) planned_account_results = planned_sentinel_account_results(profiles, existing_accounts) sentinel_quality_prepare = ensure_sentinel_state_for_sync(planned_account_results, True) if sentinel_quality_prepare.get("ok") is not True: @@ -5985,6 +6144,7 @@ def run_sync(): "processControl": {"schedulableRestore": "sentinel marker probe only; sync does not restore schedulable for existing accounts", "durableConfig": False}, "valuesPrinted": False, }, + "manualAccounts": manual_account_protections, "capacity": capacity_status, "loadFactor": load_factor_status, "webSocketsV2": ws_v2_status, @@ -6023,6 +6183,7 @@ def run_validate(): load_factor_status = account_load_factor_status(token) ws_v2_status = account_ws_v2_status(token) temp_unschedulable_status = account_temp_unschedulable_status(token) + manual_account_protections = manual_account_protection_status(token) gateway = validate_gateway(api_key) responses_smoke = validate_gateway_responses(api_key) compact_evidence = recent_compact_gateway_evidence() @@ -6050,6 +6211,7 @@ def run_validate(): "loadFactor": load_factor_status, "webSocketsV2": ws_v2_status, "tempUnschedulable": temp_unschedulable_status, + "manualAccounts": manual_account_protections, "sentinel": sentinel, "runtimeCapabilities": runtime_capabilities, "validation": {"gatewayModels": gateway, "gatewayResponses": responses_smoke, "gatewayResponsesRecent": responses_evidence, "gatewayCompactRecent": compact_evidence},