@akira-tl/forgerelay 0.2.3 → 0.2.5

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/docs/roadmap.md CHANGED
@@ -82,6 +82,26 @@ Hooks v1 的目标是给用户和 Agent 一个很小、自动、可组合的生
82
82
 
83
83
  0.2 不引入审批 UI、HTTP/prompt/agent handler、Git 字符串解析器或插件注册表。只有出现真实需求时再扩展 handler 类型。
84
84
 
85
+ ### 0.2.5 — MCP App template reliability
86
+
87
+ 0.2.5 收敛 ChatGPT/MCP App 模板身份与排障链路:
88
+
89
+ - 当前 UI resource URI 由实际构建出的 JavaScript/CSS 内容哈希生成,而不是仅依赖 npm 版本;
90
+ - `ui://forgerelay/workspace-app.html` 继续作为 legacy pointer;
91
+ - 历史 `workspace-app-*.html` URI 通过兼容 resource template 继续读取当前模板,避免旧 metadata snapshot 直接变成 missing resource;
92
+ - debug 日志区分 current / legacy / historical template read,并保留 asset request trace;
93
+ - 7677 acceptance 覆盖 tool metadata、resource list/template list、三类 template read 和静态 bundle HTTP fetch。
94
+
95
+ ### 0.2.6 — identity and transport terminology
96
+
97
+ 0.2.6 整理 ForgeRelay 中多个 `session` 含义,不改变 workspace-first 架构:
98
+
99
+ - `workspaceId` 是唯一持久的逻辑工作身份,跨请求和 transport 重连保持连续;
100
+ - `requestId` 只追踪单次 HTTP/JSON-RPC 请求,不持久化;
101
+ - MCP 协议层 session 在内部和 debug 输出中明确称为 `transportSessionId`,业务状态不得依赖它;
102
+ - 后台命令句柄逐步迁移为 `processId` / process handle;若公开 schema 改名需要兼容窗口,则在明确的版本边界完成;
103
+ - 为 stateless MCP transport 做准备,同时保留旧协议兼容 adapter,避免把 transport 生命周期重新提升成 ForgeRelay 会话模型。
104
+
85
105
  ## 0.3 — LSP code intelligence v1
86
106
 
87
107
  LSP is moderate implementation complexity if ForgeRelay does not become a
@@ -90,6 +110,12 @@ language-server installer.
90
110
  The first version should launch only language servers already available on the
91
111
  user's machine or explicitly configured by the user/project.
92
112
 
113
+ During 0.3 development, MCP App UI hardening can land alongside the LSP work when
114
+ it does not distort the code-intelligence scope. In particular, evaluate a more
115
+ self-contained template/bootstrap bundle, reduce avoidable external chunk fetches,
116
+ and keep the current content-hash/compatibility-resource contract intact. This is
117
+ reliability work, not a requirement to move application state into the UI.
118
+
93
119
  Initial operations:
94
120
 
95
121
  - diagnostics;
package/docs/security.md CHANGED
@@ -122,6 +122,16 @@ The security model is therefore based on:
122
122
 
123
123
  Do not describe ForgeRelay as a sandboxed coding environment.
124
124
 
125
+ Shell execution has a 300-second foreground wait ceiling, not a 300-second
126
+ process lifetime. When `bash` is still running after that window, ForgeRelay
127
+ returns a process `sessionId` and leaves the process alive. `write_stdin` can poll,
128
+ wait, interact, or explicitly interrupt it. An asynchronously completed process
129
+ is reported on a later tool result for the same logical workspace ID, including
130
+ error-result paths, and is never broadcast to another workspace ID. Explicitly
131
+ resuming the same workspace ID in another conversation intentionally transfers
132
+ that completion scope as well. Hook handlers keep their separate bounded timeout
133
+ policy because they are lifecycle gates rather than user-command execution.
134
+
125
135
  ## Lifecycle hooks
126
136
 
127
137
  Hook command 是本地代码执行,使用与 ForgeRelay 相同的操作系统用户权限并继承进程环境。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akira-tl/forgerelay",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "description": "Local development control plane for MCP coding agents.",
5
5
  "type": "module",
6
6
  "homepage": "https://github.com/Akira-TL/forgerelay#readme",
@@ -41,7 +41,7 @@
41
41
  "debug:accept": "node scripts/debug/accept.mjs",
42
42
  "postinstall": "node scripts/fix-node-pty-permissions.mjs",
43
43
  "start": "node dist/cli.js serve",
