windows-development-sandbox 1.9.0 → 1.10.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 (52) hide show
  1. package/config.example.json +2 -1
  2. package/dist/cli/commands/launch/launch.js +2 -0
  3. package/dist/cli/commands/vpn.js +1 -0
  4. package/dist/cli/commands/web.d.ts +5 -0
  5. package/dist/cli/commands/web.js +38 -0
  6. package/dist/core/config/defaults.js +1 -1
  7. package/dist/core/schema/config.d.ts +1 -0
  8. package/dist/core/schema/vpn.js +1 -1
  9. package/dist/core/schema/web.d.ts +1 -0
  10. package/dist/core/schema/web.js +4 -1
  11. package/dist/guest/boot/boot.js +3 -1
  12. package/dist/guest/governor/decide.d.ts +11 -1
  13. package/dist/guest/governor/decide.js +30 -1
  14. package/dist/guest/governor/governor.d.ts +3 -0
  15. package/dist/guest/governor/governor.js +18 -2
  16. package/dist/guest/network/firewall.d.ts +1 -0
  17. package/dist/guest/network/firewall.js +65 -23
  18. package/dist/guest/runtime/process.js +53 -5
  19. package/dist/guest/setup/claude-mem.js +12 -4
  20. package/dist/guest/toolchains/languages/node.js +29 -3
  21. package/dist/host/life/host-memory.d.ts +1 -0
  22. package/dist/host/life/host-memory.js +4 -1
  23. package/dist/host/life/watchdog.d.ts +2 -0
  24. package/dist/host/life/watchdog.js +19 -3
  25. package/dist/runtime/agent.js +14 -10
  26. package/dist/runtime/daemon.js +861 -711
  27. package/dist/runtime/db.js +14 -10
  28. package/dist/runtime/main.js +159 -41
  29. package/dist/runtime/netproxy.js +4 -3
  30. package/dist/runtime/view.js +4 -3
  31. package/dist/selftest/guest-boot.test.js +47 -1
  32. package/dist/selftest/heavy-load.test.js +27 -1
  33. package/dist/selftest/network.test.js +20 -1
  34. package/dist/selftest/support/fixtures.d.ts +1 -0
  35. package/dist/shared/io/copy-tree.d.ts +7 -0
  36. package/dist/shared/io/copy-tree.js +45 -0
  37. package/dist/shared/io/index.d.ts +2 -0
  38. package/dist/shared/io/index.js +2 -0
  39. package/dist/shared/io/robocopy-result.d.ts +6 -0
  40. package/dist/shared/io/robocopy-result.js +23 -0
  41. package/dist/shared/memory/policy.d.ts +5 -0
  42. package/dist/shared/memory/policy.js +8 -0
  43. package/dist/shared/network/firewall-rules.d.ts +8 -0
  44. package/dist/shared/network/firewall-rules.js +28 -0
  45. package/native/csharp/HcsHost/AssemblyInfo.cs +3 -3
  46. package/native/csharp/HostTools/AssemblyInfo.cs +3 -3
  47. package/native/csharp/HostTools/MemoryStatus.cs +128 -107
  48. package/native/scripts/memory-status.ps1 +10 -2
  49. package/package.json +1 -1
  50. package/sandbox/csharp/MemGov.cs +466 -398
  51. package/sandbox/scripts/new-shortcuts.ps1 +3 -1
  52. package/schemas/config.schema.json +6 -2
@@ -222,7 +222,8 @@
222
222
  },
223
223
  "web": {
224
224
  "enabled": false,
225
- "port": 47800
225
+ "port": 47800,
226
+ "openOnLaunch": false
226
227
  },
