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
@@ -13,3 +13,5 @@ export declare const planBootProjects: (o: {
13
13
  extraProjects?: string[];
14
14
  knownProjects?: string[];
15
15
  }) => BootProjects;
16
+ /** The launch folder is inside a mapped root and placed under it (core/workspace.ts): the root shows it, no own mapping. */
17
+ export declare const shownByRoot: (roots: RootMapping[], workspaceHost: string, guestWorkspace: string) => boolean;
@@ -4,7 +4,7 @@
4
4
  import path from 'node:path';
5
5
  import { assertSafeMapping, assertSafeWorkspace } from '../../core/safe-paths.js';
6
6
  import { dirExists, errMsg, fail } from '../../core/util.js';
7
- import { isWithin, planProjectMappings } from '../../shared/projects.js';
7
+ import { guestDirFor, isWithin, planProjectMappings, projectForHostDir, usableRoots } from '../../shared/projects.js';
8
8
  import { allProjectsToMap, readKnown } from '../projects.js';
9
9
  /** More folders than this would make the .wsb (and every boot) unwieldy; the rest join with a restart later. */
10
10
  export const MAX_BOOT_PROJECTS = 48;
@@ -21,7 +21,6 @@ export const planBootProjects = (o) => {
21
21
  return errMsg(e);
22
22
  }
23
23
  };
24
- const roots = [];
25
24
  for (const root of config.projects.roots.map((r) => path.resolve(r))) {
26
25
  if (!dirExists(root)) {
27
26
  notes.push('Project root not found, skipped: ' + root);
@@ -30,9 +29,9 @@ export const planBootProjects = (o) => {
30
29
  const refusal = safe(root);
31
30
  if (refusal)
32
31
  fail('projects.roots: ' + refusal, 'Use a folder that holds your projects, e.g. F:\\Code (not a drive, your profile or a system folder).');
33
- if (!roots.some((r) => isWithin(r, root)))
34
- roots.push(root);
35
32
  }
33
+ // The same list resolveRun used to place a launch folder that is inside a root (core/workspace.ts).
34
+ const roots = usableRoots(config.projects.roots, dirExists);
36
35
  const known = o.knownProjects ?? readKnown(storageRoot).map((k) => k.host);
37
36
  const wanted = [...(o.extraProjects ?? []), ...(config.projects.all ? allProjectsToMap(known, { primary: workspaceHost, roots, exclude: config.projects.exclude }) : [])];
38
37
  const projects = [];
@@ -52,5 +51,12 @@ export const planBootProjects = (o) => {
52
51
  }
53
52
  projects.push(folder);
54
53
  }
55
- return { ...planProjectMappings(roots, projects, Boolean(o.run.mirror)), notes };
54
+ // Projects mapped on their own sit where they would as the launch folder (C:\Workspace\<name>), never on top of it.
55
+ const taken = [config.guestWorkspace, ...config.mappings.flatMap((m) => (m.guest ? [m.guest] : []))];
56
+ return { ...planProjectMappings(roots, projects, Boolean(o.run.mirror), taken), notes };
57
+ };
58
+ /** The launch folder is inside a mapped root and placed under it (core/workspace.ts): the root shows it, no own mapping. */
59
+ export const shownByRoot = (roots, workspaceHost, guestWorkspace) => {
60
+ const root = projectForHostDir(roots, workspaceHost);
61
+ return root !== null && guestDirFor(root, workspaceHost).toLowerCase() === guestWorkspace.replace(/[\\/]+$/, '').toLowerCase();
56
62
  };
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import type { Missing } from '../core/project-policy.js';
2
3
  import { PROJECTS_GUEST, type ProjectEntry, type RootMapping } from '../shared/projects.js';
3
4
  import { type Wsb } from './wsb.js';
4
5
  export { PROJECTS_GUEST };
@@ -36,7 +37,17 @@ declare const Registry: z.ZodObject<{
36
37
  }>;
37
38
  registeredAt: z.ZodString;
38
39
  }, z.core.$strict>>;
40
+ atBoot: z.ZodOptional<z.ZodObject<{
41
+ memoryMb: z.ZodNullable<z.ZodNumber>;
42
+ mappings: z.ZodArray<z.ZodObject<{
43
+ host: z.ZodString;
44
+ guest: z.ZodString;
45
+ readOnly: z.ZodBoolean;
46
+ }, z.core.$strict>>;
47
+ widened: z.ZodRecord<z.ZodString, z.ZodString>;
48
+ }, z.core.$strict>>;
39
49
  }, z.core.$strict>;
