windows-development-sandbox 1.13.0 → 1.14.0

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 (125) hide show
  1. package/dist/app/features/accounts/accounts.def.d.ts +1 -0
  2. package/dist/app/features/accounts/accounts.def.js +12 -3
  3. package/dist/app/features/accounts/accounts.run.d.ts +1 -0
  4. package/dist/app/features/accounts/accounts.run.js +20 -11
  5. package/dist/app/features/defs.d.ts +1 -0
  6. package/dist/app/features/settings/settings-file.d.ts +7 -0
  7. package/dist/app/features/settings/settings-file.js +19 -2
  8. package/dist/app/features/settings/settings-rows.js +9 -1
  9. package/dist/app/features/storage/storage.run.js +1 -1
  10. package/dist/bin/agent-sandbox.js +7 -1
  11. package/dist/bin/chunk-BQ23GBLC.js +78790 -0
  12. package/dist/bin/chunk-F7IMME5G.js +10432 -0
  13. package/dist/bin/chunk-LQHEUN2A.js +7 -0
  14. package/dist/bin/claude-host.js +10 -4
  15. package/dist/bin/claude-sbx.js +8 -2
  16. package/dist/bin/codex-sbx.js +8 -2
  17. package/dist/bin/gemini-sbx.js +8 -2
  18. package/dist/bin/sandbox.js +7 -2
  19. package/dist/bin/sbx-hide.js +8 -2
  20. package/dist/bin/sbx-show.js +8 -2
  21. package/dist/cli/commands/launch/launch.js +1 -1
  22. package/dist/cli/commands/launch/prepare.js +24 -7
  23. package/dist/cli/commands/launch/running.js +4 -0
  24. package/dist/cli/commands/repair/checks/contained.js +1 -1
  25. package/dist/cli/commands/storage/offer.js +1 -1
  26. package/dist/cli/menu/live.d.ts +29 -0
  27. package/dist/cli/menu/live.js +92 -21
  28. package/dist/cli/menu/menu.d.ts +2 -1
  29. package/dist/cli/menu/menu.js +16 -2
  30. package/dist/cli/menu/pages/list-page.d.ts +5 -0
  31. package/dist/cli/menu/pages/list-page.js +19 -0
  32. package/dist/cli/menu/shell.d.ts +2 -0
  33. package/dist/cli/menu/shell.js +1 -1
  34. package/dist/cli/services/accounts-service.d.ts +7 -0
  35. package/dist/cli/services/accounts-service.js +20 -0
  36. package/dist/cli/tui/app.d.ts +4 -0
  37. package/dist/cli/tui/app.js +5 -0
  38. package/dist/cli/tui/terminal.d.ts +6 -0
  39. package/dist/cli/tui/terminal.js +8 -1
  40. package/dist/cli/validate.js +8 -4
  41. package/dist/core/package-info.js +1 -1
  42. package/dist/guest/daemon/sessions.d.ts +7 -0
  43. package/dist/guest/daemon/sessions.js +23 -2
  44. package/dist/guest/entries/daemon.js +1 -0
  45. package/dist/guest/sessions/pty-host.d.ts +10 -0
  46. package/dist/guest/sessions/pty-host.js +48 -3
  47. package/dist/host/channel/channel.js +1 -1
  48. package/dist/host/install/install-record.d.ts +1 -0
  49. package/dist/host/install/install-record.js +2 -0
  50. package/dist/host/sandbox/contained-deps.d.ts +2 -1
  51. package/dist/host/sandbox/contained-deps.js +6 -4
  52. package/dist/host/sandbox/platform.d.ts +2 -0
  53. package/dist/host/sandbox/platform.js +19 -4
  54. package/dist/host/sharing/lifecycle.js +9 -1
  55. package/dist/host/storage/contained/contained-storage.js +13 -0
  56. package/dist/host/storage/contained/disk-port.d.ts +5 -0
  57. package/dist/host/storage/contained/helper-port.d.ts +72 -0
  58. package/dist/host/storage/contained/helper-port.js +162 -0
  59. package/dist/host/storage/contained/index.d.ts +1 -0
  60. package/dist/host/storage/contained/index.js +1 -0
  61. package/dist/host/storage/contained/state.d.ts +3 -2
  62. package/dist/host/storage/contained/state.js +5 -2
  63. package/dist/host/web/bridge.d.ts +5 -0
  64. package/dist/host/web/bridge.js +11 -1
  65. package/dist/host/web/server.d.ts +2 -0
  66. package/dist/host/web/server.js +11 -1
  67. package/dist/runtime/agent.js +9 -0
  68. package/dist/runtime/daemon.js +57 -3
  69. package/dist/runtime/db.js +9 -0
  70. package/dist/runtime/main.js +195 -12
  71. package/dist/runtime/netproxy.js +9 -0
  72. package/dist/runtime/view.js +195 -12
  73. package/dist/selftest/channel.test.js +72 -1
  74. package/dist/selftest/contained.test.js +1 -37
  75. package/dist/selftest/hcs.test.js +5 -2
  76. package/dist/selftest/index.js +2 -0
  77. package/dist/selftest/install.test.js +11 -0
  78. package/dist/selftest/layout.test.js +10 -1
  79. package/dist/selftest/live-daemon.test.js +51 -1
  80. package/dist/selftest/live-terminal.test.js +112 -4
  81. package/dist/selftest/menu-perf.test.js +198 -12
  82. package/dist/selftest/menu.test.js +4 -0
  83. package/dist/selftest/storage-helper.test.d.ts +2 -0
  84. package/dist/selftest/storage-helper.test.js +493 -0
  85. package/dist/selftest/support/vhdx.d.ts +14 -0
  86. package/dist/selftest/support/vhdx.js +72 -0
  87. package/dist/selftest/web.test.js +14 -2
  88. package/dist/shared/channel/run-dir.d.ts +6 -0
  89. package/dist/shared/channel/run-dir.js +9 -0
  90. package/dist/shared/pty/index.d.ts +3 -1
  91. package/dist/shared/pty/index.js +3 -1
  92. package/dist/shared/pty/key-gate.d.ts +24 -0
  93. package/dist/shared/pty/key-gate.js +45 -0
  94. package/dist/shared/pty/mouse-modes.d.ts +27 -0
  95. package/dist/shared/pty/mouse-modes.js +155 -0
  96. package/dist/shared/pty/terminal.d.ts +0 -5
  97. package/dist/shared/pty/terminal.js +51 -20
  98. package/dist/sync/watcher.js +1 -1
  99. package/dist/web/index.html +1 -1
  100. package/dist/web/{main-Z2WYAE2Z.js → main-6XNBTJLT.js} +34 -34
  101. package/native/csharp/HcsHost/AssemblyInfo.cs +3 -3
  102. package/native/csharp/HostTools/AssemblyInfo.cs +3 -3
  103. package/native/csharp/StorageHelper/AssemblyInfo.cs +15 -0
  104. package/native/csharp/StorageHelper/Client.cs +297 -0
  105. package/native/csharp/StorageHelper/Log.cs +177 -0
  106. package/native/csharp/StorageHelper/MountRecords.cs +160 -0
  107. package/native/csharp/StorageHelper/Operations.cs +596 -0
  108. package/native/csharp/StorageHelper/Pins.cs +270 -0
  109. package/native/csharp/StorageHelper/Privileges.cs +78 -0
  110. package/native/csharp/StorageHelper/Program.cs +349 -0
  111. package/native/csharp/StorageHelper/Protocol.cs +107 -0
  112. package/native/csharp/StorageHelper/RateLimiter.cs +97 -0
  113. package/native/csharp/StorageHelper/Rules.cs +156 -0
  114. package/native/csharp/StorageHelper/Server.cs +656 -0
  115. package/native/csharp/StorageHelper/Settings.cs +297 -0
  116. package/native/csharp/StorageHelper/StorageService.cs +53 -0
  117. package/native/csharp/StorageHelper/UserSettings.cs +206 -0
  118. package/native/csharp/StorageHelper/Vhdx.cs +230 -0
  119. package/native/csharp/StorageHelper/VirtualDisk.cs +241 -0
  120. package/native/csharp/StorageHelper/Volumes.cs +379 -0
  121. package/native/csharp/StorageHelper/Watchdog.cs +128 -0
  122. package/native/csharp/StorageHelper/Win32.cs +250 -0
  123. package/native/scripts/set-storage-helper.ps1 +203 -0
  124. package/package.json +1 -1
  125. package/sandbox/csharp/PtyHost.cs +236 -1
