@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.
Files changed (48) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +47 -12
  3. package/dist/activity/mcp-query-tools.js +48 -2
  4. package/dist/activity/query-service.js +1 -0
  5. package/dist/cli.js +181 -4
  6. package/dist/composite-activity.js +155 -0
  7. package/dist/composite-workspaces.js +197 -0
  8. package/dist/config.js +4 -1
  9. package/dist/oauth/router.js +21 -1
  10. package/dist/oauth-provider.js +32 -4
  11. package/dist/oauth-store.js +9 -0
  12. package/dist/remote-auth.js +110 -0
  13. package/dist/remote-transport.js +196 -0
  14. package/dist/remote-workspace-relay.js +473 -0
  15. package/dist/server.js +824 -121
  16. package/dist/ui/.vite/manifest.json +33 -33
  17. package/dist/ui/activity-panel-app.html +3 -3
  18. package/dist/ui/assets/{activity-panel-app-CjZVvVNc.js → activity-panel-app-E1ju2dqI.js} +1 -1
  19. package/dist/ui/assets/{heavy-payload-vGgBRvNX.js → heavy-payload-CeW-n9w5.js} +1 -1
  20. package/dist/ui/assets/{review-payload-4erWKckt.js → review-payload-B9CO298v.js} +1 -1
  21. package/dist/ui/assets/{scrollbar-CaOPzUJd.js → scrollbar-C2twAENW.js} +1 -1
  22. package/dist/ui/assets/workspace-app-BztEvZIC.js +5 -0
  23. package/dist/ui/assets/{workspace-app-DkAiSl_0.js → workspace-app-CwbJnb_w.js} +1 -1
  24. package/dist/ui/assets/workspace-app-YnUST8IP.css +1 -0
  25. package/dist/ui/assets/workspace-app-rKuhdae8.js +1 -0
  26. package/dist/ui/assets/workspace-lifecycle-app-CEfMdudP.js +1 -0
  27. package/dist/ui/workspace-app.html +4 -4
  28. package/dist/ui/workspace-lifecycle-app.html +4 -4
  29. package/dist/user-config.js +131 -5
  30. package/docs/configuration.md +26 -3
  31. package/docs/debugging.md +7 -0
  32. package/docs/versioning.md +11 -19
  33. package/package.json +5 -2
  34. package/scripts/ci/verify.mjs +38 -0
  35. package/scripts/debug/runtime.mjs +23 -1
  36. package/scripts/debug/runtime.test.mjs +14 -2
  37. package/scripts/debug/serve.mjs +4 -4
  38. package/scripts/release/pack.mjs +36 -0
  39. package/scripts/release/publish.mjs +157 -0
  40. package/scripts/release/release-gate.test.mjs +73 -16
  41. package/scripts/release-parity.mjs +5 -13
  42. package/scripts/release-proof.mjs +28 -19
  43. package/scripts/release-proof.test.mjs +32 -11
  44. package/scripts/release-version.mjs +1 -1
  45. package/dist/ui/assets/workspace-app-CcrHAUIn.css +0 -1
  46. package/dist/ui/assets/workspace-app-DJmkPYJC.js +0 -1
  47. package/dist/ui/assets/workspace-app-QyauBrJX.js +0 -5
  48. package/dist/ui/assets/workspace-lifecycle-app-BIXEo53I.js +0 -1
