Fix bridge profile environment isolation (#796)
This commit is contained in:
@@ -11,6 +11,24 @@ interface LogEntry {
|
||||
timestamp: string; level: string; logger: string; message: string; raw: string
|
||||
}
|
||||
|
||||
function appendPinoContext(message: string, obj: any): string {
|
||||
const parts: string[] = []
|
||||
const runtime = obj.runtime && typeof obj.runtime === 'object' ? obj.runtime : null
|
||||
if (runtime) {
|
||||
if (runtime.profile) parts.push(`profile=${runtime.profile}`)
|
||||
if (runtime.cwd) parts.push(`cwd=${runtime.cwd}`)
|
||||
if (runtime.profile_dir) parts.push(`profile_dir=${runtime.profile_dir}`)
|
||||
if (runtime.config_path) parts.push(`config=${runtime.config_path}`)
|
||||
} else if (obj.profile) {
|
||||
parts.push(`profile=${obj.profile}`)
|
||||
}
|
||||
if (obj.request?.action) parts.push(`action=${obj.request.action}`)
|
||||
if (obj.sessionId) parts.push(`session=${obj.sessionId}`)
|
||||
if (obj.runId) parts.push(`run=${obj.runId}`)
|
||||
if (obj.status) parts.push(`status=${obj.status}`)
|
||||
return parts.length > 0 ? `${message} ${parts.join(' ')}` : message
|
||||
}
|
||||
|
||||
function parseLine(line: string): LogEntry {
|
||||
try {
|
||||
const obj = JSON.parse(line)
|
||||
@@ -20,7 +38,8 @@ function parseLine(line: string): LogEntry {
|
||||
// Pino 日志格式: { level, time, msg, name (logger name), hostname, pid, ... }
|
||||
const loggerName = obj.name || obj.logger || 'app'
|
||||
const message = obj.msg || (obj.err ? obj.err.message : '')
|
||||
return { timestamp: ts, level: levelMap[obj.level] || 'INFO', logger: loggerName, message: typeof message === 'string' ? message : JSON.stringify(message), raw: line }
|
||||
const baseMessage = typeof message === 'string' ? message : JSON.stringify(message)
|
||||
return { timestamp: ts, level: levelMap[obj.level] || 'INFO', logger: loggerName, message: appendPinoContext(baseMessage, obj), raw: line }
|
||||
}
|
||||
} catch {}
|
||||
let match = line.match(/^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3})\s+(DEBUG|INFO|WARNING|ERROR|CRITICAL)\s+(\S+?):\s(.*)$/)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createReadStream, existsSync, unlinkSync, writeFileSync } from 'fs'
|
||||
import { createReadStream, existsSync, readdirSync, rmSync, unlinkSync, writeFileSync } from 'fs'
|
||||
import { mkdir, writeFile } from 'fs/promises'
|
||||
import { basename, join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
@@ -7,21 +7,93 @@ import { SessionDeleter } from '../../services/hermes/session-deleter'
|
||||
import { getGatewayManagerInstance } from '../../services/gateway-bootstrap'
|
||||
import { logger } from '../../services/logger'
|
||||
import { smartCloneCleanup } from '../../services/hermes/profile-credentials'
|
||||
import { detectHermesHome } from '../../services/hermes/hermes-path'
|
||||
import { detectHermesRootHome } from '../../services/hermes/hermes-path'
|
||||
import { getActiveProfileName } from '../../services/hermes/hermes-profile'
|
||||
import type { HermesProfile } from '../../services/hermes/hermes-cli'
|
||||
|
||||
const RESERVED_PROFILE_NAMES = new Set([
|
||||
'hermes', 'default', 'test', 'tmp', 'root', 'sudo',
|
||||
])
|
||||
|
||||
const HERMES_SUBCOMMAND_PROFILE_NAMES = new Set([
|
||||
'chat', 'model', 'gateway', 'setup', 'whatsapp', 'login', 'logout',
|
||||
'status', 'cron', 'doctor', 'dump', 'config', 'pairing', 'skills', 'tools',
|
||||
'mcp', 'sessions', 'insights', 'version', 'update', 'uninstall',
|
||||
'profile', 'plugins', 'honcho', 'acp',
|
||||
])
|
||||
|
||||
function normalizeProfileName(name: string): string {
|
||||
return String(name || '').trim().toLowerCase()
|
||||
}
|
||||
|
||||
function isForbiddenProfileName(name: string): boolean {
|
||||
const normalized = normalizeProfileName(name)
|
||||
if (!normalized || normalized === 'default') return false
|
||||
return RESERVED_PROFILE_NAMES.has(normalized) || HERMES_SUBCOMMAND_PROFILE_NAMES.has(normalized)
|
||||
}
|
||||
|
||||
function getActiveProfileFile(): string {
|
||||
return join(detectHermesRootHome(), 'active_profile')
|
||||
}
|
||||
|
||||
function listProfilesFromDisk(activeProfileName: string): HermesProfile[] {
|
||||
const base = detectHermesRootHome()
|
||||
const profiles: HermesProfile[] = [{
|
||||
name: 'default',
|
||||
active: activeProfileName === 'default',
|
||||
model: '—',
|
||||
gateway: 'stopped',
|
||||
alias: '',
|
||||
}]
|
||||
const profilesDir = join(base, 'profiles')
|
||||
if (!existsSync(profilesDir)) return profiles
|
||||
for (const entry of readdirSync(profilesDir, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue
|
||||
const name = entry.name
|
||||
const dir = join(profilesDir, name)
|
||||
if (!existsSync(join(dir, 'config.yaml')) && !existsSync(dir)) continue
|
||||
profiles.push({
|
||||
name,
|
||||
active: name === activeProfileName,
|
||||
model: '—',
|
||||
gateway: 'stopped',
|
||||
alias: '',
|
||||
})
|
||||
}
|
||||
return profiles
|
||||
}
|
||||
|
||||
function profileExistsForManualSwitch(name: string): boolean {
|
||||
const base = detectHermesHome()
|
||||
const base = detectHermesRootHome()
|
||||
if (!name || name === 'default') return true
|
||||
return existsSync(join(base, 'profiles', name, 'config.yaml')) || existsSync(join(base, 'profiles', name))
|
||||
}
|
||||
|
||||
function deleteForbiddenProfileFromDisk(name: string): boolean {
|
||||
if (!isForbiddenProfileName(name)) return false
|
||||
const base = detectHermesRootHome()
|
||||
const profileDir = join(base, 'profiles', name)
|
||||
if (!existsSync(profileDir)) return false
|
||||
rmSync(profileDir, { recursive: true, force: true })
|
||||
try {
|
||||
if (normalizeProfileName(getActiveProfileName()) === normalizeProfileName(name)) {
|
||||
writeFileSync(getActiveProfileFile(), 'default\n', 'utf-8')
|
||||
}
|
||||
} catch {}
|
||||
logger.warn('[deleteProfile] removed reserved profile "%s" from disk after Hermes CLI rejected deletion', name)
|
||||
return true
|
||||
}
|
||||
|
||||
async function useProfileWithFallback(name: string): Promise<string> {
|
||||
if (isForbiddenProfileName(name)) {
|
||||
throw new Error(`Profile name '${name}' is reserved and cannot be activated`)
|
||||
}
|
||||
try {
|
||||
return await hermesCli.useProfile(name)
|
||||
} catch (err: any) {
|
||||
if (!profileExistsForManualSwitch(name)) throw err
|
||||
|
||||
const base = detectHermesHome()
|
||||
const base = detectHermesRootHome()
|
||||
writeFileSync(join(base, 'active_profile'), `${name}\n`, 'utf-8')
|
||||
logger.warn(err, '[switchProfile] hermes profile use failed; wrote active_profile directly for existing profile "%s"', name)
|
||||
return `Switched to profile ${name}`
|
||||
@@ -30,7 +102,18 @@ async function useProfileWithFallback(name: string): Promise<string> {
|
||||
|
||||
export async function list(ctx: any) {
|
||||
try {
|
||||
const profiles = await hermesCli.listProfiles()
|
||||
let profiles: HermesProfile[]
|
||||
try {
|
||||
profiles = await hermesCli.listProfiles()
|
||||
} catch (err: any) {
|
||||
const { getActiveProfileName } = await import('../../services/hermes/hermes-profile')
|
||||
const activeProfileName = getActiveProfileName()
|
||||
if (!isForbiddenProfileName(activeProfileName)) throw err
|
||||
|
||||
logger.warn(err, '[listProfiles] active_profile "%s" is invalid/reserved; resetting to default and listing profiles from disk', activeProfileName)
|
||||
writeFileSync(getActiveProfileFile(), 'default\n', 'utf-8')
|
||||
profiles = listProfilesFromDisk('default')
|
||||
}
|
||||
|
||||
// Override active flag from the authoritative source (active_profile file)
|
||||
// CLI output may be stale, but the file is written by hermes profile use
|
||||
@@ -63,6 +146,11 @@ export async function create(ctx: any) {
|
||||
ctx.body = { error: 'Missing profile name' }
|
||||
return
|
||||
}
|
||||
if (isForbiddenProfileName(name)) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: `Profile name '${name}' is reserved and cannot be created` }
|
||||
return
|
||||
}
|
||||
try {
|
||||
const output = await hermesCli.createProfile(name, clone)
|
||||
|
||||
@@ -140,6 +228,8 @@ export async function remove(ctx: any) {
|
||||
const ok = await hermesCli.deleteProfile(name)
|
||||
if (ok) {
|
||||
ctx.body = { success: true }
|
||||
} else if (deleteForbiddenProfileFromDisk(name)) {
|
||||
ctx.body = { success: true, fallback: 'removed_reserved_profile_from_disk' }
|
||||
} else {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: 'Failed to delete profile' }
|
||||
@@ -178,6 +268,11 @@ export async function switchProfile(ctx: any) {
|
||||
ctx.body = { error: 'Missing profile name' }
|
||||
return
|
||||
}
|
||||
if (isForbiddenProfileName(name)) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: `Profile name '${name}' is reserved and cannot be activated` }
|
||||
return
|
||||
}
|
||||
try {
|
||||
const output = await useProfileWithFallback(name)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user