Skip to content

@ahpd/tunnel-devtunnel ​

npmCIlicense MITnode >=22Agent Host Protocol 0.9.0

A Dev Tunnel plugin for the @ahpd/server daemon. It forwards the daemon's port through a Microsoft Dev Tunnel, labelled the way VS Code expects, so VS Code can find and reach the daemon from a machine on another network.

Part of ahpd. The source is in packages/tunnel-devtunnel.

Install ​

bash
ahpd plugin install @ahpd/tunnel-devtunnel
ahpd restart

Update, remove and one-run flags such as --plugin are in Plugins.

What it needs ​

  • The devtunnel CLI, installed and logged in:

    bash
    devtunnel user login       # a Microsoft account
    devtunnel user login -g    # or a GitHub one

Options ​

In the configuration file, with every option set:

json
{ "plugins": [{ "name": "@ahpd/tunnel-devtunnel", "options": { "name": "dev03", "anonymous": true } }] }
OptionDefaultWhat it does
namethis machine's hostnameWhat the tunnel is called in a client's list
keeptrueWhether the tunnel outlives the daemon
anonymousfalseWhether somebody not signed in to this account may reach it

Keep keep on: a client derives its connection token from the tunnel's id, so a new tunnel on every start is a new token on every start.

No option holds a credential, so the plugin takes nothing from the vault. The tunnel signs in through the devtunnel CLI's own login.

In your own host ​

The package's apply is the plugin entry. Hand it a plugin host from @ahpd/sdk and fold what it registered into the host's options. The plugin does its work on the listening and stopping events, which the daemon raises and an embedding host raises itself:

ts
import { createHost, foldHostOptions, listen, pluginHost, raise } from '@ahpd/sdk';
import { claude } from '@ahpd/agent-claude';
import { apply, name } from '@ahpd/tunnel-devtunnel';

const path = process.cwd();
const context = { path, paths: [path], version: '0.10.0', hostName: 'my-host', configDir: '/var/lib/my-host', log: console.log, say: console.log };
const { host: plugin, contribution, seal } = pluginHost(name, context);
await apply(plugin, { name: 'dev03', keep: true });
seal();

const { options } = foldHostOptions({ path, agents: [claude({ paths: [path] })] }, [contribution]);
const host = createHost(options);
const listener = await listen({ port: 9187 }, (peer) => host.accept(peer));
const { runtime, host: address, port, guarded } = listener;
await raise(options.events, { type: 'listening', runtime, host: address, port, guarded });

// On the way down, before the listener closes:
await raise(options.events, { type: 'stopping' });
await listener.close();

In VS Code ​

The daemon prints the tunnel beside its own address, and ahpd status shows it:

ahpd on ws://127.0.0.1:9187 (node), sessions in /work
tunnel sunny-otter-9k3 (dev03), port 31546

In VS Code, sign in to the same Dev Tunnels account and run Agents: Connect to Remote Agent Host via Dev Tunnel. Finding a tunnel needs that sign-in. A direct WebSocket address does not, and is what Agents: Add Remote Agent Host... takes.

The connection token ​

A client connecting over a tunnel computes its token from the tunnel's id, as the unpadded base64url of its SHA-256, and presents it as ?tkn=. Only people who pass the Dev Tunnels access check can reach the tunnel, so the token does not need to be secret.

A daemon on loopback with no connection token of its own accepts it, and that needs no configuration. A daemon started with --connection-token refuses it, because the client presents the derived token and not the daemon's. The plugin prints the derived token on its own line so you can make the two agree:

tunnel token 4d8f...  - what a client presents over the tunnel; this daemon requires its own

What it does at startup ​

  1. Finds the tunnel it made before, by its own label, or makes one.
  2. Labels it vscode-server-launcher and protocolv5, which is what makes a client list it.
  3. Puts port 31546 on it, the fixed port VS Code looks for.
  4. Forwards 31546 on loopback to the port the daemon bound, unless the daemon is already on 31546.
  5. Hosts the tunnel and prints its address.

If any step fails, the daemon keeps running without the tunnel.

It claims protocolv5 and not 6, because protocol 6 clients also expect a selection gateway at /agent-host/select, which a plain port forward does not serve.

Documentation ​

REMOTE.mdConnection tokens and remote access
PLUGINS.mdLoading a plugin into the daemon

License ​

MIT © Softov