@dexto/image-local 1.16.5 → 1.17.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.
@@ -31,13 +31,11 @@ __export(local_workspace_handle_provider_exports, {
31
31
  LocalWorkspaceHandleProvider: () => LocalWorkspaceHandleProvider
32
32
  });
33
33
  module.exports = __toCommonJS(local_workspace_handle_provider_exports);
34
- var import_node_child_process = require("node:child_process");
35
34
  var import_promises = require("node:fs/promises");
36
35
  var import_node_path = __toESM(require("node:path"), 1);
37
- var import_node_util = require("node:util");
38
36
  var import_glob = require("glob");
39
37
  var import_workspace = require("@dexto/core/workspace");
40
- const exec = (0, import_node_util.promisify)(import_node_child_process.exec);
38
+ var import_run_command = require("./run-command.js");
41
39
  class LocalWorkspaceHandleProvider {
42
40
  async open(input) {
43
41
  const root = import_node_path.default.resolve(input.context.path);
@@ -61,9 +59,21 @@ class LocalWorkspaceFiles {
61
59
  constructor(root) {
62
60
  this.root = root;
63
61
  }
62
+ realRoot;
64
63
  readText = async (filePath) => {
65
64
  try {
66
- return await (0, import_promises.readFile)(this.resolveInsideRoot(filePath), "utf-8");
65
+ return await (0, import_promises.readFile)(await this.resolveInsideRoot(filePath), "utf-8");
66
+ } catch (error) {
67
+ if (error.code === "ENOENT") {
68
+ throw import_workspace.WorkspaceError.fileNotFound(filePath);
69
+ }
70
+ throw error;
71
+ }
72
+ };
73
+ /** Size in bytes, from the same contained real path `readText` would read. */
74
+ readFileSize = async (filePath) => {
75
+ try {
76
+ return (await (0, import_promises.stat)(await this.resolveInsideRoot(filePath))).size;
67
77
  } catch (error) {
68
78
  if (error.code === "ENOENT") {
69
79
  throw import_workspace.WorkspaceError.fileNotFound(filePath);
@@ -77,7 +87,7 @@ class LocalWorkspaceFiles {
77
87
  glob = async (pattern) => {
78
88
  this.assertRelativePattern(pattern);
79
89
  const files = await (0, import_glob.glob)(pattern, {
80
- cwd: this.root,
90
+ cwd: await this.getRealRoot(),
81
91
  absolute: false,
82
92
  nodir: true,
83
93
  follow: false,
@@ -86,7 +96,7 @@ class LocalWorkspaceFiles {
86
96
  return files.sort();
87
97
  };
88
98
  writeFile = async (filePath, content) => {
89
- const resolvedPath = this.resolveInsideRoot(filePath);
99
+ const resolvedPath = await this.resolveInsideRoot(filePath);
90
100
  await (0, import_promises.mkdir)(import_node_path.default.dirname(resolvedPath), { recursive: true });
91
101
  try {
92
102
  await (0, import_promises.writeFile)(resolvedPath, content, "utf-8");
@@ -98,16 +108,27 @@ class LocalWorkspaceFiles {
98
108
  }
99
109
  };
100
110
  listFiles = async (directoryPath = ".") => {
101
- const resolvedPath = this.resolveInsideRoot(directoryPath);
102
- const relativePath = import_node_path.default.relative(this.root, resolvedPath);
111
+ const resolvedPath = await this.resolveInsideRoot(directoryPath);
112
+ const relativePath = import_node_path.default.relative(await this.getRealRoot(), resolvedPath);
103
113
  const pattern = relativePath ? `${relativePath.split(import_node_path.default.sep).join("/")}/**/*` : "**/*";
104
114
  return this.glob(pattern);
105
115
  };
106
- resolveInsideRoot(filePath) {
116
+ /** The workspace root's real path, resolved once. The root itself may sit behind a symlink. */
117
+ async getRealRoot() {
118
+ this.realRoot ??= await (0, import_promises.realpath)(this.root);
119
+ return this.realRoot;
120
+ }
121
+ /**
122
+ * Resolve a path to its real location and require it to be inside the real workspace root,
123
+ * so a symlink in the workspace cannot lead out of it.
124
+ */
125
+ async resolveInsideRoot(filePath) {
107
126
  const resolvedPath = import_node_path.default.isAbsolute(filePath) ? import_node_path.default.resolve(filePath) : import_node_path.default.resolve(this.root, filePath);
108
- const relativePath = import_node_path.default.relative(this.root, resolvedPath);
127
+ const realRoot = await this.getRealRoot();
128
+ const realPath = await realpathOfPossiblyMissing(resolvedPath, filePath);
129
+ const relativePath = import_node_path.default.relative(realRoot, realPath);
109
130
  if (relativePath === "" || !relativePath.startsWith("..") && !import_node_path.default.isAbsolute(relativePath)) {
110
- return resolvedPath;
131
+ return realPath;
111
132
  }
112
133
  throw import_workspace.WorkspaceError.pathOutsideWorkspace(filePath);
113
134
  }
@@ -123,11 +144,12 @@ class LocalWorkspaceProcesses {
123
144
  }
124
145
  exec = async (input) => {
125
146
  const cwd = input.cwd === void 0 ? this.root : resolveInsideRoot(this.root, input.cwd);
126
- const result = await exec(input.command, { cwd });
127
- return {
128
- stdout: result.stdout,
129
- stderr: result.stderr
130
- };
147
+ return (0, import_run_command.runCommand)(input.command, {
148
+ cwd,
149
+ env: (0, import_workspace.buildShellEnvironment)(process.env, input.env),
150
+ ...input.timeout === void 0 ? {} : { timeout: input.timeout },
151
+ signal: input.signal
152
+ });
131
153
  };
132
154
  }
133
155
  function resolveCapabilities(input) {
@@ -145,6 +167,25 @@ function resolveInsideRoot(root, filePath) {
145
167
  }
146
168
  throw import_workspace.WorkspaceError.pathOutsideWorkspace(filePath);
147
169
  }
170
+ async function realpathOfPossiblyMissing(target, requestedPath) {
171
+ try {
172
+ return await (0, import_promises.realpath)(target);
173
+ } catch (error) {
174
+ const code = error.code;
175
+ if (code !== "ENOENT" && code !== "ENOTDIR") {
176
+ throw error;
177
+ }
178
+ }
179
+ const stats = await (0, import_promises.lstat)(target).catch(() => void 0);
180
+ if (stats?.isSymbolicLink()) {
181
+ throw import_workspace.WorkspaceError.pathOutsideWorkspace(requestedPath);
182
+ }
183
+ const parent = import_node_path.default.dirname(target);
184
+ if (parent === target) {
185
+ return target;
186
+ }
187
+ return import_node_path.default.join(await realpathOfPossiblyMissing(parent, requestedPath), import_node_path.default.basename(target));
188
+ }
148
189
  // Annotate the CommonJS export names for ESM import in node:
149
190
  0 && (module.exports = {
150
191
  LocalWorkspaceHandleProvider
@@ -1 +1 @@
1
- {"version":3,"file":"local-workspace-handle-provider.d.ts","sourceRoot":"","sources":["../src/local-workspace-handle-provider.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EACR,kBAAkB,EAElB,gBAAgB,EAChB,eAAe,EACf,uBAAuB,EAC1B,MAAM,uBAAuB,CAAC;AAI/B,qBAAa,4BAA6B,YAAW,uBAAuB;IAClE,IAAI,CAAC,KAAK,EAAE;QACd,OAAO,EAAE,gBAAgB,CAAC;QAC1B,KAAK,CAAC,EAAE,kBAAkB,CAAC;KAC9B,GAAG,OAAO,CAAC,eAAe,CAAC;CAoB/B"}
1
+ {"version":3,"file":"local-workspace-handle-provider.d.ts","sourceRoot":"","sources":["../src/local-workspace-handle-provider.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EACR,kBAAkB,EAElB,gBAAgB,EAChB,eAAe,EACf,uBAAuB,EAE1B,MAAM,uBAAuB,CAAC;AAG/B,qBAAa,4BAA6B,YAAW,uBAAuB;IAClE,IAAI,CAAC,KAAK,EAAE;QACd,OAAO,EAAE,gBAAgB,CAAC;QAC1B,KAAK,CAAC,EAAE,kBAAkB,CAAC;KAC9B,GAAG,OAAO,CAAC,eAAe,CAAC;CAoB/B"}
@@ -1,10 +1,8 @@
1
- import { exec as execCallback } from "node:child_process";
2
- import { mkdir, readFile, writeFile } from "node:fs/promises";
1
+ import { lstat, mkdir, readFile, realpath, stat, writeFile } from "node:fs/promises";
3
2
  import path from "node:path";
4
- import { promisify } from "node:util";
5
3
  import { glob } from "glob";
6
- import { WorkspaceError } from "@dexto/core/workspace";
7
- const exec = promisify(execCallback);
4
+ import { WorkspaceError, buildShellEnvironment } from "@dexto/core/workspace";
5
+ import { runCommand } from "./run-command.js";
8
6
  class LocalWorkspaceHandleProvider {
9
7
  async open(input) {
10
8
  const root = path.resolve(input.context.path);
@@ -28,9 +26,21 @@ class LocalWorkspaceFiles {
28
26
  constructor(root) {
29
27
  this.root = root;
30
28
  }
29
+ realRoot;
31
30
  readText = async (filePath) => {
32
31
  try {
33
- return await readFile(this.resolveInsideRoot(filePath), "utf-8");
32
+ return await readFile(await this.resolveInsideRoot(filePath), "utf-8");
33
+ } catch (error) {
34
+ if (error.code === "ENOENT") {
35
+ throw WorkspaceError.fileNotFound(filePath);
36
+ }
37
+ throw error;
38
+ }
39
+ };
40
+ /** Size in bytes, from the same contained real path `readText` would read. */
41
+ readFileSize = async (filePath) => {
42
+ try {
43
+ return (await stat(await this.resolveInsideRoot(filePath))).size;
34
44
  } catch (error) {
35
45
  if (error.code === "ENOENT") {
36
46
  throw WorkspaceError.fileNotFound(filePath);
@@ -44,7 +54,7 @@ class LocalWorkspaceFiles {
44
54
  glob = async (pattern) => {
45
55
  this.assertRelativePattern(pattern);
46
56
  const files = await glob(pattern, {
47
- cwd: this.root,
57
+ cwd: await this.getRealRoot(),
48
58
  absolute: false,
49
59
  nodir: true,
50
60
  follow: false,
@@ -53,7 +63,7 @@ class LocalWorkspaceFiles {
53
63
  return files.sort();
54
64
  };
55
65
  writeFile = async (filePath, content) => {
56
- const resolvedPath = this.resolveInsideRoot(filePath);
66
+ const resolvedPath = await this.resolveInsideRoot(filePath);
57
67
  await mkdir(path.dirname(resolvedPath), { recursive: true });
58
68
  try {
59
69
  await writeFile(resolvedPath, content, "utf-8");
@@ -65,16 +75,27 @@ class LocalWorkspaceFiles {
65
75
  }
66
76
  };
67
77
  listFiles = async (directoryPath = ".") => {
68
- const resolvedPath = this.resolveInsideRoot(directoryPath);
69
- const relativePath = path.relative(this.root, resolvedPath);
78
+ const resolvedPath = await this.resolveInsideRoot(directoryPath);
79
+ const relativePath = path.relative(await this.getRealRoot(), resolvedPath);
70
80
  const pattern = relativePath ? `${relativePath.split(path.sep).join("/")}/**/*` : "**/*";
71
81
  return this.glob(pattern);
72
82
  };
73
- resolveInsideRoot(filePath) {
83
+ /** The workspace root's real path, resolved once. The root itself may sit behind a symlink. */
84
+ async getRealRoot() {
85
+ this.realRoot ??= await realpath(this.root);
86
+ return this.realRoot;
87
+ }
88
+ /**
89
+ * Resolve a path to its real location and require it to be inside the real workspace root,
90
+ * so a symlink in the workspace cannot lead out of it.
91
+ */
92
+ async resolveInsideRoot(filePath) {
74
93
  const resolvedPath = path.isAbsolute(filePath) ? path.resolve(filePath) : path.resolve(this.root, filePath);
75
- const relativePath = path.relative(this.root, resolvedPath);
94
+ const realRoot = await this.getRealRoot();
95
+ const realPath = await realpathOfPossiblyMissing(resolvedPath, filePath);
96
+ const relativePath = path.relative(realRoot, realPath);
76
97
  if (relativePath === "" || !relativePath.startsWith("..") && !path.isAbsolute(relativePath)) {
77
- return resolvedPath;
98
+ return realPath;
78
99
  }
79
100
  throw WorkspaceError.pathOutsideWorkspace(filePath);
80
101
  }
@@ -90,11 +111,12 @@ class LocalWorkspaceProcesses {
90
111
  }
91
112
  exec = async (input) => {
92
113
  const cwd = input.cwd === void 0 ? this.root : resolveInsideRoot(this.root, input.cwd);
93
- const result = await exec(input.command, { cwd });
94
- return {
95
- stdout: result.stdout,
96
- stderr: result.stderr
97
- };
114
+ return runCommand(input.command, {
115
+ cwd,
116
+ env: buildShellEnvironment(process.env, input.env),
117
+ ...input.timeout === void 0 ? {} : { timeout: input.timeout },
118
+ signal: input.signal
119
+ });
98
120
  };
99
121
  }
100
122
  function resolveCapabilities(input) {
@@ -112,6 +134,25 @@ function resolveInsideRoot(root, filePath) {
112
134
  }
113
135
  throw WorkspaceError.pathOutsideWorkspace(filePath);
114
136
  }
137
+ async function realpathOfPossiblyMissing(target, requestedPath) {
138
+ try {
139
+ return await realpath(target);
140
+ } catch (error) {
141
+ const code = error.code;
142
+ if (code !== "ENOENT" && code !== "ENOTDIR") {
143
+ throw error;
144
+ }
145
+ }
146
+ const stats = await lstat(target).catch(() => void 0);
147
+ if (stats?.isSymbolicLink()) {
148
+ throw WorkspaceError.pathOutsideWorkspace(requestedPath);
149
+ }
150
+ const parent = path.dirname(target);
151
+ if (parent === target) {
152
+ return target;
153
+ }
154
+ return path.join(await realpathOfPossiblyMissing(parent, requestedPath), path.basename(target));
155
+ }
115
156
  export {
116
157
  LocalWorkspaceHandleProvider
117
158
  };
@@ -0,0 +1,107 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __copyProps = (to, from, except, desc) => {
9
+ if (from && typeof from === "object" || typeof from === "function") {
10
+ for (let key of __getOwnPropNames(from))
11
+ if (!__hasOwnProp.call(to, key) && key !== except)
12
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
13
+ }
14
+ return to;
15
+ };
16
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
17
+ // If the importer is in node compatibility mode or this is not an ESM
18
+ // file that has been converted to a CommonJS file using a Babel-
19
+ // compatible transform (i.e. "__esModule" has not been set), then set
20
+ // "default" to the CommonJS "module.exports" for node compatibility.
21
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
22
+ mod
23
+ ));
24
+ var import_promises = require("node:fs/promises");
25
+ var import_node_os = require("node:os");
26
+ var import_node_path = __toESM(require("node:path"), 1);
27
+ var import_vitest = require("vitest");
28
+ var import_local_workspace_handle_provider = require("./local-workspace-handle-provider.js");
29
+ (0, import_vitest.describe)("LocalWorkspaceHandleProvider files", () => {
30
+ let base;
31
+ let root;
32
+ let outside;
33
+ (0, import_vitest.beforeEach)(async () => {
34
+ base = await (0, import_promises.mkdtemp)(import_node_path.default.join((0, import_node_os.tmpdir)(), "workspace-handle-"));
35
+ root = import_node_path.default.join(base, "root");
36
+ outside = import_node_path.default.join(base, "outside");
37
+ await (0, import_promises.mkdir)(root);
38
+ await (0, import_promises.mkdir)(outside);
39
+ await (0, import_promises.writeFile)(import_node_path.default.join(outside, "private.txt"), "private");
40
+ });
41
+ (0, import_vitest.afterEach)(async () => {
42
+ await (0, import_promises.rm)(base, { recursive: true, force: true });
43
+ });
44
+ async function openFiles() {
45
+ const context = { path: root };
46
+ const handle = await new import_local_workspace_handle_provider.LocalWorkspaceHandleProvider().open({ context });
47
+ return handle.files;
48
+ }
49
+ (0, import_vitest.it)("reads and writes regular files inside the workspace", async () => {
50
+ const files = await openFiles();
51
+ await files.writeFile("sub/a.txt", "hello");
52
+ (0, import_vitest.expect)(await files.readText("sub/a.txt")).toBe("hello");
53
+ });
54
+ (0, import_vitest.it)("refuses to read through a symlink that leaves the workspace", async () => {
55
+ await (0, import_promises.symlink)(import_node_path.default.join(outside, "private.txt"), import_node_path.default.join(root, "link.txt"));
56
+ const files = await openFiles();
57
+ await (0, import_vitest.expect)(files.readText("link.txt")).rejects.toThrow();
58
+ });
59
+ (0, import_vitest.it)("refuses to write through a symlink that leaves the workspace", async () => {
60
+ await (0, import_promises.symlink)(import_node_path.default.join(outside, "private.txt"), import_node_path.default.join(root, "link.txt"));
61
+ const files = await openFiles();
62
+ await (0, import_vitest.expect)(files.writeFile("link.txt", "changed")).rejects.toThrow();
63
+ (0, import_vitest.expect)(await (0, import_promises.readFile)(import_node_path.default.join(outside, "private.txt"), "utf-8")).toBe("private");
64
+ });
65
+ (0, import_vitest.it)("refuses to create files through a symlinked directory that leaves the workspace", async () => {
66
+ await (0, import_promises.symlink)(outside, import_node_path.default.join(root, "linked-dir"));
67
+ const files = await openFiles();
68
+ await (0, import_vitest.expect)(files.writeFile("linked-dir/new.txt", "x")).rejects.toThrow();
69
+ await (0, import_vitest.expect)((0, import_promises.readFile)(import_node_path.default.join(outside, "new.txt"))).rejects.toThrow();
70
+ });
71
+ (0, import_vitest.it)("allows a symlink that stays inside the workspace", async () => {
72
+ await (0, import_promises.writeFile)(import_node_path.default.join(root, "real.txt"), "inside");
73
+ await (0, import_promises.symlink)(import_node_path.default.join(root, "real.txt"), import_node_path.default.join(root, "link.txt"));
74
+ const files = await openFiles();
75
+ (0, import_vitest.expect)(await files.readText("link.txt")).toBe("inside");
76
+ });
77
+ (0, import_vitest.describe)("when the workspace root is reached through a symlink", () => {
78
+ async function openThroughLink() {
79
+ const linkRoot = import_node_path.default.join(base, "link-root");
80
+ await (0, import_promises.symlink)(root, linkRoot);
81
+ const context = { path: linkRoot };
82
+ const handle = await new import_local_workspace_handle_provider.LocalWorkspaceHandleProvider().open({ context });
83
+ return { files: handle.files, linkRoot };
84
+ }
85
+ (0, import_vitest.it)("reads, writes, lists and globs files", async () => {
86
+ const { files } = await openThroughLink();
87
+ await files.writeFile("sub/a.txt", "hello");
88
+ (0, import_vitest.expect)(await files.readText("sub/a.txt")).toBe("hello");
89
+ (0, import_vitest.expect)(await files.listFiles(".")).toEqual(["sub/a.txt"]);
90
+ (0, import_vitest.expect)(await files.listFiles("sub")).toEqual(["sub/a.txt"]);
91
+ (0, import_vitest.expect)(await files.glob("**/*.txt")).toEqual(["sub/a.txt"]);
92
+ });
93
+ (0, import_vitest.it)("accepts an absolute path under the linked or the real root", async () => {
94
+ const { files, linkRoot } = await openThroughLink();
95
+ await files.writeFile(import_node_path.default.join(linkRoot, "a.txt"), "one");
96
+ (0, import_vitest.expect)(await files.readText(import_node_path.default.join(root, "a.txt"))).toBe("one");
97
+ });
98
+ (0, import_vitest.it)("still refuses links that leave the workspace", async () => {
99
+ await (0, import_promises.symlink)(import_node_path.default.join(outside, "private.txt"), import_node_path.default.join(root, "link.txt"));
100
+ await (0, import_promises.symlink)(outside, import_node_path.default.join(root, "linked-dir"));
101
+ const { files } = await openThroughLink();
102
+ await (0, import_vitest.expect)(files.readText("link.txt")).rejects.toThrow();
103
+ await (0, import_vitest.expect)(files.writeFile("linked-dir/new.txt", "x")).rejects.toThrow();
104
+ await (0, import_vitest.expect)(files.listFiles("linked-dir")).rejects.toThrow();
105
+ });
106
+ });
107
+ });
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=local-workspace-handle-provider.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"local-workspace-handle-provider.test.d.ts","sourceRoot":"","sources":["../src/local-workspace-handle-provider.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,84 @@
1
+ import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import path from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
+ import { LocalWorkspaceHandleProvider } from "./local-workspace-handle-provider.js";
6
+ describe("LocalWorkspaceHandleProvider files", () => {
7
+ let base;
8
+ let root;
9
+ let outside;
10
+ beforeEach(async () => {
11
+ base = await mkdtemp(path.join(tmpdir(), "workspace-handle-"));
12
+ root = path.join(base, "root");
13
+ outside = path.join(base, "outside");
14
+ await mkdir(root);
15
+ await mkdir(outside);
16
+ await writeFile(path.join(outside, "private.txt"), "private");
17
+ });
18
+ afterEach(async () => {
19
+ await rm(base, { recursive: true, force: true });
20
+ });
21
+ async function openFiles() {
22
+ const context = { path: root };
23
+ const handle = await new LocalWorkspaceHandleProvider().open({ context });
24
+ return handle.files;
25
+ }
26
+ it("reads and writes regular files inside the workspace", async () => {
27
+ const files = await openFiles();
28
+ await files.writeFile("sub/a.txt", "hello");
29
+ expect(await files.readText("sub/a.txt")).toBe("hello");
30
+ });
31
+ it("refuses to read through a symlink that leaves the workspace", async () => {
32
+ await symlink(path.join(outside, "private.txt"), path.join(root, "link.txt"));
33
+ const files = await openFiles();
34
+ await expect(files.readText("link.txt")).rejects.toThrow();
35
+ });
36
+ it("refuses to write through a symlink that leaves the workspace", async () => {
37
+ await symlink(path.join(outside, "private.txt"), path.join(root, "link.txt"));
38
+ const files = await openFiles();
39
+ await expect(files.writeFile("link.txt", "changed")).rejects.toThrow();
40
+ expect(await readFile(path.join(outside, "private.txt"), "utf-8")).toBe("private");
41
+ });
42
+ it("refuses to create files through a symlinked directory that leaves the workspace", async () => {
43
+ await symlink(outside, path.join(root, "linked-dir"));
44
+ const files = await openFiles();
45
+ await expect(files.writeFile("linked-dir/new.txt", "x")).rejects.toThrow();
46
+ await expect(readFile(path.join(outside, "new.txt"))).rejects.toThrow();
47
+ });
48
+ it("allows a symlink that stays inside the workspace", async () => {
49
+ await writeFile(path.join(root, "real.txt"), "inside");
50
+ await symlink(path.join(root, "real.txt"), path.join(root, "link.txt"));
51
+ const files = await openFiles();
52
+ expect(await files.readText("link.txt")).toBe("inside");
53
+ });
54
+ describe("when the workspace root is reached through a symlink", () => {
55
+ async function openThroughLink() {
56
+ const linkRoot = path.join(base, "link-root");
57
+ await symlink(root, linkRoot);
58
+ const context = { path: linkRoot };
59
+ const handle = await new LocalWorkspaceHandleProvider().open({ context });
60
+ return { files: handle.files, linkRoot };
61
+ }
62
+ it("reads, writes, lists and globs files", async () => {
63
+ const { files } = await openThroughLink();
64
+ await files.writeFile("sub/a.txt", "hello");
65
+ expect(await files.readText("sub/a.txt")).toBe("hello");
66
+ expect(await files.listFiles(".")).toEqual(["sub/a.txt"]);
67
+ expect(await files.listFiles("sub")).toEqual(["sub/a.txt"]);
68
+ expect(await files.glob("**/*.txt")).toEqual(["sub/a.txt"]);
69
+ });
70
+ it("accepts an absolute path under the linked or the real root", async () => {
71
+ const { files, linkRoot } = await openThroughLink();
72
+ await files.writeFile(path.join(linkRoot, "a.txt"), "one");
73
+ expect(await files.readText(path.join(root, "a.txt"))).toBe("one");
74
+ });
75
+ it("still refuses links that leave the workspace", async () => {
76
+ await symlink(path.join(outside, "private.txt"), path.join(root, "link.txt"));
77
+ await symlink(outside, path.join(root, "linked-dir"));
78
+ const { files } = await openThroughLink();
79
+ await expect(files.readText("link.txt")).rejects.toThrow();
80
+ await expect(files.writeFile("linked-dir/new.txt", "x")).rejects.toThrow();
81
+ await expect(files.listFiles("linked-dir")).rejects.toThrow();
82
+ });
83
+ });
84
+ });
@@ -0,0 +1,108 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+ var run_command_exports = {};
20
+ __export(run_command_exports, {
21
+ runCommand: () => runCommand
22
+ });
23
+ module.exports = __toCommonJS(run_command_exports);
24
+ var import_node_child_process = require("node:child_process");
25
+ const MAX_OUTPUT_CHARS = 10 * 1024 * 1024;
26
+ const KILL_GRACE_MS = 1e3;
27
+ const ABORTED_EXIT_CODE = 130;
28
+ function runCommand(command, options) {
29
+ const { cwd, env, timeout, signal } = options;
30
+ return new Promise((resolve, reject) => {
31
+ if (signal?.aborted) {
32
+ resolve({ exitCode: ABORTED_EXIT_CODE, stdout: "", stderr: "(Command was cancelled)" });
33
+ return;
34
+ }
35
+ const child = (0, import_node_child_process.spawn)(command, {
36
+ cwd,
37
+ env,
38
+ shell: true,
39
+ detached: process.platform !== "win32"
40
+ });
41
+ let stdout = "";
42
+ let stderr = "";
43
+ let timedOut = false;
44
+ let aborted = false;
45
+ let killTimer;
46
+ const stop = () => {
47
+ signalProcessGroup(child, "SIGTERM");
48
+ killTimer ??= setTimeout(() => signalProcessGroup(child, "SIGKILL"), KILL_GRACE_MS);
49
+ };
50
+ const timeoutTimer = timeout === void 0 ? void 0 : setTimeout(() => {
51
+ timedOut = true;
52
+ stop();
53
+ }, timeout);
54
+ const onAbort = () => {
55
+ aborted = true;
56
+ stop();
57
+ };
58
+ signal?.addEventListener("abort", onAbort, { once: true });
59
+ const cleanUp = () => {
60
+ clearTimeout(timeoutTimer);
61
+ clearTimeout(killTimer);
62
+ signal?.removeEventListener("abort", onAbort);
63
+ };
64
+ child.stdout?.on("data", (chunk) => {
65
+ stdout = appendCapped(stdout, chunk.toString());
66
+ });
67
+ child.stderr?.on("data", (chunk) => {
68
+ stderr = appendCapped(stderr, chunk.toString());
69
+ });
70
+ child.once("error", (error) => {
71
+ cleanUp();
72
+ reject(error);
73
+ });
74
+ child.once("close", (code) => {
75
+ if (timedOut || aborted) signalProcessGroup(child, "SIGKILL");
76
+ cleanUp();
77
+ if (aborted) {
78
+ resolve({
79
+ exitCode: ABORTED_EXIT_CODE,
80
+ stdout: `${stdout}
81
+
82
+ (Command was cancelled)`,
83
+ stderr
84
+ });
85
+ return;
86
+ }
87
+ resolve({ exitCode: code ?? 1, stdout, stderr, ...timedOut && { timedOut } });
88
+ });
89
+ });
90
+ }
91
+ function appendCapped(current, chunk) {
92
+ return current.length >= MAX_OUTPUT_CHARS ? current : (current + chunk).slice(0, MAX_OUTPUT_CHARS);
93
+ }
94
+ function signalProcessGroup(child, signal) {
95
+ if (child.pid === void 0) return;
96
+ if (process.platform === "win32") {
97
+ (0, import_node_child_process.spawn)("taskkill", ["/pid", String(child.pid), "/f", "/t"], { stdio: "ignore" });
98
+ return;
99
+ }
100
+ try {
101
+ process.kill(-child.pid, signal);
102
+ } catch {
103
+ }
104
+ }
105
+ // Annotate the CommonJS export names for ESM import in node:
106
+ 0 && (module.exports = {
107
+ runCommand
108
+ });
@@ -0,0 +1,17 @@
1
+ export interface RunCommandResult {
2
+ exitCode: number;
3
+ stdout: string;
4
+ stderr: string;
5
+ timedOut?: boolean;
6
+ }
7
+ /**
8
+ * Run a shell command in its own process group. A timeout or abort stops the whole group, so
9
+ * commands started by the shell stop too. Exits, including non-zero ones, are returned.
10
+ */
11
+ export declare function runCommand(command: string, options: {
12
+ cwd: string;
13
+ env: Record<string, string>;
14
+ timeout?: number;
15
+ signal?: AbortSignal | undefined;
16
+ }): Promise<RunCommandResult>;
17
+ //# sourceMappingURL=run-command.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"run-command.d.ts","sourceRoot":"","sources":["../src/run-command.ts"],"names":[],"mappings":"AASA,MAAM,WAAW,gBAAgB;IAC7B,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,OAAO,CAAC;CACtB;AAED;;;GAGG;AACH,wBAAgB,UAAU,CACtB,OAAO,EAAE,MAAM,EACf,OAAO,EAAE;IACL,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC5B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,MAAM,CAAC,EAAE,WAAW,GAAG,SAAS,CAAC;CACpC,GACF,OAAO,CAAC,gBAAgB,CAAC,CAqE3B"}
@@ -0,0 +1,84 @@
1
+ import { spawn } from "node:child_process";
2
+ const MAX_OUTPUT_CHARS = 10 * 1024 * 1024;
3
+ const KILL_GRACE_MS = 1e3;
4
+ const ABORTED_EXIT_CODE = 130;
5
+ function runCommand(command, options) {
6
+ const { cwd, env, timeout, signal } = options;
7
+ return new Promise((resolve, reject) => {
8
+ if (signal?.aborted) {
9
+ resolve({ exitCode: ABORTED_EXIT_CODE, stdout: "", stderr: "(Command was cancelled)" });
10
+ return;
11
+ }
12
+ const child = spawn(command, {
13
+ cwd,
14
+ env,
15
+ shell: true,
16
+ detached: process.platform !== "win32"
17
+ });
18
+ let stdout = "";
19
+ let stderr = "";
20
+ let timedOut = false;
21
+ let aborted = false;
22
+ let killTimer;
23
+ const stop = () => {
24
+ signalProcessGroup(child, "SIGTERM");
25
+ killTimer ??= setTimeout(() => signalProcessGroup(child, "SIGKILL"), KILL_GRACE_MS);
26
+ };
27
+ const timeoutTimer = timeout === void 0 ? void 0 : setTimeout(() => {
28
+ timedOut = true;
29
+ stop();
30
+ }, timeout);
31
+ const onAbort = () => {
32
+ aborted = true;
33
+ stop();
34
+ };
35
+ signal?.addEventListener("abort", onAbort, { once: true });
36
+ const cleanUp = () => {
37
+ clearTimeout(timeoutTimer);
38
+ clearTimeout(killTimer);
39
+ signal?.removeEventListener("abort", onAbort);
40
+ };
41
+ child.stdout?.on("data", (chunk) => {
42
+ stdout = appendCapped(stdout, chunk.toString());
43
+ });
44
+ child.stderr?.on("data", (chunk) => {
45
+ stderr = appendCapped(stderr, chunk.toString());
46
+ });
47
+ child.once("error", (error) => {
48
+ cleanUp();
49
+ reject(error);
50
+ });
51
+ child.once("close", (code) => {
52
+ if (timedOut || aborted) signalProcessGroup(child, "SIGKILL");
53
+ cleanUp();
54
+ if (aborted) {
55
+ resolve({
56
+ exitCode: ABORTED_EXIT_CODE,
57
+ stdout: `${stdout}
58
+
59
+ (Command was cancelled)`,
60
+ stderr
61
+ });
62
+ return;
63
+ }
64
+ resolve({ exitCode: code ?? 1, stdout, stderr, ...timedOut && { timedOut } });
65
+ });
66
+ });
67
+ }
68
+ function appendCapped(current, chunk) {
69
+ return current.length >= MAX_OUTPUT_CHARS ? current : (current + chunk).slice(0, MAX_OUTPUT_CHARS);
70
+ }
71
+ function signalProcessGroup(child, signal) {
72
+ if (child.pid === void 0) return;
73
+ if (process.platform === "win32") {
74
+ spawn("taskkill", ["/pid", String(child.pid), "/f", "/t"], { stdio: "ignore" });
75
+ return;
76
+ }
77
+ try {
78
+ process.kill(-child.pid, signal);
79
+ } catch {
80
+ }
81
+ }
82
+ export {
83
+ runCommand
84
+ };
@@ -0,0 +1,121 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __copyProps = (to, from, except, desc) => {
9
+ if (from && typeof from === "object" || typeof from === "function") {
10
+ for (let key of __getOwnPropNames(from))
11
+ if (!__hasOwnProp.call(to, key) && key !== except)
12
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
13
+ }
14
+ return to;
15
+ };
16
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
17
+ // If the importer is in node compatibility mode or this is not an ESM
18
+ // file that has been converted to a CommonJS file using a Babel-
19
+ // compatible transform (i.e. "__esModule" has not been set), then set
20
+ // "default" to the CommonJS "module.exports" for node compatibility.
21
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
22
+ mod
23
+ ));
24
+ var import_promises = require("node:fs/promises");
25
+ var import_node_os = require("node:os");
26
+ var import_node_path = __toESM(require("node:path"), 1);
27
+ var import_vitest = require("vitest");
28
+ var import_workspace = require("@dexto/core/workspace");
29
+ var import_tools_process = require("@dexto/tools-process");
30
+ var import_local_workspace_handle_provider = require("./local-workspace-handle-provider.js");
31
+ function createLogger() {
32
+ const logger = {
33
+ debug: () => {
34
+ },
35
+ info: () => {
36
+ },
37
+ warn: () => {
38
+ },
39
+ error: () => {
40
+ },
41
+ createChild: () => createLogger()
42
+ };
43
+ return logger;
44
+ }
45
+ function createWorkspaceStore() {
46
+ const workspaces = /* @__PURE__ */ new Map();
47
+ let currentId;
48
+ return {
49
+ saveWorkspace: async ({ workspace }) => {
50
+ workspaces.set(workspace.id, workspace);
51
+ },
52
+ getWorkspace: async ({ id }) => workspaces.get(id),
53
+ findWorkspaceByPath: async ({ path: workspacePath }) => [...workspaces.values()].find((workspace) => workspace.path === workspacePath),
54
+ listWorkspaces: async () => [...workspaces.values()],
55
+ setCurrentWorkspace: async ({ id }) => {
56
+ currentId = id;
57
+ },
58
+ getCurrentWorkspaceId: async () => currentId,
59
+ clearCurrentWorkspace: async () => {
60
+ currentId = void 0;
61
+ }
62
+ };
63
+ }
64
+ (0, import_vitest.describe)("bash_exec in a workspace", () => {
65
+ let directory;
66
+ (0, import_vitest.beforeEach)(async () => {
67
+ directory = await (0, import_promises.mkdtemp)(import_node_path.default.join((0, import_node_os.tmpdir)(), "workspace-bash-"));
68
+ });
69
+ (0, import_vitest.afterEach)(async () => {
70
+ import_vitest.vi.unstubAllEnvs();
71
+ await (0, import_promises.rm)(directory, { recursive: true, force: true });
72
+ });
73
+ async function runForeground(command, config = {}) {
74
+ const workspaceManager = new import_workspace.WorkspaceManager(
75
+ createWorkspaceStore(),
76
+ { emit: () => true },
77
+ createLogger(),
78
+ new import_local_workspace_handle_provider.LocalWorkspaceHandleProvider()
79
+ );
80
+ await workspaceManager.setWorkspace({ path: directory });
81
+ const bashExec = import_tools_process.processToolsFactory.create(
82
+ import_tools_process.processToolsFactory.configSchema.parse({
83
+ type: "process-tools",
84
+ securityLevel: "permissive",
85
+ ...config
86
+ })
87
+ ).find((tool) => tool.id === "bash_exec");
88
+ if (bashExec === void 0) {
89
+ throw new Error("Expected bash_exec tool to be registered.");
90
+ }
91
+ const context = {
92
+ logger: createLogger(),
93
+ services: { workspaceManager }
94
+ };
95
+ return await bashExec.execute(
96
+ { command, run_in_background: false, timeout: 2e4 },
97
+ context
98
+ );
99
+ }
100
+ const printVariables = 'echo "${DEXTO_TEST_HOST_MARKER:-absent}|${DEXTO_TEST_CONFIGURED:-absent}|${DEXTO_TEST_FORWARDED:-absent}"';
101
+ (0, import_vitest.it)("does not pass host variables to the command, but passes configured ones", async () => {
102
+ import_vitest.vi.stubEnv("DEXTO_TEST_HOST_MARKER", "leaked-host-value");
103
+ import_vitest.vi.stubEnv("DEXTO_TEST_FORWARDED", "forwarded-host-value");
104
+ const result = await runForeground(printVariables, {
105
+ environment: { DEXTO_TEST_CONFIGURED: "configured-value" }
106
+ });
107
+ (0, import_vitest.expect)(result).toMatchObject({ stdout: "absent|configured-value|absent\n" });
108
+ });
109
+ (0, import_vitest.it)("forwards host variables the user names in passEnvironment", async () => {
110
+ import_vitest.vi.stubEnv("DEXTO_TEST_HOST_MARKER", "leaked-host-value");
111
+ import_vitest.vi.stubEnv("DEXTO_TEST_FORWARDED", "forwarded-host-value");
112
+ const result = await runForeground(printVariables, {
113
+ passEnvironment: ["DEXTO_TEST_FORWARDED"]
114
+ });
115
+ (0, import_vitest.expect)(result).toMatchObject({ stdout: "absent|absent|forwarded-host-value\n" });
116
+ });
117
+ (0, import_vitest.it)("still finds commands on PATH", async () => {
118
+ const result = await runForeground("echo ready");
119
+ (0, import_vitest.expect)(result).toMatchObject({ stdout: "ready\n" });
120
+ });
121
+ });
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=workspace-bash-environment.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"workspace-bash-environment.test.d.ts","sourceRoot":"","sources":["../src/workspace-bash-environment.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,98 @@
1
+ import { mkdtemp, rm } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import path from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import { WorkspaceManager } from "@dexto/core/workspace";
6
+ import { processToolsFactory } from "@dexto/tools-process";
7
+ import { LocalWorkspaceHandleProvider } from "./local-workspace-handle-provider.js";
8
+ function createLogger() {
9
+ const logger = {
10
+ debug: () => {
11
+ },
12
+ info: () => {
13
+ },
14
+ warn: () => {
15
+ },
16
+ error: () => {
17
+ },
18
+ createChild: () => createLogger()
19
+ };
20
+ return logger;
21
+ }
22
+ function createWorkspaceStore() {
23
+ const workspaces = /* @__PURE__ */ new Map();
24
+ let currentId;
25
+ return {
26
+ saveWorkspace: async ({ workspace }) => {
27
+ workspaces.set(workspace.id, workspace);
28
+ },
29
+ getWorkspace: async ({ id }) => workspaces.get(id),
30
+ findWorkspaceByPath: async ({ path: workspacePath }) => [...workspaces.values()].find((workspace) => workspace.path === workspacePath),
31
+ listWorkspaces: async () => [...workspaces.values()],
32
+ setCurrentWorkspace: async ({ id }) => {
33
+ currentId = id;
34
+ },
35
+ getCurrentWorkspaceId: async () => currentId,
36
+ clearCurrentWorkspace: async () => {
37
+ currentId = void 0;
38
+ }
39
+ };
40
+ }
41
+ describe("bash_exec in a workspace", () => {
42
+ let directory;
43
+ beforeEach(async () => {
44
+ directory = await mkdtemp(path.join(tmpdir(), "workspace-bash-"));
45
+ });
46
+ afterEach(async () => {
47
+ vi.unstubAllEnvs();
48
+ await rm(directory, { recursive: true, force: true });
49
+ });
50
+ async function runForeground(command, config = {}) {
51
+ const workspaceManager = new WorkspaceManager(
52
+ createWorkspaceStore(),
53
+ { emit: () => true },
54
+ createLogger(),
55
+ new LocalWorkspaceHandleProvider()
56
+ );
57
+ await workspaceManager.setWorkspace({ path: directory });
58
+ const bashExec = processToolsFactory.create(
59
+ processToolsFactory.configSchema.parse({
60
+ type: "process-tools",
61
+ securityLevel: "permissive",
62
+ ...config
63
+ })
64
+ ).find((tool) => tool.id === "bash_exec");
65
+ if (bashExec === void 0) {
66
+ throw new Error("Expected bash_exec tool to be registered.");
67
+ }
68
+ const context = {
69
+ logger: createLogger(),
70
+ services: { workspaceManager }
71
+ };
72
+ return await bashExec.execute(
73
+ { command, run_in_background: false, timeout: 2e4 },
74
+ context
75
+ );
76
+ }
77
+ const printVariables = 'echo "${DEXTO_TEST_HOST_MARKER:-absent}|${DEXTO_TEST_CONFIGURED:-absent}|${DEXTO_TEST_FORWARDED:-absent}"';
78
+ it("does not pass host variables to the command, but passes configured ones", async () => {
79
+ vi.stubEnv("DEXTO_TEST_HOST_MARKER", "leaked-host-value");
80
+ vi.stubEnv("DEXTO_TEST_FORWARDED", "forwarded-host-value");
81
+ const result = await runForeground(printVariables, {
82
+ environment: { DEXTO_TEST_CONFIGURED: "configured-value" }
83
+ });
84
+ expect(result).toMatchObject({ stdout: "absent|configured-value|absent\n" });
85
+ });
86
+ it("forwards host variables the user names in passEnvironment", async () => {
87
+ vi.stubEnv("DEXTO_TEST_HOST_MARKER", "leaked-host-value");
88
+ vi.stubEnv("DEXTO_TEST_FORWARDED", "forwarded-host-value");
89
+ const result = await runForeground(printVariables, {
90
+ passEnvironment: ["DEXTO_TEST_FORWARDED"]
91
+ });
92
+ expect(result).toMatchObject({ stdout: "absent|absent|forwarded-host-value\n" });
93
+ });
94
+ it("still finds commands on PATH", async () => {
95
+ const result = await runForeground("echo ready");
96
+ expect(result).toMatchObject({ stdout: "ready\n" });
97
+ });
98
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dexto/image-local",
3
- "version": "1.16.5",
3
+ "version": "1.17.0",
4
4
  "description": "Local development base image for Dexto agents with filesystem and process tools",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -22,17 +22,17 @@
22
22
  "dependencies": {
23
23
  "glob": "^14.0.3",
24
24
  "zod": "^4.3.6",
25
- "@dexto/agent-config": "1.16.5",
26
- "@dexto/agent-management": "1.16.5",
27
- "@dexto/core": "1.16.5",
28
- "@dexto/storage": "1.16.5",
29
- "@dexto/tools-builtins": "1.16.5",
30
- "@dexto/tools-filesystem": "1.16.5",
31
- "@dexto/tools-lifecycle": "1.16.5",
32
- "@dexto/tools-plan": "1.16.5",
33
- "@dexto/tools-process": "1.16.5",
34
- "@dexto/tools-scheduler": "1.16.5",
35
- "@dexto/tools-todo": "1.16.5"
25
+ "@dexto/agent-config": "1.17.0",
26
+ "@dexto/agent-management": "1.17.0",
27
+ "@dexto/core": "1.17.0",
28
+ "@dexto/storage": "1.17.0",
29
+ "@dexto/tools-builtins": "1.17.0",
30
+ "@dexto/tools-filesystem": "1.17.0",
31
+ "@dexto/tools-lifecycle": "1.17.0",
32
+ "@dexto/tools-plan": "1.17.0",
33
+ "@dexto/tools-process": "1.17.0",
34
+ "@dexto/tools-scheduler": "1.17.0",
35
+ "@dexto/tools-todo": "1.17.0"
36
36
  },
37
37
  "devDependencies": {
38
38
  "tsup": "^8.0.0",