windows-development-sandbox 1.11.7 → 1.13.0

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 (246) hide show
  1. package/README.md +13 -6
  2. package/config.example.json +15 -4
  3. package/dist/app/features/access/access.def.d.ts +190 -0
  4. package/dist/app/features/access/access.def.js +273 -0
  5. package/dist/app/features/access/access.run.d.ts +79 -0
  6. package/dist/app/features/access/access.run.js +129 -0
  7. package/dist/app/features/access/index.d.ts +2 -0
  8. package/dist/app/features/access/index.js +3 -0
  9. package/dist/app/features/agents/agent-entry.js +5 -0
  10. package/dist/app/features/agents/agents.def.js +1 -0
  11. package/dist/app/features/apps/apps-rows.d.ts +4 -1
  12. package/dist/app/features/apps/apps-rows.js +7 -2
  13. package/dist/app/features/apps/apps.def.d.ts +19 -0
  14. package/dist/app/features/apps/apps.def.js +50 -12
  15. package/dist/app/features/apps/apps.run.d.ts +2 -0
  16. package/dist/app/features/apps/apps.run.js +1 -1
  17. package/dist/app/features/audit/audit.def.d.ts +350 -0
  18. package/dist/app/features/audit/audit.def.js +457 -0
  19. package/dist/app/features/audit/audit.run.d.ts +110 -0
  20. package/dist/app/features/audit/audit.run.js +133 -0
  21. package/dist/app/features/audit/index.d.ts +2 -0
  22. package/dist/app/features/audit/index.js +3 -0
  23. package/dist/app/features/defs.d.ts +349 -4
  24. package/dist/app/features/defs.js +16 -1
  25. package/dist/app/features/files/files.def.js +1 -0
  26. package/dist/app/features/health/health.def.js +1 -0
  27. package/dist/app/features/sandbox/sandbox.def.js +3 -0
  28. package/dist/app/features/sessions/sessions.def.d.ts +36 -0
  29. package/dist/app/features/sessions/sessions.def.js +77 -9
  30. package/dist/app/features/sessions/sessions.run.d.ts +25 -2
  31. package/dist/app/features/sessions/sessions.run.js +71 -3
  32. package/dist/app/features/settings/settings.def.d.ts +8 -0
  33. package/dist/app/features/settings/settings.def.js +17 -0
  34. package/dist/app/features/settings/settings.run.d.ts +3 -0
  35. package/dist/app/features/settings/settings.run.js +16 -2
  36. package/dist/app/features/web/web.def.d.ts +9 -0
  37. package/dist/app/features/web/web.def.js +26 -1
  38. package/dist/app/features/web/web.run.d.ts +3 -0
  39. package/dist/app/features/web/web.run.js +11 -3
  40. package/dist/app/kernel/access.d.ts +33 -0
  41. package/dist/app/kernel/access.js +44 -0
  42. package/dist/app/kernel/define.d.ts +26 -2
  43. package/dist/app/kernel/index.d.ts +1 -0
  44. package/dist/app/kernel/index.js +1 -0
  45. package/dist/app/kernel/rpc.d.ts +5 -2
  46. package/dist/app/registry.d.ts +4 -1
  47. package/dist/app/registry.js +26 -4
  48. package/dist/cli/args.d.ts +0 -3
  49. package/dist/cli/args.js +3 -4
  50. package/dist/cli/commands/index.d.ts +1 -1
  51. package/dist/cli/commands/index.js +1 -1
  52. package/dist/cli/commands/launch/prepare.js +1 -1
  53. package/dist/cli/commands/maintenance/index.d.ts +0 -1
  54. package/dist/cli/commands/maintenance/index.js +0 -1
  55. package/dist/cli/commands/registry.js +3 -2
  56. package/dist/cli/commands/remote.d.ts +2 -0
  57. package/dist/cli/commands/remote.js +49 -0
  58. package/dist/cli/commands/sessions/reopen.js +3 -0
  59. package/dist/cli/commands/web.js +66 -0
  60. package/dist/cli/manifest.d.ts +63 -45
  61. package/dist/cli/manifest.js +33 -18
  62. package/dist/cli/menu/io-menu.d.ts +5 -3
  63. package/dist/cli/menu/io-menu.js +12 -5
  64. package/dist/cli/menu/pages/apps-page.js +2 -15
  65. package/dist/cli/menu/pages/maintenance-page.d.ts +4 -0
  66. package/dist/cli/menu/pages/maintenance-page.js +51 -8
  67. package/dist/cli/menu/remote-menu.d.ts +19 -0
  68. package/dist/cli/menu/remote-menu.js +108 -0
  69. package/dist/cli/menu/views.js +4 -0
  70. package/dist/cli/operations/handler.js +15 -1
  71. package/dist/cli/services/remote-client.d.ts +16 -0
  72. package/dist/cli/services/remote-client.js +141 -0
  73. package/dist/cli/services/sessions-service.d.ts +5 -0
  74. package/dist/cli/services/sessions-service.js +17 -1
  75. package/dist/core/config/defaults.js +12 -2
  76. package/dist/core/local-config/file.js +16 -2
  77. package/dist/core/local-config/overrides.js +7 -0
  78. package/dist/core/run/versions.js +9 -8
  79. package/dist/core/schema/apps.d.ts +9 -0
  80. package/dist/core/schema/apps.js +9 -0
  81. package/dist/core/schema/config.d.ts +23 -3
  82. package/dist/core/schema/config.js +42 -5
  83. package/dist/core/schema/sandbox.d.ts +1 -0
  84. package/dist/core/schema/sandbox.js +11 -3
  85. package/dist/core/schema/web.d.ts +51 -0
  86. package/dist/core/schema/web.js +46 -1
  87. package/dist/core/util/fs.d.ts +5 -0
  88. package/dist/core/util/fs.js +23 -0
  89. package/dist/core/util/index.d.ts +1 -1
  90. package/dist/core/util/index.js +1 -1
  91. package/dist/guest/apps/app-installer.js +50 -4
  92. package/dist/guest/apps/index.d.ts +1 -1
  93. package/dist/guest/apps/index.js +1 -1
  94. package/dist/guest/apps/sources.js +1 -1
  95. package/dist/guest/audit/parse.d.ts +32 -2
  96. package/dist/guest/audit/parse.js +43 -5
  97. package/dist/guest/audit/process-audit.d.ts +3 -1
  98. package/dist/guest/audit/process-audit.js +9 -6
  99. package/dist/guest/boot/boot.js +12 -3
  100. package/dist/guest/boot/guide.d.ts +1 -1
  101. package/dist/guest/boot/guide.js +27 -3
  102. package/dist/guest/daemon/daemon.js +2 -0
  103. package/dist/guest/daemon/idle-shutdown.js +2 -1
  104. package/dist/guest/daemon/sessions.d.ts +23 -2
  105. package/dist/guest/daemon/sessions.js +151 -20
  106. package/dist/guest/entries/daemon.js +2 -1
  107. package/dist/guest/git/decision.js +7 -0
  108. package/dist/guest/sessions/cmdline.d.ts +14 -0
  109. package/dist/guest/sessions/cmdline.js +10 -0
  110. package/dist/guest/setup/addon-mcp.d.ts +2 -0
  111. package/dist/guest/setup/addon-mcp.js +49 -0
  112. package/dist/guest/setup/agent-addons.d.ts +9 -0
  113. package/dist/guest/setup/agent-addons.js +22 -0
  114. package/dist/guest/setup/headroom.d.ts +7 -0
  115. package/dist/guest/setup/headroom.js +60 -0
  116. package/dist/guest/setup/managed-policy.d.ts +2 -1
  117. package/dist/guest/setup/managed-policy.js +15 -6
  118. package/dist/guest/toolchains/plan.js +8 -3
  119. package/dist/guest/toolchains/setup.js +3 -0
  120. package/dist/host/audit/attribute.d.ts +46 -0
  121. package/dist/host/audit/attribute.js +76 -0
  122. package/dist/host/audit/collect.d.ts +29 -3
  123. package/dist/host/audit/collect.js +256 -61
  124. package/dist/host/audit/git-commits.d.ts +29 -0
  125. package/dist/host/audit/git-commits.js +157 -0
  126. package/dist/host/audit/index.d.ts +5 -0
  127. package/dist/host/audit/index.js +6 -0
  128. package/dist/host/audit/log.d.ts +45 -7
  129. package/dist/host/audit/log.js +148 -45
  130. package/dist/host/audit/redact.d.ts +2 -0
  131. package/dist/host/audit/redact.js +67 -0
  132. package/dist/host/channel/channel.d.ts +5 -1
  133. package/dist/host/channel/channel.js +12 -0
  134. package/dist/host/guard/agent-files.js +7 -1
  135. package/dist/host/guard/commands.js +13 -1
  136. package/dist/host/guard/git-config.js +27 -7
  137. package/dist/host/guard/git-dirs.js +78 -3
  138. package/dist/host/guard/mark-of-the-web.js +3 -1
  139. package/dist/host/guard/nested-repos.js +7 -2
  140. package/dist/host/guard/store.js +1 -0
  141. package/dist/host/guard/types.d.ts +2 -0
  142. package/dist/host/launch/mappings.js +11 -1
  143. package/dist/host/launch/project-scan.js +2 -21
  144. package/dist/host/launch/stage.js +4 -0
  145. package/dist/host/life/session-ledger.js +2 -1
  146. package/dist/host/remote/access-store.d.ts +156 -0
  147. package/dist/host/remote/access-store.js +308 -0
  148. package/dist/host/remote/cert.d.ts +20 -0
  149. package/dist/host/remote/cert.js +103 -0
  150. package/dist/host/remote/door-client.d.ts +13 -0
  151. package/dist/host/remote/door-client.js +54 -0
  152. package/dist/host/remote/firewall.d.ts +44 -0
  153. package/dist/host/remote/firewall.js +118 -0
  154. package/dist/host/remote/gate.d.ts +125 -0
  155. package/dist/host/remote/gate.js +391 -0
  156. package/dist/host/remote/index.d.ts +8 -0
  157. package/dist/host/remote/index.js +10 -0
  158. package/dist/host/remote/lan.d.ts +24 -0
  159. package/dist/host/remote/lan.js +88 -0
  160. package/dist/host/remote/tls-store.d.ts +22 -0
  161. package/dist/host/remote/tls-store.js +63 -0
  162. package/dist/host/remote/windows-helper.d.ts +67 -0
  163. package/dist/host/remote/windows-helper.js +129 -0
  164. package/dist/host/web/bridge.d.ts +5 -1
  165. package/dist/host/web/bridge.js +6 -2
  166. package/dist/host/web/browser.js +1 -1
  167. package/dist/host/web/control.d.ts +4 -1
  168. package/dist/host/web/control.js +28 -4
  169. package/dist/host/web/rpc.d.ts +16 -2
  170. package/dist/host/web/rpc.js +37 -4
  171. package/dist/host/web/runner.d.ts +4 -0
  172. package/dist/host/web/runner.js +12 -0
  173. package/dist/host/web/server.d.ts +18 -2
  174. package/dist/host/web/server.js +97 -24
  175. package/dist/host/web/state.d.ts +5 -0
  176. package/dist/host/web/state.js +8 -0
  177. package/dist/runtime/agent.js +184 -18
  178. package/dist/runtime/daemon.js +772 -338
  179. package/dist/runtime/db.js +184 -18
  180. package/dist/runtime/gitguard.js +6 -0
  181. package/dist/runtime/main.js +966 -553
  182. package/dist/runtime/netproxy.js +184 -18
  183. package/dist/runtime/view.js +186 -18
  184. package/dist/selftest/app.test.js +66 -7
  185. package/dist/selftest/audit.test.js +354 -47
  186. package/dist/selftest/channel.test.js +16 -0
  187. package/dist/selftest/cmdline.test.js +16 -0
  188. package/dist/selftest/git-access.test.js +8 -0
  189. package/dist/selftest/hcs.test.js +35 -0
  190. package/dist/selftest/hostile-input.test.js +12 -1
  191. package/dist/selftest/index.js +2 -0
  192. package/dist/selftest/live-daemon.test.js +25 -0
  193. package/dist/selftest/local-config.test.js +30 -0
  194. package/dist/selftest/menu-screens.js +1 -1
  195. package/dist/selftest/menu.test.js +3 -3
  196. package/dist/selftest/remote.test.d.ts +2 -0
  197. package/dist/selftest/remote.test.js +536 -0
  198. package/dist/selftest/repo-guard.test.js +56 -2
  199. package/dist/selftest/resume.test.js +2 -1
  200. package/dist/selftest/sharing.test.js +9 -2
  201. package/dist/selftest/support/engine-golden.js +9 -0
  202. package/dist/selftest/support/fixtures.d.ts +14 -0
  203. package/dist/selftest/support/web-preview.js +157 -0
  204. package/dist/selftest/toolchains.test.js +147 -2
  205. package/dist/selftest/web.test.js +2 -0
  206. package/dist/shared/apps/agent-addons.d.ts +64 -0
  207. package/dist/shared/apps/agent-addons.js +95 -0
  208. package/dist/shared/apps/catalogue.d.ts +33 -2
  209. package/dist/shared/apps/catalogue.js +102 -11
  210. package/dist/shared/apps/index.d.ts +3 -2
  211. package/dist/shared/apps/index.js +3 -2
  212. package/dist/shared/apps/selection.d.ts +2 -0
  213. package/dist/shared/apps/selection.js +7 -0
  214. package/dist/shared/channel/schema.d.ts +23 -0
  215. package/dist/shared/channel/schema.js +8 -0
  216. package/dist/shared/pty/protocol.d.ts +2 -0
  217. package/dist/shared/pty/protocol.js +2 -0
  218. package/dist/sync/watcher.js +9 -1
  219. package/dist/web/index.html +2 -2
  220. package/dist/web/{main-RQFN4WBY.css → main-UAQPI5XD.css} +1 -1
  221. package/dist/web/main-Z2WYAE2Z.js +221 -0
  222. package/native/csharp/HcsHost/AssemblyInfo.cs +5 -3
  223. package/native/csharp/HcsHost/Hcs.cs +9 -0
  224. package/native/csharp/HcsHost/Program.cs +26 -1
  225. package/native/csharp/HcsHost/Share.cs +173 -0
  226. package/native/csharp/HostTools/AssemblyInfo.cs +3 -3
  227. package/native/csharp/HostTools/Program.cs +17 -1
  228. package/native/csharp/HostTools/WebHelper.cs +499 -0
  229. package/native/scripts/get-network-profile.ps1 +19 -0
  230. package/native/scripts/set-folder-access.ps1 +11 -0
  231. package/native/scripts/set-web-firewall.ps1 +103 -0
  232. package/package.json +1 -1
  233. package/sandbox/addons/ccusage/package-lock.json +111 -0
  234. package/sandbox/addons/ccusage/package.json +7 -0
  235. package/sandbox/addons/context7/package-lock.json +1313 -0
  236. package/sandbox/addons/context7/package.json +7 -0
  237. package/sandbox/addons/headroom/requirements.in +1 -0
  238. package/sandbox/addons/headroom/requirements.txt +2922 -0
  239. package/sandbox/addons/playwright-mcp/package-lock.json +56 -0
  240. package/sandbox/addons/playwright-mcp/package.json +7 -0
  241. package/sandbox/csharp/PtyHost.cs +20 -0
  242. package/schemas/config.schema.json +78 -4
  243. package/schemas/sandbox-config.schema.json +2 -20
  244. package/dist/cli/commands/maintenance/audit.d.ts +0 -3
  245. package/dist/cli/commands/maintenance/audit.js +0 -58
  246. package/dist/web/main-K2JMCAZI.js +0 -219
