windows-development-sandbox 1.1.0 → 1.4.1

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 (222) hide show
  1. package/.sandbox-config.example.json +2 -1
  2. package/README.md +3 -2
  3. package/config.example.json +39 -5
  4. package/dist/cli/args.d.ts +3 -0
  5. package/dist/cli/args.js +3 -1
  6. package/dist/cli/boot-progress.d.ts +22 -0
  7. package/dist/cli/boot-progress.js +59 -0
  8. package/dist/cli/commands/accounts.js +2 -2
  9. package/dist/cli/commands/agent-commands.js +1 -1
  10. package/dist/cli/commands/context.js +2 -1
  11. package/dist/cli/commands/data/clean.js +6 -1
  12. package/dist/cli/commands/data/collect.js +3 -3
  13. package/dist/cli/commands/index.d.ts +1 -1
  14. package/dist/cli/commands/index.js +1 -1
  15. package/dist/cli/commands/launch.d.ts +5 -2
  16. package/dist/cli/commands/launch.js +113 -25
  17. package/dist/cli/commands/maintenance.d.ts +2 -0
  18. package/dist/cli/commands/maintenance.js +70 -4
  19. package/dist/cli/commands/project.d.ts +10 -6
  20. package/dist/cli/commands/project.js +42 -3
  21. package/dist/cli/commands/restart.js +17 -7
  22. package/dist/cli/commands/sessions.js +5 -4
  23. package/dist/cli/commands/user-config.js +1 -1
  24. package/dist/cli/index.js +6 -4
  25. package/dist/cli/manifest.js +6 -1
  26. package/dist/cli/menu/apps.d.ts +21 -0
  27. package/dist/cli/menu/apps.js +122 -0
  28. package/dist/cli/menu/command-form.d.ts +28 -0
  29. package/dist/cli/menu/command-form.js +219 -0
  30. package/dist/cli/menu/index.d.ts +26 -0
  31. package/dist/cli/menu/index.js +180 -0
  32. package/dist/cli/menu/settings/categories.d.ts +9 -0
  33. package/dist/cli/menu/settings/categories.js +28 -0
  34. package/dist/cli/menu/settings/config-file.d.ts +40 -0
  35. package/dist/cli/menu/settings/config-file.js +71 -0
  36. package/dist/cli/menu/settings/editors.d.ts +30 -0
  37. package/dist/cli/menu/settings/editors.js +305 -0
  38. package/dist/cli/menu/settings/index.d.ts +32 -0
  39. package/dist/cli/menu/settings/index.js +136 -0
  40. package/dist/cli/menu/settings/schema-node.d.ts +62 -0
  41. package/dist/cli/menu/settings/schema-node.js +104 -0
  42. package/dist/cli/{menu-multi.d.ts → menu/several.d.ts} +10 -2
  43. package/dist/cli/menu/several.js +102 -0
  44. package/dist/cli/progress.d.ts +15 -0
  45. package/dist/cli/progress.js +52 -0
  46. package/dist/cli/tui/app.d.ts +39 -0
  47. package/dist/cli/tui/app.js +52 -0
  48. package/dist/cli/tui/dialogs.d.ts +57 -0
  49. package/dist/cli/tui/dialogs.js +162 -0
  50. package/dist/cli/tui/frame.d.ts +18 -0
  51. package/dist/cli/tui/frame.js +27 -0
  52. package/dist/cli/tui/index.d.ts +7 -0
  53. package/dist/cli/tui/index.js +8 -0
  54. package/dist/cli/tui/list-screen.d.ts +101 -0
  55. package/dist/cli/tui/list-screen.js +299 -0
  56. package/dist/cli/tui/terminal.d.ts +37 -0
  57. package/dist/cli/tui/terminal.js +89 -0
  58. package/dist/cli/tui/text.d.ts +20 -0
  59. package/dist/cli/tui/text.js +105 -0
  60. package/dist/cli/tui/theme.d.ts +45 -0
  61. package/dist/cli/tui/theme.js +30 -0
  62. package/dist/core/config.js +10 -7
  63. package/dist/core/local-config.d.ts +31 -1
  64. package/dist/core/local-config.js +72 -7
  65. package/dist/core/project-policy.d.ts +50 -3
  66. package/dist/core/project-policy.js +90 -4
  67. package/dist/core/schema/apps.d.ts +40 -0
  68. package/dist/core/schema/apps.js +27 -0
  69. package/dist/core/schema/config.d.ts +69 -3
  70. package/dist/core/schema/config.js +18 -6
  71. package/dist/core/schema/profile.d.ts +5 -0
  72. package/dist/core/schema/profile.js +4 -2
  73. package/dist/core/schema/projects.d.ts +1 -0
  74. package/dist/core/schema/projects.js +1 -0
  75. package/dist/core/schema/sandbox.d.ts +29 -1
  76. package/dist/core/schema/sandbox.js +29 -1
  77. package/dist/core/schema/toolchains.d.ts +3 -0
  78. package/dist/core/schema/toolchains.js +6 -1
  79. package/dist/core/types.d.ts +24 -1
  80. package/dist/core/util.d.ts +2 -0
  81. package/dist/core/util.js +2 -0
  82. package/dist/core/workspace.d.ts +21 -4
  83. package/dist/core/workspace.js +99 -21
  84. package/dist/guest/apps/index.d.ts +10 -0
  85. package/dist/guest/apps/index.js +206 -0
  86. package/dist/guest/apps/resolvers.d.ts +8 -0
  87. package/dist/guest/apps/resolvers.js +90 -0
  88. package/dist/guest/audit-parse.d.ts +7 -0
  89. package/dist/guest/audit-parse.js +24 -0
  90. package/dist/guest/audit.d.ts +4 -0
  91. package/dist/guest/audit.js +47 -0
  92. package/dist/guest/daemon/relays.d.ts +1 -0
  93. package/dist/guest/daemon/relays.js +18 -0
  94. package/dist/guest/daemon.js +4 -0
  95. package/dist/guest/env.d.ts +7 -2
  96. package/dist/guest/env.js +17 -3
  97. package/dist/guest/helpers.d.ts +1 -1
  98. package/dist/guest/main.js +20 -3
  99. package/dist/guest/netproxy.d.ts +1 -0
  100. package/dist/guest/netproxy.js +100 -0
  101. package/dist/guest/ptyhost.js +14 -4
  102. package/dist/guest/setup/claude.d.ts +0 -4
  103. package/dist/guest/setup/claude.js +48 -2
  104. package/dist/guest/setup/network.d.ts +9 -0
  105. package/dist/guest/setup/network.js +97 -4
  106. package/dist/guest/setup/user.js +4 -0
  107. package/dist/guest/toolchains/index.js +29 -3
  108. package/dist/guest/toolchains/native/mesa.js +13 -3
  109. package/dist/guest/toolchains/plan.d.ts +3 -1
  110. package/dist/guest/toolchains/plan.js +53 -9
  111. package/dist/guest/toolchains/python.js +26 -2
  112. package/dist/guest/toolchains/vscode-profile.d.ts +30 -0
  113. package/dist/guest/toolchains/vscode-profile.js +69 -0
  114. package/dist/guest/toolchains/vscode.d.ts +17 -0
  115. package/dist/guest/toolchains/vscode.js +87 -1
  116. package/dist/host/attach.js +2 -1
  117. package/dist/host/audit/collect.d.ts +34 -0
  118. package/dist/host/audit/collect.js +126 -0
  119. package/dist/host/audit/log.d.ts +46 -0
  120. package/dist/host/audit/log.js +143 -0
  121. package/dist/host/channel.d.ts +2 -2
  122. package/dist/host/channel.js +3 -2
  123. package/dist/host/generate.d.ts +7 -1
  124. package/dist/host/generate.js +23 -6
  125. package/dist/host/join.d.ts +7 -0
  126. package/dist/host/join.js +23 -9
  127. package/dist/host/launch/mappings.d.ts +7 -2
  128. package/dist/host/launch/mappings.js +68 -18
  129. package/dist/host/launch/projects.d.ts +2 -0
  130. package/dist/host/launch/projects.js +11 -5
  131. package/dist/host/launch/stage.js +1 -1
  132. package/dist/host/motw.d.ts +17 -0
  133. package/dist/host/motw.js +74 -0
  134. package/dist/host/platform.js +3 -0
  135. package/dist/host/port-forward.d.ts +8 -0
  136. package/dist/host/port-forward.js +37 -0
  137. package/dist/host/projects.d.ts +25 -2
  138. package/dist/host/projects.js +54 -5
  139. package/dist/host/repo-guard.d.ts +10 -3
  140. package/dist/host/repo-guard.js +142 -14
  141. package/dist/host/stops.d.ts +67 -0
  142. package/dist/host/stops.js +126 -0
  143. package/dist/host/terminal.d.ts +8 -0
  144. package/dist/host/terminal.js +45 -1
  145. package/dist/host/workspace-view/index.d.ts +20 -0
  146. package/dist/host/workspace-view/index.js +53 -0
  147. package/dist/host/workspace-view/rules.d.ts +26 -0
  148. package/dist/host/workspace-view/rules.js +93 -0
  149. package/dist/host/workspace-view/view.d.ts +50 -0
  150. package/dist/host/workspace-view/view.js +281 -0
  151. package/dist/runtime/daemon.js +1387 -558
  152. package/dist/runtime/db.js +199 -14
  153. package/dist/runtime/main.js +3082 -631
  154. package/dist/runtime/netproxy.js +25948 -0
  155. package/dist/runtime/view.js +531 -148
  156. package/dist/selftest/audit.test.d.ts +2 -0
  157. package/dist/selftest/audit.test.js +57 -0
  158. package/dist/selftest/channel.test.js +45 -2
  159. package/dist/selftest/cli.test.js +47 -17
  160. package/dist/selftest/fixtures.d.ts +47 -1
  161. package/dist/selftest/fixtures.js +3 -1
  162. package/dist/selftest/generate.test.js +7 -2
  163. package/dist/selftest/index.js +6 -2
  164. package/dist/selftest/live-network.test.d.ts +2 -0
  165. package/dist/selftest/live-network.test.js +63 -0
  166. package/dist/selftest/live-terminal.test.d.ts +2 -0
  167. package/dist/selftest/live-terminal.test.js +169 -0
  168. package/dist/selftest/local-config.test.js +42 -4
  169. package/dist/selftest/mirror.test.js +36 -7
  170. package/dist/selftest/network.test.js +12 -0
  171. package/dist/selftest/projects.test.js +77 -8
  172. package/dist/selftest/repo-guard.test.js +59 -1
  173. package/dist/selftest/sync.test.js +50 -10
  174. package/dist/selftest/toolchains.test.js +30 -0
  175. package/dist/selftest/workspace-view.test.d.ts +2 -0
  176. package/dist/selftest/workspace-view.test.js +89 -0
  177. package/dist/shared/apps.d.ts +49 -0
  178. package/dist/shared/apps.js +114 -0
  179. package/dist/shared/console-input.d.ts +11 -0
  180. package/dist/shared/console-input.js +61 -0
  181. package/dist/shared/download.d.ts +3 -0
  182. package/dist/shared/download.js +25 -2
  183. package/dist/shared/guest-paths.d.ts +27 -0
  184. package/dist/shared/guest-paths.js +68 -0
  185. package/dist/shared/netpolicy.d.ts +27 -0
  186. package/dist/shared/netpolicy.js +51 -0
  187. package/dist/shared/projects.d.ts +27 -4
  188. package/dist/shared/projects.js +72 -13
  189. package/dist/shared/pty-client.d.ts +26 -4
  190. package/dist/shared/pty-client.js +204 -25
  191. package/dist/shared/rundir.d.ts +2 -0
  192. package/dist/shared/rundir.js +2 -0
  193. package/dist/shared/schema.d.ts +34 -0
  194. package/dist/shared/schema.js +14 -0
  195. package/dist/shared/system.d.ts +1 -0
  196. package/dist/shared/system.js +1 -0
  197. package/dist/sync/claude-mem.js +3 -2
  198. package/dist/sync/history.d.ts +6 -0
  199. package/dist/sync/history.js +26 -11
  200. package/dist/sync/watcher.js +78 -6
  201. package/package.json +2 -1
  202. package/sandbox/cargoshim.cs +56 -0
  203. package/sandbox/cpucap.cs +33 -0
  204. package/sandbox/cpucap.ps1 +4 -35
  205. package/sandbox/logon.cmd +6 -0
  206. package/sandbox/memlog.ps1 +34 -0
  207. package/sandbox/pagefile.cs +31 -0
  208. package/sandbox/pagefile.ps1 +21 -0
  209. package/sandbox/ptyhost.cs +162 -7
  210. package/schemas/config.schema.json +318 -3
  211. package/schemas/sandbox-config.schema.json +129 -14
  212. package/dist/cli/menu-multi.js +0 -82
  213. package/dist/cli/menu.d.ts +0 -14
  214. package/dist/cli/menu.js +0 -290
  215. package/dist/cli/settings/edit.d.ts +0 -13
  216. package/dist/cli/settings/edit.js +0 -19
  217. package/dist/cli/settings/index.d.ts +0 -8
  218. package/dist/cli/settings/index.js +0 -131
  219. package/dist/cli/settings/prompts.d.ts +0 -7
  220. package/dist/cli/settings/prompts.js +0 -48
  221. package/dist/cli/settings/schema.d.ts +0 -33
  222. package/dist/cli/settings/schema.js +0 -29
