windows-development-sandbox 1.4.0 → 1.4.1
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.
- package/.sandbox-config.example.json +2 -1
- package/config.example.json +1 -0
- package/dist/cli/boot-progress.d.ts +22 -0
- package/dist/cli/boot-progress.js +59 -0
- package/dist/cli/commands/launch.d.ts +5 -2
- package/dist/cli/commands/launch.js +77 -22
- package/dist/cli/commands/maintenance.js +36 -3
- package/dist/cli/commands/project.d.ts +10 -6
- package/dist/cli/commands/project.js +42 -3
- package/dist/cli/commands/restart.js +17 -7
- package/dist/cli/commands/sessions.js +2 -1
- package/dist/cli/manifest.js +1 -1
- package/dist/cli/progress.d.ts +15 -0
- package/dist/cli/progress.js +52 -0
- package/dist/core/config.js +1 -1
- package/dist/core/local-config.d.ts +16 -1
- package/dist/core/local-config.js +15 -9
- package/dist/core/project-policy.d.ts +50 -3
- package/dist/core/project-policy.js +90 -4
- package/dist/core/schema/config.d.ts +3 -2
- package/dist/core/schema/config.js +1 -1
- package/dist/core/schema/projects.d.ts +1 -0
- package/dist/core/schema/projects.js +1 -0
- package/dist/core/schema/sandbox.d.ts +1 -1
- package/dist/core/schema/sandbox.js +1 -1
- package/dist/core/types.d.ts +18 -1
- package/dist/core/workspace.d.ts +21 -4
- package/dist/core/workspace.js +87 -26
- package/dist/guest/env.d.ts +6 -2
- package/dist/guest/env.js +15 -3
- package/dist/guest/main.js +5 -1
- package/dist/guest/toolchains/index.js +29 -3
- package/dist/host/channel.d.ts +2 -2
- package/dist/host/channel.js +3 -2
- package/dist/host/generate.d.ts +1 -1
- package/dist/host/generate.js +13 -6
- package/dist/host/join.d.ts +7 -0
- package/dist/host/join.js +23 -9
- package/dist/host/launch/mappings.d.ts +7 -2
- package/dist/host/launch/mappings.js +64 -18
- package/dist/host/launch/projects.d.ts +2 -0
- package/dist/host/launch/projects.js +11 -5
- package/dist/host/projects.d.ts +25 -2
- package/dist/host/projects.js +54 -5
- package/dist/runtime/daemon.js +71 -9
- package/dist/runtime/db.js +33 -3
- package/dist/runtime/main.js +191 -30
- package/dist/runtime/netproxy.js +15 -3
- package/dist/runtime/view.js +262 -128
- package/dist/selftest/channel.test.js +38 -2
- package/dist/selftest/fixtures.d.ts +2 -1
- package/dist/selftest/index.js +2 -1
- package/dist/selftest/live-terminal.test.d.ts +2 -0
- package/dist/selftest/live-terminal.test.js +169 -0
- package/dist/selftest/local-config.test.js +28 -3
- package/dist/selftest/mirror.test.js +36 -7
- package/dist/selftest/projects.test.js +76 -7
- package/dist/shared/console-input.d.ts +11 -0
- package/dist/shared/console-input.js +61 -0
- package/dist/shared/download.d.ts +3 -0
- package/dist/shared/download.js +25 -2
- package/dist/shared/guest-paths.d.ts +27 -0
- package/dist/shared/guest-paths.js +68 -0
- package/dist/shared/projects.d.ts +27 -4
- package/dist/shared/projects.js +72 -13
- package/dist/shared/pty-client.d.ts +17 -0
- package/dist/shared/pty-client.js +65 -8
- package/dist/shared/schema.d.ts +24 -0
- package/dist/shared/schema.js +12 -0
- package/package.json +1 -1
- package/sandbox/ptyhost.cs +60 -3
- package/schemas/config.schema.json +15 -1
- package/schemas/sandbox-config.schema.json +4 -3
|
@@ -17,6 +17,9 @@ export interface Checksums {
|
|
|
17
17
|
}
|
|
18
18
|
/** downloads.requireChecksum: from then on download() refuses anything it has no expected hash for. The guest sets it from session.json. */
|
|
19
19
|
export declare const setChecksumRequired: (on: boolean) => void;
|
|
20
|
+
/** Who wants coarse download progress: called as a download starts (in the caller's async context), returns its reporter. */
|
|
21
|
+
export type DownloadWatcher = () => ((fraction: number) => void) | null;
|
|
22
|
+
export declare const watchDownloads: (watcher: DownloadWatcher | null) => void;
|
|
20
23
|
/**
|
|
21
24
|
* Streams a download to disk, hashing as it goes: big archives (PostgreSQL is ~330 MB) never sit in memory, and the
|
|
22
25
|
* event loop stays free so other toolchains keep downloading meanwhile. A mismatch (or any failure) leaves no file behind.
|
package/dist/shared/download.js
CHANGED
|
@@ -46,6 +46,8 @@ export const fetchText = async (url) => await (await fetchRetry(url)).text();
|
|
|
46
46
|
let checksumRequired = false;
|
|
47
47
|
/** downloads.requireChecksum: from then on download() refuses anything it has no expected hash for. The guest sets it from session.json. */
|
|
48
48
|
export const setChecksumRequired = (on) => { checksumRequired = on; };
|
|
49
|
+
let downloadWatcher = null;
|
|
50
|
+
export const watchDownloads = (watcher) => { downloadWatcher = watcher; };
|
|
49
51
|
/**
|
|
50
52
|
* Streams a download to disk, hashing as it goes: big archives (PostgreSQL is ~330 MB) never sit in memory, and the
|
|
51
53
|
* event loop stays free so other toolchains keep downloading meanwhile. A mismatch (or any failure) leaves no file behind.
|
|
@@ -64,8 +66,29 @@ export const download = async (url, destination, expected = null) => {
|
|
|
64
66
|
const want = sums[kind]?.trim().toLowerCase();
|
|
65
67
|
return want ? [{ want, hash: crypto.createHash(kind) }] : [];
|
|
66
68
|
});
|
|
67
|
-
|
|
68
|
-
|
|
69
|
+
// Progress for the boot screen: every 10% or 5 s at most, never per chunk; called here so it knows who is downloading.
|
|
70
|
+
const size = Number(response.headers.get('content-length')) || 0;
|
|
71
|
+
const report = size > 0 ? downloadWatcher?.() ?? null : null;
|
|
72
|
+
let received = 0;
|
|
73
|
+
let tenth = 0;
|
|
74
|
+
let reportedAt = Date.now();
|
|
75
|
+
const tap = new Transform({ transform(chunk, _encoding, done) {
|
|
76
|
+
for (const c of checks)
|
|
77
|
+
c.hash.update(chunk);
|
|
78
|
+
if (report) {
|
|
79
|
+
received += chunk.length;
|
|
80
|
+
const now = Math.floor((received * 10) / size);
|
|
81
|
+
if (now !== tenth || Date.now() - reportedAt > 5000) {
|
|
82
|
+
tenth = now;
|
|
83
|
+
reportedAt = Date.now();
|
|
84
|
+
try {
|
|
85
|
+
report(Math.min(1, received / size));
|
|
86
|
+
}
|
|
87
|
+
catch { /* progress never breaks a download */ }
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
done(null, chunk);
|
|
91
|
+
} });
|
|
69
92
|
// Written to <destination>.part and moved into place only once complete and verified, so an interrupted download never
|
|
70
93
|
// leaves a partial file under the real name.
|
|
71
94
|
const partial = destination + '.part';
|
|
@@ -11,6 +11,33 @@ export declare const guestDrive: (guest: string) => string | null;
|
|
|
11
11
|
* no '..' or empty segments, no characters Windows rejects in paths.
|
|
12
12
|
*/
|
|
13
13
|
export declare const isValidGuestPath: (guest: string) => boolean;
|
|
14
|
+
/** Where a mapping lands when the path it asks for (or the PC path) cannot be used inside the sandbox. */
|
|
15
|
+
export declare const MAPPED_ROOT = "C:\\Mapped";
|
|
16
|
+
/** A guest path as typed, tidied: '/' -> '\', no doubled or trailing backslashes, upper-case drive letter. */
|
|
17
|
+
export declare const tidyGuestPath: (guest: string) => string;
|
|
18
|
+
/** Why a guest path cannot be used as it is (null: it can). Short, plain English. */
|
|
19
|
+
export declare const guestPathProblem: (guest: string) => string | null;
|
|
20
|
+
/** C:\Mapped\<drive>\<rest of path>: a safe place for a folder whose own path cannot be used (odd characters made safe). */
|
|
21
|
+
export declare const mappedFallback: (path: string) => string;
|
|
22
|
+
/** Two guest folders overlap where they really are (F:\x is C:\Mirror\F\x): the same, or one inside the other. */
|
|
23
|
+
export declare const guestOverlaps: (a: string, b: string) => boolean;
|
|
24
|
+
/**
|
|
25
|
+
* Where an extra folder appears in the sandbox. `wanted` is the guest path asked for (or the one derived from the PC
|
|
26
|
+
* path); when the sandbox cannot use it (reserved, invalid, or overlapping a folder in `taken`), the folder goes to
|
|
27
|
+
* C:\Mapped\<drive>\<path> instead (made unique with -2, -3 ...), and `note` says where and why.
|
|
28
|
+
*/
|
|
29
|
+
export declare const placeGuest: (host: string, wanted: string, taken: {
|
|
30
|
+
guest: string;
|
|
31
|
+
what: string;
|
|
32
|
+
}[]) => {
|
|
33
|
+
guest: string;
|
|
34
|
+
note: string | null;
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* The guest path a mapping asks for when it gives none: a folder inside the project at the same place in the project's
|
|
38
|
+
* guest folder, any other folder at the same path as on this PC (D: to Z: through C:\Mirror and subst).
|
|
39
|
+
*/
|
|
40
|
+
export declare const deriveGuest: (host: string, projectHost: string, projectGuest: string) => string;
|
|
14
41
|
/** Where a guest path really is inside the sandbox (the .wsb SandboxFolder): C: paths as-is, F:\x -> C:\Mirror\F\x. */
|
|
15
42
|
export declare const sandboxFolder: (guest: string) => string;
|
|
16
43
|
export interface SubstDrive {
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// guest path like F:\SteamLibrary\x is mapped to C:\Mirror\F\SteamLibrary\x and, at boot (bootstrap.ps1, before
|
|
3
3
|
// anything else uses the workspace), `subst F: C:\Mirror\F` makes it appear at F:\SteamLibrary\x.
|
|
4
4
|
// Pure functions only: used by the schema, the host (.wsb, session.json) and history sync.
|
|
5
|
+
import { win32 } from 'node:path';
|
|
5
6
|
/** Guest folders no mapping may use: the OS, the sandbox's own folders and the mirror root. */
|
|
6
7
|
export const RESERVED_GUEST = /^C:\\(?:Windows|Program Files|Program Files \(x86\)|ProgramData|Users|AgentLaunch|AgentData|HostTools|nvm|Mirror)(?:\\|$)/i;
|
|
7
8
|
/** Where folders for other drive letters really live inside the sandbox: C:\Mirror\<letter>. */
|
|
@@ -31,6 +32,73 @@ export const isValidGuestPath = (guest) => {
|
|
|
31
32
|
return !RESERVED_GUEST.test('C:\\' + rest);
|
|
32
33
|
return MIRROR_LETTER.test(letter);
|
|
33
34
|
};
|
|
35
|
+
/** Where a mapping lands when the path it asks for (or the PC path) cannot be used inside the sandbox. */
|
|
36
|
+
export const MAPPED_ROOT = 'C:\\Mapped';
|
|
37
|
+
/** A guest path as typed, tidied: '/' -> '\', no doubled or trailing backslashes, upper-case drive letter. */
|
|
38
|
+
export const tidyGuestPath = (guest) => {
|
|
39
|
+
const p = guest.trim().replace(/\//g, '\\').replace(/\\{2,}/g, '\\').replace(/\\+$/, '');
|
|
40
|
+
return /^[a-z]:/.test(p) ? p[0].toUpperCase() + p.slice(1) : p;
|
|
41
|
+
};
|
|
42
|
+
/** Why a guest path cannot be used as it is (null: it can). Short, plain English. */
|
|
43
|
+
export const guestPathProblem = (guest) => {
|
|
44
|
+
if (isValidGuestPath(guest))
|
|
45
|
+
return null;
|
|
46
|
+
const letter = guestDrive(guest);
|
|
47
|
+
if (letter === 'C' && RESERVED_GUEST.test(guest))
|
|
48
|
+
return guest + ' is a folder the sandbox keeps for itself (Windows, Program Files, Users, AgentData, ...)';
|
|
49
|
+
if (letter && !MIRROR_LETTER.test(letter) && letter !== 'C')
|
|
50
|
+
return 'the sandbox cannot show drive ' + letter + ':';
|
|
51
|
+
if (letter && /^[A-Za-z]:\\?$/.test(guest))
|
|
52
|
+
return guest + ' is a whole drive';
|
|
53
|
+
return guest + ' is not a folder path the sandbox can use';
|
|
54
|
+
};
|
|
55
|
+
/** C:\Mapped\<drive>\<rest of path>: a safe place for a folder whose own path cannot be used (odd characters made safe). */
|
|
56
|
+
export const mappedFallback = (path) => {
|
|
57
|
+
const m = /^([A-Za-z]):\\?(.*)$/.exec(tidyGuestPath(path)) ?? /^\\\\(.*)$/.exec(path);
|
|
58
|
+
const letter = m && m.length === 3 ? m[1].toUpperCase() : 'UNC';
|
|
59
|
+
const rest = (m ? m[m.length - 1] : path).split(/[\\/]+/).map((s) => s.replace(/[<>:"|?*\x00-\x1f]/g, '_').replace(/[. ]+$/, '')).filter((s) => s !== '' && s !== '..').join('\\');
|
|
60
|
+
return MAPPED_ROOT + '\\' + letter + (rest ? '\\' + rest : '');
|
|
61
|
+
};
|
|
62
|
+
/** Two guest folders overlap where they really are (F:\x is C:\Mirror\F\x): the same, or one inside the other. */
|
|
63
|
+
export const guestOverlaps = (a, b) => {
|
|
64
|
+
const x = sandboxFolder(a).replace(/\\+$/, '').toLowerCase();
|
|
65
|
+
const y = sandboxFolder(b).replace(/\\+$/, '').toLowerCase();
|
|
66
|
+
return x === y || x.startsWith(y + '\\') || y.startsWith(x + '\\');
|
|
67
|
+
};
|
|
68
|
+
/**
|
|
69
|
+
* Where an extra folder appears in the sandbox. `wanted` is the guest path asked for (or the one derived from the PC
|
|
70
|
+
* path); when the sandbox cannot use it (reserved, invalid, or overlapping a folder in `taken`), the folder goes to
|
|
71
|
+
* C:\Mapped\<drive>\<path> instead (made unique with -2, -3 ...), and `note` says where and why.
|
|
72
|
+
*/
|
|
73
|
+
export const placeGuest = (host, wanted, taken) => {
|
|
74
|
+
const clash = (g) => taken.find((t) => guestOverlaps(g, t.guest));
|
|
75
|
+
const tidy = tidyGuestPath(wanted);
|
|
76
|
+
const problem = guestPathProblem(tidy);
|
|
77
|
+
const other = problem ? null : clash(tidy);
|
|
78
|
+
if (!problem && !other)
|
|
79
|
+
return { guest: tidy, note: null };
|
|
80
|
+
const why = problem ?? tidy + ' is already used by ' + other.what + (other.guest.toLowerCase() === tidy.toLowerCase() ? '' : ' (' + other.guest + ')');
|
|
81
|
+
const guest = freePlace(mappedFallback(/^[A-Za-z]:/.test(tidy) ? tidy : host), clash);
|
|
82
|
+
return { guest, note: host + ': ' + why + ', so it is at ' + guest + ' in the sandbox.' };
|
|
83
|
+
};
|
|
84
|
+
/**
|
|
85
|
+
* The guest path a mapping asks for when it gives none: a folder inside the project at the same place in the project's
|
|
86
|
+
* guest folder, any other folder at the same path as on this PC (D: to Z: through C:\Mirror and subst).
|
|
87
|
+
*/
|
|
88
|
+
export const deriveGuest = (host, projectHost, projectGuest) => {
|
|
89
|
+
const rel = win32.relative(win32.resolve(projectHost), win32.resolve(host));
|
|
90
|
+
const inside = rel === '' || (!rel.startsWith('..') && !win32.isAbsolute(rel));
|
|
91
|
+
return inside ? (rel ? win32.join(projectGuest, rel) : projectGuest) : tidyGuestPath(win32.resolve(host));
|
|
92
|
+
};
|
|
93
|
+
const freePlace = (base, clash) => {
|
|
94
|
+
const ok = (g) => isValidGuestPath(g) && !clash(g);
|
|
95
|
+
if (ok(base))
|
|
96
|
+
return base;
|
|
97
|
+
for (let i = 2; i < 1000; i++)
|
|
98
|
+
if (ok(base + '-' + i))
|
|
99
|
+
return base + '-' + i;
|
|
100
|
+
return base + '-' + Date.now();
|
|
101
|
+
};
|
|
34
102
|
/** Where a guest path really is inside the sandbox (the .wsb SandboxFolder): C: paths as-is, F:\x -> C:\Mirror\F\x. */
|
|
35
103
|
export const sandboxFolder = (guest) => {
|
|
36
104
|
const m = DRIVE_PATH.exec(guest);
|
|
@@ -1,5 +1,12 @@
|
|
|
1
|
-
/** Where roots
|
|
1
|
+
/** Where project roots appear inside the sandbox. */
|
|
2
2
|
export declare const PROJECTS_GUEST = "C:\\Projects";
|
|
3
|
+
/** Where projects mapped on their own appear (the launch folder with guestWorkspace "auto", boot-mapped and shared ones). */
|
|
4
|
+
export declare const WORKSPACES_GUEST = "C:\\Workspace";
|
|
5
|
+
/**
|
|
6
|
+
* guestWorkspace "auto" for a folder: C:\Workspace\<folder name>. Keeping the real folder name means Claude's
|
|
7
|
+
* per-project history and claude-mem's project name match this PC instead of every project being "Workspace".
|
|
8
|
+
*/
|
|
9
|
+
export declare const autoGuestFolder: (hostDir: string) => string;
|
|
3
10
|
/** How a project got into the sandbox. */
|
|
4
11
|
export type Mechanism = 'launch' | 'root' | 'boot' | 'wsb';
|
|
5
12
|
/** One project the running sandbox works on. */
|
|
@@ -37,17 +44,33 @@ export declare const hostDirFor: (mapped: {
|
|
|
37
44
|
}, guestDir: string) => string;
|
|
38
45
|
/** Strictly below `parent` (never `parent` itself). */
|
|
39
46
|
export declare const isBelow: (parent: string, child: string) => boolean;
|
|
40
|
-
/**
|
|
47
|
+
/**
|
|
48
|
+
* A guest folder some project can be in: below C:\Projects or C:\Workspace (projects mapped on their own, also shared
|
|
49
|
+
* after boot), or inside one of `roots` (the roots' and boot-mapped projects' guest paths).
|
|
50
|
+
*/
|
|
41
51
|
export declare const insideProjects: (guestDir: string, roots?: string[], projectsDir?: string) => boolean;
|
|
42
52
|
export interface RootMapping {
|
|
43
53
|
host: string;
|
|
44
54
|
guest: string;
|
|
45
55
|
}
|
|
56
|
+
/** The project roots that are mapped, in order: existing folders, none inside a root listed before it. */
|
|
57
|
+
export declare const usableRoots: (roots: string[], exists: (dir: string) => boolean) => string[];
|
|
46
58
|
/**
|
|
47
|
-
*
|
|
48
|
-
*
|
|
59
|
+
* Where a project mapped on its own appears, the same place it has as the launch folder: with "mirror" its PC path
|
|
60
|
+
* when the sandbox can show it there, else C:\Workspace\<name>. -2, -3, ... when that overlaps a folder in `taken`.
|
|
61
|
+
*/
|
|
62
|
+
export declare const ownGuestFolder: (hostDir: string, mirror: boolean, taken?: string[]) => string;
|
|
63
|
+
/**
|
|
64
|
+
* Guest folders for the project roots (C:\Projects\<name>, unique; with guestWorkspace "mirror" at its PC path when
|
|
65
|
+
* the sandbox can show it there) and the projects mapped at boot (ownGuestFolder, unique against `taken` and each
|
|
66
|
+
* other). A root's folder depends only on the roots, so every launch with the same roots shows them at the same place.
|
|
49
67
|
*/
|
|
50
68
|
export declare const planProjectMappings: (roots: string[], projects: string[], mirror: boolean, taken?: string[]) => {
|
|
51
69
|
roots: RootMapping[];
|
|
52
70
|
projects: RootMapping[];
|
|
53
71
|
};
|
|
72
|
+
/**
|
|
73
|
+
* Where a folder inside one of the (usable) roots appears: under the root's guest folder, whichever project started
|
|
74
|
+
* the sandbox. Null when it is in no root.
|
|
75
|
+
*/
|
|
76
|
+
export declare const rootGuestDir: (roots: string[], mirror: boolean, hostDir: string) => string | null;
|
package/dist/shared/projects.js
CHANGED
|
@@ -1,16 +1,30 @@
|
|
|
1
1
|
// Several projects in one running sandbox. Windows Sandbox fixes its mapped folders at boot, so a project can only
|
|
2
2
|
// join a running sandbox when it is already visible inside:
|
|
3
|
-
// - the launch folder (mapped directly,
|
|
3
|
+
// - the launch folder (mapped directly, or shown by the project root it is in);
|
|
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
7
|
// - a folder shared into the running sandbox with `wsb share` (Windows 11 24H2+, untested).
|
|
8
|
-
//
|
|
8
|
+
// A project is at the same place inside the sandbox whichever project started it (git worktrees and Claude's project
|
|
9
|
+
// folders store absolute paths):
|
|
10
|
+
// - a project inside a root is always under the root's folder, C:\Projects\<root>\<name> (also as the launch folder);
|
|
11
|
+
// - any other project, mapped on its own, is always at C:\Workspace\<name> (also when it is mapped at boot or shared).
|
|
12
|
+
// With guestWorkspace "mirror" both appear at their PC paths when the sandbox can show them there.
|
|
9
13
|
// Pure functions only: used by the host (registration, commands), the guest (daemon checks) and the tests.
|
|
10
14
|
import path from 'node:path';
|
|
11
|
-
import { isValidGuestPath } from './guest-paths.js';
|
|
12
|
-
/** Where roots
|
|
15
|
+
import { isValidGuestPath, planMirrorWorkspace } from './guest-paths.js';
|
|
16
|
+
/** Where project roots appear inside the sandbox. */
|
|
13
17
|
export const PROJECTS_GUEST = 'C:\\Projects';
|
|
18
|
+
/** Where projects mapped on their own appear (the launch folder with guestWorkspace "auto", boot-mapped and shared ones). */
|
|
19
|
+
export const WORKSPACES_GUEST = 'C:\\Workspace';
|
|
20
|
+
/**
|
|
21
|
+
* guestWorkspace "auto" for a folder: C:\Workspace\<folder name>. Keeping the real folder name means Claude's
|
|
22
|
+
* per-project history and claude-mem's project name match this PC instead of every project being "Workspace".
|
|
23
|
+
*/
|
|
24
|
+
export const autoGuestFolder = (hostDir) => {
|
|
25
|
+
const name = path.win32.basename(hostDir.replace(/[\\/]+$/, '')).replace(/[^A-Za-z0-9._ -]/g, '_').replace(/^[. ]+|[. ]+$/g, '');
|
|
26
|
+
return WORKSPACES_GUEST + '\\' + (name || 'project');
|
|
27
|
+
};
|
|
14
28
|
const norm = (p) => path.win32.resolve(p).replace(/[\\/]+$/, '').toLowerCase();
|
|
15
29
|
/** `child` is `parent` or somewhere below it (Windows paths, case-insensitive). */
|
|
16
30
|
export const isWithin = (parent, child) => {
|
|
@@ -47,21 +61,66 @@ export const hostDirFor = (mapped, guestDir) => {
|
|
|
47
61
|
};
|
|
48
62
|
/** Strictly below `parent` (never `parent` itself). */
|
|
49
63
|
export const isBelow = (parent, child) => isWithin(parent, child) && norm(parent) !== norm(child);
|
|
50
|
-
/** A guest folder some project can be in: below C:\Projects, or inside one of the roots (their guest paths). */
|
|
51
|
-
export const insideProjects = (guestDir, roots = [], projectsDir = PROJECTS_GUEST) => isBelow(projectsDir, guestDir) || roots.some((r) => isWithin(r, guestDir));
|
|
52
64
|
/**
|
|
53
|
-
*
|
|
54
|
-
*
|
|
65
|
+
* A guest folder some project can be in: below C:\Projects or C:\Workspace (projects mapped on their own, also shared
|
|
66
|
+
* after boot), or inside one of `roots` (the roots' and boot-mapped projects' guest paths).
|
|
67
|
+
*/
|
|
68
|
+
export const insideProjects = (guestDir, roots = [], projectsDir = PROJECTS_GUEST) => isBelow(projectsDir, guestDir) || isBelow(WORKSPACES_GUEST, guestDir) || roots.some((r) => isWithin(r, guestDir));
|
|
69
|
+
const resolveDir = (dir) => path.win32.resolve(dir).replace(/[\\/]+$/, '');
|
|
70
|
+
/** The project roots that are mapped, in order: existing folders, none inside a root listed before it. */
|
|
71
|
+
export const usableRoots = (roots, exists) => {
|
|
72
|
+
const out = [];
|
|
73
|
+
for (const root of roots.map(resolveDir))
|
|
74
|
+
if (exists(root) && !out.some((r) => isWithin(r, root)))
|
|
75
|
+
out.push(root);
|
|
76
|
+
return out;
|
|
77
|
+
};
|
|
78
|
+
/**
|
|
79
|
+
* Where a project mapped on its own appears, the same place it has as the launch folder: with "mirror" its PC path
|
|
80
|
+
* when the sandbox can show it there, else C:\Workspace\<name>. -2, -3, ... when that overlaps a folder in `taken`.
|
|
81
|
+
*/
|
|
82
|
+
export const ownGuestFolder = (hostDir, mirror, taken = []) => {
|
|
83
|
+
const host = resolveDir(hostDir);
|
|
84
|
+
const free = (guest) => !taken.some((t) => isWithin(t, guest) || isWithin(guest, t));
|
|
85
|
+
const mirrored = mirror ? planMirrorWorkspace(host, '').guest : '';
|
|
86
|
+
if (mirrored && !isWithin(PROJECTS_GUEST, mirrored) && !isWithin(WORKSPACES_GUEST, mirrored) && free(mirrored))
|
|
87
|
+
return mirrored;
|
|
88
|
+
const base = autoGuestFolder(host);
|
|
89
|
+
if (free(base))
|
|
90
|
+
return base;
|
|
91
|
+
for (let i = 2;; i++)
|
|
92
|
+
if (free(base + '-' + i))
|
|
93
|
+
return base + '-' + i;
|
|
94
|
+
};
|
|
95
|
+
/**
|
|
96
|
+
* Guest folders for the project roots (C:\Projects\<name>, unique; with guestWorkspace "mirror" at its PC path when
|
|
97
|
+
* the sandbox can show it there) and the projects mapped at boot (ownGuestFolder, unique against `taken` and each
|
|
98
|
+
* other). A root's folder depends only on the roots, so every launch with the same roots shows them at the same place.
|
|
55
99
|
*/
|
|
56
100
|
export const planProjectMappings = (roots, projects, mirror, taken = []) => {
|
|
57
|
-
const names = [
|
|
58
|
-
const
|
|
59
|
-
const resolved =
|
|
60
|
-
if (
|
|
101
|
+
const names = [];
|
|
102
|
+
const placeRoot = (host) => {
|
|
103
|
+
const resolved = resolveDir(host);
|
|
104
|
+
if (mirror && isValidGuestPath(resolved) && !isWithin(PROJECTS_GUEST, resolved))
|
|
61
105
|
return { host: resolved, guest: resolved };
|
|
62
106
|
const name = hubName(resolved, names);
|
|
63
107
|
names.push(name);
|
|
64
108
|
return { host: resolved, guest: path.win32.join(PROJECTS_GUEST, name) };
|
|
65
109
|
};
|
|
66
|
-
|
|
110
|
+
const planned = roots.map(placeRoot);
|
|
111
|
+
const used = [...taken, ...planned.map((r) => r.guest)];
|
|
112
|
+
const placed = projects.map((p) => {
|
|
113
|
+
const guest = ownGuestFolder(p, mirror, used);
|
|
114
|
+
used.push(guest);
|
|
115
|
+
return { host: resolveDir(p), guest };
|
|
116
|
+
});
|
|
117
|
+
return { roots: planned, projects: placed };
|
|
118
|
+
};
|
|
119
|
+
/**
|
|
120
|
+
* Where a folder inside one of the (usable) roots appears: under the root's guest folder, whichever project started
|
|
121
|
+
* the sandbox. Null when it is in no root.
|
|
122
|
+
*/
|
|
123
|
+
export const rootGuestDir = (roots, mirror, hostDir) => {
|
|
124
|
+
const root = projectForHostDir(planProjectMappings(roots, [], mirror).roots, hostDir);
|
|
125
|
+
return root ? guestDirFor(root, hostDir) : null;
|
|
67
126
|
};
|
|
@@ -37,6 +37,8 @@ export type ClientResult = {
|
|
|
37
37
|
reason: 'error';
|
|
38
38
|
message: string;
|
|
39
39
|
};
|
|
40
|
+
/** Turns off every mouse tracking mode a program may have turned on in your terminal (normal, button, any, UTF-8, SGR, urxvt). */
|
|
41
|
+
export declare const MOUSE_OFF = "\u001B[?1000l\u001B[?1002l\u001B[?1003l\u001B[?1005l\u001B[?1006l\u001B[?1015l";
|
|
40
42
|
/** Ctrl+] — the same detach key telnet uses; Claude Code does not bind it. */
|
|
41
43
|
export declare const DETACH_KEY = 29;
|
|
42
44
|
export declare const encodeFrame: (type: number, payload?: Buffer) => Buffer;
|
|
@@ -82,6 +84,21 @@ export declare const queryViewers: (endpoint: Endpoint, timeoutMs?: number) => P
|
|
|
82
84
|
* what terminals send. ConPTY reads ^H as Ctrl+Backspace (delete a word), so swap them back.
|
|
83
85
|
*/
|
|
84
86
|
export declare const fixWindowsKeys: (chunk: Buffer) => Buffer;
|
|
87
|
+
/** How long an unfinished escape sequence is held back waiting for the rest (a lone Esc key press waits this long). */
|
|
88
|
+
export declare const JOIN_MS = 30;
|
|
89
|
+
/**
|
|
90
|
+
* Keeps each escape sequence typed into your terminal (keys, mouse reports, the terminal's replies) in one INPUT frame.
|
|
91
|
+
* The console can hand one over in pieces; ConPTY reads a lone "ESC[" as Alt+[ and the rest as typed text, so a split
|
|
92
|
+
* Right arrow (ESC[C) left a stray "C" in the agent's prompt. An unfinished sequence at the end of a chunk is held
|
|
93
|
+
* until the rest arrives (or JOIN_MS passes).
|
|
94
|
+
*/
|
|
95
|
+
export declare class InputJoiner {
|
|
96
|
+
private held;
|
|
97
|
+
/** The bytes to send now; an unfinished sequence at the end is kept for the next chunk. */
|
|
98
|
+
push(chunk: Buffer): Buffer;
|
|
99
|
+
holding(): boolean;
|
|
100
|
+
flush(): Buffer;
|
|
101
|
+
}
|
|
85
102
|
/**
|
|
86
103
|
* Bridges this process's terminal to the remote program until it exits, the connection drops, or the user presses
|
|
87
104
|
* the detach key. Restores the terminal before resolving.
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
// Client side of the ptyhost protocol (see sandbox/ptyhost.cs). Used by the window inside the sandbox and by
|
|
2
2
|
// `--terminal` / `attach` on the host, so it only depends on Node built-ins.
|
|
3
3
|
import net from 'node:net';
|
|
4
|
+
import { enableVtInput } from './console-input.js';
|
|
4
5
|
export const FrameType = { Hello: 1, Input: 2, Resize: 3, Restart: 4, Ping: 5, Output: 16, Exit: 17, Error: 18, Restarting: 19, Status: 20, Console: 21 };
|
|
5
6
|
/** HELLO flags: control-only connections get no output and never size the console; a query only asks for STATUS. */
|
|
6
7
|
export const HelloFlag = { ControlOnly: 1, Query: 2 };
|
|
7
8
|
/** Viewers ping this often; ptyhost counts a viewer as gone after 15 s of silence (e.g. its terminal tab was closed). */
|
|
8
9
|
export const PING_INTERVAL_MS = 5000;
|
|
10
|
+
/** Turns off every mouse tracking mode a program may have turned on in your terminal (normal, button, any, UTF-8, SGR, urxvt). */
|
|
11
|
+
export const MOUSE_OFF = '\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1005l\x1b[?1006l\x1b[?1015l';
|
|
9
12
|
/** Ctrl+] — the same detach key telnet uses; Claude Code does not bind it. */
|
|
10
13
|
export const DETACH_KEY = 0x1d;
|
|
11
14
|
export const encodeFrame = (type, payload = Buffer.alloc(0)) => {
|
|
@@ -255,6 +258,41 @@ export const fixWindowsKeys = (chunk) => {
|
|
|
255
258
|
return chunk;
|
|
256
259
|
return Buffer.from(chunk.map((b) => (b === 0x08 ? 0x7f : b === 0x7f ? 0x08 : b)));
|
|
257
260
|
};
|
|
261
|
+
/** How long an unfinished escape sequence is held back waiting for the rest (a lone Esc key press waits this long). */
|
|
262
|
+
export const JOIN_MS = 30;
|
|
263
|
+
/** Longest sequence held back; anything longer is not a key, mouse report or terminal reply, and is sent as it is. */
|
|
264
|
+
const MAX_HELD = 64;
|
|
265
|
+
/**
|
|
266
|
+
* Keeps each escape sequence typed into your terminal (keys, mouse reports, the terminal's replies) in one INPUT frame.
|
|
267
|
+
* The console can hand one over in pieces; ConPTY reads a lone "ESC[" as Alt+[ and the rest as typed text, so a split
|
|
268
|
+
* Right arrow (ESC[C) left a stray "C" in the agent's prompt. An unfinished sequence at the end of a chunk is held
|
|
269
|
+
* until the rest arrives (or JOIN_MS passes).
|
|
270
|
+
*/
|
|
271
|
+
export class InputJoiner {
|
|
272
|
+
held = Buffer.alloc(0);
|
|
273
|
+
/** The bytes to send now; an unfinished sequence at the end is kept for the next chunk. */
|
|
274
|
+
push(chunk) {
|
|
275
|
+
const data = this.held.length ? Buffer.concat([this.held, chunk]) : chunk;
|
|
276
|
+
const cut = unfinishedSequence(data);
|
|
277
|
+
this.held = Buffer.from(data.subarray(cut));
|
|
278
|
+
return data.subarray(0, cut);
|
|
279
|
+
}
|
|
280
|
+
holding() { return this.held.length > 0; }
|
|
281
|
+
flush() { const out = this.held; this.held = Buffer.alloc(0); return out; }
|
|
282
|
+
}
|
|
283
|
+
/** Where an escape sequence that has not ended yet starts at the end of `data` (data.length if there is none). */
|
|
284
|
+
const unfinishedSequence = (data) => {
|
|
285
|
+
const start = data.lastIndexOf(0x1b, -1);
|
|
286
|
+
if (start === -1 || data.length - start > MAX_HELD)
|
|
287
|
+
return data.length;
|
|
288
|
+
const rest = data.subarray(start + 1);
|
|
289
|
+
if (rest.length === 0)
|
|
290
|
+
return start;
|
|
291
|
+
// ESC [ or ESC O (SS3) followed only by parameter / intermediate bytes: the final byte has not arrived yet.
|
|
292
|
+
if (rest[0] === 0x5b || rest[0] === 0x4f)
|
|
293
|
+
return rest.subarray(1).every((b) => b >= 0x20 && b <= 0x3f) && (rest[0] === 0x5b || rest.length === 1) ? start : data.length;
|
|
294
|
+
return data.length;
|
|
295
|
+
};
|
|
258
296
|
/**
|
|
259
297
|
* Bridges this process's terminal to the remote program until it exits, the connection drops, or the user presses
|
|
260
298
|
* the detach key. Restores the terminal before resolving.
|
|
@@ -264,13 +302,15 @@ export const runTerminalClient = (socket, token, options = {}) => {
|
|
|
264
302
|
const stdout = process.stdout;
|
|
265
303
|
// getWindowSize asks the console every time; columns/rows only change when Node noticed a resize.
|
|
266
304
|
const size = () => { const [c, r] = stdout.isTTY ? stdout.getWindowSize() : [0, 0]; return [c || 120, r || 30]; };
|
|
267
|
-
|
|
305
|
+
// Until the console passes the terminal's input through as it is (enableVtInput), Node turns Backspace into ^H.
|
|
306
|
+
let swapKeys = process.platform === 'win32' && stdin.isTTY;
|
|
268
307
|
return new Promise((resolve) => {
|
|
269
308
|
let done = false;
|
|
270
309
|
const reader = new FrameReader();
|
|
271
310
|
const filter = new OutputFilter();
|
|
272
311
|
let sent = size();
|
|
273
312
|
let resizeTimer;
|
|
313
|
+
let vtInput;
|
|
274
314
|
const finish = (result) => {
|
|
275
315
|
if (done)
|
|
276
316
|
return;
|
|
@@ -280,19 +320,32 @@ export const runTerminalClient = (socket, token, options = {}) => {
|
|
|
280
320
|
clearTimeout(resizeTimer);
|
|
281
321
|
clearInterval(heartbeat);
|
|
282
322
|
clearInterval(sizeWatch);
|
|
283
|
-
|
|
284
|
-
stdin.setRawMode(false);
|
|
285
|
-
stdin.pause();
|
|
323
|
+
clearTimeout(joinTimer);
|
|
286
324
|
socket.destroy();
|
|
287
|
-
|
|
288
|
-
|
|
325
|
+
// The agent's mouse tracking ends with it: your terminal's wheel scrolls again (and clicks select text).
|
|
326
|
+
stdout.write('\x1b[0m\x1b[?25h' + MOUSE_OFF + '\r\n');
|
|
327
|
+
// Wait for a console-mode change still in progress, then give the console back in its normal mode.
|
|
328
|
+
void (vtInput?.stop() ?? Promise.resolve()).then(() => {
|
|
329
|
+
if (stdin.isTTY)
|
|
330
|
+
stdin.setRawMode(false);
|
|
331
|
+
stdin.pause();
|
|
332
|
+
resolve(result);
|
|
333
|
+
});
|
|
289
334
|
};
|
|
335
|
+
const joiner = new InputJoiner();
|
|
336
|
+
let joinTimer;
|
|
337
|
+
const sendInput = (data) => { if (data.length && !done)
|
|
338
|
+
socket.write(encodeFrame(FrameType.Input, swapKeys ? fixWindowsKeys(data) : data)); };
|
|
290
339
|
const onInput = (chunk) => {
|
|
291
340
|
if (options.detachKey !== undefined && chunk.includes(options.detachKey)) {
|
|
292
341
|
finish({ reason: 'detach' });
|
|
293
342
|
return;
|
|
294
343
|
}
|
|
295
|
-
|
|
344
|
+
clearTimeout(joinTimer);
|
|
345
|
+
sendInput(joiner.push(chunk));
|
|
346
|
+
// A lone Esc key press is a "sequence" that never completes: send it after a moment.
|
|
347
|
+
if (joiner.holding())
|
|
348
|
+
joinTimer = setTimeout(() => sendInput(joiner.flush()), JOIN_MS);
|
|
296
349
|
};
|
|
297
350
|
// Windows' own ConPTY repaints the whole screen on every resize; dragging a window edge fires dozens of them, and
|
|
298
351
|
// repaints racing each other left the screen garbled. Wait until the size settles, clear what this terminal
|
|
@@ -328,7 +381,7 @@ export const runTerminalClient = (socket, token, options = {}) => {
|
|
|
328
381
|
else if (frame.type === FrameType.Console)
|
|
329
382
|
passthrough = frame.payload.toString('latin1') === 'conpty.dll';
|
|
330
383
|
else if (frame.type === FrameType.Restarting)
|
|
331
|
-
stdout.write('\x1b[2J\x1b[3J\x1b[H');
|
|
384
|
+
stdout.write(MOUSE_OFF + '\x1b[2J\x1b[3J\x1b[H');
|
|
332
385
|
else if (frame.type === FrameType.Exit)
|
|
333
386
|
finish({ reason: 'exit', exitCode: frame.payload.length >= 4 ? frame.payload.readInt32LE(0) : 1 });
|
|
334
387
|
else if (frame.type === FrameType.Error)
|
|
@@ -342,6 +395,10 @@ export const runTerminalClient = (socket, token, options = {}) => {
|
|
|
342
395
|
socket.write(helloFrame(token, c, r));
|
|
343
396
|
if (stdin.isTTY)
|
|
344
397
|
stdin.setRawMode(true);
|
|
398
|
+
// After setRawMode (which sets the console mode): mouse reports, focus and keys then arrive as the terminal sent them.
|
|
399
|
+
vtInput = enableVtInput();
|
|
400
|
+
void vtInput.ready.then((on) => { if (on)
|
|
401
|
+
swapKeys = false; });
|
|
345
402
|
stdin.on('data', onInput);
|
|
346
403
|
stdin.resume();
|
|
347
404
|
stdout.on('resize', onResize);
|
package/dist/shared/schema.d.ts
CHANGED
|
@@ -5,6 +5,18 @@ export declare const AttachEndpoint: z.ZodObject<{
|
|
|
5
5
|
port: z.ZodNumber;
|
|
6
6
|
token: z.ZodString;
|
|
7
7
|
}, z.core.$strict>;
|
|
8
|
+
/** The boot's progress in a shape the host can show: step n of m, what is being set up now, how many are done. */
|
|
9
|
+
export declare const BootProgress: z.ZodObject<{
|
|
10
|
+
step: z.ZodNumber;
|
|
11
|
+
steps: z.ZodNumber;
|
|
12
|
+
title: z.ZodString;
|
|
13
|
+
active: z.ZodArray<z.ZodObject<{
|
|
14
|
+
name: z.ZodString;
|
|
15
|
+
percent: z.ZodOptional<z.ZodNumber>;
|
|
16
|
+
}, z.core.$strict>>;
|
|
17
|
+
done: z.ZodNumber;
|
|
18
|
+
total: z.ZodNumber;
|
|
19
|
+
}, z.core.$strict>;
|
|
8
20
|
/** C:\AgentData\run\sandbox.json: boot progress of the sandbox as a whole. */
|
|
9
21
|
export declare const SandboxStatus: z.ZodObject<{
|
|
10
22
|
launchId: z.ZodString;
|
|
@@ -20,6 +32,17 @@ export declare const SandboxStatus: z.ZodObject<{
|
|
|
20
32
|
guestWorkspace: z.ZodString;
|
|
21
33
|
headless: z.ZodBoolean;
|
|
22
34
|
addresses: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
35
|
+
progress: z.ZodCatch<z.ZodOptional<z.ZodObject<{
|
|
36
|
+
step: z.ZodNumber;
|
|
37
|
+
steps: z.ZodNumber;
|
|
38
|
+
title: z.ZodString;
|
|
39
|
+
active: z.ZodArray<z.ZodObject<{
|
|
40
|
+
name: z.ZodString;
|
|
41
|
+
percent: z.ZodOptional<z.ZodNumber>;
|
|
42
|
+
}, z.core.$strict>>;
|
|
43
|
+
done: z.ZodNumber;
|
|
44
|
+
total: z.ZodNumber;
|
|
45
|
+
}, z.core.$strict>>>;
|
|
23
46
|
}, z.core.$strip>;
|
|
24
47
|
/**
|
|
25
48
|
* A project the host registered after boot (sandbox claude from another folder): everything the guest needs to
|
|
@@ -532,6 +555,7 @@ export type ProjectSpecT = z.output<typeof ProjectSpec>;
|
|
|
532
555
|
export type ProjectStatusT = z.output<typeof ProjectStatus>;
|
|
533
556
|
export type AttachEndpointT = z.output<typeof AttachEndpoint>;
|
|
534
557
|
export type SandboxStatusT = z.output<typeof SandboxStatus>;
|
|
558
|
+
export type BootProgressT = z.output<typeof BootProgress>;
|
|
535
559
|
export type GuestRequestT = z.output<typeof GuestRequest>;
|
|
536
560
|
export type GuestResponseT = z.output<typeof GuestResponse>;
|
|
537
561
|
export type SessionStatusT = z.output<typeof SessionStatus>;
|
package/dist/shared/schema.js
CHANGED
|
@@ -10,6 +10,16 @@ export const AttachEndpoint = z.strictObject({
|
|
|
10
10
|
port: z.number().int().min(1024).max(65535),
|
|
11
11
|
token: Text(256),
|
|
12
12
|
});
|
|
13
|
+
/** The boot's progress in a shape the host can show: step n of m, what is being set up now, how many are done. */
|
|
14
|
+
export const BootProgress = z.strictObject({
|
|
15
|
+
step: z.number().int().min(1).max(32),
|
|
16
|
+
steps: z.number().int().min(1).max(32),
|
|
17
|
+
title: Text(120),
|
|
18
|
+
/** Being set up now; percent while it downloads (when the size is known). */
|
|
19
|
+
active: z.array(z.strictObject({ name: Text(64), percent: z.number().int().min(0).max(100).optional() })).max(8),
|
|
20
|
+
done: z.number().int().min(0).max(1000),
|
|
21
|
+
total: z.number().int().min(0).max(1000),
|
|
22
|
+
});
|
|
13
23
|
/** C:\AgentData\run\sandbox.json: boot progress of the sandbox as a whole. */
|
|
14
24
|
export const SandboxStatus = z.object({
|
|
15
25
|
launchId: Text(64),
|
|
@@ -21,6 +31,8 @@ export const SandboxStatus = z.object({
|
|
|
21
31
|
headless: z.boolean(),
|
|
22
32
|
/** The sandbox's IPv4 addresses (for network.forwardPorts; the host only uses ones on its Hyper-V network). */
|
|
23
33
|
addresses: z.array(z.string().max(45)).max(8).optional(),
|
|
34
|
+
/** A malformed progress is dropped, never the status (the phase still counts). */
|
|
35
|
+
progress: BootProgress.optional().catch(undefined),
|
|
24
36
|
});
|
|
25
37
|
const Version = z.strictObject({ spec: Text(128), source: Text(256) });
|
|
26
38
|
const DatabasePlan = z.strictObject({
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "windows-development-sandbox",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.1",
|
|
4
4
|
"description": "A disposable Windows Sandbox for development, with or without an AI agent (Claude Code, Codex, Gemini, opencode, ...): the folder you run it from is the workspace, toolchains and databases for your project install themselves, and nothing else on your PC is reachable.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|