@tonyclaw/agent-inspector 3.0.43 → 3.0.45

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.
Files changed (80) hide show
  1. package/.output/cli.js +20744 -16945
  2. package/.output/nitro.json +1 -1
  3. package/.output/public/assets/{CompareDrawer-BAXxwTwT.js → CompareDrawer-B3_TLTJh.js} +1 -1
  4. package/.output/public/assets/{InspectorPet-xYuGV4Qz.js → InspectorPet-Cpn6hv4P.js} +1 -1
  5. package/.output/public/assets/{ProxyViewerContainer-B6GZnUWi.js → ProxyViewerContainer-BEAhuGOJ.js} +27 -27
  6. package/.output/public/assets/{ReplayDialog-D73pyIg3.js → ReplayDialog-B3EKVdJk.js} +1 -1
  7. package/.output/public/assets/{RequestAnatomy-CSDta_nu.js → RequestAnatomy-COOQjyUC.js} +1 -1
  8. package/.output/public/assets/{ResponseView-C4aIUPby.js → ResponseView-DBCjkwye.js} +1 -1
  9. package/.output/public/assets/{StreamingChunkSequence-D4xxosLx.js → StreamingChunkSequence-CuXhDgMo.js} +1 -1
  10. package/.output/public/assets/{_sessionId-CZHbJyWO.js → _sessionId-BJlM3nmt.js} +1 -1
  11. package/.output/public/assets/{_sessionId-DvuNLzSE.js → _sessionId-zbgYl7ZJ.js} +1 -1
  12. package/.output/public/assets/{index-DZ_wpqQM.js → index-BIK4bPTv.js} +1 -1
  13. package/.output/public/assets/{index-B17u7JMC.js → index-BZo_8koU.js} +15 -15
  14. package/.output/public/assets/{index-C0g79BAs.js → index-D2Qw6Ri2.js} +1 -1
  15. package/.output/public/assets/{index-BWjeqxbk.css → index-ZWschkCh.css} +1 -1
  16. package/.output/public/assets/{index-CmEO8ykj.js → index-xF_NY8Yb.js} +5 -5
  17. package/.output/public/assets/{json-viewer-CuWU-H6K.js → json-viewer-B-Ao6pwW.js} +1 -1
  18. package/.output/public/assets/{jszip.min-DPJI_UKW.js → jszip.min-B_rfAUCe.js} +1 -1
  19. package/.output/server/_libs/modelcontextprotocol__core.mjs +25 -25
  20. package/.output/server/_libs/modelcontextprotocol__server.mjs +2 -2
  21. package/.output/server/_libs/zod.mjs +6 -6
  22. package/.output/server/{_sessionId-Cun21KZF.mjs → _sessionId-CJ99gZFV.mjs} +3 -1
  23. package/.output/server/{_sessionId-CoFyeOti.mjs → _sessionId-DEjScBpK.mjs} +5 -3
  24. package/.output/server/_ssr/{CompareDrawer-Bgc2TZh_.mjs → CompareDrawer-DtOIMeQB.mjs} +4 -2
  25. package/.output/server/_ssr/{InspectorPet-DyK2ojeh.mjs → InspectorPet-CXc4oCBQ.mjs} +3 -1
  26. package/.output/server/_ssr/{ProxyViewerContainer-hvn6oVWD.mjs → ProxyViewerContainer-DVPlwfkt.mjs} +52 -27
  27. package/.output/server/_ssr/{ReplayDialog-KpnrGtZ-.mjs → ReplayDialog-BQEA95qC.mjs} +6 -4
  28. package/.output/server/_ssr/{RequestAnatomy-uCsfucn8.mjs → RequestAnatomy-BMyd8B7Q.mjs} +4 -2
  29. package/.output/server/_ssr/{ResponseView-C8UPqoPO.mjs → ResponseView-qrTaEIM8.mjs} +4 -2
  30. package/.output/server/_ssr/{StreamingChunkSequence-BP28I9sh.mjs → StreamingChunkSequence-BFTL8mIy.mjs} +5 -3
  31. package/.output/server/_ssr/{index-Du3YNDKx.mjs → index-Bcj1Hcy4.mjs} +4 -2
  32. package/.output/server/_ssr/{index-BVwVoQlZ.mjs → index-DW-M7XUG.mjs} +3 -1
  33. package/.output/server/_ssr/index.mjs +4 -3
  34. package/.output/server/_ssr/{json-viewer-DrE0vtlB.mjs → json-viewer-DiI0Yjsn.mjs} +4 -2
  35. package/.output/server/_ssr/publicBasePathContext-5264L3rX.mjs +148 -0
  36. package/.output/server/_ssr/{router-C6nWUZHK.mjs → router-BJ_ltLim.mjs} +2432 -475
  37. package/.output/server/_ssr/start-C6MjPjxp.mjs +57 -0
  38. package/.output/server/_tanstack-start-manifest_v-C6pXXX9R.mjs +4 -0
  39. package/.output/server/index.mjs +99 -99
  40. package/.output/workers/logFinalizer.worker.js +4 -0
  41. package/.output/workers/sessionWorkerEntry.js +4 -0
  42. package/README.md +221 -25
  43. package/docs/instance-control.openapi.yaml +306 -0
  44. package/package.json +4 -1
  45. package/src/cli/alias.ts +440 -0
  46. package/src/cli/instance.ts +1037 -0
  47. package/src/cli/instanceArgs.ts +266 -0
  48. package/src/cli/instanceControl.ts +183 -0
  49. package/src/cli/instanceModel.ts +275 -0
  50. package/src/cli/instanceRegistry.ts +740 -0
  51. package/src/cli/onboard.ts +123 -35
  52. package/src/cli/onboardTarget.ts +179 -0
  53. package/src/cli/templates/codex-skill-onboard.ts +76 -28
  54. package/src/cli/templates/skill-onboard.ts +93 -59
  55. package/src/cli.ts +154 -16
  56. package/src/components/ProxyViewer.tsx +13 -5
  57. package/src/components/providers/SettingsDialog.tsx +13 -8
  58. package/src/lib/basePath.ts +8 -2
  59. package/src/lib/codingAgentEndpoints.ts +16 -0
  60. package/src/lib/instanceContract.ts +220 -0
  61. package/src/lib/managedInstance.ts +104 -0
  62. package/src/lib/publicBasePathContext.ts +107 -0
  63. package/src/mcp/instanceHandlers.ts +149 -0
  64. package/src/mcp/loopback.ts +16 -5
  65. package/src/mcp/mode.ts +25 -0
  66. package/src/mcp/server.ts +258 -18
  67. package/src/mcp/toolHandlers.ts +2 -2
  68. package/src/proxy/identityProxy.ts +656 -36
  69. package/src/proxy/runtimeShutdown.ts +73 -1
  70. package/src/proxy/sessionInfo.ts +5 -4
  71. package/src/routes/api/-instances.ts +109 -0
  72. package/src/routes/api/instances.$name.connection.ts +12 -0
  73. package/src/routes/api/instances.$name.restart.ts +23 -0
  74. package/src/routes/api/instances.$name.start.ts +18 -0
  75. package/src/routes/api/instances.$name.stop.ts +23 -0
  76. package/src/routes/api/instances.$name.ts +12 -0
  77. package/src/routes/api/instances.ts +16 -0
  78. package/src/start.ts +10 -0
  79. package/.output/server/_ssr/start-HYkvq4Ni.mjs +0 -4
  80. package/.output/server/_tanstack-start-manifest_v-rshlRaiN.mjs +0 -4
