@agent-device/proxy 0.21.28-nightly.20261014.57 → 0.21.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -5,7 +5,8 @@ The proxy behind `agent-device proxy`, as a library. Embed it in your own gatewa
5
5
 
6
6
  The proxy:
7
7
 
8
- - authenticates clients with a token you choose, and never exposes the daemon's own token;
8
+ - authenticates clients with a token you choose, or with your own check, and never exposes the
9
+ daemon's own token;
9
10
  - forwards only the routes a remote client needs: `/health`, `/rpc`, uploads, artifacts, and
10
11
  request diagnostics;
11
12
  - refuses install sources that name a file on the daemon host;
@@ -89,13 +90,53 @@ const proxy = createDaemonProxy({
89
90
  `upstreamFetch` receives a `Request` addressed to `upstreamBaseUrl` and must resolve with the
90
91
  daemon's `Response`. Abort the exchange when `request.signal` aborts.
91
92
 
93
+ ### Authenticate each client yourself
94
+
95
+ One `clientToken` is shared by every client. To give each client its own credential, pass
96
+ `authenticate` instead. It receives each client request (with its JSON body on `/rpc`) and returns
97
+ the tenant the client acts as, or `null` to refuse it with a 401:
98
+
99
+ ```ts
100
+ const proxy = createDaemonProxy({
101
+ upstreamBaseUrl: 'http://127.0.0.1:4310',
102
+ upstreamToken: daemonToken,
103
+ authenticate: async (request) => {
104
+ const account = await accounts.findByApiKey(request.headers.get('authorization'));
105
+ return account ? { tenantId: account.id } : null;
106
+ },
107
+ });
108
+ ```
109
+
110
+ The proxy then sends every authenticated request to the daemon as that tenant, whatever tenant the
111
+ client named, and isolates its sessions from other tenants' sessions. `GET /health` is not
112
+ authenticated and carries no tenant. A tenant id is 1-128 letters, numbers, dots, underscores or
113
+ hyphens; any other id fails the request with a 500. On `/rpc` the proxy accepts only
114
+ `agent_device.command` and the `agent_device.lease.*` methods (or the same names spelled
115
+ `agent-device.command` and `agent-device.lease.*`), and refuses others with a 401.
116
+
117
+ Clients upload files with a ticket that names the headers to send. The proxy builds that ticket
118
+ from the daemon ticket's `content-type` and the client's own credential, so it never carries the
119
+ daemon's token. By default the credential is the client's `authorization` and
120
+ `x-agent-device-token` headers. If your clients use another header, such as a cookie or an API key
121
+ header, return it as `uploadHeaders` so the upload passes `authenticate` too:
122
+
123
+ ```ts
124
+ authenticate: async (request) => {
125
+ const apiKey = request.headers.get('x-api-key');
126
+ if (!apiKey) return null;
127
+ const account = await accounts.findByApiKey(apiKey);
128
+ return account ? { tenantId: account.id, uploadHeaders: { 'x-api-key': apiKey } } : null;
129
+ },
130
+ ```
131
+
92
132
  ## Options
93
133
 
94
134
  | Option | Default | Meaning |
95
135
  | --- | --- | --- |
96
136
  | `upstreamBaseUrl` | required | Base URL of the daemon's HTTP server. |
97
137
  | `upstreamToken` | required | The daemon's auth token. |
98
- | `clientToken` | required | Token clients must send as their daemon auth token. |
138
+ | `clientToken` | required without `authenticate` | Token clients must send as their daemon auth token. |
139
+ | `authenticate` | none | Authenticates each client and names its tenant; replaces `clientToken`. |
99
140
  | `upstreamFetch` | global `fetch` | Sends one request to the daemon. |
100
141
  | `maxRpcBodyBytes` | 1 MiB | Larger `/rpc` bodies get a 400 response. |
101
142
  | `upstreamTimeoutMs` | 5 minutes | Longest time one daemon request may take, including its body. |
package/dist/index.d.mts CHANGED
@@ -6,17 +6,42 @@ import http from "node:http";
6
6
  * WebSocket tunnel. Abort the exchange when `request.signal` aborts.
7
7
  */
8
8
  type DaemonProxyUpstreamFetch = (request: Request) => Promise<Response>;
9
+ /** The client an `authenticate` call admitted. */
10
+ type DaemonProxyAuthenticatedClient = {
11
+ /** The tenant every request from this client acts as. */
12
+ tenantId: string;
13
+ /**
14
+ * The credential headers an upload ticket gives this client, so its direct upload passes
15
+ * `authenticate` again. Defaults to the client's own `authorization` and
16
+ * `x-agent-device-token` request headers, when it sent them.
17
+ */
18
+ uploadHeaders?: Record<string, string>;
19
+ };
20
+ /**
21
+ * Authenticates one client request and names the client it admits, or returns `null` to refuse
22
+ * it. The request carries the client's headers and, for `/rpc`, its JSON body.
23
+ */
24
+ type DaemonProxyAuthenticate = (request: Request) => Promise<DaemonProxyAuthenticatedClient | null> | DaemonProxyAuthenticatedClient | null;
9
25
  type DaemonProxyOptions = {
10
26
  /** Base URL of the upstream agent-device daemon HTTP server. */
11
27
  upstreamBaseUrl: string;
12
28
  /** Auth token of the upstream daemon. Never leaves the proxy. */
13
29
  upstreamToken: string;
14
- /** Token proxy clients must present as their daemon auth token. */
15
- clientToken: string;
16
30
  maxRpcBodyBytes?: number;
17
31
  upstreamTimeoutMs?: number;
18
32
  upstreamFetch?: DaemonProxyUpstreamFetch;
19
- };
33
+ } & ({
34
+ /** Token proxy clients must present as their daemon auth token. */
35
+ clientToken: string;
36
+ authenticate?: undefined;
37
+ } | {
38
+ clientToken?: undefined;
39
+ /**
40
+ * Replaces the `clientToken` check. Every forwarded request acts as the returned tenant,
41
+ * with its sessions isolated from other tenants.
42
+ */
43
+ authenticate: DaemonProxyAuthenticate;
44
+ });
20
45
  type DaemonProxy = {
21
46
  /** Identifies this proxy instance in its health payload, so clients notice a restart. */
22
47
  readonly instanceId: string;
@@ -33,4 +58,4 @@ declare function createDaemonProxyServer(options: DaemonProxyOptions): http.Serv
33
58
  /** Serves a proxy from any `node:http` or `node:https` server; upload tickets follow its scheme. */
34
59
  declare function createDaemonProxyRequestListener(proxy: DaemonProxy): http.RequestListener;
35
60
  //#endregion
36
- export { type DaemonProxy, type DaemonProxyOptions, type DaemonProxyUpstreamFetch, createDaemonProxy, createDaemonProxyRequestListener, createDaemonProxyServer };
61
+ export { type DaemonProxy, type DaemonProxyAuthenticate, type DaemonProxyAuthenticatedClient, type DaemonProxyOptions, type DaemonProxyUpstreamFetch, createDaemonProxy, createDaemonProxyRequestListener, createDaemonProxyServer };
package/dist/index.mjs CHANGED
@@ -31,6 +31,18 @@ function buildDaemonHttpAuthHeaders(token) {
31
31
  "x-agent-device-token": normalizedToken
32
32
  };
33
33
  }
34
+ /** The tenant id every daemon route accepts, trimmed, or `undefined` when it breaks the rule. */
35
+ function normalizeTenantId(raw) {
36
+ if (!raw) return void 0;
37
+ const value = raw.trim();
38
+ if (!value) return void 0;
39
+ if (!/^[a-zA-Z0-9._-]{1,128}$/.test(value)) return void 0;
40
+ return value;
41
+ }
42
+ /** Whether `sessionName` lies beneath the `<tenant>:` prefix tenant isolation names sessions with. */
43
+ function isTenantOwnedSessionName(tenant, sessionName) {
44
+ return sessionName.startsWith(`${tenant}:`);
45
+ }
34
46
  const DAEMON_HTTP_INSTANCE_HEADER = "x-agent-device-instance";
35
47
  const DAEMON_HTTP_UPSTREAM_INSTANCE_HEADER = "x-agent-device-upstream-instance";
36
48
  const DAEMON_HTTP_INSTANCE_MISMATCH_HEADER = "x-agent-device-instance-mismatch";
@@ -98,6 +110,8 @@ function readRecord(value) {
98
110
  }
99
111
  //#endregion
100
112
  //#region src/daemon-proxy.ts
113
+ /** The client request headers an upload ticket repeats when `authenticate` names none. */
114
+ const DEFAULT_UPLOAD_CREDENTIAL_HEADERS = Object.keys(buildDaemonHttpAuthHeaders("client"));
101
115
  const DEFAULT_MAX_RPC_BODY_BYTES = 1048576;
102
116
  const DEFAULT_UPSTREAM_TIMEOUT_MS = 3e5;
103
117
  const DAEMON_PROXY_PREFIX = `${DAEMON_HTTP_BASE_PATH}/`;
@@ -149,7 +163,14 @@ async function handleProxyRequest(request, options, instanceId) {
149
163
  if (!isSupportedDaemonRoute(route, request.method)) return new Response(new TextEncoder().encode("Not found"), { status: 404 });
150
164
  let rpcBody;
151
165
  if (route === "/rpc") rpcBody = await readRequestText(request, options.maxRpcBodyBytes, "Proxy request body is too large.");
152
- if (!isAuthorized(request, options.clientToken, rpcBody)) return unauthorizedResponse(route, readJsonRpcId(rpcBody));
166
+ const caller = await authenticateCaller(request, options, rpcBody);
167
+ if (!caller) return unauthorizedResponse(route, readJsonRpcId(rpcBody));
168
+ if (caller.tenantId && !isRouteWithinTenant(route, caller.tenantId)) return Response.json({
169
+ ok: false,
170
+ error: "Session is outside this tenant",
171
+ code: "UNAUTHORIZED"
172
+ }, { status: 401 });
173
+ if (caller.tenantId && route === "/rpc" && !isTenantStampedRpcMethod(rpcBody)) return tenantRpcMethodRefusedResponse(readJsonRpcId(rpcBody));
153
174
  const staleInstance = refuseStaleProxyInstance(request, route, readJsonRpcId(rpcBody), instanceId);
154
175
  if (staleInstance) return staleInstance;
155
176
  if (carriesUnbackedHostPathInstallSource(rpcBody)) return hostPathInstallSourceRefusedResponse(readJsonRpcId(rpcBody));
@@ -157,9 +178,61 @@ async function handleProxyRequest(request, options, instanceId) {
157
178
  request,
158
179
  route,
159
180
  options,
160
- rpcBody
181
+ rpcBody,
182
+ caller
161
183
  });
162
184
  }