@@ -0,0 +1,169 @@
1
+ // Live: the terminal path as you use it. An outer ptyhost stands in for Windows Terminal (it hosts the console, like
2
+ // Windows Terminal's own pseudo console) and runs the real viewer/attach client (runTerminalClient), which is attached
3
+ // to an inner ptyhost running a stand-in agent. The agent turns SGR mouse tracking on and echoes every byte it reads.
4
+ // Checks: clicks and the wheel reach the agent, Backspace stays Backspace, a split escape sequence arrives whole,
5
+ // 500 printed lines scroll through as lines (no alternate screen, no scrollback clear), and a viewer that joins after
6
+ // a screen clear still gets mouse tracking.
7
+ import fs from 'node:fs';
8
+ import net from 'node:net';
9
+ import path from 'node:path';
10
+ import { spawn, spawnSync } from 'node:child_process';
11
+ import { pathToFileURL } from 'node:url';
12
+ import { encodeFrame, FrameType } from '../shared/pty-client.js';
13
+ import { CSC, openViewer, tail, WAIT } from './live.js';
14
+ /** The stand-in agent: VT input and SGR mouse tracking on, echoes what it reads ("got <1b>[<0;10;5M"). L prints 500 lines, K clears the screen, q quits. */
15
+ const AGENT_CS = String.raw `
16
+ using System;
17
+ using System.Runtime.InteropServices;
18
+ using System.Text;
19
+ static class MouseAgent
20
+ {
21
+ [DllImport("kernel32.dll")] static extern IntPtr GetStdHandle(int n);
22
+ [DllImport("kernel32.dll")] static extern bool GetConsoleMode(IntPtr h, out uint m);
23
+ [DllImport("kernel32.dll")] static extern bool SetConsoleMode(IntPtr h, uint m);
24
+ [DllImport("kernel32.dll")] static extern bool ReadFile(IntPtr h, byte[] b, int n, out int read, IntPtr o);
25
+ static void Say(string s) { Console.Out.Write(s); Console.Out.Flush(); }
26
+ static int Main()
27
+ {
28
+ IntPtr input = GetStdHandle(-10), output = GetStdHandle(-11);
29
+ uint mode;
30
+ GetConsoleMode(output, out mode); SetConsoleMode(output, mode | 4);
31
+ SetConsoleMode(input, 0x200);
32
+ Say("\x1b[?1000h\x1b[?1006hAGENT-READY\r\n");
33
+ var buffer = new byte[512];
34
+ int n;
35
+ while (ReadFile(input, buffer, buffer.Length, out n, IntPtr.Zero) && n > 0)
36
+ {
37
+ var text = new StringBuilder();
38
+ for (int i = 0; i < n; i++) text.Append(buffer[i] < 32 || buffer[i] > 126 ? "<" + buffer[i].ToString("x2") + ">" : ((char)buffer[i]).ToString());
39
+ string got = text.ToString();
40
+ Say("got " + got + "\r\n");
41
+ if (got == "L") { var lines = new StringBuilder(); for (int i = 1; i <= 500; i++) lines.Append("line " + i + "\r\n"); Say(lines.ToString() + "LINES-DONE\r\n"); }
42
+ if (got == "K") Say("\x1b[2J\x1b[HCLEARED\r\n");
43
+ if (got == "q") break;
44
+ }
45
+ // Exits with mouse tracking still on (like a crash): the client must turn it off.
46
+ return 0;
47
+ }
48
+ }
49
+ `;
50
+ const freePort = () => new Promise((resolve, reject) => {
51
+ const server = net.createServer();
52
+ server.on('error', reject);
53
+ server.listen(0, '127.0.0.1', () => { const port = server.address().port; server.close(() => resolve(port)); });
54
+ });
55
+ const compile = (source, exe) => {
56
+ const result = spawnSync(CSC, ['/nologo', '/optimize', '/target:exe', '/out:' + exe, source], { encoding: 'utf8', windowsHide: true });
57
+ return result.status === 0 ? '' : ((result.stdout ?? '') + (result.stderr ?? '')).trim().slice(0, 1500) || 'csc failed';
58
+ };
59
+ /** conpty.dll as a sandbox has it (C:\AgentData\tools\conpty), when this machine has one; else only Windows' own is tested. */
60
+ const conptyDll = () => {
61
+ const dll = path.join(process.env.AGENT_DATA_DIR ?? 'C:\\AgentData', 'tools', 'conpty', 'conpty.dll');
62
+ return fs.existsSync(dll) && fs.existsSync(path.join(path.dirname(dll), 'OpenConsole.exe')) ? dll : null;
63
+ };
64
+ export const liveTerminalSuite = {
65
+ name: 'live-terminal',
66
+ title: 'live: the viewer/attach client in a terminal (mouse clicks and wheel, keys, scrolling lines, late viewers)',
67
+ live: true,
68
+ timeoutMs: 300000,
69
+ run: async (t) => {
70
+ const ptyhost = t.at('ptyhost.exe');
71
+ const agent = t.at('mouse-agent.exe');
72
+ fs.writeFileSync(t.at('mouse-agent.cs'), AGENT_CS);
73
+ const errors = compile(path.join(t.packageRoot, 'sandbox', 'ptyhost.cs'), ptyhost) || compile(t.at('mouse-agent.cs'), agent);
74
+ t.check(!errors, 'ptyhost and the stand-in agent compile', errors);
75
+ if (errors)
76
+ return;
77
+ const client = t.at('client.mjs');
78
+ fs.writeFileSync(client, [
79
+ 'import { connect, runTerminalClient } from ' + JSON.stringify(pathToFileURL(path.join(t.packageRoot, 'dist', 'shared', 'pty-client.js')).href) + ';',
80
+ 'const socket = await connect({ addresses: ["127.0.0.1"], port: Number(process.argv[2]), token: "tok" });',
81
+ 'const result = await runTerminalClient(socket, "tok");',
82
+ 'console.log("CLIENT-DONE " + result.reason);',
83
+ ].join('\n'));
84
+ const dll = conptyDll();
85
+ const kinds = dll ? [dll, null] : [null];
86
+ if (!dll)
87
+ t.skip('conpty.dll not found on this machine: only Windows\' own pseudo console is tested');
88
+ for (const kind of kinds) {
89
+ const label = kind ? 'conpty.dll' : 'Windows\' pseudo console';
90
+ t.section('live terminal (' + label + ')');
91
+ const children = [];
92
+ const viewers = [];
93
+ const start = (port, command, log) => {
94
+ const env = { ...process.env, PTYHOST_BIND: 'loopback' };
95
+ delete env.PTYHOST_CONPTY;
96
+ if (kind)
97
+ env.PTYHOST_CONPTY = kind;
98
+ children.push(spawn(ptyhost, [String(port), 'tok', '100', '30', t.at(log), command], { env, stdio: 'ignore', windowsHide: true }));
99
+ };
100
+ try {
101
+ const innerPort = await freePort();
102
+ start(innerPort, '"' + agent + '"', 'inner-' + kinds.indexOf(kind) + '.log');
103
+ const outerPort = await freePort();
104
+ start(outerPort, '"' + process.execPath + '" "' + client + '" ' + innerPort, 'outer-' + kinds.indexOf(kind) + '.log');
105
+ // The terminal: what it shows is what the client wrote; what it types goes to the client's console.
106
+ const terminal = await openViewer({ addresses: ['127.0.0.1'], port: outerPort, token: 'tok' });
107
+ viewers.push(terminal);
108
+ const screen = () => terminal.output();
109
+ const send = (text) => { terminal.socket.write(encodeFrame(FrameType.Input, Buffer.from(text, 'latin1'))); };
110
+ const ready = await t.waitFor(() => screen().includes('AGENT-READY'), WAIT.screen);
111
+ t.check(Boolean(ready), 'the client shows the agent', 'screen: ' + tail(screen()));
112
+ if (!ready)
113
+ continue;
114
+ // The client switches its console to pass the terminal's input through as it is; until then clicks are lost.
115
+ const click = '\x1b[<0;10;5M\x1b[<0;10;5m';
116
+ const clicked = await t.waitFor(() => { if (!screen().includes('got <1b>[<0;10;5M'))
117
+ send(click); return screen().includes('got <1b>[<0;10;5M'); }, WAIT.screen, 1000);
118
+ t.check(Boolean(clicked), 'a mouse click in the terminal reaches the agent (SGR mouse report)', 'screen: ' + tail(screen(), 800));
119
+ const mark = screen().length;
120
+ const after = () => screen().slice(mark);
121
+ send('\x1b[<64;10;5M');
122
+ t.check(Boolean(await t.waitFor(() => after().includes('got <1b>[<64;10;5M'), WAIT.screen)), 'the mouse wheel reaches the agent while it tracks the mouse', 'screen: ' + tail(after(), 800));
123
+ send('\x7f');
124
+ t.check(Boolean(await t.waitFor(() => after().includes('got <7f>'), WAIT.screen)), 'Backspace reaches the agent as DEL (one character, not a word)', 'screen: ' + tail(after(), 800));
125
+ send('\x08');
126
+ t.check(Boolean(await t.waitFor(() => after().includes('got <08>'), WAIT.screen)), 'Ctrl+Backspace reaches the agent as ^H', 'screen: ' + tail(after(), 800));
127
+ send('\x1b[');
128
+ await new Promise((r) => setTimeout(r, 5));
129
+ send('C');
130
+ t.check(Boolean(await t.waitFor(() => after().includes('got <1b>[C'), WAIT.screen)), 'a Right arrow that reaches the client in two pieces arrives whole (no stray "C")', 'screen: ' + tail(after(), 800));
131
+ const before = screen().length;
132
+ send('L');
133
+ const done = await t.waitFor(() => screen().includes('LINES-DONE'), WAIT.screen);
134
+ const printed = screen().slice(before);
135
+ const seen = new Set([...printed.matchAll(/line (\d+)\b/g)].map((m) => Number(m[1])));
136
+ const missing = Array.from({ length: 500 }, (_, i) => i + 1).filter((n) => !seen.has(n)).map((n) => 'line ' + n);
137
+ t.check(Boolean(done) && missing.length === 0, 'all 500 printed lines reach the terminal as lines', 'missing: ' + missing.slice(0, 10).join(', ') + '\n' + tail(printed));
138
+ // (The client clears the tab once when it starts; from then on nothing may take the scrollback away.)
139
+ const session = screen().slice(screen().indexOf('AGENT-READY'));
140
+ for (const [sequence, what] of [['\x1b[?1049h', 'alternate screen'], ['\x1b[?47h', 'alternate screen (47)'], ['\x1b[?1047h', 'alternate screen (1047)'], ['\x1b[3J', 'clear scrollback']]) {
141
+ t.excludes(session, sequence, 'the terminal is never sent ' + what + ' (' + JSON.stringify(sequence) + ')');
142
+ }
143
+ if (kind) {
144
+ // A viewer that joins after the agent cleared its screen: the replay starts at the clear, the modes come first.
145
+ send('K');
146
+ await t.waitFor(() => screen().includes('CLEARED'), WAIT.screen);
147
+ const late = await openViewer({ addresses: ['127.0.0.1'], port: innerPort, token: 'tok' });
148
+ viewers.push(late);
149
+ await t.waitFor(() => late.output().includes('CLEARED'), WAIT.screen);
150
+ const head = late.output().slice(0, late.output().indexOf('CLEARED'));
151
+ t.check(head.includes('\x1b[?1000h') && head.includes('\x1b[?1006h'), 'a viewer joining after a screen clear gets the agent\'s mouse tracking before the replay', 'late viewer: ' + JSON.stringify(late.output().slice(0, 300)));
152
+ late.close();
153
+ }
154
+ send('q');
155
+ const ended = await t.waitFor(() => screen().includes('CLIENT-DONE'), WAIT.exit);
156
+ t.check(Boolean(ended), 'the client ends with the agent', 'screen: ' + tail(screen()));
157
+ if (kind)
158
+ t.includes(screen().slice(screen().lastIndexOf('got q')), '\x1b[?1000l', 'leaving the session turns mouse tracking off in the terminal again');
159
+ }
160
+ finally {
161
+ for (const v of viewers)
162
+ v.close();
163
+ for (const c of children)
164
+ if (c.exitCode === null)
165
+ c.kill();
166
+ }
167
+ }
168
+ },
169
+ };
@@ -4,6 +4,7 @@ import fs from 'node:fs';
4
4
  import os from 'node:os';
