mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
refactor(desktop): extract filesystem IPC handlers from main.cjs into fs-ipc.cjs
Second main.cjs cluster peel (after git-ipc). The six hermes:fs:* handlers
(readDir, gitRoot, reveal, rename, writeText, trash) move verbatim into
electron/fs-ipc.cjs behind a registerFsIpc({ ipcMain, directoryExists,
expandUserPath }) registrar — same injection pattern as registerGitIpc. Path
hardening / read-dir / git-root come from their sibling modules directly; the
two main-process path helpers are injected so the module stays side-effect free.
Channel names are unchanged, so preload + renderer are untouched. main.cjs drops
~85 lines; the now-dead fs-read-dir / git-root requires in main.cjs are removed.
Adds electron/fs-ipc.test.cjs asserting the hermes:fs:* surface by invariant.
This commit is contained in:
parent
b29bb6ef9d
commit
f3ce17bf9e
3 changed files with 157 additions and 90 deletions
105
apps/desktop/electron/fs-ipc.cjs
Normal file
105
apps/desktop/electron/fs-ipc.cjs
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
'use strict'
|
||||
|
||||
const { shell } = require('electron')
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const { readDirForIpc } = require('./fs-read-dir.cjs')
|
||||
const { gitRootForIpc } = require('./git-root.cjs')
|
||||
const { resolveRequestedPathForIpc } = require('./hardening.cjs')
|
||||
|
||||
// Filesystem IPC: read-dir, git-root, reveal, rename, write-text, trash. Path
|
||||
// hardening + `~` expansion + dir-existence checks live in the main process and
|
||||
// are injected so this module stays side-effect free.
|
||||
function registerFsIpc({ directoryExists, expandUserPath, ipcMain }) {
|
||||
ipcMain.handle('hermes:fs:readDir', async (_event, dirPath) => readDirForIpc(dirPath))
|
||||
|
||||
ipcMain.handle('hermes:fs:gitRoot', async (_event, startPath) => gitRootForIpc(startPath))
|
||||
|
||||
// Reveal a path in the OS file manager (Finder / Explorer / Files).
|
||||
ipcMain.handle('hermes:fs:reveal', async (_event, targetPath) => {
|
||||
const target = String(targetPath || '').trim()
|
||||
|
||||
if (!target) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
shell.showItemInFolder(target)
|
||||
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
// Rename a file/folder in place. The renderer passes the existing path + a new
|
||||
// base name; the destination is resolved in the SAME parent dir so a rename can
|
||||
// never move the item elsewhere or traverse out. Rejects on a name collision.
|
||||
ipcMain.handle('hermes:fs:rename', async (_event, targetPath, newName) => {
|
||||
const src = String(targetPath || '').trim()
|
||||
const name = String(newName || '').trim()
|
||||
|
||||
if (!src || !name || name === '.' || name === '..' || name.includes('/') || name.includes('\\')) {
|
||||
throw new Error('Invalid rename')
|
||||
}
|
||||
|
||||
const dst = path.join(path.dirname(src), name)
|
||||
|
||||
if (dst === src) {
|
||||
return { path: dst }
|
||||
}
|
||||
|
||||
if (fs.existsSync(dst)) {
|
||||
throw new Error(`"${name}" already exists`)
|
||||
}
|
||||
|
||||
await fs.promises.rename(src, dst)
|
||||
|
||||
return { path: dst }
|
||||
})
|
||||
|
||||
// Write a small UTF-8 text file (e.g. a project's IDEA.md at creation). The path
|
||||
// is hardened (resolveRequestedPathForIpc) and the parent must already exist —
|
||||
// this never creates directory trees or escapes the allowed roots, and content
|
||||
// is size-capped so it can't be abused as a bulk-write primitive.
|
||||
ipcMain.handle('hermes:fs:writeText', async (_event, filePath, content) => {
|
||||
const raw = String(filePath || '').trim()
|
||||
|
||||
if (!raw) {
|
||||
throw new Error('Invalid path')
|
||||
}
|
||||
|
||||
const text = String(content ?? '')
|
||||
|
||||
if (text.length > 1_000_000) {
|
||||
throw new Error('Content too large')
|
||||
}
|
||||
|
||||
const resolved = resolveRequestedPathForIpc(expandUserPath(raw), { purpose: 'Write text file' })
|
||||
|
||||
if (!directoryExists(path.dirname(resolved))) {
|
||||
throw new Error('Parent directory does not exist')
|
||||
}
|
||||
|
||||
await fs.promises.writeFile(resolved, text, 'utf8')
|
||||
|
||||
return { path: resolved }
|
||||
})
|
||||
|
||||
// Move a file/folder to the OS trash (recoverable) — the VS Code "Delete"
|
||||
// default. `shell.trashItem` routes to Finder/Explorer/Files trash per platform.
|
||||
ipcMain.handle('hermes:fs:trash', async (_event, targetPath) => {
|
||||
const target = String(targetPath || '').trim()
|
||||
|
||||
if (!target) {
|
||||
throw new Error('Invalid delete')
|
||||
}
|
||||
|
||||
await shell.trashItem(target)
|
||||
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = { registerFsIpc }
|
||||
49
apps/desktop/electron/fs-ipc.test.cjs
Normal file
49
apps/desktop/electron/fs-ipc.test.cjs
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
'use strict'
|
||||
|
||||
const assert = require('node:assert/strict')
|
||||
const test = require('node:test')
|
||||
|
||||
const { registerFsIpc } = require('./fs-ipc.cjs')
|
||||
|
||||
function fakeIpcMain() {
|
||||
const handlers = new Map()
|
||||
|
||||
return {
|
||||
handlers,
|
||||
handle(channel, handler) {
|
||||
assert.ok(!handlers.has(channel), `duplicate registration for ${channel}`)
|
||||
handlers.set(channel, handler)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test('registerFsIpc wires only hermes:fs:* channels, each to a handler fn', () => {
|
||||
const ipcMain = fakeIpcMain()
|
||||
|
||||
registerFsIpc({ ipcMain, directoryExists: () => true, expandUserPath: p => p })
|
||||
|
||||
assert.ok(ipcMain.handlers.size >= 6, `expected the full fs surface, got ${ipcMain.handlers.size}`)
|
||||
|
||||
for (const [channel, handler] of ipcMain.handlers) {
|
||||
assert.match(channel, /^hermes:fs:/, `${channel} is not an fs channel`)
|
||||
assert.equal(typeof handler, 'function', `${channel} should register a handler`)
|
||||
}
|
||||
|
||||
for (const channel of ['hermes:fs:readDir', 'hermes:fs:rename', 'hermes:fs:trash']) {
|
||||
assert.ok(ipcMain.handlers.has(channel), `missing ${channel}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('rename rejects names that traverse out of the parent dir', async () => {
|
||||
const ipcMain = fakeIpcMain()
|
||||
|
||||
registerFsIpc({ ipcMain, directoryExists: () => true, expandUserPath: p => p })
|
||||
|
||||
for (const bad of ['..', '.', 'a/b', 'a\\b']) {
|
||||
await assert.rejects(
|
||||
() => ipcMain.handlers.get('hermes:fs:rename')({}, '/tmp/x', bad),
|
||||
/Invalid rename/,
|
||||
`"${bad}" should be rejected`
|
||||
)
|
||||
}
|
||||
})
|
||||
|
|
@ -46,7 +46,6 @@ const { buildDesktopBackendEnv, normalizeHermesHomeRoot } = require('./backend-e
|
|||
const { readWindowsUserEnvVar } = require('./windows-user-env.cjs')
|
||||
const { readWslWindowsClipboardImage } = require('./wsl-clipboard-image.cjs')
|
||||
const { nativeOverlayWidth: computeNativeOverlayWidth } = require('./titlebar-overlay-width.cjs')
|
||||
const { readDirForIpc } = require('./fs-read-dir.cjs')
|
||||
const { readLiveUpdateMarker } = require('./update-marker.cjs')
|
||||
const {
|
||||
resolveUnpackedRelease,
|
||||
|
|
@ -57,8 +56,8 @@ const {
|
|||
collectRelaunchEnv,
|
||||
buildRelaunchScript
|
||||
} = require('./update-relaunch.cjs')
|
||||
const { gitRootForIpc } = require('./git-root.cjs')
|
||||
const { registerGitIpc } = require('./git-ipc.cjs')
|
||||
const { registerFsIpc } = require('./fs-ipc.cjs')
|
||||
const { OFFICIAL_REPO_HTTPS_URL, isOfficialSshRemote } = require('./update-remote.cjs')
|
||||
const { resolveBehindCount, shouldCountCommits } = require('./update-count.cjs')
|
||||
const { runRebuildWithRetry } = require('./update-rebuild.cjs')
|
||||
|
|
@ -6898,94 +6897,8 @@ function disposeTerminalSession(id) {
|
|||
return true
|
||||
}
|
||||
|
||||
ipcMain.handle('hermes:fs:readDir', async (_event, dirPath) => readDirForIpc(dirPath))
|
||||
|
||||
ipcMain.handle('hermes:fs:gitRoot', async (_event, startPath) => gitRootForIpc(startPath))
|
||||
|
||||
// Reveal a path in the OS file manager (Finder / Explorer / Files).
|
||||
ipcMain.handle('hermes:fs:reveal', async (_event, targetPath) => {
|
||||
const target = String(targetPath || '').trim()
|
||||
|
||||
if (!target) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
shell.showItemInFolder(target)
|
||||
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
// Rename a file/folder in place. The renderer passes the existing path + a new
|
||||
// base name; the destination is resolved in the SAME parent dir so a rename can
|
||||
// never move the item elsewhere or traverse out. Rejects on a name collision.
|
||||
ipcMain.handle('hermes:fs:rename', async (_event, targetPath, newName) => {
|
||||
const src = String(targetPath || '').trim()
|
||||
const name = String(newName || '').trim()
|
||||
|
||||
if (!src || !name || name === '.' || name === '..' || name.includes('/') || name.includes('\\')) {
|
||||
throw new Error('Invalid rename')
|
||||
}
|
||||
|
||||
const dst = path.join(path.dirname(src), name)
|
||||
|
||||
if (dst === src) {
|
||||
return { path: dst }
|
||||
}
|
||||
|
||||
if (fs.existsSync(dst)) {
|
||||
throw new Error(`"${name}" already exists`)
|
||||
}
|
||||
|
||||
await fs.promises.rename(src, dst)
|
||||
|
||||
return { path: dst }
|
||||
})
|
||||
|
||||
// Write a small UTF-8 text file (e.g. a project's IDEA.md at creation). The path
|
||||
// is hardened (resolveRequestedPathForIpc) and the parent must already exist —
|
||||
// this never creates directory trees or escapes the allowed roots, and content
|
||||
// is size-capped so it can't be abused as a bulk-write primitive.
|
||||
ipcMain.handle('hermes:fs:writeText', async (_event, filePath, content) => {
|
||||
const raw = String(filePath || '').trim()
|
||||
|
||||
if (!raw) {
|
||||
throw new Error('Invalid path')
|
||||
}
|
||||
|
||||
const text = String(content ?? '')
|
||||
|
||||
if (text.length > 1_000_000) {
|
||||
throw new Error('Content too large')
|
||||
}
|
||||
|
||||
const resolved = resolveRequestedPathForIpc(expandUserPath(raw), { purpose: 'Write text file' })
|
||||
|
||||
if (!directoryExists(path.dirname(resolved))) {
|
||||
throw new Error('Parent directory does not exist')
|
||||
}
|
||||
|
||||
await fs.promises.writeFile(resolved, text, 'utf8')
|
||||
|
||||
return { path: resolved }
|
||||
})
|
||||
|
||||
// Move a file/folder to the OS trash (recoverable) — the VS Code "Delete"
|
||||
// default. `shell.trashItem` routes to Finder/Explorer/Files trash per platform.
|
||||
ipcMain.handle('hermes:fs:trash', async (_event, targetPath) => {
|
||||
const target = String(targetPath || '').trim()
|
||||
|
||||
if (!target) {
|
||||
throw new Error('Invalid delete')
|
||||
}
|
||||
|
||||
await shell.trashItem(target)
|
||||
|
||||
return true
|
||||
})
|
||||
// Filesystem IPC lives in fs-ipc.cjs; main-process path helpers are injected.
|
||||
registerFsIpc({ ipcMain, directoryExists, expandUserPath })
|
||||
|
||||
// Git/worktree/review IPC lives in git-ipc.cjs; the git + gh binary resolvers
|
||||
// stay here (Windows PATH discovery) and are injected into the registrar.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue