windows-development-sandbox 1.14.0 → 1.16.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 (100) hide show
  1. package/config.example.json +2 -1
  2. package/dist/app/features/accounts/accounts.def.js +4 -3
  3. package/dist/app/features/defs.d.ts +4 -2
  4. package/dist/app/features/defs.js +26 -12
  5. package/dist/app/features/projects/projects.def.d.ts +1 -1
  6. package/dist/app/features/projects/projects.run.d.ts +1 -1
  7. package/dist/app/features/projects/projects.run.js +10 -2
  8. package/dist/app/features/sandbox/sandbox.def.js +12 -6
  9. package/dist/app/features/sessions/sessions.def.js +1 -1
  10. package/dist/app/features/settings/categories.def.js +1 -0
  11. package/dist/bin/agent-sandbox.js +2 -2
  12. package/dist/bin/{chunk-F7IMME5G.js → chunk-7EXDUNE7.js} +98 -79
  13. package/dist/bin/{chunk-BQ23GBLC.js → chunk-HIC4CUMY.js} +1925 -1430
  14. package/dist/bin/{chunk-LQHEUN2A.js → chunk-PXTLKIV4.js} +1 -1
  15. package/dist/bin/claude-host.js +2 -2
  16. package/dist/bin/claude-sbx.js +2 -2
  17. package/dist/bin/codex-sbx.js +2 -2
  18. package/dist/bin/gemini-sbx.js +2 -2
  19. package/dist/bin/sandbox.js +2 -2
  20. package/dist/bin/sbx-hide.js +2 -2
  21. package/dist/bin/sbx-show.js +2 -2
  22. package/dist/cli/commands/projects/here.js +2 -0
  23. package/dist/cli/index.js +4 -1
  24. package/dist/cli/report.d.ts +7 -0
  25. package/dist/cli/report.js +31 -0
  26. package/dist/cli/services/health-service.js +15 -3
  27. package/dist/cli/services/projects-service.d.ts +8 -3
  28. package/dist/cli/services/projects-service.js +21 -4
  29. package/dist/core/config/defaults.js +1 -0
  30. package/dist/core/schema/config.d.ts +4 -0
  31. package/dist/core/schema/projects.d.ts +4 -0
  32. package/dist/core/schema/projects.js +3 -0
  33. package/dist/guest/boot/boot.js +8 -2
  34. package/dist/guest/daemon/daemon.js +3 -0
  35. package/dist/guest/daemon/live-folders.d.ts +48 -0
  36. package/dist/guest/daemon/live-folders.js +245 -0
  37. package/dist/guest/daemon/live.d.ts +4 -0
  38. package/dist/guest/daemon/live.js +57 -0
  39. package/dist/guest/entries/daemon.js +4 -2
  40. package/dist/guest/runtime/csc.d.ts +1 -0
  41. package/dist/guest/runtime/csc.js +1 -0
  42. package/dist/guest/runtime/secrets.d.ts +5 -2
  43. package/dist/guest/runtime/secrets.js +12 -5
  44. package/dist/host/engines/hcs/live-folders.d.ts +117 -0
  45. package/dist/host/engines/hcs/live-folders.js +347 -0
  46. package/dist/host/engines/index.d.ts +1 -0
  47. package/dist/host/engines/index.js +1 -0
  48. package/dist/host/launch/generate.js +8 -1
  49. package/dist/host/projects/join.d.ts +4 -0
  50. package/dist/host/projects/join.js +11 -3
  51. package/dist/host/projects/mapper.d.ts +34 -5
  52. package/dist/host/projects/mapper.js +88 -20
  53. package/dist/host/projects/registry.d.ts +2 -0
  54. package/dist/host/projects/registry.js +1 -1
  55. package/dist/host/secrets/launch-secrets.js +2 -1
  56. package/dist/runtime/agent.js +14 -0
  57. package/dist/runtime/daemon.js +466 -132
  58. package/dist/runtime/db.js +14 -0
  59. package/dist/runtime/main.js +26 -3
  60. package/dist/runtime/netproxy.js +14 -0
  61. package/dist/runtime/view.js +14 -0
  62. package/dist/selftest/accounts.test.js +22 -0
  63. package/dist/selftest/app.test.js +2 -2
  64. package/dist/selftest/index.js +2 -0
  65. package/dist/selftest/layout.test.js +1 -0
  66. package/dist/selftest/live-folders.test.d.ts +2 -0
  67. package/dist/selftest/live-folders.test.js +588 -0
  68. package/dist/selftest/projects.test.js +7 -7
  69. package/dist/selftest/support/engine-golden.js +1 -0
  70. package/dist/selftest/support/fixtures.d.ts +1 -0
  71. package/dist/selftest/support/live.js +2 -0
  72. package/dist/shared/channel/index.d.ts +1 -0
  73. package/dist/shared/channel/index.js +1 -0
  74. package/dist/shared/channel/live.d.ts +53 -0
  75. package/dist/shared/channel/live.js +92 -0
  76. package/dist/shared/channel/run-dir.d.ts +5 -0
  77. package/dist/shared/channel/run-dir.js +11 -0
  78. package/dist/shared/channel/secrets.d.ts +1 -0
  79. package/dist/shared/channel/secrets.js +5 -0
  80. package/dist/shared/paths/projects.d.ts +6 -1
  81. package/dist/shared/paths/projects.js +21 -1
  82. package/dist/sync/watcher.js +9 -0
  83. package/dist/web/index.html +2 -2
  84. package/dist/web/main-JTH3FT4O.js +221 -0
  85. package/dist/web/{main-UAQPI5XD.css → main-XT7T6ODM.css} +1 -1
  86. package/native/csharp/HcsHost/AssemblyInfo.cs +4 -4
  87. package/native/csharp/HcsHost/FolderAccess.cs +361 -0
  88. package/native/csharp/HcsHost/Hcs.cs +31 -3
  89. package/native/csharp/HcsHost/Program.cs +54 -6
  90. package/native/csharp/HcsHost/Share.cs +64 -11
  91. package/native/csharp/HcsHost/WsbGuard.cs +130 -0
  92. package/native/csharp/HcsHost/WsbShare.cs +295 -0
  93. package/native/csharp/HostTools/AssemblyInfo.cs +3 -3
  94. package/native/csharp/StorageHelper/AssemblyInfo.cs +3 -3
  95. package/native/scripts/contained-common.ps1 +4 -0
  96. package/native/scripts/hcs-live-share.ps1 +191 -0
  97. package/package.json +1 -1
  98. package/sandbox/csharp/BindMount.cs +120 -0
  99. package/schemas/config.schema.json +8 -0
  100. package/dist/web/main-6XNBTJLT.js +0 -221
