leduo-patrol 1.0.0
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 +217 -0
- package/dist/server/__tests__/access-key.test.js +25 -0
- package/dist/server/__tests__/acp-session.test.js +54 -0
- package/dist/server/__tests__/network.test.js +28 -0
- package/dist/server/__tests__/server-helpers.test.js +18 -0
- package/dist/server/__tests__/session-manager.test.js +152 -0
- package/dist/server/access-key.js +40 -0
- package/dist/server/acp-session.js +300 -0
- package/dist/server/git-diff.js +124 -0
- package/dist/server/index.js +313 -0
- package/dist/server/network.js +62 -0
- package/dist/server/server-helpers.js +23 -0
- package/dist/server/session-manager.js +778 -0
- package/dist/server/shell-session.js +84 -0
- package/dist/web/assets/addon-fit-DX4qG4td.js +1 -0
- package/dist/web/assets/brand-icon.png +0 -0
- package/dist/web/assets/index-BbPJ87hi.js +33 -0
- package/dist/web/assets/index-yhylkmhc.css +1 -0
- package/dist/web/assets/xterm-B-qIQCd3.js +16 -0
- package/dist/web/index.html +14 -0
- package/package.json +53 -0
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import express from "express";
|
|
3
|
+
import { access, readdir } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { createRequire } from "node:module";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { createServer } from "node:http";
|
|
8
|
+
import { WebSocket, WebSocketServer } from "ws";
|
|
9
|
+
import { SessionManager } from "./session-manager.js";
|
|
10
|
+
import { formatError, resolveAllowedPath } from "./server-helpers.js";
|
|
11
|
+
import { ShellSession } from "./shell-session.js";
|
|
12
|
+
import { buildSingleFileDiff, buildWorkspaceDiffFilesSnapshot } from "./git-diff.js";
|
|
13
|
+
import { buildAccessCookie, createAccessKey, hasAuthorizedAccessCookie, isAccessKeyAuthorized } from "./access-key.js";
|
|
14
|
+
import { findAvailablePort, pickPreferredLanIp } from "./network.js";
|
|
15
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
const launchCwd = process.cwd();
|
|
17
|
+
const defaultWorkspacePath = process.env.LEDUO_PATROL_WORKSPACE_PATH ?? launchCwd;
|
|
18
|
+
const allowedRoots = (process.env.LEDUO_PATROL_ALLOWED_ROOTS
|
|
19
|
+
?.split(",")
|
|
20
|
+
.map((entry) => entry.trim())
|
|
21
|
+
.filter(Boolean) ?? [launchCwd]).map((entry) => path.resolve(entry));
|
|
22
|
+
const appName = process.env.LEDUO_PATROL_APP_NAME ?? "乐多汪汪队";
|
|
23
|
+
const sshHost = process.env.LEDUO_PATROL_SSH_HOST ?? "";
|
|
24
|
+
const sshPath = process.env.LEDUO_PATROL_SSH_PATH ?? defaultWorkspacePath;
|
|
25
|
+
const vscodeRemoteUri = process.env.LEDUO_PATROL_VSCODE_URI ??
|
|
26
|
+
(sshHost ? `vscode://vscode-remote/ssh-remote+${encodeURIComponent(sshHost)}${sshPath}` : "");
|
|
27
|
+
const requestedPort = Number(process.env.PORT ?? 3001);
|
|
28
|
+
const devWebPort = Number(process.env.LEDUO_PATROL_WEB_PORT ?? 5173);
|
|
29
|
+
const isDevServer = process.env.npm_lifecycle_event === "dev:server";
|
|
30
|
+
const agentBinPath = resolveAgentBinPath();
|
|
31
|
+
const accessKey = process.env.LEDUO_PATROL_ACCESS_KEY?.trim() || createAccessKey();
|
|
32
|
+
const enableShell = process.env.LEDUO_ENABLE_SHELL === "true";
|
|
33
|
+
const app = express();
|
|
34
|
+
const server = createServer(app);
|
|
35
|
+
const wss = new WebSocketServer({ server, path: "/ws" });
|
|
36
|
+
const sessionManager = new SessionManager({
|
|
37
|
+
allowedRoots,
|
|
38
|
+
agentBinPath,
|
|
39
|
+
});
|
|
40
|
+
await sessionManager.initialize();
|
|
41
|
+
if (!process.env.LEDUO_PATROL_WORKSPACE_PATH) {
|
|
42
|
+
console.log(`LEDUO_PATROL_WORKSPACE_PATH not set, defaulting to current directory: ${defaultWorkspacePath}`);
|
|
43
|
+
console.log("Tip: set LEDUO_PATROL_WORKSPACE_PATH to customize the default workspace.");
|
|
44
|
+
}
|
|
45
|
+
if (!process.env.LEDUO_PATROL_ALLOWED_ROOTS) {
|
|
46
|
+
console.log(`LEDUO_PATROL_ALLOWED_ROOTS not set, defaulting to current directory: ${allowedRoots.join(",")}`);
|
|
47
|
+
console.log("Tip: set LEDUO_PATROL_ALLOWED_ROOTS (comma-separated) to customize allowed roots.");
|
|
48
|
+
}
|
|
49
|
+
app.use((req, res, next) => {
|
|
50
|
+
const authorizedByQuery = isAccessKeyAuthorized(req.originalUrl, accessKey);
|
|
51
|
+
const authorizedByCookie = hasAuthorizedAccessCookie(req.headers.cookie, accessKey);
|
|
52
|
+
const isApiRequest = req.path.startsWith("/api/");
|
|
53
|
+
if (authorizedByQuery) {
|
|
54
|
+
res.setHeader("Set-Cookie", buildAccessCookie(accessKey));
|
|
55
|
+
}
|
|
56
|
+
if (!isApiRequest) {
|
|
57
|
+
next();
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (authorizedByQuery || authorizedByCookie) {
|
|
61
|
+
next();
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
res.status(401).json({
|
|
65
|
+
message: "Unauthorized: invalid access key",
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
app.get("/api/config", (_req, res) => {
|
|
69
|
+
res.json({
|
|
70
|
+
appName,
|
|
71
|
+
workspacePath: defaultWorkspacePath,
|
|
72
|
+
allowedRoots,
|
|
73
|
+
sshHost,
|
|
74
|
+
sshPath,
|
|
75
|
+
vscodeRemoteUri,
|
|
76
|
+
enableShell,
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
app.get("/api/state", (_req, res) => {
|
|
80
|
+
res.json(sessionManager.getStateSnapshot());
|
|
81
|
+
});
|
|
82
|
+
app.get("/api/session-history", (req, res) => {
|
|
83
|
+
try {
|
|
84
|
+
const clientSessionId = typeof req.query.clientSessionId === "string" ? req.query.clientSessionId : "";
|
|
85
|
+
const before = Number(req.query.before ?? 0);
|
|
86
|
+
const limit = Number(req.query.limit ?? 120);
|
|
87
|
+
if (!clientSessionId) {
|
|
88
|
+
throw new Error("clientSessionId is required");
|
|
89
|
+
}
|
|
90
|
+
res.json(sessionManager.getSessionHistory(clientSessionId, before, limit));
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
res.status(400).json({
|
|
94
|
+
message: formatError(error),
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
app.get("/api/session-diff/files", async (req, res) => {
|
|
99
|
+
try {
|
|
100
|
+
const clientSessionId = typeof req.query.clientSessionId === "string" ? req.query.clientSessionId : "";
|
|
101
|
+
if (!clientSessionId) {
|
|
102
|
+
throw new Error("clientSessionId is required");
|
|
103
|
+
}
|
|
104
|
+
const workspacePath = sessionManager.getSessionWorkspacePath(clientSessionId);
|
|
105
|
+
const diffSnapshot = await buildWorkspaceDiffFilesSnapshot(workspacePath);
|
|
106
|
+
res.json(diffSnapshot);
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
res.status(400).json({
|
|
110
|
+
message: formatError(error),
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
app.get("/api/session-diff/file", async (req, res) => {
|
|
115
|
+
try {
|
|
116
|
+
const clientSessionId = typeof req.query.clientSessionId === "string" ? req.query.clientSessionId : "";
|
|
117
|
+
const category = typeof req.query.category === "string" ? req.query.category : "";
|
|
118
|
+
const filePath = typeof req.query.filePath === "string" ? req.query.filePath : "";
|
|
119
|
+
if (!clientSessionId) {
|
|
120
|
+
throw new Error("clientSessionId is required");
|
|
121
|
+
}
|
|
122
|
+
if (!["workingTree", "staged", "untracked"].includes(category)) {
|
|
123
|
+
throw new Error("category is invalid");
|
|
124
|
+
}
|
|
125
|
+
if (!filePath.trim()) {
|
|
126
|
+
throw new Error("filePath is required");
|
|
127
|
+
}
|
|
128
|
+
const workspacePath = sessionManager.getSessionWorkspacePath(clientSessionId);
|
|
129
|
+
const fileDiff = await buildSingleFileDiff(workspacePath, category, filePath);
|
|
130
|
+
res.json(fileDiff);
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
res.status(400).json({
|
|
134
|
+
message: formatError(error),
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
app.get("/api/directories", async (req, res) => {
|
|
139
|
+
try {
|
|
140
|
+
const requestedRoot = typeof req.query.root === "string" ? req.query.root : defaultWorkspacePath;
|
|
141
|
+
const resolvedRoot = resolveAllowedPath(requestedRoot, allowedRoots);
|
|
142
|
+
const entries = await readdir(resolvedRoot, { withFileTypes: true });
|
|
143
|
+
const directories = entries
|
|
144
|
+
.filter((entry) => entry.isDirectory())
|
|
145
|
+
.map((entry) => ({
|
|
146
|
+
name: entry.name,
|
|
147
|
+
path: path.join(resolvedRoot, entry.name),
|
|
148
|
+
}))
|
|
149
|
+
.sort((left, right) => left.name.localeCompare(right.name, "zh-Hans-CN"));
|
|
150
|
+
res.json({
|
|
151
|
+
rootPath: resolvedRoot,
|
|
152
|
+
directories,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
res.status(400).json({
|
|
157
|
+
message: formatError(error),
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
const webDistPath = path.resolve(__dirname, "../web");
|
|
162
|
+
const hasBundledWeb = await hasReadableFile(path.resolve(webDistPath, "index.html"));
|
|
163
|
+
if (hasBundledWeb) {
|
|
164
|
+
app.use(express.static(webDistPath));
|
|
165
|
+
app.get("/{*rest}", (_req, res) => {
|
|
166
|
+
res.sendFile(path.resolve(webDistPath, "index.html"));
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
app.get("/{*rest}", (_req, res) => {
|
|
171
|
+
res.status(503).send(`<!doctype html><html><body><h2>Web assets not found</h2><p>Missing bundled web at <code>${webDistPath}</code>.</p><p>Run <code>npm run build</code> before <code>npm start</code>, or use <code>npm run dev</code> for development.</p></body></html>`);
|
|
172
|
+
});
|
|
173
|
+
console.log(`Bundled web assets not found at ${webDistPath}.`);
|
|
174
|
+
console.log(`Tip: run \"npm run build\" before \"npm start\".`);
|
|
175
|
+
}
|
|
176
|
+
wss.on("connection", (socket, request) => {
|
|
177
|
+
if (!isAccessKeyAuthorized(request.url, accessKey)) {
|
|
178
|
+
socket.close(1008, "Unauthorized");
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
const unsubscribe = sessionManager.subscribe((event) => sendEvent(socket, event));
|
|
182
|
+
let shellSession = null;
|
|
183
|
+
socket.on("message", async (raw) => {
|
|
184
|
+
try {
|
|
185
|
+
const message = JSON.parse(String(raw));
|
|
186
|
+
switch (message.type) {
|
|
187
|
+
case "hello":
|
|
188
|
+
sendEvent(socket, {
|
|
189
|
+
type: "ready",
|
|
190
|
+
payload: { workspacePath: defaultWorkspacePath, agentConnected: sessionManager.getStateSnapshot().sessions.length > 0 },
|
|
191
|
+
});
|
|
192
|
+
break;
|
|
193
|
+
case "create_session":
|
|
194
|
+
await sessionManager.createSession(message.payload.workspacePath, message.payload.title, message.payload.modeId);
|
|
195
|
+
break;
|
|
196
|
+
case "prompt":
|
|
197
|
+
await sessionManager.prompt(message.payload.clientSessionId, message.payload.text, message.payload.modeId, message.payload.images);
|
|
198
|
+
break;
|
|
199
|
+
case "set_mode":
|
|
200
|
+
await sessionManager.setSessionMode(message.payload.clientSessionId, message.payload.modeId);
|
|
201
|
+
break;
|
|
202
|
+
case "cancel":
|
|
203
|
+
await sessionManager.cancel(message.payload.clientSessionId);
|
|
204
|
+
break;
|
|
205
|
+
case "permission":
|
|
206
|
+
await sessionManager.resolvePermission(message.payload.clientSessionId, message.payload.requestId, message.payload.optionId, message.payload.note);
|
|
207
|
+
break;
|
|
208
|
+
case "close_session":
|
|
209
|
+
await sessionManager.closeSession(message.payload.clientSessionId);
|
|
210
|
+
break;
|
|
211
|
+
case "shell_start": {
|
|
212
|
+
if (!enableShell) {
|
|
213
|
+
throw new Error("Shell feature is disabled. Set LEDUO_ENABLE_SHELL=true to enable it.");
|
|
214
|
+
}
|
|
215
|
+
shellSession?.kill();
|
|
216
|
+
shellSession = null;
|
|
217
|
+
const cols = Math.max(2, message.payload.cols);
|
|
218
|
+
const rows = Math.max(2, message.payload.rows);
|
|
219
|
+
const shellWorkspacePath = sessionManager.getSessionWorkspacePath(message.payload.clientSessionId);
|
|
220
|
+
const newShell = new ShellSession(shellWorkspacePath, cols, rows);
|
|
221
|
+
shellSession = newShell;
|
|
222
|
+
newShell.on("output", (data) => {
|
|
223
|
+
if (socket.readyState === WebSocket.OPEN) {
|
|
224
|
+
socket.send(JSON.stringify({ type: "shell_output", payload: { data } }));
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
newShell.on("exit", (exitCode) => {
|
|
228
|
+
if (shellSession === newShell) {
|
|
229
|
+
shellSession = null;
|
|
230
|
+
}
|
|
231
|
+
if (socket.readyState === WebSocket.OPEN) {
|
|
232
|
+
socket.send(JSON.stringify({ type: "shell_exited", payload: { exitCode } }));
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
break;
|
|
236
|
+
}
|
|
237
|
+
case "shell_input":
|
|
238
|
+
if (!shellSession?.alive) {
|
|
239
|
+
throw new Error("Shell is not running");
|
|
240
|
+
}
|
|
241
|
+
shellSession.write(message.payload.data);
|
|
242
|
+
break;
|
|
243
|
+
case "shell_resize":
|
|
244
|
+
shellSession?.resize(message.payload.cols, message.payload.rows);
|
|
245
|
+
break;
|
|
246
|
+
case "shell_stop":
|
|
247
|
+
shellSession?.kill();
|
|
248
|
+
shellSession = null;
|
|
249
|
+
break;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
catch (error) {
|
|
253
|
+
sendEvent(socket, {
|
|
254
|
+
type: "error",
|
|
255
|
+
payload: { message: formatError(error) },
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
socket.on("close", () => {
|
|
260
|
+
unsubscribe();
|
|
261
|
+
shellSession?.kill();
|
|
262
|
+
shellSession = null;
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
const listenPort = await findAvailablePort(requestedPort);
|
|
266
|
+
await new Promise((resolve) => {
|
|
267
|
+
server.listen(listenPort, "0.0.0.0", () => resolve());
|
|
268
|
+
});
|
|
269
|
+
const lanIp = pickPreferredLanIp();
|
|
270
|
+
const accessPort = isDevServer ? devWebPort : listenPort;
|
|
271
|
+
console.log(`${appName} server listening on http://${lanIp}:${listenPort}`);
|
|
272
|
+
if (listenPort !== requestedPort) {
|
|
273
|
+
console.log(`Port ${requestedPort} is busy, switched to ${listenPort}`);
|
|
274
|
+
}
|
|
275
|
+
if (isDevServer) {
|
|
276
|
+
console.log(`Dev Web URL (Vite default): http://${lanIp}:${devWebPort}`);
|
|
277
|
+
}
|
|
278
|
+
else if (hasBundledWeb) {
|
|
279
|
+
console.log(`Web UI is served by the same server port: http://${lanIp}:${listenPort}`);
|
|
280
|
+
}
|
|
281
|
+
else {
|
|
282
|
+
console.log("Web UI is unavailable on this start because bundled assets are missing.");
|
|
283
|
+
}
|
|
284
|
+
console.log(`Access URL: http://${lanIp}:${accessPort}/?key=${accessKey}`);
|
|
285
|
+
function sendEvent(socket, event) {
|
|
286
|
+
if (socket.readyState !== WebSocket.OPEN) {
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
socket.send(JSON.stringify(event));
|
|
290
|
+
}
|
|
291
|
+
function resolveAgentBinPath() {
|
|
292
|
+
if (process.env.LEDUO_PATROL_AGENT_BIN?.trim()) {
|
|
293
|
+
return process.env.LEDUO_PATROL_AGENT_BIN.trim();
|
|
294
|
+
}
|
|
295
|
+
const require = createRequire(import.meta.url);
|
|
296
|
+
try {
|
|
297
|
+
const pkgPath = require.resolve("@zed-industries/claude-code-acp/package.json");
|
|
298
|
+
const pkgDir = path.dirname(pkgPath);
|
|
299
|
+
return path.join(pkgDir, "dist", "index.js");
|
|
300
|
+
}
|
|
301
|
+
catch {
|
|
302
|
+
return "claude-code-acp";
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
async function hasReadableFile(filePath) {
|
|
306
|
+
try {
|
|
307
|
+
await access(filePath);
|
|
308
|
+
return true;
|
|
309
|
+
}
|
|
310
|
+
catch {
|
|
311
|
+
return false;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import net from "node:net";
|
|
3
|
+
const EXCLUDED_INTERFACE_PREFIXES = ["lo", "docker", "br-", "veth", "virbr", "vmnet", "tun", "tap"];
|
|
4
|
+
const PREFERRED_INTERFACE_PREFIXES = ["bond", "eth", "ens", "enp"];
|
|
5
|
+
function shouldSkipInterface(name) {
|
|
6
|
+
return EXCLUDED_INTERFACE_PREFIXES.some((prefix) => name.startsWith(prefix));
|
|
7
|
+
}
|
|
8
|
+
export function pickPreferredLanIp() {
|
|
9
|
+
const interfaces = os.networkInterfaces();
|
|
10
|
+
const candidates = [];
|
|
11
|
+
for (const [name, addresses] of Object.entries(interfaces)) {
|
|
12
|
+
if (!addresses || shouldSkipInterface(name)) {
|
|
13
|
+
continue;
|
|
14
|
+
}
|
|
15
|
+
for (const address of addresses) {
|
|
16
|
+
if (address.family !== "IPv4" || address.internal) {
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
const preferredIndex = PREFERRED_INTERFACE_PREFIXES.findIndex((prefix) => name.startsWith(prefix));
|
|
20
|
+
const score = preferredIndex === -1 ? 100 : preferredIndex;
|
|
21
|
+
candidates.push({ name, ip: address.address, score });
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
candidates.sort((a, b) => a.score - b.score || a.name.localeCompare(b.name));
|
|
25
|
+
return candidates[0]?.ip ?? "127.0.0.1";
|
|
26
|
+
}
|
|
27
|
+
export async function findAvailablePort(preferredPort, host = "0.0.0.0") {
|
|
28
|
+
let port = preferredPort;
|
|
29
|
+
while (port < preferredPort + 100) {
|
|
30
|
+
const available = await isPortAvailable(port, host);
|
|
31
|
+
if (available) {
|
|
32
|
+
return port;
|
|
33
|
+
}
|
|
34
|
+
port += 1;
|
|
35
|
+
}
|
|
36
|
+
throw new Error(`No available port found from ${preferredPort} to ${preferredPort + 99}`);
|
|
37
|
+
}
|
|
38
|
+
function isPortAvailable(port, host) {
|
|
39
|
+
return new Promise((resolve, reject) => {
|
|
40
|
+
const server = net.createServer();
|
|
41
|
+
server.once("error", (error) => {
|
|
42
|
+
if (error.code === "EADDRINUSE" || error.code === "EACCES") {
|
|
43
|
+
resolve(false);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
reject(error);
|
|
47
|
+
});
|
|
48
|
+
server.listen(port, host, () => {
|
|
49
|
+
server.close((error) => {
|
|
50
|
+
if (error) {
|
|
51
|
+
reject(error);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
resolve(true);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
export const networkTestables = {
|
|
60
|
+
shouldSkipInterface,
|
|
61
|
+
isPortAvailable,
|
|
62
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
export function formatError(error) {
|
|
3
|
+
if (error instanceof Error) {
|
|
4
|
+
return error.message;
|
|
5
|
+
}
|
|
6
|
+
try {
|
|
7
|
+
return JSON.stringify(error);
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return String(error);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export function resolveAllowedPath(requestedPath, allowedRoots) {
|
|
14
|
+
const resolvedPath = path.resolve(requestedPath);
|
|
15
|
+
const isAllowed = allowedRoots.some((rootPath) => {
|
|
16
|
+
const relativePath = path.relative(rootPath, resolvedPath);
|
|
17
|
+
return relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath));
|
|
18
|
+
});
|
|
19
|
+
if (!isAllowed) {
|
|
20
|
+
throw new Error(`Path is outside allowed roots: ${resolvedPath}`);
|
|
21
|
+
}
|
|
22
|
+
return resolvedPath;
|
|
23
|
+
}
|