@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
package/CHANGELOG.md CHANGED
@@ -4,6 +4,42 @@ All notable ForgeRelay changes are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.6.2] - 2026-08-29
8
+
9
+ ### Added
10
+
11
+ - Added Composite Workspace as a first-class Workspace kind on the existing `open_workspace` surface. A Composite Workspace has its own persistent `cws_...` identity, may start with zero members, and can mount named local, managed-worktree, or relayed Workspaces with Agent-facing purpose descriptions.
12
+ - Added explicit member routing across filesystem, capability, Bash/process, and Codex-compatible `apply_patch` / `exec_command` / `write_stdin` surfaces. The Composite Workspace ID remains Host-facing while each operation must name its execution member; ForgeRelay never selects or falls back to another member automatically.
13
+ - Added one Composite Host Turn / Activity presentation that aggregates local and remote member activity with member labels while keeping audit, process, filesystem, and remote execution facts owned by the actual member Workspace/Execution ForgeRelay.
14
+
15
+ ### Changed
16
+
17
+ - `close_workspace` now dissolves a Composite Workspace without closing member Workspaces, finalizing managed worktrees, interrupting member processes, deleting files, or removing Workspace Relay routes.
18
+ - Interactive debug launchers now derive their displayed local endpoints from the selected `FORGERELAY_DEBUG_CONFIG_DIR/config.json`, allowing multiple isolated debug instances to use separate config/state/worktree directories and different ports such as 7677 and 6768.
19
+
20
+ ### Fixed
21
+
22
+ - Routed Codex-compatible process and patch tools through the same Workspace Relay and Composite member resolution path as the canonical tool surface, including explicit no-fallback behavior for remote members.
23
+
24
+ ## [0.6.1] - 2026-08-28
25
+
26
+ ### Added
27
+
28
+ - Added Workspace Relay: register a remote ForgeRelay with `forgerelay auth`, then open a remote Workspace through the existing MCP surface with `open_workspace(..., relay="<alias>")`; direct targets and system-SSH-routed targets share the same authentication and MCP protocol.
29
+ - Added CLI remote authentication with hidden owner-token input, explicit `--token`, and SSH-assisted `--ssh-auth`; `-J` accepts one final SSH target or a full comma-separated jump route while the remote service target remains one independent `host:port`/URL value.
30
+ - Added remote routing for Workspace reads and mutations, Bash/process lifecycle and durable output, optional capabilities, Skills, Hooks, and Host Activity snapshot/detail/output queries while keeping execution facts owned by the execution ForgeRelay.
31
+ - Added restart-safe remote identity and Workspace routing: stable remote instance IDs survive alias/target changes, access tokens refresh through the existing token store, SSH tunnels are rebuilt on fresh random loopback ports, and relayed Workspace IDs remain stable across Gateway restarts.
32
+
33
+ ### Changed
34
+
35
+ - Activity Panel bootstrap can reconstruct Workspace presentation from the execution instance across independent MCP connections, allowing relayed Host Turns and App-only Activity queries to follow the execution Workspace without requiring one long-lived transport session.
36
+ - ForgeRelay authentication and relayed-Workspace route files now use lock-protected read/modify/write updates with atomic replacement so concurrent CLI/server sessions do not overwrite each other.
37
+
38
+ ### Security
39
+
40
+ - Remote targets, SSH routes, temporary tunnel ports, owner tokens, access tokens, refresh tokens, and execution-side Workspace IDs stay out of the model-facing relay contract; the Host sees the configured relay alias and Gateway `rws_...` identity instead.
41
+ - SSH-assisted authentication retrieves the remote owner token only into the initiating process, then reuses the normal CLI token exchange; runtime forwarding binds loopback only, and failed direct/SSH/remote operations never silently fall back to a local or different remote Workspace.
42
+
7
43
  ## [0.6.0] - 2026-08-27
8
44
 
9
45
  ### Added
package/README.md CHANGED
@@ -101,6 +101,44 @@ Once a workspace is open, the host can:
101
101
  Normal work happens in your existing checkout. ForgeRelay does not silently move
102
102
  every task into a worktree.
103
103
 
