@poncho-ai/harness 0.34.1 → 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 (64) hide show
  1. package/.turbo/turbo-build.log +12 -11
  2. package/.turbo/turbo-lint.log +6 -0
  3. package/.turbo/turbo-test.log +27100 -0
  4. package/CHANGELOG.md +37 -0
  5. package/dist/chunk-MCKGQKYU.js +15 -0
  6. package/dist/dist-3KMQR4IO.js +27092 -0
  7. package/dist/index.d.ts +553 -29
  8. package/dist/index.js +3132 -1902
  9. package/dist/isolate-5MISBSUK.js +733 -0
  10. package/dist/isolate-5R6762YA.js +605 -0
  11. package/dist/isolate-KUZ5NOPG.js +727 -0
  12. package/dist/isolate-LOL3T7RA.js +729 -0
  13. package/dist/isolate-N22X4TCE.js +740 -0
  14. package/dist/isolate-T7WXM7IL.js +1490 -0
  15. package/dist/isolate-TCWTUVG4.js +1532 -0
  16. package/dist/isolate-WFOLANOB.js +768 -0
  17. package/package.json +24 -4
  18. package/scripts/migrate-to-engine.mjs +556 -0
  19. package/src/config.ts +112 -1
  20. package/src/harness.ts +282 -91
  21. package/src/index.ts +7 -0
  22. package/src/isolate/bindings.ts +206 -0
  23. package/src/isolate/bundler.ts +179 -0
  24. package/src/isolate/index.ts +10 -0
  25. package/src/isolate/polyfills.ts +796 -0
  26. package/src/isolate/run-code-tool.ts +220 -0
  27. package/src/isolate/runtime.ts +286 -0
  28. package/src/isolate/type-stubs.ts +196 -0
  29. package/src/mcp.ts +140 -9
  30. package/src/memory.ts +142 -191
  31. package/src/reminder-store.ts +7 -235
  32. package/src/reminder-tools.ts +15 -2
  33. package/src/secrets-store.ts +163 -0
  34. package/src/state.ts +22 -1291
  35. package/src/storage/engine.ts +106 -0
  36. package/src/storage/index.ts +59 -0
  37. package/src/storage/memory-engine.ts +588 -0
  38. package/src/storage/postgres-engine.ts +139 -0
  39. package/src/storage/schema.ts +145 -0
  40. package/src/storage/sql-dialect.ts +963 -0
  41. package/src/storage/sqlite-engine.ts +99 -0
  42. package/src/storage/store-adapters.ts +100 -0
  43. package/src/subagent-manager.ts +1 -0
  44. package/src/subagent-tools.ts +1 -0
  45. package/src/telemetry.ts +5 -1
  46. package/src/tenant-token.ts +42 -0
  47. package/src/todo-tools.ts +1 -136
  48. package/src/upload-store.ts +1 -0
  49. package/src/vfs/bash-manager.ts +120 -0
  50. package/src/vfs/bash-tool.ts +59 -0
  51. package/src/vfs/create-bash-fs.ts +32 -0
  52. package/src/vfs/edit-file-tool.ts +72 -0
  53. package/src/vfs/index.ts +5 -0
  54. package/src/vfs/poncho-fs-adapter.ts +267 -0
  55. package/src/vfs/protected-fs.ts +177 -0
  56. package/src/vfs/read-file-tool.ts +103 -0
  57. package/src/vfs/write-file-tool.ts +49 -0
  58. package/test/harness.test.ts +30 -36
  59. package/test/isolate-vfs.test.ts +453 -0
  60. package/test/isolate.test.ts +252 -0
  61. package/test/state.test.ts +4 -27
  62. package/test/storage-engine.test.ts +250 -0
  63. package/test/vfs.test.ts +242 -0
  64. package/src/kv-store.ts +0 -216
