Import the client, server, skills, tests, and build scripts as the starting point for the rebuild, then trim the surfaces this project does not ship: Electron desktop packaging, the marketing website, and the ESP32 firmware tree. CI keeps the web build, e2e, lockfile, release, and Docker workflows. Rename the package and CLI entry points to hermes-ui, drop the prepare hook so npm ci no longer triggers a build, and drop the removed tsconfig and nodemon references. Rewrite AGENTS.md, ARCHITECTURE.md, DEVELOPMENT.md, and the READMEs so the documented package map and hard rules match this repository. reference_code/ is ignored: it is a read-only snapshot, not an input to the build.
250 lines
8.0 KiB
TypeScript
250 lines
8.0 KiB
TypeScript
import { Readable } from 'stream'
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
const provider = {
|
|
listDir: vi.fn(),
|
|
stat: vi.fn(),
|
|
readFile: vi.fn(),
|
|
deleteFile: vi.fn(),
|
|
deleteDir: vi.fn(),
|
|
writeFile: vi.fn(),
|
|
}
|
|
const createFileProviderMock = vi.fn(async () => provider)
|
|
const resolveHermesPathMock = vi.fn((relativePath: string) => {
|
|
const normalized = relativePath.replace(/^\/+/, '')
|
|
return normalized ? `/home/agent/.hermes/${normalized}` : '/home/agent/.hermes'
|
|
})
|
|
|
|
vi.mock('../../packages/server/src/services/hermes/file-provider', () => ({
|
|
createFileProvider: createFileProviderMock,
|
|
resolveHermesPath: resolveHermesPathMock,
|
|
isSensitivePath: vi.fn(() => false),
|
|
MAX_EDIT_SIZE: 10 * 1024 * 1024,
|
|
}))
|
|
|
|
async function runFileRoute(path: string, ctx: any) {
|
|
const { fileRoutes } = await import('../../packages/server/src/routes/hermes/files')
|
|
const layer = fileRoutes.stack.find((entry: any) => entry.path === path)
|
|
if (!layer) throw new Error(`Missing file route ${path}`)
|
|
|
|
let index = -1
|
|
async function dispatch(nextIndex: number): Promise<void> {
|
|
if (nextIndex <= index) throw new Error('next() called multiple times')
|
|
index = nextIndex
|
|
const fn = layer.stack[nextIndex]
|
|
if (!fn) return
|
|
await fn(ctx, () => dispatch(nextIndex + 1))
|
|
}
|
|
|
|
await dispatch(0)
|
|
}
|
|
|
|
function superAdminState(profile = 'research') {
|
|
return {
|
|
profile: { name: profile },
|
|
user: { id: 1, username: 'owner', role: 'super_admin' },
|
|
}
|
|
}
|
|
|
|
describe('file routes path metadata', () => {
|
|
beforeEach(() => {
|
|
vi.resetModules()
|
|
createFileProviderMock.mockClear()
|
|
resolveHermesPathMock.mockClear()
|
|
provider.listDir.mockReset()
|
|
provider.stat.mockReset()
|
|
provider.readFile.mockReset()
|
|
provider.deleteFile.mockReset()
|
|
provider.deleteDir.mockReset()
|
|
provider.writeFile.mockReset()
|
|
})
|
|
|
|
it('returns absolute paths for listed entries while preserving relative operation paths', async () => {
|
|
provider.listDir.mockResolvedValue([
|
|
{ name: 'app.log', path: 'logs/app.log', isDir: false, size: 12, modTime: '2026-05-20T00:00:00.000Z' },
|
|
])
|
|
|
|
const ctx: any = { query: { path: 'logs' }, state: { profile: { name: 'research' } }, body: null }
|
|
|
|
await runFileRoute('/api/hermes/files/list', ctx)
|
|
|
|
expect(createFileProviderMock).toHaveBeenCalledWith('research')
|
|
expect(resolveHermesPathMock).toHaveBeenCalledWith('logs', 'research')
|
|
expect(provider.listDir).toHaveBeenCalledWith('/home/agent/.hermes/logs')
|
|
expect(ctx.body).toEqual({
|
|
path: 'logs',
|
|
absolutePath: '/home/agent/.hermes/logs',
|
|
entries: [
|
|
{
|
|
name: 'app.log',
|
|
path: 'logs/app.log',
|
|
absolutePath: '/home/agent/.hermes/logs/app.log',
|
|
isDir: false,
|
|
size: 12,
|
|
modTime: '2026-05-20T00:00:00.000Z',
|
|
},
|
|
],
|
|
})
|
|
})
|
|
|
|
it('returns an absolute path in stat responses', async () => {
|
|
provider.stat.mockResolvedValue({
|
|
name: 'app.log',
|
|
path: 'logs/app.log',
|
|
isDir: false,
|
|
size: 12,
|
|
modTime: '2026-05-20T00:00:00.000Z',
|
|
})
|
|
|
|
const ctx: any = { query: { path: 'logs/app.log' }, state: { profile: { name: 'research' } }, body: null }
|
|
|
|
await runFileRoute('/api/hermes/files/stat', ctx)
|
|
|
|
expect(createFileProviderMock).toHaveBeenCalledWith('research')
|
|
expect(resolveHermesPathMock).toHaveBeenCalledWith('logs/app.log', 'research')
|
|
expect(ctx.body).toEqual({
|
|
name: 'app.log',
|
|
path: 'logs/app.log',
|
|
absolutePath: '/home/agent/.hermes/logs/app.log',
|
|
isDir: false,
|
|
size: 12,
|
|
modTime: '2026-05-20T00:00:00.000Z',
|
|
})
|
|
})
|
|
|
|
it('deletes files from the parsed request body', async () => {
|
|
provider.deleteFile.mockResolvedValue(undefined)
|
|
|
|
const ctx: any = {
|
|
request: { body: { path: 'workspace/weather.txt', recursive: false } },
|
|
state: superAdminState(),
|
|
body: null,
|
|
}
|
|
|
|
await runFileRoute('/api/hermes/files/delete', ctx)
|
|
|
|
expect(createFileProviderMock).toHaveBeenCalledWith('research')
|
|
expect(resolveHermesPathMock).toHaveBeenCalledWith('workspace/weather.txt', 'research')
|
|
expect(provider.deleteFile).toHaveBeenCalledWith('/home/agent/.hermes/workspace/weather.txt')
|
|
expect(provider.deleteDir).not.toHaveBeenCalled()
|
|
expect(ctx.body).toEqual({ ok: true })
|
|
})
|
|
|
|
it('returns missing_path instead of throwing when delete body is absent', async () => {
|
|
const ctx: any = {
|
|
request: { body: undefined },
|
|
state: superAdminState(),
|
|
body: null,
|
|
}
|
|
|
|
await runFileRoute('/api/hermes/files/delete', ctx)
|
|
|
|
expect(ctx.status).toBe(400)
|
|
expect(ctx.body).toEqual({ error: 'Missing path parameter', code: 'missing_path' })
|
|
expect(createFileProviderMock).not.toHaveBeenCalled()
|
|
expect(provider.deleteFile).not.toHaveBeenCalled()
|
|
expect(provider.deleteDir).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('uploads files with boundary parameters and RFC 5987 filenames', async () => {
|
|
provider.writeFile.mockResolvedValue(undefined)
|
|
const boundary = 'files-boundary'
|
|
const body = Buffer.from([
|
|
`--${boundary}`,
|
|
'Content-Disposition: form-data; name="file"; filename*=UTF-8\'\'daily%20report.txt',
|
|
'Content-Type: text/plain',
|
|
'',
|
|
'hello',
|
|
`--${boundary}--`,
|
|
'',
|
|
].join('\r\n'))
|
|
|
|
const ctx: any = {
|
|
query: { path: 'workspace' },
|
|
req: Readable.from([body]),
|
|
request: {},
|
|
state: superAdminState(),
|
|
body: null,
|
|
status: 200,
|
|
get: vi.fn((header: string) => header.toLowerCase() === 'content-type'
|
|
? `multipart/form-data; boundary=${boundary}; charset=utf-8`
|
|
: ''),
|
|
}
|
|
|
|
await runFileRoute('/api/hermes/files/upload', ctx)
|
|
|
|
expect(createFileProviderMock).toHaveBeenCalledWith('research')
|
|
expect(resolveHermesPathMock).toHaveBeenCalledWith('workspace/daily report.txt', 'research')
|
|
expect(provider.writeFile).toHaveBeenCalledWith(
|
|
'/home/agent/.hermes/workspace/daily report.txt',
|
|
Buffer.from('hello'),
|
|
)
|
|
expect(ctx.body).toEqual({
|
|
files: [{ name: 'daily report.txt', path: 'workspace/daily report.txt' }],
|
|
})
|
|
})
|
|
|
|
it('returns invalid_request for malformed RFC 5987 filenames', async () => {
|
|
const boundary = 'files-boundary'
|
|
const body = Buffer.from([
|
|
`--${boundary}`,
|
|
'Content-Disposition: form-data; name="file"; filename*=UTF-8\'\'bad%ZZname.txt',
|
|
'Content-Type: text/plain',
|
|
'',
|
|
'hello',
|
|
`--${boundary}--`,
|
|
'',
|
|
].join('\r\n'))
|
|
|
|
const ctx: any = {
|
|
query: { path: 'workspace' },
|
|
req: Readable.from([body]),
|
|
request: {},
|
|
state: superAdminState(),
|
|
body: null,
|
|
status: 200,
|
|
get: vi.fn((header: string) => header.toLowerCase() === 'content-type'
|
|
? `multipart/form-data; boundary=${boundary}`
|
|
: ''),
|
|
}
|
|
|
|
await runFileRoute('/api/hermes/files/upload', ctx)
|
|
|
|
expect(ctx.status).toBe(400)
|
|
expect(ctx.body).toEqual({ error: 'Malformed multipart filename', code: 'invalid_request' })
|
|
expect(provider.writeFile).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('requires a super administrator for file editor content and mutations', async () => {
|
|
const readCtx: any = {
|
|
query: { path: 'workspace/weather.txt' },
|
|
state: {
|
|
profile: { name: 'research' },
|
|
user: { id: 2, username: 'admin', role: 'admin' },
|
|
},
|
|
body: null,
|
|
}
|
|
|
|
await runFileRoute('/api/hermes/files/read', readCtx)
|
|
|
|
expect(readCtx.status).toBe(403)
|
|
expect(readCtx.body).toEqual({ error: 'Super administrator privileges are required' })
|
|
expect(provider.readFile).not.toHaveBeenCalled()
|
|
|
|
const writeCtx: any = {
|
|
request: { body: { path: 'workspace/weather.txt', content: 'rain' } },
|
|
state: {
|
|
profile: { name: 'research' },
|
|
user: { id: 2, username: 'admin', role: 'admin' },
|
|
},
|
|
body: null,
|
|
}
|
|
|
|
await runFileRoute('/api/hermes/files/write', writeCtx)
|
|
|
|
expect(writeCtx.status).toBe(403)
|
|
expect(writeCtx.body).toEqual({ error: 'Super administrator privileges are required' })
|
|
expect(provider.writeFile).not.toHaveBeenCalled()
|
|
})
|
|
})
|