windows-development-sandbox 1.1.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. package/README.md +3 -2
  2. package/config.example.json +38 -5
  3. package/dist/cli/args.d.ts +3 -0
  4. package/dist/cli/args.js +3 -1
  5. package/dist/cli/commands/accounts.js +2 -2
  6. package/dist/cli/commands/agent-commands.js +1 -1
  7. package/dist/cli/commands/context.js +2 -1
  8. package/dist/cli/commands/data/clean.js +6 -1
  9. package/dist/cli/commands/data/collect.js +3 -3
  10. package/dist/cli/commands/index.d.ts +1 -1
  11. package/dist/cli/commands/index.js +1 -1
  12. package/dist/cli/commands/launch.js +36 -3
  13. package/dist/cli/commands/maintenance.d.ts +2 -0
  14. package/dist/cli/commands/maintenance.js +35 -2
  15. package/dist/cli/commands/sessions.js +3 -3
  16. package/dist/cli/commands/user-config.js +1 -1
  17. package/dist/cli/index.js +6 -4
  18. package/dist/cli/manifest.js +5 -0
  19. package/dist/cli/menu/apps.d.ts +21 -0
  20. package/dist/cli/menu/apps.js +122 -0
  21. package/dist/cli/menu/command-form.d.ts +28 -0
  22. package/dist/cli/menu/command-form.js +219 -0
  23. package/dist/cli/menu/index.d.ts +26 -0
  24. package/dist/cli/menu/index.js +180 -0
  25. package/dist/cli/menu/settings/categories.d.ts +9 -0
  26. package/dist/cli/menu/settings/categories.js +28 -0
  27. package/dist/cli/menu/settings/config-file.d.ts +40 -0
  28. package/dist/cli/menu/settings/config-file.js +71 -0
  29. package/dist/cli/menu/settings/editors.d.ts +30 -0
  30. package/dist/cli/menu/settings/editors.js +305 -0
  31. package/dist/cli/menu/settings/index.d.ts +32 -0
  32. package/dist/cli/menu/settings/index.js +136 -0
  33. package/dist/cli/menu/settings/schema-node.d.ts +62 -0
  34. package/dist/cli/menu/settings/schema-node.js +104 -0
  35. package/dist/cli/{menu-multi.d.ts → menu/several.d.ts} +10 -2
  36. package/dist/cli/menu/several.js +102 -0
  37. package/dist/cli/tui/app.d.ts +39 -0
  38. package/dist/cli/tui/app.js +52 -0
  39. package/dist/cli/tui/dialogs.d.ts +57 -0
  40. package/dist/cli/tui/dialogs.js +162 -0
  41. package/dist/cli/tui/frame.d.ts +18 -0
  42. package/dist/cli/tui/frame.js +27 -0
  43. package/dist/cli/tui/index.d.ts +7 -0
  44. package/dist/cli/tui/index.js +8 -0
  45. package/dist/cli/tui/list-screen.d.ts +101 -0
  46. package/dist/cli/tui/list-screen.js +299 -0
  47. package/dist/cli/tui/terminal.d.ts +37 -0
  48. package/dist/cli/tui/terminal.js +89 -0
  49. package/dist/cli/tui/text.d.ts +20 -0
  50. package/dist/cli/tui/text.js +105 -0
  51. package/dist/cli/tui/theme.d.ts +45 -0
  52. package/dist/cli/tui/theme.js +30 -0
  53. package/dist/core/config.js +9 -6
  54. package/dist/core/local-config.d.ts +15 -0
  55. package/dist/core/local-config.js +63 -4
  56. package/dist/core/schema/apps.d.ts +40 -0
  57. package/dist/core/schema/apps.js +27 -0
  58. package/dist/core/schema/config.d.ts +66 -1
  59. package/dist/core/schema/config.js +18 -6
  60. package/dist/core/schema/profile.d.ts +5 -0
  61. package/dist/core/schema/profile.js +4 -2
  62. package/dist/core/schema/sandbox.d.ts +28 -0
  63. package/dist/core/schema/sandbox.js +28 -0
  64. package/dist/core/schema/toolchains.d.ts +3 -0
  65. package/dist/core/schema/toolchains.js +6 -1
  66. package/dist/core/types.d.ts +6 -0
  67. package/dist/core/util.d.ts +2 -0
  68. package/dist/core/util.js +2 -0
  69. package/dist/core/workspace.js +22 -5
  70. package/dist/guest/apps/index.d.ts +10 -0
  71. package/dist/guest/apps/index.js +206 -0
  72. package/dist/guest/apps/resolvers.d.ts +8 -0
  73. package/dist/guest/apps/resolvers.js +90 -0
  74. package/dist/guest/audit-parse.d.ts +7 -0
  75. package/dist/guest/audit-parse.js +24 -0
  76. package/dist/guest/audit.d.ts +4 -0
  77. package/dist/guest/audit.js +47 -0
  78. package/dist/guest/daemon/relays.d.ts +1 -0
  79. package/dist/guest/daemon/relays.js +18 -0
  80. package/dist/guest/daemon.js +4 -0
  81. package/dist/guest/env.d.ts +1 -0
  82. package/dist/guest/env.js +3 -1
  83. package/dist/guest/helpers.d.ts +1 -1
  84. package/dist/guest/main.js +15 -2
  85. package/dist/guest/netproxy.d.ts +1 -0
  86. package/dist/guest/netproxy.js +100 -0
  87. package/dist/guest/ptyhost.js +14 -4
  88. package/dist/guest/setup/claude.d.ts +0 -4
  89. package/dist/guest/setup/claude.js +48 -2
  90. package/dist/guest/setup/network.d.ts +9 -0
  91. package/dist/guest/setup/network.js +97 -4
  92. package/dist/guest/setup/user.js +4 -0
  93. package/dist/guest/toolchains/native/mesa.js +13 -3
  94. package/dist/guest/toolchains/plan.d.ts +3 -1
  95. package/dist/guest/toolchains/plan.js +53 -9
  96. package/dist/guest/toolchains/python.js +26 -2
  97. package/dist/guest/toolchains/vscode-profile.d.ts +30 -0
  98. package/dist/guest/toolchains/vscode-profile.js +69 -0
  99. package/dist/guest/toolchains/vscode.d.ts +17 -0
  100. package/dist/guest/toolchains/vscode.js +87 -1
  101. package/dist/host/attach.js +2 -1
  102. package/dist/host/audit/collect.d.ts +34 -0
  103. package/dist/host/audit/collect.js +126 -0
  104. package/dist/host/audit/log.d.ts +46 -0
  105. package/dist/host/audit/log.js +143 -0
  106. package/dist/host/generate.d.ts +6 -0
  107. package/dist/host/generate.js +10 -0
  108. package/dist/host/launch/mappings.js +5 -1
  109. package/dist/host/launch/stage.js +1 -1
  110. package/dist/host/motw.d.ts +17 -0
  111. package/dist/host/motw.js +74 -0
  112. package/dist/host/platform.js +3 -0
  113. package/dist/host/port-forward.d.ts +8 -0
  114. package/dist/host/port-forward.js +37 -0
  115. package/dist/host/repo-guard.d.ts +10 -3
  116. package/dist/host/repo-guard.js +142 -14
  117. package/dist/host/stops.d.ts +67 -0
  118. package/dist/host/stops.js +126 -0
  119. package/dist/host/terminal.d.ts +8 -0
  120. package/dist/host/terminal.js +45 -1
  121. package/dist/host/workspace-view/index.d.ts +20 -0
  122. package/dist/host/workspace-view/index.js +53 -0
  123. package/dist/host/workspace-view/rules.d.ts +26 -0
  124. package/dist/host/workspace-view/rules.js +93 -0
  125. package/dist/host/workspace-view/view.d.ts +50 -0
  126. package/dist/host/workspace-view/view.js +281 -0
  127. package/dist/runtime/daemon.js +1319 -552
  128. package/dist/runtime/db.js +168 -13
  129. package/dist/runtime/main.js +2898 -608
  130. package/dist/runtime/netproxy.js +25936 -0
  131. package/dist/runtime/view.js +272 -23
  132. package/dist/selftest/audit.test.d.ts +2 -0
  133. package/dist/selftest/audit.test.js +57 -0
  134. package/dist/selftest/channel.test.js +7 -0
  135. package/dist/selftest/cli.test.js +47 -17
  136. package/dist/selftest/fixtures.d.ts +45 -0
  137. package/dist/selftest/fixtures.js +3 -1
  138. package/dist/selftest/generate.test.js +7 -2
  139. package/dist/selftest/index.js +5 -2
  140. package/dist/selftest/live-network.test.d.ts +2 -0
  141. package/dist/selftest/live-network.test.js +63 -0
  142. package/dist/selftest/local-config.test.js +16 -3
  143. package/dist/selftest/network.test.js +12 -0
  144. package/dist/selftest/projects.test.js +1 -1
  145. package/dist/selftest/repo-guard.test.js +59 -1
  146. package/dist/selftest/sync.test.js +50 -10
  147. package/dist/selftest/toolchains.test.js +30 -0
  148. package/dist/selftest/workspace-view.test.d.ts +2 -0
  149. package/dist/selftest/workspace-view.test.js +89 -0
  150. package/dist/shared/apps.d.ts +49 -0
  151. package/dist/shared/apps.js +114 -0
  152. package/dist/shared/netpolicy.d.ts +27 -0
  153. package/dist/shared/netpolicy.js +51 -0
  154. package/dist/shared/pty-client.d.ts +9 -4
  155. package/dist/shared/pty-client.js +139 -17
  156. package/dist/shared/rundir.d.ts +2 -0
  157. package/dist/shared/rundir.js +2 -0
  158. package/dist/shared/schema.d.ts +10 -0
  159. package/dist/shared/schema.js +2 -0
  160. package/dist/shared/system.d.ts +1 -0
  161. package/dist/shared/system.js +1 -0
  162. package/dist/sync/claude-mem.js +3 -2
  163. package/dist/sync/history.d.ts +6 -0
  164. package/dist/sync/history.js +26 -11
  165. package/dist/sync/watcher.js +78 -6
  166. package/package.json +2 -1
  167. package/sandbox/cargoshim.cs +56 -0
  168. package/sandbox/cpucap.cs +33 -0
  169. package/sandbox/cpucap.ps1 +4 -35
  170. package/sandbox/logon.cmd +6 -0
  171. package/sandbox/memlog.ps1 +34 -0
  172. package/sandbox/pagefile.cs +31 -0
  173. package/sandbox/pagefile.ps1 +21 -0
  174. package/sandbox/ptyhost.cs +105 -7
  175. package/schemas/config.schema.json +303 -2
  176. package/schemas/sandbox-config.schema.json +126 -12
  177. package/dist/cli/menu-multi.js +0 -82
  178. package/dist/cli/menu.d.ts +0 -14
  179. package/dist/cli/menu.js +0 -290
  180. package/dist/cli/settings/edit.d.ts +0 -13
  181. package/dist/cli/settings/edit.js +0 -19
  182. package/dist/cli/settings/index.d.ts +0 -8
  183. package/dist/cli/settings/index.js +0 -131
  184. package/dist/cli/settings/prompts.d.ts +0 -7
  185. package/dist/cli/settings/prompts.js +0 -48
  186. package/dist/cli/settings/schema.d.ts +0 -33
  187. package/dist/cli/settings/schema.js +0 -29