104
+ ## Composite workspaces across multiple devices
105
+
106
+ When one task needs more than one execution environment, a Composite Workspace keeps
107
+ one Host-facing working context while preserving each member Workspace as a separate
108
+ execution boundary. For example, `code` can point at a local checkout while
109
+ `compute` points through Workspace Relay at a GPU machine.
110
+
111
+ Composite Workspaces use the same lifecycle entry points as ordinary Workspaces:
112
+
113
+ ```text
114
+ open_workspace({ kind: "composite", name: "research-project" })
115
+ open_workspace({
116
+ action: "member",
117
+ workspaceId: "cws_...",
118
+ memberAction: "add",
119
+ member: {
120
+ name: "compute",
121
+ purpose: "GPU and high-performance computation",
122
+ path: "/srv/research",
123
+ relay: "gpu-server"
124
+ }
125
+ })
126
+ ```
127
+
128
+ Work still names the member explicitly:
129
+
130
+ ```text
131
+ read({ workspaceId: "cws_...", member: "code", path: "src/model.py" })
132
+ bash({ workspaceId: "cws_...", member: "compute", command: "python train.py" })
133
+ ```
134
+
135
+ ForgeRelay does not merge member filesystems, Git state, Hooks, Skills, processes,
136
+ or audit facts, and it never infers a member from the tool type or purpose text.
137
+ The Composite Activity Panel presents member operations in one Host Turn while the
138
+ actual facts remain owned by the member Workspace. `close_workspace` on a Composite
139
+ Workspace means **dissolve**: the Composite identity and membership are removed, but
140
+ member Workspaces, worktrees, running jobs, files, and relay routes are preserved.
141
+
104
142
  ### Progressive MCP context
105
143
 
106
144
  ForgeRelay keeps the callable MCP surface and its low-frequency operating manuals
@@ -158,7 +196,7 @@ You can rebase and verify inside the worktree, then retry the close.
158
196
 
159
197
  Hook 是 ForgeRelay 的自动生命周期规则。首选方式是一个 Hook 一个文件:全局放在 `~/.forgerelay/hooks/<hook-name>.json`,项目放在 `<repo>/.forgerelay/hooks/<hook-name>.json`。文件名就是 Hook 名,方便直接从目录看出每条规则的用途;全局与项目规则组合执行,不需要额外批准。
160
198
 
161
- 例如项目里的 `.forgerelay/hooks/release-tag-local-ci.json` 可以要求稳定版本 tag push 前先完成本地发布检查:
199
+ 例如项目里的 `.forgerelay/hooks/release-tag-gate.json` 可以在稳定版本 tag push 前执行轻量发布门禁:
162
200
 
163
201
  ```json
164
202
  {
@@ -173,7 +211,7 @@ Hook 是 ForgeRelay 的自动生命周期规则。首选方式是一个 Hook 一
173
211
  }
174
212
  ```
175
213
 
176
- 耗时的 `npm run release:verify` 应先在已提交的 release-ready HEAD 上运行,它会写入绑定 HEAD/package version 的本地 release proof。命中 `BeforeTool` 后,Hook 只快速验证该 proof、clean working tree(含 untracked)与 tag 指向;成功才继续原始 `git push`,失败则直接阻断。这样发布 gate 不依赖一个持续数分钟的 MCP 请求。Hook 结果会回到 Agent,Agent 应向用户说明重要 Hook 是否通过或阻断了操作。`report:false` 可以隐藏不重要的成功报告,但阻断失败始终可见。
214
+ 命中 `BeforeTool` 后,Hook 只快速验证 clean working tree(含 untracked)、tag package version 一致,以及本地 tag 指向当前 HEAD;成功才继续原始 `git push`,失败则直接阻断。Hook 不运行、也不要求本地 CI。tag 推送后由 GitHub Actions Linux/macOS/Windows 矩阵执行权威验证,全部通过后才进入发布。`npm run release:verify` 仅作为可选的本地云端复现工具。Hook 结果会回到 Agent,Agent 应向用户说明重要 Hook 是否通过或阻断了操作。`report:false` 可以隐藏不重要的成功报告,但阻断失败始终可见。
177
215
 
178
216
  旧的 inline `hooks` 和聚合 `hooks.json` 仍兼容;新配置建议都用独立 `hooks/*.json` 文件。
179
217
 
@@ -293,18 +331,15 @@ npm run release:check
293
331
  npm run release:patch
294
332
  npm run release:minor
295
333
  npm run release:major
