@akira-tl/forgerelay 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/apply-patch.js +21 -6
  3. package/dist/artifact-tools.js +9 -4
  4. package/dist/config.js +8 -6
  5. package/dist/file-mutations.js +50 -0
  6. package/dist/hooks.js +4 -1
  7. package/dist/logger.js +157 -15
  8. package/dist/mcp/server-instructions.js +13 -9
  9. package/dist/pi-tools.js +14 -13
  10. package/dist/roots.js +30 -1
  11. package/dist/server.js +210 -54
  12. package/dist/ui/.vite/manifest.json +280 -280
  13. package/dist/ui/assets/{angular-html-CovppXt5.js → angular-html-CQmCUfkv.js} +1 -1
  14. package/dist/ui/assets/{angular-ts-0Y9l6YXu.js → angular-ts-BSbfxpMS.js} +1 -1
  15. package/dist/ui/assets/{apl-BmBhiklA.js → apl-CWCaH-E3.js} +1 -1
  16. package/dist/ui/assets/{astro-BAmqcqyV.js → astro-R_p4G0xA.js} +1 -1
  17. package/dist/ui/assets/{blade-BjFcmRJo.js → blade-irHensDY.js} +1 -1
  18. package/dist/ui/assets/{c-CaOJn4x0.js → c-_XEQUoq6.js} +1 -1
  19. package/dist/ui/assets/{cobol-DR4zWFMt.js → cobol-Css6VQjJ.js} +1 -1
  20. package/dist/ui/assets/{coffee-ReGSThmM.js → coffee-CpXUkyDm.js} +1 -1
  21. package/dist/ui/assets/{cpp-CnlQh6MO.js → cpp-BQHsPWwI.js} +1 -1
  22. package/dist/ui/assets/{crystal-BrpPbH1U.js → crystal-Cbv8J9Fm.js} +1 -1
  23. package/dist/ui/assets/{css-DRBEvyPU.js → css-D8DkrS3U.js} +1 -1
  24. package/dist/ui/assets/{edge-cfwpdvl9.js → edge-bBL_XDAG.js} +1 -1
  25. package/dist/ui/assets/{elixir-7owrJxb6.js → elixir-CQjsM9sA.js} +1 -1
  26. package/dist/ui/assets/{elm-udiOjODk.js → elm-DLFl-jEZ.js} +1 -1
  27. package/dist/ui/assets/{erb-8ctXHMkS.js → erb-BJjsdB_Q.js} +1 -1
  28. package/dist/ui/assets/{git-rebase-P0V_qK1w.js → git-rebase-CqP5A2s0.js} +1 -1
  29. package/dist/ui/assets/{glimmer-js-CZ14ED7U.js → glimmer-js-B5Hh_7xe.js} +1 -1
  30. package/dist/ui/assets/{glimmer-ts-DtBSFP-o.js → glimmer-ts-oaeSAC8s.js} +1 -1
  31. package/dist/ui/assets/{glsl-BX2BkZJK.js → glsl-BylkOpcF.js} +1 -1
  32. package/dist/ui/assets/{graphql-Dj_cPv4w.js → graphql-DsVRvP6S.js} +1 -1
  33. package/dist/ui/assets/{hack-DGRkd8xn.js → hack-D4N5abT_.js} +1 -1
  34. package/dist/ui/assets/{haml-BAPSvujW.js → haml-CIAJQIox.js} +1 -1
  35. package/dist/ui/assets/{handlebars-PYksdULG.js → handlebars-DDEZyNXR.js} +1 -1
  36. package/dist/ui/assets/{heavy-payload-BjE0LSUi.js → heavy-payload-DafM8yjB.js} +1 -1
  37. package/dist/ui/assets/{html-derivative-C6q86ZrS.js → html-derivative-CZ91Y2d5.js} +1 -1
  38. package/dist/ui/assets/{html-DloAPGZ1.js → html-zDy-3lvy.js} +1 -1
  39. package/dist/ui/assets/{http-fPvYDvHA.js → http-ezaodZRf.js} +1 -1
  40. package/dist/ui/assets/{hurl-Dbir4WvE.js → hurl-BmL0o7TO.js} +1 -1
  41. package/dist/ui/assets/{java-DvkqXg3z.js → java-Q2C0vwbH.js} +1 -1
  42. package/dist/ui/assets/{javascript-TONTV6Ty.js → javascript-UDhBkHE3.js} +1 -1
  43. package/dist/ui/assets/{jinja-OM1F5Gct.js → jinja-BWRskcYS.js} +1 -1
  44. package/dist/ui/assets/{jison-CBttugts.js → jison-B18BY9Ds.js} +1 -1
  45. package/dist/ui/assets/{json-Db5oY-7J.js → json-DNj7MpRr.js} +1 -1
  46. package/dist/ui/assets/{jsx-DKeJCgsc.js → jsx-CVpHJ6cr.js} +1 -1
  47. package/dist/ui/assets/{julia-NlIPhXaL.js → julia-CJBNBS9c.js} +1 -1
  48. package/dist/ui/assets/{just-BXBJB-mX.js → just-Bmu2JCQT.js} +1 -1
  49. package/dist/ui/assets/{latex-CGK-6yDy.js → latex-BB7F93KY.js} +1 -1
  50. package/dist/ui/assets/{liquid-D7waQ0MY.js → liquid-Btnomc6E.js} +1 -1
  51. package/dist/ui/assets/{lua-C_kkt4h1.js → lua-D0FYT9Vo.js} +1 -1
  52. package/dist/ui/assets/{marko-BokXuCts.js → marko-MHLw0ZlK.js} +1 -1
  53. package/dist/ui/assets/{mdc-Bk0nnUJA.js → mdc-CSr-suGy.js} +1 -1
  54. package/dist/ui/assets/{nginx-jM6NF9TV.js → nginx-ur1l6E-g.js} +1 -1
  55. package/dist/ui/assets/{nim-hYsL-WKb.js → nim-B2k0IugA.js} +1 -1
  56. package/dist/ui/assets/{perl-DBI_cUHJ.js → perl-BqOHxFDn.js} +1 -1
  57. package/dist/ui/assets/{php-BX_aQhFl.js → php-DzPK6O1_.js} +1 -1
  58. package/dist/ui/assets/{pug-DwFAYy1e.js → pug-D169OZkr.js} +1 -1
  59. package/dist/ui/assets/{qml-eLSwQUKt.js → qml-BtemJTC4.js} +1 -1
  60. package/dist/ui/assets/{r-BMnQsMLf.js → r-BGVHu0bd.js} +1 -1
  61. package/dist/ui/assets/{razor-CgciiMyw.js → razor-Diisjg1d.js} +1 -1
  62. package/dist/ui/assets/{regexp-ZrURNrn5.js → regexp-B2F5vMZ6.js} +1 -1
  63. package/dist/ui/assets/{review-payload-HGkpJjjm.js → review-payload-B-3I3Oj_.js} +1 -1
  64. package/dist/ui/assets/{rst-EoAOr3M6.js → rst-BxpeZwuT.js} +1 -1
  65. package/dist/ui/assets/{ruby-Dqx5_Rqw.js → ruby-DrCq4riN.js} +1 -1
  66. package/dist/ui/assets/{sas-Bf6xPLSX.js → sas-CI_2W-1n.js} +1 -1
  67. package/dist/ui/assets/{scrollbar-DX4Ujotb.js → scrollbar-DHsDF5w-.js} +3 -3
  68. package/dist/ui/assets/{scss-DDpW3yTd.js → scss-BxtyHZ1p.js} +1 -1
  69. package/dist/ui/assets/{shellscript-DayAQ-W9.js → shellscript-BY_YYrHT.js} +1 -1
  70. package/dist/ui/assets/{shellsession-jPr_lSnA.js → shellsession-C6uBrLmA.js} +1 -1
  71. package/dist/ui/assets/{soy-X9adks8G.js → soy-BHM9gdPh.js} +1 -1
  72. package/dist/ui/assets/{sql-Bmt09AIR.js → sql-XmhfEGz_.js} +1 -1
  73. package/dist/ui/assets/{stata-DVkNpH8r.js → stata-BbHY7NEo.js} +1 -1
  74. package/dist/ui/assets/{surrealql-eEXzMb8j.js → surrealql-CD49DZ3E.js} +1 -1
  75. package/dist/ui/assets/{svelte-CFoBxZF3.js → svelte-CbYsEYJY.js} +1 -1
  76. package/dist/ui/assets/{templ-Da_324JF.js → templ-B5JoUxEB.js} +1 -1
  77. package/dist/ui/assets/{tex-HAzlmKJ0.js → tex-DZcOfTZK.js} +1 -1
  78. package/dist/ui/assets/{ts-tags-7iLrNH8p.js → ts-tags-BeU-xqc0.js} +1 -1
  79. package/dist/ui/assets/{tsx-gZaXf8PJ.js → tsx-V7W1xGl3.js} +1 -1
  80. package/dist/ui/assets/{twig-CLpQ7p1N.js → twig-MIaJo5BU.js} +1 -1
  81. package/dist/ui/assets/{typescript-B2WWCRI2.js → typescript-C0WESuVc.js} +1 -1
  82. package/dist/ui/assets/{vue-No_8Jygd.js → vue-eJHwc9ra.js} +1 -1
  83. package/dist/ui/assets/{vue-html-kHJg98Kk.js → vue-html-CLmBujTU.js} +1 -1
  84. package/dist/ui/assets/{vue-vine-Df4cEYaN.js → vue-vine-BP5kjIGb.js} +1 -1
  85. package/dist/ui/assets/{workspace-app-C_wsC6VL.js → workspace-app-C1qR5cAY.js} +3 -3
  86. package/dist/ui/assets/{xml-DUZ05dpG.js → xml-j7pmyP48.js} +1 -1
  87. package/dist/ui/assets/{xsl-Bwp3w2OF.js → xsl-5VFHbg6o.js} +1 -1
  88. package/dist/ui/assets/{yaml-IA_-TEM1.js → yaml-DxFAt-KU.js} +1 -1
  89. package/dist/ui/workspace-app.html +1 -1
  90. package/dist/workspaces.js +19 -6
  91. package/docs/chatgpt-coding-workflow.md +4 -1
  92. package/docs/configuration.md +16 -8
  93. package/docs/debugging.md +6 -5
  94. package/docs/security.md +18 -7
  95. package/package.json +2 -2
  96. package/scripts/debug/accept.mjs +65 -1
