windows-development-sandbox 0.0.0-stage → 1.0.5
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 +14 -0
- package/README.md +78 -2
- package/config.example.json +332 -0
- package/dist/api.d.ts +31 -0
- package/dist/api.js +30 -0
- package/dist/bin/agent-sandbox.d.ts +2 -0
- package/dist/bin/agent-sandbox.js +3 -0
- package/dist/bin/claude-host.d.ts +2 -0
- package/dist/bin/claude-host.js +5 -0
- package/dist/bin/claude-sbx.d.ts +2 -0
- package/dist/bin/claude-sbx.js +3 -0
- package/dist/bin/codex-sbx.d.ts +2 -0
- package/dist/bin/codex-sbx.js +3 -0
- package/dist/bin/gemini-sbx.d.ts +2 -0
- package/dist/bin/gemini-sbx.js +3 -0
- package/dist/bin/sandbox.d.ts +2 -0
- package/dist/bin/sandbox.js +4 -0
- package/dist/bin/sbx-hide.d.ts +2 -0
- package/dist/bin/sbx-hide.js +3 -0
- package/dist/bin/sbx-show.d.ts +2 -0
- package/dist/bin/sbx-show.js +3 -0
- package/dist/cli/args.d.ts +32 -0
- package/dist/cli/args.js +111 -0
- package/dist/cli/commands/accounts.d.ts +2 -0
- package/dist/cli/commands/accounts.js +52 -0
- package/dist/cli/commands/context.d.ts +21 -0
- package/dist/cli/commands/context.js +54 -0
- package/dist/cli/commands/data.d.ts +46 -0
- package/dist/cli/commands/data.js +249 -0
- package/dist/cli/commands/index.d.ts +8 -0
- package/dist/cli/commands/index.js +8 -0
- package/dist/cli/commands/launch.d.ts +5 -0
- package/dist/cli/commands/launch.js +152 -0
- package/dist/cli/commands/maintenance.d.ts +8 -0
- package/dist/cli/commands/maintenance.js +125 -0
- package/dist/cli/commands/repair.d.ts +59 -0
- package/dist/cli/commands/repair.js +332 -0
- package/dist/cli/commands/sessions.d.ts +11 -0
- package/dist/cli/commands/sessions.js +113 -0
- package/dist/cli/commands/setup.d.ts +45 -0
- package/dist/cli/commands/setup.js +152 -0
- package/dist/cli/index.d.ts +14 -0
- package/dist/cli/index.js +96 -0
- package/dist/cli/manifest.d.ts +95 -0
- package/dist/cli/manifest.js +87 -0
- package/dist/cli/menu.d.ts +22 -0
- package/dist/cli/menu.js +187 -0
- package/dist/cli/settings.d.ts +11 -0
- package/dist/cli/settings.js +156 -0
- package/dist/core/config.d.ts +17 -0
- package/dist/core/config.js +103 -0
- package/dist/core/detect-databases.d.ts +15 -0
- package/dist/core/detect-databases.js +147 -0
- package/dist/core/detect-pwsh.d.ts +18 -0
- package/dist/core/detect-pwsh.js +111 -0
- package/dist/core/detect-toolchains.d.ts +30 -0
- package/dist/core/detect-toolchains.js +58 -0
- package/dist/core/local-config.d.ts +12 -0
- package/dist/core/local-config.js +64 -0
- package/dist/core/schema.d.ts +604 -0
- package/dist/core/schema.js +264 -0
- package/dist/core/types.d.ts +162 -0
- package/dist/core/types.js +1 -0
- package/dist/core/util.d.ts +53 -0
- package/dist/core/util.js +145 -0
- package/dist/core/workspace.d.ts +23 -0
- package/dist/core/workspace.js +162 -0
- package/dist/guest/daemon.d.ts +1 -0
- package/dist/guest/daemon.js +186 -0
- package/dist/guest/db.d.ts +1 -0
- package/dist/guest/db.js +60 -0
- package/dist/guest/env.d.ts +49 -0
- package/dist/guest/env.js +99 -0
- package/dist/guest/gitguard.d.ts +7 -0
- package/dist/guest/gitguard.js +60 -0
- package/dist/guest/gitrules.d.ts +2 -0
- package/dist/guest/gitrules.js +11 -0
- package/dist/guest/guide.d.ts +12 -0
- package/dist/guest/guide.js +68 -0
- package/dist/guest/main.d.ts +1 -0
- package/dist/guest/main.js +120 -0
- package/dist/guest/ptyhost.d.ts +40 -0
- package/dist/guest/ptyhost.js +94 -0
- package/dist/guest/setup/agents.d.ts +8 -0
- package/dist/guest/setup/agents.js +73 -0
- package/dist/guest/setup/claude.d.ts +21 -0
- package/dist/guest/setup/claude.js +138 -0
- package/dist/guest/setup/environment.d.ts +6 -0
- package/dist/guest/setup/environment.js +65 -0
- package/dist/guest/setup/hooks.d.ts +1 -0
- package/dist/guest/setup/hooks.js +36 -0
- package/dist/guest/setup/network.d.ts +10 -0
- package/dist/guest/setup/network.js +99 -0
- package/dist/guest/setup/policy.d.ts +3 -0
- package/dist/guest/setup/policy.js +18 -0
- package/dist/guest/setup/user.d.ts +7 -0
- package/dist/guest/setup/user.js +47 -0
- package/dist/guest/toolchains/binstall.d.ts +7 -0
- package/dist/guest/toolchains/binstall.js +55 -0
- package/dist/guest/toolchains/catalogue.d.ts +51 -0
- package/dist/guest/toolchains/catalogue.js +31 -0
- package/dist/guest/toolchains/cli.d.ts +10 -0
- package/dist/guest/toolchains/cli.js +64 -0
- package/dist/guest/toolchains/common.d.ts +37 -0
- package/dist/guest/toolchains/common.js +124 -0
- package/dist/guest/toolchains/databases.d.ts +7 -0
- package/dist/guest/toolchains/databases.js +226 -0
- package/dist/guest/toolchains/git.d.ts +3 -0
- package/dist/guest/toolchains/git.js +112 -0
- package/dist/guest/toolchains/go.d.ts +3 -0
- package/dist/guest/toolchains/go.js +33 -0
- package/dist/guest/toolchains/index.d.ts +13 -0
- package/dist/guest/toolchains/index.js +122 -0
- package/dist/guest/toolchains/java.d.ts +8 -0
- package/dist/guest/toolchains/java.js +96 -0
- package/dist/guest/toolchains/managers.d.ts +16 -0
- package/dist/guest/toolchains/managers.js +82 -0
- package/dist/guest/toolchains/msvc.d.ts +10 -0
- package/dist/guest/toolchains/msvc.js +216 -0
- package/dist/guest/toolchains/native.d.ts +12 -0
- package/dist/guest/toolchains/native.js +220 -0
- package/dist/guest/toolchains/node.d.ts +3 -0
- package/dist/guest/toolchains/node.js +132 -0
- package/dist/guest/toolchains/pwsh.d.ts +4 -0
- package/dist/guest/toolchains/pwsh.js +14 -0
- package/dist/guest/toolchains/python.d.ts +3 -0
- package/dist/guest/toolchains/python.js +47 -0
- package/dist/guest/toolchains/runtimes.d.ts +17 -0
- package/dist/guest/toolchains/runtimes.js +40 -0
- package/dist/guest/toolchains/scripting.d.ts +10 -0
- package/dist/guest/toolchains/scripting.js +137 -0
- package/dist/guest/toolchains/versions.d.ts +47 -0
- package/dist/guest/toolchains/versions.js +77 -0
- package/dist/guest/toolchains/vscode.d.ts +2 -0
- package/dist/guest/toolchains/vscode.js +53 -0
- package/dist/guest/view.d.ts +1 -0
- package/dist/guest/view.js +22 -0
- package/dist/host/accounts.d.ts +32 -0
- package/dist/host/accounts.js +119 -0
- package/dist/host/channel.d.ts +37 -0
- package/dist/host/channel.js +180 -0
- package/dist/host/claude-profile.d.ts +15 -0
- package/dist/host/claude-profile.js +103 -0
- package/dist/host/cleanup.d.ts +23 -0
- package/dist/host/cleanup.js +66 -0
- package/dist/host/generate.d.ts +15 -0
- package/dist/host/generate.js +185 -0
- package/dist/host/personalize.d.ts +10 -0
- package/dist/host/personalize.js +97 -0
- package/dist/host/platform.d.ts +17 -0
- package/dist/host/platform.js +114 -0
- package/dist/host/project-scan.d.ts +23 -0
- package/dist/host/project-scan.js +173 -0
- package/dist/host/repo-guard.d.ts +23 -0
- package/dist/host/repo-guard.js +128 -0
- package/dist/host/secrets.d.ts +14 -0
- package/dist/host/secrets.js +133 -0
- package/dist/host/terminal.d.ts +32 -0
- package/dist/host/terminal.js +127 -0
- package/dist/host/window.d.ts +4 -0
- package/dist/host/window.js +68 -0
- package/dist/runtime/daemon.js +20291 -0
- package/dist/runtime/db.js +20423 -0
- package/dist/runtime/gitguard.js +84 -0
- package/dist/runtime/main.js +22398 -0
- package/dist/runtime/view.js +19966 -0
- package/dist/selftest/cli.test.d.ts +2 -0
- package/dist/selftest/cli.test.js +30 -0
- package/dist/selftest/config.test.d.ts +3 -0
- package/dist/selftest/config.test.js +74 -0
- package/dist/selftest/data.test.d.ts +2 -0
- package/dist/selftest/data.test.js +73 -0
- package/dist/selftest/detect.test.d.ts +2 -0
- package/dist/selftest/detect.test.js +42 -0
- package/dist/selftest/fixtures.d.ts +232 -0
- package/dist/selftest/fixtures.js +63 -0
- package/dist/selftest/guest.test.d.ts +3 -0
- package/dist/selftest/guest.test.js +61 -0
- package/dist/selftest/harness.d.ts +14 -0
- package/dist/selftest/harness.js +36 -0
- package/dist/selftest/helpers.test.d.ts +2 -0
- package/dist/selftest/helpers.test.js +125 -0
- package/dist/selftest/host.test.d.ts +2 -0
- package/dist/selftest/host.test.js +71 -0
- package/dist/selftest/index.d.ts +1 -0
- package/dist/selftest/index.js +56 -0
- package/dist/selftest/live.test.d.ts +7 -0
- package/dist/selftest/live.test.js +127 -0
- package/dist/selftest/mirror.test.d.ts +3 -0
- package/dist/selftest/mirror.test.js +74 -0
- package/dist/selftest/security.test.d.ts +3 -0
- package/dist/selftest/security.test.js +143 -0
- package/dist/selftest/setup.test.d.ts +2 -0
- package/dist/selftest/setup.test.js +23 -0
- package/dist/selftest/sync.test.d.ts +3 -0
- package/dist/selftest/sync.test.js +82 -0
- package/dist/shared/download.d.ts +58 -0
- package/dist/shared/download.js +200 -0
- package/dist/shared/guest-paths.d.ts +30 -0
- package/dist/shared/guest-paths.js +61 -0
- package/dist/shared/netrules.d.ts +18 -0
- package/dist/shared/netrules.js +98 -0
- package/dist/shared/paths.d.ts +9 -0
- package/dist/shared/paths.js +22 -0
- package/dist/shared/pty-client.d.ts +86 -0
- package/dist/shared/pty-client.js +227 -0
- package/dist/shared/rundir.d.ts +30 -0
- package/dist/shared/rundir.js +86 -0
- package/dist/shared/safe-remove.d.ts +10 -0
- package/dist/shared/safe-remove.js +55 -0
- package/dist/shared/schema.d.ts +84 -0
- package/dist/shared/schema.js +51 -0
- package/dist/shared/system.d.ts +15 -0
- package/dist/shared/system.js +19 -0
- package/dist/sync/claude-mem.d.ts +8 -0
- package/dist/sync/claude-mem.js +77 -0
- package/dist/sync/history.d.ts +30 -0
- package/dist/sync/history.js +129 -0
- package/dist/sync/watcher.d.ts +13 -0
- package/dist/sync/watcher.js +116 -0
- package/hooks/post-agent/README.txt +1 -0
- package/hooks/pre-provision/README.txt +1 -0
- package/package.json +91 -4
- package/sandbox/bootstrap.ps1 +258 -0
- package/sandbox/gitshim.cs +35 -0
- package/sandbox/logon.cmd +16 -0
- package/sandbox/ptyhost.cs +603 -0
- package/sandbox/trust.cmd +35 -0
- package/sandbox/vswhere.cs +83 -0
- package/schemas/config.schema.json +1062 -0
- package/schemas/sandbox-config.schema.json +952 -0
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
// `sandbox repair`: finds and fixes common problems. Each check is a small function in CHECKS; it either confirms
|
|
2
|
+
// all is well (OK), fixes the problem itself (FIXED), or says what you need to do (NEEDS YOU). Nothing under the data
|
|
3
|
+
// folder is followed through a link, and toolchain/run-state fixes wait until no sandbox is running.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { spawnSync } from 'node:child_process';
|
|
7
|
+
import _ from 'lodash';
|
|
8
|
+
import { z } from 'zod';
|
|
9
|
+
import { DEFAULTS, loadConfig, userDir } from '../../core/config.js';
|
|
10
|
+
import { assertNoLinks, dirExists, errMsg, expandPath, fileExists, isLink, isWin, removeLink, UserError } from '../../core/util.js';
|
|
11
|
+
import { resetRunDir } from '../../host/channel.js';
|
|
12
|
+
import { isSandboxRunning, sandboxFeatureInstalled } from '../../host/platform.js';
|
|
13
|
+
import { listSessions, runPaths, scanRequests } from '../../shared/rundir.js';
|
|
14
|
+
import { createContext, printJson, table } from './context.js';
|
|
15
|
+
import { humanBytes, removeTree } from './data.js';
|
|
16
|
+
const GB = 1024 ** 3;
|
|
17
|
+
const LABEL = { ok: 'OK', fixed: 'FIXED', 'needs-you': 'NEEDS YOU', warn: 'WARNING', skipped: 'SKIPPED' };
|
|
18
|
+
const ok = (detail) => ({ status: 'ok', detail });
|
|
19
|
+
const whileRunning = { status: 'skipped', detail: 'a sandbox is running and may be using it; run repair again once it is closed' };
|
|
20
|
+
const rel = (root, p) => path.relative(root, p) || '.';
|
|
21
|
+
// ---- pure helpers (exported for tests) ----
|
|
22
|
+
/** Interrupted downloads and copies: `<name>.staging-<hex>` and `node-staging`, in tools\ and one folder down (tools\go\1.23.staging-ab12). */
|
|
23
|
+
export const findStagingLeftovers = (toolsDir) => {
|
|
24
|
+
const isStaging = (name) => /\.staging-[0-9a-f]+$/i.test(name) || name.toLowerCase() === 'node-staging';
|
|
25
|
+
const realChildren = (dir) => (dirExists(dir) && !isLink(dir) ? fs.readdirSync(dir).map((n) => path.join(dir, n)) : []);
|
|
26
|
+
return realChildren(toolsDir).flatMap((p) => (isStaging(path.basename(p)) ? [p] : isLink(p) ? [] : realChildren(p).filter((c) => isStaging(path.basename(c)))));
|
|
27
|
+
};
|
|
28
|
+
export const TOOL_RULES = [
|
|
29
|
+
{ folder: 'nvm', exe: ['nvm.exe'] },
|
|
30
|
+
{ folder: 'nvm', exe: ['node.exe'], versions: true, match: /^v\d+\.\d+\.\d+$/ },
|
|
31
|
+
{ folder: 'node', exe: ['node.exe'] },
|
|
32
|
+
{ folder: 'git', exe: ['bin', 'bash.exe'] },
|
|
33
|
+
{ folder: 'dotnet', exe: ['dotnet.exe'] },
|
|
34
|
+
{ folder: 'go', exe: ['bin', 'go.exe'], versions: true },
|
|
35
|
+
{ folder: 'java', exe: ['bin', 'java.exe'], versions: true },
|
|
36
|
+
{ folder: 'python', exe: ['python.exe'], versions: true },
|
|
37
|
+
];
|
|
38
|
+
/** Toolchain folders that exist but lack their main program (an install that stopped half way). Never enters links. */
|
|
39
|
+
export const findHalfInstalled = (toolsDir, rules = TOOL_RULES) => {
|
|
40
|
+
const realDir = (p) => dirExists(p) && !isLink(p);
|
|
41
|
+
const staging = (name) => /\.staging-[0-9a-f]+$/i.test(name) || name.toLowerCase() === 'node-staging';
|
|
42
|
+
return rules.flatMap((rule) => {
|
|
43
|
+
const base = path.join(toolsDir, rule.folder);
|
|
44
|
+
if (!realDir(base))
|
|
45
|
+
return [];
|
|
46
|
+
const folders = rule.versions
|
|
47
|
+
? fs.readdirSync(base).filter((n) => !staging(n) && (!rule.match || rule.match.test(n))).map((n) => path.join(base, n)).filter(realDir)
|
|
48
|
+
: [base];
|
|
49
|
+
return folders.filter((f) => !fileExists(path.join(f, ...rule.exe))).map((f) => ({ path: f, missing: path.join(...rule.exe) }));
|
|
50
|
+
});
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* Every link or junction under `root` (the links themselves; nothing behind them is visited). Before descending into
|
|
54
|
+
* a folder its real path must still be inside root: a folder swapped for a junction between two looks is not followed.
|
|
55
|
+
*/
|
|
56
|
+
export const findLinks = (root) => {
|
|
57
|
+
if (isLink(root))
|
|
58
|
+
return [root];
|
|
59
|
+
const real = (p) => { try {
|
|
60
|
+
return fs.realpathSync.native(p).toLowerCase();
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return '';
|
|
64
|
+
} };
|
|
65
|
+
const top = real(root);
|
|
66
|
+
const inside = (p) => { const r = real(p); return r !== '' && (r === top || r.startsWith(top + path.sep)); };
|
|
67
|
+
const walk = (dir) => {
|
|
68
|
+
if (!inside(dir))
|
|
69
|
+
return [];
|
|
70
|
+
let entries;
|
|
71
|
+
try {
|
|
72
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return [];
|
|
76
|
+
}
|
|
77
|
+
return entries.flatMap((d) => {
|
|
78
|
+
const p = path.join(dir, d.name);
|
|
79
|
+
if (d.isSymbolicLink() || isLink(p))
|
|
80
|
+
return [p];
|
|
81
|
+
return d.isDirectory() ? walk(p) : [];
|
|
82
|
+
});
|
|
83
|
+
};
|
|
84
|
+
return dirExists(root) ? walk(root) : [];
|
|
85
|
+
};
|
|
86
|
+
const ResolvedVersions = z.record(z.string(), z.unknown());
|
|
87
|
+
/** tools\resolved-versions.json is a JSON object (anything else makes the guest stumble). */
|
|
88
|
+
export const resolvedVersionsValid = (text) => {
|
|
89
|
+
try {
|
|
90
|
+
return ResolvedVersions.safeParse(JSON.parse(text.replace(/^/, ''))).success;
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
const NpmList = z.object({ dependencies: z.record(z.string(), z.unknown()).optional() });
|
|
97
|
+
/** Package names from `npm ls -g --json`; [] when the output is not what npm prints. */
|
|
98
|
+
export const globalPackages = (json) => {
|
|
99
|
+
try {
|
|
100
|
+
return Object.keys(NpmList.parse(JSON.parse(json)).dependencies ?? {});
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return [];
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
const Pid = z.coerce.number().int().positive();
|
|
107
|
+
export const pidAlive = (pid) => { try {
|
|
108
|
+
process.kill(pid, 0);
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
catch (e) {
|
|
112
|
+
return e.code === 'EPERM';
|
|
113
|
+
} };
|
|
114
|
+
/** Sync-watcher pid files (storage\run\watcher-*.pid) whose process is gone (or whose content is not a pid). */
|
|
115
|
+
export const deadPidFiles = (runDir, alive = pidAlive) => (dirExists(runDir) ? fs.readdirSync(runDir) : [])
|
|
116
|
+
.filter((n) => /^watcher-.*\.pid$/i.test(n))
|
|
117
|
+
.map((n) => path.join(runDir, n))
|
|
118
|
+
.filter((f) => { try {
|
|
119
|
+
const pid = Pid.safeParse(fs.readFileSync(f, 'utf8').trim());
|
|
120
|
+
return !pid.success || !alive(pid.data);
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return false;
|
|
124
|
+
} });
|
|
125
|
+
// ---- checks ----
|
|
126
|
+
/** Fails (throws) unless every folder from the data folder to `p` is real. */
|
|
127
|
+
const guard = (env, p) => assertNoLinks(path.dirname(env.dataPath), p);
|
|
128
|
+
const CHECKS = [
|
|
129
|
+
{
|
|
130
|
+
name: 'Config file',
|
|
131
|
+
run: (env) => (env.configError
|
|
132
|
+
? { status: 'needs-you', detail: env.configError.message + (env.configError.fix ? ' ' + env.configError.fix : '') + ' (repair never rewrites your config; built-in defaults were used for the other checks.)' }
|
|
133
|
+
: ok(env.ctx.loaded.configPath ? env.ctx.loaded.configPath + ' is valid.' : 'none; using the built-in defaults.')),
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
name: 'Windows Sandbox feature',
|
|
137
|
+
run: () => (sandboxFeatureInstalled() ? ok('enabled.') : { status: 'needs-you', detail: (isWin ? 'Windows Sandbox is not turned on.' : 'This is not Windows.') + ' Run: sandbox setup (it checks this PC and offers to turn it on).' }),
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
name: 'Links in the data folder',
|
|
141
|
+
run: (env) => {
|
|
142
|
+
if (isLink(env.dataPath))
|
|
143
|
+
return { status: 'needs-you', detail: env.dataPath + ' itself is a link or junction. agent-sandbox refuses to work through links; point storage.root at the real folder instead (sandbox config path).' };
|
|
144
|
+
// A running sandbox can swap folders for links while we look; only check a stopped one.
|
|
145
|
+
if (env.running)
|
|
146
|
+
return whileRunning;
|
|
147
|
+
const links = findLinks(env.dataPath);
|
|
148
|
+
if (!links.length)
|
|
149
|
+
return ok('none (nothing in ' + env.dataPath + ' points elsewhere).');
|
|
150
|
+
// A link here can only have been made inside the sandbox; it could point at this PC's own files. Remove the link
|
|
151
|
+
// itself, and only if every folder above it is still real.
|
|
152
|
+
for (const link of links) {
|
|
153
|
+
guard(env, path.dirname(link));
|
|
154
|
+
removeLink(link);
|
|
155
|
+
}
|
|
156
|
+
return { status: 'fixed', detail: 'removed ' + links.length + ' link(s)/junction(s) the sandbox created, without following them: ' + links.map((l) => rel(env.dataPath, l)).join(', ') };
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
name: 'Sandbox run state',
|
|
161
|
+
run: (env) => {
|
|
162
|
+
if (env.running)
|
|
163
|
+
return ok('a sandbox is running; its state is in use.');
|
|
164
|
+
if (isLink(runPaths(env.dataPath).run)) {
|
|
165
|
+
resetRunDir(env.dataPath);
|
|
166
|
+
return { status: 'fixed', detail: 'the run folder was a link; removed the link itself.' };
|
|
167
|
+
}
|
|
168
|
+
// The last sandbox's status file is normal after it closes; sessions still marked running or unanswered requests are not.
|
|
169
|
+
const ghosts = listSessions(env.dataPath).filter((s) => s.phase === 'running' || s.phase === 'starting').length;
|
|
170
|
+
const requests = scanRequests(env.dataPath);
|
|
171
|
+
const pending = requests.valid.length + requests.invalid.length;
|
|
172
|
+
if (!ghosts && !pending)
|
|
173
|
+
return ok('clean.');
|
|
174
|
+
resetRunDir(env.dataPath);
|
|
175
|
+
return { status: 'fixed', detail: 'no sandbox is running, but ' + _.compact([ghosts && ghosts + ' session(s) were still marked running', pending && pending + ' request(s) were never answered']).join(' and ') + '; cleared the run state so status/attach do not show ghosts.' };
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
name: 'Hidden-window state',
|
|
180
|
+
run: (env) => {
|
|
181
|
+
const file = path.join(env.storageRoot, 'run', 'hidden-windows.json');
|
|
182
|
+
if (!fs.existsSync(file) && !isLink(file))
|
|
183
|
+
return ok('clean.');
|
|
184
|
+
if (env.running)
|
|
185
|
+
return ok('a sandbox is running; kept.');
|
|
186
|
+
fs.rmSync(file, { force: true });
|
|
187
|
+
return { status: 'fixed', detail: 'removed the saved hidden-window list from a sandbox that is no longer running.' };
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
name: 'Sync watcher',
|
|
192
|
+
run: (env) => {
|
|
193
|
+
const dead = deadPidFiles(path.join(env.storageRoot, 'run'));
|
|
194
|
+
if (!dead.length)
|
|
195
|
+
return ok('no stale watcher records.');
|
|
196
|
+
for (const f of dead)
|
|
197
|
+
fs.rmSync(f, { force: true });
|
|
198
|
+
return { status: 'fixed', detail: 'removed ' + dead.length + ' record(s) of a history sync watcher that is no longer running (the next launch starts a new one).' };
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
name: 'Interrupted downloads',
|
|
203
|
+
run: (env) => {
|
|
204
|
+
if (env.running)
|
|
205
|
+
return whileRunning;
|
|
206
|
+
guard(env, env.toolsDir);
|
|
207
|
+
const found = findStagingLeftovers(env.toolsDir);
|
|
208
|
+
if (!found.length)
|
|
209
|
+
return ok('none.');
|
|
210
|
+
for (const p of found)
|
|
211
|
+
removeTree(p, env.dataPath);
|
|
212
|
+
return { status: 'fixed', detail: 'removed ' + found.length + ' half-finished download/copy folder(s): ' + found.map((p) => rel(env.toolsDir, p)).join(', ') };
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
name: 'Half-installed toolchains',
|
|
217
|
+
run: (env) => {
|
|
218
|
+
if (env.running)
|
|
219
|
+
return whileRunning;
|
|
220
|
+
guard(env, env.toolsDir);
|
|
221
|
+
const found = findHalfInstalled(env.toolsDir);
|
|
222
|
+
if (!found.length)
|
|
223
|
+
return ok('every toolchain folder has its program.');
|
|
224
|
+
for (const f of found)
|
|
225
|
+
removeTree(f.path, env.dataPath);
|
|
226
|
+
return { status: 'fixed', detail: 'removed ' + found.map((f) => rel(env.toolsDir, f.path) + ' (no ' + f.missing + ')').join(', ') + '; the next sandbox reinstalls them.' };
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
name: 'Resolved versions cache',
|
|
231
|
+
run: (env) => {
|
|
232
|
+
const file = path.join(env.toolsDir, 'resolved-versions.json');
|
|
233
|
+
if (!fs.existsSync(file) && !isLink(file))
|
|
234
|
+
return ok('not created yet.');
|
|
235
|
+
if (env.running)
|
|
236
|
+
return whileRunning;
|
|
237
|
+
guard(env, env.toolsDir);
|
|
238
|
+
if (isLink(file)) {
|
|
239
|
+
removeLink(file);
|
|
240
|
+
return { status: 'fixed', detail: 'removed a link in place of tools\\resolved-versions.json.' };
|
|
241
|
+
}
|
|
242
|
+
if (fs.statSync(file).size < 4 * 1024 * 1024 && resolvedVersionsValid(fs.readFileSync(file, 'utf8')))
|
|
243
|
+
return ok('valid.');
|
|
244
|
+
fs.rmSync(file, { force: true });
|
|
245
|
+
return { status: 'fixed', detail: 'tools\\resolved-versions.json was damaged; removed it (versions are looked up again on the next start).' };
|
|
246
|
+
},
|
|
247
|
+
},
|
|
248
|
+
{
|
|
249
|
+
name: 'Installed packages',
|
|
250
|
+
run: () => {
|
|
251
|
+
const npmCli = path.join(path.dirname(process.execPath), 'node_modules', 'npm', 'bin', 'npm-cli.js');
|
|
252
|
+
if (!fileExists(npmCli))
|
|
253
|
+
return { status: 'skipped', detail: 'npm not found next to this Node (' + npmCli + ').' };
|
|
254
|
+
// cwd = Node's own folder: npm reads .npmrc from the current folder, which could be a sandbox-writable workspace.
|
|
255
|
+
const result = spawnSync(process.execPath, [npmCli, 'ls', '-g', '--json', '--depth=0'], { cwd: path.dirname(process.execPath), encoding: 'utf8', windowsHide: true, timeout: 60000, maxBuffer: 16 * 1024 * 1024 });
|
|
256
|
+
const names = globalPackages(result.stdout ?? '');
|
|
257
|
+
if (!names.length)
|
|
258
|
+
return { status: 'skipped', detail: 'could not read the global npm packages.' };
|
|
259
|
+
if (names.includes('agent-sandbox') && names.includes('windows-development-sandbox'))
|
|
260
|
+
return { status: 'needs-you', detail: 'the old agent-sandbox package is still installed next to windows-development-sandbox, and their commands clash. Run: npm uninstall -g agent-sandbox' };
|
|
261
|
+
return ok(names.includes('agent-sandbox') ? 'agent-sandbox installed (the old name; fine on its own).' : 'no old agent-sandbox package.');
|
|
262
|
+
},
|
|
263
|
+
},
|
|
264
|
+
{
|
|
265
|
+
name: 'Free disk',
|
|
266
|
+
run: (env) => {
|
|
267
|
+
let at = path.resolve(env.storageRoot);
|
|
268
|
+
while (!fs.existsSync(at) && path.dirname(at) !== at)
|
|
269
|
+
at = path.dirname(at);
|
|
270
|
+
let free;
|
|
271
|
+
try {
|
|
272
|
+
const s = fs.statfsSync(at);
|
|
273
|
+
free = s.bavail * s.bsize;
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
return { status: 'skipped', detail: 'could not check the drive of ' + env.storageRoot };
|
|
277
|
+
}
|
|
278
|
+
if (free >= 20 * GB)
|
|
279
|
+
return ok(humanBytes(free) + ' free.');
|
|
280
|
+
return { status: 'warn', detail: humanBytes(free) + ' free on the drive of ' + env.storageRoot + (free < 5 * GB ? ': toolchains need several GB.' : ': getting low.') + ' See what is stored with: sandbox data, and free space with: sandbox data clean <target>' };
|
|
281
|
+
},
|
|
282
|
+
},
|
|
283
|
+
];
|
|
284
|
+
/** Runs every check in order; a check that throws is reported, never stops the rest. */
|
|
285
|
+
export const runChecks = (env, checks = CHECKS) => checks.map((check) => {
|
|
286
|
+
try {
|
|
287
|
+
return { name: check.name, ...check.run(env) };
|
|
288
|
+
}
|
|
289
|
+
catch (e) {
|
|
290
|
+
return { name: check.name, status: 'needs-you', detail: 'check failed: ' + errMsg(e) + (e instanceof UserError && e.fix ? ' ' + e.fix : '') };
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
export const repair = (ctx, configError = null) => {
|
|
294
|
+
const env = { ctx, running: isSandboxRunning(), dataPath: ctx.dataPath, storageRoot: ctx.storageRoot, toolsDir: path.join(ctx.dataPath, 'tools'), configError };
|
|
295
|
+
// Re-check the config file even when the context loaded (it may have been edited since, or come from --config).
|
|
296
|
+
if (!env.configError) {
|
|
297
|
+
try {
|
|
298
|
+
loadConfig(ctx.options.config);
|
|
299
|
+
}
|
|
300
|
+
catch (e) {
|
|
301
|
+
if (e instanceof UserError)
|
|
302
|
+
env.configError = e;
|
|
303
|
+
else
|
|
304
|
+
throw e;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
const results = runChecks(env);
|
|
308
|
+
if (printJson(ctx, results))
|
|
309
|
+
return results.some((r) => r.status === 'needs-you') ? 1 : 0;
|
|
310
|
+
table(results.map((r) => [LABEL[r.status], r.name, r.detail]));
|
|
311
|
+
const counts = _.countBy(results, 'status');
|
|
312
|
+
console.log('\n' + (counts['needs-you'] ? counts['needs-you'] + ' thing(s) need you (see NEEDS YOU above).' : 'All good' + (counts.fixed ? ': fixed ' + counts.fixed + ' problem(s).' : '.')));
|
|
313
|
+
return counts['needs-you'] ? 1 : 0;
|
|
314
|
+
};
|
|
315
|
+
/**
|
|
316
|
+
* Entry point that works even when the config file is broken (createContext would throw): falls back to the built-in
|
|
317
|
+
* defaults so the other checks still run, and reports the config problem as the first check.
|
|
318
|
+
*/
|
|
319
|
+
export const repairCommand = (packageRoot, entry, options) => {
|
|
320
|
+
let ctx;
|
|
321
|
+
try {
|
|
322
|
+
ctx = createContext(packageRoot, entry, options);
|
|
323
|
+
}
|
|
324
|
+
catch (e) {
|
|
325
|
+
if (!(e instanceof UserError))
|
|
326
|
+
throw e;
|
|
327
|
+
const loaded = { config: structuredClone(DEFAULTS), configPath: null, base: userDir() };
|
|
328
|
+
const storageRoot = expandPath(loaded.config.storage.root, loaded.base);
|
|
329
|
+
return repair({ packageRoot, entry, options, loaded, storageRoot, dataPath: path.join(storageRoot, 'data', loaded.config.storage.dataName) }, e);
|
|
330
|
+
}
|
|
331
|
+
return repair(ctx);
|
|
332
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type Context } from './context.js';
|
|
2
|
+
export declare const attach: (ctx: Context) => Promise<number>;
|
|
3
|
+
export declare const sessions: (ctx: Context) => Promise<number>;
|
|
4
|
+
export declare const status: (ctx: Context) => Promise<number>;
|
|
5
|
+
export declare const exec: (ctx: Context) => Promise<number>;
|
|
6
|
+
/** `sandbox db`: runs the sandbox's own `sbx-db`, which knows this project's database versions and ports. */
|
|
7
|
+
export declare const db: (ctx: Context) => Promise<number>;
|
|
8
|
+
export declare const restart: (ctx: Context) => Promise<number>;
|
|
9
|
+
export declare const stop: (ctx: Context) => Promise<number>;
|
|
10
|
+
export declare const show: (ctx: Context) => number;
|
|
11
|
+
export declare const hide: (ctx: Context) => Promise<number>;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// Working with a running sandbox: attach, sessions, status, exec, restart, stop, show/hide.
|
|
2
|
+
import _ from 'lodash';
|
|
3
|
+
import { activeAccount } from '../../host/accounts.js';
|
|
4
|
+
import { execInSandbox, pickSession, pingSandbox, restartSessions, currentSessions, runningSessions, sessionViewers, waitForReady } from '../../host/channel.js';
|
|
5
|
+
import { isSandboxRunning } from '../../host/platform.js';
|
|
6
|
+
import { repoFindings } from '../../host/repo-guard.js';
|
|
7
|
+
import { hideSandboxWindow, showSandboxWindow } from '../../host/window.js';
|
|
8
|
+
import { listSessions, readSandboxStatus } from '../../shared/rundir.js';
|
|
9
|
+
import { watcherRunning } from '../../sync/watcher.js';
|
|
10
|
+
import { errMsg, fail, safeText, sleep } from '../../core/util.js';
|
|
11
|
+
import { currentGuestDir, printJson, requireRunning, sandboxAlive, table } from './context.js';
|
|
12
|
+
import { attachHere } from './launch.js';
|
|
13
|
+
export const attach = async (ctx) => {
|
|
14
|
+
requireRunning('Start one with: sandbox claude --terminal');
|
|
15
|
+
await waitForReady(ctx.dataPath, null, sandboxAlive);
|
|
16
|
+
const id = ctx.options.positionals[0];
|
|
17
|
+
if (!id && runningSessions(ctx.dataPath).length === 0)
|
|
18
|
+
await sleep(1500);
|
|
19
|
+
return attachHere(ctx, pickSession(ctx.dataPath, id));
|
|
20
|
+
};
|
|
21
|
+
const viewerText = (v) => (v === undefined ? '-' : v === null ? 'unknown (older session)' : v.remote + ' here, ' + (v.viewers - v.remote) + ' in sandbox');
|
|
22
|
+
// harness, cwd and times are written by the sandbox: printed through safeText.
|
|
23
|
+
const sessionRow = (s) => [s.id, safeText(s.harness), s.phase + (s.exitCode !== undefined ? ' (' + s.exitCode + ')' : ''), viewerText(s.viewers), safeText(s.cwd), safeText(new Date(s.startedAt).toLocaleTimeString())];
|
|
24
|
+
export const sessions = async (ctx) => {
|
|
25
|
+
// Who is attached comes from each session itself, so a terminal that was closed is never counted.
|
|
26
|
+
const live = isSandboxRunning();
|
|
27
|
+
// Only this sandbox's sessions are asked (and at most MAX_QUERIED_SESSIONS): the sandbox writes these files.
|
|
28
|
+
const ask = new Set(live ? currentSessions(ctx.dataPath).map((s) => s.id) : []);
|
|
29
|
+
const list = await Promise.all(listSessions(ctx.dataPath).map(async (s) => (ask.has(s.id) ? { ...s, viewers: await sessionViewers(s) } : s)));
|
|
30
|
+
if (printJson(ctx, list.map((s) => _.omit(s, 'attach'))))
|
|
31
|
+
return 0;
|
|
32
|
+
if (list.length === 0)
|
|
33
|
+
console.log('No sessions.');
|
|
34
|
+
else
|
|
35
|
+
table([['ID', 'AGENT', 'STATE', 'ATTACHED', 'FOLDER', 'STARTED'], ...list.map(sessionRow)]);
|
|
36
|
+
return 0;
|
|
37
|
+
};
|
|
38
|
+
export const status = async (ctx) => {
|
|
39
|
+
const running = isSandboxRunning();
|
|
40
|
+
const sandbox = readSandboxStatus(ctx.dataPath);
|
|
41
|
+
const findings = repoFindings(ctx.dataPath);
|
|
42
|
+
const daemon = running && sandbox?.phase === 'ready' ? await pingSandbox(ctx.dataPath, sandboxAlive).then((r) => r.message, (e) => 'no answer (' + errMsg(e) + ')') : '-';
|
|
43
|
+
const info = {
|
|
44
|
+
running, phase: running ? sandbox?.phase ?? 'unknown' : 'stopped', message: sandbox?.message, workspace: sandbox?.workspaceHost, headless: sandbox?.headless,
|
|
45
|
+
daemon, account: activeAccount(ctx.dataPath), sessions: running ? runningSessions(ctx.dataPath).length : 0, syncWatcher: watcherRunning(ctx.dataPath),
|
|
46
|
+
repoGuard: findings.length ? findings.map((x) => x.kind + ': ' + x.detail).join('; ') : 'nothing from the sandbox needed neutralising',
|
|
47
|
+
};
|
|
48
|
+
if (!printJson(ctx, info))
|
|
49
|
+
table(Object.entries(info).map(([k, v]) => [_.startCase(k), safeText(String(v ?? '-'))]));
|
|
50
|
+
return 0;
|
|
51
|
+
};
|
|
52
|
+
export const exec = async (ctx) => {
|
|
53
|
+
const command = [...ctx.options.positionals, ...ctx.options.passthrough];
|
|
54
|
+
if (command.length === 0)
|
|
55
|
+
fail('No command given.', 'Usage: sandbox exec [--timeout <s>] -- <command> [args...]');
|
|
56
|
+
requireRunning();
|
|
57
|
+
const ready = await waitForReady(ctx.dataPath, null, sandboxAlive);
|
|
58
|
+
const result = await execInSandbox(ctx.dataPath, command, currentGuestDir(ctx, ready.workspaceHost, ready.guestWorkspace), ctx.options.timeout, sandboxAlive);
|
|
59
|
+
if (!printJson(ctx, result)) {
|
|
60
|
+
if (result.stdout)
|
|
61
|
+
process.stdout.write(safeText(result.stdout));
|
|
62
|
+
if (result.stderr)
|
|
63
|
+
process.stderr.write(safeText(result.stderr));
|
|
64
|
+
if (!result.ok && !result.stdout && !result.stderr)
|
|
65
|
+
console.error(result.message);
|
|
66
|
+
}
|
|
67
|
+
return result.exitCode ?? 1;
|
|
68
|
+
};
|
|
69
|
+
/** `sandbox db`: runs the sandbox's own `sbx-db`, which knows this project's database versions and ports. */
|
|
70
|
+
export const db = async (ctx) => {
|
|
71
|
+
const [action = 'list', name] = ctx.options.positionals;
|
|
72
|
+
if (action !== 'list' && !name)
|
|
73
|
+
fail('Which database?', 'Usage: sandbox db ' + action + ' <postgres|mysql|mongodb|redis|dynamodb>');
|
|
74
|
+
requireRunning('Start one with: sandbox start --database <name>');
|
|
75
|
+
const ready = await waitForReady(ctx.dataPath, null, sandboxAlive);
|
|
76
|
+
// Starting can download the database the first time.
|
|
77
|
+
const result = await execInSandbox(ctx.dataPath, ['sbx-db', action, ...(name ? [name] : [])], ready.guestWorkspace, 1800, sandboxAlive);
|
|
78
|
+
if (result.stdout)
|
|
79
|
+
process.stdout.write(safeText(result.stdout));
|
|
80
|
+
if (result.stderr)
|
|
81
|
+
process.stderr.write(safeText(result.stderr));
|
|
82
|
+
if (!result.ok && !result.stdout && !result.stderr)
|
|
83
|
+
console.error(result.message);
|
|
84
|
+
return result.exitCode ?? 1;
|
|
85
|
+
};
|
|
86
|
+
export const restart = async (ctx) => {
|
|
87
|
+
requireRunning();
|
|
88
|
+
console.log((await restartSessions(ctx.dataPath, ctx.options.harness ?? null, sandboxAlive)).message);
|
|
89
|
+
return 0;
|
|
90
|
+
};
|
|
91
|
+
export const stop = async (ctx) => {
|
|
92
|
+
if (!isSandboxRunning()) {
|
|
93
|
+
console.log('No Windows Sandbox is running.');
|
|
94
|
+
return 0;
|
|
95
|
+
}
|
|
96
|
+
const sandbox = readSandboxStatus(ctx.dataPath) ?? fail('This sandbox was not started by this tool.', 'Close its window instead.');
|
|
97
|
+
await execInSandbox(ctx.dataPath, ['C:\\Windows\\System32\\shutdown.exe', '/s', '/t', '2'], sandbox.guestWorkspace, 30, sandboxAlive);
|
|
98
|
+
console.log('Sandbox is shutting down; history and claude-mem sync back when it closes.');
|
|
99
|
+
return 0;
|
|
100
|
+
};
|
|
101
|
+
export const show = (ctx) => {
|
|
102
|
+
requireRunning();
|
|
103
|
+
if (!showSandboxWindow(ctx.storageRoot))
|
|
104
|
+
fail('Could not find the Windows Sandbox window.', 'It may still be starting; try again in a few seconds.');
|
|
105
|
+
return 0;
|
|
106
|
+
};
|
|
107
|
+
export const hide = async (ctx) => {
|
|
108
|
+
requireRunning();
|
|
109
|
+
if (!(await hideSandboxWindow(ctx.storageRoot, 5)))
|
|
110
|
+
fail('Could not find a visible Windows Sandbox window.', 'It may already be hidden (sbx-show brings it back).');
|
|
111
|
+
console.log('Sandbox window hidden. Bring it back with: sbx-show');
|
|
112
|
+
return 0;
|
|
113
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { type Context } from './context.js';
|
|
3
|
+
export type CheckStatus = 'ok' | 'warn' | 'action';
|
|
4
|
+
export interface Check {
|
|
5
|
+
name: string;
|
|
6
|
+
status: CheckStatus;
|
|
7
|
+
detail: string;
|
|
8
|
+
}
|
|
9
|
+
export type FeatureState = 'enabled' | 'pending' | 'disabled' | 'unknown';
|
|
10
|
+
export type EditionSupport = 'supported' | 'home' | 'unsupported' | 'unknown';
|
|
11
|
+
export interface MachineFacts {
|
|
12
|
+
edition: string | null;
|
|
13
|
+
build: number | null;
|
|
14
|
+
feature: FeatureState;
|
|
15
|
+
/** WindowsSandbox.exe exists (it appears only after the restart that follows enabling the feature). */
|
|
16
|
+
sandboxExe: boolean;
|
|
17
|
+
virtualizationFirmware: boolean | null;
|
|
18
|
+
hypervisorPresent: boolean | null;
|
|
19
|
+
totalRamBytes: number;
|
|
20
|
+
freeDiskBytes: number | null;
|
|
21
|
+
storageRoot: string;
|
|
22
|
+
nodeVersion: string;
|
|
23
|
+
}
|
|
24
|
+
export declare const ENABLE_COMMAND: string;
|
|
25
|
+
/** `reg query <key>` output to { valueName: data }. */
|
|
26
|
+
export declare const parseRegQuery: (text: string) => Record<string, string>;
|
|
27
|
+
/** Windows Sandbox needs Pro, Enterprise or Education (build 18305+); Home ("Core*") and Server cannot run it. */
|
|
28
|
+
export declare const editionSupport: (edition: string | null) => EditionSupport;
|
|
29
|
+
/** The "State : ..." line of `Dism /Get-FeatureInfo` (English output only; anything else is unknown). */
|
|
30
|
+
export declare const parseDismState: (text: string) => FeatureState;
|
|
31
|
+
/** Win32_OptionalFeature.InstallState: 1 enabled, 2 disabled, 3 absent. */
|
|
32
|
+
export declare const featureFromInstallState: (state: number | null | undefined) => FeatureState;
|
|
33
|
+
export declare const nodeMajor: (version: string) => number;
|
|
34
|
+
/** Offer to enable the feature only when it is off and this edition can have it. */
|
|
35
|
+
export declare const canEnableFeature: (f: MachineFacts) => boolean;
|
|
36
|
+
/** Turns what we learnt about the PC into the checklist (pure, so it can be tested). */
|
|
37
|
+
export declare const buildChecks: (f: MachineFacts) => Check[];
|
|
38
|
+
declare const Probe: z.ZodObject<{
|
|
39
|
+
virtualizationFirmware: z.ZodOptional<z.ZodNullable<z.ZodBoolean>>;
|
|
40
|
+
hypervisorPresent: z.ZodOptional<z.ZodNullable<z.ZodBoolean>>;
|
|
41
|
+
installState: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
|
|
42
|
+
}, z.core.$strip>;
|
|
43
|
+
export declare const parseProbe: (text: string) => z.infer<typeof Probe>;
|
|
44
|
+
export declare const setup: (ctx: Context) => Promise<number>;
|
|
45
|
+
export {};
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// `sandbox setup`: checks this PC can run Windows Sandbox and offers to turn the feature on.
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { spawnSync } from 'node:child_process';
|
|
6
|
+
import _ from 'lodash';
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
import { askYesNo } from '../../core/workspace.js';
|
|
9
|
+
import { fail, fileExists, isWin } from '../../core/util.js';
|
|
10
|
+
import { SYSTEM, system32 } from '../../shared/system.js';
|
|
11
|
+
import { table } from './context.js';
|
|
12
|
+
const FEATURE = 'Containers-DisposableClientVM';
|
|
13
|
+
export const ENABLE_COMMAND = 'Enable-WindowsOptionalFeature -Online -FeatureName ' + FEATURE + ' -All -NoRestart';
|
|
14
|
+
const GB = 1024 ** 3;
|
|
15
|
+
/** `reg query <key>` output to { valueName: data }. */
|
|
16
|
+
export const parseRegQuery = (text) => Object.fromEntries(_.compact(text.split(/\r?\n/).map((line) => /^\s+(\S+)\s+REG_\w+\s+(.*)$/.exec(line)?.slice(1, 3))));
|
|
17
|
+
/** Windows Sandbox needs Pro, Enterprise or Education (build 18305+); Home ("Core*") and Server cannot run it. */
|
|
18
|
+
export const editionSupport = (edition) => {
|
|
19
|
+
if (!edition)
|
|
20
|
+
return 'unknown';
|
|
21
|
+
if (/^Core/i.test(edition))
|
|
22
|
+
return 'home';
|
|
23
|
+
if (/^Server/i.test(edition))
|
|
24
|
+
return 'unsupported';
|
|
25
|
+
return /^(Professional|Enterprise|Education|IoTEnterprise)/i.test(edition) ? 'supported' : 'unknown';
|
|
26
|
+
};
|
|
27
|
+
/** The "State : ..." line of `Dism /Get-FeatureInfo` (English output only; anything else is unknown). */
|
|
28
|
+
export const parseDismState = (text) => {
|
|
29
|
+
const state = /^State\s*:\s*(.+?)\s*$/im.exec(text)?.[1].toLowerCase() ?? '';
|
|
30
|
+
if (state === 'enabled')
|
|
31
|
+
return 'enabled';
|
|
32
|
+
if (state.includes('pending'))
|
|
33
|
+
return 'pending';
|
|
34
|
+
return state.startsWith('disabled') ? 'disabled' : 'unknown';
|
|
35
|
+
};
|
|
36
|
+
/** Win32_OptionalFeature.InstallState: 1 enabled, 2 disabled, 3 absent. */
|
|
37
|
+
export const featureFromInstallState = (state) => state === 1 ? 'enabled' : state === 2 || state === 3 ? 'disabled' : 'unknown';
|
|
38
|
+
export const nodeMajor = (version) => Number(/^v?(\d+)/.exec(version.trim())?.[1] ?? 0);
|
|
39
|
+
/** Offer to enable the feature only when it is off and this edition can have it. */
|
|
40
|
+
export const canEnableFeature = (f) => f.feature !== 'enabled' && f.feature !== 'pending' && !f.sandboxExe && ['supported', 'unknown'].includes(editionSupport(f.edition));
|
|
41
|
+
const formatGb = (bytes) => (bytes / GB).toFixed(1) + ' GB';
|
|
42
|
+
/** Turns what we learnt about the PC into the checklist (pure, so it can be tested). */
|
|
43
|
+
export const buildChecks = (f) => {
|
|
44
|
+
const support = editionSupport(f.edition);
|
|
45
|
+
const edition = (f.edition ?? 'unknown edition') + (f.build ? ', build ' + f.build : '');
|
|
46
|
+
const virt = f.hypervisorPresent || f.virtualizationFirmware;
|
|
47
|
+
const featureReady = f.feature === 'enabled' && f.sandboxExe;
|
|
48
|
+
return [
|
|
49
|
+
support === 'supported' && (f.build ?? 18305) >= 18305 ? { name: 'Windows edition', status: 'ok', detail: edition }
|
|
50
|
+
: support === 'home' ? { name: 'Windows edition', status: 'action', detail: edition + ': Windows Home cannot run Windows Sandbox. Upgrade to Pro (Settings > System > Activation).' }
|
|
51
|
+
: support === 'unsupported' ? { name: 'Windows edition', status: 'action', detail: edition + ': Windows Sandbox needs Windows 10/11 Pro, Enterprise or Education.' }
|
|
52
|
+
: support === 'supported' ? { name: 'Windows edition', status: 'action', detail: edition + ': too old; Windows Sandbox needs Windows 10 version 1903 or newer.' }
|
|
53
|
+
: { name: 'Windows edition', status: 'warn', detail: edition + ': not sure this edition has Windows Sandbox (it needs Pro, Enterprise or Education).' },
|
|
54
|
+
virt ? { name: 'CPU virtualization', status: 'ok', detail: f.hypervisorPresent ? 'on (hypervisor running)' : 'on' }
|
|
55
|
+
: virt === false ? { name: 'CPU virtualization', status: 'action', detail: 'off. Turn on Intel VT-x / AMD-V (SVM) in your PC\'s BIOS/UEFI settings, then run this again.' }
|
|
56
|
+
: { name: 'CPU virtualization', status: 'warn', detail: 'could not tell; if Windows Sandbox will not start, turn on VT-x / AMD-V in the BIOS/UEFI.' },
|
|
57
|
+
featureReady ? { name: 'Windows Sandbox feature', status: 'ok', detail: 'enabled' }
|
|
58
|
+
: f.feature === 'pending' || (f.feature === 'enabled' && !f.sandboxExe) ? { name: 'Windows Sandbox feature', status: 'action', detail: 'enabled, but Windows needs a restart to finish.' }
|
|
59
|
+
: f.sandboxExe ? { name: 'Windows Sandbox feature', status: 'ok', detail: 'installed' }
|
|
60
|
+
: { name: 'Windows Sandbox feature', status: 'action', detail: 'not enabled. In an admin PowerShell: ' + ENABLE_COMMAND + ', then restart.' },
|
|
61
|
+
{ name: 'Memory', status: f.totalRamBytes < 4 * GB ? 'action' : f.totalRamBytes < 8 * GB ? 'warn' : 'ok', detail: formatGb(f.totalRamBytes) + (f.totalRamBytes < 4 * GB ? ': Windows Sandbox needs at least 4 GB.' : f.totalRamBytes < 8 * GB ? ': works, but 8 GB or more is recommended.' : '') },
|
|
62
|
+
f.freeDiskBytes === null ? { name: 'Free disk', status: 'warn', detail: 'could not check the drive of ' + f.storageRoot }
|
|
63
|
+
: { name: 'Free disk', status: f.freeDiskBytes < 5 * GB ? 'action' : f.freeDiskBytes < 20 * GB ? 'warn' : 'ok', detail: formatGb(f.freeDiskBytes) + ' free for ' + f.storageRoot + (f.freeDiskBytes < 5 * GB ? ': free up space (toolchains need several GB).' : f.freeDiskBytes < 20 * GB ? ': getting low; toolchains and caches can use several GB.' : '') },
|
|
64
|
+
nodeMajor(f.nodeVersion) >= 20 ? { name: 'Node.js', status: 'ok', detail: f.nodeVersion } : { name: 'Node.js', status: 'action', detail: f.nodeVersion + ': needs 20 or newer (winget install OpenJS.NodeJS.LTS).' },
|
|
65
|
+
];
|
|
66
|
+
};
|
|
67
|
+
// Fixed script (no outside data): CPU/hypervisor facts and the feature state, readable without admin rights.
|
|
68
|
+
const PROBE = [
|
|
69
|
+
'$ErrorActionPreference = "SilentlyContinue"',
|
|
70
|
+
'$cpu = @(Get-CimInstance Win32_Processor)',
|
|
71
|
+
'$cs = Get-CimInstance Win32_ComputerSystem',
|
|
72
|
+
'$f = Get-CimInstance Win32_OptionalFeature -Filter "Name=\'' + FEATURE + '\'"',
|
|
73
|
+
'$v = $null; if ($cpu.Count) { $v = [bool]($cpu | Where-Object { $_.VirtualizationFirmwareEnabled }) }',
|
|
74
|
+
'$h = $null; if ($cs) { $h = [bool]$cs.HypervisorPresent }',
|
|
75
|
+
'$s = $null; if ($f) { $s = [int]$f.InstallState }',
|
|
76
|
+
'@{ virtualizationFirmware = $v; hypervisorPresent = $h; installState = $s } | ConvertTo-Json -Compress',
|
|
77
|
+
].join('; ');
|
|
78
|
+
const Probe = z.object({ virtualizationFirmware: z.boolean().nullable(), hypervisorPresent: z.boolean().nullable(), installState: z.number().nullable() }).partial();
|
|
79
|
+
export const parseProbe = (text) => {
|
|
80
|
+
try {
|
|
81
|
+
return Probe.parse(JSON.parse(_.last(text.trim().split(/\r?\n/)) ?? ''));
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return {};
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
const run = (exe, args) => {
|
|
88
|
+
const r = spawnSync(exe, args, { encoding: 'utf8', windowsHide: true, timeout: 60000 });
|
|
89
|
+
return r.status === 0 ? r.stdout : '';
|
|
90
|
+
};
|
|
91
|
+
const freeDisk = (dir) => {
|
|
92
|
+
let at = path.resolve(dir);
|
|
93
|
+
while (!fs.existsSync(at) && path.dirname(at) !== at)
|
|
94
|
+
at = path.dirname(at);
|
|
95
|
+
try {
|
|
96
|
+
const s = fs.statfsSync(at);
|
|
97
|
+
return s.bavail * s.bsize;
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
const gatherFacts = (storageRoot) => {
|
|
104
|
+
const reg = parseRegQuery(run(SYSTEM.reg(), ['query', 'HKLM\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion']));
|
|
105
|
+
const probe = parseProbe(run(SYSTEM.powershell(), ['-NoProfile', '-NonInteractive', '-Command', PROBE]));
|
|
106
|
+
// DISM knows about a pending restart but needs admin rights; the CIM state works for everyone.
|
|
107
|
+
const dism = parseDismState(run(SYSTEM.dism(), ['/English', '/Online', '/Get-FeatureInfo', '/FeatureName:' + FEATURE]));
|
|
108
|
+
return {
|
|
109
|
+
edition: reg.EditionID ?? null,
|
|
110
|
+
build: Number(reg.CurrentBuildNumber ?? reg.CurrentBuild) || null,
|
|
111
|
+
feature: dism === 'unknown' ? featureFromInstallState(probe.installState) : dism,
|
|
112
|
+
sandboxExe: fileExists(system32('WindowsSandbox.exe')),
|
|
113
|
+
virtualizationFirmware: probe.virtualizationFirmware ?? null,
|
|
114
|
+
hypervisorPresent: probe.hypervisorPresent ?? null,
|
|
115
|
+
totalRamBytes: os.totalmem(),
|
|
116
|
+
freeDiskBytes: freeDisk(storageRoot),
|
|
117
|
+
storageRoot,
|
|
118
|
+
nodeVersion: process.version,
|
|
119
|
+
};
|
|
120
|
+
};
|
|
121
|
+
const LABEL = { ok: '\x1b[32mOK\x1b[0m', warn: '\x1b[33mWARNING\x1b[0m', action: '\x1b[31mACTION NEEDED\x1b[0m' };
|
|
122
|
+
const print = (checks) => table(checks.map((c) => [LABEL[c.status], c.name, c.detail]));
|
|
123
|
+
/** Runs the enable command in an elevated PowerShell (Windows asks for permission); true if it worked. */
|
|
124
|
+
const enableFeature = () => {
|
|
125
|
+
const inner = "'-NoProfile','-Command','" + ENABLE_COMMAND + " | Out-Null'";
|
|
126
|
+
const outer = "try { $p = Start-Process -FilePath '" + SYSTEM.powershell() + "' -Verb RunAs -Wait -PassThru -WindowStyle Hidden -ArgumentList " + inner + '; exit $p.ExitCode } catch { exit 1 }';
|
|
127
|
+
return spawnSync(SYSTEM.powershell(), ['-NoProfile', '-NonInteractive', '-Command', outer], { stdio: 'inherit' }).status === 0;
|
|
128
|
+
};
|
|
129
|
+
export const setup = async (ctx) => {
|
|
130
|
+
if (!isWin)
|
|
131
|
+
fail('Windows Sandbox only runs on Windows.', 'Run sandbox setup on a Windows 10/11 Pro, Enterprise or Education PC.');
|
|
132
|
+
console.log('Checking this PC for Windows Sandbox...\n');
|
|
133
|
+
const facts = gatherFacts(ctx.storageRoot);
|
|
134
|
+
let checks = buildChecks(facts);
|
|
135
|
+
print(checks);
|
|
136
|
+
if (canEnableFeature(facts)) {
|
|
137
|
+
console.log('');
|
|
138
|
+
if (await askYesNo('Turn on Windows Sandbox now? Windows will ask for admin permission.')) {
|
|
139
|
+
console.log('Enabling Windows Sandbox (this can take a minute)...');
|
|
140
|
+
if (!enableFeature())
|
|
141
|
+
fail('Windows Sandbox could not be enabled (or the admin prompt was declined).', 'In an admin PowerShell run: ' + ENABLE_COMMAND + ', then restart Windows.');
|
|
142
|
+
console.log('Done. Restart Windows to finish, then run sandbox setup again.\n');
|
|
143
|
+
checks = buildChecks(gatherFacts(ctx.storageRoot));
|
|
144
|
+
print(checks);
|
|
145
|
+
}
|
|
146
|
+
else
|
|
147
|
+
console.log('To do it yourself, in an admin PowerShell run:\n ' + ENABLE_COMMAND + '\nthen restart Windows.');
|
|
148
|
+
}
|
|
149
|
+
const todo = checks.filter((c) => c.status === 'action').length;
|
|
150
|
+
console.log('\n' + (todo ? todo + ' thing(s) to fix before the sandbox can start (see ACTION NEEDED above).' : 'This PC is ready. cd into a project and run sandbox claude (or sandbox for no agent).'));
|
|
151
|
+
return todo ? 1 : 0;
|
|
152
|
+
};
|