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
@@ -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-BQ23GBLC.js";
4
+ } from "./chunk-HIC4CUMY.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-F7IMME5G.js";
6
- import "./chunk-BQ23GBLC.js";
5
+ } from "./chunk-7EXDUNE7.js";
6
+ import "./chunk-HIC4CUMY.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-F7IMME5G.js";
6
- import "./chunk-BQ23GBLC.js";
5
+ } from "./chunk-7EXDUNE7.js";
6
+ import "./chunk-HIC4CUMY.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-F7IMME5G.js";
6
- import "./chunk-BQ23GBLC.js";
5
+ } from "./chunk-7EXDUNE7.js";
6
+ import "./chunk-HIC4CUMY.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-F7IMME5G.js";
6
- import "./chunk-BQ23GBLC.js";
5
+ } from "./chunk-7EXDUNE7.js";
6
+ import "./chunk-HIC4CUMY.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-F7IMME5G.js";
6
- import "./chunk-BQ23GBLC.js";
5
+ } from "./chunk-7EXDUNE7.js";
6
+ import "./chunk-HIC4CUMY.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-F7IMME5G.js";
6
- import "./chunk-BQ23GBLC.js";
5
+ } from "./chunk-7EXDUNE7.js";
6
+ import "./chunk-HIC4CUMY.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-F7IMME5G.js";
6
- import "./chunk-BQ23GBLC.js";
5
+ } from "./chunk-7EXDUNE7.js";
6
+ import "./chunk-HIC4CUMY.js";
7
7
 
8
8
  // src/bin/sbx-show.ts
9
9
  run(process.argv.slice(2), { command: "show" });