@@ -1,14 +1,22 @@
1
1
  import http from "node:http";
2
2
  import { Buffer } from "node:buffer";
3
- import { timingSafeEqual } from "node:crypto";
3
+ import { createHash, timingSafeEqual } from "node:crypto";
4
4
  import { isIP } from "node:net";
5
5
  import {
6
6
  AGENT_INSPECTOR_BASE_PATH_META_NAME,
7
+ AGENT_INSPECTOR_PUBLIC_BASE_PATH_HEADER,
8
+ AGENT_INSPECTOR_PUBLIC_PORT_HEADER,
7
9
  getConfiguredBasePath,
8
10
  normalizeBasePath,
9
11
  stripBasePath,
10
12
  stripBasePathFromRequestPath,
13
+ withBasePath,
11
14
  } from "../lib/basePath";
15
+ import {
16
+ MANAGED_INSTANCE_CONTROL_PATH,
17
+ MANAGED_INSTANCE_SHUTDOWN_PATH,
18
+ MANAGED_INSTANCE_TOKEN_HEADER,
19
+ } from "../lib/managedInstance";
12
20
  import { lookupClientByPort } from "./socketTracker";
13
21
 
14
22
  const IDENTITY_HEADERS = {
@@ -23,6 +31,187 @@ const PROXY_TOKEN_HEADER = "x-agent-inspector-proxy-token";
23
31
  const CSRF_HEADER = "x-agent-inspector-csrf";
24
32
  const DEFAULT_MAX_REQUEST_BYTES = 64 * 1024 * 1024;
25
33
  const DEFAULT_REQUEST_TIMEOUT_MS = 120_000;
34
+ const ALIAS_CONTROL_MAX_REQUEST_BYTES = 8 * 1024;
35
+
36
+ export const IDENTITY_PROXY_CONTROL_NAMESPACE = "/.agent-inspector";
37
+ export const IDENTITY_PROXY_ALIAS_CONTROL_PATH = `${IDENTITY_PROXY_CONTROL_NAMESPACE}/base-path-aliases`;
38
+
39
+ export type BasePathAliasResolution = {
40
+ effectiveBasePath: string;
41
+ isAlias: boolean;
42
+ upstreamPath: string;
43
+ };
44
+
45
+ export type BasePathAliasMutationResult =
46
+ | { ok: true; basePath: string }
47
+ | {
48
+ ok: false;
49
+ code: "canonical" | "duplicate" | "invalid" | "not-found" | "overlap" | "reserved";
50
+ error: string;
51
+ };
52
+
53
+ export type BasePathAliasRegistry = {
54
+ canonicalBasePath: string;
55
+ add: (basePath: string) => BasePathAliasMutationResult;
56
+ list: () => readonly string[];
57
+ remove: (basePath: string) => BasePathAliasMutationResult;
58
+ resolve: (rawPath: string) => BasePathAliasResolution;
59
+ };
60
+
61
+ function normalizedAliasCandidate(
62
+ value: string,
63
+ ): { ok: true; basePath: string } | { ok: false; code: "invalid" | "reserved"; error: string } {
64
+ const normalized = normalizeBasePath(value);
65
+ if (normalized.length === 0) {
66
+ return {
67
+ ok: false,
68
+ code: "invalid",
69
+ error: "Base Path aliases must be non-root paths such as /inspector.",
70
+ };
71
+ }
72
+ if (!/^\/[A-Za-z0-9._~-]+(?:\/[A-Za-z0-9._~-]+)*$/u.test(normalized)) {
73
+ return {
74
+ ok: false,
75
+ code: "invalid",
76
+ error: "Base Path aliases may contain only URL-safe path segments.",
77
+ };
78
+ }
79
+ if (normalized.split("/").some((segment) => segment === "." || segment === "..")) {
80
+ return {
81
+ ok: false,
82
+ code: "invalid",
83
+ error: "Base Path aliases cannot contain dot path segments.",
84
+ };
85
+ }
86
+ if (
87
+ normalized === IDENTITY_PROXY_CONTROL_NAMESPACE ||
88
+ normalized.startsWith(`${IDENTITY_PROXY_CONTROL_NAMESPACE}/`)
89
+ ) {
90
+ return {
91
+ ok: false,
92
+ code: "reserved",
93
+ error: `${normalized} collides with the reserved Agent Inspector control path.`,
94
+ };
95
+ }
96
+ const publicNamespace = ["/api", "/assets", "/proxy", "/session", "/livez", "/readyz"].find(
97
+ (namespace) => normalized === namespace || normalized.startsWith(`${namespace}/`),
98
+ );
99
+ if (publicNamespace !== undefined) {
100
+ return {
101
+ ok: false,
102
+ code: "reserved",
103
+ error: `${normalized} collides with the reserved ${publicNamespace} public namespace.`,
104
+ };
105
+ }
106
+ return { ok: true, basePath: normalized };
107
+ }
108
+
109
+ function pathMatchesBasePath(pathname: string, basePath: string): boolean {
110
+ return pathname === basePath || pathname.startsWith(`${basePath}/`);
111
+ }
112
+
113
+ function basePathsOverlap(left: string, right: string): boolean {
114
+ return pathMatchesBasePath(left, right) || pathMatchesBasePath(right, left);
115
+ }
116
+
117
+ /**
118
+ * Create an in-memory registry owned by one identity ingress. Runtime aliases
119
+ * intentionally disappear when that ingress exits.
120
+ */
121
+ export function createBasePathAliasRegistry(
122
+ canonicalBasePath: string,
123
+ initialAliases: readonly string[] = [],
124
+ ): BasePathAliasRegistry {
125
+ const canonical = normalizeBasePath(canonicalBasePath);
126
+ const aliases: string[] = [];
127
+
128
+ const add = (value: string): BasePathAliasMutationResult => {
129
+ const candidate = normalizedAliasCandidate(value);
130
+ if (!candidate.ok) return candidate;
131
+ if (candidate.basePath === canonical) {
132
+ return {
133
+ ok: false,
134
+ code: "canonical",
135
+ error: `${candidate.basePath} is already the canonical Base Path.`,
136
+ };
137
+ }
138
+ if (canonical.length > 0 && basePathsOverlap(candidate.basePath, canonical)) {
139
+ return {
140
+ ok: false,
141
+ code: "overlap",
142
+ error: `${candidate.basePath} overlaps the canonical Base Path ${canonical}.`,
143
+ };
144
+ }
145
+ if (aliases.includes(candidate.basePath)) {
146
+ return {
147
+ ok: false,
148
+ code: "duplicate",
149
+ error: `${candidate.basePath} is already an active runtime alias.`,
150
+ };
151
+ }
152
+ const overlappingAlias = aliases.find((alias) => basePathsOverlap(candidate.basePath, alias));
153
+ if (overlappingAlias !== undefined) {
154
+ return {
155
+ ok: false,
156
+ code: "overlap",
157
+ error: `${candidate.basePath} overlaps the active runtime alias ${overlappingAlias}.`,
158
+ };
159
+ }
160
+ aliases.push(candidate.basePath);
161
+ aliases.sort((left, right) => right.length - left.length || left.localeCompare(right));
162
+ return { ok: true, basePath: candidate.basePath };
163
+ };
164
+
165
+ const remove = (value: string): BasePathAliasMutationResult => {
166
+ const candidate = normalizedAliasCandidate(value);
167
+ if (!candidate.ok) return candidate;
168
+ if (candidate.basePath === canonical) {
169
+ return {
170
+ ok: false,
171
+ code: "canonical",
172
+ error: `${candidate.basePath} is the canonical Base Path and cannot be removed.`,
173
+ };
174
+ }
175
+ const index = aliases.indexOf(candidate.basePath);
176
+ if (index === -1) {
177
+ return {
178
+ ok: false,
179
+ code: "not-found",
180
+ error: `${candidate.basePath} is not an active runtime alias.`,
181
+ };
182
+ }
183
+ aliases.splice(index, 1);
184
+ return { ok: true, basePath: candidate.basePath };
185
+ };
186
+
187
+ const resolve = (rawPath: string): BasePathAliasResolution => {
188
+ const requestUrl = new URL(rawPath, "http://localhost");
189
+ const alias = aliases.find((candidate) => pathMatchesBasePath(requestUrl.pathname, candidate));
190
+ const effectiveBasePath = alias ?? canonical;
191
+ return {
192
+ effectiveBasePath,
193
+ isAlias: alias !== undefined,
194
+ upstreamPath: stripBasePathFromRequestPath(rawPath, effectiveBasePath),
195
+ };
196
+ };
197
+
198
+ const registry: BasePathAliasRegistry = {
199
+ canonicalBasePath: canonical,
200
+ add,
201
+ list: () => [...aliases],
202
+ remove,
203
+ resolve,
204
+ };
205
+ for (const alias of initialAliases) add(alias);
206
+ return registry;
207
+ }
208
+
209
+ export function resolveBasePathAliasRequest(
210
+ registry: BasePathAliasRegistry,
211
+ rawPath: string,
212
+ ): BasePathAliasResolution {
213
+ return registry.resolve(rawPath);
214
+ }
26
215
 
27
216
  type IdentityProxyRuntimeState = {
28
217
  acceptingRequests: boolean;
@@ -83,33 +272,95 @@ function htmlAttributeEscape(value: string): string {
83
272
  .replace(/>/gu, ">");
84
273
  }
85
274
 
86
- export function rewriteHtmlForBasePath(html: string, basePath: string): string {
275
+ function rewriteRootRelativePublicPath(
276
+ value: string,
277
+ effectiveBasePath: string,
278
+ sourceBasePath: string,
279
+ ): string {
280
+ if (!value.startsWith("/") || value.startsWith("//")) return value;
281
+ const normalizedEffective = normalizeBasePath(effectiveBasePath);
282
+ const normalizedSource = normalizeBasePath(sourceBasePath);
283
+ const suffixIndex = value.search(/[?#]/u);
284
+ const pathname = suffixIndex === -1 ? value : value.slice(0, suffixIndex);
285
+ const suffix = suffixIndex === -1 ? "" : value.slice(suffixIndex);
286
+ if (normalizedEffective.length > 0 && pathMatchesBasePath(pathname, normalizedEffective)) {
287
+ return value;
288
+ }
289
+ const rootRelativePath =
290
+ normalizedSource.length > 0 && pathMatchesBasePath(pathname, normalizedSource)
291
+ ? pathname.slice(normalizedSource.length) || "/"
292
+ : pathname;
293
+ const rewritten = withBasePath(rootRelativePath, normalizedEffective);
294
+ const trailingSlash = pathname.endsWith("/") && !rewritten.endsWith("/") ? "/" : "";
295
+ return `${rewritten}${trailingSlash}${suffix}`;
296
+ }
297
+
298
+ function rewriteQuotedAssetPaths(
299
+ source: string,
300
+ effectiveBasePath: string,
301
+ sourceBasePath: string,
302
+ ): string {
303
+ return source.replace(
304
+ /(["'])(\/(?:[^"'/]+\/)*assets\/)/gu,
305
+ (_match: string, quote: string, assetPrefix: string) =>
306
+ `${quote}${rewriteRootRelativePublicPath(assetPrefix, effectiveBasePath, sourceBasePath)}`,
307
+ );
308
+ }
309
+
310
+ export function rewriteHtmlForBasePath(
311
+ html: string,
312
+ basePath: string,
313
+ sourceBasePath = "",
314
+ ): string {
87
315
  const normalized = normalizeBasePath(basePath);
88
- if (normalized.length === 0) return html;
89
316
  const escaped = htmlAttributeEscape(normalized);
90
317
  const meta = `<meta name="${AGENT_INSPECTOR_BASE_PATH_META_NAME}" content="${escaped}">`;
91
- const withMeta = html.includes(`name="${AGENT_INSPECTOR_BASE_PATH_META_NAME}"`)
92
- ? html
93
- : html.replace(/<head(\s[^>]*)?>/iu, (match) => `${match}${meta}`);
94
- const escapedBase = normalized.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
95
- return withMeta.replace(
96
- new RegExp(`\\b(href|src|action)=("|')/(?!/|${escapedBase.slice(1)}(?:/|$))`, "giu"),
97
- (_match: string, attr: string, quote: string) => `${attr}=${quote}${normalized}/`,
318
+ const metaPattern = new RegExp(
319
+ `<meta\\b[^>]*\\bname\\s*=\\s*("|')${AGENT_INSPECTOR_BASE_PATH_META_NAME}\\1[^>]*>`,
320
+ "giu",
321
+ );
322
+ let replacedMeta = false;
323
+ const withReplacedMeta = html.replace(metaPattern, () => {
324
+ if (replacedMeta) return "";
325
+ replacedMeta = true;
326
+ return meta;
327
+ });
328
+ const withMeta = replacedMeta
329
+ ? withReplacedMeta
330
+ : withReplacedMeta.replace(/<head(\s[^>]*)?>/iu, (match) => `${match}${meta}`);
331
+ const withRewrittenAttributes = withMeta.replace(
332
+ /\b(href|src|action)=(["'])(\/(?!\/)[^"']*)\2/giu,
333
+ (_match: string, attribute: string, quote: string, path: string) =>
334
+ `${attribute}=${quote}${rewriteRootRelativePublicPath(path, normalized, sourceBasePath)}${quote}`,
98
335
  );
336
+ return rewriteQuotedAssetPaths(withRewrittenAttributes, normalized, sourceBasePath);
99
337
  }
100
338
 
101
339
  function javaScriptStringEscape(value: string): string {
102
340
  return value.replace(/\\/gu, "\\\\").replace(/"/gu, '\\"');
103
341
  }
104
342
 
105
- export function rewriteJavaScriptForBasePath(source: string, basePath: string): string {
343
+ export function rewriteJavaScriptForBasePath(
344
+ source: string,
345
+ basePath: string,
346
+ sourceBasePath = "",
347
+ ): string {
106
348
  const normalized = normalizeBasePath(basePath);
107
- if (normalized.length === 0) return source;
108
349
  const escaped = javaScriptStringEscape(normalized);
109
- return source
110
- .replace(/\bbasepath:\s*""/gu, `basepath:"${escaped}"`)
111
- .replace(/return"\/"\+([A-Za-z_$][\w$]*)/gu, `return"${escaped}/"+$1`)
112
- .replace(/(["'])\/assets\//gu, `$1${normalized}/assets/`);
350
+ const withRouterBasePath = source.replace(
351
+ /\bbasepath:\s*(["'])[^"']*\1/gu,
352
+ `basepath:"${escaped}"`,
353
+ );
354
+ const withDynamicPaths = withRouterBasePath.replace(
355
+ /return(["'])(\/(?!\/)[^"']*)\1\+([A-Za-z_$][\w$]*)/gu,
356
+ (_match: string, quote: string, path: string, variable: string) =>
357
+ `return${quote}${rewriteRootRelativePublicPath(
358
+ path,
359
+ normalized,
360
+ sourceBasePath,
361
+ )}${quote}+${variable}`,
362
+ );
363
+ return rewriteQuotedAssetPaths(withDynamicPaths, normalized, sourceBasePath);
113
364
  }
114
365
 
115
366
  export type IdentityProxyOptions = {
@@ -137,6 +388,22 @@ export type IdentityProxyOptions = {
137
388
  maxRequestBytes?: number;
138
389
  /** Deadline for receiving and forwarding one request body. */
139
390
  requestTimeoutMs?: number;
391
+ /** Runtime-scoped additional public Base Path mounts. */
392
+ aliasRegistry?: BasePathAliasRegistry;
393
+ /** Private lifecycle identity exposed only to the owning local instance manager. */
394
+ managedInstance?: ManagedInstanceControlOptions;
395
+ };
396
+
397
+ export type ManagedInstanceControlOptions = {
398
+ name: string;
399
+ instanceId: string;
400
+ launchId: string;
401
+ controlToken: string;
402
+ supervisorPid: number;
403
+ startedAt: string;
404
+ publicPort: number;
405
+ upstreamPort: number;
406
+ requestShutdown: () => void;
140
407
  };
141
408
 
142
409
  function isLoopbackPeer(address: string | undefined): boolean {
@@ -163,6 +430,13 @@ function tokensMatch(received: string | null, expected: string | undefined): boo
163
430
  );
164
431
  }
165
432
 
433
+ function managedInstanceTokensMatch(received: string | undefined, expected: string): boolean {
434
+ if (received === undefined || received.length === 0 || expected.length === 0) return false;
435
+ const receivedDigest = createHash("sha256").update(received).digest();
436
+ const expectedDigest = createHash("sha256").update(expected).digest();
437
+ return timingSafeEqual(receivedDigest, expectedDigest);
438
+ }
439
+
166
440
  function firstHeader(value: string | readonly string[] | undefined): string | undefined {
167
441
  return typeof value === "string" ? value : value?.[0];
168
442
  }
@@ -203,8 +477,11 @@ function isSafeMethod(method: string | undefined): boolean {
203
477
  }
204
478
 
205
479
  function isPublicHealthPath(pathname: string): boolean {
206
- const path = stripBasePath(pathname, getConfiguredBasePath(process.env));
207
- return path === "/livez" || path === "/readyz" || path === "/api/health";
480
+ return pathname === "/livez" || pathname === "/readyz" || pathname === "/api/health";
481
+ }
482
+
483
+ function isInstanceManagementPath(pathname: string): boolean {
484
+ return pathname === "/api/instances" || pathname.startsWith("/api/instances/");
208
485
  }
209
486
 
210
487
  function configuredPositiveInteger(value: number | undefined, fallback: number): number {
@@ -220,6 +497,7 @@ function declaredContentLength(value: string | undefined): number | null {
220
497
 
221
498
  export function isBrowserMutationAllowed(input: {
222
499
  path: string;
500
+ basePath?: string;
223
501
  method?: string;
224
502
  host?: string;
225
503
  origin?: string;
@@ -229,7 +507,7 @@ export function isBrowserMutationAllowed(input: {
229
507
  if (isSafeMethod(input.method)) return true;
230
508
  const pathname = stripBasePath(
231
509
  new URL(input.path, "http://localhost").pathname,
232
- getConfiguredBasePath(process.env),
510
+ input.basePath ?? getConfiguredBasePath(process.env),
233
511
  );
234
512
  if (pathname === "/proxy" || pathname.startsWith("/proxy/")) return true;
235
513
 
@@ -250,6 +528,7 @@ export function isBrowserMutationAllowed(input: {
250
528
 
251
529
  export function isIdentityProxyRequestAllowed(input: {
252
530
  path: string;
531
+ basePath?: string;
253
532
  remoteAddress?: string;
254
533
  authorization?: string;
255
534
  proxyAuthorization?: string;
@@ -263,14 +542,17 @@ export function isIdentityProxyRequestAllowed(input: {
263
542
  | "allowUnauthenticatedRemoteProxy"
264
543
  >;
265
544
  }): boolean {
545
+ const pathname = stripBasePath(
546
+ new URL(input.path, "http://localhost").pathname,
547
+ input.basePath ?? getConfiguredBasePath(process.env),
548
+ );
549
+ if (!isLoopbackPeer(input.remoteAddress) && isInstanceManagementPath(pathname)) {
550
+ return tokensMatch(bearerToken(input.authorization), input.options.controlToken);
551
+ }
266
552
  if (input.options.restrictRemoteControl !== true) return true;
267
553
  if (input.options.allowRemoteControl === true) return true;
268
554
  if (isLoopbackPeer(input.remoteAddress)) return true;
269
555
 
270
- const pathname = stripBasePath(
271
- new URL(input.path, "http://localhost").pathname,
272
- getConfiguredBasePath(process.env),
273
- );
274
556
  if (pathname === "/proxy" || pathname.startsWith("/proxy/")) {
275
557
  if (input.options.allowUnauthenticatedRemoteProxy === true) return true;
276
558
  const proxyCredential = bearerToken(input.proxyAuthorization) ?? input.proxyTokenHeader ?? null;
@@ -280,6 +562,267 @@ export function isIdentityProxyRequestAllowed(input: {
280
562
  return tokensMatch(bearerToken(input.authorization), input.options.controlToken);
281
563
  }
282
564
 
565
+ type AliasControlBodyResult =
566
+ | { ok: true; body: string }
567
+ | { ok: false; status: number; error: string };
568
+
569
+ function readAliasControlBody(req: http.IncomingMessage): Promise<AliasControlBodyResult> {
570
+ const contentLength = declaredContentLength(req.headers["content-length"]);
571
+ if (contentLength !== null && (!Number.isFinite(contentLength) || contentLength < 0)) {
572
+ req.resume();
573
+ return Promise.resolve({ ok: false, status: 400, error: "Invalid Content-Length header" });
574
+ }
575
+ if (contentLength !== null && contentLength > ALIAS_CONTROL_MAX_REQUEST_BYTES) {
576
+ req.resume();
577
+ return Promise.resolve({
578
+ ok: false,
579
+ status: 413,
580
+ error: `Alias control body exceeds ${String(ALIAS_CONTROL_MAX_REQUEST_BYTES)} bytes`,
581
+ });
582
+ }
583
+
584
+ return new Promise((resolve) => {
585
+ const chunks: Buffer[] = [];
586
+ let receivedBytes = 0;
587
+ let settled = false;
588
+ const finish = (result: AliasControlBodyResult): void => {
589
+ if (settled) return;
590
+ settled = true;
591
+ resolve(result);
592
+ };
593
+ req.on("data", (chunk: unknown) => {
594
+ if (settled) return;
595
+ const buffer = toBuffer(chunk);
596
+ receivedBytes += buffer.byteLength;
597
+ if (receivedBytes > ALIAS_CONTROL_MAX_REQUEST_BYTES) {
598
+ finish({
599
+ ok: false,
600
+ status: 413,
601
+ error: `Alias control body exceeds ${String(ALIAS_CONTROL_MAX_REQUEST_BYTES)} bytes`,
602
+ });
603
+ return;
604
+ }
605
+ chunks.push(buffer);
606
+ });
607
+ req.on("end", () => {
608
+ finish({ ok: true, body: Buffer.concat(chunks).toString("utf8") });
609
+ });
610
+ req.on("aborted", () => {
611
+ finish({ ok: false, status: 400, error: "Client aborted alias control request" });
612
+ });
613
+ req.on("error", () => {
614
+ finish({ ok: false, status: 400, error: "Alias control request body failed" });
615
+ });
616
+ });
617
+ }
618
+
619
+ function isUnknownRecord(value: unknown): value is Record<string, unknown> {
620
+ return typeof value === "object" && value !== null && !Array.isArray(value);
621
+ }
622
+
623
+ function aliasBasePathFromJson(
624
+ body: string,
625
+ ): { ok: true; basePath: string } | { ok: false; error: string } {
626
+ let parsed: unknown;
627
+ try {
628
+ parsed = JSON.parse(body);
629
+ } catch {
630
+ return { ok: false, error: "Alias control body must be valid JSON." };
631
+ }
632
+ if (!isUnknownRecord(parsed) || typeof parsed.basePath !== "string") {
633
+ return { ok: false, error: 'Alias control body must contain a string "basePath" field.' };
634
+ }
635
+ return { ok: true, basePath: parsed.basePath };
636
+ }
637
+
638
+ function writeAliasControlResponse(
639
+ res: http.ServerResponse,
640
+ registry: BasePathAliasRegistry,
641
+ status: number,
642
+ result:
643
+ | { basePath?: string; code?: string; error?: string }
644
+ | { basePath: string }
645
+ | undefined = undefined,
646
+ extraHeaders: Readonly<Record<string, string>> = {},
647
+ ): void {
648
+ res.writeHead(status, {
649
+ "content-type": "application/json; charset=utf-8",
650
+ "cache-control": "no-store",
651
+ ...extraHeaders,
652
+ });
653
+ res.end(
654
+ JSON.stringify({
655
+ canonicalBasePath: registry.canonicalBasePath,
656
+ aliases: registry.list(),
657
+ runtimeScoped: true,
658
+ ...result,
659
+ }),
660
+ );
661
+ }
662
+
663
+ async function handleAliasControlRequest(
664
+ req: http.IncomingMessage,
665
+ res: http.ServerResponse,
666
+ registry: BasePathAliasRegistry,
667
+ ): Promise<void> {
668
+ if (!isLoopbackPeer(req.socket.remoteAddress)) {
669
+ req.resume();
670
+ writeAliasControlResponse(res, registry, 403, {
671
+ error: "Base Path aliases can only be managed from the local machine.",
672
+ });
673
+ return;
674
+ }
675
+ if (
676
+ !isBrowserMutationAllowed({
677
+ path: req.url ?? IDENTITY_PROXY_ALIAS_CONTROL_PATH,
678
+ basePath: "",
679
+ method: req.method,
680
+ host: req.headers.host,
681
+ origin: req.headers.origin,
682
+ secFetchSite: firstHeader(req.headers["sec-fetch-site"]),
683
+ csrfToken: firstHeader(req.headers[CSRF_HEADER]),
684
+ })
685
+ ) {
686
+ req.resume();
687
+ writeAliasControlResponse(res, registry, 403, {
688
+ error: "Browser mutation requires same-origin CSRF proof.",
689
+ });
690
+ return;
691
+ }
692
+
693
+ const method = (req.method ?? "GET").toUpperCase();
694
+ if (method === "GET") {
695
+ req.resume();
696
+ writeAliasControlResponse(res, registry, 200);
697
+ return;
698
+ }
699
+ if (method !== "POST" && method !== "DELETE") {
700
+ req.resume();
701
+ writeAliasControlResponse(
702
+ res,
703
+ registry,
704
+ 405,
705
+ { error: "Alias control supports GET, POST, and DELETE." },
706
+ { allow: "GET, POST, DELETE" },
707
+ );
708
+ return;
709
+ }
710
+
711
+ const bodyResult = await readAliasControlBody(req);
712
+ if (!bodyResult.ok) {
713
+ writeAliasControlResponse(res, registry, bodyResult.status, { error: bodyResult.error });
714
+ return;
715
+ }
716
+ const input = aliasBasePathFromJson(bodyResult.body);
717
+ if (!input.ok) {
718
+ writeAliasControlResponse(res, registry, 400, { error: input.error });
719
+ return;
720
+ }
721
+ const mutation =
722
+ method === "POST" ? registry.add(input.basePath) : registry.remove(input.basePath);
723
+ if (mutation.ok) {
724
+ writeAliasControlResponse(res, registry, method === "POST" ? 201 : 200, {
725
+ basePath: mutation.basePath,
726
+ });
727
+ return;
728
+ }
729
+ const status =
730
+ mutation.code === "duplicate" || mutation.code === "overlap"
731
+ ? 409
732
+ : mutation.code === "not-found"
733
+ ? 404
734
+ : 400;
735
+ writeAliasControlResponse(res, registry, status, {
736
+ code: mutation.code,
737
+ error: mutation.error,
738
+ });
739
+ }
740
+
741
+ function writeManagedInstanceControlResponse(
742
+ res: http.ServerResponse,
743
+ status: number,
744
+ body: Readonly<Record<string, unknown>>,
745
+ extraHeaders: Readonly<Record<string, string>> = {},
746
+ onFinished: (() => void) | undefined = undefined,
747
+ ): void {
748
+ res.writeHead(status, {
749
+ "content-type": "application/json; charset=utf-8",
750
+ "cache-control": "no-store",
751
+ ...extraHeaders,
752
+ });
753
+ res.end(JSON.stringify(body), onFinished);
754
+ }
755
+
756
+ function handleManagedInstanceControlRequest(
757
+ req: http.IncomingMessage,
758
+ res: http.ServerResponse,
759
+ pathname: string,
760
+ managedInstance: ManagedInstanceControlOptions | undefined,
761
+ ): void {
762
+ req.resume();
763
+ if (managedInstance === undefined) {
764
+ writeManagedInstanceControlResponse(res, 404, { error: "Control endpoint not found" });
765
+ return;
766
+ }
767
+ const authorized = managedInstanceTokensMatch(
768
+ firstHeader(req.headers[MANAGED_INSTANCE_TOKEN_HEADER]),
769
+ managedInstance.controlToken,
770
+ );
771
+ if (!authorized) {
772
+ writeManagedInstanceControlResponse(res, 403, { error: "Instance control access denied" });
773
+ return;
774
+ }
775
+
776
+ const method = (req.method ?? "GET").toUpperCase();
777
+ if (pathname === MANAGED_INSTANCE_CONTROL_PATH) {
778
+ if (method !== "GET") {
779
+ writeManagedInstanceControlResponse(
780
+ res,
781
+ 405,
782
+ { error: "Instance identity supports GET only" },
783
+ { allow: "GET" },
784
+ );
785
+ return;
786
+ }
787
+ writeManagedInstanceControlResponse(res, 200, {
788
+ schemaVersion: 1,
789
+ name: managedInstance.name,
790
+ instanceId: managedInstance.instanceId,
791
+ launchId: managedInstance.launchId,
792
+ supervisorPid: managedInstance.supervisorPid,
793
+ startedAt: managedInstance.startedAt,
794
+ publicPort: managedInstance.publicPort,
795
+ upstreamPort: managedInstance.upstreamPort,
796
+ });
797
+ return;
798
+ }
799
+ if (method !== "POST") {
800
+ writeManagedInstanceControlResponse(
801
+ res,
802
+ 405,
803
+ { error: "Instance shutdown supports POST only" },
804
+ { allow: "POST" },
805
+ );
806
+ return;
807
+ }
808
+ writeManagedInstanceControlResponse(
809
+ res,
810
+ 202,
811
+ { accepted: true, launchId: managedInstance.launchId },
812
+ {},
813
+ () => {
814
+ process.nextTick(managedInstance.requestShutdown);
815
+ },
816
+ );
817
+ }
818
+
819
+ function isIdentityProxyControlPath(pathname: string): boolean {
820
+ return (
821
+ pathname === IDENTITY_PROXY_CONTROL_NAMESPACE ||
822
+ pathname.startsWith(`${IDENTITY_PROXY_CONTROL_NAMESPACE}/`)
823
+ );
824
+ }
825
+
283
826
  /**
284
827
  * Start a tiny native Node HTTP server that:
285
828
  * 1. Reads the client connection's source port (`req.socket.remotePort`).
@@ -306,6 +849,8 @@ export function isIdentityProxyRequestAllowed(input: {
306
849
  export function startIdentityProxy(options: IdentityProxyOptions): Promise<http.Server> {
307
850
  return new Promise((resolve, reject) => {
308
851
  const server = http.createServer();
852
+ const aliasRegistry =
853
+ options.aliasRegistry ?? createBasePathAliasRegistry(getConfiguredBasePath(process.env));
309
854
  const runtimeState: IdentityProxyRuntimeState = {
310
855
  acceptingRequests: true,
311
856
  activeRequests: 0,
@@ -348,7 +893,7 @@ export function startIdentityProxy(options: IdentityProxyOptions): Promise<http.
348
893
  };
349
894
  res.once("finish", release);
350
895
  res.once("close", release);
351
- void handleRequest(req, res, options).catch((err: unknown) => {
896
+ void handleRequest(req, res, options, aliasRegistry).catch((err: unknown) => {
352
897
  const message = err instanceof Error ? err.message : String(err);
353
898
  if (!res.headersSent) {
354
899
  res.writeHead(502, { "content-type": "text/plain" });
@@ -399,9 +944,8 @@ async function handleRequest(
399
944
  req: http.IncomingMessage,
400
945
  res: http.ServerResponse,
401
946
  options: IdentityProxyOptions,
947
+ aliasRegistry: BasePathAliasRegistry,
402
948
  ): Promise<void> {
403
- const basePath = getConfiguredBasePath(process.env);
404
- const upstreamPath = stripBasePathFromRequestPath(req.url ?? "/", basePath);
405
949
  const requestUrl = new URL(req.url ?? "/", "http://localhost");
406
950
  if (!isTrustedIdentityProxyHost(req.headers.host, options)) {
407
951
  res.writeHead(400, {
@@ -413,6 +957,29 @@ async function handleRequest(
413
957
  return;
414
958
  }
415
959
 
960
+ if (requestUrl.pathname === IDENTITY_PROXY_ALIAS_CONTROL_PATH) {
961
+ await handleAliasControlRequest(req, res, aliasRegistry);
962
+ return;
963
+ }
964
+
965
+ if (
966
+ requestUrl.pathname === MANAGED_INSTANCE_CONTROL_PATH ||
967
+ requestUrl.pathname === MANAGED_INSTANCE_SHUTDOWN_PATH
968
+ ) {
969
+ handleManagedInstanceControlRequest(req, res, requestUrl.pathname, options.managedInstance);
970
+ return;
971
+ }
972
+
973
+ if (isIdentityProxyControlPath(requestUrl.pathname)) {
974
+ req.resume();
975
+ writeManagedInstanceControlResponse(res, 404, { error: "Control endpoint not found" });
976
+ return;
977
+ }
978
+
979
+ const resolution = resolveBasePathAliasRequest(aliasRegistry, req.url ?? "/");
980
+ const basePath = resolution.effectiveBasePath;
981
+ const upstreamPath = resolution.upstreamPath;
982
+
416
983
  if (basePath.length > 0 && requestUrl.pathname.startsWith("/assets/")) {
417
984
  res.writeHead(307, {
418
985
  location: `${basePath}${requestUrl.pathname}${requestUrl.search}`,
@@ -426,6 +993,7 @@ async function handleRequest(
426
993
  if (
427
994
  !isBrowserMutationAllowed({
428
995
  path: req.url ?? "/",
996
+ basePath,
429
997
  method: req.method,
430
998
  host: req.headers.host,
431
999
  origin: req.headers.origin,
@@ -445,6 +1013,7 @@ async function handleRequest(
445
1013
  if (
446
1014
  !isIdentityProxyRequestAllowed({
447
1015
  path: req.url ?? "/",
1016
+ basePath,
448
1017
  remoteAddress: req.socket.remoteAddress,
449
1018
  authorization: req.headers.authorization,
450
1019
  proxyAuthorization: req.headers["proxy-authorization"],
@@ -507,11 +1076,20 @@ async function handleRequest(
507
1076
  for (const [name, value] of Object.entries(req.headers)) {
508
1077
  const lowerName = name.toLowerCase();
509
1078
  if (REQUEST_HOP_BY_HOP.has(lowerName)) continue;
510
- if (basePath.length > 0 && lowerName === "accept-encoding") continue;
1079
+ if (
1080
+ basePath.length > 0 &&
1081
+ (lowerName === "accept-encoding" || lowerName === "range" || lowerName === "if-range")
1082
+ )
1083
+ continue;
511
1084
  if (lowerName.startsWith(IDENTITY_HEADER_PREFIX)) continue;
512
1085
  if (value === undefined) continue;
513
1086
  upstreamHeaders[name] = value;
514
1087
  }
1088
+ upstreamHeaders[AGENT_INSPECTOR_PUBLIC_BASE_PATH_HEADER] = basePath.length === 0 ? "/" : basePath;
1089
+ const publicPort = req.socket.localPort;
1090
+ if (publicPort !== undefined && publicPort > 0 && publicPort <= 65_535) {
1091
+ upstreamHeaders[AGENT_INSPECTOR_PUBLIC_PORT_HEADER] = String(publicPort);
1092
+ }
515
1093
  if (identity !== null && identity.pid !== null) {
516
1094
  upstreamHeaders[IDENTITY_HEADERS.pid] = String(identity.pid);
517
1095
  if (identity.port !== null) {
@@ -539,10 +1117,25 @@ async function handleRequest(
539
1117
  headers: upstreamHeaders,
540
1118
  },
541
1119
  (proxyRes) => {
542
- if (terminal) {
543
- proxyRes.resume();
1120
+ if (terminal || res.destroyed) {
1121
+ proxyRes.destroy();
544
1122
  return;
545
1123
  }
1124
+ const destroyUpstreamResponse = (): void => {
1125
+ proxyRes.destroy();
1126
+ proxyRes.socket.destroy();
1127
+ proxyReq.destroy();
1128
+ };
1129
+ const removeDownstreamListeners = (): void => {
1130
+ res.removeListener("close", destroyUpstreamResponse);
1131
+ res.removeListener("error", destroyUpstreamResponse);
1132
+ req.socket.removeListener("close", destroyUpstreamResponse);
1133
+ };
1134
+ res.once("close", destroyUpstreamResponse);
1135
+ res.once("error", destroyUpstreamResponse);
1136
+ req.socket.once("close", destroyUpstreamResponse);
1137
+ proxyRes.once("end", removeDownstreamListeners);
1138
+ proxyRes.once("close", removeDownstreamListeners);
546
1139
  const outHeaders: Record<string, string | string[]> = {};
547
1140
  const contentType = firstHeader(proxyRes.headers["content-type"]);
548
1141
  const shouldRewriteHtml =
@@ -555,9 +1148,33 @@ async function handleRequest(
555
1148
  for (const [name, value] of Object.entries(proxyRes.headers)) {
556
1149
  const lowerName = name.toLowerCase();
557
1150
  if (RESPONSE_HOP_BY_HOP.has(lowerName)) continue;
558
- if (shouldRewriteBody && lowerName === "content-length") continue;
1151
+ if (
1152
+ shouldRewriteBody &&
1153
+ (lowerName === "content-length" ||
1154
+ lowerName === "content-range" ||
1155
+ lowerName === "accept-ranges" ||
1156
+ lowerName === "etag" ||
1157
+ lowerName === "last-modified" ||
1158
+ lowerName === "content-md5" ||
1159
+ lowerName === "digest")
1160
+ )
1161
+ continue;
559
1162
  if (value === undefined) continue;
560
- outHeaders[name] = value;
1163
+ if (
1164
+ lowerName === "location" &&
1165
+ basePath.length > 0 &&
1166
+ typeof value === "string" &&
1167
+ value.startsWith("/") &&
1168
+ !value.startsWith("//")
1169
+ ) {
1170
+ outHeaders[name] = rewriteRootRelativePublicPath(
1171
+ value,
1172
+ basePath,
1173
+ aliasRegistry.canonicalBasePath,
1174
+ );
1175
+ } else {
1176
+ outHeaders[name] = value;
1177
+ }
561
1178
  }
562
1179
  for (const [name, value] of Object.entries(BASE_SECURITY_HEADERS)) {
563
1180
  outHeaders[name] = value;
@@ -565,7 +1182,9 @@ async function handleRequest(
565
1182
  if (contentType?.toLowerCase().includes("text/html") === true) {
566
1183
  outHeaders["content-security-policy"] = DOCUMENT_CONTENT_SECURITY_POLICY;
567
1184
  }
568
- res.writeHead(proxyRes.statusCode ?? 502, outHeaders);
1185
+ const responseStatus =
1186
+ shouldRewriteBody && proxyRes.statusCode === 206 ? 200 : (proxyRes.statusCode ?? 502);
1187
+ res.writeHead(responseStatus, outHeaders);
569
1188
  if (shouldRewriteBody) {
570
1189
  const chunks: Buffer[] = [];
571
1190
  proxyRes.on("data", (chunk: unknown) => {
@@ -573,11 +1192,12 @@ async function handleRequest(
573
1192
  });
574
1193
  proxyRes.on("end", () => {
575
1194
  const body = Buffer.concat(chunks).toString("utf8");
576
- res.end(
1195
+ res.write(
577
1196
  shouldRewriteHtml
578
- ? rewriteHtmlForBasePath(body, basePath)
579
- : rewriteJavaScriptForBasePath(body, basePath),
1197
+ ? rewriteHtmlForBasePath(body, basePath, aliasRegistry.canonicalBasePath)
1198
+ : rewriteJavaScriptForBasePath(body, basePath, aliasRegistry.canonicalBasePath),
580
1199
  );
1200
+ res.end();
581
1201
  });
582
1202
  proxyRes.on("error", () => {
583
1203
  if (!res.writableEnded) res.destroy();
@@ -625,7 +1245,7 @@ async function handleRequest(
625
1245
  proxyReq.on("drain", () => req.resume());
626
1246
  res.once("close", () => {
627
1247
  clearDeadline();
628
- if (!res.writableEnded) proxyReq.destroy();
1248
+ proxyReq.destroy();
629
1249
  });
630
1250
 
631
1251
  let receivedBytes = 0;