[codex] fix media skill profile auth and run events (#965)

* fix media skill profile auth and run events

* test bridge run profile context
This commit is contained in:
ekko
2026-05-24 12:52:14 +08:00
committed by GitHub
parent 3e8f84aa65
commit 634a622934
20 changed files with 368 additions and 97 deletions
+66 -10
View File
@@ -1,9 +1,9 @@
import type { Context } from 'koa'
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
import { dirname, extname, isAbsolute, join, resolve } from 'path'
import { getActiveAuthPath } from '../../services/hermes/hermes-profile'
import { getActiveProfileName, getProfileDir, listProfileNamesFromDisk } from '../../services/hermes/hermes-profile'
import { config } from '../../config'
import { readConfigYaml } from '../../services/config-helpers'
import { readConfigYamlForProfile } from '../../services/config-helpers'
const XAI_VIDEO_GENERATIONS_URL = 'https://api.x.ai/v1/videos/generations'
const XAI_VIDEO_STATUS_URL = 'https://api.x.ai/v1/videos'
@@ -28,6 +28,42 @@ type FunCodexProvider = {
model: string
}
function requestedProfileName(ctx: Context): string {
const headerProfile = ctx.get('x-hermes-profile')
const queryProfile = typeof ctx.query.profile === 'string' ? ctx.query.profile : ''
const body = ctx.request.body as { profile?: unknown } | undefined
const bodyProfile = typeof body?.profile === 'string' ? body.profile : ''
return (ctx.state.profile?.name || headerProfile || queryProfile || bodyProfile || '').trim()
}
function resolveMediaProfile(ctx: Context): string {
let requested = requestedProfileName(ctx)
if (!requested && ctx.state.user?.role !== 'super_admin' && !ctx.state.serverTokenAuth) {
const profiles = ctx.state.user?.profiles || []
if (profiles.length === 1) {
requested = profiles[0]
} else {
const err: any = new Error('Profile is required')
err.status = 400
err.code = 'profile_required'
throw err
}
}
const profile = requested || getActiveProfileName() || 'default'
if (!listProfileNamesFromDisk().includes(profile)) {
const err: any = new Error(`Profile "${profile}" does not exist`)
err.status = 404
err.code = 'profile_not_found'
throw err
}
return profile
}
function authPathForProfile(profile: string): string {
return join(getProfileDir(profile), 'auth.json')
}
function readJsonFile(path: string): any {
try {
return JSON.parse(readFileSync(path, 'utf-8'))
@@ -43,8 +79,8 @@ function buildApiUrl(baseUrl: string, pathWithV1: string): string {
return `${base}${apiPath}`
}
async function resolveFunCodexProvider(): Promise<FunCodexProvider | null> {
const hermesConfig = await readConfigYaml()
async function resolveFunCodexProvider(profile: string): Promise<FunCodexProvider | null> {
const hermesConfig = await readConfigYamlForProfile(profile)
const customProviders = Array.isArray(hermesConfig.custom_providers)
? hermesConfig.custom_providers as any[]
: []
@@ -59,11 +95,11 @@ async function resolveFunCodexProvider(): Promise<FunCodexProvider | null> {
}
}
function resolveXaiToken(): { token: string; source: string } | null {
function resolveXaiToken(profile: string): { token: string; source: string } | null {
const envToken = String(process.env.XAI_API_KEY || '').trim()
if (envToken) return { token: envToken, source: 'XAI_API_KEY' }
const auth = readJsonFile(getActiveAuthPath()) as AuthJson | null
const auth = readJsonFile(authPathForProfile(profile)) as AuthJson | null
const providerToken = String(auth?.providers?.['xai-oauth']?.tokens?.access_token || auth?.providers?.['xai-oauth']?.access_token || '').trim()
if (providerToken) return { token: providerToken, source: 'xai-oauth' }
@@ -421,11 +457,20 @@ function saveGeneratedImages(images: string[], requestedOutputPath?: string): st
}
export async function apiKeyImageGenerate(ctx: Context) {
const provider = await resolveFunCodexProvider()
let profile: string
try {
profile = resolveMediaProfile(ctx)
} catch (err: any) {
ctx.status = err.status || 400
ctx.body = { error: err.message || String(err), code: err.code || 'invalid_profile' }
return
}
const provider = await resolveFunCodexProvider(profile)
if (!provider) {
ctx.status = 401
ctx.body = {
error: 'Missing fun-codex provider in active profile config.yaml.',
error: `Missing fun-codex provider in profile "${profile}" config.yaml.`,
code: 'missing_fun_codex_provider',
}
return
@@ -443,6 +488,7 @@ export async function apiKeyImageGenerate(ctx: Context) {
output_paths: outputPaths,
provider: APIKEY_IMAGE_PROVIDER,
base_url: provider.baseUrl,
profile,
}
} catch (err: any) {
ctx.status = err.status || 500
@@ -484,11 +530,20 @@ async function downloadVideo(url: string, outputPath: string): Promise<void> {
}
export async function grokImageToVideo(ctx: Context) {
const tokenInfo = resolveXaiToken()
let profile: string
try {
profile = resolveMediaProfile(ctx)
} catch (err: any) {
ctx.status = err.status || 400
ctx.body = { error: err.message || String(err), code: err.code || 'invalid_profile' }
return
}
const tokenInfo = resolveXaiToken(profile)
if (!tokenInfo) {
ctx.status = 401
ctx.body = {
error: 'Missing xAI token. Set XAI_API_KEY or complete xAI OAuth login first.',
error: `Missing xAI token for profile "${profile}". Set XAI_API_KEY or complete xAI OAuth login first.`,
code: 'missing_xai_token',
}
return
@@ -539,6 +594,7 @@ export async function grokImageToVideo(ctx: Context) {
video_url: videoUrl,
output_path: outputPath,
token_source: tokenInfo.source,
profile,
}
return
}
@@ -35,6 +35,7 @@ declare module 'koa' {
interface DefaultState {
user?: AuthenticatedUser
profile?: RequestProfile
serverTokenAuth?: boolean
}
}
@@ -69,6 +70,19 @@ function requestToken(ctx: Context): string {
return typeof ctx.query.token === 'string' ? ctx.query.token.trim() : ''
}
const SERVER_TOKEN_MEDIA_PATHS = new Set([
'/api/hermes/media/apikey-image-generate',
'/api/hermes/media/grok-image-to-video',
])
async function allowServerTokenForMedia(ctx: Context, token: string): Promise<boolean> {
if (!token || !SERVER_TOKEN_MEDIA_PATHS.has(ctx.path)) return false
const serverToken = await getToken()
if (!serverToken || token !== serverToken) return false
ctx.state.serverTokenAuth = true
return true
}
export function signUserJwt(user: Pick<UserRecord, 'id' | 'username' | 'role'>, secret: string, now = Date.now()): string {
const iat = Math.floor(now / 1000)
const payload: JwtPayload = {
@@ -149,6 +163,10 @@ export async function requireUserJwt(ctx: Context, next: Next): Promise<void> {
const token = requestToken(ctx)
const payload = token ? verifyUserJwt(token, secret) : null
if (!payload) {
if (await allowServerTokenForMedia(ctx, token)) {
await next()
return
}
ctx.status = 401
ctx.body = { error: 'Unauthorized' }
return
@@ -948,7 +948,7 @@ class AgentPool:
def _tool_progress_callback(self, session_id: str):
def callback(event_type, function_name=None, preview=None, function_args=None, **kwargs):
if event_type in (None, "tool.started", "tool.completed"):
if event_type in (None, "tool.started", "tool.completed") or str(event_type or "").startswith("subagent."):
print(
"[hermes_bridge] tool_progress_callback "
f"session={session_id} event={event_type} tool={function_name} "
@@ -964,6 +964,18 @@ class AgentPool:
})
return
if str(event_type or "").startswith("subagent."):
payload = {
"event": str(event_type),
"tool_name": str(function_name) if function_name else "",
"text": str(preview) if preview is not None else "",
"args": _jsonable(function_args) if function_args else {},
}
for key, value in kwargs.items():
payload[str(key)] = _jsonable(value)
self._append_event(session_id, payload)
return
if event_type == "_thinking":
text = function_name
if text:
@@ -473,6 +473,11 @@ class AgentClient {
return { ...block, text: `${routedPrefix}\n\n原始消息:${text || msg.content}` }
})
: `${routedPrefix}\n\n原始消息:${stripMentionRoutingTokens(msg.content, this.name) || msg.content}`
const runContext = [
`[Current Hermes profile: ${this.profile}]`,
'When calling Hermes Web UI endpoints from tools or skills, include the current Hermes profile as the X-Hermes-Profile header if the endpoint supports profile-scoped behavior.',
].join('\n')
instructions = instructions ? `${runContext}\n${instructions}` : runContext
const bridgeInput: AgentBridgeMessage = isContentBlockArray(input)
? await convertContentBlocksForAgent(input)
: input
@@ -127,11 +127,6 @@ export async function markAbortCompleted(
}
state.events = []
replaceState(sessionMap, sessionId, 'abort.completed', {
event: 'abort.completed',
run_id: runId,
synced: true,
})
emitToSession(nsp, socket, sessionId, 'abort.completed', {
event: 'abort.completed',
run_id: runId,
@@ -105,6 +105,7 @@ export async function handleApiRun(
sessionMap.set(session_id, state)
}
state.isWorking = true
state.events = []
state.profile = profile
state.source = 'api_server'
state.activeRunMarker = runMarker
@@ -128,10 +128,12 @@ export async function handleBridgeRun(
if (resolvedProvider && sessionRow.provider !== resolvedProvider) updates.provider = resolvedProvider
if (Object.keys(updates).length > 0) updateSession(session_id, updates)
}
if (sessionRow?.workspace) {
const workspaceCtx = `[Current working directory: ${sessionRow.workspace}]`
fullInstructions = `\n${workspaceCtx}\n${fullInstructions}`
}
const runContext = [
`[Current Hermes profile: ${profile}]`,
sessionRow?.workspace ? `[Current working directory: ${sessionRow.workspace}]` : '',
'When calling Hermes Web UI endpoints from tools or skills, include the current Hermes profile as the X-Hermes-Profile header if the endpoint supports profile-scoped behavior.',
].filter(Boolean).join('\n')
fullInstructions = `\n${runContext}\n${fullInstructions}`
const runMarker = `cli_run_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`
const now = Math.floor(Date.now() / 1000)
@@ -145,6 +147,7 @@ export async function handleBridgeRun(
state.isWorking = true
state.isAborting = false
state.events = []
state.profile = profile
state.source = 'cli'
state.activeRunMarker = runMarker
@@ -491,6 +494,38 @@ async function applyBridgeChunkAsync(
}
pushState(sessionMap, sessionId, 'tool.completed', payload)
emit('tool.completed', payload)
} else if (evType?.startsWith('subagent.')) {
const payload = {
event: evType,
run_id: chunk.run_id,
subagent_id: ev.subagent_id,
parent_id: ev.parent_id,
depth: ev.depth,
task_index: ev.task_index,
task_count: ev.task_count,
goal: ev.goal,
model: ev.model,
toolsets: ev.toolsets,
tool_count: ev.tool_count,
tool: ev.tool_name,
name: ev.tool_name,
preview: ev.text || ev.summary || ev.tool_preview || '',
text: ev.text || '',
status: ev.status,
summary: ev.summary,
duration: ev.duration_seconds,
duration_seconds: ev.duration_seconds,
input_tokens: ev.input_tokens,
output_tokens: ev.output_tokens,
reasoning_tokens: ev.reasoning_tokens,
api_calls: ev.api_calls,
cost_usd: ev.cost_usd,
files_read: ev.files_read,
files_written: ev.files_written,
output_tail: ev.output_tail,
}
pushState(sessionMap, sessionId, evType, payload)
emit(evType, payload)
} else if (evType === 'turn.boundary') {
flushBridgePendingToDb(state, sessionId, runMarker)
} else if (evType === 'reasoning.delta' || evType === 'thinking.delta') {
@@ -168,6 +168,7 @@ export class ChatRunSocket {
logger.info('[chat-run-socket] queued run for session %s (queue: %d)', data.session_id, state.queue.length)
return
}
state.events = []
state.isWorking = true
state.profile = runProfile
state.source = source