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
20 changes: 20 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,26 @@ explicitly with its `metadata.<key>` param; other stored strings rely on the
PostgreSQL error mapping, so keep each request's writes in one transaction. See
`contracts/agents-api/official-semantics-alignment.md`.

Serve requests on their canonical path and never redirect. `api.CanonicalPaths`
wraps the complete server handler in both configurations (the daemon ServeMux and
the API router alone), so every route group, middleware, authentication check and
handler sees one path. It starts from the request's own spelling, never a path
re-escaped from its decoded form: invalid bytes are percent-encoded, unreserved
escapes decoded, empty and dot segments resolved with ServeMux semantics, the
trailing slash kept, and other escapes such as `%2F` and `%5C` left encoded;
`Path` and `RawPath` are set consistently for chi and the ServeMux. Do not route
or authorize on a path outside that wrapper. On the Beta
group the constant OpenAI-Beta check (exactly one `agents=v1` value) runs before
authentication, and authentication still precedes every Beta handler, 404 and 405.
Every Agents API 401 has type `invalid_request_error`: null code on Beta routes; on Files,
Skills and Core project extensions `invalid_api_key` only for a rejected Bearer
credential. Agents API responses carry a fresh `X-Request-Id` (also in the log
context), `OpenAI-Version`, `OpenAI-Processing-Ms` and nosniff through the API
router's own middleware, not the shared log middleware. HEAD runs GET routes;
streaming, content-download, live directory, Runtime observation and Runtime
history routes register an explicit HEAD 405 instead. Every 405 of the API router, unknown methods included, has the JSON
body and lists the route's methods in `Allow`.

