From 37efdde73e29dd8c71baeff056bfb5027697a3f8 Mon Sep 17 00:00:00 2001 From: Andreas Gohr Date: Sat, 3 Oct 2026 16:25:04 +0000 Subject: [PATCH] Let an agent share a running web app through a private dev tunnel A box had no way to show the person a web app that runs in it. The new share-app skill gives a port in the box a public HTTPS link through Microsoft Dev Tunnels. The tunnel is private, so only the GitHub account of the deployment's login can open it. The settings page has a Dev Tunnels card. The service takes only a token that GitHub issued to its own app, so the card has a login and no paste form. The orchestrator runs GitHub's device flow itself and stores the access token and the refresh token. The access token lasts eight hours, so the credential refresh renews it, also after it has expired. Every box gets a placeholder in DEVTUNNELS_TOKEN, and the proxy swaps in the real token on the Dev Tunnels hosts. Two proxy rules had to change for hosting to work: - While the CLI hosts a tunnel, it sends a token that the service issued for that tunnel, under the "tunnel" scheme. The proxy refused that as a foreign credential. A credential can now name such pass-through schemes, and values under them pass at that credential's hosts. - The interception engine refused every WebSocket upgrade, which blocked the relay connection. An upgrade now gets the same checks as a request. It is forwarded when it needs no swap, and refused when it would carry the placeholder or a foreign credential. The box image carries the devtunnel CLI, pinned to a release and to the checksum of each build. --- ARCHITECTURE.md | 56 +++++-- README.md | 1 + box-image/Dockerfile | 21 ++- box-image/entrypoint.sh | 1 + box-image/skills/share-app/SKILL.md | 75 ++++++++++ dashboard/e2e/settings.test.ts | 16 ++ dashboard/src/components/CredentialLogin.tsx | 7 +- dashboard/src/views/Settings.tsx | 54 ++++--- orchestrator/src/app.ts | 10 +- orchestrator/src/config.test.ts | 8 +- orchestrator/src/config.ts | 26 +++- orchestrator/src/credentials.test.ts | 75 ++++++++++ orchestrator/src/credentials.ts | 125 ++++++++++++++-- orchestrator/src/docker.test.ts | 6 + orchestrator/src/docker.ts | 3 + orchestrator/src/egress.test.ts | 16 ++ orchestrator/src/egress.ts | 1 + orchestrator/src/login.test.ts | 111 +++++++++++++- orchestrator/src/login.ts | 148 ++++++++++++++++++- proxy/src/inject.test.ts | 89 ++++++++++- proxy/src/inject.ts | 113 ++++++++++---- proxy/src/policy.test.ts | 60 ++++++++ proxy/src/policy.ts | 29 +++- shared/types.ts | 12 +- 24 files changed, 968 insertions(+), 95 deletions(-) create mode 100644 box-image/skills/share-app/SKILL.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 8386fcd..50e56a6 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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 | @@ -2389,13 +2390,24 @@ credential header, which covers `Bearer

`, `token

`, 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 ` 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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 diff --git a/README.md b/README.md index 1118ffb..7df6c53 100644 --- a/README.md +++ b/README.md @@ -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 ` | A secret is write-only. It goes in, and what comes back out is its last four diff --git a/box-image/Dockerfile b/box-image/Dockerfile index 2c3bbdd..08a269a 100644 --- a/box-image/Dockerfile +++ b/box-image/Dockerfile @@ -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. @@ -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 diff --git a/box-image/entrypoint.sh b/box-image/entrypoint.sh index 341b470..5f768fd 100755 --- a/box-image/entrypoint.sh +++ b/box-image/entrypoint.sh @@ -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 diff --git a/box-image/skills/share-app/SKILL.md b/box-image/skills/share-app/SKILL.md new file mode 100644 index 0000000..be8d62c --- /dev/null +++ b/box-image/skills/share-app/SKILL.md @@ -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. diff --git a/dashboard/e2e/settings.test.ts b/dashboard/e2e/settings.test.ts index 24042a1..8159f39 100644 --- a/dashboard/e2e/settings.test.ts +++ b/dashboard/e2e/settings.test.ts @@ -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; diff --git a/dashboard/src/components/CredentialLogin.tsx b/dashboard/src/components/CredentialLogin.tsx index 35c3503..50308aa 100644 --- a/dashboard/src/components/CredentialLogin.tsx +++ b/dashboard/src/components/CredentialLogin.tsx @@ -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, @@ -132,7 +133,7 @@ export function CredentialLogin({ {state.state === 'starting' ? (

- Starting a login. This takes a few seconds: it runs in a container of its own. + Starting a login. This takes a few seconds.

) : null} diff --git a/dashboard/src/views/Settings.tsx b/dashboard/src/views/Settings.tsx index b49bddc..582cc2c 100644 --- a/dashboard/src/views/Settings.tsx +++ b/dashboard/src/views/Settings.tsx @@ -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; /** @@ -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, + }, ]; /** @@ -312,21 +328,25 @@ function CredentialCard({ ) : null}
void save(e)}> - - setSecret(e.target.value)} - /> - + {kind.hint !== null ? ( + <> + + setSecret(e.target.value)} + /> + + + ) : null} {/* A subscription has no secret to paste, so a login sits beside the form. */} {kind.canLogin && loginId === null ? (