@kubb/studio 5.11.1 → 5.11.3

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 CHANGED
@@ -85,6 +85,10 @@ running in a developer's project and a container running a fixed plugin set.
85
85
  the agent is a sandbox and carries its slug and Studio's version. From then on the agent pings every
86
86
  30 seconds, and Studio schedules one job at a time on the socket.
87
87
 
88
+ With `allowRead`, the connect payload includes the current `output.path` file list. Studio reads
89
+ file contents from disk through `readFiles` when a user opens a file. Sandbox agents have no disk
90
+ output, and output paths outside the project root are excluded.
91
+
88
92
  Studio ends a socket on purpose with one of these close codes:
89
93
 
90
94
  | Code | Meaning | `connectAgent` |
@@ -108,7 +112,7 @@ Every permission is off by default, and each covers one trust boundary:
108
112
  | `allowWrite` | Generated files are written to disk. Off means they exist only in memory and stream to Studio. |
109
113
  | `allowExec` | The formatter, the linter, and `output.postGenerate` run as child processes. |
110
114
  | `allowConfigEdit` | Studio may change plugin options in `kubb.config.ts`. |
111
- | `allowRead` | Studio may read back the files a generation produced. |
115
+ | `allowRead` | Studio may read existing output files and the files a generation produced. |
112
116
  | `allowReadAst` | Studio may read back the parsed spec (the Kubb AST). |
113
117
 
114
118
  A sandbox never writes or edits config, and always allows reads.
package/dist/index.cjs CHANGED
@@ -140,6 +140,27 @@ async function read(path) {
140
140
  if (runtime.isBun) return Bun.file(path).text();
141
141
  return (0, node_fs_promises.readFile)(path, { encoding: "utf8" });
142
142
  }
143
+ /**
144
+ * Resolves to `true` when `path` is `parent` itself or nested inside it. Both sides are resolved
145
+ * to absolute paths first, so relative and `..`-containing inputs compare correctly.
146
+ *
147
+ * Guards a destructive or an out-of-tree operation: before wiping an output directory, check that
148
+ * it does not contain the project root, and before loading a path a caller supplied, check that it
149
+ * did not escape the directory it is allowed to read from.
150
+ *
151
+ * @example
152
+ * isPathInside('./src/gen', '.') // true — nested inside the root
153
+ * isPathInside('.', '.') // true — the same directory counts as inside
154
+ * isPathInside('.', './src/gen') // false — the root is not inside its own output
155
+ * isPathInside('../other', '.') // false — escapes the root
156
+ */
157
+ function isPathInside(path, parent) {
158
+ const resolvedPath = (0, node_path.resolve)(path);
159
+ const resolvedParent = (0, node_path.resolve)(parent);
160
+ if (resolvedPath === resolvedParent) return true;
161
+ const rel = (0, node_path.relative)(resolvedParent, resolvedPath);
162
+ return rel !== "" && !rel.startsWith("..") && !(0, node_path.isAbsolute)(rel);
163
+ }
143
164
  //#endregion
144
165
  //#region ../../internals/utils/src/promise.ts
