@growthub/cli 0.9.13 → 0.9.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/README.md +17 -5
  2. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/README.md +27 -0
  3. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/integration-entities/route.js +41 -9
  4. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/list-entities/route.js +67 -0
  5. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/refresh-source/route.js +124 -0
  6. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/refresh-sources/route.js +127 -0
  7. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/register-resolver/route.js +119 -0
  8. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/resolvers/route.js +41 -0
  9. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/sandbox-adapters/route.js +21 -0
  10. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/sandbox-run/route.js +634 -0
  11. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/test-api-record/route.js +126 -0
  12. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/test-source/route.js +130 -0
  13. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/page.jsx +1349 -222
  14. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/globals.css +1048 -4
  15. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workspace-builder.jsx +1540 -433
  16. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/docs/data-sources-api-registry.md +141 -0
  17. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/docs/sandbox-environment-primitive.md +32 -0
  18. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/resolver-loader.js +57 -0
  19. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/resolvers/README.md +133 -0
  20. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/resolvers/google-analytics.js +160 -0
  21. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/source-resolver-registry.js +85 -0
  22. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/adapter-loader.js +58 -0
  23. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/adapters/README.md +63 -0
  24. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/default-local-agent-host.js +284 -0
  25. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/default-local-process.js +194 -0
  26. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/index.js +33 -0
  27. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/sandbox-adapter-registry.js +113 -0
  28. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-config.js +79 -1
  29. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-data-model.js +211 -0
  30. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-schema.js +126 -7
  31. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/package.json +1 -0
  32. package/assets/worker-kits/growthub-custom-workspace-starter-v1/kit.json +16 -0
  33. package/dist/index.js +1764 -40677
  34. package/package.json +2 -2
