windows-development-sandbox 1.13.2 → 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 (91) hide show
  1. package/dist/app/features/storage/storage.run.js +1 -1
  2. package/dist/bin/agent-sandbox.js +2 -2
  3. package/dist/bin/{chunk-VENWSGX2.js → chunk-BQ23GBLC.js} +411 -58
  4. package/dist/bin/{chunk-K4SU3MHJ.js → chunk-F7IMME5G.js} +1 -1
  5. package/dist/bin/{chunk-IL7BXJF2.js → chunk-LQHEUN2A.js} +1 -1
  6. package/dist/bin/claude-host.js +2 -2
  7. package/dist/bin/claude-sbx.js +2 -2
  8. package/dist/bin/codex-sbx.js +2 -2
  9. package/dist/bin/gemini-sbx.js +2 -2
  10. package/dist/bin/sandbox.js +2 -2
  11. package/dist/bin/sbx-hide.js +2 -2
  12. package/dist/bin/sbx-show.js +2 -2
  13. package/dist/cli/commands/launch/launch.js +1 -1
  14. package/dist/cli/commands/repair/checks/contained.js +1 -1
  15. package/dist/guest/daemon/sessions.d.ts +7 -0
  16. package/dist/guest/daemon/sessions.js +23 -2
  17. package/dist/guest/entries/daemon.js +1 -0
  18. package/dist/guest/sessions/pty-host.d.ts +10 -0
  19. package/dist/guest/sessions/pty-host.js +48 -3
  20. package/dist/host/channel/channel.js +1 -1
  21. package/dist/host/install/install-record.d.ts +1 -0
  22. package/dist/host/install/install-record.js +2 -0
  23. package/dist/host/sandbox/contained-deps.d.ts +2 -1
  24. package/dist/host/sandbox/contained-deps.js +6 -4
  25. package/dist/host/sharing/lifecycle.js +9 -1
  26. package/dist/host/storage/contained/contained-storage.js +13 -0
  27. package/dist/host/storage/contained/disk-port.d.ts +5 -0
  28. package/dist/host/storage/contained/helper-port.d.ts +72 -0
  29. package/dist/host/storage/contained/helper-port.js +162 -0
  30. package/dist/host/storage/contained/index.d.ts +1 -0
  31. package/dist/host/storage/contained/index.js +1 -0
  32. package/dist/host/web/bridge.d.ts +5 -0
  33. package/dist/host/web/bridge.js +11 -1
  34. package/dist/host/web/server.d.ts +2 -0
  35. package/dist/host/web/server.js +11 -1
  36. package/dist/runtime/agent.js +9 -0
  37. package/dist/runtime/daemon.js +57 -3
  38. package/dist/runtime/db.js +9 -0
  39. package/dist/runtime/main.js +195 -12
  40. package/dist/runtime/netproxy.js +9 -0
  41. package/dist/runtime/view.js +195 -12
  42. package/dist/selftest/channel.test.js +72 -1
  43. package/dist/selftest/contained.test.js +1 -37
  44. package/dist/selftest/hcs.test.js +5 -2
  45. package/dist/selftest/index.js +2 -0
  46. package/dist/selftest/install.test.js +11 -0
  47. package/dist/selftest/live-daemon.test.js +51 -1
  48. package/dist/selftest/live-terminal.test.js +112 -4
  49. package/dist/selftest/storage-helper.test.d.ts +2 -0
  50. package/dist/selftest/storage-helper.test.js +493 -0
  51. package/dist/selftest/support/vhdx.d.ts +14 -0
  52. package/dist/selftest/support/vhdx.js +72 -0
  53. package/dist/selftest/web.test.js +14 -2
  54. package/dist/shared/channel/run-dir.d.ts +6 -0
  55. package/dist/shared/channel/run-dir.js +9 -0
  56. package/dist/shared/pty/index.d.ts +3 -1
  57. package/dist/shared/pty/index.js +3 -1
  58. package/dist/shared/pty/key-gate.d.ts +24 -0
  59. package/dist/shared/pty/key-gate.js +45 -0
  60. package/dist/shared/pty/mouse-modes.d.ts +27 -0
  61. package/dist/shared/pty/mouse-modes.js +155 -0
  62. package/dist/shared/pty/terminal.d.ts +0 -5
  63. package/dist/shared/pty/terminal.js +51 -20
  64. package/dist/sync/watcher.js +1 -1
  65. package/dist/web/index.html +1 -1
  66. package/dist/web/{main-NBQ3LF4E.js → main-6XNBTJLT.js} +33 -33
  67. package/native/csharp/HcsHost/AssemblyInfo.cs +3 -3
  68. package/native/csharp/HostTools/AssemblyInfo.cs +3 -3
  69. package/native/csharp/StorageHelper/AssemblyInfo.cs +15 -0
  70. package/native/csharp/StorageHelper/Client.cs +297 -0
  71. package/native/csharp/StorageHelper/Log.cs +177 -0
  72. package/native/csharp/StorageHelper/MountRecords.cs +160 -0
  73. package/native/csharp/StorageHelper/Operations.cs +596 -0
  74. package/native/csharp/StorageHelper/Pins.cs +270 -0
  75. package/native/csharp/StorageHelper/Privileges.cs +78 -0
  76. package/native/csharp/StorageHelper/Program.cs +349 -0
  77. package/native/csharp/StorageHelper/Protocol.cs +107 -0
  78. package/native/csharp/StorageHelper/RateLimiter.cs +97 -0
  79. package/native/csharp/StorageHelper/Rules.cs +156 -0
  80. package/native/csharp/StorageHelper/Server.cs +656 -0
  81. package/native/csharp/StorageHelper/Settings.cs +297 -0
  82. package/native/csharp/StorageHelper/StorageService.cs +53 -0
  83. package/native/csharp/StorageHelper/UserSettings.cs +206 -0
  84. package/native/csharp/StorageHelper/Vhdx.cs +230 -0
  85. package/native/csharp/StorageHelper/VirtualDisk.cs +241 -0
  86. package/native/csharp/StorageHelper/Volumes.cs +379 -0
  87. package/native/csharp/StorageHelper/Watchdog.cs +128 -0
  88. package/native/csharp/StorageHelper/Win32.cs +250 -0
  89. package/native/scripts/set-storage-helper.ps1 +203 -0
  90. package/package.json +1 -1
  91. package/sandbox/csharp/PtyHost.cs +236 -1