145
166
  /**
@@ -178,7 +199,7 @@ function getElapsedMs(hrStart) {
178
199
  }
179
200
  //#endregion
180
201
  //#region package.json
181
- var version = "5.11.1";
202
+ var version = "5.11.3";
182
203
  //#endregion
183
204
  //#region src/resolveConfig.ts
184
205
  /**
@@ -483,6 +504,33 @@ async function mergePlugins({ diskPlugins, studioPlugins, extensions = /* @__PUR
483
504
  })];
484
505
  }
485
506
  /**
507
+ * Throws `KUBB_PATH_TRAVERSAL` when a plugin would write outside the config's output folder to a path
508
+ * the disk config does not set. Studio can change `output.path`, also inside `override`, and add
509
+ * plugins, so without this check any member of the Studio organization could write any file the agent
510
+ * can reach, including `node_modules` code the next run imports. A path from the config file stays
511
+ * allowed, since a monorepo may write outside its root.
512
+ */
513
+ function assertOutputPaths({ plugins, diskPlugins, root, outputPath }) {
514
+ const base = node_path.default.resolve(root, outputPath);
515
+ const targetsOf = (plugin) => {
516
+ const { output, override } = plugin.options ?? {};
517
+ return [output, ...Array.isArray(override) ? override.map((entry) => entry?.options?.output) : []].flatMap((entry) => typeof entry?.path === "string" ? [node_path.default.resolve(base, entry.path)] : []);
518
+ };
519
+ const diskTargets = new Set(diskPlugins.flatMap(targetsOf));
520
+ for (const plugin of plugins) {
521
+ const target = targetsOf(plugin).find((resolved) => !diskTargets.has(resolved) && !isPathInside(resolved, base));
522
+ if (!target) continue;
523
+ throw new _kubb_core.Diagnostics.Error({
524
+ code: _kubb_core.Diagnostics.code.pathTraversal,
525
+ severity: "error",
526
+ message: `Plugin "${plugin.name}" would write to "${target}", outside the output folder "${base}". Studio can only set an output path inside it.`,
527
+ help: "Choose an output path inside the output folder, or set this path in the config file.",
528
+ location: { kind: "config" },
529
+ plugin: plugin.name
530
+ });
531
+ }
532
+ }
533
+ /**
486
534
  * Re-invokes the factory behind a disk adapter or parser with Studio's options merged over its own.
487
535
  * An instance carries live functions (`parse`, `print`, ...) that cannot survive JSON, so Studio's
488
536
  * options are a patch, not a replacement, and the factory is found from the disk instance itself: the
@@ -1425,6 +1473,33 @@ async function listDisk({ root, outputPath, maxFiles }) {
1425
1473
  paths: new Set(paths)
1426
1474
  };
1427
1475
  }
1476
+ async function readDiskFiles({ files: disk, paths, cursor = 0, limit }) {
1477
+ const files = {};
1478
+ const pages = {};
1479
+ await inParallel({
1480
+ items: paths.filter((path) => disk.paths.has(path)),
1481
+ limit: READ_CONCURRENCY,
1482
+ run: async (path) => {
1483
+ const content = await disk.storage.readItem((0, node_path.resolve)(disk.root, path));
1484
+ if (content === null) return;
1485
+ if (limit === void 0) {
1486
+ files[path] = content;
1487
+ return;
1488
+ }
1489
+ const lines = content.split(/\r\n|\r|\n/);
1490
+ const end = Math.min(cursor + limit, lines.length);
1491
+ files[path] = lines.slice(cursor, end).join("\n");
1492
+ pages[path] = {
1493
+ nextCursor: end < lines.length ? end : null,
1494
+ totalLines: lines.length
1495
+ };
1496
+ }
1497
+ });
1498
+ return limit === void 0 ? { files } : {
1499
+ files,
1500
+ pages
1501
+ };
1502
+ }
1428
1503
  /**
1429
1504
  * Keeps recent generations by job id in `storage`, with an index next to them so a store on disk
1430
1505
  * survives a restart. A generation is only ever looked up by its job id, so on a pooled sandbox one
@@ -1448,7 +1523,7 @@ function createGenerationStore({ storage, maxCount, maxMb, ttlMs, now = Date.now
1448
1523
  /**
1449
1524
  * Copies `files` into the store as one set of `jobId`. Above `maxSetMb` only the hashes are kept.
1450
1525
  */
1451
- async function keep({ jobId, source, files, maxSetMb }) {
1526
+ async function keep({ jobId, source, files, maxSetMb, normalize = source === require_protocol.FileSource.OUTPUT }) {
1452
1527
  const maxSetBytes = maxSetMb * MB;
1453
1528
  const hashes = {};
1454
1529
  let bytes = 0;
@@ -1459,8 +1534,8 @@ function createGenerationStore({ storage, maxCount, maxMb, ttlMs, now = Date.now
1459
1534
  if (path.split("/").includes("..")) return;
1460
1535
  const content = await files.storage.readItem((0, node_path.resolve)(files.root, path));
1461
1536
  if (content === null) return;
1462
- if (source === require_protocol.FileSource.OUTPUT && !content.trim()) return;
1463
- hashes[path] = hashOf(source === require_protocol.FileSource.OUTPUT ? `${content.trim()}\n` : content);
1537
+ if (normalize && !content.trim()) return;
1538
+ hashes[path] = hashOf(normalize ? `${content.trim()}\n` : content);
1464
1539
  bytes += Buffer.byteLength(content);
1465
1540
  if (bytes <= maxSetBytes) await storage.writeItem(`${dirOf(jobId)}${source}/${path}`, content);
1466
1541
  }
@@ -1924,8 +1999,12 @@ var Agent = class extends capnweb.RpcTarget {
1924
1999
  this.#readConfigFileView(source),
1925
2000
  this.#installedExtensions(source)
1926
2001
  ]);
2002
+ const output = await this.#readDiskOutput(config).catch(async (error) => {
2003
+ await this.#warn({ message: `Could not read the output directory: ${getErrorMessage(error)}` });
2004
+ });
1927
2005
  this.#session.connected.resolve(info);
