@neurocode-ai/codemode 1.18.8

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.
@@ -0,0 +1,19 @@
1
+ # OpenAPI Follow-ups
2
+
3
+ The initial adapter intentionally skips operations it cannot execute correctly. Future work may add:
4
+
5
+ - Cookie parameters, authentication, and cookie-header merging.
6
+ - Matrix, label, space-delimited, pipe-delimited, `allowReserved`, and parameter `content` serialization.
7
+ - External references and complete nested `$defs` support.
8
+ - Relative or templated server URLs and server variables.
9
+ - Base URLs containing query strings or fragments.
10
+ - Runtime response-schema validation and full content negotiation.
11
+ - Binary response values and explicit byte-oriented return types.
12
+ - Request/response projection for `readOnly` and `writeOnly` properties.
13
+ - SSE, WebSocket, and other streaming transports.
14
+ - Recovery of responses rejected by a status-filtering `HttpClient`.
15
+ - Configurable request and response size limits.
16
+ - Adapter-enforced redirect policy independent of the supplied `HttpClient`.
17
+ - Strict UTF-8 and empty-body validation for JSON responses.
18
+ - Compile-time rejection of parameter schemas with nested values unsupported by their serialization style; runtime rejects them before auth resolution.
19
+ - Complete malformed-security-scheme validation and broader auth-combination coverage.
@@ -0,0 +1,130 @@
1
+ import { HttpClient } from "effect/unstable/http"
2
+ import { make, type Definition } from "../tool.js"
3
+ import { invoke } from "./runtime.js"
4
+ import {
5
+ componentDefinitions,
6
+ inputSchema,
7
+ isRecord,
8
+ methods,
9
+ nonEmptyString,
10
+ operationInput,
11
+ operationOutput,
12
+ operationPath,
13
+ operationSecurityRequirements,
14
+ securityRequirements,
15
+ securitySchemes,
16
+ specServerUrl,
17
+ validateBaseUrl,
18
+ } from "./spec.js"
19
+ import type { Operation, Options, Result, Skipped, Tools } from "./types.js"
20
+
21
+ export type {
22
+ AuthResolver,
23
+ Credential,
24
+ Document,
25
+ Operation,
26
+ Options,
27
+ Result,
28
+ SecurityScheme,
29
+ Skipped,
30
+ Tools,
31
+ } from "./types.js"
32
+
33
+ /**
34
+ * Builds a CodeMode tool subtree from an OpenAPI 3.x document, one tool per
35
+ * operation. Auth is resolved host-side via `auth.resolve` and never
36
+ * model-visible. Tools require `HttpClient.HttpClient`; unrepresentable
37
+ * operations land in `skipped`.
38
+ */
39
+ export const fromSpec = (options: Options): Result => {
40
+ const document = options.spec
41
+ const schemes = securitySchemes(document)
42
+ const defaultSecurity = securityRequirements(document.security)
43
+ const definitions = componentDefinitions(document)
44
+ const paths = isRecord(document.paths) ? document.paths : {}
45
+ const used = new Set<string>()
46
+ const namespaces = new Set<string>()
47
+ const skipped: Array<Skipped> = []
48
+ const tools = Object.create(null) as Tools
49
+
50
+ for (const [path, pathValue] of Object.entries(paths)) {
51
+ if (!isRecord(pathValue)) continue
52
+ for (const [method, operationValue] of Object.entries(pathValue)) {
53
+ if (!methods.has(method) || !isRecord(operationValue)) continue
54
+ const segments = operationPath(method, path, operationValue, used, namespaces)
55
+ const operation: Operation = {
56
+ operationId: nonEmptyString(operationValue.operationId),
57
+ method: method.toUpperCase(),
58
+ path,
59
+ summary: nonEmptyString(operationValue.summary),
60
+ description: nonEmptyString(operationValue.description),
61
+ }
62
+ const output = operationOutput(document, operationValue, definitions)
63
+ if (!output.ok) {
64
+ skipped.push({ method: operation.method, path, reason: output.reason })
65
+ continue
66
+ }
67
+
68
+ const resolvedBaseUrl = (() => {
69
+ if (options.baseUrl !== undefined) return validateBaseUrl(options.baseUrl)
70
+ if (operationValue.servers !== undefined) return specServerUrl(operationValue)
71
+ if (pathValue.servers !== undefined) return specServerUrl(pathValue)
72
+ return specServerUrl(document)
73
+ })()
74
+ if (!resolvedBaseUrl.ok) {
75
+ skipped.push({ method: operation.method, path, reason: resolvedBaseUrl.reason })
76
+ continue
77
+ }
78
+ const parsedInput = operationInput(document, pathValue, operationValue)
79
+ if (!parsedInput.ok) {
80
+ skipped.push({ method: operation.method, path, reason: parsedInput.reason })
81
+ continue
82
+ }
83
+ const input = parsedInput.value
84
+
85
+ const security = operationSecurityRequirements(operationValue.security, defaultSecurity, schemes)
86
+ if (!security.ok) {
87
+ skipped.push({ method: operation.method, path, reason: security.reason })
88
+ continue
89
+ }
90
+ const plan = {
91
+ operation,
92
+ url: `${resolvedBaseUrl.value.replace(/\/+$/, "")}${path}`,
93
+ fields: input.fields,
94
+ body: input.body,
95
+ security: security.value,
96
+ schemes,
97
+ auth: options.auth,
98
+ headers: options.headers ?? {},
99
+ }
100
+ used.add(segments.join("."))
101
+ for (const index of segments.slice(0, -1).keys()) namespaces.add(segments.slice(0, index + 1).join("."))
102
+ setTool(
103
+ tools,
104
+ segments,
105
+ make({
106
+ description: operation.description ?? operation.summary ?? `${operation.method} ${path}`,
107
+ input: inputSchema(input.fields, definitions),
108
+ output: output.value,
109
+ run: (input) => invoke(plan, input),
110
+ }),
111
+ )
112
+ }
113
+ }
114
+
115
+ return { tools, skipped }
116
+ }
117
+
118
+ const setTool = (tools: Tools, path: ReadonlyArray<string>, definition: Definition<HttpClient.HttpClient>): void => {
119
+ const [head, ...rest] = path
120
+ if (head === undefined) return
121
+ if (rest.length === 0) {
122
+ tools[head] = definition
123
+ return
124
+ }
125
+ const child = tools[head]
126
+ if (child === undefined || !isRecord(child) || child._tag === "CodeModeTool") {
127
+ tools[head] = Object.create(null) as Tools
128
+ }
129
+ setTool(tools[head] as Tools, rest, definition)
130
+ }
@@ -0,0 +1,326 @@
1
+ import { Effect, Option, Schema, Stream } from "effect"
2
+ import { HttpClient, HttpClientRequest, HttpClientResponse, type HttpMethod } from "effect/unstable/http"
3
+ import { ToolError, toolError } from "../tool-error.js"
4
+ import { isRecord, own } from "./spec.js"
5
+ import type { AppliedAuth, Credential, Plan, SecurityScheme } from "./types.js"
6
+
7
+ const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
8
+ const maxErrorBodyChars = 1_024
9
+ const maxResponseBodyBytes = 50 * 1024 * 1024
10
+
11
+ export const invoke = (plan: Plan, input: unknown): Effect.Effect<unknown, unknown, HttpClient.HttpClient> =>
12
+ Effect.gen(function* () {
13
+ const value = isRecord(input) ? input : {}
14
+
15
+ let request = yield* buildRequest(plan, value)
16
+
17
+ const auth = yield* resolveAuth(plan)
18
+ for (const [name, item] of Object.entries(auth.query)) {
19
+ request = HttpClientRequest.setUrlParam(request, name, item)
20
+ }
21
+ request = HttpClientRequest.setHeaders(request, auth.headers)
22
+
23
+ const client = yield* HttpClient.HttpClient
24
+ const response = yield* client
25
+ .execute(request)
26
+ .pipe(
27
+ Effect.catch((cause) =>
28
+ Effect.fail(toolError(`${plan.operation.method} ${plan.operation.path} failed: transport error`, cause)),
29
+ ),
30
+ )
31
+ const text = yield* readResponseBody(response, plan)
32
+ const mediaType = response.headers["content-type"]?.split(";")[0]?.trim().toLowerCase()
33
+ const json = mediaType === "application/json" || mediaType?.endsWith("+json") === true
34
+ const decoded = text === "" ? Option.some(null) : json ? decodeJson(text) : Option.none()
35
+ const parsed = json ? Option.getOrElse(decoded, () => text) : text === "" ? null : text
36
+ if (response.status < 200 || response.status >= 300) {
37
+ const rendered = typeof parsed === "string" ? parsed : (JSON.stringify(parsed) ?? "")
38
+ const summary =
39
+ rendered === "" || rendered === "null"
40
+ ? "no response body"
41
+ : rendered.length > maxErrorBodyChars
42
+ ? `${rendered.slice(0, maxErrorBodyChars)}...`
43
+ : rendered
44
+ return yield* Effect.fail(
45
+ toolError(`${plan.operation.method} ${plan.operation.path} failed with HTTP ${response.status}: ${summary}`),
46
+ )
47
+ }
48
+ if (json && Option.isNone(decoded)) {
49
+ return yield* Effect.fail(toolError(`${plan.operation.method} ${plan.operation.path} returned malformed JSON.`))
50
+ }
51
+ return parsed
52
+ })
53
+
54
+ const buildRequest = (
55
+ plan: Plan,
56
+ input: Readonly<Record<string, unknown>>,
57
+ ): Effect.Effect<HttpClientRequest.HttpClientRequest, ToolError> =>
58
+ Effect.gen(function* () {
59
+ // Validate every model-controlled value before auth resolution, which may refresh tokens.
60
+ const url = buildUrl(plan, input)
61
+ if (url instanceof ToolError) return yield* Effect.fail(url)
62
+ const missing = plan.fields.find(
63
+ (field) => field.required && field.location !== "path" && own(input, field.inputName) === undefined,
64
+ )
65
+ if (missing !== undefined) {
66
+ const label = missing.location === "body" ? "body field" : `${missing.location} parameter`
67
+ return yield* Effect.fail(toolError(`Missing required ${label} '${missing.inputName}'.`))
68
+ }
69
+
70
+ let request = HttpClientRequest.make(plan.operation.method as HttpMethod.HttpMethod)(url)
71
+ for (const field of plan.fields) {
72
+ if (field.location !== "query") continue
73
+ const item = own(input, field.inputName)
74
+ if (item === undefined) continue
75
+ const serialized = serializeQuery(request, field, item)
76
+ if (serialized instanceof ToolError) return yield* Effect.fail(serialized)
77
+ request = serialized
78
+ }
79
+
80
+ // Host headers first, then declared header parameters.
81
+ request = HttpClientRequest.setHeaders(request, plan.headers)
82
+ for (const field of plan.fields) {
83
+ if (field.location !== "header") continue
84
+ const item = own(input, field.inputName)
85
+ if (item === undefined) continue
86
+ const serialized = serializeSimple(field, item, String)
87
+ if (serialized instanceof ToolError) return yield* Effect.fail(serialized)
88
+ request = HttpClientRequest.setHeader(request, field.name, serialized)
89
+ }
90
+
91
+ const setBody = (value: unknown, mediaType: string) =>
92
+ HttpClientRequest.bodyJson(request, value).pipe(
93
+ Effect.map((next) => HttpClientRequest.setHeader(next, "content-type", mediaType)),
94
+ Effect.mapError((cause) =>
95
+ toolError(`Invalid JSON body for ${plan.operation.method} ${plan.operation.path}.`, cause),
96
+ ),
97
+ )
98
+ if (plan.body?.mode === "value") {
99
+ const field = plan.fields.find((field) => field.location === "body")
100
+ const body = field === undefined ? undefined : own(input, field.inputName)
101
+ if (body !== undefined) request = yield* setBody(body, plan.body.mediaType)
102
+ }
103
+ if (plan.body?.mode === "object") {
104
+ const entries = plan.fields.flatMap((field) => {
105
+ if (field.location !== "body") return []
106
+ const item = own(input, field.inputName)
107
+ return item === undefined ? [] : [[field.name, item] as const]
108
+ })
109
+ if (plan.body.required || entries.length > 0) {
110
+ request = yield* setBody(Object.fromEntries(entries), plan.body.mediaType)
111
+ }
112
+ }
113
+ return request
114
+ })
115
+
116
+ const resolveAuth = (plan: Plan): Effect.Effect<AppliedAuth, unknown> =>
117
+ Effect.gen(function* () {
118
+ const none: AppliedAuth = { headers: {}, query: {} }
119
+ if (plan.security.length === 0) return none
120
+
121
+ const unavailable: Array<string> = []
122
+ alternatives: for (const requirement of plan.security) {
123
+ const names = Object.keys(requirement)
124
+ if (names.length === 0) return none
125
+ const credentials: Array<readonly [string, SecurityScheme, Credential]> = []
126
+ for (const name of names) {
127
+ const scheme = own(plan.schemes, name)
128
+ if (scheme === undefined || plan.auth === undefined) {
129
+ unavailable.push(name)
130
+ continue alternatives
131
+ }
132
+ const credential = yield* plan.auth.resolve({
133
+ name,
134
+ definition: scheme,
135
+ scopes: requirement[name] ?? [],
136
+ operation: plan.operation,
137
+ })
138
+ if (credential === undefined) {
139
+ unavailable.push(name)
140
+ continue alternatives
141
+ }
142
+ credentials.push([name, scheme, credential])
143
+ }
144
+ const applied = applyCredentials(credentials)
145
+ return applied instanceof ToolError ? yield* Effect.fail(applied) : applied
146
+ }
147
+
148
+ return yield* Effect.fail(
149
+ toolError(
150
+ `${plan.operation.method} ${plan.operation.path} requires authentication; no credential available for: ${[...new Set(unavailable)].join(", ")}.`,
151
+ ),
152
+ )
153
+ })
154
+
155
+ const applyCredentials = (
156
+ credentials: ReadonlyArray<readonly [string, SecurityScheme, Credential]>,
157
+ ): AppliedAuth | ToolError => {
158
+ const headers = new Map<string, string>()
159
+ const query = new Map<string, string>()
160
+ const add = (carrier: "header" | "query", name: string, value: string): ToolError | undefined => {
161
+ const target = carrier === "header" ? headers : query
162
+ if (target.has(name)) return toolError(`Authentication resolves multiple credentials for ${carrier} '${name}'.`)
163
+ target.set(name, value)
164
+ }
165
+ for (const [name, definition, credential] of credentials) {
166
+ if (credential.type === "bearer") {
167
+ const duplicate = add("header", "authorization", `Bearer ${credential.token}`)
168
+ if (duplicate !== undefined) return duplicate
169
+ continue
170
+ }
171
+ if (credential.type === "basic") {
172
+ // Buffer instead of btoa: btoa throws on non-Latin-1 credentials.
173
+ const duplicate = add(
174
+ "header",
175
+ "authorization",
176
+ `Basic ${Buffer.from(`${credential.username}:${credential.password}`, "utf8").toString("base64")}`,
177
+ )
178
+ if (duplicate !== undefined) return duplicate
179
+ continue
180
+ }
181
+ if (credential.type === "header") {
182
+ const duplicate = add("header", credential.name.toLowerCase(), credential.value)
183
+ if (duplicate !== undefined) return duplicate
184
+ continue
185
+ }
186
+ // apiKey: the carrier comes from the scheme declaration.
187
+ if (definition.type !== "apiKey") {
188
+ return toolError(
189
+ `Security scheme '${name}' is not an apiKey scheme; resolve a bearer, basic, or header credential for it.`,
190
+ )
191
+ }
192
+ if (definition.in === "cookie") return toolError(`Cookie authentication '${name}' is not supported.`)
193
+ const parameter = definition.in === "header" ? definition.name.toLowerCase() : definition.name
194
+ const duplicate = add(definition.in, parameter, credential.value)
195
+ if (duplicate !== undefined) return duplicate
196
+ }
197
+ return { headers: Object.fromEntries(headers), query: Object.fromEntries(query) }
198
+ }
199
+
200
+ const buildUrl = (plan: Plan, input: Readonly<Record<string, unknown>>): string | ToolError => {
201
+ let url = plan.url
202
+ for (const field of plan.fields) {
203
+ if (field.location !== "path") continue
204
+ const item = own(input, field.inputName)
205
+ if (item === undefined) {
206
+ return toolError(`Missing required path parameter '${field.inputName}'.`)
207
+ }
208
+ const fieldValue = serializeSimple(field, item, (value) =>
209
+ encodeURIComponent(value).replace(
210
+ /[!'()*]/g,
211
+ (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`,
212
+ ),
213
+ )
214
+ if (fieldValue instanceof ToolError) return fieldValue
215
+ // '.'/'..' survive encoding and URL normalization collapses them, letting a
216
+ // model-supplied value retarget the request to a different endpoint.
217
+ if (fieldValue === "" || fieldValue === "." || fieldValue === "..") {
218
+ return toolError(`Invalid path parameter '${field.inputName}'.`)
219
+ }
220
+ url = url.replaceAll(`{${field.name}}`, fieldValue)
221
+ }
222
+ const unresolved = url.match(/\{[^{}]+\}/)
223
+ if (unresolved !== null) return toolError(`Unresolved path parameter ${unresolved[0]}.`)
224
+ return url
225
+ }
226
+
227
+ const serializeSimple = (
228
+ field: Plan["fields"][number],
229
+ value: unknown,
230
+ encode: (value: string) => string,
231
+ ): string | ToolError => {
232
+ const scalar = (item: unknown): string | ToolError =>
233
+ item !== null && typeof item !== "string" && typeof item !== "number" && typeof item !== "boolean"
234
+ ? toolError(`Parameter '${field.inputName}' contains an unsupported nested value.`)
235
+ : encode(String(item))
236
+ if (Array.isArray(value)) {
237
+ const items = value.map(scalar)
238
+ const invalid = items.find((item): item is ToolError => item instanceof ToolError)
239
+ return invalid ?? items.join(",")
240
+ }
241
+ if (!isRecord(value)) return scalar(value)
242
+ const entries = Object.entries(value).flatMap<string | ToolError>(([name, item]) => {
243
+ const rendered = scalar(item)
244
+ if (rendered instanceof ToolError) return [rendered]
245
+ return field.explode ? [`${encode(name)}=${rendered}`] : [encode(name), rendered]
246
+ })
247
+ const invalid = entries.find((item): item is ToolError => item instanceof ToolError)
248
+ return invalid ?? entries.join(",")
249
+ }
250
+
251
+ const serializeQuery = (
252
+ request: HttpClientRequest.HttpClientRequest,
253
+ field: Plan["fields"][number],
254
+ value: unknown,
255
+ ): HttpClientRequest.HttpClientRequest | ToolError => {
256
+ if (field.style === "deepObject") {
257
+ if (!isRecord(value)) return toolError(`Deep-object parameter '${field.inputName}' must be an object.`)
258
+ return Object.entries(value).reduce<HttpClientRequest.HttpClientRequest | ToolError>((current, [name, item]) => {
259
+ if (current instanceof ToolError) return current
260
+ if (item === undefined || (item !== null && typeof item === "object")) {
261
+ return toolError(`Deep-object parameter '${field.inputName}' contains an unsupported nested value.`)
262
+ }
263
+ return HttpClientRequest.appendUrlParam(current, `${field.name}[${name}]`, String(item))
264
+ }, request)
265
+ }
266
+ if (Array.isArray(value)) {
267
+ const rendered = serializeSimple(field, value, String)
268
+ if (rendered instanceof ToolError) return rendered
269
+ if (!field.explode) return HttpClientRequest.appendUrlParam(request, field.name, rendered)
270
+ if (value.some((item) => item === undefined || (item !== null && typeof item === "object"))) {
271
+ return toolError(`Query parameter '${field.inputName}' contains an unsupported nested value.`)
272
+ }
273
+ return value.reduce((current, item) => HttpClientRequest.appendUrlParam(current, field.name, String(item)), request)
274
+ }
275
+ if (isRecord(value) && field.explode) {
276
+ return Object.entries(value).reduce<HttpClientRequest.HttpClientRequest | ToolError>((current, [name, item]) => {
277
+ if (current instanceof ToolError) return current
278
+ if (item === undefined || (item !== null && typeof item === "object")) {
279
+ return toolError(`Query parameter '${field.inputName}' contains an unsupported nested value.`)
280
+ }
281
+ return HttpClientRequest.appendUrlParam(current, name, String(item))
282
+ }, request)
283
+ }
284
+ const rendered = serializeSimple(field, value, String)
285
+ return rendered instanceof ToolError ? rendered : HttpClientRequest.appendUrlParam(request, field.name, rendered)
286
+ }
287
+
288
+ const readResponseBody = (
289
+ response: HttpClientResponse.HttpClientResponse,
290
+ plan: Plan,
291
+ ): Effect.Effect<string, ToolError> =>
292
+ Effect.gen(function* () {
293
+ const contentLength = response.headers["content-length"]
294
+ const parsedSize = contentLength === undefined ? undefined : Number.parseInt(contentLength, 10)
295
+ const declaredSize =
296
+ parsedSize !== undefined && Number.isSafeInteger(parsedSize) && parsedSize >= 0 ? parsedSize : undefined
297
+ if (declaredSize !== undefined && declaredSize > maxResponseBodyBytes) {
298
+ return yield* Effect.fail(toolError(`${plan.operation.method} ${plan.operation.path} response exceeds 50 MiB.`))
299
+ }
300
+ let body = Buffer.allocUnsafe(Math.min(maxResponseBodyBytes, declaredSize ?? 64 * 1024))
301
+ let size = 0
302
+ yield* Stream.runForEach(response.stream, (chunk) => {
303
+ if (size + chunk.byteLength > maxResponseBodyBytes) {
304
+ return Effect.fail(toolError(`${plan.operation.method} ${plan.operation.path} response exceeds 50 MiB.`))
305
+ }
306
+ if (size + chunk.byteLength > body.byteLength) {
307
+ const grown = Buffer.allocUnsafe(
308
+ Math.min(maxResponseBodyBytes, Math.max(size + chunk.byteLength, body.byteLength * 2)),
309
+ )
310
+ body.copy(grown, 0, 0, size)
311
+ body = grown
312
+ }
313
+ body.set(chunk, size)
314
+ size += chunk.byteLength
315
+ return Effect.void
316
+ }).pipe(
317
+ Effect.catch((cause) => {
318
+ if (cause instanceof ToolError) return Effect.fail(cause)
319
+ if (cause.reason._tag === "EmptyBodyError") return Effect.void
320
+ return Effect.fail(
321
+ toolError(`${plan.operation.method} ${plan.operation.path} failed while reading the response body.`, cause),
322
+ )
323
+ }),
324
+ )
325
+ return new TextDecoder().decode(body.subarray(0, size))
326
+ })