@@ -0,0 +1,99 @@
1
+ // ---------------------------------------------------------------------------
2
+ // SqliteEngine – better-sqlite3 backed storage engine.
3
+ // ---------------------------------------------------------------------------
4
+
5
+ import { mkdirSync } from "node:fs";
6
+ import { dirname, resolve } from "node:path";
7
+ import type { QueryExecutor, QueryRow } from "./sql-dialect.js";
8
+ import { SqlStorageEngine, sqliteDialect } from "./sql-dialect.js";
9
+
10
+ type Database = import("better-sqlite3").Database;
11
+
12
+ const isServerless = (): boolean =>
13
+ !!(
14
+ process.env.VERCEL ||
15
+ process.env.AWS_LAMBDA_FUNCTION_NAME ||
16
+ process.env.NETLIFY ||
17
+ process.env.RENDER
18
+ );
19
+
20
+ export class SqliteEngine extends SqlStorageEngine {
21
+ private db!: Database;
22
+ private readonly dbPath: string;
23
+ protected readonly executor: QueryExecutor;
24
+
25
+ constructor(options: { workingDir: string; agentId: string; dbPath?: string }) {
26
+ super(sqliteDialect, options.agentId);
27
+
28
+ this.dbPath =
29
+ options.dbPath ??
30
+ (isServerless()
31
+ ? resolve("/tmp/.poncho/poncho.db")
32
+ : resolve(options.workingDir, ".poncho", "poncho.db"));
33
+
34
+ this.executor = {
35
+ run: async (sql: string, params?: unknown[]): Promise<void> => {
36
+ this.db.prepare(sql).run(...(params ?? []));
37
+ },
38
+ get: async <T extends QueryRow = QueryRow>(
39
+ sql: string,
40
+ params?: unknown[],
41
+ ): Promise<T | undefined> => {
42
+ return this.db.prepare(sql).get(...(params ?? [])) as T | undefined;
43
+ },
44
+ all: async <T extends QueryRow = QueryRow>(
45
+ sql: string,
46
+ params?: unknown[],
47
+ ): Promise<T[]> => {
48
+ return this.db.prepare(sql).all(...(params ?? [])) as T[];
49
+ },
50
+ exec: async (sql: string): Promise<void> => {
51
+ this.db.exec(sql);
52
+ },
53
+ transaction: async (fn: () => Promise<void>): Promise<void> => {
54
+ this.db.exec("BEGIN");
55
+ try {
56
+ await fn();
57
+ this.db.exec("COMMIT");
58
+ } catch (err) {
59
+ this.db.exec("ROLLBACK");
60
+ throw err;
61
+ }
62
+ },
63
+ };
64
+ }
65
+
66
+ protected override async onBeforeInit(): Promise<void> {
67
+ mkdirSync(dirname(this.dbPath), { recursive: true });
68
+
69
+ const BetterSqlite3 = (await import("better-sqlite3")).default;
70
+ this.db = new BetterSqlite3(this.dbPath);
71
+
72
+ this.db.pragma("journal_mode = WAL");
73
+ this.db.pragma("busy_timeout = 5000");
74
+
75
+ if (isServerless()) {
76
+ console.warn(
77
+ "[poncho] SQLite storage detected in serverless environment. " +
78
+ "Data will NOT persist between invocations. " +
79
+ "Configure `storage.provider: 'postgresql'` for persistent storage.",
80
+ );
81
+ }
82
+ }
83
+
84
+ override async initialize(): Promise<void> {
85
+ await super.initialize();
86
+ // Patch listAllPaths to use synchronous better-sqlite3 queries
87
+ this.vfs.listAllPaths = (tenantId: string): string[] => {
88
+ if (!this.db) return [];
89
+ const rows = this.db
90
+ .prepare("SELECT path FROM vfs_entries WHERE agent_id = ? AND tenant_id = ?")
91
+ .all(this.agentId, tenantId) as Array<{ path: string }>;
92
+ return rows.map((r) => r.path);
93
+ };
94
+ }
95
+
96
+ async close(): Promise<void> {
97
+ this.db?.close();
98
+ }
99
+ }
@@ -0,0 +1,100 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Thin adapters that wrap StorageEngine namespaces into the existing
3
+ // ConversationStore / MemoryStore / TodoStore / ReminderStore interfaces.
4
+ // This keeps backward compatibility while routing through the new engine.
5
+ // ---------------------------------------------------------------------------
6
+
7
+ import type {
8
+ Conversation,
9
+ ConversationStore,
10
+ ConversationSummary,
11
+ PendingSubagentResult,
12
+ } from "../state.js";
13
+ import type { MainMemory, MemoryStore } from "../memory.js";
14
+ import type { TodoItem, TodoStore } from "../todo-tools.js";
15
+ import type { Reminder, ReminderStore } from "../reminder-store.js";
16
+ import type { StorageEngine } from "./engine.js";
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // ConversationStore adapter
20
+ // ---------------------------------------------------------------------------
21
+
22
+ export function createConversationStoreFromEngine(
23
+ engine: StorageEngine,
24
+ ): ConversationStore {
25
+ return {
26
+ list: (ownerId?: string, tenantId?: string | null) =>
27
+ engine.conversations.list(ownerId, tenantId).then((summaries) => {
28
+ // list() returns full Conversation[] in the old interface.
29
+ // For backward compat, fetch full conversations.
30
+ return Promise.all(
31
+ summaries.map((s) => engine.conversations.get(s.conversationId)),
32
+ ).then((convs) => convs.filter(Boolean) as Conversation[]);
33
+ }),
34
+ listSummaries: (ownerId?: string, tenantId?: string | null) =>
35
+ engine.conversations.list(ownerId, tenantId),
36
+ get: (conversationId: string) =>
37
+ engine.conversations.get(conversationId),
38
+ create: (ownerId?: string, title?: string, tenantId?: string | null) =>
39
+ engine.conversations.create(ownerId, title, tenantId),
40
+ update: (conversation: Conversation) =>
41
+ engine.conversations.update(conversation),
42
+ rename: (conversationId: string, title: string) =>
43
+ engine.conversations.rename(conversationId, title),
44
+ delete: (conversationId: string) =>
45
+ engine.conversations.delete(conversationId),
46
+ appendSubagentResult: (conversationId: string, result: PendingSubagentResult) =>
47
+ engine.conversations.appendSubagentResult(conversationId, result),
48
+ clearCallbackLock: (conversationId: string) =>
49
+ engine.conversations.clearCallbackLock(conversationId),
50
+ };
51
+ }
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // MemoryStore adapter
55
+ // ---------------------------------------------------------------------------
56
+
57
+ export function createMemoryStoreFromEngine(
58
+ engine: StorageEngine,
59
+ tenantId?: string | null,
60
+ ): MemoryStore {
61
+ return {
62
+ getMainMemory: () => engine.memory.get(tenantId),
63
+ updateMainMemory: (input: { content: string }) =>
64
+ engine.memory.update(input.content, tenantId),
65
+ };
66
+ }
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // TodoStore adapter
70
+ // ---------------------------------------------------------------------------
71
+
72
+ export function createTodoStoreFromEngine(engine: StorageEngine): TodoStore {
73
+ return {
74
+ get: (conversationId: string) => engine.todos.get(conversationId),
75
+ set: (conversationId: string, todos: TodoItem[]) =>
76
+ engine.todos.set(conversationId, todos),
77
+ };
78
+ }
79
+
80
+ // ---------------------------------------------------------------------------
81
+ // ReminderStore adapter
82
+ // ---------------------------------------------------------------------------
83
+
84
+ export function createReminderStoreFromEngine(
85
+ engine: StorageEngine,
86
+ ): ReminderStore {
87
+ return {
88
+ list: () => engine.reminders.list(),
89
+ create: (input: {
90
+ task: string;
91
+ scheduledAt: number;
92
+ timezone?: string;
93
+ conversationId: string;
94
+ ownerId?: string;
95
+ tenantId?: string | null;
96
+ }) => engine.reminders.create(input),
97
+ cancel: (id: string) => engine.reminders.cancel(id),
98
+ delete: (id: string) => engine.reminders.delete(id),
99
+ };
100
+ }
@@ -24,6 +24,7 @@ export interface SubagentManager {
24
24
  task: string;
25
25
  parentConversationId: string;
26
26
  ownerId: string;
27
+ tenantId?: string | null;
27
28
  }): Promise<SubagentSpawnResult>;
