windows-development-sandbox 1.13.2 → 1.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app/features/storage/storage.run.js +1 -1
- package/dist/bin/agent-sandbox.js +2 -2
- package/dist/bin/{chunk-VENWSGX2.js → chunk-BQ23GBLC.js} +411 -58
- package/dist/bin/{chunk-K4SU3MHJ.js → chunk-F7IMME5G.js} +1 -1
- package/dist/bin/{chunk-IL7BXJF2.js → chunk-LQHEUN2A.js} +1 -1
- package/dist/bin/claude-host.js +2 -2
- package/dist/bin/claude-sbx.js +2 -2
- package/dist/bin/codex-sbx.js +2 -2
- package/dist/bin/gemini-sbx.js +2 -2
- package/dist/bin/sandbox.js +2 -2
- package/dist/bin/sbx-hide.js +2 -2
- package/dist/bin/sbx-show.js +2 -2
- package/dist/cli/commands/launch/launch.js +1 -1
- package/dist/cli/commands/repair/checks/contained.js +1 -1
- package/dist/guest/daemon/sessions.d.ts +7 -0
- package/dist/guest/daemon/sessions.js +23 -2
- package/dist/guest/entries/daemon.js +1 -0
- package/dist/guest/sessions/pty-host.d.ts +10 -0
- package/dist/guest/sessions/pty-host.js +48 -3
- package/dist/host/channel/channel.js +1 -1
- package/dist/host/install/install-record.d.ts +1 -0
- package/dist/host/install/install-record.js +2 -0
- package/dist/host/sandbox/contained-deps.d.ts +2 -1
- package/dist/host/sandbox/contained-deps.js +6 -4
- package/dist/host/sharing/lifecycle.js +9 -1
- package/dist/host/storage/contained/contained-storage.js +13 -0
- package/dist/host/storage/contained/disk-port.d.ts +5 -0
- package/dist/host/storage/contained/helper-port.d.ts +72 -0
- package/dist/host/storage/contained/helper-port.js +162 -0
- package/dist/host/storage/contained/index.d.ts +1 -0
- package/dist/host/storage/contained/index.js +1 -0
- package/dist/host/web/bridge.d.ts +5 -0
- package/dist/host/web/bridge.js +11 -1
- package/dist/host/web/server.d.ts +2 -0
- package/dist/host/web/server.js +11 -1
- package/dist/runtime/agent.js +9 -0
- package/dist/runtime/daemon.js +57 -3
- package/dist/runtime/db.js +9 -0
- package/dist/runtime/main.js +195 -12
- package/dist/runtime/netproxy.js +9 -0
- package/dist/runtime/view.js +195 -12
- package/dist/selftest/channel.test.js +72 -1
- package/dist/selftest/contained.test.js +1 -37
- package/dist/selftest/hcs.test.js +5 -2
- package/dist/selftest/index.js +2 -0
- package/dist/selftest/install.test.js +11 -0
- package/dist/selftest/live-daemon.test.js +51 -1
- package/dist/selftest/live-terminal.test.js +112 -4
- package/dist/selftest/storage-helper.test.d.ts +2 -0
- package/dist/selftest/storage-helper.test.js +493 -0
- package/dist/selftest/support/vhdx.d.ts +14 -0
- package/dist/selftest/support/vhdx.js +72 -0
- package/dist/selftest/web.test.js +14 -2
- package/dist/shared/channel/run-dir.d.ts +6 -0
- package/dist/shared/channel/run-dir.js +9 -0
- package/dist/shared/pty/index.d.ts +3 -1
- package/dist/shared/pty/index.js +3 -1
- package/dist/shared/pty/key-gate.d.ts +24 -0
- package/dist/shared/pty/key-gate.js +45 -0
- package/dist/shared/pty/mouse-modes.d.ts +27 -0
- package/dist/shared/pty/mouse-modes.js +155 -0
- package/dist/shared/pty/terminal.d.ts +0 -5
- package/dist/shared/pty/terminal.js +51 -20
- package/dist/sync/watcher.js +1 -1
- package/dist/web/index.html +1 -1
- package/dist/web/{main-NBQ3LF4E.js → main-6XNBTJLT.js} +33 -33
- package/native/csharp/HcsHost/AssemblyInfo.cs +3 -3
- package/native/csharp/HostTools/AssemblyInfo.cs +3 -3
- package/native/csharp/StorageHelper/AssemblyInfo.cs +15 -0
- package/native/csharp/StorageHelper/Client.cs +297 -0
- package/native/csharp/StorageHelper/Log.cs +177 -0
- package/native/csharp/StorageHelper/MountRecords.cs +160 -0
- package/native/csharp/StorageHelper/Operations.cs +596 -0
- package/native/csharp/StorageHelper/Pins.cs +270 -0
- package/native/csharp/StorageHelper/Privileges.cs +78 -0
- package/native/csharp/StorageHelper/Program.cs +349 -0
- package/native/csharp/StorageHelper/Protocol.cs +107 -0
- package/native/csharp/StorageHelper/RateLimiter.cs +97 -0
- package/native/csharp/StorageHelper/Rules.cs +156 -0
- package/native/csharp/StorageHelper/Server.cs +656 -0
- package/native/csharp/StorageHelper/Settings.cs +297 -0
- package/native/csharp/StorageHelper/StorageService.cs +53 -0
- package/native/csharp/StorageHelper/UserSettings.cs +206 -0
- package/native/csharp/StorageHelper/Vhdx.cs +230 -0
- package/native/csharp/StorageHelper/VirtualDisk.cs +241 -0
- package/native/csharp/StorageHelper/Volumes.cs +379 -0
- package/native/csharp/StorageHelper/Watchdog.cs +128 -0
- package/native/csharp/StorageHelper/Win32.cs +250 -0
- package/native/scripts/set-storage-helper.ps1 +203 -0
- package/package.json +1 -1
- package/sandbox/csharp/PtyHost.cs +236 -1
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
// Windows Terminal's own pseudo console) and runs the real viewer/attach client (runTerminalClient), which is attached
|
|
3
3
|
// to an inner ptyhost running a stand-in agent. The agent turns SGR mouse tracking on and echoes every byte it reads.
|
|
4
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),
|
|
6
|
-
// a screen clear still gets mouse tracking.
|
|
5
|
+
// 500 printed lines scroll through as lines (no alternate screen, no scrollback clear), a viewer that joins after
|
|
6
|
+
// a screen clear still gets mouse tracking, and drag and motion reports reach the agent only once it asked for them.
|
|
7
7
|
import fs from 'node:fs';
|
|
8
8
|
import net from 'node:net';
|
|
9
9
|
import path from 'node:path';
|
|
@@ -14,7 +14,7 @@ import { SYSTEM } from '../shared/system.js';
|
|
|
14
14
|
import { CSC, openViewer, tail, WAIT } from './support/live.js';
|
|
15
15
|
/**
|
|
16
16
|
* The stand-in agent: VT input and SGR mouse tracking on, echoes what it reads ("got <1b>[<0;10;5M"). L prints 500
|
|
17
|
-
* lines, K clears the screen, q quits.
|
|
17
|
+
* lines, K clears the screen, D turns drag reports on (1002), A every motion (1003), q quits.
|
|
18
18
|
*/
|
|
19
19
|
const AGENT_CS = String.raw `
|
|
20
20
|
using System;
|
|
@@ -44,6 +44,8 @@ static class MouseAgent
|
|
|
44
44
|
Say("got " + got + "\r\n");
|
|
45
45
|
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"); }
|
|
46
46
|
if (got == "K") Say("\x1b[2J\x1b[HCLEARED\r\n");
|
|
47
|
+
if (got == "D") Say("\x1b[?1002hDRAG-ON\r\n");
|
|
48
|
+
if (got == "A") Say("\x1b[?1003hMOVE-ON\r\n");
|
|
47
49
|
if (got == "q") break;
|
|
48
50
|
}
|
|
49
51
|
// Exits with mouse tracking still on (like a crash): the client must turn it off.
|
|
@@ -120,6 +122,75 @@ export const liveTerminalSuite = {
|
|
|
120
122
|
stdio: 'ignore',
|
|
121
123
|
});
|
|
122
124
|
}
|
|
125
|
+
t.section('a ptyhost ended from outside: what it recorded is ended, and nothing else');
|
|
126
|
+
{
|
|
127
|
+
// ptyhost records its program's process tree (PTYHOST_TREE_FILE); --end-tree ends it once ptyhost is gone,
|
|
128
|
+
// checking every process by id and start time.
|
|
129
|
+
const record = t.at('tree.json');
|
|
130
|
+
const ping = path.join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'PING.EXE');
|
|
131
|
+
const holder = '"' + SYSTEM.cmd() + '" /c "' + ping + '" -n 120 127.0.0.1 >nul';
|
|
132
|
+
const host = spawn(ptyhost, [String(await freePort()), 'tok', '80', '24', t.at('tree.log'), holder], {
|
|
133
|
+
env: { ...process.env, PTYHOST_BIND: 'loopback', PTYHOST_TREE_FILE: record },
|
|
134
|
+
stdio: 'ignore',
|
|
135
|
+
windowsHide: true,
|
|
136
|
+
});
|
|
137
|
+
const read = (file) => {
|
|
138
|
+
try {
|
|
139
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
const alive = (pid) => {
|
|
146
|
+
try {
|
|
147
|
+
process.kill(pid, 0);
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
// The program (cmd) and the PING it started, once ptyhost has followed the tree (every 3 s).
|
|
155
|
+
const recorded = await t.waitFor(() => {
|
|
156
|
+
const r = read(record);
|
|
157
|
+
return r && r.ptyhost.pid === host.pid && r.processes.length >= 2 ? r : null;
|
|
158
|
+
}, WAIT.screen);
|
|
159
|
+
t.check(recorded !== null, 'ptyhost records its program and what it started', JSON.stringify(read(record)));
|
|
160
|
+
if (recorded) {
|
|
161
|
+
const endTree = (file) => {
|
|
162
|
+
const r = spawnSync(ptyhost, ['--end-tree', file], { encoding: 'utf8', windowsHide: true });
|
|
163
|
+
return { status: r.status, stdout: (r.stdout ?? '').trim() };
|
|
164
|
+
};
|
|
165
|
+
const copy = t.at('tree-copy.json');
|
|
166
|
+
fs.writeFileSync(copy, JSON.stringify(recorded));
|
|
167
|
+
const whileRunning = endTree(copy);
|
|
168
|
+
// The same ids with other start times: what a reused id looks like.
|
|
169
|
+
const forged = t.at('tree-forged.json');
|
|
170
|
+
fs.writeFileSync(forged, JSON.stringify({
|
|
171
|
+
ptyhost: { pid: recorded.ptyhost.pid, start: '1' },
|
|
172
|
+
processes: recorded.processes.map((p) => ({ pid: p.pid, start: String(BigInt(p.start) + 1n) })),
|
|
173
|
+
}));
|
|
174
|
+
const reused = endTree(forged);
|
|
175
|
+
t.check(whileRunning.status === 3 &&
|
|
176
|
+
reused.status === 0 &&
|
|
177
|
+
reused.stdout === 'ended 0 process(es)' &&
|
|
178
|
+
recorded.processes.every((p) => alive(p.pid)), 'nothing is ended while the ptyhost that recorded it runs, nor a process whose id was reused (start time)', JSON.stringify({ whileRunning, reused }));
|
|
179
|
+
host.kill();
|
|
180
|
+
await t.waitFor(() => host.exitCode !== null, 10000);
|
|
181
|
+
const left = fs.existsSync(record);
|
|
182
|
+
const ended = endTree(record);
|
|
183
|
+
await t.waitFor(() => recorded.processes.every((p) => !alive(p.pid)), 10000);
|
|
184
|
+
t.check(left && ended.status === 0 && recorded.processes.every((p) => !alive(p.pid)), 'a ptyhost ended from outside leaves its record, and --end-tree ends the processes in it', JSON.stringify({ left, ended, alive: recorded.processes.filter((p) => alive(p.pid)) }));
|
|
185
|
+
}
|
|
186
|
+
try {
|
|
187
|
+
if (host.exitCode === null)
|
|
188
|
+
host.kill();
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
/* it ended between the check and the kill: nothing left to stop */
|
|
192
|
+
}
|
|
193
|
+
}
|
|
123
194
|
t.section("the agents' job: a last-resort backstop, set once");
|
|
124
195
|
{
|
|
125
196
|
// A job of the test's own (PTYHOST_JOB_NAME), never the agents' real one.
|
|
@@ -289,9 +360,46 @@ export const liveTerminalSuite = {
|
|
|
289
360
|
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)));
|
|
290
361
|
late.close();
|
|
291
362
|
}
|
|
363
|
+
if (kind) {
|
|
364
|
+
// Raw mouse input (passed through, the client sees the agent's modes): a drag and a move are dropped while
|
|
365
|
+
// the agent tracks clicks only (1000), the drag passes with 1002, every move with 1003.
|
|
366
|
+
const drag = '\x1b[<32;11;5M';
|
|
367
|
+
const move = '\x1b[<35;12;6M';
|
|
368
|
+
const round = async (setup, ready, marker) => {
|
|
369
|
+
if (setup) {
|
|
370
|
+
send(setup);
|
|
371
|
+
await t.waitFor(() => screen().includes(ready), WAIT.screen);
|
|
372
|
+
}
|
|
373
|
+
const from = screen().length;
|
|
374
|
+
send(drag);
|
|
375
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
376
|
+
send(move);
|
|
377
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
378
|
+
send(marker);
|
|
379
|
+
await t.waitFor(() => screen()
|
|
380
|
+
.slice(from)
|
|
381
|
+
.includes('got ' + marker), WAIT.screen);
|
|
382
|
+
return screen().slice(from);
|
|
383
|
+
};
|
|
384
|
+
const clicksOnly = await round('', '', 'y');
|
|
385
|
+
const drags = await round('D', 'DRAG-ON', 'z');
|
|
386
|
+
const moves = await round('A', 'MOVE-ON', 'w');
|
|
387
|
+
// (Two reports read at once are echoed on one line.)
|
|
388
|
+
const has = (text, report) => text.includes('<1b>' + report.slice(1));
|
|
389
|
+
t.check(clicksOnly.includes('got y') &&
|
|
390
|
+
!has(clicksOnly, drag) &&
|
|
391
|
+
!has(clicksOnly, move) &&
|
|
392
|
+
drags.includes('got z') &&
|
|
393
|
+
has(drags, drag) &&
|
|
394
|
+
!has(drags, move) &&
|
|
395
|
+
has(moves, drag) &&
|
|
396
|
+
has(moves, move), 'drag and motion reports reach the agent only once it asked for them (1002 drags, 1003 every move)', JSON.stringify({ clicksOnly: tail(clicksOnly, 200), drags: tail(drags, 200), moves: tail(moves, 200) }));
|
|
397
|
+
}
|
|
292
398
|
send('q');
|
|
293
399
|
const ended = await t.waitFor(() => screen().includes('CLIENT-DONE'), WAIT.exit);
|
|
294
|
-
|
|
400
|
+
const last = screen().slice(screen().lastIndexOf('got q'));
|
|
401
|
+
t.check(Boolean(ended) && (!kind || (last.includes('\x1b[?1000l') && last.includes('\x1b[?1003l'))), 'the client ends with the agent' +
|
|
402
|
+
(kind ? ' and turns mouse tracking (clicks, drags, motion) off in the terminal again' : ''), 'screen: ' + tail(screen()));
|
|
295
403
|
}
|
|
296
404
|
finally {
|
|
297
405
|
for (const v of viewers)
|
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
// The storage helper (native\csharp\StorageHelper, the AgentSandboxStorage service an all-users install can add): it
|
|
2
|
+
// compiles with the csc that ships with Windows; its checks refuse what contained-common.ps1 refuses (run through its
|
|
3
|
+
// `check` dry run against temp folders); it works out the storage folder from settings the way the CLI does; its pipe
|
|
4
|
+
// protocol takes exactly a verb and a set; the CLI's client reads its answers and falls back to the permission prompt;
|
|
5
|
+
// the install script checks its arguments. No test installs the service or attaches a disk: the server runs in its
|
|
6
|
+
// console mode, as this user, on a pipe name of its own, with --dry-run.
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
10
|
+
import { isWin } from '../core/util/index.js';
|
|
11
|
+
import { currentUserSid } from '../host/sharing/index.js';
|
|
12
|
+
import { containedPaths, HELPER_PIPE, HelperDiskPort, helperApplies, HelperRequestSchema, pipeConnector, readAnswer, } from '../host/storage/index.js';
|
|
13
|
+
import { SYSTEM } from '../shared/system.js';
|
|
14
|
+
import { fakeVhdx } from './support/vhdx.js';
|
|
15
|
+
class RecordingPort {
|
|
16
|
+
calls = [];
|
|
17
|
+
mount() {
|
|
18
|
+
this.calls.push('mount');
|
|
19
|
+
return Promise.resolve({ ok: true, message: 'prompt', created: false, alreadyMounted: false, notes: [] });
|
|
20
|
+
}
|
|
21
|
+
dismount() {
|
|
22
|
+
this.calls.push('dismount');
|
|
23
|
+
return Promise.resolve({ ok: true, message: 'prompt', created: false, alreadyMounted: false, notes: [] });
|
|
24
|
+
}
|
|
25
|
+
reality() {
|
|
26
|
+
this.calls.push('reality');
|
|
27
|
+
return Promise.resolve({
|
|
28
|
+
imageExists: true,
|
|
29
|
+
imageBytes: 1,
|
|
30
|
+
mountExists: true,
|
|
31
|
+
mountIsMountPoint: false,
|
|
32
|
+
attached: false,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function buildHelper(t) {
|
|
37
|
+
const dir = path.join(t.packageRoot, 'native', 'csharp', 'StorageHelper');
|
|
38
|
+
const sources = fs
|
|
39
|
+
.readdirSync(dir)
|
|
40
|
+
.filter((f) => f.endsWith('.cs'))
|
|
41
|
+
.sort()
|
|
42
|
+
.map((f) => path.join(dir, f));
|
|
43
|
+
const exe = t.at('storage-helper', 'bin', 'AgentSandboxStorage.exe');
|
|
44
|
+
fs.mkdirSync(path.dirname(exe), { recursive: true });
|
|
45
|
+
const r = spawnSync(SYSTEM.csc(), [
|
|
46
|
+
'/nologo',
|
|
47
|
+
'/optimize',
|
|
48
|
+
'/warnaserror',
|
|
49
|
+
'/target:exe',
|
|
50
|
+
'/r:System.Web.Extensions.dll',
|
|
51
|
+
'/r:System.ServiceProcess.dll',
|
|
52
|
+
'/out:' + exe,
|
|
53
|
+
...sources,
|
|
54
|
+
], { encoding: 'utf8', windowsHide: true, timeout: 120000 });
|
|
55
|
+
t.check(r.status === 0 && fs.existsSync(exe), 'AgentSandboxStorage.exe builds without warnings (C# 5)', r.stdout + r.stderr);
|
|
56
|
+
return fs.existsSync(exe) ? exe : null;
|
|
57
|
+
}
|
|
58
|
+
/** The folder and everything in it belong to `sid` (as the CLI would make them), not to Administrators. */
|
|
59
|
+
function own(dir, sid) {
|
|
60
|
+
spawnSync(SYSTEM.icacls(), [dir, '/setowner', '*' + sid, '/T', '/C', '/Q'], { windowsHide: true });
|
|
61
|
+
}
|
|
62
|
+
function units(t) {
|
|
63
|
+
t.section('the request the CLI sends, and what it makes of the answer');
|
|
64
|
+
t.check(HelperRequestSchema.safeParse({ verb: 'mount', set: 'main' }).success, 'a request is a verb and a set');
|
|
65
|
+
t.check(!HelperRequestSchema.safeParse({ verb: 'mount', set: 'main', image: 'C:\\x.vhdx' }).success &&
|
|
66
|
+
!HelperRequestSchema.safeParse({ verb: 'format', set: 'main' }).success &&
|
|
67
|
+
!HelperRequestSchema.safeParse({ verb: 'mount', set: '..\\x' }).success, 'nothing else: no paths, no other verbs, no set name with a path in it');
|
|
68
|
+
t.check(helperApplies({}, false), 'the helper is asked with your usual settings');
|
|
69
|
+
t.check(!helperApplies({}, true) &&
|
|
70
|
+
!helperApplies({ AGENT_SANDBOX_CONFIG: 'x' }, false) &&
|
|
71
|
+
!helperApplies({ AGENT_SANDBOX_HOME: 'x' }, false), 'but not with --config, AGENT_SANDBOX_CONFIG or AGENT_SANDBOX_HOME (it would read other settings)');
|
|
72
|
+
const paths = containedPaths('C:\\Store', 'main');
|
|
73
|
+
const answer = (over) => ({
|
|
74
|
+
kind: 'answer',
|
|
75
|
+
text: JSON.stringify({
|
|
76
|
+
protocol: 1,
|
|
77
|
+
ok: true,
|
|
78
|
+
message: 'The image was mounted.',
|
|
79
|
+
created: false,
|
|
80
|
+
alreadyMounted: false,
|
|
81
|
+
fallback: false,
|
|
82
|
+
image: paths.image,
|
|
83
|
+
mount: paths.mount,
|
|
84
|
+
notes: [],
|
|
85
|
+
...over,
|
|
86
|
+
}),
|
|
87
|
+
});
|
|
88
|
+
const ok = readAnswer(answer({}), paths);
|
|
89
|
+
t.check(ok.use === 'outcome' && ok.outcome.ok, 'an answer for our own paths is the outcome');
|
|
90
|
+
t.check(readAnswer(answer({ image: paths.image.toUpperCase(), mount: paths.mount + '\\' }), paths).use === 'outcome', 'case and a trailing backslash do not matter');
|
|
91
|
+
const elsewhere = readAnswer(answer({ mount: 'C:\\Other\\mounts\\main' }), paths);
|
|
92
|
+
t.check(elsewhere.use === 'outcome' && !elsewhere.outcome.ok && /other folders/.test(elsewhere.outcome.message), 'a mount in other folders than ours is a failure, never taken as done');
|
|
93
|
+
t.equal(readAnswer({ kind: 'absent', reason: 'ENOENT' }, paths).use, 'fallback', 'no helper: the permission prompt');
|
|
94
|
+
t.equal(readAnswer(answer({ protocol: 2 }), paths).use, 'fallback', 'a helper of another version: the prompt');
|
|
95
|
+
t.equal(readAnswer({ kind: 'answer', text: 'not json' }, paths).use, 'fallback', 'garbage: the prompt');
|
|
96
|
+
t.equal(readAnswer(answer({ ok: false, fallback: true }), paths).use, 'fallback', 'the helper says so: the prompt');
|
|
97
|
+
const half = readAnswer({ kind: 'failed', reason: 'timeout' }, paths);
|
|
98
|
+
t.check(half.use === 'outcome' && !half.outcome.ok, 'a helper that stopped half way is a failure (no second try through the prompt)');
|
|
99
|
+
}
|
|
100
|
+
async function ports(t) {
|
|
101
|
+
t.section('the disk port: the helper when it applies, the permission prompt otherwise');
|
|
102
|
+
const paths = containedPaths(t.dir('storage-helper', 'port'), 'main');
|
|
103
|
+
const request = { paths, maxGb: 64, create: false, userSid: 'S-1-5-21-1-2-3-1001' };
|
|
104
|
+
const seen = [];
|
|
105
|
+
const connector = (result) => (_pipe, line) => {
|
|
106
|
+
seen.push(line);
|
|
107
|
+
return Promise.resolve(result);
|
|
108
|
+
};
|
|
109
|
+
const prompt1 = new RecordingPort();
|
|
110
|
+
await new HelperDiskPort(prompt1, false, connector({ kind: 'absent', reason: 'x' }), 'test').mount(request);
|
|
111
|
+
t.check(prompt1.calls.includes('mount') && seen.length === 0, 'not applying: the helper is not even asked');
|
|
112
|
+
const prompt2 = new RecordingPort();
|
|
113
|
+
await new HelperDiskPort(prompt2, true, connector({ kind: 'absent', reason: 'ENOENT' }), 'test').dismount(request);
|
|
114
|
+
t.check(prompt2.calls.includes('dismount'), 'not installed: the permission prompt');
|
|
115
|
+
t.equal(JSON.parse(seen[0]), { verb: 'dismount', set: 'main' }, 'the line sent is the verb and the set');
|
|
116
|
+
const prompt3 = new RecordingPort();
|
|
117
|
+
const done = await new HelperDiskPort(prompt3, true, connector({
|
|
118
|
+
kind: 'answer',
|
|
119
|
+
text: JSON.stringify({
|
|
120
|
+
protocol: 1,
|
|
121
|
+
ok: true,
|
|
122
|
+
message: 'The image was created and mounted.',
|
|
123
|
+
created: true,
|
|
124
|
+
alreadyMounted: false,
|
|
125
|
+
fallback: false,
|
|
126
|
+
image: paths.image,
|
|
127
|
+
mount: paths.mount,
|
|
128
|
+
notes: ['n'],
|
|
129
|
+
}),
|
|
130
|
+
}), 'test').mount(request);
|
|
131
|
+
t.check(done.ok && done.created && prompt3.calls.length === 0, 'an answer from the helper: no prompt');
|
|
132
|
+
await new HelperDiskPort(prompt3, true, connector({ kind: 'absent', reason: 'x' }), 'test').reality(paths);
|
|
133
|
+
t.check(prompt3.calls.includes('reality'), 'looking never needs the helper');
|
|
134
|
+
await t.throws(() => new HelperDiskPort(new RecordingPort(), true, connector({ kind: 'absent', reason: 'x' }), HELPER_PIPE).mount(request), 'does not mount or dismount', 'the self-test never talks to the real helper');
|
|
135
|
+
const unconfirmed = await new HelperDiskPort(new RecordingPort(), true, connector({ kind: 'absent', reason: 'x' }), 'test').confirmMounted(request);
|
|
136
|
+
t.equal(unconfirmed, null, 'a vouched mount without the helper stays vouched (no prompt for a check)');
|
|
137
|
+
const refusedMount = await new HelperDiskPort(new RecordingPort(), true, connector({
|
|
138
|
+
kind: 'answer',
|
|
139
|
+
text: JSON.stringify({
|
|
140
|
+
protocol: 1,
|
|
141
|
+
ok: false,
|
|
142
|
+
message: 'The mount folder is a mount point already.',
|
|
143
|
+
created: false,
|
|
144
|
+
alreadyMounted: false,
|
|
145
|
+
fallback: false,
|
|
146
|
+
image: paths.image,
|
|
147
|
+
mount: paths.mount,
|
|
148
|
+
notes: [],
|
|
149
|
+
}),
|
|
150
|
+
}), 'test').confirmMounted(request);
|
|
151
|
+
t.check(refusedMount !== null && !refusedMount.ok, 'with the helper, a vouched mount is checked: a mount point of another disk is a failure');
|
|
152
|
+
const absent = await pipeConnector('\\\\.\\pipe\\agent-sandbox-test-no-such-pipe-' + process.pid, '{}', 5000);
|
|
153
|
+
t.equal(absent.kind, 'absent', 'a pipe that is not there reads as "no helper"');
|
|
154
|
+
}
|
|
155
|
+
/** The helper's `check` dry run on one data set; the parsed answer. */
|
|
156
|
+
function checkRun(exe, verb, root, set, sid, maxGb) {
|
|
157
|
+
const r = spawnSync(exe, ['check', verb, root, set, sid, String(maxGb)], { encoding: 'utf8', windowsHide: true });
|
|
158
|
+
try {
|
|
159
|
+
return JSON.parse(r.stdout);
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
return { ok: false, message: 'no answer: ' + r.stdout + r.stderr };
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
function checks(t, exe, sid) {
|
|
166
|
+
t.section('the checks (dry run): what the helper accepts and refuses');
|
|
167
|
+
const root = t.dir('storage-helper', 'store');
|
|
168
|
+
const paths = containedPaths(root, 'main');
|
|
169
|
+
for (const dir of [paths.imagesDir, paths.mountsDir, paths.mount])
|
|
170
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
171
|
+
own(root, sid);
|
|
172
|
+
const run = (over = {}) => checkRun(exe, over.verb ?? 'mount', over.root ?? root, over.set ?? 'main', over.sid ?? sid, over.maxGb ?? 64);
|
|
173
|
+
const refused = (what, a, text) => t.check(!a.ok && a.message.toLowerCase().includes(text.toLowerCase()), 'refused: ' + what, a.message);
|
|
174
|
+
const first = run();
|
|
175
|
+
t.check(first.ok && first.created === true, 'a first mount (no image yet) is accepted: it would create one', first.message);
|
|
176
|
+
t.check(first.image === paths.image && first.mount === paths.mount, 'the paths are <storage>\\images\\<set>.vhdx and <storage>\\mounts\\<set>, built by the helper');
|
|
177
|
+
fakeVhdx(paths.image, { gb: 64 });
|
|
178
|
+
own(paths.image, sid);
|
|
179
|
+
const existing = run();
|
|
180
|
+
t.check(existing.ok && existing.created === false, 'an existing image is accepted (only attached)', existing.message);
|
|
181
|
+
t.check(run({ verb: 'dismount' }).ok, 'a dismount is accepted');
|
|
182
|
+
fakeVhdx(paths.image, { gb: 64, staleLog: true });
|
|
183
|
+
t.check(run().ok, 'a log named only in the older header is fine');
|
|
184
|
+
refused('another user (the folders are not theirs)', run({ sid: 'S-1-5-21-1-2-3-1001' }), 'not yours');
|
|
185
|
+
refused('a data set name that is not allowed', run({ set: 'a b' }), 'not allowed');
|
|
186
|
+
refused('a data set name with a path', run({ set: '..\\x' }), 'not allowed');
|
|
187
|
+
refused('a relative storage folder', run({ root: 'store' }), 'plain full path');
|
|
188
|
+
refused('a storage folder with ..', run({ root: root + '\\..\\store' }), 'plain full path');
|
|
189
|
+
refused('a network storage folder', run({ root: '\\\\server\\share\\store' }), 'plain full path');
|
|
190
|
+
refused('a drive root', run({ root: 'C:\\' }), 'drive root');
|
|
191
|
+
refused('a size limit out of range', run({ maxGb: 100000 }), 'between 8 and 4096');
|
|
192
|
+
fakeVhdx(paths.image, { gb: 64, parent: true });
|
|
193
|
+
refused('a differencing image (it has a parent)', run(), 'differencing');
|
|
194
|
+
fakeVhdx(paths.image, { gb: 128 });
|
|
195
|
+
refused('an image bigger than the limit', run(), 'bigger than the limit');
|
|
196
|
+
fs.writeFileSync(paths.image, 'not a vhdx');
|
|
197
|
+
refused('a file that is not a VHDX', run(), 'not a VHDX');
|
|
198
|
+
fakeVhdx(paths.image, { gb: 64, log: true });
|
|
199
|
+
const log = run();
|
|
200
|
+
t.check(!log.ok && log.fallback === true && /not closed cleanly/.test(log.message), 'an image with a log to replay is left to the permission prompt (fallback)', log.message);
|
|
201
|
+
fakeVhdx(paths.image, { gb: 64 });
|
|
202
|
+
fs.writeFileSync(path.join(paths.mount, 'stray.txt'), 'x');
|
|
203
|
+
refused('a mount folder that is not empty', run(), 'not empty');
|
|
204
|
+
fs.rmSync(path.join(paths.mount, 'stray.txt'));
|
|
205
|
+
const twin = path.join(root, 'twin.vhdx');
|
|
206
|
+
try {
|
|
207
|
+
fs.linkSync(paths.image, twin);
|
|
208
|
+
refused('an image with a second name (hard link)', run(), 'hard links');
|
|
209
|
+
fs.rmSync(twin);
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
t.skip('image as a hard link (could not make one here)');
|
|
213
|
+
}
|
|
214
|
+
const users = spawnSync(SYSTEM.icacls(), [paths.imagesDir, '/grant', '*S-1-5-32-545:(M)', '/Q'], {
|
|
215
|
+
windowsHide: true,
|
|
216
|
+
});
|
|
217
|
+
if (users.status === 0) {
|
|
218
|
+
refused('a folder others may change (Users: modify)', run(), 'someone else may change');
|
|
219
|
+
spawnSync(SYSTEM.icacls(), [paths.imagesDir, '/remove:g', '*S-1-5-32-545', '/Q'], { windowsHide: true });
|
|
220
|
+
t.check(run().ok, 'and accepted again once only the user may change it');
|
|
221
|
+
}
|
|
222
|
+
else
|
|
223
|
+
t.skip('a folder others may change (icacls failed)');
|
|
224
|
+
const linkTarget = t.dir('storage-helper', 'linktarget');
|
|
225
|
+
const junctionRoot = t.dir('storage-helper', 'junction');
|
|
226
|
+
const junction = containedPaths(junctionRoot, 'main');
|
|
227
|
+
fs.mkdirSync(junction.mount, { recursive: true });
|
|
228
|
+
const mk = spawnSync(SYSTEM.cmd(), ['/c', 'mklink', '/J', junction.imagesDir, linkTarget], { windowsHide: true });
|
|
229
|
+
if (mk.status === 0) {
|
|
230
|
+
own(junctionRoot, sid);
|
|
231
|
+
fakeVhdx(path.join(linkTarget, 'main.vhdx'), { gb: 8 });
|
|
232
|
+
refused('an images folder that is a link', run({ root: junctionRoot }), 'link');
|
|
233
|
+
fs.rmdirSync(junction.imagesDir);
|
|
234
|
+
}
|
|
235
|
+
else
|
|
236
|
+
t.skip('images folder as a junction');
|
|
237
|
+
const above = t.dir('storage-helper', 'above');
|
|
238
|
+
const realParent = t.dir('storage-helper', 'above-real');
|
|
239
|
+
const inner = containedPaths(path.join(realParent, 'store'), 'main');
|
|
240
|
+
for (const dir of [inner.imagesDir, inner.mount])
|
|
241
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
242
|
+
own(path.join(realParent, 'store'), sid);
|
|
243
|
+
fs.rmdirSync(above);
|
|
244
|
+
const mk2 = spawnSync(SYSTEM.cmd(), ['/c', 'mklink', '/J', above, realParent], { windowsHide: true });
|
|
245
|
+
if (mk2.status === 0) {
|
|
246
|
+
refused('a link above the storage folder', run({ root: path.join(above, 'store') }), 'link');
|
|
247
|
+
fs.rmdirSync(above);
|
|
248
|
+
}
|
|
249
|
+
else
|
|
250
|
+
t.skip('a junction above the storage folder');
|
|
251
|
+
const mountRoot = t.dir('storage-helper', 'mountlink');
|
|
252
|
+
const mountLinked = containedPaths(mountRoot, 'main');
|
|
253
|
+
fs.mkdirSync(mountLinked.imagesDir, { recursive: true });
|
|
254
|
+
fs.mkdirSync(mountLinked.mountsDir, { recursive: true });
|
|
255
|
+
const mk3 = spawnSync(SYSTEM.cmd(), ['/c', 'mklink', '/J', mountLinked.mount, linkTarget], { windowsHide: true });
|
|
256
|
+
if (mk3.status === 0) {
|
|
257
|
+
own(mountRoot, sid);
|
|
258
|
+
refused('a mount folder that is a link to a folder (not a volume)', run({ root: mountRoot }), 'not a volume');
|
|
259
|
+
refused('a dismount through such a link', run({ root: mountRoot, verb: 'dismount' }), 'missing');
|
|
260
|
+
fs.rmdirSync(mountLinked.mount);
|
|
261
|
+
}
|
|
262
|
+
else
|
|
263
|
+
t.skip('mount folder as a junction');
|
|
264
|
+
const missing = t.dir('storage-helper', 'missing');
|
|
265
|
+
own(missing, sid);
|
|
266
|
+
refused('a storage folder without images and mounts', run({ root: missing }), 'missing');
|
|
267
|
+
}
|
|
268
|
+
function derive(t, exe) {
|
|
269
|
+
t.section('the storage folder, worked out from the settings like the CLI does');
|
|
270
|
+
const env = { USERNAME: 'ann', ProgramData: 'C:\\ProgramData', USERPROFILE: 'C:\\Users\\ann' };
|
|
271
|
+
let n = 0;
|
|
272
|
+
const run = (input) => {
|
|
273
|
+
const file = t.writeJson(t.at('storage-helper', 'derive', 'in' + n++ + '.json'), {
|
|
274
|
+
base: 'C:\\Users\\ann\\.agent-sandbox',
|
|
275
|
+
profile: 'C:\\Users\\ann',
|
|
276
|
+
env,
|
|
277
|
+
...input,
|
|
278
|
+
});
|
|
279
|
+
const r = spawnSync(exe, ['derive', file], { encoding: 'utf8', windowsHide: true });
|
|
280
|
+
try {
|
|
281
|
+
return JSON.parse(r.stdout);
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
return { ok: false, message: r.stdout + r.stderr };
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
t.equal(run({}), { ok: true, root: 'C:\\ProgramData\\AgentSandbox\\users\\ann', maxGb: 64 }, 'the defaults');
|
|
288
|
+
t.equal(run({ machine: '{ "storage": { "root": "D:\\\\Sandbox\\\\%USERNAME%" } }' }).root, 'D:\\Sandbox\\ann', 'the machine file, with %USERNAME% from the user');
|
|
289
|
+
t.equal(run({
|
|
290
|
+
machine: '{ "storage": { "root": "D:\\\\Sandbox" } }',
|
|
291
|
+
user: '// mine\n{ "storage": { "root": "E:\\\\Mine", /* why */ "containedMaxGb": 32, }, }',
|
|
292
|
+
}), { ok: true, root: 'E:\\Mine', maxGb: 32 }, 'the user file wins, with comments and trailing commas');
|
|
293
|
+
t.equal(run({ user: '{ "storage": { "root": "store" } }' }).root, 'C:\\Users\\ann\\.agent-sandbox\\store', 'a relative folder is relative to the file');
|
|
294
|
+
t.equal(run({ user: '{ "storage": { "root": "~\\\\data" } }' }).root, 'C:\\Users\\ann\\data', '~ is the profile');
|
|
295
|
+
t.equal(run({ user: '{ "storage": { "root": "D:/a/../b" } }' }).root, 'D:\\b', 'slashes and .. are worked out');
|
|
296
|
+
t.equal(run({ machine: '{ "storage": { "containedMaxGb": 100 } }', user: '{ "storage": { "containedMaxGb": 500 } }' })
|
|
297
|
+
.maxGb, 100, 'a size limit set for the PC is a ceiling the user file cannot raise');
|
|
298
|
+
t.equal(run({ machine: '{ "storage": { "containedMaxGb": 100 } }', user: '{ "storage": { "containedMaxGb": 20 } }' }).maxGb, 20, 'but may lower');
|
|
299
|
+
const secret = run({ user: 'SECRET-CONTENT {' });
|
|
300
|
+
t.check(!secret.ok && !(secret.message ?? '').includes('SECRET'), 'a file that is not JSON is refused without repeating it');
|
|
301
|
+
t.check(!run({ machine: '{ "storage": { "root": "relative" } }' }).ok, 'the machine file must give a full path');
|
|
302
|
+
}
|
|
303
|
+
function protocol(t, exe) {
|
|
304
|
+
t.section('the pipe protocol: exactly a verb and a set');
|
|
305
|
+
const parse = (line) => {
|
|
306
|
+
const r = spawnSync(exe, ['parse'], { input: line + '\n', encoding: 'utf8', windowsHide: true });
|
|
307
|
+
try {
|
|
308
|
+
return JSON.parse(r.stdout);
|
|
309
|
+
}
|
|
310
|
+
catch {
|
|
311
|
+
return { ok: false };
|
|
312
|
+
}
|
|
313
|
+
};
|
|
314
|
+
t.equal(parse('{"verb":"mount","set":"main"}'), { ok: true, verb: 'mount', set: 'main' }, 'mount main');
|
|
315
|
+
t.check(parse('{"verb":"dismount","set":"work.2"}').ok, 'dismount work.2');
|
|
316
|
+
for (const [line, what] of [
|
|
317
|
+
['{"verb":"mount","set":"main","image":"C:\\\\Windows\\\\x.vhdx"}', 'an extra field (a path)'],
|
|
318
|
+
['{"verb":"mount"}', 'no set'],
|
|
319
|
+
['{"verb":"format","set":"main"}', 'another verb'],
|
|
320
|
+
['{"verb":"mount","set":"..\\\\x"}', 'a set with a path'],
|
|
321
|
+
['{"verb":"mount","set":["main"]}', 'a set that is not a string'],
|
|
322
|
+
['["mount","main"]', 'not an object'],
|
|
323
|
+
['not json', 'not JSON'],
|
|
324
|
+
['{"verb":"mount","set":"' + 'a'.repeat(600) + '"}', 'longer than 512 bytes'],
|
|
325
|
+
])
|
|
326
|
+
t.check(!parse(line).ok, 'refused: ' + what);
|
|
327
|
+
}
|
|
328
|
+
async function startConsole(exe, args) {
|
|
329
|
+
const child = spawn(exe, ['console', ...args], { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true });
|
|
330
|
+
let out = '';
|
|
331
|
+
let err = '';
|
|
332
|
+
child.stdout?.on('data', (d) => (out += d.toString()));
|
|
333
|
+
child.stderr?.on('data', (d) => (err += d.toString()));
|
|
334
|
+
const ready = await new Promise((resolve) => {
|
|
335
|
+
const timer = setInterval(() => {
|
|
336
|
+
if (out.includes('ready'))
|
|
337
|
+
done(true);
|
|
338
|
+
}, 50);
|
|
339
|
+
const done = (value) => {
|
|
340
|
+
clearInterval(timer);
|
|
341
|
+
resolve(value);
|
|
342
|
+
};
|
|
343
|
+
child.on('exit', () => done(false));
|
|
344
|
+
setTimeout(() => done(out.includes('ready')), 20000);
|
|
345
|
+
});
|
|
346
|
+
return { child, ready, stderr: () => err };
|
|
347
|
+
}
|
|
348
|
+
async function server(t, exe, sid) {
|
|
349
|
+
t.section('the server in its console mode (this user, a pipe of its own, dry run)');
|
|
350
|
+
const root = t.dir('storage-helper', 'served');
|
|
351
|
+
const paths = containedPaths(root, 'main');
|
|
352
|
+
for (const dir of [paths.imagesDir, paths.mount])
|
|
353
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
354
|
+
own(root, sid);
|
|
355
|
+
const profile = t.dir('storage-helper', 'profile');
|
|
356
|
+
t.writeJson(path.join(profile, '.agent-sandbox', 'config.json'), { storage: { root, containedMaxGb: 16 } });
|
|
357
|
+
const logs = t.dir('storage-helper', 'logs');
|
|
358
|
+
const name = 'agent-sandbox-test-' + process.pid;
|
|
359
|
+
const pipe = '\\\\.\\pipe\\' + name;
|
|
360
|
+
const common = ['--group', sid, '--log-folder', logs, '--machine-config', 'none', '--profile', profile, '--dry-run'];
|
|
361
|
+
const first = await startConsole(exe, ['--pipe', name, ...common]);
|
|
362
|
+
t.check(first.ready, 'it starts', first.stderr());
|
|
363
|
+
if (!first.ready)
|
|
364
|
+
return;
|
|
365
|
+
try {
|
|
366
|
+
const port = new HelperDiskPort(new RecordingPort(), true, pipeConnector, pipe);
|
|
367
|
+
const mounted = await port.mount({ paths, maxGb: 64, create: true, userSid: sid });
|
|
368
|
+
t.check(mounted.ok && mounted.created && /would be created/.test(mounted.message), 'a mount through the pipe: the client is you, the folders come from your settings', JSON.stringify(mounted));
|
|
369
|
+
const otherPaths = containedPaths(t.dir('storage-helper', 'elsewhere'), 'main');
|
|
370
|
+
const mismatch = await port.mount({ paths: otherPaths, maxGb: 64, create: true, userSid: sid });
|
|
371
|
+
t.check(!mismatch.ok && /other folders/.test(mismatch.message), 'a CLI with other folders gets a failure, not a mount');
|
|
372
|
+
const raw = async (line) => {
|
|
373
|
+
const r = await pipeConnector(pipe, line, 10000);
|
|
374
|
+
return r.kind === 'answer' ? r.text : r.kind + ': ' + r.reason;
|
|
375
|
+
};
|
|
376
|
+
t.includes(await raw('{"verb":"mount","set":"main","image":"C:\\\\Windows\\\\x.vhdx"}'), 'exactly a verb and a set', 'a request with a path in it is refused over the pipe');
|
|
377
|
+
t.includes(await raw('{"verb":"mount","set":"other"}'), 'missing', 'another set is checked like any other');
|
|
378
|
+
const second = await startConsole(exe, ['--pipe', name, ...common]);
|
|
379
|
+
t.check(!second.ready && /exists already/.test(second.stderr()), 'a second server on the same pipe name does not start (someone else made the pipe first)', second.stderr());
|
|
380
|
+
const holder = path.join(t.packageRoot, 'src', 'selftest', 'fixtures', 'storage-helper', 'hold-mutex.ps1');
|
|
381
|
+
if (fs.existsSync(holder)) {
|
|
382
|
+
const lockName = 'Local\\agent-sandbox-test-lock-' + process.pid;
|
|
383
|
+
const hold = spawn(SYSTEM.powershell(), ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', holder, '-Name', lockName], { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true });
|
|
384
|
+
let held = '';
|
|
385
|
+
hold.stdout.on('data', (d) => (held += d.toString()));
|
|
386
|
+
await t.waitFor(() => held.includes('held'), 20000);
|
|
387
|
+
const squatted = await startConsole(exe, ['--pipe', name + '-lock', '--mutex', lockName, ...common]);
|
|
388
|
+
t.check(!squatted.ready && /not by an administrator/.test(squatted.stderr()), 'a lock that a normal user made first stops the service from starting (it could be held to block mounts)', squatted.stderr());
|
|
389
|
+
hold.stdin.end();
|
|
390
|
+
}
|
|
391
|
+
else
|
|
392
|
+
t.skip('a lock made first by a normal user (the fixture is only in the repository)');
|
|
393
|
+
let limited = 0;
|
|
394
|
+
for (let i = 0; i < 12; i++)
|
|
395
|
+
if (/Too many calls/.test(await raw('{"verb":"dismount","set":"main"}')))
|
|
396
|
+
limited++;
|
|
397
|
+
t.check(limited >= 3, 'more than 10 calls a minute from one user are refused');
|
|
398
|
+
const log = fs.readFileSync(path.join(logs, 'storage-helper.log'), 'utf8');
|
|
399
|
+
t.check(log.includes('sid=' + sid) &&
|
|
400
|
+
log.includes('verb=mount') &&
|
|
401
|
+
log.includes('set="main"') &&
|
|
402
|
+
log.includes('result="ok:'), 'every call is logged: who, what, the result');
|
|
403
|
+
t.equal((log.match(/too many calls/gi) ?? []).length, 1, 'a refused call is logged once per minute, not each time');
|
|
404
|
+
}
|
|
405
|
+
finally {
|
|
406
|
+
first.child.stdin?.end();
|
|
407
|
+
await new Promise((resolve) => {
|
|
408
|
+
first.child.on('exit', resolve);
|
|
409
|
+
setTimeout(resolve, 10000);
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
function installScript(t) {
|
|
414
|
+
t.section('set-storage-helper.ps1: encoding, syntax, what it would do');
|
|
415
|
+
const script = path.join(t.packageRoot, 'native', 'scripts', 'set-storage-helper.ps1');
|
|
416
|
+
const text = fs.readFileSync(script, 'utf8');
|
|
417
|
+
t.check(!/(?<!\r)\n/.test(text) && text.startsWith('#Requires -Version 5.1'), 'CRLF and #Requires -Version 5.1');
|
|
418
|
+
const parse = spawnSync(SYSTEM.powershell(), [
|
|
419
|
+
'-NoProfile',
|
|
420
|
+
'-NonInteractive',
|
|
421
|
+
'-ExecutionPolicy',
|
|
422
|
+
'Bypass',
|
|
423
|
+
'-File',
|
|
424
|
+
path.join(t.packageRoot, 'native', 'scripts', 'check-syntax.ps1'),
|
|
425
|
+
'-Path',
|
|
426
|
+
script,
|
|
427
|
+
], { encoding: 'utf8', windowsHide: true });
|
|
428
|
+
t.check(parse.status === 0, 'it parses in PowerShell 5.1', parse.stderr + parse.stdout);
|
|
429
|
+
const installRoot = t.dir('storage-helper', 'install-root');
|
|
430
|
+
const sources = t.dir('storage-helper', 'install-root', 'versions', '1.0.0', 'package', 'native', 'csharp', 'StorageHelper');
|
|
431
|
+
t.write(path.join(sources, 'A.cs'), '// placeholder');
|
|
432
|
+
const dry = (args) => {
|
|
433
|
+
const r = spawnSync(SYSTEM.powershell(), [
|
|
434
|
+
'-NoProfile',
|
|
435
|
+
'-NonInteractive',
|
|
436
|
+
'-ExecutionPolicy',
|
|
437
|
+
'Bypass',
|
|
438
|
+
'-File',
|
|
439
|
+
script,
|
|
440
|
+
...args,
|
|
441
|
+
'-DryRun',
|
|
442
|
+
'-ServiceName',
|
|
443
|
+
'AgentSandboxStorageDryRun',
|
|
444
|
+
'-MachineData',
|
|
445
|
+
t.dir('storage-helper', 'machine'),
|
|
446
|
+
], { encoding: 'utf8', windowsHide: true, timeout: 60000 });
|
|
447
|
+
return { status: r.status, out: (r.stdout ?? '') + (r.stderr ?? '') };
|
|
448
|
+
};
|
|
449
|
+
const group = 'S-1-5-21-111-222-333-1009';
|
|
450
|
+
const ok = dry(['-Install', '-GroupSid', group, '-InstallRoot', installRoot, '-Sources', sources]);
|
|
451
|
+
t.check(ok.status === 0, 'a dry run of the install is accepted', ok.out);
|
|
452
|
+
for (const step of [
|
|
453
|
+
'build AgentSandboxStorage.exe',
|
|
454
|
+
'admin-only folder',
|
|
455
|
+
'create the service',
|
|
456
|
+
'LocalSystem, automatic start',
|
|
457
|
+
'service --group ' + group,
|
|
458
|
+
'SeManageVolumePrivilege',
|
|
459
|
+
'start the service',
|
|
460
|
+
])
|
|
461
|
+
t.includes(ok.out, step, 'it would: ' + step);
|
|
462
|
+
t.excludes(ok.out, 'SeDebugPrivilege', 'and no privilege it does not need');
|
|
463
|
+
const refused = (what, args, text) => {
|
|
464
|
+
const r = dry(args);
|
|
465
|
+
t.check(r.status === 1 && r.out.includes(text), 'refused: ' + what, r.out);
|
|
466
|
+
};
|
|
467
|
+
refused('a group that is not a local group SID', ['-Install', '-GroupSid', 'S-1-1-0', '-InstallRoot', installRoot, '-Sources', sources], 'GroupSid');
|
|
468
|
+
refused('sources outside the install folder', ['-Install', '-GroupSid', group, '-InstallRoot', installRoot, '-Sources', t.dir('storage-helper', 'elsewhere-src')], 'Sources');
|
|
469
|
+
const removal = dry(['-Remove', '-InstallRoot', installRoot]);
|
|
470
|
+
t.check(removal.status === 0 && removal.out.includes('remove ' + path.join(installRoot, 'helper')), 'a dry run of the removal', removal.out);
|
|
471
|
+
}
|
|
472
|
+
export const storageHelperSuite = {
|
|
473
|
+
name: 'storage-helper',
|
|
474
|
+
title: 'the storage helper service: builds, checks like the scripts, settings, pipe protocol, client fallback',
|
|
475
|
+
timeoutMs: 300000,
|
|
476
|
+
run: async (t) => {
|
|
477
|
+
units(t);
|
|
478
|
+
await ports(t);
|
|
479
|
+
if (!isWin || !fs.existsSync(SYSTEM.csc())) {
|
|
480
|
+
t.skip('the helper itself (needs Windows and its csc)');
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
const exe = buildHelper(t);
|
|
484
|
+
if (!exe)
|
|
485
|
+
return;
|
|
486
|
+
const sid = currentUserSid();
|
|
487
|
+
checks(t, exe, sid);
|
|
488
|
+
derive(t, exe);
|
|
489
|
+
protocol(t, exe);
|
|
490
|
+
await server(t, exe, sid);
|
|
491
|
+
installScript(t);
|
|
492
|
+
},
|
|
493
|
+
};
|