@nifrajs/coding-agent 3.3.0 → 3.5.0
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/dist/context.js +29 -1
- package/dist/context.js.map +1 -1
- package/dist/diff.js +38 -2
- package/dist/diff.js.map +1 -1
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +146 -23
- package/dist/host.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/isolated.js +37 -2
- package/dist/isolated.js.map +1 -1
- package/dist/native.d.ts +5 -0
- package/dist/native.d.ts.map +1 -1
- package/dist/native.js +138 -34
- package/dist/native.js.map +1 -1
- package/dist/replay.d.ts +4 -0
- package/dist/replay.d.ts.map +1 -1
- package/dist/replay.js +189 -24
- package/dist/replay.js.map +1 -1
- package/dist/rpc.d.ts +10 -0
- package/dist/rpc.d.ts.map +1 -1
- package/dist/rpc.js +208 -29
- package/dist/rpc.js.map +1 -1
- package/dist/sessions.d.ts +14 -2
- package/dist/sessions.d.ts.map +1 -1
- package/dist/sessions.js +253 -59
- package/dist/sessions.js.map +1 -1
- package/dist/verification.d.ts +23 -0
- package/dist/verification.d.ts.map +1 -1
- package/dist/verification.js +113 -1
- package/dist/verification.js.map +1 -1
- package/dist/workflows.d.ts +1 -0
- package/dist/workflows.d.ts.map +1 -1
- package/dist/workflows.js +43 -21
- package/dist/workflows.js.map +1 -1
- package/package.json +6 -5
- package/src/context.ts +32 -1
- package/src/diff.ts +40 -2
- package/src/host.ts +146 -23
- package/src/index.ts +5 -0
- package/src/isolated.ts +39 -2
- package/src/native.ts +173 -32
- package/src/replay.ts +206 -25
- package/src/rpc.ts +261 -33
- package/src/sessions.ts +271 -56
- package/src/verification.ts +140 -1
- package/src/workflows.ts +37 -25
package/src/rpc.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { CodingAgentHost, type CodingAgentHostOptions } from "./host.ts"
|
|
|
11
11
|
import { readBoundedText } from "./process.ts"
|
|
12
12
|
import type { ContextWindowOptions, SessionStore } from "./sessions.ts"
|
|
13
13
|
import type { UiExtensionHost, UiExtensionManifest } from "./ui.ts"
|
|
14
|
-
import { runNifraVerification } from "./verification.ts"
|
|
14
|
+
import { isSafeReviewDiff, runNifraReview, runNifraVerification } from "./verification.ts"
|
|
15
15
|
|
|
16
16
|
export interface CodingAgentRpcServerOptions {
|
|
17
17
|
readonly backend: AgentBackend
|
|
@@ -25,6 +25,8 @@ export interface CodingAgentRpcServerOptions {
|
|
|
25
25
|
/** Include bounded exception stacks in RPC failures only for trusted loopback debugging. */
|
|
26
26
|
readonly exposeErrorStacks?: boolean
|
|
27
27
|
readonly maxBodyBytes?: number
|
|
28
|
+
/** Maximum UTF-8 bytes in one non-streaming JSON response. Defaults to 4 MiB. */
|
|
29
|
+
readonly maxResponseBytes?: number
|
|
28
30
|
readonly sessionStore?: SessionStore
|
|
29
31
|
readonly contextWindow?: ContextWindowOptions
|
|
30
32
|
readonly extensions?: ExtensionHost
|
|
@@ -50,6 +52,15 @@ interface RpcServerLike {
|
|
|
50
52
|
stop(closeActiveConnections?: boolean): void
|
|
51
53
|
}
|
|
52
54
|
|
|
55
|
+
const MAX_RPC_BODY_BYTES = 64 * 1024 * 1024
|
|
56
|
+
const DEFAULT_MAX_RPC_RESPONSE_BYTES = 4 * 1024 * 1024
|
|
57
|
+
const MAX_RPC_RESPONSE_BYTES = 64 * 1024 * 1024
|
|
58
|
+
const SESSION_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/
|
|
59
|
+
const MIN_REMOTE_TOKEN_BYTES = 32
|
|
60
|
+
const AUTH_FAILURE_WINDOW_MS = 60_000
|
|
61
|
+
const AUTH_FAILURE_THRESHOLD = 3
|
|
62
|
+
const AUTH_MAX_BACKOFF_MS = 2_000
|
|
63
|
+
|
|
53
64
|
/**
|
|
54
65
|
* Minimal loopback RPC surface for the CLI, Workbench, CI clients, and a future mobile companion.
|
|
55
66
|
* Turn output is SSE so clients do not need a WebSocket dependency; every event is still a versioned
|
|
@@ -60,7 +71,11 @@ export class CodingAgentRpcServer {
|
|
|
60
71
|
private readonly options: CodingAgentRpcServerOptions
|
|
61
72
|
private readonly token: string
|
|
62
73
|
private readonly maxBodyBytes: number
|
|
74
|
+
private readonly maxResponseBytes: number
|
|
63
75
|
private listener: RpcServerLike | undefined
|
|
76
|
+
private authFailures = 0
|
|
77
|
+
private authLastFailureAt = 0
|
|
78
|
+
private authBackoffUntil = 0
|
|
64
79
|
|
|
65
80
|
constructor(options: CodingAgentRpcServerOptions) {
|
|
66
81
|
this.options = Object.freeze({ ...options })
|
|
@@ -78,12 +93,25 @@ export class CodingAgentRpcServer {
|
|
|
78
93
|
exposeErrorStacks: options.exposeErrorStacks === true,
|
|
79
94
|
...(options.verification === undefined ? {} : { verification: options.verification }),
|
|
80
95
|
})
|
|
81
|
-
this.token = options.authToken ??
|
|
96
|
+
this.token = options.authToken ?? randomAuthToken()
|
|
82
97
|
if (!/^[A-Za-z0-9._~-]{16,256}$/.test(this.token))
|
|
83
98
|
throw new TypeError("agent rpc: authToken must be a bounded token")
|
|
84
99
|
this.maxBodyBytes = options.maxBodyBytes ?? 1_048_576
|
|
85
|
-
if (
|
|
86
|
-
|
|
100
|
+
if (
|
|
101
|
+
!Number.isSafeInteger(this.maxBodyBytes) ||
|
|
102
|
+
this.maxBodyBytes < 1024 ||
|
|
103
|
+
this.maxBodyBytes > MAX_RPC_BODY_BYTES
|
|
104
|
+
)
|
|
105
|
+
throw new RangeError(`agent rpc: maxBodyBytes must be between 1024 and ${MAX_RPC_BODY_BYTES}`)
|
|
106
|
+
this.maxResponseBytes = options.maxResponseBytes ?? DEFAULT_MAX_RPC_RESPONSE_BYTES
|
|
107
|
+
if (
|
|
108
|
+
!Number.isSafeInteger(this.maxResponseBytes) ||
|
|
109
|
+
this.maxResponseBytes < 1024 ||
|
|
110
|
+
this.maxResponseBytes > MAX_RPC_RESPONSE_BYTES
|
|
111
|
+
)
|
|
112
|
+
throw new RangeError(
|
|
113
|
+
`agent rpc: maxResponseBytes must be between 1024 and ${MAX_RPC_RESPONSE_BYTES}`,
|
|
114
|
+
)
|
|
87
115
|
}
|
|
88
116
|
|
|
89
117
|
async start(): Promise<CodingAgentRpcServerHandle> {
|
|
@@ -91,6 +119,14 @@ export class CodingAgentRpcServer {
|
|
|
91
119
|
const hostname = this.options.hostname ?? "127.0.0.1"
|
|
92
120
|
if (!this.options.allowRemote && !isLoopbackHost(hostname))
|
|
93
121
|
throw new Error("agent rpc: remote binding requires allowRemote: true")
|
|
122
|
+
if (
|
|
123
|
+
this.options.authToken !== undefined &&
|
|
124
|
+
!isLoopbackHost(hostname) &&
|
|
125
|
+
new TextEncoder().encode(this.token).byteLength < MIN_REMOTE_TOKEN_BYTES
|
|
126
|
+
)
|
|
127
|
+
throw new Error(
|
|
128
|
+
`agent rpc: explicit remote authToken must be at least ${MIN_REMOTE_TOKEN_BYTES} bytes`,
|
|
129
|
+
)
|
|
94
130
|
if (this.options.exposeErrorStacks === true && this.options.allowRemote === true)
|
|
95
131
|
throw new Error("agent rpc: exposeErrorStacks is only allowed for local-only binding")
|
|
96
132
|
this.listener = Bun.serve({
|
|
@@ -111,8 +147,13 @@ export class CodingAgentRpcServer {
|
|
|
111
147
|
async stop(): Promise<void> {
|
|
112
148
|
const listener = this.listener
|
|
113
149
|
this.listener = undefined
|
|
114
|
-
|
|
115
|
-
|
|
150
|
+
try {
|
|
151
|
+
await this.host.stop("rpc server stopped")
|
|
152
|
+
} finally {
|
|
153
|
+
// The audit write is best effort from a transport-lifecycle perspective. Even when the host
|
|
154
|
+
// reports a persistence failure, the listening socket must not remain reachable or leaked.
|
|
155
|
+
listener?.stop(true)
|
|
156
|
+
}
|
|
116
157
|
}
|
|
117
158
|
|
|
118
159
|
private async fetch(request: Request): Promise<Response> {
|
|
@@ -120,27 +161,53 @@ export class CodingAgentRpcServer {
|
|
|
120
161
|
if (request.method === "OPTIONS") return new Response(null, { status: 204, headers: cors })
|
|
121
162
|
const url = new URL(request.url)
|
|
122
163
|
if (url.pathname === "/health" && request.method === "GET")
|
|
123
|
-
return
|
|
124
|
-
|
|
125
|
-
|
|
164
|
+
return this.response({ ok: true, protocol: 1 }, 200, cors)
|
|
165
|
+
const backoffMs = this.authBackoffMs()
|
|
166
|
+
if (backoffMs > 0) {
|
|
167
|
+
const throttledCors = new Headers(cors)
|
|
168
|
+
throttledCors.set("retry-after", String(Math.ceil(backoffMs / 1000)))
|
|
169
|
+
return this.response(
|
|
170
|
+
{ error: { code: "auth_throttled", message: "too many failed authorization attempts" } },
|
|
171
|
+
429,
|
|
172
|
+
throttledCors,
|
|
173
|
+
)
|
|
174
|
+
}
|
|
175
|
+
if (!authorized(request, this.token)) {
|
|
176
|
+
this.noteAuthFailure()
|
|
177
|
+
return this.response(
|
|
126
178
|
{ error: { code: "unauthorized", message: "agent RPC authorization required" } },
|
|
127
179
|
401,
|
|
128
180
|
cors,
|
|
129
181
|
)
|
|
182
|
+
}
|
|
183
|
+
this.noteAuthSuccess()
|
|
130
184
|
if (url.pathname !== "/rpc" || request.method !== "POST")
|
|
131
|
-
return
|
|
185
|
+
return this.response(
|
|
132
186
|
{ error: { code: "not_found", message: "unknown agent RPC endpoint" } },
|
|
133
187
|
404,
|
|
134
188
|
cors,
|
|
135
189
|
)
|
|
136
|
-
|
|
190
|
+
let body: Awaited<ReturnType<CodingAgentRpcServer["readRequest"]>>
|
|
191
|
+
try {
|
|
192
|
+
body = await this.readRequest(request)
|
|
193
|
+
} catch {
|
|
194
|
+
return this.response(
|
|
195
|
+
{ error: { code: "invalid_request", message: "request body could not be read" } },
|
|
196
|
+
400,
|
|
197
|
+
cors,
|
|
198
|
+
)
|
|
199
|
+
}
|
|
137
200
|
if (!body.ok)
|
|
138
|
-
return
|
|
201
|
+
return this.response(
|
|
202
|
+
{ error: { code: "invalid_request", message: body.error } },
|
|
203
|
+
body.status,
|
|
204
|
+
cors,
|
|
205
|
+
)
|
|
139
206
|
let rpc: RpcRequest
|
|
140
207
|
try {
|
|
141
208
|
rpc = parseRpcRequest(body.text)
|
|
142
209
|
} catch (error) {
|
|
143
|
-
return
|
|
210
|
+
return this.response(
|
|
144
211
|
{ error: this.protocolError("invalid_request", error, "request body is invalid") },
|
|
145
212
|
400,
|
|
146
213
|
cors,
|
|
@@ -150,7 +217,7 @@ export class CodingAgentRpcServer {
|
|
|
150
217
|
try {
|
|
151
218
|
return await this.dispatch(rpc, request, cors)
|
|
152
219
|
} catch (error) {
|
|
153
|
-
return
|
|
220
|
+
return this.response(
|
|
154
221
|
{ error: this.protocolError("rpc_failed", error, "request could not be completed") },
|
|
155
222
|
500,
|
|
156
223
|
cors,
|
|
@@ -164,15 +231,27 @@ export class CodingAgentRpcServer {
|
|
|
164
231
|
httpRequest: Request,
|
|
165
232
|
cors: Headers,
|
|
166
233
|
): Promise<Response> {
|
|
234
|
+
const json = (
|
|
235
|
+
value: unknown,
|
|
236
|
+
status: number,
|
|
237
|
+
inherited: Headers,
|
|
238
|
+
includeErrorStacks = false,
|
|
239
|
+
): Response => this.response(value, status, inherited, includeErrorStacks)
|
|
167
240
|
switch (request.method) {
|
|
168
241
|
case "session.create": {
|
|
169
242
|
const params = record(request.params)
|
|
243
|
+
if (params.sessionId !== undefined && !isValidSessionId(params.sessionId))
|
|
244
|
+
return json(
|
|
245
|
+
{ error: { code: "invalid_session", message: "sessionId is invalid" } },
|
|
246
|
+
422,
|
|
247
|
+
cors,
|
|
248
|
+
)
|
|
170
249
|
const snapshot =
|
|
171
250
|
this.host.snapshot ??
|
|
172
251
|
(await this.host.start({
|
|
173
252
|
cwd: this.options.cwd,
|
|
174
253
|
backend: this.options.backend.info.name,
|
|
175
|
-
...(
|
|
254
|
+
...(params.sessionId === undefined ? {} : { sessionId: params.sessionId }),
|
|
176
255
|
}))
|
|
177
256
|
return json(snapshot, 200, cors)
|
|
178
257
|
}
|
|
@@ -182,10 +261,7 @@ export class CodingAgentRpcServer {
|
|
|
182
261
|
}
|
|
183
262
|
case "session.resume": {
|
|
184
263
|
const params = record(request.params)
|
|
185
|
-
if (
|
|
186
|
-
typeof params.sessionId !== "string" ||
|
|
187
|
-
!/^[A-Za-z0-9._:-]{1,128}$/.test(params.sessionId)
|
|
188
|
-
)
|
|
264
|
+
if (!isValidSessionId(params.sessionId))
|
|
189
265
|
return json(
|
|
190
266
|
{ error: { code: "invalid_session", message: "sessionId is required" } },
|
|
191
267
|
422,
|
|
@@ -233,7 +309,7 @@ export class CodingAgentRpcServer {
|
|
|
233
309
|
case "session.fork": {
|
|
234
310
|
this.requireSnapshot()
|
|
235
311
|
const params = record(request.params)
|
|
236
|
-
if (params.targetSessionId !== undefined &&
|
|
312
|
+
if (params.targetSessionId !== undefined && !isValidSessionId(params.targetSessionId))
|
|
237
313
|
return json(
|
|
238
314
|
{ error: { code: "invalid_session", message: "targetSessionId must be a string" } },
|
|
239
315
|
422,
|
|
@@ -259,8 +335,11 @@ export class CodingAgentRpcServer {
|
|
|
259
335
|
cors,
|
|
260
336
|
)
|
|
261
337
|
const stream = this.host.prompt(params.message, httpRequest.signal)
|
|
338
|
+
const sessionId = this.host.snapshot?.id
|
|
262
339
|
const encoder = new TextEncoder()
|
|
263
340
|
const includeErrorStacks = this.options.exposeErrorStacks === true
|
|
341
|
+
const maxResponseBytes = this.maxResponseBytes
|
|
342
|
+
let finished = false
|
|
264
343
|
const turnError = (error: unknown) => ({
|
|
265
344
|
code: "turn_failed",
|
|
266
345
|
...publicErrorDetails(error, "turn could not be completed", includeErrorStacks),
|
|
@@ -268,18 +347,43 @@ export class CodingAgentRpcServer {
|
|
|
268
347
|
const body = new ReadableStream<Uint8Array>({
|
|
269
348
|
async start(controller) {
|
|
270
349
|
try {
|
|
271
|
-
for await (const event of stream)
|
|
350
|
+
for await (const event of stream) {
|
|
351
|
+
if (finished) return
|
|
352
|
+
const serialized = serializeRpcValue(event, includeErrorStacks)
|
|
353
|
+
if (serializedByteLength(`data: ${serialized}\n\n`) > maxResponseBytes)
|
|
354
|
+
throw new Error("turn event exceeded the configured response limit")
|
|
355
|
+
controller.enqueue(encoder.encode(`data: ${serialized}\n\n`))
|
|
356
|
+
}
|
|
357
|
+
if (!finished) {
|
|
358
|
+
finished = true
|
|
359
|
+
controller.close()
|
|
360
|
+
}
|
|
361
|
+
} catch (error) {
|
|
362
|
+
if (finished) return
|
|
363
|
+
finished = true
|
|
364
|
+
try {
|
|
365
|
+
const detail = JSON.stringify(turnError(error))
|
|
366
|
+
const frame = `event: error\ndata: ${detail}\n\n`
|
|
272
367
|
controller.enqueue(
|
|
273
|
-
encoder.encode(
|
|
368
|
+
encoder.encode(
|
|
369
|
+
serializedByteLength(frame) <= maxResponseBytes
|
|
370
|
+
? frame
|
|
371
|
+
: 'event: error\ndata: {"code":"turn_failed","message":"turn could not be completed"}\n\n',
|
|
372
|
+
),
|
|
274
373
|
)
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
controller.close()
|
|
374
|
+
controller.close()
|
|
375
|
+
} catch {
|
|
376
|
+
// A disconnected client has already cancelled the stream; there is no response
|
|
377
|
+
// channel left on which to report the producer error.
|
|
378
|
+
}
|
|
281
379
|
}
|
|
282
380
|
},
|
|
381
|
+
cancel: async () => {
|
|
382
|
+
if (finished) return
|
|
383
|
+
finished = true
|
|
384
|
+
if (sessionId !== undefined)
|
|
385
|
+
await this.host.backend.cancel(sessionId, "client disconnected").catch(() => {})
|
|
386
|
+
},
|
|
283
387
|
})
|
|
284
388
|
return new Response(body, {
|
|
285
389
|
status: 200,
|
|
@@ -465,6 +569,38 @@ export class CodingAgentRpcServer {
|
|
|
465
569
|
this.options.exposeErrorStacks === true,
|
|
466
570
|
)
|
|
467
571
|
}
|
|
572
|
+
case "review.run": {
|
|
573
|
+
const params = record(request.params)
|
|
574
|
+
const allowed = new Set(["strict", "diff"])
|
|
575
|
+
if (Object.keys(params).some((key) => !allowed.has(key)))
|
|
576
|
+
return json(
|
|
577
|
+
{ error: { code: "invalid_review", message: "review accepts only strict and diff" } },
|
|
578
|
+
422,
|
|
579
|
+
cors,
|
|
580
|
+
)
|
|
581
|
+
if (params.strict !== undefined && typeof params.strict !== "boolean")
|
|
582
|
+
return json(
|
|
583
|
+
{ error: { code: "invalid_review", message: "strict must be a boolean" } },
|
|
584
|
+
422,
|
|
585
|
+
cors,
|
|
586
|
+
)
|
|
587
|
+
if (params.diff !== undefined && !isSafeReviewDiff(params.diff))
|
|
588
|
+
return json(
|
|
589
|
+
{ error: { code: "invalid_review", message: "diff must be a safe Git ref" } },
|
|
590
|
+
422,
|
|
591
|
+
cors,
|
|
592
|
+
)
|
|
593
|
+
return json(
|
|
594
|
+
await runNifraReview({
|
|
595
|
+
cwd: this.options.cwd,
|
|
596
|
+
...(this.options.verification === undefined ? {} : this.options.verification),
|
|
597
|
+
...(params.strict === undefined ? {} : { strict: params.strict }),
|
|
598
|
+
...(params.diff === undefined ? {} : { diff: params.diff }),
|
|
599
|
+
}),
|
|
600
|
+
200,
|
|
601
|
+
cors,
|
|
602
|
+
)
|
|
603
|
+
}
|
|
468
604
|
case "project.diff": {
|
|
469
605
|
this.requireSnapshot()
|
|
470
606
|
return json(
|
|
@@ -495,6 +631,43 @@ export class CodingAgentRpcServer {
|
|
|
495
631
|
return this.host.snapshot
|
|
496
632
|
}
|
|
497
633
|
|
|
634
|
+
private authBackoffMs(): number {
|
|
635
|
+
const now = Date.now()
|
|
636
|
+
if (this.authFailures === 0) return 0
|
|
637
|
+
if (now - this.authLastFailureAt > AUTH_FAILURE_WINDOW_MS) {
|
|
638
|
+
this.noteAuthSuccess()
|
|
639
|
+
return 0
|
|
640
|
+
}
|
|
641
|
+
return Math.max(0, this.authBackoffUntil - now)
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
private noteAuthFailure(): void {
|
|
645
|
+
const now = Date.now()
|
|
646
|
+
if (now - this.authLastFailureAt > AUTH_FAILURE_WINDOW_MS) this.authFailures = 0
|
|
647
|
+
this.authLastFailureAt = now
|
|
648
|
+
this.authFailures = Math.min(this.authFailures + 1, 31)
|
|
649
|
+
if (this.authFailures >= AUTH_FAILURE_THRESHOLD) {
|
|
650
|
+
const exponent = this.authFailures - AUTH_FAILURE_THRESHOLD
|
|
651
|
+
const delay = Math.min(AUTH_MAX_BACKOFF_MS, 250 * 2 ** exponent)
|
|
652
|
+
this.authBackoffUntil = now + delay
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
private noteAuthSuccess(): void {
|
|
657
|
+
this.authFailures = 0
|
|
658
|
+
this.authLastFailureAt = 0
|
|
659
|
+
this.authBackoffUntil = 0
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
private response(
|
|
663
|
+
value: unknown,
|
|
664
|
+
status: number,
|
|
665
|
+
inherited: Headers,
|
|
666
|
+
includeErrorStacks = false,
|
|
667
|
+
): Response {
|
|
668
|
+
return json(value, status, inherited, includeErrorStacks, this.maxResponseBytes)
|
|
669
|
+
}
|
|
670
|
+
|
|
498
671
|
private protocolError(
|
|
499
672
|
code: string,
|
|
500
673
|
error: unknown,
|
|
@@ -509,15 +682,34 @@ export class CodingAgentRpcServer {
|
|
|
509
682
|
| { readonly ok: true; readonly text: string }
|
|
510
683
|
| { readonly ok: false; readonly status: 400 | 413; readonly error: string }
|
|
511
684
|
> {
|
|
512
|
-
const
|
|
513
|
-
if (
|
|
514
|
-
|
|
685
|
+
const declared = request.headers.get("content-length")
|
|
686
|
+
if (declared !== null) {
|
|
687
|
+
const length = decimalContentLength(declared)
|
|
688
|
+
if (length === undefined)
|
|
689
|
+
return { ok: false, status: 400, error: "content-length is invalid" }
|
|
690
|
+
if (length > this.maxBodyBytes)
|
|
691
|
+
return { ok: false, status: 413, error: "request body is too large" }
|
|
692
|
+
}
|
|
515
693
|
const result = await readBoundedText(request.body, this.maxBodyBytes)
|
|
516
694
|
if (result.truncated) return { ok: false, status: 413, error: "request body is too large" }
|
|
517
695
|
return { ok: true, text: result.text }
|
|
518
696
|
}
|
|
519
697
|
}
|
|
520
698
|
|
|
699
|
+
function decimalContentLength(value: string): number | undefined {
|
|
700
|
+
if (!/^\d+$/.test(value)) return undefined
|
|
701
|
+
let length = 0
|
|
702
|
+
for (const character of value) {
|
|
703
|
+
length = length * 10 + (character.charCodeAt(0) - 48)
|
|
704
|
+
if (!Number.isSafeInteger(length)) return Number.POSITIVE_INFINITY
|
|
705
|
+
}
|
|
706
|
+
return length
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
function isValidSessionId(value: unknown): value is string {
|
|
710
|
+
return typeof value === "string" && SESSION_ID_PATTERN.test(value)
|
|
711
|
+
}
|
|
712
|
+
|
|
521
713
|
function parseRpcRequest(text: string): RpcRequest {
|
|
522
714
|
let value: unknown
|
|
523
715
|
try {
|
|
@@ -545,6 +737,13 @@ function authorized(request: Request, token: string): boolean {
|
|
|
545
737
|
return constantTimeEqual(header.slice(7), token)
|
|
546
738
|
}
|
|
547
739
|
|
|
740
|
+
function randomAuthToken(): string {
|
|
741
|
+
const bytes = crypto.getRandomValues(new Uint8Array(MIN_REMOTE_TOKEN_BYTES))
|
|
742
|
+
let binary = ""
|
|
743
|
+
for (const byte of bytes) binary += String.fromCharCode(byte)
|
|
744
|
+
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "")
|
|
745
|
+
}
|
|
746
|
+
|
|
548
747
|
function constantTimeEqual(left: string, right: string): boolean {
|
|
549
748
|
const a = new TextEncoder().encode(left)
|
|
550
749
|
const b = new TextEncoder().encode(right)
|
|
@@ -580,19 +779,48 @@ function json(
|
|
|
580
779
|
status: number,
|
|
581
780
|
inherited: Headers,
|
|
582
781
|
includeErrorStacks = false,
|
|
782
|
+
maxBytes = DEFAULT_MAX_RPC_RESPONSE_BYTES,
|
|
583
783
|
): Response {
|
|
584
784
|
const headers = new Headers(inherited)
|
|
585
785
|
headers.set("content-type", "application/json; charset=utf-8")
|
|
586
786
|
// lgtm [js/stack-trace-exposure] the default replacer removes stack fields from protocol data;
|
|
587
787
|
// stacks are serialized only in explicitly opt-in, loopback-only diagnostics mode.
|
|
588
|
-
|
|
788
|
+
let body: string
|
|
789
|
+
let responseStatus = status
|
|
790
|
+
try {
|
|
791
|
+
body = serializeRpcValue(value, includeErrorStacks)
|
|
792
|
+
} catch {
|
|
793
|
+
body = JSON.stringify({
|
|
794
|
+
error: {
|
|
795
|
+
code: "response_serialization_failed",
|
|
796
|
+
message: "RPC response could not be serialized",
|
|
797
|
+
},
|
|
798
|
+
})
|
|
799
|
+
responseStatus = 500
|
|
800
|
+
}
|
|
801
|
+
if (serializedByteLength(body) > maxBytes) {
|
|
802
|
+
body = JSON.stringify({
|
|
803
|
+
error: {
|
|
804
|
+
code: "response_too_large",
|
|
805
|
+
message: "RPC response exceeded the configured size limit",
|
|
806
|
+
},
|
|
807
|
+
})
|
|
808
|
+
responseStatus = 500
|
|
809
|
+
}
|
|
810
|
+
return new Response(body, { status: responseStatus, headers })
|
|
589
811
|
}
|
|
590
812
|
|
|
591
813
|
function serializeRpcValue(value: unknown, includeErrorStacks: boolean): string {
|
|
592
|
-
|
|
814
|
+
const serialized = JSON.stringify(
|
|
593
815
|
value,
|
|
594
816
|
includeErrorStacks
|
|
595
817
|
? undefined
|
|
596
818
|
: (key: string, nested: unknown) => (key === "stack" ? undefined : nested),
|
|
597
819
|
)
|
|
820
|
+
if (serialized === undefined) throw new TypeError("RPC value is not JSON serializable")
|
|
821
|
+
return serialized
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
function serializedByteLength(value: string): number {
|
|
825
|
+
return new TextEncoder().encode(value).byteLength
|
|
598
826
|
}
|