@@ -14,6 +14,12 @@ export class ExitRequested extends Error {
14
14
  /** DEC 2026: the terminal shows a frame only when it is complete. Others ignore it, so only these are asked. */
15
15
  const SYNC_BEGIN = '\x1b[?2026h';
16
16
  const SYNC_END = '\x1b[?2026l';
17
+ /**
18
+ * How long a lone ESC waits for the rest of an escape sequence before it counts as the Esc key. Node's default is
19
+ * 500 ms, which every Esc (back, close) waited for. Terminals send a key's whole sequence in one write, so a short wait
20
+ * is enough.
21
+ */
22
+ export const ESCAPE_TIMEOUT_MS = 25;
17
23
  /** Whether this terminal is known to take synchronized updates (AGENT_SANDBOX_SYNC_UPDATE=1 or 0 overrides). */
18
24
  export function supportsSyncUpdate(env = process.env) {
19
25
  const forced = env.AGENT_SANDBOX_SYNC_UPDATE;
@@ -59,7 +65,8 @@ export class Terminal {
59
65
  return;
60
66
  this.active = true;
61
67
  this.frame = [];
62
- readline.emitKeypressEvents(this.stdin);
68
+ // The first call on a stream sets its decoder; later calls (and readline interfaces) keep it.
69
+ readline.emitKeypressEvents(this.stdin, { escapeCodeTimeout: ESCAPE_TIMEOUT_MS });
63
70
  if (this.stdin.isTTY)
64
71
  this.stdin.setRawMode(true);
65
72
  this.stdin.on('keypress', this.onKeypress);
@@ -4,7 +4,7 @@ import fs from 'node:fs';
4
4
  import path from 'node:path';
5
5
  import { DATABASES } from '../core/detect/index.js';
6
6
  import { fail } from '../core/util/index.js';
7
- import { AccountStore } from '../host/profile/index.js';
7
+ import { AccountsService } from './services/accounts-service.js';
8
8
  /** Edit distance, for "did you mean". */
9
9
  export function distance(a, b) {
10
10
  let row = Array.from({ length: b.length + 1 }, (_, i) => i);
@@ -79,11 +79,15 @@ export function validateOptions(ctx) {
79
79
  unknownValue('database', db, DATABASES, 'Repeat --database for several.');
80
80
  }
81
81
  if (o.account !== undefined) {
82
- const names = new AccountStore(ctx.storageRoot).list(ctx.dataPath).map((a) => a.name);
82
+ // The launched agent's saved logins (an agent that keeps none says so).
83
+ const agent = o.harness ?? ctx.loaded.config.defaultHarness;
84
+ const names = new AccountsService(ctx).list(agent).map((a) => a.name);
83
85
  if (!names.includes(o.account))
84
86
  unknownValue('account', o.account, names, names.length
85
- ? 'See them with: sandbox account list'
86
- : 'None saved yet: sign in inside the sandbox, then run sandbox account save <name>.');
87
+ ? 'See them with: sandbox account list' + (agent === 'claude' ? '' : ' --agent ' + agent)
88
+ : 'None saved yet: sign in inside the sandbox, then run sandbox account save <name>' +
89
+ (agent === 'claude' ? '' : ' --agent ' + agent) +
90
+ '.');
87
91
  }
88
92
  for (const s of o.sessions) {
89
93
  checkHarness(s.harness, harnesses);
@@ -1,5 +1,5 @@
1
1
  // Where this package is installed and its version. This file is dist/core/package-info.js, two folders below
2
- // package.json.
2
+ // package.json, or bundled into a chunk in dist/bin (scripts/bundle.mjs), also two folders below.
3
3
  import path from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { z } from 'zod';
@@ -22,6 +22,13 @@ export declare class SessionManager {
22
22
  /** The sessions' ptyhosts, running or ended within ROOTS_KEPT_MS (audit events arrive after they happen). */
23
23
  roots(): SessionRoot[];
24
24
  private rootStarted;
25
+ /** What a session's ptyhost left running when it was itself ended (Task Manager, a crash) is ended now. */
26
+ private endLeftovers;
27
+ /**
28
+ * At the daemon's start: trees recorded by session hosts that are gone (a daemon that ended with them) are ended
29
+ * too. One whose session host still runs is left alone (ptyhost --end-tree checks).
30
+ */
31
+ sweepLeftovers(): void;
25
32
  private rootEnded;
26
33
  /**
27
34
  * `session` requests: an agent (or a plain shell) in its own ptyhost, in the launch folder's project or a registered
@@ -16,7 +16,7 @@ import { commandLine, shellLaunch } from '../sessions/cmdline.js';
16
16
  import { pwshDir } from '../toolchains/tools/pwsh.js';
17
17
  import { freePort } from '../runtime/ports.js';
18
18
  import { primaryProject } from '../boot/project-status.js';
19
- import { ensurePtyHost, ptyHostLogTail, startPtyHost, waitForPtyHost } from '../sessions/pty-host.js';
19
+ import { endLeftoverTree, ensurePtyHost, ptyHostLogTail, recordedTrees, startPtyHost, waitForPtyHost, } from '../sessions/pty-host.js';
20
20
  import { agents } from '../agents/agent-installer.js';
21
21
  import { saveClaudeMem } from '../setup/claude-mem.js';
22
22
  import { saveLocalState } from '../agents/local-state.js';
@@ -224,7 +224,28 @@ export class SessionManager {
224
224
  rootStarted(id, child, from) {
225
225
  if (child.pid)
226
226
  this.ptyHosts.set(id, { session: id, pid: child.pid, from });
227
- child.once('exit', () => this.rootEnded(id));
227
+ child.once('exit', () => {
228
+ this.rootEnded(id);
229
+ this.endLeftovers(id);
230
+ });
231
+ }
232
+ /** What a session's ptyhost left running when it was itself ended (Task Manager, a crash) is ended now. */
233
+ endLeftovers(id) {
234
+ void endLeftoverTree(id)
235
+ .then((done) => {
236
+ if (done)
237
+ log('Session ' + id + ': its session host was ended from outside; ' + done + ' it left running');
238
+ })
239
+ .catch((e) => warn('Ending what session ' + id + ' left running failed: ' + errMsg(e)));
240
+ }
241
+ /**
242
+ * At the daemon's start: trees recorded by session hosts that are gone (a daemon that ended with them) are ended
243
+ * too. One whose session host still runs is left alone (ptyhost --end-tree checks).
244
+ */
245
+ sweepLeftovers() {
246
+ for (const id of recordedTrees())
247
+ if (!this.live.has(id))
248
+ this.endLeftovers(id);
228
249
  }
229
250
  rootEnded(id) {
230
251
  const r = this.ptyHosts.get(id);
@@ -17,6 +17,7 @@ adoptBootSecrets();
17
17
  log('Session daemon started (pid ' + process.pid + ').');
18
18
  safely('port forwards', startRelays);
19
19
  safely('audit', () => startProcessCollector(() => sessions.roots()));
20
+ safely('leftover processes', () => sessions.sweepLeftovers());
20
21
  safely('memory governor', () => governor.start());
21
22
  log('Memory governor: ' + governor.describe());
22
23
  new Daemon().start();
@@ -20,3 +20,13 @@ export interface PtyHostOptions {
20
20
  }
21
21
  /** Starts ptyhost detached, so closing a terminal tab never kills the agent; only the agent exiting ends it. */
22
22
  export declare function startPtyHost(o: PtyHostOptions): ChildProcess;
23
+ /**
24
+ * Ends what a session's ptyhost left running when it was itself ended (Task Manager, a crash): its program and what
25
+ * that started, from the tree it recorded in run/trees (PTYHOST_TREE_FILE). ptyhost deletes the record when it ends the
26
+ * tree itself, so a record left behind is the sign. `ptyhost --end-tree` checks every process by id and start time (a
27
+ * reused id is never ended), spares the shared ones (databases, sccache ...) and does nothing while the ptyhost that
28
+ * wrote it still runs. Resolves with what it did, or null when there was nothing to do.
29
+ */
30
+ export declare function endLeftoverTree(id: string, exe?: string): Promise<string | null>;
31
+ /** Session ids with a process tree recorded (running sessions, and any whose ptyhost was ended). */
32
+ export declare function recordedTrees(): string[];
@@ -1,10 +1,10 @@
1
1
  // Starts one ptyhost (the ConPTY bridge, sandbox/csharp/PtyHost.cs) per agent session and waits for it to listen.
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
- import { spawn } from 'node:child_process';
4
+ import { execFile, spawn } from 'node:child_process';
5
5
  import { ensureCompiled } from '../runtime/csc.js';
6
6
  import { portOpen } from '../runtime/ports.js';
7
- import { logsDir, toolsDir } from '../runtime/paths.js';
7
+ import { logsDir, run, toolsDir } from '../runtime/paths.js';
8
8
  import { session } from '../runtime/session.js';
9
9
  export function ensurePtyHost() {
10
10
  return ensureCompiled('ptyhost');
@@ -51,10 +51,55 @@ const conptyDll = () => {
51
51
  };
52
52
  /** Starts ptyhost detached, so closing a terminal tab never kills the agent; only the agent exiting ends it. */
53
53
  export function startPtyHost(o) {
54
- const env = { ...o.env, PTYHOST_BIND: o.remote ? 'any' : 'loopback' };
54
+ fs.mkdirSync(run.treesDir, { recursive: true });
55
+ const env = {
56
+ ...o.env,
57
+ PTYHOST_BIND: o.remote ? 'any' : 'loopback',
58
+ PTYHOST_TREE_FILE: run.treeFile(o.id),
59
+ };
55
60
  delete env.PTYHOST_CONPTY;
56
61
  const dll = conptyDll();
57
62
  if (dll)
58
63
  env.PTYHOST_CONPTY = dll;
59
64
  return spawn(o.exe, [String(o.port), o.token, String(o.cols), String(o.rows), ptyHostLog(o.id), o.command, o.restartCommand], { cwd: o.cwd, env, detached: true, stdio: 'ignore', windowsHide: true });
60
65
  }
66
+ /** How long `ptyhost --end-tree` may take (a process snapshot and a few TerminateProcess calls). */
67
+ const END_TREE_MS = 30000;
68
+ /**
69
+ * Ends what a session's ptyhost left running when it was itself ended (Task Manager, a crash): its program and what
70
+ * that started, from the tree it recorded in run/trees (PTYHOST_TREE_FILE). ptyhost deletes the record when it ends the
71
+ * tree itself, so a record left behind is the sign. `ptyhost --end-tree` checks every process by id and start time (a
72
+ * reused id is never ended), spares the shared ones (databases, sccache ...) and does nothing while the ptyhost that
73
+ * wrote it still runs. Resolves with what it did, or null when there was nothing to do.
74
+ */
75
+ export function endLeftoverTree(id, exe = ensurePtyHost()) {
76
+ const file = run.treeFile(id);
77
+ if (!fs.existsSync(file))
78
+ return Promise.resolve(null);
79
+ return new Promise((resolve) => {
80
+ execFile(exe, ['--end-tree', file], { timeout: END_TREE_MS, windowsHide: true }, (error, stdout) => {
81
+ const code = error ? (typeof error.code === 'number' ? error.code : -1) : 0;
82
+ // 3: the ptyhost that wrote it still runs (its session goes on): the record stays.
83
+ if (code !== 3)
84
+ try {
85
+ fs.rmSync(file, { force: true });
86
+ }
87
+ catch {
88
+ /* removed by the next sweep (or the host's reset at the next launch) */
89
+ }
90
+ resolve(code === 0 ? stdout.trim().slice(0, 200) : code === 3 ? null : 'could not end them (code ' + code + ')');
91
+ });
92
+ });
93
+ }
94
+ /** Session ids with a process tree recorded (running sessions, and any whose ptyhost was ended). */
95
+ export function recordedTrees() {
96
+ try {
97
+ return fs
98
+ .readdirSync(run.treesDir)
99
+ .filter((n) => /^[A-Za-z0-9_-]{1,64}\.json$/.test(n))
100
+ .map((n) => n.slice(0, -'.json'.length));
101
+ }
102
+ catch {
103
+ return [];
104
+ }
105
+ }
@@ -231,7 +231,7 @@ export class SandboxChannel {
231
231
  removeLink(p.root);
232
232
  return;
233
233
  }
234
- for (const target of [p.sandboxFile, p.requestsDir, p.responsesDir, p.sessionsDir, p.projectsDir]) {
234
+ for (const target of [p.sandboxFile, p.requestsDir, p.responsesDir, p.sessionsDir, p.projectsDir, p.treesDir]) {
235
235
  safeRemove(target, this.dataPath);
236
236
  }
237
237
  }
@@ -6,6 +6,7 @@ export declare const InstallRecordSchema: z.ZodObject<{
6
6
  }>;
7
7
  groupSid: z.ZodNullable<z.ZodString>;
8
8
  autoUpdate: z.ZodBoolean;
9
+ storageHelper: z.ZodOptional<z.ZodBoolean>;
9
10
  installedAt: z.ZodString;
10
11
  }, z.core.$strip>;
11
12
  export type InstallRecord = z.infer<typeof InstallRecordSchema>;
@@ -7,6 +7,8 @@ export const InstallRecordSchema = z.object({
7
7
  scope: z.enum(['user', 'machine']),
8
8
  groupSid: z.string().nullable(),
9
9
  autoUpdate: z.boolean(),
10
+ /** All-users installs: the storage helper service (AgentSandboxStorage) was installed. Older records have none. */
11
+ storageHelper: z.boolean().optional(),
10
12
  installedAt: z.string(),
11
13
  });
12
14
  export class InstallRecordFile {
@@ -1,2 +1,3 @@
1
1
  import { type ContainedDeps } from '../storage/index.js';
2
- export declare function realContainedDeps(storageRoot: string, maxGb: number): ContainedDeps;
2
+ /** `configOverridden`: the command runs with --config (the helper reads only your usual settings: it is not asked). */
3
+ export declare function realContainedDeps(storageRoot: string, maxGb: number, configOverridden?: boolean): ContainedDeps;
@@ -1,17 +1,19 @@
1
- // The real wiring of contained storage on this PC: the permission-prompt scripts, protectPrivateFolder for new
1
+ // The real wiring of contained storage on this PC: the storage helper when an all-users install added it, else the
2
+ // permission-prompt scripts, protectPrivateFolder for new
2
3
  // folders, your SID, and whether a sandbox runs. Lives here (not in host/storage) so storage never imports the
3
4
  // sandbox or sharing folders, which import storage. The self-test never reaches a disk through it: the disk port
4
5
  // refuses there.
5
- import { ScriptDiskPort } from '../storage/index.js';
6
+ import { HelperDiskPort, helperApplies, ScriptDiskPort } from '../storage/index.js';
6
7
  import { currentUserSid, protectPrivateFolder } from '../sharing/index.js';
7
8
  import { isSandboxRunning } from './platform.js';
8
- export function realContainedDeps(storageRoot, maxGb) {
9
+ /** `configOverridden`: the command runs with --config (the helper reads only your usual settings: it is not asked). */
10
+ export function realContainedDeps(storageRoot, maxGb, configOverridden = false) {
9
11
  return {
10
12
  storageRoot,
11
13
  maxGb,
12
14
  protect: (dir) => protectPrivateFolder(dir),
13
15
  userSid: currentUserSid,
14
- port: new ScriptDiskPort(),
16
+ port: new HelperDiskPort(new ScriptDiskPort(), helperApplies(process.env, configOverridden)),
15
17
  sandboxRunning: isSandboxRunning,
16
18
  };
17
19
  }
@@ -7,6 +7,8 @@ export declare function insideSandbox(): boolean;
7
7
  export declare const GUEST_DATA_DIR = "C:\\AgentData";
8
8
  /** Whether a Windows Sandbox program is in this process list (tasklist /NH /FO CSV output). */
9
9
  export declare function sandboxInTasklist(tasklist: string): boolean;
10
+ /** The process ids of the Windows Sandbox programs in this process list (tasklist /NH /FO CSV output). */
11
+ export declare function sandboxPids(tasklist: string): number[];
10
12
  export declare function isSandboxRunning(): boolean;
11
13
  export declare function sandboxFeatureInstalled(): boolean;
12
14
  /** `owner`: whose sandbox runs, when the caller knows (host/sharing); it makes the message say who started it. */
@@ -10,15 +10,19 @@ import { launchBlocked } from '../sharing/rules.js';
10
10
  const HELPER_TIMEOUT_MS = 15000;
11
11
  const START_WAIT_MS = 20000;
12
12
  const SANDBOX_PROCESSES = ['WindowsSandboxClient.exe', 'WindowsSandboxRemoteSession.exe', 'WindowsSandbox.exe'];
13
+ /** Whether the launch and data folders are mapped in (looked for once; see insideSandbox). */
14
+ let mappedIn;
13
15
  /**
14
16
  * Running INSIDE a Windows Sandbox this tool started (its launch folder and data folder are mapped in): then the CLI
15
17
  * talks to the sandbox it is in, through C:\\AgentData and loopback, instead of looking for one on a PC.
16
18
  */
17
19
  export function insideSandbox() {
18
- return (isWin &&
19
- !process.env.AGENT_SANDBOX_HOST_ONLY &&
20
- fs.existsSync('C:\\AgentLaunch\\session.json') &&
21
- fs.existsSync('C:\\AgentData\\run'));
20
+ if (!isWin || process.env.AGENT_SANDBOX_HOST_ONLY)
21
+ return false;
22
+ // The launch folder and the data folder are mapped in for the sandbox's whole life (and never exist on a PC), so
23
+ // they are looked for once: the menu asks this many times a second.
24
+ mappedIn ??= fs.existsSync('C:\\AgentLaunch\\session.json') && fs.existsSync('C:\\AgentData\\run');
25
+ return mappedIn;
22
26
  }
23
27
  export const GUEST_DATA_DIR = 'C:\\AgentData';
24
28
  /** Whether a Windows Sandbox program is in this process list (tasklist /NH /FO CSV output). */
@@ -26,6 +30,17 @@ export function sandboxInTasklist(tasklist) {
26
30
  const out = tasklist.toLowerCase();
27
31
  return SANDBOX_PROCESSES.some((image) => out.includes('"' + image.toLowerCase() + '"'));
28
32
  }
33
+ /** The process ids of the Windows Sandbox programs in this process list (tasklist /NH /FO CSV output). */
34
+ export function sandboxPids(tasklist) {
35
+ const images = new Set(SANDBOX_PROCESSES.map((image) => image.toLowerCase()));
36
+ const pids = [];
37
+ for (const line of tasklist.split('\n')) {
38
+ const row = /^"([^"]{1,260})","(\d{1,10})"/.exec(line);
39
+ if (row && images.has(row[1].toLowerCase()))
40
+ pids.push(Number(row[2]));
41
+ }
42
+ return pids;
43
+ }
29
44
  export function isSandboxRunning() {
30
45
  if (!isWin)
31
46
  return false;
@@ -43,7 +43,15 @@ export function beginSharing(deps, start, now = new Date()) {
43
43
  // The owner creates the run folder and every channel folder in it before others may write there: a folder another
44
44
  // user created would stay theirs, and writable by them, after sharing is turned off.
45
45
  const run = new RunDir(start.dataPath);
46
- for (const dir of [run.root, run.requestsDir, run.responsesDir, run.sessionsDir, run.projectsDir, run.auditDir])
46
+ for (const dir of [
47
+ run.root,
48
+ run.requestsDir,
49
+ run.responsesDir,
50
+ run.sessionsDir,
51
+ run.projectsDir,
52
+ run.auditDir,
53
+ run.treesDir,
54
+ ])
47
55
  fs.mkdirSync(dir, { recursive: true });
48
56
  const plan = sharingAclPlan({
49
57
  storageUserDir: storageOf(start.dataPath),
@@ -76,6 +76,19 @@ export class ContainedStorage {
76
76
  });
77
77
  this.requireOk(outcome, 'mount');
78
78
  }
79
+ else {
80
+ // Another holder vouches for the mount, but the folder could be a mount point of some other disk (one left after
81
+ // a restart, whose volume name an image of someone else's now has). A port that can check it without a prompt
82
+ // (the storage helper) is asked: it only says "mounted already" for a mount of this very image.
83
+ const confirmed = await this.deps.port.confirmMounted?.({
84
+ paths,
85
+ maxGb: this.deps.maxGb,
86
+ create: false,
87
+ userSid: this.deps.userSid(),
88
+ });
89
+ if (confirmed)
90
+ this.requireOk(confirmed, 'mount');
91
+ }
79
92
  this.assertReallyMounted();
80
93
  for (const name of CONTAINED_FOLDERS)
81
94
  fs.mkdirSync(path.join(paths.mount, name), { recursive: true });
@@ -22,6 +22,11 @@ export interface DismountRequest {
22
22
  }
23
23
  export interface DiskPort {
24
24
  mount(request: MountRequest): Promise<ScriptOutcome>;
25
+ /**
26
+ * A mount that should be there already, checked without a permission prompt: the outcome of a mount (which, for a
27
+ * mount of this image, changes nothing), or null when this port cannot check without a prompt.
28
+ */
29
+ confirmMounted?(request: MountRequest): Promise<ScriptOutcome | null>;
25
30
  dismount(request: DismountRequest): Promise<ScriptOutcome>;
26
31
  reality(paths: ContainedPaths): Promise<MountReality>;
27
32
  }
@@ -0,0 +1,72 @@
1
+ import { z } from 'zod';
2
+ import type { ContainedPaths } from './layout.js';
3
+ import type { DiskPort, DismountRequest, MountRequest, ScriptOutcome } from './disk-port.js';
4
+ import type { MountReality } from './state.js';
5
+ export declare const HELPER_PIPE = "\\\\.\\pipe\\AgentSandboxStorage";
6
+ export declare const HELPER_PROTOCOL = 1;
7
+ /** What goes to the service: exactly the verb and the data set (the service refuses anything else). */
8
+ export declare const HelperRequestSchema: z.ZodObject<{
9
+ verb: z.ZodEnum<{
10
+ mount: "mount";
11
+ dismount: "dismount";
12
+ }>;
13
+ set: z.ZodString;
14
+ }, z.core.$strict>;
15
+ export type HelperRequest = z.infer<typeof HelperRequestSchema>;
16
+ /** The service's answer (Protocol.Answer in Protocol.cs). */
17
+ export declare const HelperAnswerSchema: z.ZodObject<{
18
+ protocol: z.ZodNumber;
19
+ ok: z.ZodBoolean;
20
+ message: z.ZodString;
21
+ created: z.ZodBoolean;
22
+ alreadyMounted: z.ZodBoolean;
23
+ fallback: z.ZodBoolean;
24
+ image: z.ZodString;
25
+ mount: z.ZodString;
26
+ notes: z.ZodArray<z.ZodString>;
27
+ }, z.core.$strip>;
28
+ export type HelperAnswer = z.infer<typeof HelperAnswerSchema>;
29
+ /** One call: the text of the answer, or why there was none. `absent`: no helper here (or not for you). */
30
+ export type PipeResult = {
31
+ readonly kind: 'answer';
32
+ readonly text: string;
33
+ } | {
34
+ readonly kind: 'absent';
35
+ readonly reason: string;
36
+ } | {
37
+ readonly kind: 'failed';
38
+ readonly reason: string;
39
+ };
40
+ export type PipeConnector = (pipe: string, line: string, timeoutMs: number) => Promise<PipeResult>;
41
+ export declare const pipeConnector: PipeConnector;
42
+ /**
43
+ * Whether the helper may be asked at all. It reads your settings from the usual place (your profile's
44
+ * .agent-sandbox\config.json and the machine file), so a run with other settings (--config, AGENT_SANDBOX_CONFIG,
45
+ * AGENT_SANDBOX_HOME) would get other folders: those use the permission prompt.
46
+ */
47
+ export declare function helperApplies(env: NodeJS.ProcessEnv, configOverridden: boolean): boolean;
48
+ /** The answer's paths are ours (Windows paths: case does not matter, a trailing backslash neither). */
49
+ export declare function samePaths(answer: Pick<HelperAnswer, 'image' | 'mount'>, paths: ContainedPaths): boolean;
50
+ /** What one answer means: an outcome, or "use the permission prompt instead". */
51
+ export declare function readAnswer(result: PipeResult, paths: ContainedPaths): {
52
+ readonly use: 'outcome';
53
+ readonly outcome: ScriptOutcome;
54
+ } | {
55
+ readonly use: 'fallback';
56
+ readonly why: string;
57
+ };
58
+ /** The helper when it is there and applies, else (and whenever it says so) the permission-prompt scripts. */
59
+ export declare class HelperDiskPort implements DiskPort {
60
+ private readonly fallback;
61
+ private readonly applies;
62
+ private readonly connect;
63
+ private readonly pipe;
64
+ constructor(fallback: DiskPort, applies: boolean, connect?: PipeConnector, pipe?: string);
65
+ mount(request: MountRequest): Promise<ScriptOutcome>;
66
+ /** The helper checks a mount without a prompt (it is "mounted already" only for this image); null without it. */
67
+ confirmMounted(request: MountRequest): Promise<ScriptOutcome | null>;
68
+ dismount(request: DismountRequest): Promise<ScriptOutcome>;
69
+ reality(paths: ContainedPaths): Promise<MountReality>;
70
+ /** The helper's outcome, or null to use the permission prompt. */
71
+ private ask;
72
+ }
@@ -0,0 +1,162 @@
1
+ // The storage helper (native\csharp\StorageHelper, the Windows service AgentSandboxStorage that an all-users install
2
+ // can add): mounting and dismounting go to its pipe instead of a Windows permission prompt. The request names only the
3
+ // data set; the service works out the folders itself from your settings, so the answer's paths must equal ours. When
4
+ // the helper is not installed, cannot be reached, or says to (an image it leaves alone), the permission-prompt scripts
5
+ // run as before. Looking (reality) never needs either.
6
+ import net from 'node:net';
7
+ import { z } from 'zod';
8
+ import { SET_NAME_RULE } from './layout.js';
9
+ import { refuseInSelfTest } from '../../test-guard.js';
10
+ export const HELPER_PIPE = '\\\\.\\pipe\\AgentSandboxStorage';
11
+ export const HELPER_PROTOCOL = 1;
12
+ const MOUNT_TIMEOUT_MS = 10 * 60_000;
13
+ const MAX_ANSWER_BYTES = 64 * 1024;
14
+ /** What goes to the service: exactly the verb and the data set (the service refuses anything else). */
15
+ export const HelperRequestSchema = z
16
+ .object({ verb: z.enum(['mount', 'dismount']), set: z.string().regex(SET_NAME_RULE) })
17
+ .strict();
18
+ /** The service's answer (Protocol.Answer in Protocol.cs). */
19
+ export const HelperAnswerSchema = z.object({
20
+ protocol: z.number().int(),
21
+ ok: z.boolean(),
22
+ message: z.string().max(4000),
23
+ created: z.boolean(),
24
+ alreadyMounted: z.boolean(),
25
+ fallback: z.boolean(),
26
+ image: z.string().max(1000),
27
+ mount: z.string().max(1000),
28
+ notes: z.array(z.string().max(1000)).max(50),
29
+ });
30
+ /** Errors that mean "no helper to talk to": not installed (or stopped), or this user may not open it. */
31
+ const ABSENT_CODES = new Set(['ENOENT', 'EACCES', 'EPERM', 'ECONNREFUSED']);
32
+ export const pipeConnector = (pipe, line, timeoutMs) => new Promise((resolve) => {
33
+ let text = '';
34
+ let connected = false;
35
+ let settled = false;
36
+ const done = (result) => {
37
+ if (settled)
38
+ return;
39
+ settled = true;
40
+ clearTimeout(timer);
41
+ socket.destroy();
42
+ resolve(result);
43
+ };
44
+ const socket = net.connect(pipe);
45
+ const timer = setTimeout(() => done({ kind: 'failed', reason: 'the storage helper did not answer in time' }), timeoutMs);
46
+ socket.setEncoding('utf8');
47
+ socket.on('connect', () => {
48
+ connected = true;
49
+ socket.write(line + '\n');
50
+ });
51
+ socket.on('data', (chunk) => {
52
+ text += chunk;
53
+ const end = text.indexOf('\n');
54
+ if (end >= 0)
55
+ done({ kind: 'answer', text: text.slice(0, end) });
56
+ else if (text.length > MAX_ANSWER_BYTES)
57
+ done({ kind: 'failed', reason: 'the storage helper answered too much' });
58
+ });
59
+ socket.on('error', (e) => {
60
+ if (!connected && ABSENT_CODES.has(e.code ?? ''))
61
+ done({ kind: 'absent', reason: e.code ?? 'no pipe' });
62
+ else
63
+ done({ kind: 'failed', reason: e.message });
64
+ });
65
+ socket.on('close', () => done({ kind: 'failed', reason: 'the storage helper closed the connection' }));
66
+ });
67
+ /**
68
+ * Whether the helper may be asked at all. It reads your settings from the usual place (your profile's
69
+ * .agent-sandbox\config.json and the machine file), so a run with other settings (--config, AGENT_SANDBOX_CONFIG,
70
+ * AGENT_SANDBOX_HOME) would get other folders: those use the permission prompt.
71
+ */
72
+ export function helperApplies(env, configOverridden) {
73
+ if (configOverridden)
74
+ return false;
75
+ return !env.AGENT_SANDBOX_CONFIG && !env.AGENT_SANDBOX_HOME;
76
+ }
77
+ /** The answer's paths are ours (Windows paths: case does not matter, a trailing backslash neither). */
78
+ export function samePaths(answer, paths) {
79
+ const norm = (p) => p.replace(/\\+$/, '').toLowerCase();
80
+ return norm(answer.image) === norm(paths.image) && norm(answer.mount) === norm(paths.mount);
81
+ }
82
+ /** What one answer means: an outcome, or "use the permission prompt instead". */
83
+ export function readAnswer(result, paths) {
84
+ if (result.kind === 'absent')
85
+ return { use: 'fallback', why: 'no storage helper (' + result.reason + ')' };
86
+ if (result.kind === 'failed')
87
+ return { use: 'outcome', outcome: failed('The storage helper did not finish: ' + result.reason + '.') };
88
+ let raw;
89
+ try {
90
+ raw = JSON.parse(result.text);
91
+ }
92
+ catch {
93
+ return { use: 'fallback', why: 'the storage helper gave an answer that is not JSON' };
94
+ }
95
+ const parsed = HelperAnswerSchema.safeParse(raw);
96
+ if (!parsed.success || parsed.data.protocol !== HELPER_PROTOCOL)
97
+ return { use: 'fallback', why: 'the storage helper speaks another version' };
98
+ const answer = parsed.data;
99
+ if (answer.fallback)
100
+ return { use: 'fallback', why: answer.message };
101
+ if (answer.ok && !samePaths(answer, paths))
102
+ return {
103
+ use: 'outcome',
104
+ outcome: failed('The storage helper used other folders (' +
105
+ answer.mount +
106
+ ') than this command (' +
107
+ paths.mount +
108
+ '): your settings differ from what it reads. Run sandbox doctor.'),
109
+ };
110
+ return {
111
+ use: 'outcome',
112
+ outcome: {
113
+ ok: answer.ok,
114
+ message: answer.message,
115
+ created: answer.created,
116
+ alreadyMounted: answer.alreadyMounted,
117
+ notes: answer.notes,
118
+ },
119
+ };
120
+ }
121
+ function failed(message) {
122
+ return { ok: false, message, created: false, alreadyMounted: false, notes: [] };
123
+ }
124
+ /** The helper when it is there and applies, else (and whenever it says so) the permission-prompt scripts. */
125
+ export class HelperDiskPort {
126
+ fallback;
127
+ applies;
128
+ connect;
129
+ pipe;
130
+ constructor(fallback, applies, connect = pipeConnector, pipe = HELPER_PIPE) {
131
+ this.fallback = fallback;
132
+ this.applies = applies;
133
+ this.connect = connect;
134
+ this.pipe = pipe;
135
+ }
136
+ async mount(request) {
137
+ const answer = await this.ask({ verb: 'mount', set: request.paths.set }, request.paths);
138
+ return answer ?? this.fallback.mount(request);
139
+ }
140
+ /** The helper checks a mount without a prompt (it is "mounted already" only for this image); null without it. */
141
+ confirmMounted(request) {
142
+ return this.ask({ verb: 'mount', set: request.paths.set }, request.paths);
143
+ }
144
+ async dismount(request) {
145
+ const answer = await this.ask({ verb: 'dismount', set: request.paths.set }, request.paths);
146
+ return answer ?? this.fallback.dismount(request);
147
+ }
148
+ reality(paths) {
149
+ return this.fallback.reality(paths);
150
+ }
151
+ /** The helper's outcome, or null to use the permission prompt. */
152
+ async ask(request, paths) {
153
+ if (!this.applies)
154
+ return null;
155
+ // A test talks to a helper of its own (another pipe name); the real one could mount a real disk.
156
+ if (this.pipe === HELPER_PIPE)
157
+ refuseInSelfTest('mount or dismount a disk image');
158
+ const line = JSON.stringify(HelperRequestSchema.parse(request));
159
+ const read = readAnswer(await this.connect(this.pipe, line, MOUNT_TIMEOUT_MS), paths);
160
+ return read.use === 'outcome' ? read.outcome : null;
161
+ }
162
+ }
@@ -2,6 +2,7 @@ export { CONTAINED_FOLDERS, containedMappings, containedPaths, setNameOf, type C
2
2
  export type { ContainedPaths } from './layout.js';
3
3
  export { ContainedStorage, type ContainedDeps, type HolderId, type ReleaseResult } from './contained-storage.js';
4
4
  export { ScriptDiskPort, spawnRunner, type DiskPort, type MountRequest, type ScriptOutcome } from './disk-port.js';
5
+ export { HELPER_PIPE, HelperAnswerSchema, HelperDiskPort, helperApplies, HelperRequestSchema, pipeConnector, readAnswer, samePaths, type PipeConnector, type PipeResult, } from './helper-port.js';
5
6
  export { containedAvailability, containedFacts, type ContainedFacts } from './check.js';
6
7
  export { hostDataFolder, hostDataRoot } from './resolve.js';
7
8
  export { writeContainedSetting } from './setting.js';
@@ -1,6 +1,7 @@
1
1
  export { CONTAINED_FOLDERS, containedMappings, containedPaths, setNameOf } from './layout.js';
2
2
  export { ContainedStorage } from './contained-storage.js';
3
3
  export { ScriptDiskPort, spawnRunner } from './disk-port.js';
4
+ export { HELPER_PIPE, HelperAnswerSchema, HelperDiskPort, helperApplies, HelperRequestSchema, pipeConnector, readAnswer, samePaths, } from './helper-port.js';
4
5
  export { containedAvailability, containedFacts } from './check.js';
5
6
  export { hostDataFolder, hostDataRoot } from './resolve.js';
6
7
  export { writeContainedSetting } from './setting.js';
@@ -83,7 +83,8 @@ export declare class StateFile {
83
83
  export declare function mountedSets(storageRoot: string): string[];
84
84
  /**
85
85
  * A line for the start of a command when the record says an image is mounted that no running process holds and no
86
- * sandbox uses (nothing is looked up on the disk: this is only the record), or null.
86
+ * sandbox uses (nothing is looked up on the disk: this is only the record), or null. `sandboxRunning` can be a
87
+ * function: it is only asked (it runs tasklist) when the record says an image is mounted.
87
88
  */
88
- export declare function staleMountNote(storageRoot: string, set: string, sandboxRunning: boolean, alive?: (pid: number) => boolean): string | null;
89
+ export declare function staleMountNote(storageRoot: string, set: string, sandboxRunning: boolean | (() => boolean), alive?: (pid: number) => boolean): string | null;
89
90
  export {};