@tonyclaw/agent-inspector 3.0.44 → 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 +20743 -16945
  2. package/.output/nitro.json +1 -1
  3. package/.output/public/assets/{CompareDrawer-DQHjxFO6.js → CompareDrawer-B3_TLTJh.js} +1 -1
  4. package/.output/public/assets/{InspectorPet-D1A4505B.js → InspectorPet-Cpn6hv4P.js} +1 -1
  5. package/.output/public/assets/{ProxyViewerContainer-BhpIgfBi.js → ProxyViewerContainer-BEAhuGOJ.js} +27 -27
  6. package/.output/public/assets/{ReplayDialog-C5vmbecv.js → ReplayDialog-B3EKVdJk.js} +1 -1
  7. package/.output/public/assets/{RequestAnatomy-CPGNB8lk.js → RequestAnatomy-COOQjyUC.js} +1 -1
  8. package/.output/public/assets/{ResponseView-DUfrajCm.js → ResponseView-DBCjkwye.js} +1 -1
  9. package/.output/public/assets/{StreamingChunkSequence-nbeCQqOz.js → StreamingChunkSequence-CuXhDgMo.js} +1 -1
  10. package/.output/public/assets/{_sessionId-mw3aA8Bp.js → _sessionId-BJlM3nmt.js} +1 -1
  11. package/.output/public/assets/{_sessionId-CDhRgzf6.js → _sessionId-zbgYl7ZJ.js} +1 -1
  12. package/.output/public/assets/{index-B2eoeT-Z.js → index-BIK4bPTv.js} +1 -1
  13. package/.output/public/assets/{index-C6qZwYlR.js → index-BZo_8koU.js} +15 -15
  14. package/.output/public/assets/{index-CX7_rf7o.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-BfRhTvOd.js → index-xF_NY8Yb.js} +5 -5
  17. package/.output/public/assets/{json-viewer-BLmF1sOZ.js → json-viewer-B-Ao6pwW.js} +1 -1
  18. package/.output/public/assets/{jszip.min-C9rCFoU3.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-U54BAqdp.mjs → _sessionId-CJ99gZFV.mjs} +3 -1
  23. package/.output/server/{_sessionId-E_Dmopc-.mjs → _sessionId-DEjScBpK.mjs} +5 -3
  24. package/.output/server/_ssr/{CompareDrawer-CksatPed.mjs → CompareDrawer-DtOIMeQB.mjs} +4 -2
  25. package/.output/server/_ssr/{InspectorPet-DNc4GQ7z.mjs → InspectorPet-CXc4oCBQ.mjs} +3 -1
  26. package/.output/server/_ssr/{ProxyViewerContainer-B02Ey0p_.mjs → ProxyViewerContainer-DVPlwfkt.mjs} +52 -27
  27. package/.output/server/_ssr/{ReplayDialog-DdMvHJF1.mjs → ReplayDialog-BQEA95qC.mjs} +6 -4
  28. package/.output/server/_ssr/{RequestAnatomy--vh2Z8__.mjs → RequestAnatomy-BMyd8B7Q.mjs} +4 -2
  29. package/.output/server/_ssr/{ResponseView-Dhxc-cKC.mjs → ResponseView-qrTaEIM8.mjs} +4 -2
  30. package/.output/server/_ssr/{StreamingChunkSequence-BG0EQmaD.mjs → StreamingChunkSequence-BFTL8mIy.mjs} +5 -3
  31. package/.output/server/_ssr/{index-lcfulIXa.mjs → index-Bcj1Hcy4.mjs} +4 -2
  32. package/.output/server/_ssr/{index-D0At9nfd.mjs → index-DW-M7XUG.mjs} +3 -1
  33. package/.output/server/_ssr/index.mjs +4 -3
  34. package/.output/server/_ssr/{json-viewer-My1J1_5R.mjs → json-viewer-DiI0Yjsn.mjs} +4 -2
  35. package/.output/server/_ssr/publicBasePathContext-5264L3rX.mjs +148 -0
  36. package/.output/server/_ssr/{router-DWcUFPR3.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 +92 -92
  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 +655 -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-B2chR8ef.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,34 +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, "\\$&");
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}`);
95
331
  const withRewrittenAttributes = withMeta.replace(
96
- new RegExp(`\\b(href|src|action)=("|')/(?!/|${escapedBase.slice(1)}(?:/|$))`, "giu"),
97
- (_match: string, attr: string, quote: string) => `${attr}=${quote}${normalized}/`,
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
  );
