windows-development-sandbox 1.1.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -2
- package/config.example.json +38 -5
- package/dist/cli/args.d.ts +3 -0
- package/dist/cli/args.js +3 -1
- 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.js +36 -3
- package/dist/cli/commands/maintenance.d.ts +2 -0
- package/dist/cli/commands/maintenance.js +35 -2
- package/dist/cli/commands/sessions.js +3 -3
- package/dist/cli/commands/user-config.js +1 -1
- package/dist/cli/index.js +6 -4
- package/dist/cli/manifest.js +5 -0
- 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/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 +9 -6
- package/dist/core/local-config.d.ts +15 -0
- package/dist/core/local-config.js +63 -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 +66 -1
- 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/sandbox.d.ts +28 -0
- package/dist/core/schema/sandbox.js +28 -0
- package/dist/core/schema/toolchains.d.ts +3 -0
- package/dist/core/schema/toolchains.js +6 -1
- package/dist/core/types.d.ts +6 -0
- package/dist/core/util.d.ts +2 -0
- package/dist/core/util.js +2 -0
- package/dist/core/workspace.js +22 -5
- 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 +1 -0
- package/dist/guest/env.js +3 -1
- package/dist/guest/helpers.d.ts +1 -1
- package/dist/guest/main.js +15 -2
- 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/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/generate.d.ts +6 -0
- package/dist/host/generate.js +10 -0
- package/dist/host/launch/mappings.js +5 -1
- 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/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 +1319 -552
- package/dist/runtime/db.js +168 -13
- package/dist/runtime/main.js +2898 -608
- package/dist/runtime/netproxy.js +25936 -0
- package/dist/runtime/view.js +272 -23
- package/dist/selftest/audit.test.d.ts +2 -0
- package/dist/selftest/audit.test.js +57 -0
- package/dist/selftest/channel.test.js +7 -0
- package/dist/selftest/cli.test.js +47 -17
- package/dist/selftest/fixtures.d.ts +45 -0
- package/dist/selftest/fixtures.js +3 -1
- package/dist/selftest/generate.test.js +7 -2
- package/dist/selftest/index.js +5 -2
- package/dist/selftest/live-network.test.d.ts +2 -0
- package/dist/selftest/live-network.test.js +63 -0
- package/dist/selftest/local-config.test.js +16 -3
- package/dist/selftest/network.test.js +12 -0
- package/dist/selftest/projects.test.js +1 -1
- 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/netpolicy.d.ts +27 -0
- package/dist/shared/netpolicy.js +51 -0
- package/dist/shared/pty-client.d.ts +9 -4
- package/dist/shared/pty-client.js +139 -17
- package/dist/shared/rundir.d.ts +2 -0
- package/dist/shared/rundir.js +2 -0
- package/dist/shared/schema.d.ts +10 -0
- package/dist/shared/schema.js +2 -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 +105 -7
- package/schemas/config.schema.json +303 -2
- package/schemas/sandbox-config.schema.json +126 -12
- 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
|
@@ -31,6 +31,18 @@ export declare const createFixtures: (t: Harness) => {
|
|
|
31
31
|
defaultHarness: string;
|
|
32
32
|
guestWorkspace: string;
|
|
33
33
|
readOnly: boolean;
|
|
34
|
+
workspaceFilter: {
|
|
35
|
+
enabled: boolean;
|
|
36
|
+
exclude: string[];
|
|
37
|
+
include: string[];
|
|
38
|
+
};
|
|
39
|
+
audit: {
|
|
40
|
+
enabled: boolean;
|
|
41
|
+
days: number;
|
|
42
|
+
maxMb: number;
|
|
43
|
+
processes: boolean;
|
|
44
|
+
files: boolean;
|
|
45
|
+
};
|
|
34
46
|
mappings: {
|
|
35
47
|
host: string;
|
|
36
48
|
guest: string;
|
|
@@ -42,6 +54,7 @@ export declare const createFixtures: (t: Harness) => {
|
|
|
42
54
|
maxMemoryMb: number;
|
|
43
55
|
maxMemoryPercent: number;
|
|
44
56
|
cpuLimit: number | "none";
|
|
57
|
+
pageFileMb: number;
|
|
45
58
|
reserveHostMb: number;
|
|
46
59
|
vGpu: boolean;
|
|
47
60
|
networking: boolean;
|
|
@@ -51,14 +64,25 @@ export declare const createFixtures: (t: Harness) => {
|
|
|
51
64
|
printerRedirection: boolean;
|
|
52
65
|
protectedClient: boolean;
|
|
53
66
|
headless: boolean;
|
|
67
|
+
keepAwake: boolean;
|
|
54
68
|
};
|
|
55
69
|
storage: {
|
|
56
70
|
root: string;
|
|
57
71
|
persistAgentState: boolean;
|
|
58
72
|
dataName: string;
|
|
73
|
+
markSandboxFiles: boolean;
|
|
74
|
+
warnDataGb: number;
|
|
59
75
|
};
|
|
60
76
|
network: {
|
|
61
77
|
blockPrivateRanges: boolean;
|
|
78
|
+
allowOnly: string[];
|
|
79
|
+
blockHosts: string[];
|
|
80
|
+
forwardPorts: {
|
|
81
|
+
pc: number;
|
|
82
|
+
sandbox: number;
|
|
83
|
+
}[];
|
|
84
|
+
dns: string[];
|
|
85
|
+
proxy: string;
|
|
62
86
|
};
|
|
63
87
|
provisioning: {
|
|
64
88
|
copyFromHost: boolean;
|
|
@@ -71,6 +95,9 @@ export declare const createFixtures: (t: Harness) => {
|
|
|
71
95
|
terminal: {
|
|
72
96
|
enabled: boolean;
|
|
73
97
|
};
|
|
98
|
+
conpty: {
|
|
99
|
+
enabled: boolean;
|
|
100
|
+
};
|
|
74
101
|
};
|
|
75
102
|
downloads: {
|
|
76
103
|
requireChecksum: boolean;
|
|
@@ -164,6 +191,9 @@ export declare const createFixtures: (t: Harness) => {
|
|
|
164
191
|
};
|
|
165
192
|
vscode: {
|
|
166
193
|
enabled: boolean;
|
|
194
|
+
setup: boolean;
|
|
195
|
+
recommended: boolean;
|
|
196
|
+
extensions: string[];
|
|
167
197
|
};
|
|
168
198
|
environment: Record<string, string>;
|
|
169
199
|
};
|
|
@@ -216,6 +246,19 @@ export declare const createFixtures: (t: Harness) => {
|
|
|
216
246
|
personalization: {
|
|
217
247
|
enabled: boolean;
|
|
218
248
|
};
|
|
249
|
+
apps: {
|
|
250
|
+
install: string[];
|
|
251
|
+
custom: Record<string, {
|
|
252
|
+
name: string;
|
|
253
|
+
exe: string;
|
|
254
|
+
github?: string | undefined;
|
|
255
|
+
asset?: string | undefined;
|
|
256
|
+
url?: string | undefined;
|
|
257
|
+
sha256?: string | undefined;
|
|
258
|
+
unpack?: "file" | "archive" | "nsis" | "msi" | undefined;
|
|
259
|
+
cli?: boolean | undefined;
|
|
260
|
+
}>;
|
|
261
|
+
};
|
|
219
262
|
claudeProfile: {
|
|
220
263
|
enabled: boolean;
|
|
221
264
|
hostDir: string;
|
|
@@ -224,7 +267,9 @@ export declare const createFixtures: (t: Harness) => {
|
|
|
224
267
|
copyCredentials: boolean;
|
|
225
268
|
overwrite: boolean;
|
|
226
269
|
syncHistory: boolean;
|
|
270
|
+
historyScope: "project" | "all";
|
|
227
271
|
syncClaudeMem: boolean;
|
|
272
|
+
syncClaudeMemBack: boolean;
|
|
228
273
|
};
|
|
229
274
|
harnesses: Record<string, {
|
|
230
275
|
arguments: string[];
|
|
@@ -62,7 +62,9 @@ export const createFixtures = (t) => {
|
|
|
62
62
|
const probe = { nodeDir: hostNode, harnessExe: { dir: hostClaude, file: 'claude.exe' }, gitDir: null, nvmDir: null, hostNodeVersion: HOST_NODE_VERSION };
|
|
63
63
|
/** Resolves a run for `workA` (or whatever `over` says). */
|
|
64
64
|
const run = (over = {}, config = baseConfig) => resolveRun({ packageRoot: install, loaded: { config, configPath: null, base: t.temp }, cwd: workA, noAgent: false, noLocalConfig: false, trustConfig: false, scripts: [], ...over }, HOST_NODE_VERSION);
|
|
65
|
-
const generate = (r, args = [], p = probe) =>
|
|
65
|
+
const generate = (r, args = [], p = probe) =>
|
|
66
|
+
// A fixed "this PC has" list, so "auto" toolchains decide the same on every machine the tests run on.
|
|
67
|
+
generateSandbox({ packageRoot: t.packageRoot, run: r, harnessArgs: args, probe: p, pcLanguages: ['python', 'rust', 'cpp', 'dotnet'] });
|
|
66
68
|
/** Resolves a run and generates its sandbox (.wsb + launch folder + session.json) in one step. */
|
|
67
69
|
const launch = async (over = {}, options = {}) => generate(await run(over, options.config ?? baseConfig), options.args, options.probe);
|
|
68
70
|
/** Writes a project's `.sandbox-config.json` (objects are serialised; strings are written as they are). */
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { LocalOverrides } from '../core/schema.js';
|
|
6
|
+
import { logonEnv } from '../host/generate.js';
|
|
6
7
|
import { gitIdentity } from '../host/launch/stage.js';
|
|
7
8
|
import { buildWsb, resolveMemoryMb } from '../host/launch/wsb.js';
|
|
8
9
|
export const generateSuite = {
|
|
@@ -21,14 +22,17 @@ export const generateSuite = {
|
|
|
21
22
|
t.includes(wsb, '<vGPU>Disable</vGPU>', 'wsb: vGPU is off');
|
|
22
23
|
t.includes(buildWsb(f.baseConfig, 4096, [{ host: 'C:\\a&b', guest: 'C:\\X', readOnly: true }]), 'C:\\a&b', 'wsb values are XML-escaped');
|
|
23
24
|
t.section('the launch folder');
|
|
24
|
-
const expectedFiles = ['package.json', 'runtime/main.js', 'runtime/daemon.js', 'runtime/view.js', 'runtime/gitguard.js', 'sandbox/bootstrap.ps1', 'sandbox/logon.cmd', 'sandbox/trust.cmd', 'sandbox/ptyhost.cs', 'sandbox/gitshim.cs'];
|
|
25
|
+
const expectedFiles = ['package.json', 'runtime/main.js', 'runtime/daemon.js', 'runtime/view.js', 'runtime/gitguard.js', 'sandbox/bootstrap.ps1', 'sandbox/logon.cmd', 'sandbox/trust.cmd', 'sandbox/ptyhost.cs', 'sandbox/gitshim.cs', 'sandbox/cpucap.ps1', 'sandbox/cpucap.cs', 'sandbox/pagefile.ps1', 'sandbox/pagefile.cs', 'sandbox/memlog.ps1'];
|
|
25
26
|
const missing = expectedFiles.filter((file) => !fs.existsSync(path.join(gen.launchPath, file)));
|
|
26
27
|
t.equal(missing, [], 'the launch folder has the runtime, the C# sources and the boot scripts');
|
|
27
|
-
t.equal(fs.readdirSync(path.join(gen.launchPath, 'runtime')).length,
|
|
28
|
+
t.equal(fs.readdirSync(path.join(gen.launchPath, 'runtime')).length, 6, 'the runtime is 6 self-contained files');
|
|
28
29
|
const logon = fs.readFileSync(path.join(gen.launchPath, 'sandbox', 'logon.cmd'), 'utf8');
|
|
29
30
|
t.check(logon.indexOf('trust.cmd') < logon.indexOf('wt.exe'), 'logon.cmd applies the sandbox trust settings before opening Windows Terminal');
|
|
30
31
|
t.includes(logon, 'tools\\terminal\\wt.exe', 'logon.cmd opens Windows Terminal from the tools folder');
|
|
31
32
|
t.includes(logon, '-NoExit', 'logon.cmd falls back to a PowerShell window');
|
|
33
|
+
t.check(logon.indexOf('env.cmd') > 0 && logon.indexOf('env.cmd') < logon.indexOf('wt.exe'), 'logon.cmd sets AGENT_WORKSPACE (env.cmd) before Windows Terminal starts, so new tabs can open in it');
|
|
34
|
+
t.includes(fs.readFileSync(path.join(gen.launchPath, 'env.cmd'), 'utf8'), 'AGENT_WORKSPACE=', 'env.cmd names the workspace');
|
|
35
|
+
t.equal(logonEnv('C:\\Workspace\\100%\\x'), '@set "AGENT_WORKSPACE=C:\\Workspace\\100%%\\x"\r\n', 'a % in the workspace path is escaped for cmd');
|
|
32
36
|
t.section('session.json');
|
|
33
37
|
const session = f.readJson(gen.sessionPath);
|
|
34
38
|
t.equal({
|
|
@@ -43,6 +47,7 @@ export const generateSuite = {
|
|
|
43
47
|
}, { launchId: true, workspace: 'C:\\Workspace\\work-a', workspaceHost: f.workA, firstArgument: '--version', gitFromHost: true, nvmFromHost: true, nodeSpec: '22.3.0', gitAccess: 'full' }, 'session.json carries the id, paths, agent arguments, host tools, versions and git access');
|
|
44
48
|
t.matches(session.hostTools.nodeVersion ?? '', /^\d+\.\d+\.\d+$/, 'session.json: the host Node version is a plain version');
|
|
45
49
|
t.equal(session.cpuLimitPercent, 80, 'session.json carries the CPU cap for cpucap.ps1 (default 80 %)');
|
|
50
|
+
t.equal(session.pageFileMb, 16384, 'session.json carries the paging file limit for pagefile.ps1 (default 16 GB)');
|
|
46
51
|
t.check(!LocalOverrides.safeParse({ sandbox: { cpuLimit: 'none' } }).success, "a project's .sandbox-config.json cannot lift your CPU cap (sandbox.cpuLimit is yours only)");
|
|
47
52
|
t.section('what is copied from this PC');
|
|
48
53
|
const staged = path.join(gen.launchPath, 'claude-profile');
|
package/dist/selftest/index.js
CHANGED
|
@@ -40,6 +40,9 @@ import { syncSuite } from './sync.test.js';
|
|
|
40
40
|
import { testGuardSuite } from './test-guard.test.js';
|
|
41
41
|
import { toolchainsSuite } from './toolchains.test.js';
|
|
42
42
|
import { workspaceSuite } from './workspace.test.js';
|
|
43
|
+
import { workspaceViewSuite } from './workspace-view.test.js';
|
|
44
|
+
import { liveNetworkSuite } from './live-network.test.js';
|
|
45
|
+
import { auditSuite } from './audit.test.js';
|
|
43
46
|
/** Every suite, in the order they run. The live ones come last: they are the slowest and need Windows. */
|
|
44
47
|
export const SUITES = [
|
|
45
48
|
// settings and project files
|
|
@@ -47,7 +50,7 @@ export const SUITES = [
|
|
|
47
50
|
// the generated sandbox and the guest that boots it
|
|
48
51
|
generateSuite, guestBootSuite, mirrorSuite, projectsSuite,
|
|
49
52
|
// syncing with this PC
|
|
50
|
-
syncSuite, accountsSuite,
|
|
53
|
+
syncSuite, accountsSuite, workspaceViewSuite, auditSuite,
|
|
51
54
|
// an untrusted sandbox: what the host refuses to do with what it finds
|
|
52
55
|
linksSuite, channelSuite, gitAccessSuite, repoGuardSuite, secretsSuite, hostileInputSuite, testGuardSuite,
|
|
53
56
|
// pure helpers and the command line
|
|
@@ -55,7 +58,7 @@ export const SUITES = [
|
|
|
55
58
|
// every command as its own process
|
|
56
59
|
hostSimSuite,
|
|
57
60
|
// real processes
|
|
58
|
-
liveDaemonSuite, liveProjectsSuite, liveGitSuite,
|
|
61
|
+
liveDaemonSuite, liveProjectsSuite, liveGitSuite, liveNetworkSuite,
|
|
59
62
|
];
|
|
60
63
|
/** Windows Sandbox (its user is always WDAGUtilityAccount) or a GitHub Actions runner: never someone's PC. */
|
|
61
64
|
export const onThrowawayMachine = () => os.userInfo().username.toLowerCase() === 'wdagutilityaccount' || process.env.GITHUB_ACTIONS === 'true';
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Live: the sandbox's allowOnly proxy (the real bundled runtime\netproxy.js) and the PC-side port forwarder, on
|
|
2
|
+
// loopback only; and that the cargo shim compiles with the compiler that ships with Windows.
|
|
3
|
+
import net from 'node:net';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
6
|
+
import { startPortForwards } from '../host/port-forward.js';
|
|
7
|
+
import { PROXY_PORT, relayPort } from '../shared/netpolicy.js';
|
|
8
|
+
import { CSC } from './live.js';
|
|
9
|
+
/** A TCP server that answers every line with "echo:<line>". */
|
|
10
|
+
const echoServer = (port = 0) => new Promise((resolve) => {
|
|
11
|
+
const server = net.createServer((s) => s.on('data', (d) => s.write('echo:' + d.toString())));
|
|
12
|
+
server.listen(port, '127.0.0.1', () => resolve(server));
|
|
13
|
+
});
|
|
14
|
+
const portOf = (server) => server.address().port;
|
|
15
|
+
/** Sends `text` after `preamble` (a CONNECT) and resolves with everything received within a short while. */
|
|
16
|
+
const talk = (port, preamble, text) => new Promise((resolve) => {
|
|
17
|
+
const socket = net.connect(port, '127.0.0.1');
|
|
18
|
+
let got = '';
|
|
19
|
+
socket.on('data', (d) => { got += d.toString(); if (preamble && got.includes('\r\n\r\n') && got.startsWith('HTTP/1.1 200') && !got.includes('echo:'))
|
|
20
|
+
socket.write(text); });
|
|
21
|
+
socket.on('error', () => resolve(got));
|
|
22
|
+
socket.on('connect', () => socket.write(preamble || text));
|
|
23
|
+
setTimeout(() => { socket.destroy(); resolve(got); }, 1500);
|
|
24
|
+
});
|
|
25
|
+
export const liveNetworkSuite = {
|
|
26
|
+
name: 'live-network',
|
|
27
|
+
title: 'live: allowOnly proxy, port forwarding, cargo shim',
|
|
28
|
+
live: true,
|
|
29
|
+
run: async (t, f) => {
|
|
30
|
+
t.section('allowOnly proxy');
|
|
31
|
+
const gen = await f.launch({}, { config: { ...f.baseConfig, network: { ...f.baseConfig.network, allowOnly: ['localhost'] } } });
|
|
32
|
+
const echo = await echoServer();
|
|
33
|
+
const proxy = spawn(process.execPath, [path.join(gen.launchPath, 'runtime', 'netproxy.js')], { env: { ...process.env, AGENT_LAUNCH_DIR: gen.launchPath, AGENT_DATA_DIR: t.dir('netproxy-data') }, stdio: 'ignore' });
|
|
34
|
+
try {
|
|
35
|
+
const up = await t.waitFor(() => new Promise((resolve) => { const s = net.connect(PROXY_PORT, '127.0.0.1', () => { s.destroy(); resolve(true); }); s.on('error', () => resolve(false)); }), 10000, 200);
|
|
36
|
+
t.check(Boolean(up), 'the proxy listens on localhost');
|
|
37
|
+
const allowed = await talk(PROXY_PORT, 'CONNECT localhost:' + portOf(echo) + ' HTTP/1.1\r\nHost: localhost\r\n\r\n', 'hi');
|
|
38
|
+
t.check(allowed.startsWith('HTTP/1.1 200') && allowed.includes('echo:hi'), 'an allowed host is tunnelled through', allowed);
|
|
39
|
+
const blocked = await talk(PROXY_PORT, 'CONNECT example.com:443 HTTP/1.1\r\nHost: example.com\r\n\r\n', '');
|
|
40
|
+
t.check(blocked.startsWith('HTTP/1.1 403'), 'any other host is refused (403)', blocked);
|
|
41
|
+
}
|
|
42
|
+
finally {
|
|
43
|
+
proxy.kill();
|
|
44
|
+
echo.close();
|
|
45
|
+
}
|
|
46
|
+
t.section('port forwarding');
|
|
47
|
+
const relay = await echoServer(relayPort(0));
|
|
48
|
+
const free = await echoServer();
|
|
49
|
+
const pcPort = portOf(free);
|
|
50
|
+
free.close();
|
|
51
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
52
|
+
const stop = startPortForwards([{ pc: pcPort, sandbox: 3000 }], () => '127.0.0.1');
|
|
53
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
54
|
+
const forwarded = await talk(pcPort, '', 'ping');
|
|
55
|
+
stop();
|
|
56
|
+
relay.close();
|
|
57
|
+
t.equal(forwarded, 'echo:ping', 'localhost:<pc> on this PC reaches the sandbox\'s relay');
|
|
58
|
+
t.section('cargo shim');
|
|
59
|
+
const out = path.join(t.dir('cargoshim'), 'cargo.exe');
|
|
60
|
+
const compiled = spawnSync(CSC, ['/nologo', '/optimize', '/out:' + out, path.join(gen.launchPath, 'sandbox', 'cargoshim.cs')], { encoding: 'utf8' });
|
|
61
|
+
t.check(compiled.status === 0, 'cargoshim.cs compiles with the .NET Framework compiler (C# 5)', compiled.stdout + compiled.stderr);
|
|
62
|
+
},
|
|
63
|
+
};
|
|
@@ -4,6 +4,7 @@ import fs from 'node:fs';
|
|
|
4
4
|
import os from 'node:os';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { download, setChecksumRequired } from '../shared/download.js';
|
|
7
|
+
import { allowedAnswer, allowStorePath, saveAllowed } from '../core/local-config.js';
|
|
7
8
|
export const localConfigSuite = {
|
|
8
9
|
name: 'local-config',
|
|
9
10
|
title: '.sandbox-config.json: approval, tighten-only rules, validation',
|
|
@@ -13,7 +14,7 @@ export const localConfigSuite = {
|
|
|
13
14
|
harness: 'codex',
|
|
14
15
|
sandbox: { memoryMb: 4096, networking: false, headless: true },
|
|
15
16
|
toolchains: { node: { version: '18' }, rust: { enabled: false } },
|
|
16
|
-
mappings: [{ host: './assets', guest: 'C:\\Assets', readOnly: true }, { host: '
|
|
17
|
+
mappings: [{ host: './assets', guest: 'C:\\Assets', readOnly: true }, { host: './data', guest: 'C:\\Data', readOnly: false }],
|
|
17
18
|
scripts: ['./setup.mjs'],
|
|
18
19
|
};
|
|
19
20
|
f.writeLocal(f.workB, localB);
|
|
@@ -30,7 +31,7 @@ export const localConfigSuite = {
|
|
|
30
31
|
pythonStillOn: Boolean(approved.config.toolchains.python.enabled),
|
|
31
32
|
nodeSource: approved.versions.node.source,
|
|
32
33
|
}, { harness: 'codex', memoryMb: 4096, headless: true, rustEnabled: false, pythonStillOn: true, nodeSource: '.sandbox-config.json' }, 'approved local settings apply over the defaults (including toolchains and headless)');
|
|
33
|
-
t.check(approved.config.mappings.some((m) => m.host === f.
|
|
34
|
+
t.check(approved.config.mappings.some((m) => m.host === path.join(f.workB, 'data') && !m.readOnly), 'relative mapping paths resolve against the folder of .sandbox-config.json', 'mappings: ' + JSON.stringify(approved.config.mappings));
|
|
34
35
|
t.check(approved.scripts.includes(path.join(f.workB, 'setup.mjs')), 'relative script paths resolve against the folder of .sandbox-config.json', 'scripts: ' + JSON.stringify(approved.scripts));
|
|
35
36
|
t.equal((await f.run({ cwd: f.workB })).harnessName, 'codex', 'an approval is remembered');
|
|
36
37
|
t.equal((await f.run({ cwd: f.workB, noLocalConfig: true })).harnessName, 'claude', '--no-local-config ignores the file');
|
|
@@ -50,6 +51,8 @@ export const localConfigSuite = {
|
|
|
50
51
|
await rejectedBecause({ toolchains: { node: { hostDir: 'x' } } }, 'toolchains.node.hostDir is not allowed', 'a project file cannot point at host tool folders');
|
|
51
52
|
await rejectedBecause({ sandbox: { memoryMb: 12 } }, 'sandbox.memoryMb:', 'values are type-checked (memory too small)');
|
|
52
53
|
await rejectedBecause({ mappings: [{ host: 'x', guest: 'C:\\Windows\\x', readOnly: true }] }, 'reserved or invalid guest path', 'reserved guest paths are rejected');
|
|
54
|
+
await rejectedBecause({ mappings: [{ host: '../shared', guest: 'C:\\Shared', readOnly: true }] }, 'only map folders inside the project', 'a project file cannot map folders outside the project');
|
|
55
|
+
await rejectedBecause({ mappings: [{ host: '%APPDATA%', guest: 'C:\\Startup', readOnly: false }] }, 'only map folders inside the project', '... not even through environment variables');
|
|
53
56
|
await rejectedBecause({ provisioning: { git: { access: 'admin' } } }, 'provisioning.git.access:', 'unknown git access modes are rejected');
|
|
54
57
|
await rejectedBecause('{\n // a comment\n "readOnly": tru\n}', 'line 3', 'broken JSON is reported with its line');
|
|
55
58
|
await rejectedBecause({ _comment: 'old style' }, '_comment is not allowed', 'unknown keys are rejected');
|
|
@@ -72,7 +75,9 @@ export const localConfigSuite = {
|
|
|
72
75
|
provisioning: { ...f.baseConfig.provisioning, git: { ...f.baseConfig.provisioning.git, access: 'readonly' } },
|
|
73
76
|
sandbox: { ...f.baseConfig.sandbox, vGpu: false, clipboard: false, networking: false, protectedClient: true },
|
|
74
77
|
};
|
|
75
|
-
const
|
|
78
|
+
const keptRun = await f.run({ cwd: dirC, trustConfig: true }, strictUser);
|
|
79
|
+
const kept = keptRun.config;
|
|
80
|
+
t.check(keptRun.notes.some((n) => n.includes('sandbox.vGpu = true') && n.includes('your own settings')), 'what a project asked for and did not get is said at launch, with how to allow it', keptRun.notes.join('\n'));
|
|
76
81
|
t.equal({
|
|
77
82
|
readOnly: kept.readOnly,
|
|
78
83
|
blockPrivateRanges: kept.network.blockPrivateRanges,
|
|
@@ -83,6 +88,14 @@ export const localConfigSuite = {
|
|
|
83
88
|
protectedClient: kept.sandbox.protectedClient,
|
|
84
89
|
copyCredentials: kept.claudeProfile.copyCredentials,
|
|
85
90
|
}, { readOnly: true, blockPrivateRanges: true, gitAccess: 'readonly', vGpu: false, clipboard: false, networking: false, protectedClient: true, copyCredentials: false }, 'a project file can never loosen network, git, read-only, GPU, clipboard or login settings');
|
|
91
|
+
// You allowed one request for this project (asked once at an interactive launch): only that one is let through.
|
|
92
|
+
const store = allowStorePath(strictUser.storage.root);
|
|
93
|
+
saveAllowed(store, path.join(dirC, '.sandbox-config.json'), 'sandbox.vGpu', true, true);
|
|
94
|
+
saveAllowed(store, path.join(dirC, '.sandbox-config.json'), 'sandbox.clipboard', true, false);
|
|
95
|
+
const allowedRun = await f.run({ cwd: dirC, trustConfig: true }, strictUser);
|
|
96
|
+
t.equal({ vGpu: allowedRun.config.sandbox.vGpu, clipboard: allowedRun.config.sandbox.clipboard, networking: allowedRun.config.sandbox.networking }, { vGpu: true, clipboard: false, networking: false }, 'a request you allowed for this project is used; one you declined and the others are not');
|
|
97
|
+
t.check(allowedRun.notes.some((n) => n.includes('Allowed for this project: sandbox.vGpu')) && allowedRun.notes.some((n) => n.includes('sandbox.clipboard') && n.includes('You said no')), 'launch says what was allowed and what you declined', allowedRun.notes.join('\n'));
|
|
98
|
+
t.equal(allowedAnswer(store, path.join(f.workB, '.sandbox-config.json'), 'sandbox.vGpu', true), null, 'an answer is kept per project file');
|
|
86
99
|
f.writeLocal(dirC, { readOnly: true, network: { blockPrivateRanges: true }, provisioning: { git: { access: 'none' } }, sandbox: { networking: false, clipboard: false } });
|
|
87
100
|
const tightened = (await f.run({ cwd: dirC, trustConfig: true })).config;
|
|
88
101
|
t.equal({ readOnly: tightened.readOnly, gitAccess: tightened.provisioning.git.access, networking: tightened.sandbox.networking, clipboard: tightened.sandbox.clipboard }, { readOnly: true, gitAccess: 'none', networking: false, clipboard: false }, 'a project file can still make things stricter');
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { isSandboxAddress } from '../host/attach.js';
|
|
2
2
|
import { buildFirewallCommands, cidrToRange, ipv4DnsServers, parseDefaultGateways, parseDnsServers, subtractIps } from '../shared/netrules.js';
|
|
3
|
+
import { expandAllowList, hostAllowed, withBlockedHosts } from '../shared/netpolicy.js';
|
|
3
4
|
export const networkSuite = {
|
|
4
5
|
name: 'network',
|
|
5
6
|
title: 'firewall maths, gateway/DNS parsing, attach address checks',
|
|
@@ -17,12 +18,23 @@ export const networkSuite = {
|
|
|
17
18
|
t.section('attach only reaches into the sandbox');
|
|
18
19
|
const interfaces = {
|
|
19
20
|
'vEthernet (Default Switch)': [{ address: '172.20.0.1', netmask: '255.255.240.0', family: 'IPv4', internal: false, mac: '', cidr: null }],
|
|
21
|
+
'vEthernet (WSL (Hyper-V firewall))': [{ address: '172.30.0.1', netmask: '255.255.240.0', family: 'IPv4', internal: false, mac: '', cidr: null }],
|
|
20
22
|
Ethernet: [{ address: '192.168.1.10', netmask: '255.255.255.0', family: 'IPv4', internal: false, mac: '', cidr: null }],
|
|
21
23
|
};
|
|
22
24
|
t.check(isSandboxAddress('172.20.5.9', interfaces), 'an address in the Hyper-V subnet is the sandbox');
|
|
23
25
|
t.check(!isSandboxAddress('192.168.1.20', interfaces), 'a LAN address is not');
|
|
26
|
+
t.check(!isSandboxAddress('172.30.4.4', interfaces), 'an address on the WSL Hyper-V network is not (keystrokes never go to a WSL VM)');
|
|
24
27
|
t.check(!isSandboxAddress('127.0.0.1', interfaces), 'loopback is not');
|
|
25
28
|
t.check(!isSandboxAddress('8.8.8.8', interfaces), 'an internet address is not');
|
|
26
29
|
t.check(!isSandboxAddress('172.20.0.1', interfaces), 'this PC\'s own address on that subnet is not');
|
|
30
|
+
t.section('network settings');
|
|
31
|
+
const allow = expandAllowList(['@developer', 'Example.COM', '*.corp.example', 'not a host!']);
|
|
32
|
+
t.check(allow.includes('registry.npmjs.org') && allow.includes('api.anthropic.com') && allow.includes('example.com') && !allow.includes('not a host!'), 'allowOnly: @developer expands to registries, GitHub and AI providers; bad entries are dropped');
|
|
33
|
+
t.equal(['a.corp.example', 'corp.example', 'example.com', 'www.example.com', 'evil-example.com'].map((h) => hostAllowed(h, allow)), [true, false, true, false, false], 'allowOnly: *.x covers subdomains only, a name covers only itself');
|
|
34
|
+
const hosts = '127.0.0.1 localhost\r\n# mine\r\n';
|
|
35
|
+
const once = withBlockedHosts(hosts, ['Telemetry.Example.com', '*.bad']);
|
|
36
|
+
t.check(once.includes('0.0.0.0 telemetry.example.com') && once.includes('# mine') && !once.includes('*.bad'), 'blockHosts: names go to 0.0.0.0, your own lines stay, wildcards are skipped (a hosts file cannot do them)');
|
|
37
|
+
t.equal(withBlockedHosts(once, ['telemetry.example.com']), once, 'blockHosts: applying again changes nothing');
|
|
38
|
+
t.check(!withBlockedHosts(once, []).includes('telemetry'), 'blockHosts: an empty list removes the section');
|
|
27
39
|
},
|
|
28
40
|
};
|
|
@@ -18,7 +18,7 @@ import { projectFolderName } from '../shared/paths.js';
|
|
|
18
18
|
import { syncHistoryOut } from '../sync/history.js';
|
|
19
19
|
import { validateOptions, validateProjectsArgs } from '../cli/validate.js';
|
|
20
20
|
import { parseCli, parseSessionWant } from '../cli/args.js';
|
|
21
|
-
import { candidateFolders, severalAgentsArgv } from '../cli/menu
|
|
21
|
+
import { candidateFolders, severalAgentsArgv } from '../cli/menu/index.js';
|
|
22
22
|
import { generateSandbox } from '../host/generate.js';
|
|
23
23
|
import { askedWhen } from '../cli/manifest.js';
|
|
24
24
|
import { describeSessions } from '../cli/commands/restart.js';
|
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { guardRepo, snapshotRepo } from '../host/repo-guard.js';
|
|
6
|
+
import { isMarked, markFromSandbox, watchAndMark } from '../host/motw.js';
|
|
6
7
|
export const repoGuardSuite = {
|
|
7
8
|
name: 'repo-guard',
|
|
8
9
|
title: 'repository guard (hooks, git config, planted commands, replaced .git)',
|
|
9
|
-
run: (t) => {
|
|
10
|
+
run: async (t) => {
|
|
10
11
|
const readConfig = (repo) => fs.readFileSync(path.join(repo, '.git', 'config'), 'utf8');
|
|
11
12
|
const disabled = (keyPattern) => new RegExp('# \\[agent-sandbox\\] disabled.*' + keyPattern);
|
|
12
13
|
t.section('hooks and git config');
|
|
@@ -59,5 +60,62 @@ export const repoGuardSuite = {
|
|
|
59
60
|
const before4 = snapshotRepo(repo4);
|
|
60
61
|
fs.appendFileSync(path.join(repo4, '.git', 'config'), '#'.repeat(5 * 1024 * 1024));
|
|
61
62
|
t.check(guardRepo(repo4, before4).some((x) => x.kind === 'git' && x.detail.includes('over 4 MB')), 'a git config too big to check is reported instead of passing');
|
|
63
|
+
t.section('more git config that runs programs');
|
|
64
|
+
const repo5 = t.at('repo5');
|
|
65
|
+
t.write(path.join(repo5, '.git', 'config'), '[alias]\n\tst = status\n');
|
|
66
|
+
const before5 = snapshotRepo(repo5);
|
|
67
|
+
fs.appendFileSync(path.join(repo5, '.git', 'config'), '[alias]\n\tlg = !calc.exe\n\tco = checkout\n[pager]\n\tlog = calc.exe\n[diff]\n\texternal = calc.exe\n');
|
|
68
|
+
guardRepo(repo5, before5);
|
|
69
|
+
const config5 = readConfig(repo5);
|
|
70
|
+
t.matches(config5, disabled('lg = !calc'), 'git config: an alias that runs a program (!) is disabled');
|
|
71
|
+
t.matches(config5, /^\tco = checkout$/m, 'git config: a plain alias stays');
|
|
72
|
+
t.matches(config5, disabled('log = calc'), 'git config: [pager] entries are disabled');
|
|
73
|
+
t.matches(config5, disabled('external = calc'), 'git config: diff.external is disabled');
|
|
74
|
+
t.section('submodules and nested repositories');
|
|
75
|
+
const repo6 = t.at('repo6');
|
|
76
|
+
t.write(path.join(repo6, '.git', 'config'), '[core]\n');
|
|
77
|
+
t.write(path.join(repo6, '.git', 'modules', 'lib', 'config'), '[core]\n');
|
|
78
|
+
t.write(path.join(repo6, '.git', 'modules', 'lib', 'HEAD'), 'ref: refs/heads/main\n');
|
|
79
|
+
t.write(path.join(repo6, 'tools', 'inner', '.git', 'config'), '[core]\n');
|
|
80
|
+
const before6 = snapshotRepo(repo6);
|
|
81
|
+
t.write(path.join(repo6, '.git', 'modules', 'lib', 'hooks', 'post-checkout'), 'calc.exe');
|
|
82
|
+
fs.appendFileSync(path.join(repo6, 'tools', 'inner', '.git', 'config'), '\tfsmonitor = calc.exe\n');
|
|
83
|
+
t.write(path.join(repo6, 'other', '.git', 'hooks', 'pre-commit'), 'calc.exe');
|
|
84
|
+
const findings6 = guardRepo(repo6, before6);
|
|
85
|
+
t.check(!fs.existsSync(path.join(repo6, '.git', 'modules', 'lib', 'hooks', 'post-checkout')), 'a hook planted in a submodule is quarantined');
|
|
86
|
+
t.matches(fs.readFileSync(path.join(repo6, 'tools', 'inner', '.git', 'config'), 'utf8'), disabled('fsmonitor'), 'git config of a nested repository is checked too');
|
|
87
|
+
t.check(!fs.existsSync(path.join(repo6, 'other', '.git', 'hooks', 'pre-commit')), 'a repository created inside the sandbox has no trusted hooks');
|
|
88
|
+
t.check(findings6.some((f) => f.detail.startsWith('tools/inner/.git: ')), 'findings say which repository they are in');
|
|
89
|
+
const legacy = { ...before6, repos: undefined, agent: undefined };
|
|
90
|
+
t.write(path.join(repo6, 'third', '.git', 'hooks', 'pre-commit'), 'yours');
|
|
91
|
+
guardRepo(repo6, legacy);
|
|
92
|
+
t.check(fs.existsSync(path.join(repo6, 'third', '.git', 'hooks', 'pre-commit')), 'a snapshot from an older version (no baseline) never moves your files');
|
|
93
|
+
t.section('agent and editor files');
|
|
94
|
+
const repo7 = t.at('repo7');
|
|
95
|
+
t.write(path.join(repo7, '.claude', 'settings.local.json'), '{ "permissions": { "allow": ["Bash(npm test)"] } }');
|
|
96
|
+
t.write(path.join(repo7, '.vscode', 'settings.json'), '{\n // comment\n "editor.tabSize": 2\n}');
|
|
97
|
+
const before7 = snapshotRepo(repo7);
|
|
98
|
+
t.write(path.join(repo7, '.claude', 'settings.local.json'), '{ "permissions": { "allow": ["Bash(npm test)", "Bash(npm run lint)"] } }');
|
|
99
|
+
t.write(path.join(repo7, '.vscode', 'settings.json'), '{\n "editor.tabSize": 4,\n}');
|
|
100
|
+
t.equal(guardRepo(repo7, before7), [], 'ordinary edits (permissions, editor settings) are left alone');
|
|
101
|
+
t.write(path.join(repo7, '.claude', 'settings.json'), '{ "hooks": { "SessionStart": [{ "hooks": [{ "type": "command", "command": "calc.exe" }] }] } }');
|
|
102
|
+
t.write(path.join(repo7, '.mcp.json'), '{ "mcpServers": { "x": { "command": "calc.exe" } } }');
|
|
103
|
+
t.write(path.join(repo7, '.vscode', 'tasks.json'), '{ "tasks": [{ "label": "x", "command": "calc.exe", "runOptions": { "runOn": "folderOpen" } }] }');
|
|
104
|
+
t.write(path.join(repo7, '.vscode', 'settings.json'), '{ "python.defaultInterpreterPath": "C:\\\\evil.exe" }');
|
|
105
|
+
const findings7 = guardRepo(repo7, before7);
|
|
106
|
+
t.equal(findings7.filter((f) => f.kind === 'agent').length, 4, 'Claude hooks, MCP servers, folder-open tasks and program paths in VS Code settings are quarantined');
|
|
107
|
+
t.check(!fs.existsSync(path.join(repo7, '.mcp.json')) && fs.existsSync(path.join(repo7, '.claude', 'settings.local.json')), '... and nothing else is moved');
|
|
108
|
+
t.section('programs from the sandbox are marked');
|
|
109
|
+
const marked = t.at('marked');
|
|
110
|
+
t.write(path.join(marked, 'setup.ps1'), 'calc');
|
|
111
|
+
t.write(path.join(marked, 'src', 'app.ts'), 'code');
|
|
112
|
+
t.check(markFromSandbox(path.join(marked, 'setup.ps1')) && isMarked(path.join(marked, 'setup.ps1')), 'a script gets the "from the internet" mark (SmartScreen / PowerShell ask before it runs)');
|
|
113
|
+
t.check(!markFromSandbox(path.join(marked, 'src', 'app.ts')) && !isMarked(path.join(marked, 'src', 'app.ts')), 'source files are left alone');
|
|
114
|
+
t.equal(fs.readFileSync(path.join(marked, 'setup.ps1'), 'utf8'), 'calc', 'only the mark is written, never the file');
|
|
115
|
+
const stop = watchAndMark([marked]);
|
|
116
|
+
t.write(path.join(marked, 'bin', 'tool.exe'), 'MZ');
|
|
117
|
+
const live = await t.waitFor(() => isMarked(path.join(marked, 'bin', 'tool.exe')), 5000);
|
|
118
|
+
stop();
|
|
119
|
+
t.check(Boolean(live), 'while it watches, a program that appears is marked within a second or two');
|
|
62
120
|
},
|
|
63
121
|
};
|
|
@@ -4,6 +4,7 @@ import fs from 'node:fs';
|
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { syncClaudeMem } from '../sync/claude-mem.js';
|
|
6
6
|
import { readSyncState, syncHistoryIn, syncHistoryOut, syncStatePath, translatePaths } from '../sync/history.js';
|
|
7
|
+
import { beatLife, endLife, explainStop, lastStopWarning, startLife } from '../host/stops.js';
|
|
7
8
|
export const syncSuite = {
|
|
8
9
|
name: 'sync',
|
|
9
10
|
title: 'history and claude-mem sync',
|
|
@@ -19,13 +20,17 @@ export const syncSuite = {
|
|
|
19
20
|
t.write(path.join(hostProject, 's1.jsonl'), '{"cwd":"D:\\\\Code\\\\my-app","t":1}\n');
|
|
20
21
|
t.write(path.join(hostDot, 'projects', 'C--Other', 's2.jsonl'), '{"x":1}\n');
|
|
21
22
|
t.write(path.join(hostDot, 'todos', 's1.json'), '[]');
|
|
22
|
-
t.write(path.join(hostDot, 'history.jsonl'), '{"display":"hi","timestamp":1,"project":"D:\\\\Code\\\\my-app"}\n');
|
|
23
|
+
t.write(path.join(hostDot, 'history.jsonl'), '{"display":"hi","timestamp":1,"project":"D:\\\\Code\\\\my-app"}\n{"display":"secret plan","timestamp":0,"project":"C:\\\\Other"}\n');
|
|
23
24
|
const sides = { hostClaudeDir: hostDot, guestClaudeDir: guestDot, hostWorkspace: hostWs, guestWorkspace: guestWs };
|
|
24
25
|
syncHistoryIn(sides);
|
|
25
26
|
t.includes(fs.readFileSync(path.join(guestProject, 's1.jsonl'), 'utf8'), 'C:\\\\Workspace\\\\my-app', 'history in: the current project is renamed and its paths translated');
|
|
26
|
-
t.check(fs.existsSync(path.join(guestDot, 'projects', 'C--Other', '
|
|
27
|
-
|
|
28
|
-
t.includes(
|
|
27
|
+
t.check(!fs.existsSync(path.join(guestDot, 'projects', 'C--Other')) && !fs.existsSync(path.join(guestDot, 'todos')), 'history in (scope "project"): other projects\' conversations and session files stay on this PC');
|
|
28
|
+
const promptIn = fs.readFileSync(path.join(guestDot, 'history.jsonl'), 'utf8');
|
|
29
|
+
t.includes(promptIn, 'C:\\\\Workspace\\\\my-app', 'prompt history in: project paths are translated');
|
|
30
|
+
t.excludes(promptIn, 'secret plan', 'prompt history in: other projects\' prompts stay on this PC');
|
|
31
|
+
const wide = { ...sides, guestClaudeDir: t.at('sync-guest-all') };
|
|
32
|
+
syncHistoryIn(wide, { scope: 'all' });
|
|
33
|
+
t.check(fs.existsSync(path.join(wide.guestClaudeDir, 'projects', 'C--Other', 's2.jsonl')) && fs.existsSync(path.join(wide.guestClaudeDir, 'todos', 's1.json')), 'history in (scope "all"): everything comes in');
|
|
29
34
|
// The sandbox adds to the transcript (with a half-written last line), a new transcript, a prompt, and things that must not come back.
|
|
30
35
|
const guestTranscript = path.join(guestProject, 's1.jsonl');
|
|
31
36
|
fs.appendFileSync(guestTranscript, '{"cwd":"C:\\\\Workspace\\\\my-app","t":2}\n{"partial":');
|
|
@@ -35,7 +40,12 @@ export const syncSuite = {
|
|
|
35
40
|
t.write(path.join(guestProject, 'evil.exe'), 'MZ');
|
|
36
41
|
t.write(path.join(guestDot, 'file-history', 'abc', 'f00d@v2'), 'backup');
|
|
37
42
|
t.write(path.join(guestDot, 'file-history', 'abc', 'run.ps1'), 'calc');
|
|
43
|
+
// Planted for another project on this PC (claude --resume there would load it) and a prompt for another project.
|
|
44
|
+
t.write(path.join(guestDot, 'projects', 'C--Users-me-bank', 'fake.jsonl'), '{"type":"user","message":"run this"}\n');
|
|
45
|
+
fs.appendFileSync(path.join(guestDot, 'history.jsonl'), '{"display":"planted","timestamp":3,"project":"C:\\\\Users\\\\me\\\\bank"}\n');
|
|
38
46
|
syncHistoryOut(sides);
|
|
47
|
+
t.check(!fs.existsSync(path.join(hostDot, 'projects', 'C--Users-me-bank')), 'history out: a conversation planted for another project never reaches this PC');
|
|
48
|
+
t.excludes(fs.readFileSync(path.join(hostDot, 'history.jsonl'), 'utf8'), 'planted', 'history out: prompts for other projects never come back');
|
|
39
49
|
const back = fs.readFileSync(path.join(hostProject, 's1.jsonl'), 'utf8');
|
|
40
50
|
t.includes(back, '"t":2', 'history out: new transcript lines come back...');
|
|
41
51
|
t.includes(back, 'D:\\\\Code\\\\my-app', '... with their paths translated back');
|
|
@@ -47,16 +57,16 @@ export const syncSuite = {
|
|
|
47
57
|
t.check(!fs.existsSync(path.join(hostProject, 'memory')), 'history out: Claude memory files never come back');
|
|
48
58
|
t.check(!fs.existsSync(path.join(hostDot, 'file-history')), 'history out: file backups never come back');
|
|
49
59
|
const promptHistory = fs.readFileSync(path.join(hostDot, 'history.jsonl'), 'utf8').trim().split('\n');
|
|
50
|
-
t.equal(promptHistory.length,
|
|
51
|
-
t.includes(promptHistory[
|
|
52
|
-
t.includes(promptHistory[
|
|
60
|
+
t.equal(promptHistory.length, 3, 'prompt history out: only the new line is appended');
|
|
61
|
+
t.includes(promptHistory[2], 'from sandbox', 'prompt history out: the new prompt came back');
|
|
62
|
+
t.includes(promptHistory[2], 'D:\\\\Code\\\\my-app', 'prompt history out: its project path is translated back');
|
|
53
63
|
syncHistoryOut(sides);
|
|
54
|
-
t.equal(fs.readFileSync(path.join(hostDot, 'history.jsonl'), 'utf8').trim().split('\n').length,
|
|
64
|
+
t.equal(fs.readFileSync(path.join(hostDot, 'history.jsonl'), 'utf8').trim().split('\n').length, 3, 'syncing again adds nothing (idempotent)');
|
|
55
65
|
t.section('history sync: what the sandbox may write into the prompt history');
|
|
56
66
|
const hostile = f.historySides('hostile-history', hostWs, guestWs);
|
|
57
|
-
t.write(path.join(hostile.guestClaudeDir, 'history.jsonl'), 'not json\n[1,2]\n{"display":"ok","timestamp":2}\n"text"\n');
|
|
67
|
+
t.write(path.join(hostile.guestClaudeDir, 'history.jsonl'), 'not json\n[1,2]\n{"display":"ok","timestamp":2,"project":"C:\\\\Workspace\\\\my-app"}\n{"display":"no project","timestamp":2}\n"text"\n');
|
|
58
68
|
syncHistoryOut(hostile);
|
|
59
|
-
t.equal(fs.readFileSync(path.join(hostile.hostClaudeDir, 'history.jsonl'), 'utf8'), '{"display":"ok","timestamp":2}\n', 'only JSON prompt-history entries come back from the sandbox, nothing else it wrote in that file');
|
|
69
|
+
t.equal(fs.readFileSync(path.join(hostile.hostClaudeDir, 'history.jsonl'), 'utf8'), '{"display":"ok","timestamp":2,"project":"D:\\\\Code\\\\my-app"}\n', 'only JSON prompt-history entries of this project come back from the sandbox, nothing else it wrote in that file');
|
|
60
70
|
t.section('sync state file');
|
|
61
71
|
const stateData = t.at('state-storage', 'data', 'd');
|
|
62
72
|
t.writeJson(syncStatePath(stateData), { hostClaudeDir: 'relative\\.claude', hostWorkspace: 'D:\\x', guestWorkspace: 'C:\\x' });
|
|
@@ -90,5 +100,35 @@ export const syncSuite = {
|
|
|
90
100
|
t.touch(t.write(hostDb, 'host-v3'), 30);
|
|
91
101
|
t.equal(syncClaudeMem(memHost, memGuest, memState, 'out'), 'conflict', 'claude-mem: both sides changed is reported as a conflict');
|
|
92
102
|
t.check(fs.readdirSync(t.temp).some((name) => name.startsWith('mem-host.conflict-')), 'claude-mem: this PC\'s database is kept as .conflict-<time> first', fs.readdirSync(t.temp).join(', '));
|
|
103
|
+
t.section('how the last sandbox ended');
|
|
104
|
+
const ev = (id, provider, at, process = '') => ({ id, provider, at, process });
|
|
105
|
+
t.equal(explainStop([ev(1074, 'User32', '2026-10-13T02:10:30Z', 'C:\\Windows\\servicing\\TrustedInstaller.exe (HOST)')])?.kind, 'updates', 'a restart by Windows Update is recognised');
|
|
106
|
+
t.equal(explainStop([ev(1074, 'User32', '2026-10-13T02:10:30Z', 'C:\\Windows\\System32\\winlogon.exe (HOST)')])?.text, 'this PC was restarted or shut down (by winlogon.exe)', 'another restart names the program');
|
|
107
|
+
t.equal(explainStop([ev(41, 'Microsoft-Windows-Kernel-Power', '2026-10-13T02:20:00Z'), ev(42, 'Microsoft-Windows-Kernel-Power', '2026-10-13T02:10:00Z')])?.kind, 'sleep', 'the first event counts (sleep, then a crash)');
|
|
108
|
+
t.equal(explainStop([ev(2004, 'Microsoft-Windows-Resource-Exhaustion-Detector', '2026-10-13T02:10:00Z')])?.kind, 'memory', 'low memory is recognised');
|
|
109
|
+
t.equal(explainStop([ev(41, 'SomethingElse', '2026-10-13T02:10:00Z')]), null, 'the same event id from another source is ignored');
|
|
110
|
+
// The message Hyper-V logged on 13 Oct 2026 when Windows inside the sandbox crashed.
|
|
111
|
+
const crashMessage = "'8abb707a-972e-42e2-9599-01d125d4595c' has encountered a fatal error. The guest operating system reported that it failed with the following error codes: ErrorCode0: 0x1A, ErrorCode1: 0x13003, ErrorCode2: 0x2346E5, ErrorCode3: 0x1";
|
|
112
|
+
t.equal(explainStop([ev(18590, 'Microsoft-Windows-Hyper-V-Worker', '2026-10-13T08:49:08Z', crashMessage)])?.text, 'Windows inside the sandbox crashed (stop code 0x1A, 0x13003)', 'a crash of Windows inside the sandbox is recognised, with its stop code');
|
|
113
|
+
t.equal(explainStop([ev(24, 'Microsoft-Windows-TerminalServices-LocalSessionManager', '2026-10-13T08:41:40Z', 'PC\\Admin')]), null, 'switching user is not taken for the cause (the sandbox keeps running)');
|
|
114
|
+
const lifeRoot = t.at('life-storage');
|
|
115
|
+
const lifeData = path.join(lifeRoot, 'data', 'testdata');
|
|
116
|
+
const noEvents = () => null;
|
|
117
|
+
t.equal(lastStopWarning(lifeRoot, lifeData, false, noEvents), null, 'no record: nothing to say');
|
|
118
|
+
startLife(lifeRoot, lifeData, true, new Date('2026-10-12T20:16:00Z'));
|
|
119
|
+
beatLife(lifeRoot, lifeData, new Date('2026-10-13T02:09:00Z'));
|
|
120
|
+
t.equal(lastStopWarning(lifeRoot, lifeData, true, noEvents), null, 'its watcher still runs: the sandbox has not ended');
|
|
121
|
+
let since = '';
|
|
122
|
+
const warning = lastStopWarning(lifeRoot, lifeData, false, (s) => { since = s; return { kind: 'updates', at: '2026-10-13T02:10:30Z', text: 'Windows restarted this PC to install updates' }; });
|
|
123
|
+
t.includes(warning ?? '', 'Windows restarted this PC to install updates', 'the PC stopped under a running sandbox: the next launch says why');
|
|
124
|
+
t.includes(warning ?? '', 'Pause updates', '... and what keeps it from happening again');
|
|
125
|
+
t.equal(since, '2026-10-13T02:09:00.000Z', 'the System log is read from the last time the sandbox was seen running');
|
|
126
|
+
t.equal(lastStopWarning(lifeRoot, lifeData, false, noEvents), null, 'said once only');
|
|
127
|
+
startLife(lifeRoot, lifeData, true);
|
|
128
|
+
endLife(lifeRoot, lifeData, null);
|
|
129
|
+
t.equal(lastStopWarning(lifeRoot, lifeData, false, noEvents), null, 'closed while this PC ran (by you): nothing to say');
|
|
130
|
+
startLife(lifeRoot, lifeData, true);
|
|
131
|
+
endLife(lifeRoot, lifeData, { kind: 'sleep', at: '2026-10-13T02:10:00Z', text: 'this PC went to sleep or hibernated' });
|
|
132
|
+
t.includes(lastStopWarning(lifeRoot, lifeData, false, noEvents) ?? '', 'sandbox.keepAwake', 'ended by sleep: the next launch names keepAwake');
|
|
93
133
|
},
|
|
94
134
|
};
|
|
@@ -5,6 +5,9 @@ import { javaMajorFrom, pickGoRelease, pickNodeVersion, pickPackageVersion, pick
|
|
|
5
5
|
import { parseReleasePage, parseReleaseTags } from '../shared/download.js';
|
|
6
6
|
import { decidePwsh, pcHasPwsh, PWSH_MAX_BYTES, readPwshScripts, usesPwsh } from '../core/detect-pwsh.js';
|
|
7
7
|
import { binstallAsset, CLI_TOOLS, decideCli, pwshAsset } from '../guest/toolchains/catalogue.js';
|
|
8
|
+
import { missingExtensions, vscodeExtensions, vscodeSettings } from '../guest/toolchains/vscode-profile.js';
|
|
9
|
+
import { APP_CATEGORIES, APPS, appsNeedMesa, appsToInstall, exeMatcher } from '../shared/apps.js';
|
|
10
|
+
import { Config, LocalOverrides } from '../core/schema.js';
|
|
8
11
|
export const toolchainsSuite = {
|
|
9
12
|
name: 'toolchains',
|
|
10
13
|
title: 'version pickers, release assets, PowerShell 7 and CLI tool decisions',
|
|
@@ -99,5 +102,32 @@ export const toolchainsSuite = {
|
|
|
99
102
|
const scanned = readPwshScripts(scanRoot);
|
|
100
103
|
t.equal(scanned.map((s) => s.path), ['moh.ps1', 'scripts/build.ps1'], 'pwsh scan: *.ps1 at the root and one folder down only (node_modules skipped)');
|
|
101
104
|
t.equal(scanned[1].text.length, PWSH_MAX_BYTES, 'pwsh scan: each script is read up to its cap');
|
|
105
|
+
t.section('VS Code set-up');
|
|
106
|
+
const vs = vscodeSettings({ javaHome: 'C:\\t\\java\\jdk-17.0.9+9', python: 'C:\\t\\python\\python.exe', pwsh: 'C:\\t\\pwsh\\pwsh.exe', gcc: 'C:\\t\\mingw\\bin\\gcc.exe', cl: 'C:\\t\\msvc\\cl.exe', dotnet: 'C:\\t\\dotnet\\dotnet.exe' });
|
|
107
|
+
t.equal([vs['java.jdt.ls.java.home'], vs['java.configuration.runtimes'][0].name, vs['python.defaultInterpreterPath'], vs['terminal.integrated.defaultProfile.windows'], vs['C_Cpp.default.compilerPath'], vs['dotnet.dotnetPath']], ['C:\\t\\java\\jdk-17.0.9+9', 'JavaSE-17', 'C:\\t\\python\\python.exe', 'PowerShell 7', 'C:\\t\\msvc\\cl.exe', 'C:\\t\\dotnet'], 'vscode: settings point at the sandbox\'s JDK (with its version), Python, PowerShell 7, compiler (MSVC first) and .NET');
|
|
108
|
+
t.check(!('go.goroot' in vs) && !('git.path' in vs), 'vscode: tools that are not installed are not set');
|
|
109
|
+
const ext = vscodeExtensions({ toolchains: ['node', 'java'], databases: ['postgres'], recommended: ['Foo.Bar', 'not an id'], extra: ['eamodio.gitlens', 'redhat.java'] });
|
|
110
|
+
t.check(ext.includes('redhat.java') && ext.includes('mtxr.sqltools-driver-pg') && ext.includes('foo.bar') && ext.includes('eamodio.gitlens') && !ext.includes('not an id') && ext.filter((e) => e === 'redhat.java').length === 1, 'vscode: extensions for the languages and databases in use, the project\'s and yours (no duplicates, nothing malformed)');
|
|
111
|
+
t.equal(missingExtensions(['golang.go', 'redhat.java'], ['golang.go-0.48.0', 'extensions.json']), ['redhat.java'], 'vscode: only extensions not installed yet are downloaded');
|
|
112
|
+
t.section('apps catalogue');
|
|
113
|
+
const ids = APPS.map((a) => a.id);
|
|
114
|
+
t.equal(ids.filter((id, i) => ids.indexOf(id) !== i), [], 'apps: every id is unique');
|
|
115
|
+
t.equal(APPS.filter((a) => !APP_CATEGORIES.some((c) => c.id === a.category)).map((a) => a.id), [], 'apps: every app is in a known category');
|
|
116
|
+
t.equal(APPS.filter((a) => 'github' in a.source && (() => { try {
|
|
117
|
+
new RegExp(a.source.asset);
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return true;
|
|
122
|
+
} })()).map((a) => a.id), [], 'apps: every GitHub asset pattern compiles');
|
|
123
|
+
t.check(exeMatcher('/^Godot_v[\\d.]+-stable_win64\\.exe$/')('Godot_v4.7.2-stable_win64.exe') && !exeMatcher('/^Godot_v[\\d.]+-stable_win64\\.exe$/')('Godot_v4.7.2-stable_win64_console.exe') && exeMatcher('Code.exe')('code.EXE'), 'apps: the program is found by name or by pattern');
|
|
124
|
+
const chosen = appsToInstall({ install: ['firefox', 'jq', 'githubdesktop'], custom: { mine: { name: 'Mine', url: 'https://example.com/m.zip', exe: 'm.exe' } } }, 'readonly');
|
|
125
|
+
t.equal(chosen.entries.map((e) => e.id), ['7zip', 'firefox', 'jq', 'custom/mine'], 'apps: 7-Zip comes first when an installer needs it; your own programs follow');
|
|
126
|
+
t.equal(chosen.skipped.length, 1, 'apps: an app with its own git (GitHub Desktop) is skipped unless git access is "full"');
|
|
127
|
+
t.check(appsNeedMesa({ install: ['godot'], custom: {} }) && !appsNeedMesa({ install: ['jq'], custom: {} }), 'apps: game engines bring Mesa (OpenGL without a GPU) along');
|
|
128
|
+
const custom = (app) => Config.shape.apps.shape.custom.safeParse({ x: app }).success;
|
|
129
|
+
t.check(custom({ name: 'A', github: 'o/r', asset: 'win.*\\.zip$', exe: 'a.exe' }) && custom({ name: 'B', url: 'https://x.org/b.zip', exe: 'b.exe' }), 'apps: your own program comes from a GitHub release or an https link');
|
|
130
|
+
t.check(!custom({ name: 'C', url: 'http://x.org/c.zip', exe: 'c.exe' }) && !custom({ name: 'D', github: 'o/r', asset: '(', exe: 'd.exe' }) && !custom({ name: 'E', github: 'o/r', asset: 'x', url: 'https://x.org', exe: 'e.exe' }), 'apps: plain http, a broken pattern, or both sources are refused');
|
|
131
|
+
t.check(!LocalOverrides.safeParse({ apps: { custom: {} } }).success && LocalOverrides.safeParse({ apps: { install: ['jq'] } }).success, 'apps: a project file may add catalogue apps but never programs of its own');
|
|
102
132
|
},
|
|
103
133
|
};
|