Protect manual Sub2API account from sentinel

This commit is contained in:
Codex
2026-06-14 12:17:50 +00:00
parent 3db53dba63
commit f5e69ac8d7
3 changed files with 182 additions and 2 deletions
@@ -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
@@ -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")
+164 -2
View File
@@ -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 <accountName>");
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<string>): void {
for (let index = 0; index < args.length; index += 1) {
const arg = args[index]!;
@@ -678,6 +694,9 @@ function codexPoolPlan(options?: DisclosureOptions): Record<string, unknown> {
: `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<Record<string, unknown>> {
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<string>();
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<string, unknown>, 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<string, unknown>
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<string, unknown>
};
}
function compactManualAccounts(block: unknown): Record<string, unknown> | 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<string, unknown> {
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<string, unknown> | 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},