@akira-tl/forgerelay 0.9.3 → 0.9.4
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/CHANGELOG.md +11 -0
- package/README.md +9 -3
- package/dist/cli/init.js +39 -23
- package/dist/cli/maintenance-prune.js +479 -0
- package/dist/cli/maintenance-retention.js +93 -0
- package/dist/cli/maintenance.js +598 -0
- package/dist/cli/setup-support.js +21 -0
- package/dist/cli.js +29 -6
- package/dist/mcp/oauth/public-url.js +3 -0
- package/dist/mcp/oauth/router.js +14 -9
- package/dist/mcp/server/transport/http-server.js +13 -8
- package/dist/runtime/config/config.js +54 -6
- package/dist/runtime/state/runtime-lease.js +109 -0
- package/docs/configuration.md +100 -13
- package/package.json +2 -2
|
@@ -34,6 +34,9 @@ export function normalizePublicBaseUrl(value) {
|
|
|
34
34
|
parsed.pathname = parsed.pathname.replace(/\/+$/, "");
|
|
35
35
|
return parsed.toString().replace(/\/$/, "");
|
|
36
36
|
}
|
|
37
|
+
export function setupBindAddress(mode) {
|
|
38
|
+
return mode === "lan" ? "0.0.0.0" : "127.0.0.1";
|
|
39
|
+
}
|
|
37
40
|
export function classifyClientFacingBaseUrl(value) {
|
|
38
41
|
const parsed = new URL(normalizePublicBaseUrl(value));
|
|
39
42
|
if (parsed.protocol === "https:")
|
|
@@ -62,6 +65,24 @@ export function validateClientFacingBaseUrls(value) {
|
|
|
62
65
|
export function hasInsecureLanBaseUrl(baseUrls) {
|
|
63
66
|
return baseUrls.some((baseUrl) => classifyClientFacingBaseUrl(baseUrl) === "insecure-lan");
|
|
64
67
|
}
|
|
68
|
+
export function validateLanClientFacingBaseUrls(value) {
|
|
69
|
+
const validation = validateClientFacingBaseUrls(value);
|
|
70
|
+
if (validation)
|
|
71
|
+
return validation;
|
|
72
|
+
const baseUrls = normalizePublicBaseUrlsInput(value ?? "");
|
|
73
|
+
return baseUrls.every((baseUrl) => new URL(baseUrl).protocol === "http:")
|
|
74
|
+
? undefined
|
|
75
|
+
: "Direct LAN mode requires http:// client-facing URLs on a trusted private network.";
|
|
76
|
+
}
|
|
77
|
+
export function validateHttpsProxyBaseUrls(value) {
|
|
78
|
+
const validation = validateClientFacingBaseUrls(value);
|
|
79
|
+
if (validation)
|
|
80
|
+
return validation;
|
|
81
|
+
const baseUrls = normalizePublicBaseUrlsInput(value ?? "");
|
|
82
|
+
return baseUrls.every((baseUrl) => new URL(baseUrl).protocol === "https:")
|
|
83
|
+
? undefined
|
|
84
|
+
: "HTTPS proxy mode requires HTTPS client-facing URLs.";
|
|
85
|
+
}
|
|
65
86
|
function isPrivateNetworkHost(hostname) {
|
|
66
87
|
const host = hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
|
67
88
|
if (host === "localhost" || !host.includes(".") || host.endsWith(".local") || host.endsWith(".lan") || host.endsWith(".home.arpa")) {
|
package/dist/cli.js
CHANGED
|
@@ -9,7 +9,9 @@ import { join, resolve } from "node:path";
|
|
|
9
9
|
import { fileURLToPath } from "node:url";
|
|
10
10
|
import * as prompts from "@clack/prompts";
|
|
11
11
|
import { loadConfig } from "./runtime/config/config.js";
|
|
12
|
+
import { acquireRuntimeLease } from "./runtime/state/runtime-lease.js";
|
|
12
13
|
import { runInit } from "./cli/init.js";
|
|
14
|
+
import { runMaintenanceCommand } from "./cli/maintenance.js";
|
|
13
15
|
import { runHooksCommand } from "./mcp/hooks/hook-cli.js";
|
|
14
16
|
import { executeSubagentSession } from "./subagents/sessions/execution.js";
|
|
15
17
|
import { SubagentDeliveryMailbox } from "./subagents/sessions/delivery-mailbox.js";
|
|
@@ -50,6 +52,9 @@ async function main(argv) {
|
|
|
50
52
|
case "auth":
|
|
51
53
|
await runAuthCommand(args);
|
|
52
54
|
return;
|
|
55
|
+
case "maintenance":
|
|
56
|
+
runMaintenanceCommand(args);
|
|
57
|
+
return;
|
|
53
58
|
case "help":
|
|
54
59
|
printHelp();
|
|
55
60
|
return;
|
|
@@ -61,7 +66,7 @@ async function main(argv) {
|
|
|
61
66
|
function normalizeCommand(command) {
|
|
62
67
|
if (!command || command === "serve" || command === "start")
|
|
63
68
|
return "serve";
|
|
64
|
-
if (command === "init" || command === "doctor" || command === "config" || command === "hooks" || command === "agents" || command === "auth")
|
|
69
|
+
if (command === "init" || command === "doctor" || command === "config" || command === "hooks" || command === "agents" || command === "auth" || command === "maintenance")
|
|
65
70
|
return command;
|
|
66
71
|
if (command === "help" || command === "--help" || command === "-h")
|
|
67
72
|
return "help";
|
|
@@ -104,9 +109,18 @@ async function serve() {
|
|
|
104
109
|
}
|
|
105
110
|
const { createServer } = await import("./server.js");
|
|
106
111
|
const config = loadConfig();
|
|
107
|
-
const
|
|
112
|
+
const runtimeLease = acquireRuntimeLease(config.stateDir);
|
|
113
|
+
let server;
|
|
114
|
+
try {
|
|
115
|
+
server = createServer(config);
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
runtimeLease.release();
|
|
119
|
+
throw error;
|
|
120
|
+
}
|
|
121
|
+
const { app, close, subagentProviders } = server;
|
|
108
122
|
const httpServer = app.listen(config.port, config.host, () => {
|
|
109
|
-
console.log(`forgerelay listening on http://${config.host}:${config.port}
|
|
123
|
+
console.log(`forgerelay listening on http://${config.host}:${config.port}${publicEndpointUrl(config.publicBaseUrl, "mcp").pathname}`);
|
|
110
124
|
console.log(`client-facing base url: ${config.publicBaseUrl}`);
|
|
111
125
|
console.log(`allowed roots: ${config.allowedRoots.join(", ")}`);
|
|
112
126
|
console.log(`allowed hosts: ${config.allowedHosts.join(", ")}`);
|
|
@@ -120,11 +134,19 @@ async function serve() {
|
|
|
120
134
|
}
|
|
121
135
|
});
|
|
122
136
|
let shuttingDown = false;
|
|
137
|
+
const releaseRuntimeLease = () => runtimeLease.release();
|
|
138
|
+
process.once("exit", releaseRuntimeLease);
|
|
123
139
|
const shutdown = async () => {
|
|
124
140
|
if (shuttingDown)
|
|
125
141
|
return;
|
|
126
142
|
shuttingDown = true;
|
|
127
|
-
|
|
143
|
+
try {
|
|
144
|
+
await shutdownHttpServer(httpServer, close);
|
|
145
|
+
}
|
|
146
|
+
finally {
|
|
147
|
+
process.removeListener("exit", releaseRuntimeLease);
|
|
148
|
+
runtimeLease.release();
|
|
149
|
+
}
|
|
128
150
|
process.exit(0);
|
|
129
151
|
};
|
|
130
152
|
const handleShutdown = () => {
|
|
@@ -308,13 +330,13 @@ async function runDoctor() {
|
|
|
308
330
|
console.log(`SQLite native dependency: ${checkSqliteNative()}`);
|
|
309
331
|
try {
|
|
310
332
|
const config = loadConfig();
|
|
311
|
-
console.log(`Bind MCP URL: http://${config.host}:${config.port}
|
|
333
|
+
console.log(`Bind MCP URL: http://${config.host}:${config.port}${publicEndpointUrl(config.publicBaseUrl, "mcp").pathname}`);
|
|
312
334
|
console.log(`Client-facing base URLs: ${config.publicBaseUrls.join(", ")}`);
|
|
313
335
|
console.log(`Client-facing base URL: ${config.publicBaseUrl}`);
|
|
314
336
|
console.log(`Client-facing MCP URL: ${publicEndpointUrl(config.publicBaseUrl, "mcp").toString()}`);
|
|
315
337
|
console.log(`Tool mode: ${config.toolMode}`);
|
|
316
338
|
console.log(`Widgets: ${config.widgets}`);
|
|
317
|
-
console.log(`Trust proxy: ${config.
|
|
339
|
+
console.log(`Trust proxy: ${config.proxyTrust === false ? "off" : config.proxyTrust.join(", ")}`);
|
|
318
340
|
console.log(`Artifacts: ${config.artifactsEnabled ? "enabled" : "disabled"}`);
|
|
319
341
|
console.log(`Subagents: ${config.subagents ? "enabled" : "disabled"}`);
|
|
320
342
|
console.log(`Skills: ${config.skillsEnabled ? "enabled" : "disabled"}`);
|
|
@@ -370,6 +392,7 @@ function printHelp() {
|
|
|
370
392
|
" forgerelay auth test <alias>",
|
|
371
393
|
" forgerelay auth rename <old-alias> <new-alias>",
|
|
372
394
|
" forgerelay auth remove <alias>",
|
|
395
|
+
" forgerelay maintenance inspect [--json]",
|
|
373
396
|
" forgerelay -v, --version Print the installed version",
|
|
374
397
|
"",
|
|
375
398
|
"For temporary tunnels:",
|
|
@@ -7,6 +7,9 @@ export function publicEndpointUrl(baseUrl, suffix) {
|
|
|
7
7
|
url.hash = "";
|
|
8
8
|
return url;
|
|
9
9
|
}
|
|
10
|
+
export function publicEndpointPaths(baseUrls, suffix) {
|
|
11
|
+
return Array.from(new Set(baseUrls.map((baseUrl) => publicEndpointUrl(baseUrl, suffix).pathname)));
|
|
12
|
+
}
|
|
10
13
|
export function oauthAuthorizationServerMetadataPath(issuerUrl) {
|
|
11
14
|
const issuer = new URL(issuerUrl instanceof URL ? issuerUrl.href : issuerUrl);
|
|
12
15
|
const issuerPath = issuer.pathname === "/" ? "" : issuer.pathname.replace(/\/+$/, "");
|
package/dist/mcp/oauth/router.js
CHANGED
|
@@ -5,9 +5,9 @@ import { tokenHandler } from "@modelcontextprotocol/sdk/server/auth/handlers/tok
|
|
|
5
5
|
import { clientRegistrationHandler } from "@modelcontextprotocol/sdk/server/auth/handlers/register.js";
|
|
6
6
|
import { revocationHandler } from "@modelcontextprotocol/sdk/server/auth/handlers/revoke.js";
|
|
7
7
|
import { metadataHandler } from "@modelcontextprotocol/sdk/server/auth/handlers/metadata.js";
|
|
8
|
-
import { oauthAuthorizationServerMetadataPath, publicEndpointUrl, } from "./public-url.js";
|
|
8
|
+
import { oauthAuthorizationServerMetadataPath, publicEndpointPaths, publicEndpointUrl, } from "./public-url.js";
|
|
9
9
|
export function createForgeRelayAuthRouter(options) {
|
|
10
|
-
const { provider, cliAuthenticationProvider, instanceId, issuerUrl, resourceServerUrl, scopesSupported, resourceName, } = options;
|
|
10
|
+
const { provider, cliAuthenticationProvider, instanceId, issuerUrl, resourceServerUrl, routeBaseUrls = [issuerUrl], scopesSupported, resourceName, } = options;
|
|
11
11
|
const authorizationEndpoint = publicEndpointUrl(issuerUrl, "authorize");
|
|
12
12
|
const tokenEndpoint = publicEndpointUrl(issuerUrl, "token");
|
|
13
13
|
const registrationEndpoint = provider.clientsStore.registerClient
|
|
@@ -16,6 +16,11 @@ export function createForgeRelayAuthRouter(options) {
|
|
|
16
16
|
const revocationEndpoint = provider.revokeToken
|
|
17
17
|
? publicEndpointUrl(issuerUrl, "revoke")
|
|
18
18
|
: undefined;
|
|
19
|
+
const cliAuthenticationPaths = publicEndpointPaths(routeBaseUrls, "auth/cli");
|
|
20
|
+
const authorizationPaths = publicEndpointPaths(routeBaseUrls, "authorize");
|
|
21
|
+
const tokenPaths = publicEndpointPaths(routeBaseUrls, "token");
|
|
22
|
+
const registrationPaths = publicEndpointPaths(routeBaseUrls, "register");
|
|
23
|
+
const revocationPaths = publicEndpointPaths(routeBaseUrls, "revoke");
|
|
19
24
|
const oauthMetadata = {
|
|
20
25
|
...createOAuthMetadata({
|
|
21
26
|
provider,
|
|
@@ -36,7 +41,7 @@ export function createForgeRelayAuthRouter(options) {
|
|
|
36
41
|
};
|
|
37
42
|
const router = express.Router();
|
|
38
43
|
if (cliAuthenticationProvider) {
|
|
39
|
-
router.post(
|
|
44
|
+
router.post(cliAuthenticationPaths, express.json({ limit: "4kb" }), (req, res) => {
|
|
40
45
|
const ownerToken = typeof req.body?.owner_token === "string" ? req.body.owner_token : undefined;
|
|
41
46
|
const refreshToken = typeof req.body?.refresh_token === "string" ? req.body.refresh_token : undefined;
|
|
42
47
|
if ((ownerToken ? 1 : 0) + (refreshToken ? 1 : 0) !== 1) {
|
|
@@ -55,13 +60,13 @@ export function createForgeRelayAuthRouter(options) {
|
|
|
55
60
|
res.status(200).json({ ...tokens, ...(instanceId ? { instance_id: instanceId } : {}) });
|
|
56
61
|
});
|
|
57
62
|
}
|
|
58
|
-
router.use(
|
|
59
|
-
router.use(
|
|
60
|
-
if (provider.clientsStore.registerClient) {
|
|
61
|
-
router.use(
|
|
63
|
+
router.use(authorizationPaths, authorizationHandler({ provider }));
|
|
64
|
+
router.use(tokenPaths, tokenHandler({ provider }));
|
|
65
|
+
if (provider.clientsStore.registerClient && registrationEndpoint) {
|
|
66
|
+
router.use(registrationPaths, clientRegistrationHandler({ clientsStore: provider.clientsStore }));
|
|
62
67
|
}
|
|
63
|
-
if (provider.revokeToken) {
|
|
64
|
-
router.use(
|
|
68
|
+
if (provider.revokeToken && revocationEndpoint) {
|
|
69
|
+
router.use(revocationPaths, revocationHandler({ provider }));
|
|
65
70
|
}
|
|
66
71
|
router.use(oauthAuthorizationServerMetadataPath(issuerUrl), metadataHandler(oauthMetadata));
|
|
67
72
|
router.use(new URL(getOAuthProtectedResourceMetadataUrl(resourceServerUrl)).pathname, metadataHandler(protectedResourceMetadata));
|
|
@@ -16,7 +16,7 @@ import { createOpenAIIncomingArtifactAdapter } from "../../artifacts/incoming-ar
|
|
|
16
16
|
import { logEvent, requestPath, transportSessionIdPrefix } from "../../../runtime/logging/logger.js";
|
|
17
17
|
import { SingleUserOAuthProvider } from "../../oauth/oauth-provider.js";
|
|
18
18
|
import { createForgeRelayAuthRouter } from "../../oauth/router.js";
|
|
19
|
-
import { publicEndpointUrl } from "../../oauth/public-url.js";
|
|
19
|
+
import { publicEndpointPaths, publicEndpointUrl } from "../../oauth/public-url.js";
|
|
20
20
|
import { McpTransportRegistry } from "./mcp-sessions.js";
|
|
21
21
|
import { ProcessManager } from "../../process/process-sessions.js";
|
|
22
22
|
import { createReviewCheckpointManager } from "../../../workspaces/review/review-checkpoints.js";
|
|
@@ -45,7 +45,11 @@ export function createHttpServer(config, options, createMcpServer) {
|
|
|
45
45
|
const transports = new McpTransportRegistry({
|
|
46
46
|
maxTransports: MAX_MCP_TRANSPORT_SESSIONS,
|
|
47
47
|
});
|
|
48
|
+
const routeBaseUrls = config.publicBaseUrls.map((baseUrl) => new URL(baseUrl));
|
|
48
49
|
const mcpUrl = publicEndpointUrl(config.publicBaseUrl, "mcp");
|
|
50
|
+
const mcpPaths = publicEndpointPaths(routeBaseUrls, "mcp");
|
|
51
|
+
const activityPanelAssetsPaths = publicEndpointPaths(routeBaseUrls, "mcp-app-assets");
|
|
52
|
+
const healthPaths = publicEndpointPaths(routeBaseUrls, "healthz");
|
|
49
53
|
const resourceServerUrl = resourceUrlFromServerUrl(mcpUrl);
|
|
50
54
|
const oauthProvider = new SingleUserOAuthProvider(config.oauth, mcpUrl, config.stateDir);
|
|
51
55
|
const bearerAuth = requireBearerAuth({
|
|
@@ -140,8 +144,8 @@ export function createHttpServer(config, options, createMcpServer) {
|
|
|
140
144
|
}, MCP_TRANSPORT_CLEANUP_INTERVAL_MS);
|
|
141
145
|
transportCleanupTimer.unref();
|
|
142
146
|
logRuntimeResources();
|
|
143
|
-
if (config.
|
|
144
|
-
app.set("trust proxy",
|
|
147
|
+
if (config.proxyTrust !== false) {
|
|
148
|
+
app.set("trust proxy", config.proxyTrust);
|
|
145
149
|
}
|
|
146
150
|
app.use((req, res, next) => {
|
|
147
151
|
const requestId = randomUUID();
|
|
@@ -151,7 +155,7 @@ export function createHttpServer(config, options, createMcpServer) {
|
|
|
151
155
|
const path = requestPath(req);
|
|
152
156
|
if (!config.logging.requests)
|
|
153
157
|
return;
|
|
154
|
-
if (!config.logging.assets && path.startsWith(
|
|
158
|
+
if (!config.logging.assets && activityPanelAssetsPaths.some((assetPath) => path.startsWith(assetPath)))
|
|
155
159
|
return;
|
|
156
160
|
logEvent(config.logging, "info", "http_request", {
|
|
157
161
|
requestId,
|
|
@@ -170,23 +174,24 @@ export function createHttpServer(config, options, createMcpServer) {
|
|
|
170
174
|
instanceId: config.instanceId,
|
|
171
175
|
issuerUrl: new URL(config.publicBaseUrl),
|
|
172
176
|
resourceServerUrl,
|
|
177
|
+
routeBaseUrls,
|
|
173
178
|
scopesSupported: config.oauth.scopes,
|
|
174
179
|
resourceName: "ForgeRelay",
|
|
175
180
|
}));
|
|
176
|
-
app.options(
|
|
181
|
+
app.options(activityPanelAssetsPaths.map((assetPath) => `${assetPath}/{*asset}`), (_req, res) => {
|
|
177
182
|
setActivityPanelAssetHeaders(res);
|
|
178
183
|
res.sendStatus(204);
|
|
179
184
|
});
|
|
180
|
-
app.use(
|
|
185
|
+
app.use(activityPanelAssetsPaths, express.static(activityPanelAssetDirectory(), {
|
|
181
186
|
immutable: true,
|
|
182
187
|
maxAge: "1y",
|
|
183
188
|
fallthrough: false,
|
|
184
189
|
setHeaders: setActivityPanelAssetHeaders,
|
|
185
190
|
}));
|
|
186
|
-
app.get(
|
|
191
|
+
app.get(healthPaths, (_req, res) => {
|
|
187
192
|
res.json({ ok: true, name: "forgerelay" });
|
|
188
193
|
});
|
|
189
|
-
app.all(
|
|
194
|
+
app.all(mcpPaths, async (req, res) => {
|
|
190
195
|
const requestId = res.locals.requestId;
|
|
191
196
|
const transportSessionId = req.header("mcp-session-id");
|
|
192
197
|
const initializeRequest = req.method === "POST" && isInitializeRequest(req.body);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isIP } from "node:net";
|
|
1
2
|
import { homedir } from "node:os";
|
|
2
3
|
import { join, resolve } from "node:path";
|
|
3
4
|
import { expandHomePath } from "../../mcp/filesystem/roots.js";
|
|
@@ -118,12 +119,11 @@ function parseNonNegativeInteger(value, fallback, name) {
|
|
|
118
119
|
}
|
|
119
120
|
return parsed;
|
|
120
121
|
}
|
|
121
|
-
function parseLoggingConfig(env,
|
|
122
|
+
function parseLoggingConfig(env, trustProxy) {
|
|
122
123
|
const format = parseLogFormat(productEnv(env, "LOG_FORMAT"));
|
|
123
124
|
const requests = productEnv(env, "LOG_REQUESTS");
|
|
124
125
|
const toolCalls = productEnv(env, "LOG_TOOL_CALLS");
|
|
125
126
|
const shellCommands = productEnv(env, "LOG_SHELL_COMMANDS");
|
|
126
|
-
const trustProxy = productEnv(env, "TRUST_PROXY");
|
|
127
127
|
return {
|
|
128
128
|
level: parseLogLevel(productEnv(env, "LOG_LEVEL")),
|
|
129
129
|
format,
|
|
@@ -131,11 +131,57 @@ function parseLoggingConfig(env, trustProxyDefault) {
|
|
|
131
131
|
assets: parseBoolean(productEnv(env, "LOG_ASSETS")),
|
|
132
132
|
toolCalls: toolCalls === undefined ? true : parseBoolean(toolCalls),
|
|
133
133
|
shellCommands: shellCommands === undefined ? format === "pretty" : parseBoolean(shellCommands),
|
|
134
|
-
trustProxy
|
|
134
|
+
trustProxy,
|
|
135
135
|
};
|
|
136
136
|
}
|
|
137
|
-
function
|
|
138
|
-
|
|
137
|
+
function resolveProxyTrust(env, config, host, publicBaseUrl) {
|
|
138
|
+
const legacyTrustProxy = productEnv(env, "TRUST_PROXY");
|
|
139
|
+
if (legacyTrustProxy !== undefined) {
|
|
140
|
+
if (!parseBoolean(legacyTrustProxy))
|
|
141
|
+
return false;
|
|
142
|
+
if (!isLoopbackHost(host)) {
|
|
143
|
+
throw new Error("FORGERELAY_TRUST_PROXY=1 is only safe with a loopback bind. Use FORGERELAY_TRUSTED_PROXIES with explicit proxy IP addresses or CIDRs for LAN binds.");
|
|
144
|
+
}
|
|
145
|
+
return ["loopback"];
|
|
146
|
+
}
|
|
147
|
+
const envTrustedProxies = productEnv(env, "TRUSTED_PROXIES");
|
|
148
|
+
const explicitTrustedProxies = parseTrustedProxies(envTrustedProxies === undefined ? config.trustedProxies : envTrustedProxies);
|
|
149
|
+
if (explicitTrustedProxies !== undefined)
|
|
150
|
+
return explicitTrustedProxies;
|
|
151
|
+
return isLoopbackHost(host) && !isLoopbackHost(new URL(publicBaseUrl).hostname)
|
|
152
|
+
? ["loopback"]
|
|
153
|
+
: false;
|
|
154
|
+
}
|
|
155
|
+
function parseTrustedProxies(value) {
|
|
156
|
+
if (value === undefined)
|
|
157
|
+
return undefined;
|
|
158
|
+
const entries = (Array.isArray(value) ? value : value.split(","))
|
|
159
|
+
.map((entry) => entry.trim())
|
|
160
|
+
.filter(Boolean);
|
|
161
|
+
if (entries.length === 0)
|
|
162
|
+
return undefined;
|
|
163
|
+
if (entries.some((entry) => !isTrustedProxyAddress(entry))) {
|
|
164
|
+
throw new Error("FORGERELAY_TRUSTED_PROXIES must list trusted proxy IP addresses or CIDRs; only the internal `loopback` alias is also accepted.");
|
|
165
|
+
}
|
|
166
|
+
return Array.from(new Set(entries));
|
|
167
|
+
}
|
|
168
|
+
function isTrustedProxyAddress(value) {
|
|
169
|
+
if (value === "loopback")
|
|
170
|
+
return true;
|
|
171
|
+
if (value === "*" || value === "0.0.0.0/0" || value === "::/0")
|
|
172
|
+
return false;
|
|
173
|
+
if (isIP(value) !== 0)
|
|
174
|
+
return true;
|
|
175
|
+
const slashIndex = value.lastIndexOf("/");
|
|
176
|
+
if (slashIndex <= 0 || slashIndex === value.length - 1)
|
|
177
|
+
return false;
|
|
178
|
+
const address = value.slice(0, slashIndex);
|
|
179
|
+
const prefixText = value.slice(slashIndex + 1);
|
|
180
|
+
const family = isIP(address);
|
|
181
|
+
const prefix = Number(prefixText);
|
|
182
|
+
if (!Number.isInteger(prefix) || prefix < 0)
|
|
183
|
+
return false;
|
|
184
|
+
return family === 4 ? prefix <= 32 : family === 6 ? prefix <= 128 : false;
|
|
139
185
|
}
|
|
140
186
|
function isLoopbackHost(host) {
|
|
141
187
|
const normalized = host.trim().toLowerCase().replace(/^\[(.*)\]$/, "$1");
|
|
@@ -221,6 +267,7 @@ export function loadConfig(env = process.env) {
|
|
|
221
267
|
const port = parsePort(env.PORT ?? files.config.port);
|
|
222
268
|
const publicDeployment = resolvePublicDeployment(env, files.config, host, port);
|
|
223
269
|
const publicBaseUrl = publicDeployment.canonicalBaseUrl;
|
|
270
|
+
const proxyTrust = resolveProxyTrust(env, files.config, host, publicBaseUrl);
|
|
224
271
|
const derivedAllowedHosts = [
|
|
225
272
|
"localhost",
|
|
226
273
|
"127.0.0.1",
|
|
@@ -239,6 +286,7 @@ export function loadConfig(env = process.env) {
|
|
|
239
286
|
allowedHosts: parseAllowedHosts(productEnv(env, "ALLOWED_HOSTS"), derivedAllowedHosts),
|
|
240
287
|
publicBaseUrl,
|
|
241
288
|
publicBaseUrls: publicDeployment.baseUrls,
|
|
289
|
+
proxyTrust,
|
|
242
290
|
toolMode: parseToolMode(env),
|
|
243
291
|
workflowInstructions: parseWorkflowInstructions(productEnv(env, "WORKFLOW_INSTRUCTIONS"), files.config.workflowInstructions),
|
|
244
292
|
appendInstructions: parseAppendInstructions(productEnv(env, "APPEND_INSTRUCTIONS"), files.config.appendInstructions),
|
|
@@ -265,7 +313,7 @@ export function loadConfig(env = process.env) {
|
|
|
265
313
|
agentDir: resolve(expandHomePath(productEnv(env, "AGENT_DIR") ?? files.config.agentDir ?? defaultAgentDir())),
|
|
266
314
|
systemInstructionsPath: parseSystemInstructionsPath(productEnv(env, "SYSTEM_INSTRUCTIONS_PATH") ?? files.config.systemInstructionsPath),
|
|
267
315
|
hooks: mergeHookConfigs(parseHookConfig(files.config.hooks), parseHookConfig(files.hooks), files.hookFiles),
|
|
268
|
-
logging: parseLoggingConfig(env,
|
|
316
|
+
logging: parseLoggingConfig(env, proxyTrust !== false),
|
|
269
317
|
};
|
|
270
318
|
}
|
|
271
319
|
function numberConfigValue(value) {
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { closeSync, constants as fsConstants, mkdirSync, openSync, readFileSync, rmSync, writeFileSync, } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
const RUNTIME_LEASE_FILE = "forgerelay-runtime.lock";
|
|
5
|
+
export function runtimeLeasePath(stateDir) {
|
|
6
|
+
return join(stateDir, RUNTIME_LEASE_FILE);
|
|
7
|
+
}
|
|
8
|
+
export function acquireRuntimeLease(stateDir) {
|
|
9
|
+
mkdirSync(stateDir, { recursive: true, mode: 0o700 });
|
|
10
|
+
const path = runtimeLeasePath(stateDir);
|
|
11
|
+
const record = {
|
|
12
|
+
pid: process.pid,
|
|
13
|
+
token: randomBytes(16).toString("hex"),
|
|
14
|
+
startedAt: new Date().toISOString(),
|
|
15
|
+
};
|
|
16
|
+
const serialized = `${JSON.stringify(record)}\n`;
|
|
17
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
18
|
+
let fd;
|
|
19
|
+
try {
|
|
20
|
+
fd = openSync(path, fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_WRONLY, 0o600);
|
|
21
|
+
writeFileSync(fd, serialized, "utf8");
|
|
22
|
+
closeSync(fd);
|
|
23
|
+
fd = undefined;
|
|
24
|
+
return {
|
|
25
|
+
path,
|
|
26
|
+
pid: record.pid,
|
|
27
|
+
release: () => releaseRuntimeLease(path, record),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
if (fd !== undefined)
|
|
32
|
+
closeSync(fd);
|
|
33
|
+
if (!isErrno(error, "EEXIST"))
|
|
34
|
+
throw error;
|
|
35
|
+
const inspection = inspectRuntimeLease(stateDir);
|
|
36
|
+
if (inspection.active || inspection.malformed) {
|
|
37
|
+
const owner = inspection.pid === undefined ? "an unknown process" : `PID ${inspection.pid}`;
|
|
38
|
+
throw new Error(`ForgeRelay state is already in use by ${owner}: ${path}`);
|
|
39
|
+
}
|
|
40
|
+
rmSync(path, { force: true });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
throw new Error(`Unable to acquire ForgeRelay runtime lease: ${path}`);
|
|
44
|
+
}
|
|
45
|
+
export function inspectRuntimeLease(stateDir) {
|
|
46
|
+
const path = runtimeLeasePath(stateDir);
|
|
47
|
+
let raw;
|
|
48
|
+
try {
|
|
49
|
+
raw = readFileSync(path, "utf8");
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
if (isErrno(error, "ENOENT")) {
|
|
53
|
+
return { path, active: false, stale: false, malformed: false };
|
|
54
|
+
}
|
|
55
|
+
throw error;
|
|
56
|
+
}
|
|
57
|
+
const record = parseRecord(raw);
|
|
58
|
+
if (!record)
|
|
59
|
+
return { path, active: true, stale: false, malformed: true };
|
|
60
|
+
const active = processIsAlive(record.pid);
|
|
61
|
+
return {
|
|
62
|
+
path,
|
|
63
|
+
active,
|
|
64
|
+
stale: !active,
|
|
65
|
+
pid: record.pid,
|
|
66
|
+
malformed: false,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
function releaseRuntimeLease(path, expected) {
|
|
70
|
+
let current;
|
|
71
|
+
try {
|
|
72
|
+
current = parseRecord(readFileSync(path, "utf8"));
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
if (isErrno(error, "ENOENT"))
|
|
76
|
+
return;
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
if (!current || current.pid !== expected.pid || current.token !== expected.token)
|
|
80
|
+
return;
|
|
81
|
+
rmSync(path, { force: true });
|
|
82
|
+
}
|
|
83
|
+
function parseRecord(raw) {
|
|
84
|
+
try {
|
|
85
|
+
const value = JSON.parse(raw);
|
|
86
|
+
if (!Number.isSafeInteger(value.pid) || (value.pid ?? 0) <= 0)
|
|
87
|
+
return undefined;
|
|
88
|
+
if (typeof value.token !== "string" || value.token.length < 16)
|
|
89
|
+
return undefined;
|
|
90
|
+
if (typeof value.startedAt !== "string" || !Number.isFinite(Date.parse(value.startedAt)))
|
|
91
|
+
return undefined;
|
|
92
|
+
return { pid: value.pid, token: value.token, startedAt: value.startedAt };
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
function processIsAlive(pid) {
|
|
99
|
+
try {
|
|
100
|
+
process.kill(pid, 0);
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
return !isErrno(error, "ESRCH");
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
function isErrno(error, code) {
|
|
108
|
+
return error instanceof Error && "code" in error && error.code === code;
|
|
109
|
+
}
|
package/docs/configuration.md
CHANGED
|
@@ -26,6 +26,7 @@ npx @akira-tl/forgerelay serve
|
|
|
26
26
|
npx @akira-tl/forgerelay doctor
|
|
27
27
|
npx @akira-tl/forgerelay config get
|
|
28
28
|
npx @akira-tl/forgerelay config set publicBaseUrl https://forge.example.com/forgerelay/main,https://forge-alt.example.com/relay
|
|
29
|
+
npx @akira-tl/forgerelay maintenance inspect
|
|
29
30
|
```
|
|
30
31
|
|
|
31
32
|
## Environment variables
|
|
@@ -46,6 +47,73 @@ The public environment-variable prefix is `FORGERELAY_*`.
|
|
|
46
47
|
| `FORGERELAY_WORKTREE_ROOT` | Managed worktree directory. New default: `~/.forgerelay/worktrees`. |
|
|
47
48
|
| `FORGERELAY_WORKFLOW_INSTRUCTIONS` | Replace the built-in workflow policy while retaining the capability contract. |
|
|
48
49
|
| `FORGERELAY_APPEND_INSTRUCTIONS` | Append project/operator workflow policy. |
|
|
50
|
+
| `FORGERELAY_RETENTION_HISTORY_DAYS` | Optional owner-authorized age window for related Activity/Audit, Host Turn, and durable Bash history. Unset means unlimited retention. |
|
|
51
|
+
| `FORGERELAY_RETENTION_ORPHANED_ADMIN` | Optional boolean authorization for provably orphaned/rebuildable administrative state. Unset/false means do not reclaim it. |
|
|
52
|
+
|
|
53
|
+
### Retention inspection and prune
|
|
54
|
+
|
|
55
|
+
Durable ForgeRelay history is retained without an age limit by default. The owner can
|
|
56
|
+
inspect what is retained, protected, and potentially reclaimable without starting the
|
|
57
|
+
server:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
npx @akira-tl/forgerelay maintenance inspect
|
|
61
|
+
npx @akira-tl/forgerelay maintenance inspect --json
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Inspection is read-only: it snapshots an existing SQLite database and WAL into a
|
|
65
|
+
temporary directory before opening that snapshot read-only, reads bounded
|
|
66
|
+
Workspace-private Task/checkpoint metadata, and uses read-only Git ref/worktree
|
|
67
|
+
queries. It does not create or migrate a missing/older source database, touch Workspace
|
|
68
|
+
last-used timestamps, move refs, or alter worktrees.
|
|
69
|
+
|
|
70
|
+
After reviewing that report, the owner can explicitly apply the configured policy:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
npx @akira-tl/forgerelay maintenance prune
|
|
74
|
+
npx @akira-tl/forgerelay maintenance prune --json
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
`maintenance prune` is manual and policy-gated. With neither `historyDays` nor
|
|
78
|
+
`orphanedAdministrativeState` authorized it is a no-op. It acquires the ForgeRelay
|
|
79
|
+
runtime lease before any destructive work, so it refuses to run concurrently with a
|
|
80
|
+
server using the same state directory. Historical cleanup is performed in whole Host
|
|
81
|
+
Turn cohorts: a turn is retained if any Activity is recent, nonterminal, owns a running
|
|
82
|
+
Bash stream, or is tied to an active Subagent Run. When removed Activity payloads share
|
|
83
|
+
a segmented log with retained payloads, retained bytes are compacted to a new segment
|
|
84
|
+
before the old unreferenced segment is deleted.
|
|
85
|
+
|
|
86
|
+
Administrative cleanup is deliberately conservative. It removes only review refs that
|
|
87
|
+
can be proven orphaned and rebuildable, plus empty orphan Workspace-state directories.
|
|
88
|
+
Canonical and alias Workspace identities, any non-empty private Workspace state,
|
|
89
|
+
Workspace Tasks, named checkpoints, active/runtime state, managed worktrees, and
|
|
90
|
+
managed branches remain protected. Repeating the same prune is idempotent and should
|
|
91
|
+
report no newly eligible state after the first successful pass.
|
|
92
|
+
|
|
93
|
+
The persisted policy shape is:
|
|
94
|
+
|
|
95
|
+
```json
|
|
96
|
+
{
|
|
97
|
+
"retention": {
|
|
98
|
+
"historyDays": 30,
|
|
99
|
+
"orphanedAdministrativeState": false
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
`historyDays` is one shared cutoff for related Activity/Audit, Host Turn, and durable
|
|
105
|
+
Bash history so later owner-authorized maintenance can preserve cross-store
|
|
106
|
+
consistency. Omit it for unlimited durable-history retention. The environment override
|
|
107
|
+
is `FORGERELAY_RETENTION_HISTORY_DAYS`. `orphanedAdministrativeState` is a separate
|
|
108
|
+
explicit authorization, with `FORGERELAY_RETENTION_ORPHANED_ADMIN` as its environment
|
|
109
|
+
override.
|
|
110
|
+
|
|
111
|
+
Named Workspace checkpoints and Workspace Tasks are protected by this policy:
|
|
112
|
+
checkpoint removal remains an explicit checkpoint operation, and retention maintenance
|
|
113
|
+
never treats Tasks as disposable history. Existing automatic runtime GC is separate:
|
|
114
|
+
it bounds rebuildable/in-memory runtime resources and expires stale context-delivery
|
|
115
|
+
bookkeeping; it does **not** age-prune durable Activity/Audit history, durable Bash
|
|
116
|
+
output, named checkpoints, persistent Workspace identity, or Task Lists.
|
|
49
117
|
|
|
50
118
|
### Routed and multi-origin public deployments
|
|
51
119
|
|
|
@@ -61,12 +129,18 @@ or multiple URLs as an array:
|
|
|
61
129
|
}
|
|
62
130
|
```
|
|
63
131
|
|
|
64
|
-
Each entry keeps its own route prefix.
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
132
|
+
Each entry keeps its own route prefix. Every configured pathname is an accepted
|
|
133
|
+
inbound operational route boundary; the first URL remains canonical for generated
|
|
134
|
+
OAuth/MCP metadata and links. For example, if the only configured URL is
|
|
135
|
+
`https://forge.example.com/forgerelay/main`, MCP, OAuth operations, health, and MCP App
|
|
136
|
+
assets are served below `/forgerelay/main/*`; naked `/mcp`, `/authorize`, `/token`,
|
|
137
|
+
`/healthz`, and `/mcp-app-assets/*` are not parallel deployment routes. Standards-based
|
|
138
|
+
OAuth/MCP discovery metadata remains under its required `/.well-known/...` paths.
|
|
139
|
+
Every configured hostname is included in the derived Host-header allowlist. MCP App
|
|
140
|
+
`_meta.ui.domain` uses the canonical URL's origin, while CSP resource/connect entries
|
|
141
|
+
include every full public base URL. The full ordered `publicBaseUrl` list also
|
|
142
|
+
participates in the MCP App resource cache identity, so changing any domain or route
|
|
143
|
+
produces a new `ui://` resource URI.
|
|
70
144
|
|
|
71
145
|
A single persisted string remains fully supported, so existing configs require no
|
|
72
146
|
migration. For environment configuration, use a comma-separated list in
|
|
@@ -632,7 +706,8 @@ from the normal Skill paths; ForgeRelay does not reserve, delete, or rewrite it.
|
|
|
632
706
|
| `FORGERELAY_LOG_ASSETS` | `0` |
|
|
633
707
|
| `FORGERELAY_LOG_TOOL_CALLS` | `1` |
|
|
634
708
|
| `FORGERELAY_LOG_SHELL_COMMANDS` | `1` in `pretty`, `0` in `json` |
|
|
635
|
-
| `FORGERELAY_TRUST_PROXY` |
|
|
709
|
+
| `FORGERELAY_TRUST_PROXY` | legacy compatibility override; `1` is accepted only with a loopback bind |
|
|
710
|
+
| `FORGERELAY_TRUSTED_PROXIES` | explicit comma-separated trusted proxy IP addresses/CIDRs; unset by default |
|
|
636
711
|
|
|
637
712
|
`pretty` is the human-facing local console format. It uses terminal-aware color,
|
|
638
713
|
short timestamps, workspace-first context, and compact operation results while
|
|
@@ -650,12 +725,24 @@ overridden, JSON mode preserves request logging and omits shell command previews
|
|
|
650
725
|
these format-specific defaults when set.
|
|
651
726
|
|
|
652
727
|
When ForgeRelay binds to loopback (`127.0.0.1`, `::1`, or `localhost`) but is
|
|
653
|
-
configured with a non-loopback public URL, it
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
`
|
|
657
|
-
|
|
658
|
-
|
|
728
|
+
configured with a non-loopback public URL, it trusts only the loopback proxy source.
|
|
729
|
+
This matches the normal local tunnel/reverse-proxy topology while preventing a public
|
|
730
|
+
or LAN client from becoming trusted merely because it supplied forwarded headers.
|
|
731
|
+
`forgerelay init` uses this model for **HTTPS reverse proxy / tunnel** mode and binds
|
|
732
|
+
that mode to `127.0.0.1`; **Direct LAN** mode binds to `0.0.0.0` and does not trust a
|
|
733
|
+
proxy by default.
|
|
734
|
+
|
|
735
|
+
Set `FORGERELAY_TRUST_PROXY=0` to disable inferred loopback trust. The legacy
|
|
736
|
+
`FORGERELAY_TRUST_PROXY=1` form is accepted only when ForgeRelay itself is bound to
|
|
737
|
+
loopback. For an advanced topology that intentionally combines direct LAN reachability
|
|
738
|
+
with a reverse proxy, list only the actual proxy source addresses or CIDRs, for example:
|
|
739
|
+
|
|
740
|
+
```bash
|
|
741
|
+
FORGERELAY_TRUSTED_PROXIES="127.0.0.1,10.20.30.0/24" forgerelay serve
|
|
742
|
+
```
|
|
743
|
+
|
|
744
|
+
The same list may be persisted as `trustedProxies` in `config.json`. Wildcard/global
|
|
745
|
+
trust is rejected; do not replace this with Express `trust proxy=true` on a LAN bind.
|
|
659
746
|
|
|
660
747
|
## Environment-only example
|
|
661
748
|
|