@@ -1,6 +1,8 @@
1
- // `session` and `restart` requests: agent sessions, each in its own ptyhost process. A session runs in the launch
2
- // folder's project (the boot environment) or in a project registered later, which is provisioned first
3
- // (daemon/project).
1
+ // `session`, `restart` and `close` requests: agent sessions and plain shell sessions, each in its own ptyhost process.
2
+ // A session runs in the launch folder's project (the boot environment) or in a project registered later, which is
3
+ // provisioned first (daemon/project). A shell gets the same environment as an agent, without the agent's own
4
+ // environment (its keys) and without the agent's install, hooks and conversation tracking.
5
+ import fs from 'node:fs';
4
6
  import path from 'node:path';
5
7
  import crypto from 'node:crypto';
6
8
  import os from 'node:os';
@@ -10,7 +12,8 @@ import { isWithin } from '../../shared/paths/projects.js';
10
12
  import { now } from '../../shared/channel/run-dir.js';
11
13
  import { SYSTEM } from '../../shared/system.js';
12
14
  import { buildJobs, cargoJobs, linkSlotsFor, usableCpus } from '../../shared/memory/index.js';
13
- import { commandLine } from '../sessions/cmdline.js';
15
+ import { commandLine, shellLaunch } from '../sessions/cmdline.js';
16
+ import { pwshDir } from '../toolchains/tools/pwsh.js';
14
17
  import { freePort } from '../runtime/ports.js';
