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
@@ -9,6 +9,12 @@ import { assertNoLinks, errMsg, sleep } from '../core/util.js';
9
9
  import { hostClaudeDir } from '../host/claude-profile.js';
10
10
  import { checkRepo } from '../host/repo-guard.js';
11
11
  import { isSandboxRunning } from '../host/platform.js';
12
+ import { readActiveViews, WorkspaceView } from '../host/workspace-view/index.js';
13
+ import { readForwards, sandboxAddress, startPortForwards } from '../host/port-forward.js';
14
+ import { AuditCollector, readAuditSettings } from '../host/audit/collect.js';
15
+ import { readMarkedProjects, saveDataSize, watchAndMark } from '../host/motw.js';
16
+ import { sizeOf } from '../core/disk.js';
17
+ import { beatLife, causeSince, endLife, keepAwake, readLife } from '../host/stops.js';
12
18
  import { hostClaudeMemDir, syncClaudeMem } from './claude-mem.js';
13
19
  import { readSyncState, syncHistoryIn, syncHistoryOut, writeSyncState } from './history.js';
14
20
  const WATCH_INTERVAL_MS = 60 * 1000;
@@ -32,7 +38,7 @@ export const syncProjectIn = (dataPath, project) => {
32
38
  const next = { ...state, projects };
33
39
  writeSyncState(dataPath, next);
34
40
  // Prompt history is left alone: sessions are appending to the sandbox's copy right now.
35
- return state.syncHistory ? syncHistoryIn(toPaths(next, dataPath), { promptHistory: false }) : 0;
41
+ return state.syncHistory ? syncHistoryIn(toPaths(next, dataPath), { promptHistory: false, scope: state.historyScope }) : 0;
36
42
  };
