@yansirplus/workspace-env-local 0.2.9

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/PUBLIC_API.md ADDED
@@ -0,0 +1,26 @@
1
+ <!-- generated by scripts/generate-docs.mjs; edit docs/surface.json and docs/api/workspace-env-local.md -->
2
+
3
+ # @agent-os/workspace-env-local Public API
4
+
5
+ Status: 0.2.x active development. Public exports are limited to local WorkspaceEnv adapter types and factories.
6
+
7
+ ## Public exports
8
+
9
+ - `.:LocalWorkspaceEnvError`
10
+ - `.:LocalWorkspaceEnvOptions`
11
+ - `.:LocalWorkspaceEnvWithRoot`
12
+ - `.:TemporaryLocalWorkspaceEnvOptions`
13
+ - `.:makeLocalWorkspaceEnv`
14
+ - `.:makeTemporaryLocalWorkspaceEnv`
15
+
16
+ ## Experimental exports
17
+
18
+ None.
19
+
20
+ ## Deprecated exports
21
+
22
+ None.
23
+
24
+ ## Internal-only exports
25
+
26
+ Any package file or symbol not listed above.
package/README.md ADDED
@@ -0,0 +1,38 @@
1
+ <!-- generated by scripts/generate-docs.mjs; edit docs/surface.json and docs/packages/workspace-env-local.md -->
2
+
3
+ # @agent-os/workspace-env-local
4
+
5
+ ## Purpose
6
+
7
+ Local host adapter for `@agent-os/workspace-env`.
8
+
9
+ ## Public API Status
10
+
11
+ 0.2.x active development. Provider-specific exports are listed in `PUBLIC_API.md`; this is not an API freeze.
12
+
13
+ ## Invariant
14
+
15
+ The adapter maps a virtual workspace root such as `/workspace` to one explicit
16
+ host directory and declares generated tools as `effectful host`. Host execution
17
+ is never implicit: a consumer must construct this adapter directly, and the
18
+ tool contract carries the host execution domain.
19
+
20
+ This is not a `just-bash` fallback. It does not embed an in-memory shell and it
21
+ does not silently replace Cloudflare Sandbox or another provider. It uses real
22
+ Node fs and real host shell calls bound to the declared root.
23
+
24
+ ## Minimal Usage
25
+
26
+ Create an isolated root directory, then call `makeLocalWorkspaceEnv`. The
27
+ returned env can be passed to `createWorkspaceTools`.
28
+
29
+ For throwaway tests or onboarding demos, call `makeTemporaryLocalWorkspaceEnv`.
30
+ The returned root directory remains caller-owned and should be removed by the
31
+ test or demo harness.
32
+
33
+ ## Verification
34
+
35
+ ```sh
36
+ cd packages/execution-domains/workspace-env-local
37
+ vp test run
38
+ ```
@@ -0,0 +1,24 @@
1
+ import { type WorkspaceEnv } from "@yansirplus/workspace-env";
2
+ export interface LocalWorkspaceEnvOptions {
3
+ readonly rootDir: string;
4
+ readonly cwd?: string;
5
+ readonly workspaceRef?: string;
6
+ readonly env?: Readonly<Record<string, string | undefined>>;
7
+ readonly envAllowlist?: ReadonlyArray<string>;
8
+ }
9
+ export interface TemporaryLocalWorkspaceEnvOptions {
10
+ readonly prefix?: string;
11
+ readonly cwd?: string;
12
+ readonly workspaceRef?: string;
13
+ readonly env?: Readonly<Record<string, string | undefined>>;
14
+ readonly envAllowlist?: ReadonlyArray<string>;
15
+ }
16
+ export interface LocalWorkspaceEnvWithRoot {
17
+ readonly env: WorkspaceEnv;
18
+ readonly rootDir: string;
19
+ }
20
+ export declare class LocalWorkspaceEnvError extends Error {
21
+ readonly name = "LocalWorkspaceEnvError";
22
+ }
23
+ export declare const makeLocalWorkspaceEnv: (options: LocalWorkspaceEnvOptions) => WorkspaceEnv;
24
+ export declare const makeTemporaryLocalWorkspaceEnv: (options?: TemporaryLocalWorkspaceEnvOptions) => Promise<LocalWorkspaceEnvWithRoot>;
package/dist/index.js ADDED
@@ -0,0 +1,254 @@
1
+ import { spawn } from "node:child_process";
2
+ import { promises as fs } from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { createWorkspaceEnv, } from "@yansirplus/workspace-env";
6
+ export class LocalWorkspaceEnvError extends Error {
7
+ name = "LocalWorkspaceEnvError";
8
+ }
9
+ const DEFAULT_CWD = "/workspace";
10
+ const TIMEOUT_EXIT_CODE = 124;
11
+ const localError = (message) => new LocalWorkspaceEnvError(message);
12
+ const abortErrorFor = (signal) => {
13
+ const reason = signal.reason;
14
+ if (reason instanceof Error)
15
+ return reason;
16
+ const error = new Error(reason === undefined ? "workspace operation aborted" : String(reason));
17
+ error.name = "AbortError";
18
+ return error;
19
+ };
20
+ const checkSignal = (signal) => {
21
+ if (signal?.aborted)
22
+ throw abortErrorFor(signal);
23
+ };
24
+ const normalizeRootDir = (rootDir) => {
25
+ const resolved = path.resolve(rootDir);
26
+ if (resolved.length === path.parse(resolved).root.length) {
27
+ throw localError("local workspace root cannot be filesystem root");
28
+ }
29
+ return resolved;
30
+ };
31
+ const relativeVirtualPath = (cwd, virtualPath) => {
32
+ if (virtualPath === cwd)
33
+ return "";
34
+ if (!virtualPath.startsWith(`${cwd}/`)) {
35
+ throw localError("workspace path is outside local workspace root");
36
+ }
37
+ return virtualPath.slice(cwd.length + 1);
38
+ };
39
+ const hostPathFor = (rootDir, cwd, virtualPath) => {
40
+ const hostPath = path.resolve(rootDir, relativeVirtualPath(cwd, virtualPath));
41
+ if (hostPath !== rootDir && !hostPath.startsWith(`${rootDir}${path.sep}`)) {
42
+ throw localError("workspace path escapes local root");
43
+ }
44
+ return hostPath;
45
+ };
46
+ const statType = (stat) => stat.isFile() ? "file" : stat.isDirectory() ? "directory" : "other";
47
+ const fileStat = async (hostPath) => {
48
+ const stat = await fs.stat(hostPath);
49
+ return {
50
+ type: statType(stat),
51
+ size: stat.size,
52
+ mtimeMs: stat.mtimeMs,
53
+ };
54
+ };
55
+ const truncateUtf8 = (bytes, maxOutputBytes) => {
56
+ if (maxOutputBytes === undefined || bytes.byteLength <= maxOutputBytes) {
57
+ return { text: new TextDecoder().decode(bytes), bytes: bytes.byteLength, truncated: false };
58
+ }
59
+ return {
60
+ text: new TextDecoder().decode(bytes.slice(0, maxOutputBytes)),
61
+ bytes: bytes.byteLength,
62
+ truncated: true,
63
+ };
64
+ };
65
+ const appendChunk = (chunks, chunk) => {
66
+ chunks.push(typeof chunk === "string" ? new TextEncoder().encode(chunk) : chunk);
67
+ };
68
+ const concatChunks = (chunks) => {
69
+ const length = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0);
70
+ const output = new Uint8Array(length);
71
+ let offset = 0;
72
+ for (const chunk of chunks) {
73
+ output.set(chunk, offset);
74
+ offset += chunk.byteLength;
75
+ }
76
+ return output;
77
+ };
78
+ const allowedEnv = (source, allowlist) => {
79
+ const entries = [];
80
+ for (const name of allowlist) {
81
+ const value = source[name];
82
+ if (value !== undefined)
83
+ entries.push([name, value]);
84
+ }
85
+ return Object.fromEntries(entries);
86
+ };
87
+ const signalAbortPromise = (signal) => new Promise((_resolve, reject) => {
88
+ if (signal === undefined)
89
+ return;
90
+ if (signal.aborted) {
91
+ reject(abortErrorFor(signal));
92
+ return;
93
+ }
94
+ signal.addEventListener("abort", () => reject(abortErrorFor(signal)), { once: true });
95
+ });
96
+ const runLocalCommand = async (command, hostCwd, sourceEnv, envAllowlist, options) => {
97
+ if (options.envRefs !== undefined && Object.keys(options.envRefs).length > 0) {
98
+ throw localError("local WorkspaceEnv does not resolve symbolic envRefs");
99
+ }
100
+ if (options.materialRefs !== undefined && options.materialRefs.length > 0) {
101
+ throw localError("local WorkspaceEnv does not resolve symbolic materialRefs");
102
+ }
103
+ if (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0) {
104
+ throw localError("local WorkspaceEnv exec requires a positive finite timeoutMs");
105
+ }
106
+ checkSignal(options.signal);
107
+ const started = Date.now();
108
+ const stdoutChunks = [];
109
+ const stderrChunks = [];
110
+ let timedOut = false;
111
+ let externalAbort = false;
112
+ const child = spawn(command, {
113
+ cwd: hostCwd,
114
+ env: allowedEnv(sourceEnv, envAllowlist),
115
+ shell: true,
116
+ stdio: ["ignore", "pipe", "pipe"],
117
+ });
118
+ const kill = () => {
119
+ if (!child.killed)
120
+ child.kill("SIGTERM");
121
+ };
122
+ const abortListener = () => {
123
+ externalAbort = true;
124
+ kill();
125
+ };
126
+ options.signal?.addEventListener("abort", abortListener, { once: true });
127
+ const timeout = setTimeout(() => {
128
+ timedOut = true;
129
+ kill();
130
+ }, options.timeoutMs);
131
+ try {
132
+ const result = await Promise.race([
133
+ new Promise((resolve, reject) => {
134
+ child.stdout?.on("data", (chunk) => appendChunk(stdoutChunks, chunk));
135
+ child.stderr?.on("data", (chunk) => appendChunk(stderrChunks, chunk));
136
+ child.once("error", reject);
137
+ child.once("close", (code, signal) => resolve({ code, signal }));
138
+ }),
139
+ signalAbortPromise(options.signal),
140
+ ]);
141
+ if (externalAbort)
142
+ throw abortErrorFor(options.signal);
143
+ const stdout = truncateUtf8(concatChunks(stdoutChunks), options.maxOutputBytes);
144
+ const stderrBytes = concatChunks(stderrChunks);
145
+ const timeoutMessage = timedOut
146
+ ? new TextEncoder().encode(`${stderrBytes.byteLength === 0 ? "" : "\n"}Command timed out after ${options.timeoutMs}ms`)
147
+ : new Uint8Array();
148
+ const stderr = truncateUtf8(timeoutMessage.byteLength === 0 ? stderrBytes : concatChunks([stderrBytes, timeoutMessage]), options.maxOutputBytes);
149
+ return {
150
+ exitCode: timedOut ? TIMEOUT_EXIT_CODE : (result.code ?? (result.signal === null ? 1 : 128)),
151
+ stdout: stdout.text,
152
+ stderr: stderr.text,
153
+ stdoutBytes: stdout.bytes,
154
+ stderrBytes: stderr.bytes,
155
+ stdoutTruncated: stdout.truncated,
156
+ stderrTruncated: stderr.truncated,
157
+ durationMs: Date.now() - started,
158
+ };
159
+ }
160
+ finally {
161
+ clearTimeout(timeout);
162
+ options.signal?.removeEventListener("abort", abortListener);
163
+ }
164
+ };
165
+ const localBackend = (rootDir, cwd, sourceEnv, envAllowlist) => ({
166
+ readFile: async (virtualPath, options) => {
167
+ checkSignal(options?.signal);
168
+ const content = await fs.readFile(hostPathFor(rootDir, cwd, virtualPath), "utf8");
169
+ checkSignal(options?.signal);
170
+ return content;
171
+ },
172
+ readFileBuffer: async (virtualPath, options) => {
173
+ checkSignal(options?.signal);
174
+ const content = await fs.readFile(hostPathFor(rootDir, cwd, virtualPath));
175
+ checkSignal(options?.signal);
176
+ return content;
177
+ },
178
+ writeFile: async (virtualPath, content, options) => {
179
+ checkSignal(options?.signal);
180
+ await fs.writeFile(hostPathFor(rootDir, cwd, virtualPath), content);
181
+ checkSignal(options?.signal);
182
+ },
183
+ stat: async (virtualPath, options) => {
184
+ checkSignal(options?.signal);
185
+ const stat = await fileStat(hostPathFor(rootDir, cwd, virtualPath));
186
+ checkSignal(options?.signal);
187
+ return stat;
188
+ },
189
+ readdir: async (virtualPath, options) => {
190
+ checkSignal(options?.signal);
191
+ const entries = await fs.readdir(hostPathFor(rootDir, cwd, virtualPath));
192
+ checkSignal(options?.signal);
193
+ return entries.sort();
194
+ },
195
+ exists: async (virtualPath, options) => {
196
+ checkSignal(options?.signal);
197
+ try {
198
+ await fs.access(hostPathFor(rootDir, cwd, virtualPath));
199
+ checkSignal(options?.signal);
200
+ return true;
201
+ }
202
+ catch {
203
+ checkSignal(options?.signal);
204
+ return false;
205
+ }
206
+ },
207
+ mkdir: async (virtualPath, options) => {
208
+ checkSignal(options?.signal);
209
+ await fs.mkdir(hostPathFor(rootDir, cwd, virtualPath), {
210
+ recursive: options?.recursive ?? false,
211
+ });
212
+ checkSignal(options?.signal);
213
+ },
214
+ rm: async (virtualPath, options) => {
215
+ checkSignal(options?.signal);
216
+ await fs.rm(hostPathFor(rootDir, cwd, virtualPath), {
217
+ recursive: options?.recursive ?? false,
218
+ force: options?.force ?? false,
219
+ });
220
+ checkSignal(options?.signal);
221
+ },
222
+ exec: async (command, options) => {
223
+ const hostCwd = hostPathFor(rootDir, cwd, options.cwd ?? cwd);
224
+ await fs.mkdir(hostCwd, { recursive: true });
225
+ return runLocalCommand(command, hostCwd, sourceEnv, envAllowlist, options);
226
+ },
227
+ });
228
+ export const makeLocalWorkspaceEnv = (options) => {
229
+ const rootDir = normalizeRootDir(options.rootDir);
230
+ const cwd = options.cwd ?? DEFAULT_CWD;
231
+ const envAllowlist = options.envAllowlist ?? [];
232
+ return createWorkspaceEnv({
233
+ cwd,
234
+ domain: {
235
+ kind: "host",
236
+ ref: options.workspaceRef ?? `local:${rootDir}`,
237
+ envAllowlist,
238
+ },
239
+ backend: localBackend(rootDir, cwd, options.env ?? process.env, envAllowlist),
240
+ });
241
+ };
242
+ export const makeTemporaryLocalWorkspaceEnv = async (options = {}) => {
243
+ const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), options.prefix ?? "agent-os-workspace-"));
244
+ return {
245
+ rootDir,
246
+ env: makeLocalWorkspaceEnv({
247
+ rootDir,
248
+ cwd: options.cwd,
249
+ workspaceRef: options.workspaceRef,
250
+ env: options.env,
251
+ envAllowlist: options.envAllowlist,
252
+ }),
253
+ };
254
+ };
package/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "@yansirplus/workspace-env-local",
3
+ "version": "0.2.9",
4
+ "type": "module",
5
+ "license": "UNLICENSED",
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
9
+ "main": "./dist/index.js",
10
+ "types": "./dist/index.d.ts",
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/index.d.ts",
14
+ "default": "./dist/index.js"
15
+ }
16
+ },
17
+ "files": [
18
+ "dist",
19
+ "README.md",
20
+ "PUBLIC_API.md"
21
+ ],
22
+ "dependencies": {
23
+ "@yansirplus/workspace-env": "0.2.9"
24
+ }
25
+ }