50
+ export type AtBoot = NonNullable<z.output<typeof Registry>['atBoot']>;
40
51
  export type RegistryEntry = z.output<typeof Entry>;
41
52
  export type ProjectRegistry = z.output<typeof Registry>;
42
53
  export declare const readRegistry: (storageRoot: string) => ProjectRegistry | null;
@@ -47,12 +58,24 @@ export declare const startRegistry: (storageRoot: string, o: {
47
58
  primary: RootMapping;
48
59
  roots: RootMapping[];
49
60
  boot: RootMapping[];
61
+ atBoot?: AtBoot;
50
62
  }) => RegistryEntry;
51
- /** Why a folder cannot join the running sandbox without a restart (the caller offers one). */
63
+ /** Reads the memory and mapped folders back from a .wsb this tool wrote (host-only launch folder; bounded). */
64
+ export declare const parseWsbBoot: (xml: string) => AtBoot;
65
+ /**
66
+ * What the running sandbox booted with. A sandbox started by 1.4.0 or older has no record: it is read from the .wsb in
67
+ * its launch folder (host-only storage) and recorded. null when neither is there.
68
+ */
69
+ export declare const bootRecordOf: (storageRoot: string, launchPath: string | null) => AtBoot | null;
70
+ /**
71
+ * Why a folder cannot join the running sandbox without a restart (the caller offers one). With `missing`: it could
72
+ * join, but you chose to restart for what it wants at boot (core/project-policy.ts).
73
+ */
52
74
  export declare class NeedsRestart extends Error {
53
75
  readonly hostDir: string;
54
76
  readonly reason: string;
55
- constructor(hostDir: string, reason: string);
77
+ readonly missing?: Missing | undefined;
78
+ constructor(hostDir: string, reason: string, missing?: Missing | undefined);
56
79
  }
