@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 +5 -1
- package/dist/index.cjs +131 -15
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +132 -16
- package/dist/index.js.map +1 -1
- package/dist/{protocol-CrMJymjT.d.ts → protocol-DpxvWX9E.d.cts} +28 -12
- package/dist/{protocol-CrMJymjT.d.cts → protocol-DpxvWX9E.d.ts} +28 -12
- package/dist/protocol.cjs +12 -2
- package/dist/protocol.cjs.map +1 -1
- package/dist/protocol.d.cts +1 -1
- package/dist/protocol.d.ts +1 -1
- package/dist/protocol.js +12 -2
- package/dist/protocol.js.map +1 -1
- package/package.json +4 -4
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
|
|
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.
|
|
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 (
|
|
1463
|
-
hashes[path] = hashOf(
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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: [],
|