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
106 changes: 106 additions & 0 deletions apps/daemon/internal/agent/codex/native_state_identity_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package codex

import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"os/exec"

"github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto"
"strings"
"sync/atomic"
"testing"
"time"
)

// Operational rehearsal against pinned native Codex and a synthetic local provider.
func TestNativeStateIdentitySurvivesExecutorRestart(t *testing.T) {
binary := os.Getenv("OAC_TEST_CODEX_BINARY")
if binary == "" {
t.Skip("pinned native required")
}
version, err := exec.Command(binary, "--version").Output()
if err != nil || strings.TrimSpace(string(version)) != "codex-cli 0.153.4" {
t.Fatal("requires pinned Codex 0.153.4")
}
var attempts atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
n := attempts.Add(1)
var body map[string]any
if json.NewDecoder(r.Body).Decode(&body) != nil {
t.Error("request decode")
w.WriteHeader(400)
return
}
raw, _ := json.Marshal(body)
if n == 2 && !strings.Contains(string(raw), "NAMESPACE-RETAIN-729") {
t.Error("resumed request lost original conversation input")
}
w.Header().Set("Content-Type", "text/event-stream")
item := map[string]any{"id": fmt.Sprintf("msg_%d", n), "type": "message", "role": "assistant", "status": "completed", "content": []any{map[string]any{"type": "output_text", "text": "SAVED", "annotations": []any{}}}}
response := map[string]any{"id": fmt.Sprintf("resp_%d", n), "object": "response", "status": "completed", "output": []any{item}, "usage": map[string]any{"input_tokens": 10, "output_tokens": 2, "total_tokens": 12, "input_tokens_details": map[string]int{"cached_tokens": 0}}}
for _, event := range []map[string]any{{"type": "response.created", "response": map[string]any{"id": response["id"], "status": "in_progress", "output": []any{}}}, {"type": "response.output_item.added", "output_index": 0, "item": item}, {"type": "response.output_text.delta", "item_id": item["id"], "output_index": 0, "content_index": 0, "delta": "SAVED"}, {"type": "response.output_item.done", "output_index": 0, "item": item}, {"type": "response.completed", "response": response}} {
data, _ := json.Marshal(event)
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event["type"], data)
}
}))
defer server.Close()
t.Setenv("OAC_RUNTIME_HOME", t.TempDir())
for _, name := range []string{"CODEX_EXEC_SERVER_URL", "CODEX_EXEC_SERVER_NOISE_REGISTRY_URL", "CODEX_EXEC_SERVER_NOISE_ENVIRONMENT_ID", "CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN"} {
t.Setenv(name, "")
}
root := testStateRoot(t)
cfg := defaultSessionConfig(root)
cfg.codexBinary = binary
cfg.logger = slog.New(slog.DiscardHandler)
id := "e4dd7710-9dab-449d-b6ec-ab0f74d05c2a"
req := proto.PromptRequestPayload{AgentKind: "codex", AgentStateKey: "agents-api-" + id, DisableExecutionEnvironment: true, DisableSubagents: true, AgentOptions: map[string]any{"model": "gpt-5.5", "model_provider": map[string]any{"protocol": "responses", "base_url": server.URL + "/v1", "api_key": "fixture"}}}
ctx, cancel := context.WithTimeout(t.Context(), 60*time.Second)
defer cancel()
for i := 0; i < 2; i++ {
e, err := newExecutor(ctx, req, cfg)
if err != nil {
t.Fatalf("prepare phase %d: %v", i, err)
}
output := make(chan proto.Envelope, 256)
prompt := "Remember NAMESPACE-RETAIN-729."
if i == 1 {
prompt = "Return the previous code."
}
turn, err := e.StartTurn(ctx, fmt.Sprintf("ops-%d", i), proto.TextInput(prompt), output)
if err != nil {
t.Fatal(err)
}
for event := range output {
if event.Type == proto.TypeError {
t.Fatal("native error")
}
}
if _, err := turn.AwaitSettlement(ctx); err != nil {
t.Fatal(err)
}
native := turn.(*Session).currentThreadID()
if native == "" {
t.Fatal("missing native identity")
}
if i == 1 && native != req.AgentSessionID {
t.Fatal("native identity changed")
}
if err = e.Close(ctx); err != nil {
t.Fatal(err)
}
if i == 0 {
req.AgentSessionID = native
req.RequireExistingNativeSession = true
req.AgentStateKey = proto.SessionStateKey(id)
}
}
if attempts.Load() != 2 {
t.Fatalf("expected exactly two inputs, got %d", attempts.Load())
}
t.Log("same native thread, original input retained, exactly two inputs with canonical namespace preserved")
}
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ func TestPreparationRouterRetainsActualNativeChild(t *testing.T) {
if err != nil {
t.Fatal(err)
}
req.AgentStateKey = session
req.AgentStateKey = proto.SessionStateKey(session)
req.DisableExecutionEnvironment = false
req.LocalEnvironment = &proto.LocalEnvironment{ID: environment, WorkspaceDirectory: "/workspace", CapabilitySources: &agentcapabilities.Input{}}
registry := agent.NewRegistry()
Expand Down
3 changes: 2 additions & 1 deletion apps/daemon/internal/agent/mcode/tool_environment_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package mcode

import (
"bytes"
"github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto"
"io/fs"
"os"
"path/filepath"
Expand Down Expand Up @@ -37,7 +38,7 @@ func TestWorkspaceCredentialsRemainInRuntimeSnapshotAcrossReconnect(t *testing.T
t.Setenv("OAC_RUNTIME_SESSION_ID", session)
t.Setenv("OAC_RUNTIME_WORKSPACE", config.Directory)
t.Setenv("OAC_RUNTIME_CAPABILITY_DIRECTORY", t.TempDir())
req.AgentStateKey = session
req.AgentStateKey = proto.SessionStateKey(session)
req.LocalEnvironment.ID, req.LocalEnvironment.WorkspaceDirectory = environment, config.Directory
req.LocalEnvironment.Capabilities = true
req.LocalEnvironment.CapabilitySources = &agentcapabilities.Input{Directories: []string{plugin}}
Expand Down
2 changes: 1 addition & 1 deletion apps/daemon/internal/cli/connect_cleanup_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ func testDisconnectedPumpCleanup(t *testing.T, suspend bool) {
}
defer peer.Close()
env, err := proto.NewEnvelope(proto.TypeExecutionPrepare, "prepare", proto.ExecutionPreparePayload{SessionID: "cleanup",
Configuration: proto.PromptRequestPayload{AgentKind: "cleanup", AgentStateKey: "cleanup", DisableExecutionEnvironment: true}})
Configuration: proto.PromptRequestPayload{AgentKind: "cleanup", AgentStateKey: proto.SessionStateKey("cleanup"), DisableExecutionEnvironment: true}})
if err != nil {
t.Fatal(err)
}
Expand Down
2 changes: 1 addition & 1 deletion apps/daemon/internal/cli/connect_suspend_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -383,7 +383,7 @@ func TestQuiesceRetainsExecutorAcrossReconnectAndShutdownConfirmsCleanup(t *test
t.Fatal("no connection")
}
defer peer.Close()
preparation, err := proto.NewEnvelope(proto.TypeExecutionPrepare, "prepare", proto.ExecutionPreparePayload{SessionID: session, Configuration: proto.PromptRequestPayload{AgentKind: "cleanup", AgentStateKey: session, LocalEnvironment: &proto.LocalEnvironment{ID: environment, WorkspaceDirectory: "/workspace", CapabilitySources: &agentcapabilities.Input{}}}})
preparation, err := proto.NewEnvelope(proto.TypeExecutionPrepare, "prepare", proto.ExecutionPreparePayload{SessionID: session, Configuration: proto.PromptRequestPayload{AgentKind: "cleanup", AgentStateKey: proto.SessionStateKey(session), LocalEnvironment: &proto.LocalEnvironment{ID: environment, WorkspaceDirectory: "/workspace", CapabilitySources: &agentcapabilities.Input{}}}})
if err != nil {
t.Fatal(err)
}
Expand Down
2 changes: 1 addition & 1 deletion apps/daemon/internal/dispatch/executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ func executorFingerprint(req proto.PromptRequestPayload) ([32]byte, error) {

func (r *Router) handleExecutorPrepare(ctx context.Context, env proto.Envelope, input proto.ExecutionPreparePayload) error {
req := input.Configuration
if strings.TrimSpace(input.SessionID) == "" || req.RunID != "" || len(req.Input) != 0 || req.AgentStateKey != input.SessionID {
if strings.TrimSpace(input.SessionID) == "" || req.RunID != "" || len(req.Input) != 0 || req.AgentStateKey != proto.SessionStateKey(input.SessionID) {
return r.rejectPreparation(env, "invalid_configuration")
}
caps, available := r.availableCapabilities(req.AgentKind)
Expand Down
2 changes: 1 addition & 1 deletion apps/daemon/internal/dispatch/executor_handoff_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ func TestPreparedDonePublishesAfterExecutorHandoff(t *testing.T) {
}
}
}
request := proto.ExecutionPreparePayload{SessionID: "session", Configuration: proto.PromptRequestPayload{AgentKind: "handoff", AgentStateKey: "session", DisableExecutionEnvironment: true}}
request := proto.ExecutionPreparePayload{SessionID: "session", Configuration: proto.PromptRequestPayload{AgentKind: "handoff", AgentStateKey: proto.SessionStateKey("session"), DisableExecutionEnvironment: true}}
admit := func(id string) proto.PreparationStatusPayload {
t.Helper()
handle(proto.TypeExecutionPrepare, id, request)
Expand Down
4 changes: 2 additions & 2 deletions apps/daemon/internal/dispatch/executor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ func (t *reusableTurn) AwaitSettlement(ctx context.Context) (agent.TurnSettlemen

// noEnvironmentPreparation prepares config for session without an execution environment.
func noEnvironmentPreparation(session string, config proto.PromptRequestPayload) proto.ExecutionPreparePayload {
config.AgentStateKey, config.DisableExecutionEnvironment = session, true
config.AgentStateKey, config.DisableExecutionEnvironment = proto.SessionStateKey(session), true
return proto.ExecutionPreparePayload{SessionID: session, Configuration: config}
}
func executorRequest() proto.ExecutionPreparePayload {
Expand Down Expand Up @@ -247,7 +247,7 @@ func poolRouter(t *testing.T, factory agent.ExecutorFactory) (*dispatch.Router,
func poolRequest(id string) proto.ExecutionPreparePayload {
req := executorRequest()
req.SessionID = id
req.Configuration.AgentStateKey = id
req.Configuration.AgentStateKey = proto.SessionStateKey(id)
return req
}

Expand Down
4 changes: 2 additions & 2 deletions apps/daemon/internal/dispatch/local_directory_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ func TestLocalDirectoryPreparationNeedsNoHarnessAndRejectsOtherOwners(t *testing
t.Fatal(err)
}
t.Cleanup(func() { _ = r.Shutdown(context.Background()) })
request := proto.PromptRequestPayload{AgentKind: "native", LocalEnvironment: &proto.LocalEnvironment{ID: environment}, AgentStateKey: session, WorkspaceReadOnly: true}
request := proto.PromptRequestPayload{AgentKind: "native", LocalEnvironment: &proto.LocalEnvironment{ID: environment}, AgentStateKey: proto.SessionStateKey(session), WorkspaceReadOnly: true}
if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "idle", proto.ExecutionPreparePayload{Configuration: request})); err != nil {
t.Fatal(err)
}
Expand Down Expand Up @@ -118,7 +118,7 @@ func TestLocalDirectoryKeepsNotDirectorySeparateFromFailures(t *testing.T) {
t.Fatal(err)
}
t.Cleanup(func() { _ = r.Shutdown(context.Background()) })
request := proto.PromptRequestPayload{AgentKind: "native", LocalEnvironment: &proto.LocalEnvironment{ID: environment}, AgentStateKey: session, WorkspaceReadOnly: true}
request := proto.PromptRequestPayload{AgentKind: "native", LocalEnvironment: &proto.LocalEnvironment{ID: environment}, AgentStateKey: proto.SessionStateKey(session), WorkspaceReadOnly: true}
if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "idle", proto.ExecutionPreparePayload{Configuration: request})); err != nil {
t.Fatal(err)
}
Expand Down
2 changes: 1 addition & 1 deletion apps/daemon/internal/dispatch/preparation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ func localPreparationHarness(t *testing.T) *harness {
}

func preparationRequest() proto.ExecutionPreparePayload {
return proto.ExecutionPreparePayload{SessionID: preparationSessionID, Configuration: proto.PromptRequestPayload{AgentKind: "prepared", AgentStateKey: preparationSessionID, LocalEnvironment: &proto.LocalEnvironment{ID: preparationEnvironmentID, WorkspaceDirectory: "/workspace", CapabilitySources: &agentcapabilities.Input{}}}}
return proto.ExecutionPreparePayload{SessionID: preparationSessionID, Configuration: proto.PromptRequestPayload{AgentKind: "prepared", AgentStateKey: proto.SessionStateKey(preparationSessionID), LocalEnvironment: &proto.LocalEnvironment{ID: preparationEnvironmentID, WorkspaceDirectory: "/workspace", CapabilitySources: &agentcapabilities.Input{}}}}
}

func preparationRouter(t *testing.T, sender dispatch.Sender, timeout time.Duration, factory preparationFactory) *dispatch.Router {
Expand Down
2 changes: 1 addition & 1 deletion apps/daemon/internal/localworkspace/binding.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,5 +83,5 @@ func (b *Binding) Configure(r proto.PromptRequestPayload) (proto.PromptRequestPa
}

func (b *Binding) Matches(environment, session string) bool {
return b != nil && b.environment == environment && b.stateKey == session
return b != nil && b.environment == environment && b.stateKey == proto.SessionStateKey(session)
}
2 changes: 1 addition & 1 deletion apps/daemon/internal/localworkspace/binding_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ func testBinding(t *testing.T) (*Binding, proto.PromptRequestPayload) {
if err != nil {
t.Fatal(err)
}
return b, proto.PromptRequestPayload{LocalEnvironment: &proto.LocalEnvironment{ID: environment, WorkspaceDirectory: "/workspace", CapabilitySources: &agentcapabilities.Input{}}, AgentStateKey: session}
return b, proto.PromptRequestPayload{LocalEnvironment: &proto.LocalEnvironment{ID: environment, WorkspaceDirectory: "/workspace", CapabilitySources: &agentcapabilities.Input{}}, AgentStateKey: proto.SessionStateKey(session)}
}

func TestBindingRejectsScopeOverrides(t *testing.T) {
Expand Down
2 changes: 1 addition & 1 deletion apps/daemon/internal/localworkspace/capabilities.go
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ func (b *Binding) loadCapabilitySnapshot(root *os.Root, input agentcapabilities.
}

func (b *Binding) capabilityIdentity() agentcapabilities.Identity {
return agentcapabilities.Identity{EnvironmentID: b.environment, SessionID: b.stateKey}
return agentcapabilities.Identity{EnvironmentID: b.environment, SessionID: strings.TrimPrefix(b.stateKey, proto.SessionStateKey(""))}
}

// The current Linux Runtime layout is shared by user-owned and managed hosts.
Expand Down
3 changes: 2 additions & 1 deletion apps/daemon/internal/localworkspace/native_binding.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"os"
"path/filepath"

"github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto"
"github.com/MiniMax-AI/OpenAgentCore/internal/runtimefs"
"github.com/google/uuid"
)
Expand All @@ -25,7 +26,7 @@ func newNativeBinding(environment, session, workspace, capabilities string) (*Bi
if err != nil || !info.IsDir() {
return nil, errors.New("local workspace root must be an existing directory")
}
return &Binding{environment: environment, stateKey: session, workspace: workspace, capabilityRoot: capabilities, writer: &fileWriter{}}, nil
return &Binding{environment: environment, stateKey: proto.SessionStateKey(session), workspace: workspace, capabilityRoot: capabilities, writer: &fileWriter{}}, nil
}

// ReadToolEnvironment reads explicit initialization values for this installation.
Expand Down
2 changes: 1 addition & 1 deletion apps/daemon/internal/localworkspace/write.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ const WriteMaxBytes = proto.WorkspaceWriteMaxBytes
var _ agent.WorkspaceWriter = (*Binding)(nil)

func (b *Binding) AcceptsFileWrite(environment, session string) bool {
return b != nil && b.writer != nil && b.environment == environment && b.stateKey == session
return b != nil && b.writer != nil && b.environment == environment && b.stateKey == proto.SessionStateKey(session)
}

// WriteWorkspaceFile starts only after the caller supplies the complete bounded
Expand Down
2 changes: 2 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,8 @@ The initialization service generates secrets and the installation ID once, then

Initialization prepares this directory; application services receive their secret directories read-only. `docker compose exec web oac-web core-key` prints the Core key to the operator terminal without writing it to container logs. Database passwords and credential encryption keys are never printed.

Credential encryption domains, keyed-fingerprint derivation domains and request-fingerprint purposes are persistent format identifiers. They remain fixed independently of product naming; changing them invalidates stored ciphertext, idempotency identities or signed authorizations.

The named Docker volume `<project>_data` contains these paths. Docker manages Linux ownership on every host; each service mounts only its required subdirectories. Preserve this volume together with the project definition and public URL. Removing only the secret directories does not reset an installation; initialization refuses to start over an existing database. Core also binds the installation ID to its database. Runtime settings continue to live in [Core's database](#runtime-settings-web).

## Docker node configuration
Expand Down
2 changes: 2 additions & 0 deletions docs/runtime-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ Core has no admission rule for `usage` and `resume`.

`retained_native_history` means the composed Runtime and Harness can reopen the same native Session from its private retained state after confirmed native shutdown. It does not promise process memory, background processes or external connection recovery. The selected filesystem must separately provide retained storage. Core uses the previous device's persisted declaration to determine whether compute can be released without terminating the Session, and validates the replacement Runtime before admitting execution. Missing or invalid native history fails explicitly; it never authorizes a new native root or replay of prior input.

Core derives `agent_state_key` with `SessionStateKey(session_id)`: the stable `agents-api-` prefix followed by the Session ID. The Runtime uses the same definition for dedicated Environment binding. This namespace names native history, including indexes containing absolute paths; it does not change with product names or wire versions.

## Envelope and identity

Every data frame is one JSON [`Envelope`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/agentdaemon/proto/envelope.go): `type`, a type-dependent `id`, a typed `payload` and an optional W3C `trace`. The trace is diagnostic correlation only; missing or invalid trace data creates a local trace and never changes ownership. Never use a trace ID as a request ID.
Expand Down
4 changes: 3 additions & 1 deletion docs/zh/configuration.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
title: "配置参考"
source: docs/configuration.md
source_hash: f59a53edb908b1a7e884e598fbb6eb85d40941110e0de60014c14de3b929aaf9
source_hash: 00ca99dc2f53357520a0d23b4950ec4bfb63a3a6bb6ef8b57cb752781ca1e8e4
---

Core 安装的每项设置、其归属位置以及修改方式。安装程序写入初始值;之后请使用本参考。每项设置都恰好只有一个归属位置,分属以下三类:
Expand Down Expand Up @@ -114,6 +114,8 @@ Session 创建可以在异步工作区准入之前成功。容量耗尽时,Cor

初始化会准备该目录;应用服务以只读方式接收各自的机密目录。`docker compose exec web oac-web core-key` 把 Core 密钥打印到运维人员终端,不写入容器日志。数据库密码和凭据加密密钥绝不打印。

凭据加密域、带密钥指纹的派生域和请求指纹用途标识属于持久化格式,不随产品名称变化;修改它们会使已有密文、幂等身份或签名授权失效。

上述路径位于 Docker 命名卷 `<project>_data` 中。所有主机平台都由 Docker 管理 Linux 文件权限;每个服务只挂载需要的子目录。数据卷必须和项目定义、公共 URL 一同保留。仅删除机密目录不会重置安装;数据库已存在时初始化会拒绝重建。Core 也会校验安装 ID 与数据库的绑定。运行时设置仍保存在 [Core 数据库](#runtime-settings-web)中。

## Docker 节点配置 {#docker-node-configuration}
Expand Down
4 changes: 3 additions & 1 deletion docs/zh/runtime-protocol.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
title: "Core–Runtime 协议"
source: docs/runtime-protocol.md
source_hash: 6d12d0da86e8f5a14ac283baf79e49902027e0510e95a9d70cca998f8d2584e9
source_hash: 53bb7a63eac869adc892d9443bbfb8297e1d904108c4114fc870dd21890c7c82
---

Core–Runtime 协议连接 Core 与 Runtime daemon,定义 daemon 连接上消息的含义和顺序。托管和自托管 Runtime 使用同一协议。
Expand Down Expand Up @@ -71,6 +71,8 @@ wire 上每个字段都是 JSON boolean,所有字段都必须出现,包括 `

`retained_native_history` 表示 Runtime 与 Harness 组合可在确认原生进程关闭后,从私有持久状态重新打开同一个原生 Session。它不保证恢复进程内存、后台进程或外部连接。所选文件系统还必须独立提供持久存储。Core 根据旧设备已持久化的声明判断能否释放计算而不终结 Session,并在准入执行前验证新 Runtime。原生历史缺失或无效时明确失败,绝不授权新建原生会话或重放此前输入。

Core 使用 `SessionStateKey(session_id)` 派生 `agent_state_key`:固定的 `agents-api-` 前缀加 Session ID。Runtime 的专属 Environment 绑定使用同一定义。该命名空间标识原生历史,包含记录绝对路径的索引,不随产品名称或通信版本变化。

Core 对 `usage` 和 `resume` 没有准入规则。

## Envelope 与身份 {#envelope-and-identity}
Expand Down
4 changes: 4 additions & 0 deletions internal/agentdaemon/proto/outbound.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,3 +111,7 @@ func (r PromptRequestPayload) ValidateAgentOptions() error {
}
return nil
}

// SessionStateKey names persisted native history. Its prefix is a storage identity,
// independent of product naming and the Runtime wire version.
func SessionStateKey(sessionID string) string { return "agents-api-" + sessionID }
Loading