windows-development-sandbox 1.1.0 → 1.4.1
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/.sandbox-config.example.json +2 -1
- package/README.md +3 -2
- package/config.example.json +39 -5
- package/dist/cli/args.d.ts +3 -0
- package/dist/cli/args.js +3 -1
- package/dist/cli/boot-progress.d.ts +22 -0
- package/dist/cli/boot-progress.js +59 -0
- package/dist/cli/commands/accounts.js +2 -2
- package/dist/cli/commands/agent-commands.js +1 -1
- package/dist/cli/commands/context.js +2 -1
- package/dist/cli/commands/data/clean.js +6 -1
- package/dist/cli/commands/data/collect.js +3 -3
- package/dist/cli/commands/index.d.ts +1 -1
- package/dist/cli/commands/index.js +1 -1
- package/dist/cli/commands/launch.d.ts +5 -2
- package/dist/cli/commands/launch.js +113 -25
- package/dist/cli/commands/maintenance.d.ts +2 -0
- package/dist/cli/commands/maintenance.js +70 -4
- package/dist/cli/commands/project.d.ts +10 -6
- package/dist/cli/commands/project.js +42 -3
- package/dist/cli/commands/restart.js +17 -7
- package/dist/cli/commands/sessions.js +5 -4
- package/dist/cli/commands/user-config.js +1 -1
- package/dist/cli/index.js +6 -4
- package/dist/cli/manifest.js +6 -1
- package/dist/cli/menu/apps.d.ts +21 -0
- package/dist/cli/menu/apps.js +122 -0
- package/dist/cli/menu/command-form.d.ts +28 -0
- package/dist/cli/menu/command-form.js +219 -0
- package/dist/cli/menu/index.d.ts +26 -0
- package/dist/cli/menu/index.js +180 -0
- package/dist/cli/menu/settings/categories.d.ts +9 -0
- package/dist/cli/menu/settings/categories.js +28 -0
- package/dist/cli/menu/settings/config-file.d.ts +40 -0
- package/dist/cli/menu/settings/config-file.js +71 -0
- package/dist/cli/menu/settings/editors.d.ts +30 -0
- package/dist/cli/menu/settings/editors.js +305 -0
- package/dist/cli/menu/settings/index.d.ts +32 -0
- package/dist/cli/menu/settings/index.js +136 -0
- package/dist/cli/menu/settings/schema-node.d.ts +62 -0
- package/dist/cli/menu/settings/schema-node.js +104 -0
- package/dist/cli/{menu-multi.d.ts → menu/several.d.ts} +10 -2
- package/dist/cli/menu/several.js +102 -0
- package/dist/cli/progress.d.ts +15 -0
- package/dist/cli/progress.js +52 -0
- package/dist/cli/tui/app.d.ts +39 -0
- package/dist/cli/tui/app.js +52 -0
- package/dist/cli/tui/dialogs.d.ts +57 -0
- package/dist/cli/tui/dialogs.js +162 -0
- package/dist/cli/tui/frame.d.ts +18 -0
- package/dist/cli/tui/frame.js +27 -0
- package/dist/cli/tui/index.d.ts +7 -0
- package/dist/cli/tui/index.js +8 -0
- package/dist/cli/tui/list-screen.d.ts +101 -0
- package/dist/cli/tui/list-screen.js +299 -0
- package/dist/cli/tui/terminal.d.ts +37 -0
- package/dist/cli/tui/terminal.js +89 -0
- package/dist/cli/tui/text.d.ts +20 -0
- package/dist/cli/tui/text.js +105 -0
- package/dist/cli/tui/theme.d.ts +45 -0
- package/dist/cli/tui/theme.js +30 -0
- package/dist/core/config.js +10 -7
- package/dist/core/local-config.d.ts +31 -1
- package/dist/core/local-config.js +72 -7
- package/dist/core/project-policy.d.ts +50 -3
- package/dist/core/project-policy.js +90 -4
- package/dist/core/schema/apps.d.ts +40 -0
- package/dist/core/schema/apps.js +27 -0
- package/dist/core/schema/config.d.ts +69 -3
- package/dist/core/schema/config.js +18 -6
- package/dist/core/schema/profile.d.ts +5 -0
- package/dist/core/schema/profile.js +4 -2
- package/dist/core/schema/projects.d.ts +1 -0
- package/dist/core/schema/projects.js +1 -0
- package/dist/core/schema/sandbox.d.ts +29 -1
- package/dist/core/schema/sandbox.js +29 -1
- package/dist/core/schema/toolchains.d.ts +3 -0
- package/dist/core/schema/toolchains.js +6 -1
- package/dist/core/types.d.ts +24 -1
- package/dist/core/util.d.ts +2 -0
- package/dist/core/util.js +2 -0
- package/dist/core/workspace.d.ts +21 -4
- package/dist/core/workspace.js +99 -21
- package/dist/guest/apps/index.d.ts +10 -0
- package/dist/guest/apps/index.js +206 -0
- package/dist/guest/apps/resolvers.d.ts +8 -0
- package/dist/guest/apps/resolvers.js +90 -0
- package/dist/guest/audit-parse.d.ts +7 -0
- package/dist/guest/audit-parse.js +24 -0
- package/dist/guest/audit.d.ts +4 -0
- package/dist/guest/audit.js +47 -0
- package/dist/guest/daemon/relays.d.ts +1 -0
- package/dist/guest/daemon/relays.js +18 -0
- package/dist/guest/daemon.js +4 -0
- package/dist/guest/env.d.ts +7 -2
- package/dist/guest/env.js +17 -3
- package/dist/guest/helpers.d.ts +1 -1
- package/dist/guest/main.js +20 -3
- package/dist/guest/netproxy.d.ts +1 -0
- package/dist/guest/netproxy.js +100 -0
- package/dist/guest/ptyhost.js +14 -4
- package/dist/guest/setup/claude.d.ts +0 -4
- package/dist/guest/setup/claude.js +48 -2
- package/dist/guest/setup/network.d.ts +9 -0
- package/dist/guest/setup/network.js +97 -4
- package/dist/guest/setup/user.js +4 -0
- package/dist/guest/toolchains/index.js +29 -3
- package/dist/guest/toolchains/native/mesa.js +13 -3
- package/dist/guest/toolchains/plan.d.ts +3 -1
- package/dist/guest/toolchains/plan.js +53 -9
- package/dist/guest/toolchains/python.js +26 -2
- package/dist/guest/toolchains/vscode-profile.d.ts +30 -0
- package/dist/guest/toolchains/vscode-profile.js +69 -0
- package/dist/guest/toolchains/vscode.d.ts +17 -0
- package/dist/guest/toolchains/vscode.js +87 -1
- package/dist/host/attach.js +2 -1
- package/dist/host/audit/collect.d.ts +34 -0
- package/dist/host/audit/collect.js +126 -0
- package/dist/host/audit/log.d.ts +46 -0
- package/dist/host/audit/log.js +143 -0
- package/dist/host/channel.d.ts +2 -2
- package/dist/host/channel.js +3 -2
- package/dist/host/generate.d.ts +7 -1
- package/dist/host/generate.js +23 -6
- package/dist/host/join.d.ts +7 -0
- package/dist/host/join.js +23 -9
- package/dist/host/launch/mappings.d.ts +7 -2
- package/dist/host/launch/mappings.js +68 -18
- package/dist/host/launch/projects.d.ts +2 -0
- package/dist/host/launch/projects.js +11 -5
- package/dist/host/launch/stage.js +1 -1
- package/dist/host/motw.d.ts +17 -0
- package/dist/host/motw.js +74 -0
- package/dist/host/platform.js +3 -0
- package/dist/host/port-forward.d.ts +8 -0
- package/dist/host/port-forward.js +37 -0
- package/dist/host/projects.d.ts +25 -2
- package/dist/host/projects.js +54 -5
- package/dist/host/repo-guard.d.ts +10 -3
- package/dist/host/repo-guard.js +142 -14
- package/dist/host/stops.d.ts +67 -0
- package/dist/host/stops.js +126 -0
- package/dist/host/terminal.d.ts +8 -0
- package/dist/host/terminal.js +45 -1
- package/dist/host/workspace-view/index.d.ts +20 -0
- package/dist/host/workspace-view/index.js +53 -0
- package/dist/host/workspace-view/rules.d.ts +26 -0
- package/dist/host/workspace-view/rules.js +93 -0
- package/dist/host/workspace-view/view.d.ts +50 -0
- package/dist/host/workspace-view/view.js +281 -0
- package/dist/runtime/daemon.js +1387 -558
- package/dist/runtime/db.js +199 -14
- package/dist/runtime/main.js +3082 -631
- package/dist/runtime/netproxy.js +25948 -0
- package/dist/runtime/view.js +531 -148
- package/dist/selftest/audit.test.d.ts +2 -0
- package/dist/selftest/audit.test.js +57 -0
- package/dist/selftest/channel.test.js +45 -2
- package/dist/selftest/cli.test.js +47 -17
- package/dist/selftest/fixtures.d.ts +47 -1
- package/dist/selftest/fixtures.js +3 -1
- package/dist/selftest/generate.test.js +7 -2
- package/dist/selftest/index.js +6 -2
- package/dist/selftest/live-network.test.d.ts +2 -0
- package/dist/selftest/live-network.test.js +63 -0
- package/dist/selftest/live-terminal.test.d.ts +2 -0
- package/dist/selftest/live-terminal.test.js +169 -0
- package/dist/selftest/local-config.test.js +42 -4
- package/dist/selftest/mirror.test.js +36 -7
- package/dist/selftest/network.test.js +12 -0
- package/dist/selftest/projects.test.js +77 -8
- package/dist/selftest/repo-guard.test.js +59 -1
- package/dist/selftest/sync.test.js +50 -10
- package/dist/selftest/toolchains.test.js +30 -0
- package/dist/selftest/workspace-view.test.d.ts +2 -0
- package/dist/selftest/workspace-view.test.js +89 -0
- package/dist/shared/apps.d.ts +49 -0
- package/dist/shared/apps.js +114 -0
- package/dist/shared/console-input.d.ts +11 -0
- package/dist/shared/console-input.js +61 -0
- package/dist/shared/download.d.ts +3 -0
- package/dist/shared/download.js +25 -2
- package/dist/shared/guest-paths.d.ts +27 -0
- package/dist/shared/guest-paths.js +68 -0
- package/dist/shared/netpolicy.d.ts +27 -0
- package/dist/shared/netpolicy.js +51 -0
- package/dist/shared/projects.d.ts +27 -4
- package/dist/shared/projects.js +72 -13
- package/dist/shared/pty-client.d.ts +26 -4
- package/dist/shared/pty-client.js +204 -25
- package/dist/shared/rundir.d.ts +2 -0
- package/dist/shared/rundir.js +2 -0
- package/dist/shared/schema.d.ts +34 -0
- package/dist/shared/schema.js +14 -0
- package/dist/shared/system.d.ts +1 -0
- package/dist/shared/system.js +1 -0
- package/dist/sync/claude-mem.js +3 -2
- package/dist/sync/history.d.ts +6 -0
- package/dist/sync/history.js +26 -11
- package/dist/sync/watcher.js +78 -6
- package/package.json +2 -1
- package/sandbox/cargoshim.cs +56 -0
- package/sandbox/cpucap.cs +33 -0
- package/sandbox/cpucap.ps1 +4 -35
- package/sandbox/logon.cmd +6 -0
- package/sandbox/memlog.ps1 +34 -0
- package/sandbox/pagefile.cs +31 -0
- package/sandbox/pagefile.ps1 +21 -0
- package/sandbox/ptyhost.cs +162 -7
- package/schemas/config.schema.json +318 -3
- package/schemas/sandbox-config.schema.json +129 -14
- package/dist/cli/menu-multi.js +0 -82
- package/dist/cli/menu.d.ts +0 -14
- package/dist/cli/menu.js +0 -290
- package/dist/cli/settings/edit.d.ts +0 -13
- package/dist/cli/settings/edit.js +0 -19
- package/dist/cli/settings/index.d.ts +0 -8
- package/dist/cli/settings/index.js +0 -131
- package/dist/cli/settings/prompts.d.ts +0 -7
- package/dist/cli/settings/prompts.js +0 -48
- package/dist/cli/settings/schema.d.ts +0 -33
- package/dist/cli/settings/schema.js +0 -29
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// The sandbox's own proxy for network.allowOnly (runs detached for the sandbox's life, as sbx-netproxy.exe so the
|
|
2
|
+
// firewall can let only it out). HTTPS goes through CONNECT, plain HTTP as proxy requests; a host that is not allowed
|
|
3
|
+
// gets 403 and a line in logs\netproxy.log. With network.proxy set it hands everything on to that proxy.
|
|
4
|
+
// Usage: sbx-netproxy.exe netproxy.js
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import http from 'node:http';
|
|
7
|
+
import net from 'node:net';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { expandAllowList, hostAllowed, PROXY_PORT } from '../shared/netpolicy.js';
|
|
10
|
+
import { logsDir, session } from './env.js';
|
|
11
|
+
const allowed = expandAllowList(session.network.allowOnly ?? []);
|
|
12
|
+
const upstream = session.network.proxy ? new URL(session.network.proxy) : null;
|
|
13
|
+
const logFile = path.join(logsDir, 'netproxy.log');
|
|
14
|
+
let logged = 0;
|
|
15
|
+
/** Refusals, capped: a program retrying in a loop must not fill the disk. */
|
|
16
|
+
const refuse = (host) => {
|
|
17
|
+
if (logged++ > 5000)
|
|
18
|
+
return;
|
|
19
|
+
try {
|
|
20
|
+
fs.appendFileSync(logFile, new Date().toISOString() + ' blocked ' + host + '\n');
|
|
21
|
+
}
|
|
22
|
+
catch { /* ignore */ }
|
|
23
|
+
};
|
|
24
|
+
/** Opens a TCP tunnel to host:port, directly or through the upstream proxy (CONNECT). */
|
|
25
|
+
const tunnel = (host, port, onReady, onError) => {
|
|
26
|
+
if (!upstream) {
|
|
27
|
+
const socket = net.connect(port, host, () => onReady(socket));
|
|
28
|
+
socket.once('error', onError);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
const socket = net.connect(Number(upstream.port || 80), upstream.hostname, () => socket.write('CONNECT ' + host + ':' + port + ' HTTP/1.1\r\nHost: ' + host + ':' + port + '\r\n\r\n'));
|
|
32
|
+
socket.once('error', onError);
|
|
33
|
+
let head = '';
|
|
34
|
+
const onData = (chunk) => {
|
|
35
|
+
head += chunk.toString('latin1');
|
|
36
|
+
const end = head.indexOf('\r\n\r\n');
|
|
37
|
+
if (end < 0) {
|
|
38
|
+
if (head.length > 16384) {
|
|
39
|
+
socket.destroy();
|
|
40
|
+
onError();
|
|
41
|
+
}
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
socket.off('data', onData);
|
|
45
|
+
if (!/^HTTP\/1\.\d 200/.test(head)) {
|
|
46
|
+
socket.destroy();
|
|
47
|
+
onError();
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const rest = Buffer.from(head.slice(end + 4), 'latin1');
|
|
51
|
+
if (rest.length)
|
|
52
|
+
socket.unshift(rest);
|
|
53
|
+
onReady(socket);
|
|
54
|
+
};
|
|
55
|
+
socket.on('data', onData);
|
|
56
|
+
};
|
|
57
|
+
const server = http.createServer((req, res) => {
|
|
58
|
+
// Plain HTTP: the request line has the full URL.
|
|
59
|
+
let target;
|
|
60
|
+
try {
|
|
61
|
+
target = new URL(req.url ?? '');
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
res.writeHead(400).end('bad proxy request');
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (!hostAllowed(target.hostname, allowed)) {
|
|
68
|
+
refuse(target.hostname);
|
|
69
|
+
res.writeHead(403, { 'Content-Type': 'text/plain' }).end('Blocked by the sandbox (network.allowOnly): ' + target.hostname + '\n');
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const options = upstream
|
|
73
|
+
? { host: upstream.hostname, port: Number(upstream.port || 80), path: target.href, method: req.method, headers: req.headers }
|
|
74
|
+
: { host: target.hostname, port: Number(target.port || 80), path: target.pathname + target.search, method: req.method, headers: req.headers };
|
|
75
|
+
const out = http.request(options, (up) => { res.writeHead(up.statusCode ?? 502, up.headers); up.pipe(res); });
|
|
76
|
+
out.on('error', () => { if (!res.headersSent)
|
|
77
|
+
res.writeHead(502).end('upstream error');
|
|
78
|
+
else
|
|
79
|
+
res.destroy(); });
|
|
80
|
+
req.pipe(out);
|
|
81
|
+
});
|
|
82
|
+
server.on('connect', (req, client, head) => {
|
|
83
|
+
const [host, portText] = (req.url ?? '').split(/:(?=\d+$)/);
|
|
84
|
+
const port = Number(portText || 443);
|
|
85
|
+
client.on('error', () => client.destroy());
|
|
86
|
+
if (!host || !(port > 0 && port < 65536) || !hostAllowed(host, allowed)) {
|
|
87
|
+
refuse(host ?? '?');
|
|
88
|
+
client.end('HTTP/1.1 403 Forbidden\r\nContent-Type: text/plain\r\n\r\nBlocked by the sandbox (network.allowOnly): ' + host + '\n');
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
tunnel(host, port, (remote) => {
|
|
92
|
+
client.write('HTTP/1.1 200 Connection Established\r\n\r\n');
|
|
93
|
+
if (head.length)
|
|
94
|
+
remote.write(head);
|
|
95
|
+
remote.pipe(client);
|
|
96
|
+
client.pipe(remote);
|
|
97
|
+
remote.on('error', () => client.destroy());
|
|
98
|
+
}, () => client.end('HTTP/1.1 502 Bad Gateway\r\n\r\n'));
|
|
99
|
+
});
|
|
100
|
+
server.listen(PROXY_PORT, '127.0.0.1');
|
package/dist/guest/ptyhost.js
CHANGED
|
@@ -4,7 +4,7 @@ import path from 'node:path';
|
|
|
4
4
|
import { spawn } from 'node:child_process';
|
|
5
5
|
import { ensureCompiled } from './helpers.js';
|
|
6
6
|
import { portOpen } from './ports.js';
|
|
7
|
-
import { logsDir } from './env.js';
|
|
7
|
+
import { logsDir, session, toolsDir } from './env.js';
|
|
8
8
|
export const ensurePtyHost = () => ensureCompiled('ptyhost');
|
|
9
9
|
/** Resolves when ptyhost listens; rejects early if it exits first (e.g. the agent could not be started). */
|
|
10
10
|
export const waitForPtyHost = async (child, port, timeoutMs) => {
|
|
@@ -30,7 +30,17 @@ export const ptyHostLogTail = (id, lines = 12) => {
|
|
|
30
30
|
return '';
|
|
31
31
|
}
|
|
32
32
|
};
|
|
33
|
+
/** conpty.dll (+ OpenConsole.exe) the host downloaded, when provisioning.conpty is on; ptyhost falls back to Windows' own. */
|
|
34
|
+
const conptyDll = () => {
|
|
35
|
+
const dll = path.join(toolsDir, 'conpty', 'conpty.dll');
|
|
36
|
+
return session.provisioning.conpty?.enabled !== false && fs.existsSync(dll) && fs.existsSync(path.join(toolsDir, 'conpty', 'OpenConsole.exe')) ? dll : null;
|
|
37
|
+
};
|
|
33
38
|
/** Starts ptyhost detached, so closing a terminal tab never kills the agent; only the agent exiting ends it. */
|
|
34
|
-
export const startPtyHost = (o) =>
|
|
35
|
-
|
|
36
|
-
|
|
39
|
+
export const startPtyHost = (o) => {
|
|
40
|
+
const env = { ...o.env, PTYHOST_BIND: o.remote ? 'any' : 'loopback' };
|
|
41
|
+
delete env.PTYHOST_CONPTY;
|
|
42
|
+
const dll = conptyDll();
|
|
43
|
+
if (dll)
|
|
44
|
+
env.PTYHOST_CONPTY = dll;
|
|
45
|
+
return spawn(o.exe, [String(o.port), o.token, String(o.cols), String(o.rows), ptyHostLog(o.id), o.command, o.restartCommand], { cwd: o.cwd, env, detached: true, stdio: 'ignore', windowsHide: true });
|
|
46
|
+
};
|
|
@@ -14,8 +14,4 @@ export declare const claudeMemPaths: {
|
|
|
14
14
|
local: string;
|
|
15
15
|
};
|
|
16
16
|
export declare const restoreClaudeMem: () => void;
|
|
17
|
-
/**
|
|
18
|
-
* Copies the live claude-mem folder back to persistent storage (staging folder + swap, never a half copy). Called from
|
|
19
|
-
* the daemon tick: when no database changed since the last save it does nothing (no copy of the whole folder).
|
|
20
|
-
*/
|
|
21
17
|
export declare const saveClaudeMem: () => void;
|
|
@@ -89,6 +89,17 @@ export const restoreClaudeMem = () => {
|
|
|
89
89
|
const previous = claudeMemPaths.store + '.previous';
|
|
90
90
|
if (!exists(claudeMemPaths.store) && exists(previous))
|
|
91
91
|
fs.renameSync(previous, claudeMemPaths.store);
|
|
92
|
+
// A store that is gone or damaged: the newest backup that is a real database.
|
|
93
|
+
const backups = claudeMemPaths.store + '.backups';
|
|
94
|
+
if (!looksLikeSqlite(path.join(claudeMemPaths.store, 'claude-mem.db')) || !exists(claudeMemPaths.store)) {
|
|
95
|
+
const good = (exists(backups) ? fs.readdirSync(backups).sort().reverse() : []).find((b) => exists(path.join(backups, b, 'claude-mem.db')) && looksLikeSqlite(path.join(backups, b, 'claude-mem.db')));
|
|
96
|
+
if (good) {
|
|
97
|
+
if (exists(claudeMemPaths.store))
|
|
98
|
+
fs.renameSync(claudeMemPaths.store, claudeMemPaths.store + '.damaged-' + Date.now());
|
|
99
|
+
fs.cpSync(path.join(backups, good), claudeMemPaths.store, { recursive: true });
|
|
100
|
+
warn('claude-mem: the stored database was missing or damaged; restored the backup from ' + new Date(Number(good)).toISOString() + '.');
|
|
101
|
+
}
|
|
102
|
+
}
|
|
92
103
|
if (!exists(claudeMemPaths.store))
|
|
93
104
|
return;
|
|
94
105
|
robocopy(claudeMemPaths.store, claudeMemPaths.local);
|
|
@@ -117,6 +128,36 @@ let lastSaved = null;
|
|
|
117
128
|
* Copies the live claude-mem folder back to persistent storage (staging folder + swap, never a half copy). Called from
|
|
118
129
|
* the daemon tick: when no database changed since the last save it does nothing (no copy of the whole folder).
|
|
119
130
|
*/
|
|
131
|
+
const SQLITE_HEADER = 'SQLite format 3\0';
|
|
132
|
+
/** A database file that starts like SQLite and is not empty (a missing one counts as fine: nothing to protect yet). */
|
|
133
|
+
const looksLikeSqlite = (file) => {
|
|
134
|
+
if (!exists(file))
|
|
135
|
+
return true;
|
|
136
|
+
const fd = fs.openSync(file, 'r');
|
|
137
|
+
try {
|
|
138
|
+
const head = Buffer.alloc(16);
|
|
139
|
+
return fs.readSync(fd, head, 0, 16, 0) === 16 && head.toString('latin1') === SQLITE_HEADER;
|
|
140
|
+
}
|
|
141
|
+
finally {
|
|
142
|
+
fs.closeSync(fd);
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
const MAX_BACKUPS = 3;
|
|
146
|
+
const BACKUP_EVERY_MS = 6 * 3600 * 1000;
|
|
147
|
+
/** Moves the replaced copy into `dir` as a dated backup (at most one per 6 hours, the newest 3 kept); else deletes it. */
|
|
148
|
+
const keepBackup = (previous, dir) => {
|
|
149
|
+
if (!exists(previous))
|
|
150
|
+
return;
|
|
151
|
+
const backups = exists(dir) ? fs.readdirSync(dir).filter((n) => /^\d{13}$/.test(n)).sort() : [];
|
|
152
|
+
const newest = Number(backups[backups.length - 1] ?? 0);
|
|
153
|
+
if (Date.now() - newest < BACKUP_EVERY_MS) {
|
|
154
|
+
fs.rmSync(previous, { recursive: true, force: true });
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
fs.renameSync(previous, path.join(mkdirp(dir), String(Date.now())));
|
|
158
|
+
for (const old of [...backups, String(Date.now())].slice(0, -MAX_BACKUPS))
|
|
159
|
+
fs.rmSync(path.join(dir, old), { recursive: true, force: true });
|
|
160
|
+
};
|
|
120
161
|
export const saveClaudeMem = () => {
|
|
121
162
|
if (!claudeMemEnabled() || !exists(claudeMemPaths.local))
|
|
122
163
|
return;
|
|
@@ -124,12 +165,17 @@ export const saveClaudeMem = () => {
|
|
|
124
165
|
const fingerprint = databaseFingerprint(local);
|
|
125
166
|
if (fingerprint === lastSaved && exists(store))
|
|
126
167
|
return;
|
|
168
|
+
// A damaged copy (zero-filled after a crash, cut short) must never replace the store: that once destroyed the only copy.
|
|
169
|
+
if (!looksLikeSqlite(path.join(local, 'claude-mem.db'))) {
|
|
170
|
+
warn('claude-mem: the local database does not look valid, so it was not saved over the stored copy (' + local + ').');
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
127
173
|
const incoming = store + '.incoming';
|
|
128
174
|
const previous = store + '.previous';
|
|
129
175
|
fs.rmSync(incoming, { recursive: true, force: true });
|
|
130
176
|
// The database, its -wal and -shm files are copied together, so the copy is consistent with itself.
|
|
131
177
|
fs.cpSync(local, incoming, { recursive: true, filter: (src) => !/\.(pid|lock)$/i.test(src) });
|
|
132
|
-
// Swap without a window where no copy exists: old aside, new in, then
|
|
178
|
+
// Swap without a window where no copy exists: old aside, new in, then keep the old one as a dated backup.
|
|
133
179
|
fs.rmSync(previous, { recursive: true, force: true });
|
|
134
180
|
if (exists(store))
|
|
135
181
|
fs.renameSync(store, previous);
|
|
@@ -141,6 +187,6 @@ export const saveClaudeMem = () => {
|
|
|
141
187
|
fs.renameSync(previous, store);
|
|
142
188
|
throw e;
|
|
143
189
|
}
|
|
144
|
-
|
|
190
|
+
keepBackup(previous, store + '.backups');
|
|
145
191
|
lastSaved = fingerprint;
|
|
146
192
|
};
|
|
@@ -6,5 +6,14 @@ export type LanState = 'blocked' | 'open' | 'offline';
|
|
|
6
6
|
* all, the block stays in place: being offline is never a reason to open the LAN.
|
|
7
7
|
*/
|
|
8
8
|
export declare const applyNetworkPolicy: (ptyHostExe: string | null) => Promise<LanState>;
|
|
9
|
+
/** HTTP_PROXY & co. for everything in the sandbox: its own proxy (allowOnly) or yours (network.proxy). */
|
|
10
|
+
export declare const proxyEnv: () => Record<string, string>;
|
|
11
|
+
/**
|
|
12
|
+
* network.allowOnly, after the tools are installed: the sandbox's proxy (a copy of Node named sbx-netproxy.exe, so the
|
|
13
|
+
* firewall can tell it apart) is the only program allowed out, besides DNS and DHCP; Windows and browsers use it as
|
|
14
|
+
* the system proxy, everything else through HTTP_PROXY. A guardrail for agents that behave: an admin in the sandbox
|
|
15
|
+
* could undo it, which is why sandbox.networking false is the only hard isolation.
|
|
16
|
+
*/
|
|
17
|
+
export declare const applyAllowOnly: () => Promise<boolean>;
|
|
9
18
|
/** Guest IPv4 addresses the host can use to reach session ports. */
|
|
10
19
|
export declare const guestAddresses: () => string[];
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
// Guest firewall: block the host's LAN (keeping DNS/DHCP to the gateway) and let the host reach session ports.
|
|
2
2
|
import dns from 'node:dns';
|
|
3
|
+
import fs from 'node:fs';
|
|
3
4
|
import os from 'node:os';
|
|
4
|
-
import
|
|
5
|
-
import {
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
7
|
+
import { expandAllowList, PROXY_PORT, relayPort, withBlockedHosts } from '../../shared/netpolicy.js';
|
|
8
|
+
import { buildFirewallCommands, ipv4DnsServers, isIPv4, parseDefaultGateways, parseDnsServers } from '../../shared/netrules.js';
|
|
6
9
|
import { SYSTEM } from '../../shared/system.js';
|
|
7
|
-
import { dryRun, isWin, log, session, warn } from '../env.js';
|
|
10
|
+
import { dryRun, errMsg, isWin, launchDir, log, session, toolsDir, warn } from '../env.js';
|
|
8
11
|
const ATTACH_RULE = 'AgentSandbox-Attach';
|
|
9
12
|
const netsh = (args) => {
|
|
10
13
|
const r = spawnSync(SYSTEM.netsh(), args, { encoding: 'utf8', windowsHide: true });
|
|
@@ -68,7 +71,21 @@ export const applyNetworkPolicy = async (ptyHostExe) => {
|
|
|
68
71
|
const block = session.network.blockPrivateRanges;
|
|
69
72
|
const exempt = block ? discoverExemptIps() : [];
|
|
70
73
|
const lanRules = block ? buildFirewallCommands(exempt) : [];
|
|
71
|
-
const commands = [...lanRules, ...(ptyHostExe && session.attach.enabled ? attachRule(ptyHostExe) : [])];
|
|
74
|
+
const commands = [...lanRules, ...(ptyHostExe && session.attach.enabled ? attachRule(ptyHostExe) : []), ...forwardRule()];
|
|
75
|
+
if (isWin && !dryRun) {
|
|
76
|
+
try {
|
|
77
|
+
applyBlockedHosts();
|
|
78
|
+
}
|
|
79
|
+
catch (e) {
|
|
80
|
+
warn('network.blockHosts could not be applied: ' + errMsg(e));
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
applyDns();
|
|
84
|
+
}
|
|
85
|
+
catch (e) {
|
|
86
|
+
warn('network.dns could not be applied: ' + errMsg(e));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
72
89
|
if (!block)
|
|
73
90
|
log('Private-range blocking disabled by config.');
|
|
74
91
|
if (block)
|
|
@@ -95,5 +112,81 @@ export const applyNetworkPolicy = async (ptyHostExe) => {
|
|
|
95
112
|
log('LAN blocked in the guest firewall (10/8, 172.16/12, 192.168/16, 100.64/10, link-local, multicast, IPv6 local, SMB); DNS/DHCP to the gateway allowed.');
|
|
96
113
|
return 'blocked';
|
|
97
114
|
};
|
|
115
|
+
// ---- the rest of the network settings -----------------------------------------------------------------------------
|
|
116
|
+
const FORWARD_RULE = 'AgentSandbox-Forward';
|
|
117
|
+
const ALLOW_RULES = ['AgentSandbox-Proxy-Out', 'AgentSandbox-DNS-Out-UDP', 'AgentSandbox-DNS-Out-TCP', 'AgentSandbox-DHCP-Out'];
|
|
118
|
+
const HOSTS_FILE = path.join(process.env.WINDIR ?? 'C:\\Windows', 'System32', 'drivers', 'etc', 'hosts');
|
|
119
|
+
/** network.blockHosts: those names go nowhere (the sandbox's hosts file; the registry and files are new every boot). */
|
|
120
|
+
const applyBlockedHosts = () => {
|
|
121
|
+
const hosts = session.network.blockHosts ?? [];
|
|
122
|
+
if (!hosts.length)
|
|
123
|
+
return;
|
|
124
|
+
const text = fs.existsSync(HOSTS_FILE) ? fs.readFileSync(HOSTS_FILE, 'utf8') : '';
|
|
125
|
+
fs.writeFileSync(HOSTS_FILE, withBlockedHosts(text, hosts));
|
|
126
|
+
log('Blocked hosts (network.blockHosts): ' + hosts.join(', '));
|
|
127
|
+
};
|
|
128
|
+
/** network.dns: these servers on every connected adapter. */
|
|
129
|
+
const applyDns = () => {
|
|
130
|
+
const servers = (session.network.dns ?? []).filter(isIPv4);
|
|
131
|
+
if (!servers.length)
|
|
132
|
+
return;
|
|
133
|
+
const r = spawnSync(SYSTEM.powershell(), ['-NoLogo', '-NoProfile', '-Command', "Get-NetAdapter | Where-Object Status -eq 'Up' | Set-DnsClientServerAddress -ServerAddresses " + servers.map((s) => "'" + s + "'").join(',')], { encoding: 'utf8', windowsHide: true });
|
|
134
|
+
if (r.status !== 0)
|
|
135
|
+
throw new Error('could not set the DNS servers: ' + (r.stderr ?? '').trim().slice(0, 300));
|
|
136
|
+
log('DNS servers (network.dns): ' + servers.join(', '));
|
|
137
|
+
};
|
|
138
|
+
/** network.forwardPorts: the relays (in the daemon) take connections from the PC only, i.e. the sandbox's own subnet. */
|
|
139
|
+
const forwardRule = () => {
|
|
140
|
+
const forwards = session.network.forwardPorts ?? [];
|
|
141
|
+
return forwards.length ? [
|
|
142
|
+
['advfirewall', 'firewall', 'delete', 'rule', 'name=' + FORWARD_RULE],
|
|
143
|
+
['advfirewall', 'firewall', 'add', 'rule', 'name=' + FORWARD_RULE, 'dir=in', 'action=allow', 'enable=yes', 'profile=any', 'protocol=tcp', 'remoteip=LocalSubnet', 'localport=' + forwards.map((_f, i) => relayPort(i)).join(',')],
|
|
144
|
+
] : [];
|
|
145
|
+
};
|
|
146
|
+
/** HTTP_PROXY & co. for everything in the sandbox: its own proxy (allowOnly) or yours (network.proxy). */
|
|
147
|
+
export const proxyEnv = () => {
|
|
148
|
+
const own = (session.network.allowOnly ?? []).length > 0;
|
|
149
|
+
const url = own ? 'http://127.0.0.1:' + PROXY_PORT : session.network.proxy ?? '';
|
|
150
|
+
if (!url)
|
|
151
|
+
return {};
|
|
152
|
+
const noProxy = 'localhost,127.0.0.1,::1';
|
|
153
|
+
return { HTTP_PROXY: url, HTTPS_PROXY: url, http_proxy: url, https_proxy: url, NO_PROXY: noProxy, no_proxy: noProxy };
|
|
154
|
+
};
|
|
155
|
+
/**
|
|
156
|
+
* network.allowOnly, after the tools are installed: the sandbox's proxy (a copy of Node named sbx-netproxy.exe, so the
|
|
157
|
+
* firewall can tell it apart) is the only program allowed out, besides DNS and DHCP; Windows and browsers use it as
|
|
158
|
+
* the system proxy, everything else through HTTP_PROXY. A guardrail for agents that behave: an admin in the sandbox
|
|
159
|
+
* could undo it, which is why sandbox.networking false is the only hard isolation.
|
|
160
|
+
*/
|
|
161
|
+
export const applyAllowOnly = async () => {
|
|
162
|
+
const hosts = session.network.allowOnly ?? [];
|
|
163
|
+
if (!session.networking || !hosts.length)
|
|
164
|
+
return false;
|
|
165
|
+
if (!isWin || dryRun) {
|
|
166
|
+
log('(dry run) would allow only: ' + hosts.join(', '));
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
const exe = path.join(toolsDir, 'netproxy', 'sbx-netproxy.exe');
|
|
170
|
+
fs.mkdirSync(path.dirname(exe), { recursive: true });
|
|
171
|
+
if (!fs.existsSync(exe) || fs.statSync(exe).size !== fs.statSync(process.execPath).size)
|
|
172
|
+
fs.copyFileSync(process.execPath, exe);
|
|
173
|
+
spawn(exe, [path.join(launchDir, 'runtime', 'netproxy.js')], { detached: true, stdio: 'ignore', windowsHide: true }).unref();
|
|
174
|
+
const add = (name, extra) => ['advfirewall', 'firewall', 'add', 'rule', 'name=' + name, 'dir=out', 'action=allow', 'enable=yes', 'profile=any', ...extra];
|
|
175
|
+
run([
|
|
176
|
+
...ALLOW_RULES.map((n) => ['advfirewall', 'firewall', 'delete', 'rule', 'name=' + n]),
|
|
177
|
+
add('AgentSandbox-Proxy-Out', ['program=' + exe]),
|
|
178
|
+
add('AgentSandbox-DNS-Out-UDP', ['protocol=udp', 'remoteport=53']),
|
|
179
|
+
add('AgentSandbox-DNS-Out-TCP', ['protocol=tcp', 'remoteport=53']),
|
|
180
|
+
add('AgentSandbox-DHCP-Out', ['protocol=udp', 'remoteport=67', 'localport=68']),
|
|
181
|
+
['advfirewall', 'set', 'allprofiles', 'firewallpolicy', 'blockinbound,blockoutbound'],
|
|
182
|
+
['winhttp', 'set', 'proxy', '127.0.0.1:' + PROXY_PORT, '<local>'],
|
|
183
|
+
]);
|
|
184
|
+
const internet = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings';
|
|
185
|
+
for (const [name, type, value] of [['ProxyEnable', 'REG_DWORD', '1'], ['ProxyServer', 'REG_SZ', '127.0.0.1:' + PROXY_PORT], ['ProxyOverride', 'REG_SZ', '<local>']]) {
|
|
186
|
+
spawnSync(SYSTEM.reg(), ['add', internet, '/v', name, '/t', type, '/d', value, '/f'], { windowsHide: true, stdio: 'ignore' });
|
|
187
|
+
}
|
|
188
|
+
log('Only these hosts can be reached now (network.allowOnly): ' + expandAllowList(hosts).join(', ') + '. Refused ones are listed in logs\\netproxy.log.');
|
|
189
|
+
return true;
|
|
190
|
+
};
|
|
98
191
|
/** Guest IPv4 addresses the host can use to reach session ports. */
|
|
99
192
|
export const guestAddresses = () => Object.values(os.networkInterfaces()).flat().filter((i) => Boolean(i) && i.family === 'IPv4' && !i.internal).map((i) => i.address);
|
package/dist/guest/setup/user.js
CHANGED
|
@@ -44,4 +44,8 @@ export const setupGitConfig = (env) => {
|
|
|
44
44
|
spawnSync(git, ['config', '--global', '--add', 'safe.directory', '*'], { env: rest, windowsHide: true, stdio: 'ignore' });
|
|
45
45
|
// Windows long paths are on in the sandbox, but git skips files over 260 characters unless told.
|
|
46
46
|
spawnSync(git, ['config', '--global', 'core.longpaths', 'true'], { env: rest, windowsHide: true, stdio: 'ignore' });
|
|
47
|
+
// Git for Windows' system config says autocrlf=true: checkouts made in the sandbox (worktrees, clones) got CRLF and
|
|
48
|
+
// tools that want LF (rustfmt, shellcheck, prettier) failed on every file. "input" keeps the line endings the repo
|
|
49
|
+
// has, and a checkout from your PC (CRLF in the folder) still shows as unchanged.
|
|
50
|
+
spawnSync(git, ['config', '--global', 'core.autocrlf', 'input'], { env: rest, windowsHide: true, stdio: 'ignore' });
|
|
47
51
|
};
|
|
@@ -1,8 +1,17 @@
|
|
|
1
|
+
// Every developer toolchain and database the sandbox provides (the list is in plan.ts). Installers run in parallel (a
|
|
2
|
+
// few at a time; downloads and installers do not block each other) and each is independent: one failing never blocks
|
|
3
|
+
// the rest. A step that needs another (Rust links with MinGW's gcc, uv installs the Python tools) waits for it.
|
|
4
|
+
// The boot runs it once for the launch folder; the session daemon runs it again for each project registered later,
|
|
5
|
+
// possibly several at once, so the same kind of step (two Node installs, two Go installs) never runs twice at a time.
|
|
6
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
7
|
+
import { watchDownloads } from '../../shared/download.js';
|
|
1
8
|
import { log, mkdirp, setSandboxStatus } from '../env.js';
|
|
2
9
|
import { emptyEnv, step, tool } from './common.js';
|
|
3
10
|
import { plan } from './plan.js';
|
|
4
11
|
/** How many installers run at once: enough to overlap downloads with installs, few enough not to thrash the disk. */
|
|
5
12
|
const PARALLEL = 4;
|
|
13
|
+
/** The step a download belongs to (several run at once), for its progress. */
|
|
14
|
+
const currentStep = new AsyncLocalStorage();
|
|
6
15
|
/** One lock per kind of step ("Node 20" and "Node 22" share nvm's folders): held while that step runs, in any run. */
|
|
7
16
|
const locks = new Map();
|
|
8
17
|
const lockKey = (name) => name.split(' ')[0].toLowerCase();
|
|
@@ -30,9 +39,17 @@ const runSteps = async (steps, o) => {
|
|
|
30
39
|
const running = new Set();
|
|
31
40
|
const waiting = [];
|
|
32
41
|
let done = 0;
|
|
33
|
-
|
|
42
|
+
// The boot (no `report`) also publishes structured progress: what runs now, how far its download is, how many are done.
|
|
43
|
+
const percent = new Map();
|
|
44
|
+
const active = () => [...running].map((name) => ({ name, ...(percent.has(name) ? { percent: percent.get(name) } : {}) }));
|
|
45
|
+
const say = o.report ?? ((message) => setSandboxStatus('provisioning', message, { active: active(), done, total: steps.length }));
|
|
34
46
|
const report = () => { if (running.size)
|
|
35
47
|
say('Installing ' + [...running].join(', ') + ' (' + done + ' of ' + steps.length + ' done)'); };
|
|
48
|
+
if (!o.report)
|
|
49
|
+
watchDownloads(() => {
|
|
50
|
+
const name = currentStep.getStore();
|
|
51
|
+
return name ? (fraction) => { percent.set(name, Math.round(fraction * 100)); report(); } : null;
|
|
52
|
+
});
|
|
36
53
|
for (const s of steps) {
|
|
37
54
|
// Dependencies first (they are earlier in the plan, so their promises exist), then a free slot.
|
|
38
55
|
finished.set(s.name, (async () => {
|
|
@@ -48,17 +65,26 @@ const runSteps = async (steps, o) => {
|
|
|
48
65
|
running.add(s.name);
|
|
49
66
|
report();
|
|
50
67
|
try {
|
|
51
|
-
await exclusive(s.name, () => step(s.name, envs.get(s.name), async () => s.run(envs.get(s.name))));
|
|
68
|
+
await currentStep.run(s.name, () => exclusive(s.name, () => step(s.name, envs.get(s.name), async () => s.run(envs.get(s.name)))));
|
|
52
69
|
}
|
|
53
70
|
finally {
|
|
54
71
|
running.delete(s.name);
|
|
72
|
+
percent.delete(s.name);
|
|
55
73
|
done++;
|
|
56
74
|
report();
|
|
57
75
|
waiting.shift()?.();
|
|
58
76
|
}
|
|
59
77
|
})());
|
|
60
78
|
}
|
|
61
|
-
|
|
79
|
+
try {
|
|
80
|
+
await Promise.all(finished.values());
|
|
81
|
+
}
|
|
82
|
+
finally {
|
|
83
|
+
if (!o.report)
|
|
84
|
+
watchDownloads(null);
|
|
85
|
+
}
|
|
86
|
+
if (!o.report)
|
|
87
|
+
setSandboxStatus('provisioning', 'Set up ' + steps.length + ' toolchains and databases', { active: [], done, total: steps.length });
|
|
62
88
|
return envs;
|
|
63
89
|
};
|
|
64
90
|
/** Provisions every enabled toolchain and database, and returns the combined environment for the agent. */
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { installArchive } from '../../../shared/download.js';
|
|
4
4
|
import { SYSTEM } from '../../../shared/system.js';
|
|
5
|
-
import { dryRun, execAsync, exists, log } from '../../env.js';
|
|
5
|
+
import { dryRun, execAsync, execOutput, exists, log } from '../../env.js';
|
|
6
6
|
import { arch, installFromGithub, tool } from '../common.js';
|
|
7
7
|
/**
|
|
8
8
|
* Mesa 3D for Windows (llvmpipe): OpenGL 4.5 on the CPU when the sandbox has no GPU. Windows only offers OpenGL 1.1 in
|
|
@@ -40,7 +40,17 @@ const setupLavapipe = async (env, mesaDir) => {
|
|
|
40
40
|
// The zip has x86 and x64 folders: only the 64-bit loader (and vulkaninfo) is extracted.
|
|
41
41
|
await installArchive(VULKAN_RUNTIME, dir, { locate: 'vulkan-1.dll', fileName: 'vulkan-runtime.zip', members: ['*/x64/*'] });
|
|
42
42
|
}
|
|
43
|
-
|
|
43
|
+
const register = (file) => execAsync(SYSTEM.reg(), ['add', 'HKLM\\SOFTWARE\\Khronos\\Vulkan\\Drivers', '/v', file, '/t', 'REG_DWORD', '/d', '0', '/f'], { stdio: 'ignore' });
|
|
44
|
+
await register(icd);
|
|
45
|
+
// sandbox.vGpu: the sandbox sees this PC's GPU through Direct3D 12, and Dozen runs Vulkan on it. Programs prefer it
|
|
46
|
+
// over lavapipe (a GPU before a CPU device); lavapipe stays for the ones that need it.
|
|
47
|
+
const dozen = path.join(mesaDir, arch === 'arm64' ? 'dzn_icd.aarch64.json' : 'dzn_icd.x86_64.json');
|
|
48
|
+
const gpu = hasGpu();
|
|
49
|
+
if (gpu && exists(dozen))
|
|
50
|
+
await register(dozen);
|
|
44
51
|
env.prepend.push(dir);
|
|
45
|
-
env.summary.push('vulkan: lavapipe (software Vulkan 1.4 on the CPU), vulkaninfo on PATH; WGPU_BACKEND=vulkan for wgpu');
|
|
52
|
+
env.summary.push('vulkan: ' + (gpu && exists(dozen) ? 'Dozen (Vulkan on the GPU via Direct3D 12) and ' : '') + 'lavapipe (software Vulkan 1.4 on the CPU), vulkaninfo on PATH; WGPU_BACKEND=vulkan for wgpu');
|
|
46
53
|
};
|
|
54
|
+
/** A real display adapter (sandbox.vGpu on), not only the remote display or basic render driver. */
|
|
55
|
+
const hasGpu = () => execOutput(SYSTEM.powershell(), ['-NoProfile', '-NonInteractive', '-Command', '(Get-CimInstance Win32_VideoController).Name'], { timeout: 30000 })
|
|
56
|
+
.split(/\r?\n/).map((n) => n.trim()).some((n) => n && !/^Microsoft (Remote Display|Basic (Render|Display))/i.test(n));
|
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
import type { DatabasePlan, GitAccess, Toolchains, ToolVersions } from '../../core/types.js';
|
|
1
|
+
import type { Config, DatabasePlan, GitAccess, Toolchains, ToolVersions } from '../../core/types.js';
|
|
2
2
|
import { type ToolEnv } from './common.js';
|
|
3
|
+
/** apps: the boot only (apps are machine-wide; a project that joins later shares them). */
|
|
3
4
|
export interface ToolchainOptions {
|
|
4
5
|
gitEnabled: boolean;
|
|
5
6
|
gitAccess: GitAccess;
|
|
6
7
|
hostGit: boolean;
|
|
7
8
|
refresh: boolean;
|
|
8
9
|
databases: DatabasePlan[];
|
|
10
|
+
apps?: Config['apps'];
|
|
9
11
|
}
|
|
10
12
|
/**
|
|
11
13
|
* shared: the step's effect is machine-wide and the same for every project (an installer, a registry entry, a
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
// Which toolchain and database steps this sandbox runs, in the order their PATH entries are listed, and what each
|
|
2
2
|
// step depends on. The scheduler (index.ts) runs them.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
3
5
|
import path from 'node:path';
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
+
import { appsNeedMesa, setupApps } from '../apps/index.js';
|
|
7
|
+
import { dataDir, dryRun, execAsync, exists, localDir, mkdirp } from '../env.js';
|
|
8
|
+
import { ensureCompiled } from '../helpers.js';
|
|
6
9
|
import { arch, installOnce, tool, wanted } from './common.js';
|
|
7
10
|
import { DATABASE_NAMES, setupDatabase } from './databases/index.js';
|
|
8
11
|
import { setupGit } from './git.js';
|
|
@@ -30,16 +33,46 @@ const setupPythonTools = async (env, tools) => {
|
|
|
30
33
|
throw new Error('toolchains.python.tools needs uv (toolchains.uv.enabled)');
|
|
31
34
|
await installOnce(env, 'uv tool', tools, (pkg) => execAsync(uv, ['tool', 'install', pkg], { env: { ...process.env, ...uvVars() } }));
|
|
32
35
|
};
|
|
33
|
-
/**
|
|
36
|
+
/**
|
|
37
|
+
* Where cargo builds: shared with the PC's target\, one on the sandbox's disk, or a path. "separate" sets no variable:
|
|
38
|
+
* the cargo shim (sandbox/cargoshim.cs) gives every checkout its own target\sandbox, so git worktrees of several agents
|
|
39
|
+
* never overwrite each other's builds.
|
|
40
|
+
*/
|
|
34
41
|
const cargoTarget = (targetDir) => {
|
|
35
|
-
if (targetDir === 'shared')
|
|
42
|
+
if (targetDir === 'shared' || targetDir === 'separate')
|
|
36
43
|
return null;
|
|
37
|
-
if (targetDir === 'separate')
|
|
38
|
-
return path.join(projectWorkspace(), 'target', 'sandbox');
|
|
39
44
|
if (targetDir === 'local')
|
|
40
45
|
return path.join(localDir, 'cargo-target');
|
|
41
46
|
return targetDir;
|
|
42
47
|
};
|
|
48
|
+
/** "separate": tools\shims\cargo\cargo.exe in front of the real cargo, which it runs with the checkout's target dir. */
|
|
49
|
+
const cargoShim = (env, rust) => {
|
|
50
|
+
const real = path.join(tool('rust', 'cargo', 'bin'), 'cargo.exe');
|
|
51
|
+
if (rust.targetDir !== 'separate' || !exists(real))
|
|
52
|
+
return;
|
|
53
|
+
const dir = mkdirp(tool('shims', 'cargo'));
|
|
54
|
+
fs.copyFileSync(ensureCompiled('cargoshim'), path.join(dir, 'cargo.exe'));
|
|
55
|
+
env.prepend.unshift(dir);
|
|
56
|
+
env.vars.AGENT_REAL_CARGO = real;
|
|
57
|
+
};
|
|
58
|
+
/**
|
|
59
|
+
* Cargo defaults that fit Windows, written only when there is no $CARGO_HOME\config.toml yet: fewer parallel jobs
|
|
60
|
+
* (parallel rustc ran out of paging file) and, for the GNU host, only line tables as debug info (full debug info made
|
|
61
|
+
* test executables bigger than the 2 GB Windows allows).
|
|
62
|
+
*/
|
|
63
|
+
const cargoDefaults = (host) => {
|
|
64
|
+
const file = tool('rust', 'cargo', 'config.toml');
|
|
65
|
+
if (exists(file) || !exists(path.dirname(file)))
|
|
66
|
+
return;
|
|
67
|
+
// Several agents often build at once: up to 4 jobs each (8 each crashed Windows inside the sandbox on 13 Oct 2026).
|
|
68
|
+
const jobs = Math.max(2, Math.min(4, Math.floor(os.cpus().length / 4)));
|
|
69
|
+
const gnu = host.endsWith('-gnu');
|
|
70
|
+
fs.writeFileSync(file, [
|
|
71
|
+
'# Written by windows-development-sandbox once; edit freely, it is never overwritten.',
|
|
72
|
+
'[build]', 'jobs = ' + jobs, '',
|
|
73
|
+
...(gnu ? ['[profile.dev]', 'debug = "line-tables-only"', '', '[profile.test]', 'debug = "line-tables-only"', ''] : []),
|
|
74
|
+
].join('\n'));
|
|
75
|
+
};
|
|
43
76
|
/**
|
|
44
77
|
* Where cargo builds and where sccache keeps its cache. Cargo fingerprints hold absolute paths and the host toolchain,
|
|
45
78
|
* so a target\\ shared with builds on the PC rebuilds every time the side changes: "separate" (the default) keeps a
|
|
@@ -57,6 +90,7 @@ const rustHost = (config) => {
|
|
|
57
90
|
return cpu + (wanted(config.msvc.enabled, 'msvc') ? '-pc-windows-msvc' : '-pc-windows-gnu');
|
|
58
91
|
};
|
|
59
92
|
export const plan = (config, versions, o) => {
|
|
93
|
+
const apps = Boolean(o.apps && (o.apps.install.length || Object.keys(o.apps.custom).length));
|
|
60
94
|
const java = wanted(config.java.enabled, 'java');
|
|
61
95
|
const python = wanted(config.python.enabled, 'python');
|
|
62
96
|
return [
|
|
@@ -71,14 +105,22 @@ export const plan = (config, versions, o) => {
|
|
|
71
105
|
// Rust links with MSVC (msvc host) or MinGW's gcc (GNU host): whichever it uses goes first.
|
|
72
106
|
{
|
|
73
107
|
name: 'Rust', on: wanted(config.rust.enabled, 'rust'), after: ['C/C++', 'MSVC'],
|
|
74
|
-
run: async (env) => {
|
|
108
|
+
run: async (env) => {
|
|
109
|
+
await setupRust(env, { ...config.rust, host: rustHost(config) }, o.refresh);
|
|
110
|
+
Object.assign(env.vars, rustCaches(config.rust));
|
|
111
|
+
if (!dryRun) {
|
|
112
|
+
cargoDefaults(rustHost(config));
|
|
113
|
+
cargoShim(env, config.rust);
|
|
114
|
+
}
|
|
115
|
+
},
|
|
75
116
|
},
|
|
76
117
|
{ name: '.NET', on: wanted(config.dotnet.enabled, 'dotnet'), run: (env) => setupDotnet(env, config.dotnet, o.refresh) },
|
|
77
118
|
{ name: 'Go', on: wanted(config.go.enabled, 'go'), run: (env) => setupGo(env, config.go) },
|
|
78
119
|
{ name: 'Java', on: java, run: (env) => setupJava(env, config.java) },
|
|
79
120
|
{ name: 'Ruby', on: wanted(config.ruby.enabled, 'ruby'), run: (env) => setupRuby(env, config.ruby) },
|
|
80
121
|
// PHP installs the Visual C++ runtime itself: one installer at a time.
|
|
81
|
-
|
|
122
|
+
// Many apps (bat, delta, Neovim, most GUIs, your own programs) are built against it too.
|
|
123
|
+
{ name: 'Visual C++ runtime', on: config.vcRuntime.enabled || apps, shared: true, run: async (env) => { await setupVcRuntime(env); } },
|
|
82
124
|
{ name: 'PHP', on: wanted(config.php.enabled, 'php'), after: ['Visual C++ runtime'], run: (env) => setupPhp(env, config.php) },
|
|
83
125
|
{ name: 'Deno', on: wanted(config.deno.enabled, 'deno'), run: (env) => setupDeno(env) },
|
|
84
126
|
{ name: 'Bun', on: config.bun.enabled, run: (env) => setupBun(env) },
|
|
@@ -86,8 +128,10 @@ export const plan = (config, versions, o) => {
|
|
|
86
128
|
{ name: 'PowerShell 7', on: wanted(config.pwsh.enabled, 'pwsh'), run: (env) => setupPwsh(env, o.refresh) },
|
|
87
129
|
// The openssl tool comes from Git's install: after Git.
|
|
88
130
|
{ name: 'CLI tools', on: Object.values(config.cli).some((v) => v !== false), after: ['Git'], run: (env) => setupCli(env, config.cli, o.gitAccess, o.refresh) },
|
|
89
|
-
{ name: 'Mesa 3D', on: config.mesa.enabled, shared: true, run: (env) => setupMesa(env, config.mesa) },
|
|
131
|
+
{ name: 'Mesa 3D', on: config.mesa.enabled || appsNeedMesa(o.apps), shared: true, run: (env) => setupMesa(env, config.mesa) },
|
|
90
132
|
{ name: 'VS Code', on: config.vscode.enabled, shared: true, run: (env) => setupVscode(env, o.refresh) },
|
|
133
|
+
// Desktop apps and extra tools (apps.install / apps.custom); games and 3D apps copy Mesa's OpenGL next to them.
|
|
134
|
+
{ name: 'Apps', on: apps, shared: true, after: ['Mesa 3D', 'Visual C++ runtime'], run: (env) => setupApps(env, o.apps, { gitAccess: o.gitAccess, refresh: o.refresh }) },
|
|
91
135
|
{ name: 'Python tools', on: python && config.python.tools.length > 0, after: ['Python', 'uv'], run: (env) => setupPythonTools(env, config.python.tools) },
|
|
92
136
|
// DynamoDB Local runs on Java: the Java step installs the JDK first instead of racing it.
|
|
93
137
|
// One server per database in the sandbox: a project that joins later shares the one the boot started.
|
|
@@ -35,6 +35,27 @@ const shimPair = (folder, version, dir) => {
|
|
|
35
35
|
shim(folder, 'python' + version, python);
|
|
36
36
|
shim(folder, 'pip' + version, python + ' -m pip');
|
|
37
37
|
};
|
|
38
|
+
/**
|
|
39
|
+
* `py` like the Python launcher Windows installs: `py -3 x.py`, `py -3.12 ...`, `py -2 ...`, `py x.py`. The launcher
|
|
40
|
+
* itself is not portable, so py.cmd runs a small script that picks the interpreter (cmd's %* ignores shift).
|
|
41
|
+
*/
|
|
42
|
+
const pyLauncher = (folder, py3, py2) => {
|
|
43
|
+
const script = path.join(mkdirp(folder), 'py-launcher.py');
|
|
44
|
+
fs.writeFileSync(script, [
|
|
45
|
+
'import subprocess, sys',
|
|
46
|
+
'PY3, PY2 = ' + JSON.stringify(path.join(py3, 'python.exe')) + ', ' + JSON.stringify(py2 ? path.join(py2, 'python.exe') : null),
|
|
47
|
+
'args = sys.argv[1:]',
|
|
48
|
+
'exe = PY3',
|
|
49
|
+
"if args and args[0].startswith('-2'):",
|
|
50
|
+
' if not PY2: sys.exit("py: Python 2 is not installed (toolchains.python.python2)")',
|
|
51
|
+
' exe, args = PY2, args[1:]',
|
|
52
|
+
"elif args and (args[0] == '-3' or args[0].startswith('-3.') or args[0].startswith('-3-')):",
|
|
53
|
+
' args = args[1:]',
|
|
54
|
+
'sys.exit(subprocess.call([exe] + args))',
|
|
55
|
+
'',
|
|
56
|
+
].join('\n'));
|
|
57
|
+
shim(folder, 'py', '"' + path.join(py3, 'python.exe') + '" "' + script + '"');
|
|
58
|
+
};
|
|
38
59
|
export const setupPython = async (env, config, versions) => {
|
|
39
60
|
if (dryRun) {
|
|
40
61
|
log('(dry run) would provision Python ' + versions.python.spec + (config.python2 ? ' and Python 2.7' : ''));
|
|
@@ -45,10 +66,13 @@ export const setupPython = async (env, config, versions) => {
|
|
|
45
66
|
shimPair(shims, '3', py3);
|
|
46
67
|
env.prepend.push(py3, path.join(py3, 'Scripts'), shims);
|
|
47
68
|
env.vars.PYTHONIOENCODING = 'utf-8';
|
|
48
|
-
env.summary.push('python: ' + path.basename(py3).replace('python-', '') + ' (from ' + versions.python.source + ')');
|
|
49
|
-
if (!config.python2)
|
|
69
|
+
env.summary.push('python: ' + path.basename(py3).replace('python-', '') + ' (from ' + versions.python.source + '; py works too)');
|
|
70
|
+
if (!config.python2) {
|
|
71
|
+
pyLauncher(shims, py3, null);
|
|
50
72
|
return;
|
|
73
|
+
}
|
|
51
74
|
const py2 = await installPython('python2', '2.7');
|
|
52
75
|
shimPair(shims, '2', py2);
|
|
76
|
+
pyLauncher(shims, py3, py2);
|
|
53
77
|
env.summary.push('python2: ' + path.basename(py2).replace('python2-', ''));
|
|
54
78
|
};
|