@nifrajs/coding-agent 3.2.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.
Files changed (49) hide show
  1. package/dist/context.js +29 -1
  2. package/dist/context.js.map +1 -1
  3. package/dist/diff.js +38 -2
  4. package/dist/diff.js.map +1 -1
  5. package/dist/host.d.ts.map +1 -1
  6. package/dist/host.js +217 -24
  7. package/dist/host.js.map +1 -1
  8. package/dist/index.d.ts +1 -1
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +1 -1
  11. package/dist/index.js.map +1 -1
  12. package/dist/isolated.js +37 -2
  13. package/dist/isolated.js.map +1 -1
  14. package/dist/native.d.ts +17 -0
  15. package/dist/native.d.ts.map +1 -1
  16. package/dist/native.js +265 -41
  17. package/dist/native.js.map +1 -1
  18. package/dist/replay.d.ts +4 -0
  19. package/dist/replay.d.ts.map +1 -1
  20. package/dist/replay.js +189 -24
  21. package/dist/replay.js.map +1 -1
  22. package/dist/rpc.d.ts +10 -0
  23. package/dist/rpc.d.ts.map +1 -1
  24. package/dist/rpc.js +208 -29
  25. package/dist/rpc.js.map +1 -1
  26. package/dist/sessions.d.ts +14 -2
  27. package/dist/sessions.d.ts.map +1 -1
  28. package/dist/sessions.js +253 -59
  29. package/dist/sessions.js.map +1 -1
  30. package/dist/verification.d.ts +23 -0
  31. package/dist/verification.d.ts.map +1 -1
  32. package/dist/verification.js +113 -1
  33. package/dist/verification.js.map +1 -1
  34. package/dist/workflows.d.ts +1 -0
  35. package/dist/workflows.d.ts.map +1 -1
  36. package/dist/workflows.js +43 -21
  37. package/dist/workflows.js.map +1 -1
  38. package/package.json +6 -5
  39. package/src/context.ts +32 -1
  40. package/src/diff.ts +40 -2
  41. package/src/host.ts +219 -24
  42. package/src/index.ts +5 -0
  43. package/src/isolated.ts +39 -2
  44. package/src/native.ts +408 -42
  45. package/src/replay.ts +206 -25
  46. package/src/rpc.ts +261 -33
  47. package/src/sessions.ts +271 -56
  48. package/src/verification.ts +140 -1
  49. 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 ?? 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,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 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
+ 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 json(
185
+ return this.response(
132
186
  { error: { code: "not_found", message: "unknown agent RPC endpoint" } },
133
187
  404,
134
188
  cors,
135
189
  )
136
- const body = await this.readRequest(request)
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 json({ error: { code: "invalid_request", message: body.error } }, body.status, cors)
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 json(
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 json(
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
- ...(typeof params.sessionId === "string" ? { sessionId: params.sessionId } : {}),
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 && typeof params.targetSessionId !== "string")
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(`data: ${serializeRpcValue(event, includeErrorStacks)}\n\n`),
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
- controller.close()
276
- } catch (error) {
277
- controller.enqueue(
278
- encoder.encode(`event: error\ndata: ${JSON.stringify(turnError(error))}\n\n`),
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 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" }
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
- return new Response(serializeRpcValue(value, includeErrorStacks), { status, headers })
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
- return JSON.stringify(
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
  }