package/CHANGELOG.md CHANGED
@@ -4,6 +4,30 @@ All notable ForgeRelay changes are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.2.2] - 2026-08-09
8
+
9
+ ### Added
10
+
11
+ - First-class `rename` and `delete` MCP tools for files and directories in workspace or OS-temp file roots, including direct availability in minimal, full, and Codex tool modes.
12
+
13
+ ### Security
14
+
15
+ - `rename` validates both source and destination canonical roots and refuses existing destinations; `delete` refuses allowed roots themselves and requires explicit `recursive: true` for non-empty directory trees.
16
+
17
+ ## [0.2.1] - 2026-08-09
18
+
19
+ ### Changed
20
+
21
+ - 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.
22
+
23
+ ### Fixed
24
+
25
+ - 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.
26
+
27
+ ### Security
28
+
29
+ - 文件工具在 workspace 与 OS temp 边界内都会校验 canonical path,阻止通过 symlink 跳转到任意文件系统位置;shell cwd 与 workspace-open allowed roots 不随 temp 文件访问而扩大。
30
+
7
31
  ## [0.2.0] - 2026-08-09
8
32
 
9
33
  ### Added
@@ -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") || isAbsolute(input)) {
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);
@@ -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, async () => {
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 === "json")
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: parseLogFormat(productEnv(env, "LOG_FORMAT")),
118
- requests: requests === undefined ? true : parseBoolean(requests),
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: parseBoolean(productEnv(env, "LOG_SHELL_COMMANDS")),
123
+ shellCommands: shellCommands === undefined ? format === "pretty" : parseBoolean(shellCommands),
122
124
  trustProxy: parseBoolean(productEnv(env, "TRUST_PROXY")),
