@akira-tl/forgerelay 0.8.4 → 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.
Files changed (59) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +10 -10
  3. package/dist/activity/audit-store.js +44 -6
  4. package/dist/activity/mcp-query-tools.js +53 -36
  5. package/dist/activity/query-service.js +41 -11
  6. package/dist/cli.js +16 -17
  7. package/dist/composite-activity.js +124 -33
  8. package/dist/config.js +8 -13
  9. package/dist/lsp/test-support/server-fixture.js +7 -7
  10. package/dist/process-sessions.js +2 -2
  11. package/dist/remote-auth.js +11 -1
  12. package/dist/remote-mcp-connection-pool.js +90 -0
  13. package/dist/remote-transport.js +28 -14
  14. package/dist/remote-workspace-relay.js +203 -45
  15. package/dist/server.js +134 -64
  16. package/dist/skills.js +1 -1
  17. package/dist/subagents/profiles.js +1 -2
  18. package/dist/subagents/providers/adapters/pi.js +2 -2
  19. package/dist/subagents/providers/availability.js +2 -2
  20. package/dist/subagents/providers/path.js +4 -4
  21. package/dist/ui/.vite/manifest.json +32 -32
  22. package/dist/ui/activity-panel-app.html +3 -3
  23. package/dist/ui/assets/{activity-panel-app-E1ju2dqI.js → activity-panel-app-CUAN6zyW.js} +1 -1
  24. package/dist/ui/assets/{heavy-payload-CeW-n9w5.js → heavy-payload-CgzrutLm.js} +1 -1
  25. package/dist/ui/assets/{review-payload-B9CO298v.js → review-payload-BrLbezbq.js} +1 -1
  26. package/dist/ui/assets/{scrollbar-C2twAENW.js → scrollbar-CbhpdW05.js} +1 -1
  27. package/dist/ui/assets/workspace-app-Bhj96tsR.js +1 -0
  28. package/dist/ui/assets/{workspace-app-CwbJnb_w.js → workspace-app-CxwJuZyS.js} +1 -1
  29. package/dist/ui/assets/{workspace-app-BztEvZIC.js → workspace-app-D6UR0AFl.js} +3 -3
  30. package/dist/ui/assets/workspace-app-ldjBmCJR.css +1 -0
  31. package/dist/ui/assets/workspace-lifecycle-app-Cqfhx9pV.js +1 -0
  32. package/dist/ui/workspace-app.html +4 -4
  33. package/dist/ui/workspace-lifecycle-app.html +4 -4
  34. package/dist/user-config.js +3 -19
  35. package/dist/workspace-presentation.js +69 -0
  36. package/docs/agent-profile-schema.md +4 -9
  37. package/docs/artifact-exchange.md +2 -1
  38. package/docs/chatgpt-coding-workflow.md +9 -9
  39. package/docs/configuration.md +20 -29
  40. package/docs/gotchas.md +10 -7
  41. package/docs/roadmap.md +6 -3
  42. package/docs/security.md +3 -2
  43. package/docs/setup.md +8 -5
  44. package/docs/versioning.md +1 -1
  45. package/package.json +6 -2
  46. package/scripts/debug/accept.mjs +7 -1
  47. package/scripts/debug/relay-accept.mjs +889 -0
  48. package/scripts/debug/runtime.mjs +0 -4
  49. package/scripts/debug/runtime.test.mjs +0 -2
  50. package/scripts/debug/traffic/run.sh +5 -0
  51. package/scripts/debug/traffic/traffic-audit.mjs +907 -0
  52. package/scripts/release/push-ready.mjs +124 -0
  53. package/scripts/release/push-ready.test.mjs +108 -0
  54. package/scripts/release/release-gate.test.mjs +1 -0
  55. package/scripts/release-proof.mjs +16 -1
  56. package/scripts/wiki/sync.mjs +246 -0
  57. package/dist/ui/assets/workspace-app-YnUST8IP.css +0 -1
  58. package/dist/ui/assets/workspace-app-rKuhdae8.js +0 -1
  59. 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 snapshotResult(snapshot, `Started Composite Workspace Host Turn ${snapshot.turnId}.`);
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.snapshot(turnId);
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({ turnId: route.remoteTurnId }, state.conversationScopeId),
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 structured = snapshot.structuredContent;
66
- const remoteRevision = structured.revision;
67
- if (typeof remoteRevision === "number" && Number.isInteger(remoteRevision) && remoteRevision >= 0) {
68
- revision += remoteRevision;
69
- }
70
- const activities = Array.isArray(structured.activities)
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
- for (const activity of activities) {
74
- const presented = { ...activity, member: route.member };
75
- remoteActivities.push(presented);
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
- const activities = [...local.activities, ...remoteActivities]
82
- .sort((left, right) => left.startedAt.localeCompare(right.startedAt));
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 snapshot = {
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: aggregateState(activities),
89
- activities: changed ? activities : [],
131
+ state: aggregateSourceState(local, state.remoteMembers.values()),
132
+ activities,
90
133
  };
91
- return snapshotResult(snapshot, changed
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 snapshotResult(snapshot, text) {
174
+ function panelResult(snapshot, text) {
134
175
  return {
135
176
  content: [{ type: "text", text }],
136
177
  structuredContent: snapshot,
137
178
  };
138
179
  }
139
- function aggregateState(activities) {
140
- if (activities.length === 0)
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 (activities.some((activity) => activity.status === "working"))
196
+ if (sources.some((source) => source.state === "working"))
143
197
  return "working";
144
- if (activities.some((activity) => activity.status === "error"))
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}`] ?? env[`DEVSPACE_${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 legacyMinimal = productEnv(env, "MINIMAL_TOOLS");
61
- if (legacyMinimal !== undefined) {
62
- return parseBoolean(legacyMinimal) ? "minimal" : "full";
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
- const current = join(homedir(), ".local", "share", "forgerelay");
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
- const current = join(homedir(), ".forgerelay", "worktrees");
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
- devspaceSkillsDir: forgerelaySkillsDir(env),
264
- devspaceAgentsDir: forgerelayAgentsDir(env),
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
- DEVSPACE_CONFIG_DIR: join(root, ".config"),
30
- DEVSPACE_ALLOWED_ROOTS: root,
31
- DEVSPACE_WORKTREE_ROOT: join(root, ".worktrees"),
32
- DEVSPACE_AGENT_DIR: agentDir,
33
- DEVSPACE_WIDGETS: "full",
34
- DEVSPACE_TOOL_MODE: "full",
35
- DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough",
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);
@@ -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, DEVSPACE_WORKSPACE_ID: input.workspaceId } : {}),
69
- ...(input?.workspaceRoot ? { FORGERELAY_WORKSPACE_ROOT: input.workspaceRoot, DEVSPACE_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) {
@@ -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 withRemoteMcpClient(remote, endpointInput, operation) {
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
+ }
@@ -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 withRemoteServiceEndpoint(target, sshRoute, operation) {
40
- if (!sshRoute)
41
- return operation(target);
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
- finally {
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) {