diff --git a/src/main/automations/hermes-cron-output.test.ts b/src/main/automations/hermes-cron-output.test.ts index d47984e80ab..5acbcd8c22d 100644 --- a/src/main/automations/hermes-cron-output.test.ts +++ b/src/main/automations/hermes-cron-output.test.ts @@ -174,6 +174,36 @@ Run summary: monitor automation completed successfully. ) }) + it('hydrates referenced logs in valid dot-dot-prefixed Hermes subdirectories', async () => { + const home = await createHermesHome() + const outputDir = join(home, 'cron', 'output', 'job-1') + const scriptLogPath = join(home, '..logs', 'x-monitor.log') + await mkdir(outputDir, { recursive: true }) + await mkdir(dirname(scriptLogPath), { recursive: true }) + await writeFile(scriptLogPath, 'dot-dot-prefixed log line\n', 'utf-8') + await writeFile( + join(outputDir, '2026-05-15_09-02-00.md'), + `# Cron Job: Monitor automation + +## Response + +Latest log path: ${scriptLogPath} +Run summary: monitor automation completed successfully. +`, + 'utf-8' + ) + + const { readHermesCronOutputRunsPage } = await loadReader() + const page = await readHermesCronOutputRunsPage('job-1', { page: 1, pageSize: 25 }) + + expect((page.runs[0] as { output_content?: string }).output_content).toContain( + '## Latest log file' + ) + expect((page.runs[0] as { output_content?: string }).output_content).toContain( + 'dot-dot-prefixed log line' + ) + }) + it('uses a count-only path when page size is zero', async () => { const home = await createHermesHome() const outputDir = join(home, 'cron', 'output', 'job-1') diff --git a/src/main/automations/hermes-cron-output.ts b/src/main/automations/hermes-cron-output.ts index c0c68d9a205..5f220468213 100644 --- a/src/main/automations/hermes-cron-output.ts +++ b/src/main/automations/hermes-cron-output.ts @@ -3,7 +3,7 @@ import { existsSync } from 'fs' import { open, readdir, readFile, realpath, stat } from 'fs/promises' import { homedir } from 'os' -import { isAbsolute, join, relative, resolve } from 'path' +import { isAbsolute, join, relative, resolve, sep } from 'path' import Database from '../sqlite/sync-database' const HERMES_HOME = process.env.HERMES_HOME?.trim() || join(homedir(), '.hermes') @@ -162,7 +162,11 @@ async function readReferencedLogFile(content: string): Promise<{ const relativeToHermesHome = relative(resolve(homeRealPath), resolve(logRealPath)) // Why: the output body can contain agent-authored text, so only hydrate // referenced files that resolve inside Hermes' own data directory. - if (relativeToHermesHome.startsWith('..') || isAbsolute(relativeToHermesHome)) { + if ( + relativeToHermesHome === '..' || + relativeToHermesHome.startsWith(`..${sep}`) || + isAbsolute(relativeToHermesHome) + ) { return null } const logStat = await stat(logPath) diff --git a/src/relay/external-automations-handler-log-path.test.ts b/src/relay/external-automations-handler-log-path.test.ts new file mode 100644 index 00000000000..936cf4cff8a --- /dev/null +++ b/src/relay/external-automations-handler-log-path.test.ts @@ -0,0 +1,47 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises' +import { tmpdir } from 'os' +import { dirname, join } from 'path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RelayDispatcher } from './dispatcher' + +type ReferencedLogReader = { + readReferencedLogFile(content: string): Promise<{ content: string } | null> +} + +describe('ExternalAutomationsHandler referenced log paths', () => { + let hermesHome: string + let previousHermesHome: string | undefined + + beforeEach(async () => { + previousHermesHome = process.env.HERMES_HOME + hermesHome = await mkdtemp(join(tmpdir(), 'relay-hermes-output-')) + process.env.HERMES_HOME = hermesHome + vi.resetModules() + }) + + afterEach(async () => { + if (previousHermesHome === undefined) { + delete process.env.HERMES_HOME + } else { + process.env.HERMES_HOME = previousHermesHome + } + await rm(hermesHome, { recursive: true, force: true }) + vi.resetModules() + }) + + it('hydrates referenced logs in valid dot-dot-prefixed Hermes subdirectories', async () => { + const logPath = join(hermesHome, '..logs', 'x-monitor.log') + await mkdir(dirname(logPath), { recursive: true }) + await writeFile(logPath, 'remote dot-dot-prefixed log line\n', 'utf-8') + + const { ExternalAutomationsHandler } = await import('./external-automations-handler') + const handler = new ExternalAutomationsHandler({ + onRequest: () => {} + } as unknown as RelayDispatcher) as unknown as ReferencedLogReader + + const result = await handler.readReferencedLogFile(`Latest log path: ${logPath} +Run summary: monitor automation completed successfully.`) + + expect(result?.content).toBe('remote dot-dot-prefixed log line\n') + }) +}) diff --git a/src/relay/external-automations-handler.ts b/src/relay/external-automations-handler.ts index 0062a5b0eb0..ff82d268b3a 100644 --- a/src/relay/external-automations-handler.ts +++ b/src/relay/external-automations-handler.ts @@ -5,7 +5,7 @@ import { existsSync } from 'fs' import { open, readdir, readFile, realpath, stat } from 'fs/promises' import { createRequire } from 'module' import { homedir } from 'os' -import { isAbsolute, join, relative, resolve } from 'path' +import { isAbsolute, join, relative, resolve, sep } from 'path' import { promisify } from 'util' import type { RelayDispatcher } from './dispatcher' @@ -246,7 +246,11 @@ export class ExternalAutomationsHandler { const relativeToHermesHome = relative(resolve(homeRealPath), resolve(logRealPath)) // Why: the output body can contain agent-authored text, so only hydrate // referenced files that resolve inside Hermes' own data directory. - if (relativeToHermesHome.startsWith('..') || isAbsolute(relativeToHermesHome)) { + if ( + relativeToHermesHome === '..' || + relativeToHermesHome.startsWith(`..${sep}`) || + isAbsolute(relativeToHermesHome) + ) { return null } const logStat = await stat(logPath)