Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 44 additions & 12 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2373,6 +2373,7 @@ TLS under the deployment CA and `decideCredentials` rules on the request:
| The request carries | What happens |
|---|---|
| the placeholder, in a credential header | rewritten to carry the real credential |
| a value under one of the credential's pass-through schemes | forwarded as it stands |
| any other value in a credential header | 403 from the proxy; nothing reaches the host |
| nothing in a credential header | forwarded as it stands |

Expand All @@ -2389,13 +2390,24 @@ credential header, which covers `Bearer <p>`, `token <p>`, a bare value, and
the HTTP Basic pair git's credential helper produces — one mechanism instead of
a rule per tool.

A protocol upgrade on a translated host is refused with `501`, because the
swap cannot follow a request there: the engine rewrites the host, the path,
the query and the protocol of an upgrade, and no header, so a forwarded one
would carry the placeholder to the far end and be refused as a bad credential
after sending it. It is the same answer the front door gives an upgrade on a
host it is only tunnelling, so the proxy has one position on upgrades rather
than two.
A *pass-through scheme* is an authorization scheme under which a host accepts
tokens it issued itself. Dev Tunnels has one: the GitHub token buys a token
for one tunnel, and the CLI then sends `Authorization: tunnel <token>` to the
same hosts. That value is not the deployment's credential and cannot carry
it, so refusing it would protect nothing and break hosting. The scheme is
part of the credential set, so it is allowed at that credential's hosts only.

A protocol upgrade on a translated host goes through the same checks as a
request, with one difference: one that would need a swap is refused with
`403`, because the swap cannot follow a request there. The engine rewrites
the host, the path, the query and the protocol of an upgrade, and no header,
so a forwarded one would carry the placeholder to the far end and be refused
as a bad credential after sending it. An upgrade that carries no credential,
or a value under a pass-through scheme, is forwarded as it stands, which is
what lets the Dev Tunnels relay connect. Nothing forwarded unchanged can
carry the real credential, because only a swap puts it on the wire. A plain
`http://` upgrade at the front door is still refused with `501`, as the front
door cannot vet the protocol that follows.

Codex's built-in provider opens its transport with one, against
`wss://api.openai.com/v1/responses`, retries a few times, and only then falls
Expand Down Expand Up @@ -2426,6 +2438,12 @@ pair, or a bearer — and in `PRIVATE-TOKEN`, which is what glab sends a
personal access token in. A deployment that names its own instance intercepts
that host instead, and gitlab.com becomes an ordinary passthrough host.

The Dev Tunnels credential comes last in the set. It is translated on
`*.rel.tunnels.api.visualstudio.com`, which covers the global control plane,
the regional ones and the regional relays. Only a control plane ever gets the
GitHub token; while a tunnel is hosted, the CLI sends both kinds of host the
tunnel's own token, under the `tunnel` pass-through scheme.

`chatgpt.com` is in that set as a host to allow and never to intercept. It
carries the other kind of OpenAI credential — a subscription — and the two
kinds reject each other's material, so leaving it alone is what lets a
Expand All @@ -2435,7 +2453,19 @@ not delivered to a box at all: it is a document rather than a header value, and
the traffic it authenticates goes to that unintercepted host. Such a credential
is stored, refreshed and reported, and the harness that needs it says it cannot
run until a key is pasted. A Claude login is not that case: it ends in a token,
which is delivered exactly as a pasted one is.
which is delivered exactly as a pasted one is. Nor is a Dev Tunnels login,
whose document holds the access token and the refresh token side by side: the
access token is what the proxy swaps in.

The Dev Tunnels service takes only a token GitHub issued to its own GitHub
App, so a personal access token is refused and the card has no paste form.
The login is GitHub's device flow, which the orchestrator runs itself rather
than in a container, since there is no CLI output to read: it shows GitHub's
URL and code, polls until the person has entered the code, and stores the
answer. The access token lasts eight hours and the refresh token six months.
The refresh needs only the app's public client id, and it rotates both
tokens, so a login stays alive for as long as the orchestrator keeps
refreshing it.