@@ -0,0 +1,63 @@
1
+ # Sandbox Adapters
2
+
3
+ Drop one `.js` file per execution target here. Each file calls `registerSandboxAdapter()` once at module load.
4
+
5
+ This is the thin agnostic extension point for the **`sandbox-environment` governed Data Model object**. Two default adapters ship eagerly with every workspace, loaded by `lib/adapters/sandboxes/index.js`:
6
+
7
+ - **`local-process`** (`default-local-process.js`) — spawns python3 / node / bash inside an isolated `/tmp/growthub-sandbox-*` workdir with timeout + captured stdio. Use this when the row is a deterministic script.
8
+ - **`local-agent-host`** (`default-local-agent-host.js`) — Paperclip thin local adapter. Routes the row through whichever local agent host CLI the operator has on PATH (Claude Code, Codex, Cursor, Gemini, OpenCode, Pi, Qwen, Hermes, OpenClaw Gateway). Cross-platform — works on macOS, Windows, and Linux. Slugs mirror the canonical `AGENT_ADAPTER_TYPES` enum so a row is portable to the upstream Paperclip server adapter registry without translation.
9
+
10
+ Files added to this drop-zone are loaded by `adapter-loader.js` on the first sandbox-run route invocation. Use the drop-zone for hardened isolation primitives (firejail, gVisor, Docker, Fly Machines, e2b, modal.com) or for additional agent host targets the canonical catalog does not yet cover.
11
+
12
+ ## Adapter shape
13
+
14
+ ```js
15
+ import { registerSandboxAdapter } from "../sandbox-adapter-registry.js";
16
+
17
+ registerSandboxAdapter({
18
+ id: "your-target-slug", // stable adapter slug, must match the row's `adapter` column
19
+ label: "Human-readable label",
20
+ description: "1-line capability hint for the drawer dropdown",
21
+ locality: "local" | "serverless" | "remote",
22
+ supportedRuntimes: ["python", "node"],
23
+ run: async (request) => RunResult
24
+ });
25
+ ```
26
+
27
+ `request` is a sealed envelope minted by the sandbox-run route. It includes a freshly-created workdir (under `/tmp/growthub-sandbox-*`), the user's `command`, the `runtime`, the `timeoutMs`, the `networkAllow` boolean, the explicit `allowList`, and a server-resolved `env` object plus the audit-only `envRefSlugs` / `envRefsMissing` arrays. **Never** reach outside `request.env` for credentials and never log secret values, even on error paths.
28
+
29
+ `RunResult` must include `{ ok, exitCode, durationMs, stdout, stderr, error?, adapterMeta? }`. The route writes the full result into `growthub.source-records.json` keyed by the sandbox row's stable sourceId, and also stamps `status` / `lastTested` / `lastResponse` on the row in `growthub.config.json` via the standard governed PATCH path.
30
+
31
+ ## Hard rules
32
+
33
+ 1. **No credential resolution inside the adapter** — the route already resolved env refs server-side. Adapters consume `request.env` only.
34
+ 2. **No filesystem writes outside the workdir** — workdir is the single owned scratch space; the route cleans it after the run.
35
+ 3. **No mutation of `growthub.config.json` or `growthub.source-records.json`** — the route owns versioned record persistence.
36
+ 4. **No browser-side execution** — adapters run server-side only. The browser never imports this folder.
37
+ 5. **No silent secret logging** — even on stderr, redact env values before returning.
38
+
39
+ ## Agent CLI commands
40
+
41
+ ```bash
42
+ # List registered sandbox adapters (id + label + locality + supported runtimes)
43
+ curl -s http://localhost:3000/api/workspace/sandbox-adapters
44
+
45
+ # Run a single sandbox row by objectId + sandbox name
46
+ curl -s -X POST http://localhost:3000/api/workspace/sandbox-run \
47
+ -H "Content-Type: application/json" \
48
+ -d '{
49
+ "objectId": "my-sandboxes",
50
+ "name": "data-prep"
51
+ }'
52
+ ```
53
+
54
+ Versioned run history lives in `growthub.source-records.json` under `sandbox:<objectId>:<slug(name)>` and survives fork export/import alongside the rest of the workspace artifact.
55
+
56
+ ## Serverless scheduling (`runLocality === "serverless"`)
57
+
58
+ When the sandbox row’s **`runLocality`** is **`serverless`**, the sandbox-run route does **not** call `run()` on `local-process` / `local-agent-host`. Execution is delegated to an HTTP scheduler:
59
+
60
+ 1. **`schedulerRegistryId`** must equal an API Registry row’s **`integrationId`** (webhook-capable deployment URL + method + **`authRef`**).
61
+ 2. The route mints **`growthub-sandbox-run-v1`** JSON (no secrets inline) and **POST**s using the merged registry/request shape (parity with outbound test routes).
62
+ 3. The handler translates its own queue (KV, Postgres, cron tick, etc.) into a normal JSON or text reply; the route maps that reply into **`stdout`**, optional **`stderr`**, and **`exitCode`** — same **`lastResponse`** / sidecar semantics as **local**.
63
+ 4. Drop-zone adapters labelled **`locality: "serverless"`** are **not** used for delegation today; outbound HTTP replaces them so operators wire **already-supported** integrations + infra without a second resolver graph.
@@ -0,0 +1,284 @@
1
+ /**
2
+ * Default sandbox adapter — local-agent-host (Paperclip thin local adapter).
3
+ *
4
+ * Routes a sandbox-environment row through whichever local agent host CLI the
5
+ * user has on PATH on their machine — Claude Code, Codex, Cursor, Gemini,
6
+ * OpenCode, Pi, Qwen, Hermes, OpenClaw Gateway. The host slugs mirror the
7
+ * canonical `AGENT_ADAPTER_TYPES` enum in `packages/shared/src/constants.ts`,
8
+ * so a sandbox configured here is portable to the upstream Paperclip server
9
+ * adapter registry without translation.
10
+ *
11
+ * The standalone `growthub-custom-workspace-starter-v1` ships without any
12
+ * @paperclipai/adapter-* workspace package import (it's a portable Next.js
13
+ * app that runs on a fresh user machine). Instead this adapter spawns the
14
+ * host CLI binary directly with the user's command. Cross-platform: works on
15
+ * macOS, Windows, and Linux as long as the host CLI is installed.
16
+ *
17
+ * The adapter is intentionally thin:
18
+ * - it does NOT manage the host's auth state, model selection, or context window
19
+ * - it does NOT mutate any host config file
20
+ * - it does NOT route through the upstream Paperclip server (use the
21
+ * hosted bridge for that)
22
+ * - it ONLY captures stdout/stderr/exit code and returns a standard RunResult
23
+ *
24
+ * The catalog below maps each canonical host slug to the binary the operator
25
+ * is expected to have on PATH. Forks extend this by dropping a sibling file
26
+ * under `lib/adapters/sandboxes/adapters/` that calls
27
+ * `registerSandboxAdapter()` with their own dispatch logic — this default
28
+ * adapter does not need to know about every possible host.
29
+ */
30
+
31
+ import { spawn } from "node:child_process";
32
+ import { promises as fs } from "node:fs";
33
+ import path from "node:path";
34
+ import { registerSandboxAdapter } from "./sandbox-adapter-registry.js";
35
+
36
+ const MAX_OUTPUT_BYTES = 1024 * 256;
37
+
38
+ /**
39
+ * Canonical Paperclip host catalog — slugs mirror `AGENT_ADAPTER_TYPES`.
40
+ *
41
+ * Each entry declares the binary the operator must have on PATH and how to
42
+ * invoke it for one-shot prompt execution. `argv` returns the argv array the
43
+ * adapter should pass; `inputMode` chooses whether the user's command is sent
44
+ * via stdin or as a positional argument. `installHint` is surfaced verbatim
45
+ * when the binary is not found, so operators get an actionable error.
46
+ */
47
+ const HOST_CATALOG = {
48
+ claude_local: {
49
+ label: "Claude Code (local)",
50
+ binary: "claude",
51
+ argv: () => ["-p", "--output-format", "text"],
52
+ inputMode: "stdin",
53
+ installHint: "Install Claude Code: npm i -g @anthropic-ai/claude-code"
54
+ },
55
+ codex_local: {
56
+ label: "Codex CLI (local)",
57
+ binary: "codex",
58
+ argv: () => ["exec", "--skip-git-repo-check", "--sandbox", "read-only", "-"],
59
+ inputMode: "stdin",
60
+ installHint: "Install Codex CLI: npm i -g @openai/codex"
61
+ },
62
+ cursor: {
63
+ label: "Cursor Agent (local)",
64
+ binary: "cursor-agent",
65
+ argv: () => ["--print"],
66
+ inputMode: "stdin",
67
+ installHint: "Install Cursor Agent CLI: curl https://cursor.com/install -fsS | bash"
68
+ },
69
+ gemini_local: {
70
+ label: "Gemini CLI (local)",
71
+ binary: "gemini",
72
+ argv: () => ["-p", "-"],
73
+ inputMode: "stdin",
74
+ installHint: "Install Gemini CLI: npm i -g @google/gemini-cli"
75
+ },
76
+ opencode_local: {
77
+ label: "OpenCode (local)",
78
+ binary: "opencode",
79
+ argv: () => ["run", "--quiet"],
80
+ inputMode: "stdin",
81
+ installHint: "Install OpenCode: npm i -g opencode-ai"
82
+ },
83
+ pi_local: {
84
+ label: "Pi (local)",
85
+ binary: "pi",
86
+ argv: () => ["run", "--stdin"],
87
+ inputMode: "stdin",
88
+ installHint: "Install Pi CLI: refer to your Paperclip Pi distribution"
89
+ },
90
+ qwen_local: {
91
+ label: "Qwen Code (local)",
92
+ binary: "qwen",
93
+ argv: () => ["-p"],
94
+ inputMode: "stdin",
95
+ installHint: "Install Qwen Code CLI: refer to your Qwen distribution"
96
+ },
97
+ hermes_local: {
98
+ label: "Hermes Paperclip (local)",
99
+ binary: "hermes",
100
+ argv: () => ["run", "--stdin"],
101
+ inputMode: "stdin",
102
+ installHint: "Install Hermes Paperclip adapter: npm i -g hermes-paperclip-adapter"
103
+ },
104
+ openclaw_gateway: {
105
+ label: "OpenClaw Gateway (local)",
106
+ binary: "openclaw",
107
+ argv: () => ["gateway", "exec", "--stdin"],
108
+ inputMode: "stdin",
109
+ installHint: "Install OpenClaw Gateway: refer to your Paperclip distribution"
110
+ }
111
+ };
112
+
113
+ const SUPPORTED_HOSTS = Object.keys(HOST_CATALOG);
114
+
115
+ function clampStream(buffer) {
116
+ if (buffer.length <= MAX_OUTPUT_BYTES) return buffer.toString("utf8");
117
+ const head = buffer.slice(0, MAX_OUTPUT_BYTES);
118
+ return `${head.toString("utf8")}\n…\n[output truncated at ${MAX_OUTPUT_BYTES} bytes]`;
119
+ }
120
+
121
+ async function run(request) {
122
+ const hostSlug = typeof request.agentHost === "string" ? request.agentHost.trim() : "";
123
+ const host = HOST_CATALOG[hostSlug];
124
+ if (!host) {
125
+ return {
126
+ ok: false,
127
+ exitCode: null,
128
+ durationMs: 0,
129
+ stdout: "",
130
+ stderr: "",
131
+ error: `agentHost is required for local-agent-host adapter; pick one of ${SUPPORTED_HOSTS.join(", ")}`,
132
+ adapterMeta: { adapter: "local-agent-host" }
133
+ };
134
+ }
135
+
136
+ const command = typeof request.command === "string" ? request.command : "";
137
+ const workdir = request.workdir;
138
+ if (typeof workdir !== "string" || !workdir) {
139
+ return {
140
+ ok: false,
141
+ exitCode: null,
142
+ durationMs: 0,
143
+ stdout: "",
144
+ stderr: "",
145
+ error: "workdir is required",
146
+ adapterMeta: { adapter: "local-agent-host", agentHost: hostSlug }
147
+ };
148
+ }
149
+
150
+ const promptPath = path.join(workdir, "prompt.txt");
151
+ try {
152
+ await fs.writeFile(promptPath, command, "utf8");
153
+ } catch {
154
+ // Best-effort audit copy of the prompt — execution still continues
155
+ }
156
+
157
+ const env = {
158
+ PATH: process.env.PATH || "",
159
+ HOME: process.env.HOME || workdir,
160
+ TMPDIR: workdir,
161
+ GROWTHUB_SANDBOX: "1",
162
+ GROWTHUB_SANDBOX_RUN_ID: request.runId || "",
163
+ GROWTHUB_SANDBOX_AGENT_HOST: hostSlug,
164
+ GROWTHUB_SANDBOX_NET_ALLOW: request.networkAllow ? "1" : "0",
165
+ GROWTHUB_SANDBOX_NET_ALLOWLIST: Array.isArray(request.allowList) ? request.allowList.join(",") : "",
166
+ ...(request.env || {})
167
+ };
168
+
169
+ const timeoutMs = Number.isFinite(request.timeoutMs) && request.timeoutMs > 0 ? request.timeoutMs : 60000;
170
+ const startedAt = Date.now();
171
+ const argv = host.argv(command);
172
+
173
+ return await new Promise((resolve) => {
174
+ let stdout = Buffer.alloc(0);
175
+ let stderr = Buffer.alloc(0);
176
+ let timedOut = false;
177
+ let resolved = false;
178
+
179
+ let child;
180
+ try {
181
+ child = spawn(host.binary, argv, {
182
+ cwd: workdir,
183
+ env,
184
+ stdio: ["pipe", "pipe", "pipe"]
185
+ });
186
+ } catch (error) {
187
+ resolve({
188
+ ok: false,
189
+ exitCode: null,
190
+ durationMs: Date.now() - startedAt,
191
+ stdout: "",
192
+ stderr: "",
193
+ error: error?.message || `failed to spawn ${host.binary}`,
194
+ adapterMeta: { adapter: "local-agent-host", agentHost: hostSlug, binary: host.binary, installHint: host.installHint }
195
+ });
196
+ return;
197
+ }
198
+
199
+ const timer = setTimeout(() => {
200
+ timedOut = true;
201
+ try { child.kill("SIGKILL"); } catch {}
202
+ }, timeoutMs);
203
+
204
+ child.stdout.on("data", (chunk) => {
205
+ if (stdout.length < MAX_OUTPUT_BYTES) stdout = Buffer.concat([stdout, chunk]);
206
+ });
207
+ child.stderr.on("data", (chunk) => {
208
+ if (stderr.length < MAX_OUTPUT_BYTES) stderr = Buffer.concat([stderr, chunk]);
209
+ });
210
+
211
+ child.on("error", (error) => {
212
+ if (resolved) return;
213
+ resolved = true;
214
+ clearTimeout(timer);
215
+ const notFound = error?.code === "ENOENT";
216
+ resolve({
217
+ ok: false,
218
+ exitCode: null,
219
+ durationMs: Date.now() - startedAt,
220
+ stdout: clampStream(stdout),
221
+ stderr: clampStream(stderr),
222
+ error: notFound
223
+ ? `${host.binary} not found on PATH. ${host.installHint}`
224
+ : (error?.message || "spawn failed"),
225
+ adapterMeta: {
226
+ adapter: "local-agent-host",
227
+ agentHost: hostSlug,
228
+ binary: host.binary,
229
+ installHint: host.installHint,
230
+ notFound
231
+ }
232
+ });
233
+ });
234
+
235
+ child.on("close", (exitCode, signal) => {
236
+ if (resolved) return;
237
+ resolved = true;
238
+ clearTimeout(timer);
239
+ const durationMs = Date.now() - startedAt;
240
+ const ok = !timedOut && exitCode === 0;
241
+ resolve({
242
+ ok,
243
+ exitCode: typeof exitCode === "number" ? exitCode : null,
244
+ durationMs,
245
+ stdout: clampStream(stdout),
246
+ stderr: clampStream(stderr),
247
+ error: timedOut
248
+ ? `timed out after ${timeoutMs}ms`
249
+ : (ok ? undefined : `exit ${exitCode ?? signal ?? "unknown"}`),
250
+ adapterMeta: {
251
+ adapter: "local-agent-host",
252
+ agentHost: hostSlug,
253
+ binary: host.binary,
254
+ argv,
255
+ inputMode: host.inputMode,
256
+ timedOut,
257
+ signal: signal || null
258
+ }
259
+ });
260
+ });
261
+
262
+ if (host.inputMode === "stdin") {
263
+ try {
264
+ child.stdin.write(command);
265
+ } catch {
266
+ // child may have already exited via spawn error — ignore
267
+ }
268
+ try { child.stdin.end(); } catch {}
269
+ }
270
+ });
271
+ }
272
+
273
+ registerSandboxAdapter({
274
+ id: "local-agent-host",
275
+ label: "Local agent host (Paperclip thin adapter)",
276
+ description: "Spawns a local agent host CLI on the operator's machine — Claude Code, Codex, Cursor, Gemini, OpenCode, Pi, Qwen, Hermes, OpenClaw Gateway. Cross-platform (macOS / Windows / Linux). Slugs mirror Paperclip AGENT_ADAPTER_TYPES so the row is portable to the upstream server adapter registry.",
277
+ locality: "local",
278
+ supportedRuntimes: ["bash", "node", "python"],
279
+ supportedHosts: SUPPORTED_HOSTS,
280
+ hostCatalog: HOST_CATALOG,
281
+ run
282
+ });
283
+
284
+ export { HOST_CATALOG, SUPPORTED_HOSTS };
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Default sandbox adapter — local-process.
3
+ *
4
+ * Thin agnostic execution surface. For each invocation:
5
+ * 1. The sandbox-run route mints a fresh /tmp/growthub-sandbox-<runId>/ workdir
6
+ * and resolves env refs server-side (browser never sees secrets).
7
+ * 2. This adapter writes the user-supplied command to a runtime-specific
8
+ * entry file inside the workdir, then spawns the appropriate interpreter
9
+ * with strict timeout + captured stdio.
10
+ * 3. After the child exits (or is killed for timeout), the route cleans the
11
+ * workdir and persists a versioned record into
12
+ * `growthub.source-records.json` keyed by sandbox sourceId.
13
+ *
14
+ * The adapter intentionally does NOT:
15
+ * - read or write outside the supplied workdir
16
+ * - persist state across runs
17
+ * - expand env refs (the route already resolved them)
18
+ * - log secret values, even on stderr (only stream the child's own stderr)
19
+ * - enforce OS-level network isolation (the operator runtime owns that;
20
+ * this adapter simply forwards `networkAllow` + `allowList` into
21
+ * adapterMeta and into a `GROWTHUB_SANDBOX_NET_ALLOW`/`_ALLOWLIST` env
22
+ * pair the user's script can consult.)
23
+ *
24
+ * Forks that need a hardened isolation primitive (firejail, gVisor, Docker,
25
+ * Fly Machines, e2b, modal.com, etc.) ship a sibling adapter file under
26
+ * `lib/adapters/sandboxes/adapters/` and call `registerSandboxAdapter()`.
27
+ */
28
+
29
+ import { spawn } from "node:child_process";
30
+ import { promises as fs } from "node:fs";
31
+ import path from "node:path";
32
+ import { registerSandboxAdapter } from "./sandbox-adapter-registry.js";
33
+
34
+ const ENTRY_FILE_BY_RUNTIME = {
35
+ python: "entry.py",
36
+ node: "entry.js",
37
+ bash: "entry.sh"
38
+ };
39
+
40
+ const INTERPRETER_BY_RUNTIME = {
41
+ python: { command: "python3", argv: (entry) => [entry] },
42
+ node: { command: "node", argv: (entry) => [entry] },
43
+ bash: { command: "bash", argv: (entry) => [entry] }
44
+ };
45
+
46
+ const MAX_OUTPUT_BYTES = 1024 * 256; // 256 KiB per stream — enough for diagnostics, prevents runaway capture
47
+
48
+ function clampStream(buffer) {
49
+ if (buffer.length <= MAX_OUTPUT_BYTES) return buffer.toString("utf8");
50
+ const head = buffer.slice(0, MAX_OUTPUT_BYTES);
51
+ return `${head.toString("utf8")}\n…\n[output truncated at ${MAX_OUTPUT_BYTES} bytes]`;
52
+ }
53
+
54
+ async function run(request) {
55
+ const runtime = request?.runtime;
56
+ const interpreter = INTERPRETER_BY_RUNTIME[runtime];
57
+ const entryName = ENTRY_FILE_BY_RUNTIME[runtime];
58
+ if (!interpreter || !entryName) {
59
+ return {
60
+ ok: false,
61
+ exitCode: null,
62
+ durationMs: 0,
63
+ stdout: "",
64
+ stderr: "",
65
+ error: `unsupported runtime: ${String(runtime)}`,
66
+ adapterMeta: { adapter: "local-process" }
67
+ };
68
+ }
69
+
70
+ const workdir = request.workdir;
71
+ if (typeof workdir !== "string" || !workdir) {
72
+ return {
73
+ ok: false,
74
+ exitCode: null,
75
+ durationMs: 0,
76
+ stdout: "",
77
+ stderr: "",
78
+ error: "workdir is required",
79
+ adapterMeta: { adapter: "local-process" }
80
+ };
81
+ }
82
+
83
+ const entryPath = path.join(workdir, entryName);
84
+ const command = typeof request.command === "string" ? request.command : "";
85
+
86
+ try {
87
+ await fs.writeFile(entryPath, command, "utf8");
88
+ if (runtime === "bash") {
89
+ await fs.chmod(entryPath, 0o700);
90
+ }
91
+ } catch (error) {
92
+ return {
93
+ ok: false,
94
+ exitCode: null,
95
+ durationMs: 0,
96
+ stdout: "",
97
+ stderr: "",
98
+ error: `failed to write entry file: ${error.message || "unknown"}`,
99
+ adapterMeta: { adapter: "local-process" }
100
+ };
101
+ }
102
+
103
+ const env = {
104
+ PATH: process.env.PATH || "",
105
+ HOME: workdir,
106
+ TMPDIR: workdir,
107
+ GROWTHUB_SANDBOX: "1",
108
+ GROWTHUB_SANDBOX_RUN_ID: request.runId || "",
109
+ GROWTHUB_SANDBOX_NET_ALLOW: request.networkAllow ? "1" : "0",
110
+ GROWTHUB_SANDBOX_NET_ALLOWLIST: Array.isArray(request.allowList) ? request.allowList.join(",") : "",
111
+ ...(request.env || {})
112
+ };
113
+
114
+ const timeoutMs = Number.isFinite(request.timeoutMs) && request.timeoutMs > 0 ? request.timeoutMs : 60000;
115
+ const startedAt = Date.now();
116
+
117
+ return await new Promise((resolve) => {
118
+ let stdout = Buffer.alloc(0);
119
+ let stderr = Buffer.alloc(0);
120
+ let timedOut = false;
121
+ let resolved = false;
122
+
123
+ const child = spawn(interpreter.command, interpreter.argv(entryPath), {
124
+ cwd: workdir,
125
+ env,
126
+ stdio: ["ignore", "pipe", "pipe"]
127
+ });
128
+
129
+ const timer = setTimeout(() => {
130
+ timedOut = true;
131
+ try { child.kill("SIGKILL"); } catch {}
132
+ }, timeoutMs);
133
+
134
+ child.stdout.on("data", (chunk) => {
135
+ if (stdout.length < MAX_OUTPUT_BYTES) stdout = Buffer.concat([stdout, chunk]);
136
+ });
137
+ child.stderr.on("data", (chunk) => {
138
+ if (stderr.length < MAX_OUTPUT_BYTES) stderr = Buffer.concat([stderr, chunk]);
139
+ });
140
+
141
+ child.on("error", (error) => {
142
+ if (resolved) return;
143
+ resolved = true;
144
+ clearTimeout(timer);
145
+ resolve({
146
+ ok: false,
147
+ exitCode: null,
148
+ durationMs: Date.now() - startedAt,
149
+ stdout: clampStream(stdout),
150
+ stderr: clampStream(stderr),
151
+ error: error.code === "ENOENT"
152
+ ? `interpreter not found: ${interpreter.command} (install ${runtime} runtime on the host)`
153
+ : error.message || "spawn failed",
154
+ adapterMeta: { adapter: "local-process", runtime, timedOut: false }
155
+ });
156
+ });
157
+
158
+ child.on("close", (exitCode, signal) => {
159
+ if (resolved) return;
160
+ resolved = true;
161
+ clearTimeout(timer);
162
+ const durationMs = Date.now() - startedAt;
163
+ const ok = !timedOut && exitCode === 0;
164
+ resolve({
165
+ ok,
166
+ exitCode: typeof exitCode === "number" ? exitCode : null,
167
+ durationMs,
168
+ stdout: clampStream(stdout),
169
+ stderr: clampStream(stderr),
170
+ error: timedOut
171
+ ? `timed out after ${timeoutMs}ms`
172
+ : (ok ? undefined : `exit ${exitCode ?? signal ?? "unknown"}`),
173
+ adapterMeta: {
174
+ adapter: "local-process",
175
+ runtime,
176
+ interpreter: interpreter.command,
177
+ timedOut,
178
+ signal: signal || null,
179
+ networkAllow: Boolean(request.networkAllow),
180
+ allowList: Array.isArray(request.allowList) ? request.allowList : []
181
+ }
182
+ });
183
+ });
184
+ });
185
+ }
186
+
187
+ registerSandboxAdapter({
188
+ id: "local-process",
189
+ label: "Local process (default)",
190
+ description: "Spawns python3/node/bash inside an isolated /tmp/growthub-sandbox-* workdir with timeout + captured stdio. Operator runtime is responsible for OS-level network isolation; allow list is published to the script via GROWTHUB_SANDBOX_NET_ALLOW(LIST).",
191
+ locality: "local",
192
+ supportedRuntimes: ["python", "node", "bash"],
193
+ run
194
+ });
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Sandbox adapter facade.
3
+ *
4
+ * Eagerly registers the default `local-process` adapter so the workspace works
5
+ * out of the box, then loads any drop-zone adapter files added by the fork.
6
+ * Routes import `ensureSandboxAdaptersLoaded()` once before they call
7
+ * `getSandboxAdapter(id)`.
8
+ */
9
+
10
+ import "./default-local-process.js";
11
+ import "./default-local-agent-host.js";
12
+ import { loadAllSandboxAdapters } from "./adapter-loader.js";
13
+
14
+ let baseLoaded = true; // default-local-process registered via static import
15
+ let dropZoneLoadStarted = false;
16
+ let dropZoneLoadComplete = null;
17
+
18
+ async function ensureSandboxAdaptersLoaded() {
19
+ if (!baseLoaded) baseLoaded = true;
20
+ if (!dropZoneLoadStarted) {
21
+ dropZoneLoadStarted = true;
22
+ dropZoneLoadComplete = loadAllSandboxAdapters();
23
+ }
24
+ await dropZoneLoadComplete;
25
+ }
26
+
27
+ export { ensureSandboxAdaptersLoaded };
28
+ export {
29
+ describeRegisteredSandboxAdapters,
30
+ getSandboxAdapter,
31
+ listRegisteredSandboxAdapters,
32
+ registerSandboxAdapter
33
+ } from "./sandbox-adapter-registry.js";
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Sandbox Adapter Registry — execution-target-agnostic dispatch layer.
3
+ *
4
+ * The sandbox-environment governed Data Model object selects an adapter by id.
5
+ * Each adapter is a thin, opinion-free execution target that takes a sealed
6
+ * RunRequest and returns a RunResult. Adapters are dropped into
7
+ * `lib/adapters/sandboxes/adapters/` and loaded by `adapter-loader.js`.
8
+ *
9
+ * Contract — every adapter must call `registerSandboxAdapter()` once at module
10
+ * load with the following shape:
11
+ *
12
+ * {
13
+ * id: string, // stable adapter slug, e.g. "local-process", "fly-machines", "e2b"
14
+ * label: string, // human-readable name for the drawer dropdown
15
+ * description: string, // 1-line capability hint
16
+ * locality: "local" | "serverless" | "remote", // surfacing hint for the drawer
17
+ * supportedRuntimes: string[], // e.g. ["python", "node", "bash"]
18
+ * run: async (request, options?) => RunResult // the execution function
19
+ * }
20
+ *
21
+ * RunRequest (sealed envelope passed to `run`):
22
+ * {
23
+ * runId: string, // stable id for the record
24
+ * name: string, // sandbox row name (display only)
25
+ * runtime: string, // KNOWN_SANDBOX_RUNTIMES member
26
+ * command: string, // bash script / entry script (server-resolved)
27
+ * timeoutMs: number, // hard cap, capped at SANDBOX_MAX_TIMEOUT_MS
28
+ * networkAllow: boolean, // allow outbound network from inside the sandbox
29
+ * allowList: string[], // hostnames the user explicitly allowed
30
+ * env: Record<string,string>, // server-resolved env (NEVER sent to browser)
31
+ * envRefSlugs: string[], // ref slugs resolved (kept for record metadata)
32
+ * envRefsMissing: string[], // slugs the server could not resolve (audit-only)
33
+ * workdir: string, // freshly-minted /tmp/growthub-sandbox-* path
34
+ * ranAt: string // ISO timestamp
35
+ * }
36
+ *
37
+ * RunResult (returned by `run`):
38
+ * {
39
+ * ok: boolean,
40
+ * exitCode: number | null,
41
+ * durationMs: number,
42
+ * stdout: string,
43
+ * stderr: string,
44
+ * error?: string,
45
+ * adapterMeta?: Record<string, unknown>
46
+ * }
47
+ *
48
+ * Adapters MUST NOT:
49
+ * - read or persist files inside the workspace cwd; use the supplied workdir
50
+ * - log secret values, even on error paths
51
+ * - reach outside `request.env` for credential resolution
52
+ * - mutate `growthub.config.json` or `growthub.source-records.json` directly
53
+ * (the sandbox-run route handles versioned record persistence)
54
+ *
55
+ * The route and the data-model drawer reference this registry only — they have
56
+ * zero knowledge of any specific execution target. This keeps the adapter
57
+ * surface infinitely composable while preserving the governed integration
58
+ * substrate (server-side credential boundary, sidecar persistence,
59
+ * fork-sync-safe drop-zone extensibility).
60
+ */
61
+
62
+ if (!globalThis.__growthubSandboxAdapterRegistry) {
63
+ globalThis.__growthubSandboxAdapterRegistry = new Map();
64
+ }
65
+ const registry = globalThis.__growthubSandboxAdapterRegistry;
66
+
67
+ function registerSandboxAdapter(adapter) {
68
+ if (!adapter || typeof adapter !== "object") {
69
+ throw new Error("registerSandboxAdapter: adapter must be a plain object");
70
+ }
71
+ if (typeof adapter.id !== "string" || !adapter.id.trim()) {
72
+ throw new Error("registerSandboxAdapter: adapter.id must be a non-empty string");
73
+ }
74
+ if (typeof adapter.run !== "function") {
75
+ throw new Error(`registerSandboxAdapter(${adapter.id}): adapter.run must be a function`);
76
+ }
77
+ registry.set(adapter.id.trim(), adapter);
78
+ }
79
+
80
+ function getSandboxAdapter(id) {
81
+ if (typeof id !== "string" || !id.trim()) return null;
82
+ return registry.get(id.trim()) || null;
83
+ }
84
+
85
+ function listRegisteredSandboxAdapters() {
86
+ return Array.from(registry.keys());
87
+ }
88
+
89
+ function describeRegisteredSandboxAdapters() {
90
+ return Array.from(registry.entries()).map(([id, adapter]) => ({
91
+ id,
92
+ label: typeof adapter.label === "string" ? adapter.label : id,
93
+ description: typeof adapter.description === "string" ? adapter.description : "",
94
+ locality: ["local", "serverless", "remote"].includes(adapter.locality) ? adapter.locality : "local",
95
+ supportedRuntimes: Array.isArray(adapter.supportedRuntimes) ? adapter.supportedRuntimes : [],
96
+ supportedHosts: Array.isArray(adapter.supportedHosts) ? adapter.supportedHosts : null,
97
+ hostCatalog: adapter.hostCatalog && typeof adapter.hostCatalog === "object"
98
+ ? Object.entries(adapter.hostCatalog).map(([slug, host]) => ({
99
+ slug,
100
+ label: host?.label || slug,
101
+ binary: host?.binary || null,
102
+ installHint: host?.installHint || null
103
+ }))
104
+ : null
105
+ }));
106
+ }
107
+
108
+ export {
109
+ describeRegisteredSandboxAdapters,
110
+ getSandboxAdapter,
111
+ listRegisteredSandboxAdapters,
112
+ registerSandboxAdapter
113
+ };