1928
2006
  return {
2007
+ output,
1929
2008
  versions: {
1930
2009
  kubb: version,
1931
2010
  agent: version$1
@@ -1962,6 +2041,16 @@ var Agent = class extends capnweb.RpcTarget {
1962
2041
  permissions: this.#permissions
1963
2042
  };
1964
2043
  }
2044
+ async #readDiskOutput(config) {
2045
+ if (this.#sandbox || !this.#permissions.allowRead || this.#session.active) return void 0;
2046
+ const files = await listDisk({
2047
+ root: this.#options.root,
2048
+ outputPath: config.output.path,
2049
+ maxFiles: DISK_SNAPSHOT_MAX_FILES
2050
+ });
2051
+ if (!files) return void 0;
2052
+ return { files: [...files.paths] };
2053
+ }
1965
2054
  startGeneration(input) {
1966
2055
  const stream = createGenerationStream(this.#options.hooks, input.jobId);
1967
2056
  const controller = new AbortController();
@@ -1987,7 +2076,7 @@ var Agent = class extends capnweb.RpcTarget {
1987
2076
  severity: "error",
1988
2077
  message: "Connection is closed"
1989
2078
  });
1990
- if (this.#session.active) return this.#refuse({ message: "A generation is already in progress, please wait for it to finish" });
2079
+ if (this.#session.active) return this.#refuse({ message: require_protocol.AgentMessage.GENERATION_IN_PROGRESS });
1991
2080
  const settled = Promise.withResolvers();
1992
2081
  this.#session.active = {
1993
2082
  jobId: input.jobId,
@@ -2014,16 +2103,25 @@ var Agent = class extends capnweb.RpcTarget {
2014
2103
  settled.resolve();
2015
2104
  }
2016
2105
  }
2106
+ /** Merges Studio's config over the disk config, refuses output paths outside the output folder, then generates in `root`. */
2017
2107
  async #generate({ input, root, signal }) {
2018
2108
  const { jobId, config: patch } = input;
2019
2109
  const studioInput = this.#sandbox ? patch?.input : void 0;
2020
- if (this.#sandbox && (typeof studioInput !== "string" || !studioInput.trim() || (0, _kubb_core.getInputKind)(studioInput) !== "inline")) return this.#refuse({ message: "A sandbox agent generates only from the OpenAPI document content Studio sends, not from a file path or URL" });
2110
+ if (this.#sandbox && (typeof studioInput !== "string" || !studioInput.trim() || (0, _kubb_core.getInputKind)(studioInput) !== "inline")) return this.#refuse({ message: require_protocol.AgentMessage.SANDBOX_INPUT_ONLY });
2021
2111
  const [config, extensions] = await Promise.all([this.#options.loadConfig(), this.#installedExtensions()]);
2022
2112
  const [plugins, adapter, parsers] = await Promise.all([
2023
2113
  mergePlugins({
2024
2114
  diskPlugins: config.plugins,
2025
2115
  studioPlugins: patch?.plugins,
2026
2116
  extensions
2117
+ }).then((merged) => {
2118
+ assertOutputPaths({
2119
+ plugins: merged ?? [],
2120
+ diskPlugins: config.plugins,
2121
+ root,
2122
+ outputPath: config.output.path
2123
+ });
2124
+ return merged;
2027
2125
  }),
2028
2126
  mergeAdapter(config.adapter, patch?.adapter, extensions),
2029
2127
  mergeParsers({
@@ -2142,7 +2240,7 @@ var Agent = class extends capnweb.RpcTarget {
2142
2240
  message: require_protocol.AgentMessage.READ_AST_NOT_ALLOWED,
2143
2241
  permission: require_protocol.AgentPermission.READ_AST
2144
2242
  });
2145
- if (typeof input.jobId !== "string" || !input.jobId) return this.#refuse({ message: "The request must name a job" });
2243
+ if (typeof input.jobId !== "string" || !input.jobId) return this.#refuse({ message: require_protocol.AgentMessage.JOB_REQUIRED });
2146
2244
  const generation = await this.#generations.get(input.jobId);
2147
2245
  if (!generation) return this.#refuse({ message: require_protocol.AgentMessage.GENERATION_GONE });
2148
2246
  const kept = generation.inputBytes ? await this.#generations.readInput(input.jobId) : void 0;
@@ -2159,14 +2257,32 @@ var Agent = class extends capnweb.RpcTarget {
2159
2257
  message: require_protocol.AgentMessage.READ_NOT_ALLOWED,
2160
2258
  permission: require_protocol.AgentPermission.READ
2161
2259
  });
2162
- if (!Array.isArray(input.paths) || typeof input.jobId !== "string" || !input.jobId) return this.#refuse({ message: "The request must name a job and carry paths" });
2260
+ if (!Array.isArray(input.paths) || typeof input.jobId !== "string" || !input.jobId || !["generation", "currentDisk"].includes(input.jobType)) return this.#refuse({ message: require_protocol.AgentMessage.FILE_REQUEST_INVALID });
2163
2261
  const { paths } = input;
2164
- if (paths.length > 50) return this.#refuse({ message: `At most 50 paths may be requested at once` });
2262
+ if (paths.length > 50) return this.#refuse({ message: require_protocol.AgentMessage.TOO_MANY_FILES });
2263
+ if (!isValidPage(input)) return this.#refuse({ message: require_protocol.AgentMessage.INVALID_PAGE });
2264
+ if (input.jobType === "currentDisk") {
2265
+ if (this.#sandbox) return this.#refuse({ message: require_protocol.AgentMessage.SANDBOX_DISK_UNAVAILABLE });
2266
+ const config = await this.#options.loadConfig();
2267
+ const disk = await listDisk({
2268
+ root: this.#options.root,
2269
+ outputPath: config.output.path,
2270
+ maxFiles: DISK_SNAPSHOT_MAX_FILES
2271
+ });
2272
+ if (!disk) return this.#refuse({ message: require_protocol.AgentMessage.GENERATION_GONE });
2273
+ const result = await readDiskFiles({
2274
+ files: disk,
2275
+ paths,
2276
+ cursor: input.cursor,
2277
+ limit: input.limit
2278
+ });
2279
+ await this.#logCommand("readFiles", `read ${Object.keys(result.files).length}/${paths.length} requested file${paths.length === 1 ? "" : "s"}`);
2280
+ return result;
2281
+ }
2165
2282
  const generation = await this.#generations.get(input.jobId);
2166
2283
  if (!generation) return this.#refuse({ message: require_protocol.AgentMessage.GENERATION_GONE });
2167
- if (!isValidPage(input)) return this.#refuse({ message: `The cursor must be a whole line from 0 and come with a limit of 1 to ${require_protocol.MAX_PAGE_LINES} lines` });
2168
2284
  const source = input.source === require_protocol.FileSource.DISK ? require_protocol.FileSource.DISK : require_protocol.FileSource.OUTPUT;
2169
- if (!generation[source]) return this.#refuse({ message: "This agent kept no snapshot of the files on disk for that job" });
2285
+ if (!generation[source]) return this.#refuse({ message: require_protocol.AgentMessage.NO_DISK_SNAPSHOT });
2170
2286
  const result = await this.#generations.read({
2171
2287
  generation,
2172
2288
  source,
@@ -2187,15 +2303,15 @@ var Agent = class extends capnweb.RpcTarget {
2187
2303
  permission: require_protocol.AgentPermission.READ
2188
2304
  });
2189
2305
  const { jobId, path, base, cursor = 0, limit } = input;
2190
- if (typeof jobId !== "string" || !jobId || typeof path !== "string" || !path || typeof base?.jobId !== "string" || !base.jobId) return this.#refuse({ message: "The request must name a job, a base, and a path" });
2306
+ if (typeof jobId !== "string" || !jobId || typeof path !== "string" || !path || typeof base?.jobId !== "string" || !base.jobId) return this.#refuse({ message: require_protocol.AgentMessage.DIFF_REQUEST_INVALID });
2191
2307
  if (!isValidPage({
2192
2308
  cursor,
2193
2309
  limit
2194
- })) return this.#refuse({ message: `The cursor must be a whole line from 0 and come with a limit of 1 to ${require_protocol.MAX_PAGE_LINES} lines` });
2310
+ })) return this.#refuse({ message: require_protocol.AgentMessage.INVALID_PAGE });
2195
2311
  const [generation, baseGeneration] = await Promise.all([this.#generations.get(jobId), this.#generations.get(base.jobId)]);
2196
2312
  if (!generation || !baseGeneration) return this.#refuse({ message: require_protocol.AgentMessage.GENERATION_GONE });
2197
2313
  const baseSource = base.source === require_protocol.FileSource.DISK ? require_protocol.FileSource.DISK : require_protocol.FileSource.OUTPUT;
2198
- if (!baseGeneration[baseSource]) return this.#refuse({ message: "This agent kept no snapshot of the files on disk for that job" });
2314
+ if (!baseGeneration[baseSource]) return this.#refuse({ message: require_protocol.AgentMessage.NO_DISK_SNAPSHOT });
2199
2315
  const [after, before] = await Promise.all([this.#generations.read({
2200
2316
  generation,
2201
2317
  source: require_protocol.FileSource.OUTPUT,
@@ -2206,7 +2322,7 @@ var Agent = class extends capnweb.RpcTarget {
2206
2322
  paths: [path]
2207
2323
  })]);
2208
2324
  const afterContent = after.files[path];
2209
- if (afterContent === void 0 && path in generation.output.hashes) return this.#refuse({ message: "This agent kept only the hashes of that job" });
2325
+ if (afterContent === void 0 && path in generation.output.hashes) return this.#refuse({ message: require_protocol.AgentMessage.HASHES_ONLY });
2210
2326
  const beforeContent = before.files[path];
2211
2327
  if (beforeContent === void 0) return {
2212
2328
  hunks: [],