windows-development-sandbox 1.13.2 → 1.15.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 (153) hide show
  1. package/config.example.json +2 -1
  2. package/dist/app/features/defs.d.ts +1 -1
  3. package/dist/app/features/projects/projects.def.d.ts +1 -1
  4. package/dist/app/features/projects/projects.run.d.ts +1 -1
  5. package/dist/app/features/projects/projects.run.js +10 -2
  6. package/dist/app/features/sandbox/sandbox.def.js +3 -1
  7. package/dist/app/features/settings/categories.def.js +1 -0
  8. package/dist/app/features/storage/storage.run.js +1 -1
  9. package/dist/bin/agent-sandbox.js +2 -2
  10. package/dist/bin/{chunk-VENWSGX2.js → chunk-D7CM63BS.js} +2303 -1462
  11. package/dist/bin/{chunk-K4SU3MHJ.js → chunk-HTQT5SBY.js} +1 -1
  12. package/dist/bin/{chunk-IL7BXJF2.js → chunk-RQ4BWBYX.js} +1 -1
  13. package/dist/bin/claude-host.js +2 -2
  14. package/dist/bin/claude-sbx.js +2 -2
  15. package/dist/bin/codex-sbx.js +2 -2
  16. package/dist/bin/gemini-sbx.js +2 -2
  17. package/dist/bin/sandbox.js +2 -2
  18. package/dist/bin/sbx-hide.js +2 -2
  19. package/dist/bin/sbx-show.js +2 -2
  20. package/dist/cli/commands/launch/launch.js +1 -1
  21. package/dist/cli/commands/projects/here.js +2 -0
  22. package/dist/cli/commands/repair/checks/contained.js +1 -1
  23. package/dist/cli/services/health-service.js +15 -3
  24. package/dist/cli/services/projects-service.d.ts +8 -3
  25. package/dist/cli/services/projects-service.js +21 -4
  26. package/dist/core/config/defaults.js +1 -0
  27. package/dist/core/schema/config.d.ts +4 -0
  28. package/dist/core/schema/projects.d.ts +4 -0
  29. package/dist/core/schema/projects.js +3 -0
  30. package/dist/guest/boot/boot.js +8 -2
  31. package/dist/guest/daemon/daemon.js +3 -0
  32. package/dist/guest/daemon/live-folders.d.ts +48 -0
  33. package/dist/guest/daemon/live-folders.js +245 -0
  34. package/dist/guest/daemon/live.d.ts +4 -0
  35. package/dist/guest/daemon/live.js +57 -0
  36. package/dist/guest/daemon/sessions.d.ts +7 -0
  37. package/dist/guest/daemon/sessions.js +23 -2
  38. package/dist/guest/entries/daemon.js +5 -2
  39. package/dist/guest/runtime/csc.d.ts +1 -0
  40. package/dist/guest/runtime/csc.js +1 -0
  41. package/dist/guest/runtime/secrets.d.ts +5 -2
  42. package/dist/guest/runtime/secrets.js +12 -5
  43. package/dist/guest/sessions/pty-host.d.ts +10 -0
  44. package/dist/guest/sessions/pty-host.js +48 -3
  45. package/dist/host/channel/channel.js +1 -1
  46. package/dist/host/engines/hcs/live-folders.d.ts +117 -0
  47. package/dist/host/engines/hcs/live-folders.js +347 -0
  48. package/dist/host/engines/index.d.ts +1 -0
  49. package/dist/host/engines/index.js +1 -0
  50. package/dist/host/install/install-record.d.ts +1 -0
  51. package/dist/host/install/install-record.js +2 -0
  52. package/dist/host/launch/generate.js +8 -1
  53. package/dist/host/projects/join.d.ts +4 -0
  54. package/dist/host/projects/join.js +11 -3
  55. package/dist/host/projects/mapper.d.ts +34 -5
  56. package/dist/host/projects/mapper.js +88 -20
  57. package/dist/host/projects/registry.d.ts +2 -0
  58. package/dist/host/projects/registry.js +1 -1
  59. package/dist/host/sandbox/contained-deps.d.ts +2 -1
  60. package/dist/host/sandbox/contained-deps.js +6 -4
  61. package/dist/host/secrets/launch-secrets.js +2 -1
  62. package/dist/host/sharing/lifecycle.js +9 -1
  63. package/dist/host/storage/contained/contained-storage.js +13 -0
  64. package/dist/host/storage/contained/disk-port.d.ts +5 -0
  65. package/dist/host/storage/contained/helper-port.d.ts +72 -0
  66. package/dist/host/storage/contained/helper-port.js +162 -0
  67. package/dist/host/storage/contained/index.d.ts +1 -0
  68. package/dist/host/storage/contained/index.js +1 -0
  69. package/dist/host/web/bridge.d.ts +5 -0
  70. package/dist/host/web/bridge.js +11 -1
  71. package/dist/host/web/server.d.ts +2 -0
  72. package/dist/host/web/server.js +11 -1
  73. package/dist/runtime/agent.js +23 -0
  74. package/dist/runtime/daemon.js +523 -135
  75. package/dist/runtime/db.js +23 -0
  76. package/dist/runtime/main.js +221 -15
  77. package/dist/runtime/netproxy.js +23 -0
  78. package/dist/runtime/view.js +209 -12
  79. package/dist/selftest/channel.test.js +72 -1
  80. package/dist/selftest/contained.test.js +1 -37
  81. package/dist/selftest/hcs.test.js +5 -2
  82. package/dist/selftest/index.js +4 -0
  83. package/dist/selftest/install.test.js +11 -0
  84. package/dist/selftest/layout.test.js +1 -0
  85. package/dist/selftest/live-daemon.test.js +51 -1
  86. package/dist/selftest/live-folders.test.d.ts +2 -0
  87. package/dist/selftest/live-folders.test.js +588 -0
  88. package/dist/selftest/live-terminal.test.js +112 -4
  89. package/dist/selftest/projects.test.js +7 -7
  90. package/dist/selftest/storage-helper.test.d.ts +2 -0
  91. package/dist/selftest/storage-helper.test.js +493 -0
  92. package/dist/selftest/support/engine-golden.js +1 -0
  93. package/dist/selftest/support/fixtures.d.ts +1 -0
  94. package/dist/selftest/support/live.js +2 -0
  95. package/dist/selftest/support/vhdx.d.ts +14 -0
  96. package/dist/selftest/support/vhdx.js +72 -0
  97. package/dist/selftest/web.test.js +14 -2
  98. package/dist/shared/channel/index.d.ts +1 -0
  99. package/dist/shared/channel/index.js +1 -0
  100. package/dist/shared/channel/live.d.ts +53 -0
  101. package/dist/shared/channel/live.js +92 -0
  102. package/dist/shared/channel/run-dir.d.ts +11 -0
  103. package/dist/shared/channel/run-dir.js +20 -0
  104. package/dist/shared/channel/secrets.d.ts +1 -0
  105. package/dist/shared/channel/secrets.js +5 -0
  106. package/dist/shared/paths/projects.d.ts +6 -1
  107. package/dist/shared/paths/projects.js +21 -1
  108. package/dist/shared/pty/index.d.ts +3 -1
  109. package/dist/shared/pty/index.js +3 -1
  110. package/dist/shared/pty/key-gate.d.ts +24 -0
  111. package/dist/shared/pty/key-gate.js +45 -0
  112. package/dist/shared/pty/mouse-modes.d.ts +27 -0
  113. package/dist/shared/pty/mouse-modes.js +155 -0
  114. package/dist/shared/pty/terminal.d.ts +0 -5
  115. package/dist/shared/pty/terminal.js +51 -20
  116. package/dist/sync/watcher.js +10 -1
  117. package/dist/web/index.html +1 -1
  118. package/dist/web/{main-NBQ3LF4E.js → main-7DJZ2M27.js} +34 -34
  119. package/native/csharp/HcsHost/AssemblyInfo.cs +4 -4
  120. package/native/csharp/HcsHost/FolderAccess.cs +361 -0
  121. package/native/csharp/HcsHost/Hcs.cs +31 -3
  122. package/native/csharp/HcsHost/Program.cs +54 -6
  123. package/native/csharp/HcsHost/Share.cs +64 -11
  124. package/native/csharp/HcsHost/WsbGuard.cs +130 -0
  125. package/native/csharp/HcsHost/WsbShare.cs +295 -0
  126. package/native/csharp/HostTools/AssemblyInfo.cs +3 -3
  127. package/native/csharp/StorageHelper/AssemblyInfo.cs +15 -0
  128. package/native/csharp/StorageHelper/Client.cs +297 -0
  129. package/native/csharp/StorageHelper/Log.cs +177 -0
  130. package/native/csharp/StorageHelper/MountRecords.cs +160 -0
  131. package/native/csharp/StorageHelper/Operations.cs +596 -0
  132. package/native/csharp/StorageHelper/Pins.cs +270 -0
  133. package/native/csharp/StorageHelper/Privileges.cs +78 -0
  134. package/native/csharp/StorageHelper/Program.cs +349 -0
  135. package/native/csharp/StorageHelper/Protocol.cs +107 -0
  136. package/native/csharp/StorageHelper/RateLimiter.cs +97 -0
  137. package/native/csharp/StorageHelper/Rules.cs +156 -0
  138. package/native/csharp/StorageHelper/Server.cs +656 -0
  139. package/native/csharp/StorageHelper/Settings.cs +297 -0
  140. package/native/csharp/StorageHelper/StorageService.cs +53 -0
  141. package/native/csharp/StorageHelper/UserSettings.cs +206 -0
  142. package/native/csharp/StorageHelper/Vhdx.cs +230 -0
  143. package/native/csharp/StorageHelper/VirtualDisk.cs +241 -0
  144. package/native/csharp/StorageHelper/Volumes.cs +379 -0
  145. package/native/csharp/StorageHelper/Watchdog.cs +128 -0
  146. package/native/csharp/StorageHelper/Win32.cs +250 -0
  147. package/native/scripts/contained-common.ps1 +4 -0
  148. package/native/scripts/hcs-live-share.ps1 +191 -0
  149. package/native/scripts/set-storage-helper.ps1 +203 -0
  150. package/package.json +1 -1
  151. package/sandbox/csharp/BindMount.cs +120 -0
  152. package/sandbox/csharp/PtyHost.cs +236 -1
  153. package/schemas/config.schema.json +8 -0
