@poncho-ai/harness 0.35.0 → 0.36.1
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/.turbo/turbo-build.log +12 -11
- package/CHANGELOG.md +25 -0
- package/dist/index.d.ts +485 -29
- package/dist/index.js +2839 -2114
- package/dist/isolate-TCWTUVG4.js +1532 -0
- package/package.json +23 -4
- package/scripts/migrate-to-engine.mjs +556 -0
- package/src/config.ts +106 -1
- package/src/harness.ts +226 -91
- package/src/index.ts +5 -0
- package/src/isolate/bindings.ts +206 -0
- package/src/isolate/bundler.ts +179 -0
- package/src/isolate/index.ts +10 -0
- package/src/isolate/polyfills.ts +796 -0
- package/src/isolate/run-code-tool.ts +220 -0
- package/src/isolate/runtime.ts +286 -0
- package/src/isolate/type-stubs.ts +196 -0
- package/src/memory.ts +129 -198
- package/src/reminder-store.ts +3 -237
- package/src/secrets-store.ts +2 -91
- package/src/state.ts +11 -1302
- package/src/storage/engine.ts +106 -0
- package/src/storage/index.ts +59 -0
- package/src/storage/memory-engine.ts +588 -0
- package/src/storage/postgres-engine.ts +139 -0
- package/src/storage/schema.ts +145 -0
- package/src/storage/sql-dialect.ts +963 -0
- package/src/storage/sqlite-engine.ts +99 -0
- package/src/storage/store-adapters.ts +100 -0
- package/src/todo-tools.ts +1 -136
- package/src/upload-store.ts +1 -0
- package/src/vfs/bash-manager.ts +120 -0
- package/src/vfs/bash-tool.ts +59 -0
- package/src/vfs/create-bash-fs.ts +32 -0
- package/src/vfs/edit-file-tool.ts +72 -0
- package/src/vfs/index.ts +5 -0
- package/src/vfs/poncho-fs-adapter.ts +267 -0
- package/src/vfs/protected-fs.ts +177 -0
- package/src/vfs/read-file-tool.ts +103 -0
- package/src/vfs/write-file-tool.ts +49 -0
- package/test/harness.test.ts +30 -36
- package/test/isolate-vfs.test.ts +453 -0
- package/test/isolate.test.ts +252 -0
- package/test/state.test.ts +4 -27
- package/test/storage-engine.test.ts +250 -0
- package/test/vfs.test.ts +242 -0
- package/.turbo/turbo-lint.log +0 -6
- package/.turbo/turbo-test.log +0 -11931
- 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
|
+
}
|
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
|
|
package/src/upload-store.ts
CHANGED
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// edit_file tool – targeted string replacement in VFS files.
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
|
|
5
|
+
import { defineTool, type ToolDefinition } from "@poncho-ai/sdk";
|
|
6
|
+
import type { StorageEngine } from "../storage/engine.js";
|
|
7
|
+
|
|
8
|
+
export const createEditFileTool = (
|
|
9
|
+
engine: StorageEngine,
|
|
10
|
+
): ToolDefinition => defineTool({
|
|
11
|
+
name: "edit_file",
|
|
12
|
+
description:
|
|
13
|
+
"Edit a file by replacing an exact string match with new content. " +
|
|
14
|
+
"The old_str must match exactly one location in the file. " +
|
|
15
|
+
"Use an empty new_str to delete matched content. " +
|
|
16
|
+
"Use read_file first to see current content before editing.",
|
|
17
|
+
inputSchema: {
|
|
18
|
+
type: "object",
|
|
19
|
+
properties: {
|
|
20
|
+
path: {
|
|
21
|
+
type: "string",
|
|
22
|
+
description: "Absolute path of the file to edit",
|
|
23
|
+
},
|
|
24
|
+
old_str: {
|
|
25
|
+
type: "string",
|
|
26
|
+
description:
|
|
27
|
+
"The exact text to find and replace (must be unique in the file). " +
|
|
28
|
+
"Include surrounding context if needed to ensure uniqueness.",
|
|
29
|
+
},
|
|
30
|
+
new_str: {
|
|
31
|
+
type: "string",
|
|
32
|
+
description: "The replacement text (use empty string to delete the matched content)",
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
required: ["path", "old_str", "new_str"],
|
|
36
|
+
additionalProperties: false,
|
|
37
|
+
},
|
|
38
|
+
handler: async (input, context) => {
|
|
39
|
+
const filePath = typeof input.path === "string" ? input.path.trim() : "";
|
|
40
|
+
const oldStr = typeof input.old_str === "string" ? input.old_str : "";
|
|
41
|
+
const newStr = typeof input.new_str === "string" ? input.new_str : "";
|
|
42
|
+
|
|
43
|
+
if (!filePath) throw new Error("path is required");
|
|
44
|
+
if (!oldStr) throw new Error("old_str must not be empty");
|
|
45
|
+
|
|
46
|
+
const tenantId = context.tenantId ?? "__default__";
|
|
47
|
+
const stat = await engine.vfs.stat(tenantId, filePath);
|
|
48
|
+
if (!stat) throw new Error(`File not found: ${filePath}`);
|
|
49
|
+
if (stat.type === "directory") throw new Error(`${filePath} is a directory`);
|
|
50
|
+
|
|
51
|
+
const buf = await engine.vfs.readFile(tenantId, filePath);
|
|
52
|
+
const content = Buffer.from(buf).toString("utf8");
|
|
53
|
+
|
|
54
|
+
const first = content.indexOf(oldStr);
|
|
55
|
+
if (first === -1) {
|
|
56
|
+
throw new Error(
|
|
57
|
+
"old_str not found in file. Make sure it matches exactly, including whitespace and line breaks.",
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
const last = content.lastIndexOf(oldStr);
|
|
61
|
+
if (first !== last) {
|
|
62
|
+
throw new Error(
|
|
63
|
+
"old_str appears multiple times in the file. Include more surrounding context to ensure a unique match.",
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const updated = content.slice(0, first) + newStr + content.slice(first + oldStr.length);
|
|
68
|
+
await engine.vfs.writeFile(tenantId, filePath, new TextEncoder().encode(updated));
|
|
69
|
+
|
|
70
|
+
return { ok: true, path: filePath };
|
|
71
|
+
},
|
|
72
|
+
});
|
package/src/vfs/index.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { PonchoFsAdapter } from "./poncho-fs-adapter.js";
|
|
2
|
+
export { ProtectedFs } from "./protected-fs.js";
|
|
3
|
+
export { createBashFs } from "./create-bash-fs.js";
|
|
4
|
+
export { BashEnvironmentManager } from "./bash-manager.js";
|
|
5
|
+
export { createBashTool } from "./bash-tool.js";
|