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.
- package/config.example.json +2 -1
- package/dist/cli/commands/launch/launch.js +2 -0
- package/dist/cli/commands/vpn.js +1 -0
- package/dist/cli/commands/web.d.ts +5 -0
- package/dist/cli/commands/web.js +38 -0
- package/dist/core/config/defaults.js +1 -1
- package/dist/core/schema/config.d.ts +1 -0
- package/dist/core/schema/vpn.js +1 -1
- package/dist/core/schema/web.d.ts +1 -0
- package/dist/core/schema/web.js +4 -1
- package/dist/guest/boot/boot.js +3 -1
- package/dist/guest/governor/decide.d.ts +11 -1
- package/dist/guest/governor/decide.js +30 -1
- package/dist/guest/governor/governor.d.ts +3 -0
- package/dist/guest/governor/governor.js +18 -2
- package/dist/guest/network/firewall.d.ts +1 -0
- package/dist/guest/network/firewall.js +65 -23
- package/dist/guest/runtime/process.js +53 -5
- package/dist/guest/setup/claude-mem.js +12 -4
- package/dist/guest/toolchains/languages/node.js +29 -3
- package/dist/host/life/host-memory.d.ts +1 -0
- package/dist/host/life/host-memory.js +4 -1
- package/dist/host/life/watchdog.d.ts +2 -0
- package/dist/host/life/watchdog.js +19 -3
- package/dist/runtime/agent.js +14 -10
- package/dist/runtime/daemon.js +861 -711
- package/dist/runtime/db.js +14 -10
- package/dist/runtime/main.js +159 -41
- package/dist/runtime/netproxy.js +4 -3
- package/dist/runtime/view.js +4 -3
- package/dist/selftest/guest-boot.test.js +47 -1
- package/dist/selftest/heavy-load.test.js +27 -1
- package/dist/selftest/network.test.js +20 -1
- package/dist/selftest/support/fixtures.d.ts +1 -0
- package/dist/shared/io/copy-tree.d.ts +7 -0
- package/dist/shared/io/copy-tree.js +45 -0
- package/dist/shared/io/index.d.ts +2 -0
- package/dist/shared/io/index.js +2 -0
- package/dist/shared/io/robocopy-result.d.ts +6 -0
- package/dist/shared/io/robocopy-result.js +23 -0
- package/dist/shared/memory/policy.d.ts +5 -0
- package/dist/shared/memory/policy.js +8 -0
- package/dist/shared/network/firewall-rules.d.ts +8 -0
- package/dist/shared/network/firewall-rules.js +28 -0
- package/native/csharp/HcsHost/AssemblyInfo.cs +3 -3
- package/native/csharp/HostTools/AssemblyInfo.cs +3 -3
- package/native/csharp/HostTools/MemoryStatus.cs +128 -107
- package/native/scripts/memory-status.ps1 +10 -2
- package/package.json +1 -1
- package/sandbox/csharp/MemGov.cs +466 -398
- package/sandbox/scripts/new-shortcuts.ps1 +3 -1
- package/schemas/config.schema.json +6 -2
package/config.example.json
CHANGED
|
@@ -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);
|
package/dist/cli/commands/vpn.js
CHANGED
|
@@ -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>;
|
package/dist/cli/commands/web.js
CHANGED
|
@@ -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 },
|
package/dist/core/schema/vpn.js
CHANGED
|
@@ -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.'),
|
package/dist/core/schema/web.js
CHANGED
|
@@ -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)
|
|
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
|
});
|
package/dist/guest/boot/boot.js
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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 ?
|
|
95
|
-
...
|
|
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
|
-
|
|
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
|
-
|
|
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'
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
53
|
-
|
|
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
|
|
120
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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]);
|