windows-development-sandbox 0.0.0-stage → 1.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.sandbox-config.example.json +14 -0
- package/README.md +78 -2
- package/config.example.json +332 -0
- package/dist/api.d.ts +31 -0
- package/dist/api.js +30 -0
- package/dist/bin/agent-sandbox.d.ts +2 -0
- package/dist/bin/agent-sandbox.js +3 -0
- package/dist/bin/claude-host.d.ts +2 -0
- package/dist/bin/claude-host.js +5 -0
- package/dist/bin/claude-sbx.d.ts +2 -0
- package/dist/bin/claude-sbx.js +3 -0
- package/dist/bin/codex-sbx.d.ts +2 -0
- package/dist/bin/codex-sbx.js +3 -0
- package/dist/bin/gemini-sbx.d.ts +2 -0
- package/dist/bin/gemini-sbx.js +3 -0
- package/dist/bin/sandbox.d.ts +2 -0
- package/dist/bin/sandbox.js +4 -0
- package/dist/bin/sbx-hide.d.ts +2 -0
- package/dist/bin/sbx-hide.js +3 -0
- package/dist/bin/sbx-show.d.ts +2 -0
- package/dist/bin/sbx-show.js +3 -0
- package/dist/cli/args.d.ts +32 -0
- package/dist/cli/args.js +111 -0
- package/dist/cli/commands/accounts.d.ts +2 -0
- package/dist/cli/commands/accounts.js +52 -0
- package/dist/cli/commands/context.d.ts +21 -0
- package/dist/cli/commands/context.js +54 -0
- package/dist/cli/commands/data.d.ts +46 -0
- package/dist/cli/commands/data.js +249 -0
- package/dist/cli/commands/index.d.ts +8 -0
- package/dist/cli/commands/index.js +8 -0
- package/dist/cli/commands/launch.d.ts +5 -0
- package/dist/cli/commands/launch.js +152 -0
- package/dist/cli/commands/maintenance.d.ts +8 -0
- package/dist/cli/commands/maintenance.js +125 -0
- package/dist/cli/commands/repair.d.ts +59 -0
- package/dist/cli/commands/repair.js +332 -0
- package/dist/cli/commands/sessions.d.ts +11 -0
- package/dist/cli/commands/sessions.js +113 -0
- package/dist/cli/commands/setup.d.ts +45 -0
- package/dist/cli/commands/setup.js +152 -0
- package/dist/cli/index.d.ts +14 -0
- package/dist/cli/index.js +96 -0
- package/dist/cli/manifest.d.ts +95 -0
- package/dist/cli/manifest.js +87 -0
- package/dist/cli/menu.d.ts +22 -0
- package/dist/cli/menu.js +187 -0
- package/dist/cli/settings.d.ts +11 -0
- package/dist/cli/settings.js +156 -0
- package/dist/core/config.d.ts +17 -0
- package/dist/core/config.js +103 -0
- package/dist/core/detect-databases.d.ts +15 -0
- package/dist/core/detect-databases.js +147 -0
- package/dist/core/detect-pwsh.d.ts +18 -0
- package/dist/core/detect-pwsh.js +111 -0
- package/dist/core/detect-toolchains.d.ts +30 -0
- package/dist/core/detect-toolchains.js +58 -0
- package/dist/core/local-config.d.ts +12 -0
- package/dist/core/local-config.js +64 -0
- package/dist/core/schema.d.ts +604 -0
- package/dist/core/schema.js +264 -0
- package/dist/core/types.d.ts +162 -0
- package/dist/core/types.js +1 -0
- package/dist/core/util.d.ts +53 -0
- package/dist/core/util.js +145 -0
- package/dist/core/workspace.d.ts +23 -0
- package/dist/core/workspace.js +162 -0
- package/dist/guest/daemon.d.ts +1 -0
- package/dist/guest/daemon.js +186 -0
- package/dist/guest/db.d.ts +1 -0
- package/dist/guest/db.js +60 -0
- package/dist/guest/env.d.ts +49 -0
- package/dist/guest/env.js +99 -0
- package/dist/guest/gitguard.d.ts +7 -0
- package/dist/guest/gitguard.js +60 -0
- package/dist/guest/gitrules.d.ts +2 -0
- package/dist/guest/gitrules.js +11 -0
- package/dist/guest/guide.d.ts +12 -0
- package/dist/guest/guide.js +68 -0
- package/dist/guest/main.d.ts +1 -0
- package/dist/guest/main.js +120 -0
- package/dist/guest/ptyhost.d.ts +40 -0
- package/dist/guest/ptyhost.js +94 -0
- package/dist/guest/setup/agents.d.ts +8 -0
- package/dist/guest/setup/agents.js +73 -0
- package/dist/guest/setup/claude.d.ts +21 -0
- package/dist/guest/setup/claude.js +138 -0
- package/dist/guest/setup/environment.d.ts +6 -0
- package/dist/guest/setup/environment.js +65 -0
- package/dist/guest/setup/hooks.d.ts +1 -0
- package/dist/guest/setup/hooks.js +36 -0
- package/dist/guest/setup/network.d.ts +10 -0
- package/dist/guest/setup/network.js +99 -0
- package/dist/guest/setup/policy.d.ts +3 -0
- package/dist/guest/setup/policy.js +18 -0
- package/dist/guest/setup/user.d.ts +7 -0
- package/dist/guest/setup/user.js +47 -0
- package/dist/guest/toolchains/binstall.d.ts +7 -0
- package/dist/guest/toolchains/binstall.js +55 -0
- package/dist/guest/toolchains/catalogue.d.ts +51 -0
- package/dist/guest/toolchains/catalogue.js +31 -0
- package/dist/guest/toolchains/cli.d.ts +10 -0
- package/dist/guest/toolchains/cli.js +64 -0
- package/dist/guest/toolchains/common.d.ts +37 -0
- package/dist/guest/toolchains/common.js +124 -0
- package/dist/guest/toolchains/databases.d.ts +7 -0
- package/dist/guest/toolchains/databases.js +226 -0
- package/dist/guest/toolchains/git.d.ts +3 -0
- package/dist/guest/toolchains/git.js +112 -0
- package/dist/guest/toolchains/go.d.ts +3 -0
- package/dist/guest/toolchains/go.js +33 -0
- package/dist/guest/toolchains/index.d.ts +13 -0
- package/dist/guest/toolchains/index.js +122 -0
- package/dist/guest/toolchains/java.d.ts +8 -0
- package/dist/guest/toolchains/java.js +96 -0
- package/dist/guest/toolchains/managers.d.ts +16 -0
- package/dist/guest/toolchains/managers.js +82 -0
- package/dist/guest/toolchains/msvc.d.ts +10 -0
- package/dist/guest/toolchains/msvc.js +216 -0
- package/dist/guest/toolchains/native.d.ts +12 -0
- package/dist/guest/toolchains/native.js +220 -0
- package/dist/guest/toolchains/node.d.ts +3 -0
- package/dist/guest/toolchains/node.js +132 -0
- package/dist/guest/toolchains/pwsh.d.ts +4 -0
- package/dist/guest/toolchains/pwsh.js +14 -0
- package/dist/guest/toolchains/python.d.ts +3 -0
- package/dist/guest/toolchains/python.js +47 -0
- package/dist/guest/toolchains/runtimes.d.ts +17 -0
- package/dist/guest/toolchains/runtimes.js +40 -0
- package/dist/guest/toolchains/scripting.d.ts +10 -0
- package/dist/guest/toolchains/scripting.js +137 -0
- package/dist/guest/toolchains/versions.d.ts +47 -0
- package/dist/guest/toolchains/versions.js +77 -0
- package/dist/guest/toolchains/vscode.d.ts +2 -0
- package/dist/guest/toolchains/vscode.js +53 -0
- package/dist/guest/view.d.ts +1 -0
- package/dist/guest/view.js +22 -0
- package/dist/host/accounts.d.ts +32 -0
- package/dist/host/accounts.js +119 -0
- package/dist/host/channel.d.ts +37 -0
- package/dist/host/channel.js +180 -0
- package/dist/host/claude-profile.d.ts +15 -0
- package/dist/host/claude-profile.js +103 -0
- package/dist/host/cleanup.d.ts +23 -0
- package/dist/host/cleanup.js +66 -0
- package/dist/host/generate.d.ts +15 -0
- package/dist/host/generate.js +185 -0
- package/dist/host/personalize.d.ts +10 -0
- package/dist/host/personalize.js +97 -0
- package/dist/host/platform.d.ts +17 -0
- package/dist/host/platform.js +114 -0
- package/dist/host/project-scan.d.ts +23 -0
- package/dist/host/project-scan.js +173 -0
- package/dist/host/repo-guard.d.ts +23 -0
- package/dist/host/repo-guard.js +128 -0
- package/dist/host/secrets.d.ts +14 -0
- package/dist/host/secrets.js +133 -0
- package/dist/host/terminal.d.ts +32 -0
- package/dist/host/terminal.js +127 -0
- package/dist/host/window.d.ts +4 -0
- package/dist/host/window.js +68 -0
- package/dist/runtime/daemon.js +20291 -0
- package/dist/runtime/db.js +20423 -0
- package/dist/runtime/gitguard.js +84 -0
- package/dist/runtime/main.js +22398 -0
- package/dist/runtime/view.js +19966 -0
- package/dist/selftest/cli.test.d.ts +2 -0
- package/dist/selftest/cli.test.js +30 -0
- package/dist/selftest/config.test.d.ts +3 -0
- package/dist/selftest/config.test.js +74 -0
- package/dist/selftest/data.test.d.ts +2 -0
- package/dist/selftest/data.test.js +73 -0
- package/dist/selftest/detect.test.d.ts +2 -0
- package/dist/selftest/detect.test.js +42 -0
- package/dist/selftest/fixtures.d.ts +232 -0
- package/dist/selftest/fixtures.js +63 -0
- package/dist/selftest/guest.test.d.ts +3 -0
- package/dist/selftest/guest.test.js +61 -0
- package/dist/selftest/harness.d.ts +14 -0
- package/dist/selftest/harness.js +36 -0
- package/dist/selftest/helpers.test.d.ts +2 -0
- package/dist/selftest/helpers.test.js +125 -0
- package/dist/selftest/host.test.d.ts +2 -0
- package/dist/selftest/host.test.js +71 -0
- package/dist/selftest/index.d.ts +1 -0
- package/dist/selftest/index.js +56 -0
- package/dist/selftest/live.test.d.ts +7 -0
- package/dist/selftest/live.test.js +127 -0
- package/dist/selftest/mirror.test.d.ts +3 -0
- package/dist/selftest/mirror.test.js +74 -0
- package/dist/selftest/security.test.d.ts +3 -0
- package/dist/selftest/security.test.js +143 -0
- package/dist/selftest/setup.test.d.ts +2 -0
- package/dist/selftest/setup.test.js +23 -0
- package/dist/selftest/sync.test.d.ts +3 -0
- package/dist/selftest/sync.test.js +82 -0
- package/dist/shared/download.d.ts +58 -0
- package/dist/shared/download.js +200 -0
- package/dist/shared/guest-paths.d.ts +30 -0
- package/dist/shared/guest-paths.js +61 -0
- package/dist/shared/netrules.d.ts +18 -0
- package/dist/shared/netrules.js +98 -0
- package/dist/shared/paths.d.ts +9 -0
- package/dist/shared/paths.js +22 -0
- package/dist/shared/pty-client.d.ts +86 -0
- package/dist/shared/pty-client.js +227 -0
- package/dist/shared/rundir.d.ts +30 -0
- package/dist/shared/rundir.js +86 -0
- package/dist/shared/safe-remove.d.ts +10 -0
- package/dist/shared/safe-remove.js +55 -0
- package/dist/shared/schema.d.ts +84 -0
- package/dist/shared/schema.js +51 -0
- package/dist/shared/system.d.ts +15 -0
- package/dist/shared/system.js +19 -0
- package/dist/sync/claude-mem.d.ts +8 -0
- package/dist/sync/claude-mem.js +77 -0
- package/dist/sync/history.d.ts +30 -0
- package/dist/sync/history.js +129 -0
- package/dist/sync/watcher.d.ts +13 -0
- package/dist/sync/watcher.js +116 -0
- package/hooks/post-agent/README.txt +1 -0
- package/hooks/pre-provision/README.txt +1 -0
- package/package.json +91 -4
- package/sandbox/bootstrap.ps1 +258 -0
- package/sandbox/gitshim.cs +35 -0
- package/sandbox/logon.cmd +16 -0
- package/sandbox/ptyhost.cs +603 -0
- package/sandbox/trust.cmd +35 -0
- package/sandbox/vswhere.cs +83 -0
- package/schemas/config.schema.json +1062 -0
- package/schemas/sandbox-config.schema.json +952 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// Host side of the sandbox channel: follow the boot, send requests (sessions, exec, restart), and bridge this terminal
|
|
2
|
+
// to a session's pseudo console. Everything read from the run folder is guest-written and treated as untrusted.
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import net from 'node:net';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import { insideSandbox } from './platform.js';
|
|
8
|
+
import { safeRemove } from '../shared/safe-remove.js';
|
|
9
|
+
import { connect, DETACH_KEY, queryViewers, runTerminalClient } from '../shared/pty-client.js';
|
|
10
|
+
import { listSessions, now, readResponse, readSandboxStatus, readSession, runPaths, writeJsonAtomic } from '../shared/rundir.js';
|
|
11
|
+
import { assertNoLinks, fail, isLink, removeLink, safeText, sleep } from '../core/util.js';
|
|
12
|
+
const BOOT_TIMEOUT_MS = 30 * 60 * 1000;
|
|
13
|
+
const ipToInt = (ip) => ip.split('.').reduce((n, part) => n * 256 + Number(part), 0);
|
|
14
|
+
/**
|
|
15
|
+
* True only for IPv4 addresses inside the subnet of one of this PC's Hyper-V adapters ("vEthernet ..."), which is
|
|
16
|
+
* where Windows Sandbox lives. Stops a compromised sandbox from pointing the host (and your keystrokes) anywhere else.
|
|
17
|
+
*/
|
|
18
|
+
export const isSandboxAddress = (ip, interfaces = os.networkInterfaces()) => {
|
|
19
|
+
if (!net.isIPv4(ip))
|
|
20
|
+
return false;
|
|
21
|
+
return Object.entries(interfaces).some(([name, list]) => /^vEthernet/i.test(name) && (list ?? []).some((i) => {
|
|
22
|
+
if (i.family !== 'IPv4' || i.internal)
|
|
23
|
+
return false;
|
|
24
|
+
const mask = ipToInt(i.netmask);
|
|
25
|
+
return mask !== 0 && (ipToInt(i.address) & mask) === (ipToInt(ip) & mask) && i.address !== ip;
|
|
26
|
+
}));
|
|
27
|
+
};
|
|
28
|
+
const id = () => Date.now().toString(36) + crypto.randomBytes(3).toString('hex');
|
|
29
|
+
const until = async (timeoutMs, isAlive, poll, timeoutMessage) => {
|
|
30
|
+
const deadline = Date.now() + timeoutMs;
|
|
31
|
+
for (let round = 0; Date.now() < deadline; round++) {
|
|
32
|
+
const value = poll();
|
|
33
|
+
if (value !== null && value !== undefined)
|
|
34
|
+
return value;
|
|
35
|
+
if (round % 10 === 0 && !isAlive())
|
|
36
|
+
fail('Windows Sandbox closed (or is not running).', 'Start it again with sandbox claude; sandbox logs shows what happened.');
|
|
37
|
+
await sleep(500);
|
|
38
|
+
}
|
|
39
|
+
fail(timeoutMessage, 'Check what the sandbox is doing with sandbox logs -f (or sbx-show to see its window).');
|
|
40
|
+
};
|
|
41
|
+
/** Waits until the sandbox finished provisioning, printing each boot step. */
|
|
42
|
+
export const waitForReady = (dataPath, launchId, isAlive) => {
|
|
43
|
+
let last = '';
|
|
44
|
+
return until(BOOT_TIMEOUT_MS, isAlive, () => {
|
|
45
|
+
const status = readSandboxStatus(dataPath);
|
|
46
|
+
if (!status || (launchId !== null && status.launchId !== launchId))
|
|
47
|
+
return null;
|
|
48
|
+
if (status.message !== last) {
|
|
49
|
+
console.log(' [sandbox] ' + safeText(status.message).slice(0, 300));
|
|
50
|
+
last = status.message;
|
|
51
|
+
}
|
|
52
|
+
if (status.phase === 'failed')
|
|
53
|
+
fail('The sandbox failed to start: ' + status.message, 'See sandbox logs for details; the sandbox window shows the error too (sbx-show if headless).');
|
|
54
|
+
return status.phase === 'ready' ? status : null;
|
|
55
|
+
}, 'Timed out waiting for the sandbox to finish provisioning.');
|
|
56
|
+
};
|
|
57
|
+
export const sendRequest = (dataPath, request) => {
|
|
58
|
+
assertNoLinks(dataPath, runPaths(dataPath).requests);
|
|
59
|
+
writeJsonAtomic(runPaths(dataPath).request(request.id), request);
|
|
60
|
+
};
|
|
61
|
+
export const waitForResponse = (dataPath, requestId, isAlive, timeoutMs) => until(timeoutMs, isAlive, () => {
|
|
62
|
+
const response = readResponse(dataPath, requestId);
|
|
63
|
+
if (response && !isLink(runPaths(dataPath).responses))
|
|
64
|
+
fs.rmSync(runPaths(dataPath).response(requestId), { force: true });
|
|
65
|
+
return response;
|
|
66
|
+
}, 'The sandbox did not answer (its session daemon may have stopped).');
|
|
67
|
+
export const requestSession = (dataPath, harness, args, cwd) => {
|
|
68
|
+
const requestId = id();
|
|
69
|
+
sendRequest(dataPath, { kind: 'session', id: requestId, harness, args, cwd, createdAt: now() });
|
|
70
|
+
return requestId;
|
|
71
|
+
};
|
|
72
|
+
export const waitForSession = (dataPath, sessionId, isAlive, timeoutMs = 15 * 60 * 1000) => {
|
|
73
|
+
let last = '';
|
|
74
|
+
return until(timeoutMs, isAlive, () => {
|
|
75
|
+
const session = readSession(dataPath, sessionId);
|
|
76
|
+
if (!session)
|
|
77
|
+
return null;
|
|
78
|
+
if (session.message !== last) {
|
|
79
|
+
console.log(' [session] ' + safeText(session.message).slice(0, 300));
|
|
80
|
+
last = session.message;
|
|
81
|
+
}
|
|
82
|
+
if (session.phase === 'failed')
|
|
83
|
+
fail('The agent session failed to start: ' + session.message, 'See sandbox logs for details.');
|
|
84
|
+
if (session.phase === 'exited')
|
|
85
|
+
fail('The agent exited right away (code ' + session.exitCode + ').', 'Check sandbox logs; a wrong agent argument is the usual cause.');
|
|
86
|
+
return session.phase === 'running' ? session : null;
|
|
87
|
+
}, 'Timed out waiting for the session to start.');
|
|
88
|
+
};
|
|
89
|
+
/** Runs a command inside the sandbox (in the workspace or a folder below it) and returns its output. */
|
|
90
|
+
export const execInSandbox = async (dataPath, command, cwd, timeoutSeconds, isAlive) => {
|
|
91
|
+
const requestId = id();
|
|
92
|
+
sendRequest(dataPath, { kind: 'exec', id: requestId, command, cwd, timeoutSeconds, createdAt: now() });
|
|
93
|
+
return waitForResponse(dataPath, requestId, isAlive, (timeoutSeconds + 30) * 1000);
|
|
94
|
+
};
|
|
95
|
+
/** Restarts running sessions in place (they resume via the harness's resumeArguments, e.g. claude --continue). */
|
|
96
|
+
export const restartSessions = async (dataPath, harness, isAlive) => {
|
|
97
|
+
const requestId = id();
|
|
98
|
+
sendRequest(dataPath, { kind: 'restart', id: requestId, harness, createdAt: now() });
|
|
99
|
+
return waitForResponse(dataPath, requestId, isAlive, 30000);
|
|
100
|
+
};
|
|
101
|
+
export const pingSandbox = async (dataPath, isAlive) => {
|
|
102
|
+
const requestId = id();
|
|
103
|
+
sendRequest(dataPath, { kind: 'ping', id: requestId, createdAt: now() });
|
|
104
|
+
return waitForResponse(dataPath, requestId, isAlive, 15000);
|
|
105
|
+
};
|
|
106
|
+
export const runningSessions = (dataPath) => listSessions(dataPath).filter((s) => s.phase === 'running');
|
|
107
|
+
/** Most sessions the host will ever contact for one listing: a sandbox can write as many session files as it likes. */
|
|
108
|
+
export const MAX_QUERIED_SESSIONS = 32;
|
|
109
|
+
/** Running sessions of the sandbox that is running now (its launch id), newest last, capped. */
|
|
110
|
+
export const currentSessions = (dataPath) => {
|
|
111
|
+
const launchId = readSandboxStatus(dataPath)?.launchId;
|
|
112
|
+
return runningSessions(dataPath).filter((s) => s.launchId === launchId).slice(-MAX_QUERIED_SESSIONS);
|
|
113
|
+
};
|
|
114
|
+
/** The session with this id (or id prefix), else the most recently started running one. */
|
|
115
|
+
export const pickSession = (dataPath, sessionId) => {
|
|
116
|
+
const running = runningSessions(dataPath);
|
|
117
|
+
if (running.length === 0)
|
|
118
|
+
fail('No agent session is running in the sandbox.', 'Start one with sandbox claude (it opens a new session in the running sandbox).');
|
|
119
|
+
if (!sessionId)
|
|
120
|
+
return running[running.length - 1];
|
|
121
|
+
return running.find((s) => s.id === sessionId || s.id.startsWith(sessionId)) ?? fail('No running session "' + sessionId + '".', 'Running sessions: ' + running.map((s) => s.id).join(', ') + ' (sandbox sessions lists them).');
|
|
122
|
+
};
|
|
123
|
+
/** Where this PC may connect for a session: only addresses on its own Hyper-V network (null if there are none). */
|
|
124
|
+
const hostEndpoint = (session) => {
|
|
125
|
+
// Inside the sandbox every session is on loopback; on the PC only addresses on its Hyper-V network are trusted.
|
|
126
|
+
const addresses = insideSandbox() ? ['127.0.0.1'] : session.attach?.addresses.filter((a) => isSandboxAddress(a)) ?? [];
|
|
127
|
+
return session.attach && addresses.length ? { ...session.attach, addresses } : null;
|
|
128
|
+
};
|
|
129
|
+
/** Who is watching a session right now (closed terminals do not count); null when it cannot be asked. */
|
|
130
|
+
export const sessionViewers = (session) => {
|
|
131
|
+
const endpoint = hostEndpoint(session);
|
|
132
|
+
return endpoint ? queryViewers(endpoint) : Promise.resolve(null);
|
|
133
|
+
};
|
|
134
|
+
/**
|
|
135
|
+
* The newest running session of this agent in exactly this folder that no terminal on this PC is attached to, so
|
|
136
|
+
* sandbox claude -t picks it back up instead of starting another. A session that cannot answer is never reused.
|
|
137
|
+
*/
|
|
138
|
+
export const findUnwatchedSession = async (dataPath, harness, cwd) => {
|
|
139
|
+
const candidates = currentSessions(dataPath).filter((s) => s.harness === harness && s.cwd.toLowerCase() === cwd.toLowerCase()).reverse().slice(0, 8);
|
|
140
|
+
for (const session of candidates)
|
|
141
|
+
if ((await sessionViewers(session))?.remote === 0)
|
|
142
|
+
return session;
|
|
143
|
+
return null;
|
|
144
|
+
};
|
|
145
|
+
/** Bridges this terminal to a session. Returns the agent's exit code, or 0 when the user detaches. */
|
|
146
|
+
export const attachTerminal = async (session) => {
|
|
147
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY)
|
|
148
|
+
fail('--terminal / attach needs an interactive terminal.', 'Run it directly in Windows Terminal or PowerShell, not through a pipe or script.');
|
|
149
|
+
if (!session.attach)
|
|
150
|
+
fail('This session cannot be attached to: networking or attach is disabled for this sandbox.', 'Set sandbox.networking and attach.enabled to true, then start a new sandbox.');
|
|
151
|
+
const endpoint = hostEndpoint(session) ?? fail('Refusing to connect: the sandbox reported no address on this PC\'s Hyper-V (vEthernet) network (' + session.attach.addresses.join(', ') + ').', 'Use the sandbox window instead (sbx-show); if this keeps happening, run sandbox doctor.');
|
|
152
|
+
const socket = await connect(endpoint);
|
|
153
|
+
console.log('Attaching to ' + safeText(session.harness) + ' session ' + session.id + ' (' + safeText(session.cwd) + '). Detach any time with Ctrl+]');
|
|
154
|
+
await sleep(400);
|
|
155
|
+
const result = await runTerminalClient(socket, session.attach.token, { detachKey: DETACH_KEY });
|
|
156
|
+
switch (result.reason) {
|
|
157
|
+
case 'detach':
|
|
158
|
+
console.log('Detached. The session keeps running; reattach with: sandbox attach ' + session.id);
|
|
159
|
+
return 0;
|
|
160
|
+
case 'exit':
|
|
161
|
+
console.log(safeText(session.harness) + ' exited with code ' + result.exitCode + '.');
|
|
162
|
+
return result.exitCode;
|
|
163
|
+
case 'error': fail('The sandbox refused the connection: ' + result.message, 'Try sandbox attach again; the session may have restarted.');
|
|
164
|
+
default:
|
|
165
|
+
console.log('Connection to the sandbox closed.');
|
|
166
|
+
return 1;
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
/** Forgets status from a previous sandbox run so nothing stale is picked up. */
|
|
170
|
+
export const resetRunDir = (dataPath) => {
|
|
171
|
+
const p = runPaths(dataPath);
|
|
172
|
+
// A link (planted by a sandbox) is removed itself and never followed, including `run` itself.
|
|
173
|
+
if (isLink(p.run)) {
|
|
174
|
+
removeLink(p.run);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
for (const target of [p.sandbox, p.requests, p.responses, p.sessions]) {
|
|
178
|
+
safeRemove(target, dataPath);
|
|
179
|
+
}
|
|
180
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ClaudeProfileConfig, ProfileResult } from '../core/types.js';
|
|
2
|
+
import { type SecretsConfig } from './secrets.js';
|
|
3
|
+
export declare const hostClaudeDir: (settings: ClaudeProfileConfig) => string;
|
|
4
|
+
export declare const hostGlobalStateFile: (settings: ClaudeProfileConfig) => string;
|
|
5
|
+
/**
|
|
6
|
+
* Copies SELECTED Claude settings from this PC into the launch folder. Conversation history is handled
|
|
7
|
+
* separately by history.ts (it needs path translation), and the login only travels when copyCredentials is on.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* What of your .claude.json the sandbox gets: everything except secrets and other projects. API keys and the account
|
|
11
|
+
* block stay on this PC (logins travel only through `sandbox account`), and of the per-project entries only
|
|
12
|
+
* this workspace's (with its MCP servers) is kept; other projects' entries can hold their MCP servers' tokens.
|
|
13
|
+
*/
|
|
14
|
+
export declare const sandboxGlobalState: (state: Record<string, unknown>, workspaceHost: string) => Record<string, unknown>;
|
|
15
|
+
export declare const stageClaudeProfile: (settings: ClaudeProfileConfig, launchPath: string, workspaceHost: string, secrets: SecretsConfig) => ProfileResult;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { stripMcpServers, stripSettings } from './secrets.js';
|
|
4
|
+
import { dirExists, ensureDir, fileExists, homeDir, readJson, writeJson } from '../core/util.js';
|
|
5
|
+
const MAX_BYTES = 250 * 1024 * 1024;
|
|
6
|
+
const directorySize = (dir) => {
|
|
7
|
+
let total = 0;
|
|
8
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
9
|
+
const full = path.join(dir, entry.name);
|
|
10
|
+
total += entry.isDirectory() ? directorySize(full) : fs.statSync(full).size;
|
|
11
|
+
}
|
|
12
|
+
return total;
|
|
13
|
+
};
|
|
14
|
+
const validJson = (file) => {
|
|
15
|
+
try {
|
|
16
|
+
JSON.parse(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, ''));
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
export const hostClaudeDir = (settings) => {
|
|
24
|
+
return settings.hostDir === 'auto' ? (process.env.CLAUDE_CONFIG_DIR ?? path.join(homeDir(), '.claude')) : settings.hostDir;
|
|
25
|
+
};
|
|
26
|
+
export const hostGlobalStateFile = (settings) => {
|
|
27
|
+
if (settings.globalStateFile !== 'auto')
|
|
28
|
+
return settings.globalStateFile;
|
|
29
|
+
return process.env.CLAUDE_CONFIG_DIR ? path.join(process.env.CLAUDE_CONFIG_DIR, '.claude.json') : path.join(homeDir(), '.claude.json');
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Copies SELECTED Claude settings from this PC into the launch folder. Conversation history is handled
|
|
33
|
+
* separately by history.ts (it needs path translation), and the login only travels when copyCredentials is on.
|
|
34
|
+
*/
|
|
35
|
+
/**
|
|
36
|
+
* What of your .claude.json the sandbox gets: everything except secrets and other projects. API keys and the account
|
|
37
|
+
* block stay on this PC (logins travel only through `sandbox account`), and of the per-project entries only
|
|
38
|
+
* this workspace's (with its MCP servers) is kept; other projects' entries can hold their MCP servers' tokens.
|
|
39
|
+
*/
|
|
40
|
+
export const sandboxGlobalState = (state, workspaceHost) => {
|
|
41
|
+
const { primaryApiKey: _key, customApiKeyResponses: _responses, oauthAccount: _account, projects, ...rest } = state;
|
|
42
|
+
const same = (key) => key.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase() === workspaceHost.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
|
|
43
|
+
const own = Object.entries((projects ?? {})).filter(([key]) => same(key));
|
|
44
|
+
return { ...rest, projects: Object.fromEntries(own) };
|
|
45
|
+
};
|
|
46
|
+
export const stageClaudeProfile = (settings, launchPath, workspaceHost, secrets) => {
|
|
47
|
+
const result = { hostDir: null, staged: [], skipped: [], credentials: false, secretsRemoved: [] };
|
|
48
|
+
if (!settings.enabled)
|
|
49
|
+
return result;
|
|
50
|
+
const hostDir = hostClaudeDir(settings);
|
|
51
|
+
result.hostDir = hostDir;
|
|
52
|
+
const target = path.join(launchPath, 'claude-profile');
|
|
53
|
+
ensureDir(target);
|
|
54
|
+
// The login file goes only through copyCredentials (a project file asking for it is flagged); include never copies it.
|
|
55
|
+
const items = settings.include.filter((name) => name.toLowerCase() !== '.credentials.json');
|
|
56
|
+
if (settings.copyCredentials)
|
|
57
|
+
items.push('.credentials.json');
|
|
58
|
+
for (const name of items) {
|
|
59
|
+
if (!/^[A-Za-z0-9._-]+$/.test(name) || name === '.' || name === '..') {
|
|
60
|
+
result.skipped.push(name + ' (invalid name)');
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
const source = path.join(hostDir, name);
|
|
64
|
+
const destination = path.join(target, name);
|
|
65
|
+
if (fileExists(source)) {
|
|
66
|
+
if (name.toLowerCase().endsWith('.json') && !validJson(source)) {
|
|
67
|
+
result.skipped.push(name + ' (invalid JSON on host; fix or remove it)');
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
// Claude's settings can carry credentials in "env" and commands that fetch them; those stay here.
|
|
71
|
+
if (/^settings(.local)?.json$/i.test(name))
|
|
72
|
+
writeJson(destination, stripSettings(readJson(source), secrets, result.secretsRemoved));
|
|
73
|
+
else
|
|
74
|
+
fs.copyFileSync(source, destination);
|
|
75
|
+
}
|
|
76
|
+
else if (dirExists(source)) {
|
|
77
|
+
if (directorySize(source) > MAX_BYTES) {
|
|
78
|
+
result.skipped.push(name + ' (over 250 MB)');
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
fs.cpSync(source, destination, { recursive: true, force: true });
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
result.skipped.push(name + ' (not found)');
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
result.staged.push(name);
|
|
88
|
+
if (name === '.credentials.json')
|
|
89
|
+
result.credentials = true;
|
|
90
|
+
}
|
|
91
|
+
if (settings.globalStateFile !== '') {
|
|
92
|
+
const globalFile = hostGlobalStateFile(settings);
|
|
93
|
+
if (!fileExists(globalFile))
|
|
94
|
+
result.skipped.push('.claude.json (not found)');
|
|
95
|
+
else if (!validJson(globalFile))
|
|
96
|
+
result.skipped.push('.claude.json (invalid JSON on host)');
|
|
97
|
+
else {
|
|
98
|
+
writeJson(path.join(target, '.claude.json'), stripMcpServers(sandboxGlobalState(readJson(globalFile), workspaceHost), secrets, result.secretsRemoved));
|
|
99
|
+
result.staged.push('.claude.json');
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return result;
|
|
103
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export interface PruneOptions {
|
|
2
|
+
launchDays: number;
|
|
3
|
+
logDays: number;
|
|
4
|
+
conflictDays: number;
|
|
5
|
+
keep: string[];
|
|
6
|
+
now?: number;
|
|
7
|
+
}
|
|
8
|
+
export interface PruneResult {
|
|
9
|
+
launches: number;
|
|
10
|
+
logs: number;
|
|
11
|
+
leftovers: number;
|
|
12
|
+
bytes: number;
|
|
13
|
+
}
|
|
14
|
+
export declare const DEFAULT_PRUNE: PruneOptions;
|
|
15
|
+
/**
|
|
16
|
+
* Deletes, under the storage root:
|
|
17
|
+
* - launch folders older than `launchDays` (a launch folder is only used by the sandbox it started; `keep` protects the running one)
|
|
18
|
+
* - sandbox logs older than `logDays`
|
|
19
|
+
* - interrupted downloads/copies (*.staging-*, *.incoming, *.tmp) older than a day
|
|
20
|
+
* - claude-mem conflict copies older than `conflictDays`
|
|
21
|
+
*/
|
|
22
|
+
export declare const pruneStorage: (storageRoot: string, options?: Partial<PruneOptions>) => PruneResult;
|
|
23
|
+
export declare const describePrune: (r: PruneResult) => string;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Keeps storage from growing forever. Runs automatically on every launch and via `sandbox clean`.
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import { safeRemove } from '../shared/safe-remove.js';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import _ from 'lodash';
|
|
6
|
+
import { dirExists, isLink } from '../core/util.js';
|
|
7
|
+
const DAY = 24 * 60 * 60 * 1000;
|
|
8
|
+
export const DEFAULT_PRUNE = { launchDays: 7, logDays: 14, conflictDays: 30, keep: [] };
|
|
9
|
+
/** Size on disk, never following links (a sandbox-planted junction must not make us walk this PC's disk). */
|
|
10
|
+
const sizeOf = (p) => {
|
|
11
|
+
try {
|
|
12
|
+
const stat = fs.lstatSync(p);
|
|
13
|
+
return stat.isDirectory() ? _.sumBy(fs.readdirSync(p), (name) => sizeOf(path.join(p, name))) : stat.isSymbolicLink() ? 0 : stat.size;
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return 0;
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
const ageMs = (p, now) => { try {
|
|
20
|
+
return now - fs.lstatSync(p).mtimeMs;
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return 0;
|
|
24
|
+
} };
|
|
25
|
+
/** Entries of a real folder; a link in place of a folder yields nothing (it is never listed or entered). */
|
|
26
|
+
const children = (dir) => (dirExists(dir) && !isLink(dir) ? fs.readdirSync(dir).map((name) => path.join(dir, name)) : []);
|
|
27
|
+
/**
|
|
28
|
+
* Deletes, under the storage root:
|
|
29
|
+
* - launch folders older than `launchDays` (a launch folder is only used by the sandbox it started; `keep` protects the running one)
|
|
30
|
+
* - sandbox logs older than `logDays`
|
|
31
|
+
* - interrupted downloads/copies (*.staging-*, *.incoming, *.tmp) older than a day
|
|
32
|
+
* - claude-mem conflict copies older than `conflictDays`
|
|
33
|
+
*/
|
|
34
|
+
export const pruneStorage = (storageRoot, options = {}) => {
|
|
35
|
+
const o = { ...DEFAULT_PRUNE, ...options };
|
|
36
|
+
const now = o.now ?? Date.now();
|
|
37
|
+
const result = { launches: 0, logs: 0, leftovers: 0, bytes: 0 };
|
|
38
|
+
const remove = (p, counter) => {
|
|
39
|
+
result.bytes += sizeOf(p);
|
|
40
|
+
safeRemove(p, storageRoot);
|
|
41
|
+
result[counter]++;
|
|
42
|
+
};
|
|
43
|
+
for (const launch of children(path.join(storageRoot, 'launches'))) {
|
|
44
|
+
if (!o.keep.includes(path.basename(launch)) && ageMs(launch, now) > o.launchDays * DAY)
|
|
45
|
+
remove(launch, 'launches');
|
|
46
|
+
}
|
|
47
|
+
for (const data of children(path.join(storageRoot, 'data'))) {
|
|
48
|
+
for (const log of children(path.join(data, 'logs')))
|
|
49
|
+
if (ageMs(log, now) > o.logDays * DAY)
|
|
50
|
+
remove(log, 'logs');
|
|
51
|
+
const runDir = path.join(data, 'run');
|
|
52
|
+
const dirs = [path.join(data, 'tools'), path.join(data, 'state'), ...(isLink(runDir) ? [] : [runDir, path.join(runDir, 'responses')])];
|
|
53
|
+
for (const dir of dirs) {
|
|
54
|
+
for (const entry of children(dir)) {
|
|
55
|
+
const name = path.basename(entry);
|
|
56
|
+
const leftover = /\.staging-[0-9a-f]+$|\.incoming$|\.tmp$/i.test(name) && ageMs(entry, now) > DAY;
|
|
57
|
+
const oldResponse = dir.endsWith('responses') && ageMs(entry, now) > DAY;
|
|
58
|
+
const oldConflict = /\.conflict-\d+$/.test(name) && ageMs(entry, now) > o.conflictDays * DAY;
|
|
59
|
+
if (leftover || oldResponse || oldConflict)
|
|
60
|
+
remove(entry, 'leftovers');
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return result;
|
|
65
|
+
};
|
|
66
|
+
export const describePrune = (r) => r.launches + r.logs + r.leftovers === 0 ? 'nothing to clean' : _.compact([r.launches && r.launches + ' launch folder(s)', r.logs && r.logs + ' log(s)', r.leftovers && r.leftovers + ' leftover(s)']).join(', ') + ' removed (' + Math.round(r.bytes / 1048576) + ' MB)';
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Config, GenerateOptions, Generated, ResolvedMapping } from '../core/types.js';
|
|
2
|
+
export declare const GUEST: {
|
|
3
|
+
readonly launch: "C:\\AgentLaunch";
|
|
4
|
+
readonly data: "C:\\AgentData";
|
|
5
|
+
readonly hostTools: "C:\\HostTools";
|
|
6
|
+
readonly mirror: "C:\\Mirror";
|
|
7
|
+
};
|
|
8
|
+
export declare const resolveMemoryMb: (sandbox: Config["sandbox"], freeMemoryBytes?: number) => number;
|
|
9
|
+
export declare const buildWsb: (config: Config, memoryMb: number, mappings: ResolvedMapping[]) => string;
|
|
10
|
+
export declare const RUNTIME_FILES: string[];
|
|
11
|
+
/** Only the identity from ~/.gitconfig: the rest can hold credentials (helpers, tokens in insteadOf URLs). */
|
|
12
|
+
export declare const gitIdentity: (gitconfig: string) => string;
|
|
13
|
+
export declare const generateSandbox: (o: GenerateOptions) => Generated & {
|
|
14
|
+
personalization: string;
|
|
15
|
+
};
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
import { assertNoGuestOverlap, userDir } from '../core/config.js';
|
|
6
|
+
import { capturePersonalization } from './personalize.js';
|
|
7
|
+
import { assertSafeMapping } from '../core/workspace.js';
|
|
8
|
+
import { isValidGuestPath, MIRROR_ROOT, sandboxFolder, substDrives } from '../shared/guest-paths.js';
|
|
9
|
+
import { hostClaudeDir, stageClaudeProfile } from './claude-profile.js';
|
|
10
|
+
import { decideToolchains, resolveToolchains } from '../core/detect-toolchains.js';
|
|
11
|
+
import { databaseCatalog, msvcHint, pcLanguages, planDatabases, projectEntries } from './project-scan.js';
|
|
12
|
+
import { readPwshScripts, usesPwsh } from '../core/detect-pwsh.js';
|
|
13
|
+
import { dirExists, ensureDir, fail, fileExists, homeDir, writeJson, xmlEscape } from '../core/util.js';
|
|
14
|
+
export const GUEST = { launch: 'C:\\AgentLaunch', data: 'C:\\AgentData', hostTools: 'C:\\HostTools', mirror: MIRROR_ROOT };
|
|
15
|
+
const HOOK_STAGES = ['pre-provision', 'pre-agent', 'post-agent'];
|
|
16
|
+
const toggle = (value) => (value ? 'Enable' : 'Disable');
|
|
17
|
+
export const resolveMemoryMb = (sandbox, freeMemoryBytes = os.freemem()) => {
|
|
18
|
+
if (sandbox.memoryMb !== 'auto')
|
|
19
|
+
return sandbox.memoryMb;
|
|
20
|
+
const freeMb = Math.floor(freeMemoryBytes / 1048576);
|
|
21
|
+
const rounded = Math.floor(Math.min(sandbox.maxMemoryMb, freeMb - sandbox.reserveHostMb) / 1024) * 1024;
|
|
22
|
+
if (rounded < 4096)
|
|
23
|
+
fail('Not enough free RAM for the sandbox (' + freeMb + ' MB free, ' + sandbox.reserveHostMb + ' MB kept for this PC).', 'Close some apps, lower sandbox.reserveHostMb, or set sandbox.memoryMb to a fixed value (>= 4096).');
|
|
24
|
+
return rounded;
|
|
25
|
+
};
|
|
26
|
+
export const buildWsb = (config, memoryMb, mappings) => {
|
|
27
|
+
const s = config.sandbox;
|
|
28
|
+
// Guest paths on other drive letters (F:\x) really live under C:\Mirror\F; bootstrap.ps1 substs F: onto it at boot.
|
|
29
|
+
const folders = mappings.map((m) => '<MappedFolder><HostFolder>' + xmlEscape(m.host) + '</HostFolder><SandboxFolder>' + xmlEscape(sandboxFolder(m.guest)) + '</SandboxFolder><ReadOnly>' + String(m.readOnly) + '</ReadOnly></MappedFolder>').join('');
|
|
30
|
+
return '<Configuration>'
|
|
31
|
+
+ '<MemoryInMB>' + memoryMb + '</MemoryInMB>'
|
|
32
|
+
+ '<vGPU>' + toggle(s.vGpu) + '</vGPU>'
|
|
33
|
+
+ '<Networking>' + toggle(s.networking) + '</Networking>'
|
|
34
|
+
+ '<ClipboardRedirection>' + toggle(s.clipboard) + '</ClipboardRedirection>'
|
|
35
|
+
+ '<AudioInput>' + toggle(s.audioInput) + '</AudioInput>'
|
|
36
|
+
+ '<VideoInput>' + toggle(s.videoInput) + '</VideoInput>'
|
|
37
|
+
+ '<PrinterRedirection>' + toggle(s.printerRedirection) + '</PrinterRedirection>'
|
|
38
|
+
+ '<ProtectedClient>' + toggle(s.protectedClient) + '</ProtectedClient>'
|
|
39
|
+
+ '<MappedFolders>' + folders + '</MappedFolders>'
|
|
40
|
+
+ '<LogonCommand><Command>' + xmlEscape(GUEST.launch + '\\sandbox\\logon.cmd') + '</Command></LogonCommand>'
|
|
41
|
+
+ '</Configuration>';
|
|
42
|
+
};
|
|
43
|
+
const collectMappings = (o, launchPath) => {
|
|
44
|
+
const { config, dataPath, workspaceHost, harnessName } = o.run;
|
|
45
|
+
assertGuestPath(config.guestWorkspace, 'guestWorkspace');
|
|
46
|
+
const mappings = [
|
|
47
|
+
{ host: launchPath, guest: GUEST.launch, readOnly: true },
|
|
48
|
+
{ host: dataPath, guest: GUEST.data, readOnly: false },
|
|
49
|
+
{ host: workspaceHost, guest: config.guestWorkspace, readOnly: config.readOnly },
|
|
50
|
+
];
|
|
51
|
+
for (const m of config.mappings) {
|
|
52
|
+
const host = m.host;
|
|
53
|
+
assertGuestPath(m.guest, 'mappings[].guest for ' + host);
|
|
54
|
+
if (!dirExists(host)) {
|
|
55
|
+
if (m.optional) {
|
|
56
|
+
console.warn('Skipping optional mapping (folder not found): ' + host);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
fail('A mapped folder does not exist: ' + host, 'Create it, fix the path in your config, or mark the mapping "optional": true.');
|
|
60
|
+
}
|
|
61
|
+
assertSafeMapping(host, o.packageRoot, o.run.storageRoot);
|
|
62
|
+
mappings.push({ host, guest: m.guest, readOnly: m.readOnly });
|
|
63
|
+
}
|
|
64
|
+
const { probe } = o;
|
|
65
|
+
if (probe.nodeDir)
|
|
66
|
+
mappings.push({ host: probe.nodeDir, guest: GUEST.hostTools + '\\node', readOnly: true });
|
|
67
|
+
if (probe.harnessExe && harnessName !== 'none')
|
|
68
|
+
mappings.push({ host: probe.harnessExe.dir, guest: GUEST.hostTools + '\\agents\\' + harnessName, readOnly: true });
|
|
69
|
+
if (probe.gitDir)
|
|
70
|
+
mappings.push({ host: probe.gitDir, guest: GUEST.hostTools + '\\git', readOnly: true });
|
|
71
|
+
if (probe.nvmDir)
|
|
72
|
+
mappings.push({ host: probe.nvmDir, guest: GUEST.hostTools + '\\nvm', readOnly: true });
|
|
73
|
+
assertNoGuestOverlap(mappings);
|
|
74
|
+
// Same check on where the folders really are (F:\x and C:\Mirror\F\x are one place).
|
|
75
|
+
assertNoGuestOverlap(mappings.map((m) => ({ guest: sandboxFolder(m.guest) })));
|
|
76
|
+
return mappings;
|
|
77
|
+
};
|
|
78
|
+
/** The schema checks this already; the host re-checks what goes into the .wsb and the subst list anyway. */
|
|
79
|
+
const assertGuestPath = (guest, what) => {
|
|
80
|
+
if (!isValidGuestPath(guest))
|
|
81
|
+
fail(what + ' is not a usable sandbox folder: ' + guest, 'Use a folder under C:\\ (not Windows, Program Files, Users, AgentData, Mirror, ...) or on drive D: to Z:, e.g. C:\\Assets or F:\\SteamLibrary\\steamapps\\common\\Game.');
|
|
82
|
+
};
|
|
83
|
+
/** Package hooks first, then ~/.agent-sandbox/hooks (same file name = user's wins), then --script / "scripts". */
|
|
84
|
+
const stageHooks = (packageRoot, launchPath, scripts) => {
|
|
85
|
+
for (const stage of HOOK_STAGES) {
|
|
86
|
+
const target = path.join(launchPath, 'hooks', stage);
|
|
87
|
+
ensureDir(target);
|
|
88
|
+
for (const source of [path.join(packageRoot, 'hooks', stage), path.join(userDir(), 'hooks', stage)]) {
|
|
89
|
+
if (dirExists(source))
|
|
90
|
+
fs.cpSync(source, target, { recursive: true, force: true });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
scripts.forEach((script, index) => {
|
|
94
|
+
if (!fileExists(script))
|
|
95
|
+
fail('Script file not found: ' + script, 'Check the --script path.');
|
|
96
|
+
fs.copyFileSync(script, path.join(launchPath, 'hooks', 'pre-agent', '90-extra-' + index + '-' + path.basename(script)));
|
|
97
|
+
});
|
|
98
|
+
};
|
|
99
|
+
export const RUNTIME_FILES = ['main.js', 'daemon.js', 'view.js', 'gitguard.js', 'db.js'];
|
|
100
|
+
/** The bundled sandbox runtime (dist/runtime: one self-contained file per entry point) and the sandbox/ scripts. */
|
|
101
|
+
const copyRuntime = (packageRoot, launchPath) => {
|
|
102
|
+
const runtime = path.join(packageRoot, 'dist', 'runtime');
|
|
103
|
+
const missing = RUNTIME_FILES.filter((f) => !fileExists(path.join(runtime, f)));
|
|
104
|
+
if (missing.length)
|
|
105
|
+
fail('The sandbox runtime is missing from ' + runtime + ' (' + missing.join(', ') + ').', 'Rebuild it: npm run build (or reinstall the package).');
|
|
106
|
+
ensureDir(path.join(launchPath, 'runtime'));
|
|
107
|
+
for (const file of RUNTIME_FILES)
|
|
108
|
+
fs.copyFileSync(path.join(runtime, file), path.join(launchPath, 'runtime', file));
|
|
109
|
+
fs.writeFileSync(path.join(launchPath, 'package.json'), '{ "type": "module" }\n', 'utf8');
|
|
110
|
+
fs.cpSync(path.join(packageRoot, 'sandbox'), path.join(launchPath, 'sandbox'), { recursive: true });
|
|
111
|
+
};
|
|
112
|
+
/** Only the identity from ~/.gitconfig: the rest can hold credentials (helpers, tokens in insteadOf URLs). */
|
|
113
|
+
export const gitIdentity = (gitconfig) => {
|
|
114
|
+
const values = {};
|
|
115
|
+
let section = '';
|
|
116
|
+
for (const line of gitconfig.split(/\r?\n/)) {
|
|
117
|
+
const header = /^\s*\[([^\]]+)\]/.exec(line);
|
|
118
|
+
if (header) {
|
|
119
|
+
section = header[1].trim().toLowerCase();
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
const pair = /^\s*(name|email)\s*=\s*(.*?)\s*$/i.exec(line);
|
|
123
|
+
if (section === 'user' && pair && !/[\r\n]/.test(pair[2]))
|
|
124
|
+
values[pair[1].toLowerCase()] = pair[2].replace(/^"(.*)"$/, '$1');
|
|
125
|
+
}
|
|
126
|
+
const lines = Object.entries(values).map(([k, v]) => '\t' + k + ' = ' + v);
|
|
127
|
+
return lines.length ? '[user]\n' + lines.join('\n') + '\n' : '';
|
|
128
|
+
};
|
|
129
|
+
export const generateSandbox = (o) => {
|
|
130
|
+
const { config, dataPath, workspaceHost, harnessName, storageRoot } = o.run;
|
|
131
|
+
const harness = harnessName === 'none' ? null : config.harnesses[harnessName] ?? fail('Unknown agent: ' + harnessName, 'Configured agents: ' + Object.keys(config.harnesses).join(', '));
|
|
132
|
+
if (!dirExists(workspaceHost))
|
|
133
|
+
fail('The workspace folder does not exist: ' + workspaceHost, 'cd into the project first.');
|
|
134
|
+
// Checked before anything is written, so a refusal leaves nothing behind.
|
|
135
|
+
const memoryMb = resolveMemoryMb(config.sandbox, o.freeMemoryBytes);
|
|
136
|
+
const launchId = Date.now() + '-' + crypto.randomBytes(3).toString('hex');
|
|
137
|
+
const launchPath = path.join(storageRoot, 'launches', launchId);
|
|
138
|
+
ensureDir(launchPath);
|
|
139
|
+
ensureDir(dataPath);
|
|
140
|
+
const mappings = collectMappings(o, launchPath);
|
|
141
|
+
const profile = harnessName === 'claude' ? stageClaudeProfile(config.claudeProfile, launchPath, workspaceHost, config.secrets) : { hostDir: null, staged: [], skipped: [], credentials: false, secretsRemoved: [] };
|
|
142
|
+
const gitconfig = path.join(homeDir(), '.gitconfig');
|
|
143
|
+
const identity = fileExists(gitconfig) ? gitIdentity(fs.readFileSync(gitconfig, 'utf8')) : '';
|
|
144
|
+
if (identity) {
|
|
145
|
+
ensureDir(path.join(launchPath, 'home'));
|
|
146
|
+
fs.writeFileSync(path.join(launchPath, 'home', '.gitconfig'), identity, 'utf8');
|
|
147
|
+
}
|
|
148
|
+
const personalization = config.personalization.enabled ? capturePersonalization(launchPath) : 'disabled';
|
|
149
|
+
const databases = o.databases ?? planDatabases(config.databases, workspaceHost);
|
|
150
|
+
const msvcScript = config.toolchains.msvc.enabled === 'auto' ? msvcHint(workspaceHost) : undefined;
|
|
151
|
+
const pwshScript = config.toolchains.pwsh.enabled === 'auto' ? usesPwsh(readPwshScripts(workspaceHost)) : null;
|
|
152
|
+
const toolchainDecisions = decideToolchains(config.toolchains, projectEntries(workspaceHost), o.pcLanguages ?? pcLanguages(), { ...(msvcScript ? { msvc: msvcScript } : {}), ...(pwshScript ? { pwsh: pwshScript } : {}) });
|
|
153
|
+
const session = {
|
|
154
|
+
launchId,
|
|
155
|
+
project: path.basename(workspaceHost),
|
|
156
|
+
workspace: config.guestWorkspace,
|
|
157
|
+
workspaceHost,
|
|
158
|
+
harnessName,
|
|
159
|
+
harness,
|
|
160
|
+
harnessArguments: o.harnessArgs,
|
|
161
|
+
persistAgentState: config.storage.persistAgentState,
|
|
162
|
+
networking: config.sandbox.networking,
|
|
163
|
+
headless: config.sandbox.headless,
|
|
164
|
+
network: config.network,
|
|
165
|
+
provisioning: config.provisioning,
|
|
166
|
+
// "auto" is decided here, so the guest and the launch table agree.
|
|
167
|
+
toolchains: resolveToolchains(config.toolchains, toolchainDecisions),
|
|
168
|
+
databases,
|
|
169
|
+
databaseCatalog: databaseCatalog(config.databases),
|
|
170
|
+
versions: o.run.versions,
|
|
171
|
+
attach: config.attach,
|
|
172
|
+
hooks: config.hooks,
|
|
173
|
+
hostTools: { node: Boolean(o.probe.nodeDir), nodeVersion: o.probe.nodeDir || o.probe.nvmNode ? o.probe.hostNodeVersion.replace(/^v/, '') : null, harnessExecutable: o.probe.harnessExe?.file ?? null, git: Boolean(o.probe.gitDir), nvm: Boolean(o.probe.nvmDir), volta: Boolean(o.probe.volta), pyenv: Boolean(o.probe.pyenv) },
|
|
174
|
+
claudeProfile: { overwrite: config.claudeProfile.overwrite, items: profile.staged, credentials: profile.credentials, hostConfigDir: profile.hostDir ?? hostClaudeDir(config.claudeProfile), syncClaudeMem: config.claudeProfile.syncClaudeMem },
|
|
175
|
+
personalization: fs.existsSync(path.join(launchPath, 'personalize', 'personalize.reg')),
|
|
176
|
+
substDrives: substDrives(mappings.map((m) => m.guest)),
|
|
177
|
+
};
|
|
178
|
+
const sessionPath = path.join(launchPath, 'session.json');
|
|
179
|
+
writeJson(sessionPath, session);
|
|
180
|
+
copyRuntime(o.packageRoot, launchPath);
|
|
181
|
+
stageHooks(o.packageRoot, launchPath, o.run.scripts);
|
|
182
|
+
const wsbPath = path.join(launchPath, 'agent.wsb');
|
|
183
|
+
fs.writeFileSync(wsbPath, buildWsb(config, memoryMb, mappings), 'utf8');
|
|
184
|
+
return { wsbPath, launchPath, launchId, sessionPath, memoryMb, mappings, profile, personalization, databases, toolchains: toolchainDecisions };
|
|
185
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export interface RegValue {
|
|
2
|
+
key: string;
|
|
3
|
+
name: string;
|
|
4
|
+
kind: string;
|
|
5
|
+
value: string | number | string[];
|
|
6
|
+
}
|
|
7
|
+
/** Formats captured values as a .reg file (UTF-16 friendly text; written as UTF-16LE with BOM). */
|
|
8
|
+
export declare const toRegFile: (values: RegValue[]) => string;
|
|
9
|
+
/** Writes personalize.reg (+ the wallpaper image) into `<launch>\personalize`. Returns a short summary. */
|
|
10
|
+
export declare const capturePersonalization: (launchPath: string) => string;
|