99
- return withRewrittenAttributes.replace(/(["'])\/assets\//gu, `$1${normalized}/assets/`);
336
+ return rewriteQuotedAssetPaths(withRewrittenAttributes, normalized, sourceBasePath);
100
337
  }
101
338
 
102
339
  function javaScriptStringEscape(value: string): string {
103
340
  return value.replace(/\\/gu, "\\\\").replace(/"/gu, '\\"');
104
341
  }
105
342
 
106
- export function rewriteJavaScriptForBasePath(source: string, basePath: string): string {
343
+ export function rewriteJavaScriptForBasePath(
344
+ source: string,
345
+ basePath: string,
346
+ sourceBasePath = "",
347
+ ): string {
107
348
  const normalized = normalizeBasePath(basePath);
108
- if (normalized.length === 0) return source;
109
349
  const escaped = javaScriptStringEscape(normalized);
110
- return source
111
- .replace(/\bbasepath:\s*""/gu, `basepath:"${escaped}"`)
112
- .replace(/return"\/"\+([A-Za-z_$][\w$]*)/gu, `return"${escaped}/"+$1`)
113
- .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);
114
364
  }
115
365
 
116
366
  export type IdentityProxyOptions = {
@@ -138,6 +388,22 @@ export type IdentityProxyOptions = {
138
388
  maxRequestBytes?: number;
139
389
  /** Deadline for receiving and forwarding one request body. */
140
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;
141
407
  };
142
408
 
143
409
  function isLoopbackPeer(address: string | undefined): boolean {
@@ -164,6 +430,13 @@ function tokensMatch(received: string | null, expected: string | undefined): boo
164
430
  );
165
431
  }
166
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
+
167
440
  function firstHeader(value: string | readonly string[] | undefined): string | undefined {
168
441
  return typeof value === "string" ? value : value?.[0];
169
442
  }
@@ -204,8 +477,11 @@ function isSafeMethod(method: string | undefined): boolean {
204
477
  }
205
478
 
206
479
  function isPublicHealthPath(pathname: string): boolean {
207
- const path = stripBasePath(pathname, getConfiguredBasePath(process.env));
208
- 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/");
209
485
  }
210
486
 
211
487
  function configuredPositiveInteger(value: number | undefined, fallback: number): number {
@@ -221,6 +497,7 @@ function declaredContentLength(value: string | undefined): number | null {
221
497
 
222
498
  export function isBrowserMutationAllowed(input: {
223
499
  path: string;
500
+ basePath?: string;
224
501
  method?: string;
225
502
  host?: string;
226
503
  origin?: string;
@@ -230,7 +507,7 @@ export function isBrowserMutationAllowed(input: {
230
507
  if (isSafeMethod(input.method)) return true;
231
508
  const pathname = stripBasePath(
232
509
  new URL(input.path, "http://localhost").pathname,
233
- getConfiguredBasePath(process.env),
510
+ input.basePath ?? getConfiguredBasePath(process.env),
234
511
  );
235
512
  if (pathname === "/proxy" || pathname.startsWith("/proxy/")) return true;
236
513
 
@@ -251,6 +528,7 @@ export function isBrowserMutationAllowed(input: {
251
528
 
252
529
  export function isIdentityProxyRequestAllowed(input: {
253
530
  path: string;
531
+ basePath?: string;
254
532
  remoteAddress?: string;
255
533
  authorization?: string;
256
534
  proxyAuthorization?: string;
@@ -264,14 +542,17 @@ export function isIdentityProxyRequestAllowed(input: {
264
542
  | "allowUnauthenticatedRemoteProxy"
265
543
  >;
266
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
+ }
267
552
  if (input.options.restrictRemoteControl !== true) return true;
268
553
  if (input.options.allowRemoteControl === true) return true;
269
554
  if (isLoopbackPeer(input.remoteAddress)) return true;
270
555
 
271
- const pathname = stripBasePath(
272
- new URL(input.path, "http://localhost").pathname,
273
- getConfiguredBasePath(process.env),
274
- );
275
556
  if (pathname === "/proxy" || pathname.startsWith("/proxy/")) {
276
557
  if (input.options.allowUnauthenticatedRemoteProxy === true) return true;
277
558
  const proxyCredential = bearerToken(input.proxyAuthorization) ?? input.proxyTokenHeader ?? null;
@@ -281,6 +562,267 @@ export function isIdentityProxyRequestAllowed(input: {
281
562
  return tokensMatch(bearerToken(input.authorization), input.options.controlToken);
282
563
  }
283
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
+
284
826
  /**
285
827
  * Start a tiny native Node HTTP server that:
286
828
  * 1. Reads the client connection's source port (`req.socket.remotePort`).
@@ -307,6 +849,8 @@ export function isIdentityProxyRequestAllowed(input: {
307
849
  export function startIdentityProxy(options: IdentityProxyOptions): Promise<http.Server> {
308
850
  return new Promise((resolve, reject) => {
309
851
  const server = http.createServer();
852
+ const aliasRegistry =
853
+ options.aliasRegistry ?? createBasePathAliasRegistry(getConfiguredBasePath(process.env));
310
854
  const runtimeState: IdentityProxyRuntimeState = {
311
855
  acceptingRequests: true,
312
856
  activeRequests: 0,
@@ -349,7 +893,7 @@ export function startIdentityProxy(options: IdentityProxyOptions): Promise<http.
349
893
  };
350
894
  res.once("finish", release);
351
895
  res.once("close", release);
352
- void handleRequest(req, res, options).catch((err: unknown) => {
896
+ void handleRequest(req, res, options, aliasRegistry).catch((err: unknown) => {
353
897
  const message = err instanceof Error ? err.message : String(err);
354
898
  if (!res.headersSent) {
355
899
  res.writeHead(502, { "content-type": "text/plain" });
@@ -400,9 +944,8 @@ async function handleRequest(
400
944
  req: http.IncomingMessage,
401
945
  res: http.ServerResponse,
402
946
  options: IdentityProxyOptions,
947
+ aliasRegistry: BasePathAliasRegistry,
403
948
  ): Promise<void> {
404
- const basePath = getConfiguredBasePath(process.env);
405
- const upstreamPath = stripBasePathFromRequestPath(req.url ?? "/", basePath);
406
949
  const requestUrl = new URL(req.url ?? "/", "http://localhost");
407
950
  if (!isTrustedIdentityProxyHost(req.headers.host, options)) {
408
951
  res.writeHead(400, {
@@ -414,6 +957,29 @@ async function handleRequest(
414
957
  return;
415
958
  }
416
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
+
417
983
  if (basePath.length > 0 && requestUrl.pathname.startsWith("/assets/")) {
418
984
  res.writeHead(307, {
419
985
  location: `${basePath}${requestUrl.pathname}${requestUrl.search}`,
@@ -427,6 +993,7 @@ async function handleRequest(
427
993
  if (
428
994
  !isBrowserMutationAllowed({
429
995
  path: req.url ?? "/",
996
+ basePath,
430
997
  method: req.method,
431
998
  host: req.headers.host,
432
999
  origin: req.headers.origin,
@@ -446,6 +1013,7 @@ async function handleRequest(
446
1013
  if (
447
1014
  !isIdentityProxyRequestAllowed({
448
1015
  path: req.url ?? "/",
1016
+ basePath,
449
1017
  remoteAddress: req.socket.remoteAddress,
450
1018
  authorization: req.headers.authorization,
451
1019
  proxyAuthorization: req.headers["proxy-authorization"],
@@ -508,11 +1076,20 @@ async function handleRequest(
508
1076
  for (const [name, value] of Object.entries(req.headers)) {
509
1077
  const lowerName = name.toLowerCase();
510
1078
  if (REQUEST_HOP_BY_HOP.has(lowerName)) continue;
511
- 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;
512
1084
  if (lowerName.startsWith(IDENTITY_HEADER_PREFIX)) continue;
513
1085
  if (value === undefined) continue;
514
1086
  upstreamHeaders[name] = value;
515
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
+ }
516
1093
  if (identity !== null && identity.pid !== null) {
517
1094
  upstreamHeaders[IDENTITY_HEADERS.pid] = String(identity.pid);
518
1095
  if (identity.port !== null) {
@@ -540,10 +1117,25 @@ async function handleRequest(
540
1117
  headers: upstreamHeaders,
541
1118
  },
542
1119
  (proxyRes) => {
543
- if (terminal) {
544
- proxyRes.resume();
1120
+ if (terminal || res.destroyed) {
1121
+ proxyRes.destroy();
545
1122
  return;
546
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);
547
1139
  const outHeaders: Record<string, string | string[]> = {};
548
1140
  const contentType = firstHeader(proxyRes.headers["content-type"]);
549
1141
  const shouldRewriteHtml =
@@ -556,9 +1148,33 @@ async function handleRequest(
556
1148
  for (const [name, value] of Object.entries(proxyRes.headers)) {
557
1149
  const lowerName = name.toLowerCase();
558
1150
  if (RESPONSE_HOP_BY_HOP.has(lowerName)) continue;
559
- 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;
560
1162
  if (value === undefined) continue;
561
- 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
+ }
562
1178
  }
563
1179
  for (const [name, value] of Object.entries(BASE_SECURITY_HEADERS)) {
564
1180
  outHeaders[name] = value;
@@ -566,7 +1182,9 @@ async function handleRequest(
566
1182
  if (contentType?.toLowerCase().includes("text/html") === true) {
567
1183
  outHeaders["content-security-policy"] = DOCUMENT_CONTENT_SECURITY_POLICY;
568
1184
  }
569
- res.writeHead(proxyRes.statusCode ?? 502, outHeaders);
1185
+ const responseStatus =
1186
+ shouldRewriteBody && proxyRes.statusCode === 206 ? 200 : (proxyRes.statusCode ?? 502);
1187
+ res.writeHead(responseStatus, outHeaders);
570
1188
  if (shouldRewriteBody) {
571
1189
  const chunks: Buffer[] = [];
572
1190
  proxyRes.on("data", (chunk: unknown) => {
@@ -574,11 +1192,12 @@ async function handleRequest(
574
1192
  });
575
1193
  proxyRes.on("end", () => {
576
1194
  const body = Buffer.concat(chunks).toString("utf8");
577
- res.end(
1195
+ res.write(
578
1196
  shouldRewriteHtml
579
- ? rewriteHtmlForBasePath(body, basePath)
580
- : rewriteJavaScriptForBasePath(body, basePath),
1197
+ ? rewriteHtmlForBasePath(body, basePath, aliasRegistry.canonicalBasePath)
1198
+ : rewriteJavaScriptForBasePath(body, basePath, aliasRegistry.canonicalBasePath),
581
1199
  );
1200
+ res.end();
582
1201
  });
583
1202
  proxyRes.on("error", () => {
584
1203
  if (!res.writableEnded) res.destroy();
@@ -626,7 +1245,7 @@ async function handleRequest(
626
1245
  proxyReq.on("drain", () => req.resume());
627
1246
  res.once("close", () => {
628
1247
  clearDeadline();
629
- if (!res.writableEnded) proxyReq.destroy();
1248
+ proxyReq.destroy();
630
1249
  });
631
1250
 
632
1251
  let receivedBytes = 0;