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.
Files changed (73) hide show
  1. package/.sandbox-config.example.json +2 -1
  2. package/config.example.json +1 -0
  3. package/dist/cli/boot-progress.d.ts +22 -0
  4. package/dist/cli/boot-progress.js +59 -0
  5. package/dist/cli/commands/launch.d.ts +5 -2
  6. package/dist/cli/commands/launch.js +77 -22
  7. package/dist/cli/commands/maintenance.js +36 -3
  8. package/dist/cli/commands/project.d.ts +10 -6
  9. package/dist/cli/commands/project.js +42 -3
  10. package/dist/cli/commands/restart.js +17 -7
  11. package/dist/cli/commands/sessions.js +2 -1
  12. package/dist/cli/manifest.js +1 -1
  13. package/dist/cli/progress.d.ts +15 -0
  14. package/dist/cli/progress.js +52 -0
  15. package/dist/core/config.js +1 -1
  16. package/dist/core/local-config.d.ts +16 -1
  17. package/dist/core/local-config.js +15 -9
  18. package/dist/core/project-policy.d.ts +50 -3
  19. package/dist/core/project-policy.js +90 -4
  20. package/dist/core/schema/config.d.ts +3 -2
  21. package/dist/core/schema/config.js +1 -1
  22. package/dist/core/schema/projects.d.ts +1 -0
  23. package/dist/core/schema/projects.js +1 -0
  24. package/dist/core/schema/sandbox.d.ts +1 -1
  25. package/dist/core/schema/sandbox.js +1 -1
  26. package/dist/core/types.d.ts +18 -1
  27. package/dist/core/workspace.d.ts +21 -4
  28. package/dist/core/workspace.js +87 -26
  29. package/dist/guest/env.d.ts +6 -2
  30. package/dist/guest/env.js +15 -3
  31. package/dist/guest/main.js +5 -1
  32. package/dist/guest/toolchains/index.js +29 -3
  33. package/dist/host/channel.d.ts +2 -2
  34. package/dist/host/channel.js +3 -2
  35. package/dist/host/generate.d.ts +1 -1
  36. package/dist/host/generate.js +13 -6
  37. package/dist/host/join.d.ts +7 -0
  38. package/dist/host/join.js +23 -9
  39. package/dist/host/launch/mappings.d.ts +7 -2
  40. package/dist/host/launch/mappings.js +64 -18
  41. package/dist/host/launch/projects.d.ts +2 -0
  42. package/dist/host/launch/projects.js +11 -5
  43. package/dist/host/projects.d.ts +25 -2
  44. package/dist/host/projects.js +54 -5
  45. package/dist/runtime/daemon.js +71 -9
  46. package/dist/runtime/db.js +33 -3
  47. package/dist/runtime/main.js +191 -30
  48. package/dist/runtime/netproxy.js +15 -3
  49. package/dist/runtime/view.js +262 -128
  50. package/dist/selftest/channel.test.js +38 -2
  51. package/dist/selftest/fixtures.d.ts +2 -1
  52. package/dist/selftest/index.js +2 -1
  53. package/dist/selftest/live-terminal.test.d.ts +2 -0
  54. package/dist/selftest/live-terminal.test.js +169 -0
  55. package/dist/selftest/local-config.test.js +28 -3
  56. package/dist/selftest/mirror.test.js +36 -7
  57. package/dist/selftest/projects.test.js +76 -7
  58. package/dist/shared/console-input.d.ts +11 -0
  59. package/dist/shared/console-input.js +61 -0
  60. package/dist/shared/download.d.ts +3 -0
  61. package/dist/shared/download.js +25 -2
  62. package/dist/shared/guest-paths.d.ts +27 -0
  63. package/dist/shared/guest-paths.js +68 -0
  64. package/dist/shared/projects.d.ts +27 -4
  65. package/dist/shared/projects.js +72 -13
  66. package/dist/shared/pty-client.d.ts +17 -0
  67. package/dist/shared/pty-client.js +65 -8
  68. package/dist/shared/schema.d.ts +24 -0
  69. package/dist/shared/schema.js +12 -0
  70. package/package.json +1 -1
  71. package/sandbox/ptyhost.cs +60 -3
  72. package/schemas/config.schema.json +15 -1
  73. package/schemas/sandbox-config.schema.json +4 -3
@@ -8,7 +8,8 @@
8
8
  "rust": { "enabled": false }
9
9
  },
10
10
  "mappings": [
11
- { "host": "D:\\Assets\\textures", "guest": "C:\\Assets", "readOnly": true, "optional": true }
11
+ { "host": "./data", "guest": "C:\\Data", "readOnly": true, "optional": true },
12
+ { "host": "D:\\Assets\\textures", "readOnly": true, "optional": true }
12
13
  ],
13
14
  "scripts": []
14
15
  }
@@ -377,6 +377,7 @@
377
377
  "roots": [],
378
378
  "askToAddRoot": true,
379
379
  "declinedRoots": [],
380
+ "allowOutsideMappings": "ask",
380
381
  "wsbShare": true
381
382
  }
382
383
  }