296
- npm run release:verify
297
334
  ```
298
335
 
299
- Daily branch pushes do not run cloud CI. When preparing a release, commit the
300
- release-ready tree and run the full local release verification on that clean HEAD.
301
- `release:verify` includes a focused parity pass in an isolated Node 22.19.0 environment
302
- with its own `npm ci`, matching the cloud CI runtime for native addons and high-risk
303
- LSP lifecycle tests, then writes the local proof consumed by the tag-push Hook. Pushing a matching
304
- `vX.Y.Z` tag to `Akira-TL/forgerelay` is the only cloud CI and publish trigger:
305
- GitHub Actions runs the reusable multi-platform CI, then publishes
306
- `@akira-tl/forgerelay` and creates the matching GitHub Release only after CI
307
- succeeds.
336
+ Daily branch pushes do not run cloud CI. Commit the release-ready tree, push it to
337
+ `main`, then push the matching `vX.Y.Z` tag. That tag is the only cloud CI and publish
338
+ trigger: GitHub Actions runs the reusable Linux/macOS/Windows verification matrix,
339
+ then publishes `@akira-tl/forgerelay` and creates the matching GitHub Release only
340
+ after all platforms succeed. `npm run release:verify` remains available only when a
341
+ cloud failure needs local reproduction against the pinned Node runtime; it is not a
342
+ release prerequisite.
308
343
 
309
344
  See [Versioning and Release Management](docs/versioning.md) for the bootstrap and
310
345
  Trusted Publishing setup.
@@ -20,6 +20,7 @@ const activitySummarySchema = z.object({
20
20
  target: z.string(),
21
21
  detailAvailable: z.boolean(),
22
22
  workspaceId: z.string().optional(),
23
+ member: z.string().optional(),
23
24
  processId: z.number().int().positive().optional(),
24
25
  outputId: z.string().optional(),
25
26
  commandLength: z.number().int().nonnegative().optional(),
@@ -42,7 +43,7 @@ const snapshotOutputSchema = {
42
43
  activities: z.array(activitySummarySchema),
43
44
  [ACTIVITY_PANEL_WORKSPACE_META_KEY]: z.record(z.string(), z.unknown()).optional(),
44
45
  };
45
- export function registerActivityQueryTools(server, queries, connectionScopeId, panelMeta = {}, panelDefaultExpanded = false, logging, workspacePanelState) {
46
+ export function registerActivityQueryTools(server, queries, connectionScopeId, panelMeta = {}, panelDefaultExpanded = false, logging, workspacePanelState, relay) {
46
47
  const panelUi = typeof panelMeta.ui === "object" && panelMeta.ui !== null
47
48
  ? panelMeta.ui
48
49
  : {};
@@ -69,7 +70,23 @@ export function registerActivityQueryTools(server, queries, connectionScopeId, p
69
70
  if (!workspace) {
70
71
  throw new Error(`No Workspace presentation is available for ${workspaceId}. Call open_workspace for that workspace before activity_panel.`);
71
72
  }
72
- const snapshot = queries.beginTurn(hostConversationScopeId(extra._meta, extra.sessionId, connectionScopeId), workspaceId);
73
+ const conversationScopeId = hostConversationScopeId(extra._meta, extra.sessionId, connectionScopeId);
74
+ const relayed = await relay?.panel(workspaceId, conversationScopeId);
75
+ if (relayed) {
76
+ return {
77
+ ...relayed,
78
+ _meta: {
79
+ ...(relayed._meta ?? {}),
80
+ [ACTIVITY_PANEL_DEFAULT_EXPANDED_META_KEY]: panelDefaultExpanded,
81
+ [ACTIVITY_PANEL_WORKSPACE_META_KEY]: workspace,
82
+ },
83
+ structuredContent: {
84
+ ...(relayed.structuredContent ?? {}),
85
+ [ACTIVITY_PANEL_WORKSPACE_META_KEY]: workspace,
86
+ },
87
+ };
88
+ }
89
+ const snapshot = queries.beginTurn(conversationScopeId, workspaceId);
73
90
  if (logging) {
74
91
  logEvent(logging, "debug", "activity_panel_call", {
75
92
  turnId: snapshot.turnId,
@@ -108,6 +125,27 @@ export function registerActivityQueryTools(server, queries, connectionScopeId, p
108
125
  annotations: READ_ONLY_ANNOTATIONS,
109
126
  }, async ({ turnId, workspaceId, knownRevision }, extra) => {
110
127
  const conversationScopeId = hostConversationScopeId(extra._meta, extra.sessionId, connectionScopeId);
128
+ const relayed = await relay?.snapshot({ turnId, workspaceId, knownRevision }, conversationScopeId);
129
+ if (relayed) {
130
+ const workspace = workspaceId ? workspacePanelState?.(workspaceId) : undefined;
131
+ if (workspaceId && !workspace) {
132
+ throw new Error(`No Workspace presentation is available for ${workspaceId}.`);
133
+ }
134
+ return workspace
135
+ ? {
136
+ ...relayed,
137
+ _meta: {
138
+ ...(relayed._meta ?? {}),
139
+ [ACTIVITY_PANEL_DEFAULT_EXPANDED_META_KEY]: panelDefaultExpanded,
140
+ [ACTIVITY_PANEL_WORKSPACE_META_KEY]: workspace,
141
+ },
142
+ structuredContent: {
143
+ ...(relayed.structuredContent ?? {}),
144
+ [ACTIVITY_PANEL_WORKSPACE_META_KEY]: workspace,
145
+ },
146
+ }
147
+ : relayed;
148
+ }
111
149
  const resolvedTurnId = turnId ?? queries.currentTurnId(conversationScopeId, workspaceId);
112
150
  if (!resolvedTurnId) {
113
151
  throw new Error("Activity snapshot bootstrap could not resolve the current Host Turn from conversation and workspace metadata.");
@@ -162,6 +200,10 @@ export function registerActivityQueryTools(server, queries, connectionScopeId, p
162
200
  _meta: { ui: { visibility: ["app"] } },
163
201
  annotations: READ_ONLY_ANNOTATIONS,
164
202
  }, async ({ turnId, activityId }, extra) => {
203
+ const conversationScopeId = hostConversationScopeId(extra._meta, extra.sessionId, connectionScopeId);
204
+ const relayed = await relay?.detail(turnId, activityId, conversationScopeId);
205
+ if (relayed)
206
+ return relayed;
165
207
  const detail = queries.detail(turnId, activityId);
166
208
  if (logging) {
167
209
  logEvent(logging, "debug", "activity_detail_call", {
@@ -201,6 +243,10 @@ export function registerActivityQueryTools(server, queries, connectionScopeId, p
201
243
  _meta: { ui: { visibility: ["app"] } },
202
244
  annotations: READ_ONLY_ANNOTATIONS,
203
245
  }, async ({ turnId, outputId }, extra) => {
246
+ const conversationScopeId = hostConversationScopeId(extra._meta, extra.sessionId, connectionScopeId);
247
+ const relayed = await relay?.output(turnId, outputId, conversationScopeId);
248
+ if (relayed)
249
+ return relayed;
204
250
  const output = queries.bashOutput(turnId, outputId);
205
251
  if (logging) {
206
252
  logEvent(logging, "debug", "activity_output_call", {
@@ -126,6 +126,7 @@ function toSummary(record) {
126
126
  target: activityTarget(record, request, result, structured),
127
127
  detailAvailable: !bulkGroup && record.tool !== "rename" && record.tool !== "delete" && record.tool !== "batch",
128
128
  ...(record.workspace.id ? { workspaceId: record.workspace.id } : {}),
129
+ ...(stringField(request, "member") ? { member: stringField(request, "member") } : {}),
129
130
  ...(processId !== undefined ? { processId } : {}),
130
131
  ...(outputId !== undefined ? { outputId } : {}),
131
132
  ...(bashLike && command !== undefined ? { commandLength: command.length } : {}),
package/dist/cli.js CHANGED
@@ -18,10 +18,12 @@ import { isLocalAgentProvider, loadLocalAgentProfiles, } from "./local-agent-pro
18
18
  import { assertLocalAgentProviderAvailable, formatLocalAgentProviderAvailabilitySummary, } from "./local-agent-availability.js";
19
19
  import { formatAvailableLocalAgentTargets, parseLocalAgentRunArgs, resolveLocalAgentTarget, } from "./local-agent-targets.js";
20
20
  import { createLocalAgentStore } from "./local-agent-store.js";
21
- import { generateOwnerToken, loadDevspaceFiles, resolveSubagentsFlag, writeDevspaceAuth, writeDevspaceConfig, } from "./user-config.js";
21
+ import { ensureForgeRelayInstanceId, generateInstanceId, generateOwnerToken, loadDevspaceFiles, removeForgeRelayRemote, renameForgeRelayRemote, resolveSubagentsFlag, writeDevspaceAuth, writeDevspaceConfig, writeForgeRelayRemote, } from "./user-config.js";
22
22
  import { expandHomePath } from "./roots.js";
23
23
  import { shutdownHttpServer } from "./server-shutdown.js";
24
24
  import { publicEndpointUrl } from "./oauth/public-url.js";
25
+ import { authenticateRemote, defaultRemoteAlias, isRemoteMcpUnauthorized, normalizeRemoteServiceTarget, refreshRemoteAuthentication, verifyRemoteMcp, } from "./remote-auth.js";
26
+ import { defaultSshRouteAlias, parseSshRoute, readRemoteOwnerToken, withRemoteServiceEndpoint, } from "./remote-transport.js";
25
27
  const require = createRequire(import.meta.url);
26
28
  const SUPPORTED_NODE_RANGE = ">=20.12 <27";
27
29
  async function main(argv) {
@@ -48,6 +50,9 @@ async function main(argv) {
48
50
  case "agents":
49
51
  await runAgentsCommand(args);
50
52
  return;
53
+ case "auth":
54
+ await runAuthCommand(args);
55
+ return;
51
56
  case "help":
52
57
  printHelp();
53
58
  return;
@@ -59,7 +64,7 @@ async function main(argv) {
59
64
  function normalizeCommand(command) {
60
65
  if (!command || command === "serve" || command === "start")
61
66
  return "serve";
62
- if (command === "init" || command === "doctor" || command === "config" || command === "hooks" || command === "agents")
67
+ if (command === "init" || command === "doctor" || command === "config" || command === "hooks" || command === "agents" || command === "auth")
63
68
  return command;
64
69
  if (command === "help" || command === "--help" || command === "-h")
65
70
  return "help";
@@ -69,10 +74,14 @@ function normalizeCommand(command) {
69
74
  }
70
75
  async function ensureConfigured() {
71
76
  const files = loadDevspaceFiles();
72
- if (files.configExists && files.authExists)
77
+ if (files.configExists && files.authExists) {
78
+ ensureForgeRelayInstanceId();
73
79
  return;
74
- if (process.env.FORGERELAY_OAUTH_OWNER_TOKEN ?? process.env.DEVSPACE_OAUTH_OWNER_TOKEN)
80
+ }
81
+ if (process.env.FORGERELAY_OAUTH_OWNER_TOKEN ?? process.env.DEVSPACE_OAUTH_OWNER_TOKEN) {
82
+ ensureForgeRelayInstanceId();
75
83
  return;
84
+ }
76
85
  if (!input.isTTY || !output.isTTY) {
77
86
  throw new Error([
78
87
  "ForgeRelay is not configured and this terminal is non-interactive.",
@@ -143,7 +152,9 @@ async function runInit({ force }) {
143
152
  subagents: resolveSubagentsFlag(files.config),
144
153
  };
145
154
  const auth = {
155
+ ...files.auth,
146
156
  ownerToken: files.auth.ownerToken ?? generateOwnerToken(),
157
+ instanceId: files.auth.instanceId ?? generateInstanceId(),
147
158
  };
148
159
  const configPath = writeDevspaceConfig(config);
149
160
  const authPath = writeDevspaceAuth(auth);
@@ -214,6 +225,166 @@ async function serve() {
214
225
  process.once("SIGINT", handleShutdown);
215
226
  process.once("SIGTERM", handleShutdown);
216
227
  }
228
+ function parseAuthCommandArgs(args) {
229
+ let target;
230
+ let alias;
231
+ let ownerToken;
232
+ let sshRoute;
233
+ let sshAuth = false;
234
+ for (let index = 0; index < args.length; index += 1) {
235
+ const arg = args[index];
236
+ if (arg === "--alias") {
237
+ alias = args[++index];
238
+ if (!alias)
239
+ throw new Error("Missing value for --alias.");
240
+ continue;
241
+ }
242
+ if (arg === "--token") {
243
+ ownerToken = args[++index];
244
+ if (!ownerToken)
245
+ throw new Error("Missing value for --token.");
246
+ continue;
247
+ }
248
+ if (arg === "-J") {
249
+ const route = args[++index];
250
+ if (!route)
251
+ throw new Error("Missing value for -J.");
252
+ sshRoute = parseSshRoute(route);
253
+ continue;
254
+ }
255
+ if (arg === "--ssh-auth") {
256
+ sshAuth = true;
257
+ continue;
258
+ }
259
+ if (arg.startsWith("-"))
260
+ throw new Error(`Unknown auth option: ${arg}`);
261
+ if (target)
262
+ throw new Error(`Unexpected auth argument: ${arg}`);
263
+ target = arg;
264
+ }
265
+ if (!target)
266
+ throw new Error("Missing remote service target.");
267
+ if (sshAuth && !sshRoute)
268
+ throw new Error("--ssh-auth requires -J <ssh-route>.");
269
+ if (sshAuth && ownerToken)
270
+ throw new Error("--ssh-auth and --token cannot be used together.");
271
+ return { target, alias, ownerToken, sshRoute, sshAuth };
272
+ }
273
+ async function resolveAuthOwnerToken(ownerToken) {
274
+ if (ownerToken)
275
+ return ownerToken;
276
+ if (!input.isTTY || !output.isTTY) {
277
+ throw new Error("Missing owner token. Pass --token, use --ssh-auth with -J, or run in an interactive terminal.");
278
+ }
279
+ const result = await prompts.password({
280
+ message: "Remote ForgeRelay owner token",
281
+ validate: (value) => value?.trim() ? undefined : "Enter the remote owner token.",
282
+ });
283
+ if (prompts.isCancel(result))
284
+ throw new Error("Remote authentication cancelled.");
285
+ return String(result);
286
+ }
287
+ function localOwnerToken() {
288
+ const token = process.env.FORGERELAY_OAUTH_OWNER_TOKEN
289
+ ?? process.env.DEVSPACE_OAUTH_OWNER_TOKEN
290
+ ?? loadDevspaceFiles().auth.ownerToken;
291
+ if (!token)
292
+ throw new Error("ForgeRelay owner token is not configured on this machine.");
293
+ return token;
294
+ }
295
+ async function runAuthCommand(args) {
296
+ const [subcommand, ...rest] = args;
297
+ if (subcommand === "__owner-token") {
298
+ if (rest.length > 0)
299
+ throw new Error("Internal owner-token command does not accept arguments.");
300
+ process.stdout.write(`${localOwnerToken()}\n`);
301
+ return;
302
+ }
303
+ if (subcommand === "list") {
304
+ if (rest.length > 0)
305
+ throw new Error("forgerelay auth list does not accept additional arguments.");
306
+ const remotes = loadDevspaceFiles().auth.remotes ?? {};
307
+ if (Object.keys(remotes).length === 0) {
308
+ console.log("No remote ForgeRelay instances registered.");
309
+ return;
310
+ }
311
+ for (const [alias, remote] of Object.entries(remotes).sort(([left], [right]) => left.localeCompare(right))) {
312
+ console.log(`${alias}\t${remote.target}\t${remote.instanceId}`);
313
+ }
314
+ return;
315
+ }
316
+ if (subcommand === "rename") {
317
+ const [fromAlias, toAlias, ...extra] = rest;
318
+ if (!fromAlias || !toAlias || extra.length > 0) {
319
+ throw new Error("Usage: forgerelay auth rename <old-alias> <new-alias>");
320
+ }
321
+ renameForgeRelayRemote(fromAlias, toAlias);
322
+ console.log(`Renamed remote ${fromAlias} to ${toAlias}.`);
323
+ return;
324
+ }
325
+ if (subcommand === "remove") {
326
+ const [alias, ...extra] = rest;
327
+ if (!alias || extra.length > 0)
328
+ throw new Error("Usage: forgerelay auth remove <alias>");
329
+ removeForgeRelayRemote(alias);
330
+ console.log(`Removed remote ${alias}.`);
331
+ return;
332
+ }
333
+ if (subcommand === "test") {
334
+ const [alias, ...extra] = rest;
335
+ if (!alias || extra.length > 0)
336
+ throw new Error("Usage: forgerelay auth test <alias>");
337
+ const files = loadDevspaceFiles();
338
+ const storedRemote = files.auth.remotes?.[alias];
339
+ if (!storedRemote)
340
+ throw new Error(`Unknown remote alias: ${alias}`);
341
+ let remote = storedRemote;
342
+ await withRemoteServiceEndpoint(remote.target, remote.sshRoute, async (endpoint) => {
343
+ let refreshed = false;
344
+ if (remote.accessTokenExpiresAt <= Math.floor(Date.now() / 1000)) {
345
+ remote = await refreshRemoteAuthentication(remote, endpoint);
346
+ writeForgeRelayRemote(alias, remote);
347
+ refreshed = true;
348
+ }
349
+ try {
350
+ await verifyRemoteMcp(remote, endpoint);
351
+ }
352
+ catch (error) {
353
+ if (refreshed || !isRemoteMcpUnauthorized(error))
354
+ throw error;
355
+ remote = await refreshRemoteAuthentication(remote, endpoint);
356
+ writeForgeRelayRemote(alias, remote);
357
+ await verifyRemoteMcp(remote, endpoint);
358
+ }
359
+ });
360
+ console.log(`${alias}\tok\t${remote.instanceId}`);
361
+ return;
362
+ }
363
+ const parsed = parseAuthCommandArgs(args);
364
+ const target = normalizeRemoteServiceTarget(parsed.target);
365
+ const authenticated = await withRemoteServiceEndpoint(target, parsed.sshRoute, async (endpoint) => {
366
+ const ownerToken = parsed.sshAuth
367
+ ? await readRemoteOwnerToken(parsed.sshRoute ?? [])
368
+ : await resolveAuthOwnerToken(parsed.ownerToken);
369
+ return authenticateRemote(endpoint, ownerToken);
370
+ });
371
+ const remote = {
372
+ ...authenticated,
373
+ target,
374
+ ...(parsed.sshRoute ? { sshRoute: parsed.sshRoute } : {}),
375
+ };
376
+ const files = loadDevspaceFiles();
377
+ const existingAlias = Object.entries(files.auth.remotes ?? {}).find(([, record]) => record.instanceId === remote.instanceId)?.[0];
378
+ const defaultAlias = parsed.sshRoute
379
+ ? defaultSshRouteAlias(parsed.sshRoute)
380
+ : defaultRemoteAlias(remote.target);
381
+ const alias = parsed.alias?.trim() || existingAlias || defaultAlias;
382
+ if (!files.auth.instanceId) {
383
+ ensureForgeRelayInstanceId();
384
+ }
385
+ writeForgeRelayRemote(alias, remote);
386
+ console.log(`Authenticated remote ${alias} (${remote.instanceId}).`);
387
+ }
217
388
  async function runDoctor() {
218
389
  const files = loadDevspaceFiles();
219
390
  console.log(`Config dir: ${files.dir}`);
@@ -282,6 +453,12 @@ function printHelp() {
282
453
  " forgerelay agents ls List subagent sessions",
283
454
  " forgerelay agents run <profile-or-provider-or-id> [--model <model>] <prompt>",
284
455
  " forgerelay agents show <id>",
456
+ " forgerelay auth <target> [--alias <name>] [--token <owner-token>]",
457
+ " forgerelay auth -J <ssh-route> <target> [--alias <name>] [--token <owner-token>|--ssh-auth]",
458
+ " forgerelay auth list",
459
+ " forgerelay auth test <alias>",
460
+ " forgerelay auth rename <old-alias> <new-alias>",
461
+ " forgerelay auth remove <alias>",
285
462
  " forgerelay -v, --version Print the installed version",
286
463
  "",
287
464
  "For temporary tunnels:",
@@ -0,0 +1,155 @@
1
+ export class CompositeActivityCoordinator {
2
+ composites;
3
+ queries;
4
+ remoteWorkspaces;
5
+ turns = new Map();
6
+ constructor(composites, queries, remoteWorkspaces) {
7
+ this.composites = composites;
8
+ this.queries = queries;
9
+ this.remoteWorkspaces = remoteWorkspaces;
10
+ }
11
+ hasComposite(workspaceId) {
12
+ return this.composites.has(workspaceId);
13
+ }
14
+ beginPanel(workspaceId, conversationScopeId) {
15
+ this.composites.open(workspaceId);
16
+ const snapshot = this.queries.beginTurn(conversationScopeId, workspaceId);
17
+ this.turns.set(snapshot.turnId, {
18
+ compositeWorkspaceId: workspaceId,
19
+ conversationScopeId,
20
+ remoteMembers: new Map(),
21
+ remoteActivities: new Map(),
22
+ remoteOutputs: new Map(),
23
+ });
24
+ return snapshotResult(snapshot, `Started Composite Workspace Host Turn ${snapshot.turnId}.`);
25
+ }
26
+ currentTurnId(conversationScopeId, compositeWorkspaceId) {
27
+ const turnId = this.queries.currentTurnId(conversationScopeId, compositeWorkspaceId);
28
+ return turnId && this.turns.has(turnId) ? turnId : undefined;
29
+ }
30
+ async prepareMember(compositeWorkspaceId, member, executionWorkspaceId, conversationScopeId) {
31
+ const turnId = this.currentTurnId(conversationScopeId, compositeWorkspaceId);
32
+ if (!turnId)
33
+ return undefined;
34
+ if (!this.remoteWorkspaces.has(executionWorkspaceId))
35
+ return turnId;
36
+ const state = this.turns.get(turnId);
37
+ const existing = state.remoteMembers.get(member);
38
+ if (existing?.workspaceId === executionWorkspaceId)
39
+ return turnId;
40
+ const panel = await this.remoteWorkspaces.activityPanel(executionWorkspaceId, conversationScopeId);
41
+ const remoteTurnId = requiredString(panel.structuredContent, "turnId", "Remote Activity panel");
42
+ state.remoteMembers.set(member, {
43
+ member,
44
+ workspaceId: executionWorkspaceId,
45
+ remoteTurnId,
46
+ });
47
+ return turnId;
48
+ }
49
+ async snapshot(turnId, knownRevision) {
50
+ const state = this.turns.get(turnId);
51
+ if (!state)
52
+ return undefined;
53
+ const local = this.queries.snapshot(turnId);
54
+ const remoteSnapshots = await Promise.all([...state.remoteMembers.values()].map(async (route) => ({
55
+ route,
56
+ snapshot: await this.remoteWorkspaces.activitySnapshot({ turnId: route.remoteTurnId }, state.conversationScopeId),
57
+ })));
58
+ state.remoteActivities.clear();
59
+ state.remoteOutputs.clear();
60
+ const remoteActivities = [];
61
+ let revision = local.revision;
62
+ for (const { route, snapshot } of remoteSnapshots) {
63
+ if (!snapshot?.structuredContent)
64
+ 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)
71
+ ? structured.activities
72
+ : [];
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);
79
+ }
80
+ }
81
+ const activities = [...local.activities, ...remoteActivities]
82
+ .sort((left, right) => left.startedAt.localeCompare(right.startedAt));
83
+ const changed = knownRevision === undefined || knownRevision !== revision;
84
+ const snapshot = {
85
+ turnId,
86
+ revision,
87
+ changed,
88
+ state: aggregateState(activities),
89
+ activities: changed ? activities : [],
90
+ };
91
+ return snapshotResult(snapshot, changed
92
+ ? `Composite Activity snapshot ${turnId} revision ${revision}.`
93
+ : `Composite Activity snapshot ${turnId} unchanged at revision ${revision}.`);
94
+ }
95
+ async detail(turnId, activityId) {
96
+ const state = this.turns.get(turnId);
97
+ if (!state)
98
+ return undefined;
99
+ const route = state.remoteActivities.get(activityId);
100
+ if (!route)
101
+ return undefined;
102
+ const result = await this.remoteWorkspaces.activityDetail(route.remoteTurnId, activityId, state.conversationScopeId);
103
+ if (!result?.structuredContent)
104
+ return result;
105
+ const structured = result.structuredContent;
106
+ const activity = structured.activity;
107
+ return {
108
+ ...result,
109
+ structuredContent: {
110
+ ...structured,
111
+ ...(activity && typeof activity === "object"
112
+ ? { activity: { ...activity, member: route.member } }
113
+ : {}),
114
+ },
115
+ };
116
+ }
117
+ async output(turnId, outputId) {
118
+ const state = this.turns.get(turnId);
119
+ if (!state)
120
+ return undefined;
121
+ const route = state.remoteOutputs.get(outputId);
122
+ if (!route)
123
+ return undefined;
124
+ return this.remoteWorkspaces.activityOutput(route.remoteTurnId, outputId, state.conversationScopeId);
125
+ }
126
+ forgetComposite(workspaceId) {
127
+ for (const [turnId, state] of this.turns) {
128
+ if (state.compositeWorkspaceId === workspaceId)
129
+ this.turns.delete(turnId);
130
+ }
131
+ }
132
+ }
133
+ function snapshotResult(snapshot, text) {
134
+ return {
135
+ content: [{ type: "text", text }],
136
+ structuredContent: snapshot,
137
+ };
138
+ }
139
+ function aggregateState(activities) {
140
+ if (activities.length === 0)
141
+ return "working";
142
+ if (activities.some((activity) => activity.status === "working"))
143
+ return "working";
144
+ if (activities.some((activity) => activity.status === "error"))
145
+ return "error";
146
+ return "done";
147
+ }
148
+ function requiredString(value, field, label) {
149
+ if (!value || typeof value !== "object")
150
+ throw new Error(`${label} did not return structured content.`);
151
+ const fieldValue = value[field];
152
+ if (typeof fieldValue !== "string" || !fieldValue)
153
+ throw new Error(`${label} did not include ${field}.`);
154
+ return fieldValue;
155
+ }