28
29
 
29
30
  sendMessage(subagentId: string, message: string): Promise<SubagentSpawnResult>;
@@ -44,6 +44,7 @@ export const createSubagentTools = (
44
44
  task: task.trim(),
45
45
  parentConversationId: conversationId,
46
46
  ownerId,
47
+ tenantId: context.tenantId,
47
48
  });
48
49
  return { subagentId, status: "running" };
49
50
  },
package/src/telemetry.ts CHANGED
@@ -1,9 +1,13 @@
1
1
  import type { AgentEvent } from "@poncho-ai/sdk";
2
2
 
3
3
  const MAX_FIELD_LENGTH = 200;
4
+ const OMIT_FROM_LOG = new Set(["continuationMessages", "_harnessMessages", "messages", "compactedHistory"]);
4
5
 
5
6
  function sanitizeEventForLog(event: AgentEvent): string {
6
- return JSON.stringify(event, (_key, value) => {
7
+ return JSON.stringify(event, (key, value) => {
8
+ if (OMIT_FROM_LOG.has(key) && Array.isArray(value)) {
9
+ return `[${value.length} messages]`;
10
+ }
7
11
  if (typeof value === "string" && value.length > MAX_FIELD_LENGTH) {
8
12
  return `${value.slice(0, 80)}...[${value.length} chars]`;
9
13
  }
@@ -0,0 +1,42 @@
1
+ import { jwtVerify, type JWTPayload } from "jose";
2
+
3
+ export interface TenantTokenPayload {
4
+ tenantId: string;
5
+ metadata?: Record<string, unknown>;
6
+ }
7
+
8
+ /**
9
+ * Verify a tenant JWT (HS256) signed with the given key.
10
+ * Returns the decoded payload on success, or undefined on any failure.
11
+ */
12
+ export async function verifyTenantToken(
13
+ signingKey: string,
14
+ token: string,
15
+ ): Promise<TenantTokenPayload | undefined> {
16
+ try {
17
+ const secret = new TextEncoder().encode(signingKey);
18
+ const { payload } = await jwtVerify(token, secret, {
19
+ algorithms: ["HS256"],
20
+ });
21
+
22
+ const tenantId = payload.sub;
23
+ if (!tenantId || typeof tenantId !== "string") {
24
+ return undefined;
25
+ }
26
+
27
+ const metadata = extractMetadata(payload);
28
+ return { tenantId, metadata };
29
+ } catch {
30
+ return undefined;
31
+ }
32
+ }
33
+
34
+ function extractMetadata(
35
+ payload: JWTPayload,
36
+ ): Record<string, unknown> | undefined {
37
+ const meta = payload.meta;
38
+ if (meta && typeof meta === "object" && !Array.isArray(meta)) {
39
+ return meta as Record<string, unknown>;
40
+ }
41
+ return undefined;
42
+ }
package/src/todo-tools.ts CHANGED
@@ -1,14 +1,4 @@
1
- import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
2
- import { dirname, resolve } from "node:path";
3
1
  import { defineTool, type ToolDefinition } from "@poncho-ai/sdk";
4
- import type { StateConfig } from "./state.js";
5
- import {
6
- ensureAgentIdentity,
7
- getAgentStoreDirectory,
8
- slugifyStorageComponent,
9
- STORAGE_SCHEMA_VERSION,
10
- } from "./agent-identity.js";
11
- import { createRawKVStore, type RawKVStore } from "./kv-store.js";
12
2
 
13
3
  // ---------------------------------------------------------------------------
14
4
  // Data model
@@ -37,25 +27,6 @@ export interface TodoStore {
37
27
 
38
28
  const VALID_STATUSES: TodoStatus[] = ["pending", "in_progress", "completed"];
39
29
  const VALID_PRIORITIES: TodoPriority[] = ["high", "medium", "low"];
40
- const TODOS_DIRECTORY = "todos";
41
-
42
- const writeJsonAtomic = async (filePath: string, payload: unknown): Promise<void> => {
43
- await mkdir(dirname(filePath), { recursive: true });
44
- const tmpPath = `${filePath}.tmp`;
45
- await writeFile(tmpPath, JSON.stringify(payload, null, 2), "utf8");
46
- await rename(tmpPath, filePath);
47
- };
48
-
49
- const parseTodoList = (raw: unknown): TodoItem[] => {
50
- if (!Array.isArray(raw)) return [];
51
- return raw.filter(
52
- (item): item is TodoItem =>
53
- typeof item === "object" &&
54
- item !== null &&
55
- typeof (item as Record<string, unknown>).id === "string" &&
56
- typeof (item as Record<string, unknown>).content === "string",
57
- );
58
- };
59
30
 
60
31
  const generateId = (): string =>
61
32
  (globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random()}`).slice(0, 8);
@@ -76,117 +47,11 @@ class InMemoryTodoStore implements TodoStore {
76
47
  }
77
48
  }
78
49
 
79
- // ---------------------------------------------------------------------------
80
- // FileTodoStore — one JSON file per conversation
81
- // ---------------------------------------------------------------------------
82
-
83
- class FileTodoStore implements TodoStore {
84
- private readonly workingDir: string;
85
- private todosDir = "";
86
-
87
- constructor(workingDir: string) {
88
- this.workingDir = workingDir;
89
- }
90
-
91
- private async ensureTodosDir(): Promise<string> {
92
- if (this.todosDir) return this.todosDir;
93
- const identity = await ensureAgentIdentity(this.workingDir);
94
- this.todosDir = resolve(getAgentStoreDirectory(identity), TODOS_DIRECTORY);
95
- await mkdir(this.todosDir, { recursive: true });
96
- return this.todosDir;
97
- }
98
-
99
- private async filePath(conversationId: string): Promise<string> {
100
- const dir = await this.ensureTodosDir();
101
- return resolve(dir, `${slugifyStorageComponent(conversationId)}.json`);
102
- }
103
-
104
- async get(conversationId: string): Promise<TodoItem[]> {
105
- try {
106
- const fp = await this.filePath(conversationId);
107
- const raw = await readFile(fp, "utf8");
108
- return parseTodoList(JSON.parse(raw));
109
- } catch {
110
- return [];
111
- }
112
- }
113
-
114
- async set(conversationId: string, todos: TodoItem[]): Promise<void> {
115
- const fp = await this.filePath(conversationId);
116
- await writeJsonAtomic(fp, todos);
117
- }
118
- }
119
-
120
- // ---------------------------------------------------------------------------
121
- // KVBackedTodoStore — wraps any RawKVStore (Upstash, Redis, DynamoDB)
122
- // ---------------------------------------------------------------------------
123
-
124
- class KVBackedTodoStore implements TodoStore {
125
- private readonly kv: RawKVStore;
126
- private readonly baseKey: string;
127
- private readonly ttl?: number;
128
- private readonly memoryFallback = new InMemoryTodoStore();
129
-
130
- constructor(kv: RawKVStore, baseKey: string, ttl?: number) {
131
- this.kv = kv;
132
- this.baseKey = baseKey;
133
- this.ttl = ttl;
134
- }
135
-
136
- private keyFor(conversationId: string): string {
137
- return `${this.baseKey}:${slugifyStorageComponent(conversationId)}`;
138
- }
139
-
140
- async get(conversationId: string): Promise<TodoItem[]> {
141
- try {
142
- const raw = await this.kv.get(this.keyFor(conversationId));
143
- if (!raw) return [];
144
- return parseTodoList(JSON.parse(raw));
145
- } catch {
146
- return this.memoryFallback.get(conversationId);
147
- }
148
- }
149
-
150
- async set(conversationId: string, todos: TodoItem[]): Promise<void> {
151
- try {
152
- const serialized = JSON.stringify(todos);
153
- const key = this.keyFor(conversationId);
154
- if (typeof this.ttl === "number") {
155
- await this.kv.setWithTtl(key, serialized, Math.max(1, this.ttl));
156
- } else {
157
- await this.kv.set(key, serialized);
158
- }
159
- } catch {
160
- await this.memoryFallback.set(conversationId, todos);
161
- }
162
- }
163
- }
164
-
165
50
  // ---------------------------------------------------------------------------
166
51
  // Factory
167
52
  // ---------------------------------------------------------------------------
168
53
 
169
- export const createTodoStore = (
170
- agentId: string,
171
- config?: StateConfig,
172
- options?: { workingDir?: string },
173
- ): TodoStore => {
174
- const provider = config?.provider ?? "local";
175
- const ttl = config?.ttl;
176
- const workingDir = options?.workingDir ?? process.cwd();
177
-
178
- if (provider === "local") {
179
- return new FileTodoStore(workingDir);
180
- }
181
- if (provider === "memory") {
182
- return new InMemoryTodoStore();
183
- }
184
-
185
- const kv = createRawKVStore(config);
186
- if (kv) {
187
- const baseKey = `poncho:${STORAGE_SCHEMA_VERSION}:${slugifyStorageComponent(agentId)}:todos`;
188
- return new KVBackedTodoStore(kv, baseKey, ttl);
189
- }
54
+ export const createTodoStore = (): TodoStore => {
190
55
  return new InMemoryTodoStore();
191
56
  };
192
57
 
@@ -26,6 +26,7 @@ const tryImport = async (mod: string, workingDir?: string): Promise<any> => {
26
26
  };
27
27
 
28
28
  export const PONCHO_UPLOAD_SCHEME = "poncho-upload://";
29
+ export const VFS_SCHEME = "vfs://";
29
30
 
30
31
  export interface UploadStore {
31
32
  put(key: string, data: Buffer, mediaType: string): Promise<string>;
@@ -0,0 +1,120 @@
1
+ // ---------------------------------------------------------------------------
2
+ // BashEnvironmentManager – manages per-tenant bash instances.
3
+ // ---------------------------------------------------------------------------
4
+
5
+ import { Bash } from "just-bash";
6
+ import type {
7
+ BashOptions,
8
+ CommandName,
9
+ NetworkConfig as JustBashNetworkConfig,
10
+ } from "just-bash";
11
+ import type { StorageEngine } from "../storage/engine.js";
12
+ import type { BashConfig, NetworkConfig } from "../config.js";
13
+ import { PonchoFsAdapter } from "./poncho-fs-adapter.js";
14
+ import { createBashFs } from "./create-bash-fs.js";
15
+ import type { PostgresEngine } from "../storage/postgres-engine.js";
16
+
17
+ /** Convert poncho NetworkConfig → just-bash NetworkConfig. */
18
+ function toJustBashNetwork(cfg: NetworkConfig): JustBashNetworkConfig {
19
+ return {
20
+ allowedUrlPrefixes: cfg.allowedUrls,
21
+ allowedMethods: cfg.allowedMethods,
22
+ dangerouslyAllowFullInternetAccess: cfg.dangerouslyAllowAll,
23
+ maxRedirects: cfg.maxRedirects,
24
+ timeoutMs: cfg.timeoutMs,
25
+ maxResponseSize: cfg.maxResponseSize,
26
+ denyPrivateRanges: cfg.denyPrivateRanges,
27
+ };
28
+ }
29
+
30
+ /** Build the just-bash BashOptions from poncho BashConfig + NetworkConfig. */
31
+ function toBashOptions(
32
+ cfg: BashConfig | undefined,
33
+ network: NetworkConfig | undefined,
34
+ ): Partial<BashOptions> {
35
+ const opts: Partial<BashOptions> = {};
36
+
37
+ if (network) {
38
+ opts.network = toJustBashNetwork(network);
39
+ }
40
+
41
+ if (!cfg) return opts;
42
+
43
+ if (cfg.commands) {
44
+ opts.commands = cfg.commands as CommandName[];
45
+ }
46
+
47
+ if (cfg.executionLimits) {
48
+ opts.executionLimits = { ...cfg.executionLimits };
49
+ }
50
+
51
+ if (cfg.python) {
52
+ opts.python = true;
53
+ }
54
+
55
+ if (cfg.javascript) {
56
+ opts.javascript = true;
57
+ }
58
+
59
+ if (cfg.env) {
60
+ opts.env = cfg.env;
61
+ }
62
+
63
+ return opts;
64
+ }
65
+
66
+ export class BashEnvironmentManager {
67
+ private environments = new Map<string, Bash>();
68
+ private readonly workingDir: string | null;
69
+ private readonly bashOptions: Partial<BashOptions>;
70
+
71
+ constructor(
72
+ private engine: StorageEngine,
73
+ private limits: { maxFileSize: number; maxTotalStorage: number },
74
+ workingDir: string | null,
75
+ bashConfig?: BashConfig,
76
+ network?: NetworkConfig,
77
+ ) {
78
+ this.workingDir = workingDir;
79
+ this.bashOptions = toBashOptions(bashConfig, network);
80
+ }
81
+
82
+ getOrCreate(tenantId: string): Bash {
83
+ let bash = this.environments.get(tenantId);
84
+ if (!bash) {
85
+ const adapter = new PonchoFsAdapter(this.engine, tenantId, this.limits);
86
+ const fs = createBashFs(adapter, this.workingDir);
87
+ bash = new Bash({
88
+ fs,
89
+ cwd: "/",
90
+ ...this.bashOptions,
91
+ });
92
+ this.environments.set(tenantId, bash);
93
+ }
94
+ return bash;
95
+ }
96
+
97
+ getAdapter(tenantId: string): PonchoFsAdapter {
98
+ return new PonchoFsAdapter(this.engine, tenantId, this.limits);
99
+ }
100
+
101
+ /** Refresh the PostgreSQL path cache before a bash.exec() call. */
102
+ async refreshPathCache(tenantId: string): Promise<void> {
103
+ if ("refreshPathCache" in this.engine) {
104
+ // Not on StorageEngine interface but on PostgresEngine
105
+ }
106
+ // Check if the engine is a PostgresEngine with refreshPathCache
107
+ const pg = this.engine as unknown as PostgresEngine;
108
+ if (typeof pg.refreshPathCache === "function") {
109
+ await pg.refreshPathCache(tenantId);
110
+ }
111
+ }
112
+
113
+ destroy(tenantId: string): void {
114
+ this.environments.delete(tenantId);
115
+ }
116
+
117
+ destroyAll(): void {
118
+ this.environments.clear();
119
+ }
120
+ }
@@ -0,0 +1,59 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Bash tool definition – agent-facing bash interpreter tool.
3
+ // ---------------------------------------------------------------------------
4
+
5
+ import { defineTool, type ToolDefinition } from "@poncho-ai/sdk";
6
+ import type { BashEnvironmentManager } from "./bash-manager.js";
7
+
8
+ export const createBashTool = (
9
+ bashManager: BashEnvironmentManager,
10
+ ): ToolDefinition => defineTool({
11
+ name: "bash",
12
+ description:
13
+ "Execute a bash command or script in a sandboxed environment. " +
14
+ "The environment has a persistent virtual filesystem — files written in one call " +
15
+ "are available in subsequent calls. Supports standard commands: ls, cat, echo, " +
16
+ "grep, awk, jq, sed, sort, head, tail, wc, find, mkdir, cp, mv, rm, etc. " +
17
+ "Use this for data processing, file manipulation, and script execution.",
18
+ inputSchema: {
19
+ type: "object",
20
+ properties: {
21
+ command: {
22
+ type: "string",
23
+ description: "The bash command or script to execute",
24
+ },
25
+ timeout: {
26
+ type: "number",
27
+ description: "Timeout in milliseconds (default: 30000)",
28
+ },
29
+ },
30
+ required: ["command"],
31
+ additionalProperties: false,
32
+ },
33
+ handler: async (input, context) => {
34
+ const tenantId = context.tenantId ?? "__default__";
35
+
36
+ // Refresh PostgreSQL path cache before exec
37
+ await bashManager.refreshPathCache(tenantId);
38
+
39
+ const bash = bashManager.getOrCreate(tenantId);
40
+ const timeout = typeof input.timeout === "number" ? input.timeout : 30_000;
41
+
42
+ const controller = new AbortController();
43
+ const timer = setTimeout(() => controller.abort(), timeout);
44
+
45
+ try {
46
+ const result = await bash.exec(input.command as string, {
47
+ signal: controller.signal,
48
+ });
49
+
50
+ return {
51
+ stdout: result.stdout,
52
+ stderr: result.stderr,
53
+ exitCode: result.exitCode,
54
+ };
55
+ } finally {
56
+ clearTimeout(timer);
57
+ }
58
+ },
59
+ });
@@ -0,0 +1,32 @@
1
+ // ---------------------------------------------------------------------------
2
+ // createBashFs – assembles MountableFs with VFS root + optional /project mount.
3
+ // ---------------------------------------------------------------------------
4
+
5
+ import type { IFileSystem } from "just-bash";
6
+ import { MountableFs, ReadWriteFs } from "just-bash";
7
+ import type { PonchoFsAdapter } from "./poncho-fs-adapter.js";
8
+ import { ProtectedFs } from "./protected-fs.js";
9
+
10
+ /**
11
+ * Create the filesystem tree for the bash environment.
12
+ *
13
+ * - Production: VFS only (no project access).
14
+ * - Development: VFS root + real project files at /project.
15
+ */
16
+ export function createBashFs(
17
+ adapter: PonchoFsAdapter,
18
+ workingDir: string | null,
19
+ ): IFileSystem {
20
+ if (!workingDir) {
21
+ // Prod: VFS only
22
+ return adapter;
23
+ }
24
+
25
+ const realFs = new ReadWriteFs({ root: workingDir });
26
+ const protectedFs = new ProtectedFs(realFs);
27
+
28
+ return new MountableFs({
29
+ base: adapter,
30
+ mounts: [{ mountPoint: "/project", filesystem: protectedFs }],
31
+ });
32
+ }