@@ -0,0 +1,197 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ export class CompositeWorkspaceRegistry {
5
+ stateDir;
6
+ statePath;
7
+ records = new Map();
8
+ constructor(stateDir) {
9
+ this.stateDir = stateDir;
10
+ this.statePath = join(stateDir, "composite-workspaces.json");
11
+ this.load();
12
+ }
13
+ has(workspaceId) {
14
+ return this.records.has(workspaceId);
15
+ }
16
+ create(name) {
17
+ const normalized = normalizeName(name);
18
+ const existing = [...this.records.values()].find((record) => record.name === normalized);
19
+ if (existing)
20
+ return this.touch(existing.id);
21
+ const now = new Date().toISOString();
22
+ const record = {
23
+ id: `cws_${randomBytes(5).toString("hex")}`,
24
+ kind: "composite",
25
+ name: normalized,
26
+ members: [],
27
+ createdAt: now,
28
+ lastUsedAt: now,
29
+ };
30
+ this.records.set(record.id, record);
31
+ this.persist();
32
+ return cloneRecord(record);
33
+ }
34
+ get(workspaceId) {
35
+ const record = this.records.get(workspaceId);
36
+ if (!record)
37
+ throw new Error(`Unknown Composite Workspace ${workspaceId}.`);
38
+ return cloneRecord(record);
39
+ }
40
+ open(workspaceId) {
41
+ return this.touch(workspaceId);
42
+ }
43
+ list() {
44
+ return [...this.records.values()]
45
+ .map(cloneRecord)
46
+ .sort((left, right) => right.lastUsedAt.localeCompare(left.lastUsedAt));
47
+ }
48
+ addMember(workspaceId, input) {
49
+ const record = this.requireRecord(workspaceId);
50
+ const name = normalizeMemberName(input.name);
51
+ const purpose = input.purpose.trim();
52
+ if (!purpose)
53
+ throw new Error("Composite Workspace member purpose must not be empty.");
54
+ const existing = record.members.find((member) => member.name === name);
55
+ if (existing) {
56
+ if (existing.purpose === purpose && existing.workspaceId === input.workspaceId) {
57
+ return cloneRecord(record);
58
+ }
59
+ throw new Error(`Composite Workspace ${workspaceId} already has member ${name} with a different definition.`);
60
+ }
61
+ record.members.push({ name, purpose, workspaceId: input.workspaceId });
62
+ record.lastUsedAt = new Date().toISOString();
63
+ this.persist();
64
+ return cloneRecord(record);
65
+ }
66
+ updateMember(workspaceId, memberName, input) {
67
+ const record = this.requireRecord(workspaceId);
68
+ const currentName = normalizeMemberName(memberName);
69
+ const index = record.members.findIndex((member) => member.name === currentName);
70
+ if (index < 0)
71
+ throw new Error(`Composite Workspace ${workspaceId} has no member ${currentName}.`);
72
+ const current = record.members[index];
73
+ const nextName = input.name === undefined ? current.name : normalizeMemberName(input.name);
74
+ const nextPurpose = input.purpose === undefined ? current.purpose : input.purpose.trim();
75
+ if (!nextPurpose)
76
+ throw new Error("Composite Workspace member purpose must not be empty.");
77
+ const nextWorkspaceId = input.workspaceId ?? current.workspaceId;
78
+ if (nextName !== current.name &&
79
+ record.members.some((member, memberIndex) => memberIndex !== index && member.name === nextName)) {
80
+ throw new Error(`Composite Workspace ${workspaceId} already has member ${nextName}.`);
81
+ }
82
+ if (nextName === current.name &&
83
+ nextPurpose === current.purpose &&
84
+ nextWorkspaceId === current.workspaceId) {
85
+ return cloneRecord(record);
86
+ }
87
+ record.members[index] = {
88
+ name: nextName,
89
+ purpose: nextPurpose,
90
+ workspaceId: nextWorkspaceId,
91
+ };
92
+ record.lastUsedAt = new Date().toISOString();
93
+ this.persist();
94
+ return cloneRecord(record);
95
+ }
96
+ removeMember(workspaceId, memberName) {
97
+ const record = this.requireRecord(workspaceId);
98
+ const name = normalizeMemberName(memberName);
99
+ const index = record.members.findIndex((member) => member.name === name);
100
+ if (index < 0)
101
+ throw new Error(`Composite Workspace ${workspaceId} has no member ${name}.`);
102
+ record.members.splice(index, 1);
103
+ record.lastUsedAt = new Date().toISOString();
104
+ this.persist();
105
+ return cloneRecord(record);
106
+ }
107
+ member(workspaceId, memberName) {
108
+ const record = this.requireRecord(workspaceId);
109
+ const name = normalizeMemberName(memberName);
110
+ const member = record.members.find((entry) => entry.name === name);
111
+ if (!member)
112
+ throw new Error(`Composite Workspace ${workspaceId} has no member ${name}.`);
113
+ return { ...member };
114
+ }
115
+ dissolve(workspaceId) {
116
+ const record = this.requireRecord(workspaceId);
117
+ this.records.delete(workspaceId);
118
+ this.persist();
119
+ return cloneRecord(record);
120
+ }
121
+ touch(workspaceId) {
122
+ const record = this.requireRecord(workspaceId);
123
+ record.lastUsedAt = new Date().toISOString();
124
+ this.persist();
125
+ return cloneRecord(record);
126
+ }
127
+ requireRecord(workspaceId) {
128
+ const record = this.records.get(workspaceId);
129
+ if (!record)
130
+ throw new Error(`Unknown Composite Workspace ${workspaceId}.`);
131
+ return record;
132
+ }
133
+ load() {
134
+ let parsed;
135
+ try {
136
+ parsed = JSON.parse(readFileSync(this.statePath, "utf8"));
137
+ }
138
+ catch (error) {
139
+ if (isMissingFile(error))
140
+ return;
141
+ throw new Error(`Failed to load Composite Workspace state: ${errorMessage(error)}`);
142
+ }
143
+ if (parsed?.version !== 1 || !Array.isArray(parsed.workspaces)) {
144
+ throw new Error("Composite Workspace state has an unsupported format.");
145
+ }
146
+ for (const record of parsed.workspaces) {
147
+ if (!record?.id?.startsWith("cws_") || record.kind !== "composite")
148
+ continue;
149
+ this.records.set(record.id, {
150
+ ...record,
151
+ members: Array.isArray(record.members) ? record.members.map((member) => ({ ...member })) : [],
152
+ });
153
+ }
154
+ }
155
+ persist() {
156
+ mkdirSync(this.stateDir, { recursive: true });
157
+ const state = {
158
+ version: 1,
159
+ workspaces: [...this.records.values()].map(cloneRecord),
160
+ };
161
+ const tempPath = `${this.statePath}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
162
+ try {
163
+ writeFileSync(tempPath, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
164
+ renameSync(tempPath, this.statePath);
165
+ }
166
+ finally {
167
+ rmSync(tempPath, { force: true });
168
+ }
169
+ }
170
+ }
171
+ function normalizeName(name) {
172
+ const value = name.trim();
173
+ if (!value)
174
+ throw new Error("Composite Workspace name must not be empty.");
175
+ if (value.length > 120)
176
+ throw new Error("Composite Workspace name must be at most 120 characters.");
177
+ return value;
178
+ }
179
+ function normalizeMemberName(name) {
180
+ const value = name.trim();
181
+ if (!/^[a-z][a-z0-9_-]{0,31}$/.test(value)) {
182
+ throw new Error("Composite Workspace member name must match /^[a-z][a-z0-9_-]{0,31}$/.");
183
+ }
184
+ return value;
185
+ }
186
+ function cloneRecord(record) {
187
+ return {
188
+ ...record,
189
+ members: record.members.map((member) => ({ ...member })),
190
+ };
191
+ }
192
+ function isMissingFile(error) {
193
+ return error instanceof Error && "code" in error && error.code === "ENOENT";
194
+ }
195
+ function errorMessage(error) {
196
+ return error instanceof Error ? error.message : String(error);
197
+ }
package/dist/config.js CHANGED
@@ -3,7 +3,7 @@ import { homedir } from "node:os";
3
3
  import { join, resolve } from "node:path";
4
4
  import { expandHomePath } from "./roots.js";
5
5
  import { mergeHookConfigs, parseHookConfig } from "./hooks.js";
6
- import { forgerelayAgentsDir, forgerelaySkillsDir, loadForgeRelayFiles, } from "./user-config.js";
6
+ import { forgerelayAgentsDir, forgerelaySkillsDir, generateInstanceId, loadForgeRelayFiles, } from "./user-config.js";
7
7
  const DEFAULT_OAUTH_ACCESS_TOKEN_TTL_SECONDS = 60 * 60;
8
8
  const DEFAULT_OAUTH_REFRESH_TOKEN_TTL_SECONDS = 30 * 24 * 60 * 60;
9
9
  const DEFAULT_ARTIFACT_MAX_FILE_BYTES = 100 * 1024 * 1024;
@@ -211,6 +211,7 @@ function resolvePublicDeployment(env, fileConfig, host, port) {
211
211
  }
212
212
  export function loadConfig(env = process.env) {
213
213
  const files = loadForgeRelayFiles(env);
214
+ const instanceId = files.auth.instanceId?.trim() || generateInstanceId();
214
215
  const host = env.HOST ?? files.config.host ?? "127.0.0.1";
215
216
  const port = parsePort(env.PORT ?? files.config.port);
216
217
  const publicDeployment = resolvePublicDeployment(env, files.config, host, port);
@@ -224,6 +225,8 @@ export function loadConfig(env = process.env) {
224
225
  ...(files.config.allowedHosts ?? []),
225
226
  ];
226
227
  return {
228
+ instanceId,
229
+ configDir: files.dir,
227
230
  host,
228
231
  port,
229
232
  oauth: parseOAuthConfig(env, files.auth.ownerToken),
@@ -7,7 +7,7 @@ import { revocationHandler } from "@modelcontextprotocol/sdk/server/auth/handler
7
7
  import { metadataHandler } from "@modelcontextprotocol/sdk/server/auth/handlers/metadata.js";
8
8
  import { oauthAuthorizationServerMetadataPath, publicEndpointUrl, } from "./public-url.js";
9
9
  export function createForgeRelayAuthRouter(options) {
10
- const { provider, issuerUrl, resourceServerUrl, scopesSupported, resourceName } = options;
10
+ const { provider, cliAuthenticationProvider, instanceId, issuerUrl, resourceServerUrl, scopesSupported, resourceName, } = options;
11
11
  const authorizationEndpoint = publicEndpointUrl(issuerUrl, "authorize");
12
12
  const tokenEndpoint = publicEndpointUrl(issuerUrl, "token");
13
13
  const registrationEndpoint = provider.clientsStore.registerClient
@@ -35,6 +35,26 @@ export function createForgeRelayAuthRouter(options) {
35
35
  resource_name: resourceName,
36
36
  };
37
37
  const router = express.Router();
38
+ if (cliAuthenticationProvider) {
39
+ router.post("/auth/cli", express.json({ limit: "4kb" }), (req, res) => {
40
+ const ownerToken = typeof req.body?.owner_token === "string" ? req.body.owner_token : undefined;
41
+ const refreshToken = typeof req.body?.refresh_token === "string" ? req.body.refresh_token : undefined;
42
+ if ((ownerToken ? 1 : 0) + (refreshToken ? 1 : 0) !== 1) {
43
+ res.status(400).json({ error: "invalid_request" });
44
+ return;
45
+ }
46
+ const tokens = ownerToken
47
+ ? cliAuthenticationProvider.issueCliTokens(ownerToken)
48
+ : cliAuthenticationProvider.exchangeCliRefreshToken(refreshToken);
49
+ if (!tokens) {
50
+ res.status(401).json({ error: "invalid_grant" });
51
+ return;
52
+ }
53
+ res.setHeader("Cache-Control", "no-store");
54
+ res.setHeader("Pragma", "no-cache");
55
+ res.status(200).json({ ...tokens, ...(instanceId ? { instance_id: instanceId } : {}) });
56
+ });
57
+ }
38
58
  router.use("/authorize", authorizationHandler({ provider }));
39
59
  router.use("/token", tokenHandler({ provider }));
40
60
  if (provider.clientsStore.registerClient) {
@@ -3,6 +3,7 @@ import { AccessDeniedError, InvalidGrantError, InvalidRequestError, InvalidToken
3
3
  import { checkResourceAllowed, resourceUrlFromServerUrl } from "@modelcontextprotocol/sdk/shared/auth-utils.js";
4
4
  import { SqliteOAuthClientsStore, SqliteOAuthStore } from "./oauth-store.js";
5
5
  const CODE_TTL_MS = 5 * 60 * 1000;
6
+ const CLI_CLIENT_ID = "forgerelay-cli";
6
7
  function randomToken() {
7
8
  return randomBytes(32).toString("base64url");
8
9
  }
@@ -85,6 +86,15 @@ export class SingleUserOAuthProvider {
85
86
  this.config = config;
86
87
  this.resourceServerUrl = resourceUrlFromServerUrl(resourceServerUrl);
87
88
  this.oauthStore = new SqliteOAuthStore(stateDir);
89
+ this.oauthStore.ensureClient({
90
+ client_id: CLI_CLIENT_ID,
91
+ client_id_issued_at: Math.floor(Date.now() / 1000),
92
+ client_name: "ForgeRelay CLI",
93
+ redirect_uris: [],
94
+ token_endpoint_auth_method: "none",
95
+ grant_types: ["refresh_token"],
96
+ response_types: [],
97
+ });
88
98
  this.clientsStore = new SqliteOAuthClientsStore(this.oauthStore, config.allowedRedirectHosts);
89
99
  }
90
100
  async authorize(client, params, res) {
@@ -145,11 +155,10 @@ export class SingleUserOAuthProvider {
145
155
  return this.issueTokens(client.client_id, record.params.scopes ?? this.config.scopes, record.params.resource);
146
156
  }
147
157
  async exchangeRefreshToken(client, refreshToken, scopes, resource) {
148
- const refreshTokenHash = hashToken(refreshToken);
149
- const record = this.oauthStore.getRefreshToken(refreshTokenHash);
150
- if (!record || record.clientId !== client.client_id || record.expiresAt < Math.floor(Date.now() / 1000)) {
158
+ const refresh = this.validRefreshToken(refreshToken, client.client_id);
159
+ if (!refresh)
151
160
  throw new InvalidGrantError("Invalid refresh token");
152
- }
161
+ const { refreshTokenHash, record } = refresh;
153
162
  if (resource && !checkResourceAllowed({ requestedResource: resource, configuredResource: this.resourceServerUrl })) {
154
163
  throw new InvalidGrantError("Invalid resource");
155
164
  }
@@ -159,6 +168,17 @@ export class SingleUserOAuthProvider {
159
168
  }
160
169
  return this.issueTokens(client.client_id, requestedScopes, resource ?? (record.resource ? new URL(record.resource) : undefined), refreshTokenHash);
161
170
  }
171
+ issueCliTokens(ownerToken) {
172
+ if (!safeEquals(ownerToken, this.config.ownerToken))
173
+ return undefined;
174
+ return this.issueTokens(CLI_CLIENT_ID, this.config.scopes, this.resourceServerUrl);
175
+ }
176
+ exchangeCliRefreshToken(refreshToken) {
177
+ const refresh = this.validRefreshToken(refreshToken, CLI_CLIENT_ID);
178
+ if (!refresh)
179
+ return undefined;
180
+ return this.issueTokens(CLI_CLIENT_ID, refresh.record.scopes, refresh.record.resource ? new URL(refresh.record.resource) : this.resourceServerUrl, refresh.refreshTokenHash);
181
+ }
162
182
  async verifyAccessToken(token) {
163
183
  const record = this.oauthStore.getAccessToken(hashToken(token));
164
184
  if (!record || record.expiresAt < Math.floor(Date.now() / 1000)) {
@@ -181,6 +201,14 @@ export class SingleUserOAuthProvider {
181
201
  this.codes.clear();
182
202
  this.oauthStore.close();
183
203
  }
204
+ validRefreshToken(refreshToken, clientId) {
205
+ const refreshTokenHash = hashToken(refreshToken);
206
+ const record = this.oauthStore.getRefreshToken(refreshTokenHash);
207
+ if (!record || record.clientId !== clientId || record.expiresAt < Math.floor(Date.now() / 1000)) {
208
+ return undefined;
209
+ }
210
+ return { refreshTokenHash, record };
211
+ }
184
212
  pruneExpiredAuthorizationCodes(nowMs = Date.now()) {
185
213
  for (const [code, record] of this.codes) {
186
214
  if (record.expiresAtMs < nowMs)
@@ -25,6 +25,15 @@ export class SqliteOAuthStore {
25
25
  .get(clientId);
26
26
  return row ? JSON.parse(row.client_json) : undefined;
27
27
  }
28
+ ensureClient(client) {
29
+ const existing = this.getClient(client.client_id);
30
+ if (existing)
31
+ return existing;
32
+ this.database.sqlite
33
+ .prepare("insert into oauth_clients (client_id, client_json, issued_at) values (?, ?, ?)")
34
+ .run(client.client_id, JSON.stringify(client), client.client_id_issued_at);
35
+ return client;
36
+ }
28
37
  registerClient(client, allowedRedirectHosts) {
29
38
  if (!client.redirect_uris.every((uri) => redirectHostAllowed(String(uri), allowedRedirectHosts))) {
30
39
  throw new InvalidRequestError("Client redirect_uri is not allowed for this ForgeRelay server");
@@ -0,0 +1,110 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js";
3
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
4
+ import { StreamableHTTPClientTransport, StreamableHTTPError, } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
5
+ import { publicEndpointUrl } from "./oauth/public-url.js";
6
+ const REMOTE_AUTH_TIMEOUT_MS = 15_000;
7
+ const packageVersion = readForgeRelayVersion();
8
+ export function normalizeRemoteServiceTarget(value) {
9
+ const trimmed = value.trim();
10
+ if (!trimmed)
11
+ throw new Error("Missing remote service target.");
12
+ const url = new URL(trimmed.includes("://") ? trimmed : `http://${trimmed}`);
13
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
14
+ throw new Error(`Unsupported remote service protocol: ${url.protocol}`);
15
+ }
16
+ if (url.username || url.password) {
17
+ throw new Error("Remote service target must not contain credentials.");
18
+ }
19
+ if (url.search || url.hash) {
20
+ throw new Error("Remote service target must not contain a query or fragment.");
21
+ }
22
+ url.pathname = url.pathname.replace(/\/+$/, "");
23
+ return url.toString().replace(/\/$/, "");
24
+ }
25
+ export function defaultRemoteAlias(target) {
26
+ const hostname = new URL(target).hostname;
27
+ return hostname.replace(/^\[|\]$/g, "");
28
+ }
29
+ export async function authenticateRemote(targetInput, ownerToken) {
30
+ const target = normalizeRemoteServiceTarget(targetInput);
31
+ return exchangeCliCredential(target, { owner_token: ownerToken });
32
+ }
33
+ export async function refreshRemoteAuthentication(remote, endpointInput = remote.target) {
34
+ const refreshed = await exchangeCliCredential(normalizeRemoteServiceTarget(endpointInput), { refresh_token: remote.refreshToken });
35
+ if (refreshed.instanceId !== remote.instanceId) {
36
+ throw new Error(`Remote instance changed from ${remote.instanceId} to ${refreshed.instanceId}; refusing to update stored credentials.`);
37
+ }
38
+ return {
39
+ ...remote,
40
+ accessToken: refreshed.accessToken,
41
+ refreshToken: refreshed.refreshToken,
42
+ accessTokenExpiresAt: refreshed.accessTokenExpiresAt,
43
+ scope: refreshed.scope,
44
+ };
45
+ }
46
+ export function isRemoteMcpUnauthorized(error) {
47
+ return error instanceof UnauthorizedError ||
48
+ (error instanceof StreamableHTTPError && error.code === 401);
49
+ }
50
+ export async function withRemoteMcpClient(remote, endpointInput, operation) {
51
+ const endpoint = normalizeRemoteServiceTarget(endpointInput);
52
+ const client = new Client({ name: "forgerelay-cli", version: packageVersion });
53
+ const transport = new StreamableHTTPClientTransport(publicEndpointUrl(endpoint, "mcp"), {
54
+ requestInit: {
55
+ headers: { Authorization: `Bearer ${remote.accessToken}` },
56
+ },
57
+ });
58
+ try {
59
+ await client.connect(transport);
60
+ return await operation(client);
61
+ }
62
+ finally {
63
+ await client.close().catch(() => undefined);
64
+ }
65
+ }
66
+ export async function verifyRemoteMcp(remote, endpointInput = remote.target) {
67
+ await withRemoteMcpClient(remote, endpointInput, async () => undefined);
68
+ }
69
+ async function exchangeCliCredential(target, credential) {
70
+ const response = await fetch(publicEndpointUrl(target, "auth/cli"), {
71
+ method: "POST",
72
+ headers: { "content-type": "application/json" },
73
+ body: JSON.stringify(credential),
74
+ signal: AbortSignal.timeout(REMOTE_AUTH_TIMEOUT_MS),
75
+ });
76
+ if (!response.ok) {
77
+ let reason = response.statusText || `HTTP ${response.status}`;
78
+ try {
79
+ const body = await response.json();
80
+ if (typeof body.error === "string" && body.error)
81
+ reason = body.error;
82
+ }
83
+ catch {
84
+ // Keep the status-derived error without exposing the submitted secret.
85
+ }
86
+ throw new Error(`Remote authentication failed: ${reason}`);
87
+ }
88
+ const body = await response.json();
89
+ if (typeof body.instance_id !== "string" || !body.instance_id.trim()) {
90
+ throw new Error("Remote authentication response did not include an instance id.");
91
+ }
92
+ if (typeof body.access_token !== "string" || typeof body.refresh_token !== "string") {
93
+ throw new Error("Remote authentication response did not include the required tokens.");
94
+ }
95
+ return {
96
+ instanceId: body.instance_id,
97
+ target,
98
+ accessToken: body.access_token,
99
+ refreshToken: body.refresh_token,
100
+ accessTokenExpiresAt: Math.floor(Date.now() / 1000) + (body.expires_in ?? 0),
101
+ ...(body.scope ? { scope: body.scope } : {}),
102
+ };
103
+ }
104
+ function readForgeRelayVersion() {
105
+ const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
106
+ if (typeof packageJson.version !== "string" || packageJson.version.length === 0) {
107
+ throw new Error("Unable to read ForgeRelay package version.");
108
+ }
109
+ return packageJson.version;
110
+ }
@@ -0,0 +1,196 @@
1
+ import { spawn } from "node:child_process";
2
+ import { once } from "node:events";
3
+ import { createConnection, createServer } from "node:net";
4
+ const SSH_START_TIMEOUT_MS = 15_000;
5
+ const SSH_COMMAND_TIMEOUT_MS = 15_000;
6
+ const SSH_STOP_TIMEOUT_MS = 2_000;
7
+ export function parseSshRoute(value) {
8
+ const route = value.split(",").map((entry) => entry.trim()).filter(Boolean);
9
+ if (route.length === 0)
10
+ throw new Error("SSH route must contain at least one target.");
11
+ for (const entry of route) {
12
+ if (entry.startsWith("-") || /\s/.test(entry)) {
13
+ throw new Error(`Invalid SSH route target: ${entry}`);
14
+ }
15
+ }
16
+ return route;
17
+ }
18
+ export function defaultSshRouteAlias(route) {
19
+ const finalTarget = route.at(-1);
20
+ if (!finalTarget)
21
+ throw new Error("SSH route must contain a final target.");
22
+ const at = finalTarget.lastIndexOf("@");
23
+ return (at >= 0 ? finalTarget.slice(at + 1) : finalTarget).trim();
24
+ }
25
+ export async function readRemoteOwnerToken(sshRoute) {
26
+ const { prefix, target } = sshDestinationArgs(sshRoute);
27
+ const result = await runSshCommand([
28
+ ...prefix,
29
+ target,
30
+ "forgerelay",
31
+ "auth",
32
+ "__owner-token",
33
+ ]);
34
+ const token = result.stdout.trim();
35
+ if (!token)
36
+ throw new Error("SSH owner-token command returned an empty token.");
37
+ return token;
38
+ }
39
+ export async function withRemoteServiceEndpoint(target, sshRoute, operation) {
40
+ if (!sshRoute)
41
+ return operation(target);
42
+ const url = new URL(target);
43
+ if (url.protocol === "https:") {
44
+ 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.");
45
+ }
46
+ const remotePort = url.port || "80";
47
+ const localPort = await allocateLoopbackPort();
48
+ const { prefix, target: sshTarget } = sshDestinationArgs(sshRoute);
49
+ const forwardSpec = `127.0.0.1:${localPort}:${url.hostname}:${remotePort}`;
50
+ const tunnel = spawn("ssh", [
51
+ ...prefix,
52
+ "-v",
53
+ "-N",
54
+ "-T",
55
+ "-o",
56
+ "ExitOnForwardFailure=yes",
57
+ "-L",
58
+ forwardSpec,
59
+ sshTarget,
60
+ ], {
61
+ stdio: ["ignore", "pipe", "pipe"],
62
+ windowsHide: true,
63
+ });
64
+ let forwardingReady = false;
65
+ const readyMarker = `Local forwarding listening on 127.0.0.1 port ${localPort}`;
66
+ const stderr = collectStream(tunnel.stderr, (value) => {
67
+ if (value.includes(readyMarker))
68
+ forwardingReady = true;
69
+ });
70
+ let spawnError;
71
+ tunnel.once("error", (error) => {
72
+ spawnError = error;
73
+ });
74
+ try {
75
+ 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
+ }
87
+ finally {
88
+ await stopTunnel(tunnel);
89
+ }
90
+ }
91
+ function sshDestinationArgs(sshRoute) {
92
+ if (sshRoute.length === 0)
93
+ throw new Error("SSH route must contain a final target.");
94
+ const target = sshRoute[sshRoute.length - 1];
95
+ const jumps = sshRoute.slice(0, -1);
96
+ return {
97
+ prefix: jumps.length > 0 ? ["-J", jumps.join(",")] : [],
98
+ target,
99
+ };
100
+ }
101
+ async function allocateLoopbackPort() {
102
+ const server = createServer();
103
+ server.listen(0, "127.0.0.1");
104
+ await once(server, "listening");
105
+ const address = server.address();
106
+ if (!address || typeof address === "string") {
107
+ server.close();
108
+ throw new Error("Unable to allocate a local SSH forwarding port.");
109
+ }
110
+ const port = address.port;
111
+ await new Promise((resolve, reject) => {
112
+ server.close((error) => error ? reject(error) : resolve());
113
+ });
114
+ return port;
115
+ }
116
+ async function waitForLoopbackPort(process, port, stderr, spawnError, forwardingReady) {
117
+ const deadline = Date.now() + SSH_START_TIMEOUT_MS;
118
+ while (Date.now() < deadline) {
119
+ const error = spawnError();
120
+ if (error)
121
+ throw new Error(`Unable to start SSH tunnel: ${error.message}`);
122
+ if (process.exitCode !== null) {
123
+ throw new Error(formatSshFailure("SSH tunnel exited before forwarding was ready", process.exitCode, stderr()));
124
+ }
125
+ if (forwardingReady() && await canConnect(port))
126
+ return;
127
+ await new Promise((resolve) => setTimeout(resolve, 40));
128
+ }
129
+ throw new Error("SSH tunnel did not become ready before the connection timeout.");
130
+ }
131
+ function canConnect(port) {
132
+ return new Promise((resolve) => {
133
+ const socket = createConnection({ host: "127.0.0.1", port });
134
+ socket.once("connect", () => {
135
+ socket.destroy();
136
+ resolve(true);
137
+ });
138
+ socket.once("error", () => resolve(false));
139
+ });
140
+ }
141
+ async function runSshCommand(args) {
142
+ const child = spawn("ssh", args, {
143
+ stdio: ["ignore", "pipe", "pipe"],
144
+ windowsHide: true,
145
+ });
146
+ const stdout = collectStream(child.stdout);
147
+ const stderr = collectStream(child.stderr);
148
+ const timeout = setTimeout(() => child.kill("SIGKILL"), SSH_COMMAND_TIMEOUT_MS);
149
+ let code;
150
+ let signal;
151
+ try {
152
+ [code, signal] = await once(child, "close");
153
+ }
154
+ catch (error) {
155
+ const message = error instanceof Error ? error.message : String(error);
156
+ throw new Error(`Unable to start SSH command: ${message}`);
157
+ }
158
+ finally {
159
+ clearTimeout(timeout);
160
+ }
161
+ if (code !== 0) {
162
+ const suffix = signal ? ` (${signal})` : "";
163
+ throw new Error(formatSshFailure(`SSH command failed${suffix}`, code, stderr()));
164
+ }
165
+ return { stdout: stdout(), stderr: stderr() };
166
+ }
167
+ function collectStream(stream, observe) {
168
+ let value = "";
169
+ stream.setEncoding?.("utf8");
170
+ stream.on("data", (chunk) => {
171
+ value += String(chunk);
172
+ observe?.(value);
173
+ if (value.length > 16_384)
174
+ value = value.slice(-16_384);
175
+ });
176
+ return () => value;
177
+ }
178
+ async function stopTunnel(process) {
179
+ if (process.exitCode !== null)
180
+ return;
181
+ const closed = once(process, "close").then(() => true).catch(() => true);
182
+ process.kill("SIGTERM");
183
+ const timedOut = new Promise((resolve) => setTimeout(() => resolve(false), SSH_STOP_TIMEOUT_MS));
184
+ if (await Promise.race([closed, timedOut]))
185
+ return;
186
+ if (process.exitCode !== null)
187
+ return;
188
+ const killed = once(process, "close").then(() => undefined).catch(() => undefined);
189
+ process.kill("SIGKILL");
190
+ const killTimedOut = new Promise((resolve) => setTimeout(resolve, SSH_STOP_TIMEOUT_MS));
191
+ await Promise.race([killed, killTimedOut]);
192
+ }
193
+ function formatSshFailure(prefix, code, stderr) {
194
+ const detail = stderr.trim();
195
+ return `${prefix} (exit ${code ?? "unknown"})${detail ? `: ${detail}` : ""}`;
196
+ }