Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ The OpenAI Agents API, open source and self-hosted. Run Codex, Claude Code or Mi

- **Same API as OpenAI.** Set `OPENAI_BASE_URL` and the [official OpenAI SDK](https://developers.openai.com/api/docs/libraries), or plain HTTP, works unchanged.
- **Your agent, your model.** Each Session runs Codex, Claude Code or MiniMax Code natively, as it runs on your machine, with the model provider you configure. See [Harness capabilities](https://openagentcore.dev/contracts/agents-api/harness-capabilities).
- **Compute that sleeps, workspaces that stay.** On microsandbox, which keeps every workspace on independent storage, idle sandboxes release compute and resume on a compatible node with their files intact. E2B, Docker and your own Linux, macOS or Windows machine also run Sessions. See [Sandbox Provider protocol](https://openagentcore.dev/docs/sandbox-provider).
- **Compute that sleeps, workspaces that stay.** On microsandbox, which keeps every workspace on independent storage, idle sandboxes release compute and resume on a compatible node with their files intact. E2B pauses idle sandboxes in place. Docker and your own Linux, macOS or Windows machine also run Sessions. See [Sandbox Provider protocol](https://openagentcore.dev/docs/sandbox-provider).
- **Replaceable parts.** Sandbox Providers, workspace filesystems, Harnesses and model providers plug in through [defined protocols](AGENTS.md#protocols-at-every-boundary).

![Deployment overview](docs/assets/console-overview-en.webp)
Expand Down
2 changes: 1 addition & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@

- **与 OpenAI 相同的 API。** 设置 `OPENAI_BASE_URL` 后,[官方 OpenAI SDK](https://developers.openai.com/api/docs/libraries)或直接的 HTTP 调用无需改动即可使用。
- **你的 Agent,你的模型。** 每个 Session 原生运行 Codex、Claude Code 或 MiniMax Code,使用你配置的模型提供商,与在本机运行一致。详见 [Harness 能力](https://openagentcore.dev/zh/contracts/agents-api/harness-capabilities)。
- **计算会休眠,工作区会保留。** 在 microsandbox 上(每个工作区都保存在独立存储中),空闲沙箱释放计算资源,并在兼容节点上带着文件恢复。E2B、Docker 以及你自己的 Linux、macOS 或 Windows 机器同样可以运行 Session。详见[沙箱提供商协议](https://openagentcore.dev/zh/docs/sandbox-provider)。
- **计算会休眠,工作区会保留。** 在 microsandbox 上(每个工作区都保存在独立存储中),空闲沙箱释放计算资源,并在兼容节点上带着文件恢复。E2B 会就地暂停空闲沙箱。Docker 以及你自己的 Linux、macOS 或 Windows 机器同样可以运行 Session。详见[沙箱提供商协议](https://openagentcore.dev/zh/docs/sandbox-provider)。
- **部件可替换。** 沙箱提供商、工作区文件系统、Harness 和模型提供商都通过[既定协议](AGENTS.md#protocols-at-every-boundary)接入。

![部署概览](docs/assets/console-overview-zh.webp)
Expand Down
13 changes: 7 additions & 6 deletions apps/daemon/internal/cli/connect.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,11 +69,12 @@ func runConnect(ctx *runContext, args []string) error {
return fmt.Errorf("connect: %w", err)
}
var bootstrapped *auth.Profile
var suspendEnabled bool
if *bootstrapFile != "" {
if *remote != "" || *environment != "" || *credentialFile != "" || *workspace != "" || fs.NArg() != 0 {
return errors.New("connect: bootstrap input cannot be combined with enrollment options")
}
bootstrapped, err = bootstrapProfile(*bootstrapFile)
bootstrapped, suspendEnabled, err = bootstrapProfile(*bootstrapFile)
if err != nil {
return err
}
Expand Down Expand Up @@ -118,7 +119,7 @@ func runConnect(ctx *runContext, args []string) error {
}
}

return mainLoop(ctx, *profile, prof, agentCLIs)
return mainLoop(ctx, *profile, prof, agentCLIs, suspendEnabled)
}

// spawnBackground forks the daemon into the background. Parent
Expand Down Expand Up @@ -189,11 +190,11 @@ func spawnBackground(ctx context.Context, rc *runContext, profile string, argv [
// background process. SIGINT / SIGTERM cancels the root context, which
// unblocks the read pump and any in-flight Send so the daemon exits
// without orphaning agent subprocesses.
func mainLoop(rc *runContext, profile string, prof auth.Profile, agentCLIs agentCLIDiscovery) error {
return mainLoopRemote(context.Background(), rc, profile, prof, agentCLIs, "")
func mainLoop(rc *runContext, profile string, prof auth.Profile, agentCLIs agentCLIDiscovery, suspend bool) error {
return mainLoopRemote(context.Background(), rc, profile, prof, agentCLIs, "", suspend)
}

func mainLoopRemote(parent context.Context, rc *runContext, profile string, prof auth.Profile, agentCLIs agentCLIDiscovery, remote string) error {
func mainLoopRemote(parent context.Context, rc *runContext, profile string, prof auth.Profile, agentCLIs agentCLIDiscovery, remote string, suspend bool) error {
// Route through obs/log so daemon log lines pick up the same
// trace_id / span_id auto-injection as the server side — when the
// daemon adopts an envelope's trace, every log call under that ctx
Expand Down Expand Up @@ -229,7 +230,7 @@ func mainLoopRemote(parent context.Context, rc *runContext, profile string, prof
registry := agent.NewRegistry()
registerAgentKinds(registry, agentCLIs)

control, err := newSuspendControl()
control, err := newSuspendControl(suspend)
if err != nil {
return err
}
Expand Down
10 changes: 5 additions & 5 deletions apps/daemon/internal/cli/connect_bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,17 @@ import (

// The launch file is the sole credential source for this connection. Reopening
// it on process restart never reads or overwrites an auth profile.
func bootstrapProfile(path string) (*auth.Profile, error) {
func bootstrapProfile(path string) (*auth.Profile, bool, error) {
raw, err := runtimefs.ReadPrivatePath(path, runtimebootstrap.MaxBytes)
if err != nil {
return nil, errors.New("connect: Runtime bootstrap file unavailable")
return nil, false, errors.New("connect: Runtime bootstrap file unavailable")
}
input, err := runtimebootstrap.Decode(raw)
if err != nil {
return nil, err
return nil, false, err
}
if err = localworkspace.Bind(input.EnvironmentID, input.SessionID, input.Workspace, localworkspace.CapabilityDirectory); err != nil {
return nil, errors.New("connect: Runtime workspace binding unavailable")
return nil, false, errors.New("connect: Runtime workspace binding unavailable")
}
return &auth.Profile{ServerURL: input.CoreURL, RuntimeID: input.DeviceID, RunnerCredential: input.Credential}, nil
return &auth.Profile{ServerURL: input.CoreURL, RuntimeID: input.DeviceID, RunnerCredential: input.Credential}, input.SuspendControl, nil
}
6 changes: 3 additions & 3 deletions apps/daemon/internal/cli/connect_bootstrap_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ func TestBootstrapConnectionDoesNotReadOrOverwritePrivateProfile(t *testing.T) {
}
t.Cleanup(localworkspace.Unbind)
for range 2 {
p, err := bootstrapProfile(path)
p, _, err := bootstrapProfile(path)
if err != nil || p.ServerURL != input.CoreURL || p.RuntimeID != input.DeviceID || p.RunnerCredential != input.Credential {
t.Fatal("failed bootstrap/restart", err)
}
Expand All @@ -60,13 +60,13 @@ func TestBootstrapConnectionDoesNotReadOrOverwritePrivateProfile(t *testing.T) {
if err = os.WriteFile(path, []byte("bootstrap-secret"), 0600); err != nil {
t.Fatal(err)
}
if _, err = bootstrapProfile(path); err == nil || strings.Contains(err.Error(), input.Credential) {
if _, _, err = bootstrapProfile(path); err == nil || strings.Contains(err.Error(), input.Credential) {
t.Fatal("invalid input fell back or leaked")
}
if err = os.Remove(path); err != nil {
t.Fatal(err)
}
if _, err = bootstrapProfile(path); err == nil {
if _, _, err = bootstrapProfile(path); err == nil {
t.Fatal("missing input fell back to private auth")
}
}
Expand Down
2 changes: 1 addition & 1 deletion apps/daemon/internal/cli/connect_environment.go
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ func runEnvironmentConnect(parent context.Context, rc *runContext, profile strin
return err
}
prof := auth.Profile{ServerURL: base, RuntimeID: bound.DeviceID, RunnerCredential: credential}
return rejected(mainLoopRemote(parent, rc, profile, prof, discovery, remote))
return rejected(mainLoopRemote(parent, rc, profile, prof, discovery, remote, false))
}

var (
Expand Down
17 changes: 9 additions & 8 deletions apps/daemon/internal/cli/suspend_control.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,9 @@ import (

"github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/localworkspace"
"github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto"
"github.com/MiniMax-AI/OpenAgentCore/internal/runtimebootstrap"
)

const suspendControlEnv = "OAC_RUNTIME_DAEMON_SUSPEND_PID_FILE"

type suspendIdentity struct {
PID int `json:"pid"`
StartTime string `json:"start_time"`
Expand All @@ -34,14 +33,16 @@ type suspendControl struct {
signal chan os.Signal
}

func newSuspendControl() (*suspendControl, error) {
path := os.Getenv(suspendControlEnv)
if path == "" {
// newSuspendControl publishes the control at runtimebootstrap.SuspendControlFile
// when the bootstrap enables it, and fails the connection when it cannot.
func newSuspendControl(enabled bool) (*suspendControl, error) {
if !enabled {
return nil, nil
}
if !filepath.IsAbs(path) || filepath.Clean(path) != path {
return nil, errors.New("connect: absolute suspend control file required")
}
return openSuspendControl(runtimebootstrap.SuspendControlFile)
}

func openSuspendControl(path string) (*suspendControl, error) {
info, err := os.Lstat(filepath.Dir(path))
if err != nil || !info.IsDir() || info.Mode().Perm()&0077 != 0 {
return nil, errors.New("connect: private suspend control directory required")
Expand Down
3 changes: 1 addition & 2 deletions apps/daemon/internal/cli/suspend_control_linux_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,12 @@ func TestResumeControlRejectsStaleIdentityAndDiscardsPreParkSignals(t *testing.T
t.Fatal(err)
}
path := filepath.Join(dir, "control.json")
t.Setenv(suspendControlEnv, path)
environment := uuid.NewString()
if err := localworkspace.Bind(environment, uuid.NewString(), t.TempDir(), t.TempDir()); err != nil {
t.Fatal(err)
}
t.Cleanup(localworkspace.Unbind)
control, err := newSuspendControl()
control, err := openSuspendControl(path)
if err != nil {
t.Fatal(err)
}
Expand Down
6 changes: 2 additions & 4 deletions apps/daemon/internal/cli/suspend_process_other.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,6 @@ import (
"os"
)

const suspendControlEnv = "OAC_RUNTIME_DAEMON_SUSPEND_PID_FILE"

type suspendIdentity struct {
EnvironmentID string
SuspendID string
Expand All @@ -21,8 +19,8 @@ type suspendControl struct {
lastResumed *proto.EnvironmentSuspendPayload
}

func newSuspendControl() (*suspendControl, error) {
if os.Getenv(suspendControlEnv) != "" {
func newSuspendControl(enabled bool) (*suspendControl, error) {
if enabled {
return nil, errors.New("hosted suspension requires Linux")
}
return nil, nil
Expand Down
8 changes: 4 additions & 4 deletions apps/web/e2e/fixture-console.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -82,18 +82,18 @@ function nodeRollout(previous = 0) {
}

function unconfiguredDeployment(generation = 0, ownerEpoch = 3) {
return { installation_id: INSTALLATION_ID, provider: "", credential_configured: false, core_url: publicUrl(), reset: null, rollout: noNodeRollout(), owner_epoch: ownerEpoch, generation, mode: "", resources: { allocations: 0, pending: 0 }, suspension: null };
return { installation_id: INSTALLATION_ID, provider: "", credential_configured: false, core_url: publicUrl(), reset: null, rollout: noNodeRollout(), owner_epoch: ownerEpoch, generation, mode: "", resources: { allocations: 0, running: 0, pending: 0 }, suspension: null };
}

function configuredDeployment() {
return { installation_id: INSTALLATION_ID, provider: "docker", credential_configured: false, configuration: {}, metadata: {}, core_url: publicUrl(), reset: null, rollout: nodeRollout(), owner_epoch: 3, generation: 1, mode: "nodes", resources: { allocations: 0, pending: 0 }, specification: { resources: { cpus: 2, memory_mib: 4096 } }, specification_digest: "fixture", suspension: null };
return { installation_id: INSTALLATION_ID, provider: "docker", credential_configured: false, configuration: {}, metadata: {}, core_url: publicUrl(), reset: null, rollout: nodeRollout(), owner_epoch: 3, generation: 1, mode: "nodes", resources: { allocations: 0, running: 0, pending: 0 }, specification: { resources: { cpus: 2, memory_mib: 4096 } }, specification_digest: "fixture", suspension: null };
}
/** The E2B template build as Core read it when the selection was saved. */
const templateBuild = { status: "ready", resources: { cpus: 2, memory_mib: 2048, root_disk_mib: 10240 } };

// E2B runs sandboxes in its cloud: no nodes, only what Core holds there.
function e2bDeployment() {
return { ...configuredDeployment(), provider: "e2b", mode: "direct", rollout: noNodeRollout(), resources: { allocations: 3, pending: 1 }, specification: { resources: { cpus: 2, memory_mib: 2048 } }, configuration: { template: "oac-runtime:0f1e2d3c-4b5a-6978-8a9b-0c1d2e3f4a5b", api_url: "https://api.e2b.app", domain: "e2b.app" } , credential_configured: true, metadata: { template_build: templateBuild } };
return { ...configuredDeployment(), provider: "e2b", mode: "direct", rollout: noNodeRollout(), resources: { allocations: 3, running: 3, pending: 1 }, specification: { resources: { cpus: 2, memory_mib: 2048 } }, configuration: { template: "oac-runtime:0f1e2d3c-4b5a-6978-8a9b-0c1d2e3f4a5b", api_url: "https://api.e2b.app", domain: "e2b.app" } , credential_configured: true, metadata: { template_build: templateBuild } };
}

function reset(mode = "login", fresh = false, sandbox = "configured", nodes = "demo", address = "public", installers = true, artifacts = "docker,microsandbox") {
Expand Down Expand Up @@ -419,7 +419,7 @@ async function sandboxRoute(request, response, path, url) {
if (!initialize && !e2b) {
for (const node of state.nodes) node.rollout = { state: node.online ? "preparing" : "unknown", ready_generation: node.rollout.ready_generation };
}
const held = initialize ? { allocations: 0, pending: 0 } : previous.resources;
const held = initialize ? { allocations: 0, running: 0, pending: 0 } : previous.resources;
state.deployment = {
...configuredDeployment(), provider: input.provider, mode: e2b ? "direct" : "nodes",
generation: previous.generation + 1, owner_epoch: previous.owner_epoch,
Expand Down
2 changes: 1 addition & 1 deletion apps/web/e2e/nodes.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -315,7 +315,7 @@ test("shows the retained E2B build while a replacement key is checked", async ({
test("edits only the saved backend, preserving a custom size", async ({ page, request }) => {
const current = { resources: { cpus: 7, memory_mib: 8192 } };
let deployment = { configuration: {}, metadata: {}, credential_configured: false, installation_id: "94be54a1-138c-4f30-bc87-b13686272dbe", provider: "docker", core_url: "https://core.example", reset: null, rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: { ready: 0, preparing: 0, failed: 0, unknown: 0 } },
owner_epoch: 1, generation: 1, mode: "nodes", resources: { allocations: 0, pending: 0 }, specification: current, specification_digest: "e".repeat(64),
owner_epoch: 1, generation: 1, mode: "nodes", resources: { allocations: 0, running: 0, pending: 0 }, specification: current, specification_digest: "e".repeat(64),
suspension: null };
let submitted: Record<string, unknown> | null = null;
await page.route("**/core/v1/sandbox/deployment", async (route) => {
Expand Down
2 changes: 1 addition & 1 deletion apps/web/e2e/sandbox-generation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ test("online configuration retains existing resources and stops rapid polling wh
await expect(rollout(page)).toBeVisible();
const before = await inventory(page);
expect(before.allocations.length).toBeGreaterThan(0);
await setDeployment(request, { resources: { allocations: before.allocations.length, pending: 0 } });
await setDeployment(request, { resources: { allocations: before.allocations.length, running: before.allocations.length, pending: 0 } });
await page.getByRole("button", { name: "Refresh sandbox state", exact: true }).click();
await page.getByRole("button", { name: "Change resources", exact: true }).click();
await page.getByRole("button", { name: /^Large/ }).click();
Expand Down
Loading
Loading