Skip to content

Commit 75cdbe3

Browse files
committed
feat(proxy): serve the daemon proxy over any transport and publish it
`@agent-device/proxy` becomes a public package so gateways such as simlock can embed the proxy behind `agent-device proxy`. `createDaemonProxy(options).handle(Request)` resolves with a `Response`, and `upstreamFetch` replaces the HTTP call to the daemon, so either side can run over HTTP, WebSocket, or a tunnel. `@agent-device/proxy/node` keeps `createDaemonProxyServer` for node:http; the CLI uses it unchanged.
1 parent 49ea97d commit 75cdbe3

21 files changed

Lines changed: 767 additions & 258 deletions

‎packages/proxy/LICENSE‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 Callstack
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

‎packages/proxy/README.md‎

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
# @agent-device/proxy
2+
3+
The proxy behind `agent-device proxy`, as a library. Embed it in your own gateway to give remote
4+
`agent-device` clients access to a daemon on another machine, over whichever transport you choose.
5+
6+
The proxy:
7+
8+
- authenticates clients with a token you choose, and never exposes the daemon's own token;
9+
- forwards only the routes a remote client needs: `/health`, `/rpc`, uploads, artifacts, and
10+
request diagnostics;
11+
- refuses install sources that name a file on the daemon host;
12+
- rewrites upload tickets so clients upload through the proxy, not to the daemon directly.
13+
14+
Requires Node.js 22.12 or later.
15+
16+
## Install
17+
18+
```bash
19+
npm install @agent-device/proxy
20+
```
21+
22+
Keep its version close to the daemon behind it. Clients refuse a proxy whose daemon speaks a
23+
different RPC protocol version, before any command runs.
24+
25+
## Serve the proxy over HTTP
26+
27+
```ts
28+
import { createDaemonProxyServer } from '@agent-device/proxy/node';
29+
30+
const server = createDaemonProxyServer({
31+
upstreamBaseUrl: 'http://127.0.0.1:4310',
32+
upstreamToken: process.env.AGENT_DEVICE_DAEMON_TOKEN!,
33+
clientToken: process.env.GATEWAY_CLIENT_TOKEN!,
34+
});
35+
server.listen(8080);
36+
```
37+
38+
Clients connect with `agent-device connect proxy --daemon-base-url https://gateway.example.com/agent-device`
39+
and `AGENT_DEVICE_DAEMON_AUTH_TOKEN` set to `clientToken`.
40+
41+
To mount the proxy in an existing `node:http` or `node:https` server, use
42+
`createDaemonProxyRequestListener(createDaemonProxy(options))`.
43+
44+
## Bring your own transport
45+
46+
`createDaemonProxy` has no server of its own. It answers standard `Request` objects with standard
47+
`Response` objects, so any Node.js-compatible runtime or framework that speaks the Fetch API can
48+
host it:
49+
50+
```ts
51+
import { createDaemonProxy } from '@agent-device/proxy';
52+
53+
const proxy = createDaemonProxy({
54+
upstreamBaseUrl: 'http://127.0.0.1:4310',
55+
upstreamToken: daemonToken,
56+
clientToken,
57+
});
58+
59+
// A fetch-style server entry, as used by Bun and by Hono on Node.js
60+
export default { fetch: proxy.handle };
61+
```
62+
63+
For a WebSocket or other message transport, turn each incoming message into a `Request` and send
64+
the `Response` back. Three things the proxy relies on:
65+
66+
- `request.url` is the URL the client used. Upload tickets point clients at its origin. An
67+
`x-forwarded-proto` header overrides the scheme.
68+
- `request.signal` aborts when the client goes away. The proxy then cancels the daemon request, so
69+
work the client stopped waiting for does not keep running.
70+
- Response bodies stream. Artifact downloads can be large, so forward the body as it arrives.
71+
72+
`handle` never rejects. Errors come back as JSON responses with a 4xx or 5xx status, except a route
73+
the proxy does not serve, which gets a plain `Not found` 404.
74+
75+
### Reach the daemon over your own transport
76+
77+
By default the proxy calls the daemon with the global `fetch`. Pass `upstreamFetch` when the daemon
78+
is only reachable some other way, such as a tunnel the device host opened to your gateway:
79+
80+
```ts
81+
const proxy = createDaemonProxy({
82+
upstreamBaseUrl: 'http://device-host-17.internal',
83+
upstreamToken: daemonToken,
84+
clientToken,
85+
upstreamFetch: (request) => deviceHostTunnel.send(request),
86+
});
87+
```
88+
89+
`upstreamFetch` receives a `Request` addressed to `upstreamBaseUrl` and must resolve with the
90+
daemon's `Response`. Abort the exchange when `request.signal` aborts.
91+
92+
## Options
93+
94+
| Option | Default | Meaning |
95+
| --- | --- | --- |
96+
| `upstreamBaseUrl` | required | Base URL of the daemon's HTTP server. |
97+
| `upstreamToken` | required | The daemon's auth token. |
98+
| `clientToken` | required | Token clients must send as their daemon auth token. |
99+
| `upstreamFetch` | global `fetch` | Sends one request to the daemon. |
100+
| `maxRpcBodyBytes` | 1 MiB | Larger `/rpc` bodies get a 400 response. |
101+
| `upstreamTimeoutMs` | 5 minutes | Longest time one daemon request may take, including its body. |
102+
103+
## Health and restarts
104+
105+
`GET /health` (also served as `/agent-device/health`) needs no token. It reports the proxy's version and `instanceId`, and
106+
includes the daemon's own health under `upstream`. A new `createDaemonProxy` call gets a new
107+
`instanceId`, so connected clients notice the restart and re-check the connection before sending
108+
more commands.

