@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.
- package/README.md +11 -4
- package/SECURITY.md +2 -1
- package/dist/approvals.d.ts +14 -2
- package/dist/approvals.d.ts.map +1 -1
- package/dist/approvals.js +51 -43
- package/dist/approvals.js.map +1 -1
- package/dist/cli.d.ts +1 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +11 -2
- package/dist/cli.js.map +1 -1
- 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/handoffs.d.ts.map +1 -1
- package/dist/handoffs.js +12 -4
- package/dist/handoffs.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 +2 -2
- 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 +12 -0
- package/dist/native.d.ts.map +1 -1
- package/dist/native.js +165 -40
- package/dist/native.js.map +1 -1
- package/dist/orchestration/compile.d.ts +7 -0
- package/dist/orchestration/compile.d.ts.map +1 -1
- package/dist/orchestration/compile.js +16 -2
- package/dist/orchestration/compile.js.map +1 -1
- package/dist/orchestration/evidence-store.d.ts.map +1 -1
- package/dist/orchestration/evidence-store.js +9 -4
- package/dist/orchestration/evidence-store.js.map +1 -1
- package/dist/orchestration/host.d.ts +10 -0
- package/dist/orchestration/host.d.ts.map +1 -1
- package/dist/orchestration/host.js +9 -0
- package/dist/orchestration/host.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 +209 -29
- package/dist/rpc.js.map +1 -1
- package/dist/session-migration.d.ts.map +1 -1
- package/dist/session-migration.js +3 -2
- package/dist/session-migration.js.map +1 -1
- package/dist/sessions.d.ts +20 -2
- package/dist/sessions.d.ts.map +1 -1
- package/dist/sessions.js +269 -60
- package/dist/sessions.js.map +1 -1
- package/dist/subagents.d.ts +30 -0
- package/dist/subagents.d.ts.map +1 -1
- package/dist/subagents.js +118 -15
- package/dist/subagents.js.map +1 -1
- package/dist/verification.d.ts +28 -1
- package/dist/verification.d.ts.map +1 -1
- package/dist/verification.js +119 -3
- 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 +55 -23
- package/dist/workflows.js.map +1 -1
- package/package.json +9 -5
- package/src/approvals.ts +62 -45
- package/src/cli.ts +11 -2
- package/src/context.ts +32 -1
- package/src/diff.ts +40 -2
- package/src/handoffs.ts +20 -8
- package/src/host.ts +146 -23
- package/src/index.ts +7 -0
- package/src/isolated.ts +39 -2
- package/src/native.ts +213 -39
- package/src/orchestration/compile.ts +28 -2
- package/src/orchestration/evidence-store.ts +9 -3
- package/src/orchestration/host.ts +17 -0
- package/src/replay.ts +206 -25
- package/src/rpc.ts +262 -33
- package/src/session-migration.ts +3 -2
- package/src/sessions.ts +288 -57
- package/src/subagents.ts +147 -17
- package/src/verification.ts +146 -3
- 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 ??
|
|
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,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
|
|
124
|
-
if (!authorized(request, this.token))
|
|
125
|
-
|
|
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
|
|
186
|
+
return this.response(
|
|
132
187
|
{ error: { code: "not_found", message: "unknown agent RPC endpoint" } },
|
|
133
188
|
404,
|
|
134
189
|
cors,
|
|
135
190
|
)
|
|
136
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
...(
|
|
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 &&
|
|
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(
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
|
513
|
-
if (
|
|
514
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/session-migration.ts
CHANGED
|
@@ -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)
|