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) {