The proxy behind agent-device proxy, as a library. Embed it in your own gateway to give remote
agent-device clients access to a daemon on another machine, over whichever transport you choose.
The proxy:
- authenticates clients with a token you choose, or with your own check, and never exposes the daemon's own token;
- forwards only the routes a remote client needs:
/health,/rpc, uploads, artifacts, and request diagnostics; - refuses install sources that name a file on the daemon host;
- rewrites upload tickets so clients upload through the proxy, not to the daemon directly.
Requires Node.js 22.12 or later.
npm install @agent-device/proxyKeep its version close to the daemon behind it. Clients refuse a proxy whose daemon speaks a different RPC protocol version, before any command runs.
import { createDaemonProxyServer } from '@agent-device/proxy';
const server = createDaemonProxyServer({
upstreamBaseUrl: 'http://127.0.0.1:4310',
upstreamToken: process.env.AGENT_DEVICE_DAEMON_TOKEN!,
clientToken: process.env.GATEWAY_CLIENT_TOKEN!,
});
server.listen(8080);Clients connect with agent-device connect proxy --daemon-base-url https://gateway.example.com/agent-device
and AGENT_DEVICE_DAEMON_AUTH_TOKEN set to clientToken. Each agent sets its own AGENT_DEVICE_SESSION
(or passes --session): a command that names no session on a proxy connection is refused.
To mount the proxy in an existing node:http or node:https server, use
createDaemonProxyRequestListener(createDaemonProxy(options)).
createDaemonProxy has no server of its own. It answers standard Request objects with standard
Response objects, so any Node.js-compatible runtime or framework that speaks the Fetch API can
host it:
import { createDaemonProxy } from '@agent-device/proxy';
const proxy = createDaemonProxy({
upstreamBaseUrl: 'http://127.0.0.1:4310',
upstreamToken: daemonToken,
clientToken,
});
// A fetch-style server entry, as used by Bun and by Hono on Node.js
export default { fetch: proxy.handle };For a WebSocket or other message transport, turn each incoming message into a Request and send
the Response back. Three things the proxy relies on:
- Without
publicOrigin, constructrequest.urlwith the canonical client-facing URL for each request. The embedder is responsible for that URL.ForwardedandX-Forwarded-*headers do not select the upload ticket origin. request.signalaborts when the client goes away. The proxy then cancels the daemon request, so work the client stopped waiting for does not keep running.- Response bodies stream. Artifact downloads can be large, so forward the body as it arrives.
handle never rejects. Errors come back as JSON responses with a 4xx or 5xx status, except a route
the proxy does not serve, which gets a plain Not found 404.
Set publicOrigin to the public HTTP(S) origin when TLS terminates before the proxy, for example
https://public.example:8443. Use the scheme, host and optional port, without credentials, a path,
query or fragment. With the CLI, run
agent-device proxy --port 4310 --public-origin https://public.example:8443. For an ephemeral
tunnel, obtain its public name first, then configure and restart the proxy. This replaces the
previous first-X-Forwarded-Proto behavior.
Without a fixed origin, the Node listener uses the actual socket scheme and one validated Host
header. Actual Node HTTPS sockets are supported. A custom direct-daemon HTTP embedding behind TLS
termination can pass publicOrigin to createDaemonHttpServer. The built-in daemon listens on
loopback HTTP; expose it behind TLS through a configured proxy.
Your Node HTTP client must send exactly one valid Host header on every request, including
/health. Missing, duplicate or malformed Host headers receive HTTP 400, even with publicOrigin
configured. Configure raw-socket and HTTP/1.0 health probes to send Host: localhost (or your
deployment's host and port).
By default the proxy calls the daemon with the global fetch. Pass upstreamFetch when the daemon
is only reachable some other way, such as a tunnel the device host opened to your gateway:
const proxy = createDaemonProxy({
upstreamBaseUrl: 'http://device-host-17.internal',
upstreamToken: daemonToken,
clientToken,
upstreamFetch: (request) => deviceHostTunnel.send(request),
});upstreamFetch receives a Request addressed to upstreamBaseUrl and must resolve with the
daemon's Response. Abort the exchange when request.signal aborts.
One clientToken is shared by every client. To give each client its own credential, pass
authenticate instead. It receives each client request (with its JSON body on /rpc) and returns
the tenant the client acts as, or null to refuse it with a 401:
const proxy = createDaemonProxy({
upstreamBaseUrl: 'http://127.0.0.1:4310',
upstreamToken: daemonToken,
authenticate: async (request) => {
const account = await accounts.findByApiKey(request.headers.get('authorization'));
return account ? { tenantId: account.id } : null;
},
});The proxy then sends every authenticated request to the daemon as that tenant, whatever tenant the
client named, and isolates its sessions from other tenants' sessions. GET /health is not
authenticated and carries no tenant. A tenant id is 1-128 letters, numbers, dots, underscores or
hyphens; any other id fails the request with a 500. On /rpc the proxy accepts only
agent_device.command and the agent_device.lease.* methods (or the same names spelled
agent-device.command and agent-device.lease.*), and refuses others with a 401.
Clients upload files with a ticket that names the headers to send. The proxy builds that ticket
from the daemon ticket's content-type and the client's own credential, so it never carries the
daemon's token. By default the credential is the client's authorization and
x-agent-device-token headers. If your clients use another header, such as a cookie or an API key
header, return it as uploadHeaders so the upload passes authenticate too:
authenticate: async (request) => {
const apiKey = request.headers.get('x-api-key');
if (!apiKey) return null;
const account = await accounts.findByApiKey(apiKey);
return account ? { tenantId: account.id, uploadHeaders: { 'x-api-key': apiKey } } : null;
},| Option | Default | Meaning |
|---|---|---|
upstreamBaseUrl |
required | Base URL of the daemon's HTTP server. |
upstreamToken |
required | The daemon's auth token. |
clientToken |
required without authenticate |
Token clients must send as their daemon auth token. |
authenticate |
none | Authenticates each client and names its tenant; replaces clientToken. |
upstreamFetch |
global fetch |
Sends one request to the daemon. |
maxRpcBodyBytes |
64 MiB, the daemon's own request limit | Larger /rpc bodies get a 400 response. |
upstreamTimeoutMs |
none | Longest time one daemon request may take, including its body. Without it, a request lasts as long as the client waits, and the client's command timeout bounds it. |
maxRpcBodyBytes limits the bytes of one /rpc body. The proxy reads the whole body before it
authenticates the client, then decodes and parses it. One unauthenticated request can therefore
hold more memory than the limit: the raw bytes plus the decoded string and the parsed request. For
a proxy that faces the internet, set maxRpcBodyBytes lower than the 64 MiB default, to the largest
request your clients send.
After the limit check, the proxy rewrites the body: it adds the daemon token and, with
authenticate, the tenant fields. The rewritten body can be a few hundred bytes larger than the
client's. The daemon applies its own 64 MiB limit, so a client body within that margin of 64 MiB
passes the proxy and the daemon refuses it. Clients refuse requests over 64 MiB before sending them,
so only a body in that narrow margin is affected.
GET /health (also served as /agent-device/health) needs no token. It reports the proxy's version and instanceId, and
includes the daemon's own health under upstream. A new createDaemonProxy call gets a new
instanceId, so connected clients notice the restart and re-check the connection before sending
more commands.