windows-development-sandbox 1.14.0 → 1.16.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/accounts/accounts.def.js +4 -3
- package/dist/app/features/defs.d.ts +4 -2
- package/dist/app/features/defs.js +26 -12
- 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 +12 -6
- package/dist/app/features/sessions/sessions.def.js +1 -1
- package/dist/app/features/settings/categories.def.js +1 -0
- package/dist/bin/agent-sandbox.js +2 -2
- package/dist/bin/{chunk-F7IMME5G.js → chunk-7EXDUNE7.js} +98 -79
- package/dist/bin/{chunk-BQ23GBLC.js → chunk-HIC4CUMY.js} +1925 -1430
- package/dist/bin/{chunk-LQHEUN2A.js → chunk-PXTLKIV4.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/index.js +4 -1
- package/dist/cli/report.d.ts +7 -0
- package/dist/cli/report.js +31 -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/accounts.test.js +22 -0
- package/dist/selftest/app.test.js +2 -2
- 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 +2 -2
- package/dist/web/main-JTH3FT4O.js +221 -0
- package/dist/web/{main-UAQPI5XD.css → main-XT7T6ODM.css} +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
- package/dist/web/main-6XNBTJLT.js +0 -221
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
#Requires -Version 5.1
|
|
2
|
+
# Live folders (opt-in alpha, docs/design/hcs-live-folders.md): adds or removes one folder share on the running Windows
|
|
3
|
+
# Sandbox through HcsHost.exe wsb-share. Run elevated (host/engines/hcs/live-folders.ts starts it through
|
|
4
|
+
# run-elevated.ps1: one Windows permission prompt per add or remove). The request is a JSON file in a private temp
|
|
5
|
+
# folder: { action, id, name, path, readOnly, storageRoot, dataPath, userSid }; its SHA-256 is on the command line, and
|
|
6
|
+
# HcsHost hashes the exact bytes it parses again.
|
|
7
|
+
# Safety, all checked here again: the temp folder is the user's and not a link; HcsHost.exe is compiled here, from the
|
|
8
|
+
# sources next to this script (copied first), into a fresh folder under %ProgramData%\AgentSandboxLiveFolders that only
|
|
9
|
+
# administrators and SYSTEM may use (checked, and repaired when someone else made it, on every run), never taken from
|
|
10
|
+
# the caller; one add or remove at a time (a machine-wide lock). HcsHost does the rest (the caller must be the account
|
|
11
|
+
# that answered the prompt, which compute system, which folder, the canary). Work folders left by an earlier run (a
|
|
12
|
+
# canary the sandbox kept busy) are swept. -DryRun checks the request and prints what it would do, and does nothing
|
|
13
|
+
# else. The result goes to result.json next to the request file.
|
|
14
|
+
param(
|
|
15
|
+
[Parameter(Mandatory = $true)][string]$Json,
|
|
16
|
+
[string]$Sha256 = '',
|
|
17
|
+
[switch]$DryRun
|
|
18
|
+
)
|
|
19
|
+
$ErrorActionPreference = 'Stop'
|
|
20
|
+
Set-StrictMode -Version 2.0
|
|
21
|
+
. (Join-Path $PSScriptRoot 'contained-common.ps1')
|
|
22
|
+
|
|
23
|
+
$result = @{ ok = $false; message = ''; id = ''; canary = '' }
|
|
24
|
+
$AdminSids = @('S-1-5-18', 'S-1-5-32-544')
|
|
25
|
+
|
|
26
|
+
function Assert-LiveRequest($Req) {
|
|
27
|
+
foreach ($name in @('action', 'name', 'userSid')) {
|
|
28
|
+
if (-not ($Req.PSObject.Properties.Name -contains $name) -or -not $Req.$name) { throw "The request has no $name." }
|
|
29
|
+
}
|
|
30
|
+
if (@('add', 'remove') -notcontains [string]$Req.action) { throw 'The action must be add or remove.' }
|
|
31
|
+
if ([string]$Req.name -cnotmatch '^agentsbx-[0-9a-f]{8,32}$') { throw "Not one of this tool's share names: $($Req.name)" }
|
|
32
|
+
if ([string]$Req.userSid -notmatch '^S-1-[0-9-]{3,120}$') { throw 'The request has no valid user.' }
|
|
33
|
+
if ($Req.PSObject.Properties.Name -contains 'id' -and $Req.id -and ([string]$Req.id -notmatch '^[0-9A-Fa-f]{8}-([0-9A-Fa-f]{4}-){3}[0-9A-Fa-f]{12}$')) {
|
|
34
|
+
throw 'The sandbox id must be a GUID.'
|
|
35
|
+
}
|
|
36
|
+
if ($Req.action -eq 'remove' -and -not $Req.id) { throw 'A remove needs the id of the sandbox the folder was added to.' }
|
|
37
|
+
if ($Req.action -eq 'add') {
|
|
38
|
+
if (-not ($Req.PSObject.Properties.Name -contains 'path') -or -not (Test-PlainAbsolute $Req.path)) { throw 'The request has no plain full path to share.' }
|
|
39
|
+
if (-not ($Req.PSObject.Properties.Name -contains 'readOnly') -or -not ($Req.readOnly -is [bool])) { throw 'readOnly must be true or false.' }
|
|
40
|
+
foreach ($name in @('storageRoot', 'dataPath')) {
|
|
41
|
+
if (-not ($Req.PSObject.Properties.Name -contains $name) -or -not (Test-PlainAbsolute $Req.$name)) { throw "The request has no plain full $name." }
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
# The request's own folder: a private temp folder of the user who asked, not a link.
|
|
47
|
+
function Assert-PrivateTemp($Req, [string]$Dir) {
|
|
48
|
+
if (Test-ReparsePoint $Dir) { throw "Refusing a link: $Dir" }
|
|
49
|
+
$acl = Get-Acl -LiteralPath $Dir
|
|
50
|
+
$owner = $acl.GetOwner([Security.Principal.SecurityIdentifier]).Value
|
|
51
|
+
if (@($Req.userSid, 'S-1-5-32-544') -notcontains $owner) { throw "The request folder is not yours (owner $owner)." }
|
|
52
|
+
foreach ($rule in $acl.GetAccessRules($true, $true, [Security.Principal.SecurityIdentifier])) {
|
|
53
|
+
if ($rule.AccessControlType -ne 'Allow') { continue }
|
|
54
|
+
if (@('S-1-1-0', 'S-1-5-11', 'S-1-5-32-545') -contains $rule.IdentityReference.Value) { throw 'The request folder is open to other users.' }
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function New-AdminSecurity {
|
|
59
|
+
$acl = New-Object Security.AccessControl.DirectorySecurity
|
|
60
|
+
$acl.SetOwner((New-Object Security.Principal.SecurityIdentifier('S-1-5-32-544')))
|
|
61
|
+
$acl.SetAccessRuleProtection($true, $false)
|
|
62
|
+
$inherit = [Security.AccessControl.InheritanceFlags]'ContainerInherit, ObjectInherit'
|
|
63
|
+
foreach ($sid in $AdminSids) {
|
|
64
|
+
$who = New-Object Security.Principal.SecurityIdentifier($sid)
|
|
65
|
+
$acl.AddAccessRule((New-Object Security.AccessControl.FileSystemAccessRule($who, 'FullControl', $inherit, 'None', 'Allow')))
|
|
66
|
+
}
|
|
67
|
+
return $acl
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function Test-AdminOnly([string]$Path) {
|
|
71
|
+
$acl = Get-Acl -LiteralPath $Path
|
|
72
|
+
if ($AdminSids -notcontains $acl.GetOwner([Security.Principal.SecurityIdentifier]).Value) { return $false }
|
|
73
|
+
if (-not $acl.AreAccessRulesProtected) { return $false }
|
|
74
|
+
foreach ($rule in $acl.GetAccessRules($true, $true, [Security.Principal.SecurityIdentifier])) {
|
|
75
|
+
if ($rule.AccessControlType -eq 'Allow' -and $AdminSids -notcontains $rule.IdentityReference.Value) { return $false }
|
|
76
|
+
}
|
|
77
|
+
return $true
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
# %ProgramData%\AgentSandboxLiveFolders: made with an administrator-only DACL. One that someone else made first (any
|
|
81
|
+
# user may create folders in ProgramData) is taken over: owner Administrators and the DACL written whole; its old
|
|
82
|
+
# content stays, unused (each run works in a new random folder made with its own DACL).
|
|
83
|
+
function Get-AdminParent {
|
|
84
|
+
$base = [Environment]::GetFolderPath('CommonApplicationData')
|
|
85
|
+
if (-not $base -or (Test-ReparsePoint $base)) { throw "ProgramData is missing or a link: $base" }
|
|
86
|
+
$parent = Join-Path $base 'AgentSandboxLiveFolders'
|
|
87
|
+
if (-not (Test-Path -LiteralPath $parent)) {
|
|
88
|
+
[void][IO.Directory]::CreateDirectory($parent, (New-AdminSecurity))
|
|
89
|
+
}
|
|
90
|
+
if (Test-ReparsePoint $parent) { throw "Refusing a link: $parent" }
|
|
91
|
+
if (-not (Test-AdminOnly $parent)) {
|
|
92
|
+
Add-Note "taking over $parent (someone else made it or may use it)"
|
|
93
|
+
Set-Acl -LiteralPath $parent -AclObject (New-AdminSecurity)
|
|
94
|
+
if ((Test-ReparsePoint $parent) -or -not (Test-AdminOnly $parent)) { throw "$parent could not be made administrator-only." }
|
|
95
|
+
}
|
|
96
|
+
# Work folders an earlier run could not delete (the sandbox kept its canary busy): gone after an hour.
|
|
97
|
+
foreach ($old in @(Get-ChildItem -LiteralPath $parent -Force -Directory -ErrorAction SilentlyContinue)) {
|
|
98
|
+
if ($old.Name -match '^[0-9a-f]{32}$' -and -not ($old.Attributes -band [IO.FileAttributes]::ReparsePoint) -and $old.CreationTimeUtc -lt [DateTime]::UtcNow.AddHours(-1)) {
|
|
99
|
+
Remove-Item -LiteralPath $old.FullName -Recurse -Force -ErrorAction SilentlyContinue
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return $parent
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
# HcsHost.exe built here from this package's sources (copied into the admin-only work folder first), with the
|
|
106
|
+
# compiler that ships with Windows: nothing the caller names is ever run elevated.
|
|
107
|
+
function Build-HcsHost([string]$Work) {
|
|
108
|
+
$sources = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\csharp\HcsHost'))
|
|
109
|
+
if (-not (Test-Path -LiteralPath $sources -PathType Container) -or (Test-ReparsePoint $sources)) { throw "The HcsHost sources are missing or a link: $sources" }
|
|
110
|
+
$copy = Join-Path $Work 'src'
|
|
111
|
+
[void][IO.Directory]::CreateDirectory($copy)
|
|
112
|
+
foreach ($file in @(Get-ChildItem -LiteralPath $sources -Filter '*.cs' -File)) {
|
|
113
|
+
if ($file.Attributes -band [IO.FileAttributes]::ReparsePoint) { throw "Refusing a link: $($file.FullName)" }
|
|
114
|
+
[IO.File]::Copy($file.FullName, (Join-Path $copy $file.Name), $false)
|
|
115
|
+
}
|
|
116
|
+
$csc = Join-Path ([Environment]::GetFolderPath('Windows')) 'Microsoft.NET\Framework64\v4.0.30319\csc.exe'
|
|
117
|
+
$exe = Join-Path $Work 'HcsHost.exe'
|
|
118
|
+
$files = @(Get-ChildItem -LiteralPath $copy -Filter '*.cs' -File | ForEach-Object { $_.FullName })
|
|
119
|
+
if ($files.Count -eq 0) { throw 'No HcsHost sources were found.' }
|
|
120
|
+
& $csc '/nologo' '/optimize' '/target:exe' '/r:System.Web.Extensions.dll' ('/out:' + $exe) @files | Out-Null
|
|
121
|
+
if (-not (Test-Path -LiteralPath $exe -PathType Leaf)) { throw 'HcsHost.exe could not be built (the .NET Framework compiler that ships with Windows is needed).' }
|
|
122
|
+
return $exe
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function Invoke-Live($Req, [string]$JsonPath, [string]$Checksum) {
|
|
126
|
+
$parent = Get-AdminParent
|
|
127
|
+
$work = Join-Path $parent ([Guid]::NewGuid().ToString('N'))
|
|
128
|
+
if (Test-Path -LiteralPath $work) { throw "The work folder exists already: $work" }
|
|
129
|
+
[void][IO.Directory]::CreateDirectory($work, (New-AdminSecurity))
|
|
130
|
+
try {
|
|
131
|
+
$exe = Build-HcsHost $work
|
|
132
|
+
$out = Join-Path $work 'reply.json'
|
|
133
|
+
& $exe 'wsb-share' $JsonPath '--sha256' $Checksum '--out' $out | Out-Null
|
|
134
|
+
if (-not (Test-Path -LiteralPath $out)) { throw 'HcsHost.exe gave no answer.' }
|
|
135
|
+
$bytes = [IO.File]::ReadAllBytes($out)
|
|
136
|
+
if ($bytes.Length -gt 65536) { throw 'HcsHost.exe answered with too much text.' }
|
|
137
|
+
$reply = [Text.Encoding]::UTF8.GetString($bytes) | ConvertFrom-Json
|
|
138
|
+
$script:result.ok = [bool]$reply.ok
|
|
139
|
+
$script:result.message = [string]$reply.message
|
|
140
|
+
$names = @($reply.values.PSObject.Properties | ForEach-Object { $_.Name })
|
|
141
|
+
if ($names -contains 'id') { $script:result.id = [string]$reply.values.id }
|
|
142
|
+
if ($names -contains 'canary') { $script:result.canary = [string]$reply.values.canary }
|
|
143
|
+
} finally {
|
|
144
|
+
if (-not (Test-ReparsePoint $work)) { Remove-Item -LiteralPath $work -Recurse -Force -ErrorAction SilentlyContinue }
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
# result.json in the caller's folder: checked for a link right before, and only made as a new file.
|
|
149
|
+
function Write-LiveResult([hashtable]$Result, [string]$JsonPath) {
|
|
150
|
+
$Result.notes = @(Get-Notes)
|
|
151
|
+
$dir = Split-Path -Parent $JsonPath
|
|
152
|
+
if (Test-ReparsePoint $dir) { return }
|
|
153
|
+
$bytes = (New-Object Text.UTF8Encoding($false)).GetBytes(($Result | ConvertTo-Json -Compress))
|
|
154
|
+
$stream = New-Object IO.FileStream((Join-Path $dir 'result.json'), [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None)
|
|
155
|
+
try { $stream.Write($bytes, 0, $bytes.Length) } finally { $stream.Dispose() }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
$mutex = $null
|
|
159
|
+
$owned = $false
|
|
160
|
+
try {
|
|
161
|
+
$request = Read-ContainedRequest -JsonPath $Json -Sha256 $Sha256 -Dry $DryRun.IsPresent
|
|
162
|
+
Assert-LiveRequest $request
|
|
163
|
+
if ($DryRun) {
|
|
164
|
+
Add-Note ("would {0} the share {1}{2} on the running Windows Sandbox{3}" -f $request.action, $request.name, $(if ($request.action -eq 'add') { ' (' + $request.path + ', ' + $(if ($request.readOnly) { 'read-only' } else { 'writable' }) + ')' } else { '' }), $(if ($request.id) { ' ' + $request.id } else { '' }))
|
|
165
|
+
$result.ok = $true
|
|
166
|
+
$result.message = 'checked'
|
|
167
|
+
} else {
|
|
168
|
+
if (-not (Test-IsAdministrator)) { throw 'Adding a folder to the running sandbox needs administrator rights.' }
|
|
169
|
+
Assert-PrivateTemp $request (Split-Path -Parent $Json)
|
|
170
|
+
$mutex = New-Object System.Threading.Mutex($false, 'Global\AgentSandboxLiveFolders')
|
|
171
|
+
try { $owned = $mutex.WaitOne(120000) } catch [System.Threading.AbandonedMutexException] { $owned = $true }
|
|
172
|
+
if (-not $owned) { throw 'Another live folder is being added or removed (maybe by another user of this PC). Try again in a few minutes.' }
|
|
173
|
+
Invoke-Live $request $Json $Sha256
|
|
174
|
+
}
|
|
175
|
+
} catch {
|
|
176
|
+
$result.ok = $false
|
|
177
|
+
$result.message = $_.Exception.Message
|
|
178
|
+
} finally {
|
|
179
|
+
if ($mutex) {
|
|
180
|
+
if ($owned) { $mutex.ReleaseMutex() }
|
|
181
|
+
$mutex.Dispose()
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if ($DryRun) {
|
|
185
|
+
Write-ContainedResult $result $Json $true
|
|
186
|
+
} else {
|
|
187
|
+
try { Write-LiveResult $result $Json } catch { [Console]::Error.WriteLine('The result could not be written: ' + $_.Exception.Message) }
|
|
188
|
+
}
|
|
189
|
+
if ($result.ok) { exit 0 }
|
|
190
|
+
if ($DryRun) { [Console]::Error.WriteLine($result.message) }
|
|
191
|
+
exit 1
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "windows-development-sandbox",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.16.0",
|
|
4
4
|
"description": "A disposable Windows Sandbox for development, with or without an AI agent (Claude Code, Codex, Gemini, opencode, ...): the folder you run it from is the workspace, toolchains and databases for your project install themselves, and nothing else on your PC is reachable.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// Live folders, guest side (docs/design/hcs-live-folders.md): shows a vSMB share the host added to this running sandbox
|
|
2
|
+
// at C:\Workspace\<name> with the bind filter, the way Windows Sandbox's own guest agent shows .wsb mapped folders, so
|
|
3
|
+
// tools see a plain folder (not a link). Run by the session daemon (an administrator) by path; compiled inside the
|
|
4
|
+
// sandbox with the .NET Framework csc.exe (C# 5). Verbs (exit 0 = done, 1 = failed, 2 = refused, 3 = no such share yet):
|
|
5
|
+
// BindMount map <C:\Workspace\name> <agentsbx-share> [rw] the folder shows the share (read-only unless rw)
|
|
6
|
+
// BindMount unmap <C:\Workspace\name> removes that mapping again
|
|
7
|
+
// BindMount mapped <C:\Workspace\name> exit 0 when the bind filter has a mapping there, else 4
|
|
8
|
+
// Only share names with this tool's prefix and only C:\Workspace\<plain name> (no dots or spaces, so no "..", "foo."
|
|
9
|
+
// or "foo " aliases of other folders), so it can never re-point a folder Windows Sandbox mapped. Flag names and the
|
|
10
|
+
// BfGetMappings layout: go-winio pkg/bindfilter (vendored in microsoft/hcsshim) and BuildXL's NativeContainerUtilities.
|
|
11
|
+
using System;
|
|
12
|
+
using System.IO;
|
|
13
|
+
using System.Runtime.InteropServices;
|
|
14
|
+
using System.Text;
|
|
15
|
+
using System.Text.RegularExpressions;
|
|
16
|
+
|
|
17
|
+
static class BindMount
|
|
18
|
+
{
|
|
19
|
+
private const string VsmbRoot = "\\\\?\\GLOBALROOT\\Device\\vmsmb\\VSMB-{dcc079ae-60ba-4d07-847c-3493609c0870}\\";
|
|
20
|
+
private const uint ReadOnlyMapping = 0x1;
|
|
21
|
+
private const uint NoMultipleTargets = 0x40;
|
|
22
|
+
private const uint GetMappingsVolume = 0x1;
|
|
23
|
+
/// <summary>
|
|
24
|
+
/// A share that has not arrived: ERROR_NOT_FOUND (seen in a Windows Sandbox, 2026-10-23, with C:\Workspace there) or
|
|
25
|
+
/// ERROR_FILE_NOT_FOUND (what the design note recorded; also the answer when C:\Workspace itself is missing).
|
|
26
|
+
/// </summary>
|
|
27
|
+
private const int NotFound = unchecked((int)0x80070490);
|
|
28
|
+
private const int FileNotFound = unchecked((int)0x80070002);
|
|
29
|
+
private const int ExitOk = 0;
|
|
30
|
+
private const int ExitFailed = 1;
|
|
31
|
+
private const int ExitRefused = 2;
|
|
32
|
+
private const int ExitNoShare = 3;
|
|
33
|
+
private const int ExitNotMapped = 4;
|
|
34
|
+
private static readonly Regex ShareName = new Regex("^agentsbx-[0-9a-f]{8,32}$");
|
|
35
|
+
private static readonly Regex GuestFolder = new Regex("^C:\\\\Workspace\\\\[a-z0-9][a-z0-9_-]{0,63}$", RegexOptions.IgnoreCase);
|
|
36
|
+
|
|
37
|
+
private static class NativeMethods
|
|
38
|
+
{
|
|
39
|
+
[DllImport("bindfltapi.dll", CharSet = CharSet.Unicode, ExactSpelling = true)]
|
|
40
|
+
public static extern int BfSetupFilter(IntPtr job, uint flags, string virtRoot, string target, IntPtr exceptions, uint exceptionCount);
|
|
41
|
+
|
|
42
|
+
[DllImport("bindfltapi.dll", CharSet = CharSet.Unicode, ExactSpelling = true)]
|
|
43
|
+
public static extern int BfRemoveMapping(IntPtr job, string virtRoot);
|
|
44
|
+
|
|
45
|
+
[DllImport("bindfltapi.dll", CharSet = CharSet.Unicode, ExactSpelling = true)]
|
|
46
|
+
public static extern int BfGetMappings(uint flags, IntPtr job, string virtRoot, IntPtr sid, ref uint bufferSize, byte[] buffer);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
static int Main(string[] args)
|
|
50
|
+
{
|
|
51
|
+
if ((args.Length == 3 || args.Length == 4) && args[0] == "map")
|
|
52
|
+
{
|
|
53
|
+
if (!GuestFolder.IsMatch(args[1]) || !ShareName.IsMatch(args[2]) || (args.Length == 4 && args[3] != "rw"))
|
|
54
|
+
{
|
|
55
|
+
Console.WriteLine("Refused: map C:\\Workspace\\<plain name> to an agentsbx- share only");
|
|
56
|
+
return ExitRefused;
|
|
57
|
+
}
|
|
58
|
+
if (Directory.Exists(args[1]) || File.Exists(args[1]))
|
|
59
|
+
{
|
|
60
|
+
Console.WriteLine("Refused: " + args[1] + " exists already");
|
|
61
|
+
return ExitRefused;
|
|
62
|
+
}
|
|
63
|
+
uint flags = NoMultipleTargets | (args.Length == 4 ? 0u : ReadOnlyMapping);
|
|
64
|
+
int hr = NativeMethods.BfSetupFilter(IntPtr.Zero, flags, args[1], VsmbRoot + args[2] + "\\", IntPtr.Zero, 0);
|
|
65
|
+
Console.WriteLine("BfSetupFilter 0x" + hr.ToString("X8"));
|
|
66
|
+
return hr == 0 ? ExitOk : (hr == NotFound || hr == FileNotFound ? ExitNoShare : ExitFailed);
|
|
67
|
+
}
|
|
68
|
+
if (args.Length == 2 && (args[0] == "unmap" || args[0] == "mapped"))
|
|
69
|
+
{
|
|
70
|
+
if (!GuestFolder.IsMatch(args[1]))
|
|
71
|
+
{
|
|
72
|
+
Console.WriteLine("Refused: only C:\\Workspace\\<plain name>");
|
|
73
|
+
return ExitRefused;
|
|
74
|
+
}
|
|
75
|
+
if (args[0] == "mapped")
|
|
76
|
+
{
|
|
77
|
+
bool found;
|
|
78
|
+
int listed = HasMapping(args[1], out found);
|
|
79
|
+
Console.WriteLine("BfGetMappings 0x" + listed.ToString("X8") + (found ? " mapped" : " not mapped"));
|
|
80
|
+
return listed != 0 ? ExitFailed : (found ? ExitOk : ExitNotMapped);
|
|
81
|
+
}
|
|
82
|
+
int hr = NativeMethods.BfRemoveMapping(IntPtr.Zero, args[1]);
|
|
83
|
+
Console.WriteLine("BfRemoveMapping 0x" + hr.ToString("X8"));
|
|
84
|
+
return hr == 0 ? ExitOk : ExitFailed;
|
|
85
|
+
}
|
|
86
|
+
Console.WriteLine("Usage: BindMount map <folder> <share> [rw] | unmap <folder> | mapped <folder>");
|
|
87
|
+
return ExitRefused;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/// <summary>Whether the bind filter has a mapping whose virtual root is `folder` (BfGetMappings on C:).</summary>
|
|
91
|
+
private static int HasMapping(string folder, out bool found)
|
|
92
|
+
{
|
|
93
|
+
found = false;
|
|
94
|
+
uint size = 1024 * 1024;
|
|
95
|
+
byte[] buffer = new byte[size];
|
|
96
|
+
int hr = NativeMethods.BfGetMappings(GetMappingsVolume, IntPtr.Zero, "C:\\", IntPtr.Zero, ref size, buffer);
|
|
97
|
+
if (hr != 0 || size < 12)
|
|
98
|
+
{
|
|
99
|
+
return hr != 0 ? hr : ExitFailed;
|
|
100
|
+
}
|
|
101
|
+
uint count = BitConverter.ToUInt32(buffer, 8);
|
|
102
|
+
string wanted = "\\" + folder.Substring(3).TrimEnd('\\');
|
|
103
|
+
for (int i = 0; i < count && 12 + (i * 20) + 20 <= buffer.Length; i++)
|
|
104
|
+
{
|
|
105
|
+
int entry = 12 + (i * 20);
|
|
106
|
+
uint length = BitConverter.ToUInt32(buffer, entry);
|
|
107
|
+
uint offset = BitConverter.ToUInt32(buffer, entry + 4);
|
|
108
|
+
if (offset + length > buffer.Length)
|
|
109
|
+
{
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
string root = Encoding.Unicode.GetString(buffer, (int)offset, (int)length).TrimEnd('\\');
|
|
113
|
+
if (root.EndsWith(wanted, StringComparison.OrdinalIgnoreCase))
|
|
114
|
+
{
|
|
115
|
+
found = true;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return 0;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -1801,6 +1801,14 @@
|
|
|
1801
1801
|
"wsbShare": {
|
|
1802
1802
|
"type": "boolean",
|
|
1803
1803
|
"description": "On Windows 11 24H2+ with the Windows Sandbox command line (wsb), add other folders to the running sandbox with `wsb share` (untested). Otherwise starting a project outside every root offers a restart."
|
|
1804
|
+
},
|
|
1805
|
+
"liveFolders": {
|
|
1806
|
+
"type": "string",
|
|
1807
|
+
"enum": [
|
|
1808
|
+
"off",
|
|
1809
|
+
"hcs"
|
|
1810
|
+
],
|
|
1811
|
+
"description": "ALPHA, UNTESTED ON A REAL PC: \"hcs\" adds a project folder to the running sandbox through the Host Compute Service where `wsb share` is missing (one administrator prompt per add or remove; always read-only in this alpha). If it fails, the sandbox may close: save your work first. Turn it on before the sandbox starts. \"off\" (default) offers a restart instead. The real-PC tests still to pass are in docs/design/hcs-live-folders.md (\"Test plan\")."
|
|
1804
1812
|
}
|
|
1805
1813
|
},
|
|
1806
1814
|
"additionalProperties": false,
|