agentlas 0.4.0 → 0.5.5
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 +112 -23
- package/bin/agentlas.cjs +55 -8
- package/engine/agentlas-api-agent.cjs +1 -1
- package/engine/agentlas-banner.cjs +66 -51
- package/engine/agentlas-capabilities.cjs +3 -0
- package/engine/agentlas-cloud-runtime.cjs +65 -11
- package/engine/agentlas-composer.cjs +109 -44
- package/engine/agentlas-doctor.cjs +65 -14
- package/engine/agentlas-i18n.cjs +132 -12
- package/engine/agentlas-input.cjs +123 -19
- package/engine/agentlas-native-host.cjs +381 -83
- package/engine/agentlas-parity.cjs +373 -53
- package/engine/agentlas-permissions.cjs +90 -0
- package/engine/agentlas-repl.cjs +149 -47
- package/engine/agentlas-tasks.cjs +111 -0
- package/engine/agentlas-tools.cjs +174 -12
- package/engine/agentlas-ui.cjs +349 -24
- package/engine/agentlas.cjs +3074 -379
- package/engine/architecture.data.json +5 -1
- package/engine/semver.cjs +64 -0
- package/package.json +1 -1
- package/test/bootstrap-race.cjs +47 -0
- package/test/capture-runtime-guard.cjs +122 -0
- package/test/cloud-asset-restore.cjs +423 -0
- package/test/cloud-cas-client.cjs +333 -0
- package/test/cloud-owner-restore.cjs +183 -0
- package/test/cloud-runtime-paths.cjs +40 -0
- package/test/cloud-save-publish.cjs +453 -0
- package/test/credential-env-regression.cjs +52 -0
- package/test/login-loopback-security.cjs +115 -0
- package/test/mcp-config-isolation.cjs +36 -0
- package/test/permission-mapping.cjs +180 -0
- package/test/run-api-regression.cjs +322 -0
- package/test/runtime-env-protection.cjs +45 -0
- package/test/semver-precedence.cjs +39 -0
- package/test/smoke.sh +33 -0
- package/test/sqlite-driver-probe.cjs +22 -0
- package/test/terminal-ui-regression.cjs +454 -0
- package/test/timeout-regression.cjs +218 -0
- package/test/tool-workspace-boundary.cjs +165 -0
- package/test/update-safety.cjs +376 -0
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
const assert = require("node:assert/strict");
|
|
3
|
+
const crypto = require("node:crypto");
|
|
4
|
+
const { execFileSync } = require("node:child_process");
|
|
5
|
+
const fs = require("node:fs");
|
|
6
|
+
const http = require("node:http");
|
|
7
|
+
const os = require("node:os");
|
|
8
|
+
const path = require("node:path");
|
|
9
|
+
|
|
10
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "agentlas-cloud-save-publish-"));
|
|
11
|
+
process.env.AGENTLAS_USER_DATA_DIR = path.join(tempDir, "user-data");
|
|
12
|
+
process.env.AGENTLAS_SESSION = "test-owner-session";
|
|
13
|
+
|
|
14
|
+
const {
|
|
15
|
+
cloudActionForTopLevelUpload,
|
|
16
|
+
cloudHashPackage,
|
|
17
|
+
cloudPortableExecutableForFile,
|
|
18
|
+
cloudPortablePathConflict,
|
|
19
|
+
cloudVisibilityForAction,
|
|
20
|
+
packageCloudAgentCli,
|
|
21
|
+
} = require("../engine/agentlas.cjs");
|
|
22
|
+
|
|
23
|
+
function writePrivateNotes(root) {
|
|
24
|
+
fs.mkdirSync(root, { recursive: true });
|
|
25
|
+
fs.writeFileSync(path.join(root, "notes.md"), "Owner-private agent working notes.\n", "utf8");
|
|
26
|
+
fs.writeFileSync(path.join(root, "asset.bin"), Buffer.from([0x00, 0xff, 0x81, 0x41, 0x00]));
|
|
27
|
+
fs.writeFileSync(path.join(root, "run.sh"), "#!/bin/sh\nexit 0\n", { mode: 0o700 });
|
|
28
|
+
fs.chmodSync(path.join(root, "run.sh"), 0o700);
|
|
29
|
+
fs.writeFileSync(path.join(root, ".agentlas-cloud-package.json"), JSON.stringify({
|
|
30
|
+
packageHash: "local-only-marker",
|
|
31
|
+
packageHashVersion: "path-sha256-executable-v2",
|
|
32
|
+
executablePaths: ["run.sh"],
|
|
33
|
+
}));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function writePublicAgent(root) {
|
|
37
|
+
writePrivateNotes(root);
|
|
38
|
+
fs.mkdirSync(path.join(root, ".agentlas"), { recursive: true });
|
|
39
|
+
fs.writeFileSync(path.join(root, "AGENTS.md"), "# Public Test Agent\n\nRun the public test task.\n", "utf8");
|
|
40
|
+
fs.writeFileSync(
|
|
41
|
+
path.join(root, ".agentlas", "routing-card.json"),
|
|
42
|
+
JSON.stringify({
|
|
43
|
+
schemaVersion: "routing-card/2.0",
|
|
44
|
+
id: "public-test-agent",
|
|
45
|
+
type: "agent",
|
|
46
|
+
name: "Public Test Agent",
|
|
47
|
+
summary: "Routes public test requests.",
|
|
48
|
+
capabilities: ["public_test"],
|
|
49
|
+
routing_status: "routing_ready",
|
|
50
|
+
}, null, 2) + "\n",
|
|
51
|
+
"utf8",
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function listen(server) {
|
|
56
|
+
return new Promise((resolve, reject) => {
|
|
57
|
+
server.once("error", reject);
|
|
58
|
+
server.listen(0, "127.0.0.1", () => resolve(server.address()));
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function close(server) {
|
|
63
|
+
return new Promise((resolve) => server.close(resolve));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
(async () => {
|
|
67
|
+
const requests = [];
|
|
68
|
+
const requestHeaders = [];
|
|
69
|
+
const server = http.createServer((req, res) => {
|
|
70
|
+
const chunks = [];
|
|
71
|
+
req.on("data", (chunk) => chunks.push(chunk));
|
|
72
|
+
req.on("end", () => {
|
|
73
|
+
requests.push(JSON.parse(Buffer.concat(chunks).toString("utf8")));
|
|
74
|
+
requestHeaders.push(req.headers);
|
|
75
|
+
if (requests.at(-1).manifest.slug === "invalid-receipt-agent") {
|
|
76
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
77
|
+
res.end(JSON.stringify({ cloudId: "synthetic-success-must-not-be-accepted" }));
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const revision = `rev-${requests.length}-${requests.at(-1).manifest.packageHash.slice(0, 16)}`;
|
|
81
|
+
res.writeHead(200, {
|
|
82
|
+
"content-type": "application/json",
|
|
83
|
+
"cache-control": "no-store",
|
|
84
|
+
etag: `"${revision}"`,
|
|
85
|
+
});
|
|
86
|
+
res.end(JSON.stringify({
|
|
87
|
+
schema: "agentlas.agent_cloud.registration.v1",
|
|
88
|
+
operation: "created",
|
|
89
|
+
source: requests.at(-1).visibility === "marketplace" ? "hub" : "agent-cloud",
|
|
90
|
+
visibility: requests.at(-1).visibility === "marketplace" ? "marketplace" : "owner-private",
|
|
91
|
+
scope: requests.at(-1).visibility === "marketplace" ? "hub-public" : "owner-private",
|
|
92
|
+
owner: true,
|
|
93
|
+
publicHubPublished: requests.at(-1).visibility === "marketplace",
|
|
94
|
+
cloudId: `cloud-test-${requests.length}`,
|
|
95
|
+
slug: requests.at(-1).manifest.slug,
|
|
96
|
+
packageHash: requests.at(-1).manifest.packageHash,
|
|
97
|
+
packageHashVersion: requests.at(-1).manifest.packageHashVersion,
|
|
98
|
+
revision,
|
|
99
|
+
url: `http://agent-cloud.test/owned/${requests.at(-1).manifest.slug}`,
|
|
100
|
+
marketplaceUrl: requests.at(-1).visibility === "marketplace"
|
|
101
|
+
? `http://agent-cloud.test/hub/${requests.at(-1).manifest.slug}`
|
|
102
|
+
: undefined,
|
|
103
|
+
registeredAt: new Date().toISOString(),
|
|
104
|
+
dryRun: false,
|
|
105
|
+
}));
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
const address = await listen(server);
|
|
111
|
+
process.env.AGENTLAS_WEB_BASE_URL = `http://127.0.0.1:${address.port}`;
|
|
112
|
+
|
|
113
|
+
assert.equal(cloudVisibilityForAction("package", { _: [] }), "private-link");
|
|
114
|
+
assert.equal(cloudVisibilityForAction("save", { _: [] }), "private-link");
|
|
115
|
+
assert.equal(cloudVisibilityForAction("save", { _: [], visibility: "private-link" }), "private-link");
|
|
116
|
+
assert.equal(cloudVisibilityForAction("publish", { _: [] }), "marketplace");
|
|
117
|
+
assert.equal(cloudVisibilityForAction("publish", { _: [], visibility: "marketplace" }), "marketplace");
|
|
118
|
+
assert.throws(
|
|
119
|
+
() => cloudVisibilityForAction("save", { _: [], visibility: "marketplace" }),
|
|
120
|
+
/owner-private/,
|
|
121
|
+
);
|
|
122
|
+
assert.throws(
|
|
123
|
+
() => cloudVisibilityForAction("publish", { _: [], visibility: "private-link" }),
|
|
124
|
+
/public Hub publication/,
|
|
125
|
+
);
|
|
126
|
+
assert.equal(cloudActionForTopLevelUpload(["/tmp/agent"]), "save");
|
|
127
|
+
assert.equal(cloudActionForTopLevelUpload(["/tmp/agent", "--visibility", "private-link"]), "save");
|
|
128
|
+
assert.equal(cloudActionForTopLevelUpload(["/tmp/agent", "--visibility", "marketplace"]), "publish");
|
|
129
|
+
|
|
130
|
+
const privateRoot = path.join(tempDir, "private-notes-only");
|
|
131
|
+
writePrivateNotes(privateRoot);
|
|
132
|
+
const privateDryRun = await packageCloudAgentCli(null, privateRoot, {
|
|
133
|
+
dryRun: true,
|
|
134
|
+
llmReview: true,
|
|
135
|
+
});
|
|
136
|
+
assert.equal(privateDryRun.status, "dry-run");
|
|
137
|
+
assert.equal(privateDryRun.manifest.visibility, "private-link");
|
|
138
|
+
assert.equal(privateDryRun.review.mode, "static-only");
|
|
139
|
+
assert.equal(privateDryRun.review.costOwner, "none");
|
|
140
|
+
assert.equal(privateDryRun.review.verdict, "pass");
|
|
141
|
+
assert.equal(privateDryRun.review.findings.some((finding) => finding.id === "missing-agent-definition"), false);
|
|
142
|
+
assert.equal(privateDryRun.review.findings.some((finding) => finding.id.startsWith("routing-card")), false);
|
|
143
|
+
const privateBundle = JSON.parse(fs.readFileSync(privateDryRun.bundlePath, "utf8"));
|
|
144
|
+
assert.equal(privateBundle.manifest.packageHashVersion, "path-sha256-executable-v2");
|
|
145
|
+
assert.equal(
|
|
146
|
+
privateBundle.manifest.packageHash,
|
|
147
|
+
cloudHashPackage(privateBundle.files, privateBundle.manifest.packageHashVersion),
|
|
148
|
+
);
|
|
149
|
+
assert.equal(
|
|
150
|
+
privateBundle.manifest.rootFingerprint,
|
|
151
|
+
crypto.createHash("sha256").update(`agentlas-package-root:${privateBundle.manifest.packageHash}`).digest("hex"),
|
|
152
|
+
"root fingerprint must be content-derived and match Desktop",
|
|
153
|
+
);
|
|
154
|
+
const binary = privateBundle.files.find((file) => file.path === "asset.bin");
|
|
155
|
+
assert.deepEqual(Buffer.from(binary.contentBase64, "base64"), Buffer.from([0x00, 0xff, 0x81, 0x41, 0x00]));
|
|
156
|
+
assert.equal(binary.executable, false);
|
|
157
|
+
assert.equal(privateBundle.files.find((file) => file.path === "run.sh")?.executable, true);
|
|
158
|
+
assert.equal(privateBundle.files.some((file) => file.path === ".agentlas-cloud-package.json"), false);
|
|
159
|
+
assert.equal(
|
|
160
|
+
cloudPortablePathConflict(["Skills/writer/SKILL.md", "skills/reviewer/SKILL.md"])?.code,
|
|
161
|
+
"path-alias-collision",
|
|
162
|
+
);
|
|
163
|
+
assert.equal(
|
|
164
|
+
cloudPortablePathConflict(["Caf\u00e9/a.md", "Cafe\u0301/b.md"])?.code,
|
|
165
|
+
"path-alias-collision",
|
|
166
|
+
);
|
|
167
|
+
assert.equal(
|
|
168
|
+
cloudPortableExecutableForFile("run.sh", 0, new Set(["run.sh"]), "win32"),
|
|
169
|
+
true,
|
|
170
|
+
"Windows re-save must recover the portable bit from restore metadata",
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
const symlinkRoot = path.join(tempDir, "private-symlink-agent");
|
|
174
|
+
writePrivateNotes(symlinkRoot);
|
|
175
|
+
const outsideFile = path.join(tempDir, "outside-secret.txt");
|
|
176
|
+
fs.writeFileSync(outsideFile, "must not follow this link\n", "utf8");
|
|
177
|
+
fs.symlinkSync(outsideFile, path.join(symlinkRoot, "outside-link.txt"));
|
|
178
|
+
const symlinkBlocked = await packageCloudAgentCli(null, symlinkRoot, {
|
|
179
|
+
dryRun: true,
|
|
180
|
+
llmReview: false,
|
|
181
|
+
});
|
|
182
|
+
assert.equal(symlinkBlocked.status, "blocked");
|
|
183
|
+
assert.ok(symlinkBlocked.review.findings.some((finding) => finding.id.startsWith("symlink-")));
|
|
184
|
+
|
|
185
|
+
const rootSymlink = path.join(tempDir, "private-root-link");
|
|
186
|
+
try {
|
|
187
|
+
fs.symlinkSync(privateRoot, rootSymlink, "dir");
|
|
188
|
+
await assert.rejects(
|
|
189
|
+
packageCloudAgentCli(null, rootSymlink, { dryRun: true, llmReview: false }),
|
|
190
|
+
/실제 폴더가 아닙니다/,
|
|
191
|
+
);
|
|
192
|
+
} catch (error) {
|
|
193
|
+
if (!error || !["EPERM", "EACCES"].includes(error.code)) throw error;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const outsideRaceSecret = path.join(tempDir, "outside-race-secret.txt");
|
|
197
|
+
fs.writeFileSync(outsideRaceSecret, "glpat-abcdefghijklmnopqrstuvwxyz123456\n", "utf8");
|
|
198
|
+
const swapRoot = path.join(tempDir, "file-swap-agent");
|
|
199
|
+
writePrivateNotes(swapRoot);
|
|
200
|
+
const swapTarget = path.join(swapRoot, "zz-race.txt");
|
|
201
|
+
fs.writeFileSync(swapTarget, "safe captured bytes\n", "utf8");
|
|
202
|
+
const originalOpenSync = fs.openSync;
|
|
203
|
+
let fileSwapped = false;
|
|
204
|
+
fs.openSync = function patchedOpenSync(file, flags, ...rest) {
|
|
205
|
+
if (!fileSwapped && String(file).endsWith(`${path.sep}zz-race.txt`)) {
|
|
206
|
+
fileSwapped = true;
|
|
207
|
+
fs.renameSync(swapTarget, `${swapTarget}.original`);
|
|
208
|
+
fs.symlinkSync(outsideRaceSecret, swapTarget);
|
|
209
|
+
}
|
|
210
|
+
return originalOpenSync.call(fs, file, flags, ...rest);
|
|
211
|
+
};
|
|
212
|
+
let swapBlocked;
|
|
213
|
+
try {
|
|
214
|
+
swapBlocked = await packageCloudAgentCli(null, swapRoot, { dryRun: true, llmReview: false });
|
|
215
|
+
} finally {
|
|
216
|
+
fs.openSync = originalOpenSync;
|
|
217
|
+
}
|
|
218
|
+
assert.equal(swapBlocked.status, "blocked", JSON.stringify(swapBlocked.review.findings));
|
|
219
|
+
assert.ok(swapBlocked.review.findings.some((finding) => finding.id.startsWith("unstable-file")));
|
|
220
|
+
const swapBundle = JSON.parse(fs.readFileSync(swapBlocked.bundlePath, "utf8"));
|
|
221
|
+
assert.equal(JSON.stringify(swapBundle).includes(Buffer.from("glpat-abcdefghijklmnopqrstuvwxyz123456\n").toString("base64")), false);
|
|
222
|
+
|
|
223
|
+
const growthRoot = path.join(tempDir, "file-growth-agent");
|
|
224
|
+
writePrivateNotes(growthRoot);
|
|
225
|
+
const growthTarget = path.join(growthRoot, "zz-growth.txt");
|
|
226
|
+
fs.writeFileSync(growthTarget, "stable start\n", "utf8");
|
|
227
|
+
const originalReadSync = fs.readSync;
|
|
228
|
+
let growthFd = null;
|
|
229
|
+
let grew = false;
|
|
230
|
+
fs.openSync = function captureGrowthFd(file, flags, ...rest) {
|
|
231
|
+
const fd = originalOpenSync.call(fs, file, flags, ...rest);
|
|
232
|
+
if (String(file).endsWith(`${path.sep}zz-growth.txt`) && (Number(flags) & 3) === fs.constants.O_RDONLY) growthFd = fd;
|
|
233
|
+
return fd;
|
|
234
|
+
};
|
|
235
|
+
fs.readSync = function growAfterFirstRead(fd, ...args) {
|
|
236
|
+
const read = originalReadSync.call(fs, fd, ...args);
|
|
237
|
+
if (!grew && fd === growthFd && read > 0) {
|
|
238
|
+
grew = true;
|
|
239
|
+
const appendFd = originalOpenSync(growthTarget, fs.constants.O_WRONLY | fs.constants.O_APPEND);
|
|
240
|
+
try { fs.writeSync(appendFd, Buffer.from("changed during scan\n")); } finally { fs.closeSync(appendFd); }
|
|
241
|
+
}
|
|
242
|
+
return read;
|
|
243
|
+
};
|
|
244
|
+
let growthBlocked;
|
|
245
|
+
try {
|
|
246
|
+
growthBlocked = await packageCloudAgentCli(null, growthRoot, { dryRun: true, llmReview: false });
|
|
247
|
+
} finally {
|
|
248
|
+
fs.openSync = originalOpenSync;
|
|
249
|
+
fs.readSync = originalReadSync;
|
|
250
|
+
}
|
|
251
|
+
assert.equal(growthBlocked.status, "blocked");
|
|
252
|
+
assert.ok(growthBlocked.review.findings.some((finding) => finding.id.startsWith("unstable-file")));
|
|
253
|
+
|
|
254
|
+
const directorySwapRoot = path.join(tempDir, "directory-swap-agent");
|
|
255
|
+
writePrivateNotes(directorySwapRoot);
|
|
256
|
+
const nested = path.join(directorySwapRoot, "nested");
|
|
257
|
+
fs.mkdirSync(nested);
|
|
258
|
+
fs.writeFileSync(path.join(nested, "safe.txt"), "safe\n", "utf8");
|
|
259
|
+
const outsideDirectory = path.join(tempDir, "outside-directory");
|
|
260
|
+
fs.mkdirSync(outsideDirectory);
|
|
261
|
+
fs.writeFileSync(path.join(outsideDirectory, "safe.txt"), "outside must not enter\n", "utf8");
|
|
262
|
+
const originalReadDirSync = fs.readdirSync;
|
|
263
|
+
let directorySwapped = false;
|
|
264
|
+
fs.readdirSync = function swapDirectoryAfterListing(dir, options) {
|
|
265
|
+
const entries = originalReadDirSync.call(fs, dir, options);
|
|
266
|
+
if (!directorySwapped && String(dir).endsWith(`${path.sep}directory-swap-agent`)) {
|
|
267
|
+
directorySwapped = true;
|
|
268
|
+
fs.renameSync(nested, `${nested}.original`);
|
|
269
|
+
fs.symlinkSync(outsideDirectory, nested, "dir");
|
|
270
|
+
}
|
|
271
|
+
return entries;
|
|
272
|
+
};
|
|
273
|
+
let directoryBlocked;
|
|
274
|
+
try {
|
|
275
|
+
directoryBlocked = await packageCloudAgentCli(null, directorySwapRoot, { dryRun: true, llmReview: false });
|
|
276
|
+
} finally {
|
|
277
|
+
fs.readdirSync = originalReadDirSync;
|
|
278
|
+
}
|
|
279
|
+
assert.equal(directoryBlocked.status, "blocked");
|
|
280
|
+
assert.ok(directoryBlocked.review.findings.some((finding) => /unsafe-directory|unstable-directory/.test(finding.id)));
|
|
281
|
+
|
|
282
|
+
if (process.platform !== "win32") {
|
|
283
|
+
const fifoRoot = path.join(tempDir, "fifo-agent");
|
|
284
|
+
writePrivateNotes(fifoRoot);
|
|
285
|
+
execFileSync("mkfifo", [path.join(fifoRoot, "blocked.pipe")]);
|
|
286
|
+
const fifoBlocked = await packageCloudAgentCli(null, fifoRoot, { dryRun: true, llmReview: false });
|
|
287
|
+
assert.equal(fifoBlocked.status, "blocked");
|
|
288
|
+
assert.ok(fifoBlocked.review.findings.some((finding) => finding.id.startsWith("unsupported-entry")));
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const requestsBeforeSecretGates = requests.length;
|
|
292
|
+
const unquotedSecretRoot = path.join(tempDir, "unquoted-secret-agent");
|
|
293
|
+
writePrivateNotes(unquotedSecretRoot);
|
|
294
|
+
fs.writeFileSync(path.join(unquotedSecretRoot, "config.yaml"), "password: hunter2secret\n", "utf8");
|
|
295
|
+
const unquotedSecret = await packageCloudAgentCli(null, unquotedSecretRoot, { dryRun: false, llmReview: false });
|
|
296
|
+
assert.equal(unquotedSecret.status, "blocked");
|
|
297
|
+
assert.ok(unquotedSecret.review.findings.some((finding) => finding.id.startsWith("generic-unquoted-secret")));
|
|
298
|
+
|
|
299
|
+
const utf16SecretRoot = path.join(tempDir, "utf16-secret-agent");
|
|
300
|
+
writePrivateNotes(utf16SecretRoot);
|
|
301
|
+
fs.writeFileSync(
|
|
302
|
+
path.join(utf16SecretRoot, "settings.ps1"),
|
|
303
|
+
Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from("api_key=unquoted-secret-value-123456\r\n", "utf16le")]),
|
|
304
|
+
);
|
|
305
|
+
const utf16Secret = await packageCloudAgentCli(null, utf16SecretRoot, { dryRun: false, llmReview: false });
|
|
306
|
+
assert.equal(utf16Secret.status, "blocked");
|
|
307
|
+
|
|
308
|
+
const bomlessUtf16SecretRoot = path.join(tempDir, "bomless-utf16-secret-agent");
|
|
309
|
+
writePrivateNotes(bomlessUtf16SecretRoot);
|
|
310
|
+
fs.writeFileSync(
|
|
311
|
+
path.join(bomlessUtf16SecretRoot, "opaque.payload"),
|
|
312
|
+
Buffer.from(`${"A".repeat(5000)}\napi_key=unquoted-secret-value-123456\n`, "utf16le"),
|
|
313
|
+
);
|
|
314
|
+
const bomlessUtf16Secret = await packageCloudAgentCli(null, bomlessUtf16SecretRoot, { dryRun: false, llmReview: false });
|
|
315
|
+
assert.equal(bomlessUtf16Secret.status, "blocked");
|
|
316
|
+
|
|
317
|
+
const binarySecretRoot = path.join(tempDir, "binary-secret-agent");
|
|
318
|
+
writePrivateNotes(binarySecretRoot);
|
|
319
|
+
fs.writeFileSync(path.join(binarySecretRoot, "opaque.payload"), Buffer.from([0x00, ...Buffer.from("glpat-abcdefghijklmnopqrstuvwxyz123456"), 0xff]));
|
|
320
|
+
const binarySecret = await packageCloudAgentCli(null, binarySecretRoot, { dryRun: false, llmReview: false });
|
|
321
|
+
assert.equal(binarySecret.status, "blocked");
|
|
322
|
+
assert.ok(binarySecret.review.findings.some((finding) => finding.id.startsWith("gitlab-token")));
|
|
323
|
+
assert.equal(requests.length, requestsBeforeSecretGates, "blocked secret packages must perform zero registration fetches");
|
|
324
|
+
|
|
325
|
+
const placeholderRoot = path.join(tempDir, "placeholder-agent");
|
|
326
|
+
writePrivateNotes(placeholderRoot);
|
|
327
|
+
fs.writeFileSync(path.join(placeholderRoot, "config.yaml"), "password: configure_on_this_machine\napi_key: ${API_KEY}\n", "utf8");
|
|
328
|
+
const placeholderPackage = await packageCloudAgentCli(null, placeholderRoot, { dryRun: true, llmReview: false });
|
|
329
|
+
assert.equal(placeholderPackage.status, "dry-run");
|
|
330
|
+
|
|
331
|
+
const privateSaved = await packageCloudAgentCli(null, privateRoot, {
|
|
332
|
+
dryRun: false,
|
|
333
|
+
llmReview: false,
|
|
334
|
+
});
|
|
335
|
+
assert.equal(privateSaved.status, "registered");
|
|
336
|
+
assert.match(privateSaved.summary, /Saved .* privately in Agent Cloud/);
|
|
337
|
+
|
|
338
|
+
const invalidReceiptRoot = path.join(tempDir, "invalid-receipt-agent");
|
|
339
|
+
writePrivateNotes(invalidReceiptRoot);
|
|
340
|
+
await assert.rejects(
|
|
341
|
+
packageCloudAgentCli(null, invalidReceiptRoot, {
|
|
342
|
+
slug: "invalid-receipt-agent",
|
|
343
|
+
dryRun: false,
|
|
344
|
+
llmReview: false,
|
|
345
|
+
}),
|
|
346
|
+
/invalid or mismatched registration receipt/,
|
|
347
|
+
"malformed HTTP 2xx must never become synthetic registration success",
|
|
348
|
+
);
|
|
349
|
+
|
|
350
|
+
const publicWithoutRoutingRoot = path.join(tempDir, "public-without-routing");
|
|
351
|
+
fs.mkdirSync(publicWithoutRoutingRoot, { recursive: true });
|
|
352
|
+
fs.writeFileSync(path.join(publicWithoutRoutingRoot, "AGENTS.md"), "# Missing Routing\n", "utf8");
|
|
353
|
+
const publicBlocked = await packageCloudAgentCli(null, publicWithoutRoutingRoot, {
|
|
354
|
+
visibility: "marketplace",
|
|
355
|
+
dryRun: true,
|
|
356
|
+
llmReview: false,
|
|
357
|
+
});
|
|
358
|
+
assert.equal(publicBlocked.status, "blocked");
|
|
359
|
+
assert.ok(publicBlocked.review.findings.some((finding) => finding.id === "routing-card-required"));
|
|
360
|
+
|
|
361
|
+
const publicRoot = path.join(tempDir, "public-agent");
|
|
362
|
+
writePublicAgent(publicRoot);
|
|
363
|
+
const publicPublished = await packageCloudAgentCli(null, publicRoot, {
|
|
364
|
+
visibility: "marketplace",
|
|
365
|
+
dryRun: false,
|
|
366
|
+
llmReview: false,
|
|
367
|
+
});
|
|
368
|
+
assert.equal(publicPublished.status, "registered");
|
|
369
|
+
assert.match(publicPublished.summary, /Published .* publicly to Agentlas Hub/);
|
|
370
|
+
|
|
371
|
+
const publicCareerRoot = path.join(tempDir, "public-career-agent");
|
|
372
|
+
writePublicAgent(publicCareerRoot);
|
|
373
|
+
const rawCareerCard = {
|
|
374
|
+
kind: "agentlas-public-career-card",
|
|
375
|
+
schemaVersion: "1",
|
|
376
|
+
projectName: "Career fixture",
|
|
377
|
+
privacy: {
|
|
378
|
+
rawLocalPathsIncluded: false,
|
|
379
|
+
rawPromptsIncluded: false,
|
|
380
|
+
rawTranscriptsIncluded: false,
|
|
381
|
+
sourceTextIncluded: false,
|
|
382
|
+
},
|
|
383
|
+
counts: { evidence: 3 },
|
|
384
|
+
generatorInternal: { rawSourceId: "must-not-leave-host" },
|
|
385
|
+
};
|
|
386
|
+
fs.writeFileSync(
|
|
387
|
+
path.join(publicCareerRoot, ".agentlas", "public-career-card.json"),
|
|
388
|
+
JSON.stringify(rawCareerCard, null, 2) + "\n",
|
|
389
|
+
"utf8",
|
|
390
|
+
);
|
|
391
|
+
const publicCareer = await packageCloudAgentCli(null, publicCareerRoot, {
|
|
392
|
+
visibility: "marketplace",
|
|
393
|
+
dryRun: true,
|
|
394
|
+
llmReview: false,
|
|
395
|
+
});
|
|
396
|
+
assert.equal(publicCareer.status, "dry-run");
|
|
397
|
+
const publicCareerBundle = JSON.parse(fs.readFileSync(publicCareer.bundlePath, "utf8"));
|
|
398
|
+
const sanitizedCareerFile = publicCareerBundle.files.find((file) => file.path === ".agentlas/public-career-card.json");
|
|
399
|
+
assert.ok(sanitizedCareerFile);
|
|
400
|
+
const sanitizedCareer = JSON.parse(Buffer.from(sanitizedCareerFile.contentBase64, "base64").toString("utf8"));
|
|
401
|
+
assert.equal("generatorInternal" in sanitizedCareer, false);
|
|
402
|
+
assert.deepEqual(sanitizedCareer, publicCareerBundle.manifest.careerGraph);
|
|
403
|
+
assert.deepEqual(sanitizedCareer, publicCareerBundle.careerGraph);
|
|
404
|
+
|
|
405
|
+
const requestsBeforeLeakyCareer = requests.length;
|
|
406
|
+
const leakyCareerRoot = path.join(tempDir, "leaky-career-agent");
|
|
407
|
+
writePublicAgent(leakyCareerRoot);
|
|
408
|
+
fs.writeFileSync(
|
|
409
|
+
path.join(leakyCareerRoot, ".agentlas", "public-career-card.json"),
|
|
410
|
+
JSON.stringify({ ...rawCareerCard, generatorInternal: { sourcePath: "/Users/private/career.sqlite" } }, null, 2) + "\n",
|
|
411
|
+
"utf8",
|
|
412
|
+
);
|
|
413
|
+
const leakyCareer = await packageCloudAgentCli(null, leakyCareerRoot, {
|
|
414
|
+
visibility: "marketplace",
|
|
415
|
+
dryRun: false,
|
|
416
|
+
llmReview: false,
|
|
417
|
+
});
|
|
418
|
+
assert.equal(leakyCareer.status, "blocked");
|
|
419
|
+
assert.ok(leakyCareer.review.findings.some((finding) => finding.id === "career-card-local-path"));
|
|
420
|
+
const leakyCareerBundle = JSON.parse(fs.readFileSync(leakyCareer.bundlePath, "utf8"));
|
|
421
|
+
assert.equal(leakyCareerBundle.files.some((file) => file.path === ".agentlas/public-career-card.json"), false);
|
|
422
|
+
assert.equal(requests.length, requestsBeforeLeakyCareer, "blocked Career Graph packages must perform zero registration fetches");
|
|
423
|
+
|
|
424
|
+
const secretRoot = path.join(tempDir, "secret-agent");
|
|
425
|
+
writePrivateNotes(secretRoot);
|
|
426
|
+
fs.writeFileSync(path.join(secretRoot, ".env"), "TOKEN=not-a-real-secret-for-tests\n", "utf8");
|
|
427
|
+
const secretBlocked = await packageCloudAgentCli(null, secretRoot, {
|
|
428
|
+
dryRun: true,
|
|
429
|
+
llmReview: false,
|
|
430
|
+
});
|
|
431
|
+
assert.equal(secretBlocked.status, "blocked");
|
|
432
|
+
assert.ok(secretBlocked.review.findings.some((finding) => finding.category === "secret"));
|
|
433
|
+
|
|
434
|
+
assert.equal(requests.length, 3);
|
|
435
|
+
assert.equal(requests[0].visibility, "private-link");
|
|
436
|
+
assert.equal(requests[0].manifest.visibility, "private-link");
|
|
437
|
+
assert.equal(requests[0].manifest.packageHashVersion, "path-sha256-executable-v2");
|
|
438
|
+
assert.equal(requests[0].manifest.routingCard, undefined);
|
|
439
|
+
assert.equal(requestHeaders[0]["if-none-match"], "*");
|
|
440
|
+
assert.equal(requestHeaders[0]["if-match"], undefined);
|
|
441
|
+
assert.equal(requests[2].visibility, "marketplace");
|
|
442
|
+
assert.equal(requests[2].manifest.visibility, "marketplace");
|
|
443
|
+
assert.equal(requests[2].manifest.routingCard.schemaVersion, "routing-card/2.0");
|
|
444
|
+
|
|
445
|
+
console.log("cloud private-save/public-publish: PASS");
|
|
446
|
+
} finally {
|
|
447
|
+
await close(server).catch(() => {});
|
|
448
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
449
|
+
}
|
|
450
|
+
})().catch((error) => {
|
|
451
|
+
console.error(error);
|
|
452
|
+
process.exitCode = 1;
|
|
453
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
const assert = require("node:assert/strict");
|
|
5
|
+
const fs = require("node:fs");
|
|
6
|
+
const os = require("node:os");
|
|
7
|
+
const path = require("node:path");
|
|
8
|
+
|
|
9
|
+
const root = path.resolve(__dirname, "..");
|
|
10
|
+
const terminal = require(path.join(root, "engine", "agentlas.cjs"));
|
|
11
|
+
const tools = require(path.join(root, "engine", "agentlas-tools.cjs"));
|
|
12
|
+
const temp = fs.mkdtempSync(path.join(os.tmpdir(), "agentlas-credential-env-"));
|
|
13
|
+
|
|
14
|
+
try {
|
|
15
|
+
const shellCwd = path.join(temp, "project");
|
|
16
|
+
fs.mkdirSync(shellCwd, { recursive: true });
|
|
17
|
+
assert.equal(
|
|
18
|
+
terminal.resolveCredentialSourcePath("keys/service.json", shellCwd),
|
|
19
|
+
path.join(shellCwd, "keys", "service.json"),
|
|
20
|
+
"relative credential source must resolve from the caller cwd",
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
const credentials = path.join(temp, "credentials.env");
|
|
24
|
+
terminal.upsertEnvLine(credentials, "SERVICE_TOKEN", "first");
|
|
25
|
+
terminal.upsertEnvLine(credentials, "SERVICE_TOKEN", "second");
|
|
26
|
+
assert.equal(fs.readFileSync(credentials, "utf8"), "SERVICE_TOKEN=second\n");
|
|
27
|
+
if (process.platform !== "win32") {
|
|
28
|
+
assert.equal(fs.statSync(credentials).mode & 0o777, 0o600, "credential env must be owner-only");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const before = process.env.AGENTLAS_SCOPE_TEST;
|
|
32
|
+
const result = tools.runTool(
|
|
33
|
+
"bash",
|
|
34
|
+
{ command: 'printf %s "$AGENTLAS_SCOPE_TEST"' },
|
|
35
|
+
{
|
|
36
|
+
cwd: shellCwd,
|
|
37
|
+
permission: "full",
|
|
38
|
+
env: { ...process.env, AGENTLAS_SCOPE_TEST: "turn-only" },
|
|
39
|
+
},
|
|
40
|
+
);
|
|
41
|
+
assert.equal(result.ok, true);
|
|
42
|
+
assert.match(result.content, /turn-only/);
|
|
43
|
+
assert.equal(process.env.AGENTLAS_SCOPE_TEST, before, "turn env must not leak into the host process");
|
|
44
|
+
|
|
45
|
+
const replSource = fs.readFileSync(path.join(root, "engine", "agentlas-repl.cjs"), "utf8");
|
|
46
|
+
assert.equal(replSource.includes("Object.assign(process.env"), false);
|
|
47
|
+
assert.match(replSource, /ctx:\s*\{ \.\.\.ctx, env: runEnv \}/);
|
|
48
|
+
|
|
49
|
+
console.log(JSON.stringify({ ok: true, checks: 8 }, null, 2));
|
|
50
|
+
} finally {
|
|
51
|
+
fs.rmSync(temp, { recursive: true, force: true });
|
|
52
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
const assert = require("node:assert/strict");
|
|
5
|
+
const { create, _test } = require("../engine/agentlas-parity.cjs");
|
|
6
|
+
|
|
7
|
+
const parity = create({});
|
|
8
|
+
|
|
9
|
+
function startAttempt(options = {}) {
|
|
10
|
+
let readyResolve;
|
|
11
|
+
let readyReject;
|
|
12
|
+
const ready = new Promise((resolve, reject) => {
|
|
13
|
+
readyResolve = resolve;
|
|
14
|
+
readyReject = reject;
|
|
15
|
+
});
|
|
16
|
+
const result = parity.waitForLoopbackSession({
|
|
17
|
+
baseUrl: "https://agentlas.cloud",
|
|
18
|
+
timeoutMs: options.timeoutMs || 1_000,
|
|
19
|
+
onLoginUrl(url) {
|
|
20
|
+
readyResolve(url);
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
void result.catch((error) => readyReject(error));
|
|
24
|
+
return { ready, result };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function callbackFromLoginUrl(loginUrl) {
|
|
28
|
+
const login = new URL(loginUrl);
|
|
29
|
+
assert.equal(login.origin, "https://agentlas.cloud");
|
|
30
|
+
assert.equal(login.pathname, "/account");
|
|
31
|
+
assert.equal(login.searchParams.get("desktop"), "1");
|
|
32
|
+
const rawCallback = login.searchParams.get("callback");
|
|
33
|
+
assert.ok(rawCallback, "Hub login URL must carry the loopback callback");
|
|
34
|
+
const callback = new URL(rawCallback);
|
|
35
|
+
assert.equal(callback.hostname, "127.0.0.1");
|
|
36
|
+
assert.equal(callback.pathname, "/callback");
|
|
37
|
+
assert.match(callback.searchParams.get("state") || "", /^[A-Za-z0-9_-]{43}$/);
|
|
38
|
+
return callback;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function readResponse(response) {
|
|
42
|
+
await response.text();
|
|
43
|
+
return response;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function main() {
|
|
47
|
+
const stateA = _test.createLoginState();
|
|
48
|
+
const stateB = _test.createLoginState();
|
|
49
|
+
assert.match(stateA, /^[A-Za-z0-9_-]{43}$/);
|
|
50
|
+
assert.notEqual(stateA, stateB, "each login must use a fresh cryptographic state nonce");
|
|
51
|
+
|
|
52
|
+
// Pure transaction guard: exact path only, valid response consumes once, replay is rejected.
|
|
53
|
+
const guard = _test.createLoginCallbackGuard(stateA);
|
|
54
|
+
assert.equal(guard.consume(`/callback-extra?state=${stateA}&session=evil`).statusCode, 404);
|
|
55
|
+
assert.equal(guard.isConsumed(), false, "unrelated paths must not consume the transaction");
|
|
56
|
+
assert.equal(guard.consume(`/callback?state=${stateA}&session=first`).value, "first");
|
|
57
|
+
assert.equal(guard.isConsumed(), true);
|
|
58
|
+
assert.equal(guard.consume(`/callback?state=${stateA}&session=second`).statusCode, 410, "callback replay must fail");
|
|
59
|
+
|
|
60
|
+
const mismatchGuard = _test.createLoginCallbackGuard(stateB);
|
|
61
|
+
const mismatch = mismatchGuard.consume(`/callback?state=wrong&session=attacker`);
|
|
62
|
+
assert.equal(mismatch.ok, false);
|
|
63
|
+
assert.match(mismatch.message, /state/);
|
|
64
|
+
assert.equal(mismatchGuard.consume(`/callback?state=${stateB}&session=late`).statusCode, 410, "state mismatch must close the transaction");
|
|
65
|
+
|
|
66
|
+
const errorGuard = _test.createLoginCallbackGuard(stateB);
|
|
67
|
+
assert.match(errorGuard.consume(`/callback?state=${stateB}&error=access_denied`).message, /access_denied/);
|
|
68
|
+
const missingGuard = _test.createLoginCallbackGuard(stateB);
|
|
69
|
+
assert.match(missingGuard.consume(`/callback?state=${stateB}`).message, /session/);
|
|
70
|
+
const tokenGuard = _test.createLoginCallbackGuard(stateB);
|
|
71
|
+
assert.equal(tokenGuard.consume(`/callback?state=${stateB}&token=legacy-compatible`).value, "legacy-compatible");
|
|
72
|
+
|
|
73
|
+
// Real loopback server: unrelated path and non-GET do not consume; correct callback succeeds.
|
|
74
|
+
const success = startAttempt();
|
|
75
|
+
const successCallback = callbackFromLoginUrl(await success.ready);
|
|
76
|
+
const unrelated = new URL("/callback-extra", successCallback.origin);
|
|
77
|
+
unrelated.searchParams.set("state", successCallback.searchParams.get("state"));
|
|
78
|
+
unrelated.searchParams.set("session", "evil");
|
|
79
|
+
assert.equal((await readResponse(await fetch(unrelated))).status, 404);
|
|
80
|
+
assert.equal((await readResponse(await fetch(successCallback, { method: "POST" }))).status, 405);
|
|
81
|
+
successCallback.searchParams.set("session", "valid-hub-session");
|
|
82
|
+
const successResponse = await readResponse(await fetch(successCallback));
|
|
83
|
+
assert.equal(successResponse.status, 200);
|
|
84
|
+
assert.match(successResponse.headers.get("cache-control") || "", /no-store/);
|
|
85
|
+
assert.equal(await success.result, "valid-hub-session");
|
|
86
|
+
|
|
87
|
+
// A forged state is never persisted and terminates this login attempt.
|
|
88
|
+
const forged = startAttempt();
|
|
89
|
+
const forgedCallback = callbackFromLoginUrl(await forged.ready);
|
|
90
|
+
forgedCallback.searchParams.set("state", "forged-state");
|
|
91
|
+
forgedCallback.searchParams.set("session", "attacker-session");
|
|
92
|
+
assert.equal((await readResponse(await fetch(forgedCallback))).status, 400);
|
|
93
|
+
await assert.rejects(forged.result, /state/);
|
|
94
|
+
|
|
95
|
+
// Hub/OAuth errors with the correct state also fail closed.
|
|
96
|
+
const denied = startAttempt();
|
|
97
|
+
const deniedCallback = callbackFromLoginUrl(await denied.ready);
|
|
98
|
+
deniedCallback.searchParams.set("error", "access_denied");
|
|
99
|
+
deniedCallback.searchParams.set("error_description", "must-not-be-reflected");
|
|
100
|
+
const deniedResponse = await readResponse(await fetch(deniedCallback));
|
|
101
|
+
assert.equal(deniedResponse.status, 400);
|
|
102
|
+
await assert.rejects(denied.result, /access_denied/);
|
|
103
|
+
|
|
104
|
+
// Timeout closes the listener and never returns a session.
|
|
105
|
+
const timedOut = startAttempt({ timeoutMs: 25 });
|
|
106
|
+
callbackFromLoginUrl(await timedOut.ready);
|
|
107
|
+
await assert.rejects(timedOut.result, /대기 시간/);
|
|
108
|
+
|
|
109
|
+
console.log("login-loopback-security: PASS");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
main().catch((error) => {
|
|
113
|
+
console.error(error);
|
|
114
|
+
process.exitCode = 1;
|
|
115
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
const assert = require("node:assert/strict");
|
|
5
|
+
const fs = require("node:fs");
|
|
6
|
+
const os = require("node:os");
|
|
7
|
+
const path = require("node:path");
|
|
8
|
+
|
|
9
|
+
const temp = fs.mkdtempSync(path.join(os.tmpdir(), "agentlas-mcp-config-"));
|
|
10
|
+
process.env.AGENTLAS_USER_DATA_DIR = temp;
|
|
11
|
+
const host = require("../engine/agentlas-native-host.cjs");
|
|
12
|
+
|
|
13
|
+
try {
|
|
14
|
+
const alpha = host.cliMcpConfigPath([
|
|
15
|
+
{ id: "alpha", name: "alpha", transport: "stdio", command: "alpha-mcp", args_json: '["--a"]', enabled: 1 },
|
|
16
|
+
]);
|
|
17
|
+
const beta = host.cliMcpConfigPath([
|
|
18
|
+
{ id: "beta", name: "beta", transport: "stdio", command: "beta-mcp", args_json: '["--b"]', enabled: 1 },
|
|
19
|
+
]);
|
|
20
|
+
assert.notEqual(alpha.file, beta.file, "different MCP sets must not race on one filename");
|
|
21
|
+
assert.equal(JSON.parse(fs.readFileSync(alpha.file, "utf8")).mcpServers.alpha.command, "alpha-mcp");
|
|
22
|
+
assert.equal(JSON.parse(fs.readFileSync(beta.file, "utf8")).mcpServers.beta.command, "beta-mcp");
|
|
23
|
+
assert.equal(host.cliMcpConfigPath([{ id: "alpha", name: "alpha", transport: "stdio", command: "alpha-mcp", args_json: '["--a"]', enabled: 1 }]).file, alpha.file);
|
|
24
|
+
if (process.platform !== "win32") {
|
|
25
|
+
assert.equal(fs.statSync(path.dirname(alpha.file)).mode & 0o777, 0o700);
|
|
26
|
+
assert.equal(fs.statSync(alpha.file).mode & 0o777, 0o600);
|
|
27
|
+
}
|
|
28
|
+
const codex = host.codexMcpArgs([{ id: "alpha", name: "alpha", transport: "stdio", command: "alpha-mcp", args_json: '["--a"]', enabled: 1 }]);
|
|
29
|
+
assert.ok(codex.some((value) => value.includes("mcp_servers.alpha.command")));
|
|
30
|
+
const mainSource = fs.readFileSync(path.join(__dirname, "../engine/agentlas.cjs"), "utf8");
|
|
31
|
+
assert.match(mainSource, /agentlas-native-host\.cjs"\)\.cliMcpConfigPath/);
|
|
32
|
+
assert.equal(mainSource.includes('path.join(dir, "agentlas-cli-mcp.json")'), false);
|
|
33
|
+
console.log(JSON.stringify({ ok: true, checks: 9 }, null, 2));
|
|
34
|
+
} finally {
|
|
35
|
+
fs.rmSync(temp, { recursive: true, force: true });
|
|
36
|
+
}
|