Skip to content
Open
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 CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ Native adapter changes require their build/check targets and live provider accep
| Provider ownership labels | `io.oac.*` |
| E2B metadata | `oac_*` |

Provider bootstrap, Runtime images and Harness adapters must agree on these names. The separate Parsar product integration settings keep their own names.
Provider bootstrap, Runtime images and Harness adapters must agree on these names.

The [installation version policy](docs/getting-started/operations.md#installation-version-policy) owns release changes and preservation of installed data and resources.

Expand Down
4 changes: 2 additions & 2 deletions apps/daemon/cmd/oac-daemon/main.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// Command oac-daemon is the reverse-WebSocket worker that pairs a user
// machine with a OpenAgentCore server and exposes a local agent CLI
// Command oac-daemon is the reverse-WebSocket worker that connects a
// machine to an OpenAgentCore server and exposes a local agent CLI
// subprocess as a connector_type=agent_daemon target. See
// apps/daemon/README.md for the subcommand spec.
package main
Expand Down
66 changes: 8 additions & 58 deletions apps/daemon/internal/auth/store.go
Original file line number Diff line number Diff line change
@@ -1,31 +1,27 @@
// Package auth persists the credential bundle from
// /api/v1/runtimes/pair: server URL, runtime row id (= device_id), and
// the long-lived runner_credential. Stored as JSON per-profile at
// ~/.oac/daemon/<profile>/auth.json (0o600), written via
// atomic rename so a half-flushed pair never leaves the daemon paired
// with garbage state.
// Package auth reads the daemon credential profile written by
// oac-core-device: server URL, runtime row id (= device_id), and the
// long-lived runner_credential. Stored as JSON per-profile at
// ~/.oac/daemon/<profile>/auth.json (0o600).
package auth

import (
"encoding/json"
"errors"
"fmt"
"os"
"time"

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

// Profile is the on-disk representation of one paired credential.
// Profile is the on-disk representation of one daemon credential.
type Profile struct {
// ServerURL is the absolute base URL the daemon dials (no
// trailing slash). The daemon joins this with paths like
// /agent-daemon/bootstrap.
ServerURL string `json:"server_url"`

// RuntimeID is the runtimes row id minted at pair time. The
// gateway uses it verbatim as device_id on WS upgrade.
// RuntimeID is the runtimes row id. The gateway uses it verbatim
// as device_id on WS upgrade.
RuntimeID string `json:"runtime_id"`

// RunnerCredential is the bearer presented on every
Expand All @@ -35,57 +31,11 @@ type Profile struct {
RunnerCredential string `json:"runner_credential"`

DeviceName string `json:"device_name,omitempty"`

Hostname string `json:"hostname,omitempty"`

// PairedAt is when the credential was minted. `omitzero` because
// `omitempty` doesn't elide zero structs like time.Time.
PairedAt time.Time `json:"paired_at,omitzero"`

// RunnerPublicKey is the base64 X25519 public half generated at
// pair time. Server stores the matching value in
// runtimes.config.runner_public_key for SealAnonymous addressed
// to this daemon.
RunnerPublicKey string `json:"runner_public_key,omitempty"`

// RunnerPrivateKey is the base64 X25519 private half — used by
// runtimecrypto.OpenSeal to decrypt incoming sealed payloads.
// MUST NEVER appear in logs or leave the box.
RunnerPrivateKey string `json:"runner_private_key,omitempty"`
}

// ErrNotPaired is returned by Load when no auth.json exists for the
// requested profile.
var ErrNotPaired = errors.New("auth: not paired — use `oac-daemon connect --url ... --token ...`")

// Save writes p atomically to the profile's auth.json (0o600 even if
// the previous file was world-readable).
func Save(profile string, p Profile) error {
if profile == "" {
return fmt.Errorf("auth: profile name required")
}
dir, err := paths.EnsureProfileDir(profile)
if err != nil {
return err
}
raw, err := json.MarshalIndent(p, "", " ")
if err != nil {
return errors.New("auth: could not encode profile")
}
if err = runtimefs.EnsurePrivateDir(dir); err != nil {
return errors.New("auth: private profile unavailable")
}
held, err := os.OpenRoot(dir)
if err != nil {
return errors.New("auth: private profile unavailable")
}
defer held.Close()
if err = runtimefs.WritePrivateAtomic(held, "auth.json", raw); err != nil {
return errors.New("auth: private profile write failed")
}

return nil
}
var ErrNotPaired = errors.New("auth: no daemon credential profile — create one with oac-core-device")

// Load reads the profile's auth.json. Returns ErrNotPaired wrapping
// fs.ErrNotExist when the file is missing.
Expand Down
133 changes: 39 additions & 94 deletions apps/daemon/internal/auth/store_test.go
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
package auth_test

import (
"encoding/json"
"errors"
"io/fs"
"os"
"path/filepath"
"runtime"
"testing"
"time"

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

func withTempHome(t *testing.T) string {
Expand All @@ -20,105 +20,62 @@ func withTempHome(t *testing.T) string {
return dir
}

func TestSaveLoadRoundTrip(t *testing.T) {
_ = withTempHome(t)
now := time.Date(2026, 6, 4, 12, 0, 0, 0, time.UTC)
want := auth.Profile{
ServerURL: "https://core.example.com",
RuntimeID: "rt_abc123",
RunnerCredential: "secret-credential",
DeviceName: "alice-mac",
Hostname: "alice-mac.local",
PairedAt: now,
}
if err := auth.Save("test", want); err != nil {
t.Fatalf("Save: %v", err)
}
got, err := auth.Load("test")
func profileDir(t *testing.T, profile string) string {
t.Helper()
dir, err := paths.ProfileDir(profile)
if err != nil {
t.Fatalf("Load: %v", err)
t.Fatalf("ProfileDir: %v", err)
}
if got.ServerURL != want.ServerURL ||
got.RuntimeID != want.RuntimeID ||
got.RunnerCredential != want.RunnerCredential ||
got.DeviceName != want.DeviceName ||
got.Hostname != want.Hostname ||
!got.PairedAt.Equal(want.PairedAt) {
t.Fatalf("Load round-trip mismatch:\n got=%+v\nwant=%+v", got, want)
if err := runtimefs.EnsurePrivateDir(dir); err != nil {
t.Fatalf("EnsurePrivateDir: %v", err)
}
return dir
}

func TestSaveSetsRestrictivePerms(t *testing.T) {
_ = withTempHome(t)
if err := auth.Save("default", auth.Profile{ServerURL: "https://x", RuntimeID: "rt", RunnerCredential: "c"}); err != nil {
t.Fatalf("Save: %v", err)
}
authPath, err := paths.AuthFile("default")
if err != nil {
t.Fatalf("AuthFile: %v", err)
}
info, err := os.Stat(authPath)
func writeProfile(t *testing.T, profile string, p auth.Profile) {
t.Helper()
dir := profileDir(t, profile)
raw, err := json.Marshal(p)
if err != nil {
t.Fatalf("stat auth.json: %v", err)
t.Fatalf("marshal profile: %v", err)
}
// Unix stores credentials with 0600; Windows uses normal account ACLs,
// which are not represented by Go permission bits.
if mode := info.Mode().Perm(); runtime.GOOS != "windows" && mode != 0o600 {
t.Errorf("auth.json perm = %o, want 0600", mode)
if err := os.WriteFile(filepath.Join(dir, "auth.json"), raw, 0o600); err != nil {
t.Fatalf("write auth.json: %v", err)
}
}

func TestSaveIsAtomicNoStrayTempFile(t *testing.T) {
func TestLoadReadsProfile(t *testing.T) {
_ = withTempHome(t)
if err := auth.Save("default", auth.Profile{ServerURL: "https://x", RuntimeID: "rt", RunnerCredential: "c"}); err != nil {
t.Fatalf("Save: %v", err)
}
authPath, err := paths.AuthFile("default")
if err != nil {
t.Fatalf("AuthFile: %v", err)
want := auth.Profile{
ServerURL: "https://core.example.com",
RuntimeID: "rt_abc123",
RunnerCredential: "secret-credential",
DeviceName: "alice-mac",
}
// Writes to auth.json.tmp then renames — no stray .tmp on success.
entries, err := os.ReadDir(filepath.Dir(authPath))
writeProfile(t, "test", want)
got, err := auth.Load("test")
if err != nil {
t.Fatalf("ReadDir: %v", err)
t.Fatalf("Load: %v", err)
}
for _, e := range entries {
if filepath.Ext(e.Name()) == ".tmp" {
t.Fatalf("found stray temp file after Save: %s", e.Name())
}
if got != want {
t.Fatalf("Load mismatch:\n got=%+v\nwant=%+v", got, want)
}
}

func TestSaveOverwritesAndHealsPerms(t *testing.T) {
func TestLoadIgnoresLegacyProfileFields(t *testing.T) {
_ = withTempHome(t)
if err := auth.Save("default", auth.Profile{ServerURL: "https://x", RuntimeID: "rt1", RunnerCredential: "c1"}); err != nil {
t.Fatalf("Save first: %v", err)
}
authPath, err := paths.AuthFile("default")
if err != nil {
t.Fatalf("AuthFile: %v", err)
}
// Simulate a previously-world-readable file (user chmod'd it);
// atomic-rename Save must re-establish 0600 on the new inode.
if err := os.Chmod(authPath, 0o644); err != nil {
t.Fatalf("chmod loose perms: %v", err)
}
if err := auth.Save("default", auth.Profile{ServerURL: "https://x", RuntimeID: "rt2", RunnerCredential: "c2"}); err != nil {
t.Fatalf("Save second: %v", err)
}
info, err := os.Stat(authPath)
if err != nil {
t.Fatalf("stat: %v", err)
}
if mode := info.Mode().Perm(); runtime.GOOS != "windows" && mode != 0o600 {
t.Errorf("perm after re-save = %o, want 0600 (healing failed)", mode)
raw := `{"server_url":"https://core.example.com/api/v1","runtime_id":"rt","runner_credential":"c","device_name":"d",` +
`"hostname":"h","paired_at":"2026-06-04T12:00:00Z","runner_public_key":"pub","runner_private_key":"priv"}`
if err := os.WriteFile(filepath.Join(profileDir(t, "legacy"), "auth.json"), []byte(raw), 0o600); err != nil {
t.Fatalf("write auth.json: %v", err)
}
got, err := auth.Load("default")
got, err := auth.Load("legacy")
if err != nil {
t.Fatalf("Load: %v", err)
}
if got.RuntimeID != "rt2" || got.RunnerCredential != "c2" {
t.Errorf("overwrite did not take effect: %+v", got)
want := auth.Profile{ServerURL: "https://core.example.com/api/v1", RuntimeID: "rt", RunnerCredential: "c", DeviceName: "d"}
if got != want {
t.Fatalf("Load = %+v, want %+v", got, want)
}
}

Expand All @@ -137,14 +94,11 @@ func TestLoadMissingReturnsErrNotPaired(t *testing.T) {

func TestLoadCorruptJSONReturnsError(t *testing.T) {
_ = withTempHome(t)
dir, err := paths.EnsureProfileDir("default")
if err != nil {
t.Fatalf("EnsureProfileDir: %v", err)
}
dir := profileDir(t, "default")
if err := os.WriteFile(filepath.Join(dir, "auth.json"), []byte("{not valid json"), 0o600); err != nil {
t.Fatalf("seed corrupt file: %v", err)
}
_, err = auth.Load("default")
_, err := auth.Load("default")
if err == nil {
t.Fatal("Load returned nil error on corrupt JSON")
}
Expand All @@ -158,9 +112,7 @@ func TestDeleteIsIdempotent(t *testing.T) {
if err := auth.Delete("default"); err != nil {
t.Fatalf("Delete on missing profile returned %v, want nil (idempotent)", err)
}
if err := auth.Save("default", auth.Profile{ServerURL: "https://x", RuntimeID: "rt", RunnerCredential: "c"}); err != nil {
t.Fatalf("Save: %v", err)
}
writeProfile(t, "default", auth.Profile{ServerURL: "https://x", RuntimeID: "rt", RunnerCredential: "c"})
if err := auth.Delete("default"); err != nil {
t.Fatalf("Delete: %v", err)
}
Expand All @@ -172,10 +124,3 @@ func TestDeleteIsIdempotent(t *testing.T) {
t.Fatalf("Delete second call = %v, want nil", err)
}
}

func TestSaveRejectsEmptyProfile(t *testing.T) {
_ = withTempHome(t)
if err := auth.Save("", auth.Profile{ServerURL: "https://x", RuntimeID: "rt", RunnerCredential: "c"}); err == nil {
t.Fatal("Save with empty profile should error")
}
}
2 changes: 1 addition & 1 deletion apps/daemon/internal/cli/agent_registration.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ func registerAgentKinds(registry *agent.Registry, discovery agentCLIDiscovery, s
for _, discovered := range discovery {
runtime := discovered.runtime
if runtime.SessionCapabilityContext {
runtime.Session = withSkillUploadServer(withCapabilityDownloads(runtime.Session, serverURL), serverURL)
runtime.Session = withCapabilityDownloads(runtime.Session, serverURL)
}
if runtime.Executor != nil && runtime.ExecutorCapabilityContext {
runtime.Executor = withExecutorCapabilities(runtime.Executor, serverURL)
Expand Down
5 changes: 0 additions & 5 deletions apps/daemon/internal/cli/authoring.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,6 @@ package cli
import (
"context"
"maps"
"os"
"path/filepath"

"github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent"
"github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/authoring"
Expand All @@ -30,9 +28,6 @@ func withAuthoringBridge(factory agent.Factory, bridge *authoring.Bridge) agent.
env = make(map[string]any)
}
env[proto.AuthoringSocketEnv] = path
if executable, err := os.Executable(); err == nil {
addCompanionCLIPath(env, filepath.Dir(executable))
}
req.AgentOptions["env"] = env
upstream := make(chan proto.Envelope, 64)
session, err := factory(ctx, req, upstream)
Expand Down
3 changes: 1 addition & 2 deletions apps/daemon/internal/cli/capability_downloads.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import (
"github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto"
)

// Use the paired server address for loopback PG downloads: inside Compose,
// Use the connected server address for loopback PG downloads: inside Compose,
// the public loopback address points to the runtime container itself.
func withCapabilityDownloads(factory agent.Factory, serverURL string) agent.Factory {
base, err := url.Parse(serverURL)
Expand Down Expand Up @@ -79,7 +79,6 @@ func withExecutorCapabilities(factory agent.ExecutorFactory, serverURL string) a
if valid {
req = capabilityDownloadRequest(req, base)
}
req = skillUploadRequest(req, serverURL)
return factory(ctx, req)
}
}
37 changes: 0 additions & 37 deletions apps/daemon/internal/cli/companion_path_test.go

This file was deleted.

Loading
Loading