machine-bridge-mcp 3.0.0-beta.17 → 3.0.0-beta.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +50 -0
- package/README.md +2 -0
- package/SECURITY.md +9 -7
- package/browser-extension/browser-error-boundary.js +45 -0
- package/browser-extension/browser-operations.js +1 -1
- package/browser-extension/manifest.json +1 -1
- package/browser-extension/service-worker.js +9 -8
- package/docs/ARCHITECTURE.md +17 -15
- package/docs/AUDIT.md +30 -0
- package/docs/ENGINEERING.md +3 -3
- package/docs/GETTING_STARTED.md +1 -1
- package/docs/LOCAL_AUTHORIZATION.md +2 -2
- package/docs/LOCAL_AUTOMATION.md +1 -1
- package/docs/LOGGING.md +3 -1
- package/docs/MULTI_ACCOUNT.md +2 -2
- package/docs/OPERATIONS.md +13 -5
- package/docs/OVERVIEW.md +2 -2
- package/docs/PRIVACY.md +1 -1
- package/docs/TESTING.md +17 -13
- package/docs/THREAT_MODEL.md +13 -8
- package/docs/UPGRADING.md +19 -1
- package/package.json +1 -1
- package/scripts/coverage-check.mjs +12 -0
- package/src/local/account-admin.mjs +68 -1
- package/src/local/agent-skill-discovery.mjs +16 -5
- package/src/local/app-automation.mjs +27 -7
- package/src/local/cli-options.mjs +1 -1
- package/src/local/path-inspection.mjs +23 -0
- package/src/local/process-tree-ownership.mjs +17 -5
- package/src/local/process-tree.mjs +6 -2
- package/src/local/relay-connection.mjs +24 -11
- package/src/local/resource-operations.mjs +37 -8
- package/src/local/runtime-activation.mjs +16 -0
- package/src/local/runtime-capabilities.mjs +15 -3
- package/src/local/runtime.mjs +7 -4
- package/src/local/worker-deployment.mjs +16 -6
- package/src/local/workspace-file-service.mjs +29 -11
- package/src/worker/daemon-socket-attachment.ts +52 -0
- package/src/worker/daemon-sockets.ts +17 -48
- package/src/worker/durable-stream-calls.ts +129 -0
- package/src/worker/durable-stream-result.ts +22 -0
- package/src/worker/http.ts +37 -15
- package/src/worker/index.ts +143 -146
- package/src/worker/mcp-pending-call-expiry.ts +20 -0
- package/src/worker/mcp-pending-call-inspection.ts +33 -0
- package/src/worker/mcp-pending-call-records.ts +58 -0
- package/src/worker/mcp-pending-call-storage.ts +18 -0
- package/src/worker/mcp-pending-call-store.ts +285 -0
- package/src/worker/mcp-resumption-index.ts +41 -0
- package/src/worker/mcp-resumption-records.ts +8 -2
- package/src/worker/mcp-resumption.ts +84 -86
- package/src/worker/mcp-stream-dispatch.ts +42 -54
- package/src/worker/mcp-stream-proxy.ts +7 -69
- package/src/worker/mcp-stream-subscription.ts +98 -0
- package/src/worker/oauth-controller.ts +11 -2
- package/src/worker/oauth-refresh-exchange.ts +147 -0
- package/src/worker/oauth-refresh-families.ts +59 -17
- package/src/worker/oauth-state.ts +13 -2
- package/src/worker/oauth-token-derivation.ts +33 -0
- package/src/worker/oauth-token-issuance.ts +106 -0
- package/src/worker/oauth-tokens.ts +12 -184
- package/src/worker/observability.ts +19 -0
- package/src/worker/pending-admission.ts +15 -0
- package/src/worker/pending-call-contract.ts +4 -7
- package/src/worker/pending-calls.ts +4 -10
- package/src/worker/runtime-alarm-storage.ts +35 -0
- package/src/worker/runtime-alarm.ts +30 -20
- package/src/worker/websocket-protocol.ts +4 -0
- package/src/worker/worker-edge-guard.ts +81 -0
- package/src/worker/worker-edge-log.ts +63 -0
- package/src/worker/worker-entry.ts +50 -0
- package/src/worker/worker-metadata.ts +39 -0
- package/src/worker/worker-static-routes.ts +38 -50
- package/wrangler.jsonc +8 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { sanitizeDaemonChallengeAttachment } from "./daemon-auth.ts";
|
|
2
|
+
import type { DaemonRole } from "./daemon-liveness.ts";
|
|
3
|
+
import { sanitizeMetadataText } from "./http.ts";
|
|
4
|
+
import { sanitizeDaemonPolicy, sanitizeDaemonTools, type DaemonPolicy } from "./policy.ts";
|
|
5
|
+
|
|
6
|
+
export interface DaemonAttachment {
|
|
7
|
+
role: DaemonRole;
|
|
8
|
+
connectedAt: string;
|
|
9
|
+
lastSeenAt?: string;
|
|
10
|
+
probeId?: string;
|
|
11
|
+
instanceId?: string;
|
|
12
|
+
connectionId?: string;
|
|
13
|
+
policy?: DaemonPolicy;
|
|
14
|
+
tools?: string[];
|
|
15
|
+
authChallenge?: string;
|
|
16
|
+
authIssuedAt?: number;
|
|
17
|
+
authExpiresAt?: number;
|
|
18
|
+
workerOrigin?: string;
|
|
19
|
+
authSessionPublicKeyJson?: string;
|
|
20
|
+
authSessionKeyId?: string;
|
|
21
|
+
authCertificateExpiresAt?: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function sanitizeDaemonAttachment(value: unknown): DaemonAttachment | undefined {
|
|
25
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
26
|
+
const candidate = value as Partial<DaemonAttachment>;
|
|
27
|
+
if (!["candidate", "probing", "expired", "daemon"].includes(String(candidate.role))) return undefined;
|
|
28
|
+
const policy = sanitizeDaemonPolicy(candidate.policy);
|
|
29
|
+
return {
|
|
30
|
+
role: candidate.role as DaemonRole,
|
|
31
|
+
connectedAt: sanitizeMetadataText(candidate.connectedAt, 64) ?? "",
|
|
32
|
+
lastSeenAt: sanitizeMetadataText(candidate.lastSeenAt, 64),
|
|
33
|
+
probeId: sanitizeProbeId(candidate.probeId),
|
|
34
|
+
instanceId: sanitizeDaemonInstanceId(candidate.instanceId),
|
|
35
|
+
connectionId: sanitizeConnectionId(candidate.connectionId),
|
|
36
|
+
policy,
|
|
37
|
+
tools: sanitizeDaemonTools(candidate.tools, policy),
|
|
38
|
+
...sanitizeDaemonChallengeAttachment(candidate as Record<string, unknown>),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function sanitizeProbeId(value: unknown): string | undefined {
|
|
43
|
+
return typeof value === "string" && /^probe_[A-Za-z0-9_-]{8,240}$/.test(value) ? value : undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function sanitizeDaemonInstanceId(value: unknown): string | undefined {
|
|
47
|
+
return typeof value === "string" && /^daemon_[A-Za-z0-9_-]{16,96}$/.test(value) ? value : undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function sanitizeConnectionId(value: unknown): string | undefined {
|
|
51
|
+
return typeof value === "string" && /^connection_[A-Za-z0-9_-]{43}$/.test(value) ? value : undefined;
|
|
52
|
+
}
|
|
@@ -1,24 +1,7 @@
|
|
|
1
1
|
import { isLiveDaemonAttachment, withDaemonLastSeenAt, type DaemonRole } from "./daemon-liveness.ts";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
export interface DaemonAttachment {
|
|
6
|
-
role: DaemonRole;
|
|
7
|
-
connectedAt: string;
|
|
8
|
-
lastSeenAt?: string;
|
|
9
|
-
probeId?: string;
|
|
10
|
-
instanceId?: string;
|
|
11
|
-
policy?: DaemonPolicy;
|
|
12
|
-
tools?: string[];
|
|
13
|
-
authChallenge?: string;
|
|
14
|
-
authIssuedAt?: number;
|
|
15
|
-
authExpiresAt?: number;
|
|
16
|
-
workerOrigin?: string;
|
|
17
|
-
authSessionPublicKeyJson?: string;
|
|
18
|
-
authSessionKeyId?: string;
|
|
19
|
-
authCertificateExpiresAt?: number;
|
|
20
|
-
}
|
|
21
|
-
|
|
2
|
+
import type { DaemonPolicy } from "./policy.ts";
|
|
3
|
+
import type { DaemonChallenge } from "./daemon-auth.ts";
|
|
4
|
+
import { sanitizeDaemonAttachment, type DaemonAttachment } from "./daemon-socket-attachment.ts";
|
|
22
5
|
interface WebSocketContext {
|
|
23
6
|
getWebSockets(): WebSocket[];
|
|
24
7
|
}
|
|
@@ -28,21 +11,7 @@ export class DaemonSocketRegistry {
|
|
|
28
11
|
constructor(context: WebSocketContext) { this.context = context; }
|
|
29
12
|
|
|
30
13
|
attachment(socket: WebSocket): DaemonAttachment | undefined {
|
|
31
|
-
|
|
32
|
-
if (!raw || typeof raw !== "object") return undefined;
|
|
33
|
-
const candidate = raw as Partial<DaemonAttachment>;
|
|
34
|
-
if (!["candidate", "probing", "expired", "daemon"].includes(String(candidate.role))) return undefined;
|
|
35
|
-
const policy = sanitizeDaemonPolicy(candidate.policy);
|
|
36
|
-
return {
|
|
37
|
-
role: candidate.role as DaemonRole,
|
|
38
|
-
connectedAt: sanitizeMetadataText(candidate.connectedAt, 64) ?? "",
|
|
39
|
-
lastSeenAt: sanitizeMetadataText(candidate.lastSeenAt, 64),
|
|
40
|
-
probeId: sanitizeProbeId(candidate.probeId),
|
|
41
|
-
instanceId: sanitizeDaemonInstanceId(candidate.instanceId),
|
|
42
|
-
policy,
|
|
43
|
-
tools: sanitizeDaemonTools(candidate.tools, policy),
|
|
44
|
-
...sanitizeDaemonChallengeAttachment(candidate as Record<string, unknown>),
|
|
45
|
-
};
|
|
14
|
+
return sanitizeDaemonAttachment(socket.deserializeAttachment());
|
|
46
15
|
}
|
|
47
16
|
|
|
48
17
|
readyAttachment(socket: WebSocket): DaemonAttachment | undefined {
|
|
@@ -68,11 +37,13 @@ export class DaemonSocketRegistry {
|
|
|
68
37
|
socket: WebSocket,
|
|
69
38
|
challenge: DaemonChallenge,
|
|
70
39
|
preflight: { sessionPublicKeyJson: string; sessionKeyId: string; certificateExpiresAt: number },
|
|
40
|
+
connectionId: string,
|
|
71
41
|
connectedAt = new Date().toISOString(),
|
|
72
42
|
): void {
|
|
73
43
|
socket.serializeAttachment({
|
|
74
44
|
role: "candidate",
|
|
75
45
|
connectedAt,
|
|
46
|
+
connectionId,
|
|
76
47
|
authChallenge: challenge.challenge,
|
|
77
48
|
authIssuedAt: challenge.issuedAt,
|
|
78
49
|
authExpiresAt: challenge.expiresAt,
|
|
@@ -83,10 +54,11 @@ export class DaemonSocketRegistry {
|
|
|
83
54
|
} satisfies DaemonAttachment);
|
|
84
55
|
}
|
|
85
56
|
|
|
86
|
-
beginProbe(socket: WebSocket, values: { connectedAt: string; probeId: string; instanceId: string; policy: DaemonPolicy; tools: string[] }): void {
|
|
57
|
+
beginProbe(socket: WebSocket, values: { connectedAt: string; probeId: string; instanceId: string; connectionId: string; policy: DaemonPolicy; tools: string[] }): void {
|
|
87
58
|
socket.serializeAttachment({
|
|
88
59
|
role: "probing", connectedAt: values.connectedAt, lastSeenAt: values.connectedAt,
|
|
89
|
-
probeId: values.probeId, instanceId: values.instanceId,
|
|
60
|
+
probeId: values.probeId, instanceId: values.instanceId, connectionId: values.connectionId,
|
|
61
|
+
policy: values.policy, tools: values.tools,
|
|
90
62
|
} satisfies DaemonAttachment);
|
|
91
63
|
}
|
|
92
64
|
|
|
@@ -111,10 +83,17 @@ export class DaemonSocketRegistry {
|
|
|
111
83
|
const attachment = this.attachment(socket);
|
|
112
84
|
if (!attachment) return;
|
|
113
85
|
socket.serializeAttachment({
|
|
114
|
-
role: "expired", connectedAt: attachment.connectedAt, lastSeenAt: attachment.lastSeenAt,
|
|
86
|
+
role: "expired", connectedAt: attachment.connectedAt, lastSeenAt: attachment.lastSeenAt,
|
|
87
|
+
instanceId: attachment.instanceId, connectionId: attachment.connectionId,
|
|
115
88
|
} satisfies DaemonAttachment);
|
|
116
89
|
}
|
|
117
90
|
|
|
91
|
+
socketForConnectionId(connectionId: string): WebSocket | undefined {
|
|
92
|
+
if (!connectionId) return undefined;
|
|
93
|
+
return this.context.getWebSockets().find((socket) =>
|
|
94
|
+
this.attachment(socket)?.connectionId === connectionId && socket.readyState === WebSocket.OPEN);
|
|
95
|
+
}
|
|
96
|
+
|
|
118
97
|
private openSockets(role: DaemonRole): WebSocket[] {
|
|
119
98
|
return this.context.getWebSockets().filter((socket) => this.attachment(socket)?.role === role && socket.readyState === WebSocket.OPEN);
|
|
120
99
|
}
|
|
@@ -123,13 +102,3 @@ export class DaemonSocketRegistry {
|
|
|
123
102
|
return Date.parse(this.attachment(socket)?.connectedAt ?? "") || 0;
|
|
124
103
|
}
|
|
125
104
|
}
|
|
126
|
-
|
|
127
|
-
function sanitizeProbeId(value: unknown): string | undefined {
|
|
128
|
-
if (typeof value !== "string" || !/^probe_[A-Za-z0-9_-]{8,240}$/.test(value)) return undefined;
|
|
129
|
-
return value;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
function sanitizeDaemonInstanceId(value: unknown): string | undefined {
|
|
133
|
-
if (typeof value !== "string" || !/^daemon_[A-Za-z0-9_-]{16,96}$/.test(value)) return undefined;
|
|
134
|
-
return value;
|
|
135
|
-
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { daemonLastSeenMs } from "./daemon-liveness.ts";
|
|
2
|
+
import type { DaemonSocketRegistry } from "./daemon-sockets.ts";
|
|
3
|
+
import { publicWorkerToolError, WorkerToolError } from "./errors.ts";
|
|
4
|
+
import { workerErrorClass } from "./http.ts";
|
|
5
|
+
import type { PendingCallOutcome } from "./pending-call-contract.ts";
|
|
6
|
+
import type { McpPendingCallStore, PendingStreamCallView } from "./mcp-pending-call-store.ts";
|
|
7
|
+
import { streamTerminalMessage } from "./mcp-stream-dispatch.ts";
|
|
8
|
+
import { transformDurableStreamOutcome } from "./durable-stream-result.ts";
|
|
9
|
+
import type { WorkerObservability } from "./observability.ts";
|
|
10
|
+
import { sendWebSocketQuietly } from "./websocket-protocol.ts";
|
|
11
|
+
|
|
12
|
+
type InvalidateDaemonSocket = (
|
|
13
|
+
socket: WebSocket,
|
|
14
|
+
message: string,
|
|
15
|
+
closeReason: string,
|
|
16
|
+
errorCode?: string,
|
|
17
|
+
) => Promise<void>;
|
|
18
|
+
|
|
19
|
+
type TransientPendingSnapshot = {
|
|
20
|
+
active: number;
|
|
21
|
+
detached: number;
|
|
22
|
+
request_keys: number;
|
|
23
|
+
oldest_ms: number;
|
|
24
|
+
by_tool: Record<string, number>;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export class DurableStreamCallCoordinator {
|
|
28
|
+
private readonly calls: McpPendingCallStore;
|
|
29
|
+
private readonly daemonRegistry: DaemonSocketRegistry;
|
|
30
|
+
private readonly observability: WorkerObservability;
|
|
31
|
+
private readonly maximumPendingCalls: number;
|
|
32
|
+
private readonly invalidateDaemonSocket: InvalidateDaemonSocket;
|
|
33
|
+
|
|
34
|
+
constructor(
|
|
35
|
+
calls: McpPendingCallStore,
|
|
36
|
+
daemonRegistry: DaemonSocketRegistry,
|
|
37
|
+
observability: WorkerObservability,
|
|
38
|
+
maximumPendingCalls: number,
|
|
39
|
+
invalidateDaemonSocket: InvalidateDaemonSocket,
|
|
40
|
+
) {
|
|
41
|
+
this.calls = calls;
|
|
42
|
+
this.daemonRegistry = daemonRegistry;
|
|
43
|
+
this.observability = observability;
|
|
44
|
+
this.maximumPendingCalls = maximumPendingCalls;
|
|
45
|
+
this.invalidateDaemonSocket = invalidateDaemonSocket;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async snapshot(transient: TransientPendingSnapshot): Promise<Record<string, unknown>> {
|
|
49
|
+
const durable = await this.calls.snapshot(this.maximumPendingCalls);
|
|
50
|
+
const byTool = Object.assign(Object.create(null) as Record<string, number>, transient.by_tool);
|
|
51
|
+
for (const [tool, count] of Object.entries(durable.by_tool)) byTool[tool] = (byTool[tool] ?? 0) + count;
|
|
52
|
+
return {
|
|
53
|
+
active: transient.active + durable.active,
|
|
54
|
+
detached: transient.detached + durable.detached,
|
|
55
|
+
request_keys: transient.request_keys + durable.request_keys,
|
|
56
|
+
maximum: this.maximumPendingCalls,
|
|
57
|
+
oldest_ms: Math.max(transient.oldest_ms, durable.oldest_ms),
|
|
58
|
+
by_tool: byTool,
|
|
59
|
+
transient: transient.active,
|
|
60
|
+
durable_streams: durable.active,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async settle(
|
|
65
|
+
callId: string,
|
|
66
|
+
connectionId: string,
|
|
67
|
+
outcome: PendingCallOutcome,
|
|
68
|
+
knownCall?: PendingStreamCallView,
|
|
69
|
+
): Promise<boolean> {
|
|
70
|
+
const call = knownCall ?? await this.calls.get(callId);
|
|
71
|
+
if (!call || call.connection_id !== connectionId) return false;
|
|
72
|
+
const normalized = transformDurableStreamOutcome(call, outcome);
|
|
73
|
+
const code = normalized.ok ? "" : publicWorkerToolError(normalized.error).code;
|
|
74
|
+
try {
|
|
75
|
+
const completed = await this.calls.complete(
|
|
76
|
+
callId,
|
|
77
|
+
connectionId,
|
|
78
|
+
streamTerminalMessage(call.requestId, normalized),
|
|
79
|
+
);
|
|
80
|
+
if (!completed) return false;
|
|
81
|
+
} catch (error) {
|
|
82
|
+
this.observability.event("error", "mcp.stream.persist.failed", { error_class: workerErrorClass(error) });
|
|
83
|
+
}
|
|
84
|
+
this.observability.callFinished(call.tool, code);
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async expire(call: PendingStreamCallView): Promise<void> {
|
|
89
|
+
const socket = call.state === "attached" ? this.daemonRegistry.socketForConnectionId(call.connection_id) : undefined;
|
|
90
|
+
if (socket) {
|
|
91
|
+
sendWebSocketQuietly(socket, { type: "cancel_call", id: call.call_id });
|
|
92
|
+
const silentForMs = Date.now() - daemonLastSeenMs(this.daemonRegistry.readyAttachment(socket));
|
|
93
|
+
if (!Number.isFinite(silentForMs) || silentForMs > 45_000) {
|
|
94
|
+
void this.invalidateDaemonSocket(socket, "daemon became unresponsive", "daemon liveness timeout");
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const error = call.state === "attached"
|
|
98
|
+
? new WorkerToolError("timeout", `daemon tool timed out: ${call.tool}`, true)
|
|
99
|
+
: new WorkerToolError("unavailable", "daemon disconnected; reconnect grace expired", true);
|
|
100
|
+
await this.settle(call.call_id, call.connection_id, { ok: false, error }, call);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async expireDue(now = Date.now()): Promise<number> {
|
|
104
|
+
const due = await this.calls.due(now);
|
|
105
|
+
for (const call of due) await this.expire(call);
|
|
106
|
+
return due.length;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async cancel(requestKey: string): Promise<boolean> {
|
|
110
|
+
const call = await this.calls.getByRequestKey(requestKey);
|
|
111
|
+
if (!call) return false;
|
|
112
|
+
const socket = call.state === "attached" ? this.daemonRegistry.socketForConnectionId(call.connection_id) : undefined;
|
|
113
|
+
if (socket) sendWebSocketQuietly(socket, { type: "cancel_call", id: call.call_id });
|
|
114
|
+
return this.settle(
|
|
115
|
+
call.call_id,
|
|
116
|
+
call.connection_id,
|
|
117
|
+
{ ok: false, error: new WorkerToolError("cancelled", "tool call cancelled by client") },
|
|
118
|
+
call,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
detach(connectionId: string, graceMs: number): Promise<number> {
|
|
123
|
+
return this.calls.detach(connectionId, graceMs);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
rebind(daemonInstanceId: string, connectionId: string): Promise<string[]> {
|
|
127
|
+
return this.calls.rebind(daemonInstanceId, connectionId);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { decorateProjectOverview } from "./authority.ts";
|
|
2
|
+
import type { PendingCallOutcome } from "./pending-call-contract.ts";
|
|
3
|
+
import type { PendingStreamCallView } from "./mcp-pending-call-store.ts";
|
|
4
|
+
|
|
5
|
+
export function transformDurableStreamOutcome(
|
|
6
|
+
call: PendingStreamCallView,
|
|
7
|
+
outcome: PendingCallOutcome,
|
|
8
|
+
): PendingCallOutcome {
|
|
9
|
+
if (!outcome.ok || call.transform?.kind !== "project_overview") return outcome;
|
|
10
|
+
try {
|
|
11
|
+
return {
|
|
12
|
+
ok: true,
|
|
13
|
+
value: decorateProjectOverview(outcome.value, {
|
|
14
|
+
accountId: call.transform.account_id,
|
|
15
|
+
accountVersion: call.transform.account_version,
|
|
16
|
+
role: call.transform.role,
|
|
17
|
+
}),
|
|
18
|
+
};
|
|
19
|
+
} catch (error) {
|
|
20
|
+
return { ok: false, error: error instanceof Error ? error : new Error("stream result transformation failed") };
|
|
21
|
+
}
|
|
22
|
+
}
|
package/src/worker/http.ts
CHANGED
|
@@ -143,41 +143,55 @@ export async function parseRequestBody(request: Request, limit: number): Promise
|
|
|
143
143
|
export async function discardRequestBody(request: Pick<Request, "body" | "headers">, limit: number): Promise<{ bytes_read: number; exceeded: boolean }> {
|
|
144
144
|
const boundedLimit = normalizeBodyLimit(limit);
|
|
145
145
|
const declaredLength = Number(request.headers.get("content-length") ?? "0");
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
if (!request.body) return { bytes_read: 0, exceeded };
|
|
146
|
+
const declaredExceeded = Number.isFinite(declaredLength) && declaredLength > boundedLimit;
|
|
147
|
+
if (!request.body) return { bytes_read: 0, exceeded: declaredExceeded };
|
|
149
148
|
const reader = request.body.getReader();
|
|
149
|
+
let observed = 0;
|
|
150
150
|
try {
|
|
151
|
+
if (declaredExceeded) {
|
|
152
|
+
await cancelBodyReader(reader);
|
|
153
|
+
return { bytes_read: 0, exceeded: true };
|
|
154
|
+
}
|
|
151
155
|
for (;;) {
|
|
152
156
|
const { done, value } = await reader.read();
|
|
153
|
-
if (done)
|
|
157
|
+
if (done) return { bytes_read: observed, exceeded: false };
|
|
154
158
|
if (!value) continue;
|
|
155
159
|
observed = Math.min(boundedLimit + 1, observed + value.byteLength);
|
|
156
|
-
if (observed > boundedLimit)
|
|
160
|
+
if (observed > boundedLimit) {
|
|
161
|
+
await cancelBodyReader(reader);
|
|
162
|
+
return { bytes_read: observed, exceeded: true };
|
|
163
|
+
}
|
|
157
164
|
}
|
|
158
165
|
} finally {
|
|
159
166
|
reader.releaseLock();
|
|
160
167
|
}
|
|
161
|
-
return { bytes_read: observed, exceeded };
|
|
162
168
|
}
|
|
163
169
|
|
|
164
170
|
export async function readBoundedText(request: Request, limit: number): Promise<string> {
|
|
165
171
|
const boundedLimit = normalizeBodyLimit(limit);
|
|
166
172
|
const declaredLength = Number(request.headers.get("content-length") ?? "0");
|
|
167
|
-
|
|
173
|
+
const declaredExceeded = Number.isFinite(declaredLength) && declaredLength > boundedLimit;
|
|
168
174
|
const reader = request.body?.getReader();
|
|
169
175
|
if (!reader) return "";
|
|
170
176
|
const chunks: Uint8Array[] = [];
|
|
171
177
|
let observed = 0;
|
|
178
|
+
let exceeded = declaredExceeded;
|
|
172
179
|
try {
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
180
|
+
if (declaredExceeded) await cancelBodyReader(reader);
|
|
181
|
+
else {
|
|
182
|
+
for (;;) {
|
|
183
|
+
const { done, value } = await reader.read();
|
|
184
|
+
if (done) break;
|
|
185
|
+
if (!value) continue;
|
|
186
|
+
const nextObserved = Math.min(boundedLimit + 1, observed + value.byteLength);
|
|
187
|
+
if (observed + value.byteLength <= boundedLimit) chunks.push(value);
|
|
188
|
+
else {
|
|
189
|
+
exceeded = true;
|
|
190
|
+
await cancelBodyReader(reader);
|
|
191
|
+
}
|
|
192
|
+
observed = nextObserved;
|
|
193
|
+
if (exceeded) break;
|
|
194
|
+
}
|
|
181
195
|
}
|
|
182
196
|
} finally {
|
|
183
197
|
reader.releaseLock();
|
|
@@ -196,6 +210,14 @@ export async function readBoundedText(request: Request, limit: number): Promise<
|
|
|
196
210
|
}
|
|
197
211
|
}
|
|
198
212
|
|
|
213
|
+
async function cancelBodyReader(reader: ReadableStreamDefaultReader<Uint8Array>): Promise<void> {
|
|
214
|
+
try {
|
|
215
|
+
await reader.cancel("request body limit reached");
|
|
216
|
+
} catch {
|
|
217
|
+
// Cancellation is cleanup after the size decision; it must not replace the bounded response.
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
199
221
|
function normalizeBodyLimit(value: number): number {
|
|
200
222
|
const parsed = Number(value);
|
|
201
223
|
return Number.isFinite(parsed) && parsed >= 0 ? Math.floor(parsed) : 0;
|