windows-development-sandbox 1.18.3 → 1.20.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 (216) hide show
  1. package/README.md +1 -1
  2. package/config.example.json +9 -0
  3. package/dist/app/features/accounts/accounts.def.d.ts +4 -0
  4. package/dist/app/features/accounts/accounts.run.d.ts +1 -0
  5. package/dist/app/features/accounts/services.def.d.ts +3 -0
  6. package/dist/app/features/accounts/services.def.js +4 -2
  7. package/dist/app/features/accounts/services.run.d.ts +1 -0
  8. package/dist/app/features/accounts/services.run.js +3 -1
  9. package/dist/app/features/agent-commands/agent-commands.def.d.ts +37 -0
  10. package/dist/app/features/agent-commands/agent-commands.def.js +91 -0
  11. package/dist/app/features/agent-commands/agent-commands.run.d.ts +28 -0
  12. package/dist/app/features/agent-commands/agent-commands.run.js +35 -0
  13. package/dist/app/features/agent-commands/index.d.ts +2 -0
  14. package/dist/app/features/agent-commands/index.js +3 -0
  15. package/dist/app/features/agents/agents.def.d.ts +14 -1
  16. package/dist/app/features/agents/agents.def.js +61 -6
  17. package/dist/app/features/agents/agents.run.d.ts +1 -0
  18. package/dist/app/features/agents/agents.run.js +8 -1
  19. package/dist/app/features/agents/claude-env.def.d.ts +36 -0
  20. package/dist/app/features/agents/claude-env.def.js +305 -0
  21. package/dist/app/features/agents/env.def.d.ts +163 -0
  22. package/dist/app/features/agents/env.def.js +184 -0
  23. package/dist/app/features/agents/env.run.d.ts +43 -0
  24. package/dist/app/features/agents/env.run.js +86 -0
  25. package/dist/app/features/agents/index.d.ts +2 -0
  26. package/dist/app/features/agents/index.js +2 -0
  27. package/dist/app/features/defs.d.ts +150 -1
  28. package/dist/app/features/defs.js +11 -1
  29. package/dist/app/features/sandbox/sandbox.def.d.ts +22 -0
  30. package/dist/app/features/sandbox/sandbox.def.js +41 -14
  31. package/dist/app/features/sandbox/sandbox.run.d.ts +20 -0
  32. package/dist/app/features/sandbox/sandbox.run.js +41 -1
  33. package/dist/app/features/settings/categories.def.js +5 -2
  34. package/dist/app/features/settings/settings-rows.js +5 -1
  35. package/dist/app/features/settings/settings.run.d.ts +8 -0
  36. package/dist/app/features/settings/settings.run.js +9 -0
  37. package/dist/app/features/updates/updates.run.js +12 -2
  38. package/dist/app/features/vpn/vpn.def.d.ts +33 -0
  39. package/dist/app/features/vpn/vpn.def.js +30 -6
  40. package/dist/app/features/vpn/vpn.run.d.ts +11 -0
  41. package/dist/app/features/vpn/vpn.run.js +4 -1
  42. package/dist/app/kernel/rpc.d.ts +1 -1
  43. package/dist/app/registry.js +9 -2
  44. package/dist/bin/agent-sandbox.js +2 -2
  45. package/dist/bin/{chunk-M6XUQK53.js → chunk-2IMT7ZCT.js} +1 -1
  46. package/dist/bin/{chunk-3G53NNZP.js → chunk-DK4NTBUF.js} +69 -26
  47. package/dist/bin/{chunk-FQWYCF6L.js → chunk-Z2P5OGKL.js} +6427 -5259
  48. package/dist/bin/claude-host.js +2 -2
  49. package/dist/bin/claude-sbx.js +2 -2
  50. package/dist/bin/codex-sbx.js +2 -2
  51. package/dist/bin/gemini-sbx.js +2 -2
  52. package/dist/bin/sandbox.js +2 -2
  53. package/dist/bin/sbx-hide.js +2 -2
  54. package/dist/bin/sbx-show.js +2 -2
  55. package/dist/cli/commands/agent-commands.d.ts +0 -1
  56. package/dist/cli/commands/agent-commands.js +2 -32
  57. package/dist/cli/commands/index.d.ts +1 -1
  58. package/dist/cli/commands/index.js +1 -1
  59. package/dist/cli/commands/launch/launch.js +6 -3
  60. package/dist/cli/commands/launch/leftover-offer.d.ts +3 -0
  61. package/dist/cli/commands/launch/leftover-offer.js +26 -0
  62. package/dist/cli/commands/registry.js +3 -2
  63. package/dist/cli/commands/repair/checks/sandbox.js +29 -2
  64. package/dist/cli/manifest.d.ts +1 -1
  65. package/dist/cli/manifest.js +1 -1
  66. package/dist/cli/menu/menu.d.ts +1 -1
  67. package/dist/cli/menu/menu.js +3 -3
  68. package/dist/cli/menu/pages/agents-page.d.ts +7 -2
  69. package/dist/cli/menu/pages/agents-page.js +67 -11
  70. package/dist/cli/menu/pages/maintenance-page.js +1 -2
  71. package/dist/cli/menu/pages/settings-page.js +1 -0
  72. package/dist/cli/menu/views.js +2 -1
  73. package/dist/cli/services/agent-commands-service.d.ts +2 -0
  74. package/dist/cli/services/agent-commands-service.js +13 -7
  75. package/dist/cli/services/vpn-service.d.ts +5 -1
  76. package/dist/cli/services/vpn-service.js +6 -1
  77. package/dist/cli/tui/dialogs/text-screen.js +2 -1
  78. package/dist/cli/tui/terminal.js +3 -1
  79. package/dist/core/config/defaults.js +9 -0
  80. package/dist/core/detect/toolchains.js +9 -0
  81. package/dist/core/disk.d.ts +1 -5
  82. package/dist/core/disk.js +1 -16
  83. package/dist/core/schema/codex.d.ts +29 -0
  84. package/dist/core/schema/codex.js +24 -0
  85. package/dist/core/schema/config.d.ts +46 -14
  86. package/dist/core/schema/config.js +2 -0
  87. package/dist/core/schema/index.d.ts +1 -0
  88. package/dist/core/schema/index.js +1 -0
  89. package/dist/core/schema/toolchains.d.ts +4 -0
  90. package/dist/core/schema/toolchains.js +3 -0
  91. package/dist/core/types.d.ts +2 -0
  92. package/dist/guest/accounts/apply.d.ts +6 -6
  93. package/dist/guest/agents/agent-files.d.ts +26 -6
  94. package/dist/guest/agents/agent-files.js +84 -14
  95. package/dist/guest/agents/local-state.d.ts +5 -2
  96. package/dist/guest/agents/local-state.js +24 -10
  97. package/dist/guest/boot/boot.js +10 -5
  98. package/dist/guest/daemon/heartbeat.d.ts +7 -2
  99. package/dist/guest/daemon/heartbeat.js +34 -15
  100. package/dist/guest/daemon/idle-shutdown.js +2 -0
  101. package/dist/guest/daemon/sessions.js +2 -1
  102. package/dist/guest/entries/daemon.js +1 -1
  103. package/dist/guest/toolchains/installers.d.ts +6 -10
  104. package/dist/guest/toolchains/installers.js +4 -12
  105. package/dist/guest/toolchains/native/autosdk.d.ts +3 -0
  106. package/dist/guest/toolchains/native/autosdk.js +50 -0
  107. package/dist/guest/toolchains/native/unreal.js +3 -0
  108. package/dist/guest/toolchains/native/vcruntime.d.ts +0 -3
  109. package/dist/guest/toolchains/native/vcruntime.js +2 -5
  110. package/dist/guest/toolchains/native/visual-studio.d.ts +2 -2
  111. package/dist/guest/toolchains/native/visual-studio.js +100 -16
  112. package/dist/guest/toolchains/native/vs-request.d.ts +21 -0
  113. package/dist/guest/toolchains/native/vs-request.js +60 -1
  114. package/dist/guest/toolchains/not-yet.d.ts +1 -1
  115. package/dist/guest/toolchains/not-yet.js +3 -3
  116. package/dist/guest/toolchains/persisted.js +2 -7
  117. package/dist/guest/toolchains/plan.js +9 -4
  118. package/dist/guest/toolchains/signature.d.ts +5 -0
  119. package/dist/guest/toolchains/signature.js +22 -0
  120. package/dist/guest/vpn/tunnel.d.ts +3 -0
  121. package/dist/guest/vpn/tunnel.js +10 -12
  122. package/dist/guest/vpn/vpn.js +6 -1
  123. package/dist/host/accounts/leases.d.ts +3 -3
  124. package/dist/host/accounts/source.js +1 -1
  125. package/dist/host/agent-commands/commands.js +1 -1
  126. package/dist/host/agent-commands/forwarders.d.ts +14 -2
  127. package/dist/host/agent-commands/forwarders.js +31 -13
  128. package/dist/host/agent-commands/reconcile.d.ts +2 -1
  129. package/dist/host/agent-commands/reconcile.js +7 -8
  130. package/dist/host/agent-commands/user-path.d.ts +5 -0
  131. package/dist/host/agent-commands/user-path.js +13 -0
  132. package/dist/host/health/index.d.ts +1 -0
  133. package/dist/host/health/index.js +1 -0
  134. package/dist/host/health/leftovers.d.ts +3 -0
  135. package/dist/host/health/leftovers.js +40 -0
  136. package/dist/host/health/stale.d.ts +48 -0
  137. package/dist/host/health/stale.js +40 -0
  138. package/dist/host/launch/session-file.js +1 -0
  139. package/dist/host/life/crash-report.d.ts +1 -1
  140. package/dist/host/life/crash-report.js +5 -3
  141. package/dist/host/life/last-end.d.ts +7 -0
  142. package/dist/host/life/last-end.js +21 -0
  143. package/dist/host/life/life-record.d.ts +3 -0
  144. package/dist/host/life/life-record.js +2 -2
  145. package/dist/host/life/stop-events.d.ts +3 -1
  146. package/dist/host/life/stop-events.js +59 -0
  147. package/dist/host/life/stop-warning.js +3 -0
  148. package/dist/host/profile/accounts.js +2 -4
  149. package/dist/host/profile/claude-login.d.ts +3 -2
  150. package/dist/host/profile/claude-login.js +3 -13
  151. package/dist/host/sandbox/cleanup.d.ts +37 -0
  152. package/dist/host/sandbox/cleanup.js +82 -0
  153. package/dist/host/sandbox/index.d.ts +1 -0
  154. package/dist/host/sandbox/index.js +1 -0
  155. package/dist/host/sandbox/platform.js +1 -1
  156. package/dist/host/vpn/index.d.ts +2 -1
  157. package/dist/host/vpn/index.js +2 -1
  158. package/dist/host/vpn/last.d.ts +28 -0
  159. package/dist/host/vpn/last.js +43 -0
  160. package/dist/host/vpn/login.d.ts +3 -1
  161. package/dist/host/vpn/login.js +17 -3
  162. package/dist/runtime/agent.js +31 -1
  163. package/dist/runtime/daemon.js +1419 -1197
  164. package/dist/runtime/db.js +33 -1
  165. package/dist/runtime/main.js +1311 -1037
  166. package/dist/runtime/netproxy.js +25 -1
  167. package/dist/runtime/tool.js +241 -209
  168. package/dist/runtime/view.js +25 -1
  169. package/dist/selftest/agent-commands.test.js +45 -7
  170. package/dist/selftest/agent-paths.test.js +1 -12
  171. package/dist/selftest/agent-settings.test.d.ts +2 -0
  172. package/dist/selftest/agent-settings.test.js +94 -0
  173. package/dist/selftest/app.test.js +2 -1
  174. package/dist/selftest/cli.test.js +4 -0
  175. package/dist/selftest/heavy-load.test.js +119 -2
  176. package/dist/selftest/index.js +2 -0
  177. package/dist/selftest/menu.test.js +5 -3
  178. package/dist/selftest/pc-installs.test.js +5 -0
  179. package/dist/selftest/persisted-installs.test.js +69 -0
  180. package/dist/selftest/service-accounts.test.js +1 -1
  181. package/dist/selftest/support/engine-golden.js +1 -1
  182. package/dist/selftest/support/fixtures.d.ts +9 -0
  183. package/dist/selftest/vpn.test.js +51 -3
  184. package/dist/shared/accounts/anthropic.d.ts +5 -0
  185. package/dist/shared/accounts/anthropic.js +15 -0
  186. package/dist/shared/accounts/index.d.ts +1 -0
  187. package/dist/shared/accounts/index.js +1 -0
  188. package/dist/shared/channel/schema.d.ts +20 -0
  189. package/dist/shared/channel/schema.js +4 -1
  190. package/dist/shared/io/disk.d.ts +9 -0
  191. package/dist/shared/io/disk.js +19 -0
  192. package/dist/shared/io/index.d.ts +1 -0
  193. package/dist/shared/io/index.js +1 -0
  194. package/dist/shared/readiness.d.ts +9 -0
  195. package/dist/shared/readiness.js +11 -0
  196. package/dist/shared/signer.d.ts +24 -0
  197. package/dist/shared/signer.js +15 -0
  198. package/dist/shared/system.d.ts +1 -0
  199. package/dist/shared/system.js +1 -0
  200. package/dist/shared/vpn/failure.d.ts +10 -0
  201. package/dist/shared/vpn/failure.js +28 -0
  202. package/dist/sync/watcher.js +11 -4
  203. package/dist/web/index.html +1 -1
  204. package/dist/web/{main-6XYO5FZC.js → main-OUUQYNK4.js} +75 -74
  205. package/native/csharp/HcsHost/AssemblyInfo.cs +3 -3
  206. package/native/csharp/HostTools/AssemblyInfo.cs +3 -3
  207. package/native/csharp/HostTools/Program.cs +19 -2
  208. package/native/csharp/HostTools/UserPath.cs +17 -0
  209. package/native/csharp/StorageHelper/AssemblyInfo.cs +3 -3
  210. package/native/scripts/edit-user-path.ps1 +6 -2
  211. package/native/scripts/stop-events.ps1 +7 -3
  212. package/package.json +1 -1
  213. package/sandbox/scripts/get-signature.ps1 +13 -0
  214. package/schemas/config.schema.json +64 -0
  215. package/schemas/sandbox-config.schema.json +8 -0
  216. package/sandbox/scripts/get-signer.ps1 +0 -8
