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
@@ -9,7 +9,7 @@ import { cancelled } from '../app/kernel/index.js';
9
9
  import { webSession } from '../cli/services/index.js';
10
10
  import { fail } from '../core/util/index.js';
11
11
  import { cookieValue, csrfOk, hostAllowed, LiveHub, originAllowed, sameSecret, StaticFiles, WebAuth, WebServer, WebStore, } from '../host/web/index.js';
12
- import { encodeFrame, FrameReader, FrameType } from '../shared/pty/index.js';
12
+ import { encodeFrame, FrameReader, FrameType, MOUSE_OFF } from '../shared/pty/index.js';
13
13
  import { CSRF_HEADER, CSRF_VALUE, parseClientMessage } from '../shared/web/index.js';
14
14
  const TOKEN = 'a'.repeat(64);
15
15
  const ATTACH_TOKEN = 'SECRET-ATTACH-TOKEN';
@@ -249,7 +249,7 @@ export const webSuite = {
249
249
  }
250
250
  t.section('an unused server ends by itself (no process left behind)');
251
251
  {
252
- const idleServer = async (sandbox, limits) => {
252
+ const idleServer = async (sandbox, limits, sleepMs = 0) => {
253
253
  let ended = false;
254
254
  const idle = new WebServer({
255
255
  port: 0,
@@ -272,6 +272,16 @@ export const webSuite = {
272
272
  onStop: () => (ended = true),
273
273
  });
274
274
  await idle.start();
275
+ if (sleepMs) {
276
+ // The PC "sleeps": no timer runs, then the overdue check fires at once on waking.
277
+ const until = Date.now() + sleepMs;
278
+ while (Date.now() < until)
279
+ ;
280
+ await new Promise((r) => setTimeout(r, 20));
281
+ const endedOnWaking = ended;
282
+ await idle.stop();
283
+ return endedOnWaking;
284
+ }
275
285
  await new Promise((r) => setTimeout(r, 1500));
276
286
  await idle.stop();
277
287
  return ended;
@@ -279,6 +289,7 @@ export const webSuite = {
279
289
  t.check(await idleServer('stopped', { exit: 60_000, quiet: 200 }), 'no browser and no sandbox: it ends after the short wait');
280
290
  t.check(!(await idleServer('running', { exit: 60_000, quiet: 200 })), 'a sandbox runs: the short wait does not end it');
281
291
  t.check(await idleServer('running', { exit: 300, quiet: 200 }), 'no browser for the long wait: it ends even while a sandbox runs');
292
+ t.check(!(await idleServer('stopped', { exit: 60_000, quiet: 200 }, 1200)), 'after the PC slept, it does not end on waking: open pages get time to reconnect');
282
293
  }
283
294
  t.section('the server');
284
295
  const fake = new FakePtyHost();
@@ -373,6 +384,7 @@ export const webSuite = {
373
384
  tab.socket.send(Buffer.from('x'));
374
385
  await t.waitFor(() => tab.closeCode !== null, 5000);
375
386
  t.check(tab.texts.includes('{"t":"exit","code":3}'), 'EXIT reaches the browser with its code');
387
+ t.check(Buffer.concat(tab.binary).toString('latin1').endsWith(MOUSE_OFF), "before it, the browser's terminal is told to stop tracking the mouse (a drag selects text again)");
376
388
  t.equal(tab.closeCode, 1000, 'and the socket closes cleanly');
377
389
  const rude = new Tab(url, { Origin: origin, Cookie });
378
390
  await t.waitFor(() => rude.binary.length > 0, 5000);
@@ -1,3 +1,4 @@
1
1
  export * from './schema.js';
2
2
  export * from './run-dir.js';
3
3
  export * from './secrets.js';
4
+ export * from './live.js';
@@ -1,3 +1,4 @@
1
1
  export * from './schema.js';
2
2
  export * from './run-dir.js';
3
3
  export * from './secrets.js';
4
+ export * from './live.js';
@@ -0,0 +1,53 @@
1
+ import { z } from 'zod';
2
+ /** This tool's vSMB share names: never the 64-hex names Windows Sandbox gives its own shares. */
3
+ export declare const LIVE_SHARE_NAME: RegExp;
4
+ /** A folder name under C:\Workspace: no dots or spaces, so no "..", "foo." or "foo " aliases of other folders. */
5
+ export declare const LIVE_FOLDER_NAME: RegExp;
6
+ export declare const LIVE_KEY: RegExp;
7
+ export declare const LIVE_TOKEN: RegExp;
8
+ /** How old (or how far in the future) a request may be, by the guest's clock. */
9
+ export declare const LIVE_MAX_AGE_MS: number;
10
+ export declare const LivePayloadSchema: z.ZodObject<{
11
+ v: z.ZodLiteral<1>;
12
+ kind: z.ZodEnum<{
13
+ map: "map";
14
+ canary: "canary";
15
+ unmap: "unmap";
16
+ }>;
17
+ id: z.ZodString;
18
+ launchId: z.ZodString;
19
+ share: z.ZodOptional<z.ZodString>;
20
+ folder: z.ZodString;
21
+ readOnly: z.ZodBoolean;
22
+ createdAt: z.ZodString;
23
+ }, z.core.$strict>;
24
+ export type LivePayload = z.output<typeof LivePayloadSchema>;
25
+ export declare const LiveEnvelopeSchema: z.ZodObject<{
26
+ payload: z.ZodString;
27
+ mac: z.ZodString;
28
+ }, z.core.$strict>;
29
+ export type LiveEnvelope = z.output<typeof LiveEnvelopeSchema>;
30
+ /** run\live-replies\<id>.json (guest-written: checked, capped; the host only ever compares the token). */
31
+ export declare const LiveReplySchema: z.ZodObject<{
32
+ id: z.ZodString;
33
+ ok: z.ZodBoolean;
34
+ message: z.ZodString;
35
+ token: z.ZodOptional<z.ZodString>;
36
+ finishedAt: z.ZodString;
37
+ }, z.core.$strict>;
38
+ export type LiveReply = z.output<typeof LiveReplySchema>;
39
+ /** A signed request for `payload` (host side). */
40
+ export declare function signLive(key: string, payload: LivePayload): LiveEnvelope;
41
+ /** Why a request is refused (guest side), or the payload it carries. */
42
+ export declare function openLive(key: string | null, envelope: unknown, o: {
43
+ launchId: string;
44
+ now: number;
45
+ }): {
46
+ payload: LivePayload;
47
+ } | {
48
+ refused: string;
49
+ };
50
+ /** A new random share name (host side). */
51
+ export declare function newShareName(): string;
52
+ /** A new launch key (host side). */
53
+ export declare function newLiveKey(): string;
@@ -0,0 +1,92 @@
1
+ // Live folders through HCS (opt-in alpha, docs/design/hcs-live-folders.md): the host -> guest messages that make a
2
+ // folder the host shared into the running sandbox appear at C:\Workspace\<name>. Requests go in run\live, replies in
3
+ // run\live-replies. Every request is signed: {payload: "<the JSON text>", mac: HMAC-SHA256(key, payload)}; the MAC is
4
+ // over the exact payload text, never over a re-serialised object. The key is made per launch, travels to the sandbox
5
+ // only in the one-shot secrets file (other users of a shared sandbox may write run\, never secrets\) and is kept on the
6
+ // host sealed with DPAPI. The agent is an administrator in the sandbox, so it can read the key there: the signature
7
+ // keeps other users and stale or replayed files from driving the daemon, and the real boundary is that only the host
8
+ // decides what is shared (a guest can only show or hide what it was given).
9
+ import crypto from 'node:crypto';
10
+ import { z } from 'zod';
11
+ import { IdSchema } from './schema.js';
12
+ /** This tool's vSMB share names: never the 64-hex names Windows Sandbox gives its own shares. */
13
+ export const LIVE_SHARE_NAME = /^agentsbx-[0-9a-f]{8,32}$/;
14
+ /** A folder name under C:\Workspace: no dots or spaces, so no "..", "foo." or "foo " aliases of other folders. */
15
+ export const LIVE_FOLDER_NAME = /^[a-z0-9][a-z0-9_-]{0,63}$/;
16
+ export const LIVE_KEY = /^[0-9a-f]{64}$/;
17
+ export const LIVE_TOKEN = /^[0-9a-f]{32}$/;
18
+ /** How old (or how far in the future) a request may be, by the guest's clock. */
19
+ export const LIVE_MAX_AGE_MS = 2 * 60 * 1000;
20
+ const MAX_PAYLOAD = 4096;
21
+ export const LivePayloadSchema = z.strictObject({
22
+ v: z.literal(1),
23
+ /** canary: show the share, read token.txt, hide it again and reply with the token; map / unmap: the real folder. */
24
+ kind: z.enum(['canary', 'map', 'unmap']),
25
+ id: IdSchema,
26
+ launchId: z.string().min(1).max(64),
27
+ share: z.string().regex(LIVE_SHARE_NAME).optional(),
28
+ folder: z.string().regex(LIVE_FOLDER_NAME),
29
+ readOnly: z.boolean(),
30
+ createdAt: z.string().max(64),
31
+ });
32
+ export const LiveEnvelopeSchema = z.strictObject({
33
+ payload: z.string().min(2).max(MAX_PAYLOAD),
34
+ mac: z.string().regex(/^[0-9a-f]{64}$/),
35
+ });
36
+ /** run\live-replies\<id>.json (guest-written: checked, capped; the host only ever compares the token). */
37
+ export const LiveReplySchema = z.strictObject({
38
+ id: IdSchema,
39
+ ok: z.boolean(),
40
+ message: z.string().max(1000),
41
+ token: z.string().regex(LIVE_TOKEN).optional(),
42
+ finishedAt: z.string().max(64),
43
+ });
44
+ function mac(key, payload) {
45
+ return crypto.createHmac('sha256', Buffer.from(key, 'hex')).update(payload, 'utf8').digest('hex');
46
+ }
47
+ /** A signed request for `payload` (host side). */
48
+ export function signLive(key, payload) {
49
+ if (!LIVE_KEY.test(key))
50
+ throw new Error('not a live-folder key');
51
+ const text = JSON.stringify(LivePayloadSchema.parse(payload));
52
+ return { payload: text, mac: mac(key, text) };
53
+ }
54
+ /** Why a request is refused (guest side), or the payload it carries. */
55
+ export function openLive(key, envelope, o) {
56
+ if (!key || !LIVE_KEY.test(key))
57
+ return { refused: 'no live-folder key (live folders were off when the sandbox started)' };
58
+ const env = LiveEnvelopeSchema.safeParse(envelope);
59
+ if (!env.success)
60
+ return { refused: 'not a live-folder request' };
61
+ const want = Buffer.from(mac(key, env.data.payload), 'hex');
62
+ const got = Buffer.from(env.data.mac, 'hex');
63
+ if (want.length !== got.length || !crypto.timingSafeEqual(want, got))
64
+ return { refused: 'the signature does not match' };
65
+ let json;
66
+ try {
67
+ json = JSON.parse(env.data.payload);
68
+ }
69
+ catch {
70
+ return { refused: 'the signed payload is not JSON' };
71
+ }
72
+ const parsed = LivePayloadSchema.safeParse(json);
73
+ if (!parsed.success)
74
+ return { refused: 'the signed payload has the wrong shape' };
75
+ const p = parsed.data;
76
+ if (p.launchId !== o.launchId)
77
+ return { refused: 'it is for another launch' };
78
+ const at = Date.parse(p.createdAt);
79
+ if (!Number.isFinite(at) || Math.abs(o.now - at) > LIVE_MAX_AGE_MS)
80
+ return { refused: 'it is too old' };
81
+ if (p.kind !== 'unmap' && !p.share)
82
+ return { refused: 'it names no share' };
83
+ return { payload: p };
84
+ }
85
+ /** A new random share name (host side). */
86
+ export function newShareName() {
87
+ return 'agentsbx-' + crypto.randomBytes(8).toString('hex');
88
+ }
89
+ /** A new launch key (host side). */
90
+ export function newLiveKey() {
91
+ return crypto.randomBytes(32).toString('hex');
92
+ }
@@ -17,12 +17,23 @@ export declare class RunDir {
17
17
  readonly projectsDir: string;
18
18
  /** audit.processes: programs the sandbox started, in small .jsonl files the host takes in and deletes. */
19
19
  readonly auditDir: string;
20
+ /**
21
+ * Each running session's process tree, recorded by its ptyhost (guest only: the daemon ends what a ptyhost that was
22
+ * itself ended left running).
23
+ */
24
+ readonly treesDir: string;
20
25
  /** One-shot secrets the host hands over (see secrets.ts). Next to run, not in it: shared-use users can change run. */
21
26
  readonly secretsDir: string;
27
+ /** Live folders (live.ts): signed host requests, and the daemon's replies. */
28
+ readonly liveDir: string;
29
+ readonly liveRepliesDir: string;
22
30
  constructor(dataDir: string);
31
+ liveFile(id: string): string;
32
+ liveReplyFile(id: string): string;
23
33
  requestFile(id: string): string;
24
34
  responseFile(id: string): string;
25
35
  sessionFile(id: string): string;
36
+ treeFile(id: string): string;
26
37
  projectFile(name: string): string;
27
38
  secretFile(id: string): string;
28
39
  readSandboxStatus(): SandboxStatus | null;
@@ -44,8 +44,16 @@ export class RunDir {
44
44
  projectsDir;
45
45
  /** audit.processes: programs the sandbox started, in small .jsonl files the host takes in and deletes. */
46
46
  auditDir;
47
+ /**
48
+ * Each running session's process tree, recorded by its ptyhost (guest only: the daemon ends what a ptyhost that was
49
+ * itself ended left running).
50
+ */
51
+ treesDir;
47
52
  /** One-shot secrets the host hands over (see secrets.ts). Next to run, not in it: shared-use users can change run. */
48
53
  secretsDir;
54
+ /** Live folders (live.ts): signed host requests, and the daemon's replies. */
55
+ liveDir;
56
+ liveRepliesDir;
49
57
  constructor(dataDir) {
50
58
  this.root = path.join(dataDir, 'run');
51
59
  this.sandboxFile = path.join(this.root, 'sandbox.json');
@@ -57,7 +65,16 @@ export class RunDir {
57
65
  this.sessionsDir = path.join(this.root, 'sessions');
58
66
  this.projectsDir = path.join(this.root, 'projects');
59
67
  this.auditDir = path.join(this.root, 'audit');
68
+ this.treesDir = path.join(this.root, 'trees');
60
69
  this.secretsDir = path.join(dataDir, 'secrets');
70
+ this.liveDir = path.join(this.root, 'live');
71
+ this.liveRepliesDir = path.join(this.root, 'live-replies');
72
+ }
73
+ liveFile(id) {
74
+ return path.join(this.liveDir, safeId(id) + '.json');
75
+ }
76
+ liveReplyFile(id) {
77
+ return path.join(this.liveRepliesDir, safeId(id) + '.json');
61
78
  }
62
79
  requestFile(id) {
63
80
  return path.join(this.requestsDir, safeId(id) + '.json');
@@ -68,6 +85,9 @@ export class RunDir {
68
85
  sessionFile(id) {
69
86
  return path.join(this.sessionsDir, safeId(id) + '.json');
70
87
  }
88
+ treeFile(id) {
89
+ return path.join(this.treesDir, safeId(id) + '.json');
90
+ }
71
91
  projectFile(name) {
72
92
  return path.join(this.projectsDir, safeId(name) + '.json');
73
93
  }
@@ -10,6 +10,7 @@ export declare const SecretsFileSchema: z.ZodObject<{
10
10
  }, z.core.$strict>>;
11
11
  wireguardConfig: z.ZodOptional<z.ZodString>;
12
12
  githubToken: z.ZodOptional<z.ZodString>;
13
+ liveFoldersKey: z.ZodOptional<z.ZodString>;
13
14
  }, z.core.$strict>;
14
15
  export type SecretsFile = z.output<typeof SecretsFileSchema>;
15
16
  interface WithHarnesses {
@@ -28,6 +28,11 @@ export const SecretsFileSchema = z.strictObject({
28
28
  .optional(),
29
29
  /** The GitHub token you saved for sandboxes (github.shareToken), only with git access "full". */
30
30
  githubToken: GithubTokenSchema.optional(),
31
+ /** projects.liveFolders "hcs": this launch's key for signed live-folder requests (live.ts). */
32
+ liveFoldersKey: z
33
+ .string()
34
+ .regex(/^[0-9a-f]{64}$/)
35
+ .optional(),
31
36
  });
32
37
  /** The environment a harness gets: its own, then the values that travelled in the secrets file. */
33
38
  const withEnvironment = (harness, environment) => (environment ? { ...harness, environment: { ...harness.environment, ...environment } } : harness);
@@ -11,7 +11,7 @@ export declare const WORKSPACES_GUEST = "C:\\Workspace";
11
11
  */
12
12
  export declare function autoGuestFolder(hostDir: string): string;
13
13
  /** How a project got into the sandbox. */
14
- export type Mechanism = 'launch' | 'root' | 'boot' | 'wsb';
14
+ export type Mechanism = 'launch' | 'root' | 'boot' | 'wsb' | 'hcs';
15
15
  /** One project the running sandbox works on. */
16
16
  export interface ProjectEntry {
17
17
  name: string;
@@ -65,6 +65,11 @@ export declare function usableRoots(roots: string[], exists: (dir: string) => bo
65
65
  * when the sandbox can show it there, else C:\Workspace\<name>. -2, -3, ... when that overlaps a folder in `taken`.
66
66
  */
67
67
  export declare function ownGuestFolder(hostDir: string, mirror: boolean, taken?: string[]): string;
68
+ /**
69
+ * Where a live folder (projects.liveFolders "hcs") appears: C:\Workspace\<name>, the name in the strict form the guest
70
+ * daemon accepts (lower-case letters, digits, '-' and '_'; no dots or spaces), -2, -3, ... when it overlaps `taken`.
71
+ */
72
+ export declare function liveGuestFolder(hostDir: string, taken?: string[]): string;
68
73
  /**
69
74
  * Guest folders for the project roots (C:\Projects\<name>, unique; with guestWorkspace "mirror" at its PC path when
70
75
  * the sandbox can show it there) and the projects mapped at boot (ownGuestFolder, unique against `taken` and each
@@ -4,7 +4,8 @@
4
4
  // - a folder inside a project root (projects.roots: whole folders of projects, mapped at every start), including
5
5
  // projects created after boot;
6
6
  // - projects mapped at boot because they were in use when the sandbox was restarted to add them;
7
- // - a folder shared into the running sandbox with `wsb share` (Windows 11 24H2+, untested).
7
+ // - a folder shared into the running sandbox with `wsb share` (Windows 11 24H2+, untested);
8
+ // - a folder shared into the running sandbox through HCS (projects.liveFolders "hcs", alpha, untested on a PC).
8
9
  // A project is at the same place inside the sandbox whichever project started it (git worktrees and Claude's project
9
10
  // folders store absolute paths):
10
11
  // - a project inside a root is always under the root's folder, C:\Projects\<root>\<name> (also as the launch folder);
@@ -115,6 +116,25 @@ export function ownGuestFolder(hostDir, mirror, taken = []) {
115
116
  if (free(base + '-' + i))
116
117
  return base + '-' + i;
117
118
  }
119
+ /**
120
+ * Where a live folder (projects.liveFolders "hcs") appears: C:\Workspace\<name>, the name in the strict form the guest
121
+ * daemon accepts (lower-case letters, digits, '-' and '_'; no dots or spaces), -2, -3, ... when it overlaps `taken`.
122
+ */
123
+ export function liveGuestFolder(hostDir, taken = []) {
124
+ const base = path.win32
125
+ .basename(resolveDir(hostDir))
126
+ .toLowerCase()
127
+ .replace(/[^a-z0-9_-]+/g, '-')
128
+ .replace(/^[-_]+|-+$/g, '')
129
+ .slice(0, 56) || 'project';
130
+ const free = (guest) => !taken.some((t) => isWithin(t, guest) || isWithin(guest, t));
131
+ const at = (name) => WORKSPACES_GUEST + '\\' + name;
132
+ if (free(at(base)))
133
+ return at(base);
134
+ for (let i = 2;; i++)
135
+ if (free(at(base + '-' + i)))
136
+ return at(base + '-' + i);
137
+ }
118
138
  /**
119
139
  * Guest folders for the project roots (C:\Projects\<name>, unique; with guestWorkspace "mirror" at its PC path when
120
140
  * the sandbox can show it there) and the projects mapped at boot (ownGuestFolder, unique against `taken` and each
@@ -1,6 +1,8 @@
1
1
  export { FrameType, HelloFlag, PING_INTERVAL_MS, type Frame, DETACH_KEY, encodeFrame, helloFrame, resizeFrame, MAX_FRAME_BYTES, FrameReader, } from './protocol.js';
2
2
  export { type Endpoint, connect, sendControl, type Viewers, queryViewers } from './client.js';
3
- export { type ClientResult, MOUSE_OFF, fixWindowsKeys, runTerminalClient } from './terminal.js';
3
+ export { type ClientResult, MOUSE_OFF, runTerminalClient } from './terminal.js';
4
+ export { fixWindowsKeys, KeyGate } from './key-gate.js';
5
+ export { MouseModes } from './mouse-modes.js';
4
6
  export { JOIN_MS, InputJoiner } from './input-joiner.js';
5
7
  export { CTRL_C_CONFIRM_MS, CTRL_C_HINT, CtrlCGuard } from './ctrl-c.js';
6
8
  export { type VtInput, enableVtInput } from './console-input.js';
@@ -1,6 +1,8 @@
1
1
  export { FrameType, HelloFlag, PING_INTERVAL_MS, DETACH_KEY, encodeFrame, helloFrame, resizeFrame, MAX_FRAME_BYTES, FrameReader, } from './protocol.js';
2
2
  export { connect, sendControl, queryViewers } from './client.js';
3
- export { MOUSE_OFF, fixWindowsKeys, runTerminalClient } from './terminal.js';
3
+ export { MOUSE_OFF, runTerminalClient } from './terminal.js';
4
+ export { fixWindowsKeys, KeyGate } from './key-gate.js';
5
+ export { MouseModes } from './mouse-modes.js';
4
6
  export { JOIN_MS, InputJoiner } from './input-joiner.js';
5
7
  export { CTRL_C_CONFIRM_MS, CTRL_C_HINT, CtrlCGuard } from './ctrl-c.js';
6
8
  export { enableVtInput } from './console-input.js';
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Node on Windows reads keys through the console API, where Backspace is ^H and Ctrl+Backspace is DEL: the reverse of
3
+ * what terminals send. ConPTY reads ^H as Ctrl+Backspace (delete a word), so swap them back.
4
+ */
5
+ export declare function fixWindowsKeys(chunk: Buffer): Buffer;
6
+ /**
7
+ * Typed input before the console's mode is settled. Once ENABLE_VIRTUAL_TERMINAL_INPUT is on (enableVtInput), the
8
+ * console hands over the terminal's own bytes (DEL for Backspace, ^H for Ctrl+Backspace) and nothing may be swapped;
9
+ * only when it could not be turned on are keys read as console key records and swapped (fixWindowsKeys). Turning it
10
+ * on takes a moment (a PowerShell helper), and input read meanwhile used to be swapped although it was the terminal's
11
+ * own: a Backspace pressed then reached the agent as ^H and deleted a word. So input waits here until the mode is
12
+ * known.
13
+ */
14
+ export declare class KeyGate {
15
+ private pending;
16
+ private decided;
17
+ private swap;
18
+ /** windowsConsole: input comes from a Windows console in raw mode (its mode is decided later); else it goes as is. */
19
+ constructor(windowsConsole: boolean);
20
+ /** The bytes to send now (empty while the mode is not known yet). */
21
+ push(data: Buffer): Buffer;
22
+ /** The console's mode is known: what was held, ready to send. Later calls only change how new input is read. */
23
+ decide(vtInput: boolean): Buffer;
24
+ }
@@ -0,0 +1,45 @@
1
+ // Windows: holds what you type until the client knows how the console hands keys over, so Backspace stays Backspace.
2
+ /**
3
+ * Node on Windows reads keys through the console API, where Backspace is ^H and Ctrl+Backspace is DEL: the reverse of
4
+ * what terminals send. ConPTY reads ^H as Ctrl+Backspace (delete a word), so swap them back.
5
+ */
6
+ export function fixWindowsKeys(chunk) {
7
+ if (!chunk.includes(0x08) && !chunk.includes(0x7f))
8
+ return chunk;
9
+ return Buffer.from(chunk.map((b) => (b === 0x08 ? 0x7f : b === 0x7f ? 0x08 : b)));
10
+ }
11
+ /**
12
+ * Typed input before the console's mode is settled. Once ENABLE_VIRTUAL_TERMINAL_INPUT is on (enableVtInput), the
13
+ * console hands over the terminal's own bytes (DEL for Backspace, ^H for Ctrl+Backspace) and nothing may be swapped;
14
+ * only when it could not be turned on are keys read as console key records and swapped (fixWindowsKeys). Turning it
15
+ * on takes a moment (a PowerShell helper), and input read meanwhile used to be swapped although it was the terminal's
16
+ * own: a Backspace pressed then reached the agent as ^H and deleted a word. So input waits here until the mode is
17
+ * known.
18
+ */
19
+ export class KeyGate {
20
+ pending = [];
21
+ decided;
22
+ swap = false;
23
+ /** windowsConsole: input comes from a Windows console in raw mode (its mode is decided later); else it goes as is. */
24
+ constructor(windowsConsole) {
25
+ this.decided = !windowsConsole;
26
+ }
27
+ /** The bytes to send now (empty while the mode is not known yet). */
28
+ push(data) {
29
+ if (!data.length)
30
+ return data;
31
+ if (!this.decided) {
32
+ this.pending.push(data);
33
+ return Buffer.alloc(0);
34
+ }
35
+ return this.swap ? fixWindowsKeys(data) : data;
36
+ }
37
+ /** The console's mode is known: what was held, ready to send. Later calls only change how new input is read. */
38
+ decide(vtInput) {
39
+ this.swap = !vtInput;
40
+ this.decided = true;
41
+ const held = Buffer.concat(this.pending);
42
+ this.pending = [];
43
+ return this.swap ? fixWindowsKeys(held) : held;
44
+ }
45
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Follows the private modes a program turns on and off in its output (DECSET / DECRST, kept across chunks: mouse
3
+ * tracking 9, 1000, 1002, 1003, the encodings 1005 and 1015, bracketed paste 2004 ...) and drops the mouse reports it
4
+ * did not ask for: none without tracking, motion only with 1002 (while a button is held: a drag) or 1003 (every move).
5
+ * Your terminal reports what the program's modes say; what is dropped is what was still on its way when the program
6
+ * turned tracking off, exited or restarted, and would reach it as text.
7
+ */
8
+ export declare class MouseModes {
9
+ private readonly on;
10
+ /** 0 text, 1 after ESC, 2 after ESC[, 3 in ESC[? parameters, 4 after ESC[! */
11
+ private state;
12
+ private params;
13
+ /** Reads the program's output (after OutputFilter): the mode changes in it. */
14
+ watch(output: Buffer): void;
15
+ /** The program is gone (exited or restarting): its modes went with it. */
16
+ reset(): void;
17
+ /** True while the program tracks the mouse in any way. */
18
+ tracking(): boolean;
19
+ /** True while the program has a mode on (2004: bracketed paste). */
20
+ has(mode: number): boolean;
21
+ /** The mode numbers on now (for the report and tests). */
22
+ modes(): number[];
23
+ /** `input` without the mouse reports the program has not asked for. Sequences must be whole (InputJoiner). */
24
+ filter(input: Buffer): Buffer;
25
+ private allows;
26
+ private apply;
27
+ }
@@ -0,0 +1,155 @@
1
+ // The mouse tracking a program asked for, read from its output, and the mouse reports it may receive.
2
+ /** X10 compatibility (press only), normal (press/release/wheel), button-event (+ drag), any-event (+ every motion). */
3
+ const TRACKING = [9, 1000, 1002, 1003];
4
+ /** Longest DECSET / DECRST parameter list followed; anything longer is not a mode change. */
5
+ const MAX_PARAMS = 64;
6
+ /** Button code bits of a mouse report: motion (drag or move), and "no button" in the low two bits. */
7
+ const MOTION = 32;
8
+ const NO_BUTTON = 3;
9
+ /**
10
+ * Follows the private modes a program turns on and off in its output (DECSET / DECRST, kept across chunks: mouse
11
+ * tracking 9, 1000, 1002, 1003, the encodings 1005 and 1015, bracketed paste 2004 ...) and drops the mouse reports it
12
+ * did not ask for: none without tracking, motion only with 1002 (while a button is held: a drag) or 1003 (every move).
13
+ * Your terminal reports what the program's modes say; what is dropped is what was still on its way when the program
14
+ * turned tracking off, exited or restarted, and would reach it as text.
15
+ */
16
+ export class MouseModes {
17
+ on = new Set();
18
+ /** 0 text, 1 after ESC, 2 after ESC[, 3 in ESC[? parameters, 4 after ESC[! */
19
+ state = 0;
20
+ params = '';
21
+ /** Reads the program's output (after OutputFilter): the mode changes in it. */
22
+ watch(output) {
23
+ for (const b of output) {
24
+ if (b === 0x1b) {
25
+ this.state = 1;
26
+ continue;
27
+ }
28
+ if (this.state === 1) {
29
+ // RIS (ESC c), a full reset, turns every mode off.
30
+ if (b === 0x63)
31
+ this.on.clear();
32
+ this.state = b === 0x5b ? 2 : 0;
33
+ }
34
+ else if (this.state === 2) {
35
+ // DECSTR (ESC[!p), a soft reset, turns mouse tracking off too.
36
+ this.state = b === 0x3f ? 3 : b === 0x21 ? 4 : 0;
37
+ this.params = '';
38
+ }
39
+ else if (this.state === 4) {
40
+ if (b === 0x70)
41
+ this.on.clear();
42
+ this.state = 0;
43
+ }
44
+ else if (this.state === 3) {
45
+ if ((b >= 0x30 && b <= 0x39) || b === 0x3b) {
46
+ this.params += String.fromCharCode(b);
47
+ if (this.params.length > MAX_PARAMS)
48
+ this.state = 0;
49
+ }
50
+ else {
51
+ if (b === 0x68 || b === 0x6c)
52
+ this.apply(this.params, b === 0x68);
53
+ this.state = 0;
54
+ }
55
+ }
56
+ }
57
+ }
58
+ /** The program is gone (exited or restarting): its modes went with it. */
59
+ reset() {
60
+ this.on.clear();
61
+ this.state = 0;
62
+ this.params = '';
63
+ }
64
+ /** True while the program tracks the mouse in any way. */
65
+ tracking() {
66
+ return TRACKING.some((m) => this.on.has(m));
67
+ }
68
+ /** True while the program has a mode on (2004: bracketed paste). */
69
+ has(mode) {
70
+ return this.on.has(mode);
71
+ }
72
+ /** The mode numbers on now (for the report and tests). */
73
+ modes() {
74
+ return [...this.on].sort((a, b) => a - b);
75
+ }
76
+ /** `input` without the mouse reports the program has not asked for. Sequences must be whole (InputJoiner). */
77
+ filter(input) {
78
+ if (!input.includes(0x1b))
79
+ return input;
80
+ const parts = [];
81
+ let from = 0;
82
+ let i = input.indexOf(0x1b);
83
+ while (i !== -1) {
84
+ const report = mouseReport(input, i, this.on.has(1005), this.on.has(1015));
85
+ if (report && !this.allows(report.button)) {
86
+ parts.push(input.subarray(from, i));
87
+ from = i + report.length;
88
+ i = input.indexOf(0x1b, from);
89
+ }
90
+ else
91
+ i = input.indexOf(0x1b, i + 1);
92
+ }
93
+ if (from === 0)
94
+ return input;
95
+ parts.push(input.subarray(from));
96
+ return Buffer.concat(parts);
97
+ }
98
+ allows(button) {
99
+ if (!this.tracking())
100
+ return false;
101
+ if ((button & MOTION) === 0)
102
+ return true;
103
+ if (this.on.has(1003))
104
+ return true;
105
+ return this.on.has(1002) && (button & NO_BUTTON) !== NO_BUTTON;
106
+ }
107
+ apply(params, set) {
108
+ for (const p of params.split(';')) {
109
+ const mode = Number(p);
110
+ if (!p || !Number.isSafeInteger(mode))
111
+ continue;
112
+ if (set)
113
+ this.on.add(mode);
114
+ else
115
+ this.on.delete(mode);
116
+ }
117
+ }
118
+ }
119
+ /**
120
+ * The mouse report starting at `at`: SGR (ESC[<b;x;yM or m, 1006), urxvt (ESC[b;x;yM, 1015, b offset by 32; only while
121
+ * 1015 is on) or X10
122
+ * (ESC[M and three bytes; not with UTF-8 coordinates, 1005, where its length cannot be told). Null when there is none.
123
+ */
124
+ function mouseReport(data, at, utf8, urxvt) {
125
+ if (data[at + 1] !== 0x5b)
126
+ return null;
127
+ const sgr = data[at + 2] === 0x3c;
128
+ if (sgr || (urxvt && data[at + 2] >= 0x30 && data[at + 2] <= 0x39)) {
129
+ let i = at + (sgr ? 3 : 2);
130
+ let digits = '';
131
+ let fields = 1;
132
+ for (; i < data.length && i - at < 32; i++) {
133
+ const b = data[i];
134
+ if (b >= 0x30 && b <= 0x39) {
135
+ if (fields === 1)
136
+ digits += String.fromCharCode(b);
137
+ }
138
+ else if (b === 0x3b)
139
+ fields++;
140
+ else
141
+ break;
142
+ }
143
+ if (i >= data.length || fields !== 3 || !digits)
144
+ return null;
145
+ if (sgr ? data[i] !== 0x4d && data[i] !== 0x6d : data[i] !== 0x4d)
146
+ return null;
147
+ const button = Number(digits) - (sgr ? 0 : 32);
148
+ return button >= 0 ? { button, length: i - at + 1 } : null;
149
+ }
150
+ if (data[at + 2] === 0x4d && !utf8 && data.length - at >= 6) {
151
+ const button = data[at + 3] - 32;
152
+ return button >= 0 ? { button, length: 6 } : null;
153
+ }
154
+ return null;
155
+ }
@@ -13,11 +13,6 @@ export type ClientResult = {
13
13
  * urxvt).
14
14
  */
15
15
  export declare const MOUSE_OFF = "\u001B[?1000l\u001B[?1002l\u001B[?1003l\u001B[?1005l\u001B[?1006l\u001B[?1015l";
16
- /**
17
- * Node on Windows reads keys through the console API, where Backspace is ^H and Ctrl+Backspace is DEL: the reverse of
18
- * what terminals send. ConPTY reads ^H as Ctrl+Backspace (delete a word), so swap them back.
19
- */
20
- export declare function fixWindowsKeys(chunk: Buffer): Buffer;
21
16
  /**
22
17
  * Bridges this process's terminal to the remote program until it exits, the connection drops, or the user presses
23
18
  * the detach key. Restores the terminal before resolving.