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
13 changes: 13 additions & 0 deletions apps/daemon/internal/cli/native_install.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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")
}
Expand All @@ -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")
Expand Down
37 changes: 24 additions & 13 deletions apps/daemon/internal/cli/native_install_input.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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")
Expand All @@ -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 <Runtime home>/environments/<environment-id>; 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")
Expand All @@ -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()
Expand All @@ -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 != "" {
Expand All @@ -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 <Runtime home>/environments/<environment-id>)", &o.Directory, true},
{"Distribution directory (Enter uses current default)", &o.Bundle, true},
{"Environment remote URL", &o.Remote, false},
{"Environment ID", &o.Environment, false},
Expand Down
26 changes: 26 additions & 0 deletions apps/daemon/internal/cli/native_install_io.go
Original file line number Diff line number Diff line change
Expand Up @@ -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]+$`),
Expand Down
9 changes: 5 additions & 4 deletions apps/daemon/internal/cli/native_install_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"strings"
"testing"

v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1"
"github.com/google/uuid"
)

Expand All @@ -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 {
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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")
}
}
Expand Down
10 changes: 3 additions & 7 deletions apps/daemon/internal/cli/native_onboarding.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading