fix(composer): prevent ~/.hermes from masquerading as workspace context (#243)

The composer workspace selector and file browser now resolve through one
profile-local workspace catalog, so ~/.hermes remains runtime state instead of
becoming the project root.

Changes:
- Rework /api/workspace into a profile-local workspace catalog
- Prevent ~/.hermes, Hermes profile dirs, and system roots from being
  selected as workspaces
- Make /api/files use the same workspace catalog as the composer
- Update the composer workspace selector to list/switch backend workspaces
- Invalidate workspace state after profile switches
- Change the workspace menu action to open the in-place files sidebar
  instead of navigating to /files
- Remove the "Add path manually..." button (used window.prompt; not suitable
  for remote workspaces)
- Composer bottom-row layout: profile, workspace, reasoning, model, context
  ring near send button; mic/attachment ordering aligned
- Add focused regression coverage for workspace resolution and composer
  control wiring

Constraint: Hermes Web UI treats workspaces/spaces separately from
profile state.
Rejected: Keep localStorage saved workspace paths | stale saved paths could
keep showing ~/.hermes.
Rejected: Keep /api/files fallback to HERMES_HOME | files sidebar would still
browse runtime state.
Confidence: high
Scope-risk: moderate
Directive: Do not reintroduce HERMES_HOME/CLAUDE_HOME as project workspace
fallbacks; use /api/workspace catalog instead.
Tested: targeted vitest for workspace/files/composer controls; targeted
eslint; LSP diagnostics on key changed files; pnpm build green.
Not-tested: dedicated Spaces manager UI remains follow-up.

Worked with Interstellar Code
This commit is contained in:
Interstellar-code
2026-05-03 18:49:25 +02:00
committed by GitHub
parent acaa4e5081
commit b72b47544d
10 changed files with 1439 additions and 412 deletions

View File

@@ -42,10 +42,7 @@ describe('ensureWorkspacePath (#121)', () => {
// The new check (path.relative) correctly rejects it. // The new check (path.relative) correctly rejects it.
const rel = path.relative(root, sibling) const rel = path.relative(root, sibling)
const escapes = const escapes =
!rel || !rel || rel.startsWith('..') || rel === '..' || path.isAbsolute(rel)
rel.startsWith('..') ||
rel === '..' ||
path.isAbsolute(rel)
expect(escapes).toBe(true) expect(escapes).toBe(true)
}) })
@@ -57,10 +54,7 @@ describe('ensureWorkspacePath (#121)', () => {
const rel = path.relative(root, escape) const rel = path.relative(root, escape)
expect( expect(
!rel || !rel || rel.startsWith('..') || rel === '..' || path.isAbsolute(rel),
rel.startsWith('..') ||
rel === '..' ||
path.isAbsolute(rel),
).toBe(true) ).toBe(true)
}) })
@@ -72,10 +66,7 @@ describe('ensureWorkspacePath (#121)', () => {
const rel = path.relative(root, inside) const rel = path.relative(root, inside)
expect( expect(
!rel || !rel || rel.startsWith('..') || rel === '..' || path.isAbsolute(rel),
rel.startsWith('..') ||
rel === '..' ||
path.isAbsolute(rel),
).toBe(false) ).toBe(false)
}) })

View File

