@akira-tl/forgerelay 1.3.1 → 1.3.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 +13 -0
- package/dist/activity/history/query-service.js +6 -0
- package/dist/activity/runtime/mcp-query-tools.js +4 -0
- package/dist/mcp/process/process-sessions.js +24 -32
- package/dist/mcp/process/process-wait-policy.js +34 -0
- package/dist/mcp/process/tools.js +2 -2
- package/dist/mcp/server/workspace/runtime/workspace-open-presentation.js +6 -3
- package/dist/mcp/server-instructions.js +4 -5
- package/dist/workspaces/relay/transport/remote-call-timeout.js +42 -0
- package/dist/workspaces/relay/workspace-relay.js +26 -10
- package/package.json +1 -1
- package/scripts/debug/accept/bootstrap.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,19 @@ All notable ForgeRelay changes are documented here.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [1.3.2] - 2026-10-08
|
|
8
|
+
|
|
9
|
+
### Security
|
|
10
|
+
|
|
11
|
+
- 现在对所有 Activity App 查询校验 Host Turn 的对话归属,阻止知道其他会话 `turnId` 的调用者通过 `activity_snapshot`、`activity_index`、`activity_detail` 或 `activity_output` 读取跨会话操作历史、命令内容及输出。
|
|
12
|
+
- Workspace Relay 网关在转发 Activity 查询前验证会话与远端 Turn 的归属关系;即使远端尚未升级,也不能通过已知的跨会话 Turn 路由读取历史。为本地、Relay 和重启路径补充回归覆盖。
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- 修复 Bash 进程的非交互等待轮询可能过早返回或被高频零等待查询绕过的问题;有界等待及带缓存输出的行为由确定性测试覆盖,同时保留交互输入的短等待行为。
|
|
17
|
+
- Workspace Relay 现在为较长的进程等待窗口留出请求截止时间余量,并与本地进程等待共享计算规则,避免在远端等待刚到边界时请求提前超时。
|
|
18
|
+
- Shell 工作流指令在 MCP server instructions 中继续保持可见;重复打开 Workspace 时强化调用 `activity_panel` 建立 Host Turn 的提醒,但不自动创建 Panel,也不改变现有显式面板生命周期。
|
|
19
|
+
|
|
7
20
|
## [1.3.1] - 2026-09-18
|
|
8
21
|
|
|
9
22
|
### Fixed
|
|
@@ -130,6 +130,12 @@ export class ActivityQueryService {
|
|
|
130
130
|
: summary;
|
|
131
131
|
});
|
|
132
132
|
}
|
|
133
|
+
assertConversationTurn(turnId, conversationScopeId) {
|
|
134
|
+
const turn = this.turns.get(turnId);
|
|
135
|
+
if (!turn || turn.conversationScopeId !== conversationScopeId) {
|
|
136
|
+
throw new Error(`Unknown Host Turn: ${turnId}.`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
133
139
|
requireTurn(turnId) {
|
|
134
140
|
if (!this.turns.get(turnId))
|
|
135
141
|
throw new Error(`Unknown Host Turn: ${turnId}.`);
|
|
@@ -143,6 +143,7 @@ export function registerActivityQueryTools(server, queries, connectionScopeId, p
|
|
|
143
143
|
if (!resolvedTurnId) {
|
|
144
144
|
throw new Error("Activity snapshot bootstrap could not resolve the current Host Turn from conversation and workspace metadata.");
|
|
145
145
|
}
|
|
146
|
+
queries.assertConversationTurn(resolvedTurnId, conversationScopeId);
|
|
146
147
|
const snapshot = queries.state(resolvedTurnId, knownRevision);
|
|
147
148
|
if (logging) {
|
|
148
149
|
logEvent(logging, "debug", "activity_snapshot_call", {
|
|
@@ -184,6 +185,7 @@ export function registerActivityQueryTools(server, queries, connectionScopeId, p
|
|
|
184
185
|
const relayed = await relay?.index(turnId, knownRevision, conversationScopeId);
|
|
185
186
|
if (relayed)
|
|
186
187
|
return relayed;
|
|
188
|
+
queries.assertConversationTurn(turnId, conversationScopeId);
|
|
187
189
|
const index = queries.index(turnId, knownRevision);
|
|
188
190
|
if (logging) {
|
|
189
191
|
logEvent(logging, "debug", "activity_index_call", {
|
|
@@ -222,6 +224,7 @@ export function registerActivityQueryTools(server, queries, connectionScopeId, p
|
|
|
222
224
|
const relayed = await relay?.detail(turnId, activityId, conversationScopeId);
|
|
223
225
|
if (relayed)
|
|
224
226
|
return relayed;
|
|
227
|
+
queries.assertConversationTurn(turnId, conversationScopeId);
|
|
225
228
|
const detail = queries.detail(turnId, activityId);
|
|
226
229
|
if (logging) {
|
|
227
230
|
logEvent(logging, "debug", "activity_detail_call", {
|
|
@@ -268,6 +271,7 @@ export function registerActivityQueryTools(server, queries, connectionScopeId, p
|
|
|
268
271
|
const relayed = await relay?.output(turnId, outputId, conversationScopeId, cursor);
|
|
269
272
|
if (relayed)
|
|
270
273
|
return relayed;
|
|
274
|
+
queries.assertConversationTurn(turnId, conversationScopeId);
|
|
271
275
|
const output = queries.bashOutput(turnId, outputId, cursor);
|
|
272
276
|
if (logging) {
|
|
273
277
|
logEvent(logging, "debug", "activity_output_call", {
|
|
@@ -1,13 +1,8 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { releasePtyProcessResources, resolveShellCommandForRuntime, terminateProcessTree, terminatePtyProcessTree } from "./process-platform.js";
|
|
3
3
|
import { resolveCompatibilityCommandShellRuntime, snapshotCommandShellRuntime } from "../../runtime/shell/command-shell-runtime.js";
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
export const DEFAULT_POLL_YIELD_MS = 60_000;
|
|
7
|
-
const MAX_START_YIELD_MS = 300_000;
|
|
8
|
-
const MAX_COMMAND_YIELD_MS = 300_000;
|
|
9
|
-
const MAX_POLL_YIELD_MS = 300_000;
|
|
10
|
-
const MAX_EXECUTION_TIMEOUT_MS = 24 * 60 * 60 * 1_000;
|
|
4
|
+
import { DEFAULT_EXEC_YIELD_MS, DEFAULT_INTERACTIVE_YIELD_MS, MAX_COMMAND_YIELD_MS, MAX_START_YIELD_MS, boundedDuration, executionTimeout, minimumPollYield, waitOnlyYieldMs, } from "./process-wait-policy.js";
|
|
5
|
+
export { DEFAULT_POLL_YIELD_MS } from "./process-wait-policy.js";
|
|
11
6
|
const DEFAULT_MAX_OUTPUT_TOKENS = 10_000;
|
|
12
7
|
const DEFAULT_BUFFER_CHARACTERS = 256_000;
|
|
13
8
|
const DEFAULT_MAX_ACTIVE_PROCESSES = 64;
|
|
@@ -18,22 +13,6 @@ const COMPACT_COMPLETION_CHARACTERS = 16 * 1024;
|
|
|
18
13
|
const COMPACT_COMPLETION_OUTPUT_TOKENS = COMPACT_COMPLETION_CHARACTERS / 4;
|
|
19
14
|
const DEFAULT_COLUMNS = 80;
|
|
20
15
|
const DEFAULT_ROWS = 24;
|
|
21
|
-
function boundedInteger(value, fallback, maximum) {
|
|
22
|
-
if (value === undefined)
|
|
23
|
-
return Math.min(fallback, maximum);
|
|
24
|
-
if (!Number.isFinite(value) || value < 0) {
|
|
25
|
-
throw new Error("Duration and output limits must be non-negative.");
|
|
26
|
-
}
|
|
27
|
-
return Math.min(Math.floor(value), maximum);
|
|
28
|
-
}
|
|
29
|
-
function optionalExecutionTimeout(value) {
|
|
30
|
-
if (value === undefined)
|
|
31
|
-
return undefined;
|
|
32
|
-
if (!Number.isInteger(value) || value < 1 || value > MAX_EXECUTION_TIMEOUT_MS) {
|
|
33
|
-
throw new Error(`Execution timeout must be an integer between 1 and ${MAX_EXECUTION_TIMEOUT_MS}ms.`);
|
|
34
|
-
}
|
|
35
|
-
return value;
|
|
36
|
-
}
|
|
37
16
|
function terminalSize(value, fallback) {
|
|
38
17
|
if (value === undefined)
|
|
39
18
|
return fallback;
|
|
@@ -209,6 +188,7 @@ export class ProcessManager {
|
|
|
209
188
|
maxCompletedProcesses;
|
|
210
189
|
completedProcessTtlMs;
|
|
211
190
|
maxStartYieldMs;
|
|
191
|
+
minimumPollYieldMs;
|
|
212
192
|
monotonicNow;
|
|
213
193
|
outputAudit;
|
|
214
194
|
commandShellRuntime;
|
|
@@ -227,6 +207,7 @@ export class ProcessManager {
|
|
|
227
207
|
?? options.completedSessionTtlMs
|
|
228
208
|
?? COMPLETED_PROCESS_TTL_MS;
|
|
229
209
|
this.maxStartYieldMs = options.maxStartYieldMs ?? MAX_START_YIELD_MS;
|
|
210
|
+
this.minimumPollYieldMs = minimumPollYield(options.minimumPollYieldMs);
|
|
230
211
|
this.monotonicNow = options.monotonicNow ?? (() => performance.now());
|
|
231
212
|
this.outputAudit = options.outputAudit;
|
|
232
213
|
this.commandShellRuntime = snapshotCommandShellRuntime(options.commandShellRuntime ?? resolveCompatibilityCommandShellRuntime());
|
|
@@ -236,7 +217,7 @@ export class ProcessManager {
|
|
|
236
217
|
if (this.stats().running >= this.maxActiveProcesses) {
|
|
237
218
|
throw new Error(`Active process limit reached (${this.maxActiveProcesses}). Poll, interrupt, or wait for an existing process before starting another.`);
|
|
238
219
|
}
|
|
239
|
-
const executionTimeoutMs =
|
|
220
|
+
const executionTimeoutMs = executionTimeout(input.timeoutMs);
|
|
240
221
|
const processEntry = this.createProcess(input);
|
|
241
222
|
this.processes.set(processEntry.id, processEntry);
|
|
242
223
|
try {
|
|
@@ -254,7 +235,7 @@ export class ProcessManager {
|
|
|
254
235
|
throw error;
|
|
255
236
|
}
|
|
256
237
|
this.armExecutionTimeout(processEntry, executionTimeoutMs);
|
|
257
|
-
const yieldTimeMs =
|
|
238
|
+
const yieldTimeMs = boundedDuration(input.yieldTimeMs, DEFAULT_EXEC_YIELD_MS, this.maxStartYieldMs);
|
|
258
239
|
try {
|
|
259
240
|
await this.waitForExit(processEntry, yieldTimeMs, input.signal);
|
|
260
241
|
input.signal?.throwIfAborted();
|
|
@@ -293,12 +274,22 @@ export class ProcessManager {
|
|
|
293
274
|
const writableChars = chars.replaceAll("\u0003", "");
|
|
294
275
|
if (writableChars && processEntry.running)
|
|
295
276
|
processEntry.process?.write(writableChars);
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
277
|
+
if (processEntry.running) {
|
|
278
|
+
if (interactionRequested) {
|
|
279
|
+
const yieldTimeMs = boundedDuration(input.yieldTimeMs, DEFAULT_INTERACTIVE_YIELD_MS, MAX_COMMAND_YIELD_MS);
|
|
280
|
+
await this.waitForExit(processEntry, yieldTimeMs, input.signal);
|
|
281
|
+
}
|
|
282
|
+
else if (input.yieldTimeMs === 0) {
|
|
283
|
+
if (processEntry.waitOnlyProbeUsed) {
|
|
284
|
+
throw new Error(`Immediate wait-only status probe for process ${processEntry.id} was already used. ` +
|
|
285
|
+
"Use a positive or omitted yieldTimeMs for subsequent waits.");
|
|
286
|
+
}
|
|
287
|
+
processEntry.waitOnlyProbeUsed = true;
|
|
288
|
+
}
|
|
289
|
+
else {
|
|
290
|
+
const yieldTimeMs = waitOnlyYieldMs(input.yieldTimeMs, this.minimumPollYieldMs);
|
|
291
|
+
await this.waitForExit(processEntry, yieldTimeMs, input.signal);
|
|
292
|
+
}
|
|
302
293
|
}
|
|
303
294
|
const snapshot = this.consume(processEntry, input.maxOutputTokens);
|
|
304
295
|
if (!processEntry.running)
|
|
@@ -433,6 +424,7 @@ export class ProcessManager {
|
|
|
433
424
|
outputWasTruncated: false,
|
|
434
425
|
background: false,
|
|
435
426
|
discardOnFinish: false,
|
|
427
|
+
waitOnlyProbeUsed: false,
|
|
436
428
|
exitPromise,
|
|
437
429
|
resolveExit,
|
|
438
430
|
};
|
|
@@ -555,7 +547,7 @@ export class ProcessManager {
|
|
|
555
547
|
this.outputAudit?.finish(processEntry.outputId, input);
|
|
556
548
|
}
|
|
557
549
|
consume(processEntry, maxOutputTokens) {
|
|
558
|
-
const limit =
|
|
550
|
+
const limit = boundedDuration(maxOutputTokens, DEFAULT_MAX_OUTPUT_TOKENS, 100_000);
|
|
559
551
|
const maxCharacters = Math.max(256, limit * 4);
|
|
560
552
|
const buffered = processEntry.buffer.drain(maxCharacters);
|
|
561
553
|
if (buffered.truncated)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export const DEFAULT_EXEC_YIELD_MS = 10_000;
|
|
2
|
+
export const DEFAULT_INTERACTIVE_YIELD_MS = 250;
|
|
3
|
+
export const DEFAULT_POLL_YIELD_MS = 60_000;
|
|
4
|
+
export const MAX_START_YIELD_MS = 300_000;
|
|
5
|
+
export const MAX_COMMAND_YIELD_MS = 300_000;
|
|
6
|
+
export const MAX_POLL_YIELD_MS = 300_000;
|
|
7
|
+
const MAX_EXECUTION_TIMEOUT_MS = 24 * 60 * 60 * 1_000;
|
|
8
|
+
export function boundedDuration(value, fallback, maximum) {
|
|
9
|
+
if (value === undefined)
|
|
10
|
+
return Math.min(fallback, maximum);
|
|
11
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
12
|
+
throw new Error("Duration and output limits must be non-negative.");
|
|
13
|
+
}
|
|
14
|
+
return Math.min(Math.floor(value), maximum);
|
|
15
|
+
}
|
|
16
|
+
export function executionTimeout(value) {
|
|
17
|
+
if (value === undefined)
|
|
18
|
+
return undefined;
|
|
19
|
+
if (!Number.isInteger(value) || value < 1 || value > MAX_EXECUTION_TIMEOUT_MS) {
|
|
20
|
+
throw new Error(`Execution timeout must be an integer between 1 and ${MAX_EXECUTION_TIMEOUT_MS}ms.`);
|
|
21
|
+
}
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
export function minimumPollYield(value) {
|
|
25
|
+
const resolved = value ?? DEFAULT_POLL_YIELD_MS;
|
|
26
|
+
if (!Number.isInteger(resolved) || resolved < 1 || resolved > MAX_POLL_YIELD_MS) {
|
|
27
|
+
throw new Error(`Minimum poll yield must be an integer between 1 and ${MAX_POLL_YIELD_MS}ms.`);
|
|
28
|
+
}
|
|
29
|
+
return resolved;
|
|
30
|
+
}
|
|
31
|
+
export function waitOnlyYieldMs(requested, minimum = DEFAULT_POLL_YIELD_MS) {
|
|
32
|
+
const resolvedMinimum = minimumPollYield(minimum);
|
|
33
|
+
return Math.max(resolvedMinimum, boundedDuration(requested, resolvedMinimum, MAX_POLL_YIELD_MS));
|
|
34
|
+
}
|
|
@@ -41,7 +41,7 @@ function registerBashTool(options) {
|
|
|
41
41
|
columns: z.number().int().min(1).max(1_000).optional().describe("Initial PTY width for action=run, or resize width for action=process."),
|
|
42
42
|
rows: z.number().int().min(1).max(1_000).optional().describe("Initial PTY height for action=run, or resize height for action=process."),
|
|
43
43
|
workingDirectory: z.string().optional().describe("For action=run, working directory relative to the workspace root. Defaults to the workspace root."),
|
|
44
|
-
yieldTimeMs: z.number().int().min(0).max(300_000).optional().describe("
|
|
44
|
+
yieldTimeMs: z.number().int().min(0).max(300_000).optional().describe("Feedback wait. action=run defaults to 10000ms; 0 hands off immediately. Wait-only action=process defaults to 60000ms; positive shorter values are raised to 60000ms and buffered output does not shorten the wait. Use 0 once per running process for one immediate wait-only status probe; repeated wait-only 0 is rejected. Interactive action=process defaults to 250ms. Process exit returns sooner."),
|
|
45
45
|
timeoutMs: z.number().int().min(1).max(86_400_000).optional().describe("For action=run, total execution timeout from process start. On expiry ForgeRelay terminates the process. Omit for no ForgeRelay execution deadline."),
|
|
46
46
|
maxOutputTokens: z.number().int().positive().max(100_000).optional().describe("Approximate output token budget. Defaults to 10000."),
|
|
47
47
|
},
|
|
@@ -232,7 +232,7 @@ function registerCodexProcessTools(options) {
|
|
|
232
232
|
chars: z.string().optional().describe("Characters to write. Omit or pass an empty string to poll."),
|
|
233
233
|
columns: z.number().int().min(1).max(1_000).optional().describe("Resize a PTY to this width."),
|
|
234
234
|
rows: z.number().int().min(1).max(1_000).optional().describe("Resize a PTY to this height."),
|
|
235
|
-
yieldTimeMs: z.number().int().min(0).max(300_000).optional().describe("
|
|
235
|
+
yieldTimeMs: z.number().int().min(0).max(300_000).optional().describe("Wait window, max 300000ms. Wait-only polling defaults to 60000ms; positive shorter values are raised to 60000ms and buffered output does not shorten the wait. Use 0 once per running process for one immediate wait-only status probe; repeated wait-only 0 is rejected. Interaction defaults to 250ms; process exit returns sooner."),
|
|
236
236
|
maxOutputTokens: z.number().int().positive().max(100_000).optional().describe("Approximate output token budget. Defaults to 10000."),
|
|
237
237
|
},
|
|
238
238
|
outputSchema: processOutputSchema(),
|
|
@@ -97,6 +97,7 @@ export async function presentLocalWorkspaceOpen(options, input, contextData) {
|
|
|
97
97
|
const availableAgentsFileOutputs = bootstrapComponents.has("availableAgentsFiles")
|
|
98
98
|
? cardAvailableAgentsFiles
|
|
99
99
|
: [];
|
|
100
|
+
const panelTurnReminder = `Before the first non-lifecycle ForgeRelay work tool after this Workspace resolution, call activity_panel(workspaceId=\"${workspace.id}\") exactly once to begin the project Host Turn. This is required even when the Workspace was already open or reused.`;
|
|
100
101
|
const workspaceContextInstruction = "For later open_workspace calls, context=\"auto\" avoids repeating unchanged bootstrap context; use context=\"none\" when only the workspace handle/metadata is needed, or context=\"full\" to force a refresh.";
|
|
101
102
|
const workspaceManagementInstruction = [
|
|
102
103
|
"Use open_workspace(action=\"list\") for lightweight Workspace inventory. Use action=\"inspect\" with one known workspaceId for bounded read-only metadata without opening/resuming it. Explicitly open a Workspace before executing or mutating against it, and ask the user before close_workspace cleanup.",
|
|
@@ -106,12 +107,13 @@ export async function presentLocalWorkspaceOpen(options, input, contextData) {
|
|
|
106
107
|
: undefined,
|
|
107
108
|
].filter(Boolean).join(" ");
|
|
108
109
|
const cardInstruction = config.skillsEnabled
|
|
109
|
-
?
|
|
110
|
-
:
|
|
110
|
+
? `${panelTurnReminder} Use this workspaceId in all subsequent tool calls for this project. Follow loaded agentsFiles instructions. Read an availableAgentsFiles path before working under it. When a task matches an available skill, load it with read(path=\"skills://<name>\") before proceeding. When a task matches a capability guide, read its advertised path before proceeding. ${workspaceContextInstruction} ${workspaceManagementInstruction} ${executionContext.agentInstruction}`
|
|
111
|
+
: `${panelTurnReminder} Use this workspaceId in all subsequent tool calls for this project. Follow loaded agentsFiles instructions. Read an availableAgentsFiles path before working under it. When a task matches a capability guide, read its advertised path before proceeding. ${workspaceContextInstruction} ${workspaceManagementInstruction} ${executionContext.agentInstruction}`;
|
|
111
112
|
const instruction = workspaceReused
|
|
112
113
|
? effectiveIncludeBootstrapContext
|
|
113
114
|
? [
|
|
114
115
|
`Workspace already exists as ${workspace.id} for this directory.`,
|
|
116
|
+
panelTurnReminder,
|
|
115
117
|
"Reuse this workspaceId for subsequent tool calls.",
|
|
116
118
|
effectiveBootstrapContextComponents.length > 0
|
|
117
119
|
? `Project bootstrap context components included in this response: ${effectiveBootstrapContextComponents.join(", ")}. Components not listed are unchanged and are not repeated.`
|
|
@@ -122,6 +124,7 @@ export async function presentLocalWorkspaceOpen(options, input, contextData) {
|
|
|
122
124
|
].join("\n\n")
|
|
123
125
|
: [
|
|
124
126
|
`Workspace already open as ${workspace.id}.`,
|
|
127
|
+
panelTurnReminder,
|
|
125
128
|
"Reuse this workspaceId for subsequent tool calls. This is the same directory previously opened in this conversation.",
|
|
126
129
|
"Continue following the project instructions, nested instruction files, skills, capability guides, agent profiles, and diagnostics previously provided for this workspace. They remain active and are not repeated here.",
|
|
127
130
|
workspaceContextInstruction,
|
|
@@ -129,7 +132,7 @@ export async function presentLocalWorkspaceOpen(options, input, contextData) {
|
|
|
129
132
|
executionContext.agentInstruction,
|
|
130
133
|
].join("\n\n")
|
|
131
134
|
: workspace.mode === "worktree"
|
|
132
|
-
?
|
|
135
|
+
? `${panelTurnReminder} Use this workspaceId for subsequent tool calls. Follow the project instructions, nested instruction files, skills, agent profiles, and diagnostics returned for this isolated worktree. ${workspaceManagementInstruction} ${executionContext.agentInstruction}`
|
|
133
136
|
: cardInstruction;
|
|
134
137
|
const resultContent = [
|
|
135
138
|
{
|
|
@@ -53,7 +53,7 @@ export function buildToolDescriptions(config) {
|
|
|
53
53
|
rename: `Rename or move one file or directory inside an open workspace or the OS temp directory without overwriting an existing destination. Source and destination must both remain inside the permitted file roots. Call ${toolNames.openWorkspace} first and pass workspaceId.`,
|
|
54
54
|
delete: `Delete one path or multiple paths inside an open workspace or the OS temp directory. Use path for one target or paths for multiple targets; a bulk Delete preflights all targets before deleting anything. Non-empty directories require recursive=true. An allowed root itself cannot be deleted. Call ${toolNames.openWorkspace} first and pass workspaceId.`,
|
|
55
55
|
applyPatch: `Apply one Codex-style patch inside an open workspace or the OS temp directory. Supports adding, overwriting, updating, deleting, and moving files. Workspace paths must remain relative; absolute paths are accepted only inside the OS temp directory. Call ${toolNames.openWorkspace} first and pass workspaceId.`,
|
|
56
|
-
shell: `Run or manage a shell process inside an open workspace.${shellSurface} Commands run with the local user's authority; workspace containment does not make shell execution a sandbox.
|
|
56
|
+
shell: `Run or manage a shell process inside an open workspace.${shellSurface} Commands run with the local user's authority; workspace containment does not make shell execution a sandbox. action=run uses yieldTimeMs as feedback wait (default 10000ms; 0 returns processId) and timeoutMs as the execution limit. Wait-only action=process uses at least 60000ms for positive yieldTimeMs, even with buffered output; 0 is one immediate probe per running process and repeats are rejected. Process exit returns sooner; short waits are for interaction. Background completions can arrive later. Call ${toolNames.openWorkspace} first with workspaceId. Expose only behind strong authentication.`,
|
|
57
57
|
shellCommand: "Shell command to run with the local user's authority.",
|
|
58
58
|
};
|
|
59
59
|
}
|
|
@@ -70,7 +70,6 @@ function capabilityContractInstructions(config) {
|
|
|
70
70
|
? ""
|
|
71
71
|
: ` If ${toolNames.openWorkspace} reports stale workspaces, let the user choose resume or ${toolNames.closeWorkspace}; never auto-close.`;
|
|
72
72
|
const workspaceLifecycle = `Default to the user's existing checkout. Reuse workspaceId from ${toolNames.openWorkspace}; change it only when asked.${staleWorkspacePolicy} Only open mode=\"worktree\" when the user explicitly asks for isolated or parallel Git work. ${toolNames.closeWorkspace} preserves Workspace identity. Managed close finalizes backing and needs commitMessage. Composite close preserves members; delete removes only Composite state. Active worktree delete still finalizes safely; checkout files are never deleted.`;
|
|
73
|
-
const activityPanel = `Project-work order: ${toolNames.openWorkspace} if needed → activity_panel(workspaceId) once → work tools. activity_panel is the single ForgeRelay UI render tool: Workspace above Activity. A new workspaceId creates a new card. Never call activity_panel before needed ${toolNames.openWorkspace}.`;
|
|
74
73
|
const agents = `Follow instructions returned by ${toolNames.openWorkspace}. Read an availableAgentsFiles path before working under it.`;
|
|
75
74
|
const capabilityGuides = `For optional capabilities from ${toolNames.openWorkspace}, use ${toolNames.capability}; if unfamiliar, describe first and read its advertised capability guide with ${toolNames.read}.`;
|
|
76
75
|
const skills = config.skillsEnabled
|
|
@@ -78,7 +77,7 @@ function capabilityContractInstructions(config) {
|
|
|
78
77
|
: "";
|
|
79
78
|
const shellMutationPolicy = buildShellMutationPolicy();
|
|
80
79
|
const hooks = "When a ForgeRelay tool result reports Hook results, tell the user which meaningful hooks ran and whether they passed or blocked the operation. Do not claim the requested operation succeeded when a blocking hook prevented it.";
|
|
81
|
-
return joinInstructions(workspaceLifecycle,
|
|
80
|
+
return joinInstructions(workspaceLifecycle, agents, capabilityGuides, skills, shellMutationPolicy, hooks);
|
|
82
81
|
}
|
|
83
82
|
function selectedWorkflowInstructions(config) {
|
|
84
83
|
if (config.workflowInstructions === false)
|
|
@@ -89,10 +88,10 @@ function selectedWorkflowInstructions(config) {
|
|
|
89
88
|
}
|
|
90
89
|
function defaultWorkflowInstructions(config) {
|
|
91
90
|
if (config.toolMode === "codex") {
|
|
92
|
-
return `Use ${toolNames.read} for direct file reads, ${toolNames.rename} and ${toolNames.delete} for direct path moves or removals, apply_patch for content modifications, exec_command for inspection, tests, builds, and other commands, and ${toolNames.writeStdin} to poll or interact with running processes.`;
|
|
91
|
+
return `Use ${toolNames.read} for direct file reads, ${toolNames.rename} and ${toolNames.delete} for direct path moves or removals, apply_patch for content modifications, exec_command for inspection, tests, builds, and other commands, and ${toolNames.writeStdin} to poll or interact with running processes. Wait-only polling uses at least 60000ms for positive yieldTimeMs even with buffered output; 0 is one immediate probe per running process and repeats are rejected. Short waits are for interaction.`;
|
|
93
92
|
}
|
|
94
93
|
const inspection = `Use ${toolNames.shell} with command-line tools such as grep, rg, find, ls, and tree for search and directory inspection.`;
|
|
95
|
-
return joinInstructions(inspection, `Prefer ${toolNames.edit} for targeted content modifications, ${toolNames.write} only for new files or complete rewrites, ${toolNames.rename} for path moves, ${toolNames.delete} for removals, and ${toolNames.shell} for tests, builds, Git/package scripts, generators, formatters, and shell-suited commands.
|
|
94
|
+
return joinInstructions(inspection, `Prefer ${toolNames.edit} for targeted content modifications, ${toolNames.write} only for new files or complete rewrites, ${toolNames.rename} for path moves, ${toolNames.delete} for removals, and ${toolNames.shell} for tests, builds, Git/package scripts, generators, formatters, and shell-suited commands. Wait-only polling uses at least 60000ms for positive yieldTimeMs even with buffered output; 0 is one immediate probe per running process and repeats are rejected. Process exit returns sooner; short waits are for interaction.`);
|
|
96
95
|
}
|
|
97
96
|
function joinInstructions(...parts) {
|
|
98
97
|
return parts
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { DEFAULT_REQUEST_TIMEOUT_MSEC } from "@modelcontextprotocol/client";
|
|
2
|
+
import { DEFAULT_EXEC_YIELD_MS, DEFAULT_INTERACTIVE_YIELD_MS, DEFAULT_POLL_YIELD_MS, } from "../../../mcp/process/process-wait-policy.js";
|
|
3
|
+
const REMOTE_WAIT_HEADROOM_MS = 5_000;
|
|
4
|
+
export function remoteToolCallTimeoutMs(name, args) {
|
|
5
|
+
const waitMs = remoteToolWaitMs(name, args);
|
|
6
|
+
if (waitMs === undefined || waitMs < DEFAULT_REQUEST_TIMEOUT_MSEC)
|
|
7
|
+
return undefined;
|
|
8
|
+
return waitMs + REMOTE_WAIT_HEADROOM_MS;
|
|
9
|
+
}
|
|
10
|
+
function remoteToolWaitMs(name, args) {
|
|
11
|
+
const requested = numberField(args.yieldTimeMs);
|
|
12
|
+
if (name === "bash") {
|
|
13
|
+
const action = typeof args.action === "string" ? args.action : "run";
|
|
14
|
+
if (action === "output")
|
|
15
|
+
return undefined;
|
|
16
|
+
if (action === "run")
|
|
17
|
+
return requested ?? DEFAULT_EXEC_YIELD_MS;
|
|
18
|
+
if (action !== "process")
|
|
19
|
+
return undefined;
|
|
20
|
+
return processControlWaitMs(requested, typeof args.input === "string" && args.input.length > 0 ||
|
|
21
|
+
args.interrupt === true ||
|
|
22
|
+
args.columns !== undefined ||
|
|
23
|
+
args.rows !== undefined);
|
|
24
|
+
}
|
|
25
|
+
if (name === "exec_command")
|
|
26
|
+
return requested ?? DEFAULT_EXEC_YIELD_MS;
|
|
27
|
+
if (name !== "write_stdin" || args.outputId !== undefined)
|
|
28
|
+
return undefined;
|
|
29
|
+
return processControlWaitMs(requested, typeof args.chars === "string" && args.chars.length > 0 ||
|
|
30
|
+
args.columns !== undefined ||
|
|
31
|
+
args.rows !== undefined);
|
|
32
|
+
}
|
|
33
|
+
function processControlWaitMs(requested, interactionRequested) {
|
|
34
|
+
if (interactionRequested)
|
|
35
|
+
return requested ?? DEFAULT_INTERACTIVE_YIELD_MS;
|
|
36
|
+
if (requested === 0)
|
|
37
|
+
return 0;
|
|
38
|
+
return Math.max(DEFAULT_POLL_YIELD_MS, requested ?? DEFAULT_POLL_YIELD_MS);
|
|
39
|
+
}
|
|
40
|
+
function numberField(value) {
|
|
41
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
42
|
+
}
|
|
@@ -4,6 +4,7 @@ import { join } from "node:path";
|
|
|
4
4
|
import { CompatibilityCallToolResultSchema } from "@modelcontextprotocol/core";
|
|
5
5
|
import { isRemoteMcpUnauthorized, refreshRemoteAuthentication, } from "./auth/remote-auth.js";
|
|
6
6
|
import { RemoteMcpConnectionPool } from "./transport/remote-mcp-connection-pool.js";
|
|
7
|
+
import { remoteToolCallTimeoutMs } from "./transport/remote-call-timeout.js";
|
|
7
8
|
import { withFileLock } from "../../runtime/state/lock/file-lock.js";
|
|
8
9
|
import { withRemoteServiceEndpoint } from "./transport/remote-transport.js";
|
|
9
10
|
import { loadForgeRelayFiles, writeForgeRelayRemote, } from "../../runtime/config/user-config.js";
|
|
@@ -227,15 +228,22 @@ export class RemoteWorkspaceRelay {
|
|
|
227
228
|
throw new Error(`Remote activity_panel failed: ${toolResultText(result)}`);
|
|
228
229
|
}
|
|
229
230
|
const turnId = stringField(result.structuredContent, "turnId", "Remote activity_panel response");
|
|
230
|
-
this.turnRoutes.set(turnId, gatewayWorkspaceId);
|
|
231
|
+
this.turnRoutes.set(turnId, { workspaceId: gatewayWorkspaceId, conversationScopeId });
|
|
231
232
|
return result;
|
|
232
233
|
}
|
|
233
234
|
async activitySnapshot(input, conversationScopeId) {
|
|
235
|
+
const turnRoute = input.turnId ? this.turnRoutes.get(input.turnId) : undefined;
|
|
236
|
+
if (input.turnId && !turnRoute)
|
|
237
|
+
return undefined;
|
|
238
|
+
if (turnRoute && turnRoute.conversationScopeId !== conversationScopeId) {
|
|
239
|
+
throw new Error(`Unknown Host Turn: ${input.turnId}.`);
|
|
240
|
+
}
|
|
234
241
|
const gatewayWorkspaceId = input.workspaceId && this.has(input.workspaceId)
|
|
235
242
|
? input.workspaceId
|
|
236
|
-
:
|
|
237
|
-
|
|
238
|
-
|
|
243
|
+
: turnRoute?.workspaceId;
|
|
244
|
+
if (turnRoute && turnRoute.workspaceId !== gatewayWorkspaceId) {
|
|
245
|
+
throw new Error(`Unknown Host Turn: ${input.turnId}.`);
|
|
246
|
+
}
|
|
239
247
|
if (!gatewayWorkspaceId)
|
|
240
248
|
return undefined;
|
|
241
249
|
const route = this.requireRoute(gatewayWorkspaceId);
|
|
@@ -248,7 +256,10 @@ export class RemoteWorkspaceRelay {
|
|
|
248
256
|
}, conversationScopeId);
|
|
249
257
|
const remapped = remapToolResultWorkspaceId(result, route.remoteWorkspaceId, gatewayWorkspaceId);
|
|
250
258
|
const turnId = stringField(remapped.structuredContent, "turnId", "Remote activity_snapshot response");
|
|
251
|
-
|
|
259
|
+
if (input.turnId && turnId !== input.turnId) {
|
|
260
|
+
throw new Error(`Unknown Host Turn: ${input.turnId}.`);
|
|
261
|
+
}
|
|
262
|
+
this.turnRoutes.set(turnId, { workspaceId: gatewayWorkspaceId, conversationScopeId });
|
|
252
263
|
return remapped;
|
|
253
264
|
}
|
|
254
265
|
catch (error) {
|
|
@@ -265,9 +276,13 @@ export class RemoteWorkspaceRelay {
|
|
|
265
276
|
return this.callTurnTool(turnId, "activity_output", { turnId, outputId, ...(cursor !== undefined ? { cursor } : {}) }, conversationScopeId);
|
|
266
277
|
}
|
|
267
278
|
async callTurnTool(turnId, name, args, conversationScopeId) {
|
|
268
|
-
const
|
|
269
|
-
if (!
|
|
279
|
+
const turnRoute = this.turnRoutes.get(turnId);
|
|
280
|
+
if (!turnRoute)
|
|
270
281
|
return undefined;
|
|
282
|
+
if (turnRoute.conversationScopeId !== conversationScopeId) {
|
|
283
|
+
throw new Error(`Unknown Host Turn: ${turnId}.`);
|
|
284
|
+
}
|
|
285
|
+
const gatewayWorkspaceId = turnRoute.workspaceId;
|
|
271
286
|
const route = this.requireRoute(gatewayWorkspaceId);
|
|
272
287
|
const resolved = this.remoteByInstance(route.remoteInstanceId);
|
|
273
288
|
try {
|
|
@@ -313,8 +328,8 @@ export class RemoteWorkspaceRelay {
|
|
|
313
328
|
this.routes.delete(gatewayWorkspaceId);
|
|
314
329
|
await this.deletePersistedRoute(gatewayWorkspaceId);
|
|
315
330
|
}
|
|
316
|
-
for (const [turnId,
|
|
317
|
-
if (
|
|
331
|
+
for (const [turnId, turnRoute] of this.turnRoutes) {
|
|
332
|
+
if (turnRoute.workspaceId === gatewayWorkspaceId)
|
|
318
333
|
this.turnRoutes.delete(turnId);
|
|
319
334
|
}
|
|
320
335
|
const actionText = action === "delete" ? "Deleted" : "Closed";
|
|
@@ -469,13 +484,14 @@ export class RemoteWorkspaceRelay {
|
|
|
469
484
|
}
|
|
470
485
|
}
|
|
471
486
|
const invoke = async (active) => {
|
|
487
|
+
const requestTimeoutMs = remoteToolCallTimeoutMs(name, args);
|
|
472
488
|
const parsed = CompatibilityCallToolResultSchema.parse(await active.client.callTool({
|
|
473
489
|
name,
|
|
474
490
|
arguments: args,
|
|
475
491
|
...(conversationScopeId
|
|
476
492
|
? { _meta: { "openai/session": conversationScopeId } }
|
|
477
493
|
: {}),
|
|
478
|
-
}));
|
|
494
|
+
}, requestTimeoutMs === undefined ? undefined : { timeout: requestTimeoutMs }));
|
|
479
495
|
if (!Array.isArray(parsed.content)) {
|
|
480
496
|
throw new Error("Remote ForgeRelay returned a modern-only tool result on the legacy Relay protocol path.");
|
|
481
497
|
}
|
package/package.json
CHANGED
|
@@ -59,7 +59,7 @@ export async function runBootstrapAcceptance({ server, packageJson, ownerToken,
|
|
|
59
59
|
assert.match(serverInstructions, /Shell commands may modify ordinary project files/);
|
|
60
60
|
assert.match(serverInstructions, /\/etc\/sudoers/);
|
|
61
61
|
assert.match(serverInstructions, /configuration files through shell only when the user's request explicitly calls for that configuration change/);
|
|
62
|
-
assert.
|
|
62
|
+
assert.doesNotMatch(serverInstructions, /activity_panel/);
|
|
63
63
|
assert.ok(serverInstructions.length < 3_000, `server instructions should stay compact, got ${serverInstructions.length} characters`);
|
|
64
64
|
assert.doesNotMatch(serverInstructions, /fast-forwards the original target branch/);
|
|
65
65
|
assert.doesNotMatch(serverInstructions, /Do not create or modify files with bash/);
|