123
125
  };
124
126
  }
@@ -0,0 +1,50 @@
1
+ import { lstat, realpath, rename, rm, rmdir, unlink } from "node:fs/promises";
2
+ import { resolve } from "node:path";
3
+ import { expandHomePath, resolveCanonicalAllowedPath } from "./roots.js";
4
+ export async function renamePath(input, context) {
5
+ const source = await resolveCanonicalAllowedPath(input.path, context.cwd, context.allowedRoots);
6
+ await assertNotAllowedRootItself(source, context.allowedRoots, input.path);
7
+ const destination = await resolveCanonicalAllowedPath(input.newPath, context.cwd, context.allowedRoots);
8
+ await assertDestinationMissing(destination, input.newPath);
9
+ await lstat(source);
10
+ await rename(source, destination);
11
+ return { path: input.path, newPath: input.newPath };
12
+ }
13
+ export async function deletePath(input, context) {
14
+ const path = await resolveCanonicalAllowedPath(input.path, context.cwd, context.allowedRoots);
15
+ await assertNotAllowedRootItself(path, context.allowedRoots, input.path);
16
+ const entry = await lstat(path);
17
+ const recursive = input.recursive ?? false;
18
+ if (entry.isDirectory()) {
19
+ if (recursive) {
20
+ await rm(path, { recursive: true, force: false });
21
+ }
22
+ else {
23
+ await rmdir(path);
24
+ }
25
+ }
26
+ else {
27
+ await unlink(path);
28
+ }
29
+ return { path: input.path, recursive };
30
+ }
31
+ async function assertDestinationMissing(path, inputPath) {
32
+ try {
33
+ await lstat(path);
34
+ }
35
+ catch (error) {
36
+ if (error.code === "ENOENT")
37
+ return;
38
+ throw error;
39
+ }
40
+ throw new Error(`Rename destination already exists: ${inputPath}`);
41
+ }
42
+ async function assertNotAllowedRootItself(path, allowedRoots, inputPath) {
43
+ const canonicalPath = await realpath(path);
44
+ for (const root of allowedRoots) {
45
+ const canonicalRoot = await realpath(resolve(expandHomePath(root)));
46
+ if (canonicalPath === canonicalRoot) {
47
+ throw new Error(`Cannot rename or delete an allowed root itself: ${inputPath}`);
48
+ }
49
+ }
50
+ }
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 line = config.format === "pretty" ? formatPretty(entry) : JSON.stringify(entry);
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 formatPretty(entry) {
56
- const ts = String(entry.ts);
57
- const level = String(entry.level).toUpperCase();
58
- const event = String(entry.event);
59
- const rest = Object.entries(entry)
60
- .filter(([key, value]) => !["ts", "level", "event"].includes(key) && value !== undefined)
61
- .map(([key, value]) => `${key}=${formatPrettyValue(value)}`)
62
- .join(" ");
63
- return rest ? `${ts} ${level} ${event} ${rest}` : `${ts} ${level} ${event}`;
64
- }
65
- function formatPrettyValue(value) {
66
- if (typeof value === "string")
67
- return JSON.stringify(value);
68
- return JSON.stringify(value);
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
  }
