From 01c9e8e32c3dba60746f4664383b0e31bd2162b1 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Wed, 7 Oct 2026 17:25:32 +0000 Subject: [PATCH] Project deployment rules and error codes from Go The TypeScript client now reads the deployment bounds and Runtime release patterns from a projection that specification-contract generates from sandbox/deployment_contract.go, so Web accepts only Core's oac-runtime microsandbox reference. The Harness catalog generator projects the model-provider protocol names from internal/modelprovider, so every client copy includes chat_completions. A shared catalog fixture lists every error code a /core/v1 or console caller can receive outside the wire vocabulary of wire-semantics.md. A Go test requires a producer for each listed code, an exact match with core-errors.md, and a catalog entry for every other code Core's error writers produce unless it never reaches an administration caller. Web tests require both locales to localize exactly those codes. The ten administration codes that were missing are now catalogued, documented and localized; the unproduced sandbox_admin_not_configured message is deleted, and the client-only sandbox_configuration_unconfirmed code is a named client constant. --- CONTRIBUTING.md | 2 +- .../features/sandbox/SandboxSetupWizard.tsx | 9 +- .../sandbox/deployment-specification.test.ts | 16 +- .../sandbox/deployment-specification.ts | 38 +++-- .../src/features/sandbox/runtime-release.ts | 23 --- apps/web/src/i18n/locales/en/common.ts | 3 +- apps/web/src/i18n/locales/en/core-errors.ts | 34 +++-- apps/web/src/i18n/locales/zh-CN/common.ts | 3 +- .../web/src/i18n/locales/zh-CN/core-errors.ts | 32 ++-- apps/web/src/lib/core-error.test.ts | 8 +- apps/web/src/lib/core-error.ts | 35 ++--- apps/web/src/lib/locale-strings.ts | 2 +- apps/web/src/lib/locale.test.ts | 5 +- apps/web/src/lib/sandbox-labels.ts | 4 +- contracts/agents-api/core-errors.md | 33 +++- contracts/agents-api/sandbox-deployment.md | 2 +- contracts/agents-api/zh/core-errors.md | 35 ++++- contracts/agents-api/zh/sandbox-deployment.md | 4 +- internal/modelprovider/config.go | 15 +- .../agents-client/src/admin-projection.ts | 4 +- .../agents-client/src/deployment-contract.ts | 3 + .../src/execution-configuration-projection.ts | 6 +- packages/agents-client/src/harness-catalog.ts | 2 + packages/agents-client/src/index.ts | 3 +- packages/agents-client/src/sandbox-client.ts | 8 +- packages/agents-client/src/types.ts | 8 +- scripts/generate-harness-catalog.py | 29 ++-- scripts/generate-harness-catalog.test.py | 4 +- scripts/harness-catalog/main.go | 10 +- services/core/README.md | 2 +- .../core/cmd/specification-contract/main.go | 11 +- .../internal/api/core_error_catalog_test.go | 142 ++++++++++++++++++ .../internal/api/testdata/core-errors.json | 45 ++++++ .../internal/sandbox/deployment_contract.go | 38 +++-- .../providers/deployment_contract_test.go | 12 +- 35 files changed, 467 insertions(+), 163 deletions(-) delete mode 100644 apps/web/src/features/sandbox/runtime-release.ts create mode 100644 packages/agents-client/src/deployment-contract.ts create mode 100644 services/core/internal/api/core_error_catalog_test.go create mode 100644 services/core/internal/api/testdata/core-errors.json diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b2e088a86..e5dee072e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -115,7 +115,7 @@ The role needs `CREATE DATABASE`: tests of database-wide state, such as the exec ### Contract and schema rules -- `internal/harnessconfig/builtin/catalog.json` is the single authored public Harness registration list. `make generate-harness-catalog` generates Go configuration/profile registration, client identifiers/names and the reference; `make openapi` derives the matching enums. `make check-harness-catalog` verifies freshness in the full gate. Native configuration rules stay in their adapter declarations; Core qualification and Runtime availability stay separate. +- `internal/harnessconfig/builtin/catalog.json` is the single authored public Harness registration list. `make generate-harness-catalog` generates Go configuration/profile registration, client identifiers/names and the reference, and projects the model-provider protocol names of `internal/modelprovider/config.go` to the client; `make openapi` derives the matching enums. `make check-harness-catalog` verifies freshness in the full gate. Native configuration rules stay in their adapter declarations; Core qualification and Runtime availability stay separate. - `make sqlc-generate` owns only `services/core/internal/db/sqlc` (sqlc v1.29.0). Do not rewrite landed migrations. - `make check-runtime-contract` is the focused Core–Runtime contract entry point; see [Contract verification](docs/runtime-protocol.md#contract-verification). It also runs through `check-go` and `check-core`. diff --git a/apps/web/src/features/sandbox/SandboxSetupWizard.tsx b/apps/web/src/features/sandbox/SandboxSetupWizard.tsx index d9ffce98a..79a29f2d7 100644 --- a/apps/web/src/features/sandbox/SandboxSetupWizard.tsx +++ b/apps/web/src/features/sandbox/SandboxSetupWizard.tsx @@ -1,4 +1,4 @@ -import { AgentCoreError, type InitializeSandboxDeployment, type UpdateSandboxDeployment, type SandboxE2BReadyBuild, type SandboxE2BTemplate, type SandboxProvider, type SandboxResources, type SandboxRuntimeRelease, type SandboxSpecification } from "@oac/agents-client"; +import { AgentCoreError, deploymentContract, type InitializeSandboxDeployment, type UpdateSandboxDeployment, type SandboxE2BReadyBuild, type SandboxE2BTemplate, type SandboxProvider, type SandboxResources, type SandboxRuntimeRelease, type SandboxSpecification } from "@oac/agents-client"; import { useQuery } from "@tanstack/react-query"; import { AnimatePresence } from "motion/react"; import * as m from "motion/react-m"; @@ -15,9 +15,8 @@ import { formatBytes } from "../../lib/format"; import { installationQuery } from "../../lib/installation"; import type { MessageKey } from "../../lib/locale-strings"; import { sandboxConfigurationRejection } from "../../lib/sandbox-labels"; -import { defaultSandboxResources, distributionRuntime, savedSpecification, validSandboxResources } from "./deployment-specification"; +import { defaultSandboxResources, distributionRuntime, isRuntimeRelease, isRuntimeReleaseField, RUNTIME_RELEASE_FIELDS, savedSpecification, validSandboxResources } from "./deployment-specification"; import { e2bKeyReady, e2bUpdateSelection } from "./sandbox-update"; -import { isRuntimeRelease, isRuntimeReleaseField, RUNTIME_RELEASE_FIELDS } from "./runtime-release"; import { sandboxAdmin } from "./sandbox-queries"; import "./sandbox-wizard.css"; @@ -41,6 +40,8 @@ function e2bService(apiURL?: string): E2BService { } const MIB = 2 ** 20; +const bounds = Object.fromEntries(deploymentContract.resources.map(({ name, min, max }) => [name, `${min}–${max}`])); +const resourceBounds = { cpus: bounds.cpus, memory: bounds.memory_mib, disk: deploymentContract.minimum_disk }; const EASE = [0.16, 1, 0.3, 1] as const; // Core accepts a template ID of up to 128 characters and a canonical, non-nil build UUID. const TEMPLATE = /^[a-zA-Z0-9_-]{1,128}:([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/; @@ -413,7 +414,7 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab
{ event.preventDefault(); setStep("review"); }}> {sized ?
- {t("Each sandbox")}{t("1–255 CPUs, 512–1048576 MiB of memory. microsandbox disks are at least 1024 MiB.")} + {t("Each sandbox")}{t("{{cpus}} CPUs, {{memory}} MiB of memory. microsandbox disks are at least {{disk}} MiB.", resourceBounds)}
{ setSize("custom"); setResources({ ...resources, cpus }); setFieldRejection(null); }} /> { setSize("custom"); setResources({ ...resources, memory_mib }); setFieldRejection(null); }} /> diff --git a/apps/web/src/features/sandbox/deployment-specification.test.ts b/apps/web/src/features/sandbox/deployment-specification.test.ts index f3e923f81..eac961bff 100644 --- a/apps/web/src/features/sandbox/deployment-specification.test.ts +++ b/apps/web/src/features/sandbox/deployment-specification.test.ts @@ -1,6 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { defaultSandboxResources, distributionRuntime, sandboxesThatFit, savedSpecification, validSandboxResources } from "./deployment-specification"; -import { isRuntimeReleaseField } from "./runtime-release"; +import { defaultSandboxResources, distributionRuntime, isRuntimeReleaseField, sandboxesThatFit, savedSpecification, validSandboxResources } from "./deployment-specification"; import standardSizes from "./standard-sizes.json"; import type { SandboxSpecification } from "@oac/agents-client"; @@ -49,14 +48,13 @@ describe("deployment resources and Runtime", () => { expect(validSandboxResources("e2b", { cpus: 2, memory_mib: 2048, root_disk_mib: 1024 })).toBe(false); expect(validSandboxResources("microsandbox", { cpus: 2, memory_mib: 2048, root_disk_mib: 1023, environment_disk_mib: 8192 })).toBe(false); }); - it("accepts any well-formed Runtime image name in the Runtime reference", async () => { + it("accepts only Core's Runtime image in the Runtime reference", async () => { const digest = "b".repeat(64); - for (const runtime_ref of [`oac-runtime@sha256:${digest}`, `custom-runtime@sha256:${digest}`]) { - vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify({ ...manifest, runtime_ref })))); - expect((await distributionRuntime(new AbortController().signal)).microsandbox_ref).toBe(runtime_ref); - expect(isRuntimeReleaseField("microsandbox_ref", runtime_ref)).toBe(true); - } - for (const runtime_ref of [`oac-runtime:${digest}`, `oac-runtime@sha256:${"b".repeat(63)}`, `oac-runtime@sha256:${"B".repeat(64)}`, `@sha256:${digest}`]) { + const runtime_ref = `oac-runtime@sha256:${digest}`; + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify({ ...manifest, runtime_ref })))); + expect((await distributionRuntime(new AbortController().signal)).microsandbox_ref).toBe(runtime_ref); + expect(isRuntimeReleaseField("microsandbox_ref", runtime_ref)).toBe(true); + for (const runtime_ref of [`custom-runtime@sha256:${digest}`, `oac-runtime:${digest}`, `oac-runtime@sha256:${"b".repeat(63)}`, `oac-runtime@sha256:${"B".repeat(64)}`, `@sha256:${digest}`]) { vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify({ ...manifest, runtime_ref })))); await expect(distributionRuntime(new AbortController().signal)).rejects.toThrow(); expect(isRuntimeReleaseField("microsandbox_ref", runtime_ref)).toBe(false); diff --git a/apps/web/src/features/sandbox/deployment-specification.ts b/apps/web/src/features/sandbox/deployment-specification.ts index 5fb2bc773..8859539f3 100644 --- a/apps/web/src/features/sandbox/deployment-specification.ts +++ b/apps/web/src/features/sandbox/deployment-specification.ts @@ -1,5 +1,4 @@ -import type { SandboxDeployment, SandboxE2BTemplateBuild, SandboxProvider, SandboxResources, SandboxRuntimeRelease, SandboxSpecification } from "@oac/agents-client"; -import { RUNTIME_REF_PATTERN } from "./runtime-release"; +import { deploymentContract, type SandboxDeployment, type SandboxE2BTemplateBuild, type SandboxProvider, type SandboxResources, type SandboxRuntimeRelease, type SandboxSpecification } from "@oac/agents-client"; import standardSizes from "./standard-sizes.json"; interface Manifest { @@ -15,12 +14,25 @@ export function defaultSandboxResources(provider: SandboxProvider): SandboxResou return { ...(provider === "microsandbox" ? standardSizes.microsandbox : standardSizes.docker) }; } +/** Core's resource rule: optional disk fields stay zero unless the provider supports disk limits. */ export function validSandboxResources(provider: SandboxProvider, resources: SandboxResources): boolean { - const bounded = (value: number | undefined, minimum: number, maximum: number) => Number.isInteger(value) && value! >= minimum && value! <= maximum; - if (!bounded(resources.cpus, 1, 255) || !bounded(resources.memory_mib, 512, 1048576)) return false; - return provider === "microsandbox" - ? bounded(resources.root_disk_mib, 1024, 4294967295) && bounded(resources.environment_disk_mib, 1024, 4294967295) - : (resources.root_disk_mib ?? 0) === 0 && (resources.environment_disk_mib ?? 0) === 0; + return deploymentContract.resources.every((rule) => { + const [min, max] = !rule.omit_zero ? [rule.min, rule.max] : provider === "microsandbox" ? [deploymentContract.minimum_disk, rule.max] : [0, 0]; + const value = resources[rule.name] ?? 0; + return Number.isInteger(value) && value >= min && value <= max; + }); +} + +const releasePatterns = Object.fromEntries(deploymentContract.runtime.map(({ name, pattern }) => [name, new RegExp(`^(?:${pattern})$`)])) as Record; + +export const RUNTIME_RELEASE_FIELDS = deploymentContract.runtime.map(({ name }) => name); + +export function isRuntimeReleaseField(field: keyof SandboxRuntimeRelease, value: string): boolean { + return releasePatterns[field].test(value); +} + +export function isRuntimeRelease(value: Partial): value is SandboxRuntimeRelease { + return RUNTIME_RELEASE_FIELDS.every((field) => typeof value[field] === "string" && releasePatterns[field].test(value[field])); } export function savedSpecification(provider: SandboxProvider, savedProvider?: SandboxProvider | "", specification?: SandboxSpecification): SandboxSpecification | null { @@ -59,12 +71,8 @@ export async function distributionRuntime(signal: AbortSignal): Promise@sha256:` shape. */ -export const RUNTIME_REF_PATTERN = /^[a-z0-9][a-z0-9._-]*@sha256:[0-9a-f]{64}$/; - -const patterns: Record = { - source_commit: /^[0-9a-f]{40}$/, - image_id: /^sha256:[0-9a-f]{64}$/, - image_manifest_digest: /^sha256:[0-9a-f]{64}$/, - microsandbox_ref: RUNTIME_REF_PATTERN, - runtime_sha256: /^[0-9a-f]{64}$/, - firmware_sha256: /^[0-9a-f]{64}$/, -}; - -export const RUNTIME_RELEASE_FIELDS = Object.keys(patterns) as (keyof SandboxRuntimeRelease)[]; - -export function isRuntimeReleaseField(field: keyof SandboxRuntimeRelease, value: string): boolean { - return patterns[field].test(value); -} - -export function isRuntimeRelease(value: Partial): value is SandboxRuntimeRelease { - return RUNTIME_RELEASE_FIELDS.every((field) => typeof value[field] === "string" && patterns[field].test(value[field]!)); -} diff --git a/apps/web/src/i18n/locales/en/common.ts b/apps/web/src/i18n/locales/en/common.ts index 5cc163979..2f5d564a9 100644 --- a/apps/web/src/i18n/locales/en/common.ts +++ b/apps/web/src/i18n/locales/en/common.ts @@ -1,7 +1,8 @@ -import { coreErrors } from "./core-errors"; +import { coreErrorDetails, coreErrors } from "./core-errors"; export const common = { coreErrors, + coreErrorDetails, readFailure: { title: "Could not read the data", partial: "Some reads failed. Any figures and rows shown cover only data already read; they may be incomplete or out of date.", diff --git a/apps/web/src/i18n/locales/en/core-errors.ts b/apps/web/src/i18n/locales/en/core-errors.ts index 8b86a85a3..d263d3e24 100644 --- a/apps/web/src/i18n/locales/en/core-errors.ts +++ b/apps/web/src/i18n/locales/en/core-errors.ts @@ -1,3 +1,4 @@ +/** One message for each code in Core's shared catalog, services/core/internal/api/testdata/core-errors.json. */ export const coreErrors = { "invalid_admin_key": "The console's Core key was rejected. Rotate it on the Core host, then sign in again.", "console_sign_in_required": "Sign in to the console again.", @@ -5,23 +6,15 @@ export const coreErrors = { "console_request_invalid": "The console request was rejected. Reload the page.", "core_unreachable": "Core is unreachable. Check the Core service, then refresh.", "invalid_name": "Enter a nonempty name without control characters.", - "nameLimit": "Enter a name of at most {{max}} characters without control characters.", - "nodeNameLimit": "Enter a name of at most {{max}} UTF-8 bytes.", "invalid_node_capacity": "Enter a valid whole-number capacity; retained capacity must be at least active capacity.", - "capacityRange": "Enter a whole number from {{min}} to {{max}}; retained capacity must be at least active capacity.", "model_configuration_model_invalid": "Enter a model ID of at most 1024 UTF-8 bytes without control characters.", "harness_config_invalid": "Check the supported native fields and their values. The JSON object must be at most 16 KiB and cannot redefine Core-managed settings.", "invalid_model_provider": "Enter the complete model provider configuration.", "model_provider_base_url_invalid": "Use an HTTPS URL without credentials, query parameters or a fragment.", "model_provider_protocol_unsupported": "This protocol is not supported by the harness.", - "protocols": "Allowed protocols: {{protocols}}.", "model_provider_api_key_invalid": "Enter a valid API key without control characters.", - "keyLimit": "Enter a valid API key of at most {{max}} characters.", "model_provider_token_limits_invalid": "Use valid token limits; output cannot exceed context, and required limits must be positive.", "invalid_sandbox_configuration": "Check the sandbox resources and Runtime release.", - "resourceRange": "Enter a whole number from {{min}} to {{max}}.", - "resourceMin": "Enter a whole number of at least {{min}}.", - "runtime": "Use a valid immutable Runtime release for this backend.", "project_archived": "This Project is archived.", "project_exists": "A Project with this name already exists.", "project_api_key_exists": "An active API key with this name already exists.", @@ -35,10 +28,31 @@ export const coreErrors = { "sandbox_in_use": "Hosted resources remain. Wait for confirmed cleanup before changing the configuration.", "runtime_node_in_use": "The node has active allocations or retained resources. Clear allocations, snapshots, reservations and pending cleanup before removal.", "runtime_node_unavailable": "The selected sandbox node is unavailable or has no capacity.", - "sandbox_admin_not_configured": "Sandbox administration is not configured on this console.", "sandbox_credential_ownership": "This E2B key cannot manage the retained deployment. Reset before changing teams.", "sandbox_credential_invalid": "The E2B API key was rejected. The saved configuration is unchanged.", "sandbox_configuration_invalid": "Select a ready immutable E2B template build with matching resources.", "sandbox_verification_unconfirmed": "E2B verification could not be confirmed. Refresh before submitting again.", - "sandbox_configuration_error": "E2B sandboxes need a public HTTPS address. Set OAC_PUBLIC_URL to an HTTPS origin." + "sandbox_configuration_error": "E2B sandboxes need a public HTTPS address. Set OAC_PUBLIC_URL to an HTTPS origin.", + "sandbox_deployment_conflict": "The sandbox deployment cannot change in its current state. Refresh and check its reset and resource state.", + "sandbox_specification_mismatch": "The saved sandbox specification does not match the deployment. Refresh to check the configuration.", + "sandbox_operation_unsupported": "The selected sandbox provider does not support this operation.", + "environment_unavailable": "The Session's environment is no longer available.", + "execution_unavailable": "Execution is not available on this Core.", + "runtime_history_unavailable": "Runtime history is unavailable on this Core.", + "runtime_history_unsupported": "Runtime history is not supported for this Session.", + "core_metrics_unavailable": "Core metrics could not be read. Try again later.", + "file_transfer_unavailable": "The file transfer is unavailable. Try again later.", + "not_found": "The requested Core resource does not exist." +} as const; + +/** Messages that format a catalogued code's typed details. */ +export const coreErrorDetails = { + "nameLimit": "Enter a name of at most {{max}} characters without control characters.", + "nodeNameLimit": "Enter a name of at most {{max}} UTF-8 bytes.", + "capacityRange": "Enter a whole number from {{min}} to {{max}}; retained capacity must be at least active capacity.", + "protocols": "Allowed protocols: {{protocols}}.", + "keyLimit": "Enter a valid API key of at most {{max}} characters.", + "resourceRange": "Enter a whole number from {{min}} to {{max}}.", + "resourceMin": "Enter a whole number of at least {{min}}.", + "runtime": "Use a valid immutable Runtime release for this backend." } as const; diff --git a/apps/web/src/i18n/locales/zh-CN/common.ts b/apps/web/src/i18n/locales/zh-CN/common.ts index 026112ab8..b67c624ab 100644 --- a/apps/web/src/i18n/locales/zh-CN/common.ts +++ b/apps/web/src/i18n/locales/zh-CN/common.ts @@ -1,7 +1,8 @@ -import { coreErrors } from "./core-errors"; +import { coreErrorDetails, coreErrors } from "./core-errors"; export const common = { coreErrors, + coreErrorDetails, readFailure: { title: "无法读取数据", partial: "部分读取失败。当前数字和列表仅来自已读取的数据,可能不完整或已过期。", diff --git a/apps/web/src/i18n/locales/zh-CN/core-errors.ts b/apps/web/src/i18n/locales/zh-CN/core-errors.ts index 310ade305..705b712d4 100644 --- a/apps/web/src/i18n/locales/zh-CN/core-errors.ts +++ b/apps/web/src/i18n/locales/zh-CN/core-errors.ts @@ -5,23 +5,15 @@ export const coreErrors = { "console_request_invalid": "控制台请求被拒绝,请重新加载页面。", "core_unreachable": "无法连接 Core。请检查 Core 服务后刷新。", "invalid_name": "请输入非空名称,不要包含控制字符。", - "nameLimit": "名称最多 {{max}} 个字符,且不能包含控制字符。", - "nodeNameLimit": "节点名称最多 {{max}} 个 UTF-8 字节。", "invalid_node_capacity": "请输入有效的整数容量;保留容量不能小于运行容量。", - "capacityRange": "请输入 {{min}} 到 {{max}} 的整数;保留容量不能小于运行容量。", "model_configuration_model_invalid": "请输入模型 ID,最多 1024 个 UTF-8 字节,且不能包含控制字符。", "harness_config_invalid": "请检查支持的原生字段及其取值。JSON 对象不能超过 16 KiB,也不能重复定义 Core 管理的设置。", "invalid_model_provider": "请填写完整的模型服务配置。", "model_provider_base_url_invalid": "请使用 HTTPS 地址,不要包含凭证、查询参数或片段。", "model_provider_protocol_unsupported": "此执行引擎不支持该协议。", - "protocols": "支持的协议:{{protocols}}。", "model_provider_api_key_invalid": "请输入有效的 API Key,不要包含控制字符。", - "keyLimit": "请输入有效的 API Key,长度最多 {{max}} 个字符。", "model_provider_token_limits_invalid": "请填写有效的 token 限制;输出不能超过上下文,必填限制必须大于零。", "invalid_sandbox_configuration": "请检查沙箱资源和 Runtime 版本。", - "resourceRange": "请输入 {{min}} 到 {{max}} 的整数。", - "resourceMin": "请输入不小于 {{min}} 的整数。", - "runtime": "请使用适用于此后端的有效不可变 Runtime 版本。", "project_archived": "此项目已归档。", "project_exists": "此项目名称已被使用。", "project_api_key_exists": "此名称已被使用中的 API Key 占用。", @@ -35,10 +27,30 @@ export const coreErrors = { "sandbox_in_use": "仍有托管资源。请等待 Core 确认清理完成后再修改配置。", "runtime_node_in_use": "节点仍有活跃分配或保留资源。请先清理资源分配、快照、预留资源和待清理项,再移除节点。", "runtime_node_unavailable": "所选沙箱节点不可用或容量不足。", - "sandbox_admin_not_configured": "此控制台尚未配置沙箱管理权限。", "sandbox_credential_ownership": "此 E2B 密钥无法管理当前保留的部署。更换团队前请先重置。", "sandbox_credential_invalid": "E2B API 密钥被拒绝。已保存的配置未改变。", "sandbox_configuration_invalid": "请选择已就绪且资源匹配的不可变 E2B 模板构建。", "sandbox_verification_unconfirmed": "无法确认 E2B 验证结果。请刷新后再提交。", - "sandbox_configuration_error": "E2B 沙箱需要可从互联网访问的 HTTPS 地址,请把 OAC_PUBLIC_URL 设为一个 HTTPS 源地址。" + "sandbox_configuration_error": "E2B 沙箱需要可从互联网访问的 HTTPS 地址,请把 OAC_PUBLIC_URL 设为一个 HTTPS 源地址。", + "sandbox_deployment_conflict": "沙箱部署在当前状态下无法更改。请刷新并检查重置和资源状态。", + "sandbox_specification_mismatch": "已保存的沙箱规格与部署不一致。请刷新检查配置。", + "sandbox_operation_unsupported": "所选沙箱提供商不支持此操作。", + "environment_unavailable": "此 Session 的环境已不可用。", + "execution_unavailable": "此 Core 不提供执行功能。", + "runtime_history_unavailable": "此 Core 上的 Runtime 历史不可用。", + "runtime_history_unsupported": "此 Session 不支持 Runtime 历史。", + "core_metrics_unavailable": "无法读取 Core 指标,请稍后重试。", + "file_transfer_unavailable": "文件传输不可用,请稍后重试。", + "not_found": "请求的 Core 资源不存在。" +} as const; + +export const coreErrorDetails = { + "nameLimit": "名称最多 {{max}} 个字符,且不能包含控制字符。", + "nodeNameLimit": "节点名称最多 {{max}} 个 UTF-8 字节。", + "capacityRange": "请输入 {{min}} 到 {{max}} 的整数;保留容量不能小于运行容量。", + "protocols": "支持的协议:{{protocols}}。", + "keyLimit": "请输入有效的 API Key,长度最多 {{max}} 个字符。", + "resourceRange": "请输入 {{min}} 到 {{max}} 的整数。", + "resourceMin": "请输入不小于 {{min}} 的整数。", + "runtime": "请使用适用于此后端的有效不可变 Runtime 版本。" } as const; diff --git a/apps/web/src/lib/core-error.test.ts b/apps/web/src/lib/core-error.test.ts index b69358dbb..cc03dd7b9 100644 --- a/apps/web/src/lib/core-error.test.ts +++ b/apps/web/src/lib/core-error.test.ts @@ -1,6 +1,7 @@ import { AgentCoreError } from "@oac/agents-client"; import { describe, expect, it } from "vitest"; import i18n from "../i18n"; +import catalog from "../../../../services/core/internal/api/testdata/core-errors.json"; import { coreErrors as english } from "../i18n/locales/en/core-errors"; import { coreErrors as chinese } from "../i18n/locales/zh-CN/core-errors"; import { coreError, coreFieldError, knownCoreError } from "./core-error"; @@ -11,9 +12,10 @@ const zh = i18n.getFixedT("zh-CN", "common"); const failure = (code: string, param?: string, details?: AgentCoreError["details"], status = 400) => new AgentCoreError("unparsed backend prose", status, code, param, undefined, details); describe("Core error catalog localization", () => { - it("covers both languages, without relying on backend prose", () => { - expect(Object.keys(chinese).sort()).toEqual(Object.keys(english).sort()); - for (const code of ["invalid_admin_key", "console_sign_in_required", "console_origin_rejected", "console_request_invalid", "core_unreachable", "invalid_name", "invalid_node_capacity", "invalid_model_provider", "model_provider_base_url_invalid", "model_provider_protocol_unsupported", "model_provider_api_key_invalid", "model_provider_token_limits_invalid", "invalid_sandbox_configuration", "sandbox_credential_invalid", "sandbox_configuration_invalid", "sandbox_credential_ownership", "sandbox_verification_unconfirmed", "sandbox_generation_stale", "sandbox_admin_not_configured", "project_archived", "project_exists", "project_api_key_exists"]) { + it("localizes exactly Core's shared catalog in both languages, without relying on backend prose", () => { + expect(Object.keys(english).sort()).toEqual([...catalog].sort()); + expect(Object.keys(chinese).sort()).toEqual([...catalog].sort()); + for (const code of catalog) { expect(knownCoreError(failure(code), en)).not.toBeNull(); expect(coreError(failure(code), en)).not.toContain("backend prose"); expect(coreError(failure(code), zh)).toMatch(/[\u4e00-\u9fff]/); diff --git a/apps/web/src/lib/core-error.ts b/apps/web/src/lib/core-error.ts index 2e0405cf1..3f7f303cf 100644 --- a/apps/web/src/lib/core-error.ts +++ b/apps/web/src/lib/core-error.ts @@ -1,39 +1,32 @@ -import { AgentCoreError } from "@oac/agents-client"; +import { AgentCoreError, deploymentContract, modelProviderProtocols } from "@oac/agents-client"; import type { TFunction } from "i18next"; -import type { coreErrors } from "../i18n/locales/en/core-errors"; +import { coreErrors } from "../i18n/locales/en/core-errors"; -const codes = new Set([ - "sandbox_credential_ownership", "sandbox_credential_invalid", "sandbox_configuration_invalid", "sandbox_verification_unconfirmed", "sandbox_configuration_error", - "sandbox_generation_stale", "sandbox_reset_required", "sandbox_reset_in_progress", "sandbox_not_configured", "sandbox_in_use", "runtime_node_in_use", "runtime_node_unavailable", "sandbox_admin_not_configured", - "invalid_admin_key", "console_sign_in_required", "console_origin_rejected", "console_request_invalid", "core_unreachable", - "invalid_name", "invalid_node_capacity", "invalid_model_provider", "model_configuration_model_invalid", "harness_config_invalid", "model_provider_base_url_invalid", - "model_provider_protocol_unsupported", "model_provider_api_key_invalid", "model_provider_token_limits_invalid", - "invalid_sandbox_configuration", "project_archived", "project_exists", "project_api_key_exists", - "executor_credential_exists", "credential_storage_unavailable", "internal_error", -]); +const protocols: ReadonlySet = new Set(modelProviderProtocols); +const resourceParams: ReadonlySet = new Set(deploymentContract.resources.map(({ name }) => `resources.${name}`)); /** Only catalogued, correctly typed detail keys can enter localized text. */ export function knownCoreError(error: unknown, t: TFunction<"common">, nameUnit: "characters" | "bytes" = "characters"): string | null { - if (!(error instanceof AgentCoreError) || !codes.has(error.code as keyof typeof coreErrors)) return null; + if (!(error instanceof AgentCoreError) || !error.code || !Object.hasOwn(coreErrors, error.code)) return null; const number = (key: string) => { const value = error.details?.[key]; return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : undefined; }; const maxLength = number("max_length"); - if (error.code === "invalid_name" && maxLength !== undefined) return t(nameUnit === "bytes" ? "coreErrors.nodeNameLimit" : "coreErrors.nameLimit", { max: maxLength }); - if (error.code === "model_provider_api_key_invalid" && maxLength !== undefined) return t("coreErrors.keyLimit", { max: maxLength }); + if (error.code === "invalid_name" && maxLength !== undefined) return t(nameUnit === "bytes" ? "coreErrorDetails.nodeNameLimit" : "coreErrorDetails.nameLimit", { max: maxLength }); + if (error.code === "model_provider_api_key_invalid" && maxLength !== undefined) return t("coreErrorDetails.keyLimit", { max: maxLength }); const min = number("min"), max = number("max"); - if (error.code === "invalid_node_capacity" && min !== undefined && max !== undefined && min <= max) return t("coreErrors.capacityRange", { min, max }); + if (error.code === "invalid_node_capacity" && min !== undefined && max !== undefined && min <= max) return t("coreErrorDetails.capacityRange", { min, max }); if (error.code === "invalid_sandbox_configuration") { - if (error.param === "runtime") return t("coreErrors.runtime"); - if (["resources.cpus", "resources.memory_mib", "resources.root_disk_mib", "resources.environment_disk_mib"].includes(error.param ?? "") && min !== undefined) { - if (max !== undefined && min <= max) return t("coreErrors.resourceRange", { min, max }); - if (max === undefined) return t("coreErrors.resourceMin", { min }); + if (error.param === "runtime") return t("coreErrorDetails.runtime"); + if (resourceParams.has(error.param) && min !== undefined) { + if (max !== undefined && min <= max) return t("coreErrorDetails.resourceRange", { min, max }); + if (max === undefined) return t("coreErrorDetails.resourceMin", { min }); } } if (error.code === "model_provider_protocol_unsupported") { - const protocols = error.details?.allowed_protocols; - if (Array.isArray(protocols) && protocols.length > 0 && protocols.every((value) => ["responses", "anthropic"].includes(value))) return t("coreErrors.protocols", { protocols: protocols.join(", ") }); + const allowed = error.details?.allowed_protocols; + if (Array.isArray(allowed) && allowed.length > 0 && allowed.every((value) => protocols.has(value))) return t("coreErrorDetails.protocols", { protocols: allowed.join(", ") }); } return t(`coreErrors.${error.code as keyof typeof coreErrors}`); } diff --git a/apps/web/src/lib/locale-strings.ts b/apps/web/src/lib/locale-strings.ts index 708c873fa..880588c9c 100644 --- a/apps/web/src/lib/locale-strings.ts +++ b/apps/web/src/lib/locale-strings.ts @@ -365,7 +365,7 @@ export const chinese = { "{{name}} will be removed from this deployment.": "{{name}} 将从此部署中移除。", "Open {{name}}": "打开 {{name}}", "Remove {{name}}": "移除 {{name}}", - "1–255 CPUs, 512–1048576 MiB of memory. microsandbox disks are at least 1024 MiB.": "CPU 1–255 核,内存 512–1048576 MiB;microsandbox 的磁盘至少 1024 MiB。", + "{{cpus}} CPUs, {{memory}} MiB of memory. microsandbox disks are at least {{disk}} MiB.": "CPU {{cpus}} 核,内存 {{memory}} MiB;microsandbox 的磁盘至少 {{disk}} MiB。", "Advanced settings": "高级设置", "CPUs": "CPU(核)", "Check this value": "请检查这个值", diff --git a/apps/web/src/lib/locale.test.ts b/apps/web/src/lib/locale.test.ts index fc47a3e83..439cefe4b 100644 --- a/apps/web/src/lib/locale.test.ts +++ b/apps/web/src/lib/locale.test.ts @@ -20,13 +20,12 @@ describe("sandbox localization", () => { } expect(sandboxDiagnosticMessage("", "zh")).toBeNull(); }); - it("shows Core's reason for a refusal, names an unconfigured console, and keeps other failures to the console's words", () => { - expect(sandboxRequestError(new AgentCoreError("raw secret", 503, "sandbox_admin_not_configured"), "zh")).toBe("此控制台尚未配置沙箱管理权限。"); + it("shows Core's reason for a refusal and keeps other failures to the console's words", () => { // A code with one exact meaning keeps the console's localized words. expect(sandboxRequestError(new AgentCoreError("Node node-edge still has allocations.", 409, "runtime_node_in_use"), "zh")).toBe("节点仍有活跃分配或保留资源。请先清理资源分配、快照、预留资源和待清理项,再移除节点。"); // A refusal is Core's to explain: one code, such as a 409 conflict, covers several reasons. expect(sandboxRequestError(new AgentCoreError("This console is read-only.", 403), "zh")).toBe("This console is read-only."); - expect(sandboxRequestError(new AgentCoreError("expected_generation is stale.", 409, "sandbox_deployment_conflict"), "zh")).toBe("expected_generation is stale."); + expect(sandboxRequestError(new AgentCoreError("The session is busy.", 409, "conflict_error"), "zh")).toBe("The session is busy."); // A sandbox refusal whose reason the client withheld is named without it. expect(sandboxRequestError(new AgentCoreError("withheld", 400, "sandbox_configuration_unconfirmed"), "zh")).toBe("Core 拒绝了这个沙箱配置。"); expect(sandboxRequestError(new AgentCoreError("raw secret", 502), "zh")).not.toContain("raw secret"); diff --git a/apps/web/src/lib/sandbox-labels.ts b/apps/web/src/lib/sandbox-labels.ts index 0c92dc5f5..0995dd7aa 100644 --- a/apps/web/src/lib/sandbox-labels.ts +++ b/apps/web/src/lib/sandbox-labels.ts @@ -1,4 +1,4 @@ -import { AgentCoreError, type SandboxNode, type SandboxProvider } from "@oac/agents-client"; +import { AgentCoreError, sandboxConfigurationUnconfirmed, type SandboxNode, type SandboxProvider } from "@oac/agents-client"; import i18n from "../i18n"; import { knownCoreError } from "./core-error"; import { translate, type Locale } from "./locale"; @@ -19,7 +19,7 @@ export function sandboxRequestError(error: unknown, locale: Locale): string { let key: MessageKey = "The sandbox request failed. Refresh to check the current state before trying again."; if (error instanceof AgentCoreError) { const refused = !sandboxWriteUncertain(error); - if (error.code === "sandbox_configuration_unconfirmed") { if (refused) key = "Core rejected the sandbox configuration."; else if (error.status >= 500) key = "The sandbox service is unavailable. Refresh to check the current state."; } + if (error.code === sandboxConfigurationUnconfirmed) { if (refused) key = "Core rejected the sandbox configuration."; else if (error.status >= 500) key = "The sandbox service is unavailable. Refresh to check the current state."; } else if (refused) { if (error.message) return error.message; key = "The sandbox request was rejected. Refresh to check the current state."; diff --git a/contracts/agents-api/core-errors.md b/contracts/agents-api/core-errors.md index 00be4d1c5..3873dcdf5 100644 --- a/contracts/agents-api/core-errors.md +++ b/contracts/agents-api/core-errors.md @@ -8,7 +8,7 @@ Errors on `/core/v1` use this envelope. `message` is safe English text; `code` a {"error":{"message":"A valid Core key is required as the bearer credential.","type":"invalid_request_error","code":"invalid_admin_key","param":null}} ``` -Errors on `/v1` and `/api/v1` keep their own envelopes and never carry `details`. +Errors on `/v1` and `/api/v1` keep their own envelopes and never carry `details`. The tables below list every code a `/core/v1` or console caller can receive, except the [wire vocabulary](./wire-semantics.md#errors), such as `invalid_request`, `not_found_error` or `idempotency_conflict`, which keeps its `/v1` meaning. Codes of `/v1` Session input and of the machine routes, such as `turn_conflict` or `invalid_node_credential`, never reach these callers. The shared catalog `services/core/internal/api/testdata/core-errors.json` holds exactly the listed codes. A Go test requires a producer for each, an exact match with these tables and a catalog entry for every other code Core's error writers produce; Web's tests require a message for exactly these codes in each language. ## Optional details @@ -47,7 +47,7 @@ A `POST` or `PUT /core/v1/sandbox/deployment` ([sandbox deployment](./sandbox-de | 409 | `sandbox_credential_ownership` | The candidate credential cannot manage the retained deployment; reset before changing accounts | `credential` | | 503 | `sandbox_verification_unconfirmed` | Verification, receipt settlement or the credential fence could not be confirmed | null | -On every deployment write, the typed client replaces the message of these codes and of the other `sandbox_*` deployment codes with fixed local text. It keeps only the `current_generation`, `allocations`, `pending`, `min` and `max` details, and keeps `param` only when status, code and param match the table or the `invalid_sandbox_configuration` rows below exactly. `409 sandbox_configuration_error` becomes fixed public-URL guidance with a null `param`, even for a `PUT` without a key. Any other error becomes `sandbox_configuration_unconfirmed` and is not resent, because a rejection could echo the key. +On every deployment write, the typed client replaces the message of these codes and of the other `sandbox_*` deployment codes with fixed local text. It keeps only the `current_generation`, `allocations`, `pending`, `min` and `max` details, and keeps `param` only when status, code and param match the table or the `invalid_sandbox_configuration` rows below exactly. `409 sandbox_configuration_error` becomes fixed public-URL guidance with a null `param`, even for a `PUT` without a key. Any other error becomes the client-only code `sandbox_configuration_unconfirmed`, which Core never returns, and is not resent, because a rejection could echo the key. ## Operation validation @@ -71,6 +71,35 @@ Each code returns HTTP 400 with `type: "invalid_request_error"`. A missing, malf Bounds are validation constants, never submitted values. Node names are limited in bytes; Project and key names in trimmed Unicode characters without control characters. Only the first failure is reported, in this order: model provider URL, protocol, key, general limits, the Harness's protocol, then the Harness's required limits; sandbox resources CPU, memory, disk, then Runtime. Model-provider field errors inside a `model_provider` object keep that object's field as `param`. An unknown sandbox provider returns an error without these fields. +## Other administration errors + +These codes have null `param` and no `details`. [Sandbox deployment](./sandbox-deployment.md#errors) describes when each sandbox code occurs. + +| HTTP | Code | Meaning | +| --- | --- | --- | +| 400 | `sandbox_operation_unsupported` | The selected sandbox provider does not support the operation | +| 401 | `invalid_admin_key` | The bearer credential is not a valid Core key | +| 404 | `not_found` | The operation does not exist, the Harness is unknown, or the Harness has no deployment default model provider | +| 409 | `project_archived` | The target Project is archived | +| 409 | `project_exists` | The Project ID already exists | +| 409 | `project_api_key_exists` | The API key ID already exists | +| 409 | `executor_credential_exists` | The executor credential ID already exists; rotate it to replace the secret | +| 409 | `sandbox_not_configured` | The sandbox deployment is not configured | +| 409 or 503 | `sandbox_reset_in_progress` | A sandbox reset is in progress | +| 409 | `sandbox_configuration_error` | The installation cannot serve the selected provider, such as E2B while the public URL is loopback | +| 409 | `sandbox_deployment_conflict` | The sandbox deployment cannot change in its current state | +| 409 | `sandbox_specification_mismatch` | The saved deployment specification is no longer valid for its provider | +| 409 | `runtime_node_in_use` | The node still holds allocations, snapshots, reservations or pending cleanup | +| 409 | `environment_unavailable` | The Session's environment is no longer available, such as an archive on a Core without execution | +| 409 | `runtime_history_unsupported` | Runtime history is not supported for the Session | +| 500 | `internal_error` | Core could not complete the operation | +| 503 | `runtime_node_unavailable` | No sandbox node is available or has capacity | +| 503 | `credential_storage_unavailable` | Core has no credential encryption key | +| 503 | `execution_unavailable` | Execution is not available on this Core | +| 503 | `runtime_history_unavailable` | Durable Runtime history is not configured or temporarily unavailable | +| 503 | `core_metrics_unavailable` | Core metrics could not be read | +| 503 | `file_transfer_unavailable` | Bounded content transfer is unavailable | + ## Diagnostic failure categories The [Session and Turn diagnostics reads](./session-diagnostics.md) return these categories inside a successful 200 snapshot, not as an error envelope. Public `/v1` Turn errors do not change. `params` is `{}` unless the table says otherwise. diff --git a/contracts/agents-api/sandbox-deployment.md b/contracts/agents-api/sandbox-deployment.md index 2af2c9244..94e62f090 100644 --- a/contracts/agents-api/sandbox-deployment.md +++ b/contracts/agents-api/sandbox-deployment.md @@ -224,6 +224,6 @@ Storage and credential failures stay errors: an empty or failed read never prove ## Canonical node specification -`sandbox/deployment_contract.go` owns the resource bounds, provider requirements, release patterns and canonical field order; `sandbox/deployment.go` applies them in Core. The installer consumes the generated declaration in `deploy/node/node_spec.py`, so there is no second set of limits or patterns. Regenerate it from the repository root with `go run ./services/core/cmd/specification-contract -write`; the sandbox Go tests, part of `make check`, reject a stale projection. +`sandbox/deployment_contract.go` owns the resource bounds, provider requirements, release patterns and canonical field order; `sandbox/deployment.go` applies them in Core. The installer consumes the generated declaration in `deploy/node/node_spec.py`, and the TypeScript client and Web the generated bounds and patterns in `packages/agents-client/src/deployment-contract.ts`, so there is no second set of limits or patterns. Regenerate both from the repository root with `go run ./services/core/cmd/specification-contract -write`; the sandbox Go tests, part of `make check`, reject a stale projection. The specification digest is the SHA-256 of compact UTF-8 JSON with `provider` first, then `resources`, then `runtime` when the provider requires it. Resource and Runtime fields follow the contract's declaration order; zero optional disk fields are omitted and required fields stay present. Release identities are lowercase ASCII, and the digest never depends on the incoming field order or whitespace. `services/core/internal/sandbox/testdata/deployment-contract.json` holds shared acceptance cases, exact canonical bytes and digests that both the Go and Python tests consume. diff --git a/contracts/agents-api/zh/core-errors.md b/contracts/agents-api/zh/core-errors.md index c7bc4301b..a0d533547 100644 --- a/contracts/agents-api/zh/core-errors.md +++ b/contracts/agents-api/zh/core-errors.md @@ -1,7 +1,7 @@ --- title: "Core 管理错误" source: contracts/agents-api/core-errors.md -source_hash: 3d8e6a03d6a54c7dc86a27164749ffae963b5ef52cbede0e0da843c7da0216ba +source_hash: 78fcbde855beafae4d1f5eb38b87596eeca25c99c025c6c54978db988d2a534a --- `/core/v1` 上的错误使用此封装结构。`message` 是安全的英文文本;`code` 和 `param` 可以为 null。客户端依据稳定的 `code` 和可选的 `param` 进行处理,对未知代码显示 `message`,绝不解析消息,也绝不自动重试被拒绝的写操作。 @@ -10,7 +10,7 @@ source_hash: 3d8e6a03d6a54c7dc86a27164749ffae963b5ef52cbede0e0da843c7da0216ba {"error":{"message":"A valid Core key is required as the bearer credential.","type":"invalid_request_error","code":"invalid_admin_key","param":null}} ``` -`/v1` 和 `/api/v1` 上的错误仍使用各自的封装结构,且绝不包含 `details`。 +`/v1` 和 `/api/v1` 上的错误仍使用各自的封装结构,且绝不包含 `details`。下面各表列出 `/core/v1` 或控制台调用方可能收到的所有代码,[线路词汇](wire-semantics.md#errors)除外;例如 `invalid_request`、`not_found_error` 或 `idempotency_conflict` 沿用其在 `/v1` 上的含义。`/v1` Session 输入和机器路由的代码(例如 `turn_conflict` 或 `invalid_node_credential`)不会到达这些调用方。共享目录 `services/core/internal/api/testdata/core-errors.json` 恰好包含所列代码。Go 测试要求每个代码都有生产者、与这些表完全一致,并要求 Core 错误写入函数产生的其他代码都登记在目录中;Web 测试要求每种语言恰好为这些代码提供消息。 ## 可选详细信息 {#optional-details} @@ -49,7 +49,7 @@ Web 的控制台服务器在 `/core` 路径上发生自身故障时使用此封 | 409 | `sandbox_credential_ownership` | 候选凭据无法管理保留的部署;更换账户前必须重置 | `credential` | | 503 | `sandbox_verification_unconfirmed` | 无法确认验证结果、回执结算结果或凭据隔离状态 | null | -每次写入部署时,类型化客户端都会将上述代码及其他 `sandbox_*` 部署代码的消息替换为固定的本地文本。`details` 中仅保留 `current_generation`、`allocations`、`pending`、`min` 和 `max`,并且仅当 status、code 和 param 与上表或下方 `invalid_sandbox_configuration` 各行完全匹配时,才保留 `param`。`409 sandbox_configuration_error` 会转换为有关公开 URL 的固定指引,并将 `param` 设为 null,即使对于未提供密钥的 `PUT` 也是如此。其他任何错误都会转换为 `sandbox_configuration_unconfirmed` 且不会重新发送,因为拒绝响应可能会回显密钥。 +每次写入部署时,类型化客户端都会将上述代码及其他 `sandbox_*` 部署代码的消息替换为固定的本地文本。`details` 中仅保留 `current_generation`、`allocations`、`pending`、`min` 和 `max`,并且仅当 status、code 和 param 与上表或下方 `invalid_sandbox_configuration` 各行完全匹配时,才保留 `param`。`409 sandbox_configuration_error` 会转换为有关公开 URL 的固定指引,并将 `param` 设为 null,即使对于未提供密钥的 `PUT` 也是如此。其他任何错误都会转换为仅客户端使用的代码 `sandbox_configuration_unconfirmed`(Core 从不返回它),且不会重新发送,因为拒绝响应可能会回显密钥。 ## 操作验证 {#operation-validation} @@ -73,6 +73,35 @@ Web 的控制台服务器在 `/core` 路径上发生自身故障时使用此封 这些边界是验证常量,绝不是提交的值。节点名称按字节数限制;Project 名称和键名称按去除首尾空白后的 Unicode 字符数限制,且不得包含控制字符。系统仅按以下顺序报告第一个失败项:模型提供商 URL、协议、密钥、常规限制、Harness 协议,然后是 Harness 的必需限制;沙箱资源依次为 CPU、内存、磁盘,然后是 Runtime。`model_provider` 对象内的模型提供商字段错误仍以该对象的相应字段作为 `param`。未知的沙箱提供商返回一个不含这些字段的错误。 +## 其他管理错误 {#other-administration-errors} + +这些代码的 `param` 为 null,且没有 `details`。[沙箱部署](./sandbox-deployment.md#errors)说明各沙箱代码何时出现。 + +| HTTP | 代码 | 含义 | +| --- | --- | --- | +| 400 | `sandbox_operation_unsupported` | 所选沙箱提供商不支持该操作 | +| 401 | `invalid_admin_key` | Bearer 凭据不是有效的 Core Key | +| 404 | `not_found` | 操作不存在、Harness 未知,或该 Harness 没有部署默认模型服务 | +| 409 | `project_archived` | 目标 Project 已归档 | +| 409 | `project_exists` | 该 Project ID 已存在 | +| 409 | `project_api_key_exists` | 该 API Key ID 已存在 | +| 409 | `executor_credential_exists` | 该执行器凭证 ID 已存在;要替换密钥,请轮换它 | +| 409 | `sandbox_not_configured` | 沙箱部署尚未配置 | +| 409 或 503 | `sandbox_reset_in_progress` | 沙箱正在重置 | +| 409 | `sandbox_configuration_error` | 当前安装无法支持所选提供商,例如公开 URL 为 loopback 时选择 E2B | +| 409 | `sandbox_deployment_conflict` | 沙箱部署在当前状态下无法更改 | +| 409 | `sandbox_specification_mismatch` | 已保存的部署规格对其提供商不再有效 | +| 409 | `runtime_node_in_use` | 节点仍有资源分配、快照、预留资源或待清理项 | +| 409 | `environment_unavailable` | Session 的环境已不可用,例如在不提供执行的 Core 上归档 | +| 409 | `runtime_history_unsupported` | 该 Session 不支持 Runtime 历史 | +| 500 | `internal_error` | Core 未能完成操作 | +| 503 | `runtime_node_unavailable` | 没有可用或有剩余容量的沙箱节点 | +| 503 | `credential_storage_unavailable` | Core 没有凭证加密密钥 | +| 503 | `execution_unavailable` | 此 Core 不提供执行功能 | +| 503 | `runtime_history_unavailable` | 持久 Runtime 历史未配置或暂时不可用 | +| 503 | `core_metrics_unavailable` | 无法读取 Core 指标 | +| 503 | `file_transfer_unavailable` | 有界内容传输不可用 | + ## 诊断失败类别 {#diagnostic-failure-categories} [Session and Turn diagnostics reads](session-diagnostics.md) 会在成功的 200 快照内返回以下类别,而不是以错误封装的形式返回。公开 `/v1` 的 Turn 错误保持不变。除非表格另有说明,否则 `params` 为 `{}`。 diff --git a/contracts/agents-api/zh/sandbox-deployment.md b/contracts/agents-api/zh/sandbox-deployment.md index dea0cf983..cf026eb4b 100644 --- a/contracts/agents-api/zh/sandbox-deployment.md +++ b/contracts/agents-api/zh/sandbox-deployment.md @@ -1,7 +1,7 @@ --- title: "沙箱部署" source: contracts/agents-api/sandbox-deployment.md -source_hash: 06a69e3d0ba245ab27c0b5d7390364a35d10fb8478e2d0f6867749b29368f980 +source_hash: 6f765be45518f23ace6384938616eb12aba7554e7f8fbfadb89738f26c692dd5 --- 沙箱部署为 Core 管理的 `openai_hosted` 执行选择 Sandbox Provider、每个沙箱的资源以及不可变的 Runtime 发行版。PostgreSQL 为每个安装维护一个当前有效选择;Web 和 Core API 写入同一配置。节点文件保存其已安装副本和特定于主机的路径,且不能覆盖其资源或 Runtime。该选择独立于 Harness;部署可以保持未配置状态,既无节点,也不接受托管准入。 @@ -226,6 +226,6 @@ POST 会在持久保存候选配置之前对其进行验证,并且不会创建 ## 规范的节点规格 {#canonical-node-specification} -`sandbox/deployment_contract.go`负责资源边界、提供商要求、发行版模式和规范字段顺序;`sandbox/deployment.go`在 Core 中应用这些规则。安装程序会使用 `deploy/node/node_spec.py` 中生成的声明,因此不存在第二套限制或模式。请在仓库根目录运行 `go run ./services/core/cmd/specification-contract -write` 重新生成;作为 `make check` 一部分的沙箱 Go 测试会拒绝过时的投影。 +`sandbox/deployment_contract.go`负责资源边界、提供商要求、发行版模式和规范字段顺序;`sandbox/deployment.go`在 Core 中应用这些规则。安装程序会使用 `deploy/node/node_spec.py` 中生成的声明,TypeScript 客户端和 Web 使用 `packages/agents-client/src/deployment-contract.ts` 中生成的边界和模式,因此不存在第二套限制或模式。请在仓库根目录运行 `go run ./services/core/cmd/specification-contract -write` 重新生成两者;作为 `make check` 一部分的沙箱 Go 测试会拒绝过时的投影。 规范摘要是紧凑 UTF-8 JSON 的 SHA-256,其中 `provider` 位于首位,其次是 `resources`,然后在提供商需要时放置 `runtime`。资源和 Runtime 字段遵循契约的声明顺序;值为零的可选磁盘字段会被省略,必填字段则保持存在。发行版标识采用小写 ASCII,摘要绝不会受传入字段顺序或空白字符影响。`services/core/internal/sandbox/testdata/deployment-contract.json`保存共享验收用例、精确的规范字节和摘要,Go 与 Python 测试都会使用这些内容。 diff --git a/internal/modelprovider/config.go b/internal/modelprovider/config.go index 386277d8f..595beb289 100644 --- a/internal/modelprovider/config.go +++ b/internal/modelprovider/config.go @@ -7,6 +7,7 @@ import ( "errors" "net" "net/url" + "slices" "strings" ) @@ -47,15 +48,11 @@ func ParseProvider(raw any) (Provider, error) { return provider, nil } -// Valid is the single vocabulary of supported upstream protocol formats. -func (p Protocol) Valid() bool { - switch p { - case Anthropic, Responses, ChatCompletions: - return true - default: - return false - } -} +// Protocols is the single vocabulary of supported upstream protocol formats. +// The Harness catalog generator projects it to the TypeScript client. +func Protocols() []Protocol { return []Protocol{Anthropic, Responses, ChatCompletions} } + +func (p Protocol) Valid() bool { return slices.Contains(Protocols(), p) } func (p Provider) Validate() error { if !p.Protocol.Valid() { diff --git a/packages/agents-client/src/admin-projection.ts b/packages/agents-client/src/admin-projection.ts index 6b94de644..206502a28 100644 --- a/packages/agents-client/src/admin-projection.ts +++ b/packages/agents-client/src/admin-projection.ts @@ -1,4 +1,4 @@ -import { coreHarnessKinds } from "./harness-catalog"; +import { coreHarnessKinds, modelProviderProtocols } from "./harness-catalog"; import { AgentCoreError, projectRuntimeObservation, projectSavedAgentConfiguration } from "./client"; import { projectTokenUsage } from "./usage-projection"; import { safeProvider } from "./execution-configuration-projection"; @@ -227,7 +227,7 @@ export function projectHarnessModelConfiguration(value: unknown, harness?: CoreH } function projectModelConfigurationSupport(value: unknown): CoreHarness["model_configuration_support"] { const support = record(value, ["protocols", "accepts_harness_config", "token_limits_required"]); - const known = new Set(["anthropic", "responses", "chat_completions"]); + const known: ReadonlySet = new Set(modelProviderProtocols); const protocols = support.protocols; if (!Array.isArray(protocols) || protocols.length === 0 || protocols.some((protocol) => !known.has(protocol)) || new Set(protocols).size !== protocols.length || diff --git a/packages/agents-client/src/deployment-contract.ts b/packages/agents-client/src/deployment-contract.ts new file mode 100644 index 000000000..77ccf9d18 --- /dev/null +++ b/packages/agents-client/src/deployment-contract.ts @@ -0,0 +1,3 @@ +// Code generated by services/core/cmd/specification-contract from sandbox/deployment_contract.go; DO NOT EDIT. + +export const deploymentContract = {"resources":[{"name":"cpus","min":1,"max":255,"omit_zero":false},{"name":"memory_mib","min":512,"max":1048576,"omit_zero":false},{"name":"root_disk_mib","min":0,"max":4294967295,"omit_zero":true},{"name":"environment_disk_mib","min":0,"max":4294967295,"omit_zero":true}],"runtime":[{"name":"source_commit","pattern":"[0-9a-f]{40}"},{"name":"image_id","pattern":"sha256:[0-9a-f]{64}"},{"name":"image_manifest_digest","pattern":"sha256:[0-9a-f]{64}"},{"name":"microsandbox_ref","pattern":"oac-runtime@sha256:[0-9a-f]{64}"},{"name":"runtime_sha256","pattern":"[0-9a-f]{64}"},{"name":"firmware_sha256","pattern":"[0-9a-f]{64}"}],"minimum_disk":1024} as const; diff --git a/packages/agents-client/src/execution-configuration-projection.ts b/packages/agents-client/src/execution-configuration-projection.ts index 5ae4c5000..35ff00a33 100644 --- a/packages/agents-client/src/execution-configuration-projection.ts +++ b/packages/agents-client/src/execution-configuration-projection.ts @@ -1,3 +1,4 @@ +import { modelProviderProtocols } from "./harness-catalog"; import { canonicalUuid, exactFields, isNonnegativeInteger, isRecord, onlyFields, sameResourceId } from "./response-projection"; import type { ExecutionConfigurationSource, ModelProviderView, SessionExecutionConfiguration } from "./types"; @@ -5,6 +6,7 @@ type Invalid = () => never; const sources = new Set(["session", "agent", "deployment", "unknown"]); const selectionFields = new Set(["value", "source"]); const providerFields = new Set(["protocol", "base_url", "api_key_configured", "context_window", "max_output_tokens"]); +const protocols: ReadonlySet = new Set(modelProviderProtocols); function selection(value: unknown, invalid: Invalid): SessionExecutionConfiguration["model"] { if (!isRecord(value) || !exactFields(value, selectionFields) || !sources.has(String(value.source)) || @@ -32,14 +34,14 @@ function safeBaseURL(value: string): boolean { /** The safe provider view shared by frozen Session configuration and saved Agent reads. */ export function safeProvider(value: unknown, invalid: Invalid): ModelProviderView { if (!isRecord(value) || !onlyFields(value, providerFields) || - (value.protocol !== "responses" && value.protocol !== "anthropic" && value.protocol !== "chat_completions") || + !protocols.has(value.protocol) || typeof value.base_url !== "string" || typeof value.api_key_configured !== "boolean" || (value.context_window !== undefined && !isNonnegativeInteger(value.context_window)) || (value.max_output_tokens !== undefined && !isNonnegativeInteger(value.max_output_tokens)) || Number(value.max_output_tokens ?? 0) > Number(value.context_window ?? 0)) return invalid(); if (!safeBaseURL(value.base_url)) return invalid(); return { - protocol: value.protocol, base_url: value.base_url, api_key_configured: value.api_key_configured, + protocol: value.protocol as ModelProviderView["protocol"], base_url: value.base_url, api_key_configured: value.api_key_configured, ...(value.context_window === undefined ? {} : { context_window: value.context_window as number }), ...(value.max_output_tokens === undefined ? {} : { max_output_tokens: value.max_output_tokens as number }), }; diff --git a/packages/agents-client/src/harness-catalog.ts b/packages/agents-client/src/harness-catalog.ts index 2594b6442..3d1934a99 100644 --- a/packages/agents-client/src/harness-catalog.ts +++ b/packages/agents-client/src/harness-catalog.ts @@ -7,3 +7,5 @@ export const coreHarnessNames: Record = { "codex": "Codex", "mcode": "MiniMax Code", }; +export const modelProviderProtocols = ["anthropic", "responses", "chat_completions"] as const; +export type ModelProviderProtocol = (typeof modelProviderProtocols)[number]; diff --git a/packages/agents-client/src/index.ts b/packages/agents-client/src/index.ts index b58933aa3..669bdcd4b 100644 --- a/packages/agents-client/src/index.ts +++ b/packages/agents-client/src/index.ts @@ -1,4 +1,5 @@ -export { coreHarnessKinds, coreHarnessNames } from "./harness-catalog"; +export { coreHarnessKinds, coreHarnessNames, modelProviderProtocols } from "./harness-catalog"; +export { deploymentContract } from "./deployment-contract"; export { AgentCoreError, CreationStreamRetryError, createIdempotencyKey, isSessionDeletionConflict, OpenAIAgentsClient } from "./client"; export type { OpenAIAgentsClientOptions, CoreErrorDetail, CoreErrorDetails } from "./client"; export { createSSEDecoder } from "./sse"; diff --git a/packages/agents-client/src/sandbox-client.ts b/packages/agents-client/src/sandbox-client.ts index ef18d0c02..256ee7001 100644 --- a/packages/agents-client/src/sandbox-client.ts +++ b/packages/agents-client/src/sandbox-client.ts @@ -1,5 +1,6 @@ import { AgentCoreError } from "./client"; import { CoreRequester, type CoreClientOptions } from "./core-request"; +import { deploymentContract } from "./deployment-contract"; import { hasOwn, isNonnegativeInteger, isRecord, onlyFields, sameResourceId } from "./response-projection"; import type { ReadOptions } from "./types"; @@ -25,6 +26,9 @@ export function normalizeSandboxNodeDiagnostic(value: string): Exclude(["runtime", ...deploymentContract.resources.map(({ name }) => `resources.${name}`)]); /** CPU and MiB limits for each sandbox, not node concurrency. */ export interface SandboxResources { cpus: number; memory_mib: number; root_disk_mib?: number; environment_disk_mib?: number } export interface SandboxRuntimeRelease { source_commit: string; image_id: string; image_manifest_digest: string; microsandbox_ref: string; runtime_sha256: string; firmware_sha256: string } @@ -389,7 +393,7 @@ export class SandboxAdminClient { const fields = error.code === "sandbox_generation_stale" ? ["current_generation"] : error.code === "sandbox_in_use" ? ["allocations", "pending"] : error.code === "invalid_sandbox_configuration" ? ["min", "max"] : []; const details = Object.fromEntries(fields.filter(field => isNonnegativeInteger(error.details?.[field])).map(field => [field, Number(error.details![field])])); const safeParam = error.status === 400 - ? error.code === "sandbox_credential_invalid" ? "credential" : error.code === "sandbox_configuration_invalid" ? "configuration" : error.code === "invalid_sandbox_configuration" && ["runtime", "resources.cpus", "resources.memory_mib", "resources.root_disk_mib", "resources.environment_disk_mib"].includes(error.param ?? "") ? error.param : null + ? error.code === "sandbox_credential_invalid" ? "credential" : error.code === "sandbox_configuration_invalid" ? "configuration" : error.code === "invalid_sandbox_configuration" && sandboxConfigurationParams.has(error.param) ? error.param : null : error.status === 409 && error.code === "sandbox_credential_ownership" ? "credential" : null; const param = error.param === safeParam ? safeParam : null; throw new AgentCoreError(messages[error.code]!, error.status, error.code, param, undefined, Object.keys(details).length ? details : undefined); @@ -401,7 +405,7 @@ export class SandboxAdminClient { throw new AgentCoreError("E2B sandboxes reach Core over the internet. Set an HTTPS public URL that is not loopback.", 409, "sandbox_configuration_error", null); } // Any other credential-bearing rejection may reflect the key in any error field. - throw new AgentCoreError("Sandbox configuration could not be confirmed. Refresh before submitting again.", error instanceof AgentCoreError ? error.status : 0, "sandbox_configuration_unconfirmed"); + throw new AgentCoreError("Sandbox configuration could not be confirmed. Refresh before submitting again.", error instanceof AgentCoreError ? error.status : 0, sandboxConfigurationUnconfirmed); } } async listNodes(options?: ReadOptions): Promise<{ data: SandboxNode[] }> { diff --git a/packages/agents-client/src/types.ts b/packages/agents-client/src/types.ts index bb75dc520..ceac7b2c9 100644 --- a/packages/agents-client/src/types.ts +++ b/packages/agents-client/src/types.ts @@ -1,4 +1,4 @@ -import type { CoreHarnessKind } from "./harness-catalog"; +import type { CoreHarnessKind, ModelProviderProtocol } from "./harness-catalog"; export type PageOrder = "asc" | "desc"; export interface ListPage { @@ -1185,11 +1185,11 @@ export interface RuntimeHistory { token_usage: RuntimeHistoryTokenUsagePoint[]; } -export type { CoreHarnessKind } from "./harness-catalog"; +export type { CoreHarnessKind, ModelProviderProtocol } from "./harness-catalog"; /** A complete replacement bundle. API keys are write-only. */ export interface ModelProviderInput { - protocol: "anthropic" | "responses" | "chat_completions"; + protocol: ModelProviderProtocol; base_url: string; api_key: string; context_window?: number; @@ -1198,7 +1198,7 @@ export interface ModelProviderInput { } export interface ModelProviderView { - protocol: "anthropic" | "responses" | "chat_completions"; + protocol: ModelProviderProtocol; base_url: string; context_window?: number; max_output_tokens?: number; diff --git a/scripts/generate-harness-catalog.py b/scripts/generate-harness-catalog.py index af3d2091d..0af1ba279 100644 --- a/scripts/generate-harness-catalog.py +++ b/scripts/generate-harness-catalog.py @@ -47,6 +47,20 @@ def render_installer(providers): "PROVIDERS = " + pformat(providers, sort_dicts=True, width=100) + "\n") +def render_client(entries, protocols): + kinds = ", ".join(json.dumps(entry["kind"]) for entry in entries) + labels = "\n".join(f' {json.dumps(entry["kind"])}: {json.dumps(entry["label"])},' for entry in entries) + return HEADER + f''' +export const coreHarnessKinds = [{kinds}] as const; +export type CoreHarnessKind = (typeof coreHarnessKinds)[number]; +export const coreHarnessNames: Record = {{ +{labels} +}}; +export const modelProviderProtocols = [{", ".join(json.dumps(protocol) for protocol in protocols)}] as const; +export type ModelProviderProtocol = (typeof modelProviderProtocols)[number]; +''' + + def render(entries): packages = sorted({entry["configuration"] for entry in entries}) imports = "\n".join(f'\t"{MODULE}/internal/harnessconfig/{package}"' for package in packages) @@ -56,7 +70,6 @@ def render(entries): kinds = ", ".join(json.dumps(entry["kind"]) for entry in entries) profiles = "\n".join( f'\t{json.dumps(entry["kind"])}: {entry["profile"]}(),' for entry in entries) - labels = "\n".join(f' {json.dumps(entry["kind"])}: {json.dumps(entry["label"])},' for entry in entries) tick = chr(96) rows = "\n".join( f'| {tick}{e["kind"]}{tick} | {e["label"]} | {tick}{e["configuration"]}.Configuration{tick} | {tick}{e["profile"]}{tick} |' @@ -96,13 +109,6 @@ def render(entries): {profiles} }}) '''), - Path("packages/agents-client/src/harness-catalog.ts"): HEADER + f''' -export const coreHarnessKinds = [{kinds}] as const; -export type CoreHarnessKind = (typeof coreHarnessKinds)[number]; -export const coreHarnessNames: Record = {{ -{labels} -}}; -''', Path("contracts/agents-api/harness-catalog.md"): f'''[//]: # (Generated by scripts/generate-harness-catalog.py; DO NOT EDIT.) # Built-in Harness registrations @@ -149,9 +155,10 @@ def projection(relative, content): # mode a stale registration must fail before its declarations can be projected. if stale: raise SystemExit("Stale Harness catalog projections; run make generate-harness-catalog:\n" + "\n".join(stale)) - providers = json.loads(subprocess.run(["go", "run", "./scripts/harness-catalog"], cwd=ROOT, - text=True, check=True, capture_output=True).stdout) - projection(Path("scripts/acceptance/harness_catalog.py"), render_installer(providers)) + declared = json.loads(subprocess.run(["go", "run", "./scripts/harness-catalog"], cwd=ROOT, + text=True, check=True, capture_output=True).stdout) + projection(Path("scripts/acceptance/harness_catalog.py"), render_installer(declared["harnesses"])) + projection(Path("packages/agents-client/src/harness-catalog.ts"), render_client(entries, declared["protocols"])) if stale: raise SystemExit("Stale Harness catalog projections; run make generate-harness-catalog:\n" + "\n".join(stale)) diff --git a/scripts/generate-harness-catalog.test.py b/scripts/generate-harness-catalog.test.py index a0ff636c3..70b9b7ce7 100644 --- a/scripts/generate-harness-catalog.test.py +++ b/scripts/generate-harness-catalog.test.py @@ -35,7 +35,9 @@ def test_new_registration_reaches_all_projections(self): with tempfile.TemporaryDirectory() as directory: path = Path(directory) / "catalog.json" path.write_text(json.dumps(entries)) - generated = catalog.render(catalog.load_catalog(path)) + loaded = catalog.load_catalog(path) + generated = catalog.render(loaded) + generated["client"] = catalog.render_client(loaded, ["responses"]) for content in generated.values(): self.assertIn("example", content) for old in ("codex", "claude_sdk", "mcode"): diff --git a/scripts/harness-catalog/main.go b/scripts/harness-catalog/main.go index 6ce96690f..dff896066 100644 --- a/scripts/harness-catalog/main.go +++ b/scripts/harness-catalog/main.go @@ -1,4 +1,5 @@ -// Command harness-catalog projects adapter-owned provider declarations for tooling. +// Command harness-catalog projects adapter-owned provider declarations and the +// model-provider protocol vocabulary for tooling. package main import ( @@ -6,6 +7,7 @@ import ( "os" "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig/builtin" + "github.com/MiniMax-AI/OpenAgentCore/internal/modelprovider" ) type provider struct { @@ -25,7 +27,11 @@ func declarations() map[string]map[string]provider { } func main() { - if err := json.NewEncoder(os.Stdout).Encode(declarations()); err != nil { + projection := struct { + Harnesses map[string]map[string]provider `json:"harnesses"` + Protocols []modelprovider.Protocol `json:"protocols"` + }{declarations(), modelprovider.Protocols()} + if err := json.NewEncoder(os.Stdout).Encode(projection); err != nil { panic(err) } } diff --git a/services/core/README.md b/services/core/README.md index 19563a3c2..3ca7b19ec 100644 --- a/services/core/README.md +++ b/services/core/README.md @@ -10,7 +10,7 @@ | `cmd/device` | `oac-core-device` | Provisions or revokes an [operator device profile](../../contracts/agents-api/machine-api.md#operator-device-profile) for `environment: none` engine hosts | | `cmd/environment-key` | `oac-core-environment-key` | The [break-glass executor credential command](../../contracts/agents-api/environment-executor-credentials.md#break-glass-command) | | `cmd/sandbox-node` | `oac-node` | The sandbox node program; see the [nodes guide](../../docs/getting-started/nodes.md) | -| `cmd/specification-contract` | None | Regenerates the installer's node specification projection | +| `cmd/specification-contract` | None | Regenerates the deployment contract projections of the node installer and the TypeScript client | `make build-core` builds the four executables into `~/.oac/build/oac-core`; [Standalone Core builds](../../docs/maintainers.md#standalone-core-builds) describes the build and its options. `make build-daemon` builds `oac-daemon`. diff --git a/services/core/cmd/specification-contract/main.go b/services/core/cmd/specification-contract/main.go index 308224655..c79213dda 100644 --- a/services/core/cmd/specification-contract/main.go +++ b/services/core/cmd/specification-contract/main.go @@ -1,24 +1,28 @@ -// specification-contract maintains the generated node installer projection. +// specification-contract maintains the generated deployment contract +// projections of the node installer and the TypeScript client. package main import ( "flag" "fmt" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/providers" "os" "strings" ) func main() { - write := flag.Bool("write", false, "update deploy/node/node_spec.py from the repository root") + write := flag.Bool("write", false, "update deploy/node/node_spec.py and packages/agents-client/src/deployment-contract.ts from the repository root") flag.Parse() projection, err := providers.Builtin().PythonDeploymentContract() if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } + typescript := sandbox.TypeScriptDeploymentContract() if !*write { fmt.Println(projection) + fmt.Print(typescript) return } path := "deploy/node/node_spec.py" @@ -36,4 +40,7 @@ func main() { if err = os.WriteFile(path, []byte(source[:start]+projection+source[end:]), 0644); err != nil { panic(err) } + if err = os.WriteFile("packages/agents-client/src/deployment-contract.ts", []byte(typescript), 0644); err != nil { + panic(err) + } } diff --git a/services/core/internal/api/core_error_catalog_test.go b/services/core/internal/api/core_error_catalog_test.go new file mode 100644 index 000000000..2d81518ab --- /dev/null +++ b/services/core/internal/api/core_error_catalog_test.go @@ -0,0 +1,142 @@ +package api + +import ( + "encoding/json" + "go/ast" + "go/parser" + "go/token" + "io/fs" + "maps" + "os" + "path/filepath" + "regexp" + "slices" + "strconv" + "strings" + "testing" +) + +// Codes the error writers produce that never reach an administration caller. +// Everything else they produce is in the catalog or in the wire vocabulary of +// wire-semantics.md, which keeps its /v1 meaning on every route. +var nonAdministrationCodes = []string{ + // Session creation and input admission on /v1. + "environment_input_cancelled", "environment_input_expired", "model_provider_required", "sandbox_nodes_preparing", "turn_conflict", + // Machine routes for nodes and native installers. + "installation_authorization_invalid", "installation_unavailable", "invalid_node_credential", "sandbox_node_address_mismatch", + // Removal of the file-managed local node, which the process deployment path owns. + "runtime_local_node_configured", +} + +// The shared catalog lists every code an administration caller (/core/v1 or +// the console's /core paths) can receive outside the wire vocabulary. +// core-errors.md documents and Web localizes exactly these codes. +func TestCoreErrorCatalog(t *testing.T) { + raw, err := os.ReadFile("testdata/core-errors.json") + if err != nil { + t.Fatal(err) + } + var catalog []string + if err := json.Unmarshal(raw, &catalog); err != nil { + t.Fatal(err) + } + // One pass collects every string literal, which covers codes carried by + // typed errors, and the literal codes passed to the error writers. + literals, written := map[string]bool{}, map[string]bool{} + files := token.NewFileSet() + for _, root := range []string{"../../../../services", "../../../../contracts"} { + err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { + switch { + case err != nil: + return err + case entry.IsDir() && (entry.Name() == "node_modules" || entry.Name() == "testdata"): + return filepath.SkipDir + case entry.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go"): + return nil + } + file, err := parser.ParseFile(files, path, nil, parser.SkipObjectResolution) + if err != nil { + return err + } + ast.Inspect(file, func(node ast.Node) bool { + switch node := node.(type) { + case *ast.BasicLit: + if value, err := strconv.Unquote(node.Value); node.Kind == token.STRING && err == nil { + literals[value] = true + } + case *ast.CallExpr: + if name, ok := node.Fun.(*ast.Ident); ok && len(node.Args) > 2 && slices.Contains([]string{"writeError", "writeAPIError", "writeCoreError", "consoleCoreError"}, name.Name) { + if code, ok := node.Args[2].(*ast.BasicLit); ok { + value, _ := strconv.Unquote(code.Value) + written[value] = value != "" + } + } + } + return true + }) + return nil + }) + if err != nil { + t.Fatal(err) + } + } + for _, code := range slices.Concat(catalog, nonAdministrationCodes) { + if !literals[code] { + t.Errorf("code %q has no Go producer", code) + } + } + wire := backtickedCodes(t, "../../../../contracts/agents-api/wire-semantics.md") + for code, produced := range written { + if produced && !slices.Contains(catalog, code) && !slices.Contains(nonAdministrationCodes, code) && !wire[code] { + t.Errorf("produced code %q is missing from the catalog; list it in nonAdministrationCodes only if no administration caller can receive it", code) + } + } + documented := documentedCoreErrorCodes(t, "../../../../contracts/agents-api/core-errors.md") + slices.Sort(catalog) + if !slices.Equal(documented, catalog) { + t.Errorf("core-errors.md codes = %v, want shared catalog %v", documented, catalog) + } +} + +var backtickedCode = regexp.MustCompile("`([a-z_]+)`") + +func backtickedCodes(t *testing.T, path string) map[string]bool { + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + codes := map[string]bool{} + for _, match := range backtickedCode.FindAllStringSubmatch(string(raw), -1) { + codes[match[1]] = true + } + return codes +} + +// documentedCoreErrorCodes reads the Code column of every error table before +// the diagnostics catalog, which is a separate vocabulary. +func documentedCoreErrorCodes(t *testing.T, path string) []string { + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + text, _, _ := strings.Cut(string(raw), "## Diagnostic failure categories") + codes := map[string]bool{} + column := -1 + for _, line := range strings.Split(text, "\n") { + if !strings.HasPrefix(line, "|") { + column = -1 + continue + } + cells := strings.Split(strings.Trim(line, "|"), "|") + if column < 0 { + column = slices.IndexFunc(cells, func(cell string) bool { return strings.TrimSpace(cell) == "Code" }) + continue + } + if column < len(cells) { + for _, match := range backtickedCode.FindAllStringSubmatch(cells[column], -1) { + codes[match[1]] = true + } + } + } + return slices.Sorted(maps.Keys(codes)) +} diff --git a/services/core/internal/api/testdata/core-errors.json b/services/core/internal/api/testdata/core-errors.json new file mode 100644 index 000000000..256654003 --- /dev/null +++ b/services/core/internal/api/testdata/core-errors.json @@ -0,0 +1,45 @@ +[ + "console_origin_rejected", + "console_request_invalid", + "console_sign_in_required", + "core_metrics_unavailable", + "core_unreachable", + "credential_storage_unavailable", + "environment_unavailable", + "execution_unavailable", + "executor_credential_exists", + "file_transfer_unavailable", + "harness_config_invalid", + "internal_error", + "invalid_admin_key", + "invalid_model_provider", + "invalid_name", + "invalid_node_capacity", + "invalid_sandbox_configuration", + "model_configuration_model_invalid", + "model_provider_api_key_invalid", + "model_provider_base_url_invalid", + "model_provider_protocol_unsupported", + "model_provider_token_limits_invalid", + "not_found", + "project_api_key_exists", + "project_archived", + "project_exists", + "runtime_history_unavailable", + "runtime_history_unsupported", + "runtime_node_in_use", + "runtime_node_unavailable", + "sandbox_configuration_error", + "sandbox_configuration_invalid", + "sandbox_credential_invalid", + "sandbox_credential_ownership", + "sandbox_deployment_conflict", + "sandbox_generation_stale", + "sandbox_in_use", + "sandbox_not_configured", + "sandbox_operation_unsupported", + "sandbox_reset_in_progress", + "sandbox_reset_required", + "sandbox_specification_mismatch", + "sandbox_verification_unconfirmed" +] diff --git a/services/core/internal/sandbox/deployment_contract.go b/services/core/internal/sandbox/deployment_contract.go index 4350100d4..e79ea2f40 100644 --- a/services/core/internal/sandbox/deployment_contract.go +++ b/services/core/internal/sandbox/deployment_contract.go @@ -8,7 +8,8 @@ import ( ) // This contract owns numeric bounds, release patterns and canonical field order. -// Python installers consume its generated projection in node_spec.py. +// The node installer (node_spec.py) and the TypeScript client +// (deployment-contract.ts) consume its generated projections. const minimumDiskMiB uint32 = 1024 type resourceRule struct { @@ -45,9 +46,31 @@ var runtimeContract = []runtimeRule{ {"firmware_sha256", "[0-9a-f]{64}"}, } -// PythonDeploymentContract generates the installer projection. Struct order is -// checked before generation because it also defines Go's canonical JSON bytes. +// PythonDeploymentContract generates the installer projection. func PythonDeploymentContract(policies map[string]DeploymentPolicy) string { + raw := projectDeploymentContract(struct { + Resources []resourceRule `json:"resources"` + Runtime []runtimeRule `json:"runtime"` + Providers map[string]DeploymentPolicy `json:"providers"` + MinimumDisk uint32 `json:"minimum_disk"` + }{resourceContract, runtimeContract, policies, minimumDiskMiB}) + return "# BEGIN GENERATED DEPLOYMENT CONTRACT\n# Generated from sandbox/deployment_contract.go; do not edit.\n_CONTRACT = json.loads(" + fmt.Sprintf("%q", string(raw)) + ")\n# END GENERATED DEPLOYMENT CONTRACT" +} + +// TypeScriptDeploymentContract generates the client projection of the bounds +// and patterns; provider policies are not part of it. +func TypeScriptDeploymentContract() string { + raw := projectDeploymentContract(struct { + Resources []resourceRule `json:"resources"` + Runtime []runtimeRule `json:"runtime"` + MinimumDisk uint32 `json:"minimum_disk"` + }{resourceContract, runtimeContract, minimumDiskMiB}) + return "// Code generated by services/core/cmd/specification-contract from sandbox/deployment_contract.go; DO NOT EDIT.\n\nexport const deploymentContract = " + string(raw) + " as const;\n" +} + +// projectDeploymentContract encodes a projection. Struct order is checked +// first because it also defines Go's canonical JSON bytes. +func projectDeploymentContract(projection any) []byte { for _, item := range []struct { value any names []string @@ -64,13 +87,8 @@ func PythonDeploymentContract(policies map[string]DeploymentPolicy) string { } } } - raw, _ := json.Marshal(struct { - Resources []resourceRule `json:"resources"` - Runtime []runtimeRule `json:"runtime"` - Providers map[string]DeploymentPolicy `json:"providers"` - MinimumDisk uint32 `json:"minimum_disk"` - }{resourceContract, runtimeContract, policies, minimumDiskMiB}) - return "# BEGIN GENERATED DEPLOYMENT CONTRACT\n# Generated from sandbox/deployment_contract.go; do not edit.\n_CONTRACT = json.loads(" + fmt.Sprintf("%q", string(raw)) + ")\n# END GENERATED DEPLOYMENT CONTRACT" + raw, _ := json.Marshal(projection) + return raw } func resourceNames() []string { var names []string diff --git a/services/core/internal/sandbox/providers/deployment_contract_test.go b/services/core/internal/sandbox/providers/deployment_contract_test.go index 15e80b5e4..ea6477748 100644 --- a/services/core/internal/sandbox/providers/deployment_contract_test.go +++ b/services/core/internal/sandbox/providers/deployment_contract_test.go @@ -9,9 +9,13 @@ import ( "testing" ) -func TestInstallerDeploymentProjectionIsCurrent(t *testing.T) { +func TestDeploymentContractProjectionsAreCurrent(t *testing.T) { registry := Builtin() - raw, err := os.ReadFile("../../../../../deploy/node/node_spec.py") + python, err := os.ReadFile("../../../../../deploy/node/node_spec.py") + if err != nil { + t.Fatal(err) + } + typescript, err := os.ReadFile("../../../../../packages/agents-client/src/deployment-contract.ts") if err != nil { t.Fatal(err) } @@ -19,8 +23,8 @@ func TestInstallerDeploymentProjectionIsCurrent(t *testing.T) { if err != nil { t.Fatal(err) } - if !strings.Contains(string(raw), expected) { - t.Fatal("node_spec.py contract is stale; regenerate with go run ./services/core/cmd/specification-contract -write") + if !strings.Contains(string(python), expected) || string(typescript) != sandbox.TypeScriptDeploymentContract() { + t.Fatal("deployment contract projection is stale; regenerate with go run ./services/core/cmd/specification-contract -write") } } func TestDeploymentContractFixtures(t *testing.T) {