@@ -0,0 +1,22 @@
1
+ import type { SandboxStatus } from '../core/types.js';
2
+ /** One progress line: `line` is shown, `key` changes only when the step or what runs changes, `what` names it. */
3
+ export interface Shown {
4
+ line: string;
5
+ key: string;
6
+ what: string;
7
+ }
8
+ /** "[3/8] Developer tools: downloading rust 40%, setting up go (2 of 6 done)". */
9
+ export declare const bootLine: (status: SandboxStatus | null, fallback?: string) => Shown;
10
+ /** Nothing new for a while: say so, and where the details are. */
11
+ export declare const stillWorking: (what: string, ms: number) => string;
12
+ export declare const readyIn: (ms: number) => string;
13
+ /** The wanted tools that are not downloaded yet (`has` says whether a folder in <data>\tools exists), with their rough size. */
14
+ export declare const pendingDownloads: (wanted: string[], has: (dir: string) => boolean) => {
15
+ names: string[];
16
+ mb: number;
17
+ };
18
+ /** The heads-up before the wait, or null when everything is already downloaded. first: nothing was ever installed. */
19
+ export declare const firstStartNotice: (first: boolean, pending: {
20
+ names: string[];
21
+ mb: number;
22
+ }) => string | null;
@@ -0,0 +1,59 @@
1
+ // What the terminal says while a sandbox starts: the first-start heads-up (what is downloaded, roughly how much) and
2
+ // one line of progress from the guest's status. Pure: the status is guest-written, so every bit of it is made safe
3
+ // and short here.
4
+ import { plainText } from '../core/util.js';
5
+ import { elapsed } from './progress.js';
6
+ const clean = (text, max) => plainText(text).replace(/\s+/g, ' ').trim().slice(0, max);
7
+ /** "[3/8] Developer tools: downloading rust 40%, setting up go (2 of 6 done)". */
8
+ export const bootLine = (status, fallback = 'Starting Windows Sandbox') => {
9
+ if (!status)
10
+ return { line: fallback, key: fallback, what: fallback.toLowerCase() };
11
+ const p = status.progress;
12
+ if (!p) {
13
+ const line = clean(status.message, 200) || fallback;
14
+ return { line, key: line, what: line };
15
+ }
16
+ const title = clean(p.title, 60);
17
+ const items = p.active.slice(0, 8).map((a) => ({ name: clean(a.name, 40), percent: a.percent }));
18
+ const loading = items.filter((a) => a.percent !== undefined && a.percent < 100);
19
+ const others = items.filter((a) => !loading.includes(a));
20
+ const parts = [
21
+ ...(loading.length ? ['downloading ' + loading.map((a) => a.name + ' ' + a.percent + '%').join(', ')] : []),
22
+ ...(others.length ? ['setting up ' + others.map((a) => a.name).join(', ')] : []),
23
+ ];
24
+ const count = p.total > 0 && items.length ? ' (' + Math.min(p.done, p.total) + ' of ' + p.total + ' done)' : '';
25
+ const head = '[' + p.step + '/' + p.steps + '] ' + title;
26
+ return {
27
+ line: (head + (parts.length ? ': ' + parts.join('; ') : '') + count).slice(0, 240),
28
+ key: head + '|' + items.map((a) => a.name).sort().join(','),
29
+ what: items.length ? items.map((a) => a.name).join(', ') : title,
30
+ };
31
+ };
32
+ /** Nothing new for a while: say so, and where the details are. */
33
+ export const stillWorking = (what, ms) => 'Still working on ' + clean(what, 80) + ' (' + Math.max(1, Math.round(ms / 60000)) + ' min). Full log: sandbox logs -f';
34
+ export const readyIn = (ms) => 'Ready in ' + elapsed(ms) + '.';
35
+ // ---- the first-start heads-up ----------------------------------------------------------------------------------------
36
+ /** What a first start downloads, by setting name: its folder in <data>\tools, rough size and a readable name. */
37
+ const DOWNLOADS = {
38
+ node: { dir: 'nvm', mb: 30 }, python: { dir: 'python', mb: 30 }, rust: { dir: 'rust', mb: 300 }, msvc: { dir: 'msvc', mb: 1500, label: 'MSVC' },
39
+ cpp: { dir: 'mingw', mb: 270, label: 'C/C++' }, dotnet: { dir: 'dotnet', mb: 250, label: '.NET' }, go: { dir: 'go', mb: 80 }, java: { dir: 'java', mb: 200 },
40
+ ruby: { dir: 'ruby', mb: 100 }, php: { dir: 'php', mb: 30 }, deno: { dir: 'deno', mb: 40 }, pwsh: { dir: 'pwsh', mb: 100, label: 'PowerShell 7' },
41
+ vscode: { dir: 'vscode', mb: 150, label: 'VS Code' },
42
+ postgres: { dir: 'postgres', mb: 330, label: 'PostgreSQL' }, mysql: { dir: 'mariadb', mb: 90, label: 'MariaDB' }, mongodb: { dir: 'mongodb', mb: 450, label: 'MongoDB' },
43
+ redis: { dir: 'redis', mb: 10 }, dynamodb: { dir: 'dynamodb-local', mb: 80, label: 'DynamoDB Local' },
44
+ };
45
+ /** The wanted tools that are not downloaded yet (`has` says whether a folder in <data>\tools exists), with their rough size. */
46
+ export const pendingDownloads = (wanted, has) => {
47
+ const missing = [...new Set(wanted)].filter((w) => Object.hasOwn(DOWNLOADS, w) && !has(DOWNLOADS[w].dir));
48
+ return { names: missing.map((w) => DOWNLOADS[w].label ?? w), mb: missing.reduce((sum, w) => sum + DOWNLOADS[w].mb, 0) };
49
+ };
50
+ const roughSize = (mb) => (mb >= 950 ? (Math.round(mb / 100) / 10) + ' GB' : Math.max(10, Math.round(mb / 10) * 10) + ' MB');
51
+ /** The heads-up before the wait, or null when everything is already downloaded. first: nothing was ever installed. */
52
+ export const firstStartNotice = (first, pending) => {
53
+ if (!first && !pending.names.length)
54
+ return null;
55
+ const what = pending.names.length ? ' (about ' + roughSize(pending.mb) + ': ' + pending.names.slice(0, 8).join(', ') + (pending.names.length > 8 ? ', ...' : '') + ')' : '';
56
+ return first
57
+ ? 'First start: the sandbox downloads the tools this project needs once' + what + '. This can take 5-20 minutes depending on your connection; later starts take about a minute.'
58
+ : 'New tools for this project: the sandbox downloads them once' + what + '. This can take a few minutes; later starts skip it.';
59
+ };
@@ -6,8 +6,11 @@ import { type Joined } from './project.js';
6
6
  export declare const attachHere: (ctx: Context, session: Parameters<typeof attachTerminal>[0]) => Promise<number>;
7
7
  /** The sandbox writes sandbox.json a few seconds into its boot; a second launch right after the first waits for it. */
8
8
  export declare const runningSandboxStatus: (dataPath: string) => Promise<SandboxStatus>;
