windows-development-sandbox 0.0.0-stage → 1.0.5

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 (231) hide show
  1. package/.sandbox-config.example.json +14 -0
  2. package/README.md +78 -2
  3. package/config.example.json +332 -0
  4. package/dist/api.d.ts +31 -0
  5. package/dist/api.js +30 -0
  6. package/dist/bin/agent-sandbox.d.ts +2 -0
  7. package/dist/bin/agent-sandbox.js +3 -0
  8. package/dist/bin/claude-host.d.ts +2 -0
  9. package/dist/bin/claude-host.js +5 -0
  10. package/dist/bin/claude-sbx.d.ts +2 -0
  11. package/dist/bin/claude-sbx.js +3 -0
  12. package/dist/bin/codex-sbx.d.ts +2 -0
  13. package/dist/bin/codex-sbx.js +3 -0
  14. package/dist/bin/gemini-sbx.d.ts +2 -0
  15. package/dist/bin/gemini-sbx.js +3 -0
  16. package/dist/bin/sandbox.d.ts +2 -0
  17. package/dist/bin/sandbox.js +4 -0
  18. package/dist/bin/sbx-hide.d.ts +2 -0
  19. package/dist/bin/sbx-hide.js +3 -0
  20. package/dist/bin/sbx-show.d.ts +2 -0
  21. package/dist/bin/sbx-show.js +3 -0
  22. package/dist/cli/args.d.ts +32 -0
  23. package/dist/cli/args.js +111 -0
  24. package/dist/cli/commands/accounts.d.ts +2 -0
  25. package/dist/cli/commands/accounts.js +52 -0
  26. package/dist/cli/commands/context.d.ts +21 -0
  27. package/dist/cli/commands/context.js +54 -0
  28. package/dist/cli/commands/data.d.ts +46 -0
  29. package/dist/cli/commands/data.js +249 -0
  30. package/dist/cli/commands/index.d.ts +8 -0
  31. package/dist/cli/commands/index.js +8 -0
  32. package/dist/cli/commands/launch.d.ts +5 -0
  33. package/dist/cli/commands/launch.js +152 -0
  34. package/dist/cli/commands/maintenance.d.ts +8 -0
  35. package/dist/cli/commands/maintenance.js +125 -0
  36. package/dist/cli/commands/repair.d.ts +59 -0
  37. package/dist/cli/commands/repair.js +332 -0
  38. package/dist/cli/commands/sessions.d.ts +11 -0
  39. package/dist/cli/commands/sessions.js +113 -0
  40. package/dist/cli/commands/setup.d.ts +45 -0
  41. package/dist/cli/commands/setup.js +152 -0
  42. package/dist/cli/index.d.ts +14 -0
  43. package/dist/cli/index.js +96 -0
  44. package/dist/cli/manifest.d.ts +95 -0
  45. package/dist/cli/manifest.js +87 -0
  46. package/dist/cli/menu.d.ts +22 -0
  47. package/dist/cli/menu.js +187 -0
  48. package/dist/cli/settings.d.ts +11 -0
  49. package/dist/cli/settings.js +156 -0
  50. package/dist/core/config.d.ts +17 -0
  51. package/dist/core/config.js +103 -0
  52. package/dist/core/detect-databases.d.ts +15 -0
  53. package/dist/core/detect-databases.js +147 -0
  54. package/dist/core/detect-pwsh.d.ts +18 -0
  55. package/dist/core/detect-pwsh.js +111 -0
  56. package/dist/core/detect-toolchains.d.ts +30 -0
  57. package/dist/core/detect-toolchains.js +58 -0
  58. package/dist/core/local-config.d.ts +12 -0
  59. package/dist/core/local-config.js +64 -0
  60. package/dist/core/schema.d.ts +604 -0
  61. package/dist/core/schema.js +264 -0
  62. package/dist/core/types.d.ts +162 -0
  63. package/dist/core/types.js +1 -0
  64. package/dist/core/util.d.ts +53 -0
  65. package/dist/core/util.js +145 -0
  66. package/dist/core/workspace.d.ts +23 -0
  67. package/dist/core/workspace.js +162 -0
  68. package/dist/guest/daemon.d.ts +1 -0
  69. package/dist/guest/daemon.js +186 -0
  70. package/dist/guest/db.d.ts +1 -0
  71. package/dist/guest/db.js +60 -0
  72. package/dist/guest/env.d.ts +49 -0
  73. package/dist/guest/env.js +99 -0
  74. package/dist/guest/gitguard.d.ts +7 -0
  75. package/dist/guest/gitguard.js +60 -0
  76. package/dist/guest/gitrules.d.ts +2 -0
  77. package/dist/guest/gitrules.js +11 -0
  78. package/dist/guest/guide.d.ts +12 -0
  79. package/dist/guest/guide.js +68 -0
  80. package/dist/guest/main.d.ts +1 -0
  81. package/dist/guest/main.js +120 -0
  82. package/dist/guest/ptyhost.d.ts +40 -0
  83. package/dist/guest/ptyhost.js +94 -0
  84. package/dist/guest/setup/agents.d.ts +8 -0
  85. package/dist/guest/setup/agents.js +73 -0
  86. package/dist/guest/setup/claude.d.ts +21 -0
  87. package/dist/guest/setup/claude.js +138 -0
  88. package/dist/guest/setup/environment.d.ts +6 -0
  89. package/dist/guest/setup/environment.js +65 -0
  90. package/dist/guest/setup/hooks.d.ts +1 -0
  91. package/dist/guest/setup/hooks.js +36 -0
  92. package/dist/guest/setup/network.d.ts +10 -0
  93. package/dist/guest/setup/network.js +99 -0
  94. package/dist/guest/setup/policy.d.ts +3 -0
  95. package/dist/guest/setup/policy.js +18 -0
  96. package/dist/guest/setup/user.d.ts +7 -0
  97. package/dist/guest/setup/user.js +47 -0
  98. package/dist/guest/toolchains/binstall.d.ts +7 -0
  99. package/dist/guest/toolchains/binstall.js +55 -0
  100. package/dist/guest/toolchains/catalogue.d.ts +51 -0
  101. package/dist/guest/toolchains/catalogue.js +31 -0
  102. package/dist/guest/toolchains/cli.d.ts +10 -0
  103. package/dist/guest/toolchains/cli.js +64 -0
  104. package/dist/guest/toolchains/common.d.ts +37 -0
  105. package/dist/guest/toolchains/common.js +124 -0
  106. package/dist/guest/toolchains/databases.d.ts +7 -0
  107. package/dist/guest/toolchains/databases.js +226 -0
  108. package/dist/guest/toolchains/git.d.ts +3 -0
  109. package/dist/guest/toolchains/git.js +112 -0
  110. package/dist/guest/toolchains/go.d.ts +3 -0
  111. package/dist/guest/toolchains/go.js +33 -0
  112. package/dist/guest/toolchains/index.d.ts +13 -0
  113. package/dist/guest/toolchains/index.js +122 -0
  114. package/dist/guest/toolchains/java.d.ts +8 -0
  115. package/dist/guest/toolchains/java.js +96 -0
  116. package/dist/guest/toolchains/managers.d.ts +16 -0
  117. package/dist/guest/toolchains/managers.js +82 -0
  118. package/dist/guest/toolchains/msvc.d.ts +10 -0
  119. package/dist/guest/toolchains/msvc.js +216 -0
  120. package/dist/guest/toolchains/native.d.ts +12 -0
  121. package/dist/guest/toolchains/native.js +220 -0
  122. package/dist/guest/toolchains/node.d.ts +3 -0
  123. package/dist/guest/toolchains/node.js +132 -0
  124. package/dist/guest/toolchains/pwsh.d.ts +4 -0
  125. package/dist/guest/toolchains/pwsh.js +14 -0
  126. package/dist/guest/toolchains/python.d.ts +3 -0
  127. package/dist/guest/toolchains/python.js +47 -0
  128. package/dist/guest/toolchains/runtimes.d.ts +17 -0
  129. package/dist/guest/toolchains/runtimes.js +40 -0
  130. package/dist/guest/toolchains/scripting.d.ts +10 -0
  131. package/dist/guest/toolchains/scripting.js +137 -0
  132. package/dist/guest/toolchains/versions.d.ts +47 -0
  133. package/dist/guest/toolchains/versions.js +77 -0
  134. package/dist/guest/toolchains/vscode.d.ts +2 -0
  135. package/dist/guest/toolchains/vscode.js +53 -0
  136. package/dist/guest/view.d.ts +1 -0
  137. package/dist/guest/view.js +22 -0
  138. package/dist/host/accounts.d.ts +32 -0
  139. package/dist/host/accounts.js +119 -0
  140. package/dist/host/channel.d.ts +37 -0
  141. package/dist/host/channel.js +180 -0
  142. package/dist/host/claude-profile.d.ts +15 -0
  143. package/dist/host/claude-profile.js +103 -0
  144. package/dist/host/cleanup.d.ts +23 -0
  145. package/dist/host/cleanup.js +66 -0
  146. package/dist/host/generate.d.ts +15 -0
  147. package/dist/host/generate.js +185 -0
  148. package/dist/host/personalize.d.ts +10 -0
  149. package/dist/host/personalize.js +97 -0
  150. package/dist/host/platform.d.ts +17 -0
  151. package/dist/host/platform.js +114 -0
  152. package/dist/host/project-scan.d.ts +23 -0
  153. package/dist/host/project-scan.js +173 -0
  154. package/dist/host/repo-guard.d.ts +23 -0
  155. package/dist/host/repo-guard.js +128 -0
  156. package/dist/host/secrets.d.ts +14 -0
  157. package/dist/host/secrets.js +133 -0
  158. package/dist/host/terminal.d.ts +32 -0
  159. package/dist/host/terminal.js +127 -0
  160. package/dist/host/window.d.ts +4 -0
  161. package/dist/host/window.js +68 -0
  162. package/dist/runtime/daemon.js +20291 -0
  163. package/dist/runtime/db.js +20423 -0
  164. package/dist/runtime/gitguard.js +84 -0
  165. package/dist/runtime/main.js +22398 -0
  166. package/dist/runtime/view.js +19966 -0
  167. package/dist/selftest/cli.test.d.ts +2 -0
  168. package/dist/selftest/cli.test.js +30 -0
  169. package/dist/selftest/config.test.d.ts +3 -0
  170. package/dist/selftest/config.test.js +74 -0
  171. package/dist/selftest/data.test.d.ts +2 -0
  172. package/dist/selftest/data.test.js +73 -0
  173. package/dist/selftest/detect.test.d.ts +2 -0
  174. package/dist/selftest/detect.test.js +42 -0
  175. package/dist/selftest/fixtures.d.ts +232 -0
  176. package/dist/selftest/fixtures.js +63 -0
  177. package/dist/selftest/guest.test.d.ts +3 -0
  178. package/dist/selftest/guest.test.js +61 -0
  179. package/dist/selftest/harness.d.ts +14 -0
  180. package/dist/selftest/harness.js +36 -0
  181. package/dist/selftest/helpers.test.d.ts +2 -0
  182. package/dist/selftest/helpers.test.js +125 -0
  183. package/dist/selftest/host.test.d.ts +2 -0
  184. package/dist/selftest/host.test.js +71 -0
  185. package/dist/selftest/index.d.ts +1 -0
  186. package/dist/selftest/index.js +56 -0
  187. package/dist/selftest/live.test.d.ts +7 -0
  188. package/dist/selftest/live.test.js +127 -0
  189. package/dist/selftest/mirror.test.d.ts +3 -0
  190. package/dist/selftest/mirror.test.js +74 -0
  191. package/dist/selftest/security.test.d.ts +3 -0
  192. package/dist/selftest/security.test.js +143 -0
  193. package/dist/selftest/setup.test.d.ts +2 -0
  194. package/dist/selftest/setup.test.js +23 -0
  195. package/dist/selftest/sync.test.d.ts +3 -0
  196. package/dist/selftest/sync.test.js +82 -0
  197. package/dist/shared/download.d.ts +58 -0
  198. package/dist/shared/download.js +200 -0
  199. package/dist/shared/guest-paths.d.ts +30 -0
  200. package/dist/shared/guest-paths.js +61 -0
  201. package/dist/shared/netrules.d.ts +18 -0
  202. package/dist/shared/netrules.js +98 -0
  203. package/dist/shared/paths.d.ts +9 -0
  204. package/dist/shared/paths.js +22 -0
  205. package/dist/shared/pty-client.d.ts +86 -0
  206. package/dist/shared/pty-client.js +227 -0
  207. package/dist/shared/rundir.d.ts +30 -0
  208. package/dist/shared/rundir.js +86 -0
  209. package/dist/shared/safe-remove.d.ts +10 -0
  210. package/dist/shared/safe-remove.js +55 -0
  211. package/dist/shared/schema.d.ts +84 -0
  212. package/dist/shared/schema.js +51 -0
  213. package/dist/shared/system.d.ts +15 -0
  214. package/dist/shared/system.js +19 -0
  215. package/dist/sync/claude-mem.d.ts +8 -0
  216. package/dist/sync/claude-mem.js +77 -0
  217. package/dist/sync/history.d.ts +30 -0
  218. package/dist/sync/history.js +129 -0
  219. package/dist/sync/watcher.d.ts +13 -0
  220. package/dist/sync/watcher.js +116 -0
  221. package/hooks/post-agent/README.txt +1 -0
  222. package/hooks/pre-provision/README.txt +1 -0
  223. package/package.json +91 -4
  224. package/sandbox/bootstrap.ps1 +258 -0
  225. package/sandbox/gitshim.cs +35 -0
  226. package/sandbox/logon.cmd +16 -0
  227. package/sandbox/ptyhost.cs +603 -0
  228. package/sandbox/trust.cmd +35 -0
  229. package/sandbox/vswhere.cs +83 -0
  230. package/schemas/config.schema.json +1062 -0
  231. package/schemas/sandbox-config.schema.json +952 -0
