zeitlich 0.2.15 → 0.2.16

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.
Files changed (64) hide show
  1. package/README.md +50 -0
  2. package/dist/adapters/sandbox/daytona/index.cjs +52 -23
  3. package/dist/adapters/sandbox/daytona/index.cjs.map +1 -1
  4. package/dist/adapters/sandbox/daytona/index.d.cts +10 -2
  5. package/dist/adapters/sandbox/daytona/index.d.ts +10 -2
  6. package/dist/adapters/sandbox/daytona/index.js +52 -23
  7. package/dist/adapters/sandbox/daytona/index.js.map +1 -1
  8. package/dist/adapters/sandbox/inmemory/index.cjs +21 -16
  9. package/dist/adapters/sandbox/inmemory/index.cjs.map +1 -1
  10. package/dist/adapters/sandbox/inmemory/index.d.cts +1 -1
  11. package/dist/adapters/sandbox/inmemory/index.d.ts +1 -1
  12. package/dist/adapters/sandbox/inmemory/index.js +21 -16
  13. package/dist/adapters/sandbox/inmemory/index.js.map +1 -1
  14. package/dist/adapters/sandbox/virtual/index.cjs +38 -38
  15. package/dist/adapters/sandbox/virtual/index.cjs.map +1 -1
  16. package/dist/adapters/sandbox/virtual/index.d.cts +6 -6
  17. package/dist/adapters/sandbox/virtual/index.d.ts +6 -6
  18. package/dist/adapters/sandbox/virtual/index.js +37 -37
  19. package/dist/adapters/sandbox/virtual/index.js.map +1 -1
  20. package/dist/adapters/thread/google-genai/index.d.cts +2 -2
  21. package/dist/adapters/thread/google-genai/index.d.ts +2 -2
  22. package/dist/adapters/thread/langchain/index.d.cts +2 -2
  23. package/dist/adapters/thread/langchain/index.d.ts +2 -2
  24. package/dist/index.cjs +2 -3
  25. package/dist/index.cjs.map +1 -1
  26. package/dist/index.d.cts +5 -5
  27. package/dist/index.d.ts +5 -5
  28. package/dist/index.js +2 -3
  29. package/dist/index.js.map +1 -1
  30. package/dist/{types-CDubRtad.d.cts → types-BMRzfELQ.d.cts} +2 -0
  31. package/dist/{types-CDubRtad.d.ts → types-BMRzfELQ.d.ts} +2 -0
  32. package/dist/{types-CwwgQ_9H.d.ts → types-BSOte_8s.d.ts} +6 -2
  33. package/dist/{types-BVP87m_W.d.cts → types-DCi2qXjN.d.cts} +6 -2
  34. package/dist/{types-Dje1TdH6.d.cts → types-Drli9aCK.d.cts} +1 -1
  35. package/dist/{types-BWvIYK28.d.ts → types-XPtivmSJ.d.ts} +1 -1
  36. package/dist/workflow.cjs +2 -3
  37. package/dist/workflow.cjs.map +1 -1
  38. package/dist/workflow.d.cts +6 -6
  39. package/dist/workflow.d.ts +6 -6
  40. package/dist/workflow.js +2 -3
  41. package/dist/workflow.js.map +1 -1
  42. package/package.json +1 -1
  43. package/src/adapters/sandbox/daytona/filesystem.ts +43 -19
  44. package/src/adapters/sandbox/daytona/index.ts +16 -3
  45. package/src/adapters/sandbox/daytona/types.ts +4 -0
  46. package/src/adapters/sandbox/inmemory/index.ts +22 -16
  47. package/src/adapters/sandbox/virtual/filesystem.ts +29 -31
  48. package/src/adapters/sandbox/virtual/index.ts +5 -3
  49. package/src/adapters/sandbox/virtual/provider.ts +5 -2
  50. package/src/adapters/sandbox/virtual/types.ts +3 -0
  51. package/src/adapters/sandbox/virtual/with-virtual-sandbox.ts +4 -3
  52. package/src/lib/sandbox/tree.integration.test.ts +153 -0
  53. package/src/lib/sandbox/types.ts +2 -0
  54. package/src/lib/session/session-edge-cases.integration.test.ts +962 -0
  55. package/src/lib/session/session.integration.test.ts +852 -0
  56. package/src/lib/session/session.ts +5 -4
  57. package/src/lib/skills/skills.integration.test.ts +308 -0
  58. package/src/lib/state/manager.integration.test.ts +342 -0
  59. package/src/lib/subagent/subagent.integration.test.ts +467 -0
  60. package/src/lib/thread/id.test.ts +50 -0
  61. package/src/lib/tool-router/auto-append-sandbox.integration.test.ts +344 -0
  62. package/src/lib/tool-router/router-edge-cases.integration.test.ts +623 -0
  63. package/src/lib/tool-router/router.integration.test.ts +699 -0
  64. package/src/lib/types.test.ts +29 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zeitlich",
