@akira-tl/forgerelay 0.8.5 → 0.8.6
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 +15 -0
- package/README.md +8 -10
- package/dist/activity/audit-store.js +44 -6
- package/dist/activity/mcp-query-tools.js +53 -36
- package/dist/activity/query-service.js +41 -11
- package/dist/cli.js +16 -17
- package/dist/composite-activity.js +124 -33
- package/dist/config.js +8 -13
- package/dist/lsp/test-support/server-fixture.js +7 -7
- package/dist/process-sessions.js +2 -2
- package/dist/remote-auth.js +11 -1
- package/dist/remote-mcp-connection-pool.js +90 -0
- package/dist/remote-transport.js +28 -14
- package/dist/remote-workspace-relay.js +53 -23
- package/dist/server.js +70 -39
- package/dist/skills.js +1 -1
- package/dist/subagents/profiles.js +1 -2
- package/dist/subagents/providers/adapters/pi.js +2 -2
- package/dist/subagents/providers/availability.js +2 -2
- package/dist/subagents/providers/path.js +4 -4
- package/dist/ui/.vite/manifest.json +32 -32
- package/dist/ui/activity-panel-app.html +3 -3
- package/dist/ui/assets/{activity-panel-app-E1ju2dqI.js → activity-panel-app-CUAN6zyW.js} +1 -1
- package/dist/ui/assets/{heavy-payload-CeW-n9w5.js → heavy-payload-CgzrutLm.js} +1 -1
- package/dist/ui/assets/{review-payload-B9CO298v.js → review-payload-BrLbezbq.js} +1 -1
- package/dist/ui/assets/{scrollbar-C2twAENW.js → scrollbar-CbhpdW05.js} +1 -1
- package/dist/ui/assets/workspace-app-Bhj96tsR.js +1 -0
- package/dist/ui/assets/{workspace-app-CwbJnb_w.js → workspace-app-CxwJuZyS.js} +1 -1
- package/dist/ui/assets/{workspace-app-BztEvZIC.js → workspace-app-D6UR0AFl.js} +3 -3
- package/dist/ui/assets/workspace-app-ldjBmCJR.css +1 -0
- package/dist/ui/assets/workspace-lifecycle-app-Cqfhx9pV.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 +3 -19
- package/dist/workspace-presentation.js +69 -0
- package/docs/agent-profile-schema.md +4 -9
- package/docs/artifact-exchange.md +2 -1
- package/docs/chatgpt-coding-workflow.md +9 -9
- package/docs/configuration.md +20 -29
- package/docs/gotchas.md +10 -7
- package/docs/roadmap.md +6 -3
- package/docs/security.md +3 -2
- package/docs/setup.md +8 -5
- package/docs/versioning.md +1 -1
- package/package.json +3 -2
- package/scripts/debug/accept.mjs +7 -1
- package/scripts/debug/relay-accept.mjs +0 -1
- package/scripts/debug/runtime.mjs +0 -4
- package/scripts/debug/runtime.test.mjs +0 -2
- package/scripts/debug/traffic/run.sh +5 -0
- package/scripts/debug/traffic/traffic-audit.mjs +907 -0
- package/scripts/release/push-ready.mjs +124 -0
- package/scripts/release/push-ready.test.mjs +108 -0
- package/scripts/release/release-gate.test.mjs +1 -0
- package/scripts/release-proof.mjs +16 -1
- package/dist/ui/assets/workspace-app-YnUST8IP.css +0 -1
- package/dist/ui/assets/workspace-app-rKuhdae8.js +0 -1
- package/dist/ui/assets/workspace-lifecycle-app-CEfMdudP.js +0 -1
|
@@ -21,7 +21,7 @@ export class CompositeActivityCoordinator {
|
|
|
21
21
|
remoteActivities: new Map(),
|
|
22
22
|
remoteOutputs: new Map(),
|
|
23
23
|
});
|
|
24
|
-
return
|
|
24
|
+
return panelResult(snapshot, `Started Composite Workspace Host Turn ${snapshot.turnId}.`);
|
|
25
25
|
}
|
|
26
26
|
currentTurnId(conversationScopeId, compositeWorkspaceId) {
|
|
27
27
|
const turnId = this.queries.currentTurnId(conversationScopeId, compositeWorkspaceId);
|
|
@@ -43,6 +43,8 @@ export class CompositeActivityCoordinator {
|
|
|
43
43
|
member,
|
|
44
44
|
workspaceId: executionWorkspaceId,
|
|
45
45
|
remoteTurnId,
|
|
46
|
+
stateRevision: optionalNonnegativeInteger(panel.structuredContent, "revision"),
|
|
47
|
+
state: optionalActivityState(panel.structuredContent),
|
|
46
48
|
});
|
|
47
49
|
return turnId;
|
|
48
50
|
}
|
|
@@ -50,47 +52,86 @@ export class CompositeActivityCoordinator {
|
|
|
50
52
|
const state = this.turns.get(turnId);
|
|
51
53
|
if (!state)
|
|
52
54
|
return undefined;
|
|
53
|
-
const local = this.queries.
|
|
55
|
+
const local = this.queries.state(turnId);
|
|
54
56
|
const remoteSnapshots = await Promise.all([...state.remoteMembers.values()].map(async (route) => ({
|
|
55
57
|
route,
|
|
56
|
-
snapshot: await this.remoteWorkspaces.activitySnapshot({
|
|
58
|
+
snapshot: await this.remoteWorkspaces.activitySnapshot({
|
|
59
|
+
turnId: route.remoteTurnId,
|
|
60
|
+
...(route.stateRevision !== undefined ? { knownRevision: route.stateRevision } : {}),
|
|
61
|
+
}, state.conversationScopeId),
|
|
57
62
|
})));
|
|
58
|
-
state.remoteActivities.clear();
|
|
59
|
-
state.remoteOutputs.clear();
|
|
60
|
-
const remoteActivities = [];
|
|
61
63
|
let revision = local.revision;
|
|
62
64
|
for (const { route, snapshot } of remoteSnapshots) {
|
|
63
65
|
if (!snapshot?.structuredContent)
|
|
64
66
|
continue;
|
|
65
|
-
const
|
|
66
|
-
const
|
|
67
|
-
if (
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
67
|
+
const remoteRevision = optionalNonnegativeInteger(snapshot.structuredContent, "revision");
|
|
68
|
+
const remoteState = optionalActivityState(snapshot.structuredContent);
|
|
69
|
+
if (remoteRevision !== undefined)
|
|
70
|
+
route.stateRevision = remoteRevision;
|
|
71
|
+
if (remoteState !== undefined)
|
|
72
|
+
route.state = remoteState;
|
|
73
|
+
revision += route.stateRevision ?? 0;
|
|
74
|
+
}
|
|
75
|
+
const snapshot = {
|
|
76
|
+
turnId,
|
|
77
|
+
revision,
|
|
78
|
+
changed: knownRevision === undefined || knownRevision !== revision,
|
|
79
|
+
state: aggregateSourceState(local, state.remoteMembers.values()),
|
|
80
|
+
};
|
|
81
|
+
return dataResult(snapshot);
|
|
82
|
+
}
|
|
83
|
+
async index(turnId, knownRevision) {
|
|
84
|
+
const state = this.turns.get(turnId);
|
|
85
|
+
if (!state)
|
|
86
|
+
return undefined;
|
|
87
|
+
const incremental = knownRevision !== undefined && state.lastIndexRevision === knownRevision;
|
|
88
|
+
const local = this.queries.index(turnId, incremental ? state.localIndexRevision : undefined);
|
|
89
|
+
const remoteIndexes = await Promise.all([...state.remoteMembers.values()].map(async (route) => ({
|
|
90
|
+
route,
|
|
91
|
+
index: await this.remoteWorkspaces.activityIndex(route.remoteTurnId, incremental ? route.indexRevision : undefined, state.conversationScopeId),
|
|
92
|
+
})));
|
|
93
|
+
state.localIndexRevision = local.revision;
|
|
94
|
+
const returnedRemoteActivities = [];
|
|
95
|
+
for (const { route, index } of remoteIndexes) {
|
|
96
|
+
if (!index?.structuredContent)
|
|
97
|
+
continue;
|
|
98
|
+
const structured = index.structuredContent;
|
|
99
|
+
const remoteRevision = optionalNonnegativeInteger(structured, "revision");
|
|
100
|
+
const remoteState = optionalActivityState(structured);
|
|
101
|
+
const incoming = Array.isArray(structured.activities)
|
|
71
102
|
? structured.activities
|
|
72
103
|
: [];
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
state.remoteActivities.set(activity.activityId, route);
|
|
77
|
-
if (activity.outputId)
|
|
78
|
-
state.remoteOutputs.set(activity.outputId, route);
|
|
104
|
+
if (remoteRevision !== undefined) {
|
|
105
|
+
route.indexRevision = remoteRevision;
|
|
106
|
+
route.stateRevision = remoteRevision;
|
|
79
107
|
}
|
|
108
|
+
if (remoteState !== undefined)
|
|
109
|
+
route.state = remoteState;
|
|
110
|
+
route.activities = incremental
|
|
111
|
+
? mergeActivities(route.activities ?? [], incoming)
|
|
112
|
+
: [...incoming];
|
|
113
|
+
returnedRemoteActivities.push(...(incremental ? incoming : route.activities).map((activity) => ({
|
|
114
|
+
...activity,
|
|
115
|
+
member: route.member,
|
|
116
|
+
})));
|
|
80
117
|
}
|
|
81
|
-
|
|
82
|
-
|
|
118
|
+
rebuildRemoteRoutes(state);
|
|
119
|
+
const revision = local.revision + [...state.remoteMembers.values()]
|
|
120
|
+
.reduce((sum, route) => sum + (route.indexRevision ?? route.stateRevision ?? 0), 0);
|
|
83
121
|
const changed = knownRevision === undefined || knownRevision !== revision;
|
|
84
|
-
const
|
|
122
|
+
const activities = changed
|
|
123
|
+
? [...local.activities, ...returnedRemoteActivities]
|
|
124
|
+
.sort((left, right) => left.startedAt.localeCompare(right.startedAt))
|
|
125
|
+
: [];
|
|
126
|
+
state.lastIndexRevision = revision;
|
|
127
|
+
const index = {
|
|
85
128
|
turnId,
|
|
86
129
|
revision,
|
|
87
130
|
changed,
|
|
88
|
-
state:
|
|
89
|
-
activities
|
|
131
|
+
state: aggregateSourceState(local, state.remoteMembers.values()),
|
|
132
|
+
activities,
|
|
90
133
|
};
|
|
91
|
-
return
|
|
92
|
-
? `Composite Activity snapshot ${turnId} revision ${revision}.`
|
|
93
|
-
: `Composite Activity snapshot ${turnId} unchanged at revision ${revision}.`);
|
|
134
|
+
return dataResult(index);
|
|
94
135
|
}
|
|
95
136
|
async detail(turnId, activityId) {
|
|
96
137
|
const state = this.turns.get(turnId);
|
|
@@ -114,14 +155,14 @@ export class CompositeActivityCoordinator {
|
|
|
114
155
|
},
|
|
115
156
|
};
|
|
116
157
|
}
|
|
117
|
-
async output(turnId, outputId) {
|
|
158
|
+
async output(turnId, outputId, cursor) {
|
|
118
159
|
const state = this.turns.get(turnId);
|
|
119
160
|
if (!state)
|
|
120
161
|
return undefined;
|
|
121
162
|
const route = state.remoteOutputs.get(outputId);
|
|
122
163
|
if (!route)
|
|
123
164
|
return undefined;
|
|
124
|
-
return this.remoteWorkspaces.activityOutput(route.remoteTurnId, outputId, state.conversationScopeId);
|
|
165
|
+
return this.remoteWorkspaces.activityOutput(route.remoteTurnId, outputId, state.conversationScopeId, cursor);
|
|
125
166
|
}
|
|
126
167
|
forgetComposite(workspaceId) {
|
|
127
168
|
for (const [turnId, state] of this.turns) {
|
|
@@ -130,21 +171,71 @@ export class CompositeActivityCoordinator {
|
|
|
130
171
|
}
|
|
131
172
|
}
|
|
132
173
|
}
|
|
133
|
-
function
|
|
174
|
+
function panelResult(snapshot, text) {
|
|
134
175
|
return {
|
|
135
176
|
content: [{ type: "text", text }],
|
|
136
177
|
structuredContent: snapshot,
|
|
137
178
|
};
|
|
138
179
|
}
|
|
139
|
-
function
|
|
140
|
-
|
|
180
|
+
function dataResult(snapshot) {
|
|
181
|
+
return {
|
|
182
|
+
content: [],
|
|
183
|
+
structuredContent: snapshot,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
function aggregateSourceState(local, remotes) {
|
|
187
|
+
const sources = [
|
|
188
|
+
...(local.revision > 0 ? [{ revision: local.revision, state: local.state }] : []),
|
|
189
|
+
...[...remotes].flatMap((route) => {
|
|
190
|
+
const revision = route.stateRevision ?? route.indexRevision ?? 0;
|
|
191
|
+
return revision > 0 && route.state ? [{ revision, state: route.state }] : [];
|
|
192
|
+
}),
|
|
193
|
+
];
|
|
194
|
+
if (sources.length === 0)
|
|
141
195
|
return "working";
|
|
142
|
-
if (
|
|
196
|
+
if (sources.some((source) => source.state === "working"))
|
|
143
197
|
return "working";
|
|
144
|
-
if (
|
|
198
|
+
if (sources.some((source) => source.state === "error"))
|
|
145
199
|
return "error";
|
|
146
200
|
return "done";
|
|
147
201
|
}
|
|
202
|
+
function mergeActivities(current, incoming) {
|
|
203
|
+
const updates = new Map(incoming.map((activity) => [activity.activityId, activity]));
|
|
204
|
+
const merged = current.map((activity) => updates.get(activity.activityId) ?? activity);
|
|
205
|
+
const existing = new Set(current.map((activity) => activity.activityId));
|
|
206
|
+
for (const activity of incoming) {
|
|
207
|
+
if (!existing.has(activity.activityId))
|
|
208
|
+
merged.push(activity);
|
|
209
|
+
}
|
|
210
|
+
return merged;
|
|
211
|
+
}
|
|
212
|
+
function rebuildRemoteRoutes(state) {
|
|
213
|
+
state.remoteActivities.clear();
|
|
214
|
+
state.remoteOutputs.clear();
|
|
215
|
+
for (const route of state.remoteMembers.values()) {
|
|
216
|
+
for (const activity of route.activities ?? []) {
|
|
217
|
+
state.remoteActivities.set(activity.activityId, route);
|
|
218
|
+
if (activity.outputId)
|
|
219
|
+
state.remoteOutputs.set(activity.outputId, route);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
function optionalNonnegativeInteger(value, field) {
|
|
224
|
+
if (!value || typeof value !== "object")
|
|
225
|
+
return undefined;
|
|
226
|
+
const candidate = value[field];
|
|
227
|
+
return typeof candidate === "number" && Number.isInteger(candidate) && candidate >= 0
|
|
228
|
+
? candidate
|
|
229
|
+
: undefined;
|
|
230
|
+
}
|
|
231
|
+
function optionalActivityState(value) {
|
|
232
|
+
if (!value || typeof value !== "object")
|
|
233
|
+
return undefined;
|
|
234
|
+
const candidate = value.state;
|
|
235
|
+
return candidate === "working" || candidate === "done" || candidate === "error"
|
|
236
|
+
? candidate
|
|
237
|
+
: undefined;
|
|
238
|
+
}
|
|
148
239
|
function requiredString(value, field, label) {
|
|
149
240
|
if (!value || typeof value !== "object")
|
|
150
241
|
throw new Error(`${label} did not return structured content.`);
|
package/dist/config.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { existsSync } from "node:fs";
|
|
2
1
|
import { homedir } from "node:os";
|
|
3
2
|
import { join, resolve } from "node:path";
|
|
4
3
|
import { expandHomePath } from "./roots.js";
|
|
@@ -49,7 +48,7 @@ function parseBoolean(value) {
|
|
|
49
48
|
return ["1", "true", "yes", "on"].includes(value?.toLowerCase() ?? "");
|
|
50
49
|
}
|
|
51
50
|
function productEnv(env, suffix) {
|
|
52
|
-
return env[`FORGERELAY_${suffix}`]
|
|
51
|
+
return env[`FORGERELAY_${suffix}`];
|
|
53
52
|
}
|
|
54
53
|
function parseToolMode(env) {
|
|
55
54
|
const mode = productEnv(env, "TOOL_MODE");
|
|
@@ -57,9 +56,9 @@ function parseToolMode(env) {
|
|
|
57
56
|
return mode;
|
|
58
57
|
if (mode)
|
|
59
58
|
throw new Error(`Invalid FORGERELAY_TOOL_MODE: ${mode}`);
|
|
60
|
-
const
|
|
61
|
-
if (
|
|
62
|
-
return parseBoolean(
|
|
59
|
+
const minimalTools = productEnv(env, "MINIMAL_TOOLS");
|
|
60
|
+
if (minimalTools !== undefined) {
|
|
61
|
+
return parseBoolean(minimalTools) ? "minimal" : "full";
|
|
63
62
|
}
|
|
64
63
|
return "minimal";
|
|
65
64
|
}
|
|
@@ -173,14 +172,10 @@ function parseOAuthConfig(env, ownerToken) {
|
|
|
173
172
|
};
|
|
174
173
|
}
|
|
175
174
|
function defaultStateDir() {
|
|
176
|
-
|
|
177
|
-
const legacy = join(homedir(), ".local", "share", "devspace");
|
|
178
|
-
return existsSync(current) || !existsSync(legacy) ? current : legacy;
|
|
175
|
+
return join(homedir(), ".local", "share", "forgerelay");
|
|
179
176
|
}
|
|
180
177
|
function defaultWorktreeRoot() {
|
|
181
|
-
|
|
182
|
-
const legacy = join(homedir(), ".devspace", "worktrees");
|
|
183
|
-
return existsSync(current) || !existsSync(legacy) ? current : legacy;
|
|
178
|
+
return join(homedir(), ".forgerelay", "worktrees");
|
|
184
179
|
}
|
|
185
180
|
function defaultAgentDir() {
|
|
186
181
|
return join(homedir(), ".codex");
|
|
@@ -260,8 +255,8 @@ export function loadConfig(env = process.env) {
|
|
|
260
255
|
taskReminderInterval: parseNonNegativeInteger(productEnv(env, "TASK_REMINDER_INTERVAL") ?? numberConfigValue(files.config.taskReminderInterval), DEFAULT_TASK_REMINDER_INTERVAL, "FORGERELAY_TASK_REMINDER_INTERVAL"),
|
|
261
256
|
skillsEnabled: productEnv(env, "SKILLS") === undefined ? true : parseBoolean(productEnv(env, "SKILLS")),
|
|
262
257
|
skillPaths: parsePathList(productEnv(env, "SKILL_PATHS")),
|
|
263
|
-
|
|
264
|
-
|
|
258
|
+
configSkillsDir: forgerelaySkillsDir(env),
|
|
259
|
+
configAgentsDir: forgerelayAgentsDir(env),
|
|
265
260
|
subagents: productEnv(env, "SUBAGENTS") === undefined
|
|
266
261
|
? files.config.subagents === true
|
|
267
262
|
: parseBoolean(productEnv(env, "SUBAGENTS")),
|
|
@@ -26,13 +26,13 @@ export async function createCodeIntelligenceServerFixture(t, options = {}) {
|
|
|
26
26
|
await writeFile(join(agentDir, "AGENTS.md"), "global instructions\n");
|
|
27
27
|
await writeFile(join(project, "AGENTS.md"), "project instructions\n");
|
|
28
28
|
const config = loadConfig({
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
29
|
+
FORGERELAY_CONFIG_DIR: join(root, ".config"),
|
|
30
|
+
FORGERELAY_ALLOWED_ROOTS: root,
|
|
31
|
+
FORGERELAY_WORKTREE_ROOT: join(root, ".worktrees"),
|
|
32
|
+
FORGERELAY_AGENT_DIR: agentDir,
|
|
33
|
+
FORGERELAY_WIDGETS: "full",
|
|
34
|
+
FORGERELAY_TOOL_MODE: "full",
|
|
35
|
+
FORGERELAY_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough",
|
|
36
36
|
PORT: "1",
|
|
37
37
|
});
|
|
38
38
|
const store = new SqliteWorkspaceStore(stateDir);
|
package/dist/process-sessions.js
CHANGED
|
@@ -65,8 +65,8 @@ function processEnvironment(input) {
|
|
|
65
65
|
...(input?.codexCi ? { CODEX_CI: "1" } : {}),
|
|
66
66
|
LANG: process.env.LANG ?? "C.UTF-8",
|
|
67
67
|
LC_ALL: process.env.LC_ALL ?? "C.UTF-8",
|
|
68
|
-
...(input?.workspaceId ? { FORGERELAY_WORKSPACE_ID: input.workspaceId
|
|
69
|
-
...(input?.workspaceRoot ? { FORGERELAY_WORKSPACE_ROOT: input.workspaceRoot
|
|
68
|
+
...(input?.workspaceId ? { FORGERELAY_WORKSPACE_ID: input.workspaceId } : {}),
|
|
69
|
+
...(input?.workspaceRoot ? { FORGERELAY_WORKSPACE_ROOT: input.workspaceRoot } : {}),
|
|
70
70
|
};
|
|
71
71
|
}
|
|
72
72
|
function codePointLength(value) {
|
package/dist/remote-auth.js
CHANGED
|
@@ -47,7 +47,7 @@ export function isRemoteMcpUnauthorized(error) {
|
|
|
47
47
|
return error instanceof UnauthorizedError ||
|
|
48
48
|
(error instanceof StreamableHTTPError && error.code === 401);
|
|
49
49
|
}
|
|
50
|
-
export async function
|
|
50
|
+
export async function connectRemoteMcpClient(remote, endpointInput) {
|
|
51
51
|
const endpoint = normalizeRemoteServiceTarget(endpointInput);
|
|
52
52
|
const client = new Client({ name: "forgerelay-cli", version: packageVersion });
|
|
53
53
|
const transport = new StreamableHTTPClientTransport(publicEndpointUrl(endpoint, "mcp"), {
|
|
@@ -57,6 +57,16 @@ export async function withRemoteMcpClient(remote, endpointInput, operation) {
|
|
|
57
57
|
});
|
|
58
58
|
try {
|
|
59
59
|
await client.connect(transport);
|
|
60
|
+
return client;
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
await client.close().catch(() => undefined);
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
export async function withRemoteMcpClient(remote, endpointInput, operation) {
|
|
68
|
+
const client = await connectRemoteMcpClient(remote, endpointInput);
|
|
69
|
+
try {
|
|
60
70
|
return await operation(client);
|
|
61
71
|
}
|
|
62
72
|
finally {
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { connectRemoteMcpClient } from "./remote-auth.js";
|
|
2
|
+
import { openRemoteServiceEndpoint, } from "./remote-transport.js";
|
|
3
|
+
export class RemoteMcpConnectionPool {
|
|
4
|
+
entries = new Map();
|
|
5
|
+
async get(remote) {
|
|
6
|
+
const fingerprint = connectionFingerprint(remote);
|
|
7
|
+
const existing = this.entries.get(remote.instanceId);
|
|
8
|
+
if (existing?.fingerprint === fingerprint)
|
|
9
|
+
return existing.promise;
|
|
10
|
+
if (existing)
|
|
11
|
+
await this.closeEntry(remote.instanceId, existing);
|
|
12
|
+
const entry = {
|
|
13
|
+
fingerprint,
|
|
14
|
+
promise: this.create(remote),
|
|
15
|
+
};
|
|
16
|
+
this.entries.set(remote.instanceId, entry);
|
|
17
|
+
try {
|
|
18
|
+
return await entry.promise;
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
if (this.entries.get(remote.instanceId) === entry) {
|
|
22
|
+
this.entries.delete(remote.instanceId);
|
|
23
|
+
}
|
|
24
|
+
throw error;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
async invalidate(instanceId, expected) {
|
|
28
|
+
const entry = this.entries.get(instanceId);
|
|
29
|
+
if (!entry)
|
|
30
|
+
return;
|
|
31
|
+
let connection;
|
|
32
|
+
try {
|
|
33
|
+
connection = await entry.promise;
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
if (this.entries.get(instanceId) === entry)
|
|
37
|
+
this.entries.delete(instanceId);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (expected && connection !== expected)
|
|
41
|
+
return;
|
|
42
|
+
await this.closeEntry(instanceId, entry, connection);
|
|
43
|
+
}
|
|
44
|
+
async closeAll() {
|
|
45
|
+
const entries = [...this.entries.entries()];
|
|
46
|
+
this.entries.clear();
|
|
47
|
+
await Promise.all(entries.map(async ([, entry]) => {
|
|
48
|
+
try {
|
|
49
|
+
const connection = await entry.promise;
|
|
50
|
+
await closeConnection(connection);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// Failed connection attempts already clean up their endpoint lease.
|
|
54
|
+
}
|
|
55
|
+
}));
|
|
56
|
+
}
|
|
57
|
+
async create(remote) {
|
|
58
|
+
const lease = await openRemoteServiceEndpoint(remote.target, remote.sshRoute);
|
|
59
|
+
try {
|
|
60
|
+
const client = await connectRemoteMcpClient(remote, lease.endpoint);
|
|
61
|
+
return { client, endpoint: lease.endpoint, lease };
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
await lease.close().catch(() => undefined);
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
async closeEntry(instanceId, entry, resolved) {
|
|
69
|
+
if (this.entries.get(instanceId) === entry)
|
|
70
|
+
this.entries.delete(instanceId);
|
|
71
|
+
try {
|
|
72
|
+
const connection = resolved ?? await entry.promise;
|
|
73
|
+
await closeConnection(connection);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
// Failed connection attempts already clean up their endpoint lease.
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
async function closeConnection(connection) {
|
|
81
|
+
await connection.client.close().catch(() => undefined);
|
|
82
|
+
await connection.lease.close().catch(() => undefined);
|
|
83
|
+
}
|
|
84
|
+
function connectionFingerprint(remote) {
|
|
85
|
+
return JSON.stringify({
|
|
86
|
+
target: remote.target,
|
|
87
|
+
sshRoute: remote.sshRoute ?? [],
|
|
88
|
+
accessToken: remote.accessToken,
|
|
89
|
+
});
|
|
90
|
+
}
|
package/dist/remote-transport.js
CHANGED
|
@@ -36,9 +36,10 @@ export async function readRemoteOwnerToken(sshRoute) {
|
|
|
36
36
|
throw new Error("SSH owner-token command returned an empty token.");
|
|
37
37
|
return token;
|
|
38
38
|
}
|
|
39
|
-
export async function
|
|
40
|
-
if (!sshRoute)
|
|
41
|
-
return
|
|
39
|
+
export async function openRemoteServiceEndpoint(target, sshRoute) {
|
|
40
|
+
if (!sshRoute) {
|
|
41
|
+
return { endpoint: target, close: async () => undefined };
|
|
42
|
+
}
|
|
42
43
|
const url = new URL(target);
|
|
43
44
|
if (url.protocol === "https:") {
|
|
44
45
|
throw new Error("SSH-routed HTTPS service targets are not supported because loopback forwarding breaks TLS hostname verification; use the remote ForgeRelay HTTP loopback endpoint through SSH or direct HTTPS.");
|
|
@@ -73,19 +74,32 @@ export async function withRemoteServiceEndpoint(target, sshRoute, operation) {
|
|
|
73
74
|
});
|
|
74
75
|
try {
|
|
75
76
|
await waitForLoopbackPort(tunnel, localPort, stderr, () => spawnError, () => forwardingReady);
|
|
76
|
-
const mapped = new URL(url);
|
|
77
|
-
mapped.hostname = "127.0.0.1";
|
|
78
|
-
mapped.port = String(localPort);
|
|
79
|
-
try {
|
|
80
|
-
return await operation(mapped.toString().replace(/\/$/, ""));
|
|
81
|
-
}
|
|
82
|
-
catch (error) {
|
|
83
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
84
|
-
throw new Error(`Remote service request through SSH tunnel failed: ${message}`, { cause: error });
|
|
85
|
-
}
|
|
86
77
|
}
|
|
87
|
-
|
|
78
|
+
catch (error) {
|
|
88
79
|
await stopTunnel(tunnel);
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
const mapped = new URL(url);
|
|
83
|
+
mapped.hostname = "127.0.0.1";
|
|
84
|
+
mapped.port = String(localPort);
|
|
85
|
+
return {
|
|
86
|
+
endpoint: mapped.toString().replace(/\/$/, ""),
|
|
87
|
+
close: () => stopTunnel(tunnel),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
export async function withRemoteServiceEndpoint(target, sshRoute, operation) {
|
|
91
|
+
const lease = await openRemoteServiceEndpoint(target, sshRoute);
|
|
92
|
+
try {
|
|
93
|
+
return await operation(lease.endpoint);
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
if (!sshRoute)
|
|
97
|
+
throw error;
|
|
98
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
99
|
+
throw new Error(`Remote service request through SSH tunnel failed: ${message}`, { cause: error });
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
await lease.close();
|
|
89
103
|
}
|
|
90
104
|
}
|
|
91
105
|
function sshDestinationArgs(sshRoute) {
|
|
@@ -2,7 +2,8 @@ import { randomBytes } from "node:crypto";
|
|
|
2
2
|
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { CallToolResultSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
5
|
-
import { isRemoteMcpUnauthorized, refreshRemoteAuthentication,
|
|
5
|
+
import { isRemoteMcpUnauthorized, refreshRemoteAuthentication, } from "./remote-auth.js";
|
|
6
|
+
import { RemoteMcpConnectionPool } from "./remote-mcp-connection-pool.js";
|
|
6
7
|
import { withRemoteServiceEndpoint } from "./remote-transport.js";
|
|
7
8
|
import { loadForgeRelayFiles, writeForgeRelayRemote, } from "./user-config.js";
|
|
8
9
|
const ROUTE_LOCK_RETRY_MS = 10;
|
|
@@ -15,6 +16,7 @@ export class RemoteWorkspaceRelay {
|
|
|
15
16
|
authEnv;
|
|
16
17
|
routeStateDir;
|
|
17
18
|
routeStatePath;
|
|
19
|
+
mcpConnections = new RemoteMcpConnectionPool();
|
|
18
20
|
constructor(configDir, stateDir) {
|
|
19
21
|
this.authEnv = { FORGERELAY_CONFIG_DIR: configDir };
|
|
20
22
|
this.routeStateDir = stateDir;
|
|
@@ -27,6 +29,9 @@ export class RemoteWorkspaceRelay {
|
|
|
27
29
|
this.loadRoutes();
|
|
28
30
|
return this.routes.has(workspaceId);
|
|
29
31
|
}
|
|
32
|
+
async shutdown() {
|
|
33
|
+
await this.mcpConnections.closeAll();
|
|
34
|
+
}
|
|
30
35
|
async inspectWorkspace(gatewayWorkspaceId) {
|
|
31
36
|
const route = this.requireRoute(gatewayWorkspaceId);
|
|
32
37
|
const resolved = this.remoteByInstance(route.remoteInstanceId);
|
|
@@ -235,11 +240,14 @@ export class RemoteWorkspaceRelay {
|
|
|
235
240
|
throw sanitizedRemoteError(error, route.remoteWorkspaceId, gatewayWorkspaceId);
|
|
236
241
|
}
|
|
237
242
|
}
|
|
243
|
+
async activityIndex(turnId, knownRevision, conversationScopeId) {
|
|
244
|
+
return this.callTurnTool(turnId, "activity_index", { turnId, ...(knownRevision !== undefined ? { knownRevision } : {}) }, conversationScopeId);
|
|
245
|
+
}
|
|
238
246
|
async activityDetail(turnId, activityId, conversationScopeId) {
|
|
239
247
|
return this.callTurnTool(turnId, "activity_detail", { turnId, activityId }, conversationScopeId);
|
|
240
248
|
}
|
|
241
|
-
async activityOutput(turnId, outputId, conversationScopeId) {
|
|
242
|
-
return this.callTurnTool(turnId, "activity_output", { turnId, outputId }, conversationScopeId);
|
|
249
|
+
async activityOutput(turnId, outputId, conversationScopeId, cursor) {
|
|
250
|
+
return this.callTurnTool(turnId, "activity_output", { turnId, outputId, ...(cursor !== undefined ? { cursor } : {}) }, conversationScopeId);
|
|
243
251
|
}
|
|
244
252
|
async callTurnTool(turnId, name, args, conversationScopeId) {
|
|
245
253
|
const gatewayWorkspaceId = this.turnRoutes.get(turnId);
|
|
@@ -464,31 +472,53 @@ export class RemoteWorkspaceRelay {
|
|
|
464
472
|
return { alias: entry[0], remote: entry[1] };
|
|
465
473
|
}
|
|
466
474
|
async callRemoteTool(alias, initialRemote, name, args, conversationScopeId) {
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
475
|
+
let remote = initialRemote;
|
|
476
|
+
let refreshed = false;
|
|
477
|
+
if (remote.accessTokenExpiresAt <= Math.floor(Date.now() / 1000)) {
|
|
478
|
+
remote = await withRemoteServiceEndpoint(remote.target, remote.sshRoute, (endpoint) => this.refreshRemote(alias, remote, endpoint));
|
|
479
|
+
refreshed = true;
|
|
480
|
+
}
|
|
481
|
+
let connection;
|
|
482
|
+
try {
|
|
483
|
+
connection = await this.mcpConnections.get(remote);
|
|
484
|
+
}
|
|
485
|
+
catch (error) {
|
|
486
|
+
if (!refreshed && isRemoteMcpUnauthorized(error)) {
|
|
487
|
+
remote = await withRemoteServiceEndpoint(remote.target, remote.sshRoute, (endpoint) => this.refreshRemote(alias, remote, endpoint));
|
|
472
488
|
refreshed = true;
|
|
489
|
+
connection = await this.mcpConnections.get(remote);
|
|
473
490
|
}
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
arguments: args,
|
|
477
|
-
...(conversationScopeId
|
|
478
|
-
? { _meta: { "openai/session": conversationScopeId } }
|
|
479
|
-
: {}),
|
|
480
|
-
})));
|
|
481
|
-
try {
|
|
482
|
-
return await invoke();
|
|
491
|
+
else {
|
|
492
|
+
throw new Error(`Remote ForgeRelay ${alias} request failed: ${errorMessage(error)}`, { cause: error });
|
|
483
493
|
}
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
494
|
+
}
|
|
495
|
+
const invoke = async (active) => CallToolResultSchema.parse(await active.client.callTool({
|
|
496
|
+
name,
|
|
497
|
+
arguments: args,
|
|
498
|
+
...(conversationScopeId
|
|
499
|
+
? { _meta: { "openai/session": conversationScopeId } }
|
|
500
|
+
: {}),
|
|
501
|
+
}));
|
|
502
|
+
try {
|
|
503
|
+
return await invoke(connection);
|
|
504
|
+
}
|
|
505
|
+
catch (error) {
|
|
506
|
+
if (!refreshed && isRemoteMcpUnauthorized(error)) {
|
|
507
|
+
remote = await this.refreshRemote(alias, remote, connection.endpoint);
|
|
508
|
+
refreshed = true;
|
|
509
|
+
await this.mcpConnections.invalidate(remote.instanceId, connection);
|
|
510
|
+
const refreshedConnection = await this.mcpConnections.get(remote);
|
|
511
|
+
try {
|
|
512
|
+
return await invoke(refreshedConnection);
|
|
513
|
+
}
|
|
514
|
+
catch (retryError) {
|
|
515
|
+
await this.mcpConnections.invalidate(remote.instanceId, refreshedConnection);
|
|
516
|
+
throw new Error(`Remote ForgeRelay ${alias} request failed: ${errorMessage(retryError)}`, { cause: retryError });
|
|
488
517
|
}
|
|
489
|
-
throw new Error(`Remote ForgeRelay ${alias} request failed: ${errorMessage(error)}`, { cause: error });
|
|
490
518
|
}
|
|
491
|
-
|
|
519
|
+
await this.mcpConnections.invalidate(remote.instanceId, connection);
|
|
520
|
+
throw new Error(`Remote ForgeRelay ${alias} request failed: ${errorMessage(error)}`, { cause: error });
|
|
521
|
+
}
|
|
492
522
|
}
|
|
493
523
|
async refreshRemote(alias, remote, endpoint) {
|
|
494
524
|
const refreshed = await refreshRemoteAuthentication(remote, endpoint);
|