History

Add scoped agent storage tools with local and Cloudflare backends

This introduces storage/fs/kv/sqlite system tools, workspace storage instance lifecycle APIs, and a new Tools > Storage UI so agents can persist state safely. It also adds execute-flow smoke automation to validate both local and Cloudflare runtime paths end-to-end.

Rhys Sullivan committed 8 months ago

Commit 1df97bf1 parent a445fa831 files · +3810 −39Browse files

Unified · Split

31 files
  1. .env.example +8 −0
  2. bun.lock +22 −0
  3. executor/apps/web/src/components/app-shell.tsx +1 −0
  4. executor/apps/web/src/components/tools/storage.tsx +238 −0
  5. executor/apps/web/src/components/tools/view.tsx +101 −2
  6. executor/apps/web/src/lib/types.ts +25 −0
  7. executor/apps/web/src/routeTree.gen.ts +21 −0
  8. executor/apps/web/src/routes/tools/storage.tsx +17 −0
  9. executor/package.json +3 −0
  10. executor/packages/core/src/types.ts +25 −0
  11. executor/packages/database/convex/_generated/api.d.ts +177 −0
  12. executor/packages/database/convex/database.ts +8 −0
  13. executor/packages/database/convex/database/storage_instances.ts +414 −0
  14. executor/packages/database/convex/schema.ts +33 −0
  15. executor/packages/database/convex/workspace.ts +71 −0
  16. executor/packages/database/package.json +4 −0
  17. executor/packages/database/src/database/mappers.ts +23 −0
  18. executor/packages/database/src/database/validators.ts +13 −0
  19. executor/packages/database/src/runtime/base_tools.ts +202 −30
  20. executor/packages/database/src/runtime/storage_provider.ts +641 −0
  21. executor/packages/database/src/runtime/storage_tool_contracts.ts +540 −0
  22. executor/packages/database/src/runtime/storage_tools.ts +387 −0
  23. executor/packages/database/src/runtime/tool_invocation.ts +9 −2
  24. executor/packages/database/src/runtime/workspace_tools.ts +4 −1
  25. executor/packages/database/src/types/sqljs-asm.d.ts +23 −0
  26. executor/packages/runner-sandbox-host/package.json +1 −0
  27. executor/packages/runner-sandbox-host/src/index.ts +380 −2
  28. executor/packages/runner-sandbox-host/src/types.ts +20 −0
  29. executor/packages/runner-sandbox-host/wrangler.jsonc +19 −1
  30. executor/scripts/dev/storage/smoke.execute-flow.ts +375 −0
  31. package.json +5 −1