185
+ async function authenticateCaller(request, options, rpcBody) {
186
+ if (!options.authenticate) return isAuthorized(request, options.clientToken ?? "", rpcBody) ? { uploadHeaders: buildDaemonHttpAuthHeaders(options.clientToken) } : null;
187
+ const result = await options.authenticate(new Request(request.url, {
188
+ method: request.method,
189
+ headers: request.headers,
190
+ signal: request.signal,
191
+ ...rpcBody !== void 0 ? { body: rpcBody } : {}
192
+ }));
193
+ if (!result) return null;
194
+ const tenantId = normalizeTenantId(typeof result.tenantId === "string" ? result.tenantId : void 0);
195
+ if (!tenantId) throw new AppError("COMMAND_FAILED", "Proxy authenticate returned an invalid tenantId", { hint: "Return a tenantId of 1-128 letters, digits, \".\", \"_\" or \"-\"." });
196
+ return {
197
+ tenantId,
198
+ uploadHeaders: result.uploadHeaders ?? defaultUploadHeaders(request)
199
+ };
200
+ }
201
+ function defaultUploadHeaders(request) {
202
+ const headers = {};
203
+ for (const name of DEFAULT_UPLOAD_CREDENTIAL_HEADERS) {
204
+ const value = request.headers.get(name);
205
+ if (value !== null) headers[name] = value;
206
+ }
207
+ return headers;
208
+ }
209
+ /**
210
+ * The daemon does not partition a tenant it did not attest itself, so the proxy confines the
211
+ * diagnostics route to the sessions the daemon named beneath `<tenant>:`. Other routes pass.
212
+ */
213
+ function isRouteWithinTenant(route, tenantId) {
214
+ if (!isRequestDiagnosticsRoute(route)) return true;
215
+ try {
216
+ return isTenantOwnedSessionName(tenantId, decodeURIComponent(route.split("/")[2] ?? ""));
217
+ } catch {
218
+ return false;
219
+ }
220
+ }
221
+ /** The RPC methods whose every tenant field `stampRpcTenant` sets. */
222
+ const TENANT_STAMPED_RPC_METHODS = /* @__PURE__ */ new Set([
223
+ "agent_device.command",
224
+ "agent-device.command",
225
+ "agent_device.lease.allocate",
226
+ "agent-device.lease.allocate",
227
+ "agent_device.lease.heartbeat",
228
+ "agent-device.lease.heartbeat",
229
+ "agent_device.lease.release",
230
+ "agent-device.lease.release"
231
+ ]);
232
+ function isTenantStampedRpcMethod(rpcBody) {
233
+ const method = readJsonRpcMethod(rpcBody);
234
+ return method !== void 0 && TENANT_STAMPED_RPC_METHODS.has(method);
235
+ }
163
236
  async function proxyHealthResponse(request, options, instanceId) {
164
237
  const [upstream, { readVersion }, { readHostCpuArch }] = await Promise.all([
165
238
  readUpstreamHealth(request, options),
@@ -192,11 +265,11 @@ async function readUpstreamHealth(request, options) {
192
265
  }
193
266
  }
194
267
  async function forwardProxyRequest(params) {
195
- const { request, route, options, rpcBody } = params;
196
- const body = resolveUpstreamBody(request, route, rpcBody, options.upstreamToken);
268
+ const { request, route, options, rpcBody, caller } = params;
269
+ const body = resolveUpstreamBody(request, route, rpcBody, options.upstreamToken, caller.tenantId);
197
270
  const upstreamResponse = await options.upstreamFetch(new Request(buildUpstreamUrl(options.upstreamBaseUrl, route, request.url), {
198
271
  method: request.method,
199
- headers: buildUpstreamHeaders(request.headers, options.upstreamToken, route),
272
+ headers: buildUpstreamHeaders(request.headers, options.upstreamToken, route, caller.tenantId),
200
273
  signal: upstreamSignal(request, options),
201
274
  ...body ? {
202
275
  body,
@@ -208,7 +281,7 @@ async function forwardProxyRequest(params) {
208
281
  request,
209
282
  upstreamResponse,
210
283
  headers,
211
- clientToken: options.clientToken
284
+ uploadHeaders: caller.uploadHeaders
212
285
  });
213
286
  return new Response(upstreamResponse.body, {
214
287
  status: upstreamResponse.status,
@@ -238,15 +311,15 @@ function routeResponseHeaders(route) {
238
311
  return [];
239
312
  }
240
313
  async function rewrittenUploadPreflightResponse(params) {
241
- const { request, upstreamResponse, headers, clientToken } = params;
314
+ const { request, upstreamResponse, headers, uploadHeaders } = params;
242
315
  const text = await upstreamResponse.text();
243
316
  headers.set("content-type", upstreamResponse.headers.get("content-type") ?? "application/json");
244
- return new Response(rewriteUploadPreflightResponse(text, request, clientToken), {
317
+ return new Response(rewriteUploadPreflightResponse(text, request, uploadHeaders), {
245
318
  status: upstreamResponse.status,
246
319
  headers
247
320
  });
248
321
  }
249
- function rewriteUploadPreflightResponse(body, request, clientToken) {
322
+ function rewriteUploadPreflightResponse(body, request, uploadHeaders) {
250
323
  let parsed;
251
324
  try {
252
325
  parsed = JSON.parse(body);
@@ -255,20 +328,26 @@ function rewriteUploadPreflightResponse(body, request, clientToken) {
255
328
  }
256
329
  if (!parsed || typeof parsed !== "object") return body;
257
330
  const record = parsed;
258
- if (!record.upload || typeof record.upload.url !== "string") return body;
259
- const rewrittenUrl = rewriteUploadDirectUrl(record.upload.url, request);
260
- if (!rewrittenUrl) return body;
261
- const headers = record.upload.headers && typeof record.upload.headers === "object" ? { ...record.upload.headers } : {};
262
- Object.assign(headers, buildDaemonHttpAuthHeaders(clientToken));
331
+ if (!record.upload || typeof record.upload !== "object") return body;
332
+ const rewrittenUrl = typeof record.upload.url === "string" ? rewriteUploadDirectUrl(record.upload.url, request) : null;
263
333
  return JSON.stringify({
264
334
  ...parsed,
265
335
  upload: {
266
336
  ...record.upload,
267
- url: rewrittenUrl,
268
- headers
337
+ ...rewrittenUrl ? { url: rewrittenUrl } : {},
338
+ headers: {
339
+ ...upstreamTicketContentType(record.upload.headers),
340
+ ...rewrittenUrl ? uploadHeaders : {}
341
+ }
269
342
  }
270
343
  });
271
344
  }
345
+ /** The only upstream ticket header a client ticket repeats; daemon credentials never pass. */
346
+ function upstreamTicketContentType(headers) {
347
+ if (!headers || typeof headers !== "object") return {};
348
+ for (const [name, value] of Object.entries(headers)) if (name.toLowerCase() === "content-type" && typeof value === "string") return { "content-type": value };
349
+ return {};
350
+ }
272
351
  function rewriteUploadDirectUrl(upstreamUrl, request) {
273
352
  let parsed;
274
353
  try {
@@ -290,7 +369,8 @@ function normalizeProxyOptions(options) {
290
369
  return {
291
370
  upstreamBaseUrl: normalizeBaseUrl(options.upstreamBaseUrl, "upstreamBaseUrl"),
292
371
  upstreamToken: normalizeToken(options.upstreamToken, "upstreamToken"),
293
- clientToken: normalizeToken(options.clientToken, "clientToken"),
372
+ clientToken: options.authenticate ? void 0 : normalizeToken(options.clientToken ?? "", "clientToken"),
373
+ authenticate: options.authenticate,
294
374
  maxRpcBodyBytes: options.maxRpcBodyBytes ?? DEFAULT_MAX_RPC_BODY_BYTES,
295
375
  upstreamTimeoutMs: options.upstreamTimeoutMs ?? DEFAULT_UPSTREAM_TIMEOUT_MS,
296
376
  upstreamFetch: options.upstreamFetch ?? ((request) => fetch(request))
@@ -352,12 +432,13 @@ function buildUpstreamInstancePreconditionHeaders(requestHeaders) {
352
432
  const expectedUpstreamInstance = requestHeaders.get(DAEMON_HTTP_UPSTREAM_INSTANCE_HEADER);
353
433
  return expectedUpstreamInstance !== null ? { [DAEMON_HTTP_INSTANCE_HEADER]: expectedUpstreamInstance } : {};
354
434
  }
355
- function buildUpstreamHeaders(requestHeaders, upstreamToken, route) {
435
+ function buildUpstreamHeaders(requestHeaders, upstreamToken, route, tenantId) {
356
436
  const headers = new Headers();
357
437
  for (const name of FORWARDED_REQUEST_HEADERS) {
358
438
  const value = requestHeaders.get(name);
359
439
  if (value?.trim()) headers.set(name, value);
360
440
  }
441
+ if (tenantId) headers.set(DAEMON_HTTP_TENANT_HEADER, tenantId);
361
442
  if (route === "/rpc" && !headers.has("content-type")) headers.set("content-type", "application/json");
362
443
  if (route === "/rpc") {
363
444
  headers.set(DAEMON_HTTP_NETWORK_ACCESS_HEADER, DAEMON_HTTP_PUBLIC_NETWORK_ACCESS);
@@ -366,9 +447,9 @@ function buildUpstreamHeaders(requestHeaders, upstreamToken, route) {
366
447
  for (const [name, value] of Object.entries(buildDaemonHttpAuthHeaders(upstreamToken))) headers.set(name, value);
367
448
  return headers;
368
449
  }
369
- function resolveUpstreamBody(request, route, rpcBody, upstreamToken) {
450
+ function resolveUpstreamBody(request, route, rpcBody, upstreamToken, tenantId) {
370
451
  if (request.method === "GET" || request.method === "HEAD") return null;
371
- if (route === "/rpc") return rewriteRpcToken(rpcBody ?? "", upstreamToken);
452
+ if (route === "/rpc") return rewriteRpcParams(rpcBody ?? "", upstreamToken, tenantId);
372
453
  return request.body;
373
454
  }
374
455
  async function readRequestText(request, maxBodyBytes, tooLargeMessage) {
@@ -403,14 +484,33 @@ function resolveRequestToken(request, rpcBody) {
403
484
  }
404
485
  return "";
405
486
  }
406
- function rewriteRpcToken(body, upstreamToken) {
487
+ function rewriteRpcParams(body, upstreamToken, tenantId) {
407
488
  const parsed = JSON.parse(body);
408
489
  parsed.params = {
409
490
  ...parsed.params ?? {},
410
491
  token: upstreamToken
411
492
  };
493
+ if (tenantId) stampRpcTenant(parsed.params, tenantId);
412
494
  return JSON.stringify(parsed);
413
495
  }
496
+ /**
497
+ * Sets the tenant everywhere the daemon reads it — `meta` for commands, `tenantId` for lease
498
+ * calls — with tenant session isolation, overwriting whatever the client sent.
499
+ */
500
+ function stampRpcTenant(params, tenantId) {
501
+ params.tenantId = tenantId;
502
+ params.meta = {
503
+ ...asRecord(params.meta),
504
+ tenantId,
505
+ sessionIsolation: "tenant"
506
+ };
507
+ const flags = asRecord(params.flags);
508
+ if (flags.tenant !== void 0) flags.tenant = tenantId;
509
+ if (flags.sessionIsolation !== void 0) flags.sessionIsolation = "tenant";
510
+ }
511
+ function asRecord(value) {
512
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
513
+ }
414
514
  function readJsonRpcToken(body) {
415
515
  try {
416
516
  const parsed = JSON.parse(body);
@@ -419,6 +519,15 @@ function readJsonRpcToken(body) {
419
519
  return "";
420
520
  }
421
521
  }
522
+ function readJsonRpcMethod(body) {
523
+ if (!body) return void 0;
524
+ try {
525
+ const { method } = JSON.parse(body);
526
+ return typeof method === "string" ? method : void 0;
527
+ } catch {
528
+ return;
529
+ }
530
+ }
422
531
  function readJsonRpcId(body) {
423
532
  if (!body) return null;
424
533
  try {
@@ -460,6 +569,18 @@ function unauthorizedResponse(route, rpcId) {
460
569
  code: "UNAUTHORIZED"
461
570
  }, { status: 401 });
462
571
  }
572
+ function tenantRpcMethodRefusedResponse(rpcId) {
573
+ const error = new AppError("UNAUTHORIZED", "This RPC method cannot act as an authenticated tenant through the proxy", { hint: "Use agent_device.command or the agent_device.lease methods." });
574
+ return Response.json({
575
+ jsonrpc: "2.0",
576
+ id: rpcId,
577
+ error: {
578
+ code: -32001,
579
+ message: error.message,
580
+ data: normalizeError(error)
581
+ }
582
+ }, { status: 401 });
583
+ }
463
584
  function proxyErrorResponse(error) {
464
585
  const normalized = normalizeError(error);
465
586
  return Response.json({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-device/proxy",
3
- "version": "0.21.28-nightly.20261014.57",
3
+ "version": "0.21.28",
4
4
  "type": "module",
5
5
  "description": "Transport-neutral authenticating proxy in front of an agent-device daemon.",
6
6
  "license": "MIT",
@@ -25,9 +25,9 @@
25
25
  "dist"
26
26
  ],
27
27
  "devDependencies": {
28
- "@agent-device/host-kit": "0.0.0",
28
+ "@agent-device/contracts": "0.0.0",
29
29
  "@agent-device/kernel": "0.0.0",
30
- "@agent-device/contracts": "0.0.0"
30
+ "@agent-device/host-kit": "0.0.0"
31
31
  },
32
32
  "scripts": {
33
33
  "build": "tsdown --config tsdown.config.ts"