diff --git a/apps/web/src/i18n/locales/en/diagnostics.ts b/apps/web/src/i18n/locales/en/diagnostics.ts index 89b8f3741..32cdfe5b5 100644 --- a/apps/web/src/i18n/locales/en/diagnostics.ts +++ b/apps/web/src/i18n/locales/en/diagnostics.ts @@ -17,6 +17,7 @@ export const diagnostics = { server_error: "The model provider reported a server error.", invalid_request: "The model provider rejected the request.", resource_not_found: "The model provider could not find the model.", + request_timeout: "The model provider request timed out.", context_length_exceeded: "The input exceeds the model's context limit.", cyber_policy: "The model provider rejected the input under its safety policy.", connection_failed: "The connection to the model provider failed.", @@ -43,6 +44,7 @@ export const diagnostics = { server_error: "Retry later.", invalid_request: "Check the model and parameters, starting with the model on the Session page. Without a model provider set on the Session or its saved Agent, a hosted sandbox Session uses the default in Settings → Models and credentials.", resource_not_found: "Check the model on the Session page. Without a model provider set on the Session or its saved Agent, a hosted sandbox Session uses the default in Settings → Models and credentials.", + request_timeout: "Retry later.", context_length_exceeded: "Start a new Session or shorten the input.", cyber_policy: "Change the input before sending it again, or start a new Session.", connection_failed: "Check the Base URL of the Session's model provider. Without one set on the Session or its saved Agent, a hosted sandbox Session uses the default in Settings → Models and credentials.", @@ -59,7 +61,7 @@ export const diagnostics = { exitCode: "Exit code {{code}}.", httpStatus: "HTTP {{status}}.", unreachableHost: "Then check that the URL's host resolves and is reachable from the Session's sandbox, or from the host of a self-hosted Session.", - step: { setup: "setup", python: "Python dependencies", npm: "npm dependencies", file: "file preparation", skill: "Skill preparation" }, + step: { setup: "setup", python: "Python dependencies", npm: "npm dependencies", file: "file preparation", skill: "Skill preparation", harness: "Harness preparation" }, unavailable: "Failure details unavailable", unavailableHelp: "The diagnostics read failed or no longer matches this status. Refresh to check again.", checking: "Checking failure details", diff --git a/apps/web/src/i18n/locales/zh-CN/diagnostics.ts b/apps/web/src/i18n/locales/zh-CN/diagnostics.ts index 252f679a3..8df62ea55 100644 --- a/apps/web/src/i18n/locales/zh-CN/diagnostics.ts +++ b/apps/web/src/i18n/locales/zh-CN/diagnostics.ts @@ -17,6 +17,7 @@ export const diagnostics = { server_error: "模型提供商发生了服务器错误。", invalid_request: "模型提供商拒绝了请求。", resource_not_found: "模型提供商找不到该模型。", + request_timeout: "模型提供商请求超时。", context_length_exceeded: "输入超出模型的上下文上限。", cyber_policy: "模型提供商因安全策略拒绝了输入。", connection_failed: "与模型提供商的连接失败。", @@ -43,6 +44,7 @@ export const diagnostics = { server_error: "请稍后重试。", invalid_request: "请检查模型和参数,先看 Session 页显示的模型。Session 及其保存的 Agent 都未设置模型提供商时,托管沙箱 Session 使用“设置 → 模型与凭据”中的默认配置。", resource_not_found: "请检查 Session 页显示的模型。Session 及其保存的 Agent 都未设置模型提供商时,托管沙箱 Session 使用“设置 → 模型与凭据”中的默认配置。", + request_timeout: "请稍后重试。", context_length_exceeded: "请新建 Session 或缩短输入。", cyber_policy: "请修改输入后再发送,或新建 Session。", connection_failed: "请检查该 Session 所用模型提供商的 Base URL。Session 及其保存的 Agent 都未设置模型提供商时,托管沙箱 Session 使用“设置 → 模型与凭据”中的默认配置。", @@ -59,7 +61,7 @@ export const diagnostics = { exitCode: "退出码 {{code}}。", httpStatus: "HTTP {{status}}。", unreachableHost: "再确认 Session 的沙箱(自托管 Session 则为其主机)能解析并访问该 URL 的主机。", - step: { setup: "设置", python: "Python 依赖", npm: "npm 依赖", file: "文件准备", skill: "Skill 准备" }, + step: { setup: "设置", python: "Python 依赖", npm: "npm 依赖", file: "文件准备", skill: "Skill 准备", harness: "Harness 准备" }, unavailable: "失败详情不可用", unavailableHelp: "诊断读取失败或与当前状态不一致,请刷新后重试。", checking: "正在读取失败详情", diff --git a/contracts/agents-api/core-errors.md b/contracts/agents-api/core-errors.md index 821de8edf..3fe655a6d 100644 --- a/contracts/agents-api/core-errors.md +++ b/contracts/agents-api/core-errors.md @@ -118,6 +118,7 @@ The [Session diagnostics](./session-diagnostics.md) routes return these categori | `server_error` | Native server failure classification | | `invalid_request` | Native request rejection | | `resource_not_found` | Native resource/model not found | +| `request_timeout` | Native provider timeout classification | | `context_length_exceeded` | Native context limit classification | | `cyber_policy` | Native cyber policy rejection | | `connection_failed` | Native connection failure; params contain `http_status`, an integer in 100–599 or null | diff --git a/contracts/agents-api/core.openapi.yaml b/contracts/agents-api/core.openapi.yaml index ac053cee4..ff92e1b18 100644 --- a/contracts/agents-api/core.openapi.yaml +++ b/contracts/agents-api/core.openapi.yaml @@ -373,6 +373,7 @@ definitions: - server_error - invalid_request - resource_not_found + - request_timeout - context_length_exceeded - cyber_policy - connection_failed @@ -831,6 +832,7 @@ definitions: - server_error - invalid_request - resource_not_found + - request_timeout - context_length_exceeded - cyber_policy - connection_failed @@ -1746,6 +1748,7 @@ definitions: - server_overloaded - server_error - resource_not_found + - request_timeout - invalid_request type: string x-enum-varnames: @@ -1756,6 +1759,7 @@ definitions: - ProviderServerOverloaded - ProviderServerError - ProviderResourceNotFound + - ProviderRequestTimeout - ProviderInvalidRequest modelprovider.Protocol: enum: diff --git a/contracts/agents-api/harness-onboarding.md b/contracts/agents-api/harness-onboarding.md index 55a247b3b..e44277e89 100644 --- a/contracts/agents-api/harness-onboarding.md +++ b/contracts/agents-api/harness-onboarding.md @@ -236,7 +236,7 @@ Before starting, record the operation set, expected results, exclusions and stop 1. **Contract tests.** Call `agent/contracttest.TextLifecycle` from a test named `TestSharedTextLifecycle` with the adapter's prepared Executor and a deterministic native fixture; `claudesdk/executor_test.go` is the reference. It checks independent Turn streams, native owner and history continuity, durable write and application receipts, stale cancellation and healthy continuation after cancellation. `make check-runtime-contract` runs it together with the shared wire, gateway, transport and dispatcher tests, the declaration completeness check and each adapter's `TestUnsupportedExtensionsHaveNoNativeEffects`. Adapter tests also cover two ordinary Turns sharing one native process or connection and history, cancellation followed by another Turn, stale cancellation and late events, native exit, cleanup failure, input write and application receipts, unknown outcomes and fresh per-Turn usage, function, input and child-observation state. State whether a fixture is controlled or a real provider. 2. **Shared integration.** `TestThirdHarnessPublicOnboarding` runs the synthetic Harness through public Session and input admission, Worker device selection, the real WebSocket gateway, the daemon Registry and Router, neutral events and durable terminal projection. It uses a custom immutable registry in the same `execution.Policy` given to the API handler and the dispatcher, and checks applied input receipts, saved native identity, continuation, cancellation, unsupported optional requests and missing mandatory Runtime support. The fixture has no workspace, MCP or public functions, and its registration stays local to the test. It proves the integration path, not native execution. -3. **Real acceptance.** Use the pinned official Python SDK and raw HTTP against Core, a real provider API, the native Harness and a dedicated database. Verify initial execution, a warm follow-up, cancellation and restart with continuation; record native owner identity and same-condition cold and warm timing. For workspace placements also verify Files and Artifacts, workspace identity, that no credentials appear in public responses and that foreign history is rejected. `services/core/tests/official_hosted_functions_native.py` holds the shared function assertions: success and error, native file output and public Artifact bytes, same-history continuation after restart, foreign result rejection and pending-call cancellation. Synthetic or failed runs never count. The opt-in tests below run the pinned-SDK fixtures in `services/core/tests` against a real daemon and model; each runs when `OAC_TEST_OFFICIAL_SDK_PYTHON`, `OAC_TEST_NATIVE_DAEMON_BIN`, `OAC_TEST_NATIVE_PROOF_DIR` and its private options file are set. The options file is a JSON object with exactly `model` and `model_provider` (the fields of `x_agents_core.model_provider`); the test sets it as the deployment default model provider, which the fixtures' `environment: none` Sessions freeze at creation. +3. **Real acceptance.** Use the pinned official Python SDK and raw HTTP against Core, a real provider API, the native Harness and a dedicated database. Verify initial execution, a warm follow-up, cancellation and restart with continuation; record native owner identity and same-condition cold and warm timing. For workspace placements also verify Files and Artifacts, workspace identity, that no credentials appear in public responses and that foreign history is rejected. Synthetic or failed runs never count. The opt-in tests below run the pinned-SDK fixtures in `services/core/tests` against a real daemon and model; each runs when `OAC_TEST_OFFICIAL_SDK_PYTHON`, `OAC_TEST_NATIVE_DAEMON_BIN`, `OAC_TEST_NATIVE_PROOF_DIR` and its private options file are set. The options file is a JSON object with exactly `model` and `model_provider` (the fields of `x_agents_core.model_provider`); the test sets it as the deployment default model provider, which the fixtures' `environment: none` Sessions freeze at creation. 4. **Regression.** Existing Harnesses keep working. Run targeted tests, then `make check`; run `make openapi` after API changes and `make sqlc-generate` after query changes. 5. **Review.** Follow the [blind review workflow](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/CONTRIBUTING.md#review). @@ -250,7 +250,7 @@ Before starting, record the operation set, expected results, exclusions and stop | Deferred function discovery | `TestNativeToolSearchPublicExecution` | `OAC_TEST_TOOL_SEARCH_REAL_OPTIONS` | | Disabled web search and programmatic tool calling | `TestNativeToolPolicyPublicExecution` | `OAC_TEST_TOOL_POLICY_REAL_OPTIONS`, `OAC_TEST_TOOL_POLICY_ENGINE` | -Environment acceptance uses `services/core/tests/official_environment_{templates,setup,skills,plugins,plugin_mcp,composition,initial_files,network,skill_references}.py`. For composed preparation, change the Skill default and Template, delete the sources, retry and restart; verify frozen bytes, one setup execution and MCP cancellation. `official_hosted_structured_native.py` covers hosted structured output. Record exact source revisions, native versions and commands with each acceptance result. +Record exact source revisions, native versions and commands with each acceptance result. Keep provider keys in private operator files, never in commits or logs. Existing focused tests, relative to `apps/daemon/internal/agent`: diff --git a/contracts/agents-api/model-execution.md b/contracts/agents-api/model-execution.md index 7be69e624..159fd06af 100644 --- a/contracts/agents-api/model-execution.md +++ b/contracts/agents-api/model-execution.md @@ -141,7 +141,7 @@ Session creation decrypts the default of the resolved Harness and freezes it in The Harness and default-model reads include nullable `last_used_at`, `last_error_code` and `last_error_at`; PUT resets all three, even for the same bundle. - Completed root Turns record use only when their Session froze the exact current default revision. -- Failed root Turns record only the native provider codes `authentication_error`, `connection_failed`, `rate_limit_exceeded`, `usage_limit_exceeded`, `server_overloaded`, `server_error`, `resource_not_found` and `invalid_request`. Input-policy, Core or Runtime, cancelled and waiting outcomes do not count. +- Failed root Turns record only the native provider codes `authentication_error`, `connection_failed`, `rate_limit_exceeded`, `usage_limit_exceeded`, `server_overloaded`, `server_error`, `resource_not_found`, `request_timeout` and `invalid_request`. Input-policy, Core or Runtime, cancelled and waiting outcomes do not count. - A success keeps the earlier error; comparing the timestamps is a display convention. - Errors are throttled for 30 seconds regardless of code, and ordinary successes for 30 seconds; the first success after an error records recovery at once. For an unchanged revision this allows at most three effective writes in any 30-second window of database time. - Each observation has a one-second budget, including pool and row-lock acquisition, and cannot change the committed Turn. A crash, failure or throttle can omit the latest observation. diff --git a/contracts/agents-api/session-diagnostics.md b/contracts/agents-api/session-diagnostics.md index 0b77ab561..a8b06aa21 100644 --- a/contracts/agents-api/session-diagnostics.md +++ b/contracts/agents-api/session-diagnostics.md @@ -25,7 +25,7 @@ The object is `core.session_diagnostics`, with `session_id`, the official Sessio A hosted provisioning failure takes precedence over input activity, and input activity over the latest root Turn; the public Session response uses the same precedence. Private outcome text, native messages, provider bodies, command text, paths and credentials are never included. Unknown outcome codes become `internal_error`; no prefix matching or reason-text parsing is used. -Provisioning parameters come from the confirmed structured receipt, persisted in the failure transaction that settles the Environment and its input and records the events. `step` is `setup`, `python`, `npm`, `file`, `skill` or null. `index` is a nonnegative JSON-safe integer for setup only, otherwise null. `exit_code` is 1 to 255 for script steps, otherwise null. Details that were not recorded remain null. These private fields do not change the public failure reason or SSE. +Provisioning parameters come from the confirmed structured receipt, persisted in the failure transaction that settles the Environment and its input and records the events. `step` is `setup`, `python`, `npm`, `file`, `skill`, `harness` or null. `index` is a nonnegative JSON-safe integer for setup only, otherwise null. `exit_code` is 1 to 255 for script steps, otherwise null. Details that were not recorded remain null. These private fields do not change the public failure reason or SSE. ## Turn snapshot and Item receipt timing diff --git a/contracts/agents-api/zh/core-errors.md b/contracts/agents-api/zh/core-errors.md index 8a869a4af..110a55188 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: 1532078430d8e8a5570e652db60a99b282d57897ca6f3360cd0b1a94a948d02d +source_hash: fbdf667a44da9eb95dae54ca7fc54129d7f8d1b50e1d7e864f00a8ce1794860b --- `/core/v1` 上的错误使用此封装结构。`message` 是安全的英文文本;`code` 和 `param` 可以为 null。客户端依据稳定的 `code` 和可选的 `param` 进行处理,对未知代码显示 `message`,绝不解析消息,也绝不自动重试被拒绝的写操作。 @@ -120,6 +120,7 @@ Web 的控制台服务器在 `/core` 路径上发生自身故障时使用此封 | `server_error` | 原生服务器故障分类 | | `invalid_request` | 原生请求被拒绝 | | `resource_not_found` | 未找到原生资源或模型 | +| `request_timeout` | 原生提供商超时分类 | | `context_length_exceeded` | 原生上下文限制分类 | | `cyber_policy` | 原生网络安全策略拒绝 | | `connection_failed` | 原生连接故障;params 包含 `http_status`,其值为 100–599 范围内的整数或 null | diff --git a/contracts/agents-api/zh/harness-onboarding.md b/contracts/agents-api/zh/harness-onboarding.md index 1ea07e9ff..c2cab3c52 100644 --- a/contracts/agents-api/zh/harness-onboarding.md +++ b/contracts/agents-api/zh/harness-onboarding.md @@ -1,7 +1,7 @@ --- title: "Harness 协议" source: contracts/agents-api/harness-onboarding.md -source_hash: 7658191e20060c3227a3604a5095383bcddded2ea71970d0bdde74421396b36d +source_hash: b3bd31e1272d7788db048b986cae774f4bc52902e91669ef7a87f86594014e18 --- **Harness** 是一种运行模型和工具循环的原生代理引擎(Codex、Claude Code、MiniMax Code);**Harness 适配器**将 Runtime 的 Executor 和 Turn 契约转换到该引擎的 SDK 或协议。Harness 协议是 Runtime 与适配器之间的契约:适配器接口及其生命周期义务、声明、注册和验收。本文面向添加或维护 Harness 的开发者。 @@ -238,7 +238,7 @@ Runtime 对每个 Turn 的限制(结算等待、回执发送预算和输出通 1. **契约测试。** 在名为 `TestSharedTextLifecycle` 的测试中,使用适配器准备好的 Executor 和确定性的原生夹具调用 `agent/contracttest.TextLifecycle`;`claudesdk/executor_test.go` 是参考实现。它检查独立的 Turn 流、原生所有者和历史连续性、持久化写入与应用回执、过期取消,以及取消后的健康继续执行。`make check-runtime-contract` 会将它与共享线协议、gateway、传输层和调度器测试、声明完整性检查以及每个适配器的 `TestUnsupportedExtensionsHaveNoNativeEffects` 一起运行。适配器测试还覆盖两个普通 Turn 共享一个原生进程或连接和历史、取消后执行另一个 Turn、过期取消和迟到事件、原生退出、清理失败、输入写入与应用回执、未知结果,以及每 Turn 新鲜的 usage、函数、输入和子项观察状态。必须说明夹具是受控夹具还是真实提供商。 2. **共享集成。** `TestThirdHarnessPublicOnboarding` 让合成 Harness 通过公共 Session 和输入准入、Worker 设备选择、真实 WebSocket gateway、daemon Registry 和 Router、中立事件以及持久化终态投影运行。它在与 API 处理程序和调度器相同的 `execution.Policy` 中使用自定义不可变注册表,并检查已应用输入回执、已保存原生身份、继续执行、取消、不受支持的可选请求以及缺少强制 Runtime 支持。该夹具没有工作区、MCP 或公共函数,其注册仅保留在测试本地。它证明的是集成路径,而不是原生执行。 -3. **真实验收。** 使用锁定的官方 Python SDK 和针对 Core 的原始 HTTP、真实提供商 API、原生 Harness 以及专用数据库。验证初始执行、热后续执行、取消以及带继续执行的重启;记录原生所有者身份以及相同条件下的冷启动和热运行时间。对于工作区放置方式,还要验证 Files 和 Artifacts、工作区身份、公开响应中未出现凭据,以及外部历史会被拒绝。`services/core/tests/official_hosted_functions_native.py` 保存共享函数断言:成功和错误、原生文件输出和公共 Artifact 字节、重启后的同历史继续执行、外部结果拒绝以及待处理调用取消。合成运行或失败运行绝不计入。下面的选择性测试会在 `services/core/tests` 中针对真实 daemon 和模型运行锁定 SDK 夹具;设置 `OAC_TEST_OFFICIAL_SDK_PYTHON`、`OAC_TEST_NATIVE_DAEMON_BIN`、`OAC_TEST_NATIVE_PROOF_DIR` 及其私有选项文件后,每项测试才会运行。选项文件是一个 JSON 对象,恰好包含 `model` 和 `model_provider`(即 `x_agents_core.model_provider` 的字段);测试会将其设置为部署默认模型提供商,而夹具的 `environment: none` Session 会在创建时将其冻结。 +3. **真实验收。** 使用锁定的官方 Python SDK 和针对 Core 的原始 HTTP、真实提供商 API、原生 Harness 以及专用数据库。验证初始执行、热后续执行、取消以及带继续执行的重启;记录原生所有者身份以及相同条件下的冷启动和热运行时间。对于工作区放置方式,还要验证 Files 和 Artifacts、工作区身份、公开响应中未出现凭据,以及外部历史会被拒绝。合成运行或失败运行绝不计入。下面的选择性测试会在 `services/core/tests` 中针对真实 daemon 和模型运行锁定 SDK 夹具;设置 `OAC_TEST_OFFICIAL_SDK_PYTHON`、`OAC_TEST_NATIVE_DAEMON_BIN`、`OAC_TEST_NATIVE_PROOF_DIR` 及其私有选项文件后,每项测试才会运行。选项文件是一个 JSON 对象,恰好包含 `model` 和 `model_provider`(即 `x_agents_core.model_provider` 的字段);测试会将其设置为部署默认模型提供商,而夹具的 `environment: none` Session 会在创建时将其冻结。 4. **回归。** 现有 Harness 必须继续正常工作。先运行定向测试,然后运行 `make check`;API 更改后运行 `make openapi`,查询更改后运行 `make sqlc-generate`。 5. **审查。** 遵循 [blind review workflow](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/CONTRIBUTING.md#review)。 @@ -252,7 +252,7 @@ Runtime 对每个 Turn 的限制(结算等待、回执发送预算和输出通 | 延迟函数发现 | `TestNativeToolSearchPublicExecution` | `OAC_TEST_TOOL_SEARCH_REAL_OPTIONS` | | 禁用 Web 搜索和程序化工具调用 | `TestNativeToolPolicyPublicExecution` | `OAC_TEST_TOOL_POLICY_REAL_OPTIONS`、`OAC_TEST_TOOL_POLICY_ENGINE` | -Environment 验收使用 `services/core/tests/official_environment_{templates,setup,skills,plugins,plugin_mcp,composition,initial_files,network,skill_references}.py`。对于组合式准备,请更改 Skill 默认值和模板,删除源文件,重试并重启;验证冻结字节、一次 setup 执行和 MCP 取消。`official_hosted_structured_native.py` 覆盖托管结构化输出。随每项验收结果记录精确源修订版本、原生版本和命令。 +随每项验收结果记录精确源修订版本、原生版本和命令。 将提供商密钥保存在私有操作员文件中,绝不能放入提交或日志。相对于 `apps/daemon/internal/agent` 的现有定向测试如下: diff --git a/contracts/agents-api/zh/model-execution.md b/contracts/agents-api/zh/model-execution.md index cdfde9b40..3436cd82d 100644 --- a/contracts/agents-api/zh/model-execution.md +++ b/contracts/agents-api/zh/model-execution.md @@ -1,7 +1,7 @@ --- title: "模型执行" source: contracts/agents-api/model-execution.md -source_hash: 266d585b72187dfcb7d9b3b328b9a941079bc26495f4e4b991d936f604229bf1 +source_hash: 1ceb0a7799a57aa1e53b6354b0c4c6acd1002e328430c7a98dd0713f9f7a334b --- 每个 Session 都运行一个 Harness,并使用一个模型提供商。Core 通过三个固定版本上游协议未定义的 Core 扩展来选择它们:`x_agents_core.harness` 选择 Harness,`x_agents_core.model_provider` 提供端点和密钥,`x_agents_core.harness_config` 携带原生模型参数。Core 没有提供商目录、模型别名解析或产品权限模型;除 Session 和已保存 Agent 配置包外,唯一存储的配置包是每个 Harness 的一个 [deployment default](#deployment-defaults)。本文档定义 Harness—模型提供商协议:[`internal/modelprovider/config.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/modelprovider/config.go) 负责验证冻结的提供商连接,每个 Harness 则通过 [`internal/harnessconfig/harness.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/harnessconfig/harness.go) 声明其协议和原生参数。 @@ -143,7 +143,7 @@ PUT 要求提供 `model` 和完整的 `model_provider`;`harness_config` 默认 Harness 和默认模型读取结果包含可空的 `last_used_at`、`last_error_code` 和 `last_error_at`;即使配置包未发生变化,PUT 也会重置这三项。 - 只有当 Session 冻结的正是当前默认修订版时,已完成的根 Turn 才会记录使用情况。 -- 失败的根 Turn 仅记录原生提供商错误码 `authentication_error`、`connection_failed`、`rate_limit_exceeded`、`usage_limit_exceeded`、`server_overloaded`、`server_error`、`resource_not_found` 和 `invalid_request`。输入策略、Core 或 Runtime 导致的失败,以及 cancelled 和 waiting 结果均不计入。 +- 失败的根 Turn 仅记录原生提供商错误码 `authentication_error`、`connection_failed`、`rate_limit_exceeded`、`usage_limit_exceeded`、`server_overloaded`、`server_error`、`resource_not_found`、`request_timeout` 和 `invalid_request`。输入策略、Core 或 Runtime 导致的失败,以及 cancelled 和 waiting 结果均不计入。 - 成功不会清除较早的错误;比较时间戳只是一种显示约定。 - 无论错误码是什么,错误观测都会节流 30 秒,常规成功观测也会节流 30 秒;错误后的首次成功会立即记录恢复。对于未变化的修订版,这意味着数据库时间的任意 30 秒窗口内最多允许三次有效写入。 - 每次观测的预算为 1 秒,包括获取连接池资源和行锁的时间,并且无法更改已提交的 Turn。崩溃、失败或节流都可能导致最新观测缺失。 diff --git a/contracts/agents-api/zh/session-diagnostics.md b/contracts/agents-api/zh/session-diagnostics.md index 2af4df4a9..d81427fcc 100644 --- a/contracts/agents-api/zh/session-diagnostics.md +++ b/contracts/agents-api/zh/session-diagnostics.md @@ -1,7 +1,7 @@ --- title: "Session 诊断" source: contracts/agents-api/session-diagnostics.md -source_hash: 705486548640818b9b17065f31e7b0f3aede38ae4e66d93d5483c5669d9e0613 +source_hash: fe7192fda8191ceca39aa19bed83f37f79b381638a84ca931b51e3d671e4251b --- 管理员通过两个需要 Core 密钥的路由读取 Session 或 Turn 失败的原因,它们报告失败类别和 Item 接收时间。 @@ -27,7 +27,7 @@ source_hash: 705486548640818b9b17065f31e7b0f3aede38ae4e66d93d5483c5669d9e0613 托管配置失败优先于输入活动,输入活动优先于最新根 Turn;公开 Session 响应使用相同优先级。不会包含私有结果文本、原生消息、提供商响应体、命令文本、路径或凭据。未知结果代码变为 `internal_error`;不使用前缀匹配或原因文本解析。 -配置参数来自已确认的结构化回执,在结算 Environment 及其输入并记录事件的失败事务中持久化。`step` 为 `setup`、`python`、`npm`、`file`、`skill` 或 null。`index` 仅对 setup 为 JSON 安全的非负整数,其他情况为 null。`exit_code` 对脚本步骤为 1 至 255,其他情况为 null。未记录的详情保持 null。这些私有字段不会改变公开失败原因或 SSE。 +配置参数来自已确认的结构化回执,在结算 Environment 及其输入并记录事件的失败事务中持久化。`step` 为 `setup`、`python`、`npm`、`file`、`skill`、`harness` 或 null。`index` 仅对 setup 为 JSON 安全的非负整数,其他情况为 null。`exit_code` 对脚本步骤为 1 至 255,其他情况为 null。未记录的详情保持 null。这些私有字段不会改变公开失败原因或 SSE。 ## Turn 快照与 Item 回执时间 {#turn-snapshot-and-item-receipt-timing} diff --git a/deploy/README.md b/deploy/README.md index 397a74409..2b2861bcc 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -18,7 +18,7 @@ An existing directory runs the release named by `OAC_REVISION` in its `compose.y `install.py` resumes a source-bundle installation of its own release under the same lock; another release is refused, and `--public-url` is required only for a new directory ([Offline hosts](../docs/getting-started/install-options.md#offline-hosts)). -`install.py` prepares a new installation in a `.staging-*` directory inside the installation directory: the node payload, `.env`, `oac` and both Compose files. After `docker compose config` accepts them, it moves them into place, `compose.yaml` last. A failed or killed run leaves nothing to clean up by hand; rerun the same command ([Offline hosts](../docs/getting-started/install-options.md#offline-hosts)). A directory with other contents but no `compose.yaml` is refused. +`install.py` prepares a new installation in a `.staging-*` directory inside the installation directory: the node payload, `.env`, `oac` and both Compose files. After `docker compose config` accepts them, it moves them into place, `compose.yaml` last. An earlier failure removes the stage, and the directory too when the run created it, so the same command can be rerun; the next run also removes a stage left by a killed run ([Offline hosts](../docs/getting-started/install-options.md#offline-hosts)). A directory with other contents but no `compose.yaml` is refused. The host `oac` command also implements `apply`, `core-key`, `rotate-core-key` and `uninstall`. `apply` runs init, which finishes a first start that stopped before it and refuses a data volume committed to another release, then checks Core configuration before starting services and recreating those that changed. Reading the key executes `oac-web core-key` in Web. Rotation runs in the initialization container, where Linux file ownership is identical on every host, then restarts Core and Web. Start, stop and logs use `docker compose`; `oac uninstall` removes the installation, its data volume, the lock and, on Windows, the lock file beside the removed directory. The initialization image verifies and copies bundled node metadata without network access. Its receipt authenticates immutable metadata and credentials; the operator-rotatable Core key and its digest remain mutable. No service receives a Docker socket. Initialization emits structured step logs without credential values; view them with `docker compose logs --timestamps init`. diff --git a/docs/getting-started/operations.md b/docs/getting-started/operations.md index 3a97bcb7f..2898ea322 100644 --- a/docs/getting-started/operations.md +++ b/docs/getting-started/operations.md @@ -191,7 +191,7 @@ Keep application ingress and Core/node services stopped while restoring. Fence o Restore matching secrets and database state, the complete independent filesystem namespaces and any required node identities/provider stores before starting Core. Preserve the database's configuration and namespace UUIDs, object identities and deletion markers; do not run first-time namespace initialization over restored storage. Restore a quota mode filesystem by writing its block-level copy to the backing device and mounting it with `prjquota` at the configured mount point; never run `oac-workspace-quota init` over it, and do not mount the copy beside the original, which has the same filesystem UUID. Reestablish the configured mount paths and service UIDs, then validate the mounted filesystem, ownership and xattrs from each participating service context. [Set up workspace storage](./workspace-storage.md) owns deployment setup. -A cold restore resumes only qualified native history after new compute admission. A backup containing retained checkpoints also depends on the matching native runtime, node identity, provider store and external filesystem identities. File-level restore can change inode identity and prevent strict checkpoint restore even when file bytes match. Memory snapshots are not portable and restoration onto an arbitrary replacement node is unsupported; preserve unresolved ownership rather than substituting a new sandbox. +A cold restore resumes only qualified native history after new compute admission. A backup containing retained checkpoints also depends on the matching native runtime, node identity, provider store and external filesystem identities. File-level restore can change inode identity and prevent strict checkpoint restore even when file bytes match. This procedure restores onto the original node identities; [checkpoint transfer](../sandbox-provider.md#checkpoint-transfer) defines when another node can restore a checkpoint. Preserve unresolved ownership rather than substituting a new sandbox. Start the database, Core and node services only after their restored dependencies are complete; keep ingress restricted while checking them. In an isolated rehearsal, verify a retained Session's files and native continuation on fresh compute where qualified, confirm that deleted objects cannot be reopened, and check that no old writer can access the restored namespace. Record the results before relying on the backup for recovery. diff --git a/docs/runtime-protocol.md b/docs/runtime-protocol.md index 1cfa33362..c8c1adb64 100644 --- a/docs/runtime-protocol.md +++ b/docs/runtime-protocol.md @@ -295,7 +295,7 @@ Retry only an operation whose own contract makes retry safe. A retired preparati An adapter may add `code` and `http_status` to a Run's `error` frame. They are optional neutral metadata, not a terminal event or a public error contract; the frame's text, Usage, `done`, native Session identity and cancellation receipts keep their order and meaning. -The accepted codes are `authentication_error`, `rate_limit_exceeded`, `usage_limit_exceeded`, `server_overloaded`, `server_error`, `invalid_request`, `resource_not_found`, `context_length_exceeded`, `cyber_policy` and `connection_failed` ([`engine_failure.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/agentdaemon/proto/engine_failure.go)). Only `connection_failed` keeps `http_status`, and only an integer from 100 to 599; every other status is discarded. A missing, malformed or unknown value leaves the error unclassified without discarding Usage or `done`. +The accepted codes are `authentication_error`, `rate_limit_exceeded`, `usage_limit_exceeded`, `server_overloaded`, `server_error`, `invalid_request`, `resource_not_found`, `request_timeout`, `context_length_exceeded`, `cyber_policy` and `connection_failed` ([`engine_failure.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/agentdaemon/proto/engine_failure.go)). Only `connection_failed` keeps `http_status`, and only an integer from 100 to 599; every other status is discarded. A missing, malformed or unknown value leaves the error unclassified without discarding Usage or `done`. Core stores accepted values in the Turn outcome as `engine_error_code` and `engine_http_status`. A Run may carry more than one `error` frame, such as an adapter's classified failure followed by the Runtime's own settlement failure; a frame without an accepted code leaves the stored classification in place, and a later accepted code replaces it. The classification is subordinate to the terminal status and Core's `error_code`: it cannot turn a completed or cancelled Turn into a failure, hide an incomplete event stream, or override a persistence or cancellation-receipt failure. Normal delivery and terminal journal draining use the same extraction. [Session diagnostics](../contracts/agents-api/session-diagnostics.md) expose the category only for a failed Turn whose Core error is `engine_failed`. The [Codex](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/deploy/codex/README.md) and [Claude Code](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/deploy/claude/README.md) adapter guides give each Harness's mapping. An adapter classifies only from structured error data: the Harness's own error variant or status, or a model provider's JSON error body that the Harness passes through, whose own `error.code` or `error.type` may name an accepted code. It never classifies from error prose. diff --git a/docs/web/index.md b/docs/web/index.md index 80887254d..df93900a9 100644 --- a/docs/web/index.md +++ b/docs/web/index.md @@ -27,7 +27,7 @@ Signing in opens the Overview. While any step is still to do, its **Getting star | Resources | Agents, Environment templates, Skills, Files, Vaults | Inspection and permitted deletion, with the Project and the creating key of each resource | | Platform | Projects and keys | Create, rename and archive Projects; issue and revoke keys; each Project's usage, write history and how to call the API | | Platform | Nodes | Add, edit and remove microsandbox or Docker nodes; each node's readiness, active and retained limits, host CPU and memory with their history, free disk, and allocations with elapsed time in each compute phase | -| Platform | Settings | Platform configuration, one group per [runtime setting](../configuration.md#runtime-settings-web) | +| Platform | Settings | **Sandbox and scheduling**, **Workspace storage**, **Models and credentials**, and **Service and deployment**; see [configuration](../configuration.md#runtime-settings-web) | Missing data is shown as missing (—), never as zero. [Console API usage](./console-api-usage.md) lists what each page reads and how its figures are bounded. diff --git a/docs/zh/getting-started/operations.md b/docs/zh/getting-started/operations.md index 6467c2309..4e446de16 100644 --- a/docs/zh/getting-started/operations.md +++ b/docs/zh/getting-started/operations.md @@ -1,7 +1,7 @@ --- title: "运维" source: docs/getting-started/operations.md -source_hash: 4fd70597517560cfab44ba9b10adedf7713c3a9c5441f0babd07531330174fdc +source_hash: e3e28c2904453b38465e84d955e45ffae533b202f1ce0b88bc18c4c3efe6a773 --- Core 安装运维人员的日常任务:`oac` 命令、服务健康状态、Core 密钥、备份和卸载。节点有[专门的指南](./nodes.md),[故障排查](./troubleshooting.md)列出了安装程序和访问方面的提示信息。设置见[配置参考](../configuration.md)。 @@ -193,7 +193,7 @@ docker compose -f "$HOME/.oac/core/compose.yaml" exec -T database \ 启动 Core 前,恢复相匹配的 secrets 和数据库状态、完整的独立文件系统命名空间,以及任何所需的节点标识和提供商存储。保留数据库中的配置和命名空间 UUID、对象标识及删除标记;不要对恢复后的存储执行首次命名空间初始化。恢复配额模式文件系统时,将其块级副本写入后备设备,并以 `prjquota` 挂载到配置的挂载点;绝不要对其运行 `oac-workspace-quota init`,也不要将副本与原文件系统同时挂载,二者具有相同的文件系统 UUID。重新建立配置中的挂载路径和服务 UID,然后从每个参与服务的上下文验证所挂载文件系统、所有权及 xattr。[设置工作区存储](./workspace-storage.md)负责定义部署设置。 -冷恢复仅在新计算资源准入后续接具备资格的原生历史。包含保留检查点的备份还依赖匹配的原生 Runtime、节点标识、提供商存储和外部文件系统标识。文件级恢复可能改变 inode 标识,即使文件字节一致,也可能阻止严格检查点恢复。内存快照不可移植,也不支持恢复到任意替代节点;应保留未解决的归属,而不是替换为新沙箱。 +冷恢复仅在新计算资源准入后续接具备资格的原生历史。包含保留检查点的备份还依赖匹配的原生 Runtime、节点标识、提供商存储和外部文件系统标识。文件级恢复可能改变 inode 标识,即使文件字节一致,也可能阻止严格检查点恢复。此流程恢复到原有节点标识;其他节点何时可以恢复检查点由[检查点转移](../sandbox-provider.md#checkpoint-transfer)定义。应保留未解决的归属,而不是替换为新沙箱。 恢复依赖全部完整后才启动 database、Core 和节点服务;检查期间保持入口受限。在隔离演练中,验证保留 Session 的文件和具备资格时在新计算资源上的原生续接,确认已删除对象无法重新打开,并检查旧写入方均无法访问恢复后的命名空间。在依赖备份进行恢复前记录结果。 diff --git a/docs/zh/runtime-protocol.md b/docs/zh/runtime-protocol.md index d726ddb37..63c06f23e 100644 --- a/docs/zh/runtime-protocol.md +++ b/docs/zh/runtime-protocol.md @@ -1,7 +1,7 @@ --- title: "Core–Runtime 协议" source: docs/runtime-protocol.md -source_hash: 56e2831e66c8638c833d1d296cce2d6597c6a808f34a60a29bde0a46d67b8c59 +source_hash: bf12f97d6420804e057223a02609bc4295d9169a834e2758bc0210ef70923efa --- Core–Runtime 协议连接 Core 与 Runtime daemon,定义 daemon 连接上消息的含义和顺序。托管和自托管 Runtime 使用同一协议。 @@ -297,7 +297,7 @@ transport 与 execution 结果分开。Core 唯一 Run 订阅入口是 `Subscrib adapter 可以给 Run 的 `error` frame 添加 `code` 和 `http_status`。它们是可选的中性元数据,不是终结事件或公开错误契约;frame 文本、Usage、`done`、原生 Session 身份和取消回执保持原顺序与含义。 -接受的 code 为 `authentication_error`、`rate_limit_exceeded`、`usage_limit_exceeded`、`server_overloaded`、`server_error`、`invalid_request`、`resource_not_found`、`context_length_exceeded`、`cyber_policy` 和 `connection_failed`([`engine_failure.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/agentdaemon/proto/engine_failure.go))。仅 `connection_failed` 保留 `http_status`,且只能是 100 到 599 的整数;其他 status 都丢弃。值缺失、格式错误或未知时保持未分类错误,不丢弃 Usage 或 `done`。 +接受的 code 为 `authentication_error`、`rate_limit_exceeded`、`usage_limit_exceeded`、`server_overloaded`、`server_error`、`invalid_request`、`resource_not_found`、`request_timeout`、`context_length_exceeded`、`cyber_policy` 和 `connection_failed`([`engine_failure.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/agentdaemon/proto/engine_failure.go))。仅 `connection_failed` 保留 `http_status`,且只能是 100 到 599 的整数;其他 status 都丢弃。值缺失、格式错误或未知时保持未分类错误,不丢弃 Usage 或 `done`。 Core 在 Turn outcome 中将接受的值保存为 `engine_error_code` 和 `engine_http_status`。一个 Run 可以有多个 `error` frame,例如 adapter 的分类失败之后跟着 Runtime 自己的结算失败;没有接受 code 的 frame 不改变已保存的分类,后来接受的 code 会替换它。分类从属于终结状态和 Core 的 `error_code`:不能将已完成或已取消 Turn 变成失败、隐藏不完整事件流,或覆盖持久化或取消回执失败。正常 delivery 和 terminal journal draining 使用同一提取逻辑。[Session 诊断](../../contracts/agents-api/zh/session-diagnostics.md)仅为 Core error 为 `engine_failed` 的失败 Turn 暴露类别。[Codex](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/deploy/codex/README.md) 和 [Claude Code](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/deploy/claude/README.md) adapter 指南给出各 Harness 的映射。adapter 只依据结构化错误数据分类:Harness 自身的错误类型或状态,或 Harness 原样转交的模型提供商 JSON 错误 body,其 `error.code` 或 `error.type` 可以给出接受的 code。adapter 不依据错误文字分类。 diff --git a/docs/zh/web/index.md b/docs/zh/web/index.md index 703063f8d..80d3906d0 100644 --- a/docs/zh/web/index.md +++ b/docs/zh/web/index.md @@ -1,7 +1,7 @@ --- title: "Web 控制台" source: docs/web/index.md -source_hash: d8f267f45ad305a3c7280d98333aaa1fdfdf4d5cc5082a5781d5de7ab1bd56b4 +source_hash: c28ab8e8f10d5cb7ac6aea42ef4f9a111007a3833bf7949c037b2ce71a50ac19 --- Web 是 OpenAgentCore 安装的管理员控制台:运维人员用 Core 密钥登录,查看健康状态和用量,检查应用创建的内容,并管理项目、密钥、节点和设置。应用使用项目 API 密钥调用 `/v1`,不使用 Web。 @@ -29,7 +29,7 @@ Web 是 OpenAgentCore 安装的管理员控制台:运维人员用 Core 密钥 | 资源 | Agent、Environment 模板、Skills、文件、Vault | 检查和允许的删除操作,显示各资源所属项目及创建密钥 | | 平台 | 项目与密钥 | 创建、重命名和归档项目;签发与撤销密钥;各项目的用量、写入历史和 API 调用方法 | | 平台 | 节点 | 添加、编辑和移除 microsandbox 或 Docker 节点;各节点的就绪状态、活跃与保留上限、主机 CPU 与内存及其历史、可用磁盘,以及分配及其在各计算阶段的已用时间 | -| 平台 | 设置 | 平台配置,每个[运行时设置](../configuration.md#runtime-settings-web)一组 | +| 平台 | 设置 | **沙箱与调度**、**工作区存储**、**模型与凭据** 和 **服务与部署**;参阅[配置](../configuration.md#runtime-settings-web) | 缺失数据展示为缺失(—),不会当作零。[控制台 API 使用](console-api-usage.md)列出各页面读取内容及统计边界。 diff --git a/internal/agentdaemon/proto/engine_failure.go b/internal/agentdaemon/proto/engine_failure.go index 1d6f29551..03574f499 100644 --- a/internal/agentdaemon/proto/engine_failure.go +++ b/internal/agentdaemon/proto/engine_failure.go @@ -6,7 +6,7 @@ import "encoding/json" // metadata from newer or malformed peers never changes terminal/usage decoding. func NormalizeEngineFailure(code string, status *int) (string, *int) { switch code { - case "authentication_error", "rate_limit_exceeded", "usage_limit_exceeded", "server_overloaded", "server_error", "invalid_request", "resource_not_found", "context_length_exceeded", "cyber_policy": + case "authentication_error", "rate_limit_exceeded", "usage_limit_exceeded", "server_overloaded", "server_error", "invalid_request", "resource_not_found", "request_timeout", "context_length_exceeded", "cyber_policy": return code, nil case "connection_failed": if status != nil && *status >= 100 && *status <= 599 { diff --git a/packages/agents-client/src/generated/core-api.ts b/packages/agents-client/src/generated/core-api.ts index 0db806dad..9af63ee48 100644 --- a/packages/agents-client/src/generated/core-api.ts +++ b/packages/agents-client/src/generated/core-api.ts @@ -224,7 +224,7 @@ export interface DiagnosticFailure { params: Record; } export const diagnosticFailureFields = ["code", "failed_at", "params"] as const; -export const diagnosticFailureCodeValues = ["harness_error", "model_provider_required", "runtime_unavailable", "runtime_disconnected", "runtime_preparation_failed", "execution_interrupted", "delivery_unconfirmed", "input_rejected", "executor_protocol_error", "core_storage_failed", "internal_error", "environment_connection_timeout", "environment_unavailable", "environment_provisioning_failed", "authentication_error", "rate_limit_exceeded", "usage_limit_exceeded", "server_overloaded", "server_error", "invalid_request", "resource_not_found", "context_length_exceeded", "cyber_policy", "connection_failed"] as const; +export const diagnosticFailureCodeValues = ["harness_error", "model_provider_required", "runtime_unavailable", "runtime_disconnected", "runtime_preparation_failed", "execution_interrupted", "delivery_unconfirmed", "input_rejected", "executor_protocol_error", "core_storage_failed", "internal_error", "environment_connection_timeout", "environment_unavailable", "environment_provisioning_failed", "authentication_error", "rate_limit_exceeded", "usage_limit_exceeded", "server_overloaded", "server_error", "invalid_request", "resource_not_found", "request_timeout", "context_length_exceeded", "cyber_policy", "connection_failed"] as const; export type DiagnosticFailureCode = (typeof diagnosticFailureCodeValues)[number]; export const diagnosticSourceValues = ["turn", "environment", "environment_input"] as const; export type DiagnosticSource = (typeof diagnosticSourceValues)[number]; @@ -561,7 +561,7 @@ export interface ProjectsPage { has_more: boolean; } export const projectsPageFields = ["data", "has_more"] as const; -export const providerErrorCodeValues = ["authentication_error", "connection_failed", "rate_limit_exceeded", "usage_limit_exceeded", "server_overloaded", "server_error", "resource_not_found", "invalid_request"] as const; +export const providerErrorCodeValues = ["authentication_error", "connection_failed", "rate_limit_exceeded", "usage_limit_exceeded", "server_overloaded", "server_error", "resource_not_found", "request_timeout", "invalid_request"] as const; export type ProviderErrorCode = (typeof providerErrorCodeValues)[number]; export interface Range { end: string; @@ -870,7 +870,7 @@ export interface SessionDiagnosticFailure { } export const sessionDiagnosticFailureFields = ["code", "failed_at", "params", "source", "turn_id"] as const; export const sessionDiagnosticFailureRequired = ["code", "failed_at", "params", "source"] as const; -export const sessionDiagnosticFailureCodeValues = ["harness_error", "model_provider_required", "runtime_unavailable", "runtime_disconnected", "runtime_preparation_failed", "execution_interrupted", "delivery_unconfirmed", "input_rejected", "executor_protocol_error", "core_storage_failed", "internal_error", "environment_connection_timeout", "environment_unavailable", "environment_provisioning_failed", "authentication_error", "rate_limit_exceeded", "usage_limit_exceeded", "server_overloaded", "server_error", "invalid_request", "resource_not_found", "context_length_exceeded", "cyber_policy", "connection_failed"] as const; +export const sessionDiagnosticFailureCodeValues = ["harness_error", "model_provider_required", "runtime_unavailable", "runtime_disconnected", "runtime_preparation_failed", "execution_interrupted", "delivery_unconfirmed", "input_rejected", "executor_protocol_error", "core_storage_failed", "internal_error", "environment_connection_timeout", "environment_unavailable", "environment_provisioning_failed", "authentication_error", "rate_limit_exceeded", "usage_limit_exceeded", "server_overloaded", "server_error", "invalid_request", "resource_not_found", "request_timeout", "context_length_exceeded", "cyber_policy", "connection_failed"] as const; export type SessionDiagnosticFailureCode = (typeof sessionDiagnosticFailureCodeValues)[number]; export interface SessionDiagnostics { failure: SessionDiagnosticFailure | null; diff --git a/packages/agents-client/src/session-diagnostics.test.ts b/packages/agents-client/src/session-diagnostics.test.ts index bfb0fd176..1f6d32f6f 100644 --- a/packages/agents-client/src/session-diagnostics.test.ts +++ b/packages/agents-client/src/session-diagnostics.test.ts @@ -30,6 +30,11 @@ it("preserves historical nulls and finite safe provisioning parameters", () => { expect(projectTurnDiagnostics(snapshot, id.toUpperCase(), turn.toUpperCase())).toEqual(snapshot); }); +it("accepts the Harness step Core records when the selected Harness is unavailable", () => { + const value = { ...session, failure: { source: "environment", code: "environment_provisioning_failed", params: { step: "harness", index: null, exit_code: null }, failed_at: null } }; + expect(projectSessionDiagnostics(value, id)).toEqual(value); +}); + it.each([ (v: any) => { v.failure.error = "secret-canary"; }, (v: any) => { v.failure.code = "input_secret-canary"; }, @@ -59,7 +64,7 @@ it.each([ expect(() => projectSessionDiagnostics(value, id)).toThrow(); }); -it.each(["authentication_error", "rate_limit_exceeded", "usage_limit_exceeded", "server_overloaded", "server_error", "invalid_request", "resource_not_found", "context_length_exceeded", "cyber_policy", "connection_failed"])("projects finite native category %s", (code) => { +it.each(["authentication_error", "rate_limit_exceeded", "usage_limit_exceeded", "server_overloaded", "server_error", "invalid_request", "resource_not_found", "request_timeout", "context_length_exceeded", "cyber_policy", "connection_failed"])("projects finite native category %s", (code) => { const params = code === "connection_failed" ? { http_status: 503 } : {}; const failed = { ...failure, code, params }; expect(projectTurnDiagnostics({ ...snapshot, failure: failed }, id, turn).failure).toEqual(failed); diff --git a/packages/agents-client/src/session-diagnostics.ts b/packages/agents-client/src/session-diagnostics.ts index 0dff746ff..a3c470b7b 100644 --- a/packages/agents-client/src/session-diagnostics.ts +++ b/packages/agents-client/src/session-diagnostics.ts @@ -12,7 +12,7 @@ import { // ItemDiagnosticTiming holds Core receipt intervals, distinct from the public tool-reported duration_ms. export type { DiagnosticFailureCode, ItemDiagnosticTiming }; // The schema types params as a free-form object; each code that carries params has one of these shapes. -export type ProvisioningFailureParams = { step: "setup" | "python" | "npm" | "file" | "skill" | null; index: number | null; exit_code: number | null }; +export type ProvisioningFailureParams = { step: "setup" | "python" | "npm" | "file" | "skill" | "harness" | null; index: number | null; exit_code: number | null }; export type ConnectionFailureParams = { http_status: number | null }; export type DiagnosticFailure = Omit & { params: Record | ProvisioningFailureParams | ConnectionFailureParams }; export type SessionDiagnosticFailure = DiagnosticFailure & Pick; @@ -20,7 +20,7 @@ export type SessionDiagnostics = Omit & { export type TurnDiagnostics = Omit & { failure: DiagnosticFailure | null }; // The schema's one code enum covers every failure source; each source produces only its subset. -const turnCodes: readonly DiagnosticFailureCode[] = ["authentication_error", "rate_limit_exceeded", "usage_limit_exceeded", "server_overloaded", "server_error", "invalid_request", "resource_not_found", "context_length_exceeded", "cyber_policy", "connection_failed", "harness_error", "model_provider_required", "runtime_unavailable", "runtime_disconnected", "runtime_preparation_failed", "execution_interrupted", "delivery_unconfirmed", "input_rejected", "executor_protocol_error", "core_storage_failed", "internal_error"]; +const turnCodes: readonly DiagnosticFailureCode[] = ["authentication_error", "rate_limit_exceeded", "usage_limit_exceeded", "server_overloaded", "server_error", "invalid_request", "resource_not_found", "request_timeout", "context_length_exceeded", "cyber_policy", "connection_failed", "harness_error", "model_provider_required", "runtime_unavailable", "runtime_disconnected", "runtime_preparation_failed", "execution_interrupted", "delivery_unconfirmed", "input_rejected", "executor_protocol_error", "core_storage_failed", "internal_error"]; const inputCodes: readonly DiagnosticFailureCode[] = ["environment_connection_timeout", "environment_unavailable", "model_provider_required", "internal_error"]; const environmentCodes: readonly DiagnosticFailureCode[] = ["environment_provisioning_failed"]; const fields = (...values: string[]) => new Set(values); @@ -33,7 +33,7 @@ function failure(value: unknown, codes: readonly DiagnosticFailureCode[], sessio let params: DiagnosticFailure["params"] = {}; if (value.code === "environment_provisioning_failed") { const p = value.params; - if (!exactFields(p, fields("step", "index", "exit_code")) || (p.step !== null && (typeof p.step !== "string" || !["setup", "python", "npm", "file", "skill"].includes(p.step))) || + if (!exactFields(p, fields("step", "index", "exit_code")) || (p.step !== null && (typeof p.step !== "string" || !["setup", "python", "npm", "file", "skill", "harness"].includes(p.step))) || (p.index !== null && (!Number.isSafeInteger(p.index) || (p.index as number) < 0 || p.step !== "setup")) || (p.exit_code !== null && (!Number.isSafeInteger(p.exit_code) || (p.exit_code as number) < 1 || (p.exit_code as number) > 255 || !["setup", "python", "npm"].includes(p.step ?? "")))) return invalidAdminResponse(); params = { step: p.step as ProvisioningFailureParams["step"], index: p.index as number | null, exit_code: p.exit_code as number | null }; diff --git a/services/core/internal/api/session_diagnostics.go b/services/core/internal/api/session_diagnostics.go index 098f7299c..dd5ad70d3 100644 --- a/services/core/internal/api/session_diagnostics.go +++ b/services/core/internal/api/session_diagnostics.go @@ -19,7 +19,7 @@ const ( ) type DiagnosticFailure struct { - Code string `json:"code" binding:"required" enums:"harness_error,model_provider_required,runtime_unavailable,runtime_disconnected,runtime_preparation_failed,execution_interrupted,delivery_unconfirmed,input_rejected,executor_protocol_error,core_storage_failed,internal_error,environment_connection_timeout,environment_unavailable,environment_provisioning_failed,authentication_error,rate_limit_exceeded,usage_limit_exceeded,server_overloaded,server_error,invalid_request,resource_not_found,context_length_exceeded,cyber_policy,connection_failed"` + Code string `json:"code" binding:"required" enums:"harness_error,model_provider_required,runtime_unavailable,runtime_disconnected,runtime_preparation_failed,execution_interrupted,delivery_unconfirmed,input_rejected,executor_protocol_error,core_storage_failed,internal_error,environment_connection_timeout,environment_unavailable,environment_provisioning_failed,authentication_error,rate_limit_exceeded,usage_limit_exceeded,server_overloaded,server_error,invalid_request,resource_not_found,request_timeout,context_length_exceeded,cyber_policy,connection_failed"` Params CoreErrorDetails `json:"params" swaggertype:"object" binding:"required"` FailedAt *time.Time `json:"failed_at" extensions:"x-nullable" binding:"required"` } diff --git a/services/core/internal/api/turn_diagnostic_failure_test.go b/services/core/internal/api/turn_diagnostic_failure_test.go index 81a6320ef..d5b3b20a7 100644 --- a/services/core/internal/api/turn_diagnostic_failure_test.go +++ b/services/core/internal/api/turn_diagnostic_failure_test.go @@ -95,7 +95,7 @@ func TestDiagnosticFailureWhitelist(t *testing.T) { } func TestDiagnosticNativeClassification(t *testing.T) { - codes := []string{"authentication_error", "rate_limit_exceeded", "usage_limit_exceeded", "server_overloaded", "server_error", "invalid_request", "resource_not_found", "context_length_exceeded", "cyber_policy", "connection_failed"} + codes := []string{"authentication_error", "rate_limit_exceeded", "usage_limit_exceeded", "server_overloaded", "server_error", "invalid_request", "resource_not_found", "request_timeout", "context_length_exceeded", "cyber_policy", "connection_failed"} catalog, err := os.ReadFile("../../../../contracts/agents-api/core-errors.md") if err != nil { t.Fatal(err) diff --git a/services/core/internal/db/queries/deployment_provider_observations.sql b/services/core/internal/db/queries/deployment_provider_observations.sql index b98269973..e69823bde 100644 --- a/services/core/internal/db/queries/deployment_provider_observations.sql +++ b/services/core/internal/db/queries/deployment_provider_observations.sql @@ -16,7 +16,7 @@ WITH locked AS MATERIALIZED ( AND t.outcome->>'engine_error_code' IN ( 'authentication_error', 'connection_failed', 'rate_limit_exceeded', 'usage_limit_exceeded', 'server_overloaded', 'server_error', - 'resource_not_found', 'invalid_request'))) + 'resource_not_found', 'request_timeout', 'invalid_request'))) FOR UPDATE OF d ), observed AS MATERIALIZED ( SELECT harness, revision, clock_timestamp() AS received_at FROM locked diff --git a/services/core/internal/db/queries/runtime_enrollment.sql b/services/core/internal/db/queries/runtime_enrollment.sql index 0c68f0bcf..4059c280d 100644 --- a/services/core/internal/db/queries/runtime_enrollment.sql +++ b/services/core/internal/db/queries/runtime_enrollment.sql @@ -27,7 +27,7 @@ WHERE devices.id = sqlc.arg(id) AND EXISTS ( ); -- name: ListEnrolledRuntimeBindings :many -SELECT d.id AS device_id, d.tenant_id, e.id AS environment_id, e.session_id +SELECT d.id AS device_id, d.tenant_id, e.id AS environment_id FROM devices d JOIN environments e ON e.id = d.environment_id JOIN sessions s ON s.id = e.session_id AND s.tenant_id = d.tenant_id diff --git a/services/core/internal/db/sqlc/deployment_provider_observations.sql.go b/services/core/internal/db/sqlc/deployment_provider_observations.sql.go index affc37f2e..9f9c0b5a9 100644 --- a/services/core/internal/db/sqlc/deployment_provider_observations.sql.go +++ b/services/core/internal/db/sqlc/deployment_provider_observations.sql.go @@ -27,7 +27,7 @@ WITH locked AS MATERIALIZED ( AND t.outcome->>'engine_error_code' IN ( 'authentication_error', 'connection_failed', 'rate_limit_exceeded', 'usage_limit_exceeded', 'server_overloaded', 'server_error', - 'resource_not_found', 'invalid_request'))) + 'resource_not_found', 'request_timeout', 'invalid_request'))) FOR UPDATE OF d ), observed AS MATERIALIZED ( SELECT harness, revision, clock_timestamp() AS received_at FROM locked diff --git a/services/core/internal/db/sqlc/runtime_enrollment.sql.go b/services/core/internal/db/sqlc/runtime_enrollment.sql.go index a34dcf9c5..60c482126 100644 --- a/services/core/internal/db/sqlc/runtime_enrollment.sql.go +++ b/services/core/internal/db/sqlc/runtime_enrollment.sql.go @@ -79,7 +79,7 @@ func (q *Queries) EnrollRuntimeDevice(ctx context.Context, arg EnrollRuntimeDevi } const listEnrolledRuntimeBindings = `-- name: ListEnrolledRuntimeBindings :many -SELECT d.id AS device_id, d.tenant_id, e.id AS environment_id, e.session_id +SELECT d.id AS device_id, d.tenant_id, e.id AS environment_id FROM devices d JOIN environments e ON e.id = d.environment_id JOIN sessions s ON s.id = e.session_id AND s.tenant_id = d.tenant_id @@ -92,7 +92,6 @@ type ListEnrolledRuntimeBindingsRow struct { DeviceID pgtype.UUID `json:"device_id"` TenantID pgtype.UUID `json:"tenant_id"` EnvironmentID pgtype.UUID `json:"environment_id"` - SessionID pgtype.UUID `json:"session_id"` } func (q *Queries) ListEnrolledRuntimeBindings(ctx context.Context) ([]ListEnrolledRuntimeBindingsRow, error) { @@ -104,12 +103,7 @@ func (q *Queries) ListEnrolledRuntimeBindings(ctx context.Context) ([]ListEnroll items := []ListEnrolledRuntimeBindingsRow{} for rows.Next() { var i ListEnrolledRuntimeBindingsRow - if err := rows.Scan( - &i.DeviceID, - &i.TenantID, - &i.EnvironmentID, - &i.SessionID, - ); err != nil { + if err := rows.Scan(&i.DeviceID, &i.TenantID, &i.EnvironmentID); err != nil { return nil, err } items = append(items, i) diff --git a/services/core/internal/modelconfiguration/observation.go b/services/core/internal/modelconfiguration/observation.go index d642d4610..fc9e48afc 100644 --- a/services/core/internal/modelconfiguration/observation.go +++ b/services/core/internal/modelconfiguration/observation.go @@ -12,6 +12,7 @@ const ( ProviderServerOverloaded ProviderErrorCode = "server_overloaded" ProviderServerError ProviderErrorCode = "server_error" ProviderResourceNotFound ProviderErrorCode = "resource_not_found" + ProviderRequestTimeout ProviderErrorCode = "request_timeout" ProviderInvalidRequest ProviderErrorCode = "invalid_request" ) @@ -25,7 +26,7 @@ type Observation struct { // itself. Context-length and cyber-policy failures describe the request. The // observation statement's fence lists the same codes; testdata holds the cases // both are checked against. -var providerErrorCodes = []ProviderErrorCode{ProviderAuthenticationError, ProviderConnectionFailed, ProviderRateLimitExceeded, ProviderUsageLimitExceeded, ProviderServerOverloaded, ProviderServerError, ProviderResourceNotFound, ProviderInvalidRequest} +var providerErrorCodes = []ProviderErrorCode{ProviderAuthenticationError, ProviderConnectionFailed, ProviderRateLimitExceeded, ProviderUsageLimitExceeded, ProviderServerOverloaded, ProviderServerError, ProviderResourceNotFound, ProviderRequestTimeout, ProviderInvalidRequest} // ShouldObserveProvider reports whether a committed root Turn outcome says // something about its model provider: every completed Turn, and a failed Turn diff --git a/services/core/internal/modelconfiguration/testdata/observation_cases.json b/services/core/internal/modelconfiguration/testdata/observation_cases.json index 057a597dc..0794fd47d 100644 --- a/services/core/internal/modelconfiguration/testdata/observation_cases.json +++ b/services/core/internal/modelconfiguration/testdata/observation_cases.json @@ -7,6 +7,7 @@ {"status": "failed", "error_code": "engine_failed", "engine_error_code": "server_overloaded", "observed": true}, {"status": "failed", "error_code": "engine_failed", "engine_error_code": "server_error", "observed": true}, {"status": "failed", "error_code": "engine_failed", "engine_error_code": "resource_not_found", "observed": true}, + {"status": "failed", "error_code": "engine_failed", "engine_error_code": "request_timeout", "observed": true}, {"status": "failed", "error_code": "engine_failed", "engine_error_code": "invalid_request", "observed": true}, {"status": "failed", "error_code": "engine_failed", "engine_error_code": "context_length_exceeded", "observed": false}, {"status": "failed", "error_code": "engine_failed", "engine_error_code": "cyber_policy", "observed": false}, diff --git a/services/core/migrations/000004_remove_unused_schema.sql b/services/core/migrations/000004_remove_unused_schema.sql index 98c3ec3c4..c378ff7a4 100644 --- a/services/core/migrations/000004_remove_unused_schema.sql +++ b/services/core/migrations/000004_remove_unused_schema.sql @@ -1,4 +1,2 @@ -- +goose Up DROP VIEW public_execution_turns; -ALTER TABLE deployment_model_providers DROP CONSTRAINT deployment_provider_error_code; -ALTER TABLE deployment_model_providers ADD CONSTRAINT deployment_provider_error_code CHECK ((last_error_code = ANY (ARRAY['authentication_error'::text, 'connection_failed'::text, 'rate_limit_exceeded'::text, 'usage_limit_exceeded'::text, 'server_overloaded'::text, 'server_error'::text, 'resource_not_found'::text, 'invalid_request'::text]))); diff --git a/services/core/migrations/000007_restore_request_timeout_error_code.sql b/services/core/migrations/000007_restore_request_timeout_error_code.sql new file mode 100644 index 000000000..8fb34fb8c --- /dev/null +++ b/services/core/migrations/000007_restore_request_timeout_error_code.sql @@ -0,0 +1,3 @@ +-- +goose Up +ALTER TABLE deployment_model_providers DROP CONSTRAINT deployment_provider_error_code; +ALTER TABLE deployment_model_providers ADD CONSTRAINT deployment_provider_error_code CHECK ((last_error_code = ANY (ARRAY['authentication_error'::text, 'connection_failed'::text, 'rate_limit_exceeded'::text, 'usage_limit_exceeded'::text, 'server_overloaded'::text, 'server_error'::text, 'resource_not_found'::text, 'request_timeout'::text, 'invalid_request'::text]))); diff --git a/services/core/tests/official_environment_composition.py b/services/core/tests/official_environment_composition.py deleted file mode 100644 index 285d02398..000000000 --- a/services/core/tests/official_environment_composition.py +++ /dev/null @@ -1,104 +0,0 @@ -"""Compose existing real acceptance fixtures; no model or Runtime emulation.""" -import base64 -import io -import json -import zipfile - -from official_environment_initial_files import initial_files, assert_initial_bytes_script -from official_environment_plugin_mcp import plugin_mcp_fixture, verify_plugin_mcp_metadata -from official_environment_setup import setup_configuration, native_setup_script, verify_setup_metadata -from official_environment_skill_references import upload_reference_skill -from official_environment_skills import inline_skill - - -def composition_fixture(client, foreign, http, agent, proxy): - """The runner owns source cleanup as well as Session/Template cleanup.""" - fixture = plugin_mcp_fixture(installed_dependencies=True) - environment, source_id, initial = initial_files(client, foreign, http, agent) - skill, expected = upload_reference_skill(client) - setup, marker = setup_configuration(proxy) - setup['packages']['system'] = ['jq'] - fixture['env'].update(setup['env']) - fixture['packages'] = setup['packages'] - fixture['setup_commands'] = setup['setup_commands'] + fixture['setup_commands'] - fixture['files'] += environment['files'] - fixture['skills'] = [{'type': 'skill_reference', 'skill_id': skill.id}] - fixture['expected_skills'] = [{'type': 'skill_reference', 'skill_id': skill.id, - 'version': '1', 'name': skill.name, - 'description': skill.description}] - fixture['source_file_id'] = source_id - fixture['source_skill_id'] = skill.id - fixture['setup_configuration'] = setup - proof = {'marker': fixture['marker'], 'checks': ['initial_files', 'ordered_setup', - 'caller_env', 'system_npm_python', 'uploaded_skill', 'readonly_system_root', 'private_paths_denied']} - script = native_setup_script(marker) + assert_initial_bytes_script(initial) + ''' -import json -assert subprocess.check_output(['jq', '-r', '.value'], input=b'{"value":42}').strip() == b'42' -private_paths = json.loads(Path('/workspace/plugin-isolation-paths.json').read_text()) -assert set(private_paths) == {'staging', 'native_history', 'daemon_auth'} -for path in private_paths.values(): - try: - with open(path, 'rb') as stream: - exposed = stream.read(1) - except OSError: - continue - assert not exposed, 'private Runtime data readable by uploaded Skill' -try: - Path('/usr/bin/jq').open('r+b').close() -except OSError: - pass -else: - raise AssertionError('system package root is writable') -Path('/workspace/outputs').mkdir(exist_ok=True) -''' - script += 'Path("/workspace/outputs/composition.json").write_text(' + repr( - json.dumps(proof, sort_keys=True) + '\n') + ')\n' - fixture['files'].append({'type': 'inline', 'path': '/workspace/verify.py', - 'data': base64.b64encode(script.encode()).decode()}) - fixture['outputs']['/workspace/outputs/composition.json'] = (json.dumps(proof, sort_keys=True) + '\n').encode() - fixture['outputs']['/workspace/outputs/skill-proof.txt'] = expected - fixture['prompt'] = ('Use the installed proof-skill Skill and run its packaged script. ' - 'It must verify initialized files, environment, setup and installed dependencies. ' - 'Do not recreate or print scripts or environment values. ' + fixture['prompt']) - fixture['initial_sizes'] = {item['path']: (len(base64.b64decode(item['data'])) - if item['type'] == 'inline' else len(initial[item['path']])) - for item in fixture['files']} - fixture['configuration'] = {key: fixture[key] for key in ['files', 'env', 'packages', - 'setup_commands', 'skills', 'plugins', 'capability_directories']} - fixture['configuration']['network'] = {'access': 'enabled'} - return fixture - - -def verify_composition_metadata(client, http, session, fixture): - verify_plugin_mcp_metadata(client, session, fixture, expected_skills=fixture['expected_skills']) - verify_setup_metadata(client, session, fixture['setup_configuration']) - headers = {'Authorization': 'Bearer ' + client.api_key, 'OpenAI-Beta': 'agents=v1'} - root = str(client.base_url).rstrip('/') - for suffix, expected in [('/agents/sessions/' + session.id, - client.beta.agents.sessions.retrieve(session.id).to_dict()), - ('/agents/environments/' + session.environment.id, - client.beta.agents.environments.retrieve(session.environment.id).to_dict())]: - response = http.get(root + suffix, headers=headers) - assert response.status_code == 200 and response.json() == expected - environment = expected.get('environment', expected) - assert environment['skills'] == fixture['expected_skills'] - assert {value['path']: value['size_bytes'] for value in environment['files']} == fixture['initial_sizes'] - assert len({value['id'] for value in environment['files']}) == len(fixture['files']) - assert 'env' not in environment and 'setup_commands' not in environment - - -def change_and_delete_sources(client, fixture, template_id=None): - """Existing Session must retain v1 despite changed source pointers and bytes.""" - replacement, replacement_output = inline_skill() - assert replacement_output != fixture['outputs']['/workspace/outputs/skill-proof.txt'] - with zipfile.ZipFile(io.BytesIO(base64.b64decode(replacement['source']['data']))) as archive: - files = [(name, archive.read(name), 'application/octet-stream') for name in archive.namelist()] - version = client.skills.versions.create(skill_id=fixture['source_skill_id'], files=files, default=True) - assert version.version == '2' - assert client.skills.retrieve(fixture['source_skill_id']).default_version == '2' - if template_id: - updated = client.beta.agents.environments.templates.update(template_id, env={'CHANGED': 'replacement'}, - files=[], skills=[], plugins=[], capability_directories=[], setup_commands=[], packages=None) - assert updated.skills == [] and updated.plugins == [] and updated.files == [] - client.files.delete(fixture['source_file_id']) - client.skills.delete(fixture['source_skill_id']) diff --git a/services/core/tests/official_environment_initial_files.py b/services/core/tests/official_environment_initial_files.py deleted file mode 100644 index a5cbfca70..000000000 --- a/services/core/tests/official_environment_initial_files.py +++ /dev/null @@ -1,48 +0,0 @@ -"""Real-deployment checks for confidential initial files and frozen metadata.""" -import base64 -import json -import secrets - - -def initial_files(client, foreign, http, agent, template_id=None): - inline = secrets.token_bytes(40) - source_body = bytes(range(256)) - source = client.files.create(file=('initial.bin', source_body), purpose='user_data') - files = [{'type': 'inline', 'path': '/workspace/initial-inline.bin', - 'data': base64.b64encode(inline).decode()}, - {'type': 'file_id', 'path': '/workspace/initial-source.bin', 'file_id': source.id}] - endpoint = str(client.base_url).rstrip('/') - headers = {'Authorization': 'Bearer ' + client.api_key, 'OpenAI-Beta': 'agents=v1'} - foreign_headers = {**headers, 'Authorization': 'Bearer ' + foreign.api_key} - attempted = http.post(endpoint + '/agents/sessions', headers=foreign_headers, - json={'agent': agent, 'environment': {'type': 'openai_hosted', 'files': [files[1]]}}) - assert attempted.status_code == 404 and source.id not in attempted.text - if template_id: - response = client.beta.agents.environments.templates.with_raw_response.update(template_id, files=files) - body = response.http_response.json() - assert body['files'] == [{'type': 'inline', 'path': files[0]['path'], 'size_bytes': len(inline)}, - {'type': 'file_id', 'path': files[1]['path'], 'file_id': source.id}] - assert files[0]['data'] not in json.dumps(body) - listing = client.beta.agents.environments.templates.list().to_dict() - assert files[0]['data'] not in json.dumps(listing) - environment = {'type': 'openai_hosted', 'environment_template_id': template_id} - else: - environment = {'type': 'openai_hosted', 'network': {'access': 'disabled'}, 'files': files} - return environment, source.id, {files[0]['path']: inline, files[1]['path']: source_body} - - -def verify_initial_snapshot(client, http, session, expected, source_id): - metadata = [value.to_dict() for value in session.environment.files] - assert len(metadata) == 2 and len({value['id'] for value in metadata}) == 2 - assert {value['path']: value['size_bytes'] for value in metadata} == {p: len(b) for p, b in expected.items()} - assert metadata[0]['type'] == 'inline' and 'data' not in metadata[0] and 'file_id' not in metadata[0] - assert metadata[1]['type'] == 'file_id' and metadata[1]['file_id'] == source_id - endpoint = str(client.base_url).rstrip('/') + '/agents/environments/' + session.environment.id - response = http.get(endpoint, headers={'Authorization': 'Bearer ' + client.api_key, 'OpenAI-Beta': 'agents=v1'}) - assert response.status_code == 200 and response.json()['files'] == metadata - client.files.delete(source_id) - - -def assert_initial_bytes_script(expected): - return ''.join('assert Path(' + repr(path) + ').read_bytes() == bytes.fromhex(' + repr(body.hex()) + ')\n' - for path, body in expected.items()) diff --git a/services/core/tests/official_environment_network.py b/services/core/tests/official_environment_network.py deleted file mode 100644 index 1430740e1..000000000 --- a/services/core/tests/official_environment_network.py +++ /dev/null @@ -1,76 +0,0 @@ -"""Restricted-policy assertions for a real standalone Core and fixed SDK. - -These checks establish resource behavior only. Native model/tool enforcement, -Files/Artifacts, cancellation and recovery need the real deployment driver. -""" - -NETWORK = {'access': 'restricted', 'allowed_domains': ['Example.com', 'httpbingo.org', 'example.com']} - - -def verify_network_resources(client, foreign, http, agent): - templates = client.beta.agents.environments.templates - root = str(client.base_url).rstrip('/') - headers = {'Authorization': 'Bearer ' + client.api_key, 'OpenAI-Beta': 'agents=v1'} - template = templates.create(network=NETWORK) - endpoint = root + '/agents/environments/templates/' + template.id - try: - assert template.network.to_dict() == NETWORK - assert templates.update(template.id, name='Network policy').network.to_dict() == NETWORK - assert http.get(endpoint, headers=headers).json()['network'] == NETWORK - assert next(item for item in templates.list() if item.id == template.id).network.to_dict() == NETWORK - foreign_headers = {**headers, 'Authorization': 'Bearer ' + foreign.api_key} - for method in ['GET', 'POST', 'DELETE']: - response = http.request(method, endpoint, headers=foreign_headers, - json={'network': {'access': 'enabled'}} if method == 'POST' else None) - assert response.status_code == 404 - for network in [ - {'access': 'restricted'}, {'access': 'restricted', 'allowed_domains': []}, - {'access': 'restricted', 'allowed_domains': None}, - {'access': 'restricted', 'allowed_domains': ['*.example.com']}, - {'access': 'restricted', 'allowed_domains': ['https://example.com']}, - {'access': 'restricted', 'allowed_domains': ['127.0.0.1']}, - {'access': 'restricted', 'allowed_domains': ['example.com'] * 101}, - {'access': 'enabled', 'allowed_domains': ['example.com']}, - ]: - for target in [endpoint, root + '/agents/environments/templates']: - response = http.post(target, headers=headers, json={'network': network}) - assert response.status_code == 400 and response.json()['error']['code'] == 'invalid_request_error' - assert response.json()['error']['param'] is None - assert templates.retrieve(template.id).network.to_dict() == NETWORK - for override in [{'access': 'enabled'}, {'access': 'restricted', 'allowed_domains': ['sub.example.com']}, - {'access': 'restricted', 'allowed_domains': ['example.org']}]: - response = http.post(root + '/agents/sessions', headers=headers, json={ - 'agent': agent, 'environment': {'type': 'openai_hosted', - 'environment_template_id': template.id, 'network': override}}) - assert response.status_code == 400 - response = http.post(root + '/agents/sessions', headers=foreign_headers, json={ - 'agent': agent, 'environment': {'type': 'openai_hosted', 'environment_template_id': template.id}}) - assert response.status_code == 404 - replacement = {'access': 'restricted', 'allowed_domains': ['httpbingo.org']} - assert templates.update(template.id, network=replacement).network.to_dict() == replacement - assert templates.update(template.id, network=None).network.to_dict() == {'access': 'enabled', 'allowed_domains': []} - assert templates.update(template.id, network={'access': 'disabled'}).network.to_dict() == {'access': 'disabled', 'allowed_domains': []} - finally: - templates.delete(template.id) - - -def attach_network(client, environment, template_id=None): - if template_id: - ceiling = {'access': 'restricted', 'allowed_domains': ['example.com', 'httpbingo.org', 'example.org']} - client.beta.agents.environments.templates.update(template_id, network=ceiling) - return {**environment, 'network': NETWORK} - - -def verify_network_snapshot(client, http, session, spec, key): - assert session.environment.network.to_dict() == NETWORK - sessions = client.beta.agents.sessions - assert sessions.retrieve(session.id).environment.network.to_dict() == NETWORK - retried = sessions.create(**spec, extra_headers={'Idempotency-Key': key}) - assert retried.id == session.id and retried.environment.network.to_dict() == NETWORK - changed = {**spec, 'environment': {**spec['environment'], 'network': { - 'access': 'restricted', 'allowed_domains': ['httpbingo.org', 'example.com']}}} - # Equal effective authority does not erase the caller's original array identity. - response = http.post(str(client.base_url).rstrip('/') + '/agents/sessions', json=changed, - headers={'Authorization': 'Bearer ' + client.api_key, - 'OpenAI-Beta': 'agents=v1', 'Idempotency-Key': key}) - assert response.status_code == 409 diff --git a/services/core/tests/official_environment_plugin_mcp.py b/services/core/tests/official_environment_plugin_mcp.py deleted file mode 100644 index 5a33298b9..000000000 --- a/services/core/tests/official_environment_plugin_mcp.py +++ /dev/null @@ -1,391 +0,0 @@ -"""Fixtures for real native Plugin MCP acceptance through the pinned public API. - -The caller owns Core, Runtime, positive private canaries, model calls, recovery and -cancellation. Reuse official_environment_files.verify_environment_files with -{path: len(body) for path, body in fixture['outputs'].items()}, and -official_session_artifacts.verify_session_artifacts with those same bytes. -These helpers never emulate a model or claim native acceptance from fixture checks. -""" -import base64 -import hashlib -import io -import json -import secrets -import shlex -import zipfile - -from official_environment_plugins import verify_plugin_resources - - -_CHECKS = ['selected_user_env', 'unselected_env_absent', 'native_env_absent', - 'package_cwd', 'private_contents_denied', 'installed_package_readonly'] -_SERVER = r'''import hashlib, json, os, subprocess, sys -from pathlib import Path - -root = Path(__file__).resolve().parent -config = json.loads((root / 'resources/proof.json').read_text()) - -def verify(marker): - assert marker == config['marker'], 'wrong invocation marker' - selected = os.environ.get('PLUGIN_MCP_SELECTED', '') - assert selected and hashlib.sha256(selected.encode()).hexdigest() == config['selected_sha256'], 'selected user env missing or wrong' - assert 'PLUGIN_MCP_UNSELECTED' not in os.environ, 'unselected user env present' - prefixes = ('PARSAR_', 'CODEX_', 'OPENAI_', 'ANTHROPIC_', 'MINIMAX_', 'MOONSHOT_', 'KIMI_') - assert not any(name.startswith(prefixes) or name == 'NATIVE_MCP_CANARY' for name in os.environ), 'native environment present' - assert Path.cwd() == root / 'resources', 'relative package cwd not applied' - assert json.loads(Path('proof.json').read_text()) == config, 'cwd resource differs' - private_paths = json.loads(Path('/workspace/plugin-isolation-paths.json').read_text()) - assert set(private_paths) == {'staging', 'native_history', 'daemon_auth'}, 'positive private paths missing' - assert all(isinstance(path, str) and path.startswith('/') for path in private_paths.values()), 'invalid private paths' - for path in private_paths.values(): - try: - with open(path, 'rb') as stream: - exposed = stream.read(1) - except OSError: - continue - assert not exposed, 'private Runtime content readable' - for path in [root / '.codex-plugin/plugin.json', root / '.mcp.json', root / 'resources/proof.json']: - try: - with path.open('r+b'): - pass - except OSError: - continue - raise AssertionError('installed package writable') - if config.get('installed_dependencies'): - import packaging - assert packaging.__version__ == '26.0', 'installed Python dependency missing' - assert subprocess.check_output(['semver', '1.2.3']).strip() == b'1.2.3', 'installed npm dependency missing' - assert subprocess.check_output(['jq', '-r', '.value'], input=b'{"value":42}').strip() == b'42', 'installed system dependency missing' - try: - Path('/usr/bin/jq').open('r+b').close() - except OSError: - pass - else: - raise AssertionError('installed system root writable') - return {'marker': marker, 'server': config['server'], 'checks': config['checks']} - -def invoke(name, arguments): - proof = verify(arguments.get('marker')) - if name == 'verify': - directory = Path('/workspace/outputs') - directory.mkdir(exist_ok=True) - (directory / (config['server'] + '.json')).write_text(json.dumps(proof, sort_keys=True) + '\n') - elif name == 'hold': - directory = Path('/workspace/plugin-mcp-hold') - directory.mkdir(exist_ok=True) - (directory / 'invocation.json').write_text(json.dumps(proof, sort_keys=True) + '\n') - # Only a real tools/call starts this detached descendant. Its bounded - # lifetime prevents a failed operator run from leaving an endless probe. - writer = "import json,sys,time\nfrom pathlib import Path\np=Path(sys.argv[1])\nfor tick in range(720):\n with p.open('a') as f: f.write(json.dumps({'marker':sys.argv[2],'tick':tick})+'\\n')\n time.sleep(0.25)\n" - subprocess.run([sys.executable, '-I', '-S', '-c', writer, - str(directory / 'ticks.jsonl'), proof['marker']], - start_new_session=True, check=True) - else: - raise AssertionError('unknown tool') - return {'content': [{'type': 'text', 'text': json.dumps(proof, sort_keys=True)}], - 'structuredContent': proof, 'isError': False} - -for line in sys.stdin: - request = json.loads(line) - if 'id' not in request: - continue - method, params = request.get('method'), request.get('params', {}) - response = {'jsonrpc': '2.0', 'id': request['id']} - try: - if method == 'initialize': - response['result'] = {'protocolVersion': params['protocolVersion'], - 'capabilities': {'tools': {}}, - 'serverInfo': {'name': config['server'], 'version': '1.0.0'}} - elif method == 'ping': - response['result'] = {} - elif method == 'tools/list': - response['result'] = {'tools': [{'name': name, - 'description': description, - 'inputSchema': {'type': 'object', 'properties': {'marker': {'type': 'string'}}, - 'required': ['marker'], 'additionalProperties': False}} - for name, description in [('verify', 'Verify the installed package and isolated environment; write workspace proof.'), - ('hold', 'Write invocation proof and keep a descendant writing for cancellation verification.')]]} - elif method == 'tools/call': - try: - response['result'] = invoke(params['name'], params.get('arguments', {})) - except Exception as error: - # Exception text, paths and environment values may be private. - response['result'] = {'content': [{'type': 'text', 'text': 'PLUGIN_MCP_CHECK_FAILED:' + type(error).__name__}], 'isError': True} - else: - response['error'] = {'code': -32601, 'message': 'Method not found'} - except Exception: - response['error'] = {'code': -32602, 'message': 'Invalid request'} - print(json.dumps(response), flush=True) -''' - - -def _skill(name, marker, output): - script = ("from pathlib import Path\n" - "root = Path(__file__).resolve().parent\n" - "assert '/initialization/capabilities/' in str(root)\n" - "Path('/workspace/outputs').mkdir(exist_ok=True)\n" - "Path(" + repr(output) + ").write_bytes((root / 'proof.txt').read_bytes())\n" - "print('INSTALLED_PLUGIN_SKILL_VERIFIED')\n") - manifest = ('---\nname: ' + name + '\ndescription: Execute the installed ' + name + ' proof script.\n---\n' - 'Run `python3 check.py` from this installed Skill directory using native tools. ' - 'Use this packaged script; do not recreate it.\n') - return {'SKILL.md': manifest, 'check.py': script, 'proof.txt': marker + '\n'} - - -def _package(server, marker, selected, skill=False, *, installed_dependencies=False): - manifest = {'name': server, 'description': 'Native MCP isolation proof.', 'mcpServers': './.mcp.json'} - files = {'.mcp.json': json.dumps({'mcpServers': {server: { - 'command': 'python3', 'args': ['../server.py'], 'cwd': 'resources', - 'env_vars': ['PLUGIN_MCP_SELECTED']}}}), 'server.py': _SERVER, - 'resources/proof.json': json.dumps({'server': server, 'marker': marker, - 'selected_sha256': hashlib.sha256(selected.encode()).hexdigest(), - 'installed_dependencies': installed_dependencies, - 'checks': _CHECKS + (['installed_dependencies'] if installed_dependencies else [])})} - if skill: - manifest['skills'] = ['./skills'] - files.update({'skills/combined/' + path: body for path, body in _skill( - 'combined-mcp-skill', marker, '/workspace/outputs/combined-skill.txt').items()}) - files['.codex-plugin/plugin.json'] = json.dumps(manifest) - return files - - -def _inline_plugin(name, files): - archive = io.BytesIO() - with zipfile.ZipFile(archive, 'w', zipfile.ZIP_DEFLATED) as output: - for path, body in files.items(): - output.writestr('proof/' + path, body) - return {'type': 'inline', 'name': name, 'description': 'Native MCP isolation proof.', - 'source': {'type': 'base64', 'media_type': 'application/zip', - 'data': base64.b64encode(archive.getvalue()).decode()}} - - -def plugin_mcp_fixture(*, installed_dependencies=False): - """Return one hosted configuration and exact expected public proof bytes. - - Before a native Turn, the runner must create nonempty private canary files - outside tool authority and publish only their paths in plugin-isolation-paths.json. - Never log the returned env or source bodies. The marker itself is nonsecret. - """ - marker = 'plugin-mcp-proof-' + secrets.token_hex(20) - env = {'PLUGIN_MCP_SELECTED': 'selected-' + secrets.token_hex(24), - 'PLUGIN_MCP_UNSELECTED': 'unselected-' + secrets.token_hex(24)} - servers = ['mcp_only_proof', 'combined_proof', 'generated_proof'] - plugins = [_inline_plugin(name, _package(name, marker, env['PLUGIN_MCP_SELECTED'], skill=index == 1, - installed_dependencies=installed_dependencies)) - for index, name in enumerate(servers[:2])] - exact, parent = '/workspace/generated/mcp-exact', '/workspace/generated/skill-parent' - generated = {exact + '/' + path: body for path, body in _package( - servers[2], marker, env['PLUGIN_MCP_SELECTED'], - installed_dependencies=installed_dependencies).items()} - # Selecting the parent discovers a nested Skill, but never the child's MCP. - child = _package('unselected_child_mcp', marker, env['PLUGIN_MCP_SELECTED']) - child_manifest = json.loads(child['.codex-plugin/plugin.json']) - child_manifest['skills'] = ['./skills'] - child['.codex-plugin/plugin.json'] = json.dumps(child_manifest) - child.update({'skills/parent/' + path: body for path, body in _skill( - 'parent-discovered-skill', marker, '/workspace/outputs/parent-skill.txt').items()}) - generated.update({parent + '/child/' + path: body for path, body in child.items()}) - seed = json.dumps(generated) - initial = [{'type': 'inline', 'path': '/workspace/plugin-mcp-seed.json', - 'data': base64.b64encode(seed.encode()).decode()}] - setup = ("import json\nfrom pathlib import Path\n" - "for name, body in json.loads(Path('/workspace/plugin-mcp-seed.json').read_text()).items():\n" - " p = Path(name)\n p.parent.mkdir(parents=True, exist_ok=True)\n p.write_text(body)\n" - "p = Path('/workspace/plugin-mcp-setup-count')\n" - "p.write_text(str(int(p.read_text()) + 1) if p.exists() else '1')\n") - checks = _CHECKS + (['installed_dependencies'] if installed_dependencies else []) - outputs = {'/workspace/outputs/' + server + '.json': (json.dumps( - {'marker': marker, 'server': server, 'checks': checks}, sort_keys=True) + '\n').encode() - for server in servers} - outputs.update({path: (marker + '\n').encode() for path in [ - '/workspace/outputs/combined-skill.txt', '/workspace/outputs/parent-skill.txt']}) - prompt = ('Use the installed combined-mcp-skill and parent-discovered-skill Skills and run their packaged ' - 'check.py scripts exactly as instructed. Then call the native MCP verify tool on each of ' - + ', '.join(servers) + ' exactly once, in that order, with {"marker": ' + json.dumps(marker) + '}. ' - 'Use real native MCP calls; do not recreate servers, scripts or proof files. Do not call hold. ' - 'Report only the server names and success or failure, never environment values.') - return {'plugins': plugins, 'files': initial, 'setup_commands': [{'command': 'python3 -c ' + shlex.quote(setup)}], - 'capability_directories': [exact, parent], 'env': env, 'marker': marker, - 'servers': servers, 'checks': checks, 'forbidden_servers': ['unselected_child_mcp'], 'outputs': outputs, - 'prompt': prompt, 'source_paths': ['/workspace/generated', '/workspace/plugin-mcp-seed.json'], - 'hold_server': servers[0], 'hold_paths': {'invocation': '/workspace/plugin-mcp-hold/invocation.json', - 'ticks': '/workspace/plugin-mcp-hold/ticks.jsonl'}, - 'hold_prompt': 'Call the native MCP hold tool on ' + servers[0] + ' exactly once with {"marker": ' - + json.dumps(marker) + '}. Wait for it; do not use shell tools or recreate its effects.'} - - -def _assert_private_body(value, fixture): - serialized = json.dumps(value) - secrets_to_check = list(fixture['env'].values()) + [plugin['source']['data'] for plugin in fixture['plugins']] - secrets_to_check += [item['data'] for item in fixture['files'] if item['type'] == 'inline'] - assert all(secret not in serialized for secret in secrets_to_check), 'Private initialization body exposed' - - -def verify_plugin_mcp_metadata(client, session, fixture, *, expected_skills=()): - expected = [{key: plugin[key] for key in ['type', 'name', 'description']} for plugin in fixture['plugins']] - resource = client.beta.agents.environments.retrieve(session.environment.id).to_dict() - assert session.to_dict()['environment']['capability_directories'] == fixture['capability_directories'] - for value in [session.to_dict()['environment'], resource]: - assert value['plugins'] == expected - assert value['skills'] == list(expected_skills), 'Configured metadata must not expose discovered Skill inventory' - assert 'env' not in value and 'setup_commands' not in value - _assert_private_body(value, fixture) - return {'plugins': expected, 'capability_directories': fixture['capability_directories']} - - -def verify_plugin_mcp_resources(client, foreign, http, fixture=None): - """Reuse Skill Plugin CRUD/isolation checks; add MCP-only and combined bodies. - - Public absence is not proof of encryption at rest. The runner must separately - inspect its task-owned database rows using the existing encrypted-body checks. - """ - verify_plugin_resources(client, foreign, http) - fixture = fixture or plugin_mcp_fixture() - api = client.beta.agents.environments.templates - configuration = {key: fixture[key] for key in ['plugins', 'files', 'setup_commands', 'capability_directories', 'env']} - template = api.create(**configuration) - endpoint = str(client.base_url).rstrip('/') + '/agents/environments/templates/' + template.id - headers = {'Authorization': 'Bearer ' + client.api_key, 'OpenAI-Beta': 'agents=v1'} - expected = [{key: plugin[key] for key in ['type', 'name', 'description']} for plugin in fixture['plugins']] - try: - response = http.get(endpoint, headers=headers) - assert response.status_code == 200 - bodies = [template.to_dict(), api.retrieve(template.id).to_dict(), response.json()] - bodies.append(api.update(template.id, name='Preserved MCP packages').to_dict()) - for body in bodies: - assert body['plugins'] == expected - assert body['capability_directories'] == fixture['capability_directories'] - assert 'env' not in body and 'setup_commands' not in body - _assert_private_body(body, fixture) - for method in ['GET', 'POST', 'DELETE']: - response = http.request(method, endpoint, - headers={**headers, 'Authorization': 'Bearer ' + foreign.api_key}, - **({'json': {'plugins': []}} if method == 'POST' else {})) - assert response.status_code == 404 - _assert_private_body(response.json(), fixture) - assert api.retrieve(template.id).to_dict()['plugins'] == expected - return {'plugins': expected, 'public_bodies_private': True, 'foreign_status': 404} - finally: - api.delete(template.id) - - -def _tool_proof(output): - # Claude retains the native content list; Codex/MiniMax retain the MCP object. - # Assert the same fixture result without rewriting the persisted native output. - assert isinstance(output, (dict, list)), 'Unexpected native MCP result shape' - if isinstance(output, dict): - assert output.get('isError') is not True, 'Native MCP reported a tool error' - content = output if isinstance(output, list) else output.get('content') - if isinstance(content, str): - # Claude SDK preserves this native result as text plus structuredContent. - proof = json.loads(content) - assert output.get('structuredContent') == proof, 'Native structured and text results differ' - return proof - assert isinstance(content, list) and len(content) == 1 and content[0]['type'] == 'text' - proof = json.loads(content[0]['text']) - if isinstance(output, dict) and 'structuredContent' in output: - assert output['structuredContent'] == proof, 'Native structured and text results differ' - return proof - - -def verify_plugin_mcp_items(client, http, session_id, turn_id, fixture, *, - expected_status='completed', tool='verify', servers=None, - events=None, expected_turn_status='completed'): - """Check exact native identities, saved result bodies, and SDK/raw ordering. - - For cancellation, pass tool='hold', servers=[fixture['hold_server']] and the - precise expected settled status. Observe hold_paths growth before cancellation - and stability afterwards in the runner; Items alone cannot prove effect cleanup. - Pass recorded event.to_dict() values to verify item.added < item.done < the - terminal Turn event. A cancellation run must set expected_turn_status='cancelled'. - """ - assert tool in ['verify', 'hold'] - assert expected_status in ['completed', 'failed', 'incomplete', 'in_progress'] - servers = fixture['servers'] if servers is None else servers - assert servers and len(set(servers)) == len(servers) - items = client.beta.agents.sessions.items - saved = [item.to_dict() for item in items.list(session_id, limit=3, order='asc')] - assert len({item['id'] for item in saved}) == len(saved) - assert [item.to_dict() for item in items.list(session_id, limit=5)] == list(reversed(saved)) - endpoint = str(client.base_url).rstrip('/') + '/agents/sessions/' + session_id + '/items' - headers = {'Authorization': 'Bearer ' + client.api_key, 'OpenAI-Beta': 'agents=v1'} - raw, params = [], {'limit': 3, 'order': 'asc'} - for _ in range(len(saved) + 1): - response = http.get(endpoint, headers=headers, params=params) - assert response.status_code == 200 - page = response.json() - raw.extend(page['data']) - if not page['has_more']: - break - assert page['data'] and page['data'][-1]['id'] != params.get('after'), 'Items cursor did not advance' - params['after'] = page['data'][-1]['id'] - else: - raise AssertionError('Items pagination did not terminate') - assert raw == saved, 'SDK and raw saved Items differ' - _assert_private_body(saved, fixture) - assert not any(item.get('server_label') in fixture['forbidden_servers'] for item in saved) - group = [item for item in saved if item['turn_id'] == turn_id] - assert group and group[0]['type'] == 'message' and group[0]['role'] == 'user' - calls = [item for item in group if item['type'] == 'mcp_call'] - assert [item['server_label'] for item in calls] == servers, 'Native server identities or requested invocation order differ' - for item in calls: - assert item['id'] and item['turn_id'] == turn_id - assert item['name'] == tool and item['status'] == expected_status - assert item['arguments'] == {'marker': fixture['marker']} - if expected_status == 'completed': - assert item.get('error') is None - assert _tool_proof(item['output']) == { - 'marker': fixture['marker'], 'server': item['server_label'], 'checks': fixture['checks']} - if expected_status == 'completed': - answers = [item for item in group if item['type'] == 'message' and item.get('role') == 'assistant'] - assert answers - assert answers[-1]['status'] == 'completed' - evidence = {'session_id': session_id, 'turn_id': turn_id, 'calls': calls, - 'item_ids_asc': [item['id'] for item in saved], 'sdk_raw_equal': True} - if events is not None: - assert expected_turn_status in ['completed', 'cancelled', 'failed'] - assert expected_status != 'in_progress', 'Pass settled Items for terminal stream assertions' - _assert_private_body(events, fixture) - assert len({event['event_id'] for event in events}) == len(events), 'Duplicate SSE event identity' - terminals = [(index, event) for index, event in enumerate(events) - if event['type'] in ['agent.session.turn.' + status for status in ['completed', 'cancelled', 'failed']] - and event['turn']['id'] == turn_id] - assert len(terminals) == 1 and terminals[0][1]['type'] == 'agent.session.turn.' + expected_turn_status - transitions, completed_call_positions = [], [] - for call in calls: - added = [(index, event) for index, event in enumerate(events) - if event['type'] == 'agent.session.turn.item.added' and event['item']['id'] == call['id']] - done = [(index, event) for index, event in enumerate(events) - if event['type'] == 'agent.session.turn.item.done' and event['item']['id'] == call['id']] - assert len(added) == len(done) == 1, 'Missing or duplicate native MCP transitions' - start, end = added[0], done[0] - assert start[0] < end[0] < terminals[0][0], 'Native MCP and terminal Turn ordering differs' - assert start[1]['item']['status'] == 'in_progress', 'No observable native MCP start' - # Native ACP can identify a call before its arguments arrive. - assert start[1]['item']['arguments'] in [None, {}, {'marker': fixture['marker']}], 'Unexpected initial native MCP arguments' - for event in [start[1], end[1]]: - assert event['turn_id'] == turn_id - assert event['item']['type'] == 'mcp_call' - assert event['item']['server_label'] == call['server_label'] and event['item']['name'] == tool - assert end[1]['item'] == call, 'Saved MCP item differs from its completed SSE item' - assert type(start[1]['output_index']) is int and start[1]['output_index'] == end[1]['output_index'] - completed_call_positions.append(end[0]) - transitions.append({'item_id': call['id'], 'added_event_id': start[1]['event_id'], - 'done_event_id': end[1]['event_id'], 'output_index': start[1]['output_index']}) - if expected_status == 'completed': - # Items retain their first-observation position while streaming. - # A native assistant may start before tools and complete after them. - completed_answers = [(index, event) for index, event in enumerate(events) - if event['type'] == 'agent.session.turn.item.done' - and event['turn_id'] == turn_id - and event['item']['type'] == 'message' - and event['item'].get('role') == 'assistant' - and event['item']['status'] == 'completed'] - assert completed_answers, 'Missing completed assistant SSE item' - answer_position, answer_event = completed_answers[-1] - assert max(completed_call_positions) < answer_position < terminals[0][0], 'Assistant completion must follow MCP results and precede the terminal Turn' - assert answer_event['item'] in answers, 'Completed assistant SSE item differs from saved Items' - evidence['assistant_done_event_id'] = answer_event['event_id'] - evidence['transitions'] = transitions - evidence['terminal_event_id'] = terminals[0][1]['event_id'] - return evidence diff --git a/services/core/tests/official_environment_plugins.py b/services/core/tests/official_environment_plugins.py deleted file mode 100644 index 9b8f169db..000000000 --- a/services/core/tests/official_environment_plugins.py +++ /dev/null @@ -1,108 +0,0 @@ -"""Fixed-client helpers for real hosted Plugin and generated-directory acceptance. - -Use with the existing standalone Docker/native runner and Files/Artifacts checks. -This module provides fixtures and assertions, never a model or Runtime substitute. -""" -import base64 -import io -import json -import secrets -import zipfile - - -def plugin_fixture(): - marker = 'plugin-proof-' + secrets.token_hex(20) - files = {'.codex-plugin/plugin.json': json.dumps({ - 'name': 'workspace-proof', 'description': 'Two portable verification Skills.', - 'skills': ['./layout', './more']}), 'shared/proof.txt': marker} - for name, root, relative in [('proof-alpha', 'layout/custom/alpha', '../../../shared/proof.txt'), - ('proof-beta', 'more/beta', '../../shared/proof.txt')]: - files[root + '/SKILL.md'] = ('---\nname: ' + name + '\ndescription: Execute the packaged ' + name + ' verification script.\n---\n' - 'Use your native tools to run `python3 scripts/check.py` from this installed Skill directory. ' - 'Use the provided script; do not recreate it. Report PLUGIN_VERIFIED after success.\n') - files[root + '/scripts/check.py'] = '''import json, os -from pathlib import Path -root = Path(__file__).parent.parent -for name in ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'OPENAI_API_KEY', 'MINIMAX_API_KEY']: - assert name not in os.environ, 'native credential reached Plugin script' -private_paths = json.loads(Path('/workspace/plugin-isolation-paths.json').read_text()) -assert set(private_paths) == {'staging', 'native_history', 'daemon_auth'}, 'private probe paths missing' -for private in private_paths.values(): - try: - with open(private, 'rb') as stream: - exposed = stream.read(1) - except OSError: - continue - # A readable empty mask does not expose the underlying nonempty private file. - assert not exposed, 'private Runtime content readable' -try: - (root / 'SKILL.md').write_text('tamper') -except OSError: - pass -else: - raise AssertionError('installed Skill writable') -value = (root / RELATIVE).read_text() -Path('/workspace/outputs').mkdir(exist_ok=True) -Path(OUTPUT).write_text(value) -print('PLUGIN_VERIFIED') -'''.replace('RELATIVE', repr(relative)).replace('OUTPUT', repr('/workspace/outputs/' + name + '.txt')) - archive = io.BytesIO() - with zipfile.ZipFile(archive, 'w', zipfile.ZIP_DEFLATED) as output: - for path, content in files.items(): - output.writestr('proof/' + path, content) - plugin = {'type': 'inline', 'name': 'workspace-proof', 'description': 'Two portable verification Skills.', - 'source': {'type': 'base64', 'media_type': 'application/zip', - 'data': base64.b64encode(archive.getvalue()).decode()}} - generated = { - '/workspace/generated/source.md': '---\nname: generated-proof\ndescription: Verify the setup-generated Skill snapshot.\n---\n' - 'Run `python3 check.py` from this installed Skill directory with your native tools. Do not recreate it.\n', - '/workspace/generated/check.py': "from pathlib import Path\nimport shutil\n" - "root=Path(__file__).parent\nassert '/initialization/capabilities/' in str(root)\n" - "Path('/workspace/outputs').mkdir(exist_ok=True)\n" - "Path('/workspace/outputs/generated-proof.txt').write_text((root/'proof.txt').read_text())\n" - "shutil.rmtree('/workspace/generated', ignore_errors=True)\nprint('GENERATED_SKILL_VERIFIED')\n", - '/workspace/generated/proof.txt': marker, - } - initial = [{'type': 'inline', 'path': path, 'data': base64.b64encode(content.encode()).decode()} - for path, content in generated.items()] - commands = [{'command': 'cp /workspace/generated/source.md /workspace/generated/SKILL.md; printf initialized > /workspace/setup-once'}] - outputs = {'/workspace/outputs/' + name + '.txt': marker.encode() - for name in ['proof-alpha', 'proof-beta', 'generated-proof']} - return plugin, initial, commands, outputs - - -def verify_plugin_metadata(client, session, plugin): - expected = [{key: plugin[key] for key in ['type', 'name', 'description']}] - resource = client.beta.agents.environments.retrieve(session.environment.id).to_dict() - value = session.to_dict()['environment'] - assert resource['plugins'] == value['plugins'] == expected - assert value['capability_directories'] == ['/workspace/generated'] - assert resource['skills'] == value['skills'] == [] # Configured resource metadata, not native inventory. - assert plugin['source']['data'] not in json.dumps([resource, value]) - - -def verify_plugin_resources(client, foreign, http): - """Run before native creation; resource reads need no allocated Environment.""" - api = client.beta.agents.environments.templates - plugin, initial, commands, _ = plugin_fixture() - source = str(client.base_url).rstrip('/') + '/agents/environments/templates' - headers = {'Authorization': 'Bearer ' + client.api_key, 'OpenAI-Beta': 'agents=v1'} - other = {**headers, 'Authorization': 'Bearer ' + foreign.api_key} - template = api.create(plugins=[plugin], capability_directories=['/workspace/generated'], - files=initial, setup_commands=commands) - try: - metadata = [{key: plugin[key] for key in ['type', 'name', 'description']}] - for body in [template.to_dict(), api.retrieve(template.id).to_dict(), - http.get(source + '/' + template.id, headers=headers).json()]: - assert body['plugins'] == metadata - assert body['capability_directories'] == ['/workspace/generated'] - assert plugin['source']['data'] not in json.dumps(body) - for method in ['GET', 'POST', 'DELETE']: - assert http.request(method, source + '/' + template.id, headers=other, - **({'json': {'plugins': []}} if method == 'POST' else {})).status_code == 404 - assert api.update(template.id, name='preserve').to_dict()['plugins'] == metadata - assert api.update(template.id, plugins=[], capability_directories=[]).to_dict()['plugins'] == [] - assert api.update(template.id, plugins=[plugin]).to_dict()['plugins'] == metadata - assert api.update(template.id, plugins=None, capability_directories=None).to_dict()['plugins'] == [] - finally: - api.delete(template.id) diff --git a/services/core/tests/official_environment_setup.py b/services/core/tests/official_environment_setup.py deleted file mode 100644 index ed3b225b0..000000000 --- a/services/core/tests/official_environment_setup.py +++ /dev/null @@ -1,181 +0,0 @@ -"""Shared real-deployment assertions for confidential environment initialization.""" -import json -import secrets - -from openai import NotFoundError - - -def setup_configuration(proxy=None, *, system_packages=False): - marker = 'setup-private-' + secrets.token_hex(16) + "' $()" - env = {'SETUP_VALUE': marker} - if proxy: - env.update(HTTPS_PROXY=proxy, HTTP_PROXY=proxy, https_proxy=proxy, http_proxy=proxy) - first = """test -f /workspace/initial-inline.bin && mkdir -p /workspace/setup-sub && python3 - <<'SCRIPT' -import os -from pathlib import Path -import packaging -assert packaging.__version__ == '26.0' -assert os.environ['SETUP_VALUE'] -Path('/workspace/setup-sub/order').write_text('first') -SCRIPT -semver 1.2.3 > /workspace/setup-version -""" - second = "test \"$(cat order)\" = first && printf second > order && printf initialized > /workspace/setup-once" - packages = {'npm': ['semver@7.7.2'], 'python': ['packaging==26.0']} - if system_packages: - packages['system'] = ['jq', 'build-essential', 'libpq-dev'] - first = """printf '{"value":42}' | jq -e '.value == 42' && -printf '#include \\nint main(void){return PQlibVersion() > 0 ? 0 : 1;}\\n' > /workspace/system-library.c && -cc -I/usr/include/postgresql /workspace/system-library.c -lpq -o /workspace/system-library && -/workspace/system-library && -""" + first - return { - 'env': env, 'packages': packages, - 'setup_commands': [{'command': first}, {'command': second, 'cwd': '/workspace/setup-sub'}], - }, marker - - -def attach_setup(client, foreign, http, environment, configuration, template_id=None, network="disabled"): - endpoint = str(client.base_url).rstrip('/') - if template_id: - raw = client.beta.agents.environments.templates.with_raw_response.update( - template_id, network={'access': network}, **configuration) - resource = raw.http_response.json() - assert resource['packages'] == {'system': [], **configuration['packages']} - headers = {'Authorization': 'Bearer ' + foreign.api_key, 'OpenAI-Beta': 'agents=v1'} - rejected = http.get(endpoint + '/agents/environments/templates/' + template_id, headers=headers) - assert rejected.status_code == 404 - metadata = [resource, client.beta.agents.environments.templates.list().to_dict()] - assert configuration['env']['SETUP_VALUE'] not in json.dumps(metadata) - assert all('env' not in value and 'setup_commands' not in value for value in [resource]) - return environment - return {**environment, 'network': {'access': network}, **configuration} - - -def verify_setup_metadata(client, session, configuration): - resource = client.beta.agents.environments.retrieve(session.environment.id).to_dict() - assert session.environment.to_dict()['packages'] == {'system': [], **configuration['packages']} - for value in [session.to_dict(), resource]: - assert configuration['env']['SETUP_VALUE'] not in json.dumps(value) - environment = value.get('environment', value) - assert 'env' not in environment and 'setup_commands' not in environment - - -def native_setup_script(marker, network_target=None, *, system_packages=False): - script = f'''from pathlib import Path -import os, subprocess, socket -import packaging -assert packaging.__version__ == '26.0' -assert os.environ['SETUP_VALUE'] == {marker!r} -assert Path('/workspace/setup-sub/order').read_text() == 'second' -assert Path('/workspace/setup-once').read_text() == 'initialized' -for cwd in ['/workspace', '/workspace/setup-sub']: - assert subprocess.check_output(['semver', '1.2.3'], cwd=cwd).strip() == b'1.2.3' - subprocess.run(['python3', '-c', 'import packaging; assert packaging.__version__ == "26.0"'], cwd=cwd, check=True) -''' - if system_packages: - script += '''assert Path('/workspace').samefile('/environment/workspace') -for cwd in ['/environment/workspace', '/environment/workspace/setup-sub']: - assert subprocess.check_output(['jq', '-r', '.value'], input=b'{"value":42}', cwd=cwd).strip() == b'42' -assert subprocess.check_output(['jq', '-r', '.value'], input=b'{"value":42}').strip() == b'42' -subprocess.run(['/workspace/system-library'], check=True) -for path in ['/usr/bin/system-package-write', '/environment/packages/system/usr/bin/system-package-write']: - try: - Path(path).write_text('changed') - except OSError: - pass - else: - raise AssertionError('installed system root is writable') -''' - if network_target: - script += f'''try: - connection = socket.create_connection({network_target!r}, timeout=2) -except OSError: - pass -else: - connection.close() - raise AssertionError('runtime network policy was not applied after setup') -''' - return script - - -def verify_system_package_configuration(client, http): - templates = client.beta.agents.environments.templates - template = templates.create(packages={'system': ['jq'], 'npm': ['semver@7.7.2']}) - endpoint = str(client.base_url).rstrip('/') + '/agents/environments/templates/' + template.id - headers = {'Authorization': 'Bearer ' + client.api_key, 'OpenAI-Beta': 'agents=v1'} - try: - expected = {'system': ['jq'], 'npm': ['semver@7.7.2'], 'python': []} - assert http.get(endpoint, headers=headers).json()['packages'] == expected - assert templates.update(template.id, name='System tools').to_dict()['packages'] == expected - assert templates.update(template.id, packages={'system': ['libpq-dev']}).to_dict()['packages'] == { - 'system': ['libpq-dev'], 'npm': [], 'python': []} - empty = {'system': [], 'npm': [], 'python': []} - for packages in [{'system': None}, {'system': []}, None]: - assert templates.update(template.id, packages=packages).to_dict()['packages'] == empty - for packages in [{'system': [None]}, {'system': ['']}, {'system': ['-unsafe-option']}]: - response = http.post(endpoint, headers=headers, json={'packages': packages}) - assert response.status_code == 400 - assert '-unsafe-option' not in response.text - assert templates.retrieve(template.id).to_dict()['packages'] == empty - finally: - templates.delete(template.id) - - -def verify_setup_failure(client, http, agent, until): - """Actual Provider initialization must fail before any native Turn starts.""" - sessions = client.beta.agents.sessions - for command in [{'command': 'echo confidential-setup-failure >&2; exit 7'}, - {'command': 'touch /workspace/unexpected', 'cwd': '/missing-setup-cwd'}]: - session = sessions.create(agent=agent, environment={ - 'type': 'openai_hosted', 'setup_commands': [command, - {'command': 'touch /workspace/unexpected-later-step'}]}) - try: - until(lambda: client.beta.agents.environments.retrieve(session.environment.id).status == 'failed', 180) - assert sessions.turns.list(session.id).data == [] - resource = sessions.retrieve(session.id).to_dict() - assert 'confidential-setup-failure' not in json.dumps(resource) - assert 'setup_commands' not in resource['environment'] - finally: - sessions.delete(session.id) - - -def verify_saved_agent_setup_identity(client, http): - sessions = client.beta.agents.sessions - agent = client.beta.agents.create(model='kimi-k3') - created = [] - secret = 'intent-canary-' + secrets.token_hex(12) - variants = [ - {'type': 'openai_hosted'}, - {'type': 'openai_hosted', 'env': {'VALUE': secret}}, - {'type': 'openai_hosted', 'env': {'VALUE': secret + '-changed'}}, - {'type': 'openai_hosted', 'setup_commands': [{'command': 'true'}]}, - {'type': 'openai_hosted', 'setup_commands': [{'command': 'false'}]}, - {'type': 'openai_hosted', 'env': {'VALUE': secret}, 'setup_commands': [{'command': 'true'}]}, - ] - try: - for index in [0, 1, 3]: - key = 'setup-intent-' + secrets.token_hex(12) - original = sessions.create(agent_id=agent.id, environment=variants[index], - extra_headers={'Idempotency-Key': key}) - created.append((original, key, index)) - client.beta.agents.delete(agent.id) - endpoint = str(client.base_url).rstrip('/') + '/agents/sessions' - for original, key, index in created: - assert sessions.create(agent_id=agent.id, environment=variants[index], - extra_headers={'Idempotency-Key': key}).id == original.id - for changed, environment in enumerate(variants): - if changed == index: - continue - response = http.post(endpoint, json={'agent_id': agent.id, 'environment': environment}, - headers={'Authorization': 'Bearer ' + client.api_key, - 'OpenAI-Beta': 'agents=v1', 'Idempotency-Key': key}) - assert response.status_code == 409, (index, changed, response.status_code) - assert secret not in response.text - finally: - for session, _, _ in created: - sessions.delete(session.id) - try: - client.beta.agents.delete(agent.id) - except NotFoundError: - pass diff --git a/services/core/tests/official_environment_skill_references.py b/services/core/tests/official_environment_skill_references.py deleted file mode 100644 index b07afebb2..000000000 --- a/services/core/tests/official_environment_skill_references.py +++ /dev/null @@ -1,33 +0,0 @@ -"""Real-model Skill-reference proof using the shared inline installation fixture.""" -import base64 -import io -import json -import zipfile - -from official_environment_skills import inline_skill -from official_session_artifacts import verify_session_artifacts - - -def upload_reference_skill(client): - inline, expected = inline_skill() - with zipfile.ZipFile(io.BytesIO(base64.b64decode(inline["source"]["data"]))) as archive: - files = [(name, archive.read(name), "application/octet-stream") for name in archive.namelist()] - skill = client.skills.create(files=files) - assert skill.default_version == "1" - return skill, expected - - -def verify_reference_metadata(client, session, skill, expected): - metadata = [{"type": "skill_reference", "skill_id": skill.id, "version": "1", - "name": skill.name, "description": skill.description}] - environment = client.beta.agents.environments.retrieve(session.environment.id) - assert session.environment.to_dict()["skills"] == metadata - assert environment.to_dict()["skills"] == metadata - assert expected.decode() not in json.dumps([session.to_dict(), environment.to_dict()]) - - -def verify_reference_output(client, foreign, http, session, artifacts): - files = client.beta.agents.environments.files.list(session.environment.id, path="/workspace/outputs").data - assert [(file.path, file.size_bytes) for file in files] == [ - ("/workspace/outputs/skill-proof.txt", len(next(iter(artifacts.values()))["/workspace/outputs/skill-proof.txt"]))] - verify_session_artifacts(client, foreign, http, session.id, session.environment.id, artifacts) diff --git a/services/core/tests/official_environment_skills.py b/services/core/tests/official_environment_skills.py deleted file mode 100644 index 579bbcb43..000000000 --- a/services/core/tests/official_environment_skills.py +++ /dev/null @@ -1,66 +0,0 @@ -"""Real native-model acceptance helpers for encrypted inline Skill initialization.""" -import base64 -import io -import json -import secrets -import zipfile - - -def inline_skill(): - marker = 'skill-private-' + secrets.token_hex(20) - manifest = """--- -name: proof-skill -description: Verify the initialized workspace and publish the Skill proof. ---- -Run `python3 /environment/initialization/capabilities/skills/proof-skill/scripts/check.py`. -Stop on any failed assertion. Report INITIAL_FILES_VERIFIED and SKILL_VERIFIED. -""" - script = f'''import os, runpy -from pathlib import Path -for name in ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'OPENAI_API_KEY', 'MINIMAX_API_KEY']: - assert name not in os.environ, 'native credential reached a Skill helper' -for path in ['/environment/staging/initial-files-private-canary', '/home/runtime/.oac/daemon/default/auth.json']: - assert not os.access(path, os.R_OK), 'private Runtime state reached a Skill helper' -manifest = Path('/environment/initialization/capabilities/skills/proof-skill/SKILL.md') -try: - manifest.write_text('tampered') -except OSError: - pass -else: - raise AssertionError('Skill content was writable') -runpy.run_path('/workspace/verify.py') -Path('/workspace/outputs/skill-proof.txt').write_text({marker!r}) -print('SKILL_VERIFIED') -''' - data = io.BytesIO() - with zipfile.ZipFile(data, 'w', zipfile.ZIP_DEFLATED) as archive: - archive.writestr('proof-skill/SKILL.md', manifest) - archive.writestr('proof-skill/scripts/check.py', script) - return { - 'type': 'inline', 'name': 'proof-skill', - 'description': 'Verify the initialized workspace and publish the Skill proof.', - 'source': {'type': 'base64', 'media_type': 'application/zip', - 'data': base64.b64encode(data.getvalue()).decode()}, - }, marker.encode() - - -def attach_skills(client, foreign, http, environment, skill, template_id=None): - endpoint = str(client.base_url).rstrip('/') - if template_id: - response = client.beta.agents.environments.templates.with_raw_response.update(template_id, skills=[skill]) - metadata = {key: skill[key] for key in ['type', 'name', 'description']} - assert response.http_response.json()['skills'] == [metadata] - assert skill['source']['data'] not in json.dumps(response.http_response.json()) - rejected = http.get(endpoint + '/agents/environments/templates/' + template_id, - headers={'Authorization': 'Bearer ' + foreign.api_key, 'OpenAI-Beta': 'agents=v1'}) - assert rejected.status_code == 404 - return environment - return {**environment, 'skills': [skill]} - - -def verify_skills_metadata(client, session, skill): - expected = [{key: skill[key] for key in ['type', 'name', 'description']}] - environment = client.beta.agents.environments.retrieve(session.environment.id).to_dict() - assert session.environment.to_dict()['skills'] == expected - assert environment['skills'] == expected - assert skill['source']['data'] not in json.dumps([session.to_dict(), environment]) diff --git a/services/core/tests/official_environment_templates.py b/services/core/tests/official_environment_templates.py deleted file mode 100644 index 16d2552b1..000000000 --- a/services/core/tests/official_environment_templates.py +++ /dev/null @@ -1,115 +0,0 @@ -"""Pinned SDK and raw HTTP acceptance for the basic Environment Template profile. - -Run against the same independently deployed service used for actual native/model -acceptance. This module creates no fake Provider, Runtime or model endpoint. -""" -import uuid - - -def verify_environment_templates(client, foreign, http): - api = client.beta.agents.environments.templates - other = foreign.beta.agents.environments.templates - base = str(client.base_url).rstrip('/') + '/agents/environments/templates' - headers = {'Authorization': 'Bearer ' + client.api_key, 'OpenAI-Beta': 'agents=v1'} - foreign_headers = {**headers, 'Authorization': 'Bearer ' + foreign.api_key} - owned = [] - retained = [] - try: - assert http.get(base, headers={'OpenAI-Beta': 'agents=v1'}).status_code == 401 - assert http.get(base, headers={'Authorization': 'Bearer ' + client.api_key}).status_code == 400 - assert api.list().data == [] - for values in ({}, {'packages': {}}, {'packages': {'npm': None}}, {'name': None, 'network': None, 'env': None, 'setup_commands': None}, - {'network': {'access': 'restricted', 'allowed_domains': ['Example.com', 'api.example.com', 'example.com']}}, - {'name': ' preserved ', 'network': {'access': 'disabled'}, 'files': [], - 'plugins': [], 'skills': [], 'packages': {'python': [], 'npm': None}}): - response = api.with_raw_response.create(**values) - assert response.status_code == 201 - body, template = response.http_response.json(), response.parse() - owned.append(template.id) - assert set(body) == {'id', 'object', 'created_at', 'updated_at', 'name', 'network', - 'packages', 'capability_directories', 'files', 'plugins', 'skills'} - assert body['object'] == 'agent.environment.template' - assert body['name'] == values.get('name') - assert body['network'] == {'access': (values.get('network') or {}).get('access', 'enabled'), - 'allowed_domains': (values.get('network') or {}).get('allowed_domains') or []} - assert body['packages'] == {'python': [], 'npm': [], 'system': []} - for field in ['capability_directories', 'files', 'plugins', 'skills']: - assert body[field] == [] - assert body['created_at'] == body['updated_at'] - assert api.retrieve(template.id) == template - assert http.get(base + '/' + template.id, headers=headers).json() == body - before = api.retrieve(owned[-1]) - updated = api.update(owned[-1], name='changed') - assert updated.name == 'changed' and updated.network == before.network - assert updated.created_at == before.created_at - updated = api.update(owned[-1], name=None, network=None) - assert updated.name is None and updated.network.access == 'enabled' - touched = api.update(owned[-1]) - assert touched.updated_at >= updated.updated_at - assert {k: v for k, v in touched.to_dict().items() if k != "updated_at"} == { - k: v for k, v in updated.to_dict().items() if k != "updated_at"} - assert [v.id for v in api.list(order='asc', limit=1)] == owned - assert [v.id for v in api.list(order='desc', limit=2)] == owned[::-1] - page = api.list(order='asc', limit=2) - assert page.has_more and page.first_id == owned[0] and page.last_id == owned[1] - assert [v.id for v in api.list(order='asc', after=page.last_id).data] == owned[2:] - foreign_template = other.create() - try: - for method, path, body in [('GET', '/' + owned[0], None), - ('POST', '/' + owned[0], {'name': 'forbidden'}), - ('DELETE', '/' + owned[0], None), - ('GET', '?after=' + owned[0], None)]: - assert http.request(method, base + path, headers=foreign_headers, json=body).status_code == 404 - assert [v.id for v in other.list()] == [foreign_template.id] - finally: - other.delete(foreign_template.id) - for query in ['limit=bad', 'limit=-1', 'order=wrong', 'limit=1&limit=2']: - response = http.get(base + '?' + query, headers=headers) - assert response.status_code == 400 and response.json()['error']['code'] == 'invalid_request_error' - everything = http.get(base + '?limit=100', headers=headers).json() - assert http.get(base + '?limit=101&unknown=1', headers=headers).json() == everything - first = http.get(base + '?limit=0', headers=headers).json() - assert [value['id'] for value in first['data']] == [everything['data'][0]['id']] and first['has_more'] == (len(everything['data']) > 1) - canary = 'template-private-' + uuid.uuid4().hex - for body in [{'env': {'PATH': canary}}, {'setup_commands': [{'command': canary, 'cwd': 'relative'}]}, - {'files': [{'type': 'inline', 'path': '/workspace/a', 'data': canary}]}, - {'packages': {'system': ['-' + canary]}}, {'skills': [{'type': 'inline', 'data': canary}]}, - {'plugins': [{'type': 'inline', 'data': canary}]}, - {'capability_directories': ['/private']}, - {'network': {'access': 'restricted', 'allowed_domains': ['*.example.com']}}, - {'name': ''}, {'unknown': canary}]: - for path in ['', '/' + owned[0]]: - response = http.post(base + path, headers=headers, json=body) - assert response.status_code == 400 and canary not in response.text - assert [v.id for v in api.list(order='asc')] == owned - assert canary not in http.get(base, headers=headers).text - deleted = api.delete(owned.pop()) - assert deleted.deleted and deleted.object == 'agent.environment.template.deleted' - for method in ['GET', 'DELETE']: - assert http.request(method, base + '/' + deleted.id, headers=headers).status_code == 404 - enabled = api.create(name='real execution', network={'access': 'enabled'}) - retained.append(enabled.id) - disabled = api.create(name='real network isolation', network={'access': 'disabled'}) - retained.append(disabled.id) - return enabled.id, disabled.id - except BaseException: - for template_id in retained: - api.delete(template_id) - raise - finally: - for template_id in owned: - api.delete(template_id) - - -def verify_template_session_rejections(client, foreign, http, agent, enabled, disabled): - base = str(client.base_url).rstrip('/') + '/agents/sessions' - headers = {'Authorization': 'Bearer ' + client.api_key, 'OpenAI-Beta': 'agents=v1'} - for reference, override, status, token in [ - (disabled, {'network': {'access': 'enabled'}}, 400, client.api_key), - (str(uuid.uuid4()), {}, 404, client.api_key), - (enabled, {}, 404, foreign.api_key), - ]: - response = http.post(base, headers={**headers, 'Authorization': 'Bearer ' + token}, - json={'agent': agent, 'environment': {'type': 'openai_hosted', - 'environment_template_id': reference, **override}}) - assert response.status_code == status, response.text diff --git a/services/core/tests/official_hosted_functions_native.py b/services/core/tests/official_hosted_functions_native.py deleted file mode 100644 index a573d89ee..000000000 --- a/services/core/tests/official_hosted_functions_native.py +++ /dev/null @@ -1,111 +0,0 @@ -"""Common real-harness acceptance for hosted functions and workspace execution.""" - -import importlib.metadata -import json -from pathlib import Path -import time -import uuid - -from session_cleanup import delete_session - - -def verify_hosted_functions(client, foreign, http, model, restart, evidence): - """restart(session_id, environment_id) restarts the operator-owned deployment.""" - pin = json.loads((Path(__file__).resolve().parents[3] / "contracts/agents-api/upstream.json").read_text()) - distribution = importlib.metadata.distribution("openai") - source = json.loads(distribution.read_text("direct_url.json") or "{}") - assert distribution.version == pin["sdk_version"] and source["vcs_info"]["commit_id"] == pin["commit"] - sessions = client.beta.agents.sessions - endpoint = str(client.base_url).rstrip("/") + "/agents/sessions" - headers = {"Authorization": "Bearer " + client.api_key, "OpenAI-Beta": "agents=v1"} - marker = "function-memory-" + uuid.uuid4().hex - private_error = "private-handler-" + uuid.uuid4().hex - calls, observed, checks = [], [], [] - session = sessions.create(agent={"model": model, "instructions": "Follow the requested tool calls exactly. Never repeat a failed call.", "tools": [{ - "type": "function", "name": "lookup", "description": "Retrieve the requested test value.", - "parameters": {"type": "object", "properties": {"key": {"type": "string"}}, "required": ["key"], "additionalProperties": False}, - }]}, environment={"type": "openai_hosted"}) - - def until(predicate, timeout=120): - deadline = time.monotonic() + timeout - while time.monotonic() < deadline: - value = predicate() - if value: - return value - time.sleep(0.2) - raise AssertionError("Public workflow did not reach expected state") - - def items(): - response = http.get(endpoint + "/" + session.id + "/items", headers=headers, params={"limit": 100, "order": "asc"}) - assert response.status_code == 200 - raw = response.json()["data"] - assert raw == [item.to_dict() for item in sessions.items.list(session.id, limit=100, order="asc").data] - return raw - - def invoke(text, key, handler): - with sessions.stream(session.id, input=text, tool_handlers={"lookup": handler}, idempotency_key=key, timeout=240) as stream: - events = [event.to_dict() for event in stream] - observed.append(events) - terminals = [e for e in events if e["type"] in ("agent.session.turn.completed", "agent.session.turn.failed", "agent.session.turn.cancelled")] - assert len(terminals) == 1 and terminals[0]["type"] == "agent.session.turn.completed" - assert events[-1]["type"] == "agent.session.idle" - function = [e for e in events if e["type"] == "agent.session.turn.item.added" and e["item"]["type"] == "function_call"] - result = [e for e in events if e["type"] == "agent.session.turn.item.added" and e["item"]["type"] == "function_call_output"] - assert len(function) == len(result) == 1 - assert function[0]["item"]["call_id"] == result[0]["item"]["call_id"] - assert events.index(function[0]) < events.index(result[0]) < events.index(terminals[0]) < len(events) - 1 - assert sessions.retrieve(session.id).required_actions == [] - return terminals[0]["turn"]["id"], result[0]["item"] - - try: - def success(arguments): - assert arguments == {"key": "success"} - calls.append(arguments) - return marker - - turn, result = invoke("Call lookup once with key success. Remember its returned string in conversation. Then use the native shell to create /workspace/outputs/function.txt containing exactly that string, with no newline. Do not call any other function.", "function-success", success) - assert result["output"] == marker and "error" in result and result["error"] is None - artifacts = list(sessions.artifacts.list(session.id, limit=100)) - output = [a for a in artifacts if a.turn_id == turn and a.path == "/workspace/outputs/function.txt"] - assert len(output) == 1 - with sessions.artifacts.with_streaming_response.content(output[0].id, session_id=session.id) as response: - assert response.read() == marker.encode() - listing = client.beta.agents.environments.files.list(session.environment.id, path="/workspace/outputs") - assert any(f.path == "/workspace/outputs/function.txt" for f in listing) - assert any(i["type"] == "function_call_output" and i.get("output") == marker for i in items()) - checks.append("same_turn_function_native_file_and_public_artifact") - - restart(session.id, session.environment.id) - - def failure(arguments): - assert arguments == {"key": "failure"} - calls.append(arguments) - raise RuntimeError(private_error) - - _, result = invoke("Call lookup exactly once with key failure. If it fails, do not retry. Then reply with the exact string returned by lookup in our first turn, using conversation history and no file tools.", "function-failure", failure) - assert result["error"] == "Tool handler failed." and "output" in result and result["output"] is None - messages = [i for i in items() if i["type"] == "message" and i["role"] == "assistant"] - assert marker in "\n".join(p.get("text", "") for p in messages[-1]["content"]) - assert len(calls) == 2 - checks.append("cold_history_continuation_and_public_handler_error") - - sessions.events.create(session.id, events=[{"type": "agent.session.input.message", "input": [{"role": "user", "content": [{"type": "input_text", "text": "Call lookup once with key pending and wait for the result."}]}]}], idempotency_key="function-pending") - until(lambda: sessions.retrieve(session.id).required_actions) - pending = [i for i in items() if i["type"] == "function_call"][-1] - foreign_headers = {**headers, "Authorization": "Bearer " + foreign.api_key} - result_event = {"type": "agent.session.input.tool_result", "call_id": pending["call_id"], "turn_id": sessions.turns.list(session.id).data[0].id, "success": True, "output": "foreign-value"} - response = http.post(endpoint + "/" + session.id + "/events", headers=foreign_headers, json={"events": [result_event]}) - assert response.status_code == 404 - cancel = [{"type": "agent.session.input.cancel"}] - sessions.events.create(session.id, events=cancel, idempotency_key="cancel-pending") - sessions.events.create(session.id, events=cancel, idempotency_key="cancel-pending") - until(lambda: len(sessions.turns.list(session.id).data) == 3 and sessions.turns.list(session.id).data[0].status == "cancelled") - assert sessions.retrieve(session.id).required_actions == [] - assert not any(i["type"] == "function_call_output" and i["call_id"] == pending["call_id"] for i in items()) - checks.append("pending_function_tenant_isolation_and_cancel_retry") - proof = {"checks": checks, "session": session.id, "calls": calls, "events": observed, "items": items()} - assert private_error not in json.dumps(proof) - Path(evidence).write_text(json.dumps(proof, indent=2)) - return checks - finally: - delete_session(sessions, session.id) diff --git a/services/core/tests/official_hosted_structured_native.py b/services/core/tests/official_hosted_structured_native.py deleted file mode 100644 index e4d23fb19..000000000 --- a/services/core/tests/official_hosted_structured_native.py +++ /dev/null @@ -1,214 +0,0 @@ -"""Structured final answers after real native Docker workspace/tool execution.""" -import importlib.metadata -import json -from pathlib import Path -import time -import uuid - -from session_cleanup import delete_session - - -def verify_hosted_structured(client, foreign, http, model, kind, restart, evidence): - pin = json.loads((Path(__file__).resolve().parents[3] / "contracts/agents-api/upstream.json").read_text()) - dist = importlib.metadata.distribution("openai") - assert dist.version == pin["sdk_version"] - assert json.loads(dist.read_text("direct_url.json"))["vcs_info"]["commit_id"] == pin["commit"] - sessions = client.beta.agents.sessions - root = str(client.base_url).rstrip("/") + "/agents" - headers = {"Authorization": "Bearer " + client.api_key, "OpenAI-Beta": "agents=v1"} - other_headers = {**headers, "Authorization": "Bearer " + foreign.api_key} - proof = {"engine": kind, "checks": [], "runs": [], "calls": []} - owned = [] - saved = None - schema = {"type": "object", "properties": {name: {"type": "string"} for name in ("memory", "path", "marker")}, - "required": ["memory", "path", "marker"], "additionalProperties": False} - output_format = {"type": "json_schema", "schema": schema} - tool = {"type": "function", "name": "remember", "description": "Return the memory value for this task.", - "parameters": {"type": "object", "properties": {}, "additionalProperties": False}} - - def save(): - Path(evidence).write_text(json.dumps(proof, indent=2)) - - def check(name): - proof["checks"].append(name) - save() - print(name, flush=True) - - def until(fn, timeout=240): - deadline = time.monotonic() + timeout - while time.monotonic() < deadline: - value = fn() - if value: - return value - time.sleep(0.2) - raise AssertionError("Public workflow did not reach expected state") - - def message(text): - return {"type": "agent.session.input.message", "input": [{"role": "user", "content": [{"type": "input_text", "text": text}]}]} - - def prompt(path, marker, recall=False): - source = "Recall the last memory from conversation history without calling remember or reading files." if recall else "Call remember exactly once to obtain the memory." - return source + " Use native workspace tools to write that exact memory with no newline to " + path + ". Create the parent directory if needed. Return memory, that path, and marker '" + marker + "' using the requested output format." - - def items(sid): - response = http.get(root + "/sessions/" + sid + "/items", headers=headers, params={"order": "asc", "limit": 100}) - assert response.status_code == 200 and not response.json()["has_more"] - raw = response.json()["data"] - assert raw == [i.to_dict() for i in sessions.items.list(sid, order="asc", limit=100).data] - return raw - - def respond(sid, action, memory): - assert action.name == "remember" - result = {"type": "agent.session.input.tool_result", "turn_id": action.turn_id, "call_id": action.call_id, "success": True, "output": memory} - key = "result-" + action.call_id - assert http.post(root + "/sessions/" + sid + "/events", headers=other_headers, json={"events": [result]}).status_code == 404 - sessions.events.create(sid, events=[result], idempotency_key=key) - sessions.events.create(sid, events=[result], idempotency_key=key) - response = http.post(root + "/sessions/" + sid + "/events", headers={**headers, "Idempotency-Key": key}, json={"events": [{**result, "output": "conflict"}]}) - assert response.status_code == 409 - proof["calls"].append(result) - - def terminal(sid, count, status="completed"): - def finished(): - turns = sessions.turns.list(sid, order="asc", limit=100).data - if len(turns) < count or turns[-1].status not in {"completed", "failed", "cancelled"}: - return False - assert len(turns) == count and turns[-1].status == status, [t.to_dict() for t in turns] - return turns[-1] - return until(finished) - - def final(sid, eid, turn, expected, events=None): - stored = items(sid) - answers = [i for i in stored if i["type"] == "message" and i.get("role") == "assistant" and i.get("phase") == "final_answer" and i["turn_id"] == turn.id] - assert len(answers) == 1, answers - answer = answers[0] - raw = answer["content"][0]["text"] - assert json.loads(raw) == expected, raw - if events: - added = next(i for i, e in enumerate(events) if e["type"] == "agent.session.turn.item.added" and e["item"]["id"] == answer["id"]) - done = next(i for i, e in enumerate(events) if e["type"] == "agent.session.turn.item.done" and e["item"]["id"] == answer["id"]) - complete = next(i for i, e in enumerate(events) if e["type"] == "agent.session.turn.completed") - assert added < done < complete - assert next(e["text"] for e in events if e["type"] == "agent.session.turn.output_text.done" and e["item_id"] == answer["id"]) == raw - # The native final is framed like a streamed message; deltas carry its exact text (EVT-10). - assert events[added]["item"]["status"] == "in_progress" and events[added]["item"]["content"] == [] - deltas = [e["delta"] for e in events if e["type"] == "agent.session.turn.output_text.delta" and e["item_id"] == answer["id"]] - assert deltas and "".join(deltas) == raw, deltas - artifacts = [a for a in sessions.artifacts.list(sid, limit=100) if a.path == expected["path"] and a.turn_id == turn.id] - assert len(artifacts) == 1 - with sessions.artifacts.with_streaming_response.content(artifacts[0].id, session_id=sid) as response: - assert response.read() == expected["memory"].encode() - assert any(f.path == expected["path"] for f in client.beta.agents.environments.files.list(eid, path="/workspace/outputs")) - assert http.get(root + "/sessions/" + sid + "/artifacts/" + artifacts[0].id + "/content", headers=other_headers).status_code == 404 - proof.setdefault("answers", []).append(answer) - - def run(sid, text, handler=None): - events = [] - handled = False - try: - with sessions.events.stream(sid, timeout=300) as stream: - sessions.events.create(sid, events=[message(text)]) - for event in stream: - events.append(event.to_dict()) - if event.type == "agent.session.requires_action": - assert handler is not None and not handled and len(event.session.required_actions) == 1 - handled = True - handler(event.session.required_actions[0]) - assert event.type not in {"agent.session.failed", "agent.session.turn.failed"}, event.to_dict() - if event.type == "agent.session.idle" and any(e["type"] == "agent.session.turn.created" for e in events): - break - else: - raise AssertionError("SSE ended without idle") - finally: - proof["runs"].append(events) - save() - assert handled == (handler is not None) - types = [e["type"] for e in events] - terminals = [t for t in types if t in {"agent.session.turn.completed", "agent.session.turn.cancelled"}] - assert len(terminals) == 1 and types[-1] == "agent.session.idle" - assert types.index("agent.session.turn.created") < types.index(terminals[0]) < len(types) - 1 - assert len({e["event_id"] for e in events}) == len(events) - return events - - try: - saved = client.beta.agents.create(model=model, text={"format": output_format}, tools=[tool]) - first_memory = uuid.uuid4().hex - path = "/workspace/outputs/initial.txt" - session = sessions.create(agent_id=saved.id, environment={"type": "openai_hosted"}, input=prompt(path, "initial")) - sid, eid = session.id, session.environment.id - owned.append(sid) - proof.update(session=sid, environment=eid, agent=saved.id, format=output_format) - assert session.agent.text.format.to_dict() == output_format - def pending(): - current = sessions.retrieve(sid) - assert current.status != "failed", current.to_dict() - return current.required_actions - action = until(pending)[0] - respond(sid, action, first_memory) - first = terminal(sid, 1) - final(sid, eid, first, {"memory": first_memory, "path": path, "marker": "initial"}) - check("saved_schema_initial_function_native_file_and_final_json") - - memory = uuid.uuid4().hex - path = "/workspace/outputs/active.txt" - def active(action): - incoming = [message("Use marker 'active' for the final result, replacing the earlier marker. Keep the requested file path and memory task.")] - sessions.events.create(sid, events=incoming, idempotency_key="active-marker") - sessions.events.create(sid, events=incoming, idempotency_key="active-marker") - respond(sid, action, memory) - events = run(sid, prompt(path, "obsolete"), active) - second = terminal(sid, 2) - final(sid, eid, second, {"memory": memory, "path": path, "marker": "active"}, events) - outputs = [i for i in items(sid) if i["type"] == "function_call_output"] - assert len(outputs) == 2 and [i["output"] for i in outputs] == [first_memory, memory] - check("prepared_schema_active_receipt_function_retry_and_exact_sse") - - for suffix in ("", "/items", "/turns", "/artifacts"): - assert http.get(root + "/sessions/" + sid + suffix, headers=other_headers).status_code == 404 - assert http.get(root + "/environments/" + eid + "/files", headers=other_headers).status_code == 404 - assert http.post(root + "/sessions", headers=other_headers, json={"agent_id": saved.id, "environment": {"type": "openai_hosted"}}).status_code == 404 - before = items(sid) - restart(sid, eid) - assert items(sid) == before and sessions.retrieve(sid).agent.text.format.to_dict() == output_format - path = "/workspace/outputs/resumed.txt" - events = run(sid, prompt(path, "resumed", recall=True)) - third = terminal(sid, 3) - final(sid, eid, third, {"memory": memory, "path": path, "marker": "resumed"}, events) - assert len([i for i in items(sid) if i["type"] == "function_call"]) == 2 - check("cold_core_runtime_continuation_without_replay_and_tenant_isolation") - - def cancel(action): - for _ in range(2): - sessions.events.create(sid, events=[{"type": "agent.session.input.cancel"}], idempotency_key="cancel-pending") - run(sid, prompt("/workspace/outputs/cancelled.txt", "cancelled"), cancel) - cancelled = terminal(sid, 4, "cancelled") - assert not any(i.get("turn_id") == cancelled.id and i["type"] == "message" and i.get("phase") == "final_answer" for i in items(sid)) - assert not sessions.retrieve(sid).required_actions - check("pending_cancellation_has_no_structured_final") - - plain = sessions.create(agent_id=saved.id, agent={"text": {"format": {"type": "text"}}}, environment={"type": "openai_hosted"}) - owned.append(plain.id) - run(plain.id, "Do not call remember. Use native tools to write exactly PLAIN_OK to /workspace/plain.txt with no newline, then reply only PLAIN_OK.") - assert any(i["type"] == "message" and i.get("role") == "assistant" and "PLAIN_OK" in i["content"][0].get("text", "") for i in items(plain.id)) - foreign_result = proof["calls"][0] - assert http.post(root + "/sessions/" + plain.id + "/events", headers=headers, json={"events": [foreign_result]}).status_code in {400, 404, 409} - artifact = list(sessions.artifacts.list(sid, limit=100))[0] - assert http.get(root + "/sessions/" + plain.id + "/artifacts/" + artifact.id + "/content", headers=headers).status_code == 404 - check("text_override_and_same_tenant_session_isolation") - - inline = sessions.create(agent={"model": model, "text": {"format": output_format}}, environment={"type": "openai_hosted"}) - owned.append(inline.id) - inline_memory = uuid.uuid4().hex - path = "/workspace/outputs/inline.txt" - events = run(inline.id, "Use native tools to create the parent directory and write exactly " + inline_memory + " with no newline to " + path + ". Return that memory, path, and marker 'inline' using the requested output format.") - final(inline.id, inline.environment.id, terminal(inline.id, 1), {"memory": inline_memory, "path": path, "marker": "inline"}, events) - assert inline.agent.text.format.to_dict() == output_format - check("inline_schema_prepared_execution_native_file_and_final_json") - proof.update(passed=True, items=items(sid), turns=[t.to_dict() for t in sessions.turns.list(sid, order="asc", limit=100).data]) - return proof["checks"] - finally: - save() - for sid in reversed(owned): - delete_session(sessions, sid) - if saved: - client.beta.agents.delete(saved.id) diff --git a/services/core/tests/session_cleanup.py b/services/core/tests/session_cleanup.py deleted file mode 100644 index 3560948f3..000000000 --- a/services/core/tests/session_cleanup.py +++ /dev/null @@ -1,39 +0,0 @@ -"""Session cleanup for acceptance scripts that must not mask an earlier failure.""" - -import sys -import time - -from openai import APIStatusError - - -def delete_session(sessions, session_id, timeout=60): - """Delete an owned Session from a ``finally`` block. - - Core deletes only a durably idle or failed Session. When deletion returns the - 409 ``conflict_error`` for a busy Session, cancel once, poll until the Session - is idle or failed without required actions, then delete once more. A cleanup - failure is logged while another exception is propagating, so the original - assertion stays visible, and raised otherwise. - """ - original = sys.exc_info()[1] - try: - try: - sessions.delete(session_id) - return - except APIStatusError as exc: - if exc.status_code != 409 or getattr(exc, "code", None) != "conflict_error": - raise - sessions.events.create(session_id, events=[{"type": "agent.session.input.cancel"}]) - deadline = time.monotonic() + timeout - while True: - state = sessions.retrieve(session_id) - if state.status in ("idle", "failed") and not state.required_actions: - break - if time.monotonic() >= deadline: - break - time.sleep(1) - sessions.delete(session_id) - except Exception as exc: - if original is None: - raise - print(f"Session {session_id} cleanup failed after an earlier error: {exc!r}", file=sys.stderr) diff --git a/website/README.md b/website/README.md index 6fe19efa3..b8e982057 100644 --- a/website/README.md +++ b/website/README.md @@ -58,4 +58,4 @@ Pull request checks build under the published `/OpenAgentCore/` path. Output tes ## Maintain bilingual documentation -The translation rules are in [Documentation](../AGENTS.md#documentation). Resolve relative links from the Chinese file to the corresponding Chinese document or shared original asset. The Harness catalog generator produces both language editions. Extend other generators before translating their output; preserve generated regions in otherwise authored pages byte for byte, with a Chinese explanation before the region. Update `source_hash` only after reconciling the complete translation with the source. `pnpm --dir website check-translations` checks coverage and freshness before the build. `docs.json` remains the single navigation order; titles are read from the selected language's source. +The translation rules are in [Documentation](../AGENTS.md#documentation). Give each Chinese heading the English heading ID as an explicit `{#id}` anchor. Resolve relative links from the Chinese file to the corresponding Chinese document or shared original asset. The Harness catalog generator produces both language editions. Extend other generators before translating their output; preserve generated regions in otherwise authored pages byte for byte, with a Chinese explanation before the region. Update `source_hash` only after reconciling the complete translation with the source. `pnpm --dir website check-translations` checks coverage and freshness before the build. `docs.json` remains the single navigation order; titles are read from the selected language's source.