@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.
- package/.output/cli.js +20743 -16945
- package/.output/nitro.json +1 -1
- package/.output/public/assets/{CompareDrawer-DQHjxFO6.js → CompareDrawer-B3_TLTJh.js} +1 -1
- package/.output/public/assets/{InspectorPet-D1A4505B.js → InspectorPet-Cpn6hv4P.js} +1 -1
- package/.output/public/assets/{ProxyViewerContainer-BhpIgfBi.js → ProxyViewerContainer-BEAhuGOJ.js} +27 -27
- package/.output/public/assets/{ReplayDialog-C5vmbecv.js → ReplayDialog-B3EKVdJk.js} +1 -1
- package/.output/public/assets/{RequestAnatomy-CPGNB8lk.js → RequestAnatomy-COOQjyUC.js} +1 -1
- package/.output/public/assets/{ResponseView-DUfrajCm.js → ResponseView-DBCjkwye.js} +1 -1
- package/.output/public/assets/{StreamingChunkSequence-nbeCQqOz.js → StreamingChunkSequence-CuXhDgMo.js} +1 -1
- package/.output/public/assets/{_sessionId-mw3aA8Bp.js → _sessionId-BJlM3nmt.js} +1 -1
- package/.output/public/assets/{_sessionId-CDhRgzf6.js → _sessionId-zbgYl7ZJ.js} +1 -1
- package/.output/public/assets/{index-B2eoeT-Z.js → index-BIK4bPTv.js} +1 -1
- package/.output/public/assets/{index-C6qZwYlR.js → index-BZo_8koU.js} +15 -15
- package/.output/public/assets/{index-CX7_rf7o.js → index-D2Qw6Ri2.js} +1 -1
- package/.output/public/assets/{index-BWjeqxbk.css → index-ZWschkCh.css} +1 -1
- package/.output/public/assets/{index-BfRhTvOd.js → index-xF_NY8Yb.js} +5 -5
- package/.output/public/assets/{json-viewer-BLmF1sOZ.js → json-viewer-B-Ao6pwW.js} +1 -1
- package/.output/public/assets/{jszip.min-C9rCFoU3.js → jszip.min-B_rfAUCe.js} +1 -1
- package/.output/server/_libs/modelcontextprotocol__core.mjs +25 -25
- package/.output/server/_libs/modelcontextprotocol__server.mjs +2 -2
- package/.output/server/_libs/zod.mjs +6 -6
- package/.output/server/{_sessionId-U54BAqdp.mjs → _sessionId-CJ99gZFV.mjs} +3 -1
- package/.output/server/{_sessionId-E_Dmopc-.mjs → _sessionId-DEjScBpK.mjs} +5 -3
- package/.output/server/_ssr/{CompareDrawer-CksatPed.mjs → CompareDrawer-DtOIMeQB.mjs} +4 -2
- package/.output/server/_ssr/{InspectorPet-DNc4GQ7z.mjs → InspectorPet-CXc4oCBQ.mjs} +3 -1
- package/.output/server/_ssr/{ProxyViewerContainer-B02Ey0p_.mjs → ProxyViewerContainer-DVPlwfkt.mjs} +52 -27
- package/.output/server/_ssr/{ReplayDialog-DdMvHJF1.mjs → ReplayDialog-BQEA95qC.mjs} +6 -4
- package/.output/server/_ssr/{RequestAnatomy--vh2Z8__.mjs → RequestAnatomy-BMyd8B7Q.mjs} +4 -2
- package/.output/server/_ssr/{ResponseView-Dhxc-cKC.mjs → ResponseView-qrTaEIM8.mjs} +4 -2
- package/.output/server/_ssr/{StreamingChunkSequence-BG0EQmaD.mjs → StreamingChunkSequence-BFTL8mIy.mjs} +5 -3
- package/.output/server/_ssr/{index-lcfulIXa.mjs → index-Bcj1Hcy4.mjs} +4 -2
- package/.output/server/_ssr/{index-D0At9nfd.mjs → index-DW-M7XUG.mjs} +3 -1
- package/.output/server/_ssr/index.mjs +4 -3
- package/.output/server/_ssr/{json-viewer-My1J1_5R.mjs → json-viewer-DiI0Yjsn.mjs} +4 -2
- package/.output/server/_ssr/publicBasePathContext-5264L3rX.mjs +148 -0
- package/.output/server/_ssr/{router-DWcUFPR3.mjs → router-BJ_ltLim.mjs} +2432 -475
- package/.output/server/_ssr/start-C6MjPjxp.mjs +57 -0
- package/.output/server/_tanstack-start-manifest_v-C6pXXX9R.mjs +4 -0
- package/.output/server/index.mjs +92 -92
- package/.output/workers/logFinalizer.worker.js +4 -0
- package/.output/workers/sessionWorkerEntry.js +4 -0
- package/README.md +221 -25
- package/docs/instance-control.openapi.yaml +306 -0
- package/package.json +4 -1
- package/src/cli/alias.ts +440 -0
- package/src/cli/instance.ts +1037 -0
- package/src/cli/instanceArgs.ts +266 -0
- package/src/cli/instanceControl.ts +183 -0
- package/src/cli/instanceModel.ts +275 -0
- package/src/cli/instanceRegistry.ts +740 -0
- package/src/cli/onboard.ts +123 -35
- package/src/cli/onboardTarget.ts +179 -0
- package/src/cli/templates/codex-skill-onboard.ts +76 -28
- package/src/cli/templates/skill-onboard.ts +93 -59
- package/src/cli.ts +154 -16
- package/src/components/ProxyViewer.tsx +13 -5
- package/src/components/providers/SettingsDialog.tsx +13 -8
- package/src/lib/basePath.ts +8 -2
- package/src/lib/codingAgentEndpoints.ts +16 -0
- package/src/lib/instanceContract.ts +220 -0
- package/src/lib/managedInstance.ts +104 -0
- package/src/lib/publicBasePathContext.ts +107 -0
- package/src/mcp/instanceHandlers.ts +149 -0
- package/src/mcp/loopback.ts +16 -5
- package/src/mcp/mode.ts +25 -0
- package/src/mcp/server.ts +258 -18
- package/src/mcp/toolHandlers.ts +2 -2
- package/src/proxy/identityProxy.ts +655 -36
- package/src/proxy/runtimeShutdown.ts +73 -1
- package/src/proxy/sessionInfo.ts +5 -4
- package/src/routes/api/-instances.ts +109 -0
- package/src/routes/api/instances.$name.connection.ts +12 -0
- package/src/routes/api/instances.$name.restart.ts +23 -0
- package/src/routes/api/instances.$name.start.ts +18 -0
- package/src/routes/api/instances.$name.stop.ts +23 -0
- package/src/routes/api/instances.$name.ts +12 -0
- package/src/routes/api/instances.ts +16 -0
- package/src/start.ts +10 -0
- package/.output/server/_ssr/start-HYkvq4Ni.mjs +0 -4
- 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
|
-
|
|
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
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
97
|
-
(_match: string,
|
|
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
|
|
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(
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
208
|
-
|
|
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 (
|
|
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.
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
1248
|
+
proxyReq.destroy();
|
|
630
1249
|
});
|
|
631
1250
|
|
|
632
1251
|
let receivedBytes = 0;
|