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
2 changes: 1 addition & 1 deletion apps/daemon/internal/cli/connect.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ func runConnect(ctx *runContext, args []string) error {
}
connectCtx, stop := daemonize.NotifyContext(context.Background())
defer stop()
return runEnvironmentConnect(connectCtx, ctx, *profile, *background, *remote, *environment, *credentialFile)
return runEnvironmentConnect(connectCtx, ctx, *profile, *background, false, *remote, *environment, *credentialFile)
}

// -b mode: parent forks, child re-enters with sentinel env set
Expand Down
20 changes: 16 additions & 4 deletions apps/daemon/internal/cli/connect_environment.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,10 @@ func environmentBootstrap(ctx context.Context, prof auth.Profile, remote string)
return boot, nil
}

func runEnvironmentConnect(parent context.Context, rc *runContext, profile string, background bool, remote, environment, credentialFile string) error {
// exitOnRejection selects the native `start` behaviour: a permanent rejection
// ends the process with the message as its error. Otherwise the process that
// owns the connection parks (see parkEnvironment).
func runEnvironmentConnect(parent context.Context, rc *runContext, profile string, background, exitOnRejection bool, remote, environment, credentialFile string) error {
base, err := environmentBase(remote)
if err != nil {
return err
Expand All @@ -143,7 +146,12 @@ func runEnvironmentConnect(parent context.Context, rc *runContext, profile strin
// the connection parks instead.
parks := !background || daemonize.IsBackgroundChild()
rejected := func(err error) error {
if message := environmentRejection(err, keyID, environment); parks && message != "" {
message := environmentRejection(err, keyID, environment, exitOnRejection)
switch {
case message == "":
case exitOnRejection:
return errors.New(message)
case parks:
return parkEnvironment(parent, rc.stderr, message)
}
return err
Expand Down Expand Up @@ -180,9 +188,13 @@ var (
// environmentRejection names the fix for a permanent Environment rejection:
// enrollment 401 or 409, or a permanent WebSocket rejection or close. It returns
// "" for anything else (transport failures, 5xx, 404), which keeps the ordinary
// failure exit so the Runtime's restart policy retries it.
func environmentRejection(err error, keyID, environment string) string {
// failure exit so the Runtime's restart policy retries it. The native daemon
// names its own commands; the Docker Runtime names its restart.
func environmentRejection(err error, keyID, environment string, native bool) string {
reconnect, remove := "install it for this Runtime and restart it", "stop this Runtime"
if native {
reconnect, remove = "replace the credential file and run oac-daemon start", "run oac-daemon uninstall"
}
switch {
case errors.Is(err, errEnvironmentBindingConflict):
return fmt.Sprintf("executor credential %s cannot connect: Environment %s is bound to a different executor credential. This Runtime will not retry. Rotate the credential first used for this Environment instead of issuing a new one, then %s. To remove this Runtime instead, %s.", keyID, environment, reconnect, remove)
Expand Down
2 changes: 1 addition & 1 deletion apps/daemon/internal/cli/connect_environment_park_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ func TestEnvironmentRejectionClassifiesConnectionErrors(t *testing.T) {
{errors.New("connect: bootstrap: Environment bootstrap failed"), ""},
{nil, ""},
} {
got := environmentRejection(tc.err, uuid.NewString(), uuid.NewString())
got := environmentRejection(tc.err, uuid.NewString(), uuid.NewString(), false)
if tc.want == "" && got != "" || !strings.Contains(got, tc.want) || strings.Contains(got, "container") {
t.Errorf("environmentRejection(%v) = %q", tc.err, got)
}
Expand Down
2 changes: 1 addition & 1 deletion apps/daemon/internal/cli/native_install.go
Original file line number Diff line number Diff line change
Expand Up @@ -345,7 +345,7 @@ func runStart(rc *runContext, args []string) error {
return err
}
}
return runEnvironmentConnect(ctx, rc, paths.DefaultProfile, !*foreground, config.Remote, config.Environment, config.Credential)
return runEnvironmentConnect(ctx, rc, paths.DefaultProfile, !*foreground, true, config.Remote, config.Environment, config.Credential)
}

func useInstalledNativeHome() {
Expand Down
138 changes: 138 additions & 0 deletions apps/daemon/internal/cli/native_uninstall.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
package cli

import (
"bufio"
"errors"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"

"github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/paths"
"github.com/MiniMax-AI/OpenAgentCore/internal/runtimefs"
)

// runUninstall removes one native installation: it stops the daemon, shows what
// goes, asks, deletes. The workspace belongs to the user and always stays.
func runUninstall(rc *runContext, args []string) error {
flags := newFlagSet("uninstall")
if err := flags.Parse(args); err != nil || flags.NArg() != 0 {
return errors.New("uninstall: takes no arguments; run the installed bin/oac-daemon uninstall")
}
root, err := paths.Root()
if err != nil {
return err
}
raw, err := runtimefs.ReadPrivatePath(filepath.Join(root, "daemon", "installation.json"), 1<<20)
if errors.Is(err, os.ErrNotExist) {
fmt.Fprintf(rc.stdout, "oac-daemon: nothing is installed in %s\n", root)
return nil
}
if err != nil {
return errors.New("uninstall: cannot read the installation; delete its directory by hand")
}
// A damaged record still allows removal; only the workspace check and the
// follow-up need its contents.
var config nativeInstallation
_ = decodeEnvironmentJSON(raw, &config)
if config.Workspace != "" && within(root, config.Workspace) {
return fmt.Errorf("uninstall: the workspace %s is inside the installation directory; move it out first", config.Workspace)
}
targets := []string{root}
if cache := downloadCache(root); cache != "" && !within(root, cache) {
targets = append(targets, cache)
}
fmt.Fprintln(rc.stdout, "This stops the daemon and deletes, with everything inside:")
for _, target := range targets {
fmt.Fprintln(rc.stdout, " "+target)
}
fmt.Fprintln(rc.stdout, "The installation directory holds the executor credential, native history and Harnesses; native-download holds the download lock. The workspace is kept.")
fmt.Fprint(rc.stdout, "Continue? [y/N] ")
answer, _ := bufio.NewReader(rc.stdin).ReadString('\n')
if !strings.EqualFold(strings.TrimSpace(answer), "y") {
return errors.New("uninstall: cancelled; nothing was deleted")
}
if err = runStop(rc, nil); err != nil {
return err
}
// Windows cannot delete the executable it is running.
running := ""
if runtime.GOOS == "windows" {
running, _ = os.Executable()
}
for _, target := range targets[1:] {
if err = os.RemoveAll(target); err != nil {
return fmt.Errorf("uninstall: cannot delete %s: %w", target, err)
}
}
remaining, err := removeInstallation(root, running)
if err != nil {
return fmt.Errorf("uninstall: cannot delete %s; close programs using it and run the command again: %w", root, err)
}
if remaining {
// Windows keeps the running executable open until this process exits.
deleteAfterExit(root)
}
fmt.Fprintln(rc.stdout, "oac-daemon: uninstalled")
if config.Workspace != "" {
fmt.Fprintln(rc.stdout, "Workspace kept: "+config.Workspace)
}
fmt.Fprintf(rc.stdout, "Core still accepts this machine's credential. In Web, open the Session (Environment %s) and choose Revoke in Executor credentials, or Delete the Session.\n", config.Environment)
return nil
}

// removeInstallation deletes dir. A running executable cannot be deleted on
// Windows, so everything else goes and the function reports what is left.
func removeInstallation(dir, running string) (remaining bool, err error) {
if running == "" || !within(dir, running) {
return false, os.RemoveAll(dir)
}
return true, removeAllExcept(dir, running)
}

func removeAllExcept(dir, keep string) error {
entries, err := os.ReadDir(dir)
if err != nil {
return err
}
for _, entry := range entries {
path := filepath.Join(dir, entry.Name())
switch {
case path == keep:
case entry.IsDir() && within(path, keep):
if err = removeAllExcept(path, keep); err != nil {
return err
}
default:
if err = os.RemoveAll(path); err != nil {
return err
}
}
}
return nil
}

func within(dir, path string) bool {
rel, err := filepath.Rel(dir, path)
return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
}

// downloadCache is the bootstrap's native-download directory (staging and the
// download lock). It sits in the Runtime home, which the default installation
// directory <home>/environments/<id> shares with other installations; it goes
// only with the last of them.
func downloadCache(root string) string {
home := root
if environments := filepath.Dir(root); filepath.Base(environments) == "environments" {
if entries, err := os.ReadDir(environments); err != nil || len(entries) > 1 {
return ""
}
home = filepath.Dir(environments)
}
cache := filepath.Join(home, "native-download")
if info, err := os.Lstat(cache); err != nil || !info.IsDir() {
return ""
}
return cache
}
103 changes: 103 additions & 0 deletions apps/daemon/internal/cli/native_uninstall_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package cli

import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"slices"
"strings"
"testing"

"github.com/google/uuid"
)

// The native daemon ends with the fix in its error when Core rejects its
// credential, so `start` reports it instead of parking.
func TestNativeStartExitsOnPermanentRejection(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusUnauthorized) }))
defer server.Close()
home := t.TempDir()
t.Setenv("OAC_RUNTIME_HOME", home)
environment, keyID := uuid.NewString(), uuid.NewString()
credential := filepath.Join(home, "executor-key.json")
raw, _ := json.Marshal(map[string]string{"key_id": keyID, "environment_id": environment, "executor_token": "private-canary"})
if err := os.WriteFile(credential, raw, 0600); err != nil {
t.Fatal(err)
}
rc := &runContext{stdout: new(bytes.Buffer), stderr: new(bytes.Buffer)}
err := runEnvironmentConnect(t.Context(), rc, "default", false, true, "ws"+strings.TrimPrefix(server.URL, "http")+"/api/v1/agent-daemon/ws", environment, credential)
if err == nil || !strings.Contains(err.Error(), "run oac-daemon uninstall") || !strings.Contains(err.Error(), keyID) || strings.Contains(err.Error(), "private-canary") {
t.Fatalf("rejection: %v", err)
}
}

func TestUninstallDeletesInstallationAndKeepsWorkspace(t *testing.T) {
rc, args, root, _ := nativeInstallFixture(t)
workspace := args[slices.Index(args, "--workspace")+1]
if err := runInstall(rc, args); err != nil {
t.Fatal(err)
}
cache := filepath.Join(root, "native-download")
if err := os.MkdirAll(cache, 0700); err != nil {
t.Fatal(err)
}
output := new(bytes.Buffer)
rc = &runContext{stdin: strings.NewReader("n\n"), stdout: output, stderr: output}
if err := runUninstall(rc, nil); err == nil || !strings.Contains(output.String(), root) {
t.Fatalf("declined uninstall: %v\n%s", err, output)
}
if _, err := os.Stat(filepath.Join(root, "daemon", "installation.json")); err != nil {
t.Fatal("declining deleted the installation")
}
output.Reset()
rc.stdin = strings.NewReader("y\n")
if err := runUninstall(rc, nil); err != nil {
t.Fatal(err)
}
for _, text := range []string{"Revoke", "Executor credentials", "Delete the Session"} {
if !strings.Contains(output.String(), text) {
t.Fatalf("missing %q in\n%s", text, output)
}
}
if _, err := os.Stat(root); !os.IsNotExist(err) {
t.Fatal("installation directory remains")
}
if _, err := os.Stat(workspace); err != nil {
t.Fatal("workspace was deleted")
}
output.Reset()
if err := runUninstall(rc, nil); err != nil || !strings.Contains(output.String(), "nothing is installed") {
t.Fatalf("second run: %v\n%s", err, output)
}
}

// Windows cannot delete its running executable; everything else must go.
func TestRemoveInstallationKeepsOnlyRunningExecutable(t *testing.T) {
dir := t.TempDir()
running := filepath.Join(dir, "bin", "oac-daemon.exe")
for _, path := range []string{running, filepath.Join(dir, "bin", "other"), filepath.Join(dir, "daemon", "installation.json")} {
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, nil, 0600); err != nil {
t.Fatal(err)
}
}
remaining, err := removeInstallation(dir, running)
if err != nil || !remaining {
t.Fatalf("remaining=%v err=%v", remaining, err)
}
var left []string
_ = filepath.WalkDir(dir, func(path string, entry os.DirEntry, _ error) error {
if !entry.IsDir() {
left = append(left, path)
}
return nil
})
if len(left) != 1 || left[0] != running {
t.Fatalf("left behind: %v", left)
}
}
6 changes: 6 additions & 0 deletions apps/daemon/internal/cli/native_uninstall_unix.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
//go:build !windows

package cli

// Unix deletes the running executable with the rest, so nothing is left to schedule.
func deleteAfterExit(string) {}
18 changes: 18 additions & 0 deletions apps/daemon/internal/cli/native_uninstall_windows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package cli

import (
"os/exec"
"syscall"
)

// deleteAfterExit removes what the running executable kept open, once this
// process has exited.
func deleteAfterExit(dir string) {
cmd := exec.Command("cmd.exe")
cmd.SysProcAttr = &syscall.SysProcAttr{
CmdLine: `cmd.exe /d /s /c "ping -n 3 127.0.0.1 >nul & rmdir /s /q "` + dir + `""`,
CreationFlags: 0x00000008 | 0x00000200, // DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP
HideWindow: true,
}
_ = cmd.Start()
}
1 change: 1 addition & 0 deletions apps/daemon/internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ func defaultRunContext() *runContext {
var commands = []command{
{name: "install", summary: "Install a native daemon and selected Harnesses", run: runInstall},
{name: "start", summary: "Start the installed native daemon", run: runStart},
{name: "uninstall", summary: "Stop the daemon and delete this installation", run: runUninstall},
{name: "resume", summary: "Wake one planned hosted suspension", run: runResume},
{name: "runtime-mcp-exec", summary: "Execute an installed MCP server", run: runRuntimeMCP},
{name: "placement", summary: "Enroll or retire an explicitly managed local execution placement", run: runPlacement},
Expand Down
1 change: 1 addition & 0 deletions apps/daemon/internal/cli/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ func TestSubcommandsAreRegistered(t *testing.T) {
want := map[string]bool{
"install": false,
"start": false,
"uninstall": false,
"resume": false,
"runtime-mcp-exec": false,
"placement": false,
Expand Down
2 changes: 1 addition & 1 deletion contracts/agents-api/environment-executor-credentials.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ The installer derives this route from the returned `remote_url` and does not fol

## Revoked or rotated credential

When Core permanently rejects the daemon (enrollment 401 or 409, a permanent WebSocket rejection, or a daemon version from another Core distribution), the daemon prints the reason once and makes no further requests until it is stopped; it then exits successfully, so a supervisor that restarts on exit does not loop. When started again, it tries enrollment once and parks again. Transient failures keep the normal reconnect behavior and never replay execution.
When Core permanently rejects the daemon (enrollment 401 or 409, a permanent WebSocket rejection, or a daemon version from another Core distribution), a daemon started by `oac-daemon start` prints the reason once and exits with an error, and starting it again tries enrollment once and exits again. A Docker Runtime (`oac-daemon connect`) prints the reason once and makes no further requests until it is stopped, then exits successfully, so a supervisor that restarts on exit does not loop; started again, it tries enrollment once and parks again. Transient failures keep the normal reconnect behavior and never replay execution.

A machine reconnects only with its bound `key_id`, rotated to a new secret that replaces the credential file at its configured path; the [self-hosted guide](../../docs/getting-started/self-hosted.md#rotate-or-revoke) gives the steps. A new `key_id` cannot reconnect an Environment that is already bound: issuing it succeeds, but enrollment with it returns 409. Rotation does not reinstall Harnesses, change the workspace or replace native history; never create a new Session history to recover a credential.

Expand Down
4 changes: 2 additions & 2 deletions contracts/agents-api/zh/environment-executor-credentials.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
title: "Environment 执行器凭据"
source: contracts/agents-api/environment-executor-credentials.md
source_hash: a86ec5fdfc52bad459ff3112f92effaa810b5c44ddd138f6f871900a9b835026
source_hash: 5e18405343cbd5aa8814ed71efc0c97453ffc91e6c8d604159db7765dcb83f28
---

执行器凭据允许 `oac-daemon` 为一个 `self_hosted` Environment 注册并连接。它只授权该 Environment 的私有 daemon 传输(`/api/v1/agent-daemon/*`),不授权 `/v1`、`/core/v1`、sandbox node 注册或项目资源。项目的 principal 是其执行 principal。Core 只保存密钥摘要。
Expand Down Expand Up @@ -91,7 +91,7 @@ Connected 表示 Environment 已连接,其设备和执行器凭据仍有当前

## 已撤销或轮换的凭据 {#revoked-or-rotated-credential}

Core 永久拒绝 daemon 时(注册 401/409、永久 WebSocket 拒绝,或 daemon 版本来自其他 Core 分发),daemon 只打印一次原因,停止发请求直到被停止;随后成功退出,避免按退出重启的 supervisor 循环。再次启动时只尝试一次注册,然后再次停驻。临时故障保持正常重连行为,不重放执行。
Core 永久拒绝 daemon 时(注册 401/409、永久 WebSocket 拒绝,或 daemon 版本来自其他 Core 分发),由 `oac-daemon start` 启动的 daemon 只打印一次原因并以错误退出,再次启动时只尝试一次注册,然后再次退出。Docker Runtime(`oac-daemon connect`)只打印一次原因,停止发请求直到被停止,随后成功退出,避免按退出重启的 supervisor 循环;再次启动时只尝试一次注册,然后再次停驻。临时故障保持正常重连行为,不重放执行。

机器只能使用绑定的 `key_id` 重连,轮换新密钥后替换配置路径的凭据文件;[自托管指南](../../../docs/zh/getting-started/self-hosted.md#rotate-or-revoke)提供步骤。新 `key_id` 无法重新连接已绑定的 Environment:签发成功,但用它注册返回 409。轮换不重装 Harness、不改变工作区、不替换原生历史;不要为恢复凭据创建新 Session 历史。

Expand Down
Loading
Loading