@@ -0,0 +1,57 @@
1
+ // The daemon's live-folder handler with the real bind filter (BindMount.exe, compiled from sandbox\csharp each boot and
2
+ // run by path) and its state on the sandbox's own disk, in a folder only administrators and SYSTEM may use.
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { spawnSync } from 'node:child_process';
6
+ import { SYSTEM } from '../../shared/system.js';
7
+ import { ensureCompiled } from '../runtime/csc.js';
8
+ import { isWin, localDir, run, testMode } from '../runtime/paths.js';
9
+ import { session } from '../runtime/session.js';
10
+ import { warn } from '../runtime/log.js';
11
+ import { LiveFolderHandler } from './live-folders.js';
12
+ const BIND_TIMEOUT_MS = 30000;
13
+ /** A test (it names its own local folder) never touches C:\ProgramData of the machine it runs on. */
14
+ const testing = Boolean(process.env.AGENT_DAEMON_STATE_DIR || process.env.AGENT_LOCAL_DIR);
15
+ const stateDir = process.env.AGENT_DAEMON_STATE_DIR ??
16
+ (process.env.AGENT_LOCAL_DIR
17
+ ? path.join(localDir, 'daemon-state')
18
+ : path.join(process.env.ProgramData ?? 'C:\\ProgramData', 'AgentSandboxDaemon'));
19
+ function bindMount(args) {
20
+ const r = spawnSync(ensureCompiled('bindmount'), args, {
21
+ windowsHide: true,
22
+ timeout: BIND_TIMEOUT_MS,
23
+ encoding: 'utf8',
24
+ });
25
+ return r.status ?? 1;
26
+ }
27
+ const realBind = {
28
+ map: (folder, share, readOnly) => bindMount(['map', folder, share, ...(readOnly ? [] : ['rw'])]),
29
+ unmap: (folder) => bindMount(['unmap', folder]),
30
+ mapped: (folder) => bindMount(['mapped', folder]),
31
+ };
32
+ /** The state folder, made administrator-only (inside a real sandbox; a test names its own temp folder). */
33
+ function protectStateDir() {
34
+ fs.mkdirSync(stateDir, { recursive: true });
35
+ if (!isWin || testMode || testing)
36
+ return;
37
+ spawnSync(SYSTEM.icacls(), [stateDir, '/inheritance:r', '/grant:r', '*S-1-5-32-544:(OI)(CI)F', '*S-1-5-18:(OI)(CI)F'], {
38
+ windowsHide: true,
39
+ timeout: BIND_TIMEOUT_MS,
40
+ });
41
+ }
42
+ export const liveFolders = new LiveFolderHandler({
43
+ run,
44
+ stateFile: path.join(stateDir, 'live-folders.json'),
45
+ launchId: session.launchId,
46
+ bind: realBind,
47
+ log: warn,
48
+ });
49
+ /** At daemon start: the folder protected, the boot's key taken in, the recorded mappings made again. */
50
+ export function startLiveFolders(key) {
51
+ protectStateDir();
52
+ if (key)
53
+ liveFolders.setKey(key);
54
+ const again = liveFolders.reapply();
55
+ if (again)
56
+ warn('Live folders: ' + again + ' shown again after the daemon restarted.');
57
+ }
@@ -1,5 +1,5 @@
1
1
  // Session daemon (runs detached inside the sandbox for its whole life). It is the only thing that acts on host
2
- // requests, and it only knows four request kinds: start a session, run a command, restart sessions, ping.
2
+ // requests: sessions, commands, restarts, pings (run\requests) and the signed live-folder requests (run\live).
3
3
  // It must never die quietly: every iteration is guarded, and anything unexpected is written to its log.
4
4
  // The handlers live in daemon/ (session, exec); this file is the polling loop.
5
5
  import { startRelays } from '../daemon/relays.js';
@@ -9,12 +9,14 @@ import { log, warn } from '../runtime/log.js';
9
9
  import { adoptBootSecrets } from '../runtime/secrets.js';
10
10
  import { errMsg } from '../../core/util/errors.js';
11
11
  import { Daemon } from '../daemon/daemon.js';
12
+ import { startLiveFolders } from '../daemon/live.js';
12
13
  import { sessions } from '../daemon/sessions.js';
13
14
  import { governor } from '../governor/index.js';
