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,32 @@
|
|
|
1
|
+
// `exec` requests: a command run for the host in the workspace.
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { SYSTEM } from '../../shared/system.js';
|
|
4
|
+
import { errMsg, exists } from '../env.js';
|
|
5
|
+
import { provisionedFor } from './project.js';
|
|
6
|
+
import { insideWorkspace, respond } from './state.js';
|
|
7
|
+
const MAX_OUTPUT = 1024 * 1024;
|
|
8
|
+
const quoteForCmd = (a) => (/[\s"&|<>^]/.test(a) ? '"' + a.replace(/"/g, '""') + '"' : a);
|
|
9
|
+
const cap = (s) => (s.length > MAX_OUTPUT ? s.slice(0, MAX_OUTPUT) + '\n[output truncated]' : s);
|
|
10
|
+
/** Runs a command for the host without blocking the daemon (other requests and pings keep being answered). */
|
|
11
|
+
export const execCommand = (req) => {
|
|
12
|
+
if (!insideWorkspace(req.cwd) || !exists(req.cwd)) {
|
|
13
|
+
respond(req.id, { ok: false, message: 'Folder is not inside the sandbox workspace: ' + req.cwd });
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
// chcp 65001 first: console programs then write UTF-8 (not the OEM code page) into the pipes.
|
|
17
|
+
// In a project registered after boot, its own environment (its Node, Python, database variables).
|
|
18
|
+
const env = provisionedFor(req.cwd)?.env ?? process.env;
|
|
19
|
+
const child = spawn(SYSTEM.cmd(), ['/d', '/s', '/c', '"chcp 65001>nul & ' + req.command.map(quoteForCmd).join(' ') + '"'], { cwd: req.cwd, env, windowsHide: true, windowsVerbatimArguments: true });
|
|
20
|
+
const output = { stdout: '', stderr: '' };
|
|
21
|
+
// setEncoding decodes across chunks: a multi-byte character split between two reads is not mangled.
|
|
22
|
+
const collect = (key) => (d) => { if (output[key].length < MAX_OUTPUT)
|
|
23
|
+
output[key] += d; };
|
|
24
|
+
child.stdout.setEncoding('utf8').on('data', collect('stdout'));
|
|
25
|
+
child.stderr.setEncoding('utf8').on('data', collect('stderr'));
|
|
26
|
+
const timer = setTimeout(() => child.kill(), Math.min(req.timeoutSeconds, 3600) * 1000);
|
|
27
|
+
child.on('error', (e) => { clearTimeout(timer); respond(req.id, { ok: false, message: 'Could not run the command: ' + errMsg(e) }); });
|
|
28
|
+
child.on('close', (code) => {
|
|
29
|
+
clearTimeout(timer);
|
|
30
|
+
respond(req.id, { ok: code === 0, message: code === null ? 'stopped after ' + req.timeoutSeconds + ' s' : 'exit ' + code, exitCode: code ?? -1, stdout: cap(output.stdout), stderr: cap(output.stderr) });
|
|
31
|
+
});
|
|
32
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ProjectSpecT } from '../../shared/schema.js';
|
|
2
|
+
export interface Provisioned {
|
|
3
|
+
spec: ProjectSpecT;
|
|
4
|
+
env: NodeJS.ProcessEnv;
|
|
5
|
+
summary: string[];
|
|
6
|
+
}
|
|
7
|
+
/** The provisioned project containing this folder, if any (exec in its folders uses its environment). */
|
|
8
|
+
export declare const provisionedFor: (cwd: string) => Provisioned | null;
|
|
9
|
+
export declare const provisionedProjects: () => Provisioned[];
|
|
10
|
+
/**
|
|
11
|
+
* Provisions a project (or joins the run already doing it: two sessions requested at once wait for the same one).
|
|
12
|
+
* `say` gets the progress lines.
|
|
13
|
+
*/
|
|
14
|
+
export declare const provisionProject: (spec: ProjectSpecT, say: (message: string) => void) => Promise<Provisioned>;
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// Projects registered after boot (sandbox claude from another folder on the PC, which the host links into the hub).
|
|
2
|
+
// Each is provisioned once, on demand, while every other session keeps running: the toolchain steps it needs that are
|
|
3
|
+
// not installed yet (installOnce markers and installed folders make the rest free), its Node/Python versions, its
|
|
4
|
+
// databases and hooks. Its sessions get ITS environment, built from the boot's base environment rather than the boot
|
|
5
|
+
// project's: nothing machine-wide is switched under the sessions already running (no nvm symlink switch, machine
|
|
6
|
+
// variables only added, never replaced). A failure is that project's alone.
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { isWithin } from '../../shared/projects.js';
|
|
10
|
+
import { insideWorkspace } from './state.js';
|
|
11
|
+
import { baseEnvFor, readBootEnv } from '../boot-env.js';
|
|
12
|
+
import { dryRun, errMsg, exists, localDir, log, mkdirp, session, warn } from '../env.js';
|
|
13
|
+
import { writeGuides } from '../guide.js';
|
|
14
|
+
import { withProject } from '../project-context.js';
|
|
15
|
+
import { saveProjectStatus } from '../project-status.js';
|
|
16
|
+
import { addMachineEnvironment, agentEnv } from '../setup/environment.js';
|
|
17
|
+
import { projectHooks, runHooksAsync } from '../setup/hooks.js';
|
|
18
|
+
import { setupToolchains } from '../toolchains/index.js';
|
|
19
|
+
/** By project name (lower case). The same spec again is free; a changed one (its config was edited) provisions again. */
|
|
20
|
+
const projects = new Map();
|
|
21
|
+
/** The last finished provisioning of each project (kept while a changed spec provisions again). */
|
|
22
|
+
const ready = new Map();
|
|
23
|
+
/** The provisioned project containing this folder, if any (exec in its folders uses its environment). */
|
|
24
|
+
export const provisionedFor = (cwd) => [...ready.values()].filter((p) => isWithin(p.spec.workspace, cwd)).sort((a, b) => b.spec.workspace.length - a.spec.workspace.length)[0] ?? null;
|
|
25
|
+
export const provisionedProjects = () => [...ready.values()];
|
|
26
|
+
/** `set` lines for a .cmd file. Values are quoted; a value with a line break cannot be set this way and is left out. */
|
|
27
|
+
const cmdSet = (env, names) => names.filter((n) => env[n] !== undefined && !/[\r\n]/.test(String(env[n]))).map((n) => '@set "' + n + '=' + String(env[n]).replace(/%/g, '%%') + '"');
|
|
28
|
+
/**
|
|
29
|
+
* A terminal for the project inside the sandbox: `sbx-shell <name>` (from any terminal tab) opens cmd in its folder with
|
|
30
|
+
* its environment. Written to the sandbox's own disk.
|
|
31
|
+
*/
|
|
32
|
+
const writeShell = (spec, env, names) => {
|
|
33
|
+
const dir = mkdirp(path.join(localDir, 'projects', spec.name));
|
|
34
|
+
fs.writeFileSync(path.join(dir, 'env.cmd'), [...cmdSet(env, [...names, 'PATH']), ''].join('\r\n'));
|
|
35
|
+
fs.writeFileSync(path.join(dir, 'shell.cmd'), ['@call "' + path.join(dir, 'env.cmd') + '"', '@cd /d "' + spec.workspace + '"', '@title ' + spec.name, ''].join('\r\n'));
|
|
36
|
+
const bin = mkdirp(path.join(localDir, 'bin'));
|
|
37
|
+
fs.writeFileSync(path.join(bin, 'sbx-shell.cmd'), [
|
|
38
|
+
'@if "%~1"=="" (echo Usage: sbx-shell ^<project^> ^(a terminal in that project with its tools^) & dir /b "' + path.join(localDir, 'projects') + '" & exit /b 2)',
|
|
39
|
+
'@if not exist "' + path.join(localDir, 'projects') + '\\%~1\\shell.cmd" (echo No project %~1 in this sandbox. & exit /b 2)',
|
|
40
|
+
'@cmd /k ""' + path.join(localDir, 'projects') + '\\%~1\\shell.cmd""',
|
|
41
|
+
'',
|
|
42
|
+
].join('\r\n'));
|
|
43
|
+
};
|
|
44
|
+
/** The guide every agent reads lists every project in the sandbox (they can all see each other's folders). */
|
|
45
|
+
const refreshGuide = async () => {
|
|
46
|
+
// The self-test must never rewrite this machine's real managed CLAUDE.md.
|
|
47
|
+
if (dryRun || (process.env.AGENT_SANDBOX_SELFTEST === '1' && !process.env.AGENT_MANAGED_DIR))
|
|
48
|
+
return;
|
|
49
|
+
const boot = readBootEnv();
|
|
50
|
+
if (!boot)
|
|
51
|
+
return;
|
|
52
|
+
const list = provisionedProjects().map((p) => ({ guest: p.spec.workspace, host: p.spec.workspaceHost }));
|
|
53
|
+
try {
|
|
54
|
+
writeGuides(boot.summary, boot.lan, list);
|
|
55
|
+
}
|
|
56
|
+
catch (e) {
|
|
57
|
+
warn('Could not update the sandbox guide: ' + errMsg(e));
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
const provision = async (spec, say) => {
|
|
61
|
+
if (!insideWorkspace(spec.workspace) || !exists(spec.workspace)) {
|
|
62
|
+
throw new Error('Project folder ' + spec.workspace + ' (' + spec.workspaceHost + ') is not visible inside the sandbox. Stop the sandbox (sandbox stop) and start it from that folder, or add its parent folder to projects.roots.');
|
|
63
|
+
}
|
|
64
|
+
saveProjectStatus(spec.name, spec.workspace, 'provisioning', 'Setting up ' + spec.name, spec.databases, []);
|
|
65
|
+
log('Project ' + spec.name + ': setting up ' + spec.workspace + ' (' + spec.workspaceHost + ')');
|
|
66
|
+
const boot = readBootEnv();
|
|
67
|
+
const base = baseEnvFor(boot);
|
|
68
|
+
const hooks = projectHooks(spec.name, spec.workspace);
|
|
69
|
+
if (spec.hooks) {
|
|
70
|
+
say('Pre-provision hooks');
|
|
71
|
+
await runHooksAsync('pre-provision', base, hooks);
|
|
72
|
+
}
|
|
73
|
+
const tools = await withProject({ workspace: spec.workspace, duringSession: true }, () => setupToolchains(spec.toolchains, spec.versions, {
|
|
74
|
+
gitEnabled: session.provisioning.git.enabled,
|
|
75
|
+
// Git access is machine-wide: the host refuses a project that needs a stricter one, so the sandbox's applies.
|
|
76
|
+
gitAccess: session.provisioning.git.access ?? 'full',
|
|
77
|
+
hostGit: session.hostTools.git,
|
|
78
|
+
refresh: false,
|
|
79
|
+
databases: spec.databases,
|
|
80
|
+
}, { report: (m) => { saveProjectStatus(spec.name, spec.workspace, 'provisioning', m, spec.databases, []); say(m); }, reuse: boot?.steps }));
|
|
81
|
+
tools.prepend.push(path.join(localDir, 'bin'));
|
|
82
|
+
const env = agentEnv(tools, { workspace: spec.workspace, project: spec.name, base });
|
|
83
|
+
const specFile = path.join(mkdirp(path.join(localDir, 'projects', spec.name)), 'project.json');
|
|
84
|
+
fs.writeFileSync(specFile, JSON.stringify(spec));
|
|
85
|
+
env.AGENT_PROJECT_FILE = specFile;
|
|
86
|
+
// New terminal tabs see the new tools too, after everything the boot project has (never in place of it).
|
|
87
|
+
addMachineEnvironment(tools.vars, [...tools.prepend, ...tools.append], true);
|
|
88
|
+
try {
|
|
89
|
+
writeShell(spec, env, [...Object.keys(tools.vars), 'AGENT_WORKSPACE', 'AGENT_PROJECT', 'AGENT_SANDBOX_WORKSPACE', 'AGENT_PROJECT_FILE']);
|
|
90
|
+
}
|
|
91
|
+
catch (e) {
|
|
92
|
+
warn('sbx-shell for ' + spec.name + ': ' + errMsg(e));
|
|
93
|
+
}
|
|
94
|
+
if (spec.hooks) {
|
|
95
|
+
say('Pre-agent hooks');
|
|
96
|
+
await runHooksAsync('pre-agent', env, hooks);
|
|
97
|
+
}
|
|
98
|
+
for (const line of tools.summary)
|
|
99
|
+
log(' [' + spec.name + '] ' + line);
|
|
100
|
+
saveProjectStatus(spec.name, spec.workspace, 'ready', 'Ready', spec.databases, tools.summary);
|
|
101
|
+
return { spec, env, summary: tools.summary };
|
|
102
|
+
};
|
|
103
|
+
/**
|
|
104
|
+
* Provisions a project (or joins the run already doing it: two sessions requested at once wait for the same one).
|
|
105
|
+
* `say` gets the progress lines.
|
|
106
|
+
*/
|
|
107
|
+
export const provisionProject = (spec, say) => {
|
|
108
|
+
const id = spec.name.toLowerCase();
|
|
109
|
+
const key = JSON.stringify(spec);
|
|
110
|
+
const known = projects.get(id);
|
|
111
|
+
if (known && known.key === key) {
|
|
112
|
+
known.listeners.add(say);
|
|
113
|
+
return known.work.finally(() => known.listeners.delete(say));
|
|
114
|
+
}
|
|
115
|
+
const listeners = new Set([say]);
|
|
116
|
+
const broadcast = (message) => { for (const l of listeners)
|
|
117
|
+
try {
|
|
118
|
+
l(message);
|
|
119
|
+
}
|
|
120
|
+
catch { /* a listener's write failing never stops provisioning */ } };
|
|
121
|
+
const work = provision(spec, broadcast);
|
|
122
|
+
const entry = { key, work, listeners };
|
|
123
|
+
projects.set(id, entry);
|
|
124
|
+
work.then((done) => { entry.done = done; ready.set(id, done); return refreshGuide(); }, (e) => {
|
|
125
|
+
warn('Project ' + spec.name + ' could not be set up: ' + errMsg(e));
|
|
126
|
+
saveProjectStatus(spec.name, spec.workspace, 'failed', errMsg(e), spec.databases, []);
|
|
127
|
+
// The next session for it tries again.
|
|
128
|
+
if (projects.get(id) === entry)
|
|
129
|
+
projects.delete(id);
|
|
130
|
+
});
|
|
131
|
+
return work;
|
|
132
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { GuestRequest } from '../../core/types.js';
|
|
2
|
+
type SessionRequest = Extract<GuestRequest, {
|
|
3
|
+
kind: 'session';
|
|
4
|
+
}>;
|
|
5
|
+
export declare const startSession: (req: SessionRequest) => Promise<void>;
|
|
6
|
+
/** `project` requests: provision a registered project without an agent (sandbox start / exec / db from its folder). */
|
|
7
|
+
export declare const prepareProject: (req: Extract<GuestRequest, {
|
|
8
|
+
kind: "project";
|
|
9
|
+
}>) => Promise<void>;
|
|
10
|
+
export declare const restartSessions: (req: Extract<GuestRequest, {
|
|
11
|
+
kind: "restart";
|
|
12
|
+
}>) => Promise<void>;
|
|
13
|
+
export {};
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// `session` and `restart` requests: agent sessions, each in its own ptyhost process. A session runs in the launch
|
|
2
|
+
// folder's project (the boot environment) or in a project registered later, which is provisioned first (daemon/project).
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
6
|
+
import { FrameType, sendControl } from '../../shared/pty-client.js';
|
|
7
|
+
import { isWithin } from '../../shared/projects.js';
|
|
8
|
+
import { now } from '../../shared/rundir.js';
|
|
9
|
+
import { SYSTEM } from '../../shared/system.js';
|
|
10
|
+
import { commandLine } from '../cmdline.js';
|
|
11
|
+
import { freePort } from '../ports.js';
|
|
12
|
+
import { primaryProject } from '../project-status.js';
|
|
13
|
+
import { ensurePtyHost, ptyHostLogTail, startPtyHost, waitForPtyHost } from '../ptyhost.js';
|
|
14
|
+
import { provisionHarness, resolveLaunch } from '../setup/agents.js';
|
|
15
|
+
import { saveClaudeMem } from '../setup/claude.js';
|
|
16
|
+
import { projectHooks, runHooks, runHooksAsync } from '../setup/hooks.js';
|
|
17
|
+
import { guestAddresses } from '../setup/network.js';
|
|
18
|
+
import { dataDir, errMsg, exists, log, session, toolsDir, warn, workspace } from '../env.js';
|
|
19
|
+
import { provisionProject } from './project.js';
|
|
20
|
+
import { maybeShutdown } from './shutdown.js';
|
|
21
|
+
import { insidePrimary, live, pending, respond, safely, saveStatus } from './state.js';
|
|
22
|
+
const terminalTab = (id, title, cwd) => {
|
|
23
|
+
const view = [process.execPath, path.join(path.dirname(process.argv[1]), 'view.js'), id];
|
|
24
|
+
const wt = path.join(toolsDir, 'terminal', 'wt.exe');
|
|
25
|
+
if (exists(wt))
|
|
26
|
+
spawn(wt, ['-w', '0', 'nt', '--title', title, '-d', cwd, ...view], { detached: true, stdio: 'ignore' }).unref();
|
|
27
|
+
else
|
|
28
|
+
spawn(SYSTEM.cmd(), ['/d', '/c', 'start', title, ...view], { detached: true, stdio: 'ignore', cwd }).unref();
|
|
29
|
+
};
|
|
30
|
+
const onExit = (running, place) => (code) => {
|
|
31
|
+
live.delete(running.id);
|
|
32
|
+
const tail = code === 0 ? '' : ptyHostLogTail(running.id, 6);
|
|
33
|
+
saveStatus({ ...running, phase: 'exited', message: running.harness + ' exited with code ' + code + (tail ? '. Last output:\n' + tail : ''), exitCode: code ?? 1, attach: undefined });
|
|
34
|
+
log('Session ' + running.id + ' ended (exit ' + code + ')');
|
|
35
|
+
if (place.hooks)
|
|
36
|
+
void runHooksAsync('post-agent', place.env, place.hooks).catch((e) => warn('post-agent hooks failed: ' + errMsg(e)));
|
|
37
|
+
else
|
|
38
|
+
safely('post-agent hooks', () => runHooks('post-agent', process.env));
|
|
39
|
+
safely('claude-mem save', saveClaudeMem);
|
|
40
|
+
maybeShutdown();
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* The agent's definition: the one the sandbox booted with, the one the host sent along (another agent, e.g.
|
|
44
|
+
* sandbox codex in a sandbox started for claude), or one the project's config names.
|
|
45
|
+
*/
|
|
46
|
+
const harnessFor = (req) => {
|
|
47
|
+
if (req.harness === session.harnessName && session.harness)
|
|
48
|
+
return session.harness;
|
|
49
|
+
const sent = req.harnessSpec ?? req.project?.harnesses[req.harness];
|
|
50
|
+
if (sent)
|
|
51
|
+
return sent;
|
|
52
|
+
throw new Error('Agent "' + req.harness + '" is not configured for this sandbox (it runs ' + session.harnessName + ').');
|
|
53
|
+
};
|
|
54
|
+
/** Starts ptyhost for the agent and waits until it listens; a failure carries the tail of ptyhost's own log. */
|
|
55
|
+
const launchPtyHost = async (req, remote, env, say) => {
|
|
56
|
+
const harness = harnessFor(req);
|
|
57
|
+
// Never refresh here: main already did, and replacing a running agent's executable would fail.
|
|
58
|
+
say('Preparing ' + req.harness);
|
|
59
|
+
await provisionHarness(req.harness, harness, env, false);
|
|
60
|
+
const launch = resolveLaunch(req.harness, harness);
|
|
61
|
+
const base = [...launch.args, ...harness.arguments];
|
|
62
|
+
const port = await freePort();
|
|
63
|
+
const token = crypto.randomBytes(24).toString('base64url');
|
|
64
|
+
const child = startPtyHost({
|
|
65
|
+
exe: ensurePtyHost(), port, token, cols: 120, rows: 32, id: req.id, cwd: req.cwd, env: { ...env, ...harness.environment }, remote,
|
|
66
|
+
command: commandLine(launch.file, [...base, ...req.args]), restartCommand: commandLine(launch.file, [...base, ...(harness.resumeArguments ?? [])]),
|
|
67
|
+
});
|
|
68
|
+
try {
|
|
69
|
+
await waitForPtyHost(child, port, 15000);
|
|
70
|
+
}
|
|
71
|
+
catch (e) {
|
|
72
|
+
const tail = ptyHostLogTail(req.id);
|
|
73
|
+
throw new Error(errMsg(e) + (tail ? '. From its log:\n' + tail : '') + '\nLog: ' + path.join(dataDir, 'logs', 'ptyhost-' + req.id + '.log'));
|
|
74
|
+
}
|
|
75
|
+
return { child, port, token };
|
|
76
|
+
};
|
|
77
|
+
/** The launch folder's project, or the registered project this session is for (provisioned first if needed). */
|
|
78
|
+
const placeFor = async (req, say) => {
|
|
79
|
+
if (!req.project) {
|
|
80
|
+
if (!insidePrimary(req.cwd) || !exists(req.cwd))
|
|
81
|
+
throw new Error('Folder is not inside the sandbox workspace ' + workspace + ': ' + req.cwd);
|
|
82
|
+
return { env: process.env, hooks: null, project: primaryProject() };
|
|
83
|
+
}
|
|
84
|
+
if (!isWithin(req.project.workspace, req.cwd))
|
|
85
|
+
throw new Error('Folder ' + req.cwd + ' is not inside project ' + req.project.name + ' (' + req.project.workspace + ').');
|
|
86
|
+
const ready = await provisionProject(req.project, say);
|
|
87
|
+
if (!exists(req.cwd))
|
|
88
|
+
throw new Error('Folder does not exist in the sandbox: ' + req.cwd);
|
|
89
|
+
return { env: ready.env, hooks: req.project.hooks ? projectHooks(req.project.name, req.project.workspace) : null, project: req.project.name };
|
|
90
|
+
};
|
|
91
|
+
export const startSession = async (req) => {
|
|
92
|
+
let status = { id: req.id, launchId: session.launchId, harness: req.harness, cwd: req.cwd, ...(req.project ? { project: req.project.name } : {}), phase: 'starting', message: 'Starting ' + req.harness, updatedAt: now(), startedAt: now() };
|
|
93
|
+
saveStatus(status);
|
|
94
|
+
// Progress goes into the session's status: the host prints it while it waits, and the tab inside shows it.
|
|
95
|
+
const say = (message) => { status = { ...status, message }; saveStatus(status); };
|
|
96
|
+
pending.starting++;
|
|
97
|
+
// Sessions after the first get their own tab right away (it shows the setup progress until the agent starts).
|
|
98
|
+
if (req.id !== session.launchId && process.env.AGENT_NO_TABS !== '1')
|
|
99
|
+
safely('terminal tab', () => terminalTab(req.id, req.harness + ' ' + path.basename(req.cwd), req.cwd));
|
|
100
|
+
try {
|
|
101
|
+
const place = await placeFor(req, say);
|
|
102
|
+
const remote = session.networking && session.attach.enabled;
|
|
103
|
+
const { child, port, token } = await launchPtyHost(req, remote, place.env, say);
|
|
104
|
+
const running = { ...status, project: place.project, phase: 'running', message: req.harness + ' is running', attach: { addresses: remote ? guestAddresses() : [], port, token } };
|
|
105
|
+
live.set(req.id, { status: running, child });
|
|
106
|
+
child.once('exit', onExit(running, place));
|
|
107
|
+
saveStatus(running);
|
|
108
|
+
log('Session ' + req.id + ' started (' + req.harness + ' in ' + req.cwd + ', project ' + place.project + ', port ' + port + ')');
|
|
109
|
+
}
|
|
110
|
+
catch (e) {
|
|
111
|
+
saveStatus({ ...status, phase: 'failed', message: errMsg(e) });
|
|
112
|
+
warn('Session ' + req.id + ' failed: ' + errMsg(e));
|
|
113
|
+
maybeShutdown();
|
|
114
|
+
}
|
|
115
|
+
finally {
|
|
116
|
+
pending.starting--;
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
/** `project` requests: provision a registered project without an agent (sandbox start / exec / db from its folder). */
|
|
120
|
+
export const prepareProject = async (req) => {
|
|
121
|
+
pending.starting++;
|
|
122
|
+
try {
|
|
123
|
+
const ready = await provisionProject(req.project, () => undefined);
|
|
124
|
+
respond(req.id, { ok: true, message: 'Project ' + req.project.name + ' is ready in ' + req.project.workspace + '\n' + ready.summary.join('\n') });
|
|
125
|
+
}
|
|
126
|
+
catch (e) {
|
|
127
|
+
respond(req.id, { ok: false, message: 'Project ' + req.project.name + ' could not be set up: ' + errMsg(e) });
|
|
128
|
+
}
|
|
129
|
+
finally {
|
|
130
|
+
pending.starting--;
|
|
131
|
+
maybeShutdown();
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
export const restartSessions = async (req) => {
|
|
135
|
+
const targets = [...live.values()].filter((s) => s.status.attach && (req.harness === null || s.status.harness === req.harness) && (!req.cwd || isWithin(req.cwd, s.status.cwd)));
|
|
136
|
+
for (const s of targets)
|
|
137
|
+
await sendControl({ addresses: ['127.0.0.1'], port: s.status.attach.port, token: s.status.attach.token }, FrameType.Restart);
|
|
138
|
+
respond(req.id, { ok: true, message: 'restarted ' + targets.length + ' session(s)' });
|
|
139
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const maybeShutdown: () => void;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// A headless sandbox with nothing running (or starting) only wastes RAM, so it shuts itself down.
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { SYSTEM } from '../../shared/system.js';
|
|
4
|
+
import { scanRequests } from '../../shared/rundir.js';
|
|
5
|
+
import { saveClaudeMem } from '../setup/claude.js';
|
|
6
|
+
import { dataDir, isWin, log, session } from '../env.js';
|
|
7
|
+
import { live, pending, safely } from './state.js';
|
|
8
|
+
export const maybeShutdown = () => {
|
|
9
|
+
if (!session.headless || !isWin)
|
|
10
|
+
return;
|
|
11
|
+
const idle = () => live.size === 0 && pending.starting === 0 && scanRequests(dataDir).valid.length === 0;
|
|
12
|
+
if (!idle())
|
|
13
|
+
return;
|
|
14
|
+
setTimeout(() => {
|
|
15
|
+
if (!idle())
|
|
16
|
+
return;
|
|
17
|
+
log('Headless sandbox has no sessions left; shutting down.');
|
|
18
|
+
safely('claude-mem save', saveClaudeMem);
|
|
19
|
+
spawnSync(SYSTEM.shutdown(), ['/s', '/t', '5'], { windowsHide: true });
|
|
20
|
+
}, 15000);
|
|
21
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ChildProcess } from 'node:child_process';
|
|
2
|
+
import type { GuestResponse, SessionStatus } from '../../core/types.js';
|
|
3
|
+
export declare const live: Map<string, {
|
|
4
|
+
status: SessionStatus;
|
|
5
|
+
child: ChildProcess;
|
|
6
|
+
}>;
|
|
7
|
+
/** Sessions being started right now (a headless sandbox must not shut down under them). */
|
|
8
|
+
export declare const pending: {
|
|
9
|
+
starting: number;
|
|
10
|
+
};
|
|
11
|
+
/** Inside the launch folder's workspace. */
|
|
12
|
+
export declare const insidePrimary: (cwd: string) => boolean;
|
|
13
|
+
/** Inside any project of this sandbox: the launch folder, a project root, or C:\Projects\<name> (older launch folders: only the first). */
|
|
14
|
+
export declare const insideWorkspace: (cwd: string) => boolean;
|
|
15
|
+
export declare const respond: (id: string, result: Omit<GuestResponse, "id" | "finishedAt">) => void;
|
|
16
|
+
export declare const saveStatus: (status: SessionStatus) => void;
|
|
17
|
+
export declare const safely: (what: string, work: () => void) => void;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// What the daemon's request handlers share: the running sessions and best-effort status/response writes.
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { now, writeJsonAtomic } from '../../shared/rundir.js';
|
|
4
|
+
import { insideProjects } from '../../shared/projects.js';
|
|
5
|
+
import { errMsg, run, session, warn, workspace } from '../env.js';
|
|
6
|
+
const MAX_MESSAGE = 3500;
|
|
7
|
+
export const live = new Map();
|
|
8
|
+
/** Sessions being started right now (a headless sandbox must not shut down under them). */
|
|
9
|
+
export const pending = { starting: 0 };
|
|
10
|
+
/** Inside the launch folder's workspace. */
|
|
11
|
+
export const insidePrimary = (cwd) => {
|
|
12
|
+
const rel = path.relative(workspace.toLowerCase(), path.resolve(cwd).toLowerCase());
|
|
13
|
+
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
|
14
|
+
};
|
|
15
|
+
/** Inside any project of this sandbox: the launch folder, a project root, or C:\Projects\<name> (older launch folders: only the first). */
|
|
16
|
+
export const insideWorkspace = (cwd) => insidePrimary(cwd) || (session.projectsDir !== undefined && insideProjects(path.resolve(cwd), session.roots ?? [], session.projectsDir));
|
|
17
|
+
/** Status/response writes are best-effort: a briefly locked file (antivirus, the host's sync) must not kill the daemon. */
|
|
18
|
+
const tryWrite = (file, value) => { try {
|
|
19
|
+
writeJsonAtomic(file, value);
|
|
20
|
+
}
|
|
21
|
+
catch (e) {
|
|
22
|
+
warn('Could not write ' + file + ': ' + errMsg(e));
|
|
23
|
+
} };
|
|
24
|
+
export const respond = (id, result) => tryWrite(run.response(id), { id, ...result, message: result.message.slice(0, MAX_MESSAGE), finishedAt: now() });
|
|
25
|
+
export const saveStatus = (status) => tryWrite(run.session(status.id), { ...status, message: status.message.slice(0, MAX_MESSAGE), updatedAt: now() });
|
|
26
|
+
export const safely = (what, work) => { try {
|
|
27
|
+
work();
|
|
28
|
+
}
|
|
29
|
+
catch (e) {
|
|
30
|
+
warn(what + ' failed: ' + errMsg(e));
|
|
31
|
+
} };
|
package/dist/guest/daemon.js
CHANGED
|
@@ -1,159 +1,26 @@
|
|
|
1
1
|
// Session daemon (runs detached inside the sandbox for its whole life). It is the only thing that acts on host
|
|
2
2
|
// requests, and it only knows four request kinds: start a session, run a command, restart sessions, ping.
|
|
3
3
|
// It must never die quietly: every iteration is guarded, and anything unexpected is written to its log.
|
|
4
|
+
// The handlers live in daemon/ (session, exec); this file is the polling loop.
|
|
4
5
|
import fs from 'node:fs';
|
|
5
|
-
import
|
|
6
|
-
import crypto from 'node:crypto';
|
|
7
|
-
import { spawn, spawnSync } from 'node:child_process';
|
|
8
|
-
import { FrameType, sendControl } from '../shared/pty-client.js';
|
|
9
|
-
import { SYSTEM } from '../shared/system.js';
|
|
10
|
-
import { commandLine, ensurePtyHost, freePort, ptyHostLogTail, startPtyHost, waitForPtyHost } from './ptyhost.js';
|
|
11
|
-
import { provisionHarness, resolveLaunch } from './setup/agents.js';
|
|
6
|
+
import { readSession, scanRequests } from '../shared/rundir.js';
|
|
12
7
|
import { saveClaudeMem } from './setup/claude.js';
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import { dataDir, errMsg,
|
|
17
|
-
const MAX_OUTPUT = 1024 * 1024;
|
|
18
|
-
const MAX_MESSAGE = 3500;
|
|
19
|
-
const live = new Map();
|
|
20
|
-
let starting = 0;
|
|
8
|
+
import { execCommand } from './daemon/exec.js';
|
|
9
|
+
import { prepareProject, restartSessions, startSession } from './daemon/session.js';
|
|
10
|
+
import { live, respond, safely } from './daemon/state.js';
|
|
11
|
+
import { dataDir, errMsg, log, run, warn } from './env.js';
|
|
21
12
|
let lastMemSave = Date.now();
|
|
22
13
|
process.on('uncaughtException', (e) => warn('daemon: unexpected error (continuing): ' + (e.stack ?? errMsg(e))));
|
|
23
14
|
process.on('unhandledRejection', (e) => warn('daemon: unexpected error (continuing): ' + errMsg(e)));
|
|
24
|
-
const insideWorkspace = (cwd) => {
|
|
25
|
-
const rel = path.relative(workspace.toLowerCase(), path.resolve(cwd).toLowerCase());
|
|
26
|
-
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
|
27
|
-
};
|
|
28
|
-
/** Status/response writes are best-effort: a briefly locked file (antivirus, the host's sync) must not kill the daemon. */
|
|
29
|
-
const tryWrite = (file, value) => { try {
|
|
30
|
-
writeJsonAtomic(file, value);
|
|
31
|
-
}
|
|
32
|
-
catch (e) {
|
|
33
|
-
warn('Could not write ' + file + ': ' + errMsg(e));
|
|
34
|
-
} };
|
|
35
|
-
const respond = (id, result) => tryWrite(run.response(id), { id, ...result, message: result.message.slice(0, MAX_MESSAGE), finishedAt: now() });
|
|
36
|
-
const saveStatus = (status) => tryWrite(run.session(status.id), { ...status, message: status.message.slice(0, MAX_MESSAGE), updatedAt: now() });
|
|
37
|
-
const safely = (what, work) => { try {
|
|
38
|
-
work();
|
|
39
|
-
}
|
|
40
|
-
catch (e) {
|
|
41
|
-
warn(what + ' failed: ' + errMsg(e));
|
|
42
|
-
} };
|
|
43
|
-
const terminalTab = (id, title, cwd) => {
|
|
44
|
-
const view = [process.execPath, path.join(path.dirname(process.argv[1]), 'view.js'), id];
|
|
45
|
-
const wt = path.join(toolsDir, 'terminal', 'wt.exe');
|
|
46
|
-
if (exists(wt))
|
|
47
|
-
spawn(wt, ['-w', '0', 'nt', '--title', title, '-d', cwd, ...view], { detached: true, stdio: 'ignore' }).unref();
|
|
48
|
-
else
|
|
49
|
-
spawn(SYSTEM.cmd(), ['/d', '/c', 'start', title, ...view], { detached: true, stdio: 'ignore', cwd }).unref();
|
|
50
|
-
};
|
|
51
|
-
const onExit = (running) => (code) => {
|
|
52
|
-
live.delete(running.id);
|
|
53
|
-
const tail = code === 0 ? '' : ptyHostLogTail(running.id, 6);
|
|
54
|
-
saveStatus({ ...running, phase: 'exited', message: running.harness + ' exited with code ' + code + (tail ? '. Last output:\n' + tail : ''), exitCode: code ?? 1, attach: undefined });
|
|
55
|
-
log('Session ' + running.id + ' ended (exit ' + code + ')');
|
|
56
|
-
safely('post-agent hooks', () => runHooks('post-agent', process.env));
|
|
57
|
-
safely('claude-mem save', saveClaudeMem);
|
|
58
|
-
maybeShutdown();
|
|
59
|
-
};
|
|
60
|
-
const startSession = async (req) => {
|
|
61
|
-
const status = { id: req.id, launchId: session.launchId, harness: req.harness, cwd: req.cwd, phase: 'starting', message: 'Starting ' + req.harness, updatedAt: now(), startedAt: now() };
|
|
62
|
-
saveStatus(status);
|
|
63
|
-
starting++;
|
|
64
|
-
try {
|
|
65
|
-
if (!insideWorkspace(req.cwd) || !exists(req.cwd))
|
|
66
|
-
throw new Error('Folder is not inside the sandbox workspace ' + workspace + ': ' + req.cwd);
|
|
67
|
-
const harness = req.harness === session.harnessName && session.harness ? session.harness : null;
|
|
68
|
-
if (!harness)
|
|
69
|
-
throw new Error('Agent "' + req.harness + '" is not configured for this sandbox (it runs ' + session.harnessName + ').');
|
|
70
|
-
// Never refresh here: main already did, and replacing a running agent's executable would fail.
|
|
71
|
-
provisionHarness(req.harness, harness, process.env, false);
|
|
72
|
-
const launch = resolveLaunch(req.harness, harness);
|
|
73
|
-
const base = [...launch.args, ...harness.arguments];
|
|
74
|
-
const port = await freePort();
|
|
75
|
-
const token = crypto.randomBytes(24).toString('base64url');
|
|
76
|
-
const remote = session.networking && session.attach.enabled;
|
|
77
|
-
const child = startPtyHost({
|
|
78
|
-
exe: ensurePtyHost(), port, token, cols: 120, rows: 32, id: req.id, cwd: req.cwd, env: { ...process.env, ...harness.environment }, remote,
|
|
79
|
-
command: commandLine(launch.file, [...base, ...req.args]), restartCommand: commandLine(launch.file, [...base, ...(harness.resumeArguments ?? [])]),
|
|
80
|
-
});
|
|
81
|
-
try {
|
|
82
|
-
await waitForPtyHost(child, port, 15000);
|
|
83
|
-
}
|
|
84
|
-
catch (e) {
|
|
85
|
-
const tail = ptyHostLogTail(req.id);
|
|
86
|
-
throw new Error(errMsg(e) + (tail ? '. From its log:\n' + tail : '') + '\nLog: ' + path.join(dataDir, 'logs', 'ptyhost-' + req.id + '.log'));
|
|
87
|
-
}
|
|
88
|
-
const running = { ...status, phase: 'running', message: req.harness + ' is running', attach: { addresses: remote ? guestAddresses() : [], port, token } };
|
|
89
|
-
live.set(req.id, { status: running, child });
|
|
90
|
-
child.once('exit', onExit(running));
|
|
91
|
-
saveStatus(running);
|
|
92
|
-
log('Session ' + req.id + ' started (' + req.harness + ' in ' + req.cwd + ', port ' + port + ')');
|
|
93
|
-
if (req.id !== session.launchId && process.env.AGENT_NO_TABS !== '1')
|
|
94
|
-
terminalTab(req.id, req.harness + ' ' + path.basename(req.cwd), req.cwd);
|
|
95
|
-
}
|
|
96
|
-
catch (e) {
|
|
97
|
-
saveStatus({ ...status, phase: 'failed', message: errMsg(e) });
|
|
98
|
-
warn('Session ' + req.id + ' failed: ' + errMsg(e));
|
|
99
|
-
maybeShutdown();
|
|
100
|
-
}
|
|
101
|
-
finally {
|
|
102
|
-
starting--;
|
|
103
|
-
}
|
|
104
|
-
};
|
|
105
|
-
const quoteForCmd = (a) => (/[\s"&|<>^]/.test(a) ? '"' + a.replace(/"/g, '""') + '"' : a);
|
|
106
|
-
/** Runs a command for the host without blocking the daemon (other requests and pings keep being answered). */
|
|
107
|
-
const execCommand = (req) => {
|
|
108
|
-
if (!insideWorkspace(req.cwd) || !exists(req.cwd)) {
|
|
109
|
-
respond(req.id, { ok: false, message: 'Folder is not inside the sandbox workspace: ' + req.cwd });
|
|
110
|
-
return;
|
|
111
|
-
}
|
|
112
|
-
// chcp 65001 first: console programs then write UTF-8 (not the OEM code page) into the pipes.
|
|
113
|
-
const child = spawn(SYSTEM.cmd(), ['/d', '/s', '/c', '"chcp 65001>nul & ' + req.command.map(quoteForCmd).join(' ') + '"'], { cwd: req.cwd, env: process.env, windowsHide: true, windowsVerbatimArguments: true });
|
|
114
|
-
const output = { stdout: '', stderr: '' };
|
|
115
|
-
// setEncoding decodes across chunks: a multi-byte character split between two reads is not mangled.
|
|
116
|
-
const collect = (key) => (d) => { if (output[key].length < MAX_OUTPUT)
|
|
117
|
-
output[key] += d; };
|
|
118
|
-
child.stdout.setEncoding('utf8').on('data', collect('stdout'));
|
|
119
|
-
child.stderr.setEncoding('utf8').on('data', collect('stderr'));
|
|
120
|
-
const timer = setTimeout(() => child.kill(), Math.min(req.timeoutSeconds, 3600) * 1000);
|
|
121
|
-
const cap = (s) => (s.length > MAX_OUTPUT ? s.slice(0, MAX_OUTPUT) + '\n[output truncated]' : s);
|
|
122
|
-
child.on('error', (e) => { clearTimeout(timer); respond(req.id, { ok: false, message: 'Could not run the command: ' + errMsg(e) }); });
|
|
123
|
-
child.on('close', (code) => {
|
|
124
|
-
clearTimeout(timer);
|
|
125
|
-
respond(req.id, { ok: code === 0, message: code === null ? 'stopped after ' + req.timeoutSeconds + ' s' : 'exit ' + code, exitCode: code ?? -1, stdout: cap(output.stdout), stderr: cap(output.stderr) });
|
|
126
|
-
});
|
|
127
|
-
};
|
|
128
|
-
const restart = async (req) => {
|
|
129
|
-
const targets = [...live.values()].filter((s) => s.status.attach && (req.harness === null || s.status.harness === req.harness));
|
|
130
|
-
for (const s of targets)
|
|
131
|
-
await sendControl({ addresses: ['127.0.0.1'], port: s.status.attach.port, token: s.status.attach.token }, FrameType.Restart);
|
|
132
|
-
respond(req.id, { ok: true, message: 'restarted ' + targets.length + ' session(s)' });
|
|
133
|
-
};
|
|
134
15
|
const handle = (req) => {
|
|
135
16
|
switch (req.kind) {
|
|
136
17
|
case 'session': return startSession(req);
|
|
18
|
+
case 'project': return prepareProject(req);
|
|
137
19
|
case 'exec': return execCommand(req);
|
|
138
|
-
case 'restart': return
|
|
20
|
+
case 'restart': return restartSessions(req);
|
|
139
21
|
case 'ping': return respond(req.id, { ok: true, message: 'pong (' + live.size + ' session(s) running)' });
|
|
140
22
|
}
|
|
141
23
|
};
|
|
142
|
-
/** A headless sandbox with nothing running (or starting) only wastes RAM, so it shuts itself down. */
|
|
143
|
-
const maybeShutdown = () => {
|
|
144
|
-
if (!session.headless || !isWin)
|
|
145
|
-
return;
|
|
146
|
-
const idle = () => live.size === 0 && starting === 0 && scanRequests(dataDir).valid.length === 0;
|
|
147
|
-
if (!idle())
|
|
148
|
-
return;
|
|
149
|
-
setTimeout(() => {
|
|
150
|
-
if (!idle())
|
|
151
|
-
return;
|
|
152
|
-
log('Headless sandbox has no sessions left; shutting down.');
|
|
153
|
-
safely('claude-mem save', saveClaudeMem);
|
|
154
|
-
spawnSync(SYSTEM.shutdown(), ['/s', '/t', '5'], { windowsHide: true });
|
|
155
|
-
}, 15000);
|
|
156
|
-
};
|
|
157
24
|
const tick = () => {
|
|
158
25
|
const { valid, invalid } = scanRequests(dataDir);
|
|
159
26
|
// Anything in requests/ that is not a valid request (wrong kind, bad id, junk) is deleted unread.
|
package/dist/guest/db.js
CHANGED
|
@@ -1,13 +1,28 @@
|
|
|
1
1
|
// `sbx-db`: list, start and stop the sandbox's databases from a terminal inside it (`sandbox db` on the PC runs it too).
|
|
2
2
|
// Usage: sbx-db [list] | sbx-db start <name> | sbx-db stop <name>
|
|
3
|
-
import
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import { ProjectSpec } from '../shared/schema.js';
|
|
5
|
+
import { portOpen } from './ports.js';
|
|
6
|
+
import { DATABASE_NAMES, setupDatabase, stopDatabase } from './toolchains/databases/index.js';
|
|
4
7
|
import { emptyEnv } from './toolchains/common.js';
|
|
5
8
|
import { addMachineEnvironment } from './setup/environment.js';
|
|
6
9
|
import { errMsg, session } from './env.js';
|
|
7
10
|
const USAGE = 'Usage: sbx-db [list] | sbx-db start <name> | sbx-db stop <name>';
|
|
8
|
-
/**
|
|
11
|
+
/** In a project registered after boot (its terminals and exec have AGENT_PROJECT_FILE): that project's database plan. */
|
|
12
|
+
const projectPlans = () => {
|
|
13
|
+
const file = process.env.AGENT_PROJECT_FILE;
|
|
14
|
+
if (!file)
|
|
15
|
+
return null;
|
|
16
|
+
try {
|
|
17
|
+
return ProjectSpec.safeParse(JSON.parse(fs.readFileSync(file, 'utf8'))).data?.databases ?? null;
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
/** The project's own plans first (their versions and ports were chosen for it), then everything else. */
|
|
9
24
|
const plans = () => {
|
|
10
|
-
const boot = session.databases ?? [];
|
|
25
|
+
const boot = projectPlans() ?? session.databases ?? [];
|
|
11
26
|
return [...boot, ...(session.databaseCatalog ?? []).filter((c) => !boot.some((b) => b.database === c.database))];
|
|
12
27
|
};
|
|
13
28
|
const find = (name) => {
|
package/dist/guest/env.d.ts
CHANGED
|
@@ -17,9 +17,11 @@ export declare const run: {
|
|
|
17
17
|
requests: string;
|
|
18
18
|
responses: string;
|
|
19
19
|
sessions: string;
|
|
20
|
+
projects: string;
|
|
20
21
|
request: (id: string) => string;
|
|
21
22
|
response: (id: string) => string;
|
|
22
23
|
session: (id: string) => string;
|
|
24
|
+
project: (id: string) => string;
|
|
23
25
|
};
|
|
24
26
|
export declare const session: GuestSession;
|
|
25
27
|
export declare const workspace: string;
|