From f5e7f941463a6555561e716f20c04541e0a73009 Mon Sep 17 00:00:00 2001
From: SaladDay <1203511142@qq.com>
Date: Thu, 15 Oct 2026 10:36:07 +0000
Subject: [PATCH] Explain self-hosted install failures
---
apps/daemon/internal/cli/connect.go | 20 +++--
.../connect_environment_diagnostics_test.go | 75 +++++++++++++++++++
apps/daemon/internal/cli/native_install.go | 3 -
apps/daemon/internal/cli/native_onboarding.go | 4 +-
apps/daemon/internal/transport/ws.go | 4 +-
apps/web/src/i18n/locales/en/sessions.ts | 2 +-
apps/web/src/i18n/locales/zh-CN/sessions.ts | 2 +-
docs/getting-started/operations.md | 2 +-
docs/getting-started/self-hosted.md | 18 ++++-
docs/zh/getting-started/operations.md | 4 +-
docs/zh/getting-started/self-hosted.md | 20 ++++-
scripts/native-onboarding-smoke.mjs | 4 +-
12 files changed, 130 insertions(+), 28 deletions(-)
create mode 100644 apps/daemon/internal/cli/connect_environment_diagnostics_test.go
diff --git a/apps/daemon/internal/cli/connect.go b/apps/daemon/internal/cli/connect.go
index dc87bb4a9..6122d229d 100644
--- a/apps/daemon/internal/cli/connect.go
+++ b/apps/daemon/internal/cli/connect.go
@@ -245,13 +245,7 @@ func mainLoopRemote(parent context.Context, rc *runContext, profile string, prof
DaemonVersion: proto.Version,
})
if remote != "" && err != nil {
- if errors.Is(err, transport.ErrIncompatibleVersion) {
- return nil, fmt.Errorf("Environment connection rejected: %w: %w", transport.ErrPermanent, transport.ErrIncompatibleVersion)
- }
- if errors.Is(err, transport.ErrPermanent) {
- return nil, fmt.Errorf("Environment connection rejected: %w", transport.ErrPermanent)
- }
- return nil, errors.New("Environment connection failed")
+ return nil, environmentDialError(err)
}
return conn, err
}
@@ -375,3 +369,15 @@ func pumpConn(parentCtx context.Context, conn *transport.Conn, registry *agent.R
}
}
}
+
+// environmentDialError wraps a Dial failure for connect.log. The credential
+// travels only in a request header, so the transport's cause is safe to keep.
+func environmentDialError(err error) error {
+ if errors.Is(err, transport.ErrIncompatibleVersion) {
+ return fmt.Errorf("Environment connection rejected: %w: %w", transport.ErrPermanent, transport.ErrIncompatibleVersion)
+ }
+ if errors.Is(err, transport.ErrPermanent) {
+ return fmt.Errorf("Environment connection rejected: %w", transport.ErrPermanent)
+ }
+ return fmt.Errorf("Environment connection failed: %w", err)
+}
diff --git a/apps/daemon/internal/cli/connect_environment_diagnostics_test.go b/apps/daemon/internal/cli/connect_environment_diagnostics_test.go
new file mode 100644
index 000000000..aabf66dfa
--- /dev/null
+++ b/apps/daemon/internal/cli/connect_environment_diagnostics_test.go
@@ -0,0 +1,75 @@
+package cli
+
+import (
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/transport"
+)
+
+// connect.log must say why the connection failed, and never carry the credential.
+func TestEnvironmentDialErrorKeepsCause(t *testing.T) {
+ err := environmentDialError(errors.New("transport.Dial: ws upgrade rejected with HTTP 502"))
+ if message := err.Error(); !strings.Contains(message, "Environment connection failed") || !strings.Contains(message, "HTTP 502") {
+ t.Fatalf("cause lost: %q", message)
+ }
+ if errors.Is(err, transport.ErrPermanent) {
+ t.Fatal("a transient failure became permanent")
+ }
+}
+
+// The installed bin/oac-daemon acts on its own installation even when
+// OAC_RUNTIME_HOME names another directory.
+func TestInstalledDaemonIgnoresRuntimeHomeEnvironment(t *testing.T) {
+ if os.Getenv("OAC_TEST_INSTALLED_STATUS") != "" {
+ if err := Execute([]string{"status"}); err != nil {
+ os.Exit(1)
+ }
+ os.Exit(0)
+ }
+ installation := t.TempDir()
+ if err := os.MkdirAll(filepath.Join(installation, "bin"), 0700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.MkdirAll(filepath.Join(installation, "daemon"), 0700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(installation, "daemon", "installation.json"), []byte("{}"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ raw, err := os.ReadFile(os.Args[0])
+ if err != nil {
+ t.Fatal(err)
+ }
+ executable := filepath.Join(installation, "bin", nativeExe("oac-daemon"))
+ if err = os.WriteFile(executable, raw, 0700); err != nil {
+ t.Fatal(err)
+ }
+ cmd := exec.Command(executable, "-test.run=^TestInstalledDaemonIgnoresRuntimeHomeEnvironment$")
+ cmd.Env = append(os.Environ(), "OAC_TEST_INSTALLED_STATUS=1", "OAC_RUNTIME_HOME="+t.TempDir())
+ output, err := cmd.Output()
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := filepath.Join(installation, "daemon")
+ if !strings.Contains(string(output), want) {
+ t.Fatalf("status did not use the installation %s:\n%s", want, output)
+ }
+}
+
+// A lost, moved or incompatible installation gets the working recovery, not a
+// dead end.
+func TestInstallationConflictNamesRecovery(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusConflict) }))
+ defer server.Close()
+ err := installationRequest(t.Context(), server.URL+"/api/v1/agent-daemon/installation/claim", "grant", map[string]string{"executor_token": "x"}, nil)
+ if err == nil || !strings.Contains(err.Error(), "already claimed") || !strings.Contains(err.Error(), "Reconnect an existing Session") {
+ t.Fatalf("conflict message: %v", err)
+ }
+}
diff --git a/apps/daemon/internal/cli/native_install.go b/apps/daemon/internal/cli/native_install.go
index 9e0f87695..706ec76ac 100644
--- a/apps/daemon/internal/cli/native_install.go
+++ b/apps/daemon/internal/cli/native_install.go
@@ -349,9 +349,6 @@ func runStart(rc *runContext, args []string) error {
}
func useInstalledNativeHome() {
- if os.Getenv("OAC_RUNTIME_HOME") != "" {
- return
- }
exe, err := os.Executable()
if err != nil {
return
diff --git a/apps/daemon/internal/cli/native_onboarding.go b/apps/daemon/internal/cli/native_onboarding.go
index cfecf5cb7..caddd77d7 100644
--- a/apps/daemon/internal/cli/native_onboarding.go
+++ b/apps/daemon/internal/cli/native_onboarding.go
@@ -44,7 +44,7 @@ func installationRequest(ctx context.Context, endpoint, authorization string, in
case http.StatusUnauthorized:
return errors.New("install: authorization rejected or expired; copy a fresh command from the Session")
case http.StatusConflict:
- return errors.New("install: this Environment already has a different or revoked credential; reuse the original installation or resolve its credential in Core")
+ return errors.New("install: this Session's machine credential is already claimed by another installation; rerun the command in the original installation directory, or rotate the credential in Web and install from an extracted distribution (see Self-hosted executors > Reconnect an existing Session), or create a new Session")
default:
return fmt.Errorf("install: Core rejected installation (HTTP %d); check the Session and obtain a fresh command", response.StatusCode)
}
@@ -116,7 +116,7 @@ func prepareOnboardingCredential(ctx context.Context, o *nativeInstallOptions, h
var previous nativeInstallation
if raw, err := runtimefs.ReadPrivate(held, "installation.json", 1<<20); err == nil {
if decodeEnvironmentJSON(raw, &previous) != nil || previous.Version != Version || previous.Remote != o.Remote || previous.Environment != o.Environment || previous.Workspace != o.Workspace {
- return errors.New("install: this directory belongs to an incompatible installation; choose a separate directory")
+ return errors.New("install: this directory holds an installation of another Core build or Session, which the installer never changes; rotate the Session's credential in Web and install from an extracted distribution into a new directory (see Self-hosted executors > Reconnect an existing Session), or create a new Session")
}
o.Credential = previous.Credential
// A completed installation already owns its key. Rotation and revocation
diff --git a/apps/daemon/internal/transport/ws.go b/apps/daemon/internal/transport/ws.go
index e39411074..827af93e4 100644
--- a/apps/daemon/internal/transport/ws.go
+++ b/apps/daemon/internal/transport/ws.go
@@ -117,8 +117,8 @@ func Dial(ctx context.Context, opts DialOptions) (*Conn, error) {
if dialCtx.Err() != nil {
return nil, fmt.Errorf("transport.Dial: ws upgrade: %w", dialCtx.Err())
}
- // Upgrade errors can include peer-controlled text; never log credentials.
- return nil, fmt.Errorf("transport.Dial: ws upgrade failed")
+ // The credential travels only in the request header, never in the error.
+ return nil, fmt.Errorf("transport.Dial: ws upgrade failed: %w", err)
}
// Bound a single inbound frame so a misbehaving server can't OOM
// us. Matches the gateway's 4 MiB outbound ceiling.
diff --git a/apps/web/src/i18n/locales/en/sessions.ts b/apps/web/src/i18n/locales/en/sessions.ts
index 6c17c4db1..00285837d 100644
--- a/apps/web/src/i18n/locales/en/sessions.ts
+++ b/apps/web/src/i18n/locales/en/sessions.ts
@@ -153,7 +153,7 @@ export const sessions = {
rotateBound: "Rotate bound credential",
},
title: "Executor credentials",
- help: "A credential lets one self-hosted executor connect to Core for this environment only. It cannot call the Agents API. Save the downloaded credential JSON as a private file on the executor host and give the installer its absolute path. Core shows each credential once; rotate a credential to replace a lost one.",
+ help: "A credential lets one self-hosted executor connect to Core for this environment only. It cannot call the Agents API. Save the downloaded credential JSON as a private file on the executor host. The install command claims its own credential; use a downloaded one to reconnect after a rotation or to install from an extracted distribution. Core shows each credential once; rotate a credential to replace a lost one.",
issue: "Issue credential",
issuing: "Issuing…",
loading: "Loading executor credentials…",
diff --git a/apps/web/src/i18n/locales/zh-CN/sessions.ts b/apps/web/src/i18n/locales/zh-CN/sessions.ts
index 4cd38ef11..d4135395a 100644
--- a/apps/web/src/i18n/locales/zh-CN/sessions.ts
+++ b/apps/web/src/i18n/locales/zh-CN/sessions.ts
@@ -150,7 +150,7 @@ export const sessions = {
rotateBound: "轮换绑定凭据",
},
title: "执行器凭据",
- help: "凭据让一个自托管执行器连接 Core,只对这个 Environment 有效,不能调用 Agents API。请将下载的凭据 JSON 私密保存在执行器主机上,并向安装程序提供文件的绝对路径。每份凭据只显示一次;丢失时,轮换凭据即可换一份新的。",
+ help: "凭据让一个自托管执行器连接 Core,只对这个 Environment 有效,不能调用 Agents API。请将下载的凭据 JSON 私密保存在执行器主机上。安装命令会自行认领凭据;下载的凭据用于轮换后重新连接,或从已解压的发行包安装。每份凭据只显示一次;丢失时,轮换凭据即可换一份新的。",
issue: "签发凭据",
issuing: "正在签发…",
loading: "正在加载执行器凭据…",
diff --git a/docs/getting-started/operations.md b/docs/getting-started/operations.md
index 2658f5bb7..6779d1976 100644
--- a/docs/getting-started/operations.md
+++ b/docs/getting-started/operations.md
@@ -202,7 +202,7 @@ Three connections require the same protocol version on both sides: Core and its
- reinstall each node: [remove it](./nodes.md#remove-a-node), clean up the host, then add the host again with **Add node**;
- apply the release's Runtime right after the upgrade; sandboxes on earlier generations stop working;
- [build a new E2B template](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/deploy/e2b/README.md#build-a-template) and select it in [the sandbox configuration](./nodes.md#change-the-sandbox-configuration);
-- reinstall [self-hosted machines](./self-hosted.md#connect-a-machine).
+- reconnect [self-hosted machines](./self-hosted.md#reconnect-an-existing-session).
### Downgrades
diff --git a/docs/getting-started/self-hosted.md b/docs/getting-started/self-hosted.md
index 828d884e2..60fbd5d30 100644
--- a/docs/getting-started/self-hosted.md
+++ b/docs/getting-started/self-hosted.md
@@ -129,11 +129,11 @@ The installation's `bin/oac-daemon` finds its own installation. Use it for:
| `oac-daemon logs -n 100`, `oac-daemon logs -f` | Print or follow the daemon log |
| `oac-daemon stop` | Stop the daemon |
-If you set `OAC_RUNTIME_HOME`, use the same value for every command. Check the connection under **Host connection** on the Session's page in Web, or with the [connection status](../../contracts/agents-api/environment-executor-credentials.md#connection-status).
+The installed `bin/oac-daemon` ignores `OAC_RUNTIME_HOME` and always acts on its own installation. Check the connection under **Host connection** on the Session's page in Web, or with the [connection status](../../contracts/agents-api/environment-executor-credentials.md#connection-status).
To add a Harness, run the install command again (a fresh copy from Web if it has expired) with the same installation directory and the Harness to add. The installer checks the existing contents, adds only missing components and keeps the Harnesses already installed. Restart a running daemon afterwards so it discovers the new Harness.
-Stopping the daemon, cancelling a Turn or deleting the Session never removes the machine's workspace, native history or capability snapshot. An installation from another daemon version, or one whose files were changed, is refused. The installer never upgrades, repairs or migrates it; install into a separate directory.
+Stopping the daemon, cancelling a Turn or deleting the Session never removes the machine's workspace, native history or capability snapshot. An installation from another daemon version, or one whose files were changed, is refused. The installer never upgrades, repairs or migrates it. To use the Session on a new directory, [reconnect it](#reconnect-an-existing-session).
## Rotate or revoke
@@ -148,6 +148,18 @@ To reconnect after a rotation, stop the daemon with `oac-daemon stop`, replace t
In an archived Project, credentials cannot be issued or rotated; revocation remains available. Operators can manage credentials with the Core key; see the [credential contract](../../contracts/agents-api/environment-executor-credentials.md#core-key-routes).
+## Reconnect an existing Session {#reconnect-an-existing-session}
+
+The install command claims the Session's machine credential once, and a second claim ends in `This Session's machine credential is already claimed by another installation`. That happens when you run the command in a different directory, on another machine, after the installation directory was lost, or when the directory holds an installation of another Core build or Session, which the installer refuses to change. Rerunning the command in the original directory always works.
+
+To reconnect the Session without the original directory, rotate its credential and install from an extracted distribution into a new directory:
+
+1. In Web, open the Session and choose **Rotate** in **Executor credentials**. Save the credential JSON it returns.
+2. Download `/.tar.gz` and extract it, where `` is the first URL of the install command (it ends with the Core version) and `` is `linux-amd64`, `linux-arm64`, `darwin-arm64` or `windows-amd64`.
+3. Run the installer as in [Install from an extracted distribution](#install-from-an-extracted-distribution) with the Session's `remote_url`, Environment ID, workspace and the saved credential file.
+
+Or create a new Session and run its command.
+
## Install from an extracted distribution
The same installer accepts an already extracted distribution and a credential file issued by an operator, without the install command:
@@ -162,4 +174,4 @@ The same installer accepts an already extracted distribution and a credential fi
"$HOME/.oac/my-runtime/bin/oac-daemon" start
```
-Use the Session's `remote_url` and Environment ID. In PowerShell, run `.\oac-daemon.exe` with native absolute paths. This mode needs an existing workspace and does not start the daemon until you run `start`.
+Use the Session's `remote_url` and Environment ID. The distribution is the archive named in [Reconnect an existing Session](#reconnect-an-existing-session). In PowerShell, run `.\oac-daemon.exe` with native absolute paths. This mode needs an existing workspace and does not start the daemon until you run `start`.
diff --git a/docs/zh/getting-started/operations.md b/docs/zh/getting-started/operations.md
index fb2f84a34..58672b65f 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: 9281f985f203a33cf2e861a118204839277cef13ebdd484c7b41dfd5cd68b8d5
+source_hash: 635fa6022d6fd132ee0af8ce329298355b924803e421db1d6e41add73c970f4f
---
安装运维人员负责 Core 主机、存储和可用性。节点主机运行各自的服务;参阅[节点](nodes.md)。设置见[配置参考](../configuration.md)。
@@ -204,7 +204,7 @@ v0.0.10 及更高版本的安装可以原地升级到更新的发布版本,并
- 重新安装每个节点:[移除节点](nodes.md#remove-a-node),清理主机,再通过 **添加节点** 重新添加该主机;
- 升级后立即应用该版本的 Runtime;仍在较早代次上的沙箱将停止工作;
- [构建新的 E2B 模板](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/deploy/e2b/README.md#build-a-template),并在[沙箱配置](nodes.md#change-the-sandbox-configuration)中选择它;
-- 重新安装[自托管机器](self-hosted.md#connect-a-machine)。
+- 重新连接[自托管机器](self-hosted.md#reconnect-an-existing-session)。
### 降级 {#downgrades}
diff --git a/docs/zh/getting-started/self-hosted.md b/docs/zh/getting-started/self-hosted.md
index 4698c2402..f81daf160 100644
--- a/docs/zh/getting-started/self-hosted.md
+++ b/docs/zh/getting-started/self-hosted.md
@@ -1,7 +1,7 @@
---
title: "自托管执行器"
source: docs/getting-started/self-hosted.md
-source_hash: c42a64b5e59406594143866c503817525c55ea844d858f25119c8fa54993134c
+source_hash: 2b61e5062fd95157615bced421f4f25a1862b3b17a2100f2113c9a4fcd7b3372
---
`self_hosted` Session 在应用拥有的机器上运行:工作站、虚拟机或你管理的沙箱。应用通过 `/v1` 创建 Session,并获得安装 `oac-daemon`、启动它并连接 Core 的命令。Web 在 Session 页面展示同一命令;Web 是可选的。Core 不创建、停止或回收这台机器。
@@ -131,11 +131,11 @@ session = client.beta.agents.sessions.create(
| `oac-daemon logs -n 100`、`oac-daemon logs -f` | 输出或持续跟踪守护进程日志 |
| `oac-daemon stop` | 停止守护进程 |
-设置了 `OAC_RUNTIME_HOME` 时,每个命令都使用同一值。在 Web 的 Session 页面 **主机连接** 下检查连接,或使用[连接状态](../../../contracts/agents-api/zh/environment-executor-credentials.md#connection-status)。
+已安装的 `bin/oac-daemon` 忽略 `OAC_RUNTIME_HOME`,始终操作自己所在的安装。在 Web 的 Session 页面 **主机连接** 下检查连接,或使用[连接状态](../../../contracts/agents-api/zh/environment-executor-credentials.md#connection-status)。
添加 Harness 时,以同一安装目录重新执行安装命令,并指定要添加的 Harness(命令过期时从 Web 复制新的)。安装程序检查已有内容、只添加缺失组件,并保留已安装的 Harness。之后重启正在运行的守护进程,让它发现新的 Harness。
-停止守护进程、取消 Turn 或删除 Session,都不会删除机器的工作区、原生历史或能力快照。安装程序拒绝其他守护进程版本的安装,以及文件已被修改的安装。程序不升级、修复或迁移它们;请安装到另一个目录。
+停止守护进程、取消 Turn 或删除 Session,都不会删除机器的工作区、原生历史或能力快照。安装程序拒绝其他守护进程版本的安装,以及文件已被修改的安装。程序不升级、修复或迁移它们。要在新目录上使用该 Session,请[重新连接](#reconnect-an-existing-session)。
## 轮换或撤销 {#rotate-or-revoke}
@@ -150,6 +150,18 @@ session = client.beta.agents.sessions.create(
已归档项目无法签发或轮换凭据,仍可撤销。运维人员可以使用 Core 密钥管理凭据;参阅[凭据协议](../../../contracts/agents-api/zh/environment-executor-credentials.md#core-key-routes)。
+## 重新连接已有 Session {#reconnect-an-existing-session}
+
+安装命令只能认领一次 Session 的机器凭据,再次认领会以 `This Session's machine credential is already claimed by another installation` 结束。在另一个目录、另一台机器上运行命令,安装目录丢失,或目录中是其他 Core 版本或其他 Session 的安装(安装程序拒绝修改)时,都会出现这种情况。在原目录中重新运行命令始终可行。
+
+没有原目录时,轮换该 Session 的凭据,并用已解压的发行包安装到新目录:
+
+1. 在 Web 中打开该 Session,在 **执行器凭据** 中选择 **轮换**,保存返回的凭据 JSON。
+2. 下载并解压 `/.tar.gz`。`` 是安装命令中的第一个 URL(以 Core 版本结尾),`` 是 `linux-amd64`、`linux-arm64`、`darwin-arm64` 或 `windows-amd64`。
+3. 按[从已解压的发行包安装](#install-from-an-extracted-distribution)运行安装程序,使用该 Session 的 `remote_url`、Environment ID、工作区和保存的凭据文件。
+
+或者新建 Session 并运行它的命令。
+
## 从已解压的发行包安装 {#install-from-an-extracted-distribution}
同一安装程序也接受已解压的发行包和运维人员签发的凭据文件,无需安装命令:
@@ -164,4 +176,4 @@ session = client.beta.agents.sessions.create(
"$HOME/.oac/my-runtime/bin/oac-daemon" start
```
-使用 Session 的 `remote_url` 和 Environment ID。在 PowerShell 中,以原生绝对路径运行 `.\oac-daemon.exe`。此模式要求工作区已存在,且在运行 `start` 前不会启动守护进程。
+使用 Session 的 `remote_url` 和 Environment ID。发行包即[重新连接已有 Session](#reconnect-an-existing-session) 中的压缩包。在 PowerShell 中,以原生绝对路径运行 `.\oac-daemon.exe`。此模式要求工作区已存在,且在运行 `start` 前不会启动守护进程。
diff --git a/scripts/native-onboarding-smoke.mjs b/scripts/native-onboarding-smoke.mjs
index 153a8a940..fa4f2bfeb 100644
--- a/scripts/native-onboarding-smoke.mjs
+++ b/scripts/native-onboarding-smoke.mjs
@@ -93,10 +93,10 @@ try {
assert.equal(connections, 1, 'Repeated installation created a duplicate daemon');
console.log(JSON.stringify({ installation: 'passed', connection: 'passed', interactive: 'passed', retry: 'passed', repeat: 'passed', authentication: 'passed', model_requests: 0 }));
} finally {
- // The installed executable resolves its own home unless explicitly overridden.
+ // The installed executable always acts on its own installation.
delete process.env.OAC_RUNTIME_HOME;
const installed = join(installation, 'bin', windows ? 'oac-daemon.exe' : 'oac-daemon');
- try { execFileSync(installed, ['stop'], { env: { ...process.env, OAC_RUNTIME_HOME: installation }, stdio: 'ignore', timeout: 15000 }); } catch { /* A failed installation may never have started. */ }
+ try { execFileSync(installed, ['stop'], { stdio: 'ignore', timeout: 15000 }); } catch { /* A failed installation may never have started. */ }
for (const socket of sockets) socket.destroy();
await new Promise(resolve => server.close(resolve));
}