@@ -5756,6 +5756,7 @@ var SYSTEM = {
5756
5756
  cmd: () => system32("cmd.exe"),
5757
5757
  powershell: () => system32("WindowsPowerShell\\v1.0\\powershell.exe"),
5758
5758
  tasklist: () => system32("tasklist.exe"),
5759
+ taskkill: () => system32("taskkill.exe"),
5759
5760
  whoami: () => system32("whoami.exe"),
5760
5761
  icacls: () => system32("icacls.exe"),
5761
5762
  tar: () => system32("tar.exe"),
@@ -25954,6 +25955,22 @@ var DatabasesSchema = external_exports.strictObject({
25954
25955
  })
25955
25956
  });
25956
25957
 
25958
+ // src/core/schema/codex.ts
25959
+ var CodexSchema = external_exports.strictObject({
25960
+ sandboxMode: external_exports.enum(["danger-full-access", "workspace-write", "read-only"]).describe(
25961
+ "Codex's own sandbox. Inside Windows Sandbox Codex's own sandbox only slows it down (it re-grants file permissions over your profile at every boot), so the default is danger-full-access: Windows Sandbox is the sandbox."
25962
+ ),
25963
+ approvalPolicy: external_exports.enum(["never", "on-request", "on-failure", "untrusted"]).describe('When Codex asks before it runs a command. "never" with danger-full-access starts it with no questions.'),
25964
+ model: external_exports.string().max(100).regex(/^[A-Za-z0-9._:/-]*$/, "letters, digits and . _ : / - only").describe("The model Codex uses. Empty = Codex's own default."),
25965
+ reasoningEffort: external_exports.enum(["default", "minimal", "low", "medium", "high", "xhigh"]).describe(`How hard Codex thinks. "default" = Codex's own default.`),
25966
+ windowsSandbox: external_exports.enum(["off", "unelevated", "elevated"]).describe(
25967
+ `Codex's Windows sandbox mode in its config.toml. "off" removes the setting so Codex does not set up its sandbox users and firewall rules at every boot.`
25968
+ ),
25969
+ mcpLoginsInFile: external_exports.boolean().describe(
25970
+ `Keep the sign-ins of Codex's MCP servers in a file in the saved Codex folder (mcp_oauth_credentials_store = "file"), so they survive between sandboxes. A value you set in config.toml is kept.`
25971
+ )
25972
+ });
25973
+
25957
25974
  // src/core/schema/harness.ts
25958
25975
  var SubfolderSchema = external_exports.string().regex(/^(?!.*(^|[\\/])\.\.([\\/]|$))[A-Za-z0-9._ -]+([\\/][A-Za-z0-9._ -]+)*$/, "a plain relative folder");
