From efbbc23e70c92a6ab67dd93a257c0be31aaf66b1 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Mon, 28 Sep 2026 05:06:51 +0000 Subject: [PATCH 1/4] Expose current executor connection observations in Core credential lists --- contracts/agents-api/core.openapi.yaml | 33 +++++- .../environment-executor-credentials.md | 25 +++- .../agents-client/src/admin-client.test.ts | 11 +- packages/agents-client/src/admin-client.ts | 4 +- .../agents-client/src/admin-projection.ts | 15 ++- packages/agents-client/src/admin-types.ts | 9 ++ packages/agents-client/src/index.ts | 2 +- services/agents-api/cmd/server/main.go | 3 + .../api/environment_executor_management.go | 44 ++++++- .../environment_executor_management_test.go | 58 +++++++++- services/agents-api/internal/api/handler.go | 1 + .../environment_executor_credentials.sql | 10 ++ .../environment_executor_credentials.sql.go | 39 +++++++ .../internal/runtimeenrollment/connection.go | 23 +++- .../runtimeenrollment/connection_test.go | 101 +++++++++++++++++ .../store/environment_executor_management.go | 91 +++++++++++---- .../environment_executor_management_test.go | 107 ++++++++++++++++++ 17 files changed, 535 insertions(+), 41 deletions(-) diff --git a/contracts/agents-api/core.openapi.yaml b/contracts/agents-api/core.openapi.yaml index 8dba36b29..d2e8fde9a 100644 --- a/contracts/agents-api/core.openapi.yaml +++ b/contracts/agents-api/core.openapi.yaml @@ -258,12 +258,43 @@ definitions: rotate: type: boolean type: object + api.ExecutorConnection: + properties: + bound_key_id: + format: uuid + type: string + x-nullable: true + enrolled_at: + format: date-time + type: string + x-nullable: true + last_seen_at: + format: date-time + type: string + x-nullable: true + status: + enum: + - never_enrolled + - connected + - disconnected + type: string + required: + - bound_key_id + - enrolled_at + - last_seen_at + - status + type: object api.ExecutorCredentialList: properties: + connection: + $ref: '#/definitions/api.ExecutorConnection' data: items: $ref: '#/definitions/store.ExecutorCredential' type: array + required: + - connection + - data type: object api.HarnessModelProvider: properties: @@ -3860,7 +3891,7 @@ paths: - Environment Templates /core/v1/projects/{project_id}/environments/{environment_id}/executor-credentials: get: - description: Core key only. Returns metadata of the credentials restricted to this Environment, oldest first; secrets are never listed. The Environment must be a self_hosted Environment of the Project whose Session exists; otherwise 404. + description: Core key only. Returns metadata of the credentials restricted to this Environment, oldest first; secrets are never listed. Connection combines current credential authority and an open matching gateway peer; timestamps are historical observations, not readiness. Without a gateway it is never connected. The Environment must be a self_hosted Environment of the Project whose Session exists; otherwise 404. parameters: - description: Project UUID in: path diff --git a/contracts/agents-api/environment-executor-credentials.md b/contracts/agents-api/environment-executor-credentials.md index 1796b8032..36cb3de15 100644 --- a/contracts/agents-api/environment-executor-credentials.md +++ b/contracts/agents-api/environment-executor-credentials.md @@ -18,7 +18,7 @@ type, missing Environment or deleted Session returns 404. | Operation | Request | Result | | --- | --- | --- | -| List | `GET …/executor-credentials` | `{"data":[{"key_id","created_at","revoked_at"}]}` | +| List | `GET …/executor-credentials` | Credential metadata in `data`, plus required `connection` observation | | Issue or rotate | `POST …/executor-credentials` with `{"key_id":"UUID","rotate":false}` | 201 credential file, returned once | | Revoke | `DELETE …/executor-credentials/{key_id}` | 204 | @@ -26,6 +26,29 @@ The list holds metadata only, oldest first, for the credentials restricted to th Environment; `revoked_at` is null while a credential is active. It never contains a secret. +The required `connection` object contains `status` (`never_enrolled`, `connected`, +or `disconnected`), `bound_key_id`, `enrolled_at`, and `last_seen_at`. All three +binding fields are null before enrollment. Once enrolled, the bound key and +enrollment time describe the existing device; a null `last_seen_at` means no +authenticated heartbeat has been recorded. Issuing another key does not change +the binding. Rotation/revocation can make the binding disconnected while its +history remains visible. Expired Environments remain readable under the existing +list rules but cannot have current executor authority. + +Connected means the Environment is connected, its device and executor key still +have current Core authority, and the process-local gateway has an open peer +that authenticated with that current key. Core rechecks authority after observing +the peer. A former key's live socket, a device timestamp, or a ready-looking +Environment alone is insufficient; without a gateway, Core never returns +connected. These facts are an observation, not a reservation of connectivity or +native/model readiness. `last_seen_at` may lag by a heartbeat interval. + +List metadata and binding facts use one read-only database snapshot. That snapshot +ends before the live authority checks, so a committed rotation/revocation is not +hidden by snapshot isolation. Known authority loss projects as disconnected; +observation/storage failures remain errors. Device IDs and credential digests are +internal and never serialized. The public `/v1` Environment shape is unchanged. + `key_id` is a canonical nonzero UUID chosen and retained before the request. `rotate` is optional and defaults to false. The 201 response is the daemon credential-file format: diff --git a/packages/agents-client/src/admin-client.test.ts b/packages/agents-client/src/admin-client.test.ts index 363976bb6..d21b5dc91 100644 --- a/packages/agents-client/src/admin-client.test.ts +++ b/packages/agents-client/src/admin-client.test.ts @@ -138,9 +138,16 @@ describe("AdminClient transport boundary", () => { it("lists executor credential metadata only and revokes with 204", async () => { const credential = { key_id: keyId, created_at: "2026-09-25T00:00:00Z", revoked_at: null }; - expect(await clientWith({ data: [credential] }).client.listExecutorCredentials(projectId, resourceId)).toEqual({ data: [credential] }); + const connection = { status: "never_enrolled", bound_key_id: null, enrolled_at: null, last_seen_at: null }; + expect(await clientWith({ data: [credential], connection }).client.listExecutorCredentials(projectId, resourceId)).toEqual({ data: [credential], connection }); for (const entry of [{ ...credential, executor_token: "leak" }, { ...credential, revoked_at: "later" }, { key_id: keyId, created_at: "2026-09-25T00:00:00Z" }]) { - await expect(clientWith({ data: [entry] }).client.listExecutorCredentials(projectId, resourceId)).rejects.toMatchObject({ code: "invalid_admin_response" }); + await expect(clientWith({ data: [entry], connection }).client.listExecutorCredentials(projectId, resourceId)).rejects.toMatchObject({ code: "invalid_admin_response" }); + } + const enrolled = { status: "connected", bound_key_id: keyId, enrolled_at: credential.created_at, last_seen_at: null }; + expect((await clientWith({ data: [credential], connection: enrolled }).client.listExecutorCredentials(projectId, resourceId)).connection).toEqual(enrolled); + for (const bad of [undefined, { ...enrolled, status: "ready" }, { ...enrolled, enrolled_at: null }, { ...enrolled, bound_key_id: null }, + { ...enrolled, last_seen_at: "invalid" }, { ...enrolled, credential_hash: "secret" }, { ...connection, last_seen_at: credential.created_at }]) { + await expect(clientWith({ data: [credential], connection: bad }).client.listExecutorCredentials(projectId, resourceId)).rejects.toMatchObject({ code: "invalid_admin_response" }); } const fetch = vi.fn().mockImplementation(async () => new Response(null, { status: 204 })); await expect(new AdminClient({ fetch }).revokeExecutorCredential(projectId, resourceId, keyId)).resolves.toBeUndefined(); diff --git a/packages/agents-client/src/admin-client.ts b/packages/agents-client/src/admin-client.ts index 28e073b57..4ab23613d 100644 --- a/packages/agents-client/src/admin-client.ts +++ b/packages/agents-client/src/admin-client.ts @@ -20,7 +20,7 @@ import type { import type { AdminClientOptions, AdminAuditOptions, AdminContent, ArchiveAdminSessionInput, CreateAdminProjectInput, RenameAdminProjectInput, IssueAdminAPIKeyInput, AdminSummaryOptions, AdminResourceType, AdminResourceOwner, AdminWriteOperationOptions, AdminWriteOperationPage, - ExecutorCredential, IssueExecutorCredentialInput, IssuedExecutorCredential, CoreInstallation, + ExecutorCredentialList, IssueExecutorCredentialInput, IssuedExecutorCredential, CoreInstallation, } from "./admin-types"; function segment(value: string): string { @@ -260,7 +260,7 @@ export class AdminClient { } /** Credential metadata for one self_hosted Environment; the credentials themselves are never listed. */ - async listExecutorCredentials(projectId: string, environmentId: string, options?: ReadOptions): Promise<{ data: ExecutorCredential[] }> { + async listExecutorCredentials(projectId: string, environmentId: string, options?: ReadOptions): Promise { return projectExecutorCredentials(await this.#json(`${scope(projectId)}/environments/${segment(environmentId)}/executor-credentials`, options)); } /** diff --git a/packages/agents-client/src/admin-projection.ts b/packages/agents-client/src/admin-projection.ts index 653bd1302..74db035e1 100644 --- a/packages/agents-client/src/admin-projection.ts +++ b/packages/agents-client/src/admin-projection.ts @@ -3,7 +3,7 @@ import { projectTokenUsage } from "./usage-projection"; import { safeProvider } from "./execution-configuration-projection"; import { canonicalUuid, exactFields, isNonnegativeInteger, isRecord, onlyFields, sameResourceId } from "./response-projection"; import type { CoreHarness, CoreHarnessKind, HarnessModelProvider, ListPage, SavedAgent } from "./types"; -import type { AdminAPIKey, AdminProject, AdminAuditPage, AdminSummary, AdminRuntimeObservation, RuntimeDiskObservation, AdminKeyProvenance, AdminResourceOwner, AdminWriteOperationPage, AdminAuditResultID, AdminDeleted, AdminIssuedAPIKey, AdminPage, AdminSessionArchive, SessionArtifact, Skill, SkillVersion, ExecutorCredential, IssuedExecutorCredential, CoreInstallation, CoreInstallationSetting } from "./admin-types"; +import type { AdminAPIKey, AdminProject, AdminAuditPage, AdminSummary, AdminRuntimeObservation, RuntimeDiskObservation, AdminKeyProvenance, AdminResourceOwner, AdminWriteOperationPage, AdminAuditResultID, AdminDeleted, AdminIssuedAPIKey, AdminPage, AdminSessionArchive, SessionArtifact, Skill, SkillVersion, ExecutorCredentialList, ExecutorConnection, IssuedExecutorCredential, CoreInstallation, CoreInstallationSetting } from "./admin-types"; export function invalidAdminResponse(): never { throw new AgentCoreError("Core returned an invalid administration response.", 502, "invalid_admin_response"); @@ -198,10 +198,17 @@ export function projectAdminAudit(value: unknown): AdminAuditPage { }); return { data, has_more: page.has_more, next_cursor: page.next_cursor } as AdminAuditPage; } -export function projectExecutorCredentials(value: unknown): { data: ExecutorCredential[] } { - const page = record(value, ["data"]); +export function projectExecutorCredentials(value: unknown): ExecutorCredentialList { + const page = record(value, ["data", "connection"]); if (!Array.isArray(page.data)) return invalidAdminResponse(); - return { data: page.data.map((entry) => { + const connection = record(page.connection, ["status", "bound_key_id", "enrolled_at", "last_seen_at"]); + if (!["never_enrolled", "connected", "disconnected"].includes(connection.status as string) || + !(connection.bound_key_id === null || (typeof connection.bound_key_id === "string" && connection.bound_key_id.length > 0)) || + !date(connection.enrolled_at) || !date(connection.last_seen_at)) return invalidAdminResponse(); + if (connection.status === "never_enrolled" && [connection.bound_key_id, connection.enrolled_at, connection.last_seen_at].some(value => value !== null)) return invalidAdminResponse(); + if (connection.status !== "never_enrolled" && connection.enrolled_at === null) return invalidAdminResponse(); + if (connection.status === "connected" && connection.bound_key_id === null) return invalidAdminResponse(); + return { connection: { ...connection } as unknown as ExecutorConnection, data: page.data.map((entry) => { const credential = record(entry, ["key_id", "created_at", "revoked_at"]); if (typeof credential.key_id !== "string" || typeof credential.created_at !== "string" || !date(credential.created_at) || !date(credential.revoked_at)) return invalidAdminResponse(); return { key_id: credential.key_id, created_at: credential.created_at, revoked_at: credential.revoked_at as string | null }; diff --git a/packages/agents-client/src/admin-types.ts b/packages/agents-client/src/admin-types.ts index 2542df3c6..b11ba2dd5 100644 --- a/packages/agents-client/src/admin-types.ts +++ b/packages/agents-client/src/admin-types.ts @@ -160,6 +160,15 @@ export interface AdminAuditPage extends AdminPage { next_cursor /** Executor credential metadata for one self_hosted environment; the credential itself is never listed. */ export interface ExecutorCredential { key_id: string; created_at: string; revoked_at: string | null } +/** Core authority plus a matching live gateway peer; timestamps alone are not readiness. */ +export interface ExecutorConnection { + status: "never_enrolled" | "connected" | "disconnected"; + bound_key_id: string | null; + enrolled_at: string | null; + last_seen_at: string | null; +} +export interface ExecutorCredentialList { data: ExecutorCredential[]; connection: ExecutorConnection } + /** `key_id` is chosen by the caller, so an uncertain issuance can be reissued with the same ID and `rotate: true`. */ export interface IssueExecutorCredentialInput { key_id: string; rotate?: boolean } /** Returned once, on issuance or rotation. */ diff --git a/packages/agents-client/src/index.ts b/packages/agents-client/src/index.ts index 7a73d2078..eca855087 100644 --- a/packages/agents-client/src/index.ts +++ b/packages/agents-client/src/index.ts @@ -11,4 +11,4 @@ export { isOpenAIHostedSessionEnvironment } from "./session-environment-projecti export { compareSkillVersionNumbers, isSkillId, isSkillUploadPath, isSkillVersionId, isSkillVersionNumber, maxSkillUploadFiles } from "./skill-projection"; export { AdminClient } from "./admin-client"; // Skill and SkillVersion come from ./types; the admin projections use the same shapes. -export type { AdminClientOptions, AdminProject, CreateAdminProjectInput, RenameAdminProjectInput, AdminAPIKey, AdminIssuedAPIKey, IssueAdminAPIKeyInput, AdminPage, AdminDeleted, SessionArtifact, AdminContent, AdminResourceType, AdminKeyProvenance, AdminResourceOwner, AdminWriteOperation, AdminWriteOperationOptions, AdminWriteOperationPage, AdminSummaryOptions, AdminSummaryEntry, AdminSummary, AdminRuntimeObservation, AdminAuditOptions, AdminAuditResultID, AdminAuditEntry, AdminAuditPage, ExecutorCredential, IssueExecutorCredentialInput, IssuedExecutorCredential, CoreProjectReader, CoreInstallation, CoreInstallationConfiguration, CoreInstallationSetting, CoreAddressBindings } from "./admin-types"; +export type { AdminClientOptions, AdminProject, CreateAdminProjectInput, RenameAdminProjectInput, AdminAPIKey, AdminIssuedAPIKey, IssueAdminAPIKeyInput, AdminPage, AdminDeleted, SessionArtifact, AdminContent, AdminResourceType, AdminKeyProvenance, AdminResourceOwner, AdminWriteOperation, AdminWriteOperationOptions, AdminWriteOperationPage, AdminSummaryOptions, AdminSummaryEntry, AdminSummary, AdminRuntimeObservation, AdminAuditOptions, AdminAuditResultID, AdminAuditEntry, AdminAuditPage, ExecutorCredential, ExecutorConnection, ExecutorCredentialList, IssueExecutorCredentialInput, IssuedExecutorCredential, CoreProjectReader, CoreInstallation, CoreInstallationConfiguration, CoreInstallationSetting, CoreAddressBindings } from "./admin-types"; diff --git a/services/agents-api/cmd/server/main.go b/services/agents-api/cmd/server/main.go index fae3683fc..b3e86ac51 100644 --- a/services/agents-api/cmd/server/main.go +++ b/services/agents-api/cmd/server/main.go @@ -224,6 +224,9 @@ func run() error { defer runtime.CloseConnections(registry) options = append(options, api.WithEnvironmentRemoteURL(wsURL)) } + options = append(options, api.WithExecutorConnections(func(ctx context.Context, environment, digest string) (bool, error) { + return runtimeenrollment.RuntimeConnected(ctx, executionStore, registry, environment, digest) + })) if registry != nil { dispatcher := &execution.Dispatcher{Store: executionStore, Registry: registry, ManagedRuntimes: managed, MaxConcurrentExecutions: concurrency} diff --git a/services/agents-api/internal/api/environment_executor_management.go b/services/agents-api/internal/api/environment_executor_management.go index 75c869718..13437a403 100644 --- a/services/agents-api/internal/api/environment_executor_management.go +++ b/services/agents-api/internal/api/environment_executor_management.go @@ -4,7 +4,9 @@ import ( "bytes" "context" "encoding/json" + "errors" "net/http" + "time" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/identity" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" @@ -16,7 +18,7 @@ import ( // self_hosted Environments. The Project's principal is the credential's // execution principal; the Core key that authorizes the request is not. type EnvironmentExecutorStore interface { - ListProjectExecutorCredentials(context.Context, identity.Principal, string) ([]store.ExecutorCredential, error) + ProjectExecutorCredentialState(context.Context, identity.Principal, string) (store.ExecutorCredentialState, error) IssueProjectExecutorCredential(context.Context, identity.Principal, string, string, bool) (store.IssuedExecutorCredential, error) RevokeProjectExecutorCredential(context.Context, identity.Principal, string, string) error } @@ -28,7 +30,24 @@ type EnvironmentExecutorCredentialRequest struct { // ExecutorCredentialList holds credential metadata only, never a secret. type ExecutorCredentialList struct { - Data []store.ExecutorCredential `json:"data"` + Data []store.ExecutorCredential `json:"data" binding:"required"` + Connection ExecutorConnection `json:"connection" binding:"required"` +} + +// ExecutorConnection reports binding history and current Core-observed connectivity. +// Heartbeat times are observations, not execution or native readiness. +type ExecutorConnection struct { + Status string `json:"status" binding:"required" enums:"never_enrolled,connected,disconnected"` + BoundKeyID *string `json:"bound_key_id" binding:"required" extensions:"x-nullable" format:"uuid"` + EnrolledAt *time.Time `json:"enrolled_at" binding:"required" format:"date-time" extensions:"x-nullable"` + LastSeenAt *time.Time `json:"last_seen_at" binding:"required" format:"date-time" extensions:"x-nullable"` +} + +// WithExecutorConnections observes current authority and its actual gateway peer. +// The observer runs after the store snapshot closes and must recheck authority +// after inspecting the peer. Without an observer, no binding is called connected. +func WithExecutorConnections(observe func(context.Context, string, string) (bool, error)) Option { + return func(h *Handler) { h.executorConnections = observe } } // registerExecutorCredentialRoutes adds executor credential issuance to the @@ -45,7 +64,7 @@ func (h *Handler) registerExecutorCredentialRoutes(r chi.Router) { } // @Summary List a self_hosted Environment's executor credentials -// @Description Core key only. Returns metadata of the credentials restricted to this Environment, oldest first; secrets are never listed. The Environment must be a self_hosted Environment of the Project whose Session exists; otherwise 404. +// @Description Core key only. Returns metadata of the credentials restricted to this Environment, oldest first; secrets are never listed. Connection combines current credential authority and an open matching gateway peer; timestamps are historical observations, not readiness. Without a gateway it is never connected. The Environment must be a self_hosted Environment of the Project whose Session exists; otherwise 404. // @Tags Executor Credentials // @Produce json // @Security DeploymentAdminAuth @@ -59,12 +78,27 @@ func (h *Handler) listExecutorCredentials(w http.ResponseWriter, r *http.Request if !ok { return } - credentials, err := s.ListProjectExecutorCredentials(r.Context(), binding.Principal, chi.URLParam(r, "environment_id")) + state, err := s.ProjectExecutorCredentialState(r.Context(), binding.Principal, chi.URLParam(r, "environment_id")) if err != nil { writeStoreError(w, r, err) return } - writeJSON(w, http.StatusOK, ExecutorCredentialList{Data: credentials}) + connection := ExecutorConnection{Status: "never_enrolled"} + observed := state.Connection + if observed.DeviceID != "" { + connection = ExecutorConnection{Status: "disconnected", BoundKeyID: observed.BoundKeyID, EnrolledAt: observed.EnrolledAt, LastSeenAt: observed.LastSeenAt} + if observed.EnvironmentStatus == "connected" && observed.CredentialHash != "" && h.executorConnections != nil { + connected, err := h.executorConnections(r.Context(), chi.URLParam(r, "environment_id"), observed.CredentialHash) + if err != nil && !errors.Is(err, store.ErrNotFound) && !errors.Is(err, store.ErrDeviceBindingConflict) { + writeStoreError(w, r, err) + return + } + if err == nil && connected { + connection.Status = "connected" + } + } + } + writeJSON(w, http.StatusOK, ExecutorCredentialList{Data: state.Credentials, Connection: connection}) } // @Summary Issue or explicitly rotate a self_hosted Environment executor credential diff --git a/services/agents-api/internal/api/environment_executor_management_test.go b/services/agents-api/internal/api/environment_executor_management_test.go index d5c0e73c5..e129d3e42 100644 --- a/services/agents-api/internal/api/environment_executor_management_test.go +++ b/services/agents-api/internal/api/environment_executor_management_test.go @@ -3,6 +3,7 @@ package api import ( "context" "encoding/json" + "errors" "strings" "testing" "time" @@ -21,6 +22,7 @@ type executorManagementFixture struct { rotate, audited bool calls int err error + connection store.ExecutorConnectionState } func (f *executorManagementFixture) record(ctx context.Context, principal identity.Principal, environment, key string) { @@ -28,9 +30,9 @@ func (f *executorManagementFixture) record(ctx context.Context, principal identi _, f.audited = adminaudit.FromContext(ctx) f.calls++ } -func (f *executorManagementFixture) ListProjectExecutorCredentials(ctx context.Context, principal identity.Principal, environment string) ([]store.ExecutorCredential, error) { +func (f *executorManagementFixture) ProjectExecutorCredentialState(ctx context.Context, principal identity.Principal, environment string) (store.ExecutorCredentialState, error) { f.record(ctx, principal, environment, "") - return []store.ExecutorCredential{{KeyID: "listed", CreatedAt: time.Unix(1, 0).UTC()}}, f.err + return store.ExecutorCredentialState{Credentials: []store.ExecutorCredential{{KeyID: "listed", CreatedAt: time.Unix(1, 0).UTC()}}, Connection: f.connection}, f.err } func (f *executorManagementFixture) IssueProjectExecutorCredential(ctx context.Context, principal identity.Principal, environment, key string, rotate bool) (store.IssuedExecutorCredential, error) { f.record(ctx, principal, environment, key) @@ -85,7 +87,7 @@ func TestProjectExecutorCredentialsHTTP(t *testing.T) { } w := projectKeyHTTP(h, "GET", path, "admin", "") - if w.Code != 200 || w.Body.String() != `{"data":[{"key_id":"listed","created_at":"1970-01-01T00:00:01Z","revoked_at":null}]}`+"\n" { + if w.Code != 200 || w.Body.String() != `{"data":[{"key_id":"listed","created_at":"1970-01-01T00:00:01Z","revoked_at":null}],"connection":{"status":"never_enrolled","bound_key_id":null,"enrolled_at":null,"last_seen_at":null}}`+"\n" { t.Fatal("list", w.Code, w.Body) } w = projectKeyHTTP(h, "POST", path, "admin", body) @@ -131,3 +133,53 @@ func TestProjectExecutorCredentialsHTTP(t *testing.T) { } } } + +func TestExecutorConnectionListObservation(t *testing.T) { + for _, tc := range []struct { + name string + observer bool + connected bool + err error + want string + status int + }{ + {"live", true, true, nil, "connected", 200}, {"closed", true, false, nil, "disconnected", 200}, + {"no registry", false, false, nil, "disconnected", 200}, {"rotated", true, false, store.ErrDeviceBindingConflict, "disconnected", 200}, + {"revoked", true, false, store.ErrNotFound, "disconnected", 200}, {"database failure", true, false, errors.New("private-database"), "", 500}, + } { + t.Run(tc.name, func(t *testing.T) { + key := callerBinding() + at := time.Unix(1, 0).UTC() + bound := "bound-key" + f := &executorManagementFixture{connection: store.ExecutorConnectionState{DeviceID: "device", BoundKeyID: &bound, EnrolledAt: &at, CredentialHash: "private-digest", EnvironmentStatus: "connected"}} + auth, _ := NewAuthenticator([]APIKey{key}) + admin, _ := NewDeploymentAuthenticator([]string{device.HashCredential("admin")}) + opts := []Option{WithProjectAPIKeys(managementProjectStore(key), admin)} + if tc.observer { + opts = append(opts, WithExecutorConnections(func(_ context.Context, environment, digest string) (bool, error) { + if environment != "environment" || digest != "private-digest" { + t.Fatal("wrong binding") + } + return tc.connected, tc.err + })) + } + h, err := NewHandler(f, auth, "codex", opts...) + if err != nil { + t.Fatal(err) + } + w := projectKeyHTTP(h, "GET", "/core/v1/projects/"+managementProjectID+"/environments/environment/executor-credentials", "admin", "") + if w.Code != tc.status { + t.Fatal(w.Code, w.Body) + } + if strings.Contains(w.Body.String(), "private-") { + t.Fatal("internal observation leaked") + } + if tc.status == 200 { + var got ExecutorCredentialList + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil || got.Connection.Status != tc.want || got.Connection.BoundKeyID == nil || *got.Connection.BoundKeyID != bound { + t.Fatal("projection", err, w.Body) + } + } + }) + } +} diff --git a/services/agents-api/internal/api/handler.go b/services/agents-api/internal/api/handler.go index 4ab72d73f..d959075f6 100644 --- a/services/agents-api/internal/api/handler.go +++ b/services/agents-api/internal/api/handler.go @@ -37,6 +37,7 @@ type ResourceStore interface { } type Handler struct { + executorConnections func(context.Context, string, string) (bool, error) coreMetrics CoreMetricsService sandboxStore *store.Store deploymentAuth *DeploymentAuthenticator diff --git a/services/agents-api/internal/db/queries/environment_executor_credentials.sql b/services/agents-api/internal/db/queries/environment_executor_credentials.sql index bc77eb818..241875d79 100644 --- a/services/agents-api/internal/db/queries/environment_executor_credentials.sql +++ b/services/agents-api/internal/db/queries/environment_executor_credentials.sql @@ -57,3 +57,13 @@ FROM environment_executor_credentials WHERE tenant_id = sqlc.arg(tenant_id) AND environment_id = sqlc.arg(environment_id) AND subject_kind = sqlc.arg(subject_kind) AND subject_id = sqlc.arg(subject_id) ORDER BY created_at, key_id; + +-- name: GetEnvironmentExecutorConnection :one +SELECT d.id AS device_id, d.executor_key_id, d.created_at AS enrolled_at, + d.last_seen_at, a.credential_hash, e.status AS environment_status +FROM environments e +JOIN sessions s ON s.id = e.session_id +LEFT JOIN devices d ON d.environment_id = e.id AND d.tenant_id = s.tenant_id +LEFT JOIN runtime_device_authority a ON a.id = d.id +WHERE e.id = sqlc.arg(environment_id) AND s.tenant_id = sqlc.arg(tenant_id) + AND s.deleted_at IS NULL AND s.configuration->'environment'->>'type' = 'self_hosted'; diff --git a/services/agents-api/internal/db/sqlc/environment_executor_credentials.sql.go b/services/agents-api/internal/db/sqlc/environment_executor_credentials.sql.go index 866850148..54db31689 100644 --- a/services/agents-api/internal/db/sqlc/environment_executor_credentials.sql.go +++ b/services/agents-api/internal/db/sqlc/environment_executor_credentials.sql.go @@ -54,6 +54,45 @@ func (q *Queries) ExecutorProjectScopeExists(ctx context.Context, arg ExecutorPr return exists, err } +const getEnvironmentExecutorConnection = `-- name: GetEnvironmentExecutorConnection :one +SELECT d.id AS device_id, d.executor_key_id, d.created_at AS enrolled_at, + d.last_seen_at, a.credential_hash, e.status AS environment_status +FROM environments e +JOIN sessions s ON s.id = e.session_id +LEFT JOIN devices d ON d.environment_id = e.id AND d.tenant_id = s.tenant_id +LEFT JOIN runtime_device_authority a ON a.id = d.id +WHERE e.id = $1 AND s.tenant_id = $2 + AND s.deleted_at IS NULL AND s.configuration->'environment'->>'type' = 'self_hosted' +` + +type GetEnvironmentExecutorConnectionParams struct { + EnvironmentID pgtype.UUID `json:"environment_id"` + TenantID pgtype.UUID `json:"tenant_id"` +} + +type GetEnvironmentExecutorConnectionRow struct { + DeviceID pgtype.UUID `json:"device_id"` + ExecutorKeyID pgtype.UUID `json:"executor_key_id"` + EnrolledAt pgtype.Timestamptz `json:"enrolled_at"` + LastSeenAt pgtype.Timestamptz `json:"last_seen_at"` + CredentialHash pgtype.Text `json:"credential_hash"` + EnvironmentStatus string `json:"environment_status"` +} + +func (q *Queries) GetEnvironmentExecutorConnection(ctx context.Context, arg GetEnvironmentExecutorConnectionParams) (GetEnvironmentExecutorConnectionRow, error) { + row := q.db.QueryRow(ctx, getEnvironmentExecutorConnection, arg.EnvironmentID, arg.TenantID) + var i GetEnvironmentExecutorConnectionRow + err := row.Scan( + &i.DeviceID, + &i.ExecutorKeyID, + &i.EnrolledAt, + &i.LastSeenAt, + &i.CredentialHash, + &i.EnvironmentStatus, + ) + return i, err +} + const getExecutorCredentialForPrincipal = `-- name: GetExecutorCredentialForPrincipal :one SELECT c.environment_id FROM environment_executor_credentials c diff --git a/services/agents-api/internal/runtimeenrollment/connection.go b/services/agents-api/internal/runtimeenrollment/connection.go index 07563337c..f143661de 100644 --- a/services/agents-api/internal/runtimeenrollment/connection.go +++ b/services/agents-api/internal/runtimeenrollment/connection.go @@ -46,7 +46,7 @@ func ConnectionHandler(s ConnectionStore, registry *gateway.Registry) http.Handl digest := device.HashCredential(authorization[1]) ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) defer cancel() - connected, err := runtimeConnected(ctx, s, registry, environment, digest) + connected, err := RuntimeConnected(ctx, s, registry, environment, digest) switch { case errors.Is(err, store.ErrNotFound): fail(http.StatusUnauthorized) @@ -68,7 +68,9 @@ func ConnectionHandler(s ConnectionStore, registry *gateway.Registry) http.Handl }) } -func runtimeConnected(ctx context.Context, s ConnectionStore, registry *gateway.Registry, environment, digest string) (bool, error) { +// RuntimeConnected observes current executor authority and a matching open peer. +// It rechecks authority after the peer; callers must not supply a stale transaction. +func RuntimeConnected(ctx context.Context, s ConnectionStore, registry *gateway.Registry, environment, digest string) (bool, error) { tenant, err := s.AuthenticateEnvironmentExecutor(ctx, environment, digest) if err != nil { return false, err @@ -100,6 +102,9 @@ func runtimeConnected(ctx context.Context, s ConnectionStore, registry *gateway. if credential.CredentialHash != digest { return false, store.ErrDeviceBindingConflict } + if registry == nil { + return false, nil + } peer, err := registry.LookupDevice(bound.ID) if errors.Is(err, gateway.ErrDeviceNotRegistered) { return false, nil @@ -115,5 +120,17 @@ func runtimeConnected(ctx context.Context, s ConnectionStore, registry *gateway. if _, err = s.AuthenticateEnvironmentExecutor(ctx, environment, digest); err != nil { return false, err } - return !peer.IsClosed(), nil + // The executor key can remain valid while the device itself is revoked. + // Recheck the shared authority view too, including Environment retirement. + credential, found, err = s.GetDeviceCredential(ctx, bound.ID) + if err != nil { + return false, err + } + if !found { + return false, store.ErrNotFound + } + if credential.CredentialHash != digest { + return false, store.ErrDeviceBindingConflict + } + return !peer.IsClosed() && peer.AuthenticatedWith(digest), nil } diff --git a/services/agents-api/internal/runtimeenrollment/connection_test.go b/services/agents-api/internal/runtimeenrollment/connection_test.go index 15ea4fee2..4b02ff7e3 100644 --- a/services/agents-api/internal/runtimeenrollment/connection_test.go +++ b/services/agents-api/internal/runtimeenrollment/connection_test.go @@ -3,9 +3,13 @@ package runtimeenrollment import ( "context" "errors" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/gorilla/websocket" + "net/http" "net/http/httptest" "strings" "testing" + "time" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" @@ -65,3 +69,100 @@ func TestConnectionReadContract(t *testing.T) { } } } + +// A real gateway peer captures its digest at HTTP upgrade. The test store makes +// authority changes at the deterministic post-peer recheck, without timing sleeps. +type liveConnectionStore struct { + digest string + authCalls int + credentialCalls int + revokeAtRecheck bool + deviceRevokedAtRecheck bool + recheckError error + credentialRecheckError error +} + +func (s *liveConnectionStore) AuthenticateEnvironmentExecutor(context.Context, string, string) (string, error) { + s.authCalls++ + if s.authCalls == 2 { + if s.recheckError != nil { + return "", s.recheckError + } + if s.revokeAtRecheck { + return "", store.ErrNotFound + } + } + return "tenant", nil +} +func (s *liveConnectionStore) GetEnvironment(context.Context, string, string) (store.Environment, error) { + return store.Environment{ID: "environment", SessionID: "session", Status: "connected"}, nil +} +func (s *liveConnectionStore) GetSessionDevice(context.Context, string, string) (store.ExecutionDevice, error) { + return store.ExecutionDevice{ID: "device", EnvironmentID: "environment"}, nil +} +func (s *liveConnectionStore) GetDeviceCredential(context.Context, string) (device.Credential, bool, error) { + s.credentialCalls++ + if s.authCalls >= 2 && s.credentialRecheckError != nil { + return device.Credential{}, false, s.credentialRecheckError + } + if s.deviceRevokedAtRecheck && s.authCalls >= 2 { + return device.Credential{}, false, nil + } + return device.Credential{ID: "device", WorkspaceID: "tenant", Type: gateway.RuntimeTypeAgentDaemon, CredentialHash: s.digest}, true, nil +} +func TestRuntimeConnectedCurrentAuthorityAfterPeer(t *testing.T) { + for _, name := range []string{"connected", "rotated before read", "revoked after peer", "device revoked after peer", "retired after peer", "store error after peer", "device store error after peer", "closed", "no registry"} { + t.Run(name, func(t *testing.T) { + digest := device.HashCredential("fixture-key") + s := &liveConnectionStore{digest: digest} + registry := gateway.NewRegistry() + handler := gateway.NewHandler(gateway.HandlerConfig{Registry: registry, Authenticator: gateway.NewAuthenticator(s)}) + server := httptest.NewServer(http.HandlerFunc(handler.WS)) + defer server.Close() + conn, _, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(server.URL, "http")+"?device_id=device&version="+proto.Version, http.Header{"Authorization": {"Bearer fixture-key"}}) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + // Wait for the actual registration, not merely the transport upgrade. + ctx, cancel := context.WithTimeout(t.Context(), time.Second) + defer cancel() + peer, err := registry.WaitForDevice(ctx, "device", time.Second) + if err != nil { + t.Fatal(err) + } + defer peer.Close("test complete") + s.authCalls = 0 + var wantErr error + want := name == "connected" + switch name { + case "rotated before read": + s.digest = device.HashCredential("new-key") + digest = s.digest + case "revoked after peer": + s.revokeAtRecheck = true + wantErr = store.ErrNotFound + case "device revoked after peer", "retired after peer": + s.deviceRevokedAtRecheck = true + wantErr = store.ErrNotFound + case "store error after peer": + s.recheckError = errors.New("database unavailable") + wantErr = s.recheckError + case "device store error after peer": + s.credentialRecheckError = errors.New("device authority unavailable") + wantErr = s.credentialRecheckError + case "closed": + peer.Close("closed before observation") + case "no registry": + registry = nil + } + connected, err := RuntimeConnected(t.Context(), s, registry, "environment", digest) + if connected != want || !errors.Is(err, wantErr) { + t.Fatalf("connected=%v err=%v", connected, err) + } + if name == "connected" && s.authCalls != 2 { + t.Fatal("post-peer authority was not checked") + } + }) + } +} diff --git a/services/agents-api/internal/store/environment_executor_management.go b/services/agents-api/internal/store/environment_executor_management.go index 62c8e9380..f8f3575c4 100644 --- a/services/agents-api/internal/store/environment_executor_management.go +++ b/services/agents-api/internal/store/environment_executor_management.go @@ -26,31 +26,84 @@ type ExecutorCredential struct { // principal; the target must be a self_hosted Environment of that Project whose // Session is not deleted, and only credentials restricted to it are managed. +// ExecutorConnectionState is an internal durable observation, never a wire payload. +// In particular the current credential digest must not be serialized. +type ExecutorConnectionState struct { + DeviceID string `json:"-"` + BoundKeyID *string `json:"-"` + EnrolledAt *time.Time `json:"-"` + LastSeenAt *time.Time `json:"-"` + CredentialHash string `json:"-"` + EnvironmentStatus string `json:"-"` +} + +type ExecutorCredentialState struct { + Credentials []ExecutorCredential + Connection ExecutorConnectionState +} + func (s *Store) ListProjectExecutorCredentials(ctx context.Context, project identity.Principal, environment string) ([]ExecutorCredential, error) { - if err := s.selfHostedExecutorTarget(ctx, project, environment); err != nil { - return nil, err + state, err := s.ProjectExecutorCredentialState(ctx, project, environment) + return state.Credentials, err +} + +// ProjectExecutorCredentialState reads list metadata and binding facts in one +// read-only snapshot. The snapshot ends before any live peer/authority observation. +func (s *Store) ProjectExecutorCredentialState(ctx context.Context, project identity.Principal, environment string) (ExecutorCredentialState, error) { + if err := project.Validate(); err != nil { + return ExecutorCredentialState{}, ErrInvalidInput } tenant, err := parseID(project.TenantID) if err != nil { - return nil, err + return ExecutorCredentialState{}, err } - rows, err := s.queries.ListEnvironmentExecutorCredentials(ctx, sqlc.ListEnvironmentExecutorCredentialsParams{ - TenantID: tenant, EnvironmentID: parsePathID(environment), - SubjectKind: pgtype.Text{String: project.SubjectKind, Valid: true}, SubjectID: pgtype.Text{String: project.SubjectID, Valid: true}, - }) - if err != nil { - return nil, err - } - result := make([]ExecutorCredential, 0, len(rows)) - for _, row := range rows { - credential := ExecutorCredential{KeyID: uuid.UUID(row.KeyID.Bytes).String(), CreatedAt: row.CreatedAt.Time} - if row.RevokedAt.Valid { - revoked := row.RevokedAt.Time - credential.RevokedAt = &revoked + result := ExecutorCredentialState{Credentials: []ExecutorCredential{}} + err = pgx.BeginTxFunc(ctx, s.pool, pgx.TxOptions{IsoLevel: pgx.RepeatableRead, AccessMode: pgx.ReadOnly}, func(tx pgx.Tx) error { + q := s.queries.WithTx(tx) + row, err := q.GetEnvironmentExecutorConnection(ctx, sqlc.GetEnvironmentExecutorConnectionParams{EnvironmentID: parsePathID(environment), TenantID: tenant}) + if errors.Is(err, pgx.ErrNoRows) { + return ErrNotFound } - result = append(result, credential) - } - return result, nil + if err != nil { + return err + } + result.Connection.EnvironmentStatus = row.EnvironmentStatus + if row.DeviceID.Valid { + result.Connection.DeviceID = uuid.UUID(row.DeviceID.Bytes).String() + } + if row.ExecutorKeyID.Valid { + key := uuid.UUID(row.ExecutorKeyID.Bytes).String() + result.Connection.BoundKeyID = &key + } + if row.EnrolledAt.Valid { + at := row.EnrolledAt.Time + result.Connection.EnrolledAt = &at + } + if row.LastSeenAt.Valid { + at := row.LastSeenAt.Time + result.Connection.LastSeenAt = &at + } + if row.CredentialHash.Valid { + result.Connection.CredentialHash = row.CredentialHash.String + } + rows, err := q.ListEnvironmentExecutorCredentials(ctx, sqlc.ListEnvironmentExecutorCredentialsParams{ + TenantID: tenant, EnvironmentID: parsePathID(environment), + SubjectKind: pgtype.Text{String: project.SubjectKind, Valid: true}, SubjectID: pgtype.Text{String: project.SubjectID, Valid: true}, + }) + if err != nil { + return err + } + for _, row := range rows { + credential := ExecutorCredential{KeyID: uuid.UUID(row.KeyID.Bytes).String(), CreatedAt: row.CreatedAt.Time} + if row.RevokedAt.Valid { + revoked := row.RevokedAt.Time + credential.RevokedAt = &revoked + } + result.Credentials = append(result.Credentials, credential) + } + return nil + }) + return result, err } // IssueProjectExecutorCredential issues a new key or, with rotate, replaces the diff --git a/services/agents-api/internal/store/environment_executor_management_test.go b/services/agents-api/internal/store/environment_executor_management_test.go index f40b60ccf..781f66b6e 100644 --- a/services/agents-api/internal/store/environment_executor_management_test.go +++ b/services/agents-api/internal/store/environment_executor_management_test.go @@ -2,6 +2,7 @@ package store import ( "context" + "encoding/json" "errors" "strings" "testing" @@ -198,3 +199,109 @@ func TestArchivedProjectExecutorCredentials(t *testing.T) { t.Fatal("revoked list", listed, err) } } + +func TestProjectExecutorConnectionState(t *testing.T) { + s, pool := testStore(t) + ctx := t.Context() + project := createTestProject(t, s) + binding, err := s.GetProject(ctx, project.ID) + if err != nil { + t.Fatal(err) + } + session, env, key := runtimeEnrollmentFixture(t, s, binding.Principal) + state, err := s.ProjectExecutorCredentialState(ctx, binding.Principal, env.ID) + if err != nil || state.Connection.DeviceID != "" || state.Connection.EnrolledAt != nil || state.Connection.BoundKeyID != nil || state.Connection.LastSeenAt != nil { + t.Fatal("never enrolled", state, err) + } + enrolled, err := s.EnrollRuntime(ctx, env.ID, executorDigest(key.Token)) + if err != nil { + t.Fatal(err) + } + check := func() ExecutorCredentialState { + t.Helper() + v, e := s.ProjectExecutorCredentialState(ctx, binding.Principal, env.ID) + if e != nil { + t.Fatal(e) + } + return v + } + state = check() + if state.Connection.DeviceID != enrolled.DeviceID || state.Connection.BoundKeyID == nil || *state.Connection.BoundKeyID != key.KeyID || state.Connection.EnrolledAt == nil || state.Connection.LastSeenAt != nil || state.Connection.CredentialHash != executorDigest(key.Token) { + t.Fatal("binding", state) + } + // An additional credential never changes the enrolled device's bound key. + if _, err = s.IssueProjectExecutorCredential(keyAdminContext(ctx, project.ID), binding.Principal, env.ID, uuid.NewString(), false); err != nil { + t.Fatal(err) + } + state = check() + if *state.Connection.BoundKeyID != key.KeyID || len(state.Credentials) != 2 { + t.Fatal("second key changed binding") + } + if _, err = pool.Exec(ctx, "UPDATE devices SET last_seen_at=clock_timestamp() WHERE id=$1", enrolled.DeviceID); err != nil { + t.Fatal(err) + } + state = check() + if state.Connection.LastSeenAt == nil { + t.Fatal("heartbeat history missing") + } + rotated, err := s.IssueProjectExecutorCredential(keyAdminContext(ctx, project.ID), binding.Principal, env.ID, key.KeyID, true) + if err != nil { + t.Fatal(err) + } + state = check() + if state.Connection.CredentialHash != executorDigest(rotated.Token) || *state.Connection.BoundKeyID != key.KeyID { + t.Fatal("rotation must expose only the fresh authority internally") + } + if _, err = pool.Exec(ctx, "UPDATE environments SET status='expired' WHERE id=$1", env.ID); err != nil { + t.Fatal(err) + } + if _, err = s.AuthenticateEnvironmentExecutor(ctx, env.ID, executorDigest(rotated.Token)); err != nil { + t.Fatal("fixture executor key should still authenticate", err) + } + state = check() + if state.Connection.CredentialHash != "" { + t.Fatal("retired Environment retained device authority") + } + if _, err = pool.Exec(ctx, "UPDATE environments SET status='connected' WHERE id=$1", env.ID); err != nil { + t.Fatal(err) + } + if err = s.RevokeProjectExecutorCredential(keyAdminContext(ctx, project.ID), binding.Principal, env.ID, key.KeyID); err != nil { + t.Fatal(err) + } + state = check() + if state.Connection.CredentialHash != "" || state.Connection.EnrolledAt == nil || *state.Connection.BoundKeyID != key.KeyID { + t.Fatal("revocation lost history or retained authority") + } + raw, err := json.Marshal(state.Connection) + if err != nil || string(raw) != "{}" { + t.Fatal("internal facts serialize", string(raw), err) + } + // Existing target visibility is preserved: an expired self-hosted Environment + // is readable but never has runtime_device_authority; deletion removes it. + if _, err = pool.Exec(ctx, "UPDATE environments SET status='expired' WHERE id=$1", env.ID); err != nil { + t.Fatal(err) + } + state = check() + if state.Connection.EnvironmentStatus != "expired" || state.Connection.CredentialHash != "" { + t.Fatal("expired authority") + } + foreignProject := createTestProject(t, s) + foreign, err := s.GetProject(ctx, foreignProject.ID) + if err != nil { + t.Fatal(err) + } + for _, id := range []string{uuid.NewString(), "malformed"} { + if _, err = s.ProjectExecutorCredentialState(ctx, binding.Principal, id); !errors.Is(err, ErrNotFound) { + t.Fatal("missing target", err) + } + } + if _, err = s.ProjectExecutorCredentialState(ctx, foreign.Principal, env.ID); !errors.Is(err, ErrNotFound) { + t.Fatal("foreign target", err) + } + if _, err = pool.Exec(ctx, "UPDATE sessions SET deleted_at=clock_timestamp() WHERE id=$1", session.ID); err != nil { + t.Fatal(err) + } + if _, err = s.ProjectExecutorCredentialState(ctx, binding.Principal, env.ID); !errors.Is(err, ErrNotFound) { + t.Fatal("deleted target", err) + } +} From 8bd6e79c77fd106c9d5514e3c98d24ab4ff26a90 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Mon, 28 Sep 2026 05:08:51 +0000 Subject: [PATCH 2/4] Align executor connection documentation and browser fixture --- CONTRIBUTING.md | 11 +++++++++++ apps/web/e2e/fixture-console.mjs | 6 ++++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2272dac20..4f801703a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1976,6 +1976,17 @@ Docker's `unless-stopped` restart loop. Rerunning the installer with the same ke rotated replaces it in the same stopped container through the launcher's `replace-credential`, which checks the container's labels, name, volumes and private directories; never relaunch or adopt history to recover a credential. +Core-key executor credential lists expose a required connection observation with +never_enrolled, connected or disconnected status, immutable bound key identity, +enrollment time and last authenticated heartbeat time. Read credential metadata +and binding facts in a closed read-only snapshot, then reuse runtimeenrollment's +current authority and actual gateway peer checks. Recheck executor and device +authority after reading the peer; rotation, revocation or Environment retirement +must not inherit a former key's connected state. No gateway means not connected, +never an authentication bypass. Known authority loss is disconnected; storage +errors remain errors. Keep digests/device IDs internal and public /v1 unchanged. +Connection timestamps are history, not execution/native/model readiness. + Self-hosted installation confirms connection through the private daemon transport using only its restricted executor credential. The read checks the exact live Environment/key binding and current authenticated connection; it never enrolls, diff --git a/apps/web/e2e/fixture-console.mjs b/apps/web/e2e/fixture-console.mjs index 2c44ac18d..0c5b51198 100644 --- a/apps/web/e2e/fixture-console.mjs +++ b/apps/web/e2e/fixture-console.mjs @@ -431,7 +431,10 @@ async function executorCredentialRoute(request, response, projectId, environment if (!session) return error(response, 404, "No such self-hosted environment.", "not_found_error"); if (!state.executorCredentials.has(environmentId)) state.executorCredentials.set(environmentId, []); const credentials = state.executorCredentials.get(environmentId); - if (!keyId && request.method === "GET") return send(response, 200, { data: credentials.map((entry) => ({ ...entry })) }); + if (!keyId && request.method === "GET") return send(response, 200, { + data: credentials.map((entry) => ({ ...entry })), + connection: { status: "never_enrolled", bound_key_id: null, enrolled_at: null, last_seen_at: null }, + }); if (!keyId && request.method === "POST") { // As Core, a body that is not JSON is invalid input like any other: 400 with one message. const input = await body(request).catch(() => null); @@ -611,4 +614,3 @@ http.createServer(async (request, response) => { error(response, 500, String(caught)); } }).listen(port, "127.0.0.1", () => console.log(`Console acceptance fixture on http://127.0.0.1:${port}`)); - From 4f1f0d53ae9049ab3ce5e249549a95176aefebea Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Mon, 28 Sep 2026 05:17:06 +0000 Subject: [PATCH 3/4] Regenerate executor connection API reference --- .../core/executor-credentials.mdx | 8 +++-- apps/docs/openapi/core-api.yaml | 36 ++++++++++++++++++- apps/docs/openapi/sources.json | 6 ++-- 3 files changed, 43 insertions(+), 7 deletions(-) diff --git a/apps/docs/content/docs/api-reference/core/executor-credentials.mdx b/apps/docs/content/docs/api-reference/core/executor-credentials.mdx index 326c5bed2..2ef8db657 100644 --- a/apps/docs/content/docs/api-reference/core/executor-credentials.mdx +++ b/apps/docs/content/docs/api-reference/core/executor-credentials.mdx @@ -12,9 +12,11 @@ _openapi: contents: - content: >- Core key only. Returns metadata of the credentials restricted to this - Environment, oldest first; secrets are never listed. The Environment - must be a self_hosted Environment of the Project whose Session exists; - otherwise 404. + Environment, oldest first; secrets are never listed. Connection + combines current credential authority and an open matching gateway + peer; timestamps are historical observations, not readiness. Without a + gateway it is never connected. The Environment must be a self_hosted + Environment of the Project whose Session exists; otherwise 404. - content: >- Core key only. Returns a connect-only secret once, restricted to daemon enrollment and connection for this Environment, with the diff --git a/apps/docs/openapi/core-api.yaml b/apps/docs/openapi/core-api.yaml index f90dc61ac..0832b7e56 100644 --- a/apps/docs/openapi/core-api.yaml +++ b/apps/docs/openapi/core-api.yaml @@ -878,7 +878,7 @@ paths: - Environment Templates /core/v1/projects/{project_id}/environments/{environment_id}/executor-credentials: get: - description: Core key only. Returns metadata of the credentials restricted to this Environment, oldest first; secrets are never listed. The Environment must be a self_hosted Environment of the Project whose Session exists; otherwise 404. + description: Core key only. Returns metadata of the credentials restricted to this Environment, oldest first; secrets are never listed. Connection combines current credential authority and an open matching gateway peer; timestamps are historical observations, not readiness. Without a gateway it is never connected. The Environment must be a self_hosted Environment of the Project whose Session exists; otherwise 404. parameters: - schema: type: string @@ -4240,12 +4240,46 @@ components: rotate: type: boolean type: object + api.ExecutorConnection: + properties: + bound_key_id: + anyOf: + - format: uuid + type: string + - type: 'null' + enrolled_at: + anyOf: + - format: date-time + type: string + - type: 'null' + last_seen_at: + anyOf: + - format: date-time + type: string + - type: 'null' + status: + enum: + - never_enrolled + - connected + - disconnected + type: string + required: + - bound_key_id + - enrolled_at + - last_seen_at + - status + type: object api.ExecutorCredentialList: properties: + connection: + $ref: '#/components/schemas/api.ExecutorConnection' data: items: $ref: '#/components/schemas/store.ExecutorCredential' type: array + required: + - connection + - data type: object api.HarnessModelProvider: properties: diff --git a/apps/docs/openapi/sources.json b/apps/docs/openapi/sources.json index a8ed47ac1..f6d797ee4 100644 --- a/apps/docs/openapi/sources.json +++ b/apps/docs/openapi/sources.json @@ -1,7 +1,7 @@ { "sources": { "contracts/agents-api/openapi.yaml": "a8896e8b67d78ed0608c7cc5636077ec33f4b4895a04bd6cfb8b038afe941e01", - "contracts/agents-api/core.openapi.yaml": "5594e256e06a608d0be4232e395acbe0bb1d258ff4749112d2f5d85ff05e3c54", + "contracts/agents-api/core.openapi.yaml": "e0bcc1b017c358b543a80cf1d50d33304ea92857015ed15e06f6646fb9213c64", "contracts/agents-api/runtime.openapi.yaml": "23852205d3b45892f2de9ecf3d52518c0dd0808962c0c512f219b565aa7ccef2" }, "outputs": { @@ -26,7 +26,7 @@ "content/docs/api-reference/core/administrator-projects.mdx": "8fd687bb2a7119fa16e938d7c71710c2816afb9cf959eb6849ef5e6300c5419b", "content/docs/api-reference/core/agents.mdx": "b6c089fbea4b07bc4249e7c7ca5699d47f1512a4fc2d8ee01cb26910ae541a84", "content/docs/api-reference/core/environment-templates.mdx": "05907f35d7f02d66af9da8b1efaa3765badb4cdf7b94d0f6c3ab0413a2cfa1a9", - "content/docs/api-reference/core/executor-credentials.mdx": "f73656c850bf982b187ac5586669dabae1e4d3b714293a7bae090127b25e479b", + "content/docs/api-reference/core/executor-credentials.mdx": "352dc9cb02cceab6a7fa4fe13356e9532c329277224e7e01d18ae0c02cad1a3e", "content/docs/api-reference/core/files.mdx": "05ce1d2296dbb3fc76cf4cf98d24dff249114c73529335214cee97363229f384", "content/docs/api-reference/core/write-audit.mdx": "1298590ab7037077e56f6ab8adb020277763f7a670be796d24142323b2f95411", "content/docs/api-reference/core/sessions.mdx": "e8519b5d3b21296b0ea1edede3160766ad6a49197b92b201ee1a845542d99619", @@ -40,7 +40,7 @@ "content/docs/api-reference/core/vaults.mdx": "12d0aac96cd4a3472ef4eaa287f7945f78862017956e79bfc3ed0ef8e772e78a", "content/docs/api-reference/core/credentials.mdx": "27d586b9a8fd738b9947802f32c80458195370252886bc26abedccf2125ee981", "content/docs/api-reference/core/sandbox-manager.mdx": "6be7c52e057365949f0247c18c710561d680847a955f78f1bbe57825d4d5f549", - "openapi/core-api.yaml": "d0fcaa48655905f72d7f8a0a58208e56d88570ebd882b08795625ebc8c4308d3", + "openapi/core-api.yaml": "5f40de88d0c39e6083f5c002e2772a03971a266a841726d76c594570f6b9bbcd", "content/docs/api-reference/core/meta.json": "588b163ed2de2100b0f8376688274f80698a6bf6ac037eef56d17c93fa8a5062", "content/docs/api-reference/core/index.mdx": "ddf0a8d87a6c21761beac703a32f2a40486a1c74abb341bd726f40c44a866405", "content/docs/api-reference/machine/sandbox-node.mdx": "5b367a08d43cea82686345d2ba1c75cc90627a7b2970fe42a0df9d77fb3e1e80", From 35dcc1e477b7a132b9632ff54446c7ef30d8d44f Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Mon, 28 Sep 2026 05:20:46 +0000 Subject: [PATCH 4/4] fix(core): observe executor connection using resolved environment ID --- .../api/environment_executor_management.go | 2 +- .../environment_executor_management_test.go | 55 ++++++++++++++++--- .../store/environment_executor_management.go | 11 ++-- .../environment_executor_management_test.go | 6 ++ 4 files changed, 62 insertions(+), 12 deletions(-) diff --git a/services/agents-api/internal/api/environment_executor_management.go b/services/agents-api/internal/api/environment_executor_management.go index 13437a403..88ac376ba 100644 --- a/services/agents-api/internal/api/environment_executor_management.go +++ b/services/agents-api/internal/api/environment_executor_management.go @@ -88,7 +88,7 @@ func (h *Handler) listExecutorCredentials(w http.ResponseWriter, r *http.Request if observed.DeviceID != "" { connection = ExecutorConnection{Status: "disconnected", BoundKeyID: observed.BoundKeyID, EnrolledAt: observed.EnrolledAt, LastSeenAt: observed.LastSeenAt} if observed.EnvironmentStatus == "connected" && observed.CredentialHash != "" && h.executorConnections != nil { - connected, err := h.executorConnections(r.Context(), chi.URLParam(r, "environment_id"), observed.CredentialHash) + connected, err := h.executorConnections(r.Context(), state.EnvironmentID, observed.CredentialHash) if err != nil && !errors.Is(err, store.ErrNotFound) && !errors.Is(err, store.ErrDeviceBindingConflict) { writeStoreError(w, r, err) return diff --git a/services/agents-api/internal/api/environment_executor_management_test.go b/services/agents-api/internal/api/environment_executor_management_test.go index e129d3e42..210e4386f 100644 --- a/services/agents-api/internal/api/environment_executor_management_test.go +++ b/services/agents-api/internal/api/environment_executor_management_test.go @@ -17,12 +17,13 @@ import ( type executorManagementFixture struct { ResourceStore - principal identity.Principal - environment, key string - rotate, audited bool - calls int - err error - connection store.ExecutorConnectionState + principal identity.Principal + environment, key string + rotate, audited bool + calls int + err error + connection store.ExecutorConnectionState + resolvedEnvironment string } func (f *executorManagementFixture) record(ctx context.Context, principal identity.Principal, environment, key string) { @@ -32,7 +33,11 @@ func (f *executorManagementFixture) record(ctx context.Context, principal identi } func (f *executorManagementFixture) ProjectExecutorCredentialState(ctx context.Context, principal identity.Principal, environment string) (store.ExecutorCredentialState, error) { f.record(ctx, principal, environment, "") - return store.ExecutorCredentialState{Credentials: []store.ExecutorCredential{{KeyID: "listed", CreatedAt: time.Unix(1, 0).UTC()}}, Connection: f.connection}, f.err + resolved := f.resolvedEnvironment + if resolved == "" { + resolved = environment + } + return store.ExecutorCredentialState{EnvironmentID: resolved, Credentials: []store.ExecutorCredential{{KeyID: "listed", CreatedAt: time.Unix(1, 0).UTC()}}, Connection: f.connection}, f.err } func (f *executorManagementFixture) IssueProjectExecutorCredential(ctx context.Context, principal identity.Principal, environment, key string, rotate bool) (store.IssuedExecutorCredential, error) { f.record(ctx, principal, environment, key) @@ -183,3 +188,39 @@ func TestExecutorConnectionListObservation(t *testing.T) { }) } } + +func TestExecutorConnectionListUsesResolvedEnvironment(t *testing.T) { + const canonical = "a21e4155-d8bb-4e99-afce-273a907efbc8" + key := callerBinding() + f := &executorManagementFixture{ + resolvedEnvironment: canonical, + connection: store.ExecutorConnectionState{DeviceID: "device", CredentialHash: "private-digest", EnvironmentStatus: "connected"}, + } + auth, _ := NewAuthenticator([]APIKey{key}) + admin, _ := NewDeploymentAuthenticator([]string{device.HashCredential("admin")}) + observations := 0 + h, err := NewHandler(f, auth, "codex", WithProjectAPIKeys(managementProjectStore(key), admin), + WithExecutorConnections(func(_ context.Context, environment, digest string) (bool, error) { + observations++ + if environment != canonical || digest != "private-digest" { + return false, store.ErrDeviceBindingConflict + } + return true, nil + })) + if err != nil { + t.Fatal(err) + } + for _, spelling := range []string{canonical, strings.ToUpper(canonical), strings.ReplaceAll(canonical, "-", "")} { + w := projectKeyHTTP(h, "GET", "/core/v1/projects/"+managementProjectID+"/environments/"+spelling+"/executor-credentials", "admin", "") + var got ExecutorCredentialList + if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &got) != nil || got.Connection.Status != "connected" { + t.Fatalf("equivalent target %q: %d %s", spelling, w.Code, w.Body.String()) + } + if f.environment != spelling { + t.Fatal("target spelling did not reach store resolution") + } + } + if observations != 3 { + t.Fatal("missing live authority observations", observations) + } +} diff --git a/services/agents-api/internal/store/environment_executor_management.go b/services/agents-api/internal/store/environment_executor_management.go index f8f3575c4..f14e7954e 100644 --- a/services/agents-api/internal/store/environment_executor_management.go +++ b/services/agents-api/internal/store/environment_executor_management.go @@ -38,8 +38,9 @@ type ExecutorConnectionState struct { } type ExecutorCredentialState struct { - Credentials []ExecutorCredential - Connection ExecutorConnectionState + EnvironmentID string `json:"-"` + Credentials []ExecutorCredential + Connection ExecutorConnectionState } func (s *Store) ListProjectExecutorCredentials(ctx context.Context, project identity.Principal, environment string) ([]ExecutorCredential, error) { @@ -57,16 +58,18 @@ func (s *Store) ProjectExecutorCredentialState(ctx context.Context, project iden if err != nil { return ExecutorCredentialState{}, err } + environmentID := parsePathID(environment) result := ExecutorCredentialState{Credentials: []ExecutorCredential{}} err = pgx.BeginTxFunc(ctx, s.pool, pgx.TxOptions{IsoLevel: pgx.RepeatableRead, AccessMode: pgx.ReadOnly}, func(tx pgx.Tx) error { q := s.queries.WithTx(tx) - row, err := q.GetEnvironmentExecutorConnection(ctx, sqlc.GetEnvironmentExecutorConnectionParams{EnvironmentID: parsePathID(environment), TenantID: tenant}) + row, err := q.GetEnvironmentExecutorConnection(ctx, sqlc.GetEnvironmentExecutorConnectionParams{EnvironmentID: environmentID, TenantID: tenant}) if errors.Is(err, pgx.ErrNoRows) { return ErrNotFound } if err != nil { return err } + result.EnvironmentID = uuid.UUID(environmentID.Bytes).String() result.Connection.EnvironmentStatus = row.EnvironmentStatus if row.DeviceID.Valid { result.Connection.DeviceID = uuid.UUID(row.DeviceID.Bytes).String() @@ -87,7 +90,7 @@ func (s *Store) ProjectExecutorCredentialState(ctx context.Context, project iden result.Connection.CredentialHash = row.CredentialHash.String } rows, err := q.ListEnvironmentExecutorCredentials(ctx, sqlc.ListEnvironmentExecutorCredentialsParams{ - TenantID: tenant, EnvironmentID: parsePathID(environment), + TenantID: tenant, EnvironmentID: environmentID, SubjectKind: pgtype.Text{String: project.SubjectKind, Valid: true}, SubjectID: pgtype.Text{String: project.SubjectID, Valid: true}, }) if err != nil { diff --git a/services/agents-api/internal/store/environment_executor_management_test.go b/services/agents-api/internal/store/environment_executor_management_test.go index 781f66b6e..645df6d90 100644 --- a/services/agents-api/internal/store/environment_executor_management_test.go +++ b/services/agents-api/internal/store/environment_executor_management_test.go @@ -229,6 +229,12 @@ func TestProjectExecutorConnectionState(t *testing.T) { if state.Connection.DeviceID != enrolled.DeviceID || state.Connection.BoundKeyID == nil || *state.Connection.BoundKeyID != key.KeyID || state.Connection.EnrolledAt == nil || state.Connection.LastSeenAt != nil || state.Connection.CredentialHash != executorDigest(key.Token) { t.Fatal("binding", state) } + for _, spelling := range []string{env.ID, strings.ToUpper(env.ID), strings.ReplaceAll(env.ID, "-", "")} { + resolved, err := s.ProjectExecutorCredentialState(ctx, binding.Principal, spelling) + if err != nil || resolved.EnvironmentID != env.ID || resolved.Connection.DeviceID != enrolled.DeviceID || resolved.Connection.CredentialHash != executorDigest(key.Token) { + t.Fatal("equivalent target did not retain canonical identity and binding", spelling, resolved, err) + } + } // An additional credential never changes the enrolled device's bound key. if _, err = s.IssueProjectExecutorCredential(keyAdminContext(ctx, project.ID), binding.Principal, env.ID, uuid.NewString(), false); err != nil { t.Fatal(err)