@nifrajs/coding-agent 3.3.0 → 4.0.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.
Files changed (88) hide show
  1. package/README.md +11 -4
  2. package/SECURITY.md +2 -1
  3. package/dist/approvals.d.ts +14 -2
  4. package/dist/approvals.d.ts.map +1 -1
  5. package/dist/approvals.js +51 -43
  6. package/dist/approvals.js.map +1 -1
  7. package/dist/cli.d.ts +1 -0
  8. package/dist/cli.d.ts.map +1 -1
  9. package/dist/cli.js +11 -2
  10. package/dist/cli.js.map +1 -1
  11. package/dist/context.js +29 -1
  12. package/dist/context.js.map +1 -1
  13. package/dist/diff.js +38 -2
  14. package/dist/diff.js.map +1 -1
  15. package/dist/handoffs.d.ts.map +1 -1
  16. package/dist/handoffs.js +12 -4
  17. package/dist/handoffs.js.map +1 -1
  18. package/dist/host.d.ts.map +1 -1
  19. package/dist/host.js +146 -23
  20. package/dist/host.js.map +1 -1
  21. package/dist/index.d.ts +2 -2
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +1 -1
  24. package/dist/index.js.map +1 -1
  25. package/dist/isolated.js +37 -2
  26. package/dist/isolated.js.map +1 -1
  27. package/dist/native.d.ts +12 -0
  28. package/dist/native.d.ts.map +1 -1
  29. package/dist/native.js +165 -40
  30. package/dist/native.js.map +1 -1
  31. package/dist/orchestration/compile.d.ts +7 -0
  32. package/dist/orchestration/compile.d.ts.map +1 -1
  33. package/dist/orchestration/compile.js +16 -2
  34. package/dist/orchestration/compile.js.map +1 -1
  35. package/dist/orchestration/evidence-store.d.ts.map +1 -1
  36. package/dist/orchestration/evidence-store.js +9 -4
  37. package/dist/orchestration/evidence-store.js.map +1 -1
  38. package/dist/orchestration/host.d.ts +10 -0
  39. package/dist/orchestration/host.d.ts.map +1 -1
  40. package/dist/orchestration/host.js +9 -0
  41. package/dist/orchestration/host.js.map +1 -1
  42. package/dist/replay.d.ts +4 -0
  43. package/dist/replay.d.ts.map +1 -1
  44. package/dist/replay.js +189 -24
  45. package/dist/replay.js.map +1 -1
  46. package/dist/rpc.d.ts +10 -0
  47. package/dist/rpc.d.ts.map +1 -1
  48. package/dist/rpc.js +209 -29
  49. package/dist/rpc.js.map +1 -1
  50. package/dist/session-migration.d.ts.map +1 -1
  51. package/dist/session-migration.js +3 -2
  52. package/dist/session-migration.js.map +1 -1
  53. package/dist/sessions.d.ts +20 -2
  54. package/dist/sessions.d.ts.map +1 -1
  55. package/dist/sessions.js +269 -60
  56. package/dist/sessions.js.map +1 -1
  57. package/dist/subagents.d.ts +30 -0
  58. package/dist/subagents.d.ts.map +1 -1
  59. package/dist/subagents.js +118 -15
  60. package/dist/subagents.js.map +1 -1
  61. package/dist/verification.d.ts +28 -1
  62. package/dist/verification.d.ts.map +1 -1
  63. package/dist/verification.js +119 -3
  64. package/dist/verification.js.map +1 -1
  65. package/dist/workflows.d.ts +1 -0
  66. package/dist/workflows.d.ts.map +1 -1
  67. package/dist/workflows.js +55 -23
  68. package/dist/workflows.js.map +1 -1
  69. package/package.json +9 -5
  70. package/src/approvals.ts +62 -45
  71. package/src/cli.ts +11 -2
  72. package/src/context.ts +32 -1
  73. package/src/diff.ts +40 -2
  74. package/src/handoffs.ts +20 -8
  75. package/src/host.ts +146 -23
  76. package/src/index.ts +7 -0
  77. package/src/isolated.ts +39 -2
  78. package/src/native.ts +213 -39
  79. package/src/orchestration/compile.ts +28 -2
  80. package/src/orchestration/evidence-store.ts +9 -3
  81. package/src/orchestration/host.ts +17 -0
  82. package/src/replay.ts +206 -25
  83. package/src/rpc.ts +262 -33
  84. package/src/session-migration.ts +3 -2
  85. package/src/sessions.ts +288 -57
  86. package/src/subagents.ts +147 -17
  87. package/src/verification.ts +146 -3
  88. package/src/workflows.ts +47 -27
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 ?? crypto.randomUUID().replaceAll("-", "")
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 (!Number.isSafeInteger(this.maxBodyBytes) || this.maxBodyBytes < 1024)
86
- throw new RangeError("agent rpc: maxBodyBytes must be at least 1024")
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
- await this.host.stop("rpc server stopped")
115
- listener?.stop(true)
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,54 @@ 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 json({ ok: true, protocol: 1 }, 200, cors)
124
- if (!authorized(request, this.token))
125
- return json(
164
+ return this.response({ ok: true, protocol: 1 }, 200, cors)
165
+ if (!authorized(request, this.token)) {
166
+ // Only failed attempts are throttled. A caller holding the token is served during a backoff,
167
+ // or anyone who can reach the port could keep the real client locked out with wrong guesses.
168
+ const backoffMs = this.authBackoffMs()
169
+ if (backoffMs > 0) {
170
+ const throttledCors = new Headers(cors)
171
+ throttledCors.set("retry-after", String(Math.ceil(backoffMs / 1000)))
172
+ return this.response(
173
+ { error: { code: "auth_throttled", message: "too many failed authorization attempts" } },
174
+ 429,
175
+ throttledCors,
176
+ )
177
+ }
178
+ this.noteAuthFailure()
179
+ return this.response(
126
180
  { error: { code: "unauthorized", message: "agent RPC authorization required" } },
127
181
  401,
128
182
  cors,
129
183
  )
184
+ }
130
185
  if (url.pathname !== "/rpc" || request.method !== "POST")
131
- return json(
186
+ return this.response(
132
187
  { error: { code: "not_found", message: "unknown agent RPC endpoint" } },
133
188
  404,
134
189
  cors,
135
190
  )
136
- const body = await this.readRequest(request)
191
+ let body: Awaited<ReturnType<CodingAgentRpcServer["readRequest"]>>
192
+ try {
193
+ body = await this.readRequest(request)
194
+ } catch {
195
+ return this.response(
196
+ { error: { code: "invalid_request", message: "request body could not be read" } },
197
+ 400,
198
+ cors,
199
+ )
200
+ }
137
201
  if (!body.ok)
138
- return json({ error: { code: "invalid_request", message: body.error } }, body.status, cors)
202
+ return this.response(
203
+ { error: { code: "invalid_request", message: body.error } },
204
+ body.status,
205
+ cors,
206
+ )
139
207
  let rpc: RpcRequest
140
208
  try {
141
209
  rpc = parseRpcRequest(body.text)
142
210
  } catch (error) {
143
- return json(
211
+ return this.response(
144
212
  { error: this.protocolError("invalid_request", error, "request body is invalid") },
145
213
  400,
146
214
  cors,
@@ -150,7 +218,7 @@ export class CodingAgentRpcServer {
150
218
  try {
151
219
  return await this.dispatch(rpc, request, cors)
152
220
  } catch (error) {
153
- return json(
221
+ return this.response(
154
222
  { error: this.protocolError("rpc_failed", error, "request could not be completed") },
155
223
  500,
156
224
  cors,
@@ -164,15 +232,27 @@ export class CodingAgentRpcServer {
164
232
  httpRequest: Request,
165
233
  cors: Headers,
166
234
  ): Promise<Response> {
235
+ const json = (
236
+ value: unknown,
237
+ status: number,
238
+ inherited: Headers,
239
+ includeErrorStacks = false,
240
+ ): Response => this.response(value, status, inherited, includeErrorStacks)
167
241
  switch (request.method) {
168
242
  case "session.create": {
169
243
  const params = record(request.params)
244
+ if (params.sessionId !== undefined && !isValidSessionId(params.sessionId))
245
+ return json(
246
+ { error: { code: "invalid_session", message: "sessionId is invalid" } },
247
+ 422,
248
+ cors,
249
+ )
170
250
  const snapshot =
171
251
  this.host.snapshot ??
172
252
  (await this.host.start({
173
253
  cwd: this.options.cwd,
174
254
  backend: this.options.backend.info.name,
175
- ...(typeof params.sessionId === "string" ? { sessionId: params.sessionId } : {}),
255
+ ...(params.sessionId === undefined ? {} : { sessionId: params.sessionId }),
176
256
  }))
177
257
  return json(snapshot, 200, cors)
178
258
  }
@@ -182,10 +262,7 @@ export class CodingAgentRpcServer {
182
262
  }
183
263
  case "session.resume": {
184
264
  const params = record(request.params)
185
- if (
186
- typeof params.sessionId !== "string" ||
187
- !/^[A-Za-z0-9._:-]{1,128}$/.test(params.sessionId)
188
- )
265
+ if (!isValidSessionId(params.sessionId))
189
266
  return json(
190
267
  { error: { code: "invalid_session", message: "sessionId is required" } },
191
268
  422,
@@ -233,7 +310,7 @@ export class CodingAgentRpcServer {
233
310
  case "session.fork": {
234
311
  this.requireSnapshot()
235
312
  const params = record(request.params)
236
- if (params.targetSessionId !== undefined && typeof params.targetSessionId !== "string")
313
+ if (params.targetSessionId !== undefined && !isValidSessionId(params.targetSessionId))
237
314
  return json(
238
315
  { error: { code: "invalid_session", message: "targetSessionId must be a string" } },
239
316
  422,
@@ -259,8 +336,11 @@ export class CodingAgentRpcServer {
259
336
  cors,
260
337
  )
261
338
  const stream = this.host.prompt(params.message, httpRequest.signal)
339
+ const sessionId = this.host.snapshot?.id
262
340
  const encoder = new TextEncoder()
263
341
  const includeErrorStacks = this.options.exposeErrorStacks === true
342
+ const maxResponseBytes = this.maxResponseBytes
343
+ let finished = false
264
344
  const turnError = (error: unknown) => ({
265
345
  code: "turn_failed",
266
346
  ...publicErrorDetails(error, "turn could not be completed", includeErrorStacks),
@@ -268,18 +348,43 @@ export class CodingAgentRpcServer {
268
348
  const body = new ReadableStream<Uint8Array>({
269
349
  async start(controller) {
270
350
  try {
271
- for await (const event of stream)
351
+ for await (const event of stream) {
352
+ if (finished) return
353
+ const serialized = serializeRpcValue(event, includeErrorStacks)
354
+ if (serializedByteLength(`data: ${serialized}\n\n`) > maxResponseBytes)
355
+ throw new Error("turn event exceeded the configured response limit")
356
+ controller.enqueue(encoder.encode(`data: ${serialized}\n\n`))
357
+ }
358
+ if (!finished) {
359
+ finished = true
360
+ controller.close()
361
+ }
362
+ } catch (error) {
363
+ if (finished) return
364
+ finished = true
365
+ try {
366
+ const detail = JSON.stringify(turnError(error))
367
+ const frame = `event: error\ndata: ${detail}\n\n`
272
368
  controller.enqueue(
273
- encoder.encode(`data: ${serializeRpcValue(event, includeErrorStacks)}\n\n`),
369
+ encoder.encode(
370
+ serializedByteLength(frame) <= maxResponseBytes
371
+ ? frame
372
+ : 'event: error\ndata: {"code":"turn_failed","message":"turn could not be completed"}\n\n',
373
+ ),
274
374
  )
275
- controller.close()
276
- } catch (error) {
277
- controller.enqueue(
278
- encoder.encode(`event: error\ndata: ${JSON.stringify(turnError(error))}\n\n`),
279
- )
280
- controller.close()
375
+ controller.close()
376
+ } catch {
377
+ // A disconnected client has already cancelled the stream; there is no response
378
+ // channel left on which to report the producer error.
379
+ }
281
380
  }
282
381
  },
382
+ cancel: async () => {
383
+ if (finished) return
384
+ finished = true
385
+ if (sessionId !== undefined)
386
+ await this.host.backend.cancel(sessionId, "client disconnected").catch(() => {})
387
+ },
283
388
  })
284
389
  return new Response(body, {
285
390
  status: 200,
@@ -465,6 +570,38 @@ export class CodingAgentRpcServer {
465
570
  this.options.exposeErrorStacks === true,
466
571
  )
467
572
  }
573
+ case "review.run": {
574
+ const params = record(request.params)
575
+ const allowed = new Set(["strict", "diff"])
576
+ if (Object.keys(params).some((key) => !allowed.has(key)))
577
+ return json(
578
+ { error: { code: "invalid_review", message: "review accepts only strict and diff" } },
579
+ 422,
580
+ cors,
581
+ )
582
+ if (params.strict !== undefined && typeof params.strict !== "boolean")
583
+ return json(
584
+ { error: { code: "invalid_review", message: "strict must be a boolean" } },
585
+ 422,
586
+ cors,
587
+ )
588
+ if (params.diff !== undefined && !isSafeReviewDiff(params.diff))
589
+ return json(
590
+ { error: { code: "invalid_review", message: "diff must be a safe Git ref" } },
591
+ 422,
592
+ cors,
593
+ )
594
+ return json(
595
+ await runNifraReview({
596
+ cwd: this.options.cwd,
597
+ ...(this.options.verification === undefined ? {} : this.options.verification),
598
+ ...(params.strict === undefined ? {} : { strict: params.strict }),
599
+ ...(params.diff === undefined ? {} : { diff: params.diff }),
600
+ }),
601
+ 200,
602
+ cors,
603
+ )
604
+ }
468
605
  case "project.diff": {
469
606
  this.requireSnapshot()
470
607
  return json(
@@ -495,6 +632,43 @@ export class CodingAgentRpcServer {
495
632
  return this.host.snapshot
496
633
  }
497
634
 
635
+ private authBackoffMs(): number {
636
+ const now = Date.now()
637
+ if (this.authFailures === 0) return 0
638
+ if (now - this.authLastFailureAt > AUTH_FAILURE_WINDOW_MS) {
639
+ this.resetAuthFailures()
640
+ return 0
641
+ }
642
+ return Math.max(0, this.authBackoffUntil - now)
643
+ }
644
+
645
+ private noteAuthFailure(): void {
646
+ const now = Date.now()
647
+ if (now - this.authLastFailureAt > AUTH_FAILURE_WINDOW_MS) this.authFailures = 0
648
+ this.authLastFailureAt = now
649
+ this.authFailures = Math.min(this.authFailures + 1, 31)
650
+ if (this.authFailures >= AUTH_FAILURE_THRESHOLD) {
651
+ const exponent = this.authFailures - AUTH_FAILURE_THRESHOLD
652
+ const delay = Math.min(AUTH_MAX_BACKOFF_MS, 250 * 2 ** exponent)
653
+ this.authBackoffUntil = now + delay
654
+ }
655
+ }
656
+
657
+ private resetAuthFailures(): void {
658
+ this.authFailures = 0
659
+ this.authLastFailureAt = 0
660
+ this.authBackoffUntil = 0
661
+ }
662
+
663
+ private response(
664
+ value: unknown,
665
+ status: number,
666
+ inherited: Headers,
667
+ includeErrorStacks = false,
668
+ ): Response {
669
+ return json(value, status, inherited, includeErrorStacks, this.maxResponseBytes)
670
+ }
671
+
498
672
  private protocolError(
499
673
  code: string,
500
674
  error: unknown,
@@ -509,15 +683,34 @@ export class CodingAgentRpcServer {
509
683
  | { readonly ok: true; readonly text: string }
510
684
  | { readonly ok: false; readonly status: 400 | 413; readonly error: string }
511
685
  > {
512
- const length = Number(request.headers.get("content-length") ?? "0")
513
- if (Number.isFinite(length) && length > this.maxBodyBytes)
514
- return { ok: false, status: 413, error: "request body is too large" }
686
+ const declared = request.headers.get("content-length")
687
+ if (declared !== null) {
688
+ const length = decimalContentLength(declared)
689
+ if (length === undefined)
690
+ return { ok: false, status: 400, error: "content-length is invalid" }
691
+ if (length > this.maxBodyBytes)
692
+ return { ok: false, status: 413, error: "request body is too large" }
693
+ }
515
694
  const result = await readBoundedText(request.body, this.maxBodyBytes)
516
695
  if (result.truncated) return { ok: false, status: 413, error: "request body is too large" }
517
696
  return { ok: true, text: result.text }
518
697
  }
519
698
  }
520
699
 
700
+ function decimalContentLength(value: string): number | undefined {
701
+ if (!/^\d+$/.test(value)) return undefined
702
+ let length = 0
703
+ for (const character of value) {
704
+ length = length * 10 + (character.charCodeAt(0) - 48)
705
+ if (!Number.isSafeInteger(length)) return Number.POSITIVE_INFINITY
706
+ }
707
+ return length
708
+ }
709
+
710
+ function isValidSessionId(value: unknown): value is string {
711
+ return typeof value === "string" && SESSION_ID_PATTERN.test(value)
712
+ }
713
+
521
714
  function parseRpcRequest(text: string): RpcRequest {
522
715
  let value: unknown
523
716
  try {
@@ -545,6 +738,13 @@ function authorized(request: Request, token: string): boolean {
545
738
  return constantTimeEqual(header.slice(7), token)
546
739
  }
547
740
 
741
+ function randomAuthToken(): string {
742
+ const bytes = crypto.getRandomValues(new Uint8Array(MIN_REMOTE_TOKEN_BYTES))
743
+ let binary = ""
744
+ for (const byte of bytes) binary += String.fromCharCode(byte)
745
+ return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "")
746
+ }
747
+
548
748
  function constantTimeEqual(left: string, right: string): boolean {
549
749
  const a = new TextEncoder().encode(left)
550
750
  const b = new TextEncoder().encode(right)
@@ -580,19 +780,48 @@ function json(
580
780
  status: number,
581
781
  inherited: Headers,
582
782
  includeErrorStacks = false,
783
+ maxBytes = DEFAULT_MAX_RPC_RESPONSE_BYTES,
583
784
  ): Response {
584
785
  const headers = new Headers(inherited)
585
786
  headers.set("content-type", "application/json; charset=utf-8")
586
787
  // lgtm [js/stack-trace-exposure] the default replacer removes stack fields from protocol data;
587
788
  // stacks are serialized only in explicitly opt-in, loopback-only diagnostics mode.
588
- return new Response(serializeRpcValue(value, includeErrorStacks), { status, headers })
789
+ let body: string
790
+ let responseStatus = status
791
+ try {
792
+ body = serializeRpcValue(value, includeErrorStacks)
793
+ } catch {
794
+ body = JSON.stringify({
795
+ error: {
796
+ code: "response_serialization_failed",
797
+ message: "RPC response could not be serialized",
798
+ },
799
+ })
800
+ responseStatus = 500
801
+ }
802
+ if (serializedByteLength(body) > maxBytes) {
803
+ body = JSON.stringify({
804
+ error: {
805
+ code: "response_too_large",
806
+ message: "RPC response exceeded the configured size limit",
807
+ },
808
+ })
809
+ responseStatus = 500
810
+ }
811
+ return new Response(body, { status: responseStatus, headers })
589
812
  }
590
813
 
591
814
  function serializeRpcValue(value: unknown, includeErrorStacks: boolean): string {
592
- return JSON.stringify(
815
+ const serialized = JSON.stringify(
593
816
  value,
594
817
  includeErrorStacks
595
818
  ? undefined
596
819
  : (key: string, nested: unknown) => (key === "stack" ? undefined : nested),
597
820
  )
821
+ if (serialized === undefined) throw new TypeError("RPC value is not JSON serializable")
822
+ return serialized
823
+ }
824
+
825
+ function serializedByteLength(value: string): number {
826
+ return new TextEncoder().encode(value).byteLength
598
827
  }
@@ -1,5 +1,6 @@
1
1
  import { access, mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises"
2
2
  import { basename, join, resolve } from "node:path"
3
+ import { sessionFileStem } from "./sessions.ts"
3
4
 
4
5
  /** Version of the evidence-only legacy session file format. */
5
6
  export const SESSION_EVIDENCE_VERSION = 1 as const
@@ -160,7 +161,7 @@ export async function migrateLegacySession(
160
161
  const lines = records.map((record) => JSON.stringify(record))
161
162
  const content = lines.length === 0 ? "" : `${lines.join("\n")}\n`
162
163
  const digest = await sha256Hex(content)
163
- const targetFile = join(targetRoot, `${sessionId}.jsonl`)
164
+ const targetFile = join(targetRoot, `${sessionFileStem(sessionId)}.jsonl`)
164
165
  await commitTarget(targetRoot, targetFile, content, options.signal)
165
166
  // Re-read the committed target and validate every line before the caller treats this as active.
166
167
  const committed = await readEvidenceTarget(targetFile, sessionId)
@@ -221,7 +222,7 @@ async function readLegacyEntries(
221
222
  sessionId: string,
222
223
  maxSourceBytes: number,
223
224
  ): Promise<readonly LegacyEntry[]> {
224
- const path = join(sourceRoot, `${sessionId}.jsonl`)
225
+ const path = join(sourceRoot, `${sessionFileStem(sessionId)}.jsonl`)
225
226
  let text: string
226
227
  try {
227
228
  const bytes = await readFile(path)