@akira-tl/forgerelay 0.2.0 → 0.2.1
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 +14 -0
- package/dist/apply-patch.js +21 -6
- package/dist/artifact-tools.js +9 -4
- package/dist/config.js +8 -6
- package/dist/hooks.js +4 -1
- package/dist/logger.js +157 -15
- package/dist/mcp/server-instructions.js +4 -4
- package/dist/pi-tools.js +14 -13
- package/dist/roots.js +30 -1
- package/dist/server.js +67 -54
- package/dist/workspaces.js +19 -6
- package/docs/configuration.md +13 -5
- package/docs/debugging.md +5 -4
- package/docs/security.md +18 -7
- package/package.json +2 -2
- package/scripts/debug/accept.mjs +34 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,20 @@ All notable ForgeRelay changes are documented here.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.2.1] - 2026-08-09
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- Local console logging now defaults to a compact Loguru-style `pretty` format focused on Agent operations: short timestamps, workspace/session context, tool or Hook action, target, and `ok`/`error` or shell exit status. HTTP request logging is off by default in human mode, while explicit `json` mode keeps the previous request-on and shell-command-off machine defaults.
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- File and search tools can access and modify files in the operating system temporary directory without making that directory an implicit workspace root; Codex `apply_patch` supports absolute OS-temp paths while workspace patch paths remain relative.
|
|
16
|
+
|
|
17
|
+
### Security
|
|
18
|
+
|
|
19
|
+
- 文件工具在 workspace 与 OS temp 边界内都会校验 canonical path,阻止通过 symlink 跳转到任意文件系统位置;shell cwd 与 workspace-open allowed roots 不随 temp 文件访问而扩大。
|
|
20
|
+
|
|
7
21
|
## [0.2.0] - 2026-08-09
|
|
8
22
|
|
|
9
23
|
### Added
|
package/dist/apply-patch.js
CHANGED
|
@@ -4,6 +4,7 @@ import { access, lstat, mkdir, readFile, realpath, rename, rm, stat, writeFile }
|
|
|
4
4
|
import { dirname, isAbsolute, relative, resolve } from "node:path";
|
|
5
5
|
import { TextDecoder } from "node:util";
|
|
6
6
|
import { createTwoFilesPatch, FILE_HEADERS_ONLY } from "diff";
|
|
7
|
+
import { AccessDeniedError, resolveCanonicalAllowedPath } from "./roots.js";
|
|
7
8
|
function patchError(message) {
|
|
8
9
|
return new Error(`Invalid patch: ${message}`);
|
|
9
10
|
}
|
|
@@ -137,10 +138,24 @@ function isInside(root, path) {
|
|
|
137
138
|
const rel = relative(root, path);
|
|
138
139
|
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
|
139
140
|
}
|
|
140
|
-
async function resolveConfinedPath(root, input) {
|
|
141
|
-
if (!input || input.includes("\0")
|
|
141
|
+
async function resolveConfinedPath(root, input, auxiliaryRoots = []) {
|
|
142
|
+
if (!input || input.includes("\0")) {
|
|
142
143
|
throw patchError(`path must be relative to the workspace: ${input}`);
|
|
143
144
|
}
|
|
145
|
+
if (isAbsolute(input)) {
|
|
146
|
+
if (auxiliaryRoots.length === 0) {
|
|
147
|
+
throw patchError(`path must be relative to the workspace: ${input}`);
|
|
148
|
+
}
|
|
149
|
+
try {
|
|
150
|
+
return await resolveCanonicalAllowedPath(input, root, auxiliaryRoots);
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
if (error instanceof AccessDeniedError) {
|
|
154
|
+
throw patchError(`path is outside allowed auxiliary roots: ${input}`);
|
|
155
|
+
}
|
|
156
|
+
throw error;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
144
159
|
const rootPath = await realpath(root);
|
|
145
160
|
const target = resolve(rootPath, input);
|
|
146
161
|
if (!isInside(rootPath, target)) {
|
|
@@ -269,7 +284,7 @@ export async function isSamePatchFile(source, destination, readIdentity = lstat)
|
|
|
269
284
|
throw error;
|
|
270
285
|
}
|
|
271
286
|
}
|
|
272
|
-
export async function applyPatch(root, patch) {
|
|
287
|
+
export async function applyPatch(root, patch, auxiliaryRoots = []) {
|
|
273
288
|
const actions = parsePatch(patch);
|
|
274
289
|
const results = [];
|
|
275
290
|
const patches = [];
|
|
@@ -289,14 +304,14 @@ export async function applyPatch(root, patch) {
|
|
|
289
304
|
};
|
|
290
305
|
for (const action of actions) {
|
|
291
306
|
if (action.kind === "add") {
|
|
292
|
-
const absolute = await resolveConfinedPath(root, action.path);
|
|
307
|
+
const absolute = await resolveConfinedPath(root, action.path, auxiliaryRoots);
|
|
293
308
|
const original = await readStagedOptional(absolute, action.path);
|
|
294
309
|
staged.set(absolute, { content: action.content, mode: original?.mode });
|
|
295
310
|
patches.push(unifiedFilePatch(action.path, action.path, original?.content ?? null, action.content));
|
|
296
311
|
results.push({ path: action.path, operation: original ? "update" : "add" });
|
|
297
312
|
continue;
|
|
298
313
|
}
|
|
299
|
-
const absolute = await resolveConfinedPath(root, action.path);
|
|
314
|
+
const absolute = await resolveConfinedPath(root, action.path, auxiliaryRoots);
|
|
300
315
|
const file = await readStagedRequired(absolute, action.path);
|
|
301
316
|
if (action.kind === "delete") {
|
|
302
317
|
staged.set(absolute, null);
|
|
@@ -306,7 +321,7 @@ export async function applyPatch(root, patch) {
|
|
|
306
321
|
}
|
|
307
322
|
const updated = applyHunks(action.path, file.content, action.hunks);
|
|
308
323
|
if (action.moveTo) {
|
|
309
|
-
const destination = await resolveConfinedPath(root, action.moveTo);
|
|
324
|
+
const destination = await resolveConfinedPath(root, action.moveTo, auxiliaryRoots);
|
|
310
325
|
const samePatchFile = await isSamePatchFile(absolute, destination);
|
|
311
326
|
if (!samePatchFile)
|
|
312
327
|
await readStagedOptional(destination, action.moveTo);
|
package/dist/artifact-tools.js
CHANGED
|
@@ -7,7 +7,7 @@ import * as z from "zod/v4";
|
|
|
7
7
|
import { ArtifactError } from "./artifact-error.js";
|
|
8
8
|
import { runToolWithHooks } from "./hooks.js";
|
|
9
9
|
import { describeIncomingArtifactValue, IncomingArtifactAdapterRegistry, } from "./incoming-artifacts.js";
|
|
10
|
-
import { logEvent } from "./logger.js";
|
|
10
|
+
import { logEvent, sessionIdPrefix, workspaceLogLabel } from "./logger.js";
|
|
11
11
|
const ARTIFACT_WRITE_ANNOTATIONS = {
|
|
12
12
|
readOnlyHint: false,
|
|
13
13
|
destructiveHint: false,
|
|
@@ -47,7 +47,7 @@ export function registerArtifactTools(server, { config, workspaces, hooks, incom
|
|
|
47
47
|
},
|
|
48
48
|
_meta: { "openai/fileParams": ["file"] },
|
|
49
49
|
annotations: ARTIFACT_WRITE_ANNOTATIONS,
|
|
50
|
-
}, async (input) => {
|
|
50
|
+
}, async (input, extra) => {
|
|
51
51
|
const workspace = workspaces.getWorkspace(input.workspaceId);
|
|
52
52
|
return runToolWithHooks(hooks, {
|
|
53
53
|
tool: "download_artifact",
|
|
@@ -59,7 +59,10 @@ export function registerArtifactTools(server, { config, workspaces, hooks, incom
|
|
|
59
59
|
},
|
|
60
60
|
payload: { path: input.path },
|
|
61
61
|
changedPaths: (result) => [result.structuredContent.path],
|
|
62
|
-
operation: () => executeArtifactTool(config, input,
|
|
62
|
+
operation: () => executeArtifactTool(config, input, {
|
|
63
|
+
workspace: workspaceLogLabel(workspace.root, workspace.id),
|
|
64
|
+
session: sessionIdPrefix(extra?.sessionId),
|
|
65
|
+
}, async () => {
|
|
63
66
|
const downloaded = await downloadIncomingArtifact({
|
|
64
67
|
registry: incomingRegistry,
|
|
65
68
|
workspaceId: workspace.id,
|
|
@@ -165,7 +168,7 @@ export function artifactToolLogFields(input) {
|
|
|
165
168
|
path: input.path,
|
|
166
169
|
};
|
|
167
170
|
}
|
|
168
|
-
async function executeArtifactTool(config, input, operation) {
|
|
171
|
+
async function executeArtifactTool(config, input, logContext, operation) {
|
|
169
172
|
const startedAt = performance.now();
|
|
170
173
|
try {
|
|
171
174
|
const { publicResult, logResult } = await operation();
|
|
@@ -173,6 +176,7 @@ async function executeArtifactTool(config, input, operation) {
|
|
|
173
176
|
logEvent(config.logging, "info", "artifact_tool_call", {
|
|
174
177
|
tool: "download_artifact",
|
|
175
178
|
...artifactToolLogFields(input),
|
|
179
|
+
...logContext,
|
|
176
180
|
path: logResult.path,
|
|
177
181
|
size: logResult.size,
|
|
178
182
|
sha256: logResult.sha256,
|
|
@@ -187,6 +191,7 @@ async function executeArtifactTool(config, input, operation) {
|
|
|
187
191
|
logEvent(config.logging, "warn", "artifact_tool_call", {
|
|
188
192
|
tool: "download_artifact",
|
|
189
193
|
...artifactToolLogFields(input),
|
|
194
|
+
...logContext,
|
|
190
195
|
success: false,
|
|
191
196
|
errorCode: error instanceof ArtifactError ? error.code : "internal_error",
|
|
192
197
|
durationMs: Math.round(performance.now() - startedAt),
|
package/dist/config.js
CHANGED
|
@@ -81,10 +81,10 @@ function parseLogLevel(value) {
|
|
|
81
81
|
throw new Error(`Invalid FORGERELAY_LOG_LEVEL: ${value}`);
|
|
82
82
|
}
|
|
83
83
|
function parseLogFormat(value) {
|
|
84
|
-
if (!value || value === "
|
|
85
|
-
return "json";
|
|
86
|
-
if (value === "pretty")
|
|
84
|
+
if (!value || value === "pretty")
|
|
87
85
|
return "pretty";
|
|
86
|
+
if (value === "json")
|
|
87
|
+
return "json";
|
|
88
88
|
throw new Error(`Invalid FORGERELAY_LOG_FORMAT: ${value}`);
|
|
89
89
|
}
|
|
90
90
|
function parsePathList(value) {
|
|
@@ -110,15 +110,17 @@ function parsePositiveInteger(value, fallback, name, max = Number.MAX_SAFE_INTEG
|
|
|
110
110
|
return parsed;
|
|
111
111
|
}
|
|
112
112
|
function parseLoggingConfig(env) {
|
|
113
|
+
const format = parseLogFormat(productEnv(env, "LOG_FORMAT"));
|
|
113
114
|
const requests = productEnv(env, "LOG_REQUESTS");
|
|
114
115
|
const toolCalls = productEnv(env, "LOG_TOOL_CALLS");
|
|
116
|
+
const shellCommands = productEnv(env, "LOG_SHELL_COMMANDS");
|
|
115
117
|
return {
|
|
116
118
|
level: parseLogLevel(productEnv(env, "LOG_LEVEL")),
|
|
117
|
-
format
|
|
118
|
-
requests: requests === undefined ?
|
|
119
|
+
format,
|
|
120
|
+
requests: requests === undefined ? format === "json" : parseBoolean(requests),
|
|
119
121
|
assets: parseBoolean(productEnv(env, "LOG_ASSETS")),
|
|
120
122
|
toolCalls: toolCalls === undefined ? true : parseBoolean(toolCalls),
|
|
121
|
-
shellCommands:
|
|
123
|
+
shellCommands: shellCommands === undefined ? format === "pretty" : parseBoolean(shellCommands),
|
|
122
124
|
trustProxy: parseBoolean(productEnv(env, "TRUST_PROXY")),
|
|
123
125
|
};
|
|
124
126
|
}
|
package/dist/hooks.js
CHANGED
|
@@ -2,7 +2,7 @@ import { spawn } from "node:child_process";
|
|
|
2
2
|
import { readFile, readdir } from "node:fs/promises";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { performance } from "node:perf_hooks";
|
|
5
|
-
import { commandPreview, logEvent } from "./logger.js";
|
|
5
|
+
import { commandPreview, logEvent, workspaceLogLabel } from "./logger.js";
|
|
6
6
|
import { resolveShellCommand, terminateProcessTree } from "./process-platform.js";
|
|
7
7
|
export const HOOK_EVENTS = [
|
|
8
8
|
"WorkspaceOpen",
|
|
@@ -224,6 +224,9 @@ export class HookRunner {
|
|
|
224
224
|
hookName: execution.name,
|
|
225
225
|
hookScope: execution.scope,
|
|
226
226
|
workspaceId: invocation.workspaceId,
|
|
227
|
+
workspace: invocation.workspaceId
|
|
228
|
+
? workspaceLogLabel(invocation.workspaceRoot, invocation.workspaceId)
|
|
229
|
+
: invocation.workspaceRoot,
|
|
227
230
|
success: execution.status === "passed",
|
|
228
231
|
durationMs: execution.durationMs,
|
|
229
232
|
error: execution.error,
|
package/dist/logger.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
import { styleText } from "node:util";
|
|
1
3
|
const LEVEL_WEIGHT = {
|
|
2
4
|
silent: 0,
|
|
3
5
|
error: 1,
|
|
@@ -5,6 +7,12 @@ const LEVEL_WEIGHT = {
|
|
|
5
7
|
info: 3,
|
|
6
8
|
debug: 4,
|
|
7
9
|
};
|
|
10
|
+
const LEVEL_STYLE = {
|
|
11
|
+
error: "red",
|
|
12
|
+
warn: "yellow",
|
|
13
|
+
info: "green",
|
|
14
|
+
debug: "gray",
|
|
15
|
+
};
|
|
8
16
|
export function shouldLog(config, level) {
|
|
9
17
|
return LEVEL_WEIGHT[config.level] >= LEVEL_WEIGHT[level];
|
|
10
18
|
}
|
|
@@ -17,7 +25,10 @@ export function logEvent(config, level, event, fields = {}) {
|
|
|
17
25
|
event,
|
|
18
26
|
...fields,
|
|
19
27
|
};
|
|
20
|
-
const
|
|
28
|
+
const stream = level === "error" || level === "warn" ? process.stderr : process.stdout;
|
|
29
|
+
const line = config.format === "pretty"
|
|
30
|
+
? formatPrettyLogEntry(entry, { colorize: true, stream })
|
|
31
|
+
: JSON.stringify(entry);
|
|
21
32
|
if (level === "error") {
|
|
22
33
|
console.error(line);
|
|
23
34
|
}
|
|
@@ -45,25 +56,156 @@ export function requestPath(req) {
|
|
|
45
56
|
export function sessionIdPrefix(sessionId) {
|
|
46
57
|
return sessionId ? sessionId.slice(0, 8) : undefined;
|
|
47
58
|
}
|
|
59
|
+
export function workspaceLogLabel(root, workspaceId) {
|
|
60
|
+
const shortWorkspaceId = workspaceId.startsWith("ws_")
|
|
61
|
+
? `ws_${workspaceId.slice(3, 11)}`
|
|
62
|
+
: workspaceId.slice(0, 8);
|
|
63
|
+
return `${basename(root)}/${shortWorkspaceId}`;
|
|
64
|
+
}
|
|
48
65
|
export function commandPreview(command) {
|
|
49
66
|
const normalized = command.replace(/\s+/g, " ").trim();
|
|
50
67
|
return normalized.length > 120 ? `${normalized.slice(0, 117)}...` : normalized;
|
|
51
68
|
}
|
|
69
|
+
export function formatPrettyLogEntry(entry, options = {}) {
|
|
70
|
+
const level = logLevel(entry.level);
|
|
71
|
+
const time = formatTimestamp(entry.ts);
|
|
72
|
+
const source = stringField(entry.workspace) ?? stringField(entry.workspaceId) ?? "forgerelay";
|
|
73
|
+
const session = stringField(entry.session) ?? stringField(entry.sessionIdPrefix);
|
|
74
|
+
const prefix = [
|
|
75
|
+
style("gray", time, options),
|
|
76
|
+
`[${style(LEVEL_STYLE[level], level.toUpperCase(), options)}]`,
|
|
77
|
+
style(["cyan", "underline"], source, options),
|
|
78
|
+
session ? style("gray", `session:${session}`, options) : undefined,
|
|
79
|
+
style("gray", "|", options),
|
|
80
|
+
].filter((value) => Boolean(value)).join(" ");
|
|
81
|
+
return `${prefix} ${formatPrettyMessage(entry, options)}`;
|
|
82
|
+
}
|
|
52
83
|
function firstHeaderValue(value) {
|
|
53
84
|
return value?.split(",")[0]?.trim() || undefined;
|
|
54
85
|
}
|
|
55
|
-
function
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
86
|
+
function formatPrettyMessage(entry, options) {
|
|
87
|
+
switch (String(entry.event)) {
|
|
88
|
+
case "tool_call":
|
|
89
|
+
case "artifact_tool_call":
|
|
90
|
+
return formatToolMessage(entry, options);
|
|
91
|
+
case "hook_call":
|
|
92
|
+
return formatHookMessage(entry, options);
|
|
93
|
+
case "http_request":
|
|
94
|
+
return formatHttpMessage(entry, options);
|
|
95
|
+
case "mcp_session_created":
|
|
96
|
+
return `session ${stringField(entry.sessionIdPrefix) ?? "unknown"} created`;
|
|
97
|
+
case "mcp_session_closed":
|
|
98
|
+
return `session ${stringField(entry.sessionIdPrefix) ?? "unknown"} closed`;
|
|
99
|
+
case "mcp_session_close_failed":
|
|
100
|
+
return `session ${stringField(entry.sessionIdPrefix) ?? "unknown"} close -> ${style("red", "error", options)}`;
|
|
101
|
+
case "auth_denied":
|
|
102
|
+
return `auth denied${entry.reason ? `: ${String(entry.reason)}` : ""}`;
|
|
103
|
+
case "mcp_request_error":
|
|
104
|
+
return `mcp request -> ${style("red", `error${entry.error ? `: ${String(entry.error)}` : ""}`, options)}`;
|
|
105
|
+
default:
|
|
106
|
+
return formatGenericMessage(entry);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
function formatToolMessage(entry, options) {
|
|
110
|
+
const tool = stringField(entry.tool) ?? String(entry.event ?? "tool");
|
|
111
|
+
const target = toolTarget(entry, tool);
|
|
112
|
+
const operation = target ? `${tool} ${target}` : tool;
|
|
113
|
+
const result = toolResult(entry, tool, options);
|
|
114
|
+
return `${operation} -> ${result}`;
|
|
115
|
+
}
|
|
116
|
+
function toolTarget(entry, tool) {
|
|
117
|
+
if (isShellTool(tool)) {
|
|
118
|
+
return stringField(entry.commandPreview) ?? stringField(entry.workingDirectory);
|
|
119
|
+
}
|
|
120
|
+
return stringField(entry.path) ?? stringField(entry.workingDirectory);
|
|
121
|
+
}
|
|
122
|
+
function toolResult(entry, tool, options) {
|
|
123
|
+
if (entry.running === true) {
|
|
124
|
+
const processSessionId = entry.processSessionId;
|
|
125
|
+
return style("yellow", processSessionId === undefined ? "running" : `running process:${String(processSessionId)}`, options);
|
|
126
|
+
}
|
|
127
|
+
const exitCode = numberField(entry.exitCode) ?? exitCodeFromError(entry.error);
|
|
128
|
+
if (isShellTool(tool)) {
|
|
129
|
+
if (exitCode !== undefined) {
|
|
130
|
+
return style(exitCode === 0 ? "green" : "red", `exit=${exitCode}`, options);
|
|
131
|
+
}
|
|
132
|
+
if (entry.success === true)
|
|
133
|
+
return style("green", "exit=0", options);
|
|
134
|
+
}
|
|
135
|
+
if (entry.success === false) {
|
|
136
|
+
const error = stringField(entry.error);
|
|
137
|
+
return style("red", error ? `error: ${error}` : "error", options);
|
|
138
|
+
}
|
|
139
|
+
if (entry.success === true)
|
|
140
|
+
return style("green", "ok", options);
|
|
141
|
+
return "done";
|
|
142
|
+
}
|
|
143
|
+
function formatHookMessage(entry, options) {
|
|
144
|
+
const name = stringField(entry.hookName) ?? "hook";
|
|
145
|
+
const event = stringField(entry.hookEvent);
|
|
146
|
+
const operation = event ? `hook ${name} ${event}` : `hook ${name}`;
|
|
147
|
+
const exitCode = exitCodeFromHookError(entry.error);
|
|
148
|
+
if (entry.success === false) {
|
|
149
|
+
if (exitCode !== undefined)
|
|
150
|
+
return `${operation} -> ${style("red", `exit=${exitCode}`, options)}`;
|
|
151
|
+
const error = stringField(entry.error);
|
|
152
|
+
return `${operation} -> ${style("red", error ? `error: ${error}` : "error", options)}`;
|
|
153
|
+
}
|
|
154
|
+
return `${operation} -> ${style("green", "exit=0", options)}`;
|
|
155
|
+
}
|
|
156
|
+
function formatHttpMessage(entry, options) {
|
|
157
|
+
const method = stringField(entry.method) ?? "HTTP";
|
|
158
|
+
const path = stringField(entry.path) ?? "/";
|
|
159
|
+
const status = numberField(entry.status);
|
|
160
|
+
const statusText = status === undefined ? "done" : String(status);
|
|
161
|
+
const statusStyle = status !== undefined && status >= 400 ? "red" : "green";
|
|
162
|
+
return `http ${method} ${path} -> ${style(statusStyle, statusText, options)}`;
|
|
163
|
+
}
|
|
164
|
+
function formatGenericMessage(entry) {
|
|
165
|
+
const event = String(entry.event ?? "log");
|
|
166
|
+
const detail = [entry.reason, entry.error]
|
|
167
|
+
.map((value) => stringField(value))
|
|
168
|
+
.find((value) => value !== undefined);
|
|
169
|
+
return detail ? `${event}: ${detail}` : event;
|
|
170
|
+
}
|
|
171
|
+
function formatTimestamp(value) {
|
|
172
|
+
const date = new Date(String(value));
|
|
173
|
+
if (Number.isNaN(date.getTime()))
|
|
174
|
+
return String(value ?? "");
|
|
175
|
+
const two = (part) => String(part).padStart(2, "0");
|
|
176
|
+
return `${two(date.getMonth() + 1)}-${two(date.getDate())} ${two(date.getHours())}:${two(date.getMinutes())}:${two(date.getSeconds())}`;
|
|
177
|
+
}
|
|
178
|
+
function logLevel(value) {
|
|
179
|
+
return value === "error" || value === "warn" || value === "debug" ? value : "info";
|
|
180
|
+
}
|
|
181
|
+
function stringField(value) {
|
|
182
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
183
|
+
}
|
|
184
|
+
function numberField(value) {
|
|
185
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
186
|
+
}
|
|
187
|
+
function exitCodeFromError(value) {
|
|
188
|
+
const error = stringField(value);
|
|
189
|
+
if (!error)
|
|
190
|
+
return undefined;
|
|
191
|
+
const match = error.match(/Command exited with code (-?\d+)/i);
|
|
192
|
+
return match ? Number(match[1]) : undefined;
|
|
193
|
+
}
|
|
194
|
+
function exitCodeFromHookError(value) {
|
|
195
|
+
const error = stringField(value);
|
|
196
|
+
if (!error)
|
|
197
|
+
return undefined;
|
|
198
|
+
const match = error.match(/exited with code (-?\d+)/i);
|
|
199
|
+
return match ? Number(match[1]) : undefined;
|
|
200
|
+
}
|
|
201
|
+
function isShellTool(tool) {
|
|
202
|
+
return tool === "bash" || tool === "exec_command" || tool === "write_stdin";
|
|
203
|
+
}
|
|
204
|
+
function style(format, text, options) {
|
|
205
|
+
if (options.colorize !== true)
|
|
206
|
+
return text;
|
|
207
|
+
return styleText(format, text, {
|
|
208
|
+
validateStream: options.validateStream ?? true,
|
|
209
|
+
stream: options.stream ?? process.stdout,
|
|
210
|
+
});
|
|
69
211
|
}
|
|
@@ -20,10 +20,10 @@ export function buildToolDescriptions(config) {
|
|
|
20
20
|
? ` In minimal tool mode, ${toolNames.grep}, ${toolNames.glob}, and ${toolNames.ls} are disabled, so shell commands may be used for equivalent search and directory inspection.`
|
|
21
21
|
: "";
|
|
22
22
|
return {
|
|
23
|
-
read: `Read a file inside an open workspace. Instruction files returned by ${toolNames.openWorkspace} and advertised skill files are also readable when applicable.${skillCapability} Call ${toolNames.openWorkspace} first and pass workspaceId.`,
|
|
24
|
-
write: `Create or completely overwrite a file inside an open workspace.
|
|
25
|
-
edit: `Edit one file inside an open workspace by replacing exact text blocks. Each oldText must match a unique, non-overlapping region of the original file. Call ${toolNames.openWorkspace} first and pass workspaceId.`,
|
|
26
|
-
applyPatch: `Apply one Codex-style patch inside an open workspace. Supports adding, overwriting, updating, deleting, and moving files.
|
|
23
|
+
read: `Read a file inside an open workspace or the OS temp directory. Instruction files returned by ${toolNames.openWorkspace} and advertised skill files are also readable when applicable.${skillCapability} Call ${toolNames.openWorkspace} first and pass workspaceId.`,
|
|
24
|
+
write: `Create or completely overwrite a file inside an open workspace or the OS temp directory. Workspace paths may be relative; OS temp paths may be absolute. Call ${toolNames.openWorkspace} first and pass workspaceId.`,
|
|
25
|
+
edit: `Edit one file inside an open workspace or the OS temp directory by replacing exact text blocks. Each oldText must match a unique, non-overlapping region of the original file. Workspace paths may be relative; OS temp paths may be absolute. Call ${toolNames.openWorkspace} first and pass workspaceId.`,
|
|
26
|
+
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.`,
|
|
27
27
|
shell: `Run a shell command inside an open workspace.${shellSurface} Commands execute with the local user's authority; workspace filesystem containment does not make shell execution a sandbox. Do not use ${toolNames.shell} to create or modify project files; use ${toolNames.edit} or ${toolNames.write} for file changes. Call ${toolNames.openWorkspace} first and pass workspaceId. This capability should only be exposed behind strong authentication.`,
|
|
28
28
|
shellCommand: "Shell command to run with the local user's authority.",
|
|
29
29
|
};
|
package/dist/pi-tools.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createBashTool, createEditTool, createFindTool, createGrepTool, createLsTool, createReadTool, createWriteTool, } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import {
|
|
2
|
+
import { resolveCanonicalAllowedPath } from "./roots.js";
|
|
3
3
|
function toMcpContent(result) {
|
|
4
4
|
return result.content.map((content) => {
|
|
5
5
|
if (content.type === "text") {
|
|
@@ -29,7 +29,7 @@ async function runTool(execute, input, context) {
|
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
31
|
export async function readFileTool(input, context) {
|
|
32
|
-
const path =
|
|
32
|
+
const path = await resolveCanonicalAllowedPath(input.path, context.cwd, context.readRoots ?? [context.root]);
|
|
33
33
|
const tool = createReadTool(context.cwd);
|
|
34
34
|
return runTool((params) => tool.execute("read_file", params), {
|
|
35
35
|
path,
|
|
@@ -38,7 +38,7 @@ export async function readFileTool(input, context) {
|
|
|
38
38
|
}, context);
|
|
39
39
|
}
|
|
40
40
|
export async function writeFileTool(input, context) {
|
|
41
|
-
const path =
|
|
41
|
+
const path = await resolveCanonicalAllowedPath(input.path, context.cwd, context.fileRoots ?? [context.root]);
|
|
42
42
|
const tool = createWriteTool(context.cwd);
|
|
43
43
|
return runTool((params) => tool.execute("write_file", params), {
|
|
44
44
|
path,
|
|
@@ -46,7 +46,7 @@ export async function writeFileTool(input, context) {
|
|
|
46
46
|
}, context);
|
|
47
47
|
}
|
|
48
48
|
export async function editFileTool(input, context) {
|
|
49
|
-
const path =
|
|
49
|
+
const path = await resolveCanonicalAllowedPath(input.path, context.cwd, context.fileRoots ?? [context.root]);
|
|
50
50
|
const tool = createEditTool(context.cwd);
|
|
51
51
|
return runTool((params) => tool.execute("edit_file", params), {
|
|
52
52
|
path,
|
|
@@ -54,22 +54,23 @@ export async function editFileTool(input, context) {
|
|
|
54
54
|
}, context);
|
|
55
55
|
}
|
|
56
56
|
export async function grepFilesTool(input, context) {
|
|
57
|
-
|
|
58
|
-
|
|
57
|
+
const path = input.path
|
|
58
|
+
? await resolveCanonicalAllowedPath(input.path, context.cwd, context.fileRoots ?? [context.root])
|
|
59
|
+
: undefined;
|
|
59
60
|
const tool = createGrepTool(context.cwd);
|
|
60
|
-
return runTool((params) => tool.execute("grep_files", params), input, context);
|
|
61
|
+
return runTool((params) => tool.execute("grep_files", params), { ...input, path }, context);
|
|
61
62
|
}
|
|
62
63
|
export async function findFilesTool(input, context) {
|
|
63
|
-
|
|
64
|
-
|
|
64
|
+
const path = input.path
|
|
65
|
+
? await resolveCanonicalAllowedPath(input.path, context.cwd, context.fileRoots ?? [context.root])
|
|
66
|
+
: undefined;
|
|
65
67
|
const tool = createFindTool(context.cwd);
|
|
66
|
-
return runTool((params) => tool.execute("find_files", params), input, context);
|
|
68
|
+
return runTool((params) => tool.execute("find_files", params), { ...input, path }, context);
|
|
67
69
|
}
|
|
68
70
|
export async function listDirectoryTool(input, context) {
|
|
69
|
-
|
|
70
|
-
resolveAllowedPath(input.path, context.cwd, [context.root]);
|
|
71
|
+
const path = await resolveCanonicalAllowedPath(input.path ?? ".", context.cwd, context.fileRoots ?? [context.root]);
|
|
71
72
|
const tool = createLsTool(context.cwd);
|
|
72
|
-
return runTool((params) => tool.execute("list_directory", params), input, context);
|
|
73
|
+
return runTool((params) => tool.execute("list_directory", params), { ...input, path }, context);
|
|
73
74
|
}
|
|
74
75
|
export async function runShellTool(input, context) {
|
|
75
76
|
const tool = createBashTool(context.cwd);
|
package/dist/roots.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { realpath } from "node:fs/promises";
|
|
1
2
|
import { homedir } from "node:os";
|
|
2
|
-
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
3
|
+
import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
3
4
|
export class AccessDeniedError extends Error {
|
|
4
5
|
constructor(message) {
|
|
5
6
|
super(message);
|
|
@@ -35,3 +36,31 @@ export function resolveAllowedPath(inputPath, cwd, allowedRoots) {
|
|
|
35
36
|
const absolutePath = resolve(cwd, inputPath);
|
|
36
37
|
return assertAllowedPath(absolutePath, allowedRoots);
|
|
37
38
|
}
|
|
39
|
+
export async function resolveCanonicalAllowedPath(inputPath, cwd, allowedRoots) {
|
|
40
|
+
const absolutePath = resolveAllowedPath(inputPath, cwd, allowedRoots);
|
|
41
|
+
const canonicalPath = await canonicalizePath(absolutePath);
|
|
42
|
+
const canonicalRoots = await Promise.all(allowedRoots.map((root) => canonicalizePath(resolve(expandHomePath(root)))));
|
|
43
|
+
if (canonicalRoots.some((root) => isPathInsideRoot(canonicalPath, root))) {
|
|
44
|
+
return absolutePath;
|
|
45
|
+
}
|
|
46
|
+
throw new AccessDeniedError(`Path is outside allowed roots: ${inputPath}`);
|
|
47
|
+
}
|
|
48
|
+
async function canonicalizePath(path) {
|
|
49
|
+
const missingSegments = [];
|
|
50
|
+
let candidate = path;
|
|
51
|
+
while (true) {
|
|
52
|
+
try {
|
|
53
|
+
return resolve(await realpath(candidate), ...missingSegments.slice().reverse());
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
const code = error.code;
|
|
57
|
+
if (code !== "ENOENT" && code !== "ENOTDIR")
|
|
58
|
+
throw error;
|
|
59
|
+
const parent = dirname(candidate);
|
|
60
|
+
if (parent === candidate)
|
|
61
|
+
return resolve(path);
|
|
62
|
+
missingSegments.push(basename(candidate));
|
|
63
|
+
candidate = parent;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
package/dist/server.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
3
|
import { access, realpath } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
4
5
|
import { fileURLToPath } from "node:url";
|
|
5
6
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
6
7
|
import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
|
|
@@ -18,7 +19,7 @@ import { loadConfig } from "./config.js";
|
|
|
18
19
|
import { attachHookReports, HookRunner, runToolWithHooks } from "./hooks.js";
|
|
19
20
|
import { buildServerInstructions, buildToolDescriptions, toolNames, } from "./mcp/server-instructions.js";
|
|
20
21
|
import { createOpenAIIncomingArtifactAdapter, } from "./incoming-artifacts.js";
|
|
21
|
-
import { logEvent, requestIp, requestPath, commandPreview, sessionIdPrefix, } from "./logger.js";
|
|
22
|
+
import { logEvent, requestIp, requestPath, commandPreview, sessionIdPrefix, workspaceLogLabel, } from "./logger.js";
|
|
22
23
|
import { editFileTool, findFilesTool, grepFilesTool, listDirectoryTool, readFileTool, runShellTool, writeFileTool, } from "./pi-tools.js";
|
|
23
24
|
import { SingleUserOAuthProvider } from "./oauth-provider.js";
|
|
24
25
|
import { McpSessionRegistry, } from "./mcp-sessions.js";
|
|
@@ -78,6 +79,13 @@ function toolWidgetDescriptorMeta(config, kind) {
|
|
|
78
79
|
},
|
|
79
80
|
};
|
|
80
81
|
}
|
|
82
|
+
function workspaceLogContext(workspace, sessionId) {
|
|
83
|
+
return {
|
|
84
|
+
workspaceId: workspace.id,
|
|
85
|
+
workspace: workspaceLogLabel(workspace.root, workspace.id),
|
|
86
|
+
session: sessionIdPrefix(sessionId),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
81
89
|
function formatVisibleAgent(agent) {
|
|
82
90
|
const model = agent.model ? `, model ${agent.model}` : "";
|
|
83
91
|
const thinking = agent.thinking ? `, thinking ${agent.thinking}` : "";
|
|
@@ -393,7 +401,7 @@ function registerCodexProcessTools(server, config, workspaces, processSessions,
|
|
|
393
401
|
outputSchema: processOutputSchema(),
|
|
394
402
|
...toolWidgetDescriptorMeta(config, "shell"),
|
|
395
403
|
annotations: SHELL_TOOL_ANNOTATIONS,
|
|
396
|
-
}, async ({ workspaceId, cmd, tty, columns, rows, workingDirectory, yieldTimeMs, maxOutputTokens }) => {
|
|
404
|
+
}, async ({ workspaceId, cmd, tty, columns, rows, workingDirectory, yieldTimeMs, maxOutputTokens }, extra) => {
|
|
397
405
|
const workspace = workspaces.getWorkspace(workspaceId);
|
|
398
406
|
return runToolWithHooks(hooks, {
|
|
399
407
|
tool: "exec_command",
|
|
@@ -415,11 +423,14 @@ function registerCodexProcessTools(server, config, workspaces, processSessions,
|
|
|
415
423
|
});
|
|
416
424
|
logToolCall(config, {
|
|
417
425
|
tool: "exec_command",
|
|
418
|
-
|
|
426
|
+
...workspaceLogContext(workspace, extra.sessionId),
|
|
419
427
|
workingDirectory: workingDirectory ?? ".",
|
|
420
428
|
command: cmd,
|
|
421
429
|
commandLength: cmd.length,
|
|
422
|
-
|
|
430
|
+
exitCode: snapshot.exitCode,
|
|
431
|
+
running: snapshot.running,
|
|
432
|
+
processSessionId: snapshot.sessionId,
|
|
433
|
+
success: snapshot.running || snapshot.exitCode === 0,
|
|
423
434
|
durationMs: Math.round(performance.now() - startedAt),
|
|
424
435
|
});
|
|
425
436
|
return processToolResponse("exec_command", workspaceId, snapshot, {
|
|
@@ -459,7 +470,7 @@ function registerCodexProcessTools(server, config, workspaces, processSessions,
|
|
|
459
470
|
outputSchema: processOutputSchema(),
|
|
460
471
|
...toolWidgetDescriptorMeta(config, "shell"),
|
|
461
472
|
annotations: SHELL_TOOL_ANNOTATIONS,
|
|
462
|
-
}, async ({ workspaceId, sessionId, chars, columns, rows, yieldTimeMs, maxOutputTokens }) => {
|
|
473
|
+
}, async ({ workspaceId, sessionId, chars, columns, rows, yieldTimeMs, maxOutputTokens }, extra) => {
|
|
463
474
|
const workspace = workspaces.getWorkspace(workspaceId);
|
|
464
475
|
return runToolWithHooks(hooks, {
|
|
465
476
|
tool: "write_stdin",
|
|
@@ -483,8 +494,11 @@ function registerCodexProcessTools(server, config, workspaces, processSessions,
|
|
|
483
494
|
});
|
|
484
495
|
logToolCall(config, {
|
|
485
496
|
tool: "write_stdin",
|
|
486
|
-
|
|
487
|
-
|
|
497
|
+
...workspaceLogContext(workspace, extra.sessionId),
|
|
498
|
+
exitCode: snapshot.exitCode,
|
|
499
|
+
running: snapshot.running,
|
|
500
|
+
processSessionId: snapshot.sessionId,
|
|
501
|
+
success: snapshot.running || snapshot.exitCode === 0,
|
|
488
502
|
durationMs: Math.round(performance.now() - startedAt),
|
|
489
503
|
});
|
|
490
504
|
return processToolResponse("write_stdin", workspaceId, snapshot, {
|
|
@@ -597,7 +611,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
597
611
|
idempotentHint: false,
|
|
598
612
|
openWorldHint: false,
|
|
599
613
|
},
|
|
600
|
-
}, async ({ path, mode, baseRef, newWorktree }, { _meta }) => {
|
|
614
|
+
}, async ({ path, mode, baseRef, newWorktree }, { _meta, sessionId }) => {
|
|
601
615
|
const startedAt = performance.now();
|
|
602
616
|
const { workspace, agentsFiles, availableAgentsFiles, hookReports, workspaceReused, includeBootstrapContext, } = await workspaces.openWorkspace({ path, mode, baseRef, newWorktree }, { conversationScopeId: openAiConversationScopeId(_meta) });
|
|
603
617
|
const knownWorktrees = await workspaces.listKnownWorktrees(workspace);
|
|
@@ -692,7 +706,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
692
706
|
];
|
|
693
707
|
logToolCall(config, {
|
|
694
708
|
tool: "open_workspace",
|
|
695
|
-
|
|
709
|
+
...workspaceLogContext(workspace, sessionId),
|
|
696
710
|
path: workspace.root,
|
|
697
711
|
success: true,
|
|
698
712
|
durationMs: Math.round(performance.now() - startedAt),
|
|
@@ -772,7 +786,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
772
786
|
}),
|
|
773
787
|
_meta: {},
|
|
774
788
|
annotations: WRITE_TOOL_ANNOTATIONS,
|
|
775
|
-
}, async ({ workspaceId, commitMessage }) => {
|
|
789
|
+
}, async ({ workspaceId, commitMessage }, extra) => {
|
|
776
790
|
const workspace = workspaces.getWorkspace(workspaceId);
|
|
777
791
|
return runToolWithHooks(hooks, {
|
|
778
792
|
tool: toolNames.closeWorktree,
|
|
@@ -793,7 +807,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
793
807
|
].join("\n");
|
|
794
808
|
logToolCall(config, {
|
|
795
809
|
tool: toolNames.closeWorktree,
|
|
796
|
-
|
|
810
|
+
...workspaceLogContext(workspace, extra.sessionId),
|
|
797
811
|
path: closed.sourceRoot,
|
|
798
812
|
success: true,
|
|
799
813
|
durationMs: Math.round(performance.now() - startedAt),
|
|
@@ -825,8 +839,8 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
825
839
|
path: z
|
|
826
840
|
.string()
|
|
827
841
|
.describe(config.skillsEnabled
|
|
828
|
-
? "File path to read, relative to the workspace root. May also be an advertised skill path from open_workspace skills."
|
|
829
|
-
: "File path to read, relative to the workspace root."),
|
|
842
|
+
? "File path to read, relative to the workspace root or absolute inside the OS temp directory. May also be an advertised skill path from open_workspace skills."
|
|
843
|
+
: "File path to read, relative to the workspace root or absolute inside the OS temp directory."),
|
|
830
844
|
offset: z
|
|
831
845
|
.number()
|
|
832
846
|
.int()
|
|
@@ -843,7 +857,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
843
857
|
outputSchema: resultOutputSchema(),
|
|
844
858
|
...toolWidgetDescriptorMeta(config, "read"),
|
|
845
859
|
annotations: { readOnlyHint: true },
|
|
846
|
-
}, async ({ workspaceId, ...input }) => {
|
|
860
|
+
}, async ({ workspaceId, ...input }, extra) => {
|
|
847
861
|
const workspace = workspaces.getWorkspace(workspaceId);
|
|
848
862
|
return runToolWithHooks(hooks, {
|
|
849
863
|
tool: toolNames.read,
|
|
@@ -861,7 +875,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
861
875
|
if (response.isError) {
|
|
862
876
|
logFailedToolResponse(config, {
|
|
863
877
|
tool: toolNames.read,
|
|
864
|
-
|
|
878
|
+
...workspaceLogContext(workspace, extra.sessionId),
|
|
865
879
|
path: input.path,
|
|
866
880
|
}, response.content, startedAt);
|
|
867
881
|
return response;
|
|
@@ -874,7 +888,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
874
888
|
};
|
|
875
889
|
logToolCall(config, {
|
|
876
890
|
tool: toolNames.read,
|
|
877
|
-
|
|
891
|
+
...workspaceLogContext(workspace, extra.sessionId),
|
|
878
892
|
path: input.path,
|
|
879
893
|
success: true,
|
|
880
894
|
durationMs: Math.round(performance.now() - startedAt),
|
|
@@ -907,13 +921,13 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
907
921
|
.describe("Workspace identifier returned by open_workspace."),
|
|
908
922
|
path: z
|
|
909
923
|
.string()
|
|
910
|
-
.describe("File path to write, relative to the workspace root."),
|
|
924
|
+
.describe("File path to write, relative to the workspace root or absolute inside the OS temp directory."),
|
|
911
925
|
content: z.string().describe("Complete new file content."),
|
|
912
926
|
},
|
|
913
927
|
outputSchema: resultOutputSchema(),
|
|
914
928
|
...toolWidgetDescriptorMeta(config, "write"),
|
|
915
929
|
annotations: WRITE_TOOL_ANNOTATIONS,
|
|
916
|
-
}, async ({ workspaceId, ...input }) => {
|
|
930
|
+
}, async ({ workspaceId, ...input }, extra) => {
|
|
917
931
|
const workspace = workspaces.getWorkspace(workspaceId);
|
|
918
932
|
return runToolWithHooks(hooks, {
|
|
919
933
|
tool: toolNames.write,
|
|
@@ -923,15 +937,15 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
923
937
|
changedPaths: (result) => toolResultIsError(result) ? [] : [input.path],
|
|
924
938
|
operation: async () => {
|
|
925
939
|
const startedAt = performance.now();
|
|
926
|
-
workspaces.resolvePath(workspace, input.path);
|
|
927
940
|
const response = await writeFileTool(input, {
|
|
928
941
|
cwd: workspace.root,
|
|
929
942
|
root: workspace.root,
|
|
943
|
+
fileRoots: workspaces.fileToolRoots(workspace),
|
|
930
944
|
});
|
|
931
945
|
if (response.isError) {
|
|
932
946
|
logFailedToolResponse(config, {
|
|
933
947
|
tool: toolNames.write,
|
|
934
|
-
|
|
948
|
+
...workspaceLogContext(workspace, extra.sessionId),
|
|
935
949
|
path: input.path,
|
|
936
950
|
}, response.content, startedAt);
|
|
937
951
|
return response;
|
|
@@ -945,7 +959,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
945
959
|
};
|
|
946
960
|
logToolCall(config, {
|
|
947
961
|
tool: toolNames.write,
|
|
948
|
-
|
|
962
|
+
...workspaceLogContext(workspace, extra.sessionId),
|
|
949
963
|
path: input.path,
|
|
950
964
|
success: true,
|
|
951
965
|
durationMs: Math.round(performance.now() - startedAt),
|
|
@@ -980,7 +994,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
980
994
|
.describe("Workspace identifier returned by open_workspace."),
|
|
981
995
|
path: z
|
|
982
996
|
.string()
|
|
983
|
-
.describe("File path to edit, relative to the workspace root."),
|
|
997
|
+
.describe("File path to edit, relative to the workspace root or absolute inside the OS temp directory."),
|
|
984
998
|
edits: z
|
|
985
999
|
.array(z.object({
|
|
986
1000
|
oldText: z
|
|
@@ -995,7 +1009,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
995
1009
|
}),
|
|
996
1010
|
...toolWidgetDescriptorMeta(config, "edit"),
|
|
997
1011
|
annotations: EDIT_TOOL_ANNOTATIONS,
|
|
998
|
-
}, async ({ workspaceId, ...input }) => {
|
|
1012
|
+
}, async ({ workspaceId, ...input }, extra) => {
|
|
999
1013
|
const workspace = workspaces.getWorkspace(workspaceId);
|
|
1000
1014
|
return runToolWithHooks(hooks, {
|
|
1001
1015
|
tool: toolNames.edit,
|
|
@@ -1005,15 +1019,15 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
1005
1019
|
changedPaths: (result) => toolResultIsError(result) ? [] : [input.path],
|
|
1006
1020
|
operation: async () => {
|
|
1007
1021
|
const startedAt = performance.now();
|
|
1008
|
-
workspaces.resolvePath(workspace, input.path);
|
|
1009
1022
|
const response = await editFileTool(input, {
|
|
1010
1023
|
cwd: workspace.root,
|
|
1011
1024
|
root: workspace.root,
|
|
1025
|
+
fileRoots: workspaces.fileToolRoots(workspace),
|
|
1012
1026
|
});
|
|
1013
1027
|
if (response.isError) {
|
|
1014
1028
|
logFailedToolResponse(config, {
|
|
1015
1029
|
tool: toolNames.edit,
|
|
1016
|
-
|
|
1030
|
+
...workspaceLogContext(workspace, extra.sessionId),
|
|
1017
1031
|
path: input.path,
|
|
1018
1032
|
}, response.content, startedAt);
|
|
1019
1033
|
return response;
|
|
@@ -1027,7 +1041,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
1027
1041
|
const editContent = [textBlock(editResultText)];
|
|
1028
1042
|
logToolCall(config, {
|
|
1029
1043
|
tool: toolNames.edit,
|
|
1030
|
-
|
|
1044
|
+
...workspaceLogContext(workspace, extra.sessionId),
|
|
1031
1045
|
path: input.path,
|
|
1032
1046
|
success: true,
|
|
1033
1047
|
durationMs: Math.round(performance.now() - startedAt),
|
|
@@ -1078,7 +1092,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
1078
1092
|
}),
|
|
1079
1093
|
...toolWidgetDescriptorMeta(config, "edit"),
|
|
1080
1094
|
annotations: EDIT_TOOL_ANNOTATIONS,
|
|
1081
|
-
}, async ({ workspaceId, patch }) => {
|
|
1095
|
+
}, async ({ workspaceId, patch }, extra) => {
|
|
1082
1096
|
const workspace = workspaces.getWorkspace(workspaceId);
|
|
1083
1097
|
return runToolWithHooks(hooks, {
|
|
1084
1098
|
tool: "apply_patch",
|
|
@@ -1088,7 +1102,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
1088
1102
|
.filter((path) => Boolean(path)))),
|
|
1089
1103
|
operation: async () => {
|
|
1090
1104
|
const startedAt = performance.now();
|
|
1091
|
-
const applied = await applyPatch(workspace.root, patch);
|
|
1105
|
+
const applied = await applyPatch(workspace.root, patch, [tmpdir()]);
|
|
1092
1106
|
const paths = applied.files.map((file) => file.path).join(", ");
|
|
1093
1107
|
const result = `Applied patch to ${applied.files.length} file(s): ${paths}`;
|
|
1094
1108
|
const content = [textBlock(result)];
|
|
@@ -1097,7 +1111,8 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
1097
1111
|
: `${applied.files.length} files`;
|
|
1098
1112
|
logToolCall(config, {
|
|
1099
1113
|
tool: "apply_patch",
|
|
1100
|
-
|
|
1114
|
+
...workspaceLogContext(workspace, extra.sessionId),
|
|
1115
|
+
path: displayPath,
|
|
1101
1116
|
success: true,
|
|
1102
1117
|
durationMs: Math.round(performance.now() - startedAt),
|
|
1103
1118
|
});
|
|
@@ -1140,7 +1155,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
1140
1155
|
outputSchema: resultOutputSchema(),
|
|
1141
1156
|
...toolWidgetDescriptorMeta(config, "show_changes"),
|
|
1142
1157
|
annotations: { readOnlyHint: true },
|
|
1143
|
-
}, async ({ workspaceId }) => {
|
|
1158
|
+
}, async ({ workspaceId }, extra) => {
|
|
1144
1159
|
const workspace = workspaces.getWorkspace(workspaceId);
|
|
1145
1160
|
return runToolWithHooks(hooks, {
|
|
1146
1161
|
tool: "show_changes",
|
|
@@ -1155,7 +1170,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
1155
1170
|
const content = [textBlock(review.result)];
|
|
1156
1171
|
logToolCall(config, {
|
|
1157
1172
|
tool: "show_changes",
|
|
1158
|
-
|
|
1173
|
+
...workspaceLogContext(workspace, extra.sessionId),
|
|
1159
1174
|
success: true,
|
|
1160
1175
|
durationMs: Math.round(performance.now() - startedAt),
|
|
1161
1176
|
});
|
|
@@ -1183,7 +1198,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
1183
1198
|
if (config.toolMode === "full") {
|
|
1184
1199
|
registerAppTool(server, toolNames.grep, {
|
|
1185
1200
|
title: "Grep",
|
|
1186
|
-
description: "Search file contents inside an open workspace. Use this before broad reads when looking for symbols, text, or usage sites. Respects project ignore rules. Call open_workspace first and pass workspaceId.",
|
|
1201
|
+
description: "Search file contents inside an open workspace or the OS temp directory. Use this before broad reads when looking for symbols, text, or usage sites. Respects project ignore rules. Call open_workspace first and pass workspaceId.",
|
|
1187
1202
|
inputSchema: {
|
|
1188
1203
|
workspaceId: z
|
|
1189
1204
|
.string()
|
|
@@ -1192,13 +1207,13 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
1192
1207
|
path: z
|
|
1193
1208
|
.string()
|
|
1194
1209
|
.optional()
|
|
1195
|
-
.describe("Optional path or glob scope relative to the workspace root."),
|
|
1210
|
+
.describe("Optional path or glob scope relative to the workspace root, or an absolute path inside the OS temp directory."),
|
|
1196
1211
|
include: z.string().optional().describe("Optional include glob."),
|
|
1197
1212
|
},
|
|
1198
1213
|
outputSchema: resultOutputSchema(),
|
|
1199
1214
|
...toolWidgetDescriptorMeta(config, "search"),
|
|
1200
1215
|
annotations: { readOnlyHint: true },
|
|
1201
|
-
}, async ({ workspaceId, ...input }) => {
|
|
1216
|
+
}, async ({ workspaceId, ...input }, extra) => {
|
|
1202
1217
|
const workspace = workspaces.getWorkspace(workspaceId);
|
|
1203
1218
|
return runToolWithHooks(hooks, {
|
|
1204
1219
|
tool: toolNames.grep,
|
|
@@ -1207,16 +1222,15 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
1207
1222
|
isFailure: toolResultIsError,
|
|
1208
1223
|
operation: async () => {
|
|
1209
1224
|
const startedAt = performance.now();
|
|
1210
|
-
if (input.path)
|
|
1211
|
-
workspaces.resolvePath(workspace, input.path);
|
|
1212
1225
|
const response = await grepFilesTool(input, {
|
|
1213
1226
|
cwd: workspace.root,
|
|
1214
1227
|
root: workspace.root,
|
|
1228
|
+
fileRoots: workspaces.fileToolRoots(workspace),
|
|
1215
1229
|
});
|
|
1216
1230
|
if (response.isError) {
|
|
1217
1231
|
logFailedToolResponse(config, {
|
|
1218
1232
|
tool: toolNames.grep,
|
|
1219
|
-
|
|
1233
|
+
...workspaceLogContext(workspace, extra.sessionId),
|
|
1220
1234
|
path: input.path,
|
|
1221
1235
|
}, response.content, startedAt);
|
|
1222
1236
|
return response;
|
|
@@ -1228,7 +1242,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
1228
1242
|
};
|
|
1229
1243
|
logToolCall(config, {
|
|
1230
1244
|
tool: toolNames.grep,
|
|
1231
|
-
|
|
1245
|
+
...workspaceLogContext(workspace, extra.sessionId),
|
|
1232
1246
|
path: input.path,
|
|
1233
1247
|
success: true,
|
|
1234
1248
|
durationMs: Math.round(performance.now() - startedAt),
|
|
@@ -1253,7 +1267,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
1253
1267
|
});
|
|
1254
1268
|
registerAppTool(server, toolNames.glob, {
|
|
1255
1269
|
title: "Glob",
|
|
1256
|
-
description: "Find files by glob pattern inside an open workspace. Use this to discover filenames or narrow file sets before reading. Respects project ignore rules. Call open_workspace first and pass workspaceId.",
|
|
1270
|
+
description: "Find files by glob pattern inside an open workspace or the OS temp directory. Use this to discover filenames or narrow file sets before reading. Respects project ignore rules. Call open_workspace first and pass workspaceId.",
|
|
1257
1271
|
inputSchema: {
|
|
1258
1272
|
workspaceId: z
|
|
1259
1273
|
.string()
|
|
@@ -1262,12 +1276,12 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
1262
1276
|
path: z
|
|
1263
1277
|
.string()
|
|
1264
1278
|
.optional()
|
|
1265
|
-
.describe("Optional path scope relative to the workspace root."),
|
|
1279
|
+
.describe("Optional path scope relative to the workspace root, or an absolute path inside the OS temp directory."),
|
|
1266
1280
|
},
|
|
1267
1281
|
outputSchema: resultOutputSchema(),
|
|
1268
1282
|
...toolWidgetDescriptorMeta(config, "search"),
|
|
1269
1283
|
annotations: { readOnlyHint: true },
|
|
1270
|
-
}, async ({ workspaceId, ...input }) => {
|
|
1284
|
+
}, async ({ workspaceId, ...input }, extra) => {
|
|
1271
1285
|
const workspace = workspaces.getWorkspace(workspaceId);
|
|
1272
1286
|
return runToolWithHooks(hooks, {
|
|
1273
1287
|
tool: toolNames.glob,
|
|
@@ -1276,16 +1290,15 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
1276
1290
|
isFailure: toolResultIsError,
|
|
1277
1291
|
operation: async () => {
|
|
1278
1292
|
const startedAt = performance.now();
|
|
1279
|
-
if (input.path)
|
|
1280
|
-
workspaces.resolvePath(workspace, input.path);
|
|
1281
1293
|
const response = await findFilesTool(input, {
|
|
1282
1294
|
cwd: workspace.root,
|
|
1283
1295
|
root: workspace.root,
|
|
1296
|
+
fileRoots: workspaces.fileToolRoots(workspace),
|
|
1284
1297
|
});
|
|
1285
1298
|
if (response.isError) {
|
|
1286
1299
|
logFailedToolResponse(config, {
|
|
1287
1300
|
tool: toolNames.glob,
|
|
1288
|
-
|
|
1301
|
+
...workspaceLogContext(workspace, extra.sessionId),
|
|
1289
1302
|
path: input.path,
|
|
1290
1303
|
}, response.content, startedAt);
|
|
1291
1304
|
return response;
|
|
@@ -1297,7 +1310,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
1297
1310
|
};
|
|
1298
1311
|
logToolCall(config, {
|
|
1299
1312
|
tool: toolNames.glob,
|
|
1300
|
-
|
|
1313
|
+
...workspaceLogContext(workspace, extra.sessionId),
|
|
1301
1314
|
path: input.path,
|
|
1302
1315
|
success: true,
|
|
1303
1316
|
durationMs: Math.round(performance.now() - startedAt),
|
|
@@ -1322,19 +1335,19 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
1322
1335
|
});
|
|
1323
1336
|
registerAppTool(server, toolNames.ls, {
|
|
1324
1337
|
title: "Ls",
|
|
1325
|
-
description: "List a directory inside an open workspace. Use this for directory inspection before reading files. Call open_workspace first and pass workspaceId.",
|
|
1338
|
+
description: "List a directory inside an open workspace or the OS temp directory. Use this for directory inspection before reading files. Call open_workspace first and pass workspaceId.",
|
|
1326
1339
|
inputSchema: {
|
|
1327
1340
|
workspaceId: z
|
|
1328
1341
|
.string()
|
|
1329
1342
|
.describe("Workspace identifier returned by open_workspace."),
|
|
1330
1343
|
path: z
|
|
1331
1344
|
.string()
|
|
1332
|
-
.describe("Directory path to list, relative to the workspace root."),
|
|
1345
|
+
.describe("Directory path to list, relative to the workspace root or absolute inside the OS temp directory."),
|
|
1333
1346
|
},
|
|
1334
1347
|
outputSchema: resultOutputSchema(),
|
|
1335
1348
|
...toolWidgetDescriptorMeta(config, "directory"),
|
|
1336
1349
|
annotations: { readOnlyHint: true },
|
|
1337
|
-
}, async ({ workspaceId, ...input }) => {
|
|
1350
|
+
}, async ({ workspaceId, ...input }, extra) => {
|
|
1338
1351
|
const workspace = workspaces.getWorkspace(workspaceId);
|
|
1339
1352
|
return runToolWithHooks(hooks, {
|
|
1340
1353
|
tool: toolNames.ls,
|
|
@@ -1343,15 +1356,15 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
1343
1356
|
isFailure: toolResultIsError,
|
|
1344
1357
|
operation: async () => {
|
|
1345
1358
|
const startedAt = performance.now();
|
|
1346
|
-
workspaces.resolvePath(workspace, input.path);
|
|
1347
1359
|
const response = await listDirectoryTool(input, {
|
|
1348
1360
|
cwd: workspace.root,
|
|
1349
1361
|
root: workspace.root,
|
|
1362
|
+
fileRoots: workspaces.fileToolRoots(workspace),
|
|
1350
1363
|
});
|
|
1351
1364
|
if (response.isError) {
|
|
1352
1365
|
logFailedToolResponse(config, {
|
|
1353
1366
|
tool: toolNames.ls,
|
|
1354
|
-
|
|
1367
|
+
...workspaceLogContext(workspace, extra.sessionId),
|
|
1355
1368
|
path: input.path,
|
|
1356
1369
|
}, response.content, startedAt);
|
|
1357
1370
|
return response;
|
|
@@ -1359,7 +1372,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
1359
1372
|
const summary = textSummary(response.content);
|
|
1360
1373
|
logToolCall(config, {
|
|
1361
1374
|
tool: toolNames.ls,
|
|
1362
|
-
|
|
1375
|
+
...workspaceLogContext(workspace, extra.sessionId),
|
|
1363
1376
|
path: input.path,
|
|
1364
1377
|
success: true,
|
|
1365
1378
|
durationMs: Math.round(performance.now() - startedAt),
|
|
@@ -1408,7 +1421,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
1408
1421
|
outputSchema: resultOutputSchema(),
|
|
1409
1422
|
...toolWidgetDescriptorMeta(config, "shell"),
|
|
1410
1423
|
annotations: SHELL_TOOL_ANNOTATIONS,
|
|
1411
|
-
}, async ({ workspaceId, workingDirectory, ...input }) => {
|
|
1424
|
+
}, async ({ workspaceId, workingDirectory, ...input }, extra) => {
|
|
1412
1425
|
const workspace = workspaces.getWorkspace(workspaceId);
|
|
1413
1426
|
return runToolWithHooks(hooks, {
|
|
1414
1427
|
tool: toolNames.shell,
|
|
@@ -1429,7 +1442,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
1429
1442
|
if (response.isError) {
|
|
1430
1443
|
logFailedToolResponse(config, {
|
|
1431
1444
|
tool: toolNames.shell,
|
|
1432
|
-
|
|
1445
|
+
...workspaceLogContext(workspace, extra.sessionId),
|
|
1433
1446
|
workingDirectory: workingDirectory ?? ".",
|
|
1434
1447
|
command: input.command,
|
|
1435
1448
|
commandLength: input.command.length,
|
|
@@ -1443,7 +1456,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
1443
1456
|
};
|
|
1444
1457
|
logToolCall(config, {
|
|
1445
1458
|
tool: toolNames.shell,
|
|
1446
|
-
|
|
1459
|
+
...workspaceLogContext(workspace, extra.sessionId),
|
|
1447
1460
|
workingDirectory: workingDirectory ?? ".",
|
|
1448
1461
|
command: input.command,
|
|
1449
1462
|
commandLength: input.command.length,
|
package/dist/workspaces.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
2
|
import { mkdir, opendir, readFile, realpath, stat } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
3
4
|
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
4
5
|
import { HookRunner } from "./hooks.js";
|
|
5
6
|
import { closeManagedWorktree, createManagedWorktree, resolveManagedWorktreeBase, } from "./git-worktrees.js";
|
|
@@ -305,6 +306,9 @@ export class WorkspaceRegistry {
|
|
|
305
306
|
this.workspaces.set(restoredWorkspace.id, restoredWorkspace);
|
|
306
307
|
return restoredWorkspace;
|
|
307
308
|
}
|
|
309
|
+
fileToolRoots(workspace) {
|
|
310
|
+
return [workspace.root, tmpdir()];
|
|
311
|
+
}
|
|
308
312
|
resolvePath(workspace, inputPath) {
|
|
309
313
|
const absolutePath = resolveAllowedPath(inputPath, workspace.root, [workspace.root]);
|
|
310
314
|
if (!isPathInsideRoot(absolutePath, workspace.root)) {
|
|
@@ -321,13 +325,22 @@ export class WorkspaceRegistry {
|
|
|
321
325
|
}
|
|
322
326
|
catch (workspaceError) {
|
|
323
327
|
const skillRead = resolveSkillReadPath(workspace.skills, workspace.activatedSkillDirs, inputPath);
|
|
324
|
-
if (
|
|
328
|
+
if (skillRead) {
|
|
329
|
+
return {
|
|
330
|
+
absolutePath: skillRead.absolutePath,
|
|
331
|
+
readRoots: [workspace.root, skillRead.skill.baseDir],
|
|
332
|
+
skillRead,
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
try {
|
|
336
|
+
return {
|
|
337
|
+
absolutePath: resolveAllowedPath(inputPath, workspace.root, [tmpdir()]),
|
|
338
|
+
readRoots: this.fileToolRoots(workspace),
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
catch {
|
|
325
342
|
throw workspaceError;
|
|
326
|
-
|
|
327
|
-
absolutePath: skillRead.absolutePath,
|
|
328
|
-
readRoots: [workspace.root, skillRead.skill.baseDir],
|
|
329
|
-
skillRead,
|
|
330
|
-
};
|
|
343
|
+
}
|
|
331
344
|
}
|
|
332
345
|
}
|
|
333
346
|
markReadPathLoaded(workspace, readPath) {
|
package/docs/configuration.md
CHANGED
|
@@ -310,15 +310,23 @@ forgerelay agents show <id>
|
|
|
310
310
|
| Variable | Default |
|
|
311
311
|
| --- | --- |
|
|
312
312
|
| `FORGERELAY_LOG_LEVEL` | `info` |
|
|
313
|
-
| `FORGERELAY_LOG_FORMAT` | `
|
|
314
|
-
| `FORGERELAY_LOG_REQUESTS` | `1` |
|
|
313
|
+
| `FORGERELAY_LOG_FORMAT` | `pretty` |
|
|
314
|
+
| `FORGERELAY_LOG_REQUESTS` | `0` in `pretty`, `1` in `json` |
|
|
315
315
|
| `FORGERELAY_LOG_ASSETS` | `0` |
|
|
316
316
|
| `FORGERELAY_LOG_TOOL_CALLS` | `1` |
|
|
317
|
-
| `FORGERELAY_LOG_SHELL_COMMANDS` | `0` |
|
|
317
|
+
| `FORGERELAY_LOG_SHELL_COMMANDS` | `1` in `pretty`, `0` in `json` |
|
|
318
318
|
| `FORGERELAY_TRUST_PROXY` | `0` |
|
|
319
319
|
|
|
320
|
-
|
|
321
|
-
|
|
320
|
+
`pretty` is the human-facing local console format. It uses terminal-aware color,
|
|
321
|
+
short timestamps, workspace/session context, and compact operation results while
|
|
322
|
+
keeping HTTP request records off by default. Shell command previews are enabled
|
|
323
|
+
in this mode and truncated to 120 characters; set
|
|
324
|
+
`FORGERELAY_LOG_SHELL_COMMANDS=0` when command arguments may contain secrets.
|
|
325
|
+
|
|
326
|
+
Set `FORGERELAY_LOG_FORMAT=json` for machine collection. Unless explicitly
|
|
327
|
+
overridden, JSON mode preserves request logging and omits shell command previews.
|
|
328
|
+
`FORGERELAY_LOG_REQUESTS` and `FORGERELAY_LOG_SHELL_COMMANDS` always override
|
|
329
|
+
these format-specific defaults when set.
|
|
322
330
|
|
|
323
331
|
## Environment-only example
|
|
324
332
|
|
package/docs/debugging.md
CHANGED
|
@@ -61,10 +61,11 @@ The acceptance checks:
|
|
|
61
61
|
5. MCP `initialize`, including package/server version consistency;
|
|
62
62
|
6. `tools/list` for the full debug tool surface;
|
|
63
63
|
7. a real checkout workspace with `write`, `read`, `bash`, and a deliberate failed `edit`;
|
|
64
|
-
8.
|
|
65
|
-
9.
|
|
66
|
-
10.
|
|
67
|
-
11.
|
|
64
|
+
8. OS temp-directory `write` → `read` → `edit` over the same real MCP session, plus rejection of an arbitrary path outside the workspace/temp roots;
|
|
65
|
+
9. a temporary Git repository with managed worktree creation, file modification, and `close_worktree`;
|
|
66
|
+
10. 本地 bare remote 上的 release-tag-push Hook:成功 Hook 必须先运行再允许 `v0.2.0` push,失败 Hook 必须在 remote mutation 前阻断 `v0.2.1`;
|
|
67
|
+
11. deterministic local subagent error path,不联系任何模型 provider;
|
|
68
|
+
12. debug hook recorder 覆盖全部九个 Hooks v1 lifecycle events。
|
|
68
69
|
|
|
69
70
|
`curl` must be available on `PATH` for this acceptance command. Node and Git are
|
|
70
71
|
already normal ForgeRelay development prerequisites.
|
package/docs/security.md
CHANGED
|
@@ -17,8 +17,16 @@ Example:
|
|
|
17
17
|
Do not use your entire home directory unless that is intentionally the access
|
|
18
18
|
boundary you want.
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
Opening a workspace still requires the path to be inside the configured allowed
|
|
21
|
+
roots. File and search tools additionally accept paths inside the operating
|
|
22
|
+
system temporary directory (for example `/tmp` on Linux) without treating that
|
|
23
|
+
directory as an allowed workspace root. Workspace paths remain confined to the
|
|
24
|
+
opened workspace, and shell working directories are not expanded by this temp
|
|
25
|
+
access.
|
|
26
|
+
|
|
27
|
+
Filesystem-oriented tools canonicalize existing path segments before access so
|
|
28
|
+
symlinks inside either the workspace or OS temp directory cannot escape to
|
|
29
|
+
arbitrary filesystem locations.
|
|
22
30
|
|
|
23
31
|
## Owner-password OAuth
|
|
24
32
|
|
|
@@ -137,15 +145,18 @@ as owner-only files. See [Native File Download](artifact-exchange.md).
|
|
|
137
145
|
|
|
138
146
|
## Logging
|
|
139
147
|
|
|
140
|
-
ForgeRelay
|
|
141
|
-
|
|
148
|
+
ForgeRelay's default human-facing `pretty` logs focus on tool and Hook
|
|
149
|
+
operations; HTTP request records are off by default. Pretty mode includes a
|
|
150
|
+
truncated shell command preview so local operators can see what the Agent ran.
|
|
151
|
+
Command arguments can contain secrets, so disable previews when necessary:
|
|
142
152
|
|
|
143
153
|
```bash
|
|
144
|
-
FORGERELAY_LOG_SHELL_COMMANDS=
|
|
154
|
+
FORGERELAY_LOG_SHELL_COMMANDS=0
|
|
145
155
|
```
|
|
146
156
|
|
|
147
|
-
|
|
148
|
-
|
|
157
|
+
Explicit `json` mode is intended for machine collection. Its default preserves
|
|
158
|
+
HTTP request records and omits shell command previews unless those settings are
|
|
159
|
+
overridden explicitly.
|
|
149
160
|
|
|
150
161
|
## Package provenance
|
|
151
162
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akira-tl/forgerelay",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
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/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/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/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/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",
|
package/scripts/debug/accept.mjs
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
rmSync,
|
|
11
11
|
writeFileSync,
|
|
12
12
|
} from "node:fs";
|
|
13
|
+
import { homedir, tmpdir } from "node:os";
|
|
13
14
|
import { join, resolve } from "node:path";
|
|
14
15
|
import { setTimeout as delay } from "node:timers/promises";
|
|
15
16
|
import {
|
|
@@ -30,6 +31,7 @@ const gitProject = resolve(acceptanceRoot, "git-project");
|
|
|
30
31
|
const releaseProject = resolve(acceptanceRoot, "release-project");
|
|
31
32
|
const releaseRemote = resolve(acceptanceRoot, "release-remote.git");
|
|
32
33
|
const ownerToken = randomBytes(32).toString("base64url");
|
|
34
|
+
const tempAcceptanceRoot = resolve(tmpdir(), `forgerelay-debug-acceptance-${randomUUID()}`);
|
|
33
35
|
|
|
34
36
|
assertCurlAvailable();
|
|
35
37
|
await assertDebugPortFree();
|
|
@@ -144,6 +146,37 @@ try {
|
|
|
144
146
|
assert.equal(failedEdit.isError, true);
|
|
145
147
|
pass("failed tool path", "edit returned isError=true and triggered AfterToolFailure");
|
|
146
148
|
|
|
149
|
+
mkdirSync(tempAcceptanceRoot, { recursive: true });
|
|
150
|
+
const tempFile = join(tempAcceptanceRoot, "mcp-temp.txt");
|
|
151
|
+
const tempWritten = callTool(oauth.accessToken, sessionId, 70, "write", {
|
|
152
|
+
workspaceId,
|
|
153
|
+
path: tempFile,
|
|
154
|
+
content: "forgerelay temp before edit\n",
|
|
155
|
+
});
|
|
156
|
+
assert.equal(tempWritten.isError, undefined);
|
|
157
|
+
|
|
158
|
+
const tempRead = callTool(oauth.accessToken, sessionId, 71, "read", {
|
|
159
|
+
workspaceId,
|
|
160
|
+
path: tempFile,
|
|
161
|
+
});
|
|
162
|
+
assert.match(tempRead.structuredContent.result, /forgerelay temp before edit/);
|
|
163
|
+
|
|
164
|
+
const tempEdited = callTool(oauth.accessToken, sessionId, 72, "edit", {
|
|
165
|
+
workspaceId,
|
|
166
|
+
path: tempFile,
|
|
167
|
+
edits: [{ oldText: "before edit", newText: "after edit" }],
|
|
168
|
+
});
|
|
169
|
+
assert.equal(tempEdited.isError, undefined);
|
|
170
|
+
assert.equal(readFileSync(tempFile, "utf8"), "forgerelay temp after edit\n");
|
|
171
|
+
|
|
172
|
+
const outsideRoots = callTool(oauth.accessToken, sessionId, 73, "read", {
|
|
173
|
+
workspaceId,
|
|
174
|
+
path: join(homedir(), "forgerelay-debug-outside-roots.txt"),
|
|
175
|
+
});
|
|
176
|
+
assert.equal(outsideRoots.isError, true);
|
|
177
|
+
assert.match(toolText(outsideRoots), /outside allowed roots/i);
|
|
178
|
+
pass("OS temp file tools", "write + read + edit passed; arbitrary home path rejected");
|
|
179
|
+
|
|
147
180
|
setupGitProject(gitProject);
|
|
148
181
|
const worktreeOpened = callTool(oauth.accessToken, sessionId, 8, "open_workspace", {
|
|
149
182
|
path: gitProject,
|
|
@@ -211,6 +244,7 @@ try {
|
|
|
211
244
|
console.error("\nForgeRelay 7677 acceptance failed.");
|
|
212
245
|
throw error;
|
|
213
246
|
} finally {
|
|
247
|
+
rmSync(tempAcceptanceRoot, { recursive: true, force: true });
|
|
214
248
|
await stopServer(server);
|
|
215
249
|
}
|
|
216
250
|
|