@growthub/cli 0.14.1 → 0.14.2

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 (30) hide show
  1. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/sandbox-agent-auth/login/route.js +3 -2
  2. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/sandbox-agent-auth/logout/route.js +3 -2
  3. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/sandbox-agent-auth/status/route.js +3 -2
  4. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/sandbox-run/route.js +14 -1
  5. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/components/WorkspaceHelperSetupModal.jsx +1 -1
  6. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/AgentSwarmPanel.jsx +49 -2
  7. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/DataModelShell.jsx +54 -11
  8. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/OrchestrationNodeConfigPanel.jsx +113 -36
  9. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/SandboxAgentAuthPanel.jsx +34 -14
  10. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/globals.css +7 -0
  11. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workflows/WorkflowSurface.jsx +13 -4
  12. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/docs/sandbox-environment-primitive.md +26 -0
  13. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/adapters/local-intelligence-browser-access.js +516 -0
  14. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/default-local-agent-host.js +85 -7
  15. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/default-local-process.js +3 -1
  16. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/index.js +1 -0
  17. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/sandbox-adapter-registry.js +5 -1
  18. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/data-model/field-contracts.js +1 -0
  19. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-agent-swarm.js +8 -0
  20. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-graph-runner.js +3 -0
  21. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-graph.js +4 -2
  22. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-run-console.js +1 -0
  23. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/sandbox-agent-auth.js +82 -27
  24. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/sandbox-serverless-flow.js +4 -2
  25. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-data-model.js +1 -0
  26. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-schema.js +6 -0
  27. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-swarm-proposal.js +3 -0
  28. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/package-lock.json +364 -0
  29. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/package.json +1 -0
  30. package/package.json +1 -1
@@ -36,27 +36,95 @@ import { registerSandboxAdapter } from "./sandbox-adapter-registry.js";
36
36
  const MAX_OUTPUT_BYTES = 1024 * 256;
37
37
  const TELEMETRY_MARKER = "GROWTHUB_AGENT_TELEMETRY:";
38
38
 
39
+ /**
40
+ * Browser access — the product's OWN agent browser primitive, surfaced.
41
+ *
42
+ * The upstream Paperclip server already gives every agent browser access
43
+ * through one boolean: the agent config's `chrome` primitive (see
44
+ * `ui/src/components/agent-config-primitives.tsx` — "Enable Claude's Chrome
45
+ * integration by passing --chrome") gated by the chrome-lease service in
46
+ * `server/src/services/chrome-lease.ts` before `adapter.execute()`. The
47
+ * sandbox row's `browserAccess` is the SAME bit on the governed Data Model
48
+ * side, so a row stays portable to the upstream adapter registry without
49
+ * translation — exactly like the host slugs themselves.
50
+ *
51
+ * When `browserAccess` is on, each host engages its FIRST-PARTY browser
52
+ * integration — nothing is invented or injected by this adapter:
53
+ *
54
+ * native-flag the host CLI has a first-party browser flag and
55
+ * argv(request) appends it (Claude Code `--chrome`,
56
+ * Codex `--enable browser_use --enable in_app_browser`).
57
+ * env-signal the host CLI has no documented one-shot browser flag; it
58
+ * receives GROWTHUB_SANDBOX_BROWSER_ACCESS=1 (mirroring the
59
+ * upstream browser-isolation context) and its own browser
60
+ * integration — whatever the operator has configured in that
61
+ * host — honors the row's setting. We never fabricate a flag
62
+ * or write host config we cannot verify against the upstream
63
+ * tool, the same rule the auth catalog follows for login
64
+ * subcommands.
65
+ *
66
+ * In the orchestration graph this is what makes browser access node-level
67
+ * and host-agnostic: thinAdapter / ai-agent nodes execute through this same
68
+ * catalog, so every node inherits the row's browser grant regardless of
69
+ * which host runs it.
70
+ */
71
+
39
72
  /**
40
73
  * Canonical Paperclip host catalog — slugs mirror `AGENT_ADAPTER_TYPES`.
41
74
  *
42
75
  * Each entry declares the binary the operator must have on PATH and how to
43
- * invoke it for one-shot prompt execution. `argv` returns the argv array the
44
- * adapter should pass; `inputMode` chooses whether the user's command is sent
45
- * via stdin or as a positional argument. `installHint` is surfaced verbatim
46
- * when the binary is not found, so operators get an actionable error.
76
+ * invoke it for one-shot prompt execution. `argv(request)` receives the sealed
77
+ * RunRequest and returns the argv array the adapter should pass — hosts with
78
+ * native capability flags (network sandbox mode, browser access) derive them
79
+ * deterministically from the governed row's saved settings. `inputMode`
80
+ * chooses whether the user's command is sent via stdin or as a positional
81
+ * argument. `installHint` is surfaced verbatim when the binary is not found,
82
+ * so operators get an actionable error. `browser` declares how the host's
83
+ * first-party browser integration is engaged (see above).
47
84
  */