44
- "test": "tsx src/config.test.ts && tsx src/logger.test.ts && tsx src/hooks.test.ts && tsx src/mcp/server-instructions.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/file-mutations.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/workspace-conversation.test.ts && tsx src/review-checkpoints.test.ts && tsx src/server.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts",
44
+ "test": "tsx src/config.test.ts && tsx src/logger.test.ts && tsx src/mcp-app-template.test.ts && tsx src/hooks.test.ts && tsx src/mcp/server-instructions.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/file-mutations.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/workspace-conversation.test.ts && tsx src/review-checkpoints.test.ts && tsx src/server.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts",
45
45
  "typecheck": "tsc -p tsconfig.json --noEmit",
46
46
  "release:check": "node scripts/release-version.mjs check",
47
47
  "release:tag-check": "node scripts/release-version.mjs tag",
@@ -38,7 +38,13 @@ await assertDebugPortFree();
38
38
  rmSync(acceptanceRoot, { recursive: true, force: true });
39
39
  mkdirSync(acceptanceRoot, { recursive: true });
40
40
 
41
- const { env } = createDebugEnvironment({ ownerToken, stateDir, worktreeRoot, hookLog });
41
+ const { env } = createDebugEnvironment({
42
+ ownerToken,
43
+ stateDir,
44
+ worktreeRoot,
45
+ hookLog,
46
+ widgets: "full",
47
+ });
42
48
  const server = spawn(process.execPath, ["--import", "tsx", "src/cli.ts", "serve"], {
43
49
  cwd: repoRoot,
44
50
  env,
@@ -108,15 +114,80 @@ try {
108
114
  params: {},
109
115
  }).message.result.tools;
110
116
  const toolNames = tools.map((tool) => tool.name);
111
- for (const expected of ["open_workspace", "close_worktree", "read", "write", "edit", "rename", "delete", "grep", "glob", "ls", "bash"]) {
117
+ for (const expected of ["open_workspace", "close_workspace", "close_worktree", "read", "write", "edit", "rename", "delete", "grep", "glob", "ls", "bash", "write_stdin"]) {
112
118
  assert.ok(toolNames.includes(expected), `missing debug tool ${expected}`);
113
119
  }
114
120
  const bashTool = tools.find((tool) => tool.name === "bash");
115
121
  assert.match(bashTool?.description ?? "", /may modify ordinary project files/);
116
122
  assert.match(bashTool?.description ?? "", /\/etc\/sudoers/);
117
123
  assert.doesNotMatch(bashTool?.description ?? "", /Do not use bash to create, move, rename, or delete project files/);
124
+ assert.equal(bashTool?.inputSchema?.properties?.timeout, undefined);
125
+ assert.match(bashTool?.description ?? "", /waits up to 300 seconds/);
126
+ assert.match(bashTool?.description ?? "", /write_stdin/);
127
+ const writeStdinTool = tools.find((tool) => tool.name === "write_stdin");
128
+ assert.equal(writeStdinTool?.inputSchema?.properties?.yieldTimeMs?.maximum, 300000);
129
+ const openWorkspaceTool = tools.find((tool) => tool.name === "open_workspace");
130
+ assert.ok(openWorkspaceTool?.inputSchema?.properties?.workspaceId);
131
+ assert.ok(openWorkspaceTool?.inputSchema?.properties?.newWorkspace);
132
+ assert.ok(openWorkspaceTool?.outputSchema?.properties?.staleWorkspaces);
133
+ const templateUri = bashTool?._meta?.ui?.resourceUri;
134
+ assert.match(
135
+ templateUri ?? "",
136
+ /^ui:\/\/forgerelay\/workspace-app-[0-9a-f]{12}\.html$/,
137
+ JSON.stringify(bashTool ?? {}),
138
+ );
139
+ assert.deepEqual(bashTool?._meta?.ui?.visibility, ["model", "app"]);
140
+ assert.equal(bashTool?._meta?.["openai/outputTemplate"], templateUri);
118
141
  pass("MCP tools/list", `${toolNames.length} tools: ${toolNames.join(", ")}`);
119
142
 
143
+ const resources = mcpRequest(oauth.accessToken, sessionId, {
144
+ jsonrpc: "2.0",
145
+ id: 21,
146
+ method: "resources/list",
147
+ params: {},
148
+ }).message.result.resources;
149
+ assert.ok(resources.some((resource) => resource.uri === templateUri));
150
+ assert.ok(resources.some((resource) => resource.uri === "ui://forgerelay/workspace-app.html"));
151
+
152
+ const resourceTemplates = mcpRequest(oauth.accessToken, sessionId, {
153
+ jsonrpc: "2.0",
154
+ id: 22,
155
+ method: "resources/templates/list",
156
+ params: {},
157
+ }).message.result.resourceTemplates;
158
+ assert.ok(resourceTemplates.some(
159
+ (resourceTemplate) => resourceTemplate.uriTemplate === "ui://forgerelay/workspace-app-{revision}.html",
160
+ ));
161
+
162
+ const readTemplate = (id, uri) => mcpRequest(oauth.accessToken, sessionId, {
163
+ jsonrpc: "2.0",
164
+ id,
165
+ method: "resources/read",
166
+ params: { uri },
167
+ }).message.result.contents[0];
168
+
169
+ const template = readTemplate(23, templateUri);
170
+ assert.equal(template.uri, templateUri);
171
+ assert.equal(template.mimeType, "text/html;profile=mcp-app");
172
+ assert.match(template.text ?? "", /<script type="module" crossorigin src="[^"]+\/mcp-app-assets\//);
173
+ assert.ok(template._meta?.ui?.csp?.resourceDomains?.includes(debugBaseUrl));
174
+ const scriptUrl = template.text?.match(/<script type="module" crossorigin src="([^"]+)"/)?.[1];
175
+ assert.ok(scriptUrl);
176
+ const scriptAsset = curlRequest({ method: "GET", url: scriptUrl });
177
+ assert.equal(scriptAsset.status, 200, scriptAsset.body);
178
+
179
+ const legacyTemplate = readTemplate(24, "ui://forgerelay/workspace-app.html");
180
+ assert.equal(legacyTemplate.uri, "ui://forgerelay/workspace-app.html");
181
+ assert.equal(legacyTemplate.mimeType, "text/html;profile=mcp-app");
182
+ assert.equal(legacyTemplate.text, template.text);
183
+
184
+ const historicalUri = "ui://forgerelay/workspace-app-0.2.4.html";
185
+ const historicalTemplate = readTemplate(25, historicalUri);
186
+ assert.equal(historicalTemplate.uri, historicalUri);
187
+ assert.equal(historicalTemplate.mimeType, "text/html;profile=mcp-app");
188
+ assert.equal(historicalTemplate.text, template.text);
189
+ pass("MCP app template", `${templateUri} + legacy/history compatibility -> ${scriptUrl}`);
190
+
120
191
  const opened = callTool(oauth.accessToken, sessionId, 3, "open_workspace", {
121
192
  path: checkoutWorkspace,
122
193
  });
@@ -145,7 +216,8 @@ try {
145
216
  command: "printf debug-bash-ok",
146
217
  });