227
228
  "agentCommands": {
228
229
  "enabled": false,
@@ -29,6 +29,7 @@ import { packageVersion } from '../../../core/package-info.js';
29
29
  import { reopenedText, reopenSessions } from '../sessions/reopen.js';
30
30
  import { sessionsToResume } from './resume-offer.js';
31
31
  import { startIntoRunning, launchIntoRunning, contextFor, waitForBoot, attachHere, waitForStart, openSession, readySandbox, } from './running.js';
32
+ import { startWebOnLaunch } from '../web.js';
32
33
  import { maybeAddRoot } from './add-root.js';
33
34
  import { prepare, firstBootNotice } from './prepare.js';
34
35
  import { bootWantsFor } from './boot-wants.js';
@@ -202,6 +203,7 @@ class Launcher {
202
203
  this.share(generated);
203
204
  await engine.start(generated.enginePlan);
204
205
  this.startWatcher();
206
+ await startWebOnLaunch(this.ctx);
205
207
  if (reopen.length)
206
208
  await this.reopenAfterBoot(generated.launchId, reopen);
207
209
  return this.afterStart(run, generated);
@@ -12,6 +12,7 @@ const LOGIN_TEXT = {
12
12
  };
13
13
  /** The warnings every VPN user must see. */
14
14
  export const VPN_WARNINGS = [
15
+ 'The VPN is an alpha: not yet tried on a real PC (docs/vpn.md lists what still needs checking).',
15
16
  'The agent in the sandbox can read the VPN login (a service login you can revoke) and can turn the tunnel off, because it is the administrator there.',
16
17
  'Free VPNs see all your traffic: use one you trust.',
17
18
  ];
@@ -1,4 +1,9 @@
1
1
  import { type Context } from './context.js';
2
+ /**
3
+ * At a sandbox start, with web.enabled: the web UI is started in the background when it is not running, and with
4
+ * web.openOnLaunch opened in the browser, signed in. Never stops the launch: a problem is one line.
5
+ */
6
+ export declare function startWebOnLaunch(ctx: Context): Promise<void>;
2
7
  export declare function web(ctx: Context): Promise<number>;
3
8
  /** Body of the background process (`sandbox _web`): runs until stopped. */
4
9
  export declare function webServer(ctx: Context): Promise<void>;
@@ -37,6 +37,44 @@ async function startServer(ctx, control) {
37
37
  }
38
38
  return fail('The web UI did not start.', 'Run sandbox _web to see why (the port ' + config.web.port + ' may be taken: change web.port).');
39
39
  }
40
+ /**
41
+ * At a sandbox start, with web.enabled: the web UI is started in the background when it is not running, and with
42
+ * web.openOnLaunch opened in the browser, signed in. Never stops the launch: a problem is one line.
43
+ */
44
+ export async function startWebOnLaunch(ctx) {
45
+ const { web: settings } = ctx.loaded.config;
46
+ if (!settings.enabled)
47
+ return;
48
+ try {
49
+ const control = controlOf(ctx);
50
+ const before = await control.status();
51
+ let state = before.running ? before.state : null;
52
+ if (!state) {
53
+ if (before.state)
54
+ control.clear();
55
+ const configFile = ctx.options.config;
56
+ spawnDetached(ctx.entry, ['_web', ...(configFile ? ['--config', path.resolve(configFile)] : [])]);
57
+ for (const started = Date.now(); !state && Date.now() - started < START_WAIT_MS; await sleep(300)) {
58
+ const now = await control.status();
59
+ if (now.running && now.state)
60
+ state = now.state;
61
+ }
62
+ }
63
+ if (!state) {
64
+ console.log('The web UI did not start (port ' + settings.port + ' may be taken: change web.port). Try: sandbox web start');
65
+ return;
66
+ }
67
+ if (settings.openOnLaunch) {
68
+ openBrowser(control.url(state));
69
+ console.log('The web UI is open in your browser (http://127.0.0.1:' + state.port + '/).');
70
+ }
71
+ else
72
+ console.log('The web UI is at http://127.0.0.1:' + state.port + '/ (open it signed in with: sandbox web open).');
73
+ }
74
+ catch (e) {
75
+ console.log('The web UI could not be started: ' + (e instanceof Error ? e.message : String(e)));
76
+ }
77
+ }
40
78
  export async function web(ctx) {
41
79
  requireOnPc('web');
42
80
  const action = ctx.options.positionals[0] ?? 'status';
@@ -94,7 +94,7 @@ export const DEFAULTS = {
94
94
  sqlserver: { enabled: 'auto' },
95
95
  },
96
96
  attach: { enabled: true, reuseSession: true, auto: false, autoHide: false },
97
- web: { enabled: false, port: 47800 },
97
+ web: { enabled: false, port: 47800, openOnLaunch: false },
98
98
  agentCommands: { enabled: false, agents: 'auto' },
99
99
  secrets: { strip: true, allow: [], patterns: [], warnFiles: true },
100
100
  personalization: { enabled: true },
@@ -247,6 +247,7 @@ export declare const ConfigSchema: z.ZodObject<{
247
247
  web: z.ZodObject<{
248
248
  enabled: z.ZodBoolean;
249
249
  port: z.ZodNumber;
250
+ openOnLaunch: z.ZodBoolean;
250
251
  }, z.core.$strict>;
251
252
  agentCommands: z.ZodObject<{
252
253
  enabled: z.ZodBoolean;
@@ -4,7 +4,7 @@ export const VPN_PROVIDERS = ['nordvpn', 'openvpn-file', 'wireguard-file'];
4
4
  export const VpnSchema = z.strictObject({
5
5
  enabled: z
6
6
  .boolean()
7
- .describe("Send the sandbox's traffic through a VPN. Only the sandbox: your PC's own connection is untouched. Needs a saved login first (sandbox vpn login). The agent in the sandbox can read that login and can turn the tunnel off (it is the administrator there)."),
7
+ .describe("(Alpha: not yet tried on a real PC) Send the sandbox's traffic through a VPN. Only the sandbox: your PC's own connection is untouched. Needs a saved login first (sandbox vpn login). The agent in the sandbox can read that login and can turn the tunnel off (it is the administrator there)."),
8
8
  provider: z
9
9
  .enum(VPN_PROVIDERS)
10
10
  .describe('nordvpn: a recommended NordVPN server is picked at every launch. openvpn-file: your own .ovpn file (configFile). wireguard-file: your own WireGuard .conf (configFile), e.g. from your company.'),
@@ -3,4 +3,5 @@ export declare const WEB_DEFAULT_PORT = 47800;
3
3
  export declare const WebSchema: z.ZodObject<{
4
4
  enabled: z.ZodBoolean;
5
5
  port: z.ZodNumber;
6
+ openOnLaunch: z.ZodBoolean;
6
7
  }, z.core.$strict>;
@@ -4,11 +4,14 @@ export const WEB_DEFAULT_PORT = 47800;
4
4
  export const WebSchema = z.strictObject({
5
5
  enabled: z
6
6
  .boolean()
7
- .describe('(Beta) Allow the web UI: your sandbox sessions in a browser on this PC. Off by default, and nothing starts it by itself yet: sandbox web start works either way and runs it until sandbox web stop (or a restart of the PC).'),
7
+ .describe('(Beta) The web UI: your sandbox sessions in a browser on this PC (http://127.0.0.1:<port>, this PC only). On: every sandbox start also starts it in the background (it keeps running until sandbox web stop or a restart of the PC). Off: only sandbox web start runs it. Open it signed in with sandbox web open.'),
8
8
  port: z
9
9
  .number()
10
10
  .int()
11
11
  .min(1024)
12
12
  .max(65535)
13
13
  .describe('Port of the web UI on 127.0.0.1 (this PC only). Pick another when it is taken.'),
14
+ openOnLaunch: z
15
+ .boolean()
16
+ .describe('(Beta) With the web UI on: open it in your browser, already signed in, every time a sandbox starts.'),
14
17
  });
@@ -14,7 +14,7 @@ import { syncClaudeProfile } from '../setup/claude-profile.js';
14
14
  import { agentEnv, persistEnvironment } from '../setup/environment.js';
15
15
  import { runHooks } from '../setup/hooks.js';
16
16
  import { applyAllowOnly, proxyEnv } from '../network/allow-only.js';
17
- import { applyNetworkPolicy } from '../network/firewall.js';
17
+ import { applyNetworkPolicy, networkProblems } from '../network/firewall.js';
18
18
  import { writeManagedSettings } from '../setup/managed-policy.js';
19
19
  import { applyPersonalization } from '../setup/desktop.js';
20
20
  import { setupGitConfig } from '../setup/git-identity.js';
@@ -175,6 +175,8 @@ export class Boot {
175
175
  databases: session.databases ?? [],
176
176
  apps: session.apps,
177
177
  });
178
+ for (const p of networkProblems())
179
+ tools.summary.push('PROTECTION MISSING: ' + p);
178
180
  if (!dryRun)
179
181
  tools.prepend.push(writeDbCommand());
180
182
  // network.allowOnly / network.proxy: everything started from now on uses the proxy.
@@ -17,6 +17,7 @@ export interface MemSample {
17
17
  }
18
18
  export declare const isNeverTouched: (name: string) => boolean;
19
19
  export declare const canLower: (name: string) => boolean;
20
+ export declare const isHeavyCompiler: (name: string) => boolean;
20
21
  export declare const canPause: (name: string) => boolean;
21
22
  export type Level = 'ok' | 'throttled' | 'suspending';
22
23
  export interface GovernorState {
@@ -45,7 +46,16 @@ export type Action = {
45
46
  readonly pid: number;
46
47
  readonly name: string;
47
48
  readonly ms: number;
49
+ }
50
+ /** Waits (suspended) because more compilers run than the cap allows; repeated while it is over, never killed. */
51
+ | {
52
+ readonly kind: 'hold';
53
+ readonly pid: number;
54
+ readonly name: string;
55
+ readonly ms: number;
48
56
  };
57
+ /** How long a held compiler waits before it gets a turn again (the governor repeats the hold while over the cap). */
58
+ export declare const HOLD_MS = 3000;
49
59
  export interface Decision {
50
60
  readonly state: GovernorState;
51
61
  readonly actions: readonly Action[];
@@ -58,4 +68,4 @@ export declare const PAUSE_MS = 5000;
58
68
  * One step. `gentle` (vGPU on): compilers start below normal even without pressure. Programs go back to their normal
59
69
  * priority only when pressure ends (several calm samples in a row).
60
70
  */
61
- export declare function decide(prev: GovernorState, sample: MemSample, nowMs: number, thresholds: GuestThresholds, profile: LoadProfile): Decision;
71
+ export declare function decide(prev: GovernorState, sample: MemSample, nowMs: number, thresholds: GuestThresholds, profile: LoadProfile, maxCompilers?: number | null): Decision;
@@ -77,6 +77,24 @@ const NEVER = new Set([
77
77
  const key = (name) => name.toLowerCase().replace(/\.exe$/, '');
78
78
  export const isNeverTouched = (name) => NEVER.has(key(name));
79
79
  export const canLower = (name) => !isNeverTouched(name) && (COMPILERS.has(key(name)) || BUILD_TOOLS.has(key(name)));
80
+ /** The heavy ones the concurrency cap counts (drivers like gcc and collect2 only wait for these). */
81
+ const HEAVY = new Set([
82
+ 'rustc',
83
+ 'clippy-driver',
84
+ 'rustdoc',
85
+ 'cc1',
86
+ 'cc1plus',
87
+ 'clang',
88
+ 'clang++',
89
+ 'clang-cl',
90
+ 'cl',
91
+ 'link',
92
+ 'lld',
93
+ 'lld-link',
94
+ 'ld',
95
+ 'ld.lld',
96
+ ]);
97
+ export const isHeavyCompiler = (name) => !isNeverTouched(name) && HEAVY.has(key(name));
80
98
  export const canPause = (name) => !isNeverTouched(name) && COMPILERS.has(key(name));
81
99
  export const INITIAL_STATE = {
82
100
  pressure: false,
@@ -87,6 +105,8 @@ export const INITIAL_STATE = {
87
105
  nextPauseAt: 0,
88
106
  hostUntil: 0,
89
107
  };
108
+ /** How long a held compiler waits before it gets a turn again (the governor repeats the hold while over the cap). */
109
+ export const HOLD_MS = 3000;
90
110
  export const PAUSE_MS = 5000;
91
111
  const PAUSE_COOLDOWN_MS = 10000;
92
112
  /** Samples under pressure before programs go from below-normal to idle, and before one is paused. */
@@ -98,7 +118,7 @@ const PAUSE_MIN_MB = 300;
98
118
  * One step. `gentle` (vGPU on): compilers start below normal even without pressure. Programs go back to their normal
99
119
  * priority only when pressure ends (several calm samples in a row).
100
120
  */
101
- export function decide(prev, sample, nowMs, thresholds, profile) {
121
+ export function decide(prev, sample, nowMs, thresholds, profile, maxCompilers = null) {
102
122
  const ramFree = (sample.ramAvailMb / Math.max(1, sample.ramTotalMb)) * 100;
103
123
  const commit = ((sample.commitLimitMb - sample.commitAvailMb) / Math.max(1, sample.commitLimitMb)) * 100;
104
124
  const pcLow = nowMs < prev.hostUntil;
@@ -171,6 +191,15 @@ export function decide(prev, sample, nowMs, thresholds, profile) {
171
191
  nextPauseAt = pausedUntil + PAUSE_COOLDOWN_MS;
172
192
  }
173
193
  }
194
+ // Concurrency cap (vGPU profile): the oldest N heavy compilers run, the newer ones wait a few seconds at a time.
195
+ if (maxCompilers !== null) {
196
+ const heavy = sample.procs
197
+ .filter((p) => isHeavyCompiler(p.name) &&
198
+ !(nowMs < pausedUntil && actions.some((a) => a.kind === 'pause' && a.pid === p.pid)))
199
+ .sort((a, b) => a.pid - b.pid);
200
+ for (const p of heavy.slice(Math.max(1, maxCompilers)))
201
+ actions.push({ kind: 'hold', pid: p.pid, name: p.name, ms: HOLD_MS });
202
+ }
174
203
  const level = nowMs < pausedUntil ? 'suspending' : pressure ? 'throttled' : 'ok';
175
204
  return {
176
205
  state: { pressure, calm, samples, lowered, pausedUntil, nextPauseAt, hostUntil: prev.hostUntil },
@@ -10,6 +10,8 @@ export interface GovernorPort {
10
10
  export interface GovernorDeps {
11
11
  readonly port: GovernorPort;
12
12
  readonly profile: LoadProfile;
13
+ /** At most this many heavy compilers run at once (null = no cap). */
14
+ readonly maxCompilers?: number | null;
13
15
  readonly now: () => number;
14
16
  /** A line in the memory log. */
15
17
  readonly logLine: (line: string) => void;
@@ -25,6 +27,7 @@ export declare class GovernorCore {
25
27
  private lastWriteAt;
26
28
  private pausedName;
27
29
  private pausedPid;
30
+ private held;
28
31
  constructor(deps: GovernorDeps);
29
32
  hostPressure(seconds: number, reason: string): void;
30
33
  get current(): GovernorState;
@@ -2,13 +2,14 @@
2
2
  // runs low, slows the heaviest build programs the agents started down and, as a last resort, pauses the biggest
3
3
  // compiler for a few seconds (decide.ts). What it does goes to the memory log and run\pressure.json, for the host.
4
4
  import fs from 'node:fs';
5
+ import os from 'node:os';
5
6
  import path from 'node:path';
6
7
  import { spawn } from 'node:child_process';
7
8
  import { createInterface } from 'node:readline';
8
9
  import { z } from 'zod';
9
10
  import { now } from '../../shared/channel/run-dir.js';
10
11
  import { writeJsonAtomic } from '../../shared/io/json.js';
11
- import { GUEST_THRESHOLDS } from '../../shared/memory/index.js';
12
+ import { GUEST_THRESHOLDS, maxCompilers } from '../../shared/memory/index.js';
12
13
  import { ensureCompiled } from '../runtime/csc.js';
13
14
  import { dataDir } from '../runtime/paths.js';
14
15
  import { warn } from '../runtime/log.js';
@@ -39,6 +40,7 @@ export class GovernorCore {
39
40
  lastWriteAt = 0;
40
41
  pausedName = '';
41
42
  pausedPid = 0;
43
+ held = new Set();
42
44
  constructor(deps) {
43
45
  this.deps = deps;
44
46
  }
@@ -56,7 +58,11 @@ export class GovernorCore {
56
58
  onSample(sample) {
57
59
  const nowMs = this.deps.now();
58
60
  const before = this.state.pressure;
59
- const d = decide(this.state, sample, nowMs, GUEST_THRESHOLDS[this.deps.profile], this.deps.profile);
61
+ const seen = new Set(sample.procs.map((p) => p.pid));
62
+ for (const pid of this.held)
63
+ if (!seen.has(pid))
64
+ this.held.delete(pid);
65
+ const d = decide(this.state, sample, nowMs, GUEST_THRESHOLDS[this.deps.profile], this.deps.profile, this.deps.maxCompilers ?? null);
60
66
  this.state = d.state;
61
67
  for (const action of d.actions)
62
68
  this.apply(action);
@@ -92,6 +98,15 @@ export class GovernorCore {
92
98
  }
93
99
  apply(action) {
94
100
  try {
101
+ if (action.kind === 'hold') {
102
+ this.deps.port.pause(action.pid, action.ms);
103
+ if (!this.held.has(action.pid)) {
104
+ this.held.add(action.pid);
105
+ this.lastAction = 'holding ' + action.name + ' (' + action.pid + ') until another compiler ends';
106
+ this.deps.logLine('governor: ' + this.lastAction);
107
+ }
108
+ return;
109
+ }
95
110
  if (action.kind === 'priority') {
96
111
  this.deps.port.setPriority(action.pid, action.to);
97
112
  this.lastAction = action.name + ' (' + action.pid + ') priority ' + action.to;
@@ -193,6 +208,7 @@ export class Governor {
193
208
  this.core = new GovernorCore({
194
209
  port: proc,
195
210
  profile,
211
+ maxCompilers: maxCompilers(os.cpus().length, profile),
196
212
  now: () => Date.now(),
197
213
  logLine: appendToMemoryLog,
198
214
  saveStatus: (status) => {
@@ -1,5 +1,6 @@
1
1
  /** What the agent's manifest says about the LAN. */
2
2
  export type LanState = 'blocked' | 'open' | 'offline';
3
+ export declare function networkProblems(): readonly string[];
3
4
  export declare function run(commands: string[][]): void;
4
5
  /**
5
6
  * Applies the LAN block. Internet is checked first; if it works before the rules but not after, the rules (not the
@@ -4,14 +4,13 @@ import fs from 'node:fs';
4
4
  import path from 'node:path';
5
5
  import { spawnSync } from 'node:child_process';
6
6
  import { relayPort, withBlockedHosts } from '../../shared/network/policy.js';
7
- import { buildFirewallCommands, ipv4DnsServers, isIPv4, parseDefaultGateways, parseDnsServers, } from '../../shared/network/firewall-rules.js';
7
+ import { attachRuleCommands, buildFirewallCommands, hasShortName, ipv4DnsServers, isIPv4, parseDefaultGateways, parseDnsServers, } from '../../shared/network/firewall-rules.js';
8
8
  import { SYSTEM } from '../../shared/system.js';
9
9
  import { scriptArgs } from '../../shared/scripts.js';
10
10
  import { dryRun, isWin } from '../runtime/paths.js';
11
11
  import { log, warn } from '../runtime/log.js';
12
12
  import { session } from '../runtime/session.js';
13
13
  import { errMsg } from '../../core/util/errors.js';
14
- const ATTACH_RULE = 'AgentSandbox-Attach';
15
14
  const netsh = (args) => {
16
15
  const r = spawnSync(SYSTEM.netsh(), args, { encoding: 'utf8', windowsHide: true });
17
16
  return { status: r.status, out: (r.stdout ?? '') + (r.stderr ?? '') };
@@ -51,24 +50,49 @@ const waitForInternet = async (seconds) => {
51
50
  }
52
51
  return false;
53
52
  };
54
- /** Inbound rule for ptyhost only, and only from the sandbox's own subnet (where the host's Hyper-V adapter is). */
55
- const attachRule = (ptyHostExe) => [
56
- ['advfirewall', 'firewall', 'delete', 'rule', 'name=' + ATTACH_RULE],
57
- [
58
- 'advfirewall',
59
- 'firewall',
60
- 'add',
61
- 'rule',
62
- 'name=' + ATTACH_RULE,
63
- 'dir=in',
64
- 'action=allow',
65
- 'enable=yes',
66
- 'profile=any',
67
- 'protocol=tcp',
68
- 'remoteip=LocalSubnet',
69
- 'program=' + ptyHostExe,
70
- ],
71
- ];
53
+ /** Protections that could not be put in place this boot (shown in the boot summary and the log, never silent). */
54
+ const problems = [];
55
+ export function networkProblems() {
56
+ return problems;
57
+ }
58
+ /** netsh needs the long form of a path: the sandbox's TEMP is an 8.3 short path (WDAGUT~1). */
59
+ const longPath = (file) => {
60
+ if (!hasShortName(file))
61
+ return file;
62
+ try {
63
+ return fs.realpathSync.native(file);
64
+ }
65
+ catch {
66
+ return file;
67
+ }
68
+ };
69
+ /**
70
+ * The inbound rule for ptyhost (the attach channel). A rule that cannot be added is a missing protection, so it is
71
+ * tried again a few times and then reported loudly instead of being skipped.
72
+ */
73
+ async function applyAttachRule(ptyHostExe) {
74
+ if (!session.attach.enabled)
75
+ return;
76
+ if (!ptyHostExe) {
77
+ problems.push('attach: the session host was not built, so the firewall rule for it was not created');
78
+ warn(problems[problems.length - 1]);
79
+ return;
80
+ }
81
+ let last = '';
82
+ for (let attempt = 1; attempt <= 3; attempt++) {
83
+ try {
84
+ run(attachRuleCommands(longPath(ptyHostExe)));
85
+ return;
86
+ }
87
+ catch (e) {
88
+ last = errMsg(e);
89
+ if (attempt < 3)
90
+ await new Promise((r) => setTimeout(r, 1000));
91
+ }
92
+ }
93
+ problems.push('attach: the firewall rule for the session host could not be created (' + last + ')');
94
+ warn('PROTECTION MISSING: ' + problems[problems.length - 1]);
95
+ }
72
96
  export function run(commands) {
73
97
  for (const c of commands) {
74
98
  const r = netsh(c);
@@ -89,10 +113,11 @@ export async function applyNetworkPolicy(ptyHostExe) {
89
113
  const block = session.network.blockPrivateRanges;
90
114
  const exempt = block ? discoverExemptIps() : [];
91
115
  const lanRules = block ? buildFirewallCommands(exempt) : [];
116
+ const forwardRules = forwardRule();
92
117
  const commands = [
93
118
  ...lanRules,
94
- ...(ptyHostExe && session.attach.enabled ? attachRule(ptyHostExe) : []),
95
- ...forwardRule(),
119
+ ...(ptyHostExe && session.attach.enabled ? attachRuleCommands(ptyHostExe) : []),
120
+ ...forwardRules,
96
121
  ];
97
122
  if (isWin && !dryRun) {
98
123
  try {
@@ -122,7 +147,24 @@ export async function applyNetworkPolicy(ptyHostExe) {
122
147
  if (commands.length === 0)
123
148
  return 'open';
124
149
  const online = block ? await waitForInternet(30) : true;
125
- run(commands);
150
+ // Each group on its own: one failing must not skip the others (or the internet check below).
151
+ const failures = [];
152
+ try {
153
+ run(lanRules);
154
+ }
155
+ catch (e) {
156
+ failures.push(errMsg(e));
157
+ }
158
+ await applyAttachRule(ptyHostExe);
159
+ try {
160
+ run(forwardRules);
161
+ }
162
+ catch (e) {
163
+ problems.push('forwardPorts: the firewall rule for the relays could not be created (' + errMsg(e) + ')');
164
+ warn('PROTECTION MISSING: ' + problems[problems.length - 1]);
165
+ }
166
+ if (failures.length)
167
+ throw new Error(failures[0]);
126
168
  if (!block)
127
169
  return 'open';
128
170
  if (online && !(await waitForInternet(15))) {
@@ -4,6 +4,7 @@ import path from 'node:path';
4
4
  import { spawnSync } from 'node:child_process';
5
5
  import { moveIntoPlace, spawnAsync, stagingFolder } from '../../shared/download/index.js';
6
6
  import { safeRemove } from '../../shared/io/safe-remove.js';
7
+ import { robocopyErrors, robocopyFailed, robocopyMeaning } from '../../shared/io/robocopy-result.js';
7
8
  import { SYSTEM } from '../../shared/system.js';
8
9
  import { scriptArgs } from '../../shared/scripts.js';
9
10
  import { isWin } from './paths.js';
@@ -27,7 +28,18 @@ export function execOutput(file, args, options = {}) {
27
28
  }
28
29
  /** Runs sandbox\scripts\<name> (a .ps1 in the launch folder) with `args`; throws when it fails. */
29
30
  export function runScript(name, args = []) {
30
- exec(SYSTEM.powershell(), scriptArgs(name, args));
31
+ const result = spawnSync(SYSTEM.powershell(), scriptArgs(name, args), {
32
+ stdio: ['inherit', 'inherit', 'pipe'],
33
+ encoding: 'utf8',
34
+ windowsHide: true,
35
+ });
36
+ if (result.error)
37
+ throw result.error;
38
+ const errors = String(result.stderr ?? '').trim();
39
+ if (errors)
40
+ process.stderr.write(errors + '\n');
41
+ if (result.status !== 0)
42
+ throw new Error(name + ' failed with exit code ' + result.status + (errors ? ': ' + errors.slice(0, 400) : ''));
31
43
  }
32
44
  /** runScript for what it prints ('' when it fails). */
33
45
  export function scriptOutput(name, args = [], options = {}) {
@@ -59,10 +71,46 @@ export function robocopy(from, to, files = []) {
59
71
  fs.copyFileSync(path.join(from, f), path.join(to, f));
60
72
  return;
61
73
  }
62
- const what = files.length ? files : ['/E', '/MT:16'];
63
- const result = spawnSync(SYSTEM.robocopy(), [from, to, ...what, '/NFL', '/NDL', '/NJH', '/NJS', '/NP', '/R:1', '/W:1', '/XJ'], { stdio: 'ignore', windowsHide: true });
64
- if ((result.status ?? 16) >= 8)
65
- throw new Error('robocopy ' + from + ' -> ' + to + ' failed (exit ' + result.status + ')');
74
+ const what = files.length ? files : ['/E'];
75
+ // A second pass for what the first could not copy (a file another program holds open for a moment, a busy mapped
76
+ // folder): one thread and more patience; robocopy skips what is already in place.
77
+ const passes = [
78
+ ['/MT:16', '/R:1', '/W:1'],
79
+ ['/MT:1', '/R:4', '/W:3'],
80
+ ];
81
+ let code = null;
82
+ let output = '';
83
+ for (const pass of passes) {
84
+ const result = spawnSync(SYSTEM.robocopy(), [
85
+ from,
86
+ to,
87
+ ...what,
88
+ ...(files.length ? [] : [pass[0]]),
89
+ pass[1],
90
+ pass[2],
91
+ '/NFL',
92
+ '/NDL',
93
+ '/NJH',
94
+ '/NJS',
95
+ '/NP',
96
+ '/XJ',
97
+ ], { encoding: 'utf8', windowsHide: true });
98
+ code = result.status;
99
+ output = String(result.stdout ?? '') + String(result.stderr ?? '');
100
+ if (!robocopyFailed(code))
101
+ return;
102
+ }
103
+ const why = robocopyErrors(output);
104
+ throw new Error('robocopy ' +
105
+ from +
106
+ ' -> ' +
107
+ to +
108
+ ' failed (exit ' +
109
+ code +
110
+ ': ' +
111
+ robocopyMeaning(code) +
112
+ ')' +
113
+ (why.length ? ': ' + why.join(' | ') : ''));
66
114
  }
67
115
  /**
68
116
  * A Node install without its global packages (nvm keeps those per version: often tens of thousands of small files,
@@ -6,6 +6,7 @@ import { dryRun } from '../runtime/paths.js';
6
6
  import { exists, mkdirp } from '../runtime/fs.js';
7
7
  import { log, warn } from '../runtime/log.js';
8
8
  import { robocopy } from '../runtime/process.js';
9
+ import { copyTreeTolerant } from '../../shared/io/copy-tree.js';
9
10
  /**
10
11
  * claude-mem keeps SQLite + a vector store. They run from the sandbox's own disk (SQLite and mapped folders do not
11
12
  * mix) and are mirrored to persistent storage, from where the host syncs them back to your PC.
@@ -49,8 +50,13 @@ const databaseFingerprint = (dir) => {
49
50
  if (entry.isDirectory())
50
51
  walk(full);
51
52
  else if (entry.isFile() && /\.(db|sqlite3?)(-wal)?$/i.test(entry.name)) {
52
- const s = fs.statSync(full);
53
- parts.push(full + ':' + s.size + ':' + s.mtimeMs);
53
+ try {
54
+ const s = fs.statSync(full);
55
+ parts.push(full + ':' + s.size + ':' + s.mtimeMs);
56
+ }
57
+ catch {
58
+ // Gone since the listing (a database being replaced): left out, the next save looks again.
59
+ }
54
60
  }
55
61
  }
56
62
  };
@@ -116,8 +122,10 @@ export function saveClaudeMem() {
116
122
  const incoming = store + '.incoming';
117
123
  const previous = store + '.previous';
118
124
  fs.rmSync(incoming, { recursive: true, force: true });
119
- // The database, its -wal and -shm files are copied together, so the copy is consistent with itself.
120
- fs.cpSync(local, incoming, { recursive: true, filter: (src) => !/\.(pid|lock)$/i.test(src) });
125
+ // The database and its -wal are copied together, so the copy is consistent with itself. A -journal is never copied:
126
+ // beside a database taken at another moment it would be rolled back over it. The -shm is rebuilt by SQLite. Files the
127
+ // agent removes while this runs (hook spool files) are skipped.
128
+ copyTreeTolerant(local, incoming, (name) => /\.(pid|lock)$|-journal$|-shm$/i.test(name));
121
129
  // Swap without a window where no copy exists: old aside, new in, then keep the old one as a dated backup.
122
130
  fs.rmSync(previous, { recursive: true, force: true });
123
131
  if (exists(store))
@@ -14,6 +14,7 @@ import { arch, tool } from '../tool-env.js';
14
14
  import { installOnce } from '../package-ledger.js';
15
15
  import { resolveCached } from '../version-cache.js';
16
16
  import { duringSession } from '../../runtime/project-context.js';
17
+ import { session } from '../../runtime/session.js';
17
18
  import { pickNodeVersion, pickSpreadVersions } from '../../../core/detect/versions.js';
18
19
  const NVM_HOME = tool('nvm');
19
20
  const NVM_SYMLINK = path.join(localDir, 'nodejs');
@@ -73,7 +74,14 @@ const ensureNvm = async () => {
73
74
  log('Downloading nvm-windows (' + asset.url.split('/').slice(-2, -1)[0] + ') ...');
74
75
  await downloader.archive(asset.url, NVM_HOME, { sha256: asset.sha256, locate: 'nvm.exe', fileName: asset.name });
75
76
  };
76
- /** A Node version: from the host's nvm when it has it (fast, offline), else `nvm install`. */
77
+ /** This PC's own Node folder (mapped read-only), when it is exactly this version. */
78
+ const hostNodeFolder = (version) => {
79
+ const dir = path.join(hostToolsDir, 'node');
80
+ return session.hostTools.nodeVersion?.replace(/^v/, '') === version && exists(path.join(dir, 'node.exe'))
81
+ ? dir
82
+ : null;
83
+ };
84
+ /** A Node version: from the host's nvm or Node when it has it (fast, offline), else `nvm install`. */
77
85
  const installNodeVersion = async (version) => {
78
86
  const dir = path.join(NVM_HOME, 'v' + version);
79
87
  const fromHost = path.join(HOST_NVM, 'v' + version);
@@ -84,6 +92,12 @@ const installNodeVersion = async (version) => {
84
92
  copyNode(fromHost, dir);
85
93
  return;
86
94
  }
95
+ const hostNode = hostNodeFolder(version);
96
+ if (hostNode) {
97
+ log('Copying Node ' + version + ' from this PC (without global packages) ...');
98
+ copyNode(hostNode, dir);
99
+ return;
100
+ }
87
101
  log('Downloading Node ' + version + ' (~30 MB once) ...');
88
102
  mkdirp(NVM_TEMP);
89
103
  let out;
@@ -93,8 +107,20 @@ const installNodeVersion = async (version) => {
93
107
  finally {
94
108
  safeRemove(NVM_TEMP, NVM_HOME);
95
109
  }
96
- if (!exists(path.join(dir, 'node.exe')))
97
- throw new Error('nvm install ' + version + ' failed: ' + out.trim().split('\n').slice(-2).join(' '));
110
+ if (!exists(path.join(dir, 'node.exe'))) {
111
+ const tail = out.trim().split(/\r?\n/).filter(Boolean).slice(-3).join(' ');
112
+ const why = /complete/i.test(out)
113
+ ? 'nvm said it finished, but its move into the data folder did not happen'
114
+ : 'nvm could not download or unpack it: check the internet connection and the free disk space';
115
+ throw new Error('nvm install ' +
116
+ version +
117
+ ' did not leave ' +
118
+ path.join(dir, 'node.exe') +
119
+ ' (' +
120
+ why +
121
+ ')' +
122
+ (tail ? '. nvm printed: ' + tail : ''));
123
+ }
98
124
  };
99
125
  const activate = async (version) => {
100
126
  await nvm(['use', version]);