@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/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));
@@ -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.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
+ }
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
- const commit = (await git(gitRoot, [
259
- "commit-tree",
260
- tree,
261
- "-p",
262
- baseHead,
263
- "-m",
264
- "ForgeRelay persistent workspace checkpoint",
265
- ], { env })).stdout.trim();
266
- const numstat = (await git(gitRoot, ["diff", "--numstat", "-z", baseHead, commit], {
267
- maxBuffer: 50 * 1024 * 1024,
268
- })).stdout;
269
- return { commit, baseHead, summary: summarizeNumstat(numstat) };
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)