14
15
  process.on('uncaughtException', (e) => warn('daemon: unexpected error (continuing): ' + (e.stack ?? errMsg(e))));
15
16
  process.on('unhandledRejection', (e) => warn('daemon: unexpected error (continuing): ' + errMsg(e)));
16
- adoptBootSecrets();
17
+ const liveKey = adoptBootSecrets();
17
18
  log('Session daemon started (pid ' + process.pid + ').');
19
+ safely('live folders', () => startLiveFolders(liveKey));
18
20
  safely('port forwards', startRelays);
19
21
  safely('audit', () => startProcessCollector(() => sessions.roots()));
20
22
  safely('leftover processes', () => sessions.sweepLeftovers());
@@ -8,6 +8,7 @@ declare const SOURCES: {
8
8
  readonly linkgate: "LinkGate.cs";
9
9
  readonly pathsync: "PathSync.cs";
10
10
  readonly memgov: "MemGov.cs";
11
+ readonly bindmount: "BindMount.cs";
11
12
  };
12
13
  export type Helper = keyof typeof SOURCES;
13
14
  /** %TEMP%\agent-sandbox\<name>-<hash>.exe, compiled from this launch's source the first time it is needed. */
@@ -21,6 +21,7 @@ const SOURCES = {
21
21
  linkgate: 'LinkGate.cs',
22
22
  pathsync: 'PathSync.cs',
23
23
  memgov: 'MemGov.cs',
24
+ bindmount: 'BindMount.cs',
24
25
  };
