@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
@@ -4054,16 +4054,68 @@ code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0
4054
4054
  .dm-record-drawer-head h2 { margin: 0; color: #111827; font-size: 16px; font-weight: 650; }
4055
4055
  .dm-record-testbar { display: flex; align-items: center; gap: 8px; padding: 10px 18px; border-bottom: 1px solid #edf0f3; background: #fbfdff; }
4056
4056
  .dm-record-testbar > span:last-child { min-width: 0; color: #64748b; font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
4057
- .dm-record-fields { display: grid; gap: 12px; padding: 16px 18px 28px; overflow-y: auto; }
4057
+ .dm-record-fields { display: grid; gap: 8px; padding: 14px 16px 28px; overflow-y: auto; }
4058
4058
  .dm-record-field { display: grid; gap: 5px; }
4059
- .dm-record-field span { color: #475569; font-size: 12px; font-weight: 650; }
4059
+ .dm-record-field span { color: #475569; font-size: 11px; font-weight: 650; }
4060
4060
  .dm-record-field input,
4061
- .dm-record-field textarea { width: 100%; border: 1px solid #cbd5e1; border-radius: 6px; background: #fff; color: #111827; font: inherit; font-size: 13px; padding: 8px 9px; box-sizing: border-box; }
4061
+ .dm-record-field textarea { width: 100%; border: 1px solid #cbd5e1; border-radius: 6px; background: #fff; color: #111827; font: inherit; font-size: 12px; padding: 7px 9px; box-sizing: border-box; }
4062
4062
  .dm-record-field textarea { resize: vertical; font-family: ui-monospace,SFMono-Regular,Menlo,monospace; line-height: 1.45; }
4063
4063
  .dm-record-field input:focus,
4064
4064
  .dm-record-field textarea:focus { outline: none; border-color: #64748b; box-shadow: 0 0 0 3px rgba(100,116,139,.12); }
4065
4065
  .dm-record-field input:disabled,
4066
4066
  .dm-record-field textarea:disabled { background: #f8fafc; color: #64748b; }
4067
+ .dm-record-field input { width: 100%; border: 1px solid #cbd5e1; border-radius: 6px; background: #fff; color: #111827; font: inherit; font-size: 12px; padding: 7px 9px; box-sizing: border-box; }
4068
+ .dm-drawer-section { border: 1px solid #e2e8f0; border-radius: 8px; background: #fff; overflow: visible; }
4069
+ .dm-drawer-section-toggle { width: 100%; min-height: 36px; display: flex; align-items: center; gap: 8px; border: 0; border-radius: 8px; background: #f8fafc; color: #334155; font: inherit; font-size: 12px; font-weight: 700; padding: 0 11px; text-align: left; cursor: pointer; }
4070
+ .dm-drawer-section-toggle svg { flex: 0 0 auto; color: #64748b; transition: transform .12s; }
4071
+ .dm-drawer-section.open .dm-drawer-section-toggle { border-bottom: 1px solid #e2e8f0; border-bottom-left-radius: 0; border-bottom-right-radius: 0; }
4072
+ .dm-drawer-section.open .dm-drawer-section-toggle svg { transform: rotate(90deg); }
4073
+ .dm-drawer-section-body { display: grid; gap: 10px; padding: 11px; }
4074
+ .dm-sandbox-config { display: grid; gap: 8px; }
4075
+ .dm-radio-row { display: grid; gap: 8px; }
4076
+ .dm-radio-row label, .dm-check-row { display: grid; grid-template-columns: 16px minmax(0,1fr); align-items: start; column-gap: 8px; color: #1f2937; font-size: 12px; line-height: 1.35; }
4077
+ .dm-radio-row input[type="radio"], .dm-check-row input[type="checkbox"] { width: 14px; height: 14px; margin: 1px 0 0; padding: 0; box-shadow: none; accent-color: #111827; }
4078
+ .dm-radio-row span, .dm-check-row span { color: #1f2937; font-size: 12px; font-weight: 500; }
4079
+ .dm-check-row { cursor: pointer; }
4080
+ .dm-select { position: relative; width: 100%; min-width: 180px; font-size: 11px; }
4081
+ .dm-select-trigger { width: 100%; min-height: 32px; display: flex; align-items: center; justify-content: space-between; gap: 8px; border: 1px solid #cbd5e1; border-radius: 7px; background: #fff; color: #111827; box-shadow: 0 1px 2px rgba(15,23,42,.05); font: inherit; font-size: 11px; padding: 6px 10px; text-align: left; cursor: pointer; transition: border-color .12s, box-shadow .12s, background .12s; }
4082
+ .dm-select-trigger:hover:not(:disabled) { border-color: #94a3b8; box-shadow: 0 2px 8px rgba(15,23,42,.08); }
4083
+ .dm-select.open .dm-select-trigger { border-color: #64748b; box-shadow: 0 0 0 3px rgba(100,116,139,.12), 0 2px 8px rgba(15,23,42,.08); }
4084
+ .dm-select-trigger:disabled { background: #f8fafc; color: #64748b; cursor: not-allowed; }
4085
+ .dm-select-trigger span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #111827; font-size: 11px; font-weight: 600; }
4086
+ .dm-select-trigger span.empty { color: #94a3b8; }
4087
+ .dm-select-trigger svg { flex: 0 0 auto; color: #64748b; transition: transform .12s; }
4088
+ .dm-select.open .dm-select-trigger svg { transform: rotate(180deg); }
4089
+ .dm-select-popover { position: absolute; left: 0; right: 0; top: calc(100% + 6px); z-index: 120; display: grid; gap: 6px; min-width: 240px; padding: 8px; border: 1px solid #dbe2ea; border-radius: 8px; background: #fff; box-shadow: 0 18px 42px rgba(15,23,42,.18), 0 3px 10px rgba(15,23,42,.08); }
4090
+ .dm-select-search { display: flex; align-items: center; gap: 6px; height: 32px; border: 1px solid #e2e8f0; border-radius: 7px; background: #f8fafc; padding: 0 8px; color: #64748b; }
4091
+ .dm-select-search input { min-width: 0; flex: 1; border: 0; padding: 0; background: transparent; box-shadow: none; color: #111827; font: inherit; font-size: 11px; outline: none; }
4092
+ .dm-select-list { display: grid; gap: 2px; max-height: 232px; overflow-y: auto; padding: 2px 2px 2px 0; scrollbar-width: thin; scrollbar-color: #cbd5e1 transparent; }
4093
+ .dm-select-list::-webkit-scrollbar { width: 8px; }
4094
+ .dm-select-list::-webkit-scrollbar-thumb { border-radius: 999px; background: #cbd5e1; }
4095
+ .dm-select-option { display: grid; gap: 1px; width: 100%; border: 0; border-radius: 6px; background: transparent; color: #111827; font: inherit; font-size: 11px; line-height: 1.25; padding: 7px 9px; text-align: left; cursor: pointer; }
4096
+ .dm-select-option:hover { background: #f1f5f9; }
4097
+ .dm-select-option.selected { background: #eef2ff; color: #3730a3; font-weight: 650; }
4098
+ .dm-select-option span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11px; font-weight: inherit; color: inherit; }
4099
+ .dm-select-option em { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #64748b; font-size: 11px; font-style: normal; font-weight: 500; }
4100
+ .dm-select-empty { margin: 0; padding: 10px 8px; color: #94a3b8; font-size: 12px; }
4101
+ .dm-select-pager { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding-top: 4px; border-top: 1px solid #f1f5f9; }
4102
+ .dm-select-pager button { height: 26px; border: 1px solid #e2e8f0; border-radius: 6px; background: #fff; color: #334155; font: inherit; font-size: 12px; padding: 0 8px; cursor: pointer; }
4103
+ .dm-select-pager button:disabled { opacity: .45; cursor: not-allowed; }
4104
+ .dm-select-pager span { color: #64748b; font-size: 12px; font-weight: 650; }
4105
+ .dm-db-grid td:has(.dm-select) { overflow: visible; padding-top: 5px; padding-bottom: 5px; }
4106
+ .dm-db-grid td .dm-select { min-width: 230px; }
4107
+ .dm-json-field { position: relative; }
4108
+ .dm-json-field > span { padding-right: 34px; }
4109
+ .dm-json-expand { position: absolute; top: 0; right: 0; display: inline-flex; align-items: center; justify-content: center; width: 26px; height: 24px; border: 1px solid #e2e8f0; border-radius: 6px; background: #fff; color: #64748b; box-shadow: 0 1px 2px rgba(15,23,42,.05); opacity: 0; cursor: pointer; transition: opacity .12s, border-color .12s, color .12s, box-shadow .12s; }
4110
+ .dm-json-field:hover .dm-json-expand, .dm-json-expand:focus-visible { opacity: 1; }
4111
+ .dm-json-expand:hover:not(:disabled) { border-color: #94a3b8; color: #111827; box-shadow: 0 3px 10px rgba(15,23,42,.1); }
4112
+ .dm-json-expand:disabled { cursor: not-allowed; opacity: 0; }
4113
+ .dm-json-modal-backdrop { position: fixed; inset: 0; z-index: 140; display: grid; place-items: center; padding: 24px; background: rgba(15,23,42,.38); }
4114
+ .dm-json-modal { width: min(920px, 96vw); max-height: min(760px, 90vh); display: flex; flex-direction: column; overflow: hidden; border: 1px solid #dbe2ea; border-radius: 8px; background: #fff; box-shadow: 0 28px 80px rgba(15,23,42,.28); }
4115
+ .dm-json-modal header { display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 14px 16px; border-bottom: 1px solid #edf0f3; background: #fbfdff; }
4116
+ .dm-json-modal header p { margin: 0 0 2px; color: #94a3b8; font-size: 11px; font-weight: 700; letter-spacing: .05em; text-transform: uppercase; }
4117
+ .dm-json-modal header h2 { margin: 0; color: #111827; font-size: 15px; font-weight: 650; }
4118
+ .dm-json-modal pre { margin: 0; flex: 1; overflow: auto; padding: 16px; background: #0f172a; color: #e5edf8; font: 12px/1.55 ui-monospace,SFMono-Regular,Menlo,monospace; white-space: pre-wrap; word-break: break-word; scrollbar-width: thin; scrollbar-color: #64748b transparent; }
4067
4119
 
4068
4120
  /* Relations tab */
4069
4121
  .dm-relations-tab { display: grid; gap: 16px; }
@@ -126,6 +126,7 @@ function hasTestedSavedRow(table) {
126
126
  function isSelectableDataModelSource(table) {
127
127
  if (table?.storage !== "manual-object") return false;
128
128
  if (table.objectType === "api-registry") return false;
129
+ if (table.objectType === "sandbox-environment") return hasTestedSavedRow(table);
129
130
  if (table.objectType === "data-source") return hasTestedSavedRow(table);
130
131
  const hasStatusField = (table.columns || []).some((column) => String(column).toLowerCase() === "status");
131
132
  return hasStatusField ? hasTestedSavedRow(table) : true;
@@ -88,6 +88,8 @@ Data Source objects are selectable only when at least one row is:
88
88
 
89
89
  This ensures widgets bind only to tested, configured sources with a known returned shape.
90
90
 
91
+ Sandbox Environment rows are execution records, **not** widget sources. Workspace Builder excludes `objectType: "sandbox-environment"` from source pickers. For serverless sandbox runs, reuse an API Registry integration as the **scheduler webhook** by setting `schedulerRegistryId` on the sandbox row to that row’s `integrationId` (`runLocality: serverless`). See `sandbox-environment-primitive.md` in this folder.
92
+
91
93
  ## LeadShark Example
92
94
 
93
95
  LeadShark uses:
@@ -0,0 +1,32 @@
1
+ # Sandbox Environment (governed data object)
2
+
3
+ `objectType: "sandbox-environment"` is an execution-plane manual object alongside Data Source, API Registry, People, Tasks, and Custom tables. Rows live in **`growthub.config.json#dataModel.objects[]`** — the same PATCH allowlist (`dataModel`) and validator (`apps/workspace/lib/workspace-schema.js`) as every other governed object.
4
+
5
+ ## Persistence and upgrades
6
+
7
+ Deployed workspaces that adopted an older sandbox preset **without** `runLocality` / `schedulerRegistryId` columns keep working: `POST /api/workspace/sandbox-run` treats blank or unknown `runLocality` as **`local`** (`normalizeRunLocality` + `DEFAULT_SANDBOX_RUN_LOCALITY`). Persisted rows pick up defaults at **read time**, so operators do not have to replay migrations for existing JSON on disk until they decide to expose the new controls in the table.
8
+
9
+ Operators who want the radios and scheduler FK in the Data Model grid should add columns `runLocality` and `schedulerRegistryId` to the object (matching the preset) and save via the normal PATCH path.
10
+
11
+ ## Where it runs (`runLocality`)
12
+
13
+ | Value | Behaviour |
14
+ | --- | --- |
15
+ | **`local`** (default when unset / empty / unknown) | `lib/adapters/sandboxes/` resolves an adapter (`local-process`, `local-agent-host`, drop-zone). Spawn + capture happen on the Next.js host. Secrets come only from server-resolved **`envRefs`** / env. |
16
+ | **`serverless`** | No local agent-host spawn. Outbound **`POST`** to the URL merged from API Registry row identified by **`schedulerRegistryId`** (same pattern as Data Source **`registryId`**: `integrationId`, `authRef`, `baseUrl`, `endpoint`, headers resolved server-side). Body kind **`growthub-sandbox-run-v1`**. **`local-agent-host`** is rejected in this locality. Responses map to **`stdout`** / **`stderr`** / **`exitCode`** so `lastResponse` and **`growthub.source-records.json`** stay uniform. |
17
+
18
+ The scheduler webhook is deliberately thin **any** reachable HTTPS handler (Supabase Edge, Upstash/QStash-queued worker, `vercel.json` cron hitting your URL, DIY). Postgres / KV-backed workflow queues in workspace config describe *where persistence lives*, not the sandbox row itself; the **`schedulerRegistryId`** row is only the outbound HTTP binding.
19
+
20
+ Agents and streamed APIs elsewhere in the sandbox stay orthogonal: serverless swaps **who invokes** the sandbox run boundary, not the rest of workspace networking.
21
+
22
+ ## Credential surface
23
+
24
+ Sandbox rows reference **`authRef` / named env refs** — never literals in browser or config records. Scheduling uses the referenced API Registry row’s **`authRef`** merge rules identical to **`/api/workspace/test-source`**.
25
+
26
+ ## Not a widget source
27
+
28
+ Workspace Builder excludes **`sandbox-environment`** from View widget bindings (execution records, not tabular KPI sources). See **`data-sources-api-registry.md`** in this folder.
29
+
30
+ ## Extension points
31
+
32
+ - Custom adapters: `apps/workspace/lib/adapters/sandboxes/adapters/` (see `README.md` there).
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Sandbox adapter dynamic loader — filesystem-safe, ESM-compatible.
3
+ *
4
+ * Reads every `.js` file from `lib/adapters/sandboxes/adapters/` and
5
+ * side-effect-imports it so each file can call `registerSandboxAdapter()`.
6
+ *
7
+ * Mirrors `lib/adapters/integrations/resolver-loader.js` exactly so operators
8
+ * recognize the drop-zone pattern. Server-side only — the browser never sees
9
+ * this module.
10
+ *
11
+ * The default `local-process` adapter ships under
12
+ * `lib/adapters/sandboxes/default-local-process.js` and is loaded eagerly by
13
+ * `index.js`; this loader is for additional drop-zone adapters added by
14
+ * forks (e.g. `fly-machines.js`, `e2b.js`, `modal.js`).
15
+ */
16
+
17
+ import { promises as fs } from "node:fs";
18
+ import path from "node:path";
19
+ import { pathToFileURL } from "node:url";
20
+
21
+ const loaded = new Set();
22
+ let loadAttempted = false;
23
+
24
+ async function loadAllSandboxAdapters() {
25
+ if (loadAttempted) return;
26
+ loadAttempted = true;
27
+ const adaptersDir = path.resolve(/*turbopackIgnore: true*/ process.cwd(), "lib/adapters/sandboxes/adapters");
28
+ try {
29
+ const entries = await fs.readdir(adaptersDir);
30
+ const jsFiles = entries.filter((f) => f.endsWith(".js") && !f.startsWith("_") && !f.startsWith("."));
31
+ await Promise.all(
32
+ jsFiles.map(async (file) => {
33
+ if (loaded.has(file)) return;
34
+ try {
35
+ const absolutePath = path.join(adaptersDir, file);
36
+ await import(/*turbopackIgnore: true*/ pathToFileURL(absolutePath).href);
37
+ loaded.add(file);
38
+ } catch {
39
+ // Malformed adapter — skip silently; operator needs to fix the file
40
+ }
41
+ })
42
+ );
43
+ } catch {
44
+ // adapters drop-zone missing or empty — normal for fresh upstream kit
45
+ }
46
+ }
47
+
48
+ async function listSandboxAdapterFiles() {
49
+ const adaptersDir = path.resolve(/*turbopackIgnore: true*/ process.cwd(), "lib/adapters/sandboxes/adapters");
50
+ try {
51
+ const entries = await fs.readdir(adaptersDir);
52
+ return entries.filter((f) => f.endsWith(".js") && !f.startsWith("_") && !f.startsWith("."));
53
+ } catch {
54
+ return [];
55
+ }
56
+ }
57
+
58
+ export { loadAllSandboxAdapters, listSandboxAdapterFiles };
@@ -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 };