@@ -0,0 +1,153 @@
import os from 'node:os'
import path from 'node:path'
import fs from 'node:fs/promises'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { loadWorkspaceCatalog, saveWorkspaceSelection } from './workspace'
const originalEnv = { ...process.env }
let tempRoot = ''
async function makeDir(...parts: Array<string>) {
const dir = path.join(...parts)
await fs.mkdir(dir, { recursive: true })
return dir
}
beforeEach(async () => {
tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'hermes-workspace-route-'))
process.env = { ...originalEnv }
process.env.HERMES_HOME = path.join(tempRoot, '.hermes')
delete process.env.HERMES_WORKSPACE_DIR
delete process.env.CLAUDE_WORKSPACE_DIR
delete process.env.HERMES_WEBUI_DEFAULT_WORKSPACE
await fs.mkdir(process.env.HERMES_HOME, { recursive: true })
})
afterEach(async () => {
process.env = { ...originalEnv }
await fs.rm(tempRoot, { recursive: true, force: true })
})
describe('workspace API catalog semantics', () => {
it('uses the Hermes profile default workspace instead of ~/.hermes state', async () => {
const project = await makeDir(tempRoot, 'workspace')
await fs.writeFile(
path.join(process.env.HERMES_HOME!, 'config.yaml'),
`default_workspace: ${JSON.stringify(project)}\n`,
'utf-8',
)
const catalog = await loadWorkspaceCatalog()
expect(catalog).toMatchObject({
path: project,
folderName: 'Home',
source: 'config.default_workspace',
isValid: true,
last: project,
})
expect(catalog.workspaces).toEqual([{ name: 'Home', path: project }])
expect(catalog.path).not.toBe(process.env.HERMES_HOME)
})
it('ignores legacy persisted Hermes state paths as workspaces', async () => {
const project = await makeDir(tempRoot, 'workspace')
await fs.writeFile(
path.join(process.env.HERMES_HOME!, 'config.yaml'),
`default_workspace: ${JSON.stringify(project)}
`,
'utf-8',
)
await fs.mkdir(path.join(process.env.HERMES_HOME!, 'webui_state'), {
recursive: true,
})
await fs.writeFile(
path.join(process.env.HERMES_HOME!, 'webui_state', 'workspaces.json'),
JSON.stringify({
workspaces: [
{ name: 'Bad Hermes Home', path: process.env.HERMES_HOME },
{ name: 'Home', path: project },
],
last: process.env.HERMES_HOME,
}),
'utf-8',
)
await fs.writeFile(
path.join(process.env.HERMES_HOME!, 'webui_state', 'last_workspace.txt'),
`${process.env.HERMES_HOME}
`,
'utf-8',
)
const catalog = await loadWorkspaceCatalog()
expect(catalog.path).toBe(project)
expect(catalog.workspaces).toEqual([{ name: 'Home', path: project }])
})
it('rejects manual selection of Hermes state directories', async () => {
await expect(
saveWorkspaceSelection({ path: process.env.HERMES_HOME!, name: 'State' }),
).rejects.toThrow('cannot be used as workspaces')
})
it('rejects manual selection of system directories', async () => {
await expect(
saveWorkspaceSelection({ path: '/', name: 'Root' }),
).rejects.toThrow('System directories cannot be used as workspaces')
})
it('honors CLAUDE_HOME as the profile root when HERMES_HOME is unset', async () => {
const claudeHome = path.join(tempRoot, '.claude-home')
const project = await makeDir(tempRoot, 'claude-workspace')
delete process.env.HERMES_HOME
process.env.CLAUDE_HOME = claudeHome
await fs.mkdir(claudeHome, { recursive: true })
await fs.writeFile(
path.join(claudeHome, 'config.yaml'),
`default_workspace: ${JSON.stringify(project)}
`,
'utf-8',
)
const catalog = await loadWorkspaceCatalog()
expect(catalog.path).toBe(project)
await saveWorkspaceSelection({ path: project, name: 'Claude Workspace' })
await expect(
fs.readFile(
path.join(claudeHome, 'webui_state', 'last_workspace.txt'),
'utf-8',
),
).resolves.toBe(`${project}
`)
})
it('persists the selected workspace in profile-local Web UI state', async () => {
const homeProject = await makeDir(tempRoot, 'workspace')
const selectedProject = await makeDir(tempRoot, 'client-app')
process.env.HERMES_WEBUI_DEFAULT_WORKSPACE = homeProject
const saved = await saveWorkspaceSelection({
path: selectedProject,
name: 'Client App',
})
expect(saved.path).toBe(selectedProject)
expect(saved.folderName).toBe('Client App')
expect(saved.workspaces).toContainEqual({
name: 'Client App',
path: selectedProject,
})
await expect(
fs.readFile(
path.join(
process.env.HERMES_HOME!,
'webui_state',
'last_workspace.txt',
),
'utf-8',
),
).resolves.toBe(`${selectedProject}\n`)
})
})

View File