.env.example+8−0
@@ -13,6 +13,14 @@ CONVEX_SITE_URL=https://your-deployment.convex.site · 12 unmodified lines
1313 # Leave unset in production to default to Cloudflare runtime.
1414 # DANGEROUSLY_ALLOW_LOCAL_VM=1
1515
16+# ── Agent storage backend (optional) ──
17+# Default: agentfs-local
18+# AGENT_STORAGE_PROVIDER=agentfs-local
19+# AGENT_STORAGE_ROOT=/tmp/executor-agentfs
20+# For Cloudflare Durable Objects storage proxy (served by runner-sandbox-host):
21+# AGENT_STORAGE_PROVIDER=agentfs-cloudflare
22+# Reuses CLOUDFLARE_SANDBOX_RUN_URL and CLOUDFLARE_SANDBOX_AUTH_TOKEN from the runtime config.
23+
1624 # ── WorkOS (optional — enables auth, omit for anonymous mode) ──
1725 # WORKOS_CLIENT_ID=client_...
1826 # WORKOS_API_KEY=sk_test_...
bun.lock+22−0
@@ -10,6 +10,7 @@ · 9 unmodified lines
1010 "@convex-dev/stripe": "^0.1.3",
1111 "@convex-dev/workos-authkit": "^0.1.6",
1212 "@workos-inc/node": "^8.2.0",
13+ "agentfs-sdk": "^0.6.0",
1314 "convex": "^1.31.7",
1415 "convex-helpers": "^0.1.111",
1516 "jose": "^6.1.0",
@@ -149,12 +150,17 @@ · 133 unmodified lines
149150 "executor/packages/database": {
150151 "name": "@executor/database",
151152 "version": "0.0.1",
153+ "dependencies": {
154+ "convex": "^1.31.7",
155+ "sql.js": "^1.14.0",
156+ },
152157 },
153158 "executor/packages/runner-sandbox-host": {
154159 "name": "@executor/runner-sandbox-host",
155160 "version": "0.1.0",
156161 "dependencies": {
157162 "@executor/database": "workspace:*",
163+ "agentfs-sdk": "^0.6.0",
158164 "better-result": "^2.7.0",
159165 "convex": "^1.31.7",
160166 },
@@ -1195,6 +1201,18 @@ · 1034 unmodified lines
11951201
11961202 "@ts-morph/common": ["@ts-morph/common@0.27.0", "", { "dependencies": { "fast-glob": "^3.3.3", "minimatch": "^10.0.1", "path-browserify": "^1.0.1" } }, "sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ=="],
11971203
1204+ "@tursodatabase/database": ["@tursodatabase/database@0.4.4", "", { "dependencies": { "@tursodatabase/database-common": "^0.4.4" }, "optionalDependencies": { "@tursodatabase/database-darwin-arm64": "0.4.4", "@tursodatabase/database-linux-arm64-gnu": "0.4.4", "@tursodatabase/database-linux-x64-gnu": "0.4.4", "@tursodatabase/database-win32-x64-msvc": "0.4.4" } }, "sha512-QkQ0qLp0AY8dC9BOrIMyeyUXoFCvhlRaH+wcg013OvzYufSmDnnzg44U0tQkmnXYfUZEIp+IpADO/UeGNL0M2Q=="],
1205+
1206+ "@tursodatabase/database-common": ["@tursodatabase/database-common@0.4.4", "", {}, "sha512-iuSYWgBQEETjwfWOmi3E4Q++jQTb+VOPXeaKozQqW8fXtgU6ZaYUMmWbqQVMGSS9sAWZlZi9qyPVz0rkT8zoqQ=="],
1207+
1208+ "@tursodatabase/database-darwin-arm64": ["@tursodatabase/database-darwin-arm64@0.4.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-nQMYrJY1X0sNgNWdbuPdbNCL9S/Ei9KLMwx3hFdyUDYJWU0h7o+kZv50QoNXB7vb7LmEBMeVWZaVEQbHd/Ionw=="],
1209+
1210+ "@tursodatabase/database-linux-arm64-gnu": ["@tursodatabase/database-linux-arm64-gnu@0.4.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-6vnnKBgAgfKhspr+nScPevOSrrnZwNJKuUbiHMSagOqNDZCzOAGDcfeC83sKd2QvZquR17l6V3tFxcFN5FnByQ=="],
1211+
1212+ "@tursodatabase/database-linux-x64-gnu": ["@tursodatabase/database-linux-x64-gnu@0.4.4", "", { "os": "linux", "cpu": "x64" }, "sha512-N63wK6ow6wPmD1/BjBhbrR8/Ifo6+A6zavpzgsT5YABVC4wGZBvJ1TvSz935R9n5E8eqQNY8jKiQPJLwpgOjdw=="],
1213+
1214+ "@tursodatabase/database-win32-x64-msvc": ["@tursodatabase/database-win32-x64-msvc@0.4.4", "", { "os": "win32", "cpu": "x64" }, "sha512-TYEl5FaUAImgZew05FvxvcFWLb1AQ+Zeyw/ApEbBDqS7mQmbz8VGs4iq04ECzJG2esfBR+zrLdDEG3X1BnaDTg=="],
1215+
11981216 "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
11991217
12001218 "@types/accepts": ["@types/accepts@1.3.7", "", { "dependencies": { "@types/node": "*" } }, "sha512-Pay9fq2lM2wXPWbteBsRAGiWH2hig4ZE2asK+mm7kUzlxRTfL961rj89I6zV/E3PcIkDqyuBEcMxFT7rccugeQ=="],
@@ -1333,6 +1351,8 @@ · 132 unmodified lines
13331351
13341352 "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
13351353
1354+ "agentfs-sdk": ["agentfs-sdk@0.6.0", "", { "dependencies": { "@tursodatabase/database": "^0.4.0-pre.18", "@tursodatabase/database-common": "^0.4.0-pre.18", "buffer": "^6.0.3" }, "peerDependencies": { "just-bash": ">=2.0.0" }, "optionalPeers": ["just-bash"] }, "sha512-CyrrZyfJk3H+Q6feMvK/S7hlrIKykGzJ+wCq/URgt72A3tBsYHLkDF7ZGOMMu1BasT9HFrWXunyN0PXD646/ig=="],
1355+
13361356 "ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="],
13371357
13381358 "ajv-draft-04": ["ajv-draft-04@1.0.0", "", { "peerDependencies": { "ajv": "^8.5.0" }, "optionalPeers": ["ajv"] }, "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw=="],
@@ -2403,6 +2423,8 @@ · 1064 unmodified lines
24032423
24042424 "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
24052425
2426+ "sql.js": ["sql.js@1.14.0", "", {}, "sha512-NXYh+kFqLiYRCNAaHD0PcbjFgXyjuolEKLMk5vRt2DgPENtF1kkNzzMlg42dUk5wIsH8MhUzsRhaUxIisoSlZQ=="],
2427+
24062428 "srvx": ["srvx@0.10.1", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-A//xtfak4eESMWWydSRFUVvCTQbSwivnGCEf8YGPe2eHU0+Z6znfUTCPF0a7oV3sObSOcrXHlL6Bs9vVctfXdg=="],
24072429
24082430 "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
executor/apps/web/src/components/app-shell.tsx+1−0
@@ -38,6 +38,7 @@ const NAV_ITEMS = [ · 37 unmodified lines
3838 label: "Connections",
3939 matchPrefixes: ["/tools/connections", "/tools/credentials"],
4040 },
41+ { href: "/tools/storage", label: "Storage" },
4142 { href: "/tools/policies", label: "Policies" },
4243 {
4344 href: "/tools/editor",
executor/apps/web/src/components/tools/storage.tsx+238−0
@@ -0,0 +1,238 @@
1+"use client";
2+
3+import { useMemo, useState } from "react";
4+import { Database, FolderOpen, Plus, Trash2, Power } from "lucide-react";
5+import { Badge } from "@/components/ui/badge";
6+import { Button } from "@/components/ui/button";
7+import { Input } from "@/components/ui/input";
8+import { Skeleton } from "@/components/ui/skeleton";
9+import type { StorageDurability, StorageInstanceRecord, StorageScopeType } from "@/lib/types";
10+import { cn } from "@/lib/utils";
11+
12+type CreateStorageArgs = {
13+ scopeType: StorageScopeType;
14+ durability: StorageDurability;
15+ purpose?: string;
16+ ttlHours?: number;
17+};
18+
19+function prettyBytes(value?: number): string {
20+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
21+ return "-";
22+ }
23+
24+ const units = ["B", "KB", "MB", "GB", "TB"];
25+ let bytes = value;
26+ let index = 0;
27+ while (bytes >= 1024 && index < units.length - 1) {
28+ bytes /= 1024;
29+ index += 1;
30+ }
31+ const precision = bytes >= 100 || index === 0 ? 0 : 1;
32+ return `${bytes.toFixed(precision)} ${units[index]}`;
33+}
34+
35+function asLocalDate(value?: number): string {
36+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
37+ return "-";
38+ }
39+ return new Date(value).toLocaleString();
40+}
41+
42+export function StoragePanel({
43+ instances,
44+ loading,
45+ creating,
46+ busyInstanceId,
47+ onCreate,
48+ onClose,
49+ onDelete,
50+}: {
51+ instances: StorageInstanceRecord[];
52+ loading: boolean;
53+ creating: boolean;
54+ busyInstanceId?: string;
55+ onCreate: (args: CreateStorageArgs) => Promise<void>;
56+ onClose: (instanceId: string) => Promise<void>;
57+ onDelete: (instanceId: string) => Promise<void>;
58+}) {
59+ const [scopeType, setScopeType] = useState<StorageScopeType>("scratch");
60+ const [durability, setDurability] = useState<StorageDurability>("ephemeral");
61+ const [purpose, setPurpose] = useState("");
62+ const [ttlHours, setTtlHours] = useState("24");
63+
64+ const visibleInstances = useMemo(
65+ () => [...instances].sort((a, b) => b.updatedAt - a.updatedAt),
66+ [instances],
67+ );
68+
69+ const submitCreate = async () => {
70+ const parsedTtl = Number.parseInt(ttlHours, 10);
71+ await onCreate({
72+ scopeType,
73+ durability,
74+ ...(purpose.trim().length > 0 ? { purpose: purpose.trim() } : {}),
75+ ...(durability === "ephemeral" && Number.isFinite(parsedTtl) ? { ttlHours: parsedTtl } : {}),
76+ });
77+ setPurpose("");
78+ };
79+
80+ return (
81+ <section className="mx-auto flex h-full min-h-0 w-full max-w-6xl flex-col rounded-none border border-border/50 bg-card/30">
82+ <div className="flex items-center justify-between border-b border-border/40 px-4 py-3 sm:px-5">
83+ <div className="flex items-center gap-2">
84+ <div className="flex h-7 w-7 items-center justify-center border border-border/60 bg-background/80">
85+ <Database className="h-4 w-4 text-muted-foreground" />
86+ </div>
87+ <h2 className="text-sm font-medium">Storage Instances</h2>
88+ </div>
89+ <Badge variant="outline" className="h-5 px-2 text-[10px] font-mono uppercase tracking-wide text-muted-foreground">
90+ {visibleInstances.length}
91+ </Badge>
92+ </div>
93+
94+ <div className="border-b border-border/40 bg-background/40 px-4 py-3 sm:px-5">
95+ <div className="grid gap-2 md:grid-cols-5">
96+ <label className="flex flex-col gap-1 text-[11px] text-muted-foreground">
97+ Scope
98+ <select
99+ className="h-8 rounded-none border border-border/60 bg-background px-2 text-xs text-foreground"
100+ value={scopeType}
101+ onChange={(event) => {
102+ const next = event.target.value as StorageScopeType;
103+ setScopeType(next);
104+ if (next !== "scratch") {
105+ setDurability("durable");
106+ }
107+ }}
108+ >
109+ <option value="scratch">scratch</option>
110+ <option value="account">account</option>
111+ <option value="workspace">workspace</option>
112+ <option value="organization">organization</option>
113+ </select>
114+ </label>
115+
116+ <label className="flex flex-col gap-1 text-[11px] text-muted-foreground">
117+ Durability
118+ <select
119+ className="h-8 rounded-none border border-border/60 bg-background px-2 text-xs text-foreground"
120+ value={durability}
121+ onChange={(event) => setDurability(event.target.value as StorageDurability)}
122+ >
123+ <option value="ephemeral">ephemeral</option>
124+ <option value="durable">durable</option>
125+ </select>
126+ </label>
127+
128+ <label className="flex flex-col gap-1 text-[11px] text-muted-foreground md:col-span-2">
129+ Purpose
130+ <Input
131+ value={purpose}
132+ onChange={(event) => setPurpose(event.target.value)}
133+ placeholder="scratch for repo indexing"
134+ className="h-8 rounded-none text-xs"
135+ />
136+ </label>
137+
138+ <div className="flex items-end gap-2">
139+ <label className="flex flex-1 flex-col gap-1 text-[11px] text-muted-foreground">
140+ TTL (hours)
141+ <Input
142+ value={ttlHours}
143+ onChange={(event) => setTtlHours(event.target.value)}
144+ disabled={durability !== "ephemeral"}
145+ className="h-8 rounded-none text-xs"
146+ />
147+ </label>
148+ <Button size="sm" className="h-8 rounded-none text-xs" disabled={creating} onClick={() => void submitCreate()}>
149+ <Plus className="mr-1.5 h-3.5 w-3.5" />
150+ Create
151+ </Button>
152+ </div>
153+ </div>
154+ </div>
155+
156+ <div className="min-h-0 flex-1 overflow-y-auto p-4 sm:p-5">
157+ {loading ? (
158+ <div className="space-y-2">
159+ {Array.from({ length: 4 }).map((_, i) => (
160+ <Skeleton key={i} className="h-16 rounded-none" />
161+ ))}
162+ </div>
163+ ) : visibleInstances.length === 0 ? (
164+ <div className="flex h-full min-h-[260px] flex-col items-center justify-center gap-2 border border-dashed border-border/50 bg-background/50">
165+ <div className="flex h-10 w-10 items-center justify-center border border-border/60 bg-muted/40">
166+ <FolderOpen className="h-5 w-5 text-muted-foreground/50" />
167+ </div>
168+ <p className="text-sm text-muted-foreground">No storage instances yet</p>
169+ <p className="text-[11px] text-muted-foreground/70 text-center max-w-md">
170+ Create a scratch instance for short-lived runs or durable instances for shared state.
171+ </p>
172+ </div>
173+ ) : (
174+ <div className="space-y-2">
175+ {visibleInstances.map((instance) => {
176+ const busy = busyInstanceId === instance.id;
177+ return (
178+ <div
179+ key={instance.id}
180+ className={cn(
181+ "group flex items-center gap-3 border border-border/50 bg-background/70 px-3 py-2.5 transition-colors",
182+ busy ? "opacity-60" : "hover:border-border hover:bg-accent/20",
183+ )}
184+ style={{ boxShadow: "inset 2px 0 0 0 color-mix(in oklch, var(--border) 70%, transparent)" }}
185+ >
186+ <div className="flex h-8 w-8 shrink-0 items-center justify-center border border-border/60 bg-muted/50 overflow-hidden">
187+ <Database className="h-4 w-4 text-muted-foreground" />
188+ </div>
189+
190+ <div className="flex-1 min-w-0">
191+ <div className="mb-1 flex items-center gap-1.5 flex-wrap">
192+ <span className="text-sm font-medium truncate">{instance.purpose || instance.id}</span>
193+ <Badge variant="outline" className="text-[9px] font-mono uppercase tracking-wider">{instance.scopeType}</Badge>
194+ <Badge variant="outline" className="text-[9px] font-mono uppercase tracking-wider">{instance.durability}</Badge>
195+ <Badge variant="outline" className="text-[9px] font-mono uppercase tracking-wider">{instance.provider}</Badge>
196+ <Badge variant="outline" className="text-[9px] font-mono uppercase tracking-wider">{instance.status}</Badge>
197+ </div>
198+
199+ <p className="text-[11px] text-muted-foreground">
200+ {prettyBytes(instance.sizeBytes)} - {instance.fileCount ?? "-"} inode{instance.fileCount === 1 ? "" : "s"}
201+ </p>
202+ <p className="mt-0.5 text-[11px] text-muted-foreground">
203+ Last used {asLocalDate(instance.lastSeenAt)}
204+ {instance.expiresAt ? ` - expires ${asLocalDate(instance.expiresAt)}` : ""}
205+ </p>
206+ </div>
207+
208+ <div className="flex items-center gap-1">
209+ <Button
210+ variant="outline"
211+ size="sm"
212+ className="h-7 rounded-none text-[11px]"
213+ disabled={busy}
214+ onClick={() => void onClose(instance.id)}
215+ >
216+ <Power className="mr-1 h-3 w-3" />
217+ Close
218+ </Button>
219+ <Button
220+ variant="outline"
221+ size="sm"
222+ className="h-7 rounded-none text-[11px] border-destructive/40 text-destructive hover:bg-destructive/10"
223+ disabled={busy}
224+ onClick={() => void onDelete(instance.id)}
225+ >
226+ <Trash2 className="mr-1 h-3 w-3" />
227+ Delete
228+ </Button>
229+ </div>
230+ </div>
231+ );
232+ })}
233+ </div>
234+ )}
235+ </div>
236+ </section>
237+ );
238+}
executor/apps/web/src/components/tools/view.tsx+101−2
@@ -10,14 +10,17 @@ import { TaskComposer } from "@/components/tasks/task-composer"; · 9 unmodified lines
1010 import { CredentialsPanel } from "@/components/tools/credentials";
1111 import { ConnectionFormDialog } from "@/components/tools/connection/form-dialog";
1212 import { PoliciesPanel } from "@/components/tools/policies";
13+import { StoragePanel } from "@/components/tools/storage";
1314 import { useSession } from "@/lib/session-context";
1415 import { useWorkspaceTools } from "@/hooks/use/workspace-tools";
15−import { useQuery } from "convex/react";
16+import { useMutation, useQuery } from "convex/react";
1617 import { toast } from "sonner";
1718 import { convexApi } from "@/lib/convex-api";
1819 import type {
1920 ToolSourceRecord,
2021 CredentialRecord,
22+ StorageDurability,
23+ StorageScopeType,
2124 } from "@/lib/types";
2225 import {
2326 parseWarningSourceName,
@@ -29,7 +32,7 @@ import type { SourceDialogMeta } from "@/components/tools/add/source-dialog"; · 5 unmodified lines
2932 import type { FilterApproval } from "@/components/tools/explorer-derived";
3033 import { toolsCatalogQueryParsers } from "@/lib/url-state/tools";
3134
32−type ToolsTab = "catalog" | "connections" | "policies" | "editor";
35+type ToolsTab = "catalog" | "connections" | "policies" | "storage" | "editor";
3336 const INVENTORY_REGENERATION_TOAST_ID = "tool-inventory-regeneration";
3437
3538 function parseToolsTab(pathname: string): ToolsTab {
@@ -45,6 +48,9 @@ function parseToolsTab(pathname: string): ToolsTab { · 9 unmodified lines
4548 if (last === "policies") {
4649 return "policies";
4750 }
51+ if (last === "storage") {
52+ return "storage";
53+ }
4854 if (last === "editor" || last === "runner") {
4955 return "editor";
5056 }
@@ -81,6 +87,8 @@ export function ToolsView() { · 30 unmodified lines
8187 const [connectionDialogOpen, setConnectionDialogOpen] = useState(false);
8288 const [connectionDialogEditing, setConnectionDialogEditing] = useState<CredentialRecord | null>(null);
8389 const [connectionDialogSourceKey, setConnectionDialogSourceKey] = useState<string | null>(null);
90+ const [storageMutationBusyId, setStorageMutationBusyId] = useState<string | undefined>(undefined);
91+ const [storageCreateBusy, setStorageCreateBusy] = useState(false);
8492 const [regenerationInFlight, setRegenerationInFlight] = useState(false);
8593 const lastRegenerationToastMessageRef = useRef<string | null>(null);
8694
@@ -188,8 +196,17 @@ export function ToolsView() { · 101 unmodified lines
188196 convexApi.workspace.listCredentials,
189197 workspaceQueryArgs(context),
190198 );
199+ const storageInstances = useQuery(
200+ convexApi.workspace.listStorageInstances,
201+ workspaceQueryArgs(context),
202+ );
203+ const openStorageInstance = useMutation(convexApi.workspace.openStorageInstance);
204+ const closeStorageInstance = useMutation(convexApi.workspace.closeStorageInstance);
205+ const deleteStorageInstance = useMutation(convexApi.workspace.deleteStorageInstance);
191206 const credentialItems: CredentialRecord[] = credentials ?? [];
192207 const credentialsLoading = !!context && credentials === undefined;
208+ const storageItems = storageInstances ?? [];
209+ const storageLoading = !!context && storageInstances === undefined;
193210
194211 const globalWarnings = useMemo(
195212 () => warnings.filter((warning) => !parseWarningSourceName(warning)),
@@ -370,6 +387,74 @@ export function ToolsView() { · 174 unmodified lines
370387 }
371388 };
372389
390+ const handleCreateStorageInstance = useCallback(async (args: {
391+ scopeType: StorageScopeType;
392+ durability: StorageDurability;
393+ purpose?: string;
394+ ttlHours?: number;
395+ }) => {
396+ if (!context || storageCreateBusy) {
397+ return;
398+ }
399+
400+ setStorageCreateBusy(true);
401+ try {
402+ await openStorageInstance({
403+ workspaceId: context.workspaceId,
404+ sessionId: context.sessionId,
405+ scopeType: args.scopeType,
406+ durability: args.durability,
407+ purpose: args.purpose,
408+ ttlHours: args.ttlHours,
409+ });
410+ toast.success("Storage instance created");
411+ } catch (error) {
412+ toast.error(error instanceof Error ? error.message : "Failed to create storage instance");
413+ } finally {
414+ setStorageCreateBusy(false);
415+ }
416+ }, [context, openStorageInstance, storageCreateBusy]);
417+
418+ const handleCloseStorageInstance = useCallback(async (instanceId: string) => {
419+ if (!context || storageMutationBusyId) {
420+ return;
421+ }
422+
423+ setStorageMutationBusyId(instanceId);
424+ try {
425+ await closeStorageInstance({
426+ workspaceId: context.workspaceId,
427+ sessionId: context.sessionId,
428+ instanceId,
429+ });
430+ toast.success("Storage instance closed");
431+ } catch (error) {
432+ toast.error(error instanceof Error ? error.message : "Failed to close storage instance");
433+ } finally {
434+ setStorageMutationBusyId(undefined);
435+ }
436+ }, [closeStorageInstance, context, storageMutationBusyId]);
437+
438+ const handleDeleteStorageInstance = useCallback(async (instanceId: string) => {
439+ if (!context || storageMutationBusyId) {
440+ return;
441+ }
442+
443+ setStorageMutationBusyId(instanceId);
444+ try {
445+ await deleteStorageInstance({
446+ workspaceId: context.workspaceId,
447+ sessionId: context.sessionId,
448+ instanceId,
449+ });
450+ toast.success("Storage instance deleted");
451+ } catch (error) {
452+ toast.error(error instanceof Error ? error.message : "Failed to delete storage instance");
453+ } finally {
454+ setStorageMutationBusyId(undefined);
455+ }
456+ }, [context, deleteStorageInstance, storageMutationBusyId]);
457+
373458 if (sessionLoading) {
374459 return (
375460 <div className="flex h-full min-h-0 flex-col">
@@ -472,6 +557,20 @@ export function ToolsView() { · 96 unmodified lines
472557 </div>
473558 ) : null}
474559
560+ {activeTab === "storage" ? (
561+ <div className="flex-1 min-h-0 overflow-y-auto p-4 md:p-6 lg:p-8">
562+ <StoragePanel
563+ instances={storageItems}
564+ loading={storageLoading}
565+ creating={storageCreateBusy}
566+ busyInstanceId={storageMutationBusyId}
567+ onCreate={handleCreateStorageInstance}
568+ onClose={handleCloseStorageInstance}
569+ onDelete={handleDeleteStorageInstance}
570+ />
571+ </div>
572+ ) : null}
573+
475574 <ConnectionFormDialog
476575 open={connectionDialogOpen}
477576 onOpenChange={handleConnectionDialogOpenChange}
executor/apps/web/src/lib/types.ts+25−0
@@ -30,6 +30,10 @@ export type ToolSourceScopeType = "organization" | "workspace"; · 29 unmodified lines
3030 export type CredentialScopeType = "account" | "organization" | "workspace";
3131 export type ToolApprovalMode = "auto" | "required";
3232 export type ToolSourceType = "mcp" | "openapi" | "graphql";
33+export type StorageScopeType = "scratch" | "account" | "workspace" | "organization";
34+export type StorageDurability = "ephemeral" | "durable";
35+export type StorageInstanceStatus = "active" | "closed" | "deleted";
36+export type StorageProvider = "agentfs-local" | "agentfs-cloudflare";
3337
3438 export type SourceAuthType = "none" | "bearer" | "apiKey" | "basic" | "mixed";
3539
@@ -120,6 +124,27 @@ export interface ToolSourceRecord { · 84 unmodified lines
120124 updatedAt: number;
121125 }
122126
127+export interface StorageInstanceRecord {
128+ id: string;
129+ scopeType: StorageScopeType;
130+ durability: StorageDurability;
131+ status: StorageInstanceStatus;
132+ provider: StorageProvider;
133+ backendKey: string;
134+ organizationId: string;
135+ workspaceId?: string;
136+ accountId?: string;
137+ createdByAccountId?: string;
138+ purpose?: string;
139+ sizeBytes?: number;
140+ fileCount?: number;
141+ createdAt: number;
142+ updatedAt: number;
143+ lastSeenAt: number;
144+ closedAt?: number;
145+ expiresAt?: number;
146+}
147+
123148 export interface ToolDescriptor {
124149 path: string;
125150 description: string;
executor/apps/web/src/routeTree.gen.ts+21−0
@@ -25,6 +25,7 @@ import { Route as BillingRouteImport } from './routes/billing' · 24 unmodified lines
2525 import { Route as ApprovalsRouteImport } from './routes/approvals'
2626 import { Route as IndexRouteImport } from './routes/index'
2727 import { Route as ToolsIndexRouteImport } from './routes/tools/index'
28+import { Route as ToolsStorageRouteImport } from './routes/tools/storage'
2829 import { Route as ToolsPoliciesRouteImport } from './routes/tools/policies'
2930 import { Route as ToolsEditorRouteImport } from './routes/tools/editor'
3031 import { Route as ToolsConnectionsRouteImport } from './routes/tools/connections'
@@ -118,6 +119,11 @@ const ToolsIndexRoute = ToolsIndexRouteImport.update({ · 87 unmodified lines
118119 path: '/tools/',
119120 getParentRoute: () => rootRouteImport,
120121 } as any)
122+const ToolsStorageRoute = ToolsStorageRouteImport.update({
123+ id: '/tools/storage',
124+ path: '/tools/storage',
125+ getParentRoute: () => rootRouteImport,
126+} as any)
121127 const ToolsPoliciesRoute = ToolsPoliciesRouteImport.update({
122128 id: '/tools/policies',
123129 path: '/tools/policies',
@@ -199,6 +205,7 @@ export interface FileRoutesByFullPath { · 75 unmodified lines
199205 '/tools/connections': typeof ToolsConnectionsRoute
200206 '/tools/editor': typeof ToolsEditorRoute
201207 '/tools/policies': typeof ToolsPoliciesRoute
208+ '/tools/storage': typeof ToolsStorageRoute
202209 '/tools/': typeof ToolsIndexRoute
203210 '/api/auth/anonymous-token': typeof ApiAuthAnonymousTokenRoute
204211 '/api/auth/callback': typeof ApiAuthCallbackRoute
@@ -229,6 +236,7 @@ export interface FileRoutesByTo { · 24 unmodified lines
229236 '/tools/connections': typeof ToolsConnectionsRoute
230237 '/tools/editor': typeof ToolsEditorRoute
231238 '/tools/policies': typeof ToolsPoliciesRoute
239+ '/tools/storage': typeof ToolsStorageRoute
232240 '/tools': typeof ToolsIndexRoute
233241 '/api/auth/anonymous-token': typeof ApiAuthAnonymousTokenRoute
234242 '/api/auth/callback': typeof ApiAuthCallbackRoute
@@ -260,6 +268,7 @@ export interface FileRoutesById { · 25 unmodified lines
260268 '/tools/connections': typeof ToolsConnectionsRoute
261269 '/tools/editor': typeof ToolsEditorRoute
262270 '/tools/policies': typeof ToolsPoliciesRoute
271+ '/tools/storage': typeof ToolsStorageRoute
263272 '/tools/': typeof ToolsIndexRoute
264273 '/api/auth/anonymous-token': typeof ApiAuthAnonymousTokenRoute
265274 '/api/auth/callback': typeof ApiAuthCallbackRoute
@@ -292,6 +301,7 @@ export interface FileRouteTypes { · 26 unmodified lines
292301 | '/tools/connections'
293302 | '/tools/editor'
294303 | '/tools/policies'
304+ | '/tools/storage'
295305 | '/tools/'
296306 | '/api/auth/anonymous-token'
297307 | '/api/auth/callback'
@@ -322,6 +332,7 @@ export interface FileRouteTypes { · 24 unmodified lines
322332 | '/tools/connections'
323333 | '/tools/editor'
324334 | '/tools/policies'
335+ | '/tools/storage'
325336 | '/tools'
326337 | '/api/auth/anonymous-token'
327338 | '/api/auth/callback'
@@ -352,6 +363,7 @@ export interface FileRouteTypes { · 24 unmodified lines
352363 | '/tools/connections'
353364 | '/tools/editor'
354365 | '/tools/policies'
366+ | '/tools/storage'
355367 | '/tools/'
356368 | '/api/auth/anonymous-token'
357369 | '/api/auth/callback'
@@ -383,6 +395,7 @@ export interface RootRouteChildren { · 25 unmodified lines
383395 ToolsConnectionsRoute: typeof ToolsConnectionsRoute
384396 ToolsEditorRoute: typeof ToolsEditorRoute
385397 ToolsPoliciesRoute: typeof ToolsPoliciesRoute
398+ ToolsStorageRoute: typeof ToolsStorageRoute
386399 ToolsIndexRoute: typeof ToolsIndexRoute
387400 ApiAuthAnonymousTokenRoute: typeof ApiAuthAnonymousTokenRoute
388401 ApiAuthCallbackRoute: typeof ApiAuthCallbackRoute
@@ -508,6 +521,13 @@ declare module '@tanstack/react-router' { · 119 unmodified lines
508521 preLoaderRoute: typeof ToolsIndexRouteImport
509522 parentRoute: typeof rootRouteImport
510523 }
524+ '/tools/storage': {
525+ id: '/tools/storage'
526+ path: '/tools/storage'
527+ fullPath: '/tools/storage'
528+ preLoaderRoute: typeof ToolsStorageRouteImport
529+ parentRoute: typeof rootRouteImport
530+ }
511531 '/tools/policies': {
512532 id: '/tools/policies'
513533 path: '/tools/policies'
@@ -615,6 +635,7 @@ const rootRouteChildren: RootRouteChildren = { · 101 unmodified lines
615635 ToolsConnectionsRoute: ToolsConnectionsRoute,
616636 ToolsEditorRoute: ToolsEditorRoute,
617637 ToolsPoliciesRoute: ToolsPoliciesRoute,
638+ ToolsStorageRoute: ToolsStorageRoute,
618639 ToolsIndexRoute: ToolsIndexRoute,
619640 ApiAuthAnonymousTokenRoute: ApiAuthAnonymousTokenRoute,
620641 ApiAuthCallbackRoute: ApiAuthCallbackRoute,
executor/apps/web/src/routes/tools/storage.tsx+17−0
@@ -0,0 +1,17 @@
1+import { createFileRoute } from "@tanstack/react-router";
2+import { AppShell } from "@/components/app-shell";
3+import { ToolsView } from "@/components/tools/view";
4+import { normalizeToolsSearch } from "@/lib/url-state/tools";
5+
6+export const Route = createFileRoute("/tools/storage")({
7+ validateSearch: (search: Record<string, unknown>) => normalizeToolsSearch(search),
8+ component: ToolsStoragePage,
9+});
10+
11+function ToolsStoragePage() {
12+ return (
13+ <AppShell>
14+ <ToolsView />
15+ </AppShell>
16+ );
17+}
executor/package.json+3−0
@@ -29,6 +29,9 @@ · 28 unmodified lines
2929 "test:e2e:install": "bun test scripts/e2e/install.e2e.test.ts",
3030 "test:e2e:install:sandbox": "bun test scripts/e2e/install.sandbox.e2e.test.ts",
3131 "sandbox:install:session": "bun run scripts/e2e/install.sandbox.session.ts",
32+ "smoke:storage:execute-flow:local": "bun run scripts/dev/storage/smoke.execute-flow.ts --runtime local-bun --deploy-convex",
33+ "smoke:storage:execute-flow": "bun run scripts/dev/storage/smoke.execute-flow.ts --runtime cloudflare-worker-loader --deploy-convex --deploy-worker",
34+ "smoke:storage:execute-flow:cloudflare": "bun run scripts/dev/storage/smoke.execute-flow.ts --runtime cloudflare-worker-loader --deploy-convex --deploy-worker",
3235 "typecheck": "bunx --package @typescript/native-preview tsgo --noEmit -p tsconfig.json",
3336 "typecheck:slow": "bunx tsc --noEmit -p tsconfig.json",
3437 "build:binary": "bun build executor.ts --compile --outfile dist/executor",
executor/packages/core/src/types.ts+25−0
@@ -17,6 +17,10 @@ export type CredentialScope = CredentialScopeType; · 16 unmodified lines
1717 export type CredentialProvider = "local-convex" | "workos-vault";
1818 export type ToolApprovalMode = "auto" | "required";
1919 export type ToolSourceType = "mcp" | "openapi" | "graphql";
20+export type StorageScopeType = "scratch" | "account" | "workspace" | "organization";
21+export type StorageDurability = "ephemeral" | "durable";
22+export type StorageInstanceStatus = "active" | "closed" | "deleted";
23+export type StorageProvider = "agentfs-local" | "agentfs-cloudflare";
2024
2125 export type JsonSchema = Record<string, unknown>;
2226
@@ -195,6 +199,27 @@ export interface ToolSourceRecord { · 172 unmodified lines
195199 updatedAt: number;
196200 }
197201
202+export interface StorageInstanceRecord {
203+ id: string;
204+ scopeType: StorageScopeType;
205+ durability: StorageDurability;
206+ status: StorageInstanceStatus;
207+ provider: StorageProvider;
208+ backendKey: string;
209+ organizationId: Id<"organizations">;
210+ workspaceId?: Id<"workspaces">;
211+ accountId?: Id<"accounts">;
212+ createdByAccountId?: Id<"accounts">;
213+ purpose?: string;
214+ sizeBytes?: number;
215+ fileCount?: number;
216+ createdAt: number;
217+ updatedAt: number;
218+ lastSeenAt: number;
219+ closedAt?: number;
220+ expiresAt?: number;
221+}
222+
198223 export interface ToolDescriptor {
199224 path: string;
200225 description: string;
executor/packages/database/convex/_generated/api.d.ts+177−0
@@ -306,6 +306,18 @@ export declare const api: { · 305 unmodified lines
306306 { accountId?: string; sessionId?: string },
307307 any
308308 >;
309+ closeStorageInstance: FunctionReference<
310+ "mutation",
311+ "public",
312+ { instanceId: string; sessionId?: string; workspaceId: Id<"workspaces"> },
313+ any
314+ >;
315+ deleteStorageInstance: FunctionReference<
316+ "mutation",
317+ "public",
318+ { instanceId: string; sessionId?: string; workspaceId: Id<"workspaces"> },
319+ any
320+ >;
309321 deleteToolPolicyAssignment: FunctionReference<
310322 "mutation",
311323 "public",
@@ -369,6 +381,17 @@ export declare const api: { · 57 unmodified lines
369381 { sessionId?: string; workspaceId: Id<"workspaces"> },
370382 any
371383 >;
384+ listStorageInstances: FunctionReference<
385+ "query",
386+ "public",
387+ {
388+ includeDeleted?: boolean;
389+ scopeType?: "scratch" | "account" | "workspace" | "organization";
390+ sessionId?: string;
391+ workspaceId: Id<"workspaces">;
392+ },
393+ any
394+ >;
372395 listTasks: FunctionReference<
373396 "query",
374397 "public",
@@ -405,6 +428,20 @@ export declare const api: { · 30 unmodified lines
405428 { sessionId?: string; workspaceId: Id<"workspaces"> },
406429 any
407430 >;
431+ openStorageInstance: FunctionReference<
432+ "mutation",
433+ "public",
434+ {
435+ durability?: "ephemeral" | "durable";
436+ instanceId?: string;
437+ purpose?: string;
438+ scopeType?: "scratch" | "account" | "workspace" | "organization";
439+ sessionId?: string;
440+ ttlHours?: number;
441+ workspaceId: Id<"workspaces">;
442+ },
443+ any
444+ >;
408445 regenerateToolInventory: FunctionReference<
409446 "mutation",
410447 "public",
@@ -670,6 +707,16 @@ export declare const internal: { · 259 unmodified lines
670707 { accountId?: string; clientId?: string; sessionId?: string },
671708 any
672709 >;
710+ closeStorageInstance: FunctionReference<
711+ "mutation",
712+ "internal",
713+ {
714+ accountId?: Id<"accounts">;
715+ instanceId: string;
716+ workspaceId: Id<"workspaces">;
717+ },
718+ any
719+ >;
673720 createApproval: FunctionReference<
674721 "mutation",
675722 "internal",
@@ -742,6 +789,16 @@ export declare const internal: { · 66 unmodified lines
742789 any
743790 >;
744791 };
792+ deleteStorageInstance: FunctionReference<
793+ "mutation",
794+ "internal",
795+ {
796+ accountId?: Id<"accounts">;
797+ instanceId: string;
798+ workspaceId: Id<"workspaces">;
799+ },
800+ any
801+ >;
745802 deleteToolPolicyAssignment: FunctionReference<
746803 "mutation",
747804 "internal",
@@ -789,6 +846,16 @@ export declare const internal: { · 41 unmodified lines
789846 { approvalId: string; workspaceId: Id<"workspaces"> },
790847 any
791848 >;
849+ getStorageInstance: FunctionReference<
850+ "query",
851+ "internal",
852+ {
853+ accountId?: Id<"accounts">;
854+ instanceId: string;
855+ workspaceId: Id<"workspaces">;
856+ },
857+ any
858+ >;
792859 getTask: FunctionReference<"query", "internal", { taskId: string }, any>;
793860 getTaskInWorkspace: FunctionReference<
794861 "query",
@@ -831,6 +898,17 @@ export declare const internal: { · 36 unmodified lines
831898 any
832899 >;
833900 listRuntimeTargets: FunctionReference<"query", "internal", {}, any>;
901+ listStorageInstances: FunctionReference<
902+ "query",
903+ "internal",
904+ {
905+ accountId?: Id<"accounts">;
906+ includeDeleted?: boolean;
907+ scopeType?: "scratch" | "account" | "workspace" | "organization";
908+ workspaceId: Id<"workspaces">;
909+ },
910+ any
911+ >;
834912 listTaskEvents: FunctionReference<
835913 "query",
836914 "internal",
@@ -896,6 +974,20 @@ export declare const internal: { · 59 unmodified lines
896974 { taskId: string },
897975 any
898976 >;
977+ openStorageInstance: FunctionReference<
978+ "mutation",
979+ "internal",
980+ {
981+ accountId?: Id<"accounts">;
982+ durability?: "ephemeral" | "durable";
983+ instanceId?: string;
984+ purpose?: string;
985+ scopeType?: "scratch" | "account" | "workspace" | "organization";
986+ ttlHours?: number;
987+ workspaceId: Id<"workspaces">;
988+ },
989+ any
990+ >;
899991 policies: {
900992 deleteToolPolicyAssignment: FunctionReference<
901993 "mutation",
@@ -1018,6 +1110,77 @@ export declare const internal: { · 116 unmodified lines
10181110 { approvalId: string; callId: string; taskId: string },
10191111 any
10201112 >;
1113+ storage_instances: {
1114+ closeStorageInstance: FunctionReference<
1115+ "mutation",
1116+ "internal",
1117+ {
1118+ accountId?: Id<"accounts">;
1119+ instanceId: string;
1120+ workspaceId: Id<"workspaces">;
1121+ },
1122+ any
1123+ >;
1124+ deleteStorageInstance: FunctionReference<
1125+ "mutation",
1126+ "internal",
1127+ {
1128+ accountId?: Id<"accounts">;
1129+ instanceId: string;
1130+ workspaceId: Id<"workspaces">;
1131+ },
1132+ any
1133+ >;
1134+ getStorageInstance: FunctionReference<
1135+ "query",
1136+ "internal",
1137+ {
1138+ accountId?: Id<"accounts">;
1139+ instanceId: string;
1140+ workspaceId: Id<"workspaces">;
1141+ },
1142+ any
1143+ >;
1144+ listStorageInstances: FunctionReference<
1145+ "query",
1146+ "internal",
1147+ {
1148+ accountId?: Id<"accounts">;
1149+ includeDeleted?: boolean;
1150+ scopeType?: "scratch" | "account" | "workspace" | "organization";
1151+ workspaceId: Id<"workspaces">;
1152+ },
1153+ any
1154+ >;
1155+ openStorageInstance: FunctionReference<
1156+ "mutation",
1157+ "internal",
1158+ {
1159+ accountId?: Id<"accounts">;
1160+ durability?: "ephemeral" | "durable";
1161+ instanceId?: string;
1162+ purpose?: string;
1163+ scopeType?: "scratch" | "account" | "workspace" | "organization";
1164+ ttlHours?: number;
1165+ workspaceId: Id<"workspaces">;
1166+ },
1167+ any
1168+ >;
1169+ touchStorageInstance: FunctionReference<
1170+ "mutation",
1171+ "internal",
1172+ {
1173+ accountId?: Id<"accounts">;
1174+ fileCount?: number;
1175+ instanceId: string;
1176+ provider?: "agentfs-local" | "agentfs-cloudflare";
1177+ sizeBytes?: number;
1178+ status?: "active" | "closed" | "deleted";
1179+ workspaceId: Id<"workspaces">;
1180+ },
1181+ any
1182+ >;
1183+ };
10211184 task_events: {
10221185 createTaskEvent: FunctionReference<
10231186 "mutation",
@@ -1160,6 +1323,20 @@ export declare const internal: { · 136 unmodified lines
11601323 any
11611324 >;
11621325 };
1326+ touchStorageInstance: FunctionReference<
1327+ "mutation",
1328+ "internal",
1329+ {
1330+ accountId?: Id<"accounts">;
1331+ fileCount?: number;
1332+ instanceId: string;
1333+ provider?: "agentfs-local" | "agentfs-cloudflare";
1334+ sizeBytes?: number;
1335+ status?: "active" | "closed" | "deleted";
1336+ workspaceId: Id<"workspaces">;
1337+ },
1338+ any
1339+ >;
11631340 upsertCredential: FunctionReference<
11641341 "mutation",
11651342 "internal",
executor/packages/database/convex/database.ts+8−0
@@ -47,4 +47,12 @@ export { · 46 unmodified lines
4747 listToolSources,
4848 deleteToolSource,
4949 } from "./database/tool_sources";
50+export {
51+ openStorageInstance,
52+ getStorageInstance,
53+ listStorageInstances,
54+ closeStorageInstance,
55+ deleteStorageInstance,
56+ touchStorageInstance,
57+} from "./database/storage_instances";
5058 export { createTaskEvent, listTaskEvents } from "./database/task_events";
executor/packages/database/convex/database/storage_instances.ts+414−0
@@ -0,0 +1,414 @@
1+import { v } from "convex/values";
2+import { internalMutation, internalQuery } from "../_generated/server";
3+import type { Id } from "../_generated/dataModel";
4+import { mapStorageInstance } from "../../src/database/mappers";
5+import {
6+ storageDurabilityValidator,
7+ storageInstanceStatusValidator,
8+ storageScopeTypeValidator,
9+ storageProviderValidator,
10+} from "../../src/database/validators";
11+
12+const DEFAULT_EPHEMERAL_TTL_HOURS = 24;
13+const MAX_EPHEMERAL_TTL_HOURS = 24 * 30;
14+
15+function resolveStorageProvider(): "agentfs-local" | "agentfs-cloudflare" {
16+ const raw = (process.env.AGENT_STORAGE_PROVIDER ?? "").trim().toLowerCase();
17+ if (raw === "cloudflare" || raw === "agentfs-cloudflare") {
18+ return "agentfs-cloudflare";
19+ }
20+ return "agentfs-local";
21+}
22+
23+function clampEphemeralTtlHours(value?: number): number {
24+ if (typeof value !== "number" || !Number.isFinite(value)) {
25+ return DEFAULT_EPHEMERAL_TTL_HOURS;
26+ }
27+
28+ return Math.max(1, Math.min(MAX_EPHEMERAL_TTL_HOURS, Math.floor(value)));
29+}
30+
31+function canAccessInstance(args: {
32+ workspaceId: Id<"workspaces">;
33+ organizationId: Id<"organizations">;
34+ accountId?: Id<"accounts">;
35+ doc: {
36+ scopeType: "scratch" | "account" | "workspace" | "organization";
37+ organizationId: Id<"organizations">;
38+ workspaceId?: Id<"workspaces">;
39+ accountId?: Id<"accounts">;
40+ };
41+}): boolean {
42+ if (args.doc.organizationId !== args.organizationId) {
43+ return false;
44+ }
45+
46+ if (args.doc.scopeType === "workspace" || args.doc.scopeType === "scratch") {
47+ return args.doc.workspaceId === args.workspaceId;
48+ }
49+
50+ if (args.doc.scopeType === "account") {
51+ return Boolean(args.accountId && args.doc.accountId && args.accountId === args.doc.accountId);
52+ }
53+
54+ return true;
55+}
56+
57+function isExpired(doc: { expiresAt?: number }, now = Date.now()): boolean {
58+ return typeof doc.expiresAt === "number" && Number.isFinite(doc.expiresAt) && doc.expiresAt <= now;
59+}
60+
61+export const openStorageInstance = internalMutation({
62+ args: {
63+ workspaceId: v.id("workspaces"),
64+ accountId: v.optional(v.id("accounts")),
65+ instanceId: v.optional(v.string()),
66+ scopeType: v.optional(storageScopeTypeValidator),
67+ durability: v.optional(storageDurabilityValidator),
68+ purpose: v.optional(v.string()),
69+ ttlHours: v.optional(v.number()),
70+ },
71+ handler: async (ctx, args) => {
72+ const workspace = await ctx.db.get(args.workspaceId);
73+ if (!workspace) {
74+ throw new Error(`Workspace not found: ${args.workspaceId}`);
75+ }
76+
77+ const now = Date.now();
78+ const organizationId = workspace.organizationId;
79+ const requestedInstanceId = args.instanceId?.trim() ?? "";
80+ if (requestedInstanceId.length > 0) {
81+ const existing = await ctx.db
82+ .query("storageInstances")
83+ .withIndex("by_instance_id", (q) => q.eq("instanceId", requestedInstanceId))
84+ .unique();
85+ if (!existing) {
86+ throw new Error(`Storage instance not found: ${requestedInstanceId}`);
87+ }
88+
89+ if (!canAccessInstance({
90+ workspaceId: args.workspaceId,
91+ organizationId,
92+ accountId: args.accountId,
93+ doc: existing,
94+ })) {
95+ throw new Error("Storage instance is not accessible in this workspace context");
96+ }
97+
98+ if (existing.status === "deleted") {
99+ throw new Error(`Storage instance has been deleted: ${requestedInstanceId}`);
100+ }
101+
102+ if (isExpired(existing, now)) {
103+ await ctx.db.patch(existing._id, {
104+ status: "closed",
105+ closedAt: now,
106+ updatedAt: now,
107+ });
108+ throw new Error(`Storage instance has expired: ${requestedInstanceId}`);
109+ }
110+
111+ const nextStatus = existing.status === "closed" ? "active" : existing.status;
112+ await ctx.db.patch(existing._id, {
113+ status: nextStatus,
114+ updatedAt: now,
115+ lastSeenAt: now,
116+ });
117+
118+ const refreshed = await ctx.db
119+ .query("storageInstances")
120+ .withIndex("by_instance_id", (q) => q.eq("instanceId", requestedInstanceId))
121+ .unique();
122+ if (!refreshed) {
123+ throw new Error(`Failed to reopen storage instance: ${requestedInstanceId}`);
124+ }
125+
126+ return mapStorageInstance(refreshed);
127+ }
128+
129+ const scopeType = args.scopeType ?? "scratch";
130+ if (scopeType === "account" && !args.accountId) {
131+ throw new Error("accountId is required for account-scoped storage instances");
132+ }
133+
134+ const durability = args.durability
135+ ?? (scopeType === "scratch" ? "ephemeral" : "durable");
136+ const ttlHours = clampEphemeralTtlHours(args.ttlHours);
137+ const expiresAt = durability === "ephemeral"
138+ ? now + ttlHours * 60 * 60 * 1000
139+ : undefined;
140+ const instanceId = `inst_${crypto.randomUUID()}`;
141+ const provider = resolveStorageProvider();
142+ const backendKey = `${organizationId}:${instanceId}`;
143+ const purpose = args.purpose?.trim();
144+
145+ await ctx.db.insert("storageInstances", {
146+ instanceId,
147+ scopeType,
148+ durability,
149+ status: "active",
150+ provider,
151+ backendKey,
152+ organizationId,
153+ workspaceId: scopeType === "workspace" || scopeType === "scratch" ? args.workspaceId : undefined,
154+ accountId: scopeType === "account" || scopeType === "scratch" ? args.accountId : undefined,
155+ createdByAccountId: args.accountId,
156+ purpose: purpose && purpose.length > 0 ? purpose : undefined,
157+ createdAt: now,
158+ updatedAt: now,
159+ lastSeenAt: now,
160+ expiresAt,
161+ });
162+
163+ const created = await ctx.db
164+ .query("storageInstances")
165+ .withIndex("by_instance_id", (q) => q.eq("instanceId", instanceId))
166+ .unique();
167+ if (!created) {
168+ throw new Error("Failed to create storage instance");
169+ }
170+
171+ return mapStorageInstance(created);
172+ },
173+});
174+
175+export const getStorageInstance = internalQuery({
176+ args: {
177+ workspaceId: v.id("workspaces"),
178+ accountId: v.optional(v.id("accounts")),
179+ instanceId: v.string(),
180+ },
181+ handler: async (ctx, args) => {
182+ const workspace = await ctx.db.get(args.workspaceId);
183+ if (!workspace) {
184+ return null;
185+ }
186+
187+ const doc = await ctx.db
188+ .query("storageInstances")
189+ .withIndex("by_instance_id", (q) => q.eq("instanceId", args.instanceId))
190+ .unique();
191+ if (!doc) {
192+ return null;
193+ }
194+
195+ if (!canAccessInstance({
196+ workspaceId: args.workspaceId,
197+ organizationId: workspace.organizationId,
198+ accountId: args.accountId,
199+ doc,
200+ })) {
201+ return null;
202+ }
203+
204+ if (doc.status === "deleted" || isExpired(doc)) {
205+ return null;
206+ }
207+
208+ return mapStorageInstance(doc);
209+ },
210+});
211+
212+export const listStorageInstances = internalQuery({
213+ args: {
214+ workspaceId: v.id("workspaces"),
215+ accountId: v.optional(v.id("accounts")),
216+ scopeType: v.optional(storageScopeTypeValidator),
217+ includeDeleted: v.optional(v.boolean()),
218+ },
219+ handler: async (ctx, args) => {
220+ const workspace = await ctx.db.get(args.workspaceId);
221+ if (!workspace) {
222+ return [];
223+ }
224+
225+ const organizationId = workspace.organizationId;
226+ const [workspaceDocs, organizationDocs, accountDocs] = await Promise.all([
227+ ctx.db
228+ .query("storageInstances")
229+ .withIndex("by_workspace_updated", (q) => q.eq("workspaceId", args.workspaceId))
230+ .order("desc")
231+ .collect(),
232+ ctx.db
233+ .query("storageInstances")
234+ .withIndex("by_org_scope_updated", (q) => q.eq("organizationId", organizationId).eq("scopeType", "organization"))
235+ .order("desc")
236+ .collect(),
237+ args.accountId
238+ ? ctx.db
239+ .query("storageInstances")
240+ .withIndex("by_org_account_updated", (q) => q.eq("organizationId", organizationId).eq("accountId", args.accountId))
241+ .order("desc")
242+ .collect()
243+ : Promise.resolve([]),
244+ ]);
245+
246+ const deduped = [...workspaceDocs, ...organizationDocs, ...accountDocs]
247+ .filter((doc, index, docs) => docs.findIndex((candidate) => candidate.instanceId === doc.instanceId) === index)
248+ .filter((doc) => canAccessInstance({
249+ workspaceId: args.workspaceId,
250+ organizationId,
251+ accountId: args.accountId,
252+ doc,
253+ }))
254+ .filter((doc) => args.scopeType ? doc.scopeType === args.scopeType : true)
255+ .filter((doc) => args.includeDeleted ? true : (doc.status !== "deleted" && !isExpired(doc)))
256+ .sort((a, b) => b.updatedAt - a.updatedAt);
257+
258+ return deduped.map(mapStorageInstance);
259+ },
260+});
261+
262+export const closeStorageInstance = internalMutation({
263+ args: {
264+ workspaceId: v.id("workspaces"),
265+ accountId: v.optional(v.id("accounts")),
266+ instanceId: v.string(),
267+ },
268+ handler: async (ctx, args) => {
269+ const workspace = await ctx.db.get(args.workspaceId);
270+ if (!workspace) {
271+ return null;
272+ }
273+
274+ const doc = await ctx.db
275+ .query("storageInstances")
276+ .withIndex("by_instance_id", (q) => q.eq("instanceId", args.instanceId))
277+ .unique();
278+ if (!doc) {
279+ return null;
280+ }
281+
282+ if (!canAccessInstance({
283+ workspaceId: args.workspaceId,
284+ organizationId: workspace.organizationId,
285+ accountId: args.accountId,
286+ doc,
287+ })) {
288+ throw new Error("Storage instance is not accessible in this workspace context");
289+ }
290+
291+ const now = Date.now();
292+ await ctx.db.patch(doc._id, {
293+ status: "closed",
294+ closedAt: now,
295+ updatedAt: now,
296+ lastSeenAt: now,
297+ });
298+
299+ const refreshed = await ctx.db
300+ .query("storageInstances")
301+ .withIndex("by_instance_id", (q) => q.eq("instanceId", args.instanceId))
302+ .unique();
303+ return refreshed ? mapStorageInstance(refreshed) : null;
304+ },
305+});
306+
307+export const deleteStorageInstance = internalMutation({
308+ args: {
309+ workspaceId: v.id("workspaces"),
310+ accountId: v.optional(v.id("accounts")),
311+ instanceId: v.string(),
312+ },
313+ handler: async (ctx, args) => {
314+ const workspace = await ctx.db.get(args.workspaceId);
315+ if (!workspace) {
316+ return null;
317+ }
318+
319+ const doc = await ctx.db
320+ .query("storageInstances")
321+ .withIndex("by_instance_id", (q) => q.eq("instanceId", args.instanceId))
322+ .unique();
323+ if (!doc) {
324+ return null;
325+ }
326+
327+ if (!canAccessInstance({
328+ workspaceId: args.workspaceId,
329+ organizationId: workspace.organizationId,
330+ accountId: args.accountId,
331+ doc,
332+ })) {
333+ throw new Error("Storage instance is not accessible in this workspace context");
334+ }
335+
336+ if (
337+ (doc.scopeType === "organization" || doc.scopeType === "workspace")
338+ && doc.createdByAccountId
339+ && args.accountId
340+ && doc.createdByAccountId !== args.accountId
341+ ) {
342+ throw new Error("Only the instance creator can delete shared storage instances");
343+ }
344+
345+ const now = Date.now();
346+ await ctx.db.patch(doc._id, {
347+ status: "deleted",
348+ closedAt: now,
349+ updatedAt: now,
350+ lastSeenAt: now,
351+ });
352+
353+ const refreshed = await ctx.db
354+ .query("storageInstances")
355+ .withIndex("by_instance_id", (q) => q.eq("instanceId", args.instanceId))
356+ .unique();
357+ return refreshed ? mapStorageInstance(refreshed) : null;
358+ },
359+});
360+
361+export const touchStorageInstance = internalMutation({
362+ args: {
363+ workspaceId: v.id("workspaces"),
364+ accountId: v.optional(v.id("accounts")),
365+ instanceId: v.string(),
366+ status: v.optional(storageInstanceStatusValidator),
367+ sizeBytes: v.optional(v.number()),
368+ fileCount: v.optional(v.number()),
369+ provider: v.optional(storageProviderValidator),
370+ },
371+ handler: async (ctx, args) => {
372+ const workspace = await ctx.db.get(args.workspaceId);
373+ if (!workspace) {
374+ return null;
375+ }
376+
377+ const doc = await ctx.db
378+ .query("storageInstances")
379+ .withIndex("by_instance_id", (q) => q.eq("instanceId", args.instanceId))
380+ .unique();
381+ if (!doc) {
382+ return null;
383+ }
384+
385+ if (!canAccessInstance({
386+ workspaceId: args.workspaceId,
387+ organizationId: workspace.organizationId,
388+ accountId: args.accountId,
389+ doc,
390+ })) {
391+ return null;
392+ }
393+
394+ if (isExpired(doc)) {
395+ return null;
396+ }
397+
398+ const now = Date.now();
399+ await ctx.db.patch(doc._id, {
400+ updatedAt: now,
401+ lastSeenAt: now,
402+ ...(args.status ? { status: args.status } : {}),
403+ ...(typeof args.sizeBytes === "number" ? { sizeBytes: args.sizeBytes } : {}),
404+ ...(typeof args.fileCount === "number" ? { fileCount: args.fileCount } : {}),
405+ ...(args.provider ? { provider: args.provider } : {}),
406+ });
407+
408+ const refreshed = await ctx.db
409+ .query("storageInstances")
410+ .withIndex("by_instance_id", (q) => q.eq("instanceId", args.instanceId))
411+ .unique();
412+ return refreshed ? mapStorageInstance(refreshed) : null;
413+ },
414+});
executor/packages/database/convex/schema.ts+33−0
@@ -66,6 +66,10 @@ const toolRoleSelectorType = v.union( · 65 unmodified lines
6666 const toolRoleBindingStatus = v.union(v.literal("active"), v.literal("disabled"));
6767 const toolSourceScopeType = v.union(v.literal("organization"), v.literal("workspace"));
6868 const credentialScopeType = v.union(v.literal("account"), v.literal("organization"), v.literal("workspace"));
69+const storageScopeType = v.union(v.literal("scratch"), v.literal("account"), v.literal("workspace"), v.literal("organization"));
70+const storageDurability = v.union(v.literal("ephemeral"), v.literal("durable"));
71+const storageInstanceStatus = v.union(v.literal("active"), v.literal("closed"), v.literal("deleted"));
72+const storageProvider = v.union(v.literal("agentfs-local"), v.literal("agentfs-cloudflare"));
6973 const credentialProvider = v.union(
7074 v.literal("local-convex"),
7175 v.literal("workos-vault"),
@@ -565,6 +569,35 @@ export default defineSchema({ · 493 unmodified lines
565569 })
566570 .index("by_workspace_build", ["workspaceId", "buildId"]),
567571
572+ // Durable and ephemeral storage instances used by filesystem/kv/sql tools.
573+ // Rows are scope-aware and can be shared across account/workspace/org contexts.
574+ storageInstances: defineTable({
575+ instanceId: v.string(),
576+ scopeType: storageScopeType,
577+ durability: storageDurability,
578+ status: storageInstanceStatus,
579+ provider: storageProvider,
580+ backendKey: v.string(),
581+ organizationId: v.id("organizations"),
582+ workspaceId: v.optional(v.id("workspaces")),
583+ accountId: v.optional(v.id("accounts")),
584+ createdByAccountId: v.optional(v.id("accounts")),
585+ purpose: v.optional(v.string()),
586+ sizeBytes: v.optional(v.number()),
587+ fileCount: v.optional(v.number()),
588+ createdAt: v.number(),
589+ updatedAt: v.number(),
590+ lastSeenAt: v.number(),
591+ closedAt: v.optional(v.number()),
592+ expiresAt: v.optional(v.number()),
593+ })
594+ .index("by_instance_id", ["instanceId"])
595+ .index("by_workspace_updated", ["workspaceId", "updatedAt"])
596+ .index("by_workspace_status_updated", ["workspaceId", "status", "updatedAt"])
597+ .index("by_org_scope_updated", ["organizationId", "scopeType", "updatedAt"])
598+ .index("by_org_account_updated", ["organizationId", "accountId", "updatedAt"])
599+ .index("by_org_expires", ["organizationId", "expiresAt"]),
600+
568601 // Anonymous session linkage.
569602 // Used to map an unauthenticated/anonymous account to a backing `accounts` row and a
570603 // workspace.
executor/packages/database/convex/workspace.ts+71−0
@@ -10,6 +10,8 @@ import { · 9 unmodified lines
1010 policyEffectValidator,
1111 policyMatchTypeValidator,
1212 policyScopeTypeValidator,
13+ storageDurabilityValidator,
14+ storageScopeTypeValidator,
1315 toolRoleBindingStatusValidator,
1416 toolRoleSelectorTypeValidator,
1517 toolSourceScopeTypeValidator,
@@ -402,6 +404,75 @@ export const listToolSources = workspaceQuery({ · 386 unmodified lines
402404 },
403405 });
404406
407+export const openStorageInstance = workspaceMutation({
408+ method: "POST",
409+ args: {
410+ instanceId: v.optional(v.string()),
411+ scopeType: v.optional(storageScopeTypeValidator),
412+ durability: v.optional(storageDurabilityValidator),
413+ purpose: v.optional(v.string()),
414+ ttlHours: v.optional(v.number()),
415+ },
416+ handler: async (ctx, args) => {
417+ const scopeType = args.scopeType ?? "scratch";
418+ if (scopeType === "organization" || scopeType === "workspace") {
419+ if (!isAdminRole(ctx.organizationMembership.role)) {
420+ throw new Error("Only workspace admins can open workspace or organization storage instances");
421+ }
422+ }
423+
424+ return await ctx.runMutation(internal.database.openStorageInstance, {
425+ workspaceId: ctx.workspaceId,
426+ accountId: ctx.account._id,
427+ ...args,
428+ });
429+ },
430+});
431+
432+export const listStorageInstances = workspaceQuery({
433+ method: "GET",
434+ args: {
435+ scopeType: v.optional(storageScopeTypeValidator),
436+ includeDeleted: v.optional(v.boolean()),
437+ },
438+ handler: async (ctx, args) => {
439+ return await ctx.runQuery(internal.database.listStorageInstances, {
440+ workspaceId: ctx.workspaceId,
441+ accountId: ctx.account._id,
442+ ...args,
443+ });
444+ },
445+});
446+
447+export const closeStorageInstance = workspaceMutation({
448+ method: "POST",
449+ args: {
450+ instanceId: v.string(),
451+ },
452+ handler: async (ctx, args) => {
453+ return await ctx.runMutation(internal.database.closeStorageInstance, {
454+ workspaceId: ctx.workspaceId,
455+ accountId: ctx.account._id,
456+ instanceId: args.instanceId,
457+ });
458+ },
459+});
460+
461+export const deleteStorageInstance = workspaceMutation({
462+ method: "POST",
463+ requireAdmin: true,
464+ args: {
465+ instanceId: v.string(),
466+ },
467+ handler: async (ctx, args) => {
468+ return await ctx.runMutation(internal.database.deleteStorageInstance, {
469+ workspaceId: ctx.workspaceId,
470+ accountId: ctx.account._id,
471+ instanceId: args.instanceId,
472+ });
473+ },
474+});
475+
405476 export const getToolInventoryProgress = workspaceQuery({
406477 method: "GET",
407478 args: {},
executor/packages/database/package.json+4−0
@@ -7,6 +7,10 @@ · 6 unmodified lines
77 "typecheck:slow": "bunx tsc --noEmit -p tsconfig.json",
88 "clean": "rm -rf node_modules .turbo"
99 },
10+ "dependencies": {
11+ "convex": "^1.31.7",
12+ "sql.js": "^1.14.0"
13+ },
1014 "main": "./convex/_generated/api.js",
1115 "types": "./convex/_generated/api.d.ts"
1216 }
executor/packages/database/src/database/mappers.ts+23−0
@@ -137,3 +137,26 @@ export function mapSource(doc: Doc<"toolSources">) { · 136 unmodified lines
137137 updatedAt: doc.updatedAt,
138138 };
139139 }
140+
141+export function mapStorageInstance(doc: Doc<"storageInstances">) {
142+ return {
143+ id: doc.instanceId,
144+ scopeType: doc.scopeType,
145+ durability: doc.durability,
146+ status: doc.status,
147+ provider: doc.provider,
148+ backendKey: doc.backendKey,
149+ organizationId: doc.organizationId,
150+ workspaceId: doc.workspaceId,
151+ accountId: doc.accountId,
152+ createdByAccountId: doc.createdByAccountId,
153+ purpose: doc.purpose,
154+ sizeBytes: doc.sizeBytes,
155+ fileCount: doc.fileCount,
156+ createdAt: doc.createdAt,
157+ updatedAt: doc.updatedAt,
158+ lastSeenAt: doc.lastSeenAt,
159+ closedAt: doc.closedAt,
160+ expiresAt: doc.expiresAt,
161+ };
162+}
executor/packages/database/src/database/validators.ts+13−0
@@ -49,3 +49,16 @@ export const credentialProviderValidator = v.union( · 48 unmodified lines
4949 );
5050
5151 export const toolSourceTypeValidator = v.union(v.literal("mcp"), v.literal("openapi"), v.literal("graphql"));
52+
53+export const storageScopeTypeValidator = v.union(
54+ v.literal("scratch"),
55+ v.literal("account"),
56+ v.literal("workspace"),
57+ v.literal("organization"),
58+);
59+
60+export const storageDurabilityValidator = v.union(v.literal("ephemeral"), v.literal("durable"));
61+
62+export const storageInstanceStatusValidator = v.union(v.literal("active"), v.literal("closed"), v.literal("deleted"));
63+
64+export const storageProviderValidator = v.union(v.literal("agentfs-local"), v.literal("agentfs-cloudflare"));
executor/packages/database/src/runtime/base_tools.ts+202−30
@@ -7,50 +7,222 @@ import { · 6 unmodified lines
77 discoverInputJsonSchema,
88 discoverOutputJsonSchema,
99 } from "./discovery_tool_contracts";
10+import {
11+ fsMkdirInputJsonSchema,
12+ fsMkdirOutputJsonSchema,
13+ fsReadInputJsonSchema,
14+ fsReadOutputJsonSchema,
15+ fsReaddirInputJsonSchema,
16+ fsReaddirOutputJsonSchema,
17+ fsRemoveInputJsonSchema,
18+ fsRemoveOutputJsonSchema,
19+ fsStatInputJsonSchema,
20+ fsStatOutputJsonSchema,
21+ fsWriteInputJsonSchema,
22+ fsWriteOutputJsonSchema,
23+ kvDeleteInputJsonSchema,
24+ kvDeleteOutputJsonSchema,
25+ kvGetInputJsonSchema,
26+ kvGetOutputJsonSchema,
27+ kvListInputJsonSchema,
28+ kvListOutputJsonSchema,
29+ kvSetInputJsonSchema,
30+ kvSetOutputJsonSchema,
31+ sqliteQueryInputJsonSchema,
32+ sqliteQueryOutputJsonSchema,
33+ storageCloseInputJsonSchema,
34+ storageCloseOutputJsonSchema,
35+ storageDeleteInputJsonSchema,
36+ storageDeleteOutputJsonSchema,
37+ storageListInputJsonSchema,
38+ storageListOutputJsonSchema,
39+ storageOpenInputJsonSchema,
40+ storageOpenOutputJsonSchema,
41+} from "./storage_tool_contracts";
1042
1143 export const baseTools = new Map<string, ToolDefinition>();
1244
45+function registerSystemTool(
46+ path: string,
47+ description: string,
48+ typing: {
49+ inputSchema?: Record<string, unknown>;
50+ outputSchema?: Record<string, unknown>;
51+ },
52+ approval: "auto" | "required" = "auto",
53+) {
54+ baseTools.set(path, {
55+ path,
56+ source: "system",
57+ approval,
58+ description,
59+ typing,
60+ run: async () => {
61+ throw new Error(`${path} is handled by the server tool invocation pipeline`);
62+ },
63+ });
64+}
65+
1366 // Built-in system tools are resolved server-side.
1467 // Their execution is handled in the Convex tool invocation pipeline.
15−baseTools.set("discover", {
16− path: "discover",
17− source: "system",
18− approval: "auto",
19− description:
20− "Search available tools by keyword. Returns compact input/output hints by default; set includeSchemas=true for exact JSON Schemas.",
21− typing: {
68+registerSystemTool(
69+ "discover",
70+ "Search available tools by keyword. Returns compact input/output hints by default; set includeSchemas=true for exact JSON Schemas.",
71+ {
2272 inputSchema: discoverInputJsonSchema,
2373 outputSchema: discoverOutputJsonSchema,
2474 },
25− run: async () => {
26− throw new Error("discover is handled by the server tool invocation pipeline");
27− },
28−});
75+);
2976
30−baseTools.set("catalog.namespaces", {
31− path: "catalog.namespaces",
32− source: "system",
33− approval: "auto",
34− description: "List available tool namespaces with counts and sample callable paths.",
35− typing: {
77+registerSystemTool(
78+ "catalog.namespaces",
79+ "List available tool namespaces with counts and sample callable paths.",
80+ {
3681 inputSchema: catalogNamespacesInputJsonSchema,
3782 outputSchema: catalogNamespacesOutputJsonSchema,
3883 },
39− run: async () => {
40− throw new Error("catalog.namespaces is handled by the server tool invocation pipeline");
41− },
42−});
84+);
4385
44−baseTools.set("catalog.tools", {
45− path: "catalog.tools",
46− source: "system",
47− approval: "auto",
48− description: "List tools with compact hints by default. Supports namespace/query filters and optional includeSchemas for exact JSON Schemas.",
49− typing: {
86+registerSystemTool(
87+ "catalog.tools",
88+ "List tools with compact hints by default. Supports namespace/query filters and optional includeSchemas for exact JSON Schemas.",
89+ {
5090 inputSchema: catalogToolsInputJsonSchema,
5191 outputSchema: catalogToolsOutputJsonSchema,
5292 },
53− run: async () => {
54− throw new Error("catalog.tools is handled by the server tool invocation pipeline");
93+);
94+
95+registerSystemTool(
96+ "storage.open",
97+ "Open an existing storage instance or create a new one when instanceId is omitted.",
98+ {
99+ inputSchema: storageOpenInputJsonSchema,
100+ outputSchema: storageOpenOutputJsonSchema,
101+ },
102+);
103+
104+registerSystemTool(
105+ "storage.list",
106+ "List accessible storage instances for the current workspace context.",
107+ {
108+ inputSchema: storageListInputJsonSchema,
109+ outputSchema: storageListOutputJsonSchema,
110+ },
111+);
112+
113+registerSystemTool(
114+ "storage.close",
115+ "Mark a storage instance as closed without deleting its contents.",
116+ {
117+ inputSchema: storageCloseInputJsonSchema,
118+ outputSchema: storageCloseOutputJsonSchema,
119+ },
120+);
121+
122+registerSystemTool(
123+ "storage.delete",
124+ "Delete a storage instance and its backing data.",
125+ {
126+ inputSchema: storageDeleteInputJsonSchema,
127+ outputSchema: storageDeleteOutputJsonSchema,
128+ },
129+);
130+
131+registerSystemTool(
132+ "fs.read",
133+ "Read a file from a storage instance.",
134+ {
135+ inputSchema: fsReadInputJsonSchema,
136+ outputSchema: fsReadOutputJsonSchema,
137+ },
138+);
139+
140+registerSystemTool(
141+ "fs.write",
142+ "Write file contents into a storage instance.",
143+ {
144+ inputSchema: fsWriteInputJsonSchema,
145+ outputSchema: fsWriteOutputJsonSchema,
146+ },
147+);
148+
149+registerSystemTool(
150+ "fs.readdir",
151+ "List directory entries in a storage instance.",
152+ {
153+ inputSchema: fsReaddirInputJsonSchema,
154+ outputSchema: fsReaddirOutputJsonSchema,
155+ },
156+);
157+
158+registerSystemTool(
159+ "fs.stat",
160+ "Get metadata for a filesystem path in storage.",
161+ {
162+ inputSchema: fsStatInputJsonSchema,
163+ outputSchema: fsStatOutputJsonSchema,
164+ },
165+);
166+
167+registerSystemTool(
168+ "fs.mkdir",
169+ "Create a directory in a storage instance.",
170+ {
171+ inputSchema: fsMkdirInputJsonSchema,
172+ outputSchema: fsMkdirOutputJsonSchema,
173+ },
174+);
175+
176+registerSystemTool(
177+ "fs.remove",
178+ "Remove a file or directory from a storage instance.",
179+ {
180+ inputSchema: fsRemoveInputJsonSchema,
181+ outputSchema: fsRemoveOutputJsonSchema,
182+ },
183+);
184+
185+registerSystemTool(
186+ "kv.get",
187+ "Read a key-value entry from a storage instance.",
188+ {
189+ inputSchema: kvGetInputJsonSchema,
190+ outputSchema: kvGetOutputJsonSchema,
191+ },
192+);
193+
194+registerSystemTool(
195+ "kv.set",
196+ "Write a key-value entry into a storage instance.",
197+ {
198+ inputSchema: kvSetInputJsonSchema,
199+ outputSchema: kvSetOutputJsonSchema,
200+ },
201+);
202+
203+registerSystemTool(
204+ "kv.list",
205+ "List key-value entries by prefix from a storage instance.",
206+ {
207+ inputSchema: kvListInputJsonSchema,
208+ outputSchema: kvListOutputJsonSchema,
209+ },
210+);
211+
212+registerSystemTool(
213+ "kv.delete",
214+ "Delete a key-value entry from a storage instance.",
215+ {
216+ inputSchema: kvDeleteInputJsonSchema,
217+ outputSchema: kvDeleteOutputJsonSchema,
218+ },
219+);
220+
221+registerSystemTool(
222+ "sqlite.query",
223+ "Run SQL queries against the storage instance database.",
224+ {
225+ inputSchema: sqliteQueryInputJsonSchema,
226+ outputSchema: sqliteQueryOutputJsonSchema,
55227 },
56−});
228+);
executor/packages/database/src/runtime/storage_provider.ts+641−0
@@ -0,0 +1,641 @@
1+import {
2+ lstat,
3+ mkdir,
4+ readFile,
5+ readdir,
6+ rm,
7+ stat,
8+ writeFile,
9+} from "node:fs/promises";
10+import { dirname, join, normalize, resolve, sep } from "node:path";
11+import type { StorageInstanceRecord, StorageProvider as StorageProviderId } from "../../../core/src/types";
12+
13+export type StorageEncoding = "utf8" | "base64";
14+
15+export interface StorageDirectoryEntry {
16+ name: string;
17+ type: "file" | "directory" | "symlink" | "unknown";
18+ size?: number;
19+ mtime?: number;
20+}
21+
22+export interface StorageStatResult {
23+ exists: boolean;
24+ type?: "file" | "directory" | "symlink" | "unknown";
25+ size?: number;
26+ mode?: number;
27+ mtime?: number;
28+ ctime?: number;
29+}
30+
31+export interface StorageUsage {
32+ sizeBytes?: number;
33+ fileCount?: number;
34+}
35+
36+export interface StorageSqlResult {
37+ mode: "read" | "write";
38+ rows?: Record<string, unknown>[];
39+ rowCount: number;
40+ changes?: number;
41+}
42+
43+export interface StorageProvider {
44+ readFile(instance: StorageInstanceRecord, path: string, encoding: StorageEncoding): Promise<{ content: string; bytes: number }>;
45+ writeFile(
46+ instance: StorageInstanceRecord,
47+ path: string,
48+ content: string,
49+ encoding: StorageEncoding,
50+ ): Promise<{ bytesWritten: number }>;
51+ readdir(instance: StorageInstanceRecord, path: string): Promise<StorageDirectoryEntry[]>;
52+ stat(instance: StorageInstanceRecord, path: string): Promise<StorageStatResult>;
53+ mkdir(instance: StorageInstanceRecord, path: string): Promise<void>;
54+ remove(instance: StorageInstanceRecord, path: string, options: { recursive?: boolean; force?: boolean }): Promise<void>;
55+ kvGet(instance: StorageInstanceRecord, key: string): Promise<unknown>;
56+ kvSet(instance: StorageInstanceRecord, key: string, value: unknown): Promise<void>;
57+ kvList(instance: StorageInstanceRecord, prefix: string, limit: number): Promise<Array<{ key: string; value: unknown }>>;
58+ kvDelete(instance: StorageInstanceRecord, key: string): Promise<void>;
59+ sqliteQuery(
60+ instance: StorageInstanceRecord,
61+ args: { sql: string; params: Array<string | number | boolean | null>; mode: "read" | "write"; maxRows: number },
62+ ): Promise<StorageSqlResult>;
63+ usage(instance: StorageInstanceRecord): Promise<StorageUsage>;
64+ deleteInstance(instance: StorageInstanceRecord): Promise<void>;
65+}
66+
67+type SqlJsDatabase = {
68+ run: (sql: string, params?: Array<string | number | null>) => void;
69+ exec: (sql: string, params?: Array<string | number | null>) => Array<{
70+ columns: string[];
71+ values: Array<Array<string | number | null>>;
72+ }>;
73+ export: () => Uint8Array;
74+ getRowsModified: () => number;
75+ close: () => void;
76+};
77+
78+type SqlJsModule = {
79+ Database: new (data?: Uint8Array) => SqlJsDatabase;
80+};
81+
82+let sqlJsModulePromise: Promise<SqlJsModule> | null = null;
83+
84+async function loadSqlJsModule(): Promise<SqlJsModule> {
85+ if (!sqlJsModulePromise) {
86+ sqlJsModulePromise = (async () => {
87+ const module = await import("sql.js/dist/sql-asm.js") as { default?: (options?: unknown) => Promise<SqlJsModule> };
88+ const initialize = module.default;
89+ if (typeof initialize !== "function") {
90+ throw new Error("Failed to load sql.js asm module");
91+ }
92+
93+ return await initialize();
94+ })();
95+ }
96+
97+ return await sqlJsModulePromise;
98+}
99+
100+function normalizeBackendKey(backendKey: string): string {
101+ const trimmed = backendKey.trim();
102+ const fallback = `instance_${crypto.randomUUID()}`;
103+ const safe = (trimmed.length > 0 ? trimmed : fallback).replace(/[^a-zA-Z0-9._-]+/g, "_");
104+ return safe.length > 0 ? safe : fallback;
105+}
106+
107+function normalizeStoragePath(baseDir: string, backendKey: string): string {
108+ const key = normalizeBackendKey(backendKey);
109+ return join(baseDir, key);
110+}
111+
112+function normalizeRelativeFsPath(path: string): string {
113+ const candidate = (path ?? "").trim();
114+ const withoutLeadingSlash = candidate.replace(/^\/+/, "");
115+ const normalized = normalize(withoutLeadingSlash);
116+ if (!normalized || normalized === ".") {
117+ return "";
118+ }
119+
120+ if (normalized === ".." || normalized.startsWith(`..${sep}`)) {
121+ throw new Error(`Path escapes storage root: ${path}`);
122+ }
123+
124+ return normalized;
125+}
126+
127+function toDbValue(value: string | number | boolean | null): string | number | null {
128+ if (typeof value === "boolean") {
129+ return value ? 1 : 0;
130+ }
131+ return value;
132+}
133+
134+function rowsFromSqlResult(result: Array<{ columns: string[]; values: Array<Array<string | number | null>> }>): Record<string, unknown>[] {
135+ const first = result[0];
136+ if (!first) {
137+ return [];
138+ }
139+
140+ return first.values.map((row) => {
141+ const entry: Record<string, unknown> = {};
142+ for (let index = 0; index < first.columns.length; index += 1) {
143+ entry[first.columns[index]] = row[index];
144+ }
145+ return entry;
146+ });
147+}
148+
149+function getErrorCode(error: unknown): string | undefined {
150+ if (!error || typeof error !== "object") return undefined;
151+ const value = (error as { code?: unknown }).code;
152+ return typeof value === "string" ? value : undefined;
153+}
154+
155+function isNotFoundError(error: unknown): boolean {
156+ const code = getErrorCode(error);
157+ return code === "ENOENT" || code === "NOT_FOUND";
158+}
159+
160+function isReadOnlySql(sql: string): boolean {
161+ const trimmed = sql.trim().toLowerCase();
162+ return trimmed.startsWith("select")
163+ || trimmed.startsWith("pragma")
164+ || trimmed.startsWith("explain")
165+ || trimmed.startsWith("with");
166+}
167+
168+class AgentFsLocalStorageProvider implements StorageProvider {
169+ private readonly baseDir: string;
170+
171+ constructor(baseDir: string) {
172+ this.baseDir = baseDir;
173+ }
174+
175+ private getInstancePaths(instance: StorageInstanceRecord) {
176+ const rootPath = normalizeStoragePath(this.baseDir, instance.backendKey || instance.id);
177+ const fsRoot = join(rootPath, "fs");
178+ const sqlitePath = join(rootPath, "storage.sqlite");
179+
180+ return {
181+ rootPath,
182+ fsRoot,
183+ sqlitePath,
184+ };
185+ }
186+
187+ private resolveFsPath(instance: StorageInstanceRecord, path: string): string {
188+ const { fsRoot } = this.getInstancePaths(instance);
189+ const relative = normalizeRelativeFsPath(path);
190+ const fullPath = resolve(fsRoot, relative);
191+ const resolvedRoot = resolve(fsRoot);
192+ if (fullPath !== resolvedRoot && !fullPath.startsWith(`${resolvedRoot}${sep}`)) {
193+ throw new Error(`Path escapes storage root: ${path}`);
194+ }
195+
196+ return fullPath;
197+ }
198+
199+ private async withDatabase<T>(
200+ instance: StorageInstanceRecord,
201+ writeMode: boolean,
202+ callback: (db: SqlJsDatabase) => Promise<T> | T,
203+ ): Promise<T> {
204+ const { rootPath, sqlitePath } = this.getInstancePaths(instance);
205+ await mkdir(rootPath, { recursive: true });
206+
207+ const SQL = await loadSqlJsModule();
208+ let initialBytes: Uint8Array | undefined;
209+ try {
210+ const existing = await readFile(sqlitePath);
211+ initialBytes = new Uint8Array(existing);
212+ } catch (error) {
213+ if (!isNotFoundError(error)) {
214+ throw error;
215+ }
216+ }
217+
218+ const db = new SQL.Database(initialBytes);
219+ try {
220+ const result = await callback(db);
221+ if (writeMode) {
222+ const serialized = db.export();
223+ await writeFile(sqlitePath, Buffer.from(serialized));
224+ }
225+ return result;
226+ } finally {
227+ db.close();
228+ }
229+ }
230+
231+ private async ensureKvTable(db: SqlJsDatabase) {
232+ db.run(`
233+ CREATE TABLE IF NOT EXISTS kv_store (
234+ key TEXT PRIMARY KEY,
235+ value TEXT NOT NULL,
236+ updated_at INTEGER NOT NULL
237+ )
238+ `);
239+ }
240+
241+ async readFile(instance: StorageInstanceRecord, path: string, encoding: StorageEncoding): Promise<{ content: string; bytes: number }> {
242+ const fullPath = this.resolveFsPath(instance, path);
243+ const file = await readFile(fullPath);
244+ if (encoding === "base64") {
245+ return {
246+ content: file.toString("base64"),
247+ bytes: file.length,
248+ };
249+ }
250+
251+ const text = file.toString("utf8");
252+ return {
253+ content: text,
254+ bytes: Buffer.byteLength(text, "utf8"),
255+ };
256+ }
257+
258+ async writeFile(
259+ instance: StorageInstanceRecord,
260+ path: string,
261+ content: string,
262+ encoding: StorageEncoding,
263+ ): Promise<{ bytesWritten: number }> {
264+ const fullPath = this.resolveFsPath(instance, path);
265+ await mkdir(dirname(fullPath), { recursive: true });
266+ const payload = encoding === "base64"
267+ ? Buffer.from(content, "base64")
268+ : Buffer.from(content, "utf8");
269+ await writeFile(fullPath, payload);
270+ return {
271+ bytesWritten: payload.length,
272+ };
273+ }
274+
275+ async readdir(instance: StorageInstanceRecord, path: string): Promise<StorageDirectoryEntry[]> {
276+ const fullPath = this.resolveFsPath(instance, path);
277+ const entries = await readdir(fullPath, { withFileTypes: true });
278+ return entries.map((entry) => ({
279+ name: entry.name,
280+ type: entry.isDirectory()
281+ ? "directory"
282+ : entry.isFile()
283+ ? "file"
284+ : entry.isSymbolicLink()
285+ ? "symlink"
286+ : "unknown",
287+ }));
288+ }
289+
290+ async stat(instance: StorageInstanceRecord, path: string): Promise<StorageStatResult> {
291+ const fullPath = this.resolveFsPath(instance, path);
292+ try {
293+ const stats = await lstat(fullPath);
294+ return {
295+ exists: true,
296+ type: stats.isDirectory()
297+ ? "directory"
298+ : stats.isFile()
299+ ? "file"
300+ : stats.isSymbolicLink()
301+ ? "symlink"
302+ : "unknown",
303+ size: stats.size,
304+ mode: stats.mode,
305+ mtime: stats.mtimeMs,
306+ ctime: stats.ctimeMs,
307+ };
308+ } catch (error) {
309+ if (isNotFoundError(error)) {
310+ return { exists: false };
311+ }
312+ throw error;
313+ }
314+ }
315+
316+ async mkdir(instance: StorageInstanceRecord, path: string): Promise<void> {
317+ const fullPath = this.resolveFsPath(instance, path);
318+ await mkdir(fullPath, { recursive: true });
319+ }
320+
321+ async remove(instance: StorageInstanceRecord, path: string, options: { recursive?: boolean; force?: boolean }): Promise<void> {
322+ const fullPath = this.resolveFsPath(instance, path);
323+ await rm(fullPath, {
324+ recursive: options.recursive ?? false,
325+ force: options.force ?? false,
326+ });
327+ }
328+
329+ async kvGet(instance: StorageInstanceRecord, key: string): Promise<unknown> {
330+ return await this.withDatabase(instance, false, async (db) => {
331+ await this.ensureKvTable(db);
332+ const rows = rowsFromSqlResult(db.exec("SELECT value FROM kv_store WHERE key = ?", [key]));
333+ const raw = rows[0]?.value;
334+ if (typeof raw !== "string") {
335+ return undefined;
336+ }
337+ return JSON.parse(raw) as unknown;
338+ });
339+ }
340+
341+ async kvSet(instance: StorageInstanceRecord, key: string, value: unknown): Promise<void> {
342+ await this.withDatabase(instance, true, async (db) => {
343+ await this.ensureKvTable(db);
344+ db.run(
345+ "INSERT INTO kv_store (key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at",
346+ [key, JSON.stringify(value), Math.floor(Date.now() / 1000)],
347+ );
348+ });
349+ }
350+
351+ async kvList(instance: StorageInstanceRecord, prefix: string, limit: number): Promise<Array<{ key: string; value: unknown }>> {
352+ return await this.withDatabase(instance, false, async (db) => {
353+ await this.ensureKvTable(db);
354+ const rows = rowsFromSqlResult(
355+ db.exec(
356+ "SELECT key, value FROM kv_store WHERE key LIKE ? ESCAPE '\\\\' ORDER BY key LIMIT ?",
357+ [`${prefix.replaceAll("\\", "\\\\").replaceAll("%", "\\%").replaceAll("_", "\\_")}%`, limit],
358+ ),
359+ );
360+ return rows.map((row) => ({
361+ key: String(row.key ?? ""),
362+ value: typeof row.value === "string" ? JSON.parse(row.value) : undefined,
363+ }));
364+ });
365+ }
366+
367+ async kvDelete(instance: StorageInstanceRecord, key: string): Promise<void> {
368+ await this.withDatabase(instance, true, async (db) => {
369+ await this.ensureKvTable(db);
370+ db.run("DELETE FROM kv_store WHERE key = ?", [key]);
371+ });
372+ }
373+
374+ async sqliteQuery(
375+ instance: StorageInstanceRecord,
376+ args: { sql: string; params: Array<string | number | boolean | null>; mode: "read" | "write"; maxRows: number },
377+ ): Promise<StorageSqlResult> {
378+ if (args.mode === "read" && !isReadOnlySql(args.sql)) {
379+ throw new Error("sqlite.query rejected a non-read SQL statement in read mode");
380+ }
381+
382+ const normalizedParams = args.params.map(toDbValue);
383+ if (args.mode === "write") {
384+ return await this.withDatabase(instance, true, async (db) => {
385+ db.run(args.sql, normalizedParams);
386+ const changes = Number(db.getRowsModified());
387+ return {
388+ mode: "write",
389+ rowCount: 0,
390+ changes: Number.isFinite(changes) ? changes : 0,
391+ };
392+ });
393+ }
394+
395+ return await this.withDatabase(instance, false, async (db) => {
396+ const rows = rowsFromSqlResult(db.exec(args.sql, normalizedParams));
397+ const boundedRows = rows.slice(0, Math.max(1, args.maxRows));
398+ return {
399+ mode: "read",
400+ rows: boundedRows,
401+ rowCount: boundedRows.length,
402+ };
403+ });
404+ }
405+
406+ async usage(instance: StorageInstanceRecord): Promise<StorageUsage> {
407+ const { rootPath } = this.getInstancePaths(instance);
408+
409+ let sizeBytes = 0;
410+ let fileCount = 0;
411+
412+ const walk = async (currentPath: string): Promise<void> => {
413+ const entries = await readdir(currentPath, { withFileTypes: true });
414+ await Promise.all(entries.map(async (entry) => {
415+ const absolute = join(currentPath, entry.name);
416+ if (entry.isDirectory()) {
417+ await walk(absolute);
418+ return;
419+ }
420+
421+ if (entry.isFile()) {
422+ const fileStats = await stat(absolute);
423+ sizeBytes += fileStats.size;
424+ fileCount += 1;
425+ }
426+ }));
427+ };
428+
429+ try {
430+ await walk(rootPath);
431+ } catch (error) {
432+ if (!isNotFoundError(error)) {
433+ throw error;
434+ }
435+ }
436+
437+ return {
438+ sizeBytes,
439+ fileCount,
440+ };
441+ }
442+
443+ async deleteInstance(instance: StorageInstanceRecord): Promise<void> {
444+ const { rootPath } = this.getInstancePaths(instance);
445+ await rm(rootPath, { recursive: true, force: true });
446+ }
447+}
448+
449+class CloudflareRemoteStorageProvider implements StorageProvider {
450+ private readonly endpoint: string;
451+ private readonly token: string;
452+
453+ constructor(endpoint: string, token: string) {
454+ this.endpoint = endpoint.replace(/\/$/, "");
455+ this.token = token;
456+ }
457+
458+ private async request<T>(instance: StorageInstanceRecord, operation: string, payload: Record<string, unknown>): Promise<T> {
459+ const response = await fetch(`${this.endpoint}/v1/storage`, {
460+ method: "POST",
461+ headers: {
462+ "content-type": "application/json",
463+ authorization: `Bearer ${this.token}`,
464+ },
465+ body: JSON.stringify({
466+ instanceId: instance.id,
467+ operation,
468+ payload,
469+ }),
470+ });
471+
472+ const body = await response.json().catch(() => null) as { ok?: boolean; data?: T; error?: string } | null;
473+ if (!response.ok || !body?.ok) {
474+ const message = body?.error ?? `Cloudflare storage request failed with status ${response.status}`;
475+ throw new Error(message);
476+ }
477+
478+ return body.data as T;
479+ }
480+
481+ async readFile(instance: StorageInstanceRecord, path: string, encoding: StorageEncoding): Promise<{ content: string; bytes: number }> {
482+ return await this.request(instance, "fs.read", { path, encoding });
483+ }
484+
485+ async writeFile(
486+ instance: StorageInstanceRecord,
487+ path: string,
488+ content: string,
489+ encoding: StorageEncoding,
490+ ): Promise<{ bytesWritten: number }> {
491+ return await this.request(instance, "fs.write", { path, content, encoding });
492+ }
493+
494+ async readdir(instance: StorageInstanceRecord, path: string): Promise<StorageDirectoryEntry[]> {
495+ const response = await this.request<{ entries: StorageDirectoryEntry[] }>(instance, "fs.readdir", { path });
496+ return response.entries;
497+ }
498+
499+ async stat(instance: StorageInstanceRecord, path: string): Promise<StorageStatResult> {
500+ return await this.request(instance, "fs.stat", { path });
501+ }
502+
503+ async mkdir(instance: StorageInstanceRecord, path: string): Promise<void> {
504+ await this.request(instance, "fs.mkdir", { path });
505+ }
506+
507+ async remove(instance: StorageInstanceRecord, path: string, options: { recursive?: boolean; force?: boolean }): Promise<void> {
508+ await this.request(instance, "fs.remove", {
509+ path,
510+ recursive: options.recursive ?? false,
511+ force: options.force ?? false,
512+ });
513+ }
514+
515+ async kvGet(instance: StorageInstanceRecord, key: string): Promise<unknown> {
516+ const response = await this.request<{ value?: unknown }>(instance, "kv.get", { key });
517+ return response.value;
518+ }
519+
520+ async kvSet(instance: StorageInstanceRecord, key: string, value: unknown): Promise<void> {
521+ await this.request(instance, "kv.set", { key, value });
522+ }
523+
524+ async kvList(instance: StorageInstanceRecord, prefix: string, limit: number): Promise<Array<{ key: string; value: unknown }>> {
525+ const response = await this.request<{ items: Array<{ key: string; value: unknown }> }>(instance, "kv.list", {
526+ prefix,
527+ limit,
528+ });
529+ return response.items;
530+ }
531+
532+ async kvDelete(instance: StorageInstanceRecord, key: string): Promise<void> {
533+ await this.request(instance, "kv.delete", { key });
534+ }
535+
536+ async sqliteQuery(
537+ instance: StorageInstanceRecord,
538+ args: { sql: string; params: Array<string | number | boolean | null>; mode: "read" | "write"; maxRows: number },
539+ ): Promise<StorageSqlResult> {
540+ return await this.request(instance, "sqlite.query", args);
541+ }
542+
543+ async usage(instance: StorageInstanceRecord): Promise<StorageUsage> {
544+ return await this.request(instance, "instance.usage", {});
545+ }
546+
547+ async deleteInstance(instance: StorageInstanceRecord): Promise<void> {
548+ await this.request(instance, "instance.delete", {});
549+ }
550+}
551+
552+let localProviderSingleton: AgentFsLocalStorageProvider | null = null;
553+let cloudflareProviderSingleton: CloudflareRemoteStorageProvider | null = null;
554+let cloudflareStorageProbeStarted = false;
555+
556+function startCloudflareStorageProbe(endpoint: string, token: string) {
557+ if (cloudflareStorageProbeStarted) {
558+ return;
559+ }
560+ cloudflareStorageProbeStarted = true;
561+
562+ void (async () => {
563+ const controller = new AbortController();
564+ const timeout = setTimeout(() => controller.abort(), 2500);
565+ try {
566+ const response = await fetch(`${endpoint}/v1/storage`, {
567+ method: "POST",
568+ headers: {
569+ "content-type": "application/json",
570+ authorization: `Bearer ${token}`,
571+ },
572+ body: "{}",
573+ signal: controller.signal,
574+ });
575+
576+ if (response.status === 404) {
577+ console.warn(
578+ `[storage] Cloudflare storage endpoint ${endpoint}/v1/storage is unreachable (404). Ensure the runner-sandbox-host is deployed with the storage route.`,
579+ );
580+ } else if (response.status === 401 || response.status === 403) {
581+ console.warn(
582+ "[storage] Cloudflare storage endpoint rejected auth. Verify CLOUDFLARE_SANDBOX_AUTH_TOKEN matches runner-sandbox-host AUTH_TOKEN.",
583+ );
584+ }
585+ } catch (error) {
586+ const message = error instanceof Error ? error.message : String(error);
587+ console.warn(
588+ `[storage] Cloudflare storage endpoint ${endpoint}/v1/storage is unreachable: ${message}`,
589+ );
590+ } finally {
591+ clearTimeout(timeout);
592+ }
593+ })();
594+}
595+
596+function getLocalProvider(): AgentFsLocalStorageProvider {
597+ if (!localProviderSingleton) {
598+ const baseDir = (process.env.AGENT_STORAGE_ROOT ?? "").trim() || "/tmp/executor-agentfs";
599+ localProviderSingleton = new AgentFsLocalStorageProvider(baseDir);
600+ }
601+
602+ return localProviderSingleton;
603+}
604+
605+function getCloudflareProvider(): CloudflareRemoteStorageProvider {
606+ if (!cloudflareProviderSingleton) {
607+ const sandboxRunUrl = (process.env.CLOUDFLARE_SANDBOX_RUN_URL ?? "").trim();
608+ const token = (process.env.CLOUDFLARE_SANDBOX_AUTH_TOKEN ?? "").trim();
609+ if (!sandboxRunUrl || !token) {
610+ throw new Error(
611+ "Cloudflare storage provider requires CLOUDFLARE_SANDBOX_RUN_URL and CLOUDFLARE_SANDBOX_AUTH_TOKEN",
612+ );
613+ }
614+
615+ let endpoint = "";
616+ try {
617+ const url = new URL(sandboxRunUrl);
618+ url.pathname = "/";
619+ url.search = "";
620+ url.hash = "";
621+ endpoint = url.toString().replace(/\/$/, "");
622+ } catch {
623+ endpoint = sandboxRunUrl.endsWith("/v1/runs")
624+ ? sandboxRunUrl.slice(0, -"/v1/runs".length)
625+ : sandboxRunUrl.replace(/\/$/, "");
626+ }
627+
628+ cloudflareProviderSingleton = new CloudflareRemoteStorageProvider(endpoint, token);
629+ startCloudflareStorageProbe(endpoint, token);
630+ }
631+
632+ return cloudflareProviderSingleton;
633+}
634+
635+export function getStorageProvider(provider: StorageProviderId): StorageProvider {
636+ if (provider === "agentfs-cloudflare") {
637+ return getCloudflareProvider();
638+ }
639+
640+ return getLocalProvider();
641+}
executor/packages/database/src/runtime/storage_tool_contracts.ts+540−0
@@ -0,0 +1,540 @@
1+import { z } from "zod";
2+
3+type JsonSchema = Record<string, unknown>;
4+
5+function toJsonSchema(schema: z.ZodTypeAny, fallback: JsonSchema): JsonSchema {
6+ const maybeToJsonSchema = (z as unknown as { toJSONSchema?: (value: z.ZodTypeAny) => unknown }).toJSONSchema;
7+ if (typeof maybeToJsonSchema === "function") {
8+ return maybeToJsonSchema(schema) as JsonSchema;
9+ }
10+
11+ return fallback;
12+}
13+
14+export const storageScopeSchema = z.enum(["scratch", "account", "workspace", "organization"]);
15+export const storageDurabilitySchema = z.enum(["ephemeral", "durable"]);
16+export const storageStatusSchema = z.enum(["active", "closed", "deleted"]);
17+export const storageProviderSchema = z.enum(["agentfs-local", "agentfs-cloudflare"]);
18+
19+export const storageInstanceSchema = z.object({
20+ id: z.string(),
21+ scopeType: storageScopeSchema,
22+ durability: storageDurabilitySchema,
23+ status: storageStatusSchema,
24+ provider: storageProviderSchema,
25+ backendKey: z.string(),
26+ organizationId: z.string(),
27+ workspaceId: z.string().optional(),
28+ accountId: z.string().optional(),
29+ createdByAccountId: z.string().optional(),
30+ purpose: z.string().optional(),
31+ sizeBytes: z.number().optional(),
32+ fileCount: z.number().optional(),
33+ createdAt: z.number(),
34+ updatedAt: z.number(),
35+ lastSeenAt: z.number(),
36+ closedAt: z.number().optional(),
37+ expiresAt: z.number().optional(),
38+});
39+
40+export const storageOpenInputSchema = z.object({
41+ instanceId: z.string().optional(),
42+ scopeType: storageScopeSchema.optional(),
43+ durability: storageDurabilitySchema.optional(),
44+ purpose: z.string().optional(),
45+ ttlHours: z.coerce.number().optional(),
46+});
47+
48+export const storageOpenOutputSchema = z.object({
49+ instance: storageInstanceSchema,
50+});
51+
52+export const storageListInputSchema = z.object({
53+ scopeType: storageScopeSchema.optional(),
54+ includeDeleted: z.boolean().optional(),
55+});
56+
57+export const storageListOutputSchema = z.object({
58+ instances: z.array(storageInstanceSchema),
59+ total: z.number(),
60+});
61+
62+export const storageCloseInputSchema = z.object({
63+ instanceId: z.string(),
64+});
65+
66+export const storageCloseOutputSchema = z.object({
67+ instance: storageInstanceSchema.nullable(),
68+});
69+
70+export const storageDeleteInputSchema = z.object({
71+ instanceId: z.string(),
72+});
73+
74+export const storageDeleteOutputSchema = z.object({
75+ instance: storageInstanceSchema.nullable(),
76+});
77+
78+const fsAccessSchema = z.object({
79+ instanceId: z.string().optional(),
80+ scopeType: storageScopeSchema.optional(),
81+});
82+
83+export const fsReadInputSchema = fsAccessSchema.extend({
84+ path: z.string(),
85+ encoding: z.enum(["utf8", "base64"]).optional(),
86+});
87+
88+export const fsReadOutputSchema = z.object({
89+ instanceId: z.string(),
90+ path: z.string(),
91+ encoding: z.enum(["utf8", "base64"]),
92+ content: z.string(),
93+ bytes: z.number(),
94+});
95+
96+export const fsWriteInputSchema = fsAccessSchema.extend({
97+ path: z.string(),
98+ content: z.string(),
99+ encoding: z.enum(["utf8", "base64"]).optional(),
100+});
101+
102+export const fsWriteOutputSchema = z.object({
103+ instanceId: z.string(),
104+ path: z.string(),
105+ bytesWritten: z.number(),
106+});
107+
108+export const fsReaddirInputSchema = fsAccessSchema.extend({
109+ path: z.string().optional(),
110+});
111+
112+export const fsReaddirOutputSchema = z.object({
113+ instanceId: z.string(),
114+ path: z.string(),
115+ entries: z.array(z.object({
116+ name: z.string(),
117+ type: z.enum(["file", "directory", "symlink", "unknown"]),
118+ size: z.number().optional(),
119+ mtime: z.number().optional(),
120+ })),
121+});
122+
123+export const fsStatInputSchema = fsAccessSchema.extend({
124+ path: z.string(),
125+});
126+
127+export const fsStatOutputSchema = z.object({
128+ instanceId: z.string(),
129+ path: z.string(),
130+ exists: z.boolean(),
131+ type: z.enum(["file", "directory", "symlink", "unknown"]).optional(),
132+ size: z.number().optional(),
133+ mode: z.number().optional(),
134+ mtime: z.number().optional(),
135+ ctime: z.number().optional(),
136+});
137+
138+export const fsMkdirInputSchema = fsAccessSchema.extend({
139+ path: z.string(),
140+});
141+
142+export const fsMkdirOutputSchema = z.object({
143+ instanceId: z.string(),
144+ path: z.string(),
145+ ok: z.boolean(),
146+});
147+
148+export const fsRemoveInputSchema = fsAccessSchema.extend({
149+ path: z.string(),
150+ recursive: z.boolean().optional(),
151+ force: z.boolean().optional(),
152+});
153+
154+export const fsRemoveOutputSchema = z.object({
155+ instanceId: z.string(),
156+ path: z.string(),
157+ ok: z.boolean(),
158+});
159+
160+export const kvGetInputSchema = fsAccessSchema.extend({
161+ key: z.string(),
162+});
163+
164+export const kvGetOutputSchema = z.object({
165+ instanceId: z.string(),
166+ key: z.string(),
167+ found: z.boolean(),
168+ value: z.unknown().optional(),
169+});
170+
171+export const kvSetInputSchema = fsAccessSchema.extend({
172+ key: z.string(),
173+ value: z.unknown(),
174+});
175+
176+export const kvSetOutputSchema = z.object({
177+ instanceId: z.string(),
178+ key: z.string(),
179+ ok: z.boolean(),
180+});
181+
182+export const kvListInputSchema = fsAccessSchema.extend({
183+ prefix: z.string().optional(),
184+ limit: z.coerce.number().optional(),
185+});
186+
187+export const kvListOutputSchema = z.object({
188+ instanceId: z.string(),
189+ items: z.array(z.object({ key: z.string(), value: z.unknown() })),
190+ total: z.number(),
191+});
192+
193+export const kvDeleteInputSchema = fsAccessSchema.extend({
194+ key: z.string(),
195+});
196+
197+export const kvDeleteOutputSchema = z.object({
198+ instanceId: z.string(),
199+ key: z.string(),
200+ ok: z.boolean(),
201+});
202+
203+export const sqliteQueryInputSchema = fsAccessSchema.extend({
204+ sql: z.string(),
205+ params: z.array(z.union([z.string(), z.number(), z.boolean(), z.null()])).optional(),
206+ mode: z.enum(["read", "write"]).optional(),
207+ maxRows: z.coerce.number().optional(),
208+});
209+
210+export const sqliteQueryOutputSchema = z.object({
211+ instanceId: z.string(),
212+ mode: z.enum(["read", "write"]),
213+ rows: z.array(z.record(z.unknown())).optional(),
214+ rowCount: z.number(),
215+ changes: z.number().optional(),
216+});
217+
218+export const storageOpenInputJsonSchema = toJsonSchema(storageOpenInputSchema, {
219+ type: "object",
220+ properties: {
221+ instanceId: { type: "string" },
222+ scopeType: { type: "string", enum: ["scratch", "account", "workspace", "organization"] },
223+ durability: { type: "string", enum: ["ephemeral", "durable"] },
224+ purpose: { type: "string" },
225+ ttlHours: { type: "number" },
226+ },
227+ additionalProperties: false,
228+});
229+
230+export const storageOpenOutputJsonSchema = toJsonSchema(storageOpenOutputSchema, {
231+ type: "object",
232+ properties: {
233+ instance: { type: "object" },
234+ },
235+ required: ["instance"],
236+ additionalProperties: false,
237+});
238+
239+export const storageListInputJsonSchema = toJsonSchema(storageListInputSchema, {
240+ type: "object",
241+ properties: {
242+ scopeType: { type: "string", enum: ["scratch", "account", "workspace", "organization"] },
243+ includeDeleted: { type: "boolean" },
244+ },
245+ additionalProperties: false,
246+});
247+
248+export const storageListOutputJsonSchema = toJsonSchema(storageListOutputSchema, {
249+ type: "object",
250+ properties: {
251+ instances: { type: "array", items: { type: "object" } },
252+ total: { type: "number" },
253+ },
254+ required: ["instances", "total"],
255+ additionalProperties: false,
256+});
257+
258+export const storageCloseInputJsonSchema = toJsonSchema(storageCloseInputSchema, {
259+ type: "object",
260+ properties: {
261+ instanceId: { type: "string" },
262+ },
263+ required: ["instanceId"],
264+ additionalProperties: false,
265+});
266+
267+export const storageCloseOutputJsonSchema = toJsonSchema(storageCloseOutputSchema, {
268+ type: "object",
269+ properties: {
270+ instance: {
271+ oneOf: [{ type: "object" }, { type: "null" }],
272+ },
273+ },
274+ required: ["instance"],
275+ additionalProperties: false,
276+});
277+
278+export const storageDeleteInputJsonSchema = storageCloseInputJsonSchema;
279+export const storageDeleteOutputJsonSchema = storageCloseOutputJsonSchema;
280+
281+function fsAccessJsonProperties() {
282+ return {
283+ instanceId: { type: "string" },
284+ scopeType: { type: "string", enum: ["scratch", "account", "workspace", "organization"] },
285+ };
286+}
287+
288+export const fsReadInputJsonSchema = toJsonSchema(fsReadInputSchema, {
289+ type: "object",
290+ properties: {
291+ ...fsAccessJsonProperties(),
292+ path: { type: "string" },
293+ encoding: { type: "string", enum: ["utf8", "base64"] },
294+ },
295+ required: ["path"],
296+ additionalProperties: false,
297+});
298+
299+export const fsReadOutputJsonSchema = toJsonSchema(fsReadOutputSchema, {
300+ type: "object",
301+ properties: {
302+ instanceId: { type: "string" },
303+ path: { type: "string" },
304+ encoding: { type: "string", enum: ["utf8", "base64"] },
305+ content: { type: "string" },
306+ bytes: { type: "number" },
307+ },
308+ required: ["instanceId", "path", "encoding", "content", "bytes"],
309+ additionalProperties: false,
310+});
311+
312+export const fsWriteInputJsonSchema = toJsonSchema(fsWriteInputSchema, {
313+ type: "object",
314+ properties: {
315+ ...fsAccessJsonProperties(),
316+ path: { type: "string" },
317+ content: { type: "string" },
318+ encoding: { type: "string", enum: ["utf8", "base64"] },
319+ },
320+ required: ["path", "content"],
321+ additionalProperties: false,
322+});
323+
324+export const fsWriteOutputJsonSchema = toJsonSchema(fsWriteOutputSchema, {
325+ type: "object",
326+ properties: {
327+ instanceId: { type: "string" },
328+ path: { type: "string" },
329+ bytesWritten: { type: "number" },
330+ },
331+ required: ["instanceId", "path", "bytesWritten"],
332+ additionalProperties: false,
333+});
334+
335+export const fsReaddirInputJsonSchema = toJsonSchema(fsReaddirInputSchema, {
336+ type: "object",
337+ properties: {
338+ ...fsAccessJsonProperties(),
339+ path: { type: "string" },
340+ },
341+ additionalProperties: false,
342+});
343+
344+export const fsReaddirOutputJsonSchema = toJsonSchema(fsReaddirOutputSchema, {
345+ type: "object",
346+ properties: {
347+ instanceId: { type: "string" },
348+ path: { type: "string" },
349+ entries: { type: "array", items: { type: "object" } },
350+ },
351+ required: ["instanceId", "path", "entries"],
352+ additionalProperties: false,
353+});
354+
355+export const fsStatInputJsonSchema = toJsonSchema(fsStatInputSchema, {
356+ type: "object",
357+ properties: {
358+ ...fsAccessJsonProperties(),
359+ path: { type: "string" },
360+ },
361+ required: ["path"],
362+ additionalProperties: false,
363+});
364+
365+export const fsStatOutputJsonSchema = toJsonSchema(fsStatOutputSchema, {
366+ type: "object",
367+ properties: {
368+ instanceId: { type: "string" },
369+ path: { type: "string" },
370+ exists: { type: "boolean" },
371+ type: { type: "string", enum: ["file", "directory", "symlink", "unknown"] },
372+ size: { type: "number" },
373+ mode: { type: "number" },
374+ mtime: { type: "number" },
375+ ctime: { type: "number" },
376+ },
377+ required: ["instanceId", "path", "exists"],
378+ additionalProperties: false,
379+});
380+
381+export const fsMkdirInputJsonSchema = toJsonSchema(fsMkdirInputSchema, {
382+ type: "object",
383+ properties: {
384+ ...fsAccessJsonProperties(),
385+ path: { type: "string" },
386+ },
387+ required: ["path"],
388+ additionalProperties: false,
389+});
390+
391+export const fsMkdirOutputJsonSchema = toJsonSchema(fsMkdirOutputSchema, {
392+ type: "object",
393+ properties: {
394+ instanceId: { type: "string" },
395+ path: { type: "string" },
396+ ok: { type: "boolean" },
397+ },
398+ required: ["instanceId", "path", "ok"],
399+ additionalProperties: false,
400+});
401+
402+export const fsRemoveInputJsonSchema = toJsonSchema(fsRemoveInputSchema, {
403+ type: "object",
404+ properties: {
405+ ...fsAccessJsonProperties(),
406+ path: { type: "string" },
407+ recursive: { type: "boolean" },
408+ force: { type: "boolean" },
409+ },
410+ required: ["path"],
411+ additionalProperties: false,
412+});
413+
414+export const fsRemoveOutputJsonSchema = toJsonSchema(fsRemoveOutputSchema, {
415+ type: "object",
416+ properties: {
417+ instanceId: { type: "string" },
418+ path: { type: "string" },
419+ ok: { type: "boolean" },
420+ },
421+ required: ["instanceId", "path", "ok"],
422+ additionalProperties: false,
423+});
424+
425+export const kvGetInputJsonSchema = toJsonSchema(kvGetInputSchema, {
426+ type: "object",
427+ properties: {
428+ ...fsAccessJsonProperties(),
429+ key: { type: "string" },
430+ },
431+ required: ["key"],
432+ additionalProperties: false,
433+});
434+
435+export const kvGetOutputJsonSchema = toJsonSchema(kvGetOutputSchema, {
436+ type: "object",
437+ properties: {
438+ instanceId: { type: "string" },
439+ key: { type: "string" },
440+ found: { type: "boolean" },
441+ value: {},
442+ },
443+ required: ["instanceId", "key", "found"],
444+ additionalProperties: false,
445+});
446+
447+export const kvSetInputJsonSchema = toJsonSchema(kvSetInputSchema, {
448+ type: "object",
449+ properties: {
450+ ...fsAccessJsonProperties(),
451+ key: { type: "string" },
452+ value: {},
453+ },
454+ required: ["key", "value"],
455+ additionalProperties: false,
456+});
457+
458+export const kvSetOutputJsonSchema = toJsonSchema(kvSetOutputSchema, {
459+ type: "object",
460+ properties: {
461+ instanceId: { type: "string" },
462+ key: { type: "string" },
463+ ok: { type: "boolean" },
464+ },
465+ required: ["instanceId", "key", "ok"],
466+ additionalProperties: false,
467+});
468+
469+export const kvListInputJsonSchema = toJsonSchema(kvListInputSchema, {
470+ type: "object",
471+ properties: {
472+ ...fsAccessJsonProperties(),
473+ prefix: { type: "string" },
474+ limit: { type: "number" },
475+ },
476+ additionalProperties: false,
477+});
478+
479+export const kvListOutputJsonSchema = toJsonSchema(kvListOutputSchema, {
480+ type: "object",
481+ properties: {
482+ instanceId: { type: "string" },
483+ items: { type: "array", items: { type: "object" } },
484+ total: { type: "number" },
485+ },
486+ required: ["instanceId", "items", "total"],
487+ additionalProperties: false,
488+});
489+
490+export const kvDeleteInputJsonSchema = toJsonSchema(kvDeleteInputSchema, {
491+ type: "object",
492+ properties: {
493+ ...fsAccessJsonProperties(),
494+ key: { type: "string" },
495+ },
496+ required: ["key"],
497+ additionalProperties: false,
498+});
499+
500+export const kvDeleteOutputJsonSchema = toJsonSchema(kvDeleteOutputSchema, {
501+ type: "object",
502+ properties: {
503+ instanceId: { type: "string" },
504+ key: { type: "string" },
505+ ok: { type: "boolean" },
506+ },
507+ required: ["instanceId", "key", "ok"],
508+ additionalProperties: false,
509+});
510+
511+export const sqliteQueryInputJsonSchema = toJsonSchema(sqliteQueryInputSchema, {
512+ type: "object",
513+ properties: {
514+ ...fsAccessJsonProperties(),
515+ sql: { type: "string" },
516+ params: {
517+ type: "array",
518+ items: {
519+ oneOf: [{ type: "string" }, { type: "number" }, { type: "boolean" }, { type: "null" }],
520+ },
521+ },
522+ mode: { type: "string", enum: ["read", "write"] },
523+ maxRows: { type: "number" },
524+ },
525+ required: ["sql"],
526+ additionalProperties: false,
527+});
528+
529+export const sqliteQueryOutputJsonSchema = toJsonSchema(sqliteQueryOutputSchema, {
530+ type: "object",
531+ properties: {
532+ instanceId: { type: "string" },
533+ mode: { type: "string", enum: ["read", "write"] },
534+ rows: { type: "array", items: { type: "object" } },
535+ rowCount: { type: "number" },
536+ changes: { type: "number" },
537+ },
538+ required: ["instanceId", "mode", "rowCount"],
539+ additionalProperties: false,
540+});
executor/packages/database/src/runtime/storage_tools.ts+387−0
@@ -0,0 +1,387 @@
1+import type { ActionCtx } from "../../convex/_generated/server";
2+import { internal } from "../../convex/_generated/api";
3+import type { TaskRecord, StorageInstanceRecord } from "../../../core/src/types";
4+import { getStorageProvider, type StorageEncoding, type StorageProvider } from "./storage_provider";
5+import {
6+ fsMkdirInputSchema,
7+ fsMkdirOutputSchema,
8+ fsReadInputSchema,
9+ fsReadOutputSchema,
10+ fsReaddirInputSchema,
11+ fsReaddirOutputSchema,
12+ fsRemoveInputSchema,
13+ fsRemoveOutputSchema,
14+ fsStatInputSchema,
15+ fsStatOutputSchema,
16+ fsWriteInputSchema,
17+ fsWriteOutputSchema,
18+ kvDeleteInputSchema,
19+ kvDeleteOutputSchema,
20+ kvGetInputSchema,
21+ kvGetOutputSchema,
22+ kvListInputSchema,
23+ kvListOutputSchema,
24+ kvSetInputSchema,
25+ kvSetOutputSchema,
26+ sqliteQueryInputSchema,
27+ sqliteQueryOutputSchema,
28+ storageCloseInputSchema,
29+ storageCloseOutputSchema,
30+ storageDeleteInputSchema,
31+ storageDeleteOutputSchema,
32+ storageListInputSchema,
33+ storageListOutputSchema,
34+ storageOpenInputSchema,
35+ storageOpenOutputSchema,
36+} from "./storage_tool_contracts";
37+
38+const STORAGE_SYSTEM_TOOLS = new Set([
39+ "storage.open",
40+ "storage.list",
41+ "storage.close",
42+ "storage.delete",
43+ "fs.read",
44+ "fs.write",
45+ "fs.readdir",
46+ "fs.stat",
47+ "fs.mkdir",
48+ "fs.remove",
49+ "kv.get",
50+ "kv.set",
51+ "kv.list",
52+ "kv.delete",
53+ "sqlite.query",
54+]);
55+
56+function toInputRecord(input: unknown): Record<string, unknown> {
57+ if (!input || typeof input !== "object" || Array.isArray(input)) {
58+ return {};
59+ }
60+
61+ return input as Record<string, unknown>;
62+}
63+
64+function normalizeScopeType(value: unknown): undefined | "scratch" | "account" | "workspace" | "organization" {
65+ if (typeof value !== "string") return undefined;
66+ const normalized = value.trim().toLowerCase();
67+ if (normalized === "scratch" || normalized === "account" || normalized === "workspace" || normalized === "organization") {
68+ return normalized;
69+ }
70+ return undefined;
71+}
72+
73+function normalizeInstanceId(value: unknown): string | undefined {
74+ if (typeof value !== "string") return undefined;
75+ const normalized = value.trim();
76+ return normalized.length > 0 ? normalized : undefined;
77+}
78+
79+function normalizeInputPayload(input: unknown): Record<string, unknown> {
80+ const payload = toInputRecord(input);
81+ return {
82+ ...payload,
83+ ...(normalizeScopeType(payload.scopeType) ? { scopeType: normalizeScopeType(payload.scopeType) } : {}),
84+ ...(normalizeInstanceId(payload.instanceId) ? { instanceId: normalizeInstanceId(payload.instanceId) } : {}),
85+ };
86+}
87+
88+async function openStorageInstanceForTask(
89+ ctx: ActionCtx,
90+ task: TaskRecord,
91+ args: {
92+ instanceId?: string;
93+ scopeType?: "scratch" | "account" | "workspace" | "organization";
94+ durability?: "ephemeral" | "durable";
95+ purpose?: string;
96+ ttlHours?: number;
97+ },
98+): Promise<StorageInstanceRecord> {
99+ const opened = await ctx.runMutation(internal.database.openStorageInstance, {
100+ workspaceId: task.workspaceId,
101+ accountId: task.accountId,
102+ instanceId: args.instanceId,
103+ scopeType: args.scopeType,
104+ durability: args.durability,
105+ purpose: args.purpose,
106+ ttlHours: args.ttlHours,
107+ });
108+ return opened as StorageInstanceRecord;
109+}
110+
111+async function resolveStorageInstance(
112+ ctx: ActionCtx,
113+ task: TaskRecord,
114+ payload: Record<string, unknown>,
115+): Promise<StorageInstanceRecord> {
116+ const requestedInstanceId = normalizeInstanceId(payload.instanceId);
117+ const scopeType = normalizeScopeType(payload.scopeType);
118+
119+ if (requestedInstanceId) {
120+ const existing = await ctx.runQuery(internal.database.getStorageInstance, {
121+ workspaceId: task.workspaceId,
122+ accountId: task.accountId,
123+ instanceId: requestedInstanceId,
124+ });
125+ if (!existing) {
126+ throw new Error(`Storage instance not found: ${requestedInstanceId}`);
127+ }
128+
129+ const reopened = await openStorageInstanceForTask(ctx, task, {
130+ instanceId: requestedInstanceId,
131+ });
132+ return reopened;
133+ }
134+
135+ return await openStorageInstanceForTask(ctx, task, {
136+ scopeType,
137+ purpose: "auto",
138+ });
139+}
140+
141+async function touchInstance(
142+ ctx: ActionCtx,
143+ task: TaskRecord,
144+ instance: StorageInstanceRecord,
145+ provider: StorageProvider,
146+ withUsage: boolean,
147+) {
148+ const usage = withUsage ? await provider.usage(instance) : undefined;
149+ await ctx.runMutation(internal.database.touchStorageInstance, {
150+ workspaceId: task.workspaceId,
151+ accountId: task.accountId,
152+ instanceId: instance.id,
153+ provider: instance.provider,
154+ ...(usage?.sizeBytes !== undefined ? { sizeBytes: usage.sizeBytes } : {}),
155+ ...(usage?.fileCount !== undefined ? { fileCount: usage.fileCount } : {}),
156+ });
157+}
158+
159+export function isStorageSystemToolPath(path: string): boolean {
160+ return STORAGE_SYSTEM_TOOLS.has(path);
161+}
162+
163+export async function runStorageSystemTool(
164+ ctx: ActionCtx,
165+ task: TaskRecord,
166+ toolPath: string,
167+ input: unknown,
168+): Promise<unknown> {
169+ const payload = normalizeInputPayload(input);
170+
171+ if (toolPath === "storage.open") {
172+ const parsed = storageOpenInputSchema.parse(payload);
173+ const instance = await openStorageInstanceForTask(ctx, task, parsed);
174+ return storageOpenOutputSchema.parse({ instance });
175+ }
176+
177+ if (toolPath === "storage.list") {
178+ const parsed = storageListInputSchema.parse(payload);
179+ const instances = await ctx.runQuery(internal.database.listStorageInstances, {
180+ workspaceId: task.workspaceId,
181+ accountId: task.accountId,
182+ scopeType: parsed.scopeType,
183+ includeDeleted: parsed.includeDeleted,
184+ });
185+
186+ return storageListOutputSchema.parse({
187+ instances,
188+ total: instances.length,
189+ });
190+ }
191+
192+ if (toolPath === "storage.close") {
193+ const parsed = storageCloseInputSchema.parse(payload);
194+ const instance = await ctx.runMutation(internal.database.closeStorageInstance, {
195+ workspaceId: task.workspaceId,
196+ accountId: task.accountId,
197+ instanceId: parsed.instanceId,
198+ });
199+ return storageCloseOutputSchema.parse({ instance });
200+ }
201+
202+ if (toolPath === "storage.delete") {
203+ const parsed = storageDeleteInputSchema.parse(payload);
204+ const existing = await ctx.runQuery(internal.database.getStorageInstance, {
205+ workspaceId: task.workspaceId,
206+ accountId: task.accountId,
207+ instanceId: parsed.instanceId,
208+ }) as StorageInstanceRecord | null;
209+
210+ if (existing) {
211+ const provider = getStorageProvider(existing.provider);
212+ try {
213+ await provider.deleteInstance(existing);
214+ } catch {
215+ // Continue marking the instance deleted even if backend cleanup fails.
216+ }
217+ }
218+
219+ const instance = await ctx.runMutation(internal.database.deleteStorageInstance, {
220+ workspaceId: task.workspaceId,
221+ accountId: task.accountId,
222+ instanceId: parsed.instanceId,
223+ });
224+ return storageDeleteOutputSchema.parse({ instance });
225+ }
226+
227+ if (toolPath === "fs.read") {
228+ const parsed = fsReadInputSchema.parse(payload);
229+ const instance = await resolveStorageInstance(ctx, task, payload);
230+ const provider = getStorageProvider(instance.provider);
231+ const encoding = parsed.encoding ?? "utf8";
232+ const file = await provider.readFile(instance, parsed.path, encoding as StorageEncoding);
233+ await touchInstance(ctx, task, instance, provider, false);
234+ return fsReadOutputSchema.parse({
235+ instanceId: instance.id,
236+ path: parsed.path,
237+ encoding,
238+ content: file.content,
239+ bytes: file.bytes,
240+ });
241+ }
242+
243+ if (toolPath === "fs.write") {
244+ const parsed = fsWriteInputSchema.parse(payload);
245+ const instance = await resolveStorageInstance(ctx, task, payload);
246+ const provider = getStorageProvider(instance.provider);
247+ const encoding = parsed.encoding ?? "utf8";
248+ const result = await provider.writeFile(instance, parsed.path, parsed.content, encoding as StorageEncoding);
249+ await touchInstance(ctx, task, instance, provider, true);
250+ return fsWriteOutputSchema.parse({
251+ instanceId: instance.id,
252+ path: parsed.path,
253+ bytesWritten: result.bytesWritten,
254+ });
255+ }
256+
257+ if (toolPath === "fs.readdir") {
258+ const parsed = fsReaddirInputSchema.parse(payload);
259+ const instance = await resolveStorageInstance(ctx, task, payload);
260+ const provider = getStorageProvider(instance.provider);
261+ const path = parsed.path ?? "/";
262+ const entries = await provider.readdir(instance, path);
263+ await touchInstance(ctx, task, instance, provider, false);
264+ return fsReaddirOutputSchema.parse({
265+ instanceId: instance.id,
266+ path,
267+ entries,
268+ });
269+ }
270+
271+ if (toolPath === "fs.stat") {
272+ const parsed = fsStatInputSchema.parse(payload);
273+ const instance = await resolveStorageInstance(ctx, task, payload);
274+ const provider = getStorageProvider(instance.provider);
275+ const stat = await provider.stat(instance, parsed.path);
276+ await touchInstance(ctx, task, instance, provider, false);
277+ return fsStatOutputSchema.parse({
278+ instanceId: instance.id,
279+ path: parsed.path,
280+ ...stat,
281+ });
282+ }
283+
284+ if (toolPath === "fs.mkdir") {
285+ const parsed = fsMkdirInputSchema.parse(payload);
286+ const instance = await resolveStorageInstance(ctx, task, payload);
287+ const provider = getStorageProvider(instance.provider);
288+ await provider.mkdir(instance, parsed.path);
289+ await touchInstance(ctx, task, instance, provider, true);
290+ return fsMkdirOutputSchema.parse({
291+ instanceId: instance.id,
292+ path: parsed.path,
293+ ok: true,
294+ });
295+ }
296+
297+ if (toolPath === "fs.remove") {
298+ const parsed = fsRemoveInputSchema.parse(payload);
299+ const instance = await resolveStorageInstance(ctx, task, payload);
300+ const provider = getStorageProvider(instance.provider);
301+ await provider.remove(instance, parsed.path, {
302+ recursive: parsed.recursive,
303+ force: parsed.force,
304+ });
305+ await touchInstance(ctx, task, instance, provider, true);
306+ return fsRemoveOutputSchema.parse({
307+ instanceId: instance.id,
308+ path: parsed.path,
309+ ok: true,
310+ });
311+ }
312+
313+ if (toolPath === "kv.get") {
314+ const parsed = kvGetInputSchema.parse(payload);
315+ const instance = await resolveStorageInstance(ctx, task, payload);
316+ const provider = getStorageProvider(instance.provider);
317+ const value = await provider.kvGet(instance, parsed.key);
318+ await touchInstance(ctx, task, instance, provider, false);
319+ return kvGetOutputSchema.parse({
320+ instanceId: instance.id,
321+ key: parsed.key,
322+ found: value !== undefined,
323+ ...(value !== undefined ? { value } : {}),
324+ });
325+ }
326+
327+ if (toolPath === "kv.set") {
328+ const parsed = kvSetInputSchema.parse(payload);
329+ const instance = await resolveStorageInstance(ctx, task, payload);
330+ const provider = getStorageProvider(instance.provider);
331+ await provider.kvSet(instance, parsed.key, parsed.value);
332+ await touchInstance(ctx, task, instance, provider, true);
333+ return kvSetOutputSchema.parse({
334+ instanceId: instance.id,
335+ key: parsed.key,
336+ ok: true,
337+ });
338+ }
339+
340+ if (toolPath === "kv.list") {
341+ const parsed = kvListInputSchema.parse(payload);
342+ const instance = await resolveStorageInstance(ctx, task, payload);
343+ const provider = getStorageProvider(instance.provider);
344+ const limit = Math.max(1, Math.min(500, Math.floor(parsed.limit ?? 100)));
345+ const items = await provider.kvList(instance, parsed.prefix ?? "", limit);
346+ await touchInstance(ctx, task, instance, provider, false);
347+ return kvListOutputSchema.parse({
348+ instanceId: instance.id,
349+ items,
350+ total: items.length,
351+ });
352+ }
353+
354+ if (toolPath === "kv.delete") {
355+ const parsed = kvDeleteInputSchema.parse(payload);
356+ const instance = await resolveStorageInstance(ctx, task, payload);
357+ const provider = getStorageProvider(instance.provider);
358+ await provider.kvDelete(instance, parsed.key);
359+ await touchInstance(ctx, task, instance, provider, true);
360+ return kvDeleteOutputSchema.parse({
361+ instanceId: instance.id,
362+ key: parsed.key,
363+ ok: true,
364+ });
365+ }
366+
367+ if (toolPath === "sqlite.query") {
368+ const parsed = sqliteQueryInputSchema.parse(payload);
369+ const instance = await resolveStorageInstance(ctx, task, payload);
370+ const provider = getStorageProvider(instance.provider);
371+ const mode = parsed.mode ?? "read";
372+ const maxRows = Math.max(1, Math.min(1_000, Math.floor(parsed.maxRows ?? 200)));
373+ const result = await provider.sqliteQuery(instance, {
374+ sql: parsed.sql,
375+ params: parsed.params ?? [],
376+ mode,
377+ maxRows,
378+ });
379+ await touchInstance(ctx, task, instance, provider, mode === "write");
380+ return sqliteQueryOutputSchema.parse({
381+ instanceId: instance.id,
382+ ...result,
383+ });
384+ }
385+
386+ throw new Error(`Unsupported storage system tool: ${toolPath}`);
387+}
executor/packages/database/src/runtime/tool_invocation.ts+9−2
@@ -32,6 +32,7 @@ import { · 31 unmodified lines
3232 discoverInputSchema,
3333 discoverOutputSchema,
3434 } from "./discovery_tool_contracts";
35+import { isStorageSystemToolPath, runStorageSystemTool } from "./storage_tools";
3536
3637 const payloadRecordSchema = z.record(z.unknown());
3738
@@ -367,11 +368,12 @@ export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCal · 329 unmodified lines
367368 };
368369
369370 // Fast system tools are handled server-side from the registry.
370− if (toolPath === "discover" || toolPath === "catalog.namespaces" || toolPath === "catalog.tools") {
371+ if (toolPath === "discover" || toolPath === "catalog.namespaces" || toolPath === "catalog.tools" || isStorageSystemToolPath(toolPath)) {
372+ const baseSystemTool = baseTools.get(toolPath);
371373 const systemToolDecision = getDecisionForContext(
372374 {
373375 path: toolPath,
374− approval: "auto",
376+ approval: baseSystemTool?.approval ?? "auto",
375377 },
376378 {
377379 workspaceId: task.workspaceId,
@@ -391,6 +393,11 @@ export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCal · 13 unmodified lines
391393 });
392394 }
393395
396+ if (isStorageSystemToolPath(toolPath)) {
397+ const output = await runStorageSystemTool(ctx, task, toolPath, input);
398+ return await finalizeImmediateTool(output);
399+ }
400+
394401 const buildIdResult = await getReadyRegistryBuildIdResult(ctx, {
395402 workspaceId: task.workspaceId,
396403 accountId: task.accountId,
executor/packages/database/src/runtime/workspace_tools.ts+4−1
@@ -1399,7 +1399,10 @@ async function loadWorkspaceToolInventoryForContext( · 1398 unmodified lines
13991399 });
14001400 const toolsByPath = new Map<string, ToolDescriptor>();
14011401 for (const tool of baseDescriptors) toolsByPath.set(tool.path, tool);
1402− for (const tool of registryDescriptors) toolsByPath.set(tool.path, tool);
1402+ for (const tool of registryDescriptors) {
1403+ if (baseTools.has(tool.path)) continue;
1404+ toolsByPath.set(tool.path, tool);
1405+ }
14031406 const tools = [...toolsByPath.values()];
14041407
14051408 const sourceQuality = includeSourceMeta ? result.sourceQuality : {};
executor/packages/database/src/types/sqljs-asm.d.ts+23−0
@@ -0,0 +1,23 @@
1+declare module "sql.js/dist/sql-asm.js" {
2+ type SqlJsRow = Array<string | number | null>;
3+
4+ export interface SqlJsQueryResult {
5+ columns: string[];
6+ values: SqlJsRow[];
7+ }
8+
9+ export interface SqlJsDatabase {
10+ run(sql: string, params?: Array<string | number | null>): void;
11+ exec(sql: string, params?: Array<string | number | null>): SqlJsQueryResult[];
12+ export(): Uint8Array;
13+ getRowsModified(): number;
14+ close(): void;
15+ }
16+
17+ export interface SqlJsModule {
18+ Database: new (data?: Uint8Array) => SqlJsDatabase;
19+ }
20+
21+ const initialize: (options?: unknown) => Promise<SqlJsModule>;
22+ export default initialize;
23+}
executor/packages/runner-sandbox-host/package.json+1−0
@@ -17,6 +17,7 @@ · 16 unmodified lines
1717 },
1818 "dependencies": {
1919 "@executor/database": "workspace:*",
20+ "agentfs-sdk": "^0.6.0",
2021 "better-result": "^2.7.0",
2122 "convex": "^1.31.7"
2223 }
executor/packages/runner-sandbox-host/src/index.ts+380−2
@@ -34,7 +34,8 @@ · 33 unmodified lines
3434 */
3535
3636 import { Result } from "better-result";
37−import { WorkerEntrypoint } from "cloudflare:workers";
37+import { DurableObject, WorkerEntrypoint } from "cloudflare:workers";
38+import { AgentFS, type CloudflareStorage } from "agentfs-sdk/cloudflare";
3839 import { encodeToolCallResultForTransport } from "../../core/src/tool-call-result-transport";
3940 import GLOBALS_MODULE from "./isolate/globals.isolate.js";
4041 import HARNESS_CODE from "./isolate/harness.isolate.js";
@@ -42,13 +43,363 @@ import { authorizeRunRequest } from "./auth"; · 1 unmodified line
4243 import { callToolWithBridge, getBridgePropsFromContext } from "./bridge";
4344 import { parseRunRequest } from "./request";
4445 import { executeSandboxRun } from "./sandbox";
45−import type { Env, RunResult, ToolCallResult } from "./types";
46+import type { Env, RunResult, StorageProxyRequest, ToolCallResult } from "./types";
4647
4748 const failedResult = (error: string): RunResult => ({
4849 status: "failed",
4950 error,
5051 });
5152
53+function jsonResponse(body: unknown, status = 200): Response {
54+ return Response.json(body, { status });
55+}
56+
57+function toStorageError(error: unknown): string {
58+ return error instanceof Error ? error.message : String(error);
59+}
60+
61+function isReadOnlySql(sql: string): boolean {
62+ const trimmed = sql.trim().toLowerCase();
63+ return trimmed.startsWith("select")
64+ || trimmed.startsWith("pragma")
65+ || trimmed.startsWith("explain")
66+ || trimmed.startsWith("with");
67+}
68+
69+function isNotFoundError(error: unknown): boolean {
70+ if (!error || typeof error !== "object") {
71+ return false;
72+ }
73+ const code = (error as { code?: unknown }).code;
74+ return code === "ENOENT" || code === "NOT_FOUND";
75+}
76+
77+function bytesToBase64(bytes: Uint8Array): string {
78+ let binary = "";
79+ const chunk = 0x8000;
80+ for (let i = 0; i < bytes.length; i += chunk) {
81+ const slice = bytes.subarray(i, i + chunk);
82+ binary += String.fromCharCode(...slice);
83+ }
84+ return btoa(binary);
85+}
86+
87+function base64ToBytes(base64: string): Uint8Array {
88+ const binary = atob(base64);
89+ const bytes = new Uint8Array(binary.length);
90+ for (let i = 0; i < binary.length; i += 1) {
91+ bytes[i] = binary.charCodeAt(i);
92+ }
93+ return bytes;
94+}
95+
96+type StorageRequestPayload = {
97+ operation?: string;
98+ payload?: Record<string, unknown>;
99+};
100+
101+export class AgentFSStorageObject extends DurableObject<Env> {
102+ private readonly fs: AgentFS;
103+
104+ constructor(ctx: DurableObjectState, env: Env) {
105+ super(ctx, env);
106+ this.fs = AgentFS.create(ctx.storage as unknown as CloudflareStorage);
107+ this.ensureKvTable();
108+ }
109+
110+ private ensureKvTable() {
111+ this.ctx.storage.sql.exec(`
112+ CREATE TABLE IF NOT EXISTS kv_store (
113+ key TEXT PRIMARY KEY,
114+ value TEXT NOT NULL,
115+ created_at INTEGER DEFAULT (unixepoch()),
116+ updated_at INTEGER DEFAULT (unixepoch())
117+ )
118+ `);
119+ }
120+
121+ private parseRequestPayload(body: unknown): StorageRequestPayload {
122+ if (!body || typeof body !== "object" || Array.isArray(body)) {
123+ return {};
124+ }
125+ const record = body as Record<string, unknown>;
126+ return {
127+ operation: typeof record.operation === "string" ? record.operation : undefined,
128+ payload: record.payload && typeof record.payload === "object" && !Array.isArray(record.payload)
129+ ? record.payload as Record<string, unknown>
130+ : {},
131+ };
132+ }
133+
134+ private sqliteQuery(sql: string, params: unknown[], mode: "read" | "write", maxRows: number) {
135+ if (mode === "read" && !isReadOnlySql(sql)) {
136+ throw new Error("sqlite.query rejected a non-read SQL statement in read mode");
137+ }
138+
139+ const cursor = this.ctx.storage.sql.exec(sql, ...params);
140+ if (mode === "write") {
141+ return {
142+ mode,
143+ rowCount: 0,
144+ changes: cursor.rowsWritten,
145+ };
146+ }
147+
148+ const rows = cursor.toArray().slice(0, Math.max(1, Math.floor(maxRows))) as Record<string, unknown>[];
149+ return {
150+ mode,
151+ rows,
152+ rowCount: rows.length,
153+ };
154+ }
155+
156+ async fetch(request: Request): Promise<Response> {
157+ if (request.method !== "POST") {
158+ return jsonResponse({ ok: false, error: "Method not allowed" }, 405);
159+ }
160+
161+ const body = await request.json().catch(() => null);
162+ const parsed = this.parseRequestPayload(body);
163+ const operation = parsed.operation?.trim();
164+ const payload = parsed.payload ?? {};
165+
166+ if (!operation) {
167+ return jsonResponse({ ok: false, error: "Missing operation" }, 400);
168+ }
169+
170+ try {
171+ if (operation === "fs.read") {
172+ const path = typeof payload.path === "string" ? payload.path : "";
173+ if (!path) {
174+ return jsonResponse({ ok: false, error: "fs.read requires path" }, 400);
175+ }
176+ const encoding = payload.encoding === "base64" ? "base64" : "utf8";
177+ if (encoding === "base64") {
178+ const buffer = await this.fs.readFile(path);
179+ return jsonResponse({ ok: true, data: { content: bytesToBase64(buffer), bytes: buffer.length } });
180+ }
181+
182+ const content = await this.fs.readFile(path, "utf8");
183+ return jsonResponse({ ok: true, data: { content, bytes: new TextEncoder().encode(content).length } });
184+ }
185+
186+ if (operation === "fs.write") {
187+ const path = typeof payload.path === "string" ? payload.path : "";
188+ const content = typeof payload.content === "string" ? payload.content : "";
189+ if (!path) {
190+ return jsonResponse({ ok: false, error: "fs.write requires path" }, 400);
191+ }
192+ const encoding = payload.encoding === "base64" ? "base64" : "utf8";
193+ const toWrite = encoding === "base64" ? base64ToBytes(content) : content;
194+ await this.fs.writeFile(path, toWrite);
195+ const bytesWritten = encoding === "base64"
196+ ? base64ToBytes(content).length
197+ : new TextEncoder().encode(content).length;
198+ return jsonResponse({
199+ ok: true,
200+ data: {
201+ bytesWritten,
202+ },
203+ });
204+ }
205+
206+ if (operation === "fs.readdir") {
207+ const path = typeof payload.path === "string" && payload.path.trim().length > 0 ? payload.path : "/";
208+ const entries = await this.fs.readdirPlus(path);
209+ return jsonResponse({
210+ ok: true,
211+ data: {
212+ entries: entries.map((entry) => ({
213+ name: entry.name,
214+ type: entry.stats.isDirectory()
215+ ? "directory"
216+ : entry.stats.isFile()
217+ ? "file"
218+ : entry.stats.isSymbolicLink()
219+ ? "symlink"
220+ : "unknown",
221+ size: entry.stats.size,
222+ mtime: entry.stats.mtime,
223+ })),
224+ },
225+ });
226+ }
227+
228+ if (operation === "fs.stat") {
229+ const path = typeof payload.path === "string" ? payload.path : "";
230+ if (!path) {
231+ return jsonResponse({ ok: false, error: "fs.stat requires path" }, 400);
232+ }
233+
234+ try {
235+ const stat = await this.fs.stat(path);
236+ return jsonResponse({
237+ ok: true,
238+ data: {
239+ exists: true,
240+ type: stat.isDirectory()
241+ ? "directory"
242+ : stat.isFile()
243+ ? "file"
244+ : stat.isSymbolicLink()
245+ ? "symlink"
246+ : "unknown",
247+ size: stat.size,
248+ mode: stat.mode,
249+ mtime: stat.mtime,
250+ ctime: stat.ctime,
251+ },
252+ });
253+ } catch (error) {
254+ if (isNotFoundError(error)) {
255+ return jsonResponse({ ok: true, data: { exists: false } });
256+ }
257+ throw error;
258+ }
259+ }
260+
261+ if (operation === "fs.mkdir") {
262+ const path = typeof payload.path === "string" ? payload.path : "";
263+ if (!path) {
264+ return jsonResponse({ ok: false, error: "fs.mkdir requires path" }, 400);
265+ }
266+
267+ await this.fs.mkdir(path);
268+ return jsonResponse({ ok: true, data: { ok: true } });
269+ }
270+
271+ if (operation === "fs.remove") {
272+ const path = typeof payload.path === "string" ? payload.path : "";
273+ if (!path) {
274+ return jsonResponse({ ok: false, error: "fs.remove requires path" }, 400);
275+ }
276+
277+ await this.fs.rm(path, {
278+ recursive: payload.recursive === true,
279+ force: payload.force === true,
280+ });
281+ return jsonResponse({ ok: true, data: { ok: true } });
282+ }
283+
284+ if (operation === "kv.get") {
285+ const key = typeof payload.key === "string" ? payload.key : "";
286+ if (!key) {
287+ return jsonResponse({ ok: false, error: "kv.get requires key" }, 400);
288+ }
289+
290+ this.ensureKvTable();
291+ const row = this.ctx.storage.sql.exec<{ value: string }>("SELECT value FROM kv_store WHERE key = ?", key).one();
292+ if (!row || typeof row.value !== "string") {
293+ return jsonResponse({ ok: true, data: { value: undefined } });
294+ }
295+ return jsonResponse({ ok: true, data: { value: JSON.parse(row.value) } });
296+ }
297+
298+ if (operation === "kv.set") {
299+ const key = typeof payload.key === "string" ? payload.key : "";
300+ if (!key) {
301+ return jsonResponse({ ok: false, error: "kv.set requires key" }, 400);
302+ }
303+
304+ this.ensureKvTable();
305+ this.ctx.storage.sql.exec(
306+ `
307+ INSERT INTO kv_store (key, value, updated_at)
308+ VALUES (?, ?, unixepoch())
309+ ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = unixepoch()
310+ `,
311+ key,
312+ JSON.stringify(payload.value),
313+ );
314+ return jsonResponse({ ok: true, data: { ok: true } });
315+ }
316+
317+ if (operation === "kv.list") {
318+ this.ensureKvTable();
319+ const prefix = typeof payload.prefix === "string" ? payload.prefix : "";
320+ const limit = typeof payload.limit === "number" && Number.isFinite(payload.limit)
321+ ? Math.max(1, Math.min(500, Math.floor(payload.limit)))
322+ : 100;
323+ const escaped = prefix.replaceAll("\\", "\\\\").replaceAll("%", "\\%").replaceAll("_", "\\_");
324+ const rows = this.ctx.storage.sql
325+ .exec<{ key: string; value: string }>(
326+ "SELECT key, value FROM kv_store WHERE key LIKE ? ESCAPE '\\\\' ORDER BY key LIMIT ?",
327+ `${escaped}%`,
328+ limit,
329+ )
330+ .toArray();
331+
332+ return jsonResponse({
333+ ok: true,
334+ data: {
335+ items: rows.map((row) => ({
336+ key: row.key,
337+ value: JSON.parse(row.value),
338+ })),
339+ },
340+ });
341+ }
342+
343+ if (operation === "kv.delete") {
344+ const key = typeof payload.key === "string" ? payload.key : "";
345+ if (!key) {
346+ return jsonResponse({ ok: false, error: "kv.delete requires key" }, 400);
347+ }
348+
349+ this.ensureKvTable();
350+ this.ctx.storage.sql.exec("DELETE FROM kv_store WHERE key = ?", key);
351+ return jsonResponse({ ok: true, data: { ok: true } });
352+ }
353+
354+ if (operation === "sqlite.query") {
355+ const sql = typeof payload.sql === "string" ? payload.sql : "";
356+ if (!sql) {
357+ return jsonResponse({ ok: false, error: "sqlite.query requires sql" }, 400);
358+ }
359+
360+ const mode = payload.mode === "write" ? "write" : "read";
361+ const params = Array.isArray(payload.params)
362+ ? payload.params.filter((value) =>
363+ value === null
364+ || typeof value === "string"
365+ || typeof value === "number"
366+ || typeof value === "boolean"
367+ ).map((value) => (typeof value === "boolean" ? (value ? 1 : 0) : value))
368+ : [];
369+ const maxRows = typeof payload.maxRows === "number" && Number.isFinite(payload.maxRows)
370+ ? Math.max(1, Math.min(1000, Math.floor(payload.maxRows)))
371+ : 200;
372+
373+ const result = this.sqliteQuery(sql, params, mode, maxRows);
374+ return jsonResponse({ ok: true, data: result });
375+ }
376+
377+ if (operation === "instance.usage") {
378+ const stats = await this.fs.statfs();
379+ return jsonResponse({
380+ ok: true,
381+ data: {
382+ sizeBytes: stats.bytesUsed,
383+ fileCount: stats.inodes,
384+ },
385+ });
386+ }
387+
388+ if (operation === "instance.delete") {
389+ const storageWithDelete = this.ctx.storage as unknown as { deleteAll?: () => Promise<void> | void };
390+ if (typeof storageWithDelete.deleteAll === "function") {
391+ await storageWithDelete.deleteAll();
392+ }
393+ return jsonResponse({ ok: true, data: { ok: true } });
394+ }
395+
396+ return jsonResponse({ ok: false, error: `Unsupported storage operation: ${operation}` }, 400);
397+ } catch (error) {
398+ return jsonResponse({ ok: false, error: toStorageError(error) }, 500);
399+ }
400+ }
401+}
402+
52403 // ── Tool Bridge Entrypoint ───────────────────────────────────────────────────
53404 //
54405 // This class is exposed as a named entrypoint on the host Worker. A loopback
@@ -80,6 +431,33 @@ export default { · 25 unmodified lines
80431 return Response.json({ ok: true });
81432 }
82433
434+ if (request.method === "POST" && url.pathname === "/v1/storage") {
435+ const authError = authorizeRunRequest(request, env.AUTH_TOKEN);
436+ if (authError) {
437+ return authError;
438+ }
439+
440+ const parsed = await request.json().catch(() => null) as StorageProxyRequest | null;
441+ const instanceId = parsed?.instanceId?.trim() ?? "";
442+ const operation = parsed?.operation?.trim() ?? "";
443+ if (!instanceId || !operation) {
444+ return jsonResponse({ ok: false, error: "instanceId and operation are required" }, 400);
445+ }
446+
447+ const objectId = env.AGENTFS.idFromName(instanceId);
448+ const stub = env.AGENTFS.get(objectId);
449+ return await stub.fetch("https://agentfs.internal/ops", {
450+ method: "POST",
451+ headers: {
452+ "content-type": "application/json",
453+ },
454+ body: JSON.stringify({
455+ operation,
456+ payload: parsed?.payload ?? {},
457+ }),
458+ });
459+ }
460+
83461 if (request.method !== "POST" || url.pathname !== "/v1/runs") {
84462 return Response.json({ error: "Not found" }, { status: 404 });
85463 }
executor/packages/runner-sandbox-host/src/types.ts+20−0
@@ -1,6 +1,20 @@
11 export interface Env {
22 LOADER: WorkerLoader;
33 AUTH_TOKEN: string;
4+ AGENTFS: DurableObjectNamespace;
5+}
6+
7+export interface DurableObjectNamespace {
8+ idFromName(name: string): DurableObjectId;
9+ get(id: DurableObjectId): DurableObjectStub;
10+}
11+
12+export interface DurableObjectId {
13+ toString(): string;
14+}
15+
16+export interface DurableObjectStub {
17+ fetch(input: string | Request, init?: RequestInit): Promise<Response>;
418 }
519
620 /** Dynamic Worker Loader binding — provided by the `worker_loaders` config. */
@@ -72,6 +86,12 @@ export interface BridgeEntrypointContext { · 65 unmodified lines
7286 props: BridgeProps;
7387 }
7488
89+export interface StorageProxyRequest {
90+ instanceId: string;
91+ operation: string;
92+ payload?: Record<string, unknown>;
93+}
94+
7595 export interface WorkerEntrypointExports {
7696 ToolBridge(opts: { props: BridgeProps }): unknown;
7797 }
executor/packages/runner-sandbox-host/wrangler.jsonc+19−1
@@ -2,7 +2,7 @@ · 1 unmodified line
22 "name": "executor-sandbox-host",
33 "main": "src/index.ts",
44 "compatibility_date": "2025-06-01",
5− "compatibility_flags": ["enable_ctx_exports"],
5+ "compatibility_flags": ["enable_ctx_exports", "nodejs_compat"],
66
77 // Treat *.isolate.js files as raw text strings so they can be passed to the
88 // Dynamic Worker Loader API as module source code. This lets us author the
@@ -18,6 +18,24 @@ · 9 unmodified lines
1818 {
1919 "binding": "LOADER"
2020 }
21+ ],
22+
23+ "durable_objects": {
24+ "bindings": [
25+ {
26+ "name": "AGENTFS",
27+ "class_name": "AgentFSStorageObject"
28+ }
29+ ]
30+ },
31+
32+ "migrations": [
33+ {
34+ "tag": "v1",
35+ "new_sqlite_classes": [
36+ "AgentFSStorageObject"
37+ ]
38+ }
2139 ]
2240
2341 // Environment secrets (set via `wrangler secret put AUTH_TOKEN`):
executor/scripts/dev/storage/smoke.execute-flow.ts+375−0
@@ -0,0 +1,375 @@
1+#!/usr/bin/env bun
2+
3+import assert from "node:assert/strict";
4+import { ConvexHttpClient } from "convex/browser";
5+import { $ } from "bun";
6+import { api } from "../../../packages/database/convex/_generated/api";
7+
8+type RuntimeId = "local-bun" | "cloudflare-worker-loader";
9+const EXECUTOR_ROOT = new URL("../../../", import.meta.url).pathname;
10+const DATABASE_ROOT = new URL("../../../packages/database/", import.meta.url).pathname;
11+
12+function parseArgs(argv: string[]): {
13+ runtimeId?: RuntimeId;
14+ timeoutMs: number;
15+ deployWorker: boolean;
16+ deployConvex: boolean;
17+} {
18+ let runtimeId: RuntimeId | undefined;
19+ let timeoutMs = 120_000;
20+ let deployWorker = false;
21+ let deployConvex = false;
22+
23+ for (let index = 0; index < argv.length; index += 1) {
24+ const arg = argv[index];
25+ if (arg === "--runtime") {
26+ const value = argv[index + 1];
27+ if (value !== "local-bun" && value !== "cloudflare-worker-loader") {
28+ throw new Error("--runtime must be one of: local-bun, cloudflare-worker-loader");
29+ }
30+ runtimeId = value;
31+ index += 1;
32+ continue;
33+ }
34+
35+ if (arg === "--timeout-ms") {
36+ const value = Number.parseInt(argv[index + 1] ?? "", 10);
37+ if (!Number.isFinite(value) || value <= 0) {
38+ throw new Error("--timeout-ms must be a positive integer");
39+ }
40+ timeoutMs = value;
41+ index += 1;
42+ continue;
43+ }
44+
45+ if (arg === "--deploy-worker") {
46+ deployWorker = true;
47+ continue;
48+ }
49+
50+ if (arg === "--deploy-convex") {
51+ deployConvex = true;
52+ continue;
53+ }
54+
55+ throw new Error(`Unknown argument: ${arg}`);
56+ }
57+
58+ return { runtimeId, timeoutMs, deployWorker, deployConvex };
59+}
60+
61+function requireEnv(name: string): string {
62+ const value = process.env[name]?.trim();
63+ if (!value) {
64+ throw new Error(`Missing required environment variable: ${name}`);
65+ }
66+ return value;
67+}
68+
69+function makeClient(convexUrl: string): ConvexHttpClient {
70+ return new ConvexHttpClient(convexUrl, {
71+ skipConvexDeploymentUrlCheck: true,
72+ });
73+}
74+
75+function sleep(ms: number): Promise<void> {
76+ return new Promise((resolve) => setTimeout(resolve, ms));
77+}
78+
79+function parseWorkersDevUrl(output: string): string | null {
80+ const match = output.match(/https:\/\/[\w.-]+\.workers\.dev/);
81+ return match ? match[0] : null;
82+}
83+
84+async function deployCloudflareWorkerAndGetUrl(): Promise<string> {
85+ console.log("[storage:smoke:execute-flow] deploying runner-sandbox-host...");
86+ const output = await $`bun run --cwd packages/runner-sandbox-host deploy`
87+ .cwd(EXECUTOR_ROOT)
88+ .env(process.env)
89+ .text();
90+ console.log(output.trim());
91+
92+ const url = parseWorkersDevUrl(output);
93+ if (!url) {
94+ throw new Error("Could not parse workers.dev URL from runner-sandbox-host deploy output");
95+ }
96+
97+ return url;
98+}
99+
100+async function setCloudflareWorkerAuthSecret(token: string): Promise<void> {
101+ console.log("[storage:smoke:execute-flow] updating runner-sandbox-host AUTH_TOKEN secret...");
102+ await $`printf "%s" ${token} | bun run --cwd packages/runner-sandbox-host wrangler secret put AUTH_TOKEN`
103+ .cwd(EXECUTOR_ROOT)
104+ .env(process.env)
105+ .quiet(false);
106+}
107+
108+async function maybeDeployConvex(enabled: boolean): Promise<void> {
109+ if (!enabled) {
110+ return;
111+ }
112+
113+ console.log("[storage:smoke:execute-flow] deploying Convex functions...");
114+ await $`bunx --package convex convex dev --once --typecheck disable`
115+ .cwd(DATABASE_ROOT)
116+ .env(process.env)
117+ .quiet(false);
118+}
119+
120+async function setConvexEnvVar(key: string, value: string): Promise<void> {
121+ await $`bunx --package convex convex env set ${key} ${value}`
122+ .cwd(DATABASE_ROOT)
123+ .env(process.env)
124+ .quiet(false);
125+}
126+
127+async function waitForSourceReady(args: {
128+ client: ConvexHttpClient;
129+ workspaceId: string;
130+ sessionId: string;
131+ sourceName: string;
132+ timeoutMs: number;
133+}): Promise<void> {
134+ const started = Date.now();
135+
136+ while (Date.now() - started < args.timeoutMs) {
137+ const progress = await args.client.query(api.workspace.getToolInventoryProgress, {
138+ workspaceId: args.workspaceId as never,
139+ sessionId: args.sessionId,
140+ }) as {
141+ inventoryStatus?: { state?: string; error?: string };
142+ sourceStates?: Record<string, { state?: string; toolCount?: number }>;
143+ warnings?: string[];
144+ };
145+
146+ const source = progress.sourceStates?.[args.sourceName];
147+ const inventoryState = progress.inventoryStatus?.state ?? "unknown";
148+ const sourceState = source?.state ?? "unknown";
149+ const sourceCount = source?.toolCount ?? 0;
150+
151+ if (inventoryState === "failed") {
152+ throw new Error(
153+ `Tool inventory failed for source '${args.sourceName}': ${progress.inventoryStatus?.error ?? "unknown error"}`,
154+ );
155+ }
156+
157+ if (sourceState === "ready" && sourceCount > 0) {
158+ return;
159+ }
160+
161+ await sleep(1_000);
162+ }
163+
164+ throw new Error(`Timed out waiting for source '${args.sourceName}' to become ready`);
165+}
166+
167+async function run(): Promise<void> {
168+ const options = parseArgs(process.argv.slice(2));
169+ const convexUrl = requireEnv("CONVEX_URL");
170+ const client = makeClient(convexUrl);
171+
172+ const requestedRuntime: RuntimeId = options.runtimeId
173+ ?? (process.env.CLOUDFLARE_SANDBOX_RUN_URL?.trim() ? "cloudflare-worker-loader" : "local-bun");
174+
175+ await maybeDeployConvex(options.deployConvex);
176+
177+ if (requestedRuntime === "cloudflare-worker-loader") {
178+ let sandboxRunUrl = process.env.CLOUDFLARE_SANDBOX_RUN_URL?.trim();
179+ let sandboxAuthToken = process.env.CLOUDFLARE_SANDBOX_AUTH_TOKEN?.trim();
180+
181+ if (!sandboxAuthToken || options.deployWorker) {
182+ sandboxAuthToken = `smoke_${crypto.randomUUID().replace(/-/g, "")}${crypto.randomUUID().replace(/-/g, "")}`;
183+ await setCloudflareWorkerAuthSecret(sandboxAuthToken);
184+ }
185+
186+ if (!sandboxRunUrl || options.deployWorker) {
187+ const workerUrl = await deployCloudflareWorkerAndGetUrl();
188+ sandboxRunUrl = `${workerUrl}/v1/runs`;
189+ }
190+
191+ if (!sandboxRunUrl || !sandboxAuthToken) {
192+ throw new Error("Unable to resolve Cloudflare sandbox URL/token");
193+ }
194+
195+ process.env.CLOUDFLARE_SANDBOX_RUN_URL = sandboxRunUrl;
196+ process.env.CLOUDFLARE_SANDBOX_AUTH_TOKEN = sandboxAuthToken;
197+ process.env.AGENT_STORAGE_PROVIDER = "agentfs-cloudflare";
198+
199+ console.log("[storage:smoke:execute-flow] syncing Convex dev env vars for Cloudflare runtime/storage...");
200+ await setConvexEnvVar("CLOUDFLARE_SANDBOX_RUN_URL", sandboxRunUrl);
201+ await setConvexEnvVar("CLOUDFLARE_SANDBOX_AUTH_TOKEN", sandboxAuthToken);
202+ await setConvexEnvVar("AGENT_STORAGE_PROVIDER", "agentfs-cloudflare");
203+ } else {
204+ process.env.AGENT_STORAGE_PROVIDER = "agentfs-local";
205+ console.log("[storage:smoke:execute-flow] syncing Convex dev env vars for local storage backend...");
206+ await setConvexEnvVar("AGENT_STORAGE_PROVIDER", "agentfs-local");
207+ }
208+
209+ const runId = crypto.randomUUID().slice(0, 8);
210+ const sessionId = `mcp_storage_e2e_${runId}`;
211+ const organizationName = `Storage Smoke Org ${runId}`;
212+ const sourceName = `storage-smoke-source-${runId}`;
213+
214+ const bootstrap = await client.mutation(api.workspace.bootstrapAnonymousSession, {
215+ sessionId,
216+ }) as {
217+ sessionId: string;
218+ accountId: string;
219+ workspaceId: string;
220+ };
221+
222+ assert.ok(bootstrap.sessionId, "bootstrap must return a sessionId");
223+
224+ const createdOrg = await client.mutation(api.organizations.create, {
225+ sessionId: bootstrap.sessionId,
226+ name: organizationName,
227+ }) as {
228+ organization: { id: string; name: string };
229+ workspace: { id: string; name: string };
230+ };
231+
232+ const workspaceId = createdOrg.workspace.id;
233+ assert.ok(workspaceId, "organization.create must return a workspace id");
234+
235+ const openapiSpec = {
236+ openapi: "3.1.0",
237+ info: {
238+ title: `Storage Smoke API ${runId}`,
239+ version: "1.0.0",
240+ },
241+ servers: [
242+ { url: "https://example.com" },
243+ ],
244+ paths: {
245+ "/ping": {
246+ get: {
247+ operationId: "smoke_ping",
248+ summary: "Ping endpoint",
249+ responses: {
250+ "200": {
251+ description: "ok",
252+ content: {
253+ "application/json": {
254+ schema: {
255+ type: "object",
256+ properties: {
257+ ok: { type: "boolean" },
258+ },
259+ },
260+ },
261+ },
262+ },
263+ },
264+ },
265+ },
266+ },
267+ };
268+
269+ const source = await client.mutation(api.workspace.upsertToolSource, {
270+ workspaceId: workspaceId as never,
271+ sessionId: bootstrap.sessionId,
272+ name: sourceName,
273+ type: "openapi",
274+ scopeType: "workspace",
275+ config: {
276+ spec: openapiSpec,
277+ },
278+ }) as { id: string; name: string };
279+
280+ assert.equal(source.name, sourceName, "upsertToolSource should return created source");
281+
282+ await client.mutation(api.workspace.regenerateToolInventory, {
283+ workspaceId: workspaceId as never,
284+ sessionId: bootstrap.sessionId,
285+ });
286+
287+ await waitForSourceReady({
288+ client,
289+ workspaceId,
290+ sessionId: bootstrap.sessionId,
291+ sourceName,
292+ timeoutMs: options.timeoutMs,
293+ });
294+
295+ const sourceNameLiteral = JSON.stringify(sourceName);
296+ const taskCode = [
297+ `const sourceName = ${sourceNameLiteral};`,
298+ "const storage = await tools.storage.open({ scopeType: 'scratch', purpose: 'execute-flow-smoke' });",
299+ "const instanceId = storage.instance.id;",
300+ "await tools.fs.mkdir({ instanceId, path: '/workspace' });",
301+ "await tools.fs.write({ instanceId, path: '/workspace/hello.txt', content: 'hello storage execute flow' });",
302+ "const file = await tools.fs.read({ instanceId, path: '/workspace/hello.txt' });",
303+ "await tools.kv.set({ instanceId, key: 'discord.userId', value: 'uuid-smoke' });",
304+ "const kv = await tools.kv.get({ instanceId, key: 'discord.userId' });",
305+ "await tools.sqlite.query({ instanceId, mode: 'write', sql: 'CREATE TABLE IF NOT EXISTS smoke (k TEXT PRIMARY KEY, v TEXT)' });",
306+ "await tools.sqlite.query({ instanceId, mode: 'write', sql: 'INSERT INTO smoke (k, v) VALUES (?, ?) ON CONFLICT(k) DO UPDATE SET v = excluded.v', params: ['discord', 'uuid-smoke'] });",
307+ "const sql = await tools.sqlite.query({ instanceId, mode: 'read', sql: 'SELECT v FROM smoke WHERE k = ?', params: ['discord'], maxRows: 1 });",
308+ "return {",
309+ " instanceId,",
310+ " provider: storage.instance.provider,",
311+ " fileContent: file.content,",
312+ " kvValue: kv.value,",
313+ " sqlValue: Array.isArray(sql.rows) && sql.rows.length > 0 ? sql.rows[0].v : null,",
314+ "};",
315+ ].join("\n");
316+
317+ const taskOutcome = await client.action(api.executor.createTask, {
318+ workspaceId: workspaceId as never,
319+ sessionId: bootstrap.sessionId,
320+ runtimeId: requestedRuntime,
321+ waitForResult: true,
322+ timeoutMs: Math.max(90_000, options.timeoutMs),
323+ code: taskCode,
324+ metadata: {
325+ purpose: "storage-execute-flow-smoke",
326+ runId,
327+ },
328+ }) as {
329+ task: { id: string; status: string; error?: string };
330+ result?: {
331+ instanceId?: string;
332+ provider?: string;
333+ fileContent?: string;
334+ kvValue?: string;
335+ sqlValue?: string | null;
336+ };
337+ };
338+
339+ if (taskOutcome.task.status !== "completed") {
340+ throw new Error(`Task did not complete successfully: ${taskOutcome.task.status} ${taskOutcome.task.error ?? ""}`);
341+ }
342+
343+ const result = taskOutcome.result ?? {};
344+ assert.equal(result.fileContent, "hello storage execute flow");
345+ assert.equal(result.kvValue, "uuid-smoke");
346+ assert.equal(result.sqlValue, "uuid-smoke");
347+ assert.ok(result.instanceId, "task result must include storage instanceId");
348+
349+ const storageInstances = await client.query(api.workspace.listStorageInstances, {
350+ workspaceId: workspaceId as never,
351+ sessionId: bootstrap.sessionId,
352+ scopeType: "scratch",
353+ }) as Array<{ id: string; provider: string; status: string }>;
354+
355+ const createdInstance = storageInstances.find((item) => item.id === result.instanceId);
356+ assert.ok(createdInstance, "workspace.listStorageInstances should include created scratch instance");
357+
358+ if (result.instanceId) {
359+ await client.mutation(api.workspace.deleteStorageInstance, {
360+ workspaceId: workspaceId as never,
361+ sessionId: bootstrap.sessionId,
362+ instanceId: result.instanceId,
363+ });
364+ }
365+
366+ console.log("[storage:smoke:execute-flow] PASS");
367+ console.log(`runtime=${requestedRuntime}`);
368+ console.log(`organization=${createdOrg.organization.id}`);
369+ console.log(`workspace=${workspaceId}`);
370+ console.log(`source=${source.id}`);
371+ console.log(`instance=${result.instanceId}`);
372+ console.log(`provider=${createdInstance?.provider ?? result.provider ?? "unknown"}`);
373+}
374+
375+await run();
package.json+5−1
@@ -38,7 +38,10 @@ · 37 unmodified lines
3838 "clean": "bun run --filter '*' --if-present clean && rm -rf node_modules .turbo",
3939 "knip": "bunx knip --config knip.json",
4040 "db:clear:executor": "bun with-env bun run executor/scripts/dev/wipe/convex-db.ts",
41− "convex:codegen": "bunx --cwd executor convex codegen"
41+ "convex:codegen": "bunx --cwd executor convex codegen",
42+ "smoke:storage:execute-flow:local": "bun with-env bun run --cwd executor smoke:storage:execute-flow:local",
43+ "smoke:storage:execute-flow": "bun with-env bun run --cwd executor smoke:storage:execute-flow",
44+ "smoke:storage:execute-flow:cloudflare": "bun with-env bun run --cwd executor smoke:storage:execute-flow:cloudflare"
4245 },
4346 "dependencies": {
4447 "@apidevtools/swagger-parser": "^12.1.0",
@@ -46,6 +49,7 @@ · 1 unmodified line
4649 "@convex-dev/stripe": "^0.1.3",
4750 "@convex-dev/workos-authkit": "^0.1.6",
4851 "@workos-inc/node": "^8.2.0",
52+ "agentfs-sdk": "^0.6.0",
4953 "convex": "^1.31.7",
5054 "convex-helpers": "^0.1.111",
5155 "jose": "^6.1.0",