9
- /** Waits for the boot with its current step on a spinner (plain lines without a terminal). */
10
- export declare const waitForBoot: (dataPath: string, launchId: string | null) => Promise<SandboxStatus>;
9
+ /**
10
+ * Waits for the boot with its current step and what it sets up on one line (plain lines without a terminal).
11
+ * timed: say how long it took (a first start, where that is worth knowing).
12
+ */
13
+ export declare const waitForBoot: (dataPath: string, launchId: string | null, timed?: boolean) => Promise<SandboxStatus>;
11
14
  /** The running sandbox, once it is ready. */
12
15
  export declare const readySandbox: (dataPath: string) => Promise<SandboxStatus>;
13
16
  /**
@@ -12,7 +12,7 @@ import { generateSandbox } from '../../host/generate.js';
12
12
  import { findSecretFiles } from '../../host/secrets.js';
13
13
  import { checkRepo, saveRepoSnapshot } from '../../host/repo-guard.js';
14
14
  import { assertCanLaunch, insideSandbox, isSandboxRunning, probeHost, spawnDetached, startSandbox } from '../../host/platform.js';
15
- import { NeedsRestart, rememberProject, startRegistry } from '../../host/projects.js';
15
+ import { NeedsRestart, readKnown, rememberProject, startRegistry } from '../../host/projects.js';
16
16
  import { ensureConpty, ensureTerminal } from '../../host/terminal.js';
17
17
  import { planView, saveActiveViews, viewSkipped, WorkspaceView } from '../../host/workspace-view/index.js';
18
18
  import { saveForwards } from '../../host/port-forward.js';
@@ -23,14 +23,18 @@ import { hideSandboxWindow, showSandboxWindow } from '../../host/window.js';
23
23
  import { readSandboxStatus } from '../../shared/rundir.js';
24
24
  import { isWithin } from '../../shared/projects.js';
25
25
  import { syncBeforeLaunch, watcherRunning } from '../../sync/watcher.js';
26
- import { protectionsOf } from '../../core/project-policy.js';
26
+ import { BOOT_SETTINGS, protectionsOf } from '../../core/project-policy.js';
27
+ import { LOCAL_CONFIG_NAME } from '../../core/local-config.js';
27
28
  import { assertSafeMapping, assertSafeWorkspace } from '../../core/safe-paths.js';
28
- import { errMsg, fail, isRootPath, sleep } from '../../core/util.js';
29
+ import { dirExists, errMsg, fail, fileExists, isRootPath, sleep } from '../../core/util.js';
30
+ import { planBootProjects } from '../../host/launch/projects.js';
29
31
  import { syncAgentCommands } from './agent-commands.js';
30
32
  import { askYesNo } from '../../core/prompt.js';
31
- import { resolveRun } from '../../core/workspace.js';
33
+ import { autoGuestWorkspace, placeInRoot, resolveRun } from '../../core/workspace.js';
34
+ import { planMirrorWorkspace } from '../../shared/guest-paths.js';
32
35
  import { c } from '../colors.js';
33
- import { working } from '../progress.js';
36
+ import { follow, working } from '../progress.js';
37
+ import { bootLine, firstStartNotice, pendingDownloads, readyIn, stillWorking } from '../boot-progress.js';
34
38
  import { requireOnPc, sandboxAlive, table } from './context.js';
35
39
  import _ from 'lodash';
36
40
  import { hereDir, joinHere, readyHere } from './project.js';
@@ -45,8 +49,11 @@ export const attachHere = (ctx, session) => {
45
49
  };
46
50
  /** Nothing has been installed into this data set yet: the first start downloads every toolchain once. */
47
51
  const firstBoot = (dataPath) => !fs.existsSync(path.join(dataPath, 'tools', 'resolved-versions.json'));
48
- const FIRST_BOOT = 'First start for this storage: the sandbox downloads and installs the toolchains, databases and agent this project needs.\n' +
49
- 'That takes a few minutes, once. Everything is kept, so every later start reuses it and takes seconds.';
52
+ /** The first-start heads-up (also when this project needs tools not downloaded yet), or null. */
53
+ const FIRST_BOOT = (dataPath, generated, config) => {
54
+ const wanted = ['node', ...Object.entries(generated.toolchains).filter(([, d]) => d.on).map(([name]) => name), ...(config.toolchains.vscode.enabled ? ['vscode'] : []), ...generated.databases.map((d) => d.database)];
55
+ return firstStartNotice(firstBoot(dataPath), pendingDownloads(wanted, (dir) => fs.existsSync(path.join(dataPath, 'tools', dir))));
56
+ };
50
57
  /** The sandbox writes sandbox.json a few seconds into its boot; a second launch right after the first waits for it. */