15
18
  import { primaryProject } from '../boot/project-status.js';
16
19
  import { ensurePtyHost, ptyHostLogTail, startPtyHost, waitForPtyHost } from '../sessions/pty-host.js';
@@ -30,7 +33,11 @@ import { isConversationId } from '../../shared/paths/conversation.js';
30
33
  import { errMsg } from '../../core/util/errors.js';
31
34
  import { provisioner } from './project-provisioner.js';
32
35
  import { IdleShutdown } from './idle-shutdown.js';
33
- import { insidePrimary, respond, safely, saveStatus } from './state.js';
36
+ import { insidePrimary, insideWorkspace, respond, safely, saveStatus } from './state.js';
37
+ /** How long a close waits for the session to end before it ends its ptyhost instead. */
38
+ const CLOSE_WAIT_MS = 8000;
39
+ /** An ended session's ptyhost is remembered this long: its last programs reach the audit up to 15 s later. */
40
+ const ROOTS_KEPT_MS = 10 * 60 * 1000;
34
41
  function terminalTab(id, title, cwd) {
35
42
  const view = [process.execPath, path.join(path.dirname(process.argv[1]), 'view.js'), id];
36
43
  const wt = path.join(toolsDir, 'terminal', 'wt.exe');
@@ -93,16 +100,45 @@ function writeBudget(cpus, memoryGb, jobs, env) {
93
100
  warn('budget.json could not be written: ' + errMsg(e));
94
101
  }
95
102
  }