@@ -226,7 +226,7 @@ import {
226
226
  withOperationCommands,
227
227
  working,
228
228
  writeJson
229
- } from "./chunk-VENWSGX2.js";
229
+ } from "./chunk-BQ23GBLC.js";
230
230
 
231
231
  // node_modules/commander/lib/error.js
232
232
  var require_error = __commonJS({
@@ -1,7 +1,7 @@
1
1
  import { createRequire as __createRequire } from 'node:module'; const require = __createRequire(import.meta.url);
2
2
  import {
3
3
  attach
4
- } from "./chunk-VENWSGX2.js";
4
+ } from "./chunk-BQ23GBLC.js";
5
5
  export {
6
6
  attach
7
7
  };
@@ -2,8 +2,8 @@
2
2
  import { createRequire as __createRequire } from 'node:module'; const require = __createRequire(import.meta.url);
3
3
  import {
4
4
  run
5
- } from "./chunk-K4SU3MHJ.js";
6
- import "./chunk-VENWSGX2.js";
5
+ } from "./chunk-F7IMME5G.js";
6
+ import "./chunk-BQ23GBLC.js";
7
7
 
8
8
  // src/bin/claude-host.ts
9
9
  var args = process.argv.slice(2);
@@ -2,8 +2,8 @@
2
2
  import { createRequire as __createRequire } from 'node:module'; const require = __createRequire(import.meta.url);
3
3
  import {
4
4
  run
5
- } from "./chunk-K4SU3MHJ.js";
6
- import "./chunk-VENWSGX2.js";
5
+ } from "./chunk-F7IMME5G.js";
6
+ import "./chunk-BQ23GBLC.js";
7
7
 
8
8
  // src/bin/claude-sbx.ts
9
9
  run(process.argv.slice(2), { harness: "claude" });
