@nifrajs/coding-agent 3.2.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/EXTENSIONS.md +25 -0
- package/PERFORMANCE.md +19 -0
- package/README.md +69 -0
- package/REGISTRY.md +15 -0
- package/SECURITY.md +32 -0
- package/dist/approvals.d.ts +70 -0
- package/dist/approvals.d.ts.map +1 -0
- package/dist/approvals.js +174 -0
- package/dist/approvals.js.map +1 -0
- package/dist/capabilities.d.ts +20 -0
- package/dist/capabilities.d.ts.map +1 -0
- package/dist/capabilities.js +49 -0
- package/dist/capabilities.js.map +1 -0
- package/dist/cli.d.ts +24 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +380 -0
- package/dist/cli.js.map +1 -0
- package/dist/context.d.ts +17 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +66 -0
- package/dist/context.js.map +1 -0
- package/dist/deployment-adapters.d.ts +40 -0
- package/dist/deployment-adapters.d.ts.map +1 -0
- package/dist/deployment-adapters.js +147 -0
- package/dist/deployment-adapters.js.map +1 -0
- package/dist/diff.d.ts +17 -0
- package/dist/diff.d.ts.map +1 -0
- package/dist/diff.js +61 -0
- package/dist/diff.js.map +1 -0
- package/dist/errors.d.ts +9 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +35 -0
- package/dist/errors.js.map +1 -0
- package/dist/extensions.d.ts +104 -0
- package/dist/extensions.d.ts.map +1 -0
- package/dist/extensions.js +284 -0
- package/dist/extensions.js.map +1 -0
- package/dist/handoffs.d.ts +97 -0
- package/dist/handoffs.d.ts.map +1 -0
- package/dist/handoffs.js +199 -0
- package/dist/handoffs.js.map +1 -0
- package/dist/healing.d.ts +56 -0
- package/dist/healing.d.ts.map +1 -0
- package/dist/healing.js +54 -0
- package/dist/healing.js.map +1 -0
- package/dist/host.d.ts +61 -0
- package/dist/host.d.ts.map +1 -0
- package/dist/host.js +369 -0
- package/dist/host.js.map +1 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +26 -0
- package/dist/index.js.map +1 -0
- package/dist/isolated-worker.d.ts +2 -0
- package/dist/isolated-worker.d.ts.map +1 -0
- package/dist/isolated-worker.js +155 -0
- package/dist/isolated-worker.js.map +1 -0
- package/dist/isolated.d.ts +52 -0
- package/dist/isolated.d.ts.map +1 -0
- package/dist/isolated.js +293 -0
- package/dist/isolated.js.map +1 -0
- package/dist/native.d.ts +92 -0
- package/dist/native.d.ts.map +1 -0
- package/dist/native.js +339 -0
- package/dist/native.js.map +1 -0
- package/dist/orchestration/artifact-port.d.ts +30 -0
- package/dist/orchestration/artifact-port.d.ts.map +1 -0
- package/dist/orchestration/artifact-port.js +59 -0
- package/dist/orchestration/artifact-port.js.map +1 -0
- package/dist/orchestration/catalog.d.ts +71 -0
- package/dist/orchestration/catalog.d.ts.map +1 -0
- package/dist/orchestration/catalog.js +46 -0
- package/dist/orchestration/catalog.js.map +1 -0
- package/dist/orchestration/compile.d.ts +59 -0
- package/dist/orchestration/compile.d.ts.map +1 -0
- package/dist/orchestration/compile.js +268 -0
- package/dist/orchestration/compile.js.map +1 -0
- package/dist/orchestration/dispatch.d.ts +115 -0
- package/dist/orchestration/dispatch.d.ts.map +1 -0
- package/dist/orchestration/dispatch.js +220 -0
- package/dist/orchestration/dispatch.js.map +1 -0
- package/dist/orchestration/durable-jobs.d.ts +35 -0
- package/dist/orchestration/durable-jobs.d.ts.map +1 -0
- package/dist/orchestration/durable-jobs.js +273 -0
- package/dist/orchestration/durable-jobs.js.map +1 -0
- package/dist/orchestration/effect-key.d.ts +23 -0
- package/dist/orchestration/effect-key.d.ts.map +1 -0
- package/dist/orchestration/effect-key.js +24 -0
- package/dist/orchestration/effect-key.js.map +1 -0
- package/dist/orchestration/evidence-store.d.ts +75 -0
- package/dist/orchestration/evidence-store.d.ts.map +1 -0
- package/dist/orchestration/evidence-store.js +183 -0
- package/dist/orchestration/evidence-store.js.map +1 -0
- package/dist/orchestration/hash.d.ts +12 -0
- package/dist/orchestration/hash.d.ts.map +1 -0
- package/dist/orchestration/hash.js +43 -0
- package/dist/orchestration/hash.js.map +1 -0
- package/dist/orchestration/host.d.ts +121 -0
- package/dist/orchestration/host.d.ts.map +1 -0
- package/dist/orchestration/host.js +334 -0
- package/dist/orchestration/host.js.map +1 -0
- package/dist/orchestration/index.d.ts +20 -0
- package/dist/orchestration/index.d.ts.map +1 -0
- package/dist/orchestration/index.js +19 -0
- package/dist/orchestration/index.js.map +1 -0
- package/dist/orchestration/policy.d.ts +63 -0
- package/dist/orchestration/policy.d.ts.map +1 -0
- package/dist/orchestration/policy.js +80 -0
- package/dist/orchestration/policy.js.map +1 -0
- package/dist/orchestration/recovery.d.ts +87 -0
- package/dist/orchestration/recovery.d.ts.map +1 -0
- package/dist/orchestration/recovery.js +197 -0
- package/dist/orchestration/recovery.js.map +1 -0
- package/dist/orchestration/tracer.d.ts +31 -0
- package/dist/orchestration/tracer.d.ts.map +1 -0
- package/dist/orchestration/tracer.js +141 -0
- package/dist/orchestration/tracer.js.map +1 -0
- package/dist/plans.d.ts +35 -0
- package/dist/plans.d.ts.map +1 -0
- package/dist/plans.js +63 -0
- package/dist/plans.js.map +1 -0
- package/dist/presets.d.ts +10 -0
- package/dist/presets.d.ts.map +1 -0
- package/dist/presets.js +70 -0
- package/dist/presets.js.map +1 -0
- package/dist/process.d.ts +6 -0
- package/dist/process.d.ts.map +1 -0
- package/dist/process.js +42 -0
- package/dist/process.js.map +1 -0
- package/dist/registry.d.ts +37 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +51 -0
- package/dist/registry.js.map +1 -0
- package/dist/replay.d.ts +24 -0
- package/dist/replay.d.ts.map +1 -0
- package/dist/replay.js +154 -0
- package/dist/replay.js.map +1 -0
- package/dist/rpc.d.ts +51 -0
- package/dist/rpc.d.ts.map +1 -0
- package/dist/rpc.js +412 -0
- package/dist/rpc.js.map +1 -0
- package/dist/session-migration.d.ts +59 -0
- package/dist/session-migration.d.ts.map +1 -0
- package/dist/session-migration.js +315 -0
- package/dist/session-migration.js.map +1 -0
- package/dist/sessions.d.ts +74 -0
- package/dist/sessions.d.ts.map +1 -0
- package/dist/sessions.js +222 -0
- package/dist/sessions.js.map +1 -0
- package/dist/subagents.d.ts +51 -0
- package/dist/subagents.d.ts.map +1 -0
- package/dist/subagents.js +141 -0
- package/dist/subagents.js.map +1 -0
- package/dist/tools.d.ts +13 -0
- package/dist/tools.d.ts.map +1 -0
- package/dist/tools.js +37 -0
- package/dist/tools.js.map +1 -0
- package/dist/ui.d.ts +42 -0
- package/dist/ui.d.ts.map +1 -0
- package/dist/ui.js +93 -0
- package/dist/ui.js.map +1 -0
- package/dist/verification.d.ts +34 -0
- package/dist/verification.d.ts.map +1 -0
- package/dist/verification.js +91 -0
- package/dist/verification.js.map +1 -0
- package/dist/workflows.d.ts +98 -0
- package/dist/workflows.d.ts.map +1 -0
- package/dist/workflows.js +174 -0
- package/dist/workflows.js.map +1 -0
- package/package.json +84 -0
- package/src/approvals.ts +249 -0
- package/src/capabilities.ts +74 -0
- package/src/cli.ts +405 -0
- package/src/context.ts +81 -0
- package/src/deployment-adapters.ts +181 -0
- package/src/diff.ts +80 -0
- package/src/errors.ts +46 -0
- package/src/extensions.ts +402 -0
- package/src/handoffs.ts +299 -0
- package/src/healing.ts +93 -0
- package/src/host.ts +465 -0
- package/src/index.ts +165 -0
- package/src/isolated-worker.ts +177 -0
- package/src/isolated.ts +344 -0
- package/src/native.ts +478 -0
- package/src/orchestration/artifact-port.ts +75 -0
- package/src/orchestration/catalog.ts +102 -0
- package/src/orchestration/compile.ts +373 -0
- package/src/orchestration/dispatch.ts +383 -0
- package/src/orchestration/durable-jobs.ts +374 -0
- package/src/orchestration/effect-key.ts +35 -0
- package/src/orchestration/evidence-store.ts +233 -0
- package/src/orchestration/hash.ts +43 -0
- package/src/orchestration/host.ts +445 -0
- package/src/orchestration/index.ts +131 -0
- package/src/orchestration/policy.ts +116 -0
- package/src/orchestration/recovery.ts +271 -0
- package/src/orchestration/tracer.ts +177 -0
- package/src/plans.ts +105 -0
- package/src/presets.ts +90 -0
- package/src/process.ts +47 -0
- package/src/registry.ts +80 -0
- package/src/replay.ts +190 -0
- package/src/rpc.ts +598 -0
- package/src/session-migration.ts +401 -0
- package/src/sessions.ts +303 -0
- package/src/subagents.ts +210 -0
- package/src/tools.ts +52 -0
- package/src/ui.ts +137 -0
- package/src/verification.ts +133 -0
- package/src/workflows.ts +279 -0
package/src/rpc.ts
ADDED
|
@@ -0,0 +1,598 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AgentBackend,
|
|
3
|
+
type AgentSessionSnapshot,
|
|
4
|
+
type ReloadResult,
|
|
5
|
+
resumeFromCursor,
|
|
6
|
+
} from "@nifrajs/agent-protocol"
|
|
7
|
+
import { readProjectDiff } from "./diff.ts"
|
|
8
|
+
import { publicErrorDetails } from "./errors.ts"
|
|
9
|
+
import type { ExtensionHost } from "./extensions.ts"
|
|
10
|
+
import { CodingAgentHost, type CodingAgentHostOptions } from "./host.ts"
|
|
11
|
+
import { readBoundedText } from "./process.ts"
|
|
12
|
+
import type { ContextWindowOptions, SessionStore } from "./sessions.ts"
|
|
13
|
+
import type { UiExtensionHost, UiExtensionManifest } from "./ui.ts"
|
|
14
|
+
import { runNifraVerification } from "./verification.ts"
|
|
15
|
+
|
|
16
|
+
export interface CodingAgentRpcServerOptions {
|
|
17
|
+
readonly backend: AgentBackend
|
|
18
|
+
readonly cwd: string
|
|
19
|
+
readonly hostname?: string
|
|
20
|
+
/** Remote binding is opt-in; the default only permits loopback hosts. */
|
|
21
|
+
readonly allowRemote?: boolean
|
|
22
|
+
readonly port?: number
|
|
23
|
+
/** A random token is generated when omitted. Use an explicit token only for managed local launchers. */
|
|
24
|
+
readonly authToken?: string
|
|
25
|
+
/** Include bounded exception stacks in RPC failures only for trusted loopback debugging. */
|
|
26
|
+
readonly exposeErrorStacks?: boolean
|
|
27
|
+
readonly maxBodyBytes?: number
|
|
28
|
+
readonly sessionStore?: SessionStore
|
|
29
|
+
readonly contextWindow?: ContextWindowOptions
|
|
30
|
+
readonly extensions?: ExtensionHost
|
|
31
|
+
readonly ui?: UiExtensionHost
|
|
32
|
+
readonly verifyAfterTurn?: readonly ("check" | "assure" | "test")[]
|
|
33
|
+
readonly maxRepairAttempts?: number
|
|
34
|
+
readonly verification?: CodingAgentHostOptions["verification"]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface CodingAgentRpcServerHandle {
|
|
38
|
+
readonly url: string
|
|
39
|
+
readonly token: string
|
|
40
|
+
stop(): Promise<void>
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface RpcRequest {
|
|
44
|
+
readonly method: string
|
|
45
|
+
readonly params?: unknown
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface RpcServerLike {
|
|
49
|
+
readonly url: URL
|
|
50
|
+
stop(closeActiveConnections?: boolean): void
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Minimal loopback RPC surface for the CLI, Workbench, CI clients, and a future mobile companion.
|
|
55
|
+
* Turn output is SSE so clients do not need a WebSocket dependency; every event is still a versioned
|
|
56
|
+
* protocol event from the backend.
|
|
57
|
+
*/
|
|
58
|
+
export class CodingAgentRpcServer {
|
|
59
|
+
readonly host: CodingAgentHost
|
|
60
|
+
private readonly options: CodingAgentRpcServerOptions
|
|
61
|
+
private readonly token: string
|
|
62
|
+
private readonly maxBodyBytes: number
|
|
63
|
+
private listener: RpcServerLike | undefined
|
|
64
|
+
|
|
65
|
+
constructor(options: CodingAgentRpcServerOptions) {
|
|
66
|
+
this.options = Object.freeze({ ...options })
|
|
67
|
+
this.host = new CodingAgentHost({
|
|
68
|
+
backend: options.backend,
|
|
69
|
+
...(options.sessionStore === undefined ? {} : { sessionStore: options.sessionStore }),
|
|
70
|
+
...(options.contextWindow === undefined ? {} : { contextWindow: options.contextWindow }),
|
|
71
|
+
...(options.extensions === undefined ? {} : { extensions: options.extensions }),
|
|
72
|
+
...(options.verifyAfterTurn === undefined
|
|
73
|
+
? {}
|
|
74
|
+
: { verifyAfterTurn: options.verifyAfterTurn }),
|
|
75
|
+
...(options.maxRepairAttempts === undefined
|
|
76
|
+
? {}
|
|
77
|
+
: { maxRepairAttempts: options.maxRepairAttempts }),
|
|
78
|
+
exposeErrorStacks: options.exposeErrorStacks === true,
|
|
79
|
+
...(options.verification === undefined ? {} : { verification: options.verification }),
|
|
80
|
+
})
|
|
81
|
+
this.token = options.authToken ?? crypto.randomUUID().replaceAll("-", "")
|
|
82
|
+
if (!/^[A-Za-z0-9._~-]{16,256}$/.test(this.token))
|
|
83
|
+
throw new TypeError("agent rpc: authToken must be a bounded token")
|
|
84
|
+
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")
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async start(): Promise<CodingAgentRpcServerHandle> {
|
|
90
|
+
if (this.listener !== undefined) throw new Error("agent rpc: server is already running")
|
|
91
|
+
const hostname = this.options.hostname ?? "127.0.0.1"
|
|
92
|
+
if (!this.options.allowRemote && !isLoopbackHost(hostname))
|
|
93
|
+
throw new Error("agent rpc: remote binding requires allowRemote: true")
|
|
94
|
+
if (this.options.exposeErrorStacks === true && this.options.allowRemote === true)
|
|
95
|
+
throw new Error("agent rpc: exposeErrorStacks is only allowed for local-only binding")
|
|
96
|
+
this.listener = Bun.serve({
|
|
97
|
+
hostname,
|
|
98
|
+
port: this.options.port ?? 0,
|
|
99
|
+
fetch: (request) => this.fetch(request),
|
|
100
|
+
})
|
|
101
|
+
const listener = this.listener
|
|
102
|
+
return {
|
|
103
|
+
url: listener.url.toString().replace(/\/$/, ""),
|
|
104
|
+
token: this.token,
|
|
105
|
+
stop: async () => {
|
|
106
|
+
await this.stop()
|
|
107
|
+
},
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async stop(): Promise<void> {
|
|
112
|
+
const listener = this.listener
|
|
113
|
+
this.listener = undefined
|
|
114
|
+
await this.host.stop("rpc server stopped")
|
|
115
|
+
listener?.stop(true)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private async fetch(request: Request): Promise<Response> {
|
|
119
|
+
const cors = corsHeaders(request)
|
|
120
|
+
if (request.method === "OPTIONS") return new Response(null, { status: 204, headers: cors })
|
|
121
|
+
const url = new URL(request.url)
|
|
122
|
+
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(
|
|
126
|
+
{ error: { code: "unauthorized", message: "agent RPC authorization required" } },
|
|
127
|
+
401,
|
|
128
|
+
cors,
|
|
129
|
+
)
|
|
130
|
+
if (url.pathname !== "/rpc" || request.method !== "POST")
|
|
131
|
+
return json(
|
|
132
|
+
{ error: { code: "not_found", message: "unknown agent RPC endpoint" } },
|
|
133
|
+
404,
|
|
134
|
+
cors,
|
|
135
|
+
)
|
|
136
|
+
const body = await this.readRequest(request)
|
|
137
|
+
if (!body.ok)
|
|
138
|
+
return json({ error: { code: "invalid_request", message: body.error } }, body.status, cors)
|
|
139
|
+
let rpc: RpcRequest
|
|
140
|
+
try {
|
|
141
|
+
rpc = parseRpcRequest(body.text)
|
|
142
|
+
} catch (error) {
|
|
143
|
+
return json(
|
|
144
|
+
{ error: this.protocolError("invalid_request", error, "request body is invalid") },
|
|
145
|
+
400,
|
|
146
|
+
cors,
|
|
147
|
+
this.options.exposeErrorStacks === true,
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
try {
|
|
151
|
+
return await this.dispatch(rpc, request, cors)
|
|
152
|
+
} catch (error) {
|
|
153
|
+
return json(
|
|
154
|
+
{ error: this.protocolError("rpc_failed", error, "request could not be completed") },
|
|
155
|
+
500,
|
|
156
|
+
cors,
|
|
157
|
+
this.options.exposeErrorStacks === true,
|
|
158
|
+
)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private async dispatch(
|
|
163
|
+
request: RpcRequest,
|
|
164
|
+
httpRequest: Request,
|
|
165
|
+
cors: Headers,
|
|
166
|
+
): Promise<Response> {
|
|
167
|
+
switch (request.method) {
|
|
168
|
+
case "session.create": {
|
|
169
|
+
const params = record(request.params)
|
|
170
|
+
const snapshot =
|
|
171
|
+
this.host.snapshot ??
|
|
172
|
+
(await this.host.start({
|
|
173
|
+
cwd: this.options.cwd,
|
|
174
|
+
backend: this.options.backend.info.name,
|
|
175
|
+
...(typeof params.sessionId === "string" ? { sessionId: params.sessionId } : {}),
|
|
176
|
+
}))
|
|
177
|
+
return json(snapshot, 200, cors)
|
|
178
|
+
}
|
|
179
|
+
case "session.snapshot": {
|
|
180
|
+
const snapshot = this.requireSnapshot()
|
|
181
|
+
return json(snapshot, 200, cors)
|
|
182
|
+
}
|
|
183
|
+
case "session.resume": {
|
|
184
|
+
const params = record(request.params)
|
|
185
|
+
if (
|
|
186
|
+
typeof params.sessionId !== "string" ||
|
|
187
|
+
!/^[A-Za-z0-9._:-]{1,128}$/.test(params.sessionId)
|
|
188
|
+
)
|
|
189
|
+
return json(
|
|
190
|
+
{ error: { code: "invalid_session", message: "sessionId is required" } },
|
|
191
|
+
422,
|
|
192
|
+
cors,
|
|
193
|
+
)
|
|
194
|
+
const snapshot =
|
|
195
|
+
this.host.snapshot ??
|
|
196
|
+
(await this.host.start({
|
|
197
|
+
cwd: this.options.cwd,
|
|
198
|
+
backend: this.options.backend.info.name,
|
|
199
|
+
sessionId: params.sessionId,
|
|
200
|
+
}))
|
|
201
|
+
return json(snapshot, 200, cors)
|
|
202
|
+
}
|
|
203
|
+
case "session.events": {
|
|
204
|
+
const snapshot = this.requireSnapshot()
|
|
205
|
+
const params = record(request.params)
|
|
206
|
+
const limit = params.limit === undefined ? 512 : Number(params.limit)
|
|
207
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 4096)
|
|
208
|
+
return json(
|
|
209
|
+
{
|
|
210
|
+
error: { code: "invalid_history_limit", message: "limit must be between 1 and 4096" },
|
|
211
|
+
},
|
|
212
|
+
422,
|
|
213
|
+
cors,
|
|
214
|
+
)
|
|
215
|
+
const window = await this.host.history(limit)
|
|
216
|
+
// Legacy shape when no cursor is requested; a cursor opts into bounded snapshot + resume.
|
|
217
|
+
if (params.cursor === undefined) return json({ entries: window }, 200, cors)
|
|
218
|
+
const cursor = Number(params.cursor)
|
|
219
|
+
if (!Number.isSafeInteger(cursor) || cursor < -1)
|
|
220
|
+
return json(
|
|
221
|
+
{ error: { code: "invalid_cursor", message: "cursor must be a safe integer >= -1" } },
|
|
222
|
+
422,
|
|
223
|
+
cors,
|
|
224
|
+
)
|
|
225
|
+
return json({ snapshot, resume: resumeFromCursor(window, cursor) }, 200, cors)
|
|
226
|
+
}
|
|
227
|
+
case "session.checkpoint": {
|
|
228
|
+
this.requireSnapshot()
|
|
229
|
+
const params = record(request.params)
|
|
230
|
+
await this.host.checkpoint(params.payload)
|
|
231
|
+
return json({ ok: true }, 200, cors)
|
|
232
|
+
}
|
|
233
|
+
case "session.fork": {
|
|
234
|
+
this.requireSnapshot()
|
|
235
|
+
const params = record(request.params)
|
|
236
|
+
if (params.targetSessionId !== undefined && typeof params.targetSessionId !== "string")
|
|
237
|
+
return json(
|
|
238
|
+
{ error: { code: "invalid_session", message: "targetSessionId must be a string" } },
|
|
239
|
+
422,
|
|
240
|
+
cors,
|
|
241
|
+
)
|
|
242
|
+
return json(await this.host.fork(params.targetSessionId as string | undefined), 201, cors)
|
|
243
|
+
}
|
|
244
|
+
case "turn.send": {
|
|
245
|
+
const params = record(request.params)
|
|
246
|
+
if (
|
|
247
|
+
typeof params.message !== "string" ||
|
|
248
|
+
params.message.trim().length === 0 ||
|
|
249
|
+
params.message.length > this.maxBodyBytes
|
|
250
|
+
)
|
|
251
|
+
return json(
|
|
252
|
+
{
|
|
253
|
+
error: {
|
|
254
|
+
code: "invalid_message",
|
|
255
|
+
message: "message must be a non-empty bounded string",
|
|
256
|
+
},
|
|
257
|
+
},
|
|
258
|
+
422,
|
|
259
|
+
cors,
|
|
260
|
+
)
|
|
261
|
+
const stream = this.host.prompt(params.message, httpRequest.signal)
|
|
262
|
+
const encoder = new TextEncoder()
|
|
263
|
+
const includeErrorStacks = this.options.exposeErrorStacks === true
|
|
264
|
+
const turnError = (error: unknown) => ({
|
|
265
|
+
code: "turn_failed",
|
|
266
|
+
...publicErrorDetails(error, "turn could not be completed", includeErrorStacks),
|
|
267
|
+
})
|
|
268
|
+
const body = new ReadableStream<Uint8Array>({
|
|
269
|
+
async start(controller) {
|
|
270
|
+
try {
|
|
271
|
+
for await (const event of stream)
|
|
272
|
+
controller.enqueue(
|
|
273
|
+
encoder.encode(`data: ${serializeRpcValue(event, includeErrorStacks)}\n\n`),
|
|
274
|
+
)
|
|
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()
|
|
281
|
+
}
|
|
282
|
+
},
|
|
283
|
+
})
|
|
284
|
+
return new Response(body, {
|
|
285
|
+
status: 200,
|
|
286
|
+
headers: new Headers({
|
|
287
|
+
...Object.fromEntries(cors),
|
|
288
|
+
"content-type": "text/event-stream; charset=utf-8",
|
|
289
|
+
"cache-control": "no-cache, no-transform",
|
|
290
|
+
connection: "keep-alive",
|
|
291
|
+
}),
|
|
292
|
+
})
|
|
293
|
+
}
|
|
294
|
+
case "session.reload": {
|
|
295
|
+
const result: ReloadResult = await this.host.reload()
|
|
296
|
+
return json(result, 200, cors)
|
|
297
|
+
}
|
|
298
|
+
case "session.compact": {
|
|
299
|
+
this.requireSnapshot()
|
|
300
|
+
return json(await this.host.compact("manual"), 200, cors)
|
|
301
|
+
}
|
|
302
|
+
case "approval.list": {
|
|
303
|
+
this.requireSnapshot()
|
|
304
|
+
return json({ pending: this.host.pendingApprovals }, 200, cors)
|
|
305
|
+
}
|
|
306
|
+
case "approval.request": {
|
|
307
|
+
const params = record(request.params)
|
|
308
|
+
if (typeof params.action !== "string" || typeof params.capability !== "string")
|
|
309
|
+
return json(
|
|
310
|
+
{ error: { code: "invalid_approval", message: "action and capability are required" } },
|
|
311
|
+
422,
|
|
312
|
+
cors,
|
|
313
|
+
)
|
|
314
|
+
this.requireSnapshot()
|
|
315
|
+
const approval = await this.host.offerApproval({
|
|
316
|
+
...(typeof params.approvalId === "string"
|
|
317
|
+
? { id: params.approvalId }
|
|
318
|
+
: { id: crypto.randomUUID().replaceAll("-", "") }),
|
|
319
|
+
action: params.action,
|
|
320
|
+
capability: params.capability,
|
|
321
|
+
...(typeof params.turnId === "string" ? { turnId: params.turnId } : {}),
|
|
322
|
+
...(typeof params.reason === "string" ? { reason: params.reason } : {}),
|
|
323
|
+
})
|
|
324
|
+
if (approval === undefined)
|
|
325
|
+
return json(
|
|
326
|
+
{ error: { code: "approval_limit", message: "too many pending approvals" } },
|
|
327
|
+
429,
|
|
328
|
+
cors,
|
|
329
|
+
)
|
|
330
|
+
return json(approval, 201, cors)
|
|
331
|
+
}
|
|
332
|
+
case "approval.resolve": {
|
|
333
|
+
const params = record(request.params)
|
|
334
|
+
if (typeof params.approvalId !== "string" || typeof params.approved !== "boolean")
|
|
335
|
+
return json(
|
|
336
|
+
{
|
|
337
|
+
error: { code: "invalid_approval", message: "approvalId and approved are required" },
|
|
338
|
+
},
|
|
339
|
+
422,
|
|
340
|
+
cors,
|
|
341
|
+
)
|
|
342
|
+
this.requireSnapshot()
|
|
343
|
+
const decision = await this.host.resolveApproval(
|
|
344
|
+
params.approvalId,
|
|
345
|
+
params.approved,
|
|
346
|
+
typeof params.reason === "string" ? params.reason : undefined,
|
|
347
|
+
)
|
|
348
|
+
if (decision === undefined)
|
|
349
|
+
return json(
|
|
350
|
+
{ error: { code: "approval_not_found", message: "approval is not pending" } },
|
|
351
|
+
404,
|
|
352
|
+
cors,
|
|
353
|
+
)
|
|
354
|
+
return json(decision, 200, cors)
|
|
355
|
+
}
|
|
356
|
+
case "ui.snapshot": {
|
|
357
|
+
this.requireSnapshot()
|
|
358
|
+
return json(
|
|
359
|
+
this.options.ui === undefined
|
|
360
|
+
? { revision: "0", active: [] }
|
|
361
|
+
: { revision: this.options.ui.currentRevision, active: this.options.ui.extensions },
|
|
362
|
+
200,
|
|
363
|
+
cors,
|
|
364
|
+
)
|
|
365
|
+
}
|
|
366
|
+
case "ui.reload": {
|
|
367
|
+
this.requireSnapshot()
|
|
368
|
+
if (this.options.ui === undefined)
|
|
369
|
+
return json(
|
|
370
|
+
{ error: { code: "ui_unavailable", message: "UI extension host is not configured" } },
|
|
371
|
+
409,
|
|
372
|
+
cors,
|
|
373
|
+
)
|
|
374
|
+
const params = record(request.params)
|
|
375
|
+
if (!Array.isArray(params.manifests))
|
|
376
|
+
return json(
|
|
377
|
+
{ error: { code: "invalid_ui", message: "manifests must be an array" } },
|
|
378
|
+
422,
|
|
379
|
+
cors,
|
|
380
|
+
)
|
|
381
|
+
return json(this.options.ui.reload(params.manifests as UiExtensionManifest[]), 200, cors)
|
|
382
|
+
}
|
|
383
|
+
case "ui.preview": {
|
|
384
|
+
this.requireSnapshot()
|
|
385
|
+
if (this.options.ui === undefined)
|
|
386
|
+
return json(
|
|
387
|
+
{ error: { code: "ui_unavailable", message: "UI extension host is not configured" } },
|
|
388
|
+
409,
|
|
389
|
+
cors,
|
|
390
|
+
)
|
|
391
|
+
const params = record(request.params)
|
|
392
|
+
if (!Array.isArray(params.manifests))
|
|
393
|
+
return json(
|
|
394
|
+
{ error: { code: "invalid_ui", message: "manifests must be an array" } },
|
|
395
|
+
422,
|
|
396
|
+
cors,
|
|
397
|
+
)
|
|
398
|
+
return json(this.options.ui.preview(params.manifests as UiExtensionManifest[]), 200, cors)
|
|
399
|
+
}
|
|
400
|
+
case "workflow.list": {
|
|
401
|
+
this.requireSnapshot()
|
|
402
|
+
return json({ workflows: this.options.extensions?.availableWorkflows ?? [] }, 200, cors)
|
|
403
|
+
}
|
|
404
|
+
case "subagent.list": {
|
|
405
|
+
this.requireSnapshot()
|
|
406
|
+
return json({ subagents: this.options.extensions?.availableSubagents ?? [] }, 200, cors)
|
|
407
|
+
}
|
|
408
|
+
case "provider.list": {
|
|
409
|
+
this.requireSnapshot()
|
|
410
|
+
return json({ providers: this.options.extensions?.availableProviders ?? [] }, 200, cors)
|
|
411
|
+
}
|
|
412
|
+
case "workflow.run": {
|
|
413
|
+
this.requireSnapshot()
|
|
414
|
+
if (this.options.extensions === undefined)
|
|
415
|
+
return json(
|
|
416
|
+
{ error: { code: "workflow_unavailable", message: "workflow host is not configured" } },
|
|
417
|
+
409,
|
|
418
|
+
cors,
|
|
419
|
+
)
|
|
420
|
+
const params = record(request.params)
|
|
421
|
+
if (typeof params.name !== "string" || !/^[a-z][a-z0-9._-]{0,63}$/.test(params.name))
|
|
422
|
+
return json(
|
|
423
|
+
{ error: { code: "invalid_workflow", message: "name must be a bounded workflow id" } },
|
|
424
|
+
422,
|
|
425
|
+
cors,
|
|
426
|
+
)
|
|
427
|
+
const result = await this.options.extensions.runWorkflow(params.name, {
|
|
428
|
+
...(httpRequest.signal === undefined ? {} : { signal: httpRequest.signal }),
|
|
429
|
+
...(typeof params.maxSteps === "number" &&
|
|
430
|
+
Number.isSafeInteger(params.maxSteps) &&
|
|
431
|
+
params.maxSteps > 0
|
|
432
|
+
? { maxSteps: Math.min(params.maxSteps, 512) }
|
|
433
|
+
: {}),
|
|
434
|
+
...(typeof params.maxDepth === "number" &&
|
|
435
|
+
Number.isSafeInteger(params.maxDepth) &&
|
|
436
|
+
params.maxDepth > 0
|
|
437
|
+
? { maxDepth: Math.min(params.maxDepth, 16) }
|
|
438
|
+
: {}),
|
|
439
|
+
exposeErrorStacks: this.options.exposeErrorStacks === true,
|
|
440
|
+
})
|
|
441
|
+
return json(result, result.ok ? 200 : 422, cors, this.options.exposeErrorStacks === true)
|
|
442
|
+
}
|
|
443
|
+
case "verification.run": {
|
|
444
|
+
const params = record(request.params)
|
|
445
|
+
const name = params.name
|
|
446
|
+
if (name !== "check" && name !== "assure" && name !== "test")
|
|
447
|
+
return json(
|
|
448
|
+
{
|
|
449
|
+
error: {
|
|
450
|
+
code: "invalid_verification",
|
|
451
|
+
message: "name must be check, assure, or test",
|
|
452
|
+
},
|
|
453
|
+
},
|
|
454
|
+
422,
|
|
455
|
+
cors,
|
|
456
|
+
)
|
|
457
|
+
this.requireSnapshot()
|
|
458
|
+
return json(
|
|
459
|
+
await runNifraVerification(name, {
|
|
460
|
+
cwd: this.options.cwd,
|
|
461
|
+
exposeErrorStacks: this.options.exposeErrorStacks === true,
|
|
462
|
+
}),
|
|
463
|
+
200,
|
|
464
|
+
cors,
|
|
465
|
+
this.options.exposeErrorStacks === true,
|
|
466
|
+
)
|
|
467
|
+
}
|
|
468
|
+
case "project.diff": {
|
|
469
|
+
this.requireSnapshot()
|
|
470
|
+
return json(
|
|
471
|
+
await readProjectDiff({
|
|
472
|
+
cwd: this.options.cwd,
|
|
473
|
+
exposeErrorStacks: this.options.exposeErrorStacks === true,
|
|
474
|
+
}),
|
|
475
|
+
200,
|
|
476
|
+
cors,
|
|
477
|
+
this.options.exposeErrorStacks === true,
|
|
478
|
+
)
|
|
479
|
+
}
|
|
480
|
+
case "session.stop": {
|
|
481
|
+
await this.host.stop("rpc request")
|
|
482
|
+
return json({ ok: true }, 200, cors)
|
|
483
|
+
}
|
|
484
|
+
default:
|
|
485
|
+
return json(
|
|
486
|
+
{ error: { code: "method_not_found", message: `unknown method: ${request.method}` } },
|
|
487
|
+
404,
|
|
488
|
+
cors,
|
|
489
|
+
)
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
private requireSnapshot(): AgentSessionSnapshot {
|
|
494
|
+
if (this.host.snapshot === undefined) throw new Error("agent RPC session has not been created")
|
|
495
|
+
return this.host.snapshot
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
private protocolError(
|
|
499
|
+
code: string,
|
|
500
|
+
error: unknown,
|
|
501
|
+
fallback: string,
|
|
502
|
+
): { readonly code: string; readonly message: string; readonly stack?: string } {
|
|
503
|
+
return { code, ...publicErrorDetails(error, fallback, this.options.exposeErrorStacks === true) }
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
private async readRequest(
|
|
507
|
+
request: Request,
|
|
508
|
+
): Promise<
|
|
509
|
+
| { readonly ok: true; readonly text: string }
|
|
510
|
+
| { readonly ok: false; readonly status: 400 | 413; readonly error: string }
|
|
511
|
+
> {
|
|
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" }
|
|
515
|
+
const result = await readBoundedText(request.body, this.maxBodyBytes)
|
|
516
|
+
if (result.truncated) return { ok: false, status: 413, error: "request body is too large" }
|
|
517
|
+
return { ok: true, text: result.text }
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function parseRpcRequest(text: string): RpcRequest {
|
|
522
|
+
let value: unknown
|
|
523
|
+
try {
|
|
524
|
+
value = JSON.parse(text)
|
|
525
|
+
} catch {
|
|
526
|
+
throw new Error("request body must be JSON")
|
|
527
|
+
}
|
|
528
|
+
const object = record(value)
|
|
529
|
+
if (typeof object.method !== "string" || !/^[a-z][a-z0-9._-]{1,63}$/.test(object.method))
|
|
530
|
+
throw new Error("request method is invalid")
|
|
531
|
+
return {
|
|
532
|
+
method: object.method,
|
|
533
|
+
...(Object.hasOwn(object, "params") ? { params: object.params } : {}),
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function record(value: unknown): Record<string, unknown> {
|
|
538
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return {}
|
|
539
|
+
return value as Record<string, unknown>
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function authorized(request: Request, token: string): boolean {
|
|
543
|
+
const header = request.headers.get("authorization")
|
|
544
|
+
if (header === null || !header.startsWith("Bearer ")) return false
|
|
545
|
+
return constantTimeEqual(header.slice(7), token)
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function constantTimeEqual(left: string, right: string): boolean {
|
|
549
|
+
const a = new TextEncoder().encode(left)
|
|
550
|
+
const b = new TextEncoder().encode(right)
|
|
551
|
+
let difference = a.length ^ b.length
|
|
552
|
+
const length = Math.max(a.length, b.length)
|
|
553
|
+
for (let index = 0; index < length; index++) difference |= (a[index] ?? 0) ^ (b[index] ?? 0)
|
|
554
|
+
return difference === 0
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function corsHeaders(request: Request): Headers {
|
|
558
|
+
const headers = new Headers({
|
|
559
|
+
vary: "Origin",
|
|
560
|
+
"access-control-allow-headers": "authorization, content-type",
|
|
561
|
+
"access-control-allow-methods": "GET, POST, OPTIONS",
|
|
562
|
+
})
|
|
563
|
+
const origin = request.headers.get("origin")
|
|
564
|
+
if (origin !== null && /^https?:\/\/(?:127\.0\.0\.1|localhost)(?::\d+)?$/.test(origin))
|
|
565
|
+
headers.set("access-control-allow-origin", origin)
|
|
566
|
+
return headers
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function isLoopbackHost(hostname: string): boolean {
|
|
570
|
+
return (
|
|
571
|
+
hostname === "localhost" ||
|
|
572
|
+
hostname === "127.0.0.1" ||
|
|
573
|
+
hostname === "::1" ||
|
|
574
|
+
hostname === "[::1]"
|
|
575
|
+
)
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function json(
|
|
579
|
+
value: unknown,
|
|
580
|
+
status: number,
|
|
581
|
+
inherited: Headers,
|
|
582
|
+
includeErrorStacks = false,
|
|
583
|
+
): Response {
|
|
584
|
+
const headers = new Headers(inherited)
|
|
585
|
+
headers.set("content-type", "application/json; charset=utf-8")
|
|
586
|
+
// lgtm [js/stack-trace-exposure] the default replacer removes stack fields from protocol data;
|
|
587
|
+
// stacks are serialized only in explicitly opt-in, loopback-only diagnostics mode.
|
|
588
|
+
return new Response(serializeRpcValue(value, includeErrorStacks), { status, headers })
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function serializeRpcValue(value: unknown, includeErrorStacks: boolean): string {
|
|
592
|
+
return JSON.stringify(
|
|
593
|
+
value,
|
|
594
|
+
includeErrorStacks
|
|
595
|
+
? undefined
|
|
596
|
+
: (key: string, nested: unknown) => (key === "stack" ? undefined : nested),
|
|
597
|
+
)
|
|
598
|
+
}
|