25959
25976
  var HarnessHomeSchema = external_exports.strictObject({
@@ -26266,6 +26283,9 @@ var ToolchainsSchema = external_exports.strictObject({
26266
26283
  keepInstallFiles: external_exports.boolean().describe(
26267
26284
  "Keep an offline copy of the Visual Studio install files in tools\\visualstudio (~10 GB, made once), so later boots install without downloading them again. Skipped while that disk has too little room."
26268
26285
  ),
26286
+ visualStudio: external_exports.enum(["full", "autosdk"]).describe(
26287
+ `"full": install Visual Studio Community (the engine finds it through the Setup API; ~10 GB, redone every boot). "autosdk" (experimental): skip Visual Studio and build an Unreal AutoSDK tree from the portable MSVC in tools\\unreal\\autosdk (kept between sandboxes); UnrealBuildTool finds MSVC through AutoSDK, but the editor's Visual Studio integration is not available. Needs toolchains.msvc (turned on for you).`
26288
+ ),
26269
26289
  persistEngines: external_exports.boolean().describe(
26270
26290
  "Engines the Epic Games Launcher installs go to tools\\unreal\\engines (kept between sandboxes, 40-60 GB each) and are registered with the launcher again every boot. Off: they go on the sandbox disk and are lost when it closes. Engines installed on this PC can be mapped instead (pcInstalls.unrealEngines)."
26271
26291
  ),
@@ -27722,6 +27742,7 @@ var ConfigSchema = external_exports.strictObject({
27722
27742
  apps: AppsSchema,
27723
27743
  claudeProfile: ClaudeProfileSchema,
27724
27744
  harnesses: external_exports.record(NameSchema, HarnessSchema).describe("Agents that can run in the sandbox."),
27745
+ codex: CodexSchema,
27725
27746
  hooks: external_exports.strictObject({ failFast: external_exports.boolean().describe("Stop when a hook script fails.") }),
27726
27747
  projects: ProjectsSchema.describe(
27727
27748
  "Several projects in one sandbox: folders of projects mapped at every start, and adding folders while it runs."
@@ -27923,7 +27944,8 @@ var HeartbeatSchema = external_exports.object({
27923
27944
  topProcesses: external_exports.array(external_exports.object({ name: Text(64), privateMb: external_exports.number().int().min(0).max(1e7) })).max(5),
27924
27945
  /** The disk queue this PC last reported while asking the sandbox to slow down. */
27925
27946
  diskQueueReported: external_exports.number().min(0).max(1e5).optional(),
27926
- phase: external_exports.enum(["ready", "working"])
27947
+ /** "ending": the sandbox is shutting down in order (idle shutdown, sandbox stop, Windows shutting down). */
27948
+ phase: external_exports.enum(["ready", "working", "ending"])
27927
27949
  });
27928
27950
  var GuestResponseSchema = external_exports.object({
27929
27951
  id: IdSchema,
@@ -27964,6 +27986,8 @@ var ProjectStatusSchema = external_exports.object({
27964
27986
  var VpnStatusSchema = external_exports.object({
27965
27987
  state: external_exports.enum(["off", "starting", "up", "failed"]),
27966
27988
  message: Text(2e3),
27989
+ /** Why it failed, when it did: `auth` (login refused), `unreachable`, `driver`, `timeout` or `other`. */
27990
+ code: external_exports.enum(["auth", "unreachable", "driver", "timeout", "other"]).optional(),
27967
27991
  killSwitch: external_exports.boolean(),
27968
27992
  /** A direct connection was tried after the kill switch was applied: false = it still got out. */
27969
27993
  killSwitchHeld: external_exports.boolean().optional(),
@@ -1,14 +1,18 @@
1
- // Agent commands (Beta): which agents get a forwarding command, what the files contain, and that "off" changes nothing.
1
+ // Agent commands: which agents get a forwarding command, what the files contain, and that "off" changes nothing.
2
2
  // Everything happens in the test's temp folder with a made-up PATH; your real PATH is never read or written.
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
5
5
  import { DEFAULTS } from '../core/config/index.js';
6
6
  import { agentCommands, commandsDir, realAgent } from '../host/agent-commands/index.js';
7
7
  import { forwardingCmd, forwardingSh } from '../host/agent-commands/index.js';
8
- import { reconcileAgentCommands } from '../host/agent-commands/index.js';
8
+ import { forwardTarget, reconcileAgentCommands } from '../host/agent-commands/index.js';
9
+ import { agentCommandsSection } from '../app/features/agent-commands/agent-commands.def.js';
10
+ import { applyAgentCommandsChange } from '../app/features/settings/settings.run.js';
11
+ import { fakeIO } from '../app/kernel/index.js';
12
+ import { runDefinition } from '../app/registry.js';
9
13
  export const agentCommandsSuite = {
10
14
  name: 'agent-commands',
11
- title: 'agent commands (Beta): claude, codex ... forwarded into a hidden sandbox',
15
+ title: 'agent commands: claude, codex ... forwarded into a hidden sandbox',
12
16
  run: async (t) => {
13
17
  const storage = t.at('agent-cmd-storage');
14
18
  const npmDir = t.at('agent-cmd-npm');
@@ -33,19 +37,53 @@ export const agentCommandsSuite = {
33
37
  t.check(!agentCommands(on, storage, unsafeDir).some((a) => a.command === 'aider'), 'an agent in a folder whose path has % or ^ (which cmd.exe would expand) is left alone');
34
38
  t.section('the forwarding files');
35
39
  const claude = { harness: 'claude', command: 'claude', real: path.join(localBin, 'claude.exe') };
36
- const cmd = forwardingCmd(claude, 'C:\\node\\node.exe', 'C:\\tool\\dist\\bin\\sandbox.js');
40
+ const direct = { node: 'C:\\node\\node.exe', entry: 'C:\\tool\\dist\\bin\\sandbox.js' };
41
+ const cmd = forwardingCmd(claude, direct);
37
42
  t.check(cmd.includes('"C:\\node\\node.exe" "C:\\tool\\dist\\bin\\sandbox.js" launch --harness claude --headless --terminal -- %*') &&
38
43
  cmd.includes('if "%SANDBOX_AGENT_DIRECT%"=="1" goto direct') &&
39
44
  cmd.includes('call "' + claude.real + '" %*'), 'the .cmd starts the agent in a hidden sandbox attached to this terminal, unless SANDBOX_AGENT_DIRECT=1 (real agent)');
40
- const sh = forwardingSh({ ...claude, real: "C:\\Users\\me\\it's\\claude.exe" }, 'C:\\node\\node.exe', 'C:\\tool\\sandbox.js');
45
+ t.check(cmd.includes('if not exist "C:\\node\\node.exe" goto direct') &&
46
+ cmd.includes('if not exist "C:\\tool\\dist\\bin\\sandbox.js" goto direct') &&
47
+ cmd.indexOf('if not exist') < cmd.indexOf('launch --harness'), 'the .cmd runs the real agent when the tool was uninstalled or moved, instead of failing');
48
+ const managed = forwardTarget('C:\\tool\\versions\\1.0\\package', 'C:\\tool', 'C:\\node\\node.exe');
49
+ const viaLauncher = forwardingCmd(claude, managed);
50
+ t.check(managed.launcher === 'C:\\tool\\bin\\sandbox.cmd' &&
51
+ forwardTarget('C:\\src\\repo', null, 'C:\\node\\node.exe').launcher === undefined &&
52
+ viaLauncher.includes('call "C:\\tool\\bin\\sandbox.cmd" launch --harness claude --headless --terminal -- %*') &&
53
+ !viaLauncher.includes('versions') &&
54
+ forwardingSh(claude, managed).includes("[ -f '/c/tool/bin/sandbox' ] || exec "), 'under a managed install the files call its launcher (it survives updates), else node and the entry file');
55
+ const sh = forwardingSh({ ...claude, real: "C:\\Users\\me\\it's\\claude.exe" }, {
56
+ node: 'C:\\node\\node.exe',
57
+ entry: 'C:\\tool\\sandbox.js',
58
+ });
41
59
  t.check(sh.startsWith('#!/bin/sh') &&
42
60
  sh.includes("exec '/c/node/node.exe' '/c/tool/sandbox.js' launch --harness claude --headless --terminal -- \"$@\"") &&
43
61
  sh.includes("'/c/Users/me/it'\\''s/claude.exe'"), 'Git Bash gets a shell script with POSIX paths, quoted safely');
44
62
  t.section('off changes nothing');
45
63
  // Off with nothing set up: nothing to do, no PATH edit (which would be refused during the self-test anyway).
46
64
  const fresh = t.at('agent-cmd-fresh');
47
- const off = reconcileAgentCommands(DEFAULTS, fresh, 'C:\\node\\node.exe', 'C:\\tool\\sandbox.js');
65
+ const target = { node: 'C:\\node\\node.exe', entry: 'C:\\tool\\sandbox.js' };
66
+ const off = reconcileAgentCommands(DEFAULTS, fresh, target);
48
67
  t.equal({ on: off.on, changed: off.changed, wrote: fs.existsSync(commandsDir(fresh)) }, { on: false, changed: false, wrote: false }, 'off (the default) writes nothing and leaves your PATH alone');
49
- await t.throws(() => reconcileAgentCommands(on, t.at('agent-cmd-on'), 'C:\\node\\node.exe', 'C:\\tool\\sandbox.js'), /self-test|PATH/i, 'turning it on edits your PATH, which the self-test refuses (it never changes this PC)');
68
+ await t.throws(() => reconcileAgentCommands(on, t.at('agent-cmd-on'), target), /self-test|PATH/i, 'turning it on edits your PATH, which the self-test refuses (it never changes this PC)');
69
+ t.section('every surface');
70
+ await t.throws(() => runDefinition('agentCommands.on', {}, {}, fakeIO().io), /self-test|PATH|inside the sandbox/i, 'agentCommands.on refuses in the self-test (it would change this PC)');
71
+ const reconciled = [];
72
+ const fakeService = { reconcile: (c) => reconciled.push(c) };
73
+ const ctx = { loaded: { configPath: undefined } };
74
+ applyAgentCommandsChange(ctx, 'user', 'agentCommands.enabled', fakeService);
75
+ applyAgentCommandsChange(ctx, 'user', 'defaultHarness', fakeService);
76
+ applyAgentCommandsChange(ctx, 'project', 'agentCommands.enabled', fakeService);
77
+ t.equal(reconciled.length, 1, 'changing agentCommands.* in your settings (any surface) brings this PC in line');
78
+ const section = agentCommandsSection({
79
+ enabled: false,
80
+ dir: 'C:\\x',
81
+ found: [{ command: 'claude', harness: 'claude', real: 'C:\\r\\claude.exe' }],
82
+ });
83
+ const toggle = section.items?.[0].primary;
84
+ t.check(section.items?.map((i) => i.id).join() === 'agentCommands,agentCommand:claude' &&
85
+ toggle?.kind === 'operation' &&
86
+ toggle.op === 'agentCommands.on' &&
87
+ !JSON.stringify(section).includes('sandbox agent-commands'), 'the Agents page offers the switch as an action and lists each agent found, naming no command');
50
88
  },
51
89
  };
@@ -5,7 +5,7 @@ import path from 'node:path';
5
5
  import { DEFAULTS, mergeConfig, validateConfig } from '../core/config/index.js';
6
6
  import { isWin } from '../core/util/index.js';
7
7
  import { SYSTEM } from '../shared/system.js';
8
- import { agentCommand, agentShims, bootResetPaths, codexConfigWithoutSandbox, isCopyableDatabaseFile, isDatabaseFile, localStateEntries, shimText, } from '../guest/agents/agent-files.js';
8
+ import { agentCommand, agentShims, bootResetPaths, isCopyableDatabaseFile, isDatabaseFile, localStateEntries, shimText, } from '../guest/agents/agent-files.js';
9
9
  import { appendMissing, expandVars, isPinnedNodeDir, parseRegPath, pathKey, planPath, rebuildPath, splitPath, } from '../guest/setup/path-plan.js';
10
10
  import { withReloadEnvironment } from '../host/tools/index.js';
11
11
  import { HarnessSchema } from '../core/schema/index.js';
@@ -59,17 +59,6 @@ export const agentPathsSuite = {
59
59
  ], "codex's DPAPI-sealed sandbox users and setup marker are removed at boot (CryptUnprotectData otherwise)");
60
60
  t.check(HarnessSchema.safeParse({ ...DEFAULTS.harnesses.codex, bootReset: ['..\\login'] }).success === false &&
61
61
  HarnessSchema.safeParse({ ...DEFAULTS.harnesses.codex, bootReset: ['C:\\Windows'] }).success === false, 'bootReset takes only plain paths below the agent state folder');
62
- t.section('codex without its own command sandbox');
63
- const userToml = '[tui]\nscreen_reader_detection_done = true\n\n[windows]\nsandbox = "elevated"\n';
64
- const off = codexConfigWithoutSandbox(userToml) ?? '';
65
- t.check(off.split('\n').find((l) => !l.startsWith('#')) === 'sandbox_mode = "danger-full-access"' &&
66
- off.endsWith(userToml), 'sandbox_mode goes above the first table, the rest of the file is kept');
67
- t.check(codexConfigWithoutSandbox('') === off.slice(0, off.indexOf('\n\n') + 1), 'a missing config.toml gets just the setting');
68
- t.equal([
69
- codexConfigWithoutSandbox(off),
70
- codexConfigWithoutSandbox('model = "o3"\nsandbox_mode = "workspace-write"\n[windows]\n'),
71
- ], [null, null], 'a sandbox_mode already set (by the sandbox or by the user) is left alone');
72
- t.check(codexConfigWithoutSandbox('[profiles.x]\nsandbox_mode = "read-only"\n') !== null, 'a sandbox_mode inside a table does not count as the top-level one');
73
62
  t.section('PATH planner');
74
63
  const present = new Set(['c:\\a', 'c:\\b', 'c:\\c\\d', 'c:\\nvm\\v24.1.0']);
75
64
  const exists = (f) => present.has(pathKey(f));
@@ -0,0 +1,2 @@
1
+ import type { Suite } from './support/suite.js';
2
+ export declare const agentSettingsSuite: Suite;
@@ -0,0 +1,94 @@
1
+ // Codex settings (launch flags, config.toml), the Claude environment editor and the one Anthropic key pattern.
2
+ import fs from 'node:fs';
3
+ import { DEFAULTS } from '../core/config/index.js';
4
+ import { CodexSchema } from '../core/schema/index.js';
5
+ import { agentEnv, agentsEnvSet } from '../app/features/agents/index.js';
6
+ import { agentsOptions } from '../cli/menu/pages/agents-page.js';
7
+ import { fakeIO } from '../app/kernel/index.js';
8
+ import { runDefinition } from '../app/registry.js';
9
+ import { agentArguments, agentShims, codexArguments, codexConfigOwned, shimText } from '../guest/agents/agent-files.js';
10
+ import { ANTHROPIC_KEY_PATTERN, anthropicKeyProblem } from '../shared/accounts/index.js';
11
+ import { CLAUDE_ENV, ENV_GROUPS } from '../app/features/agents/claude-env.def.js';
12
+ const CODEX = DEFAULTS.codex;
13
+ export const agentSettingsSuite = {
14
+ name: 'agent-settings',
15
+ title: 'Codex settings, Claude environment editor, Anthropic key pattern',
16
+ run: async (t) => {
17
+ t.section('codex launch flags');
18
+ t.equal(codexArguments(CODEX), ['--dangerously-bypass-approvals-and-sandbox'], 'full access + never = the bypass flag');
19
+ t.equal(codexArguments({ ...CODEX, sandboxMode: 'workspace-write', approvalPolicy: 'on-request' }), ['--sandbox', 'workspace-write', '--ask-for-approval', 'on-request'], 'another mode gets explicit flags');
20
+ t.equal(codexArguments({ ...CODEX, sandboxMode: 'danger-full-access', approvalPolicy: 'on-failure' }), ['--sandbox', 'danger-full-access', '--ask-for-approval', 'on-failure'], 'the bypass flag is only for full access with no questions');
21
+ t.equal(codexArguments({ ...CODEX, model: 'gpt-5', reasoningEffort: 'high' }).slice(1), ['-c', 'model="gpt-5"', '-c', 'model_reasoning_effort="high"'], 'model and reasoning effort follow as -c overrides');
22
+ t.check(CodexSchema.safeParse({ ...CODEX, model: 'x"; evil' }).success === false, 'a model name cannot carry quotes');
23
+ const codex = DEFAULTS.harnesses.codex;
24
+ t.equal(agentArguments('codex', codex, CODEX), [...codex.arguments, '--dangerously-bypass-approvals-and-sandbox'], "codex's launch arguments carry the flags");
25
+ t.equal(agentArguments('claude', DEFAULTS.harnesses.claude, CODEX), [...DEFAULTS.harnesses.claude.arguments], 'other agents do not');
26
+ const shim = agentShims('C:\\AgentData\\tools', DEFAULTS.harnesses, { codex: codexArguments(CODEX) }).find((s) => s.name === 'codex');
27
+ const text = shim ? shimText(shim, { node: 'node.exe', script: 'agent.js' }) : '';
28
+ t.includes(text, '"%SBX_REAL%" --dangerously-bypass-approvals-and-sandbox %*', 'the codex.cmd shim adds the flags');
29
+ t.excludes(shimText(agentShims('C:\\AgentData\\tools', DEFAULTS.harnesses).find((s) => s.name === 'codex'), { node: 'node.exe', script: 'agent.js' }), 'dangerously', 'a shim without flags passes only %*');
30
+ t.section('codex config.toml');
31
+ const old = '# Set by the sandbox: x\nsandbox_mode = "danger-full-access"\n\n[tui]\nscreen_reader_detection_done = true\n\n[windows]\nsandbox = "elevated"\n';
32
+ const owned = codexConfigOwned(old, CODEX);
33
+ t.check(!owned.includes('sandbox_mode') && !owned.includes('Set by the sandbox') && !owned.includes('sandbox = '), 'the old sandbox_mode lines and [windows] sandbox are removed', owned);
34
+ t.check(owned.includes('mcp_oauth_credentials_store = "file"') &&
35
+ owned.includes('[tui]\nscreen_reader_detection_done = true\n') &&
36
+ owned.includes('[windows]'), 'MCP logins go to a file; every other line stays', owned);
37
+ t.equal(codexConfigOwned(owned, CODEX), owned, 'running it again changes nothing');
38
+ t.includes(codexConfigOwned(old, { ...CODEX, windowsSandbox: 'unelevated' }), '[windows]\nsandbox = "unelevated"', 'a Windows sandbox mode is written when asked for');
39
+ t.includes(codexConfigOwned('mcp_oauth_credentials_store = "keyring"\n', CODEX), 'mcp_oauth_credentials_store = "keyring"', "a user's own mcp_oauth_credentials_store is kept");
40
+ t.equal(codexConfigOwned('', { ...CODEX, mcpLoginsInFile: false }), '', 'an empty file stays empty when nothing is owned');
41
+ t.section('Anthropic key pattern');
42
+ const body = 'a'.repeat(60);
43
+ t.check(ANTHROPIC_KEY_PATTERN.test('sk-ant-usr01-' + body) && ANTHROPIC_KEY_PATTERN.test('sk-ant-api03-' + body), 'sk-ant-api03- and sk-ant-usr01- keys are accepted');
44
+ t.check(!ANTHROPIC_KEY_PATTERN.test('sk-ant-usr1-x') && anthropicKeyProblem('sk-ant-usr1-x') !== null, 'a short or malformed key is refused');
45
+ t.check(anthropicKeyProblem('sk-ant-usr01-' + body) === null, 'no problem for a user-workspace key');
46
+ t.section('Claude environment catalogue');
47
+ const names = CLAUDE_ENV.map((s) => s.name);
48
+ t.check(new Set(names).size === names.length, 'names are unique');
49
+ t.check(names.every((n) => /^[A-Z][A-Z0-9_]+$/.test(n)) && CLAUDE_ENV.every((s) => ENV_GROUPS.includes(s.group)), 'names and groups are valid');
50
+ t.check(names.includes('CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS'), 'the subagent limit is there');
51
+ t.section('Claude environment operations');
52
+ const configPath = t.at('agent-env', 'config.json');
53
+ t.write(configPath, '{}\n');
54
+ const ctx = {
55
+ packageRoot: t.dir('agent-env', 'package'),
56
+ options: {},
57
+ loaded: { config: structuredClone(DEFAULTS), configPath, base: t.temp },
58
+ storageRoot: t.dir('agent-env', 'storage'),
59
+ dataPath: t.dir('agent-env', 'storage', 'data', 'o'),
60
+ };
61
+ const io = fakeIO([]).io;
62
+ const set = (name, value) => runDefinition(agentsEnvSet.id, ctx, { agent: 'claude', name, value }, io);
63
+ const read = async () => (await runDefinition(agentEnv.id, ctx, { agent: 'claude' }, io));
64
+ await set('CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS', '40');
65
+ let row = (await read()).rows.find((r) => r.name === 'CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS');
66
+ t.check(row?.set === true && row.value === '40', 'a number is saved and read back');
67
+ t.includes(fs.readFileSync(configPath, 'utf8'), '"CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS": "40"', 'it is in the settings file');
68
+ await t.throws(() => set('CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS', 'many'), 'whole number', 'a word is refused for a number');
69
+ await t.throws(() => set('CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS', '0'), 'Use a number', 'zero is refused');
70
+ await set('CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS', null);
71
+ row = (await read()).rows.find((r) => r.name === 'CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS');
72
+ t.check(row?.set === false, 'an empty value removes it');
73
+ await t.throws(() => set('ANTHROPIC_API_KEY', 'sk-ant-usr1-x'), 'API key', 'the environment editor refuses a bad key');
74
+ const key = 'sk-ant-usr01-' + 'k'.repeat(60);
75
+ await set('ANTHROPIC_API_KEY', key);
76
+ const shown = JSON.stringify(await read());
77
+ t.check(!shown.includes(key.slice(13)), 'a secret value is never returned');
78
+ t.check((await read()).rows.find((r) => r.name === 'ANTHROPIC_API_KEY')?.set === true, 'but it says it is set');
79
+ const env = await read();
80
+ const menu = agentsOptions({
81
+ folder: 'C:/code',
82
+ defaultAgent: 'claude',
83
+ agents: [{ name: 'claude', ready: true }],
84
+ last: undefined,
85
+ attach: false,
86
+ window: 'config',
87
+ hostAgents: [],
88
+ claudeEnv: env,
89
+ });
90
+ t.check(menu.categories?.some((c) => c.id === 'env') === true &&
91
+ menu.items.some((i) => i.label === 'Subagents at once' || i.label === 'API key'), 'the menu has a Claude environment tab with the variables');
92
+ await t.throws(() => set('bad name', '1'), '', 'a bad variable name is refused');
93
+ },
94
+ };
@@ -282,6 +282,7 @@ export const appSuite = {
282
282
  t.check(ops(sample).every((op) => known.has(op)), view.id + ': the page offers only operations its view declares');
283
283
  }
284
284
  t.section('agents');
285
+ const noCommands = { enabled: false, dir: 'C:/x', found: [] };
285
286
  const agentsSample = agentsPage({
286
287
  folder: 'C:\\code\\api',
287
288
  defaultAgent: 'claude',
@@ -289,7 +290,7 @@ export const appSuite = {
289
290
  { name: 'codex', ready: false, isDefault: false, removable: true },
290
291
  { name: 'claude', ready: true, isDefault: true, removable: false },
291
292
  ],
292
- });
293
+ }, noCommands);
293
294
  const agentItems = agentsSample.sections[0].items ?? [];
294
295
  const agentKeys = (id) => (agentItems.find((i) => i.id === id)?.actions ?? [])
295
296
  .flatMap((a) => (a.kind === 'operation' && a.key ? [a.key] : []))
@@ -158,6 +158,10 @@ export const cliSuite = {
158
158
  });
159
159
  multi.handle(key('space'));
160
160
  t.equal(multi.handle(key('return')), { done: { kind: 'multi', values: ['a', 'b'] } }, 'list screen: space ticks several');
161
+ const pasted = new TextScreen({ path: ['T'], label: 'Secret', masked: true });
162
+ pasted.handle(key('', '\u001b[200~abc\r\n\u001b[201~'));
163
+ pasted.handle(key('', '[200~d[201~'));
164
+ t.equal(pasted.handle(key('return')), { done: 'abcd' }, 'text field: the markers around pasted text are dropped');
161
165
  const typed = new TextScreen({ path: ['T'], label: 'Port', validate: (v) => (Number(v) > 0 ? true : 'a number') });
162
166
  typed.handle(key('x', 'x'));
163
167
  const refused = typed.handle(key('return'));
@@ -10,9 +10,12 @@ import { SYSTEM } from '../shared/system.js';
10
10
  import { GuestRequestSchema, PressureStatusSchema } from '../shared/channel/index.js';
11
11
  import { compilersAllowed, DIAL_CALM_SAMPLES, DIAL_STEPS, HOST_THRESHOLDS, buildJobs, cargoJobs, linkSlotsFor, usableCpus, effectiveCpuLimit, gentleCpuLimit, maxCompilers, chooseTargetDir, } from '../shared/memory/index.js';
12
12
  import { HeartbeatSchema } from '../shared/channel/index.js';
13
- import { heartbeatLine, lastEndOf } from '../host/life/index.js';
13
+ import { heartbeatLine, lastEndOf, vanishedCause, stopWarning } from '../host/life/index.js';
14
14
  import { cappedMemoryMb, decideMemory, memoryNote, residentCapMb, residentNote, resolveMemoryMb, } from '../host/launch/index.js';
15
15
  import { vgpuWarning, GENTLE_NOTE } from '../host/launch/risk-notes.js';
16
+ import { judgeSandbox } from '../host/health/index.js';
17
+ import { cleanUpLeftovers, cleanupQuestion, killArgs } from '../host/sandbox/index.js';
18
+ import { parseTasklist } from '../host/sharing/index.js';
16
19
  import { bugcheckCode, cpuPercentBetween, explainStop, findDumps, findingLines, freezeLine, hostPressure, lowReason, severeHost, pageFileRisk, parseHostMemory, crashReportText, watchdogStep, WATCHDOG_START, } from '../host/life/index.js';
17
20
  // The pure governor files only: governor.ts loads the guest runtime (session.json), which exists only in a sandbox.
18
21
  import { canLower, canPause, decide, INITIAL_STATE, isLinker, linkerSlots, ROTATE_MS, } from '../guest/governor/decide.js';
@@ -252,12 +255,79 @@ export const heavyLoadSuite = {
252
255
  t.check(!HeartbeatSchema.safeParse({ ...beat, topProcesses: Array.from({ length: 6 }, () => beat.topProcesses[0]) })
253
256
  .success &&
254
257
  !HeartbeatSchema.safeParse({ ...beat, topProcesses: [{ name: 'x'.repeat(500), privateMb: 1 }] }).success, 'an oversize heartbeat is refused');
255
- t.check(heartbeatLine({ ...beat, topProcesses: [{ name: 'ab', privateMb: 5 }] }).includes('') === false, 'guest text in the heartbeat line goes through safeText');
258
+ t.check(heartbeatLine({ ...beat, topProcesses: [{ name: 'a\u0007b', privateMb: 5 }] }).includes('\u0007') === false, 'guest text in the heartbeat line goes through safeText');
256
259
  const life = { started: '2026-11-02T20:45:00.000Z', lastSeen: '2026-11-02T21:31:30.000Z', keepAwake: false };
257
260
  t.check((lastEndOf(life, beat, false)?.text ?? '').includes('disks were overloaded') &&
258
261
  lastEndOf(life, beat, true) === null &&
259
262
  lastEndOf(null, beat, false) === null &&
260
263
  lastEndOf({ ...life, ended: { at: life.lastSeen, cause: null } }, beat, false) === null, 'last end: an unexpected end names the disks; running, no record or a normal close say nothing');
264
+ t.section('Windows Sandbox programs left behind by a dead sandbox');
265
+ const list = [
266
+ '"explorer.exe","1200","Console","1","90,000 K"',
267
+ '"WindowsSandboxClient.exe","4100","Console","1","30,000 K"',
268
+ '"WindowsSandboxRemoteSession.exe","4200","Console","1","40,000 K"',
269
+ '"vmwp.exe","4300","Services","0","50,000 K"',
270
+ '"WindowsSandboxServer.exe","4400","Console","2","30,000 K"',
271
+ ].join('\n');
272
+ const baseFacts = {
273
+ rows: parseTasklist(list),
274
+ mySession: 1,
275
+ heartbeatAgeMs: null,
276
+ lifeEnded: false,
277
+ startedAgeMs: 3_600_000,
278
+ answers: null,
279
+ };
280
+ const judged = (patch) => judgeSandbox({ ...baseFacts, ...patch });
281
+ t.check(judged({ rows: parseTasklist('"explorer.exe","1200","Console","1","1 K"') }).state === 'none', 'no Windows Sandbox program: none');
282
+ t.check(judged({ heartbeatAgeMs: 10_000 }).state === 'running' &&
283
+ judged({ answers: true, lifeEnded: true }).state === 'running', 'a fresh heartbeat, or a ping answered: the sandbox is ours and running');
284
+ t.check(judged({ startedAgeMs: 60_000 }).state === 'starting', 'started a minute ago, no heartbeat yet: starting');
285
+ const leftoverJudged = judged({ lifeEnded: true });
286
+ t.check(leftoverJudged.state === 'leftover' &&
287
+ leftoverJudged.pids.join() === '4100,4200' &&
288
+ leftoverJudged.images.join() === 'WindowsSandboxClient.exe,WindowsSandboxRemoteSession.exe', 'the watcher saw it end but the programs remain: leftover; only this session, only the user programs (not vmwp.exe, not session 2)', leftoverJudged.pids.join());
289
+ t.check(judged({ heartbeatAgeMs: 600_000 }).state === 'leftover', 'a heartbeat that stopped ten minutes ago: leftover');
290
+ t.check(judged({ heartbeatAgeMs: 100_000 }).state === 'running', 'a heartbeat 100 seconds old is not yet "gone"');
291
+ t.check(judged({}).state === 'running', 'no heartbeat at all (an older sandbox) and no end: treated as running, never ended');
292
+ t.check(judged({ mySession: null, lifeEnded: true }).state === 'foreign' &&
293
+ judged({ mySession: 3, lifeEnded: true }).pids.length === 0, "another session's sandbox is never touched");
294
+ t.check(killArgs([4100, 4200, 0, 2, -5, 1.5]).join(' ') === '/F /PID 4100 /PID 4200' &&
295
+ cleanupQuestion(leftoverJudged).lines.join(' ').includes('Nothing else'), 'taskkill gets only real process ids; the question names what is closed');
296
+ const run = async (facts, answers, over = {}) => {
297
+ const did = [];
298
+ let n = 0;
299
+ let asked = 0;
300
+ const outcome = await cleanUpLeftovers({
301
+ facts: () => Promise.resolve(facts[Math.min(n++, facts.length - 1)]),
302
+ end: (pids) => {
303
+ did.push('end ' + pids.join(','));
304
+ return true;
305
+ },
306
+ restartService: () => {
307
+ did.push('service');
308
+ return true;
309
+ },
310
+ confirm: () => Promise.resolve(answers[asked++] ?? false),
311
+ say: (line) => did.push('say ' + line),
312
+ ...over,
313
+ });
314
+ return { outcome, did };
315
+ };
316
+ const noneLeft = { ...baseFacts, rows: [] };
317
+ const asked = await run([{ ...baseFacts, lifeEnded: true }, noneLeft], [true]);
318
+ t.check(asked.outcome === 'cleaned' && asked.did[0] === 'end 4100,4200' && !asked.did.includes('service'), 'leftover, yes: they are closed and nothing else happens');
319
+ const declinedRun = await run([{ ...baseFacts, lifeEnded: true }], [false]);
320
+ t.check(declinedRun.outcome === 'declined' && declinedRun.did.length === 0, 'leftover, no: nothing is closed');
321
+ const live = await run([{ ...baseFacts, heartbeatAgeMs: 5000 }], [true]);
322
+ t.check(live.outcome === 'alive' && !live.did.some((d) => d.startsWith('end')), 'a sandbox that is alive is never closed, whatever the answer');
323
+ t.check((await run([noneLeft], [true])).outcome === 'nothing', 'nothing left: nothing to do');
324
+ const stuck = { ...baseFacts, lifeEnded: true };
325
+ const serviceYes = await run([stuck, stuck], [true, true]);
326
+ t.check(serviceYes.outcome === 'service-restarted' && serviceYes.did.includes('service'), 'programs that will not go: asks, then restarts the service (elevated)');
327
+ const serviceNo = await run([stuck, stuck], [true, false]);
328
+ t.check(serviceNo.outcome === 'declined' && !serviceNo.did.includes('service'), 'the service is only restarted when you say yes');
329
+ const forced = await run([stuck, noneLeft], [true], { alsoService: true });
330
+ t.check(forced.outcome === 'service-restarted', 'asked to restart the service as well: it does, without a second question');
261
331
  t.section('the governor over a port (fake Windows)');
262
332
  const calls = [];
263
333
  const port = {
@@ -403,6 +473,53 @@ export const heavyLoadSuite = {
403
473
  t.check(pageFileRisk(pc({ pageFile: 'fixed', commitLimitMb: 90000, commitAvailableMb: 80000 }), 20000) === null, 'a fixed but large one is fine');
404
474
  t.section('the crash report');
405
475
  const ev = (id, provider, at, process = '') => ({ id, provider, at, process });
476
+ t.section('a sandbox that vanishes');
477
+ const kindOf = (events) => explainStop(events)?.kind ?? 'none';
478
+ t.check(kindOf([
479
+ ev(4101, 'Display', '2026-11-02T21:31:00Z', 'Display driver nvlddmkm stopped responding and has successfully recovered.'),
480
+ ]) === 'gpu' && explainStop([ev(4101, 'Display', '2026-11-02T21:31:00Z')]).text.includes('graphics driver'), 'Display 4101: the graphics driver reset (gpu)');
481
+ t.check(kindOf([
482
+ ev(1001, 'Windows Error Reporting', '2026-11-02T21:31:00Z', 'Fault bucket 1, type 5' + ' Event Name: LiveKernelEvent' + ' P1: 141' + ' P2: ffff'),
483
+ ]) === 'gpu' &&
484
+ kindOf([
485
+ ev(1001, 'Windows Error Reporting', '2026-11-02T21:31:00Z', 'Event Name: LiveKernelEvent P1: 1a8 P2: 0'),
486
+ ]) === 'gpu' &&
487
+ kindOf([
488
+ ev(1001, 'Windows Error Reporting', '2026-11-02T21:31:00Z', 'Event Name: LiveKernelEvent P1: 999 P2: 0'),
489
+ ]) === 'none', 'LiveKernelEvent 117, 141 and 1a8 are GPU resets; other codes are not a cause');
490
+ t.check(explainStop([ev(1000, 'Application Error', '2026-11-02T21:31:00Z', 'Faulting application name: vmwp.exe')])
491
+ ?.kind === 'hyperv' &&
492
+ kindOf([ev(1001, 'Windows Error Reporting', '2026-11-02T21:31:00Z', 'vmmemWindowsSandbox crashed')]) ===
493
+ 'hyperv' &&
494
+ kindOf([ev(1000, 'Application Error', '2026-11-02T21:31:00Z', 'Faulting application name: notepad.exe')]) ===
495
+ 'none', 'a crash report of vmwp.exe or vmmem is a cause; another program is not');
496
+ t.check(kindOf([ev(12010, 'Microsoft-Windows-Hyper-V-VmSwitch', '2026-11-02T21:31:00Z')]) === 'hyperv' &&
497
+ kindOf([ev(40001, 'Microsoft-Windows-Hyper-V-Worker', '2026-11-02T21:31:00Z')]) === 'none' &&
498
+ kindOf([ev(18590, 'Microsoft-Windows-Hyper-V-Worker', '2026-11-02T21:31:00Z', 'ErrorCode0: 0x50')]) ===
499
+ 'sandboxCrash', 'Hyper-V worker, switch and compute errors are a cause; the sandbox crashing inside is still its own kind');
500
+ const started = '2026-11-02T20:45:00.000Z';
501
+ const endMs = Date.parse('2026-11-02T21:32:00.000Z');
502
+ const working = {
503
+ at: '2026-11-02T21:31:50.000Z',
504
+ uptimeS: 2800,
505
+ ramFreePct: 60,
506
+ commitPct: 30,
507
+ step: 0,
508
+ hostStep: 0,
509
+ topProcesses: [],
510
+ phase: 'working',
511
+ };
512
+ t.check(vanishedCause(working, started, false, endMs)?.kind === 'vanished', 'busy, fresh heartbeat, no word of leaving: vanished');
513
+ t.check(vanishedCause({ ...working, phase: 'ending' }, started, false, endMs) === null &&
514
+ vanishedCause(working, started, true, endMs) === null &&
515
+ vanishedCause(null, started, false, endMs) === null, 'an orderly end, a stop on purpose, or no heartbeat is not a vanishing');
516
+ t.check(vanishedCause({ ...working, phase: 'ready' }, started, false, endMs) === null &&
517
+ vanishedCause({ ...working, phase: 'ready', step: 2 }, started, false, endMs)?.kind === 'vanished', 'an idle sandbox (window closed by hand) is on purpose; one that was slowing builds down is not');
518
+ t.check(vanishedCause({ ...working, at: '2026-11-02T21:20:00.000Z' }, started, false, endMs) === null &&
519
+ vanishedCause({ ...working, at: '2026-11-01T21:31:50.000Z' }, started, false, endMs) === null, 'a stale heartbeat, or one from an earlier run, says nothing about this end');
520
+ const gone = vanishedCause(working, started, false, endMs);
521
+ const warning = stopWarning({ started, lastSeen: '2026-11-02T21:31:30.000Z', keepAwake: false, ended: { at: gone.at, cause: gone } }, () => null);
522
+ t.check((warning ?? '').includes('ended suddenly') && (warning ?? '').includes('vGPU'), 'the next launch warns about a vanished sandbox, and says what usually causes it');
406
523
  t.check(bugcheckCode('BugcheckCode 209') === '0xD1' && bugcheckCode('BugcheckCode 0') === null, 'Kernel-Power 41 stop code');
407
524
  const cause = explainStop([ev(41, 'Microsoft-Windows-Kernel-Power', '2026-10-15T20:53:47Z', 'BugcheckCode 0')]);
408
525
  t.check(cause?.kind === 'crash' && cause.text.includes('freeze'), 'no stop code: a freeze, crash or power cut');
@@ -28,6 +28,7 @@ import { channelSuite } from './channel.test.js';
28
28
  import { heavyLoadSuite } from './heavy-load.test.js';
29
29
  import { cliSuite } from './cli.test.js';
30
30
  import { agentPathsSuite } from './agent-paths.test.js';
31
+ import { agentSettingsSuite } from './agent-settings.test.js';
31
32
  import { cmdlineSuite } from './cmdline.test.js';
32
33
  import { configSuite } from './config.test.js';
33
34
  import { migrateSuite } from './migrate.test.js';
@@ -140,6 +141,7 @@ export const SUITES = [
140
141
  personalizationSuite,
141
142
  cmdlineSuite,
142
143
  agentPathsSuite,
144
+ agentSettingsSuite,
143
145
  setupSuite,
144
146
  dataSuite,
145
147
  repairSuite,
@@ -243,8 +243,8 @@ export const menuSuite = {
243
243
  agentList.categories?.map((c) => c.id),
244
244
  ], [
245
245
  ['repeat', 'agent:codex', 'agent:claude', 'add'],
246
- ['start', 'options'],
247
- ], 'Agents: Repeat last, the default agent first, the others, then Add an agent; no "On this PC" while it is empty');
246
+ ['start', 'options', 'host'],
247
+ ], 'Agents: Repeat last, the default agent first, the others, then Add an agent; "On this PC" always holds the agent commands switch');
248
248
  t.check(agentList.items
249
249
  .filter((i) => i.category === 'options')
250
250
  .map((i) => i.value)
@@ -580,7 +580,9 @@ export const menuSuite = {
580
580
  keyFor('x', 'agent:mine') &&
581
581
  !keyFor('x', 'agent:codex') &&
582
582
  typeof actionList.actions === 'function' &&
583
- actionList.actions('start').length === 2 &&
583
+ actionList.actions('start').length === 3 &&
584
+ keyFor('e', 'agent:claude') &&
585
+ !keyFor('e', 'agent:codex') &&
584
586
  actionList.actions('options').length === 0, 'Make default shows on any other agent, Remove only on one you added; neither on built-in agents or other rows');
585
587
  const agentsFile = ConfigFile.user(t.at('agents-actions', 'config.json'));
586
588
  t.write(agentsFile.file, '{\n // mine\n "defaultHarness": "claude"\n}\n');
@@ -11,6 +11,7 @@ import { LocalOverridesSchema } from '../core/schema/index.js';
11
11
  import { discoverPcInstalls } from '../host/pc-installs/index.js';
12
12
  import { generateSandbox } from '../host/launch/index.js';
13
13
  import { isValidGuestPath, PC_INSTALLS_GUEST } from '../shared/paths/index.js';
14
+ import { NOT_READY } from '../shared/readiness.js';
14
15
  const PC = { localAppData: '', appData: '', programData: '' };
15
16
  export const pcInstallsSuite = {
16
17
  name: 'pc-installs',
@@ -70,6 +71,9 @@ export const pcInstallsSuite = {
70
71
  t.check(session.pcInstalls?.items.map((i) => i.id).join() === '480,UE_5.4' &&
71
72
  !JSON.stringify(session.pcInstalls).includes(game), 'session.json lists them by their place in the sandbox only');
72
73
  t.section('detection of the new toolchains');
74
+ // These toolchains are hidden until written (NOT_READY); their detection rules are tested as if they were ready.
75
+ const hidden = [...NOT_READY.toolchains];
76
+ NOT_READY.toolchains.length = 0;
73
77
  const tc = structuredClone(DEFAULTS.toolchains);
74
78
  const decide = (entries, hints = {}, settingsOf = tc) => decideToolchains(settingsOf, entries, [], hints);
75
79
  const godot = decide(['project.godot']);
@@ -92,6 +96,7 @@ export const pcInstallsSuite = {
92
96
  t.check(decide(['project.godot'], {}, godotNet).dotnet.on, "Godot's .NET build brings .NET");
93
97
  const fivem = decide(['fxmanifest.lua']);
94
98
  t.check(!fivem.fivem.on && fivem.fivem.reason.includes('fivem.acceptLicense'), 'the FiveM server is licensed too');
99
+ NOT_READY.toolchains.push(...hidden);
95
100
  t.section('a project never accepts a licence or shares a sign-in');
96
101
  t.check(LICENSED_TOOLCHAINS.every((n) => OFF_IS_SAFER.includes('toolchains.' + n + '.acceptLicense')) &&
97
102
  OFF_IS_SAFER.includes('accounts.epic.share') &&
@@ -6,6 +6,13 @@ import path from 'node:path';
6
6
  import { DEFAULTS } from '../core/config/index.js';
7
7
  import { assertLocalRoom, ensurePersisted, installRecords, InstallRecordsSchema, LOCAL_RESERVE_BYTES, recordUse, } from '../guest/toolchains/persisted.js';
8
8
  import { BUILD_TOOLS_COMPONENTS, UNREAL_COMPONENTS, visualStudioWanted, } from '../guest/toolchains/native/vs-request.js';
9
+ import { autosdkLayout, layoutNextStep, unrealUsesAutosdk, vsExitText } from '../guest/toolchains/native/vs-request.js';
10
+ import { freeBytesAt } from '../shared/io/disk.js';
11
+ import { SIGNERS, signerMatches } from '../shared/signer.js';
12
+ import { decideToolchains } from '../core/detect/toolchains.js';
13
+ import { NOT_READY } from '../shared/readiness.js';
14
+ import { ConfigFile } from '../app/features/settings/settings-file.js';
15
+ import { settingsOf } from '../app/features/settings/settings-rows.js';
9
16
  import { collectDataEntries } from '../cli/services/data-collect.js';
10
17
  const GB = 1024 ** 3;
11
18
  export const persistedInstallsSuite = {
@@ -103,5 +110,67 @@ export const persistedInstallsSuite = {
103
110
  all.for.join() === 'unreal,flutter,unity' &&
104
111
  UNREAL_COMPONENTS.every((c) => all.components.includes(c)) &&
105
112
  all.components.includes('Microsoft.VisualStudio.Component.VC.Llvm.Clang'), 'with Unreal: one Community install serves all of them');
113
+ t.section('Unreal on the portable MSVC (autosdk)');
114
+ tc.unreal.visualStudio = 'autosdk';
115
+ const noUnreal = visualStudioWanted(tc);
116
+ t.check(noUnreal?.product === 'buildtools' && noUnreal.for.join() === 'flutter,unity', 'autosdk: Unreal asks for no Visual Studio (the others still get the Build Tools)');
117
+ tc.flutter.enabled = false;
118
+ tc.unity.enabled = false;
119
+ t.equal(visualStudioWanted(tc), null, 'autosdk alone: no Visual Studio at all');
120
+ t.check(unrealUsesAutosdk(tc, (s) => s === true) && !unrealUsesAutosdk(DEFAULTS.toolchains), 'autosdk is only when asked');
121
+ const pairs = autosdkLayout('T:\\msvc', '14.44.35207', '10.0.26100.0', 'T:\\autosdk');
122
+ t.check(pairs.length === 4 &&
123
+ pairs[0].to === 'T:\\autosdk\\HostWin64\\Win64\\VS2022\\14.44.35207' &&
124
+ pairs[0].from === 'T:\\msvc\\VC\\Tools\\MSVC\\14.44.35207' &&
125
+ pairs[1].from === 'T:\\msvc\\Windows Kits\\10\\Include\\10.0.26100.0' &&
126
+ pairs[1].to === 'T:\\autosdk\\HostWin64\\Win64\\Windows Kits\\10\\Include\\10.0.26100.0', 'the AutoSDK tree is MSVC under VS2022\\<version> and the SDK under Windows Kits\\10 (copies)');
127
+ const on = DEFAULTS.toolchains;
128
+ const auto = {
129
+ ...structuredClone(on),
130
+ unreal: { ...on.unreal, visualStudio: 'autosdk', acceptLicense: true },
131
+ };
132
+ const decided = decideToolchains(auto, ['Game.uproject'], []);
133
+ t.check(decided.unreal.on && decided.msvc.on, 'an Unreal project with autosdk turns the portable MSVC on, under its licence');
134
+ t.section('the Visual Studio layout');
135
+ t.check(vsExitText(3762504530).includes('crashed') &&
136
+ vsExitText(-1).includes('exit 4294967295') &&
137
+ vsExitText(0) === 'installed' &&
138
+ vsExitText(1602) === 'cancelled' &&
139
+ vsExitText(8006).includes('running') &&
140
+ vsExitText(3010).includes('restart') &&
141
+ vsExitText(5007).includes('refused'), 'installer exit codes are told in words (0xE0434352 is a crash)');
142
+ const reserve = LOCAL_RESERVE_BYTES;
143
+ const room = (free) => freeBytesAt(t.dir('layout-room'), () => ({ bavail: free, bsize: 1 })) ?? 0;
144
+ t.check(layoutNextStep(0, 0, reserve) === 'done' &&
145
+ layoutNextStep(3762504530, room(40 * GB), reserve) === 'stage' &&
146
+ layoutNextStep(3762504530, room(20 * GB), reserve) === 'skip' &&
147
+ layoutNextStep(null, room(40 * GB), reserve) === 'stage', 'a failed layout is built on the sandbox disk only with room for it (12 GB plus the reserve)');
148
+ t.equal(freeBytesAt(path.join(t.dir('layout-room'), 'not', 'there'), () => ({ bavail: 3, bsize: 2 })), 6, 'free room of a folder that does not exist asks its parent');
149
+ t.equal(freeBytesAt(t.dir('layout-room'), () => {
150
+ throw new Error('no');
151
+ }), null, 'unknown free room is null');
152
+ t.section('signers');
153
+ const ms = { subject: SIGNERS.microsoft.subject, issuer: undefined };
154
+ const msSig = {
155
+ subject: 'CN=Microsoft Corporation, O=Microsoft Corporation, L=Redmond, S=Washington, C=US',
156
+ issuer: 'CN=Microsoft Code Signing PCA',
157
+ };
158
+ t.check(signerMatches(msSig, ms) &&
159
+ !signerMatches({ ...msSig, subject: 'CN=Microsoft Corporation Fake, O=Evil' }, ms) &&
160
+ !signerMatches(null, ms) &&
161
+ signerMatches({ subject: 'CN="Valve Corp.", O=Valve', issuer: 'x' }, SIGNERS.valve) &&
162
+ signerMatches({ subject: 'CN=Unity Technologies ApS', issuer: 'x' }, SIGNERS.unity) &&
163
+ !signerMatches({ subject: 'CN=Microsoft Corporation', issuer: 'x' }, SIGNERS.valve) &&
164
+ signerMatches(msSig, { subject: ['^CN=Microsoft'], issuer: ['code signing'] }) &&
165
+ !signerMatches(msSig, { subject: ['^CN=Microsoft'], issuer: ['nobody'] }), 'a signer matches by subject (and issuer when named), never an empty or unsigned file');
166
+ t.section('not ready yet');
167
+ const everything = structuredClone(on);
168
+ for (const name of NOT_READY.toolchains)
169
+ everything[name].enabled = 'auto';
170
+ const found = decideToolchains(everything, ['ProjectVersion.txt', 'project.godot', 'fxmanifest.lua'], ['unity']);
171
+ t.check(NOT_READY.toolchains.every((n) => !found[n].on), '"auto" never turns on a toolchain that is not written yet, whatever the project holds');
172
+ const shown = settingsOf(ConfigFile.user(t.at('settings', 'config.json'))).map((r) => r.key.join('.'));
173
+ t.check(!shown.some((k) => /^toolchains\.(unity|godot|android|flutter|dart|reactNative|fivem)\./.test(k)) &&
174
+ shown.includes('toolchains.unreal.visualStudio'), 'settings do not list the toolchains that are not written yet');
106
175
  },
107
176
  };