Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions apps/daemon/internal/agent/codex/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,12 @@ type SessionPlan struct {
// The codex binary itself is resolved via PATH only.
func BuildSessionPlan(stateRoot, agentStateKey string, opts map[string]any, controls *proto.ExecutionControls) (SessionPlan, error) {
plan := SessionPlan{
// Harnesses run unattended: Codex never offers its ask-the-user tool.
ExtraConfig: [][2]string{{"tools.experimental_request_user_input.enabled", "false"}},
Cleanup: func() {},
// Harnesses run unattended: Codex never offers its ask-the-user tool,
// and an unreachable model host fails the Turn after the provider's
// bounded retries instead of waiting for the network forever.
ExtraConfig: [][2]string{{"tools.experimental_request_user_input.enabled", "false"}},
DisableFeatures: []string{"unbounded_connection_retries"},
Cleanup: func() {},
}

nativeConfig, err := harnessconfiguration.Configuration().PrepareHarnessConfig(opts)
Expand Down
11 changes: 0 additions & 11 deletions apps/daemon/internal/agent/codex/provider_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,6 @@ type providerConfig struct {
// WireAPI is "responses" (codex Responses API) or "chat" (rejected
// upstream since codex-rs 0.140). Empty defaults to "responses".
WireAPI string
// RequestMaxRetries / StreamMaxRetries map to codex's
// request_max_retries / stream_max_retries config keys. 0 omits.
RequestMaxRetries int
StreamMaxRetries int
}

// writeCodexProviderConfig writes (or appends to) <codexHome>/config.toml
Expand Down Expand Up @@ -105,13 +101,6 @@ func writeCodexProviderConfig(codexHome string, cfg providerConfig) error {
b.WriteString(tomlQuoteString(wireAPI))
b.WriteByte('\n')

if cfg.RequestMaxRetries > 0 {
fmt.Fprintf(&b, "request_max_retries = %d\n", cfg.RequestMaxRetries)
}
if cfg.StreamMaxRetries > 0 {
fmt.Fprintf(&b, "stream_max_retries = %d\n", cfg.StreamMaxRetries)
}

if len(cfg.HTTPHeaders) > 0 {
keys := make([]string, 0, len(cfg.HTTPHeaders))
for k := range cfg.HTTPHeaders {
Expand Down
16 changes: 12 additions & 4 deletions apps/daemon/internal/agent/codex/provider_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,6 @@ func TestWriteCodexProviderConfig_FullProvider(t *testing.T) {
QueryParams: map[string]string{
"api-version": "2025-04-01-preview",
},
RequestMaxRetries: 4,
StreamMaxRetries: 3,
}
if err := writeCodexProviderConfig(dir, cfg); err != nil {
t.Fatalf("write: %v", err)
Expand All @@ -76,8 +74,6 @@ func TestWriteCodexProviderConfig_FullProvider(t *testing.T) {
`name = "mygw"`,
`base_url = "https://platform-api.example.com/v1"`,
`env_key = "OAC_CODEX_PROVIDER_API_KEY"`,
`request_max_retries = 4`,
`stream_max_retries = 3`,
`[model_providers.oac.http_headers]`,
`"X-Sub-Module" = "codex-internal"`,
`"X-Request-ID" = "abc"`,
Expand Down Expand Up @@ -219,6 +215,18 @@ func mustReadFile(t *testing.T, path string) string {
return string(b)
}

// Codex's default unbounded connection retries keep a Turn whose model host
// is unreachable in progress forever.
func TestModelConnectionRetriesStayBounded(t *testing.T) {
plan, err := BuildSessionPlan(t.TempDir(), "bounded", map[string]any{}, nil)
if err != nil {
t.Fatal(err)
}
if !slices.Contains(plan.DisableFeatures, "unbounded_connection_retries") {
t.Fatal("unbounded connection retries stay enabled")
}
}

func TestProviderCredentialOnlyInNativeLoopEnvironment(t *testing.T) {
root := t.TempDir()
const token = "synthetic-private-provider-token"
Expand Down
70 changes: 70 additions & 0 deletions apps/daemon/internal/agent/codex/unreachable_model_native_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package codex

import (
"context"
"log/slog"
"os"
"os/exec"
"strings"
"testing"
"time"

"github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto"
)

// A model host that does not resolve must fail the Turn with
// connection_failed instead of retrying forever. Opt-in: it needs the pinned
// native binary but no credentials or network.
func TestExecutorNativeUnreachableModelHost(t *testing.T) {
binary := os.Getenv("OAC_TEST_CODEX_BINARY")
if binary == "" {
t.Skip("requires the pinned native Codex binary")
}
if version, err := exec.Command(binary, "--version").Output(); err != nil || strings.TrimSpace(string(version)) != "codex-cli 0.153.4" {
t.Fatal("native artifact is not Codex 0.153.4")
}
t.Setenv("OAC_RUNTIME_HOME", t.TempDir())
cfg := defaultSessionConfig(testStateRoot(t))
cfg.codexBinary = binary
cfg.logger = slog.New(slog.DiscardHandler)
req := proto.PromptRequestPayload{
AgentKind: "codex", AgentStateKey: "unreachable-model-host",
DisableExecutionEnvironment: true, DisableSubagents: true,
AgentOptions: map[string]any{"model": "gpt-5.5", "model_provider": map[string]any{"base_url": "https://model-host.invalid/v1", "protocol": "responses", "api_key": "unused"}},
}
ctx, cancel := context.WithTimeout(t.Context(), 3*time.Minute)
defer cancel()
e, err := newExecutor(ctx, req, cfg)
if err != nil {
t.Fatalf("native prepare failed: %v", err)
}
t.Cleanup(func() {
c, stop := context.WithTimeout(context.Background(), 20*time.Second)
defer stop()
_ = e.Close(c)
})
output := make(chan proto.Envelope, 64)
began := time.Now()
turn, err := e.StartTurn(ctx, "unreachable", proto.TextInput("Reply OK."), output)
if err != nil {
t.Fatalf("start failed: %v", err)
}
var failure proto.ErrorPayload
for done := false; !done; {
select {
case event := <-output:
t.Logf("+%s %s", time.Since(began).Round(time.Millisecond), event.Type)
if event.Type == proto.TypeError {
_ = event.DecodePayload(&failure)
}
done = event.Type == proto.TypeDone
case <-ctx.Done():
t.Fatalf("Turn did not settle within %s", time.Since(began).Round(time.Second))
}
}
_, _ = turn.AwaitSettlement(ctx)
t.Logf("failed after %s with %q", time.Since(began).Round(time.Millisecond), failure.Code)
if failure.Code != "connection_failed" {
t.Fatalf("failure code = %q, want connection_failed", failure.Code)
}
}
3 changes: 3 additions & 0 deletions apps/web/src/features/sessions/session-diagnostics.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@ describe("Core diagnostics in the console", () => {
const providerDefault = "Without a model provider set on the Session or its saved Agent, a hosted sandbox Session uses the default in Settings → Models and credentials.";
expect(diagnosticFailure({ code: "invalid_request", params: {}, failed_at: null }, t)).toBe(`The model provider rejected the request. Check the model and parameters, starting with the model on the Session page. ${providerDefault}`);
expect(diagnosticFailure({ code: "connection_failed", params: { http_status: 502 }, failed_at: null }, t)).toContain("HTTP 502. Check the Base URL of the Session's model provider.");
// Only a failure without any HTTP response points at name resolution and reachability.
expect(diagnosticFailure({ code: "connection_failed", params: { http_status: 502 }, failed_at: null }, t)).not.toContain("resolves");
expect(diagnosticFailure({ code: "connection_failed", params: { http_status: null }, failed_at: null }, t)).toContain("Then check that the URL's host resolves and is reachable");
expect(diagnosticFailure({ code: "runtime_unavailable", params: {}, failed_at: null }, t)).toBe("The Runtime is unavailable. Retry later. For a self-hosted Session, check Host connection on the Session page; for a hosted sandbox Session, check Settings → Sandbox and scheduling.");
expect(diagnosticFailure({ code: "harness_error", params: {}, failed_at: null }, t)).toContain("Core does not show the Harness's own message");
expect(diagnosticFailure({ code: "invalid_request", params: {}, failed_at: null }, i18n.getFixedT("zh-CN", "diagnostics"))).toBe("模型提供商拒绝了请求。请检查模型和参数,先看 Session 页显示的模型。Session 及其保存的 Agent 都未设置模型提供商时,托管沙箱 Session 使用“设置 → 模型与凭据”中的默认配置。");
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/i18n/locales/en/diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export const diagnostics = {
setupIndex: "Setup command {{number}}.",
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", system: "system packages", file: "file preparation", skill: "Skill preparation" },
unavailable: "Failure details unavailable",
unavailableHelp: "The diagnostics read failed or no longer matches this status. Refresh to check again.",
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/i18n/locales/zh-CN/diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export const diagnostics = {
setupIndex: "第 {{number}} 条设置命令。",
exitCode: "退出码 {{code}}。",
httpStatus: "HTTP {{status}}。",
unreachableHost: "再确认 Session 的沙箱(自托管 Session 则为其主机)能解析并访问该 URL 的主机。",
step: { setup: "设置", python: "Python 依赖", npm: "npm 依赖", system: "系统软件包", file: "文件准备", skill: "Skill 准备" },
unavailable: "失败详情不可用",
unavailableHelp: "诊断读取失败或与当前状态不一致,请刷新后重试。",
Expand Down
14 changes: 10 additions & 4 deletions apps/web/src/lib/diagnostic-failure.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import type { DiagnosticFailure } from "@oac/agents-client";
import type { TFunction } from "i18next";

/** The provider's HTTP status on a connection failure; null when no response arrived. */
function connectionStatus(failure: DiagnosticFailure): number | null {
return failure.code === "connection_failed" && "http_status" in failure.params ? failure.params.http_status : null;
}

/** Translate the catalog, never native error text or guessed message categories. */
export function diagnosticReason(failure: DiagnosticFailure, t: TFunction<"diagnostics">): string {
const gap = t("sentenceGap");
Expand All @@ -10,15 +15,16 @@ export function diagnosticReason(failure: DiagnosticFailure, t: TFunction<"diagn
if (failure.params.index !== null) message += gap + t("setupIndex", { number: failure.params.index + 1 });
if (failure.params.exit_code !== null) message += gap + t("exitCode", { code: failure.params.exit_code });
}
if (failure.code === "connection_failed" && "http_status" in failure.params && failure.params.http_status !== null) {
message += gap + t("httpStatus", { status: failure.params.http_status });
}
const status = connectionStatus(failure);
if (status !== null) message += gap + t("httpStatus", { status });
return message;
}

/** The catalog's next step for the reason; empty when it has none. */
export function diagnosticNext(failure: DiagnosticFailure, t: TFunction<"diagnostics">): string {
return t(`next.${failure.code}`, { defaultValue: "" });
const next: string = t(`next.${failure.code}`, { defaultValue: "" });
// Without an HTTP status no provider answered, so its host may not resolve or be reachable.
return failure.code === "connection_failed" && connectionStatus(failure) === null ? next + t("sentenceGap") + t("unreachableHost") : next;
}

/** The classified reason, then its next step when the catalog has one. */
Expand Down
2 changes: 2 additions & 0 deletions contracts/agents-api/harness-onboarding.md
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,8 @@ Owned output pipes stay readable after the leader exits. Consumers drain stdout

Adapters run native tools unattended with the launching user's permissions, and a Harness never asks a human. Codex runs with approval policy `never`, under which it settles MCP elicitation itself without reaching the client, and with full access; the adapter also disables its blocking `request_user_input` tool. Claude runs through the adapter's tool callback, which allows or denies without asking, in native `default` permission mode with the SDK sandbox disabled. MiniMax runs with bypassed permissions and its sandbox disabled; the adapter disables `askUser`, advertises no elicitation and answers `session/request_permission` with the ACP `cancelled` outcome, which MiniMax treats as a denial. No native permission or question request reaches Core: a human in the loop goes through a function tool, the Session reads [`requires_action`](./sessions-events.md#session-status) and the application submits the function result. Do not add permission profiles, bubblewrap wrappers or native sandbox settings; there is one execution path for every Environment origin. Resource paths are operator configuration, not a permission boundary.

An unreachable model host must not hold a Turn open. The adapter keeps the Harness's own connection retries bounded through native settings, and the Harness's final connection error becomes `connection_failed` when the Harness reports it as structured data ([native failure classification](../../docs/runtime-protocol.md#native-failure-classification)). Core adds no model timeout of its own. Each adapter guide states its Harness's bound.

Network admission follows [Restricted network](./environments.md#restricted-network).

## Native references
Expand Down
4 changes: 3 additions & 1 deletion contracts/agents-api/zh/harness-onboarding.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
title: "添加 Harness"
source: contracts/agents-api/harness-onboarding.md
source_hash: f943975ae46fd7919700890d4f405c9a9e15385c3ab2b2e742098dcc2a2a8c20
source_hash: a1db7d464a42cc82bbe285b12a3290d76d0535c5a6a8fb2563586631c6b9136c
---

**Harness** 是一种运行模型和工具循环的原生代理引擎(Codex、Claude Code、MiniMax Code)。**Harness 适配器**将 Runtime 的 Executor 和 Turn 契约转换到该引擎的 SDK 或协议。本文档定义 Runtime–Harness 协议:适配器接口及其生命周期义务、注册、Core 资格认定和验收。[Harness capabilities](harness-capabilities.md) 记录了当前每个 Harness 支持的功能。
Expand Down Expand Up @@ -250,6 +250,8 @@ daemon 的 `clirunner` 让每个原生子进程在自己的 Unix 进程组中启

适配器以启动用户的权限无人值守运行原生工具,Harness 从不询问人类。Codex 使用批准策略 `never` 和完全访问权限,在该策略下 Codex 自行处理 MCP elicitation,不会发给客户端;适配器还禁用其阻塞式 `request_user_input` 工具。Claude 通过适配器的工具回调运行,回调直接允许或拒绝、不会询问,使用原生 `default` 权限模式并禁用 SDK sandbox。MiniMax 绕过权限并禁用 sandbox;适配器禁用 `askUser`、不声明 elicitation,并以 ACP `cancelled` 结果答复 `session/request_permission`,MiniMax 将其视为拒绝。原生权限或问题请求都不会到达 Core:需要人工介入时通过 function 工具完成,Session 读取 [`requires_action`](./sessions-events.md#session-status),由应用提交 function 结果。不要添加权限 profile、bubblewrap 包装器或原生 sandbox 设置;每种 Environment 来源都只有一条执行路径。资源路径属于操作员配置,而不是权限边界。

模型主机不可达时不能让 Turn 一直挂起。适配器通过原生设置让 Harness 自身的连接重试保持有界;Harness 以结构化数据报告最终连接错误时,该错误归为 `connection_failed`([原生失败分类](../../../docs/zh/runtime-protocol.md#native-failure-classification))。Core 不另设模型超时。各适配器指南写明其 Harness 的上限。

网络准入遵循 [Restricted network](environments.md#restricted-network)。

## 原生参考 {#native-references}
Expand Down
4 changes: 3 additions & 1 deletion docs/getting-started/nodes.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,12 +68,14 @@ The proxy applies only to installation downloads. It is not saved in the node's
| Node state | `/var/lib/oac-node/.oac/nodes/<installation-id>/`: identity, configuration, node files. microsandbox keeps its images and sandboxes under `/var/lib/oac-node/.oac/m/` |
| Records | `/etc/oac-node/`: what the installer created or changed, used by reruns and uninstall |
| Docker | The Runtime image, imported once, and a network `oac-node-<installation-id>` |
| microsandbox network policy | Sandboxes may reach Core, DNS on the host and public addresses, and nothing else: no inbound connections and no private networks. A private model or MCP endpoint needs a policy change on the node |
| microsandbox network policy | Sandboxes may reach Core, DNS on the host and public addresses, and nothing else: no inbound connections and no private networks |

Root only prepares the account, the group and the unit; everything else, the Docker network included, runs as `oac-node`, in its own session without a terminal. The installer never installs Docker, KVM or packages, never starts Docker, never changes device permissions, sudoers, firewall or SELinux settings, and never touches other accounts.

**Docker mode is root-equivalent.** Membership in the `docker` group lets `oac-node`, and so anything that controls the node, act as root on the host. Containers also share the host kernel, so a container escape reaches the host. Add Docker nodes only on hosts dedicated to sandboxes. microsandbox nodes need only the `kvm` group and run each sandbox in its own microVM.

**microsandbox blocks private endpoints.** A sandbox can't reach a model provider or MCP server at a private address. Sandbox DNS queries go to the nameservers listed in the host's `/etc/resolv.conf`, and DNS rebind protection discards any answer that contains a private address, so a name those nameservers resolve to a private address doesn't resolve in the sandbox at all, even for a public service such as a model API behind a cloud VPC resolver. The Turn then fails once the Harness stops retrying. Installer-added nodes have no setting to change this. Make those nameservers return the service's public addresses; microsandbox doesn't read the host's `/etc/hosts`, so an entry there helps only when `/etc/resolv.conf` points at a local stub resolver that serves it, such as systemd-resolved at `127.0.0.53`. Otherwise use a Docker node, which doesn't filter sandbox traffic, or [register the node manually](#register-a-node-manually) with a `network` policy that also allows the private range in a rule without a port, because rebind protection ignores port-scoped rules.

**One Core per host.** Nodes share the `oac-node` account, so a host serves one Core; a command from a second Core is refused.

**One release.** A node runs the program of the console that added it and is never upgraded in place; see the [installation version policy](./operations.md#installation-version-policy).
Expand Down
Loading
Loading