@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 +43 -2
- package/dist/index.d.mts +29 -4
- package/dist/index.mjs +142 -21
- package/package.json +3 -3
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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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/
|
|
28
|
+
"@agent-device/contracts": "0.0.0",
|
|
29
29
|
"@agent-device/kernel": "0.0.0",
|
|
30
|
-
"@agent-device/
|
|
30
|
+
"@agent-device/host-kit": "0.0.0"
|
|
31
31
|
},
|
|
32
32
|
"scripts": {
|
|
33
33
|
"build": "tsdown --config tsdown.config.ts"
|