@@ -4,6 +4,8 @@ export const toolNames = {
4
4
  read: "read",
5
5
  write: "write",
6
6
  edit: "edit",
7
+ rename: "rename",
8
+ delete: "delete",
7
9
  grep: "grep",
8
10
  glob: "glob",
9
11
  ls: "ls",
@@ -20,18 +22,20 @@ export function buildToolDescriptions(config) {
20
22
  ? ` 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
23
  : "";
22
24
  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. The path is resolved within the workspace filesystem boundary. Call ${toolNames.openWorkspace} first and pass workspaceId.`,
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. Paths must be relative to the workspace. Call ${toolNames.openWorkspace} first and pass workspaceId.`,
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.`,
25
+ 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.`,
26
+ 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.`,
27
+ 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.`,
28
+ 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.`,
29
+ delete: `Delete one file or directory inside an open workspace or the OS temp directory. Non-empty directories require recursive=true. An allowed root itself cannot be deleted. Call ${toolNames.openWorkspace} first and pass workspaceId.`,
30
+ 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.`,
31
+ 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, move, rename, or delete project files; use ${toolNames.edit}, ${toolNames.write}, ${toolNames.rename}, or ${toolNames.delete} for file changes. Call ${toolNames.openWorkspace} first and pass workspaceId. This capability should only be exposed behind strong authentication.`,
28
32
  shellCommand: "Shell command to run with the local user's authority.",
29
33
  };
30
34
  }