@@ -19,6 +19,7 @@ export declare const SandboxStatus: z.ZodObject<{
19
19
  workspaceHost: z.ZodString;
20
20
  guestWorkspace: z.ZodString;
21
21
  headless: z.ZodBoolean;
22
+ addresses: z.ZodOptional<z.ZodArray<z.ZodString>>;
22
23
  }, z.core.$strip>;
23
24
  /**
24
25
  * A project the host registered after boot (sandbox claude from another folder): everything the guest needs to
@@ -118,6 +119,9 @@ export declare const ProjectSpec: z.ZodObject<{
118
119
  }, z.core.$strict>;
119
120
  vscode: z.ZodObject<{
120
121
  enabled: z.ZodBoolean;
122
+ setup: z.ZodBoolean;
123
+ recommended: z.ZodBoolean;
124
+ extensions: z.ZodArray<z.ZodString>;
121
125
  }, z.core.$strict>;
122
126
  environment: z.ZodRecord<z.ZodString, z.ZodString>;
123
127
  }, z.core.$strict>;
@@ -259,6 +263,9 @@ export declare const GuestRequest: z.ZodDiscriminatedUnion<[z.ZodObject<{
259
263
  }, z.core.$strict>;
260
264
  vscode: z.ZodObject<{
261
265
  enabled: z.ZodBoolean;
266
+ setup: z.ZodBoolean;
267
+ recommended: z.ZodBoolean;
268
+ extensions: z.ZodArray<z.ZodString>;
262
269
  }, z.core.$strict>;
263
270
  environment: z.ZodRecord<z.ZodString, z.ZodString>;
264
271
  }, z.core.$strict>;
@@ -408,6 +415,9 @@ export declare const GuestRequest: z.ZodDiscriminatedUnion<[z.ZodObject<{
408
415
  }, z.core.$strict>;
409
416
  vscode: z.ZodObject<{
410
417
  enabled: z.ZodBoolean;
418
+ setup: z.ZodBoolean;
419
+ recommended: z.ZodBoolean;
420
+ extensions: z.ZodArray<z.ZodString>;
411
421
  }, z.core.$strict>;
412
422
  environment: z.ZodRecord<z.ZodString, z.ZodString>;
413
423
  }, z.core.$strict>;
@@ -19,6 +19,8 @@ export const SandboxStatus = z.object({
19
19
  workspaceHost: Text(),
20
20
  guestWorkspace: Text(),
21
21
  headless: z.boolean(),
22
+ /** The sandbox's IPv4 addresses (for network.forwardPorts; the host only uses ones on its Hyper-V network). */
23
+ addresses: z.array(z.string().max(45)).max(8).optional(),
22
24
  });