@@ -77,6 +77,8 @@ export async function joinHere(ctx, status, o = {}) {
77
77
  packageRoot,
78
78
  hostDir: dir,
79
79
  wsbShare: loaded.config.projects.wsbShare,
80
+ liveFolders: loaded.config.projects.liveFolders,
81
+ onNote: (note) => console.log(c.yellow(note)),
80
82
  resolve: (root) => resolveRun({
81
83
  packageRoot,
82
84
  loaded,
package/dist/cli/index.js CHANGED
@@ -12,7 +12,7 @@ import { takeHiddenInstallNotice } from '../host/install/index.js';
12
12
  import { c } from './output/index.js';
13
13
  import { dropInheritedModulePath } from '../shared/system.js';
14
14
  import { withConfig, hoistConfig, withoutConfig } from './argv.js';
15
- import { report } from './report.js';
15
+ import { recordUnexpected, report } from './report.js';
16
16
  import { versionText } from './version.js';
17
17
  import { HANDLERS } from './commands/index.js';
18
18
  import { definitionHandler, operationHandler } from './operations/index.js';
@@ -121,6 +121,9 @@ export async function main(rawArgv, preset = {}) {
121
121
  }
122
122
  export function run(argv, preset) {
123
123
  dropInheritedModulePath();
124
+ // Background work that throws must not end the menu or the web server: the error is kept with its stack instead.
125
+ process.on('unhandledRejection', (e) => recordUnexpected(e, 'unhandled rejection'));
126
+ process.on('uncaughtException', (e) => recordUnexpected(e, 'uncaught exception'));
124
127
  main(argv, preset).then((code) => {
125
128
  process.exitCode = code;
126
129
  }, (e) => {
@@ -1,2 +1,9 @@
1
+ /** Where errors nobody caught are written in full (this user's temp folder; kept under 1 MB). */
2
+ export declare const UNEXPECTED_LOG: string;
3
+ /**
4
+ * An error thrown by background work (a timer, a promise nobody awaited): written in full to UNEXPECTED_LOG and named
5
+ * in one line, instead of ending the menu, the web server or a command half-way.
6
+ */
7
+ export declare function recordUnexpected(e: unknown, kind: string): void;
1
8
  /** Prints a problem the way a user can act on it: what happened, how to fix it; stack traces only for real bugs. */
2
9
  export declare function report(e: unknown): number;
@@ -1,6 +1,37 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
1
4
  import { CommanderError } from 'commander';
2
5
  import { c } from './output/index.js';
3
6
  import { UserError, errMsg, plainText } from '../core/util/index.js';
7
+ /** Where errors nobody caught are written in full (this user's temp folder; kept under 1 MB). */
8
+ export const UNEXPECTED_LOG = path.join(os.tmpdir(), 'windows-development-sandbox-errors.log');
9
+ const UNEXPECTED_LOG_MAX = 1024 * 1024;
10
+ /**
11
+ * An error thrown by background work (a timer, a promise nobody awaited): written in full to UNEXPECTED_LOG and named
12
+ * in one line, instead of ending the menu, the web server or a command half-way.
13
+ */
14
+ export function recordUnexpected(e, kind) {
15
+ const text = new Date().toISOString() +
16
+ ' ' +
17
+ kind +
18
+ ' (pid ' +
19
+ process.pid +
20
+ ', ' +
21
+ process.argv.slice(2).join(' ') +
22
+ ')\n' +
23
+ (e instanceof Error && e.stack ? e.stack : errMsg(e)) +
24
+ '\n\n';
25
+ try {
26
+ if (fs.existsSync(UNEXPECTED_LOG) && fs.statSync(UNEXPECTED_LOG).size > UNEXPECTED_LOG_MAX)
27
+ fs.rmSync(UNEXPECTED_LOG, { force: true });
28
+ fs.appendFileSync(UNEXPECTED_LOG, text);
29
+ }
30
+ catch {
31
+ // Nowhere to write it: the line below still says what happened.
32
+ }
33
+ console.error(c.red('Unexpected error (kept running): ') + plainText(errMsg(e)) + c.dim(' Details: ' + UNEXPECTED_LOG));
34
+ }
4
35
  /** Prints a problem the way a user can act on it: what happened, how to fix it; stack traces only for real bugs. */
5
36
  export function report(e) {
6
37
  if (e instanceof CommanderError)
@@ -4,7 +4,8 @@ import { spawnSync } from 'node:child_process';
4
4
  import { expandPath, fail, isWin } from '../../core/util/index.js';
5
5
  import { detectWsb, doctorRows, insideSandbox, isSandboxRunning } from '../../host/sandbox/index.js';
6
6
  import { machineDoctorRows } from '../../host/sharing/index.js';
7
- import { KnownProjects, ProjectRegistry } from '../../host/projects/index.js';
7
+ import { addFoldersRow, KnownProjects, ProjectRegistry } from '../../host/projects/index.js';
8
+ import { realLiveFolders } from '../../host/engines/index.js';
8
9
  import { nativeScript } from '../../host/native.js';
9
10
  import { refuseInSelfTest } from '../../host/test-guard.js';
10
11
  import { SYSTEM } from '../../shared/system.js';
@@ -29,7 +30,9 @@ export class HealthService {
29
30
  // Several projects: the roots mapped at every start, what the running sandbox has, and whether wsb share is there.
30
31
  const p = ctx.loaded.config.projects;
31
32
  const registry = isSandboxRunning() ? new ProjectRegistry(ctx.storageRoot).read() : null;
32
- const wsb = p.wsbShare ? detectWsb() : null;
33
+ const wsb = p.wsbShare || p.liveFolders === 'hcs' ? detectWsb() : null;
34
+ const launchId = registry?.launchId ?? null;
35
+ const liveUnavailable = p.liveFolders === 'hcs' && launchId ? realLiveFolders(ctx.storageRoot, ctx.dataPath).unavailable(launchId) : null;
33
36
  return [
34
37
  ...rows,
35
38
  ...machineDoctorRows(ctx.packageRoot, ctx.dataPath),
@@ -59,7 +62,16 @@ export class HealthService {
59
62
  wsb.exe +
60
63
  (wsb.version ? ' ' + wsb.version : '') +
61
64
  ' (untested; a failure offers a restart instead)'
62
- : 'not on this Windows (needs Windows 11 24H2+ with the updated Windows Sandbox): folders outside your roots need a restart',
65
+ : 'not on this Windows (needs Windows 11 24H2+ with the updated Windows Sandbox)',
66
+ ],
67
+ [
68
+ 'Add folders while the sandbox runs',
69
+ addFoldersRow({
70
+ wsbShare: p.wsbShare,
71
+ liveFolders: p.liveFolders,
72
+ wsbHasShare: wsb?.has('share') === true,
73
+ liveUnavailable,
74
+ }),
63
75
  ],
64
76
  ];
65
77
  }
@@ -26,11 +26,16 @@ export declare class ProjectsService {
26
26
  list(): ProjectsListing;
27
27
  /** Remembers a project folder (it must pass the same checks as a workspace). Returns the folder. */
28
28
  add(folder: string): string;
29
- /** Forgets a project, in the running sandbox too (its folder stays visible until the sandbox stops). */
30
- forget(folder: string): {
29
+ /**
30
+ * Forgets a project, in the running sandbox too (a mapped folder stays visible until the sandbox stops; a live folder
31
+ * is hidden and its share removed, with one permission prompt).
32
+ */
33
+ forget(folder: string): Promise<{
31
34
  dir: string;
32
35
  removed: boolean;
33
- };
36
+ }>;
37
+ /** The folder is a live folder (HCS) of the running sandbox: forgetting it removes its share (one prompt). */
38
+ isLive(folder: string): boolean;
34
39
  /** Excludes a folder from the projects mapped at every start. True when that changed anything. */
35
40
  exclude(folder: string): boolean;
36
41
  /** Includes a folder again (and remembers it). True when it was excluded before. */
@@ -51,20 +51,37 @@ export class ProjectsService {
51
51
  new KnownProjects(this.ctx.storageRoot).remember(dir);
52
52
  return dir;
53
53
  }
54
- /** Forgets a project, in the running sandbox too (its folder stays visible until the sandbox stops). */
55
- forget(folder) {
54
+ /**
55
+ * Forgets a project, in the running sandbox too (a mapped folder stays visible until the sandbox stops; a live folder
56
+ * is hidden and its share removed, with one permission prompt).
57
+ */
58
+ async forget(folder) {
56
59
  const { ctx } = this;
57
60
  const dir = path.resolve(folder);
58
- const forgot = new KnownProjects(ctx.storageRoot).forget(dir);
59
61
  const registered = isSandboxRunning()
60
62
  ? new ProjectRegistry(ctx.storageRoot)
61
63
  .read()
62
64
  ?.projects.find((p) => !p.primary && p.host.toLowerCase() === dir.toLowerCase())
63
65
  : undefined;
66
+ // The live folder first: when it cannot be removed, the project stays (in the list and in the sandbox).
64
67
  if (registered)
65
- projectMapper({ storageRoot: ctx.storageRoot, packageRoot: ctx.packageRoot, wsbShare: false }).unregister(registered.name);
68
+ await projectMapper({
69
+ storageRoot: ctx.storageRoot,
70
+ packageRoot: ctx.packageRoot,
71
+ wsbShare: false,
72
+ dataPath: ctx.dataPath,
73
+ }).unregister(registered.name);
74
+ const forgot = new KnownProjects(ctx.storageRoot).forget(dir);
66
75
  return { dir, removed: forgot || Boolean(registered) };
67
76
  }
77
+ /** The folder is a live folder (HCS) of the running sandbox: forgetting it removes its share (one prompt). */
78
+ isLive(folder) {
79
+ const dir = path.resolve(folder);
80
+ return (isSandboxRunning() &&
81
+ new ProjectRegistry(this.ctx.storageRoot)
82
+ .read()
83
+ ?.projects.some((p) => p.mechanism === 'hcs' && p.host.toLowerCase() === dir.toLowerCase()) === true);
84
+ }
68
85
  /** Excludes a folder from the projects mapped at every start. True when that changed anything. */
69
86
  exclude(folder) {
70
87
  return addToList(this.ctx, ['projects', 'exclude'], path.resolve(folder));
@@ -292,5 +292,6 @@ export const DEFAULTS = {
292
292
  declinedRoots: [],
293
293
  allowOutsideMappings: 'ask',
294
294
  wsbShare: true,
295
+ liveFolders: 'off',
295
296
  },
296
297
  };
@@ -356,6 +356,10 @@ export declare const ConfigSchema: z.ZodObject<{
356
356
  declinedRoots: z.ZodArray<z.ZodString>;
357
357
  allowOutsideMappings: z.ZodUnion<readonly [z.ZodLiteral<"ask">, z.ZodBoolean]>;
358
358
  wsbShare: z.ZodBoolean;
359
+ liveFolders: z.ZodEnum<{
360
+ hcs: "hcs";
361
+ off: "off";
362
+ }>;
359
363
  }, z.core.$strict>;
360
364
  resume: z.ZodObject<{
361
365
  sessions: z.ZodEnum<{
@@ -7,4 +7,8 @@ export declare const ProjectsSchema: z.ZodObject<{
7
7
  declinedRoots: z.ZodArray<z.ZodString>;
8
8
  allowOutsideMappings: z.ZodUnion<readonly [z.ZodLiteral<"ask">, z.ZodBoolean]>;
9
9
  wsbShare: z.ZodBoolean;
10
+ liveFolders: z.ZodEnum<{
11
+ hcs: "hcs";
12
+ off: "off";
13
+ }>;
10
14
  }, z.core.$strict>;
@@ -19,4 +19,7 @@ export const ProjectsSchema = z.strictObject({
19
19
  wsbShare: z
20
20
  .boolean()
21
21
  .describe('On Windows 11 24H2+ with the Windows Sandbox command line (wsb), add other folders to the running sandbox with `wsb share` (untested). Otherwise starting a project outside every root offers a restart.'),
22
+ liveFolders: z
23
+ .enum(['off', 'hcs'])
24
+ .describe('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").'),
22
25
  });
@@ -73,8 +73,14 @@ function startDaemon(env, secrets) {
73
73
  ...env,
74
74
  AGENT_LOG_PREFIX: 'daemon',
75
75
  // The agent's environment (it may hold API keys): the daemon moves it into memory and removes this at once.
76
- ...(secrets.harnessEnvironment
77
- ? { [BOOT_SECRETS_VARIABLE]: JSON.stringify({ harnessEnvironment: secrets.harnessEnvironment }) }
76
+ // The live-folder key (projects.liveFolders "hcs") goes the same way into the daemon's own state.
77
+ ...(secrets.harnessEnvironment || secrets.liveFoldersKey
78
+ ? {
79
+ [BOOT_SECRETS_VARIABLE]: JSON.stringify({
80
+ ...(secrets.harnessEnvironment ? { harnessEnvironment: secrets.harnessEnvironment } : {}),
81
+ ...(secrets.liveFoldersKey ? { liveFoldersKey: secrets.liveFoldersKey } : {}),
82
+ }),
83
+ }
78
84
  : {}),
79
85
  },
80
86
  }).unref();
@@ -8,6 +8,7 @@ import { governor } from '../governor/index.js';
8
8
  import { respond, safely } from './state.js';
9
9
  import { run } from '../runtime/paths.js';
10
10
  import { withRequestSecrets } from '../runtime/secrets.js';
11
+ import { liveFolders } from './live.js';
11
12
  import { warn } from '../runtime/log.js';
12
13
  import { errMsg } from '../../core/util/errors.js';
13
14
  const POLL_MS = 400;
@@ -38,6 +39,8 @@ export class Daemon {
38
39
  // Not awaited: starting a session or running a command must not hold up other requests.
39
40
  void Promise.resolve(this.handle(withRequestSecrets(req))).catch((e) => respond(req.id, { ok: false, message: errMsg(e) }));
40
41
  }
42
+ // Live folders (signed host requests in run\live): claimed here, handled one after another.
43
+ safely('live folders', () => liveFolders.tick());
41
44
  if (Date.now() - this.lastMemSave > MEM_SAVE_MS) {
42
45
  this.lastMemSave = Date.now();
43
46
  safely('claude-mem save', saveClaudeMem);
@@ -0,0 +1,48 @@
1
+ import type { RunDir } from '../../shared/channel/run-dir.js';
2
+ /** The bind filter, behind an interface so the self-test can run the handler without one. */
3
+ export interface BindPort {
4
+ /** BindMount map: 0 done, 3 the share is not there (yet), anything else failed. */
5
+ map(folder: string, share: string, readOnly: boolean): number;
6
+ unmap(folder: string): number;
7
+ /** BindMount mapped: 0 mapped, 4 not mapped, anything else unknown. */
8
+ mapped(folder: string): number;
9
+ }
10
+ export interface LiveFolderOptions {
11
+ readonly run: RunDir;
12
+ /** The daemon's own state file on the sandbox's disk (made administrator-only by the caller). */
13
+ readonly stateFile: string;
14
+ readonly launchId: string;
15
+ readonly bind: BindPort;
16
+ readonly workspaces?: string;
17
+ readonly now?: () => number;
18
+ readonly sleep?: (ms: number) => Promise<void>;
19
+ /** How often a missing share is tried again (0.5 s apart). */
20
+ readonly tries?: number;
21
+ readonly log?: (line: string) => void;
22
+ }
23
+ export declare class LiveFolderHandler {
24
+ private readonly o;
25
+ private queue;
26
+ constructor(o: LiveFolderOptions);
27
+ private now;
28
+ private log;
29
+ private read;
30
+ private save;
31
+ /** The key the boot handed over (from the one-shot secrets file). */
32
+ setKey(key: string): void;
33
+ /** C:\Workspace\<name> from a checked name only. */
34
+ private folderPath;
35
+ /** One pass over run\live: every file is claimed (deleted) at once; valid ones are handled one after another. */
36
+ tick(): void;
37
+ /** Waits for what tick() queued (tests). */
38
+ idle(): Promise<void>;
39
+ handle(envelope: unknown): Promise<void>;
40
+ private reply;
41
+ /** map with retries while the share is not there yet. */
42
+ private mapWithRetry;
43
+ /** The folder must not exist yet: not as a folder, a file or a link. */
44
+ private assertFree;
45
+ private act;
46
+ /** When the daemon starts: the recorded mappings this launch made, made again where they are gone. */
47
+ reapply(): number;
48
+ }
@@ -0,0 +1,245 @@
1
+ // Live folders, the daemon's half (opt-in alpha, docs/design/hcs-live-folders.md): the host shares a folder into the
2
+ // running sandbox (vSMB), then asks here, with a request signed by this launch's key (shared/channel/live.ts), to show
3
+ // it at C:\Workspace\<name> through the bind filter (sandbox\csharp\BindMount.cs). A canary request does the same for a
4
+ // test share, reads the token in it and hides it again: that proves to the host that the share reached THIS sandbox.
5
+ // Every file in run\live is read at most 16 KB, links and bad names are deleted unread, and a request is acted on only
6
+ // with a valid signature, this launch's id, a fresh time and an id not seen before (the seen ids are kept on the
7
+ // sandbox's own disk, so restarting the daemon does not reopen them). Guest paths come only from the strict folder
8
+ // name: never a path from the request. The mappings made are recorded and made again when the daemon starts.
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import { z } from 'zod';
12
+ import { LIVE_FOLDER_NAME, LIVE_KEY, LIVE_SHARE_NAME, LIVE_TOKEN, openLive } from '../../shared/channel/live.js';
13
+ import { writeJsonAtomic } from '../../shared/io/json.js';
14
+ import { WORKSPACES_GUEST } from '../../shared/paths/projects.js';
15
+ import { errMsg } from '../../core/util/errors.js';
16
+ const MAX_REQUEST_BYTES = 16 * 1024;
17
+ const MAX_SEEN = 512;
18
+ /** BindMount's exit codes. */
19
+ const EXIT_OK = 0;
20
+ const EXIT_NO_SHARE = 3;
21
+ const EXIT_NOT_MAPPED = 4;
22
+ const StateSchema = z.object({
23
+ key: z.string().regex(LIVE_KEY).nullable(),
24
+ launchId: z.string().max(64),
25
+ seen: z.array(z.object({ id: z.string().max(64), at: z.number() })).max(MAX_SEEN),
26
+ mappings: z
27
+ .array(z.object({
28
+ folder: z.string().regex(LIVE_FOLDER_NAME),
29
+ share: z.string().regex(LIVE_SHARE_NAME),
30
+ readOnly: z.boolean(),
31
+ }))
32
+ .max(256),
33
+ });
34
+ const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
35
+ export class LiveFolderHandler {
36
+ o;
37
+ queue = Promise.resolve();
38
+ constructor(o) {
39
+ this.o = o;
40
+ }
41
+ now() {
42
+ return (this.o.now ?? Date.now)();
43
+ }
44
+ log(line) {
45
+ this.o.log?.(line);
46
+ }
47
+ read() {
48
+ try {
49
+ const text = fs.readFileSync(this.o.stateFile, 'utf8');
50
+ const parsed = StateSchema.safeParse(JSON.parse(text));
51
+ if (parsed.success && parsed.data.launchId === this.o.launchId)
52
+ return parsed.data;
53
+ }
54
+ catch {
55
+ // No state yet (or unreadable): a fresh one for this launch.
56
+ }
57
+ return { key: null, launchId: this.o.launchId, seen: [], mappings: [] };
58
+ }
59
+ save(state) {
60
+ fs.mkdirSync(path.dirname(this.o.stateFile), { recursive: true });
61
+ writeJsonAtomic(this.o.stateFile, state);
62
+ }
63
+ /** The key the boot handed over (from the one-shot secrets file). */
64
+ setKey(key) {
65
+ if (!LIVE_KEY.test(key))
66
+ return;
67
+ this.save({ ...this.read(), key });
68
+ }
69
+ /** C:\Workspace\<name> from a checked name only. */
70
+ folderPath(name) {
71
+ if (!LIVE_FOLDER_NAME.test(name))
72
+ throw new Error('not a plain folder name');
73
+ return path.win32.join(this.o.workspaces ?? WORKSPACES_GUEST, name);
74
+ }
75
+ /** One pass over run\live: every file is claimed (deleted) at once; valid ones are handled one after another. */
76
+ tick() {
77
+ const dir = this.o.run.liveDir;
78
+ let names;
79
+ try {
80
+ names = fs.readdirSync(dir);
81
+ }
82
+ catch {
83
+ return; // no requests yet
84
+ }
85
+ for (const name of names.slice(0, 64)) {
86
+ if (name.endsWith('.tmp'))
87
+ continue;
88
+ const file = path.join(dir, name);
89
+ let envelope = null;
90
+ try {
91
+ const stat = fs.lstatSync(file);
92
+ if (stat.isFile() && stat.size <= MAX_REQUEST_BYTES && /^[A-Za-z0-9_-]{1,64}\.json$/.test(name))
93
+ envelope = JSON.parse(fs.readFileSync(file, 'utf8'));
94
+ }
95
+ catch {
96
+ envelope = null;
97
+ }
98
+ finally {
99
+ try {
100
+ fs.rmSync(file, { recursive: true, force: true });
101
+ }
102
+ catch {
103
+ // Locked for a moment: the next pass deletes it (the id is then seen already).
104
+ }
105
+ }
106
+ if (envelope === null)
107
+ continue;
108
+ this.queue = this.queue
109
+ .then(() => this.handle(envelope))
110
+ .catch((e) => this.log('live folder: ' + errMsg(e)));
111
+ }
112
+ }
113
+ /** Waits for what tick() queued (tests). */
114
+ idle() {
115
+ return this.queue;
116
+ }
117
+ async handle(envelope) {
118
+ const state = this.read();
119
+ const now = this.now();
120
+ const opened = openLive(state.key, envelope, { launchId: this.o.launchId, now });
121
+ if ('refused' in opened) {
122
+ this.log('live folder request ignored: ' + opened.refused);
123
+ return;
124
+ }
125
+ const p = opened.payload;
126
+ const seen = state.seen.filter((s) => now - s.at < 10 * 60 * 1000);
127
+ if (seen.some((s) => s.id === p.id)) {
128
+ this.log('live folder request ignored: seen before (' + p.id + ')');
129
+ return;
130
+ }
131
+ this.save({ ...state, seen: [...seen, { id: p.id, at: now }].slice(-MAX_SEEN) });
132
+ let reply;
133
+ try {
134
+ reply = await this.act(p);
135
+ }
136
+ catch (e) {
137
+ reply = { ok: false, message: errMsg(e).slice(0, 900) };
138
+ }
139
+ this.reply(p.id, reply);
140
+ }
141
+ reply(id, r) {
142
+ try {
143
+ fs.mkdirSync(this.o.run.liveRepliesDir, { recursive: true });
144
+ writeJsonAtomic(this.o.run.liveReplyFile(id), { id, ...r, finishedAt: new Date(this.now()).toISOString() });
145
+ }
146
+ catch (e) {
147
+ this.log('live folder reply not written: ' + errMsg(e));
148
+ }
149
+ }
150
+ /** map with retries while the share is not there yet. */
151
+ async mapWithRetry(folder, share, readOnly) {
152
+ const tries = this.o.tries ?? 20;
153
+ let code = EXIT_NO_SHARE;
154
+ for (let i = 0; i < tries; i++) {
155
+ code = this.o.bind.map(folder, share, readOnly);
156
+ if (code !== EXIT_NO_SHARE)
157
+ return code;
158
+ await (this.o.sleep ?? wait)(500);
159
+ }
160
+ return code;
161
+ }
162
+ /** The folder must not exist yet: not as a folder, a file or a link. */
163
+ assertFree(folder) {
164
+ let exists = true;
165
+ try {
166
+ fs.lstatSync(folder);
167
+ }
168
+ catch {
169
+ exists = false;
170
+ }
171
+ if (exists)
172
+ throw new Error(folder + ' exists already');
173
+ // The bind filter needs the parent (C:\Workspace) to exist: without it BfSetupFilter answers "not found", exactly
174
+ // like a share that has not arrived (seen in a Windows Sandbox, 2026-10-23). It must be a plain folder.
175
+ const parent = path.dirname(folder);
176
+ fs.mkdirSync(parent, { recursive: true });
177
+ if (!fs.lstatSync(parent).isDirectory())
178
+ throw new Error(parent + ' is not a plain folder');
179
+ }
180
+ async act(p) {
181
+ const folder = this.folderPath(p.folder);
182
+ if (p.kind === 'unmap') {
183
+ const state = this.read();
184
+ const known = state.mappings.find((m) => m.folder === p.folder);
185
+ if (!known)
186
+ return { ok: false, message: 'no live folder at ' + folder };
187
+ const code = this.o.bind.unmap(folder);
188
+ if (code !== EXIT_OK || this.o.bind.mapped(folder) !== EXIT_NOT_MAPPED)
189
+ return { ok: false, message: 'the folder could not be hidden again (are files in it open?)' };
190
+ const after = this.read();
191
+ this.save({ ...after, mappings: after.mappings.filter((m) => m.folder !== p.folder) });
192
+ return { ok: true, message: 'hidden: ' + folder };
193
+ }
194
+ const share = p.share ?? '';
195
+ this.assertFree(folder);
196
+ if (p.kind === 'canary') {
197
+ const code = await this.mapWithRetry(folder, share, true);
198
+ if (code !== EXIT_OK)
199
+ return { ok: false, message: 'the test share could not be shown (' + code + ')' };
200
+ try {
201
+ const file = path.join(folder, 'token.txt');
202
+ const stat = fs.statSync(file);
203
+ const token = stat.size <= 64 ? fs.readFileSync(file, 'utf8').trim() : '';
204
+ return LIVE_TOKEN.test(token)
205
+ ? { ok: true, message: 'read the test share', token }
206
+ : { ok: false, message: 'the test share holds no token' };
207
+ }
208
+ finally {
209
+ this.o.bind.unmap(folder);
210
+ }
211
+ }
212
+ const code = await this.mapWithRetry(folder, share, p.readOnly);
213
+ if (code !== EXIT_OK)
214
+ return {
215
+ ok: false,
216
+ message: code === EXIT_NO_SHARE
217
+ ? 'the share did not reach the sandbox'
218
+ : 'the folder could not be shown (' + code + ')',
219
+ };
220
+ const state = this.read();
221
+ this.save({
222
+ ...state,
223
+ mappings: [
224
+ ...state.mappings.filter((m) => m.folder !== p.folder),
225
+ { folder: p.folder, share, readOnly: p.readOnly },
226
+ ],
227
+ });
228
+ return { ok: true, message: 'shown at ' + folder };
229
+ }
230
+ /** When the daemon starts: the recorded mappings this launch made, made again where they are gone. */
231
+ reapply() {
232
+ let done = 0;
233
+ for (const m of this.read().mappings) {
234
+ const folder = this.folderPath(m.folder);
235
+ if (this.o.bind.mapped(folder) === EXIT_OK)
236
+ continue;
237
+ fs.mkdirSync(path.dirname(folder), { recursive: true });
238
+ if (this.o.bind.map(folder, m.share, m.readOnly) === EXIT_OK)
239
+ done++;
240
+ else
241
+ this.log('live folder ' + folder + ' could not be shown again');
242
+ }
243
+ return done;
244
+ }
245
+ }
@@ -0,0 +1,4 @@
1
+ import { LiveFolderHandler } from './live-folders.js';
2
+ export declare const liveFolders: LiveFolderHandler;
3
+ /** At daemon start: the folder protected, the boot's key taken in, the recorded mappings made again. */
4
+ export declare function startLiveFolders(key: string | undefined): void;