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/dist/runtime/db.js
CHANGED
|
@@ -26538,16 +26538,17 @@ var ResumeSchema = external_exports.strictObject({
|
|
|
26538
26538
|
// src/core/schema/web.ts
|
|
26539
26539
|
var WebSchema = external_exports.strictObject({
|
|
26540
26540
|
enabled: external_exports.boolean().describe(
|
|
26541
|
-
"(Beta)
|
|
26541
|
+
"(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."
|
|
26542
26542
|
),
|
|
26543
|
-
port: external_exports.number().int().min(1024).max(65535).describe("Port of the web UI on 127.0.0.1 (this PC only). Pick another when it is taken.")
|
|
26543
|
+
port: external_exports.number().int().min(1024).max(65535).describe("Port of the web UI on 127.0.0.1 (this PC only). Pick another when it is taken."),
|
|
26544
|
+
openOnLaunch: external_exports.boolean().describe("(Beta) With the web UI on: open it in your browser, already signed in, every time a sandbox starts.")
|
|
26544
26545
|
});
|
|
26545
26546
|
|
|
26546
26547
|
// src/core/schema/vpn.ts
|
|
26547
26548
|
var VPN_PROVIDERS = ["nordvpn", "openvpn-file", "wireguard-file"];
|
|
26548
26549
|
var VpnSchema = external_exports.strictObject({
|
|
26549
26550
|
enabled: external_exports.boolean().describe(
|
|
26550
|
-
"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)."
|
|
26551
|
+
"(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)."
|
|
26551
26552
|
),
|
|
26552
26553
|
provider: external_exports.enum(VPN_PROVIDERS).describe(
|
|
26553
26554
|
"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."
|
|
@@ -27439,19 +27440,22 @@ import fs10 from "node:fs";
|
|
|
27439
27440
|
import os3 from "node:os";
|
|
27440
27441
|
import path12 from "node:path";
|
|
27441
27442
|
import { spawnSync as spawnSync2 } from "node:child_process";
|
|
27442
|
-
function exec(file2, args, options = {}) {
|
|
27443
|
-
const result = spawnSync2(file2, args, { stdio: "inherit", windowsHide: true, ...options });
|
|
27444
|
-
if (result.error) throw result.error;
|
|
27445
|
-
if (result.status !== 0)
|
|
27446
|
-
throw new Error(path12.basename(file2) + " " + args.slice(0, 3).join(" ") + " failed with exit code " + result.status);
|
|
27447
|
-
}
|
|
27448
27443
|
async function execAsync(file2, args, options = {}) {
|
|
27449
27444
|
const code = await spawnAsync(file2, args, { stdio: "inherit", ...options });
|
|
27450
27445
|
if (code !== 0)
|
|
27451
27446
|
throw new Error(path12.basename(file2) + " " + args.slice(0, 3).join(" ") + " failed with exit code " + code);
|
|
27452
27447
|
}
|
|
27453
27448
|
function runScript(name, args = []) {
|
|
27454
|
-
|
|
27449
|
+
const result = spawnSync2(SYSTEM.powershell(), scriptArgs(name, args), {
|
|
27450
|
+
stdio: ["inherit", "inherit", "pipe"],
|
|
27451
|
+
encoding: "utf8",
|
|
27452
|
+
windowsHide: true
|
|
27453
|
+
});
|
|
27454
|
+
if (result.error) throw result.error;
|
|
27455
|
+
const errors = String(result.stderr ?? "").trim();
|
|
27456
|
+
if (errors) process.stderr.write(errors + "\n");
|
|
27457
|
+
if (result.status !== 0)
|
|
27458
|
+
throw new Error(name + " failed with exit code " + result.status + (errors ? ": " + errors.slice(0, 400) : ""));
|
|
27455
27459
|
}
|
|
27456
27460
|
function runScriptWithJson(name, param, data, args = []) {
|
|
27457
27461
|
const dir = fs10.mkdtempSync(path12.join(os3.tmpdir(), "sbx-"));
|
package/dist/runtime/main.js
CHANGED
|
@@ -26595,16 +26595,17 @@ var ResumeSchema = external_exports.strictObject({
|
|
|
26595
26595
|
// src/core/schema/web.ts
|
|
26596
26596
|
var WebSchema = external_exports.strictObject({
|
|
26597
26597
|
enabled: external_exports.boolean().describe(
|
|
26598
|
-
"(Beta)
|
|
26598
|
+
"(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."
|
|
26599
26599
|
),
|
|
26600
|
-
port: external_exports.number().int().min(1024).max(65535).describe("Port of the web UI on 127.0.0.1 (this PC only). Pick another when it is taken.")
|
|
26600
|
+
port: external_exports.number().int().min(1024).max(65535).describe("Port of the web UI on 127.0.0.1 (this PC only). Pick another when it is taken."),
|
|
26601
|
+
openOnLaunch: external_exports.boolean().describe("(Beta) With the web UI on: open it in your browser, already signed in, every time a sandbox starts.")
|
|
26601
26602
|
});
|
|
26602
26603
|
|
|
26603
26604
|
// src/core/schema/vpn.ts
|
|
26604
26605
|
var VPN_PROVIDERS = ["nordvpn", "openvpn-file", "wireguard-file"];
|
|
26605
26606
|
var VpnSchema = external_exports.strictObject({
|
|
26606
26607
|
enabled: external_exports.boolean().describe(
|
|
26607
|
-
"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)."
|
|
26608
|
+
"(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)."
|
|
26608
26609
|
),
|
|
26609
26610
|
provider: external_exports.enum(VPN_PROVIDERS).describe(
|
|
26610
26611
|
"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."
|
|
@@ -27014,6 +27015,21 @@ function safeRemove(target, root) {
|
|
|
27014
27015
|
fs3.rmSync(target, { recursive: true, force: true, maxRetries: 2 });
|
|
27015
27016
|
}
|
|
27016
27017
|
|
|
27018
|
+
// src/shared/io/robocopy-result.ts
|
|
27019
|
+
function robocopyFailed(code) {
|
|
27020
|
+
return code === null || code >= 8;
|
|
27021
|
+
}
|
|
27022
|
+
function robocopyMeaning(code) {
|
|
27023
|
+
if (code === null) return "robocopy did not finish";
|
|
27024
|
+
if (code >= 16) return "robocopy stopped with a fatal error (bad path or no access)";
|
|
27025
|
+
if (code >= 8) return code & 1 ? "some files were copied and some could not be" : "files could not be copied";
|
|
27026
|
+
return code === 0 ? "nothing to copy" : "copied";
|
|
27027
|
+
}
|
|
27028
|
+
function robocopyErrors(output3, max = 3) {
|
|
27029
|
+
const lines = output3.split(/\r?\n/).map((l) => l.trim()).filter((l) => /\bERROR\b/.test(l) || /^The (process|system) cannot/i.test(l));
|
|
27030
|
+
return lines.slice(0, max).map((l) => l.length > 220 ? l.slice(0, 217) + "..." : l);
|
|
27031
|
+
}
|
|
27032
|
+
|
|
27017
27033
|
// src/shared/channel/run-dir.ts
|
|
27018
27034
|
function safeId(id) {
|
|
27019
27035
|
return IdSchema.parse(id);
|
|
@@ -27596,7 +27612,16 @@ function execOutput(file3, args, options = {}) {
|
|
|
27596
27612
|
return result.status === 0 ? String(result.stdout ?? "").trim() : "";
|
|
27597
27613
|
}
|
|
27598
27614
|
function runScript(name, args = []) {
|
|
27599
|
-
|
|
27615
|
+
const result = spawnSync(SYSTEM.powershell(), scriptArgs(name, args), {
|
|
27616
|
+
stdio: ["inherit", "inherit", "pipe"],
|
|
27617
|
+
encoding: "utf8",
|
|
27618
|
+
windowsHide: true
|
|
27619
|
+
});
|
|
27620
|
+
if (result.error) throw result.error;
|
|
27621
|
+
const errors = String(result.stderr ?? "").trim();
|
|
27622
|
+
if (errors) process.stderr.write(errors + "\n");
|
|
27623
|
+
if (result.status !== 0)
|
|
27624
|
+
throw new Error(name + " failed with exit code " + result.status + (errors ? ": " + errors.slice(0, 400) : ""));
|
|
27600
27625
|
}
|
|
27601
27626
|
function scriptOutput(name, args = [], options = {}) {
|
|
27602
27627
|
return execOutput(SYSTEM.powershell(), scriptArgs(name, args), options);
|
|
@@ -27622,14 +27647,40 @@ function robocopy(from, to, files = []) {
|
|
|
27622
27647
|
fs12.copyFileSync(path15.join(from, f), path15.join(to, f));
|
|
27623
27648
|
return;
|
|
27624
27649
|
}
|
|
27625
|
-
const what = files.length ? files : ["/E"
|
|
27626
|
-
const
|
|
27627
|
-
|
|
27628
|
-
[
|
|
27629
|
-
|
|
27650
|
+
const what = files.length ? files : ["/E"];
|
|
27651
|
+
const passes = [
|
|
27652
|
+
["/MT:16", "/R:1", "/W:1"],
|
|
27653
|
+
["/MT:1", "/R:4", "/W:3"]
|
|
27654
|
+
];
|
|
27655
|
+
let code = null;
|
|
27656
|
+
let output3 = "";
|
|
27657
|
+
for (const pass of passes) {
|
|
27658
|
+
const result = spawnSync(
|
|
27659
|
+
SYSTEM.robocopy(),
|
|
27660
|
+
[
|
|
27661
|
+
from,
|
|
27662
|
+
to,
|
|
27663
|
+
...what,
|
|
27664
|
+
...files.length ? [] : [pass[0]],
|
|
27665
|
+
pass[1],
|
|
27666
|
+
pass[2],
|
|
27667
|
+
"/NFL",
|
|
27668
|
+
"/NDL",
|
|
27669
|
+
"/NJH",
|
|
27670
|
+
"/NJS",
|
|
27671
|
+
"/NP",
|
|
27672
|
+
"/XJ"
|
|
27673
|
+
],
|
|
27674
|
+
{ encoding: "utf8", windowsHide: true }
|
|
27675
|
+
);
|
|
27676
|
+
code = result.status;
|
|
27677
|
+
output3 = String(result.stdout ?? "") + String(result.stderr ?? "");
|
|
27678
|
+
if (!robocopyFailed(code)) return;
|
|
27679
|
+
}
|
|
27680
|
+
const why = robocopyErrors(output3);
|
|
27681
|
+
throw new Error(
|
|
27682
|
+
"robocopy " + from + " -> " + to + " failed (exit " + code + ": " + robocopyMeaning(code) + ")" + (why.length ? ": " + why.join(" | ") : "")
|
|
27630
27683
|
);
|
|
27631
|
-
if ((result.status ?? 16) >= 8)
|
|
27632
|
-
throw new Error("robocopy " + from + " -> " + to + " failed (exit " + result.status + ")");
|
|
27633
27684
|
}
|
|
27634
27685
|
function copyNode(from, to) {
|
|
27635
27686
|
const work = stagingFolder(to);
|
|
@@ -29990,6 +30041,10 @@ var ensureNvm = async () => {
|
|
|
29990
30041
|
log("Downloading nvm-windows (" + asset.url.split("/").slice(-2, -1)[0] + ") ...");
|
|
29991
30042
|
await downloader.archive(asset.url, NVM_HOME, { sha256: asset.sha256, locate: "nvm.exe", fileName: asset.name });
|
|
29992
30043
|
};
|
|
30044
|
+
var hostNodeFolder = (version2) => {
|
|
30045
|
+
const dir = path45.join(hostToolsDir, "node");
|
|
30046
|
+
return session.hostTools.nodeVersion?.replace(/^v/, "") === version2 && exists(path45.join(dir, "node.exe")) ? dir : null;
|
|
30047
|
+
};
|
|
29993
30048
|
var installNodeVersion = async (version2) => {
|
|
29994
30049
|
const dir = path45.join(NVM_HOME, "v" + version2);
|
|
29995
30050
|
const fromHost = path45.join(HOST_NVM, "v" + version2);
|
|
@@ -29999,6 +30054,12 @@ var installNodeVersion = async (version2) => {
|
|
|
29999
30054
|
copyNode(fromHost, dir);
|
|
30000
30055
|
return;
|
|
30001
30056
|
}
|
|
30057
|
+
const hostNode = hostNodeFolder(version2);
|
|
30058
|
+
if (hostNode) {
|
|
30059
|
+
log("Copying Node " + version2 + " from this PC (without global packages) ...");
|
|
30060
|
+
copyNode(hostNode, dir);
|
|
30061
|
+
return;
|
|
30062
|
+
}
|
|
30002
30063
|
log("Downloading Node " + version2 + " (~30 MB once) ...");
|
|
30003
30064
|
mkdirp(NVM_TEMP);
|
|
30004
30065
|
let out;
|
|
@@ -30007,8 +30068,13 @@ var installNodeVersion = async (version2) => {
|
|
|
30007
30068
|
} finally {
|
|
30008
30069
|
safeRemove(NVM_TEMP, NVM_HOME);
|
|
30009
30070
|
}
|
|
30010
|
-
if (!exists(path45.join(dir, "node.exe")))
|
|
30011
|
-
|
|
30071
|
+
if (!exists(path45.join(dir, "node.exe"))) {
|
|
30072
|
+
const tail2 = out.trim().split(/\r?\n/).filter(Boolean).slice(-3).join(" ");
|
|
30073
|
+
const why = /complete/i.test(out) ? "nvm said it finished, but its move into the data folder did not happen" : "nvm could not download or unpack it: check the internet connection and the free disk space";
|
|
30074
|
+
throw new Error(
|
|
30075
|
+
"nvm install " + version2 + " did not leave " + path45.join(dir, "node.exe") + " (" + why + ")" + (tail2 ? ". nvm printed: " + tail2 : "")
|
|
30076
|
+
);
|
|
30077
|
+
}
|
|
30012
30078
|
};
|
|
30013
30079
|
var activate = async (version2) => {
|
|
30014
30080
|
await nvm(["use", version2]);
|
|
@@ -33042,9 +33108,31 @@ function buildFirewallCommands(exemptIps) {
|
|
|
33042
33108
|
}
|
|
33043
33109
|
return commands;
|
|
33044
33110
|
}
|
|
33111
|
+
var ATTACH_RULE = "AgentSandbox-Attach";
|
|
33112
|
+
function hasShortName(file3) {
|
|
33113
|
+
return file3.split(/[/\\]/).some((part) => /~\d/.test(part));
|
|
33114
|
+
}
|
|
33115
|
+
function attachRuleCommands(ptyHostExe) {
|
|
33116
|
+
return [
|
|
33117
|
+
["advfirewall", "firewall", "delete", "rule", "name=" + ATTACH_RULE],
|
|
33118
|
+
[
|
|
33119
|
+
"advfirewall",
|
|
33120
|
+
"firewall",
|
|
33121
|
+
"add",
|
|
33122
|
+
"rule",
|
|
33123
|
+
"name=" + ATTACH_RULE,
|
|
33124
|
+
"dir=in",
|
|
33125
|
+
"action=allow",
|
|
33126
|
+
"enable=yes",
|
|
33127
|
+
"profile=any",
|
|
33128
|
+
"protocol=tcp",
|
|
33129
|
+
"remoteip=LocalSubnet",
|
|
33130
|
+
"program=" + ptyHostExe
|
|
33131
|
+
]
|
|
33132
|
+
];
|
|
33133
|
+
}
|
|
33045
33134
|
|
|
33046
33135
|
// src/guest/network/firewall.ts
|
|
33047
|
-
var ATTACH_RULE = "AgentSandbox-Attach";
|
|
33048
33136
|
var netsh = (args) => {
|
|
33049
33137
|
const r = spawnSync5(SYSTEM.netsh(), args, { encoding: "utf8", windowsHide: true });
|
|
33050
33138
|
return { status: r.status, out: (r.stdout ?? "") + (r.stderr ?? "") };
|
|
@@ -33078,23 +33166,38 @@ var waitForInternet = async (seconds) => {
|
|
|
33078
33166
|
}
|
|
33079
33167
|
return false;
|
|
33080
33168
|
};
|
|
33081
|
-
var
|
|
33082
|
-
|
|
33083
|
-
|
|
33084
|
-
|
|
33085
|
-
|
|
33086
|
-
|
|
33087
|
-
|
|
33088
|
-
|
|
33089
|
-
|
|
33090
|
-
|
|
33091
|
-
|
|
33092
|
-
|
|
33093
|
-
|
|
33094
|
-
|
|
33095
|
-
|
|
33096
|
-
|
|
33097
|
-
];
|
|
33169
|
+
var problems = [];
|
|
33170
|
+
function networkProblems() {
|
|
33171
|
+
return problems;
|
|
33172
|
+
}
|
|
33173
|
+
var longPath = (file3) => {
|
|
33174
|
+
if (!hasShortName(file3)) return file3;
|
|
33175
|
+
try {
|
|
33176
|
+
return fs48.realpathSync.native(file3);
|
|
33177
|
+
} catch {
|
|
33178
|
+
return file3;
|
|
33179
|
+
}
|
|
33180
|
+
};
|
|
33181
|
+
async function applyAttachRule(ptyHostExe) {
|
|
33182
|
+
if (!session.attach.enabled) return;
|
|
33183
|
+
if (!ptyHostExe) {
|
|
33184
|
+
problems.push("attach: the session host was not built, so the firewall rule for it was not created");
|
|
33185
|
+
warn(problems[problems.length - 1]);
|
|
33186
|
+
return;
|
|
33187
|
+
}
|
|
33188
|
+
let last = "";
|
|
33189
|
+
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
33190
|
+
try {
|
|
33191
|
+
run2(attachRuleCommands(longPath(ptyHostExe)));
|
|
33192
|
+
return;
|
|
33193
|
+
} catch (e) {
|
|
33194
|
+
last = errMsg(e);
|
|
33195
|
+
if (attempt < 3) await new Promise((r) => setTimeout(r, 1e3));
|
|
33196
|
+
}
|
|
33197
|
+
}
|
|
33198
|
+
problems.push("attach: the firewall rule for the session host could not be created (" + last + ")");
|
|
33199
|
+
warn("PROTECTION MISSING: " + problems[problems.length - 1]);
|
|
33200
|
+
}
|
|
33098
33201
|
function run2(commands) {
|
|
33099
33202
|
for (const c of commands) {
|
|
33100
33203
|
const r = netsh(c);
|
|
@@ -33110,10 +33213,11 @@ async function applyNetworkPolicy(ptyHostExe) {
|
|
|
33110
33213
|
const block = session.network.blockPrivateRanges;
|
|
33111
33214
|
const exempt = block ? discoverExemptIps() : [];
|
|
33112
33215
|
const lanRules = block ? buildFirewallCommands(exempt) : [];
|
|
33216
|
+
const forwardRules = forwardRule();
|
|
33113
33217
|
const commands = [
|
|
33114
33218
|
...lanRules,
|
|
33115
|
-
...ptyHostExe && session.attach.enabled ?
|
|
33116
|
-
...
|
|
33219
|
+
...ptyHostExe && session.attach.enabled ? attachRuleCommands(ptyHostExe) : [],
|
|
33220
|
+
...forwardRules
|
|
33117
33221
|
];
|
|
33118
33222
|
if (isWin && !dryRun) {
|
|
33119
33223
|
try {
|
|
@@ -33139,7 +33243,20 @@ async function applyNetworkPolicy(ptyHostExe) {
|
|
|
33139
33243
|
}
|
|
33140
33244
|
if (commands.length === 0) return "open";
|
|
33141
33245
|
const online = block ? await waitForInternet(30) : true;
|
|
33142
|
-
|
|
33246
|
+
const failures = [];
|
|
33247
|
+
try {
|
|
33248
|
+
run2(lanRules);
|
|
33249
|
+
} catch (e) {
|
|
33250
|
+
failures.push(errMsg(e));
|
|
33251
|
+
}
|
|
33252
|
+
await applyAttachRule(ptyHostExe);
|
|
33253
|
+
try {
|
|
33254
|
+
run2(forwardRules);
|
|
33255
|
+
} catch (e) {
|
|
33256
|
+
problems.push("forwardPorts: the firewall rule for the relays could not be created (" + errMsg(e) + ")");
|
|
33257
|
+
warn("PROTECTION MISSING: " + problems[problems.length - 1]);
|
|
33258
|
+
}
|
|
33259
|
+
if (failures.length) throw new Error(failures[0]);
|
|
33143
33260
|
if (!block) return "open";
|
|
33144
33261
|
if (online && !await waitForInternet(15)) {
|
|
33145
33262
|
run2(lanRules.filter((c) => c[2] === "delete"));
|
|
@@ -33584,10 +33701,10 @@ function directiveOf(line) {
|
|
|
33584
33701
|
return { name: parts2[0].toLowerCase(), args: parts2.slice(1) };
|
|
33585
33702
|
}
|
|
33586
33703
|
function inspectOvpn(text) {
|
|
33587
|
-
const
|
|
33704
|
+
const problems2 = [];
|
|
33588
33705
|
const found = [];
|
|
33589
33706
|
const refuse = (message) => {
|
|
33590
|
-
if (
|
|
33707
|
+
if (problems2.length < 20 && !problems2.includes(message)) problems2.push(message);
|
|
33591
33708
|
};
|
|
33592
33709
|
if (Buffer.byteLength(text, "utf8") > MAX_OVPN_BYTES) return { ok: false, problems: ["the file is too big"] };
|
|
33593
33710
|
if (text.includes("\0")) return { ok: false, problems: ["the file is not text"] };
|
|
@@ -33635,7 +33752,7 @@ function inspectOvpn(text) {
|
|
|
33635
33752
|
if (block) refuse("a <" + block + "> section is never closed");
|
|
33636
33753
|
if (remotes.length === 0) refuse("it names no remote server");
|
|
33637
33754
|
if (remotes.length > 64) refuse("it names too many servers");
|
|
33638
|
-
return
|
|
33755
|
+
return problems2.length ? { ok: false, problems: problems2 } : { ok: true, remotes };
|
|
33639
33756
|
}
|
|
33640
33757
|
function assertSafeOvpn(text, what) {
|
|
33641
33758
|
const report = inspectOvpn(text);
|
|
@@ -33672,9 +33789,9 @@ var inRange = (text, low, high) => {
|
|
|
33672
33789
|
};
|
|
33673
33790
|
var splitList = (text) => text.split(",").map((s) => s.trim()).filter(Boolean);
|
|
33674
33791
|
function inspectWireguard(text) {
|
|
33675
|
-
const
|
|
33792
|
+
const problems2 = [];
|
|
33676
33793
|
const refuse = (message) => {
|
|
33677
|
-
if (
|
|
33794
|
+
if (problems2.length < 20 && !problems2.includes(message)) problems2.push(message);
|
|
33678
33795
|
};
|
|
33679
33796
|
if (Buffer.byteLength(text, "utf8") > MAX_WIREGUARD_BYTES) return { ok: false, problems: ["the file is too big"] };
|
|
33680
33797
|
if (text.includes("\0")) return { ok: false, problems: ["the file is not text"] };
|
|
@@ -33716,7 +33833,7 @@ function inspectWireguard(text) {
|
|
|
33716
33833
|
if (interfaces.length !== 1) refuse("it needs exactly one [Interface] section");
|
|
33717
33834
|
if (peers.length === 0) refuse("it has no [Peer] section");
|
|
33718
33835
|
if (peers.length > MAX_PEERS) refuse("it has too many [Peer] sections");
|
|
33719
|
-
if (
|
|
33836
|
+
if (problems2.length) return { ok: false, problems: problems2 };
|
|
33720
33837
|
const face = interfaces[0];
|
|
33721
33838
|
const privateKey = one(face, "privatekey");
|
|
33722
33839
|
if (!privateKey) refuse("[Interface] has no PrivateKey");
|
|
@@ -33763,7 +33880,7 @@ function inspectWireguard(text) {
|
|
|
33763
33880
|
...keepalive !== void 0 ? { keepalive } : {}
|
|
33764
33881
|
});
|
|
33765
33882
|
});
|
|
33766
|
-
if (
|
|
33883
|
+
if (problems2.length || !privateKey) return { ok: false, problems: problems2 };
|
|
33767
33884
|
return {
|
|
33768
33885
|
ok: true,
|
|
33769
33886
|
config: {
|
|
@@ -34883,6 +35000,7 @@ var Boot = class {
|
|
|
34883
35000
|
databases: session.databases ?? [],
|
|
34884
35001
|
apps: session.apps
|
|
34885
35002
|
});
|
|
35003
|
+
for (const p of networkProblems()) tools.summary.push("PROTECTION MISSING: " + p);
|
|
34886
35004
|
if (!dryRun) tools.prepend.push(writeDbCommand());
|
|
34887
35005
|
Object.assign(tools.vars, proxyEnv());
|
|
34888
35006
|
await soft("Network: allow only some hosts", async () => {
|
package/dist/runtime/netproxy.js
CHANGED
|
@@ -26591,16 +26591,17 @@ var ResumeSchema = external_exports.strictObject({
|
|
|
26591
26591
|
// src/core/schema/web.ts
|
|
26592
26592
|
var WebSchema = external_exports.strictObject({
|
|
26593
26593
|
enabled: external_exports.boolean().describe(
|
|
26594
|
-
"(Beta)
|
|
26594
|
+
"(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."
|
|
26595
26595
|
),
|
|
26596
|
-
port: external_exports.number().int().min(1024).max(65535).describe("Port of the web UI on 127.0.0.1 (this PC only). Pick another when it is taken.")
|
|
26596
|
+
port: external_exports.number().int().min(1024).max(65535).describe("Port of the web UI on 127.0.0.1 (this PC only). Pick another when it is taken."),
|
|
26597
|
+
openOnLaunch: external_exports.boolean().describe("(Beta) With the web UI on: open it in your browser, already signed in, every time a sandbox starts.")
|
|
26597
26598
|
});
|
|
26598
26599
|
|
|
26599
26600
|
// src/core/schema/vpn.ts
|
|
26600
26601
|
var VPN_PROVIDERS = ["nordvpn", "openvpn-file", "wireguard-file"];
|
|
26601
26602
|
var VpnSchema = external_exports.strictObject({
|
|
26602
26603
|
enabled: external_exports.boolean().describe(
|
|
26603
|
-
"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)."
|
|
26604
|
+
"(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)."
|
|
26604
26605
|
),
|
|
26605
26606
|
provider: external_exports.enum(VPN_PROVIDERS).describe(
|
|
26606
26607
|
"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/runtime/view.js
CHANGED
|
@@ -26942,16 +26942,17 @@ var ResumeSchema = external_exports.strictObject({
|
|
|
26942
26942
|
// src/core/schema/web.ts
|
|
26943
26943
|
var WebSchema = external_exports.strictObject({
|
|
26944
26944
|
enabled: external_exports.boolean().describe(
|
|
26945
|
-
"(Beta)
|
|
26945
|
+
"(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."
|
|
26946
26946
|
),
|
|
26947
|
-
port: external_exports.number().int().min(1024).max(65535).describe("Port of the web UI on 127.0.0.1 (this PC only). Pick another when it is taken.")
|
|
26947
|
+
port: external_exports.number().int().min(1024).max(65535).describe("Port of the web UI on 127.0.0.1 (this PC only). Pick another when it is taken."),
|
|
26948
|
+
openOnLaunch: external_exports.boolean().describe("(Beta) With the web UI on: open it in your browser, already signed in, every time a sandbox starts.")
|
|
26948
26949
|
});
|
|
26949
26950
|
|
|
26950
26951
|
// src/core/schema/vpn.ts
|
|
26951
26952
|
var VPN_PROVIDERS = ["nordvpn", "openvpn-file", "wireguard-file"];
|
|
26952
26953
|
var VpnSchema = external_exports.strictObject({
|
|
26953
26954
|
enabled: external_exports.boolean().describe(
|
|
26954
|
-
"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)."
|
|
26955
|
+
"(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)."
|
|
26955
26956
|
),
|
|
26956
26957
|
provider: external_exports.enum(VPN_PROVIDERS).describe(
|
|
26957
26958
|
"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."
|
|
@@ -2,7 +2,10 @@
|
|
|
2
2
|
// what it copies in, and what it refuses to.
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
-
import {
|
|
5
|
+
import { spawnSync } from 'node:child_process';
|
|
6
|
+
import { copyTreeTolerant, readJsonFile, robocopyErrors, robocopyFailed, robocopyMeaning } from '../shared/io/index.js';
|
|
7
|
+
import { scriptArgs } from '../shared/scripts.js';
|
|
8
|
+
import { SYSTEM } from '../shared/system.js';
|
|
6
9
|
export const guestBootSuite = {
|
|
7
10
|
name: 'guest-boot',
|
|
8
11
|
title: 'guest boot in dry-run mode (provisioning, Claude state, git policy)',
|
|
@@ -92,5 +95,48 @@ export const guestBootSuite = {
|
|
|
92
95
|
const genNoAgent = await f.launch({ noAgent: true });
|
|
93
96
|
const noAgent = await f.runGuest(genNoAgent.launchPath, t.at('data5'), f.workA);
|
|
94
97
|
t.check(noAgent.status === 0 && noAgent.out.includes('no agent requested'), '--no-agent provisions without an agent', noAgent.out);
|
|
98
|
+
t.section('robocopy results, vanished files, shortcuts');
|
|
99
|
+
t.check([0, 1, 2, 3, 7].every((c) => !robocopyFailed(c)) &&
|
|
100
|
+
[8, 9, 16].every((c) => robocopyFailed(c)) &&
|
|
101
|
+
robocopyFailed(null), 'robocopy: exit codes below 8 are success, 8 and up (9 = some copied, some failed) are failures');
|
|
102
|
+
t.includes(robocopyMeaning(9), 'some files were copied and some could not be', 'robocopy: exit 9 is explained');
|
|
103
|
+
t.equal(robocopyErrors('x\r\n2026/10/11 ERROR 32 (0x00000020) Copying File C:\\a\\b.dll\r\nThe process cannot access the file because it is being used by another process.\r\n', 5), [
|
|
104
|
+
'2026/10/11 ERROR 32 (0x00000020) Copying File C:\\a\\b.dll',
|
|
105
|
+
'The process cannot access the file because it is being used by another process.',
|
|
106
|
+
], 'robocopy: the failing file and the reason are picked out of its output');
|
|
107
|
+
const spool = t.dir('copy-tree', 'live');
|
|
108
|
+
t.write(path.join(spool, 'a.json'), '1');
|
|
109
|
+
t.write(path.join(spool, 'b.json'), '2');
|
|
110
|
+
t.write(path.join(spool, 'db', 'chroma.sqlite3'), 'db');
|
|
111
|
+
t.write(path.join(spool, 'db', 'chroma.sqlite3-journal'), 'j');
|
|
112
|
+
t.write(path.join(spool, 'x.lock'), 'l');
|
|
113
|
+
const copied = copyTreeTolerant(spool, t.at('copy-tree', 'out'), (name) => /\.lock$|-journal$/.test(name));
|
|
114
|
+
t.check(copied.copied === 3 && !fs.existsSync(t.at('copy-tree', 'out', 'db', 'chroma.sqlite3-journal')), 'copy tree: files are copied, a -journal and a lock file are left out');
|
|
115
|
+
// The first file of the listing deletes the second before it is reached: the second has vanished, not failed.
|
|
116
|
+
const racing = t.dir('copy-tree', 'racing');
|
|
117
|
+
t.write(path.join(racing, 'a.json'), '1');
|
|
118
|
+
t.write(path.join(racing, 'b.json'), '2');
|
|
119
|
+
const result = copyTreeTolerant(racing, t.at('copy-tree', 'racing-out'), (name) => {
|
|
120
|
+
if (name === 'a.json')
|
|
121
|
+
fs.rmSync(path.join(racing, 'b.json'), { force: true });
|
|
122
|
+
return false;
|
|
123
|
+
});
|
|
124
|
+
t.check(result.copied === 1 && result.vanished === 1, 'copy tree: a file that vanishes during the copy is skipped, not an error');
|
|
125
|
+
t.equal(copyTreeTolerant(t.at('copy-tree', 'nothing'), t.at('copy-tree', 'nothing-out')).vanished, 1, 'copy tree: a missing folder is tolerated');
|
|
126
|
+
if (process.platform === 'win32') {
|
|
127
|
+
// Windows PowerShell 5.1 hands a JSON array over as one object: two or more shortcuts used to fail.
|
|
128
|
+
const out = t.dir('shortcuts');
|
|
129
|
+
const list = t.writeJson(path.join(out, 'in.json'), [
|
|
130
|
+
{ link: path.join(out, 'one', 'A.lnk'), target: path.join(process.env.WINDIR ?? 'C:\\Windows', 'notepad.exe') },
|
|
131
|
+
{ link: path.join(out, 'two', 'B.lnk'), target: path.join(process.env.WINDIR ?? 'C:\\Windows', 'notepad.exe') },
|
|
132
|
+
]);
|
|
133
|
+
const made = spawnSync(SYSTEM.powershell(), scriptArgs('new-shortcuts.ps1', ['-ListFile', list]), {
|
|
134
|
+
encoding: 'utf8',
|
|
135
|
+
windowsHide: true,
|
|
136
|
+
});
|
|
137
|
+
t.check(made.status === 0 &&
|
|
138
|
+
fs.existsSync(path.join(out, 'one', 'A.lnk')) &&
|
|
139
|
+
fs.existsSync(path.join(out, 'two', 'B.lnk')), 'new-shortcuts.ps1 makes every shortcut of a list of two', String(made.stderr));
|
|
140
|
+
}
|
|
95
141
|
},
|
|
96
142
|
};
|
|
@@ -8,7 +8,7 @@ import { DEFAULTS } from '../core/config/index.js';
|
|
|
8
8
|
import { isWin } from '../core/util/index.js';
|
|
9
9
|
import { SYSTEM } from '../shared/system.js';
|
|
10
10
|
import { GuestRequestSchema, PressureStatusSchema } from '../shared/channel/index.js';
|
|
11
|
-
import { GUEST_THRESHOLDS, HOST_THRESHOLDS, cargoJobs, effectiveCpuLimit, gentleCpuLimit, } from '../shared/memory/index.js';
|
|
11
|
+
import { GUEST_THRESHOLDS, HOST_THRESHOLDS, cargoJobs, effectiveCpuLimit, gentleCpuLimit, maxCompilers, } from '../shared/memory/index.js';
|
|
12
12
|
import { agentMemoryLimitMb, decideMemory, memoryNote, resolveMemoryMb } from '../host/launch/index.js';
|
|
13
13
|
import { vgpuWarning, GENTLE_NOTE } from '../host/launch/risk-notes.js';
|
|
14
14
|
import { bugcheckCode, cpuPercentBetween, explainStop, findDumps, findingLines, freezeLine, hostPressure, lowReason, pageFileRisk, parseHostMemory, crashReportText, watchdogStep, WATCHDOG_START, } from '../host/life/index.js';
|
|
@@ -149,6 +149,22 @@ export const heavyLoadSuite = {
|
|
|
149
149
|
g.actions.length === 3 &&
|
|
150
150
|
g.actions.every((a) => a.kind === 'priority' && a.to === 'below' && a.pid !== 3), 'compilers start below normal even with plenty of memory (the agent does not)');
|
|
151
151
|
t.check(decide(INITIAL_STATE, sample(14, 50, procs), 0, gentleT, 'gentle').level === 'throttled', '14 % free is pressure');
|
|
152
|
+
t.section('the compiler cap (vGPU on)');
|
|
153
|
+
t.check(maxCompilers(16, 'gentle') === 4 && maxCompilers(4, 'gentle') === 2 && maxCompilers(16, 'normal') === null, 'at most max(2, cores / 4) with vGPU, none otherwise');
|
|
154
|
+
const many = [
|
|
155
|
+
proc(10, 'rustc.exe', 800),
|
|
156
|
+
proc(11, 'rustc.exe', 900),
|
|
157
|
+
proc(12, 'clippy-driver.exe', 700),
|
|
158
|
+
proc(13, 'link.exe', 100),
|
|
159
|
+
proc(3, 'claude.exe', 500),
|
|
160
|
+
proc(14, 'node.exe', 400),
|
|
161
|
+
];
|
|
162
|
+
const capped = decide(INITIAL_STATE, sample(60, 40, many), 0, gentleT, 'gentle', 2);
|
|
163
|
+
const holds = capped.actions.filter((a) => a.kind === 'hold').map((a) => a.pid);
|
|
164
|
+
t.check(holds.length === 2 && holds.includes(12) && holds.includes(13), 'the newest compilers beyond the cap wait; the oldest keep running', holds.join(','));
|
|
165
|
+
t.check(!holds.includes(3) && !holds.includes(14), 'agents and build tools are never held');
|
|
166
|
+
t.check(decide(INITIAL_STATE, sample(60, 40, many), 0, gentleT, 'gentle', null).actions.every((a) => a.kind !== 'hold'), 'no cap, no holds');
|
|
167
|
+
t.check(decide(INITIAL_STATE, sample(60, 40, many.slice(0, 2)), 0, gentleT, 'gentle', 2).actions.every((a) => a.kind !== 'hold'), 'under the cap nothing waits');
|
|
152
168
|
t.section('the governor over a port (fake Windows)');
|
|
153
169
|
const calls = [];
|
|
154
170
|
const port = {
|
|
@@ -209,6 +225,16 @@ export const heavyLoadSuite = {
|
|
|
209
225
|
}
|
|
210
226
|
t.check(sent && cpu.low, 'CPU above 95 % for 60 s: asks the sandbox to slow down');
|
|
211
227
|
t.check(!watchdogStep(WATCHDOG_START, input(pc(), 0, 97)).state.low, 'a short CPU peak is nothing');
|
|
228
|
+
let disk = WATCHDOG_START;
|
|
229
|
+
let diskSent = false;
|
|
230
|
+
for (let i = 0; i < 5; i++) {
|
|
231
|
+
const st = watchdogStep(disk, input(pc({ diskQueue: 40 }), i * 10000));
|
|
232
|
+
disk = st.state;
|
|
233
|
+
diskSent = diskSent || st.send;
|
|
234
|
+
}
|
|
235
|
+
t.check(diskSent && disk.low, 'a disk queue of 32 or more for 40 s: asks the sandbox to slow down');
|
|
236
|
+
t.check(!watchdogStep(WATCHDOG_START, input(pc({ diskQueue: 40 }), 0)).state.low, 'a short disk peak is nothing');
|
|
237
|
+
t.check(!watchdogStep(WATCHDOG_START, input(pc(), 0)).state.low, 'no disk counter: memory and CPU only');
|
|
212
238
|
t.check(cpuPercentBetween([{ times: { user: 0, nice: 0, sys: 0, idle: 0, irq: 0 } }], [{ times: { user: 90, nice: 0, sys: 0, idle: 10, irq: 0 } }]) === 90, 'CPU use from two readings');
|
|
213
239
|
t.check(GuestRequestSchema.safeParse({ kind: 'pressure', id: 'p1', reason: 'low', seconds: 90, createdAt: 'now' })
|
|
214
240
|
.success &&
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { isSandboxAddress } from '../host/channel/index.js';
|
|
2
|
-
import { buildFirewallCommands, cidrToRange, ipv4DnsServers, parseDefaultGateways, parseDnsServers, subtractIps, } from '../shared/network/index.js';
|
|
2
|
+
import { attachRuleCommands, hasShortName, buildFirewallCommands, cidrToRange, ipv4DnsServers, parseDefaultGateways, parseDnsServers, subtractIps, } from '../shared/network/index.js';
|
|
3
3
|
import { expandAllowList, hostAllowed, withBlockedHosts } from '../shared/network/index.js';
|
|
4
4
|
export const networkSuite = {
|
|
5
5
|
name: 'network',
|
|
@@ -11,6 +11,25 @@ export const networkSuite = {
|
|
|
11
11
|
const remaining = subtractIps([[start172, end172]], ['172.28.0.1']);
|
|
12
12
|
t.check(remaining.every(([start, end]) => gateway < start || gateway > end), 'the private block minus the gateway no longer contains the gateway');
|
|
13
13
|
t.excludes(buildFirewallCommands(['8.8.8.8']).join(), '8.8.8.8', 'public addresses are not part of the private-range rules');
|
|
14
|
+
t.section('the attach rule');
|
|
15
|
+
const exe = 'C:\\Users\\Me\\AppData\\Local\\Temp\\agent-sandbox\\ptyhost-abc.exe';
|
|
16
|
+
const attach = attachRuleCommands(exe);
|
|
17
|
+
t.equal(attach[0], ['advfirewall', 'firewall', 'delete', 'rule', 'name=AgentSandbox-Attach'], 'attach: the old rule is deleted first');
|
|
18
|
+
t.equal(attach[1].slice(2), [
|
|
19
|
+
'add',
|
|
20
|
+
'rule',
|
|
21
|
+
'name=AgentSandbox-Attach',
|
|
22
|
+
'dir=in',
|
|
23
|
+
'action=allow',
|
|
24
|
+
'enable=yes',
|
|
25
|
+
'profile=any',
|
|
26
|
+
'protocol=tcp',
|
|
27
|
+
'remoteip=LocalSubnet',
|
|
28
|
+
'program=' + exe,
|
|
29
|
+
], 'attach: inbound TCP to ptyhost only, from the local subnet only');
|
|
30
|
+
t.check(hasShortName('C:\\Users\\WDAGUT~1\\AppData\\Local\\Temp\\agent-sandbox\\ptyhost.exe'), 'an 8.3 short path is recognised (netsh cannot resolve it: "The application name could not be resolved")');
|
|
31
|
+
t.check(!hasShortName('C:\\Users\\WDAGUtilityAccount\\AppData\\Local\\Temp\\ptyhost.exe'), 'a long path is left alone');
|
|
32
|
+
t.check(!hasShortName('C:\\Users\\a~b\\x.exe'), 'a tilde not followed by a digit is not a short name');
|
|
14
33
|
t.section('reading Windows output');
|
|
15
34
|
t.equal(parseDefaultGateways(' 0.0.0.0 0.0.0.0 172.28.80.1 172.28.85.200 5\n'), ['172.28.80.1'], 'the default gateway is read from the route table');
|
|
16
35
|
t.equal(parseDnsServers(' DNS Servers . . . : 172.28.80.1\n 8.8.8.8\n NetBIOS : x\n'), ['172.28.80.1', '8.8.8.8'], 'DNS servers are read from ipconfig output');
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export interface CopyTreeResult {
|
|
2
|
+
readonly copied: number;
|
|
3
|
+
/** Files (or folders) that were gone by the time they were reached. */
|
|
4
|
+
readonly vanished: number;
|
|
5
|
+
}
|
|
6
|
+
/** Copies `from` into `to`; `skip(name)` leaves out files by name. Any error except "no such file" is thrown. */
|
|
7
|
+
export declare function copyTreeTolerant(from: string, to: string, skip?: (name: string) => boolean): CopyTreeResult;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Copies a folder that something else is still writing to: a file that disappears between the listing and the copy
|
|
2
|
+
// (a spool file the agent's hook just consumed, a SQLite journal that was committed) is skipped, not an error.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
const isGone = (e) => e?.code === 'ENOENT';
|
|
6
|
+
/** Copies `from` into `to`; `skip(name)` leaves out files by name. Any error except "no such file" is thrown. */
|
|
7
|
+
export function copyTreeTolerant(from, to, skip = () => false) {
|
|
8
|
+
let copied = 0;
|
|
9
|
+
let vanished = 0;
|
|
10
|
+
const walk = (src, dest) => {
|
|
11
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
12
|
+
let entries;
|
|
13
|
+
try {
|
|
14
|
+
entries = fs.readdirSync(src, { withFileTypes: true });
|
|
15
|
+
}
|
|
16
|
+
catch (e) {
|
|
17
|
+
if (!isGone(e))
|
|
18
|
+
throw e;
|
|
19
|
+
vanished++;
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
for (const entry of entries) {
|
|
23
|
+
if (skip(entry.name))
|
|
24
|
+
continue;
|
|
25
|
+
const source = path.join(src, entry.name);
|
|
26
|
+
const target = path.join(dest, entry.name);
|
|
27
|
+
try {
|
|
28
|
+
if (entry.isDirectory())
|
|
29
|
+
walk(source, target);
|
|
30
|
+
else if (entry.isFile()) {
|
|
31
|
+
fs.copyFileSync(source, target);
|
|
32
|
+
copied++;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
catch (e) {
|
|
36
|
+
if (!isGone(e))
|
|
37
|
+
throw e;
|
|
38
|
+
vanished++;
|
|
39
|
+
fs.rmSync(target, { force: true });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
walk(from, to);
|
|
44
|
+
return { copied, vanished };
|
|
45
|
+
}
|