23
25
  const Version = z.strictObject({ spec: Text(128), source: Text(256) });
24
26
  const DatabasePlan = z.strictObject({
@@ -12,4 +12,5 @@ export declare const SYSTEM: {
12
12
  readonly robocopy: () => string;
13
13
  readonly shutdown: () => string;
14
14
  readonly dism: () => string;
15
+ readonly msiexec: () => string;
15
16
  };
@@ -16,4 +16,5 @@ export const SYSTEM = {
16
16
  robocopy: () => system32('Robocopy.exe'),
17
17
  shutdown: () => system32('shutdown.exe'),
18
18
  dism: () => system32('Dism.exe'),
19
+ msiexec: () => system32('msiexec.exe'),
19
20
  };
@@ -1,7 +1,7 @@
1
1
  // claude-mem data (~/.claude-mem) between this PC and the sandbox's persistent store.
2
2
  //
3
3
  // IN (this PC -> sandbox): the whole folder is mirrored; nothing flows to your PC in that direction.
4
- // OUT (sandbox -> this PC): ONLY the SQLite database files come back. Never settings, never the Chroma vector store:
4
+ // OUT (sandbox -> this PC), only with claudeProfile.syncClaudeMemBack: ONLY the SQLite database files come back. Never settings, never the Chroma vector store:
5
5
  // Chroma keeps Python pickle files, and a crafted pickle runs code when loaded, so copying it back would let a
6
6
  // compromised sandbox execute code on your PC. claude-mem rebuilds its vector index from the database by itself.
7
7
  import fs from 'node:fs';
@@ -10,7 +10,8 @@ import _ from 'lodash';
10
10
  import { dirExists, homeDir, isLink } from '../core/util.js';
11
11
  import { safeRemove } from '../shared/safe-remove.js';
12
12
  import { assertTestWritable } from '../host/test-guard.js';
13
- const SKIP_IN = /(^logs$|\.pid$|\.port$|\.lock$|^worker.*\.json$|\.incoming$|\.conflict-\d+$)/i;
13
+ // Never in: its settings (provider API keys), .env files and backups; nor run-time files that mean nothing elsewhere.
14
+ const SKIP_IN = /(^logs$|^backups?$|^settings\.json$|^\.env|\.pid$|\.port$|\.lock$|^worker.*\.json$|\.incoming$|\.conflict-\d+$)/i;
14
15
  /** The only files that may come back from the sandbox. */
15
16
  const DATABASE = /^claude-mem\.db(-wal|-shm)?$/i;
16
17
  const MAX_DB_BYTES = 2 * 1024 * 1024 * 1024;
@@ -7,6 +7,11 @@ declare const SyncStateSchema: z.ZodObject<{
7
7
  guestWorkspace: z.ZodString;
8
8
  syncHistory: z.ZodOptional<z.ZodBoolean>;
9
9
  syncClaudeMem: z.ZodOptional<z.ZodBoolean>;
10
+ syncClaudeMemBack: z.ZodOptional<z.ZodBoolean>;
11
+ historyScope: z.ZodOptional<z.ZodEnum<{
12
+ project: "project";
13
+ all: "all";
14
+ }>>;
10
15
  memHostStamp: z.ZodOptional<z.ZodNumber>;
11
16
  memGuestStamp: z.ZodOptional<z.ZodNumber>;
12
17
  projects: z.ZodOptional<z.ZodArray<z.ZodObject<{
@@ -28,6 +33,7 @@ export interface SyncPaths extends WorkspacePair {
28
33
  /** Brings this PC's history into the sandbox state (run before the sandbox starts). Returns files copied. */
29
34
  export declare const syncHistoryIn: (p: SyncPaths, options?: {
30
35
  promptHistory?: boolean;
36
+ scope?: "project" | "all";
31
37
  }) => number;
32
38
  /** Copies new sandbox history back to this PC (safe while the sandbox runs: transcripts are append-only). */
33
39
  export declare const syncHistoryOut: (p: SyncPaths) => number;
@@ -7,8 +7,11 @@
7
7
  //
8
8
  // Safety (the sandbox side is untrusted):
9
9
  // - links are never followed or written through, on either side;
10
- // - what comes BACK is limited to transcripts (.jsonl), prompt history and to-do lists (.json), each capped in
11
- // size, and never Claude's memory folders. Nothing executable or instruction-like flows to this PC.
10
+ // - what comes BACK is limited to the sandbox's own projects: their transcripts (.jsonl) and prompt-history lines,
11
+ // plus to-do lists (.json), each capped in size, and never Claude's memory folders. A sandbox cannot plant a
12
+ // conversation for another project on this PC (claude --resume there would load it);
13
+ // - what goes IN is, by default (historyScope "project"), only the sandbox's own projects: other projects'
14
+ // conversations, file backups and prompts are not the agent's business.
12
15
  import fs from 'node:fs';
13
16
  import path from 'node:path';
14
17
  import _ from 'lodash';
@@ -25,7 +28,7 @@ const MAX_FILE_BYTES = 100 * 1024 * 1024;
25
28
  const AbsolutePath = z.string().max(1024).refine((p) => path.isAbsolute(p), 'must be an absolute path');
26
29
  const SyncStateSchema = z.object({
27
30
  hostClaudeDir: AbsolutePath, hostWorkspace: AbsolutePath, guestWorkspace: z.string().max(1024),
28
- syncHistory: z.boolean().optional(), syncClaudeMem: z.boolean().optional(),
31
+ syncHistory: z.boolean().optional(), syncClaudeMem: z.boolean().optional(), syncClaudeMemBack: z.boolean().optional(), historyScope: z.enum(['project', 'all']).optional(),
29
32
  /** Newest claude-mem database time on each side right after the last sync (detects edits on both sides). */
30
33
  memHostStamp: z.number().optional(), memGuestStamp: z.number().optional(),
31
34
  /** Projects registered while the sandbox ran (their history goes back to their own folders). */
@@ -91,6 +94,16 @@ const isJsonObject = (line) => { try {
91
94
  catch {
92
95
  return false;
93
96
  } };
97
+ /** A prompt-history line whose project is one of `workspaces` (on this PC's side). */
98
+ const inProjects = (line, workspaces) => {
99
+ try {
100
+ const project = JSON.parse(line).project;
101
+ return typeof project === 'string' && workspaces.some((w) => path.resolve(w).toLowerCase() === path.resolve(project).toLowerCase());
102
+ }
103
+ catch {
104
+ return false;
105
+ }
106
+ };
94
107
  const lineTime = (line) => { try {
95
108
  return Number(JSON.parse(line).timestamp) || 0;
96
109
  }
@@ -102,22 +115,24 @@ const pairsOf = (p) => _.sortBy([{ hostWorkspace: p.hostWorkspace, guestWorkspac
102
115
  const translateAll = (text, pairs) => pairs.reduce((out, [from, to]) => translatePaths(out, from, to), text);
103
116
  /** Brings this PC's history into the sandbox state (run before the sandbox starts). Returns files copied. */
104
117
  export const syncHistoryIn = (p, options = {}) => {
118
+ const all = options.scope === 'all';
105
119
  assertTestWritable(p.guestClaudeDir);
106
120
  const pairs = pairsOf(p);
107
121
  const byHost = new Map(pairs.map((x) => [projectFolderName(x.hostWorkspace), projectFolderName(x.guestWorkspace)]));
108
122
  const guestNames = new Set(pairs.map((x) => projectFolderName(x.guestWorkspace)));
109
123
  const transform = (t) => translateAll(t, pairs.map((x) => [x.hostWorkspace, x.guestWorkspace]));
110
124
  const hostProjects = path.join(p.hostClaudeDir, 'projects');
111
- const names = dirExists(hostProjects) ? fs.readdirSync(hostProjects).filter((name) => isSafeFileName(name) && (!guestNames.has(name) || byHost.has(name))) : [];
125
+ const names = dirExists(hostProjects) ? fs.readdirSync(hostProjects).filter((name) => isSafeFileName(name) && (all ? !guestNames.has(name) || byHost.has(name) : byHost.has(name))) : [];
112
126
  const projects = _.sumBy(names, (name) => byHost.has(name)
113
127
  ? copyNewer(path.join(hostProjects, name), path.join(p.guestClaudeDir, 'projects', byHost.get(name)), { transform })
114
128
  : copyNewer(path.join(hostProjects, name), path.join(p.guestClaudeDir, 'projects', name)));
115
- const sessions = _.sumBy(SESSION_DIRS, (dir) => copyNewer(path.join(p.hostClaudeDir, dir), path.join(p.guestClaudeDir, dir)));
129
+ // To-do lists, plans and file backups are kept by session, not by project: only with historyScope "all".
130
+ const sessions = all ? _.sumBy(SESSION_DIRS, (dir) => copyNewer(path.join(p.hostClaudeDir, dir), path.join(p.guestClaudeDir, dir))) : 0;
116
131
  if (options.promptHistory === false)
117
132
  return projects + sessions;
118
133
  // Prompt history: rebuild the sandbox copy from this PC's file plus anything only the sandbox has.
119
134
  const target = path.join(p.guestClaudeDir, 'history.jsonl');
120
- const hostLines = readLines(path.join(p.hostClaudeDir, 'history.jsonl')).map(transform);
135
+ const hostLines = readLines(path.join(p.hostClaudeDir, 'history.jsonl')).filter((l) => all || inProjects(l, pairs.map((x) => x.hostWorkspace))).map(transform);
121
136
  const known = new Set(hostLines);
122
137
  const merged = _.sortBy([...hostLines, ...readLines(target).filter((l) => !known.has(l))], lineTime);
123
138
  if (merged.length) {
@@ -136,15 +151,15 @@ export const syncHistoryOut = (p) => {
136
151
  const transform = (t) => translateAll(t, pairs);
137
152
  const guestProjectsDir = path.join(p.guestClaudeDir, 'projects');
138
153
  const names = dirExists(guestProjectsDir) && !isLink(guestProjectsDir) ? fs.readdirSync(guestProjectsDir).filter(isSafeFileName) : [];
139
- const projects = _.sumBy(names, (name) => byGuest.has(name)
140
- ? copyNewer(path.join(guestProjectsDir, name), path.join(p.hostClaudeDir, 'projects', byGuest.get(name)), { accept: transcript, skipDirs: NO_MEMORY, transform })
141
- : copyNewer(path.join(guestProjectsDir, name), path.join(p.hostClaudeDir, 'projects', name), { accept: transcript, skipDirs: NO_MEMORY }));
154
+ // Only this sandbox's projects: a transcript under any other folder name would be planted into that project on this PC.
155
+ const projects = _.sumBy(names.filter((name) => byGuest.has(name)), (name) => copyNewer(path.join(guestProjectsDir, name), path.join(p.hostClaudeDir, 'projects', byGuest.get(name)), { accept: transcript, skipDirs: NO_MEMORY, transform }));
142
156
  // Only to-do lists come back besides transcripts; plans and file backups stay in the sandbox.
143
157
  const sessions = copyNewer(path.join(p.guestClaudeDir, 'todos'), path.join(p.hostClaudeDir, 'todos'), { accept: json });
144
158
  const hostFile = path.join(p.hostClaudeDir, 'history.jsonl');
145
159
  const known = new Set(readLines(hostFile));
146
- // Only prompt-history entries (one JSON object per line) come back, whatever else the sandbox wrote in that file.
147
- const missing = readLines(path.join(p.guestClaudeDir, 'history.jsonl')).filter(isJsonObject).map(transform).filter((l) => !known.has(l));
160
+ // Only prompt-history entries (one JSON object per line) of this sandbox's projects come back.
161
+ const hostWorkspaces = pairsOf(p).map((x) => x.hostWorkspace);
162
+ const missing = readLines(path.join(p.guestClaudeDir, 'history.jsonl')).filter(isJsonObject).map(transform).filter((l) => inProjects(l, hostWorkspaces) && !known.has(l));
148
163
  if (missing.length) {
149
164
  ensureDir(p.hostClaudeDir);
150
165
  fs.appendFileSync(hostFile, missing.join('\n') + '\n', 'utf8');
@@ -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.0",
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