37
43
  const memSync = (dataPath, state, direction) => {
38
44
  assertNoLinks(dataPath, guestMem(dataPath));
@@ -47,11 +53,11 @@ export const syncBeforeLaunch = (config, dataPath, hostWorkspace) => {
47
53
  const previous = readSyncState(dataPath);
48
54
  if (previous?.syncHistory)
49
55
  syncHistoryOut(toPaths(previous, dataPath));
50
- const state = { ...previous, hostClaudeDir: hostClaudeDir(profile), hostWorkspace, guestWorkspace: config.guestWorkspace, syncHistory: profile.syncHistory, syncClaudeMem: profile.syncClaudeMem, projects: [] };
56
+ const state = { ...previous, hostClaudeDir: hostClaudeDir(profile), hostWorkspace, guestWorkspace: config.guestWorkspace, syncHistory: profile.syncHistory, historyScope: profile.historyScope, syncClaudeMem: profile.syncClaudeMem, syncClaudeMemBack: profile.syncClaudeMemBack, projects: [] };
51
57
  if (profile.syncHistory)
52
- summary.history = syncHistoryIn(toPaths(state, dataPath));
58
+ summary.history = syncHistoryIn(toPaths(state, dataPath), { scope: profile.historyScope });
53
59
  if (profile.syncClaudeMem) {
54
- if (previous?.syncClaudeMem)
60
+ if (previous?.syncClaudeMem && previous.syncClaudeMemBack)
55
61
  memSync(dataPath, state, 'out');
56
62
  summary.claudeMem = memSync(dataPath, state, 'in');
57
63
  }
@@ -66,7 +72,7 @@ export const syncBack = (dataPath, final) => {
66
72
  return summary;
67
73
  if (state.syncHistory)
68
74
  summary.history = syncHistoryOut(toPaths(state, dataPath));
69
- if (final && state.syncClaudeMem)
75
+ if (final && state.syncClaudeMem && state.syncClaudeMemBack)
70
76
  summary.claudeMem = memSync(dataPath, state, 'out');
71
77
  writeSyncState(dataPath, state);
72
78
  return summary;
@@ -105,8 +111,10 @@ export const watch = async (dataPath) => {
105
111
  catch { /* ignore */ } };
106
112
  const sync = (final) => {
107
113
  try {
108
- for (const finding of checkRepo(dataPath))
114
+ for (const finding of checkRepo(dataPath)) {
109
115
  note('SECURITY: neutralised a git ' + finding.kind + ' added inside the sandbox: ' + finding.detail);
116
+ audit?.guard(finding.kind + ': ' + finding.detail);
117
+ }
110
118
  }
111
119
  catch (e) {
112
120
  note('repo guard failed: ' + errMsg(e));
@@ -120,16 +128,80 @@ export const watch = async (dataPath) => {
120
128
  note('sync failed: ' + errMsg(e));
121
129
  }
122
130
  };
131
+ // workspaceFilter: each filtered project is kept in step with its view from now until the sandbox closes.
132
+ // audit.enabled: programs, files, sessions and guard findings into the host-only audit log.
133
+ const auditSettings = readAuditSettings(storageOf(dataPath), dataPath);
134
+ const audit = auditSettings?.enabled ? new AuditCollector(dataPath, storageOf(dataPath), auditSettings) : null;
135
+ audit?.start();
136
+ const auditTimer = audit ? setInterval(() => { try {
137
+ audit.flush();
138
+ }
139
+ catch (e) {
140
+ note('audit: ' + errMsg(e));
141
+ } }, 10000) : null;
142
+ // storage.markSandboxFiles: programs and scripts that appear in the projects get the "from the internet" mark.
143
+ const stopMarking = watchAndMark(readMarkedProjects(storageOf(dataPath), dataPath), (m) => note(m));
144
+ // network.forwardPorts: localhost on this PC reaches servers in the sandbox while it runs.
145
+ const stopForwards = startPortForwards(readForwards(storageOf(dataPath), dataPath), () => sandboxAddress(dataPath), (m) => note('port forward: ' + m));
146
+ const views = readActiveViews(storageOf(dataPath), dataPath);
147
+ const stops = views.flatMap((v) => {
148
+ try {
149
+ return [new WorkspaceView({ ...v, note: (m) => note('workspace ' + v.host + ': ' + m) }).watch()];
150
+ }
151
+ catch (e) {
152
+ note('workspace ' + v.host + ': could not watch: ' + errMsg(e));
153
+ return [];
154
+ }
155
+ });
156
+ // sandbox.keepAwake, and the life record that tells the next launch how this run ended (host/stops.ts).
157
+ const awake = readLife(storageOf(dataPath), dataPath)?.keepAwake ? keepAwake(process.pid) : null;
123
158
  try {
124
159
  await sleep(30000);
125
160
  while (isSandboxRunning()) {
161
+ beatLife(storageOf(dataPath), dataPath);
126
162
  sync(false);
127
163
  await sleep(WATCH_INTERVAL_MS);
128
164
  }
165
+ try {
166
+ const life = readLife(storageOf(dataPath), dataPath);
167
+ const cause = life ? causeSince(life.lastSeen) : null;
168
+ endLife(storageOf(dataPath), dataPath, cause);
169
+ if (cause)
170
+ note('sandbox ended: ' + cause.text);
171
+ }
172
+ catch (e) {
173
+ note('could not record how the sandbox ended: ' + errMsg(e));
174
+ }
129
175
  await sleep(3000);
130
176
  sync(true);
131
177
  }
132
178
  finally {
179
+ awake?.kill();
180
+ stops.forEach((stop) => stop());
181
+ stopForwards();
182
+ stopMarking();
183
+ if (auditTimer)
184
+ clearInterval(auditTimer);
185
+ try {
186
+ audit?.stop();
187
+ }
188
+ catch (e) {
189
+ note('audit: ' + errMsg(e));
190
+ }
191
+ for (const v of views) {
192
+ try {
193
+ const s = new WorkspaceView(v).syncAll();
194
+ note('workspace ' + v.host + ': final sync ' + s.toHost + ' out, ' + s.toView + ' in');
195
+ }
196
+ catch (e) {
197
+ note('workspace ' + v.host + ': final sync failed: ' + errMsg(e));
198
+ }
199
+ }
200
+ // For the next launch's storage.warnDataGb (measuring then would be slow).
201
+ try {
202
+ saveDataSize(storageOf(dataPath), dataPath, sizeOf(dataPath));
203
+ }
204
+ catch { /* best effort */ }
133
205
  fs.rmSync(watcherPidFile(dataPath), { force: true });
134
206
  }
135
207
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "windows-development-sandbox",
3
- "version": "1.1.0",
3
+ "version": "1.4.1",
4
4
  "description": "A disposable Windows Sandbox for development, with or without an AI agent (Claude Code, Codex, Gemini, opencode, ...): the folder you run it from is the workspace, toolchains and databases for your project install themselves, and nothing else on your PC is reachable.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -72,6 +72,7 @@
72
72
  "dependencies": {
73
73
  "@inquirer/prompts": "^7.10.1",
74
74
  "commander": "^14.0.3",
75
+ "ignore": "^7.0.12",
75
76
  "jsonc-parser": "^3.3.1",
76
77
  "lodash": "^4.18.1",
77
78
  "zod": "^4.6.5"
@@ -0,0 +1,56 @@
1
+ // cargo.exe stand-in for toolchains.rust.targetDir = "separate": every checkout builds into ITS OWN target\sandbox.
2
+ // One CARGO_TARGET_DIR for the whole sandbox made agents working in different git worktrees overwrite each other's
3
+ // builds (and the Claude Code Bash tool does not keep a per-call variable). The checkout is the nearest folder with a
4
+ // .git (a folder, or the file a worktree has); without one, the nearest folder with a Cargo.toml. A CARGO_TARGET_DIR
5
+ // you set yourself always wins. Compiled inside the sandbox with the .NET Framework csc.exe (C# 5).
6
+ using System;
7
+ using System.Diagnostics;
8
+ using System.IO;
9
+
10
+ static class CargoShim
11
+ {
12
+ static int Main()
13
+ {
14
+ string real = Environment.GetEnvironmentVariable("AGENT_REAL_CARGO");
15
+ if (string.IsNullOrEmpty(real) || !File.Exists(real))
16
+ {
17
+ Console.Error.WriteLine("error: the real cargo was not found (AGENT_REAL_CARGO).");
18
+ return 101;
19
+ }
20
+ var start = new ProcessStartInfo(real, ArgumentsOf(Environment.CommandLine)) { UseShellExecute = false };
21
+ if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CARGO_TARGET_DIR")))
22
+ {
23
+ string root = CheckoutOf(Environment.CurrentDirectory);
24
+ if (root != null) start.EnvironmentVariables["CARGO_TARGET_DIR"] = Path.Combine(root, "target", "sandbox");
25
+ }
26
+ // Ctrl+C reaches cargo too (same console); the shim only waits for it to finish.
27
+ Console.CancelKeyPress += delegate(object sender, ConsoleCancelEventArgs e) { e.Cancel = true; };
28
+ using (Process p = Process.Start(start))
29
+ {
30
+ p.WaitForExit();
31
+ return p.ExitCode;
32
+ }
33
+ }
34
+
35
+ static string CheckoutOf(string dir)
36
+ {
37
+ string cargoRoot = null;
38
+ for (var d = new DirectoryInfo(dir); d != null; d = d.Parent)
39
+ {
40
+ string git = Path.Combine(d.FullName, ".git");
41
+ if (Directory.Exists(git) || File.Exists(git)) return d.FullName;
42
+ if (cargoRoot == null && File.Exists(Path.Combine(d.FullName, "Cargo.toml"))) cargoRoot = d.FullName;
43
+ }
44
+ return cargoRoot;
45
+ }
46
+
47
+ // Everything after the program name, untouched, so quoting survives exactly as the caller wrote it.
48
+ static string ArgumentsOf(string commandLine)
49
+ {
50
+ string s = commandLine.TrimStart();
51
+ int end;
52
+ if (s.StartsWith("\"")) { end = s.IndexOf('"', 1); end = end < 0 ? s.Length : end + 1; }
53
+ else { end = s.IndexOf(' '); if (end < 0) end = s.Length; }
54
+ return s.Substring(end).TrimStart();
55
+ }
56
+ }
@@ -0,0 +1,33 @@
1
+ // Caps a process and everything it starts at a share of the PC's CPU. Loaded by cpucap.ps1 (Add-Type). C# 5.
2
+ using System;
3
+ using System.ComponentModel;
4
+ using System.Runtime.InteropServices;
5
+
6
+ public static class SandboxCpuCap {
7
+ // JOBOBJECT_CPU_RATE_CONTROL_INFORMATION.
8
+ [StructLayout(LayoutKind.Sequential)]
9
+ private struct CpuRate { public uint ControlFlags; public uint Rate; }
10
+
11
+ [DllImport("kernel32.dll", SetLastError = true)] private static extern IntPtr CreateJobObject(IntPtr attributes, string name);
12
+ [DllImport("kernel32.dll", SetLastError = true)] private static extern bool SetInformationJobObject(IntPtr job, int infoClass, ref CpuRate info, int length);
13
+ [DllImport("kernel32.dll", SetLastError = true)] private static extern IntPtr OpenProcess(uint access, bool inherit, int pid);
14
+ [DllImport("kernel32.dll", SetLastError = true)] private static extern bool AssignProcessToJobObject(IntPtr job, IntPtr process);
15
+ [DllImport("kernel32.dll")] private static extern bool CloseHandle(IntPtr handle);
16
+
17
+ private const int JobObjectCpuRateControlInformation = 15;
18
+ private const uint CpuRateEnable = 0x1, CpuRateHardCap = 0x4;
19
+ private const uint ProcessTerminate = 0x0001, ProcessSetQuota = 0x0100; // what AssignProcessToJobObject needs
20
+
21
+ /// <summary>Puts process pid in a new job with a hard cap of percent % of all processors. Throws on failure.</summary>
22
+ public static void Apply(int pid, int percent) {
23
+ IntPtr job = CreateJobObject(IntPtr.Zero, null);
24
+ if (job == IntPtr.Zero) throw new Win32Exception();
25
+ // The rate is in 1/100 % of all processors.
26
+ CpuRate rate = new CpuRate { ControlFlags = CpuRateEnable | CpuRateHardCap, Rate = (uint)(percent * 100) };
27
+ if (!SetInformationJobObject(job, JobObjectCpuRateControlInformation, ref rate, Marshal.SizeOf(typeof(CpuRate)))) throw new Win32Exception();
28
+ IntPtr process = OpenProcess(ProcessSetQuota | ProcessTerminate, false, pid);
29
+ if (process == IntPtr.Zero) throw new Win32Exception();
30
+ try { if (!AssignProcessToJobObject(job, process)) throw new Win32Exception(); } finally { CloseHandle(process); }
31
+ // The job's handle is left to close with this process: the job lives on while any process in it runs.
32
+ }
33
+ }
@@ -3,46 +3,15 @@
3
3
  # every processor, so heavy builds inside can take the whole PC. This puts logon.cmd (the parent of this script),
4
4
  # before it starts anything, into a Windows job object with a hard CPU-rate cap; everything it starts inherits it:
5
5
  # Windows Terminal and its tabs, the boot, the agents, installs and builds. Programs started from the sandbox's Start
6
- # menu are not covered. Never stops the boot: any problem is logged and the sandbox starts uncapped.
7
- param([int]$ProcessId = 0, [int]$Percent = 0)
6
+ # menu are not covered. Never stops the boot: it logs one line either way and the sandbox starts uncapped on failure.
7
+ param([int]$ProcessId = 0, [int]$Percent = 0) # 0 = the parent process / cpuLimitPercent from session.json
8
8
  $ErrorActionPreference = 'Stop'
9
9
  $log = 'C:\AgentData\logs\logon-hit.txt'
10
10
  try {
11
- if ($Percent -eq 0) {
12
- $session = Get-Content -LiteralPath 'C:\AgentLaunch\session.json' -Raw -Encoding UTF8 | ConvertFrom-Json
13
- if ($null -eq $session.cpuLimitPercent) { exit 0 }
14
- $Percent = [int]$session.cpuLimitPercent
15
- }
11
+ if ($Percent -eq 0) { $Percent = [int](Get-Content -LiteralPath 'C:\AgentLaunch\session.json' -Raw -Encoding UTF8 | ConvertFrom-Json).cpuLimitPercent }
16
12
  if ($Percent -lt 1 -or $Percent -ge 100) { exit 0 }
17
13
  if ($ProcessId -eq 0) { $ProcessId = [int](Get-CimInstance Win32_Process -Filter "ProcessId=$PID").ParentProcessId }
18
- Add-Type -TypeDefinition @'
19
- using System;
20
- using System.ComponentModel;
21
- using System.Runtime.InteropServices;
22
- public static class SandboxCpuCap {
23
- [StructLayout(LayoutKind.Sequential)]
24
- private struct CpuRate { public uint ControlFlags; public uint Rate; }
25
- [DllImport("kernel32.dll", SetLastError = true)] private static extern IntPtr CreateJobObject(IntPtr attributes, string name);
26
- [DllImport("kernel32.dll", SetLastError = true)] private static extern bool SetInformationJobObject(IntPtr job, int infoClass, ref CpuRate info, int length);
27
- [DllImport("kernel32.dll", SetLastError = true)] private static extern IntPtr OpenProcess(uint access, bool inherit, int pid);
28
- [DllImport("kernel32.dll", SetLastError = true)] private static extern bool AssignProcessToJobObject(IntPtr job, IntPtr process);
29
- [DllImport("kernel32.dll")] private static extern bool CloseHandle(IntPtr handle);
30
- // JobObjectCpuRateControlInformation; ENABLE | HARD_CAP; the rate is in 1/100 % of all processors.
31
- public static void Apply(int pid, int percent) {
32
- IntPtr job = CreateJobObject(IntPtr.Zero, null);
33
- if (job == IntPtr.Zero) throw new Win32Exception();
34
- CpuRate rate = new CpuRate();
35
- rate.ControlFlags = 0x1 | 0x4;
36
- rate.Rate = (uint)(percent * 100);
37
- if (!SetInformationJobObject(job, 15, ref rate, Marshal.SizeOf(typeof(CpuRate)))) throw new Win32Exception();
38
- // PROCESS_SET_QUOTA | PROCESS_TERMINATE: what AssignProcessToJobObject needs.
39
- IntPtr process = OpenProcess(0x0100 | 0x0001, false, pid);
40
- if (process == IntPtr.Zero) throw new Win32Exception();
41
- try { if (!AssignProcessToJobObject(job, process)) throw new Win32Exception(); } finally { CloseHandle(process); }
42
- // The job's handle may close: it lives on while any process in it runs.
43
- }
44
- }
45
- '@
14
+ Add-Type -Path (Join-Path $PSScriptRoot 'cpucap.cs')
46
15
  [SandboxCpuCap]::Apply($ProcessId, $Percent)
47
16
  "CPU capped at $Percent% of the PC (sandbox.cpuLimit)." | Add-Content -LiteralPath $log
48
17
  } catch {
package/sandbox/logon.cmd CHANGED
@@ -4,9 +4,15 @@ rem (downloaded once by the host into persistent storage), or a plain PowerShell
4
4
  if not exist C:\AgentData\logs mkdir C:\AgentData\logs
5
5
  echo logon.cmd ran at %date% %time% > C:\AgentData\logs\logon-hit.txt
6
6
  call C:\AgentLaunch\sandbox\trust.cmd
7
+ rem New Terminal tabs start in %AGENT_WORKSPACE%: Terminal must have it before the runtime sets it machine-wide.
8
+ if exist C:\AgentLaunch\env.cmd call C:\AgentLaunch\env.cmd
7
9
  set "PS=%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe"
8
10
  rem sandbox.cpuLimit: this window joins a CPU-capped job first, so everything started below inherits the cap.
9
11
  "%PS%" -NoLogo -NoProfile -ExecutionPolicy Bypass -File C:\AgentLaunch\sandbox\cpucap.ps1
12
+ rem sandbox.pageFileMb: room for many parallel builds (the sandbox starts with about 1 GB of paging file).
13
+ "%PS%" -NoLogo -NoProfile -ExecutionPolicy Bypass -File C:\AgentLaunch\sandbox\pagefile.ps1
14
+ rem The memory state every 30 s into the persistent logs, so a crash leaves evidence.
15
+ start "" /min "%PS%" -NoLogo -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File C:\AgentLaunch\sandbox\memlog.ps1
10
16
  set "BOOT=%PS% -NoLogo -NoProfile -ExecutionPolicy Bypass -NoExit -File C:\AgentLaunch\sandbox\bootstrap.ps1"
11
17
  if not exist C:\AgentData\tools\terminal\wt.exe goto plain
12
18
  start "" C:\AgentData\tools\terminal\wt.exe --maximized new-tab --title "Agent Sandbox" -d C:\AgentLaunch %BOOT%
@@ -0,0 +1,34 @@
1
+ #Requires -Version 5.1
2
+ # Writes the sandbox's memory state to C:\AgentData\logs\memory-<time>.log every 30 seconds, while it runs. That folder
3
+ # outlives the sandbox, so if Windows inside it ever crashes again, the last lines show what was using memory just
4
+ # before. Bounded: a file is closed at 1 MB (about two days) and a new one started; only the newest 10 are kept, so
5
+ # the logs never take more than about 10 MB. Started hidden by logon.cmd. Never stops the boot.
6
+ $ErrorActionPreference = 'SilentlyContinue'
7
+ $logFolder = 'C:\AgentData\logs'
8
+ $maxFileBytes = 1MB
9
+ $keepFiles = 10
10
+
11
+ # A new log file, and the oldest ones beyond $keepFiles removed. Returns its path.
12
+ function New-MemoryLog {
13
+ $path = Join-Path $logFolder ('memory-{0:yyyyMMdd-HHmmss}.log' -f (Get-Date))
14
+ 'time | RAM used/total MB | commit used/limit MB | biggest processes (private MB)' | Set-Content -LiteralPath $path
15
+ Get-ChildItem -LiteralPath $logFolder -Filter 'memory-*.log' | Sort-Object LastWriteTime -Descending |
16
+ Select-Object -Skip $keepFiles | Remove-Item -Force
17
+ return $path
18
+ }
19
+
20
+ $logPath = New-MemoryLog
21
+ while ($true) {
22
+ $os = Get-CimInstance Win32_OperatingSystem
23
+ $ramTotalMb = [math]::Round($os.TotalVisibleMemorySize / 1KB)
24
+ $ramUsedMb = $ramTotalMb - [math]::Round($os.FreePhysicalMemory / 1KB)
25
+ $commitLimitMb = [math]::Round($os.TotalVirtualMemorySize / 1KB)
26
+ $commitUsedMb = $commitLimitMb - [math]::Round($os.FreeVirtualMemory / 1KB)
27
+ $processes = @(Get-Process)
28
+ $biggest = $processes | Sort-Object PrivateMemorySize64 -Descending | Select-Object -First 5 |
29
+ ForEach-Object { '{0} {1}' -f $_.ProcessName, [math]::Round($_.PrivateMemorySize64 / 1MB) }
30
+ '{0:HH:mm:ss} | {1}/{2} | {3}/{4} | {5} processes; {6}' -f (Get-Date), $ramUsedMb, $ramTotalMb, $commitUsedMb, $commitLimitMb, $processes.Count, ($biggest -join ', ') |
31
+ Add-Content -LiteralPath $logPath
32
+ if ((Get-Item -LiteralPath $logPath).Length -ge $maxFileBytes) { $logPath = New-MemoryLog }
33
+ Start-Sleep -Seconds 30
34
+ }
@@ -0,0 +1,31 @@
1
+ // Sets the size of the sandbox's paging file at once, with no restart. Loaded by pagefile.ps1 (Add-Type). C# 5.
2
+ using System;
3
+ using System.ComponentModel;
4
+ using System.Runtime.InteropServices;
5
+
6
+ public static class SandboxPageFile {
7
+ // UNICODE_STRING. The marshaller copies Buffer to native memory for the length of the call.
8
+ [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
9
+ private struct NtString { public ushort Length; public ushort MaximumLength; public string Buffer; }
10
+
11
+ [DllImport("ntdll.dll")] private static extern int RtlAdjustPrivilege(int privilege, bool enable, bool currentThread, out bool wasEnabled);
12
+ [DllImport("ntdll.dll")] private static extern int NtCreatePagingFile(ref NtString path, ref long minimumBytes, ref long maximumBytes, uint priority);
13
+ [DllImport("ntdll.dll")] private static extern int RtlNtStatusToDosError(int status);
14
+
15
+ private const int SeCreatePagefilePrivilege = 15;
16
+
17
+ /// <summary>Makes C:\pagefile.sys sizeMb, fixed (it never grows). Throws with Windows' reason on failure.</summary>
18
+ public static void Set(int sizeMb) {
19
+ bool wasEnabled;
20
+ Check(RtlAdjustPrivilege(SeCreatePagefilePrivilege, true, false, out wasEnabled));
21
+ // The same call Windows makes at startup; for the existing file it changes the size in place.
22
+ const string path = @"\??\C:\pagefile.sys";
23
+ NtString name = new NtString { Length = (ushort)(path.Length * 2), MaximumLength = (ushort)(path.Length * 2 + 2), Buffer = path };
24
+ long minimum = sizeMb * 1048576L, maximum = minimum;
25
+ Check(NtCreatePagingFile(ref name, ref minimum, ref maximum, 0));
26
+ }
27
+
28
+ private static void Check(int status) {
29
+ if (status != 0) throw new Win32Exception(RtlNtStatusToDosError(status));
30
+ }
31
+ }
@@ -0,0 +1,21 @@
1
+ #Requires -Version 5.1
2
+ # Gives the sandbox a paging file of sandbox.pageFileMb, created at full size now, before anything runs. Windows Sandbox
3
+ # starts with about 1 GB, so parallel builds run out of memory ("The paging file is too small", os error 1455), and
4
+ # then Windows grows the file while they run: both crashes of Windows inside the sandbox (13 Oct 2026, stop code 0x1A
5
+ # MEMORY_MANAGEMENT) happened under that load. A fixed size never grows. Never stops the boot: it logs one line.
6
+ param([int]$SizeMb = 0) # 0 = read sandbox.pageFileMb from session.json
7
+ $ErrorActionPreference = 'Stop'
8
+ $log = 'C:\AgentData\logs\logon-hit.txt'
9
+ try {
10
+ if ($SizeMb -eq 0) { $SizeMb = [int](Get-Content -LiteralPath 'C:\AgentLaunch\session.json' -Raw -Encoding UTF8 | ConvertFrom-Json).pageFileMb }
11
+ if ($SizeMb -le 0) { exit 0 }
12
+ # Keep 8 GB of the sandbox's disk free for everything else.
13
+ $SizeMb = [math]::Min($SizeMb, [math]::Floor((Get-PSDrive -Name C).Free / 1MB) - 8192)
14
+ if ($SizeMb -lt 1024) { throw 'too little free disk.' }
15
+ Add-Type -Path (Join-Path $PSScriptRoot 'pagefile.cs')
16
+ [SandboxPageFile]::Set($SizeMb)
17
+ "Paging file set to $SizeMb MB, fixed (sandbox.pageFileMb)." | Add-Content -LiteralPath $log
18
+ } catch {
19
+ "Paging file left as it is: $($_.Exception.Message)" | Add-Content -LiteralPath $log
20
+ }
21
+ exit 0
@@ -16,6 +16,10 @@
16
16
  // 18 ERROR utf8 message
17
17
  // 19 RESTARTING
18
18
  // 20 STATUS [viewers:2][remoteViewers:2] viewers heard from recently (remote = not loopback, i.e. the PC)
19
+ // 21 CONSOLE utf8 "conpty.dll" or "windows", sent after HELLO. With conpty.dll the output is passed
20
+ // through (real scrollback, no repaint on resize): a viewer that joins later is sent the
21
+ // terminal modes in force (mouse tracking ...) and the output since the last screen clear
22
+ // instead, and must not clear its screen on a resize.
19
23
  using System;
20
24
  using System.Collections.Concurrent;
21
25
  using System.Collections.Generic;
@@ -75,6 +79,7 @@ sealed class SessionHost
75
79
  using (var console = PseudoConsole.Start(command, hub.Cols, hub.Rows, hub.Broadcast))
76
80
  {
77
81
  hub.Attach(console);
82
+ log.Write("pseudo console: " + Native.ConsoleKind);
78
83
  int exitCode = console.WaitForExit();
79
84
  if (!restartRequested)
80
85
  {
@@ -92,6 +97,7 @@ sealed class SessionHost
92
97
  public void Restart()
93
98
  {
94
99
  restartRequested = true;
100
+ hub.ResetReplay();
95
101
  hub.Notify(Frame.Restarting);
96
102
  hub.KillProgram();
97
103
  }
@@ -225,6 +231,9 @@ sealed class ClientHub
225
231
  readonly byte[] token;
226
232
  readonly byte[] tail = new byte[8192];
227
233
  int tailLength;
234
+ readonly object replayGate = new object();
235
+ readonly MemoryStream replay = new MemoryStream();
236
+ const int MaxReplay = 4 << 20;
228
237
  PseudoConsole console;
229
238
  Client sizeOwner;
230
239
  short cols, rows;
@@ -251,8 +260,23 @@ sealed class ClientHub
251
260
  client.ControlOnly = (hello[4] & 3) != 0;
252
261
  client.Query = (hello[4] & 2) != 0;
253
262
  if (client.Query) return true;
254
- lock (gate) clients.Add(client);
255
- if (!client.ControlOnly) TakeOwnership(client);
263
+ if (!client.ControlOnly)
264
+ {
265
+ Send(client, Frame.Console, Encoding.UTF8.GetBytes(Native.ConsoleKind.StartsWith("conpty") ? "conpty.dll" : "windows"));
266
+ // Taken and queued under the replay lock, so no output falls between the replay and the live stream.
267
+ lock (replayGate)
268
+ {
269
+ if (Native.ConsoleKind.StartsWith("conpty"))
270
+ {
271
+ byte[] prefix = ModePrefix();
272
+ if (prefix.Length > 0) Send(client, Frame.Output, prefix);
273
+ if (replay.Length > 0) Send(client, Frame.Output, replay.ToArray());
274
+ }
275
+ lock (gate) clients.Add(client);
276
+ }
277
+ TakeOwnership(client);
278
+ }
279
+ else lock (gate) clients.Add(client);
256
280
  return true;
257
281
  }
258
282
 
@@ -287,7 +311,92 @@ sealed class ClientHub
287
311
  public void Broadcast(byte[] output)
288
312
  {
289
313
  Remember(output);
290
- foreach (Client c in Snapshot()) if (!c.ControlOnly) Send(c, Frame.Output, output);
314
+ lock (replayGate)
315
+ {
316
+ Record(output);
317
+ foreach (Client c in Snapshot()) if (!c.ControlOnly) Send(c, Frame.Output, output);
318
+ }
319
+ }
320
+
321
+ /**
322
+ * The output a viewer joining now needs to see the screen (conpty.dll does not repaint for it): everything since the
323
+ * last "clear screen", capped. Cut at a line start when capped, so the replay never begins inside an escape sequence.
324
+ */
325
+ void Record(byte[] output)
326
+ {
327
+ if (!Native.ConsoleKind.StartsWith("conpty")) return;
328
+ TrackModes(output);
329
+ int clear = LastClear(output);
330
+ if (clear >= 0) { replay.SetLength(0); replay.Write(output, clear, output.Length - clear); }
331
+ else replay.Write(output, 0, output.Length);
332
+ if (replay.Length <= MaxReplay) return;
333
+ byte[] all = replay.ToArray();
334
+ int from = all.Length - MaxReplay / 2;
335
+ while (from < all.Length && all[from] != (byte)'\n') from++;
336
+ replay.SetLength(0);
337
+ if (from + 1 < all.Length) replay.Write(all, from + 1, all.Length - from - 1);
338
+ }
339
+
340
+ /**
341
+ * Terminal modes the program (or conpty.dll) turned on that a late viewer's terminal must have too, or its clicks and
342
+ * wheel never reach the program: mouse tracking (1000, 1002, 1003, 1005, 1006, 1015), focus events (1004), bracketed
343
+ * paste (2004), win32 input (9001), application cursor keys (1), cursor visibility (25), the alternate screen (1049).
344
+ * The replay is cut at the last screen clear, which may come after the program turned them on.
345
+ */
346
+ static readonly int[] ReplayedModes = { 1, 25, 1000, 1002, 1003, 1004, 1005, 1006, 1015, 1049, 2004, 9001 };
347
+ readonly Dictionary<int, bool> modes = new Dictionary<int, bool>();
348
+ /** DECSET / DECRST parser, kept across chunks: 0 text, 1 after ESC, 2 after ESC[, 3 in ESC[? parameters. */
349
+ int modeState;
350
+ readonly StringBuilder modeParams = new StringBuilder();
351
+
352
+ void TrackModes(byte[] output)
353
+ {
354
+ foreach (byte b in output)
355
+ {
356
+ if (b == 0x1b) { modeState = 1; continue; }
357
+ switch (modeState)
358
+ {
359
+ case 1: modeState = b == (byte)'[' ? 2 : 0; break;
360
+ case 2: modeState = b == (byte)'?' ? 3 : 0; modeParams.Length = 0; break;
361
+ case 3:
362
+ if ((b >= (byte)'0' && b <= (byte)'9') || b == (byte)';') { if (modeParams.Length < 64) modeParams.Append((char)b); }
363
+ else
364
+ {
365
+ if (b == (byte)'h' || b == (byte)'l')
366
+ foreach (string p in modeParams.ToString().Split(';'))
367
+ {
368
+ int mode;
369
+ if (int.TryParse(p, out mode) && Array.IndexOf(ReplayedModes, mode) >= 0) modes[mode] = b == (byte)'h';
370
+ }
371
+ modeState = 0;
372
+ }
373
+ break;
374
+ }
375
+ }
376
+ }
377
+
378
+ /** The modes to set before the replay, e.g. ESC[?1000h ESC[?1006h. */
379
+ byte[] ModePrefix()
380
+ {
381
+ var text = new StringBuilder();
382
+ foreach (int mode in ReplayedModes)
383
+ {
384
+ bool on;
385
+ if (modes.TryGetValue(mode, out on)) text.Append("\x1b[?" + mode + (on ? "h" : "l"));
386
+ }
387
+ return Encoding.ASCII.GetBytes(text.ToString());
388
+ }
389
+
390
+ /** Where the last ESC[2J (clear screen) or ESC c (reset) starts in `data`, or -1. */
391
+ static int LastClear(byte[] data)
392
+ {
393
+ for (int i = data.Length - 2; i >= 0; i--)
394
+ {
395
+ if (data[i] != 0x1b) continue;
396
+ if (data[i + 1] == (byte)'c') return i;
397
+ if (i + 3 < data.Length && data[i + 1] == (byte)'[' && data[i + 2] == (byte)'2' && data[i + 3] == (byte)'J') return i;
398
+ }
399
+ return -1;
291
400
  }
292
401
 
293
402
  /** The program's last output as plain text (escape sequences removed), for the log when it exits. */
@@ -311,6 +420,9 @@ sealed class ClientHub
311
420
  }
312
421
  }
313
422
 
423
+ /** A restarted program starts on a clean screen (viewers clear theirs on RESTARTING). */
424
+ public void ResetReplay() { lock (replayGate) { replay.SetLength(0); modes.Clear(); modeState = 0; } }
425
+
314
426
  public void Notify(byte type)
315
427
  {
316
428
  foreach (Client c in Snapshot()) if (!c.ControlOnly) Send(c, type, new byte[0]);
@@ -503,7 +615,7 @@ sealed class PseudoConsole : IDisposable
503
615
  static class Frame
504
616
  {
505
617
  public const byte Hello = 1, Input = 2, Resize = 3, Restart = 4, Ping = 5;
506
- public const byte Output = 16, Exit = 17, Error = 18, Restarting = 19, Status = 20;
618
+ public const byte Output = 16, Exit = 17, Error = 18, Restarting = 19, Status = 20, Console = 21;
507
619
  const int MaxPayload = 1 << 20;
508
620
 
509
621
  public static bool Read(Stream stream, out byte type, out byte[] payload)
@@ -598,9 +710,52 @@ static class Native
598
710
  }
599
711
  }
600
712
 
601
- [DllImport("kernel32.dll")] public static extern int CreatePseudoConsole(Coord size, SafeFileHandle hInput, SafeFileHandle hOutput, uint dwFlags, out IntPtr phPC);
602
- [DllImport("kernel32.dll")] public static extern int ResizePseudoConsole(IntPtr hPC, Coord size);
603
- [DllImport("kernel32.dll")] public static extern void ClosePseudoConsole(IntPtr hPC);
713
+ // The pseudo console: the newer conpty.dll (+ OpenConsole.exe next to it) when PTYHOST_CONPTY names one, else the one
714
+ // built into Windows. Windows 10/11 before 24H2 ship a ConPTY that re-renders the screen instead of passing the
715
+ // program's output through, which garbles wide characters after a resize and loses mouse input and scrollback.
716
+ [UnmanagedFunctionPointer(CallingConvention.Winapi)] delegate int CreateFn(Coord size, SafeFileHandle hInput, SafeFileHandle hOutput, uint dwFlags, out IntPtr phPC);
717
+ [UnmanagedFunctionPointer(CallingConvention.Winapi)] delegate int ResizeFn(IntPtr hPC, Coord size);
718
+ [UnmanagedFunctionPointer(CallingConvention.Winapi)] delegate void CloseFn(IntPtr hPC);
719
+ static CreateFn create;
720
+ static ResizeFn resize;
721
+ static CloseFn close;
722
+ /** "conpty.dll" or "windows": which pseudo console is in use (logged, and told to viewers). */
723
+ public static string ConsoleKind = "windows";
724
+
725
+ static Native()
726
+ {
727
+ string dll = Environment.GetEnvironmentVariable("PTYHOST_CONPTY");
728
+ if (string.IsNullOrEmpty(dll) || !File.Exists(dll) || !File.Exists(Path.Combine(Path.GetDirectoryName(dll), "OpenConsole.exe"))) return;
729
+ IntPtr module = LoadLibraryW(dll);
730
+ if (module == IntPtr.Zero) return;
731
+ IntPtr c = GetProcAddress(module, "ConptyCreatePseudoConsole"), r = GetProcAddress(module, "ConptyResizePseudoConsole"), x = GetProcAddress(module, "ConptyClosePseudoConsole");
732
+ if (c == IntPtr.Zero || r == IntPtr.Zero || x == IntPtr.Zero) return;
733
+ create = (CreateFn)Marshal.GetDelegateForFunctionPointer(c, typeof(CreateFn));
734
+ resize = (ResizeFn)Marshal.GetDelegateForFunctionPointer(r, typeof(ResizeFn));
735
+ close = (CloseFn)Marshal.GetDelegateForFunctionPointer(x, typeof(CloseFn));
736
+ ConsoleKind = "conpty.dll";
737
+ }
738
+
739
+ /** conpty.dll first; if it cannot start one (OpenConsole.exe blocked or broken), the built-in one for good. */
740
+ public static int CreatePseudoConsole(Coord size, SafeFileHandle hInput, SafeFileHandle hOutput, uint dwFlags, out IntPtr phPC)
741
+ {
742
+ if (create != null)
743
+ {
744
+ int hr = create(size, hInput, hOutput, dwFlags, out phPC);
745
+ if (hr == 0) return 0;
746
+ create = null; resize = null; close = null;
747
+ ConsoleKind = "windows (conpty.dll failed: 0x" + hr.ToString("X8") + ")";
748
+ }
749
+ return KernelCreatePseudoConsole(size, hInput, hOutput, dwFlags, out phPC);
750
+ }
751
+ public static int ResizePseudoConsole(IntPtr hPC, Coord size) { return resize != null ? resize(hPC, size) : KernelResizePseudoConsole(hPC, size); }
752
+ public static void ClosePseudoConsole(IntPtr hPC) { if (close != null) close(hPC); else KernelClosePseudoConsole(hPC); }
753
+
754
+ [DllImport("kernel32.dll", EntryPoint = "CreatePseudoConsole")] static extern int KernelCreatePseudoConsole(Coord size, SafeFileHandle hInput, SafeFileHandle hOutput, uint dwFlags, out IntPtr phPC);
755
+ [DllImport("kernel32.dll", EntryPoint = "ResizePseudoConsole")] static extern int KernelResizePseudoConsole(IntPtr hPC, Coord size);
756
+ [DllImport("kernel32.dll", EntryPoint = "ClosePseudoConsole")] static extern void KernelClosePseudoConsole(IntPtr hPC);
757
+ [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] static extern IntPtr LoadLibraryW(string path);
758
+ [DllImport("kernel32.dll", CharSet = CharSet.Ansi, BestFitMapping = false)] static extern IntPtr GetProcAddress(IntPtr module, string name);
604
759
  [DllImport("kernel32.dll", SetLastError = true)] public static extern bool CreatePipe(out SafeFileHandle hReadPipe, out SafeFileHandle hWritePipe, IntPtr lpPipeAttributes, int nSize);
605
760
  [DllImport("kernel32.dll", SetLastError = true)] static extern bool InitializeProcThreadAttributeList(IntPtr lpAttributeList, int dwAttributeCount, int dwFlags, ref IntPtr lpSize);
606
761
  [DllImport("kernel32.dll", SetLastError = true)] static extern bool UpdateProcThreadAttribute(IntPtr lpAttributeList, uint dwFlags, IntPtr attribute, IntPtr lpValue, IntPtr cbSize, IntPtr lpPreviousValue, IntPtr lpReturnSize);