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
package/dist/host/generate.js
CHANGED
|
@@ -37,6 +37,12 @@ const hostToolsOf = (probe) => ({
|
|
|
37
37
|
volta: Boolean(probe.volta),
|
|
38
38
|
pyenv: Boolean(probe.pyenv),
|
|
39
39
|
});
|
|
40
|
+
/**
|
|
41
|
+
* env.cmd, which logon.cmd calls before it opens Windows Terminal: Terminal expands "%AGENT_WORKSPACE%" (the starting
|
|
42
|
+
* folder of new tabs) from its own environment, and the runtime only sets the variable machine-wide after Terminal
|
|
43
|
+
* started. `%` is doubled for cmd; a Windows path holds no quotes.
|
|
44
|
+
*/
|
|
45
|
+
export const logonEnv = (workspace) => '@set "AGENT_WORKSPACE=' + workspace.replace(/[\r\n"]/g, '').replace(/%/g, '%%') + '"\r\n';
|
|
40
46
|
export const generateSandbox = (o) => {
|
|
41
47
|
const { config, dataPath, workspaceHost, harnessName, storageRoot } = o.run;
|
|
42
48
|
const harness = harnessName === 'none' ? null : config.harnesses[harnessName] ?? fail('Unknown agent: ' + harnessName, 'Configured agents: ' + Object.keys(config.harnesses).join(', '));
|
|
@@ -87,9 +93,13 @@ export const generateSandbox = (o) => {
|
|
|
87
93
|
personalization: fs.existsSync(path.join(launchPath, 'personalize', 'personalize.reg')),
|
|
88
94
|
substDrives: substDrives(mappings.map((m) => m.guest)),
|
|
89
95
|
cpuLimitPercent: config.sandbox.cpuLimit === 'none' ? null : config.sandbox.cpuLimit,
|
|
96
|
+
pageFileMb: config.sandbox.pageFileMb,
|
|
97
|
+
apps: config.apps,
|
|
98
|
+
auditProcesses: config.audit.enabled && config.audit.processes,
|
|
90
99
|
};
|
|
91
100
|
const sessionPath = path.join(launchPath, 'session.json');
|
|
92
101
|
writeJson(sessionPath, session);
|
|
102
|
+
fs.writeFileSync(path.join(launchPath, 'env.cmd'), logonEnv(session.workspace), 'utf8');
|
|
93
103
|
copyRuntime(o.packageRoot, launchPath);
|
|
94
104
|
stageHooks(o.packageRoot, launchPath, o.run.scripts);
|
|
95
105
|
const wsbPath = path.join(launchPath, 'agent.wsb');
|
|
@@ -3,13 +3,17 @@ import { assertSafeMapping } from '../../core/safe-paths.js';
|
|
|
3
3
|
import { dirExists, fail } from '../../core/util.js';
|
|
4
4
|
import { isValidGuestPath, sandboxFolder } from '../../shared/guest-paths.js';
|
|
5
5
|
import { GUEST } from './wsb.js';
|
|
6
|
+
import { guestGitDir, planView } from '../workspace-view/index.js';
|
|
6
7
|
export const collectMappings = (o, launchPath, boot = { roots: [], projects: [], notes: [] }) => {
|
|
7
8
|
const { config, dataPath, workspaceHost, harnessName } = o.run;
|
|
8
9
|
assertGuestPath(config.guestWorkspace, 'guestWorkspace');
|
|
10
|
+
// workspaceFilter: the sandbox gets the project's filtered view (no git-ignored files) and its real .git beside it.
|
|
11
|
+
const view = planView(config, o.run.storageRoot, workspaceHost);
|
|
9
12
|
const mappings = [
|
|
10
13
|
{ host: launchPath, guest: GUEST.launch, readOnly: true },
|
|
11
14
|
{ host: dataPath, guest: GUEST.data, readOnly: false },
|
|
12
|
-
{ host: workspaceHost, guest: config.guestWorkspace, readOnly: config.readOnly },
|
|
15
|
+
{ host: view ? view.options.view : workspaceHost, guest: config.guestWorkspace, readOnly: config.readOnly },
|
|
16
|
+
...(view?.hostGit ? [{ host: view.hostGit, guest: guestGitDir(config.guestWorkspace), readOnly: config.readOnly }] : []),
|
|
13
17
|
];
|
|
14
18
|
for (const m of config.mappings) {
|
|
15
19
|
const host = m.host;
|
|
@@ -25,7 +25,7 @@ export const stageScripts = (target, scripts) => {
|
|
|
25
25
|
fs.copyFileSync(script, path.join(target, '90-extra-' + index + '-' + path.basename(script)));
|
|
26
26
|
});
|
|
27
27
|
};
|
|
28
|
-
export const RUNTIME_FILES = ['main.js', 'daemon.js', 'view.js', 'gitguard.js', 'db.js'];
|
|
28
|
+
export const RUNTIME_FILES = ['main.js', 'daemon.js', 'view.js', 'gitguard.js', 'db.js', 'netproxy.js'];
|
|
29
29
|
/** The bundled sandbox runtime (dist/runtime: one self-contained file per entry point) and the sandbox/ scripts. */
|
|
30
30
|
export const copyRuntime = (packageRoot, launchPath) => {
|
|
31
31
|
const runtime = path.join(packageRoot, 'dist', 'runtime');
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** What Windows runs when you open it (not source files such as .js or .py: those need a program you choose). */
|
|
2
|
+
export declare const RISKY: RegExp;
|
|
3
|
+
/** Marks one file if it is a program or script (and a plain file, not a link). Returns true when it marked it. */
|
|
4
|
+
export declare const markFromSandbox: (file: string) => boolean;
|
|
5
|
+
/** Has this file the mark? */
|
|
6
|
+
export declare const isMarked: (file: string) => boolean;
|
|
7
|
+
/** Watches the projects while the sandbox runs and marks what appears or changes; returns a function that stops it. */
|
|
8
|
+
export declare const watchAndMark: (projects: string[], note?: (message: string) => void) => (() => void);
|
|
9
|
+
/** The projects to watch and mark (empty: storage.markSandboxFiles is off). */
|
|
10
|
+
export declare const saveMarkedProjects: (storageRoot: string, dataPath: string, projects: string[]) => void;
|
|
11
|
+
export declare const readMarkedProjects: (storageRoot: string, dataPath: string) => string[];
|
|
12
|
+
/** The data folder's size, measured by the watcher when a sandbox closes (measuring at launch would be slow). */
|
|
13
|
+
export declare const saveDataSize: (storageRoot: string, dataPath: string, bytes: number) => void;
|
|
14
|
+
export declare const readDataSize: (storageRoot: string, dataPath: string) => {
|
|
15
|
+
bytes: number;
|
|
16
|
+
at: string;
|
|
17
|
+
} | null;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// storage.markSandboxFiles: programs and scripts that appear or change in a project while a sandbox runs get Windows'
|
|
2
|
+
// "downloaded from the internet" mark (the Zone.Identifier stream, zone 3). Running one on this PC then goes through
|
|
3
|
+
// SmartScreen, and PowerShell's RemoteSigned policy refuses an unsigned marked script. So something an agent planted is
|
|
4
|
+
// not run by a double-click or a habit. Runs in the background watcher; only the mark is written, never the file.
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
import { readValid, writeJsonAtomic } from '../shared/rundir.js';
|
|
9
|
+
/** What Windows runs when you open it (not source files such as .js or .py: those need a program you choose). */
|
|
10
|
+
export const RISKY = /\.(exe|dll|com|scr|pif|msi|msix|msp|appx|appxbundle|bat|cmd|ps1|psm1|psd1|vbs|vbe|wsf|wsh|hta|lnk|url|jar|reg|cpl|application|gadget|chm)$/i;
|
|
11
|
+
const MARK = '[ZoneTransfer]\r\nZoneId=3\r\nHostUrl=about:windows-development-sandbox\r\n';
|
|
12
|
+
/** Folders not worth watching closely (git's own files; dependency and build folders are still marked). */
|
|
13
|
+
const SKIP = /(^|[\\/])\.git([\\/]|$)/i;
|
|
14
|
+
/** Marks one file if it is a program or script (and a plain file, not a link). Returns true when it marked it. */
|
|
15
|
+
export const markFromSandbox = (file) => {
|
|
16
|
+
if (!RISKY.test(file) || SKIP.test(file))
|
|
17
|
+
return false;
|
|
18
|
+
try {
|
|
19
|
+
const stat = fs.lstatSync(file);
|
|
20
|
+
if (!stat.isFile())
|
|
21
|
+
return false;
|
|
22
|
+
fs.writeFileSync(file + ':Zone.Identifier', MARK);
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
/** Has this file the mark? */
|
|
30
|
+
export const isMarked = (file) => {
|
|
31
|
+
try {
|
|
32
|
+
return fs.readFileSync(file + ':Zone.Identifier', 'utf8').includes('ZoneId=3');
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
/** Watches the projects while the sandbox runs and marks what appears or changes; returns a function that stops it. */
|
|
39
|
+
export const watchAndMark = (projects, note = () => undefined) => {
|
|
40
|
+
const pending = new Set();
|
|
41
|
+
let timer;
|
|
42
|
+
const flush = () => {
|
|
43
|
+
timer = undefined;
|
|
44
|
+
const marked = [...pending].filter(markFromSandbox);
|
|
45
|
+
pending.clear();
|
|
46
|
+
if (marked.length)
|
|
47
|
+
note('marked as from the sandbox (SmartScreen asks before they run here): ' + marked.slice(0, 10).map((f) => path.basename(f)).join(', ') + (marked.length > 10 ? ' and ' + (marked.length - 10) + ' more' : ''));
|
|
48
|
+
};
|
|
49
|
+
const watchers = projects.flatMap((project) => {
|
|
50
|
+
try {
|
|
51
|
+
return [fs.watch(project, { recursive: true }, (_event, file) => {
|
|
52
|
+
const rel = file ? String(file) : '';
|
|
53
|
+
if (!rel || !RISKY.test(rel) || SKIP.test(rel))
|
|
54
|
+
return;
|
|
55
|
+
pending.add(path.join(project, rel));
|
|
56
|
+
timer ??= setTimeout(flush, 1000);
|
|
57
|
+
})];
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
return () => { watchers.forEach((w) => w.close()); clearTimeout(timer); if (pending.size)
|
|
64
|
+
flush(); };
|
|
65
|
+
};
|
|
66
|
+
// ---- what the watcher marks, and the size of the persisted data (host-only files next to the sync state) ----------
|
|
67
|
+
const marksFile = (storageRoot, dataPath) => path.join(storageRoot, 'sync', path.basename(dataPath) + '-mark.json');
|
|
68
|
+
const sizeFile = (storageRoot, dataPath) => path.join(storageRoot, 'sync', path.basename(dataPath) + '-size.json');
|
|
69
|
+
/** The projects to watch and mark (empty: storage.markSandboxFiles is off). */
|
|
70
|
+
export const saveMarkedProjects = (storageRoot, dataPath, projects) => writeJsonAtomic(marksFile(storageRoot, dataPath), projects);
|
|
71
|
+
export const readMarkedProjects = (storageRoot, dataPath) => readValid(z.array(z.string().max(1024)).max(64), marksFile(storageRoot, dataPath)) ?? [];
|
|
72
|
+
/** The data folder's size, measured by the watcher when a sandbox closes (measuring at launch would be slow). */
|
|
73
|
+
export const saveDataSize = (storageRoot, dataPath, bytes) => writeJsonAtomic(sizeFile(storageRoot, dataPath), { bytes, at: new Date().toISOString() });
|
|
74
|
+
export const readDataSize = (storageRoot, dataPath) => readValid(z.object({ bytes: z.number().min(0), at: z.string().max(40) }), sizeFile(storageRoot, dataPath));
|
package/dist/host/platform.js
CHANGED
|
@@ -4,6 +4,7 @@ import path from 'node:path';
|
|
|
4
4
|
import { spawn, spawnSync } from 'node:child_process';
|
|
5
5
|
import { SYSTEM } from '../shared/system.js';
|
|
6
6
|
import { refuseInSelfTest } from './test-guard.js';
|
|
7
|
+
import { updateRestartPending } from './stops.js';
|
|
7
8
|
import { commandsDir, realAgent } from './agent-commands.js';
|
|
8
9
|
import { dirExists, ensureDir, fail, fileExists, findOnPath, homeDir, isWin, realPath } from '../core/util.js';
|
|
9
10
|
const findHarnessExe = (config, harnessName) => {
|
|
@@ -116,6 +117,8 @@ export const doctorRows = (config, harnessName, storageRoot, configPath) => {
|
|
|
116
117
|
['Windows Sandbox feature', sandboxFeatureInstalled() ? 'enabled' : 'NOT enabled'],
|
|
117
118
|
['Sandbox running', isSandboxRunning() ? 'YES' : 'no'],
|
|
118
119
|
['Free host RAM (MB)', String(Math.floor(os.freemem() / 1048576))],
|
|
120
|
+
['Update restart pending', updateRestartPending() ? 'YES (Windows may restart on its own and close the sandbox; restart first or pause updates)' : 'no'],
|
|
121
|
+
['Keep this PC awake', config.sandbox.keepAwake ? 'yes, while a sandbox runs' : 'no (sandbox.keepAwake is off: the PC may sleep under running agents)'],
|
|
119
122
|
['Provisioning', config.provisioning.copyFromHost ? 'copy from this PC' + (config.provisioning.fallbackToInstall ? ', fall back to download' : ', no fallback') : 'download inside sandbox'],
|
|
120
123
|
['Host Node (runner)', probe.nvmNode ? 'nvm ' + probe.hostNodeVersion : probe.nodeDir ?? '(none; will download)'],
|
|
121
124
|
['Host nvm versions', probe.nvmDir ?? '(no NVM_HOME; versions will be downloaded)'],
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type PortForward } from '../shared/netpolicy.js';
|
|
2
|
+
/** Host-only: the forwards of the running sandbox (written at launch, read by the watcher). */
|
|
3
|
+
export declare const saveForwards: (storageRoot: string, dataPath: string, forwards: PortForward[]) => void;
|
|
4
|
+
export declare const readForwards: (storageRoot: string, dataPath: string) => PortForward[];
|
|
5
|
+
/** The sandbox's address on this PC's Hyper-V network, from its status (null until it has one). */
|
|
6
|
+
export declare const sandboxAddress: (dataPath: string) => string | null;
|
|
7
|
+
/** Starts one listener per forward; returns a function that stops them. */
|
|
8
|
+
export declare const startPortForwards: (forwards: PortForward[], address: () => string | null, note?: (message: string) => void) => (() => void);
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// network.forwardPorts, PC side: http://localhost:<pc> on this PC reaches <sandbox> inside the sandbox while it runs.
|
|
2
|
+
// Runs in the background watcher. Listens on 127.0.0.1 only (nothing else on your network can use it), and connects
|
|
3
|
+
// only to an address the sandbox published that is on this PC's Hyper-V network (isSandboxAddress). Bytes are passed
|
|
4
|
+
// through, never looked at. No admin rights needed (no netsh portproxy).
|
|
5
|
+
import net from 'node:net';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
import { relayPort } from '../shared/netpolicy.js';
|
|
9
|
+
import { readSandboxStatus, readValid, writeJsonAtomic } from '../shared/rundir.js';
|
|
10
|
+
import { isSandboxAddress } from './attach.js';
|
|
11
|
+
const Forwards = z.array(z.object({ pc: z.number().int().min(1).max(65535), sandbox: z.number().int().min(1).max(65535) })).max(64);
|
|
12
|
+
const forwardsFile = (storageRoot, dataPath) => path.join(storageRoot, 'sync', path.basename(dataPath) + '-forwards.json');
|
|
13
|
+
/** Host-only: the forwards of the running sandbox (written at launch, read by the watcher). */
|
|
14
|
+
export const saveForwards = (storageRoot, dataPath, forwards) => writeJsonAtomic(forwardsFile(storageRoot, dataPath), forwards);
|
|
15
|
+
export const readForwards = (storageRoot, dataPath) => readValid(Forwards, forwardsFile(storageRoot, dataPath)) ?? [];
|
|
16
|
+
/** The sandbox's address on this PC's Hyper-V network, from its status (null until it has one). */
|
|
17
|
+
export const sandboxAddress = (dataPath) => readSandboxStatus(dataPath)?.addresses?.find((a) => isSandboxAddress(a)) ?? null;
|
|
18
|
+
/** Starts one listener per forward; returns a function that stops them. */
|
|
19
|
+
export const startPortForwards = (forwards, address, note = () => undefined) => {
|
|
20
|
+
const servers = forwards.map((f, i) => {
|
|
21
|
+
const server = net.createServer((client) => {
|
|
22
|
+
const ip = address();
|
|
23
|
+
if (!ip) {
|
|
24
|
+
client.destroy();
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const target = net.connect(relayPort(i), ip);
|
|
28
|
+
client.pipe(target).pipe(client);
|
|
29
|
+
target.on('error', () => client.destroy());
|
|
30
|
+
client.on('error', () => target.destroy());
|
|
31
|
+
});
|
|
32
|
+
server.on('error', (e) => note('localhost:' + f.pc + ' could not be forwarded' + (e.code === 'EADDRINUSE' ? ': something on this PC already uses that port' : ': ' + e.message)));
|
|
33
|
+
server.listen(f.pc, '127.0.0.1', () => note('localhost:' + f.pc + ' -> sandbox port ' + f.sandbox));
|
|
34
|
+
return server;
|
|
35
|
+
});
|
|
36
|
+
return () => servers.forEach((s) => s.close());
|
|
37
|
+
};
|
|
@@ -1,12 +1,19 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
/** A git folder's own state: config lines, hooks, redirect files. */
|
|
2
|
+
export interface GitState {
|
|
3
3
|
config: string[];
|
|
4
4
|
hooks: Record<string, string>;
|
|
5
5
|
redirects: string[];
|
|
6
|
+
}
|
|
7
|
+
export interface RepoSnapshot extends GitState {
|
|
8
|
+
gitDir: boolean;
|
|
6
9
|
commands: Record<string, string>;
|
|
10
|
+
/** Fingerprints of AGENT_FILES (missing in snapshots of older versions: then they are not checked). */
|
|
11
|
+
agent?: Record<string, string>;
|
|
12
|
+
/** Other git folders: submodules (.git/modules/...) and repositories nested in the project, by relative path. */
|
|
13
|
+
repos?: Record<string, GitState>;
|
|
7
14
|
}
|
|
8
15
|
export interface RepoFinding {
|
|
9
|
-
kind: 'hook' | 'config' | 'git' | 'command';
|
|
16
|
+
kind: 'hook' | 'config' | 'git' | 'command' | 'agent';
|
|
10
17
|
detail: string;
|
|
11
18
|
}
|
|
12
19
|
export declare const snapshotRepo: (workspace: string) => RepoSnapshot;
|
package/dist/host/repo-guard.js
CHANGED
|
@@ -6,17 +6,22 @@
|
|
|
6
6
|
// - git: new or changed hooks, and config that makes git run programs (core.fsmonitor, core.sshCommand, filters,
|
|
7
7
|
// include.path, ...), plus files that redirect where git reads config and hooks (commondir, config.worktree);
|
|
8
8
|
// - commands: executables dropped in the project root under the name of something you type there (node.exe,
|
|
9
|
-
// git.cmd, npm.cmd, ...), because Windows runs a program from the current folder before searching PATH
|
|
9
|
+
// git.cmd, npm.cmd, ...), because Windows runs a program from the current folder before searching PATH;
|
|
10
|
+
// - agent and editor files: hooks, helper commands and MCP servers in .claude/ and .mcp.json, VS Code folder-open
|
|
11
|
+
// tasks and program paths, .envrc. Submodules and nested repositories get the git checks too.
|
|
10
12
|
import crypto from 'node:crypto';
|
|
11
13
|
import fs from 'node:fs';
|
|
12
14
|
import path from 'node:path';
|
|
13
15
|
import _ from 'lodash';
|
|
14
16
|
import { z } from 'zod';
|
|
17
|
+
import { parse as parseJsonc } from 'jsonc-parser';
|
|
15
18
|
import { readValid, writeFileAtomic, writeJsonAtomic } from '../shared/rundir.js';
|
|
16
19
|
import { assertTestWritable } from './test-guard.js';
|
|
17
20
|
import { dirExists, fileExists, isLink } from '../core/util.js';
|
|
18
21
|
/** Config keys that make git run a program, or load more config. */
|
|
19
|
-
const DANGEROUS_KEY = /^(fsmonitor|hookspath|sshcommand|pager|editor|askpass|program|gpgprogram|textconv|command|cmd|clean|smudge|process|driver|helper|uploadpack|receivepack|path|attributesfile|excludesfile|worktree|worktreeconfig|gitproxy|proxycommand|sendemailvalidate|tool)\s*=/i;
|
|
22
|
+
const DANGEROUS_KEY = /^(fsmonitor|hookspath|sshcommand|pager|editor|askpass|program|gpgprogram|textconv|command|cmd|clean|smudge|process|driver|helper|uploadpack|receivepack|path|attributesfile|excludesfile|worktree|worktreeconfig|gitproxy|proxycommand|sendemailvalidate|tool|external|difffilter|defaultkeycommand|alternaterefscommand)\s*=/i;
|
|
23
|
+
/** Sections where any key runs a program ([pager] log = x), or where a "!" value does ([alias] st = !x). */
|
|
24
|
+
const dangerousInSection = (section, keyPart) => (section === 'pager' && keyPart.includes('=')) || (section === 'alias' && /^[^=]+=\s*"?\s*!/.test(keyPart));
|
|
20
25
|
const SECTION = /^\s*\[([^\]]*)\]\s*/;
|
|
21
26
|
const MARK = '# [agent-sandbox] disabled, added inside the sandbox: ';
|
|
22
27
|
/** Files in .git that make git read config/hooks from somewhere else. */
|
|
@@ -33,6 +38,41 @@ const COMMANDS = new RegExp('^(' + [
|
|
|
33
38
|
'make', 'cmake', 'ninja', 'gcc', 'g\\+\\+', 'clang', 'psql', 'mysql', 'mariadb', 'mongosh', 'redis-cli', 'docker', 'kubectl', 'terraform', 'az', 'aws',
|
|
34
39
|
'powershell', 'pwsh', 'cmd', 'where', 'tasklist', 'tar', 'curl', 'ssh', 'scp',
|
|
35
40
|
].join('|') + ')\\.(exe|com|bat|cmd|vbs|vbe|js|jse|wsf|wsh|msc|ps1)$', 'i');
|
|
41
|
+
/**
|
|
42
|
+
* Project files that tools on this PC act on by themselves: Claude Code runs a project's hooks, helpers and MCP servers,
|
|
43
|
+
* VS Code runs folder-open tasks and the programs workspace settings point at, direnv runs .envrc. For each, only the
|
|
44
|
+
* part that can run something is compared (`risky`), so the agent may still edit the rest (permissions, formatting).
|
|
45
|
+
*/
|
|
46
|
+
const AGENT_FILES = {
|
|
47
|
+
'.claude/settings.json': (d) => claudeRisk(d),
|
|
48
|
+
'.claude/settings.local.json': (d) => claudeRisk(d),
|
|
49
|
+
'.mcp.json': (_d, text) => text,
|
|
50
|
+
'.vscode/tasks.json': (d) => JSON.stringify(d).match(/"runOn"\s*:\s*"folderOpen"/) ? d : null,
|
|
51
|
+
'.vscode/settings.json': (d) => _.pickBy(_.isPlainObject(d) ? d : {}, (_v, k) => /path|executable|command|interpreter|shell|profile|args|env|exec|program|server|launch|runtime|binary/i.test(k)),
|
|
52
|
+
'.envrc': (_d, text) => text,
|
|
53
|
+
};
|
|
54
|
+
const claudeRisk = (d) => {
|
|
55
|
+
const s = (_.isPlainObject(d) ? d : {});
|
|
56
|
+
const permissions = (_.isPlainObject(s.permissions) ? s.permissions : {});
|
|
57
|
+
return { ..._.pick(s, ['hooks', 'apiKeyHelper', 'awsAuthRefresh', 'awsCredentialExport', 'otelHeadersHelper', 'statusLine', 'env', 'enableAllProjectMcpServers', 'enabledMcpjsonServers', 'disableAllHooks']), permissions: _.pick(permissions, ['defaultMode', 'additionalDirectories']) };
|
|
58
|
+
};
|
|
59
|
+
const MAX_AGENT_FILE_BYTES = 1024 * 1024;
|
|
60
|
+
/** What can run in one agent file, as a short fingerprint (null = the file is not there). */
|
|
61
|
+
const agentRisk = (workspace, rel) => {
|
|
62
|
+
const file = path.join(workspace, ...rel.split('/'));
|
|
63
|
+
if (!fs.existsSync(file))
|
|
64
|
+
return null;
|
|
65
|
+
if (isLink(file) || isLink(path.dirname(file)) || !fileExists(file))
|
|
66
|
+
return 'link-or-not-a-file';
|
|
67
|
+
if (fs.statSync(file).size > MAX_AGENT_FILE_BYTES)
|
|
68
|
+
return 'too-large-' + hash(file);
|
|
69
|
+
const text = fs.readFileSync(file, 'utf8');
|
|
70
|
+
const errors = [];
|
|
71
|
+
const data = parseJsonc(text, errors, { allowTrailingComma: true });
|
|
72
|
+
const risk = AGENT_FILES[rel](rel === '.envrc' ? null : data, text);
|
|
73
|
+
return crypto.createHash('sha256').update(JSON.stringify(risk ?? null) + (errors.length && rel !== '.envrc' && rel !== '.mcp.json' ? hash(file) : '')).digest('hex');
|
|
74
|
+
};
|
|
75
|
+
const agentSnapshot = (workspace) => Object.fromEntries(Object.keys(AGENT_FILES).flatMap((rel) => { const r = agentRisk(workspace, rel); return r ? [[rel, r]] : []; }));
|
|
36
76
|
/** SHA-256 in small chunks: a sandbox can plant a multi-gigabyte "node.exe" to exhaust this PC's memory. */
|
|
37
77
|
const hash = (file) => {
|
|
38
78
|
if (isLink(file))
|
|
@@ -68,14 +108,66 @@ const configLines = (dir) => {
|
|
|
68
108
|
const file = path.join(dir, 'config');
|
|
69
109
|
return fileExists(file) && !isLink(file) && fs.statSync(file).size <= MAX_CONFIG_BYTES ? fs.readFileSync(file, 'utf8').split(/\r?\n/) : [];
|
|
70
110
|
};
|
|
111
|
+
const gitState = (dir) => ({
|
|
112
|
+
config: dir ? configLines(dir) : [],
|
|
113
|
+
hooks: dir ? filesIn(path.join(dir, 'hooks'), (n) => !n.endsWith('.sample')) : {},
|
|
114
|
+
redirects: dir ? REDIRECTS.filter((n) => fs.existsSync(path.join(dir, n))) : [],
|
|
115
|
+
});
|
|
116
|
+
const EMPTY_GIT = { config: [], hooks: {}, redirects: [] };
|
|
117
|
+
const MAX_OTHER_REPOS = 64;
|
|
118
|
+
const SKIP_DIRS = new Set(['node_modules', '.git', 'agent-sandbox-quarantine', 'target', 'dist', 'build', 'bin', 'obj', '.venv', 'venv']);
|
|
119
|
+
const plainDirs = (dir, limit) => {
|
|
120
|
+
try {
|
|
121
|
+
return fs.readdirSync(dir, { withFileTypes: true }).filter((e) => e.isDirectory() && !e.isSymbolicLink()).slice(0, limit).map((e) => path.join(dir, e.name));
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return [];
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
/**
|
|
128
|
+
* Git folders other than the project's own: submodules (.git/modules/**) and repositories nested up to two levels deep.
|
|
129
|
+
* git runs their hooks and config too (git status in a subfolder, VS Code's repository scan). Bounded: links are never
|
|
130
|
+
* followed and at most MAX_OTHER_REPOS are looked at.
|
|
131
|
+
*/
|
|
132
|
+
const otherGitDirs = (workspace) => {
|
|
133
|
+
const found = [];
|
|
134
|
+
const main = plainGitDir(workspace);
|
|
135
|
+
// Submodules: a folder with a config and a HEAD is a git folder, and may have modules of its own.
|
|
136
|
+
const walk = (dir, depth) => {
|
|
137
|
+
for (const d of plainDirs(dir, 200)) {
|
|
138
|
+
if (found.length >= MAX_OTHER_REPOS)
|
|
139
|
+
return;
|
|
140
|
+
if (fileExists(path.join(d, 'config')) && fileExists(path.join(d, 'HEAD'))) {
|
|
141
|
+
found.push(d);
|
|
142
|
+
if (depth < 3)
|
|
143
|
+
walk(path.join(d, 'modules'), depth + 1);
|
|
144
|
+
}
|
|
145
|
+
else if (depth < 3)
|
|
146
|
+
walk(d, depth + 1);
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
if (main)
|
|
150
|
+
walk(path.join(main, 'modules'), 0);
|
|
151
|
+
for (const sub of plainDirs(workspace, 500).filter((d) => !SKIP_DIRS.has(path.basename(d).toLowerCase()))) {
|
|
152
|
+
for (const candidate of [sub, ...plainDirs(sub, 200).filter((d) => !SKIP_DIRS.has(path.basename(d).toLowerCase()))]) {
|
|
153
|
+
if (found.length >= MAX_OTHER_REPOS)
|
|
154
|
+
return found;
|
|
155
|
+
const git = plainGitDir(candidate);
|
|
156
|
+
if (git)
|
|
157
|
+
found.push(git);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return found;
|
|
161
|
+
};
|
|
162
|
+
const relative = (workspace, dir) => path.relative(workspace, dir).split(path.sep).join('/');
|
|
71
163
|
export const snapshotRepo = (workspace) => {
|
|
72
164
|
const dir = plainGitDir(workspace);
|
|
73
165
|
return {
|
|
74
166
|
gitDir: dir !== null,
|
|
75
|
-
|
|
76
|
-
hooks: dir ? filesIn(path.join(dir, 'hooks'), (n) => !n.endsWith('.sample')) : {},
|
|
77
|
-
redirects: dir ? REDIRECTS.filter((n) => fs.existsSync(path.join(dir, n))) : [],
|
|
167
|
+
...gitState(dir),
|
|
78
168
|
commands: filesIn(workspace, (n) => COMMANDS.test(n)),
|
|
169
|
+
agent: agentSnapshot(workspace),
|
|
170
|
+
repos: Object.fromEntries(otherGitDirs(workspace).map((d) => [relative(workspace, d), gitState(d)])),
|
|
79
171
|
};
|
|
80
172
|
};
|
|
81
173
|
/** Moves files into <dir>\agent-sandbox-quarantine\<time>\ and reports them. */
|
|
@@ -101,16 +193,19 @@ const disableConfig = (dir, before) => {
|
|
|
101
193
|
const seen = {};
|
|
102
194
|
const findings = [];
|
|
103
195
|
let inInclude = false;
|
|
196
|
+
let section = '';
|
|
104
197
|
const fixed = lines.map((line) => {
|
|
105
198
|
const header = SECTION.exec(line);
|
|
106
|
-
if (header)
|
|
199
|
+
if (header) {
|
|
200
|
+
section = header[1].trim().toLowerCase().split(/\s/)[0];
|
|
107
201
|
inInclude = /^include(if\s+"[^"]*")?$/i.test(header[1].trim());
|
|
202
|
+
}
|
|
108
203
|
seen[line] = (seen[line] ?? 0) + 1;
|
|
109
204
|
if (line.startsWith(MARK) || seen[line] <= (known[line] ?? 0))
|
|
110
205
|
return line;
|
|
111
206
|
// git also accepts "[core] fsmonitor = x" on one line, so look past a leading section header.
|
|
112
207
|
const keyPart = line.replace(SECTION, '').trim();
|
|
113
|
-
if (!(DANGEROUS_KEY.test(keyPart) || (inInclude && keyPart.includes('='))))
|
|
208
|
+
if (!(DANGEROUS_KEY.test(keyPart) || (inInclude && keyPart.includes('=')) || dangerousInSection(section, keyPart)))
|
|
114
209
|
return line;
|
|
115
210
|
findings.push({ kind: 'config', detail: line.trim() });
|
|
116
211
|
return MARK + line.trim();
|
|
@@ -133,6 +228,15 @@ export const guardRepo = (workspace, before) => {
|
|
|
133
228
|
const findings = [];
|
|
134
229
|
const commandsNow = filesIn(workspace, (n) => COMMANDS.test(n));
|
|
135
230
|
findings.push(...quarantine(workspace, Object.keys(commandsNow).filter((n) => before.commands[n] !== commandsNow[n]), workspace, 'command'));
|
|
231
|
+
// Snapshots from before these checks existed have no baseline: checking against nothing would move your own files.
|
|
232
|
+
if (before.agent)
|
|
233
|
+
findings.push(...guardAgentFiles(workspace, before.agent));
|
|
234
|
+
if (before.repos) {
|
|
235
|
+
for (const other of otherGitDirs(workspace)) {
|
|
236
|
+
const rel = relative(workspace, other);
|
|
237
|
+
findings.push(...guardGitDir(other, before.repos[rel] ?? EMPTY_GIT).map((f) => ({ ...f, detail: rel + ': ' + f.detail })));
|
|
238
|
+
}
|
|
239
|
+
}
|
|
136
240
|
const dir = plainGitDir(workspace);
|
|
137
241
|
if (!dir) {
|
|
138
242
|
const replaced = before.gitDir && fs.existsSync(path.join(workspace, '.git'));
|
|
@@ -140,26 +244,50 @@ export const guardRepo = (workspace, before) => {
|
|
|
140
244
|
findings.push({ kind: 'git', detail: '.git was replaced by a link or a "gitdir:" file inside the sandbox. Do not run git in ' + workspace + ' until you have checked it.' });
|
|
141
245
|
return findings;
|
|
142
246
|
}
|
|
247
|
+
findings.push(...guardGitDir(dir, before));
|
|
248
|
+
return findings;
|
|
249
|
+
};
|
|
250
|
+
const guardGitDir = (dir, before) => {
|
|
143
251
|
const hooksDir = path.join(dir, 'hooks');
|
|
144
252
|
const hooksNow = filesIn(hooksDir, (n) => !n.endsWith('.sample'));
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
253
|
+
return [
|
|
254
|
+
...quarantine(hooksDir, Object.keys(hooksNow).filter((n) => before.hooks[n] !== hooksNow[n]), dir, 'hook'),
|
|
255
|
+
...quarantine(dir, REDIRECTS.filter((n) => fs.existsSync(path.join(dir, n)) && !before.redirects.includes(n)), dir, 'git'),
|
|
256
|
+
...disableConfig(dir, before.config),
|
|
257
|
+
];
|
|
258
|
+
};
|
|
259
|
+
/** Agent files whose runnable part changed are moved to the quarantine folder (the rest of your edits are in there too). */
|
|
260
|
+
const guardAgentFiles = (workspace, before) => {
|
|
261
|
+
const changed = Object.keys(AGENT_FILES).filter((rel) => { const now = agentRisk(workspace, rel); return now !== null && now !== before[rel]; });
|
|
262
|
+
if (!changed.length)
|
|
263
|
+
return [];
|
|
264
|
+
const target = path.join(workspace, 'agent-sandbox-quarantine', String(Date.now()));
|
|
265
|
+
fs.mkdirSync(target, { recursive: true });
|
|
266
|
+
return changed.map((rel) => {
|
|
267
|
+
const what = rel + ' (the sandbox added something it would run on this PC: hooks, MCP servers, helper commands or tasks';
|
|
268
|
+
try {
|
|
269
|
+
fs.renameSync(path.join(workspace, ...rel.split('/')), path.join(target, rel.replace(/\//g, '_')));
|
|
270
|
+
}
|
|
271
|
+
catch (e) {
|
|
272
|
+
return { kind: 'agent', detail: what + '; could not be moved: ' + e.code + '. Check it before you open this folder in Claude Code or VS Code)' };
|
|
273
|
+
}
|
|
274
|
+
return { kind: 'agent', detail: what + '; moved to ' + target + ')' };
|
|
275
|
+
});
|
|
149
276
|
};
|
|
150
277
|
// ---- snapshot storage (host-only, next to the sync state) ---------------------------------------------------------
|
|
151
278
|
const syncDir = (dataPath) => path.join(path.dirname(path.dirname(dataPath)), 'sync');
|
|
152
279
|
const snapshotFile = (dataPath) => path.join(syncDir(dataPath), path.basename(dataPath) + '-repo.json');
|
|
153
280
|
const findingsFile = (dataPath) => path.join(syncDir(dataPath), path.basename(dataPath) + '-repo-findings.json');
|
|
154
|
-
const Finding = z.object({ kind: z.enum(['hook', 'config', 'git', 'command']), detail: z.string().max(4096), at: z.string().max(64).optional() });
|
|
281
|
+
const Finding = z.object({ kind: z.enum(['hook', 'config', 'git', 'command', 'agent']), detail: z.string().max(4096), at: z.string().max(64).optional() });
|
|
282
|
+
const GitStateSchema = z.object({ config: z.array(z.string()), hooks: z.record(z.string(), z.string()), redirects: z.array(z.string()) });
|
|
155
283
|
const Saved = z.object({
|
|
156
284
|
workspace: z.string().max(1024).refine((p) => path.isAbsolute(p)),
|
|
157
|
-
snapshot:
|
|
285
|
+
snapshot: GitStateSchema.extend({ gitDir: z.boolean(), commands: z.record(z.string(), z.string()), agent: z.record(z.string(), z.string()).optional(), repos: z.record(z.string(), GitStateSchema).optional() }),
|
|
158
286
|
});
|
|
159
287
|
const Store = z.strictObject({ projects: z.array(Saved).max(4096), roots: z.array(z.string().max(1024).refine((p) => path.isAbsolute(p))).max(64) });
|
|
160
288
|
const StoreAny = z.union([Store, Saved.transform((s) => ({ projects: [s], roots: [] }))]);
|
|
161
289
|
const readStore = (dataPath) => readValid(StoreAny, snapshotFile(dataPath)) ?? { projects: [], roots: [] };
|
|
162
|
-
const EMPTY = { gitDir: false, config: [], hooks: {}, redirects: [], commands: {} };
|
|
290
|
+
const EMPTY = { gitDir: false, config: [], hooks: {}, redirects: [], commands: {}, agent: {}, repos: {} };
|
|
163
291
|
const MAX_ROOT_PROJECTS = 2000;
|
|
164
292
|
/** The folders directly inside a project root (plain folders only; links are never followed). */
|
|
165
293
|
const rootProjects = (root) => {
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { type ChildProcess } from 'node:child_process';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
declare const Life: z.ZodObject<{
|
|
4
|
+
started: z.ZodString;
|
|
5
|
+
lastSeen: z.ZodString;
|
|
6
|
+
keepAwake: z.ZodBoolean;
|
|
7
|
+
ended: z.ZodOptional<z.ZodObject<{
|
|
8
|
+
at: z.ZodString;
|
|
9
|
+
cause: z.ZodNullable<z.ZodObject<{
|
|
10
|
+
kind: z.ZodEnum<{
|
|
11
|
+
restart: "restart";
|
|
12
|
+
updates: "updates";
|
|
13
|
+
crash: "crash";
|
|
14
|
+
sleep: "sleep";
|
|
15
|
+
memory: "memory";
|
|
16
|
+
sandboxCrash: "sandboxCrash";
|
|
17
|
+
}>;
|
|
18
|
+
at: z.ZodString;
|
|
19
|
+
text: z.ZodString;
|
|
20
|
+
}, z.core.$strip>>;
|
|
21
|
+
}, z.core.$strip>>;
|
|
22
|
+
reported: z.ZodOptional<z.ZodBoolean>;
|
|
23
|
+
}, z.core.$strip>;
|
|
24
|
+
type LifeT = z.infer<typeof Life>;
|
|
25
|
+
export declare const readLife: (storageRoot: string, dataPath: string) => LifeT | null;
|
|
26
|
+
/** At launch: a fresh record (the previous one has been reported by then). */
|
|
27
|
+
export declare const startLife: (storageRoot: string, dataPath: string, keepAwake: boolean, now?: Date) => void;
|
|
28
|
+
/** Every minute while the sandbox runs. */
|
|
29
|
+
export declare const beatLife: (storageRoot: string, dataPath: string, now?: Date) => void;
|
|
30
|
+
/** The watcher saw the sandbox close while this PC kept running: closed by you (no cause), or by sleep / low memory. */
|
|
31
|
+
export declare const endLife: (storageRoot: string, dataPath: string, cause: StopCause | null, now?: Date) => void;
|
|
32
|
+
export interface StopEvent {
|
|
33
|
+
at: string;
|
|
34
|
+
id: number;
|
|
35
|
+
provider: string;
|
|
36
|
+
process: string;
|
|
37
|
+
}
|
|
38
|
+
export type StopKind = 'updates' | 'restart' | 'crash' | 'sleep' | 'memory' | 'sandboxCrash';
|
|
39
|
+
export interface StopCause {
|
|
40
|
+
kind: StopKind;
|
|
41
|
+
at: string;
|
|
42
|
+
text: string;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The first event that ends a running sandbox (pure): Windows inside the sandbox crashing (Hyper-V 18590; `process`
|
|
46
|
+
* holds its message with the stop code), a restart or shutdown, a crash or power loss, sleep, low memory. Switching
|
|
47
|
+
* user does not end it (seen on 13 Oct 2026: the sandbox ran on after a switch).
|
|
48
|
+
*/
|
|
49
|
+
export declare const explainStop: (events: StopEvent[]) => StopCause | null;
|
|
50
|
+
/** System log and Hyper-V events since `since` that can end a sandbox (Windows only; empty when the logs cannot be read). */
|
|
51
|
+
export declare const readStopEvents: (since: Date) => StopEvent[];
|
|
52
|
+
/** Looking back from the last time the sandbox was seen running (a minute between checks, plus margin). */
|
|
53
|
+
export declare const causeSince: (lastSeen: string) => StopCause | null;
|
|
54
|
+
/**
|
|
55
|
+
* At launch: one warning when the last sandbox ended without anyone closing it (the PC restarted, crashed, slept or ran
|
|
56
|
+
* out of memory), then never again for that run. `watcherAlive` = its watcher is still running (then it did not end).
|
|
57
|
+
*/
|
|
58
|
+
export declare const lastStopWarning: (storageRoot: string, dataPath: string, watcherAlive: boolean, find?: (lastSeen: string) => StopCause | null) => string | null;
|
|
59
|
+
/** Windows has installed updates and waits to restart: it may do so on its own, outside your active hours. */
|
|
60
|
+
export declare const updateRestartPending: () => boolean;
|
|
61
|
+
export declare const UPDATE_RESTART_WARNING = "Windows is waiting to restart for updates and may do so on its own (often overnight), which closes the sandbox. Restart first, or pause updates: Settings > Windows Update > Pause updates.";
|
|
62
|
+
/**
|
|
63
|
+
* sandbox.keepAwake: asks Windows not to sleep while the sandbox runs (as a video player does; the screen may still turn
|
|
64
|
+
* off). A small PowerShell holds the request until `ownerPid` exits, so it never outlives the watcher.
|
|
65
|
+
*/
|
|
66
|
+
export declare const keepAwake: (ownerPid: number) => ChildProcess | null;
|
|
67
|
+
export {};
|