5
5
  import path from 'node:path';
6
6
  import { download, setChecksumRequired } from '../shared/download.js';
7
+ import { allowedAnswer, allowStorePath, saveAllowed } from '../core/local-config.js';
7
8
  export const localConfigSuite = {
8
9
  name: 'local-config',
9
10
  title: '.sandbox-config.json: approval, tighten-only rules, validation',
@@ -13,7 +14,7 @@ export const localConfigSuite = {
13
14
  harness: 'codex',
14
15
  sandbox: { memoryMb: 4096, networking: false, headless: true },
15
16
  toolchains: { node: { version: '18' }, rust: { enabled: false } },
16
- mappings: [{ host: './assets', guest: 'C:\\Assets', readOnly: true }, { host: '../shared', guest: 'C:\\Shared', readOnly: false }],
17
+ mappings: [{ host: './assets', guest: 'C:\\Assets', readOnly: true }, { host: './data', guest: 'C:\\Data', readOnly: false }],
17
18
  scripts: ['./setup.mjs'],
18
19
  };
19
20
  f.writeLocal(f.workB, localB);
@@ -30,7 +31,7 @@ export const localConfigSuite = {
30
31
  pythonStillOn: Boolean(approved.config.toolchains.python.enabled),
31
32
  nodeSource: approved.versions.node.source,
32
33
  }, { harness: 'codex', memoryMb: 4096, headless: true, rustEnabled: false, pythonStillOn: true, nodeSource: '.sandbox-config.json' }, 'approved local settings apply over the defaults (including toolchains and headless)');
33
- t.check(approved.config.mappings.some((m) => m.host === f.shared && !m.readOnly), 'relative mapping paths resolve against the folder of .sandbox-config.json', 'mappings: ' + JSON.stringify(approved.config.mappings));
34
+ t.check(approved.config.mappings.some((m) => m.host === path.join(f.workB, 'data') && !m.readOnly), 'relative mapping paths resolve against the folder of .sandbox-config.json', 'mappings: ' + JSON.stringify(approved.config.mappings));
34
35
  t.check(approved.scripts.includes(path.join(f.workB, 'setup.mjs')), 'relative script paths resolve against the folder of .sandbox-config.json', 'scripts: ' + JSON.stringify(approved.scripts));
35
36
  t.equal((await f.run({ cwd: f.workB })).harnessName, 'codex', 'an approval is remembered');
36
37
  t.equal((await f.run({ cwd: f.workB, noLocalConfig: true })).harnessName, 'claude', '--no-local-config ignores the file');
