@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.
- package/README.md +17 -5
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/sandbox-adapters/route.js +21 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/sandbox-run/route.js +634 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/page.jsx +712 -54
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/globals.css +55 -3
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workspace-builder.jsx +1 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/docs/data-sources-api-registry.md +2 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/docs/sandbox-environment-primitive.md +32 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/adapter-loader.js +58 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/adapters/README.md +63 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/default-local-agent-host.js +284 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/default-local-process.js +194 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/index.js +33 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/sandbox-adapter-registry.js +113 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-data-model.js +107 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-schema.js +103 -1
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/kit.json +9 -0
- package/dist/index.js +41066 -1761
- 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))
|
|
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",
|