From be942983cbebc3cb71acc5fc42c5c2e8e2adbb5a Mon Sep 17 00:00:00 2001 From: yuanhe Date: Thu, 1 Oct 2026 12:17:08 +0800 Subject: [PATCH 1/2] Remove the unserved daemon pairing path and runtimecrypto `oac-daemon connect --url --token` posted to /api/v1/runtimes/pair, which Core does not serve and no contract documents, so the path always failed. Its envelope encryption was never wired: Core never stored or used runner_public_key, and SealForRuntime had no caller outside its fixture generator. Daemon credentials come from a Provider bootstrap file, self-hosted Environment enrollment or the oac-core-device profile, all unchanged. - Delete pair.go, the inline pairing flags, their environment handoff and their tests. - Delete internal/runtimecrypto; golang.org/x/crypto is no longer required. - auth: drop the pairing-only Profile fields and Save, which no longer has a production caller; the not-found error points to oac-core-device. - daemonize: drop ReExecOptions.ExtraEnv, which only carried the pairing token to the background child. - paths: drop EnsureProfileDir, whose only caller was auth.Save. - Reword comments and docs that described pairing. --- apps/daemon/cmd/oac-daemon/main.go | 4 +- apps/daemon/internal/auth/store.go | 66 +------- apps/daemon/internal/auth/store_test.go | 133 +++++----------- .../internal/cli/capability_downloads.go | 2 +- apps/daemon/internal/cli/connect.go | 146 +++--------------- apps/daemon/internal/cli/connect_bootstrap.go | 2 +- .../internal/cli/connect_bootstrap_test.go | 18 ++- .../internal/cli/connect_environment.go | 2 +- .../internal/cli/connect_environment_test.go | 11 -- apps/daemon/internal/cli/connect_test.go | 60 ------- .../internal/cli/native_install_test.go | 3 +- apps/daemon/internal/cli/pair.go | 145 ----------------- apps/daemon/internal/cli/pair_test.go | 95 ------------ apps/daemon/internal/cli/root.go | 4 +- apps/daemon/internal/cli/status.go | 8 +- apps/daemon/internal/daemonize/fork.go | 5 - apps/daemon/internal/daemonize/fork_test.go | 6 +- .../daemon/internal/daemonize/pidfile_test.go | 3 +- apps/daemon/internal/paths/paths.go | 19 +-- apps/daemon/internal/paths/paths_test.go | 31 ---- apps/daemon/internal/transport/ws.go | 2 +- docs/runtime-bootstrap.md | 2 +- go.mod | 1 - go.sum | 2 - .../runtimecrypto/cmd/emit-fixture/main.go | 76 --------- internal/runtimecrypto/runtime_seal.go | 110 ------------- internal/runtimecrypto/runtime_seal_test.go | 135 ---------------- .../runtimecrypto/runtime_seal_wire_test.go | 56 ------- internal/runtimecrypto/testdata/wire_v1.json | 8 - packages/claude-sdk-adapter/README.md | 4 +- services/core/internal/runtimegateway/auth.go | 2 +- .../core/internal/runtimegateway/handler.go | 10 +- .../core/internal/runtimegateway/routes.go | 4 +- 33 files changed, 112 insertions(+), 1063 deletions(-) delete mode 100644 apps/daemon/internal/cli/connect_test.go delete mode 100644 apps/daemon/internal/cli/pair.go delete mode 100644 apps/daemon/internal/cli/pair_test.go delete mode 100644 internal/runtimecrypto/cmd/emit-fixture/main.go delete mode 100644 internal/runtimecrypto/runtime_seal.go delete mode 100644 internal/runtimecrypto/runtime_seal_test.go delete mode 100644 internal/runtimecrypto/runtime_seal_wire_test.go delete mode 100644 internal/runtimecrypto/testdata/wire_v1.json diff --git a/apps/daemon/cmd/oac-daemon/main.go b/apps/daemon/cmd/oac-daemon/main.go index 368943517..464f72f9d 100644 --- a/apps/daemon/cmd/oac-daemon/main.go +++ b/apps/daemon/cmd/oac-daemon/main.go @@ -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 diff --git a/apps/daemon/internal/auth/store.go b/apps/daemon/internal/auth/store.go index 3a69123e8..ec636f67e 100644 --- a/apps/daemon/internal/auth/store.go +++ b/apps/daemon/internal/auth/store.go @@ -1,9 +1,7 @@ -// 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//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//auth.json (0o600). package auth import ( @@ -11,21 +9,19 @@ import ( "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 @@ -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. diff --git a/apps/daemon/internal/auth/store_test.go b/apps/daemon/internal/auth/store_test.go index 1d76ad1b7..1d3bfffcc 100644 --- a/apps/daemon/internal/auth/store_test.go +++ b/apps/daemon/internal/auth/store_test.go @@ -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 { @@ -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) } } @@ -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") } @@ -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) } @@ -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") - } -} diff --git a/apps/daemon/internal/cli/capability_downloads.go b/apps/daemon/internal/cli/capability_downloads.go index c6def5f15..cb471eec1 100644 --- a/apps/daemon/internal/cli/capability_downloads.go +++ b/apps/daemon/internal/cli/capability_downloads.go @@ -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) diff --git a/apps/daemon/internal/cli/connect.go b/apps/daemon/internal/cli/connect.go index 7fb6e82f8..e6b5809a8 100644 --- a/apps/daemon/internal/cli/connect.go +++ b/apps/daemon/internal/cli/connect.go @@ -7,7 +7,6 @@ import ( "log/slog" "os" "path/filepath" - "strings" "time" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" @@ -28,36 +27,25 @@ const ( // Allow the native process grace period and subsequent owner/pipe cleanup. stopTimeout = 10 * time.Second - - connectInlineURLEnv = "OAC_RUNTIME_DAEMON_CONNECT_URL" - connectInlineTokenEnv = "OAC_RUNTIME_DAEMON_CONNECT_TOKEN" - connectInlineDeviceNameEnv = "OAC_RUNTIME_DAEMON_CONNECT_DEVICE_NAME" ) // runConnect dials /agent-daemon/bootstrap, opens /agent-daemon/ws, // wires the dispatch router, and routes Envelope traffic both ways // until either SIGINT/SIGTERM or a permanent credential rejection. // -// `connect --url --token` folds one-shot pairing into the connect step: -// the daemon consumes the pairing token, persists the returned runner -// credential to auth.json, and connects. Subsequent `connect -b` -// invocations reload the persisted profile. +// The daemon credential comes from a Provider bootstrap file +// (--bootstrap-file), self-hosted Environment enrollment +// (--remote/--environment-id/--credential-file) or the saved profile +// written by oac-core-device. // // -b re-execs the binary in the background with stdio redirected to // connect.log and the child PID written to connect.pid. The child -// re-enters runConnect via BackgroundSentinelEnv. When --token is -// supplied, the parent forks before pairing so the one-shot token is -// consumed by the long-lived child. Inline pairing flags are scrubbed -// from child argv and passed via environment to keep the token out of -// process listings. +// re-enters runConnect via BackgroundSentinelEnv. func runConnect(ctx *runContext, args []string) error { fs := newFlagSet("connect") var ( - profile = fs.String("profile", paths.DefaultProfile, "profile name for paired credentials and pid/log files") + profile = fs.String("profile", paths.DefaultProfile, "profile name for daemon credentials and pid/log files") background = fs.Bool("b", false, "fork into the background; writes connect.pid + connect.log") - serverURL = fs.String("url", "", "Core server base URL; with --token, pair inline before connecting") - token = fs.String("token", "", "pairing token; with --url, connect consumes it without writing auth.json") - deviceName = fs.String("device-name", "", "human label for inline pairing (defaults to hostname)") remote = fs.String("remote", "", "self-hosted Environment remote_url, unchanged") environment = fs.String("environment-id", "", "self-hosted Environment ID") bootstrapFile = fs.String("bootstrap-file", "", "absolute path to Provider-to-Runtime connection JSON") @@ -75,21 +63,13 @@ func runConnect(ctx *runContext, args []string) error { } else if !errors.Is(err, os.ErrNotExist) { return errors.New("connect: cannot inspect native installation; use oac-daemon start") } - // Hydrate inline pairing inputs from env in BOTH parent and the - // re-execed background child. Server-spawned sandboxes pass the - // token via OAC_RUNTIME_DAEMON_CONNECT_TOKEN/URL env rather than --url - // /--token flags; without this hydration before the pre-fork - // auth.json check below, the parent would take the "rely on - // auth.json" branch and bail with "not paired". Idempotent — - // fills only empty flags and unsets the env after consuming. - loadInlineConnectEnv(serverURL, token, deviceName) if err := paths.ValidateProfile(*profile); err != nil { return fmt.Errorf("connect: %w", err) } var bootstrapped *auth.Profile if *bootstrapFile != "" { - if *serverURL != "" || *token != "" || *deviceName != "" || *remote != "" || *environment != "" || *credentialFile != "" || fs.NArg() != 0 { - return errors.New("connect: bootstrap input cannot be combined with enrollment or pairing options") + if *remote != "" || *environment != "" || *credentialFile != "" || fs.NArg() != 0 { + return errors.New("connect: bootstrap input cannot be combined with enrollment options") } bootstrapped, err = bootstrapProfile(*bootstrapFile) if err != nil { @@ -97,47 +77,30 @@ func runConnect(ctx *runContext, args []string) error { } } if *remote != "" || *environment != "" || *credentialFile != "" { - if *serverURL != "" || *token != "" || *deviceName != "" || fs.NArg() != 0 { - return errors.New("connect: Environment enrollment cannot use pairing options or positional arguments") + if fs.NArg() != 0 { + return errors.New("connect: Environment enrollment cannot use positional arguments") } connectCtx, stop := daemonize.NotifyContext(context.Background()) defer stop() return runEnvironmentConnect(connectCtx, ctx, *profile, *background, *remote, *environment, *credentialFile) } - inlinePair := strings.TrimSpace(*serverURL) != "" || strings.TrimSpace(*token) != "" - if inlinePair { - if strings.TrimSpace(*serverURL) == "" { - return fmt.Errorf("connect: --url is required when --token is supplied") - } - if strings.TrimSpace(*token) == "" { - return fmt.Errorf("connect: --token is required when --url is supplied") - } - } - // -b mode: parent forks, child re-enters with sentinel env set - // and skips this branch. Fork before inline pairing so the - // one-shot token is consumed by the child that owns the WS loop. + // and skips this branch. if *background && !daemonize.IsBackgroundChild() { // Validate auth.json exists before forking so the error // surfaces in the user's terminal instead of the background // child's log. - if !inlinePair && bootstrapped == nil { + if bootstrapped == nil { if _, err := auth.Load(*profile); err != nil { return fmt.Errorf("connect: %w", err) } } - argv := os.Args - extraEnv := []string(nil) - if inlinePair { - argv = scrubInlineConnectArgs(os.Args) - extraEnv = inlineConnectEnv(*serverURL, *token, *deviceName) - } - return spawnBackground(context.Background(), ctx, *profile, argv, extraEnv) + return spawnBackground(context.Background(), ctx, *profile, os.Args) } - // Self-check before pairing/loading credentials so a machine with - // no supported agent CLI fails before consuming a one-shot token. + // Self-check before loading credentials so a machine with no + // supported agent CLI fails fast. agentCLIs, err := preflightAgentCLIs(context.Background(), ctx, *profile) if err != nil { return err @@ -147,84 +110,20 @@ func runConnect(ctx *runContext, args []string) error { if bootstrapped != nil { prof = *bootstrapped } else { - prof, err = resolveConnectProfile(*profile, *serverURL, *token, *deviceName) + prof, err = auth.Load(*profile) if err != nil { - return err + return fmt.Errorf("connect: %w", err) } } return mainLoop(ctx, *profile, prof, agentCLIs) } -func loadInlineConnectEnv(serverURL, token, deviceName *string) { - if strings.TrimSpace(*serverURL) == "" { - *serverURL = os.Getenv(connectInlineURLEnv) - } - if strings.TrimSpace(*token) == "" { - *token = os.Getenv(connectInlineTokenEnv) - } - if strings.TrimSpace(*deviceName) == "" { - *deviceName = os.Getenv(connectInlineDeviceNameEnv) - } - _ = os.Unsetenv(connectInlineURLEnv) - _ = os.Unsetenv(connectInlineTokenEnv) - _ = os.Unsetenv(connectInlineDeviceNameEnv) -} - -func inlineConnectEnv(serverURL, token, deviceName string) []string { - out := []string{ - connectInlineURLEnv + "=" + serverURL, - connectInlineTokenEnv + "=" + token, - } - if strings.TrimSpace(deviceName) != "" { - out = append(out, connectInlineDeviceNameEnv+"="+deviceName) - } - return out -} - -func scrubInlineConnectArgs(argv []string) []string { - out := make([]string, 0, len(argv)) - for i := 0; i < len(argv); i++ { - arg := argv[i] - switch { - case arg == "--url" || arg == "--token" || arg == "--device-name": - i++ - continue - case strings.HasPrefix(arg, "--url=") || strings.HasPrefix(arg, "--token=") || strings.HasPrefix(arg, "--device-name="): - continue - default: - out = append(out, arg) - } - } - return out -} - -func resolveConnectProfile(profile, serverURL, token, deviceName string) (auth.Profile, error) { - if strings.TrimSpace(serverURL) == "" && strings.TrimSpace(token) == "" { - prof, err := auth.Load(profile) - if err != nil { - return auth.Profile{}, fmt.Errorf("connect: %w", err) - } - return prof, nil - } - - pairCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - prof, _, err := pairProfile(pairCtx, serverURL, token, deviceName) - if err != nil { - return auth.Profile{}, fmt.Errorf("connect: pair with server: %w", err) - } - if err := auth.Save(profile, prof); err != nil { - return auth.Profile{}, fmt.Errorf("connect: save auth profile: %w", err) - } - return prof, nil -} - // spawnBackground forks the daemon into the background. Parent // returns after printing the child PID; child re-enters runConnect // with BackgroundSentinelEnv set so the same mainLoop runs in either // mode. -func spawnBackground(ctx context.Context, rc *runContext, profile string, argv []string, extraEnv []string) error { +func spawnBackground(ctx context.Context, rc *runContext, profile string, argv []string) error { logPath, err := paths.LogFile(profile) if err != nil { return fmt.Errorf("connect: %w", err) @@ -264,9 +163,8 @@ func spawnBackground(ctx context.Context, rc *runContext, profile string, argv [ return err } pid, err := daemonize.Spawn(argv, daemonize.ReExecOptions{ - LogPath: logPath, - PIDPath: pidPath, - ExtraEnv: extraEnv, + LogPath: logPath, + PIDPath: pidPath, }) if err != nil { return fmt.Errorf("connect: spawn background: %w", err) @@ -385,7 +283,7 @@ func mainLoopRemote(parent context.Context, rc *runContext, profile string, prof return nil } if errors.Is(err, transport.ErrPermanent) { - return fmt.Errorf("connect: permanent error (re-pair the daemon): %w", err) + return fmt.Errorf("connect: permanent error (reissue the daemon credential): %w", err) } return fmt.Errorf("connect: dial: %w", err) } @@ -410,7 +308,7 @@ func mainLoopRemote(parent context.Context, rc *runContext, profile string, prof // Permanent error (e.g. runtime deleted) → exit instead of // reconnecting. if pumpErr != nil && errors.Is(pumpErr, transport.ErrPermanent) { - return fmt.Errorf("connect: runtime deleted (re-pair the daemon): %w", pumpErr) + return fmt.Errorf("connect: runtime deleted (reissue the daemon credential): %w", pumpErr) } // Small breather before redialing so a flapping server doesn't // get a tight loop of upgrade requests. diff --git a/apps/daemon/internal/cli/connect_bootstrap.go b/apps/daemon/internal/cli/connect_bootstrap.go index 4092479dc..1e7848808 100644 --- a/apps/daemon/internal/cli/connect_bootstrap.go +++ b/apps/daemon/internal/cli/connect_bootstrap.go @@ -8,7 +8,7 @@ import ( ) // The launch file is the sole credential source for this connection. Reopening -// it on process restart neither pairs again nor overwrites an auth profile. +// it on process restart never reads or overwrites an auth profile. func bootstrapProfile(path string) (*auth.Profile, error) { raw, err := runtimefs.ReadPrivatePath(path, runtimebootstrap.MaxBytes) if err != nil { diff --git a/apps/daemon/internal/cli/connect_bootstrap_test.go b/apps/daemon/internal/cli/connect_bootstrap_test.go index c3fc68a4d..7f510d699 100644 --- a/apps/daemon/internal/cli/connect_bootstrap_test.go +++ b/apps/daemon/internal/cli/connect_bootstrap_test.go @@ -1,6 +1,7 @@ package cli import ( + "encoding/json" "io" "os" "path/filepath" @@ -10,12 +11,24 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/auth" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/paths" "github.com/MiniMax-AI/OpenAgentCore/internal/runtimebootstrap" + "github.com/MiniMax-AI/OpenAgentCore/internal/runtimefs" ) func TestBootstrapConnectionDoesNotReadOrOverwritePrivateProfile(t *testing.T) { t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) prior := auth.Profile{ServerURL: "https://other.example/api/v1", RuntimeID: "retained", RunnerCredential: "retained-secret"} - if err := auth.Save("default", prior); err != nil { + profileDir, err := paths.ProfileDir("default") + if err != nil { + t.Fatal(err) + } + if err = runtimefs.EnsurePrivateDir(profileDir); err != nil { + t.Fatal(err) + } + priorRaw, err := json.Marshal(prior) + if err != nil { + t.Fatal(err) + } + if err = os.WriteFile(filepath.Join(profileDir, "auth.json"), priorRaw, 0600); err != nil { t.Fatal(err) } input := runtimebootstrap.Connection{Version: runtimebootstrap.Version, CoreURL: "https://core.example/api/v1", DeviceID: "da912024-1543-4242-a2c1-5f4f7ebbc6c7", Credential: "bootstrap-secret"} @@ -55,13 +68,12 @@ func TestConnectBootstrapRejectsOtherCredentialSourcesBeforeSideEffects(t *testi t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) ctx := &runContext{stdin: strings.NewReader(""), stdout: io.Discard, stderr: io.Discard} for _, args := range [][]string{ - {"--url", "https://core.example", "--token", "private-token"}, {"--remote", "wss://core.example/api/v1/agent-daemon/ws"}, {"--credential-file", "/credential.json"}, {"--environment-id", "foreign"}, {"unexpected"}, } { err := runConnect(ctx, append([]string{"--bootstrap-file", "/not-present"}, args...)) - if err == nil || !strings.Contains(err.Error(), "cannot be combined") || strings.Contains(err.Error(), "private-token") { + if err == nil || !strings.Contains(err.Error(), "cannot be combined") { t.Fatal("mixed startup source accepted", err) } } diff --git a/apps/daemon/internal/cli/connect_environment.go b/apps/daemon/internal/cli/connect_environment.go index de92018ee..2a20ae9f0 100644 --- a/apps/daemon/internal/cli/connect_environment.go +++ b/apps/daemon/internal/cli/connect_environment.go @@ -166,7 +166,7 @@ func runEnvironmentConnect(parent context.Context, rc *runContext, profile strin return err } if background && !daemonize.IsBackgroundChild() { - return spawnBackground(parent, rc, profile, os.Args, nil) + return spawnBackground(parent, rc, profile, os.Args) } // Discovery consumes the immutable Runtime binding; it must follow enrollment. discovery, err := preflightAgentCLIs(parent, rc, profile) diff --git a/apps/daemon/internal/cli/connect_environment_test.go b/apps/daemon/internal/cli/connect_environment_test.go index 9bea081d2..edcb3a0a1 100644 --- a/apps/daemon/internal/cli/connect_environment_test.go +++ b/apps/daemon/internal/cli/connect_environment_test.go @@ -243,17 +243,6 @@ func TestEnvironmentEnrollmentRejectsWrongIdentityAndWorkspace(t *testing.T) { } } -func TestEnvironmentConnectRejectsPairing(t *testing.T) { - t.Setenv(connectInlineURLEnv, "") - t.Setenv(connectInlineTokenEnv, "") - t.Setenv(connectInlineDeviceNameEnv, "") - rc := &runContext{stdout: &strings.Builder{}, stderr: &strings.Builder{}} - err := runConnect(rc, []string{"--remote", "wss://core/api/v1/agent-daemon/ws", "--environment-id", uuid.NewString(), "--credential-file", "/unused", "--token", "private-pairing-canary"}) - if err == nil || strings.Contains(err.Error(), "private-pairing-canary") { - t.Fatal("pairing accepted or leaked") - } -} - func TestEnvironmentEnrollmentAcceptsPhysicalWorkspaceSelection(t *testing.T) { environment := uuid.NewString() want := environmentEnrollment{uuid.NewString(), uuid.NewString(), environment, "/srv/runtime/workspace"} diff --git a/apps/daemon/internal/cli/connect_test.go b/apps/daemon/internal/cli/connect_test.go deleted file mode 100644 index 8a5daeb03..000000000 --- a/apps/daemon/internal/cli/connect_test.go +++ /dev/null @@ -1,60 +0,0 @@ -package cli - -import ( - "os" - "reflect" - "strings" - "testing" -) - -func TestScrubInlineConnectArgsRemovesTokenURLAndDeviceName(t *testing.T) { - got := scrubInlineConnectArgs([]string{ - "oac-daemon", "connect", - "--url", "https://core.example.com", - "--token=rtk_secret", - "--device-name", "dev-1", - "-b", - "--profile", "sandbox", - }) - want := []string{"oac-daemon", "connect", "-b", "--profile", "sandbox"} - if !reflect.DeepEqual(got, want) { - t.Fatalf("scrubInlineConnectArgs() = %#v, want %#v", got, want) - } -} - -func TestLoadInlineConnectEnvFillsMissingValuesAndUnsets(t *testing.T) { - t.Setenv(connectInlineURLEnv, "https://core.example.com") - t.Setenv(connectInlineTokenEnv, "rtk_secret") - t.Setenv(connectInlineDeviceNameEnv, "dev-1") - - serverURL, token, deviceName := "", "", "" - loadInlineConnectEnv(&serverURL, &token, &deviceName) - - if serverURL != "https://core.example.com" || token != "rtk_secret" || deviceName != "dev-1" { - t.Fatalf("loaded values = (%q, %q, %q)", serverURL, token, deviceName) - } - if got := inlineConnectEnvValue(connectInlineTokenEnv); got != "" { - t.Fatalf("%s still set to %q", connectInlineTokenEnv, got) - } -} - -func inlineConnectEnvValue(key string) string { return os.Getenv(key) } - -// Regression: pre-fork auth.json check used to run BEFORE env-to-flag -// hydration, so sandboxes passing the token via env bailed with -// "not paired". loadInlineConnectEnv now runs first. -func TestLoadInlineConnectEnvHydratesParentProcessFlags(t *testing.T) { - t.Setenv(connectInlineURLEnv, "https://core.example.com") - t.Setenv(connectInlineTokenEnv, "rtk_secret") - - serverURL, token, deviceName := "", "", "" - - loadInlineConnectEnv(&serverURL, &token, &deviceName) - - // Same predicate runConnect uses to decide whether to skip the - // pre-fork auth.json check. - inlinePair := strings.TrimSpace(serverURL) != "" || strings.TrimSpace(token) != "" - if !inlinePair { - t.Fatalf("inlinePair=false after env hydration; serverURL=%q token=%q", serverURL, token) - } -} diff --git a/apps/daemon/internal/cli/native_install_test.go b/apps/daemon/internal/cli/native_install_test.go index 0deb2f5b2..2a8453d1f 100644 --- a/apps/daemon/internal/cli/native_install_test.go +++ b/apps/daemon/internal/cli/native_install_test.go @@ -257,10 +257,9 @@ func TestNativeInstallationConnectCannotBypassValidation(t *testing.T) { for _, connection := range [][]string{ nil, {"--remote", "ws://127.0.0.1:1/api/v1/agent-daemon/ws"}, - {"--url", "http://127.0.0.1:1", "--token", "private-test-token"}, } { err := runConnect(rc, connection) - if err == nil || !strings.Contains(err.Error(), "use oac-daemon start") || strings.Contains(err.Error(), "private-test-token") { + if err == nil || !strings.Contains(err.Error(), "use oac-daemon start") { t.Fatal("connect bypassed native installation validation", err) } } diff --git a/apps/daemon/internal/cli/pair.go b/apps/daemon/internal/cli/pair.go deleted file mode 100644 index c5b65cee4..000000000 --- a/apps/daemon/internal/cli/pair.go +++ /dev/null @@ -1,145 +0,0 @@ -package cli - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "os" - "strings" - "time" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/auth" - "github.com/MiniMax-AI/OpenAgentCore/internal/runtimecrypto" -) - -func pairProfile(ctx context.Context, serverURL, token, deviceName string) (auth.Profile, *pairResponse, error) { - base, err := normalizeServerURL(serverURL) - if err != nil { - return auth.Profile{}, nil, err - } - - host, err := os.Hostname() - if err != nil { - // Stripped-down sandboxes can fail os.Hostname; "unknown" - // keeps the request well-formed (it's only a label). - host = "unknown" - } - if strings.TrimSpace(deviceName) == "" { - deviceName = host - } - - // Generate a fresh X25519 keypair before pairing. Public half is - // persisted server-side under runtimes.config.runner_public_key so - // SealForRuntime can encrypt payloads to this daemon; private half - // stays in auth.Profile (0o600) and is required to OpenSeal on - // receive. Every successful pair binds a brand-new pair. - pubB64, privB64, err := runtimecrypto.GenerateRuntimeKeypair() - if err != nil { - return auth.Profile{}, nil, fmt.Errorf("generate runner keypair: %w", err) - } - - pair, err := pairWithServer(ctx, base, pairRequest{ - PairingToken: token, - Hostname: host, - Version: Version, - RunnerPublicKey: pubB64, - }) - if err != nil { - return auth.Profile{}, nil, err - } - - prof := auth.Profile{ - ServerURL: base, - RuntimeID: pair.Runtime.ID, - RunnerCredential: pair.RunnerCredential, - DeviceName: deviceName, - Hostname: host, - PairedAt: time.Now().UTC(), - RunnerPublicKey: pubB64, - RunnerPrivateKey: privB64, - } - return prof, pair, nil -} - -// pairRequest mirrors the wire shape of server/internal/api/runtime. -// Re-declared here so the daemon doesn't import a server-internal -// package — keeps the wire schema as the only coupling. -type pairRequest struct { - PairingToken string `json:"pairing_token"` - Hostname string `json:"hostname"` - Version string `json:"version"` - RunnerPublicKey string `json:"runner_public_key,omitempty"` -} - -type pairRuntime struct { - ID string `json:"id"` - Type string `json:"type"` - Name string `json:"name"` - Liveness string `json:"liveness"` -} - -type pairResponse struct { - Runtime pairRuntime `json:"runtime"` - RunnerCredential string `json:"runner_credential"` -} - -// pairWithServer issues POST /api/v1/runtimes/pair. Returns the parsed -// response on success; on non-2xx, returns an error containing the -// server's error code + message when present. -func pairWithServer(ctx context.Context, base string, req pairRequest) (*pairResponse, error) { - body, err := json.Marshal(req) - if err != nil { - return nil, fmt.Errorf("marshal pair request: %w", err) - } - httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, base+"/api/v1/runtimes/pair", bytes.NewReader(body)) - if err != nil { - return nil, fmt.Errorf("build request: %w", err) - } - httpReq.Header.Set("Content-Type", "application/json") - httpReq.Header.Set("User-Agent", "oac-daemon/"+Version) - resp, err := http.DefaultClient.Do(httpReq) - if err != nil { - return nil, fmt.Errorf("post: %w", err) - } - defer resp.Body.Close() - respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) - if resp.StatusCode/100 != 2 { - return nil, fmt.Errorf("server returned %s: %s", resp.Status, strings.TrimSpace(string(respBody))) - } - var out pairResponse - if err := json.Unmarshal(respBody, &out); err != nil { - return nil, fmt.Errorf("decode pair response: %w", err) - } - if out.Runtime.ID == "" || out.RunnerCredential == "" { - return nil, fmt.Errorf("pair response missing runtime.id or runner_credential") - } - if out.Runtime.Type != "" && out.Runtime.Type != "agent_daemon" { - // Refuse rather than take over the wrong runtime row if the - // user pasted a non-agent_daemon pairing token by accident. - return nil, fmt.Errorf("pair response runtime.type=%q, expected agent_daemon (was the token issued under the Agent Daemon tab?)", out.Runtime.Type) - } - return &out, nil -} - -// normalizeServerURL rejects junk inputs and strips a trailing slash so -// concatenating "/api/v1/..." paths never produces "//". -func normalizeServerURL(raw string) (string, error) { - u, err := url.Parse(strings.TrimSpace(raw)) - if err != nil { - return "", fmt.Errorf("parse --url: %w", err) - } - if u.Scheme != "http" && u.Scheme != "https" { - return "", fmt.Errorf("--url must use http or https (got %q)", u.Scheme) - } - if u.Host == "" { - return "", fmt.Errorf("--url is missing a host") - } - u.Path = strings.TrimRight(u.Path, "/") - u.RawQuery = "" - u.Fragment = "" - return u.String(), nil -} diff --git a/apps/daemon/internal/cli/pair_test.go b/apps/daemon/internal/cli/pair_test.go deleted file mode 100644 index 620b5ed67..000000000 --- a/apps/daemon/internal/cli/pair_test.go +++ /dev/null @@ -1,95 +0,0 @@ -package cli - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - "time" - - "github.com/MiniMax-AI/OpenAgentCore/internal/runtimecrypto" -) - -// Regression for the "runner_public_key required" pair failure: the -// daemon used to never populate that field on the wire, so server-side -// pairing rejected with HTTP 400. Asserts the request carries a -// base64(stdEncoding) X25519 pubkey AND the resulting Profile retains -// the matching privkey for later OpenSeal. -func TestPairProfileGeneratesAndSendsRunnerPublicKey(t *testing.T) { - var sentPubKey string - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/api/v1/runtimes/pair" { - http.Error(w, "unexpected path", http.StatusNotFound) - return - } - var body pairRequest - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - http.Error(w, "bad json", http.StatusBadRequest) - return - } - sentPubKey = body.RunnerPublicKey - _ = json.NewEncoder(w).Encode(pairResponse{ - Runtime: pairRuntime{ - ID: "rt_test_123", - Type: "agent_daemon", - Name: "test-device", - }, - RunnerCredential: "rc_test_secret", - }) - })) - defer srv.Close() - - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - prof, _, err := pairProfile(ctx, srv.URL, "pairing-token", "test-device") - if err != nil { - t.Fatalf("pairProfile: %v", err) - } - - // Server must have received a non-empty key. - if sentPubKey == "" { - t.Fatal("server did not receive runner_public_key on the wire") - } - // And it must be a valid X25519 pubkey. - if _, err := runtimecrypto.DecodeKey(sentPubKey); err != nil { - t.Fatalf("server received invalid pubkey %q: %v", sentPubKey, err) - } - - // Profile must carry both halves. - if prof.RunnerPublicKey != sentPubKey { - t.Errorf("Profile.RunnerPublicKey = %q, want sent %q", prof.RunnerPublicKey, sentPubKey) - } - if prof.RunnerPrivateKey == "" { - t.Fatal("Profile.RunnerPrivateKey is empty") - } - if _, err := runtimecrypto.DecodeKey(prof.RunnerPrivateKey); err != nil { - t.Errorf("Profile.RunnerPrivateKey is not a valid X25519 key: %v", err) - } - - // Sanity-check the rest of the Profile so a future refactor that - // drops one of these fields fails loudly. - if prof.RuntimeID != "rt_test_123" { - t.Errorf("RuntimeID = %q, want rt_test_123", prof.RuntimeID) - } - if prof.RunnerCredential != "rc_test_secret" { - t.Errorf("RunnerCredential = %q, want rc_test_secret", prof.RunnerCredential) - } -} - -// Pair-error pass-through must still surface a 400 when the server -// fails the pair for any reason (reused token, device limit, etc.). -func TestPairProfileSurfacesServerPairFailure(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusBadRequest) - _, _ = w.Write([]byte(`{"error":"pair_failed","message":"token reused"}`)) - })) - defer srv.Close() - - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - _, _, err := pairProfile(ctx, srv.URL, "pairing-token", "test-device") - if err == nil { - t.Fatal("pairProfile succeeded against a 400 server response, want error") - } -} diff --git a/apps/daemon/internal/cli/root.go b/apps/daemon/internal/cli/root.go index 18a94b421..cd77a7d2b 100644 --- a/apps/daemon/internal/cli/root.go +++ b/apps/daemon/internal/cli/root.go @@ -40,8 +40,8 @@ var commands = []command{ {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}, - {name: "connect", summary: "Pair, open the reverse WebSocket, and start serving prompts", run: runConnect}, - {name: "status", summary: "Print the paired profile and daemon state", run: runStatus}, + {name: "connect", summary: "Open the reverse WebSocket and start serving prompts", run: runConnect}, + {name: "status", summary: "Print the credential profile and daemon state", run: runStatus}, {name: "stop", summary: "Stop a background `connect -b` daemon", run: runStop}, {name: "logs", summary: "Tail the background daemon's log file", run: runLogs}, {name: "logout", summary: "Forget the credential for a profile", run: runLogout}, diff --git a/apps/daemon/internal/cli/status.go b/apps/daemon/internal/cli/status.go index d02019d45..beb9e444d 100644 --- a/apps/daemon/internal/cli/status.go +++ b/apps/daemon/internal/cli/status.go @@ -32,7 +32,7 @@ func runStatus(ctx *runContext, args []string) error { prof, err := auth.Load(*profile) switch { case errors.Is(err, auth.ErrNotPaired): - fmt.Fprintln(ctx.stdout, "paired : no saved pairing profile; check Host connection in Core for a self-hosted Runtime") + fmt.Fprintln(ctx.stdout, "paired : no saved credential profile; check Host connection in Core for a self-hosted Runtime") case err != nil: fmt.Fprintf(ctx.stdout, "paired : ERROR — %v\n", err) default: @@ -42,12 +42,6 @@ func runStatus(ctx *runContext, args []string) error { if prof.DeviceName != "" { fmt.Fprintf(ctx.stdout, "device_name : %s\n", prof.DeviceName) } - if prof.Hostname != "" { - fmt.Fprintf(ctx.stdout, "hostname : %s\n", prof.Hostname) - } - if !prof.PairedAt.IsZero() { - fmt.Fprintf(ctx.stdout, "paired_at : %s\n", prof.PairedAt.Format("2006-01-02 15:04:05 MST")) - } } // connect.pid existence is the cheap signal; the full liveness diff --git a/apps/daemon/internal/daemonize/fork.go b/apps/daemon/internal/daemonize/fork.go index 8803c7ee1..120e7dd75 100644 --- a/apps/daemon/internal/daemonize/fork.go +++ b/apps/daemon/internal/daemonize/fork.go @@ -42,10 +42,6 @@ type ReExecOptions struct { // child identity here before returning; existing files are replaced // atomically. PIDPath string - - // ExtraEnv is appended to the child's environment in addition to - // parent environ + BackgroundSentinelEnv. - ExtraEnv []string } // Spawn re-execs the current binary in the background. argv is the @@ -89,7 +85,6 @@ func Spawn(argv []string, opts ReExecOptions) (int, error) { env := append([]string(nil), os.Environ()...) env = append(env, BackgroundSentinelEnv+"=1") - env = append(env, opts.ExtraEnv...) cmd := exec.Command(exe, argv[1:]...) cmd.Env = env diff --git a/apps/daemon/internal/daemonize/fork_test.go b/apps/daemon/internal/daemonize/fork_test.go index 7ad546605..a8f4fc5a6 100644 --- a/apps/daemon/internal/daemonize/fork_test.go +++ b/apps/daemon/internal/daemonize/fork_test.go @@ -12,6 +12,7 @@ func TestSpawnReExecsWithSentinelAndPIDFile(t *testing.T) { dir := privateTempDir(t) logPath := filepath.Join(dir, "child.log") pidPath := filepath.Join(dir, "child.pid") + t.Setenv(spawnTestChildEnv, "1") // argv[0] is ignored (Spawn uses os.Executable()); argv[1:] // becomes child args. Placeholder subcommand so flag parsing @@ -19,9 +20,8 @@ func TestSpawnReExecsWithSentinelAndPIDFile(t *testing.T) { pid, err := Spawn( []string{"oac-daemon", "child-mode"}, ReExecOptions{ - LogPath: logPath, - PIDPath: pidPath, - ExtraEnv: []string{spawnTestChildEnv + "=1"}, + LogPath: logPath, + PIDPath: pidPath, }, ) if err != nil { diff --git a/apps/daemon/internal/daemonize/pidfile_test.go b/apps/daemon/internal/daemonize/pidfile_test.go index f390a7837..4aec5a769 100644 --- a/apps/daemon/internal/daemonize/pidfile_test.go +++ b/apps/daemon/internal/daemonize/pidfile_test.go @@ -66,7 +66,8 @@ func TestProcessRecordRejectsOldAndMalformed(t *testing.T) { func TestStopTimeoutRetainsProcessRecord(t *testing.T) { dir := privateTempDir(t) path := filepath.Join(dir, "connect.pid") - pid, err := Spawn([]string{"daemon", "child"}, ReExecOptions{LogPath: filepath.Join(dir, "log"), PIDPath: path, ExtraEnv: []string{spawnTestChildEnv + "=ignore"}}) + t.Setenv(spawnTestChildEnv, "ignore") + pid, err := Spawn([]string{"daemon", "child"}, ReExecOptions{LogPath: filepath.Join(dir, "log"), PIDPath: path}) if err != nil { t.Fatal(err) } diff --git a/apps/daemon/internal/paths/paths.go b/apps/daemon/internal/paths/paths.go index fb4bcec94..6c835ae3d 100644 --- a/apps/daemon/internal/paths/paths.go +++ b/apps/daemon/internal/paths/paths.go @@ -1,6 +1,6 @@ // Package paths resolves on-disk locations for oac-daemon state under // ~/.oac/daemon// — one subdir per profile so "test" -// and "prod" servers can be paired in parallel without colliding. +// and "prod" servers can be connected in parallel without colliding. // // Files are 0o600, parent dir 0o700. These functions only resolve // paths — callers do the I/O. @@ -8,7 +8,6 @@ package paths import ( "fmt" - "github.com/MiniMax-AI/OpenAgentCore/internal/runtimefs" "os" "path/filepath" "regexp" @@ -51,8 +50,7 @@ func Root() (string, error) { return filepath.Join(home, ".oac"), nil } -// ProfileDir returns ~/.oac/daemon/. NOT created; -// use EnsureProfileDir. +// ProfileDir returns ~/.oac/daemon/. It is not created here. func ProfileDir(profile string) (string, error) { if err := ValidateProfile(profile); err != nil { return "", err @@ -64,19 +62,6 @@ func ProfileDir(profile string) (string, error) { return filepath.Join(root, "daemon", profile), nil } -// EnsureProfileDir mkdirs the profile dir at mode 0o700 and returns -// its path. Idempotent. -func EnsureProfileDir(profile string) (string, error) { - dir, err := ProfileDir(profile) - if err != nil { - return "", err - } - if err := runtimefs.EnsurePrivateDir(dir); err != nil { - return "", fmt.Errorf("create profile dir %s: %w", dir, err) - } - return dir, nil -} - // AuthFile returns the absolute path to auth.json for a profile. func AuthFile(profile string) (string, error) { dir, err := ProfileDir(profile) diff --git a/apps/daemon/internal/paths/paths_test.go b/apps/daemon/internal/paths/paths_test.go index fc151033e..7f4d8fa9e 100644 --- a/apps/daemon/internal/paths/paths_test.go +++ b/apps/daemon/internal/paths/paths_test.go @@ -4,7 +4,6 @@ import ( "errors" "os" "path/filepath" - "runtime" "strings" "testing" @@ -93,33 +92,6 @@ func TestProfileDirAndFiles(t *testing.T) { } } -func TestEnsureProfileDirCreates0700(t *testing.T) { - _ = withTempHome(t) - dir, err := paths.EnsureProfileDir("default") - if err != nil { - t.Fatalf("EnsureProfileDir: %v", err) - } - info, err := os.Stat(dir) - if err != nil { - t.Fatalf("stat after EnsureProfileDir: %v", err) - } - if !info.IsDir() { - t.Fatalf("EnsureProfileDir returned %q which is not a directory", dir) - } - if mode := info.Mode().Perm(); runtime.GOOS != "windows" && mode != 0o700 { - t.Errorf("EnsureProfileDir mode = %o, want 0700", mode) - } - - // Idempotent (mkdir -p semantics). - dir2, err := paths.EnsureProfileDir("default") - if err != nil { - t.Fatalf("EnsureProfileDir (second call): %v", err) - } - if dir2 != dir { - t.Fatalf("EnsureProfileDir second call returned %q, want %q", dir2, dir) - } -} - func TestInvalidProfileShortCircuits(t *testing.T) { _ = withTempHome(t) if _, err := paths.ProfileDir("bad/profile"); err == nil { @@ -128,9 +100,6 @@ func TestInvalidProfileShortCircuits(t *testing.T) { if _, err := paths.AuthFile("bad/profile"); err == nil { t.Fatal("AuthFile accepted invalid profile name") } - if _, err := paths.EnsureProfileDir("bad/profile"); err == nil { - t.Fatal("EnsureProfileDir accepted invalid profile name") - } } func TestRootRejectsRelativeOverride(t *testing.T) { diff --git a/apps/daemon/internal/transport/ws.go b/apps/daemon/internal/transport/ws.go index feeb2a7a5..e39411074 100644 --- a/apps/daemon/internal/transport/ws.go +++ b/apps/daemon/internal/transport/ws.go @@ -24,7 +24,7 @@ type DialOptions struct { // WSURL is the absolute ws://... or wss://... URL. WSURL string - // DeviceID is the runtime row id stamped at pair time. Sent as + // DeviceID is the runtime row id from the daemon credential. Sent as // device_id query param; the gateway uses it as the session key. DeviceID string diff --git a/docs/runtime-bootstrap.md b/docs/runtime-bootstrap.md index 9a1ca5e96..f97f3d1fe 100644 --- a/docs/runtime-bootstrap.md +++ b/docs/runtime-bootstrap.md @@ -19,7 +19,7 @@ oac-daemon connect --bootstrap-file /home/runtime/runtime-bootstrap.json The decoder rejects unknown, duplicate, missing and case-aliased fields, other versions and documents larger than `runtimebootstrap.MaxBytes` (16 KiB). Errors never include submitted values. A missing or malformed file fails before the daemon connects. -The file is the only authentication input for this launch: the daemon refuses to combine it with pairing or self-hosted enrollment options, and reads the credential into memory without saving it to a stored profile. Credentials never go in command arguments, environment variables or receipts. The provider keeps the file for process restarts and removes it only during explicit cleanup of the resources it owns. +The file is the only authentication input for this launch: the daemon refuses to combine it with self-hosted enrollment options, and reads the credential into memory without saving it to a stored profile. Credentials never go in command arguments, environment variables or receipts. The provider keeps the file for process restarts and removes it only during explicit cleanup of the resources it owns. ## Responsibilities and readiness diff --git a/go.mod b/go.mod index 9146030d1..e851cd189 100644 --- a/go.mod +++ b/go.mod @@ -16,7 +16,6 @@ require ( go.opentelemetry.io/otel/sdk v1.44.0 go.opentelemetry.io/otel/sdk/metric v1.44.0 go.opentelemetry.io/proto/otlp v1.10.0 - golang.org/x/crypto v0.55.0 golang.org/x/net v0.58.0 golang.org/x/sync v0.22.0 golang.org/x/sys v0.47.0 diff --git a/go.sum b/go.sum index 07c9f1f70..3ed07c81a 100644 --- a/go.sum +++ b/go.sum @@ -127,8 +127,6 @@ go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpu go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= -golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= diff --git a/internal/runtimecrypto/cmd/emit-fixture/main.go b/internal/runtimecrypto/cmd/emit-fixture/main.go deleted file mode 100644 index 9f9572fd3..000000000 --- a/internal/runtimecrypto/cmd/emit-fixture/main.go +++ /dev/null @@ -1,76 +0,0 @@ -// One-shot generator for the cross-language wire-format fixture. Run -// by hand: -// -// go run ./internal/runtimecrypto/cmd/emit-fixture > internal/runtimecrypto/testdata/wire_v1.json -// -// The output is COMMITTED and read-only at test time. `//go:build -// ignore` keeps this invisible to `go build ./...` / `go test ./...`, -// and the stderr WARNING forces the operator to notice they are about -// to mutate a wire-locked artefact. A *_test.go that wrote the file -// would silently green-light protocol drift on every CI run. - -//go:build ignore - -package main - -import ( - "crypto/rand" - "encoding/base64" - "encoding/json" - "fmt" - "os" - - runtimecrypto "github.com/MiniMax-AI/OpenAgentCore/internal/runtimecrypto" - "golang.org/x/crypto/nacl/box" -) - -type fixture struct { - Description string `json:"description"` - WireFormat string `json:"wire_format"` - RecipientPubB64 string `json:"recipient_public_key_b64"` - RecipientPrivB64 string `json:"recipient_private_key_b64"` - CipherB64 string `json:"cipher_b64"` - PlaintextUTF8 string `json:"plaintext_utf8"` -} - -func main() { - fmt.Fprintln(os.Stderr, - "WARNING: this regenerates internal/runtimecrypto/testdata/wire_v1.json.") - fmt.Fprintln(os.Stderr, - " Only commit the new bytes if you intentionally bumped the wire protocol version.") - fmt.Fprintln(os.Stderr, - " Re-generating without a version bump silently breaks the protocol-version lock.") - - pub, priv, err := box.GenerateKey(rand.Reader) - if err != nil { - fmt.Fprintf(os.Stderr, "keygen: %v\n", err) - os.Exit(1) - } - pubB64 := base64.StdEncoding.EncodeToString(pub[:]) - privB64 := base64.StdEncoding.EncodeToString(priv[:]) - - plaintext := `{"api_key":"sk-wire-fixture","provider":"anthropic"}` - cipherB64, err := runtimecrypto.SealForRuntime([]byte(plaintext), pubB64) - if err != nil { - fmt.Fprintf(os.Stderr, "seal: %v\n", err) - os.Exit(1) - } - - out := fixture{ - Description: "Wire fixture for the Runtime credential envelope. " + - "Recipient keypair is committed for test reproducibility. DO NOT use these " + - "keys in any other context. Wire format: NaCl SealAnonymous (X25519 + " + - "XSalsa20-Poly1305), nonce = BLAKE2b-24(ephPub || recipientPub).", - WireFormat: "nacl_sealed_box_v1", - RecipientPubB64: pubB64, - RecipientPrivB64: privB64, - CipherB64: cipherB64, - PlaintextUTF8: plaintext, - } - enc := json.NewEncoder(os.Stdout) - enc.SetIndent("", " ") - if err := enc.Encode(out); err != nil { - fmt.Fprintf(os.Stderr, "encode: %v\n", err) - os.Exit(1) - } -} diff --git a/internal/runtimecrypto/runtime_seal.go b/internal/runtimecrypto/runtime_seal.go deleted file mode 100644 index f8d6c3894..000000000 --- a/internal/runtimecrypto/runtime_seal.go +++ /dev/null @@ -1,110 +0,0 @@ -// Package runtimecrypto implements envelope-encryption for shipping -// sensitive payloads (model API keys, per-run secrets) from the -// OpenAgentCore server to a paired Agent Daemon without putting plaintext -// on the wire. -// -// Algorithm: NaCl sealed box (X25519 + XSalsa20-Poly1305) via -// box.SealAnonymous. Nonce = BLAKE2b-24(ephPub || recipientPub). -// Wire format: base64(stdEncoding) so it travels safely inside JSON. -// The committed static fixture (testdata/wire_v1.json) keeps the wire -// format locked across refactors. -// -// Threat model: -// - Server logs / debug dumps / DB middlewares see ciphertext only. -// - Daemon-machine compromise is OUT of scope (private key lives -// there and the host is trusted by definition). -// - Server compromise is OUT of scope (a malicious server could -// skip encryption entirely). -package runtimecrypto - -import ( - "crypto/rand" - "encoding/base64" - "errors" - "fmt" - - "golang.org/x/crypto/nacl/box" -) - -// PublicKeySize / PrivateKeySize match nacl/box (32 bytes Curve25519). -const ( - PublicKeySize = 32 - PrivateKeySize = 32 -) - -// ErrInvalidPublicKey wraps any reason the recipient public key is -// unusable (wrong length, bad base64). API layer returns 400. -var ErrInvalidPublicKey = errors.New("runtime crypto: invalid public key") - -// ErrDecryptFailed is returned when ciphertext fails authentication -// (tampered, wrong recipient key, malformed envelope). Generic by -// design — exposing the specific reason helps attackers. -var ErrDecryptFailed = errors.New("runtime crypto: decrypt failed") - -// SealForRuntime encrypts plaintext for the runtime identified by its -// base64-encoded X25519 public key (as stored in -// runtimes.config.runner_public_key). -// -// Output is base64(stdEncoding) of an anonymous sealed box: each call -// generates a fresh ephemeral keypair and embeds the public half in -// the envelope. Receiver derives the shared key from that and its own -// private key. -func SealForRuntime(plaintext []byte, runnerPublicKeyB64 string) (string, error) { - pub, err := decodePublicKey(runnerPublicKeyB64) - if err != nil { - return "", err - } - sealed, err := box.SealAnonymous(nil, plaintext, &pub, rand.Reader) - if err != nil { - return "", fmt.Errorf("runtime crypto: seal: %w", err) - } - return base64.StdEncoding.EncodeToString(sealed), nil -} - -// OpenSeal decrypts a SealForRuntime output using the recipient's -// keypair. Kept here for round-trip tests and to lock the wire format -// down in one place. -func OpenSeal(cipherB64 string, publicKey, privateKey [32]byte) ([]byte, error) { - sealed, err := base64.StdEncoding.DecodeString(cipherB64) - if err != nil { - return nil, ErrDecryptFailed - } - out, ok := box.OpenAnonymous(nil, sealed, &publicKey, &privateKey) - if !ok { - return nil, ErrDecryptFailed - } - return out, nil -} - -// GenerateRuntimeKeypair returns a fresh (publicKey, privateKey) pair -// as base64. Caller MUST persist the private key with mode 0600 and -// never log it. -func GenerateRuntimeKeypair() (publicKeyB64, privateKeyB64 string, err error) { - pub, priv, err := box.GenerateKey(rand.Reader) - if err != nil { - return "", "", fmt.Errorf("runtime crypto: keygen: %w", err) - } - return base64.StdEncoding.EncodeToString(pub[:]), - base64.StdEncoding.EncodeToString(priv[:]), - nil -} - -// DecodeKey turns a base64-encoded 32-byte key into a [32]byte so -// daemon code and the API layer share one parser instead of inlining -// base64 + length checks at every caller. -func DecodeKey(b64 string) ([32]byte, error) { - var out [32]byte - raw, err := base64.StdEncoding.DecodeString(b64) - if err != nil { - return out, ErrInvalidPublicKey - } - if len(raw) != PublicKeySize { - return out, ErrInvalidPublicKey - } - copy(out[:], raw) - return out, nil -} - -func decodePublicKey(b64 string) ([32]byte, error) { - return DecodeKey(b64) -} diff --git a/internal/runtimecrypto/runtime_seal_test.go b/internal/runtimecrypto/runtime_seal_test.go deleted file mode 100644 index 5852a5a4d..000000000 --- a/internal/runtimecrypto/runtime_seal_test.go +++ /dev/null @@ -1,135 +0,0 @@ -package runtimecrypto - -import ( - "bytes" - "crypto/rand" - "encoding/base64" - "testing" -) - -func TestSealOpenRoundTrip(t *testing.T) { - pub, priv, err := GenerateRuntimeKeypair() - if err != nil { - t.Fatalf("keygen: %v", err) - } - pubArr, err := DecodeKey(pub) - if err != nil { - t.Fatalf("decode pub: %v", err) - } - privArr, err := DecodeKey(priv) - if err != nil { - t.Fatalf("decode priv: %v", err) - } - - plain := []byte(`{"api_key":"sk-secret","provider":"anthropic"}`) - cipher, err := SealForRuntime(plain, pub) - if err != nil { - t.Fatalf("seal: %v", err) - } - if cipher == "" { - t.Fatal("empty cipher") - } - if bytes.Contains([]byte(cipher), []byte("sk-secret")) { - t.Fatal("ciphertext leaks plaintext") - } - got, err := OpenSeal(cipher, pubArr, privArr) - if err != nil { - t.Fatalf("open: %v", err) - } - if !bytes.Equal(got, plain) { - t.Errorf("decrypt mismatch:\n got %q\n want %q", got, plain) - } -} - -// Each call uses a fresh ephemeral keypair so the same plaintext -// yields different ciphertexts. -func TestSealNonDeterministic(t *testing.T) { - pub, _, err := GenerateRuntimeKeypair() - if err != nil { - t.Fatalf("keygen: %v", err) - } - c1, _ := SealForRuntime([]byte("hello"), pub) - c2, _ := SealForRuntime([]byte("hello"), pub) - if c1 == c2 { - t.Errorf("two seals of same plaintext should differ; got identical") - } -} - -// Tampered ciphertext must fail open (Poly1305 authenticator). -func TestOpenTamperedFails(t *testing.T) { - pub, priv, _ := GenerateRuntimeKeypair() - pubArr, _ := DecodeKey(pub) - privArr, _ := DecodeKey(priv) - cipher, _ := SealForRuntime([]byte("payload"), pub) - - raw, _ := base64.StdEncoding.DecodeString(cipher) - raw[len(raw)/2] ^= 0xFF - tampered := base64.StdEncoding.EncodeToString(raw) - if _, err := OpenSeal(tampered, pubArr, privArr); err != ErrDecryptFailed { - t.Errorf("tampered ciphertext: got err=%v, want ErrDecryptFailed", err) - } -} - -// Wrong recipient private key must fail open. -func TestOpenWrongKeyFails(t *testing.T) { - pub, _, _ := GenerateRuntimeKeypair() - cipher, _ := SealForRuntime([]byte("payload"), pub) - - otherPub, otherPriv, _ := GenerateRuntimeKeypair() - otherPubArr, _ := DecodeKey(otherPub) - otherPrivArr, _ := DecodeKey(otherPriv) - if _, err := OpenSeal(cipher, otherPubArr, otherPrivArr); err != ErrDecryptFailed { - t.Errorf("wrong key: got err=%v, want ErrDecryptFailed", err) - } -} - -// Bad public key (wrong length / non-base64) must error, not panic. -func TestSealRejectsBadPublicKey(t *testing.T) { - cases := []string{ - "", - "not-base64-!!!", - base64.StdEncoding.EncodeToString([]byte("short")), - } - for _, k := range cases { - if _, err := SealForRuntime([]byte("x"), k); err != ErrInvalidPublicKey { - t.Errorf("SealForRuntime(%q): got err=%v, want ErrInvalidPublicKey", k, err) - } - } -} - -func TestKeypairUnique(t *testing.T) { - seen := map[string]bool{} - for i := 0; i < 16; i++ { - pub, priv, _ := GenerateRuntimeKeypair() - if seen[pub] { - t.Fatalf("duplicate pub at i=%d", i) - } - if seen[priv] { - t.Fatalf("duplicate priv at i=%d", i) - } - seen[pub] = true - seen[priv] = true - } -} - -// Guard against a zero/empty-ciphertext regression if crypto/rand is -// not wired into the build. -func TestSealNotEmptyEvenForEmptyPlaintext(t *testing.T) { - pub, _, _ := GenerateRuntimeKeypair() - cipher, err := SealForRuntime([]byte{}, pub) - if err != nil { - t.Fatalf("seal empty: %v", err) - } - // nacl sealed-box overhead = 32 (ephemeral pub) + 16 (poly1305) = 48 - raw, _ := base64.StdEncoding.DecodeString(cipher) - if len(raw) < 48 { - t.Errorf("ciphertext too short: %d bytes, want >= 48", len(raw)) - } -} - -func TestRandomReaderAvailable(t *testing.T) { - var b [16]byte - if _, err := rand.Read(b[:]); err != nil { - t.Fatalf("crypto/rand unavailable: %v", err) - } -} diff --git a/internal/runtimecrypto/runtime_seal_wire_test.go b/internal/runtimecrypto/runtime_seal_wire_test.go deleted file mode 100644 index 1f439cb11..000000000 --- a/internal/runtimecrypto/runtime_seal_wire_test.go +++ /dev/null @@ -1,56 +0,0 @@ -package runtimecrypto - -import ( - "encoding/json" - "os" - "path/filepath" - "testing" -) - -// wireFixture matches the JSON shape emitted by cmd/emit-fixture and -// committed to testdata/wire_v1.json. Explicit fields so a future -// schema bump (wire_format != nacl_sealed_box_v1) breaks decoding -// instead of silently passing. -type wireFixture struct { - Description string `json:"description"` - WireFormat string `json:"wire_format"` - RecipientPubB64 string `json:"recipient_public_key_b64"` - RecipientPrivB64 string `json:"recipient_private_key_b64"` - CipherB64 string `json:"cipher_b64"` - PlaintextUTF8 string `json:"plaintext_utf8"` -} - -// TestOpenStaticWireFixture decrypts the committed wire vector via -// the production OpenSeal path. Canonical regression guard for -// cross-language protocol drift — if either side's algorithm -// (Go nacl/box, Node tweetnacl + @noble/hashes) changes, this fails -// on the first commit after the drift. -func TestOpenStaticWireFixture(t *testing.T) { - path := filepath.Join("testdata", "wire_v1.json") - raw, err := os.ReadFile(path) - if err != nil { - t.Fatalf("read fixture: %v", err) - } - var f wireFixture - if err := json.Unmarshal(raw, &f); err != nil { - t.Fatalf("parse fixture: %v", err) - } - if f.WireFormat != "nacl_sealed_box_v1" { - t.Fatalf("wire_format=%q, want nacl_sealed_box_v1 (test is locked to v1)", f.WireFormat) - } - pub, err := DecodeKey(f.RecipientPubB64) - if err != nil { - t.Fatalf("decode pub: %v", err) - } - priv, err := DecodeKey(f.RecipientPrivB64) - if err != nil { - t.Fatalf("decode priv: %v", err) - } - got, err := OpenSeal(f.CipherB64, pub, priv) - if err != nil { - t.Fatalf("open static fixture: %v", err) - } - if string(got) != f.PlaintextUTF8 { - t.Errorf("plaintext mismatch:\n got %q\n want %q", got, f.PlaintextUTF8) - } -} diff --git a/internal/runtimecrypto/testdata/wire_v1.json b/internal/runtimecrypto/testdata/wire_v1.json deleted file mode 100644 index ff34f4bc0..000000000 --- a/internal/runtimecrypto/testdata/wire_v1.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "description": "Wire fixture for the Runtime credential envelope. Recipient keypair is committed for test reproducibility. DO NOT use these keys in any other context. Wire format: NaCl SealAnonymous (X25519 + XSalsa20-Poly1305), nonce = BLAKE2b-24(ephPub || recipientPub).", - "wire_format": "nacl_sealed_box_v1", - "recipient_public_key_b64": "kxmWMYeGYw4zwKGkoXBQUh3Ac6CCD0jUechNvXEwRSk=", - "recipient_private_key_b64": "6b/6ZGaX77mlq1Q/ZUZd/T67wNc9orIpSslluBMYXz8=", - "cipher_b64": "+xJ0WbN+YwxXhccjZDMJfkRNdICegZpBkkDg2lS1gEpqVXh3ABfYWtz0vxR9zOf9JpeD0z6yprJyDdWkhn8lHbf3Mp+0Z1sMAp9BHoHO61FCGB3DdnZiHp7dTI4NAKM62Zs+LQ==", - "plaintext_utf8": "{\"api_key\":\"sk-wire-fixture\",\"provider\":\"anthropic\"}" -} diff --git a/packages/claude-sdk-adapter/README.md b/packages/claude-sdk-adapter/README.md index 26b3497d9..c4d41af6f 100644 --- a/packages/claude-sdk-adapter/README.md +++ b/packages/claude-sdk-adapter/README.md @@ -64,9 +64,9 @@ Workspace deferred-function discovery uses native ToolSearch alongside the norma ### Registration and state -For unmanaged bootstrap, daemon `connect` optionally registers this factory as `claude_sdk` when the operator sets `OAC_RUNTIME_CLAUDE_SDK_ENTRYPOINT` to the absolute packaged `dist/main.js`. `OAC_RUNTIME_CLAUDE_SDK_NODE` selects Node (default: `node` on PATH). Discovery resolves Node once and checks that exact configuration before pairing; the SDK's bounded runtime check is independent of CLI version probes. A ready SDK alone is sufficient to start the daemon. No configuration means no SDK probe or descriptor; failed readiness reports an unavailable descriptor with a rejecting factory. Runtime checks establish local readiness, not provider authentication. Installed daemons use `start` and their verified installation manifest for adapter selection and activation; ambient activation variables cannot extend that selection. See [the native installation contract](../../deploy/install/README.md#native-daemon-installer). +For unmanaged bootstrap, daemon `connect` optionally registers this factory as `claude_sdk` when the operator sets `OAC_RUNTIME_CLAUDE_SDK_ENTRYPOINT` to the absolute packaged `dist/main.js`. `OAC_RUNTIME_CLAUDE_SDK_NODE` selects Node (default: `node` on PATH). Discovery resolves Node once and checks that exact configuration before connecting; the SDK's bounded runtime check is independent of CLI version probes. A ready SDK alone is sufficient to start the daemon. No configuration means no SDK probe or descriptor; failed readiness reports an unavailable descriptor with a rejecting factory. Runtime checks establish local readiness, not provider authentication. Installed daemons use `start` and their verified installation manifest for adapter selection and activation; ambient activation variables cannot extend that selection. See [the native installation contract](../../deploy/install/README.md#native-daemon-installer). -SDK state lives under `paths.ProfileDir(profile)/runtime/claude-sdk`, independently of the replaceable runtime bundle. Both the entrypoint and managed state root must be absolute. Background re-execution inherits operator configuration; it does not persist provider credentials in pairing profiles. The daemon registers `claude_sdk` directly, without the capability-download, skill-upload and `WorkspaceAuthoring` wrappers of its product agent kinds. It accepts no caller-supplied environment variables or business write authority. +SDK state lives under `paths.ProfileDir(profile)/runtime/claude-sdk`, independently of the replaceable runtime bundle. Both the entrypoint and managed state root must be absolute. Background re-execution inherits operator configuration; it does not persist provider credentials in credential profiles. The daemon registers `claude_sdk` directly, without the capability-download, skill-upload and `WorkspaceAuthoring` wrappers of its product agent kinds. It accepts no caller-supplied environment variables or business write authority. ### Descriptor and execution profile diff --git a/services/core/internal/runtimegateway/auth.go b/services/core/internal/runtimegateway/auth.go index 4d7fadea2..40fa443f5 100644 --- a/services/core/internal/runtimegateway/auth.go +++ b/services/core/internal/runtimegateway/auth.go @@ -71,7 +71,7 @@ func (a *Authenticator) AuthenticateBearer(ctx context.Context, deviceID, bearer } storedHash := rt.CredentialHash if storedHash == "" { - // Pairing never completed, or someone wiped the credential + // No credential was issued, or someone wiped it // out-of-band. Fail closed. return AuthenticatedRuntime{}, ErrAuthBadCredential } diff --git a/services/core/internal/runtimegateway/handler.go b/services/core/internal/runtimegateway/handler.go index 85a62d55e..a22d6df2d 100644 --- a/services/core/internal/runtimegateway/handler.go +++ b/services/core/internal/runtimegateway/handler.go @@ -15,8 +15,8 @@ import ( ) // HeartbeatTouch is the persistence interface the gateway uses to -// bump last_heartbeat_at / promote pending_pairing -> online when a -// daemon connects. +// bump last_heartbeat_at and mark the runtime online when a daemon +// connects. type HeartbeatTouch interface { TouchRuntimeHeartbeat(ctx context.Context, runtimeID string) (runtimedevice.HeartbeatStatus, error) TouchAgentDaemonHeartbeat(ctx context.Context, input runtimedevice.Heartbeat) (runtimedevice.HeartbeatStatus, error) @@ -32,7 +32,7 @@ type HandlerConfig struct { Registry *Registry - // Heartbeat flips pending_pairing -> online and keeps + // Heartbeat marks the runtime online and keeps // last_heartbeat_at fresh. nil tracks liveness in-process only. Heartbeat HeartbeatTouch @@ -133,7 +133,7 @@ func (h *Handler) WS(w http.ResponseWriter, r *http.Request) { return } if h.cfg.Heartbeat != nil { - // First inbound action — promote pending_pairing -> online. + // First inbound action — mark the runtime online. // Best-effort; a transient DB blip shouldn't refuse the // upgrade since we already accepted the credential. if _, hbErr := h.cfg.Heartbeat.TouchRuntimeHeartbeat(r.Context(), auth.DeviceID); hbErr != nil { @@ -179,7 +179,7 @@ func (h *Handler) WS(w http.ResponseWriter, r *http.Request) { sess.Start() } -// Bootstrap is the daemon's first HTTP call after pairing. Validating +// Bootstrap is the daemon's first HTTP call with its credential. Validating // the bearer in a separate HTTP step (rather than folded into the WS // upgrade) lets the daemon fail fast on credential problems with a // real HTTP status rather than the opaque WS close code. diff --git a/services/core/internal/runtimegateway/routes.go b/services/core/internal/runtimegateway/routes.go index c7bf72429..d0c098c1e 100644 --- a/services/core/internal/runtimegateway/routes.go +++ b/services/core/internal/runtimegateway/routes.go @@ -11,8 +11,8 @@ import ( // POST /agent-daemon/bootstrap — daemon first-call to fetch wsUrl + heartbeat cadence // GET /agent-daemon/device-status — daemon self-check // -// All three accept the runtime credential issued via the -// runtimes/pairings flow with type='agent_daemon'. +// All three accept the daemon credential described in +// contracts/agents-api/machine-api.md. func RegisterRoutes(r chi.Router, h *Handler) { if h == nil { panic("agentdaemon gateway: RegisterRoutes called with nil handler") From 7a8cdfc4a0f6cc4e454ed1e859709c8bb317a34f Mon Sep 17 00:00:00 2001 From: yuanhe Date: Thu, 1 Oct 2026 12:17:43 +0800 Subject: [PATCH 2/2] Remove the dormant Parsar capability upload hook from the daemon The daemon wrapped every Session and Executor factory to look for PARSAR_CAPABILITY_UPLOAD_TOKEN in AgentOptions env, inject PARSAR_SERVER_URL and prepend its own directory to PATH when a `parsar` executable sat beside it. This is a product-specific path selected by name, which the protocol rules forbid, and it is unreachable: Core never writes AgentOptions env, nothing ships a `parsar` executable next to the daemon, and Core serves no upload route. Applications upload Skills through the public /v1 API. - Delete skill_upload.go and its tests, and the authoring PATH prepend. - Drop the now-unused name-guard exceptions; the capability download fixture keeps a narrow exception for its example host. --- CONTRIBUTING.md | 2 +- .../daemon/internal/cli/agent_registration.go | 2 +- apps/daemon/internal/cli/authoring.go | 5 --- .../internal/cli/capability_downloads.go | 1 - .../internal/cli/companion_path_test.go | 37 ---------------- apps/daemon/internal/cli/skill_upload.go | 44 ------------------- apps/daemon/internal/cli/skill_upload_test.go | 41 ----------------- packages/claude-sdk-adapter/README.md | 2 +- scripts/name-allowlist.json | 24 +--------- 9 files changed, 5 insertions(+), 153 deletions(-) delete mode 100644 apps/daemon/internal/cli/companion_path_test.go delete mode 100644 apps/daemon/internal/cli/skill_upload.go delete mode 100644 apps/daemon/internal/cli/skill_upload_test.go diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f61d4112c..3b7817063 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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. diff --git a/apps/daemon/internal/cli/agent_registration.go b/apps/daemon/internal/cli/agent_registration.go index f2911f217..939dbb30a 100644 --- a/apps/daemon/internal/cli/agent_registration.go +++ b/apps/daemon/internal/cli/agent_registration.go @@ -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) diff --git a/apps/daemon/internal/cli/authoring.go b/apps/daemon/internal/cli/authoring.go index 6b3b26956..d1872fefd 100644 --- a/apps/daemon/internal/cli/authoring.go +++ b/apps/daemon/internal/cli/authoring.go @@ -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" @@ -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) diff --git a/apps/daemon/internal/cli/capability_downloads.go b/apps/daemon/internal/cli/capability_downloads.go index cb471eec1..69bc3a163 100644 --- a/apps/daemon/internal/cli/capability_downloads.go +++ b/apps/daemon/internal/cli/capability_downloads.go @@ -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) } } diff --git a/apps/daemon/internal/cli/companion_path_test.go b/apps/daemon/internal/cli/companion_path_test.go deleted file mode 100644 index 4d8b560fa..000000000 --- a/apps/daemon/internal/cli/companion_path_test.go +++ /dev/null @@ -1,37 +0,0 @@ -package cli - -import ( - "os" - "path/filepath" - "testing" -) - -func TestCompanionCLIPath(t *testing.T) { - for _, tc := range []struct { - name string - mode os.FileMode - want bool - }{ - {"executable", 0o700, true}, - {"download awaiting review", 0o600, false}, - {"missing", 0, false}, - } { - t.Run(tc.name, func(t *testing.T) { - dir := t.TempDir() - if tc.mode != 0 { - if err := os.WriteFile(filepath.Join(dir, "parsar"), []byte("binary"), tc.mode); err != nil { - t.Fatal(err) - } - } - env := map[string]any{"PATH": "/existing/tools", "OTHER": "retained"} - addCompanionCLIPath(env, dir) - want := "/existing/tools" - if tc.want { - want = dir + string(os.PathListSeparator) + want - } - if env["PATH"] != want || env["OTHER"] != "retained" { - t.Fatalf("unexpected child environment: %v", env) - } - }) - } -} diff --git a/apps/daemon/internal/cli/skill_upload.go b/apps/daemon/internal/cli/skill_upload.go deleted file mode 100644 index cdb7ef279..000000000 --- a/apps/daemon/internal/cli/skill_upload.go +++ /dev/null @@ -1,44 +0,0 @@ -package cli - -import ( - "context" - "maps" - "os" - "path/filepath" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" -) - -func withSkillUploadServer(factory agent.Factory, serverURL string) agent.Factory { - return func(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { - req = skillUploadRequest(req, serverURL) - return factory(ctx, req, out) - } -} - -func addCompanionCLIPath(env map[string]any, dir string) { - info, err := os.Stat(filepath.Join(dir, "parsar")) - if err != nil || !info.Mode().IsRegular() || info.Mode().Perm()&0o111 == 0 { - return - } - path, ok := env["PATH"].(string) - if !ok { - path = os.Getenv("PATH") - } - env["PATH"] = dir + string(os.PathListSeparator) + path -} - -func skillUploadRequest(req proto.PromptRequestPayload, serverURL string) proto.PromptRequestPayload { - env, _ := req.AgentOptions["env"].(map[string]any) - if token, _ := env["PARSAR_CAPABILITY_UPLOAD_TOKEN"].(string); token != "" { - env = maps.Clone(env) - env["PARSAR_SERVER_URL"] = serverURL - if executable, err := os.Executable(); err == nil { - addCompanionCLIPath(env, filepath.Dir(executable)) - } - req.AgentOptions = maps.Clone(req.AgentOptions) - req.AgentOptions["env"] = env - } - return req -} diff --git a/apps/daemon/internal/cli/skill_upload_test.go b/apps/daemon/internal/cli/skill_upload_test.go deleted file mode 100644 index ef8fc3921..000000000 --- a/apps/daemon/internal/cli/skill_upload_test.go +++ /dev/null @@ -1,41 +0,0 @@ -package cli - -import ( - "context" - "os" - "testing" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" -) - -func TestSkillUploadUsesPairedAddressPerRequest(t *testing.T) { - t.Setenv("PARSAR_SERVER_URL", "unchanged-process-env") - env := map[string]any{"PARSAR_CAPABILITY_UPLOAD_TOKEN": "run-a", "PARSAR_SERVER_URL": "http://localhost:1234", "MODEL_KEY": "preserve"} - opts := map[string]any{"env": env, "model": "preserve"} - calls := 0 - factory := withSkillUploadServer(func(_ context.Context, req proto.PromptRequestPayload, _ chan<- proto.Envelope) (agent.Session, error) { - calls++ - if calls == 1 { - got := req.AgentOptions["env"].(map[string]any) - if got["PARSAR_SERVER_URL"] != "http://parsar-server:8080" || got["PARSAR_CAPABILITY_UPLOAD_TOKEN"] != "run-a" || got["MODEL_KEY"] != "preserve" || req.AgentOptions["model"] != "preserve" { - t.Fatal("per-run context incorrect") - } - if _, exists := got["PARSAR_RUNNER_TOKEN"]; exists { - t.Fatal("exported device credential") - } - } else if len(req.AgentOptions) != 0 { - t.Fatal("previous run's context leaked") - } - return nil, nil - }, "http://parsar-server:8080") - if _, err := factory(t.Context(), proto.PromptRequestPayload{AgentOptions: opts}, nil); err != nil { - t.Fatal(err) - } - if _, err := factory(t.Context(), proto.PromptRequestPayload{}, nil); err != nil { - t.Fatal(err) - } - if env["PARSAR_SERVER_URL"] != "http://localhost:1234" || os.Getenv("PARSAR_SERVER_URL") != "unchanged-process-env" { - t.Fatal("mutated caller or process environment") - } -} diff --git a/packages/claude-sdk-adapter/README.md b/packages/claude-sdk-adapter/README.md index c4d41af6f..099dff675 100644 --- a/packages/claude-sdk-adapter/README.md +++ b/packages/claude-sdk-adapter/README.md @@ -66,7 +66,7 @@ Workspace deferred-function discovery uses native ToolSearch alongside the norma For unmanaged bootstrap, daemon `connect` optionally registers this factory as `claude_sdk` when the operator sets `OAC_RUNTIME_CLAUDE_SDK_ENTRYPOINT` to the absolute packaged `dist/main.js`. `OAC_RUNTIME_CLAUDE_SDK_NODE` selects Node (default: `node` on PATH). Discovery resolves Node once and checks that exact configuration before connecting; the SDK's bounded runtime check is independent of CLI version probes. A ready SDK alone is sufficient to start the daemon. No configuration means no SDK probe or descriptor; failed readiness reports an unavailable descriptor with a rejecting factory. Runtime checks establish local readiness, not provider authentication. Installed daemons use `start` and their verified installation manifest for adapter selection and activation; ambient activation variables cannot extend that selection. See [the native installation contract](../../deploy/install/README.md#native-daemon-installer). -SDK state lives under `paths.ProfileDir(profile)/runtime/claude-sdk`, independently of the replaceable runtime bundle. Both the entrypoint and managed state root must be absolute. Background re-execution inherits operator configuration; it does not persist provider credentials in credential profiles. The daemon registers `claude_sdk` directly, without the capability-download, skill-upload and `WorkspaceAuthoring` wrappers of its product agent kinds. It accepts no caller-supplied environment variables or business write authority. +SDK state lives under `paths.ProfileDir(profile)/runtime/claude-sdk`, independently of the replaceable runtime bundle. Both the entrypoint and managed state root must be absolute. Background re-execution inherits operator configuration; it does not persist provider credentials in credential profiles. The daemon registers `claude_sdk` directly, without the capability-download and `WorkspaceAuthoring` wrappers of its product agent kinds. It accepts no caller-supplied environment variables or business write authority. ### Descriptor and execution profile diff --git a/scripts/name-allowlist.json b/scripts/name-allowlist.json index f041300b5..465ca104c 100644 --- a/scripts/name-allowlist.json +++ b/scripts/name-allowlist.json @@ -39,11 +39,6 @@ "regex": "(?i)parsar|\\bAGENTS_CORE_WEB_[A-Z][A-Z0-9_]*|\\bAGENTS_API_[A-Z][A-Z0-9_]*|\\bCORE_CONSOLE_[A-Z][A-Z0-9_]*|\\bagents-api(?:-(?:migrate|device|environment-key|e2b-provider|microsandbox-provider|tool-root|codex-directory|codex-write|workspace-export|runtime-initialize|claude-shell-prefix))?\\b|\\bcore-console\\b|\\bagents-runtime-|\\bAgents? Core(?: Web)?\\b|@agents-core-web/|\\bminimax-ai-dev\\b", "reason": "Guard regression fixtures intentionally contain retired identifiers, including rejected examples." }, - { - "path": "apps/daemon/internal/cli/skill_upload*.go", - "regex": "\\bPARSAR_(?:CAPABILITY_UPLOAD_TOKEN|SERVER_URL)\\b", - "reason": "These settings belong to the separate product capability upload integration; Core does not rename their protocol." - }, { "path": "services/core/migrations/000078_oac_runtime_names.sql", "regex": "parsar-core-runtime@", @@ -149,25 +144,10 @@ "regex": "AGENTS_API_PUBLIC_URL", "reason": "Historical migration commentary records the environment name used when this migration was written." }, - { - "path": "apps/daemon/internal/cli/skill_upload.go", - "regex": "parsar(?:-server|\\.example\\.test)?|PARSAR_RUNNER_TOKEN", - "reason": "The dormant product capability upload/download integration locates the separate product CLI and checks its product credentials; it is explicitly retained." - }, - { - "path": "apps/daemon/internal/cli/skill_upload_test.go", - "regex": "parsar(?:-server|\\.example\\.test)?|PARSAR_RUNNER_TOKEN", - "reason": "The dormant product capability upload/download integration locates the separate product CLI and checks its product credentials; it is explicitly retained." - }, - { - "path": "apps/daemon/internal/cli/companion_path_test.go", - "regex": "parsar(?:-server|\\.example\\.test)?|PARSAR_RUNNER_TOKEN", - "reason": "The dormant product capability upload/download integration locates the separate product CLI and checks its product credentials; it is explicitly retained." - }, { "path": "apps/daemon/internal/cli/capability_downloads_test.go", - "regex": "parsar(?:-server|\\.example\\.test)?|PARSAR_RUNNER_TOKEN", - "reason": "The dormant product capability upload/download integration locates the separate product CLI and checks its product credentials; it is explicitly retained." + "regex": "parsar\\.example\\.test", + "reason": "The capability download fixture uses an example product host as its public origin." }, { "path": "CONTRIBUTING.md",