Keep runtime state, test artifacts and build output under `~/.parsar/`. Require
absolute user-supplied working directories. Keep credentials out of source and
logs. Update this guide when architecture, ownership or generated contracts change.
Expand Down
2 changes: 1 addition & 1 deletion apps/web/e2e/fixture-sandbox.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export function handleSandboxFixture(request, response, url, sendJson, sendError
if (path === "/__fixture/sandbox-add-node") { nodes.push({ ...node("node-enrolled", "Enrolled host"), provider }); sendJson(response, {}); return true; }
const projectRoute = path === "/v1/sandbox/nodes" || /^\/v1\/agents\/sessions\/[^/]+\/sandbox-placement$/.test(path);
if (projectRoute && request.headers["openai-beta"] !== "agents=v1") {
sendError(response, 400, "OpenAI-Beta: agents=v1 is required.", "invalid_beta"); return true;
sendError(response, 400, "To access the Agents API, set the 'OpenAI-Beta' header to 'agents=v1'.", "invalid_beta"); return true;
}
if (path === "/v1/sandbox/nodes") { sendJson(response, { data: nodes.map(({ id, name, online }) => ({ id, name, available: online })) }); return true; }
if (/^\/v1\/agents\/sessions\/[^/]+\/sandbox-placement$/.test(path)) {
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/ConnectionModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ describe("Connection probe status", () => {
status: "complete",
result: { kind: "unauthorized", executionReadiness: "unknown", httpStatus: 401 },
},
"401 invalid_api_key",
"rejected the caller key (HTTP 401)",
],
[
{
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/ConnectionModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ function resultCopy(result: CoreProbeResult): { title: string; detail: string }
case "unauthorized":
return {
title: "Authentication failed",
detail: "Core returned 401 invalid_api_key. Check the server-managed caller key or current-tab token.",
detail: "Core rejected the caller key (HTTP 401). Check the server-managed caller key or current-tab token.",
};
case "protocol_mismatch":
return {
Expand Down
11 changes: 11 additions & 0 deletions apps/web/src/lib/core-probe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,8 +127,19 @@ describe("Core connection probe", () => {
expect(JSON.stringify(result)).not.toContain(token);
});

it.each([
{ type: "invalid_request_error", code: null, param: null, message: "A valid Agents API bearer key is required." },
{ type: "authentication_error", code: "invalid_api_key", param: null, message: "Older Core envelope." },
])("classifies the current and older Core 401 envelopes", async (error) => {
const result = await probeCore({ baseUrl: "/v1", fetch: recordingFetch(jsonResponse({ error }, 401), []) });

expect(result).toEqual({ kind: "unauthorized", executionReadiness: "unknown", httpStatus: 401 });
});

it.each([
jsonResponse({ error: { code: "gateway_auth_required" } }, 401),
jsonResponse({ error: { type: "invalid_request_error", code: "gateway_auth_required" } }, 401),
jsonResponse({ error: { type: "invalid_request_error" } }, 401),
new Response("proxy login required", { status: 401 }),
])("does not claim invalid_api_key for a non-canonical 401", async (response) => {
const result = await probeCore({ baseUrl: "/v1", fetch: recordingFetch(response, []) });
Expand Down
28 changes: 20 additions & 8 deletions apps/web/src/lib/core-probe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,10 +103,22 @@ function isAgentListPage(value: unknown): boolean {
return value.first_id === value.data[0]?.id && value.last_id === value.data.at(-1)?.id;
}

async function readErrorCode(response: Response): Promise<string | undefined> {
interface ProbeError {
type?: unknown;
code?: unknown;
}

async function readError(response: Response): Promise<ProbeError | undefined> {
const envelope: unknown = await response.json();
if (!isRecord(envelope) || !isRecord(envelope.error)) return undefined;
return typeof envelope.error.code === "string" ? envelope.error.code : undefined;
return envelope.error;
}

// Core reports a rejected Agents API caller as invalid_request_error with a
// null code, as the official service does; older Core used invalid_api_key.
// Other 401 bodies, such as an intermediary's login, are not Core's answer.
function isCoreUnauthorized(error: ProbeError | undefined): boolean {
return error?.code === "invalid_api_key" || (error?.type === "invalid_request_error" && error.code === null);
}

function classifyBodyReadFailure(
Expand Down Expand Up @@ -192,19 +204,19 @@ export async function probeCore(options: CoreProbeOptions): Promise<CoreProbeRes
};
}

let errorCode: string | undefined;
let error: ProbeError | undefined;
try {
errorCode = await readErrorCode(response);
} catch (error) {
if (classifyBodyReadFailure(error, options.signal, deadline.signal) === "unreachable") {
error = await readError(response);
} catch (reason) {
if (classifyBodyReadFailure(reason, options.signal, deadline.signal) === "unreachable") {
return { kind: "unreachable", executionReadiness: "unknown" };
}
}
if (response.status === 401 && errorCode === "invalid_api_key") {
if (response.status === 401 && isCoreUnauthorized(error)) {
return { kind: "unauthorized", executionReadiness: "unknown", httpStatus: response.status };
}
if (
(response.status === 400 && errorCode === "invalid_beta") ||
(response.status === 400 && error?.code === "invalid_beta") ||
response.status === 404 ||
response.status === 405
) {
Expand Down
7 changes: 5 additions & 2 deletions contracts/agents-api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -877,8 +877,11 @@ Caller keys now resolve an explicitly configured organization/project and typed
user/service-account identity. An immutable project-to-tenant mapping is verified
against PostgreSQL before startup. Optional official organization/project headers
must match the key's authorized scope; ambiguous or conflicting headers use the
existing `401 invalid_api_key` response. This error policy is an implementation
choice, not verified hosted error parity. Project resource access remains shared
existing 401 response. Every Agents API 401 has type `invalid_request_error`, as observed
officially; Beta routes report a null code, while Files, Skills and Core project
extensions report `invalid_api_key` for a rejected Bearer credential and a null
code without one. This scope-header policy is an implementation choice, not
verified hosted error parity. Project resource access remains shared
within the authorized project. New Sessions persist immutable creator kind/ID from
the authenticated principal; ordinary and streaming creation retries require the
same typed subject, including when recovering before saved-Agent lookup. Rotated
Expand Down
81 changes: 80 additions & 1 deletion contracts/agents-api/official-semantics-alignment.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ replacement baseline. A successful SDK parse alone is not conformance evidence.
| OAuth grant create/replacement | Reject an explicitly empty access token and a replacement without mutable grant fields. Preserve previously qualified refresh/expiry/null handling. |
| Input message discriminator | Omission remains valid; a supplied `type` must be `message`. Explicit null or empty strings reject through the shared initial/event decoder, matching the pinned literal type. |
| Missing beta resource | HTTP 404 with `type` and `code` equal to `not_found_error`. Missing and foreign resources remain indistinguishable. |
| Missing required Beta header | HTTP 400 with `type` and `code` equal to `invalid_beta`, after authentication. |
| Missing required Beta header | HTTP 400 with `type` and `code` equal to `invalid_beta`, before authentication (see [HTTP routing and response headers](#http-routing-and-response-headers--september-23)). |
| Missing non-beta File or Skill | HTTP 404 with `type: invalid_request_error`, `code: null`. Exact message, File `param` and additional detail payload remain outside this batch. |

The resource comparison made 40 raw requests over six newly owned resources
Expand Down Expand Up @@ -793,3 +793,82 @@ checks retrieve, list, the live GET stream and its end, the exact 409, tenant B
projection, stream lifetime, wire shapes and error mapping; Python tests pin the
initializer receipt, and the TypeScript client and Web unit tests pass. Live
Docker acceptance is recorded separately by the coordinator.

## HTTP routing and response headers — September 23

This batch aligns path handling, the Beta check, 401 envelopes and response
headers with campaign scan 6 at Core main `1eb60c27`, recorded privately in
`~/.parsar/remediation/20260923/campaign-scan-6/http-protocol/` (`findings.json`
HP-02..24, raw `official-ledger.jsonl` and `REPORT.txt`): 90 official requests on
one owned Agent, deleted afterwards, without a Session or model. Labels below are
ledger records.

| Row | Case | Core behavior |
| --- | --- | --- |
| RH1 | `//`, `.` or `..` path segments (HP-17: `R10`, `R11`, `R17`, `R18`) | Served on the canonical path, never redirected. Empty and dot segments resolve with ServeMux semantics and a trailing slash is kept, so `/v1/agents/x/../` still reaches the trailing-slash 404. The former 301 made the pinned SDK resend an update as a GET and drop it. |
| RH2 | A percent-encoded unreserved character in the path (HP-18: `R12`) | Decoded before routing, including `%2E` dot segments. The canonical path is built from the request's own path spelling; bytes that are invalid in an escaped path (such as `{`, `"`, a backslash or non-ASCII) are percent-encoded first. Other escapes, such as `%2F`, `%2f`, `%5C` and double encodings, stay encoded and never separate segments. Malformed, missing and foreign IDs keep the single 404. |
| RH3 | HEAD on a GET route (HP-19: `R13`, `R16`) | The GET route runs after the same Beta and authentication checks; 200 with its headers and no body. `Content-Length` is present when Go buffers the whole body (about 2 KiB) and omitted for larger responses. The events stream, the File, Skill, Skill version and Artifact content downloads, the live Environment Files directory list and the Core Runtime observation (single and list) and Runtime history reads answer HEAD with Core's 405 instead, so HEAD never holds a stream open, reads content, samples a provider or queries telemetry. That exclusion is a documented Core difference; official HEAD on those routes is unobserved. |
| RH4 | Unsupported method (HP-20: `R04`–`R06`) | Unchanged 405 JSON `unsupported_operation`, now with `Allow` listing the route's methods in the observed order, such as `GET,HEAD,POST,DELETE`. Routes outside the Beta group, such as `/healthz` and executor credentials, and methods chi does not know, such as `FOO`, now use the same JSON 405 instead of chi's empty one; an unknown method is answered before the Beta and authentication checks, as before. |
| RH5 | `X-Request-Id` (HP-23) | Every response of the Agents API handler, including 400, 401, 404, 405 and SSE streams, carries a fresh random `req_` plus 32 lowercase hex characters. The ID is attached to the request log context as `request_id` next to the trace carrier. |
| RH6 | No or invalid credentials without OpenAI-Beta on a Beta route (HP-05: `A06`, `A11`) | 400 `invalid_beta`: the constant Beta check now precedes authentication. Files, Skills and Core project extensions still ignore the header. |
| RH7 | Repeated OpenAI-Beta header lines (HP-03: `B08`) | 400 `invalid_beta` unless there is exactly one field value, equal to `agents=v1`. |
| RH8 | 401 (HP-07: `A01`–`A04`, `A07`–`A10`) | Type `invalid_request_error`. Beta routes report a null code for every failure. Files, Skills and Core project extensions report a null code without a Bearer credential (missing, other scheme, empty or repeated header) and `invalid_api_key` for a rejected one, including mismatched scope headers. Core's message and `WWW-Authenticate: Bearer` are kept. |
| RH9 | `invalid_beta` message (HP-02: `B01`) | "To access the Agents API, set the 'OpenAI-Beta' header to 'agents=v1'." |
| RH10 | Optional headers (HP-24) | `OpenAI-Version: 2020-10-01`, `OpenAI-Processing-Ms` and `X-Content-Type-Options: nosniff`. Organization and project headers are not reported: Core's project scope is configured, not account-derived. |
| RH11 | Trailing slashes and unknown sub-routes (HP-21), OPTIONS and CORS (HP-22), `Cache-Control` and `traceparent` (HP-26), `agents=v0` (HP-04) | Unchanged: 404 JSON, no CORS handling, Core's extension headers kept, `agents=v0` still rejected (an upstream anomaly, not copied). |

Decisions:

- One canonicalizing handler wraps the complete server handler in both server
configurations: around the ServeMux that also serves daemon, enrollment and node
transport, and around the API router when it is served alone. The ServeMux, the
router, every middleware, authentication check and handler see only the
rewritten path. A dirty or encoded path therefore reaches exactly the route
group and authentication of its canonical path written literally; internal
daemon, node and sandbox routes keep their own authentication. The input is the
request's own path spelling (`RawPath` when Go keeps one), never a path
re-escaped from its decoded form, which would turn `%2F` into a separator when
the spelling holds a byte Go considers invalid. `Path` and `RawPath` are then
set consistently, so chi, which prefers `RawPath`, and the ServeMux, which uses
`EscapedPath`, route on the same string. Decoding only unreserved characters is
RFC 3986 normalization, so a proxy that normalizes URIs the same way sees the
same route. The ServeMux still redirects the exact daemon prefix
`/api/v1/agent-daemon` to `/api/v1/agent-daemon/`; that is daemon transport, not
an Agents API path.
- The Beta check reads only a constant header and returns no tenant or resource
data. Moving it first changes only responses that were rejected either way:
every request that passes it is authenticated before the router reaches any
Beta handler, 404 or 405.
- Wrong methods and unknown sub-routes below `/v1/files` and `/v1/skills` still
reach the Beta group's 404 and 405 after its checks, as before; without the Beta
header they now report `invalid_beta` instead of 401. Official behavior there is
unobserved.
- Every 401 of the Agents API handler has type `invalid_request_error`, including
the deployment administrator (`invalid_admin_key`) and sandbox node
(`invalid_node_credential`) extensions, whose codes are unchanged. Project API
key management keeps its deployment administrator authentication behind the
same canonical path; derived project keys authenticate exactly as their static
parent binding, under the Beta and Files rules above. Daemon,
enrollment and node transport served beside it keep their own formats.
- The response headers belong to the Agents API handler. Daemon, enrollment and
node transport routes do not carry them, and the shared log middleware is
unchanged. A caller-supplied request ID is not echoed; that header is not pinned.
- Core Web recognizes both the current 401 envelope and the older
`invalid_api_key` code in its connection probe. The TypeScript client already
exposes status, type and code without branching on them. The Parsar product
repository has no code branching on these 401 fields, and its Go client refuses
redirects.

Go handler tests cover RH1–RH11, including a walk over every registered route:
unauthenticated requests, with and without the Beta header and with foreign
credentials, are rejected before any handler, and ten dirty and encoded spellings
of each path, including traversal from the daemon and sandbox prefixes, give the
clean path's exact response. Raw request-line tests over a real listener cover
invalid bytes, non-ASCII, `%2F`, `%2f`, `%5C`, double encoding and absolute-form
URIs in both server configurations, and two fuzz targets assert that any request
path reaches the same handler, route group and response as its canonical form,
with chi, the ServeMux and `Path` agreeing on it. A server test replays the
daemon-enabled composition and checks that no Agents API request is redirected. The pinned-SDK script
`official_http_routing.py` updates an Agent through base URL `/v1//`, checks
`_request_id` and the error `request_id`, and the raw checks in the other official
scripts now expect the Beta check first.
Loading
Loading