3
- "version": "0.2.15",
3
+ "version": "0.2.16",
4
4
  "description": "[EXPERIMENTAL] An opinionated AI agent implementation for Temporal",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.js",
@@ -15,46 +15,62 @@ import { posix } from "node:path";
15
15
  * (e.g. `appendFile`, `cp`) are composed from primitives.
16
16
  */
17
17
  export class DaytonaSandboxFileSystem implements SandboxFileSystem {
18
- constructor(private sandbox: DaytonaSdkSandbox) {}
18
+ readonly workspaceBase: string;
19
+
20
+ constructor(
21
+ private sandbox: DaytonaSdkSandbox,
22
+ workspaceBase = "/home/daytona",
23
+ ) {
24
+ this.workspaceBase = posix.resolve("/", workspaceBase);
25
+ }
26
+
27
+ private normalisePath(path: string): string {
28
+ return posix.resolve(this.workspaceBase, path);
29
+ }
19
30
 
20
31
  async readFile(path: string): Promise<string> {
21
- const buf = await this.sandbox.fs.downloadFile(path);
32
+ const norm = this.normalisePath(path);
33
+ const buf = await this.sandbox.fs.downloadFile(norm);
22
34
  return buf.toString("utf-8");
23
35
  }
24
36
 
25
37
  async readFileBuffer(path: string): Promise<Uint8Array> {
26
- const buf = await this.sandbox.fs.downloadFile(path);
38
+ const norm = this.normalisePath(path);
39
+ const buf = await this.sandbox.fs.downloadFile(norm);
27
40
  return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
28
41
  }
29
42
 
30
43
  async writeFile(path: string, content: string | Uint8Array): Promise<void> {
44
+ const norm = this.normalisePath(path);
31
45
  const buf =
32
46
  typeof content === "string"
33
47
  ? Buffer.from(content, "utf-8")
34
48
  : Buffer.from(content);
35
- await this.sandbox.fs.uploadFile(buf, path);
49
+ await this.sandbox.fs.uploadFile(buf, norm);
36
50
  }
37
51
 
38
52
  async appendFile(
39
53
  path: string,
40
54
  content: string | Uint8Array,
41
55
  ): Promise<void> {
56
+ const norm = this.normalisePath(path);
42
57
  let existing: Buffer;
43
58
  try {
44
- existing = await this.sandbox.fs.downloadFile(path);
59
+ existing = await this.sandbox.fs.downloadFile(norm);
45
60
  } catch {
46
- return this.writeFile(path, content);
61
+ return this.writeFile(norm, content);
47
62
  }
48
63
 
49
64
  const addition =
50
65
  typeof content === "string" ? Buffer.from(content, "utf-8") : content;
51
66
  const merged = Buffer.concat([existing, Buffer.from(addition)]);
52
- await this.sandbox.fs.uploadFile(merged, path);
67
+ await this.sandbox.fs.uploadFile(merged, norm);
53
68
  }
54
69
 
55
70
  async exists(path: string): Promise<boolean> {
71
+ const norm = this.normalisePath(path);
56
72
  try {
57
- await this.sandbox.fs.getFileDetails(path);
73
+ await this.sandbox.fs.getFileDetails(norm);
58
74
  return true;
59
75
  } catch {
60
76
  return false;
@@ -62,7 +78,8 @@ export class DaytonaSandboxFileSystem implements SandboxFileSystem {
62
78
  }
63
79
 
64
80
  async stat(path: string): Promise<FileStat> {
65
- const info = await this.sandbox.fs.getFileDetails(path);
81
+ const norm = this.normalisePath(path);
82
+ const info = await this.sandbox.fs.getFileDetails(norm);
66
83
  return {
67
84
  isFile: !info.isDir,
68
85
  isDirectory: info.isDir,
@@ -76,16 +93,19 @@ export class DaytonaSandboxFileSystem implements SandboxFileSystem {
76
93
  path: string,
77
94
  _options?: { recursive?: boolean },
78
95
  ): Promise<void> {
79
- await this.sandbox.fs.createFolder(path, "755");
96
+ const norm = this.normalisePath(path);
97
+ await this.sandbox.fs.createFolder(norm, "755");
80
98
  }
81
99
 
82
100
  async readdir(path: string): Promise<string[]> {
83
- const entries = await this.sandbox.fs.listFiles(path);
101
+ const norm = this.normalisePath(path);
102
+ const entries = await this.sandbox.fs.listFiles(norm);
84
103
  return entries.map((e) => e.name);
85
104
  }
86
105
 
87
106
  async readdirWithFileTypes(path: string): Promise<DirentEntry[]> {
88
- const entries = await this.sandbox.fs.listFiles(path);
107
+ const norm = this.normalisePath(path);
108
+ const entries = await this.sandbox.fs.listFiles(norm);
89
109
  return entries.map((e) => ({
90
110
  name: e.name,
91
111
  isFile: !e.isDir,
@@ -98,8 +118,9 @@ export class DaytonaSandboxFileSystem implements SandboxFileSystem {
98
118
  path: string,
99
119
  options?: { recursive?: boolean; force?: boolean },
100
120
  ): Promise<void> {
121
+ const norm = this.normalisePath(path);
101
122
  try {
102
- await this.sandbox.fs.deleteFile(path, options?.recursive);
123
+ await this.sandbox.fs.deleteFile(norm, options?.recursive);
103
124
  } catch (err) {
104
125
  if (!options?.force) throw err;
105
126
  }
@@ -110,19 +131,23 @@ export class DaytonaSandboxFileSystem implements SandboxFileSystem {
110
131
  dest: string,
111
132
  options?: { recursive?: boolean },
112
133
  ): Promise<void> {
113
- const info = await this.sandbox.fs.getFileDetails(src);
134
+ const normSrc = this.normalisePath(src);
135
+ const normDest = this.normalisePath(dest);
136
+ const info = await this.sandbox.fs.getFileDetails(normSrc);
114
137
  if (info.isDir) {
115
138
  if (!options?.recursive) {
116
139
  throw new Error(`EISDIR: is a directory (use recursive): ${src}`);
117
140
  }
118
- await this.sandbox.process.executeCommand(`cp -r "${src}" "${dest}"`);
141
+ await this.sandbox.process.executeCommand(`cp -r "${normSrc}" "${normDest}"`);
119
142
  } else {
120
- await this.sandbox.process.executeCommand(`cp "${src}" "${dest}"`);
143
+ await this.sandbox.process.executeCommand(`cp "${normSrc}" "${normDest}"`);
121
144
  }
122
145
  }
123
146
 
124
147
  async mv(src: string, dest: string): Promise<void> {
125
- await this.sandbox.fs.moveFiles(src, dest);
148
+ const normSrc = this.normalisePath(src);
149
+ const normDest = this.normalisePath(dest);
150
+ await this.sandbox.fs.moveFiles(normSrc, normDest);
126
151
  }
127
152
 
128
153
  async readlink(_path: string): Promise<string> {
@@ -130,7 +155,6 @@ export class DaytonaSandboxFileSystem implements SandboxFileSystem {
130
155
  }
131
156
 
132
157
  resolvePath(base: string, path: string): string {
133
- if (posix.isAbsolute(path)) return posix.normalize(path);
134
- return posix.resolve(base, path);
158
+ return posix.resolve(this.normalisePath(base), path);
135
159
  }
136
160
  }
@@ -38,8 +38,9 @@ class DaytonaSandboxImpl implements Sandbox {
38
38
  constructor(
39
39
  readonly id: string,
40
40
  private sdkSandbox: DaytonaSdkSandbox,
41
+ workspaceBase = "/home/daytona",
41
42
  ) {
42
- this.fs = new DaytonaSandboxFileSystem(sdkSandbox);
43
+ this.fs = new DaytonaSandboxFileSystem(sdkSandbox, workspaceBase);
43
44
  }
44
45
 
45
46
  async exec(command: string, options?: ExecOptions): Promise<ExecResult> {
@@ -77,9 +78,12 @@ export class DaytonaSandboxProvider
77
78
  };
78
79
 
79
80
  private client: Daytona;
81
+ private readonly defaultWorkspaceBase: string;
82
+ private workspaceBaseById = new Map<string, string>();
80
83
 
81
84
  constructor(config?: DaytonaSandboxConfig) {
82
85
  this.client = new Daytona(config);
86
+ this.defaultWorkspaceBase = config?.workspaceBase ?? "/home/daytona";
83
87
  }
84
88
 
85
89
  async create(
@@ -98,7 +102,13 @@ export class DaytonaSandboxProvider
98
102
  { timeout: options?.timeout ?? 60 },
99
103
  );
100
104
 
101
- const sandbox = new DaytonaSandboxImpl(sdkSandbox.id, sdkSandbox);
105
+ const workspaceBase = options?.workspaceBase ?? this.defaultWorkspaceBase;
106
+ this.workspaceBaseById.set(sdkSandbox.id, workspaceBase);
107
+ const sandbox = new DaytonaSandboxImpl(
108
+ sdkSandbox.id,
109
+ sdkSandbox,
110
+ workspaceBase,
111
+ );
102
112
 
103
113
  if (options?.initialFiles) {
104
114
  for (const [path, content] of Object.entries(options.initialFiles)) {
@@ -112,7 +122,9 @@ export class DaytonaSandboxProvider
112
122
  async get(sandboxId: string): Promise<DaytonaSandbox> {
113
123
  try {
114
124
  const sdkSandbox = await this.client.get(sandboxId);
115
- return new DaytonaSandboxImpl(sdkSandbox.id, sdkSandbox);
125
+ const workspaceBase =
126
+ this.workspaceBaseById.get(sandboxId) ?? this.defaultWorkspaceBase;
127
+ return new DaytonaSandboxImpl(sdkSandbox.id, sdkSandbox, workspaceBase);
116
128
  } catch {
117
129
  throw new SandboxNotFoundError(sandboxId);
118
130
  }
@@ -122,6 +134,7 @@ export class DaytonaSandboxProvider
122
134
  try {
123
135
  const sdkSandbox = await this.client.get(sandboxId);
124
136
  await this.client.delete(sdkSandbox);
137
+ this.workspaceBaseById.delete(sandboxId);
125
138
  } catch {
126
139
  // Already gone
127
140
  }
@@ -10,6 +10,8 @@ export interface DaytonaSandboxConfig {
10
10
  apiKey?: string;
11
11
  apiUrl?: string;
12
12
  target?: string;
13
+ /** Default base path for resolving relative filesystem paths. */
14
+ workspaceBase?: string;
13
15
  }
14
16
 
15
17
  export interface DaytonaSandboxCreateOptions extends SandboxCreateOptions {
@@ -31,4 +33,6 @@ export interface DaytonaSandboxCreateOptions extends SandboxCreateOptions {
31
33
  labels?: Record<string, string>;
32
34
  /** Timeout in seconds for sandbox creation. Default 60. */
33
35
  timeout?: number;
36
+ /** Base path for resolving relative filesystem paths in this sandbox. */
37
+ workspaceBase?: string;
34
38
  }
@@ -26,14 +26,18 @@ import { getShortId } from "../../../lib/thread/id";
26
26
  // ============================================================================
27
27
 
28
28
  function toSandboxFs(fs: IFileSystem): SandboxFileSystem {
29
+ const workspaceBase = "/";
30
+ const normalisePath = (path: string): string => fs.resolvePath(workspaceBase, path);
31
+
29
32
  return {
30
- readFile: (path) => fs.readFile(path),
31
- readFileBuffer: (path) => fs.readFileBuffer(path),
32
- writeFile: (path, content) => fs.writeFile(path, content),
33
- appendFile: (path, content) => fs.appendFile(path, content),
34
- exists: (path) => fs.exists(path),
33
+ workspaceBase,
34
+ readFile: (path) => fs.readFile(normalisePath(path)),
35
+ readFileBuffer: (path) => fs.readFileBuffer(normalisePath(path)),
36
+ writeFile: (path, content) => fs.writeFile(normalisePath(path), content),
37
+ appendFile: (path, content) => fs.appendFile(normalisePath(path), content),
38
+ exists: (path) => fs.exists(normalisePath(path)),
35
39
  stat: async (path): Promise<FileStat> => {
36
- const s = await fs.stat(path);
40
+ const s = await fs.stat(normalisePath(path));
37
41
  return {
38
42
  isFile: s.isFile,
39
43
  isDirectory: s.isDirectory,
@@ -42,14 +46,16 @@ function toSandboxFs(fs: IFileSystem): SandboxFileSystem {
42
46
  mtime: s.mtime,
43
47
  };
44
48
  },
45
- mkdir: (path, opts) => fs.mkdir(path, opts),
46
- readdir: (path) => fs.readdir(path),
49
+ mkdir: (path, opts) => fs.mkdir(normalisePath(path), opts),
50
+ readdir: (path) => fs.readdir(normalisePath(path)),
47
51
  readdirWithFileTypes: async (path): Promise<DirentEntry[]> => {
52
+ const dirPath = normalisePath(path);
48
53
  if (!fs.readdirWithFileTypes) {
49
- const names = await fs.readdir(path);
54
+ const names = await fs.readdir(dirPath);
50
55
  return Promise.all(
51
56
  names.map(async (name) => {
52
- const s = await fs.stat(`${path}/${name}`);
57
+ const childPath = fs.resolvePath(dirPath, name);
58
+ const s = await fs.stat(childPath);
53
59
  return {
54
60
  name,
55
61
  isFile: s.isFile,
@@ -59,13 +65,13 @@ function toSandboxFs(fs: IFileSystem): SandboxFileSystem {
59
65
  })
60
66
  );
61
67
  }
62
- return fs.readdirWithFileTypes(path);
68
+ return fs.readdirWithFileTypes(dirPath);
63
69
  },
64
- rm: (path, opts) => fs.rm(path, opts),
65
- cp: (src, dest, opts) => fs.cp(src, dest, opts),
66
- mv: (src, dest) => fs.mv(src, dest),
67
- readlink: (path) => fs.readlink(path),
68
- resolvePath: (base, p) => fs.resolvePath(base, p),
70
+ rm: (path, opts) => fs.rm(normalisePath(path), opts),
71
+ cp: (src, dest, opts) => fs.cp(normalisePath(src), normalisePath(dest), opts),
72
+ mv: (src, dest) => fs.mv(normalisePath(src), normalisePath(dest)),
73
+ readlink: (path) => fs.readlink(normalisePath(path)),
74
+ resolvePath: (base, p) => fs.resolvePath(normalisePath(base), p),
69
75
  };
70
76
  }
71
77
 
@@ -4,6 +4,7 @@ import type {
4
4
  FileStat,
5
5
  } from "../../../lib/sandbox/types";
6
6
  import { SandboxNotSupportedError } from "../../../lib/sandbox/types";
7
+ import { posix } from "node:path";
7
8
  import type {
8
9
  FileEntry,
9
10
  FileEntryMetadata,
@@ -11,15 +12,9 @@ import type {
11
12
  TreeMutation,
12
13
  } from "./types";
13
14
 
14
- /**
15
- * Normalise a virtual path to a canonical form: absolute, no trailing slash
16
- * (except root), no double slashes.
17
- */
18
- function normalisePath(p: string): string {
19
- if (!p.startsWith("/")) p = "/" + p;
20
- p = p.replace(/\/+/g, "/");
21
- if (p.length > 1 && p.endsWith("/")) p = p.slice(0, -1);
22
- return p;
15
+ /** Normalize a path against the provided workspace base. */
16
+ function normalisePath(p: string, workspaceBase = "/"): string {
17
+ return posix.resolve(workspaceBase, p);
23
18
  }
24
19
 
25
20
  /** Return the parent directory of a normalised path ("/a/b" → "/a"). */
@@ -32,11 +27,14 @@ function parentDir(p: string): string {
32
27
  * Collect the set of implicit directory paths from a flat file list.
33
28
  * E.g. "/a/b/c.ts" contributes "/a/b", "/a", "/".
34
29
  */
35
- function inferDirectories(entries: { path: string }[]): Set<string> {
30
+ function inferDirectories(
31
+ entries: { path: string }[],
32
+ workspaceBase: string,
33
+ ): Set<string> {
36
34
  const dirs = new Set<string>();
37
35
  dirs.add("/");
38
36
  for (const entry of entries) {
39
- let dir = parentDir(normalisePath(entry.path));
37
+ let dir = parentDir(normalisePath(entry.path, workspaceBase));
40
38
  while (dir !== "/" && !dirs.has(dir)) {
41
39
  dirs.add(dir);
42
40
  dir = parentDir(dir);
@@ -58,6 +56,7 @@ export class VirtualSandboxFileSystem<
58
56
  TMeta = FileEntryMetadata,
59
57
  > implements SandboxFileSystem
60
58
  {
59
+ readonly workspaceBase: string;
61
60
  private entries: Map<string, FileEntry<TMeta>>;
62
61
  private directories: Set<string>;
63
62
  private mutations: TreeMutation<TMeta>[] = [];
@@ -66,11 +65,13 @@ export class VirtualSandboxFileSystem<
66
65
  tree: FileEntry<TMeta>[],
67
66
  private resolver: FileResolver<TCtx, TMeta>,
68
67
  private ctx: TCtx,
68
+ workspaceBase = "/",
69
69
  ) {
70
+ this.workspaceBase = normalisePath(workspaceBase);
70
71
  this.entries = new Map(
71
- tree.map((e) => [normalisePath(e.path), e]),
72
+ tree.map((e) => [normalisePath(e.path, this.workspaceBase), e]),
72
73
  );
73
- this.directories = inferDirectories(tree);
74
+ this.directories = inferDirectories(tree, this.workspaceBase);
74
75
  }
75
76
 
76
77
  /** Return all mutations accumulated during this invocation. */
@@ -80,7 +81,7 @@ export class VirtualSandboxFileSystem<
80
81
 
81
82
  /** Look up a file entry by virtual path. */
82
83
  getEntry(path: string): FileEntry<TMeta> | undefined {
83
- return this.entries.get(normalisePath(path));
84
+ return this.entries.get(normalisePath(path, this.workspaceBase));
84
85
  }
85
86
 
86
87
  // --------------------------------------------------------------------------
@@ -88,13 +89,13 @@ export class VirtualSandboxFileSystem<
88
89
  // --------------------------------------------------------------------------
89
90
 
90
91
  async readFile(path: string): Promise<string> {
91
- const entry = this.entries.get(normalisePath(path));
92
+ const entry = this.entries.get(normalisePath(path, this.workspaceBase));
92
93
  if (!entry) throw new Error(`ENOENT: no such file: ${path}`);
93
94
  return this.resolver.readFile(entry.id, this.ctx);
94
95
  }
95
96
 
96
97
  async readFileBuffer(path: string): Promise<Uint8Array> {
97
- const entry = this.entries.get(normalisePath(path));
98
+ const entry = this.entries.get(normalisePath(path, this.workspaceBase));
98
99
  if (!entry) throw new Error(`ENOENT: no such file: ${path}`);
99
100
  return this.resolver.readFileBuffer(entry.id, this.ctx);
100
101
  }
@@ -104,12 +105,12 @@ export class VirtualSandboxFileSystem<
104
105
  // --------------------------------------------------------------------------
105
106
 
106
107
  async exists(path: string): Promise<boolean> {
107
- const norm = normalisePath(path);
108
+ const norm = normalisePath(path, this.workspaceBase);
108
109
  return this.entries.has(norm) || this.directories.has(norm);
109
110
  }
110
111
 
111
112
  async stat(path: string): Promise<FileStat> {
112
- const norm = normalisePath(path);
113
+ const norm = normalisePath(path, this.workspaceBase);
113
114
  const entry = this.entries.get(norm);
114
115
  if (entry) {
115
116
  return {
@@ -133,7 +134,7 @@ export class VirtualSandboxFileSystem<
133
134
  }
134
135
 
135
136
  async readdir(path: string): Promise<string[]> {
136
- const norm = normalisePath(path);
137
+ const norm = normalisePath(path, this.workspaceBase);
137
138
  if (!this.directories.has(norm)) {
138
139
  throw new Error(`ENOENT: no such directory: ${path}`);
139
140
  }
@@ -159,7 +160,7 @@ export class VirtualSandboxFileSystem<
159
160
 
160
161
  async readdirWithFileTypes(path: string): Promise<DirentEntry[]> {
161
162
  const names = await this.readdir(path);
162
- const norm = normalisePath(path);
163
+ const norm = normalisePath(path, this.workspaceBase);
163
164
  const prefix = norm === "/" ? "/" : norm + "/";
164
165
 
165
166
  return names.map((name) => {
@@ -175,7 +176,7 @@ export class VirtualSandboxFileSystem<
175
176
  // --------------------------------------------------------------------------
176
177
 
177
178
  async writeFile(path: string, content: string | Uint8Array): Promise<void> {
178
- const norm = normalisePath(path);
179
+ const norm = normalisePath(path, this.workspaceBase);
179
180
  const existing = this.entries.get(norm);
180
181
 
181
182
  if (existing) {
@@ -201,7 +202,7 @@ export class VirtualSandboxFileSystem<
201
202
  }
202
203
 
203
204
  async appendFile(path: string, content: string | Uint8Array): Promise<void> {
204
- const norm = normalisePath(path);
205
+ const norm = normalisePath(path, this.workspaceBase);
205
206
  const existing = this.entries.get(norm);
206
207
 
207
208
  if (!existing) {
@@ -226,7 +227,7 @@ export class VirtualSandboxFileSystem<
226
227
  }
227
228
 
228
229
  async mkdir(_path: string, _options?: { recursive?: boolean }): Promise<void> {
229
- const norm = normalisePath(_path);
230
+ const norm = normalisePath(_path, this.workspaceBase);
230
231
  if (this.directories.has(norm)) return;
231
232
 
232
233
  if (_options?.recursive) {
@@ -245,7 +246,7 @@ export class VirtualSandboxFileSystem<
245
246
  path: string,
246
247
  options?: { recursive?: boolean; force?: boolean },
247
248
  ): Promise<void> {
248
- const norm = normalisePath(path);
249
+ const norm = normalisePath(path, this.workspaceBase);
249
250
  const entry = this.entries.get(norm);
250
251
 
251
252
  if (entry) {
@@ -284,8 +285,8 @@ export class VirtualSandboxFileSystem<
284
285
  dest: string,
285
286
  _options?: { recursive?: boolean },
286
287
  ): Promise<void> {
287
- const normSrc = normalisePath(src);
288
- const normDest = normalisePath(dest);
288
+ const normSrc = normalisePath(src, this.workspaceBase);
289
+ const normDest = normalisePath(dest, this.workspaceBase);
289
290
 
290
291
  const entry = this.entries.get(normSrc);
291
292
  if (entry) {
@@ -325,10 +326,7 @@ export class VirtualSandboxFileSystem<
325
326
  }
326
327
 
327
328
  resolvePath(base: string, path: string): string {
328
- if (path.startsWith("/")) return normalisePath(path);
329
- const combined =
330
- base.endsWith("/") ? base + path : base + "/" + path;
331
- return normalisePath(combined);
329
+ return posix.resolve(normalisePath(base, this.workspaceBase), path);
332
330
  }
333
331
 
334
332
  // --------------------------------------------------------------------------
@@ -336,7 +334,7 @@ export class VirtualSandboxFileSystem<
336
334
  // --------------------------------------------------------------------------
337
335
 
338
336
  private addParentDirectories(filePath: string): void {
339
- let dir = parentDir(normalisePath(filePath));
337
+ let dir = parentDir(normalisePath(filePath, this.workspaceBase));
340
338
  while (!this.directories.has(dir)) {
341
339
  this.directories.add(dir);
342
340
  dir = parentDir(dir);
@@ -34,9 +34,10 @@ class VirtualSandboxImpl<
34
34
  readonly id: string,
35
35
  tree: FileEntry<TMeta>[],
36
36
  resolver: FileResolver<TCtx, TMeta>,
37
- ctx: TCtx
37
+ ctx: TCtx,
38
+ workspaceBase = "/",
38
39
  ) {
39
- this.fs = new VirtualSandboxFileSystem(tree, resolver, ctx);
40
+ this.fs = new VirtualSandboxFileSystem(tree, resolver, ctx, workspaceBase);
40
41
  }
41
42
 
42
43
  async exec(_command: string, _options?: ExecOptions): Promise<ExecResult> {
@@ -67,8 +68,9 @@ export function createVirtualSandbox<
67
68
  tree: FileEntry<TMeta>[],
68
69
  resolver: FileResolver<TCtx, TMeta>,
69
70
  ctx: TCtx,
71
+ workspaceBase = "/",
70
72
  ): VirtualSandbox<TCtx, TMeta> {
71
- return new VirtualSandboxImpl(id, tree, resolver, ctx);
73
+ return new VirtualSandboxImpl(id, tree, resolver, ctx, workspaceBase);
72
74
  }
73
75
 
74
76
  // Re-exports for convenience
@@ -16,7 +16,7 @@ import type {
16
16
  * Stateless {@link SandboxProvider} backed by a {@link FileResolver}.
17
17
  *
18
18
  * The provider holds **no internal state**. All sandbox state (sandboxId,
19
- * fileTree, resolverContext) is returned as a `stateUpdate` from
19
+ * fileTree, resolverContext, workspaceBase) is returned as a `stateUpdate` from
20
20
  * {@link create} and merged into the workflow's `AgentState` by the session.
21
21
  * {@link withVirtualSandbox} reads this state back on every tool invocation.
22
22
  *
@@ -59,12 +59,14 @@ export class VirtualSandboxProvider<
59
59
  const fileTree = await this.resolver.resolveEntries(
60
60
  options.resolverContext
61
61
  );
62
+ const workspaceBase = options.workspaceBase ?? "/";
62
63
 
63
64
  const sandbox = createVirtualSandbox(
64
65
  sandboxId,
65
66
  fileTree,
66
67
  this.resolver,
67
- options.resolverContext
68
+ options.resolverContext,
69
+ workspaceBase,
68
70
  );
69
71
 
70
72
  return {
@@ -73,6 +75,7 @@ export class VirtualSandboxProvider<
73
75
  sandboxId,
74
76
  fileTree,
75
77
  resolverContext: options.resolverContext,
78
+ workspaceBase,
76
79
  },
77
80
  };
78
81
  }
@@ -79,6 +79,8 @@ export interface VirtualSandboxCreateOptions<
79
79
  TCtx,
80
80
  > extends SandboxCreateOptions {
81
81
  resolverContext: TCtx;
82
+ /** Base path for resolving relative filesystem paths (default "/"). */
83
+ workspaceBase?: string;
82
84
  }
83
85
 
84
86
  // ============================================================================
@@ -97,6 +99,7 @@ export interface VirtualSandboxState<
97
99
  sandboxId: string;
98
100
  fileTree: FileEntry<TMeta>[];
99
101
  resolverContext: TCtx;
102
+ workspaceBase?: string;
100
103
  }
101
104
 
102
105
  // ============================================================================
@@ -15,7 +15,7 @@ import { createVirtualSandbox } from "./index";
15
15
  * the parent workflow for the current file tree and resolver context.
16
16
  *
17
17
  * On each invocation the wrapper:
18
- * 1. Queries the workflow's `AgentState` for `fileTree` and `resolverContext`
18
+ * 1. Queries the workflow's `AgentState` for `fileTree`, `resolverContext`, and `workspaceBase`
19
19
  * 2. Creates an ephemeral {@link VirtualSandbox} from tree + provider's resolver
20
20
  * 3. Runs the inner handler
21
21
  * 4. Returns the handler's result together with any {@link TreeMutation}s
@@ -63,7 +63,7 @@ export function withVirtualSandbox<
63
63
  const state =
64
64
  await queryParentWorkflowState<VirtualSandboxState<TCtx, TMeta>>(client);
65
65
 
66
- const { sandboxId, fileTree, resolverContext } = state;
66
+ const { sandboxId, fileTree, resolverContext, workspaceBase } = state;
67
67
  if (!fileTree || !sandboxId) {
68
68
  return {
69
69
  toolResponse: `Error: No fileTree/sandboxId in agent state. The ${context.toolName} tool requires a virtual sandbox.`,
@@ -75,7 +75,8 @@ export function withVirtualSandbox<
75
75
  sandboxId,
76
76
  fileTree,
77
77
  provider.resolver,
78
- resolverContext
78
+ resolverContext,
79
+ workspaceBase ?? "/",
79
80
  );
80
81
  const response = await handler(args, { ...context, sandbox });
81
82
  const mutations = sandbox.fs.getMutations();