@akira-tl/forgerelay 0.6.0 → 0.6.1

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 CHANGED
@@ -4,6 +4,25 @@ All notable ForgeRelay changes are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.6.1] - 2026-08-28
8
+
9
+ ### Added
10
+
11
+ - Added Workspace Relay: register a remote ForgeRelay with `forgerelay auth`, then open a remote Workspace through the existing MCP surface with `open_workspace(..., relay="<alias>")`; direct targets and system-SSH-routed targets share the same authentication and MCP protocol.
12
+ - Added CLI remote authentication with hidden owner-token input, explicit `--token`, and SSH-assisted `--ssh-auth`; `-J` accepts one final SSH target or a full comma-separated jump route while the remote service target remains one independent `host:port`/URL value.
13
+ - Added remote routing for Workspace reads and mutations, Bash/process lifecycle and durable output, optional capabilities, Skills, Hooks, and Host Activity snapshot/detail/output queries while keeping execution facts owned by the execution ForgeRelay.
14
+ - Added restart-safe remote identity and Workspace routing: stable remote instance IDs survive alias/target changes, access tokens refresh through the existing token store, SSH tunnels are rebuilt on fresh random loopback ports, and relayed Workspace IDs remain stable across Gateway restarts.
15
+
16
+ ### Changed
17
+
18
+ - Activity Panel bootstrap can reconstruct Workspace presentation from the execution instance across independent MCP connections, allowing relayed Host Turns and App-only Activity queries to follow the execution Workspace without requiring one long-lived transport session.
19
+ - ForgeRelay authentication and relayed-Workspace route files now use lock-protected read/modify/write updates with atomic replacement so concurrent CLI/server sessions do not overwrite each other.
20
+
21
+ ### Security
22
+
23
+ - Remote targets, SSH routes, temporary tunnel ports, owner tokens, access tokens, refresh tokens, and execution-side Workspace IDs stay out of the model-facing relay contract; the Host sees the configured relay alias and Gateway `rws_...` identity instead.
24
+ - SSH-assisted authentication retrieves the remote owner token only into the initiating process, then reuses the normal CLI token exchange; runtime forwarding binds loopback only, and failed direct/SSH/remote operations never silently fall back to a local or different remote Workspace.
25
+
7
26
  ## [0.6.0] - 2026-08-27
8
27
 
9
28
  ### Added
package/README.md CHANGED
@@ -158,7 +158,7 @@ You can rebase and verify inside the worktree, then retry the close.
158
158
 
159
159
  Hook 是 ForgeRelay 的自动生命周期规则。首选方式是一个 Hook 一个文件:全局放在 `~/.forgerelay/hooks/<hook-name>.json`,项目放在 `<repo>/.forgerelay/hooks/<hook-name>.json`。文件名就是 Hook 名,方便直接从目录看出每条规则的用途;全局与项目规则组合执行,不需要额外批准。
160
160
 
161
- 例如项目里的 `.forgerelay/hooks/release-tag-local-ci.json` 可以要求稳定版本 tag push 前先完成本地发布检查:
161
+ 例如项目里的 `.forgerelay/hooks/release-tag-gate.json` 可以在稳定版本 tag push 前执行轻量发布门禁:
162
162
 
163
163
  ```json
164
164
  {
@@ -173,7 +173,7 @@ Hook 是 ForgeRelay 的自动生命周期规则。首选方式是一个 Hook 一
173
173
  }
174
174
  ```
175
175
 
176
- 耗时的 `npm run release:verify` 应先在已提交的 release-ready HEAD 上运行,它会写入绑定 HEAD/package version 的本地 release proof。命中 `BeforeTool` 后,Hook 只快速验证该 proof、clean working tree(含 untracked)与 tag 指向;成功才继续原始 `git push`,失败则直接阻断。这样发布 gate 不依赖一个持续数分钟的 MCP 请求。Hook 结果会回到 Agent,Agent 应向用户说明重要 Hook 是否通过或阻断了操作。`report:false` 可以隐藏不重要的成功报告,但阻断失败始终可见。
176
+ 命中 `BeforeTool` 后,Hook 只快速验证 clean working tree(含 untracked)、tag package version 一致,以及本地 tag 指向当前 HEAD;成功才继续原始 `git push`,失败则直接阻断。Hook 不运行、也不要求本地 CI。tag 推送后由 GitHub Actions Linux/macOS/Windows 矩阵执行权威验证,全部通过后才进入发布。`npm run release:verify` 仅作为可选的本地云端复现工具。Hook 结果会回到 Agent,Agent 应向用户说明重要 Hook 是否通过或阻断了操作。`report:false` 可以隐藏不重要的成功报告,但阻断失败始终可见。
177
177
 
178
178
  旧的 inline `hooks` 和聚合 `hooks.json` 仍兼容;新配置建议都用独立 `hooks/*.json` 文件。
179
179
 
@@ -293,18 +293,15 @@ npm run release:check
293
293
  npm run release:patch
294
294
  npm run release:minor
