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
@@ -9,6 +9,58 @@ export const elapsed = (ms) => {
9
9
  const s = Math.round(ms / 1000);
10
10
  return s < 60 ? s + ' s' : Math.floor(s / 60) + ' min ' + (s % 60) + ' s';
11
11
  };
12
+ /**
13
+ * Follows long work (a sandbox's boot): on a terminal one line redrawn in place with the time so far; without one a
14
+ * plain line each time `key` changes. When nothing changes for `stallMs` it says so (stall(what, ms)). A failure first
15
+ * prints where it stopped, then rethrows.
16
+ */
17
+ export const follow = async (show, work, o) => {
18
+ const err = process.stderr;
19
+ const animate = canAnimate(process.env, Boolean(err.isTTY));
20
+ const colour = colorEnabled(process.env, true);
21
+ const start = Date.now();
22
+ const stallMs = o.stallMs ?? 3 * 60 * 1000;
23
+ let shown = show();
24
+ let lastKey = '';
25
+ let changedAt = start;
26
+ let noticedAt = start;
27
+ let frame = 0;
28
+ const clear = () => { if (animate)
29
+ err.write('\r\x1b[2K'); };
30
+ const tick = () => {
31
+ const now = show();
32
+ if (now.line !== shown.line)
33
+ changedAt = noticedAt = Date.now();
34
+ shown = now;
35
+ if (Date.now() - noticedAt >= stallMs) {
36
+ clear();
37
+ err.write(o.stall(shown.what, Date.now() - changedAt) + '\n');
38
+ noticedAt = Date.now();
39
+ }
40
+ if (animate) {
41
+ const time = Date.now() - start > 3000 ? ' (' + elapsed(Date.now() - start) + ')' : '';
42
+ err.write('\r\x1b[2K' + (colour ? '\x1b[36m' : '') + FRAMES[frame++ % FRAMES.length] + (colour ? '\x1b[0m' : '') + ' ' + shorten(shown.line, termWidth() - 4 - time.length) + time);
43
+ }
44
+ else if (shown.key !== lastKey) {
45
+ lastKey = shown.key;
46
+ err.write(shown.line + (Date.now() - start > 3000 ? ' (' + elapsed(Date.now() - start) + ')' : '') + '\n');
47
+ }
48
+ };
49
+ tick();
50
+ const timer = setInterval(tick, animate ? 150 : 1000);
51
+ try {
52
+ return await work();
53
+ }
54
+ catch (e) {
55
+ clear();
56
+ err.write('It stopped at: ' + shown.line + '\n');
57
+ throw e;
58
+ }
59
+ finally {
60
+ clearInterval(timer);
61
+ clear();
62
+ }
63
+ };
12
64
  /**
13
65
  * Shows `message` while work runs and removes the line afterwards. The spinner only moves while the event loop is free
14
66
  * (waiting on the sandbox); for long synchronous work (measuring folders) the static line is what you see.
@@ -72,7 +72,7 @@ export const DEFAULTS = {
72
72
  aider: { uvPackage: 'aider-chat', uvBin: 'aider', arguments: [], environment: {}, resumeArguments: ['--restore-chat-history'] },
73
73
  },
74
74
  hooks: { failFast: false },
75
- projects: { all: false, exclude: [], roots: [], askToAddRoot: true, declinedRoots: [], wsbShare: true },
75
+ projects: { all: false, exclude: [], roots: [], askToAddRoot: true, declinedRoots: [], allowOutsideMappings: 'ask', wsbShare: true },
76
76
  };
77
77
  /** Per-user folder for config and hooks. Survives reinstalling the npm package. */
78
78
  export const userDir = () => process.env.AGENT_SANDBOX_HOME ?? path.join(homeDir(), '.agent-sandbox');
@@ -2,6 +2,15 @@ import type { Config, LocalConfigFile, Overrides } from './types.js';
2
2
  export declare const LOCAL_CONFIG_NAME = ".sandbox-config.json";
3
3
  /** Validates a folder's settings and resolves its relative paths against that folder. */
4
4
  export declare const normalizeOverrides: (data: unknown, dir: string, source: string) => Overrides;