The policy is composed from the store on every sync rather than once at boot,
and the store calls `sync()` on every write — so a credential entered on the
Expand Down Expand Up @@ -2522,7 +2552,7 @@ restart; the resolver that answers the request is in memory only, so
| Proxy reconciler (`reaper.ts`) | 60s | Re-asserts both halves of the proxy's state: its attachment to every running box's network, which `compose up` can drop by recreating the container, and the policy it holds, which a restart erases entirely. Both show up in `/healthz` |
| Maintenance | 60s, with the reaper | Prunes each box's debug log to its ring size, and forgets the upstream of a box that is down and holding nothing |
| Orphan sweep (`boxes.ts`) | 60s, with the reaper | Removes the containers, networks, volumes and workspace directories labelled with boxes that no longer exist. See below |
| Credential refresh (`reaper.ts`) | 60s | The one thing Boxes holds that goes stale on its own. A subscription login whose access token is within the hour of expiring, or which has simply sat for eight days, is refreshed against the provider's token endpoint and written back through the store, which pushes the new material to the proxy. A credential that cannot be renewed and has run out is marked expired instead, so the settings page says so rather than a turn failing with a 401 nobody sees |
| Credential refresh (`reaper.ts`) | 60s | The one thing Boxes holds that goes stale on its own. A subscription login or a Dev Tunnels login whose access token is within the hour of expiring, or a subscription login which has simply sat for eight days, is refreshed against the provider's token endpoint and written back through the store, which pushes the new material to the proxy. A credential that cannot be renewed and has run out is marked expired instead, so the settings page says so rather than a turn failing with a 401 nobody sees |

The list screen polls `GET /api/boxes` every 5 seconds while it is up and
its tab is visible. A view watching one box — its thread, its review, its info
Expand Down Expand Up @@ -2583,7 +2613,7 @@ the store's `onChange`, which recomposes the egress policy and pushes it.

What reaches a box container is a placeholder for each of them, built by
`credentialEnv` from every harness's `env()` plus `GH_TOKEN`, `GITLAB_TOKEN`,
`GITLAB_HOST`, `GIT_NAME` and `GIT_EMAIL`, and fixed into the container at
`GITLAB_HOST`, `DEVTUNNELS_TOKEN`, `GIT_NAME` and `GIT_EMAIL`, and fixed into the container at
create time. The real value never enters a box and never reaches a filesystem
outside the orchestrator's own data volume. The CA certificate travels the same path, as `BOXES_PROXY_CA`, which
the entrypoint writes to `~/.boxes/proxy-ca.crt`. From it the entrypoint
Expand Down Expand Up @@ -2614,7 +2644,9 @@ package that young puts its breaking changes.

glab is pinned exactly, to a release and to the checksum of its `.deb` for
each architecture, because there is no apt repository to take a signed package
from. `gh` comes from one, and is pinned by nothing but that.
from. `gh` comes from one, and is pinned by nothing but that. The `devtunnel`
CLI is pinned the same way as glab, to a release and to the checksum of each
build, because Microsoft ships it as a bare binary.

Codex is pinned as a pair, and exactly. `@openai/codex` on npm is a 13 KB
launcher whose platform binary arrives as an optional dependency of 339 MB, so
Expand Down Expand Up @@ -2650,7 +2682,7 @@ orchestrator/src/
harness.ts The registry: one record per harness, and every value that varies between them
credentials.ts The credential store, and the refresh that keeps a login true
settings.ts Git identity and each dialog's last choice, over the settings table
login.ts Logging in to an account: a CLI in a throwaway container, and the state a page polls
login.ts Logging in to an account: a CLI in a throwaway container or GitHub's device flow, and the state a page polls
secret.ts WS auth token: configured, stored, or generated
notify.ts "A thread wants you", pushed to every subscribed browser
push.ts VAPID and RFC 8291 payload encryption, on node:crypto
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ card per credential, and the identity every box commits as.
| OpenAI | An API key, `sk-…` | A Codex thread fails at its first prompt |
| GitHub | A classic personal access token, `ghp_…` | git and gh reach GitHub unauthenticated, and a push is refused |
| GitLab | A personal access token, `glpat-…` | git and glab reach GitLab unauthenticated, and a push is refused |
| Dev Tunnels | A GitHub login, with **Log in** on the card | An agent cannot share a running web app with you |
| Git identity | The name and email a box commits as | Boxes commit as `boxes-bot <boxes-bot@users.noreply.github.com>` |

A secret is write-only. It goes in, and what comes back out is its last four
Expand Down
21 changes: 20 additions & 1 deletion box-image/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,25 @@ RUN arch="$(dpkg --print-architecture)" \
&& dpkg -i /tmp/glab.deb \
&& rm -f /tmp/glab.deb