‎packages/proxy/package.json‎

Lines changed: 42 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,53 @@
11
{
22
"name": "@agent-device/proxy",
3-
"version": "0.0.0",
4-
"private": true,
3+
"version": "0.21.22",
54
"type": "module",
6-
"description": "Authenticating proxy in front of an agent-device daemon. Internal workspace package bundled into the published artifact.",
7-
"dependencies": {
8-
"@agent-device/contracts": "workspace:*",
9-
"@agent-device/host-kit": "workspace:*",
10-
"@agent-device/kernel": "workspace:*"
5+
"description": "Transport-neutral authenticating proxy in front of an agent-device daemon.",
6+
"license": "MIT",
7+
"author": "Callstack",
8+
"homepage": "https://agent-device.dev/",
9+
"repository": {
10+
"type": "git",
11+
"url": "git+https://github.com/callstack/agent-device.git",
12+
"directory": "packages/proxy"
13+
},
14+
"engines": {
15+
"node": ">=22.12"
1116
},
1217
"exports": {
1318
".": {
1419
"types": "./src/index.ts",
1520
"default": "./src/index.ts"
21+
},
22+
"./node": {
23+
"types": "./src/node.ts",
24+
"default": "./src/node.ts"
25+
},
26+
"./package.json": "./package.json"
27+
},
28+
"files": [
29+
"dist"
30+
],
31+
"scripts": {
32+
"build": "tsdown --config tsdown.config.ts",
33+
"prepack": "pnpm build"
34+
},
35+
"devDependencies": {
36+
"@agent-device/contracts": "workspace:*",
37+
"@agent-device/host-kit": "workspace:*",
38+
"@agent-device/kernel": "workspace:*"
39+
},
40+
"publishConfig": {
41+
"exports": {
42+
".": {
43+
"types": "./dist/index.d.mts",
44+
"import": "./dist/index.mjs"
45+
},
46+
"./node": {
47+
"types": "./dist/node.d.mts",
48+
"import": "./dist/node.mjs"
49+
},
50+
"./package.json": "./package.json"
1651
}
1752
}
1853
}
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
import { expect, test } from 'vitest';
2+
import { createDaemonProxy, type DaemonProxyUpstreamFetch } from './daemon-proxy.ts';
3+
4+
const PROXY_ORIGIN = 'https://gateway.example.test';
5+
6+
function recordingUpstream(respond: (request: Request) => Response | Promise<Response>): {
7+
fetch: DaemonProxyUpstreamFetch;
8+
requests: Request[];
9+
} {
10+
const requests: Request[] = [];
11+
return {
12+
requests,
13+
fetch: async (request) => {
14+
requests.push(request);
15+
return await respond(request);
16+
},
17+
};
18+
}
19+
20+
function signalled(): { promise: Promise<void>; resolve: () => void } {
21+
let resolve = () => {};
22+
const promise = new Promise<void>((settle) => {
23+
resolve = settle;
24+
});
25+
return { promise, resolve };
26+
}
27+
28+
function proxyWith(upstreamFetch: DaemonProxyUpstreamFetch, maxRpcBodyBytes?: number) {
29+
return createDaemonProxy({
30+
upstreamBaseUrl: 'http://daemon.internal:4310',
31+
upstreamToken: 'daemon-secret',
32+
clientToken: 'client-secret',
33+
upstreamFetch,
34+
...(maxRpcBodyBytes !== undefined ? { maxRpcBodyBytes } : {}),
35+
});
36+
}
37+
38+
function rpcRequest(body: unknown, init: RequestInit = {}): Request {
39+
return new Request(`${PROXY_ORIGIN}/agent-device/rpc`, {
40+
method: 'POST',
41+
headers: { authorization: 'Bearer client-secret', 'content-type': 'application/json' },
42+
body: JSON.stringify(body),
43+
...init,
44+
});
45+
}
46+
47+
test('rpc reaches the daemon through the supplied upstream transport with the daemon token', async () => {
48+
const upstream = recordingUpstream(async (request) =>
49+
Response.json({ jsonrpc: '2.0', id: 1, echo: await request.json() }),
50+
);
51+
const proxy = proxyWith(upstream.fetch);
52+
53+
const response = await proxy.handle(
54+
rpcRequest({ jsonrpc: '2.0', id: 1, method: 'agent-device.command', params: { token: 'x' } }),
55+
);
56+
57+
expect(response.status).toBe(200);
58+
const [forwarded] = upstream.requests;
59+
expect(forwarded?.url).toBe('http://daemon.internal:4310/rpc');
60+
expect(forwarded?.headers.get('authorization')).toBe('Bearer daemon-secret');
61+
const payload = (await response.json()) as { echo: { params: { token: string } } };
62+
expect(payload.echo.params.token).toBe('daemon-secret');
63+
});
64+
65+
test('a request without the client token never reaches the upstream transport', async () => {
66+
const upstream = recordingUpstream(() => Response.json({}));
67+
const proxy = proxyWith(upstream.fetch);
68+
69+
const response = await proxy.handle(
70+
rpcRequest({ jsonrpc: '2.0', id: 7, method: 'agent-device.command' }, { headers: {} }),
71+
);
72+
73+
expect(response.status).toBe(401);
74+
expect(upstream.requests).toHaveLength(0);
75+
});
76+
77+
test('aborting the client request aborts the upstream exchange', async () => {
78+
const upstreamReceived = signalled();
79+
const upstreamAborted = signalled();
80+
const proxy = proxyWith(
81+
(request) =>
82+
new Promise<Response>((_resolve, reject) => {
83+
request.signal.addEventListener('abort', () => {
84+
upstreamAborted.resolve();
85+
reject(request.signal.reason);
86+
});
87+
upstreamReceived.resolve();
88+
}),
89+
);
90+
const client = new AbortController();
91+
92+
const response = proxy.handle(
93+
rpcRequest(
94+
{ jsonrpc: '2.0', id: 2, method: 'agent-device.command' },
95+
{ signal: client.signal },
96+
),
97+
);
98+
await upstreamReceived.promise;
99+
client.abort();
100+
101+
await upstreamAborted.promise;
102+
expect((await response).status).toBe(500);
103+
});
104+
105+
test('upload tickets point at the origin the client used, not the daemon', async () => {
106+
const upstream = recordingUpstream(() =>
107+
Response.json({
108+
ok: true,
109+
upload: { url: 'http://127.0.0.1:4310/upload/direct/abc?part=1', headers: {} },
110+
}),
111+
);
112+
const proxy = proxyWith(upstream.fetch);
113+
114+
const response = await proxy.handle(
115+
new Request(`${PROXY_ORIGIN}/agent-device/upload/preflight`, {
116+
method: 'POST',
117+
headers: { authorization: 'Bearer client-secret' },
118+
body: '{}',
119+
}),
120+
);
121+
122+
const payload = (await response.json()) as {
123+
upload: { url: string; headers: Record<string, string> };
124+
};
125+
expect(payload.upload.url).toBe(`${PROXY_ORIGIN}/agent-device/upload/direct/abc?part=1`);
126+
expect(payload.upload.headers.authorization).toBe('Bearer client-secret');
127+
});
128+
129+
test('an oversized rpc body resolves with a 400 instead of rejecting', async () => {
130+
const upstream = recordingUpstream(() => Response.json({}));
131+
const proxy = proxyWith(upstream.fetch, 16);
132+
133+
const response = await proxy.handle(
134+
rpcRequest({ jsonrpc: '2.0', id: 3, method: 'agent-device.command', padding: 'x'.repeat(64) }),
135+
);
136+
137+
expect(response.status).toBe(400);
138+
expect(upstream.requests).toHaveLength(0);
139+
});
140+
141+
test('aborting a health request aborts the upstream health probe', async () => {
142+
const upstreamReceived = signalled();
143+
const upstreamAborted = signalled();
144+
const proxy = proxyWith(
145+
(request) =>
146+
new Promise<Response>((_resolve, reject) => {
147+
request.signal.addEventListener('abort', () => {
148+
upstreamAborted.resolve();
149+
reject(request.signal.reason);
150+
});
151+
upstreamReceived.resolve();
152+
}),
153+
);
154+
const client = new AbortController();
155+
156+
const response = proxy.handle(
157+
new Request(`${PROXY_ORIGIN}/agent-device/health`, { signal: client.signal }),
158+
);
159+
await upstreamReceived.promise;
160+
client.abort();
161+
162+
await upstreamAborted.promise;
163+
expect((await response).status).toBe(500);
164+
});
165+
166+
test('an unsupported route answers a bare 404 without reaching the daemon', async () => {
167+
const upstream = recordingUpstream(() => Response.json({}));
168+
const proxy = proxyWith(upstream.fetch);
169+
170+
const response = await proxy.handle(
171+
new Request(`${PROXY_ORIGIN}/admin/leases/lease-1`, {
172+
headers: { authorization: 'Bearer client-secret' },
173+
}),
174+
);
175+
176+
expect(response.status).toBe(404);
177+
expect(response.headers.get('content-type')).toBeNull();
178+
expect(upstream.requests).toHaveLength(0);
179+
});

0 commit comments

Comments
 (0)