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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions contracts/agents-api/sandbox-deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -276,9 +276,16 @@ Malformed selections return 400; validated configuration diagnostics use
`invalid_sandbox_configuration`. Stale generations, retained resources or an
incompatible deployment return 409 `sandbox_deployment_conflict`. A node
configuration mismatch returns 409 `sandbox_specification_mismatch`; rejected
node credentials return 401 `invalid_node_credential`. Unavailable provider
preparation returns 503 `execution_unavailable`. Storage and credential failures
remain errors; an empty or failed read is not evidence of cleanup.
node credentials return 401 `invalid_node_credential`. Node machine routes check
the credential before any deployment state, so a missing or rejected credential,
including one issued for another installation, gets that 401 even before
initialization or under E2B. Until the deployment is initialized,
`GET /api/v1/sandbox-node/configuration` and `POST /api/v1/sandbox-node/enroll`
answer an otherwise accepted credential with 503 `runtime_node_unavailable`;
`GET /api/v1/sandbox-node/identity` and the node connection answer 401 for any
credential. Unavailable provider preparation returns 503 `execution_unavailable`.
Storage and credential failures remain errors; an empty or failed read is not
evidence of cleanup.

The administrator node list and node detail report an unready provider with one
fixed `diagnostic` code: `docker_unavailable`, `docker_limits_unsupported`,
Expand Down
10 changes: 8 additions & 2 deletions contracts/agents-api/v1/model_execution_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,15 @@ func TestModelExecutionValidation(t *testing.T) {
t.Fatalf("incorrect protocol validation: %s/%s", tc.protocol, tc.harness)
}
}
for _, url := range []string{"http://example.com", "https://user:pass@example.com", "https://example.com?key=secret", "https://example.com#secret", "https://"} {
for _, url := range []string{"http://example.com", "https://user:pass@example.com", "https://example.com?key=secret", "https://example.com#secret", "https://",
"https://example.com:99999/v1", "https://example.com:0/v1", "https://xn--.test", "https://xn--a.test", "https://a..b", "https://999.1.1.1"} {
if (&ModelProviderInput{Protocol: "responses", BaseURL: url, APIKey: "secret"}).Validate() == nil {
t.Fatal("unsafe provider URL accepted")
t.Fatal("unsafe or unusable provider URL accepted", url)
}
}
for _, url := range []string{"https://example.com:8443/v1", "https://127.0.0.1/v1", "https://[::1]:8443/v1", "https://model_gateway.internal/v1", "https://bücher.example/v1"} {
if err := (&ModelProviderInput{Protocol: "responses", BaseURL: url, APIKey: "secret"}).Validate(); err != nil {
t.Fatal("valid provider URL rejected", url, err)
}
}
if (&ModelProviderInput{Protocol: "anthropic", BaseURL: "https://example.com", APIKey: "secret"}).ValidateHarness("mcode") == nil {
Expand Down
41 changes: 40 additions & 1 deletion contracts/agents-api/v1/model_provider_admission.go
Original file line number Diff line number Diff line change
@@ -1,18 +1,57 @@
package v1

import (
"net"
"net/url"
"strconv"
"strings"

"golang.org/x/net/idna"
)

const providerEnvironment = "openai_hosted"
const providerScheme = "https"

// providerHost converts a domain as URL host parsing does (UTS #46 without
// hyphen or STD3 restrictions), rejecting invalid labels such as bad punycode.
var providerHost = idna.New(idna.MapForLookup(), idna.BidiRule(), idna.StrictDomainName(false), idna.CheckHyphens(false))

func ModelProviderEnvironmentSupported(environment string) bool {
return environment == providerEnvironment
}

func validModelProviderBaseURL(base string) bool {
u, err := url.Parse(base)
return err == nil && u.Scheme == providerScheme && u.Hostname() != "" && u.User == nil && u.RawQuery == "" && u.Fragment == "" && !strings.ContainsAny(base, "\x00\r\n")
return err == nil && u.Scheme == providerScheme && validModelProviderHost(u) && u.User == nil && u.RawQuery == "" && u.Fragment == "" && !strings.ContainsAny(base, "\x00\r\n")
}

// validModelProviderHost requires a usable host: an IP address, or a domain
// whose labels are nonempty letters, digits, hyphens and underscores and whose
// final label is not numeric. Any port must be in 1-65535.
func validModelProviderHost(u *url.URL) bool {
if port := u.Port(); port != "" {
if n, err := strconv.Atoi(port); err != nil || n < 1 || n > 65535 {
return false
}
}
host := u.Hostname()
if net.ParseIP(host) != nil {
return true
}
ascii, err := providerHost.ToASCII(host)
if err != nil {
return false
}
labels := strings.Split(strings.TrimSuffix(ascii, "."), ".")
for _, label := range labels {
if label == "" || len(label) > 63 || label == "xn--" || strings.IndexFunc(label, invalidHostRune) >= 0 {
return false
}
}
// A numeric final label makes the host an IPv4 address, which ParseIP rejected.
return strings.Trim(labels[len(labels)-1], "0123456789") != ""
}

func invalidHostRune(r rune) bool {
return !(r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '-' || r == '_')
}
2 changes: 2 additions & 0 deletions packages/agents-client/saved-agent-defaults.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ Reads return `ModelProviderView`, containing safe endpoint/limit fields and
`api_key_configured`, never `api_key`. It is distinct from `ModelProviderInput`:
do not submit a read response as an update. Replacing a provider requires its full
protocol, endpoint and key; MiniMax Code also requires both token limits.
Either default may be absent from a read: an Agent saved with only a provider has
no `harness`, and one saved with an empty extension reads `x_agents_core: {}`.

On update, omitting the extension preserves all defaults; omitting either nested
member preserves that member. A null provider clears its saved bundle, while
Expand Down
29 changes: 29 additions & 0 deletions packages/agents-client/src/admin-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,35 @@ describe("AdminClient response contracts", () => {
expect((await clientWith(page([session])).client.listSessions(projectId)).data).toEqual([session]);
});

it("projects saved Agent Core defaults, including the safe provider view, and rejects secrets", async () => {
const saved = { id: resourceId, object: "agent", model: "model", name: null, instructions: null, metadata: {}, multi_agent: { enabled: false, max_concurrent_subagents: null }, reasoning: { effort: null, summary: null }, service_tier: "auto", text: { format: { type: "text" }, verbosity: "medium" }, tools: [], created_at: 1, updated_at: 1 };
const provider = { protocol: "anthropic", base_url: "https://provider.test/v1", context_window: 200000, max_output_tokens: 8000, api_key_configured: true };
// Core omits the extension, or returns each default only when saved.
for (const core of [undefined, null, {}, { harness: "mcode" }, { model_provider: provider }, { harness: "mcode", model_provider: provider },
{ model_provider: { protocol: "responses", base_url: "https://provider.test", api_key_configured: true } }]) {
const agent = core === undefined ? saved : { ...saved, x_agents_core: core };
expect(await clientWith(agent).client.retrieveAgent(projectId, resourceId)).toEqual(agent);
expect((await clientWith(page([agent])).client.listAgents(projectId)).data).toEqual([agent]);
}
for (const core of ["mcode", { harness: "other" }, { harness: null }, { harness: "mcode", api_key: "leak" }, { extra: 1 },
{ model_provider: null }, { model_provider: { ...provider, api_key: "leak" } }, { model_provider: { protocol: "anthropic", base_url: "https://provider.test" } }]) {
const agent = { ...saved, x_agents_core: core };
await expect(clientWith(agent).client.retrieveAgent(projectId, resourceId)).rejects.toBeInstanceOf(AgentCoreError);
await expect(clientWith(page([agent])).client.listAgents(projectId)).rejects.toBeInstanceOf(AgentCoreError);
}
});

it("reads any provider base URL an earlier Core stored but still rejects unsafe ones", async () => {
const saved = { id: resourceId, object: "agent", model: "model", name: null, instructions: null, metadata: {}, multi_agent: { enabled: false, max_concurrent_subagents: null }, reasoning: { effort: null, summary: null }, service_tier: "auto", text: { format: { type: "text" }, verbosity: "medium" }, tools: [], created_at: 1, updated_at: 1 };
const withURL = (base_url: string, id = resourceId) => ({ ...saved, id, x_agents_core: { model_provider: { protocol: "responses", base_url, api_key_configured: true } } });
// Core once accepted hosts and ports that URL parsing rejects; one must not fail the list.
const stored = ["https://p.test:99999/v1", "https://xn--.test", "https://[::1]:8443/v1", "HTTPS://p.test/v1?"].map((url, index) => withURL(url, `agent-${index}`));
expect((await clientWith(page(stored)).client.listAgents(projectId)).data).toEqual(stored);
for (const url of ["http://p.test", "https://user:pw@p.test", "https://p.test/?key=secret", "https://p.test/#secret", "https://:443/v1"]) {
await expect(clientWith(page([withURL(url)])).client.listAgents(projectId)).rejects.toBeInstanceOf(AgentCoreError);
}
});

it("binds Skills, versions and Artifacts to requested resources", async () => {
const skill = { id: "skill", object: "skill", created_at: 1, name: "helper", description: "help", default_version: "1", latest_version: "2" };
expect(await clientWith(skill).client.retrieveSkill(projectId, "skill")).toEqual(skill);
Expand Down
4 changes: 2 additions & 2 deletions packages/agents-client/src/admin-projection.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { AgentCoreError, projectAgentSnapshot, projectRuntimeObservation } from "./client";
import { AgentCoreError, projectRuntimeObservation, projectSavedAgentConfiguration } from "./client";
import { projectTokenUsage } from "./usage-projection";
import { canonicalUuid, exactFields, isNonnegativeInteger, isRecord, sameResourceId } from "./response-projection";
import type { ListPage, SavedAgent } from "./types";
Expand Down Expand Up @@ -69,7 +69,7 @@ export function projectAdminSessionArchive(value: unknown, sessionId: string): A
export function projectSavedAgent(value: unknown, expectedId?: string): SavedAgent {
if (!isRecord(value)) return invalidAdminResponse();
const { object, metadata, created_at, updated_at, ...snapshot } = value;
const agent = projectAgentSnapshot(snapshot);
const agent = projectSavedAgentConfiguration(snapshot);
if (object !== "agent" || !isRecord(metadata) || Object.values(metadata).some((entry) => typeof entry !== "string") ||
!isNonnegativeInteger(created_at) || !isNonnegativeInteger(updated_at) || updated_at < created_at ||
(expectedId !== undefined && !sameResourceId(agent.id, expectedId))) return invalidAdminResponse();
Expand Down
15 changes: 15 additions & 0 deletions packages/agents-client/src/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -677,6 +677,21 @@ describe("OpenAIAgentsClient", () => {
});
});

it("accepts only a harness in a Session Agent's x_agents_core", async () => {
const retrieve = (core: unknown) => new OpenAIAgentsClient({ fetch: recordingFetch(jsonResponse({
...sessionResource(),
agent: core === undefined ? agentSnapshot() : { ...agentSnapshot(), x_agents_core: core },
}), []) }).retrieveSession("session");
for (const core of [undefined, null, { harness: "codex" }]) {
expect((await retrieve(core)).agent.x_agents_core).toEqual(core);
}
// Session reads never carry saved provider defaults; they live in execution_configuration.
const provider = { protocol: "responses", base_url: "https://provider.test", api_key_configured: true };
for (const core of [{}, { model_provider: provider }, { harness: "codex", model_provider: provider }, { harness: "codex", api_key: "leak" }]) {
await expect(retrieve(core)).rejects.toMatchObject({ status: 502, code: "invalid_session_resource" });
}
});

it("preserves a nullable resource error code", async () => {
const client = new OpenAIAgentsClient({
fetch: recordingFetch(jsonResponse({ error: {
Expand Down
56 changes: 49 additions & 7 deletions packages/agents-client/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { exactFields, onlyFields, isRecord, hasOwn, canonicalUuid, isNonnegative
import { projectTokenUsage } from "./usage-projection";
import { projectAgentTurn, projectSessionItem, projectItemContent, projectHistoryPage, validateHistoryPageOptions } from "./history-projection";
import { projectOpenAIHostedSessionEnvironment } from "./session-environment-projection";
import { safeProvider } from "./execution-configuration-projection";
import { createSSEDecoder } from "./sse";
import { projectVaultCredentialAuth, validCredentialURL } from "./vault-credential-auth";
import {
Expand All @@ -28,6 +29,8 @@ import {
} from "./skill-projection";
import type {
AgentCore,
AgentsCoreSelection,
AgentSnapshot,
AgentDeleted,
AgentEnvironmentInput,
AgentEnvironmentResource,
Expand Down Expand Up @@ -58,6 +61,7 @@ import type {
PageOrder,
ReadOptions,
SavedAgent,
SavedAgentCore,
SessionDeleted,
SessionEvent,
SessionListOptions,
Expand Down Expand Up @@ -256,6 +260,8 @@ const agentSnapshotFields = new Set([
"service_tier", "text", "tools",
]);
const agentSnapshotAcceptedFields = new Set([...agentSnapshotFields, "x_agents_core"]);
const sessionAgentCoreFields = new Set(["harness"]);
const savedAgentCoreFields = new Set(["harness", "model_provider"]);
const multiAgentFields = new Set(["enabled", "max_concurrent_subagents"]);
const reasoningFields = new Set(["effort", "summary"]);
const textFields = new Set(["format", "verbosity"]);
Expand Down Expand Up @@ -595,12 +601,53 @@ function invalidSessionResource(message = "Agent Core returned an invalid Sessio
throw new AgentCoreError(message, 502, "invalid_session_resource");
}

type AgentConfiguration<Core> = Omit<AgentSnapshot, "x_agents_core"> & { x_agents_core?: Core | null };

/** A Session's effective Agent reports only its persisted harness. */
function projectSessionAgentCore(value: unknown): AgentsCoreSelection | null | undefined {
if (value === undefined || value === null) return value;
if (!isRecord(value) || !exactFields(value, sessionAgentCoreFields) || !isHarnessKind(value.harness)) {
return invalidSessionResource();
}
return { harness: value.harness };
}

/**
* Saved Agent defaults: an optional harness and an optional safe provider view.
* Either may be absent, so an empty object is valid. The API key is write-only.
*/
function projectSavedAgentCore(value: unknown): SavedAgentCore | null | undefined {
if (value === undefined || value === null) return value;
if (
!isRecord(value) || !onlyFields(value, savedAgentCoreFields) ||
(hasOwn(value, "harness") && !isHarnessKind(value.harness))
) return invalidSessionResource();
return {
...(hasOwn(value, "harness") ? { harness: value.harness as CoreHarnessKind } : {}),
...(hasOwn(value, "model_provider")
? { model_provider: safeProvider(value.model_provider, invalidSessionResource) }
: {}),
};
}

export function projectAgentSnapshot(value: unknown): AgentSession["agent"] {
return projectAgentConfiguration(value, projectSessionAgentCore);
}

/** Projects a saved Agent's configuration members, without its resource fields. */
export function projectSavedAgentConfiguration(value: unknown): AgentConfiguration<SavedAgentCore> {
return projectAgentConfiguration(value, projectSavedAgentCore);
}

function projectAgentConfiguration<Core>(
value: unknown,
projectCore: (value: unknown) => Core | null | undefined,
): AgentConfiguration<Core> {
if (
!isRecord(value) || !onlyFields(value, agentSnapshotAcceptedFields) ||
[...agentSnapshotFields].some((field) => !hasOwn(value, field))
) return invalidSessionResource();
const agentsCore = value.x_agents_core;
const agentsCore = projectCore(value.x_agents_core);
const multiAgent = value.multi_agent;
const reasoning = value.reasoning;
const text = value.text;
Expand All @@ -609,9 +656,6 @@ export function projectAgentSnapshot(value: unknown): AgentSession["agent"] {
typeof value.model !== "string" || value.model.trim() === "" ||
!(value.name === null || typeof value.name === "string") ||
!(value.instructions === null || typeof value.instructions === "string") ||
!(agentsCore === undefined || agentsCore === null || (
isRecord(agentsCore) && exactFields(agentsCore, new Set(["harness"])) && isHarnessKind(agentsCore.harness)
)) ||
!isRecord(multiAgent) || !exactFields(multiAgent, multiAgentFields) ||
typeof multiAgent.enabled !== "boolean" ||
!(multiAgent.max_concurrent_subagents === null ||
Expand All @@ -635,9 +679,7 @@ export function projectAgentSnapshot(value: unknown): AgentSession["agent"] {

return {
id: value.id,
...(agentsCore === undefined
? {}
: { x_agents_core: agentsCore === null ? null : { harness: agentsCore.harness as CoreHarnessKind } }),
...(agentsCore === undefined ? {} : { x_agents_core: agentsCore }),
model: value.model,
name: value.name,
instructions: value.instructions,
Expand Down
24 changes: 19 additions & 5 deletions packages/agents-client/src/execution-configuration-projection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,31 @@ function selection(value: unknown, invalid: Invalid): SessionExecutionConfigurat
return { value: value.value as string | null, source: value.source as ExecutionConfigurationSource };
}

function safeProvider(value: unknown, invalid: Invalid): ModelProviderView {
// Splits an absolute URL as RFC 3986 appendix B does, without parsing its host.
const baseURLPattern = /^https:\/\/([^/?#]*)[^?#]*(?:\?([^#]*))?(?:#([\s\S]*))?$/iu;

/**
* Checks a stored base URL no more strictly than Core's write rule: HTTPS with a
* host and no credentials, query or fragment. Host syntax is Core's to enforce;
* a value an earlier Core accepted must not fail a whole list.
*/
function safeBaseURL(value: string): boolean {
const match = baseURLPattern.exec(value);
if (match === null || /[\r\n\0]/u.test(value)) return false;
const [, authority = "", query = "", fragment = ""] = match;
const host = authority.replace(/:[0-9]*$/u, "").replace(/^\[(.*)\]$/u, "$1");
return !authority.includes("@") && host !== "" && query === "" && fragment === "";
}

/** The safe provider view shared by frozen Session configuration and saved Agent reads. */
export function safeProvider(value: unknown, invalid: Invalid): ModelProviderView {
if (!isRecord(value) || !onlyFields(value, providerFields) ||
(value.protocol !== "responses" && value.protocol !== "anthropic") ||
typeof value.base_url !== "string" || typeof value.api_key_configured !== "boolean" ||
(value.context_window !== undefined && !isNonnegativeInteger(value.context_window)) ||
(value.max_output_tokens !== undefined && !isNonnegativeInteger(value.max_output_tokens)) ||
Number(value.max_output_tokens ?? 0) > Number(value.context_window ?? 0)) return invalid();
try {
const url = new URL(value.base_url);
if (url.protocol !== "https:" || !url.hostname || url.username || url.password || url.search || url.hash || /[\r\n\0]/u.test(value.base_url)) return invalid();
} catch { return invalid(); }
if (!safeBaseURL(value.base_url)) return invalid();
return {
protocol: value.protocol, base_url: value.base_url, api_key_configured: value.api_key_configured,
...(value.context_window === undefined ? {} : { context_window: value.context_window as number }),
Expand Down
Loading
Loading