# The Microsoft Dev Tunnels CLI, which the share-app skill hosts a tunnel
# with. It is one self-contained binary with no package, so the version and
# the checksum of each build are pinned here. The version check runs it as
# root, and the step removes the files it unpacks into root's home.
ARG DEVTUNNEL_VERSION=1.0.2094+24665e6583
ARG DEVTUNNEL_SHA256_AMD64=2aa6c41aaf7840427e84b7e1e99be5b826a9966fc027ddf61dc0f69b68db060b
ARG DEVTUNNEL_SHA256_ARM64=efac378f9ffb40914935fbd2361543088b50cd62248372df06c243e6f7c49cc8
RUN case "$(dpkg --print-architecture)" in \
amd64) build=linux-x64; sha="$DEVTUNNEL_SHA256_AMD64" ;; \
arm64) build=linux-arm64; sha="$DEVTUNNEL_SHA256_ARM64" ;; \
*) echo "no devtunnel build pinned for $(dpkg --print-architecture)" >&2; exit 1 ;; \
esac \
&& curl -fsSL "https://tunnelsassetsprod.blob.core.windows.net/cli/$(echo "$DEVTUNNEL_VERSION" | sed 's/+/%2B/')/${build}-devtunnel" \
-o /usr/local/bin/devtunnel \
&& echo "$sha /usr/local/bin/devtunnel" | sha256sum -c - \
&& chmod 0755 /usr/local/bin/devtunnel \
&& devtunnel --version | grep -qF "${DEVTUNNEL_VERSION}" \
&& rm -rf /root/.net /root/.local/share/DevTunnels

# Go, Rust and PHP from the distribution, which carries their security
# updates. pkg-config and libssl-dev are what common native crates and
# extensions look for.
Expand Down Expand Up @@ -382,7 +401,7 @@ RUN set -eu; \
for t in pdftotext pdftoppm pdfinfo qpdf mutool gs pandoc tesseract ocrmypdf \
magick dot unzip zip xz zstd 7z file tree patch wget git-lfs \
sqlite3 fd fdfind shellcheck yq cmake uv uvx dig playwright-cli playwright \
certutil timeout; do \
certutil timeout devtunnel; do \
command -v "$t" >/dev/null || { echo "missing tool: $t" >&2; exit 1; }; \
done

Expand Down
1 change: 1 addition & 0 deletions box-image/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,7 @@ install_image_skill() {
log "installed the image's $name skill"
}
install_image_skill nix
install_image_skill share-app

# --- git identity -----------------------------------------------------------
if [ -n "${GIT_NAME:-}" ]; then
Expand Down
75 changes: 75 additions & 0 deletions box-image/skills/share-app/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
---
name: share-app
description: Show a web application that runs in this box to the person you work for, through a private Microsoft Dev Tunnels link that only their GitHub account can open. Use when asked to share, demo or let them try the running app, or to give them a link to it.
---

# Sharing a running web application

The person you work for cannot reach this box's ports. A dev tunnel gives
a port in this box a public HTTPS URL. The tunnel is private: only the
GitHub account that the deployment logged in with can open it. The person
clicks the link, signs in with GitHub once, and sees the application. There
is no password to pass on.

## Before you start

- `DEVTUNNELS_TOKEN` is set in every box. If the API answers `401`, nobody
has logged in to Dev Tunnels on the settings page, or the login has
expired. Stop and say so; sharing is not possible until then.
- The application must listen on a port in this box. `127.0.0.1` is enough;
it does not have to listen on all interfaces.
- The token in `DEVTUNNELS_TOKEN` is a placeholder. The egress proxy swaps
in the real one. Use it only as shown below, and never print it.

## Steps

1. Create a tunnel with one port. Replace `3000` with the application's
port. The answer holds the tunnel's id, a host token for this tunnel, and
the URL.

```sh
api=https://global.rel.tunnels.api.visualstudio.com/api/v1
curl -fsS -X POST "$api/tunnels?tokenScopes=host&includePorts=true" \
-H "Authorization: github $DEVTUNNELS_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"ports":[{"portNumber":3000,"protocol":"http"}]}' \
> "$TMPDIR/share-app-tunnel.json"
jq -r '.tunnelId, .ports[0].portForwardingUris[0]' "$TMPDIR/share-app-tunnel.json"
```

2. Host the tunnel as a background command, so it keeps running while the
turn ends. The host token works for this one tunnel only and lasts 24
hours.

```sh
devtunnel host "$(jq -r .tunnelId "$TMPDIR/share-app-tunnel.json")" \
--access-token "$(jq -r .accessTokens.host "$TMPDIR/share-app-tunnel.json")"
```

It is ready when it prints `Ready to accept connections`.

3. Tell the person the URL from step 1. Say that the first visit asks them
to sign in with GitHub, with the account that is logged in on the
settings page, and may show a warning page about dev tunnels, where they
click "Continue".

4. When they say they are done, or when you finish the task, stop the
`devtunnel` process and delete the tunnel. A tunnel left behind counts
against the account's limit of ten.

```sh
curl -fsS -X DELETE \
"$api/tunnels/$(jq -r .tunnelId "$TMPDIR/share-app-tunnel.json")" \
-H "Authorization: github $DEVTUNNELS_TOKEN"
```

## Problems and fixes