25
26
  function sourceOf(name) {
26
27
  return path.join(launchDir, 'sandbox', 'csharp', SOURCES[name]);
@@ -6,7 +6,10 @@ export declare const BOOT_SECRETS_VARIABLE = "SBX_BOOT_SECRETS";
6
6
  export declare function takeSecrets(id: string): SecretsFile;
7
7
  /** The agent the sandbox booted with gets its environment (in memory only). */
8
8
  export declare function applyBootEnvironment(environment: Record<string, string> | undefined): void;
9
- /** The daemon takes what main.js passed it, and removes it from its own environment before any session starts. */
10
- export declare function adoptBootSecrets(): void;
9
+ /**
10
+ * The daemon takes what main.js passed it, and removes it from its own environment before any session starts. Returns
11
+ * the live-folder key, if the launch made one (the daemon keeps it in its own state).
12
+ */
13
+ export declare function adoptBootSecrets(): string | undefined;
11
14
  /** A request with the agent environment values that travelled in its own secrets file put back (in memory only). */
12
15
  export declare function withRequestSecrets(req: GuestRequest): GuestRequest;
@@ -40,20 +40,27 @@ export function applyBootEnvironment(environment) {
40
40
  if (session.harness && environment)
41
41
  session.harness.environment = { ...environment };
42
42
  }
43
- /** The daemon takes what main.js passed it, and removes it from its own environment before any session starts. */
43
+ /**
44
+ * The daemon takes what main.js passed it, and removes it from its own environment before any session starts. Returns
45
+ * the live-folder key, if the launch made one (the daemon keeps it in its own state).
46
+ */
44
47
  export function adoptBootSecrets() {
45
48
  const text = process.env[BOOT_SECRETS_VARIABLE];
46
49
  delete process.env[BOOT_SECRETS_VARIABLE];
47
50
  if (!text)
48
- return;
51
+ return undefined;
49
52
  try {
50
- const parsed = SecretsFileSchema.pick({ harnessEnvironment: true }).safeParse(JSON.parse(text));
51
- if (parsed.success)
53
+ const parsed = SecretsFileSchema.pick({ harnessEnvironment: true, liveFoldersKey: true }).safeParse(JSON.parse(text));
54
+ if (parsed.success) {
52
55
  applyBootEnvironment(parsed.data.harnessEnvironment);
56
+ return parsed.data.liveFoldersKey;
57
+ }
53
58
  }
54
59
  catch {
55
- warn('The boot secrets given to the daemon were not valid.');
60
+ // Reported below.
56
61
  }
62
+ warn('The boot secrets given to the daemon were not valid.');
63
+ return undefined;
57
64
  }
58
65
  /** A request with the agent environment values that travelled in its own secrets file put back (in memory only). */
59
66
  export function withRequestSecrets(req) {
@@ -0,0 +1,117 @@
1
+ import { z } from 'zod';
2
+ import { SecretStore } from '../../secrets/index.js';
3
+ /** The warning shown wherever live folders are offered or used. */
4
+ export declare const LIVE_FOLDERS_WARNING = "Live folders through HCS are an alpha, untested on a real PC: if it fails, the sandbox may close, so save your work first. Each add or remove asks for administrator permission.";
5
+ declare const ShareSchema: z.ZodObject<{
6
+ name: z.ZodString;
7
+ host: z.ZodString;
8
+ guest: z.ZodString;
9
+ folder: z.ZodString;
10
+ readOnly: z.ZodBoolean;
11
+ addedAt: z.ZodString;
12
+ }, z.core.$strip>;
13
+ declare const RecordSchema: z.ZodObject<{
14
+ launchId: z.ZodString;
15
+ dataPath: z.ZodString;
16
+ key: z.ZodString;
17
+ systemId: z.ZodNullable<z.ZodString>;
18
+ shares: z.ZodArray<z.ZodObject<{
19
+ name: z.ZodString;
20
+ host: z.ZodString;
21
+ guest: z.ZodString;
22
+ folder: z.ZodString;
23
+ readOnly: z.ZodBoolean;
24
+ addedAt: z.ZodString;
25
+ }, z.core.$strip>>;
26
+ }, z.core.$strip>;
27
+ export type LiveRecord = z.output<typeof RecordSchema>;
28
+ export type LiveShare = z.output<typeof ShareSchema>;
29
+ /** What the elevated script was asked (request.json; HcsHost checks every field again). */
30
+ export interface LiveShareRequest {
31
+ readonly action: 'add' | 'remove';
32
+ readonly id: string;
33
+ readonly name: string;
34
+ readonly path?: string;
35
+ readonly readOnly?: boolean;
36
+ readonly storageRoot: string;
37
+ readonly dataPath: string;
38
+ readonly userSid: string;
39
+ }
40
+ declare const OutcomeSchema: z.ZodObject<{
41
+ ok: z.ZodBoolean;
42
+ message: z.ZodString;
43
+ id: z.ZodOptional<z.ZodString>;
44
+ canary: z.ZodOptional<z.ZodString>;
45
+ }, z.core.$strip>;
46
+ export type LiveOutcome = z.output<typeof OutcomeSchema>;
47
+ /** The elevated half: one run of hcs-live-share.ps1. `onCanary` gets the canary share's name, returns its token. */
48
+ export interface ElevatedLivePort {
49
+ run(request: LiveShareRequest, onCanary: (share: string) => Promise<string | null>): Promise<LiveOutcome>;
50
+ }
51
+ export type LiveRunner = (file: string, args: readonly string[], timeoutMs: number) => Promise<{
52
+ status: number | null;
53
+ }>;
54
+ export declare const spawnLiveRunner: LiveRunner;
55
+ /** run-elevated.ps1 -Script hcs-live-share.ps1 with the request in a private temp folder; relays the canary. */
56
+ export declare class ScriptLivePort implements ElevatedLivePort {
57
+ private readonly runner;
58
+ private readonly tempRoot;
59
+ private readonly pollMs;
60
+ constructor(runner?: LiveRunner, tempRoot?: string, pollMs?: number);
61
+ run(request: LiveShareRequest, onCanary: (share: string) => Promise<string | null>): Promise<LiveOutcome>;
62
+ }
63
+ export interface LiveFolderDeps {
64
+ readonly storageRoot: string;
65
+ readonly dataPath: string;
66
+ readonly secrets: SecretStore;
67
+ readonly elevated: ElevatedLivePort;
68
+ readonly userSid: () => string;
69
+ readonly isAlive?: () => boolean;
70
+ readonly pollMs?: number;
71
+ readonly now?: () => Date;
72
+ /** How long the sandbox's daemon has for the canary and for a map or unmap (tests make them short). */
73
+ readonly canaryMs?: number;
74
+ readonly mapMs?: number;
75
+ }
76
+ /** <storage>\state\live-folders.json. */
77
+ export declare function liveRecordFile(storageRoot: string): string;
78
+ export declare class LiveFolders {
79
+ private readonly d;
80
+ private readonly file;
81
+ private readonly run;
82
+ constructor(d: LiveFolderDeps);
83
+ read(): LiveRecord | null;
84
+ /** At launch (projects.liveFolders "hcs"): a new key for this launch, kept sealed; returned for the secrets file. */
85
+ startLaunch(launchId: string): string;
86
+ /** The sandbox stopped (its shares ended with it), or a launch without live folders: the record goes. */
87
+ clear(): void;
88
+ /** Why live folders cannot be used for this launch now (null: they can). */
89
+ unavailable(launchId: string): string | null;
90
+ private log;
91
+ private key;
92
+ /** Sends one signed request to the sandbox's daemon and waits for its reply. */
93
+ private ask;
94
+ private request;
95
+ /**
96
+ * Shares `host` (its real path) into the running sandbox and shows it at C:\Workspace\`folder`. Throws with the
97
+ * reason when anything refuses or fails; the folder is then not shared.
98
+ */
99
+ add(o: {
100
+ launchId: string;
101
+ host: string;
102
+ guest: string;
103
+ folder: string;
104
+ readOnly: boolean;
105
+ }): Promise<LiveShare>;
106
+ /** The share itself (elevated). True when it is gone; the record is kept when not. */
107
+ private removeShare;
108
+ /** The live folder of `host`, if there is one. */
109
+ shareOf(host: string): LiveShare | null;
110
+ /** Removes a live folder: the guest mapping first, then the share. Throws (and keeps it) when either fails. */
111
+ remove(launchId: string, host: string): Promise<void>;
112
+ }
113
+ /** The real thing: DPAPI, the elevated script (it builds HcsHost.exe itself), this user's SID. */
114
+ export declare function realLiveFolders(storageRoot: string, dataPath: string): LiveFolders;
115
+ /** The sandbox of `dataPath` stopped: its live-folder record (and key) goes; its shares ended with the VM. */
116
+ export declare function clearLiveRecord(storageRoot: string, dataPath: string): boolean;
117
+ export {};
@@ -0,0 +1,347 @@
1
+ // Live folders through HCS (opt-in alpha, projects.liveFolders "hcs"; docs/design/hcs-live-folders.md): adds a folder
2
+ // of this PC to the RUNNING Windows Sandbox where `wsb share` is missing. Two halves:
3
+ // - the share: native\scripts\hcs-live-share.ps1, run elevated (one Windows permission prompt per add or remove),
4
+ // runs HcsHost.exe wsb-share, which finds the one running Windows Sandbox, proves with a canary share that it is
5
+ // the sandbox whose daemon answers in our run folder, and only then shares the folder (read-only unless asked);
6
+ // - the guest mapping: a request signed with this launch's key (shared/channel/live.ts) asks the sandbox's daemon to
7
+ // show the share at C:\Workspace\<name>.
8
+ // The record is host-only, <storage>\state\live-folders.json (never in the data folder): the launch, its key (sealed
9
+ // with DPAPI), the Windows Sandbox id the first canary proved, and the shares this tool added. Remove takes only names
10
+ // in that record. A stop of the sandbox clears it (shares end with the VM). Nothing read from the sandbox can ask for a
11
+ // share: the host only ever compares the canary token the daemon read.
12
+ import crypto from 'node:crypto';
13
+ import fs from 'node:fs';
14
+ import os from 'node:os';
15
+ import path from 'node:path';
16
+ import { spawn } from 'node:child_process';
17
+ import { z } from 'zod';
18
+ import { assertNoLinks, fail, safeText, sleep } from '../../../core/util/index.js';
19
+ import { LIVE_SHARE_NAME, LIVE_TOKEN, LiveReplySchema, newLiveKey, newShareName, RunDir, signLive, } from '../../../shared/channel/index.js';
20
+ import { JsonFile, readValid, writeFileAtomic, writeJsonAtomic } from '../../../shared/io/index.js';
21
+ import { SYSTEM } from '../../../shared/system.js';
22
+ import { nativeFile, nativeScript } from '../../native.js';
23
+ import { refuseInSelfTest } from '../../test-guard.js';
24
+ import { SecretStore } from '../../secrets/index.js';
25
+ import { isSandboxRunning } from '../../sandbox/index.js';
26
+ import { currentUserSid } from '../../sharing/index.js';
27
+ const GUID = /^[0-9A-Fa-f]{8}-([0-9A-Fa-f]{4}-){3}[0-9A-Fa-f]{12}$/;
28
+ /** The prompt, the canary (45 s at most in HcsHost) and three HCS operations of 60 s at most. */
29
+ const ELEVATED_TIMEOUT_MS = 6 * 60_000;
30
+ const CANARY_REPLY_MS = 30_000;
31
+ const MAP_REPLY_MS = 60_000;
32
+ const MAX_LOG_BYTES = 256 * 1024;
33
+ /** The warning shown wherever live folders are offered or used. */
34
+ export const LIVE_FOLDERS_WARNING = 'Live folders through HCS are an alpha, untested on a real PC: if it fails, the sandbox may close, so save your work first. Each add or remove asks for administrator permission.';
35
+ const ShareSchema = z.object({
36
+ name: z.string().regex(LIVE_SHARE_NAME),
37
+ host: z.string().min(3).max(1024),
38
+ guest: z.string().max(1024),
39
+ folder: z.string().max(64),
40
+ readOnly: z.boolean(),
41
+ addedAt: z.string().max(40),
42
+ });
43
+ const RecordSchema = z.object({
44
+ launchId: z.string().min(1).max(64),
45
+ dataPath: z.string().max(1024),
46
+ /** This launch's key for signed requests, sealed (SecretStore). */
47
+ key: z.string().max(4096),
48
+ /** The running Windows Sandbox's HCS id, once the first canary proved it. */
49
+ systemId: z.string().regex(GUID).nullable(),
50
+ shares: z.array(ShareSchema).max(256),
51
+ });
52
+ const OutcomeSchema = z.object({
53
+ ok: z.boolean(),
54
+ message: z.string().max(4000),
55
+ id: z.string().max(64).optional(),
56
+ canary: z.string().max(64).optional(),
57
+ });
58
+ export const spawnLiveRunner = (file, args, timeoutMs) => new Promise((resolve) => {
59
+ const child = spawn(file, [...args], { windowsHide: true, stdio: 'ignore' });
60
+ const timer = setTimeout(() => child.kill(), timeoutMs);
61
+ child.on('error', () => {
62
+ clearTimeout(timer);
63
+ resolve({ status: null });
64
+ });
65
+ child.on('close', (code) => {
66
+ clearTimeout(timer);
67
+ resolve({ status: code });
68
+ });
69
+ });
70
+ /** run-elevated.ps1 -Script hcs-live-share.ps1 with the request in a private temp folder; relays the canary. */
71
+ export class ScriptLivePort {
72
+ runner;
73
+ tempRoot;
74
+ pollMs;
75
+ constructor(runner = spawnLiveRunner, tempRoot = os.tmpdir(), pollMs = 300) {
76
+ this.runner = runner;
77
+ this.tempRoot = tempRoot;
78
+ this.pollMs = pollMs;
79
+ }
80
+ async run(request, onCanary) {
81
+ if (this.runner === spawnLiveRunner)
82
+ refuseInSelfTest('add a folder to the running Windows Sandbox');
83
+ const dir = fs.mkdtempSync(path.join(this.tempRoot, 'agent-sandbox-live-'));
84
+ try {
85
+ const file = path.join(dir, 'request.json');
86
+ fs.writeFileSync(file, JSON.stringify(request, null, 2), 'utf8');
87
+ const sha = crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
88
+ let done = false;
89
+ const finished = this.runner(SYSTEM.powershell(), nativeScript('run-elevated.ps1', [
90
+ '-Script',
91
+ nativeFile('scripts', 'hcs-live-share.ps1'),
92
+ '-Json',
93
+ file,
94
+ '-Sha256',
95
+ sha,
96
+ ]), ELEVATED_TIMEOUT_MS).then((r) => {
97
+ done = true;
98
+ return r;
99
+ });
100
+ let relayed = false;
101
+ while (!done) {
102
+ if (!relayed) {
103
+ const share = readCanary(path.join(dir, 'canary.json'));
104
+ if (share) {
105
+ relayed = true;
106
+ const token = await onCanary(share).catch(() => null);
107
+ writeFileAtomic(path.join(dir, 'answer.txt'), token && LIVE_TOKEN.test(token) ? token : 'none');
108
+ }
109
+ }
110
+ await sleep(this.pollMs);
111
+ }
112
+ const r = await finished;
113
+ const answer = readValid(OutcomeSchema, path.join(dir, 'result.json'));
114
+ if (!answer)
115
+ return fail(r.status === 1
116
+ ? 'Windows did not give permission (the prompt was declined or closed).'
117
+ : 'The live-folder helper gave no answer.', 'Run the command again and choose Yes in the Windows prompt, or restart the sandbox to add the folder.');
118
+ return answer;
119
+ }
120
+ finally {
121
+ fs.rmSync(dir, { recursive: true, force: true });
122
+ }
123
+ }
124
+ }
125
+ /** canary.json from the elevated helper: {"share": "agentsbx-..."} (small, checked). */
126
+ function readCanary(file) {
127
+ try {
128
+ if (fs.statSync(file).size > 1024)
129
+ return null;
130
+ const parsed = z
131
+ .object({ share: z.string().regex(LIVE_SHARE_NAME) })
132
+ .safeParse(JSON.parse(fs.readFileSync(file, 'utf8')));
133
+ return parsed.success ? parsed.data.share : null;
134
+ }
135
+ catch {
136
+ return null;
137
+ }
138
+ }
139
+ /** <storage>\state\live-folders.json. */
140
+ export function liveRecordFile(storageRoot) {
141
+ return path.join(storageRoot, 'state', 'live-folders.json');
142
+ }
143
+ export class LiveFolders {
144
+ d;
145
+ file;
146
+ run;
147
+ constructor(d) {
148
+ this.d = d;
149
+ this.file = new JsonFile(RecordSchema, liveRecordFile(d.storageRoot));
150
+ this.run = new RunDir(d.dataPath);
151
+ }
152
+ read() {
153
+ return this.file.read();
154
+ }
155
+ /** At launch (projects.liveFolders "hcs"): a new key for this launch, kept sealed; returned for the secrets file. */
156
+ startLaunch(launchId) {
157
+ const key = newLiveKey();
158
+ this.file.write({
159
+ launchId,
160
+ dataPath: this.d.dataPath,
161
+ key: this.d.secrets.seal(key),
162
+ systemId: null,
163
+ shares: [],
164
+ });
165
+ return key;
166
+ }
167
+ /** The sandbox stopped (its shares ended with it), or a launch without live folders: the record goes. */
168
+ clear() {
169
+ fs.rmSync(liveRecordFile(this.d.storageRoot), { force: true });
170
+ }
171
+ /** Why live folders cannot be used for this launch now (null: they can). */
172
+ unavailable(launchId) {
173
+ const record = this.read();
174
+ if (!record)
175
+ return 'the running sandbox was started while projects.liveFolders was off (turn it on before it starts)';
176
+ if (record.launchId !== launchId)
177
+ return 'the live-folder record belongs to an earlier sandbox';
178
+ if (path.resolve(record.dataPath).toLowerCase() !== path.resolve(this.d.dataPath).toLowerCase())
179
+ return 'the live-folder record belongs to another data folder';
180
+ return null;
181
+ }
182
+ log(line) {
183
+ try {
184
+ const file = path.join(this.d.storageRoot, 'state', 'live-folders.log');
185
+ fs.mkdirSync(path.dirname(file), { recursive: true });
186
+ try {
187
+ if (fs.statSync(file).size > MAX_LOG_BYTES)
188
+ fs.renameSync(file, file + '.old');
189
+ }
190
+ catch {
191
+ // No log yet.
192
+ }
193
+ fs.appendFileSync(file, (this.d.now ?? (() => new Date()))().toISOString() + ' ' + line + '\n', 'utf8');
194
+ }
195
+ catch {
196
+ // The log is a convenience.
197
+ }
198
+ }
199
+ key(record) {
200
+ return this.d.secrets.open(record.key);
201
+ }
202
+ /** Sends one signed request to the sandbox's daemon and waits for its reply. */
203
+ async ask(record, p, timeoutMs) {
204
+ const id = Date.now().toString(36) + crypto.randomBytes(4).toString('hex');
205
+ const payload = {
206
+ v: 1,
207
+ id,
208
+ launchId: record.launchId,
209
+ createdAt: (this.d.now ?? (() => new Date()))().toISOString(),
210
+ ...p,
211
+ };
212
+ assertNoLinks(this.d.dataPath, this.run.liveDir);
213
+ assertNoLinks(this.d.dataPath, this.run.liveRepliesDir);
214
+ writeJsonAtomic(this.run.liveFile(id), signLive(this.key(record), payload));
215
+ const reply = this.run.liveReplyFile(id);
216
+ const deadline = Date.now() + timeoutMs;
217
+ for (let round = 0; Date.now() < deadline; round++) {
218
+ const answer = readValid(LiveReplySchema, reply);
219
+ if (answer && answer.id === id) {
220
+ fs.rmSync(reply, { force: true });
221
+ // Guest text: printable only before it can reach a message or the terminal.
222
+ return { ...answer, message: safeText(answer.message).slice(0, 300) };
223
+ }
224
+ if (round % 10 === 9 && this.d.isAlive && !this.d.isAlive())
225
+ break;
226
+ await sleep(this.d.pollMs ?? 300);
227
+ }
228
+ fs.rmSync(this.run.liveFile(id), { force: true });
229
+ return { id, ok: false, message: 'the sandbox did not answer', finishedAt: '' };
230
+ }
231
+ request(record, action, name, extra) {
232
+ return {
233
+ action,
234
+ id: record.systemId ?? '',
235
+ name,
236
+ ...extra,
237
+ storageRoot: this.d.storageRoot,
238
+ dataPath: this.d.dataPath,
239
+ userSid: this.d.userSid(),
240
+ };
241
+ }
242
+ /**
243
+ * Shares `host` (its real path) into the running sandbox and shows it at C:\Workspace\`folder`. Throws with the
244
+ * reason when anything refuses or fails; the folder is then not shared.
245
+ */
246
+ async add(o) {
247
+ const why = this.unavailable(o.launchId);
248
+ if (why)
249
+ fail('Live folders cannot be used: ' + why + '.');
250
+ const record = this.read();
251
+ if (record.shares.some((s) => s.host.toLowerCase() === o.host.toLowerCase()))
252
+ fail(o.host + ' is a live folder already.');
253
+ const name = newShareName();
254
+ const onCanary = async (share) => {
255
+ const reply = await this.ask(record, { kind: 'canary', share, folder: 'live-canary-' + crypto.randomBytes(4).toString('hex'), readOnly: true }, this.d.canaryMs ?? CANARY_REPLY_MS);
256
+ return reply.ok && reply.token ? reply.token : null;
257
+ };
258
+ const outcome = await this.d.elevated.run(this.request(record, 'add', name, { path: o.host, readOnly: o.readOnly }), onCanary);
259
+ this.log('add ' +
260
+ name +
261
+ ' ' +
262
+ o.host +
263
+ ' -> ' +
264
+ o.guest +
265
+ ' id=' +
266
+ (outcome.id ?? '') +
267
+ ' canary=' +
268
+ (outcome.canary ?? '') +
269
+ ' ' +
270
+ (outcome.ok ? 'ok' : 'failed: ' + outcome.message));
271
+ if (!outcome.ok)
272
+ fail('The folder was not shared: ' + safeText(outcome.message) + '.');
273
+ if (!outcome.id || !GUID.test(outcome.id))
274
+ fail('The live-folder helper did not say which sandbox it shared the folder with; it ends when the sandbox stops.');
275
+ const unexpected = Boolean(record.systemId && record.systemId.toLowerCase() !== outcome.id.toLowerCase());
276
+ const share = {
277
+ name,
278
+ host: o.host,
279
+ guest: o.guest,
280
+ folder: o.folder,
281
+ readOnly: o.readOnly,
282
+ addedAt: new Date().toISOString(),
283
+ };
284
+ const latest = this.read() ?? record;
285
+ // Recorded before anything else can fail, so remove can still take it away.
286
+ this.file.write({ ...latest, systemId: latest.systemId ?? outcome.id, shares: [...latest.shares, share] });
287
+ if (unexpected)
288
+ fail('The live-folder helper answered for an unexpected sandbox; nothing more is done.');
289
+ const mapped = await this.ask(record, { kind: 'map', share: name, folder: o.folder, readOnly: o.readOnly }, this.d.mapMs ?? MAP_REPLY_MS);
290
+ if (!mapped.ok) {
291
+ this.log('map ' + name + ' failed: ' + mapped.message);
292
+ const undone = await this.removeShare(share).catch((e) => (e instanceof Error ? e.message : String(e)));
293
+ fail('The folder was shared but the sandbox could not show it: ' +
294
+ mapped.message +
295
+ (undone === true ? ' (the share was removed again).' : ' (the share stays until the sandbox stops).'));
296
+ }
297
+ return share;
298
+ }
299
+ /** The share itself (elevated). True when it is gone; the record is kept when not. */
300
+ async removeShare(share) {
301
+ const record = this.read();
302
+ if (!record?.systemId || !record.shares.some((s) => s.name === share.name))
303
+ fail('That share is not in the live-folder record.');
304
+ const outcome = await this.d.elevated.run(this.request(record, 'remove', share.name, {}), async () => null);
305
+ this.log('remove ' + share.name + ' ' + share.host + ' ' + (outcome.ok ? 'ok' : 'failed: ' + outcome.message));
306
+ if (!outcome.ok)
307
+ fail('The share could not be removed: ' + safeText(outcome.message) + '. It ends when the sandbox stops.');
308
+ const latest = this.read() ?? record;
309
+ this.file.write({ ...latest, shares: latest.shares.filter((s) => s.name !== share.name) });
310
+ return true;
311
+ }
312
+ /** The live folder of `host`, if there is one. */
313
+ shareOf(host) {
314
+ return this.read()?.shares.find((s) => s.host.toLowerCase() === host.toLowerCase()) ?? null;
315
+ }
316
+ /** Removes a live folder: the guest mapping first, then the share. Throws (and keeps it) when either fails. */
317
+ async remove(launchId, host) {
318
+ const why = this.unavailable(launchId);
319
+ if (why)
320
+ fail('Live folders cannot be used: ' + why + '.');
321
+ const record = this.read();
322
+ const share = this.shareOf(host) ?? fail(host + ' is not a live folder.');
323
+ const hidden = await this.ask(record, { kind: 'unmap', folder: share.folder, readOnly: share.readOnly }, this.d.mapMs ?? MAP_REPLY_MS);
324
+ if (!hidden.ok)
325
+ fail('The sandbox could not hide ' + share.guest + ': ' + hidden.message + '.', 'Close what uses it in the sandbox and try again; otherwise it stays until the sandbox stops.');
326
+ await this.removeShare(share);
327
+ }
328
+ }
329
+ /** The real thing: DPAPI, the elevated script (it builds HcsHost.exe itself), this user's SID. */
330
+ export function realLiveFolders(storageRoot, dataPath) {
331
+ return new LiveFolders({
332
+ storageRoot,
333
+ dataPath,
334
+ secrets: SecretStore.standard(),
335
+ elevated: new ScriptLivePort(),
336
+ userSid: currentUserSid,
337
+ isAlive: isSandboxRunning,
338
+ });
339
+ }
340
+ /** The sandbox of `dataPath` stopped: its live-folder record (and key) goes; its shares ended with the VM. */
341
+ export function clearLiveRecord(storageRoot, dataPath) {
342
+ const record = new JsonFile(RecordSchema, liveRecordFile(storageRoot)).read();
343
+ if (!record || path.resolve(record.dataPath).toLowerCase() !== path.resolve(dataPath).toLowerCase())
344
+ return false;
345
+ fs.rmSync(liveRecordFile(storageRoot), { force: true });
346
+ return true;
347
+ }
@@ -6,3 +6,4 @@ export { wsbText } from './windows-sandbox/wsb-text.js';
6
6
  export { HcsEngine, HCS_CAPABILITIES, hcsStartEnabled } from './hcs/engine.js';
7
7
  export { checkHcs, realHcsProbes, type HcsProbes } from './hcs/check.js';
8
8
  export { lookForLeftovers, stopLeftovers, LEFTOVER_DEPS, type LeftoverDeps, type LeftoverLook, type LeftoverOutcome, type LeftoverSystem, } from './hcs/leftovers.js';
9
+ export { LiveFolders, ScriptLivePort, clearLiveRecord, realLiveFolders, liveRecordFile, LIVE_FOLDERS_WARNING, type LiveRecord, type LiveShare, type LiveShareRequest, type LiveOutcome, type ElevatedLivePort, type LiveRunner, } from './hcs/live-folders.js';
@@ -4,3 +4,4 @@ export { wsbText } from './windows-sandbox/wsb-text.js';
4
4
  export { HcsEngine, HCS_CAPABILITIES, hcsStartEnabled } from './hcs/engine.js';
5
5
  export { checkHcs, realHcsProbes } from './hcs/check.js';
6
6
  export { lookForLeftovers, stopLeftovers, LEFTOVER_DEPS, } from './hcs/leftovers.js';
7
+ export { LiveFolders, ScriptLivePort, clearLiveRecord, realLiveFolders, liveRecordFile, LIVE_FOLDERS_WARNING, } from './hcs/live-folders.js';
@@ -6,7 +6,7 @@ import crypto from 'node:crypto';
6
6
  import os from 'node:os';
7
7
  import { widenForProjects } from '../../core/local-config/index.js';
8
8
  import { dirExists, ensureDir, fail, writeJson } from '../../core/util/index.js';
9
- import { engineFor } from '../engines/index.js';
9
+ import { engineFor, realLiveFolders } from '../engines/index.js';
10
10
  import { capturePersonalization, stageClaudeProfile } from '../profile/index.js';
11
11
  import { clearSecrets, writeSecrets } from '../secrets/index.js';
12
12
  import { githubTokenAllowed } from '../github/index.js';
@@ -102,7 +102,14 @@ opened) {
102
102
  profile: loadProfileOf(config),
103
103
  reserveHostMb: config.sandbox.reserveHostMb,
104
104
  });