31
35
  function capabilityContractInstructions(config, context) {
32
36
  const workspaceLifecycle = config.toolMode === "codex"
33
37
  ? `Use ForgeRelay as a local coding workspace. Default to the user's existing checkout and reuse its workspaceId. Only open mode=\"worktree\" when the user explicitly asks for isolated or parallel work. Managed worktrees use dedicated forgerelay/* branches, not detached HEADs. When work in a managed worktree is complete and verified, call ${toolNames.closeWorktree}; it commits remaining worktree changes, fast-forwards the original target branch only when safe, then removes the worktree and its branch. If the target branch diverged or the source checkout is dirty, closing is refused and the worktree is preserved.`
34
- : `Use ForgeRelay as a local coding workspace. Default to the user's existing checkout and reuse its workspaceId for later file, search, edit, write, show-changes, and shell tools. Only open mode=\"worktree\" when the user explicitly asks for isolated or parallel work. Managed worktrees use dedicated forgerelay/* branches, not detached HEADs. When work in a managed worktree is complete and verified, call ${toolNames.closeWorktree}; it commits remaining worktree changes, fast-forwards the original target branch only when safe, then removes the worktree and its branch. If the target branch diverged or the source checkout is dirty, closing is refused and the worktree is preserved.`;
38
+ : `Use ForgeRelay as a local coding workspace. Default to the user's existing checkout and reuse its workspaceId for later file, search, edit, write, rename, delete, show-changes, and shell tools. Only open mode=\"worktree\" when the user explicitly asks for isolated or parallel work. Managed worktrees use dedicated forgerelay/* branches, not detached HEADs. When work in a managed worktree is complete and verified, call ${toolNames.closeWorktree}; it commits remaining worktree changes, fast-forwards the original target branch only when safe, then removes the worktree and its branch. If the target branch diverged or the source checkout is dirty, closing is refused and the worktree is preserved.`;
35
39
  const agents = `Follow instructions returned by ${toolNames.openWorkspace}. Before working under a path listed in availableAgentsFiles, use ${toolNames.read} to inspect that instruction file and follow it.`;
36
40
  const skills = config.skillsEnabled
37
41
  ? `When ${toolNames.openWorkspace} returns available skills and a task matches a skill, use ${toolNames.read} to read that skill's path before proceeding. Skill paths may be outside the workspace, but ${toolNames.read} only permits advertised SKILL.md files and files under already-loaded skill directories.`
@@ -48,7 +52,7 @@ function capabilityContractInstructions(config, context) {
48
52
  }
49
53
  function toolSurfaceInstructions(config) {
50
54
  if (config.toolMode === "codex") {
51
- return `In codex tool mode, workspace file and command operations use ${toolNames.read}, apply_patch, exec_command, and write_stdin.`;
55
+ return `In codex tool mode, workspace file and command operations use ${toolNames.read}, ${toolNames.rename}, ${toolNames.delete}, apply_patch, exec_command, and write_stdin.`;
52
56
  }
53
57
  if (config.toolMode === "full") {
54
58
  return `In full tool mode, dedicated ${toolNames.grep}, ${toolNames.glob}, and ${toolNames.ls} inspection tools are available alongside the core workspace tools.`;
@@ -64,12 +68,12 @@ function selectedWorkflowInstructions(config) {
64
68
  }
65
69
  function defaultWorkflowInstructions(config) {
66
70
  if (config.toolMode === "codex") {
67
- return `Use ${toolNames.read} for direct file reads, apply_patch for all file modifications, exec_command for inspection, tests, builds, and other commands, and write_stdin to poll or interact with running processes.`;
71
+ 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 write_stdin to poll or interact with running processes.`;
68
72
  }
69
73
  const inspection = config.toolMode === "full"
70
74
  ? `Prefer ${toolNames.read}, ${toolNames.grep}, ${toolNames.glob}, and ${toolNames.ls} for file inspection.`
71
75
  : `Use ${toolNames.shell} with command-line tools such as grep, rg, find, ls, and tree for search and directory inspection.`;
72
- return joinInstructions(inspection, `Prefer ${toolNames.edit} for targeted modifications, ${toolNames.write} only for new files or complete rewrites, and ${toolNames.shell} for tests, builds, git inspection, package scripts, and commands that are better executed by the shell.`, `Do not create or modify files with ${toolNames.shell}; avoid shell redirection, heredocs, tee, sed -i, perl -i, node/python/ruby scripts, or any command whose purpose is to write project files.`);
76
+ 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 inspection, package scripts, and commands that are better executed by the shell.`, `Do not create or modify files with ${toolNames.shell}; avoid shell redirection, heredocs, tee, sed -i, perl -i, node/python/ruby scripts, or any command whose purpose is to write project files.`);
73
77
  }
74
78
  function joinInstructions(...parts) {
75
79
  return parts
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 { resolveAllowedPath } from "./roots.js";
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 = resolveAllowedPath(input.path, context.cwd, context.readRoots ?? [context.root]);
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 = resolveAllowedPath(input.path, context.cwd, [context.root]);
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 = resolveAllowedPath(input.path, context.cwd, [context.root]);
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
- if (input.path)
58
- resolveAllowedPath(input.path, context.cwd, [context.root]);
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
- if (input.path)
64
- resolveAllowedPath(input.path, context.cwd, [context.root]);
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
- if (input.path)
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
+ }