Skip to content
Open
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
3 changes: 2 additions & 1 deletion docs/acceptance/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -342,7 +342,8 @@ PR after verification. There is no cleanup or status-only PR.
- [TypeScript SDK malformed-success decoding](sdk-malformed-success-decoding.md) -
cause-free protocol errors for malformed successful unary HTTP and ordinary SSE payloads,
retaining safe status and request-ID metadata while preserving server, transport, and
cancellation causes. Status: proposed.
cancellation causes. Status: in-progress stacked implementation candidate; the local full
race suite is host-linker-blocked and remains for CI.

- [Canonical Shell command tool](canonical-shell-command-tool.md) — canonical `Shell`
and `ShellStatus` model-facing names, safe legacy `Bash` input normalization, and
Expand Down
4 changes: 2 additions & 2 deletions docs/acceptance/sdk-malformed-success-decoding.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@
**Work classification:** Architectural - this changes the durable public diagnostic and security policy for `ProtocolError` at the TypeScript SDK's HTTP successful-response boundary.
**Decision record:** [ADR 0348](../adr/0348-typescript-sdk-malformed-success-decoding.md)
**Phase:** TypeScript SDK HTTP transport hardening
**Status:** proposed, 2026-09-18. Drafted from issue #1694 with no unresolved human decisions.
**Status:** in-progress, 2026-09-18. The stacked implementation candidate satisfies AC1.1-AC2.4 and all applicable local gates; the host Xcode/macOS 27 linker blocks the full CGO race suite, so CI must supply that final proof. The contract becomes authoritative only after the Plan / Interface and Implementation PRs merge in order.
**Delivery:** Split, with an explicit checkpoint waiver. The Plan / Interface PR records the security and compatibility boundary; in this session the directing human explicitly instructed the implementation to proceed as the next `gh stack` layer without waiting for the plan to merge. `/plan-orchestrate` is not used because its merged-baseline precondition is intentionally waived.
**Expected tasks:** 1
**Issue:** [stacklok/mecatl#1694](https://github.com/stacklok/mecatl/issues/1694).
**Plan PR:** [#1698](https://github.com/stacklok/mecatl/pull/1698)
**Approved baseline:** absent by explicit human exception; the directing human requested two sequential `gh stack` PRs and explicitly said there is no need to wait for the Plan / Interface PR to merge.
**Approved baseline:** `b6e05820685a85bf3cf027ed42da4e269060e6ed`, the exact Plan / Interface commit used under the explicit human stacking exception; it is not merged authority. The directing human requested two sequential `gh stack` PRs and explicitly said there is no need to wait for the plan to merge.

The TypeScript SDK rejects malformed successful unary HTTP responses and
ordinary SSE data frames without retaining runtime-dependent decoder exceptions.
Expand Down
7 changes: 6 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -332,7 +332,12 @@ protobuf-es decoding, the HTTP transport recursively follows the output descript
stdlib-JSON `{seconds,nanos}` objects only at `google.protobuf.Timestamp` and
`google.protobuf.Duration` fields. ProtoJSON strings and `null` pass through, malformed objects
fail as typed HTTP protocol errors, and normalization uses a detached value so `getRawJson()`
retains the original unary or SSE data. UDS dials by
retains the original unary or SSE data. JSON, well-known-type, and protobuf decode failures for
successful unary responses and ordinary SSE data frames expose only generic SDK messages, HTTP
status, and an available request ID; they omit decoder causes so rejected response text cannot
escape through runtime-specific exception messages. Body acquisition, server errors,
authentication, network, cancellation, and SSE reader failures stay outside that cause-free
boundary. UDS dials by
supplying connect-node's HTTP/2 node connection option for the socket path, never a
`unix://` base URL. Unit tests inject transports; `sdk/typescript/e2e/` separately
builds and spawns the same checkout's `mecated` with the offline mock provider to
Expand Down
9 changes: 8 additions & 1 deletion docs/design/IMPLEMENTATION-NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -8561,7 +8561,14 @@ numbers; and protobuf range and sign rules fail closed. An existing string or `n
protobuf-es unchanged. The walk produces a detached JSON value and leaves unknown fields intact;
`sdk/typescript/src/http.ts` registers the original parsed unary response, SSE envelope, and nested
event with `getRawJson()` before yielding the decoded message. Any conversion or protobuf-es decode
failure becomes the existing `ProtocolError` with HTTP transport identity.
failure becomes the existing `ProtocolError` with HTTP transport identity. The shared
`malformedSuccess` constructor retains only the generic message, status, request ID, and transport;
the JSON/protobuf exception is deliberately absent. Unary handling acquires `response.text()` in a
separate caused-error block before `JSON.parse`, so a post-header body-stream failure keeps its
existing `ProtocolError.cause`. SSE `reader.read()` failures remain outside both decoder catches.
Non-2xx and valid `event: error` frames still use `errorFromProblem`, and credential, fetch, abort,
control, and stream lifecycle paths are unchanged. [ADR 0348](../adr/0348-typescript-sdk-malformed-success-decoding.md)
records this cause boundary.

`sdk/typescript/src/raw.ts` enforces API-major compatibility before all non-compatibility RPCs;
the ergonomic client also probes status and maps transport/auth/incompatibility states without
Expand Down
12 changes: 11 additions & 1 deletion sdk/typescript/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,17 @@ export class AuthenticationError extends MecatlError {
}
}

/** A transport response violated the SDK's protocol contract. @public */
/**
* A transport response violated the SDK's protocol contract.
*
* Malformed successful HTTP responses and ordinary SSE data frames omit the
* underlying JSON or protobuf decoder cause. They retain safe correlation
* metadata such as HTTP status and a response request ID when available.
* Server errors, authentication failures, and transport or body-read failures
* keep their separately defined cause behavior.
*
* @public
*/
export class ProtocolError extends MecatlError {
constructor(message: string, options: Omit<MecatlErrorOptions, "code">) {
super(message, { ...options, code: "protocol" });
Expand Down
40 changes: 21 additions & 19 deletions sdk/typescript/src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,14 @@ function record(value: JsonValue): JsonRecord {
return value as JsonRecord;
}

function malformedSuccess(message: string, response: Response): ProtocolError {
return new ProtocolError(message, {
requestId: response.headers.get("x-request-id") ?? undefined,
status: response.status,
transport: "http",
});
}

function permissionMode(value: JsonValue | undefined): string {
switch (value) {
case 2:
Expand Down Expand Up @@ -288,15 +296,21 @@ class HttpTransport implements Transport {
if (!response.ok) await this.#problem(response);
let raw: JsonValue = {};
if (response.status !== 204) {
let body: string;
try {
raw = (await response.json()) as JsonValue;
body = await response.text();
} catch (cause) {
throw new ProtocolError("The mecatl server returned invalid JSON", {
cause,
status: response.status,
transport: "http",
});
}
try {
raw = JSON.parse(body) as JsonValue;
} catch {
throw malformedSuccess("The mecatl server returned invalid JSON", response);
}
}
let normalized: JsonValue;
let message: MessageShape<O>;
Expand All @@ -306,12 +320,8 @@ class HttpTransport implements Transport {
normalizeUnaryResponse(resolved.classification, normalizeMethodResponse(method.name, raw)),
);
message = fromJson(method.output, normalized, { ignoreUnknownFields: true });
} catch (cause) {
throw new ProtocolError("The mecatl server returned an invalid response", {
cause,
status: response.status,
transport: "http",
});
} catch {
throw malformedSuccess("The mecatl server returned an invalid response", response);
}
registerRawJson(message, raw);
if (method.name === "GetCompatibilityInfo") {
Expand Down Expand Up @@ -497,12 +507,8 @@ class HttpTransport implements Transport {
let raw: JsonValue;
try {
raw = JSON.parse(next.value.data) as JsonValue;
} catch (cause) {
throw new ProtocolError("The mecatl SSE stream contained invalid JSON", {
cause,
status: response.status,
transport: "http",
});
} catch {
throw malformedSuccess("The mecatl SSE stream contained invalid JSON", response);
}
if (next.value.event === "error") {
throw errorFromProblem(
Expand All @@ -516,12 +522,8 @@ class HttpTransport implements Transport {
try {
normalized = normalizeHttpWktJson(output, wrapEvent ? { event: raw } : raw);
message = fromJson(output, normalized, { ignoreUnknownFields: true });
} catch (cause) {
throw new ProtocolError("The mecatl SSE stream contained an invalid event", {
cause,
status: response.status,
transport: "http",
});
} catch {
throw malformedSuccess("The mecatl SSE stream contained an invalid event", response);
}
registerRawJson(message, raw);
if (wrapEvent) {
Expand Down
Loading
Loading