105
+ // Live folders through HCS (opt-in alpha, Windows Sandbox only): a new key per launch, sealed on this side and handed
106
+ // over in the one-shot file; without the setting, an earlier launch's record goes.
107
+ const live = realLiveFolders(storageRoot, dataPath);
108
+ const liveFoldersKey = config.projects.liveFolders === 'hcs' && engine.id === 'windows-sandbox' ? live.startLaunch(launchId) : undefined;
109
+ if (!liveFoldersKey)
110
+ live.clear();
105
111
  writeSecrets(dataPath, launchId, {
112
+ ...(liveFoldersKey ? { liveFoldersKey } : {}),
106
113
  ...(harness && Object.keys(harness.environment).length ? { harnessEnvironment: harness.environment } : {}),
107
114
  ...(claudeCredentials ? { claudeCredentials } : {}),
108
115
  ...(o.vpn?.credentials ? { vpn: o.vpn.credentials } : {}),
@@ -35,6 +35,10 @@ export declare function joinRunning(o: {
35
35
  packageRoot: string;
36
36
  hostDir: string;
37
37
  wsbShare: boolean;
38
+ /** projects.liveFolders ("hcs": the opt-in alpha that adds folders through HCS where wsb share is missing). */
39
+ liveFolders?: 'off' | 'hcs';
40
+ /** Shown at once (the live-folder warning comes before its permission prompt). */
41
+ onNote?: (note: string) => void;
38
42
  resolve: (projectRoot: string) => Promise<ResolvedRun>;
39
43
  mapper?: ProjectMapper;
40
44
  /** Asked before anything is registered when something is missing (default: continue without it). */