diff --git a/apps/daemon/internal/cli/native_install.go b/apps/daemon/internal/cli/native_install.go index 2f1b1a4c9..a59fc9d5c 100644 --- a/apps/daemon/internal/cli/native_install.go +++ b/apps/daemon/internal/cli/native_install.go @@ -12,6 +12,7 @@ import ( "os" "path/filepath" "slices" + "strings" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/daemonize" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/paths" @@ -106,6 +107,9 @@ func runInstall(rc *runContext, args []string) error { } func installNativeOptions(ctx context.Context, rc *runContext, o *nativeInstallOptions) error { + if o.Directory == "" { + return errors.New("install: --environment-id is required; it names the default installation directory") + } if runtimefs.ValidateLocalPath(o.Directory) != nil || runtimefs.ValidateLocalPath(o.Bundle) != nil { return errors.New("install: --install-dir and --bundle-dir must be clean absolute directories") } @@ -116,6 +120,15 @@ func installNativeOptions(ctx context.Context, rc *runContext, o *nativeInstallO if o.RequiredHarness != "" && !slices.Contains(selected, o.RequiredHarness) { return errors.New("install: --harness must include the Session Harness") } + if _, err = os.Stat(filepath.Join(o.Directory, "daemon", "installation.json")); err != nil { + foreign, e := foreignEntries(o.Directory) + if e != nil { + return errors.New("install: cannot read --install-dir " + o.Directory) + } + if len(foreign) > 0 { + return fmt.Errorf("install: %s is not empty and holds no installation (it contains %s); choose an empty --install-dir", o.Directory, strings.Join(foreign, ", ")) + } + } o.Version = Version if o.CapabilityDirectory == "" { o.CapabilityDirectory = filepath.Join(o.Directory, "capabilities") diff --git a/apps/daemon/internal/cli/native_install_input.go b/apps/daemon/internal/cli/native_install_input.go index bb71d7f27..1ced32af7 100644 --- a/apps/daemon/internal/cli/native_install_input.go +++ b/apps/daemon/internal/cli/native_install_input.go @@ -11,6 +11,7 @@ import ( "strings" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/paths" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" ) type nativeInstallOptions struct { @@ -20,14 +21,11 @@ type nativeInstallOptions struct { Interactive, NonInteractive bool } -// grantEnv carries the short-lived installation grant, never an executor credential. Core's command sets it. -const grantEnv = "OAC_INSTALL_GRANT" - func parseNativeInstall(rc *runContext, args []string) (nativeInstallOptions, error) { var o nativeInstallOptions // Take the grant first and drop it from the environment, which start, the daemon and Harnesses inherit. - o.Authorization = os.Getenv(grantEnv) - _ = os.Unsetenv(grantEnv) + o.Authorization = os.Getenv(v1.NativeInstallGrantEnv) + _ = os.Unsetenv(v1.NativeInstallGrantEnv) flags := newFlagSet("install") flags.StringVar(&o.OnboardURL, "onboard-url", "", "Core installation endpoint") flags.StringVar(&o.Remote, "remote", "", "Environment remote_url from Core") @@ -36,7 +34,7 @@ func parseNativeInstall(rc *runContext, args []string) (nativeInstallOptions, er flags.StringVar(&o.Credential, "credential-file", "", "absolute executor credential JSON file (never the token)") flags.StringVar(&o.CapabilityDirectory, "capability-directory", "", "capability snapshot destination") flags.StringVar(&o.ToolEnvironmentFile, "tool-env-file", "", "optional tool environment JSON file") - flags.StringVar(&o.Directory, "install-dir", "", "installation directory (default OAC_RUNTIME_HOME or ~/.oac)") + flags.StringVar(&o.Directory, "install-dir", "", "installation directory (default /environments/; Runtime home is OAC_RUNTIME_HOME or ~/.oac)") flags.StringVar(&o.Bundle, "bundle-dir", "", "native distribution directory (defaults beside the executable)") flags.StringVar(&o.Harness, "harness", "", "comma-separated codex,claude,minimax") flags.BoolVar(&o.Interactive, "interactive", false, "ask for missing installation options") @@ -56,12 +54,8 @@ func parseNativeInstall(rc *runContext, args []string) (nativeInstallOptions, er if err := prepareOnboarding(&o); err != nil { return o, err } - if o.Directory == "" { - var err error - o.Directory, err = paths.Root() - if err != nil { - return o, err - } + if err := o.defaultDirectory(); err != nil { + return o, err } if o.Bundle == "" { exe, err := os.Executable() @@ -83,10 +77,27 @@ func parseNativeInstall(rc *runContext, args []string) (nativeInstallOptions, er if err := promptNativeInstall(input, rc.stdout, &o); err != nil { return o, err } + if err := o.defaultDirectory(); err != nil { + return o, err + } } return o, nil } +// defaultDirectory is the one definition of where an installation goes unless +// --install-dir says otherwise: its own directory in the Runtime home. +func (o *nativeInstallOptions) defaultDirectory() error { + if o.Directory != "" || o.Environment == "" { + return nil + } + root, err := paths.Root() + if err != nil { + return err + } + o.Directory = filepath.Join(root, "environments", o.Environment) + return nil +} + func promptNativeInstall(input io.Reader, output io.Writer, o *nativeInstallOptions) error { r := bufio.NewReader(input) if o.OnboardURL != "" { @@ -100,7 +111,7 @@ func promptNativeInstall(input io.Reader, output io.Writer, o *nativeInstallOpti optional bool }{ {"Harnesses (comma-separated codex,claude,minimax)", &o.Harness, false}, - {"Installation directory (Enter uses current default)", &o.Directory, true}, + {"Installation directory (Enter uses /environments/)", &o.Directory, true}, {"Distribution directory (Enter uses current default)", &o.Bundle, true}, {"Environment remote URL", &o.Remote, false}, {"Environment ID", &o.Environment, false}, diff --git a/apps/daemon/internal/cli/native_install_io.go b/apps/daemon/internal/cli/native_install_io.go index aa2060641..999e71a4b 100644 --- a/apps/daemon/internal/cli/native_install_io.go +++ b/apps/daemon/internal/cli/native_install_io.go @@ -8,9 +8,35 @@ import ( "os" "path/filepath" "regexp" + "slices" "time" ) +// nativeInstallationEntries are the top-level entries an installation directory +// owns: the installer writes bin, daemon, components and the default +// capabilities, and Windows keeps the lock file of daemon beside it; the daemon +// running from it adds runtime, initialization and packages. Install refuses a directory with anything else, and uninstall +// deletes nothing else. +var nativeInstallationEntries = []string{"bin", "daemon", "components", "capabilities", ".daemon.runtime.lock", "runtime", "initialization", "packages"} + +// foreignEntries names what dir holds that no installation owns. +func foreignEntries(dir string) ([]string, error) { + entries, err := os.ReadDir(dir) + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, err + } + var foreign []string + for _, entry := range entries { + if !slices.Contains(nativeInstallationEntries, entry.Name()) { + foreign = append(foreign, entry.Name()) + } + } + return foreign, nil +} + // These exact temporary names are reserved by the installer, never workspace data. var nativeTemporaryNames = map[string]*regexp.Regexp{ "components": regexp.MustCompile(`^\.install-(node|codex|claude|minimax)-[0-9]+$`), diff --git a/apps/daemon/internal/cli/native_install_test.go b/apps/daemon/internal/cli/native_install_test.go index eb904e34c..d423c8c4f 100644 --- a/apps/daemon/internal/cli/native_install_test.go +++ b/apps/daemon/internal/cli/native_install_test.go @@ -13,6 +13,7 @@ import ( "strings" "testing" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" "github.com/google/uuid" ) @@ -22,7 +23,7 @@ func nativeInstallFixture(t *testing.T) (*runContext, []string, string, string) bundle := t.TempDir() workspace := t.TempDir() t.Setenv("OAC_RUNTIME_HOME", root) - key := filepath.Join(root, "credential.json") + key := filepath.Join(t.TempDir(), "credential.json") environment := uuid.NewString() body, _ := json.Marshal(map[string]string{"key_id": uuid.NewString(), "executor_token": "private-test-credential", "environment_id": environment}) if err := os.WriteFile(key, body, 0600); err != nil { @@ -50,7 +51,7 @@ func nativeInstallFixture(t *testing.T) (*runContext, []string, string, string) t.Cleanup(func() { probeNativeInstallation = old }) output := new(bytes.Buffer) rc := &runContext{stdin: strings.NewReader("must not read"), stdout: output, stderr: output} - args := []string{"--non-interactive", "--bundle-dir", bundle, "--harness", "codex", "--remote", "ws://localhost:12345/api/v1/agent-daemon/ws", "--environment-id", environment, "--workspace", workspace, "--credential-file", key} + args := []string{"--non-interactive", "--bundle-dir", bundle, "--harness", "codex", "--remote", "ws://localhost:12345/api/v1/agent-daemon/ws", "--environment-id", environment, "--workspace", workspace, "--credential-file", key, "--install-dir", root} return rc, args, root, bundle } @@ -269,14 +270,14 @@ func TestNativeInstallationConnectCannotBypassValidation(t *testing.T) { func TestNativeInstallTakesGrantOutOfTheEnvironment(t *testing.T) { rc, _, _, _ := nativeInstallFixture(t) - t.Setenv(grantEnv, "private-grant") + t.Setenv(v1.NativeInstallGrantEnv, "private-grant") // The endpoint is invalid so parsing stops before it contacts Core; the grant is already taken. o, _ := parseNativeInstall(rc, []string{"--onboard-url", "https://core.example/other", "--non-interactive"}) if o.Authorization != "private-grant" { t.Fatal("grant not read from the environment") } for _, entry := range withNativeEnv(nil) { - if strings.HasPrefix(entry, grantEnv+"=") { + if strings.HasPrefix(entry, v1.NativeInstallGrantEnv+"=") { t.Fatal("grant reaches child processes") } } diff --git a/apps/daemon/internal/cli/native_onboarding.go b/apps/daemon/internal/cli/native_onboarding.go index 1958c03b3..aa251339c 100644 --- a/apps/daemon/internal/cli/native_onboarding.go +++ b/apps/daemon/internal/cli/native_onboarding.go @@ -63,7 +63,7 @@ func prepareOnboarding(o *nativeInstallOptions) error { return nil } if o.OnboardURL == "" || o.Authorization == "" { - return errors.New("install: --onboard-url and " + grantEnv + " are required together") + return errors.New("install: --onboard-url and " + v1.NativeInstallGrantEnv + " are required together") } const suffix = "/api/v1/agent-daemon/installation" if !strings.HasSuffix(o.OnboardURL, suffix) { @@ -96,12 +96,8 @@ func prepareOnboarding(o *nativeInstallOptions) error { if o.RequiredHarness == "" { return errors.New("install: the Session requires an unsupported Harness") } - if o.Directory == "" { - root, err := paths.Root() - if err != nil { - return err - } - o.Directory = filepath.Join(root, "environments", o.Environment) + if err := o.defaultDirectory(); err != nil { + return err } if !o.NonInteractive { o.Interactive = true diff --git a/apps/daemon/internal/cli/native_uninstall.go b/apps/daemon/internal/cli/native_uninstall.go index a535c4c20..5ee504578 100644 --- a/apps/daemon/internal/cli/native_uninstall.go +++ b/apps/daemon/internal/cli/native_uninstall.go @@ -39,15 +39,21 @@ func runUninstall(rc *runContext, args []string) error { 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) + var owned []string + for _, name := range nativeInstallationEntries { + if _, err = os.Lstat(filepath.Join(root, name)); err == nil { + owned = append(owned, filepath.Join(root, name)) + } + } + foreign, err := foreignEntries(root) + if err != nil { + return errors.New("uninstall: cannot read the installation directory; delete its entries by hand") } fmt.Fprintln(rc.stdout, "This stops the daemon and deletes, with everything inside:") - for _, target := range targets { + for _, target := range owned { 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.Fprintln(rc.stdout, "These hold the executor credential, native history and Harnesses. The workspace stays, and so does anything else in "+root+" and any credential or capability directory outside the installation.") fmt.Fprint(rc.stdout, "Continue? [y/N] ") answer, _ := bufio.NewReader(rc.stdin).ReadString('\n') if !strings.EqualFold(strings.TrimSpace(answer), "y") { @@ -57,82 +63,98 @@ func runUninstall(rc *runContext, args []string) error { return err } // Windows cannot delete the executable it is running. - running := "" + var running os.FileInfo if runtime.GOOS == "windows" { - running, _ = os.Executable() + if exe, e := os.Executable(); e == nil { + running, _ = os.Stat(exe) + } } - for _, target := range targets[1:] { - if err = os.RemoveAll(target); err != nil { - return fmt.Errorf("uninstall: cannot delete %s: %w", target, err) + // The record and the executable go last: a failed uninstall can run again. + order := owned[:0:0] + for _, target := range owned { + if name := filepath.Base(target); name != "bin" && name != "daemon" { + order = append(order, target) } } - 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) + record := filepath.Join(root, "daemon", "installation.json") + if entries, e := os.ReadDir(filepath.Join(root, "daemon")); e == nil { + for _, entry := range entries { + if path := filepath.Join(root, "daemon", entry.Name()); path != record { + order = append(order, path) + } + } } - if remaining { + bin := filepath.Join(root, "bin") + order = append(order, bin, record) + var kept bool + for _, target := range order { + held, e := removeTree(target, running) + if e != nil { + return fmt.Errorf("uninstall: cannot delete %s; close programs using it and run the command again: %w", target, e) + } + kept = kept || held + } + // Only empty directories go; anything left belongs to the user. + _ = os.Remove(filepath.Join(root, "daemon")) + if kept { // Windows keeps the running executable open until this process exits. deleteAfterExit(root) + } else { + _ = os.Remove(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) + for _, name := range foreign { + fmt.Fprintln(rc.stdout, "Kept: "+filepath.Join(root, name)) + } + for _, path := range []string{config.Credential, config.CapabilityDirectory} { + if path != "" && !within(root, path) { + fmt.Fprintln(rc.stdout, "Kept: "+path) + } + } + 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. This Session's install command cannot be used again; to reconnect it, choose Rotate and install from an extracted distribution, or create a new 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) +// removeTree deletes path. keep is the running executable, which Windows +// cannot delete; it stays, and so does every directory that holds it. +func removeTree(path string, keep os.FileInfo) (kept bool, err error) { + if keep == nil { + return false, os.RemoveAll(path) + } + info, err := os.Lstat(path) + if errors.Is(err, os.ErrNotExist) { + return false, nil } - return true, removeAllExcept(dir, running) -} - -func removeAllExcept(dir, keep string) error { - entries, err := os.ReadDir(dir) if err != nil { - return err + return false, err + } + if os.SameFile(info, keep) { + return true, nil + } + if !info.IsDir() { + return false, os.Remove(path) + } + entries, err := os.ReadDir(path) + if err != nil { + return false, 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 - } + held, e := removeTree(filepath.Join(path, entry.Name()), keep) + if e != nil { + return false, e } + kept = kept || held } - return nil + if kept { + return true, nil + } + return false, os.Remove(path) } 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 /environments/ 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 -} diff --git a/apps/daemon/internal/cli/native_uninstall_test.go b/apps/daemon/internal/cli/native_uninstall_test.go index f43d1a30c..c5c789e0b 100644 --- a/apps/daemon/internal/cli/native_uninstall_test.go +++ b/apps/daemon/internal/cli/native_uninstall_test.go @@ -7,6 +7,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "runtime" "slices" "strings" "testing" @@ -37,16 +38,21 @@ func TestNativeStartExitsOnPermanentRejection(t *testing.T) { func TestUninstallDeletesInstallationAndKeepsWorkspace(t *testing.T) { rc, args, root, _ := nativeInstallFixture(t) workspace := args[slices.Index(args, "--workspace")+1] + credential := args[slices.Index(args, "--credential-file")+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 { + // A file and a directory the installation does not own. + foreignFile, foreignDir := filepath.Join(root, "notes.txt"), filepath.Join(root, "core") + if err := os.MkdirAll(foreignDir, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(foreignFile, []byte("keep"), 0600); 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) { + if err := runUninstall(rc, nil); err == nil || !strings.Contains(output.String(), filepath.Join(root, "bin")) { t.Fatalf("declined uninstall: %v\n%s", err, output) } if _, err := os.Stat(filepath.Join(root, "daemon", "installation.json")); err != nil { @@ -57,16 +63,20 @@ func TestUninstallDeletesInstallationAndKeepsWorkspace(t *testing.T) { if err := runUninstall(rc, nil); err != nil { t.Fatal(err) } - for _, text := range []string{"Revoke", "Executor credentials", "Delete the Session"} { + for _, text := range []string{"Revoke", "Executor credentials", "Delete the Session", "Kept: " + foreignFile, "Kept: " + foreignDir, "Kept: " + credential, "Rotate"} { 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") + for _, owned := range nativeInstallationEntries { + if _, err := os.Lstat(filepath.Join(root, owned)); !os.IsNotExist(err) { + t.Fatalf("%s remains", owned) + } } - if _, err := os.Stat(workspace); err != nil { - t.Fatal("workspace was deleted") + for _, kept := range []string{workspace, foreignFile, foreignDir, credential} { + if _, err := os.Stat(kept); err != nil { + t.Fatalf("%s was deleted", kept) + } } output.Reset() if err := runUninstall(rc, nil); err != nil || !strings.Contains(output.String(), "nothing is installed") { @@ -74,11 +84,95 @@ func TestUninstallDeletesInstallationAndKeepsWorkspace(t *testing.T) { } } +// An installation with nothing else in its directory leaves no directory behind. +func TestUninstallRemovesEmptiedInstallationDirectory(t *testing.T) { + rc, args, root, _ := nativeInstallFixture(t) + if err := runInstall(rc, args); err != nil { + t.Fatal(err) + } + rc = &runContext{stdin: strings.NewReader("y\n"), stdout: new(bytes.Buffer), stderr: new(bytes.Buffer)} + if err := runUninstall(rc, nil); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(root); !os.IsNotExist(err) { + t.Fatal("installation directory remains") + } +} + +// A file that cannot be deleted fails the uninstall with the installation's +// record and executable still in place, so the command can run again. +func TestUninstallFailureCanBeRerun(t *testing.T) { + if runtime.GOOS == "windows" || os.Geteuid() == 0 { + t.Skip("needs a directory the user cannot write to") + } + rc, args, root, _ := nativeInstallFixture(t) + if err := runInstall(rc, args); err != nil { + t.Fatal(err) + } + locked := filepath.Join(root, "components", "locked") + if err := os.MkdirAll(locked, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(locked, "file"), nil, 0600); err != nil { + t.Fatal(err) + } + if err := os.Chmod(locked, 0500); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(locked, 0700) }) + rc = &runContext{stdin: strings.NewReader("y\n"), stdout: new(bytes.Buffer), stderr: new(bytes.Buffer)} + err := runUninstall(rc, nil) + if err == nil || !strings.Contains(err.Error(), "run the command again") { + t.Fatalf("uninstall: %v", err) + } + for _, path := range []string{filepath.Join(root, "daemon", "installation.json"), filepath.Join(root, "bin")} { + if _, err = os.Stat(path); err != nil { + t.Fatalf("%s was deleted before the failure: %v", path, err) + } + } + if err = os.Chmod(locked, 0700); err != nil { + t.Fatal(err) + } + rc = &runContext{stdin: strings.NewReader("y\n"), stdout: new(bytes.Buffer), stderr: new(bytes.Buffer)} + if err = runUninstall(rc, nil); err != nil { + t.Fatalf("rerun: %v", err) + } +} + +// Install writes only into an empty directory or an installation, never beside +// a Runtime home's other contents. +func TestInstallRefusesDirectoryHoldingOtherFiles(t *testing.T) { + rc, args, root, _ := nativeInstallFixture(t) + if err := os.MkdirAll(filepath.Join(root, "core"), 0700); err != nil { + t.Fatal(err) + } + err := runInstall(rc, args) + if err == nil || !strings.Contains(err.Error(), root) || !strings.Contains(err.Error(), "core") { + t.Fatalf("install into a foreign directory: %v", err) + } + if _, err = os.Stat(filepath.Join(root, "daemon")); !os.IsNotExist(err) { + t.Fatal("refused install still wrote into the directory") + } +} + +// Without --install-dir an installation gets its own directory in the Runtime home. +func TestInstallDefaultsToEnvironmentDirectory(t *testing.T) { + rc, args, root, _ := nativeInstallFixture(t) + args = slices.Delete(args, slices.Index(args, "--install-dir"), slices.Index(args, "--install-dir")+2) + if err := runInstall(rc, args); err != nil { + t.Fatal(err) + } + environment := args[slices.Index(args, "--environment-id")+1] + if _, err := os.Stat(filepath.Join(root, "environments", environment, "daemon", "installation.json")); err != nil { + t.Fatalf("installation is not in the default directory: %v", err) + } +} + // Windows cannot delete its running executable; everything else must go. -func TestRemoveInstallationKeepsOnlyRunningExecutable(t *testing.T) { +func TestRemoveTreeKeepsOnlyRunningExecutable(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")} { + for _, path := range []string{running, filepath.Join(dir, "bin", "other")} { if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { t.Fatal(err) } @@ -86,18 +180,18 @@ func TestRemoveInstallationKeepsOnlyRunningExecutable(t *testing.T) { t.Fatal(err) } } - remaining, err := removeInstallation(dir, running) - if err != nil || !remaining { - t.Fatalf("remaining=%v err=%v", remaining, err) + keep, err := os.Stat(running) + if err != nil { + t.Fatal(err) + } + kept, err := removeTree(filepath.Join(dir, "bin"), keep) + if err != nil || !kept { + t.Fatalf("kept=%v err=%v", kept, 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) + if _, err = os.Stat(running); err != nil { + t.Fatal("running executable was deleted") + } + if _, err = os.Stat(filepath.Join(dir, "bin", "other")); !os.IsNotExist(err) { + t.Fatal("other file remains") } } diff --git a/apps/daemon/internal/cli/native_uninstall_windows.go b/apps/daemon/internal/cli/native_uninstall_windows.go index 2a39cf5a4..049933284 100644 --- a/apps/daemon/internal/cli/native_uninstall_windows.go +++ b/apps/daemon/internal/cli/native_uninstall_windows.go @@ -2,15 +2,17 @@ package cli import ( "os/exec" + "path/filepath" "syscall" ) -// deleteAfterExit removes what the running executable kept open, once this -// process has exited. +// deleteAfterExit removes the bin directory the running executable kept open, +// once this process has exited, then the installation directory if that is +// empty. 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 + `""`, + CmdLine: `cmd.exe /d /s /c "ping -n 3 127.0.0.1 >nul & rmdir /s /q "` + filepath.Join(dir, "bin") + `" & rmdir "` + dir + `""`, CreationFlags: 0x00000008 | 0x00000200, // DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP HideWindow: true, } diff --git a/contracts/agents-api/environment-executor-credentials.md b/contracts/agents-api/environment-executor-credentials.md index 3f9a45aa0..e3ddd57b1 100644 --- a/contracts/agents-api/environment-executor-credentials.md +++ b/contracts/agents-api/environment-executor-credentials.md @@ -22,7 +22,7 @@ Session create, retrieve and update responses of a `self_hosted` Session carry ` | `expires_at` | Unix time when the grant expires, 30 minutes after the response | | `commands.posix`, `commands.powershell` | The install command for Linux/macOS and for Windows PowerShell | -The grant is bound to the Environment, the Session creator's principal and the Core build. It stops working when it expires, when the Session is deleted, when the Project is archived or when Core runs a different build. Reading the Session again returns a fresh grant. Core stores no grant: each response signs a new one, and stored events never carry it. The command passes the grant to the installer in the `OAC_INSTALL_GRANT` environment variable, never in a process argument, and the installer removes the variable from its environment before it starts anything. Treat the command as a temporary secret: it can claim the credential, but it cannot run work or read files. +The grant is bound to the Environment, the Session creator's principal and the Core build. It stops working when it expires, when the Session is deleted, when the Project is archived or when Core runs a different build. Reading the Session again returns a fresh grant. Core stores no grant: each response signs a new one, and stored events never carry it. The command passes the grant in the `OAC_INSTALL_GRANT` environment variable, never in a process argument. The bootstrap hands it to the installer alone, and the installer removes the variable from its environment before it starts the daemon or any Harness. Treat the command as a temporary secret: it can claim the credential, but it cannot run work or read files. The installer generates the secret and saves it privately as `daemon/executor-credential.json` in the installation directory before it claims the key. Core stores the digest under the key ID equal to the Environment ID. A lost response is safe to retry: the retry must present the same secret. A grant never replaces or restores a credential. If the Environment already has a different, rotated or revoked credential, the claim fails with 409 `executor_credential_exists`. diff --git a/contracts/agents-api/v1/installation.go b/contracts/agents-api/v1/installation.go index a1f77808a..8fab83f69 100644 --- a/contracts/agents-api/v1/installation.go +++ b/contracts/agents-api/v1/installation.go @@ -22,6 +22,9 @@ type EnvironmentInstallation struct { Message string `json:"message,omitempty"` } +// NativeInstallGrantEnv names the environment variable that carries the short-lived installation grant from Core's command to the installer. It never carries an executor credential. +const NativeInstallGrantEnv = "OAC_INSTALL_GRANT" + // NativeInstallationContext is bootstrap metadata, not an execution protocol. type NativeInstallationContext struct { Version string `json:"version"` diff --git a/contracts/agents-api/zh/environment-executor-credentials.md b/contracts/agents-api/zh/environment-executor-credentials.md index 728bfaeea..26d245fb9 100644 --- a/contracts/agents-api/zh/environment-executor-credentials.md +++ b/contracts/agents-api/zh/environment-executor-credentials.md @@ -1,7 +1,7 @@ --- title: "Environment 执行器凭据" source: contracts/agents-api/environment-executor-credentials.md -source_hash: 5e18405343cbd5aa8814ed71efc0c97453ffc91e6c8d604159db7765dcb83f28 +source_hash: 5615fb412652ebcf34a1378a81128e0cd153b08932c443c02c6a59d2326718ec --- 执行器凭据允许 `oac-daemon` 为一个 `self_hosted` Environment 注册并连接。它只授权该 Environment 的私有 daemon 传输(`/api/v1/agent-daemon/*`),不授权 `/v1`、`/core/v1`、sandbox node 注册或项目资源。项目的 principal 是其执行 principal。Core 只保存密钥摘要。 @@ -24,7 +24,7 @@ Core 不创建、停止或回收机器。断开连接、撤销凭据或删除 Se | `expires_at` | grant 到期的 Unix 时间,为响应生成后 30 分钟 | | `commands.posix`, `commands.powershell` | Linux/macOS 和 Windows PowerShell 的安装命令 | -grant 绑定 Environment、Session 创建者的 principal 和 Core 构建版本。在到期、Session 被删除、项目被归档或 Core 运行另一构建版本时失效。重新读取 Session 会获得新 grant。Core 不存储 grant:每个响应重新签名,存储的事件从不包含它。命令通过环境变量 `OAC_INSTALL_GRANT` 把 grant 交给安装器,从不放在进程参数中,安装器在启动任何进程之前先从自身环境中移除该变量。将命令视为临时秘密:它能领取凭据,但不能执行工作或读取文件。 +grant 绑定 Environment、Session 创建者的 principal 和 Core 构建版本。在到期、Session 被删除、项目被归档或 Core 运行另一构建版本时失效。重新读取 Session 会获得新 grant。Core 不存储 grant:每个响应重新签名,存储的事件从不包含它。命令通过环境变量 `OAC_INSTALL_GRANT` 传递 grant,从不放在进程参数中。引导脚本只把它交给安装器,安装器在启动守护进程或任何 Harness 之前先从自身环境中移除该变量。将命令视为临时秘密:它能领取凭据,但不能执行工作或读取文件。 安装器生成密钥,在领取前将其私密保存到安装目录的 `daemon/executor-credential.json`。Core 将摘要保存在 key ID 等于 Environment ID 的记录中。响应丢失后可以安全重试,但必须提交同一个密钥。grant 不替换或恢复凭据。如果 Environment 已有不同、已轮换或已撤销的凭据,领取返回 409 `executor_credential_exists`。 diff --git a/docs/getting-started/self-hosted.md b/docs/getting-started/self-hosted.md index 119f4d6f6..b0c9de429 100644 --- a/docs/getting-started/self-hosted.md +++ b/docs/getting-started/self-hosted.md @@ -61,7 +61,7 @@ No administrator privileges or Docker are needed. The Unix download command also In Web, open the Session and copy the command under **Connect a host**. -Keep the command private: it carries a short-lived [installation grant](../../contracts/agents-api/environment-executor-credentials.md#installation-grant) that claims the machine's credential. The command passes the grant in the `OAC_INSTALL_GRANT` environment variable, never as an argument, and starts with a space so shells that skip space-prefixed commands leave it out of history. When it has expired, read the Session again or copy a fresh command from Web. +Keep the command private: it carries a short-lived [installation grant](../../contracts/agents-api/environment-executor-credentials.md#installation-grant) that claims the machine's credential. The command passes the grant in the `OAC_INSTALL_GRANT` environment variable, never as an argument. On Linux and macOS it starts with a space so shells that skip space-prefixed commands leave it out of history. PowerShell has no such rule: its history file (`ConsoleHost_history.txt`) keeps the whole command, grant included, until you clear it, although the grant stops working after 30 minutes. When it has expired, read the Session again or copy a fresh command from Web. The installer reports three results: @@ -83,7 +83,7 @@ Append these to the command: | --- | --- | | `--non-interactive` | Never prompt; missing input fails | | `--harness codex,claude,minimax` | Harnesses to install, comma-separated. Must include the Session's Harness | -| `--install-dir ABS` | Installation directory. Default: `environments/` under `~/.oac`, or under `OAC_RUNTIME_HOME` when set | +| `--install-dir ABS` | Installation directory. It must be missing, empty or hold an installation; any other non-empty directory is refused. Default: `environments/` under `~/.oac`, or under `OAC_RUNTIME_HOME` when set | | `--capability-directory ABS` | Where [capability snapshots](#local-capability-directories) are stored. Default: `capabilities` in the installation directory | | `--tool-env-file ABS` | A JSON file of string variables for tools and MCP servers; see [explicit local tool environment](../../contracts/agents-api/environments.md#explicit-local-tool-environment) | @@ -146,10 +146,18 @@ Run the installation's own command: "$HOME/.oac/environments//bin/oac-daemon" uninstall ``` -It stops the daemon, lists what it deletes and asks for confirmation. It deletes the installation directory with its credential file, native history and Harnesses, and, when no other installation shares the Runtime home, the `native-download` directory that holds the installer's download lock. It never deletes the workspace. Where nothing is installed it says so and exits 0. On Windows, the final deletion finishes a few seconds after the command exits. +On Windows PowerShell: + +```powershell +& "$HOME\.oac\environments\\bin\oac-daemon.exe" uninstall +``` + +It stops the daemon, lists what it deletes and asks for confirmation. It deletes only what an installation owns inside its directory (`bin`, `daemon`, `components`, `capabilities`, `runtime`, `initialization` and `packages`, with their credential file, native history and Harnesses) and then the directory itself if nothing else is left in it. Anything else in the directory stays, and so do the workspace and any credential file or capability directory outside the installation; the command names each one it keeps. The `native-download` directory and its `download.lock` are never touched. Where nothing is installed it says so and exits 0. If a file cannot be deleted, the command names it and can be run again once it is released. On Windows, the final deletion finishes a few seconds after the command exits. Then, in Web, open the Session and choose **Revoke** in **Executor credentials**, or **Delete** the Session; Core keeps accepting the machine's credential until you do. +The Session's install command cannot connect the machine again: its credential stays claimed in Core. To reconnect the Session, choose **Rotate** and [install from an extracted distribution](#reconnect-an-existing-session), or create a new Session. + After a Session is deleted or its credential is revoked or rotated, the daemon started with `start` prints why in `connect.log` (see `oac-daemon logs`) and exits. Starting it again prints the same reason. To reconnect it, follow [Rotate or revoke](#rotate-or-revoke) or [Reconnect an existing Session](#reconnect-an-existing-session); to remove it, run `uninstall`. ## Rotate or revoke @@ -191,4 +199,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. 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`. +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. The installation directory must be missing, empty or hold an installation. This mode needs an existing workspace and does not start the daemon until you run `start`. diff --git a/docs/zh/getting-started/self-hosted.md b/docs/zh/getting-started/self-hosted.md index 767902e00..638b524b9 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: 1084921d73014c1c48c6b4c2df5fa356544ed3b046bf0bc90cb9dce85b7188f7 +source_hash: 9fda47d5be8d2977ac66a7db3ae2ce7c1112784a0a6521dc3791cdd0f284899f --- `self_hosted` Session 在应用拥有的机器上运行:工作站、虚拟机或你管理的沙箱。应用通过 `/v1` 创建 Session,并获得安装 `oac-daemon`、启动它并连接 Core 的命令。Web 在 Session 页面展示同一命令;Web 是可选的。Core 不创建、停止或回收这台机器。 @@ -63,7 +63,7 @@ Session 自带模型提供商;安装默认模型不适用([原因](../../../ 在 Web 中打开 Session,复制 **连接主机** 下的命令。 -请保密该命令:它包含短期[安装授权](../../../contracts/agents-api/zh/environment-executor-credentials.md#installation-grant),用于领取机器凭据。命令通过环境变量 `OAC_INSTALL_GRANT` 传递授权,从不放在参数中;命令以空格开头,会忽略空格开头命令的 shell 不会把它记入历史。过期后重新读取 Session,或从 Web 复制新命令。 +请保密该命令:它包含短期[安装授权](../../../contracts/agents-api/zh/environment-executor-credentials.md#installation-grant),用于领取机器凭据。命令通过环境变量 `OAC_INSTALL_GRANT` 传递授权,从不放在参数中。在 Linux 和 macOS 上,命令以空格开头,会忽略空格开头命令的 shell 不会把它记入历史。PowerShell 没有这条规则:它的历史文件(`ConsoleHost_history.txt`)会把整条命令连同授权一起保存,直到你清除它,不过授权在 30 分钟后就会失效。过期后重新读取 Session,或从 Web 复制新命令。 安装程序报告三个结果: @@ -85,7 +85,7 @@ Session 自带模型提供商;安装默认模型不适用([原因](../../../ | --- | --- | | `--non-interactive` | 不提示;缺少输入时失败 | | `--harness codex,claude,minimax` | 要安装的 Harness,以逗号分隔。必须包含 Session 的 Harness | -| `--install-dir ABS` | 安装目录。默认是 `~/.oac` 下的 `environments/`;设置了 `OAC_RUNTIME_HOME` 时则在该目录下 | +| `--install-dir ABS` | 安装目录。它必须不存在、为空,或已包含一个安装;其他非空目录会被拒绝。默认是 `~/.oac` 下的 `environments/`;设置了 `OAC_RUNTIME_HOME` 时则在该目录下 | | `--capability-directory ABS` | [能力快照](#local-capability-directories)存储位置。默认是安装目录中的 `capabilities` | | `--tool-env-file ABS` | 为工具和 MCP 服务器提供字符串变量的 JSON 文件;参阅[显式本地工具环境](../../../contracts/agents-api/zh/environments.md#explicit-local-tool-environment) | @@ -148,10 +148,18 @@ session = client.beta.agents.sessions.create( "$HOME/.oac/environments//bin/oac-daemon" uninstall ``` -它会停止守护进程,列出将删除的内容并请求确认。删除的内容包括安装目录(含凭据文件、原生历史和 Harness),以及(没有其他安装共用该 Runtime 主目录时)保存安装程序下载锁的 `native-download` 目录。工作区永远不会被删除。在没有安装的位置运行时,它会说明这一点并以 0 退出。在 Windows 上,最后的删除会在命令退出几秒后完成。 +在 Windows PowerShell 中: + +```powershell +& "$HOME\.oac\environments\\bin\oac-daemon.exe" uninstall +``` + +它会停止守护进程,列出将删除的内容并请求确认。它只删除安装目录中属于该安装的条目(`bin`、`daemon`、`components`、`capabilities`、`runtime`、`initialization` 和 `packages`,含凭据文件、原生历史和 Harness),之后若目录中没有其他内容,再删除目录本身。目录中的其他内容会保留,工作区以及安装目录之外的凭据文件或能力目录也会保留;命令会逐一列出保留的路径。`native-download` 目录及其 `download.lock` 不会被触碰。在没有安装的位置运行时,它会说明这一点并以 0 退出。如果某个文件无法删除,命令会指出它,待其被释放后可再次运行。在 Windows 上,最后的删除会在命令退出几秒后完成。 随后在 Web 中打开该 Session,在 **执行器凭据** 中选择 **撤销**,或 **删除** 该 Session;在此之前,Core 仍接受这台机器的凭据。 +该 Session 的安装命令无法再次连接这台机器:它的凭据在 Core 中仍处于已领取状态。要重新连接该 Session,请选择 **轮换**,并[从已解压的发行包安装](#reconnect-an-existing-session),或新建 Session。 + Session 被删除,或其凭据被撤销或轮换后,由 `start` 启动的守护进程会在 `connect.log` 中写明原因(见 `oac-daemon logs`)并退出,再次启动会打印同样的原因。要重新连接,参阅[轮换或撤销](#rotate-or-revoke)或[重新连接已有 Session](#reconnect-an-existing-session);要移除,运行 `uninstall`。 ## 轮换或撤销 {#rotate-or-revoke} @@ -193,4 +201,4 @@ Session 被删除,或其凭据被撤销或轮换后,由 `start` 启动的守 "$HOME/.oac/my-runtime/bin/oac-daemon" start ``` -使用 Session 的 `remote_url` 和 Environment ID。发行包即[重新连接已有 Session](#reconnect-an-existing-session) 中的压缩包。在 PowerShell 中,以原生绝对路径运行 `.\oac-daemon.exe`。此模式要求工作区已存在,且在运行 `start` 前不会启动守护进程。 +使用 Session 的 `remote_url` 和 Environment ID。发行包即[重新连接已有 Session](#reconnect-an-existing-session) 中的压缩包。在 PowerShell 中,以原生绝对路径运行 `.\oac-daemon.exe`。安装目录必须不存在、为空,或已包含一个安装。此模式要求工作区已存在,且在运行 `start` 前不会启动守护进程。 diff --git a/services/core/internal/nativeinstaller/assets/bootstrap.ps1 b/services/core/internal/nativeinstaller/assets/bootstrap.ps1 index 4d10dff6e..c04665665 100644 --- a/services/core/internal/nativeinstaller/assets/bootstrap.ps1 +++ b/services/core/internal/nativeinstaller/assets/bootstrap.ps1 @@ -3,6 +3,9 @@ param( [Parameter(ValueFromRemainingArguments=$true)][string[]]$InstallArguments ) $ErrorActionPreference = 'Stop' +# Only the installer receives the grant; every other program started below runs without it. +$grant = $env:OAC_INSTALL_GRANT +Remove-Item Env:OAC_INSTALL_GRANT -ErrorAction SilentlyContinue $tar = Join-Path $env:SystemRoot 'System32\tar.exe' if (!(Test-Path -LiteralPath $tar -PathType Leaf)) { throw 'Windows tar.exe is required. Install the Windows archive tools, then rerun this command.' } Add-Type -AssemblyName System.Net.Http @@ -125,11 +128,11 @@ function Get-NativeFile([string]$Uri, [string]$Destination, [long]$Limit) { function Get-ArchiveHash([string]$Path) { (Get-FileHash -Algorithm SHA256 -LiteralPath $Path).Hash.ToLowerInvariant() } # Reject a rejected grant before any large download. Only a 401 stops here; any other answer is reported by the installer. function Test-Grant { - if (!$env:OAC_INSTALL_GRANT) { return } + if (!$grant) { return } $rejected = $false try { $message = [Net.Http.HttpRequestMessage]::new([Net.Http.HttpMethod]::Post, ($Base -replace '/install/[^/]+$', '/installation')) - $message.Headers.Authorization = [Net.Http.Headers.AuthenticationHeaderValue]::new('Bearer', $env:OAC_INSTALL_GRANT) + $message.Headers.Authorization = [Net.Http.Headers.AuthenticationHeaderValue]::new('Bearer', $grant) $answer = $client.SendAsync($message).GetAwaiter().GetResult() $rejected = [int]$answer.StatusCode -eq 401 } catch { } finally { if ($message) { $message.Dispose() }; if ($answer) { $answer.Dispose() } } @@ -198,7 +201,9 @@ try { if ($LASTEXITCODE -ne 0) { throw 'Installer extraction failed. Check disk space, quota and filesystem permissions.' } $endpoint = $Base -replace '/install/[^/]+$', '/installation' Write-Host 'Starting installation...' - & (Join-Path $bundle 'oac-daemon.exe') install --onboard-url $endpoint @InstallArguments + $env:OAC_INSTALL_GRANT = $grant + try { & (Join-Path $bundle 'oac-daemon.exe') install --onboard-url $endpoint @InstallArguments } + finally { Remove-Item Env:OAC_INSTALL_GRANT -ErrorAction SilentlyContinue } if ($LASTEXITCODE -ne 0) { throw 'Installation or connection failed; follow the installer guidance and retry.' } Remove-Item -LiteralPath $archive -Force } finally { diff --git a/services/core/internal/nativeinstaller/assets/bootstrap.sh b/services/core/internal/nativeinstaller/assets/bootstrap.sh index d4ed9a10e..f36176614 100644 --- a/services/core/internal/nativeinstaller/assets/bootstrap.sh +++ b/services/core/internal/nativeinstaller/assets/bootstrap.sh @@ -2,6 +2,9 @@ set -euo pipefail base=$1 shift +# Only the installer receives the grant; every other program started below runs without it. +grant=${OAC_INSTALL_GRANT:-} +unset OAC_INSTALL_GRANT fail() { printf '%s\n' "$*" >&2; exit 1; } case "$(uname -s)" in Linux) os=linux;; Darwin) os=darwin;; *) fail 'Unsupported operating system.';; esac case "$(uname -m)" in x86_64) arch=amd64;; arm64|aarch64) arch=arm64;; *) fail 'Unsupported processor architecture.';; esac @@ -66,6 +69,7 @@ fetch() { 33) rm -f "$destination";; # The server cannot resume; start the file again. esac case "$code:$status" in + 22:416) return 0;; # The partial file already holds the whole download; the checksum decides. 6:*|7:*|16:*|18:*|28:*|33:*|35:*|52:*|55:*|56:*|92:*|22:408|22:429|22:500|22:502|22:503|22:504) if [ "$attempt" -lt 5 ]; then printf 'Download interrupted; retrying (%s/4)...\n' "$attempt" >&2; run sleep "$attempt"; continue; fi;; esac @@ -74,9 +78,9 @@ fetch() { } # Reject a rejected grant before any large download. Only a 401 stops here; any other answer is reported by the installer. check_grant() { - [ -n "${OAC_INSTALL_GRANT:-}" ] || return 0 + [ -n "$grant" ] || return 0 local status - printf 'header = "Authorization: Bearer %s"\n' "$OAC_INSTALL_GRANT" | run curl -sS --config - --request POST --connect-timeout 15 --max-time 30 --write-out '%{http_code}' --output /dev/null "${base%/install/*}/installation" >"$work/http-status" 2>/dev/null || : + printf 'header = "Authorization: Bearer %s"\n' "$grant" | run curl -sS --config - --request POST --connect-timeout 15 --max-time 30 --write-out '%{http_code}' --output /dev/null "${base%/install/*}/installation" >"$work/http-status" 2>/dev/null || : status=$(cat "$work/http-status") [ "$status" != 401 ] || fail 'Installation authorization rejected or expired; copy a fresh command from the Session.' } @@ -118,7 +122,7 @@ printf 'Extracting the installer...\n' mkdir "$work/bundle" run tar -xzf "$archive" -C "$work/bundle" || fail 'Installer extraction failed. Check disk space, quota and filesystem permissions.' [ -x "$work/bundle/oac-daemon" ] || fail 'Cannot start the native installer. Check executable permissions and use a Runtime home that permits execution.' -if run "$work/bundle/oac-daemon" install --onboard-url "${base%/install/*}/installation" "$@"; then +if OAC_INSTALL_GRANT=$grant run "$work/bundle/oac-daemon" install --onboard-url "${base%/install/*}/installation" "$@"; then rm -f "$archive" exit 0 else diff --git a/services/core/internal/nativeinstaller/bootstrap_recovery_test.go b/services/core/internal/nativeinstaller/bootstrap_recovery_test.go index 4e2bc23c6..04a82f2a8 100644 --- a/services/core/internal/nativeinstaller/bootstrap_recovery_test.go +++ b/services/core/internal/nativeinstaller/bootstrap_recovery_test.go @@ -16,6 +16,8 @@ import ( "sync/atomic" "testing" "time" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" ) func bootstrapFixtureArchive(t *testing.T) []byte { @@ -51,7 +53,7 @@ func bootstrapCommand(t *testing.T, base, home string) *exec.Cmd { } else { command = exec.Command("bash", "assets/bootstrap.sh", base) } - command.Env = append(os.Environ(), "OAC_RUNTIME_HOME="+home, GrantEnv+"=fixture-grant", "NO_PROXY=127.0.0.1", "no_proxy=127.0.0.1") + command.Env = append(os.Environ(), "OAC_RUNTIME_HOME="+home, v1.NativeInstallGrantEnv+"=fixture-grant", "NO_PROXY=127.0.0.1", "no_proxy=127.0.0.1") return command } diff --git a/services/core/internal/nativeinstaller/bootstrap_resume_unix_test.go b/services/core/internal/nativeinstaller/bootstrap_resume_unix_test.go index e5deefd7c..d529cbcb1 100644 --- a/services/core/internal/nativeinstaller/bootstrap_resume_unix_test.go +++ b/services/core/internal/nativeinstaller/bootstrap_resume_unix_test.go @@ -15,6 +15,8 @@ import ( "sync/atomic" "testing" "time" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" ) // resumeServer serves the installation endpoint and a ranged archive. @@ -60,7 +62,7 @@ func newResumeServer(t *testing.T, archive []byte) *resumeServer { func (s *resumeServer) command(t *testing.T, home string, args ...string) *exec.Cmd { command := exec.Command("bash", append([]string{"assets/bootstrap.sh", s.URL + "/api/v1/agent-daemon/install/test"}, args...)...) - command.Env = append(os.Environ(), "OAC_RUNTIME_HOME="+home, GrantEnv+"=fixture-grant", "NO_PROXY=127.0.0.1", "no_proxy=127.0.0.1") + command.Env = append(os.Environ(), "OAC_RUNTIME_HOME="+home, v1.NativeInstallGrantEnv+"=fixture-grant", "NO_PROXY=127.0.0.1", "no_proxy=127.0.0.1") return command } @@ -117,6 +119,46 @@ func TestBootstrapKeepsVerifiedArchiveUntilInstallCompletes(t *testing.T) { } } +// A rerun after an interrupt between the last byte and the rename finds a complete .part file; the server answers 416 and the checksum decides. +func TestBootstrapAcceptsCompletePartialDownload(t *testing.T) { + archive := bootstrapFixtureArchive(t) + server := newResumeServer(t, archive) + home := t.TempDir() + part := filepath.Join(home, "native-download", sha256Hex(archive)+".tar.gz.part") + if err := os.MkdirAll(filepath.Dir(part), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(part, archive, 0600); err != nil { + t.Fatal(err) + } + if output, err := server.command(t, home).CombinedOutput(); err != nil { + t.Fatalf("%v: %s", err, output) + } +} + +// Only the installer receives the grant, not curl, tar or the other tools around it. +func TestBootstrapGivesGrantOnlyToInstaller(t *testing.T) { + curl, err := exec.LookPath("curl") + if err != nil { + t.Skip("curl unavailable") + } + leak := filepath.Join(t.TempDir(), "leak") + bin := t.TempDir() + wrapper := "#!/bin/sh\n[ -z \"$OAC_INSTALL_GRANT\" ] || echo curl >> " + shellQuote(leak) + "\nexec " + shellQuote(curl) + " \"$@\"\n" + if err = os.WriteFile(filepath.Join(bin, "curl"), []byte(wrapper), 0700); err != nil { + t.Fatal(err) + } + server := newResumeServer(t, bootstrapScriptArchive(t, "#!/bin/sh\n[ \"$OAC_INSTALL_GRANT\" = fixture-grant ]\n")) + command := server.command(t, t.TempDir()) + command.Env = append(command.Env, "PATH="+bin+string(os.PathListSeparator)+os.Getenv("PATH")) + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("the installer did not receive the grant: %v: %s", err, output) + } + if _, err = os.Stat(leak); !os.IsNotExist(err) { + t.Fatal("a download tool received the grant") + } +} + func TestBootstrapDiscardsCachedArchiveThatFailsVerification(t *testing.T) { archive := bootstrapFixtureArchive(t) server := newResumeServer(t, archive) diff --git a/services/core/internal/nativeinstaller/bootstrap_test.go b/services/core/internal/nativeinstaller/bootstrap_test.go index 4eda8217f..38750346f 100644 --- a/services/core/internal/nativeinstaller/bootstrap_test.go +++ b/services/core/internal/nativeinstaller/bootstrap_test.go @@ -16,6 +16,8 @@ import ( "runtime" "strings" "testing" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" ) // Exercise the shipped script against two actual TLS origins. The downloaded @@ -87,7 +89,7 @@ func TestBootstrapDownloadsVerifiedPlatformAcrossHTTPSRedirect(t *testing.T) { defer core.Close() result := filepath.Join(t.TempDir(), "result") command := exec.Command("bash", "assets/bootstrap.sh", core.URL+"/api/v1/agent-daemon/install/build", "--harness", "codex") - command.Env = append(os.Environ(), "OAC_RUNTIME_HOME="+t.TempDir(), GrantEnv+"=private-grant", "CURL_CA_BUNDLE="+ca, "OAC_BOOTSTRAP_TEST_RESULT="+result, "NO_PROXY=127.0.0.1", "no_proxy=127.0.0.1") + command.Env = append(os.Environ(), "OAC_RUNTIME_HOME="+t.TempDir(), v1.NativeInstallGrantEnv+"=private-grant", "CURL_CA_BUNDLE="+ca, "OAC_BOOTSTRAP_TEST_RESULT="+result, "NO_PROXY=127.0.0.1", "no_proxy=127.0.0.1") output, err := command.CombinedOutput() if valid { if err != nil { diff --git a/services/core/internal/nativeinstaller/catalog.go b/services/core/internal/nativeinstaller/catalog.go index a83178fde..40f8d1a88 100644 --- a/services/core/internal/nativeinstaller/catalog.go +++ b/services/core/internal/nativeinstaller/catalog.go @@ -17,6 +17,7 @@ import ( "regexp" "strings" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) @@ -127,18 +128,16 @@ func (c *Catalog) ServeHTTP(w http.ResponseWriter, r *http.Request) { func shellQuote(s string) string { return "'" + strings.ReplaceAll(s, "'", "'\"'\"'") + "'" } func psQuote(s string) string { return "'" + strings.ReplaceAll(s, "'", "''") + "'" } -// GrantEnv names the environment variable that carries the installation grant to the installer. -const GrantEnv = "OAC_INSTALL_GRANT" - // Commands installs this catalog's version from the installer base URL. func (c *Catalog) Commands(installerBase, authorization string) map[string]string { base := installerBase + c.Version // Hold the small bootstrap in memory so an interrupted fetch leaves no file. // Only execute a complete successful response; preserve interactive stdin. - posix := `set -e; script=; for attempt in 1 2 3; do if script=$(curl -fsS --connect-timeout 15 --max-time 60 --max-filesize 1048576 ` + shellQuote(base+"/bootstrap.sh") + `); then break; fi; [ "$attempt" -lt 3 ] || exit 1; sleep "$attempt"; done; bash -c "$script" -- "$@"` + // The grant leaves the environment of the fetch and reaches only the bootstrap. + posix := `set -e; grant=$` + v1.NativeInstallGrantEnv + `; unset ` + v1.NativeInstallGrantEnv + `; script=; for attempt in 1 2 3; do if script=$(curl -fsS --connect-timeout 15 --max-time 60 --max-filesize 1048576 ` + shellQuote(base+"/bootstrap.sh") + `); then break; fi; [ "$attempt" -lt 3 ] || exit 1; sleep "$attempt"; done; ` + v1.NativeInstallGrantEnv + `=$grant bash -c "$script" -- "$@"` return map[string]string{ // The grant travels in the environment, never in an argument; the leading space keeps it out of shell history where the shell supports that. - "posix": " " + GrantEnv + "=" + shellQuote(authorization) + " bash -c " + shellQuote(posix) + " -- " + shellQuote(base), - "powershell": "& { $env:" + GrantEnv + "=" + psQuote(authorization) + "; try { $source=$null; for ($attempt=1; $attempt -le 3; $attempt++) { try { $source=(Invoke-WebRequest -UseBasicParsing " + psQuote(base+"/bootstrap.ps1") + " -TimeoutSec 60 -ErrorAction Stop).Content; break } catch { if ($attempt -eq 3) { throw }; Start-Sleep -Seconds $attempt } }; & ([scriptblock]::Create($source)) -Base " + psQuote(base) + " @args } finally { Remove-Item Env:" + GrantEnv + " -ErrorAction SilentlyContinue } }", + "posix": " " + v1.NativeInstallGrantEnv + "=" + shellQuote(authorization) + " bash -c " + shellQuote(posix) + " -- " + shellQuote(base), + "powershell": "& { $env:" + v1.NativeInstallGrantEnv + "=" + psQuote(authorization) + "; try { $source=$null; for ($attempt=1; $attempt -le 3; $attempt++) { try { $source=(Invoke-WebRequest -UseBasicParsing " + psQuote(base+"/bootstrap.ps1") + " -TimeoutSec 60 -ErrorAction Stop).Content; break } catch { if ($attempt -eq 3) { throw }; Start-Sleep -Seconds $attempt } }; & ([scriptblock]::Create($source)) -Base " + psQuote(base) + " @args } finally { Remove-Item Env:" + v1.NativeInstallGrantEnv + " -ErrorAction SilentlyContinue } }", } }