48
85
  const HOST_CATALOG = {
49
86
  claude_local: {
50
87
  label: "Claude Code (local)",
51
88
  binary: "claude",
52
- argv: () => ["-p", "--output-format", "text"],
89
+ argv: (request = {}) => {
90
+ const args = ["-p", "--output-format", "text"];
91
+ if (request.browserAccess) {
92
+ // Claude Code's first-party Chrome integration — the same flag the
93
+ // upstream server adapter passes when the agent config's `chrome`
94
+ // primitive is on.
95
+ args.push("--chrome");
96
+ }
97
+ return args;
98
+ },
99
+ browser: { lane: "native-flag", flags: ["--chrome"] },
53
100
  inputMode: "stdin",
54
101
  installHint: "Install Claude Code: npm i -g @anthropic-ai/claude-code"
55
102
  },
56
103
  codex_local: {
57
104
  label: "Codex CLI (local)",
58
105
  binary: "codex",
59
- argv: () => ["exec", "--skip-git-repo-check", "--sandbox", "read-only", "-"],
106
+ argv: (request = {}) => {
107
+ // INTENTIONAL: networkAllow alone selects `workspace-write`. Codex's
108
+ // `read-only` sandbox blocks ALL outbound network, so workspace-write is
109
+ // the least-privileged Codex mode where the row's network grant can take
110
+ // effect — and writes are confined to the sealed ephemeral workdir the
111
+ // adapter spawns into (cwd), never the operator's repo. Browser flags
112
+ // remain gated on browserAccess only; network alone never opens a browser.
113
+ const netOn = Boolean(request.networkAllow);
114
+ const browserOn = Boolean(request.browserAccess);
115
+ const args = [
116
+ "exec",
117
+ "--skip-git-repo-check",
118
+ "--sandbox",
119
+ netOn ? "workspace-write" : "read-only",
120
+ ];
121
+ if (browserOn) {
122
+ args.push("--enable", "browser_use", "--enable", "in_app_browser");
123
+ }
124
+ args.push("-");
125
+ return args;
126
+ },
127
+ browser: { lane: "native-flag", flags: ["--enable", "browser_use", "--enable", "in_app_browser"] },
60
128
  inputMode: "stdin",
61
129
  installHint: "Install Codex CLI: npm i -g @openai/codex"
62
130
  },
@@ -64,6 +132,7 @@ const HOST_CATALOG = {
64
132
  label: "Cursor Agent (local)",
65
133
  binary: "cursor-agent",
66
134
  argv: () => ["--print"],
135
+ browser: { lane: "env-signal" },
67
136
  inputMode: "stdin",
68
137
  installHint: "Install Cursor Agent CLI: curl https://cursor.com/install -fsS | bash"
69
138
  },
@@ -71,6 +140,7 @@ const HOST_CATALOG = {
71
140
  label: "Gemini CLI (local)",
72
141
  binary: "gemini",
73
142
  argv: () => ["-p", "-"],
143
+ browser: { lane: "env-signal" },
74
144
  inputMode: "stdin",
75
145
  installHint: "Install Gemini CLI: npm i -g @google/gemini-cli"
76
146
  },
@@ -78,6 +148,7 @@ const HOST_CATALOG = {
78
148
  label: "OpenCode (local)",
79
149
  binary: "opencode",
80
150
  argv: () => ["run", "--quiet"],
151
+ browser: { lane: "env-signal" },
81
152
  inputMode: "stdin",
82
153
  installHint: "Install OpenCode: npm i -g opencode-ai"
83
154
  },
@@ -85,6 +156,7 @@ const HOST_CATALOG = {
85
156
  label: "Pi (local)",
86
157
  binary: "pi",
87
158
  argv: () => ["run", "--stdin"],
159
+ browser: { lane: "env-signal" },
88
160
  inputMode: "stdin",
89
161
  installHint: "Install Pi CLI: refer to your Paperclip Pi distribution"
90
162
  },
@@ -92,6 +164,7 @@ const HOST_CATALOG = {
92
164
  label: "Qwen Code (local)",
93
165
  binary: "qwen",
94
166
  argv: () => ["-p"],
167
+ browser: { lane: "env-signal" },
95
168
  inputMode: "stdin",
96
169
  installHint: "Install Qwen Code CLI: refer to your Qwen distribution"
97
170
  },
@@ -99,6 +172,7 @@ const HOST_CATALOG = {
99
172
  label: "Hermes Paperclip (local)",
100
173
  binary: "hermes",
101
174
  argv: () => ["run", "--stdin"],
175
+ browser: { lane: "env-signal" },
102
176
  inputMode: "stdin",
103
177
  installHint: "Install Hermes Paperclip adapter: npm i -g hermes-paperclip-adapter"
104
178
  },
@@ -106,6 +180,7 @@ const HOST_CATALOG = {
106
180
  label: "OpenClaw Gateway (local)",
107
181
  binary: "openclaw",
108
182
  argv: () => ["gateway", "exec", "--stdin"],
183
+ browser: { lane: "env-signal" },
109
184
  inputMode: "stdin",
110
185
  installHint: "Install OpenClaw Gateway: refer to your Paperclip distribution"
111
186
  }
@@ -292,12 +367,13 @@ async function run(request) {
292
367
  GROWTHUB_SANDBOX_AGENT_HOST: hostSlug,
293
368
  GROWTHUB_SANDBOX_NET_ALLOW: request.networkAllow ? "1" : "0",
294
369
  GROWTHUB_SANDBOX_NET_ALLOWLIST: Array.isArray(request.allowList) ? request.allowList.join(",") : "",
370
+ GROWTHUB_SANDBOX_BROWSER_ACCESS: request.browserAccess ? "1" : "0",
295
371
  ...(request.env || {})
296
372
  };
297
373
 
298
374
  const timeoutMs = Number.isFinite(request.timeoutMs) && request.timeoutMs > 0 ? request.timeoutMs : 60000;
299
375
  const startedAt = Date.now();
300
- const argv = host.argv(command);
376
+ const argv = host.argv(request);
301
377
 
302
378
  return await new Promise((resolve) => {
303
379
  let stdout = Buffer.alloc(0);
@@ -385,6 +461,8 @@ async function run(request) {
385
461
  binary: host.binary,
386
462
  argv,
387
463
  inputMode: host.inputMode,
464
+ browserAccess: Boolean(request.browserAccess),
465
+ browserLane: request.browserAccess ? host.browser.lane : null,
388
466
  timedOut,
389
467
  signal: signal || null,
390
468
  tokens: telemetry.tokens,
@@ -108,6 +108,7 @@ async function run(request) {
108
108
  GROWTHUB_SANDBOX_RUN_ID: request.runId || "",
109
109
  GROWTHUB_SANDBOX_NET_ALLOW: request.networkAllow ? "1" : "0",
110
110
  GROWTHUB_SANDBOX_NET_ALLOWLIST: Array.isArray(request.allowList) ? request.allowList.join(",") : "",
111
+ GROWTHUB_SANDBOX_BROWSER_ACCESS: request.browserAccess ? "1" : "0",
111
112
  ...(request.env || {})
112
113
  };
113
114
 
@@ -177,7 +178,8 @@ async function run(request) {
177
178
  timedOut,
178
179
  signal: signal || null,
179
180
  networkAllow: Boolean(request.networkAllow),
180
- allowList: Array.isArray(request.allowList) ? request.allowList : []
181
+ allowList: Array.isArray(request.allowList) ? request.allowList : [],
182
+ browserAccess: Boolean(request.browserAccess)
181
183
  }
182
184
  });
183
185
  });