51
58
  export const runningSandboxStatus = async (dataPath) => {
52
59
  for (let i = 0; i < 60; i++) {
@@ -57,10 +64,18 @@ export const runningSandboxStatus = async (dataPath) => {
57
64
  }
58
65
  return fail('A Windows Sandbox is running that this tool did not start (or that uses another storage.dataName).', 'Close that sandbox window first, then run sandbox claude again.');
59
66
  };
60
- /** Waits for the boot with its current step on a spinner (plain lines without a terminal). */
61
- export const waitForBoot = (dataPath, launchId) => {
62
- let step = 'starting Windows Sandbox';
63
- return working(() => 'Waiting for the sandbox: ' + step, () => waitForReady(dataPath, launchId, sandboxAlive, (m) => { step = m; }));
67
+ /**
68
+ * Waits for the boot with its current step and what it sets up on one line (plain lines without a terminal).
69
+ * timed: say how long it took (a first start, where that is worth knowing).
70
+ */
71
+ export const waitForBoot = async (dataPath, launchId, timed = false) => {
72
+ let status = null;
73
+ const start = Date.now();
74
+ const ready = await follow(() => bootLine(status), () => waitForReady(dataPath, launchId, sandboxAlive, () => undefined, (s) => { if (s.phase !== 'failed')
75
+ status = s; }), { stall: stillWorking });
76
+ if (timed)
77
+ console.log(readyIn(Date.now() - start));
78
+ return ready;
64
79
  };
65
80
  /** The running sandbox, once it is ready. */
66
81
  export const readySandbox = async (dataPath) => {
@@ -70,7 +85,8 @@ export const readySandbox = async (dataPath) => {
70
85
  /** Waits for a session to start (its project's setup, the agent's install) with its progress on a spinner. */
71
86
  const waitForStart = (ctx, requestId, what) => {
72
87
  let step = 'starting';
73
- return working(() => what + ': ' + step, () => waitForSession(ctx.dataPath, requestId, sandboxAlive, 60 * 60 * 1000, (m) => { step = m; }));
88
+ const shown = () => ({ line: what + ': ' + step, key: step, what: step });
89
+ return follow(shown, () => waitForSession(ctx.dataPath, requestId, sandboxAlive, 60 * 60 * 1000, (m) => { step = m; }), { stall: stillWorking });
74
90
  };
75
91
  /**
76
92
  * Starts (or picks back up) a session in the folder `joined` describes. attach: show it in this terminal.
@@ -216,6 +232,14 @@ const maybeAddRoot = async (ctx, run) => {
216
232
  addToList(ctx, ['projects', 'roots'], parent);
217
233
  run.config.projects.roots.push(parent);
218
234
  console.log('Added ' + parent + ' to projects.roots (sandbox projects roots remove ' + parent + ' undoes it).');
235
+ // From now on this project is under the root's folder inside the sandbox, from this start on (one move, not two).
236
+ const before = run.config.guestWorkspace;
237
+ const derived = [autoGuestWorkspace(run.workspaceHost), planMirrorWorkspace(run.workspaceHost, autoGuestWorkspace(run.workspaceHost)).guest];
238
+ // In place: the caller holds run.config already.
239
+ if (derived.some((g) => g.toLowerCase() === before.toLowerCase()))
240
+ run.config.guestWorkspace = placeInRoot(run.config, run.workspaceHost, Boolean(run.mirror), run.notes).guestWorkspace;
241
+ if (run.config.guestWorkspace !== before)
242
+ console.log('Inside the sandbox this project is now at ' + run.config.guestWorkspace + ' (was ' + before + '): git worktrees made at the old place need `git worktree repair` once.');
219
243
  }
220
244
  if (answer === 'no')
221
245
  addToList(ctx, ['projects', 'declinedRoots'], parent);
@@ -263,6 +287,32 @@ const prepare = async (ctx, run) => {
263
287
  const filter = await prepareWorkspaceView(ctx, run);
264
288
  return { account, terminal: terminal.message + '; pseudo console: ' + conpty, history, pruned: describePrune(pruneStorage(storageRoot)), filter };
265
289
  };
290
+ /**
291
+ * The other projects mapped at boot (a restart's, --session's, projects.all's, and the known projects in your roots),
292
+ * with their effective configs: only approved .sandbox-config.json files and the looser values you allowed for each
293
+ * (nothing is asked here). The sandbox boots with what they want too (generate.ts).
294
+ */
295
+ const bootWantsFor = async (ctx, run, extraProjects) => {
296
+ const { roots } = run.config.projects;
297
+ const planned = planBootProjects({ run, packageRoot: ctx.packageRoot, extraProjects }).projects.map((p) => p.host);
298
+ const inRoots = readKnown(run.storageRoot).map((k) => k.host).filter((h) => roots.some((r) => isWithin(path.resolve(r), h)));
299
+ const folders = _.uniqBy([...(extraProjects ?? []).map((p) => path.resolve(p)), ...planned, ...inRoots], (f) => f.toLowerCase())
300
+ .filter((f) => !isWithin(run.workspaceHost, f) && dirExists(f)).slice(0, 200);
301
+ const projects = [];
302
+ const notes = [];
303
+ for (const folder of folders) {
304
+ try {
305
+ const other = await resolveRun({ packageRoot: ctx.packageRoot, loaded: ctx.loaded, cwd: folder, workspace: folder, noAgent: true, noLocalConfig: false, trustConfig: false, scripts: [] });
306
+ projects.push({ name: path.basename(folder), config: other.config });
307
+ }
308
+ catch (e) {
309
+ if (fileExists(path.join(folder, LOCAL_CONFIG_NAME)))
310
+ notes.push('What ' + path.basename(folder) + ' asks for at boot is not included: ' + errMsg(e).split('\n')[0]);
311
+ }
312
+ }
313
+ const keepOff = BOOT_SETTINGS.filter((s) => _.get(ctx.loaded.config, s) === true && _.get(run.config, s) === false);
314
+ return { wants: { projects, keepOff }, notes };
315
+ };
266
316
  export const launch = async (ctx, generateOnly = false, extras = {}) => {
267
317
  const { options, loaded, packageRoot, dataPath, storageRoot } = ctx;
268
318
  // attach.auto: an agent started from an interactive terminal attaches it, as with -t.
@@ -303,8 +353,11 @@ export const launch = async (ctx, generateOnly = false, extras = {}) => {
303
353
  if (!generateOnly && !extras.extraProjects)
304
354
  await maybeAddRoot(ctx, run);
305
355
  const prepared = generateOnly ? null : await prepare(ctx, run);
306
- const generated = generateSandbox({ packageRoot, run, harnessArgs: options.passthrough, probe: probeHost(config, run.harnessName), extraProjects: extras.extraProjects, autoSession: extras.autoSession });
307
- for (const note of [...run.notes, ...generated.projects.notes])
356
+ const bootWants = await bootWantsFor(ctx, run, extras.extraProjects);
357
+ const generated = generateSandbox({ packageRoot, run, harnessArgs: options.passthrough, probe: probeHost(config, run.harnessName), extraProjects: extras.extraProjects, autoSession: extras.autoSession, bootWants: bootWants.wants });
358
+ // What the sandbox boots with (the launch folder's settings plus what the other projects at boot want).
359
+ const booted = generated.bootConfig;
360
+ for (const note of [...run.notes, ...bootWants.notes, ...generated.projects.notes, ...generated.mappingNotes])
308
361
  console.log(note);
309
362
  const secretFiles = config.secrets.warnFiles ? findSecretFiles(run.workspaceHost) : [];
310
363
  if (secretFiles.length) {
@@ -314,7 +367,7 @@ export const launch = async (ctx, generateOnly = false, extras = {}) => {
314
367
  const { roots, projects } = generated.projects;
315
368
  table([
316
369
  ['Workspace', run.workspaceHost + ' -> ' + config.guestWorkspace + (config.readOnly ? ' (read-only)' : '')],
317
- ['Agent', run.harnessName + (prepared?.account ? ' (account: ' + prepared.account + ')' : '') + ' Memory: ' + generated.memoryMb + ' MB CPU: ' + (config.sandbox.cpuLimit === 'none' ? 'all' : 'max ' + config.sandbox.cpuLimit + '%') + ' Network: ' + config.sandbox.networking + (config.sandbox.headless ? ' HEADLESS' : '')],
370
+ ['Agent', run.harnessName + (prepared?.account ? ' (account: ' + prepared.account + ')' : '') + ' Memory: ' + generated.memoryMb + ' MB CPU: ' + (config.sandbox.cpuLimit === 'none' ? 'all' : 'max ' + config.sandbox.cpuLimit + '%') + ' Network: ' + booted.sandbox.networking + (booted.sandbox.vGpu ? ' vGPU' : '') + (config.sandbox.headless ? ' HEADLESS' : '')],
318
371
  ['Node', run.versions.node.spec + ' (' + run.versions.node.source + ') Python: ' + run.versions.python.spec + ' (' + run.versions.python.source + ')'],
319
372
  ...(prepared ? [['Ignored files', prepared.filter], ['Terminal', prepared.terminal], ['History', prepared.history], ['Cleanup', prepared.pruned]] : []),
320
373
  ['Toolchains', Object.entries(generated.toolchains).filter(([, d]) => d.on).map(([name, d]) => name + ' (' + d.reason + ')').join(', ') || 'Node only (nothing else used by this project)'],
@@ -326,7 +379,7 @@ export const launch = async (ctx, generateOnly = false, extras = {}) => {
326
379
  ['Secrets', !config.secrets.strip ? 'NOT stripped (secrets.strip is off)' : generated.profile.secretsRemoved.length ? 'kept on this PC: ' + generated.profile.secretsRemoved.join(', ') + ' (secrets.allow lets one through)' : 'none found in what is copied'],
327
380
  ...roots.map((r, i) => [i ? '' : 'Project roots', r.host + ' -> ' + r.guest + ' (every project in it opens without a restart)']),
328
381
  ...projects.map((p, i) => [i ? '' : 'Projects', p.host + ' -> ' + p.guest]),
329
- ...generated.mappings.slice(3).filter((m) => ![...roots, ...projects].some((p) => p.guest === m.guest)).map((m) => [' map ' + (m.readOnly ? 'ro' : 'rw'), m.host + ' -> ' + m.guest]),
382
+ ...generated.mappings.slice(2).filter((m) => m.guest !== config.guestWorkspace && ![...roots, ...projects].some((p) => p.guest === m.guest)).map((m) => [' map ' + (m.readOnly ? 'ro' : 'rw'), m.host + ' -> ' + m.guest + (m.label ? ' (' + m.label + ')' : '')]),
330
383
  ['Generated', generated.wsbPath],
331
384
  ]);
332
385
  if (generateOnly)
@@ -334,7 +387,8 @@ export const launch = async (ctx, generateOnly = false, extras = {}) => {
334
387
  // Every folder the sandbox can write is snapshotted before it starts (the guard compares against this), and the
335
388
  // projects it starts with are recorded host-side for the commands that run later.
336
389
  saveRepoSnapshot(dataPath, run.workspaceHost, projects.map((p) => p.host), roots.map((r) => r.host));
337
- startRegistry(storageRoot, { launchId: generated.launchId, protections: protectionsOf(config), primary: { host: run.workspaceHost, guest: config.guestWorkspace }, roots, boot: projects });
390
+ startRegistry(storageRoot, { launchId: generated.launchId, protections: protectionsOf(booted), primary: { host: run.workspaceHost, guest: config.guestWorkspace }, roots, boot: projects,
391
+ atBoot: { memoryMb: generated.memoryMb, mappings: generated.mappings, widened: generated.widened } });
338
392
  rememberProject(storageRoot, run.workspaceHost);
339
393
  startLife(storageRoot, dataPath, config.sandbox.keepAwake);
340
394
  startSandbox(generated.wsbPath);
@@ -343,10 +397,11 @@ export const launch = async (ctx, generateOnly = false, extras = {}) => {
343
397
  spawnDetached(ctx.entry, ['_watch', ...(options.config ? ['--config', path.resolve(options.config)] : [])]);
344
398
  // Hiding runs alongside; a failure to find the window must never break the launch or an attach.
345
399
  const hidden = config.sandbox.headless ? hideSandboxWindow(storageRoot).catch(() => false) : null;
400
+ const headsUp = FIRST_BOOT(dataPath, generated, config);
346
401
  if (options.terminal || options.noAgent || extras.waitUntilReady) {
347
- if (firstBoot(dataPath))
348
- console.log(FIRST_BOOT);
349
- await waitForBoot(dataPath, generated.launchId);
402
+ if (headsUp)
403
+ console.log(headsUp);
404
+ await waitForBoot(dataPath, generated.launchId, Boolean(headsUp));
350
405
  if (extras.waitUntilReady)
351
406
  return 0;
352
407
  // Without an agent there is no session to attach to: the sandbox itself is the result.
@@ -356,8 +411,8 @@ export const launch = async (ctx, generateOnly = false, extras = {}) => {
356
411
  }
357
412
  return attachHere(ctx, await waitForStart(ctx, generated.launchId, 'Starting ' + run.harnessName));
358
413
  }
359
- if (firstBoot(dataPath))
360
- console.log(FIRST_BOOT);
414
+ if (headsUp)
415
+ console.log(headsUp + '\nFollow along: sandbox logs -f');
361
416
  if (hidden)
362
417
  console.log(await hidden ? 'Sandbox running headless. Open the window: sbx-show Attach here: sandbox claude -t' : 'Could not find the sandbox window to hide (it may still be starting); run sandbox hide in a moment.');
363
418
  else
@@ -4,8 +4,9 @@ import path from 'node:path';
4
4
  import { pathToFileURL } from 'node:url';
5
5
  import _ from 'lodash';
6
6
  import { DEFAULTS, userConfigPath, userDir } from '../../core/config.js';
7
- import { describeOverrides, isTrusted, LOCAL_CONFIG_NAME, normalizeOverrides, readLocalConfig, trustStorePath } from '../../core/local-config.js';
8
- import { assertNoLinks, expandPath, fail, plainText, safeText, sleep, writeJson } from '../../core/util.js';
7
+ import { allowedAnswer, allowStorePath, applyOverrides, describeOverrides, isTrusted, LOCAL_CONFIG_NAME, loosenedRequests, normalizeOverrides, OUTSIDE_FOLDER, outsideKey, outsideMappings, readLocalConfig, trustStorePath } from '../../core/local-config.js';
8
+ import { guestWorkspaceOf, previewGuest } from '../../core/workspace.js';
9
+ import { assertNoLinks, dirExists, expandPath, fail, plainText, safeText, sleep, writeJson } from '../../core/util.js';
9
10
  import { humanBytes } from '../../core/disk.js';
10
11
  import { AuditLog } from '../../host/audit/log.js';
11
12
  import { readKnown, readRegistry } from '../../host/projects.js';
@@ -107,8 +108,40 @@ const checkLocalConfig = (ctx) => {
107
108
  const overrides = normalizeOverrides(file.data, dir, file.path);
108
109
  const approved = isTrusted(trustStorePath(ctx.storageRoot), file.path, file.hash);
109
110
  console.log(file.path + ': valid. ' + (approved ? 'Approved (this exact version).' : 'Not approved yet: the next launch asks you.') + '\n');
110
- for (const line of describeOverrides(overrides))
111
+ for (const line of describeOverrides(overrides, dir))
111
112
  console.log(' ' + safeText(line));
113
+ // What a launch from here would do with each folder and each looser setting, without launching.
114
+ const config = ctx.loaded.config;
115
+ const store = allowStorePath(ctx.storageRoot);
116
+ const workspaceGuest = guestWorkspaceOf(applyOverrides(config, overrides), dir).guest;
117
+ const outside = outsideMappings(dir, overrides.mappings);
118
+ const policy = config.projects.allowOutsideMappings;
119
+ const rows = (overrides.mappings ?? []).map((m) => {
120
+ const where = previewGuest(m, dir, workspaceGuest);
121
+ const status = !dirExists(m.host) ? (m.optional ? 'not on this PC: skipped' : c.red('the folder does not exist: create it, fix the path, or add "optional": true'))
122
+ : !outside.includes(m) ? (m.guest ? 'inside the project' : 'inside the project (already in the sandbox there)')
123
+ : policy === true ? 'outside the project, allowed (projects.allowOutsideMappings is true)'
124
+ : policy === false ? c.red('outside the project: refused (projects.allowOutsideMappings is false)')
125
+ : allowedAnswer(store, file.path, OUTSIDE_FOLDER, outsideKey(m)) === true ? 'outside the project, allowed (you said yes)'
126
+ : c.yellow('outside the project: the next interactive launch asks you');
127
+ return [(m.readOnly ? 'ro ' : 'rw ') + safeText(m.host), '-> ' + safeText(where.guest), status];
128
+ });
129
+ if (rows.length) {
130
+ console.log('\nFolders (where they appear in the sandbox):');
131
+ table(rows);
132
+ }
133
+ for (const m of overrides.mappings ?? []) {
134
+ const note = dirExists(m.host) ? previewGuest(m, dir, workspaceGuest).note : null;
135
+ if (note)
136
+ console.log(' ' + safeText(note));
137
+ }
138
+ const looser = loosenedRequests(config, overrides);
139
+ if (looser.length)
140
+ console.log('\nLooser than your settings (used only if you allow it for this project):');
141
+ for (const a of looser) {
142
+ const answer = allowedAnswer(store, file.path, a.key, a.wanted);
143
+ console.log(' ' + a.key + ' = ' + JSON.stringify(a.wanted) + ': ' + (answer === true ? 'allowed (you said yes)' : answer === false ? 'not used (you said no)' : 'the next interactive launch asks you'));
144
+ }
112
145
  return 0;
113
146
  };
114
147
  export const config = (ctx) => {
@@ -1,4 +1,5 @@
1
1
  import type { SandboxStatus } from '../../core/types.js';
2
+ import { type Missing } from '../../core/project-policy.js';
2
3
  import { type RegistryEntry } from '../../host/projects.js';
3
4
  import type { ProjectSpecT } from '../../shared/schema.js';
4
5
  import { type Context } from './context.js';
@@ -17,12 +18,15 @@ export interface Joined {
17
18
  harness: string | null;
18
19
  notes: string[];
19
20
  }
20
- /**
21
- * Makes this folder a project of the running sandbox (nothing to do for the launch folder and projects already in
22
- * it): its .sandbox-config.json is approved like at a launch, its protections are checked, and it is made visible.
23
- * Throws NeedsRestart (host/projects.ts) when that is impossible without a restart.
24
- */
25
- export declare const joinHere: (ctx: Context, status: SandboxStatus) => Promise<Joined>;
21
+ /** restart.ts: the sandbox was restarted for a project in this command. */
22
+ export declare const markRestarted: () => void;
23
+ export declare const joinHere: (ctx: Context, status: SandboxStatus, o?: {
24
+ noRestart?: boolean;
25
+ }) => Promise<Joined>;
26
+ /** The command that restarts the running sandbox with this folder's boot wishes (consent given by --yes). */
27
+ export declare const restartCommand: (ctx: Context, host: string) => string;
28
+ /** Joined without what it wants at boot: what is missing, how to restart with it, and how to always have it. */
29
+ export declare const missingNotes: (ctx: Context, name: string, host: string, missing: Missing, shown?: boolean) => string[];
26
30
  /** Waits until the sandbox has set a project up, with its progress on a spinner (plain lines without a terminal). */
27
31
  export declare const provisionJoined: (ctx: Context, joined: Joined) => Promise<void>;
28
32
  /** joinHere, then (for another project) waits until the sandbox has set it up. */
@@ -2,13 +2,17 @@
2
2
  // these, so they all agree: `locateHere` only looks (attach, sessions, status, restart), `joinHere` also makes the
3
3
  // folder a project of the running sandbox when it can without a restart (launch, start, exec, db).
4
4
  import path from 'node:path';
5
+ import { select } from '@inquirer/prompts';
6
+ import { describeMissing, missingTip } from '../../core/project-policy.js';
5
7
  import { askYesNo } from '../../core/prompt.js';
6
8
  import { isInsideOrEqual } from '../../core/util.js';
7
9
  import { resolveRun } from '../../core/workspace.js';
8
- import { prepareProject } from '../../host/channel.js';
10
+ import { currentSessions, prepareProject } from '../../host/channel.js';
9
11
  import { projectHere, readRegistry } from '../../host/projects.js';
10
12
  import { joinRunning } from '../../host/join.js';
13
+ import { c } from '../colors.js';
11
14
  import { working } from '../progress.js';
15
+ import { describeSessions } from './restart.js';
12
16
  import { guestPathFor, sandboxAlive } from './context.js';
13
17
  /** The folder the command is about: --workspace, else the current one. */
14
18
  export const hereDir = (ctx) => path.resolve(ctx.options.workspace ?? process.cwd());
@@ -28,12 +32,16 @@ export const locateHere = (ctx, status) => {
28
32
  * it): its .sandbox-config.json is approved like at a launch, its protections are checked, and it is made visible.
29
33
  * Throws NeedsRestart (host/projects.ts) when that is impossible without a restart.
30
34
  */
31
- export const joinHere = async (ctx, status) => {
35
+ let restartedOnce = false;
36
+ /** restart.ts: the sandbox was restarted for a project in this command. */
37
+ export const markRestarted = () => { restartedOnce = true; };
38
+ export const joinHere = async (ctx, status, o = {}) => {
32
39
  const { options, loaded, packageRoot, storageRoot, dataPath } = ctx;
33
40
  const dir = hereDir(ctx);
34
41
  // A sandbox started before projects existed: only its launch folder (guestPathFor explains why otherwise).
35
42
  if (!readRegistry(storageRoot))
36
43
  return { project: null, guestDir: guestPathFor(dir, status.workspaceHost, status.guestWorkspace), spec: null, harness: null, notes: [] };
44
+ let shown = false;
37
45
  const joined = await joinRunning({
38
46
  storageRoot, dataPath, packageRoot, hostDir: dir, wsbShare: loaded.config.projects.wsbShare,
39
47
  resolve: (root) => resolveRun({
@@ -41,10 +49,41 @@ export const joinHere = async (ctx, status) => {
41
49
  noLocalConfig: options.noLocalConfig, trustConfig: options.trustConfig, scripts: options.scripts, databases: options.databases,
42
50
  nodeVersion: options.node, pythonVersion: options.python, ask: askYesNo,
43
51
  }),
52
+ // Something it wants only comes with a restart: --yes restarts, a terminal asks, anything else continues.
53
+ decide: async (name, missing) => {
54
+ // Once per command: what a restart could not give (see the launch notes) never restarts it again.
55
+ if (o.noRestart || restartedOnce)
56
+ return 'continue';
57
+ if (options.yes)
58
+ return 'restart';
59
+ if (!process.stdin.isTTY || !process.stdout.isTTY)
60
+ return 'continue';
61
+ console.log(c.yellow(describeMissing(name, missing)));
62
+ shown = true;
63
+ const sessions = currentSessions(dataPath);
64
+ return select({
65
+ message: 'What now?',
66
+ choices: [
67
+ { name: 'Continue anyway (join without them)', value: 'continue' },
68
+ { name: 'Restart with them (' + (sessions.length ? describeSessions(sessions) + ' will end' : 'no session is running') + ')', value: 'restart' },
69
+ { name: 'Cancel', value: 'cancel' },
70
+ ],
71
+ default: 'continue',
72
+ });
73
+ },
44
74
  });
45
75
  const harness = joined.run && joined.run.harnessName !== 'none' ? joined.run.harnessName : null;
46
- return { project: joined.project, guestDir: joined.guestDir, spec: joined.spec, harness, notes: joined.notes };
76
+ const notes = joined.missing ? [...joined.notes, ...missingNotes(ctx, joined.project.name, joined.project.host, joined.missing, shown)] : joined.notes;
77
+ return { project: joined.project, guestDir: joined.guestDir, spec: joined.spec, harness, notes };
47
78
  };
79
+ /** The command that restarts the running sandbox with this folder's boot wishes (consent given by --yes). */
80
+ export const restartCommand = (ctx, host) => 'sandbox ' + (ctx.options.noAgent ? 'start' : ctx.options.harness ?? ctx.loaded.config.defaultHarness) + ' --workspace "' + host + '" --yes';
81
+ /** Joined without what it wants at boot: what is missing, how to restart with it, and how to always have it. */
82
+ export const missingNotes = (ctx, name, host, missing, shown = false) => [
83
+ ...(shown ? [] : [c.yellow(describeMissing(name, missing))]),
84
+ 'Joined without them. To restart the sandbox with them (its sessions end): ' + restartCommand(ctx, host),
85
+ c.dim(missingTip(missing)),
86
+ ];
48
87
  /** Waits until the sandbox has set a project up, with its progress on a spinner (plain lines without a terminal). */
49
88
  export const provisionJoined = async (ctx, joined) => {
50
89
  if (!joined.spec)
@@ -5,6 +5,7 @@
5
5
  // where the agent supports it) and then starts the new project's session.
6
6
  import path from 'node:path';
7
7
  import _ from 'lodash';
8
+ import { missingTip } from '../../core/project-policy.js';
8
9
  import { askYesNo } from '../../core/prompt.js';
9
10
  import { fail, safeText, sleep } from '../../core/util.js';
10
11
  import { currentSessions, execInSandbox } from '../../host/channel.js';
@@ -44,14 +45,20 @@ export const offerRestart = async (ctx, needs, then) => {
44
45
  const registry = readRegistry(ctx.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 from ' + folders[0] + '.');
45
46
  const primary = registry.projects.find((p) => p.primary) ?? fail('The running sandbox has no launch folder on record.', 'Stop it (sandbox stop) and start it again.');
46
47
  const sessions = currentSessions(ctx.dataPath);
47
- for (const need of needs)
48
+ // A project that only wants boot settings or folders: you already chose the restart (or gave --yes) when asked.
49
+ const hidden = needs.filter((n) => !n.missing);
50
+ for (const need of hidden)
48
51
  console.log(c.yellow('Windows Sandbox cannot add ' + need.hostDir + ' while it runs: ' + need.reason + '.'));
49
- console.log('A restart maps ' + (needs.length === 1 ? 'it' : 'them') + ' as well (and keeps ' + registry.projects.map((p) => p.host).join(', ') + '). ' +
52
+ console.log((hidden.length ? 'A restart maps ' + (hidden.length === 1 ? 'it' : 'them') + ' as well' : 'Restarting with what ' + needs.map((n) => path.basename(n.hostDir)).join(', ') + ' wants') +
53
+ ' (and keeps ' + registry.projects.map((p) => p.host).join(', ') + '). ' +
50
54
  (sessions.length ? describeSessions(sessions) + ' will end' : 'No session is running') + '; your files, tools, databases and logins are kept. It takes about a minute.');
51
- for (const parent of _.uniq(folders.map((f) => path.dirname(f))))
55
+ for (const parent of _.uniq(hidden.map((n) => path.dirname(n.hostDir))))
52
56
  console.log(c.dim('No restart next time for folders in ' + parent + ': sandbox projects roots add "' + parent + '"'));
57
+ for (const need of needs)
58
+ if (need.missing)
59
+ console.log(c.dim(missingTip(need.missing)));
53
60
  const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
54
- if (!ctx.options.yes) {
61
+ if (!ctx.options.yes && hidden.length) {
55
62
  if (!interactive)
56
63
  fail('Not restarting without your consent.', 'Run it again with --yes to restart: ' + againWithYes(ctx));
57
64
  if (!(await askYesNo('Restart the sandbox now?'))) {
@@ -62,9 +69,10 @@ export const offerRestart = async (ctx, needs, then) => {
62
69
  const reopen = sessionsToReopen(registry, sessions);
63
70
  const resume = reopen.length > 0 && (ctx.options.yes || (await askYesNo('Reopen the ' + reopen.length + ' session(s) afterwards (continuing their conversations where the agent supports it)?', true)));
64
71
  await stopAndWait(ctx);
65
- // Imported here: launch.ts imports this file.
72
+ // Imported here: launch.ts imports this file (and project.ts imports describeSessions).
66
73
  const { launch, openSession, readySandbox } = await import('./launch.js');
67
- const { joinHere } = await import('./project.js');
74
+ const { joinHere, markRestarted } = await import('./project.js');
75
+ markRestarted();
68
76
  const keep = registry.projects.filter((p) => !p.primary && p.mechanism !== 'root').map((p) => p.host);
69
77
  const relaunch = { ...ctx, options: { ...ctx.options, workspace: primary.host, harness: ctx.options.harness ?? ctx.options.sessions[0]?.harness, terminal: false, noAgent: false, newSession: true, passthrough: [], sessions: [] } };
70
78
  await launch(relaunch, false, { extraProjects: [...keep, ...folders], autoSession: false, waitUntilReady: true });
@@ -73,7 +81,9 @@ export const offerRestart = async (ctx, needs, then) => {
73
81
  for (const s of reopen) {
74
82
  const at = { ...ctx, options: { ...ctx.options, workspace: s.hostDir, harness: s.harness, terminal: false, newSession: true, passthrough: [], sessions: [] } };
75
83
  try {
76
- const joined = await joinHere(at, ready);
84
+ const joined = await joinHere(at, ready, { noRestart: true });
85
+ for (const note of joined.notes)
86
+ console.log(note);
77
87
  await openSession(at, ready, joined, { attach: false, harness: s.harness, args: ctx.loaded.config.harnesses[s.harness]?.resumeArguments ?? [], reuse: false });
78
88
  }
79
89
  catch (e) {
@@ -161,7 +161,8 @@ const printExecResult = (result) => {
161
161
  const guestFolderHere = async (ctx, what) => {
162
162
  const ready = await readySandbox(ctx.dataPath);
163
163
  try {
164
- const joined = await joinHere(ctx, ready);
164
+ // A command needs no restart for what the project wants at boot: it runs without them (and says so).
165
+ const joined = await joinHere(ctx, ready, { noRestart: true });
165
166
  for (const note of joined.notes)
166
167
  console.error(note);
167
168
  await provisionJoined(ctx, joined);
@@ -57,7 +57,7 @@ const SANDBOX_OPTIONS = [
57
57
  bool('--window', 'show the sandbox window even if config says headless', { advanced: true }),
58
58
  bool('--trust-config', 'approve the folder\'s .sandbox-config.json without asking', { advanced: true }),
59
59
  bool('--no-local-config', 'ignore the folder\'s .sandbox-config.json', { advanced: true }),
60
- bool('--yes', 'when this folder is not in the running sandbox and adding it needs a restart, restart without asking', { noMenu: true }),
60
+ bool('--yes', 'when this folder is not in the running sandbox, or wants what it started without (vGPU, folders), restart without asking', { noMenu: true }),
61
61
  ];
62
62
  const AGENT_OPTIONS = [
63
63
  text('--harness', 'name', 'agent to run', { choices: 'harnesses' }),
@@ -2,6 +2,21 @@
2
2
  export declare const canAnimate: (env: NodeJS.ProcessEnv, isTTY: boolean) => boolean;
3
3
  /** "12 s", "2 min 5 s". */
4
4
  export declare const elapsed: (ms: number) => string;
5
+ /** One line of progress: `line` is shown, `key` changes when it is worth a new plain line, `what` names what runs. */
6
+ export interface Progressing {
7
+ line: string;
8
+ key: string;
9
+ what: string;
10
+ }
11
+ /**
12
+ * Follows long work (a sandbox's boot): on a terminal one line redrawn in place with the time so far; without one a
13
+ * plain line each time `key` changes. When nothing changes for `stallMs` it says so (stall(what, ms)). A failure first
14
+ * prints where it stopped, then rethrows.
15
+ */
16
+ export declare const follow: <T>(show: () => Progressing, work: () => Promise<T>, o: {
17
+ stallMs?: number;
18
+ stall: (what: string, ms: number) => string;
19
+ }) => Promise<T>;
5
20
  /**
6
21
  * Shows `message` while work runs and removes the line afterwards. The spinner only moves while the event loop is free
7
22
  * (waiting on the sandbox); for long synchronous work (measuring folders) the static line is what you see.