From 29e58ff4ee7d5337fb27d3904124bdf2ff97cca7 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Sat, 24 Oct 2026 16:39:27 +0000 Subject: [PATCH 1/2] Project never-connected self_hosted Environments as disconnected --- contracts/agents-api/environments.md | 2 +- contracts/agents-api/zh/environments.md | 4 ++-- services/core/internal/api/environments.go | 7 ++++++- services/core/internal/api/environments_test.go | 2 +- 4 files changed, 10 insertions(+), 5 deletions(-) diff --git a/contracts/agents-api/environments.md b/contracts/agents-api/environments.md index a5e397587..623ab7bb6 100644 --- a/contracts/agents-api/environments.md +++ b/contracts/agents-api/environments.md @@ -53,7 +53,7 @@ stateDiagram-v2 Session environment events carry Session and Environment identity and an optional Turn identity, and never configuration, credentials, registration IDs or revisions. A Session's `required_actions` can contain `{type: "environment_connection", environment_id}`, separate from function calls. Environment connection, Session status and Turn status are independent. -Core creates the Environment record in the Session creation transaction; the Session upsert picks the retry winner, and retries never create or repair an Environment. The Environment derives its Project and immutable configuration from the owning Session. Its first state is `pending`. After the Session is deleted, reads hide the Environment while Core keeps the record for settlement and cleanup. A Session with `none` has no Environment. +Core creates the Environment record in the Session creation transaction; the Session upsert picks the retry winner, and retries never create or repair an Environment. The Environment derives its Project and immutable configuration from the owning Session. Its first state is `pending`; a `self_hosted` Environment reads `disconnected` until its first connection. After the Session is deleted, reads hide the Environment while Core keeps the record for settlement and cleanup. A Session with `none` has no Environment. A managed outer Environment must exclude broader application credentials and other tenants’ secrets. diff --git a/contracts/agents-api/zh/environments.md b/contracts/agents-api/zh/environments.md index 7d0a82934..d5a0d4d64 100644 --- a/contracts/agents-api/zh/environments.md +++ b/contracts/agents-api/zh/environments.md @@ -1,7 +1,7 @@ --- title: "Environment 与模板" source: contracts/agents-api/environments.md -source_hash: eb7282383dcac86723189824b0391c7dd50336257db922a1fce2b2f2d2153049 +source_hash: 15550304b85596e09cbeb4cc6d7253a4bdd64ffd6adc58a539511e0c939f7ad6 --- Environment 是 Session 的执行资源,包括 Harness 运行所在的机器、工作区以及已完成准备的能力。Session 通过其 `environment` 配置创建 Environment;不存在独立的 create 调用。Environment 模板是 Session 创建时解析的可复用准备配置。本契约涵盖这两类资源、两种放置方式、输入接纳、能力准备、Skills、Plugins 和 MCP 连接来源。 @@ -55,7 +55,7 @@ stateDiagram-v2 Session environment 事件携带 Session 和 Environment 的身份标识以及可选的 Turn 身份标识,绝不携带配置、凭据、注册 ID 或修订号。Session 的 `required_actions` 可以包含 `{type: "environment_connection", environment_id}`,这与函数调用相互独立。Environment 连接、Session 状态和 Turn 状态相互独立。 -Core 在 Session 创建事务中创建 Environment 记录;Session upsert 会选出重试的胜者,而重试绝不创建或修复 Environment。Environment 从所属 Session 派生其项目和不可变配置。其首个状态为 `pending`。Session 删除后,读取会隐藏 Environment,而 Core 仍保留该记录,用于结算和清理。Session 为 `none` 时没有 Environment。 +Core 在 Session 创建事务中创建 Environment 记录;Session upsert 会选出重试的胜者,而重试绝不创建或修复 Environment。Environment 从所属 Session 派生其项目和不可变配置。其首个状态为 `pending`;`self_hosted` Environment 在首次连接前读作 `disconnected`。Session 删除后,读取会隐藏 Environment,而 Core 仍保留该记录,用于结算和清理。Session 为 `none` 时没有 Environment。 托管的外层 Environment 必须排除权限范围更广的应用程序凭据和其他租户的机密。 diff --git a/services/core/internal/api/environments.go b/services/core/internal/api/environments.go index 9c75d471a..22d7e071a 100644 --- a/services/core/internal/api/environments.go +++ b/services/core/internal/api/environments.go @@ -55,6 +55,11 @@ func environmentResponse(environment sessions.Environment) (v1.EnvironmentInfo, default: return v1.EnvironmentInfo{}, errors.New("unsupported stored environment resource status") } + status := environment.Status + if status == "pending" && configuration.Type == "self_hosted" { + // A self_hosted Environment that has never connected reads disconnected. + status = "disconnected" + } files := configuration.Files if files == nil { files = []json.RawMessage{} @@ -66,7 +71,7 @@ func environmentResponse(environment sessions.Environment) (v1.EnvironmentInfo, configuration.Plugins = []json.RawMessage{} } return v1.EnvironmentInfo{ - ID: environment.ID, Object: "agent.environment", Type: configuration.Type, Status: environment.Status, + ID: environment.ID, Object: "agent.environment", Type: configuration.Type, Status: status, Files: files, Plugins: configuration.Plugins, Skills: configuration.Skills, }, nil } diff --git a/services/core/internal/api/environments_test.go b/services/core/internal/api/environments_test.go index f7a8db708..22d1cc59e 100644 --- a/services/core/internal/api/environments_test.go +++ b/services/core/internal/api/environments_test.go @@ -61,7 +61,7 @@ func TestEnvironmentResourceExactProjectionWithoutExecution(t *testing.T) { t.Fatal("resource read failed", w.Code, w.Body) } want := map[string]any{ - "id": f.environment.ID, "object": "agent.environment", "type": "self_hosted", "status": status, + "id": f.environment.ID, "object": "agent.environment", "type": "self_hosted", "status": strings.Replace(status, "pending", "disconnected", 1), "files": []any{}, "plugins": []any{}, "skills": []any{}, } if !reflect.DeepEqual(got, want) || f.calls != 1 || f.tenant != f.environment.TenantID || f.id != f.environment.ID { From 73650cc628852bf4e7172a4d7fa4fa6d15596247 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Sat, 24 Oct 2026 16:51:12 +0000 Subject: [PATCH 2/2] Expect disconnected in the self_hosted official-client tests --- services/core/tests/official_environment_initial_failure.py | 2 +- services/core/tests/official_environment_retrieve.py | 6 +++--- services/core/tests/official_self_hosted_initial.py | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/services/core/tests/official_environment_initial_failure.py b/services/core/tests/official_environment_initial_failure.py index 80a6aadc2..4ad06c051 100644 --- a/services/core/tests/official_environment_initial_failure.py +++ b/services/core/tests/official_environment_initial_failure.py @@ -77,7 +77,7 @@ def observe(name): assert list(api.beta.agents.sessions.turns.list(session_id)) == [] assert list(api.beta.agents.sessions.items.list(session_id)) == [] environment = api.beta.agents.environments.retrieve(settings["environment_id"]) - assert environment.status == "pending" + assert environment.status == "disconnected" for suffix in ("", "/events", "/turns", "/items"): assert raw.get("/agents/sessions/" + session_id + suffix, headers={"Authorization": "Bearer " + settings["foreign_token"]}).status_code == 404 print(json.dumps({"status": "private_initial_failure_public_reads_and_live_events_verified", "session_id": session_id, diff --git a/services/core/tests/official_environment_retrieve.py b/services/core/tests/official_environment_retrieve.py index 0e71ffa3b..4b47dccbb 100644 --- a/services/core/tests/official_environment_retrieve.py +++ b/services/core/tests/official_environment_retrieve.py @@ -53,14 +53,14 @@ def client(key): other = foreign.beta.agents.sessions.create(**creation, extra_body=PROVIDER) result = {"environment_id": session.environment.id, "deleted_environment_id": removed.environment.id, "foreign_environment_id": other.environment.id} - verify_environment(api.beta.agents.environments.retrieve(removed.environment.id).to_dict(), removed.environment.id, "pending") + verify_environment(api.beta.agents.environments.retrieve(removed.environment.id).to_dict(), removed.environment.id, "disconnected") api.beta.agents.sessions.delete(removed.id) assert list(api.beta.agents.sessions.turns.list(session.id)) == [] assert list(api.beta.agents.sessions.items.list(session.id)) == [] environment_id = result["environment_id"] endpoint = base + "/v1/agents/environments/" + environment_id - expected = verify_environment(api.beta.agents.environments.retrieve(environment_id).to_dict(), environment_id, "pending") + expected = verify_environment(api.beta.agents.environments.retrieve(environment_id).to_dict(), environment_id, "disconnected") assert peer.beta.agents.environments.retrieve(environment_id).to_dict() == expected raw = api.beta.agents.environments.with_raw_response.retrieve(environment_id.upper()) assert raw.status_code == 200 and raw.http_response.json() == expected and raw.parse().to_dict() == expected @@ -112,7 +112,7 @@ def rejected(url, status, code, request_headers=headers, method="GET"): for method in ("POST", "PATCH", "DELETE"): rejected(endpoint, 405, "unsupported_operation", method=method) assert api.beta.agents.environments.retrieve(environment_id).to_dict() == expected - verify_environment(foreign.beta.agents.environments.retrieve(result["foreign_environment_id"]).to_dict(), result["foreign_environment_id"], "pending") + verify_environment(foreign.beta.agents.environments.retrieve(result["foreign_environment_id"]).to_dict(), result["foreign_environment_id"], "disconnected") print(json.dumps(result)) diff --git a/services/core/tests/official_self_hosted_initial.py b/services/core/tests/official_self_hosted_initial.py index 2e52b57ba..42dba418b 100644 --- a/services/core/tests/official_self_hosted_initial.py +++ b/services/core/tests/official_self_hosted_initial.py @@ -181,7 +181,7 @@ def observe(name): raise AssertionError("public initial failure observer failed") from failures[0] assert observations["sdk"] == observations["raw"] assert retry(case, "failed") == observations["sdk"]["session"] - assert api.beta.agents.environments.retrieve(case["environment_id"]).status == "pending" + assert api.beta.agents.environments.retrieve(case["environment_id"]).status == "disconnected" for retained in cases[1:]: assert retry(retained) == retained["snapshot"] result["controlled_deadline_expiry"] = True