@growthub/cli 0.9.14 → 0.9.17

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 (19) hide show
  1. package/README.md +17 -5
  2. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/sandbox-adapters/route.js +21 -0
  3. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/sandbox-run/route.js +634 -0
  4. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/page.jsx +712 -54
  5. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/globals.css +55 -3
  6. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workspace-builder.jsx +1 -0
  7. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/docs/data-sources-api-registry.md +2 -0
  8. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/docs/sandbox-environment-primitive.md +32 -0
  9. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/adapter-loader.js +58 -0
  10. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/adapters/README.md +63 -0
  11. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/default-local-agent-host.js +284 -0
  12. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/default-local-process.js +194 -0
  13. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/index.js +33 -0
  14. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/sandbox-adapter-registry.js +113 -0
  15. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-data-model.js +107 -0
  16. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-schema.js +103 -1
  17. package/assets/worker-kits/growthub-custom-workspace-starter-v1/kit.json +9 -0
  18. package/dist/index.js +41066 -1761
  19. package/package.json +2 -2
@@ -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
+ };
@@ -370,6 +370,42 @@ const OBJECT_TYPE_PRESETS = {
370
370
  columns: ["Name", "Status", "DueDate", "Assignee", "Priority"],
371
371
  relations: []
372
372
  },
373
+ "sandbox-environment": {
374
+ label: "Sandbox Environment",
375
+ icon: "Terminal",
376
+ description: "Execution locality: local (process sandbox or Paperclip thin local agent-host CLI) or serverless (delegates to an API Registry HTTP target: Edge/QStash/cron webhook). Env refs resolve server-side; run history in growthub.source-records.json. Not a widget binding source.",
377
+ columns: [
378
+ "Name",
379
+ "lifecycleStatus",
380
+ "version",
381
+ "runLocality",
382
+ "schedulerRegistryId",
383
+ "runtime",
384
+ "adapter",
385
+ "agentHost",
386
+ "envRefs",
387
+ "networkAllow",
388
+ "allowList",
389
+ "instructions",
390
+ "command",
391
+ "timeoutMs",
392
+ "status",
393
+ "lastTested",
394
+ "lastRunId",
395
+ "lastSourceId",
396
+ "lastResponse"
397
+ ],
398
+ relations: [
399
+ {
400
+ id: "scheduler-registry-binding",
401
+ name: "Scheduler (serverless)",
402
+ field: "schedulerRegistryId",
403
+ targetObjectType: "api-registry",
404
+ type: "belongs-to",
405
+ description: "When runLocality is serverless, POST /api/workspace/sandbox-run sends growthub-sandbox-run-v1 to this API Registry record (METHOD, baseUrl, endpoint, authRef resolved server-side). Use for Supabase Edge URL, QStash forwarder, Vercel-exposed webhook, cron targets, etc."
406
+ }
407
+ ]
408
+ },
373
409
  "custom": {
374
410
  label: "Custom",
375
411
  icon: "Plus",
@@ -510,6 +546,73 @@ function describeBindingLane(binding) {
510
546
  return "manual";
511
547
  }
512
548
 
549
+ /**
550
+ * Saved env-key references — name-only projection of workspace integrations[].
551
+ *
552
+ * Used by the sandbox-environment drawer's env-ref multi-select. The browser
553
+ * receives the `endpointRef` slug only (never the secret value); the sandbox
554
+ * run route resolves the slug to a server-side env value using the same
555
+ * `envKeyCandidates(authRef)` pattern as `test-api-record/route.js`.
556
+ *
557
+ * Returns: [{ id, endpointRef, kind, hasSecret }]
558
+ */
559
+ function listSavedEnvRefs(workspaceConfig) {
560
+ const integrations = Array.isArray(workspaceConfig?.integrations) ? workspaceConfig.integrations : [];
561
+ return integrations
562
+ .filter((entry) => entry?.sourceType === "custom-api-webhooks" && typeof entry.endpointRef === "string" && entry.endpointRef.trim())
563
+ .map((entry) => ({
564
+ id: entry.id || entry.endpointRef,
565
+ endpointRef: entry.endpointRef,
566
+ kind: entry.kind === "webhook" ? "webhook" : "api",
567
+ hasSecret: entry.hasSecret === true
568
+ }));
569
+ }
570
+
571
+ /**
572
+ * Parse a sandbox-environment row's `envRefs` column into a clean string array.
573
+ * Stored as a comma-separated string in the row to keep the column flat under
574
+ * the existing governed Data Model schema; rendered as a multi-select chip
575
+ * group in the drawer. The server reads the same comma-separated form.
576
+ */
577
+ function parseSandboxEnvRefs(value) {
578
+ if (Array.isArray(value)) {
579
+ return value.map((item) => String(item || "").trim()).filter(Boolean);
580
+ }
581
+ if (typeof value !== "string") return [];
582
+ return value
583
+ .split(",")
584
+ .map((item) => item.trim())
585
+ .filter(Boolean);
586
+ }
587
+
588
+ /**
589
+ * Parse a sandbox-environment row's `allowList` column into a clean array of
590
+ * domain hostnames. Stored as comma-separated string for governed flatness;
591
+ * the run route enforces the list when `networkAllow` is truthy.
592
+ */
593
+ function parseSandboxAllowList(value) {
594
+ if (Array.isArray(value)) {
595
+ return value.map((item) => String(item || "").trim().toLowerCase()).filter(Boolean);
596
+ }
597
+ if (typeof value !== "string") return [];
598
+ return value
599
+ .split(",")
600
+ .map((item) => item.trim().toLowerCase())
601
+ .filter(Boolean);
602
+ }
603
+
604
+ /**
605
+ * Stable sourceId for a sandbox-environment row's run history sidecar.
606
+ * Keyed by object id + slugified Name so the key survives reorder of rows
607
+ * inside the same object. The sandbox-run route uses this id to read/write
608
+ * `growthub.source-records.json`.
609
+ */
610
+ function sandboxRunSourceId(objectId, name) {
611
+ const slug = String(name || "").trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
612
+ if (!objectId || !slug) return null;
613
+ return `sandbox:${objectId}:${slug}`;
614
+ }
615
+
513
616
  function describeBindingMode(binding) {
514
617
  const lane = describeBindingLane(binding);
515
618
  if (lane === "data-source") return { label: "Data source scope", description: "Integration reference selected in the existing widget source flow. Dynamic data resolves through the governed server-side integration path." };
@@ -531,7 +634,11 @@ export {
531
634
  duplicateTableRow,
532
635
  exportTableAsCsv,
533
636
  importTableFromCsv,
637
+ listSavedEnvRefs,
534
638
  listWorkspaceDataModelTables,
639
+ parseSandboxAllowList,
640
+ parseSandboxEnvRefs,
535
641
  replaceTableContent,
642
+ sandboxRunSourceId,
536
643
  updateTableCell
537
644
  };
@@ -47,6 +47,35 @@ const KNOWN_FILTER_OPERATORS = ["eq", "ne", "contains", "gt", "lt", "isEmpty", "
47
47
  const KNOWN_FILTER_CONJUNCTIONS = ["and", "or"];
48
48
  const KNOWN_SORT_DIRECTIONS = ["asc", "desc"];
49
49
  const KNOWN_AGGREGATIONS = ["sum", "avg", "count", "min", "max"];
50
+ const KNOWN_SANDBOX_RUNTIMES = ["python", "node", "bash"];
51
+ /** Where execution is delegated: locally (process / agent-host CLI) or to a scheduler webhook (Supabase Edge, QStash, Vercel cron hitting your URL, etc.). */
52
+ const KNOWN_SANDBOX_RUN_LOCALITY = ["local", "serverless"];
53
+ const KNOWN_SANDBOX_LIFECYCLE_STATUSES = ["draft", "live"];
54
+ const DEFAULT_SANDBOX_RUN_LOCALITY = "local";
55
+ const DEFAULT_SANDBOX_ADAPTER = "local-process";
56
+ const SANDBOX_DEFAULT_TIMEOUT_MS = 60000;
57
+ const SANDBOX_MAX_TIMEOUT_MS = 600000;
58
+ /**
59
+ * Canonical Paperclip local agent-host slugs — mirrors the upstream
60
+ * `AGENT_ADAPTER_TYPES` enum in `packages/shared/src/constants.ts`. The
61
+ * sandbox-environment row's `agentHost` column accepts any of these values
62
+ * when `adapter === "local-agent-host"`. The standalone workspace starter
63
+ * does NOT import the @paperclipai/adapter-* packages directly — instead the
64
+ * default `local-agent-host` adapter spawns the host CLI binary the user has
65
+ * on PATH (cross-platform: macOS / Windows / Linux), keeping the workspace
66
+ * starter portable and thin.
67
+ */
68
+ const KNOWN_SANDBOX_AGENT_HOSTS = [
69
+ "claude_local",
70
+ "codex_local",
71
+ "cursor",
72
+ "gemini_local",
73
+ "opencode_local",
74
+ "pi_local",
75
+ "qwen_local",
76
+ "openclaw_gateway",
77
+ "hermes_local"
78
+ ];
50
79
 
51
80
  const NORMALIZED_OBJECT_FIELD_IDS = ["id", "label", "secondaryLabel", "entityType", "provider", "lane", "status"];
52
81
  const WORKSPACE_TEMPLATE_KIND = "growthub-workspace-template";
@@ -791,6 +820,65 @@ function validateCanvasConfig(canvas, errors) {
791
820
  }
792
821
  }
793
822
 
823
+ function validateSandboxEnvironmentRow(row, path, errors) {
824
+ if (!isPlainObject(row)) return;
825
+ const lifecycleStatus = String(row.lifecycleStatus || "").trim().toLowerCase();
826
+ if (row.lifecycleStatus !== undefined && row.lifecycleStatus !== "" && !KNOWN_SANDBOX_LIFECYCLE_STATUSES.includes(lifecycleStatus)) {
827
+ errors.push(`${path}.lifecycleStatus must be one of ${KNOWN_SANDBOX_LIFECYCLE_STATUSES.join(", ")}`);
828
+ }
829
+ if (row.version !== undefined && typeof row.version !== "string" && typeof row.version !== "number") {
830
+ errors.push(`${path}.version must be a string or number`);
831
+ }
832
+ const runLocalityNorm = String(row.runLocality || "").trim().toLowerCase();
833
+ if (row.runLocality !== undefined && row.runLocality !== "" && !KNOWN_SANDBOX_RUN_LOCALITY.includes(runLocalityNorm)) {
834
+ errors.push(`${path}.runLocality must be one of ${KNOWN_SANDBOX_RUN_LOCALITY.join(", ")}`);
835
+ }
836
+ if (runLocalityNorm === "serverless") {
837
+ if (typeof row.schedulerRegistryId !== "string" || !row.schedulerRegistryId.trim()) {
838
+ errors.push(`${path}.schedulerRegistryId must reference an API Registry integrationId when runLocality is serverless`);
839
+ }
840
+ }
841
+ if (runLocalityNorm === "local" && row.runtime !== undefined && row.runtime !== "" && !KNOWN_SANDBOX_RUNTIMES.includes(row.runtime)) {
842
+ errors.push(`${path}.runtime must be one of ${KNOWN_SANDBOX_RUNTIMES.join(", ")}`);
843
+ }
844
+ if (row.adapter !== undefined && typeof row.adapter !== "string") {
845
+ errors.push(`${path}.adapter must be a string`);
846
+ }
847
+ if (row.agentHost !== undefined && row.agentHost !== "" && !KNOWN_SANDBOX_AGENT_HOSTS.includes(row.agentHost)) {
848
+ errors.push(`${path}.agentHost must be one of ${KNOWN_SANDBOX_AGENT_HOSTS.join(", ")}`);
849
+ }
850
+ if (row.envRefs !== undefined && typeof row.envRefs !== "string" && !Array.isArray(row.envRefs)) {
851
+ errors.push(`${path}.envRefs must be a comma-separated string or array of env-ref slugs (never values)`);
852
+ }
853
+ if (row.networkAllow !== undefined) {
854
+ const value = String(row.networkAllow).trim().toLowerCase();
855
+ if (!["", "true", "false", "0", "1", "on", "off"].includes(value)) {
856
+ errors.push(`${path}.networkAllow must coerce to a boolean (true/false/on/off)`);
857
+ }
858
+ }
859
+ if (row.allowList !== undefined && typeof row.allowList !== "string" && !Array.isArray(row.allowList)) {
860
+ errors.push(`${path}.allowList must be a comma-separated string or array of hostnames`);
861
+ }
862
+ if (row.instructions !== undefined && typeof row.instructions !== "string") {
863
+ errors.push(`${path}.instructions must be a string`);
864
+ }
865
+ if (row.command !== undefined && typeof row.command !== "string") {
866
+ errors.push(`${path}.command must be a string`);
867
+ }
868
+ if (row.lastRunId !== undefined && typeof row.lastRunId !== "string") {
869
+ errors.push(`${path}.lastRunId must be a string`);
870
+ }
871
+ if (row.lastSourceId !== undefined && typeof row.lastSourceId !== "string") {
872
+ errors.push(`${path}.lastSourceId must be a string`);
873
+ }
874
+ if (row.timeoutMs !== undefined && row.timeoutMs !== "") {
875
+ const ms = Number(row.timeoutMs);
876
+ if (!Number.isFinite(ms) || ms < 0 || ms > SANDBOX_MAX_TIMEOUT_MS) {
877
+ errors.push(`${path}.timeoutMs must be a finite number between 0 and ${SANDBOX_MAX_TIMEOUT_MS}`);
878
+ }
879
+ }
880
+ }
881
+
794
882
  function validateDataModelConfig(dataModel, errors) {
795
883
  if (dataModel === undefined) return;
796
884
  if (!isPlainObject(dataModel)) {
@@ -824,7 +912,13 @@ function validateDataModelConfig(dataModel, errors) {
824
912
  errors.push(`${prefix}.rows must be an array`);
825
913
  } else {
826
914
  object.rows.forEach((row, rowIndex) => {
827
- if (!isPlainObject(row)) errors.push(`${prefix}.rows[${rowIndex}] must be a plain object`);
915
+ if (!isPlainObject(row)) {
916
+ errors.push(`${prefix}.rows[${rowIndex}] must be a plain object`);
917
+ return;
918
+ }
919
+ if (object.objectType === "sandbox-environment") {
920
+ validateSandboxEnvironmentRow(row, `${prefix}.rows[${rowIndex}]`, errors);
921
+ }
828
922
  });
829
923
  }
830
924
  validateStaticDataBinding(object.binding, `${prefix}.binding`, errors);
@@ -1079,11 +1173,19 @@ export {
1079
1173
  KNOWN_AGGREGATIONS,
1080
1174
  KNOWN_CHART_TYPES,
1081
1175
  KNOWN_DATA_BINDING_MODES,
1176
+ DEFAULT_SANDBOX_RUN_LOCALITY,
1177
+ KNOWN_SANDBOX_LIFECYCLE_STATUSES,
1082
1178
  KNOWN_FIELDS,
1083
1179
  KNOWN_FILTER_CONJUNCTIONS,
1084
1180
  KNOWN_FILTER_OPERATORS,
1181
+ KNOWN_SANDBOX_AGENT_HOSTS,
1182
+ KNOWN_SANDBOX_RUN_LOCALITY,
1183
+ KNOWN_SANDBOX_RUNTIMES,
1085
1184
  KNOWN_SORT_DIRECTIONS,
1086
1185
  KNOWN_WIDGET_KINDS,
1186
+ DEFAULT_SANDBOX_ADAPTER,
1187
+ SANDBOX_DEFAULT_TIMEOUT_MS,
1188
+ SANDBOX_MAX_TIMEOUT_MS,
1087
1189
  NORMALIZED_OBJECT_FIELD_IDS,
1088
1190
  SAMPLE_DATA_BINDINGS,
1089
1191
  SAMPLE_VIEW_ROWS,
@@ -61,6 +61,7 @@
61
61
  "studio/src/main.jsx",
62
62
  "studio/src/App.jsx",
63
63
  "studio/src/app.css",
64
+ "apps/workspace/docs/sandbox-environment-primitive.md",
64
65
  "apps/workspace/README.md",
65
66
  "apps/workspace/.env.example",
66
67
  "apps/workspace/package.json",
@@ -80,6 +81,8 @@
80
81
  "apps/workspace/app/api/workspace/test-source/route.js",
81
82
  "apps/workspace/app/api/workspace/register-resolver/route.js",
82
83
  "apps/workspace/app/api/workspace/resolvers/route.js",
84
+ "apps/workspace/app/api/workspace/sandbox-adapters/route.js",
85
+ "apps/workspace/app/api/workspace/sandbox-run/route.js",
83
86
  "apps/workspace/app/api/settings/integrations/route.js",
84
87
  "apps/workspace/lib/workspace-schema.js",
85
88
  "apps/workspace/lib/workspace-config.js",
@@ -92,6 +95,12 @@
92
95
  "apps/workspace/lib/adapters/integrations/source-resolver-registry.js",
93
96
  "apps/workspace/lib/adapters/integrations/resolver-loader.js",
94
97
  "apps/workspace/lib/adapters/integrations/resolvers/README.md",
98
+ "apps/workspace/lib/adapters/sandboxes/adapter-loader.js",
99
+ "apps/workspace/lib/adapters/sandboxes/adapters/README.md",
100
+ "apps/workspace/lib/adapters/sandboxes/default-local-agent-host.js",
101
+ "apps/workspace/lib/adapters/sandboxes/default-local-process.js",
102
+ "apps/workspace/lib/adapters/sandboxes/index.js",
103
+ "apps/workspace/lib/adapters/sandboxes/sandbox-adapter-registry.js",
95
104
  "apps/workspace/lib/adapters/payments/index.js",
96
105
  "apps/workspace/lib/adapters/persistence/index.js",
97
106
  "apps/workspace/lib/adapters/persistence/postgres.js",