@@ -10,6 +10,7 @@ declare const RegistryEntrySchema: z.ZodObject<{
10
10
  guest: z.ZodString;
11
11
  primary: z.ZodBoolean;
12
12
  mechanism: z.ZodCatch<z.ZodEnum<{
13
+ hcs: "hcs";
13
14
  root: "root";
14
15
  launch: "launch";
15
16
  boot: "boot";
@@ -30,6 +31,7 @@ declare const RegistrySchema: z.ZodObject<{
30
31
  guest: z.ZodString;
31
32
  primary: z.ZodBoolean;
32
33
  mechanism: z.ZodCatch<z.ZodEnum<{
34
+ hcs: "hcs";
33
35
  root: "root";
34
36
  launch: "launch";
35
37
  boot: "boot";
@@ -22,7 +22,7 @@ const RegistryEntrySchema = z.object({
22
22
  host: AbsolutePathSchema,
23
23
  guest: z.string().max(1024),
24
24
  primary: z.boolean(),
25
- mechanism: z.enum(['launch', 'root', 'boot', 'wsb']).catch('root'),
25
+ mechanism: z.enum(['launch', 'root', 'boot', 'wsb', 'hcs']).catch('root'),
26
26
  registeredAt: z.string().max(64),
27
27
  });
28
28
  const RegistrySchema = z.object({
@@ -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
  }
@@ -13,7 +13,8 @@ export function writeSecrets(dataPath, id, secrets) {
13
13
  !secrets.claudeCredentials &&
14
14
  !secrets.vpn &&
15
15
  !secrets.wireguardConfig &&
16
- !secrets.githubToken;
16
+ !secrets.githubToken &&
17
+ !secrets.liveFoldersKey;
17
18
  if (empty)
18
19
  return null;
19
20
  const run = new RunDir(dataPath);
@@ -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';
@@ -23,6 +23,11 @@ export declare class TerminalBridge {
23
23
  start(): Promise<void>;
24
24
  private fromBrowser;
25
25
  private fromSession;
26
+ /**
27
+ * The program's mouse tracking ends with it: the browser's terminal stops sending clicks and moves, and a drag
28
+ * selects text again (an ended terminal stays on screen).
29
+ */
30
+ private mouseOff;
26
31
  /** Stops reading from the session until the browser has taken what it was sent. */
27
32
  private hold;
28
33
  private tell;
@@ -1,5 +1,5 @@
1
1
  import { safeText } from '../../core/util/index.js';
2
- import { connect, encodeFrame, FrameReader, FrameType, helloFrame, OutputFilter, PING_INTERVAL_MS, resizeFrame, } from '../../shared/pty/index.js';
2
+ import { connect, encodeFrame, FrameReader, FrameType, helloFrame, MOUSE_OFF, OutputFilter, PING_INTERVAL_MS, resizeFrame, } from '../../shared/pty/index.js';
3
3
  import { parseClientMessage } from '../../shared/web/index.js';
4
4
  /** The browser is slower than the sandbox: past this much waiting to be sent, output is held back in the socket. */
5
5
  const MAX_BUFFERED_BYTES = 4 * 1024 * 1024;
@@ -94,9 +94,11 @@ export class TerminalBridge {
94
94
  break;
95
95
  }
96
96
  case FrameType.Restarting:
97
+ this.mouseOff();
97
98
  this.tell({ t: 'restarting' });
98
99
  break;
99
100
  case FrameType.Exit:
101
+ this.mouseOff();
100
102
  this.tell({ t: 'exit', code: frame.payload.length >= 4 ? frame.payload.readInt32LE(0) : 1 });
101
103
  this.finish(1000, 'exit');
102
104
  return;
@@ -111,6 +113,14 @@ export class TerminalBridge {
111
113
  if (this.ws.bufferedAmount > MAX_BUFFERED_BYTES)
112
114
  this.hold();
113
115
  }
116
+ /**
117
+ * The program's mouse tracking ends with it: the browser's terminal stops sending clicks and moves, and a drag
118
+ * selects text again (an ended terminal stays on screen).
119
+ */
120
+ mouseOff() {
121
+ if (this.ws.readyState === this.ws.OPEN)
122
+ this.ws.send(Buffer.from(MOUSE_OFF, 'latin1'), { binary: true });
123
+ }
114
124
  /** Stops reading from the session until the browser has taken what it was sent. */
115
125
  hold() {
116
126
  if (this.drain || !this.socket)
@@ -55,6 +55,7 @@ export declare class WebServer {
55
55
  private readonly live;
56
56
  private actualPort;
57
57
  private lastUsed;
58
+ private lastCheck;
58
59
  private heartbeat;
59
60
  private idleCheck;
60
61
  constructor(options: WebServerOptions);
@@ -69,6 +70,7 @@ export declare class WebServer {
69
70
  private beat;
70
71
  /** No browser has used the server for a while: it ends (sooner when no sandbox runs). */
71
72
  private checkIdle;
73
+ private checkEveryMs;
72
74
  /** How long unused before it ends when no sandbox runs. */
73
75
  private quietMs;
74
76
  /** Counts a new socket; at the limit, the oldest of its kind makes room (a new tab always works). */
@@ -55,6 +55,7 @@ export class WebServer {
55
55
  live;
56
56
  actualPort = 0;
57
57
  lastUsed = Date.now();
58
+ lastCheck = Date.now();
58
59
  heartbeat;
59
60
  idleCheck;
60
61
  constructor(options) {
@@ -82,7 +83,8 @@ export class WebServer {
82
83
  this.heartbeat = setInterval(() => this.beat(), this.options.heartbeatMs ?? HEARTBEAT_MS);
83
84
  this.heartbeat.unref();
84
85
  if (this.options.idleExitMs) {
85
- this.idleCheck = setInterval(() => void this.checkIdle(), Math.min(60_000, this.quietMs()));
86
+ this.lastCheck = Date.now();
87
+ this.idleCheck = setInterval(() => void this.checkIdle(), this.checkEveryMs());
86
88
  this.idleCheck.unref();
87
89
  }
88
90
  const gate = this.options.gate;
@@ -127,6 +129,11 @@ export class WebServer {
127
129
  /** No browser has used the server for a while: it ends (sooner when no sandbox runs). */
128
130
  async checkIdle() {
129
131
  const limit = this.options.idleExitMs ?? 0;
132
+ const now = Date.now();
133
+ // A check much later than planned: the PC slept. Its tabs get time to reconnect before anything counts as idle.
134
+ if (now - this.lastCheck > 3 * this.checkEveryMs())
135
+ this.lastUsed = now;
136
+ this.lastCheck = now;
130
137
  if (!limit || this.tracked.size > 0)
131
138
  return;
132
139
  const idle = Date.now() - this.lastUsed;
@@ -143,6 +150,9 @@ export class WebServer {
143
150
  }
144
151
  this.options.onStop?.();
145
152
  }
153
+ checkEveryMs() {
154
+ return Math.min(60_000, this.quietMs());
155
+ }
146
156
  /** How long unused before it ends when no sandbox runs. */
147
157
  quietMs() {
148
158
  return Math.min(this.options.idleExitMs ?? 0, this.options.idleNoSandboxMs ?? IDLE_NO_SANDBOX_MS);
@@ -26645,6 +26645,9 @@ var ProjectsSchema = external_exports.strictObject({
26645
26645
  ),
26646
26646
  wsbShare: external_exports.boolean().describe(
26647
26647
  "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."
26648
+ ),
26649
+ liveFolders: external_exports.enum(["off", "hcs"]).describe(
26650
+ 'ALPHA, UNTESTED ON A REAL PC: "hcs" adds a project folder to the running sandbox through the Host Compute Service where `wsb share` is missing (one administrator prompt per add or remove; always read-only in this alpha). If it fails, the sandbox may close: save your work first. Turn it on before the sandbox starts. "off" (default) offers a restart instead. The real-PC tests still to pass are in docs/design/hcs-live-folders.md ("Test plan").'
26648
26651
  )
26649
26652
  });
26650
26653
 
@@ -27173,8 +27176,16 @@ var RunDir = class {
27173
27176
  projectsDir;
27174
27177
  /** audit.processes: programs the sandbox started, in small .jsonl files the host takes in and deletes. */
27175
27178
  auditDir;
27179
+ /**
27180
+ * Each running session's process tree, recorded by its ptyhost (guest only: the daemon ends what a ptyhost that was
27181
+ * itself ended left running).
27182
+ */
27183
+ treesDir;
27176
27184
  /** One-shot secrets the host hands over (see secrets.ts). Next to run, not in it: shared-use users can change run. */
27177
27185
  secretsDir;
27186
+ /** Live folders (live.ts): signed host requests, and the daemon's replies. */
27187
+ liveDir;
27188
+ liveRepliesDir;
27178
27189
  constructor(dataDir2) {
27179
27190
  this.root = path5.join(dataDir2, "run");
27180
27191
  this.sandboxFile = path5.join(this.root, "sandbox.json");
@@ -27186,7 +27197,16 @@ var RunDir = class {
27186
27197
  this.sessionsDir = path5.join(this.root, "sessions");
27187
27198
  this.projectsDir = path5.join(this.root, "projects");
27188
27199
  this.auditDir = path5.join(this.root, "audit");
27200
+ this.treesDir = path5.join(this.root, "trees");
27189
27201
  this.secretsDir = path5.join(dataDir2, "secrets");
27202
+ this.liveDir = path5.join(this.root, "live");
27203
+ this.liveRepliesDir = path5.join(this.root, "live-replies");
27204
+ }
27205
+ liveFile(id) {
27206
+ return path5.join(this.liveDir, safeId(id) + ".json");
27207
+ }
27208
+ liveReplyFile(id) {
27209
+ return path5.join(this.liveRepliesDir, safeId(id) + ".json");
27190
27210
  }
27191
27211
  requestFile(id) {
27192
27212
  return path5.join(this.requestsDir, safeId(id) + ".json");
@@ -27197,6 +27217,9 @@ var RunDir = class {
27197
27217
  sessionFile(id) {
27198
27218
  return path5.join(this.sessionsDir, safeId(id) + ".json");
27199
27219
  }
27220
+ treeFile(id) {
27221
+ return path5.join(this.treesDir, safeId(id) + ".json");
27222
+ }
27200
27223
  projectFile(name) {
27201
27224
  return path5.join(this.projectsDir, safeId(name) + ".json");
27202
27225
  }