5
+ /**
6
+ * The project file's mappings of folders outside the project (%VAR% expanded, links and short names followed). A repo
7
+ * ships this file, so these reach the rest of this PC: projects.allowOutsideMappings in YOUR settings decides.
8
+ */
9
+ export declare const outsideMappings: <M extends {
10
+ host: string;
11
+ }>(dir: string, mappings?: M[]) => M[];
12
+ /** `folder` (also where it really is, through links) is inside the project folder `dir`. */
13
+ export declare const insideProject: (dir: string, folder: string) => boolean;
5
14
  /**
6
15
  * Protections a project file may only make stricter, never looser. A repo can ship the file, so it must not be able to
7
16
  * switch off what you turned on (or turn on what you turned off). The strictest of the two values always wins.
@@ -19,7 +28,7 @@ export declare const loosenedRequests: (config: Config, ov: Overrides) => {
19
28
  /** Applies validated overrides. Mappings with the same guest folder replace the configured ones. */
20
29
  export declare const applyOverrides: (config: Config, ov: Overrides) => Config;
21
30
  /** Human-readable list of what a local config changes, with risky items flagged. */
22
- export declare const describeOverrides: (ov: Overrides) => string[];
31
+ export declare const describeOverrides: (ov: Overrides, dir?: string) => string[];
23
32
  export declare const readLocalConfig: (dir: string) => LocalConfigFile | null;
24
33
  export declare const trustStorePath: (storageRoot: string) => string;
25
34
  export declare const isTrusted: (store: string, file: string, hash: string) => boolean;
@@ -28,6 +37,12 @@ export declare const allowStorePath: (storageRoot: string) => string;
28
37
  /** true = you allowed it, false = you said no (not asked again), null = not asked yet. */
29
38
  export declare const allowedAnswer: (store: string, file: string, setting: string, value: unknown) => boolean | null;
30
39
  export declare const saveAllowed: (store: string, file: string, setting: string, value: unknown, allow: boolean) => void;
40
+ /** Answers for folders outside the project are kept with the others, one per folder (and read-only or not). */
41
+ export declare const OUTSIDE_FOLDER = "folder outside the project";
42
+ export declare const outsideKey: (m: {
43
+ host: string;
44
+ readOnly: boolean;
45
+ }) => string;
31
46
  /** Your config with these requests let through, so applyOverrides' "strictest wins" keeps the project's value. */
