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,14 @@
1
+ export interface Harness {
2
+ packageRoot: string;
3
+ temp: string;
4
+ check: (ok: boolean, name: string) => void;
5
+ rejects: (fn: () => unknown, needle: string) => Promise<boolean>;
6
+ write: (file: string, content: string) => string;
7
+ /** Polls `read` until it returns something truthy (or the timeout passes). */
8
+ waitFor: <T>(read: () => T | null | undefined | false | Promise<T | null | undefined | false>, ms?: number) => Promise<T | null>;
9
+ section: (title: string) => void;
10
+ }
11
+ export declare const createHarness: (packageRoot: string) => Harness & {
12
+ failures: () => number;
13
+ dispose: () => void;
14
+ };
@@ -0,0 +1,36 @@
1
+ // Minimal test harness: PASS/FAIL lines, a temp folder that is always removed, small helpers.
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { ensureDir, errMsg, sleep } from '../core/util.js';
6
+ export const createHarness = (packageRoot) => {
7
+ let failures = 0;
8
+ const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-sandbox-test-'));
9
+ return {
10
+ packageRoot,
11
+ temp,
12
+ failures: () => failures,
13
+ check: (ok, name) => { console.log((ok ? '\x1b[32mPASS\x1b[0m ' : '\x1b[31mFAIL\x1b[0m ') + name); if (!ok)
14
+ failures++; },
15
+ rejects: async (fn, needle) => { try {
16
+ await fn();
17
+ return false;
18
+ }
19
+ catch (e) {
20
+ return (errMsg(e) + ' ' + (e.fix ?? '')).includes(needle);
21
+ } },
22
+ write: (file, content) => { ensureDir(path.dirname(file)); fs.writeFileSync(file, content); return file; },
23
+ waitFor: async (read, ms = 30000) => {
24
+ const end = Date.now() + ms;
25
+ while (Date.now() < end) {
26
+ const v = await read();
27
+ if (v)
28
+ return v;
29
+ await sleep(250);
30
+ }
31
+ return null;
32
+ },
33
+ section: (title) => console.log('\n\x1b[36m-- ' + title + '\x1b[0m'),
34
+ dispose: () => fs.rmSync(temp, { recursive: true, force: true, maxRetries: 5, retryDelay: 300 }),
35
+ };
36
+ };
@@ -0,0 +1,2 @@
1
+ import type { Harness } from './harness.js';
2
+ export declare const helperTests: (t: Harness) => void;
@@ -0,0 +1,125 @@
1
+ // Small pure helpers: version picking, .reg generation, firewall maths, storage cleanup.
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { spawnSync } from 'node:child_process';
5
+ import { pathToFileURL } from 'node:url';
6
+ import { pruneStorage } from '../host/cleanup.js';
7
+ import { toRegFile } from '../host/personalize.js';
8
+ import { javaMajorFrom, pickGoRelease, pickNodeVersion, pickPackageVersion, pickSpreadVersions, projectUses, specFromVersionFile } from '../guest/toolchains/versions.js';
9
+ import { parseReleaseTags } from '../shared/download.js';
10
+ import { buildFirewallCommands, cidrToRange, ipv4DnsServers, parseDefaultGateways, parseDnsServers, subtractIps } from '../shared/netrules.js';
11
+ import { ensureDir, isWin } from '../core/util.js';
12
+ import { decidePwsh, pcHasPwsh, PWSH_MAX_BYTES, readPwshScripts, usesPwsh } from '../core/detect-pwsh.js';
13
+ import { binstallAsset, CLI_TOOLS, decideCli, pwshAsset } from '../guest/toolchains/catalogue.js';
14
+ import { PWSH_PROFILE_GUID, syncPwshProfile, withPwshProfile } from '../host/terminal.js';
15
+ export const helperTests = (t) => {
16
+ const { check, write } = t;
17
+ t.section('versions, registry, firewall');
18
+ const releases = [{ version: 'v23.1.0', lts: false }, { version: 'v22.11.0', lts: 'Jod' }, { version: 'v20.18.0', lts: 'Iron' }, { version: 'v20.9.0', lts: 'Iron' }];
19
+ check(pickNodeVersion(releases, 'lts/*') === '22.11.0' && pickNodeVersion(releases, 'lts/iron') === '20.18.0' && pickNodeVersion(releases, '20') === '20.18.0' && pickNodeVersion(releases, 'v20.9.0') === '20.9.0' && pickNodeVersion(releases, 'node') === '23.1.0' && pickNodeVersion(releases, '99') === null, '.nvmrc specs resolve (lts/*, lts/<name>, major, exact, node)');
20
+ const spread = pickSpreadVersions(['16.20.2', '18.19.0', '18.20.4', '20.18.0', '22.3.0', '22.11.0', '23.1.0', 'notes'], '22.11.0', 3);
21
+ check(JSON.stringify(spread) === '["22.11.0","16.20.2","23.1.0"]' && pickSpreadVersions(['20.18.0'], '22.3.0', 3).join() === '22.3.0,20.18.0' && pickSpreadVersions(['18.20.4', '20.18.0'], '22.3.0', 1).join() === '22.3.0', 'host nvm versions: capped, current first, rest spread across other majors');
22
+ const go = [{ version: 'go1.24rc1', stable: false, files: [] }, { version: 'go1.23.2', stable: true, files: [] }, { version: 'go1.23.1', stable: true, files: [] }, { version: 'go1.22.8', stable: true, files: [] }];
23
+ check(pickGoRelease(go, 'stable')?.version === 'go1.23.2' && pickGoRelease(go, '1.22')?.version === 'go1.22.8' && pickGoRelease(go, 'go1.23.1')?.version === 'go1.23.1' && pickGoRelease(go, '1.30') === null, 'Go versions resolve (stable, minor prefix, exact; release candidates skipped)');
24
+ check(javaMajorFrom('21\n') === '21' && javaMajorFrom('17.0.9') === '17' && javaMajorFrom('1.8') === '8' && javaMajorFrom('temurin-21.0.2') === '21' && javaMajorFrom('# sdkman\njava=21.0.2-tem\n', true) === '21' && specFromVersionFile('ruby-3.3.1\n') === '3.3.1', 'version files: .java-version, .sdkmanrc, .ruby-version');
25
+ check(projectUses(['README.md', 'server/go.mod'], ['go.mod']) && projectUses(['App.csproj'], ['*.csproj']) && !projectUses(['notes.txt', 'docs/readme.md'], ['go.mod', '*.csproj']), 'enabled "auto": a language is used when its marker file is in the workspace or one folder down');
26
+ check(pickPackageVersion(['3.9.13', '3.12.7', '3.12.10', '3.13.0-rc1', '3.13.1'], '3') === '3.13.1' && pickPackageVersion(['3.12.7', '3.12.10'], '3.12') === '3.12.10' && pickPackageVersion(['2.7.18'], '2.7') === '2.7.18', 'Python versions resolve to the newest stable match');
27
+ const reg = toRegFile([{ key: 'Control Panel\\Desktop', name: 'WallpaperStyle', kind: 'String', value: '10' }, { key: 'X', name: 'D', kind: 'DWord', value: 4294967295 }, { key: 'X', name: 'B', kind: 'Binary', value: Buffer.from([1, 255]).toString('base64') }]);
28
+ check(reg.includes('[HKEY_CURRENT_USER\\Control Panel\\Desktop]') && reg.includes('"WallpaperStyle"="10"') && reg.includes('"D"=dword:ffffffff') && reg.includes('"B"=hex:01,ff'), 'personalisation: registry values become a valid .reg file (HKCU only)');
29
+ const [s172, e172] = cidrToRange('172.16.0.0/12');
30
+ const gw = 172 * 16777216 + 28 * 65536 + 1;
31
+ check(subtractIps([[s172, e172]], ['172.28.0.1']).every(([s, e]) => gw < s || gw > e) && !buildFirewallCommands(['8.8.8.8']).join().includes('8.8.8.8'), 'firewall maths: private block minus the gateway; public addresses ignored');
32
+ check(parseDefaultGateways(' 0.0.0.0 0.0.0.0 172.28.80.1 172.28.85.200 5\n').join() === '172.28.80.1' && parseDnsServers(' DNS Servers . . . : 172.28.80.1\n 8.8.8.8\n NetBIOS : x\n').join() === '172.28.80.1,8.8.8.8', 'parses gateway and DNS servers');
33
+ check(ipv4DnsServers(['172.28.80.1', '8.8.8.8:1053', '[::1]:53', 'fe80::1']).join() === '172.28.80.1,8.8.8.8', 'DNS servers from the resolver (any Windows language): IPv4 only, ports dropped');
34
+ check(parseReleaseTags('<a href="/coreybutler/nvm-windows/releases/tag/1.2.2">x</a><a href="/coreybutler/nvm-windows/releases/tag/1.2.2">y</a><a href="/other/repo/releases/tag/9">z</a><a href="/coreybutler/nvm-windows/releases/tag/1.1.12">w</a>', 'coreybutler/nvm-windows').join() === '1.2.2,1.1.12', 'GitHub release list page: this repo\'s tags in order, once each');
35
+ t.section('cmd quoting (npm agents)');
36
+ // ptyhost.ts loads env.ts, which reads session.json: import it in a child with a throwaway launch/data folder.
37
+ const quoteDir = path.join(t.temp, 'quote');
38
+ write(path.join(quoteDir, 'launch', 'session.json'), '{}');
39
+ const agentCmd = write(path.join(quoteDir, 'agent.cmd'), '@"' + process.execPath + '" -e "console.log(JSON.stringify(process.argv.slice(1)))" %*\r\n');
40
+ // Odd quote counts flip cmd's quote state for every later argument: those come after them on purpose.
41
+ const tricky = ['https://x?a=1&b=2', '50% off', 'a|b<c>d', 'x^y', 'say "hi" & go', 'plain', '%PATH%', 'a"b&c', 'q"x|y^z"w', 'e"5%', 'k&v', '100%'];
42
+ const probe = spawnSync(process.execPath, ['--input-type=module', '-e', [
43
+ 'const m = await import(process.argv[1]); const [cmd, tricky] = JSON.parse(process.argv[2]);',
44
+ 'console.log(JSON.stringify({ normal: m.commandLine("C:\\\\W\\\\cmd.exe", ["/d", "/c", "C:\\\\a\\\\agent.cmd", "--model", "big one", "plain", ""]), direct: m.commandLine("C:\\\\a\\\\agent.exe", ["a&b", "50%"]),',
45
+ ' url: m.cmdArgs(["https://x?a=1&b=2"]), pct: m.cmdArgs(["50%"]), live: m.commandLine("cmd.exe", ["/d", "/c", cmd, ...tricky]).slice(8) }));',
46
+ ].join('\n'), pathToFileURL(path.join(t.packageRoot, 'dist', 'guest', 'ptyhost.js')).href, JSON.stringify([agentCmd, tricky])], {
47
+ encoding: 'utf8', env: { ...process.env, AGENT_LAUNCH_DIR: path.join(quoteDir, 'launch'), AGENT_DATA_DIR: path.join(quoteDir, 'data') },
48
+ });
49
+ const quoting = (() => { try {
50
+ return JSON.parse(probe.stdout);
51
+ }
52
+ catch {
53
+ return {};
54
+ } })();
55
+ check(quoting.normal === 'C:\\W\\cmd.exe /d /c C:\\a\\agent.cmd --model "big one" plain ""' && quoting.direct === 'C:\\a\\agent.exe a&b 50%', 'command lines: ordinary arguments quoted as before; a program started directly gets & and % unchanged' + (quoting.normal ? '' : ' (' + (probe.stderr || probe.stdout).trim().slice(0, 300) + ')'));
56
+ check(quoting.url === '"https://x?a=1&b=2"' && quoting.pct === '"50"%""', 'cmd /c arguments: & | < > ^ quoted, % split out so no %name% expands');
57
+ // (Agents live under C:\AgentData\tools; a temp folder with spaces would also need cmd's quote rules, so skip there.)
58
+ if (isWin && quoting.live && !/\s/.test(agentCmd)) {
59
+ const echoed = spawnSync(process.env.ComSpec ?? 'cmd.exe', [quoting.live], { encoding: 'utf8', windowsVerbatimArguments: true, windowsHide: true });
60
+ check(echoed.stdout.trim() === JSON.stringify(tricky), 'cmd /d /c <agent>.cmd passes &, |, <, >, ^, % and quotes through to the agent unchanged (got ' + echoed.stdout.trim() + ')');
61
+ }
62
+ t.section('cleanup');
63
+ const store = path.join(t.temp, 'prune');
64
+ const aged = (p, days) => { const d = new Date(Date.now() - days * 86400000); fs.utimesSync(p, d, d); };
65
+ for (const [name, days] of [['old', 9], ['kept', 30], ['new', 1]]) {
66
+ aged(write(path.join(store, 'launches', name, 'agent.wsb'), 'x'), days);
67
+ aged(path.join(store, 'launches', name), days);
68
+ }
69
+ aged(write(path.join(store, 'data', 'd', 'logs', 'guest-old.log'), 'x'), 20);
70
+ write(path.join(store, 'data', 'd', 'logs', 'guest-new.log'), 'x');
71
+ ensureDir(path.join(store, 'data', 'd', 'tools', 'git.staging-ab12'));
72
+ aged(path.join(store, 'data', 'd', 'tools', 'git.staging-ab12'), 2);
73
+ ensureDir(path.join(store, 'data', 'd', 'tools', 'node'));
74
+ const pruned = pruneStorage(store, { keep: ['kept'] });
75
+ const left = (p) => fs.existsSync(path.join(store, p));
76
+ check(pruned.launches === 1 && !left('launches/old') && left('launches/kept') && left('launches/new') && !left('data/d/logs/guest-old.log') && left('data/d/logs/guest-new.log') && !left('data/d/tools/git.staging-ab12') && left('data/d/tools/node'), 'cleanup: launches > 7 days, logs > 14 days and interrupted downloads go; the running launch and real tools stay');
77
+ t.section('PowerShell 7, CLI catalogue, cargo-binstall');
78
+ const pwshNames = ['PowerShell-7.6.6-win-x64.zip', 'PowerShell-7.6.6-win-arm64.zip', 'PowerShell-7.6.6-win-x64.msi', 'PowerShell-7.6.6-win-fxdependent.zip', 'PowerShell-7.6.6-win-x86.zip', 'PowerShell-7.6.6-win-x64.zip.sha256'];
79
+ check(pwshNames.filter((n) => pwshAsset('x64').test(n)).join() === 'PowerShell-7.6.6-win-x64.zip' && pwshNames.filter((n) => pwshAsset('arm64').test(n)).join() === 'PowerShell-7.6.6-win-arm64.zip', 'PowerShell 7: the portable zip for the architecture (no MSI, fxdependent or x86 build)');
80
+ const binstallNames = ['cargo-binstall-x86_64-pc-windows-msvc.zip', 'cargo-binstall-x86_64-pc-windows-msvc.full.zip', 'cargo-binstall-x86_64-pc-windows-msvc.zip.sig', 'cargo-binstall-aarch64-pc-windows-msvc.zip', 'cargo-binstall-x86_64-unknown-linux-musl.tgz'];
81
+ check(binstallNames.filter((n) => binstallAsset('x64').test(n)).join() === 'cargo-binstall-x86_64-pc-windows-msvc.zip' && binstallNames.filter((n) => binstallAsset('arm64').test(n)).join() === 'cargo-binstall-aarch64-pc-windows-msvc.zip', 'cargo-binstall: the plain Windows zip for the architecture (not .full, not the signature)');
82
+ const cliAsset = (name, arch, names) => names.filter((n) => CLI_TOOLS.find((c) => c.name === name).asset(arch).test(n)).join();
83
+ const ghNames = ['gh_2.102.0_windows_amd64.zip', 'gh_2.102.0_windows_amd64.msi', 'gh_2.102.0_windows_386.zip', 'gh_2.102.0_windows_arm64.zip', 'gh_2.102.0_linux_amd64.tar.gz'];
84
+ const zstdNames = ['zstd-v1.5.7-win64.zip', 'zstd-v1.5.7-win32.zip', 'zstd-v1.5.7.tar.gz'];
85
+ check(cliAsset('gh', 'x64', ghNames) === 'gh_2.102.0_windows_amd64.zip' && cliAsset('gh', 'arm64', ghNames) === 'gh_2.102.0_windows_arm64.zip' && cliAsset('zstd', 'x64', zstdNames) === 'zstd-v1.5.7-win64.zip' && cliAsset('zstd', 'arm64', zstdNames) === 'zstd-v1.5.7-win64.zip' && CLI_TOOLS.every((c) => c.exe.endsWith('.exe')), 'CLI catalogue: gh and zstd release zips per architecture (zstd: win64 also on Arm)');
86
+ const cli = (settings, gitAccess, hasGithubDir = false) => Object.entries(decideCli(settings, { gitAccess, hasGithubDir })).filter(([, d]) => d.on).map(([n]) => n).join();
87
+ check(cli({ gh: true, zstd: true, openssl: true }, 'full') === 'gh,zstd,openssl' && cli({ gh: true, zstd: true }, 'readonly') === 'zstd' && cli({ gh: 'auto' }, 'none', true) === '' && decideCli({ gh: true }, { gitAccess: 'readonly', hasGithubDir: false }).gh.reason.includes('"full"'), 'CLI catalogue: gh only with git access "full" (it pushes through the API), whatever the config says');
88
+ check(cli({ gh: 'auto', zstd: 'auto', openssl: 'auto' }, 'full', true) === 'gh,openssl' && cli({ gh: 'auto', zstd: 'auto' }, 'full', false) === '' && cli({}, 'full', true) === '', 'CLI catalogue: "auto" = gh with a .github folder, openssl always (a shim), zstd only when listed; unlisted tools stay off');
89
+ const ps = (text, file = 'moh.ps1') => usesPwsh([{ path: file, text }]);
90
+ check(ps('#Requires -Version 7.2\nparam()') === 'moh.ps1: #Requires -Version 7' && ps(' #requires -PSEdition Core') !== null && ps('#Requires -Modules Pester -Version 7') !== null && ps('<#\n.SYNOPSIS\n Usage: pwsh scripts/moh/build-openmohaa.ps1 -Release\n#>') !== null && ps('& pwsh.exe -NoProfile -File x.ps1') !== null && ps('C:\\Program Files\\PowerShell\\7\\pwsh.exe -c 1') !== null, 'pwsh markers: #Requires -Version 7+ / -PSEdition Core, and pwsh usage lines');
91
+ check(ps('#Requires -Version 5.1') === null && ps('#Requires -PSEdition Desktop') === null && ps('# Works in Windows PowerShell and pwsh too.') === null && ps('$pwsh -x; Write-Host mypwsh -y') === null && ps('#Requires -Version 7', 'notes.txt') === null && ps('Write-Host hi') === null, 'pwsh markers: Windows PowerShell scripts, prose, variables and non-scripts do not count');
92
+ const evilStart = Date.now();
93
+ const evilText = (' #requires' + ' -version'.repeat(5000) + '\n' + 'xpwsh pwsh\t\t'.repeat(20000) + '\n' + ' '.repeat(100000) + 'pwsh\n' + 'pwsh\n'.repeat(50000)).repeat(3);
94
+ const evil = usesPwsh(Array.from({ length: 100 }, (_, i) => ({ path: 'a' + i + '.ps1', text: evilText })));
95
+ check(evil === null && Date.now() - evilStart < 2000, 'pwsh markers: hostile scripts (huge lines, endless repeats) are capped and scanned in linear time (' + (Date.now() - evilStart) + ' ms)');
96
+ check(decidePwsh('auto', [], true, true).on && !decidePwsh('auto', [], false, true).on && !decidePwsh('auto', [], true, false).on && decidePwsh('auto', [{ path: 'x/run.ps1', text: '#Requires -PSEdition Core' }], false, false).reason === 'x/run.ps1: #Requires -PSEdition Core' && !decidePwsh(false, [{ path: 'a.ps1', text: '#Requires -Version 7' }], true, true).on && decidePwsh(true, [], false, false).on, 'pwsh "auto": a script that needs it, else this PC has it (new folder only); true/false win');
97
+ const pf = 'C:\\PF';
98
+ check(pcHasPwsh({ ProgramFiles: pf }, (p) => p === 'C:\\PF\\PowerShell\\7\\pwsh.exe') && pcHasPwsh({ PATH: 'C:\\x;C:\\tools\\pwsh' }, (p) => p === 'C:\\tools\\pwsh\\pwsh.exe') && !pcHasPwsh({ ProgramFiles: pf, PATH: 'C:\\Users\\u\\AppData\\Local\\Microsoft\\WindowsApps' }, (p) => /WindowsApps/.test(p)), 'pwsh on this PC: Program Files\\PowerShell\\7 or PATH, not a Store alias');
99
+ const psRoot = path.join(t.temp, 'pwsh-scan');
100
+ write(path.join(psRoot, 'moh.ps1'), '#Requires -Version 7\n');
101
+ write(path.join(psRoot, 'scripts', 'build.ps1'), 'x'.repeat(200000) + '\n#Requires -Version 7');
102
+ write(path.join(psRoot, 'node_modules', 'pkg.ps1'), '#Requires -Version 7');
103
+ write(path.join(psRoot, 'scripts', 'deep', 'far.ps1'), '#Requires -Version 7');
104
+ write(path.join(psRoot, 'readme.md'), 'pwsh -File x.ps1');
105
+ const scanned = readPwshScripts(psRoot);
106
+ check(scanned.map((f) => f.path).join() === 'moh.ps1,scripts/build.ps1' && scanned[1].text.length === PWSH_MAX_BYTES, 'pwsh scan: *.ps1 at the root and one folder down only (node_modules skipped), each read up to its cap');
107
+ const shown = withPwshProfile({ theme: 'dark', profiles: { defaults: { font: {} } } }, true);
108
+ const shownList = shown?.profiles;
109
+ const tweaked = { profiles: { list: [{ guid: 'other' }, { guid: PWSH_PROFILE_GUID.toUpperCase(), name: 'My pwsh', fontSize: 14 }] } };
110
+ const hidden = withPwshProfile(tweaked, false)?.profiles;
111
+ check(shownList?.list.length === 1 && shownList.list[0].guid === PWSH_PROFILE_GUID && String(shownList.list[0].commandline).includes('pwsh.exe') && Boolean(shownList.defaults) && shown?.theme === 'dark' && withPwshProfile(shown, true) === null && withPwshProfile({ profiles: { defaults: {} } }, false) === null, 'Windows Terminal: a PowerShell 7 profile is added once pwsh is installed, nothing rewritten when already there or never wanted');
112
+ check(hidden?.list[1].hidden === true && hidden.list[1].name === 'My pwsh' && hidden.list[1].fontSize === 14 && hidden.list.length === 2 && (withPwshProfile({ profiles: [{ guid: 'a' }] }, true)?.profiles).list.length === 2, 'Windows Terminal: turning pwsh off hides the profile, keeping the user\'s edits; the old array form of "profiles" works');
113
+ const wtData = path.join(t.temp, 'wt-data');
114
+ const wtSettings = write(path.join(wtData, 'tools', 'terminal', 'settings', 'settings.json'), '// edited in the sandbox\n{ "theme": "light", "profiles": { "list": [{ "guid": "{x}", }] } }');
115
+ syncPwshProfile(wtData, true);
116
+ const before = fs.readFileSync(wtSettings, 'utf8');
117
+ write(path.join(wtData, 'tools', 'pwsh', 'pwsh.exe'), 'MZ');
118
+ syncPwshProfile(wtData, true);
119
+ const after = JSON.parse(fs.readFileSync(wtSettings, 'utf8'));
120
+ syncPwshProfile(wtData, false);
121
+ const off = JSON.parse(fs.readFileSync(wtSettings, 'utf8'));
122
+ write(path.join(wtData, 'tools', 'terminal', 'settings', 'settings.json'), '{ not json');
123
+ syncPwshProfile(wtData, true);
124
+ check(before.startsWith('// edited') && after.theme === 'light' && after.profiles.list.map((p) => p.guid).join() === '{x},' + PWSH_PROFILE_GUID && off.profiles.list[1].hidden === true && fs.readFileSync(wtSettings, 'utf8') === '{ not json', 'Windows Terminal settings.json (JSONC): untouched until pwsh is installed, then the profile is added, hidden when off; an unreadable file is left alone');
125
+ };
@@ -0,0 +1,2 @@
1
+ import type { Harness } from './harness.js';
2
+ export declare const hostTests: (t: Harness) => void;
@@ -0,0 +1,71 @@
1
+ // Runs every command and shortcut binary as a separate process in a simulated HOST: none of the sandbox-only folders
2
+ // or variables exist (C:\AgentLaunch, C:\AgentData, CLAUDE_CONFIG_DIR, ...), with a fresh home and storage. Tests
3
+ // usually run inside a sandbox, where those exist, so this is what catches code that only works there. Every command
4
+ // must either succeed or fail with a friendly "Error:" message; a crash or stack trace fails.
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { spawnSync } from 'node:child_process';
8
+ import { ensureDir } from '../core/util.js';
9
+ const SANDBOX_ONLY_VARS = ['CLAUDE_CONFIG_DIR', 'CODEX_HOME', 'GEMINI_CLI_HOME', 'AGENT_WORKSPACE', 'AGENT_DATA', 'AGENT_TOOLS', 'AGENT_PROJECT', 'NVM_HOME', 'NVM_SYMLINK', 'BASH_ENV', 'CLAUDE_CODE_GIT_BASH_PATH', 'AGENT_GIT_ACCESS', 'AGENT_REAL_GIT', 'AGENT_SANDBOX_CONFIG'];
10
+ export const hostTests = (t) => {
11
+ t.section('simulated host: every command, outside a sandbox');
12
+ const home = path.join(t.temp, 'host-sim-home');
13
+ const project = path.join(t.temp, 'host-sim-project');
14
+ const storage = path.join(t.temp, 'host-sim-storage');
15
+ for (const dir of [home, project, storage])
16
+ ensureDir(dir);
17
+ const config = t.write(path.join(t.temp, 'host-sim-config.json'), JSON.stringify({ storage: { root: storage }, personalization: { enabled: false }, provisioning: { terminal: { enabled: false } } }));
18
+ const env = {
19
+ ...Object.fromEntries(Object.entries(process.env).filter(([k]) => !SANDBOX_ONLY_VARS.includes(k))),
20
+ AGENT_SANDBOX_HOME: home,
21
+ AGENT_LAUNCH_DIR: path.join(t.temp, 'no-agent-launch'),
22
+ AGENT_DATA_DIR: path.join(t.temp, 'no-agent-data'),
23
+ AGENT_HOST_TOOLS_DIR: path.join(t.temp, 'no-host-tools'),
24
+ NO_COLOR: '1',
25
+ // This test may itself run inside a sandbox: the CLI must behave as it would on a PC.
26
+ AGENT_SANDBOX_HOST_ONLY: '1',
27
+ };
28
+ const bin = (name) => path.join(t.packageRoot, 'dist', 'bin', name + '.js');
29
+ const runs = [
30
+ ['agent-sandbox', '--help', ['--help']],
31
+ ['agent-sandbox', 'status', ['status', '--config', config]],
32
+ ['agent-sandbox', 'status --json', ['status', '--json', '--config', config]],
33
+ ['agent-sandbox', 'sessions', ['sessions', '--config', config]],
34
+ ['agent-sandbox', 'doctor', ['doctor', '--config', config]],
35
+ ['agent-sandbox', 'account list', ['account', 'list', '--config', config]],
36
+ ['agent-sandbox', 'account use nobody', ['account', 'use', 'nobody', '--config', config]],
37
+ ['agent-sandbox', 'config path', ['config', 'path']],
38
+ ['agent-sandbox', 'config show', ['config', 'show', '--config', config]],
39
+ ['agent-sandbox', 'config schema', ['config', 'schema']],
40
+ ['agent-sandbox', 'config init', ['config', 'init']],
41
+ ['agent-sandbox', 'config init (again)', ['config', 'init']],
42
+ ['agent-sandbox', 'clean', ['clean', '--config', config]],
43
+ ['agent-sandbox', 'sync', ['sync', '--config', config]],
44
+ ['agent-sandbox', 'logs', ['logs', '--config', config]],
45
+ ['agent-sandbox', 'attach', ['attach', '--config', config]],
46
+ ['agent-sandbox', 'exec', ['exec', '--config', config, '--', 'echo', 'hi']],
47
+ ['agent-sandbox', 'restart', ['restart', '--config', config]],
48
+ ['agent-sandbox', 'stop', ['stop', '--config', config]],
49
+ ['agent-sandbox', 'show', ['show', '--config', config]],
50
+ ['agent-sandbox', 'hide', ['hide', '--config', config]],
51
+ ['agent-sandbox', 'generate', ['generate', '--config', config]],
52
+ ['agent-sandbox', 'launch', ['launch', '--config', config]],
53
+ ['agent-sandbox', 'unknown command', ['frobnicate']],
54
+ ['claude-sbx', 'claude-sbx --headless --continue', ['--headless', '--continue', '--config', config]],
55
+ ['codex-sbx', 'codex-sbx', ['--config', config]],
56
+ ['gemini-sbx', 'gemini-sbx', ['--config', config]],
57
+ ['sbx-show', 'sbx-show', ['--config', config]],
58
+ ['claude-host', 'claude-host --version', ['--version']],
59
+ ];
60
+ const broken = [];
61
+ for (const [name, label, args] of runs) {
62
+ const r = spawnSync(process.execPath, [bin(name), ...args], { cwd: project, env, encoding: 'utf8', timeout: 120000 });
63
+ const output = ((r.stdout ?? '') + (r.stderr ?? '')).replace(/\x1b\[[0-9;]*m/g, '');
64
+ const crashed = r.status === null || r.status > 1 || /Unexpected error|node:internal|^\s+at .+:\d+:\d+\)?$/m.test(output);
65
+ const unexplained = r.status === 1 && !/(^|\n)(Error: |error: )/.test(output) && name !== 'claude-host';
66
+ if (crashed || unexplained)
67
+ broken.push(label + ' (exit ' + r.status + '): ' + output.trim().split('\n').slice(0, 4).join(' | '));
68
+ }
69
+ t.check(broken.length === 0, 'all ' + runs.length + ' commands work or fail with a clear message when run outside a sandbox' + (broken.length ? ':\n ' + broken.join('\n ') : ''));
70
+ t.check(fs.existsSync(path.join(home, 'config.json')) && fs.existsSync(path.join(home, 'config.schema.json')) && fs.existsSync(path.join(home, 'sandbox-config.schema.json')), 'on a fresh PC, config init and the local schema files are created');
71
+ };
@@ -0,0 +1 @@
1
+ export declare const selfTest: (packageRoot: string) => Promise<number>;
@@ -0,0 +1,56 @@
1
+ // `agent-sandbox self-test`: offline logic and security tests, plus live tests of the session daemon, ptyhost and
2
+ // the git guard when running on Windows (they need csc.exe and ConPTY, both part of Windows 10/11).
3
+ import { errMsg } from '../core/util.js';
4
+ import { configTests } from './config.test.js';
5
+ import { cliTests } from './cli.test.js';
6
+ import { dataTests } from './data.test.js';
7
+ import { detectTests } from './detect.test.js';
8
+ import { mirrorTests } from './mirror.test.js';
9
+ import { createFixtures } from './fixtures.js';
10
+ import { guestTests } from './guest.test.js';
11
+ import { createHarness } from './harness.js';
12
+ import { helperTests } from './helpers.test.js';
13
+ import { hostTests } from './host.test.js';
14
+ import { daemonTests, gitGuardTests, liveTestsAvailable } from './live.test.js';
15
+ import { securityTests } from './security.test.js';
16
+ import { setupTests } from './setup.test.js';
17
+ import { syncTests } from './sync.test.js';
18
+ export const selfTest = async (packageRoot) => {
19
+ const t = createHarness(packageRoot);
20
+ const saved = { home: process.env.AGENT_SANDBOX_HOME, windir: process.env.WINDIR };
21
+ process.env.AGENT_SANDBOX_HOME = t.temp + '\\user-home';
22
+ try {
23
+ const f = createFixtures(t);
24
+ await configTests(t, f);
25
+ const launchPath = await guestTests(t, f);
26
+ await syncTests(t, f);
27
+ await mirrorTests(t, f);
28
+ await securityTests(t, f);
29
+ helperTests(t);
30
+ detectTests(t);
31
+ setupTests(t);
32
+ hostTests(t);
33
+ dataTests(t);
34
+ cliTests(t);
35
+ if (liveTestsAvailable()) {
36
+ await daemonTests(t, f, launchPath);
37
+ await gitGuardTests(t, f, launchPath);
38
+ }
39
+ else
40
+ console.log('SKIP live tests (need Windows with the .NET Framework compiler)');
41
+ }
42
+ catch (e) {
43
+ t.check(false, 'unexpected error: ' + errMsg(e) + (e instanceof Error && e.stack ? '\n' + e.stack : ''));
44
+ }
45
+ finally {
46
+ if (saved.home === undefined)
47
+ delete process.env.AGENT_SANDBOX_HOME;
48
+ else
49
+ process.env.AGENT_SANDBOX_HOME = saved.home;
50
+ process.env.WINDIR = saved.windir;
51
+ t.dispose();
52
+ }
53
+ const failures = t.failures();
54
+ console.log(failures === 0 ? '\nALL TESTS PASSED' : '\n' + failures + ' TEST(S) FAILED');
55
+ return failures === 0 ? 0 : 1;
56
+ };
@@ -0,0 +1,7 @@
1
+ import type { Fixtures } from './fixtures.js';
2
+ import type { Harness } from './harness.js';
3
+ export declare const liveTestsAvailable: () => boolean;
4
+ /** Drives the real daemon through the run folder exactly like the host does, with cmd.exe standing in for claude. */
5
+ export declare const daemonTests: (t: Harness, f: Fixtures, launchPath: string) => Promise<void>;
6
+ /** Compiles gitshim.cs and runs real git through it in a scratch repository, as git.exe and through Git Bash. */
7
+ export declare const gitGuardTests: (t: Harness, f: Fixtures, launchPath: string) => Promise<void>;
@@ -0,0 +1,127 @@
1
+ // Live tests on Windows: the real guest session daemon + ptyhost (ConPTY), and the compiled git guard on a real repo.
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { spawn, spawnSync } from 'node:child_process';
5
+ import { ensureDir, findOnPath, sleep } from '../core/util.js';
6
+ import { connect, encodeFrame, fixWindowsKeys, FrameReader, FrameType, helloFrame, queryViewers } from '../shared/pty-client.js';
7
+ import { readJsonFile, writeJsonAtomic } from '../shared/rundir.js';
8
+ const CSC = path.join(process.env.WINDIR ?? 'C:\\Windows', 'Microsoft.NET', 'Framework64', 'v4.0.30319', 'csc.exe');
9
+ export const liveTestsAvailable = () => process.platform === 'win32' && fs.existsSync(CSC);
10
+ /** Drives the real daemon through the run folder exactly like the host does, with cmd.exe standing in for claude. */
11
+ export const daemonTests = async (t, f, launchPath) => {
12
+ const { check, waitFor } = t;
13
+ t.section('live: session daemon + ptyhost');
14
+ const data = f.at('live-data');
15
+ const workspace = f.workA;
16
+ ensureDir(path.join(data, 'tools', 'agents', 'claude'));
17
+ fs.copyFileSync(path.join(process.env.WINDIR ?? 'C:\\Windows', 'System32', 'cmd.exe'), path.join(data, 'tools', 'agents', 'claude', 'claude.exe'));
18
+ const sessionFile = path.join(launchPath, 'session.json');
19
+ const session = f.readJson(sessionFile);
20
+ Object.assign(session, { workspace, attach: { enabled: true } });
21
+ Object.assign(session.harness, { arguments: ['/d', '/k', 'echo LIVE-AGENT-READY'], resumeArguments: ['/d', '/k', 'echo LIVE-AGENT-RESUMED'] });
22
+ fs.writeFileSync(sessionFile, JSON.stringify(session));
23
+ const env = { ...process.env, AGENT_LAUNCH_DIR: launchPath, AGENT_DATA_DIR: data, AGENT_WORKSPACE: workspace, AGENT_LOCAL_DIR: f.at('local'), AGENT_LOG_PREFIX: 'daemon', AGENT_NO_TABS: '1' };
24
+ const daemon = spawn(process.execPath, [path.join(launchPath, 'runtime', 'daemon.js')], { env, stdio: 'ignore', windowsHide: true });
25
+ const run = path.join(data, 'run');
26
+ const request = (body) => writeJsonAtomic(path.join(run, 'requests', body.id + '.json'), { createdAt: new Date().toISOString(), ...body });
27
+ const response = (id) => waitFor(() => readJsonFile(path.join(run, 'responses', id + '.json')));
28
+ try {
29
+ request({ kind: 'session', id: 'live1', harness: 'claude', args: [], cwd: workspace });
30
+ const s = await waitFor(() => { const v = readJsonFile(path.join(run, 'sessions', 'live1.json')); return v && v.phase !== 'starting' ? v : null; }, 60000);
31
+ check(s?.phase === 'running' && Boolean(s.attach?.port), 'daemon compiles ptyhost and starts a session on request' + (s?.phase === 'failed' ? ' (' + s.message + ')' : ''));
32
+ if (!s?.attach)
33
+ return;
34
+ const socket = await connect({ ...s.attach, addresses: ['127.0.0.1'] });
35
+ const reader = new FrameReader();
36
+ let out = '';
37
+ let exitCode = null;
38
+ socket.on('data', (c) => { for (const fr of reader.push(c)) {
39
+ if (fr.type === FrameType.Output)
40
+ out += fr.payload.toString();
41
+ if (fr.type === FrameType.Exit)
42
+ exitCode = fr.payload.readInt32LE(0);
43
+ } });
44
+ socket.write(helloFrame(s.attach.token, 100, 30));
45
+ check(Boolean(await waitFor(() => out.includes('LIVE-AGENT-READY'), 10000)), 'an attached client sees the agent\'s screen');
46
+ socket.write(encodeFrame(FrameType.Input, Buffer.from('echo TYPED-%USERNAME:~0,0%OK\r')));
47
+ check(Boolean(await waitFor(() => out.includes('TYPED-OK'), 10000)), 'keystrokes reach the agent');
48
+ const badClient = await connect({ ...s.attach, addresses: ['127.0.0.1'] });
49
+ const badReader = new FrameReader();
50
+ let denied = '';
51
+ badClient.on('data', (c) => { for (const fr of badReader.push(c))
52
+ if (fr.type === FrameType.Error)
53
+ denied = fr.payload.toString(); });
54
+ badClient.write(helloFrame('wrong-token', 80, 24));
55
+ check(Boolean(await waitFor(() => denied, 20000)) && denied.includes('denied'), 'a client with the wrong token is refused');
56
+ const local = { ...s.attach, addresses: ['127.0.0.1'] };
57
+ const first = await queryViewers(local);
58
+ const second = await connect(local);
59
+ second.on('data', () => undefined);
60
+ second.write(helloFrame(s.attach.token, 60, 20));
61
+ const both = await waitFor(async () => ((await queryViewers(local))?.viewers === 2 ? true : null), 5000);
62
+ second.destroy();
63
+ const after = await waitFor(async () => { const v = await queryViewers(local); return v?.viewers === 1 ? v : null; }, 5000);
64
+ const still = readJsonFile(path.join(run, 'sessions', 'live1.json'));
65
+ check(first?.viewers === 1 && first.remote === 0 && Boolean(both) && after?.remote === 0 && still?.phase === 'running', 'viewers are counted live: a closed terminal stops counting at once and the session keeps running');
66
+ check(await queryViewers({ ...local, token: 'wrong-token' }) === null, 'the viewer query needs the session token too');
67
+ check(fixWindowsKeys(Buffer.from([0x61, 0x08, 0x7f, 0x1b])).equals(Buffer.from([0x61, 0x7f, 0x08, 0x1b])), 'Windows console Backspace (^H) reaches the agent as DEL, so it deletes one character, not a word');
68
+ request({ kind: 'exec', id: 'exec1', command: ['echo', 'hello from exec'], cwd: workspace, timeoutSeconds: 30 });
69
+ const ex = await response('exec1');
70
+ check(Boolean(ex?.ok && ex.stdout?.includes('hello from exec')), 'exec runs a command in the sandbox and returns its output');
71
+ request({ kind: 'exec', id: 'exec2', command: ['dir'], cwd: 'C:\\Windows', timeoutSeconds: 30 });
72
+ const ex2 = await response('exec2');
73
+ check(ex2?.ok === false && ex2.message.includes('not inside the sandbox workspace'), 'exec refuses folders outside the workspace');
74
+ request({ kind: 'shell', id: 'evil', command: 'calc' });
75
+ request({ kind: 'ping', id: 'ping1' });
76
+ const ping = await response('ping1');
77
+ check(Boolean(ping?.ok) && !fs.existsSync(path.join(run, 'responses', 'evil.json')) && !fs.existsSync(path.join(run, 'requests', 'evil.json')), 'ping answers; unknown request kinds are deleted unanswered');
78
+ request({ kind: 'restart', id: 'restart1', harness: 'claude' });
79
+ check(Boolean(await waitFor(() => out.includes('LIVE-AGENT-RESUMED'), 15000)), 'restart relaunches the agent with its resume arguments while attached');
80
+ socket.write(encodeFrame(FrameType.Input, Buffer.from('exit 3\r')));
81
+ await waitFor(() => exitCode !== null, 10000);
82
+ const ended = await waitFor(() => { const v = readJsonFile(path.join(run, 'sessions', 'live1.json')); return v?.phase === 'exited' ? v : null; });
83
+ check(exitCode === 3 && ended?.exitCode === 3, 'the agent\'s exit code reaches the client and the session status');
84
+ socket.destroy();
85
+ badClient.destroy();
86
+ }
87
+ finally {
88
+ daemon.kill();
89
+ await sleep(500);
90
+ }
91
+ };
92
+ /** Compiles gitshim.cs and runs real git through it in a scratch repository, as git.exe and through Git Bash. */
93
+ export const gitGuardTests = async (t, f, launchPath) => {
94
+ const realGit = findOnPath(['git.exe']);
95
+ t.section('live: git access guard');
96
+ if (!realGit) {
97
+ console.log('SKIP git guard (no git.exe on PATH)');
98
+ return;
99
+ }
100
+ const shimDir = f.at('gitshim');
101
+ ensureDir(shimDir);
102
+ const compiled = spawnSync(CSC, ['/nologo', '/optimize', '/out:' + path.join(shimDir, 'git.exe'), path.join(launchPath, 'sandbox', 'gitshim.cs')], { encoding: 'utf8' });
103
+ t.check(compiled.status === 0, 'gitshim.cs compiles with the .NET Framework compiler' + (compiled.status === 0 ? '' : ': ' + compiled.stdout));
104
+ if (compiled.status !== 0)
105
+ return;
106
+ const repo = f.at('guard-repo');
107
+ ensureDir(repo);
108
+ spawnSync(realGit, ['init', '-q'], { cwd: repo });
109
+ fs.writeFileSync(path.join(repo, 'a.txt'), 'hello');
110
+ const guard = path.join(launchPath, 'runtime', 'gitguard.js');
111
+ const env = (access) => ({ ...process.env, PATH: shimDir + path.delimiter + process.env.PATH, AGENT_GIT_ACCESS: access, AGENT_REAL_GIT: access === 'readonly' ? realGit : '', AGENT_GUARD_NODE: process.execPath, AGENT_GUARD_SCRIPT: guard, GIT_CONFIG_NOSYSTEM: '1' });
112
+ const git = (access, args) => spawnSync('git', args, { cwd: repo, env: env(access), encoding: 'utf8' });
113
+ const status = git('readonly', ['status', '--porcelain']);
114
+ t.check(status.status === 0 && status.stdout.includes('a.txt'), 'readonly: `git status` works through the git.exe stand-in (exact arguments preserved)');
115
+ const add = git('readonly', ['add', 'a.txt']);
116
+ t.check(add.status === 128 && add.stderr.includes('read-only') && git('readonly', ['status', '--porcelain']).stdout.includes('??'), 'readonly: `git add` is refused and nothing changes');
117
+ const none = git('none', ['status']);
118
+ t.check(none.status === 128 && none.stderr.includes('disabled'), 'none: every git call is refused');
119
+ const bash = [path.join(path.dirname(path.dirname(realGit)), 'bin', 'bash.exe'), path.join(path.dirname(path.dirname(realGit)), 'usr', 'bin', 'bash.exe')].find((b) => fs.existsSync(b));
120
+ if (bash) {
121
+ const bashEnv = t.write(f.at('gitshim', 'bashenv.sh'), 'git() { "' + process.execPath.replace(/\\/g, '/') + '" "' + guard.replace(/\\/g, '/') + '" "$@"; }\nexport -f git\n');
122
+ const viaBash = spawnSync(bash, ['-c', 'git commit -m x'], { cwd: repo, env: { ...env('readonly'), BASH_ENV: bashEnv.replace(/\\/g, '/') }, encoding: 'utf8' });
123
+ t.check(viaBash.status === 128 && viaBash.stderr.includes('read-only'), 'readonly: git inside Git Bash goes through the guard too (BASH_ENV)');
124
+ }
125
+ else
126
+ console.log('SKIP Git Bash check (no bash.exe next to git)');
127
+ };
@@ -0,0 +1,3 @@
1
+ import type { Fixtures } from './fixtures.js';
2
+ import type { Harness } from './harness.js';
3
+ export declare const mirrorTests: (t: Harness, f: Fixtures) => Promise<void>;
@@ -0,0 +1,74 @@
1
+ // Guest paths on other drive letters (C:\Mirror\<letter> + subst) and guestWorkspace "mirror".
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { Config, Mapping } from '../core/schema.js';
5
+ import { guestPathFor } from '../cli/commands/context.js';
6
+ import { buildWsb } from '../host/generate.js';
7
+ import { guestDrive, isValidGuestPath, planMirrorWorkspace, sandboxFolder, substDrives } from '../shared/guest-paths.js';
8
+ import { guestWorkspaceAliases, projectFolderName } from '../shared/paths.js';
9
+ import { syncHistoryIn, syncHistoryOut } from '../sync/history.js';
10
+ export const mirrorTests = async (t, f) => {
11
+ const { check, rejects, write } = t;
12
+ t.section('mirrored guest paths (other drive letters)');
13
+ check(['C:\\Assets', 'C:\\Workspace\\x', 'F:\\SteamLibrary\\steamapps\\common\\Game', 'f:\\x', 'Z:\\a b\\c (1)'].every(isValidGuestPath), 'guest paths under C:\\ or on D: to Z: are accepted');
14
+ check(!['C:\\', 'F:\\', 'F:', 'C:\\Windows\\x', 'C:\\Users\\me', 'c:\\program files\\x', 'C:\\AgentData\\x', 'C:\\Mirror', 'C:\\Mirror\\F\\x', 'A:\\x', 'B:\\x', 'C:\\a\\..\\Windows', 'F:\\a\\..\\b', 'F:\\a:b', 'F:\\a/b', 'F:\\a\\\\b', 'F:\\a.\\b', 'F:\\a|b', 'F:\\a\nb', '\\\\server\\share\\x', 'relative\\x', 'F:x'].some(isValidGuestPath), 'drive roots, reserved folders, C:\\Mirror, A:/B:, .., streams, odd characters and UNC paths are refused');
15
+ const mapping = (guest) => Mapping.safeParse({ host: 'E:\\Games', guest, readOnly: true }).success;
16
+ check(mapping('F:\\SteamLibrary\\Game') && !mapping('C:\\Mirror\\F\\x') && mapping('C:\\F') && !mapping('F:\\'), 'schema: mappings[].guest may be on another drive letter, never C:\\Mirror or a drive root');
17
+ check(Config.safeParse({ ...f.makeConfig(), guestWorkspace: 'mirror' }).success && Config.safeParse({ ...f.makeConfig(), guestWorkspace: 'F:\\AgentProjects\\x' }).success && !Config.safeParse({ ...f.makeConfig(), guestWorkspace: 'C:\\Users\\x' }).success, 'schema: guestWorkspace accepts "mirror" and D:-Z: paths, refuses reserved ones');
18
+ check(guestDrive('f:\\x') === 'F' && guestDrive('\\\\s\\x') === null, 'drive letter of a guest path');
19
+ check(sandboxFolder('F:\\SteamLibrary\\Game') === 'C:\\Mirror\\F\\SteamLibrary\\Game' && sandboxFolder('f:\\x') === 'C:\\Mirror\\F\\x' && sandboxFolder('C:\\Assets') === 'C:\\Assets', 'F:\\x really lives at C:\\Mirror\\F\\x; C: paths stay as they are');
20
+ check(JSON.stringify(substDrives(['C:\\a', 'G:\\z', 'F:\\x', 'f:\\y'])) === JSON.stringify([{ letter: 'F', target: 'C:\\Mirror\\F' }, { letter: 'G', target: 'C:\\Mirror\\G' }]), 'one subst drive per used letter (never C:), sorted');
21
+ check(JSON.stringify(guestWorkspaceAliases('F:\\AgentProjects\\mw2')) === JSON.stringify(['C:\\Mirror\\F\\AgentProjects\\mw2', 'F:\\AgentProjects\\mw2']) && guestWorkspaceAliases('C:\\Workspace\\x').length === 1, 'a mirrored workspace is also known by its real C:\\Mirror path');
22
+ const wsb = buildWsb(f.baseConfig, 4096, [{ host: 'E:\\SteamLibrary\\steamapps\\common\\Game', guest: 'F:\\SteamLibrary\\steamapps\\common\\Game', readOnly: true }, { host: 'D:\\Assets', guest: 'C:\\Assets', readOnly: false }]);
23
+ check(wsb.includes('<SandboxFolder>C:\\Mirror\\F\\SteamLibrary\\steamapps\\common\\Game</SandboxFolder>') && wsb.includes('<SandboxFolder>C:\\Assets</SandboxFolder>') && !wsb.includes('<SandboxFolder>F:'), 'the .wsb maps F:\\ guests under C:\\Mirror\\F');
24
+ t.section('guestWorkspace "mirror"');
25
+ const fallback = 'C:\\Workspace\\mw2-agent';
26
+ const onF = planMirrorWorkspace('F:\\AgentProjects\\mw2-agent', fallback);
27
+ check(onF.guest === 'F:\\AgentProjects\\mw2-agent' && (onF.note ?? '').includes('C:\\Mirror\\F\\AgentProjects\\mw2-agent'), 'a workspace on F: appears at its PC path (mirror + subst), with a note');
28
+ check(planMirrorWorkspace('f:\\AgentProjects\\x\\', fallback).guest === 'F:\\AgentProjects\\x', 'trailing backslash and lower-case letter are normalised');
29
+ const onC = planMirrorWorkspace('C:\\dev\\mw2-agent', fallback);
30
+ check(onC.guest === 'C:\\dev\\mw2-agent' && onC.note === null, 'a workspace on C: outside reserved folders keeps its path');
31
+ const reserved = planMirrorWorkspace('C:\\Users\\josh\\mw2-agent', fallback);
32
+ check(reserved.guest === fallback && (reserved.note ?? '').includes('reserved'), 'C:\\Users\\... falls back to C:\\Workspace\\<name> with a note');
33
+ check(planMirrorWorkspace('\\\\server\\share\\mw2-agent', fallback).guest === fallback && planMirrorWorkspace('A:\\mw2-agent', fallback).guest === fallback, 'UNC paths and A:/B: fall back too');
34
+ const mirrorConfig = { ...f.makeConfig(), guestWorkspace: 'mirror' };
35
+ const resolved = await f.run({}, mirrorConfig);
36
+ const expected = planMirrorWorkspace(f.workA, 'C:\\Workspace\\work-a');
37
+ check(resolved.config.guestWorkspace === expected.guest && (expected.note === null || resolved.notes.includes(expected.note)), 'resolveRun turns "mirror" into a concrete guest path (and explains a fallback in run.notes)');
38
+ t.section('generate: mirrored mappings and workspace');
39
+ const game = f.at('steam', 'Game');
40
+ fs.mkdirSync(game, { recursive: true });
41
+ const base = await f.run();
42
+ const run = { ...base, config: { ...base.config, guestWorkspace: 'G:\\Projects\\work-a', mappings: [{ host: game, guest: 'F:\\SteamLibrary\\steamapps\\common\\Game', readOnly: true }] } };
43
+ const generated = f.generate(run);
44
+ const session = f.readJson(generated.sessionPath);
45
+ const text = fs.readFileSync(generated.wsbPath, 'utf8');
46
+ check(session.workspace === 'G:\\Projects\\work-a' && JSON.stringify(session.substDrives) === JSON.stringify([{ letter: 'F', target: 'C:\\Mirror\\F' }, { letter: 'G', target: 'C:\\Mirror\\G' }]), 'session.json: workspace at its drive-letter path, subst drives listed for bootstrap.ps1');
47
+ check(text.includes('<SandboxFolder>C:\\Mirror\\G\\Projects\\work-a</SandboxFolder>') && text.includes('<SandboxFolder>C:\\Mirror\\F\\SteamLibrary\\steamapps\\common\\Game</SandboxFolder>'), '.wsb: workspace and mapping under C:\\Mirror');
48
+ check(JSON.stringify(f.readJson(f.generate(base).sessionPath).substDrives) === '[]', 'no other drive letters: no subst drives');
49
+ check(await rejects(() => f.generate({ ...run, config: { ...run.config, mappings: [{ host: game, guest: 'C:\\Mirror\\F\\x', readOnly: true }] } }), 'not a usable sandbox folder'), 'a mapping straight into C:\\Mirror is refused');
50
+ check(await rejects(() => f.generate({ ...run, config: { ...run.config, mappings: [{ host: game, guest: 'C:\\Windows\\x', readOnly: true }] } }), 'not a usable sandbox folder'), 'a reserved guest folder is refused by the host too (not only the schema)');
51
+ check(await rejects(() => f.generate({ ...run, config: { ...run.config, mappings: [{ host: game, guest: 'g:\\projects\\work-a\\game', readOnly: true }] } }), 'overlapping'), 'mappings overlapping on the same mirrored drive are refused');
52
+ t.section('exec / attach cwd and history for a mirrored workspace');
53
+ const ws = 'F:\\AgentProjects\\mw2-agent';
54
+ check(guestPathFor(ws + '\\crates\\engine', ws, ws) === ws + '\\crates\\engine', 'a host folder maps to the same guest folder');
55
+ const hostDot = f.at('mirror-host-claude');
56
+ const guestDot = f.at('mirror-guest-claude');
57
+ const project = projectFolderName(ws);
58
+ check(project === 'F--AgentProjects-mw2-agent', 'Claude project folder for F:\\AgentProjects\\mw2-agent is F--AgentProjects-mw2-agent on both sides');
59
+ write(path.join(hostDot, 'projects', project, 's1.jsonl'), '{"cwd":"F:\\\\AgentProjects\\\\mw2-agent","t":1}\n');
60
+ write(path.join(hostDot, 'history.jsonl'), '{"display":"hi","timestamp":1,"project":"F:\\\\AgentProjects\\\\mw2-agent"}\n');
61
+ const paths = { hostClaudeDir: hostDot, guestClaudeDir: guestDot, hostWorkspace: ws, guestWorkspace: ws };
62
+ syncHistoryIn(paths);
63
+ check(fs.readFileSync(path.join(guestDot, 'projects', project, 's1.jsonl'), 'utf8').includes('F:\\\\AgentProjects\\\\mw2-agent') && fs.readdirSync(path.join(guestDot, 'projects')).length === 1, 'history in: same folder name, paths unchanged');
64
+ write(path.join(guestDot, 'projects', project, 's2.jsonl'), '{"cwd":"F:\\\\AgentProjects\\\\mw2-agent","t":2}\n');
65
+ write(path.join(guestDot, 'projects', 'C--Mirror-F-AgentProjects-mw2-agent', 's3.jsonl'), '{"cwd":"C:\\\\Mirror\\\\F\\\\AgentProjects\\\\mw2-agent","f":"C:/Mirror/F/AgentProjects/mw2-agent/src/main.rs"}\n');
66
+ fs.appendFileSync(path.join(guestDot, 'history.jsonl'), '{"display":"real path","timestamp":2,"project":"C:\\\\Mirror\\\\F\\\\AgentProjects\\\\mw2-agent"}\n');
67
+ syncHistoryOut(paths);
68
+ const s3 = path.join(hostDot, 'projects', project, 's3.jsonl');
69
+ check(fs.existsSync(path.join(hostDot, 'projects', project, 's2.jsonl')) && fs.existsSync(s3) && !fs.existsSync(path.join(hostDot, 'projects', 'C--Mirror-F-AgentProjects-mw2-agent')), 'history out: transcripts under the real C:\\Mirror path come back into the PC project folder');
70
+ const s3Text = fs.existsSync(s3) ? fs.readFileSync(s3, 'utf8') : '';
71
+ check(s3Text.includes('"cwd":"F:\\\\AgentProjects\\\\mw2-agent"') && s3Text.includes('F:/AgentProjects/mw2-agent/src/main.rs') && !s3Text.includes('Mirror'), 'history out: C:\\Mirror\\F\\... paths are translated back to F:\\...');
72
+ const hist = fs.readFileSync(path.join(hostDot, 'history.jsonl'), 'utf8');
73
+ check(hist.includes('"project":"F:\\\\AgentProjects\\\\mw2-agent"}') && !hist.includes('Mirror'), 'prompt history out: real paths translated too');
74
+ };
@@ -0,0 +1,3 @@
1
+ import type { Fixtures } from './fixtures.js';
2
+ import type { Harness } from './harness.js';
3
+ export declare const securityTests: (t: Harness, f: Fixtures) => Promise<void>;