feat(server): add the HTTP/2 bridge admission operator configuration chain

Let an operator set both HTTP/2 bridge admission caps through
PAPERCLIP_MAX_PARALLEL_HTTP2_BRIDGE_REQUESTS and
PAPERCLIP_MAX_LIVE_HTTP2_BRIDGE_SESSIONS. The new env helper runs each
per-field resolver with a bound reporter before the joint budget check, so a
per-field rejection is never silent. The host logs a refused cap pair at
error level with the refused total and the reserved budget, and never keeps
one operator value and one default from a refused pair.

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Priya RamanandPaperclip authored and Nicky Leach committed 2026-08-26 18:01:32 -07:00
1 parent 41e6d9823b
commit ffb5bcde53
3 files changed
+277

No files matched your search

@@ -0,0 +1,147 @@
import { describe, expect, it, vi } from "vitest";
import {
DEFAULT_MAX_LIVE_HTTP2_BRIDGE_SESSIONS,
DEFAULT_MAX_PARALLEL_HTTP2_BRIDGE_REQUESTS,
} from "@paperclipai/adapter-utils/http2-bridge-admission";
import {
resolveHttp2BridgeAdmissionCapsFromEnv,
resolveMaxLiveHttp2BridgeSessionsFromEnv,
resolveMaxParallelHttp2BridgeRequestsFromEnv,
} from "./http2-bridge-admission-env.js";
describe("resolveMaxParallelHttp2BridgeRequestsFromEnv", () => {
it("an absent variable uses the default", () => {
const onRejectedOverride = vi.fn();
const resolved = resolveMaxParallelHttp2BridgeRequestsFromEnv(undefined, onRejectedOverride);
expect(resolved).toBe(DEFAULT_MAX_PARALLEL_HTTP2_BRIDGE_REQUESTS);
expect(onRejectedOverride).not.toHaveBeenCalled();
});
it("a blank or whitespace-only value is invalid and reports a rejection", () => {
const onRejectedOverride = vi.fn();
const resolved = resolveMaxParallelHttp2BridgeRequestsFromEnv(" ", onRejectedOverride);
expect(resolved).toBe(DEFAULT_MAX_PARALLEL_HTTP2_BRIDGE_REQUESTS);
expect(onRejectedOverride).toHaveBeenCalledTimes(1);
// The reporter receives only the parsed number, never the raw string.
const reportedValue = onRejectedOverride.mock.calls[0]?.[0];
expect(typeof reportedValue).toBe("number");
});
it("a value above the hard range is invalid and reports a rejection", () => {
const onRejectedOverride = vi.fn();
const resolved = resolveMaxParallelHttp2BridgeRequestsFromEnv("65", onRejectedOverride);
expect(resolved).toBe(DEFAULT_MAX_PARALLEL_HTTP2_BRIDGE_REQUESTS);
expect(onRejectedOverride).toHaveBeenCalledTimes(1);
});
it("a valid positive integer inside the range passes through unchanged", () => {
const onRejectedOverride = vi.fn();
const resolved = resolveMaxParallelHttp2BridgeRequestsFromEnv("32", onRejectedOverride);
expect(resolved).toBe(32);
expect(onRejectedOverride).not.toHaveBeenCalled();
});
});
describe("resolveMaxLiveHttp2BridgeSessionsFromEnv", () => {
it("an absent variable uses the default", () => {
const onRejectedOverride = vi.fn();
const resolved = resolveMaxLiveHttp2BridgeSessionsFromEnv(undefined, onRejectedOverride);
expect(resolved).toBe(DEFAULT_MAX_LIVE_HTTP2_BRIDGE_SESSIONS);
expect(onRejectedOverride).not.toHaveBeenCalled();
});
it("a blank or whitespace-only value is invalid and reports a rejection", () => {
const onRejectedOverride = vi.fn();
const resolved = resolveMaxLiveHttp2BridgeSessionsFromEnv("", onRejectedOverride);
expect(resolved).toBe(DEFAULT_MAX_LIVE_HTTP2_BRIDGE_SESSIONS);
expect(onRejectedOverride).toHaveBeenCalledTimes(1);
});
it("a value above the hard range is invalid and reports a rejection", () => {
const onRejectedOverride = vi.fn();
const resolved = resolveMaxLiveHttp2BridgeSessionsFromEnv("65", onRejectedOverride);
expect(resolved).toBe(DEFAULT_MAX_LIVE_HTTP2_BRIDGE_SESSIONS);
expect(onRejectedOverride).toHaveBeenCalledTimes(1);
});
it("a valid positive integer inside the range passes through unchanged", () => {
const onRejectedOverride = vi.fn();
const resolved = resolveMaxLiveHttp2BridgeSessionsFromEnv("4", onRejectedOverride);
expect(resolved).toBe(4);
expect(onRejectedOverride).not.toHaveBeenCalled();
});
});
describe("resolveHttp2BridgeAdmissionCapsFromEnv", () => {
it("an absent pair uses both defaults", () => {
const onRejectedOverride = vi.fn();
const onRejectedPair = vi.fn();
const resolved = resolveHttp2BridgeAdmissionCapsFromEnv(
undefined,
undefined,
onRejectedOverride,
onRejectedPair,
);
expect(resolved).toEqual({
maxParallel: DEFAULT_MAX_PARALLEL_HTTP2_BRIDGE_REQUESTS,
maxSessions: DEFAULT_MAX_LIVE_HTTP2_BRIDGE_SESSIONS,
});
expect(onRejectedOverride).not.toHaveBeenCalled();
expect(onRejectedPair).not.toHaveBeenCalled();
});
it("a rejected per-field override calls the override reporter exactly once", () => {
const onRejectedOverride = vi.fn();
const onRejectedPair = vi.fn();
const resolved = resolveHttp2BridgeAdmissionCapsFromEnv(
"not-a-number",
"4",
onRejectedOverride,
onRejectedPair,
);
expect(resolved).toEqual({
maxParallel: DEFAULT_MAX_PARALLEL_HTTP2_BRIDGE_REQUESTS,
maxSessions: 4,
});
// The pair resolver reruns each per-field check with no reporter bound, so
// only the env helper's own per-field pass reports the rejection.
expect(onRejectedOverride).toHaveBeenCalledTimes(1);
expect(onRejectedPair).not.toHaveBeenCalled();
});
it("an over-budget environment pair of 64 and 64 resolves to both defaults and reports an error", () => {
const onRejectedOverride = vi.fn();
const onRejectedPair = vi.fn();
const resolved = resolveHttp2BridgeAdmissionCapsFromEnv("64", "64", onRejectedOverride, onRejectedPair);
expect(resolved).toEqual({
maxParallel: DEFAULT_MAX_PARALLEL_HTTP2_BRIDGE_REQUESTS,
maxSessions: DEFAULT_MAX_LIVE_HTTP2_BRIDGE_SESSIONS,
});
expect(onRejectedOverride).not.toHaveBeenCalled();
expect(onRejectedPair).toHaveBeenCalledTimes(1);
const rejection = onRejectedPair.mock.calls[0]?.[0];
expect(rejection).toEqual({ maxParallel: 64, maxSessions: 64, totalBytes: expect.any(Number) });
});
});
+79
View File
@@ -0,0 +1,79 @@
import {
resolveHttp2BridgeAdmissionCaps,
resolveMaxLiveHttp2BridgeSessions,
resolveMaxParallelHttp2BridgeRequests,
type Http2BridgeAdmissionCapOverrideRejectionReporter,
type Http2BridgeAdmissionCaps,
type Http2BridgeAdmissionPairRejectionReporter,
} from "@paperclipai/adapter-utils/http2-bridge-admission";
/**
* Resolve the parallel-stream admission cap from the raw operator override
* string. The host reads the override from
* PAPERCLIP_MAX_PARALLEL_HTTP2_BRIDGE_REQUESTS.
*
* This helper distinguishes an absent variable from a present blank value. An
* absent variable is `undefined`. The helper then uses the documented default
* and does not report a rejection. A present variable is a string, even when
* the string holds only whitespace. The helper sends every present string
* through `Number`, so a blank or whitespace-only value becomes `0`. The
* numeric validation in {@link resolveMaxParallelHttp2BridgeRequests} rejects
* `0`, reports it through `onRejectedOverride`, and returns the safe default.
* So a present blank value is visible as invalid instead of silent as absent.
*
* `Number` ignores surrounding whitespace, so a valid value with surrounding
* spaces still parses. The reporter receives only the parsed number, never
* the raw string, so no operator-supplied text reaches a log line.
*/
export function resolveMaxParallelHttp2BridgeRequestsFromEnv(
rawOverride: string | undefined,
onRejectedOverride?: Http2BridgeAdmissionCapOverrideRejectionReporter,
): number {
return resolveMaxParallelHttp2BridgeRequests(
rawOverride === undefined ? undefined : Number(rawOverride),
onRejectedOverride,
);
}
/**
* Resolve the live-session admission cap from the raw operator override
* string. The host reads the override from
* PAPERCLIP_MAX_LIVE_HTTP2_BRIDGE_SESSIONS. Same present-versus-absent
* behavior as {@link resolveMaxParallelHttp2BridgeRequestsFromEnv}.
*/
export function resolveMaxLiveHttp2BridgeSessionsFromEnv(
rawOverride: string | undefined,
onRejectedOverride?: Http2BridgeAdmissionCapOverrideRejectionReporter,
): number {
return resolveMaxLiveHttp2BridgeSessions(
rawOverride === undefined ? undefined : Number(rawOverride),
onRejectedOverride,
);
}
/**
* Resolve one admission cap pair from the two raw operator override strings.
*
* {@link resolveHttp2BridgeAdmissionCaps} runs the two per-field resolvers
* without a rejection reporter, so a per-field rejection would stay silent if
* this helper only called that function. This helper runs each per-field
* resolver itself first, with `onRejectedOverride` bound, so a rejected
* PAPERCLIP_MAX_PARALLEL_HTTP2_BRIDGE_REQUESTS or
* PAPERCLIP_MAX_LIVE_HTTP2_BRIDGE_SESSIONS value always reports through
* `onRejectedOverride` exactly once. It then passes the two resolved numbers
* into {@link resolveHttp2BridgeAdmissionCaps}, which reruns the same
* per-field checks on two already-valid numbers (harmless) and then applies
* the joint budget rule. A pair that fails the joint rule reports through
* `onRejectedPair` and returns both defaults; the host never keeps one
* operator value and one default from a refused pair.
*/
export function resolveHttp2BridgeAdmissionCapsFromEnv(
rawParallel: string | undefined,
rawSessions: string | undefined,
onRejectedOverride?: Http2BridgeAdmissionCapOverrideRejectionReporter,
onRejectedPair?: Http2BridgeAdmissionPairRejectionReporter,
): Http2BridgeAdmissionCaps {
const maxParallel = resolveMaxParallelHttp2BridgeRequestsFromEnv(rawParallel, onRejectedOverride);
const maxSessions = resolveMaxLiveHttp2BridgeSessionsFromEnv(rawSessions, onRejectedOverride);
return resolveHttp2BridgeAdmissionCaps({ maxParallel, maxSessions }, onRejectedPair);
}
+51
View File
@@ -92,6 +92,12 @@ import {
} from "@paperclipai/adapter-utils/duplex-aggregate-byte-ledger";
import { resolveDuplexAggregateCeilingBytesFromEnv } from "./duplex-aggregate-ceiling-env.js";
import { DUPLEX_COUNTER_AGGREGATE_BYTE_ACCOUNTING_UNDERFLOW_TOTAL } from "@paperclipai/adapter-utils/duplex-observability";
import {
configureHttp2BridgeAdmissionGate,
http2BridgeAdmissionBudgetBytes,
HTTP2_BRIDGE_ADMISSION_RESERVED_BUDGET_BYTES,
} from "@paperclipai/adapter-utils/http2-bridge-admission";
import { resolveHttp2BridgeAdmissionCapsFromEnv } from "./http2-bridge-admission-env.js";
import { createStorageServiceFromConfig } from "./storage/index.js";
import { printStartupBanner } from "./startup-banner.js";
import { getBoardClaimWarningUrl, initializeBoardClaimChallenge } from "./board-claim.js";
@@ -792,6 +798,51 @@ export async function startServer(): Promise<StartedServer> {
);
},
);
// The process-wide admission gate for the host HTTP/2 sandbox bridge. Two
// optional operator overrides read PAPERCLIP_MAX_PARALLEL_HTTP2_BRIDGE_REQUESTS
// and PAPERCLIP_MAX_LIVE_HTTP2_BRIDGE_SESSIONS. An absent variable uses the
// documented default. A present invalid, blank, whitespace-only, non-finite,
// zero, negative, non-integer, unsafe, or over-maximum value does not fail
// startup: the resolver rejects it, reports it at error level, and uses the
// safe default. Each per-field cap can pass its own range and the pair can
// still spend more than the reserved memory budget, so the resolver also
// checks the joint rule on the resolved pair. A pair that fails the joint
// rule gives both defaults and one error log line; the host never keeps one
// operator value and one default from a refused pair. Every log line carries
// only the parsed number, never the raw operator string.
const http2BridgeAdmissionCaps = resolveHttp2BridgeAdmissionCapsFromEnv(
process.env.PAPERCLIP_MAX_PARALLEL_HTTP2_BRIDGE_REQUESTS,
process.env.PAPERCLIP_MAX_LIVE_HTTP2_BRIDGE_SESSIONS,
(rejectedValue) => {
logger.error(
{ rejectedValue },
"HTTP/2 bridge admission cap override rejected; using the safe default",
);
},
(rejection) => {
logger.error(
{
maxParallel: rejection.maxParallel,
maxSessions: rejection.maxSessions,
totalBytes: rejection.totalBytes,
reservedBudgetBytes: HTTP2_BRIDGE_ADMISSION_RESERVED_BUDGET_BYTES,
},
"HTTP/2 bridge admission cap pair refused; using the safe default pair",
);
},
);
configureHttp2BridgeAdmissionGate(http2BridgeAdmissionCaps);
logger.info(
{
maxParallel: http2BridgeAdmissionCaps.maxParallel,
maxSessions: http2BridgeAdmissionCaps.maxSessions,
totalBytes: http2BridgeAdmissionBudgetBytes(
http2BridgeAdmissionCaps.maxParallel,
http2BridgeAdmissionCaps.maxSessions,
),
},
"HTTP/2 bridge admission gate configured",
);
// The server has no process metric pipeline yet, so the ledger telemetry maps to
// the structured logger. The gauge logs at debug. A reservation rejection logs at
// warn, because it marks an availability limit hit. An accounting-underflow defect