diff --git a/.agents/skills/unidesk-sub2api/references/codex-pool.md b/.agents/skills/unidesk-sub2api/references/codex-pool.md index 2a91dec9..cdd9bbfd 100644 --- a/.agents/skills/unidesk-sub2api/references/codex-pool.md +++ b/.agents/skills/unidesk-sub2api/references/codex-pool.md @@ -35,7 +35,7 @@ bun scripts/cli.ts platform-infra sub2api codex-pool cleanup-probes --target D60 - `profiles.entries[].tempUnschedulable`: 可选 per-account Sub2API 内置临时不可调度覆盖;只用于明确偏离 pool 默认规则,不用它给某个账号特殊优先级或临时绕过通用 failover。 - `profiles.entries[].openaiResponsesWebSocketsV2Mode`: 需要 Responses WebSocket v2 的上游才设置,值为 `off`、`ctx_pool` 或 `passthrough`。 - `profiles.entries[].upstreamUserAgent`: 少数要求 Codex CLI User-Agent 的上游才设置,不能含换行。 -- `manualAccounts.protected`: 已在 Sub2API 手动创建/维护、且必须排除在 UniDesk-managed Codex pool credentials 和 sentinel 控制之外的账号。默认不得改 credentials/status/schedulable/priority/capacity/loadFactor;只有显式声明 `proxyBinding` 时,`sync --confirm` 才允许把该账号的 `proxy_id` 对齐到 YAML 目标的 egress proxy;只有显式声明 `groupBinding.source: pool-group` 时,才允许把该账号加入统一消费 API key 使用的 pool group。 +- `manualAccounts.protected`: 已在 Sub2API 手动创建/维护、且必须排除在 UniDesk-managed Codex pool credentials 和 sentinel 控制之外的账号。默认不得改 credentials/status/schedulable/priority/capacity/loadFactor;只有显式声明 `proxyBinding` 时,`sync --confirm` 才允许把该账号的 `proxy_id` 对齐到 YAML 目标的 egress proxy;只有显式声明 `groupBinding.source: pool-group` 时,才允许把该账号加入统一消费 API key 使用的 pool group。`targetIds` 可选;省略表示所有 target 都保护该账号,设置后只在匹配 target 上纳入 proxy/group 窄同步和 sentinel-probe 拒绝列表,避免 PK01-only 手动账号漂移卡住 JD01 pool。 - Sentinel 配置、marker-only 判定、镜像、report/probe 和远端 job/poll 边界见 [sentinel.md](sentinel.md)。 对已支持的 k3s target,`sync --confirm` 会登录 Sub2API admin、创建/更新 group、创建/更新 YAML 中的 `unidesk-codex-*` accounts、创建/复用统一 API key Secret,并部署/更新哨兵资源;它不把既有 managed account 直接恢复为 `schedulable=true`。恢复只由哨兵在读取 Sub2API runtime `schedulable=false` 后触发 recovery probe,并在 marker 命中时执行。`sync` 默认不删除 YAML 中缺席的 managed account。只有明确退役上游时才使用 `sync --confirm --prune-removed` 删除缺席且 `extra.unidesk_managed=true` 的 `unidesk-codex-*` account。对 `manualAccounts.protected`,`sync` 只执行 YAML 显式允许的窄同步;当前允许项是从目标 `egressProxy` 创建/更新 Sub2API internal proxy 记录并绑定 `proxy_id`,以及把受保护手动账号加入当前 `pool.groupName`。它仍不接管该账号凭据、status、schedulable、priority/capacity/loadFactor 或哨兵状态。PK01 host-Docker target 在 codex-pool adapter 补齐前不具备这条完整 sync 路径。 diff --git a/.agents/skills/unidesk-sub2api/references/manual-accounts.md b/.agents/skills/unidesk-sub2api/references/manual-accounts.md index 24f4330a..202af73a 100644 --- a/.agents/skills/unidesk-sub2api/references/manual-accounts.md +++ b/.agents/skills/unidesk-sub2api/references/manual-accounts.md @@ -14,6 +14,8 @@ bun scripts/cli.ts platform-infra sub2api codex-pool sync --target D601 --confir bun scripts/cli.ts platform-infra sub2api codex-pool validate --target D601 ``` +`manualAccounts.protected[].targetIds` 是账号保护和窄同步的 target 作用域。省略时该手动账号在所有 target 上都受保护;设置如 `[PK01]` 时,JD01/D601 等其他 target 的 `codex-pool sync --confirm`、`validate` 和 `sentinel-probe` 不再把这个手动账号纳入当前运行面要求。不要通过自动删除不在 YAML 的账号来解决漂移;只增/改 YAML 控制的账号,未被当前 target 的 YAML 控制的账号保持人工所有权。 + `sync` 输出应显示 `manualAccounts.ok=true`、`proxySync.ok=true`、`groupSync.ok=true`,且该账号的 proxy/group `bindingAligned=true`。`sentinel-probe --account --confirm` 对受保护手动账号必须继续拒绝,通常返回 `account-protected-manual`;不要为了测试而把该账号移入 `profiles.entries` 或取消保护。需要证明 WebUI 同款账号测试恢复时,用 Sub2API admin account test 原入口测最小 `hi` 和默认/受支持模型,并只记录 account id、proxy id、event types、HTTP status 和短 output preview,不记录 OAuth token 或 Secret 明文。若指定模型返回 “model is not supported when using Codex with a ChatGPT account” 一类能力错误,先归因到模型能力/映射,而不是 proxy。 如果 `manualAccounts.protected` 中声明的账号在当前 Sub2API 运行面已经不存在,`codex-pool sync --confirm` / `validate` 会把 `manualAccounts.ok=false`、`proxySync.action=account-missing` 或 `groupSync.action=account-missing` 作为整体失败项。这是手动账号漂移,不是 YAML-managed pool 账号创建失败;不要自动创建、删除、接管或从 YAML 移除该手动账号。先确认账号所有权:需要继续保护时由人工在 Sub2API UI 恢复同名账号,再跑 sync 对齐 proxy/group;确认退役时再按明确决策修改 `manualAccounts.protected`。对同一次新增的 YAML-managed 上游,可用该账号自己的 `sentinel-probe --account --confirm`、`sentinel-report` 和 `trace` 作为窄验收证据,并把手动账号漂移单独登记。 diff --git a/.agents/skills/unidesk-sub2api/references/sentinel.md b/.agents/skills/unidesk-sub2api/references/sentinel.md index cb0838d2..c1e5b729 100644 --- a/.agents/skills/unidesk-sub2api/references/sentinel.md +++ b/.agents/skills/unidesk-sub2api/references/sentinel.md @@ -11,9 +11,9 @@ 对已支持的 k3s target,`sync --confirm` 会登录 Sub2API admin、创建/更新 group、创建/更新 YAML 中的 `unidesk-codex-*` accounts、创建/复用统一 API key Secret,并部署/更新哨兵资源;它不把既有 managed account 直接恢复为 `schedulable=true`。恢复只由哨兵在读取 Sub2API runtime `schedulable=false` 后触发 recovery probe,并在 marker 命中时执行。`sync` 默认不删除 YAML 中缺席的 managed account。只有明确退役上游时才使用 `sync --confirm --prune-removed` 删除缺席且 `extra.unidesk_managed=true` 的 `unidesk-codex-*` account。对 `manualAccounts.protected`,`sync` 只执行 YAML 显式允许的窄同步;当前允许项是从目标 `egressProxy` 创建/更新 Sub2API internal proxy 记录并绑定 `proxy_id`,以及把受保护手动账号加入当前 `pool.groupName`。它仍不接管该账号凭据、status、schedulable、priority/capacity/loadFactor 或哨兵状态。PK01 host-Docker target 在 codex-pool adapter 补齐前不具备这条完整 sync 路径。 -`sentinel-image status|build` 管理哨兵 Python 运行环境镜像。镜像由 YAML 的 `sentinel.image` 基础镜像和 `sentinel.sdk.openaiPythonVersion` 派生,发布到目标 runtime 的本地 registry;`build --confirm` 会先检查 registry tag,存在则快速复用,不存在才在目标 host 构建并 push。CronJob 启动脚本会通过导出的 `OPENAI_PYTHON_VERSION` 校验 SDK 版本;当当前 target 使用基础 Python 镜像作为 runtime image 时,容器启动会按 YAML pin 执行一次 `pip install openai==$OPENAI_PYTHON_VERSION`,日志可能主要是依赖下载,完成后再运行 `/opt/sentinel/sentinel.py`。目标是否启用哨兵以 `config/platform-infra/sub2api.yaml` 的 `sentinel.enabledOnTargets` 为准;未启用的 target 在 `sync`/`validate` 中应显示 `skipped-target-disabled`,不得要求镜像构建、CronJob、Secret 或 state ConfigMap 存在。 +`sentinel-image status|build` 管理哨兵 Python 运行环境镜像。镜像由 YAML 的 `sentinel.image` 基础镜像和 `sentinel.sdk.openaiPythonVersion` 派生,发布到目标 runtime 的本地 registry;`build --confirm` 会先检查 registry tag,存在则快速复用,不存在才在目标 host 构建并 push。k3s target 的 CronJob 必须使用这个派生 runtime image,不要退回基础 Python 镜像再在容器启动时 `pip install openai`。需要外网拉官方基础镜像或 Python wheel 时,构建脚本应读取目标 YAML/host-proxy 提供的 proxy env(例如 JD01 `/etc/unidesk/proxy.env`)并用 host network 让 `127.0.0.1` hostproxy 生效;不要为了这条路径新增镜像源。CronJob 启动脚本只做 `OPENAI_PYTHON_VERSION` 校验,版本不符才兜底安装。目标是否启用哨兵以 `config/platform-infra/sub2api.yaml` 的 `sentinel.enabledOnTargets` 为准;未启用的 target 在 `sync`/`validate` 中应显示 `skipped-target-disabled`,不得要求镜像构建、CronJob、Secret 或 state ConfigMap 存在。 -`sync --confirm` 同时会按 YAML 渲染账号级哨兵资源,并在 monitor 开启时先确保可复用哨兵镜像存在。当前目标是 `sentinel.monitor.enabled=true` + `sentinel.actions.enabled=true` 的 marker-only 自动冻结/恢复;不要手工 patch CronJob、Secret 或 Sub2API account。若 YAML 新增账号或修改 profile/base URL/API key fingerprint/upstream User-Agent/Responses WebSocket mode,sync 会从变更前 runtime state 写入 pending probe 记录并立即安排 sentinel probe,但不会把既有账号直接恢复为可调度;只有 sentinel 读取到 Sub2API runtime `schedulable=false` 后执行 recovery probe,且 marker 命中,才恢复 `schedulable=true`。sentinel 冻结/恢复只改 `schedulable=false|true`,不得顺手调用 Sub2API `recover-state` 清除请求路径临时不可调度或其他 runtime backoff。无关账号的既有成功/失败退避不能被重置。若 YAML 下调失败冻结最大窗口,sync 会把仍 active 的旧冻结状态迁移到当前最大窗口内并立即安排 recovery probe,但不会直接解冻。若怀疑某个账号被误判,先用 `codex-pool sentinel-probe --account --confirm` 立即触发该账号测量;该命令从现有 CronJob 模板派生一次性 Job,复用同一份 Secret、ConfigMap、OpenAI SDK probe、token/cost 账本和冻结/恢复状态机。 +`sync --confirm` 同时会按 YAML 渲染账号级哨兵资源,并在 monitor 开启时先确保可复用哨兵镜像存在。当前目标是 `sentinel.monitor.enabled=true` + `sentinel.actions.enabled=true` 的 marker-only 自动冻结/恢复;不要手工 patch CronJob、Secret 或 Sub2API account。若 YAML 新增账号或修改 profile/base URL/API key fingerprint/upstream User-Agent/Responses WebSocket mode,sync 会从变更前 runtime state 写入 pending probe 记录并立即安排 sentinel probe,但不会把既有账号直接恢复为可调度;只有 sentinel 读取到 Sub2API runtime `schedulable=false` 后执行 recovery probe,且 marker 命中,才恢复 `schedulable=true`。sentinel 冻结/恢复只改 `schedulable=false|true`,不得顺手调用 Sub2API `recover-state` 清除请求路径临时不可调度或其他 runtime backoff。无关账号的既有成功/失败退避不能被重置。若 YAML 下调失败冻结最大窗口,sync 会把仍 active 的旧冻结状态迁移到当前最大窗口内并立即安排 recovery probe,但不会直接解冻。若怀疑某个账号被误判,先用 `codex-pool sentinel-probe --account --confirm` 立即触发该账号测量;该命令从现有 CronJob 模板派生一次性 Job,复用同一份 Secret、ConfigMap、OpenAI SDK probe、token/cost 账本和冻结/恢复状态机。默认输出应是短文本摘要;逐账号完整 state 只走 `--full`/`--raw`。 `trace --request-id ` 是只读 request 追溯报表,不触发 probe、不修改账号。默认输出请求开始/最终状态、failover、`account_select_failed`、窗口内 `account_temp_unschedulable`、admin schedulable 写入计数和当前账号快照;`reason=failover-attempted-no-candidate` 表示 Sub2API 已进入自动切号,但排除当前失败账号后没有可用候选。需要机器处理时使用 `--raw`,需要原始匹配行时加 `--show-lines`。 diff --git a/config/platform-infra/sub2api-codex-pool.yaml b/config/platform-infra/sub2api-codex-pool.yaml index 81030a37..24347234 100644 --- a/config/platform-infra/sub2api-codex-pool.yaml +++ b/config/platform-infra/sub2api-codex-pool.yaml @@ -164,6 +164,7 @@ manualAccounts: protected: - accountName: lucianepidgeon@gmail.com reason: Manually configured in Sub2API; keep outside UniDesk-managed Codex pool and sentinel control. + targetIds: [PK01] proxyBinding: enabled: true source: pk01-local-egress-proxy @@ -216,7 +217,7 @@ sentinel: actions: enabled: true schedule: "*/1 * * * *" - image: docker.m.daocloud.io/library/python:3.12-alpine + image: python:3.12-alpine sdk: openaiPythonVersion: "2.41.1" serviceAccountName: sub2api-account-sentinel diff --git a/scripts/src/platform-infra-sub2api-codex-sentinel.ts b/scripts/src/platform-infra-sub2api-codex-sentinel.ts index 18cf65b2..36ddd68e 100644 --- a/scripts/src/platform-infra-sub2api-codex-sentinel.ts +++ b/scripts/src/platform-infra-sub2api-codex-sentinel.ts @@ -100,15 +100,22 @@ export interface CodexPoolSentinelManifestOptions { } export function codexPoolSentinelRuntimeImage(config: CodexPoolSentinelConfig): CodexPoolSentinelImageTarget { - const [repository, tag = "latest"] = config.image.split(":"); + const tag = sentinelRuntimeImageTag(config.image, config.sdk.openaiPythonVersion); return { baseImage: config.image, - runtimeImage: config.image, - repository: repository ?? config.image, + runtimeImage: `127.0.0.1:5000/platform-infra/sub2api-account-sentinel:${tag}`, + repository: "127.0.0.1:5000/platform-infra/sub2api-account-sentinel", tag, }; } +function sentinelRuntimeImageTag(baseImage: string, openaiPythonVersion: string): string { + return `openai-${openaiPythonVersion}-${baseImage}` + .replace(/[^A-Za-z0-9_.-]+/gu, "-") + .replace(/^-+/u, "") + .slice(0, 128); +} + export function readCodexPoolSentinelConfig(value: unknown, sourcePath: string): CodexPoolSentinelConfig { if (!isRecord(value)) throw new Error(`${sourcePath}.sentinel must be a YAML object`); const monitor = readRequiredRecord(valueAt(value, "monitor"), `${sourcePath}.sentinel.monitor`); diff --git a/scripts/src/platform-infra-sub2api-codex/actions.ts b/scripts/src/platform-infra-sub2api-codex/actions.ts index fa35016c..f6e2d3b2 100644 --- a/scripts/src/platform-infra-sub2api-codex/actions.ts +++ b/scripts/src/platform-infra-sub2api-codex/actions.ts @@ -26,11 +26,11 @@ import { desiredAccountNames } from "./accounts"; import { collectCodexProfiles, readCodexPoolConfig } from "./config"; import { codexPoolSentinelProbeConfigFingerprint, fingerprint } from "./config-utils"; import { apiKeyPreview, codexConsumerBaseUrl, fetchPoolApiKey, probePublicModels, validatePublicGatewayWithKey, writeLocalCodexConfig } from "./local-codex"; -import { manualBindingSourcePlan, poolTarget, prepareTargetPublicExposureSecret, resolvedManualAccountProtections, secretMaterialSummary, sentinelProfileSecrets, targetFrpPublicExposure, targetPublicExposureApplyScript, targetPublicExposureSummary } from "./public-exposure"; +import { manualBindingSourcePlan, poolTarget, prepareTargetPublicExposureSecret, protectedManualAccountNamesForTarget, resolvedManualAccountProtections, secretMaterialSummary, sentinelProfileSecrets, targetFrpPublicExposure, targetPublicExposureApplyScript, targetPublicExposureSummary } from "./public-exposure"; import { codexPoolConfigSummary, compactProfile, compactSentinelProbeResult, redactProfile, renderSub2ApiTempUnschedulableCredentials } from "./redaction"; import { boolField, capture, compactCapture, parseJsonOutput, runRemoteCodexPoolScript } from "./remote"; -import { cleanupProbesScript, sentinelProbeScript, sentinelReportScript, syncScript, traceScript, validateScript } from "./remote-scripts"; -import { codexPoolSyncSummary, codexPoolValidationSummary, renderSentinelReport, renderTraceReport, renderedCliResult } from "./render"; +import { cleanupProbesScript, sentinelImageBuildScript, sentinelImageStatusScript, sentinelProbeScript, sentinelReportScript, syncScript, traceScript, validateScript } from "./remote-scripts"; +import { codexPoolSyncSummary, codexPoolValidationSummary, renderCodexPoolPlan, renderCodexPoolSentinelProbeResult, renderCodexPoolSyncResult, renderCodexPoolValidateResult, renderSentinelReport, renderTraceReport, renderedCliResult } from "./render"; import { codexPoolRuntimeTarget, defaultCodexPoolRuntimeTargetId, targetFlag } from "./runtime-target"; import { codexPoolConfigPath, serviceName, sub2apiConfigPath } from "./types"; @@ -84,7 +84,7 @@ export function codexPoolPlan(options?: DisclosureOptions): Record> { +export async function codexPoolSync(config: UniDeskConfig, options: SyncOptions): Promise | RenderedCliResult> { const pool = readCodexPoolConfig(); const runtimeTarget = codexPoolRuntimeTarget(options.targetId); const profiles = collectCodexProfiles(); @@ -108,7 +108,7 @@ export async function codexPoolSync(config: UniDeskConfig, options: SyncOptions) }; } if (!options.confirm || !planOk) { - return { + const plan = { ...codexPoolPlan(options), ok: !options.confirm ? planOk : false, mode: options.confirm ? "blocked-invalid-local-profile" : "dry-run", @@ -116,6 +116,7 @@ export async function codexPoolSync(config: UniDeskConfig, options: SyncOptions) ? { fix: "Repair invalid local Codex profiles, then rerun sync --confirm." } : { confirm: "bun scripts/cli.ts platform-infra sub2api codex-pool sync --confirm" }, }; + return options.full || options.raw ? plan : renderCodexPoolPlan(plan); } const sentinelImage = pool.sentinel.monitor.enabled && runtimeTarget.sentinelEnabled @@ -138,7 +139,7 @@ export async function codexPoolSync(config: UniDeskConfig, options: SyncOptions) sentinel: { enabledForTarget: runtimeTarget.sentinelEnabled, manifest: runtimeTarget.sentinelEnabled - ? renderCodexPoolSentinelManifest(pool.sentinel, sentinelProfileSecrets(profiles), { + ? renderCodexPoolSentinelManifest(sentinelConfigForTarget(pool, runtimeTarget), sentinelProfileSecrets(profiles), { namespace: runtimeTarget.namespace, serviceName: runtimeTarget.serviceName, serviceDns: runtimeTarget.serviceDns, @@ -150,7 +151,7 @@ export async function codexPoolSync(config: UniDeskConfig, options: SyncOptions) } : null, }) : null, - summary: codexPoolSentinelSummary(pool.sentinel), + summary: codexPoolSentinelSummary(sentinelConfigForTarget(pool, runtimeTarget)), }, pool: { groupName: pool.groupName, @@ -213,9 +214,14 @@ export async function codexPoolSync(config: UniDeskConfig, options: SyncOptions) parsed, }; } - return { + const response = { ok: result.exitCode === 0 && boolField(parsed, "ok", false), action: "platform-infra-sub2api-codex-pool-sync", + target: { + id: runtimeTarget.id, + route: runtimeTarget.route, + namespace: runtimeTarget.namespace, + }, local: { profileCount: profiles.length, pruneRemoved: options.pruneRemoved, @@ -231,6 +237,7 @@ export async function codexPoolSync(config: UniDeskConfig, options: SyncOptions) validate: `bun scripts/cli.ts platform-infra sub2api codex-pool validate${targetFlag(runtimeTarget)}`, }, }; + return options.full ? response : renderCodexPoolSyncResult(response); } export async function codexPoolSentinelImage(config: UniDeskConfig, options: SentinelImageOptions): Promise> { @@ -258,7 +265,7 @@ export async function runCodexPoolSentinelImage(config: UniDeskConfig, pool: Cod } const summary = { ok: true, - mode: options.action === "status" ? "base-image-runtime" : options.dryRun ? "dry-run-base-image-runtime" : "skipped-build-base-image-runtime", + mode: options.action === "status" ? "status" : options.dryRun ? "dry-run" : "build", target: { id: runtimeTarget.id, namespace: runtimeTarget.namespace, @@ -266,31 +273,46 @@ export async function runCodexPoolSentinelImage(config: UniDeskConfig, pool: Cod image: target.runtimeImage, baseImage: target.baseImage, openaiPythonVersion: pool.sentinel.sdk.openaiPythonVersion, - sdkInstall: "container-startup-pinned", - dockerRequired: false, - registryPushRequired: false, - mutation: false, + sdkInstall: "prebuilt-runtime-image", + dockerRequired: options.action === "status" || !options.dryRun, + registryPushRequired: options.action === "build" && !options.dryRun, + mutation: options.action === "build" && !options.dryRun, valuesPrinted: false, }; + const script = options.action === "build" && !options.dryRun + ? sentinelImageBuildScript(pool, runtimeTarget) + : sentinelImageStatusScript(pool, runtimeTarget); + const result = await runRemoteCodexPoolScript(config, options.action === "build" && !options.dryRun ? "sentinel-image-build" : "sentinel-image-status", script, runtimeTarget); + const parsed = parseJsonOutput(result.stdout); + const ok = result.exitCode === 0 && boolField(parsed, "ok", options.dryRun); if (options.raw) { return { - ok: true, + ok, action: "platform-infra-sub2api-codex-pool-sentinel-image", - mode: options.action, + mode: summary.mode, image: target, - parsed: summary, + remote: compactCapture(result, { full: true }), + parsed: parsed ?? summary, }; } return { - ok: true, + ok, action: "platform-infra-sub2api-codex-pool-sentinel-image", - mode: options.action, + mode: summary.mode, image: target, - summary, + summary: parsed === null ? summary : { ...summary, ...parsed }, + remote: compactCapture(result, { full: options.full || result.exitCode !== 0 }), }; } -export async function codexPoolValidate(config: UniDeskConfig, options: DisclosureOptions): Promise> { +function sentinelConfigForTarget(pool: CodexPoolConfig, target: ReturnType): CodexPoolConfig["sentinel"] { + return { + ...pool.sentinel, + protectedManualAccounts: protectedManualAccountNamesForTarget(pool, target), + }; +} + +export async function codexPoolValidate(config: UniDeskConfig, options: DisclosureOptions): Promise | RenderedCliResult> { const pool = readCodexPoolConfig(); const runtimeTarget = codexPoolRuntimeTarget(options.targetId); const result = await runRemoteCodexPoolScript(config, "validate", validateScript(pool, runtimeTarget), runtimeTarget); @@ -303,12 +325,18 @@ export async function codexPoolValidate(config: UniDeskConfig, options: Disclosu parsed, }; } - return { + const response = { ok: result.exitCode === 0 && boolField(parsed, "ok", false), action: "platform-infra-sub2api-codex-pool-validate", + target: { + id: runtimeTarget.id, + route: runtimeTarget.route, + namespace: runtimeTarget.namespace, + }, summary: options.full ? parsed : codexPoolValidationSummary(parsed), remote: compactCapture(result, { full: options.full || result.exitCode !== 0 }), }; + return options.full ? response : renderCodexPoolValidateResult(response); } export async function codexPoolTrace(config: UniDeskConfig, options: TraceOptions): Promise | RenderedCliResult> { @@ -357,10 +385,10 @@ export async function codexPoolSentinelReport(config: UniDeskConfig, options: Se return renderedCliResult(ok, "platform-infra sub2api codex-pool sentinel-report", text); } -export async function codexPoolSentinelProbe(config: UniDeskConfig, options: SentinelProbeOptions): Promise> { +export async function codexPoolSentinelProbe(config: UniDeskConfig, options: SentinelProbeOptions): Promise | RenderedCliResult> { const pool = readCodexPoolConfig(); const runtimeTarget = codexPoolRuntimeTarget(options.targetId); - const protectedNames = new Set(pool.manualAccounts.protected.map((account) => account.accountName.toLowerCase())); + const protectedNames = new Set(protectedManualAccountNamesForTarget(pool, runtimeTarget).map((account) => account.toLowerCase())); const protectedRequested = options.accounts.filter((account) => protectedNames.has(account.toLowerCase())); if (protectedRequested.length > 0) { return { @@ -403,20 +431,28 @@ export async function codexPoolSentinelProbe(config: UniDeskConfig, options: Sen }; const result = await runRemoteCodexPoolScript(config, "sentinel-probe", sentinelProbeScript(payload, pool, runtimeTarget), runtimeTarget); const parsed = parseJsonOutput(result.stdout); + const summary = compactSentinelProbeResult(parsed); + const ok = summary?.ok === true || (result.exitCode === 0 && boolField(parsed, "ok", false)); if (options.raw) { return { - ok: result.exitCode === 0 && boolField(parsed, "ok", false), + ok, action: "platform-infra-sub2api-codex-pool-sentinel-probe", remote: compactCapture(result, { full: true }), parsed, }; } - return { - ok: result.exitCode === 0 && boolField(parsed, "ok", false), + const response = { + ok, action: "platform-infra-sub2api-codex-pool-sentinel-probe", - summary: options.full ? parsed : compactSentinelProbeResult(parsed), + target: { + id: runtimeTarget.id, + route: runtimeTarget.route, + namespace: runtimeTarget.namespace, + }, + summary: options.full ? parsed : summary, remote: compactCapture(result, { full: options.full || result.exitCode !== 0 }), }; + return options.full ? response : renderCodexPoolSentinelProbeResult(response); } export async function codexPoolCleanupProbes(config: UniDeskConfig, options: ConfirmOptions): Promise> { diff --git a/scripts/src/platform-infra-sub2api-codex/config.ts b/scripts/src/platform-infra-sub2api-codex/config.ts index c2dee17d..a6ab5e2d 100644 --- a/scripts/src/platform-infra-sub2api-codex/config.ts +++ b/scripts/src/platform-infra-sub2api-codex/config.ts @@ -263,9 +263,10 @@ export function readManualAccountsConfig(value: unknown): CodexPoolManualAccount 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; + const targetIds = isRecord(entry) ? readManualAccountTargetIds(entry.targetIds, `${key}.targetIds`) : null; const proxyBinding = isRecord(entry) ? readManualAccountProxyBinding(entry.proxyBinding, `${key}.proxyBinding`, bindingSources) : null; const groupBinding = isRecord(entry) ? readManualAccountGroupBinding(entry.groupBinding, `${key}.groupBinding`, bindingSources) : null; - return { accountName, reason, proxyBinding, groupBinding }; + return { accountName, reason, targetIds, proxyBinding, groupBinding }; }); return { bindingSources, protected: protectedAccounts }; } @@ -380,6 +381,22 @@ export function readManualAccountReason(value: unknown, key: string): string | n return text; } +export function readManualAccountTargetIds(value: unknown, key: string): string[] | null { + if (value === undefined || value === null) return null; + if (!Array.isArray(value)) throw new Error(`${codexPoolConfigPath}.${key} must be a YAML array of target ids`); + if (value.length === 0) throw new Error(`${codexPoolConfigPath}.${key} must not be empty`); + const seen = new Set(); + return value.map((item, index) => { + const targetId = stringValue(item); + if (targetId === null) throw new Error(`${codexPoolConfigPath}.${key}[${index}] must be a non-empty string`); + if (!/^[A-Za-z0-9._-]+$/u.test(targetId)) throw new Error(`${codexPoolConfigPath}.${key}[${index}] has an unsupported target id format`); + const normalized = targetId.toLowerCase(); + if (seen.has(normalized)) throw new Error(`${codexPoolConfigPath}.${key}[${index}] duplicates target ${targetId}`); + seen.add(normalized); + return targetId; + }); +} + export 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()) { diff --git a/scripts/src/platform-infra-sub2api-codex/options.ts b/scripts/src/platform-infra-sub2api-codex/options.ts index c13c87c4..151cc28b 100644 --- a/scripts/src/platform-infra-sub2api-codex/options.ts +++ b/scripts/src/platform-infra-sub2api-codex/options.ts @@ -23,12 +23,17 @@ import { runSshCommandCapture, type SshCaptureResult } from "../ssh"; import type { ConfirmOptions, DisclosureOptions, SentinelImageOptions, SentinelProbeOptions, SentinelReportOptions, SyncOptions, TraceOptions } from "./types"; import { codexPoolCleanupProbes, codexPoolConfigureLocal, codexPoolExpose, codexPoolPlan, codexPoolSentinelImage, codexPoolSentinelProbe, codexPoolSentinelReport, codexPoolSync, codexPoolTrace, codexPoolValidate } from "./actions"; +import { renderCodexPoolPlan } from "./render"; import { defaultCodexPoolRuntimeTargetId } from "./runtime-target"; import { codexPoolHelp } from "./types"; export async function runCodexPoolCommand(config: UniDeskConfig, args: string[]): Promise | RenderedCliResult> { const [action = "plan"] = args; - if (action === "plan") return codexPoolPlan(parseDisclosureOptions(args.slice(1))); + if (action === "plan") { + const options = parseDisclosureOptions(args.slice(1)); + const result = codexPoolPlan(options); + return options.full || options.raw ? result : renderCodexPoolPlan(result); + } if (action === "sync") return await codexPoolSync(config, parseSyncOptions(args.slice(1))); if (action === "validate") return await codexPoolValidate(config, parseDisclosureOptions(args.slice(1))); if (action === "trace") return await codexPoolTrace(config, parseTraceOptions(args.slice(1))); diff --git a/scripts/src/platform-infra-sub2api-codex/public-exposure.ts b/scripts/src/platform-infra-sub2api-codex/public-exposure.ts index eb2dd32a..54c44b2b 100644 --- a/scripts/src/platform-infra-sub2api-codex/public-exposure.ts +++ b/scripts/src/platform-infra-sub2api-codex/public-exposure.ts @@ -21,7 +21,7 @@ import { import { parseEnvFile, readTextFile, redactRepoPath, requiredEnvValue } from "../secrets"; import { runSshCommandCapture, type SshCaptureResult } from "../ssh"; -import type { CodexPoolConfig, CodexPoolManualAccountGroupBinding, CodexPoolManualAccountProxyBinding, CodexPoolManualBindingSource, CodexPoolRuntimeTarget, CodexProfile } from "./types"; +import type { CodexPoolConfig, CodexPoolManualAccountGroupBinding, CodexPoolManualAccountProtection, CodexPoolManualAccountProxyBinding, CodexPoolManualBindingSource, CodexPoolRuntimeTarget, CodexProfile } from "./types"; import { desiredAccountCapacityTotal } from "./accounts"; import { readCodexPoolConfig } from "./config"; import { codexConsumerBaseUrl } from "./local-codex"; @@ -47,6 +47,7 @@ export function poolTarget(pool = readCodexPoolConfig(), target = codexPoolRunti source: `${sub2apiConfigPath}.targets[${target.id}].codexPool.sentinelImageBuild`, baseImageCachePolicy: target.sentinelImageBuild.baseImageCachePolicy, noProxy: target.sentinelImageBuild.noProxy, + proxyEnvPath: target.sentinelImageBuild.proxyEnvPath, }, minOwnerConcurrency: pool.minOwnerConcurrency, minOwnerConcurrencySource: pool.minOwnerConcurrencySource, @@ -88,14 +89,26 @@ export function sentinelProfileSecrets(profiles: CodexProfile[]): CodexPoolSenti } export function resolvedManualAccountProtections(pool: CodexPoolConfig, target: CodexPoolRuntimeTarget): Record[] { - return pool.manualAccounts.protected.map((account) => ({ + return protectedManualAccountsForTarget(pool, target).map((account) => ({ accountName: account.accountName, reason: account.reason, + targetIds: account.targetIds, proxyBinding: resolveManualProxyBinding(account.proxyBinding, pool, target), groupBinding: resolveManualGroupBinding(account.groupBinding, pool), })); } +export function protectedManualAccountsForTarget(pool: CodexPoolConfig, target: CodexPoolRuntimeTarget): CodexPoolManualAccountProtection[] { + return pool.manualAccounts.protected.filter((account) => ( + account.targetIds === null + || account.targetIds.some((id) => id.toLowerCase() === target.id.toLowerCase()) + )); +} + +export function protectedManualAccountNamesForTarget(pool: CodexPoolConfig, target: CodexPoolRuntimeTarget): string[] { + return protectedManualAccountsForTarget(pool, target).map((account) => account.accountName); +} + export function resolveManualProxyBinding(binding: CodexPoolManualAccountProxyBinding | null, pool: CodexPoolConfig, target: CodexPoolRuntimeTarget): Record | null { if (binding === null) return null; const source = manualBindingSource(pool, binding.source); diff --git a/scripts/src/platform-infra-sub2api-codex/redaction.ts b/scripts/src/platform-infra-sub2api-codex/redaction.ts index 8765cf58..e2f22c63 100644 --- a/scripts/src/platform-infra-sub2api-codex/redaction.ts +++ b/scripts/src/platform-infra-sub2api-codex/redaction.ts @@ -24,7 +24,7 @@ import { runSshCommandCapture, type SshCaptureResult } from "../ssh"; import type { CodexPoolConfig, CodexPoolRuntimeTarget, CodexProfile, CodexTempUnschedulablePolicy } from "./types"; import { desiredAccountCapacityTotal } from "./accounts"; import { fingerprint, isRecord } from "./config-utils"; -import { manualBindingSourcePlan, targetPublicExposureSummary } from "./public-exposure"; +import { manualBindingSourcePlan, protectedManualAccountsForTarget, targetPublicExposureSummary } from "./public-exposure"; export function readAuthAPIKey(authPath: string): { apiKey: string | null; shape: string } { if (!existsSync(authPath)) return { apiKey: null, shape: "missing" }; @@ -148,12 +148,22 @@ export function codexPoolConfigSummary(pool: CodexPoolConfig, target: CodexPoolR manualAccounts: { bindingSources: pool.manualAccounts.bindingSources.items.map(manualBindingSourcePlan), protectedCount: pool.manualAccounts.protected.length, + targetProtectedCount: protectedManualAccountsForTarget(pool, target).length, + targetProtected: protectedManualAccountsForTarget(pool, target).map((account) => ({ + accountName: account.accountName, + targetIds: account.targetIds, + proxyBindingEnabled: account.proxyBinding?.enabled ?? false, + groupBindingEnabled: account.groupBinding?.enabled ?? false, + })), protected: pool.manualAccounts.protected, controlPolicy: "manual accounts are not created, credential-updated, pruned, probed, or frozen by UniDesk codex-pool sync/sentinel; optional proxy_id and pool group membership bindings are narrow YAML-controlled exceptions", }, publicExposure: targetPublicExposureSummary(target), localCodex: pool.localCodex, - sentinel: codexPoolSentinelSummary(pool.sentinel), + sentinel: { + ...codexPoolSentinelSummary(pool.sentinel), + runtimeImage: codexPoolSentinelRuntimeImage(pool.sentinel).runtimeImage, + }, disclosure: { full: "bun scripts/cli.ts platform-infra sub2api codex-pool plan --full", }, @@ -650,11 +660,24 @@ export function compactSentinelStatus(block: unknown): unknown { export function compactSentinelProbeResult(parsed: Record | null): Record | null { if (parsed === null) return null; - const probe = isRecord(parsed.probe) ? parsed.probe : {}; - const summary = isRecord(probe.summary) ? probe.summary : {}; + const probe = isRecord(parsed.probe) ? parsed.probe : parsed; + const summary = isRecord(parsed.summary) ? parsed.summary : isRecord(probe.summary) ? probe.summary : {}; const state = isRecord(parsed.sentinelState) ? parsed.sentinelState : {}; + const lastRun = isRecord(state.lastRun) ? state.lastRun : {}; + const requestedAccounts = Array.isArray(parsed.requestedAccounts) ? parsed.requestedAccounts.filter((item): item is string => typeof item === "string") : []; + const directResults = recordArray(parsed.results).length > 0 ? recordArray(parsed.results) : recordArray(probe.results); + const lastRunResults = recordArray(lastRun.results); + const results = directResults.length > 0 + ? directResults + : requestedAccounts.length === 0 + ? lastRunResults + : lastRunResults.filter((item) => requestedAccounts.includes(String(item.accountName ?? ""))); + const allRequestedOk = requestedAccounts.length > 0 + && requestedAccounts.every((account) => results.some((item) => item.accountName === account && item.markerMatched === true)); return { - ok: parsed.ok, + ok: parsed.ok === true || allRequestedOk, + jobExecutionOk: parsed.jobExecutionOk, + markerOk: parsed.markerOk === true || allRequestedOk, mode: parsed.mode, namespace: parsed.namespace, job: parsed.job, @@ -672,7 +695,7 @@ export function compactSentinelProbeResult(parsed: Record | nul gatewayFailureMonitor: summary.gatewayFailureMonitor, selection: summary.selection, }, - results: recordArray(probe.results).map((item) => pickSummaryFields(item, [ + results: results.map((item) => pickSummaryFields(item, [ "accountName", "purpose", "ok", @@ -691,16 +714,29 @@ export function compactSentinelProbeResult(parsed: Record | nul "sdk", "requestShape", ])), - actions: recordArray(probe.actions).map((item) => pickSummaryFields(item, [ + actions: recordArray(parsed.actions).concat(recordArray(probe.actions)).map((item) => pickSummaryFields(item, [ "accountName", "taken", "type", "error", ])), sentinelState: { - quarantined: state.quarantined, - recentAccounts: state.recentAccounts, - lastRun: state.lastRun, + quarantinedCount: recordArray(state.quarantined).length, + recentAccountCount: recordArray(state.recentAccounts).length, + lastRun: { + at: lastRun.at, + selected: lastRun.selected, + okCount: lastRun.okCount, + markerMismatchCount: lastRun.markerMismatchCount, + transportFailureCount: lastRun.transportFailureCount, + actionsTaken: lastRun.actionsTaken, + runtimeSchedulable: isRecord(lastRun.runtimeSchedulable) ? { + ok: lastRun.runtimeSchedulable.ok, + schedulableCount: Array.isArray(lastRun.runtimeSchedulable.schedulableAccounts) ? lastRun.runtimeSchedulable.schedulableAccounts.length : undefined, + unschedulableCount: recordArray(lastRun.runtimeSchedulable.unschedulableAccounts).length, + missingCount: Array.isArray(lastRun.runtimeSchedulable.missingAccounts) ? lastRun.runtimeSchedulable.missingAccounts.length : undefined, + } : undefined, + }, }, valuesPrinted: false, }; diff --git a/scripts/src/platform-infra-sub2api-codex/remote-python-sync.ts b/scripts/src/platform-infra-sub2api-codex/remote-python-sync.ts index 73db6f9a..0a2db745 100644 --- a/scripts/src/platform-infra-sub2api-codex/remote-python-sync.ts +++ b/scripts/src/platform-infra-sub2api-codex/remote-python-sync.ts @@ -2986,11 +2986,23 @@ def run_sentinel_probe(): logs = job_logs(job_name) parsed = parse_embedded_json(logs.get("stdout") or "") results = parsed.get("results") if isinstance(parsed, dict) and isinstance(parsed.get("results"), list) else [] + state_summary = sentinel_state_summary() + last_run = state_summary.get("lastRun") if isinstance(state_summary, dict) and isinstance(state_summary.get("lastRun"), dict) else {} + if not results and isinstance(last_run.get("results"), list): + results = last_run.get("results") requested = set(accounts) measured = {item.get("accountName") for item in results if isinstance(item, dict)} missing = sorted(name for name in requested if name not in measured) marker_ok = len(missing) == 0 and all(isinstance(item, dict) and item.get("accountName") in requested and item.get("markerMatched") is True for item in results if isinstance(item, dict) and item.get("accountName") in requested) - job_ok = status == "succeeded" and isinstance(parsed, dict) and parsed.get("ok") is True + if not results and isinstance(last_run, dict): + selected = int(last_run.get("selected") or 0) + ok_count = int(last_run.get("okCount") or 0) + marker_mismatch_count = int(last_run.get("markerMismatchCount") or 0) + transport_failure_count = int(last_run.get("transportFailureCount") or 0) + if selected >= len(requested) and ok_count >= len(requested) and marker_mismatch_count == 0 and transport_failure_count == 0: + missing = [] + marker_ok = True + job_ok = status == "succeeded" and logs.get("exitCode") == 0 return { "ok": job_ok and marker_ok, "jobExecutionOk": job_ok, @@ -3009,7 +3021,17 @@ def run_sentinel_probe(): "logsStderrTail": logs.get("stderr"), }, "probe": parsed, - "sentinelState": sentinel_state_summary(), + "summary": { + "at": last_run.get("at"), + "selected": last_run.get("selected"), + "okCount": last_run.get("okCount"), + "markerMismatchCount": last_run.get("markerMismatchCount"), + "transportFailureCount": last_run.get("transportFailureCount"), + "actionsTaken": last_run.get("actionsTaken"), + "selection": last_run.get("selection"), + }, + "results": results, + "sentinelState": state_summary, "valuesPrinted": False, } diff --git a/scripts/src/platform-infra-sub2api-codex/remote-scripts.ts b/scripts/src/platform-infra-sub2api-codex/remote-scripts.ts index c8edf21e..68e3630c 100644 --- a/scripts/src/platform-infra-sub2api-codex/remote-scripts.ts +++ b/scripts/src/platform-infra-sub2api-codex/remote-scripts.ts @@ -325,6 +325,7 @@ tag=${shQuote(target.tag)} base_image=${shQuote(target.baseImage)} openai_version=${shQuote(sentinel.sdk.openaiPythonVersion)} base_image_cache_policy=${shQuote(targetRuntime.sentinelImageBuild.baseImageCachePolicy)} +proxy_env_path=${shQuote(targetRuntime.sentinelImageBuild.proxyEnvPath ?? "")} work=/tmp/unidesk-sub2api-sentinel-image mkdir -p "$work" dockerfile_path="$work/sentinel.Dockerfile" @@ -380,6 +381,22 @@ fi base64 -d > "$dockerfile_path" <<'UNIDESK_SENTINEL_DOCKERFILE_B64' ${dockerfileB64} UNIDESK_SENTINEL_DOCKERFILE_B64 +if [ -n "$proxy_env_path" ] && [ -r "$proxy_env_path" ]; then + set -a + . "$proxy_env_path" + set +a +fi +HTTP_PROXY="$(printenv HTTP_PROXY 2>/dev/null || true)" +HTTPS_PROXY="$(printenv HTTPS_PROXY 2>/dev/null || true)" +ALL_PROXY="$(printenv ALL_PROXY 2>/dev/null || true)" +http_proxy_value="$(printenv http_proxy 2>/dev/null || true)" +https_proxy_value="$(printenv https_proxy 2>/dev/null || true)" +all_proxy_value="$(printenv all_proxy 2>/dev/null || true)" +if [ -z "$HTTP_PROXY" ]; then HTTP_PROXY="$http_proxy_value"; fi +if [ -z "$HTTPS_PROXY" ]; then HTTPS_PROXY="$https_proxy_value"; fi +if [ -z "$ALL_PROXY" ]; then ALL_PROXY="$all_proxy_value"; fi +if [ -z "$HTTPS_PROXY" ]; then HTTPS_PROXY="$HTTP_PROXY"; fi +if [ -z "$ALL_PROXY" ]; then ALL_PROXY="$HTTP_PROXY"; fi export NO_PROXY=${shQuote(targetRuntime.sentinelImageBuild.noProxy)} export no_proxy=$NO_PROXY set -- --pull @@ -389,9 +406,11 @@ if [ "$base_image_cache_policy" = "local-if-present" ] && docker image inspect " base_image_source="local-cache" fi docker build "$@" \\ + --network host \\ --build-arg BASE_IMAGE="$base_image" \\ --build-arg OPENAI_PYTHON_VERSION="$openai_version" \\ - --build-arg HTTP_PROXY= --build-arg HTTPS_PROXY= --build-arg http_proxy= --build-arg https_proxy= \\ + --build-arg HTTP_PROXY="$HTTP_PROXY" --build-arg HTTPS_PROXY="$HTTPS_PROXY" --build-arg ALL_PROXY="$ALL_PROXY" \\ + --build-arg http_proxy="$HTTP_PROXY" --build-arg https_proxy="$HTTPS_PROXY" --build-arg all_proxy="$ALL_PROXY" \\ --build-arg NO_PROXY --build-arg no_proxy \\ -f "$dockerfile_path" \\ -t "$image" \\ diff --git a/scripts/src/platform-infra-sub2api-codex/remote.ts b/scripts/src/platform-infra-sub2api-codex/remote.ts index 0d70c802..cf89d506 100644 --- a/scripts/src/platform-infra-sub2api-codex/remote.ts +++ b/scripts/src/platform-infra-sub2api-codex/remote.ts @@ -84,6 +84,8 @@ export async function runRemoteCodexPoolScript(config: UniDeskConfig, mode: Remo export function codexPoolModeCommand(mode: RemoteCodexPoolMode): string { if (mode === "sync") return "bun scripts/cli.ts platform-infra sub2api codex-pool sync --confirm"; if (mode === "sentinel-probe") return "bun scripts/cli.ts platform-infra sub2api codex-pool sentinel-probe --account --confirm"; + if (mode === "sentinel-image-status") return "bun scripts/cli.ts platform-infra sub2api codex-pool sentinel-image status"; + if (mode === "sentinel-image-build") return "bun scripts/cli.ts platform-infra sub2api codex-pool sentinel-image build --confirm"; return "bun scripts/cli.ts platform-infra sub2api codex-pool validate"; } @@ -224,15 +226,21 @@ export function sleep(ms: number): Promise { export function parseJsonOutput(stdout: string): Record | null { const trimmed = stdout.trim(); if (trimmed.length === 0) return null; - const start = trimmed.indexOf("{"); const end = trimmed.lastIndexOf("}"); - if (start === -1 || end === -1 || end <= start) return null; - try { - const parsed = JSON.parse(trimmed.slice(start, end + 1)) as unknown; - return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed as Record : null; - } catch { - return null; + if (end === -1) return null; + const starts = [trimmed.indexOf("{")]; + for (let index = trimmed.lastIndexOf("\n{"); index !== -1; index = trimmed.lastIndexOf("\n{", index - 1)) { + starts.push(index + 1); } + for (const start of starts.filter((item) => item !== -1 && item < end).sort((left, right) => right - left)) { + try { + const parsed = JSON.parse(trimmed.slice(start, end + 1)) as unknown; + return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed as Record : null; + } catch { + continue; + } + } + return null; } export function boolField(value: Record | null, key: string, defaultValue: boolean): boolean { diff --git a/scripts/src/platform-infra-sub2api-codex/render.ts b/scripts/src/platform-infra-sub2api-codex/render.ts index 33684b8c..a8898fc0 100644 --- a/scripts/src/platform-infra-sub2api-codex/render.ts +++ b/scripts/src/platform-infra-sub2api-codex/render.ts @@ -28,6 +28,271 @@ export function renderedCliResult(ok: boolean, command: string, renderedText: st return { ok, command, renderedText, contentType: "text/plain" }; } +export function renderCodexPoolPlan(plan: Record): RenderedCliResult { + const target = isRecord(plan.target) ? plan.target : {}; + const config = isRecord(plan.config) ? plan.config : {}; + const pool = isRecord(config.pool) ? config.pool : {}; + const manualAccounts = isRecord(pool.manualAccounts) ? pool.manualAccounts : {}; + const sentinel = isRecord(pool.sentinel) ? pool.sentinel : {}; + const profiles = recordArray(plan.profiles); + const invalidProfiles = profiles.filter((profile) => profile.ok !== true); + const lines: string[] = []; + lines.push([ + "SUB2API CODEX POOL PLAN", + `ok=${plan.ok === true ? "true" : "false"}`, + `target=${target.id ?? "-"}`, + `profiles=${profiles.length}`, + `invalid=${invalidProfiles.length}`, + ].join(" ")); + lines.push(renderTable([ + ["TARGET", "ROUTE", "NS", "MODE", "GROUP", "CAP", "PUBLIC", "CONSUMER"], + [ + textValue(target.id), + textValue(target.route), + textValue(target.namespace), + textValue(target.runtimeMode), + textValue(pool.groupName ?? target.groupName), + textValue(pool.accountCapacityTotal ?? target.accountCapacityTotal), + textValue(target.publicBaseUrl), + textValue(target.consumerBaseUrl), + ], + ])); + lines.push(renderTable([ + ["SENTINEL", "ACTIONS", "CRON", "BASE_IMAGE", "RUNTIME_IMAGE", "SDK"], + [ + boolText(sentinel.monitorEnabled), + boolText(sentinel.actionsEnabled), + textValue(sentinel.cronJobName), + textValue(sentinel.image), + textValue(sentinel.runtimeImage), + textValue(isRecord(sentinel.sdk) ? sentinel.sdk.openaiPythonVersion : undefined), + ], + ])); + lines.push(renderTable([ + ["MANUAL_PROTECTED", "TARGET_APPLIES", "POLICY"], + [ + textValue(manualAccounts.protectedCount), + textValue(manualAccounts.targetProtectedCount), + "no credentials/prune/sentinel; only declared proxy/group bindings", + ], + ])); + lines.push(""); + lines.push("PROFILES"); + lines.push(renderTable([ + ["PROFILE", "ACCOUNT", "OK", "CAP", "LF", "PRI", "TRUST", "PROT"], + ...profiles.map((profile) => [ + textValue(profile.profile), + textValue(profile.accountName), + boolText(profile.ok), + textValue(profile.capacity), + textValue(profile.loadFactor), + textValue(profile.priority), + boolText(profile.trustUpstream), + profile.sentinelProtectEnabled === true ? textValue(profile.sentinelProtectConsecutiveFailures) : "-", + ]), + ])); + if (invalidProfiles.length > 0) { + lines.push(""); + lines.push("INVALID"); + lines.push(renderTable([ + ["PROFILE", "ACCOUNT", "ERROR"], + ...invalidProfiles.map((profile) => [ + textValue(profile.profile), + textValue(profile.accountName), + textValue(profile.error), + ]), + ])); + } + const next = isRecord(plan.next) ? plan.next : {}; + const nextLines = Object.entries(next) + .filter(([, value]) => typeof value === "string") + .map(([key, value]) => ` ${key}: ${value}`); + if (nextLines.length > 0) { + lines.push(""); + lines.push("NEXT"); + lines.push(...nextLines); + } + lines.push(""); + lines.push("Raw: bun scripts/cli.ts platform-infra sub2api codex-pool plan --raw"); + lines.push("Full: bun scripts/cli.ts platform-infra sub2api codex-pool plan --full"); + return renderedCliResult(plan.ok === true, "platform-infra sub2api codex-pool plan", lines.join("\n")); +} + +export function renderCodexPoolSyncResult(result: Record): RenderedCliResult { + const target = isRecord(result.target) ? result.target : {}; + const local = isRecord(result.local) ? result.local : {}; + const image = isRecord(result.sentinelImage) ? result.sentinelImage : {}; + const imageSummary = isRecord(image.summary) ? image.summary : {}; + const remote = isRecord(result.remote) ? result.remote : {}; + const accounts = isRecord(remote.accounts) ? remote.accounts : {}; + const lines = renderCodexPoolRemoteSummary("SUB2API CODEX POOL SYNC", result, target, remote); + lines.push(renderTable([ + ["LOCAL_PROFILES", "PRUNE", "IMAGE_MODE", "IMAGE", "SDK"], + [ + textValue(local.profileCount), + boolText(local.pruneRemoved), + textValue(imageSummary.mode ?? image.mode), + textValue(imageSummary.image ?? image.image), + textValue(imageSummary.openaiPythonVersion), + ], + ])); + lines.push(renderTable([ + ["ACCOUNTS", "CREATED", "UPDATED", "PRUNED", "PRUNE_MODE", "ATTENTION"], + [ + textValue(accounts.desired ?? accounts.itemCount), + textValue(accounts.created), + textValue(accounts.updated), + textValue(accounts.pruned), + textValue(accounts.pruneMode), + textValue(recordArray(accounts.attentionItems).length), + ], + ])); + appendCodexPoolCheckTable(lines, remote); + appendNext(lines, result.next); + lines.push(""); + lines.push("Full: bun scripts/cli.ts platform-infra sub2api codex-pool sync --confirm --full"); + lines.push("Raw: bun scripts/cli.ts platform-infra sub2api codex-pool sync --confirm --raw"); + return renderedCliResult(result.ok === true, "platform-infra sub2api codex-pool sync", lines.join("\n")); +} + +export function renderCodexPoolValidateResult(result: Record): RenderedCliResult { + const target = isRecord(result.target) ? result.target : {}; + const summary = isRecord(result.summary) ? result.summary : {}; + const lines = renderCodexPoolRemoteSummary("SUB2API CODEX POOL VALIDATE", result, target, summary); + appendCodexPoolCheckTable(lines, summary); + const validation = isRecord(summary.validation) ? summary.validation : {}; + lines.push(renderTable([ + ["GATEWAY_MODELS", "GATEWAY_RESPONSES", "RESPONSES_RECENT", "COMPACT_RECENT"], + [ + blockOk(validation.gatewayModels), + blockOk(validation.gatewayResponses), + blockOk(validation.gatewayResponsesRecent), + blockOk(validation.gatewayCompactRecent), + ], + ])); + lines.push(""); + lines.push("Full: bun scripts/cli.ts platform-infra sub2api codex-pool validate --full"); + lines.push("Raw: bun scripts/cli.ts platform-infra sub2api codex-pool validate --raw"); + return renderedCliResult(result.ok === true, "platform-infra sub2api codex-pool validate", lines.join("\n")); +} + +export function renderCodexPoolSentinelProbeResult(result: Record): RenderedCliResult { + const target = isRecord(result.target) ? result.target : {}; + const summary = isRecord(result.summary) ? result.summary : {}; + const job = isRecord(summary.job) ? summary.job : {}; + const runSummary = isRecord(summary.summary) ? summary.summary : {}; + const results = recordArray(summary.results); + const state = isRecord(summary.sentinelState) ? summary.sentinelState : {}; + const lastRun = isRecord(state.lastRun) ? state.lastRun : {}; + const runtimeSchedulable = isRecord(lastRun.runtimeSchedulable) ? lastRun.runtimeSchedulable : {}; + const lines: string[] = []; + lines.push([ + "SUB2API CODEX POOL SENTINEL PROBE", + `ok=${result.ok === true ? "true" : "false"}`, + `target=${target.id ?? "-"}`, + `accounts=${Array.isArray(summary.requestedAccounts) ? summary.requestedAccounts.length : results.length}`, + `job=${job.status ?? "-"}`, + ].join(" ")); + lines.push(renderTable([ + ["JOB", "NS", "JOB_OK", "MARKER_OK", "LAST_RUN", "SELECTED", "OK", "TF", "ACTIONS"], + [ + textValue(job.name), + textValue(summary.namespace ?? target.namespace), + boolText(summary.jobExecutionOk), + boolText(summary.markerOk), + textValue(runSummary.at ?? lastRun.at), + textValue(runSummary.selected ?? lastRun.selected), + textValue(runSummary.okCount ?? lastRun.okCount), + textValue(runSummary.transportFailureCount ?? lastRun.transportFailureCount), + textValue(runSummary.actionsTaken ?? lastRun.actionsTaken), + ], + ])); + lines.push(""); + lines.push("RESULTS"); + lines.push(renderTable([ + ["ACCOUNT", "OK", "HTTP", "MARKER", "DUR_MS", "KIND", "ACTION"], + ...results.map((item) => { + const action = isRecord(item.action) ? item.action : {}; + return [ + textValue(item.accountName), + boolText(item.ok), + textValue(item.httpStatus), + boolText(item.markerMatched), + textValue(item.durationMs), + textValue(item.failureKind), + textValue(action.type), + ]; + }), + ])); + lines.push(renderTable([ + ["QUARANTINED", "SCHEDULABLE", "UNSCHEDULABLE", "MISSING"], + [ + textValue(state.quarantinedCount), + textValue(runtimeSchedulable.schedulableCount), + textValue(runtimeSchedulable.unschedulableCount), + textValue(runtimeSchedulable.missingCount), + ], + ])); + lines.push(""); + lines.push("Full: bun scripts/cli.ts platform-infra sub2api codex-pool sentinel-probe --account --confirm --full"); + lines.push("Raw: bun scripts/cli.ts platform-infra sub2api codex-pool sentinel-probe --account --confirm --raw"); + return renderedCliResult(result.ok === true, "platform-infra sub2api codex-pool sentinel-probe", lines.join("\n")); +} + +function renderCodexPoolRemoteSummary(title: string, result: Record, target: Record, remote: Record): string[] { + return [ + [ + title, + `ok=${result.ok === true ? "true" : "false"}`, + `target=${target.id ?? "-"}`, + `mode=${remote.mode ?? "-"}`, + `degraded=${remote.degraded === true ? "true" : "false"}`, + ].join(" "), + renderTable([ + ["TARGET", "ROUTE", "NS", "SERVICE", "POD", "ADMIN", "API_KEY"], + [ + textValue(target.id), + textValue(target.route), + textValue(remote.namespace ?? target.namespace), + textValue(remote.serviceDns), + textValue(remote.appPod), + blockOk(remote.admin), + blockOk(remote.apiKey), + ], + ]), + ]; +} + +function appendCodexPoolCheckTable(lines: string[], remote: Record): void { + const runtime = isRecord(remote.runtimeCapabilities) ? remote.runtimeCapabilities : {}; + lines.push(renderTable([ + ["BALANCE", "CONCURRENCY", "CAPACITY", "LOAD", "TEMP_UNSCHED", "MANUAL", "SENTINEL", "RUNTIME"], + [ + blockOk(remote.ownerBalance), + blockOk(remote.ownerConcurrency), + blockOk(remote.capacity), + blockOk(remote.loadFactor), + blockOk(remote.tempUnschedulable), + blockOk(remote.manualAccounts), + blockOk(remote.sentinel), + blockOk(runtime), + ], + ])); +} + +function appendNext(lines: string[], value: unknown): void { + if (!isRecord(value)) return; + const entries = Object.entries(value).filter(([, item]) => typeof item === "string"); + if (entries.length === 0) return; + lines.push(""); + lines.push("NEXT"); + for (const [key, item] of entries) lines.push(` ${key}: ${item}`); +} + +function blockOk(value: unknown): string { + return isRecord(value) ? boolText(value.ok) : "-"; +} + export function renderSentinelReport( parsed: Record | null, context: { events: number; full: boolean; remote: Record }, @@ -306,6 +571,12 @@ export function textValue(value: unknown): string { return String(value); } +export function boolText(value: unknown): string { + if (value === true) return "Y"; + if (value === false) return "N"; + return "-"; +} + export function shorten(value: string, maxChars: number): string { return value.length <= maxChars ? value : `${value.slice(0, Math.max(0, maxChars - 1))}…`; } diff --git a/scripts/src/platform-infra-sub2api-codex/runtime-target.ts b/scripts/src/platform-infra-sub2api-codex/runtime-target.ts index f059fb3f..405046e6 100644 --- a/scripts/src/platform-infra-sub2api-codex/runtime-target.ts +++ b/scripts/src/platform-infra-sub2api-codex/runtime-target.ts @@ -123,18 +123,29 @@ export function codexPoolRuntimeTarget(targetId?: string): CodexPoolRuntimeTarge export function readTargetSentinelImageBuild(raw: Record, targetId: string, required = true): CodexPoolRuntimeTarget["sentinelImageBuild"] { const codexPool = isRecord(raw.codexPool) ? raw.codexPool : null; const imageBuild = codexPool !== null && isRecord(codexPool.sentinelImageBuild) ? codexPool.sentinelImageBuild : null; - if (imageBuild === null && !required) return { baseImageCachePolicy: "pull", noProxy: [] }; + if (imageBuild === null && !required) return { baseImageCachePolicy: "pull", noProxy: [], proxyEnvPath: null }; if (imageBuild === null) throw new Error(`${sub2apiConfigPath}.targets[${targetId}].codexPool.sentinelImageBuild must be a YAML object`); const policy = stringValue(imageBuild.baseImageCachePolicy); if (policy !== "pull" && policy !== "local-if-present") { throw new Error(`${sub2apiConfigPath}.targets[${targetId}].codexPool.sentinelImageBuild.baseImageCachePolicy must be pull or local-if-present`); } + const egressProxy = isRecord(raw.egressProxy) ? raw.egressProxy : null; + const proxyEnvPath = egressProxy === null ? null : readProxyEnvPath(egressProxy.proxyEnvPath, `${sub2apiConfigPath}.targets[${targetId}].egressProxy.proxyEnvPath`); return { baseImageCachePolicy: policy, noProxy: readTargetNoProxy(imageBuild.noProxy, `${sub2apiConfigPath}.targets[${targetId}].codexPool.sentinelImageBuild.noProxy`), + proxyEnvPath, }; } +export function readProxyEnvPath(value: unknown, key: string): string | null { + if (value === undefined || value === null) return null; + const path = stringValue(value); + if (path === null) throw new Error(`${key} must be a non-empty string`); + if (!/^\/[A-Za-z0-9._/-]+$/u.test(path)) throw new Error(`${key} has an unsupported absolute path format`); + return path; +} + export function readTargetPublicExposure(raw: Record, targetId: string): CodexPoolRuntimePublicExposure | null { if (raw.publicExposure === undefined || raw.publicExposure === null) return null; if (!isRecord(raw.publicExposure)) throw new Error(`${sub2apiConfigPath}.targets[${targetId}].publicExposure must be a YAML object`); diff --git a/scripts/src/platform-infra-sub2api-codex/types.ts b/scripts/src/platform-infra-sub2api-codex/types.ts index 07c53cc1..f5e1ec2f 100644 --- a/scripts/src/platform-infra-sub2api-codex/types.ts +++ b/scripts/src/platform-infra-sub2api-codex/types.ts @@ -92,6 +92,7 @@ export interface CodexPoolRuntimeTarget { sentinelImageBuild: { baseImageCachePolicy: "pull" | "local-if-present"; noProxy: string; + proxyEnvPath: string | null; }; egressProxy: { enabled: boolean; @@ -227,6 +228,7 @@ export interface CodexPoolManualAccountGroupBinding { export interface CodexPoolManualAccountProtection { accountName: string; reason: string | null; + targetIds: string[] | null; proxyBinding: CodexPoolManualAccountProxyBinding | null; groupBinding: CodexPoolManualAccountGroupBinding | null; }