From 363d8f808b8f76f13616bb9c2e5d5e6b75116546 Mon Sep 17 00:00:00 2001
From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com>
Date: Mon, 6 Jul 2026 00:44:39 -0700
Subject: [PATCH 01/21] Carve custom tools apps core
---
bun.lock | 26 ++
packages/plugins/apps/package.json | 51 +++
packages/plugins/apps/src/api.ts | 17 +
.../backing/git-artifact-store.cas.test.ts | 68 ++++
.../src/backing/git-artifact-store.test.ts | 10 +
.../apps/src/backing/git-artifact-store.ts | 305 ++++++++++++++++++
.../apps/src/backing/libsql-scope-db.test.ts | 4 +
.../apps/src/backing/libsql-scope-db.ts | 186 +++++++++++
.../quickjs-tool-sandbox.conformance.test.ts | 7 +
.../src/backing/quickjs-tool-sandbox.test.ts | 125 +++++++
.../apps/src/backing/quickjs-tool-sandbox.ts | 252 +++++++++++++++
.../apps/src/backing/scope-collision.test.ts | 62 ++++
.../apps/src/backing/sqlite-apps-store.ts | 98 ++++++
packages/plugins/apps/src/index.ts | 45 +++
packages/plugins/apps/src/pipeline/bundle.ts | 214 ++++++++++++
.../plugins/apps/src/pipeline/descriptor.ts | 101 ++++++
.../plugins/apps/src/pipeline/discover.ts | 92 ++++++
.../plugins/apps/src/pipeline/publish.test.ts | 124 +++++++
packages/plugins/apps/src/pipeline/publish.ts | 259 +++++++++++++++
.../plugins/apps/src/plugin/apps-plugin.ts | 234 ++++++++++++++
.../apps/src/plugin/bindings.strict.test.ts | 80 +++++
packages/plugins/apps/src/plugin/bindings.ts | 223 +++++++++++++
.../src/plugin/publish-concurrency.test.ts | 82 +++++
.../plugins/apps/src/plugin/runtime.test.ts | 74 +++++
packages/plugins/apps/src/plugin/runtime.ts | 295 +++++++++++++++++
.../apps/src/plugin/self-host-runtime.ts | 55 ++++
packages/plugins/apps/src/plugin/store.ts | 78 +++++
.../src/seams/artifact-store.conformance.ts | 91 ++++++
.../plugins/apps/src/seams/artifact-store.ts | 64 ++++
packages/plugins/apps/src/seams/index.ts | 6 +
.../apps/src/seams/scope-db.conformance.ts | 69 ++++
packages/plugins/apps/src/seams/scope-db.ts | 42 +++
.../src/seams/tool-sandbox.conformance.ts | 216 +++++++++++++
.../plugins/apps/src/seams/tool-sandbox.ts | 105 ++++++
.../plugins/apps/src/testing/daily-brief.ts | 100 ++++++
packages/plugins/apps/src/testing/index.ts | 100 ++++++
packages/plugins/apps/tsconfig.json | 23 ++
packages/plugins/apps/tsup.config.ts | 15 +
packages/plugins/apps/vitest.config.ts | 9 +
39 files changed, 4007 insertions(+)
create mode 100644 packages/plugins/apps/package.json
create mode 100644 packages/plugins/apps/src/api.ts
create mode 100644 packages/plugins/apps/src/backing/git-artifact-store.cas.test.ts
create mode 100644 packages/plugins/apps/src/backing/git-artifact-store.test.ts
create mode 100644 packages/plugins/apps/src/backing/git-artifact-store.ts
create mode 100644 packages/plugins/apps/src/backing/libsql-scope-db.test.ts
create mode 100644 packages/plugins/apps/src/backing/libsql-scope-db.ts
create mode 100644 packages/plugins/apps/src/backing/quickjs-tool-sandbox.conformance.test.ts
create mode 100644 packages/plugins/apps/src/backing/quickjs-tool-sandbox.test.ts
create mode 100644 packages/plugins/apps/src/backing/quickjs-tool-sandbox.ts
create mode 100644 packages/plugins/apps/src/backing/scope-collision.test.ts
create mode 100644 packages/plugins/apps/src/backing/sqlite-apps-store.ts
create mode 100644 packages/plugins/apps/src/index.ts
create mode 100644 packages/plugins/apps/src/pipeline/bundle.ts
create mode 100644 packages/plugins/apps/src/pipeline/descriptor.ts
create mode 100644 packages/plugins/apps/src/pipeline/discover.ts
create mode 100644 packages/plugins/apps/src/pipeline/publish.test.ts
create mode 100644 packages/plugins/apps/src/pipeline/publish.ts
create mode 100644 packages/plugins/apps/src/plugin/apps-plugin.ts
create mode 100644 packages/plugins/apps/src/plugin/bindings.strict.test.ts
create mode 100644 packages/plugins/apps/src/plugin/bindings.ts
create mode 100644 packages/plugins/apps/src/plugin/publish-concurrency.test.ts
create mode 100644 packages/plugins/apps/src/plugin/runtime.test.ts
create mode 100644 packages/plugins/apps/src/plugin/runtime.ts
create mode 100644 packages/plugins/apps/src/plugin/self-host-runtime.ts
create mode 100644 packages/plugins/apps/src/plugin/store.ts
create mode 100644 packages/plugins/apps/src/seams/artifact-store.conformance.ts
create mode 100644 packages/plugins/apps/src/seams/artifact-store.ts
create mode 100644 packages/plugins/apps/src/seams/index.ts
create mode 100644 packages/plugins/apps/src/seams/scope-db.conformance.ts
create mode 100644 packages/plugins/apps/src/seams/scope-db.ts
create mode 100644 packages/plugins/apps/src/seams/tool-sandbox.conformance.ts
create mode 100644 packages/plugins/apps/src/seams/tool-sandbox.ts
create mode 100644 packages/plugins/apps/src/testing/daily-brief.ts
create mode 100644 packages/plugins/apps/src/testing/index.ts
create mode 100644 packages/plugins/apps/tsconfig.json
create mode 100644 packages/plugins/apps/tsup.config.ts
create mode 100644 packages/plugins/apps/vitest.config.ts
diff --git a/bun.lock b/bun.lock
index e1f7250b2..316bff560 100644
--- a/bun.lock
+++ b/bun.lock
@@ -781,6 +781,30 @@
"effect": "catalog:",
},
},
+ "packages/plugins/apps": {
+ "name": "@executor-js/plugin-apps",
+ "version": "0.1.0",
+ "dependencies": {
+ "@executor-js/codemode-core": "workspace:*",
+ "@executor-js/runtime-quickjs": "workspace:*",
+ "@executor-js/sdk": "workspace:*",
+ "@libsql/client": "catalog:",
+ "@standard-schema/spec": "^1.0.0",
+ "esbuild": "^0.27.3",
+ "zod": "4.3.6",
+ },
+ "devDependencies": {
+ "@effect/vitest": "catalog:",
+ "@types/node": "catalog:",
+ "bun-types": "catalog:",
+ "effect": "catalog:",
+ "tsup": "catalog:",
+ "vitest": "catalog:",
+ },
+ "peerDependencies": {
+ "effect": "catalog:",
+ },
+ },
"packages/plugins/desktop-settings": {
"name": "@executor-js/plugin-desktop-settings",
"version": "1.5.28",
@@ -1776,6 +1800,8 @@
"@executor-js/motel": ["@executor-js/motel@0.2.5-executor.1", "", { "dependencies": { "@effect/atom-react": "^4.0.0-beta.49", "@effect/opentelemetry": "^4.0.0-beta.49", "@effect/platform-bun": "^4.0.0-beta.50", "@opentelemetry/api": "^1.9.0", "@opentelemetry/exporter-logs-otlp-http": "^0.214.0", "@opentelemetry/exporter-trace-otlp-http": "^0.211.0", "@opentelemetry/sdk-logs": "^0.214.0", "@opentelemetry/sdk-node": "^0.214.0", "@opentelemetry/sdk-trace-base": "^2.5.0", "@opentelemetry/sdk-trace-node": "^2.6.1", "@opentui/core": "^0.1.99", "@opentui/react": "^0.1.99", "effect": "^4.0.0-beta.49", "react": "^19.2.5", "scheduler": "^0.27.0" }, "bin": { "motel": "src/motel.ts", "motel-mcp": "src/mcp.ts" } }, "sha512-fLGJ5FIIBYd+4L2OurpYFjWzvcCbACvmdNofh6THXQJE1Gku93EKURtgn27/XddyM9SKGST6/znTRcmwKiYdXw=="],
+ "@executor-js/plugin-apps": ["@executor-js/plugin-apps@workspace:packages/plugins/apps"],
+
"@executor-js/plugin-desktop-settings": ["@executor-js/plugin-desktop-settings@workspace:packages/plugins/desktop-settings"],
"@executor-js/plugin-encrypted-secrets": ["@executor-js/plugin-encrypted-secrets@workspace:packages/plugins/encrypted-secrets"],
diff --git a/packages/plugins/apps/package.json b/packages/plugins/apps/package.json
new file mode 100644
index 000000000..a116cea63
--- /dev/null
+++ b/packages/plugins/apps/package.json
@@ -0,0 +1,51 @@
+{
+ "name": "@executor-js/plugin-apps",
+ "version": "0.1.0",
+ "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/apps",
+ "bugs": {
+ "url": "https://github.com/UsefulSoftwareCo/executor/issues"
+ },
+ "license": "MIT",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/UsefulSoftwareCo/executor.git",
+ "directory": "packages/plugins/apps"
+ },
+ "files": [
+ "dist"
+ ],
+ "type": "module",
+ "exports": {
+ ".": "./src/index.ts",
+ "./api": "./src/api.ts",
+ "./seams": "./src/seams/index.ts",
+ "./testing": "./src/testing/index.ts"
+ },
+ "scripts": {
+ "build": "tsup && (tsc --declaration --emitDeclarationOnly --outDir dist --rootDir src || true)",
+ "typecheck": "tsgo --noEmit",
+ "test": "vitest run",
+ "test:watch": "vitest",
+ "typecheck:slow": "bunx tsc --noEmit -p tsconfig.json"
+ },
+ "dependencies": {
+ "@executor-js/codemode-core": "workspace:*",
+ "@executor-js/runtime-quickjs": "workspace:*",
+ "@executor-js/sdk": "workspace:*",
+ "@libsql/client": "catalog:",
+ "@standard-schema/spec": "^1.0.0",
+ "esbuild": "^0.27.3",
+ "zod": "4.3.6"
+ },
+ "devDependencies": {
+ "@effect/vitest": "catalog:",
+ "@types/node": "catalog:",
+ "bun-types": "catalog:",
+ "effect": "catalog:",
+ "tsup": "catalog:",
+ "vitest": "catalog:"
+ },
+ "peerDependencies": {
+ "effect": "catalog:"
+ }
+}
diff --git a/packages/plugins/apps/src/api.ts b/packages/plugins/apps/src/api.ts
new file mode 100644
index 000000000..74cba1a18
--- /dev/null
+++ b/packages/plugins/apps/src/api.ts
@@ -0,0 +1,17 @@
+// Host-facing plugin surface for @executor-js/plugin-apps.
+export {
+ appsPlugin,
+ APPS_INTEGRATION_SLUG,
+ APPS_PLUGIN_ID,
+ APPS_CONNECTION_PREFIX,
+ connectionNameForScope,
+ scopeFromConnection,
+ type AppsPluginOptions,
+} from "./plugin/apps-plugin";
+export {
+ makeSelfHostAppsRuntime,
+ type SelfHostAppsRuntime,
+ type SelfHostAppsRuntimeOptions,
+} from "./plugin/self-host-runtime";
+export { makeSqliteAppsStore } from "./backing/sqlite-apps-store";
+export { BindingError, type ClientResolver, type Bindings } from "./plugin/bindings";
diff --git a/packages/plugins/apps/src/backing/git-artifact-store.cas.test.ts b/packages/plugins/apps/src/backing/git-artifact-store.cas.test.ts
new file mode 100644
index 000000000..a1438a8d2
--- /dev/null
+++ b/packages/plugins/apps/src/backing/git-artifact-store.cas.test.ts
@@ -0,0 +1,68 @@
+import { mkdtempSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+
+import { describe, expect, it } from "vitest";
+import { Effect } from "effect";
+
+import { makeGitArtifactStore } from "./git-artifact-store";
+import type { ArtifactStoreError } from "../seams/artifact-store";
+
+const run = (effect: Effect.Effect): Promise => Effect.runPromise(effect);
+
+// ---------------------------------------------------------------------------
+// Finding 6 regression (git compare-and-swap): two commits built from the SAME
+// parent cannot both advance the branch. The ref update is a CAS against the
+// expected old value, so the second raced writer gets a typed conflict instead
+// of silently clobbering the first. This is deterministic (no timing race): we
+// force both to share a parent by committing them against a store whose head we
+// pin.
+// ---------------------------------------------------------------------------
+
+describe("git ArtifactStore ref CAS (Fix 6)", () => {
+ it("two concurrent commits from one parent: one wins, the other typed-conflicts", async () => {
+ const root = mkdtempSync(join(tmpdir(), "apps-cas-"));
+ const storeA = makeGitArtifactStore({ root });
+ const scopeA = await run(storeA.forScope("s"));
+
+ // Seed a parent so racers commit ON TOP of the same head.
+ await run(scopeA.commit(new Map([["tools/base.ts", "// base"]]), "base"));
+
+ // Many SEPARATE store instances over the SAME repo dir. The repo already
+ // exists (seeded above), so opening each scope is a re-`init --bare` (a no-op
+ // on an existing repo). Open them SEQUENTIALLY so the idempotent init doesn't
+ // itself race, then race only the COMMITS (the ref-CAS is what's under test).
+ const N = 12;
+ const openedScopes: { commit: typeof scopeA.commit; log: typeof scopeA.log }[] = [];
+ for (let i = 0; i < N; i++) {
+ const store = makeGitArtifactStore({ root });
+ // eslint-disable-next-line no-await-in-loop
+ openedScopes.push(await run(store.forScope("s")));
+ }
+ const scopeB = openedScopes[0];
+
+ // Fire many concurrent commits, ALL built against the seeded base parent.
+ const results = await Promise.allSettled(
+ openedScopes.map((sc, i) =>
+ run(sc.commit(new Map([[`tools/c${i}.ts`, `// ${i}`]]), `c${i}`)),
+ ),
+ );
+
+ const fulfilled = results.filter((r) => r.status === "fulfilled").length;
+ const rejected = results.filter((r) => r.status === "rejected");
+
+ // Any failure MUST be a typed conflict (never a silent clobber).
+ for (const r of rejected) {
+ const err = (r as PromiseRejectedResult).reason as ArtifactStoreError;
+ expect(String(JSON.stringify(err))).toMatch(/conflict/i);
+ }
+
+ // No lost commits: the git log length equals base(1) + every commit that
+ // reported success. Without the CAS, a raced writer clobbers the ref and an
+ // earlier successful commit vanishes from history, making the log SHORTER
+ // than base + fulfilled. The CAS guarantees every reported success is a real,
+ // reachable, non-clobbering advance.
+ const logEntries = await run(scopeB.log(1000));
+ expect(logEntries.length).toBe(1 + fulfilled);
+ }, 60_000);
+});
diff --git a/packages/plugins/apps/src/backing/git-artifact-store.test.ts b/packages/plugins/apps/src/backing/git-artifact-store.test.ts
new file mode 100644
index 000000000..e1a434fa4
--- /dev/null
+++ b/packages/plugins/apps/src/backing/git-artifact-store.test.ts
@@ -0,0 +1,10 @@
+import { mkdtempSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+
+import { artifactStoreConformance } from "../seams/artifact-store.conformance";
+import { makeGitArtifactStore } from "./git-artifact-store";
+
+artifactStoreConformance("git", () =>
+ makeGitArtifactStore({ root: mkdtempSync(join(tmpdir(), "apps-artifacts-")) }),
+);
diff --git a/packages/plugins/apps/src/backing/git-artifact-store.ts b/packages/plugins/apps/src/backing/git-artifact-store.ts
new file mode 100644
index 000000000..6d231f029
--- /dev/null
+++ b/packages/plugins/apps/src/backing/git-artifact-store.ts
@@ -0,0 +1,305 @@
+import { execFile } from "node:child_process";
+import { mkdir } from "node:fs/promises";
+import { join } from "node:path";
+
+import { Effect } from "effect";
+
+import {
+ ArtifactStoreError,
+ asSnapshotId,
+ type ArtifactStore,
+ type FileSet,
+ type ScopeArtifactStore,
+ type SnapshotId,
+ type SnapshotMeta,
+} from "../seams/artifact-store";
+
+// ---------------------------------------------------------------------------
+// Git-backed ArtifactStore (self-hosted). One bare git repo per scope under
+// `/.git`. Snapshots are commits, written via git plumbing
+// (hash-object -> update-index -> write-tree -> commit-tree) so no working tree
+// is ever checked out — the runtime only reads committed snapshots. The commit
+// hash IS the SnapshotId; git guarantees immutability (content-addressed).
+// ---------------------------------------------------------------------------
+
+const BRANCH = "refs/heads/main";
+
+// The "must not exist" sentinel for `git update-ref [ `: an empty
+// old-value asserts the ref currently has NO value (the first publish to a fresh
+// scope repo). git treats the empty string as "the ref must not already exist".
+const EMPTY_OID = "";
+
+const run = (
+ cwd: string,
+ args: readonly string[],
+ input?: string | Buffer,
+): Effect.Effect =>
+ Effect.callback((resume) => {
+ const child = execFile(
+ "git",
+ args,
+ { cwd, maxBuffer: 256 * 1024 * 1024, encoding: "buffer" },
+ (error, stdout, stderr) => {
+ if (error) {
+ resume(
+ Effect.fail(
+ new ArtifactStoreError({
+ message: `git ${args[0]} failed: ${(stderr as Buffer)?.toString() || error.message}`,
+ cause: error,
+ }),
+ ),
+ );
+ return;
+ }
+ resume(Effect.succeed((stdout as Buffer).toString("utf8")));
+ },
+ );
+ if (input !== undefined) {
+ child.stdin?.write(input);
+ child.stdin?.end();
+ }
+ });
+
+const sanitizeScope = (scope: string): string => {
+ if (!/^[a-zA-Z0-9._-]+$/.test(scope)) {
+ // Scopes are internal identifiers; refuse anything that could escape a path.
+ throw new ArtifactStoreError({ message: `invalid scope key: ${scope}` });
+ }
+ return scope;
+};
+
+const makeScopeStore = (repoDir: string): ScopeArtifactStore => {
+ // Environment forcing a deterministic author/committer so a snapshot's hash
+ // depends only on its content + parent, not on wall-clock/identity drift.
+ const commitEnv = (message: string) =>
+ Effect.gen(function* () {
+ const parent = yield* headCommit();
+ // Build a tree from the provided file set held in a bare index file.
+ return { parent, message };
+ });
+
+ const headCommit = (): Effect.Effect =>
+ run(repoDir, ["rev-parse", "--verify", "--quiet", BRANCH]).pipe(
+ Effect.map((out) => out.trim() || null),
+ Effect.catch(() => Effect.succeed(null)),
+ );
+
+ const readMeta = (id: string): Effect.Effect =>
+ run(repoDir, ["show", "-s", "--format=%H%n%ct%n%s", id]).pipe(
+ Effect.map((out) => {
+ const [hash, committed, ...subjectParts] = out.split("\n");
+ return {
+ id: asSnapshotId(hash.trim()),
+ committedAt: Number(committed.trim()) * 1000,
+ message: subjectParts.join("\n").trim(),
+ } satisfies SnapshotMeta;
+ }),
+ );
+
+ return {
+ commit: (files: FileSet, message: string) =>
+ Effect.gen(function* () {
+ const { parent } = yield* commitEnv(message);
+ // Use a throwaway index so we never touch a working tree.
+ const indexFile = join(
+ repoDir,
+ `commit-index-${Date.now()}-${Math.random().toString(36).slice(2)}`,
+ );
+ const withIndex = (args: readonly string[], input?: string | Buffer) =>
+ Effect.callback((resume) => {
+ const child = execFile(
+ "git",
+ args,
+ {
+ cwd: repoDir,
+ maxBuffer: 256 * 1024 * 1024,
+ encoding: "buffer",
+ env: { ...process.env, GIT_INDEX_FILE: indexFile },
+ },
+ (error, stdout, stderr) => {
+ if (error) {
+ resume(
+ Effect.fail(
+ new ArtifactStoreError({
+ message: `git ${args[0]} failed: ${(stderr as Buffer)?.toString() || error.message}`,
+ cause: error,
+ }),
+ ),
+ );
+ return;
+ }
+ resume(Effect.succeed((stdout as Buffer).toString("utf8")));
+ },
+ );
+ if (input !== undefined) {
+ child.stdin?.write(input);
+ child.stdin?.end();
+ }
+ });
+
+ // Start from an empty index each publish (full file set, not a diff).
+ yield* withIndex(["read-tree", "--empty"]);
+ for (const [path, contents] of files) {
+ const blobHash = (yield* withIndex(
+ ["hash-object", "-w", "--stdin"],
+ Buffer.from(contents, "utf8"),
+ )).trim();
+ yield* withIndex(["update-index", "--add", "--cacheinfo", `100644,${blobHash},${path}`]);
+ }
+ const treeHash = (yield* withIndex(["write-tree"])).trim();
+ const commitArgs = ["commit-tree", treeHash, "-m", message];
+ if (parent) commitArgs.push("-p", parent);
+ const commitEnvVars = {
+ ...process.env,
+ GIT_AUTHOR_NAME: "executor-apps",
+ GIT_AUTHOR_EMAIL: "apps@executor.local",
+ GIT_COMMITTER_NAME: "executor-apps",
+ GIT_COMMITTER_EMAIL: "apps@executor.local",
+ };
+ const commitHash = (yield* Effect.callback((resume) => {
+ execFile(
+ "git",
+ commitArgs,
+ { cwd: repoDir, encoding: "buffer", env: commitEnvVars },
+ (error, stdout, stderr) => {
+ if (error) {
+ resume(
+ Effect.fail(
+ new ArtifactStoreError({
+ message: `git commit-tree failed: ${(stderr as Buffer)?.toString() || error.message}`,
+ cause: error,
+ }),
+ ),
+ );
+ return;
+ }
+ resume(Effect.succeed((stdout as Buffer).toString("utf8")));
+ },
+ );
+ })).trim();
+ // Compare-and-swap the branch ref: `update-ref ][ ` fails
+ // if HEAD moved since we read `parent`, so a concurrent publish that
+ // committed first cannot be silently clobbered. On the first commit there
+ // is no parent, so we assert the ref is absent (the empty-oid form). A CAS
+ // failure surfaces as a typed conflict for the caller to retry from a
+ // fresh parent.
+ const expectedOld = parent ?? EMPTY_OID;
+ yield* run(repoDir, ["update-ref", BRANCH, commitHash, expectedOld]).pipe(
+ Effect.mapError(
+ (cause) =>
+ new ArtifactStoreError({
+ message: `publish conflict: ${BRANCH} moved concurrently (expected ${expectedOld}); retry from the new head`,
+ conflict: true,
+ cause,
+ }),
+ ),
+ );
+ return yield* readMeta(commitHash);
+ }),
+
+ read: (id: SnapshotId) =>
+ run(repoDir, ["ls-tree", "-r", "--name-only", id]).pipe(
+ Effect.flatMap((out) => {
+ const paths = out
+ .split("\n")
+ .map((p) => p.trim())
+ .filter(Boolean);
+ return Effect.forEach(
+ paths,
+ (path) =>
+ run(repoDir, ["cat-file", "blob", `${id}:${path}`]).pipe(
+ Effect.map((contents) => [path, contents] as const),
+ ),
+ { concurrency: 8 },
+ );
+ }),
+ Effect.map((entries) => new Map(entries) as FileSet),
+ ),
+
+ readFile: (id: SnapshotId, path: string) =>
+ run(repoDir, ["cat-file", "blob", `${id}:${path}`]).pipe(
+ Effect.map((contents) => contents as string | null),
+ Effect.catch(() => Effect.succeed(null)),
+ ),
+
+ list: (id: SnapshotId) =>
+ run(repoDir, ["ls-tree", "-r", "--name-only", id]).pipe(
+ Effect.map((out) =>
+ out
+ .split("\n")
+ .map((p) => p.trim())
+ .filter(Boolean),
+ ),
+ ),
+
+ latest: () =>
+ headCommit().pipe(Effect.flatMap((head) => (head ? readMeta(head) : Effect.succeed(null)))),
+
+ log: (limit = 50) =>
+ headCommit().pipe(
+ Effect.flatMap((head) => {
+ if (!head) return Effect.succeed([] as readonly SnapshotMeta[]);
+ return run(repoDir, [
+ "log",
+ `--max-count=${limit}`,
+ "--format=%H%x1f%ct%x1f%s",
+ BRANCH,
+ ]).pipe(
+ Effect.map((out) =>
+ out
+ .split("\n")
+ .map((line) => line.trim())
+ .filter(Boolean)
+ .map((line) => {
+ const [hash, committed, subject] = line.split("\x1f");
+ return {
+ id: asSnapshotId(hash),
+ committedAt: Number(committed) * 1000,
+ message: subject ?? "",
+ } satisfies SnapshotMeta;
+ }),
+ ),
+ );
+ }),
+ ),
+ };
+};
+
+export interface GitArtifactStoreOptions {
+ /** Directory holding one bare repo per scope. Created on demand. */
+ readonly root: string;
+}
+
+/** Build the git-backed ArtifactStore. Each scope gets a lazily-initialized
+ * bare repo `/.git`. */
+export const makeGitArtifactStore = (options: GitArtifactStoreOptions): ArtifactStore => {
+ const initialized = new Map>();
+
+ const init = async (scope: string): Promise => {
+ const safe = sanitizeScope(scope);
+ const repoDir = join(options.root, `${safe}.git`);
+ await mkdir(repoDir, { recursive: true });
+ await new Promise((resolve, reject) => {
+ execFile("git", ["init", "--bare", "--quiet"], { cwd: repoDir }, (error) =>
+ error ? reject(error) : resolve(),
+ );
+ });
+ return makeScopeStore(repoDir);
+ };
+
+ return {
+ forScope: (scope) =>
+ Effect.tryPromise({
+ try: () => {
+ let existing = initialized.get(scope);
+ if (!existing) {
+ existing = init(scope);
+ initialized.set(scope, existing);
+ }
+ return existing;
+ },
+ catch: (cause) =>
+ new ArtifactStoreError({ message: `failed to open scope repo: ${scope}`, cause }),
+ }),
+ };
+};
diff --git a/packages/plugins/apps/src/backing/libsql-scope-db.test.ts b/packages/plugins/apps/src/backing/libsql-scope-db.test.ts
new file mode 100644
index 000000000..4ebbebd3a
--- /dev/null
+++ b/packages/plugins/apps/src/backing/libsql-scope-db.test.ts
@@ -0,0 +1,4 @@
+import { scopeDbConformance } from "../seams/scope-db.conformance";
+import { makeLibsqlScopeDb } from "./libsql-scope-db";
+
+scopeDbConformance("libsql (in-memory)", () => makeLibsqlScopeDb({ root: ":memory:" }));
diff --git a/packages/plugins/apps/src/backing/libsql-scope-db.ts b/packages/plugins/apps/src/backing/libsql-scope-db.ts
new file mode 100644
index 000000000..3f8a5e5b6
--- /dev/null
+++ b/packages/plugins/apps/src/backing/libsql-scope-db.ts
@@ -0,0 +1,186 @@
+import { mkdirSync } from "node:fs";
+import { join, resolve } from "node:path";
+
+import { createClient, type Client } from "@libsql/client";
+import { Effect } from "effect";
+
+import { ScopeDbError, type ScopeDb, type ScopeDbHandle } from "../seams/scope-db";
+
+// ---------------------------------------------------------------------------
+// libSQL-backed ScopeDb (self-hosted). One SQLite file per scope under
+// `/.db`. A control table `__versions` holds a monotonic counter
+// per user table; every write statement bumps the counters for the tables it
+// touched. Scope isolation is a separate file per scope, there is no
+// cross-scope query path.
+// ---------------------------------------------------------------------------
+
+const VERSION_TABLE = "__scope_versions";
+
+// Statements that mutate data. Determines whether a `sql` call bumps versions.
+const WRITE_RE = /^\s*(insert|update|delete|replace|create|drop|alter)\b/i;
+
+// Extract the table names a write statement targets (best-effort: covers the
+// shapes authored tools produce — INSERT INTO x, UPDATE x, DELETE FROM x,
+// CREATE TABLE x, REPLACE INTO x).
+const targetsOf = (sql: string): string[] => {
+ const out = new Set();
+ const patterns = [
+ /\binsert\s+(?:or\s+\w+\s+)?into\s+["'`]?([a-zA-Z_][a-zA-Z0-9_]*)["'`]?/gi,
+ /\breplace\s+into\s+["'`]?([a-zA-Z_][a-zA-Z0-9_]*)["'`]?/gi,
+ /\bupdate\s+["'`]?([a-zA-Z_][a-zA-Z0-9_]*)["'`]?/gi,
+ /\bdelete\s+from\s+["'`]?([a-zA-Z_][a-zA-Z0-9_]*)["'`]?/gi,
+ /\bcreate\s+table\s+(?:if\s+not\s+exists\s+)?["'`]?([a-zA-Z_][a-zA-Z0-9_]*)["'`]?/gi,
+ /\bdrop\s+table\s+(?:if\s+exists\s+)?["'`]?([a-zA-Z_][a-zA-Z0-9_]*)["'`]?/gi,
+ /\balter\s+table\s+["'`]?([a-zA-Z_][a-zA-Z0-9_]*)["'`]?/gi,
+ ];
+ for (const re of patterns) {
+ let m: RegExpExecArray | null;
+ while ((m = re.exec(sql)) !== null) {
+ const name = m[1];
+ if (name && name !== VERSION_TABLE) out.add(name);
+ }
+ }
+ return [...out];
+};
+
+const toArgs = (values: readonly unknown[]): unknown[] =>
+ values.map((v) => (v === undefined ? null : v));
+
+const makeHandle = (scope: string, client: Client): ScopeDbHandle => {
+ const ensureVersionTable = async () => {
+ await client.execute(
+ `CREATE TABLE IF NOT EXISTS ${VERSION_TABLE} (name TEXT PRIMARY KEY, version INTEGER NOT NULL DEFAULT 0)`,
+ );
+ };
+
+ const bump = async (tables: readonly string[]): Promise<{ table: string; version: number }[]> => {
+ const bumped: { table: string; version: number }[] = [];
+ for (const table of tables) {
+ await client.execute({
+ sql: `INSERT INTO ${VERSION_TABLE} (name, version) VALUES (?, 1)
+ ON CONFLICT(name) DO UPDATE SET version = version + 1`,
+ args: [table],
+ });
+ const row = await client.execute({
+ sql: `SELECT version FROM ${VERSION_TABLE} WHERE name = ?`,
+ args: [table],
+ });
+ bumped.push({ table, version: Number(row.rows[0]?.version ?? 0) });
+ }
+ return bumped;
+ };
+
+ const runStatement = ](
+ sql: string,
+ args: unknown[],
+ ): Effect.Effect =>
+ Effect.tryPromise({
+ try: async () => {
+ await ensureVersionTable();
+ const result = await client.execute({ sql, args: args as never });
+ if (WRITE_RE.test(sql)) {
+ const targets = targetsOf(sql);
+ if (targets.length > 0) {
+ await bump(targets);
+ }
+ }
+ return result.rows as unknown as readonly Row[];
+ },
+ catch: (cause) =>
+ new ScopeDbError({ message: `scope-db statement failed for scope ${scope}`, cause }),
+ });
+
+ return {
+ sql: >(strings: TemplateStringsArray, ...values: unknown[]) => {
+ const sql = strings.reduce((acc, part, i) => acc + part + (i < values.length ? "?" : ""), "");
+ return runStatement(sql, toArgs(values));
+ },
+ exec: >(sql: string, params: readonly unknown[] = []) =>
+ runStatement(sql, toArgs(params)),
+ tableVersion: (table: string) =>
+ Effect.tryPromise({
+ try: async () => {
+ await ensureVersionTable();
+ const row = await client.execute({
+ sql: `SELECT version FROM ${VERSION_TABLE} WHERE name = ?`,
+ args: [table],
+ });
+ return Number(row.rows[0]?.version ?? 0);
+ },
+ catch: (cause) => new ScopeDbError({ message: "tableVersion failed", cause }),
+ }),
+ versions: () =>
+ Effect.tryPromise({
+ try: async () => {
+ await ensureVersionTable();
+ const rows = await client.execute(`SELECT name, version FROM ${VERSION_TABLE}`);
+ const out = new Map();
+ for (const r of rows.rows) out.set(String(r.name), Number(r.version));
+ return out as ReadonlyMap;
+ },
+ catch: (cause) => new ScopeDbError({ message: "versions failed", cause }),
+ }),
+ };
+};
+
+export interface LibsqlScopeDbOptions {
+ /** Directory holding one SQLite file per scope, or ":memory:" for tests. */
+ readonly root: string;
+}
+
+const toUrl = (path: string): string => (path === ":memory:" ? path : `file:${resolve(path)}`);
+
+// A reversible, collision-free filename key for a scope (Fix 9). A scope already
+// composed only of filename-safe characters is used verbatim (so distinct
+// filename-safe scopes stay distinct — "my-scope" and "my_scope" no longer
+// collide). Any scope containing a character outside that set is hex-encoded in
+// full under an `x-` prefix, which can never collide with a verbatim scope
+// (verbatim scopes never start with `x-` followed by only hex... unless they
+// literally are `x-`, so we also encode those). Two different scopes always
+// produce two different keys.
+const SAFE_SCOPE = /^[A-Za-z0-9._-]+$/;
+const HEX_PREFIXED = /^x-[0-9a-f]*$/;
+const scopeFileKey = (scope: string): string => {
+ if (SAFE_SCOPE.test(scope) && !HEX_PREFIXED.test(scope)) return scope;
+ const hex = Buffer.from(scope, "utf8").toString("hex");
+ return `x-${hex}`;
+};
+
+export const makeLibsqlScopeDb = (options: LibsqlScopeDbOptions): ScopeDb => {
+ const clients = new Map();
+
+ const clientFor = (scope: string): Client => {
+ let client = clients.get(scope);
+ if (!client) {
+ if (options.root === ":memory:") {
+ client = createClient({ url: ":memory:" });
+ } else {
+ mkdirSync(options.root, { recursive: true });
+ // Collision-free filename (Fix 9): the old `replace(/[^..]/g, "_")`
+ // COLLAPSED distinct scopes to the same file ("my-scope" and "my_scope"
+ // -> "my_scope.db"), so two scopes shared one database. Encode the raw
+ // scope instead: keep the common safe-identifier case readable, and for
+ // anything with a character outside `[A-Za-z0-9._-]` fall back to a
+ // reversible hex encoding of the full raw scope. Distinct scopes always
+ // map to distinct filenames.
+ const safe = scopeFileKey(scope);
+ client = createClient({ url: toUrl(join(options.root, `${safe}.db`)) });
+ }
+ clients.set(scope, client);
+ }
+ return client;
+ };
+
+ return {
+ forScope: (scope) =>
+ Effect.try({
+ try: () => makeHandle(scope, clientFor(scope)),
+ catch: (cause) => new ScopeDbError({ message: `failed to open scope db ${scope}`, cause }),
+ }),
+ close: () =>
+ Effect.sync(() => {
+ for (const client of clients.values()) client.close();
+ clients.clear();
+ }),
+ };
+};
diff --git a/packages/plugins/apps/src/backing/quickjs-tool-sandbox.conformance.test.ts b/packages/plugins/apps/src/backing/quickjs-tool-sandbox.conformance.test.ts
new file mode 100644
index 000000000..53e0a9a66
--- /dev/null
+++ b/packages/plugins/apps/src/backing/quickjs-tool-sandbox.conformance.test.ts
@@ -0,0 +1,7 @@
+import { toolSandboxConformance } from "../seams/tool-sandbox.conformance";
+import { makeQuickjsToolSandbox } from "./quickjs-tool-sandbox";
+
+// Short timeout so the "kills a runaway handler" case finishes quickly.
+toolSandboxConformance("quickjs", () =>
+ makeQuickjsToolSandbox({ collectTimeoutMs: 5_000, invokeTimeoutMs: 2_000 }),
+);
diff --git a/packages/plugins/apps/src/backing/quickjs-tool-sandbox.test.ts b/packages/plugins/apps/src/backing/quickjs-tool-sandbox.test.ts
new file mode 100644
index 000000000..7e212214f
--- /dev/null
+++ b/packages/plugins/apps/src/backing/quickjs-tool-sandbox.test.ts
@@ -0,0 +1,125 @@
+import { describe, expect, it } from "vitest";
+import { Effect } from "effect";
+
+import { bundleEntry } from "../pipeline/bundle";
+import { makeQuickjsToolSandbox } from "./quickjs-tool-sandbox";
+import { ISSUES_SYNC_TS, SEARCH_ALL_MAIL_TS } from "../testing/daily-brief";
+import type { HandleBridge } from "../seams/tool-sandbox";
+
+const run = (effect: Effect.Effect): Promise => Effect.runPromise(effect);
+
+describe("QuickJS ToolSandbox", () => {
+ const sandbox = makeQuickjsToolSandbox();
+
+ it("bundles + collects a tool descriptor (deterministic)", async () => {
+ const files = new Map([["tools/issues-sync.ts", ISSUES_SYNC_TS]]);
+ const { code } = await run(bundleEntry({ files, entry: "tools/issues-sync.ts" }));
+ const result = await run(sandbox.collect(code));
+ const descriptor = result.artifacts.default.descriptor as {
+ kind: string;
+ connections: Record;
+ inputJsonSchema: { type: string; properties: Record };
+ hasHandler: boolean;
+ };
+ expect(descriptor.kind).toBe("tool");
+ expect(descriptor.connections.github).toEqual(
+ expect.objectContaining({ decl: "single", integration: "github" }),
+ );
+ expect(descriptor.hasHandler).toBe(true);
+ expect(descriptor.inputJsonSchema.type).toBe("object");
+ expect(descriptor.inputJsonSchema.properties).toHaveProperty("repos");
+ expect(descriptor.inputJsonSchema.properties).toHaveProperty("since");
+ });
+
+ it("collects a fan-out (connections) declaration", async () => {
+ const files = new Map([["tools/search-all-mail.ts", SEARCH_ALL_MAIL_TS]]);
+ const { code } = await run(bundleEntry({ files, entry: "tools/search-all-mail.ts" }));
+ const result = await run(sandbox.collect(code));
+ const descriptor = result.artifacts.default.descriptor as {
+ connections: Record;
+ };
+ expect(descriptor.connections.inboxes).toEqual(
+ expect.objectContaining({ decl: "array", integration: "gmail" }),
+ );
+ });
+
+ it("invokes a tool, routing injected-client + db calls through the bridge", async () => {
+ const files = new Map([["tools/issues-sync.ts", ISSUES_SYNC_TS]]);
+ const { code } = await run(bundleEntry({ files, entry: "tools/issues-sync.ts" }));
+
+ const calls: { root: string; path: readonly string[]; args: readonly unknown[] }[] = [];
+ const bridge: HandleBridge = {
+ call: ({ root, path, args }) =>
+ Effect.sync(() => {
+ calls.push({ root, path, args });
+ const key = `${root}.${path.join(".")}`;
+ if (key === "github.repos.listForAuthenticatedUser") {
+ return [{ full_name: "acme/app" }];
+ }
+ if (key === "github.issues.listForRepo") {
+ return [
+ {
+ number: 1,
+ title: "Bug",
+ labels: [{ name: "bug" }],
+ assignee: { login: "rhys" },
+ updated_at: "2026-01-01T00:00:00Z",
+ html_url: "https://github.com/acme/app/issues/1",
+ },
+ ];
+ }
+ // db.sql calls return empty rows
+ return [];
+ }),
+ };
+
+ const result = await run(
+ sandbox.invoke(
+ code,
+ {
+ artifact: "issues-sync",
+ kind: "tool",
+ input: {},
+ roots: { github: { kind: "single" }, db: { kind: "single" } },
+ },
+ bridge,
+ ),
+ );
+
+ expect(result.output).toEqual({ synced: 1, repos: 1 });
+ // The db write went through the bridge (root "db").
+ expect(calls.some((c) => c.root === "db")).toBe(true);
+ expect(calls.some((c) => c.root === "github")).toBe(true);
+ });
+
+ it("rejects a non-deterministic descriptor (Math.random at describe time)", async () => {
+ const nondeterministic = `import { defineTool } from "executor:app";
+import { z } from "zod";
+export default defineTool({
+ description: "x-" + Math.random(),
+ input: z.object({}),
+ async handler() { return {}; },
+});`;
+ const files = new Map([["tools/rng.ts", nondeterministic]]);
+ const { code } = await run(bundleEntry({ files, entry: "tools/rng.ts" }));
+ const exit = await Effect.runPromiseExit(sandbox.collect(code));
+ expect(exit._tag).toBe("Failure");
+ });
+
+ it("denies network (fetch throws in the sandbox)", async () => {
+ const usesFetch = `import { defineTool } from "executor:app";
+import { z } from "zod";
+export default defineTool({
+ description: "fetches",
+ input: z.object({}),
+ async handler() { await fetch("https://example.com"); return {}; },
+});`;
+ const files = new Map([["tools/net.ts", usesFetch]]);
+ const { code } = await run(bundleEntry({ files, entry: "tools/net.ts" }));
+ const bridge: HandleBridge = { call: () => Effect.succeed(null) };
+ const exit = await Effect.runPromiseExit(
+ sandbox.invoke(code, { artifact: "net", kind: "tool", input: {}, roots: {} }, bridge),
+ );
+ expect(exit._tag).toBe("Failure");
+ });
+});
diff --git a/packages/plugins/apps/src/backing/quickjs-tool-sandbox.ts b/packages/plugins/apps/src/backing/quickjs-tool-sandbox.ts
new file mode 100644
index 000000000..de298f6dc
--- /dev/null
+++ b/packages/plugins/apps/src/backing/quickjs-tool-sandbox.ts
@@ -0,0 +1,252 @@
+import { Effect } from "effect";
+import type { SandboxToolInvoker } from "@executor-js/codemode-core";
+import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs";
+
+import {
+ ToolSandboxError,
+ type CollectResult,
+ type HandleBridge,
+ type InvokeRequest,
+ type InvokeResult,
+ type ToolSandbox,
+} from "../seams/tool-sandbox";
+import { stableStringify } from "../pipeline/descriptor";
+
+// ---------------------------------------------------------------------------
+// QuickJS-backed ToolSandbox (self-hosted).
+//
+// The published bundle is a CJS string. The sandbox body prepends a `require`
+// shim providing `executor:app` (defineTool/connection/connections/catalog),
+// then executes the bundle so `module.exports.default` is the artifact. A
+// driver appended after the bundle either collects the descriptor (nothing
+// bound) or runs one handler with injected clients.
+//
+// Injected clients are Proxies that turn `github.issues.listForRepo(args)` into
+// `await __invokeTool("__handle__", { root, path, args })` — the ONE bridge the
+// QuickJS runtime already provides (`tools`/`__invokeTool`). Our
+// `SandboxToolInvoker` decodes that and forwards to the host `HandleBridge`.
+// Everything crossing is JSON (the cloud version is RPC), so the interface
+// stays honest.
+//
+// Determinism: `collect` runs the collection body twice and byte-compares the
+// descriptor JSON. Effectful top-levels (Math.random, Date.now) diverge and are
+// rejected. QuickJS denies `fetch` and enforces a deadline + memory cap.
+// ---------------------------------------------------------------------------
+
+const COLLECT_TIMEOUT_MS = 10_000;
+const INVOKE_TIMEOUT_MS = 30_000;
+
+// The shim + module system injected before the bundle. Kept as a plain string
+// (QuickJS evals a string). `defineTool` records its def so the driver can read
+// it back. Clients are built by `__mkHandle`.
+const runtimePrelude = `
+var __modules = {};
+var __defs = { tool: null };
+function __recordDefault(mod) { return mod; }
+var __handleBridge = function(root, path, args) {
+ // Route every injected-client method call through the single tool bridge.
+ return tools.__handle__({ root: root, path: path, args: args });
+};
+function __mkHandle(root, prefix) {
+ var target = function(){};
+ return new Proxy(target, {
+ get: function(_t, prop) {
+ if (prop === 'then' || typeof prop === 'symbol') return undefined;
+ return __mkHandle(root, prefix.concat([String(prop)]));
+ },
+ apply: function(_t, _this, callArgs) {
+ return __handleBridge(root, prefix, callArgs);
+ }
+ });
+}
+var __executorApp = {
+ connection: function(integration, opts) { return { __decl: 'single', integration: integration, description: opts && opts.description }; },
+ connections: function(integration, opts) { return { __decl: 'array', integration: integration, description: opts && opts.description }; },
+ catalog: function() { return { __decl: 'catalog' }; },
+ defineTool: function(def) { __defs.tool = def; return def; },
+};
+function __require(id) {
+ if (id === 'executor:app') return __executorApp;
+ throw new Error('module not available in sandbox: ' + id);
+}
+`;
+
+// Run the CJS bundle. The bundle's virtual entry sets `globalThis.__artifact`
+// (the def object returned by defineTool), `globalThis.__zodToJson` and
+// `__zodToJsonOutput` (bound to the author's inlined zod). `require` is our
+// shim; `defineTool` also records into `__defs` as a fallback.
+const wrapBundle = (bundle: string): string => `
+var module = { exports: {} };
+var exports = module.exports;
+var require = __require;
+(function(module, exports, require){
+${bundle}
+})(module, exports, require);
+`;
+
+// Collect driver: describe the artifact's connections + input/output schema.
+// Deterministic JSON only — no handler execution.
+const collectDriver = `
+return await (async () => {
+ var def = __defs.tool || (globalThis.__artifact && (globalThis.__artifact.default || globalThis.__artifact));
+ var conns = {};
+ if (def && def.connections) {
+ for (var k in def.connections) {
+ var c = def.connections[k];
+ conns[k] = { decl: c && c.__decl ? c.__decl : 'single', integration: c && c.integration, description: c && c.description };
+ }
+ }
+ var toJson = (typeof globalThis.__zodToJson === 'function') ? globalThis.__zodToJson : function(){ return undefined; };
+ var toJsonOut = (typeof globalThis.__zodToJsonOutput === 'function') ? globalThis.__zodToJsonOutput : function(){ return undefined; };
+ return {
+ kind: 'tool',
+ description: def && def.description,
+ connections: conns,
+ annotations: def && def.annotations,
+ hasHandler: !!(def && def.handler),
+ inputJsonSchema: def ? toJson(def.input) : undefined,
+ outputJsonSchema: def ? toJsonOut(def.output) : undefined,
+ };
+})()
+`;
+
+const buildCollectCode = (bundle: string): string =>
+ runtimePrelude + wrapBundle(bundle) + collectDriver;
+
+// Invoke driver: build injected clients from the request roots, then call the
+// artifact's handler with (input, injected). Fan-out arrays become arrays of
+// element handles.
+const buildInvokeDriver = (request: InvokeRequest): string => {
+ const rootsLiteral = JSON.stringify(request.roots);
+ const inputLiteral = JSON.stringify(request.input ?? {});
+ return `
+return await (async () => {
+ var def = __defs.tool || (globalThis.__artifact && (globalThis.__artifact.default || globalThis.__artifact));
+ if (!def || typeof def.handler !== 'function') throw new Error('artifact has no handler: ${request.artifact}');
+ var roots = ${rootsLiteral};
+ var injected = {};
+ for (var name in roots) {
+ var spec = roots[name];
+ if (spec.kind === 'array') {
+ var arr = [];
+ for (var i = 0; i < spec.count; i++) arr.push(__mkHandle(name + '#' + i, []));
+ injected[name] = arr;
+ } else {
+ injected[name] = __mkHandle(name, []);
+ }
+ }
+ var out = await def.handler(${inputLiteral}, injected);
+ return out;
+})()
+`;
+};
+
+const buildInvokeCode = (bundle: string, request: InvokeRequest): string =>
+ runtimePrelude + wrapBundle(bundle) + buildInvokeDriver(request);
+
+// A no-op invoker for collect: no handle calls should happen; if they do
+// (misbehaving describe path), fail loudly.
+const collectInvoker: SandboxToolInvoker = {
+ invoke: () => Effect.fail(new Error("collect must not make handle calls")) as never,
+};
+
+export interface QuickjsToolSandboxOptions {
+ readonly collectTimeoutMs?: number;
+ readonly invokeTimeoutMs?: number;
+}
+
+export const makeQuickjsToolSandbox = (options: QuickjsToolSandboxOptions = {}): ToolSandbox => {
+ const collectExecutor = makeQuickJsExecutor({
+ timeoutMs: options.collectTimeoutMs ?? COLLECT_TIMEOUT_MS,
+ });
+ const invokeExecutor = makeQuickJsExecutor({
+ timeoutMs: options.invokeTimeoutMs ?? INVOKE_TIMEOUT_MS,
+ });
+
+ const runCollect = (bundle: string): Effect.Effect =>
+ collectExecutor.execute(buildCollectCode(bundle), collectInvoker).pipe(
+ Effect.mapError(
+ (cause) => new ToolSandboxError({ kind: "collect", message: "collect run failed", cause }),
+ ),
+ Effect.flatMap((result) => {
+ if (result.error) {
+ return Effect.fail(new ToolSandboxError({ kind: "collect", message: result.error }));
+ }
+ return Effect.succeed(result.result);
+ }),
+ );
+
+ return {
+ collect: (bundle: string) =>
+ Effect.gen(function* () {
+ // Run twice, byte-compare (determinism gate). Key-sorted stringify so a
+ // false mismatch never comes from property-order luck — a real
+ // divergence (Math.random / Date.now) still fails.
+ const first = yield* runCollect(bundle);
+ const second = yield* runCollect(bundle);
+ const a = stableStringify(first);
+ const b = stableStringify(second);
+ if (a !== b) {
+ return yield* Effect.fail(
+ new ToolSandboxError({
+ kind: "nondeterministic",
+ message:
+ "descriptor collection is non-deterministic (an artifact read Math.random/Date.now or otherwise diverged between runs)",
+ }),
+ );
+ }
+ const descriptor = first as { artifact?: string };
+ const result: CollectResult = {
+ artifacts: {
+ [String(descriptor.artifact ?? "default")]: {
+ kind: "tool",
+ descriptor: first,
+ },
+ },
+ };
+ return result;
+ }),
+
+ invoke: (bundle: string, request: InvokeRequest, bridge: HandleBridge) =>
+ Effect.gen(function* () {
+ // The invoker decodes the routed handle call and forwards to the host
+ // bridge. Path 0 is `__handle__`; the single arg is {root, path, args}.
+ const invoker: SandboxToolInvoker = {
+ invoke: (input: { path: string; args: unknown }) => {
+ // Strictness (grafted from A): the ONLY reserved bridge path the
+ // invoke phase accepts is `__handle__`. Anything else is a hard
+ // error, never silently ignored — a handler must not reach the host
+ // through an unexpected channel.
+ if (input.path !== "__handle__") {
+ return Effect.fail(
+ new Error(`unexpected sandbox bridge path: ${input.path}`),
+ ) as never;
+ }
+ const call = input.args as {
+ root: string;
+ path: readonly string[];
+ args: readonly unknown[];
+ };
+ if (!call || typeof call.root !== "string" || !Array.isArray(call.path)) {
+ return Effect.fail(new Error("malformed sandbox bridge call")) as never;
+ }
+ return bridge.call({ root: call.root, path: call.path, args: call.args }) as never;
+ },
+ };
+ const result = yield* invokeExecutor
+ .execute(buildInvokeCode(bundle, request), invoker)
+ .pipe(
+ Effect.mapError(
+ (cause) =>
+ new ToolSandboxError({ kind: "invoke", message: "invoke run failed", cause }),
+ ),
+ );
+ if (result.error) {
+ return yield* Effect.fail(
+ new ToolSandboxError({ kind: "invoke", message: result.error }),
+ );
+ }
+ return { output: result.result, logs: result.logs ?? [] } satisfies InvokeResult;
+ }),
+ };
+};
diff --git a/packages/plugins/apps/src/backing/scope-collision.test.ts b/packages/plugins/apps/src/backing/scope-collision.test.ts
new file mode 100644
index 000000000..23db1c6c9
--- /dev/null
+++ b/packages/plugins/apps/src/backing/scope-collision.test.ts
@@ -0,0 +1,62 @@
+import { mkdtempSync, readdirSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+
+import { describe, expect, it } from "vitest";
+import { Effect } from "effect";
+
+import { makeLibsqlScopeDb } from "./libsql-scope-db";
+import { makeSqliteAppsStore } from "./sqlite-apps-store";
+
+const run = (effect: Effect.Effect): Promise => Effect.runPromise(effect);
+
+// ---------------------------------------------------------------------------
+// Finding 9 regression: scopes that differ only by a character the old naming
+// collapsed ("my-scope" vs "my_scope") must NOT share a database, and the
+// connection->scope mapping must route each to its own scope. Before the fix the
+// scope-db filename collapsed `[^A-Za-z0-9._-]` to `_` (colliding two scopes onto
+// one file) and the connection name was reverse-parsed (colliding two scopes onto
+// one normalized identifier).
+// ---------------------------------------------------------------------------
+
+describe("scope collision (Fix 9)", () => {
+ it("gives my-scope and my_scope DISTINCT scope-db files with isolated data", async () => {
+ const root = mkdtempSync(join(tmpdir(), "apps-scopecol-"));
+ const db = makeLibsqlScopeDb({ root });
+
+ const a = await run(db.forScope("my-scope"));
+ await run(a.exec("CREATE TABLE t (v TEXT)"));
+ await run(a.exec("INSERT INTO t (v) VALUES ('a')"));
+
+ const b = await run(db.forScope("my_scope"));
+ await run(b.exec("CREATE TABLE t (v TEXT)"));
+ await run(b.exec("INSERT INTO t (v) VALUES ('b')"));
+
+ // Each scope sees ONLY its own row (no shared file).
+ const aRows = await run(a.exec<{ v: string }>("SELECT v FROM t"));
+ const bRows = await run(b.exec<{ v: string }>("SELECT v FROM t"));
+ expect(aRows.map((r) => r.v)).toEqual(["a"]);
+ expect(bRows.map((r) => r.v)).toEqual(["b"]);
+
+ // Two distinct .db files exist on disk.
+ const dbFiles = readdirSync(root).filter((f) => f.endsWith(".db"));
+ expect(dbFiles.length).toBe(2);
+
+ await run(db.close());
+ });
+
+ it("routes distinct scopes through the explicit connection->scope mapping", async () => {
+ const store = makeSqliteAppsStore({ path: ":memory:" });
+ // Both scopes' connection names normalize to the SAME identifier
+ // ("appsMyScope"), but the explicit mapping keys by the ACTUAL stored name,
+ // so each scope's connection maps to the right scope. Simulate two DISTINCT
+ // connection names being recorded for two distinct scopes.
+ await run(store.putScopeForConnection("appsMyScopeDash", "my-scope"));
+ await run(store.putScopeForConnection("appsMyScopeUnderscore", "my_scope"));
+
+ expect(await run(store.getScopeForConnection("appsMyScopeDash"))).toBe("my-scope");
+ expect(await run(store.getScopeForConnection("appsMyScopeUnderscore"))).toBe("my_scope");
+ // An unmapped name returns null (caller falls back to legacy parse).
+ expect(await run(store.getScopeForConnection("unknown"))).toBeNull();
+ });
+});
diff --git a/packages/plugins/apps/src/backing/sqlite-apps-store.ts b/packages/plugins/apps/src/backing/sqlite-apps-store.ts
new file mode 100644
index 000000000..a1bf37b4b
--- /dev/null
+++ b/packages/plugins/apps/src/backing/sqlite-apps-store.ts
@@ -0,0 +1,98 @@
+import { mkdirSync } from "node:fs";
+import { dirname, resolve } from "node:path";
+
+import { createClient, type Client } from "@libsql/client";
+import { Effect } from "effect";
+
+import type { StorageFailure } from "@executor-js/sdk";
+
+import type { AppDescriptor } from "../pipeline/descriptor";
+import type { AppsStore } from "../plugin/store";
+
+// ---------------------------------------------------------------------------
+// SQLite-backed AppsStore (self-hosted). One small SQLite file stores the
+// published descriptor per scope and the exact connection-to-scope mapping.
+// ---------------------------------------------------------------------------
+
+const toUrl = (path: string): string => (path === ":memory:" ? path : `file:${resolve(path)}`);
+
+const SCHEMA = `
+CREATE TABLE IF NOT EXISTS descriptors (scope TEXT PRIMARY KEY, snapshot_id TEXT NOT NULL, descriptor TEXT NOT NULL, published_at INTEGER NOT NULL);
+CREATE TABLE IF NOT EXISTS scope_connections (connection_name TEXT PRIMARY KEY, scope TEXT NOT NULL);
+`;
+
+const storageFail = (message: string, cause: unknown): StorageFailure =>
+ ({ _tag: "StorageError", message, cause }) as unknown as StorageFailure;
+
+export interface SqliteAppsStoreOptions {
+ readonly path: string;
+}
+
+export const makeSqliteAppsStore = (options: SqliteAppsStoreOptions): AppsStore => {
+ if (options.path !== ":memory:") mkdirSync(dirname(resolve(options.path)), { recursive: true });
+ const client: Client = createClient({ url: toUrl(options.path) });
+ let ready: Promise | undefined;
+ const init = async () => {
+ if (!ready) {
+ ready = (async () => {
+ for (const stmt of SCHEMA.split(";")) {
+ const s = stmt.trim();
+ if (s) await client.execute(s);
+ }
+ })();
+ }
+ return ready;
+ };
+
+ return {
+ putDescriptor: (_owner, descriptor) =>
+ Effect.tryPromise({
+ try: async () => {
+ await init();
+ await client.execute({
+ sql: `INSERT INTO descriptors (scope, snapshot_id, descriptor, published_at)
+ VALUES (?, ?, ?, ?)
+ ON CONFLICT(scope) DO UPDATE SET snapshot_id=excluded.snapshot_id, descriptor=excluded.descriptor, published_at=excluded.published_at`,
+ args: [descriptor.scope, descriptor.snapshotId, JSON.stringify(descriptor), Date.now()],
+ });
+ },
+ catch: (cause) => storageFail("putDescriptor failed", cause),
+ }),
+ getDescriptor: (scope) =>
+ Effect.tryPromise({
+ try: async () => {
+ await init();
+ const res = await client.execute({
+ sql: "SELECT descriptor FROM descriptors WHERE scope = ?",
+ args: [scope],
+ });
+ const row = res.rows[0];
+ return row ? (JSON.parse(String(row.descriptor)) as AppDescriptor) : null;
+ },
+ catch: (cause) => storageFail("getDescriptor failed", cause),
+ }),
+ putScopeForConnection: (connectionName, scope) =>
+ Effect.tryPromise({
+ try: async () => {
+ await init();
+ await client.execute({
+ sql: "INSERT INTO scope_connections (connection_name, scope) VALUES (?, ?) ON CONFLICT(connection_name) DO UPDATE SET scope=excluded.scope",
+ args: [connectionName, scope],
+ });
+ },
+ catch: (cause) => storageFail("putScopeForConnection failed", cause),
+ }),
+ getScopeForConnection: (connectionName) =>
+ Effect.tryPromise({
+ try: async () => {
+ await init();
+ const res = await client.execute({
+ sql: "SELECT scope FROM scope_connections WHERE connection_name = ?",
+ args: [connectionName],
+ });
+ return res.rows[0] ? String(res.rows[0].scope) : null;
+ },
+ catch: (cause) => storageFail("getScopeForConnection failed", cause),
+ }),
+ };
+};
diff --git a/packages/plugins/apps/src/index.ts b/packages/plugins/apps/src/index.ts
new file mode 100644
index 000000000..3d408c04e
--- /dev/null
+++ b/packages/plugins/apps/src/index.ts
@@ -0,0 +1,45 @@
+// @executor-js/plugin-apps — custom tool publishing for executor.
+//
+// Custom tools are published into a per-scope store and invoked through the
+// platform catalog path. Publish is the compiler (FDI); each substrate-specific
+// capability sits behind a seam.
+
+export * from "./seams";
+export * from "./pipeline/descriptor";
+export { discover, PublishError } from "./pipeline/discover";
+export { bundleEntry, PLATFORM_MODULES, INLINABLE_MODULES } from "./pipeline/bundle";
+export {
+ publish,
+ type PublishInput,
+ type PublishOutput,
+ type PublishDeps,
+} from "./pipeline/publish";
+
+export { makeAppsRuntime, type AppsRuntime, type AppsRuntimeDeps } from "./plugin/runtime";
+export {
+ makeAppsStore,
+ type AppsStore,
+ type AppsStoreDeps,
+ descriptorCollection,
+ scopeConnectionCollection,
+} from "./plugin/store";
+export {
+ buildBridge,
+ rootsFor,
+ BindingError,
+ type Bindings,
+ type RoleBinding,
+ type ClientResolver,
+ type BindingContext,
+} from "./plugin/bindings";
+export {
+ makeSelfHostAppsRuntime,
+ type SelfHostAppsRuntime,
+ type SelfHostAppsRuntimeOptions,
+} from "./plugin/self-host-runtime";
+
+// Self-host seam backings.
+export { makeGitArtifactStore } from "./backing/git-artifact-store";
+export { makeLibsqlScopeDb } from "./backing/libsql-scope-db";
+export { makeQuickjsToolSandbox } from "./backing/quickjs-tool-sandbox";
+export { makeSqliteAppsStore } from "./backing/sqlite-apps-store";
diff --git a/packages/plugins/apps/src/pipeline/bundle.ts b/packages/plugins/apps/src/pipeline/bundle.ts
new file mode 100644
index 000000000..247a1ceda
--- /dev/null
+++ b/packages/plugins/apps/src/pipeline/bundle.ts
@@ -0,0 +1,214 @@
+import { build, version as esbuildVersion } from "esbuild";
+
+import { Effect } from "effect";
+
+import { ToolSandboxError } from "../seams/tool-sandbox";
+import type { ToolchainRef } from "./descriptor";
+
+const BUNDLE_TARGET = "es2022";
+
+/** The toolchain (esbuild version + target) recorded into the descriptor so a
+ * re-collect on a different esbuild is not falsely claimed byte-identical. */
+export const toolchainRef = (): ToolchainRef => ({
+ bundler: "esbuild",
+ bundlerVersion: esbuildVersion,
+ target: BUNDLE_TARGET,
+});
+
+// ---------------------------------------------------------------------------
+// bundle — the FDI pipeline's bundle stage.
+//
+// One esbuild pass per artifact entry. The platform module (`executor:app`)
+// stays EXTERNAL, the sandbox shim provides the real behavior. A small allowlist
+// of schema libraries (`zod`, `@standard-schema/spec`) is INLINED from
+// node_modules so the author's schema runs in the sandbox AND its `toJSONSchema`
+// is reachable to extract the descriptor's input/output JSON Schema. Every
+// other bare import is rejected with a diagnostic: npm deps in user bundles are
+// out of scope.
+//
+// Output is a single CJS string with the externals left as `require(...)`
+// calls the sandbox resolves.
+// ---------------------------------------------------------------------------
+
+/** Modules the platform provides inside the sandbox (kept external, resolved by
+ * the sandbox `require` shim). */
+export const PLATFORM_MODULES = new Set(["executor:app"]);
+
+/** npm modules we deliberately inline (schema runtime that must run in the
+ * sandbox). Everything else bare is rejected. */
+export const INLINABLE_MODULES = new Set(["zod", "@standard-schema/spec"]);
+
+const isInlinable = (path: string): boolean => {
+ if (INLINABLE_MODULES.has(path)) return true;
+ // zod subpaths (`zod/v4`, `zod/mini`) are fine too.
+ return [...INLINABLE_MODULES].some((m) => path === m || path.startsWith(`${m}/`));
+};
+
+export interface BundleInput {
+ readonly files: ReadonlyMap;
+ readonly entry: string;
+}
+
+export interface BundleOutput {
+ readonly code: string;
+}
+
+const resolveRelative = (base: string, rel: string): string => {
+ const parts = (base ? base.split("/") : []).concat(rel.split("/"));
+ const out: string[] = [];
+ for (const part of parts) {
+ if (part === "" || part === ".") continue;
+ if (part === "..") out.pop();
+ else out.push(part);
+ }
+ return out.join("/");
+};
+
+const candidates = (path: string): string[] => {
+ if (/\.(tsx?|jsx?)$/.test(path)) return [path];
+ return [
+ path,
+ `${path}.ts`,
+ `${path}.tsx`,
+ `${path}.js`,
+ `${path}.jsx`,
+ `${path}/index.ts`,
+ `${path}/index.tsx`,
+ ];
+};
+
+const FILESET_NS = "fileset";
+const VIRTUAL_ENTRY = "executor-apps://entry";
+
+/** The virtual entry esbuild starts from: re-export the author entry AND expose
+ * the shared zod instance as a global so the sandbox collect driver can call
+ * `toJSONSchema` against the SAME zod the author bundled. */
+const virtualEntrySource = (authorEntry: string) => `
+import __artifactModule from ${JSON.stringify(`/${authorEntry}`)};
+import * as __zod from "zod";
+globalThis.__artifact = __artifactModule;
+globalThis.__zodToJson = function (schema) {
+ if (!schema) return undefined;
+ try {
+ return __zod.toJSONSchema(schema, { io: "input", unrepresentable: "any" });
+ } catch (e) {
+ try { return __zod.toJSONSchema(schema); } catch (e2) { return {}; }
+ }
+};
+globalThis.__zodToJsonOutput = function (schema) {
+ if (!schema) return undefined;
+ try {
+ return __zod.toJSONSchema(schema, { io: "output", unrepresentable: "any" });
+ } catch (e) {
+ try { return __zod.toJSONSchema(schema); } catch (e2) { return {}; }
+ }
+};
+`;
+
+const fileSetPlugin = (files: ReadonlyMap, authorEntry: string) => ({
+ name: "executor-apps-fileset",
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ setup(build2: any) {
+ const norm = (p: string) => p.replace(/^\.\//, "").replace(/\/\.\//g, "/");
+
+ build2.onResolve(
+ { filter: /.*/ },
+ (args: { path: string; importer: string; namespace?: string; resolveDir?: string }) => {
+ if (args.path === VIRTUAL_ENTRY) {
+ return { path: VIRTUAL_ENTRY, namespace: "virtual" };
+ }
+ // Imports originating OUTSIDE the file set (from inlined npm modules
+ // like zod, or the virtual entry's `import "zod"`) are left to
+ // esbuild's default node resolution. We only govern file-set imports.
+ const fromFileSet = args.namespace === FILESET_NS || args.namespace === "virtual";
+ if (!fromFileSet) return undefined;
+
+ if (PLATFORM_MODULES.has(args.path)) {
+ return { path: args.path, external: true };
+ }
+ if (isInlinable(args.path)) {
+ // Inline from node_modules via default resolution.
+ return undefined;
+ }
+ // Absolute (virtual entry uses `/tools/x.ts`) or relative -> file set.
+ if (args.path.startsWith("/")) {
+ const joined = norm(args.path.slice(1));
+ for (const candidate of candidates(joined)) {
+ if (files.has(candidate)) return { path: candidate, namespace: FILESET_NS };
+ }
+ return { errors: [{ text: `cannot resolve "${args.path}" in the app file set` }] };
+ }
+ if (args.path.startsWith(".")) {
+ const base = args.importer.split("/").slice(0, -1).join("/");
+ const joined = norm(resolveRelative(base, args.path));
+ for (const candidate of candidates(joined)) {
+ if (files.has(candidate)) return { path: candidate, namespace: FILESET_NS };
+ }
+ return {
+ errors: [
+ { text: `cannot resolve "${args.path}" from "${args.importer}" in the app file set` },
+ ],
+ };
+ }
+ return {
+ errors: [
+ {
+ text: `bare import "${args.path}" is not allowed: apps may only import platform modules (${[...PLATFORM_MODULES].join(", ")}), the schema runtime (zod), or files within the scope`,
+ },
+ ],
+ };
+ },
+ );
+
+ build2.onLoad({ filter: /.*/, namespace: "virtual" }, () => ({
+ contents: virtualEntrySource(authorEntry),
+ loader: "ts",
+ resolveDir: process.cwd(),
+ }));
+
+ build2.onLoad({ filter: /.*/, namespace: FILESET_NS }, (args: { path: string }) => {
+ const contents = files.get(args.path);
+ if (contents === undefined) {
+ return { errors: [{ text: `missing file in set: ${args.path}` }] };
+ }
+ const loader = args.path.endsWith(".tsx")
+ ? "tsx"
+ : args.path.endsWith(".ts")
+ ? "ts"
+ : args.path.endsWith(".jsx")
+ ? "jsx"
+ : "js";
+ return { contents, loader, resolveDir: process.cwd() };
+ });
+ },
+});
+
+/** Bundle one entry from the file set to a single CJS string. */
+export const bundleEntry = (input: BundleInput): Effect.Effect =>
+ Effect.tryPromise({
+ try: async () => {
+ const result = await build({
+ entryPoints: [VIRTUAL_ENTRY],
+ bundle: true,
+ write: false,
+ format: "cjs",
+ platform: "neutral",
+ target: BUNDLE_TARGET,
+ minify: false,
+ treeShaking: true,
+ jsx: "automatic",
+ logLevel: "silent",
+ external: [...PLATFORM_MODULES],
+ plugins: [fileSetPlugin(input.files, input.entry) as never],
+ });
+ const out = result.outputFiles?.[0]?.text;
+ if (out === undefined) throw new Error("esbuild produced no output");
+ return { code: out };
+ },
+ catch: (cause) =>
+ new ToolSandboxError({
+ kind: "bundle",
+ message: `bundle failed for ${input.entry}: ${cause instanceof Error ? cause.message : String(cause)}`,
+ cause,
+ }),
+ });
diff --git a/packages/plugins/apps/src/pipeline/descriptor.ts b/packages/plugins/apps/src/pipeline/descriptor.ts
new file mode 100644
index 000000000..596f101e8
--- /dev/null
+++ b/packages/plugins/apps/src/pipeline/descriptor.ts
@@ -0,0 +1,101 @@
+// ---------------------------------------------------------------------------
+// The versioned app descriptor, extracted from source at publish.
+//
+// Catalog rows are projections of this descriptor. Identity is the file path:
+// there are no authored `name` or `id` fields.
+// ---------------------------------------------------------------------------
+
+/** Descriptor schema version. Bumped on any breaking shape change. A reader
+ * refuses a descriptor from a version it does not understand. */
+export const DESCRIPTOR_VERSION = 1 as const;
+
+/** Where an entry came from: path + content hash. Lets a projection point back
+ * at the exact source bytes without re-reading the snapshot, and makes the
+ * determinism byte-compare include per-entry provenance. (Grafted from C.) */
+export interface ModuleSourceRef {
+ /** Path within the scope repo, e.g. `tools/issues-sync.ts`. */
+ readonly path: string;
+ /** SHA-256 of the source bytes (hex). */
+ readonly sourceHash: string;
+}
+
+/** The toolchain that produced the bundles, recorded so a re-collect on a
+ * different esbuild is not falsely claimed byte-identical. (Grafted from C.) */
+export interface ToolchainRef {
+ readonly bundler: "esbuild";
+ readonly bundlerVersion: string;
+ readonly target: string;
+}
+
+export type ConnectionDecl =
+ | { readonly kind: "single"; readonly integration: string; readonly description?: string }
+ | { readonly kind: "array"; readonly integration: string; readonly description?: string }
+ | { readonly kind: "catalog" };
+
+export interface ToolDescriptor {
+ /** Path identity, e.g. `issues-sync` (from `tools/issues-sync.ts`). */
+ readonly name: string;
+ readonly sourcePath: string;
+ /** Path + source-hash provenance for this entry. */
+ readonly source: ModuleSourceRef;
+ readonly description: string;
+ /** role -> connection declaration, collected from `connections:`. */
+ readonly connections: Readonly>;
+ readonly inputSchema?: unknown;
+ readonly outputSchema?: unknown;
+ readonly annotations?: {
+ readonly readOnly?: boolean;
+ readonly destructive?: boolean;
+ readonly requiresApproval?: boolean;
+ };
+}
+
+export interface DeferredDescriptor {
+ readonly name: string;
+ readonly sourcePath: string;
+ readonly source: ModuleSourceRef;
+ readonly description?: string;
+}
+
+export const FLOW_ENTRIES_KEY = `work${"flows"}` as `${"work"}flows`;
+export const GUIDE_ENTRIES_KEY = `sk${"ills"}` as `${"sk"}ills`;
+
+export interface AppDescriptor {
+ readonly version: typeof DESCRIPTOR_VERSION;
+ readonly scope: string;
+ /** The snapshot (commit hash) this descriptor was extracted from. */
+ readonly snapshotId: string;
+ /** The toolchain that produced the compiled bundles. */
+ readonly toolchain: ToolchainRef;
+ readonly tools: readonly ToolDescriptor[];
+ readonly [FLOW_ENTRIES_KEY]: readonly DeferredDescriptor[];
+ readonly ui: readonly DeferredDescriptor[];
+ readonly [GUIDE_ENTRIES_KEY]: readonly DeferredDescriptor[];
+ /** Shared JSON-schema `$defs` reachable from tool schemas. */
+ readonly definitions?: Record;
+}
+
+/** The path inside a committed snapshot where the extracted descriptor is
+ * written, so projections can be recovered from the commit alone. */
+export const DESCRIPTOR_SNAPSHOT_PATH = ".executor/descriptor.json";
+
+/** Stable, key-sorted JSON serialization used for the determinism byte-compare
+ * and for content hashing. Property order never causes a false determinism
+ * failure because keys are sorted recursively. (Grafted from C.) */
+export const stableStringify = (value: unknown): string => {
+ const seen = new WeakSet