147
218
  assert.match(shell.structuredContent.result, /debug-bash-ok/);
148
- pass("bash", "debug-bash-ok");
219
+ assert.equal(shell.structuredContent.running, false);
220
+ pass("bash", "foreground command completed through ProcessSessionManager");
149
221
 
150
222
  const failedEdit = callTool(oauth.accessToken, sessionId, 7, "edit", {
151
223
  workspaceId,
@@ -354,7 +426,13 @@ function initializeRequest(id) {
354
426
  method: "initialize",
355
427
  params: {
356
428
  protocolVersion: "2025-06-18",
357
- capabilities: {},
429
+ capabilities: {
430
+ extensions: {
431
+ "io.modelcontextprotocol/ui": {
432
+ mimeTypes: ["text/html;profile=mcp-app"],
433
+ },
434
+ },
435
+ },
358
436
  clientInfo: { name: "forgerelay-debug-acceptance", version: "1.0.0" },
359
437
  },
360
438
  };
@@ -20,6 +20,7 @@ export function createDebugEnvironment({
20
20
  stateDir = resolve(debugRoot, "state"),
21
21
  worktreeRoot = resolve(debugRoot, "worktrees"),
22
22
  hookLog = debugHookLog,
23
+ widgets = process.env.FORGERELAY_DEBUG_WIDGETS ?? "off",
23
24
  } = {}) {
24
25
  const token = ownerToken ?? process.env.FORGERELAY_DEBUG_OWNER_TOKEN ?? createDebugOwnerToken();
25
26
  mkdirSync(debugRoot, { recursive: true });
@@ -37,7 +38,7 @@ export function createDebugEnvironment({
37
38
  FORGERELAY_WORKTREE_ROOT: worktreeRoot,
38
39
  FORGERELAY_OAUTH_OWNER_TOKEN: token,
39
40
  FORGERELAY_TOOL_MODE: "full",
40
- FORGERELAY_WIDGETS: "off",
41
+ FORGERELAY_WIDGETS: widgets,
41
42
  FORGERELAY_LOG_LEVEL: process.env.FORGERELAY_LOG_LEVEL ?? "info",
42
43
  FORGERELAY_LOG_FORMAT: process.env.FORGERELAY_LOG_FORMAT ?? "pretty",
43
44
  FORGERELAY_DEBUG_HOOK_RECORDER: debugHookRecorder,