@poncho-ai/harness 0.35.0 → 0.36.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.
Files changed (57) hide show
  1. package/.turbo/turbo-build.log +6 -5
  2. package/.turbo/turbo-test.log +15169 -0
  3. package/CHANGELOG.md +18 -0
  4. package/dist/chunk-MCKGQKYU.js +15 -0
  5. package/dist/dist-3KMQR4IO.js +27092 -0
  6. package/dist/index.d.ts +485 -29
  7. package/dist/index.js +2839 -2114
  8. package/dist/isolate-5MISBSUK.js +733 -0
  9. package/dist/isolate-5R6762YA.js +605 -0
  10. package/dist/isolate-KUZ5NOPG.js +727 -0
  11. package/dist/isolate-LOL3T7RA.js +729 -0
  12. package/dist/isolate-N22X4TCE.js +740 -0
  13. package/dist/isolate-T7WXM7IL.js +1490 -0
  14. package/dist/isolate-TCWTUVG4.js +1532 -0
  15. package/dist/isolate-WFOLANOB.js +768 -0
  16. package/package.json +22 -3
  17. package/scripts/migrate-to-engine.mjs +556 -0
  18. package/src/config.ts +106 -1
  19. package/src/harness.ts +226 -91
  20. package/src/index.ts +5 -0
  21. package/src/isolate/bindings.ts +206 -0
  22. package/src/isolate/bundler.ts +179 -0
  23. package/src/isolate/index.ts +10 -0
  24. package/src/isolate/polyfills.ts +796 -0
  25. package/src/isolate/run-code-tool.ts +220 -0
  26. package/src/isolate/runtime.ts +286 -0
  27. package/src/isolate/type-stubs.ts +196 -0
  28. package/src/memory.ts +129 -198
  29. package/src/reminder-store.ts +3 -237
  30. package/src/secrets-store.ts +2 -91
  31. package/src/state.ts +11 -1302
  32. package/src/storage/engine.ts +106 -0
  33. package/src/storage/index.ts +59 -0
  34. package/src/storage/memory-engine.ts +588 -0
  35. package/src/storage/postgres-engine.ts +139 -0
  36. package/src/storage/schema.ts +145 -0
  37. package/src/storage/sql-dialect.ts +963 -0
  38. package/src/storage/sqlite-engine.ts +99 -0
  39. package/src/storage/store-adapters.ts +100 -0
  40. package/src/todo-tools.ts +1 -136
  41. package/src/upload-store.ts +1 -0
  42. package/src/vfs/bash-manager.ts +120 -0
  43. package/src/vfs/bash-tool.ts +59 -0
  44. package/src/vfs/create-bash-fs.ts +32 -0
  45. package/src/vfs/edit-file-tool.ts +72 -0
  46. package/src/vfs/index.ts +5 -0
  47. package/src/vfs/poncho-fs-adapter.ts +267 -0
  48. package/src/vfs/protected-fs.ts +177 -0
  49. package/src/vfs/read-file-tool.ts +103 -0
  50. package/src/vfs/write-file-tool.ts +49 -0
  51. package/test/harness.test.ts +30 -36
  52. package/test/isolate-vfs.test.ts +453 -0
  53. package/test/isolate.test.ts +252 -0
  54. package/test/state.test.ts +4 -27
  55. package/test/storage-engine.test.ts +250 -0
  56. package/test/vfs.test.ts +242 -0
  57. package/src/kv-store.ts +0 -216