@@ -0,0 +1,2 @@
1
+ import type { Harness } from './harness.js';
2
+ export declare const cliTests: (t: Harness) => void;
@@ -0,0 +1,30 @@
1
+ // The command manifest, parser and menu helpers: one definition drives both the flags and the menu.
2
+ import { buildProgram, parseCli } from '../cli/args.js';
3
+ import { COMMANDS } from '../cli/manifest.js';
4
+ import { commandLine, readHistory, recordHistory, splitArgs } from '../cli/menu.js';
5
+ import { editConfigText } from '../cli/settings.js';
6
+ export const cliTests = (t) => {
7
+ const { check } = t;
8
+ t.section('CLI: manifest, parser, menu');
9
+ const program = buildProgram(() => undefined);
10
+ const visible = COMMANDS.filter((c) => !c.hidden).map((c) => c.name);
11
+ check(visible.every((name) => program.commands.some((c) => c.name() === name)) && program.commands.find((c) => c.name() === 'launch').options.some((o) => o.long === '--terminal'), 'every manifest command and option exists in the parser');
12
+ const start = parseCli(['start', '--headless']);
13
+ check(start.command === 'launch' && start.noAgent && start.headless === true, '`start` is launch without an agent');
14
+ const clean = parseCli(['data', 'clean', 'tools/go', 'logs', '--yes']);
15
+ check(clean.command === 'data' && clean.positionals.join() === 'clean,tools/go,logs' && clean.yes, 'data clean takes targets and --yes');
16
+ check(parseCli(['start', '--database', 'postgres,redis', '--database', 'mongodb']).databases.join() === 'postgres,redis,mongodb' && parseCli(['db', 'start', 'redis']).positionals.join() === 'start,redis', '--database takes lists and repeats; `db` takes an action and a name');
17
+ check(parseCli(['launch', '--harness', 'opencode', '--', '--continue']).passthrough.join() === '--continue', 'agent arguments after -- reach the agent');
18
+ check(JSON.stringify(splitArgs('--model "big one" -c \'x y\'')) === '["--model","big one","-c","x y"]' && commandLine(['exec', 'echo', 'a b']) === 'sandbox exec echo "a b"', 'menu: arguments split like a shell, command lines quoted');
19
+ const original = '{\n // my notes stay\n "sandbox": { "headless": true }\n}\n';
20
+ const set = editConfigText(original, ['attach', 'auto'], true);
21
+ const nested = 'text' in set ? editConfigText(set.text, ['databases', 'postgres', 'port'], 6543) : set;
22
+ const reset = 'text' in nested ? editConfigText(nested.text, ['sandbox', 'headless'], undefined) : nested;
23
+ const invalid = editConfigText(original, ['sandbox', 'memoryMb'], 12);
24
+ check('text' in reset && reset.text.includes('// my notes stay') && reset.text.includes('"auto": true') && reset.text.includes('"port": 6543') && !reset.text.includes('headless') && 'problems' in invalid && invalid.problems.join().includes('memoryMb'), 'settings screen: changes keep the file\'s comments, nested keys are created, reset removes a key, invalid values are not saved');
25
+ recordHistory(['launch', '--harness', 'claude']);
26
+ recordHistory(['status']);
27
+ recordHistory(['launch', '--harness', 'claude']);
28
+ recordHistory(['menu']);
29
+ check(readHistory().map((h) => h.argv.join(' ')).join('|') === 'launch --harness claude|status', 'history: newest first, no duplicates, the menu itself is not recorded');
30
+ };
@@ -0,0 +1,3 @@
1
+ import type { Fixtures } from './fixtures.js';
2
+ import type { Harness } from './harness.js';
3
+ export declare const configTests: (t: Harness, f: Fixtures) => Promise<void>;
@@ -0,0 +1,74 @@
1
+ // Config loading, argument parsing, workspace resolution and .sandbox-config.json trust.
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { parseCli as parseArgs } from '../cli/args.js';
5
+ import { loadConfig } from '../core/config.js';
6
+ import { ensureDir } from '../core/util.js';
7
+ import { autoGuestWorkspace } from '../core/workspace.js';
8
+ export const configTests = async (t, f) => {
9
+ const { check, rejects, write, temp } = t;
10
+ t.section('config');
11
+ const builtIn = loadConfig();
12
+ check(builtIn.configPath === null && builtIn.config.guestWorkspace === 'auto' && !builtIn.config.sandbox.headless && !builtIn.config.sandbox.vGpu && builtIn.config.provisioning.git.access === 'full', 'no user config: built-in defaults (auto guest folder, headless off, vGPU off, git full)');
13
+ const custom = loadConfig(write(path.join(temp, 'cfg.json'), JSON.stringify({ sandbox: { headless: true }, toolchains: { rust: { enabled: false } }, mappings: [{ host: 'rel-folder', guest: 'C:\\Rel', readOnly: true }] })));
14
+ check(custom.config.sandbox.headless && custom.config.toolchains.rust.enabled === false && custom.config.toolchains.python.enabled === 'auto', 'a config file only needs the keys it changes (deep merge over defaults)');
15
+ check(await rejects(() => loadConfig(path.join(temp, 'missing.json')), 'Config file not found'), 'an explicit --config that does not exist is an error');
16
+ check(loadConfig(path.join(t.packageRoot, 'config.example.json')).config.defaultHarness === 'claude', 'config.example.json is valid');
17
+ ensureDir(path.join(temp, 'rel-folder'));
18
+ const relRun = await f.run({}, { ...custom.config, storage: f.baseConfig.storage });
19
+ check(relRun.config.mappings.some((m) => m.host === path.join(temp, 'rel-folder')), 'relative mappings in the user config resolve against the config file\'s folder');
20
+ t.section('arguments');
21
+ const wrapped = parseArgs(['--terminal', '--continue', '--headless', '--node', '20', '--', '--terminal'], true);
22
+ check(wrapped.command === 'launch' && wrapped.terminal && wrapped.headless === true && wrapped.node === '20' && wrapped.passthrough.join(' ') === '--continue --terminal', 'claude-sbx: sandbox flags are consumed, everything else goes to the agent');
23
+ check(await rejects(() => parseArgs(['status', '--bogus']), 'unknown option') && parseArgs(['account', 'use', 'work', '--no-restart']).positionals.join() === 'use,work' && parseArgs(['clean', '--days=3']).days === 3, 'agent-sandbox: unknown options are errors; positionals and --opt=value work');
24
+ const agentFlags = parseArgs(['--model', 'opus', '-p', 'fix it', '--json', '-f', 'x', '--timeout', '5', '--resume', 'abc'], true);
25
+ check(agentFlags.passthrough.join(' ') === '--model opus -p fix it --json -f x --timeout 5 --resume abc' && !agentFlags.json && !agentFlags.follow && agentFlags.timeout === 600, 'claude-sbx: only launch options are ours; any other flag (even ones agent-sandbox uses elsewhere) goes to the agent, in order');
26
+ const reserved = parseArgs(['--sbx-terminal', '--terminal', '--sbx-node=18'], true);
27
+ check(reserved.terminal && reserved.node === '18' && reserved.passthrough.join(' ') === '' && parseArgs(['launch', '--continue', '--sbx-headless']).passthrough.join() === '--continue' && await rejects(() => parseArgs(['--sbx-nope'], true), 'Unknown option: --sbx-nope'), '--sbx-<option> always means agent-sandbox; agent-sandbox launch passes agent flags too');
28
+ t.section('workspace');
29
+ const rA = await f.run();
30
+ check(rA.workspaceHost === f.workA && rA.config.guestWorkspace === 'C:\\Workspace\\work-a' && rA.harnessName === 'claude' && rA.dataPath.endsWith(path.join('data', 'testdata')), 'any folder is the workspace; guest path is C:\\Workspace\\<folder name>');
31
+ check(autoGuestWorkspace('C:\\x\\My Proj (2)') === 'C:\\Workspace\\My Proj _2_' && autoGuestWorkspace('D:\\...') === 'C:\\Workspace\\project', 'auto guest folder names are sanitised');
32
+ check(rA.versions.node.spec === '22.3.0' && rA.versions.node.source === 'this PC' && rA.versions.python.spec === '3', 'default Node version is this PC\'s; Python defaults to latest 3.x');
33
+ write(path.join(f.workA, '.nvmrc'), 'lts/iron\n');
34
+ write(path.join(f.workA, '.python-version'), '3.11\n');
35
+ const rV = await f.run();
36
+ check(rV.versions.node.spec === 'lts/iron' && rV.versions.node.source === '.nvmrc' && rV.versions.python.spec === '3.11', '.nvmrc and .python-version are respected');
37
+ check((await f.run({ nodeVersion: '18' })).versions.node.source === 'command line', '--node beats .nvmrc');
38
+ fs.rmSync(path.join(f.workA, '.nvmrc'));
39
+ fs.rmSync(path.join(f.workA, '.python-version'));
40
+ write(path.join(f.workA, '.node-version'), 'v20.11.1');
41
+ check((await f.run()).versions.node.spec === 'v20.11.1', '.node-version is respected');
42
+ fs.rmSync(path.join(f.workA, '.node-version'));
43
+ check(await rejects(() => f.run({ cwd: f.install }), 'install folder') && await rejects(() => f.run({ cwd: temp }), 'must not be able to change') && await rejects(() => f.run({ cwd: path.join(temp, 'storage') }), 'storage folder'), 'refuses the install folder, its parents and the storage folder');
44
+ const originalWindir = process.env.WINDIR;
45
+ ensureDir(path.join(temp, 'winroot', 'sys'));
46
+ process.env.WINDIR = path.join(temp, 'winroot');
47
+ const sysRefused = await rejects(() => f.run({ cwd: path.join(temp, 'winroot', 'sys') }), 'system folder');
48
+ process.env.WINDIR = originalWindir;
49
+ check(sysRefused && await rejects(() => f.run({ cwd: path.parse(temp).root }), 'whole drive'), 'refuses system folders and drive roots');
50
+ t.section('.sandbox-config.json');
51
+ const localB = { harness: 'codex', sandbox: { memoryMb: 4096, networking: false, headless: true }, toolchains: { node: { version: '18' }, rust: { enabled: false } }, mappings: [{ host: './assets', guest: 'C:\\Assets', readOnly: true }, { host: '../shared', guest: 'C:\\Shared', readOnly: false }], scripts: ['./setup.mjs'] };
52
+ f.writeLocal(f.workB, localB);
53
+ check(await rejects(() => f.run({ cwd: f.workB }), '--trust-config') && await rejects(() => f.run({ cwd: f.workB }), 'READ/WRITE'), 'an untrusted .sandbox-config.json is refused and its effects are listed');
54
+ check(await rejects(() => f.run({ cwd: f.workB, ask: async () => false }), 'has not been approved'), 'answering "no" refuses it');
55
+ const rB = await f.run({ cwd: f.workB, ask: async () => true });
56
+ check(rB.harnessName === 'codex' && rB.config.sandbox.memoryMb === 4096 && rB.config.sandbox.headless && !rB.config.toolchains.rust.enabled && rB.config.toolchains.python.enabled && rB.versions.node.source === '.sandbox-config.json', 'approved local settings apply over the defaults (incl. toolchains, headless)');
57
+ check(rB.config.mappings.some((m) => m.host === f.shared && !m.readOnly) && rB.scripts.includes(path.join(f.workB, 'setup.mjs')), 'relative paths resolve against the folder of .sandbox-config.json');
58
+ check((await f.run({ cwd: f.workB })).harnessName === 'codex' && (await f.run({ cwd: f.workB, noLocalConfig: true })).harnessName === 'claude' && (await f.run({ cwd: f.workB, harness: 'gemini' })).harnessName === 'gemini', 'approval is remembered; --no-local-config and --harness win');
59
+ check((await f.run({ cwd: f.workB, headless: false })).config.sandbox.headless === false, '--window beats a local headless setting');
60
+ f.writeLocal(f.workB, { ...localB, sandbox: { memoryMb: 6144 } });
61
+ check(await rejects(() => f.run({ cwd: f.workB }), 'has not been approved'), 'changing the file requires approval again');
62
+ const dirC = path.join(temp, 'work-c');
63
+ ensureDir(dirC);
64
+ const bad = async (content, needle) => { f.writeLocal(dirC, content); return rejects(() => f.run({ cwd: dirC, trustConfig: true }), needle); };
65
+ check(await bad({ storage: { root: 'C:\\x' } }, 'storage is not allowed') && await bad({ harnesses: {} }, 'harnesses is not allowed'), 'local file cannot change storage or agent executables');
66
+ check(await bad({ claudeProfile: { hostDir: 'C:\\x' } }, 'claudeProfile.hostDir is not allowed') && await bad({ toolchains: { node: { hostDir: 'x' } } }, 'toolchains.node.hostDir is not allowed'), 'local file cannot point at host folders');
67
+ check(await bad({ sandbox: { memoryMb: 12 } }, 'sandbox.memoryMb:') && await bad({ mappings: [{ host: 'x', guest: 'C:\\Windows\\x', readOnly: true }] }, 'reserved or invalid guest path') && await bad({ provisioning: { git: { access: 'admin' } } }, 'provisioning.git.access:'), 'values are type-checked; reserved guest paths and unknown git modes are rejected');
68
+ check(await bad('{\n // a comment\n "readOnly": tru\n}', 'line 3') && await bad({ _comment: 'old style' }, '_comment is not allowed'), 'broken JSON is reported with line and column; unknown keys are rejected');
69
+ f.writeLocal(dirC, '// comments and trailing commas are fine\n{\n "readOnly": true, /* inline */\n}\n');
70
+ check((await f.run({ cwd: dirC, trustConfig: true })).config.readOnly === true, '.sandbox-config.json may contain comments and trailing commas');
71
+ f.writeLocal(dirC, { claudeProfile: { copyCredentials: true }, network: { blockPrivateRanges: false }, provisioning: { git: { access: 'full' } } });
72
+ check(await rejects(() => f.run({ cwd: dirC }), 'copies your Claude LOGIN') && await rejects(() => f.run({ cwd: dirC }), 'turns OFF the LAN block') && await rejects(() => f.run({ cwd: dirC }), 'commit and push'), 'risky settings are called out before approval');
73
+ fs.rmSync(path.join(dirC, '.sandbox-config.json'));
74
+ };
@@ -0,0 +1,2 @@
1
+ import type { Harness } from './harness.js';
2
+ export declare const dataTests: (t: Harness) => void;
@@ -0,0 +1,73 @@
1
+ // `sandbox data` target resolution and `sandbox repair` detection (half-installed toolchains, leftovers, links).
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { humanBytes, removeTree, resolveCleanTargets, sizeOf } from '../cli/commands/data.js';
5
+ import { deadPidFiles, findHalfInstalled, findLinks, findStagingLeftovers, globalPackages, resolvedVersionsValid } from '../cli/commands/repair.js';
6
+ import { ensureDir, errMsg } from '../core/util.js';
7
+ export const dataTests = (t) => {
8
+ const { check, write } = t;
9
+ t.section('data: clean targets');
10
+ const storage = path.join(t.temp, 'data-store');
11
+ const dataPath = path.join(storage, 'data', 'default');
12
+ const resolve = (...inputs) => resolveCleanTargets(inputs, dataPath, storage).map((r) => path.relative(storage, r.path));
13
+ const rejected = (...inputs) => { try {
14
+ resolveCleanTargets(inputs, dataPath, storage);
15
+ return false;
16
+ }
17
+ catch (e) {
18
+ return errMsg(e).length > 0;
19
+ } };
20
+ const d = (...parts) => path.join('data', 'default', ...parts);
21
+ check(resolve('tools/go').join() === d('tools', 'go') && resolve('tools\\go').join() === d('tools', 'go') && resolve('Tools/agents/claude').join() === d('tools', 'agents', 'claude'), 'clean: tools/<name> and tools/agents/<name> resolve inside the data folder (either slash)');
22
+ check(resolve('databases/postgres-18').join() === d('databases', 'postgres-18') && resolve('logs').join() === d('logs') && resolve('launches').join() === 'launches' && resolve('state/claude').join() === d('state', 'claude'), 'clean: databases/<name>, logs, launches, state/<agent>');
23
+ check(resolve('tools', 'tools/go', 'tools/go').join() === d('tools') && resolve('all', 'logs').join() === [d(), 'launches'].join(), 'clean: nested and repeated targets collapse; all = the data folder + launches');
24
+ check(Boolean(resolveCleanTargets(['state/claude'], dataPath, storage)[0].warning?.includes('login')) && Boolean(resolveCleanTargets(['all'], dataPath, storage)[0].warning), 'clean: state and all carry a warning');
25
+ const bad = ['', '..', 'tools/..', 'tools/../../x', '../data', 'C:\\Windows', 'C:tools', '/etc', '\\\\server\\share', 'tools/*', 'tools/go:stream', 'tools/a/b', 'tools/agents/a/b', 'logs/guest.log', 'accounts', 'sync', 'windows', 'tools/.', 'tools/go.'];
26
+ const accepted = bad.filter((b) => !rejected(b));
27
+ check(accepted.length === 0 && rejected(), 'clean: rejects .., absolute paths, drive letters, UNC, wildcards, streams, too deep, unknown areas, nothing (accepted: ' + JSON.stringify(accepted) + ')');
28
+ t.section('data: sizes and removal never follow links');
29
+ const sized = path.join(t.temp, 'sized');
30
+ write(path.join(sized, 'a.bin'), 'x'.repeat(1000));
31
+ write(path.join(sized, 'sub', 'b.bin'), 'y'.repeat(24));
32
+ const outside = path.join(t.temp, 'outside');
33
+ write(path.join(outside, 'keep.txt'), 'z'.repeat(5000));
34
+ let linked = false;
35
+ try {
36
+ fs.symlinkSync(outside, path.join(sized, 'sub', 'escape'), 'junction');
37
+ linked = true;
38
+ }
39
+ catch { /* no junctions here */ }
40
+ check(sizeOf(sized) === 1024 && humanBytes(1536) === '1.5 KB' && humanBytes(0) === '0 B' && humanBytes(3 * 1024 ** 3) === '3.0 GB', 'data: sizes count real files only' + (linked ? ' (a junction counts 0)' : ''));
41
+ if (linked)
42
+ check(findLinks(sized).length === 1, 'repair: finds a junction planted in the data folder');
43
+ removeTree(sized, t.temp);
44
+ check(!fs.existsSync(sized) && fs.existsSync(path.join(outside, 'keep.txt')), 'data: removing a folder never deletes what a link inside points to');
45
+ t.section('repair: detection');
46
+ const tools = path.join(t.temp, 'repair', 'tools');
47
+ write(path.join(tools, 'go', '1.23.2', 'bin', 'go.exe'), 'x');
48
+ ensureDir(path.join(tools, 'go', '1.22.8', 'src'));
49
+ write(path.join(tools, 'java', 'jdk-21', 'bin', 'java.exe'), 'x');
50
+ ensureDir(path.join(tools, 'java', 'jdk-17', 'lib'));
51
+ write(path.join(tools, 'python', 'python-3.12.7', 'python.exe'), 'x');
52
+ write(path.join(tools, 'nvm', 'settings.txt'), 'x');
53
+ write(path.join(tools, 'nvm', 'v22.11.0', 'node.exe'), 'x');
54
+ ensureDir(path.join(tools, 'nvm', 'v20.18.0'));
55
+ write(path.join(tools, 'git', 'bin', 'bash.exe'), 'x');
56
+ ensureDir(path.join(tools, 'dotnet', 'sdk'));
57
+ ensureDir(path.join(tools, 'go', '1.24.0.staging-ab12cd'));
58
+ ensureDir(path.join(tools, 'node-staging'));
59
+ ensureDir(path.join(tools, 'rust.staging-0f'));
60
+ const half = findHalfInstalled(tools).map((h) => path.relative(tools, h.path)).sort();
61
+ check(half.join() === ['dotnet', 'java\\jdk-17', 'go\\1.22.8', 'nvm', 'nvm\\v20.18.0'].map((p) => p.split('\\').join(path.sep)).sort().join(), 'repair: half-installed toolchains = folder without its program (got ' + half.join(', ') + ')');
62
+ const staging = findStagingLeftovers(tools).map((p) => path.relative(tools, p)).sort();
63
+ check(staging.join() === [path.join('go', '1.24.0.staging-ab12cd'), 'node-staging', 'rust.staging-0f'].sort().join(), 'repair: interrupted downloads in tools and one level down (got ' + staging.join(', ') + ')');
64
+ check(resolvedVersionsValid('{"go":"1.23.2"}') && resolvedVersionsValid('\uFEFF{}') && !resolvedVersionsValid('{"go":') && !resolvedVersionsValid('[1]'), 'repair: resolved-versions.json must be a JSON object');
65
+ check(globalPackages('{"dependencies":{"agent-sandbox":{"version":"1.0.0"},"npm":{}}}').join() === 'agent-sandbox,npm' && globalPackages('nope').length === 0 && globalPackages('{"dependencies":3}').length === 0, 'repair: reads npm ls -g --json');
66
+ const run = path.join(t.temp, 'repair', 'run');
67
+ write(path.join(run, 'watcher-default.pid'), '4242');
68
+ write(path.join(run, 'watcher-other.pid'), String(process.pid));
69
+ write(path.join(run, 'watcher-junk.pid'), 'not a pid');
70
+ write(path.join(run, 'hidden-windows.json'), '[]');
71
+ const dead = deadPidFiles(run, (pid) => pid === process.pid).map((f) => path.basename(f)).sort();
72
+ check(dead.join() === 'watcher-default.pid,watcher-junk.pid', 'repair: stale watcher pid files (dead process or junk) are found; a live one is kept');
73
+ };
@@ -0,0 +1,2 @@
1
+ import type { Harness } from './harness.js';
2
+ export declare const detectTests: (t: Harness) => void;
@@ -0,0 +1,42 @@
1
+ // "Which databases does this project need?": detection from real-world file shapes, and the plan the host builds.
2
+ import { databasesFromServices, detectDatabases } from '../core/detect-databases.js';
3
+ import { DEFAULTS } from '../core/config.js';
4
+ import { databaseCatalog, planDatabases } from '../host/project-scan.js';
5
+ import { voltaNode } from '../core/workspace.js';
6
+ import { decideToolchains, resolveToolchains } from '../core/detect-toolchains.js';
7
+ import { parseReleasePage } from '../shared/download.js';
8
+ const found = (files, host = []) => detectDatabases(files, host).map((d) => d.database).join(',');
9
+ export const detectTests = (t) => {
10
+ const { check } = t;
11
+ t.section('database detection');
12
+ const compose = { path: 'docker-compose.yml', text: 'services:\n db:\n image: postgres:16-alpine\n cache:\n image: "redis:7"\n dynamo:\n image: amazon/dynamodb-local:latest\n' };
13
+ check(found([compose]) === 'postgres,redis,dynamodb', 'compose services: postgres, redis and DynamoDB Local images');
14
+ check(found([{ path: 'package.json', text: '{"dependencies":{"pg":"^8.11.0","ioredis":"5","@aws-sdk/client-dynamodb":"3"}}' }]) === 'postgres,redis,dynamodb', 'package.json: pg, ioredis, @aws-sdk/client-dynamodb');
15
+ check(found([{ path: 'package.json', text: '{"dependencies":{"pgadmin-ui":"1","mysql-logger":"2","express":"4"}}' }]) === '', 'package.json: names that only contain a driver name do not count');
16
+ check(found([{ path: 'prisma/schema.prisma', text: 'datasource db {\n provider = "mysql"\n url = env("DATABASE_URL")\n}' }]) === 'mysql', 'Prisma: the datasource provider decides');
17
+ check(found([{ path: 'requirements.txt', text: 'django==5.0\npsycopg2-binary==2.9\npymongo>=4\n' }]) === 'postgres,mongodb', 'Python requirements: psycopg2 and pymongo');
18
+ check(found([{ path: 'Api/Api.csproj', text: '<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.0" />' }]) === 'sqlserver', '.NET: EF Core SQL Server provider');
19
+ check(found([{ path: '.env.example', text: 'DATABASE_URL=postgresql://user:pass@localhost:5432/app\nMONGO_URL=mongodb+srv://cluster/x\n' }]) === 'postgres,mongodb', '.env templates: connection-string schemes');
20
+ check(found([{ path: 'template.yaml', text: 'Resources:\n Table:\n Type: AWS::DynamoDB::Table\n' }]) === 'dynamodb', 'SAM/CloudFormation: a DynamoDB table resource');
21
+ check(found([{ path: 'go.mod', text: 'require (\n\tgithub.com/jackc/pgx/v5 v5.5.0\n\tgithub.com/aws/aws-sdk-go-v2/service/dynamodb v1.0.0\n)' }]) === 'postgres,dynamodb', 'go.mod: pgx and the DynamoDB SDK');
22
+ check(found([{ path: 'package.json', text: '{"dependencies":{"knex":"3"}}' }]) === '' && found([{ path: 'package.json', text: '{"dependencies":{"knex":"3"}}' }], ['mysql']) === 'mysql', 'an ORM that names no database uses the one installed on this PC (and nothing without one)');
23
+ check(databasesFromServices(['postgresql-x64-16', 'MySQL80', 'MSSQL$SQLEXPRESS', 'Spooler', 'Redis']).join() === 'postgres,mysql,sqlserver,redis', 'database services on this PC are recognised by name');
24
+ const plan = planDatabases({ ...DEFAULTS.databases, mongodb: { ...DEFAULTS.databases.mongodb, enabled: true }, redis: { ...DEFAULTS.databases.redis, enabled: false } }, 'unused', { files: [compose], host: [] });
25
+ check(plan.map((p) => p.database + '@' + p.version + ':' + p.port).join() === 'postgres@16:5432,mongodb@latest:27017,dynamodb@latest:8000' && plan.find((p) => p.database === 'dynamodb')?.adminPort === 8001, 'plan: "auto" from the project (compose pins Postgres 16), true always, false never; DynamoDB gets its admin UI');
26
+ const catalog = databaseCatalog({ ...DEFAULTS.databases, postgres: { ...DEFAULTS.databases.postgres, enabled: false, port: 6543 } });
27
+ check(catalog.map((p) => p.database).join() === 'postgres,mysql,mongodb,redis,dynamodb' && catalog[0].port === 6543, 'sbx-db catalog: every database that can run (not SQL Server), even ones off at boot, with their configured ports');
28
+ const volta = t.write(t.temp + '\\volta-ws\\package.json', '{"name":"x","volta":{"node":"20.11.1","pnpm":"9.1.0"}}');
29
+ t.write(t.temp + '\\no-volta-ws\\package.json', '{"name":"y","volta":{"node":"latest; rm -rf"}}');
30
+ check(voltaNode(volta.replace(/\\package\.json$/, '')) === '20.11.1' && voltaNode(t.temp + '\\no-volta-ws') === null && voltaNode(t.temp + '\\missing') === null, 'Volta: package.json "volta.node" picks the Node version (only a plain version is accepted)');
31
+ const tc = DEFAULTS.toolchains;
32
+ const goApi = decideToolchains(tc, ['README.md', 'go.mod', 'api/main.go'], ['python', 'rust']);
33
+ check(goApi.go.on && goApi.go.reason === 'go.mod' && !goApi.python.on && !goApi.rust.on && !goApi.dotnet.on && !goApi.java.on, 'toolchains: a Go project gets Go and nothing it does not use, whatever this PC has');
34
+ const fresh = decideToolchains(tc, ['notes.txt'], ['python', 'dotnet']);
35
+ check(fresh.python.on && fresh.dotnet.on && !fresh.go.on && fresh.python.reason.includes('this PC'), 'toolchains: a folder that is no project yet gets the languages this PC has');
36
+ const rusty = decideToolchains({ ...tc, python: { ...tc.python, enabled: true } }, ['Cargo.toml'], []);
37
+ check(rusty.rust.on && rusty.cpp.on && rusty.python.on && rusty.python.reason === 'enabled in config', 'toolchains: Rust brings its C/C++ linker; true in config always wins');
38
+ check(resolveToolchains(tc, goApi).go.enabled === true && resolveToolchains(tc, goApi).python.enabled === false, 'the sandbox gets plain decisions instead of "auto"');
39
+ const page = '<a href="/o/r/releases/download/v1/tool-win-x64.zip">x</a><span>sha256:' + 'a'.repeat(64) + '</span><a href="/o/r/releases/download/v1/tool-linux.tar.gz">y</a>';
40
+ const assets = parseReleasePage(page);
41
+ check(assets.length === 2 && assets[0].url === 'https://github.com/o/r/releases/download/v1/tool-win-x64.zip' && assets[0].sha256 === 'a'.repeat(64) && assets[1].sha256 === null, 'GitHub release page fallback (API rate limit): assets with their own sha256 only');
42
+ };
@@ -0,0 +1,232 @@
1
+ import type { Config, HostProbe, ResolvedRun, RunRequest } from '../core/types.js';
2
+ import { generateSandbox } from '../host/generate.js';
3
+ import type { Harness } from './harness.js';
4
+ export declare const HOST_NODE_VERSION = "v22.3.0";
5
+ export declare const createFixtures: (t: Harness) => {
6
+ at: (...parts: string[]) => string;
7
+ hostNode: string;
8
+ hostClaude: string;
9
+ hostProfile: string;
10
+ hostGlobalState: string;
11
+ install: string;
12
+ workA: string;
13
+ workB: string;
14
+ shared: string;
15
+ makeConfig: (copyFromHost?: boolean) => Config;
16
+ baseConfig: {
17
+ defaultHarness: string;
18
+ guestWorkspace: string;
19
+ readOnly: boolean;
20
+ mappings: {
21
+ host: string;
22
+ guest: string;
23
+ readOnly: boolean;
24
+ optional?: boolean | undefined;
25
+ }[];
26
+ sandbox: {
27
+ memoryMb: number | "auto";
28
+ maxMemoryMb: number;
29
+ reserveHostMb: number;
30
+ vGpu: boolean;
31
+ networking: boolean;
32
+ clipboard: boolean;
33
+ audioInput: boolean;
34
+ videoInput: boolean;
35
+ printerRedirection: boolean;
36
+ protectedClient: boolean;
37
+ headless: boolean;
38
+ };
39
+ storage: {
40
+ root: string;
41
+ persistAgentState: boolean;
42
+ dataName: string;
43
+ };
44
+ network: {
45
+ blockPrivateRanges: boolean;
46
+ };
47
+ provisioning: {
48
+ copyFromHost: boolean;
49
+ fallbackToInstall: boolean;
50
+ refresh: boolean;
51
+ git: {
52
+ enabled: boolean;
53
+ access: "readonly" | "full" | "none";
54
+ };
55
+ terminal: {
56
+ enabled: boolean;
57
+ };
58
+ };
59
+ toolchains: {
60
+ node: {
61
+ version: string;
62
+ extraVersions: string[];
63
+ corepack: boolean;
64
+ volta: boolean | "auto";
65
+ globalPackages: string[];
66
+ };
67
+ python: {
68
+ enabled: boolean | "auto";
69
+ version: string;
70
+ python2: boolean;
71
+ pyenv: boolean | "auto";
72
+ tools: string[];
73
+ };
74
+ rust: {
75
+ enabled: boolean | "auto";
76
+ toolchain: string;
77
+ host: string;
78
+ components: string[];
79
+ targets: string[];
80
+ crates: string[];
81
+ targetDir: string;
82
+ };
83
+ msvc: {
84
+ enabled: boolean | "auto";
85
+ acceptLicense: boolean;
86
+ msvcVersion: string;
87
+ sdkVersion: string;
88
+ };
89
+ cpp: {
90
+ enabled: boolean | "auto";
91
+ flexBison: boolean;
92
+ };
93
+ dotnet: {
94
+ enabled: boolean | "auto";
95
+ channels: string[];
96
+ tools: string[];
97
+ workloads: string[];
98
+ frameworkTargets: string[];
99
+ netfx35: boolean;
100
+ };
101
+ go: {
102
+ enabled: boolean | "auto";
103
+ version: string;
104
+ tools: string[];
105
+ };
106
+ java: {
107
+ enabled: boolean | "auto";
108
+ version: string;
109
+ maven: boolean;
110
+ gradle: boolean;
111
+ };
112
+ ruby: {
113
+ enabled: boolean | "auto";
114
+ version: string;
115
+ gems: string[];
116
+ };
117
+ php: {
118
+ enabled: boolean | "auto";
119
+ version: string;
120
+ composer: boolean;
121
+ };
122
+ deno: {
123
+ enabled: boolean | "auto";
124
+ };
125
+ pwsh: {
126
+ enabled: boolean | "auto";
127
+ };
128
+ cli: {
129
+ gh: boolean | "auto";
130
+ zstd: boolean | "auto";
131
+ openssl: boolean | "auto";
132
+ };
133
+ bun: {
134
+ enabled: boolean;
135
+ };
136
+ uv: {
137
+ enabled: boolean;
138
+ };
139
+ vcRuntime: {
140
+ enabled: boolean;
141
+ };
142
+ mesa: {
143
+ enabled: boolean;
144
+ vulkan: boolean;
145
+ };
146
+ vscode: {
147
+ enabled: boolean;
148
+ };
149
+ environment: Record<string, string>;
150
+ };
151
+ databases: {
152
+ postgres: {
153
+ enabled: boolean | "auto";
154
+ version: string;
155
+ port: number;
156
+ };
157
+ mysql: {
158
+ enabled: boolean | "auto";
159
+ version: string;
160
+ port: number;
161
+ };
162
+ mongodb: {
163
+ enabled: boolean | "auto";
164
+ version: string;
165
+ port: number;
166
+ };
167
+ redis: {
168
+ enabled: boolean | "auto";
169
+ port: number;
170
+ };
171
+ dynamodb: {
172
+ enabled: boolean | "auto";
173
+ port: number;
174
+ adminPort: number;
175
+ admin: boolean;
176
+ };
177
+ sqlserver: {
178
+ enabled: boolean | "auto";
179
+ };
180
+ };
181
+ attach: {
182
+ enabled: boolean;
183
+ reuseSession: boolean;
184
+ auto: boolean;
185
+ autoHide: boolean;
186
+ };
187
+ secrets: {
188
+ strip: boolean;
189
+ allow: string[];
190
+ patterns: string[];
191
+ warnFiles: boolean;
192
+ };
193
+ personalization: {
194
+ enabled: boolean;
195
+ };
196
+ claudeProfile: {
197
+ enabled: boolean;
198
+ hostDir: string;
199
+ include: string[];
200
+ globalStateFile: string;
201
+ copyCredentials: boolean;
202
+ overwrite: boolean;
203
+ syncHistory: boolean;
204
+ syncClaudeMem: boolean;
205
+ };
206
+ harnesses: Record<string, {
207
+ arguments: string[];
208
+ environment: Record<string, string>;
209
+ hostExecutable?: string | undefined;
210
+ hostDir?: string | undefined;
211
+ npmPackage?: string | undefined;
212
+ npmBin?: string | undefined;
213
+ uvPackage?: string | undefined;
214
+ uvBin?: string | undefined;
215
+ resumeArguments?: string[] | undefined;
216
+ }>;
217
+ hooks: {
218
+ failFast: boolean;
219
+ };
220
+ $schema?: string | undefined;
221
+ };
222
+ probe: HostProbe;
223
+ run: (over?: Partial<RunRequest>, config?: Config) => Promise<ResolvedRun>;
224
+ generate: (r: ResolvedRun, args?: string[], p?: HostProbe) => ReturnType<typeof generateSandbox>;
225
+ writeLocal: (dir: string, content: unknown) => void;
226
+ runGuest: (launchPath: string, data: string, workspace: string, extra?: Record<string, string>) => {
227
+ status: number | null;
228
+ out: string;
229
+ };
230
+ readJson: (file: string) => any;
231
+ };
232
+ export type Fixtures = ReturnType<typeof createFixtures>;
@@ -0,0 +1,63 @@
1
+ // A fake "host PC" inside the temp folder: Node, claude.exe, a ~/.claude profile, workspaces, and helpers to resolve,
2
+ // generate and run the guest against it.
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { spawnSync } from 'node:child_process';
6
+ import { DEFAULTS } from '../core/config.js';
7
+ import { generateSandbox } from '../host/generate.js';
8
+ import { ensureDir } from '../core/util.js';
9
+ import { resolveRun } from '../core/workspace.js';
10
+ export const HOST_NODE_VERSION = 'v22.3.0';
11
+ export const createFixtures = (t) => {
12
+ const { temp, write } = t;
13
+ const at = (...parts) => path.join(temp, ...parts);
14
+ const hostNode = at('host-node');
15
+ write(path.join(hostNode, 'node.exe'), 'x');
16
+ write(path.join(hostNode, 'node_modules', 'npm', 'bin', 'npm-cli.js'), '');
17
+ const hostClaude = at('host-claude');
18
+ write(path.join(hostClaude, 'claude.exe'), 'fake-claude');
19
+ const hostProfile = at('host-dot-claude');
20
+ write(path.join(hostProfile, 'settings.json'), '{ "theme": "dark" }');
21
+ write(path.join(hostProfile, 'CLAUDE.md'), 'my rules');
22
+ write(path.join(hostProfile, 'commands', 'review.md'), 'review cmd');
23
+ write(path.join(hostProfile, 'plugins', 'installed_plugins.json'), JSON.stringify({ path: hostProfile + '\\plugins\\cache\\claude-mem' }));
24
+ write(path.join(hostProfile, '.credentials.json'), '{ "token": "secret" }');
25
+ const hostGlobalState = write(at('host-claude.json'), JSON.stringify({ hasCompletedOnboarding: true, mcpServers: { github: { command: 'npx' } } }));
26
+ const install = at('install');
27
+ ensureDir(path.join(install, 'sub'));
28
+ const workA = at('work-a');
29
+ const workB = at('work-b');
30
+ const shared = at('shared');
31
+ for (const d of [workA, workB, shared, path.join(workB, 'assets'), at('storage')])
32
+ ensureDir(d);
33
+ write(path.join(workB, 'setup.mjs'), "import fs from 'node:fs'; import path from 'node:path'; fs.writeFileSync(path.join(process.env.AGENT_DATA, 'hook-ran.txt'), process.env.AGENT_WORKSPACE);\n");
34
+ write(at('host-tools', 'agents', 'claude', 'claude.exe'), 'fake-claude');
35
+ const makeConfig = (copyFromHost = true) => {
36
+ const c = structuredClone(DEFAULTS);
37
+ c.storage.root = at('storage');
38
+ c.storage.dataName = 'testdata';
39
+ c.sandbox.memoryMb = 8192;
40
+ c.provisioning.copyFromHost = copyFromHost;
41
+ c.provisioning.terminal.enabled = false;
42
+ c.harnesses.claude.hostDir = hostClaude;
43
+ c.claudeProfile = { ...c.claudeProfile, hostDir: hostProfile, globalStateFile: hostGlobalState };
44
+ c.personalization.enabled = false;
45
+ c.mappings = [{ host: at('missing-optional'), guest: 'C:\\GameSources\\Missing', readOnly: true, optional: true }];
46
+ return c;
47
+ };
48
+ const baseConfig = makeConfig();
49
+ const probe = { nodeDir: hostNode, harnessExe: { dir: hostClaude, file: 'claude.exe' }, gitDir: null, nvmDir: null, hostNodeVersion: HOST_NODE_VERSION };
50
+ const run = (over = {}, config = baseConfig) => resolveRun({ packageRoot: install, loaded: { config, configPath: null, base: temp }, cwd: workA, noAgent: false, noLocalConfig: false, trustConfig: false, scripts: [], ...over }, HOST_NODE_VERSION);
51
+ const generate = (r, args = [], p = probe) => generateSandbox({ packageRoot: t.packageRoot, run: r, harnessArgs: args, probe: p });
52
+ const writeLocal = (dir, content) => { write(path.join(dir, '.sandbox-config.json'), typeof content === 'string' ? content : JSON.stringify(content)); };
53
+ /** Runs the guest's bundled main.js in dry-run mode against fake sandbox folders. */
54
+ const runGuest = (launchPath, data, workspace, extra = {}) => {
55
+ const r = spawnSync(process.execPath, [path.join(launchPath, 'runtime', 'main.js')], {
56
+ encoding: 'utf8',
57
+ env: { ...process.env, AGENT_LAUNCH_DIR: launchPath, AGENT_DATA_DIR: data, AGENT_HOST_TOOLS_DIR: at('host-tools'), AGENT_WORKSPACE: workspace, AGENT_LOCAL_DIR: at('local'), AGENT_MANAGED_DIR: at('managed'), AGENT_DRY_RUN: '1', ...extra },
58
+ });
59
+ return { status: r.status, out: (r.stdout ?? '') + (r.stderr ?? '') };
60
+ };
61
+ const readJson = (file) => JSON.parse(fs.readFileSync(file, 'utf8'));
62
+ return { at, hostNode, hostClaude, hostProfile, hostGlobalState, install, workA, workB, shared, makeConfig, baseConfig, probe, run, generate, writeLocal, runGuest, readJson };
63
+ };
@@ -0,0 +1,3 @@
1
+ import type { Fixtures } from './fixtures.js';
2
+ import type { Harness } from './harness.js';
3
+ export declare const guestTests: (t: Harness, f: Fixtures) => Promise<string>;
@@ -0,0 +1,61 @@
1
+ // Generated sandbox (.wsb + launch folder) and the guest boot in dry-run mode.
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { buildWsb, gitIdentity, resolveMemoryMb } from '../host/generate.js';
5
+ import { readJsonFile } from '../shared/rundir.js';
6
+ export const guestTests = async (t, f) => {
7
+ const { check, rejects, at } = { ...t, at: f.at };
8
+ t.section('generated sandbox');
9
+ const rA = await f.run();
10
+ const gen = f.generate(rA, ['--version'], { ...f.probe, gitDir: f.shared, nvmDir: f.workB });
11
+ const wsb = fs.readFileSync(gen.wsbPath, 'utf8');
12
+ check(wsb.includes('<MemoryInMB>8192</MemoryInMB>') && wsb.includes('<SandboxFolder>C:\\Workspace\\work-a</SandboxFolder>') && wsb.includes('C:\\HostTools\\git') && wsb.includes('C:\\HostTools\\nvm') && !wsb.includes('Missing') && wsb.includes('logon.cmd</Command>') && wsb.includes('<vGPU>Disable</vGPU>'), 'wsb: memory, workspace, host git/nvm, optional mapping skipped, logon command, vGPU off');
13
+ const files = ['package.json', 'runtime/main.js', 'runtime/daemon.js', 'runtime/view.js', 'runtime/gitguard.js', 'sandbox/bootstrap.ps1', 'sandbox/logon.cmd', 'sandbox/trust.cmd', 'sandbox/ptyhost.cs', 'sandbox/gitshim.cs'];
14
+ const missing = files.filter((file) => !fs.existsSync(path.join(gen.launchPath, file)));
15
+ check(missing.length === 0 && fs.readdirSync(path.join(gen.launchPath, 'runtime')).length === 5, 'launch folder has the bundled sandbox runtime (5 self-contained files), the C# sources and the boot scripts' + (missing.length ? ' (missing: ' + missing.join(', ') + ')' : ''));
16
+ const logon = fs.readFileSync(path.join(gen.launchPath, 'sandbox', 'logon.cmd'), 'utf8');
17
+ check(logon.indexOf('trust.cmd') < logon.indexOf('wt.exe') && logon.includes('tools\\terminal\\wt.exe') && logon.includes('-NoExit'), 'logon.cmd applies sandbox trust settings first, then opens Windows Terminal (PowerShell fallback)');
18
+ const session = f.readJson(gen.sessionPath);
19
+ check(session.launchId === gen.launchId && session.workspace === 'C:\\Workspace\\work-a' && session.workspaceHost === f.workA && session.harnessArguments[0] === '--version' && session.hostTools.git && session.hostTools.nvm && /^\d+\.\d+\.\d+$/.test(session.hostTools.nodeVersion ?? '') && session.versions.node.spec === '22.3.0' && session.provisioning.git.access === 'full', 'session.json carries ids, paths, versions, host tools and git access');
20
+ const staged = path.join(gen.launchPath, 'claude-profile');
21
+ check(['settings.json', 'CLAUDE.md', '.claude.json', 'plugins'].every((file) => fs.existsSync(path.join(staged, file))) && !fs.existsSync(path.join(staged, '.credentials.json')), 'Claude settings and plugins staged; the login is not');
22
+ check(gitIdentity('[user]\n\tname = Josh\n\temail = "j@x.io"\n[credential]\n\thelper = store\n[url "https://TOKEN@github.com/"]\n\tinsteadOf = https://github.com/\n') === '[user]\n\tname = Josh\n\temail = j@x.io\n' && gitIdentity('[core]\n\tname = x\n') === '', 'only name/email from ~/.gitconfig reach the sandbox (no credential helpers or token URLs)');
23
+ check(buildWsb(f.baseConfig, 4096, [{ host: 'C:\\a&b', guest: 'C:\\X', readOnly: true }]).includes('C:\\a&amp;b'), 'wsb values are XML-escaped');
24
+ check(resolveMemoryMb({ ...f.baseConfig.sandbox, memoryMb: 'auto' }, 40000 * 1048576) === 31744 && await rejects(() => resolveMemoryMb({ ...f.baseConfig.sandbox, memoryMb: 'auto' }, 9000 * 1048576), 'Not enough free RAM'), "memory 'auto' follows free RAM minus reserve");
25
+ t.section('guest boot (dry run)');
26
+ const data1 = at('data1');
27
+ const r1 = f.runGuest(gen.launchPath, data1, f.workA, { AGENT_TEST_EXEMPT: '172.28.0.1' });
28
+ check(r1.status === 0 && r1.out.includes('would run:') && r1.out.includes('--version') && r1.out.includes('cwd=' + f.workA), 'guest starts the agent with its arguments in the workspace' + (r1.status === 0 ? '' : '\n' + r1.out));
29
+ check(['would provision nvm and Node 22.3.0 (from this PC)', 'would provision Python 3', 'would provision MinGW-w64 GCC', 'would provision Rust', '.NET SDK', 'would provision bun', 'would provision uv', 'would provision Git for Windows from this PC'].every((s) => r1.out.includes(s)), 'guest provisions nvm/Node, Python, MinGW, Rust, .NET, Bun, uv, Git');
30
+ const claudeState = path.join(data1, 'state', 'claude');
31
+ check(fs.readFileSync(path.join(claudeState, 'settings.json'), 'utf8').includes('dark') && fs.existsSync(path.join(claudeState, 'commands', 'review.md')) && !fs.existsSync(path.join(claudeState, '.credentials.json')), 'guest applied host Claude settings (no login)');
32
+ check(fs.readFileSync(path.join(claudeState, 'plugins', 'installed_plugins.json'), 'utf8').includes(JSON.stringify(claudeState).slice(1, -1)), 'plugin manifests are re-pointed at the sandbox Claude folder');
33
+ const guestState = f.readJson(path.join(claudeState, '.claude.json'));
34
+ check(guestState.mcpServers?.github?.command === 'npx', 'MCP servers from this PC are in the sandbox');
35
+ const manifest = fs.readFileSync(at('managed', 'CLAUDE.md'), 'utf8');
36
+ check(manifest.includes('DISPOSABLE Windows Sandbox') && manifest.includes(f.workA) && manifest.split('\n').length < 16 && fs.readFileSync(path.join(claudeState, 'CLAUDE.md'), 'utf8') === 'my rules' && fs.existsSync(path.join(data1, 'SANDBOX.md')), 'agent manifest goes to the managed CLAUDE.md (tiny); the user CLAUDE.md is untouched');
37
+ check(!fs.existsSync(at('managed', 'managed-settings.json')), 'git access full: no managed deny rules');
38
+ check(r1.out.includes('AgentSandbox-Block-Private') && r1.out.includes('remoteport=1-52,54-65535'), 'guest firewall plan blocks the LAN but keeps DNS to the gateway');
39
+ check(readJsonFile(path.join(data1, 'run', 'sandbox.json'))?.phase === 'ready', 'guest reports "ready" in run/sandbox.json');
40
+ // Second boot: host changed its MCP servers; the sandbox keeps its own .claude.json but gets the new servers.
41
+ fs.writeFileSync(path.join(claudeState, '.claude.json'), JSON.stringify({ ...guestState, oauthAccount: { emailAddress: 'sandbox@x' }, mcpServers: { local: { command: 'x' } } }));
42
+ fs.writeFileSync(path.join(gen.launchPath, 'claude-profile', '.claude.json'), JSON.stringify({ mcpServers: { github: { command: 'npx2' } }, projects: { [f.workA.replace(/\\/g, '/')]: { mcpServers: { db: { command: 'pg' } } } } }));
43
+ f.runGuest(gen.launchPath, data1, f.workA);
44
+ const merged = f.readJson(path.join(claudeState, '.claude.json'));
45
+ check(merged.oauthAccount.emailAddress === 'sandbox@x' && merged.mcpServers.github.command === 'npx2' && merged.mcpServers.local.command === 'x' && Object.values(merged.projects).some((p) => p.mcpServers?.db), 'MCP servers re-sync every boot (user + project scope) without touching the sandbox login');
46
+ const readonlyConfig = f.makeConfig();
47
+ readonlyConfig.provisioning.git.access = 'readonly';
48
+ const genRo = f.generate(await f.run({}, readonlyConfig));
49
+ f.runGuest(genRo.launchPath, at('data-ro'), f.workA, { AGENT_MANAGED_DIR: at('managed-ro') });
50
+ const policy = f.readJson(at('managed-ro', 'managed-settings.json'));
51
+ check(policy.permissions.deny.includes('Bash(git commit:*)') && policy.permissions.deny.includes('PowerShell(git push:*)') && policy.permissions.deny.includes('Edit(**/.git/**)') && !policy.permissions.deny.includes('Bash(git status:*)'), 'git readonly: Claude managed settings deny writing git commands and .git edits');
52
+ const genHook = f.generate(await f.run({ cwd: f.workB, harness: 'claude', trustConfig: true }));
53
+ const rHook = f.runGuest(genHook.launchPath, at('data-hook'), f.workB);
54
+ check(rHook.status === 0 && fs.readFileSync(at('data-hook', 'hook-ran.txt'), 'utf8') === f.workB, 'local "scripts" run inside the sandbox with the workspace set');
55
+ const genInstall = f.generate(await f.run({}, f.makeConfig(false)), [], { ...f.probe, nodeDir: null, harnessExe: null });
56
+ const r3 = f.runGuest(genInstall.launchPath, at('data3'), f.workA, { AGENT_HOST_TOOLS_DIR: at('empty') });
57
+ check(r3.status === 0 && r3.out.includes('npm-cli.js') && r3.out.includes('@anthropic-ai/claude-code') && !fs.readFileSync(genInstall.wsbPath, 'utf8').includes('HostTools'), 'install mode: nothing mapped from this PC, agent installed with npm');
58
+ const r5 = f.runGuest(f.generate(await f.run({ noAgent: true })).launchPath, at('data5'), f.workA);
59
+ check(r5.status === 0 && r5.out.includes('no agent requested'), '--no-agent provisions without an agent');
60
+ return gen.launchPath;
61
+ };