History

Remove custom tools storage

Rhys Sullivan committed 4 months ago

Commit ff362d21 parent 4f647b525 files · +221 −617Browse files

Unified · Split

25 files
  1. apps/host-selfhost/src/apps-backings.ts +1 −10
  2. packages/plugins/apps/src/api.ts +1 −1
  3. packages/plugins/apps/src/authoring.ts +0 −3
  4. packages/plugins/apps/src/backing/libsql-scope-db.test.ts +0 −4
  5. packages/plugins/apps/src/backing/libsql-scope-db.ts +0 −187
  6. packages/plugins/apps/src/backing/quickjs-tool-sandbox.test.ts +30 −6
  7. packages/plugins/apps/src/backing/quickjs-tool-sandbox.ts +27 −1
  8. packages/plugins/apps/src/backing/scope-collision.test.ts +3 −37
  9. packages/plugins/apps/src/index.ts +1 −1
  10. packages/plugins/apps/src/pipeline/publish.test.ts +28 −0
  11. packages/plugins/apps/src/plugin/apps-plugin.test.ts +52 −47
  12. packages/plugins/apps/src/plugin/apps-plugin.ts +38 −20
  13. packages/plugins/apps/src/plugin/backings.ts +0 −3
  14. packages/plugins/apps/src/plugin/bindings.strict.test.ts +1 −13
  15. packages/plugins/apps/src/plugin/bindings.ts +9 −30
  16. packages/plugins/apps/src/plugin/runtime.test.ts +14 −8
  17. packages/plugins/apps/src/plugin/runtime.ts +0 −16
  18. packages/plugins/apps/src/plugin/self-host-runtime.ts +2 −14
  19. packages/plugins/apps/src/plugin/tenant-isolation.test.ts +3 −22
  20. packages/plugins/apps/src/seams/index.ts +0 −1
  21. packages/plugins/apps/src/seams/scope-db.conformance.ts +0 −70
  22. packages/plugins/apps/src/seams/scope-db.ts +0 −44
  23. packages/plugins/apps/src/seams/tool-sandbox.conformance.ts +0 −55
  24. packages/plugins/apps/src/seams/tool-sandbox.ts +5 −5
  25. packages/plugins/apps/src/testing/daily-brief.ts +6 −19