@@ -0,0 +1,242 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { Bash } from "just-bash";
3
+ import { InMemoryEngine } from "../src/storage/memory-engine.js";
4
+ import { PonchoFsAdapter } from "../src/vfs/poncho-fs-adapter.js";
5
+ import { ProtectedFs } from "../src/vfs/protected-fs.js";
6
+ import { createBashFs } from "../src/vfs/create-bash-fs.js";
7
+ import { BashEnvironmentManager } from "../src/vfs/bash-manager.js";
8
+
9
+ const MB = 1024 * 1024;
10
+ const LIMITS = { maxFileSize: 10 * MB, maxTotalStorage: 100 * MB };
11
+
12
+ describe("PonchoFsAdapter", () => {
13
+ it("implements IFileSystem contract with InMemoryEngine", async () => {
14
+ const engine = new InMemoryEngine("test");
15
+ await engine.initialize();
16
+ const adapter = new PonchoFsAdapter(engine, "t1", LIMITS);
17
+
18
+ // writeFile + readFile
19
+ await adapter.writeFile("/hello.txt", "world");
20
+ const content = await adapter.readFile("/hello.txt");
21
+ expect(content).toBe("world");
22
+
23
+ // readFileBuffer
24
+ const buf = await adapter.readFileBuffer("/hello.txt");
25
+ expect(new TextDecoder().decode(buf)).toBe("world");
26
+
27
+ // exists
28
+ expect(await adapter.exists("/hello.txt")).toBe(true);
29
+ expect(await adapter.exists("/nope.txt")).toBe(false);
30
+
31
+ // stat
32
+ const stat = await adapter.stat("/hello.txt");
33
+ expect(stat.isFile).toBe(true);
34
+ expect(stat.size).toBe(5);
35
+
36
+ // mkdir + readdir
37
+ await adapter.mkdir("/mydir", { recursive: true });
38
+ await adapter.writeFile("/mydir/a.txt", "a");
39
+ await adapter.writeFile("/mydir/b.txt", "b");
40
+ const entries = await adapter.readdir("/mydir");
41
+ expect(entries.sort()).toEqual(["a.txt", "b.txt"]);
42
+
43
+ // cp
44
+ await adapter.cp("/mydir/a.txt", "/mydir/copy.txt");
45
+ expect(await adapter.readFile("/mydir/copy.txt")).toBe("a");
46
+
47
+ // mv
48
+ await adapter.mv("/mydir/copy.txt", "/mydir/moved.txt");
49
+ expect(await adapter.exists("/mydir/copy.txt")).toBe(false);
50
+ expect(await adapter.readFile("/mydir/moved.txt")).toBe("a");
51
+
52
+ // rm
53
+ await adapter.rm("/mydir/moved.txt");
54
+ expect(await adapter.exists("/mydir/moved.txt")).toBe(false);
55
+
56
+ // getAllPaths
57
+ const paths = adapter.getAllPaths();
58
+ expect(paths).toContain("/hello.txt");
59
+
60
+ // chmod
61
+ await adapter.chmod("/hello.txt", 0o755);
62
+ const stat2 = await adapter.stat("/hello.txt");
63
+ expect(stat2.mode).toBe(0o755);
64
+
65
+ await engine.close();
66
+ });
67
+
68
+ it("enforces file size limits", async () => {
69
+ const engine = new InMemoryEngine("test");
70
+ await engine.initialize();
71
+ const adapter = new PonchoFsAdapter(engine, "t1", { maxFileSize: 10, maxTotalStorage: 1000 });
72
+
73
+ await expect(adapter.writeFile("/big.txt", "x".repeat(20))).rejects.toThrow("File too large");
74
+ await engine.close();
75
+ });
76
+ });
77
+
78
+ describe("ProtectedFs", () => {
79
+ it("blocks writes to protected paths", async () => {
80
+ const engine = new InMemoryEngine("test");
81
+ await engine.initialize();
82
+ const inner = new PonchoFsAdapter(engine, "t1", LIMITS);
83
+ const protectedFs = new ProtectedFs(inner);
84
+
85
+ // Write to .env should be blocked (guard throws synchronously)
86
+ expect(() => protectedFs.writeFile(".env", "SECRET=bad")).toThrow("Permission denied");
87
+ expect(() => protectedFs.writeFile(".env.local", "SECRET=bad")).toThrow("Permission denied");
88
+ expect(() => protectedFs.writeFile(".git/config", "bad")).toThrow("Permission denied");
89
+ expect(() => protectedFs.writeFile("node_modules/foo", "bad")).toThrow("Permission denied");
90
+
91
+ // Write to normal paths should work
92
+ await protectedFs.writeFile("/src/index.ts", "console.log('ok')");
93
+ const content = await protectedFs.readFile("/src/index.ts");
94
+ expect(content).toBe("console.log('ok')");
95
+
96
+ // Reads from protected paths should work
97
+ await inner.writeFile("/.env", "SECRET=ok");
98
+ const secret = await protectedFs.readFile("/.env");
99
+ expect(secret).toBe("SECRET=ok");
100
+
101
+ await engine.close();
102
+ });
103
+ });
104
+
105
+ describe("bash + VFS integration", () => {
106
+ it("executes bash commands against the VFS", async () => {
107
+ const engine = new InMemoryEngine("test");
108
+ await engine.initialize();
109
+ const manager = new BashEnvironmentManager(engine, LIMITS, null);
110
+ const bash = manager.getOrCreate("t1");
111
+
112
+ // Write via bash
113
+ const writeResult = await bash.exec('echo "hello from bash" > /greeting.txt');
114
+ expect(writeResult.exitCode).toBe(0);
115
+
116
+ // Read via bash
117
+ const catResult = await bash.exec("cat /greeting.txt");
118
+ expect(catResult.stdout.trim()).toBe("hello from bash");
119
+
120
+ // Data processing pipeline
121
+ await bash.exec('echo -e "3\\n1\\n2" > /numbers.txt');
122
+ const sortResult = await bash.exec("cat /numbers.txt | sort -n");
123
+ expect(sortResult.stdout.trim()).toBe("1\n2\n3");
124
+
125
+ // Files persist across exec calls
126
+ const lsResult = await bash.exec("ls /");
127
+ expect(lsResult.stdout).toContain("greeting.txt");
128
+ expect(lsResult.stdout).toContain("numbers.txt");
129
+
130
+ // Verify data is in the engine (not just in bash memory)
131
+ const adapter = manager.getAdapter("t1");
132
+ const content = await adapter.readFile("/greeting.txt");
133
+ expect(content.trim()).toBe("hello from bash");
134
+
135
+ manager.destroyAll();
136
+ await engine.close();
137
+ });
138
+
139
+ it("creates production filesystem without /project mount", () => {
140
+ const engine = new InMemoryEngine("test");
141
+ const adapter = new PonchoFsAdapter(engine, "t1", LIMITS);
142
+
143
+ // null workingDir = production mode, no project mount
144
+ const fs = createBashFs(adapter, null);
145
+ expect(fs).toBe(adapter); // Should return adapter directly
146
+ });
147
+
148
+ it("enables curl when network config is provided", async () => {
149
+ const engine = new InMemoryEngine("test");
150
+ await engine.initialize();
151
+ const manager = new BashEnvironmentManager(engine, LIMITS, null, undefined, {
152
+ dangerouslyAllowAll: true,
153
+ });
154
+ const bash = manager.getOrCreate("t1");
155
+
156
+ // curl should be registered as a command when network is configured.
157
+ // Fetching a known URL and writing to VFS:
158
+ const result = await bash.exec("curl -s -o /test.txt https://example.com");
159
+ expect(result.exitCode).toBe(0);
160
+
161
+ // Verify the file was written to VFS
162
+ const adapter = manager.getAdapter("t1");
163
+ expect(await adapter.exists("/test.txt")).toBe(true);
164
+ const content = await adapter.readFile("/test.txt");
165
+ expect(content.length).toBeGreaterThan(0);
166
+ expect(content).toContain("Example Domain");
167
+
168
+ manager.destroyAll();
169
+ await engine.close();
170
+ });
171
+
172
+ it("blocks curl when no network config is provided", async () => {
173
+ const engine = new InMemoryEngine("test");
174
+ await engine.initialize();
175
+ const manager = new BashEnvironmentManager(engine, LIMITS, null);
176
+ const bash = manager.getOrCreate("t1");
177
+
178
+ const result = await bash.exec("curl https://example.com");
179
+ // curl should either not be found or be blocked
180
+ expect(result.exitCode).not.toBe(0);
181
+
182
+ manager.destroyAll();
183
+ await engine.close();
184
+ });
185
+
186
+ it("restricts commands when whitelist is provided", async () => {
187
+ const engine = new InMemoryEngine("test");
188
+ await engine.initialize();
189
+ const manager = new BashEnvironmentManager(engine, LIMITS, null, {
190
+ commands: ["echo", "cat"],
191
+ });
192
+ const bash = manager.getOrCreate("t1");
193
+
194
+ // Allowed commands work
195
+ const echoResult = await bash.exec('echo "hello"');
196
+ expect(echoResult.exitCode).toBe(0);
197
+ expect(echoResult.stdout.trim()).toBe("hello");
198
+
199
+ // Disallowed commands fail
200
+ const rmResult = await bash.exec("rm /some-file");
201
+ expect(rmResult.exitCode).not.toBe(0);
202
+
203
+ manager.destroyAll();
204
+ await engine.close();
205
+ });
206
+
207
+ it("enforces execution limits", async () => {
208
+ const engine = new InMemoryEngine("test");
209
+ await engine.initialize();
210
+ const manager = new BashEnvironmentManager(engine, LIMITS, null, {
211
+ executionLimits: { maxLoopIterations: 5 },
212
+ });
213
+ const bash = manager.getOrCreate("t1");
214
+
215
+ // A loop that exceeds the limit should fail
216
+ const result = await bash.exec("for i in $(seq 1 100); do echo $i; done");
217
+ expect(result.exitCode).not.toBe(0);
218
+
219
+ manager.destroyAll();
220
+ await engine.close();
221
+ });
222
+
223
+ it("injects environment variables", async () => {
224
+ const engine = new InMemoryEngine("test");
225
+ await engine.initialize();
226
+ const manager = new BashEnvironmentManager(engine, LIMITS, null, {
227
+ env: { MY_VAR: "hello_world", TZ: "UTC" },
228
+ });
229
+ const bash = manager.getOrCreate("t1");
230
+
231
+ const result = await bash.exec("echo $MY_VAR");
232
+ expect(result.exitCode).toBe(0);
233
+ expect(result.stdout.trim()).toBe("hello_world");
234
+
235
+ const tzResult = await bash.exec("echo $TZ");
236
+ expect(tzResult.exitCode).toBe(0);
237
+ expect(tzResult.stdout.trim()).toBe("UTC");
238
+
239
+ manager.destroyAll();
240
+ await engine.close();
241
+ });
242
+ });
package/src/kv-store.ts DELETED
@@ -1,216 +0,0 @@
1
- import type { StateConfig } from "./state.js";
2
-
3
- /**
4
- * Minimal raw key-value interface shared by MemoryStore, TodoStore, and any
5
- * future stores that sit on top of the same user-configured backend.
6
- */
7
- export interface RawKVStore {
8
- get(key: string): Promise<string | undefined>;
9
- set(key: string, value: string): Promise<void>;
10
- setWithTtl(key: string, value: string, ttlSeconds: number): Promise<void>;
11
- }
12
-
13
- // ---------------------------------------------------------------------------
14
- // Upstash
15
- // ---------------------------------------------------------------------------
16
-
17
- class UpstashKVStore implements RawKVStore {
18
- private readonly baseUrl: string;
19
- private readonly token: string;
20
-
21
- constructor(baseUrl: string, token: string) {
22
- this.baseUrl = baseUrl.replace(/\/+$/, "");
23
- this.token = token;
24
- }
25
-
26
- private headers(): HeadersInit {
27
- return { Authorization: `Bearer ${this.token}`, "Content-Type": "application/json" };
28
- }
29
-
30
- async get(key: string): Promise<string | undefined> {
31
- const response = await fetch(`${this.baseUrl}/get/${encodeURIComponent(key)}`, {
32
- method: "POST",
33
- headers: this.headers(),
34
- });
35
- if (!response.ok) return undefined;
36
- const payload = (await response.json()) as { result?: string | null };
37
- return payload.result ?? undefined;
38
- }
39
-
40
- async set(key: string, value: string): Promise<void> {
41
- const response = await fetch(this.baseUrl, {
42
- method: "POST",
43
- headers: this.headers(),
44
- body: JSON.stringify(["SET", key, value]),
45
- });
46
- if (!response.ok) {
47
- const text = await response.text().catch(() => "");
48
- console.error(`[kv][upstash] SET failed (${response.status}): ${text.slice(0, 200)}`);
49
- }
50
- }
51
-
52
- async setWithTtl(key: string, value: string, ttl: number): Promise<void> {
53
- const response = await fetch(this.baseUrl, {
54
- method: "POST",
55
- headers: this.headers(),
56
- body: JSON.stringify(["SETEX", key, Math.max(1, ttl), value]),
57
- });
58
- if (!response.ok) {
59
- const text = await response.text().catch(() => "");
60
- console.error(`[kv][upstash] SETEX failed (${response.status}): ${text.slice(0, 200)}`);
61
- }
62
- }
63
- }
64
-
65
- // ---------------------------------------------------------------------------
66
- // Redis
67
- // ---------------------------------------------------------------------------
68
-
69
- class RedisKVStore implements RawKVStore {
70
- private readonly clientPromise: Promise<
71
- | {
72
- get: (key: string) => Promise<string | null>;
73
- set: (key: string, value: string, options?: { EX?: number }) => Promise<unknown>;
74
- }
75
- | undefined
76
- >;
77
-
78
- constructor(url: string) {
79
- this.clientPromise = (async () => {
80
- try {
81
- const redisModule = (await import("redis")) as unknown as {
82
- createClient: (args: { url: string }) => {
83
- connect: () => Promise<unknown>;
84
- get: (key: string) => Promise<string | null>;
85
- set: (key: string, value: string, options?: { EX?: number }) => Promise<unknown>;
86
- };
87
- };
88
- const client = redisModule.createClient({ url });
89
- await client.connect();
90
- return client;
91
- } catch {
92
- return undefined;
93
- }
94
- })();
95
- }
96
-
97
- async get(key: string): Promise<string | undefined> {
98
- const client = await this.clientPromise;
99
- if (!client) throw new Error("Redis unavailable");
100
- const value = await client.get(key);
101
- return value ?? undefined;
102
- }
103
-
104
- async set(key: string, value: string): Promise<void> {
105
- const client = await this.clientPromise;
106
- if (!client) throw new Error("Redis unavailable");
107
- await client.set(key, value);
108
- }
109
-
110
- async setWithTtl(key: string, value: string, ttl: number): Promise<void> {
111
- const client = await this.clientPromise;
112
- if (!client) throw new Error("Redis unavailable");
113
- await client.set(key, value, { EX: Math.max(1, ttl) });
114
- }
115
- }
116
-
117
- // ---------------------------------------------------------------------------
118
- // DynamoDB
119
- // ---------------------------------------------------------------------------
120
-
121
- class DynamoDbKVStore implements RawKVStore {
122
- private readonly table: string;
123
- private readonly clientPromise: Promise<
124
- | {
125
- send: (command: unknown) => Promise<unknown>;
126
- GetItemCommand: new (input: unknown) => unknown;
127
- PutItemCommand: new (input: unknown) => unknown;
128
- }
129
- | undefined
130
- >;
131
-
132
- constructor(table: string, region?: string) {
133
- this.table = table;
134
- this.clientPromise = (async () => {
135
- try {
136
- const module = (await import("@aws-sdk/client-dynamodb")) as {
137
- DynamoDBClient: new (input: { region?: string }) => {
138
- send: (command: unknown) => Promise<unknown>;
139
- };
140
- GetItemCommand: new (input: unknown) => unknown;
141
- PutItemCommand: new (input: unknown) => unknown;
142
- };
143
- const client = new module.DynamoDBClient({ region });
144
- return {
145
- send: client.send.bind(client),
146
- GetItemCommand: module.GetItemCommand,
147
- PutItemCommand: module.PutItemCommand,
148
- };
149
- } catch {
150
- return undefined;
151
- }
152
- })();
153
- }
154
-
155
- async get(key: string): Promise<string | undefined> {
156
- const client = await this.clientPromise;
157
- if (!client) throw new Error("DynamoDB unavailable");
158
- const result = (await client.send(
159
- new client.GetItemCommand({ TableName: this.table, Key: { runId: { S: key } } }),
160
- )) as { Item?: { value?: { S?: string } } };
161
- return result.Item?.value?.S;
162
- }
163
-
164
- async set(key: string, value: string): Promise<void> {
165
- const client = await this.clientPromise;
166
- if (!client) throw new Error("DynamoDB unavailable");
167
- await client.send(
168
- new client.PutItemCommand({
169
- TableName: this.table,
170
- Item: { runId: { S: key }, value: { S: value } },
171
- }),
172
- );
173
- }
174
-
175
- async setWithTtl(key: string, value: string, ttl: number): Promise<void> {
176
- const client = await this.clientPromise;
177
- if (!client) throw new Error("DynamoDB unavailable");
178
- const ttlEpoch = Math.floor(Date.now() / 1000) + Math.max(1, ttl);
179
- await client.send(
180
- new client.PutItemCommand({
181
- TableName: this.table,
182
- Item: { runId: { S: key }, value: { S: value }, ttl: { N: String(ttlEpoch) } },
183
- }),
184
- );
185
- }
186
- }
187
-
188
- // ---------------------------------------------------------------------------
189
- // Factory — resolves the user's storage config into a RawKVStore, or
190
- // undefined when the provider is "local" or "memory" (handled by callers).
191
- // ---------------------------------------------------------------------------
192
-
193
- export const createRawKVStore = (config?: StateConfig): RawKVStore | undefined => {
194
- const provider = config?.provider ?? "local";
195
-
196
- if (provider === "upstash") {
197
- const urlEnv = config?.urlEnv ?? (process.env.UPSTASH_REDIS_REST_URL ? "UPSTASH_REDIS_REST_URL" : "KV_REST_API_URL");
198
- const tokenEnv = config?.tokenEnv ?? (process.env.UPSTASH_REDIS_REST_TOKEN ? "UPSTASH_REDIS_REST_TOKEN" : "KV_REST_API_TOKEN");
199
- const url = process.env[urlEnv] ?? "";
200
- const token = process.env[tokenEnv] ?? "";
201
- if (url && token) return new UpstashKVStore(url, token);
202
- }
203
-
204
- if (provider === "redis") {
205
- const urlEnv = config?.urlEnv ?? "REDIS_URL";
206
- const url = process.env[urlEnv] ?? "";
207
- if (url) return new RedisKVStore(url);
208
- }
209
-
210
- if (provider === "dynamodb") {
211
- const table = config?.table ?? process.env.PONCHO_DYNAMODB_TABLE ?? "";
212
- if (table) return new DynamoDbKVStore(table, config?.region);
213
- }
214
-
215
- return undefined;
216
- };