windows-development-sandbox 1.14.0 → 1.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/config.example.json +2 -1
- package/dist/app/features/defs.d.ts +1 -1
- package/dist/app/features/projects/projects.def.d.ts +1 -1
- package/dist/app/features/projects/projects.run.d.ts +1 -1
- package/dist/app/features/projects/projects.run.js +10 -2
- package/dist/app/features/sandbox/sandbox.def.js +3 -1
- package/dist/app/features/settings/categories.def.js +1 -0
- package/dist/bin/agent-sandbox.js +2 -2
- package/dist/bin/{chunk-BQ23GBLC.js → chunk-D7CM63BS.js} +1893 -1405
- package/dist/bin/{chunk-F7IMME5G.js → chunk-HTQT5SBY.js} +1 -1
- package/dist/bin/{chunk-LQHEUN2A.js → chunk-RQ4BWBYX.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/projects/here.js +2 -0
- package/dist/cli/services/health-service.js +15 -3
- package/dist/cli/services/projects-service.d.ts +8 -3
- package/dist/cli/services/projects-service.js +21 -4
- package/dist/core/config/defaults.js +1 -0
- package/dist/core/schema/config.d.ts +4 -0
- package/dist/core/schema/projects.d.ts +4 -0
- package/dist/core/schema/projects.js +3 -0
- package/dist/guest/boot/boot.js +8 -2
- package/dist/guest/daemon/daemon.js +3 -0
- package/dist/guest/daemon/live-folders.d.ts +48 -0
- package/dist/guest/daemon/live-folders.js +245 -0
- package/dist/guest/daemon/live.d.ts +4 -0
- package/dist/guest/daemon/live.js +57 -0
- package/dist/guest/entries/daemon.js +4 -2
- package/dist/guest/runtime/csc.d.ts +1 -0
- package/dist/guest/runtime/csc.js +1 -0
- package/dist/guest/runtime/secrets.d.ts +5 -2
- package/dist/guest/runtime/secrets.js +12 -5
- package/dist/host/engines/hcs/live-folders.d.ts +117 -0
- package/dist/host/engines/hcs/live-folders.js +347 -0
- package/dist/host/engines/index.d.ts +1 -0
- package/dist/host/engines/index.js +1 -0
- package/dist/host/launch/generate.js +8 -1
- package/dist/host/projects/join.d.ts +4 -0
- package/dist/host/projects/join.js +11 -3
- package/dist/host/projects/mapper.d.ts +34 -5
- package/dist/host/projects/mapper.js +88 -20
- package/dist/host/projects/registry.d.ts +2 -0
- package/dist/host/projects/registry.js +1 -1
- package/dist/host/secrets/launch-secrets.js +2 -1
- package/dist/runtime/agent.js +14 -0
- package/dist/runtime/daemon.js +466 -132
- package/dist/runtime/db.js +14 -0
- package/dist/runtime/main.js +26 -3
- package/dist/runtime/netproxy.js +14 -0
- package/dist/runtime/view.js +14 -0
- package/dist/selftest/index.js +2 -0
- package/dist/selftest/layout.test.js +1 -0
- package/dist/selftest/live-folders.test.d.ts +2 -0
- package/dist/selftest/live-folders.test.js +588 -0
- package/dist/selftest/projects.test.js +7 -7
- package/dist/selftest/support/engine-golden.js +1 -0
- package/dist/selftest/support/fixtures.d.ts +1 -0
- package/dist/selftest/support/live.js +2 -0
- package/dist/shared/channel/index.d.ts +1 -0
- package/dist/shared/channel/index.js +1 -0
- package/dist/shared/channel/live.d.ts +53 -0
- package/dist/shared/channel/live.js +92 -0
- package/dist/shared/channel/run-dir.d.ts +5 -0
- package/dist/shared/channel/run-dir.js +11 -0
- package/dist/shared/channel/secrets.d.ts +1 -0
- package/dist/shared/channel/secrets.js +5 -0
- package/dist/shared/paths/projects.d.ts +6 -1
- package/dist/shared/paths/projects.js +21 -1
- package/dist/sync/watcher.js +9 -0
- package/dist/web/index.html +1 -1
- package/dist/web/{main-6XNBTJLT.js → main-7DJZ2M27.js} +1 -1
- package/native/csharp/HcsHost/AssemblyInfo.cs +4 -4
- package/native/csharp/HcsHost/FolderAccess.cs +361 -0
- package/native/csharp/HcsHost/Hcs.cs +31 -3
- package/native/csharp/HcsHost/Program.cs +54 -6
- package/native/csharp/HcsHost/Share.cs +64 -11
- package/native/csharp/HcsHost/WsbGuard.cs +130 -0
- package/native/csharp/HcsHost/WsbShare.cs +295 -0
- package/native/csharp/HostTools/AssemblyInfo.cs +3 -3
- package/native/csharp/StorageHelper/AssemblyInfo.cs +3 -3
- package/native/scripts/contained-common.ps1 +4 -0
- package/native/scripts/hcs-live-share.ps1 +191 -0
- package/package.json +1 -1
- package/sandbox/csharp/BindMount.cs +120 -0
- package/schemas/config.schema.json +8 -0
|
@@ -0,0 +1,588 @@
|
|
|
1
|
+
// Live folders through HCS (opt-in alpha, docs/design/hcs-live-folders.md "Test plan", unit part): the signed
|
|
2
|
+
// host -> guest messages, the guest daemon's handler with a fake bind filter, the host's LiveFolders with a fake
|
|
3
|
+
// elevated helper (canary matched, wrong and late), the mapper's order (roots, wsb share, HCS, restart), the doctor
|
|
4
|
+
// row, and the refusals of HcsHost.exe and hcs-live-share.ps1 (no Hyper-V needed: all refused before HCS is asked).
|
|
5
|
+
import crypto from 'node:crypto';
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { spawnSync } from 'node:child_process';
|
|
10
|
+
import { isWin } from '../core/util/index.js';
|
|
11
|
+
import { LiveFolderHandler } from '../guest/daemon/live-folders.js';
|
|
12
|
+
import { buildHcsHostTo, HcsHelper } from '../host/engines/hcs/helper.js';
|
|
13
|
+
import { clearLiveRecord, LiveFolders, ScriptLivePort, } from '../host/engines/hcs/live-folders.js';
|
|
14
|
+
import { SecretStore } from '../host/secrets/index.js';
|
|
15
|
+
import { addFoldersRow, NeedsRestart, ProjectRegistry, projectMapper } from '../host/projects/index.js';
|
|
16
|
+
import { nativeFile } from '../host/native.js';
|
|
17
|
+
import { insideSandbox } from '../host/sandbox/index.js';
|
|
18
|
+
import { currentUserSid } from '../host/sharing/index.js';
|
|
19
|
+
import { LiveReplySchema, newLiveKey, openLive, RunDir, signLive } from '../shared/channel/index.js';
|
|
20
|
+
import { liveGuestFolder } from '../shared/paths/index.js';
|
|
21
|
+
import { SYSTEM } from '../shared/system.js';
|
|
22
|
+
const LAUNCH = '1700000000000-abcdef';
|
|
23
|
+
const GUID = '0b9a3c2e-1f00-4a4c-9d1e-6a7b8c9d0e1f';
|
|
24
|
+
const payload = (over = {}) => ({
|
|
25
|
+
v: 1,
|
|
26
|
+
kind: 'map',
|
|
27
|
+
id: 'req' + crypto.randomBytes(4).toString('hex'),
|
|
28
|
+
launchId: LAUNCH,
|
|
29
|
+
share: 'agentsbx-0123abcd',
|
|
30
|
+
folder: 'web',
|
|
31
|
+
readOnly: true,
|
|
32
|
+
createdAt: new Date().toISOString(),
|
|
33
|
+
...over,
|
|
34
|
+
});
|
|
35
|
+
/** A request signed over any text (to send payloads signLive itself would refuse). */
|
|
36
|
+
const signRaw = (key, text) => ({
|
|
37
|
+
payload: text,
|
|
38
|
+
mac: crypto.createHmac('sha256', Buffer.from(key, 'hex')).update(text, 'utf8').digest('hex'),
|
|
39
|
+
});
|
|
40
|
+
/** A bind filter in memory: a "share" is a token; map makes the folder with token.txt, unmap removes it. */
|
|
41
|
+
class FakeBind {
|
|
42
|
+
calls = [];
|
|
43
|
+
tokens = new Map();
|
|
44
|
+
missingFor = 0;
|
|
45
|
+
map(folder, share, readOnly) {
|
|
46
|
+
this.calls.push('map ' + path.win32.basename(folder) + ' ' + share + (readOnly ? ' ro' : ' rw'));
|
|
47
|
+
if (this.missingFor > 0) {
|
|
48
|
+
this.missingFor--;
|
|
49
|
+
return 3;
|
|
50
|
+
}
|
|
51
|
+
if (!this.tokens.has(share))
|
|
52
|
+
return 3;
|
|
53
|
+
fs.mkdirSync(folder, { recursive: true });
|
|
54
|
+
fs.writeFileSync(path.join(folder, 'token.txt'), this.tokens.get(share) ?? '');
|
|
55
|
+
return 0;
|
|
56
|
+
}
|
|
57
|
+
unmap(folder) {
|
|
58
|
+
this.calls.push('unmap ' + path.win32.basename(folder));
|
|
59
|
+
fs.rmSync(folder, { recursive: true, force: true });
|
|
60
|
+
return 0;
|
|
61
|
+
}
|
|
62
|
+
mapped(folder) {
|
|
63
|
+
return fs.existsSync(folder) ? 0 : 4;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
export const liveFoldersSuite = {
|
|
67
|
+
name: 'live-folders',
|
|
68
|
+
title: 'live folders through HCS (alpha): signed requests, guest handler, LiveFolders, mapper order, HcsHost refusals',
|
|
69
|
+
timeoutMs: 240000,
|
|
70
|
+
run: async (t) => {
|
|
71
|
+
t.section('live folders: signed requests');
|
|
72
|
+
const key = newLiveKey();
|
|
73
|
+
const signed = signLive(key, payload({ id: 'a1' }));
|
|
74
|
+
const now = Date.now();
|
|
75
|
+
const ok = openLive(key, signed, { launchId: LAUNCH, now });
|
|
76
|
+
const tampered = openLive(key, { ...signed, payload: signed.payload.replace('"web"', '"api"') }, { launchId: LAUNCH, now });
|
|
77
|
+
const wrongKey = openLive(newLiveKey(), signed, { launchId: LAUNCH, now });
|
|
78
|
+
const otherLaunch = openLive(key, signed, { launchId: '1700000000001-000000', now });
|
|
79
|
+
const old = openLive(key, signed, { launchId: LAUNCH, now: now + 3 * 60_000 });
|
|
80
|
+
const noKey = openLive(null, signed, { launchId: LAUNCH, now });
|
|
81
|
+
t.check('payload' in ok &&
|
|
82
|
+
'refused' in tampered &&
|
|
83
|
+
'refused' in wrongKey &&
|
|
84
|
+
'refused' in otherLaunch &&
|
|
85
|
+
'refused' in old &&
|
|
86
|
+
'refused' in noKey, 'a request opens only with its key, untouched, for this launch and while fresh');
|
|
87
|
+
const aliases = ['..', 'foo.', 'foo ', 'Foo', 'a\\b', ''].map((folder) => openLive(key, signRaw(key, JSON.stringify({ ...payload(), folder })), { launchId: LAUNCH, now }));
|
|
88
|
+
const foreignShare = openLive(key, signRaw(key, JSON.stringify({ ...payload(), share: '3ad483dec1a233132da4197c9790dcda06668793230cd6d9e94b1882e0f7f29c' })), { launchId: LAUNCH, now });
|
|
89
|
+
const extra = openLive(key, signRaw(key, JSON.stringify({ ...payload(), path: 'C:\\Windows' })), {
|
|
90
|
+
launchId: LAUNCH,
|
|
91
|
+
now,
|
|
92
|
+
});
|
|
93
|
+
t.check(aliases.every((r) => 'refused' in r) && 'refused' in foreignShare && 'refused' in extra, "even signed: folder names '..', 'foo.', 'foo ', upper case, a path, a Windows Sandbox share name or an extra field are refused");
|
|
94
|
+
t.check(liveGuestFolder('D:\\code\\My.App') === 'C:\\Workspace\\my-app' &&
|
|
95
|
+
liveGuestFolder('D:\\x\\web', ['C:\\Workspace\\web']) === 'C:\\Workspace\\web-2' &&
|
|
96
|
+
liveGuestFolder('D:\\x\\...') === 'C:\\Workspace\\project', 'a live folder gets a strict C:\\Workspace\\<name> (lower case, no dots), unique against the folders in use');
|
|
97
|
+
t.section('live folders: the guest handler (fake bind filter)');
|
|
98
|
+
const data = t.dir('live-data');
|
|
99
|
+
const run = new RunDir(data);
|
|
100
|
+
const workspaces = t.dir('live-ws');
|
|
101
|
+
const bind = new FakeBind();
|
|
102
|
+
const logs = [];
|
|
103
|
+
const handler = new LiveFolderHandler({
|
|
104
|
+
run,
|
|
105
|
+
stateFile: t.at('live-daemon', 'live-folders.json'),
|
|
106
|
+
launchId: LAUNCH,
|
|
107
|
+
bind,
|
|
108
|
+
workspaces,
|
|
109
|
+
sleep: async () => undefined,
|
|
110
|
+
tries: 5,
|
|
111
|
+
log: (l) => logs.push(l),
|
|
112
|
+
});
|
|
113
|
+
const send = async (envelope, id = 'x' + crypto.randomBytes(3).toString('hex')) => {
|
|
114
|
+
t.writeJson(path.join(run.liveDir, id + '.json'), envelope);
|
|
115
|
+
handler.tick();
|
|
116
|
+
await handler.idle();
|
|
117
|
+
};
|
|
118
|
+
const replyOf = (id) => {
|
|
119
|
+
const file = run.liveReplyFile(id);
|
|
120
|
+
if (!fs.existsSync(file))
|
|
121
|
+
return null;
|
|
122
|
+
const parsed = LiveReplySchema.safeParse(JSON.parse(fs.readFileSync(file, 'utf8')));
|
|
123
|
+
return parsed.success ? parsed.data : null;
|
|
124
|
+
};
|
|
125
|
+
await send(signLive(key, payload({ id: 'nokey' })));
|
|
126
|
+
t.check(replyOf('nokey') === null && fs.readdirSync(run.liveDir).length === 0, 'without a key nothing is done and the file is deleted');
|
|
127
|
+
handler.setKey(key);
|
|
128
|
+
const token = crypto.randomBytes(16).toString('hex');
|
|
129
|
+
bind.tokens.set('agentsbx-cafe0001', token);
|
|
130
|
+
bind.missingFor = 2;
|
|
131
|
+
await send(signLive(key, payload({ id: 'c1', kind: 'canary', share: 'agentsbx-cafe0001', folder: 'live-canary-01' })));
|
|
132
|
+
const canary = replyOf('c1');
|
|
133
|
+
t.check(canary?.ok === true &&
|
|
134
|
+
canary.token === token &&
|
|
135
|
+
bind.calls.filter((c) => c.startsWith('map live-canary-01')).length === 3 &&
|
|
136
|
+
!fs.existsSync(path.join(workspaces, 'live-canary-01')), 'a canary is retried while the share is not there yet, answers with the token read from it, and is hidden again', JSON.stringify({ canary, calls: bind.calls }));
|
|
137
|
+
bind.tokens.set('agentsbx-0123abcd', 'x');
|
|
138
|
+
await send(signLive(key, payload({ id: 'm1', folder: 'web', readOnly: false })));
|
|
139
|
+
t.check(replyOf('m1')?.ok === true &&
|
|
140
|
+
bind.calls.includes('map web agentsbx-0123abcd rw') &&
|
|
141
|
+
fs.existsSync(path.join(workspaces, 'web')), 'a map shows the share at C:\\Workspace\\<name>, writable only when the signed request says so');
|
|
142
|
+
await send(signLive(key, payload({ id: 'm1' })));
|
|
143
|
+
t.check(replyOf('m1') === null || logs.some((l) => l.includes('seen before')), 'a replayed request id is ignored');
|
|
144
|
+
fs.rmSync(run.liveReplyFile('m1'), { force: true });
|
|
145
|
+
await send(signLive(key, payload({ id: 'm1' })));
|
|
146
|
+
t.check(replyOf('m1') === null && logs.some((l) => l.includes('seen before')), 'the same id twice is acted on once');
|
|
147
|
+
t.dir('live-ws', 'taken');
|
|
148
|
+
await send(signLive(key, payload({ id: 'm2', folder: 'taken' })));
|
|
149
|
+
t.check(replyOf('m2')?.ok === false && /exists already/.test(replyOf('m2')?.message ?? ''), 'a folder that exists already is refused');
|
|
150
|
+
await send({ payload: signed.payload, mac: '0'.repeat(64) }, 'forged');
|
|
151
|
+
await send(signRaw(key, JSON.stringify({ ...payload({ id: 'dots' }), folder: '..' })), 'dots');
|
|
152
|
+
t.check(replyOf('forged') === null && replyOf('dots') === null && fs.readdirSync(run.liveDir).length === 0, 'a forged signature or a signed alias name is deleted unread and never answered');
|
|
153
|
+
// run\live can be written by the agent (an administrator in the sandbox) and by other users of a shared sandbox:
|
|
154
|
+
// only the signature counts, so a planted folder or link there is removed and never followed.
|
|
155
|
+
const planted = path.join(run.liveDir, 'planted.json');
|
|
156
|
+
fs.mkdirSync(planted, { recursive: true });
|
|
157
|
+
t.write(path.join(planted, 'inside.txt'), 'x');
|
|
158
|
+
handler.tick();
|
|
159
|
+
await handler.idle();
|
|
160
|
+
t.check(!fs.existsSync(planted), 'a folder planted in run\\live is removed unread');
|
|
161
|
+
await send(signLive(key, payload({ id: 'u0', kind: 'unmap', share: undefined, folder: 'other' })));
|
|
162
|
+
t.check(replyOf('u0')?.ok === false, 'unmap only takes folders this handler mapped');
|
|
163
|
+
const handler2 = new LiveFolderHandler({
|
|
164
|
+
run,
|
|
165
|
+
stateFile: t.at('live-daemon', 'live-folders.json'),
|
|
166
|
+
launchId: LAUNCH,
|
|
167
|
+
bind,
|
|
168
|
+
workspaces,
|
|
169
|
+
});
|
|
170
|
+
fs.rmSync(path.join(workspaces, 'web'), { recursive: true, force: true });
|
|
171
|
+
t.check(handler2.reapply() === 1 && fs.existsSync(path.join(workspaces, 'web')), 'when the daemon starts again it shows the recorded mappings again');
|
|
172
|
+
await send(signLive(key, payload({ id: 'u1', kind: 'unmap', share: undefined, folder: 'web' })));
|
|
173
|
+
t.check(replyOf('u1')?.ok === true && !fs.existsSync(path.join(workspaces, 'web')) && handler2.reapply() === 0, 'unmap hides the folder, checks it is gone and forgets it');
|
|
174
|
+
t.section('live folders: LiveFolders on the host (fake elevated helper, the handler as the sandbox)');
|
|
175
|
+
const storage = t.dir('live-storage');
|
|
176
|
+
const hostData = path.join(storage, 'data', 'testdata');
|
|
177
|
+
t.dir('live-storage', 'data', 'testdata');
|
|
178
|
+
const hostRun = new RunDir(hostData);
|
|
179
|
+
const guestBind = new FakeBind();
|
|
180
|
+
const guest = new LiveFolderHandler({
|
|
181
|
+
run: hostRun,
|
|
182
|
+
stateFile: t.at('live-daemon2', 'live-folders.json'),
|
|
183
|
+
launchId: LAUNCH,
|
|
184
|
+
bind: guestBind,
|
|
185
|
+
workspaces: t.dir('live-ws2'),
|
|
186
|
+
sleep: async () => undefined,
|
|
187
|
+
tries: 2,
|
|
188
|
+
});
|
|
189
|
+
let guestOn = true;
|
|
190
|
+
const ticker = setInterval(() => {
|
|
191
|
+
if (guestOn)
|
|
192
|
+
guest.tick();
|
|
193
|
+
}, 50);
|
|
194
|
+
/** The elevated helper's part: a canary token (what the guest's share holds), the real share only on a match. */
|
|
195
|
+
const shares = [];
|
|
196
|
+
let wrongToken = false;
|
|
197
|
+
let failRemove = false;
|
|
198
|
+
const requests = [];
|
|
199
|
+
const elevated = {
|
|
200
|
+
run: async (request, onCanary) => {
|
|
201
|
+
requests.push(request);
|
|
202
|
+
if (request.action === 'remove') {
|
|
203
|
+
if (failRemove)
|
|
204
|
+
return { ok: false, message: 'The Host Compute Service said: busy' };
|
|
205
|
+
shares.splice(shares.indexOf(request.name), 1);
|
|
206
|
+
return { ok: true, message: 'removed', id: GUID };
|
|
207
|
+
}
|
|
208
|
+
const canaryShare = 'agentsbx-' + crypto.randomBytes(8).toString('hex');
|
|
209
|
+
const canaryToken = crypto.randomBytes(16).toString('hex');
|
|
210
|
+
guestBind.tokens.set(canaryShare, wrongToken ? crypto.randomBytes(16).toString('hex') : canaryToken);
|
|
211
|
+
const answer = await onCanary(canaryShare);
|
|
212
|
+
if (answer !== canaryToken)
|
|
213
|
+
return {
|
|
214
|
+
ok: false,
|
|
215
|
+
message: 'wrong token or no answer',
|
|
216
|
+
id: GUID,
|
|
217
|
+
canary: answer ? 'wrong token' : 'no answer',
|
|
218
|
+
};
|
|
219
|
+
shares.push(request.name);
|
|
220
|
+
guestBind.tokens.set(request.name, 'real');
|
|
221
|
+
return { ok: true, message: 'added', id: GUID, canary: 'matched' };
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
const live = new LiveFolders({
|
|
225
|
+
storageRoot: storage,
|
|
226
|
+
dataPath: hostData,
|
|
227
|
+
secrets: SecretStore.memory(),
|
|
228
|
+
elevated,
|
|
229
|
+
userSid: () => 'S-1-5-21-1-2-3-1001',
|
|
230
|
+
pollMs: 20,
|
|
231
|
+
canaryMs: 3000,
|
|
232
|
+
mapMs: 3000,
|
|
233
|
+
});
|
|
234
|
+
t.check(/turn it on before it starts/.test(live.unavailable(LAUNCH) ?? ''), 'no record: live folders are unavailable, and the reason says why');
|
|
235
|
+
const launchKey = live.startLaunch(LAUNCH);
|
|
236
|
+
guest.setKey(launchKey);
|
|
237
|
+
const recordText = fs.readFileSync(path.join(storage, 'state', 'live-folders.json'), 'utf8');
|
|
238
|
+
t.check(/^[0-9a-f]{64}$/.test(launchKey) && !recordText.includes(launchKey) && live.unavailable(LAUNCH) === null, 'a launch makes a key, kept sealed in the host-only record (never in plain text)');
|
|
239
|
+
t.check(/earlier sandbox/.test(live.unavailable('1700000000009-999999') ?? ''), 'a record of another launch is not used');
|
|
240
|
+
const hostFolder = t.dir('live-project');
|
|
241
|
+
const added = await live.add({
|
|
242
|
+
launchId: LAUNCH,
|
|
243
|
+
host: hostFolder,
|
|
244
|
+
guest: 'C:\\Workspace\\live-project',
|
|
245
|
+
folder: 'live-project',
|
|
246
|
+
readOnly: true,
|
|
247
|
+
});
|
|
248
|
+
const rec = live.read();
|
|
249
|
+
t.check(shares.includes(added.name) &&
|
|
250
|
+
rec?.systemId === GUID &&
|
|
251
|
+
rec.shares.length === 1 &&
|
|
252
|
+
requests[0].id === '' &&
|
|
253
|
+
requests[0].readOnly === true &&
|
|
254
|
+
requests[0].path === hostFolder &&
|
|
255
|
+
guestBind.calls.some((c) => c === 'map live-project ' + added.name + ' ro'), 'add: the canary comes back through the sandbox, then the folder is shared read-only, recorded with the proved id and shown inside', JSON.stringify({ rec, calls: guestBind.calls }));
|
|
256
|
+
const second = t.dir('live-project-2');
|
|
257
|
+
await live.add({
|
|
258
|
+
launchId: LAUNCH,
|
|
259
|
+
host: second,
|
|
260
|
+
guest: 'C:\\Workspace\\live-project-2',
|
|
261
|
+
folder: 'live-project-2',
|
|
262
|
+
readOnly: false,
|
|
263
|
+
});
|
|
264
|
+
t.check(requests[requests.length - 1].id === GUID, 'later adds name the recorded sandbox id (a restarted sandbox is refused by HcsHost)');
|
|
265
|
+
wrongToken = true;
|
|
266
|
+
const before = shares.length;
|
|
267
|
+
const third = t.dir('live-project-3');
|
|
268
|
+
const mismatch = await t.thrownMessage(() => live.add({
|
|
269
|
+
launchId: LAUNCH,
|
|
270
|
+
host: third,
|
|
271
|
+
guest: 'C:\\Workspace\\live-project-3',
|
|
272
|
+
folder: 'live-project-3',
|
|
273
|
+
readOnly: true,
|
|
274
|
+
}));
|
|
275
|
+
wrongToken = false;
|
|
276
|
+
t.check(/not shared/.test(mismatch) && shares.length === before && live.read()?.shares.length === 2, 'a canary token that does not match: the real folder is never shared and nothing is recorded', mismatch);
|
|
277
|
+
guestOn = false;
|
|
278
|
+
const late = await t.thrownMessage(() => live.add({
|
|
279
|
+
launchId: LAUNCH,
|
|
280
|
+
host: third,
|
|
281
|
+
guest: 'C:\\Workspace\\live-project-3',
|
|
282
|
+
folder: 'live-project-3',
|
|
283
|
+
readOnly: true,
|
|
284
|
+
}));
|
|
285
|
+
guestOn = true;
|
|
286
|
+
t.check(/not shared/.test(late) && shares.length === before && live.read()?.shares.length === 2, 'no canary answer in time: the real folder is never shared', late);
|
|
287
|
+
failRemove = true;
|
|
288
|
+
const keep = await t.thrownMessage(() => live.remove(LAUNCH, second));
|
|
289
|
+
failRemove = false;
|
|
290
|
+
t.check(/could not be removed/.test(keep) && live.read()?.shares.length === 2, 'a share that cannot be removed stays in the record (and the user is told)', keep);
|
|
291
|
+
await live.remove(LAUNCH, hostFolder);
|
|
292
|
+
t.check(!shares.includes(added.name) &&
|
|
293
|
+
live.read()?.shares.length === 1 &&
|
|
294
|
+
guestBind.calls.includes('unmap live-project'), 'remove: the guest mapping first, then the share, then the record');
|
|
295
|
+
t.check(!clearLiveRecord(storage, path.join(storage, 'data', 'other')) &&
|
|
296
|
+
live.read() !== null &&
|
|
297
|
+
clearLiveRecord(storage, hostData) &&
|
|
298
|
+
live.read() === null, 'a stop clears the record of its own data folder only');
|
|
299
|
+
clearInterval(ticker);
|
|
300
|
+
t.section('live folders: the elevated script port relays the canary (fake runner)');
|
|
301
|
+
const relay = async (token) => {
|
|
302
|
+
let answer = '';
|
|
303
|
+
const port = new ScriptLivePort(async (_file, args) => {
|
|
304
|
+
const json = args[args.indexOf('-Json') + 1];
|
|
305
|
+
const dir = path.dirname(json);
|
|
306
|
+
fs.writeFileSync(path.join(dir, 'canary.json'), JSON.stringify({ share: 'agentsbx-00000000aaaa' }));
|
|
307
|
+
for (let i = 0; i < 100 && !fs.existsSync(path.join(dir, 'answer.txt')); i++)
|
|
308
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
309
|
+
answer = fs.existsSync(path.join(dir, 'answer.txt'))
|
|
310
|
+
? fs.readFileSync(path.join(dir, 'answer.txt'), 'utf8')
|
|
311
|
+
: '';
|
|
312
|
+
fs.writeFileSync(path.join(dir, 'result.json'), JSON.stringify({ ok: answer === 'f'.repeat(32), message: 'm', id: GUID }));
|
|
313
|
+
return { status: 0 };
|
|
314
|
+
}, t.dir('live-tmp'), 10);
|
|
315
|
+
const outcome = await port.run({
|
|
316
|
+
action: 'add',
|
|
317
|
+
id: '',
|
|
318
|
+
name: 'agentsbx-0123abcd',
|
|
319
|
+
path: hostFolder,
|
|
320
|
+
readOnly: true,
|
|
321
|
+
storageRoot: storage,
|
|
322
|
+
dataPath: hostData,
|
|
323
|
+
userSid: 'S-1-5-21-1',
|
|
324
|
+
}, async () => token);
|
|
325
|
+
return { answer, ok: outcome.ok };
|
|
326
|
+
};
|
|
327
|
+
const good = await relay('f'.repeat(32));
|
|
328
|
+
const none = await relay(null);
|
|
329
|
+
const junk = await relay('not-a-token');
|
|
330
|
+
t.check(good.answer === 'f'.repeat(32) && good.ok && none.answer === 'none' && !none.ok && junk.answer === 'none', "the canary token goes to the helper as it came; no answer (or anything that is not a token) is 'none'", JSON.stringify({ good, none, junk }));
|
|
331
|
+
t.check(fs.readdirSync(t.at('live-tmp')).length === 0, 'the private request folder is removed afterwards');
|
|
332
|
+
t.section('live folders: the mapper tries roots, then wsb share, then HCS, then a restart');
|
|
333
|
+
const mStorage = t.dir('live-map');
|
|
334
|
+
const root = t.dir('live-map-root');
|
|
335
|
+
const inRoot = t.dir('live-map-root', 'web');
|
|
336
|
+
const outside = t.dir('live-map-outside');
|
|
337
|
+
new ProjectRegistry(mStorage).start({
|
|
338
|
+
launchId: LAUNCH,
|
|
339
|
+
protections: {},
|
|
340
|
+
primary: { host: t.dir('live-map-primary'), guest: 'C:\\Workspace\\live-map-primary' },
|
|
341
|
+
roots: [{ host: root, guest: 'C:\\Projects\\live-map-root' }],
|
|
342
|
+
boot: [],
|
|
343
|
+
});
|
|
344
|
+
const liveCalls = [];
|
|
345
|
+
let liveFails = false;
|
|
346
|
+
const fakeLive = {
|
|
347
|
+
unavailable: () => null,
|
|
348
|
+
shareOf: (host) => (liveCalls.some((c) => c.startsWith('add ' + host)) ? { host } : null),
|
|
349
|
+
add: async (o) => {
|
|
350
|
+
liveCalls.push('add ' + o.host + ' ' + o.guest + ' ' + (o.readOnly ? 'ro' : 'rw'));
|
|
351
|
+
if (liveFails)
|
|
352
|
+
throw new Error('canary did not match');
|
|
353
|
+
return {
|
|
354
|
+
name: 'agentsbx-0123abcd',
|
|
355
|
+
host: o.host,
|
|
356
|
+
guest: o.guest,
|
|
357
|
+
folder: o.folder,
|
|
358
|
+
readOnly: o.readOnly,
|
|
359
|
+
addedAt: '',
|
|
360
|
+
};
|
|
361
|
+
},
|
|
362
|
+
remove: async (_l, host) => {
|
|
363
|
+
liveCalls.push('remove ' + host);
|
|
364
|
+
if (liveFails)
|
|
365
|
+
throw new Error('files are open');
|
|
366
|
+
},
|
|
367
|
+
};
|
|
368
|
+
const withShare = () => ({
|
|
369
|
+
exe: t.at('no-wsb.exe'),
|
|
370
|
+
version: '0.1',
|
|
371
|
+
commands: ['share', 'list'],
|
|
372
|
+
has: (c) => c === 'share' || c === 'list',
|
|
373
|
+
});
|
|
374
|
+
const mapper = (o) => projectMapper({
|
|
375
|
+
storageRoot: mStorage,
|
|
376
|
+
packageRoot: t.dir('live-map-install'),
|
|
377
|
+
wsbShare: o.wsbShare ?? true,
|
|
378
|
+
liveFolders: 'hcs',
|
|
379
|
+
dataPath: path.join(mStorage, 'data', 'x'),
|
|
380
|
+
detect: o.detect ?? (() => null),
|
|
381
|
+
live: () => ({ ...fakeLive, unavailable: () => o.unavailable ?? null }),
|
|
382
|
+
isMine: () => o.mine ?? true,
|
|
383
|
+
});
|
|
384
|
+
const fromRoot = await mapper({}).register(inRoot);
|
|
385
|
+
t.check(fromRoot.mechanism === 'root' && liveCalls.length === 0, 'a folder in a root joins as before; HCS is not asked');
|
|
386
|
+
let viaWsb = null;
|
|
387
|
+
try {
|
|
388
|
+
await mapper({ detect: withShare }).register(outside);
|
|
389
|
+
}
|
|
390
|
+
catch (e) {
|
|
391
|
+
viaWsb = e;
|
|
392
|
+
}
|
|
393
|
+
t.check(viaWsb instanceof NeedsRestart && /wsb share/.test(viaWsb.reason) && liveCalls.length === 0, 'where wsb share exists it is used (here it fails): HCS is never tried');
|
|
394
|
+
const offWsb = await t.thrownMessage(() => mapper({ wsbShare: false, detect: withShare }).register(outside));
|
|
395
|
+
t.check(/projects.wsbShare/.test(offWsb) && liveCalls.length === 0, 'wsb share switched off but present: a restart, never HCS');
|
|
396
|
+
const notMine = await t.thrownMessage(() => mapper({ mine: false }).register(outside));
|
|
397
|
+
const noRecord = await t.thrownMessage(() => mapper({ unavailable: 'turn it on before it starts' }).register(outside));
|
|
398
|
+
t.check(/not started by you/.test(notMine) && /turn it on before it starts/.test(noRecord) && liveCalls.length === 0, "another user's sandbox, or one started without live folders: a restart with the reason, HCS not asked");
|
|
399
|
+
const notes = [];
|
|
400
|
+
const viaHcs = await mapper({}).register(outside, { readOnly: false, onNote: (n) => notes.push(n) });
|
|
401
|
+
t.check(viaHcs.mechanism === 'hcs' &&
|
|
402
|
+
viaHcs.guest === 'C:\\Workspace\\live-map-outside' &&
|
|
403
|
+
liveCalls[0]?.endsWith(' rw') &&
|
|
404
|
+
notes.some((n) => /save your work first/.test(n)), 'no wsb share and liveFolders "hcs": HCS adds it (writable only when the caller asks; joining always asks read-only), after the warning', JSON.stringify(liveCalls));
|
|
405
|
+
liveFails = true;
|
|
406
|
+
const outside2 = t.dir('live-map-outside-2');
|
|
407
|
+
let failed = null;
|
|
408
|
+
try {
|
|
409
|
+
await mapper({}).register(outside2);
|
|
410
|
+
}
|
|
411
|
+
catch (e) {
|
|
412
|
+
failed = e;
|
|
413
|
+
}
|
|
414
|
+
t.check(failed instanceof NeedsRestart && /through HCS failed/.test(failed.reason) && liveCalls[1]?.endsWith(' ro'), 'an HCS failure becomes a restart offer with the reason (read-only when the project mapping is not known)');
|
|
415
|
+
const kept = await t.thrownMessage(() => mapper({}).unregister(viaHcs.name));
|
|
416
|
+
t.check(/files are open/.test(kept) &&
|
|
417
|
+
new ProjectRegistry(mStorage).read()?.projects.some((p) => p.name === viaHcs.name) === true, 'removing a live folder that cannot be hidden keeps the project');
|
|
418
|
+
liveFails = false;
|
|
419
|
+
await mapper({}).unregister(viaHcs.name);
|
|
420
|
+
t.check(liveCalls.includes('remove ' + fs.realpathSync.native(outside)) &&
|
|
421
|
+
new ProjectRegistry(mStorage).read()?.projects.some((p) => p.name === viaHcs.name) === false, 'removing a live folder hides it and removes its share, then forgets the project');
|
|
422
|
+
t.section('live folders: the doctor row');
|
|
423
|
+
t.check(/^wsb share/.test(addFoldersRow({ wsbShare: true, liveFolders: 'hcs', wsbHasShare: true, liveUnavailable: null })) &&
|
|
424
|
+
/^HCS \(alpha, untested on a real PC\): needs an administrator prompt/.test(addFoldersRow({ wsbShare: true, liveFolders: 'hcs', wsbHasShare: false, liveUnavailable: null })) &&
|
|
425
|
+
/not for the running sandbox: x/.test(addFoldersRow({ wsbShare: true, liveFolders: 'hcs', wsbHasShare: false, liveUnavailable: 'x' })) &&
|
|
426
|
+
/^restart/.test(addFoldersRow({ wsbShare: true, liveFolders: 'off', wsbHasShare: false, liveUnavailable: null })), '"Add folders while the sandbox runs" says wsb share, HCS (alpha, with the prompt) or restart, and why');
|
|
427
|
+
t.section('live folders: hcs-live-share.ps1 checks its request (dry run, nothing elevated)');
|
|
428
|
+
if (!isWin) {
|
|
429
|
+
t.skip('not Windows');
|
|
430
|
+
}
|
|
431
|
+
else {
|
|
432
|
+
const dry = (request) => {
|
|
433
|
+
const file = t.writeJson(t.at('live-dry', crypto.randomBytes(4).toString('hex') + '.json'), request);
|
|
434
|
+
const r = spawnSync(SYSTEM.powershell(), [
|
|
435
|
+
'-NoProfile',
|
|
436
|
+
'-NonInteractive',
|
|
437
|
+
'-ExecutionPolicy',
|
|
438
|
+
'Bypass',
|
|
439
|
+
'-File',
|
|
440
|
+
nativeFile('scripts', 'hcs-live-share.ps1'),
|
|
441
|
+
'-Json',
|
|
442
|
+
file,
|
|
443
|
+
'-DryRun',
|
|
444
|
+
], { encoding: 'utf8', windowsHide: true, timeout: 60000 });
|
|
445
|
+
return { ok: r.status === 0, out: (r.stdout ?? '') + (r.stderr ?? '') };
|
|
446
|
+
};
|
|
447
|
+
const base = {
|
|
448
|
+
action: 'add',
|
|
449
|
+
id: '',
|
|
450
|
+
name: 'agentsbx-0123abcd',
|
|
451
|
+
path: hostFolder,
|
|
452
|
+
readOnly: true,
|
|
453
|
+
storageRoot: storage,
|
|
454
|
+
dataPath: hostData,
|
|
455
|
+
userSid: 'S-1-5-21-1-2-3-1001',
|
|
456
|
+
};
|
|
457
|
+
const good2 = dry(base);
|
|
458
|
+
t.check(good2.ok && /would add the share agentsbx-0123abcd/.test(good2.out), 'a valid request passes the dry run', good2.out);
|
|
459
|
+
const bad = [
|
|
460
|
+
dry({ ...base, name: '3ad483dec1a233132da4197c9790dcda06668793230cd6d9e94b1882e0f7f29c' }),
|
|
461
|
+
dry({ ...base, action: 'update' }),
|
|
462
|
+
dry({ ...base, storageRoot: '' }),
|
|
463
|
+
dry({ ...base, dataPath: 'C:\\x\\..\\data' }),
|
|
464
|
+
dry({ ...base, userSid: 'me' }),
|
|
465
|
+
dry({ ...base, id: 'agent-sandbox-1' }),
|
|
466
|
+
dry({ ...base, action: 'remove', id: '' }),
|
|
467
|
+
dry({ ...base, readOnly: 'yes' }),
|
|
468
|
+
dry({ ...base, path: 'relative' }),
|
|
469
|
+
];
|
|
470
|
+
t.check(bad.every((b) => !b.ok && /refused/.test(b.out)), 'it refuses a foreign share name, an update, no storage folder, a dotted data path, no valid user, a foreign id, a remove without id, a non-boolean readOnly and a relative path', bad.map((b) => b.out.trim()).join(' | '));
|
|
471
|
+
}
|
|
472
|
+
t.section('live folders: HcsHost.exe wsb-share and its rules (C# 5, no HCS asked)');
|
|
473
|
+
if (!isWin || !fs.existsSync(SYSTEM.csc())) {
|
|
474
|
+
t.skip('no Windows csc on this machine');
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
const exe = t.at('live-hcshost', 'HcsHost.exe');
|
|
478
|
+
t.check(buildHcsHostTo(exe), 'HcsHost.exe builds');
|
|
479
|
+
const raw = new HcsHelper(() => exe, undefined, t.dir('live-hcs-tmp'));
|
|
480
|
+
const rules = (systems, id) => {
|
|
481
|
+
const file = t.writeJson(t.at('live-enum', crypto.randomBytes(4).toString('hex') + '.json'), systems);
|
|
482
|
+
return raw.call(['wsb-rules', file, ...(id ? [id] : [])]);
|
|
483
|
+
};
|
|
484
|
+
const sandbox = { Id: GUID, SystemType: 'VirtualMachine', Owner: 'Madrid', State: 'Running' };
|
|
485
|
+
const one = rules([
|
|
486
|
+
sandbox,
|
|
487
|
+
{ Id: 'agent-sandbox-x', SystemType: 'VirtualMachine', Owner: 'agent-sandbox', State: 'Running' },
|
|
488
|
+
]);
|
|
489
|
+
t.check(one.ok && one.values.id === GUID, 'exactly one running Windows Sandbox is found among other systems', JSON.stringify(one));
|
|
490
|
+
t.check(!rules([]).ok &&
|
|
491
|
+
!rules([sandbox, { ...sandbox, Id: '1b9a3c2e-1f00-4a4c-9d1e-6a7b8c9d0e1f' }]).ok &&
|
|
492
|
+
!rules([{ ...sandbox, Owner: 'CmService' }]).ok &&
|
|
493
|
+
!rules([{ ...sandbox, State: 'Stopped' }]).ok &&
|
|
494
|
+
!rules([{ ...sandbox, Id: 'WindowsSandbox' }]).ok &&
|
|
495
|
+
!rules([{ ...sandbox, SystemType: 'Container' }]).ok &&
|
|
496
|
+
!rules([sandbox], '1b9a3c2e-1f00-4a4c-9d1e-6a7b8c9d0e1f').ok &&
|
|
497
|
+
!rules([sandbox], 'agent-sandbox-1').ok &&
|
|
498
|
+
rules([sandbox], GUID.toUpperCase()).ok, 'none, two, a wrong owner, a stopped one, a non-GUID id, a container, another recorded id or one of ours: refused');
|
|
499
|
+
const notJson = t.write(t.at('live-enum', 'bad.json'), 'not json');
|
|
500
|
+
t.check(!raw.call(['wsb-rules', notJson]).ok, 'a list that is not JSON is refused');
|
|
501
|
+
// wsb-share: refused before HCS is asked.
|
|
502
|
+
const wsbShare = (request, sha) => {
|
|
503
|
+
const file = t.writeJson(t.at('live-req', crypto.randomBytes(4).toString('hex') + '.json'), request);
|
|
504
|
+
const hash = sha ?? crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
|
505
|
+
return raw.call(['wsb-share', file, '--sha256', hash]);
|
|
506
|
+
};
|
|
507
|
+
// Long forms (the temp folder may be a short WDAGUT~1 path, which HcsHost refuses on its own): each refusal below
|
|
508
|
+
// is then for the reason it names.
|
|
509
|
+
const long = (p) => fs.realpathSync.native(p);
|
|
510
|
+
const lFolder = long(hostFolder);
|
|
511
|
+
const lStorage = long(storage);
|
|
512
|
+
const lData = long(hostData);
|
|
513
|
+
const req = {
|
|
514
|
+
id: '',
|
|
515
|
+
action: 'add',
|
|
516
|
+
name: 'agentsbx-0123abcd',
|
|
517
|
+
path: lFolder,
|
|
518
|
+
readOnly: true,
|
|
519
|
+
storageRoot: lStorage,
|
|
520
|
+
dataPath: lData,
|
|
521
|
+
userSid: currentUserSid(),
|
|
522
|
+
};
|
|
523
|
+
const usage = raw.call(['wsb-share', t.writeJson(t.at('live-req', 'nosha.json'), req)]);
|
|
524
|
+
const changed = wsbShare(req, 'b'.repeat(64));
|
|
525
|
+
t.check(!usage.ok && /Usage/.test(usage.message), 'wsb-share without the request checksum is a usage error');
|
|
526
|
+
t.check(!changed.ok && /changed after it was checked/.test(changed.message), 'a request file that changed after it was hashed is refused');
|
|
527
|
+
const refusedAll = [
|
|
528
|
+
wsbShare({ ...req, id: 'agent-sandbox-1' }),
|
|
529
|
+
wsbShare({ ...req, id: 'WindowsSandbox' }),
|
|
530
|
+
wsbShare({ ...req, action: 'remove' }),
|
|
531
|
+
wsbShare({ ...req, action: 'update' }),
|
|
532
|
+
wsbShare({ ...req, name: '3ad483dec1a233132da4197c9790dcda06668793230cd6d9e94b1882e0f7f29c' }),
|
|
533
|
+
wsbShare({ ...req, path: 'C:\\' }),
|
|
534
|
+
wsbShare({ ...req, path: process.env.SystemRoot ?? 'C:\\Windows' }),
|
|
535
|
+
// The profile root (from the temp folder: C:\Users\<you>\AppData\Local\Temp), never HOME/USERPROFILE.
|
|
536
|
+
wsbShare({ ...req, path: fs.realpathSync.native(path.resolve(os.tmpdir(), '..', '..', '..')) }),
|
|
537
|
+
wsbShare({ ...req, path: lStorage }),
|
|
538
|
+
wsbShare({ ...req, path: lData }),
|
|
539
|
+
wsbShare({ ...req, path: path.dirname(lStorage) }),
|
|
540
|
+
wsbShare({ ...req, path: lFolder + ':stream' }),
|
|
541
|
+
wsbShare({ ...req, path: lFolder + '.' }),
|
|
542
|
+
wsbShare({ ...req, readOnly: 'yes' }),
|
|
543
|
+
wsbShare({ ...req, storageRoot: '' }),
|
|
544
|
+
];
|
|
545
|
+
t.check(refusedAll.every((r) => !r.ok && /^Refused: /.test(r.message) && !/another account/.test(r.message)), 'wsb-share refuses our own ids, a non-GUID, a remove without id, an update, a foreign share name, a drive, Windows, the profile root, the storage or data folder (or one that holds them), a stream, a trailing dot, a non-boolean readOnly and a request without the storage folder', refusedAll.map((r) => r.message).join(' | '));
|
|
546
|
+
const otherUser = wsbShare({ ...req, userSid: 'S-1-5-21-1-2-3-1001' });
|
|
547
|
+
t.check(!otherUser.ok && /another account/.test(otherUser.message), 'a prompt answered with another account (or a request for another user) is refused', otherUser.message);
|
|
548
|
+
const shareGuid = raw.call(['share'], { id: GUID, action: 'add', name: 'agentsbx-0123abcd', path: hostFolder });
|
|
549
|
+
t.check(!shareGuid.ok && /^Refused: /.test(shareGuid.message), "share (our own systems) refuses Windows Sandbox's GUID");
|
|
550
|
+
// A computecore.dll planted next to the exe is never loaded (System32 only).
|
|
551
|
+
const planted2 = t.dir('live-planted');
|
|
552
|
+
fs.copyFileSync(exe, path.join(planted2, 'HcsHost.exe'));
|
|
553
|
+
fs.writeFileSync(path.join(planted2, 'computecore.dll'), 'MZ not a real dll');
|
|
554
|
+
const clean = raw.check();
|
|
555
|
+
const withPlant = new HcsHelper(() => path.join(planted2, 'HcsHost.exe'), undefined, t.dir('live-hcs-tmp2')).check();
|
|
556
|
+
t.check(withPlant.values.computecore === clean.values.computecore, 'a computecore.dll planted next to HcsHost.exe is ignored (it loads from System32 only)', JSON.stringify({ clean: clean.values, withPlant: withPlant.values }));
|
|
557
|
+
const systemFolders = [wsbShare({ ...req, path: 'C:\\ProgramData' }), wsbShare({ ...req, path: 'C:\\Users' })];
|
|
558
|
+
t.check(systemFolders.every((r) => !r.ok && /^Refused: /.test(r.message)), 'wsb-share refuses ProgramData and the users folder itself', systemFolders.map((r) => r.message).join(' | '));
|
|
559
|
+
// The shipped guest helper, inside a Windows Sandbox only. Nothing is mapped: a share that does not exist gives
|
|
560
|
+
// "not found" and creates nothing, so this sandbox's own folders are never touched (the full map of a real share
|
|
561
|
+
// is the manual scripts/experiments/hcs/Test-LiveGuest.ps1).
|
|
562
|
+
t.section('live folders: BindMount.exe in this sandbox (refusals and a missing share; nothing is mapped)');
|
|
563
|
+
if (!insideSandbox()) {
|
|
564
|
+
t.skip('not inside a Windows Sandbox');
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
const bindExe = t.at('live-bind', 'BindMount.exe');
|
|
568
|
+
fs.mkdirSync(path.dirname(bindExe), { recursive: true });
|
|
569
|
+
const built = spawnSync(SYSTEM.csc(), [
|
|
570
|
+
'/nologo',
|
|
571
|
+
'/optimize',
|
|
572
|
+
'/target:exe',
|
|
573
|
+
'/out:' + bindExe,
|
|
574
|
+
path.join(t.packageRoot, 'sandbox', 'csharp', 'BindMount.cs'),
|
|
575
|
+
], { windowsHide: true, timeout: 120000 });
|
|
576
|
+
t.check(built.status === 0 && fs.existsSync(bindExe), 'BindMount.cs compiles (C# 5)');
|
|
577
|
+
const bm = (...args) => spawnSync(bindExe, args, { windowsHide: true, timeout: 30000, encoding: 'utf8' }).status ?? -1;
|
|
578
|
+
const probe = 'C:\\Workspace\\live-selftest-' + crypto.randomBytes(4).toString('hex');
|
|
579
|
+
t.check(bm('map', probe, 'agentsbx-' + crypto.randomBytes(8).toString('hex')) === 3 && !fs.existsSync(probe), 'a share that is not there gives "not found" (3) and creates nothing');
|
|
580
|
+
t.check(bm('map', 'C:\\Workspace\\..', 'agentsbx-0123abcd') === 2 &&
|
|
581
|
+
bm('map', 'C:\\Workspace\\foo.', 'agentsbx-0123abcd') === 2 &&
|
|
582
|
+
bm('map', 'C:\\Projects\\x', 'agentsbx-0123abcd') === 2 &&
|
|
583
|
+
bm('map', probe, 'e64f927cbf5c723e4cb36b51bd44c97ee7b7c825ec4b348d457e98e1c6527835') === 2 &&
|
|
584
|
+
bm('map', probe, 'agentsbx-0123abcd', 'all') === 2 &&
|
|
585
|
+
bm('unmap', 'C:\\Projects\\AgentProjects') === 2 &&
|
|
586
|
+
bm('mapped', probe) === 4, "it refuses alias names, folders outside C:\\Workspace, Windows Sandbox's own share names and unknown options");
|
|
587
|
+
},
|
|
588
|
+
};
|
|
@@ -23,9 +23,9 @@ import { severalAgentsArgv } from '../cli/menu/index.js';
|
|
|
23
23
|
import { generateSandbox } from '../host/launch/index.js';
|
|
24
24
|
import { describeSessions } from '../cli/commands/projects/index.js';
|
|
25
25
|
/** The error `fn` throws, or null. */
|
|
26
|
-
const caught = (fn) => {
|
|
26
|
+
const caught = async (fn) => {
|
|
27
27
|
try {
|
|
28
|
-
fn();
|
|
28
|
+
await fn();
|
|
29
29
|
return null;
|
|
30
30
|
}
|
|
31
31
|
catch (e) {
|
|
@@ -126,10 +126,10 @@ export const projectsSuite = {
|
|
|
126
126
|
new ProjectRegistry(storage).here(path.join(f.workA, 'src'))?.guestDir === 'C:\\Workspace\\work-a\\src' &&
|
|
127
127
|
new ProjectRegistry(storage).here(outside) === null, 'at launch the registry holds the launch folder (primary) and the projects mapped with it; a folder inside a project finds it, one in no project does not');
|
|
128
128
|
const noWsb = projectMapper({ storageRoot: storage, packageRoot: f.install, wsbShare: true, detect: () => null });
|
|
129
|
-
const web = noWsb.register(inRoot);
|
|
129
|
+
const web = await noWsb.register(inRoot);
|
|
130
130
|
t.check(web.mechanism === 'root' &&
|
|
131
131
|
web.guest === 'C:\\Projects\\proj-root\\web' &&
|
|
132
|
-
noWsb.register(inRoot).name === web.name &&
|
|
132
|
+
(await noWsb.register(inRoot)).name === web.name &&
|
|
133
133
|
new ProjectRegistry(storage).read()?.projects.filter((p) => p.host === inRoot).length === 1, 'a folder inside a root joins at once, and only once');
|
|
134
134
|
t.section('projects: a registry another copy wrote, lost or left from an earlier sandbox');
|
|
135
135
|
const lostStorage = t.at('proj-lost');
|
|
@@ -169,9 +169,9 @@ export const projectsSuite = {
|
|
|
169
169
|
t.check(runningRegistry(lostStorage, lostData, base)?.roots.length === 1, "a registry left from an earlier sandbox is replaced by the running one's");
|
|
170
170
|
lostStatus('1700000000002-def456');
|
|
171
171
|
t.check(runningRegistry(lostStorage, lostData, base) === null, 'no launch folder for the running sandbox: no registry (never an earlier sandbox one)');
|
|
172
|
-
const needs = caught(() => noWsb.register(outside));
|
|
173
|
-
const shareOff = caught(() => projectMapper({ storageRoot: storage, packageRoot: f.install, wsbShare: false }).register(outside));
|
|
174
|
-
const listFails = caught(() => projectMapper({
|
|
172
|
+
const needs = await caught(() => noWsb.register(outside));
|
|
173
|
+
const shareOff = await caught(() => projectMapper({ storageRoot: storage, packageRoot: f.install, wsbShare: false }).register(outside));
|
|
174
|
+
const listFails = await caught(() => projectMapper({
|
|
175
175
|
storageRoot: storage,
|
|
176
176
|
packageRoot: f.install,
|
|
177
177
|
wsbShare: true,
|
|
@@ -46,6 +46,7 @@ export const LAUNCH_FILES_GOLDEN = [
|
|
|
46
46
|
'sandbox/boot/bootstrap/status.ps1',
|
|
47
47
|
'sandbox/boot/logon.cmd',
|
|
48
48
|
'sandbox/boot/trust.cmd',
|
|
49
|
+
'sandbox/csharp/BindMount.cs',
|
|
49
50
|
'sandbox/csharp/CargoShim.cs',
|
|
50
51
|
'sandbox/csharp/Desktop.cs',
|
|
51
52
|
'sandbox/csharp/EnvBroadcast.cs',
|
|
@@ -347,6 +347,7 @@ export declare function createFixtures(t: Harness): {
|
|
|
347
347
|
declinedRoots: string[];
|
|
348
348
|
allowOutsideMappings: boolean | "ask";
|
|
349
349
|
wsbShare: boolean;
|
|
350
|
+
liveFolders: "hcs" | "off";
|
|
350
351
|
};
|
|
351
352
|
resume: {
|
|
352
353
|
sessions: "never" | "ask" | "always" | "afterCrash";
|