windows-development-sandbox 1.4.0 → 1.4.1
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 +2 -1
- package/config.example.json +1 -0
- package/dist/cli/boot-progress.d.ts +22 -0
- package/dist/cli/boot-progress.js +59 -0
- package/dist/cli/commands/launch.d.ts +5 -2
- package/dist/cli/commands/launch.js +77 -22
- package/dist/cli/commands/maintenance.js +36 -3
- package/dist/cli/commands/project.d.ts +10 -6
- package/dist/cli/commands/project.js +42 -3
- package/dist/cli/commands/restart.js +17 -7
- package/dist/cli/commands/sessions.js +2 -1
- package/dist/cli/manifest.js +1 -1
- package/dist/cli/progress.d.ts +15 -0
- package/dist/cli/progress.js +52 -0
- package/dist/core/config.js +1 -1
- package/dist/core/local-config.d.ts +16 -1
- package/dist/core/local-config.js +15 -9
- package/dist/core/project-policy.d.ts +50 -3
- package/dist/core/project-policy.js +90 -4
- package/dist/core/schema/config.d.ts +3 -2
- package/dist/core/schema/config.js +1 -1
- package/dist/core/schema/projects.d.ts +1 -0
- package/dist/core/schema/projects.js +1 -0
- package/dist/core/schema/sandbox.d.ts +1 -1
- package/dist/core/schema/sandbox.js +1 -1
- package/dist/core/types.d.ts +18 -1
- package/dist/core/workspace.d.ts +21 -4
- package/dist/core/workspace.js +87 -26
- package/dist/guest/env.d.ts +6 -2
- package/dist/guest/env.js +15 -3
- package/dist/guest/main.js +5 -1
- package/dist/guest/toolchains/index.js +29 -3
- package/dist/host/channel.d.ts +2 -2
- package/dist/host/channel.js +3 -2
- package/dist/host/generate.d.ts +1 -1
- package/dist/host/generate.js +13 -6
- package/dist/host/join.d.ts +7 -0
- package/dist/host/join.js +23 -9
- package/dist/host/launch/mappings.d.ts +7 -2
- package/dist/host/launch/mappings.js +64 -18
- package/dist/host/launch/projects.d.ts +2 -0
- package/dist/host/launch/projects.js +11 -5
- package/dist/host/projects.d.ts +25 -2
- package/dist/host/projects.js +54 -5
- package/dist/runtime/daemon.js +71 -9
- package/dist/runtime/db.js +33 -3
- package/dist/runtime/main.js +191 -30
- package/dist/runtime/netproxy.js +15 -3
- package/dist/runtime/view.js +262 -128
- package/dist/selftest/channel.test.js +38 -2
- package/dist/selftest/fixtures.d.ts +2 -1
- package/dist/selftest/index.js +2 -1
- package/dist/selftest/live-terminal.test.d.ts +2 -0
- package/dist/selftest/live-terminal.test.js +169 -0
- package/dist/selftest/local-config.test.js +28 -3
- package/dist/selftest/mirror.test.js +36 -7
- package/dist/selftest/projects.test.js +76 -7
- package/dist/shared/console-input.d.ts +11 -0
- package/dist/shared/console-input.js +61 -0
- package/dist/shared/download.d.ts +3 -0
- package/dist/shared/download.js +25 -2
- package/dist/shared/guest-paths.d.ts +27 -0
- package/dist/shared/guest-paths.js +68 -0
- package/dist/shared/projects.d.ts +27 -4
- package/dist/shared/projects.js +72 -13
- package/dist/shared/pty-client.d.ts +17 -0
- package/dist/shared/pty-client.js +65 -8
- package/dist/shared/schema.d.ts +24 -0
- package/dist/shared/schema.js +12 -0
- package/package.json +1 -1
- package/sandbox/ptyhost.cs +60 -3
- package/schemas/config.schema.json +15 -1
- package/schemas/sandbox-config.schema.json +4 -3
package/dist/core/workspace.js
CHANGED
|
@@ -1,18 +1,29 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { z } from 'zod';
|
|
4
|
-
import { planMirrorWorkspace } from '../shared/guest-paths.js';
|
|
4
|
+
import { deriveGuest, placeGuest, planMirrorWorkspace } from '../shared/guest-paths.js';
|
|
5
|
+
import { autoGuestFolder, PROJECTS_GUEST, rootGuestDir, usableRoots } from '../shared/projects.js';
|
|
5
6
|
import { DATABASES } from './detect-databases.js';
|
|
6
|
-
import { allowedAnswer, allowRequests, allowStorePath, applyOverrides, describeOverrides, isTrusted,
|
|
7
|
+
import { allowedAnswer, allowRequests, allowStorePath, applyOverrides, describeOverrides, isTrusted, LOCAL_CONFIG_NAME, loosenedRequests, normalizeOverrides, OUTSIDE_FOLDER, outsideKey, outsideMappings, readLocalConfig, saveAllowed, saveTrust, trustStorePath } from './local-config.js';
|
|
7
8
|
import { assertSafeWorkspace } from './safe-paths.js';
|
|
8
|
-
import { dirExists, expandPath, fail, fileExists, plainText } from './util.js';
|
|
9
|
+
import { dirExists, expandPath, fail, fileExists, isInsideOrEqual, plainText } from './util.js';
|
|
10
|
+
/** 'auto' guest workspace: C:\Workspace\<folder name> (shared/projects.ts, where every project's place is decided). */
|
|
11
|
+
export const autoGuestWorkspace = autoGuestFolder;
|
|
9
12
|
/**
|
|
10
|
-
*
|
|
11
|
-
*
|
|
13
|
+
* A launch folder inside a project root appears under the root's folder (C:\Projects\<root>\<name>, or its PC path
|
|
14
|
+
* with "mirror"), where it also is when another project started the sandbox, so git worktrees and Claude's project
|
|
15
|
+
* folders keep working whichever project starts it. Only for "auto" / "mirror"; a filtered or read-only workspace
|
|
16
|
+
* keeps its own mapping (the root would show the folder unfiltered and writable at that place).
|
|
12
17
|
*/
|
|
13
|
-
export const
|
|
14
|
-
const
|
|
15
|
-
|
|
18
|
+
export const placeInRoot = (config, workspaceHost, mirror, notes) => {
|
|
19
|
+
const guest = rootGuestDir(usableRoots(config.projects.roots, dirExists), mirror, workspaceHost);
|
|
20
|
+
if (!guest)
|
|
21
|
+
return config;
|
|
22
|
+
if (config.workspaceFilter.enabled || config.readOnly) {
|
|
23
|
+
notes.push(workspaceHost + ' is inside a project root, but ' + (config.readOnly ? 'readOnly' : 'workspaceFilter') + ' is on, so it is mapped on its own at ' + config.guestWorkspace + ' (when another project starts the sandbox it is at ' + guest + ').');
|
|
24
|
+
return config;
|
|
25
|
+
}
|
|
26
|
+
return { ...config, guestWorkspace: guest };
|
|
16
27
|
};
|
|
17
28
|
const firstLine = (file) => {
|
|
18
29
|
if (!fileExists(file))
|
|
@@ -79,11 +90,12 @@ export const resolveRun = async (req, hostNodeVersion = process.version) => {
|
|
|
79
90
|
let localConfigPath = null;
|
|
80
91
|
let overrides = {};
|
|
81
92
|
const local = req.noLocalConfig ? null : readLocalConfig(workspaceHost);
|
|
93
|
+
let outsideAllowed = [];
|
|
82
94
|
if (local) {
|
|
83
95
|
overrides = normalizeOverrides(local.data, workspaceHost, local.path);
|
|
84
96
|
const store = trustStorePath(storageRoot);
|
|
85
97
|
if (!isTrusted(store, local.path, local.hash)) {
|
|
86
|
-
const summary = describeOverrides(overrides);
|
|
98
|
+
const summary = describeOverrides(overrides, workspaceHost);
|
|
87
99
|
let trusted = req.trustConfig;
|
|
88
100
|
if (!trusted && req.ask) {
|
|
89
101
|
console.log('\nFound ' + local.path + ' which is not trusted yet. It would apply:');
|
|
@@ -96,16 +108,51 @@ export const resolveRun = async (req, hostNodeVersion = process.version) => {
|
|
|
96
108
|
saveTrust(store, local.path, local.hash);
|
|
97
109
|
notes.push('Trusted ' + local.path + ' (you will only be asked again if the file changes).');
|
|
98
110
|
}
|
|
99
|
-
// A project may only tighten. What it asks for beyond that
|
|
100
|
-
//
|
|
111
|
+
// A project may only tighten. What it asks for beyond that (a looser setting, a folder outside the project) is used
|
|
112
|
+
// only if you allowed it for this project: asked once, in one question, at an interactive launch, never by
|
|
113
|
+
// --trust-config. Folders outside the project follow projects.allowOutsideMappings (your settings only). A looser
|
|
114
|
+
// setting you did not allow is named in the notes instead of dropped silently.
|
|
101
115
|
const allowStore = allowStorePath(storageRoot);
|
|
116
|
+
const policy = config.projects.allowOutsideMappings;
|
|
117
|
+
// An optional folder that is not on this PC is skipped at launch, so it needs no answer (yet).
|
|
118
|
+
const outside = outsideMappings(workspaceHost, overrides.mappings).filter((m) => !m.optional || dirExists(m.host));
|
|
119
|
+
const name = path.basename(workspaceHost) + '\'s ' + LOCAL_CONFIG_NAME;
|
|
120
|
+
const preview = guestWorkspaceOf(applyOverrides(config, overrides), workspaceHost).guest;
|
|
121
|
+
const describe = (m) => (m.readOnly ? 'read-only ' : 'READ/WRITE ') + m.host + ' -> ' + previewGuest(m, workspaceHost, preview).guest;
|
|
122
|
+
if (outside.length && policy === false) {
|
|
123
|
+
fail(name + ' maps ' + outside.length + ' folder' + (outside.length === 1 ? '' : 's') + ' outside the project, and your settings do not allow that:\n ' + outside.map(describe).join('\n '), 'Allow it: set projects.allowOutsideMappings to "ask" or true in your settings (sandbox > Settings, or the file sandbox config path shows). Or remove those mappings from the file.');
|
|
124
|
+
}
|
|
125
|
+
const looser = loosenedRequests(config, overrides);
|
|
126
|
+
const interactive = Boolean(req.ask) && (req.interactive ?? Boolean(process.stdin.isTTY));
|
|
127
|
+
const openOutside = policy === 'ask' ? outside.filter((m) => allowedAnswer(allowStore, local.path, OUTSIDE_FOLDER, outsideKey(m)) !== true) : [];
|
|
128
|
+
const openLooser = looser.filter((a) => allowedAnswer(allowStore, local.path, a.key, a.wanted) === null);
|
|
129
|
+
let declinedOutside = false;
|
|
130
|
+
if (interactive && req.ask && (openOutside.length || openLooser.length)) {
|
|
131
|
+
console.log('\n' + name + ' asks for more than your settings allow:');
|
|
132
|
+
if (openOutside.length)
|
|
133
|
+
console.log(' Folders outside the project:\n' + openOutside.map((m) => ' ' + plainText(describe(m))).join('\n'));
|
|
134
|
+
for (const a of openLooser)
|
|
135
|
+
console.log(' ' + a.key + ' = ' + JSON.stringify(a.wanted) + ' (your settings: ' + JSON.stringify(a.yours) + ')');
|
|
136
|
+
const ok = await req.ask('Allow ' + (openOutside.length + openLooser.length === 1 ? 'it' : 'these') + ' for this project?');
|
|
137
|
+
for (const a of openLooser)
|
|
138
|
+
saveAllowed(allowStore, local.path, a.key, a.wanted, ok);
|
|
139
|
+
// A "no" to folders is not kept: the next interactive launch asks again.
|
|
140
|
+
if (ok)
|
|
141
|
+
for (const m of openOutside)
|
|
142
|
+
saveAllowed(allowStore, local.path, OUTSIDE_FOLDER, outsideKey(m), true);
|
|
143
|
+
declinedOutside = !ok && openOutside.length > 0;
|
|
144
|
+
}
|
|
145
|
+
const notAllowed = policy === 'ask' ? outside.filter((m) => allowedAnswer(allowStore, local.path, OUTSIDE_FOLDER, outsideKey(m)) !== true) : [];
|
|
146
|
+
if (notAllowed.length) {
|
|
147
|
+
const what = name + ' maps ' + notAllowed.length + ' folder' + (notAllowed.length === 1 ? '' : 's') + ' outside the project:\n ' + notAllowed.map(describe).join('\n ');
|
|
148
|
+
fail(what + (declinedOutside ? '\nYou did not allow ' + (notAllowed.length === 1 ? 'it' : 'them') + ', so the sandbox was not started.' : '\nThat needs your OK once (a repo ships this file), and this launch cannot ask (no interactive terminal).'), 'Allow ' + (notAllowed.length === 1 ? 'it' : 'them') + ': start it from an interactive terminal and answer y, or set projects.allowOutsideMappings to true in your settings (sandbox > Settings, or the file sandbox config path shows).');
|
|
149
|
+
}
|
|
150
|
+
outsideAllowed = [...new Set(outside.map((m) => m.host))];
|
|
151
|
+
if (outside.length)
|
|
152
|
+
notes.push('Folders outside the project allowed for ' + name + ' (' + (policy === true ? 'projects.allowOutsideMappings is true' : 'your answer is kept in ' + allowStore) + '): ' + outsideAllowed.join(', '));
|
|
102
153
|
const allowed = [];
|
|
103
|
-
for (const ask of
|
|
104
|
-
|
|
105
|
-
if (ok === null && req.ask && process.stdin.isTTY) {
|
|
106
|
-
ok = await req.ask('This project asks for ' + ask.key + ' = ' + JSON.stringify(ask.wanted) + ', which is looser than your settings (' + JSON.stringify(ask.yours) + '). Allow it for this project only?');
|
|
107
|
-
saveAllowed(allowStore, local.path, ask.key, ask.wanted, ok);
|
|
108
|
-
}
|
|
154
|
+
for (const ask of looser) {
|
|
155
|
+
const ok = allowedAnswer(allowStore, local.path, ask.key, ask.wanted);
|
|
109
156
|
if (ok) {
|
|
110
157
|
allowed.push(ask);
|
|
111
158
|
notes.push('Allowed for this project: ' + ask.key + ' = ' + JSON.stringify(ask.wanted) + ' (your answer is kept in ' + allowStore + ').');
|
|
@@ -126,14 +173,12 @@ export const resolveRun = async (req, hostNodeVersion = process.version) => {
|
|
|
126
173
|
fail('Unknown database "' + name + '".', 'Choose from: ' + DATABASES.join(', '));
|
|
127
174
|
config = { ...config, databases: { ...config.databases, [db]: { ...config.databases[db], enabled: true } } };
|
|
128
175
|
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
config = { ...config, guestWorkspace: plan.guest };
|
|
136
|
-
}
|
|
176
|
+
// auto / mirror: a project inside a root is where the root shows it, whichever project starts the sandbox.
|
|
177
|
+
const guest = guestWorkspaceOf(config, workspaceHost);
|
|
178
|
+
const placed = config.guestWorkspace === 'auto' || mirror ? placeInRoot({ ...config, guestWorkspace: guest.guest }, workspaceHost, mirror, notes) : { ...config, guestWorkspace: guest.guest };
|
|
179
|
+
if (guest.note && placed.guestWorkspace === guest.guest)
|
|
180
|
+
notes.push(guest.note);
|
|
181
|
+
config = placed;
|
|
137
182
|
scripts.push(...req.scripts.map((s) => path.resolve(s)));
|
|
138
183
|
const harnessName = req.noAgent ? 'none' : (req.harness ?? config.defaultHarness);
|
|
139
184
|
if (harnessName !== 'none' && !config.harnesses[harnessName])
|
|
@@ -141,5 +186,21 @@ export const resolveRun = async (req, hostNodeVersion = process.version) => {
|
|
|
141
186
|
const versions = resolveVersions(config, workspaceHost, overrides, req, hostNodeVersion);
|
|
142
187
|
const dataPath = path.join(storageRoot, 'data', config.storage.dataName);
|
|
143
188
|
// A project file may ask for "mirror" too (it decides only its own path); project roots follow the user's choice.
|
|
144
|
-
return { config, storageRoot, dataPath, workspaceHost, harnessName, scripts, localConfigPath, notes, versions, mirror };
|
|
189
|
+
return { config, storageRoot, dataPath, workspaceHost, harnessName, scripts, localConfigPath, notes, versions, mirror, outsideMappings: outsideAllowed };
|
|
190
|
+
};
|
|
191
|
+
/** Where the workspace appears: guestWorkspace "auto", "mirror" (with a note when it falls back) or a path. */
|
|
192
|
+
export const guestWorkspaceOf = (config, workspaceHost) => {
|
|
193
|
+
if (config.guestWorkspace === 'auto')
|
|
194
|
+
return { guest: autoGuestWorkspace(workspaceHost), note: null };
|
|
195
|
+
if (config.guestWorkspace === 'mirror')
|
|
196
|
+
return planMirrorWorkspace(workspaceHost, autoGuestWorkspace(workspaceHost));
|
|
197
|
+
return { guest: config.guestWorkspace, note: null };
|
|
145
198
|
};
|
|
199
|
+
/**
|
|
200
|
+
* Where a mapping will most likely appear, before the launch knows every other folder (the prompt and config check
|
|
201
|
+
* show it; the launch places it for real in host/launch/mappings.ts with the same rules).
|
|
202
|
+
*/
|
|
203
|
+
export const previewGuest = (m, workspaceHost, workspaceGuest) => placeGuest(m.host, m.guest ?? deriveGuest(m.host, workspaceHost, workspaceGuest), [
|
|
204
|
+
...(m.guest || !isInsideOrEqual(workspaceHost, m.host) ? [{ guest: workspaceGuest, what: 'the project' }] : []),
|
|
205
|
+
{ guest: PROJECTS_GUEST, what: 'other projects' },
|
|
206
|
+
]);
|
package/dist/guest/env.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type SpawnOptions, type SpawnSyncOptions } from 'node:child_process';
|
|
2
2
|
import type { GuestSession, SandboxStatus } from '../core/types.js';
|
|
3
|
+
import type { BootProgressT } from '../shared/schema.js';
|
|
3
4
|
export declare const isWin: boolean;
|
|
4
5
|
export declare const dryRun: boolean;
|
|
5
6
|
/** Real provisioning, but nothing machine-wide (environment variables): for end-to-end tests outside a sandbox. */
|
|
@@ -34,8 +35,11 @@ export declare const errMsg: (e: unknown) => string;
|
|
|
34
35
|
export declare const exists: (p: string) => boolean;
|
|
35
36
|
export declare const mkdirp: (p: string) => string;
|
|
36
37
|
export declare const logPath: string;
|
|
37
|
-
/**
|
|
38
|
-
|
|
38
|
+
/**
|
|
39
|
+
* Best-effort: progress reporting must never be what breaks the boot. `update` changes the structured progress
|
|
40
|
+
* (a new step, what is being set up); it stays as it was otherwise.
|
|
41
|
+
*/
|
|
42
|
+
export declare const setSandboxStatus: (phase: SandboxStatus["phase"], message: string, update?: Partial<BootProgressT>) => void;
|
|
39
43
|
/** Runs a program, streaming output, and throws with a readable message when it fails. */
|
|
40
44
|
export declare const exec: (file: string, args: string[], options?: SpawnSyncOptions) => void;
|
|
41
45
|
/** exec without blocking: other toolchains keep downloading while this one runs an installer. */
|
package/dist/guest/env.js
CHANGED
|
@@ -36,18 +36,30 @@ export const errMsg = (e) => (e instanceof Error ? e.message : String(e));
|
|
|
36
36
|
export const exists = (p) => fs.existsSync(p);
|
|
37
37
|
export const mkdirp = (p) => { fs.mkdirSync(p, { recursive: true }); return p; };
|
|
38
38
|
export const logPath = logFile;
|
|
39
|
-
/**
|
|
40
|
-
|
|
39
|
+
/** The boot's structured progress (the host shows it as one line); kept between status writes. */
|
|
40
|
+
let progress;
|
|
41
|
+
/**
|
|
42
|
+
* Best-effort: progress reporting must never be what breaks the boot. `update` changes the structured progress
|
|
43
|
+
* (a new step, what is being set up); it stays as it was otherwise.
|
|
44
|
+
*/
|
|
45
|
+
export const setSandboxStatus = (phase, message, update) => {
|
|
41
46
|
try {
|
|
47
|
+
if (update)
|
|
48
|
+
progress = fitProgress({ step: 1, steps: 1, title: '', active: [], done: 0, total: 0, ...progress, ...update });
|
|
42
49
|
writeJsonAtomicChecked(phase, message);
|
|
43
50
|
}
|
|
44
51
|
catch (e) {
|
|
45
52
|
warn('Could not update sandbox status: ' + errMsg(e));
|
|
46
53
|
}
|
|
47
54
|
};
|
|
55
|
+
/** Within the limits the host accepts (it drops a progress that is not). */
|
|
56
|
+
const fitProgress = (p) => ({
|
|
57
|
+
...p, title: p.title.slice(0, 120), done: Math.min(p.done, 1000), total: Math.min(p.total, 1000),
|
|
58
|
+
active: p.active.slice(0, 8).map((a) => ({ name: a.name.slice(0, 64), ...(a.percent === undefined ? {} : { percent: Math.max(0, Math.min(100, Math.round(a.percent))) }) })),
|
|
59
|
+
});
|
|
48
60
|
const ownAddresses = () => Object.values(os.networkInterfaces()).flat().filter((i) => Boolean(i) && i.family === 'IPv4' && !i.internal).map((i) => i.address).slice(0, 8);
|
|
49
61
|
const writeJsonAtomicChecked = (phase, message) => {
|
|
50
|
-
writeJsonAtomic(run.sandbox, { launchId: session.launchId, phase, message, updatedAt: now(), workspaceHost: session.workspaceHost, guestWorkspace: workspace, headless: session.headless, addresses: ownAddresses() });
|
|
62
|
+
writeJsonAtomic(run.sandbox, { launchId: session.launchId, phase, message, updatedAt: now(), workspaceHost: session.workspaceHost, guestWorkspace: workspace, headless: session.headless, addresses: ownAddresses(), ...(progress ? { progress } : {}) });
|
|
51
63
|
};
|
|
52
64
|
/** Runs a program, streaming output, and throws with a readable message when it fails. */
|
|
53
65
|
export const exec = (file, args, options = {}) => {
|
package/dist/guest/main.js
CHANGED
|
@@ -23,7 +23,11 @@ import { saveBootEnv } from './boot-env.js';
|
|
|
23
23
|
import { primaryProject, saveProjectStatus } from './project-status.js';
|
|
24
24
|
import { dataDir, dryRun, errMsg, isWin, localDir, log, logPath, logsDir, mkdirp, run, session, setSandboxStatus, toolsDir, warn, workspace } from './env.js';
|
|
25
25
|
const STEPS = 8;
|
|
26
|
-
const step = (n, title) => {
|
|
26
|
+
const step = (n, title) => {
|
|
27
|
+
log('\n\x1b[36m[' + n + '/' + STEPS + '] ' + title + '\x1b[0m');
|
|
28
|
+
// The host shows the short title ("Developer tools") with what is being set up in it.
|
|
29
|
+
setSandboxStatus(n === 1 ? 'booting' : 'provisioning', '[' + n + '/' + STEPS + '] ' + title, { step: n, steps: STEPS, title: title.replace(/ \(.*\)$/, ''), active: [], done: 0, total: 0 });
|
|
30
|
+
};
|
|
27
31
|
const soft = async (what, work) => { try {
|
|
28
32
|
await work();
|
|
29
33
|
}
|
|
@@ -1,8 +1,17 @@
|
|
|
1
|
+
// Every developer toolchain and database the sandbox provides (the list is in plan.ts). Installers run in parallel (a
|
|
2
|
+
// few at a time; downloads and installers do not block each other) and each is independent: one failing never blocks
|
|
3
|
+
// the rest. A step that needs another (Rust links with MinGW's gcc, uv installs the Python tools) waits for it.
|
|
4
|
+
// The boot runs it once for the launch folder; the session daemon runs it again for each project registered later,
|
|
5
|
+
// possibly several at once, so the same kind of step (two Node installs, two Go installs) never runs twice at a time.
|
|
6
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
7
|
+
import { watchDownloads } from '../../shared/download.js';
|
|
1
8
|
import { log, mkdirp, setSandboxStatus } from '../env.js';
|
|
2
9
|
import { emptyEnv, step, tool } from './common.js';
|
|
3
10
|
import { plan } from './plan.js';
|
|
4
11
|
/** How many installers run at once: enough to overlap downloads with installs, few enough not to thrash the disk. */
|
|
5
12
|
const PARALLEL = 4;
|
|
13
|
+
/** The step a download belongs to (several run at once), for its progress. */
|
|
14
|
+
const currentStep = new AsyncLocalStorage();
|
|
6
15
|
/** One lock per kind of step ("Node 20" and "Node 22" share nvm's folders): held while that step runs, in any run. */
|
|
7
16
|
const locks = new Map();
|
|
8
17
|
const lockKey = (name) => name.split(' ')[0].toLowerCase();
|
|
@@ -30,9 +39,17 @@ const runSteps = async (steps, o) => {
|
|
|
30
39
|
const running = new Set();
|
|
31
40
|
const waiting = [];
|
|
32
41
|
let done = 0;
|
|
33
|
-
|
|
42
|
+
// The boot (no `report`) also publishes structured progress: what runs now, how far its download is, how many are done.
|
|
43
|
+
const percent = new Map();
|
|
44
|
+
const active = () => [...running].map((name) => ({ name, ...(percent.has(name) ? { percent: percent.get(name) } : {}) }));
|
|
45
|
+
const say = o.report ?? ((message) => setSandboxStatus('provisioning', message, { active: active(), done, total: steps.length }));
|
|
34
46
|
const report = () => { if (running.size)
|
|
35
47
|
say('Installing ' + [...running].join(', ') + ' (' + done + ' of ' + steps.length + ' done)'); };
|
|
48
|
+
if (!o.report)
|
|
49
|
+
watchDownloads(() => {
|
|
50
|
+
const name = currentStep.getStore();
|
|
51
|
+
return name ? (fraction) => { percent.set(name, Math.round(fraction * 100)); report(); } : null;
|
|
52
|
+
});
|
|
36
53
|
for (const s of steps) {
|
|
37
54
|
// Dependencies first (they are earlier in the plan, so their promises exist), then a free slot.
|
|
38
55
|
finished.set(s.name, (async () => {
|
|
@@ -48,17 +65,26 @@ const runSteps = async (steps, o) => {
|
|
|
48
65
|
running.add(s.name);
|
|
49
66
|
report();
|
|
50
67
|
try {
|
|
51
|
-
await exclusive(s.name, () => step(s.name, envs.get(s.name), async () => s.run(envs.get(s.name))));
|
|
68
|
+
await currentStep.run(s.name, () => exclusive(s.name, () => step(s.name, envs.get(s.name), async () => s.run(envs.get(s.name)))));
|
|
52
69
|
}
|
|
53
70
|
finally {
|
|
54
71
|
running.delete(s.name);
|
|
72
|
+
percent.delete(s.name);
|
|
55
73
|
done++;
|
|
56
74
|
report();
|
|
57
75
|
waiting.shift()?.();
|
|
58
76
|
}
|
|
59
77
|
})());
|
|
60
78
|
}
|
|
61
|
-
|
|
79
|
+
try {
|
|
80
|
+
await Promise.all(finished.values());
|
|
81
|
+
}
|
|
82
|
+
finally {
|
|
83
|
+
if (!o.report)
|
|
84
|
+
watchDownloads(null);
|
|
85
|
+
}
|
|
86
|
+
if (!o.report)
|
|
87
|
+
setSandboxStatus('provisioning', 'Set up ' + steps.length + ' toolchains and databases', { active: [], done, total: steps.length });
|
|
62
88
|
return envs;
|
|
63
89
|
};
|
|
64
90
|
/** Provisions every enabled toolchain and database, and returns the combined environment for the agent. */
|
package/dist/host/channel.d.ts
CHANGED
|
@@ -2,8 +2,8 @@ import type { GuestRequest, GuestResponse, Harness, SandboxStatus, SessionStatus
|
|
|
2
2
|
import type { ProjectSpecT } from '../shared/schema.js';
|
|
3
3
|
/** Where progress lines go: printed by default; a spinner passes its own. Text from the sandbox, made safe first. */
|
|
4
4
|
export type Progress = (line: string) => void;
|
|
5
|
-
/** Waits until the sandbox finished provisioning, reporting each boot step. */
|
|
6
|
-
export declare const waitForReady: (dataPath: string, launchId: string | null, isAlive: () => boolean, progress?: Progress) => Promise<SandboxStatus>;
|
|
5
|
+
/** Waits until the sandbox finished provisioning, reporting each boot step (onStatus: every status read, validated). */
|
|
6
|
+
export declare const waitForReady: (dataPath: string, launchId: string | null, isAlive: () => boolean, progress?: Progress, onStatus?: (status: SandboxStatus) => void) => Promise<SandboxStatus>;
|
|
7
7
|
export declare const sendRequest: (dataPath: string, request: GuestRequest) => void;
|
|
8
8
|
export declare const waitForResponse: (dataPath: string, requestId: string, isAlive: () => boolean, timeoutMs: number) => Promise<GuestResponse>;
|
|
9
9
|
/** project: a project registered after boot (provisioned first). harnessSpec: the agent's definition, for an agent the sandbox did not boot with. */
|
package/dist/host/channel.js
CHANGED
|
@@ -22,13 +22,14 @@ const until = async (timeoutMs, isAlive, poll, timeoutMessage) => {
|
|
|
22
22
|
fail(timeoutMessage, 'Check what the sandbox is doing with sandbox logs -f (or sbx-show to see its window).');
|
|
23
23
|
};
|
|
24
24
|
const printLine = (line) => console.log(line);
|
|
25
|
-
/** Waits until the sandbox finished provisioning, reporting each boot step. */
|
|
26
|
-
export const waitForReady = (dataPath, launchId, isAlive, progress = (m) => printLine(' [sandbox] ' + m)) => {
|
|
25
|
+
/** Waits until the sandbox finished provisioning, reporting each boot step (onStatus: every status read, validated). */
|
|
26
|
+
export const waitForReady = (dataPath, launchId, isAlive, progress = (m) => printLine(' [sandbox] ' + m), onStatus) => {
|
|
27
27
|
let last = '';
|
|
28
28
|
return until(BOOT_TIMEOUT_MS, isAlive, () => {
|
|
29
29
|
const status = readSandboxStatus(dataPath);
|
|
30
30
|
if (!status || (launchId !== null && status.launchId !== launchId))
|
|
31
31
|
return null;
|
|
32
|
+
onStatus?.(status);
|
|
32
33
|
if (status.message !== last) {
|
|
33
34
|
progress(safeText(status.message).slice(0, 300));
|
|
34
35
|
last = status.message;
|
package/dist/host/generate.d.ts
CHANGED
|
@@ -8,6 +8,6 @@ export declare const decideProjectToolchains: (o: Pick<GenerateOptions, "run" |
|
|
|
8
8
|
* started. `%` is doubled for cmd; a Windows path holds no quotes.
|
|
9
9
|
*/
|
|
10
10
|
export declare const logonEnv: (workspace: string) => string;
|
|
11
|
-
export declare const generateSandbox: (
|
|
11
|
+
export declare const generateSandbox: (given: GenerateOptions) => Generated & {
|
|
12
12
|
personalization: string;
|
|
13
13
|
};
|
package/dist/host/generate.js
CHANGED
|
@@ -5,6 +5,7 @@ import path from 'node:path';
|
|
|
5
5
|
import crypto from 'node:crypto';
|
|
6
6
|
import { decideToolchains, resolveToolchains } from '../core/detect-toolchains.js';
|
|
7
7
|
import { readPwshScripts, usesPwsh } from '../core/detect-pwsh.js';
|
|
8
|
+
import { widenForProjects } from '../core/project-policy.js';
|
|
8
9
|
import { dirExists, ensureDir, fail, writeJson } from '../core/util.js';
|
|
9
10
|
import { substDrives } from '../shared/guest-paths.js';
|
|
10
11
|
import { PROJECTS_GUEST, hubName } from '../shared/projects.js';
|
|
@@ -43,7 +44,12 @@ const hostToolsOf = (probe) => ({
|
|
|
43
44
|
* started. `%` is doubled for cmd; a Windows path holds no quotes.
|
|
44
45
|
*/
|
|
45
46
|
export const logonEnv = (workspace) => '@set "AGENT_WORKSPACE=' + workspace.replace(/[\r\n"]/g, '').replace(/%/g, '%%') + '"\r\n';
|
|
46
|
-
export const generateSandbox = (
|
|
47
|
+
export const generateSandbox = (given) => {
|
|
48
|
+
// Several projects at boot: the sandbox also gets what the others want and you allowed (core/project-policy.ts).
|
|
49
|
+
const others = given.bootWants?.projects ?? [];
|
|
50
|
+
const boot = planBootProjects({ run: given.run, packageRoot: given.packageRoot, extraProjects: given.extraProjects, knownProjects: given.knownProjects });
|
|
51
|
+
const wide = widenForProjects(given.run.config, others, { keepOff: given.bootWants?.keepOff, exists: dirExists });
|
|
52
|
+
const o = { ...given, run: { ...given.run, config: wide.config } };
|
|
47
53
|
const { config, dataPath, workspaceHost, harnessName, storageRoot } = o.run;
|
|
48
54
|
const harness = harnessName === 'none' ? null : config.harnesses[harnessName] ?? fail('Unknown agent: ' + harnessName, 'Configured agents: ' + Object.keys(config.harnesses).join(', '));
|
|
49
55
|
if (!dirExists(workspaceHost))
|
|
@@ -51,13 +57,13 @@ export const generateSandbox = (o) => {
|
|
|
51
57
|
assertTestWritable(storageRoot);
|
|
52
58
|
assertTestWritable(dataPath);
|
|
53
59
|
// Checked before anything is written, so a refusal leaves nothing behind.
|
|
54
|
-
const memoryMb = resolveMemoryMb(config.sandbox, o.freeMemoryBytes);
|
|
60
|
+
const memoryMb = Math.max(resolveMemoryMb(config.sandbox, o.freeMemoryBytes), ...others.map((p) => typeof p.config.sandbox.memoryMb === 'number' ? p.config.sandbox.memoryMb : 0));
|
|
55
61
|
const launchId = Date.now() + '-' + crypto.randomBytes(3).toString('hex');
|
|
56
62
|
const launchPath = path.join(storageRoot, 'launches', launchId);
|
|
57
63
|
ensureDir(launchPath);
|
|
58
64
|
ensureDir(dataPath);
|
|
59
|
-
const
|
|
60
|
-
const mappings = collectMappings(o, launchPath, boot);
|
|
65
|
+
const mappingNotes = [];
|
|
66
|
+
const mappings = collectMappings(o, launchPath, boot, mappingNotes);
|
|
61
67
|
const profile = harnessName === 'claude' ? stageClaudeProfile(config.claudeProfile, launchPath, workspaceHost, config.secrets) : NO_PROFILE;
|
|
62
68
|
stageGitIdentity(launchPath);
|
|
63
69
|
const personalization = config.personalization.enabled ? capturePersonalization(launchPath) : 'disabled';
|
|
@@ -70,7 +76,8 @@ export const generateSandbox = (o) => {
|
|
|
70
76
|
workspace: config.guestWorkspace,
|
|
71
77
|
workspaceHost,
|
|
72
78
|
projectsDir: PROJECTS_GUEST,
|
|
73
|
-
roots
|
|
79
|
+
// Every folder mapped for other projects (roots and projects mapped at boot): the guest accepts projects in them.
|
|
80
|
+
roots: [...boot.roots, ...boot.projects].map((r) => r.guest),
|
|
74
81
|
...(o.autoSession === false ? { autoSession: false } : {}),
|
|
75
82
|
harnessName,
|
|
76
83
|
harness,
|
|
@@ -104,5 +111,5 @@ export const generateSandbox = (o) => {
|
|
|
104
111
|
stageHooks(o.packageRoot, launchPath, o.run.scripts);
|
|
105
112
|
const wsbPath = path.join(launchPath, 'agent.wsb');
|
|
106
113
|
fs.writeFileSync(wsbPath, buildWsb(config, memoryMb, mappings), 'utf8');
|
|
107
|
-
return { wsbPath, launchPath, launchId, sessionPath, memoryMb, mappings, profile, personalization, databases, toolchains: toolchainDecisions, projects: boot };
|
|
114
|
+
return { wsbPath, launchPath, launchId, sessionPath, memoryMb, bootConfig: config, widened: wide.widened, mappings, profile, personalization, databases, toolchains: toolchainDecisions, projects: { ...boot, notes: [...boot.notes, ...wide.notes] }, mappingNotes };
|
|
108
115
|
};
|
package/dist/host/join.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ResolvedRun } from '../core/types.js';
|
|
2
|
+
import { type Missing } from '../core/project-policy.js';
|
|
2
3
|
import { type ProjectSpecT } from '../shared/schema.js';
|
|
3
4
|
import { type ProjectMapper, type RegistryEntry } from './projects.js';
|
|
4
5
|
/** The launch folder of the running sandbox (host-only storage; its id comes from the guest, so it is checked). */
|
|
@@ -10,7 +11,11 @@ export interface Joined {
|
|
|
10
11
|
spec: ProjectSpecT | null;
|
|
11
12
|
run: ResolvedRun | null;
|
|
12
13
|
notes: string[];
|
|
14
|
+
/** What it wants at boot that the running sandbox started without (it joined without them). */
|
|
15
|
+
missing: Missing | null;
|
|
13
16
|
}
|
|
17
|
+
/** What to do when a joining project wants something the running sandbox started without. */
|
|
18
|
+
export type MissingChoice = 'continue' | 'restart' | 'cancel';
|
|
14
19
|
/** The project spec the guest provisions from (scans the project folder like a launch does). */
|
|
15
20
|
export declare const projectSpec: (run: ResolvedRun, entry: RegistryEntry, hooks: boolean) => ProjectSpecT;
|
|
16
21
|
/**
|
|
@@ -25,4 +30,6 @@ export declare const joinRunning: (o: {
|
|
|
25
30
|
wsbShare: boolean;
|
|
26
31
|
resolve: (projectRoot: string) => Promise<ResolvedRun>;
|
|
27
32
|
mapper?: ProjectMapper;
|
|
33
|
+
/** Asked before anything is registered when something is missing (default: continue without it). */
|
|
34
|
+
decide?: (name: string, missing: Missing) => Promise<MissingChoice>;
|
|
28
35
|
}) => Promise<Joined>;
|
package/dist/host/join.js
CHANGED
|
@@ -2,18 +2,20 @@
|
|
|
2
2
|
// resolve it (its approved .sandbox-config.json, versions, toolchain detection, database plan), checked against the
|
|
3
3
|
// running sandbox's machine-wide protections, made visible inside (host/projects.ts: a project root, a project
|
|
4
4
|
// mapped at boot, or wsb share) and described to the guest, which provisions what is missing before its session.
|
|
5
|
-
// When none of those can make it visible, NeedsRestart reaches the caller, which offers a restart.
|
|
5
|
+
// When none of those can make it visible, NeedsRestart reaches the caller, which offers a restart. So does a project
|
|
6
|
+
// that wants what the sandbox only gets at its start (a .wsb device, more memory, its mappings) when you choose to
|
|
7
|
+
// restart for it; otherwise it joins without them and they are named.
|
|
6
8
|
import fs from 'node:fs';
|
|
7
9
|
import path from 'node:path';
|
|
8
|
-
import { checkJoin, protectionsOf } from '../core/project-policy.js';
|
|
10
|
+
import { checkJoin, missingAtBoot, nothingMissing, protectionsOf } from '../core/project-policy.js';
|
|
9
11
|
import { resolveToolchains } from '../core/detect-toolchains.js';
|
|
10
|
-
import { assertNoLinks, fail } from '../core/util.js';
|
|
12
|
+
import { assertNoLinks, dirExists, fail } from '../core/util.js';
|
|
11
13
|
import { Id } from '../shared/schema.js';
|
|
12
14
|
import { readSandboxStatus } from '../shared/rundir.js';
|
|
13
15
|
import { syncProjectIn } from '../sync/watcher.js';
|
|
14
16
|
import { planDatabases } from './database-plan.js';
|
|
15
17
|
import { decideProjectToolchains } from './generate.js';
|
|
16
|
-
import { projectHere, projectMapper, readRegistry, rememberProject, rootHere } from './projects.js';
|
|
18
|
+
import { bootRecordOf, NeedsRestart, projectHere, projectMapper, readRegistry, rememberProject, rootHere } from './projects.js';
|
|
17
19
|
import { stageScripts } from './launch/stage.js';
|
|
18
20
|
import { addRepoSnapshot } from './repo-guard.js';
|
|
19
21
|
/** The launch folder of the running sandbox (host-only storage; its id comes from the guest, so it is checked). */
|
|
@@ -44,15 +46,27 @@ export const projectSpec = (run, entry, hooks) => {
|
|
|
44
46
|
export const joinRunning = async (o) => {
|
|
45
47
|
const here = projectHere(o.storageRoot, o.hostDir);
|
|
46
48
|
if (here?.project.primary)
|
|
47
|
-
return { ...here, spec: null, run: null, notes: [] };
|
|
49
|
+
return { ...here, spec: null, run: null, notes: [], missing: null };
|
|
48
50
|
const registry = readRegistry(o.storageRoot) ?? fail('The running sandbox was started by an older version, so it cannot take another folder.', 'Stop it (sandbox stop) and start it again.');
|
|
49
51
|
// A folder below a project already in the sandbox uses that project; anything else becomes a project of its own.
|
|
50
52
|
const root = here?.project.host ?? path.resolve(o.hostDir);
|
|
51
53
|
const run = await o.resolve(root);
|
|
52
|
-
const check = checkJoin(registry.protections, protectionsOf(run.config));
|
|
54
|
+
const check = checkJoin(registry.protections, protectionsOf(run.config), Object.keys(registry.atBoot?.widened ?? {}));
|
|
53
55
|
if (check.stricter.length) {
|
|
54
56
|
fail('This project needs stricter protection than the running sandbox has, and those settings are machine-wide:\n ' + check.stricter.join('\n '), 'Its sessions cannot get them without changing them for the sessions already running. Stop the sandbox (sandbox stop) and start it from ' + root + '.');
|
|
55
57
|
}
|
|
58
|
+
// Boot-only wishes (a .wsb device you allowed for it, more memory, its mappings) the running sandbox lacks.
|
|
59
|
+
const launchPath = runningLaunchPath(o.storageRoot, o.dataPath);
|
|
60
|
+
const boot = bootRecordOf(o.storageRoot, launchPath);
|
|
61
|
+
const found = boot ? missingAtBoot(registry.protections, boot, run.config, dirExists) : null;
|
|
62
|
+
const missing = found && !nothingMissing(found) ? found : null;
|
|
63
|
+
if (missing) {
|
|
64
|
+
const choice = o.decide ? await o.decide(here?.project.name ?? path.basename(root), missing) : 'continue';
|
|
65
|
+
if (choice === 'cancel')
|
|
66
|
+
fail('Cancelled: nothing changed.');
|
|
67
|
+
if (choice === 'restart')
|
|
68
|
+
throw new NeedsRestart(root, 'it wants what the sandbox only gets at its start', missing);
|
|
69
|
+
}
|
|
56
70
|
const mapper = o.mapper ?? projectMapper({ storageRoot: o.storageRoot, packageRoot: o.packageRoot, wsbShare: o.wsbShare });
|
|
57
71
|
// Guarded from now on. A folder the sandbox could already write (inside a root, or mapped at boot) and that has no
|
|
58
72
|
// snapshot from the launch gets an empty one: what is there now may have been planted, so it counts as new.
|
|
@@ -60,7 +74,6 @@ export const joinRunning = async (o) => {
|
|
|
60
74
|
addRepoSnapshot(o.dataPath, root, { empty: Boolean(here) || Boolean(rootHere(o.storageRoot, root)) });
|
|
61
75
|
const entry = mapper.register(root);
|
|
62
76
|
rememberProject(o.storageRoot, entry.host);
|
|
63
|
-
const launchPath = runningLaunchPath(o.storageRoot, o.dataPath);
|
|
64
77
|
// Your hooks in ~/.agent-sandbox/hooks ran at boot (once per sandbox); the project's own scripts ("scripts" in its
|
|
65
78
|
// .sandbox-config.json, --script) run before its first agent. The launch folder is read-only for the sandbox, but
|
|
66
79
|
// this side can still add them to it.
|
|
@@ -71,6 +84,7 @@ export const joinRunning = async (o) => {
|
|
|
71
84
|
syncProjectIn(o.dataPath, { hostWorkspace: entry.host, guestWorkspace: entry.guest });
|
|
72
85
|
}
|
|
73
86
|
catch { /* history is a convenience; never block the session */ }
|
|
74
|
-
const notes = [...run.notes, ...check.looser.map((l) => 'The running sandbox is stricter: ' + l + '.')
|
|
75
|
-
|
|
87
|
+
const notes = [...run.notes, ...check.looser.map((l) => 'The running sandbox is stricter: ' + l + '.'),
|
|
88
|
+
...check.shared.map((s) => s + ' is on in this sandbox for another of its projects (you allowed it there); this project shares it.')];
|
|
89
|
+
return { project: entry, guestDir: here ? here.guestDir : entry.guest, spec: projectSpec(run, entry, hooks), run, notes, missing };
|
|
76
90
|
};
|
|
@@ -1,3 +1,8 @@
|
|
|
1
1
|
import type { GenerateOptions, ResolvedMapping } from '../../core/types.js';
|
|
2
|
-
import type
|
|
3
|
-
|
|
2
|
+
import { type BootProjects } from './projects.js';
|
|
3
|
+
/**
|
|
4
|
+
* Every mapping, placed. Your extra folders never stop a launch because of their guest path: one the sandbox cannot
|
|
5
|
+
* use (reserved, invalid, or already in use) goes to C:\Mapped\<drive>\<path>, and `notes` says where and why. What
|
|
6
|
+
* does stop it (a folder that is not there, a whole drive, this tool's own folders) is listed in one message.
|
|
7
|
+
*/
|
|
8
|
+
export declare const collectMappings: (o: GenerateOptions, launchPath: string, boot?: BootProjects, notes?: string[]) => ResolvedMapping[];
|
|
@@ -1,38 +1,84 @@
|
|
|
1
|
+
// Which folders are mapped into the sandbox: the launch folder, the data folder, the workspace, your extra folders and
|
|
2
|
+
// the tools copied from this PC. Everything is checked before it can reach the .wsb.
|
|
3
|
+
import path from 'node:path';
|
|
1
4
|
import { assertNoGuestOverlap } from '../../core/config.js';
|
|
2
5
|
import { assertSafeMapping } from '../../core/safe-paths.js';
|
|
3
|
-
import { dirExists, fail } from '../../core/util.js';
|
|
4
|
-
import {
|
|
6
|
+
import { dirExists, errMsg, fail, isInsideOrEqual, UserError } from '../../core/util.js';
|
|
7
|
+
import { shownByRoot } from './projects.js';
|
|
8
|
+
import { deriveGuest, isValidGuestPath, placeGuest, sandboxFolder, tidyGuestPath } from '../../shared/guest-paths.js';
|
|
9
|
+
import { PROJECTS_GUEST } from '../../shared/projects.js';
|
|
5
10
|
import { GUEST } from './wsb.js';
|
|
6
11
|
import { guestGitDir, planView } from '../workspace-view/index.js';
|
|
7
|
-
|
|
12
|
+
/**
|
|
13
|
+
* Every mapping, placed. Your extra folders never stop a launch because of their guest path: one the sandbox cannot
|
|
14
|
+
* use (reserved, invalid, or already in use) goes to C:\Mapped\<drive>\<path>, and `notes` says where and why. What
|
|
15
|
+
* does stop it (a folder that is not there, a whole drive, this tool's own folders) is listed in one message.
|
|
16
|
+
*/
|
|
17
|
+
export const collectMappings = (o, launchPath, boot = { roots: [], projects: [], notes: [] }, notes = []) => {
|
|
8
18
|
const { config, dataPath, workspaceHost, harnessName } = o.run;
|
|
9
19
|
assertGuestPath(config.guestWorkspace, 'guestWorkspace');
|
|
10
20
|
// workspaceFilter: the sandbox gets the project's filtered view (no git-ignored files) and its real .git beside it.
|
|
11
21
|
const view = planView(config, o.run.storageRoot, workspaceHost);
|
|
22
|
+
// A launch folder inside a project root is shown by the root, at the place it has whichever project starts the sandbox.
|
|
23
|
+
const inRoot = !view && shownByRoot(boot.roots, workspaceHost, config.guestWorkspace);
|
|
12
24
|
const mappings = [
|
|
13
25
|
{ host: launchPath, guest: GUEST.launch, readOnly: true },
|
|
14
26
|
{ host: dataPath, guest: GUEST.data, readOnly: false },
|
|
15
|
-
{ host: view ? view.options.view : workspaceHost, guest: config.guestWorkspace, readOnly: config.readOnly },
|
|
27
|
+
...(inRoot ? [] : [{ host: view ? view.options.view : workspaceHost, guest: config.guestWorkspace, readOnly: config.readOnly }]),
|
|
16
28
|
...(view?.hostGit ? [{ host: view.hostGit, guest: guestGitDir(config.guestWorkspace), readOnly: config.readOnly }] : []),
|
|
17
29
|
];
|
|
30
|
+
// Project roots and other projects, so they can join the running sandbox without a restart (launch/projects.ts).
|
|
31
|
+
const projects = [];
|
|
32
|
+
for (const m of [...boot.roots, ...boot.projects]) {
|
|
33
|
+
assertGuestPath(m.guest, 'project folder for ' + m.host);
|
|
34
|
+
projects.push({ host: m.host, guest: m.guest, readOnly: false });
|
|
35
|
+
}
|
|
36
|
+
const taken = [
|
|
37
|
+
{ guest: config.guestWorkspace, what: 'the project' },
|
|
38
|
+
...(view?.hostGit ? [{ guest: guestGitDir(config.guestWorkspace), what: 'the project\'s .git' }] : []),
|
|
39
|
+
{ guest: PROJECTS_GUEST, what: 'the other projects' },
|
|
40
|
+
...projects.map((p) => ({ guest: p.guest, what: p.host })),
|
|
41
|
+
];
|
|
42
|
+
const outside = new Set((o.run.outsideMappings ?? []).map((h) => path.resolve(h).toLowerCase()));
|
|
43
|
+
const problems = [];
|
|
44
|
+
const missing = [];
|
|
45
|
+
const extra = [];
|
|
18
46
|
for (const m of config.mappings) {
|
|
19
47
|
const host = m.host;
|
|
20
|
-
assertGuestPath(m.guest, 'mappings[].guest for ' + host);
|
|
21
48
|
if (!dirExists(host)) {
|
|
22
|
-
if (m.optional)
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
49
|
+
if (m.optional)
|
|
50
|
+
missing.push(host);
|
|
51
|
+
else
|
|
52
|
+
problems.push(host + ': the folder does not exist. Fix: create it, correct the path, or mark the mapping "optional": true.');
|
|
53
|
+
continue;
|
|
27
54
|
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
55
|
+
try {
|
|
56
|
+
assertSafeMapping(host, o.packageRoot, o.run.storageRoot);
|
|
57
|
+
}
|
|
58
|
+
catch (e) {
|
|
59
|
+
problems.push(errMsg(e) + (e instanceof UserError && e.fix ? ' Fix: ' + e.fix : ''));
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
// A folder inside the project with no guest path of its own is already there (unless the filtered view hides it).
|
|
63
|
+
if (!m.guest && !view && isInsideOrEqual(workspaceHost, host)) {
|
|
64
|
+
notes.push(host + ' is inside the project, so it is already in the sandbox at ' + deriveGuest(host, workspaceHost, config.guestWorkspace) + '.');
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
const wanted = tidyGuestPath(m.guest ?? deriveGuest(host, workspaceHost, config.guestWorkspace));
|
|
68
|
+
// The same folder at the same place twice (your settings and the project file): once is enough.
|
|
69
|
+
if (extra.some((x) => x.host.toLowerCase() === host.toLowerCase() && x.guest.toLowerCase() === wanted.toLowerCase()))
|
|
70
|
+
continue;
|
|
71
|
+
const placed = placeGuest(host, wanted, [...taken, ...extra.map((x) => ({ guest: x.guest, what: x.host }))]);
|
|
72
|
+
if (placed.note)
|
|
73
|
+
notes.push(placed.note);
|
|
74
|
+
const label = outside.has(path.resolve(host).toLowerCase()) ? 'outside the project, allowed' : undefined;
|
|
75
|
+
extra.push({ host, guest: placed.guest, readOnly: m.readOnly, ...(label ? { label } : {}) });
|
|
35
76
|
}
|
|
77
|
+
if (problems.length)
|
|
78
|
+
fail((problems.length === 1 ? 'A mapped folder cannot be used:\n ' : problems.length + ' mapped folders cannot be used:\n ') + problems.join('\n '));
|
|
79
|
+
if (missing.length)
|
|
80
|
+
notes.push('Skipped ' + (missing.length === 1 ? 'an optional folder' : missing.length + ' optional folders') + ' not on this PC: ' + missing.join(', '));
|
|
81
|
+
mappings.push(...extra, ...projects);
|
|
36
82
|
const { probe } = o;
|
|
37
83
|
if (probe.nodeDir)
|
|
38
84
|
mappings.push({ host: probe.nodeDir, guest: GUEST.hostTools + '\\node', readOnly: true });
|
|
@@ -42,8 +88,8 @@ export const collectMappings = (o, launchPath, boot = { roots: [], projects: [],
|
|
|
42
88
|
mappings.push({ host: probe.gitDir, guest: GUEST.hostTools + '\\git', readOnly: true });
|
|
43
89
|
if (probe.nvmDir)
|
|
44
90
|
mappings.push({ host: probe.nvmDir, guest: GUEST.hostTools + '\\nvm', readOnly: true });
|
|
91
|
+
// Placement keeps extra folders apart; this guards the rest (and where the folders really are: F:\x is C:\Mirror\F\x).
|
|
45
92
|
assertNoGuestOverlap(mappings);
|
|
46
|
-
// Same check on where the folders really are (F:\x and C:\Mirror\F\x are one place).
|
|
47
93
|
assertNoGuestOverlap(mappings.map((m) => ({ guest: sandboxFolder(m.guest) })));
|
|
48
94
|
return mappings;
|
|
49
95
|
};
|