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
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// The host <-> sandbox channel and the terminal protocol: everything arriving from the sandbox is validated, sized and
|
|
2
2
|
// filtered before this PC acts on it.
|
|
3
3
|
import { safeId } from '../shared/rundir.js';
|
|
4
|
-
import { GuestRequest, SessionStatus } from '../shared/schema.js';
|
|
5
|
-
import {
|
|
4
|
+
import { GuestRequest, SandboxStatus, SessionStatus } from '../shared/schema.js';
|
|
5
|
+
import { bootLine, firstStartNotice, pendingDownloads, readyIn, stillWorking } from '../cli/boot-progress.js';
|
|
6
|
+
import { encodeFrame, FrameReader, FrameType, fixWindowsKeys, helloFrame, InputJoiner, MAX_FRAME_BYTES, MOUSE_OFF, OutputFilter } from '../shared/pty-client.js';
|
|
6
7
|
export const channelSuite = {
|
|
7
8
|
name: 'channel',
|
|
8
9
|
title: 'request/status validation and the terminal protocol (frames, OSC 52, keys)',
|
|
@@ -19,6 +20,32 @@ export const channelSuite = {
|
|
|
19
20
|
t.check(validSession(goodSession), 'a well-formed session status is accepted');
|
|
20
21
|
t.check(!validSession({ ...goodSession, attach: { ...goodSession.attach, port: 70000 } }), 'a session status with an impossible port is rejected');
|
|
21
22
|
t.check(!validSession({ ...goodSession, phase: 'pwned' }), 'a session status with an unknown phase is rejected');
|
|
23
|
+
t.section('boot progress');
|
|
24
|
+
const goodStatus = { launchId: 'l1', phase: 'provisioning', message: '[3/8] Developer tools', updatedAt: 'x', workspaceHost: 'C:\\p', guestWorkspace: 'C:\\Workspace', headless: false };
|
|
25
|
+
const progress = { step: 3, steps: 8, title: 'Developer tools', active: [{ name: 'Rust', percent: 40 }, { name: 'Go' }], done: 2, total: 6 };
|
|
26
|
+
const parsed = (s) => SandboxStatus.safeParse(s);
|
|
27
|
+
t.equal(parsed({ ...goodStatus, progress }).data?.progress, progress, 'a well-formed boot progress is accepted');
|
|
28
|
+
t.check(parsed(goodStatus).success && parsed(goodStatus).data?.progress === undefined, 'a status without progress (an older sandbox) is accepted');
|
|
29
|
+
const hostile = [
|
|
30
|
+
{ ...progress, title: 'x'.repeat(5000) }, { ...progress, active: Array.from({ length: 100 }, () => ({ name: 'a' })) },
|
|
31
|
+
{ ...progress, active: [{ name: 'a', percent: 500 }] }, { ...progress, step: -1 }, { ...progress, done: 1e9 }, { ...progress, extra: 1 }, 'pwned',
|
|
32
|
+
];
|
|
33
|
+
t.equal(hostile.map((p) => { const r = parsed({ ...goodStatus, progress: p }); return r.success && r.data.progress === undefined; }), hostile.map(() => true), 'an oversized or malformed progress is dropped, the status itself still counts');
|
|
34
|
+
t.check(!parsed({ ...goodStatus, phase: 'pwned', progress }).success, 'a status with an unknown phase is still rejected');
|
|
35
|
+
const line = (p) => bootLine(parsed({ ...goodStatus, progress: p }).data ?? null).line;
|
|
36
|
+
t.equal(line(progress), '[3/8] Developer tools: downloading Rust 40%; setting up Go (2 of 6 done)', 'the line shows the step, the download and what is set up');
|
|
37
|
+
t.equal(line({ ...progress, title: 'Agent: claude', step: 6, active: [], done: 0, total: 0 }), '[6/8] Agent: claude', 'a step without tools is just its title');
|
|
38
|
+
t.equal(line({ ...progress, active: [{ name: '\x1b]52;c;ZXZpbA==\x07Ru\x1b[2Jst\r\n', percent: 99 }] }), '[3/8] Developer tools: downloading Rust 99% (2 of 6 done)', 'escape sequences and control characters from the sandbox never reach the terminal');
|
|
39
|
+
t.equal(bootLine(null).line, 'Starting Windows Sandbox', 'before the sandbox writes anything');
|
|
40
|
+
t.equal(bootLine(parsed({ ...goodStatus, message: 'Installing Go (1 of 3 done)' }).data).line, 'Installing Go (1 of 3 done)', 'an older sandbox: its message');
|
|
41
|
+
t.equal(bootLine(parsed({ ...goodStatus, progress: { ...progress, active: [{ name: 'Rust', percent: 50 }] } }).data).key, bootLine(parsed({ ...goodStatus, progress: { ...progress, active: [{ name: 'Rust', percent: 60 }] } }).data).key, 'a download moving on is no new plain line (only a new step or tool is)');
|
|
42
|
+
t.equal(stillWorking('Rust', 3 * 60 * 1000), 'Still working on Rust (3 min). Full log: sandbox logs -f', 'the notice after a long quiet spell');
|
|
43
|
+
t.equal(readyIn(432_000), 'Ready in 7 min 12 s.', 'the time a first start took');
|
|
44
|
+
const pending = pendingDownloads(['node', 'rust', 'cpp', 'postgres', 'node', 'constructor'], (dir) => dir === 'nvm');
|
|
45
|
+
t.equal(pending, { names: ['rust', 'C/C++', 'PostgreSQL'], mb: 900 }, 'only what is not downloaded yet counts, once each');
|
|
46
|
+
t.equal(firstStartNotice(false, { names: [], mb: 0 }), null, 'a later start with everything downloaded says nothing');
|
|
47
|
+
t.check(/^First start: .*\(about 900 MB: rust, C\/C\+\+, PostgreSQL\)\. This can take 5-20 minutes/.test(firstStartNotice(true, pending) ?? ''), 'the first start says what it downloads, roughly how much, and how long it can take');
|
|
48
|
+
t.check(/^New tools for this project: .*about 1\.8 GB: MSVC, rust/.test(firstStartNotice(false, pendingDownloads(['msvc', 'rust'], () => false)) ?? ''), 'a new toolchain later on gets its own heads-up');
|
|
22
49
|
t.section('terminal frames');
|
|
23
50
|
const filter = new OutputFilter();
|
|
24
51
|
const filtered = Buffer.concat([
|
|
@@ -40,5 +67,14 @@ export const channelSuite = {
|
|
|
40
67
|
const hello = new FrameReader().push(helloFrame('tok', 100, 30, 2))[0];
|
|
41
68
|
t.equal({ type: hello.type, cols: hello.payload.readInt16LE(0), rows: hello.payload.readInt16LE(2), flags: hello.payload[4], token: hello.payload.subarray(5).toString() }, { type: FrameType.Hello, cols: 100, rows: 30, flags: 2, token: 'tok' }, 'a hello frame carries size, flags and token');
|
|
42
69
|
t.equal(fixWindowsKeys(Buffer.from([0x61, 0x08, 0x7f, 0x1b])), Buffer.from([0x61, 0x7f, 0x08, 0x1b]), 'Windows console Backspace (^H) reaches the agent as DEL, so it deletes one character, not a word');
|
|
70
|
+
t.section('typed input keeps escape sequences whole');
|
|
71
|
+
const joined = (...chunks) => { const j = new InputJoiner(); const out = chunks.map((c) => j.push(Buffer.from(c, 'latin1')).toString('latin1')); out.push(j.flush().toString('latin1')); return out.filter(Boolean); };
|
|
72
|
+
t.equal(joined('ab\x1b[', 'C'), ['ab', '\x1b[C'], 'a Right arrow split after ESC[ is sent whole (ConPTY would read Alt+[ and a typed "C")');
|
|
73
|
+
t.equal(joined('\x1b', '[<0;10;', '5M', 'x'), ['\x1b[<0;10;5M', 'x'], 'a mouse report split three ways is sent as one');
|
|
74
|
+
t.equal(joined('\x1bO', 'A'), ['\x1bOA'], 'an SS3 key split after ESC O is sent whole');
|
|
75
|
+
t.equal(joined('a\x1b'), ['a', '\x1b'], 'a lone Esc key is held, then sent by the flush');
|
|
76
|
+
t.equal(joined('\x1b[A\x1bx\x7f'), ['\x1b[A\x1bx\x7f'], 'complete keys (arrows, Alt+x, Backspace) pass at once');
|
|
77
|
+
t.equal(joined('\x1b[' + '1;'.repeat(40)), ['\x1b[' + '1;'.repeat(40)], 'an endless "sequence" is not held back');
|
|
78
|
+
t.check(MOUSE_OFF.includes('\x1b[?1000l') && MOUSE_OFF.includes('\x1b[?1003l') && MOUSE_OFF.includes('\x1b[?1006l'), 'leaving a session turns every mouse tracking mode off in your terminal');
|
|
43
79
|
},
|
|
44
80
|
};
|
|
@@ -45,8 +45,8 @@ export declare const createFixtures: (t: Harness) => {
|
|
|
45
45
|
};
|
|
46
46
|
mappings: {
|
|
47
47
|
host: string;
|
|
48
|
-
guest: string;
|
|
49
48
|
readOnly: boolean;
|
|
49
|
+
guest?: string | undefined;
|
|
50
50
|
optional?: boolean | undefined;
|
|
51
51
|
}[];
|
|
52
52
|
sandbox: {
|
|
@@ -291,6 +291,7 @@ export declare const createFixtures: (t: Harness) => {
|
|
|
291
291
|
roots: string[];
|
|
292
292
|
askToAddRoot: boolean;
|
|
293
293
|
declinedRoots: string[];
|
|
294
|
+
allowOutsideMappings: boolean | "ask";
|
|
294
295
|
wsbShare: boolean;
|
|
295
296
|
};
|
|
296
297
|
$schema?: string | undefined;
|
package/dist/selftest/index.js
CHANGED
|
@@ -42,6 +42,7 @@ import { toolchainsSuite } from './toolchains.test.js';
|
|
|
42
42
|
import { workspaceSuite } from './workspace.test.js';
|
|
43
43
|
import { workspaceViewSuite } from './workspace-view.test.js';
|
|
44
44
|
import { liveNetworkSuite } from './live-network.test.js';
|
|
45
|
+
import { liveTerminalSuite } from './live-terminal.test.js';
|
|
45
46
|
import { auditSuite } from './audit.test.js';
|
|
46
47
|
/** Every suite, in the order they run. The live ones come last: they are the slowest and need Windows. */
|
|
47
48
|
export const SUITES = [
|
|
@@ -58,7 +59,7 @@ export const SUITES = [
|
|
|
58
59
|
// every command as its own process
|
|
59
60
|
hostSimSuite,
|
|
60
61
|
// real processes
|
|
61
|
-
liveDaemonSuite, liveProjectsSuite, liveGitSuite, liveNetworkSuite,
|
|
62
|
+
liveDaemonSuite, liveProjectsSuite, liveGitSuite, liveNetworkSuite, liveTerminalSuite,
|
|
62
63
|
];
|
|
63
64
|
/** Windows Sandbox (its user is always WDAGUtilityAccount) or a GitHub Actions runner: never someone's PC. */
|
|
64
65
|
export const onThrowawayMachine = () => os.userInfo().username.toLowerCase() === 'wdagutilityaccount' || process.env.GITHUB_ACTIONS === 'true';
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// Live: the terminal path as you use it. An outer ptyhost stands in for Windows Terminal (it hosts the console, like
|
|
2
|
+
// Windows Terminal's own pseudo console) and runs the real viewer/attach client (runTerminalClient), which is attached
|
|
3
|
+
// to an inner ptyhost running a stand-in agent. The agent turns SGR mouse tracking on and echoes every byte it reads.
|
|
4
|
+
// Checks: clicks and the wheel reach the agent, Backspace stays Backspace, a split escape sequence arrives whole,
|
|
5
|
+
// 500 printed lines scroll through as lines (no alternate screen, no scrollback clear), and a viewer that joins after
|
|
6
|
+
// a screen clear still gets mouse tracking.
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import net from 'node:net';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
11
|
+
import { pathToFileURL } from 'node:url';
|
|
12
|
+
import { encodeFrame, FrameType } from '../shared/pty-client.js';
|
|
13
|
+
import { CSC, openViewer, tail, WAIT } from './live.js';
|
|
14
|
+
/** The stand-in agent: VT input and SGR mouse tracking on, echoes what it reads ("got <1b>[<0;10;5M"). L prints 500 lines, K clears the screen, q quits. */
|
|
15
|
+
const AGENT_CS = String.raw `
|
|
16
|
+
using System;
|
|
17
|
+
using System.Runtime.InteropServices;
|
|
18
|
+
using System.Text;
|
|
19
|
+
static class MouseAgent
|
|
20
|
+
{
|
|
21
|
+
[DllImport("kernel32.dll")] static extern IntPtr GetStdHandle(int n);
|
|
22
|
+
[DllImport("kernel32.dll")] static extern bool GetConsoleMode(IntPtr h, out uint m);
|
|
23
|
+
[DllImport("kernel32.dll")] static extern bool SetConsoleMode(IntPtr h, uint m);
|
|
24
|
+
[DllImport("kernel32.dll")] static extern bool ReadFile(IntPtr h, byte[] b, int n, out int read, IntPtr o);
|
|
25
|
+
static void Say(string s) { Console.Out.Write(s); Console.Out.Flush(); }
|
|
26
|
+
static int Main()
|
|
27
|
+
{
|
|
28
|
+
IntPtr input = GetStdHandle(-10), output = GetStdHandle(-11);
|
|
29
|
+
uint mode;
|
|
30
|
+
GetConsoleMode(output, out mode); SetConsoleMode(output, mode | 4);
|
|
31
|
+
SetConsoleMode(input, 0x200);
|
|
32
|
+
Say("\x1b[?1000h\x1b[?1006hAGENT-READY\r\n");
|
|
33
|
+
var buffer = new byte[512];
|
|
34
|
+
int n;
|
|
35
|
+
while (ReadFile(input, buffer, buffer.Length, out n, IntPtr.Zero) && n > 0)
|
|
36
|
+
{
|
|
37
|
+
var text = new StringBuilder();
|
|
38
|
+
for (int i = 0; i < n; i++) text.Append(buffer[i] < 32 || buffer[i] > 126 ? "<" + buffer[i].ToString("x2") + ">" : ((char)buffer[i]).ToString());
|
|
39
|
+
string got = text.ToString();
|
|
40
|
+
Say("got " + got + "\r\n");
|
|
41
|
+
if (got == "L") { var lines = new StringBuilder(); for (int i = 1; i <= 500; i++) lines.Append("line " + i + "\r\n"); Say(lines.ToString() + "LINES-DONE\r\n"); }
|
|
42
|
+
if (got == "K") Say("\x1b[2J\x1b[HCLEARED\r\n");
|
|
43
|
+
if (got == "q") break;
|
|
44
|
+
}
|
|
45
|
+
// Exits with mouse tracking still on (like a crash): the client must turn it off.
|
|
46
|
+
return 0;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
`;
|
|
50
|
+
const freePort = () => new Promise((resolve, reject) => {
|
|
51
|
+
const server = net.createServer();
|
|
52
|
+
server.on('error', reject);
|
|
53
|
+
server.listen(0, '127.0.0.1', () => { const port = server.address().port; server.close(() => resolve(port)); });
|
|
54
|
+
});
|
|
55
|
+
const compile = (source, exe) => {
|
|
56
|
+
const result = spawnSync(CSC, ['/nologo', '/optimize', '/target:exe', '/out:' + exe, source], { encoding: 'utf8', windowsHide: true });
|
|
57
|
+
return result.status === 0 ? '' : ((result.stdout ?? '') + (result.stderr ?? '')).trim().slice(0, 1500) || 'csc failed';
|
|
58
|
+
};
|
|
59
|
+
/** conpty.dll as a sandbox has it (C:\AgentData\tools\conpty), when this machine has one; else only Windows' own is tested. */
|
|
60
|
+
const conptyDll = () => {
|
|
61
|
+
const dll = path.join(process.env.AGENT_DATA_DIR ?? 'C:\\AgentData', 'tools', 'conpty', 'conpty.dll');
|
|
62
|
+
return fs.existsSync(dll) && fs.existsSync(path.join(path.dirname(dll), 'OpenConsole.exe')) ? dll : null;
|
|
63
|
+
};
|
|
64
|
+
export const liveTerminalSuite = {
|
|
65
|
+
name: 'live-terminal',
|
|
66
|
+
title: 'live: the viewer/attach client in a terminal (mouse clicks and wheel, keys, scrolling lines, late viewers)',
|
|
67
|
+
live: true,
|
|
68
|
+
timeoutMs: 300000,
|
|
69
|
+
run: async (t) => {
|
|
70
|
+
const ptyhost = t.at('ptyhost.exe');
|
|
71
|
+
const agent = t.at('mouse-agent.exe');
|
|
72
|
+
fs.writeFileSync(t.at('mouse-agent.cs'), AGENT_CS);
|
|
73
|
+
const errors = compile(path.join(t.packageRoot, 'sandbox', 'ptyhost.cs'), ptyhost) || compile(t.at('mouse-agent.cs'), agent);
|
|
74
|
+
t.check(!errors, 'ptyhost and the stand-in agent compile', errors);
|
|
75
|
+
if (errors)
|
|
76
|
+
return;
|
|
77
|
+
const client = t.at('client.mjs');
|
|
78
|
+
fs.writeFileSync(client, [
|
|
79
|
+
'import { connect, runTerminalClient } from ' + JSON.stringify(pathToFileURL(path.join(t.packageRoot, 'dist', 'shared', 'pty-client.js')).href) + ';',
|
|
80
|
+
'const socket = await connect({ addresses: ["127.0.0.1"], port: Number(process.argv[2]), token: "tok" });',
|
|
81
|
+
'const result = await runTerminalClient(socket, "tok");',
|
|
82
|
+
'console.log("CLIENT-DONE " + result.reason);',
|
|
83
|
+
].join('\n'));
|
|
84
|
+
const dll = conptyDll();
|
|
85
|
+
const kinds = dll ? [dll, null] : [null];
|
|
86
|
+
if (!dll)
|
|
87
|
+
t.skip('conpty.dll not found on this machine: only Windows\' own pseudo console is tested');
|
|
88
|
+
for (const kind of kinds) {
|
|
89
|
+
const label = kind ? 'conpty.dll' : 'Windows\' pseudo console';
|
|
90
|
+
t.section('live terminal (' + label + ')');
|
|
91
|
+
const children = [];
|
|
92
|
+
const viewers = [];
|
|
93
|
+
const start = (port, command, log) => {
|
|
94
|
+
const env = { ...process.env, PTYHOST_BIND: 'loopback' };
|
|
95
|
+
delete env.PTYHOST_CONPTY;
|
|
96
|
+
if (kind)
|
|
97
|
+
env.PTYHOST_CONPTY = kind;
|
|
98
|
+
children.push(spawn(ptyhost, [String(port), 'tok', '100', '30', t.at(log), command], { env, stdio: 'ignore', windowsHide: true }));
|
|
99
|
+
};
|
|
100
|
+
try {
|
|
101
|
+
const innerPort = await freePort();
|
|
102
|
+
start(innerPort, '"' + agent + '"', 'inner-' + kinds.indexOf(kind) + '.log');
|
|
103
|
+
const outerPort = await freePort();
|
|
104
|
+
start(outerPort, '"' + process.execPath + '" "' + client + '" ' + innerPort, 'outer-' + kinds.indexOf(kind) + '.log');
|
|
105
|
+
// The terminal: what it shows is what the client wrote; what it types goes to the client's console.
|
|
106
|
+
const terminal = await openViewer({ addresses: ['127.0.0.1'], port: outerPort, token: 'tok' });
|
|
107
|
+
viewers.push(terminal);
|
|
108
|
+
const screen = () => terminal.output();
|
|
109
|
+
const send = (text) => { terminal.socket.write(encodeFrame(FrameType.Input, Buffer.from(text, 'latin1'))); };
|
|
110
|
+
const ready = await t.waitFor(() => screen().includes('AGENT-READY'), WAIT.screen);
|
|
111
|
+
t.check(Boolean(ready), 'the client shows the agent', 'screen: ' + tail(screen()));
|
|
112
|
+
if (!ready)
|
|
113
|
+
continue;
|
|
114
|
+
// The client switches its console to pass the terminal's input through as it is; until then clicks are lost.
|
|
115
|
+
const click = '\x1b[<0;10;5M\x1b[<0;10;5m';
|
|
116
|
+
const clicked = await t.waitFor(() => { if (!screen().includes('got <1b>[<0;10;5M'))
|
|
117
|
+
send(click); return screen().includes('got <1b>[<0;10;5M'); }, WAIT.screen, 1000);
|
|
118
|
+
t.check(Boolean(clicked), 'a mouse click in the terminal reaches the agent (SGR mouse report)', 'screen: ' + tail(screen(), 800));
|
|
119
|
+
const mark = screen().length;
|
|
120
|
+
const after = () => screen().slice(mark);
|
|
121
|
+
send('\x1b[<64;10;5M');
|
|
122
|
+
t.check(Boolean(await t.waitFor(() => after().includes('got <1b>[<64;10;5M'), WAIT.screen)), 'the mouse wheel reaches the agent while it tracks the mouse', 'screen: ' + tail(after(), 800));
|
|
123
|
+
send('\x7f');
|
|
124
|
+
t.check(Boolean(await t.waitFor(() => after().includes('got <7f>'), WAIT.screen)), 'Backspace reaches the agent as DEL (one character, not a word)', 'screen: ' + tail(after(), 800));
|
|
125
|
+
send('\x08');
|
|
126
|
+
t.check(Boolean(await t.waitFor(() => after().includes('got <08>'), WAIT.screen)), 'Ctrl+Backspace reaches the agent as ^H', 'screen: ' + tail(after(), 800));
|
|
127
|
+
send('\x1b[');
|
|
128
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
129
|
+
send('C');
|
|
130
|
+
t.check(Boolean(await t.waitFor(() => after().includes('got <1b>[C'), WAIT.screen)), 'a Right arrow that reaches the client in two pieces arrives whole (no stray "C")', 'screen: ' + tail(after(), 800));
|
|
131
|
+
const before = screen().length;
|
|
132
|
+
send('L');
|
|
133
|
+
const done = await t.waitFor(() => screen().includes('LINES-DONE'), WAIT.screen);
|
|
134
|
+
const printed = screen().slice(before);
|
|
135
|
+
const seen = new Set([...printed.matchAll(/line (\d+)\b/g)].map((m) => Number(m[1])));
|
|
136
|
+
const missing = Array.from({ length: 500 }, (_, i) => i + 1).filter((n) => !seen.has(n)).map((n) => 'line ' + n);
|
|
137
|
+
t.check(Boolean(done) && missing.length === 0, 'all 500 printed lines reach the terminal as lines', 'missing: ' + missing.slice(0, 10).join(', ') + '\n' + tail(printed));
|
|
138
|
+
// (The client clears the tab once when it starts; from then on nothing may take the scrollback away.)
|
|
139
|
+
const session = screen().slice(screen().indexOf('AGENT-READY'));
|
|
140
|
+
for (const [sequence, what] of [['\x1b[?1049h', 'alternate screen'], ['\x1b[?47h', 'alternate screen (47)'], ['\x1b[?1047h', 'alternate screen (1047)'], ['\x1b[3J', 'clear scrollback']]) {
|
|
141
|
+
t.excludes(session, sequence, 'the terminal is never sent ' + what + ' (' + JSON.stringify(sequence) + ')');
|
|
142
|
+
}
|
|
143
|
+
if (kind) {
|
|
144
|
+
// A viewer that joins after the agent cleared its screen: the replay starts at the clear, the modes come first.
|
|
145
|
+
send('K');
|
|
146
|
+
await t.waitFor(() => screen().includes('CLEARED'), WAIT.screen);
|
|
147
|
+
const late = await openViewer({ addresses: ['127.0.0.1'], port: innerPort, token: 'tok' });
|
|
148
|
+
viewers.push(late);
|
|
149
|
+
await t.waitFor(() => late.output().includes('CLEARED'), WAIT.screen);
|
|
150
|
+
const head = late.output().slice(0, late.output().indexOf('CLEARED'));
|
|
151
|
+
t.check(head.includes('\x1b[?1000h') && head.includes('\x1b[?1006h'), 'a viewer joining after a screen clear gets the agent\'s mouse tracking before the replay', 'late viewer: ' + JSON.stringify(late.output().slice(0, 300)));
|
|
152
|
+
late.close();
|
|
153
|
+
}
|
|
154
|
+
send('q');
|
|
155
|
+
const ended = await t.waitFor(() => screen().includes('CLIENT-DONE'), WAIT.exit);
|
|
156
|
+
t.check(Boolean(ended), 'the client ends with the agent', 'screen: ' + tail(screen()));
|
|
157
|
+
if (kind)
|
|
158
|
+
t.includes(screen().slice(screen().lastIndexOf('got q')), '\x1b[?1000l', 'leaving the session turns mouse tracking off in the terminal again');
|
|
159
|
+
}
|
|
160
|
+
finally {
|
|
161
|
+
for (const v of viewers)
|
|
162
|
+
v.close();
|
|
163
|
+
for (const c of children)
|
|
164
|
+
if (c.exitCode === null)
|
|
165
|
+
c.kill();
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
},
|
|
169
|
+
};
|
|
@@ -50,14 +50,39 @@ export const localConfigSuite = {
|
|
|
50
50
|
await rejectedBecause({ claudeProfile: { hostDir: 'C:\\x' } }, 'claudeProfile.hostDir is not allowed', 'a project file cannot point at the Claude profile folder');
|
|
51
51
|
await rejectedBecause({ toolchains: { node: { hostDir: 'x' } } }, 'toolchains.node.hostDir is not allowed', 'a project file cannot point at host tool folders');
|
|
52
52
|
await rejectedBecause({ sandbox: { memoryMb: 12 } }, 'sandbox.memoryMb:', 'values are type-checked (memory too small)');
|
|
53
|
-
await rejectedBecause({ mappings: [{ host: 'x', guest: 'C:\\Windows\\x', readOnly: true }] }, 'reserved or invalid guest path', 'reserved guest paths are rejected');
|
|
54
|
-
await rejectedBecause({ mappings: [{ host: '../shared', guest: 'C:\\Shared', readOnly: true }] }, 'only map folders inside the project', 'a project file cannot map folders outside the project');
|
|
55
|
-
await rejectedBecause({ mappings: [{ host: '%APPDATA%', guest: 'C:\\Startup', readOnly: false }] }, 'only map folders inside the project', '... not even through environment variables');
|
|
56
53
|
await rejectedBecause({ provisioning: { git: { access: 'admin' } } }, 'provisioning.git.access:', 'unknown git access modes are rejected');
|
|
57
54
|
await rejectedBecause('{\n // a comment\n "readOnly": tru\n}', 'line 3', 'broken JSON is reported with its line');
|
|
58
55
|
await rejectedBecause({ _comment: 'old style' }, '_comment is not allowed', 'unknown keys are rejected');
|
|
59
56
|
f.writeLocal(dirC, '// comments and trailing commas are fine\n{\n "readOnly": true, /* inline */\n}\n');
|
|
60
57
|
t.equal((await f.run({ cwd: dirC, trustConfig: true })).config.readOnly, true, '.sandbox-config.json may contain comments and trailing commas');
|
|
58
|
+
t.section('folders outside the project (projects.allowOutsideMappings)');
|
|
59
|
+
const dirD = t.dir('work-d');
|
|
60
|
+
const withOutside = (setting) => ({ ...f.baseConfig, projects: { ...f.baseConfig.projects, allowOutsideMappings: setting } });
|
|
61
|
+
const outsideFile = { mappings: [{ host: '../shared', readOnly: true }] };
|
|
62
|
+
f.writeLocal(dirD, outsideFile);
|
|
63
|
+
const notAllowed = await t.thrownMessage(() => f.run({ cwd: dirD, trustConfig: true }, withOutside(false)));
|
|
64
|
+
t.check(notAllowed.includes('outside the project') && notAllowed.includes(f.shared) && notAllowed.includes('projects.allowOutsideMappings'), 'false: refused, naming the folder and the setting that allows it', notAllowed);
|
|
65
|
+
const allowedOutside = await f.run({ cwd: dirD, trustConfig: true }, withOutside(true));
|
|
66
|
+
t.check(allowedOutside.config.mappings.some((m) => m.host === f.shared) && (allowedOutside.outsideMappings ?? []).includes(f.shared), 'true: allowed, and marked as outside the project');
|
|
67
|
+
f.writeLocal(dirD, { ...outsideFile, readOnly: true });
|
|
68
|
+
t.includes(await t.thrownMessage(() => f.run({ cwd: dirD }, withOutside(true))), '(outside the project)', 'true: still listed, flagged, when you approve the file');
|
|
69
|
+
const asking = await t.thrownMessage(() => f.run({ cwd: dirD, trustConfig: true, ask: async () => true, interactive: false }));
|
|
70
|
+
t.check(asking.includes('projects.allowOutsideMappings') && asking.includes('interactive'), '"ask" without a terminal (even with --trust-config): refused, naming the setting and that an interactive launch asks', asking);
|
|
71
|
+
t.includes(await t.thrownMessage(() => f.run({ cwd: dirD, trustConfig: true, ask: async () => false, interactive: true })), 'You did not allow', '"ask": answering no stops the launch');
|
|
72
|
+
const questions = [];
|
|
73
|
+
f.writeLocal(dirD, { ...outsideFile, sandbox: { vGpu: true } });
|
|
74
|
+
const asked = await f.run({ cwd: dirD, trustConfig: true, interactive: true, ask: async (q) => { questions.push(q); return true; } });
|
|
75
|
+
t.check(questions.length === 1 && asked.config.sandbox.vGpu && asked.config.mappings.some((m) => m.host === f.shared), '"ask": the folder and a looser setting (vGPU) are asked together, once', JSON.stringify(questions));
|
|
76
|
+
t.check((await f.run({ cwd: dirD, trustConfig: true, interactive: false })).config.mappings.some((m) => m.host === f.shared), '"ask": the answer is remembered for this project file');
|
|
77
|
+
// What counts as outside is where the folder really is: environment variables are expanded and links followed.
|
|
78
|
+
f.writeLocal(dirD, { mappings: [{ host: '%APPDATA%', readOnly: false }] });
|
|
79
|
+
t.includes(await t.thrownMessage(() => f.run({ cwd: dirD, trustConfig: true }, withOutside(false))), 'outside the project', '%VAR% is expanded before deciding inside or outside');
|
|
80
|
+
fs.symlinkSync(f.shared, path.join(dirD, 'link'), 'junction');
|
|
81
|
+
f.writeLocal(dirD, { mappings: [{ host: './link', readOnly: true }] });
|
|
82
|
+
t.includes(await t.thrownMessage(() => f.run({ cwd: dirD, trustConfig: true }, withOutside(false))), 'outside the project', 'a link inside the project to a folder outside counts as outside');
|
|
83
|
+
fs.rmSync(path.join(dirD, 'link'));
|
|
84
|
+
f.writeLocal(dirD, { mappings: [{ host: './assets', readOnly: true }] });
|
|
85
|
+
t.check((await f.run({ cwd: dirD, trustConfig: true }, withOutside(false))).config.mappings.some((m) => m.host === path.join(dirD, 'assets')), 'a folder inside the project needs no permission');
|
|
61
86
|
t.section('a project can only tighten protections');
|
|
62
87
|
// Settings that would loosen a protection are listed before approval, then ignored.
|
|
63
88
|
f.writeLocal(dirC, {
|
|
@@ -4,7 +4,7 @@ import path from 'node:path';
|
|
|
4
4
|
import { Config, Mapping } from '../core/schema.js';
|
|
5
5
|
import { guestPathFor } from '../cli/commands/context.js';
|
|
6
6
|
import { buildWsb } from '../host/launch/wsb.js';
|
|
7
|
-
import { guestDrive, isValidGuestPath, planMirrorWorkspace, sandboxFolder, substDrives } from '../shared/guest-paths.js';
|
|
7
|
+
import { deriveGuest, guestDrive, isValidGuestPath, planMirrorWorkspace, sandboxFolder, substDrives } from '../shared/guest-paths.js';
|
|
8
8
|
import { guestWorkspaceAliases, projectFolderName } from '../shared/paths.js';
|
|
9
9
|
import { syncHistoryIn, syncHistoryOut } from '../sync/history.js';
|
|
10
10
|
export const mirrorSuite = {
|
|
@@ -17,8 +17,7 @@ export const mirrorSuite = {
|
|
|
17
17
|
const invalid = ['C:\\', 'F:\\', 'F:', 'C:\\Windows\\x', 'C:\\Users\\me', 'c:\\program files\\x', 'C:\\AgentData\\x', 'C:\\Mirror', 'C:\\Mirror\\F\\x', 'A:\\x', 'B:\\x', 'C:\\a\\..\\Windows', 'F:\\a\\..\\b', 'F:\\a:b', 'F:\\a/b', 'F:\\a\\\\b', 'F:\\a.\\b', 'F:\\a|b', 'F:\\a\nb', '\\\\server\\share\\x', 'relative\\x', 'F:x'];
|
|
18
18
|
t.equal(invalid.filter(isValidGuestPath), [], 'drive roots, reserved folders, C:\\Mirror, A:/B:, .., streams, odd characters and UNC paths are refused');
|
|
19
19
|
const mappingAccepts = (guest) => Mapping.safeParse({ host: 'E:\\Games', guest, readOnly: true }).success;
|
|
20
|
-
t.check(mappingAccepts('F:\\SteamLibrary\\Game') && mappingAccepts('C:\\
|
|
21
|
-
t.check(!mappingAccepts('C:\\Mirror\\F\\x') && !mappingAccepts('F:\\'), 'schema: mappings[].guest may never be C:\\Mirror or a drive root');
|
|
20
|
+
t.check(mappingAccepts('F:\\SteamLibrary\\Game') && mappingAccepts('C:\\Windows\\x') && mappingAccepts(undefined), 'schema: any mappings[].guest is accepted, or none (the launch places it; it never fails the config)');
|
|
22
21
|
const workspaceAccepts = (guestWorkspace) => Config.safeParse({ ...f.makeConfig(), guestWorkspace }).success;
|
|
23
22
|
t.check(workspaceAccepts('mirror') && workspaceAccepts('F:\\AgentProjects\\x'), 'schema: guestWorkspace accepts "mirror" and D:-Z: paths');
|
|
24
23
|
t.check(!workspaceAccepts('C:\\Users\\x'), 'schema: guestWorkspace refuses reserved paths');
|
|
@@ -64,10 +63,40 @@ export const mirrorSuite = {
|
|
|
64
63
|
t.includes(text, '<SandboxFolder>C:\\Mirror\\G\\Projects\\work-a</SandboxFolder>', '.wsb: the workspace is under C:\\Mirror');
|
|
65
64
|
t.includes(text, '<SandboxFolder>C:\\Mirror\\F\\SteamLibrary\\steamapps\\common\\Game</SandboxFolder>', '.wsb: the mapping is under C:\\Mirror');
|
|
66
65
|
t.equal(f.readJson(f.generate(base).sessionPath).substDrives, [], 'no other drive letters: no subst drives');
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
66
|
+
t.section('where an extra folder goes: guest left out, or one the sandbox cannot use');
|
|
67
|
+
t.equal([
|
|
68
|
+
deriveGuest('F:\\P\\mw2\\data\\maps', 'F:\\P\\mw2', 'C:\\Workspace\\mw2'),
|
|
69
|
+
deriveGuest('C:\\Steam\\MOHAA', 'F:\\P\\mw2', 'C:\\Workspace\\mw2'),
|
|
70
|
+
deriveGuest('f:\\SteamLibrary\\steamapps\\common\\MW2\\', 'F:\\P\\mw2', 'C:\\Workspace\\mw2'),
|
|
71
|
+
], ['C:\\Workspace\\mw2\\data\\maps', 'C:\\Steam\\MOHAA', 'F:\\SteamLibrary\\steamapps\\common\\MW2'], 'no guest: inside the project = the same place in it; outside = the same path as on the PC (C: as is, F: through C:\\Mirror\\F + subst)');
|
|
72
|
+
const other = t.dir('steam', 'Other');
|
|
73
|
+
const placed = (mappings) => {
|
|
74
|
+
const g = f.generate({ ...run, config: { ...run.config, mappings } });
|
|
75
|
+
return { guests: g.mappings.filter((m) => m.host === game || m.host === other).map((m) => m.guest), notes: g.mappingNotes, wsb: fs.readFileSync(g.wsbPath, 'utf8') };
|
|
76
|
+
};
|
|
77
|
+
const awkward = placed([
|
|
78
|
+
{ host: game, guest: 'C:\\Windows\\foo', readOnly: true },
|
|
79
|
+
{ host: game, guest: 'C:\\Users\\x', readOnly: true },
|
|
80
|
+
{ host: game, guest: 'C:\\AgentData\\y', readOnly: true },
|
|
81
|
+
{ host: game, guest: 'C:\\Mirror\\F\\x', readOnly: true },
|
|
82
|
+
{ host: game, guest: 'D:\\Games', readOnly: true },
|
|
83
|
+
{ host: game, guest: 'G:\\Projects\\work-a', readOnly: true },
|
|
84
|
+
{ host: game, guest: 'c:/assets/', readOnly: true },
|
|
85
|
+
{ host: other, guest: 'C:\\Assets', readOnly: true },
|
|
86
|
+
{ host: game, guest: 'C:\\Assets', readOnly: true },
|
|
87
|
+
]);
|
|
88
|
+
t.equal(awkward.guests, ['C:\\Mapped\\C\\Windows\\foo', 'C:\\Mapped\\C\\Users\\x', 'C:\\Mapped\\C\\AgentData\\y', 'C:\\Mapped\\C\\Mirror\\F\\x', 'D:\\Games', 'C:\\Mapped\\G\\Projects\\work-a', 'C:\\assets', 'C:\\Mapped\\C\\Assets'], 'reserved paths, C:\\Mirror and the workspace itself go to C:\\Mapped\\<drive>\\<path>; D:\\Games and a tidied c:/assets/ are used as they are; a repeat is mapped once; a second folder on the same path moves');
|
|
89
|
+
t.check(awkward.notes.some((n) => n.includes('C:\\Windows\\foo') && n.includes('keeps for itself') && n.includes('C:\\Mapped\\C\\Windows\\foo')), 'a note says where a reserved path went and why', awkward.notes.join('\n'));
|
|
90
|
+
t.check(awkward.notes.some((n) => n.startsWith(other) && n.includes('already used by') && n.includes(game)), 'a conflict note names both folders', awkward.notes.join('\n'));
|
|
91
|
+
t.check(!/<SandboxFolder>C:\\(Windows|Users|AgentData\\y|Mirror\\F\\x)/i.test(awkward.wsb), 'a reserved guest path never reaches the .wsb');
|
|
92
|
+
const derived = placed([{ host: game, readOnly: true }]);
|
|
93
|
+
t.check(derived.guests.length === 1 && (/^C:\\Mapped\\C\\Users\\/i.test(derived.guests[0]) || derived.guests[0].toLowerCase() === game.toLowerCase()), 'no guest, outside the project: the PC path, or C:\\Mapped\\... when that path is reserved (the temp folder is under C:\\Users)', JSON.stringify(derived));
|
|
94
|
+
const inside = t.dir('work-a', 'data');
|
|
95
|
+
const insideRun = await f.run();
|
|
96
|
+
const insideGen = f.generate({ ...insideRun, config: { ...insideRun.config, mappings: [{ host: inside, readOnly: true }] } });
|
|
97
|
+
t.check(!insideGen.mappings.some((m) => m.host === inside) && insideGen.mappingNotes.some((n) => n.includes('already in the sandbox at C:\\Workspace\\work-a\\data')), 'no guest, inside the project: already there, no second mapping', insideGen.mappingNotes.join('\n'));
|
|
98
|
+
const missingRequired = await t.thrownMessage(() => f.generate({ ...run, config: { ...run.config, mappings: [{ host: t.at('nope-1'), readOnly: true }, { host: t.at('nope-2'), guest: 'C:\\Windows', readOnly: true }] } }));
|
|
99
|
+
t.check(missingRequired.includes('2 mapped folders cannot be used') && missingRequired.includes('nope-1') && missingRequired.includes('nope-2') && missingRequired.includes('"optional": true'), 'folders that are not there: one message listing each, with the fix', missingRequired);
|
|
71
100
|
t.section('exec, attach and history for a mirrored workspace');
|
|
72
101
|
const workspace = 'F:\\AgentProjects\\mw2-agent';
|
|
73
102
|
t.equal(guestPathFor(workspace + '\\crates\\engine', workspace, workspace), workspace + '\\crates\\engine', 'a host folder maps to the same guest folder');
|
|
@@ -4,15 +4,16 @@
|
|
|
4
4
|
import fs from 'node:fs';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { LocalOverrides } from '../core/schema.js';
|
|
7
|
-
import { checkJoin, protectionsOf } from '../core/project-policy.js';
|
|
7
|
+
import { checkJoin, describeMissing, missingAtBoot, missingTip, nothingMissing, protectionsOf } from '../core/project-policy.js';
|
|
8
|
+
import { allowStorePath, saveAllowed } from '../core/local-config.js';
|
|
8
9
|
import { sessionsFirstIn } from '../host/channel.js';
|
|
9
10
|
import { buildWsb } from '../host/launch/wsb.js';
|
|
10
11
|
import { projectSpec, joinRunning } from '../host/join.js';
|
|
11
|
-
import { allProjectsToMap, forgetProject, NeedsRestart, projectHere, projectMapper, readKnown, readRegistry, rememberProject, startRegistry } from '../host/projects.js';
|
|
12
|
+
import { allProjectsToMap, forgetProject, NeedsRestart, parseWsbBoot, projectHere, projectMapper, readKnown, readRegistry, rememberProject, startRegistry } from '../host/projects.js';
|
|
12
13
|
import { checkRepo, saveRepoSnapshot } from '../host/repo-guard.js';
|
|
13
14
|
import { findWsb, parseCommands, parseIp, parseList, parseVersion } from '../host/wsb.js';
|
|
14
15
|
import { baseEnvFor } from '../guest/project-env.js';
|
|
15
|
-
import { guestDirFor, hostDirFor, hubName, insideProjects, isBelow, isWithin, planProjectMappings, projectForGuestDir, projectForHostDir, PROJECTS_GUEST } from '../shared/projects.js';
|
|
16
|
+
import { guestDirFor, hostDirFor, hubName, insideProjects, isBelow, isWithin, planProjectMappings, projectForGuestDir, projectForHostDir, PROJECTS_GUEST, rootGuestDir } from '../shared/projects.js';
|
|
16
17
|
import { GuestRequest, ProjectSpec } from '../shared/schema.js';
|
|
17
18
|
import { projectFolderName } from '../shared/paths.js';
|
|
18
19
|
import { syncHistoryOut } from '../sync/history.js';
|
|
@@ -57,7 +58,11 @@ export const projectsSuite = {
|
|
|
57
58
|
t.check(isBelow('C:\\a', 'C:\\a\\b') && !isBelow('C:\\a', 'C:\\a'), 'isBelow is strict');
|
|
58
59
|
const plan = planProjectMappings(['F:\\Code', 'E:\\Work\\Code', 'C:\\Users\\me\\src'], ['D:\\x\\api', 'D:\\y\\api'], false);
|
|
59
60
|
t.equal(plan.roots.map((r) => r.guest), ['C:\\Projects\\Code', 'C:\\Projects\\Code-2', 'C:\\Projects\\src'], 'roots get unique C:\\Projects\\<name> folders');
|
|
60
|
-
t.equal(plan.projects.map((p) => p.guest), ['C:\\
|
|
61
|
+
t.equal(plan.projects.map((p) => p.guest), ['C:\\Workspace\\api', 'C:\\Workspace\\api-2'], 'projects mapped on their own get unique C:\\Workspace\\<name> folders (where they are as the launch folder)');
|
|
62
|
+
t.equal(planProjectMappings([], ['D:\\x\\app'], false, ['C:\\Workspace\\app']).projects[0].guest, 'C:\\Workspace\\app-2', 'a project mapped on its own never lands on the launch folder\'s place');
|
|
63
|
+
t.equal(planProjectMappings(['F:\\Code'], ['D:\\x\\api'], true).projects[0].guest, 'D:\\x\\api', 'with "mirror" a project mapped on its own appears at its PC path, as it would as the launch folder');
|
|
64
|
+
t.equal(rootGuestDir(['F:\\Code'], false, 'F:\\Code\\web\\src'), 'C:\\Projects\\Code\\web\\src', 'a folder inside a root is placed under the root\'s guest folder');
|
|
65
|
+
t.equal(rootGuestDir(['F:\\Code'], false, 'F:\\Other'), null, 'a folder in no root has no root place');
|
|
61
66
|
const mirror = planProjectMappings(['F:\\Code', 'C:\\Users\\me\\src'], [], true);
|
|
62
67
|
t.equal(mirror.roots.map((r) => r.guest), ['F:\\Code', 'C:\\Projects\\src'], 'with "mirror" a root appears at its PC path when the sandbox can show it there (C:\\Users\\... cannot)');
|
|
63
68
|
t.section('projects: tighten-only protections when joining a running sandbox');
|
|
@@ -159,7 +164,23 @@ export const projectsSuite = {
|
|
|
159
164
|
t.check(gen.projects.notes.some((n) => n.includes('no-such-root')), 'a missing root is skipped with a note', JSON.stringify(gen.projects.notes));
|
|
160
165
|
t.check(gen.projects.projects.some((p) => p.host === outside), 'projects.all maps the known projects too');
|
|
161
166
|
t.includes(wsb, '<HostFolder>' + root + '</HostFolder><SandboxFolder>C:\\Projects\\proj-root</SandboxFolder><ReadOnly>false</ReadOnly>', 'roots are mapped read/write in the .wsb');
|
|
162
|
-
t.equal({ projectsDir: session.projectsDir, roots: session.roots, projectId: session.projectId }, { projectsDir: PROJECTS_GUEST, roots: ['C:\\Projects\\proj-root'], projectId: 'work-a' }, 'session.json tells the guest where other projects can be');
|
|
167
|
+
t.equal({ projectsDir: session.projectsDir, roots: session.roots, projectId: session.projectId }, { projectsDir: PROJECTS_GUEST, roots: ['C:\\Projects\\proj-root', 'C:\\Workspace\\proj-outside'], projectId: 'work-a' }, 'session.json tells the guest where other projects can be');
|
|
168
|
+
// The same project is at the same place whether it started the sandbox or joined one another project started.
|
|
169
|
+
const fromRoot = f.generate(await f.run({ cwd: inRoot }, rootConfig));
|
|
170
|
+
const joinedAt = guestDirFor(gen.projects.roots[0], inRoot);
|
|
171
|
+
t.equal([f.readJson(fromRoot.sessionPath).workspace, joinedAt], ['C:\\Projects\\proj-root\\web', 'C:\\Projects\\proj-root\\web'], 'a project in a root: the same guest folder as the launch folder and when another project started the sandbox');
|
|
172
|
+
t.check(!fromRoot.mappings.some((m) => m.host.toLowerCase() === inRoot.toLowerCase()), 'a launch folder inside a root is shown by the root (no second mapping of it)');
|
|
173
|
+
t.includes(fs.readFileSync(path.join(fromRoot.launchPath, 'env.cmd'), 'utf8'), 'AGENT_WORKSPACE=C:\\Projects\\proj-root\\web', 'env.cmd: AGENT_WORKSPACE follows');
|
|
174
|
+
const asLaunch = f.readJson(f.generate(await f.run({ cwd: outside })).sessionPath).workspace;
|
|
175
|
+
t.equal([asLaunch, gen.projects.projects.find((p) => p.host === outside)?.guest], ['C:\\Workspace\\proj-outside', 'C:\\Workspace\\proj-outside'], 'a project outside the roots: the same guest folder as the launch folder and when mapped at boot by another');
|
|
176
|
+
const mirrorRoot = structuredClone(rootConfig);
|
|
177
|
+
mirrorRoot.guestWorkspace = 'mirror';
|
|
178
|
+
t.equal(f.readJson(f.generate(await f.run({ cwd: inRoot }, mirrorRoot)).sessionPath).workspace, rootGuestDir([root], true, inRoot), 'with "mirror" a launch folder inside a root is where the root shows it (no overlapping mappings)');
|
|
179
|
+
const filtered = structuredClone(rootConfig);
|
|
180
|
+
filtered.workspaceFilter = { ...filtered.workspaceFilter, enabled: true };
|
|
181
|
+
const filteredRun = await f.run({ cwd: inRoot }, filtered);
|
|
182
|
+
t.equal(filteredRun.config.guestWorkspace, 'C:\\Workspace\\web', 'workspaceFilter: a launch folder inside a root keeps its own (filtered) mapping');
|
|
183
|
+
t.check(filteredRun.notes.some((n) => n.includes('C:\\Projects\\proj-root\\web')), 'and a note says where it is when another project starts the sandbox', JSON.stringify(filteredRun.notes));
|
|
163
184
|
const badRoot = structuredClone(f.baseConfig);
|
|
164
185
|
badRoot.projects = { ...badRoot.projects, roots: [f.baseConfig.storage.root] };
|
|
165
186
|
await t.throws(async () => f.generate(await f.run({}, badRoot)), 'projects.roots', 'a root that would expose the storage folder is refused');
|
|
@@ -233,9 +254,57 @@ export const projectsSuite = {
|
|
|
233
254
|
const multiGen = generateSandbox({ packageRoot: t.packageRoot, run: await f.run(), harnessArgs: [], probe: f.probe, extraProjects: [f.workB, path.join(f.workA, 'sub'), f.workB], autoSession: false });
|
|
234
255
|
const multiSession = f.readJson(multiGen.sessionPath);
|
|
235
256
|
const multiWsb = fs.readFileSync(multiGen.wsbPath, 'utf8');
|
|
236
|
-
t.equal(multiGen.projects.projects.map((p) => p.guest), ['C:\\
|
|
237
|
-
t.includes(multiWsb, '<HostFolder>' + f.workB + '</HostFolder><SandboxFolder>C:\\
|
|
257
|
+
t.equal(multiGen.projects.projects.map((p) => p.guest), ['C:\\Workspace\\work-b'], 'a multi-project launch maps every other project once (folders inside the launch folder and duplicates need nothing)');
|
|
258
|
+
t.includes(multiWsb, '<HostFolder>' + f.workB + '</HostFolder><SandboxFolder>C:\\Workspace\\work-b</SandboxFolder>', 'a multi-project launch maps the other project in the .wsb');
|
|
238
259
|
t.equal({ autoSession: multiSession.autoSession, workspaceHost: multiSession.workspaceHost }, { autoSession: false, workspaceHost: f.workA }, 'the boot provisions the launch folder only; the host then opens every session (each project set up on demand, in parallel)');
|
|
260
|
+
t.section('projects: what a joining project wants at boot (vGPU, memory, its mappings)');
|
|
261
|
+
// A games project: its approved file asks for vGPU (you allowed it for this project) and maps a folder of its own.
|
|
262
|
+
const games = t.dir('proj-games');
|
|
263
|
+
t.dir('proj-games', 'assets');
|
|
264
|
+
f.writeLocal(games, { sandbox: { vGpu: true }, mappings: [{ host: 'assets', guest: 'F:\\GameAssets', readOnly: true }] });
|
|
265
|
+
saveAllowed(allowStorePath(f.baseConfig.storage.root), path.join(games, '.sandbox-config.json'), 'sandbox.vGpu', true, true);
|
|
266
|
+
const gamesRun = await f.run({ cwd: games, trustConfig: true });
|
|
267
|
+
t.equal(gamesRun.config.sandbox.vGpu, true, 'the vGPU you allowed for the project is in its effective config');
|
|
268
|
+
const launchedRun = await f.run();
|
|
269
|
+
const runningBoot = { memoryMb: 8192, mappings: [{ host: f.workA, guest: 'C:\\Workspace\\work-a', readOnly: false }] };
|
|
270
|
+
const wanted = missingAtBoot(protectionsOf(launchedRun.config), runningBoot, gamesRun.config, fs.existsSync);
|
|
271
|
+
t.equal({ settings: wanted.settings, memoryMb: wanted.memoryMb, mappings: wanted.mappings.map((m) => m.guest) }, { settings: ['sandbox.vGpu'], memoryMb: null, mappings: ['F:\\GameAssets'] }, 'missingAtBoot: vGPU and the project\'s folder are missing from a sandbox started without them (the missing optional mapping is no wish)');
|
|
272
|
+
const mappedMirror = missingAtBoot(protectionsOf(gamesRun.config), { memoryMb: 8192, mappings: [{ host: path.join(games, 'assets'), guest: 'C:\\Mirror\\F\\GameAssets', readOnly: true }] }, gamesRun.config, fs.existsSync);
|
|
273
|
+
t.check(nothingMissing(mappedMirror), 'missingAtBoot: nothing missing when the sandbox has vGPU and the folder (F:\\x is C:\\Mirror\\F\\x in the .wsb)', JSON.stringify(mappedMirror));
|
|
274
|
+
const bigger = structuredClone(launchedRun.config);
|
|
275
|
+
bigger.sandbox.memoryMb = 16384;
|
|
276
|
+
t.equal(missingAtBoot(protectionsOf(launchedRun.config), runningBoot, bigger).memoryMb, 16384, 'missingAtBoot: a larger fixed memory size is missing too');
|
|
277
|
+
const twoFolders = { settings: ['sandbox.vGpu'], memoryMb: null, mappings: [{ host: 'C:\\Steam\\common', guest: 'C:\\Steam', readOnly: true }, { host: 'F:\\SteamLibrary', guest: 'F:\\SteamLibrary', readOnly: true }] };
|
|
278
|
+
t.equal(describeMissing('mw2-agent', twoFolders), 'mw2-agent wants vGPU and 2 folders (C:\\Steam\\common, F:\\SteamLibrary), but this sandbox started without them.', 'the message names each missing item');
|
|
279
|
+
t.equal(missingTip(twoFolders), 'Set sandbox.vGpu (or the mappings) in your own settings to have them in every sandbox.', 'the tip names the settings');
|
|
280
|
+
t.equal(describeMissing('api', { settings: ['sandbox.vGpu'], memoryMb: null, mappings: [] }), 'api wants vGPU, but this sandbox started without it.', 'one item reads naturally');
|
|
281
|
+
t.equal(parseWsbBoot(buildWsb(f.baseConfig, 6144, [{ host: 'C:\\a&b', guest: 'F:\\x', readOnly: true }])), { memoryMb: 6144, mappings: [{ host: 'C:\\a&b', guest: 'C:\\Mirror\\F\\x', readOnly: true }], widened: {} }, 'a sandbox started by an older version: memory and mapped folders are read back from its .wsb');
|
|
282
|
+
const widenedJoin = checkJoin({ ...base, 'sandbox.vGpu': true }, base, ['sandbox.vGpu']);
|
|
283
|
+
t.equal({ stricter: widenedJoin.stricter, shared: widenedJoin.shared }, { stricter: [], shared: ['sandbox.vGpu'] }, 'vGPU turned on at boot for another project you allowed it for: a project without it shares it instead of being refused');
|
|
284
|
+
t.check(checkJoin({ ...base, 'sandbox.vGpu': true }, base).stricter.some((s) => s.startsWith('sandbox.vGpu')), '... without that record a project without vGPU is still refused (it needs it off)');
|
|
285
|
+
// joinRunning asks before anything is registered: restart (NeedsRestart with what is missing), or continue without.
|
|
286
|
+
const storage2 = t.dir('proj-storage2');
|
|
287
|
+
const data2 = t.dir('proj-storage2', 'data', 'testdata');
|
|
288
|
+
startRegistry(storage2, { launchId: '1700000000001-abcdef', protections: protectionsOf(launchedRun.config), primary: { host: f.workA, guest: 'C:\\Workspace\\work-a' }, roots: [{ host: path.dirname(games), guest: 'C:\\Projects\\temp' }], boot: [], atBoot: { ...runningBoot, widened: {} } });
|
|
289
|
+
const joinGames = (choice) => joinRunning({ storageRoot: storage2, dataPath: data2, packageRoot: f.install, hostDir: games, wsbShare: false, resolve: resolveFor(gamesRun), decide: async () => choice });
|
|
290
|
+
const restartFor = await joinGames('restart').then(() => null, (e) => e);
|
|
291
|
+
t.check(restartFor instanceof NeedsRestart && restartFor.missing?.settings.includes('sandbox.vGpu') === true && restartFor.missing.mappings.length === 1, 'choosing "Restart with them" hands the restart offer what is missing');
|
|
292
|
+
await t.throws(() => joinGames('cancel'), 'Cancelled', '"Cancel" stops with a short line');
|
|
293
|
+
t.check(!readRegistry(storage2)?.projects.some((p) => p.host === games), 'neither registers the project');
|
|
294
|
+
const continued = await joinGames('continue');
|
|
295
|
+
t.equal(continued.missing?.settings, ['sandbox.vGpu'], '"Continue anyway" joins without them and says what is missing');
|
|
296
|
+
// The restart that adds it (and any launch with it mapped at boot) boots with the union.
|
|
297
|
+
const unionGen = generateSandbox({ packageRoot: t.packageRoot, run: launchedRun, harnessArgs: [], probe: f.probe, extraProjects: [games], autoSession: false, bootWants: { projects: [{ name: 'proj-games', config: gamesRun.config }], keepOff: [] } });
|
|
298
|
+
const unionWsb = fs.readFileSync(unionGen.wsbPath, 'utf8');
|
|
299
|
+
t.includes(unionWsb, '<vGPU>Enable</vGPU>', 'the restart plan: vGPU on because the added project asks for it (and you allowed it)');
|
|
300
|
+
t.includes(unionWsb, '<HostFolder>' + path.join(games, 'assets') + '</HostFolder><SandboxFolder>C:\\Mirror\\F\\GameAssets</SandboxFolder><ReadOnly>true</ReadOnly>', 'the restart plan: the added project\'s mapping is mapped');
|
|
301
|
+
t.includes(unionWsb, '<HostFolder>' + games + '</HostFolder><SandboxFolder>C:\\Workspace\\proj-games</SandboxFolder>', 'the restart plan: the added project itself is mapped (where it is as the launch folder)');
|
|
302
|
+
t.equal(unionGen.widened, { 'sandbox.vGpu': 'proj-games' }, 'the registry learns which boot setting was turned on for which project');
|
|
303
|
+
t.check(unionGen.projects.notes.some((n) => n.startsWith('vGPU is on in this sandbox')), 'the launch says vGPU is on for every project and why', JSON.stringify(unionGen.projects.notes));
|
|
304
|
+
t.check(nothingMissing(missingAtBoot(protectionsOf(unionGen.bootConfig), { memoryMb: unionGen.memoryMb, mappings: unionGen.mappings }, gamesRun.config, fs.existsSync)), 'after that restart the project joins with nothing missing');
|
|
305
|
+
const keptOff = generateSandbox({ packageRoot: t.packageRoot, run: launchedRun, harnessArgs: [], probe: f.probe, extraProjects: [games], autoSession: false, bootWants: { projects: [{ name: 'proj-games', config: gamesRun.config }], keepOff: ['sandbox.vGpu'] } });
|
|
306
|
+
t.includes(fs.readFileSync(keptOff.wsbPath, 'utf8'), '<vGPU>Disable</vGPU>', 'a device the launch folder\'s own file switched off stays off');
|
|
307
|
+
t.includes(fs.readFileSync(f.generate(launchedRun).wsbPath, 'utf8'), '<vGPU>Disable</vGPU>', 'without other projects nothing changes');
|
|
239
308
|
t.section('projects: wsb (Windows Sandbox command line) adapter');
|
|
240
309
|
t.equal([parseVersion('Windows Sandbox CLI version 0.4.1.0\n'), parseVersion('nothing')], ['0.4.1.0', null], 'wsb --version');
|
|
241
310
|
const help = 'Description:\n Windows Sandbox command line\n\nUsage:\n wsb [command] [options]\n\nOptions:\n --version Show version information\n\nCommands:\n start Starts a sandbox\n list Lists sandboxes\n exec Runs a command\n share Shares a folder\n stop Stops a sandbox\n connect Connects\n ip Shows the IP\n';
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface VtInput {
|
|
2
|
+
/** true once the console passes the terminal's input through (mouse reports included); false if it could not. */
|
|
3
|
+
ready: Promise<boolean>;
|
|
4
|
+
/** Stops a change still in progress; resolves when nothing can change the console mode any more. */
|
|
5
|
+
stop: () => Promise<void>;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Turns on ENABLE_VIRTUAL_TERMINAL_INPUT for this process's console. Call it after stdin.setRawMode(true) (which
|
|
9
|
+
* overwrites the mode); setRawMode(false) turns it off again. No-op off Windows or without a console.
|
|
10
|
+
*/
|
|
11
|
+
export declare const enableVtInput: () => VtInput;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Windows: lets the terminal's own input sequences (mouse clicks and wheel, focus) reach a Node process in raw mode.
|
|
2
|
+
// Node reads the console as key records in raw mode and drops every mouse record, so an agent that turned mouse
|
|
3
|
+
// tracking on (its terminal then sends the wheel and clicks as mouse reports) got neither. With
|
|
4
|
+
// ENABLE_VIRTUAL_TERMINAL_INPUT the console hands over the terminal's input as it is: mouse reports, focus, keys.
|
|
5
|
+
// Node has no API for the console mode, so a PowerShell child sets it on the console both share (the input mode
|
|
6
|
+
// belongs to the console, not to a handle). Only built-ins here: view.js bundles this.
|
|
7
|
+
import { spawn } from 'node:child_process';
|
|
8
|
+
import { SYSTEM } from './system.js';
|
|
9
|
+
/**
|
|
10
|
+
* PowerShell starts in a console of its own, hidden (so it never retitles your tab or touches its modes), then switches
|
|
11
|
+
* to ours. Started without any console, powershell.exe does nothing at all.
|
|
12
|
+
*/
|
|
13
|
+
const script = (pid) => [
|
|
14
|
+
'Add-Type -TypeDefinition @"',
|
|
15
|
+
'using System; using System.Runtime.InteropServices;',
|
|
16
|
+
'public static class SbxVtInput {',
|
|
17
|
+
' [DllImport("kernel32.dll")] static extern bool FreeConsole();',
|
|
18
|
+
' [DllImport("kernel32.dll")] static extern bool AttachConsole(uint pid);',
|
|
19
|
+
' [DllImport("kernel32.dll", CharSet = CharSet.Unicode)] static extern IntPtr CreateFileW(string name, uint access, uint share, IntPtr security, uint creation, uint flags, IntPtr template);',
|
|
20
|
+
' [DllImport("kernel32.dll")] static extern bool GetConsoleMode(IntPtr handle, out uint mode);',
|
|
21
|
+
' [DllImport("kernel32.dll")] static extern bool SetConsoleMode(IntPtr handle, uint mode);',
|
|
22
|
+
' [DllImport("kernel32.dll")] static extern bool CloseHandle(IntPtr handle);',
|
|
23
|
+
' public static int On(uint pid) {',
|
|
24
|
+
' FreeConsole();',
|
|
25
|
+
' if (!AttachConsole(pid)) return 2;',
|
|
26
|
+
' IntPtr input = CreateFileW("CONIN$", 0xC0000000, 3, IntPtr.Zero, 3, 0, IntPtr.Zero);',
|
|
27
|
+
' if (input == new IntPtr(-1)) return 3;',
|
|
28
|
+
' uint mode; bool ok = GetConsoleMode(input, out mode) && SetConsoleMode(input, mode | 0x200);',
|
|
29
|
+
' CloseHandle(input);',
|
|
30
|
+
' FreeConsole();',
|
|
31
|
+
' return ok ? 0 : 4;',
|
|
32
|
+
' }',
|
|
33
|
+
'}',
|
|
34
|
+
'"@',
|
|
35
|
+
'exit [SbxVtInput]::On(' + pid + ')',
|
|
36
|
+
].join('\n');
|
|
37
|
+
/**
|
|
38
|
+
* Turns on ENABLE_VIRTUAL_TERMINAL_INPUT for this process's console. Call it after stdin.setRawMode(true) (which
|
|
39
|
+
* overwrites the mode); setRawMode(false) turns it off again. No-op off Windows or without a console.
|
|
40
|
+
*/
|
|
41
|
+
export const enableVtInput = () => {
|
|
42
|
+
if (process.platform !== 'win32' || !process.stdin.isTTY)
|
|
43
|
+
return { ready: Promise.resolve(false), stop: () => Promise.resolve() };
|
|
44
|
+
let child = null;
|
|
45
|
+
let exited = Promise.resolve();
|
|
46
|
+
const ready = new Promise((resolve) => {
|
|
47
|
+
try {
|
|
48
|
+
child = spawn(SYSTEM.powershell(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', Buffer.from(script(process.pid), 'utf16le').toString('base64')], { stdio: 'ignore', windowsHide: true });
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
resolve(false);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
exited = new Promise((done) => {
|
|
55
|
+
child?.on('exit', (code) => { resolve(code === 0); done(); });
|
|
56
|
+
child?.on('error', () => { resolve(false); done(); });
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
return { ready, stop: () => { if (child && child.exitCode === null && child.signalCode === null)
|
|
60
|
+
child.kill(); return exited; } };
|
|
61
|
+
};
|