96
- /** Starts ptyhost for the agent and waits until it listens; a failure carries the tail of ptyhost's own log. */
97
- const launchPtyHost = async (req, remote, env, say, sessions) => {
103
+ /** The agent, installed if it is not yet; its environment may hold its API keys. */
104
+ const agentProgram = async (req, env, say) => {
98
105
  const harness = harnessFor(req);
99
106
  // Never refresh here: main already did, and replacing a running agent's executable would fail.
100
107
  say('Preparing ' + req.harness);
101
108
  await agents.provision(req.harness, harness, env, false);
102
109
  const launch = agents.resolveLaunch(req.harness, harness);
103
110
  const base = [...launch.args, ...harness.arguments];
111
+ return {
112
+ command: commandLine(launch.file, [...base, ...req.args]),
113
+ restartCommand: commandLine(launch.file, [...base, ...(harness.resumeArguments ?? [])]),
114
+ environment: harness.environment,
115
+ };
116
+ };
117
+ /** Where the shells are: PowerShell 7 from the toolchains (or an installed copy), Windows' own PowerShell and cmd. */
118
+ function shellPaths() {
119
+ const candidates = [
120
+ path.join(pwshDir(), 'pwsh.exe'),
121
+ path.join(process.env.ProgramFiles ?? 'C:\\Program Files', 'PowerShell', '7', 'pwsh.exe'),
122
+ ];
123
+ return { pwsh: candidates.find((f) => fs.existsSync(f)) ?? null, powershell: SYSTEM.powershell(), cmd: SYSTEM.cmd() };
124
+ }
125
+ /** A plain shell: no install, no arguments of the request, nothing of the agent's environment. */
126
+ const shellProgram = (shell) => {
127
+ const line = commandLine(shell.file, shell.args);
128
+ return { command: line, restartCommand: line, environment: {} };
129
+ };
130
+ /** Starts ptyhost for the program and waits until it listens; a failure carries the tail of ptyhost's own log. */
131
+ const launchPtyHost = async (req, program, remote, env, sessions, onSpawn) => {
104
132
  const port = await freePort();
105
133
  const token = crypto.randomBytes(24).toString('base64url');
134
+ const sessionEnv = {
135
+ ...withRegistryPath(env),
136
+ ...program.environment,
137
+ ...memoryEnv(env, sessions),
138
+ };
139
+ // Each PowerShell builds its own module path; one inherited from another PowerShell breaks its modules.
140
+ if (req.shell)
141
+ delete sessionEnv.PSModulePath;
106
142
  const child = startPtyHost({
107
143
  exe: ensurePtyHost(),
108
144
  port,
@@ -111,11 +147,12 @@ const launchPtyHost = async (req, remote, env, say, sessions) => {
111
147
  rows: 32,
112
148
  id: req.id,
113
149
  cwd: req.cwd,
114
- env: { ...withRegistryPath(env), ...harness.environment, ...memoryEnv(env, sessions) },
150
+ env: sessionEnv,
115
151
  remote,
116
- command: commandLine(launch.file, [...base, ...req.args]),
117
- restartCommand: commandLine(launch.file, [...base, ...(harness.resumeArguments ?? [])]),
152
+ command: program.command,
153
+ restartCommand: program.restartCommand,
118
154
  });
155
+ onSpawn(child);
119
156
  try {
120
157
  await waitForPtyHost(child, port, 15000);
121
158
  }
@@ -131,6 +168,16 @@ const launchPtyHost = async (req, remote, env, say, sessions) => {
131
168
  /** The launch folder's project, or the registered project this session is for (provisioned first if needed). */
132
169
  const placeFor = async (req, say) => {
133
170
  if (!req.project) {
171
+ // A project set up earlier (the web UI and sandbox shell name it by its folder only): its own environment.
172
+ const known = insidePrimary(req.cwd) ? null : provisioner.provisionedFor(req.cwd);
173
+ if (known && exists(req.cwd))
174
+ return {
175
+ env: known.env,
176
+ hooks: known.spec.hooks ? projectHooks(known.spec.name, known.spec.workspace) : null,
177
+ project: known.spec.name,
178
+ };
179
+ if (!insidePrimary(req.cwd) && insideWorkspace(req.cwd))
180
+ throw new Error('The project in ' + req.cwd + ' is not set up in the sandbox yet: run sandbox start in its folder first.');
134
181
  if (!insidePrimary(req.cwd) || !exists(req.cwd))
135
182
  throw new Error('Folder is not inside the sandbox workspace ' + workspace + ': ' + req.cwd);
136
183
  return { env: process.env, hooks: null, project: primaryProject() };
@@ -160,20 +207,55 @@ export class SessionManager {
160
207
  /** Sessions and projects being set up right now (a headless sandbox must not shut down under them). */
161
208
  starting = 0;
162
209
  idle = new IdleShutdown(() => this.live.size > 0 || this.starting > 0);
210
+ /** Each session's ptyhost (process id and when it ran), for linking the programs it starts to it (audit). */
211
+ ptyHosts = new Map();
163
212
  /** How many sessions are running. */
164
213
  get count() {
165
214
  return this.live.size;
166
215
  }
167
- /** `session` requests: an agent in its own ptyhost, in the launch folder's project or a registered one. */
216
+ /** The sessions' ptyhosts, running or ended within ROOTS_KEPT_MS (audit events arrive after they happen). */
217
+ roots() {
218
+ const cutoff = Date.now() - ROOTS_KEPT_MS;
219
+ for (const [id, r] of this.ptyHosts)
220
+ if (r.to !== undefined && r.to < cutoff)
221
+ this.ptyHosts.delete(id);
222
+ return [...this.ptyHosts.values()];
223
+ }
224
+ rootStarted(id, child, from) {
225
+ if (child.pid)
226
+ this.ptyHosts.set(id, { session: id, pid: child.pid, from });
227
+ child.once('exit', () => this.rootEnded(id));
228
+ }
229
+ rootEnded(id) {
230
+ const r = this.ptyHosts.get(id);
231
+ if (r && r.to === undefined)
232
+ this.ptyHosts.set(id, { ...r, to: Date.now() });
233
+ }
234
+ /**
235
+ * `session` requests: an agent (or a plain shell) in its own ptyhost, in the launch folder's project or a registered
236
+ * one.
237
+ */
168
238
  async start(req) {
239
+ let shell = null;
240
+ let problem = null;
241
+ if (req.shell)
242
+ try {
243
+ shell = shellLaunch(req.shell, shellPaths());
244
+ }
245
+ catch (e) {
246
+ problem = errMsg(e);
247
+ }
248
+ // A shell is listed by its own name (pwsh, powershell, cmd).
249
+ const name = shell?.name ?? (req.shell ? 'shell' : req.harness);
169
250
  let status = {
170
251
  id: req.id,
171
252
  launchId: session.launchId,
172
- harness: req.harness,
253
+ harness: name,
254
+ ...(req.shell ? { kind: 'shell' } : {}),
173
255
  cwd: req.cwd,
174
256
  ...(req.project ? { project: req.project.name } : {}),
175
257
  phase: 'starting',
176
- message: 'Starting ' + req.harness,
258
+ message: 'Starting ' + name,
177
259
  updatedAt: now(),
178
260
  startedAt: now(),
179
261
  };
@@ -186,29 +268,34 @@ export class SessionManager {
186
268
  this.starting++;
187
269
  // Sessions after the first get their own tab right away (it shows the setup progress until the agent starts).
188
270
  if (req.id !== session.launchId && process.env.AGENT_NO_TABS !== '1')
189
- safely('terminal tab', () => terminalTab(req.id, req.harness + ' ' + path.basename(req.cwd), req.cwd));
271
+ safely('terminal tab', () => terminalTab(req.id, name + ' ' + path.basename(req.cwd), req.cwd));
190
272
  try {
273
+ if (problem)
274
+ throw new Error(problem);
191
275
  const place = await placeFor(req, say);
276
+ const program = shell ? shellProgram(shell) : await agentProgram(req, place.env, say);
192
277
  const remote = session.networking && session.attach.enabled;
193
- const { child, port, token } = await launchPtyHost(req, remote, place.env, say, this.live.size + 1);
194
- const resumed = req.harness === 'claude' ? resumedConversation(req.args) : undefined;
278
+ const from = Date.now();
279
+ const { child, port, token } = await launchPtyHost(req, program, remote, place.env, this.live.size + 1, (c) => this.rootStarted(req.id, c, from));
280
+ const agent = shell === null;
281
+ const resumed = agent && req.harness === 'claude' ? resumedConversation(req.args) : undefined;
195
282
  const running = {
196
283
  ...status,
197
284
  ...(resumed ? { conversation: resumed } : {}),
198
285
  project: place.project,
199
286
  phase: 'running',
200
- message: req.harness + ' is running',
287
+ message: name + ' is running',
201
288
  attach: { addresses: remote ? guestAddresses() : [], port, token },
202
289
  };
203
290
  this.live.set(req.id, { status: running, child });
204
291
  child.once('exit', (code) => this.onExit(running, place, code));
205
292
  saveStatus(running);
206
- if (req.harness === 'claude')
293
+ if (agent && req.harness === 'claude')
207
294
  this.trackConversation(running.id, resumed);
208
295
  log('Session ' +
209
296
  req.id +
210
297
  ' started (' +
211
- req.harness +
298
+ name +
212
299
  ' in ' +
213
300
  req.cwd +
214
301
  ', project ' +
@@ -218,6 +305,7 @@ export class SessionManager {
218
305
  ')');
219
306
  }
220
307
  catch (e) {
308
+ this.rootEnded(req.id);
221
309
  saveStatus({ ...status, phase: 'failed', message: errMsg(e) });
222
310
  warn('Session ' + req.id + ' failed: ' + errMsg(e));
223
311
  this.idle.check();
@@ -246,15 +334,53 @@ export class SessionManager {
246
334
  this.idle.check();
247
335
  }
248
336
  }
249
- /** `restart` requests: the matching sessions restart their agent (continuing the conversation where it can). */
337
+ /**
338
+ * `restart` requests: the matching sessions restart their agent (continuing the conversation where it can). Shells
339
+ * are left alone: restarting one would only throw away what it was doing.
340
+ */
250
341
  async restart(req) {
251
342
  const targets = [...this.live.values()].filter((s) => s.status.attach &&
343
+ s.status.kind !== 'shell' &&
252
344
  (req.harness === null || s.status.harness === req.harness) &&
253
345
  (!req.cwd || isWithin(req.cwd, s.status.cwd)));
254
346
  for (const s of targets)
255
347
  await sendControl({ addresses: ['127.0.0.1'], port: s.status.attach.port, token: s.status.attach.token }, FrameType.Restart);
256
348
  respond(req.id, { ok: true, message: 'restarted ' + targets.length + ' session(s)' });
257
349
  }
350
+ /**
351
+ * `close` requests: the session's ptyhost ends its program and everything it started (ProcessTree), then exits as
352
+ * if the program had. One that does not end in time has its ptyhost ended instead (closing the pseudo console ends
353
+ * what is attached to it).
354
+ */
355
+ async close(req) {
356
+ const entry = this.live.get(req.session);
357
+ if (!entry?.status.attach) {
358
+ respond(req.id, { ok: false, message: 'Session ' + req.session + ' is not running.' });
359
+ return;
360
+ }
361
+ const { child, status } = entry;
362
+ const ended = new Promise((resolve) => {
363
+ if (child.exitCode !== null || child.signalCode !== null)
364
+ return resolve(true);
365
+ const timer = setTimeout(() => resolve(false), CLOSE_WAIT_MS);
366
+ child.once('exit', () => {
367
+ clearTimeout(timer);
368
+ resolve(true);
369
+ });
370
+ });
371
+ try {
372
+ await sendControl({ addresses: ['127.0.0.1'], port: status.attach.port, token: status.attach.token }, FrameType.Close);
373
+ }
374
+ catch (e) {
375
+ warn('Session ' + req.session + ' did not take the close request: ' + errMsg(e));
376
+ }
377
+ if (!(await ended)) {
378
+ warn('Session ' + req.session + ' did not end in time; ending its session host.');
379
+ safely('ending a session host', () => child.kill());
380
+ }
381
+ log('Session ' + req.session + ' closed on request');
382
+ respond(req.id, { ok: true, message: 'Closed ' + status.harness + ' session ' + req.session + '.' });
383
+ }
258
384
  /**
259
385
  * Finds out which of Claude's conversation files this session writes (it appears with the first message) and puts
260
386
  * it in the status, so the host can reopen this very conversation. A reopened session starts with the one it was
@@ -299,6 +425,11 @@ export class SessionManager {
299
425
  attach: undefined,
300
426
  });
301
427
  log('Session ' + running.id + ' ended (exit ' + code + ')');
428
+ // A shell ran no agent: no post-agent hooks, nothing of an agent's to save.
429
+ if (running.kind === 'shell') {
430
+ this.idle.check();
431
+ return;
432
+ }
302
433
  if (place.hooks)
303
434
  void runHooksAsync('post-agent', place.env, place.hooks).catch((e) => warn('post-agent hooks failed: ' + errMsg(e)));
304
435
  else
@@ -9,13 +9,14 @@ import { log, warn } from '../runtime/log.js';
9
9
  import { adoptBootSecrets } from '../runtime/secrets.js';
10
10
  import { errMsg } from '../../core/util/errors.js';
11
11
  import { Daemon } from '../daemon/daemon.js';
12
+ import { sessions } from '../daemon/sessions.js';
12
13
  import { governor } from '../governor/index.js';
13
14
  process.on('uncaughtException', (e) => warn('daemon: unexpected error (continuing): ' + (e.stack ?? errMsg(e))));
14
15
  process.on('unhandledRejection', (e) => warn('daemon: unexpected error (continuing): ' + errMsg(e)));
15
16
  adoptBootSecrets();
16
17
  log('Session daemon started (pid ' + process.pid + ').');
17
18
  safely('port forwards', startRelays);
18
- safely('audit', startProcessCollector);
19
+ safely('audit', () => startProcessCollector(() => sessions.roots()));
19
20
  safely('memory governor', () => governor.start());
20
21
  log('Memory governor: ' + governor.describe());
21
22
  new Daemon().start();
@@ -57,6 +57,8 @@ const LISTING = {
57
57
  /** Global options that take a value (`git -C dir status`). Config overrides (-c, --config-env) are refused outright. */
58
58
  const WITH_VALUE = new Set(['-C', '--namespace']);
59
59
  const FORBIDDEN_GLOBAL = /^(-c|--config-env|--exec-path|--git-dir|--work-tree|--super-prefix)(=|$)/;
60
+ /** Options of reading commands that run a program or write a file (grep -O<cmd>, ls-remote --upload-pack, --output). */
61
+ const RUNS_OR_WRITES = /^(--open-files-in-pager|--upload-pack|--output|--ext-diff)(=|$)/;
60
62
  export function gitDecision(args, access) {
61
63
  if (access === 'full')
62
64
  return { allow: true };
@@ -72,6 +74,11 @@ export function gitDecision(args, access) {
72
74
  i += WITH_VALUE.has(args[i]) ? 2 : 1;
73
75
  }
74
76
  const [command, ...rest] = args.slice(i);
77
+ const runsOrWrites = rest.find((a) => RUNS_OR_WRITES.test(a) ||
78
+ (command === 'ls-remote' && /^-u(.|$)/.test(a)) ||
79
+ (command === 'grep' && /^-O/.test(a)));
80
+ if (runsOrWrites)
81
+ return { allow: false, reason: 'git ' + runsOrWrites + ' is not allowed: git is read-only in this sandbox.' };
75
82
  if (command === undefined || command === '--version' || READ_ONLY.has(command) || (LISTING[command]?.(rest) ?? false))
76
83
  return { allow: true };
77
84
  return {
@@ -10,3 +10,17 @@ export declare function quoteArg(arg: string): string;
10
10
  export declare function cmdArgs(args: string[]): string;
11
11
  /** The command line ptyhost starts; when the program is cmd.exe its arguments are quoted for cmd (cmdArgs). */
12
12
  export declare function commandLine(file: string, args: string[]): string;
13
+ /** A plain shell session's program: which shell, its absolute path and its arguments. */
14
+ export interface ShellLaunch {
15
+ readonly name: 'pwsh' | 'powershell' | 'cmd';
16
+ readonly file: string;
17
+ readonly args: string[];
18
+ }
19
+ /** Where the shells are in this sandbox (pwsh: null when PowerShell 7 is not installed). */
20
+ export interface ShellPaths {
21
+ readonly pwsh: string | null;
22
+ readonly powershell: string;
23
+ readonly cmd: string;
24
+ }
25
+ /** The shell a session asked for runs as (auto: PowerShell 7 when present, else Windows PowerShell). Pure. */
26
+ export declare function shellLaunch(asked: 'auto' | ShellLaunch['name'], paths: ShellPaths): ShellLaunch;
@@ -31,3 +31,13 @@ export function commandLine(file, args) {
31
31
  ? [quoteArg(file), cmdArgs(args)].filter(Boolean).join(' ')
32
32
  : [file, ...args].map(quoteArg).join(' ');
33
33
  }
34
+ /** The shell a session asked for runs as (auto: PowerShell 7 when present, else Windows PowerShell). Pure. */
35
+ export function shellLaunch(asked, paths) {
36
+ if (asked === 'cmd')
37
+ return { name: 'cmd', file: paths.cmd, args: [] };
38
+ if (asked === 'powershell' || (asked === 'auto' && !paths.pwsh))
39
+ return { name: 'powershell', file: paths.powershell, args: ['-NoLogo'] };
40
+ if (!paths.pwsh)
41
+ throw new Error('PowerShell 7 is not installed in this sandbox (toolchains.pwsh). Open powershell or cmd instead, or turn pwsh on.');
42
+ return { name: 'pwsh', file: paths.pwsh, args: ['-NoLogo'] };
43
+ }
@@ -0,0 +1,2 @@
1
+ /** Sets the add-ons' MCP servers in the sandbox's .claude.json; the names that are on. */
2
+ export declare function writeAddonMcpServers(): string[];
@@ -0,0 +1,49 @@
1
+ // The agent add-ons' MCP servers (Playwright, Context7) in the sandbox's own Claude Code: user-scope servers named
2
+ // sbx-... in its .claude.json, set every boot while the add-on is on and removed again when it is off. Your servers
3
+ // (from this PC or added in the sandbox) are never touched, and nothing on this PC changes.
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { addonMcpServer, APPS, withAddonMcpServers } from '../../shared/apps/index.js';
7
+ import { writeJsonAtomic } from '../../shared/io/json.js';
8
+ import { claudeDir } from '../runtime/session.js';
9
+ import { log, warn } from '../runtime/log.js';
10
+ import { installedMcpAddons } from './agent-addons.js';
11
+ /** The names of every MCP server an add-on can add (the ones the sandbox may remove). */
12
+ const ADDON_MCP_NAMES = APPS.flatMap((a) => (a.mcp ? [a.mcp.name] : []));
13
+ /** Sets the add-ons' MCP servers in the sandbox's .claude.json; the names that are on. */
14
+ export function writeAddonMcpServers() {
15
+ const servers = {};
16
+ for (const { id, folder } of installedMcpAddons()) {
17
+ const mcp = APPS.find((a) => a.id === id)?.mcp;
18
+ if (mcp)
19
+ servers[mcp.name] = addonMcpServer(folder, mcp);
20
+ }
21
+ const file = path.join(claudeDir, '.claude.json');
22
+ let state = {};
23
+ if (fs.existsSync(file)) {
24
+ try {
25
+ const text = fs.readFileSync(file, 'utf8');
26
+ state = JSON.parse(text.charCodeAt(0) === 0xfeff ? text.slice(1) : text);
27
+ }
28
+ catch {
29
+ // Never rewrite a file Claude Code could not read either: its login and state are in it.
30
+ warn('Claude Code MCP servers from Apps: ' + file + ' is not valid JSON, left as it is');
31
+ return [];
32
+ }
33
+ }
34
+ else if (!Object.keys(servers).length)
35
+ return [];
36
+ if (typeof state !== 'object' || state === null || Array.isArray(state))
37
+ return [];
38
+ const current = state.mcpServers;
39
+ const before = current && typeof current === 'object' && !Array.isArray(current) ? current : {};
40
+ const after = withAddonMcpServers(before, servers, ADDON_MCP_NAMES);
41
+ if (JSON.stringify(before) !== JSON.stringify(after)) {
42
+ fs.mkdirSync(claudeDir, { recursive: true });
43
+ writeJsonAtomic(file, { ...state, mcpServers: after });
44
+ }
45
+ const names = Object.keys(servers);
46
+ if (names.length)
47
+ log('Claude Code MCP servers from Apps: ' + names.join(', '));
48
+ return names;
49
+ }
@@ -0,0 +1,9 @@
1
+ /** The add-on's program when it is chosen and installed, else null. */
2
+ export declare function installedAddon(id: string): string | null;
3
+ /** rtk.exe when rtk is chosen and installed, else null. */
4
+ export declare function installedRtk(): string | null;
5
+ /** The add-ons with an MCP server that are chosen and installed: their folder and server. */
6
+ export declare function installedMcpAddons(): {
7
+ id: string;
8
+ folder: string;
9
+ }[];
@@ -0,0 +1,22 @@
1
+ // Agent add-ons in this sandbox: an add-on is plugged into the agents only while it is chosen (apps.install) AND its
2
+ // program is really there (a failed download adds no hook, proxy or MCP server that would fail on every use).
3
+ import { APPS, appById } from '../../shared/apps/index.js';
4
+ import { findExe } from '../apps/find-exe.js';
5
+ import { dryRun } from '../runtime/paths.js';
6
+ import { session } from '../runtime/session.js';
7
+ import { tool } from '../toolchains/tool-env.js';
8
+ /** The add-on's program when it is chosen and installed, else null. */
9
+ export function installedAddon(id) {
10
+ const entry = appById(id);
11
+ if (dryRun || !entry || !(session.apps?.install ?? []).includes(id))
12
+ return null;
13
+ return findExe(tool('apps', id), entry.exe);
14
+ }
15
+ /** rtk.exe when rtk is chosen and installed, else null. */
16
+ export function installedRtk() {
17
+ return installedAddon('rtk');
18
+ }
19
+ /** The add-ons with an MCP server that are chosen and installed: their folder and server. */
20
+ export function installedMcpAddons() {
21
+ return APPS.filter((a) => a.mcp && installedAddon(a.id)).map((a) => ({ id: a.id, folder: tool('apps', a.id) }));
22
+ }
@@ -0,0 +1,7 @@
1
+ /** The proxy's log (it redacts keys and tokens in what it logs). */
2
+ export declare const HEADROOM_LOG: string;
3
+ /**
4
+ * Starts the proxy when Headroom is chosen and installed; its base URL once it answers, else null (Claude Code then
5
+ * talks to the API directly).
6
+ */
7
+ export declare function startHeadroom(env: NodeJS.ProcessEnv): Promise<string | null>;
@@ -0,0 +1,60 @@
1
+ // The Headroom agent add-on: its proxy runs detached on 127.0.0.1 for the sandbox's life, and Claude Code is pointed at
2
+ // it (managed-policy.ts) only once it answers as Headroom. It forwards only to Anthropic's API, starts without the
3
+ // agents' keys in its environment and with HEADROOM_OFFLINE (no telemetry, update checks or model downloads).
4
+ import fs from 'node:fs';
5
+ import http from 'node:http';
6
+ import path from 'node:path';
7
+ import { spawn } from 'node:child_process';
8
+ import { HEADROOM_PORT, headroomArgs, headroomEnv, headroomUrl } from '../../shared/apps/index.js';
9
+ import { logsDir } from '../runtime/paths.js';
10
+ import { portOpen, waitForPort } from '../runtime/ports.js';
11
+ import { log } from '../runtime/log.js';
12
+ import { installedAddon } from './agent-addons.js';
13
+ /** The proxy's log (it redacts keys and tokens in what it logs). */
14
+ export const HEADROOM_LOG = path.join(logsDir, 'headroom.log');
15
+ /** Whether Headroom's own health check answers on the port (not some other program that took it). */
16
+ function answersAsHeadroom(port) {
17
+ return new Promise((resolve) => {
18
+ const req = http.get({ host: '127.0.0.1', port, path: '/livez', timeout: 3000 }, (res) => {
19
+ let body = '';
20
+ res.setEncoding('utf8');
21
+ res.on('data', (chunk) => {
22
+ if (body.length < 4096)
23
+ body += chunk;
24
+ });
25
+ res.on('end', () => resolve(res.statusCode === 200 && /"service"\s*:\s*"headroom-proxy"/.test(body)));
26
+ });
27
+ req.on('timeout', () => req.destroy());
28
+ req.on('error', () => resolve(false));
29
+ });
30
+ }
31
+ /**
32
+ * Starts the proxy when Headroom is chosen and installed; its base URL once it answers, else null (Claude Code then
33
+ * talks to the API directly).
34
+ */
35
+ export async function startHeadroom(env) {
36
+ const exe = installedAddon('headroom');
37
+ if (!exe)
38
+ return null;
39
+ if (env.ANTHROPIC_BASE_URL) {
40
+ log('Headroom: not used, ANTHROPIC_BASE_URL is set in the agent environment (' + env.ANTHROPIC_BASE_URL + ')');
41
+ return null;
42
+ }
43
+ const port = HEADROOM_PORT;
44
+ if (!(await portOpen(port))) {
45
+ const out = fs.openSync(HEADROOM_LOG, 'a');
46
+ spawn(exe, headroomArgs(port), {
47
+ detached: true,
48
+ stdio: ['ignore', out, out],
49
+ windowsHide: true,
50
+ env: headroomEnv(env),
51
+ }).unref();
52
+ fs.closeSync(out);
53
+ if (!(await waitForPort(port, 90)))
54
+ throw new Error('the Headroom proxy did not start on port ' + port + '; see ' + HEADROOM_LOG);
55
+ }
56
+ if (!(await answersAsHeadroom(port)))
57
+ throw new Error('port ' + port + ' is taken by another program; Claude Code talks to the API directly');
58
+ log('Headroom: Claude Code reaches the API through ' + headroomUrl(port) + ' (log: ' + HEADROOM_LOG + ')');
59
+ return headroomUrl(port);
60
+ }
@@ -1,3 +1,4 @@
1
1
  import type { GitAccess } from '../../core/types.js';
2
2
  export declare function managedSettingsFile(): string;
3
- export declare function writeManagedSettings(access: GitAccess): void;
3
+ /** `headroom`: the proxy's base URL when it answered at this boot (null: Claude Code talks to the API directly). */
4
+ export declare function writeManagedSettings(access: GitAccess, headroom?: string | null): void;
@@ -1,20 +1,29 @@
1
1
  // Claude Code managed settings for the sandbox (C:\Program Files\ClaudeCode\managed-settings.json). Managed settings
2
- // have the highest precedence and cannot be overridden by user or project settings, so they carry the hard rules:
3
- // today, the git access mode. Rewritten every boot; removed when there is nothing to enforce.
2
+ // have the highest precedence and cannot be overridden by user or project settings, so they carry the hard rules (the
3
+ // git access mode), the sandbox's own hooks (rtk, when it is installed) and the Headroom proxy's address while it runs
4
+ // (env.ANTHROPIC_BASE_URL). Rewritten every boot; removed when there is nothing to write. Your Claude settings from
5
+ // this PC are never changed for this.
4
6
  import fs from 'node:fs';
5
7
  import path from 'node:path';
8
+ import { claudeManagedSettings } from '../../shared/apps/index.js';
6
9
  import { gitDenyRules } from './git-rules.js';
7
10
  import { managedClaudeMd } from '../boot/guide.js';
11
+ import { installedRtk } from './agent-addons.js';
12
+ import { log } from '../runtime/log.js';
8
13
  export function managedSettingsFile() {
9
14
  return path.join(path.dirname(managedClaudeMd()), 'managed-settings.json');
10
15
  }
11
- export function writeManagedSettings(access) {
16
+ /** `headroom`: the proxy's base URL when it answered at this boot (null: Claude Code talks to the API directly). */
17
+ export function writeManagedSettings(access, headroom = null) {
12
18
  const file = managedSettingsFile();
13
- const deny = gitDenyRules(access);
14
- if (deny.length === 0) {
19
+ const rtk = installedRtk();
20
+ const settings = claudeManagedSettings(gitDenyRules(access), rtk, headroom);
21
+ if (!settings) {
15
22
  fs.rmSync(file, { force: true });
16
23
  return;
17
24
  }
18
25
  fs.mkdirSync(path.dirname(file), { recursive: true });
19
- fs.writeFileSync(file, JSON.stringify({ permissions: { deny } }, null, 2) + '\n', 'utf8');
26
+ fs.writeFileSync(file, JSON.stringify(settings, null, 2) + '\n', 'utf8');
27
+ if (rtk)
28
+ log("rtk: Claude Code's Bash commands go through it (hook in " + file + ')');
20
29
  }
@@ -1,4 +1,4 @@
1
- import { appsNeedMesa, setupApps } from '../apps/index.js';
1
+ import { appsNeedMesa, appsNeedUv, setupApps } from '../apps/index.js';
2
2
  import { dryRun } from '../runtime/paths.js';
3
3
  import { wanted } from './detect.js';
4
4
  import { DATABASE_NAMES, setupDatabase } from '../databases/index.js';
@@ -85,7 +85,12 @@ export function plan(config, versions, o) {
85
85
  },
86
86
  { name: 'Deno', enabled: wanted(config.deno.enabled, 'deno'), install: (env) => setupDeno(env) },
87
87
  { name: 'Bun', enabled: wanted(config.bun.enabled, 'bun'), install: (env) => setupBun(env) },
88
- { name: 'uv', enabled: wanted(config.uv.enabled, 'uv'), install: (env) => setupUv(env) },
88
+ // Headroom (an agent add-on) installs with uv: the uv step runs for it too, before the apps.
89
+ {
90
+ name: 'uv',
91
+ enabled: wanted(config.uv.enabled, 'uv') || appsNeedUv(o.apps),
92
+ install: (env) => setupUv(env),
93
+ },
89
94
  { name: 'PowerShell 7', enabled: wanted(config.pwsh.enabled, 'pwsh'), install: (env) => setupPwsh(env, o.refresh) },
90
95
  // The openssl tool comes from Git's install: after Git.
91
96
  {
@@ -111,7 +116,7 @@ export function plan(config, versions, o) {
111
116
  name: 'Apps',
112
117
  enabled: apps,
113
118
  shared: true,
114
- after: ['Mesa 3D', 'Visual C++ runtime'],
119
+ after: ['Mesa 3D', 'Visual C++ runtime', 'uv'],
115
120
  install: (env) => setupApps(env, o.apps, { gitAccess: o.gitAccess, refresh: o.refresh }),
116
121
  },
117
122
  {
@@ -17,6 +17,9 @@ export async function setupToolchains(config, versions, options, run = {}) {
17
17
  env.summary.push(...part.summary);
18
18
  }
19
19
  Object.assign(env.vars, config.environment);
20
+ // The git guard's variables (AGENT_GIT_ACCESS, GIT_DIR, GIT_CONFIG_*, BASH_ENV) always win over
21
+ // toolchains.environment.
22
+ Object.assign(env.vars, envs.get('Git')?.vars);
20
23
  log('Set up ' + steps.length + ' toolchains and databases.');
21
24
  return env;
22
25
  }