295
295
  npm run release:major
296
- npm run release:verify
297
296
  ```
298
297
 
299
- Daily branch pushes do not run cloud CI. When preparing a release, commit the
300
- release-ready tree and run the full local release verification on that clean HEAD.
301
- `release:verify` includes a focused parity pass in an isolated Node 22.19.0 environment
302
- with its own `npm ci`, matching the cloud CI runtime for native addons and high-risk
303
- LSP lifecycle tests, then writes the local proof consumed by the tag-push Hook. Pushing a matching
304
- `vX.Y.Z` tag to `Akira-TL/forgerelay` is the only cloud CI and publish trigger:
305
- GitHub Actions runs the reusable multi-platform CI, then publishes
306
- `@akira-tl/forgerelay` and creates the matching GitHub Release only after CI
307
- succeeds.
298
+ Daily branch pushes do not run cloud CI. Commit the release-ready tree, push it to
299
+ `main`, then push the matching `vX.Y.Z` tag. That tag is the only cloud CI and publish
300
+ trigger: GitHub Actions runs the reusable Linux/macOS/Windows verification matrix,
301
+ then publishes `@akira-tl/forgerelay` and creates the matching GitHub Release only
302
+ after all platforms succeed. `npm run release:verify` remains available only when a
303
+ cloud failure needs local reproduction against the pinned Node runtime; it is not a
304
+ release prerequisite.
308
305
 
309
306
  See [Versioning and Release Management](docs/versioning.md) for the bootstrap and
310
307
  Trusted Publishing setup.
@@ -42,7 +42,7 @@ const snapshotOutputSchema = {
42
42
  activities: z.array(activitySummarySchema),
43
43
  [ACTIVITY_PANEL_WORKSPACE_META_KEY]: z.record(z.string(), z.unknown()).optional(),
44
44
  };
45
- export function registerActivityQueryTools(server, queries, connectionScopeId, panelMeta = {}, panelDefaultExpanded = false, logging, workspacePanelState) {
45
+ export function registerActivityQueryTools(server, queries, connectionScopeId, panelMeta = {}, panelDefaultExpanded = false, logging, workspacePanelState, relay) {
46
46
  const panelUi = typeof panelMeta.ui === "object" && panelMeta.ui !== null
47
47
  ? panelMeta.ui
48
48
  : {};
@@ -69,7 +69,23 @@ export function registerActivityQueryTools(server, queries, connectionScopeId, p
69
69
  if (!workspace) {
70
70
  throw new Error(`No Workspace presentation is available for ${workspaceId}. Call open_workspace for that workspace before activity_panel.`);
71
71
  }
72
- const snapshot = queries.beginTurn(hostConversationScopeId(extra._meta, extra.sessionId, connectionScopeId), workspaceId);
72
+ const conversationScopeId = hostConversationScopeId(extra._meta, extra.sessionId, connectionScopeId);
73
+ const relayed = await relay?.panel(workspaceId, conversationScopeId);
74
+ if (relayed) {
75
+ return {
76
+ ...relayed,
77
+ _meta: {
78
+ ...(relayed._meta ?? {}),
79
+ [ACTIVITY_PANEL_DEFAULT_EXPANDED_META_KEY]: panelDefaultExpanded,
80
+ [ACTIVITY_PANEL_WORKSPACE_META_KEY]: workspace,
81
+ },
82
+ structuredContent: {
83
+ ...(relayed.structuredContent ?? {}),
84
+ [ACTIVITY_PANEL_WORKSPACE_META_KEY]: workspace,
85
+ },
86
+ };
87
+ }
88
+ const snapshot = queries.beginTurn(conversationScopeId, workspaceId);
73
89
  if (logging) {
74
90
  logEvent(logging, "debug", "activity_panel_call", {
75
91
  turnId: snapshot.turnId,
@@ -108,6 +124,27 @@ export function registerActivityQueryTools(server, queries, connectionScopeId, p
108
124
  annotations: READ_ONLY_ANNOTATIONS,
109
125
  }, async ({ turnId, workspaceId, knownRevision }, extra) => {
110
126
  const conversationScopeId = hostConversationScopeId(extra._meta, extra.sessionId, connectionScopeId);
127
+ const relayed = await relay?.snapshot({ turnId, workspaceId, knownRevision }, conversationScopeId);
128
+ if (relayed) {
129
+ const workspace = workspaceId ? workspacePanelState?.(workspaceId) : undefined;
130
+ if (workspaceId && !workspace) {
131
+ throw new Error(`No Workspace presentation is available for ${workspaceId}.`);
132
+ }
133
+ return workspace
134
+ ? {
135
+ ...relayed,
136
+ _meta: {
137
+ ...(relayed._meta ?? {}),
138
+ [ACTIVITY_PANEL_DEFAULT_EXPANDED_META_KEY]: panelDefaultExpanded,
139
+ [ACTIVITY_PANEL_WORKSPACE_META_KEY]: workspace,
140
+ },
141
+ structuredContent: {
142
+ ...(relayed.structuredContent ?? {}),
143
+ [ACTIVITY_PANEL_WORKSPACE_META_KEY]: workspace,
144
+ },
145
+ }
146
+ : relayed;
147
+ }
111
148
  const resolvedTurnId = turnId ?? queries.currentTurnId(conversationScopeId, workspaceId);
112
149
  if (!resolvedTurnId) {
113
150
  throw new Error("Activity snapshot bootstrap could not resolve the current Host Turn from conversation and workspace metadata.");
@@ -162,6 +199,10 @@ export function registerActivityQueryTools(server, queries, connectionScopeId, p
162
199
  _meta: { ui: { visibility: ["app"] } },
163
200
  annotations: READ_ONLY_ANNOTATIONS,
164
201
  }, async ({ turnId, activityId }, extra) => {
202
+ const conversationScopeId = hostConversationScopeId(extra._meta, extra.sessionId, connectionScopeId);
203
+ const relayed = await relay?.detail(turnId, activityId, conversationScopeId);
204
+ if (relayed)
205
+ return relayed;
165
206
  const detail = queries.detail(turnId, activityId);
166
207
  if (logging) {
167
208
  logEvent(logging, "debug", "activity_detail_call", {
@@ -201,6 +242,10 @@ export function registerActivityQueryTools(server, queries, connectionScopeId, p
201
242
  _meta: { ui: { visibility: ["app"] } },
202
243
  annotations: READ_ONLY_ANNOTATIONS,
203
244
  }, async ({ turnId, outputId }, extra) => {
245
+ const conversationScopeId = hostConversationScopeId(extra._meta, extra.sessionId, connectionScopeId);
246
+ const relayed = await relay?.output(turnId, outputId, conversationScopeId);
247
+ if (relayed)
248
+ return relayed;
204
249
  const output = queries.bashOutput(turnId, outputId);
205
250
  if (logging) {
206
251
  logEvent(logging, "debug", "activity_output_call", {
package/dist/cli.js CHANGED
@@ -18,10 +18,12 @@ import { isLocalAgentProvider, loadLocalAgentProfiles, } from "./local-agent-pro
18
18
  import { assertLocalAgentProviderAvailable, formatLocalAgentProviderAvailabilitySummary, } from "./local-agent-availability.js";
19
19
  import { formatAvailableLocalAgentTargets, parseLocalAgentRunArgs, resolveLocalAgentTarget, } from "./local-agent-targets.js";
20
20
  import { createLocalAgentStore } from "./local-agent-store.js";
21
- import { generateOwnerToken, loadDevspaceFiles, resolveSubagentsFlag, writeDevspaceAuth, writeDevspaceConfig, } from "./user-config.js";
21
+ import { ensureForgeRelayInstanceId, generateInstanceId, generateOwnerToken, loadDevspaceFiles, removeForgeRelayRemote, renameForgeRelayRemote, resolveSubagentsFlag, writeDevspaceAuth, writeDevspaceConfig, writeForgeRelayRemote, } from "./user-config.js";
22
22
  import { expandHomePath } from "./roots.js";
23
23
  import { shutdownHttpServer } from "./server-shutdown.js";
24
24
  import { publicEndpointUrl } from "./oauth/public-url.js";
25
+ import { authenticateRemote, defaultRemoteAlias, isRemoteMcpUnauthorized, normalizeRemoteServiceTarget, refreshRemoteAuthentication, verifyRemoteMcp, } from "./remote-auth.js";
26
+ import { defaultSshRouteAlias, parseSshRoute, readRemoteOwnerToken, withRemoteServiceEndpoint, } from "./remote-transport.js";
25
27
  const require = createRequire(import.meta.url);
26
28
  const SUPPORTED_NODE_RANGE = ">=20.12 <27";
27
29
  async function main(argv) {
@@ -48,6 +50,9 @@ async function main(argv) {
48
50
  case "agents":
49
51
  await runAgentsCommand(args);
50
52
  return;
53
+ case "auth":
54
+ await runAuthCommand(args);
55
+ return;
51
56
  case "help":
52
57
  printHelp();
53
58
  return;
@@ -59,7 +64,7 @@ async function main(argv) {
59
64
  function normalizeCommand(command) {
60
65
  if (!command || command === "serve" || command === "start")
61
66
  return "serve";
62
- if (command === "init" || command === "doctor" || command === "config" || command === "hooks" || command === "agents")
67
+ if (command === "init" || command === "doctor" || command === "config" || command === "hooks" || command === "agents" || command === "auth")
63
68
  return command;
64
69
  if (command === "help" || command === "--help" || command === "-h")
65
70
  return "help";
@@ -69,10 +74,14 @@ function normalizeCommand(command) {
69
74
  }
70
75
  async function ensureConfigured() {
71
76
  const files = loadDevspaceFiles();
72
- if (files.configExists && files.authExists)
77
+ if (files.configExists && files.authExists) {
78
+ ensureForgeRelayInstanceId();
73
79
  return;
74
- if (process.env.FORGERELAY_OAUTH_OWNER_TOKEN ?? process.env.DEVSPACE_OAUTH_OWNER_TOKEN)
80
+ }
81
+ if (process.env.FORGERELAY_OAUTH_OWNER_TOKEN ?? process.env.DEVSPACE_OAUTH_OWNER_TOKEN) {
82
+ ensureForgeRelayInstanceId();
75
83
  return;
84
+ }
76
85
  if (!input.isTTY || !output.isTTY) {
77
86
  throw new Error([
78
87
  "ForgeRelay is not configured and this terminal is non-interactive.",
@@ -143,7 +152,9 @@ async function runInit({ force }) {
143
152
  subagents: resolveSubagentsFlag(files.config),
144
153
  };
145
154
  const auth = {
155
+ ...files.auth,
146
156
  ownerToken: files.auth.ownerToken ?? generateOwnerToken(),
157
+ instanceId: files.auth.instanceId ?? generateInstanceId(),
147
158
  };
148
159
  const configPath = writeDevspaceConfig(config);
149
160
  const authPath = writeDevspaceAuth(auth);
@@ -214,6 +225,166 @@ async function serve() {
214
225
  process.once("SIGINT", handleShutdown);
215
226
  process.once("SIGTERM", handleShutdown);
216
227
  }
228
+ function parseAuthCommandArgs(args) {
229
+ let target;
230
+ let alias;
231
+ let ownerToken;
232
+ let sshRoute;
233
+ let sshAuth = false;
234
+ for (let index = 0; index < args.length; index += 1) {
235
+ const arg = args[index];
236
+ if (arg === "--alias") {
237
+ alias = args[++index];
238
+ if (!alias)
239
+ throw new Error("Missing value for --alias.");
240
+ continue;
241
+ }
242
+ if (arg === "--token") {
243
+ ownerToken = args[++index];
244
+ if (!ownerToken)
245
+ throw new Error("Missing value for --token.");
246
+ continue;
247
+ }
248
+ if (arg === "-J") {
249
+ const route = args[++index];
250
+ if (!route)
251
+ throw new Error("Missing value for -J.");
252
+ sshRoute = parseSshRoute(route);
253
+ continue;
254
+ }
255
+ if (arg === "--ssh-auth") {
256
+ sshAuth = true;
257
+ continue;
258
+ }
259
+ if (arg.startsWith("-"))
260
+ throw new Error(`Unknown auth option: ${arg}`);
261
+ if (target)
262
+ throw new Error(`Unexpected auth argument: ${arg}`);
263
+ target = arg;
264
+ }
265
+ if (!target)
266
+ throw new Error("Missing remote service target.");
267
+ if (sshAuth && !sshRoute)
268
+ throw new Error("--ssh-auth requires -J <ssh-route>.");
269
+ if (sshAuth && ownerToken)
270
+ throw new Error("--ssh-auth and --token cannot be used together.");
271
+ return { target, alias, ownerToken, sshRoute, sshAuth };
272
+ }
273
+ async function resolveAuthOwnerToken(ownerToken) {
274
+ if (ownerToken)
275
+ return ownerToken;
276
+ if (!input.isTTY || !output.isTTY) {
277
+ throw new Error("Missing owner token. Pass --token, use --ssh-auth with -J, or run in an interactive terminal.");
278
+ }
279
+ const result = await prompts.password({
280
+ message: "Remote ForgeRelay owner token",
281
+ validate: (value) => value?.trim() ? undefined : "Enter the remote owner token.",
282
+ });
283
+ if (prompts.isCancel(result))
284
+ throw new Error("Remote authentication cancelled.");
285
+ return String(result);
286
+ }
287
+ function localOwnerToken() {
288
+ const token = process.env.FORGERELAY_OAUTH_OWNER_TOKEN
289
+ ?? process.env.DEVSPACE_OAUTH_OWNER_TOKEN
290
+ ?? loadDevspaceFiles().auth.ownerToken;
291
+ if (!token)
292
+ throw new Error("ForgeRelay owner token is not configured on this machine.");
293
+ return token;
294
+ }
295
+ async function runAuthCommand(args) {
296
+ const [subcommand, ...rest] = args;
297
+ if (subcommand === "__owner-token") {
298
+ if (rest.length > 0)
299
+ throw new Error("Internal owner-token command does not accept arguments.");
300
+ process.stdout.write(`${localOwnerToken()}\n`);
301
+ return;
302
+ }
303
+ if (subcommand === "list") {
304
+ if (rest.length > 0)
305
+ throw new Error("forgerelay auth list does not accept additional arguments.");
306
+ const remotes = loadDevspaceFiles().auth.remotes ?? {};
307
+ if (Object.keys(remotes).length === 0) {
308
+ console.log("No remote ForgeRelay instances registered.");
309
+ return;
310
+ }
311
+ for (const [alias, remote] of Object.entries(remotes).sort(([left], [right]) => left.localeCompare(right))) {
312
+ console.log(`${alias}\t${remote.target}\t${remote.instanceId}`);
313
+ }
314
+ return;
315
+ }
316
+ if (subcommand === "rename") {
317
+ const [fromAlias, toAlias, ...extra] = rest;
318
+ if (!fromAlias || !toAlias || extra.length > 0) {
319
+ throw new Error("Usage: forgerelay auth rename <old-alias> <new-alias>");
320
+ }
321
+ renameForgeRelayRemote(fromAlias, toAlias);
322
+ console.log(`Renamed remote ${fromAlias} to ${toAlias}.`);
323
+ return;
324
+ }
325
+ if (subcommand === "remove") {
326
+ const [alias, ...extra] = rest;
327
+ if (!alias || extra.length > 0)
328
+ throw new Error("Usage: forgerelay auth remove <alias>");
329
+ removeForgeRelayRemote(alias);
330
+ console.log(`Removed remote ${alias}.`);
331
+ return;
332
+ }
333
+ if (subcommand === "test") {
334
+ const [alias, ...extra] = rest;
335
+ if (!alias || extra.length > 0)
336
+ throw new Error("Usage: forgerelay auth test <alias>");
337
+ const files = loadDevspaceFiles();
338
+ const storedRemote = files.auth.remotes?.[alias];
339
+ if (!storedRemote)
340
+ throw new Error(`Unknown remote alias: ${alias}`);
341
+ let remote = storedRemote;
342
+ await withRemoteServiceEndpoint(remote.target, remote.sshRoute, async (endpoint) => {
343
+ let refreshed = false;
344
+ if (remote.accessTokenExpiresAt <= Math.floor(Date.now() / 1000)) {
345
+ remote = await refreshRemoteAuthentication(remote, endpoint);
346
+ writeForgeRelayRemote(alias, remote);
347
+ refreshed = true;
348
+ }
349
+ try {
350
+ await verifyRemoteMcp(remote, endpoint);
351
+ }
352
+ catch (error) {
353
+ if (refreshed || !isRemoteMcpUnauthorized(error))
354
+ throw error;
355
+ remote = await refreshRemoteAuthentication(remote, endpoint);
356
+ writeForgeRelayRemote(alias, remote);
357
+ await verifyRemoteMcp(remote, endpoint);
358
+ }
359
+ });
360
+ console.log(`${alias}\tok\t${remote.instanceId}`);
361
+ return;
362
+ }
363
+ const parsed = parseAuthCommandArgs(args);
364
+ const target = normalizeRemoteServiceTarget(parsed.target);
365
+ const authenticated = await withRemoteServiceEndpoint(target, parsed.sshRoute, async (endpoint) => {
366
+ const ownerToken = parsed.sshAuth
367
+ ? await readRemoteOwnerToken(parsed.sshRoute ?? [])
368
+ : await resolveAuthOwnerToken(parsed.ownerToken);
369
+ return authenticateRemote(endpoint, ownerToken);
370
+ });
371
+ const remote = {
372
+ ...authenticated,
373
+ target,
374
+ ...(parsed.sshRoute ? { sshRoute: parsed.sshRoute } : {}),
375
+ };
376
+ const files = loadDevspaceFiles();
377
+ const existingAlias = Object.entries(files.auth.remotes ?? {}).find(([, record]) => record.instanceId === remote.instanceId)?.[0];
378
+ const defaultAlias = parsed.sshRoute
379
+ ? defaultSshRouteAlias(parsed.sshRoute)
380
+ : defaultRemoteAlias(remote.target);
381
+ const alias = parsed.alias?.trim() || existingAlias || defaultAlias;
382
+ if (!files.auth.instanceId) {
383
+ ensureForgeRelayInstanceId();
384
+ }
385
+ writeForgeRelayRemote(alias, remote);
386
+ console.log(`Authenticated remote ${alias} (${remote.instanceId}).`);
387
+ }
217
388
  async function runDoctor() {
218
389
  const files = loadDevspaceFiles();
219
390
  console.log(`Config dir: ${files.dir}`);
@@ -282,6 +453,12 @@ function printHelp() {
282
453
  " forgerelay agents ls List subagent sessions",
283
454
  " forgerelay agents run <profile-or-provider-or-id> [--model <model>] <prompt>",
284
455
  " forgerelay agents show <id>",
456
+ " forgerelay auth <target> [--alias <name>] [--token <owner-token>]",
457
+ " forgerelay auth -J <ssh-route> <target> [--alias <name>] [--token <owner-token>|--ssh-auth]",
458
+ " forgerelay auth list",
459
+ " forgerelay auth test <alias>",
460
+ " forgerelay auth rename <old-alias> <new-alias>",
461
+ " forgerelay auth remove <alias>",
285
462
  " forgerelay -v, --version Print the installed version",
286
463
  "",
287
464
  "For temporary tunnels:",
package/dist/config.js CHANGED
@@ -3,7 +3,7 @@ import { homedir } from "node:os";
3
3
  import { join, resolve } from "node:path";
4
4
  import { expandHomePath } from "./roots.js";
5
5
  import { mergeHookConfigs, parseHookConfig } from "./hooks.js";
6
- import { forgerelayAgentsDir, forgerelaySkillsDir, loadForgeRelayFiles, } from "./user-config.js";
6
+ import { forgerelayAgentsDir, forgerelaySkillsDir, generateInstanceId, loadForgeRelayFiles, } from "./user-config.js";
7
7
  const DEFAULT_OAUTH_ACCESS_TOKEN_TTL_SECONDS = 60 * 60;
8
8
  const DEFAULT_OAUTH_REFRESH_TOKEN_TTL_SECONDS = 30 * 24 * 60 * 60;
9
9
  const DEFAULT_ARTIFACT_MAX_FILE_BYTES = 100 * 1024 * 1024;
@@ -211,6 +211,7 @@ function resolvePublicDeployment(env, fileConfig, host, port) {
211
211
  }
212
212
  export function loadConfig(env = process.env) {
213
213
  const files = loadForgeRelayFiles(env);
214
+ const instanceId = files.auth.instanceId?.trim() || generateInstanceId();
214
215
  const host = env.HOST ?? files.config.host ?? "127.0.0.1";
215
216
  const port = parsePort(env.PORT ?? files.config.port);
216
217
  const publicDeployment = resolvePublicDeployment(env, files.config, host, port);
@@ -224,6 +225,8 @@ export function loadConfig(env = process.env) {
224
225
  ...(files.config.allowedHosts ?? []),
225
226
  ];
226
227
  return {
228
+ instanceId,
229
+ configDir: files.dir,
227
230
  host,
228
231
  port,
229
232
  oauth: parseOAuthConfig(env, files.auth.ownerToken),
@@ -7,7 +7,7 @@ import { revocationHandler } from "@modelcontextprotocol/sdk/server/auth/handler
7
7
  import { metadataHandler } from "@modelcontextprotocol/sdk/server/auth/handlers/metadata.js";
8
8
  import { oauthAuthorizationServerMetadataPath, publicEndpointUrl, } from "./public-url.js";
9
9
  export function createForgeRelayAuthRouter(options) {
10
- const { provider, issuerUrl, resourceServerUrl, scopesSupported, resourceName } = options;
10
+ const { provider, cliAuthenticationProvider, instanceId, issuerUrl, resourceServerUrl, scopesSupported, resourceName, } = options;
11
11
  const authorizationEndpoint = publicEndpointUrl(issuerUrl, "authorize");
12
12
  const tokenEndpoint = publicEndpointUrl(issuerUrl, "token");
13
13
  const registrationEndpoint = provider.clientsStore.registerClient
@@ -35,6 +35,26 @@ export function createForgeRelayAuthRouter(options) {
35
35
  resource_name: resourceName,
36
36
  };
37
37
  const router = express.Router();
38
+ if (cliAuthenticationProvider) {
39
+ router.post("/auth/cli", express.json({ limit: "4kb" }), (req, res) => {
40
+ const ownerToken = typeof req.body?.owner_token === "string" ? req.body.owner_token : undefined;
41
+ const refreshToken = typeof req.body?.refresh_token === "string" ? req.body.refresh_token : undefined;
42
+ if ((ownerToken ? 1 : 0) + (refreshToken ? 1 : 0) !== 1) {
43
+ res.status(400).json({ error: "invalid_request" });
44
+ return;
45
+ }
46
+ const tokens = ownerToken
47
+ ? cliAuthenticationProvider.issueCliTokens(ownerToken)
48
+ : cliAuthenticationProvider.exchangeCliRefreshToken(refreshToken);
49
+ if (!tokens) {
50
+ res.status(401).json({ error: "invalid_grant" });
51
+ return;
52
+ }
53
+ res.setHeader("Cache-Control", "no-store");
54
+ res.setHeader("Pragma", "no-cache");
55
+ res.status(200).json({ ...tokens, ...(instanceId ? { instance_id: instanceId } : {}) });
56
+ });
57
+ }
38
58
  router.use("/authorize", authorizationHandler({ provider }));
39
59
  router.use("/token", tokenHandler({ provider }));
40
60
  if (provider.clientsStore.registerClient) {
@@ -3,6 +3,7 @@ import { AccessDeniedError, InvalidGrantError, InvalidRequestError, InvalidToken
3
3
  import { checkResourceAllowed, resourceUrlFromServerUrl } from "@modelcontextprotocol/sdk/shared/auth-utils.js";
4
4
  import { SqliteOAuthClientsStore, SqliteOAuthStore } from "./oauth-store.js";
5
5
  const CODE_TTL_MS = 5 * 60 * 1000;
6
+ const CLI_CLIENT_ID = "forgerelay-cli";
6
7
  function randomToken() {
7
8
  return randomBytes(32).toString("base64url");
8
9
  }
@@ -85,6 +86,15 @@ export class SingleUserOAuthProvider {
85
86
  this.config = config;
86
87
  this.resourceServerUrl = resourceUrlFromServerUrl(resourceServerUrl);
87
88
  this.oauthStore = new SqliteOAuthStore(stateDir);
89
+ this.oauthStore.ensureClient({
90
+ client_id: CLI_CLIENT_ID,
91
+ client_id_issued_at: Math.floor(Date.now() / 1000),
92
+ client_name: "ForgeRelay CLI",
93
+ redirect_uris: [],
94
+ token_endpoint_auth_method: "none",
95
+ grant_types: ["refresh_token"],
96
+ response_types: [],
97
+ });
88
98
  this.clientsStore = new SqliteOAuthClientsStore(this.oauthStore, config.allowedRedirectHosts);
89
99
  }
90
100
  async authorize(client, params, res) {
@@ -145,11 +155,10 @@ export class SingleUserOAuthProvider {
145
155
  return this.issueTokens(client.client_id, record.params.scopes ?? this.config.scopes, record.params.resource);
146
156
  }
147
157
  async exchangeRefreshToken(client, refreshToken, scopes, resource) {
148
- const refreshTokenHash = hashToken(refreshToken);
149
- const record = this.oauthStore.getRefreshToken(refreshTokenHash);
150
- if (!record || record.clientId !== client.client_id || record.expiresAt < Math.floor(Date.now() / 1000)) {
158
+ const refresh = this.validRefreshToken(refreshToken, client.client_id);
159
+ if (!refresh)
151
160
  throw new InvalidGrantError("Invalid refresh token");
152
- }
161
+ const { refreshTokenHash, record } = refresh;
153
162
  if (resource && !checkResourceAllowed({ requestedResource: resource, configuredResource: this.resourceServerUrl })) {
154
163
  throw new InvalidGrantError("Invalid resource");
155
164
  }
@@ -159,6 +168,17 @@ export class SingleUserOAuthProvider {
159
168
  }
160
169
  return this.issueTokens(client.client_id, requestedScopes, resource ?? (record.resource ? new URL(record.resource) : undefined), refreshTokenHash);
161
170
  }
171
+ issueCliTokens(ownerToken) {
172
+ if (!safeEquals(ownerToken, this.config.ownerToken))
173
+ return undefined;
174
+ return this.issueTokens(CLI_CLIENT_ID, this.config.scopes, this.resourceServerUrl);
175
+ }
176
+ exchangeCliRefreshToken(refreshToken) {
177
+ const refresh = this.validRefreshToken(refreshToken, CLI_CLIENT_ID);
178
+ if (!refresh)
179
+ return undefined;
180
+ return this.issueTokens(CLI_CLIENT_ID, refresh.record.scopes, refresh.record.resource ? new URL(refresh.record.resource) : this.resourceServerUrl, refresh.refreshTokenHash);
181
+ }
162
182
  async verifyAccessToken(token) {
163
183
  const record = this.oauthStore.getAccessToken(hashToken(token));
164
184
  if (!record || record.expiresAt < Math.floor(Date.now() / 1000)) {
@@ -181,6 +201,14 @@ export class SingleUserOAuthProvider {
181
201
  this.codes.clear();
182
202
  this.oauthStore.close();
183
203
  }
204
+ validRefreshToken(refreshToken, clientId) {
205
+ const refreshTokenHash = hashToken(refreshToken);
206
+ const record = this.oauthStore.getRefreshToken(refreshTokenHash);
207
+ if (!record || record.clientId !== clientId || record.expiresAt < Math.floor(Date.now() / 1000)) {
208
+ return undefined;
209
+ }
210
+ return { refreshTokenHash, record };
211
+ }
184
212
  pruneExpiredAuthorizationCodes(nowMs = Date.now()) {
185
213
  for (const [code, record] of this.codes) {
186
214
  if (record.expiresAtMs < nowMs)
@@ -25,6 +25,15 @@ export class SqliteOAuthStore {
25
25
  .get(clientId);
26
26
  return row ? JSON.parse(row.client_json) : undefined;
27
27
  }
28
+ ensureClient(client) {
29
+ const existing = this.getClient(client.client_id);
30
+ if (existing)
31
+ return existing;
32
+ this.database.sqlite
33
+ .prepare("insert into oauth_clients (client_id, client_json, issued_at) values (?, ?, ?)")
34
+ .run(client.client_id, JSON.stringify(client), client.client_id_issued_at);
35
+ return client;
36
+ }
28
37
  registerClient(client, allowedRedirectHosts) {
29
38
  if (!client.redirect_uris.every((uri) => redirectHostAllowed(String(uri), allowedRedirectHosts))) {
30
39
  throw new InvalidRequestError("Client redirect_uri is not allowed for this ForgeRelay server");
@@ -0,0 +1,110 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js";
3
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
4
+ import { StreamableHTTPClientTransport, StreamableHTTPError, } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
5
+ import { publicEndpointUrl } from "./oauth/public-url.js";
6
+ const REMOTE_AUTH_TIMEOUT_MS = 15_000;
7
+ const packageVersion = readForgeRelayVersion();
8
+ export function normalizeRemoteServiceTarget(value) {
9
+ const trimmed = value.trim();
10
+ if (!trimmed)
11
+ throw new Error("Missing remote service target.");
12
+ const url = new URL(trimmed.includes("://") ? trimmed : `http://${trimmed}`);
13
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
14
+ throw new Error(`Unsupported remote service protocol: ${url.protocol}`);
15
+ }
16
+ if (url.username || url.password) {
17
+ throw new Error("Remote service target must not contain credentials.");
18
+ }
19
+ if (url.search || url.hash) {
20
+ throw new Error("Remote service target must not contain a query or fragment.");
21
+ }
22
+ url.pathname = url.pathname.replace(/\/+$/, "");
23
+ return url.toString().replace(/\/$/, "");
24
+ }
25
+ export function defaultRemoteAlias(target) {
26
+ const hostname = new URL(target).hostname;
27
+ return hostname.replace(/^\[|\]$/g, "");
28
+ }
29
+ export async function authenticateRemote(targetInput, ownerToken) {
30
+ const target = normalizeRemoteServiceTarget(targetInput);
31
+ return exchangeCliCredential(target, { owner_token: ownerToken });
32
+ }
33
+ export async function refreshRemoteAuthentication(remote, endpointInput = remote.target) {
34
+ const refreshed = await exchangeCliCredential(normalizeRemoteServiceTarget(endpointInput), { refresh_token: remote.refreshToken });
35
+ if (refreshed.instanceId !== remote.instanceId) {
36
+ throw new Error(`Remote instance changed from ${remote.instanceId} to ${refreshed.instanceId}; refusing to update stored credentials.`);
37
+ }
38
+ return {
39
+ ...remote,
40
+ accessToken: refreshed.accessToken,
41
+ refreshToken: refreshed.refreshToken,
42
+ accessTokenExpiresAt: refreshed.accessTokenExpiresAt,
43
+ scope: refreshed.scope,
44
+ };
45
+ }
46
+ export function isRemoteMcpUnauthorized(error) {
47
+ return error instanceof UnauthorizedError ||
48
+ (error instanceof StreamableHTTPError && error.code === 401);
49
+ }
50
+ export async function withRemoteMcpClient(remote, endpointInput, operation) {
51
+ const endpoint = normalizeRemoteServiceTarget(endpointInput);
52
+ const client = new Client({ name: "forgerelay-cli", version: packageVersion });
53
+ const transport = new StreamableHTTPClientTransport(publicEndpointUrl(endpoint, "mcp"), {
54
+ requestInit: {
55
+ headers: { Authorization: `Bearer ${remote.accessToken}` },
56
+ },
57
+ });
58
+ try {
59
+ await client.connect(transport);
60
+ return await operation(client);
61
+ }
62
+ finally {
63
+ await client.close().catch(() => undefined);
64
+ }
65
+ }
66
+ export async function verifyRemoteMcp(remote, endpointInput = remote.target) {
67
+ await withRemoteMcpClient(remote, endpointInput, async () => undefined);
68
+ }
69
+ async function exchangeCliCredential(target, credential) {
70
+ const response = await fetch(publicEndpointUrl(target, "auth/cli"), {
71
+ method: "POST",
72
+ headers: { "content-type": "application/json" },
73
+ body: JSON.stringify(credential),
74
+ signal: AbortSignal.timeout(REMOTE_AUTH_TIMEOUT_MS),
75
+ });
76
+ if (!response.ok) {
77
+ let reason = response.statusText || `HTTP ${response.status}`;
78
+ try {
79
+ const body = await response.json();
80
+ if (typeof body.error === "string" && body.error)
81
+ reason = body.error;
82
+ }
83
+ catch {
84
+ // Keep the status-derived error without exposing the submitted secret.
85
+ }
86
+ throw new Error(`Remote authentication failed: ${reason}`);
87
+ }
88
+ const body = await response.json();
89
+ if (typeof body.instance_id !== "string" || !body.instance_id.trim()) {
90
+ throw new Error("Remote authentication response did not include an instance id.");
91
+ }
92
+ if (typeof body.access_token !== "string" || typeof body.refresh_token !== "string") {
93
+ throw new Error("Remote authentication response did not include the required tokens.");
94
+ }
95
+ return {
96
+ instanceId: body.instance_id,
97
+ target,
98
+ accessToken: body.access_token,
99
+ refreshToken: body.refresh_token,
100
+ accessTokenExpiresAt: Math.floor(Date.now() / 1000) + (body.expires_in ?? 0),
101
+ ...(body.scope ? { scope: body.scope } : {}),
102
+ };
103
+ }
104
+ function readForgeRelayVersion() {
105
+ const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
106
+ if (typeof packageJson.version !== "string" || packageJson.version.length === 0) {
107
+ throw new Error("Unable to read ForgeRelay package version.");
108
+ }
109
+ return packageJson.version;
110
+ }