@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 +19 -0
- package/README.md +9 -12
- package/dist/activity/mcp-query-tools.js +47 -2
- package/dist/cli.js +181 -4
- package/dist/config.js +4 -1
- package/dist/oauth/router.js +21 -1
- package/dist/oauth-provider.js +32 -4
- package/dist/oauth-store.js +9 -0
- package/dist/remote-auth.js +110 -0
- package/dist/remote-transport.js +196 -0
- package/dist/remote-workspace-relay.js +457 -0
- package/dist/server.js +189 -15
- package/dist/user-config.js +131 -5
- package/docs/configuration.md +3 -3
- package/docs/versioning.md +11 -19
- package/package.json +5 -2
- package/scripts/ci/verify.mjs +38 -0
- package/scripts/release/pack.mjs +36 -0
- package/scripts/release/publish.mjs +157 -0
- package/scripts/release/release-gate.test.mjs +73 -16
- package/scripts/release-parity.mjs +5 -13
- package/scripts/release-proof.mjs +28 -19
- package/scripts/release-proof.test.mjs +32 -11
- package/scripts/release-version.mjs +1 -1
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-
|
|
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
|
-
|
|
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.
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
|
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
|
-
|
|
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),
|
package/dist/oauth/router.js
CHANGED
|
@@ -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) {
|
package/dist/oauth-provider.js
CHANGED
|
@@ -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
|
|
149
|
-
|
|
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)
|
package/dist/oauth-store.js
CHANGED
|
@@ -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
|
+
}
|