diff --git a/apps/daemon/internal/agent/codex/native_state_identity_test.go b/apps/daemon/internal/agent/codex/native_state_identity_test.go new file mode 100644 index 000000000..ff434d5aa --- /dev/null +++ b/apps/daemon/internal/agent/codex/native_state_identity_test.go @@ -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") +} diff --git a/apps/daemon/internal/agent/codex/preparation_router_test.go b/apps/daemon/internal/agent/codex/preparation_router_test.go index f1a3086d3..18d031f80 100644 --- a/apps/daemon/internal/agent/codex/preparation_router_test.go +++ b/apps/daemon/internal/agent/codex/preparation_router_test.go @@ -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() diff --git a/apps/daemon/internal/agent/mcode/tool_environment_test.go b/apps/daemon/internal/agent/mcode/tool_environment_test.go index 66ffa720e..5fa31ea5a 100644 --- a/apps/daemon/internal/agent/mcode/tool_environment_test.go +++ b/apps/daemon/internal/agent/mcode/tool_environment_test.go @@ -2,6 +2,7 @@ package mcode import ( "bytes" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "io/fs" "os" "path/filepath" @@ -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}} diff --git a/apps/daemon/internal/cli/connect_cleanup_test.go b/apps/daemon/internal/cli/connect_cleanup_test.go index e84fd7dd2..49b7df233 100644 --- a/apps/daemon/internal/cli/connect_cleanup_test.go +++ b/apps/daemon/internal/cli/connect_cleanup_test.go @@ -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) } diff --git a/apps/daemon/internal/cli/connect_suspend_test.go b/apps/daemon/internal/cli/connect_suspend_test.go index 34cf456c8..9b290ad80 100644 --- a/apps/daemon/internal/cli/connect_suspend_test.go +++ b/apps/daemon/internal/cli/connect_suspend_test.go @@ -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) } diff --git a/apps/daemon/internal/dispatch/executor.go b/apps/daemon/internal/dispatch/executor.go index 113458736..e8617f5d7 100644 --- a/apps/daemon/internal/dispatch/executor.go +++ b/apps/daemon/internal/dispatch/executor.go @@ -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) diff --git a/apps/daemon/internal/dispatch/executor_handoff_test.go b/apps/daemon/internal/dispatch/executor_handoff_test.go index 31f0b9ed3..20abe7293 100644 --- a/apps/daemon/internal/dispatch/executor_handoff_test.go +++ b/apps/daemon/internal/dispatch/executor_handoff_test.go @@ -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) diff --git a/apps/daemon/internal/dispatch/executor_test.go b/apps/daemon/internal/dispatch/executor_test.go index e5ed86a39..de643a8f9 100644 --- a/apps/daemon/internal/dispatch/executor_test.go +++ b/apps/daemon/internal/dispatch/executor_test.go @@ -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 { @@ -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 } diff --git a/apps/daemon/internal/dispatch/local_directory_test.go b/apps/daemon/internal/dispatch/local_directory_test.go index 7ac10720f..766c0244d 100644 --- a/apps/daemon/internal/dispatch/local_directory_test.go +++ b/apps/daemon/internal/dispatch/local_directory_test.go @@ -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) } @@ -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) } diff --git a/apps/daemon/internal/dispatch/preparation_test.go b/apps/daemon/internal/dispatch/preparation_test.go index 41f97ded1..28d532fd9 100644 --- a/apps/daemon/internal/dispatch/preparation_test.go +++ b/apps/daemon/internal/dispatch/preparation_test.go @@ -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 { diff --git a/apps/daemon/internal/localworkspace/binding.go b/apps/daemon/internal/localworkspace/binding.go index 1dfdd84e5..93a6a68d9 100644 --- a/apps/daemon/internal/localworkspace/binding.go +++ b/apps/daemon/internal/localworkspace/binding.go @@ -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) } diff --git a/apps/daemon/internal/localworkspace/binding_test.go b/apps/daemon/internal/localworkspace/binding_test.go index 321f092b3..06ba291fe 100644 --- a/apps/daemon/internal/localworkspace/binding_test.go +++ b/apps/daemon/internal/localworkspace/binding_test.go @@ -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) { diff --git a/apps/daemon/internal/localworkspace/capabilities.go b/apps/daemon/internal/localworkspace/capabilities.go index 4199aac3f..096933020 100644 --- a/apps/daemon/internal/localworkspace/capabilities.go +++ b/apps/daemon/internal/localworkspace/capabilities.go @@ -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. diff --git a/apps/daemon/internal/localworkspace/native_binding.go b/apps/daemon/internal/localworkspace/native_binding.go index 02c57adae..85e10a7c0 100644 --- a/apps/daemon/internal/localworkspace/native_binding.go +++ b/apps/daemon/internal/localworkspace/native_binding.go @@ -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" ) @@ -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. diff --git a/apps/daemon/internal/localworkspace/write.go b/apps/daemon/internal/localworkspace/write.go index 062f49330..afaed2199 100644 --- a/apps/daemon/internal/localworkspace/write.go +++ b/apps/daemon/internal/localworkspace/write.go @@ -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 diff --git a/docs/configuration.md b/docs/configuration.md index 70eb1d2d8..ed3b99d54 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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 `_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 diff --git a/docs/runtime-protocol.md b/docs/runtime-protocol.md index 491a65403..c324e8999 100644 --- a/docs/runtime-protocol.md +++ b/docs/runtime-protocol.md @@ -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. diff --git a/docs/zh/configuration.md b/docs/zh/configuration.md index c9ab6ea5c..3f3b6240b 100644 --- a/docs/zh/configuration.md +++ b/docs/zh/configuration.md @@ -1,7 +1,7 @@ --- title: "配置参考" source: docs/configuration.md -source_hash: f59a53edb908b1a7e884e598fbb6eb85d40941110e0de60014c14de3b929aaf9 +source_hash: 00ca99dc2f53357520a0d23b4950ec4bfb63a3a6bb6ef8b57cb752781ca1e8e4 --- Core 安装的每项设置、其归属位置以及修改方式。安装程序写入初始值;之后请使用本参考。每项设置都恰好只有一个归属位置,分属以下三类: @@ -114,6 +114,8 @@ Session 创建可以在异步工作区准入之前成功。容量耗尽时,Cor 初始化会准备该目录;应用服务以只读方式接收各自的机密目录。`docker compose exec web oac-web core-key` 把 Core 密钥打印到运维人员终端,不写入容器日志。数据库密码和凭据加密密钥绝不打印。 +凭据加密域、带密钥指纹的派生域和请求指纹用途标识属于持久化格式,不随产品名称变化;修改它们会使已有密文、幂等身份或签名授权失效。 + 上述路径位于 Docker 命名卷 `_data` 中。所有主机平台都由 Docker 管理 Linux 文件权限;每个服务只挂载需要的子目录。数据卷必须和项目定义、公共 URL 一同保留。仅删除机密目录不会重置安装;数据库已存在时初始化会拒绝重建。Core 也会校验安装 ID 与数据库的绑定。运行时设置仍保存在 [Core 数据库](#runtime-settings-web)中。 ## Docker 节点配置 {#docker-node-configuration} diff --git a/docs/zh/runtime-protocol.md b/docs/zh/runtime-protocol.md index c0d1e047a..8f518f6fb 100644 --- a/docs/zh/runtime-protocol.md +++ b/docs/zh/runtime-protocol.md @@ -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 使用同一协议。 @@ -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} diff --git a/internal/agentdaemon/proto/outbound.go b/internal/agentdaemon/proto/outbound.go index 106fe054a..2e17b324d 100644 --- a/internal/agentdaemon/proto/outbound.go +++ b/internal/agentdaemon/proto/outbound.go @@ -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 } diff --git a/internal/agentdaemon/proto/prototest/wire.go b/internal/agentdaemon/proto/prototest/wire.go index 82142f3b2..7833863c9 100644 --- a/internal/agentdaemon/proto/prototest/wire.go +++ b/internal/agentdaemon/proto/prototest/wire.go @@ -27,7 +27,7 @@ const ( // Correlation values the scenarios use. const ( SessionID = "session" - StateKey = "session" + StateKey = "agents-api-session" PreparationID = "prepare" RunID = "run" CancelDeliveryID = "cancel" diff --git a/internal/agentdaemon/proto/session_state_key_test.go b/internal/agentdaemon/proto/session_state_key_test.go new file mode 100644 index 000000000..db85c4ba1 --- /dev/null +++ b/internal/agentdaemon/proto/session_state_key_test.go @@ -0,0 +1,9 @@ +package proto + +import "testing" + +func TestSessionStateKeyIsDurable(t *testing.T) { + if SessionStateKey("e4dd7710-9dab-449d-b6ec-ab0f74d05c2a") != "agents-api-e4dd7710-9dab-449d-b6ec-ab0f74d05c2a" { + t.Fatal("native history identity changed") + } +} diff --git a/services/core/internal/credentialcrypto/agent_model_execution.go b/services/core/internal/credentialcrypto/agent_model_execution.go index 2f8e2fad7..05ab2ac7f 100644 --- a/services/core/internal/credentialcrypto/agent_model_execution.go +++ b/services/core/internal/credentialcrypto/agent_model_execution.go @@ -9,7 +9,7 @@ func agentModelExecutionData(tenantID, agentID string) ([]byte, error) { if tenantID == "" || agentID == "" || !utf8.ValidString(tenantID) || !utf8.ValidString(agentID) { return nil, errInvalidBinding } - return json.Marshal([]string{"oac.agent-model-execution.v1", tenantID, agentID}) + return json.Marshal([]string{"parsar.agents-api.agent-model-execution.v1", tenantID, agentID}) } func (c *Cipher) SealAgentModelExecution(plaintext []byte, tenantID, agentID string) ([]byte, error) { diff --git a/services/core/internal/credentialcrypto/cipher.go b/services/core/internal/credentialcrypto/cipher.go index e1cbc40ce..8a9136545 100644 --- a/services/core/internal/credentialcrypto/cipher.go +++ b/services/core/internal/credentialcrypto/cipher.go @@ -15,7 +15,7 @@ import ( const ( formatVersion byte = 1 - bindingDomain = "oac.credential" + bindingDomain = "parsar.agents-api.credential" ) var ( @@ -55,7 +55,7 @@ func New(key []byte) (*Cipher, error) { return nil, errUnavailable } derive := hmac.New(sha256.New, key) - derive.Write([]byte("oac.credential-fingerprint.v1")) + derive.Write([]byte("parsar.agents-api.credential-fingerprint.v1")) return &Cipher{aead: aead, fingerprint: derive.Sum(nil)}, nil } diff --git a/services/core/internal/credentialcrypto/deployment_model_provider.go b/services/core/internal/credentialcrypto/deployment_model_provider.go index 8c5dd8e49..07db34fc9 100644 --- a/services/core/internal/credentialcrypto/deployment_model_provider.go +++ b/services/core/internal/credentialcrypto/deployment_model_provider.go @@ -12,7 +12,7 @@ func deploymentModelProviderData(harness string) ([]byte, error) { if harness == "" || !utf8.ValidString(harness) { return nil, errInvalidBinding } - return json.Marshal([]string{"oac.deployment-model-provider.v1", harness}) + return json.Marshal([]string{"parsar.agents-api.deployment-model-provider.v1", harness}) } func (c *Cipher) SealDeploymentModelProvider(plaintext []byte, harness string) ([]byte, error) { diff --git a/services/core/internal/credentialcrypto/durable_format_test.go b/services/core/internal/credentialcrypto/durable_format_test.go new file mode 100644 index 000000000..19f88a8d2 --- /dev/null +++ b/services/core/internal/credentialcrypto/durable_format_test.go @@ -0,0 +1,63 @@ +package credentialcrypto + +import ( + "bytes" + "encoding/hex" + "encoding/json" + "os" + "testing" +) + +// Fixtures were generated with public synthetic plaintext and a fixed test key +// by source 4b16ddbbfe5ad5984547e54d60bb9a89c7c79d81, not by the implementation under test. +func TestDurableFormatV1(t *testing.T) { + data, err := os.ReadFile("testdata/durable_format_v1.json") + if err != nil { + t.Fatal(err) + } + var fixtures map[string]string + if err = json.Unmarshal(data, &fixtures); err != nil { + t.Fatal(err) + } + c, err := New(bytes.Repeat([]byte{7}, 32)) + if err != nil { + t.Fatal(err) + } + cases := map[string]func([]byte) ([]byte, error){ + "workspace": func(b []byte) ([]byte, error) { + return c.OpenWorkspaceConfiguration(b, "installation", "configuration") + }, + "vault": func(b []byte) ([]byte, error) { + return c.Open(b, Binding{TenantID: "tenant", VaultID: "vault", CredentialID: "credential", AuthType: "static_bearer", Destination: "https://example.invalid/mcp"}) + }, + "deployment": func(b []byte) ([]byte, error) { return c.OpenSandboxDeployment(b, "installation", 7) }, + "deployment_model": func(b []byte) ([]byte, error) { return c.OpenDeploymentModelProvider(b, "codex") }, + "agent_model": func(b []byte) ([]byte, error) { return c.OpenAgentModelExecution(b, "tenant", "agent") }, + "session_model": func(b []byte) ([]byte, error) { return c.OpenModelExecution(b, "tenant", "session") }, + "file": func(b []byte) ([]byte, error) { + return c.OpenEnvironmentFile(b, EnvironmentFileBinding{TenantID: "tenant", Resource: "session", OwnerID: "session", FileID: "file"}) + }, + "setup": func(b []byte) ([]byte, error) { + return c.OpenEnvironmentSetup(b, EnvironmentSetupBinding{TenantID: "tenant", Resource: "session", OwnerID: "session", Field: "initialization"}) + }, + "skill": func(b []byte) ([]byte, error) { + return c.OpenSkill(b, SkillBinding{TenantID: "tenant", SkillID: "skill", VersionID: "version", Version: "1"}) + }, + } + for name, open := range cases { + t.Run(name, func(t *testing.T) { + b, err := hex.DecodeString(fixtures[name]) + if err != nil { + t.Fatal(err) + } + plain, err := open(b) + if err != nil || string(plain) != "public synthetic durable-format fixture" { + t.Fatal("stored format is unreadable") + } + }) + } + got, err := c.Fingerprint("parsar.agents-api.model-provider-api-key.v1", "public-fixture-key") + if err != nil || got != fixtures["provider_key_fingerprint"] { + t.Fatal("durable fingerprint changed") + } +} diff --git a/services/core/internal/credentialcrypto/environment_file.go b/services/core/internal/credentialcrypto/environment_file.go index 779ed5e87..cb079b959 100644 --- a/services/core/internal/credentialcrypto/environment_file.go +++ b/services/core/internal/credentialcrypto/environment_file.go @@ -42,5 +42,5 @@ func environmentFileData(binding EnvironmentFileBinding) ([]byte, error) { Domain string `json:"domain"` Version byte `json:"version"` Binding EnvironmentFileBinding `json:"binding"` - }{"oac.environment-file", formatVersion, binding}) + }{"parsar.agents-api.environment-file", formatVersion, binding}) } diff --git a/services/core/internal/credentialcrypto/environment_setup.go b/services/core/internal/credentialcrypto/environment_setup.go index 7a452bc4b..8955d3394 100644 --- a/services/core/internal/credentialcrypto/environment_setup.go +++ b/services/core/internal/credentialcrypto/environment_setup.go @@ -42,5 +42,5 @@ func environmentSetupData(binding EnvironmentSetupBinding) ([]byte, error) { Domain string `json:"domain"` Version byte `json:"version"` Binding EnvironmentSetupBinding `json:"binding"` - }{"oac.environment-setup", formatVersion, binding}) + }{"parsar.agents-api.environment-setup", formatVersion, binding}) } diff --git a/services/core/internal/credentialcrypto/model_execution.go b/services/core/internal/credentialcrypto/model_execution.go index 3a681d3a4..61dc204c3 100644 --- a/services/core/internal/credentialcrypto/model_execution.go +++ b/services/core/internal/credentialcrypto/model_execution.go @@ -9,7 +9,7 @@ func modelExecutionData(tenantID, sessionID string) ([]byte, error) { if tenantID == "" || sessionID == "" || !utf8.ValidString(tenantID) || !utf8.ValidString(sessionID) { return nil, errInvalidBinding } - return json.Marshal([]string{"oac.session-model-execution.v1", tenantID, sessionID}) + return json.Marshal([]string{"parsar.agents-api.session-model-execution.v1", tenantID, sessionID}) } func (c *Cipher) SealModelExecution(plaintext []byte, tenantID, sessionID string) ([]byte, error) { diff --git a/services/core/internal/credentialcrypto/sandbox_deployment.go b/services/core/internal/credentialcrypto/sandbox_deployment.go index 4066aed81..0346f14ff 100644 --- a/services/core/internal/credentialcrypto/sandbox_deployment.go +++ b/services/core/internal/credentialcrypto/sandbox_deployment.go @@ -10,7 +10,7 @@ func sandboxDeploymentData(installation string, generation uint64) ([]byte, erro if installation == "" || !utf8.ValidString(installation) || generation == 0 { return nil, errInvalidBinding } - return json.Marshal([]string{"oac.sandbox-deployment.v1", installation, strconv.FormatUint(generation, 10)}) + return json.Marshal([]string{"parsar.agents-api.sandbox-deployment.v1", installation, strconv.FormatUint(generation, 10)}) } func (c *Cipher) SealSandboxDeployment(plaintext []byte, installation string, generation uint64) ([]byte, error) { diff --git a/services/core/internal/credentialcrypto/skill.go b/services/core/internal/credentialcrypto/skill.go index 29d84830f..e827e24c9 100644 --- a/services/core/internal/credentialcrypto/skill.go +++ b/services/core/internal/credentialcrypto/skill.go @@ -48,5 +48,5 @@ func skillData(binding SkillBinding) ([]byte, error) { Domain string `json:"domain"` Version byte `json:"version"` Binding SkillBinding `json:"binding"` - }{"oac.skill", formatVersion, binding}) + }{"parsar.agents-api.skill", formatVersion, binding}) } diff --git a/services/core/internal/credentialcrypto/testdata/durable_format_v1.json b/services/core/internal/credentialcrypto/testdata/durable_format_v1.json new file mode 100644 index 000000000..ac1cab1c6 --- /dev/null +++ b/services/core/internal/credentialcrypto/testdata/durable_format_v1.json @@ -0,0 +1,13 @@ +{ + "agent_model": "0152f762c3e7cf5016ef333da80972f379af27d5038f9527174fff3b6c1c65ecb4254a82cfff6389b303d1f5d7fe09003717d1ebee66724dfa032e185d7d45a1bef610b7", + "deployment": "014601cf1d1628aa26f240bcca17a0b381063de3eb5726fcd2ca35ab89a7a03cbd5c521e7d1ca16d895fe0f2d8ac86d32ef33ce40e7b9dea6d3a603d5ee02463d673ffe9", + "deployment_model": "01f7b14a2f98064c0625f91940d597ad09e1040e07f8cbd24d934d793ab299bd166e5b4e3fd02fbcb33491b775f03e46e04e73ddcb2fd4e66e5f58b1b957a51ba3a2b1dc", + "file": "0118340768b67d209cac9d3a78dc6cc0f0f829289a0e1f12de991d2291f9b8dae7d802f7d35a3ce9d2de2b3fbe0fc8e0620faf5db5311e563fe3117bd90b1210c163f25f", + "installation_signature": "f7be23c8bf606c87300aa7c3c6087b3639a63dab8c8d682af220e0747a1f90fc", + "provider_key_fingerprint": "09a28bdd93e9fdcdc5fc09d4fea1c0cf4d75adf1bdf04f08e9678e1fd0e45304", + "session_model": "010e04989fec6871203b52de39f999adcafbb96e9bd474ca004b8d1d484457a29e50c211c8afd7546901d3f99831db099fc2471c55611c3a57107b9a5206c852fb2444fc", + "setup": "01da86aadbf9aa868e41eb55a250feabb3588a4ee5a08846c51e7899516c03a98ab63254b7d8ff5983f459872350dd203e1858ab0d301d2c9f156a3ce381111797e412e6", + "skill": "0176b9489b42c307b9fb2960651c92f590175a458903df9097239ea5bd0cfbfa53d06080a4605fd9f12fedefe3b4e32444a591df096c07bc413565970b50246d9b9df355", + "vault": "0107d05eaa6f4e1844709491d758884805183f23b63bd8b04bb2a6e7e1d5474786f4c62459cb7382861f462e01ad4e0d50f76fc8434b054cd22724de1ea1fde7eef6fef4", + "workspace": "016035e44fa5fb0b9dad671c59fdf87bb826020e5d4dd59856ee8485ec87385e08599b494c283410b93f890218406a88d38c164bce6d78ecc54defae3b75ed7e664a51ad" +} diff --git a/services/core/internal/execution/directory_preparation.go b/services/core/internal/execution/directory_preparation.go index 803d1ccba..2e19ac6ab 100644 --- a/services/core/internal/execution/directory_preparation.go +++ b/services/core/internal/execution/directory_preparation.go @@ -32,7 +32,7 @@ func (d *Dispatcher) withPreparedWorkspace(owner context.Context, peer *runtimeg observeArtifactWorkspace(owner, "artifact_workspace_prepare", prepareStarted, ErrExecutionUnavailable) } }() - req := proto.PromptRequestPayload{AgentKind: session.Engine, AgentStateKey: session.ID, WorkspaceReadOnly: true} + req := proto.PromptRequestPayload{AgentKind: session.Engine, AgentStateKey: proto.SessionStateKey(session.ID), WorkspaceReadOnly: true} if err := d.configurePreparedEnvironment(session, environment, bound, &req); err != nil { return ErrExecutionUnavailable } diff --git a/services/core/internal/execution/recovery_test.go b/services/core/internal/execution/recovery_test.go index b50620f10..75e798208 100644 --- a/services/core/internal/execution/recovery_test.go +++ b/services/core/internal/execution/recovery_test.go @@ -20,7 +20,7 @@ func TestExistingSessionRecoveryRequiresVerifiedCapability(t *testing.T) { } continue } - if err != nil || req.RequireExistingNativeSession != wantRecovery || req.AgentSessionID != nativeID || req.AgentStateKey != "session" { + if err != nil || req.RequireExistingNativeSession != wantRecovery || req.AgentSessionID != nativeID || req.AgentStateKey != "agents-api-session" { t.Fatalf("engine=%s started=%v id=%s capability=%v request=%+v err=%v", engine, started, nativeID, capable, req, err) } } diff --git a/services/core/internal/execution/request.go b/services/core/internal/execution/request.go index 17a7e3085..5844e3f22 100644 --- a/services/core/internal/execution/request.go +++ b/services/core/internal/execution/request.go @@ -36,7 +36,7 @@ func (d *Dispatcher) executionRequest(ctx context.Context, session sessions.Sess options["harness_config"] = native } request := proto.PromptRequestPayload{AgentKind: session.Engine, FunctionTools: tools.Functions, ToolSearch: tools.Search, - AgentOptions: options, ExecutionControls: executionControls(snapshot.Agent, tools), AgentStateKey: session.ID, + AgentOptions: options, ExecutionControls: executionControls(snapshot.Agent, tools), AgentStateKey: proto.SessionStateKey(session.ID), AgentSessionID: bound.NativeSessionID, RequireExistingNativeSession: recoverNativeSession, ObserveSubagentIdentities: snapshot.Agent.MultiAgent.Enabled, MaxConcurrentSubagents: snapshot.Agent.MultiAgent.MaxConcurrentSubagents, diff --git a/services/core/internal/persistence/postgres/sessionpg/creation.go b/services/core/internal/persistence/postgres/sessionpg/creation.go index 0cce6e145..5a1252090 100644 --- a/services/core/internal/persistence/postgres/sessionpg/creation.go +++ b/services/core/internal/persistence/postgres/sessionpg/creation.go @@ -27,7 +27,7 @@ import ( // modelProviderKeyPurpose keys the provider-key fingerprint in creation // identities. Changing it changes every stored identity. -const modelProviderKeyPurpose = "oac.model-provider-api-key.v1" +const modelProviderKeyPurpose = "parsar.agents-api.model-provider-api-key.v1" var _ sessions.CreationTx = (*creationTx)(nil) diff --git a/services/core/internal/persistence/postgres/sessionpg/durable_identity_test.go b/services/core/internal/persistence/postgres/sessionpg/durable_identity_test.go new file mode 100644 index 000000000..23801bf2b --- /dev/null +++ b/services/core/internal/persistence/postgres/sessionpg/durable_identity_test.go @@ -0,0 +1,37 @@ +package sessionpg + +import ( + "bytes" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" + "testing" +) + +func TestDurableProviderKeyFingerprint(t *testing.T) { + c, err := credentialcrypto.New(bytes.Repeat([]byte{7}, 32)) + if err != nil { + t.Fatal(err) + } + got, err := (&Store{cipher: c}).FingerprintProviderKey("public-fixture-key") + // Fixed public fixture generated by source 4b16ddbbfe5ad5984547e54d60bb9a89c7c79d81. + if err != nil || got != "09a28bdd93e9fdcdc5fc09d4fea1c0cf4d75adf1bdf04f08e9678e1fd0e45304" { + t.Fatal("persisted request identity changed") + } +} + +func TestDurableInstallationSignature(t *testing.T) { + c, err := credentialcrypto.New(bytes.Repeat([]byte{7}, 32)) + if err != nil { + t.Fatal(err) + } + s := &Store{cipher: c} + got, err := s.installationSignature("public-fixture-authorization") + if err != nil || got != "f7be23c8bf606c87300aa7c3c6087b3639a63dab8c8d682af220e0747a1f90fc" { + t.Fatal("installation authorization signature changed") + } + if err := s.VerifyInstallation(t.Context(), "public-fixture-authorization", got); err != nil { + t.Fatal("stored authorization rejected") + } + if err := s.VerifyInstallation(t.Context(), "another-authorization", got); err == nil { + t.Fatal("signature accepted changed payload") + } +} diff --git a/services/core/tests/integration/dispatch_test.go b/services/core/tests/integration/dispatch_test.go index c0871bd57..332c42d70 100644 --- a/services/core/tests/integration/dispatch_test.go +++ b/services/core/tests/integration/dispatch_test.go @@ -231,7 +231,7 @@ func TestExecutionDispatchSteeringAndNativeContinuity(t *testing.T) { request := h.read(testExecutionRequest) var prompt proto.PromptRequestPayload _ = request.DecodePayload(&prompt) - if inputTextForTest(t, prompt.Input) != "Initial input" || prompt.AgentStateKey != h.session.ID || prompt.AgentOptions["model"] != "test-model" || prompt.AgentOptions["system_prompt"] != "Keep this instruction." { + if inputTextForTest(t, prompt.Input) != "Initial input" || prompt.AgentStateKey != proto.SessionStateKey(h.session.ID) || prompt.AgentOptions["model"] != "test-model" || prompt.AgentOptions["system_prompt"] != "Keep this instruction." { t.Fatalf("wrong resolved request: %+v", prompt) } if _, err := h.bound().Run(ctx, uuid.NewString(), h.session.ID, first.TurnID); !errors.Is(err, sessions.ErrNotFound) { @@ -276,7 +276,7 @@ func TestExecutionDispatchSteeringAndNativeContinuity(t *testing.T) { result = h.run(ctx, next.TurnID) request = h.read(testExecutionRequest) _ = request.DecodePayload(&prompt) - if prompt.AgentSessionID != "native-thread-1" || prompt.AgentStateKey != h.session.ID { + if prompt.AgentSessionID != "native-thread-1" || prompt.AgentStateKey != proto.SessionStateKey(h.session.ID) { t.Fatal("native continuity lost") } h.write(next.TurnID, proto.TypeDone, proto.DonePayload{Content: "Continued"}) diff --git a/services/core/tests/integration/environment_directory_test.go b/services/core/tests/integration/environment_directory_test.go index 5b02797ef..322f5e7ca 100644 --- a/services/core/tests/integration/environment_directory_test.go +++ b/services/core/tests/integration/environment_directory_test.go @@ -83,7 +83,7 @@ func prepareDirectoryRead(t *testing.T, h *dispatchHarness, environment sessions t.Helper() frame := h.read(proto.TypeExecutionPrepare) var request proto.ExecutionPreparePayload - if frame.DecodePayload(&request) != nil || !proto.ValidWorkspaceReadPreparation(request.Configuration) || request.Configuration.LocalEnvironment == nil || request.Configuration.LocalEnvironment.ID != environment.ID || request.Configuration.AgentStateKey != h.session.ID { + if frame.DecodePayload(&request) != nil || !proto.ValidWorkspaceReadPreparation(request.Configuration) || request.Configuration.LocalEnvironment == nil || request.Configuration.LocalEnvironment.ID != environment.ID || request.Configuration.AgentStateKey != proto.SessionStateKey(h.session.ID) { t.Fatal("read did not use the closed preparation profile") } handle := acknowledgePreparation(h, frame.ID) diff --git a/services/core/tests/integration/environment_worker_helpers_test.go b/services/core/tests/integration/environment_worker_helpers_test.go index c69afb4e7..1da5853dd 100644 --- a/services/core/tests/integration/environment_worker_helpers_test.go +++ b/services/core/tests/integration/environment_worker_helpers_test.go @@ -87,7 +87,7 @@ func workerRuntimeForPreparation(t *testing.T, h *dispatchHarness, frame proto.E t.Fatal("invalid worker preparation") } for _, candidate := range h.environments { - if input.Configuration.AgentStateKey == candidate.session.ID && input.Configuration.LocalEnvironment != nil && input.Configuration.LocalEnvironment.ID == candidate.device.EnvironmentID { + if input.Configuration.AgentStateKey == proto.SessionStateKey(candidate.session.ID) && input.Configuration.LocalEnvironment != nil && input.Configuration.LocalEnvironment.ID == candidate.device.EnvironmentID { return candidate } } diff --git a/services/core/tests/integration/environment_worker_test.go b/services/core/tests/integration/environment_worker_test.go index 9f6af2dba..7ece6eee0 100644 --- a/services/core/tests/integration/environment_worker_test.go +++ b/services/core/tests/integration/environment_worker_test.go @@ -16,7 +16,7 @@ func TestWorkerEnvironmentSharesCapacityThroughClaimAndCleanup(t *testing.T) { pending := map[string]sessions.EnvironmentInputReservation{} for range 2 { value := workerEnvironmentReservation(t, h) - pending[value.SessionID] = value + pending[proto.SessionStateKey(value.SessionID)] = value } runtimes := []*dispatchHarness{h} for _, runtime := range h.environments {