@@ -2,8 +2,8 @@
2
2
  import { createRequire as __createRequire } from 'node:module'; const require = __createRequire(import.meta.url);
3
3
  import {
4
4
  run
5
- } from "./chunk-K4SU3MHJ.js";
6
- import "./chunk-VENWSGX2.js";
5
+ } from "./chunk-F7IMME5G.js";
6
+ import "./chunk-BQ23GBLC.js";
7
7
 
8
8
  // src/bin/codex-sbx.ts
9
9
  run(process.argv.slice(2), { harness: "codex" });
@@ -2,8 +2,8 @@
2
2
  import { createRequire as __createRequire } from 'node:module'; const require = __createRequire(import.meta.url);
3
3
  import {
4
4
  run
5
- } from "./chunk-K4SU3MHJ.js";
6
- import "./chunk-VENWSGX2.js";
5
+ } from "./chunk-F7IMME5G.js";
6
+ import "./chunk-BQ23GBLC.js";
7
7
 
8
8
  // src/bin/gemini-sbx.ts
9
9
  run(process.argv.slice(2), { harness: "gemini" });
@@ -2,8 +2,8 @@
2
2
  import { createRequire as __createRequire } from 'node:module'; const require = __createRequire(import.meta.url);
3
3
  import {
4
4
  run
5
- } from "./chunk-K4SU3MHJ.js";
6
- import "./chunk-VENWSGX2.js";
5
+ } from "./chunk-F7IMME5G.js";
6
+ import "./chunk-BQ23GBLC.js";
7
7
 
8
8
  // src/bin/sandbox.ts
9
9
  run(process.argv.slice(2));
@@ -2,8 +2,8 @@
2
2
  import { createRequire as __createRequire } from 'node:module'; const require = __createRequire(import.meta.url);
3
3
  import {
4
4
  run
5
- } from "./chunk-K4SU3MHJ.js";
6
- import "./chunk-VENWSGX2.js";
5
+ } from "./chunk-F7IMME5G.js";
6
+ import "./chunk-BQ23GBLC.js";
7
7
 
8
8
  // src/bin/sbx-hide.ts
9
9
  run(process.argv.slice(2), { command: "hide" });
@@ -2,8 +2,8 @@
2
2
  import { createRequire as __createRequire } from 'node:module'; const require = __createRequire(import.meta.url);
3
3
  import {
4
4
  run
5
- } from "./chunk-K4SU3MHJ.js";
6
- import "./chunk-VENWSGX2.js";
5
+ } from "./chunk-F7IMME5G.js";
6
+ import "./chunk-BQ23GBLC.js";
7
7
 
8
8
  // src/bin/sbx-show.ts
9
9
  run(process.argv.slice(2), { command: "show" });
@@ -130,7 +130,7 @@ class Launcher {
130
130
  const engine = await openEngine(config);
131
131
  const provider = providerFor(config, config.storage.contained === 'off'
132
132
  ? {}
133
- : { contained: realContainedDeps(ctx.storageRoot, config.storage.containedMaxGb) });
133
+ : { contained: realContainedDeps(ctx.storageRoot, config.storage.containedMaxGb, Boolean(ctx.options.config)) });
134
134
  if (!generateOnly)
135
135
  assertCanLaunch(isSandboxRunning() ? runningOwner() : undefined);
136
136
  // Contained storage: the image is mounted now (before anything reads or writes the data set), and let go of again
@@ -10,7 +10,7 @@ import { containedAvailability, ContainedStorage, containedPaths } from '../../.
10
10
  import { ok } from './common.js';
11
11
  function storageOf(env) {
12
12
  const { config } = env.ctx.loaded;
13
- return new ContainedStorage(realContainedDeps(env.storageRoot, config.storage.containedMaxGb), config.storage.dataName);
13
+ return new ContainedStorage(realContainedDeps(env.storageRoot, config.storage.containedMaxGb, Boolean(env.ctx.options.config)), config.storage.dataName);
14
14
  }
15
15
  /** Only when contained storage is on, or an image or a record of one exists. */
16
16
  const inUse = (env) => {
@@ -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
  }
@@ -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';