57
80
  export interface ProjectMapper {
58
81
  /** The projects of the running sandbox (the launch folder first). */
@@ -4,13 +4,14 @@
4
4
  // as folders join);
5
5
  // - known-projects.json: every folder this tool has started a project in (for projects.all and `sandbox projects`).
6
6
  // Everything about HOW a folder becomes visible is behind ProjectMapper, so the mechanisms can change independently.
7
+ import fs from 'node:fs';
7
8
  import path from 'node:path';
8
9
  import { z } from 'zod';
9
10
  import { assertSafeWorkspace } from '../core/safe-paths.js';
10
11
  import { dirExists, fail } from '../core/util.js';
11
12
  import { Id } from '../shared/schema.js';
12
13
  import { readValid, writeJsonAtomic } from '../shared/rundir.js';
13
- import { guestDirFor, hubName, isWithin, PROJECTS_GUEST, projectForHostDir } from '../shared/projects.js';
14
+ import { guestDirFor, hubName, isWithin, ownGuestFolder, PROJECTS_GUEST, projectForHostDir } from '../shared/projects.js';
14
15
  import { detectWsb, runningSandboxId, shareFolder } from './wsb.js';
15
16
  export { PROJECTS_GUEST };
16
17
  const AbsolutePath = z.string().max(1024).refine((p) => path.isAbsolute(p), 'must be an absolute path');
@@ -24,6 +25,13 @@ const Registry = z.strictObject({
24
25
  protections: z.record(z.string(), z.union([z.boolean(), z.string()])),
25
26
  roots: z.array(z.strictObject({ host: AbsolutePath, guest: z.string().max(1024) })).max(64),
26
27
  projects: z.array(Entry).max(1024),
28
+ /** What the sandbox booted with that a joining project may also want (missing in registries of 1.4.0 and older). */
29
+ atBoot: z.strictObject({
30
+ memoryMb: z.number().int().positive().nullable(),
31
+ mappings: z.array(z.strictObject({ host: z.string().max(1024), guest: z.string().max(1024), readOnly: z.boolean() })).max(1024),
32
+ /** Boot settings turned on for a project mapped at boot (setting -> project): other projects share them. */
33
+ widened: z.record(z.string().max(100), z.string().max(100)),
34
+ }).optional(),
27
35
  });
28
36
  const registryFile = (storageRoot) => path.join(storageRoot, 'run', 'projects.json');
29
37
  export const readRegistry = (storageRoot) => readValid(Registry, registryFile(storageRoot));
@@ -34,17 +42,57 @@ export const startRegistry = (storageRoot, o) => {
34
42
  const primary = { name: hubName(o.primary.host, []), host: o.primary.host, guest: o.primary.guest, primary: true, mechanism: 'launch', registeredAt: at };
35
43
  const names = [primary.name];
36
44
  const boot = o.boot.map((b) => { const name = hubName(b.host, names); names.push(name); return { name, host: b.host, guest: b.guest, primary: false, mechanism: 'boot', registeredAt: at }; });
37
- saveRegistry(storageRoot, { launchId: o.launchId, protections: o.protections, roots: o.roots, projects: [primary, ...boot] });
45
+ saveRegistry(storageRoot, { launchId: o.launchId, protections: o.protections, roots: o.roots, projects: [primary, ...boot], ...(o.atBoot ? { atBoot: o.atBoot } : {}) });
38
46
  return primary;
39
47
  };
40
- /** Why a folder cannot join the running sandbox without a restart (the caller offers one). */
48
+ /** Reads the memory and mapped folders back from a .wsb this tool wrote (host-only launch folder; bounded). */
49
+ export const parseWsbBoot = (xml) => {
50
+ const text = xml.slice(0, 1_000_000);
51
+ const unescape = (s) => s.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&apos;/g, '\'').replace(/&amp;/g, '&');
52
+ const mappings = [...text.matchAll(/<MappedFolder><HostFolder>([^<]{1,1024})<\/HostFolder><SandboxFolder>([^<]{1,1024})<\/SandboxFolder><ReadOnly>(true|false)<\/ReadOnly><\/MappedFolder>/g)]
53
+ .slice(0, 1024).map((m) => ({ host: unescape(m[1]), guest: unescape(m[2]), readOnly: m[3] === 'true' }));
54
+ const memory = /<MemoryInMB>(\d{1,7})<\/MemoryInMB>/.exec(text);
55
+ return { memoryMb: memory ? Number(memory[1]) : null, mappings, widened: {} };
56
+ };
57
+ /**
58
+ * What the running sandbox booted with. A sandbox started by 1.4.0 or older has no record: it is read from the .wsb in
59
+ * its launch folder (host-only storage) and recorded. null when neither is there.
60
+ */
61
+ export const bootRecordOf = (storageRoot, launchPath) => {
62
+ const registry = readRegistry(storageRoot);
63
+ if (!registry)
64
+ return null;
65
+ if (registry.atBoot)
66
+ return registry.atBoot;
67
+ if (!launchPath)
68
+ return null;
69
+ let xml;
70
+ try {
71
+ xml = fs.readFileSync(path.join(launchPath, 'agent.wsb'), 'utf8');
72
+ }
73
+ catch {
74
+ return null;
75
+ }
76
+ const atBoot = parseWsbBoot(xml);
77
+ try {
78
+ saveRegistry(storageRoot, { ...registry, atBoot });
79
+ }
80
+ catch { /* the record is a convenience */ }
81
+ return atBoot;
82
+ };
83
+ /**
84
+ * Why a folder cannot join the running sandbox without a restart (the caller offers one). With `missing`: it could
85
+ * join, but you chose to restart for what it wants at boot (core/project-policy.ts).
86
+ */
41
87
  export class NeedsRestart extends Error {
42
88
  hostDir;
43
89
  reason;
44
- constructor(hostDir, reason) {
90
+ missing;
91
+ constructor(hostDir, reason, missing) {
45
92
  super(reason);
46
93
  this.hostDir = hostDir;
47
94
  this.reason = reason;
95
+ this.missing = missing;
48
96
  }
49
97
  }
50
98
  /** Roots first (instant), then `wsb share` where the Windows Sandbox CLI exists, else NeedsRestart. */
@@ -84,7 +132,8 @@ export const projectMapper = (o) => {
84
132
  const sandbox = runningSandboxId(wsb);
85
133
  if (!sandbox.id)
86
134
  throw new NeedsRestart(host, 'wsb share could not find the running sandbox: ' + sandbox.error);
87
- const guest = path.win32.join(PROJECTS_GUEST, name);
135
+ // Where it is as a launch folder too (C:\Workspace\<name>), unless another project already uses that place.
136
+ const guest = ownGuestFolder(host, false, [...reg.projects.map((p) => p.guest), ...reg.roots.map((r) => r.guest)]);
88
137
  const shared = shareFolder(wsb, sandbox.id, host, guest);
89
138
  if (!shared.ok)
90
139
  throw new NeedsRestart(host, 'wsb share failed (untested on this Windows): ' + shared.error);
@@ -25320,7 +25320,7 @@ var ClaudeProfile = external_exports.strictObject({
25320
25320
  var GuestWorkspace = external_exports.union([external_exports.literal("auto"), external_exports.literal("mirror"), GuestPath]).describe('Where the workspace appears inside the sandbox. "auto" = C:\\Workspace\\<folder name>. "mirror" = the same path as on this PC, so absolute paths in configs and build caches match (another drive letter such as F: is a subst drive onto C:\\Mirror\\F); paths the sandbox reserves (C:\\Users\\..., C:\\Program Files\\...) fall back to "auto".');
25321
25321
  var Mapping = external_exports.strictObject({
25322
25322
  host: external_exports.string().min(1).describe("Folder on this PC (relative paths are relative to the config file)."),
25323
- guest: GuestPath.describe("Where it appears inside the sandbox, e.g. C:\\Assets, or on another drive letter (D: to Z:) such as F:\\SteamLibrary\\steamapps\\common\\Game: mapped to C:\\Mirror\\F\\... and shown as F: (subst) at boot."),
25323
+ guest: external_exports.string().min(1).max(1024).optional().describe("Where it appears inside the sandbox, e.g. C:\\Assets, or on another drive letter (D: to Z:) such as F:\\SteamLibrary\\steamapps\\common\\Game (shown as F: at boot). Leave it out to use the same path as on this PC (a folder inside the project stays where it is in the project). A path the sandbox cannot use (C:\\Windows\\..., C:\\Users\\..., one already in use) goes to C:\\Mapped\\<drive>\\<path> instead, and the launch says so."),
25324
25324
  readOnly: external_exports.boolean().describe("true = the agent can only read it."),
25325
25325
  optional: external_exports.boolean().optional().describe("true = skip it quietly when the host folder does not exist.")
25326
25326
  });
@@ -25608,6 +25608,7 @@ var Projects = external_exports.strictObject({
25608
25608
  roots: external_exports.array(external_exports.string().min(1)).describe('Folders whose subfolders are your projects (e.g. "F:\\\\Code"), mapped read/write at every start. Any project inside one, including ones you create later, opens in the running sandbox at once, without a restart. Every agent in the sandbox can see all of them.'),
25609
25609
  askToAddRoot: external_exports.boolean().describe("When you start a project whose parent folder is not a root yet, ask once whether to add it."),
25610
25610
  declinedRoots: external_exports.array(external_exports.string().min(1)).describe("Parent folders you said no to (not asked again)."),
25611
+ allowOutsideMappings: external_exports.union([external_exports.literal("ask"), external_exports.boolean()]).describe(`A project's .sandbox-config.json may map folders from outside the project (e.g. a game folder on another drive). "ask" = an interactive launch asks once per project and set of folders, and remembers your answer; true = always allowed (still listed when you approve the file); false = never (the launch stops and says which folders). Only your own settings decide this, never a project file.`),
25611
25612
  wsbShare: external_exports.boolean().describe("On Windows 11 24H2+ with the Windows Sandbox command line (wsb), add other folders to the running sandbox with `wsb share` (untested). Otherwise starting a project outside every root offers a restart.")
25612
25613
  });
25613
25614
 
@@ -25650,7 +25651,7 @@ var LocalOverrides = external_exports.strictObject({
25650
25651
  readOnly: external_exports.boolean().describe("Map this project read-only."),
25651
25652
  // A project may turn the filter on and keep more out, never let ignored files in.
25652
25653
  workspaceFilter: WorkspaceFilter.pick({ enabled: true, exclude: true }).partial(),
25653
- mappings: external_exports.array(Mapping).describe("Extra folders inside this project (relative to this file), e.g. a read-only data folder at another place in the sandbox."),
25654
+ mappings: external_exports.array(Mapping).describe("Extra folders (relative paths are relative to this file), e.g. a read-only data folder at another place in the sandbox. Folders outside the project are used only as your projects.allowOutsideMappings setting allows (by default you are asked once)."),
25654
25655
  scripts: Strings.describe("Scripts (.ps1 / .mjs / .cmd, relative to this file) run inside the sandbox before the agent."),
25655
25656
  // This PC's resources are yours to share: CPU and paging file (this PC's disk) are not a project's to set.
25656
25657
  sandbox: Sandbox.omit({ cpuLimit: true, pageFileMb: true }).partial(),
@@ -25681,6 +25682,15 @@ var AttachEndpoint = external_exports.strictObject({
25681
25682
  port: external_exports.number().int().min(1024).max(65535),
25682
25683
  token: Text(256)
25683
25684
  });
25685
+ var BootProgress = external_exports.strictObject({
25686
+ step: external_exports.number().int().min(1).max(32),
25687
+ steps: external_exports.number().int().min(1).max(32),
25688
+ title: Text(120),
25689
+ /** Being set up now; percent while it downloads (when the size is known). */
25690
+ active: external_exports.array(external_exports.strictObject({ name: Text(64), percent: external_exports.number().int().min(0).max(100).optional() })).max(8),
25691
+ done: external_exports.number().int().min(0).max(1e3),
25692
+ total: external_exports.number().int().min(0).max(1e3)
25693
+ });
25684
25694
  var SandboxStatus = external_exports.object({
25685
25695
  launchId: Text(64),
25686
25696
  phase: external_exports.enum(["booting", "provisioning", "ready", "failed"]),
@@ -25690,7 +25700,9 @@ var SandboxStatus = external_exports.object({
25690
25700
  guestWorkspace: Text(),
25691
25701
  headless: external_exports.boolean(),
25692
25702
  /** The sandbox's IPv4 addresses (for network.forwardPorts; the host only uses ones on its Hyper-V network). */
25693
- addresses: external_exports.array(external_exports.string().max(45)).max(8).optional()
25703
+ addresses: external_exports.array(external_exports.string().max(45)).max(8).optional(),
25704
+ /** A malformed progress is dropped, never the status (the phase still counts). */
25705
+ progress: BootProgress.optional().catch(void 0)
25694
25706
  });
25695
25707
  var Version = external_exports.strictObject({ spec: Text(128), source: Text(256) });
25696
25708
  var DatabasePlan = external_exports.strictObject({
@@ -25984,6 +25996,10 @@ var checksumRequired = false;
25984
25996
  var setChecksumRequired = (on) => {
25985
25997
  checksumRequired = on;
25986
25998
  };
25999
+ var downloadWatcher = null;
26000
+ var watchDownloads = (watcher) => {
26001
+ downloadWatcher = watcher;
26002
+ };
25987
26003
  var download = async (url2, destination, expected = null) => {
25988
26004
  const sums = typeof expected === "string" || expected === null ? { sha256: expected } : expected;
25989
26005
  if (checksumRequired && !sums.sha256?.trim() && !sums.sha512?.trim()) {
@@ -25996,8 +26012,25 @@ var download = async (url2, destination, expected = null) => {
25996
26012
  const want = sums[kind]?.trim().toLowerCase();
25997
26013
  return want ? [{ want, hash: crypto.createHash(kind) }] : [];
25998
26014
  });
26015
+ const size = Number(response.headers.get("content-length")) || 0;
26016
+ const report = size > 0 ? downloadWatcher?.() ?? null : null;
26017
+ let received = 0;
26018
+ let tenth = 0;
26019
+ let reportedAt = Date.now();
25999
26020
  const tap = new Transform({ transform(chunk, _encoding, done) {
26000
26021
  for (const c of checks) c.hash.update(chunk);
26022
+ if (report) {
26023
+ received += chunk.length;
26024
+ const now2 = Math.floor(received * 10 / size);
26025
+ if (now2 !== tenth || Date.now() - reportedAt > 5e3) {
26026
+ tenth = now2;
26027
+ reportedAt = Date.now();
26028
+ try {
26029
+ report(Math.min(1, received / size));
26030
+ } catch {
26031
+ }
26032
+ }
26033
+ }
26001
26034
  done(null, chunk);
26002
26035
  } });
26003
26036
  const partial2 = destination + ".part";
@@ -26145,16 +26178,25 @@ var mkdirp = (p) => {
26145
26178
  fs5.mkdirSync(p, { recursive: true });
26146
26179
  return p;
26147
26180
  };
26148
- var setSandboxStatus = (phase, message) => {
26181
+ var progress;
26182
+ var setSandboxStatus = (phase, message, update) => {
26149
26183
  try {
26184
+ if (update) progress = fitProgress({ step: 1, steps: 1, title: "", active: [], done: 0, total: 0, ...progress, ...update });
26150
26185
  writeJsonAtomicChecked(phase, message);
26151
26186
  } catch (e) {
26152
26187
  warn("Could not update sandbox status: " + errMsg(e));
26153
26188
  }
26154
26189
  };
26190
+ var fitProgress = (p) => ({
26191
+ ...p,
26192
+ title: p.title.slice(0, 120),
26193
+ done: Math.min(p.done, 1e3),
26194
+ total: Math.min(p.total, 1e3),
26195
+ active: p.active.slice(0, 8).map((a) => ({ name: a.name.slice(0, 64), ...a.percent === void 0 ? {} : { percent: Math.max(0, Math.min(100, Math.round(a.percent))) } }))
26196
+ });
26155
26197
  var ownAddresses = () => Object.values(os3.networkInterfaces()).flat().filter((i) => Boolean(i) && i.family === "IPv4" && !i.internal).map((i) => i.address).slice(0, 8);
26156
26198
  var writeJsonAtomicChecked = (phase, message) => {
26157
- writeJsonAtomic(run.sandbox, { launchId: session.launchId, phase, message, updatedAt: now(), workspaceHost: session.workspaceHost, guestWorkspace: workspace, headless: session.headless, addresses: ownAddresses() });
26199
+ writeJsonAtomic(run.sandbox, { launchId: session.launchId, phase, message, updatedAt: now(), workspaceHost: session.workspaceHost, guestWorkspace: workspace, headless: session.headless, addresses: ownAddresses(), ...progress ? { progress } : {} });
26158
26200
  };
26159
26201
  var exec = (file3, args, options = {}) => {
26160
26202
  const result = spawnSync(file3, args, { stdio: "inherit", windowsHide: true, ...options });
@@ -26273,6 +26315,7 @@ import path45 from "node:path";
26273
26315
  // src/shared/projects.ts
26274
26316
  import path8 from "node:path";
26275
26317
  var PROJECTS_GUEST = "C:\\Projects";
26318
+ var WORKSPACES_GUEST = "C:\\Workspace";
26276
26319
  var norm2 = (p) => path8.win32.resolve(p).replace(/[\\/]+$/, "").toLowerCase();
26277
26320
  var isWithin = (parent, child) => {
26278
26321
  const a = norm2(parent);
@@ -26286,7 +26329,7 @@ var hubName = (hostDir, taken) => {
26286
26329
  for (let i = 2; ; i++) if (!used.has((base + "-" + i).toLowerCase())) return base + "-" + i;
26287
26330
  };
26288
26331
  var isBelow = (parent, child) => isWithin(parent, child) && norm2(parent) !== norm2(child);
26289
- var insideProjects = (guestDir, roots = [], projectsDir = PROJECTS_GUEST) => isBelow(projectsDir, guestDir) || roots.some((r) => isWithin(r, guestDir));
26332
+ var insideProjects = (guestDir, roots = [], projectsDir = PROJECTS_GUEST) => isBelow(projectsDir, guestDir) || isBelow(WORKSPACES_GUEST, guestDir) || roots.some((r) => isWithin(r, guestDir));
26290
26333
 
26291
26334
  // src/guest/daemon/state.ts
26292
26335
  import path9 from "node:path";
@@ -26510,6 +26553,9 @@ var runHooksAsync = async (stage, env, target) => {
26510
26553
  }
26511
26554
  };
26512
26555
 
26556
+ // src/guest/toolchains/index.ts
26557
+ import { AsyncLocalStorage as AsyncLocalStorage2 } from "node:async_hooks";
26558
+
26513
26559
  // src/guest/toolchains/common.ts
26514
26560
  import fs10 from "node:fs";
26515
26561
  import path14 from "node:path";
@@ -28644,6 +28690,7 @@ var plan = (config2, versions, o) => {
28644
28690
 
28645
28691
  // src/guest/toolchains/index.ts
28646
28692
  var PARALLEL2 = 4;
28693
+ var currentStep = new AsyncLocalStorage2();
28647
28694
  var locks = /* @__PURE__ */ new Map();
28648
28695
  var lockKey = (name) => name.split(" ")[0].toLowerCase();
28649
28696
  var exclusive = async (name, work) => {
@@ -28669,10 +28716,19 @@ var runSteps = async (steps, o) => {
28669
28716
  const running = /* @__PURE__ */ new Set();
28670
28717
  const waiting = [];
28671
28718
  let done = 0;
28672
- const say = o.report ?? ((message) => setSandboxStatus("provisioning", message));
28719
+ const percent = /* @__PURE__ */ new Map();
28720
+ const active = () => [...running].map((name) => ({ name, ...percent.has(name) ? { percent: percent.get(name) } : {} }));
28721
+ const say = o.report ?? ((message) => setSandboxStatus("provisioning", message, { active: active(), done, total: steps.length }));
28673
28722
  const report = () => {
28674
28723
  if (running.size) say("Installing " + [...running].join(", ") + " (" + done + " of " + steps.length + " done)");
28675
28724
  };
28725
+ if (!o.report) watchDownloads(() => {
28726
+ const name = currentStep.getStore();
28727
+ return name ? (fraction) => {
28728
+ percent.set(name, Math.round(fraction * 100));
28729
+ report();
28730
+ } : null;
28731
+ });
28676
28732
  for (const s of steps) {
28677
28733
  finished.set(s.name, (async () => {
28678
28734
  await Promise.all((s.after ?? []).map((name) => finished.get(name)));
@@ -28686,16 +28742,22 @@ var runSteps = async (steps, o) => {
28686
28742
  running.add(s.name);
28687
28743
  report();
28688
28744
  try {
28689
- await exclusive(s.name, () => step(s.name, envs.get(s.name), async () => s.run(envs.get(s.name))));
28745
+ await currentStep.run(s.name, () => exclusive(s.name, () => step(s.name, envs.get(s.name), async () => s.run(envs.get(s.name)))));
28690
28746
  } finally {
28691
28747
  running.delete(s.name);
28748
+ percent.delete(s.name);
28692
28749
  done++;
28693
28750
  report();
28694
28751
  waiting.shift()?.();
28695
28752
  }
28696
28753
  })());
28697
28754
  }
28698
- await Promise.all(finished.values());
28755
+ try {
28756
+ await Promise.all(finished.values());
28757
+ } finally {
28758
+ if (!o.report) watchDownloads(null);
28759
+ }
28760
+ if (!o.report) setSandboxStatus("provisioning", "Set up " + steps.length + " toolchains and databases", { active: [], done, total: steps.length });
28699
28761
  return envs;
28700
28762
  };
28701
28763
  var setupToolchains = async (config2, versions, options, run2 = {}) => {
@@ -25316,7 +25316,7 @@ var ClaudeProfile = external_exports.strictObject({
25316
25316
  var GuestWorkspace = external_exports.union([external_exports.literal("auto"), external_exports.literal("mirror"), GuestPath]).describe('Where the workspace appears inside the sandbox. "auto" = C:\\Workspace\\<folder name>. "mirror" = the same path as on this PC, so absolute paths in configs and build caches match (another drive letter such as F: is a subst drive onto C:\\Mirror\\F); paths the sandbox reserves (C:\\Users\\..., C:\\Program Files\\...) fall back to "auto".');
25317
25317
  var Mapping = external_exports.strictObject({
25318
25318
  host: external_exports.string().min(1).describe("Folder on this PC (relative paths are relative to the config file)."),
25319
- guest: GuestPath.describe("Where it appears inside the sandbox, e.g. C:\\Assets, or on another drive letter (D: to Z:) such as F:\\SteamLibrary\\steamapps\\common\\Game: mapped to C:\\Mirror\\F\\... and shown as F: (subst) at boot."),
25319
+ guest: external_exports.string().min(1).max(1024).optional().describe("Where it appears inside the sandbox, e.g. C:\\Assets, or on another drive letter (D: to Z:) such as F:\\SteamLibrary\\steamapps\\common\\Game (shown as F: at boot). Leave it out to use the same path as on this PC (a folder inside the project stays where it is in the project). A path the sandbox cannot use (C:\\Windows\\..., C:\\Users\\..., one already in use) goes to C:\\Mapped\\<drive>\\<path> instead, and the launch says so."),
25320
25320
  readOnly: external_exports.boolean().describe("true = the agent can only read it."),
25321
25321
  optional: external_exports.boolean().optional().describe("true = skip it quietly when the host folder does not exist.")
25322
25322
  });
@@ -25573,6 +25573,7 @@ var Projects = external_exports.strictObject({
25573
25573
  roots: external_exports.array(external_exports.string().min(1)).describe('Folders whose subfolders are your projects (e.g. "F:\\\\Code"), mapped read/write at every start. Any project inside one, including ones you create later, opens in the running sandbox at once, without a restart. Every agent in the sandbox can see all of them.'),
25574
25574
  askToAddRoot: external_exports.boolean().describe("When you start a project whose parent folder is not a root yet, ask once whether to add it."),
25575
25575
  declinedRoots: external_exports.array(external_exports.string().min(1)).describe("Parent folders you said no to (not asked again)."),
25576
+ allowOutsideMappings: external_exports.union([external_exports.literal("ask"), external_exports.boolean()]).describe(`A project's .sandbox-config.json may map folders from outside the project (e.g. a game folder on another drive). "ask" = an interactive launch asks once per project and set of folders, and remembers your answer; true = always allowed (still listed when you approve the file); false = never (the launch stops and says which folders). Only your own settings decide this, never a project file.`),
25576
25577
  wsbShare: external_exports.boolean().describe("On Windows 11 24H2+ with the Windows Sandbox command line (wsb), add other folders to the running sandbox with `wsb share` (untested). Otherwise starting a project outside every root offers a restart.")
25577
25578
  });
25578
25579
 
@@ -25615,7 +25616,7 @@ var LocalOverrides = external_exports.strictObject({
25615
25616
  readOnly: external_exports.boolean().describe("Map this project read-only."),
25616
25617
  // A project may turn the filter on and keep more out, never let ignored files in.
25617
25618
  workspaceFilter: WorkspaceFilter.pick({ enabled: true, exclude: true }).partial(),
25618
- mappings: external_exports.array(Mapping).describe("Extra folders inside this project (relative to this file), e.g. a read-only data folder at another place in the sandbox."),
25619
+ mappings: external_exports.array(Mapping).describe("Extra folders (relative paths are relative to this file), e.g. a read-only data folder at another place in the sandbox. Folders outside the project are used only as your projects.allowOutsideMappings setting allows (by default you are asked once)."),
25619
25620
  scripts: Strings.describe("Scripts (.ps1 / .mjs / .cmd, relative to this file) run inside the sandbox before the agent."),
25620
25621
  // This PC's resources are yours to share: CPU and paging file (this PC's disk) are not a project's to set.
25621
25622
  sandbox: Sandbox.omit({ cpuLimit: true, pageFileMb: true }).partial(),
@@ -25646,6 +25647,15 @@ var AttachEndpoint = external_exports.strictObject({
25646
25647
  port: external_exports.number().int().min(1024).max(65535),
25647
25648
  token: Text(256)
25648
25649
  });
25650
+ var BootProgress = external_exports.strictObject({
25651
+ step: external_exports.number().int().min(1).max(32),
25652
+ steps: external_exports.number().int().min(1).max(32),
25653
+ title: Text(120),
25654
+ /** Being set up now; percent while it downloads (when the size is known). */
25655
+ active: external_exports.array(external_exports.strictObject({ name: Text(64), percent: external_exports.number().int().min(0).max(100).optional() })).max(8),
25656
+ done: external_exports.number().int().min(0).max(1e3),
25657
+ total: external_exports.number().int().min(0).max(1e3)
25658
+ });
25649
25659
  var SandboxStatus = external_exports.object({
25650
25660
  launchId: Text(64),
25651
25661
  phase: external_exports.enum(["booting", "provisioning", "ready", "failed"]),
@@ -25655,7 +25665,9 @@ var SandboxStatus = external_exports.object({
25655
25665
  guestWorkspace: Text(),
25656
25666
  headless: external_exports.boolean(),
25657
25667
  /** The sandbox's IPv4 addresses (for network.forwardPorts; the host only uses ones on its Hyper-V network). */
25658
- addresses: external_exports.array(external_exports.string().max(45)).max(8).optional()
25668
+ addresses: external_exports.array(external_exports.string().max(45)).max(8).optional(),
25669
+ /** A malformed progress is dropped, never the status (the phase still counts). */
25670
+ progress: BootProgress.optional().catch(void 0)
25659
25671
  });
25660
25672
  var Version = external_exports.strictObject({ spec: Text(128), source: Text(256) });
25661
25673
  var DatabasePlan = external_exports.strictObject({
@@ -25895,6 +25907,7 @@ var checksumRequired = false;
25895
25907
  var setChecksumRequired = (on) => {
25896
25908
  checksumRequired = on;
25897
25909
  };
25910
+ var downloadWatcher = null;
25898
25911
  var download = async (url2, destination, expected = null) => {
25899
25912
  const sums = typeof expected === "string" || expected === null ? { sha256: expected } : expected;
25900
25913
  if (checksumRequired && !sums.sha256?.trim() && !sums.sha512?.trim()) {
@@ -25907,8 +25920,25 @@ var download = async (url2, destination, expected = null) => {
25907
25920
  const want = sums[kind]?.trim().toLowerCase();
25908
25921
  return want ? [{ want, hash: crypto.createHash(kind) }] : [];
25909
25922
  });
25923
+ const size = Number(response.headers.get("content-length")) || 0;
25924
+ const report = size > 0 ? downloadWatcher?.() ?? null : null;
25925
+ let received = 0;
25926
+ let tenth = 0;
25927
+ let reportedAt = Date.now();
25910
25928
  const tap = new Transform({ transform(chunk, _encoding, done) {
25911
25929
  for (const c of checks) c.hash.update(chunk);
25930
+ if (report) {
25931
+ received += chunk.length;
25932
+ const now2 = Math.floor(received * 10 / size);
25933
+ if (now2 !== tenth || Date.now() - reportedAt > 5e3) {
25934
+ tenth = now2;
25935
+ reportedAt = Date.now();
25936
+ try {
25937
+ report(Math.min(1, received / size));
25938
+ } catch {
25939
+ }
25940
+ }
25941
+ }
25912
25942
  done(null, chunk);
25913
25943
  } });
25914
25944
  const partial2 = destination + ".part";