@@ -1,4 +1,3 @@
import os from 'node:os'
import path from 'node:path' import path from 'node:path'
import fs from 'node:fs/promises' import fs from 'node:fs/promises'
import { execFile } from 'node:child_process' import { execFile } from 'node:child_process'
@@ -16,16 +15,10 @@ import {
requireJsonContentType, requireJsonContentType,
safeErrorMessage, safeErrorMessage,
} from '../../server/rate-limit' } from '../../server/rate-limit'
import { loadWorkspaceCatalog } from './workspace'
const execFileAsync = promisify(execFile) const execFileAsync = promisify(execFile)
const WORKSPACE_ROOT = (
process.env.HERMES_WORKSPACE_DIR ||
process.env.CLAUDE_WORKSPACE_DIR ||
process.env.HERMES_HOME || process.env.CLAUDE_HOME ||
path.join(os.homedir(), '.hermes')
).trim()
type FileEntry = { type FileEntry = {
name: string name: string
path: string path: string
@@ -43,14 +36,22 @@ type FileEntry = {
* form rejects any candidate that escapes the root via `..` segments or * form rejects any candidate that escapes the root via `..` segments or
* that resolves to an absolute path outside the root. See #121. * that resolves to an absolute path outside the root. See #121.
*/ */
function ensureWorkspacePath(input: string) { async function getWorkspaceRoot(): Promise<string> {
const catalog = await loadWorkspaceCatalog()
if (!catalog.isValid || !catalog.path) {
throw new Error('No valid workspace selected')
}
return catalog.path
}
function ensureWorkspacePath(input: string, workspaceRoot: string) {
const raw = input.trim() const raw = input.trim()
if (!raw) return WORKSPACE_ROOT if (!raw) return workspaceRoot
const resolved = path.isAbsolute(raw) const resolved = path.isAbsolute(raw)
? path.resolve(raw) ? path.resolve(raw)
: path.resolve(WORKSPACE_ROOT, raw) : path.resolve(workspaceRoot, raw)
if (resolved === WORKSPACE_ROOT) return resolved if (resolved === workspaceRoot) return resolved
const relative = path.relative(WORKSPACE_ROOT, resolved) const relative = path.relative(workspaceRoot, resolved)
if ( if (
!relative || !relative ||
relative.startsWith('..') || relative.startsWith('..') ||
@@ -62,8 +63,8 @@ function ensureWorkspacePath(input: string) {
return resolved return resolved
} }
function toRelative(resolvedPath: string) { function toRelative(resolvedPath: string, workspaceRoot: string) {
const relative = path.relative(WORKSPACE_ROOT, resolvedPath) const relative = path.relative(workspaceRoot, resolvedPath)
return relative || '' return relative || ''
} }
@@ -121,6 +122,7 @@ type ReadDirectoryOptions = {
maxDepth: number maxDepth: number
maxEntries: number | null maxEntries: number | null
countedEntries: { value: number } countedEntries: { value: number }
workspaceRoot: string
} }
function parseMaxDepth(input: string | null): number | null { function parseMaxDepth(input: string | null): number | null {
@@ -163,7 +165,7 @@ async function readDirectory(
if (IGNORED_DIRS.has(entry.name)) continue if (IGNORED_DIRS.has(entry.name)) continue
const fullPath = path.join(dirPath, entry.name) const fullPath = path.join(dirPath, entry.name)
const relativePath = toRelative(fullPath) const relativePath = toRelative(fullPath, options.workspaceRoot)
try { try {
const stats = await fs.stat(fullPath) const stats = await fs.stat(fullPath)
if (entry.isDirectory()) { if (entry.isDirectory()) {
@@ -195,9 +197,9 @@ async function readDirectory(
return sortEntries(mapped) return sortEntries(mapped)
} }
async function readGlobDirectory(globPath: string) { async function readGlobDirectory(globPath: string, workspaceRoot: string) {
const { directoryPath, regex } = parseGlobPattern(globPath) const { directoryPath, regex } = parseGlobPattern(globPath)
const resolvedDirectory = ensureWorkspacePath(directoryPath) const resolvedDirectory = ensureWorkspacePath(directoryPath, workspaceRoot)
const entries = await fs.readdir(resolvedDirectory, { withFileTypes: true }) const entries = await fs.readdir(resolvedDirectory, { withFileTypes: true })
const mapped: Array<FileEntry> = [] const mapped: Array<FileEntry> = []
@@ -207,7 +209,7 @@ async function readGlobDirectory(globPath: string) {
const stats = await fs.stat(fullPath) const stats = await fs.stat(fullPath)
mapped.push({ mapped.push({
name: entry.name, name: entry.name,
path: toRelative(fullPath), path: toRelative(fullPath, workspaceRoot),
type: entry.isDirectory() ? 'folder' : 'file', type: entry.isDirectory() ? 'folder' : 'file',
size: stats.size, size: stats.size,
modifiedAt: stats.mtime.toISOString(), modifiedAt: stats.mtime.toISOString(),
@@ -215,7 +217,7 @@ async function readGlobDirectory(globPath: string) {
} }
return { return {
root: toRelative(resolvedDirectory), root: toRelative(resolvedDirectory, workspaceRoot),
entries: sortEntries(mapped), entries: sortEntries(mapped),
} }
} }
@@ -260,16 +262,21 @@ export const Route = createFileRoute('/api/files')({
url.searchParams.get('maxEntries'), url.searchParams.get('maxEntries'),
) )
const workspaceRoot = await getWorkspaceRoot()
if (action === 'list' && hasGlob(inputPath)) { if (action === 'list' && hasGlob(inputPath)) {
const globListing = await readGlobDirectory(inputPath) const globListing = await readGlobDirectory(
inputPath,
workspaceRoot,
)
return json({ return json({
root: globListing.root, root: globListing.root,
base: WORKSPACE_ROOT, base: workspaceRoot,
entries: globListing.entries, entries: globListing.entries,
}) })
} }
const resolvedPath = ensureWorkspacePath(inputPath) const resolvedPath = ensureWorkspacePath(inputPath, workspaceRoot)
if (action === 'read') { if (action === 'read') {
const buffer = await fs.readFile(resolvedPath) const buffer = await fs.readFile(resolvedPath)
@@ -277,13 +284,13 @@ export const Route = createFileRoute('/api/files')({
const mime = getMimeType(resolvedPath) const mime = getMimeType(resolvedPath)
return json({ return json({
type: 'image', type: 'image',
path: toRelative(resolvedPath), path: toRelative(resolvedPath, workspaceRoot),
content: `data:${mime};base64,${buffer.toString('base64')}`, content: `data:${mime};base64,${buffer.toString('base64')}`,
}) })
} }
return json({ return json({
type: 'text', type: 'text',
path: toRelative(resolvedPath), path: toRelative(resolvedPath, workspaceRoot),
content: buffer.toString('utf8'), content: buffer.toString('utf8'),
}) })
} }
@@ -304,10 +311,11 @@ export const Route = createFileRoute('/api/files')({
maxDepth: maxDepthParam ?? MAX_DIRECTORY_DEPTH, maxDepth: maxDepthParam ?? MAX_DIRECTORY_DEPTH,
maxEntries: maxEntriesParam, maxEntries: maxEntriesParam,
countedEntries: { value: 0 }, countedEntries: { value: 0 },
workspaceRoot,
}) })
return json({ return json({
root: toRelative(resolvedPath), root: toRelative(resolvedPath, workspaceRoot),
base: WORKSPACE_ROOT, base: workspaceRoot,
entries: tree, entries: tree,
}) })
} catch (err) { } catch (err) {
@@ -324,6 +332,7 @@ export const Route = createFileRoute('/api/files')({
} }
try { try {
const workspaceRoot = await getWorkspaceRoot()
const contentType = request.headers.get('content-type') || '' const contentType = request.headers.get('content-type') || ''
if (!contentType.includes('multipart/form-data')) { if (!contentType.includes('multipart/form-data')) {
const csrfCheck = requireJsonContentType(request) const csrfCheck = requireJsonContentType(request)
@@ -340,7 +349,10 @@ export const Route = createFileRoute('/api/files')({
if (!(file instanceof File)) { if (!(file instanceof File)) {
return json({ error: 'Missing file' }, { status: 400 }) return json({ error: 'Missing file' }, { status: 400 })
} }
const resolvedTarget = ensureWorkspacePath(targetPath) const resolvedTarget = ensureWorkspacePath(
targetPath,
workspaceRoot,
)
const isDir = (await fs.stat(resolvedTarget)).isDirectory() const isDir = (await fs.stat(resolvedTarget)).isDirectory()
const destination = isDir const destination = isDir
? path.join(resolvedTarget, file.name) ? path.join(resolvedTarget, file.name)
@@ -348,7 +360,10 @@ export const Route = createFileRoute('/api/files')({
await fs.mkdir(path.dirname(destination), { recursive: true }) await fs.mkdir(path.dirname(destination), { recursive: true })
const buffer = Buffer.from(await file.arrayBuffer()) const buffer = Buffer.from(await file.arrayBuffer())
await fs.writeFile(destination, buffer) await fs.writeFile(destination, buffer)
return json({ ok: true, path: toRelative(destination) }) return json({
ok: true,
path: toRelative(destination, workspaceRoot),
})
} }
const body = (await request.json().catch(() => ({}))) as Record< const body = (await request.json().catch(() => ({}))) as Record<
@@ -358,24 +373,36 @@ export const Route = createFileRoute('/api/files')({
const action = typeof body.action === 'string' ? body.action : 'write' const action = typeof body.action === 'string' ? body.action : 'write'
if (action === 'mkdir') { if (action === 'mkdir') {
const dirPath = ensureWorkspacePath(String(body.path || '')) const dirPath = ensureWorkspacePath(
String(body.path || ''),
workspaceRoot,
)
await fs.mkdir(dirPath, { recursive: true }) await fs.mkdir(dirPath, { recursive: true })
return json({ ok: true, path: toRelative(dirPath) }) return json({ ok: true, path: toRelative(dirPath, workspaceRoot) })
} }
if (action === 'rename') { if (action === 'rename') {
const fromPath = ensureWorkspacePath(String(body.from || '')) const fromPath = ensureWorkspacePath(
const toPath = ensureWorkspacePath(String(body.to || '')) String(body.from || ''),
workspaceRoot,
)
const toPath = ensureWorkspacePath(
String(body.to || ''),
workspaceRoot,
)
await fs.mkdir(path.dirname(toPath), { recursive: true }) await fs.mkdir(path.dirname(toPath), { recursive: true })
await fs.rename(fromPath, toPath) await fs.rename(fromPath, toPath)
return json({ ok: true, path: toRelative(toPath) }) return json({ ok: true, path: toRelative(toPath, workspaceRoot) })
} }
if (action === 'delete') { if (action === 'delete') {
if (!requireLocalOrAuth(request)) { if (!requireLocalOrAuth(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 }) return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
} }
const targetPath = ensureWorkspacePath(String(body.path || '')) const targetPath = ensureWorkspacePath(
String(body.path || ''),
workspaceRoot,
)
try { try {
// Try macOS trash command first // Try macOS trash command first
await execFileAsync('trash', [targetPath]) await execFileAsync('trash', [targetPath])
@@ -386,11 +413,14 @@ export const Route = createFileRoute('/api/files')({
return json({ ok: true }) return json({ ok: true })
} }
const filePath = ensureWorkspacePath(String(body.path || '')) const filePath = ensureWorkspacePath(
String(body.path || ''),
workspaceRoot,
)
const content = typeof body.content === 'string' ? body.content : '' const content = typeof body.content === 'string' ? body.content : ''
await fs.mkdir(path.dirname(filePath), { recursive: true }) await fs.mkdir(path.dirname(filePath), { recursive: true })
await fs.writeFile(filePath, content, 'utf8') await fs.writeFile(filePath, content, 'utf8')
return json({ ok: true, path: toRelative(filePath) }) return json({ ok: true, path: toRelative(filePath, workspaceRoot) })
} catch (err) { } catch (err) {
return json({ error: safeErrorMessage(err) }, { status: 500 }) return json({ error: safeErrorMessage(err) }, { status: 500 })
} }

View File

@@ -1,17 +1,141 @@
/** /**
* Phase 2.6: Workspace detection API * Hermes workspace API.
* Auto-detects workspace from Hermes config, env, or default paths *
* Important distinction: HERMES_HOME / ~/.hermes is Hermes state/config, not the
* user's project workspace. Workspace resolution intentionally mirrors the
* Hermes Web UI semantics: active profile config first, then user workspace
* defaults such as ~/workspace. Never fall back to ~/.hermes as a workspace.
*/ */
import os from 'node:os' import os from 'node:os'
import path from 'node:path' import path from 'node:path'
import fs from 'node:fs/promises' import fs from 'node:fs/promises'
import YAML from 'yaml'
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start' import { json } from '@tanstack/react-start'
import { isAuthenticated } from '../../server/auth-middleware' import { isAuthenticated } from '../../server/auth-middleware'
import { requireJsonContentType } from '../../server/rate-limit'
import {
getActiveProfileName,
readProfile,
} from '../../server/profiles-browser'
type WorkspaceEntry = {
name: string
path: string
}
type WorkspaceDetectionResponse = {
path: string
folderName: string
source: string
isValid: boolean
workspaces: Array<WorkspaceEntry>
last: string
}
type WorkspaceState = {
workspaces?: Array<WorkspaceEntry>
last?: string
}
function extractFolderName(fullPath: string): string { function extractFolderName(fullPath: string): string {
const parts = fullPath.replace(/\\/g, '/').split('/') const parts = fullPath.replace(/\\/g, '/').split('/').filter(Boolean)
return parts[parts.length - 1] || 'workspace' return parts.at(-1) || 'workspace'
}
function expandHome(input: string): string {
if (input === '~') return os.homedir()
if (input.startsWith('~/')) return path.join(os.homedir(), input.slice(2))
return input
}
function normalizeCandidate(input: string): string {
return path.resolve(expandHome(input.trim()))
}
function pathContains(parent: string, candidate: string): boolean {
const relative = path.relative(parent, candidate)
return (
Boolean(relative) &&
!relative.startsWith('..') &&
!path.isAbsolute(relative)
)
}
function isPathOrChild(parent: string, candidate: string): boolean {
const normalizedParent = normalizeCandidate(parent)
const normalizedCandidate = normalizeCandidate(candidate)
return (
normalizedCandidate === normalizedParent ||
pathContains(normalizedParent, normalizedCandidate)
)
}
function exactBlockedSystemRoots(): Array<string> {
return ['/', 'C:/']
}
function blockedSystemSubtrees(): Array<string> {
return [
'/bin',
'/sbin',
'/etc',
'/usr',
'/boot',
'/proc',
'/sys',
'/dev',
'/root',
'/private/etc',
'/private/var/db',
'/private/var/log',
'C:/Windows',
'C:/Program Files',
'C:/Program Files (x86)',
]
}
function isBlockedSystemPath(candidatePath: string): boolean {
const normalized = normalizeCandidate(candidatePath)
return (
exactBlockedSystemRoots().some(
(root) => normalizeCandidate(root) === normalized,
) || blockedSystemSubtrees().some((root) => isPathOrChild(root, normalized))
)
}
function isHermesStatePath(candidatePath: string): boolean {
const normalized = normalizeCandidate(candidatePath)
const stateRoots = Array.from(
new Set(
[
process.env.HERMES_HOME,
process.env.CLAUDE_HOME,
path.join(os.homedir(), '.hermes'),
activeProfileHome(),
]
.map((value) => readString(value))
.filter(Boolean)
.map(normalizeCandidate),
),
)
return stateRoots.some(
(root) => normalized === root || pathContains(root, normalized),
)
}
function assertWorkspaceAllowed(candidatePath: string): void {
if (isHermesStatePath(candidatePath)) {
throw new Error(
'Hermes profile/state directories cannot be used as workspaces',
)
}
if (isBlockedSystemPath(candidatePath)) {
throw new Error(
`System directories cannot be used as workspaces: ${candidatePath}`,
)
}
} }
async function isValidDirectory(dirPath: string): Promise<boolean> { async function isValidDirectory(dirPath: string): Promise<boolean> {
@@ -23,24 +147,174 @@ async function isValidDirectory(dirPath: string): Promise<boolean> {
} }
} }
async function detectWorkspace(savedPath?: string): Promise<{ async function readYamlConfig(
path: string configPath: string,
folderName: string ): Promise<Record<string, unknown>> {
source: string try {
isValid: boolean const raw = await fs.readFile(configPath, 'utf-8')
}> { const parsed = YAML.parse(raw)
// Priority 1: Saved path from localStorage (passed via query param) return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
if (savedPath) { ? (parsed as Record<string, unknown>)
const isValid = await isValidDirectory(savedPath) : {}
if (isValid) { } catch {
return { return {}
path: savedPath, }
folderName: extractFolderName(savedPath), }
source: 'localStorage',
isValid: true, function readString(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
async function firstValidDirectory(
candidates: Array<{ path: string; source: string; create?: boolean }>,
): Promise<{ path: string; source: string } | null> {
for (const candidate of candidates) {
const raw = candidate.path.trim()
if (!raw || raw === '.') continue
const resolved = normalizeCandidate(raw)
if (candidate.create) {
try {
await fs.mkdir(resolved, { recursive: true })
} catch {
// Continue to next candidate.
} }
} }
// Saved path is stale, fall through to auto-detect if (isHermesStatePath(resolved) || isBlockedSystemPath(resolved)) continue
if (await isValidDirectory(resolved)) {
return { path: resolved, source: candidate.source }
}
}
return null
}
function activeProfileHome(): string {
try {
const active = getActiveProfileName()
return readProfile(active).path
} catch {
return (
process.env.HERMES_HOME ??
process.env.CLAUDE_HOME ??
path.join(os.homedir(), '.hermes')
)
}
}
function workspaceStateDir(): string {
return path.join(activeProfileHome(), 'webui_state')
}
function workspaceStateFile(): string {
return path.join(workspaceStateDir(), 'workspaces.json')
}
function lastWorkspaceFile(): string {
return path.join(workspaceStateDir(), 'last_workspace.txt')
}
async function readWorkspaceState(): Promise<WorkspaceState> {
try {
const raw = await fs.readFile(workspaceStateFile(), 'utf-8')
const parsed = JSON.parse(raw) as unknown
if (Array.isArray(parsed))
return { workspaces: parsed as Array<WorkspaceEntry> }
if (parsed && typeof parsed === 'object') return parsed as WorkspaceState
} catch {
// No persisted state yet.
}
return {}
}
async function writeWorkspaceState(state: WorkspaceState): Promise<void> {
const stateDir = workspaceStateDir()
await fs.mkdir(stateDir, { recursive: true })
await fs.writeFile(
workspaceStateFile(),
JSON.stringify(
{
workspaces: state.workspaces ?? [],
last: state.last ?? '',
},
null,
2,
),
'utf-8',
)
if (state.last) {
await fs.writeFile(lastWorkspaceFile(), `${state.last}\n`, 'utf-8')
}
}
async function configuredDefaultWorkspace(): Promise<{
path: string
source: string
} | null> {
const profileHome = activeProfileHome()
const cfg = await readYamlConfig(path.join(profileHome, 'config.yaml'))
const terminal = cfg.terminal
const terminalCwd =
terminal && typeof terminal === 'object' && !Array.isArray(terminal)
? readString((terminal as Record<string, unknown>).cwd)
: ''
return firstValidDirectory([
{ path: process.env.HERMES_WORKSPACE_DIR ?? '', source: 'env' },
{ path: process.env.CLAUDE_WORKSPACE_DIR ?? '', source: 'env' },
{ path: process.env.HERMES_WEBUI_DEFAULT_WORKSPACE ?? '', source: 'env' },
{ path: readString(cfg.workspace), source: 'config.workspace' },
{
path: readString(cfg.default_workspace),
source: 'config.default_workspace',
},
{ path: terminalCwd, source: 'config.terminal.cwd' },
{ path: path.join(os.homedir(), 'workspace'), source: 'home.workspace' },
{ path: path.join(os.homedir(), 'work'), source: 'home.work' },
{
path: path.join(os.homedir(), 'workspace'),
source: 'home.workspace.created',
create: true,
},
])
}
function dedupeWorkspaces(
workspaces: Array<WorkspaceEntry>,
): Array<WorkspaceEntry> {
const seen = new Set<string>()
const cleaned: Array<WorkspaceEntry> = []
for (const workspace of workspaces) {
const rawPath = readString(workspace.path)
if (!rawPath) continue
const normalized = normalizeCandidate(rawPath)
if (isHermesStatePath(normalized) || isBlockedSystemPath(normalized))
continue
if (seen.has(normalized)) continue
seen.add(normalized)
const name = readString(workspace.name) || extractFolderName(normalized)
cleaned.push({ name: name === 'default' ? 'Home' : name, path: normalized })
}
return cleaned
}
async function cleanExistingWorkspaces(
workspaces: Array<WorkspaceEntry>,
): Promise<Array<WorkspaceEntry>> {
const cleaned = dedupeWorkspaces(workspaces)
const existing: Array<WorkspaceEntry> = []
for (const workspace of cleaned) {
if (await isValidDirectory(workspace.path)) existing.push(workspace)
}
return existing
}
export async function loadWorkspaceCatalog(): Promise<WorkspaceDetectionResponse> {
const state = await readWorkspaceState()
const configured = await configuredDefaultWorkspace()
const fallback = configured ?? { path: '', source: 'none' }
let workspaces = await cleanExistingWorkspaces(state.workspaces ?? [])
if (workspaces.length === 0 && fallback.path) {
workspaces = [{ name: 'Home', path: fallback.path }]
} }
// Priority 2: Environment variable // Priority 2: Environment variable
@@ -55,43 +329,70 @@ async function detectWorkspace(savedPath?: string): Promise<{
folderName: extractFolderName(envWorkspace), folderName: extractFolderName(envWorkspace),
source: 'env', source: 'env',
isValid: true, isValid: true,
workspaces,
last: envWorkspace,
} }
} }
} }
// Priority 3: Default Claude workspace path const savedLast = readString(state.last)
const defaultPath = process.env.HERMES_HOME ?? process.env.CLAUDE_HOME ?? path.join(os.homedir(), '.hermes') const lastFromFile = await (async () => {
const defaultValid = await isValidDirectory(defaultPath) try {
if (defaultValid) { return (await fs.readFile(lastWorkspaceFile(), 'utf-8')).trim()
return { } catch {
path: defaultPath, return ''
folderName: extractFolderName(defaultPath),
source: 'default',
isValid: true,
} }
} })()
const lastCandidate =
[savedLast, lastFromFile, fallback.path].find(Boolean) ?? ''
const normalizedLast = lastCandidate ? normalizeCandidate(lastCandidate) : ''
const activeWorkspace =
workspaces.find((workspace) => workspace.path === normalizedLast) ??
workspaces.at(0)
const active =
activeWorkspace ??
(fallback.path ? { name: 'Home', path: fallback.path } : undefined)
const activePath = active ? active.path : ''
// Priority 4: Claude home directory
const claudeDir = process.env.HERMES_HOME ?? process.env.CLAUDE_HOME ?? path.join(os.homedir(), '.hermes')
const claudeDirValid = await isValidDirectory(claudeDir)
if (claudeDirValid) {
return {
path: claudeDir,
folderName: path.basename(claudeDir),
source: 'default',
isValid: true,
}
}
// Nothing found
return { return {
path: '', path: activePath,
folderName: '', folderName: active ? active.name || extractFolderName(active.path) : '',
source: 'none', source:
isValid: false, activePath && activePath === fallback.path
? fallback.source
: 'workspace-state',
isValid: Boolean(activePath),
workspaces,
last: activePath,
} }
} }
export async function saveWorkspaceSelection(input: {
path?: string
name?: string
}): Promise<WorkspaceDetectionResponse> {
const rawPath = readString(input.path)
if (!rawPath) throw new Error('path is required')
const target = normalizeCandidate(rawPath)
assertWorkspaceAllowed(target)
if (!(await isValidDirectory(target))) {
throw new Error(`Path is not an existing directory: ${target}`)
}
const current = await loadWorkspaceCatalog()
const next = dedupeWorkspaces([
...current.workspaces,
{
path: target,
name:
readString(input.name) ||
(current.workspaces.length === 0 ? 'Home' : extractFolderName(target)),
},
])
await writeWorkspaceState({ workspaces: next, last: target })
return loadWorkspaceCatalog()
}
export const Route = createFileRoute('/api/workspace')({ export const Route = createFileRoute('/api/workspace')({
server: { server: {
handlers: { handlers: {
@@ -100,12 +401,7 @@ export const Route = createFileRoute('/api/workspace')({
return json({ ok: false, error: 'Unauthorized' }, { status: 401 }) return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
} }
try { try {
const url = new URL(request.url) return json(await loadWorkspaceCatalog())
const savedPath = url.searchParams.get('saved') || undefined
const result = await detectWorkspace(savedPath)
return json(result)
} catch (err) { } catch (err) {
return json( return json(
{ {
@@ -113,12 +409,36 @@ export const Route = createFileRoute('/api/workspace')({
folderName: '', folderName: '',
source: 'error', source: 'error',
isValid: false, isValid: false,
workspaces: [],
last: '',
error: err instanceof Error ? err.message : String(err), error: err instanceof Error ? err.message : String(err),
}, },
{ status: 500 }, { status: 500 },
) )
} }
}, },
POST: async ({ request }) => {
if (!isAuthenticated(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
const contentTypeError = requireJsonContentType(request)
if (contentTypeError) return contentTypeError
try {
const body = (await request.json()) as {
path?: string
name?: string
}
return json(await saveWorkspaceSelection(body))
} catch (err) {
return json(
{
ok: false,
error: err instanceof Error ? err.message : String(err),
},
{ status: 400 },
)
}
},
}, },
}, },
}) })

View File

@@ -0,0 +1,32 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const source = () =>
readFileSync(
resolve(process.cwd(), 'src/screens/chat/components/chat-composer.tsx'),
'utf8',
)
describe('ChatComposer context controls', () => {
it('wires profile selection through the existing profile APIs', () => {
const src = source()
expect(src).toContain("fetch('/api/profiles/list')")
expect(src).toContain("fetch('/api/profiles/activate'")
expect(src).toContain('Activated profile')
})
it('surfaces workspace and reasoning controls next to the model picker', () => {
const src = source()
expect(src).toContain("fetch('/api/workspace')")
expect(src).toContain('Workspace context')
expect(src).toContain('workspaceSelectMutation')
expect(src).toContain('workspaceEntries.map')
expect(src).toContain('SEARCH_MODAL_EVENTS.TOGGLE_FILE_EXPLORER')
expect(src).toContain('Reasoning effort')
expect(src).toContain("['medium', 'Medium']")
expect(src).toContain("['high', 'High']")
})
})

View File

@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { import {
getZeroForkModelInfoFlags,
MODEL_SWITCH_BLOCKED_TOAST, MODEL_SWITCH_BLOCKED_TOAST,
getZeroForkModelInfoFlags,
shouldBlockZeroForkModelSwitch, shouldBlockZeroForkModelSwitch,
} from './chat-composer-model-switch' } from './chat-composer-model-switch'

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,10 @@
// Module-level local model override — set by composer when user picks a local model.
// Avoids prop threading. Reset when switching back to cloud models.
//
// Lives in its own file so chat-screen.tsx remains a components-only module —
// React Fast Refresh requires modules to export only components, otherwise
// HMR falls back to full-page reload.
export let _localModelOverride = ''
export function setLocalModelOverride(model: string) {
_localModelOverride = model
}

View File

@@ -45,7 +45,11 @@ const TEXT_REWRITE_EXTENSIONS = new Set([
]) ])
function getHermesRoot(): string { function getHermesRoot(): string {
return process.env.HERMES_HOME ?? path.join(os.homedir(), '.hermes') return (
process.env.HERMES_HOME ??
process.env.CLAUDE_HOME ??
path.join(os.homedir(), '.hermes')
)
} }
function getClaudeRoot(): string { function getClaudeRoot(): string {
@@ -95,9 +99,10 @@ function safeReadText(filePath: string): string {
function readYamlConfig(configPath: string): Record<string, unknown> { function readYamlConfig(configPath: string): Record<string, unknown> {
if (!fs.existsSync(configPath)) return {} if (!fs.existsSync(configPath)) return {}
try { try {
return ( const parsed = YAML.parse(safeReadText(configPath)) as unknown
(YAML.parse(safeReadText(configPath)) as Record<string, unknown>) || {} return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
) ? (parsed as Record<string, unknown>)
: {}
} catch { } catch {
return {} return {}
} }
@@ -312,7 +317,6 @@ export function setActiveProfile(name: string): void {
if (!fs.existsSync(profilePath)) throw new Error('Profile not found') if (!fs.existsSync(profilePath)) throw new Error('Profile not found')
fs.mkdirSync(getClaudeRoot(), { recursive: true }) fs.mkdirSync(getClaudeRoot(), { recursive: true })
fs.writeFileSync(getActiveProfilePath(), `${normalized}\n`, 'utf-8') fs.writeFileSync(getActiveProfilePath(), `${normalized}\n`, 'utf-8')
// eslint-disable-next-line no-console
console.warn( console.warn(
`[profiles] Active profile set to "${normalized}". Restart the Hermes Agent gateway for this profile switch to take effect.`, `[profiles] Active profile set to "${normalized}". Restart the Hermes Agent gateway for this profile switch to take effect.`,
) )

View File

@@ -0,0 +1,59 @@
import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'
/**
* Per-session model preference.
*
* Stored locally in the browser keyed by sessionKey, so a user can pick a
* different model for one chat without affecting the global default in
* `~/.hermes/config.yaml` or any other channel (Telegram, Discord, etc.).
*
* On every send, the workspace passes this value as the `model` field in
* the chat-completion request body. The gateway uses it for that request
* only; nothing else mutates.
*
* Cleared automatically when the session is deleted.
*/
type State = {
models: Record<string, string>
}
type Actions = {
getModel: (sessionKey: string | null | undefined) => string | undefined
setModel: (sessionKey: string, model: string) => void
clearModel: (sessionKey: string) => void
}
export const useSessionModelStore = create<State & Actions>()(
persist(
(set, get) => ({
models: {},
getModel: (sessionKey) => {
if (!sessionKey) return undefined
return get().models[sessionKey]
},
setModel: (sessionKey, model) => {
if (!sessionKey) return
const trimmed = model.trim()
if (!trimmed) return
set((state) => ({
models: { ...state.models, [sessionKey]: trimmed },
}))
},
clearModel: (sessionKey) => {
if (!sessionKey) return
set((state) => {
if (!(sessionKey in state.models)) return state
const next = { ...state.models }
delete next[sessionKey]
return { models: next }
})
},
}),
{
name: 'hermes-session-model',
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({ models: state.models }),
},
),
)