@@ -10,6 +10,7 @@
10
10
  import "./default-local-process.js";
11
11
  import "./default-local-agent-host.js";
12
12
  import "./default-local-intelligence.js";
13
+ import "./adapters/local-intelligence-browser-access.js";
13
14
  import { loadAllSandboxAdapters } from "./adapter-loader.js";
14
15
 
15
16
  let baseLoaded = true; // default-local-process registered via static import
@@ -27,6 +27,9 @@
27
27
  * timeoutMs: number, // hard cap, capped at SANDBOX_MAX_TIMEOUT_MS
28
28
  * networkAllow: boolean, // allow outbound network from inside the sandbox
29
29
  * allowList: string[], // hostnames the user explicitly allowed
30
+ * browserAccess: boolean, // row-level browser capability (implies networkAllow);
31
+ * // adapters surface it natively where the target supports
32
+ * // it and always publish GROWTHUB_SANDBOX_BROWSER_ACCESS
30
33
  * env: Record<string,string>, // server-resolved env (NEVER sent to browser)
31
34
  * envRefSlugs: string[], // ref slugs resolved (kept for record metadata)
32
35
  * envRefsMissing: string[], // slugs the server could not resolve (audit-only)
@@ -99,7 +102,8 @@ function describeRegisteredSandboxAdapters() {
99
102
  slug,
100
103
  label: host?.label || slug,
101
104
  binary: host?.binary || null,
102
- installHint: host?.installHint || null
105
+ installHint: host?.installHint || null,
106
+ browserLane: host?.browser?.lane || "env-signal"
103
107
  }))
104
108
  : null
105
109
  }));