32
47
  export declare const allowRequests: (config: Config, allowed: {
33
48
  key: string;
@@ -20,14 +20,15 @@ export const normalizeOverrides = (data, dir, source) => {
20
20
  if (missing.length)
21
21
  fail(source + ' is not valid:\n - scripts: file not found: ' + missing.join(', '));
22
22
  const mappings = overrides.mappings?.map((m) => ({ ...m, host: expandPath(m.host, dir) }));
23
- // A repo ships this file, so it must not reach the rest of this PC: its extra folders stay inside the project.
24
- const outside = mappings?.filter((m) => !insideProject(dir, m.host)) ?? [];
25
- if (outside.length)
26
- fail(source + ' is not valid:\n - mappings: a project file may only map folders inside the project, not ' + outside.map((m) => m.host).join(', '), 'Map other folders in your own settings (sandbox config path shows where they are).');
27
23
  return { ...overrides, scripts, mappings };
28
24
  };
25
+ /**
26
+ * The project file's mappings of folders outside the project (%VAR% expanded, links and short names followed). A repo
27
+ * ships this file, so these reach the rest of this PC: projects.allowOutsideMappings in YOUR settings decides.
28
+ */
29
+ export const outsideMappings = (dir, mappings = []) => mappings.filter((m) => !insideProject(dir, m.host));
29
30
  /** `folder` (also where it really is, through links) is inside the project folder `dir`. */
30
- const insideProject = (dir, folder) => {
31
+ export const insideProject = (dir, folder) => {
31
32
  // Where a path really is: the nearest existing folder resolved (links, 8.3 short names), plus the part not created yet.
32
33
  const real = (p) => {
33
34
  const full = path.resolve(p);
@@ -75,17 +76,19 @@ export const applyOverrides = (config, ov) => {
75
76
  next.network.blockHosts = _.union(config.network.blockHosts, rest.network.blockHosts);
76
77
  if (rest.workspaceFilter?.exclude)
77
78
  next.workspaceFilter.exclude = _.union(config.workspaceFilter.exclude, rest.workspaceFilter.exclude);
79
+ const sameGuest = (a, b) => Boolean(a && b && a.toLowerCase() === b.toLowerCase());
78
80
  if (mappings)
79
- next.mappings = [..._.reject(config.mappings, (m) => mappings.some((o) => o.guest.toLowerCase() === m.guest.toLowerCase())), ...mappings];
81
+ next.mappings = [..._.reject(config.mappings, (m) => mappings.some((o) => sameGuest(o.guest, m.guest))), ...mappings];
80
82
  return next;
81
83
  };
82
84
  const flatten = (value, prefix = '') => _.isPlainObject(value) ? Object.entries(value).flatMap(([k, v]) => flatten(v, prefix ? prefix + '.' + k : k)) : [prefix + ' = ' + JSON.stringify(value)];
83
85
  /** Human-readable list of what a local config changes, with risky items flagged. */
84
- export const describeOverrides = (ov) => {
86
+ export const describeOverrides = (ov, dir) => {
85
87
  const { harness, mappings = [], scripts = [], $schema: _schema, ...sections } = ov;
88
+ const outside = dir ? outsideMappings(dir, mappings) : [];
86
89
  const lines = [
87
90
  ...(harness ? ['agent = ' + harness] : []),
88
- ...mappings.map((m) => (m.readOnly ? 'read-only' : 'READ/WRITE') + ' folder ' + m.host + ' -> ' + m.guest),
91
+ ...mappings.map((m) => (m.readOnly ? 'read-only' : 'READ/WRITE') + ' folder ' + m.host + ' -> ' + (m.guest ?? 'the same path') + (outside.includes(m) ? ' (outside the project)' : '')),
89
92
  ...scripts.map((s) => 'runs inside the sandbox: ' + s),
90
93
  ...flatten(sections),
91
94
  ];
@@ -114,7 +117,7 @@ export const saveTrust = (store, file, hash) => writeJson(store, { ...loadTrust(
114
117
  // is kept here, per project file and per "setting = value", so only that value is ever let through for that project.
115
118
  export const allowStorePath = (storageRoot) => path.join(storageRoot, 'allowed-requests.json');
116
119
  const loadAllowed = (store) => {
117
- const parsed = z.record(z.string(), z.array(z.string().max(300)).max(50)).safeParse(tryReadJson(store) ?? {});
120
+ const parsed = z.record(z.string(), z.array(z.string().max(2000)).max(500)).safeParse(tryReadJson(store) ?? {});
118
121
  return parsed.success ? parsed.data : {};
119
122
  };
120
123
  const allowKey = (setting, value) => setting + ' = ' + JSON.stringify(value);
@@ -128,6 +131,9 @@ export const saveAllowed = (store, file, setting, value, allow) => {
128
131
  const entry = allowKey(setting, value);
129
132
  writeJson(store, { ...all, [key(file)]: _.union(_.without(all[key(file)] ?? [], entry, 'no: ' + entry), [(allow ? '' : 'no: ') + entry]) });
130
133
  };
134
+ /** Answers for folders outside the project are kept with the others, one per folder (and read-only or not). */
135
+ export const OUTSIDE_FOLDER = 'folder outside the project';
136
+ export const outsideKey = (m) => (m.readOnly ? 'read-only ' : 'read/write ') + path.resolve(m.host).toLowerCase();
131
137
  /** Your config with these requests let through, so applyOverrides' "strictest wins" keeps the project's value. */
132
138
  export const allowRequests = (config, allowed) => {
133
139
  const next = _.cloneDeep(config);
@@ -1,4 +1,4 @@
1
- import type { Config } from './types.js';
1
+ import type { Config, ResolvedMapping } from './types.js';
2
2
  /** The protections of a running sandbox, by setting path (`sandbox.networking`, `provisioning.git.access`, ...). */
3
3
  export type Protections = Record<string, boolean | string>;
4
4
  export declare const protectionsOf: (config: Config) => Protections;
@@ -7,9 +7,56 @@ export interface JoinCheck {
7
7
  stricter: string[];
8
8
  /** Settings it would have looser: it gets the sandbox's (stricter) value; shown as a note. */
9
9
  looser: string[];
10
+ /** Settings the sandbox turned on at its start for another project you allowed them for: shared, shown as a note. */
11
+ shared: string[];
10
12
  }
11
13
  /**
12
14
  * Compares a project's effective protections with the running sandbox's. Hub projects are always read/write
13
- * (Windows Sandbox maps the hub once, read/write), so a read-only project counts as stricter.
15
+ * (Windows Sandbox maps the hub once, read/write), so a read-only project counts as stricter. `widened`: the boot
16
+ * settings the sandbox turned on for another of its projects (with your consent); a project that has them off
17
+ * shares them instead of being refused.
14
18
  */
15
- export declare const checkJoin: (running: Protections, wanted: Protections) => JoinCheck;
19
+ export declare const checkJoin: (running: Protections, wanted: Protections, widened?: string[]) => JoinCheck;
20
+ /** .wsb devices a project may want on (only when your settings or your answer for that project allow it). */
21
+ export declare const BOOT_SETTINGS: string[];
22
+ /** What the running sandbox booted with (host-only registry). */
23
+ export interface BootRecord {
24
+ memoryMb: number | null;
25
+ mappings: ResolvedMapping[];
26
+ }
27
+ /** What a project wants that the running sandbox started without. */
28
+ export interface Missing {
29
+ settings: string[];
30
+ memoryMb: number | null;
31
+ mappings: ResolvedMapping[];
32
+ }
33
+ /**
34
+ * The boot-only things `config` (a project's effective config: your settings, its approved file, your answers) asks
35
+ * for that the running sandbox lacks. `exists`: optional mappings whose folder is missing are no wish.
36
+ */
37
+ export declare const missingAtBoot: (running: Protections, boot: BootRecord, config: Config, exists?: (folder: string) => boolean) => Missing;
38
+ export declare const nothingMissing: (m: Missing) => boolean;
39
+ /** "mw2-agent wants vGPU and 2 folders (C:\Steam\..., F:\SteamLibrary\...), but this sandbox started without them." */
40
+ export declare const describeMissing: (project: string, m: Missing) => string;
41
+ /** "Set sandbox.vGpu (or the mappings) in your own settings to have them in every sandbox." */
42
+ export declare const missingTip: (m: Missing) => string;
43
+ /** A project mapped at boot besides the launch folder, with its effective config. */
44
+ export interface BootWant {
45
+ name: string;
46
+ config: Config;
47
+ }
48
+ export interface Widened {
49
+ config: Config;
50
+ /** Setting -> the project it was turned on for. */
51
+ widened: Record<string, string>;
52
+ notes: string[];
53
+ }
54
+ /**
55
+ * The launch folder's config plus what the other projects mapped at boot want: their allowed .wsb devices, a fixed
56
+ * memory size larger than the launch's, and their mappings (placed with every other mapping at launch).
57
+ * `keepOff`: devices the launch folder's own .sandbox-config.json switched off; they stay off.
58
+ */
59
+ export declare const widenForProjects: (launch: Config, others: BootWant[], opts?: {
60
+ keepOff?: string[];
61
+ exists?: (folder: string) => boolean;
62
+ }) => Widened;
@@ -3,6 +3,9 @@
3
3
  // A second project may never loosen them, and a project that needs a STRICTER setting than the running sandbox has
4
4
  // cannot get it there without changing it for the sessions already running, so that launch is refused with a clear
5
5
  // message. A project asking for less than the sandbox has simply runs with the sandbox's (stricter) setting.
6
+ // The other direction: what a project WANTS at boot (a .wsb device you allowed for it, more memory, its mappings) and
7
+ // the running sandbox started without. Those can only come with a restart, so they are named (missingAtBoot), and a
8
+ // sandbox started with several projects boots with all of their allowed wants (widenForProjects).
6
9
  // Pure: the host checks it before registering a project, the tests drive it directly.
7
10
  import _ from 'lodash';
8
11
  import { GIT_STRICTNESS, NEVER_LOOSER, OFF_IS_SAFER, ON_IS_SAFER } from './local-config.js';
@@ -19,17 +22,100 @@ const compare = (setting, running, wanted) => {
19
22
  };
20
23
  /**
21
24
  * Compares a project's effective protections with the running sandbox's. Hub projects are always read/write
22
- * (Windows Sandbox maps the hub once, read/write), so a read-only project counts as stricter.
25
+ * (Windows Sandbox maps the hub once, read/write), so a read-only project counts as stricter. `widened`: the boot
26
+ * settings the sandbox turned on for another of its projects (with your consent); a project that has them off
27
+ * shares them instead of being refused.
23
28
  */
24
- export const checkJoin = (running, wanted) => {
29
+ export const checkJoin = (running, wanted, widened = []) => {
25
30
  const stricter = [];
26
31
  const looser = [];
32
+ const shared = [];
27
33
  for (const setting of NEVER_LOOSER) {
28
34
  const result = compare(setting, setting === 'readOnly' ? false : running[setting], wanted[setting]);
29
- if (result > 0)
35
+ if (result > 0 && widened.includes(setting))
36
+ shared.push(setting);
37
+ else if (result > 0)
30
38
  stricter.push(setting + ' = ' + JSON.stringify(wanted[setting]) + ' (the running sandbox has ' + JSON.stringify(setting === 'readOnly' ? false : running[setting]) + ')');
31
39
  if (result < 0)
32
40
  looser.push(setting + ' stays ' + JSON.stringify(running[setting]));
33
41
  }
34
- return { stricter, looser };
42
+ return { stricter, looser, shared };
43
+ };
44
+ // ---- what a project wants at boot ---------------------------------------------------------------------------------
45
+ /** .wsb devices a project may want on (only when your settings or your answer for that project allow it). */
46
+ export const BOOT_SETTINGS = ['sandbox.vGpu', 'sandbox.networking', 'sandbox.clipboard', 'sandbox.audioInput', 'sandbox.videoInput', 'sandbox.printerRedirection'];
47
+ const LABELS = {
48
+ 'sandbox.vGpu': 'vGPU', 'sandbox.networking': 'networking', 'sandbox.clipboard': 'the shared clipboard',
49
+ 'sandbox.audioInput': 'the microphone', 'sandbox.videoInput': 'the camera', 'sandbox.printerRedirection': 'printers',
50
+ };
51
+ const sameHost = (a, b) => a.toLowerCase().replace(/\\+$/, '') === b.toLowerCase().replace(/\\+$/, '');
52
+ /** `m`'s folder is mapped, wherever the launch placed it (read-only is enough for a read-only wish). */
53
+ const isMapped = (mapped, m) => mapped.some((x) => sameHost(x.host, m.host) && (m.readOnly || !x.readOnly));
54
+ /**
55
+ * The boot-only things `config` (a project's effective config: your settings, its approved file, your answers) asks
56
+ * for that the running sandbox lacks. `exists`: optional mappings whose folder is missing are no wish.
57
+ */
58
+ export const missingAtBoot = (running, boot, config, exists = () => true) => ({
59
+ settings: BOOT_SETTINGS.filter((s) => _.get(config, s) === true && running[s] === false),
60
+ memoryMb: typeof config.sandbox.memoryMb === 'number' && boot.memoryMb !== null && config.sandbox.memoryMb > boot.memoryMb ? config.sandbox.memoryMb : null,
61
+ mappings: config.mappings.filter((m) => (!m.optional || exists(m.host)) && !isMapped(boot.mappings, m)).map((m) => ({ host: m.host, guest: m.guest ?? m.host, readOnly: m.readOnly })),
62
+ });
63
+ export const nothingMissing = (m) => m.settings.length === 0 && m.memoryMb === null && m.mappings.length === 0;
64
+ const listing = (items) => items.length <= 1 ? items.join('') : items.slice(0, -1).join(', ') + ' and ' + items[items.length - 1];
65
+ /** "mw2-agent wants vGPU and 2 folders (C:\Steam\..., F:\SteamLibrary\...), but this sandbox started without them." */
66
+ export const describeMissing = (project, m) => {
67
+ const items = [
68
+ ...m.settings.map((s) => LABELS[s] ?? s),
69
+ ...(m.memoryMb !== null ? [m.memoryMb + ' MB of memory'] : []),
70
+ ...(m.mappings.length ? [m.mappings.length + ' folder' + (m.mappings.length === 1 ? '' : 's') + ' (' + m.mappings.map((x) => x.host).join(', ') + ')'] : []),
71
+ ];
72
+ return project + ' wants ' + listing(items) + ', but this sandbox started without ' + (items.length === 1 && !m.mappings.length ? 'it' : 'them') + '.';
73
+ };
74
+ /** "Set sandbox.vGpu (or the mappings) in your own settings to have them in every sandbox." */
75
+ export const missingTip = (m) => {
76
+ const keys = [...m.settings, ...(m.memoryMb !== null ? ['sandbox.memoryMb'] : [])];
77
+ const what = keys.length ? listing(keys) + (m.mappings.length ? ' (or the mappings)' : '') : 'the mappings';
78
+ return 'Set ' + what + ' in your own settings to have them in every sandbox.';
79
+ };
80
+ /**
81
+ * The launch folder's config plus what the other projects mapped at boot want: their allowed .wsb devices, a fixed
82
+ * memory size larger than the launch's, and their mappings (placed with every other mapping at launch).
83
+ * `keepOff`: devices the launch folder's own .sandbox-config.json switched off; they stay off.
84
+ */
85
+ export const widenForProjects = (launch, others, opts = {}) => {
86
+ const keepOff = opts.keepOff ?? [];
87
+ const exists = opts.exists ?? (() => true);
88
+ const config = _.cloneDeep(launch);
89
+ const widened = {};
90
+ const notes = [];
91
+ for (const setting of BOOT_SETTINGS) {
92
+ const by = others.find((o) => _.get(o.config, setting) === true);
93
+ if (!by || _.get(config, setting) === true)
94
+ continue;
95
+ if (keepOff.includes(setting)) {
96
+ notes.push(by.name + ' wants ' + (LABELS[setting] ?? setting) + ', but the launch folder\'s .sandbox-config.json switches it off, so it stays off.');
97
+ continue;
98
+ }
99
+ _.set(config, setting, true);
100
+ widened[setting] = by.name;
101
+ notes.push((LABELS[setting] ?? setting) + ' is on in this sandbox (for every project in it): ' + by.name + ' asks for it and you allowed it.');
102
+ }
103
+ for (const o of others) {
104
+ const mem = o.config.sandbox.memoryMb;
105
+ if (typeof mem === 'number' && typeof config.sandbox.memoryMb === 'number' && mem > config.sandbox.memoryMb)
106
+ config.sandbox.memoryMb = mem;
107
+ for (const m of o.config.mappings) {
108
+ if (config.mappings.some((x) => sameHost(x.host, m.host)))
109
+ continue;
110
+ if (m.optional && !exists(m.host))
111
+ continue;
112
+ if (!exists(m.host)) {
113
+ notes.push(o.name + ': mapped folder not found, skipped: ' + m.host);
114
+ continue;
115
+ }
116
+ // Where it appears (and moving it when that place is taken) is decided with every other mapping at launch.
117
+ config.mappings.push({ ...m, optional: true });
118
+ }
119
+ }
120
+ return { config, widened, notes };
35
121
  };
@@ -18,7 +18,7 @@ export declare const Config: z.ZodObject<{
18
18
  }, z.core.$strict>;
19
19
  mappings: z.ZodArray<z.ZodObject<{
20
20
  host: z.ZodString;
21
- guest: z.ZodString;
21
+ guest: z.ZodOptional<z.ZodString>;
22
22
  readOnly: z.ZodBoolean;
23
23
  optional: z.ZodOptional<z.ZodBoolean>;
24
24
  }, z.core.$strict>>;
@@ -278,6 +278,7 @@ export declare const Config: z.ZodObject<{
278
278
  roots: z.ZodArray<z.ZodString>;
279
279
  askToAddRoot: z.ZodBoolean;
280
280
  declinedRoots: z.ZodArray<z.ZodString>;
281
+ allowOutsideMappings: z.ZodUnion<readonly [z.ZodLiteral<"ask">, z.ZodBoolean]>;
281
282
  wsbShare: z.ZodBoolean;
282
283
  }, z.core.$strict>;
283
284
  }, z.core.$strict>;
@@ -296,7 +297,7 @@ export declare const LocalOverrides: z.ZodObject<{
296
297
  }, z.core.$strict>>;
297
298
  mappings: z.ZodOptional<z.ZodArray<z.ZodObject<{
298
299
  host: z.ZodString;
299
- guest: z.ZodString;
300
+ guest: z.ZodOptional<z.ZodString>;
300
301
  readOnly: z.ZodBoolean;
301
302
  optional: z.ZodOptional<z.ZodBoolean>;
302
303
  }, z.core.$strict>>>;
@@ -52,7 +52,7 @@ export const LocalOverrides = z.strictObject({
52
52
  readOnly: z.boolean().describe('Map this project read-only.'),
53
53
  // A project may turn the filter on and keep more out, never let ignored files in.
54
54
  workspaceFilter: WorkspaceFilter.pick({ enabled: true, exclude: true }).partial(),
55
- mappings: z.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.'),
55
+ mappings: z.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).'),
56
56
  scripts: Strings.describe('Scripts (.ps1 / .mjs / .cmd, relative to this file) run inside the sandbox before the agent.'),
57
57
  // This PC's resources are yours to share: CPU and paging file (this PC's disk) are not a project's to set.
58
58
  sandbox: Sandbox.omit({ cpuLimit: true, pageFileMb: true }).partial(),
@@ -5,5 +5,6 @@ export declare const Projects: z.ZodObject<{
5
5
  roots: z.ZodArray<z.ZodString>;
6
6
  askToAddRoot: z.ZodBoolean;
7
7
  declinedRoots: z.ZodArray<z.ZodString>;
8
+ allowOutsideMappings: z.ZodUnion<readonly [z.ZodLiteral<"ask">, z.ZodBoolean]>;
8
9
  wsbShare: z.ZodBoolean;
9
10
  }, z.core.$strict>;
@@ -7,5 +7,6 @@ export const Projects = z.strictObject({
7
7
  roots: z.array(z.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.'),
8
8
  askToAddRoot: z.boolean().describe('When you start a project whose parent folder is not a root yet, ask once whether to add it.'),
9
9
  declinedRoots: z.array(z.string().min(1)).describe('Parent folders you said no to (not asked again).'),
10
+ allowOutsideMappings: z.union([z.literal('ask'), z.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.'),
10
11
  wsbShare: z.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.'),
11
12
  });
@@ -2,7 +2,7 @@ import { z } from 'zod';
2
2
  export declare const GuestWorkspace: z.ZodUnion<readonly [z.ZodLiteral<"auto">, z.ZodLiteral<"mirror">, z.ZodString]>;
3
3
  export declare const Mapping: z.ZodObject<{
4
4
  host: z.ZodString;
5
- guest: z.ZodString;
5
+ guest: z.ZodOptional<z.ZodString>;
6
6
  readOnly: z.ZodBoolean;
7
7
  optional: z.ZodOptional<z.ZodBoolean>;
8
8
  }, z.core.$strict>;
@@ -4,7 +4,7 @@ import { GitAccess, GuestPath, toggle } from './common.js';
4
4
  export const GuestWorkspace = z.union([z.literal('auto'), z.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".');
5
5
  export const Mapping = z.strictObject({
6
6
  host: z.string().min(1).describe('Folder on this PC (relative paths are relative to the config file).'),
7
- 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.'),
7
+ guest: z.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.'),
8
8
  readOnly: z.boolean().describe('true = the agent can only read it.'),
9
9
  optional: z.boolean().optional().describe('true = skip it quietly when the host folder does not exist.'),
10
10
  });
@@ -8,10 +8,12 @@ export type Harness = z.output<typeof HarnessSchema>;
8
8
  export type Mapping = z.output<typeof MappingSchema>;
9
9
  export type Toolchains = Config['toolchains'];
10
10
  export type ClaudeProfileConfig = Config['claudeProfile'];
11
+ /** label: shown in the launch table, e.g. "outside the project, allowed". */
11
12
  export interface ResolvedMapping {
12
13
  host: string;
13
14
  guest: string;
14
15
  readOnly: boolean;
16
+ label?: string;
15
17
  }
16
18
  export interface LocalConfigFile {
17
19
  path: string;
@@ -39,6 +41,8 @@ export interface RunRequest {
39
41
  nodeVersion?: string;
40
42
  pythonVersion?: string;
41
43
  ask?: (question: string) => Promise<boolean>;
44
+ /** `ask` can reach you (default: stdin is a terminal). Tests set it. */
45
+ interactive?: boolean;
42
46
  }
43
47
  export interface ResolvedRun {
44
48
  config: Config;
@@ -52,6 +56,8 @@ export interface ResolvedRun {
52
56
  versions: ToolVersions;
53
57
  /** Your config asks for guestWorkspace "mirror" (project roots then appear at their PC paths too). */
54
58
  mirror?: boolean;
59
+ /** The project file's folders outside the project that you allowed (host paths; the launch table marks them). */
60
+ outsideMappings?: string[];
55
61
  }
56
62
  /** A requested version plus where the request came from (shown to the user). */
57
63
  export interface VersionSpec {
@@ -100,6 +106,11 @@ export interface GenerateOptions {
100
106
  knownProjects?: string[];
101
107
  /** false: no agent session at boot. */
102
108
  autoSession?: boolean;
109
+ /** The other projects mapped at boot with their effective configs: the sandbox boots with what they want too. */
110
+ bootWants?: {
111
+ projects: import('./project-policy.js').BootWant[];
112
+ keepOff: string[];
113
+ };
103
114
  }
104
115
  export interface Generated {
105
116
  wsbPath: string;
@@ -107,6 +118,10 @@ export interface Generated {
107
118
  launchId: string;
108
119
  sessionPath: string;
109
120
  memoryMb: number;
121
+ /** The config the sandbox boots with: the launch folder's plus what the other projects mapped at boot want. */
122
+ bootConfig: Config;
123
+ /** Boot settings turned on for another project (setting -> project). */
124
+ widened: Record<string, string>;
110
125
  mappings: ResolvedMapping[];
111
126
  profile: ProfileResult;
112
127
  databases: DatabasePlan[];
@@ -123,6 +138,8 @@ export interface Generated {
123
138
  notes: string[];
124
139
  };
125
140
  toolchains: Record<import('./detect-toolchains.js').AutoToolchain, import('./detect-toolchains.js').ToolchainDecision>;
141
+ /** Where extra folders went when their own path could not be used, and the optional ones skipped. */
142
+ mappingNotes: string[];
126
143
  }
127
144
  /** secretsRemoved: what secrets.strip kept on this PC, e.g. "settings.json env.AWS_SECRET_ACCESS_KEY". */
128
145
  export interface ProfileResult {
@@ -140,7 +157,7 @@ export interface GuestSession {
140
157
  workspaceHost: string;
141
158
  /** The launch folder's project id (file-name safe; status files and sessions use it). Older launch folders lack it. */
142
159
  projectId?: string;
143
- /** Where other projects appear (C:\Projects), and the project roots' guest folders. Older launch folders lack them. */
160
+ /** Where project roots appear (C:\Projects), and the guest folders of the roots and the projects mapped at boot. Older launch folders lack them. */
144
161
  projectsDir?: string;
145
162
  roots?: string[];
146
163
  /** false: start no agent session at boot (a restart that adds a project opens its sessions itself). */
@@ -1,9 +1,13 @@
1
- import type { Config, Overrides, ResolvedRun, RunRequest, ToolVersions } from './types.js';
1
+ import type { Config, Mapping, Overrides, ResolvedRun, RunRequest, ToolVersions } from './types.js';
2
+ /** 'auto' guest workspace: C:\Workspace\<folder name> (shared/projects.ts, where every project's place is decided). */
3
+ export declare const autoGuestWorkspace: (hostDir: string) => string;
2
4
  /**
3
- * 'auto' guest workspace: C:\Workspace\<folder name>. Keeping the real folder name means Claude's per-project history
4
- * and claude-mem's project name match this PC instead of every project being "Workspace".
5
+ * A launch folder inside a project root appears under the root's folder (C:\Projects\<root>\<name>, or its PC path
6
+ * with "mirror"), where it also is when another project started the sandbox, so git worktrees and Claude's project
7
+ * folders keep working whichever project starts it. Only for "auto" / "mirror"; a filtered or read-only workspace
8
+ * keeps its own mapping (the root would show the folder unfiltered and writable at that place).
5
9
  */
6
- export declare const autoGuestWorkspace: (workspaceHost: string) => string;
10
+ export declare const placeInRoot: (config: Config, workspaceHost: string, mirror: boolean, notes: string[]) => Config;
7
11
  /** The Node version a Volta project pins ("volta": { "node": "20.11.1" } in package.json), so nvm defaults to it too. */
8
12
  export declare const voltaNode: (workspace: string) => string | null;
9
13
  /**
@@ -16,3 +20,16 @@ export declare const resolveVersions: (config: Config, workspace: string, local:
16
20
  }, hostNodeVersion: string) => ToolVersions;
17
21
  /** Precedence (lowest to highest): built-in defaults, user config, .sandbox-config.json in the workspace, command-line flags. */
18
22
  export declare const resolveRun: (req: RunRequest, hostNodeVersion?: string) => Promise<ResolvedRun>;
23
+ /** Where the workspace appears: guestWorkspace "auto", "mirror" (with a note when it falls back) or a path. */
24
+ export declare const guestWorkspaceOf: (config: Pick<Config, "guestWorkspace">, workspaceHost: string) => {
25
+ guest: string;
26
+ note: string | null;
27
+ };
28
+ /**
29
+ * Where a mapping will most likely appear, before the launch knows every other folder (the prompt and config check
30
+ * show it; the launch places it for real in host/launch/mappings.ts with the same rules).
31
+ */
32
+ export declare const previewGuest: (m: Pick<Mapping, "host" | "guest">, workspaceHost: string, workspaceGuest: string) => {
33
+ guest: string;
34
+ note: string | null;
35
+ };