@@ -49,12 +50,39 @@ export const localConfigSuite = {
49
50
  await rejectedBecause({ claudeProfile: { hostDir: 'C:\\x' } }, 'claudeProfile.hostDir is not allowed', 'a project file cannot point at the Claude profile folder');
50
51
  await rejectedBecause({ toolchains: { node: { hostDir: 'x' } } }, 'toolchains.node.hostDir is not allowed', 'a project file cannot point at host tool folders');
51
52
  await rejectedBecause({ sandbox: { memoryMb: 12 } }, 'sandbox.memoryMb:', 'values are type-checked (memory too small)');
52
- await rejectedBecause({ mappings: [{ host: 'x', guest: 'C:\\Windows\\x', readOnly: true }] }, 'reserved or invalid guest path', 'reserved guest paths are rejected');
53
53
  await rejectedBecause({ provisioning: { git: { access: 'admin' } } }, 'provisioning.git.access:', 'unknown git access modes are rejected');
54
54
  await rejectedBecause('{\n // a comment\n "readOnly": tru\n}', 'line 3', 'broken JSON is reported with its line');
55
55
  await rejectedBecause({ _comment: 'old style' }, '_comment is not allowed', 'unknown keys are rejected');
56
56
  f.writeLocal(dirC, '// comments and trailing commas are fine\n{\n "readOnly": true, /* inline */\n}\n');
57
57
  t.equal((await f.run({ cwd: dirC, trustConfig: true })).config.readOnly, true, '.sandbox-config.json may contain comments and trailing commas');
58
+ t.section('folders outside the project (projects.allowOutsideMappings)');
59
+ const dirD = t.dir('work-d');
60
+ const withOutside = (setting) => ({ ...f.baseConfig, projects: { ...f.baseConfig.projects, allowOutsideMappings: setting } });
61
+ const outsideFile = { mappings: [{ host: '../shared', readOnly: true }] };
62
+ f.writeLocal(dirD, outsideFile);
63
+ const notAllowed = await t.thrownMessage(() => f.run({ cwd: dirD, trustConfig: true }, withOutside(false)));
64
+ t.check(notAllowed.includes('outside the project') && notAllowed.includes(f.shared) && notAllowed.includes('projects.allowOutsideMappings'), 'false: refused, naming the folder and the setting that allows it', notAllowed);
65
+ const allowedOutside = await f.run({ cwd: dirD, trustConfig: true }, withOutside(true));
66
+ t.check(allowedOutside.config.mappings.some((m) => m.host === f.shared) && (allowedOutside.outsideMappings ?? []).includes(f.shared), 'true: allowed, and marked as outside the project');
67
+ f.writeLocal(dirD, { ...outsideFile, readOnly: true });
68
+ t.includes(await t.thrownMessage(() => f.run({ cwd: dirD }, withOutside(true))), '(outside the project)', 'true: still listed, flagged, when you approve the file');
69
+ const asking = await t.thrownMessage(() => f.run({ cwd: dirD, trustConfig: true, ask: async () => true, interactive: false }));
70
+ t.check(asking.includes('projects.allowOutsideMappings') && asking.includes('interactive'), '"ask" without a terminal (even with --trust-config): refused, naming the setting and that an interactive launch asks', asking);
71
+ t.includes(await t.thrownMessage(() => f.run({ cwd: dirD, trustConfig: true, ask: async () => false, interactive: true })), 'You did not allow', '"ask": answering no stops the launch');
72
+ const questions = [];
73
+ f.writeLocal(dirD, { ...outsideFile, sandbox: { vGpu: true } });
74
+ const asked = await f.run({ cwd: dirD, trustConfig: true, interactive: true, ask: async (q) => { questions.push(q); return true; } });
75
+ t.check(questions.length === 1 && asked.config.sandbox.vGpu && asked.config.mappings.some((m) => m.host === f.shared), '"ask": the folder and a looser setting (vGPU) are asked together, once', JSON.stringify(questions));
76
+ t.check((await f.run({ cwd: dirD, trustConfig: true, interactive: false })).config.mappings.some((m) => m.host === f.shared), '"ask": the answer is remembered for this project file');
77
+ // What counts as outside is where the folder really is: environment variables are expanded and links followed.
78
+ f.writeLocal(dirD, { mappings: [{ host: '%APPDATA%', readOnly: false }] });
79
+ t.includes(await t.thrownMessage(() => f.run({ cwd: dirD, trustConfig: true }, withOutside(false))), 'outside the project', '%VAR% is expanded before deciding inside or outside');
80
+ fs.symlinkSync(f.shared, path.join(dirD, 'link'), 'junction');
81
+ f.writeLocal(dirD, { mappings: [{ host: './link', readOnly: true }] });
82
+ t.includes(await t.thrownMessage(() => f.run({ cwd: dirD, trustConfig: true }, withOutside(false))), 'outside the project', 'a link inside the project to a folder outside counts as outside');
83
+ fs.rmSync(path.join(dirD, 'link'));
84
+ f.writeLocal(dirD, { mappings: [{ host: './assets', readOnly: true }] });
85
+ t.check((await f.run({ cwd: dirD, trustConfig: true }, withOutside(false))).config.mappings.some((m) => m.host === path.join(dirD, 'assets')), 'a folder inside the project needs no permission');
58
86
  t.section('a project can only tighten protections');
59
87
  // Settings that would loosen a protection are listed before approval, then ignored.
60
88
  f.writeLocal(dirC, {
@@ -72,7 +100,9 @@ export const localConfigSuite = {
72
100
  provisioning: { ...f.baseConfig.provisioning, git: { ...f.baseConfig.provisioning.git, access: 'readonly' } },
73
101
  sandbox: { ...f.baseConfig.sandbox, vGpu: false, clipboard: false, networking: false, protectedClient: true },
74
102
  };
75
- const kept = (await f.run({ cwd: dirC, trustConfig: true }, strictUser)).config;
103
+ const keptRun = await f.run({ cwd: dirC, trustConfig: true }, strictUser);
104
+ const kept = keptRun.config;
105
+ t.check(keptRun.notes.some((n) => n.includes('sandbox.vGpu = true') && n.includes('your own settings')), 'what a project asked for and did not get is said at launch, with how to allow it', keptRun.notes.join('\n'));
76
106
  t.equal({
77
107
  readOnly: kept.readOnly,
78
108
  blockPrivateRanges: kept.network.blockPrivateRanges,
@@ -83,6 +113,14 @@ export const localConfigSuite = {
83
113
  protectedClient: kept.sandbox.protectedClient,
84
114
  copyCredentials: kept.claudeProfile.copyCredentials,
85
115
  }, { readOnly: true, blockPrivateRanges: true, gitAccess: 'readonly', vGpu: false, clipboard: false, networking: false, protectedClient: true, copyCredentials: false }, 'a project file can never loosen network, git, read-only, GPU, clipboard or login settings');
116
+ // You allowed one request for this project (asked once at an interactive launch): only that one is let through.
117
+ const store = allowStorePath(strictUser.storage.root);
118
+ saveAllowed(store, path.join(dirC, '.sandbox-config.json'), 'sandbox.vGpu', true, true);
119
+ saveAllowed(store, path.join(dirC, '.sandbox-config.json'), 'sandbox.clipboard', true, false);
120
+ const allowedRun = await f.run({ cwd: dirC, trustConfig: true }, strictUser);
121
+ t.equal({ vGpu: allowedRun.config.sandbox.vGpu, clipboard: allowedRun.config.sandbox.clipboard, networking: allowedRun.config.sandbox.networking }, { vGpu: true, clipboard: false, networking: false }, 'a request you allowed for this project is used; one you declined and the others are not');
122
+ t.check(allowedRun.notes.some((n) => n.includes('Allowed for this project: sandbox.vGpu')) && allowedRun.notes.some((n) => n.includes('sandbox.clipboard') && n.includes('You said no')), 'launch says what was allowed and what you declined', allowedRun.notes.join('\n'));
123
+ t.equal(allowedAnswer(store, path.join(f.workB, '.sandbox-config.json'), 'sandbox.vGpu', true), null, 'an answer is kept per project file');
86
124
  f.writeLocal(dirC, { readOnly: true, network: { blockPrivateRanges: true }, provisioning: { git: { access: 'none' } }, sandbox: { networking: false, clipboard: false } });
87
125
  const tightened = (await f.run({ cwd: dirC, trustConfig: true })).config;
88
126
  t.equal({ readOnly: tightened.readOnly, gitAccess: tightened.provisioning.git.access, networking: tightened.sandbox.networking, clipboard: tightened.sandbox.clipboard }, { readOnly: true, gitAccess: 'none', networking: false, clipboard: false }, 'a project file can still make things stricter');
@@ -4,7 +4,7 @@ import path from 'node:path';
4
4
  import { Config, Mapping } from '../core/schema.js';
5
5
  import { guestPathFor } from '../cli/commands/context.js';
6
6
  import { buildWsb } from '../host/launch/wsb.js';
7
- import { guestDrive, isValidGuestPath, planMirrorWorkspace, sandboxFolder, substDrives } from '../shared/guest-paths.js';
7
+ import { deriveGuest, guestDrive, isValidGuestPath, planMirrorWorkspace, sandboxFolder, substDrives } from '../shared/guest-paths.js';
8
8
  import { guestWorkspaceAliases, projectFolderName } from '../shared/paths.js';
9
9
  import { syncHistoryIn, syncHistoryOut } from '../sync/history.js';
10
10
  export const mirrorSuite = {
@@ -17,8 +17,7 @@ export const mirrorSuite = {
17
17
  const invalid = ['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'];
18
18
  t.equal(invalid.filter(isValidGuestPath), [], 'drive roots, reserved folders, C:\\Mirror, A:/B:, .., streams, odd characters and UNC paths are refused');
19
19
  const mappingAccepts = (guest) => Mapping.safeParse({ host: 'E:\\Games', guest, readOnly: true }).success;
20
- t.check(mappingAccepts('F:\\SteamLibrary\\Game') && mappingAccepts('C:\\F'), 'schema: mappings[].guest may be on another drive letter');
21
- t.check(!mappingAccepts('C:\\Mirror\\F\\x') && !mappingAccepts('F:\\'), 'schema: mappings[].guest may never be C:\\Mirror or a drive root');
20
+ t.check(mappingAccepts('F:\\SteamLibrary\\Game') && mappingAccepts('C:\\Windows\\x') && mappingAccepts(undefined), 'schema: any mappings[].guest is accepted, or none (the launch places it; it never fails the config)');
22
21
  const workspaceAccepts = (guestWorkspace) => Config.safeParse({ ...f.makeConfig(), guestWorkspace }).success;
23
22
  t.check(workspaceAccepts('mirror') && workspaceAccepts('F:\\AgentProjects\\x'), 'schema: guestWorkspace accepts "mirror" and D:-Z: paths');
24
23
  t.check(!workspaceAccepts('C:\\Users\\x'), 'schema: guestWorkspace refuses reserved paths');
@@ -64,10 +63,40 @@ export const mirrorSuite = {
64
63
  t.includes(text, '<SandboxFolder>C:\\Mirror\\G\\Projects\\work-a</SandboxFolder>', '.wsb: the workspace is under C:\\Mirror');
65
64
  t.includes(text, '<SandboxFolder>C:\\Mirror\\F\\SteamLibrary\\steamapps\\common\\Game</SandboxFolder>', '.wsb: the mapping is under C:\\Mirror');
66
65
  t.equal(f.readJson(f.generate(base).sessionPath).substDrives, [], 'no other drive letters: no subst drives');
67
- const withMapping = (guest) => ({ ...run, config: { ...run.config, mappings: [{ host: game, guest, readOnly: true }] } });
68
- await t.throws(() => f.generate(withMapping('C:\\Mirror\\F\\x')), 'not a usable sandbox folder', 'a mapping straight into C:\\Mirror is refused');
69
- await t.throws(() => f.generate(withMapping('C:\\Windows\\x')), 'not a usable sandbox folder', 'a reserved guest folder is refused by the host too (not only the schema)');
70
- await t.throws(() => f.generate(withMapping('g:\\projects\\work-a\\game')), 'overlapping', 'mappings overlapping on the same mirrored drive are refused');
66
+ t.section('where an extra folder goes: guest left out, or one the sandbox cannot use');
67
+ t.equal([
68
+ deriveGuest('F:\\P\\mw2\\data\\maps', 'F:\\P\\mw2', 'C:\\Workspace\\mw2'),
69
+ deriveGuest('C:\\Steam\\MOHAA', 'F:\\P\\mw2', 'C:\\Workspace\\mw2'),
70
+ deriveGuest('f:\\SteamLibrary\\steamapps\\common\\MW2\\', 'F:\\P\\mw2', 'C:\\Workspace\\mw2'),
71
+ ], ['C:\\Workspace\\mw2\\data\\maps', 'C:\\Steam\\MOHAA', 'F:\\SteamLibrary\\steamapps\\common\\MW2'], 'no guest: inside the project = the same place in it; outside = the same path as on the PC (C: as is, F: through C:\\Mirror\\F + subst)');
72
+ const other = t.dir('steam', 'Other');
73
+ const placed = (mappings) => {
74
+ const g = f.generate({ ...run, config: { ...run.config, mappings } });
75
+ return { guests: g.mappings.filter((m) => m.host === game || m.host === other).map((m) => m.guest), notes: g.mappingNotes, wsb: fs.readFileSync(g.wsbPath, 'utf8') };
76
+ };
77
+ const awkward = placed([
78
+ { host: game, guest: 'C:\\Windows\\foo', readOnly: true },
79
+ { host: game, guest: 'C:\\Users\\x', readOnly: true },
80
+ { host: game, guest: 'C:\\AgentData\\y', readOnly: true },
81
+ { host: game, guest: 'C:\\Mirror\\F\\x', readOnly: true },
82
+ { host: game, guest: 'D:\\Games', readOnly: true },
83
+ { host: game, guest: 'G:\\Projects\\work-a', readOnly: true },
84
+ { host: game, guest: 'c:/assets/', readOnly: true },
85
+ { host: other, guest: 'C:\\Assets', readOnly: true },
86
+ { host: game, guest: 'C:\\Assets', readOnly: true },
87
+ ]);
88
+ t.equal(awkward.guests, ['C:\\Mapped\\C\\Windows\\foo', 'C:\\Mapped\\C\\Users\\x', 'C:\\Mapped\\C\\AgentData\\y', 'C:\\Mapped\\C\\Mirror\\F\\x', 'D:\\Games', 'C:\\Mapped\\G\\Projects\\work-a', 'C:\\assets', 'C:\\Mapped\\C\\Assets'], 'reserved paths, C:\\Mirror and the workspace itself go to C:\\Mapped\\<drive>\\<path>; D:\\Games and a tidied c:/assets/ are used as they are; a repeat is mapped once; a second folder on the same path moves');
89
+ t.check(awkward.notes.some((n) => n.includes('C:\\Windows\\foo') && n.includes('keeps for itself') && n.includes('C:\\Mapped\\C\\Windows\\foo')), 'a note says where a reserved path went and why', awkward.notes.join('\n'));
90
+ t.check(awkward.notes.some((n) => n.startsWith(other) && n.includes('already used by') && n.includes(game)), 'a conflict note names both folders', awkward.notes.join('\n'));
91
+ t.check(!/<SandboxFolder>C:\\(Windows|Users|AgentData\\y|Mirror\\F\\x)/i.test(awkward.wsb), 'a reserved guest path never reaches the .wsb');
92
+ const derived = placed([{ host: game, readOnly: true }]);
93
+ t.check(derived.guests.length === 1 && (/^C:\\Mapped\\C\\Users\\/i.test(derived.guests[0]) || derived.guests[0].toLowerCase() === game.toLowerCase()), 'no guest, outside the project: the PC path, or C:\\Mapped\\... when that path is reserved (the temp folder is under C:\\Users)', JSON.stringify(derived));
94
+ const inside = t.dir('work-a', 'data');
95
+ const insideRun = await f.run();
96
+ const insideGen = f.generate({ ...insideRun, config: { ...insideRun.config, mappings: [{ host: inside, readOnly: true }] } });
97
+ t.check(!insideGen.mappings.some((m) => m.host === inside) && insideGen.mappingNotes.some((n) => n.includes('already in the sandbox at C:\\Workspace\\work-a\\data')), 'no guest, inside the project: already there, no second mapping', insideGen.mappingNotes.join('\n'));
98
+ const missingRequired = await t.thrownMessage(() => f.generate({ ...run, config: { ...run.config, mappings: [{ host: t.at('nope-1'), readOnly: true }, { host: t.at('nope-2'), guest: 'C:\\Windows', readOnly: true }] } }));
99
+ t.check(missingRequired.includes('2 mapped folders cannot be used') && missingRequired.includes('nope-1') && missingRequired.includes('nope-2') && missingRequired.includes('"optional": true'), 'folders that are not there: one message listing each, with the fix', missingRequired);
71
100
  t.section('exec, attach and history for a mirrored workspace');
72
101
  const workspace = 'F:\\AgentProjects\\mw2-agent';
73
102
  t.equal(guestPathFor(workspace + '\\crates\\engine', workspace, workspace), workspace + '\\crates\\engine', 'a host folder maps to the same guest folder');
@@ -1,5 +1,6 @@
1
1
  import { isSandboxAddress } from '../host/attach.js';
2
2
  import { buildFirewallCommands, cidrToRange, ipv4DnsServers, parseDefaultGateways, parseDnsServers, subtractIps } from '../shared/netrules.js';
3
+ import { expandAllowList, hostAllowed, withBlockedHosts } from '../shared/netpolicy.js';
3
4
  export const networkSuite = {
4
5
  name: 'network',
5
6
  title: 'firewall maths, gateway/DNS parsing, attach address checks',
@@ -17,12 +18,23 @@ export const networkSuite = {
17
18
  t.section('attach only reaches into the sandbox');
18
19
  const interfaces = {
19
20
  'vEthernet (Default Switch)': [{ address: '172.20.0.1', netmask: '255.255.240.0', family: 'IPv4', internal: false, mac: '', cidr: null }],
21
+ 'vEthernet (WSL (Hyper-V firewall))': [{ address: '172.30.0.1', netmask: '255.255.240.0', family: 'IPv4', internal: false, mac: '', cidr: null }],
20
22
  Ethernet: [{ address: '192.168.1.10', netmask: '255.255.255.0', family: 'IPv4', internal: false, mac: '', cidr: null }],
21
23
  };
22
24
  t.check(isSandboxAddress('172.20.5.9', interfaces), 'an address in the Hyper-V subnet is the sandbox');
23
25
  t.check(!isSandboxAddress('192.168.1.20', interfaces), 'a LAN address is not');
26
+ t.check(!isSandboxAddress('172.30.4.4', interfaces), 'an address on the WSL Hyper-V network is not (keystrokes never go to a WSL VM)');
24
27
  t.check(!isSandboxAddress('127.0.0.1', interfaces), 'loopback is not');
25
28
  t.check(!isSandboxAddress('8.8.8.8', interfaces), 'an internet address is not');
26
29
  t.check(!isSandboxAddress('172.20.0.1', interfaces), 'this PC\'s own address on that subnet is not');
30
+ t.section('network settings');
31
+ const allow = expandAllowList(['@developer', 'Example.COM', '*.corp.example', 'not a host!']);
32
+ t.check(allow.includes('registry.npmjs.org') && allow.includes('api.anthropic.com') && allow.includes('example.com') && !allow.includes('not a host!'), 'allowOnly: @developer expands to registries, GitHub and AI providers; bad entries are dropped');
33
+ t.equal(['a.corp.example', 'corp.example', 'example.com', 'www.example.com', 'evil-example.com'].map((h) => hostAllowed(h, allow)), [true, false, true, false, false], 'allowOnly: *.x covers subdomains only, a name covers only itself');
34
+ const hosts = '127.0.0.1 localhost\r\n# mine\r\n';
35
+ const once = withBlockedHosts(hosts, ['Telemetry.Example.com', '*.bad']);
36
+ t.check(once.includes('0.0.0.0 telemetry.example.com') && once.includes('# mine') && !once.includes('*.bad'), 'blockHosts: names go to 0.0.0.0, your own lines stay, wildcards are skipped (a hosts file cannot do them)');
37
+ t.equal(withBlockedHosts(once, ['telemetry.example.com']), once, 'blockHosts: applying again changes nothing');
38
+ t.check(!withBlockedHosts(once, []).includes('telemetry'), 'blockHosts: an empty list removes the section');
27
39
  },
28
40
  };
@@ -4,21 +4,22 @@
4
4
  import fs from 'node:fs';
5
5
  import path from 'node:path';
6
6
  import { LocalOverrides } from '../core/schema.js';
7
- import { checkJoin, protectionsOf } from '../core/project-policy.js';
7
+ import { checkJoin, describeMissing, missingAtBoot, missingTip, nothingMissing, protectionsOf } from '../core/project-policy.js';
8
+ import { allowStorePath, saveAllowed } from '../core/local-config.js';
8
9
  import { sessionsFirstIn } from '../host/channel.js';
9
10
  import { buildWsb } from '../host/launch/wsb.js';
10
11
  import { projectSpec, joinRunning } from '../host/join.js';
11
- import { allProjectsToMap, forgetProject, NeedsRestart, projectHere, projectMapper, readKnown, readRegistry, rememberProject, startRegistry } from '../host/projects.js';
12
+ import { allProjectsToMap, forgetProject, NeedsRestart, parseWsbBoot, projectHere, projectMapper, readKnown, readRegistry, rememberProject, startRegistry } from '../host/projects.js';
12
13
  import { checkRepo, saveRepoSnapshot } from '../host/repo-guard.js';
13
14
  import { findWsb, parseCommands, parseIp, parseList, parseVersion } from '../host/wsb.js';
14
15
  import { baseEnvFor } from '../guest/project-env.js';
15
- import { guestDirFor, hostDirFor, hubName, insideProjects, isBelow, isWithin, planProjectMappings, projectForGuestDir, projectForHostDir, PROJECTS_GUEST } from '../shared/projects.js';
16
+ import { guestDirFor, hostDirFor, hubName, insideProjects, isBelow, isWithin, planProjectMappings, projectForGuestDir, projectForHostDir, PROJECTS_GUEST, rootGuestDir } from '../shared/projects.js';
16
17
  import { GuestRequest, ProjectSpec } from '../shared/schema.js';
17
18
  import { projectFolderName } from '../shared/paths.js';
18
19
  import { syncHistoryOut } from '../sync/history.js';
19
20
  import { validateOptions, validateProjectsArgs } from '../cli/validate.js';
20
21
  import { parseCli, parseSessionWant } from '../cli/args.js';
21
- import { candidateFolders, severalAgentsArgv } from '../cli/menu-multi.js';
22
+ import { candidateFolders, severalAgentsArgv } from '../cli/menu/index.js';
22
23
  import { generateSandbox } from '../host/generate.js';
23
24
  import { askedWhen } from '../cli/manifest.js';
24
25
  import { describeSessions } from '../cli/commands/restart.js';
@@ -57,7 +58,11 @@ export const projectsSuite = {
57
58
  t.check(isBelow('C:\\a', 'C:\\a\\b') && !isBelow('C:\\a', 'C:\\a'), 'isBelow is strict');
58
59
  const plan = planProjectMappings(['F:\\Code', 'E:\\Work\\Code', 'C:\\Users\\me\\src'], ['D:\\x\\api', 'D:\\y\\api'], false);
59
60
  t.equal(plan.roots.map((r) => r.guest), ['C:\\Projects\\Code', 'C:\\Projects\\Code-2', 'C:\\Projects\\src'], 'roots get unique C:\\Projects\\<name> folders');
60
- t.equal(plan.projects.map((p) => p.guest), ['C:\\Projects\\api', 'C:\\Projects\\api-2'], 'projects get unique C:\\Projects\\<name> folders');
61
+ t.equal(plan.projects.map((p) => p.guest), ['C:\\Workspace\\api', 'C:\\Workspace\\api-2'], 'projects mapped on their own get unique C:\\Workspace\\<name> folders (where they are as the launch folder)');
62
+ t.equal(planProjectMappings([], ['D:\\x\\app'], false, ['C:\\Workspace\\app']).projects[0].guest, 'C:\\Workspace\\app-2', 'a project mapped on its own never lands on the launch folder\'s place');
63
+ t.equal(planProjectMappings(['F:\\Code'], ['D:\\x\\api'], true).projects[0].guest, 'D:\\x\\api', 'with "mirror" a project mapped on its own appears at its PC path, as it would as the launch folder');
64
+ t.equal(rootGuestDir(['F:\\Code'], false, 'F:\\Code\\web\\src'), 'C:\\Projects\\Code\\web\\src', 'a folder inside a root is placed under the root\'s guest folder');
65
+ t.equal(rootGuestDir(['F:\\Code'], false, 'F:\\Other'), null, 'a folder in no root has no root place');
61
66
  const mirror = planProjectMappings(['F:\\Code', 'C:\\Users\\me\\src'], [], true);
62
67
  t.equal(mirror.roots.map((r) => r.guest), ['F:\\Code', 'C:\\Projects\\src'], 'with "mirror" a root appears at its PC path when the sandbox can show it there (C:\\Users\\... cannot)');
63
68
  t.section('projects: tighten-only protections when joining a running sandbox');
@@ -159,7 +164,23 @@ export const projectsSuite = {
159
164
  t.check(gen.projects.notes.some((n) => n.includes('no-such-root')), 'a missing root is skipped with a note', JSON.stringify(gen.projects.notes));
160
165
  t.check(gen.projects.projects.some((p) => p.host === outside), 'projects.all maps the known projects too');
161
166
  t.includes(wsb, '<HostFolder>' + root + '</HostFolder><SandboxFolder>C:\\Projects\\proj-root</SandboxFolder><ReadOnly>false</ReadOnly>', 'roots are mapped read/write in the .wsb');
162
- t.equal({ projectsDir: session.projectsDir, roots: session.roots, projectId: session.projectId }, { projectsDir: PROJECTS_GUEST, roots: ['C:\\Projects\\proj-root'], projectId: 'work-a' }, 'session.json tells the guest where other projects can be');
167
+ t.equal({ projectsDir: session.projectsDir, roots: session.roots, projectId: session.projectId }, { projectsDir: PROJECTS_GUEST, roots: ['C:\\Projects\\proj-root', 'C:\\Workspace\\proj-outside'], projectId: 'work-a' }, 'session.json tells the guest where other projects can be');
168
+ // The same project is at the same place whether it started the sandbox or joined one another project started.
169
+ const fromRoot = f.generate(await f.run({ cwd: inRoot }, rootConfig));
170
+ const joinedAt = guestDirFor(gen.projects.roots[0], inRoot);
171
+ t.equal([f.readJson(fromRoot.sessionPath).workspace, joinedAt], ['C:\\Projects\\proj-root\\web', 'C:\\Projects\\proj-root\\web'], 'a project in a root: the same guest folder as the launch folder and when another project started the sandbox');
172
+ t.check(!fromRoot.mappings.some((m) => m.host.toLowerCase() === inRoot.toLowerCase()), 'a launch folder inside a root is shown by the root (no second mapping of it)');
173
+ t.includes(fs.readFileSync(path.join(fromRoot.launchPath, 'env.cmd'), 'utf8'), 'AGENT_WORKSPACE=C:\\Projects\\proj-root\\web', 'env.cmd: AGENT_WORKSPACE follows');
174
+ const asLaunch = f.readJson(f.generate(await f.run({ cwd: outside })).sessionPath).workspace;
175
+ t.equal([asLaunch, gen.projects.projects.find((p) => p.host === outside)?.guest], ['C:\\Workspace\\proj-outside', 'C:\\Workspace\\proj-outside'], 'a project outside the roots: the same guest folder as the launch folder and when mapped at boot by another');
176
+ const mirrorRoot = structuredClone(rootConfig);
177
+ mirrorRoot.guestWorkspace = 'mirror';
178
+ t.equal(f.readJson(f.generate(await f.run({ cwd: inRoot }, mirrorRoot)).sessionPath).workspace, rootGuestDir([root], true, inRoot), 'with "mirror" a launch folder inside a root is where the root shows it (no overlapping mappings)');
179
+ const filtered = structuredClone(rootConfig);
180
+ filtered.workspaceFilter = { ...filtered.workspaceFilter, enabled: true };
181
+ const filteredRun = await f.run({ cwd: inRoot }, filtered);
182
+ t.equal(filteredRun.config.guestWorkspace, 'C:\\Workspace\\web', 'workspaceFilter: a launch folder inside a root keeps its own (filtered) mapping');
183
+ t.check(filteredRun.notes.some((n) => n.includes('C:\\Projects\\proj-root\\web')), 'and a note says where it is when another project starts the sandbox', JSON.stringify(filteredRun.notes));
163
184
  const badRoot = structuredClone(f.baseConfig);
164
185
  badRoot.projects = { ...badRoot.projects, roots: [f.baseConfig.storage.root] };
165
186
  await t.throws(async () => f.generate(await f.run({}, badRoot)), 'projects.roots', 'a root that would expose the storage folder is refused');
@@ -233,9 +254,57 @@ export const projectsSuite = {
233
254
  const multiGen = generateSandbox({ packageRoot: t.packageRoot, run: await f.run(), harnessArgs: [], probe: f.probe, extraProjects: [f.workB, path.join(f.workA, 'sub'), f.workB], autoSession: false });
234
255
  const multiSession = f.readJson(multiGen.sessionPath);
235
256
  const multiWsb = fs.readFileSync(multiGen.wsbPath, 'utf8');
236
- t.equal(multiGen.projects.projects.map((p) => p.guest), ['C:\\Projects\\work-b'], 'a multi-project launch maps every other project once (folders inside the launch folder and duplicates need nothing)');
237
- t.includes(multiWsb, '<HostFolder>' + f.workB + '</HostFolder><SandboxFolder>C:\\Projects\\work-b</SandboxFolder>', 'a multi-project launch maps the other project in the .wsb');
257
+ t.equal(multiGen.projects.projects.map((p) => p.guest), ['C:\\Workspace\\work-b'], 'a multi-project launch maps every other project once (folders inside the launch folder and duplicates need nothing)');
258
+ t.includes(multiWsb, '<HostFolder>' + f.workB + '</HostFolder><SandboxFolder>C:\\Workspace\\work-b</SandboxFolder>', 'a multi-project launch maps the other project in the .wsb');
238
259
  t.equal({ autoSession: multiSession.autoSession, workspaceHost: multiSession.workspaceHost }, { autoSession: false, workspaceHost: f.workA }, 'the boot provisions the launch folder only; the host then opens every session (each project set up on demand, in parallel)');
260
+ t.section('projects: what a joining project wants at boot (vGPU, memory, its mappings)');
261
+ // A games project: its approved file asks for vGPU (you allowed it for this project) and maps a folder of its own.
262
+ const games = t.dir('proj-games');
263
+ t.dir('proj-games', 'assets');
264
+ f.writeLocal(games, { sandbox: { vGpu: true }, mappings: [{ host: 'assets', guest: 'F:\\GameAssets', readOnly: true }] });
265
+ saveAllowed(allowStorePath(f.baseConfig.storage.root), path.join(games, '.sandbox-config.json'), 'sandbox.vGpu', true, true);
266
+ const gamesRun = await f.run({ cwd: games, trustConfig: true });
267
+ t.equal(gamesRun.config.sandbox.vGpu, true, 'the vGPU you allowed for the project is in its effective config');
268
+ const launchedRun = await f.run();
269
+ const runningBoot = { memoryMb: 8192, mappings: [{ host: f.workA, guest: 'C:\\Workspace\\work-a', readOnly: false }] };
270
+ const wanted = missingAtBoot(protectionsOf(launchedRun.config), runningBoot, gamesRun.config, fs.existsSync);
271
+ t.equal({ settings: wanted.settings, memoryMb: wanted.memoryMb, mappings: wanted.mappings.map((m) => m.guest) }, { settings: ['sandbox.vGpu'], memoryMb: null, mappings: ['F:\\GameAssets'] }, 'missingAtBoot: vGPU and the project\'s folder are missing from a sandbox started without them (the missing optional mapping is no wish)');
272
+ const mappedMirror = missingAtBoot(protectionsOf(gamesRun.config), { memoryMb: 8192, mappings: [{ host: path.join(games, 'assets'), guest: 'C:\\Mirror\\F\\GameAssets', readOnly: true }] }, gamesRun.config, fs.existsSync);
273
+ t.check(nothingMissing(mappedMirror), 'missingAtBoot: nothing missing when the sandbox has vGPU and the folder (F:\\x is C:\\Mirror\\F\\x in the .wsb)', JSON.stringify(mappedMirror));
274
+ const bigger = structuredClone(launchedRun.config);
275
+ bigger.sandbox.memoryMb = 16384;
276
+ t.equal(missingAtBoot(protectionsOf(launchedRun.config), runningBoot, bigger).memoryMb, 16384, 'missingAtBoot: a larger fixed memory size is missing too');
277
+ const twoFolders = { settings: ['sandbox.vGpu'], memoryMb: null, mappings: [{ host: 'C:\\Steam\\common', guest: 'C:\\Steam', readOnly: true }, { host: 'F:\\SteamLibrary', guest: 'F:\\SteamLibrary', readOnly: true }] };
278
+ t.equal(describeMissing('mw2-agent', twoFolders), 'mw2-agent wants vGPU and 2 folders (C:\\Steam\\common, F:\\SteamLibrary), but this sandbox started without them.', 'the message names each missing item');
279
+ t.equal(missingTip(twoFolders), 'Set sandbox.vGpu (or the mappings) in your own settings to have them in every sandbox.', 'the tip names the settings');
280
+ t.equal(describeMissing('api', { settings: ['sandbox.vGpu'], memoryMb: null, mappings: [] }), 'api wants vGPU, but this sandbox started without it.', 'one item reads naturally');
281
+ t.equal(parseWsbBoot(buildWsb(f.baseConfig, 6144, [{ host: 'C:\\a&b', guest: 'F:\\x', readOnly: true }])), { memoryMb: 6144, mappings: [{ host: 'C:\\a&b', guest: 'C:\\Mirror\\F\\x', readOnly: true }], widened: {} }, 'a sandbox started by an older version: memory and mapped folders are read back from its .wsb');
282
+ const widenedJoin = checkJoin({ ...base, 'sandbox.vGpu': true }, base, ['sandbox.vGpu']);
283
+ t.equal({ stricter: widenedJoin.stricter, shared: widenedJoin.shared }, { stricter: [], shared: ['sandbox.vGpu'] }, 'vGPU turned on at boot for another project you allowed it for: a project without it shares it instead of being refused');
284
+ t.check(checkJoin({ ...base, 'sandbox.vGpu': true }, base).stricter.some((s) => s.startsWith('sandbox.vGpu')), '... without that record a project without vGPU is still refused (it needs it off)');
285
+ // joinRunning asks before anything is registered: restart (NeedsRestart with what is missing), or continue without.
286
+ const storage2 = t.dir('proj-storage2');
287
+ const data2 = t.dir('proj-storage2', 'data', 'testdata');
288
+ startRegistry(storage2, { launchId: '1700000000001-abcdef', protections: protectionsOf(launchedRun.config), primary: { host: f.workA, guest: 'C:\\Workspace\\work-a' }, roots: [{ host: path.dirname(games), guest: 'C:\\Projects\\temp' }], boot: [], atBoot: { ...runningBoot, widened: {} } });
289
+ const joinGames = (choice) => joinRunning({ storageRoot: storage2, dataPath: data2, packageRoot: f.install, hostDir: games, wsbShare: false, resolve: resolveFor(gamesRun), decide: async () => choice });
290
+ const restartFor = await joinGames('restart').then(() => null, (e) => e);
291
+ t.check(restartFor instanceof NeedsRestart && restartFor.missing?.settings.includes('sandbox.vGpu') === true && restartFor.missing.mappings.length === 1, 'choosing "Restart with them" hands the restart offer what is missing');
292
+ await t.throws(() => joinGames('cancel'), 'Cancelled', '"Cancel" stops with a short line');
293
+ t.check(!readRegistry(storage2)?.projects.some((p) => p.host === games), 'neither registers the project');
294
+ const continued = await joinGames('continue');
295
+ t.equal(continued.missing?.settings, ['sandbox.vGpu'], '"Continue anyway" joins without them and says what is missing');
296
+ // The restart that adds it (and any launch with it mapped at boot) boots with the union.
297
+ const unionGen = generateSandbox({ packageRoot: t.packageRoot, run: launchedRun, harnessArgs: [], probe: f.probe, extraProjects: [games], autoSession: false, bootWants: { projects: [{ name: 'proj-games', config: gamesRun.config }], keepOff: [] } });
298
+ const unionWsb = fs.readFileSync(unionGen.wsbPath, 'utf8');
299
+ t.includes(unionWsb, '<vGPU>Enable</vGPU>', 'the restart plan: vGPU on because the added project asks for it (and you allowed it)');
300
+ t.includes(unionWsb, '<HostFolder>' + path.join(games, 'assets') + '</HostFolder><SandboxFolder>C:\\Mirror\\F\\GameAssets</SandboxFolder><ReadOnly>true</ReadOnly>', 'the restart plan: the added project\'s mapping is mapped');
301
+ t.includes(unionWsb, '<HostFolder>' + games + '</HostFolder><SandboxFolder>C:\\Workspace\\proj-games</SandboxFolder>', 'the restart plan: the added project itself is mapped (where it is as the launch folder)');
302
+ t.equal(unionGen.widened, { 'sandbox.vGpu': 'proj-games' }, 'the registry learns which boot setting was turned on for which project');
303
+ t.check(unionGen.projects.notes.some((n) => n.startsWith('vGPU is on in this sandbox')), 'the launch says vGPU is on for every project and why', JSON.stringify(unionGen.projects.notes));
304
+ t.check(nothingMissing(missingAtBoot(protectionsOf(unionGen.bootConfig), { memoryMb: unionGen.memoryMb, mappings: unionGen.mappings }, gamesRun.config, fs.existsSync)), 'after that restart the project joins with nothing missing');
305
+ const keptOff = generateSandbox({ packageRoot: t.packageRoot, run: launchedRun, harnessArgs: [], probe: f.probe, extraProjects: [games], autoSession: false, bootWants: { projects: [{ name: 'proj-games', config: gamesRun.config }], keepOff: ['sandbox.vGpu'] } });
306
+ t.includes(fs.readFileSync(keptOff.wsbPath, 'utf8'), '<vGPU>Disable</vGPU>', 'a device the launch folder\'s own file switched off stays off');
307
+ t.includes(fs.readFileSync(f.generate(launchedRun).wsbPath, 'utf8'), '<vGPU>Disable</vGPU>', 'without other projects nothing changes');
239
308
  t.section('projects: wsb (Windows Sandbox command line) adapter');
240
309
  t.equal([parseVersion('Windows Sandbox CLI version 0.4.1.0\n'), parseVersion('nothing')], ['0.4.1.0', null], 'wsb --version');
241
310
  const help = 'Description:\n Windows Sandbox command line\n\nUsage:\n wsb [command] [options]\n\nOptions:\n --version Show version information\n\nCommands:\n start Starts a sandbox\n list Lists sandboxes\n exec Runs a command\n share Shares a folder\n stop Stops a sandbox\n connect Connects\n ip Shows the IP\n';
@@ -3,10 +3,11 @@
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
5
5
  import { guardRepo, snapshotRepo } from '../host/repo-guard.js';
6
+ import { isMarked, markFromSandbox, watchAndMark } from '../host/motw.js';
6
7
  export const repoGuardSuite = {
7
8
  name: 'repo-guard',
8
9
  title: 'repository guard (hooks, git config, planted commands, replaced .git)',
9
- run: (t) => {
10
+ run: async (t) => {
10
11
  const readConfig = (repo) => fs.readFileSync(path.join(repo, '.git', 'config'), 'utf8');
11
12
  const disabled = (keyPattern) => new RegExp('# \\[agent-sandbox\\] disabled.*' + keyPattern);
12
13
  t.section('hooks and git config');
@@ -59,5 +60,62 @@ export const repoGuardSuite = {
59
60
  const before4 = snapshotRepo(repo4);
60
61
  fs.appendFileSync(path.join(repo4, '.git', 'config'), '#'.repeat(5 * 1024 * 1024));
61
62
  t.check(guardRepo(repo4, before4).some((x) => x.kind === 'git' && x.detail.includes('over 4 MB')), 'a git config too big to check is reported instead of passing');
63
+ t.section('more git config that runs programs');
64
+ const repo5 = t.at('repo5');
65
+ t.write(path.join(repo5, '.git', 'config'), '[alias]\n\tst = status\n');
66
+ const before5 = snapshotRepo(repo5);
67
+ fs.appendFileSync(path.join(repo5, '.git', 'config'), '[alias]\n\tlg = !calc.exe\n\tco = checkout\n[pager]\n\tlog = calc.exe\n[diff]\n\texternal = calc.exe\n');
68
+ guardRepo(repo5, before5);
69
+ const config5 = readConfig(repo5);
70
+ t.matches(config5, disabled('lg = !calc'), 'git config: an alias that runs a program (!) is disabled');
71
+ t.matches(config5, /^\tco = checkout$/m, 'git config: a plain alias stays');
72
+ t.matches(config5, disabled('log = calc'), 'git config: [pager] entries are disabled');
73
+ t.matches(config5, disabled('external = calc'), 'git config: diff.external is disabled');
74
+ t.section('submodules and nested repositories');
75
+ const repo6 = t.at('repo6');
76
+ t.write(path.join(repo6, '.git', 'config'), '[core]\n');
77
+ t.write(path.join(repo6, '.git', 'modules', 'lib', 'config'), '[core]\n');
78
+ t.write(path.join(repo6, '.git', 'modules', 'lib', 'HEAD'), 'ref: refs/heads/main\n');
79
+ t.write(path.join(repo6, 'tools', 'inner', '.git', 'config'), '[core]\n');
80
+ const before6 = snapshotRepo(repo6);
81
+ t.write(path.join(repo6, '.git', 'modules', 'lib', 'hooks', 'post-checkout'), 'calc.exe');
82
+ fs.appendFileSync(path.join(repo6, 'tools', 'inner', '.git', 'config'), '\tfsmonitor = calc.exe\n');
83
+ t.write(path.join(repo6, 'other', '.git', 'hooks', 'pre-commit'), 'calc.exe');
84
+ const findings6 = guardRepo(repo6, before6);
85
+ t.check(!fs.existsSync(path.join(repo6, '.git', 'modules', 'lib', 'hooks', 'post-checkout')), 'a hook planted in a submodule is quarantined');
86
+ t.matches(fs.readFileSync(path.join(repo6, 'tools', 'inner', '.git', 'config'), 'utf8'), disabled('fsmonitor'), 'git config of a nested repository is checked too');
87
+ t.check(!fs.existsSync(path.join(repo6, 'other', '.git', 'hooks', 'pre-commit')), 'a repository created inside the sandbox has no trusted hooks');
88
+ t.check(findings6.some((f) => f.detail.startsWith('tools/inner/.git: ')), 'findings say which repository they are in');
89
+ const legacy = { ...before6, repos: undefined, agent: undefined };
90
+ t.write(path.join(repo6, 'third', '.git', 'hooks', 'pre-commit'), 'yours');
91
+ guardRepo(repo6, legacy);
92
+ t.check(fs.existsSync(path.join(repo6, 'third', '.git', 'hooks', 'pre-commit')), 'a snapshot from an older version (no baseline) never moves your files');
93
+ t.section('agent and editor files');
94
+ const repo7 = t.at('repo7');
95
+ t.write(path.join(repo7, '.claude', 'settings.local.json'), '{ "permissions": { "allow": ["Bash(npm test)"] } }');
96
+ t.write(path.join(repo7, '.vscode', 'settings.json'), '{\n // comment\n "editor.tabSize": 2\n}');
97
+ const before7 = snapshotRepo(repo7);
98
+ t.write(path.join(repo7, '.claude', 'settings.local.json'), '{ "permissions": { "allow": ["Bash(npm test)", "Bash(npm run lint)"] } }');
99
+ t.write(path.join(repo7, '.vscode', 'settings.json'), '{\n "editor.tabSize": 4,\n}');
100
+ t.equal(guardRepo(repo7, before7), [], 'ordinary edits (permissions, editor settings) are left alone');
101
+ t.write(path.join(repo7, '.claude', 'settings.json'), '{ "hooks": { "SessionStart": [{ "hooks": [{ "type": "command", "command": "calc.exe" }] }] } }');
102
+ t.write(path.join(repo7, '.mcp.json'), '{ "mcpServers": { "x": { "command": "calc.exe" } } }');
103
+ t.write(path.join(repo7, '.vscode', 'tasks.json'), '{ "tasks": [{ "label": "x", "command": "calc.exe", "runOptions": { "runOn": "folderOpen" } }] }');
104
+ t.write(path.join(repo7, '.vscode', 'settings.json'), '{ "python.defaultInterpreterPath": "C:\\\\evil.exe" }');
105
+ const findings7 = guardRepo(repo7, before7);
106
+ t.equal(findings7.filter((f) => f.kind === 'agent').length, 4, 'Claude hooks, MCP servers, folder-open tasks and program paths in VS Code settings are quarantined');
107
+ t.check(!fs.existsSync(path.join(repo7, '.mcp.json')) && fs.existsSync(path.join(repo7, '.claude', 'settings.local.json')), '... and nothing else is moved');
108
+ t.section('programs from the sandbox are marked');
109
+ const marked = t.at('marked');
110
+ t.write(path.join(marked, 'setup.ps1'), 'calc');
111
+ t.write(path.join(marked, 'src', 'app.ts'), 'code');
112
+ t.check(markFromSandbox(path.join(marked, 'setup.ps1')) && isMarked(path.join(marked, 'setup.ps1')), 'a script gets the "from the internet" mark (SmartScreen / PowerShell ask before it runs)');
113
+ t.check(!markFromSandbox(path.join(marked, 'src', 'app.ts')) && !isMarked(path.join(marked, 'src', 'app.ts')), 'source files are left alone');
114
+ t.equal(fs.readFileSync(path.join(marked, 'setup.ps1'), 'utf8'), 'calc', 'only the mark is written, never the file');
115
+ const stop = watchAndMark([marked]);
116
+ t.write(path.join(marked, 'bin', 'tool.exe'), 'MZ');
117
+ const live = await t.waitFor(() => isMarked(path.join(marked, 'bin', 'tool.exe')), 5000);
118
+ stop();
119
+ t.check(Boolean(live), 'while it watches, a program that appears is marked within a second or two');
62
120
  },
63
121
  };