@@ -11,6 +11,7 @@ const SANDBOX_ENVIRONMENT_FIELDS = {
11
11
  options: ["local", "serverless"]
12
12
  },
13
13
  networkAllow: { editor: "boolean-toggle" },
14
+ browserAccess: { editor: "boolean-toggle" },
14
15
  lifecycleStatus: {
15
16
  editor: "select",
16
17
  options: ["draft", "live"]
@@ -288,6 +288,7 @@ async function runThroughAdapter({
288
288
  timeoutMs,
289
289
  networkAllow,
290
290
  allowList,
291
+ browserAccess,
291
292
  env,
292
293
  envRefSlugs,
293
294
  envRefsMissing,
@@ -331,6 +332,7 @@ async function runThroughAdapter({
331
332
  timeoutMs,
332
333
  networkAllow,
333
334
  allowList,
335
+ browserAccess: browserAccess === true,
334
336
  env,
335
337
  envRefSlugs,
336
338
  envRefsMissing,
@@ -400,6 +402,7 @@ async function runOrchestratorPhase({ orchestratorNode, subagents, inputPayload,
400
402
  timeoutMs: clampPositiveInt(orchestratorNode?.config?.timeoutMs, DEFAULT_ORCHESTRATOR_TIMEOUT_MS),
401
403
  networkAllow: executionContext.networkAllow === true,
402
404
  allowList: executionContext.allowList || [],
405
+ browserAccess: executionContext.browserAccess === true,
403
406
  env,
404
407
  envRefSlugs: executionContext.envRefSlugs || [],
405
408
  envRefsMissing: executionContext.envRefsMissing || [],
@@ -488,6 +491,9 @@ async function dispatchSubagentTask({
488
491
  command,
489
492
  timeoutMs: clampPositiveInt(subagentConfig.timeoutMs, executionContext.timeoutMs || DEFAULT_SUBAGENT_TIMEOUT_MS),
490
493
  networkAllow: subagentConfig.networkAccess === true && executionContext.networkAllow === true,
494
+ // Browser is a superset of network: a subagent only inherits the row's
495
+ // browser access through the same node-level network gate.
496
+ browserAccess: subagentConfig.networkAccess === true && executionContext.browserAccess === true,
491
497
  allowList: executionContext.allowList || [],
492
498
  env,
493
499
  envRefSlugs: executionContext.envRefSlugs || [],
@@ -568,6 +574,7 @@ async function runSynthesisPhase({ synthesisNode, swarmConfig, tasks, inputPaylo
568
574
  timeoutMs: clampPositiveInt(cfg.timeoutMs, DEFAULT_SYNTHESIS_TIMEOUT_MS),
569
575
  networkAllow: executionContext.networkAllow === true,
570
576
  allowList: executionContext.allowList || [],
577
+ browserAccess: executionContext.browserAccess === true,
571
578
  env,
572
579
  envRefSlugs: executionContext.envRefSlugs || [],
573
580
  envRefsMissing: executionContext.envRefsMissing || [],
@@ -822,6 +829,7 @@ async function runAgentSwarmGraphIfPresent({
822
829
  envRefsMissing: executionContext?.envRefsMissing || [],
823
830
  networkAllow: executionContext?.networkAllow === true,
824
831
  allowList: executionContext?.allowList || [],
832
+ browserAccess: executionContext?.browserAccess === true,
825
833
  timeoutMs: clampPositiveInt(timeoutMs, DEFAULT_SUBAGENT_TIMEOUT_MS),
826
834
  sandboxName: executionContext?.sandboxName || row?.Name || "swarm",
827
835
  onEvent: executionContext?.onEvent,
@@ -281,6 +281,9 @@ async function runOrchestrationGraphIfPresent({ workspaceConfig, row, timeoutMs,
281
281
 
282
282
  const apiNode = extractApiRegistryCallNode(graph);
283
283
  if (!apiNode?.config) {
284
+ if ((Array.isArray(graph.nodes) ? graph.nodes : []).some((node) => node?.type === "ai-agent")) {
285
+ return null;
286
+ }
284
287
  return {
285
288
  ok: false,
286
289
  exitCode: 1,
@@ -157,9 +157,10 @@ function validateOrchestrationGraph(graph) {
157
157
  } else {
158
158
  const hasThinAdapter = graph.nodes.some((n) => n?.type === "thinAdapter");
159
159
  const hasApi = graph.nodes.some((n) => n?.type === "api-registry-call");
160
+ const hasAiAgent = graph.nodes.some((n) => n?.type === "ai-agent");
160
161
  const hasResult = graph.nodes.some((n) => n?.type === "tool-result");
161
- if (!hasThinAdapter && !hasApi) errors.push("orchestrationGraph requires an api-registry-call node");
162
- if (!hasThinAdapter && !hasResult) errors.push("orchestrationGraph requires a tool-result node");
162
+ if (!hasThinAdapter && !hasApi && !hasAiAgent) errors.push("orchestrationGraph requires an executable node");
163
+ if (!hasThinAdapter && !hasAiAgent && !hasResult) errors.push("orchestrationGraph requires a tool-result node");
163
164
  }
164
165
  }
165
166
  if (!Array.isArray(graph.edges)) {
@@ -712,6 +713,7 @@ function buildCanonicalNode(nodeId, registryRow = {}, options = {}) {
712
713
  function getNextCanonicalNodeId(graph) {
713
714
  const parsed = parseOrchestrationGraph(graph) || graph;
714
715
  if ((parsed?.nodes || []).some((n) => n?.type === "thinAdapter")) return null;
716
+ if ((parsed?.nodes || []).some((n) => n?.type === "ai-agent")) return null;
715
717
  const ids = new Set((parsed?.nodes || []).map((n) => String(n.id)));
716
718
  for (const id of CANONICAL_NODE_ORDER) {
717
719
  if (!ids.has(id)) return id;
@@ -685,6 +685,7 @@ function normalizeRunConsoleRecord(record) {
685
685
  envRefsMissing: Array.isArray(record.envRefsMissing) ? record.envRefsMissing.slice() : [],
686
686
  networkAllow: Boolean(record.networkAllow),
687
687
  allowList: Array.isArray(record.allowList) ? record.allowList.slice() : [],
688
+ browserAccess: Boolean(record.browserAccess),
688
689
  adapterMeta,
689
690
  templateTrace
690
691
  },
@@ -34,15 +34,14 @@
34
34
  * on-disk auth state; this module only records *readiness*, not secrets.
35
35
  *
36
36
  * The status semantics are deliberately conservative:
37
- * - "active" a real auth probe confirmed authentication (auth-status
38
- * exit 0 with auth-shaped output, or a clean login exit)
39
- * - "reachable" the binary is callable (version probe exit 0) — but
40
- * authentication is NOT yet confirmed
37
+ * - "active" the selected pinned host CLI is callable and ready for the
38
+ * local agent-host bridge, or a clean login exit completed
39
+ * - "reachable" legacy metadata value treated as active by the UI
41
40
  * - "stale" the binary printed auth-shaped failure output
42
41
  * - "missing" binary not found on PATH
43
42
  *
44
- * A `--version` probe NEVER promotes to "active". The next sandbox-run is
45
- * the final source of truth for session readiness.
43
+ * A successful host probe promotes to "active" because the sidecar represents
44
+ * selected local host readiness, not provider-account auth semantics.
46
45
  */
47
46
 
48
47
  import { spawn } from "node:child_process";
@@ -132,6 +131,38 @@ function assertAgentHostEligible(row, { requireLogin = false, requireLogout = fa
132
131
  return { spec, agentHost };
133
132
  }
134
133
 
134
+ function normalizeAgentHostOverride(agentHost) {
135
+ const nextAgentHost = String(agentHost || "").trim();
136
+ if (!nextAgentHost) return "";
137
+ if (!getHostAuthSpec(nextAgentHost)) {
138
+ const error = new Error(
139
+ `Agent auth setup is not registered for agentHost "${nextAgentHost}"`
140
+ );
141
+ error.code = "SANDBOX_AGENT_AUTH_HOST_UNSUPPORTED";
142
+ throw error;
143
+ }
144
+ return nextAgentHost;
145
+ }
146
+
147
+ function applyAgentHostOverride(row, agentHost) {
148
+ if (!agentHost) return row;
149
+ return {
150
+ ...row,
151
+ runLocality: "local",
152
+ adapter: "local-agent-host",
153
+ agentHost
154
+ };
155
+ }
156
+
157
+ function buildAgentHostSelectionPatch(agentHost) {
158
+ if (!agentHost) return {};
159
+ return {
160
+ runLocality: "local",
161
+ adapter: "local-agent-host",
162
+ agentHost
163
+ };
164
+ }
165
+
135
166
  function resolveHostBinary(row, spec) {
136
167
  const candidates = [row?.agentCommand, row?.claudeCommand];
137
168
  for (const candidate of candidates) {
@@ -205,9 +236,24 @@ function hasAny(patterns, text) {
205
236
  return patterns.some((p) => p.test(text));
206
237
  }
207
238
 
239
+ function deriveStatusFromAuthStatusJson(text) {
240
+ if (!text || typeof text !== "string") return null;
241
+ const trimmed = text.trim();
242
+ if (!trimmed.startsWith("{")) return null;
243
+ try {
244
+ const parsed = JSON.parse(trimmed);
245
+ if (parsed && typeof parsed === "object" && typeof parsed.loggedIn === "boolean") {
246
+ return parsed.loggedIn ? "active" : "stale";
247
+ }
248
+ } catch {}
249
+ return null;
250
+ }
251
+
208
252
  function deriveStatusFromAuthStatusProbe({ exitCode, stdout = "", stderr = "", spawnError }) {
209
253
  if (spawnError) return spawnError.notFound ? "missing" : null;
210
254
  const combined = `${stdout}\n${stderr}`;
255
+ const jsonStatus = deriveStatusFromAuthStatusJson(stdout) || deriveStatusFromAuthStatusJson(stderr);
256
+ if (jsonStatus) return jsonStatus;
211
257
  if (hasAny(UNKNOWN_SUBCOMMAND_PATTERNS, combined)) return null; // fall back
212
258
  if (hasAny(STALE_AUTH_PATTERNS, combined)) return "stale";
213
259
  if (exitCode === 0) return "active";
@@ -219,7 +265,7 @@ function deriveStatusFromAuthStatusProbe({ exitCode, stdout = "", stderr = "", s
219
265
 
220
266
  function deriveStatusFromVersionProbe({ exitCode, stderr, spawnError }) {
221
267
  if (spawnError) return spawnError.notFound ? "missing" : "unknown";
222
- if (typeof exitCode === "number" && exitCode === 0) return "reachable";
268
+ if (typeof exitCode === "number" && exitCode === 0) return "active";
223
269
  const text = String(stderr || "");
224
270
  if (hasAny(STALE_AUTH_PATTERNS, text)) return "stale";
225
271
  return "unknown";
@@ -237,8 +283,8 @@ function deriveLoginStatus({ exitCode, stderr, stdout, timedOut, spawnError }) {
237
283
  function shortMessage({ status, label, exitCode, error, loginUrl }) {
238
284
  const name = label || "Local agent CLI";
239
285
  if (error) return `${name}: ${redactSecrets(String(error))}`;
240
- if (status === "active") return loginUrl ? "Login completed." : "Authenticated.";
241
- if (status === "reachable") return "CLI reachable. Run Login to verify authentication.";
286
+ if (status === "active") return loginUrl ? "Login completed." : "Active.";
287
+ if (status === "reachable") return "Active.";
242
288
  if (status === "stale") return "Authentication needs setup. Run Login, then run the sandbox again.";
243
289
  if (status === "missing") return `${name} not found. Install it and try again.`;
244
290
  if (status === "checking") return `Checking ${name}…`;
@@ -329,16 +375,18 @@ function runCommand({ binary, args, cwd, timeoutMs, stdin }) {
329
375
  // Public API — login / logout / status
330
376
  // ──────────────────────────────────────────────────────────────────────────
331
377
 
332
- async function runAgentLogin({ objectId, name }) {
378
+ async function runAgentLogin({ objectId, name, agentHost }) {
333
379
  const workspaceConfig = await readWorkspaceConfig();
334
380
  const { object, row, rowIndex } = findSandboxRow(workspaceConfig, objectId, name);
335
381
  if (!object) throw notFoundError(`no sandbox-environment object with id ${objectId}`);
336
382
  if (!row) throw notFoundError(`no sandbox row named ${name} in object ${objectId}`);
337
383
 
338
- const { spec, agentHost } = assertAgentHostEligible(row, { requireLogin: true });
384
+ const selectedAgentHost = normalizeAgentHostOverride(agentHost);
385
+ const effectiveRow = applyAgentHostOverride(row, selectedAgentHost);
386
+ const { spec, agentHost: effectiveAgentHost } = assertAgentHostEligible(effectiveRow, { requireLogin: true });
339
387
 
340
- const binary = resolveHostBinary(row, spec);
341
- const cwd = resolveCwd(row);
388
+ const binary = resolveHostBinary(effectiveRow, spec);
389
+ const cwd = resolveCwd(effectiveRow);
342
390
  const startedAt = Date.now();
343
391
 
344
392
  const result = await runCommand({
@@ -356,20 +404,21 @@ async function runAgentLogin({ objectId, name }) {
356
404
 
357
405
  const patch = buildRowPatch({
358
406
  status,
359
- provider: agentHost,
407
+ provider: effectiveAgentHost,
360
408
  checkedAt,
361
409
  exitCode: result.exitCode,
362
410
  loginUrl,
363
411
  label: spec.label,
364
412
  spawnError: result.spawnError
365
413
  });
414
+ Object.assign(patch, buildAgentHostSelectionPatch(selectedAgentHost));
366
415
 
367
416
  await applyRowPatch({ workspaceConfig, object, rowIndex, patch });
368
417
 
369
418
  return {
370
419
  ok: status === "active",
371
420
  status,
372
- provider: agentHost,
421
+ provider: effectiveAgentHost,
373
422
  label: spec.label,
374
423
  binary,
375
424
  cwd,
@@ -384,16 +433,18 @@ async function runAgentLogin({ objectId, name }) {
384
433
  };
385
434
  }
386
435
 
387
- async function runAgentLogout({ objectId, name }) {
436
+ async function runAgentLogout({ objectId, name, agentHost }) {
388
437
  const workspaceConfig = await readWorkspaceConfig();
389
438
  const { object, row, rowIndex } = findSandboxRow(workspaceConfig, objectId, name);
390
439
  if (!object) throw notFoundError(`no sandbox-environment object with id ${objectId}`);
391
440
  if (!row) throw notFoundError(`no sandbox row named ${name} in object ${objectId}`);
392
441
 
393
- const { spec, agentHost } = assertAgentHostEligible(row, { requireLogout: true });
442
+ const selectedAgentHost = normalizeAgentHostOverride(agentHost);
443
+ const effectiveRow = applyAgentHostOverride(row, selectedAgentHost);
444
+ const { spec, agentHost: effectiveAgentHost } = assertAgentHostEligible(effectiveRow, { requireLogout: true });
394
445
 
395
- const binary = resolveHostBinary(row, spec);
396
- const cwd = resolveCwd(row);
446
+ const binary = resolveHostBinary(effectiveRow, spec);
447
+ const cwd = resolveCwd(effectiveRow);
397
448
  const startedAt = Date.now();
398
449
 
399
450
  let exitCode = null;
@@ -422,7 +473,7 @@ async function runAgentLogout({ objectId, name }) {
422
473
 
423
474
  const patch = buildRowPatch({
424
475
  status,
425
- provider: agentHost,
476
+ provider: effectiveAgentHost,
426
477
  checkedAt,
427
478
  exitCode,
428
479
  loginUrl: null,
@@ -432,13 +483,14 @@ async function runAgentLogout({ objectId, name }) {
432
483
  patch.agentAuthLastMessage = spawnError?.notFound
433
484
  ? shortMessage({ status: "missing", label: spec.label })
434
485
  : `${spec.label} logged out — auth will be required before next run.`;
486
+ Object.assign(patch, buildAgentHostSelectionPatch(selectedAgentHost));
435
487
 
436
488
  await applyRowPatch({ workspaceConfig, object, rowIndex, patch });
437
489
 
438
490
  return {
439
491
  ok: !spawnError,
440
492
  status,
441
- provider: agentHost,
493
+ provider: effectiveAgentHost,
442
494
  label: spec.label,
443
495
  binary,
444
496
  cwd,
@@ -451,16 +503,18 @@ async function runAgentLogout({ objectId, name }) {
451
503
  };
452
504
  }
453
505
 
454
- async function checkAgentStatus({ objectId, name }) {
506
+ async function checkAgentStatus({ objectId, name, agentHost }) {
455
507
  const workspaceConfig = await readWorkspaceConfig();
456
508
  const { object, row, rowIndex } = findSandboxRow(workspaceConfig, objectId, name);
457
509
  if (!object) throw notFoundError(`no sandbox-environment object with id ${objectId}`);
458
510
  if (!row) throw notFoundError(`no sandbox row named ${name} in object ${objectId}`);
459
511
 
460
- const { spec, agentHost } = assertAgentHostEligible(row);
512
+ const selectedAgentHost = normalizeAgentHostOverride(agentHost);
513
+ const effectiveRow = applyAgentHostOverride(row, selectedAgentHost);
514
+ const { spec, agentHost: effectiveAgentHost } = assertAgentHostEligible(effectiveRow);
461
515
 
462
- const binary = resolveHostBinary(row, spec);
463
- const cwd = resolveCwd(row);
516
+ const binary = resolveHostBinary(effectiveRow, spec);
517
+ const cwd = resolveCwd(effectiveRow);
464
518
 
465
519
  // Two-phase probe:
466
520
  // 1. If the catalog declares an auth-status subcommand, try it first.
@@ -503,20 +557,21 @@ async function checkAgentStatus({ objectId, name }) {
503
557
 
504
558
  const patch = buildRowPatch({
505
559
  status,
506
- provider: agentHost,
560
+ provider: effectiveAgentHost,
507
561
  checkedAt,
508
562
  exitCode: usedResult.exitCode,
509
563
  loginUrl: null,
510
564
  label: spec.label,
511
565
  spawnError: usedResult.spawnError
512
566
  });
567
+ Object.assign(patch, buildAgentHostSelectionPatch(selectedAgentHost));
513
568
 
514
569
  await applyRowPatch({ workspaceConfig, object, rowIndex, patch });
515
570
 
516
571
  return {
517
572
  ok: status === "active",
518
573
  status,
519
- provider: agentHost,
574
+ provider: effectiveAgentHost,
520
575
  label: spec.label,
521
576
  binary,
522
577
  cwd,
@@ -70,6 +70,7 @@ function deriveSandboxServerlessState(input = {}) {
70
70
 
71
71
  const locality = clean(row.runLocality).toLowerCase() === "serverless" ? "serverless" : "local";
72
72
  const isServerless = locality === "serverless";
73
+ const browserAccess = ["true", "1", "on", "yes"].includes(clean(row.browserAccess).toLowerCase());
73
74
  const adapterId = clean(row.adapter);
74
75
  const adapterChosen = Boolean(adapterId);
75
76
 
@@ -162,8 +163,8 @@ function deriveSandboxServerlessState(input = {}) {
162
163
  label: isServerless ? "Run on the scheduler" : "Run locally",
163
164
  status: "optional",
164
165
  description: isServerless
165
- ? "Once the scheduler, auth, and store are ready, run delegates to the serverless scheduler."
166
- : "Run this workflow in-process.",
166
+ ? `Once the scheduler, auth, and store are ready, run delegates to the serverless scheduler.${browserAccess ? " Browser access travels with the run in the growthub-sandbox-run-v1 envelope (sandbox.browserAccess), so the remote handler grants the same capability as a local run." : ""}`
167
+ : `Run this workflow in-process.${browserAccess ? (adapterId === "local-intelligence" ? " Browser access is executed by the local-intelligence browser bridge." : " Browser access is engaged through the selected agent host's first-party browser integration.") : ""}`,
167
168
  action: inline({ id: "run-sandbox", label: "Run" }),
168
169
  });
169
170
 
@@ -192,6 +193,7 @@ function deriveSandboxServerlessState(input = {}) {
192
193
  version: 1,
193
194
  locality,
194
195
  isServerless,
196
+ browserAccess,
195
197
  adapterChosen,
196
198
  schedulerLinked,
197
199
  schedulerHealthy,
@@ -881,6 +881,7 @@ const OBJECT_TYPE_PRESETS = {
881
881
  "envRefs",
882
882
  "networkAllow",
883
883
  "allowList",
884
+ "browserAccess",
884
885
  "instructions",
885
886
  "command",
886
887
  "timeoutMs",
@@ -1044,6 +1044,12 @@ function validateSandboxEnvironmentRow(row, path, errors) {
1044
1044
  if (row.allowList !== undefined && typeof row.allowList !== "string" && !Array.isArray(row.allowList)) {
1045
1045
  errors.push(`${path}.allowList must be a comma-separated string or array of hostnames`);
1046
1046
  }
1047
+ if (row.browserAccess !== undefined) {
1048
+ const value = String(row.browserAccess).trim().toLowerCase();
1049
+ if (!["", "true", "false", "0", "1", "on", "off"].includes(value)) {
1050
+ errors.push(`${path}.browserAccess must coerce to a boolean (true/false/on/off)`);
1051
+ }
1052
+ }
1047
1053
  if (row.instructions !== undefined && typeof row.instructions !== "string") {
1048
1054
  errors.push(`${path}.instructions must be a string`);
1049
1055
  }
@@ -69,6 +69,7 @@ const SWARM_EXECUTION_TARGET_FIELDS = [
69
69
  "timeoutMs",
70
70
  "networkAllow",
71
71
  "allowList",
72
+ "browserAccess",
72
73
  ];
73
74
 
74
75
  function clean(value) {
@@ -152,6 +153,7 @@ function resolveSwarmExecutionTarget(workspaceConfig, payload = {}) {
152
153
  timeoutMs: String(clampPositiveInt(payload?.timeoutMs || helperRow?.timeoutMs, SWARM_DEFAULT_TIMEOUT_MS)),
153
154
  networkAllow: clean(payload?.networkAllow || helperRow?.networkAllow),
154
155
  allowList: clean(payload?.allowList || helperRow?.allowList),
156
+ browserAccess: clean(payload?.browserAccess || helperRow?.browserAccess),
155
157
  inheritedFromObjectId: helperRow ? WORKSPACE_HELPER_SANDBOX_OBJECT_ID : "",
156
158
  inheritedFromName: helperRow ? clean(helperRow.Name || WORKSPACE_HELPER_ROW_NAME) : "",
157
159
  };
@@ -406,6 +408,7 @@ function buildSandboxRowFromSwarmProposal(workspaceConfig, proposal) {
406
408
  envRefs: "",
407
409
  networkAllow: executionTarget.networkAllow,
408
410
  allowList: executionTarget.allowList,
411
+ browserAccess: executionTarget.browserAccess,
409
412
  instructions: clean(payload.objective),
410
413
  command: "",
411
414
  timeoutMs: executionTarget.timeoutMs,