@akira-tl/forgerelay 0.6.0 → 0.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +36 -0
- package/README.md +47 -12
- package/dist/activity/mcp-query-tools.js +48 -2
- package/dist/activity/query-service.js +1 -0
- package/dist/cli.js +181 -4
- package/dist/composite-activity.js +155 -0
- package/dist/composite-workspaces.js +197 -0
- package/dist/config.js +4 -1
- package/dist/oauth/router.js +21 -1
- package/dist/oauth-provider.js +32 -4
- package/dist/oauth-store.js +9 -0
- package/dist/remote-auth.js +110 -0
- package/dist/remote-transport.js +196 -0
- package/dist/remote-workspace-relay.js +473 -0
- package/dist/server.js +824 -121
- package/dist/ui/.vite/manifest.json +33 -33
- package/dist/ui/activity-panel-app.html +3 -3
- package/dist/ui/assets/{activity-panel-app-CjZVvVNc.js → activity-panel-app-E1ju2dqI.js} +1 -1
- package/dist/ui/assets/{heavy-payload-vGgBRvNX.js → heavy-payload-CeW-n9w5.js} +1 -1
- package/dist/ui/assets/{review-payload-4erWKckt.js → review-payload-B9CO298v.js} +1 -1
- package/dist/ui/assets/{scrollbar-CaOPzUJd.js → scrollbar-C2twAENW.js} +1 -1
- package/dist/ui/assets/workspace-app-BztEvZIC.js +5 -0
- package/dist/ui/assets/{workspace-app-DkAiSl_0.js → workspace-app-CwbJnb_w.js} +1 -1
- package/dist/ui/assets/workspace-app-YnUST8IP.css +1 -0
- package/dist/ui/assets/workspace-app-rKuhdae8.js +1 -0
- package/dist/ui/assets/workspace-lifecycle-app-CEfMdudP.js +1 -0
- package/dist/ui/workspace-app.html +4 -4
- package/dist/ui/workspace-lifecycle-app.html +4 -4
- package/dist/user-config.js +131 -5
- package/docs/configuration.md +26 -3
- package/docs/debugging.md +7 -0
- package/docs/versioning.md +11 -19
- package/package.json +5 -2
- package/scripts/ci/verify.mjs +38 -0
- package/scripts/debug/runtime.mjs +23 -1
- package/scripts/debug/runtime.test.mjs +14 -2
- package/scripts/debug/serve.mjs +4 -4
- package/scripts/release/pack.mjs +36 -0
- package/scripts/release/publish.mjs +157 -0
- package/scripts/release/release-gate.test.mjs +73 -16
- package/scripts/release-parity.mjs +5 -13
- package/scripts/release-proof.mjs +28 -19
- package/scripts/release-proof.test.mjs +32 -11
- package/scripts/release-version.mjs +1 -1
- package/dist/ui/assets/workspace-app-CcrHAUIn.css +0 -1
- package/dist/ui/assets/workspace-app-DJmkPYJC.js +0 -1
- package/dist/ui/assets/workspace-app-QyauBrJX.js +0 -5
- package/dist/ui/assets/workspace-lifecycle-app-BIXEo53I.js +0 -1
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-
|
|
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
|
-
|
|
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.
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|