@akira-tl/forgerelay 0.9.2 → 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 +17 -0
- package/README.md +9 -3
- package/capabilities/workspace/workspace-checkpoints/GUIDE.md +35 -4
- 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/core/capabilities/workspace-checkpoint.js +9 -0
- package/dist/mcp/server/core/capability-registry.js +1 -1
- 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/dist/server.js +6 -0
- package/dist/workspaces/state/workspace-checkpoints.js +129 -13
- package/docs/configuration.md +100 -13
- package/package.json +2 -2
- package/scripts/ci/architecture.mjs +9 -2
- package/scripts/release/release-gate.test.mjs +6 -0
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));
|
|
@@ -13,6 +13,15 @@ export const workspaceCheckpointInputSchema = z.discriminatedUnion("operation",
|
|
|
13
13
|
operation: z.literal("inspect"),
|
|
14
14
|
checkpointId: z.string().regex(/^cp_[a-f0-9]{10}$/),
|
|
15
15
|
}).strict(),
|
|
16
|
+
z.object({
|
|
17
|
+
operation: z.literal("restore.preflight"),
|
|
18
|
+
checkpointId: z.string().regex(/^cp_[a-f0-9]{10}$/),
|
|
19
|
+
}).strict(),
|
|
20
|
+
z.object({
|
|
21
|
+
operation: z.literal("restore"),
|
|
22
|
+
checkpointId: z.string().regex(/^cp_[a-f0-9]{10}$/),
|
|
23
|
+
expectedCurrentSnapshot: z.string().regex(/^[a-f0-9]{40,64}$/),
|
|
24
|
+
}).strict(),
|
|
16
25
|
z.object({
|
|
17
26
|
operation: z.literal("delete"),
|
|
18
27
|
checkpointId: z.string().regex(/^cp_[a-f0-9]{10}$/),
|
|
@@ -308,7 +308,7 @@ export function createCapabilityRegistry(dependencies) {
|
|
|
308
308
|
...(dependencies.workspaceCheckpoint
|
|
309
309
|
? [{
|
|
310
310
|
name: "workspace.checkpoint",
|
|
311
|
-
description: "Create, list, inspect, or delete immutable Git-backed checkpoints owned by the current persistent Workspace.",
|
|
311
|
+
description: "Create, list, inspect, safely restore, or delete immutable Git-backed checkpoints owned by the current persistent Workspace.",
|
|
312
312
|
guideName: "workspace-checkpoints",
|
|
313
313
|
readGuideBeforeFirstUse: true,
|
|
314
314
|
batchPolicy: "unsupported",
|
|
@@ -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/dist/server.js
CHANGED
|
@@ -156,6 +156,12 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
156
156
|
return { value: await workspaceCheckpoints.list(context.workspaceId, root, input) };
|
|
157
157
|
case "inspect":
|
|
158
158
|
return { value: await workspaceCheckpoints.inspect(context.workspaceId, root, input.checkpointId) };
|
|
159
|
+
case "restore.preflight":
|
|
160
|
+
return { value: await workspaceCheckpoints.preflightRestore(context.workspaceId, root, input.checkpointId) };
|
|
161
|
+
case "restore":
|
|
162
|
+
return {
|
|
163
|
+
value: await workspaceCheckpoints.restore(context.workspaceId, root, input.checkpointId, input.expectedCurrentSnapshot),
|
|
164
|
+
};
|
|
159
165
|
case "delete":
|
|
160
166
|
return { value: await workspaceCheckpoints.delete(context.workspaceId, root, input.checkpointId) };
|
|
161
167
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
2
|
import { mkdirSync, readFileSync, renameSync, rmSync, rmdirSync, writeFileSync } from "node:fs";
|
|
3
|
-
import { mkdtemp, realpath, rm } from "node:fs/promises";
|
|
3
|
+
import { mkdtemp, realpath, rm, writeFile } from "node:fs/promises";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { join, resolve } from "node:path";
|
|
6
6
|
import * as z from "zod/v4";
|
|
@@ -117,6 +117,73 @@ export class WorkspaceCheckpointStore {
|
|
|
117
117
|
await assertCheckpointRef(state.gitCommonDir, id, checkpoint);
|
|
118
118
|
return { workspaceId: id, checkpoint: cloneCheckpoint(checkpoint), ignoredFilesIncluded: false };
|
|
119
119
|
}
|
|
120
|
+
async preflightRestore(workspaceId, workspaceRoot, checkpointId) {
|
|
121
|
+
const id = normalizeWorkspaceId(workspaceId);
|
|
122
|
+
const cpId = normalizeCheckpointId(checkpointId);
|
|
123
|
+
const repository = await resolveRepository(workspaceRoot);
|
|
124
|
+
const state = this.requireState(id);
|
|
125
|
+
await assertSameRepository(state.gitCommonDir, repository.gitCommonDir, id);
|
|
126
|
+
const checkpoint = requireCheckpoint(state, cpId);
|
|
127
|
+
await assertCheckpointRef(state.gitCommonDir, id, checkpoint);
|
|
128
|
+
const [checkpointSnapshot, current] = await Promise.all([
|
|
129
|
+
checkpointTree(repository.gitRoot, checkpoint.commit),
|
|
130
|
+
snapshotWorkingTree(repository.gitRoot),
|
|
131
|
+
]);
|
|
132
|
+
const restoreSummary = summarizeNumstat((await git(repository.gitRoot, [
|
|
133
|
+
"diff",
|
|
134
|
+
"--numstat",
|
|
135
|
+
"-z",
|
|
136
|
+
"--no-renames",
|
|
137
|
+
current.tree,
|
|
138
|
+
checkpointSnapshot,
|
|
139
|
+
"--",
|
|
140
|
+
".",
|
|
141
|
+
], { maxBuffer: 50 * 1024 * 1024 })).stdout);
|
|
142
|
+
return {
|
|
143
|
+
workspaceId: id,
|
|
144
|
+
checkpoint: cloneCheckpoint(checkpoint),
|
|
145
|
+
checkpointSnapshot,
|
|
146
|
+
currentSnapshot: current.tree,
|
|
147
|
+
restoreSummary,
|
|
148
|
+
ignoredFilesIncluded: false,
|
|
149
|
+
stagingStateRestored: false,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
async restore(workspaceId, workspaceRoot, checkpointId, expectedCurrentSnapshot) {
|
|
153
|
+
const id = normalizeWorkspaceId(workspaceId);
|
|
154
|
+
const cpId = normalizeCheckpointId(checkpointId);
|
|
155
|
+
const expected = normalizeSnapshotId(expectedCurrentSnapshot);
|
|
156
|
+
return this.runMutation(id, async () => {
|
|
157
|
+
const repository = await resolveRepository(workspaceRoot);
|
|
158
|
+
const state = this.requireState(id);
|
|
159
|
+
await assertSameRepository(state.gitCommonDir, repository.gitCommonDir, id);
|
|
160
|
+
const checkpoint = requireCheckpoint(state, cpId);
|
|
161
|
+
await assertCheckpointRef(state.gitCommonDir, id, checkpoint);
|
|
162
|
+
const checkpointSnapshot = await checkpointTree(repository.gitRoot, checkpoint.commit);
|
|
163
|
+
const current = await snapshotWorkingTree(repository.gitRoot);
|
|
164
|
+
assertExpectedCurrentSnapshot(expected, current.tree);
|
|
165
|
+
if (current.tree !== checkpointSnapshot) {
|
|
166
|
+
await applyTreeRestore(repository.gitRoot, current.tree, checkpointSnapshot, async () => {
|
|
167
|
+
const immediate = await snapshotWorkingTree(repository.gitRoot);
|
|
168
|
+
assertExpectedCurrentSnapshot(expected, immediate.tree);
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
const restored = await snapshotWorkingTree(repository.gitRoot);
|
|
172
|
+
if (restored.tree !== checkpointSnapshot) {
|
|
173
|
+
throw new Error(`Workspace checkpoint restore did not reproduce checkpoint snapshot ${checkpointSnapshot}; current snapshot is ${restored.tree}.`);
|
|
174
|
+
}
|
|
175
|
+
return {
|
|
176
|
+
workspaceId: id,
|
|
177
|
+
checkpointId: cpId,
|
|
178
|
+
restored: true,
|
|
179
|
+
checkpointSnapshot,
|
|
180
|
+
previousSnapshot: current.tree,
|
|
181
|
+
currentSnapshot: restored.tree,
|
|
182
|
+
ignoredFilesIncluded: false,
|
|
183
|
+
stagingStateRestored: false,
|
|
184
|
+
};
|
|
185
|
+
});
|
|
186
|
+
}
|
|
120
187
|
async delete(workspaceId, workspaceRoot, checkpointId) {
|
|
121
188
|
const id = normalizeWorkspaceId(workspaceId);
|
|
122
189
|
const cpId = normalizeCheckpointId(checkpointId);
|
|
@@ -247,6 +314,21 @@ async function resolveRepository(workspaceRoot) {
|
|
|
247
314
|
return { gitRoot: eligibility.gitRoot, gitCommonDir: commonDir };
|
|
248
315
|
}
|
|
249
316
|
async function createWorkingTreeSnapshot(gitRoot) {
|
|
317
|
+
const snapshot = await snapshotWorkingTree(gitRoot);
|
|
318
|
+
const commit = (await git(gitRoot, [
|
|
319
|
+
"commit-tree",
|
|
320
|
+
snapshot.tree,
|
|
321
|
+
"-p",
|
|
322
|
+
snapshot.baseHead,
|
|
323
|
+
"-m",
|
|
324
|
+
"ForgeRelay persistent workspace checkpoint",
|
|
325
|
+
], { env: checkpointIdentityEnv() })).stdout.trim();
|
|
326
|
+
const numstat = (await git(gitRoot, ["diff", "--numstat", "-z", snapshot.baseHead, commit], {
|
|
327
|
+
maxBuffer: 50 * 1024 * 1024,
|
|
328
|
+
})).stdout;
|
|
329
|
+
return { commit, baseHead: snapshot.baseHead, summary: summarizeNumstat(numstat) };
|
|
330
|
+
}
|
|
331
|
+
async function snapshotWorkingTree(gitRoot) {
|
|
250
332
|
const tempDir = await mkdtemp(join(tmpdir(), "forgerelay-checkpoint-index-"));
|
|
251
333
|
const indexPath = join(tempDir, "index");
|
|
252
334
|
const env = checkpointEnv(indexPath);
|
|
@@ -255,18 +337,35 @@ async function createWorkingTreeSnapshot(gitRoot) {
|
|
|
255
337
|
await git(gitRoot, ["add", "-A", "--", "."], { env });
|
|
256
338
|
const tree = (await git(gitRoot, ["write-tree"], { env })).stdout.trim();
|
|
257
339
|
const baseHead = (await git(gitRoot, ["rev-parse", "--verify", "HEAD^{commit}"])).stdout.trim();
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
340
|
+
return { tree, baseHead };
|
|
341
|
+
}
|
|
342
|
+
finally {
|
|
343
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
async function checkpointTree(gitRoot, checkpointCommit) {
|
|
347
|
+
return (await git(gitRoot, ["rev-parse", "--verify", `${checkpointCommit}^{tree}`])).stdout.trim();
|
|
348
|
+
}
|
|
349
|
+
async function applyTreeRestore(gitRoot, currentTree, checkpointTreeId, verifyImmediatelyBeforeApply) {
|
|
350
|
+
const tempDir = await mkdtemp(join(tmpdir(), "forgerelay-checkpoint-restore-"));
|
|
351
|
+
const patchPath = join(tempDir, "restore.patch");
|
|
352
|
+
try {
|
|
353
|
+
const patch = (await git(gitRoot, [
|
|
354
|
+
"diff",
|
|
355
|
+
"--binary",
|
|
356
|
+
"--full-index",
|
|
357
|
+
"--no-renames",
|
|
358
|
+
"--no-ext-diff",
|
|
359
|
+
"--no-textconv",
|
|
360
|
+
currentTree,
|
|
361
|
+
checkpointTreeId,
|
|
362
|
+
"--",
|
|
363
|
+
".",
|
|
364
|
+
], { maxBuffer: 100 * 1024 * 1024 })).stdout;
|
|
365
|
+
await writeFile(patchPath, patch, { encoding: "utf8", mode: 0o600 });
|
|
366
|
+
await git(gitRoot, ["apply", "--check", "--binary", "--whitespace=nowarn", patchPath]);
|
|
367
|
+
await verifyImmediatelyBeforeApply();
|
|
368
|
+
await git(gitRoot, ["apply", "--binary", "--whitespace=nowarn", patchPath]);
|
|
270
369
|
}
|
|
271
370
|
finally {
|
|
272
371
|
await rm(tempDir, { recursive: true, force: true });
|
|
@@ -296,7 +395,12 @@ function parseStatNumber(value) {
|
|
|
296
395
|
}
|
|
297
396
|
function checkpointEnv(indexPath) {
|
|
298
397
|
return {
|
|
398
|
+
...checkpointIdentityEnv(),
|
|
299
399
|
GIT_INDEX_FILE: indexPath,
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
function checkpointIdentityEnv() {
|
|
403
|
+
return {
|
|
300
404
|
GIT_AUTHOR_NAME: "ForgeRelay",
|
|
301
405
|
GIT_AUTHOR_EMAIL: "forgerelay@users.noreply.local",
|
|
302
406
|
GIT_COMMITTER_NAME: "ForgeRelay",
|
|
@@ -358,6 +462,18 @@ function normalizeCheckpointId(checkpointId) {
|
|
|
358
462
|
throw new Error(`Invalid checkpoint id ${checkpointId}.`);
|
|
359
463
|
return value;
|
|
360
464
|
}
|
|
465
|
+
function normalizeSnapshotId(snapshotId) {
|
|
466
|
+
const value = snapshotId.trim();
|
|
467
|
+
if (!/^[a-f0-9]{40,64}$/.test(value)) {
|
|
468
|
+
throw new Error("Workspace checkpoint snapshot identity must be a Git object id.");
|
|
469
|
+
}
|
|
470
|
+
return value;
|
|
471
|
+
}
|
|
472
|
+
function assertExpectedCurrentSnapshot(expected, actual) {
|
|
473
|
+
if (actual !== expected) {
|
|
474
|
+
throw new Error(`Workspace checkpoint restore refused because the current working snapshot changed: expected ${expected}, found ${actual}. Run restore.preflight again before retrying.`);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
361
477
|
function normalizeCheckpointName(name) {
|
|
362
478
|
const value = name.trim();
|
|
363
479
|
if (!value)
|