apps/host-selfhost/src/apps-backings.ts+1−10
@@ -1,10 +1,7 @@
1import { join } from "node:path";1import { join } from "node:path";
2 2
3import { Effect } from "effect";
4
5import {3import {
6 makeGitArtifactStore,4 makeGitArtifactStore,
7 makeLibsqlScopeDb,
8 makeQuickjsToolSandbox,5 makeQuickjsToolSandbox,
9 makeSqliteAppsStore,6 makeSqliteAppsStore,
10 type AppsBackings,7 type AppsBackings,
@@ -19,19 +16,13 @@ interface SelfHostAppsBackings { · 8 unmodified lines
19 16
20const createBackings = (dataDir: string): SelfHostAppsBackings => {17const createBackings = (dataDir: string): SelfHostAppsBackings => {
21 const appsDir = join(dataDir, "apps");18 const appsDir = join(dataDir, "apps");
22 const scopeDb = makeLibsqlScopeDb({
23 root: join(appsDir, "scope-db"),
24 });
25 return {19 return {
26 backings: {20 backings: {
27 artifactStore: makeGitArtifactStore({ root: join(appsDir, "artifacts") }),21 artifactStore: makeGitArtifactStore({ root: join(appsDir, "artifacts") }),
28 scopeDb,
29 sandbox: makeQuickjsToolSandbox(),22 sandbox: makeQuickjsToolSandbox(),
30 store: makeSqliteAppsStore({ path: join(appsDir, "store.sqlite") }),23 store: makeSqliteAppsStore({ path: join(appsDir, "store.sqlite") }),
31 },24 },
32 close: async () => {25 close: async () => {},
33 await Effect.runPromise(scopeDb.close().pipe(Effect.orElseSucceed(() => undefined)));
34 },
35 };26 };
36};27};
37 28
packages/plugins/apps/src/api.ts+1−1
@@ -28,7 +28,6 @@ export { · 27 unmodified lines
28export { type AppsRuntime, type GitHubCustomToolsSourceSummary } from "./plugin/runtime";28export { type AppsRuntime, type GitHubCustomToolsSourceSummary } from "./plugin/runtime";
29export { makeAppsRuntimeFromBackings, type AppsBackings } from "./plugin/backings";29export { makeAppsRuntimeFromBackings, type AppsBackings } from "./plugin/backings";
30export { makeGitArtifactStore } from "./backing/git-artifact-store";30export { makeGitArtifactStore } from "./backing/git-artifact-store";
31export { makeLibsqlScopeDb } from "./backing/libsql-scope-db";
32export { makeQuickjsToolSandbox } from "./backing/quickjs-tool-sandbox";31export { makeQuickjsToolSandbox } from "./backing/quickjs-tool-sandbox";
33export { makeSqliteAppsStore } from "./backing/sqlite-apps-store";32export { makeSqliteAppsStore } from "./backing/sqlite-apps-store";
34export {33export {
@@ -38,6 +37,7 @@ export { · 3 unmodified lines
38 type RoleBindings,37 type RoleBindings,
39} from "./plugin/bindings";38} from "./plugin/bindings";
40export { makePluginCtxAppsResolver } from "./plugin/resolver";39export { makePluginCtxAppsResolver } from "./plugin/resolver";
40export { SourceOriginError, assertSourceOrigin } from "./plugin/apps-plugin";
41export {41export {
42 fetchGitHubSource,42 fetchGitHubSource,
43 parseGitHubSourceUrl,43 parseGitHubSourceUrl,
packages/plugins/apps/src/authoring.ts+0−3
@@ -1,4 +1,3 @@
1import type { ScopeDbHandle } from "./seams/scope-db";
2import type { StandardSchemaV1 } from "./standard-schema";1import type { StandardSchemaV1 } from "./standard-schema";
3 2
4export type JsonPrimitive = string | number | boolean | null;3export type JsonPrimitive = string | number | boolean | null;
@@ -16,8 +15,6 @@ export interface IntegrationDeclaration<Slug extends string = string> { · 11 unmodified lines
16export type ToolHandlerContext<15export type ToolHandlerContext<
17 TIntegrations extends Readonly<Record<string, IntegrationDeclaration>> | undefined,16 TIntegrations extends Readonly<Record<string, IntegrationDeclaration>> | undefined,
18> = {17> = {
19 readonly db: ScopeDbHandle;
20} & {
21 readonly [K in keyof NonNullable<TIntegrations>]: unknown;18 readonly [K in keyof NonNullable<TIntegrations>]: unknown;
22};19};
23 20
packages/plugins/apps/src/backing/libsql-scope-db.test.ts+0−4
@@ -1,4 +0,0 @@
1import { scopeDbConformance } from "../seams/scope-db.conformance";
2import { makeLibsqlScopeDb } from "./libsql-scope-db";
3
4scopeDbConformance("libsql (in-memory)", () => makeLibsqlScopeDb({ root: ":memory:" }));
packages/plugins/apps/src/backing/libsql-scope-db.ts+0−187
@@ -1,187 +0,0 @@
1import { mkdirSync, rmSync } from "node:fs";
2import { join, resolve } from "node:path";
3
4import { createClient, type Client } from "@libsql/client";
5import { Effect } from "effect";
6
7import { ScopeDbError, type ScopeDb, type ScopeDbHandle } from "../seams/scope-db";
8import { scopeAddressStorageKey, type ScopeAddress } from "../seams/scope-address";
9
10// ---------------------------------------------------------------------------
11// libSQL-backed ScopeDb (self-hosted). One SQLite file per tenant/scope under
12// `<root>`. A control table `__versions` holds a monotonic counter per user
13// table; every write statement bumps the counters for the tables it touched.
14// Scope isolation is a separate file per tenant/scope, there is no cross-scope
15// query path.
16// ---------------------------------------------------------------------------
17
18const VERSION_TABLE = "__scope_versions";
19
20// Statements that mutate data. Determines whether a `sql` call bumps versions.
21const WRITE_RE = /^\s*(insert|update|delete|replace|create|drop|alter)\b/i;
22
23// Extract the table names a write statement targets (best-effort: covers the
24// shapes authored tools produce — INSERT INTO x, UPDATE x, DELETE FROM x,
25// CREATE TABLE x, REPLACE INTO x).
26const targetsOf = (sql: string): string[] => {
27 const out = new Set<string>();
28 const patterns = [
29 /\binsert\s+(?:or\s+\w+\s+)?into\s+["'`]?([a-zA-Z_][a-zA-Z0-9_]*)["'`]?/gi,
30 /\breplace\s+into\s+["'`]?([a-zA-Z_][a-zA-Z0-9_]*)["'`]?/gi,
31 /\bupdate\s+["'`]?([a-zA-Z_][a-zA-Z0-9_]*)["'`]?/gi,
32 /\bdelete\s+from\s+["'`]?([a-zA-Z_][a-zA-Z0-9_]*)["'`]?/gi,
33 /\bcreate\s+table\s+(?:if\s+not\s+exists\s+)?["'`]?([a-zA-Z_][a-zA-Z0-9_]*)["'`]?/gi,
34 /\bdrop\s+table\s+(?:if\s+exists\s+)?["'`]?([a-zA-Z_][a-zA-Z0-9_]*)["'`]?/gi,
35 /\balter\s+table\s+["'`]?([a-zA-Z_][a-zA-Z0-9_]*)["'`]?/gi,
36 ];
37 for (const re of patterns) {
38 let m: RegExpExecArray | null;
39 while ((m = re.exec(sql)) !== null) {
40 const name = m[1];
41 if (name && name !== VERSION_TABLE) out.add(name);
42 }
43 }
44 return [...out];
45};
46
47const toArgs = (values: readonly unknown[]): unknown[] =>
48 values.map((v) => (v === undefined ? null : v));
49
50const makeHandle = (address: ScopeAddress, client: Client): ScopeDbHandle => {
51 const label = `${address.tenant}/${address.scope}`;
52 const ensureVersionTable = async () => {
53 await client.execute(
54 `CREATE TABLE IF NOT EXISTS ${VERSION_TABLE} (name TEXT PRIMARY KEY, version INTEGER NOT NULL DEFAULT 0)`,
55 );
56 };
57
58 const bump = async (tables: readonly string[]): Promise<{ table: string; version: number }[]> => {
59 const bumped: { table: string; version: number }[] = [];
60 for (const table of tables) {
61 await client.execute({
62 sql: `INSERT INTO ${VERSION_TABLE} (name, version) VALUES (?, 1)
63 ON CONFLICT(name) DO UPDATE SET version = version + 1`,
64 args: [table],
65 });
66 const row = await client.execute({
67 sql: `SELECT version FROM ${VERSION_TABLE} WHERE name = ?`,
68 args: [table],
69 });
70 bumped.push({ table, version: Number(row.rows[0]?.version ?? 0) });
71 }
72 return bumped;
73 };
74
75 const runStatement = <Row>(
76 sql: string,
77 args: unknown[],
78 ): Effect.Effect<readonly Row[], ScopeDbError> =>
79 Effect.tryPromise({
80 try: async () => {
81 await ensureVersionTable();
82 const result = await client.execute({ sql, args: args as never });
83 if (WRITE_RE.test(sql)) {
84 const targets = targetsOf(sql);
85 if (targets.length > 0) {
86 await bump(targets);
87 }
88 }
89 return result.rows.map((row) => row as Row);
90 },
91 catch: (cause) =>
92 new ScopeDbError({ message: `scope-db statement failed for scope ${label}`, cause }),
93 });
94
95 return {
96 sql: <Row = Record<string, unknown>>(strings: TemplateStringsArray, ...values: unknown[]) => {
97 const sql = strings.reduce((acc, part, i) => acc + part + (i < values.length ? "?" : ""), "");
98 return runStatement<Row>(sql, toArgs(values));
99 },
100 exec: <Row = Record<string, unknown>>(sql: string, params: readonly unknown[] = []) =>
101 runStatement<Row>(sql, toArgs(params)),
102 tableVersion: (table: string) =>
103 Effect.tryPromise({
104 try: async () => {
105 await ensureVersionTable();
106 const row = await client.execute({
107 sql: `SELECT version FROM ${VERSION_TABLE} WHERE name = ?`,
108 args: [table],
109 });
110 return Number(row.rows[0]?.version ?? 0);
111 },
112 catch: (cause) => new ScopeDbError({ message: "tableVersion failed", cause }),
113 }),
114 versions: () =>
115 Effect.tryPromise({
116 try: async () => {
117 await ensureVersionTable();
118 const rows = await client.execute(`SELECT name, version FROM ${VERSION_TABLE}`);
119 const out = new Map<string, number>();
120 for (const r of rows.rows) out.set(String(r.name), Number(r.version));
121 return out as ReadonlyMap<string, number>;
122 },
123 catch: (cause) => new ScopeDbError({ message: "versions failed", cause }),
124 }),
125 };
126};
127
128export interface LibsqlScopeDbOptions {
129 /** Directory holding one SQLite file per tenant/scope, or ":memory:" for tests. */
130 readonly root: string;
131}
132
133const toUrl = (path: string): string => (path === ":memory:" ? path : `file:${resolve(path)}`);
134
135export const makeLibsqlScopeDb = (options: LibsqlScopeDbOptions): ScopeDb => {
136 const clients = new Map<string, Client>();
137
138 const clientFor = (address: ScopeAddress): Client => {
139 const key = scopeAddressStorageKey(address);
140 let client = clients.get(key);
141 if (!client) {
142 if (options.root === ":memory:") {
143 client = createClient({ url: ":memory:" });
144 } else {
145 mkdirSync(options.root, { recursive: true });
146 client = createClient({ url: toUrl(join(options.root, `${key}.db`)) });
147 }
148 clients.set(key, client);
149 }
150 return client;
151 };
152
153 return {
154 forScope: (address) =>
155 Effect.try({
156 try: () => makeHandle(address, clientFor(address)),
157 catch: (cause) =>
158 new ScopeDbError({
159 message: `failed to open scope db ${address.tenant}/${address.scope}`,
160 cause,
161 }),
162 }),
163 removeScope: (address) =>
164 Effect.try({
165 try: () => {
166 const key = scopeAddressStorageKey(address);
167 clients.get(key)?.close();
168 clients.delete(key);
169 if (options.root === ":memory:") return;
170 const dbPath = join(options.root, `${key}.db`);
171 for (const path of [dbPath, `${dbPath}-shm`, `${dbPath}-wal`]) {
172 rmSync(path, { force: true });
173 }
174 },
175 catch: (cause) =>
176 new ScopeDbError({
177 message: `failed to remove scope db ${address.tenant}/${address.scope}`,
178 cause,
179 }),
180 }),
181 close: () =>
182 Effect.sync(() => {
183 for (const client of clients.values()) client.close();
184 clients.clear();
185 }),
186 };
187};
packages/plugins/apps/src/backing/quickjs-tool-sandbox.test.ts+30−6
@@ -31,7 +31,7 @@ describe("QuickJS ToolSandbox", () => { · 30 unmodified lines
31 expect(descriptor.inputJsonSchema.properties).toHaveProperty("since");31 expect(descriptor.inputJsonSchema.properties).toHaveProperty("since");
32 });32 });
33 33
34 it("invokes a tool, routing injected-client + db calls through the bridge", async () => {34 it("invokes a tool, routing injected-client calls through the bridge", async () => {
35 const files = new Map([["tools/issues-sync.ts", ISSUES_SYNC_TS]]);35 const files = new Map([["tools/issues-sync.ts", ISSUES_SYNC_TS]]);
36 const { code } = await run(bundleEntry({ files, entry: "tools/issues-sync.ts" }));36 const { code } = await run(bundleEntry({ files, entry: "tools/issues-sync.ts" }));
37 37
@@ -56,7 +56,6 @@ describe("QuickJS ToolSandbox", () => { · 18 unmodified lines
56 },56 },
57 ];57 ];
58 }58 }
59 // db.sql calls return empty rows
60 return [];59 return [];
61 }),60 }),
62 };61 };
@@ -68,18 +67,43 @@ describe("QuickJS ToolSandbox", () => { · 5 unmodified lines
68 artifact: "issues-sync",67 artifact: "issues-sync",
69 kind: "tool",68 kind: "tool",
70 input: {},69 input: {},
71 roots: { github: { kind: "single" }, db: { kind: "single" } },70 roots: { github: { kind: "single" } },
72 },71 },
73 bridge,72 bridge,
74 ),73 ),
75 );74 );
76 75
77 expect(result.output).toEqual({ synced: 1, repos: 1 });76 expect(result.output).toEqual({
78 // The db write went through the bridge (root "db").77 synced: 1,
79 expect(calls.some((c) => c.root === "db")).toBe(true);78 repos: 1,
79 issues: [{ repo: "acme/app", number: 1, title: "Bug" }],
80 });
80 expect(calls.some((c) => c.root === "github")).toBe(true);81 expect(calls.some((c) => c.root === "github")).toBe(true);
81 });82 });
82 83
84 it("reports storage as unavailable when an old handler calls db.sql", async () => {
85 const source = `import { defineTool } from "executor:app";
86import { z } from "zod";
87export default defineTool({
88 description: "legacy storage",
89 input: z.object({}),
90 async handler(_input, { db }) {
91 await db.sql\`SELECT 1\`;
92 return { ok: true };
93 },
94});`;
95 const files = new Map([["tools/storage.ts", source]]);
96 const { code } = await run(bundleEntry({ files, entry: "tools/storage.ts" }));
97 const bridge: HandleBridge = { call: () => Effect.succeed(null) };
98
99 const exit = await Effect.runPromiseExit(
100 sandbox.invoke(code, { artifact: "storage", kind: "tool", input: {}, roots: {} }, bridge),
101 );
102
103 expect(Exit.isFailure(exit)).toBe(true);
104 expect(JSON.stringify(exit)).toContain("storage is not available yet");
105 });
106
83 it("surfaces Standard Schema validation issues before the handler runs", async () => {107 it("surfaces Standard Schema validation issues before the handler runs", async () => {
84 const source = `import { defineTool } from "executor:app";108 const source = `import { defineTool } from "executor:app";
85import { z } from "zod";109import { z } from "zod";
packages/plugins/apps/src/backing/quickjs-tool-sandbox.ts+27−1
@@ -29,6 +29,8 @@ import { stableStringify } from "../pipeline/descriptor"; · 28 unmodified lines
29// `await __invokeTool("__handle__", { root, path, args })` — the ONE bridge the29// `await __invokeTool("__handle__", { root, path, args })` — the ONE bridge the
30// QuickJS runtime already provides (`tools`/`__invokeTool`). Our30// QuickJS runtime already provides (`tools`/`__invokeTool`). Our
31// `SandboxToolInvoker` decodes that and forwards to the host `HandleBridge`.31// `SandboxToolInvoker` decodes that and forwards to the host `HandleBridge`.
32// The old `db` handle is intentionally unavailable in v1; handlers that still
33// try it get a clear error before any host storage call exists.
32// Everything crossing is JSON (the cloud version is RPC), so the interface34// Everything crossing is JSON (the cloud version is RPC), so the interface
33// stays honest.35// stays honest.
34//36//
@@ -63,6 +65,16 @@ function __mkHandle(root, prefix) { · 28 unmodified lines
63 }65 }
64 });66 });
65}67}
68function __unavailableStorage() {
69 return new Proxy({}, {
70 get: function() {
71 return function() { throw new Error('storage is not available yet'); };
72 },
73 apply: function() {
74 throw new Error('storage is not available yet');
75 }
76 });
77}
66var __executorApp = {78var __executorApp = {
67 integration: function(integration) { return { integration: integration }; },79 integration: function(integration) { return { integration: integration }; },
68 defineTool: function(def) { __defs.tool = def; return def; },80 defineTool: function(def) { __defs.tool = def; return def; },
@@ -167,6 +179,16 @@ return await (async () => { · 98 unmodified lines
167 });179 });
168 };180 };
169 var def = __defs.tool || (globalThis.__artifact && (globalThis.__artifact.default || globalThis.__artifact));181 var def = __defs.tool || (globalThis.__artifact && (globalThis.__artifact.default || globalThis.__artifact));
182 if (def && typeof def.handler === 'function') {
183 var handlerSource = Function.prototype.toString.call(def.handler);
184 if (/\\bdb\\b/.test(handlerSource)) {
185 fail('StorageUnavailableError', {
186 tool: artifactName,
187 field: 'handler',
188 message: 'tool "' + artifactName + '" uses storage, but storage is not available yet',
189 });
190 }
191 }
170 var integrations = {};192 var integrations = {};
171 if (def && def.integrations) {193 if (def && def.integrations) {
172 for (var k in def.integrations) {194 for (var k in def.integrations) {
@@ -226,6 +248,7 @@ return await (async () => { · 53 unmodified lines
226 if (spec.kind !== 'single') throw new Error('unsupported handle root kind: ' + spec.kind);248 if (spec.kind !== 'single') throw new Error('unsupported handle root kind: ' + spec.kind);
227 injected[name] = __mkHandle(name, []);249 injected[name] = __mkHandle(name, []);
228 }250 }
251 injected.db = __unavailableStorage();
229 var input = await validateWithStandardSchema('input', def.input, ${inputLiteral});252 var input = await validateWithStandardSchema('input', def.input, ${inputLiteral});
230 var out = await def.handler(input, injected);253 var out = await def.handler(input, injected);
231 out = await validateWithStandardSchema('output', def.output, out);254 out = await validateWithStandardSchema('output', def.output, out);
@@ -267,7 +290,10 @@ export const makeQuickjsToolSandbox = (options: QuickjsToolSandboxOptions = {}): · 35 unmodified lines
267 Effect.flatMap((result) => {290 Effect.flatMap((result) => {
268 if (result.error) {291 if (result.error) {
269 const marker = parseMarker(result.error);292 const marker = parseMarker(result.error);
270 if (isMarker(marker, "SchemaExportError")) {293 if (
294 isMarker(marker, "SchemaExportError") ||
295 isMarker(marker, "StorageUnavailableError")
296 ) {
271 return Effect.fail(297 return Effect.fail(
272 new ToolSandboxError({298 new ToolSandboxError({
273 kind: "collect",299 kind: "collect",
packages/plugins/apps/src/backing/scope-collision.test.ts+3−37
@@ -1,52 +1,18 @@
1import { mkdtempSync, readdirSync } from "node:fs";
2import { tmpdir } from "node:os";
3import { join } from "node:path";
4
5import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
6import { Effect } from "effect";2import { Effect } from "effect";
7 3
8import { makeLibsqlScopeDb } from "./libsql-scope-db";
9import { makeSqliteAppsStore } from "./sqlite-apps-store";4import { makeSqliteAppsStore } from "./sqlite-apps-store";
10import { DESCRIPTOR_VERSION, type AppDescriptor } from "../pipeline/descriptor";5import { DESCRIPTOR_VERSION, type AppDescriptor } from "../pipeline/descriptor";
11import { scopeAddress } from "../seams/scope-address";
12 6
13const run = <A, E>(effect: Effect.Effect<A, E>): Promise<A> => Effect.runPromise(effect);7const run = <A, E>(effect: Effect.Effect<A, E>): Promise<A> => Effect.runPromise(effect);
14 8
15// ---------------------------------------------------------------------------9// ---------------------------------------------------------------------------
16// Finding 9 regression: scopes that differ only by a character the old naming10// Finding 9 regression: scopes that differ only by a character an old naming
17// collapsed ("my-scope" vs "my_scope") must NOT share a database, and the11// scheme collapsed ("my-scope" vs "my_scope") must not share descriptor
18// connection->scope mapping must route each to its own scope. Before the fix the12// storage.
19// scope-db filename collapsed `[^A-Za-z0-9._-]` to `_` (colliding two scopes onto
20// one file) and the connection name was reverse-parsed (colliding two scopes onto
21// one normalized identifier).
22// ---------------------------------------------------------------------------13// ---------------------------------------------------------------------------
23 14
24describe("scope collision (Fix 9)", () => {15describe("scope collision (Fix 9)", () => {
25 it("gives my-scope and my_scope DISTINCT scope-db files with isolated data", async () => {
26 const root = mkdtempSync(join(tmpdir(), "apps-scopecol-"));
27 const db = makeLibsqlScopeDb({ root });
28
29 const a = await run(db.forScope(scopeAddress("org", "my-scope")));
30 await run(a.exec("CREATE TABLE t (v TEXT)"));
31 await run(a.exec("INSERT INTO t (v) VALUES ('a')"));
32
33 const b = await run(db.forScope(scopeAddress("org", "my_scope")));
34 await run(b.exec("CREATE TABLE t (v TEXT)"));
35 await run(b.exec("INSERT INTO t (v) VALUES ('b')"));
36
37 // Each scope sees ONLY its own row (no shared file).
38 const aRows = await run(a.exec<{ v: string }>("SELECT v FROM t"));
39 const bRows = await run(b.exec<{ v: string }>("SELECT v FROM t"));
40 expect(aRows.map((r) => r.v)).toEqual(["a"]);
41 expect(bRows.map((r) => r.v)).toEqual(["b"]);
42
43 // Two distinct .db files exist on disk.
44 const dbFiles = readdirSync(root).filter((f) => f.endsWith(".db"));
45 expect(dbFiles.length).toBe(2);
46
47 await run(db.close());
48 });
49
50 it("stores distinct descriptor scopes independently", async () => {16 it("stores distinct descriptor scopes independently", async () => {
51 const store = makeSqliteAppsStore({ path: ":memory:" });17 const store = makeSqliteAppsStore({ path: ":memory:" });
52 const descriptor = (scope: string): AppDescriptor => ({18 const descriptor = (scope: string): AppDescriptor => ({
packages/plugins/apps/src/index.ts+1−1
@@ -52,6 +52,7 @@ export { · 51 unmodified lines
52 type BindingContext,52 type BindingContext,
53} from "./plugin/bindings";53} from "./plugin/bindings";
54export { makePluginCtxAppsResolver } from "./plugin/resolver";54export { makePluginCtxAppsResolver } from "./plugin/resolver";
55export { SourceOriginError, assertSourceOrigin } from "./plugin/apps-plugin";
55export {56export {
56 makeSelfHostAppsRuntime,57 makeSelfHostAppsRuntime,
57 type SelfHostAppsRuntime,58 type SelfHostAppsRuntime,
@@ -60,6 +61,5 @@ export { · 2 unmodified lines
60 61
61// Self-host seam backings.62// Self-host seam backings.
62export { makeGitArtifactStore } from "./backing/git-artifact-store";63export { makeGitArtifactStore } from "./backing/git-artifact-store";
63export { makeLibsqlScopeDb } from "./backing/libsql-scope-db";
64export { makeQuickjsToolSandbox } from "./backing/quickjs-tool-sandbox";64export { makeQuickjsToolSandbox } from "./backing/quickjs-tool-sandbox";
65export { makeSqliteAppsStore } from "./backing/sqlite-apps-store";65export { makeSqliteAppsStore } from "./backing/sqlite-apps-store";
packages/plugins/apps/src/pipeline/publish.test.ts+28−0
@@ -94,6 +94,34 @@ export default defineTool({ · 93 unmodified lines
94 expect(JSON.stringify(exit)).toContain("crm");94 expect(JSON.stringify(exit)).toContain("crm");
95 });95 });
96 96
97 it("rejects tools that reference storage", async () => {
98 const deps = makeDeps();
99 const files = new Map<string, string>([
100 [
101 "tools/storage.ts",
102 `import { defineTool } from "executor:app";
103import { z } from "zod";
104export default defineTool({
105 description: "storage",
106 input: z.object({}),
107 async handler(_input, { db }) {
108 await db.sql\`SELECT 1\`;
109 return { ok: true };
110 },
111});`,
112 ],
113 ]);
114
115 const exit = await Effect.runPromiseExit(publish(deps, { scope: "s", files }));
116
117 expect(Exit.isFailure(exit)).toBe(true);
118 expect(JSON.stringify(exit)).toContain("storage is not available yet");
119 const latest = await run(
120 Effect.flatMap(deps.artifactStore.forScope(scopeAddress("org", "s")), (s) => s.latest()),
121 );
122 expect(latest).toBeNull();
123 });
124
97 it("rejects a Standard Schema vendor without JSON Schema export", async () => {125 it("rejects a Standard Schema vendor without JSON Schema export", async () => {
98 const deps = makeDeps();126 const deps = makeDeps();
99 const files = new Map<string, string>([127 const files = new Map<string, string>([
packages/plugins/apps/src/plugin/apps-plugin.test.ts+52−47
@@ -1,4 +1,4 @@
1import { mkdtempSync, readFileSync } from "node:fs";1import { mkdtempSync } from "node:fs";
2import { tmpdir } from "node:os";2import { tmpdir } from "node:os";
3import { join } from "node:path";3import { join } from "node:path";
4 4
@@ -7,27 +7,13 @@ import { Effect } from "effect"; · 2 unmodified lines
7import { IntegrationSlug } from "@executor-js/sdk";7import { IntegrationSlug } from "@executor-js/sdk";
8import { makeTestExecutor } from "@executor-js/sdk/testing";8import { makeTestExecutor } from "@executor-js/sdk/testing";
9 9
10import { appsPlugin } from "./apps-plugin";10import { SourceOriginError, appsPlugin, assertSourceOrigin } from "./apps-plugin";
11import { makeSelfHostAppsRuntime } from "./self-host-runtime";11import { makeSelfHostAppsRuntime } from "./self-host-runtime";
12import { makeInMemoryAppsStore, makeTestResolver } from "../testing";12import { makeInMemoryAppsStore, makeTestResolver } from "../testing";
13import { dailyBriefFileSet } from "../testing/daily-brief";
13 14
14const run = <A, E>(effect: Effect.Effect<A, E>): Promise<A> => Effect.runPromise(effect);15const run = <A, E>(effect: Effect.Effect<A, E>): Promise<A> => Effect.runPromise(effect);
15 16
16const PROTOTYPE_ROOT = "/Users/rhyssullivan/agent-workspace/prototypes/custom-tools";
17
18const prototypeFileSet = (): Map<string, string> =>
19 new Map<string, string>([
20 ["executor.json", readFileSync(join(PROTOTYPE_ROOT, "executor.json"), "utf8")],
21 [
22 "tools/deal-pipeline-sync.ts",
23 readFileSync(join(PROTOTYPE_ROOT, "tools/deal-pipeline-sync.ts"), "utf8"),
24 ],
25 [
26 "tools/find-deal-docs.ts",
27 readFileSync(join(PROTOTYPE_ROOT, "tools/find-deal-docs.ts"), "utf8"),
28 ],
29 ]);
30
31describe("appsPlugin custom-tools contract", () => {17describe("appsPlugin custom-tools contract", () => {
32 it.effect("detects GitHub repo URLs for console auto-detect", () =>18 it.effect("detects GitHub repo URLs for console auto-detect", () =>
33 Effect.gen(function* () {19 Effect.gen(function* () {
@@ -77,30 +63,41 @@ describe("appsPlugin custom-tools contract", () => { · 43 unmodified lines
77 }),63 }),
78 );64 );
79 65
80 it("round-trips prototype files through publish, resolveTools, and invokeTool", async () => {66 it.effect("rejects publishing through a different source door for a GitHub-managed app", () =>
81 let dealListArgs: unknown;67 Effect.gen(function* () {
68 const failure = yield* Effect.flip(assertSourceOrigin("github", "mcp"));
69
70 expect(failure).toBeInstanceOf(SourceOriginError);
71 expect(failure.message).toBe("this app is managed by its GitHub repo");
72 expect(failure.existingOrigin).toBe("github");
73 expect(failure.requestedOrigin).toBe("mcp");
74 }),
75 );
76
77 it("round-trips custom tool files through publish, resolveTools, and invokeTool", async () => {
78 let issueListArgs: unknown;
82 const resolver = makeTestResolver(79 const resolver = makeTestResolver(
83 {80 {
84 dealcloud: {81 github: {
85 "deals.list": (args) => {82 "issues.listForRepo": (args) => {
86 dealListArgs = args[0];83 issueListArgs = args[0];
87 return [];84 return [{ number: 7, title: "Renewal diligence" }];
88 },85 },
89 },86 },
90 "microsoft-sharepoint": {87 gmail: {
91 "search.query": () => [],88 "messages.search": () => ({ messages: [] }),
92 },89 },
93 },90 },
94 [91 [
95 {92 {
96 address: "tools.dealcloud.user.crm-main",93 address: "tools.github.user.github-main",
97 integration: "dealcloud",94 integration: "github",
98 name: "crm-main",95 name: "github-main",
99 },96 },
100 {97 {
101 address: "tools.microsoft-sharepoint.user.sharepoint-main",98 address: "tools.gmail.user.inbox-main",
102 integration: "microsoft-sharepoint",99 integration: "gmail",
103 name: "sharepoint-main",100 name: "inbox-main",
104 },101 },
105 ],102 ],
106 );103 );
@@ -114,6 +111,7 @@ describe("appsPlugin custom-tools contract", () => { · 7 unmodified lines
114 const plugin = appsPlugin({ backings: host.backings });111 const plugin = appsPlugin({ backings: host.backings });
115 const appIntegration = IntegrationSlug.make("rhys-tools");112 const appIntegration = IntegrationSlug.make("rhys-tools");
116 const appConfig = {113 const appConfig = {
114 origin: "github",
117 kind: "github",115 kind: "github",
118 repoUrl: "https://github.com/rhys/tools",116 repoUrl: "https://github.com/rhys/tools",
119 repo: "rhys/tools",117 repo: "rhys/tools",
@@ -142,7 +140,7 @@ describe("appsPlugin custom-tools contract", () => { · 22 unmodified lines
142 },140 },
143 };141 };
144 142
145 await run(runtime.publish({ scope: "rhys", files: prototypeFileSet() }));143 await run(runtime.publish({ scope: "rhys", files: dailyBriefFileSet() }));
146 144
147 const resolved = await run(145 const resolved = await run(
148 plugin.resolveTools!({146 plugin.resolveTools!({
@@ -151,18 +149,18 @@ describe("appsPlugin custom-tools contract", () => { · 2 unmodified lines
151 connection: { name: "main" },149 connection: { name: "main" },
152 } as never),150 } as never),
153 );151 );
154 const syncTool = resolved.tools.find((tool) => String(tool.name) === "deal-pipeline-sync");152 const syncTool = resolved.tools.find((tool) => String(tool.name) === "issues-sync");
155 expect(syncTool).toBeTruthy();153 expect(syncTool).toBeTruthy();
156 const persistedInputSchema = syncTool!.inputSchema as {154 const persistedInputSchema = syncTool!.inputSchema as {
157 properties: Record<string, unknown>;155 properties: Record<string, unknown>;
158 };156 };
159 expect(persistedInputSchema.properties.crm).toBeUndefined();157 expect(persistedInputSchema.properties.github).toBeUndefined();
160 158
161 const projected = await run(159 const projected = await run(
162 plugin.projectToolSchema!({160 plugin.projectToolSchema!({
163 ctx,161 ctx,
164 toolRow: {162 toolRow: {
165 name: "deal-pipeline-sync",163 name: "issues-sync",
166 integration: appIntegration,164 integration: appIntegration,
167 connection: "main",165 connection: "main",
168 },166 },
@@ -174,31 +172,38 @@ describe("appsPlugin custom-tools contract", () => { · 5 unmodified lines
174 properties: Record<string, { enum?: string[]; default?: string; description?: string }>;172 properties: Record<string, { enum?: string[]; default?: string; description?: string }>;
175 required?: string[];173 required?: string[];
176 };174 };
177 expect(inputSchema.properties.crm.enum).toEqual(["tools.dealcloud.user.crm-main"]);175 expect(inputSchema.properties.github.enum).toEqual(["tools.github.user.github-main"]);
178 expect(inputSchema.properties.crm.default).toBe("tools.dealcloud.user.crm-main");176 expect(inputSchema.properties.github.default).toBe("tools.github.user.github-main");
179 expect(inputSchema.properties.crm.description).toBe("Connection to use for crm (dealcloud)");177 expect(inputSchema.properties.github.description).toBe("Connection to use for github (github)");
180 expect(inputSchema.required ?? []).not.toContain("crm");178 expect(inputSchema.required ?? []).not.toContain("github");
181 179
182 const output = await run(180 const output = await run(
183 plugin.invokeTool!({181 plugin.invokeTool!({
184 ctx,182 ctx,
185 toolRow: {183 toolRow: {
186 name: "deal-pipeline-sync",184 name: "issues-sync",
187 integration: appIntegration,185 integration: appIntegration,
188 connection: "main",186 connection: "main",
189 },187 },
190 args: {188 args: {
191 crm: "tools.dealcloud.user.crm-main",189 github: "tools.github.user.github-main",
192 updatedSince: "2026-01-01T00:00:00Z",190 repos: ["acme/tools"],
191 since: "2026-01-01T00:00:00Z",
193 },192 },
194 } as never),193 } as never),
195 );194 );
196 195
197 expect(output).toEqual({ synced: 0 });196 expect(output).toEqual({
198 expect(dealListArgs).toEqual({197 synced: 1,
199 status: "active",198 repos: 1,
200 updatedSince: "2026-01-01T00:00:00Z",199 issues: [{ repo: "acme/tools", number: 7, title: "Renewal diligence" }],
201 pageSize: 200,200 });
201 expect(issueListArgs).toEqual({
202 owner: "acme",
203 repo: "tools",
204 state: "open",
205 since: "2026-01-01T00:00:00Z",
206 per_page: 100,
202 });207 });
203 await host.close();208 await host.close();
204 });209 });
packages/plugins/apps/src/plugin/apps-plugin.ts+38−20
@@ -1,4 +1,4 @@
1import { Effect, Result } from "effect";1import { Data, Effect, Result } from "effect";
2 2
3import {3import {
4 AuthTemplateSlug,4 AuthTemplateSlug,
@@ -24,7 +24,6 @@ import { · 19 unmodified lines
24import type { IntegrationDecl, ToolDescriptor } from "../pipeline/descriptor";24import type { IntegrationDecl, ToolDescriptor } from "../pipeline/descriptor";
25import { PublishError } from "../pipeline/discover";25import { PublishError } from "../pipeline/discover";
26import { ArtifactStoreError } from "../seams/artifact-store";26import { ArtifactStoreError } from "../seams/artifact-store";
27import { ScopeDbError } from "../seams/scope-db";
28import { ToolSandboxError } from "../seams/tool-sandbox";27import { ToolSandboxError } from "../seams/tool-sandbox";
29import {28import {
30 parseGitHubSourceUrl,29 parseGitHubSourceUrl,
@@ -45,6 +44,7 @@ export const APPS_PLUGIN_ID = "apps"; · 14 unmodified lines
45const APP_CONNECTION_NAME = connectionIdentifier("main");44const APP_CONNECTION_NAME = connectionIdentifier("main");
46 45
47interface AppsGitHubSourceConfig {46interface AppsGitHubSourceConfig {
47 readonly origin: "github";
48 readonly kind: "github";48 readonly kind: "github";
49 readonly repoUrl: string;49 readonly repoUrl: string;
50 readonly repo: string;50 readonly repo: string;
@@ -59,6 +59,29 @@ type ResolverFactory = (input: { · 8 unmodified lines
59 readonly tool: string;59 readonly tool: string;
60}) => ClientResolver;60}) => ClientResolver;
61 61
62export class SourceOriginError extends Data.TaggedError("SourceOriginError")<{
63 readonly message: string;
64 readonly existingOrigin: string;
65 readonly requestedOrigin: string;
66}> {}
67
68export const assertSourceOrigin = (
69 existingOrigin: string,
70 requestedOrigin: string,
71): Effect.Effect<void, SourceOriginError> =>
72 existingOrigin === requestedOrigin
73 ? Effect.void
74 : Effect.fail(
75 new SourceOriginError({
76 existingOrigin,
77 requestedOrigin,
78 message:
79 existingOrigin === "github"
80 ? "this app is managed by its GitHub repo"
81 : `this app is managed by its ${existingOrigin} source`,
82 }),
83 );
84
62export type AppsPluginOptions =85export type AppsPluginOptions =
63 | {86 | {
64 readonly backings: AppsBackings;87 readonly backings: AppsBackings;
@@ -171,21 +194,6 @@ const missingRuntime = (): AppsRuntime => ({ · 106 unmodified lines
171 }),194 }),
172 ),195 ),
173 },196 },
174 scopeDb: {
175 forScope: () =>
176 Effect.fail(
177 new ScopeDbError({
178 message: missingBackingsMessage,
179 }),
180 ),
181 removeScope: () =>
182 Effect.fail(
183 new ScopeDbError({
184 message: missingBackingsMessage,
185 }),
186 ),
187 close: () => Effect.void,
188 },
189 sandbox: {197 sandbox: {
190 collect: () =>198 collect: () =>
191 Effect.fail(199 Effect.fail(
@@ -221,7 +229,7 @@ const configBaseUrl = (config: unknown): string | undefined => · 29 unmodified lines
221 : undefined;229 : undefined;
222 230
223const decodeSourceConfig = (config: unknown): AppsGitHubSourceConfig | null => {231const decodeSourceConfig = (config: unknown): AppsGitHubSourceConfig | null => {
224 if (!isRecord(config) || config.kind !== "github") return null;232 if (!isRecord(config) || config.origin !== "github" || config.kind !== "github") return null;
225 const repoUrl = asString(config.repoUrl);233 const repoUrl = asString(config.repoUrl);
226 const repo = asString(config.repo);234 const repo = asString(config.repo);
227 const scope = asString(config.scope);235 const scope = asString(config.scope);
@@ -235,6 +243,7 @@ const decodeSourceConfig = (config: unknown): AppsGitHubSourceConfig | null => { · 7 unmodified lines
235 }243 }
236 : undefined;244 : undefined;
237 return {245 return {
246 origin: "github",
238 kind: "github",247 kind: "github",
239 repoUrl,248 repoUrl,
240 repo,249 repo,
@@ -440,10 +449,18 @@ export const appsPlugin = definePlugin((options?: AppsPluginOptions) => { · 199 unmodified lines
440 Effect.gen(function* () {449 Effect.gen(function* () {
441 const slug = IntegrationSlug.make(slugValue);450 const slug = IntegrationSlug.make(slugValue);
442 const record = yield* ctx.core.integrations.get(slug).pipe(Effect.orElseSucceed(() => null));451 const record = yield* ctx.core.integrations.get(slug).pipe(Effect.orElseSucceed(() => null));
443 const config = record?.kind === APPS_PLUGIN_ID ? decodeSourceConfig(record.config) : null;452 if (!record) {
444 if (!record || !config) {
445 return syncFailure(`Custom tools source "${slugValue}" does not exist.`);453 return syncFailure(`Custom tools source "${slugValue}" does not exist.`);
446 }454 }
455 if (record.kind === APPS_PLUGIN_ID && isRecord(record.config)) {
456 const origin = asString(record.config.origin);
457 if (origin) {
458 const originCheck = yield* assertSourceOrigin(origin, "github").pipe(Effect.result);
459 if (Result.isFailure(originCheck)) return syncFailure(originCheck.failure.message);
460 }
461 }
462 const config = record.kind === APPS_PLUGIN_ID ? decodeSourceConfig(record.config) : null;
463 if (!config) return syncFailure(`Custom tools source "${slugValue}" does not exist.`);
447 const github = yield* ctx.core.integrations464 const github = yield* ctx.core.integrations
448 .get(IntegrationSlug.make("github"))465 .get(IntegrationSlug.make("github"))
449 .pipe(Effect.orElseSucceed(() => null));466 .pipe(Effect.orElseSucceed(() => null));
@@ -542,6 +559,7 @@ export const appsPlugin = definePlugin((options?: AppsPluginOptions) => { · 92 unmodified lines
542 const descriptor = yield* runtime.getDescriptor(tenant, scope);559 const descriptor = yield* runtime.getDescriptor(tenant, scope);
543 const source = descriptor?.source?.kind === "github" ? descriptor.source : null;560 const source = descriptor?.source?.kind === "github" ? descriptor.source : null;
544 const config: AppsGitHubSourceConfig = {561 const config: AppsGitHubSourceConfig = {
562 origin: "github",
545 kind: "github",563 kind: "github",
546 repoUrl: source?.url ?? input.url.trim(),564 repoUrl: source?.url ?? input.url.trim(),
547 repo: source?.repo ?? parsed.value.repo,565 repo: source?.repo ?? parsed.value.repo,
packages/plugins/apps/src/plugin/backings.ts+0−3
@@ -1,5 +1,4 @@
1import type { ArtifactStore } from "../seams/artifact-store";1import type { ArtifactStore } from "../seams/artifact-store";
2import type { ScopeDb } from "../seams/scope-db";
3import type { ToolSandbox } from "../seams/tool-sandbox";2import type { ToolSandbox } from "../seams/tool-sandbox";
4import { makeAppsRuntime, type AppsRuntime } from "./runtime";3import { makeAppsRuntime, type AppsRuntime } from "./runtime";
5import type { AppsStore } from "./store";4import type { AppsStore } from "./store";
@@ -7,7 +6,6 @@ import type { ClientResolver } from "./bindings"; · 1 unmodified line
7 6
8export interface AppsBackings {7export interface AppsBackings {
9 readonly artifactStore: ArtifactStore;8 readonly artifactStore: ArtifactStore;
10 readonly scopeDb: ScopeDb;
11 readonly sandbox: ToolSandbox;9 readonly sandbox: ToolSandbox;
12 readonly store: AppsStore;10 readonly store: AppsStore;
13 readonly defaultTenant?: string;11 readonly defaultTenant?: string;
@@ -20,7 +18,6 @@ export const makeAppsRuntimeFromBackings = ( · 6 unmodified lines
20): AppsRuntime =>18): AppsRuntime =>
21 makeAppsRuntime({19 makeAppsRuntime({
22 artifactStore: backings.artifactStore,20 artifactStore: backings.artifactStore,
23 scopeDb: backings.scopeDb,
24 sandbox: backings.sandbox,21 sandbox: backings.sandbox,
25 store: backings.store,22 store: backings.store,
26 resolver: backings.resolver ?? fallbackResolver,23 resolver: backings.resolver ?? fallbackResolver,
packages/plugins/apps/src/plugin/bindings.strict.test.ts+1−13
@@ -3,7 +3,6 @@ import { Effect, Exit } from "effect"; · 2 unmodified lines
3 3
4import { buildBridge, resolveIntegrationBindings, type ClientResolver } from "./bindings";4import { buildBridge, resolveIntegrationBindings, type ClientResolver } from "./bindings";
5import type { IntegrationDecl } from "../pipeline/descriptor";5import type { IntegrationDecl } from "../pipeline/descriptor";
6import type { ScopeDbHandle } from "../seams/scope-db";
7 6
8// ---------------------------------------------------------------------------7// ---------------------------------------------------------------------------
9// Strict HandleBridge dispatch (Fix 4, grafted from build A). The bridge is the8// Strict HandleBridge dispatch (Fix 4, grafted from build A). The bridge is the
@@ -11,13 +10,6 @@ import type { ScopeDbHandle } from "../seams/scope-db"; · 1 unmodified line
11// malformed, reserved, undeclared, or out-of-range rather than resolve it.10// malformed, reserved, undeclared, or out-of-range rather than resolve it.
12// ---------------------------------------------------------------------------11// ---------------------------------------------------------------------------
13 12
14const okDb: ScopeDbHandle = {
15 sql: () => Effect.succeed([]),
16 exec: () => Effect.succeed([]),
17 tableVersion: () => Effect.succeed(0),
18 versions: () => Effect.succeed(new Map()),
19};
20
21const okResolver: ClientResolver = {13const okResolver: ClientResolver = {
22 listConnections: ({ integration }) =>14 listConnections: ({ integration }) =>
23 Effect.succeed([15 Effect.succeed([
@@ -55,7 +47,7 @@ describe("HandleBridge strict dispatch", () => { · 31 unmodified lines
55 const bindings = {47 const bindings = {
56 gh: "tools.github.user.main",48 gh: "tools.github.user.main",
57 };49 };
58 const bridge = buildBridge({ declared, bindings, db: okDb, resolver: okResolver });50 const bridge = buildBridge({ declared, bindings, resolver: okResolver });
59 51
60 it("rejects an empty method path", async () => {52 it("rejects an empty method path", async () => {
61 expect(await fails(bridge.call({ root: "gh", path: [], args: [] }))).toBe(true);53 expect(await fails(bridge.call({ root: "gh", path: [], args: [] }))).toBe(true);
@@ -75,10 +67,6 @@ describe("HandleBridge strict dispatch", () => { · 13 unmodified lines
75 );67 );
76 });68 });
77 69
78 it("rejects an unsupported db call", async () => {
79 expect(await fails(bridge.call({ root: "db", path: ["drop"], args: [] }))).toBe(true);
80 });
81
82 it("routes a valid single-connection call through the resolver", async () => {70 it("routes a valid single-connection call through the resolver", async () => {
83 const out = await Effect.runPromise(71 const out = await Effect.runPromise(
84 bridge.call({ root: "gh", path: ["repos", "list"], args: [{}] }),72 bridge.call({ root: "gh", path: ["repos", "list"], args: [{}] }),
packages/plugins/apps/src/plugin/bindings.ts+9−30
@@ -4,7 +4,6 @@ import type { InvokeOptions } from "@executor-js/sdk"; · 3 unmodified lines
4 4
5import type { HandleBridge, HandleRootSpec } from "../seams/tool-sandbox";5import type { HandleBridge, HandleRootSpec } from "../seams/tool-sandbox";
6import { ToolSandboxError } from "../seams/tool-sandbox";6import { ToolSandboxError } from "../seams/tool-sandbox";
7import type { ScopeDbHandle } from "../seams/scope-db";
8import type { IntegrationDecl } from "../pipeline/descriptor";7import type { IntegrationDecl } from "../pipeline/descriptor";
9 8
10// ---------------------------------------------------------------------------9// ---------------------------------------------------------------------------
@@ -16,7 +15,7 @@ import type { IntegrationDecl } from "../pipeline/descriptor"; · 5 unmodified lines
16// has exactly one connection for a role's integration, that connection is used15// has exactly one connection for a role's integration, that connection is used
17// as the default. Missing, unknown, or wrong-integration choices are typed16// as the default. Missing, unknown, or wrong-integration choices are typed
18// BindingError failures. The handler receives clients under the declared role17// BindingError failures. The handler receives clients under the declared role
19// names and `db`.18// names.
20// ---------------------------------------------------------------------------19// ---------------------------------------------------------------------------
21 20
22export class BindingError extends Data.TaggedError("BindingError")<{21export class BindingError extends Data.TaggedError("BindingError")<{
@@ -60,8 +59,6 @@ export interface BindingContext { · 37 unmodified lines
60 readonly declared: Readonly<Record<string, IntegrationDecl>>;59 readonly declared: Readonly<Record<string, IntegrationDecl>>;
61 /** Resolved role -> connection address choices for this invocation. */60 /** Resolved role -> connection address choices for this invocation. */
62 readonly bindings: RoleBindings;61 readonly bindings: RoleBindings;
63 /** The invoking scope's app database (bound to the `db` root). */
64 readonly db: ScopeDbHandle;
65 /** Routes a bound method call to the real integration. */62 /** Routes a bound method call to the real integration. */
66 readonly resolver: ClientResolver;63 readonly resolver: ClientResolver;
67 /** Caller-supplied invoke options to preserve approval/elicitation context. */64 /** Caller-supplied invoke options to preserve approval/elicitation context. */
@@ -166,12 +163,12 @@ export const resolveIntegrationBindings = ( · 98 unmodified lines
166 return { input, bindings };163 return { input, bindings };
167 });164 });
168 165
169/** Compute the sandbox handle roots: `db` plus one single root per declared166/** Compute the sandbox handle roots: one single root per declared integration
170 * integration role. */167 * role. */
171export const rootsFor = (168export const rootsFor = (
172 declared: Readonly<Record<string, IntegrationDecl>>,169 declared: Readonly<Record<string, IntegrationDecl>>,
173): Readonly<Record<string, HandleRootSpec>> => {170): Readonly<Record<string, HandleRootSpec>> => {
174 const roots: Record<string, HandleRootSpec> = { db: { kind: "single" } };171 const roots: Record<string, HandleRootSpec> = {};
175 for (const role of Object.keys(declared)) {172 for (const role of Object.keys(declared)) {
176 roots[role] = { kind: "single" };173 roots[role] = { kind: "single" };
177 }174 }
@@ -192,12 +189,11 @@ const invokeErr = (message: string): ToolSandboxError => · 14 unmodified lines
192 new ToolSandboxError({ kind: "invoke", message });189 new ToolSandboxError({ kind: "invoke", message });
193 190
194/**191/**
195 * Build the HandleBridge the sandbox calls out through. `db` routes to the192 * Build the HandleBridge the sandbox calls out through. A declared role routes
196 * scope database; a declared role routes to its resolved connection through the193 * to its resolved connection through the `ClientResolver`. The dispatch is
197 * `ClientResolver`. The dispatch is STRICT: an empty/malformed path, a reserved194 * STRICT: an empty/malformed path, a reserved root, an undeclared root, or an
198 * root, an undeclared root, or an indexed root is an error, never a silent195 * indexed root is an error, never a silent success. A `.account` read on a
199 * success. A `.account` read on a client returns bound-connection metadata196 * client returns bound-connection metadata without a round-trip.
200 * without a round-trip.
201 */197 */
202export const buildBridge = (context: BindingContext): HandleBridge => ({198export const buildBridge = (context: BindingContext): HandleBridge => ({
203 call: ({ root, path, args }) => {199 call: ({ root, path, args }) => {
@@ -211,23 +207,6 @@ export const buildBridge = (context: BindingContext): HandleBridge => ({ · 7 unmodified lines
211 return Effect.fail(invokeErr(`reserved handle root is not callable: ${root}`));207 return Effect.fail(invokeErr(`reserved handle root is not callable: ${root}`));
212 }208 }
213 209
214 if (root === "db") {
215 if (path.length === 1 && path[0] === "sql") {
216 const [strings, ...values] = args as [TemplateStringsArray, ...unknown[]];
217 return context.db.sql(strings, ...values).pipe(
218 Effect.mapError(
219 (cause) =>
220 new ToolSandboxError({
221 kind: "invoke",
222 message: "scope database call failed",
223 cause,
224 }),
225 ),
226 );
227 }
228 return Effect.fail(invokeErr(`unsupported db call: ${path.join(".")}`));
229 }
230
231 const { role, index } = parseRoot(root);210 const { role, index } = parseRoot(root);
232 if (index !== undefined) {211 if (index !== undefined) {
233 return Effect.fail(212 return Effect.fail(
packages/plugins/apps/src/plugin/runtime.test.ts+14−8
@@ -7,7 +7,6 @@ import { Effect, Exit } from "effect"; · 6 unmodified lines
7 7
8import { makeSelfHostAppsRuntime } from "./self-host-runtime";8import { makeSelfHostAppsRuntime } from "./self-host-runtime";
9import { makeInMemoryAppsStore, makeTestResolver, dailyBriefFileSet } from "../testing";9import { makeInMemoryAppsStore, makeTestResolver, dailyBriefFileSet } from "../testing";
10import { scopeAddress } from "../seams/scope-address";
11 10
12const run = <A, E>(effect: Effect.Effect<A, E>): Promise<A> => Effect.runPromise(effect);11const run = <A, E>(effect: Effect.Effect<A, E>): Promise<A> => Effect.runPromise(effect);
13 12
@@ -36,7 +35,7 @@ const githubHandlers = { · 22 unmodified lines
36};35};
37 36
38describe("AppsRuntime end-to-end (publish -> invoke)", () => {37describe("AppsRuntime end-to-end (publish -> invoke)", () => {
39 it("publishes daily-brief and invokes the tool into the scope db", async () => {38 it("publishes daily-brief and invokes the tool through declared integrations", async () => {
40 const store = makeInMemoryAppsStore();39 const store = makeInMemoryAppsStore();
41 const resolver = makeTestResolver(githubHandlers, [40 const resolver = makeTestResolver(githubHandlers, [
42 { address: "tools.github.user.rhys-github", integration: "github", name: "rhys-github" },41 { address: "tools.github.user.rhys-github", integration: "github", name: "rhys-github" },
@@ -61,12 +60,19 @@ describe("AppsRuntime end-to-end (publish -> invoke)", () => { · 18 unmodified lines
61 tool: "issues-sync",60 tool: "issues-sync",
62 args: { github: "tools.github.user.rhys-github" },61 args: { github: "tools.github.user.rhys-github" },
63 }),62 }),
64 )) as { synced: number; repos: number };63 )) as {
65 expect(syncResult).toEqual({ synced: 2, repos: 1 });64 synced: number;
66 65 repos: number;
67 const db = await run(host.scopeDb.forScope(scopeAddress("org", "rhys")));66 issues: readonly { repo: string; number: number; title: string }[];
68 const rows = await run(db.exec<{ n: number }>("SELECT COUNT(*) AS n FROM issues"));67 };
69 expect(Number(rows[0].n)).toBe(2);68 expect(syncResult).toEqual({
69 synced: 2,
70 repos: 1,
71 issues: [
72 { repo: "acme/app", number: 1, title: "Fresh bug" },
73 { repo: "acme/app", number: 2, title: "Old bug" },
74 ],
75 });
70 76
71 await host.close();77 await host.close();
72 });78 });
packages/plugins/apps/src/plugin/runtime.ts+0−16
@@ -2,7 +2,6 @@ import { Effect, Predicate } from "effect"; · 1 unmodified line
2import type { InvokeOptions } from "@executor-js/sdk";2import type { InvokeOptions } from "@executor-js/sdk";
3 3
4import type { ArtifactStore } from "../seams/artifact-store";4import type { ArtifactStore } from "../seams/artifact-store";
5import type { ScopeDb } from "../seams/scope-db";
6import {5import {
7 InputValidationError,6 InputValidationError,
8 OutputValidationError,7 OutputValidationError,
@@ -33,7 +32,6 @@ import type { GitHubSkippedArtifact } from "../source/github-source"; · 24 unmodified lines
33 32
34export interface AppsRuntimeDeps {33export interface AppsRuntimeDeps {
35 readonly artifactStore: ArtifactStore;34 readonly artifactStore: ArtifactStore;
36 readonly scopeDb: ScopeDb;
37 readonly sandbox: ToolSandbox;35 readonly sandbox: ToolSandbox;
38 readonly store: AppsStore;36 readonly store: AppsStore;
39 /** Routes a bound integration method call to the real API (policy/audit). */37 /** Routes a bound integration method call to the real API (policy/audit). */
@@ -289,20 +287,9 @@ export const makeAppsRuntime = (deps: AppsRuntimeDeps): AppsRuntime => { · 249 unmodified lines
289 );287 );
290 const roots = rootsFor(toolDesc.integrations);288 const roots = rootsFor(toolDesc.integrations);
291 const code = yield* bundleFor(descriptor, toolDesc.sourcePath);289 const code = yield* bundleFor(descriptor, toolDesc.sourcePath);
292 const db = yield* deps.scopeDb.forScope(scopeAddress(descriptor.tenant, scope)).pipe(
293 Effect.mapError(
294 (c) =>
295 new PublishError({
296 message: c.message,
297 stage: "project",
298 diagnostics: [],
299 }),
300 ),
301 );
302 const bridge = buildBridge({290 const bridge = buildBridge({
303 declared: toolDesc.integrations,291 declared: toolDesc.integrations,
304 bindings: resolved.bindings,292 bindings: resolved.bindings,
305 db,
306 resolver: activeResolver,293 resolver: activeResolver,
307 invokeOptions,294 invokeOptions,
308 });295 });
@@ -434,9 +421,6 @@ export const makeAppsRuntime = (deps: AppsRuntimeDeps): AppsRuntime => { · 125 unmodified lines
434 yield* deps.artifactStore421 yield* deps.artifactStore
435 .removeScope(address)422 .removeScope(address)
436 .pipe(Effect.mapError(toPublishError("remove artifact scope failed")));423 .pipe(Effect.mapError(toPublishError("remove artifact scope failed")));
437 yield* deps.scopeDb
438 .removeScope(address)
439 .pipe(Effect.mapError(toPublishError("remove scope db failed")));
440 });424 });
441 },425 },
442 426
packages/plugins/apps/src/plugin/self-host-runtime.ts+2−14
@@ -1,18 +1,14 @@
1import { join } from "node:path";1import { join } from "node:path";
2 2
3import { Effect } from "effect";
4
5import { makeGitArtifactStore } from "../backing/git-artifact-store";3import { makeGitArtifactStore } from "../backing/git-artifact-store";
6import { makeLibsqlScopeDb } from "../backing/libsql-scope-db";
7import { makeQuickjsToolSandbox } from "../backing/quickjs-tool-sandbox";4import { makeQuickjsToolSandbox } from "../backing/quickjs-tool-sandbox";
8import type { ScopeDb } from "../seams/scope-db";
9import { type AppsRuntime } from "./runtime";5import { type AppsRuntime } from "./runtime";
10import type { AppsStore } from "./store";6import type { AppsStore } from "./store";
11import type { ClientResolver } from "./bindings";7import type { ClientResolver } from "./bindings";
12import { makeAppsRuntimeFromBackings, type AppsBackings } from "./backings";8import { makeAppsRuntimeFromBackings, type AppsBackings } from "./backings";
13 9
14export interface SelfHostAppsRuntimeOptions {10export interface SelfHostAppsRuntimeOptions {
15 /** Data dir root; `<root>/artifacts` and `<root>/scope-db`. */11 /** Data dir root; `<root>/artifacts`. */
16 readonly dataDir: string;12 readonly dataDir: string;
17 /** Default tenant for direct runtime calls; request paths pass tenant explicitly. */13 /** Default tenant for direct runtime calls; request paths pass tenant explicitly. */
18 readonly tenant?: string;14 readonly tenant?: string;
@@ -26,7 +22,6 @@ export interface SelfHostAppsRuntimeOptions { · 7 unmodified lines
26export interface SelfHostAppsRuntime {22export interface SelfHostAppsRuntime {
27 readonly runtime: AppsRuntime;23 readonly runtime: AppsRuntime;
28 readonly backings: AppsBackings;24 readonly backings: AppsBackings;
29 readonly scopeDb: ScopeDb;
30 readonly close: () => Promise<void>;25 readonly close: () => Promise<void>;
31}26}
32 27
@@ -37,13 +32,9 @@ export const makeSelfHostAppsRuntime = ( · 4 unmodified lines
37 const artifactStore = makeGitArtifactStore({32 const artifactStore = makeGitArtifactStore({
38 root: inMem ? options.dataDir : join(options.dataDir, "artifacts"),33 root: inMem ? options.dataDir : join(options.dataDir, "artifacts"),
39 });34 });
40 const scopeDb = makeLibsqlScopeDb({
41 root: inMem ? ":memory:" : join(options.dataDir, "scope-db"),
42 });
43 const sandbox = makeQuickjsToolSandbox();35 const sandbox = makeQuickjsToolSandbox();
44 const backings: AppsBackings = {36 const backings: AppsBackings = {
45 artifactStore,37 artifactStore,
46 scopeDb,
47 sandbox,38 sandbox,
48 store: options.store,39 store: options.store,
49 resolver: options.resolver,40 resolver: options.resolver,
@@ -54,9 +45,6 @@ export const makeSelfHostAppsRuntime = ( · 4 unmodified lines
54 return {45 return {
55 runtime,46 runtime,
56 backings,47 backings,
57 scopeDb,48 close: async () => {},
58 close: async () => {
59 await Effect.runPromise(scopeDb.close().pipe(Effect.orElseSucceed(() => undefined)));
60 },
61 };49 };
62};50};
packages/plugins/apps/src/plugin/tenant-isolation.test.ts+3−22
@@ -6,7 +6,6 @@ import { describe, expect, it } from "@effect/vitest"; · 5 unmodified lines
6import { Effect } from "effect";6import { Effect } from "effect";
7 7
8import { makeSqliteAppsStore } from "../backing/sqlite-apps-store";8import { makeSqliteAppsStore } from "../backing/sqlite-apps-store";
9import { scopeAddress } from "../seams/scope-address";
10import { syncGitHubSource } from "../source/github-source";9import { syncGitHubSource } from "../source/github-source";
11import { makeTestResolver } from "../testing";10import { makeTestResolver } from "../testing";
12import { makeSelfHostAppsRuntime } from "./self-host-runtime";11import { makeSelfHostAppsRuntime } from "./self-host-runtime";
@@ -18,11 +17,9 @@ import { z } from "zod"; · 5 unmodified lines
18export default defineTool({17export default defineTool({
19 description: "${name}",18 description: "${name}",
20 input: z.object({}),19 input: z.object({}),
21 output: z.object({ ok: z.boolean() }),20 output: z.object({ ok: z.boolean(), name: z.string() }),
22 async handler(_input, { db }) {21 async handler() {
23 await db.sql\`CREATE TABLE IF NOT EXISTS markers (name TEXT NOT NULL)\`;22 return { ok: true, name: ${JSON.stringify(name)} };
24 await db.sql\`INSERT INTO markers (name) VALUES (${JSON.stringify(name)})\`;
25 return { ok: true };
26 },23 },
27});`;24});`;
28 25
@@ -119,22 +116,6 @@ describe("tenant scope isolation", () => { · 90 unmodified lines
119 expect(descriptorA?.tools.map((tool) => tool.name)).toEqual(["alpha"]);116 expect(descriptorA?.tools.map((tool) => tool.name)).toEqual(["alpha"]);
120 expect(descriptorB?.tools.map((tool) => tool.name)).toEqual(["beta"]);117 expect(descriptorB?.tools.map((tool) => tool.name)).toEqual(["beta"]);
121 118
122 const dbA = await run(hostA.scopeDb.forScope(scopeAddress(tenantA, scope)));
123 const dbB = await run(hostB.scopeDb.forScope(scopeAddress(tenantB, scope)));
124 await run(dbA.exec("CREATE TABLE marker (value TEXT)"));
125 await run(dbA.exec("INSERT INTO marker (value) VALUES ('a')"));
126 await run(dbB.exec("CREATE TABLE marker (value TEXT)"));
127 await run(dbB.exec("INSERT INTO marker (value) VALUES ('b')"));
128 expect(
129 (await run(dbA.exec<{ value: string }>("SELECT value FROM marker"))).map((r) => r.value),
130 ).toEqual(["a"]);
131 expect(
132 (await run(dbB.exec<{ value: string }>("SELECT value FROM marker"))).map((r) => r.value),
133 ).toEqual(["b"]);
134 expect(
135 readdirSync(join(dataDir, "scope-db")).filter((name) => name.endsWith(".db")).length,
136 ).toBe(2);
137
138 await hostA.close();119 await hostA.close();
139 await hostB.close();120 await hostB.close();
140 });121 });
packages/plugins/apps/src/seams/index.ts+0−1
@@ -2,5 +2,4 @@ · 1 unmodified line
2// `src/backing/`; each seam has a conformance suite (`*.conformance.ts`) that a2// `src/backing/`; each seam has a conformance suite (`*.conformance.ts`) that a
3// backing must pass, keeping a future Cloudflare backing honest.3// backing must pass, keeping a future Cloudflare backing honest.
4export * from "./artifact-store";4export * from "./artifact-store";
5export * from "./scope-db";
6export * from "./tool-sandbox";5export * from "./tool-sandbox";
packages/plugins/apps/src/seams/scope-db.conformance.ts+0−70
@@ -1,70 +0,0 @@
1import { describe, expect, it } from "@effect/vitest";
2import { Effect } from "effect";
3
4import type { ScopeDb } from "./scope-db";
5import { scopeAddress } from "./scope-address";
6
7// ---------------------------------------------------------------------------
8// ScopeDb conformance suite. Runs against the interface. Covers: scope
9// isolation (a write in scope A is invisible in scope B), per-table version
10// bumps on write, and tagged-template parameters.
11// ---------------------------------------------------------------------------
12
13const run = <A, E>(effect: Effect.Effect<A, E>): Promise<A> => Effect.runPromise(effect);
14
15export const scopeDbConformance = (
16 name: string,
17 makeDb: () => Promise<ScopeDb> | ScopeDb,
18): void => {
19 describe(`ScopeDb conformance: ${name}`, () => {
20 it("isolates data between scopes", async () => {
21 const db = await makeDb();
22 const a = await run(db.forScope(scopeAddress("org", "scope-a")));
23 const b = await run(db.forScope(scopeAddress("org", "scope-b")));
24 await run(a.exec("CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)"));
25 await run(a.exec("INSERT INTO items (name) VALUES ('a-only')"));
26 await run(b.exec("CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)"));
27
28 const aRows = await run(a.exec<{ name: string }>("SELECT name FROM items"));
29 const bRows = await run(b.exec<{ name: string }>("SELECT name FROM items"));
30 expect(aRows.map((r) => r.name)).toEqual(["a-only"]);
31 expect(bRows).toEqual([]);
32 await run(db.close());
33 });
34
35 it("bumps a table's version on each write", async () => {
36 const db = await makeDb();
37 const s = await run(db.forScope(scopeAddress("org", "ver")));
38 await run(s.exec("CREATE TABLE t (id INTEGER PRIMARY KEY)"));
39 const afterCreate = await run(s.tableVersion("t"));
40 expect(afterCreate).toBeGreaterThanOrEqual(1);
41
42 await run(s.exec("INSERT INTO t (id) VALUES (1)"));
43 const afterInsert = await run(s.tableVersion("t"));
44 expect(afterInsert).toBe(afterCreate + 1);
45
46 await run(s.exec("UPDATE t SET id = 2 WHERE id = 1"));
47 const afterUpdate = await run(s.tableVersion("t"));
48 expect(afterUpdate).toBe(afterInsert + 1);
49
50 // Reads do not bump.
51 await run(s.exec("SELECT * FROM t"));
52 expect(await run(s.tableVersion("t"))).toBe(afterUpdate);
53 await run(db.close());
54 });
55
56 it("supports the author-facing tagged-template sql with parameters", async () => {
57 const db = await makeDb();
58 const s = await run(db.forScope(scopeAddress("org", "tpl")));
59 await run(s.exec("CREATE TABLE issues (repo TEXT, title TEXT)"));
60 const repo = "acme/app";
61 const title = "Bug";
62 await run(s.sql`INSERT INTO issues (repo, title) VALUES (${repo}, ${title})`);
63 const rows = await run(
64 s.sql<{ repo: string; title: string }>`SELECT * FROM issues WHERE repo = ${repo}`,
65 );
66 expect(rows).toEqual([{ repo: "acme/app", title: "Bug" }]);
67 await run(db.close());
68 });
69 });
70};
packages/plugins/apps/src/seams/scope-db.ts+0−44
@@ -1,44 +0,0 @@
1import type { Effect } from "effect";
2import { Data } from "effect";
3import type { ScopeAddress } from "./scope-address";
4
5// ---------------------------------------------------------------------------
6// ScopeDb — the per-scope app database (the shared primitive).
7//
8// One SQL database per scope: tools read and write it. The self-hosted backing
9// is one libSQL (SQLite) file per scope; the cloud backing (future) is a
10// Durable Object facet. The seam carries a template-tag `sql` (the author-facing
11// shape) plus per-table version counters.
12//
13// Scope isolation is structural: `forScope(a)` and `forScope(b)` are distinct
14// databases; there is no cross-scope query path.
15// ---------------------------------------------------------------------------
16
17export class ScopeDbError extends Data.TaggedError("ScopeDbError")<{
18 readonly message: string;
19 readonly cause?: unknown;
20}> {}
21
22/** The author-facing db handle (matches `executor:app`'s `ScopeDb`): a tagged
23 * template returning rows. */
24export interface ScopeDbHandle {
25 readonly sql: <Row = Record<string, unknown>>(
26 strings: TemplateStringsArray,
27 ...values: unknown[]
28 ) => Effect.Effect<readonly Row[], ScopeDbError>;
29 /** Run a raw statement (used by the runtime for probes/migrations). */
30 readonly exec: <Row = Record<string, unknown>>(
31 sql: string,
32 params?: readonly unknown[],
33 ) => Effect.Effect<readonly Row[], ScopeDbError>;
34 /** Current version counter for a table (0 if never written). */
35 readonly tableVersion: (table: string) => Effect.Effect<number, ScopeDbError>;
36 /** Snapshot of every tracked table's version. */
37 readonly versions: () => Effect.Effect<ReadonlyMap<string, number>, ScopeDbError>;
38}
39
40export interface ScopeDb {
41 readonly forScope: (address: ScopeAddress) => Effect.Effect<ScopeDbHandle, ScopeDbError>;
42 readonly removeScope: (address: ScopeAddress) => Effect.Effect<void, ScopeDbError>;
43 readonly close: () => Effect.Effect<void, ScopeDbError>;
44}
packages/plugins/apps/src/seams/tool-sandbox.conformance.ts+0−55
@@ -106,60 +106,5 @@ export default defineTool({ · 105 unmodified lines
106 expect(seen[0].path).toEqual(["messages", "search"]);106 expect(seen[0].path).toEqual(["messages", "search"]);
107 expect(seen[0].args[0]).toMatchObject({ q: "invoice" });107 expect(seen[0].args[0]).toMatchObject({ q: "invoice" });
108 });108 });
109
110 // --- richer assertions grafted from build A ---------------------------
111
112 it("round-trips a db.sql write with its parameters intact", async () => {
113 const sandbox = makeSandbox();
114 // A tool with a github client AND a db.sql write, asserting BOTH the
115 // connection call and the parameterized db statement cross the bridge with
116 // their args intact (the db.sql param round-trip A's suite covers).
117 const src = `import { defineTool, integration } from "executor:app";
118import { z } from "zod";
119export default defineTool({
120 description: "bridge",
121 integrations: { gh: integration("github") },
122 input: z.object({}),
123 async handler(_i, { gh, db }) {
124 const repos = await gh.repos.listForAuthenticatedUser({ per_page: 5 });
125 await db.sql\`INSERT INTO log (n) VALUES (\${repos.length})\`;
126 return { count: repos.length };
127 },
128});`;
129 const b = await bundle("tools/bridge.ts", src);
130 const calls: { root: string; path: readonly string[]; args: readonly unknown[] }[] = [];
131 const bridge: HandleBridge = {
132 call: ({ root, path, args }) =>
133 Effect.sync(() => {
134 calls.push({ root, path, args });
135 if (root === "gh" && path.join(".") === "repos.listForAuthenticatedUser") {
136 return [{ full_name: "a/b" }, { full_name: "c/d" }];
137 }
138 return [];
139 }),
140 };
141 const result = await run(
142 sandbox.invoke(
143 b,
144 {
145 artifact: "bridge",
146 kind: "tool",
147 input: {},
148 roots: { gh: { kind: "single" }, db: { kind: "single" } },
149 },
150 bridge,
151 ),
152 );
153 expect(result.output).toEqual({ count: 2 });
154 // The github call crossed the bridge.
155 expect(calls.some((c) => c.root === "gh")).toBe(true);
156 // The db.sql write crossed as ["sql"] with the template strings + the
157 // interpolated parameter (2) intact.
158 const dbCall = calls.find((c) => c.root === "db");
159 expect(dbCall?.path).toEqual(["sql"]);
160 const [strings, ...params] = dbCall!.args as [readonly string[], ...unknown[]];
161 expect(strings.join("?")).toContain("INSERT INTO log");
162 expect(params).toEqual([2]);
163 });
164 });109 });
165};110};
packages/plugins/apps/src/seams/tool-sandbox.ts+5−5
@@ -49,7 +49,7 @@ export class OutputValidationError extends Data.TaggedError("OutputValidationErr · 48 unmodified lines
49 49
50/**50/**
51 * The serializable bridge the sandbox calls out through. `root` names an51 * The serializable bridge the sandbox calls out through. `root` names an
52 * injected handle (a connection role, `db`, or one element of a fan-out set);52 * injected handle (a connection role or one element of a fan-out set);
53 * `path` is the method chain (`["events", "list"]`); `args` is the JSON call53 * `path` is the method chain (`["events", "list"]`); `args` is the JSON call
54 * arguments. The return value is JSON. This is the ONE way sandboxed code54 * arguments. The return value is JSON. This is the ONE way sandboxed code
55 * reaches the host — nothing else is wired.55 * reaches the host — nothing else is wired.
@@ -62,9 +62,9 @@ export interface HandleBridge { · 6 unmodified lines
62 }) => Effect.Effect<unknown, ToolSandboxError>;62 }) => Effect.Effect<unknown, ToolSandboxError>;
63}63}
64 64
65/** Which handle roots to inject. Each declared integration role and `db` are65/** Which handle roots to inject. Each declared integration role is a single
66 * single roots. Everything the handler can see is enumerated here —66 * root. Everything the handler can see is enumerated here; undeclared roots
67 * undeclared roots are simply absent. */67 * are simply absent. */
68export interface HandleRootSpec {68export interface HandleRootSpec {
69 readonly kind: "single";69 readonly kind: "single";
70}70}
@@ -77,7 +77,7 @@ export interface InvokeRequest { · 6 unmodified lines
77 /** JSON input passed to the handler. */77 /** JSON input passed to the handler. */
78 readonly input: unknown;78 readonly input: unknown;
79 /** The handle roots to inject, keyed by the name the handler destructures79 /** The handle roots to inject, keyed by the name the handler destructures
80 * (`github`, `db`, `inboxes`). */80 * (`github`, `inboxes`). */
81 readonly roots: Readonly<Record<string, HandleRootSpec>>;81 readonly roots: Readonly<Record<string, HandleRootSpec>>;
82}82}
83 83
packages/plugins/apps/src/testing/daily-brief.ts+6−19
@@ -11,7 +11,7 @@ import { defineTool, integration } from "executor:app"; · 10 unmodified lines
11 11
12export default defineTool({12export default defineTool({
13 description:13 description:
14 "Refresh the scope \\\`issues\\\` table from GitHub. Syncs open issues across the given repos (default: every repo the connection can see).",14 "Summarize open GitHub issues across the given repos (default: every repo the connection can see).",
15 integrations: {15 integrations: {
16 github: integration("github"),16 github: integration("github"),
17 },17 },
@@ -19,38 +19,25 @@ export default defineTool({ · 1 unmodified line
19 repos: z.array(z.string()).optional().describe("owner/repo entries; omit to sync all accessible repos"),19 repos: z.array(z.string()).optional().describe("owner/repo entries; omit to sync all accessible repos"),
20 since: z.string().optional().describe("ISO timestamp, only issues updated after this"),20 since: z.string().optional().describe("ISO timestamp, only issues updated after this"),
21 }),21 }),
22 output: z.object({ synced: z.number(), repos: z.number() }),22 output: z.object({ synced: z.number(), repos: z.number(), issues: z.array(z.object({ repo: z.string(), number: z.number(), title: z.string() })) }),
23 annotations: { readOnly: false, destructive: false },23 annotations: { readOnly: false, destructive: false },
24 async handler({ repos, since }, { github, db }) {24 async handler({ repos, since }, { github }) {
25 const targets =25 const targets =
26 repos ??26 repos ??
27 (await github.repos.listForAuthenticatedUser({ per_page: 100 })).map((r) => r.full_name);27 (await github.repos.listForAuthenticatedUser({ per_page: 100 })).map((r) => r.full_name);
28 28
29 await db.sql\`
30 CREATE TABLE IF NOT EXISTS issues (
31 repo TEXT NOT NULL, number INTEGER NOT NULL, title TEXT NOT NULL,
32 labels TEXT NOT NULL DEFAULT '[]', assignee TEXT, updated_at TEXT NOT NULL, url TEXT NOT NULL,
33 PRIMARY KEY (repo, number)
34 )\`;
35
36 let synced = 0;29 let synced = 0;
30 const collected = [];
37 for (const target of targets) {31 for (const target of targets) {
38 const [owner, repo] = target.split("/");32 const [owner, repo] = target.split("/");
39 const issues = await github.issues.listForRepo({ owner, repo, state: "open", since, per_page: 100 });33 const issues = await github.issues.listForRepo({ owner, repo, state: "open", since, per_page: 100 });
40 for (const issue of issues) {34 for (const issue of issues) {
41 if (issue.pull_request) continue;35 if (issue.pull_request) continue;
42 await db.sql\`36 collected.push({ repo: target, number: issue.number, title: issue.title });
43 INSERT INTO issues (repo, number, title, labels, assignee, updated_at, url)
44 VALUES (\${target}, \${issue.number}, \${issue.title},
45 \${JSON.stringify(issue.labels.map((l) => l.name))},
46 \${issue.assignee?.login ?? null}, \${issue.updated_at}, \${issue.html_url})
47 ON CONFLICT (repo, number) DO UPDATE SET
48 title = excluded.title, labels = excluded.labels,
49 assignee = excluded.assignee, updated_at = excluded.updated_at\`;
50 synced++;37 synced++;
51 }38 }
52 }39 }
53 return { synced, repos: targets.length };40 return { synced, repos: targets.length, issues: collected };
54 },41 },
55});42});
56`;43`;