Docker sbx mixin kits v3 and how they work with Business Central, Azure DevOps and VS Code tunnels
In my recent blog posts (1, 2, 3 and 4), I have shown how Docker Sandboxes and Kits can be used to securely let an agent work on Business Central development with the AL MCP Server and the Azure DevOps MCP Server. In the latest one, I also showed how to connect to a Sandbox from VS Code via SSH. Since then, Docker has been working on version 3 of the Sandbox Kit specification, and I took that as an opportunity to rework my existing AL and Azure DevOps mixin kits and add three new ones for the VS Code CLI, VS Code tunnels and Business Central Basic authentication. All five are available in my fork of the sbx-kits-contrib repo and with them, it is now very quick and easy to get a Sandbox with everything you need for AL development, Azure DevOps access and a VS Code tunnel that you can immediately connect to.
The TL;DR
First, you need two secrets on the host: A personal access token (PAT) for Azure DevOps and a GitHub token for the VS Code tunnel1. You only need to do this once:
1
2
echo "$([System.Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes(":<your-PAT>")))" | sbx secret set -g az-devops
sbx secret set -g vscode-tunnel -t "$(gh auth token)"
Then you create the Sandbox with all four mixin kits on top of GitHub Copilot (or any other agent you like):
1
2
3
4
5
6
sbx run `
--kit "git+https://github.com/tfenster/sbx-kits-contrib.git#dir=al" `
--kit "git+https://github.com/tfenster/sbx-kits-contrib.git#dir=az-devops" `
--kit "git+https://github.com/tfenster/sbx-kits-contrib.git#dir=vscode-cli" `
--kit "git+https://github.com/tfenster/sbx-kits-contrib.git#dir=vscode-tunnel" `
copilot .
That’s it. While Copilot starts in your terminal, the Sandbox also starts a VS Code tunnel in the background, named after the Sandbox hostname. Open https://vscode.dev/tunnel/<name> in a browser, or use Remote-Tunnels: Connect to Tunnel… in your local VS Code, signed in with the same GitHub account, and you are working inside of the Sandbox. No device code login, no license prompt, no SSH setup and no port to publish. Inside, al is ready to compile your extension or to run as MCP server, and the Azure DevOps MCP Server can talk to your organization while your PAT never enters the Sandbox. Pretty neat, right?
One distinction is important: The generic AL kit installs the tools, but doesn’t configure or authenticate your Business Central connection. If you want the AL MCP Server to download symbols or publish to a backend using Basic authentication without seeing its credentials, add the generic bc-basic-auth mixin with your backend’s hostname as parameter and the host secret shown in the AL authentication section below, and start the MCP Server with --noAuth. Bringing it all together looks like this:
Note that Kit v3 is still experimental and things might change. If you want to know what happens behind the scenes, read on.
The details: Kit v3 in a nutshell
Before going into the individual kits, a few words about what changed in general with v3, because that explains most of the benefits you will see below:
- A kit is now one OCI image: The declarations (the “descriptor”) are stored in a manifest annotation and the content is stored in the image layers. A mixin’s layers are an overlay that lands on the filesystem of the agent’s image. This means that the content can be built at publish time with a regular Dockerfile instead of being downloaded and installed every time you create a Sandbox.
- Everything the kit needs from the host is a typed capability in one
capabilitieslist: network policy, credentials, lifecycle hooks, agent context and so on. In v1/v2, those were spread overnetwork,credentials,environmentandcommands. - The network policy is phased: There are separate allow lists for
install(at Sandbox creation) andruntime. If a kit doesn’t need network access during creation, it simply doesn’t declare aninstallphase and gets nothing. - Kits can declare
providesandrequires, so one kit can build on top of another and the runtime makes sure that the combination makes sense. - Kits can have
argswith defaults and validation patterns, e.g. for a version to install or a name to use.
You can find all details in the v3 spec, but that should be enough to follow along.
The details: The AL mixin
If you remember the first blog post of this series, the AL kit installed .NET and the ALTool every time a Sandbox was created:
1
2
3
4
5
commands:
install:
- command: "curl -fsSL https://dot.net/v1/dotnet-install.sh | bash -s -- --channel 8.0 && /home/agent/.dotnet/dotnet tool install --global Microsoft.Dynamics.BusinessCentral.Development.Tools"
user: "1000"
description: Install altool
That worked, but it meant a download of a few hundred megabytes on every create, network access to dot.net, builds.dotnet.microsoft.com and nuget.org during that time and a failure in your Sandbox if anything went wrong. With v3, the kit looks like this (comments removed for brevity):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# syntax=docker/sandbox-kit:3
schemaVersion: "3"
kind: mixin
displayName: AL (Business Central)
description: >-
altool, the AL command-line compiler and MCP server for Microsoft Dynamics 365
Business Central, with the .NET 8 and 10 runtimes it runs on — so the agent can
compile, package and inspect AL extensions in the sandbox.
version: "${{ kit.args.version }}"
args:
version:
default: "18.1.43.24897"
pattern: '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$'
description: altool (Microsoft.Dynamics.BusinessCentral.Development.Tools) package version to install
buildArg: ALTOOL_VERSION
provides: ["al@${{ kit.args.version }}"]
capabilities:
- type: com.docker.sandbox/network-policy@1
config:
runtime:
allow:
- api.nuget.org
- globalcdn.nuget.org
- type: com.docker.sandbox/agent-context@1
config:
contentFile: ./al-context.md
The most important change is what you don’t see: There are no install commands at all. Instead, a companion al.dockerfile installs the .NET 8 and .NET 10 runtimes (for the AL extension in VS Code and the ALTool) and the ALTool into /opt/al at build time and only that ends up in the overlay. The SDK needed for dotnet tool install stays in the build stage. The Dockerfile also has a “gate” at the end, which checks that the installed version matches the requested one, that both runtimes are there and that al --version actually runs. So if something is broken, the kit build fails and not your Sandbox.
This leads to a couple of benefits:
- Faster and more stable creation: Nothing is downloaded when you create the Sandbox, it’s just there.
- Less network access: As you can see in lines 20-26, the network policy only has a
runtimephase with nuget.org, which the ALTool needs to restore symbol packages. All the .NET download URLs are gone because they are only reached by the build, not by the Sandbox. - A pinned and visible version: The
versionarg in lines 11-16 is the single source for the installed ALTool version, theal@<version>the kitprovides(line 18) and the tag it is published under. If you want to try one of Microsoft’s-betareleases, you can override the arg, the pattern in line 14 allows for that. - Agent context: Lines 28-30 add a short markdown file to the context of whatever agent you are using, telling it that
alis available and how to compile a project or start the MCP Server. So the agent doesn’t have to figure that out by itself.
Another small but nice improvement is in how al is started. The Dockerfile creates a little launcher script at /usr/local/bin/al:
1
2
3
4
5
#!/bin/sh
# Installed by the `al` sandbox kit: altool on the .NET runtime in /opt/al.
DOTNET_ROOT=/opt/al/dotnet
export DOTNET_ROOT
exec /opt/al/tools/al "$@"
Because the launcher sets DOTNET_ROOT for itself, al works from any process, including an MCP Server spawned directly by the agent, which doesn’t go through a login shell. That means the MCP configuration becomes simpler, as you no longer need the path into the agent’s home folder or an env block:
1
2
3
4
5
6
7
8
{
"mcpServers": {
"al": {
"command": "/usr/local/bin/al",
"args": ["launchmcpserver", "--transport", "stdio", "."]
}
}
}
Authentication: Connecting the AL MCP Server to Business Central
One thing I deliberately removed from the generic AL kit is the connection to my Business Central backend on COSMO Alpaca, including the credential injection I showed in the second blog post. That is specific to my environment and doesn’t belong in a generic kit. But of course, installing the ALTool is only half of the story if you want the agent to download symbols or publish an extension. So how do we put authentication back without putting the credentials into the Sandbox?
For my HTTPS backend using NavUserPassword authentication, the hostname is the environment-specific setting in the mixin, while the credentials stay in the host’s secret store. And that is where another v3 feature comes in handy: args don’t have to be resolved at build time like the ALTool version above. An arg without buildArg is a create-time arg, which is expanded when the Sandbox is created, across the whole descriptor, including the capability configs. So we can put the hostname into an arg and get a generic mixin for Business Central backends using Basic authentication. I have added it to the bc-basic-auth folder in my fork, so you can reference it directly without creating a local kit. Its descriptor looks like this:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# syntax=docker/sandbox-kit:3
schemaVersion: "3"
kind: mixin
version: "1.0.0"
displayName: Business Central authentication
description: >-
Allows access to a Business Central backend and lets the sandbox proxy inject
Basic auth credentials for it. The credentials stay on the host.
args:
host:
required: true
pattern: '^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$'
description: Hostname of the Business Central backend, e.g. bc.example.com
capabilities:
- type: com.docker.sandbox/network-policy@1
config:
runtime:
allow:
- "${{ kit.args.host }}"
- type: com.docker.sandbox/credential@1
description: Business Central username and password, stored on the host as base64 of `user:password`; the proxy injects it as Basic auth on requests to the backend.
config:
service: bc
phase: runtime
apiKey:
inject:
- domain: "${{ kit.args.host }}"
header: Authorization
format: "Basic %s"
Lines 10-14 declare the host arg. It is required, so you can’t create a Sandbox with this mixin and forget the hostname. The pattern in line 13 only accepts a plain hostname, which is a deliberate security decision: It rejects wildcards like *.cloudapp.azure.com, so nobody can accidentally configure the proxy to send the BC credentials to a whole range of hosts. The arg is then used in two places, the allow list in line 21 and the injection rule in line 30, which has to match the allow list literally anyway. As in the Azure DevOps kit, the credential is inject-only, so nothing about it is visible inside the Sandbox.
Note that the network policy and the credential capability are two separate things: A rule like sbx policy allow network --sandbox <name> "<your-bc-host>" only enables network access, it does not configure authentication. That is why the mixin declares both.
I also thought about putting this directly into the AL kit with an optional host arg, but an empty value would result in an empty allow list entry and injection domain, which isn’t valid. And not everybody using the AL kit has a backend with Basic auth, so a separate mixin that you only add when you need it seemed like the cleaner option. One of the things I still need to figure out is OAuth, which would be handy for BC as well.
On the host, store the base64-encoded username:password as the bc secret, just as in the earlier post. The values below are placeholders, not real credentials:
1
echo "$([System.Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes("<your-bc-user>:<your-bc-password>")))" | sbx secret set -g bc
Then add the mixin to the sbx run command from the TL;DR and set the hostname with --kit-arg. The bc-basic-auth. prefix is the handle of the kit, which for a Git reference is the selected subdirectory name. It makes sure that the arg only goes to this kit:
1
2
3
4
5
6
7
8
sbx run `
--kit "git+https://github.com/tfenster/sbx-kits-contrib.git#dir=al" `
--kit "git+https://github.com/tfenster/sbx-kits-contrib.git#dir=az-devops" `
--kit "git+https://github.com/tfenster/sbx-kits-contrib.git#dir=vscode-cli" `
--kit "git+https://github.com/tfenster/sbx-kits-contrib.git#dir=vscode-tunnel" `
--kit "git+https://github.com/tfenster/sbx-kits-contrib.git#dir=bc-basic-auth" `
--kit-arg bc-basic-auth.host=fps-alpaca.westeurope.cloudapp.azure.com `
copilot .
According to the v3 spec, the permission gate judges the effective descriptor, i.e. the one with the arg values expanded. So the hostname you pass becomes part of the Sandbox’s permission surface just like a hard-coded one, and a different hostname counts as a widening of the permissions rather than slipping through unnoticed. The project still needs its Business Central connection settings, for example in launch.json; this mixin only supplies network access and authentication.
Finally, change the AL MCP configuration above to include --noAuth, as explained in the third blog post:
1
2
3
4
5
6
7
8
{
"mcpServers": {
"al": {
"command": "/usr/local/bin/al",
"args": ["launchmcpserver", "--transport", "stdio", "--noAuth", "."]
}
}
}
Despite the name, --noAuth doesn’t turn off authentication on the Business Central server. It tells the AL MCP Server not to handle authentication itself or ask the agent for credentials, because the Sandbox proxy supplies the header instead. The actual secret stays on the host; no username/password environment variables or credentials in the MCP configuration are needed inside the Sandbox.
This example is specifically for a backend accepting Basic authentication, not Business Central online with Microsoft Entra ID. Don’t add --noAuth to an otherwise unconfigured connection and expect it to authenticate: Without matching proxy injection, you still need the authentication flow appropriate for your backend.
The details: The Azure DevOps mixin
For the Azure DevOps kit, the biggest change is how credential injection is declared. In v1/v2, it needed serviceDomains, serviceAuth, credentials and environment, as explained in the first blog post. Now all of that is one credential@1 capability:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
# syntax=docker/sandbox-kit:3
schemaVersion: "3"
kind: mixin
version: "1.0.0"
displayName: Azure DevOps MCP
description: >-
Wires an Azure DevOps personal access token into the sandbox proxy so the
Azure DevOps REST APIs and MCP endpoints authenticate from inside the
sandbox. The token stays on the host.
capabilities:
- type: com.docker.sandbox/network-policy@1
config:
runtime:
allow:
- dev.azure.com
- "*.dev.azure.com"
- mcp.dev.azure.com
- vssps.dev.azure.com
- "*.visualstudio.com"
- dc.services.visualstudio.com
- aka.ms
- registry.npmjs.org
- github.com
- release-assets.githubusercontent.com
- type: com.docker.sandbox/credential@1
description: Azure DevOps personal access token, stored on the host as base64 of `:<PAT>`; the proxy injects it as Basic auth on requests to Azure DevOps.
config:
service: az-devops
phase: runtime
apiKey:
inject:
- domain: dev.azure.com
header: Authorization
format: "Basic %s"
- domain: "*.dev.azure.com"
header: Authorization
format: "Basic %s"
- domain: mcp.dev.azure.com
header: Authorization
format: "Basic %s"
- domain: vssps.dev.azure.com
header: Authorization
format: "Basic %s"
- domain: "*.visualstudio.com"
header: Authorization
format: "Basic %s"
- type: com.docker.sandbox/lifecycle@1
config:
install:
- command: "mkdir -p /usr/local/share/npm-global/lib"
user: "0"
description: Create missing folder
Lines 27-48 define the az-devops service, the phase in which it is used and for which domains the proxy should inject which header. Note that the credential is now inject-only: There is no name for an environment variable, so not even a placeholder AZ_DEVOPS_PAT exists inside the Sandbox anymore. The wildcard *.visualstudio.com in line 46 also means that old organizations using <your-org>.visualstudio.com URLs work without any org-specific configuration, as I already mentioned in the third blog post. A small catch is that every domain used for injection has to appear literally in the allow list of the same phase, which is why you see e.g. mcp.dev.azure.com in line 18, although *.dev.azure.com in line 17 already covers it.
The second change you can’t see in the YAML: The Azure DevOps MCP Server uses keytar, a native Node.js addon that needs libsecret, which isn’t available in the Sandbox base images. That made the MCP Server fail with libsecret-1.so.0: cannot open shared object file. The companion az-devops.dockerfile now installs libsecret-1-0 at build time, stages exactly the packages that apt had to add on top of the base image and verifies with ldd that all needed libraries resolve. Same as for the AL kit, nothing is apt-installed when you create your Sandbox and you don’t need network access to the Ubuntu mirrors.
The workaround for the missing npm folder is still there, but now as a lifecycle@1 capability in lines 50-55, running as root with mkdir -p, so it doesn’t fail if the folder already exists.
The details: The VS Code CLI mixin
The first new kit is very simple: It puts the standalone VS Code CLI as code into the Sandbox. The CLI is a single static binary, so the vscode-cli.dockerfile downloads the musl build for the target architecture, runs code --version as a check and copies it to /usr/local/bin/code in the overlay. Again, nothing is downloaded at Sandbox creation. Most of the kit definition is the network policy:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
# syntax=docker/sandbox-kit:3
schemaVersion: "3"
kind: mixin
version: "1.0.0"
displayName: VS Code CLI
description: >-
Installs the standalone VS Code CLI (`code`), with the network access its
VS Code Server features such as `code tunnel` need.
sourceUrl: https://code.visualstudio.com/docs/editor/command-line
provides: ["vscode-cli"]
args:
version:
default: latest
pattern: '^(latest|[0-9]+\.[0-9]+\.[0-9]+)$'
description: VS Code CLI release to build into the overlay, or `latest`
buildArg: VSCODE_VERSION
capabilities:
- type: com.docker.sandbox/network-policy@1
config:
runtime:
allow:
- update.code.visualstudio.com
- vscode.download.prss.microsoft.com
- global.rel.tunnels.api.visualstudio.com
- "*.rel.tunnels.api.visualstudio.com"
- "**.devtunnels.ms"
- github.com
- api.github.com
- login.microsoftonline.com
- marketplace.visualstudio.com
- "*.gallery.vsassets.io"
- "*.gallerycdn.vsassets.io"
The allow list covers what the VS Code Server features of the CLI need: The download of the matching VS Code Server (lines 25/26), the dev tunnels management API and relay (lines 27-29), the GitHub and Microsoft logins (lines 30-32) and the extension marketplace (lines 33-35). VS Code telemetry endpoints are intentionally not on the list, so they stay blocked. Line 11 is where the kit provides vscode-cli, which becomes relevant for the next kit.
With only this kit, you can already user the CLI, e.g. to start a tunnel manually:
1
sbx exec -it <sandbox-name> -- code tunnel
The CLI then asks you to log in via device code, accept the VS Code Server license terms and name the tunnel. That works, but it is interactive and not what I promised in the TL;DR. Which brings us to the last kit.
The details: The VS Code tunnel mixin
The second new kit starts the tunnel automatically when the Sandbox boots and authenticates it through the credential proxy, so there is no prompt at all. It is a declaration-only mixin, meaning it has no Dockerfile and no content of its own. Instead, it builds on the VS Code CLI kit. Here is a shortened version (I removed most of the dev tunnels clusters, which are just repetitions):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
# syntax=docker/sandbox-kit:3
schemaVersion: "3"
kind: mixin
version: "1.0.0"
displayName: VS Code Tunnel
description: >-
Starts `code tunnel` at sandbox boot, authenticated with the host's GitHub
token through the sandbox proxy, so the sandbox opens from vscode.dev or a
local VS Code without a device code login.
sourceUrl: https://code.visualstudio.com/docs/remote/tunnels
requires: ["vscode-cli"]
args:
name:
default: ""
pattern: '^[a-z0-9-]{0,20}$'
description: Tunnel name; empty uses the sandbox hostname, cut to 20 characters
env: VSCODE_TUNNEL_NAME
capabilities:
- type: com.docker.sandbox/network-policy@1
config:
runtime:
allow:
- global.rel.tunnels.api.visualstudio.com
- euw.rel.tunnels.api.visualstudio.com
# ... one line per dev tunnels cluster ...
- "*.rel.tunnels.api.visualstudio.com"
- type: com.docker.sandbox/credential@1
description: GitHub token, stored on the host; the proxy injects it on dev tunnels management API requests.
config:
service: vscode-tunnel
phase: runtime
apiKey:
proxyManaged: true
inject:
- domain: global.rel.tunnels.api.visualstudio.com
header: Authorization
format: "github %s"
- domain: euw.rel.tunnels.api.visualstudio.com
header: Authorization
format: "github %s"
# ... one entry per dev tunnels cluster ...
- type: com.docker.sandbox/lifecycle@1
config:
startup:
- command: >-
set -eu;
log="$HOME/.vscode-tunnel.log";
name=$(printf '%s' "${VSCODE_TUNNEL_NAME:-$(hostname)}" | tr 'A-Z' 'a-z' | tr -c 'a-z0-9-' '-' | cut -c1-20 | sed 's/^-*//; s/-*$//');
i=0;
until curl -fsS -o /dev/null -H 'Authorization: token proxy-managed' https://api.github.com/user 2>>"$log"; do
i=$((i + 1));
if [ "$i" -ge 30 ]; then echo "vscode-tunnel: api.github.com/user still unauthenticated after 60s; not starting the tunnel" >>"$log"; exit 1; fi;
sleep 2;
done;
code tunnel user login --provider github --access-token proxy-managed >>"$log" 2>&1;
exec code tunnel --accept-server-license-terms --name "$name" >>"$log" 2>&1
user: "1000"
background: true
env: [VSCODE_TUNNEL_NAME]
description: Log in with the proxy sentinel and start the tunnel
- type: com.docker.sandbox/long-running@1
Let me walk you through it:
- Line 12
requiresthe VS Code CLI kit, whichprovidesit as shown above. So if you forget to add the CLI kit, you get a clear error instead of a failing startup command. - Lines 14-19 define an optional
namearg for the tunnel, which is handed to the startup command as environment variable. If you don’t set it, the Sandbox hostname is used. - Lines 47-65 are the startup hook, which runs in the
backgroundon every boot. It first waits untilapi.github.comanswers with authentication through the proxy, then logs in with the placeholder tokenproxy-managedand finally starts the tunnel, already accepting the license terms. As the CLI thinks it has a token, it never starts the device code login. Everything is logged to~/.vscode-tunnel.log, so if something doesn’t work,sbx exec <sandbox-name> -- cat /home/agent/.vscode-tunnel.logis your friend. - Lines 31-45 make sure that the placeholder is replaced by the real token on the host. The CLI usually sends
github client_id=<VS Code's app> <token>, which the dev tunnels service rejects for a token created by another app likegh. Without theclient_id, it accepts it, so the proxy simply replaces the whole header (line 41/44). - Line 67 declares
long-running@1: VS Code connects through the tunnel and not through ansbxsession, so without this, the Sandbox could automatically stop while you are still working in it.
A few things took a bit of trial and error, so I want to share them as well:
- The kit uses its own
vscode-tunnelservice and notgithub. In v3, there can only be one owner per credential service and phase and agent kits likecopilotalready claimgithub. With a separate service, the two don’t get in the way of each other. - The management hosts are listed one by one per cluster, both in the allow list and in the injection rules. A wildcard
*.rel.tunnels.api.visualstudio.comfor injection would also match the relay hosts (<cluster>-data.rel...) and their websocket carries its owntunnel <token>header, which fails with a 401 if the proxy overwrites it. The downside is that if Microsoft adds a new cluster, it needs a new entry. - There is deliberately no rule for
api.github.com, where the CLI checks the token before using it. The built-in GitHub injection ofsbxalready covers that and an explicit rule in the kit replaced it and left the check unauthenticated. This is also why thegithubsecret needs to be set and has to be for the same GitHub account as thevscode-tunnelsecret.
With that, the whole flow from the TL;DR works: Create the Sandbox, wait a few seconds and connect. And because the tunnel only connects outbound, all of that still happens with the network policy and credential injection of the Sandbox in place.
I will also create Pull Requests for all five mixin kits to contribute them to the official sbx-kits-contrib repository. The commands above currently point to my fork; once the PRs have been merged, those URLs can be changed to the corresponding directories in the official repository.
-
The tunnel also relies on the
githubsecret through the built-in GitHub injection ofsbx. If you are using Copilot in your Sandbox, you most likely have set that up already. ↩
Webmentions:
No webmentions were found.
No likes were found.
No reposts were found.
.png)