@akira-tl/forgerelay 0.6.0 → 0.6.2
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 +36 -0
- package/README.md +47 -12
- package/dist/activity/mcp-query-tools.js +48 -2
- package/dist/activity/query-service.js +1 -0
- package/dist/cli.js +181 -4
- package/dist/composite-activity.js +155 -0
- package/dist/composite-workspaces.js +197 -0
- 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 +473 -0
- package/dist/server.js +824 -121
- package/dist/ui/.vite/manifest.json +33 -33
- package/dist/ui/activity-panel-app.html +3 -3
- package/dist/ui/assets/{activity-panel-app-CjZVvVNc.js → activity-panel-app-E1ju2dqI.js} +1 -1
- package/dist/ui/assets/{heavy-payload-vGgBRvNX.js → heavy-payload-CeW-n9w5.js} +1 -1
- package/dist/ui/assets/{review-payload-4erWKckt.js → review-payload-B9CO298v.js} +1 -1
- package/dist/ui/assets/{scrollbar-CaOPzUJd.js → scrollbar-C2twAENW.js} +1 -1
- package/dist/ui/assets/workspace-app-BztEvZIC.js +5 -0
- package/dist/ui/assets/{workspace-app-DkAiSl_0.js → workspace-app-CwbJnb_w.js} +1 -1
- package/dist/ui/assets/workspace-app-YnUST8IP.css +1 -0
- package/dist/ui/assets/workspace-app-rKuhdae8.js +1 -0
- package/dist/ui/assets/workspace-lifecycle-app-CEfMdudP.js +1 -0
- package/dist/ui/workspace-app.html +4 -4
- package/dist/ui/workspace-lifecycle-app.html +4 -4
- package/dist/user-config.js +131 -5
- package/docs/configuration.md +26 -3
- package/docs/debugging.md +7 -0
- package/docs/versioning.md +11 -19
- package/package.json +5 -2
- package/scripts/ci/verify.mjs +38 -0
- package/scripts/debug/runtime.mjs +23 -1
- package/scripts/debug/runtime.test.mjs +14 -2
- package/scripts/debug/serve.mjs +4 -4
- 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/dist/ui/assets/workspace-app-CcrHAUIn.css +0 -1
- package/dist/ui/assets/workspace-app-DJmkPYJC.js +0 -1
- package/dist/ui/assets/workspace-app-QyauBrJX.js +0 -5
- package/dist/ui/assets/workspace-lifecycle-app-BIXEo53I.js +0 -1
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { CallToolResultSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
5
|
+
import { isRemoteMcpUnauthorized, refreshRemoteAuthentication, withRemoteMcpClient, } from "./remote-auth.js";
|
|
6
|
+
import { withRemoteServiceEndpoint } from "./remote-transport.js";
|
|
7
|
+
import { loadForgeRelayFiles, writeForgeRelayRemote, } from "./user-config.js";
|
|
8
|
+
const ROUTE_LOCK_RETRY_MS = 10;
|
|
9
|
+
const ROUTE_LOCK_TIMEOUT_MS = 5_000;
|
|
10
|
+
const ROUTE_LOCK_STALE_MS = 30_000;
|
|
11
|
+
const ROUTE_LOCK_SLEEP = new Int32Array(new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT));
|
|
12
|
+
export class RemoteWorkspaceRelay {
|
|
13
|
+
routes = new Map();
|
|
14
|
+
turnRoutes = new Map();
|
|
15
|
+
authEnv;
|
|
16
|
+
routeStateDir;
|
|
17
|
+
routeStatePath;
|
|
18
|
+
constructor(configDir, stateDir) {
|
|
19
|
+
this.authEnv = { FORGERELAY_CONFIG_DIR: configDir };
|
|
20
|
+
this.routeStateDir = stateDir;
|
|
21
|
+
this.routeStatePath = join(stateDir, "remote-workspace-routes.json");
|
|
22
|
+
this.loadRoutes();
|
|
23
|
+
}
|
|
24
|
+
has(workspaceId) {
|
|
25
|
+
if (this.routes.has(workspaceId))
|
|
26
|
+
return true;
|
|
27
|
+
this.loadRoutes();
|
|
28
|
+
return this.routes.has(workspaceId);
|
|
29
|
+
}
|
|
30
|
+
async openWorkspace(alias, input, conversationScopeId) {
|
|
31
|
+
const resolved = this.remoteByAlias(alias);
|
|
32
|
+
let result;
|
|
33
|
+
try {
|
|
34
|
+
result = await this.callRemoteTool(resolved.alias, resolved.remote, "open_workspace", {
|
|
35
|
+
path: input.path,
|
|
36
|
+
...(input.mode ? { mode: input.mode } : {}),
|
|
37
|
+
...(input.baseRef ? { baseRef: input.baseRef } : {}),
|
|
38
|
+
...(input.newWorktree !== undefined ? { newWorktree: input.newWorktree } : {}),
|
|
39
|
+
...(input.newWorkspace !== undefined ? { newWorkspace: input.newWorkspace } : {}),
|
|
40
|
+
...(input.context !== undefined ? { context: input.context } : {}),
|
|
41
|
+
}, conversationScopeId);
|
|
42
|
+
assertRemoteToolSucceeded(alias, "open_workspace", result);
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
throw sanitizedRemoteError(error);
|
|
46
|
+
}
|
|
47
|
+
const structured = result.structuredContent;
|
|
48
|
+
const remoteWorkspaceId = stringField(structured, "workspaceId", "Remote open_workspace response");
|
|
49
|
+
const root = stringField(structured, "root", "Remote open_workspace response");
|
|
50
|
+
const mode = structured?.mode;
|
|
51
|
+
if (mode !== "checkout" && mode !== "worktree") {
|
|
52
|
+
throw new Error("Remote open_workspace response did not include a valid workspace mode.");
|
|
53
|
+
}
|
|
54
|
+
const gatewayWorkspaceId = this.allocateGatewayWorkspaceId();
|
|
55
|
+
const sourceRoot = typeof structured?.sourceRoot === "string" ? structured.sourceRoot : undefined;
|
|
56
|
+
const route = {
|
|
57
|
+
gatewayWorkspaceId,
|
|
58
|
+
remoteInstanceId: resolved.remote.instanceId,
|
|
59
|
+
remoteWorkspaceId,
|
|
60
|
+
root,
|
|
61
|
+
mode,
|
|
62
|
+
...(sourceRoot ? { sourceRoot } : {}),
|
|
63
|
+
};
|
|
64
|
+
this.routes.set(gatewayWorkspaceId, route);
|
|
65
|
+
this.persistRoute(route);
|
|
66
|
+
const remapContext = (value) => replaceExactWorkspaceId(value, remoteWorkspaceId, gatewayWorkspaceId);
|
|
67
|
+
const remoteInstruction = typeof structured?.instruction === "string"
|
|
68
|
+
? String(remapContext(structured.instruction))
|
|
69
|
+
: `Use workspaceId ${gatewayWorkspaceId} for subsequent calls.`;
|
|
70
|
+
return {
|
|
71
|
+
workspaceId: gatewayWorkspaceId,
|
|
72
|
+
root,
|
|
73
|
+
mode,
|
|
74
|
+
...(sourceRoot ? { sourceRoot } : {}),
|
|
75
|
+
...(structured?.contextFingerprint !== undefined
|
|
76
|
+
? { contextFingerprint: remapContext(structured.contextFingerprint) }
|
|
77
|
+
: {}),
|
|
78
|
+
...(structured?.capabilityFingerprint !== undefined
|
|
79
|
+
? { capabilityFingerprint: remapContext(structured.capabilityFingerprint) }
|
|
80
|
+
: {}),
|
|
81
|
+
...(structured?.capabilityCatalog !== undefined
|
|
82
|
+
? { capabilityCatalog: remapContext(structured.capabilityCatalog) }
|
|
83
|
+
: {}),
|
|
84
|
+
...(structured?.capabilityGuides !== undefined
|
|
85
|
+
? { capabilityGuides: remapContext(structured.capabilityGuides) }
|
|
86
|
+
: {}),
|
|
87
|
+
...(structured?.agentsFiles !== undefined
|
|
88
|
+
? { agentsFiles: remapContext(structured.agentsFiles) }
|
|
89
|
+
: {}),
|
|
90
|
+
...(structured?.availableAgentsFiles !== undefined
|
|
91
|
+
? { availableAgentsFiles: remapContext(structured.availableAgentsFiles) }
|
|
92
|
+
: {}),
|
|
93
|
+
...(structured?.skills !== undefined ? { skills: remapContext(structured.skills) } : {}),
|
|
94
|
+
...(structured?.agentProviders !== undefined
|
|
95
|
+
? { agentProviders: remapContext(structured.agentProviders) }
|
|
96
|
+
: {}),
|
|
97
|
+
...(structured?.agents !== undefined ? { agents: remapContext(structured.agents) } : {}),
|
|
98
|
+
...(structured?.skillDiagnostics !== undefined
|
|
99
|
+
? { skillDiagnostics: remapContext(structured.skillDiagnostics) }
|
|
100
|
+
: {}),
|
|
101
|
+
instruction: `${remoteInstruction}\nThis workspace executes on remote ${alias}.`,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
async resumeWorkspace(gatewayWorkspaceId, context = "auto", conversationScopeId) {
|
|
105
|
+
const result = await this.callWorkspaceTool(gatewayWorkspaceId, "open_workspace", { context }, conversationScopeId);
|
|
106
|
+
if (result.isError === true) {
|
|
107
|
+
throw new Error(`Remote open_workspace failed: ${toolResultText(result)}`);
|
|
108
|
+
}
|
|
109
|
+
return result;
|
|
110
|
+
}
|
|
111
|
+
async read(gatewayWorkspaceId, input, conversationScopeId) {
|
|
112
|
+
return this.callWorkspaceTool(gatewayWorkspaceId, "read", input, conversationScopeId);
|
|
113
|
+
}
|
|
114
|
+
async write(gatewayWorkspaceId, input, conversationScopeId) {
|
|
115
|
+
return this.callWorkspaceTool(gatewayWorkspaceId, "write", input, conversationScopeId);
|
|
116
|
+
}
|
|
117
|
+
async edit(gatewayWorkspaceId, input, conversationScopeId) {
|
|
118
|
+
return this.callWorkspaceTool(gatewayWorkspaceId, "edit", input, conversationScopeId);
|
|
119
|
+
}
|
|
120
|
+
async rename(gatewayWorkspaceId, input, conversationScopeId) {
|
|
121
|
+
return this.callWorkspaceTool(gatewayWorkspaceId, "rename", input, conversationScopeId);
|
|
122
|
+
}
|
|
123
|
+
async delete(gatewayWorkspaceId, input, conversationScopeId) {
|
|
124
|
+
return this.callWorkspaceTool(gatewayWorkspaceId, "delete", input, conversationScopeId);
|
|
125
|
+
}
|
|
126
|
+
async bash(gatewayWorkspaceId, input, conversationScopeId) {
|
|
127
|
+
return this.callWorkspaceTool(gatewayWorkspaceId, "bash", input, conversationScopeId);
|
|
128
|
+
}
|
|
129
|
+
async execCommand(gatewayWorkspaceId, input, conversationScopeId) {
|
|
130
|
+
return this.callWorkspaceTool(gatewayWorkspaceId, "exec_command", input, conversationScopeId);
|
|
131
|
+
}
|
|
132
|
+
async writeStdin(gatewayWorkspaceId, input, conversationScopeId) {
|
|
133
|
+
return this.callWorkspaceTool(gatewayWorkspaceId, "write_stdin", input, conversationScopeId);
|
|
134
|
+
}
|
|
135
|
+
async applyPatch(gatewayWorkspaceId, input, conversationScopeId) {
|
|
136
|
+
return this.callWorkspaceTool(gatewayWorkspaceId, "apply_patch", input, conversationScopeId);
|
|
137
|
+
}
|
|
138
|
+
async capability(gatewayWorkspaceId, input, conversationScopeId) {
|
|
139
|
+
return this.callWorkspaceTool(gatewayWorkspaceId, "capability", input, conversationScopeId);
|
|
140
|
+
}
|
|
141
|
+
async activityPanel(gatewayWorkspaceId, conversationScopeId) {
|
|
142
|
+
const result = await this.callWorkspaceTool(gatewayWorkspaceId, "activity_panel", {}, conversationScopeId);
|
|
143
|
+
if (result.isError === true) {
|
|
144
|
+
throw new Error(`Remote activity_panel failed: ${toolResultText(result)}`);
|
|
145
|
+
}
|
|
146
|
+
const turnId = stringField(result.structuredContent, "turnId", "Remote activity_panel response");
|
|
147
|
+
this.turnRoutes.set(turnId, gatewayWorkspaceId);
|
|
148
|
+
return result;
|
|
149
|
+
}
|
|
150
|
+
async activitySnapshot(input, conversationScopeId) {
|
|
151
|
+
const gatewayWorkspaceId = input.workspaceId && this.has(input.workspaceId)
|
|
152
|
+
? input.workspaceId
|
|
153
|
+
: input.turnId
|
|
154
|
+
? this.turnRoutes.get(input.turnId)
|
|
155
|
+
: undefined;
|
|
156
|
+
if (!gatewayWorkspaceId)
|
|
157
|
+
return undefined;
|
|
158
|
+
const route = this.requireRoute(gatewayWorkspaceId);
|
|
159
|
+
const resolved = this.remoteByInstance(route.remoteInstanceId);
|
|
160
|
+
try {
|
|
161
|
+
const result = await this.callRemoteTool(resolved.alias, resolved.remote, "activity_snapshot", {
|
|
162
|
+
...(input.turnId !== undefined ? { turnId: input.turnId } : {}),
|
|
163
|
+
...(input.workspaceId !== undefined ? { workspaceId: route.remoteWorkspaceId } : {}),
|
|
164
|
+
...(input.knownRevision !== undefined ? { knownRevision: input.knownRevision } : {}),
|
|
165
|
+
}, conversationScopeId);
|
|
166
|
+
const remapped = remapToolResultWorkspaceId(result, route.remoteWorkspaceId, gatewayWorkspaceId);
|
|
167
|
+
const turnId = stringField(remapped.structuredContent, "turnId", "Remote activity_snapshot response");
|
|
168
|
+
this.turnRoutes.set(turnId, gatewayWorkspaceId);
|
|
169
|
+
return remapped;
|
|
170
|
+
}
|
|
171
|
+
catch (error) {
|
|
172
|
+
throw sanitizedRemoteError(error, route.remoteWorkspaceId, gatewayWorkspaceId);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
async activityDetail(turnId, activityId, conversationScopeId) {
|
|
176
|
+
return this.callTurnTool(turnId, "activity_detail", { turnId, activityId }, conversationScopeId);
|
|
177
|
+
}
|
|
178
|
+
async activityOutput(turnId, outputId, conversationScopeId) {
|
|
179
|
+
return this.callTurnTool(turnId, "activity_output", { turnId, outputId }, conversationScopeId);
|
|
180
|
+
}
|
|
181
|
+
async callTurnTool(turnId, name, args, conversationScopeId) {
|
|
182
|
+
const gatewayWorkspaceId = this.turnRoutes.get(turnId);
|
|
183
|
+
if (!gatewayWorkspaceId)
|
|
184
|
+
return undefined;
|
|
185
|
+
const route = this.requireRoute(gatewayWorkspaceId);
|
|
186
|
+
const resolved = this.remoteByInstance(route.remoteInstanceId);
|
|
187
|
+
try {
|
|
188
|
+
const result = await this.callRemoteTool(resolved.alias, resolved.remote, name, args, conversationScopeId);
|
|
189
|
+
return remapToolResultWorkspaceId(result, route.remoteWorkspaceId, gatewayWorkspaceId);
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
throw sanitizedRemoteError(error, route.remoteWorkspaceId, gatewayWorkspaceId);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
async callWorkspaceTool(gatewayWorkspaceId, name, args, conversationScopeId) {
|
|
196
|
+
const route = this.requireRoute(gatewayWorkspaceId);
|
|
197
|
+
const resolved = this.remoteByInstance(route.remoteInstanceId);
|
|
198
|
+
try {
|
|
199
|
+
const result = await this.callRemoteTool(resolved.alias, resolved.remote, name, {
|
|
200
|
+
...args,
|
|
201
|
+
workspaceId: route.remoteWorkspaceId,
|
|
202
|
+
}, conversationScopeId);
|
|
203
|
+
return remapToolResultWorkspaceId(result, route.remoteWorkspaceId, gatewayWorkspaceId);
|
|
204
|
+
}
|
|
205
|
+
catch (error) {
|
|
206
|
+
throw sanitizedRemoteError(error, route.remoteWorkspaceId, gatewayWorkspaceId);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
async closeWorkspace(gatewayWorkspaceId, commitMessage, conversationScopeId) {
|
|
210
|
+
const route = this.requireRoute(gatewayWorkspaceId);
|
|
211
|
+
const resolved = this.remoteByInstance(route.remoteInstanceId);
|
|
212
|
+
let result;
|
|
213
|
+
try {
|
|
214
|
+
result = await this.callRemoteTool(resolved.alias, resolved.remote, "close_workspace", {
|
|
215
|
+
workspaceId: route.remoteWorkspaceId,
|
|
216
|
+
...(commitMessage !== undefined ? { commitMessage } : {}),
|
|
217
|
+
}, conversationScopeId);
|
|
218
|
+
assertRemoteToolSucceeded(resolved.alias, "close_workspace", result);
|
|
219
|
+
}
|
|
220
|
+
catch (error) {
|
|
221
|
+
throw sanitizedRemoteError(error, route.remoteWorkspaceId, gatewayWorkspaceId);
|
|
222
|
+
}
|
|
223
|
+
const remoteStructured = result.structuredContent;
|
|
224
|
+
this.routes.delete(gatewayWorkspaceId);
|
|
225
|
+
this.deletePersistedRoute(gatewayWorkspaceId);
|
|
226
|
+
for (const [turnId, routedWorkspaceId] of this.turnRoutes) {
|
|
227
|
+
if (routedWorkspaceId === gatewayWorkspaceId)
|
|
228
|
+
this.turnRoutes.delete(turnId);
|
|
229
|
+
}
|
|
230
|
+
const text = route.mode === "worktree"
|
|
231
|
+
? `Closed relayed worktree workspace ${gatewayWorkspaceId} on remote ${resolved.alias}.`
|
|
232
|
+
: `Closed relayed checkout workspace ${gatewayWorkspaceId} on remote ${resolved.alias}.`;
|
|
233
|
+
const structuredContent = {
|
|
234
|
+
result: text,
|
|
235
|
+
workspaceId: gatewayWorkspaceId,
|
|
236
|
+
mode: route.mode,
|
|
237
|
+
};
|
|
238
|
+
for (const field of [
|
|
239
|
+
"sourceRoot",
|
|
240
|
+
"branch",
|
|
241
|
+
"targetBranch",
|
|
242
|
+
"commitSha",
|
|
243
|
+
"mergedSha",
|
|
244
|
+
"committed",
|
|
245
|
+
"cleanupWarning",
|
|
246
|
+
]) {
|
|
247
|
+
const value = remoteStructured?.[field];
|
|
248
|
+
if (value !== undefined) {
|
|
249
|
+
structuredContent[field] = replaceExactWorkspaceId(value, route.remoteWorkspaceId, gatewayWorkspaceId);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return {
|
|
253
|
+
content: [{ type: "text", text }],
|
|
254
|
+
_meta: {
|
|
255
|
+
tool: "close_workspace",
|
|
256
|
+
card: {
|
|
257
|
+
workspaceId: gatewayWorkspaceId,
|
|
258
|
+
mode: route.mode,
|
|
259
|
+
payload: { content: [{ type: "text", text }] },
|
|
260
|
+
},
|
|
261
|
+
},
|
|
262
|
+
structuredContent,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
requireRoute(workspaceId) {
|
|
266
|
+
let route = this.routes.get(workspaceId);
|
|
267
|
+
if (!route) {
|
|
268
|
+
this.loadRoutes();
|
|
269
|
+
route = this.routes.get(workspaceId);
|
|
270
|
+
}
|
|
271
|
+
if (!route)
|
|
272
|
+
throw new Error(`Unknown relayed workspace: ${workspaceId}`);
|
|
273
|
+
return route;
|
|
274
|
+
}
|
|
275
|
+
loadRoutes() {
|
|
276
|
+
for (const [workspaceId, route] of this.readRoutesFromDisk()) {
|
|
277
|
+
this.routes.set(workspaceId, route);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
persistRoute(route) {
|
|
281
|
+
this.updatePersistedRoutes((routes) => {
|
|
282
|
+
routes.set(route.gatewayWorkspaceId, route);
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
deletePersistedRoute(workspaceId) {
|
|
286
|
+
this.updatePersistedRoutes((routes) => {
|
|
287
|
+
routes.delete(workspaceId);
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
updatePersistedRoutes(update) {
|
|
291
|
+
mkdirSync(this.routeStateDir, { recursive: true });
|
|
292
|
+
this.withRouteFileLock(() => {
|
|
293
|
+
const routes = this.readRoutesFromDisk();
|
|
294
|
+
update(routes);
|
|
295
|
+
this.writeRoutesToDisk(routes);
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
readRoutesFromDisk() {
|
|
299
|
+
const routes = new Map();
|
|
300
|
+
if (!existsSync(this.routeStatePath))
|
|
301
|
+
return routes;
|
|
302
|
+
const parsed = JSON.parse(readFileSync(this.routeStatePath, "utf8"));
|
|
303
|
+
if (!Array.isArray(parsed)) {
|
|
304
|
+
throw new Error(`Invalid relayed workspace route state: ${this.routeStatePath}`);
|
|
305
|
+
}
|
|
306
|
+
for (const route of parsed) {
|
|
307
|
+
if (!route || typeof route.gatewayWorkspaceId !== "string" ||
|
|
308
|
+
typeof route.remoteInstanceId !== "string" || typeof route.remoteWorkspaceId !== "string" ||
|
|
309
|
+
typeof route.root !== "string" || (route.mode !== "checkout" && route.mode !== "worktree")) {
|
|
310
|
+
throw new Error(`Invalid relayed workspace route state: ${this.routeStatePath}`);
|
|
311
|
+
}
|
|
312
|
+
routes.set(route.gatewayWorkspaceId, route);
|
|
313
|
+
}
|
|
314
|
+
return routes;
|
|
315
|
+
}
|
|
316
|
+
writeRoutesToDisk(routes) {
|
|
317
|
+
const tempPath = `${this.routeStatePath}.${process.pid}.${randomBytes(5).toString("hex")}.tmp`;
|
|
318
|
+
try {
|
|
319
|
+
writeFileSync(tempPath, JSON.stringify([...routes.values()], null, 2) + "\n", { mode: 0o600 });
|
|
320
|
+
renameSync(tempPath, this.routeStatePath);
|
|
321
|
+
}
|
|
322
|
+
finally {
|
|
323
|
+
rmSync(tempPath, { force: true });
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
withRouteFileLock(operation) {
|
|
327
|
+
const lockPath = `${this.routeStatePath}.lock`;
|
|
328
|
+
const deadline = Date.now() + ROUTE_LOCK_TIMEOUT_MS;
|
|
329
|
+
for (;;) {
|
|
330
|
+
try {
|
|
331
|
+
const fd = openSync(lockPath, "wx", 0o600);
|
|
332
|
+
closeSync(fd);
|
|
333
|
+
break;
|
|
334
|
+
}
|
|
335
|
+
catch (error) {
|
|
336
|
+
const code = error.code;
|
|
337
|
+
if (code !== "EEXIST")
|
|
338
|
+
throw error;
|
|
339
|
+
try {
|
|
340
|
+
if (Date.now() - statSync(lockPath).mtimeMs > ROUTE_LOCK_STALE_MS) {
|
|
341
|
+
rmSync(lockPath, { force: true });
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
catch (statError) {
|
|
346
|
+
if (statError.code === "ENOENT")
|
|
347
|
+
continue;
|
|
348
|
+
throw statError;
|
|
349
|
+
}
|
|
350
|
+
if (Date.now() >= deadline) {
|
|
351
|
+
throw new Error(`Timed out waiting for relayed workspace route lock: ${lockPath}`);
|
|
352
|
+
}
|
|
353
|
+
Atomics.wait(ROUTE_LOCK_SLEEP, 0, 0, ROUTE_LOCK_RETRY_MS);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
try {
|
|
357
|
+
return operation();
|
|
358
|
+
}
|
|
359
|
+
finally {
|
|
360
|
+
rmSync(lockPath, { force: true });
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
remoteByAlias(aliasInput) {
|
|
364
|
+
const alias = aliasInput.trim();
|
|
365
|
+
if (!alias)
|
|
366
|
+
throw new Error("Remote relay alias must not be empty.");
|
|
367
|
+
const remote = loadForgeRelayFiles(this.authEnv).auth.remotes?.[alias];
|
|
368
|
+
if (!remote)
|
|
369
|
+
throw new Error(`Unknown remote relay alias: ${alias}`);
|
|
370
|
+
return { alias, remote };
|
|
371
|
+
}
|
|
372
|
+
remoteByInstance(instanceId) {
|
|
373
|
+
const entry = Object.entries(loadForgeRelayFiles(this.authEnv).auth.remotes ?? {})
|
|
374
|
+
.find(([, remote]) => remote.instanceId === instanceId);
|
|
375
|
+
if (!entry)
|
|
376
|
+
throw new Error(`Remote ForgeRelay instance ${instanceId} is no longer registered.`);
|
|
377
|
+
return { alias: entry[0], remote: entry[1] };
|
|
378
|
+
}
|
|
379
|
+
async callRemoteTool(alias, initialRemote, name, args, conversationScopeId) {
|
|
380
|
+
return withRemoteServiceEndpoint(initialRemote.target, initialRemote.sshRoute, async (endpoint) => {
|
|
381
|
+
let remote = initialRemote;
|
|
382
|
+
let refreshed = false;
|
|
383
|
+
if (remote.accessTokenExpiresAt <= Math.floor(Date.now() / 1000)) {
|
|
384
|
+
remote = await this.refreshRemote(alias, remote, endpoint);
|
|
385
|
+
refreshed = true;
|
|
386
|
+
}
|
|
387
|
+
const invoke = () => withRemoteMcpClient(remote, endpoint, async (client) => CallToolResultSchema.parse(await client.callTool({
|
|
388
|
+
name,
|
|
389
|
+
arguments: args,
|
|
390
|
+
...(conversationScopeId
|
|
391
|
+
? { _meta: { "openai/session": conversationScopeId } }
|
|
392
|
+
: {}),
|
|
393
|
+
})));
|
|
394
|
+
try {
|
|
395
|
+
return await invoke();
|
|
396
|
+
}
|
|
397
|
+
catch (error) {
|
|
398
|
+
if (!refreshed && isRemoteMcpUnauthorized(error)) {
|
|
399
|
+
remote = await this.refreshRemote(alias, remote, endpoint);
|
|
400
|
+
return invoke();
|
|
401
|
+
}
|
|
402
|
+
throw new Error(`Remote ForgeRelay ${alias} request failed: ${errorMessage(error)}`, { cause: error });
|
|
403
|
+
}
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
async refreshRemote(alias, remote, endpoint) {
|
|
407
|
+
const refreshed = await refreshRemoteAuthentication(remote, endpoint);
|
|
408
|
+
writeForgeRelayRemote(alias, refreshed, this.authEnv);
|
|
409
|
+
return refreshed;
|
|
410
|
+
}
|
|
411
|
+
allocateGatewayWorkspaceId() {
|
|
412
|
+
let workspaceId;
|
|
413
|
+
do {
|
|
414
|
+
workspaceId = `rws_${randomBytes(5).toString("hex")}`;
|
|
415
|
+
} while (this.routes.has(workspaceId));
|
|
416
|
+
return workspaceId;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
function assertRemoteToolSucceeded(alias, tool, result) {
|
|
420
|
+
if (result.isError !== true)
|
|
421
|
+
return;
|
|
422
|
+
throw new Error(`Remote ForgeRelay ${alias} ${tool} failed: ${toolResultText(result)}`);
|
|
423
|
+
}
|
|
424
|
+
function stringField(structured, field, label) {
|
|
425
|
+
const value = structured?.[field];
|
|
426
|
+
if (typeof value !== "string" || !value) {
|
|
427
|
+
throw new Error(`${label} did not include ${field}.`);
|
|
428
|
+
}
|
|
429
|
+
return value;
|
|
430
|
+
}
|
|
431
|
+
function toolResultText(result) {
|
|
432
|
+
return (result.content ?? [])
|
|
433
|
+
.filter((entry) => entry.type === "text")
|
|
434
|
+
.map((entry) => entry.text)
|
|
435
|
+
.join("\n") || "remote tool returned an error";
|
|
436
|
+
}
|
|
437
|
+
function remapToolResultWorkspaceId(result, remoteWorkspaceId, gatewayWorkspaceId) {
|
|
438
|
+
return {
|
|
439
|
+
...result,
|
|
440
|
+
content: (result.content ?? []).map((entry) => entry.type === "text"
|
|
441
|
+
? { ...entry, text: entry.text.split(remoteWorkspaceId).join(gatewayWorkspaceId) }
|
|
442
|
+
: entry),
|
|
443
|
+
...(result._meta
|
|
444
|
+
? { _meta: replaceExactWorkspaceId(result._meta, remoteWorkspaceId, gatewayWorkspaceId) }
|
|
445
|
+
: {}),
|
|
446
|
+
...(result.structuredContent
|
|
447
|
+
? {
|
|
448
|
+
structuredContent: replaceExactWorkspaceId(result.structuredContent, remoteWorkspaceId, gatewayWorkspaceId),
|
|
449
|
+
}
|
|
450
|
+
: {}),
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
function replaceExactWorkspaceId(value, from, to) {
|
|
454
|
+
if (typeof value === "string")
|
|
455
|
+
return value.split(from).join(to);
|
|
456
|
+
if (Array.isArray(value))
|
|
457
|
+
return value.map((entry) => replaceExactWorkspaceId(entry, from, to));
|
|
458
|
+
if (!value || typeof value !== "object")
|
|
459
|
+
return value;
|
|
460
|
+
return Object.fromEntries(Object.entries(value)
|
|
461
|
+
.map(([key, entry]) => [key, replaceExactWorkspaceId(entry, from, to)]));
|
|
462
|
+
}
|
|
463
|
+
function sanitizedRemoteError(error, remoteWorkspaceId, gatewayWorkspaceId) {
|
|
464
|
+
let message = errorMessage(error);
|
|
465
|
+
if (remoteWorkspaceId && gatewayWorkspaceId) {
|
|
466
|
+
message = message.split(remoteWorkspaceId).join(gatewayWorkspaceId);
|
|
467
|
+
}
|
|
468
|
+
message = message.replace(/(^|[^A-Za-z0-9_])ws_[0-9a-f]{10}(?=$|[^A-Za-z0-9_])/g, "$1[remote-workspace]");
|
|
469
|
+
return new Error(message, { cause: error });
|
|
470
|
+
}
|
|
471
|
+
function errorMessage(error) {
|
|
472
|
+
return error instanceof Error ? error.message : String(error);
|
|
473
|
+
}
|