windows-development-sandbox 1.0.5 → 1.1.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/LICENSE +21 -0
- package/README.md +22 -6
- package/config.example.json +17 -0
- package/dist/api.js +2 -1
- package/dist/cli/args.d.ts +20 -0
- package/dist/cli/args.js +35 -3
- package/dist/cli/colors.d.ts +24 -0
- package/dist/cli/colors.js +42 -0
- package/dist/cli/commands/agent-commands.d.ts +6 -0
- package/dist/cli/commands/agent-commands.js +61 -0
- package/dist/cli/commands/context.d.ts +2 -0
- package/dist/cli/commands/context.js +3 -0
- package/dist/cli/commands/data/clean.d.ts +15 -0
- package/dist/cli/commands/data/clean.js +101 -0
- package/dist/cli/commands/data/collect.d.ts +11 -0
- package/dist/cli/commands/data/collect.js +54 -0
- package/dist/cli/commands/data/index.d.ts +4 -0
- package/dist/cli/commands/data/index.js +45 -0
- package/dist/cli/commands/data/remove.d.ts +2 -0
- package/dist/cli/commands/data/remove.js +30 -0
- package/dist/cli/commands/index.d.ts +5 -3
- package/dist/cli/commands/index.js +5 -3
- package/dist/cli/commands/launch.d.ts +26 -2
- package/dist/cli/commands/launch.js +220 -39
- package/dist/cli/commands/maintenance.js +33 -4
- package/dist/cli/commands/project.d.ts +29 -0
- package/dist/cli/commands/project.js +63 -0
- package/dist/cli/commands/projects.d.ts +14 -0
- package/dist/cli/commands/projects.js +146 -0
- package/dist/cli/commands/repair/checks.d.ts +2 -0
- package/dist/cli/commands/{repair.js → repair/checks.js} +19 -181
- package/dist/cli/commands/{repair.d.ts → repair/finders.d.ts} +0 -29
- package/dist/cli/commands/repair/finders.js +108 -0
- package/dist/cli/commands/repair/index.d.ts +12 -0
- package/dist/cli/commands/repair/index.js +61 -0
- package/dist/cli/commands/repair/types.d.ts +26 -0
- package/dist/cli/commands/repair/types.js +3 -0
- package/dist/cli/commands/restart.d.ts +6 -0
- package/dist/cli/commands/restart.js +85 -0
- package/dist/cli/commands/sessions.d.ts +2 -1
- package/dist/cli/commands/sessions.js +189 -45
- package/dist/cli/commands/setup/checks.d.ts +13 -0
- package/dist/cli/commands/setup/checks.js +71 -0
- package/dist/cli/commands/{setup.d.ts → setup/facts.d.ts} +1 -15
- package/dist/cli/commands/setup/facts.js +60 -0
- package/dist/cli/commands/setup/index.d.ts +2 -0
- package/dist/cli/commands/setup/index.js +43 -0
- package/dist/cli/commands/user-config.d.ts +8 -0
- package/dist/cli/commands/user-config.js +41 -0
- package/dist/cli/history.d.ts +11 -0
- package/dist/cli/history.js +21 -0
- package/dist/cli/index.d.ts +7 -1
- package/dist/cli/index.js +38 -13
- package/dist/cli/manifest.d.ts +8 -0
- package/dist/cli/manifest.js +35 -15
- package/dist/cli/menu-multi.d.ts +12 -0
- package/dist/cli/menu-multi.js +82 -0
- package/dist/cli/menu.d.ts +5 -13
- package/dist/cli/menu.js +149 -46
- package/dist/cli/progress.d.ts +9 -0
- package/dist/cli/progress.js +49 -0
- package/dist/cli/settings/edit.d.ts +13 -0
- package/dist/cli/settings/edit.js +19 -0
- package/dist/cli/settings/index.d.ts +8 -0
- package/dist/cli/settings/index.js +131 -0
- package/dist/cli/settings/prompts.d.ts +7 -0
- package/dist/cli/settings/prompts.js +48 -0
- package/dist/cli/settings/schema.d.ts +33 -0
- package/dist/cli/settings/schema.js +29 -0
- package/dist/cli/validate.d.ts +17 -0
- package/dist/cli/validate.js +89 -0
- package/dist/core/config.js +4 -1
- package/dist/core/disk.d.ts +16 -0
- package/dist/core/disk.js +55 -0
- package/dist/core/local-config.d.ts +8 -0
- package/dist/core/local-config.js +18 -5
- package/dist/core/project-policy.d.ts +15 -0
- package/dist/core/project-policy.js +35 -0
- package/dist/core/prompt.d.ts +4 -0
- package/dist/core/prompt.js +15 -0
- package/dist/core/safe-paths.d.ts +4 -0
- package/dist/core/safe-paths.js +37 -0
- package/dist/core/schema/common.d.ts +17 -0
- package/dist/core/schema/common.js +15 -0
- package/dist/core/schema/config.d.ts +594 -0
- package/dist/core/schema/config.js +63 -0
- package/dist/core/schema/databases.d.ts +33 -0
- package/dist/core/schema/databases.js +35 -0
- package/dist/core/schema/harness.d.ts +12 -0
- package/dist/core/schema/harness.js +17 -0
- package/dist/core/schema/json-schema.d.ts +5 -0
- package/dist/core/schema/json-schema.js +25 -0
- package/dist/core/schema/profile.d.ts +27 -0
- package/dist/core/schema/profile.js +29 -0
- package/dist/core/schema/projects.d.ts +9 -0
- package/dist/core/schema/projects.js +11 -0
- package/dist/core/schema/sandbox.d.ts +50 -0
- package/dist/core/schema/sandbox.js +36 -0
- package/dist/core/schema/toolchains.d.ts +93 -0
- package/dist/core/schema/toolchains.js +83 -0
- package/dist/core/schema.d.ts +6 -604
- package/dist/core/schema.js +8 -264
- package/dist/core/types.d.ts +29 -1
- package/dist/core/util.d.ts +7 -0
- package/dist/core/util.js +12 -3
- package/dist/core/workspace.d.ts +0 -5
- package/dist/core/workspace.js +12 -46
- package/dist/guest/boot-env.d.ts +4 -0
- package/dist/guest/boot-env.js +25 -0
- package/dist/guest/cmdline.d.ts +12 -0
- package/dist/guest/cmdline.js +23 -0
- package/dist/guest/daemon/exec.d.ts +5 -0
- package/dist/guest/daemon/exec.js +32 -0
- package/dist/guest/daemon/project.d.ts +14 -0
- package/dist/guest/daemon/project.js +132 -0
- package/dist/guest/daemon/session.d.ts +13 -0
- package/dist/guest/daemon/session.js +139 -0
- package/dist/guest/daemon/shutdown.d.ts +1 -0
- package/dist/guest/daemon/shutdown.js +21 -0
- package/dist/guest/daemon/state.d.ts +17 -0
- package/dist/guest/daemon/state.js +31 -0
- package/dist/guest/daemon.js +8 -141
- package/dist/guest/db.js +18 -3
- package/dist/guest/env.d.ts +2 -0
- package/dist/guest/env.js +4 -2
- package/dist/guest/guide.d.ts +6 -2
- package/dist/guest/guide.js +5 -4
- package/dist/guest/helpers.d.ts +3 -0
- package/dist/guest/helpers.js +26 -0
- package/dist/guest/main.js +17 -7
- package/dist/guest/ports.d.ts +6 -0
- package/dist/guest/ports.js +22 -0
- package/dist/guest/project-context.d.ts +7 -0
- package/dist/guest/project-context.js +10 -0
- package/dist/guest/project-env.d.ts +20 -0
- package/dist/guest/project-env.js +20 -0
- package/dist/guest/project-status.d.ts +5 -0
- package/dist/guest/project-status.js +17 -0
- package/dist/guest/ptyhost.d.ts +0 -18
- package/dist/guest/ptyhost.js +5 -63
- package/dist/guest/setup/agents.d.ts +5 -2
- package/dist/guest/setup/agents.js +22 -8
- package/dist/guest/setup/claude.js +11 -3
- package/dist/guest/setup/environment.d.ts +13 -3
- package/dist/guest/setup/environment.js +14 -10
- package/dist/guest/setup/hooks.d.ts +9 -1
- package/dist/guest/setup/hooks.js +28 -14
- package/dist/guest/toolchains/catalogue.d.ts +4 -0
- package/dist/guest/toolchains/catalogue.js +2 -2
- package/dist/guest/toolchains/cli.js +25 -17
- package/dist/guest/toolchains/common.d.ts +18 -1
- package/dist/guest/toolchains/common.js +34 -11
- package/dist/guest/toolchains/databases/dynamodb.d.ts +8 -0
- package/dist/guest/toolchains/databases/dynamodb.js +52 -0
- package/dist/guest/toolchains/{databases.d.ts → databases/index.d.ts} +2 -3
- package/dist/guest/toolchains/databases/index.js +62 -0
- package/dist/guest/toolchains/databases/mongodb.d.ts +3 -0
- package/dist/guest/toolchains/databases/mongodb.js +31 -0
- package/dist/guest/toolchains/databases/mysql.d.ts +3 -0
- package/dist/guest/toolchains/databases/mysql.js +38 -0
- package/dist/guest/toolchains/databases/postgres.d.ts +5 -0
- package/dist/guest/toolchains/databases/postgres.js +38 -0
- package/dist/guest/toolchains/databases/redis.d.ts +3 -0
- package/dist/guest/toolchains/databases/redis.js +16 -0
- package/dist/guest/toolchains/databases/server.d.ts +14 -0
- package/dist/guest/toolchains/databases/server.js +52 -0
- package/dist/guest/toolchains/git.js +25 -16
- package/dist/guest/toolchains/go.js +1 -1
- package/dist/guest/toolchains/index.d.ts +13 -9
- package/dist/guest/toolchains/index.js +40 -82
- package/dist/guest/toolchains/java.js +5 -5
- package/dist/guest/toolchains/leftovers.d.ts +2 -0
- package/dist/guest/toolchains/leftovers.js +26 -0
- package/dist/guest/toolchains/managers.js +12 -10
- package/dist/guest/toolchains/{binstall.js → native/binstall.js} +6 -9
- package/dist/guest/toolchains/native/cpp.d.ts +4 -0
- package/dist/guest/toolchains/native/cpp.js +38 -0
- package/dist/guest/toolchains/native/dotnet.d.ts +4 -0
- package/dist/guest/toolchains/native/dotnet.js +103 -0
- package/dist/guest/toolchains/native/mesa.d.ts +7 -0
- package/dist/guest/toolchains/native/mesa.js +46 -0
- package/dist/guest/toolchains/native/msvc/environment.d.ts +16 -0
- package/dist/guest/toolchains/native/msvc/environment.js +57 -0
- package/dist/guest/toolchains/native/msvc/index.d.ts +6 -0
- package/dist/guest/toolchains/native/msvc/index.js +48 -0
- package/dist/guest/toolchains/native/msvc/packages.d.ts +6 -0
- package/dist/guest/toolchains/native/msvc/packages.js +156 -0
- package/dist/guest/toolchains/native/msvc/tree.d.ts +15 -0
- package/dist/guest/toolchains/native/msvc/tree.js +21 -0
- package/dist/guest/toolchains/native/rust.d.ts +3 -0
- package/dist/guest/toolchains/native/rust.js +45 -0
- package/dist/guest/toolchains/node.js +26 -6
- package/dist/guest/toolchains/plan.d.ts +22 -0
- package/dist/guest/toolchains/plan.js +96 -0
- package/dist/guest/toolchains/pwsh.js +1 -1
- package/dist/guest/toolchains/python.js +17 -10
- package/dist/guest/toolchains/runtimes.d.ts +2 -0
- package/dist/guest/toolchains/runtimes.js +6 -8
- package/dist/guest/toolchains/scripting/php.d.ts +3 -0
- package/dist/guest/toolchains/scripting/php.js +71 -0
- package/dist/guest/toolchains/scripting/ruby.d.ts +3 -0
- package/dist/guest/toolchains/scripting/ruby.js +47 -0
- package/dist/guest/toolchains/scripting/vcruntime.d.ts +7 -0
- package/dist/guest/toolchains/scripting/vcruntime.js +45 -0
- package/dist/guest/view.js +21 -1
- package/dist/host/accounts.js +14 -5
- package/dist/host/agent-commands.d.ts +25 -0
- package/dist/host/agent-commands.js +145 -0
- package/dist/host/attach.d.ts +17 -0
- package/dist/host/attach.js +69 -0
- package/dist/host/channel.d.ts +23 -22
- package/dist/host/channel.js +66 -84
- package/dist/host/claude-profile.js +7 -3
- package/dist/host/cleanup.js +2 -11
- package/dist/host/database-plan.d.ts +12 -0
- package/dist/host/database-plan.js +52 -0
- package/dist/host/generate.d.ts +4 -12
- package/dist/host/generate.js +48 -135
- package/dist/host/join.d.ts +28 -0
- package/dist/host/join.js +76 -0
- package/dist/host/launch/mappings.d.ts +3 -0
- package/dist/host/launch/mappings.js +50 -0
- package/dist/host/launch/projects.d.ts +15 -0
- package/dist/host/launch/projects.js +56 -0
- package/dist/host/launch/stage.d.ts +11 -0
- package/dist/host/launch/stage.js +66 -0
- package/dist/host/launch/wsb.d.ts +10 -0
- package/dist/host/launch/wsb.js +34 -0
- package/dist/host/pc-scan.d.ts +6 -0
- package/dist/host/pc-scan.js +37 -0
- package/dist/host/platform.d.ts +2 -0
- package/dist/host/platform.js +13 -1
- package/dist/host/project-scan.d.ts +1 -17
- package/dist/host/project-scan.js +2 -86
- package/dist/host/projects.d.ts +100 -0
- package/dist/host/projects.js +147 -0
- package/dist/host/repo-guard.d.ts +15 -2
- package/dist/host/repo-guard.js +103 -12
- package/dist/host/terminal.js +3 -0
- package/dist/host/test-guard.d.ts +6 -0
- package/dist/host/test-guard.js +15 -0
- package/dist/host/window.js +7 -3
- package/dist/host/wsb.d.ts +37 -0
- package/dist/host/wsb.js +101 -0
- package/dist/runtime/daemon.js +10997 -2794
- package/dist/runtime/db.js +6428 -464
- package/dist/runtime/main.js +7359 -1135
- package/dist/runtime/view.js +5922 -47
- package/dist/selftest/accounts.test.d.ts +2 -0
- package/dist/selftest/accounts.test.js +44 -0
- package/dist/selftest/agent-commands.test.d.ts +2 -0
- package/dist/selftest/agent-commands.test.js +47 -0
- package/dist/selftest/channel.test.d.ts +2 -0
- package/dist/selftest/channel.test.js +37 -0
- package/dist/selftest/cli.test.d.ts +2 -2
- package/dist/selftest/cli.test.js +115 -27
- package/dist/selftest/cmdline.test.d.ts +2 -0
- package/dist/selftest/cmdline.test.js +45 -0
- package/dist/selftest/config.test.d.ts +2 -3
- package/dist/selftest/config.test.js +36 -72
- package/dist/selftest/data.test.d.ts +2 -2
- package/dist/selftest/data.test.js +77 -70
- package/dist/selftest/detect.test.d.ts +2 -2
- package/dist/selftest/detect.test.js +54 -38
- package/dist/selftest/fixtures.d.ts +44 -4
- package/dist/selftest/fixtures.js +40 -23
- package/dist/selftest/generate.test.d.ts +2 -0
- package/dist/selftest/generate.test.js +63 -0
- package/dist/selftest/git-access.test.d.ts +2 -0
- package/dist/selftest/git-access.test.js +26 -0
- package/dist/selftest/guest-boot.test.d.ts +2 -0
- package/dist/selftest/guest-boot.test.js +76 -0
- package/dist/selftest/harness.d.ts +44 -7
- package/dist/selftest/harness.js +62 -21
- package/dist/selftest/host-sim.test.d.ts +2 -0
- package/dist/selftest/host-sim.test.js +93 -0
- package/dist/selftest/hostile-input.test.d.ts +2 -0
- package/dist/selftest/hostile-input.test.js +54 -0
- package/dist/selftest/index.d.ts +8 -1
- package/dist/selftest/index.js +134 -35
- package/dist/selftest/links.test.d.ts +2 -0
- package/dist/selftest/links.test.js +50 -0
- package/dist/selftest/live-daemon.test.d.ts +2 -0
- package/dist/selftest/live-daemon.test.js +91 -0
- package/dist/selftest/live-git.test.d.ts +2 -0
- package/dist/selftest/live-git.test.js +60 -0
- package/dist/selftest/live-projects.test.d.ts +2 -0
- package/dist/selftest/live-projects.test.js +127 -0
- package/dist/selftest/live.d.ts +65 -0
- package/dist/selftest/live.js +109 -0
- package/dist/selftest/local-config.test.d.ts +2 -0
- package/dist/selftest/local-config.test.js +107 -0
- package/dist/selftest/mirror.test.d.ts +2 -3
- package/dist/selftest/mirror.test.js +92 -65
- package/dist/selftest/network.test.d.ts +2 -0
- package/dist/selftest/network.test.js +28 -0
- package/dist/selftest/personalization.test.d.ts +2 -0
- package/dist/selftest/personalization.test.js +53 -0
- package/dist/selftest/projects.test.d.ts +2 -0
- package/dist/selftest/projects.test.js +254 -0
- package/dist/selftest/repair.test.d.ts +2 -0
- package/dist/selftest/repair.test.js +42 -0
- package/dist/selftest/repo-guard.test.d.ts +2 -0
- package/dist/selftest/repo-guard.test.js +63 -0
- package/dist/selftest/secrets.test.d.ts +2 -0
- package/dist/selftest/secrets.test.js +71 -0
- package/dist/selftest/setup.test.d.ts +2 -2
- package/dist/selftest/setup.test.js +45 -22
- package/dist/selftest/suite.d.ts +13 -0
- package/dist/selftest/suite.js +1 -0
- package/dist/selftest/sync.test.d.ts +2 -3
- package/dist/selftest/sync.test.js +90 -78
- package/dist/selftest/test-guard.test.d.ts +2 -0
- package/dist/selftest/test-guard.test.js +49 -0
- package/dist/selftest/toolchains.test.d.ts +2 -0
- package/dist/selftest/toolchains.test.js +103 -0
- package/dist/selftest/workspace.test.d.ts +2 -0
- package/dist/selftest/workspace.test.js +53 -0
- package/dist/shared/download.d.ts +3 -1
- package/dist/shared/download.js +19 -4
- package/dist/shared/paths.d.ts +5 -0
- package/dist/shared/paths.js +6 -0
- package/dist/shared/projects.d.ts +53 -0
- package/dist/shared/projects.js +67 -0
- package/dist/shared/rundir.d.ts +10 -1
- package/dist/shared/rundir.js +12 -4
- package/dist/shared/safe-remove.js +11 -0
- package/dist/shared/schema.d.ts +443 -0
- package/dist/shared/schema.js +41 -2
- package/dist/shared/test-guard.d.ts +4 -0
- package/dist/shared/test-guard.js +37 -0
- package/dist/sync/claude-mem.js +3 -1
- package/dist/sync/history.d.ts +23 -12
- package/dist/sync/history.js +52 -20
- package/dist/sync/watcher.d.ts +8 -0
- package/dist/sync/watcher.js +23 -4
- package/package.json +6 -6
- package/sandbox/bootstrap.ps1 +258 -258
- package/sandbox/cpucap.ps1 +51 -0
- package/sandbox/logon.cmd +18 -16
- package/sandbox/openssl.cs +42 -0
- package/sandbox/ptyhost.cs +15 -4
- package/sandbox/trust.cmd +35 -35
- package/schemas/config.schema.json +100 -1
- package/schemas/sandbox-config.schema.json +17 -1
- package/dist/cli/commands/data.d.ts +0 -46
- package/dist/cli/commands/data.js +0 -249
- package/dist/cli/commands/setup.js +0 -152
- package/dist/cli/settings.d.ts +0 -11
- package/dist/cli/settings.js +0 -156
- package/dist/guest/toolchains/databases.js +0 -226
- package/dist/guest/toolchains/msvc.d.ts +0 -10
- package/dist/guest/toolchains/msvc.js +0 -216
- package/dist/guest/toolchains/native.d.ts +0 -12
- package/dist/guest/toolchains/native.js +0 -220
- package/dist/guest/toolchains/scripting.d.ts +0 -10
- package/dist/guest/toolchains/scripting.js +0 -137
- package/dist/selftest/guest.test.d.ts +0 -3
- package/dist/selftest/guest.test.js +0 -61
- package/dist/selftest/helpers.test.d.ts +0 -2
- package/dist/selftest/helpers.test.js +0 -125
- package/dist/selftest/host.test.d.ts +0 -2
- package/dist/selftest/host.test.js +0 -71
- package/dist/selftest/live.test.d.ts +0 -7
- package/dist/selftest/live.test.js +0 -127
- package/dist/selftest/security.test.d.ts +0 -3
- package/dist/selftest/security.test.js +0 -143
- /package/dist/guest/toolchains/{binstall.d.ts → native/binstall.d.ts} +0 -0
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
// Several projects in one sandbox: guest paths, the safety of what gets mapped, joining a running sandbox (roots,
|
|
2
|
+
// wsb share, the restart fallback), the project spec sent to the guest, tighten-only protections, the per-project
|
|
3
|
+
// environment, history sync and the repo guard for every project. Offline: temp folders only.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { LocalOverrides } from '../core/schema.js';
|
|
7
|
+
import { checkJoin, protectionsOf } from '../core/project-policy.js';
|
|
8
|
+
import { sessionsFirstIn } from '../host/channel.js';
|
|
9
|
+
import { buildWsb } from '../host/launch/wsb.js';
|
|
10
|
+
import { projectSpec, joinRunning } from '../host/join.js';
|
|
11
|
+
import { allProjectsToMap, forgetProject, NeedsRestart, projectHere, projectMapper, readKnown, readRegistry, rememberProject, startRegistry } from '../host/projects.js';
|
|
12
|
+
import { checkRepo, saveRepoSnapshot } from '../host/repo-guard.js';
|
|
13
|
+
import { findWsb, parseCommands, parseIp, parseList, parseVersion } from '../host/wsb.js';
|
|
14
|
+
import { baseEnvFor } from '../guest/project-env.js';
|
|
15
|
+
import { guestDirFor, hostDirFor, hubName, insideProjects, isBelow, isWithin, planProjectMappings, projectForGuestDir, projectForHostDir, PROJECTS_GUEST } from '../shared/projects.js';
|
|
16
|
+
import { GuestRequest, ProjectSpec } from '../shared/schema.js';
|
|
17
|
+
import { projectFolderName } from '../shared/paths.js';
|
|
18
|
+
import { syncHistoryOut } from '../sync/history.js';
|
|
19
|
+
import { validateOptions, validateProjectsArgs } from '../cli/validate.js';
|
|
20
|
+
import { parseCli, parseSessionWant } from '../cli/args.js';
|
|
21
|
+
import { candidateFolders, severalAgentsArgv } from '../cli/menu-multi.js';
|
|
22
|
+
import { generateSandbox } from '../host/generate.js';
|
|
23
|
+
import { askedWhen } from '../cli/manifest.js';
|
|
24
|
+
import { describeSessions } from '../cli/commands/restart.js';
|
|
25
|
+
/** The error `fn` throws, or null. */
|
|
26
|
+
const caught = (fn) => { try {
|
|
27
|
+
fn();
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
catch (e) {
|
|
31
|
+
return e;
|
|
32
|
+
} };
|
|
33
|
+
export const projectsSuite = {
|
|
34
|
+
name: 'projects',
|
|
35
|
+
title: 'several projects in one sandbox (paths, joining, policy, specs, --session)',
|
|
36
|
+
run: async (t, f) => {
|
|
37
|
+
t.section('projects: guest paths');
|
|
38
|
+
t.check(isWithin('C:\\code\\a', 'c:\\CODE\\a\\src') && isWithin('C:\\code\\a\\', 'C:\\code\\a'), 'isWithin: case-insensitive, trailing slashes ignored');
|
|
39
|
+
t.check(!isWithin('C:\\code\\a', 'C:\\code\\ab') && !isWithin('C:\\code\\a', 'C:\\code'), 'isWithin: no prefix matches (a vs ab) and no parents');
|
|
40
|
+
t.equal(hubName('F:\\Code\\my app (v2)', []), 'my-app-v2', 'project ids are file-name safe');
|
|
41
|
+
t.equal(hubName('F:\\Code\\api', ['API', 'api-2']), 'api-3', 'project ids are unique (case-insensitive)');
|
|
42
|
+
t.equal(hubName('F:\\Code\\...', []), 'project', 'project ids are never empty');
|
|
43
|
+
t.equal(hubName('F:\\' + 'x'.repeat(80), []).length, 48, 'project ids are capped at 48 characters');
|
|
44
|
+
const projects = [{ host: 'F:\\Code', guest: 'C:\\Projects\\Code' }, { host: 'F:\\Code\\api', guest: 'C:\\Projects\\api' }];
|
|
45
|
+
t.equal(projectForHostDir(projects, 'f:\\code\\api\\src')?.guest, 'C:\\Projects\\api', 'the most specific mapped folder wins');
|
|
46
|
+
t.equal(projectForHostDir(projects, 'F:\\Code\\web')?.guest, 'C:\\Projects\\Code', 'a folder inside a mapped folder belongs to it');
|
|
47
|
+
t.equal(projectForHostDir(projects, 'F:\\Other'), null, 'unrelated folders belong to no project');
|
|
48
|
+
t.equal(projectForGuestDir(projects, 'C:\\Projects\\api\\x')?.host, 'F:\\Code\\api', 'the same works from inside the sandbox');
|
|
49
|
+
const mapped = { host: 'F:\\Code', guest: 'C:\\Projects\\Code' };
|
|
50
|
+
const webGuest = guestDirFor(mapped, 'F:\\Code\\web\\src');
|
|
51
|
+
t.equal(webGuest, 'C:\\Projects\\Code\\web\\src', 'host folder -> guest folder');
|
|
52
|
+
t.equal(hostDirFor(mapped, webGuest), 'F:\\Code\\web\\src', 'guest folder -> host folder (round trip)');
|
|
53
|
+
t.equal(guestDirFor(mapped, 'F:\\Code'), 'C:\\Projects\\Code', 'the mapped folder itself translates');
|
|
54
|
+
t.check(insideProjects('C:\\Projects\\api') && insideProjects('c:\\projects\\api\\src'), 'C:\\Projects\\<name> holds projects');
|
|
55
|
+
t.check(!insideProjects('C:\\Projects') && !insideProjects('C:\\ProjectsX\\a') && !insideProjects('C:\\Windows'), 'C:\\Projects itself and look-alikes do not');
|
|
56
|
+
t.check(insideProjects('F:\\Code\\web', ['F:\\Code']) && !insideProjects('F:\\Other', ['F:\\Code']), 'a mirrored root counts too');
|
|
57
|
+
t.check(isBelow('C:\\a', 'C:\\a\\b') && !isBelow('C:\\a', 'C:\\a'), 'isBelow is strict');
|
|
58
|
+
const plan = planProjectMappings(['F:\\Code', 'E:\\Work\\Code', 'C:\\Users\\me\\src'], ['D:\\x\\api', 'D:\\y\\api'], false);
|
|
59
|
+
t.equal(plan.roots.map((r) => r.guest), ['C:\\Projects\\Code', 'C:\\Projects\\Code-2', 'C:\\Projects\\src'], 'roots get unique C:\\Projects\\<name> folders');
|
|
60
|
+
t.equal(plan.projects.map((p) => p.guest), ['C:\\Projects\\api', 'C:\\Projects\\api-2'], 'projects get unique C:\\Projects\\<name> folders');
|
|
61
|
+
const mirror = planProjectMappings(['F:\\Code', 'C:\\Users\\me\\src'], [], true);
|
|
62
|
+
t.equal(mirror.roots.map((r) => r.guest), ['F:\\Code', 'C:\\Projects\\src'], 'with "mirror" a root appears at its PC path when the sandbox can show it there (C:\\Users\\... cannot)');
|
|
63
|
+
t.section('projects: tighten-only protections when joining a running sandbox');
|
|
64
|
+
const base = protectionsOf(f.baseConfig);
|
|
65
|
+
const same = checkJoin(base, base);
|
|
66
|
+
t.equal({ stricter: same.stricter, looser: same.looser }, { stricter: [], looser: [] }, 'the same settings join without notes');
|
|
67
|
+
const readonlyGit = protectionsOf({ ...f.baseConfig, provisioning: { ...f.baseConfig.provisioning, git: { ...f.baseConfig.provisioning.git, access: 'readonly' } } });
|
|
68
|
+
t.check(checkJoin(base, readonlyGit).stricter.some((s) => s.startsWith('provisioning.git.access')), 'a project needing stricter git access than the running sandbox is refused (git access is machine-wide)');
|
|
69
|
+
const loosening = checkJoin(readonlyGit, base);
|
|
70
|
+
t.equal(loosening.stricter.length, 0, 'a project asking for less git access is not refused...');
|
|
71
|
+
t.check(loosening.looser.some((s) => s.includes('provisioning.git.access stays "readonly"')), '... it gets the sandbox\'s stricter value, with a note: never loosened', JSON.stringify(loosening.looser));
|
|
72
|
+
const offline = protectionsOf({ ...f.baseConfig, sandbox: { ...f.baseConfig.sandbox, networking: false } });
|
|
73
|
+
t.check(checkJoin(base, offline).stricter.some((s) => s.startsWith('sandbox.networking')), 'networking off is stricter than a sandbox with networking');
|
|
74
|
+
t.check(checkJoin(offline, base).looser.some((s) => s.startsWith('sandbox.networking')), 'networking on is looser than an offline sandbox');
|
|
75
|
+
const readOnly = protectionsOf({ ...f.baseConfig, readOnly: true });
|
|
76
|
+
t.check(checkJoin(readOnly, readOnly).stricter.some((s) => s.startsWith('readOnly')), 'a read-only project cannot join (other projects are mapped read/write), even when the launch folder was read-only');
|
|
77
|
+
t.check(!LocalOverrides.safeParse({ projects: { roots: ['C:\\'] } }).success, 'a project\'s .sandbox-config.json can never set projects.roots (they map folders of this PC)');
|
|
78
|
+
t.check(!LocalOverrides.safeParse({ projects: { all: true } }).success, 'a project\'s .sandbox-config.json can never set projects.all');
|
|
79
|
+
// A project file that loosens git access is clamped by keepStrictest first, then joins without being refused.
|
|
80
|
+
const strictUser = structuredClone(f.baseConfig);
|
|
81
|
+
strictUser.provisioning.git.access = 'readonly';
|
|
82
|
+
const looseDir = t.dir('proj-loose');
|
|
83
|
+
f.writeLocal(looseDir, { provisioning: { git: { access: 'full' } } });
|
|
84
|
+
const loose = await f.run({ cwd: looseDir, trustConfig: true }, strictUser);
|
|
85
|
+
t.equal(loose.config.provisioning.git.access, 'readonly', 'tighten-only: a project file cannot loosen git access');
|
|
86
|
+
t.equal(checkJoin(protectionsOf(strictUser), protectionsOf(loose.config)).stricter, [], 'tighten-only: so it joins a readonly sandbox as readonly');
|
|
87
|
+
const tightDir = t.dir('proj-tight');
|
|
88
|
+
f.writeLocal(tightDir, { provisioning: { git: { access: 'none' } } });
|
|
89
|
+
const tight = await f.run({ cwd: tightDir, trustConfig: true });
|
|
90
|
+
t.section('projects: registry and joining');
|
|
91
|
+
const storage = t.at('proj-storage');
|
|
92
|
+
const data = path.join(storage, 'data', 'testdata');
|
|
93
|
+
t.dir('proj-storage', 'data', 'testdata');
|
|
94
|
+
const root = t.at('proj-root');
|
|
95
|
+
const inRoot = t.dir('proj-root', 'web');
|
|
96
|
+
const outside = t.dir('proj-outside');
|
|
97
|
+
const primary = startRegistry(storage, { launchId: '1700000000000-abcdef', protections: base, primary: { host: f.workA, guest: 'C:\\Workspace\\work-a' }, roots: [{ host: root, guest: 'C:\\Projects\\proj-root' }], boot: [{ host: f.workB, guest: 'C:\\Projects\\work-b' }] });
|
|
98
|
+
const registry = readRegistry(storage);
|
|
99
|
+
t.check(primary.primary, 'at launch the launch folder is the primary project');
|
|
100
|
+
t.equal(registry?.projects.length, 2, 'at launch the registry holds the launch folder and the projects mapped with it');
|
|
101
|
+
t.equal({ mechanism: registry?.projects[1].mechanism, name: registry?.projects[1].name }, { mechanism: 'boot', name: 'work-b' }, 'a project mapped at boot is registered by its mechanism and name');
|
|
102
|
+
t.equal(projectHere(storage, path.join(f.workA, 'src'))?.guestDir, 'C:\\Workspace\\work-a\\src', 'projectHere: a folder inside the launch folder');
|
|
103
|
+
t.equal(projectHere(storage, f.workB)?.project.mechanism, 'boot', 'projectHere: a project mapped at boot');
|
|
104
|
+
t.equal(projectHere(storage, outside), null, 'projectHere: a folder in no project');
|
|
105
|
+
const noWsb = projectMapper({ storageRoot: storage, packageRoot: f.install, wsbShare: true, detect: () => null });
|
|
106
|
+
const web = noWsb.register(inRoot);
|
|
107
|
+
t.equal({ mechanism: web.mechanism, guest: web.guest }, { mechanism: 'root', guest: 'C:\\Projects\\proj-root\\web' }, 'a folder inside a root joins at once (also one created after boot)');
|
|
108
|
+
t.equal(noWsb.register(inRoot).name, web.name, 'registering the same folder again gives the same project');
|
|
109
|
+
t.equal(readRegistry(storage)?.projects.filter((p) => p.host === inRoot).length, 1, 'a folder is registered once');
|
|
110
|
+
const needs = caught(() => noWsb.register(outside));
|
|
111
|
+
t.check(needs instanceof NeedsRestart && needs.reason.includes('wsb share needs Windows 11 24H2'), 'a folder outside every root without the Windows Sandbox CLI needs a restart (and says why)');
|
|
112
|
+
const shareOff = caught(() => projectMapper({ storageRoot: storage, packageRoot: f.install, wsbShare: false }).register(outside));
|
|
113
|
+
t.check(shareOff instanceof NeedsRestart && shareOff.reason.includes('projects.wsbShare'), 'projects.wsbShare off goes straight to the restart offer');
|
|
114
|
+
const fakeWsb = { exe: t.at('no-such-wsb.exe'), version: '0.1.0', commands: ['share', 'list'], has: (c) => c === 'share' || c === 'list' };
|
|
115
|
+
const listFails = caught(() => projectMapper({ storageRoot: storage, packageRoot: f.install, wsbShare: true, detect: () => fakeWsb }).register(outside));
|
|
116
|
+
t.check(listFails instanceof NeedsRestart && listFails.reason.includes('wsb share could not find the running sandbox'), 'when wsb cannot tell which sandbox is ours, it falls back to the restart offer with the reason');
|
|
117
|
+
await t.throws(() => noWsb.register(storage), 'cannot be the workspace', 'joining uses the launch-folder safety checks: not the storage folder');
|
|
118
|
+
await t.throws(() => noWsb.register('C:\\'), 'whole drive', 'joining uses the launch-folder safety checks: not a drive root');
|
|
119
|
+
await t.throws(() => noWsb.unregister(primary.name), 'stays until the sandbox stops', 'the launch folder cannot be removed from a running sandbox');
|
|
120
|
+
// joinRunning: resolves the folder, checks protections, registers it, guards it, and builds the spec for the guest.
|
|
121
|
+
const resolveFor = (run) => async () => run;
|
|
122
|
+
const join = (hostDir, resolve) => joinRunning({ storageRoot: storage, dataPath: data, packageRoot: f.install, hostDir, wsbShare: false, resolve });
|
|
123
|
+
const webRun = await f.run({ cwd: inRoot, trustConfig: true });
|
|
124
|
+
const joined = await join(inRoot, resolveFor(webRun));
|
|
125
|
+
t.equal(joined.spec?.workspace, 'C:\\Projects\\proj-root\\web', 'joining a project builds a spec with its guest folder...');
|
|
126
|
+
t.check(ProjectSpec.safeParse(joined.spec).success && joined.spec?.harnesses.claude !== undefined, '... that is valid and carries the agent');
|
|
127
|
+
t.check(readKnown(storage).some((k) => k.host === inRoot), 'joined projects are remembered (projects.all, sandbox projects)');
|
|
128
|
+
const launchFolder = await join(path.join(f.workA, 'sub'), () => Promise.reject(new Error('must not resolve')));
|
|
129
|
+
t.equal(launchFolder.spec, null, 'the launch folder (and below) needs no spec: its sessions use the boot environment');
|
|
130
|
+
await t.throws(() => join(tightDir, resolveFor(tight)), 'stricter protection than the running sandbox', 'a project whose .sandbox-config.json tightens git access beyond the running sandbox is refused, with the reason');
|
|
131
|
+
t.check(readRegistry(storage)?.projects.every((p) => p.host !== tightDir) === true, 'a refused project is not registered');
|
|
132
|
+
const outsideRun = await f.run({ cwd: outside });
|
|
133
|
+
await t.throws(() => join(outside, resolveFor(outsideRun)), 'projects.wsbShare', 'a folder that cannot be shown lets NeedsRestart reach the caller');
|
|
134
|
+
t.section('projects: the spec and request the guest gets');
|
|
135
|
+
const spec = projectSpec(webRun, web, false);
|
|
136
|
+
const sessionRequest = (extra) => GuestRequest.safeParse({ kind: 'session', id: 'r1', harness: 'claude', args: [], cwd: spec.workspace, createdAt: new Date().toISOString(), ...extra });
|
|
137
|
+
t.check(sessionRequest({ project: spec }).success, 'a session request carries a project spec');
|
|
138
|
+
t.check(sessionRequest({ project: spec, harnessSpec: f.baseConfig.harnesses.codex }).success, 'a session request carries an agent definition');
|
|
139
|
+
t.check(!sessionRequest({ project: { ...spec, name: '..\\evil' } }).success, 'specs are validated: project ids are file-name safe');
|
|
140
|
+
t.check(!sessionRequest({ project: { ...spec, extra: 1 } }).success, 'specs are validated: no unknown keys');
|
|
141
|
+
const manyDatabases = Array(9).fill(spec.databases[0] ?? { database: 'redis', reasons: [], version: 'x', port: 6379 });
|
|
142
|
+
t.check(!sessionRequest({ project: { ...spec, databases: manyDatabases } }).success, 'specs are validated: lists are capped');
|
|
143
|
+
const manyAgents = Object.fromEntries(Array.from({ length: 9 }, (_, i) => ['a' + i, f.baseConfig.harnesses.codex]));
|
|
144
|
+
t.check(!sessionRequest({ project: { ...spec, harnesses: manyAgents } }).success, 'at most 8 agent definitions in one spec');
|
|
145
|
+
t.check(GuestRequest.safeParse({ kind: 'project', id: 'p1', project: spec, createdAt: 'x' }).success, '"project" requests (set up without an agent) carry a spec');
|
|
146
|
+
t.check(!GuestRequest.safeParse({ kind: 'project', id: 'p1', createdAt: 'x' }).success, '"project" requests need a spec');
|
|
147
|
+
t.check(GuestRequest.safeParse({ kind: 'restart', id: 'x', harness: null, cwd: 'C:\\Projects\\a', createdAt: 'x' }).success, 'restart can be scoped to one project\'s folder');
|
|
148
|
+
t.check(GuestRequest.safeParse({ kind: 'restart', id: 'x', harness: null, createdAt: 'x' }).success, 'restart still works without a folder');
|
|
149
|
+
t.section('projects: which projects are mapped at boot');
|
|
150
|
+
const toMap = allProjectsToMap(['C:\\a', 'C:\\a\\b', 'C:\\work-a\\x', 'C:\\r\\p', 'C:\\ex\\y', 'C:\\gone'], { primary: 'C:\\work-a', roots: ['C:\\r'], exclude: ['C:\\ex'], exists: (p) => p !== 'C:\\gone' });
|
|
151
|
+
t.equal(toMap, ['C:\\a'], 'projects.all: known projects except excluded, missing, nested, the launch folder\'s and the roots\'');
|
|
152
|
+
const rootConfig = structuredClone(f.baseConfig);
|
|
153
|
+
rootConfig.projects = { ...rootConfig.projects, roots: [root, t.at('no-such-root')], all: true };
|
|
154
|
+
rememberProject(f.baseConfig.storage.root, outside);
|
|
155
|
+
const gen = f.generate(await f.run({}, rootConfig));
|
|
156
|
+
const session = f.readJson(gen.sessionPath);
|
|
157
|
+
const wsb = fs.readFileSync(gen.wsbPath, 'utf8');
|
|
158
|
+
t.equal(gen.projects.roots.map((r) => r.guest), ['C:\\Projects\\proj-root'], 'roots are mapped as C:\\Projects\\<name>');
|
|
159
|
+
t.check(gen.projects.notes.some((n) => n.includes('no-such-root')), 'a missing root is skipped with a note', JSON.stringify(gen.projects.notes));
|
|
160
|
+
t.check(gen.projects.projects.some((p) => p.host === outside), 'projects.all maps the known projects too');
|
|
161
|
+
t.includes(wsb, '<HostFolder>' + root + '</HostFolder><SandboxFolder>C:\\Projects\\proj-root</SandboxFolder><ReadOnly>false</ReadOnly>', 'roots are mapped read/write in the .wsb');
|
|
162
|
+
t.equal({ projectsDir: session.projectsDir, roots: session.roots, projectId: session.projectId }, { projectsDir: PROJECTS_GUEST, roots: ['C:\\Projects\\proj-root'], projectId: 'work-a' }, 'session.json tells the guest where other projects can be');
|
|
163
|
+
const badRoot = structuredClone(f.baseConfig);
|
|
164
|
+
badRoot.projects = { ...badRoot.projects, roots: [f.baseConfig.storage.root] };
|
|
165
|
+
await t.throws(async () => f.generate(await f.run({}, badRoot)), 'projects.roots', 'a root that would expose the storage folder is refused');
|
|
166
|
+
t.check(forgetProject(f.baseConfig.storage.root, outside), '`sandbox projects remove` forgets a project...');
|
|
167
|
+
t.check(!readKnown(f.baseConfig.storage.root).some((k) => k.host === outside), '... so it is no longer known (its folder is untouched)');
|
|
168
|
+
t.includes(buildWsb(f.baseConfig, 4096, []), '<MappedFolders></MappedFolders>', 'no projects: nothing extra mapped');
|
|
169
|
+
t.section('projects: repo guard for every project the sandbox can write');
|
|
170
|
+
const guardData = t.at('guard-data');
|
|
171
|
+
const rootG = t.at('guard-root');
|
|
172
|
+
const old = path.join(rootG, 'old');
|
|
173
|
+
t.write(path.join(old, '.git', 'hooks', 'pre-commit'), 'mine');
|
|
174
|
+
t.write(path.join(old, '.git', 'config'), '[core]\n\tbare = false\n');
|
|
175
|
+
saveRepoSnapshot(guardData, f.workA, [], [rootG]);
|
|
176
|
+
t.write(path.join(old, '.git', 'hooks', 'post-checkout'), 'planted');
|
|
177
|
+
const fresh = path.join(rootG, 'created-later');
|
|
178
|
+
t.write(path.join(fresh, '.git', 'hooks', 'pre-push'), 'planted');
|
|
179
|
+
t.write(path.join(fresh, '.git', 'config'), '[core]\n\tfsmonitor = evil.exe\n\tbare = false\n');
|
|
180
|
+
t.write(path.join(fresh, 'node.exe'), 'planted');
|
|
181
|
+
const found = checkRepo(guardData);
|
|
182
|
+
t.check(fs.existsSync(path.join(old, '.git', 'hooks', 'pre-commit')), 'a hook that was there before the sandbox ran stays');
|
|
183
|
+
t.check(!fs.existsSync(path.join(old, '.git', 'hooks', 'post-checkout')), 'a hook added inside the sandbox to a project in a root is quarantined');
|
|
184
|
+
t.check(!fs.existsSync(path.join(fresh, '.git', 'hooks', 'pre-push')), 'a project that appeared in a root after the start has its planted hooks removed');
|
|
185
|
+
t.check(!fs.existsSync(path.join(fresh, 'node.exe')), 'a project that appeared in a root after the start has its planted commands removed');
|
|
186
|
+
t.includes(fs.readFileSync(path.join(fresh, '.git', 'config'), 'utf8'), '# [agent-sandbox] disabled', 'a project that appeared in a root after the start has its program-running git config disabled');
|
|
187
|
+
t.check(found.some((x) => x.detail.startsWith(fresh)), 'the findings name the project', JSON.stringify(found));
|
|
188
|
+
t.section('projects: history and environment');
|
|
189
|
+
const hostClaude = t.at('hist-host');
|
|
190
|
+
const guestClaude = t.at('hist-guest');
|
|
191
|
+
const bHost = t.at('hist-b');
|
|
192
|
+
t.write(path.join(guestClaude, 'projects', projectFolderName('C:\\Projects\\hist-b'), 's.jsonl'), JSON.stringify({ cwd: 'C:\\Projects\\hist-b\\src' }) + '\n');
|
|
193
|
+
syncHistoryOut({ hostClaudeDir: hostClaude, guestClaudeDir: guestClaude, hostWorkspace: f.workA, guestWorkspace: 'C:\\Workspace\\work-a', extra: [{ hostWorkspace: bHost, guestWorkspace: 'C:\\Projects\\hist-b' }] });
|
|
194
|
+
const back = path.join(hostClaude, 'projects', projectFolderName(bHost), 's.jsonl');
|
|
195
|
+
t.check(fs.existsSync(back), 'a joined project\'s history goes back to ITS folder on this PC');
|
|
196
|
+
t.includes(fs.existsSync(back) ? fs.readFileSync(back, 'utf8') : '', JSON.stringify(bHost + '\\src').slice(1, -1), 'a joined project\'s history paths are translated');
|
|
197
|
+
const env = baseEnvFor({ basePath: 'C:\\Windows', toolVars: ['NVM_HOME', 'CARGO_HOME'], steps: {}, summary: [], lan: 'blocked' }, { Path: 'C:\\tools\\a;C:\\Windows', NVM_HOME: 'x', CARGO_HOME: 'y', CARGO_TARGET_DIR: 'C:\\Workspace\\a\\target', AGENT_WORKSPACE: 'C:\\Workspace\\a', TEMP: 'C:\\t' });
|
|
198
|
+
t.equal({ PATH: env.PATH, Path: env.Path, NVM_HOME: env.NVM_HOME, CARGO_TARGET_DIR: env.CARGO_TARGET_DIR, AGENT_WORKSPACE: env.AGENT_WORKSPACE, TEMP: env.TEMP }, { PATH: 'C:\\Windows', Path: undefined, NVM_HOME: undefined, CARGO_TARGET_DIR: undefined, AGENT_WORKSPACE: undefined, TEMP: 'C:\\t' }, 'a joined project starts from the boot\'s base environment: none of the first project\'s tools, workspace or target folder');
|
|
199
|
+
t.section('projects: commands');
|
|
200
|
+
const status = (id, cwd, startedAt) => ({ id, launchId: 'l', harness: 'claude', cwd, phase: 'running', message: '', updatedAt: startedAt, startedAt });
|
|
201
|
+
const sessions = [status('a1', 'C:\\Workspace\\a', '1'), status('b1', 'C:\\Projects\\b', '2'), status('a2', 'C:\\Workspace\\a\\x', '3'), status('b2', 'C:\\Projects\\b\\y', '4')];
|
|
202
|
+
t.equal(sessionsFirstIn(sessions, 'C:\\Projects\\b').map((x) => x.id), ['b2', 'b1', 'a2', 'a1'], 'attach and sessions put this project\'s sessions first, newest first');
|
|
203
|
+
t.equal(describeSessions([{ harness: 'claude', cwd: 'C:\\Workspace\\api', project: 'api' }, { harness: 'codex', cwd: 'C:\\Projects\\web' }]), '2 sessions (claude in api, codex in web)', 'the restart offer lists the sessions that would end');
|
|
204
|
+
await t.throws(() => validateProjectsArgs(['bogus']), 'projects action', '`sandbox projects` rejects an unknown action');
|
|
205
|
+
await t.throws(() => validateProjectsArgs(['add']), 'Which folder', '`sandbox projects add` needs a folder');
|
|
206
|
+
await t.throws(() => validateProjectsArgs(['all', 'maybe']), 'on', '`sandbox projects all` needs on or off');
|
|
207
|
+
await t.throws(() => validateProjectsArgs(['roots', 'add']), 'Which folder', '`sandbox projects roots add` needs a folder');
|
|
208
|
+
validateProjectsArgs([]);
|
|
209
|
+
validateProjectsArgs(['roots']);
|
|
210
|
+
validateProjectsArgs(['all', 'on']);
|
|
211
|
+
t.check(askedWhen(['add', 'remove'], 'add') && !askedWhen(['add'], 'list') && askedWhen(undefined, 'x') && askedWhen('save', 'save'), 'the menu asks an argument only for the actions it belongs to');
|
|
212
|
+
t.section('projects: several sessions at once (--session agent@folder)');
|
|
213
|
+
const want = parseSessionWant('claude@C:\\code\\a@b', 'C:\\here');
|
|
214
|
+
t.equal({ harness: want.harness, folder: want.folder }, { harness: 'claude', folder: 'C:\\code\\a@b' }, '--session: the first @ splits agent from folder');
|
|
215
|
+
t.equal(parseSessionWant('codex', 'C:\\here').folder, 'C:\\here', '--session: no folder means this one');
|
|
216
|
+
t.equal(parseSessionWant('gemini@sub', 'C:\\here').folder, 'C:\\here\\sub', '--session: relative folders become absolute');
|
|
217
|
+
await t.throws(() => parseSessionWant('@C:\\x'), 'names no agent', '--session without an agent is refused');
|
|
218
|
+
const own = parseSessionWant('codex@C:\\code\\web|--model o3 "two words"', 'C:\\here');
|
|
219
|
+
t.equal({ harness: own.harness, folder: own.folder, args: own.args }, { harness: 'codex', folder: 'C:\\code\\web', args: ['--model', 'o3', 'two words'] }, '--session: after | come that agent\'s own arguments (quotes keep words together)');
|
|
220
|
+
t.equal(parseSessionWant('claude@C:\\x').args, undefined, '--session: no | means no own arguments');
|
|
221
|
+
const both = parseCli(['launch', '--session', 'claude@C:\\a|--continue', '--session', 'codex@C:\\b', '--', '--verbose']);
|
|
222
|
+
t.equal(both.passthrough, ['--verbose'], 'arguments after -- go to every session...');
|
|
223
|
+
t.equal(both.sessions.map((s) => s.args ?? []), [['--continue'], []], '... arguments after | only to that one');
|
|
224
|
+
const multi = parseCli(['launch', '--session', 'claude@' + f.workA, '--session', 'codex@' + f.workB, '--session', 'claude@' + f.workB, '-t', '--', '--continue']);
|
|
225
|
+
t.equal({ command: multi.command, sessions: multi.sessions.length, terminal: multi.terminal, second: multi.sessions[1].harness, passthrough: multi.passthrough }, { command: 'launch', sessions: 3, terminal: true, second: 'codex', passthrough: ['--continue'] }, 'several --session options parse in order, with -t and agent arguments');
|
|
226
|
+
const validationContext = (options) => ({ packageRoot: f.install, entry: '', options, loaded: { config: f.baseConfig, configPath: null, base: t.temp }, storageRoot: f.baseConfig.storage.root, dataPath: t.at('vdata') });
|
|
227
|
+
await t.throws(() => validateOptions(validationContext(parseCli(['launch', '--session', 'claud@' + f.workA]))), 'Did you mean', 'an unknown agent in --session suggests the closest one');
|
|
228
|
+
await t.throws(() => validateOptions(validationContext(parseCli(['launch', '--session', 'claude@' + t.at('nope')]))), 'does not exist', 'a missing --session folder is refused before anything starts');
|
|
229
|
+
await t.throws(() => validateOptions(validationContext(parseCli(['launch', '--session', 'claude@' + f.workA, '--harness', 'codex']))), 'do not apply', '--session cannot be mixed with --harness/--workspace');
|
|
230
|
+
validateOptions(validationContext(multi));
|
|
231
|
+
t.equal(severalAgentsArgv([{ harness: 'claude', folder: 'C:\\a' }, { harness: 'codex', folder: 'C:\\b c' }], { headless: true, attachFirst: true }).join(' '), 'launch --session claude@C:\\a --session codex@C:\\b c --headless -t', 'the menu builds the same command line (so repeat-last works)');
|
|
232
|
+
t.equal(candidateFolders(validationContext(parseCli(['launch'])), f.workA)[0], path.resolve(f.workA), 'the menu offers this folder first, then known projects and the folders in your roots');
|
|
233
|
+
const multiGen = generateSandbox({ packageRoot: t.packageRoot, run: await f.run(), harnessArgs: [], probe: f.probe, extraProjects: [f.workB, path.join(f.workA, 'sub'), f.workB], autoSession: false });
|
|
234
|
+
const multiSession = f.readJson(multiGen.sessionPath);
|
|
235
|
+
const multiWsb = fs.readFileSync(multiGen.wsbPath, 'utf8');
|
|
236
|
+
t.equal(multiGen.projects.projects.map((p) => p.guest), ['C:\\Projects\\work-b'], 'a multi-project launch maps every other project once (folders inside the launch folder and duplicates need nothing)');
|
|
237
|
+
t.includes(multiWsb, '<HostFolder>' + f.workB + '</HostFolder><SandboxFolder>C:\\Projects\\work-b</SandboxFolder>', 'a multi-project launch maps the other project in the .wsb');
|
|
238
|
+
t.equal({ autoSession: multiSession.autoSession, workspaceHost: multiSession.workspaceHost }, { autoSession: false, workspaceHost: f.workA }, 'the boot provisions the launch folder only; the host then opens every session (each project set up on demand, in parallel)');
|
|
239
|
+
t.section('projects: wsb (Windows Sandbox command line) adapter');
|
|
240
|
+
t.equal([parseVersion('Windows Sandbox CLI version 0.4.1.0\n'), parseVersion('nothing')], ['0.4.1.0', null], 'wsb --version');
|
|
241
|
+
const help = 'Description:\n Windows Sandbox command line\n\nUsage:\n wsb [command] [options]\n\nOptions:\n --version Show version information\n\nCommands:\n start Starts a sandbox\n list Lists sandboxes\n exec Runs a command\n share Shares a folder\n stop Stops a sandbox\n connect Connects\n ip Shows the IP\n';
|
|
242
|
+
t.equal(parseCommands(help), ['start', 'list', 'exec', 'share', 'stop', 'connect', 'ip'], 'wsb --help: the subcommands it offers');
|
|
243
|
+
t.equal(parseCommands('no commands here').length, 0, 'wsb --help: no commands in other text');
|
|
244
|
+
const id = '12345678-1234-1234-1234-1234567890ab';
|
|
245
|
+
t.equal(parseList(JSON.stringify([{ Id: id, Status: 'Running', Uptime: '00:10' }]))[0]?.id, id, 'wsb list --raw: an array');
|
|
246
|
+
t.equal(parseList(JSON.stringify({ WindowsSandboxEnvironments: [{ id, status: 'Stopped' }] }))[0]?.status, 'Stopped', 'wsb list --raw: an object holding one');
|
|
247
|
+
t.equal(parseList('Id Status Uptime\n' + id + ' Running 00:01:02\n')[0]?.status, 'Running', 'wsb list as a table');
|
|
248
|
+
t.equal(parseList('nothing').length, 0, 'wsb list: nothing to read gives no sandboxes');
|
|
249
|
+
t.equal([parseIp('172.20.1.5\r\n'), parseIp('none')], ['172.20.1.5', null], 'wsb ip');
|
|
250
|
+
const apps = 'C:\\Users\\me\\AppData\\Local\\Microsoft\\WindowsApps\\wsb.exe';
|
|
251
|
+
t.equal(findWsb({ LOCALAPPDATA: 'C:\\Users\\me\\AppData\\Local', SystemRoot: 'C:\\Windows' }, (p) => p === apps), apps, 'wsb.exe is looked for at absolute paths only (WindowsApps alias, System32)');
|
|
252
|
+
t.equal(findWsb({ SystemRoot: 'C:\\Windows' }, () => false), null, 'wsb.exe is not found when nothing is there');
|
|
253
|
+
},
|
|
254
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// `sandbox repair`: finding half-installed toolchains, interrupted downloads, stale pid files and broken state.
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { deadPidFiles, findHalfInstalled, findStagingLeftovers, globalPackages, resolvedVersionsValid } from '../cli/commands/repair/finders.js';
|
|
4
|
+
export const repairSuite = {
|
|
5
|
+
name: 'repair',
|
|
6
|
+
title: 'repair detection (half-installed tools, leftovers, stale pids)',
|
|
7
|
+
run: (t) => {
|
|
8
|
+
t.section('tools folder');
|
|
9
|
+
const tools = t.at('repair', 'tools');
|
|
10
|
+
t.write(path.join(tools, 'go', '1.23.2', 'bin', 'go.exe'), 'x');
|
|
11
|
+
t.dir('repair', 'tools', 'go', '1.22.8', 'src');
|
|
12
|
+
t.write(path.join(tools, 'java', 'jdk-21', 'bin', 'java.exe'), 'x');
|
|
13
|
+
t.dir('repair', 'tools', 'java', 'jdk-17', 'lib');
|
|
14
|
+
t.write(path.join(tools, 'python', 'python-3.12.7', 'python.exe'), 'x');
|
|
15
|
+
t.write(path.join(tools, 'nvm', 'settings.txt'), 'x');
|
|
16
|
+
t.write(path.join(tools, 'nvm', 'v22.11.0', 'node.exe'), 'x');
|
|
17
|
+
t.dir('repair', 'tools', 'nvm', 'v20.18.0');
|
|
18
|
+
t.write(path.join(tools, 'git', 'bin', 'bash.exe'), 'x');
|
|
19
|
+
t.dir('repair', 'tools', 'dotnet', 'sdk');
|
|
20
|
+
t.dir('repair', 'tools', 'go', '1.24.0.staging-ab12cd');
|
|
21
|
+
t.dir('repair', 'tools', 'node-staging');
|
|
22
|
+
t.dir('repair', 'tools', 'rust.staging-0f');
|
|
23
|
+
const half = findHalfInstalled(tools).map((h) => path.relative(tools, h.path)).sort();
|
|
24
|
+
const expectedHalf = [path.join('go', '1.22.8'), path.join('java', 'jdk-17'), 'dotnet', 'nvm', path.join('nvm', 'v20.18.0')].sort();
|
|
25
|
+
t.equal(half, expectedHalf, 'half-installed toolchains are folders without their program');
|
|
26
|
+
const leftovers = findStagingLeftovers(tools).map((p) => path.relative(tools, p)).sort();
|
|
27
|
+
t.equal(leftovers, [path.join('go', '1.24.0.staging-ab12cd'), 'node-staging', 'rust.staging-0f'].sort(), 'interrupted downloads are found in tools and one level down');
|
|
28
|
+
t.section('state files');
|
|
29
|
+
t.check(resolvedVersionsValid('{"go":"1.23.2"}') && resolvedVersionsValid('{}'), 'resolved-versions.json: a JSON object is valid (a byte-order mark is fine)');
|
|
30
|
+
t.check(!resolvedVersionsValid('{"go":') && !resolvedVersionsValid('[1]'), 'resolved-versions.json: broken JSON and non-objects are invalid');
|
|
31
|
+
t.equal(globalPackages('{"dependencies":{"agent-sandbox":{"version":"1.0.0"},"npm":{}}}'), ['agent-sandbox', 'npm'], '`npm ls -g --json` output is read');
|
|
32
|
+
t.equal(globalPackages('nope'), [], '`npm ls -g` output that is not JSON gives no packages');
|
|
33
|
+
t.equal(globalPackages('{"dependencies":3}'), [], '`npm ls -g` output of the wrong shape gives no packages');
|
|
34
|
+
const run = t.at('repair', 'run');
|
|
35
|
+
t.write(path.join(run, 'watcher-default.pid'), '4242');
|
|
36
|
+
t.write(path.join(run, 'watcher-other.pid'), String(process.pid));
|
|
37
|
+
t.write(path.join(run, 'watcher-junk.pid'), 'not a pid');
|
|
38
|
+
t.write(path.join(run, 'hidden-windows.json'), '[]');
|
|
39
|
+
const dead = deadPidFiles(run, (pid) => pid === process.pid).map((file) => path.basename(file)).sort();
|
|
40
|
+
t.equal(dead, ['watcher-default.pid', 'watcher-junk.pid'], 'stale watcher pid files (dead process or junk) are found; a live one is kept');
|
|
41
|
+
},
|
|
42
|
+
};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Repository guard (zero-click code execution on this PC): after the sandbox ran, hooks, program-running git config
|
|
2
|
+
// and commands planted in the repository are quarantined before anything on this PC can run them.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { guardRepo, snapshotRepo } from '../host/repo-guard.js';
|
|
6
|
+
export const repoGuardSuite = {
|
|
7
|
+
name: 'repo-guard',
|
|
8
|
+
title: 'repository guard (hooks, git config, planted commands, replaced .git)',
|
|
9
|
+
run: (t) => {
|
|
10
|
+
const readConfig = (repo) => fs.readFileSync(path.join(repo, '.git', 'config'), 'utf8');
|
|
11
|
+
const disabled = (keyPattern) => new RegExp('# \\[agent-sandbox\\] disabled.*' + keyPattern);
|
|
12
|
+
t.section('hooks and git config');
|
|
13
|
+
const repo = t.at('repo');
|
|
14
|
+
t.write(path.join(repo, '.git', 'config'), '[core]\n\trepositoryformatversion = 0\n[remote "origin"]\n\turl = https://example.com/x.git\n');
|
|
15
|
+
t.write(path.join(repo, '.git', 'hooks', 'pre-commit.sample'), 'sample');
|
|
16
|
+
t.write(path.join(repo, '.git', 'hooks', 'pre-push'), 'existing hook');
|
|
17
|
+
const before = snapshotRepo(repo);
|
|
18
|
+
t.write(path.join(repo, '.git', 'hooks', 'post-checkout'), 'calc.exe');
|
|
19
|
+
t.write(path.join(repo, '.git', 'hooks', 'pre-push'), 'changed hook');
|
|
20
|
+
fs.appendFileSync(path.join(repo, '.git', 'config'), '[core]\n\tfsmonitor = calc.exe\n\tsshCommand = evil\n[include]\n\tpath = C:/evil.cfg\n[branch "feature"]\n\tremote = origin\n');
|
|
21
|
+
const findings = guardRepo(repo, before);
|
|
22
|
+
t.check(!fs.existsSync(path.join(repo, '.git', 'hooks', 'post-checkout')), 'a new git hook is quarantined');
|
|
23
|
+
t.check(!fs.existsSync(path.join(repo, '.git', 'hooks', 'pre-push')), 'a changed git hook is quarantined');
|
|
24
|
+
t.check(fs.existsSync(path.join(repo, '.git', 'hooks', 'pre-commit.sample')), 'hook samples are left alone');
|
|
25
|
+
t.equal(findings.filter((x) => x.kind === 'hook').length, 2, 'both hooks are reported');
|
|
26
|
+
const config = readConfig(repo);
|
|
27
|
+
t.matches(config, disabled('fsmonitor'), 'git config: fsmonitor (runs a program) is disabled');
|
|
28
|
+
t.matches(config, disabled('sshCommand'), 'git config: sshCommand is disabled');
|
|
29
|
+
t.matches(config, disabled('path = C:/evil'), 'git config: [include] of an outside file is disabled');
|
|
30
|
+
t.matches(config, /^\tremote = origin$/m, 'git config: a harmless change stays');
|
|
31
|
+
t.matches(config, /^\turl = https/m, 'git config: existing lines stay');
|
|
32
|
+
t.equal(guardRepo(repo, before).length, 0, 'the guard is idempotent');
|
|
33
|
+
t.section('planted commands and tricks');
|
|
34
|
+
const repo2 = t.at('repo2');
|
|
35
|
+
t.write(path.join(repo2, '.git', 'config'), '[core]\n\tbare = false\n');
|
|
36
|
+
t.write(path.join(repo2, 'build.cmd'), 'echo legit');
|
|
37
|
+
const before2 = snapshotRepo(repo2);
|
|
38
|
+
for (const name of ['node.exe', 'git.cmd', 'sandbox.cmd', 'opencode.ps1'])
|
|
39
|
+
t.write(path.join(repo2, name), name === 'node.exe' ? 'MZ' : 'calc');
|
|
40
|
+
t.write(path.join(repo2, '.git', 'commondir'), 'C:/evil');
|
|
41
|
+
fs.appendFileSync(path.join(repo2, '.git', 'config'), '[core] fsmonitor = calc.exe\n');
|
|
42
|
+
const found2 = guardRepo(repo2, before2);
|
|
43
|
+
const gone = ['node.exe', 'git.cmd', 'sandbox.cmd', 'opencode.ps1'].filter((name) => !fs.existsSync(path.join(repo2, name)));
|
|
44
|
+
t.equal(gone, ['node.exe', 'git.cmd', 'sandbox.cmd', 'opencode.ps1'], 'executables planted under command names are quarantined');
|
|
45
|
+
t.check(fs.existsSync(path.join(repo2, 'build.cmd')), 'other scripts stay');
|
|
46
|
+
t.equal(found2.filter((x) => x.kind === 'command').length, 4, 'every planted command is reported');
|
|
47
|
+
t.check(!fs.existsSync(path.join(repo2, '.git', 'commondir')), 'a planted commondir is removed');
|
|
48
|
+
t.matches(readConfig(repo2), disabled('fsmonitor'), 'a key on the same line as its [section] is neutralised');
|
|
49
|
+
t.section('a replaced or oversized .git');
|
|
50
|
+
const victim = t.dir('repo-victim');
|
|
51
|
+
const repo3 = t.at('repo3');
|
|
52
|
+
t.write(path.join(repo3, '.git', 'config'), '[core]\n');
|
|
53
|
+
const before3 = snapshotRepo(repo3);
|
|
54
|
+
fs.rmSync(path.join(repo3, '.git'), { recursive: true });
|
|
55
|
+
fs.symlinkSync(victim, path.join(repo3, '.git'), 'junction');
|
|
56
|
+
t.check(guardRepo(repo3, before3).some((x) => x.kind === 'git' && x.detail.includes('replaced')), '.git replaced by a link is reported loudly');
|
|
57
|
+
const repo4 = t.at('repo4');
|
|
58
|
+
t.write(path.join(repo4, '.git', 'config'), '[core]\n');
|
|
59
|
+
const before4 = snapshotRepo(repo4);
|
|
60
|
+
fs.appendFileSync(path.join(repo4, '.git', 'config'), '#'.repeat(5 * 1024 * 1024));
|
|
61
|
+
t.check(guardRepo(repo4, before4).some((x) => x.kind === 'git' && x.detail.includes('over 4 MB')), 'a git config too big to check is reported instead of passing');
|
|
62
|
+
},
|
|
63
|
+
};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// What reaches the sandbox from this PC's Claude files, and what is stripped first: API keys, cloud credentials, tokens in
|
|
2
|
+
// MCP server definitions. Also the secret-looking files in a workspace that the launch warns about.
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { sandboxGlobalState } from '../host/claude-profile.js';
|
|
5
|
+
import { findSecretFiles, stripMcpServers, stripSettings } from '../host/secrets.js';
|
|
6
|
+
const REMOVED = '[removed by agent-sandbox]';
|
|
7
|
+
export const secretsSuite = {
|
|
8
|
+
name: 'secrets',
|
|
9
|
+
title: 'credentials stripped from everything copied into the sandbox',
|
|
10
|
+
run: (t) => {
|
|
11
|
+
t.section('~/.claude.json');
|
|
12
|
+
const state = sandboxGlobalState({
|
|
13
|
+
primaryApiKey: 'sk-secret',
|
|
14
|
+
oauthAccount: { emailAddress: 'me' },
|
|
15
|
+
mcpServers: { a: {} },
|
|
16
|
+
theme: 'dark',
|
|
17
|
+
projects: { 'D:/Code/app': { mcpServers: { db: { env: { TOKEN: 'mine' } } } }, 'D:/Other': { mcpServers: { x: { env: { TOKEN: 'other' } } } } },
|
|
18
|
+
}, 'D:\\Code\\app');
|
|
19
|
+
const stateText = JSON.stringify(state);
|
|
20
|
+
t.check(!('primaryApiKey' in state), 'the sandbox never gets your API key');
|
|
21
|
+
t.check(!('oauthAccount' in state), 'the sandbox never gets your signed-in account');
|
|
22
|
+
t.includes(stateText, 'mine', 'the sandbox gets this project\'s MCP servers');
|
|
23
|
+
t.excludes(stateText, 'other', 'the sandbox never gets other projects');
|
|
24
|
+
t.equal(state.theme, 'dark', 'ordinary settings are kept');
|
|
25
|
+
t.section('settings.json');
|
|
26
|
+
const secrets = { strip: true, allow: ['AWS_REGION'], patterns: ['ACME_*'], warnFiles: true };
|
|
27
|
+
const removed = [];
|
|
28
|
+
const settings = stripSettings({
|
|
29
|
+
apiKeyHelper: 'aws-vault exec prod -- get-key',
|
|
30
|
+
model: 'opus',
|
|
31
|
+
env: {
|
|
32
|
+
AWS_ACCESS_KEY_ID: 'x', AWS_SECRET_ACCESS_KEY: 'y', AWS_REGION: 'eu-west-2', AZURE_CLIENT_SECRET: 'z', ARM_CLIENT_ID: 'id', CLOUDFLARE_API_TOKEN: 't',
|
|
33
|
+
GOOGLE_APPLICATION_CREDENTIALS: 'C:\\key.json', PROD_DB_HOST: 'db.internal', STRIPE_KEY: 'k', ACME_THING: 'a', DATABASE_URL: 'postgres://u:p@h/db',
|
|
34
|
+
INNOCENT: 'AKIAABCDEFGHIJKLMNOP', SAFE_URL: 'https://example.com', DISABLE_TELEMETRY: '1', CLAUDE_CODE_USE_BEDROCK: '1',
|
|
35
|
+
},
|
|
36
|
+
}, secrets, removed);
|
|
37
|
+
const env = settings.env;
|
|
38
|
+
t.check(!('apiKeyHelper' in settings) && settings.model === 'opus', 'settings: the API key helper goes, ordinary settings stay');
|
|
39
|
+
t.equal(Object.keys(env).sort(), ['AWS_REGION', 'CLAUDE_CODE_USE_BEDROCK', 'DISABLE_TELEMETRY', 'SAFE_URL'], 'settings: AWS, Azure, Google, Cloudflare, prod, payment and database credentials (by name or by value) stay on this PC; secrets.allow and ordinary settings pass');
|
|
40
|
+
t.check(removed.some((r) => r.endsWith('AWS_SECRET_ACCESS_KEY')), 'settings: what was removed is reported', JSON.stringify(removed));
|
|
41
|
+
t.equal(JSON.stringify(stripSettings({ env: { AWS_SECRET_ACCESS_KEY: 'y' } }, { ...secrets, strip: false }, [])), '{"env":{"AWS_SECRET_ACCESS_KEY":"y"}}', 'secrets.strip = false copies settings unchanged');
|
|
42
|
+
t.section('MCP servers');
|
|
43
|
+
const mcp = stripMcpServers({
|
|
44
|
+
mcpServers: { gh: { command: 'npx', args: ['server', '--api-key', 'plain', '--verbose', 'ghp_abcdefghijklmnopqrstuvwxyz0123456789'], env: { GITHUB_TOKEN: 'g', LOG_LEVEL: 'debug' } } },
|
|
45
|
+
projects: { 'D:/app': { mcpServers: { api: { type: 'http', url: 'https://x', headers: { Authorization: 'Bearer abcdefghijklmnopqrstuvwxyz', Accept: 'json' } } } } },
|
|
46
|
+
}, secrets, []);
|
|
47
|
+
const gh = mcp.mcpServers.gh;
|
|
48
|
+
const api = mcp.projects['D:/app'].mcpServers.api;
|
|
49
|
+
t.equal(gh.args, ['server', '--api-key', REMOVED, '--verbose', REMOVED], 'MCP servers: key arguments and token-looking arguments are removed');
|
|
50
|
+
t.equal(gh.env, { LOG_LEVEL: 'debug' }, 'MCP servers: token env vars are removed, the rest stays');
|
|
51
|
+
t.equal(api.headers, { Accept: 'json' }, 'MCP servers: Authorization headers are removed');
|
|
52
|
+
const sneaky = stripMcpServers({
|
|
53
|
+
mcpServers: {
|
|
54
|
+
r: {
|
|
55
|
+
type: 'http', url: 'https://mcp.example.com/mcp?api_key=0123456789abcdef®ion=eu',
|
|
56
|
+
headers: { 'X-API-Key': 'k', 'x-auth-token': 't', Accept: 'json' },
|
|
57
|
+
args: ['--api-key=deadbeefdeadbeef', '--header', 'Authorization: Bearer abcdefghijklmnopqrstuvwxyz', '--port=8080'],
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
}, secrets, []);
|
|
61
|
+
const r = sneaky.mcpServers.r;
|
|
62
|
+
t.equal(r.url, 'https://mcp.example.com/mcp?api_key=REMOVED®ion=eu', 'MCP servers: ?api_key= in URLs is removed');
|
|
63
|
+
t.equal(r.headers, { Accept: 'json' }, 'MCP servers: X-API-Key style headers are removed');
|
|
64
|
+
t.equal([r.args[0], r.args[2], r.args[3]], [REMOVED, REMOVED, '--port=8080'], 'MCP servers: --flag=value keys and "Authorization: Bearer" arguments are removed, ordinary flags stay');
|
|
65
|
+
t.section('secret-looking files in the workspace');
|
|
66
|
+
const workspace = t.at('secret-ws');
|
|
67
|
+
for (const file of ['.env', '.env.example', 'server.pem', 'README.md', 'api/credentials.json', 'node_modules/x/.env'])
|
|
68
|
+
t.write(path.join(workspace, file), 'x');
|
|
69
|
+
t.equal(findSecretFiles(workspace).sort(), ['.env', 'api\\credentials.json', 'server.pem'], 'secret-looking workspace files are found for the launch warning (templates and dependencies ignored)');
|
|
70
|
+
},
|
|
71
|
+
};
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
export declare const
|
|
1
|
+
import type { Suite } from './suite.js';
|
|
2
|
+
export declare const setupSuite: Suite;
|
|
@@ -1,23 +1,46 @@
|
|
|
1
|
-
// `agent-sandbox setup`: the pure pieces of the machine checklist.
|
|
2
|
-
import { buildChecks, canEnableFeature, editionSupport,
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
1
|
+
// `agent-sandbox setup`: the pure pieces of the machine checklist (parsing what Windows reports, judging each item).
|
|
2
|
+
import { buildChecks, canEnableFeature, editionSupport, nodeMajor } from '../cli/commands/setup/checks.js';
|
|
3
|
+
import { featureFromInstallState, parseDismState, parseProbe, parseRegQuery } from '../cli/commands/setup/facts.js';
|
|
4
|
+
const GB = 1024 ** 3;
|
|
5
|
+
export const setupSuite = {
|
|
6
|
+
name: 'setup',
|
|
7
|
+
title: 'machine checklist for `sandbox setup`',
|
|
8
|
+
run: (t) => {
|
|
9
|
+
t.section('reading what Windows reports');
|
|
10
|
+
const reg = parseRegQuery('\r\nHKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\r\n CurrentBuild REG_SZ 22631\r\n EditionID REG_SZ Professional\r\n ProductName REG_SZ Windows 10 Pro\r\n InstallDate REG_DWORD 0x65a1b2c3\r\n');
|
|
11
|
+
t.equal({ EditionID: reg.EditionID, CurrentBuild: reg.CurrentBuild, ProductName: reg.ProductName }, { EditionID: 'Professional', CurrentBuild: '22631', ProductName: 'Windows 10 Pro' }, 'reg query output parses into name/value pairs');
|
|
12
|
+
t.equal(['Professional', 'EnterpriseS', 'EducationN', 'Core', 'CoreSingleLanguage', 'ServerStandard', 'Weird', null].map(editionSupport), ['supported', 'supported', 'supported', 'home', 'home', 'unsupported', 'unknown', 'unknown'], 'editions: Pro/Enterprise/Education yes, Home and Server no');
|
|
13
|
+
const dism = (state) => 'Deployment Image Servicing and Management tool\r\n\r\nFeature Name : Containers-DisposableClientVM\r\nDisplay Name : Windows Sandbox\r\nRestart Required : Possible\r\nState : ' + state + '\r\n';
|
|
14
|
+
t.equal([parseDismState(dism('Enabled')), parseDismState(dism('Enable Pending')), parseDismState(dism('Disabled')), parseDismState(dism('Disabled with Payload Removed')), parseDismState('Error: 740\r\nElevated permissions are required')], ['enabled', 'pending', 'disabled', 'disabled', 'unknown'], 'DISM feature state: enabled, pending restart, disabled; errors are unknown');
|
|
15
|
+
t.equal([featureFromInstallState(1), featureFromInstallState(2), featureFromInstallState(null)], ['enabled', 'disabled', 'unknown'], 'Win32_OptionalFeature install state maps to the feature state');
|
|
16
|
+
t.equal(parseProbe('WARNING: x\r\n{"virtualizationFirmware":false,"hypervisorPresent":true,"installState":1}\r\n').hypervisorPresent, true, 'PowerShell probe: the last line is read as JSON');
|
|
17
|
+
t.equal(Object.keys(parseProbe('not json')).length, 0, 'PowerShell probe: text that is not JSON is ignored');
|
|
18
|
+
t.equal(Object.keys(parseProbe('{"installState":"1"}')).length, 0, 'PowerShell probe: JSON of the wrong shape is ignored');
|
|
19
|
+
t.equal([nodeMajor('v22.11.0'), nodeMajor('18.0.0'), nodeMajor('')], [22, 18, 0], 'Node major version');
|
|
20
|
+
t.section('judging each item');
|
|
21
|
+
const good = { edition: 'Professional', build: 22631, feature: 'enabled', sandboxExe: true, virtualizationFirmware: false, hypervisorPresent: true, totalRamBytes: 16 * GB, freeDiskBytes: 100 * GB, storageRoot: 'C:\\store', nodeVersion: 'v22.11.0' };
|
|
22
|
+
const status = (facts) => Object.fromEntries(buildChecks({ ...good, ...facts }).map((c) => [c.name, c.status]));
|
|
23
|
+
t.check(buildChecks(good).every((c) => c.status === 'ok'), 'a ready PC is all OK (a running hypervisor counts as virtualization on)');
|
|
24
|
+
t.equal(status({ edition: 'Core' })['Windows edition'], 'action', 'Windows Home needs action');
|
|
25
|
+
t.equal(status({ build: 17763 })['Windows edition'], 'action', 'an old Windows build needs action');
|
|
26
|
+
t.equal(status({ edition: 'Weird' })['Windows edition'], 'warn', 'an unknown edition warns');
|
|
27
|
+
t.equal(status({ hypervisorPresent: false })['CPU virtualization'], 'action', 'virtualization: off needs action');
|
|
28
|
+
t.equal(status({ hypervisorPresent: null, virtualizationFirmware: null })['CPU virtualization'], 'warn', 'virtualization: unknown warns');
|
|
29
|
+
t.equal(status({ hypervisorPresent: false, virtualizationFirmware: true })['CPU virtualization'], 'ok', 'virtualization: on in the firmware is fine');
|
|
30
|
+
t.equal(status({ feature: 'disabled', sandboxExe: false })['Windows Sandbox feature'], 'action', 'feature: off needs action');
|
|
31
|
+
const waiting = buildChecks({ ...good, feature: 'pending', sandboxExe: false })[2];
|
|
32
|
+
t.includes(waiting.detail, 'restart', 'feature: waiting for a restart says so');
|
|
33
|
+
t.includes(buildChecks({ ...good, feature: 'enabled', sandboxExe: false })[2].detail, 'restart', 'feature: enabled but not usable yet says to restart');
|
|
34
|
+
t.equal(status({ totalRamBytes: 6 * GB }).Memory, 'warn', 'RAM under 8 GB warns');
|
|
35
|
+
t.equal(status({ totalRamBytes: 3 * GB }).Memory, 'action', 'RAM under 4 GB needs action');
|
|
36
|
+
t.equal(status({ freeDiskBytes: 10 * GB })['Free disk'], 'warn', 'low free disk warns');
|
|
37
|
+
t.equal(status({ freeDiskBytes: 2 * GB })['Free disk'], 'action', 'very low free disk needs action');
|
|
38
|
+
t.equal(status({ freeDiskBytes: null })['Free disk'], 'warn', 'unknown free disk warns');
|
|
39
|
+
t.equal(status({ nodeVersion: 'v18.20.0' })['Node.js'], 'action', 'an old Node.js needs action');
|
|
40
|
+
const off = { ...good, feature: 'disabled', sandboxExe: false };
|
|
41
|
+
t.check(canEnableFeature(off), 'offers to enable the feature when it is off on an edition that has it');
|
|
42
|
+
t.check(!canEnableFeature({ ...off, edition: 'Core' }), 'does not offer it on Windows Home');
|
|
43
|
+
t.check(!canEnableFeature({ ...off, feature: 'pending' }), 'does not offer it while a restart is pending');
|
|
44
|
+
t.check(!canEnableFeature(good), 'does not offer it when it is already on');
|
|
45
|
+
},
|
|
23
46
|
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Fixtures } from './fixtures.js';
|
|
2
|
+
import type { Harness } from './harness.js';
|
|
3
|
+
export interface Suite {
|
|
4
|
+
/** Short name used to run just this suite: `sandbox self-test <name>`. */
|
|
5
|
+
name: string;
|
|
6
|
+
/** One line: what area this suite covers (shown by `self-test --list` style output and in failures). */
|
|
7
|
+
title: string;
|
|
8
|
+
run: (t: Harness, f: Fixtures) => void | Promise<void>;
|
|
9
|
+
/** Starts real processes (the daemon, ptyhost, csc, git): skipped where Windows cannot run them. */
|
|
10
|
+
live?: boolean;
|
|
11
|
+
/** A suite that takes longer than this is reported as a failure and the run continues. Default 2 minutes. */
|
|
12
|
+
timeoutMs?: number;
|
|
13
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -1,3 +1,2 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
|
|
3
|
-
export declare const syncTests: (t: Harness, f: Fixtures) => Promise<void>;
|
|
1
|
+
import type { Suite } from './suite.js';
|
|
2
|
+
export declare const syncSuite: Suite;
|