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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 35 additions & 1 deletion apps/docs/openapi/core-api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
6 changes: 3 additions & 3 deletions apps/docs/openapi/sources.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand All @@ -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",
Expand All @@ -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",
Expand Down
6 changes: 4 additions & 2 deletions apps/web/e2e/fixture-console.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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}`));

33 changes: 32 additions & 1 deletion contracts/agents-api/core.openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
25 changes: 24 additions & 1 deletion contracts/agents-api/environment-executor-credentials.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,37 @@ 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 |

The list holds metadata only, oldest first, for the credentials restricted to this
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:
Expand Down
11 changes: 9 additions & 2 deletions packages/agents-client/src/admin-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof globalThis.fetch>().mockImplementation(async () => new Response(null, { status: 204 }));
await expect(new AdminClient({ fetch }).revokeExecutorCredential(projectId, resourceId, keyId)).resolves.toBeUndefined();
Expand Down
4 changes: 2 additions & 2 deletions packages/agents-client/src/admin-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<ExecutorCredentialList> {
return projectExecutorCredentials(await this.#json(`${scope(projectId)}/environments/${segment(environmentId)}/executor-credentials`, options));
}
/**
Expand Down
15 changes: 11 additions & 4 deletions packages/agents-client/src/admin-projection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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 };
Expand Down
9 changes: 9 additions & 0 deletions packages/agents-client/src/admin-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,15 @@ export interface AdminAuditPage extends AdminPage<AdminAuditEntry> { 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. */
Expand Down
Loading
Loading