- Creating a tunnel fails because the account has too many: list them with
`curl -fsS "$api/tunnels" -H "Authorization: github $DEVTUNNELS_TOKEN"`,
and delete the ones no task uses any more.
- The page loads, but the application redirects to `localhost` or rejects
the request: development servers such as Vite check the `Host` header.
Add the tunnel's host name to the server's allowed hosts setting.
- The free service allows 5 GB of traffic per month. A long demo of a page
that polls or streams can use that up.
16 changes: 16 additions & 0 deletions dashboard/e2e/settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,22 @@ for (const scheme of ['light', 'dark'] as const) {
});
}

test('the Dev Tunnels card offers a login and no paste form', async () => {
const { page, errors, close } = await openPage(stub.url, '/settings');
try {
await expect.poll(() => page.getByRole('heading', { name: 'Dev Tunnels' }).isVisible())
.toBe(true);
await expect
.poll(() => page.getByRole('button', { name: 'Log in to Dev Tunnels' }).isVisible())
.toBe(true);
// The service takes only a token from GitHub's device flow.
expect(await page.getByLabel('Dev Tunnels secret').count()).toBe(0);
expect(errors).toEqual([]);
} finally {
await close();
}
});

test('a pasted credential is stored and comes back as its last four characters', async () => {
stub.state.claudeCredential = null;

Expand Down
7 changes: 4 additions & 3 deletions dashboard/src/components/CredentialLogin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,9 @@ const POLL_MS = 1_000;
*
* The orchestrator runs the harness's own CLI in a throwaway container. Codex
* prints a URL and a one-time code and polls by itself. Claude prints a URL
* and waits until the code is pasted back. The page starts the login, so a
* remount does not start a second one.
* and waits until the code is pasted back. For Dev Tunnels the orchestrator
* runs GitHub's device flow itself, which shows a URL and a code like Codex.
* The page starts the login, so a remount does not start a second one.
*/
export function CredentialLogin({
credential,
Expand Down Expand Up @@ -132,7 +133,7 @@ export function CredentialLogin({
{state.state === 'starting' ? (
<p className="flex items-center gap-2 text-xs text-muted-foreground">
<Spinner label={`Starting the ${label} login`} />
Starting a login. This takes a few seconds: it runs in a container of its own.
Starting a login. This takes a few seconds.
</p>
) : null}

Expand Down
54 changes: 37 additions & 17 deletions dashboard/src/views/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,12 @@ interface CredentialKind {
harnesses: HarnessId[];
/** What stops working without it. */
blurb: string;
/** What the secret looks like, so a wrong paste is obvious before saving. */
hint: string;
/**
* What the secret looks like, so a wrong paste is obvious before saving.
* Null for a credential that can only be obtained by logging in, which
* then gets no paste form.
*/
hint: string | null;
/** What a pasted secret is stored as; a login decides its own. */
method: CredentialMethod;
/**
Expand Down Expand Up @@ -95,6 +99,18 @@ const KINDS: CredentialKind[] = [
method: 'token',
canLogin: false,
},
{
id: 'devtunnels',
label: 'Dev Tunnels',
harnesses: [],
blurb:
'What an agent shows you a running web app with, through a private Microsoft ' +
'Dev Tunnels link that only your GitHub account can open. It is a GitHub login, ' +
'because the service takes no pasted token.',
hint: null,
method: 'oauth',
canLogin: true,
},
];

/**
Expand Down Expand Up @@ -312,21 +328,25 @@ function CredentialCard({
) : null}

<form className="flex flex-col gap-2 sm:flex-row" onSubmit={(e) => void save(e)}>
<Label className="sr-only" htmlFor={`secret-${kind.id}`}>
{`${kind.label} secret`}
</Label>
<Input
id={`secret-${kind.id}`}
type="password"
autoComplete="off"
className="font-mono"
placeholder={kind.hint}
value={secret}
onChange={(e) => setSecret(e.target.value)}
/>
<Button type="submit" disabled={busy || secret.trim() === ''}>
{stored ? 'Replace' : 'Save'}
</Button>
{kind.hint !== null ? (
<>
<Label className="sr-only" htmlFor={`secret-${kind.id}`}>
{`${kind.label} secret`}
</Label>
<Input
id={`secret-${kind.id}`}
type="password"
autoComplete="off"
className="font-mono"
placeholder={kind.hint}
value={secret}
onChange={(e) => setSecret(e.target.value)}
/>
<Button type="submit" disabled={busy || secret.trim() === ''}>
{stored ? 'Replace' : 'Save'}
</Button>
</>
) : null}
{/* A subscription has no secret to paste, so a login sits beside the form. */}
{kind.canLogin && loginId === null ? (
<Button
Expand Down
Loading
Loading