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
@@ -0,0 +1,126 @@
1
+ // Why the last sandbox stopped, when nobody stopped it. The background watcher writes a host-only "life" record (when
2
+ // the sandbox started, when it was last seen running, how it ended) and keeps this PC awake while it runs
3
+ // (sandbox.keepAwake). When the PC restarts, sleeps or crashes, the watcher stops too and the record has no end: the
4
+ // next launch reads Windows' System log from that moment on and says what happened, and what keeps it from happening.
5
+ import { spawn, spawnSync } from 'node:child_process';
6
+ import path from 'node:path';
7
+ import { z } from 'zod';
8
+ import { SYSTEM } from '../shared/system.js';
9
+ import { readValid, writeJsonAtomic } from '../shared/rundir.js';
10
+ import { isWin } from '../core/util.js';
11
+ import { refuseInSelfTest } from './test-guard.js';
12
+ const Iso = z.string().max(40);
13
+ const Life = z.object({
14
+ started: Iso, lastSeen: Iso, keepAwake: z.boolean(),
15
+ ended: z.object({ at: Iso, cause: z.object({ kind: z.enum(['updates', 'restart', 'crash', 'sleep', 'memory', 'sandboxCrash']), at: Iso, text: z.string().max(400) }).nullable() }).optional(),
16
+ reported: z.boolean().optional(),
17
+ });
18
+ const lifeFile = (storageRoot, dataPath) => path.join(storageRoot, 'sync', path.basename(dataPath) + '-life.json');
19
+ export const readLife = (storageRoot, dataPath) => readValid(Life, lifeFile(storageRoot, dataPath));
20
+ const writeLife = (storageRoot, dataPath, life) => writeJsonAtomic(lifeFile(storageRoot, dataPath), life);
21
+ /** At launch: a fresh record (the previous one has been reported by then). */
22
+ export const startLife = (storageRoot, dataPath, keepAwake, now = new Date()) => writeLife(storageRoot, dataPath, { started: now.toISOString(), lastSeen: now.toISOString(), keepAwake });
23
+ /** Every minute while the sandbox runs. */
24
+ export const beatLife = (storageRoot, dataPath, now = new Date()) => {
25
+ const life = readLife(storageRoot, dataPath);
26
+ if (life && !life.ended)
27
+ writeLife(storageRoot, dataPath, { ...life, lastSeen: now.toISOString() });
28
+ };
29
+ /** The watcher saw the sandbox close while this PC kept running: closed by you (no cause), or by sleep / low memory. */
30
+ export const endLife = (storageRoot, dataPath, cause, now = new Date()) => {
31
+ const life = readLife(storageRoot, dataPath);
32
+ if (life && !life.ended)
33
+ writeLife(storageRoot, dataPath, { ...life, ended: { at: now.toISOString(), cause } });
34
+ };
35
+ const StopEvents = z.array(z.object({ at: Iso, id: z.number().int(), provider: z.string().max(200), process: z.string().max(400).nullable().transform((p) => p ?? '') })).max(100);
36
+ const UPDATERS = /^(trustedinstaller|mousocoreworker|musnotification(ux)?|usoclient|wuauclt|tiworker|updateorchestrator)\.exe$/i;
37
+ /**
38
+ * The first event that ends a running sandbox (pure): Windows inside the sandbox crashing (Hyper-V 18590; `process`
39
+ * holds its message with the stop code), a restart or shutdown, a crash or power loss, sleep, low memory. Switching
40
+ * user does not end it (seen on 13 Oct 2026: the sandbox ran on after a switch).
41
+ */
42
+ export const explainStop = (events) => {
43
+ const causes = events.flatMap((e) => {
44
+ const p = e.provider.toLowerCase();
45
+ if (e.id === 1074 && p.includes('user32')) {
46
+ const exe = e.process.split(/[\\/]/).pop()?.replace(/\s*\(.*$/, '') ?? '';
47
+ return UPDATERS.test(exe)
48
+ ? [{ kind: 'updates', at: e.at, text: 'Windows restarted this PC to install updates' }]
49
+ : [{ kind: 'restart', at: e.at, text: 'this PC was restarted or shut down' + (exe ? ' (by ' + exe + ')' : '') }];
50
+ }
51
+ if ((e.id === 41 && p.includes('kernel-power')) || (e.id === 6008 && p.includes('eventlog')))
52
+ return [{ kind: 'crash', at: e.at, text: 'Windows stopped unexpectedly (a crash, a power cut, or the power button held down)' }];
53
+ if (e.id === 42 && p.includes('kernel-power'))
54
+ return [{ kind: 'sleep', at: e.at, text: 'this PC went to sleep or hibernated' }];
55
+ if (e.id === 2004 && p.includes('resource-exhaustion'))
56
+ return [{ kind: 'memory', at: e.at, text: 'this PC ran out of memory' }];
57
+ if (e.id === 18590 && p.includes('hyper-v-worker')) {
58
+ const code = /ErrorCode0:\s*(0x[0-9a-f]{1,8})/i.exec(e.process)?.[1];
59
+ const sub = /ErrorCode1:\s*(0x[0-9a-f]{1,16})/i.exec(e.process)?.[1];
60
+ return [{ kind: 'sandboxCrash', at: e.at, text: 'Windows inside the sandbox crashed' + (code ? ' (stop code ' + code + (sub ? ', ' + sub : '') + ')' : '') }];
61
+ }
62
+ return [];
63
+ });
64
+ return causes.sort((a, b) => Date.parse(a.at) - Date.parse(b.at))[0] ?? null;
65
+ };
66
+ const ADVICE = {
67
+ updates: 'Before leaving agents overnight, restart for pending updates first or pause them (Settings > Windows Update > Pause updates).',
68
+ restart: 'If that was not you, check Settings > Windows Update and Event Viewer > Windows Logs > System.',
69
+ crash: 'Event Viewer > Windows Logs > System has the details; a laptop also needs to stay plugged in.',
70
+ sleep: 'sandbox.keepAwake keeps this PC awake while a sandbox runs (closing a laptop lid still sleeps it). Check it is on, and that sleep is not forced by a policy.',
71
+ sandboxCrash: 'C:\\AgentData\\logs\\memory-*.log shows what used memory just before. Fewer builds at once inside the sandbox (e.g. cargo build.jobs) makes it less likely.',
72
+ memory: 'Give the sandbox less RAM (sandbox.maxMemoryMb, sandbox.reserveHostMb) or run fewer builds at once inside it.',
73
+ };
74
+ /** System log and Hyper-V events since `since` that can end a sandbox (Windows only; empty when the logs cannot be read). */
75
+ export const readStopEvents = (since) => {
76
+ if (!isWin)
77
+ return [];
78
+ refuseInSelfTest('read the Windows event log');
79
+ const script = "$ErrorActionPreference='SilentlyContinue'; $t=[datetime]::Parse('" + since.toISOString() + "').ToLocalTime(); $e = Get-WinEvent -FilterHashtable @(@{LogName='System'; Id=1074,6008,41,42,2004; StartTime=$t}, @{LogName='Microsoft-Windows-Hyper-V-Worker-Admin'; Id=18590; StartTime=$t}) -MaxEvents 100 | ForEach-Object { [pscustomobject]@{ at=$_.TimeCreated.ToUniversalTime().ToString('o'); id=$_.Id; provider=$_.ProviderName; process=$(if ($_.Id -eq 1074) { [string]$_.Properties[0].Value } elseif ($_.Id -eq 18590) { ([string]$_.Message).Substring(0, [math]::Min(390, ([string]$_.Message).Length)) } else { '' }) } }; ConvertTo-Json -Compress -InputObject @($e)";
80
+ const result = spawnSync(SYSTEM.powershell(), ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')], { encoding: 'utf8', windowsHide: true, timeout: 60000 });
81
+ try {
82
+ return StopEvents.parse(JSON.parse(result.stdout || '[]'));
83
+ }
84
+ catch {
85
+ return [];
86
+ }
87
+ };
88
+ /** Looking back from the last time the sandbox was seen running (a minute between checks, plus margin). */
89
+ export const causeSince = (lastSeen) => explainStop(readStopEvents(new Date(Date.parse(lastSeen) - 2 * 60 * 1000)));
90
+ const when = (iso) => new Date(iso).toLocaleString(undefined, { weekday: 'short', hour: '2-digit', minute: '2-digit' });
91
+ /**
92
+ * At launch: one warning when the last sandbox ended without anyone closing it (the PC restarted, crashed, slept or ran
93
+ * out of memory), then never again for that run. `watcherAlive` = its watcher is still running (then it did not end).
94
+ */
95
+ export const lastStopWarning = (storageRoot, dataPath, watcherAlive, find = causeSince) => {
96
+ const life = readLife(storageRoot, dataPath);
97
+ if (!life || life.reported || watcherAlive)
98
+ return null;
99
+ writeLife(storageRoot, dataPath, { ...life, reported: true });
100
+ if (life.ended && !life.ended.cause)
101
+ return null;
102
+ const cause = life.ended ? life.ended.cause : find(life.lastSeen);
103
+ const what = cause ? cause.text + ' at ' + when(cause.at) + '. ' + ADVICE[cause.kind] : 'this PC stopped (Windows logged no reason). ' + ADVICE.crash;
104
+ return 'The last sandbox did not close normally: it was last seen running at ' + when(life.lastSeen) + ', and ' + what + ' Agent conversations are kept: /resume in Claude picks one up.';
105
+ };
106
+ // ---- Windows Update and keeping awake ----------------------------------------------------------------------------
107
+ /** Windows has installed updates and waits to restart: it may do so on its own, outside your active hours. */
108
+ export const updateRestartPending = () => isWin && [
109
+ 'HKLM\\SOFTWARE\\Microsoft\\WindowsUpdate\\Auto Update\\RebootRequired',
110
+ 'HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Component Based Servicing\\RebootPending',
111
+ ].some((key) => spawnSync(SYSTEM.reg(), ['query', key], { windowsHide: true, stdio: 'ignore', timeout: 15000 }).status === 0);
112
+ export const UPDATE_RESTART_WARNING = 'Windows is waiting to restart for updates and may do so on its own (often overnight), which closes the sandbox. Restart first, or pause updates: Settings > Windows Update > Pause updates.';
113
+ /**
114
+ * sandbox.keepAwake: asks Windows not to sleep while the sandbox runs (as a video player does; the screen may still turn
115
+ * off). A small PowerShell holds the request until `ownerPid` exits, so it never outlives the watcher.
116
+ */
117
+ export const keepAwake = (ownerPid) => {
118
+ if (!isWin)
119
+ return null;
120
+ refuseInSelfTest('keep this PC awake');
121
+ const script = "Add-Type -Name Power -Namespace AgentSandbox -MemberDefinition '[DllImport(\"kernel32.dll\")] public static extern uint SetThreadExecutionState(uint f);'; "
122
+ + '[void][AgentSandbox.Power]::SetThreadExecutionState([uint32]2147483649); Wait-Process -Id ' + Math.trunc(ownerPid) + ' -ErrorAction SilentlyContinue';
123
+ const child = spawn(SYSTEM.powershell(), ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')], { stdio: 'ignore', windowsHide: true });
124
+ child.on('error', () => undefined);
125
+ return child;
126
+ };
@@ -29,4 +29,12 @@ export declare const ensureTerminal: (dataPath: string, refresh: boolean, option
29
29
  exe: string | null;
30
30
  message: string;
31
31
  }>;
32
+ /** Where the sandbox finds conpty.dll + OpenConsole.exe (guest/ptyhost.ts points ptyhost at it). */
33
+ export declare const conptyDir: (dataPath: string) => string;
34
+ /**
35
+ * Microsoft's newer pseudo console (the Microsoft.Windows.Console.ConPTY package: conpty.dll and OpenConsole.exe, the
36
+ * same one Windows Terminal and VS Code use), downloaded once and verified against the SHA-512 NuGet publishes. Agent
37
+ * sessions use it instead of the one built into Windows, which re-renders the screen and garbles it after resizes.
38
+ */
39
+ export declare const ensureConpty: (dataPath: string, refresh: boolean) => Promise<string>;
32
40
  export {};
@@ -2,7 +2,7 @@
2
2
  // logon.cmd can open it the moment the sandbox logs in (no PowerShell window first).
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
5
- import { githubAsset, installArchive } from '../shared/download.js';
5
+ import { download, extractArchive, fetchJson, githubAsset, installArchive, moveIntoPlace, stagingFolder } from '../shared/download.js';
6
6
  import { safeRemove } from '../shared/safe-remove.js';
7
7
  import { assertTestWritable } from './test-guard.js';
8
8
  import { z } from 'zod';
@@ -88,6 +88,50 @@ export const ensureTerminal = async (dataPath, refresh, options = {}) => {
88
88
  }
89
89
  return result;
90
90
  };
91
+ // ---- conpty.dll ------------------------------------------------------------------------------------------------------
92
+ /** Where the sandbox finds conpty.dll + OpenConsole.exe (guest/ptyhost.ts points ptyhost at it). */
93
+ export const conptyDir = (dataPath) => path.join(dataPath, 'tools', 'conpty');
94
+ const NUGET_CONPTY = 'https://api.nuget.org/v3-flatcontainer/microsoft.windows.console.conpty/';
95
+ const NugetVersions = z.object({ versions: z.array(z.string()) });
96
+ const NugetLeaf = z.object({ catalogEntry: z.string().url() });
97
+ const NugetCatalog = z.object({ packageHash: z.string(), packageHashAlgorithm: z.literal('SHA512') });
98
+ /**
99
+ * Microsoft's newer pseudo console (the Microsoft.Windows.Console.ConPTY package: conpty.dll and OpenConsole.exe, the
100
+ * same one Windows Terminal and VS Code use), downloaded once and verified against the SHA-512 NuGet publishes. Agent
101
+ * sessions use it instead of the one built into Windows, which re-renders the screen and garbles it after resizes.
102
+ */
103
+ export const ensureConpty = async (dataPath, refresh) => {
104
+ const dir = conptyDir(dataPath);
105
+ assertTestWritable(dir);
106
+ assertNoLinks(dataPath, dir);
107
+ if (fileExists(path.join(dir, 'conpty.dll')) && fileExists(path.join(dir, 'OpenConsole.exe')) && !refresh)
108
+ return 'cached';
109
+ const arch = process.arch === 'arm64' ? 'arm64' : 'x64';
110
+ const work = stagingFolder(dir);
111
+ try {
112
+ const version = NugetVersions.parse(await fetchJson(NUGET_CONPTY + 'index.json')).versions.filter((v) => !v.includes('-')).pop();
113
+ if (!version)
114
+ throw new Error('no release found');
115
+ const leaf = NugetLeaf.parse(await fetchJson('https://api.nuget.org/v3/registration5-gz-semver2/microsoft.windows.console.conpty/' + version + '.json'));
116
+ const sha512 = Buffer.from(NugetCatalog.parse(await fetchJson(leaf.catalogEntry)).packageHash, 'base64').toString('hex');
117
+ const pkg = path.join(work, 'conpty.nupkg');
118
+ await download(NUGET_CONPTY + version + '/microsoft.windows.console.conpty.' + version + '.nupkg', pkg, { sha512 });
119
+ const files = { 'conpty.dll': 'runtimes/win-' + arch + '/native/conpty.dll', 'OpenConsole.exe': 'build/native/runtimes/' + arch + '/OpenConsole.exe' };
120
+ await extractArchive(pkg, path.join(work, 'x'), Object.values(files));
121
+ fs.mkdirSync(path.join(work, 'out'));
122
+ for (const [name, member] of Object.entries(files))
123
+ fs.copyFileSync(path.join(work, 'x', ...member.split('/')), path.join(work, 'out', name));
124
+ fs.writeFileSync(path.join(work, 'out', 'VERSION'), version);
125
+ moveIntoPlace(path.join(work, 'out'), dir);
126
+ return 'downloaded ' + version;
127
+ }
128
+ catch (e) {
129
+ return 'not downloaded, sessions use Windows\' own pseudo console (' + errMsg(e) + ')';
130
+ }
131
+ finally {
132
+ safeRemove(work, path.dirname(work));
133
+ }
134
+ };
91
135
  const installTerminal = async (dataPath, refresh) => {
92
136
  const exe = terminalExe(dataPath);
93
137
  const dir = terminalDir(dataPath);
@@ -0,0 +1,20 @@
1
+ import type { Config } from '../../core/types.js';
2
+ import { type ViewOptions } from './view.js';
3
+ export { IgnoreRules } from './rules.js';
4
+ export { WorkspaceView, type SyncStats, type ViewOptions } from './view.js';
5
+ /** The project's .git as the sandbox sees it: next to the workspace (C:\Workspace\app -> C:\Workspace\app.git). */
6
+ export declare const guestGitDir: (guestWorkspace: string) => string;
7
+ export interface ViewPlan {
8
+ options: ViewOptions; /** The project's real .git folder, mapped at options.guestGitDir (null: none). */
9
+ hostGit: string | null;
10
+ note?: string;
11
+ }
12
+ /**
13
+ * The view for a launch, or null when the folder itself is mapped: workspaceFilter off, or a git worktree (its .git is
14
+ * a file pointing elsewhere, which a view cannot follow; then the folder is mapped as before and a note says so).
15
+ */
16
+ export declare const planView: (config: Config, storageRoot: string, workspaceHost: string) => ViewPlan | null;
17
+ /** Why a filtered launch maps the folder itself after all (for the launch notes). */
18
+ export declare const viewSkipped: (config: Config, workspaceHost: string) => string | null;
19
+ export declare const saveActiveViews: (storageRoot: string, dataPath: string, views: ViewOptions[]) => void;
20
+ export declare const readActiveViews: (storageRoot: string, dataPath: string) => ViewOptions[];
@@ -0,0 +1,53 @@
1
+ // workspaceFilter in practice: which view a launch maps instead of the project, the project's .git mapped beside it,
2
+ // and the host-only list of views the background watcher keeps in step while the sandbox runs.
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { z } from 'zod';
6
+ import { readValid, writeJsonAtomic } from '../../shared/rundir.js';
7
+ import { WorkspaceView } from './view.js';
8
+ export { IgnoreRules } from './rules.js';
9
+ export { WorkspaceView } from './view.js';
10
+ /** The project's .git as the sandbox sees it: next to the workspace (C:\Workspace\app -> C:\Workspace\app.git). */
11
+ export const guestGitDir = (guestWorkspace) => guestWorkspace.replace(/[\\/]+$/, '') + '.git';
12
+ /**
13
+ * The view for a launch, or null when the folder itself is mapped: workspaceFilter off, or a git worktree (its .git is
14
+ * a file pointing elsewhere, which a view cannot follow; then the folder is mapped as before and a note says so).
15
+ */
16
+ export const planView = (config, storageRoot, workspaceHost) => {
17
+ if (!config.workspaceFilter.enabled)
18
+ return null;
19
+ const git = path.join(workspaceHost, '.git');
20
+ let gitIsDir = false;
21
+ try {
22
+ const s = fs.lstatSync(git);
23
+ if (s.isFile())
24
+ return null;
25
+ gitIsDir = s.isDirectory() && !s.isSymbolicLink();
26
+ }
27
+ catch { /* no .git */ }
28
+ // git access "none": the agent gets no .git at all.
29
+ const hostGit = gitIsDir && config.provisioning.git.enabled && config.provisioning.git.access !== 'none' ? git : null;
30
+ return {
31
+ options: { ...WorkspaceView.pathsFor(storageRoot, workspaceHost), exclude: config.workspaceFilter.exclude, include: config.workspaceFilter.include, guestGitDir: hostGit ? guestGitDir(config.guestWorkspace) : null },
32
+ hostGit,
33
+ };
34
+ };
35
+ /** Why a filtered launch maps the folder itself after all (for the launch notes). */
36
+ export const viewSkipped = (config, workspaceHost) => {
37
+ if (!config.workspaceFilter.enabled)
38
+ return null;
39
+ try {
40
+ if (fs.lstatSync(path.join(workspaceHost, '.git')).isFile())
41
+ return 'workspaceFilter: ' + workspaceHost + ' is a git worktree (.git is a file); the folder is mapped as it is, git-ignored files included.';
42
+ }
43
+ catch { /* fine */ }
44
+ return null;
45
+ };
46
+ // ---- the views the watcher keeps in step (host-only, next to the sync state) ------------------------------------------
47
+ const ActiveView = z.object({
48
+ host: z.string().max(1024), view: z.string().max(1024), state: z.string().max(1024),
49
+ exclude: z.array(z.string().max(500)).max(500), include: z.array(z.string().max(500)).max(500), guestGitDir: z.string().max(1024).nullable(),
50
+ });
51
+ const activeFile = (storageRoot, dataPath) => path.join(storageRoot, 'sync', path.basename(dataPath) + '-views.json');
52
+ export const saveActiveViews = (storageRoot, dataPath, views) => writeJsonAtomic(activeFile(storageRoot, dataPath), views.map((v) => ({ host: v.host, view: v.view, state: v.state, exclude: v.exclude ?? [], include: v.include ?? [], guestGitDir: v.guestGitDir ?? null })));
53
+ export const readActiveViews = (storageRoot, dataPath) => readValid(z.array(ActiveView).max(64), activeFile(storageRoot, dataPath)) ?? [];
@@ -0,0 +1,26 @@
1
+ export declare class IgnoreRules {
2
+ private readonly root;
3
+ private readonly include;
4
+ /** One matcher per folder that has a .gitignore (null = the folder has none), by its path relative to the root. */
5
+ private readonly perFolder;
6
+ private readonly extra;
7
+ private readonly forced;
8
+ /** `root`: the project folder on this PC (its .gitignore files are the rules); `exclude` / `include`: your patterns. */
9
+ constructor(root: string, exclude?: string[], include?: string[]);
10
+ /** One of your include patterns lets this path in, whatever .gitignore says (even inside an ignored folder). */
11
+ isForced(rel: string): boolean;
12
+ /** Could one of your include patterns match something inside this ignored folder (so a walk must look inside)? */
13
+ mayIncludeInside(dir: string): boolean;
14
+ /** Forget the .gitignore files read so far (one of them changed). */
15
+ reset(): void;
16
+ private matcher;
17
+ /**
18
+ * Is this one entry ignored, given that its parent folder is not? `rel` uses / and has no leading slash. Your include
19
+ * patterns win over everything, your exclude patterns over .gitignore.
20
+ */
21
+ private ignoredHere;
22
+ /** Is `rel` (a file, or a folder with isDir) ignored, counting its folders: inside an ignored folder is ignored. */
23
+ isIgnored(rel: string, isDir?: boolean): boolean;
24
+ /** For a walk that already checked the parent folder: is this child ignored? */
25
+ isIgnoredChild(rel: string, isDir: boolean): boolean;
26
+ }
@@ -0,0 +1,93 @@
1
+ // Which files of a project git ignores, read the way git reads them: the .gitignore in every folder (deeper files win,
2
+ // `!pattern` lets a file back in), .git/info/exclude, and nothing inside an ignored folder ever comes back. Plus your
3
+ // own extra patterns (workspaceFilter.exclude) and exceptions (workspaceFilter.include). Bounded reads: a .gitignore
4
+ // over 256 KB is treated as empty.
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import ignore from 'ignore';
8
+ const MAX_RULES_BYTES = 256 * 1024;
9
+ const readRules = (file) => {
10
+ try {
11
+ const stat = fs.lstatSync(file);
12
+ return stat.isFile() && stat.size <= MAX_RULES_BYTES ? fs.readFileSync(file, 'utf8') : '';
13
+ }
14
+ catch {
15
+ return '';
16
+ }
17
+ };
18
+ export class IgnoreRules {
19
+ root;
20
+ include;
21
+ /** One matcher per folder that has a .gitignore (null = the folder has none), by its path relative to the root. */
22
+ perFolder = new Map();
23
+ extra;
24
+ forced;
25
+ /** `root`: the project folder on this PC (its .gitignore files are the rules); `exclude` / `include`: your patterns. */
26
+ constructor(root, exclude = [], include = []) {
27
+ this.root = root;
28
+ this.include = include;
29
+ this.extra = ignore().add(exclude);
30
+ this.forced = ignore().add(include);
31
+ }
32
+ /** One of your include patterns lets this path in, whatever .gitignore says (even inside an ignored folder). */
33
+ isForced(rel) { return this.include.length > 0 && this.forced.ignores(rel); }
34
+ /** Could one of your include patterns match something inside this ignored folder (so a walk must look inside)? */
35
+ mayIncludeInside(dir) {
36
+ return this.include.some((p) => { const clean = p.replace(/^\//, ''); return clean.startsWith('**') || clean.startsWith(dir + '/'); });
37
+ }
38
+ /** Forget the .gitignore files read so far (one of them changed). */
39
+ reset() { this.perFolder.clear(); }
40
+ matcher(folder) {
41
+ if (this.perFolder.has(folder))
42
+ return this.perFolder.get(folder);
43
+ const dir = path.join(this.root, ...folder.split('/').filter(Boolean));
44
+ let text = readRules(path.join(dir, '.gitignore'));
45
+ if (folder === '')
46
+ text += '\n' + readRules(path.join(this.root, '.git', 'info', 'exclude'));
47
+ const matcher = text.trim() ? ignore().add(text) : null;
48
+ this.perFolder.set(folder, matcher);
49
+ return matcher;
50
+ }
51
+ /**
52
+ * Is this one entry ignored, given that its parent folder is not? `rel` uses / and has no leading slash. Your include
53
+ * patterns win over everything, your exclude patterns over .gitignore.
54
+ */
55
+ ignoredHere(rel, isDir) {
56
+ const probe = isDir ? rel + '/' : rel;
57
+ if (this.forced.ignores(probe))
58
+ return false;
59
+ if (this.extra.ignores(probe))
60
+ return true;
61
+ const parts = rel.split('/');
62
+ let ignored = false;
63
+ for (let i = 0; i < parts.length; i++) {
64
+ const folder = parts.slice(0, i).join('/');
65
+ const m = this.matcher(folder);
66
+ if (!m)
67
+ continue;
68
+ const below = parts.slice(i).join('/') + (isDir ? '/' : '');
69
+ const result = m.test(below);
70
+ if (result.ignored)
71
+ ignored = true;
72
+ else if (result.unignored)
73
+ ignored = false;
74
+ }
75
+ return ignored;
76
+ }
77
+ /** Is `rel` (a file, or a folder with isDir) ignored, counting its folders: inside an ignored folder is ignored. */
78
+ isIgnored(rel, isDir = false) {
79
+ const parts = rel.split('/').filter(Boolean);
80
+ if (parts[0] === '.git')
81
+ return true;
82
+ if (!isDir && this.isForced(parts.join('/')))
83
+ return false;
84
+ for (let i = 1; i < parts.length; i++)
85
+ if (this.ignoredHere(parts.slice(0, i).join('/'), true))
86
+ return true;
87
+ return this.ignoredHere(parts.join('/'), isDir);
88
+ }
89
+ /** For a walk that already checked the parent folder: is this child ignored? */
90
+ isIgnoredChild(rel, isDir) {
91
+ return rel.split('/')[0] === '.git' || this.ignoredHere(rel, isDir);
92
+ }
93
+ }
@@ -0,0 +1,50 @@
1
+ import { IgnoreRules } from './rules.js';
2
+ export interface ViewPaths {
3
+ /** The project on this PC. */
4
+ host: string;
5
+ /** Its filtered copy (mapped into the sandbox as the workspace). */
6
+ view: string;
7
+ /** Host-only: what both sides looked like after the last sync. */
8
+ state: string;
9
+ }
10
+ export interface ViewOptions extends ViewPaths {
11
+ exclude?: string[];
12
+ include?: string[];
13
+ /** The project's .git as the sandbox sees it (written into the view's .git file); null = the project has no .git. */
14
+ guestGitDir?: string | null;
15
+ note?: (message: string) => void;
16
+ }
17
+ export interface SyncStats {
18
+ toView: number;
19
+ toHost: number;
20
+ deleted: number;
21
+ conflicts: number;
22
+ skipped: number;
23
+ }
24
+ export declare class WorkspaceView {
25
+ private readonly o;
26
+ readonly rules: IgnoreRules;
27
+ private seen;
28
+ constructor(o: ViewOptions);
29
+ /** Where a project's view and its sync state live: <storage>\views\<id> and <storage>\views\<id>.json. */
30
+ static pathsFor(storageRoot: string, host: string): ViewPaths;
31
+ private abs;
32
+ /** Creates the view (with its .git file) and brings it fully in step with the project. */
33
+ prepare(): SyncStats;
34
+ /** Every file not ignored, on one side (bounded, links skipped, ignored folders not entered). */
35
+ private walk;
36
+ /** A full pass over both sides and everything synced before. */
37
+ syncAll(): SyncStats;
38
+ /** Paths that changed (from file events): files, or folders whose contents are walked. */
39
+ syncChanged(rels: Iterable<string>): SyncStats;
40
+ private syncPaths;
41
+ private syncOne;
42
+ /** Copies (keeping the file time) or deletes on the other side. */
43
+ private carry;
44
+ private pruneEmpty;
45
+ /**
46
+ * Keeps both sides in step while it runs: file events from both, batched every 300 ms, plus a full pass every
47
+ * `fullEveryMs` to catch anything an event missed. Returns a function that stops it.
48
+ */
49
+ watch(fullEveryMs?: number): () => void;
50
+ }