@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.
@@ -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 { app, close, subagentProviders } = createServer(config);
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}/mcp`);
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
- await shutdownHttpServer(httpServer, close);
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}/mcp`);
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.logging.trustProxy ? "one hop" : "off"}`);
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(/\/+$/, "");
@@ -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("/auth/cli", express.json({ limit: "4kb" }), (req, res) => {
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("/authorize", authorizationHandler({ provider }));
59
- router.use("/token", tokenHandler({ provider }));
60
- if (provider.clientsStore.registerClient) {
61
- router.use("/register", clientRegistrationHandler({ clientsStore: provider.clientsStore }));
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("/revoke", revocationHandler({ provider }));
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.logging.trustProxy) {
144
- app.set("trust proxy", 1);
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("/mcp-app-assets"))
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("/mcp-app-assets/{*asset}", (_req, res) => {
181
+ app.options(activityPanelAssetsPaths.map((assetPath) => `${assetPath}/{*asset}`), (_req, res) => {
177
182
  setActivityPanelAssetHeaders(res);
178
183
  res.sendStatus(204);
179
184
  });
180
- app.use("/mcp-app-assets", express.static(activityPanelAssetDirectory(), {
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("/healthz", (_req, res) => {
191
+ app.get(healthPaths, (_req, res) => {
187
192
  res.json({ ok: true, name: "forgerelay" });
188
193
  });
189
- app.all("/mcp", async (req, res) => {
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, trustProxyDefault) {
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: trustProxy === undefined ? trustProxyDefault : parseBoolean(trustProxy),
134
+ trustProxy,
135
135
  };
136
136
  }
137
- function shouldTrustOneProxyByDefault(host, publicBaseUrl) {
138
- return isLoopbackHost(host) && !isLoopbackHost(new URL(publicBaseUrl).hostname);
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, shouldTrustOneProxyByDefault(host, publicBaseUrl)),
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
+ }
@@ -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. The first URL is canonical and is used for
65
- generated OAuth/MCP URLs; every configured hostname is included in the derived
66
- Host-header allowlist. MCP App `_meta.ui.domain` uses the canonical URL's origin,
67
- while CSP resource/connect entries include every full public base URL. The full
68
- ordered `publicBaseUrl` list also participates in the MCP App resource cache
69
- identity, so changing any domain or route produces a new `ui://` resource URI.
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` | auto: `1` only for loopback bind + non-loopback public URL; otherwise `0` |
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 automatically trusts exactly one
654
- upstream proxy hop. This matches the normal tunnel/reverse-proxy topology and
655
- keeps OAuth rate limiting aligned with Express client-IP resolution. Set
656
- `FORGERELAY_TRUST_PROXY=0` to disable this inference, or `=1` to enable one-hop
657
- trust explicitly. ForgeRelay never auto-enables proxy trust when binding to
658
- `0.0.0.0` or another directly reachable interface.
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