@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/dist/index.d.cts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { D as InstalledExtensions, P as PublishedSnapshot, W as SnapshotUploadMetadata, a as AgentConnectInfo, c as AgentPermissions, d as ConfigEditOutcome, s as AgentPermission, u as ConfigEdit } from "./protocol-
|
|
1
|
+
import { D as InstalledExtensions, P as PublishedSnapshot, W as SnapshotUploadMetadata, a as AgentConnectInfo, c as AgentPermissions, d as ConfigEditOutcome, s as AgentPermission, u as ConfigEdit } from "./protocol-DpxvWX9E.cjs";
|
|
2
2
|
import { Config, Hookable, KubbHooks } from "@kubb/core";
|
|
3
3
|
//#region src/connect.d.ts
|
|
4
4
|
/**
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { D as InstalledExtensions, P as PublishedSnapshot, W as SnapshotUploadMetadata, a as AgentConnectInfo, c as AgentPermissions, d as ConfigEditOutcome, s as AgentPermission, u as ConfigEdit } from "./protocol-
|
|
1
|
+
import { D as InstalledExtensions, P as PublishedSnapshot, W as SnapshotUploadMetadata, a as AgentConnectInfo, c as AgentPermissions, d as ConfigEditOutcome, s as AgentPermission, u as ConfigEdit } from "./protocol-DpxvWX9E.js";
|
|
2
2
|
import { Config, Hookable, KubbHooks } from "@kubb/core";
|
|
3
3
|
//#region src/connect.d.ts
|
|
4
4
|
/**
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { AGENT_INSTANCE_HEADER, AGENT_SOCKET_PATH, AgentCloseCode, AgentMessage, AgentPermission, FileSource,
|
|
1
|
+
import { AGENT_INSTANCE_HEADER, AGENT_SOCKET_PATH, AgentCloseCode, AgentMessage, AgentPermission, FileSource, MAX_SNAPSHOT_BYTES, generationEventTypes } from "./protocol.js";
|
|
2
2
|
import { createRequire } from "node:module";
|
|
3
3
|
import { createHash, hash, randomUUID } from "node:crypto";
|
|
4
4
|
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
@@ -116,6 +116,27 @@ async function read(path) {
|
|
|
116
116
|
if (runtime.isBun) return Bun.file(path).text();
|
|
117
117
|
return readFile(path, { encoding: "utf8" });
|
|
118
118
|
}
|
|
119
|
+
/**
|
|
120
|
+
* Resolves to `true` when `path` is `parent` itself or nested inside it. Both sides are resolved
|
|
121
|
+
* to absolute paths first, so relative and `..`-containing inputs compare correctly.
|
|
122
|
+
*
|
|
123
|
+
* Guards a destructive or an out-of-tree operation: before wiping an output directory, check that
|
|
124
|
+
* it does not contain the project root, and before loading a path a caller supplied, check that it
|
|
125
|
+
* did not escape the directory it is allowed to read from.
|
|
126
|
+
*
|
|
127
|
+
* @example
|
|
128
|
+
* isPathInside('./src/gen', '.') // true — nested inside the root
|
|
129
|
+
* isPathInside('.', '.') // true — the same directory counts as inside
|
|
130
|
+
* isPathInside('.', './src/gen') // false — the root is not inside its own output
|
|
131
|
+
* isPathInside('../other', '.') // false — escapes the root
|
|
132
|
+
*/
|
|
133
|
+
function isPathInside(path, parent) {
|
|
134
|
+
const resolvedPath = resolve(path);
|
|
135
|
+
const resolvedParent = resolve(parent);
|
|
136
|
+
if (resolvedPath === resolvedParent) return true;
|
|
137
|
+
const rel = relative(resolvedParent, resolvedPath);
|
|
138
|
+
return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
|
|
139
|
+
}
|
|
119
140
|
//#endregion
|
|
120
141
|
//#region ../../internals/utils/src/promise.ts
|
|
121
142
|
/**
|
|
@@ -154,7 +175,7 @@ function getElapsedMs(hrStart) {
|
|
|
154
175
|
}
|
|
155
176
|
//#endregion
|
|
156
177
|
//#region package.json
|
|
157
|
-
var version = "5.11.
|
|
178
|
+
var version = "5.11.3";
|
|
158
179
|
//#endregion
|
|
159
180
|
//#region src/resolveConfig.ts
|
|
160
181
|
/**
|
|
@@ -459,6 +480,33 @@ async function mergePlugins({ diskPlugins, studioPlugins, extensions = /* @__PUR
|
|
|
459
480
|
})];
|
|
460
481
|
}
|
|
461
482
|
/**
|
|
483
|
+
* Throws `KUBB_PATH_TRAVERSAL` when a plugin would write outside the config's output folder to a path
|
|
484
|
+
* the disk config does not set. Studio can change `output.path`, also inside `override`, and add
|
|
485
|
+
* plugins, so without this check any member of the Studio organization could write any file the agent
|
|
486
|
+
* can reach, including `node_modules` code the next run imports. A path from the config file stays
|
|
487
|
+
* allowed, since a monorepo may write outside its root.
|
|
488
|
+
*/
|
|
489
|
+
function assertOutputPaths({ plugins, diskPlugins, root, outputPath }) {
|
|
490
|
+
const base = path.resolve(root, outputPath);
|
|
491
|
+
const targetsOf = (plugin) => {
|
|
492
|
+
const { output, override } = plugin.options ?? {};
|
|
493
|
+
return [output, ...Array.isArray(override) ? override.map((entry) => entry?.options?.output) : []].flatMap((entry) => typeof entry?.path === "string" ? [path.resolve(base, entry.path)] : []);
|
|
494
|
+
};
|
|
495
|
+
const diskTargets = new Set(diskPlugins.flatMap(targetsOf));
|
|
496
|
+
for (const plugin of plugins) {
|
|
497
|
+
const target = targetsOf(plugin).find((resolved) => !diskTargets.has(resolved) && !isPathInside(resolved, base));
|
|
498
|
+
if (!target) continue;
|
|
499
|
+
throw new Diagnostics.Error({
|
|
500
|
+
code: Diagnostics.code.pathTraversal,
|
|
501
|
+
severity: "error",
|
|
502
|
+
message: `Plugin "${plugin.name}" would write to "${target}", outside the output folder "${base}". Studio can only set an output path inside it.`,
|
|
503
|
+
help: "Choose an output path inside the output folder, or set this path in the config file.",
|
|
504
|
+
location: { kind: "config" },
|
|
505
|
+
plugin: plugin.name
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
/**
|
|
462
510
|
* Re-invokes the factory behind a disk adapter or parser with Studio's options merged over its own.
|
|
463
511
|
* An instance carries live functions (`parse`, `print`, ...) that cannot survive JSON, so Studio's
|
|
464
512
|
* options are a patch, not a replacement, and the factory is found from the disk instance itself: the
|
|
@@ -1401,6 +1449,33 @@ async function listDisk({ root, outputPath, maxFiles }) {
|
|
|
1401
1449
|
paths: new Set(paths)
|
|
1402
1450
|
};
|
|
1403
1451
|
}
|
|
1452
|
+
async function readDiskFiles({ files: disk, paths, cursor = 0, limit }) {
|
|
1453
|
+
const files = {};
|
|
1454
|
+
const pages = {};
|
|
1455
|
+
await inParallel({
|
|
1456
|
+
items: paths.filter((path) => disk.paths.has(path)),
|
|
1457
|
+
limit: READ_CONCURRENCY,
|
|
1458
|
+
run: async (path) => {
|
|
1459
|
+
const content = await disk.storage.readItem(resolve(disk.root, path));
|
|
1460
|
+
if (content === null) return;
|
|
1461
|
+
if (limit === void 0) {
|
|
1462
|
+
files[path] = content;
|
|
1463
|
+
return;
|
|
1464
|
+
}
|
|
1465
|
+
const lines = content.split(/\r\n|\r|\n/);
|
|
1466
|
+
const end = Math.min(cursor + limit, lines.length);
|
|
1467
|
+
files[path] = lines.slice(cursor, end).join("\n");
|
|
1468
|
+
pages[path] = {
|
|
1469
|
+
nextCursor: end < lines.length ? end : null,
|
|
1470
|
+
totalLines: lines.length
|
|
1471
|
+
};
|
|
1472
|
+
}
|
|
1473
|
+
});
|
|
1474
|
+
return limit === void 0 ? { files } : {
|
|
1475
|
+
files,
|
|
1476
|
+
pages
|
|
1477
|
+
};
|
|
1478
|
+
}
|
|
1404
1479
|
/**
|
|
1405
1480
|
* Keeps recent generations by job id in `storage`, with an index next to them so a store on disk
|
|
1406
1481
|
* survives a restart. A generation is only ever looked up by its job id, so on a pooled sandbox one
|
|
@@ -1424,7 +1499,7 @@ function createGenerationStore({ storage, maxCount, maxMb, ttlMs, now = Date.now
|
|
|
1424
1499
|
/**
|
|
1425
1500
|
* Copies `files` into the store as one set of `jobId`. Above `maxSetMb` only the hashes are kept.
|
|
1426
1501
|
*/
|
|
1427
|
-
async function keep({ jobId, source, files, maxSetMb }) {
|
|
1502
|
+
async function keep({ jobId, source, files, maxSetMb, normalize = source === FileSource.OUTPUT }) {
|
|
1428
1503
|
const maxSetBytes = maxSetMb * MB;
|
|
1429
1504
|
const hashes = {};
|
|
1430
1505
|
let bytes = 0;
|
|
@@ -1435,8 +1510,8 @@ function createGenerationStore({ storage, maxCount, maxMb, ttlMs, now = Date.now
|
|
|
1435
1510
|
if (path.split("/").includes("..")) return;
|
|
1436
1511
|
const content = await files.storage.readItem(resolve(files.root, path));
|
|
1437
1512
|
if (content === null) return;
|
|
1438
|
-
if (
|
|
1439
|
-
hashes[path] = hashOf(
|
|
1513
|
+
if (normalize && !content.trim()) return;
|
|
1514
|
+
hashes[path] = hashOf(normalize ? `${content.trim()}\n` : content);
|
|
1440
1515
|
bytes += Buffer.byteLength(content);
|
|
1441
1516
|
if (bytes <= maxSetBytes) await storage.writeItem(`${dirOf(jobId)}${source}/${path}`, content);
|
|
1442
1517
|
}
|
|
@@ -1900,8 +1975,12 @@ var Agent = class extends RpcTarget {
|
|
|
1900
1975
|
this.#readConfigFileView(source),
|
|
1901
1976
|
this.#installedExtensions(source)
|
|
1902
1977
|
]);
|
|
1978
|
+
const output = await this.#readDiskOutput(config).catch(async (error) => {
|
|
1979
|
+
await this.#warn({ message: `Could not read the output directory: ${getErrorMessage(error)}` });
|
|
1980
|
+
});
|
|
1903
1981
|
this.#session.connected.resolve(info);
|
|
1904
1982
|
return {
|
|
1983
|
+
output,
|
|
1905
1984
|
versions: {
|
|
1906
1985
|
kubb: version,
|
|
1907
1986
|
agent: version$1
|
|
@@ -1938,6 +2017,16 @@ var Agent = class extends RpcTarget {
|
|
|
1938
2017
|
permissions: this.#permissions
|
|
1939
2018
|
};
|
|
1940
2019
|
}
|
|
2020
|
+
async #readDiskOutput(config) {
|
|
2021
|
+
if (this.#sandbox || !this.#permissions.allowRead || this.#session.active) return void 0;
|
|
2022
|
+
const files = await listDisk({
|
|
2023
|
+
root: this.#options.root,
|
|
2024
|
+
outputPath: config.output.path,
|
|
2025
|
+
maxFiles: DISK_SNAPSHOT_MAX_FILES
|
|
2026
|
+
});
|
|
2027
|
+
if (!files) return void 0;
|
|
2028
|
+
return { files: [...files.paths] };
|
|
2029
|
+
}
|
|
1941
2030
|
startGeneration(input) {
|
|
1942
2031
|
const stream = createGenerationStream(this.#options.hooks, input.jobId);
|
|
1943
2032
|
const controller = new AbortController();
|
|
@@ -1963,7 +2052,7 @@ var Agent = class extends RpcTarget {
|
|
|
1963
2052
|
severity: "error",
|
|
1964
2053
|
message: "Connection is closed"
|
|
1965
2054
|
});
|
|
1966
|
-
if (this.#session.active) return this.#refuse({ message:
|
|
2055
|
+
if (this.#session.active) return this.#refuse({ message: AgentMessage.GENERATION_IN_PROGRESS });
|
|
1967
2056
|
const settled = Promise.withResolvers();
|
|
1968
2057
|
this.#session.active = {
|
|
1969
2058
|
jobId: input.jobId,
|
|
@@ -1990,16 +2079,25 @@ var Agent = class extends RpcTarget {
|
|
|
1990
2079
|
settled.resolve();
|
|
1991
2080
|
}
|
|
1992
2081
|
}
|
|
2082
|
+
/** Merges Studio's config over the disk config, refuses output paths outside the output folder, then generates in `root`. */
|
|
1993
2083
|
async #generate({ input, root, signal }) {
|
|
1994
2084
|
const { jobId, config: patch } = input;
|
|
1995
2085
|
const studioInput = this.#sandbox ? patch?.input : void 0;
|
|
1996
|
-
if (this.#sandbox && (typeof studioInput !== "string" || !studioInput.trim() || getInputKind(studioInput) !== "inline")) return this.#refuse({ message:
|
|
2086
|
+
if (this.#sandbox && (typeof studioInput !== "string" || !studioInput.trim() || getInputKind(studioInput) !== "inline")) return this.#refuse({ message: AgentMessage.SANDBOX_INPUT_ONLY });
|
|
1997
2087
|
const [config, extensions] = await Promise.all([this.#options.loadConfig(), this.#installedExtensions()]);
|
|
1998
2088
|
const [plugins, adapter, parsers] = await Promise.all([
|
|
1999
2089
|
mergePlugins({
|
|
2000
2090
|
diskPlugins: config.plugins,
|
|
2001
2091
|
studioPlugins: patch?.plugins,
|
|
2002
2092
|
extensions
|
|
2093
|
+
}).then((merged) => {
|
|
2094
|
+
assertOutputPaths({
|
|
2095
|
+
plugins: merged ?? [],
|
|
2096
|
+
diskPlugins: config.plugins,
|
|
2097
|
+
root,
|
|
2098
|
+
outputPath: config.output.path
|
|
2099
|
+
});
|
|
2100
|
+
return merged;
|
|
2003
2101
|
}),
|
|
2004
2102
|
mergeAdapter(config.adapter, patch?.adapter, extensions),
|
|
2005
2103
|
mergeParsers({
|
|
@@ -2118,7 +2216,7 @@ var Agent = class extends RpcTarget {
|
|
|
2118
2216
|
message: AgentMessage.READ_AST_NOT_ALLOWED,
|
|
2119
2217
|
permission: AgentPermission.READ_AST
|
|
2120
2218
|
});
|
|
2121
|
-
if (typeof input.jobId !== "string" || !input.jobId) return this.#refuse({ message:
|
|
2219
|
+
if (typeof input.jobId !== "string" || !input.jobId) return this.#refuse({ message: AgentMessage.JOB_REQUIRED });
|
|
2122
2220
|
const generation = await this.#generations.get(input.jobId);
|
|
2123
2221
|
if (!generation) return this.#refuse({ message: AgentMessage.GENERATION_GONE });
|
|
2124
2222
|
const kept = generation.inputBytes ? await this.#generations.readInput(input.jobId) : void 0;
|
|
@@ -2135,14 +2233,32 @@ var Agent = class extends RpcTarget {
|
|
|
2135
2233
|
message: AgentMessage.READ_NOT_ALLOWED,
|
|
2136
2234
|
permission: AgentPermission.READ
|
|
2137
2235
|
});
|
|
2138
|
-
if (!Array.isArray(input.paths) || typeof input.jobId !== "string" || !input.jobId) return this.#refuse({ message:
|
|
2236
|
+
if (!Array.isArray(input.paths) || typeof input.jobId !== "string" || !input.jobId || !["generation", "currentDisk"].includes(input.jobType)) return this.#refuse({ message: AgentMessage.FILE_REQUEST_INVALID });
|
|
2139
2237
|
const { paths } = input;
|
|
2140
|
-
if (paths.length > 50) return this.#refuse({ message:
|
|
2238
|
+
if (paths.length > 50) return this.#refuse({ message: AgentMessage.TOO_MANY_FILES });
|
|
2239
|
+
if (!isValidPage(input)) return this.#refuse({ message: AgentMessage.INVALID_PAGE });
|
|
2240
|
+
if (input.jobType === "currentDisk") {
|
|
2241
|
+
if (this.#sandbox) return this.#refuse({ message: AgentMessage.SANDBOX_DISK_UNAVAILABLE });
|
|
2242
|
+
const config = await this.#options.loadConfig();
|
|
2243
|
+
const disk = await listDisk({
|
|
2244
|
+
root: this.#options.root,
|
|
2245
|
+
outputPath: config.output.path,
|
|
2246
|
+
maxFiles: DISK_SNAPSHOT_MAX_FILES
|
|
2247
|
+
});
|
|
2248
|
+
if (!disk) return this.#refuse({ message: AgentMessage.GENERATION_GONE });
|
|
2249
|
+
const result = await readDiskFiles({
|
|
2250
|
+
files: disk,
|
|
2251
|
+
paths,
|
|
2252
|
+
cursor: input.cursor,
|
|
2253
|
+
limit: input.limit
|
|
2254
|
+
});
|
|
2255
|
+
await this.#logCommand("readFiles", `read ${Object.keys(result.files).length}/${paths.length} requested file${paths.length === 1 ? "" : "s"}`);
|
|
2256
|
+
return result;
|
|
2257
|
+
}
|
|
2141
2258
|
const generation = await this.#generations.get(input.jobId);
|
|
2142
2259
|
if (!generation) return this.#refuse({ message: AgentMessage.GENERATION_GONE });
|
|
2143
|
-
if (!isValidPage(input)) return this.#refuse({ message: `The cursor must be a whole line from 0 and come with a limit of 1 to ${MAX_PAGE_LINES} lines` });
|
|
2144
2260
|
const source = input.source === FileSource.DISK ? FileSource.DISK : FileSource.OUTPUT;
|
|
2145
|
-
if (!generation[source]) return this.#refuse({ message:
|
|
2261
|
+
if (!generation[source]) return this.#refuse({ message: AgentMessage.NO_DISK_SNAPSHOT });
|
|
2146
2262
|
const result = await this.#generations.read({
|
|
2147
2263
|
generation,
|
|
2148
2264
|
source,
|
|
@@ -2163,15 +2279,15 @@ var Agent = class extends RpcTarget {
|
|
|
2163
2279
|
permission: AgentPermission.READ
|
|
2164
2280
|
});
|
|
2165
2281
|
const { jobId, path, base, cursor = 0, limit } = input;
|
|
2166
|
-
if (typeof jobId !== "string" || !jobId || typeof path !== "string" || !path || typeof base?.jobId !== "string" || !base.jobId) return this.#refuse({ message:
|
|
2282
|
+
if (typeof jobId !== "string" || !jobId || typeof path !== "string" || !path || typeof base?.jobId !== "string" || !base.jobId) return this.#refuse({ message: AgentMessage.DIFF_REQUEST_INVALID });
|
|
2167
2283
|
if (!isValidPage({
|
|
2168
2284
|
cursor,
|
|
2169
2285
|
limit
|
|
2170
|
-
})) return this.#refuse({ message:
|
|
2286
|
+
})) return this.#refuse({ message: AgentMessage.INVALID_PAGE });
|
|
2171
2287
|
const [generation, baseGeneration] = await Promise.all([this.#generations.get(jobId), this.#generations.get(base.jobId)]);
|
|
2172
2288
|
if (!generation || !baseGeneration) return this.#refuse({ message: AgentMessage.GENERATION_GONE });
|
|
2173
2289
|
const baseSource = base.source === FileSource.DISK ? FileSource.DISK : FileSource.OUTPUT;
|
|
2174
|
-
if (!baseGeneration[baseSource]) return this.#refuse({ message:
|
|
2290
|
+
if (!baseGeneration[baseSource]) return this.#refuse({ message: AgentMessage.NO_DISK_SNAPSHOT });
|
|
2175
2291
|
const [after, before] = await Promise.all([this.#generations.read({
|
|
2176
2292
|
generation,
|
|
2177
2293
|
source: FileSource.OUTPUT,
|
|
@@ -2182,7 +2298,7 @@ var Agent = class extends RpcTarget {
|
|
|
2182
2298
|
paths: [path]
|
|
2183
2299
|
})]);
|
|
2184
2300
|
const afterContent = after.files[path];
|
|
2185
|
-
if (afterContent === void 0 && path in generation.output.hashes) return this.#refuse({ message:
|
|
2301
|
+
if (afterContent === void 0 && path in generation.output.hashes) return this.#refuse({ message: AgentMessage.HASHES_ONLY });
|
|
2186
2302
|
const beforeContent = before.files[path];
|
|
2187
2303
|
if (beforeContent === void 0) return {
|
|
2188
2304
|
hunks: [],
|