fix: install bounded Browser RPC stream leases

R-04: install the RPC contract bindings as exact immutable snapshots. Registry
and row data are copied from own data descriptors into frozen null-prototype
maps before validation, so a getter is never invoked, extra and symbol keys and
malformed descriptors are composition-time TypeErrors, and the runtime reads
only the snapshot. A post-validation mutation can no longer change replay
policy, deadlines, byte ceilings or transport selection.

R-01: bound transport stream cleanup. The generation is fenced and listeners
released immediately, and iterator.return() is awaited only within a cleanup
bound, so a non-cooperative iterator cannot keep the application generator, its
listeners or the total deadline alive. Unresolved cleanup stays observed.

R-05: reject oversized WebSocket text frames before allocating an encoded copy
and count UTF-8 bytes incrementally with an early exit, matching TextEncoder for
surrogate pairs and lone surrogates.

R-06: canonicalise clock and generation-fence failures into the closed Result
taxonomy instead of letting them escape as native rejections, with listener and
timer cleanup on every exit path.

Browser RPC remains AVAILABLE_NOT_COMPOSED; R-07 transport evidence is still
required before composition.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-08-14 00:00:15 +09:00
co-authored by Claude Opus 5
parent 2f29ccbf1a
commit 8f67974f68
7 changed files with 585 additions and 38 deletions
+182
View File
@@ -327,6 +327,188 @@ export function composeBrowserRpcRequestEncoderRegistry(
);
}
export type InstalledBrowserRpcContractBindings = Readonly<{
operations: ReadonlyMap<string, BrowserRpcOperationV3>;
profiles: ReadonlyMap<string, BrowserRpcProviderProfile>;
schemaCodecs: ReadonlyMap<string, RuntimeSchemaCodec>;
mappers: ReadonlyMap<string, InstalledBoundaryMapper>;
requestEncoders: ReadonlyMap<string, BrowserRpcRequestEncoder>;
runtimeBindings: ReadonlyMap<string, BrowserRpcRuntimeBindingIdentity>;
}>;
/**
* R-04. Parse → validate → install.
*
* `Readonly` is a TypeScript annotation, not a runtime guarantee, and a source
* registry can be mutated after validation so replay policy, deadlines, byte
* ceilings or transport selection differ from what was checked. Every row is
* therefore copied once into a frozen null-prototype snapshot built from exact
* own data properties. A getter, an extra or symbol key, a malformed descriptor
* or a revoked proxy is a composition-time `TypeError`, and the runtime reads
* only the snapshot afterwards.
*/
function installRegistrySnapshot<Value extends object>(
source: Readonly<Record<string, Value>>,
label: string,
allowedKeys: readonly string[],
): ReadonlyMap<string, Value> {
let ownKeys: string[];
let symbols: readonly symbol[];
try {
ownKeys = Object.keys(source);
symbols = Object.getOwnPropertySymbols(source);
} catch {
throw new TypeError(`Browser RPC ${label} registry is unreadable.`);
}
if (symbols.length > 0) {
throw new TypeError(`Browser RPC ${label} registry has symbol keys.`);
}
const installed = new Map<string, Value>();
for (const key of ownKeys) {
const descriptor = Object.getOwnPropertyDescriptor(source, key);
if (!descriptor || !("value" in descriptor)) {
throw new TypeError(
`Browser RPC ${label} registry entry is not a data property: ${key}`,
);
}
installed.set(
key,
installRowSnapshot(descriptor.value as Value, `${label}.${key}`, allowedKeys),
);
}
return Object.freeze(installed) as ReadonlyMap<string, Value>;
}
function installRowSnapshot<Value extends object>(
row: Value,
label: string,
allowedKeys: readonly string[],
): Value {
if (!row || typeof row !== "object") {
throw new TypeError(`Browser RPC ${label} row is not an object.`);
}
let ownKeys: string[];
let symbols: readonly symbol[];
try {
ownKeys = Object.keys(row);
symbols = Object.getOwnPropertySymbols(row);
} catch {
throw new TypeError(`Browser RPC ${label} row is unreadable.`);
}
if (symbols.length > 0) {
throw new TypeError(`Browser RPC ${label} row has symbol keys.`);
}
const snapshot = Object.create(null) as Record<string, unknown>;
for (const key of ownKeys) {
if (!allowedKeys.includes(key)) {
throw new TypeError(
`Browser RPC ${label} row has an unexpected key: ${key}`,
);
}
const descriptor = Object.getOwnPropertyDescriptor(row, key);
// Reading an accessor would invoke a getter; refuse without calling it.
if (!descriptor || !("value" in descriptor)) {
throw new TypeError(
`Browser RPC ${label} row key is not a data property: ${key}`,
);
}
const value = descriptor.value as unknown;
snapshot[key] = Array.isArray(value)
? Object.freeze([...value])
: value;
}
return Object.freeze(snapshot) as Value;
}
const OPERATION_KEYS = Object.freeze([
"contractVersion", "operationId", "owner", "protocol", "semantics",
"replayPolicy", "idempotencyKeyPolicy", "idempotencyLevel",
"dataClassification", "runtimeProfileId", "providerId",
"fullyQualifiedService", "method", "rpcKind", "requestMessageId",
"responseMessageId", "descriptorArtifactId", "descriptorDigest",
"requestSchemaId", "responseSchemaId", "requestEncoderId", "mapperId",
"authProfileId", "csrfProfileId", "errorProfileId", "deadlineProfileId",
"retryProfileId", "serverStateProfileId", "maxRequestMessageBytes",
"maxResponseMessageBytes", "maxResponseMessages", "maxTotalResponseBytes",
"maxBufferedBytes", "idleDeadlineMs", "totalDeadlineMs",
] as const);
const PROFILE_KEYS = Object.freeze([
"runtimeProfileId", "providerId", "fixedBaseUrl", "runtimeId",
"runtimeVersion", "runtimeDigest", "protocol", "runtimeKind",
"clientApiKind", "rpcKind", "messageEncoding", "framing", "requestMethod",
"descriptorArtifactId", "descriptorDigest", "allowedProcedures",
"authProfileId", "csrfProfileId", "corsProfileId", "errorProfileId",
"deadlineProfileId", "retryProfileId", "retryOwner", "maxAttempts",
"backoffMs", "retryableFailures", "maxRetryAfterMs", "deadlineDialect",
"cancelDialect", "rawByteCeilingOwner", "streamMessageCompression",
] as const);
const SCHEMA_KEYS = Object.freeze(["schemaId", "parse"] as const);
const MAPPER_KEYS = Object.freeze([
"mapperId", "mapperVersion", "inputSchemaId", "outputContractId", "owner",
"maxOutputItems", "map",
] as const);
const ENCODER_KEYS = Object.freeze([
"encoderId", "operationId", "encode",
] as const);
const RUNTIME_BINDING_KEYS = Object.freeze([
"runtimeProfileId", "providerId", "protocol", "rpcKind",
] as const);
export function installBrowserRpcContractBindings(
bindings: BrowserRpcContractBindings,
): InstalledBrowserRpcContractBindings {
// Parse first. Snapshotting from own data descriptors rejects accessors
// without ever invoking them, so a hostile getter cannot observe validation
// or return a different value to it than to the runtime.
const operations = installRegistrySnapshot(
bindings.operations,
"operation",
OPERATION_KEYS,
);
const profiles = installRegistrySnapshot(
bindings.profiles,
"profile",
PROFILE_KEYS,
);
const schemaCodecs = installRegistrySnapshot(
bindings.schemaCodecs,
"schema",
SCHEMA_KEYS,
);
const mappers = installRegistrySnapshot(
bindings.mappers,
"mapper",
MAPPER_KEYS,
);
const requestEncoders = installRegistrySnapshot(
bindings.requestEncoders,
"encoder",
ENCODER_KEYS,
);
const runtimeBindings = installRegistrySnapshot(
bindings.runtimeBindings ?? {},
"runtime",
RUNTIME_BINDING_KEYS,
);
// Then validate the snapshot, so what was checked is exactly what installs.
validateBrowserRpcContractBindings({
operations: Object.fromEntries(operations),
profiles: Object.fromEntries(profiles),
schemaCodecs: Object.fromEntries(schemaCodecs),
mappers: Object.fromEntries(mappers),
requestEncoders: Object.fromEntries(requestEncoders),
runtimeBindings: Object.fromEntries(runtimeBindings),
});
return Object.freeze({
operations,
profiles,
schemaCodecs,
mappers,
requestEncoders,
runtimeBindings,
});
}
export function validateBrowserRpcContractBindings(
bindings: BrowserRpcContractBindings,
): true {