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:
committed by
GitHub
parent
acaa4e5081
commit
b72b47544d
@@ -42,10 +42,7 @@ describe('ensureWorkspacePath (#121)', () => {
|
||||
// The new check (path.relative) correctly rejects it.
|
||||
const rel = path.relative(root, sibling)
|
||||
const escapes =
|
||||
!rel ||
|
||||
rel.startsWith('..') ||
|
||||
rel === '..' ||
|
||||
path.isAbsolute(rel)
|
||||
!rel || rel.startsWith('..') || rel === '..' || path.isAbsolute(rel)
|
||||
expect(escapes).toBe(true)
|
||||
})
|
||||
|
||||
@@ -57,10 +54,7 @@ describe('ensureWorkspacePath (#121)', () => {
|
||||
|
||||
const rel = path.relative(root, escape)
|
||||
expect(
|
||||
!rel ||
|
||||
rel.startsWith('..') ||
|
||||
rel === '..' ||
|
||||
path.isAbsolute(rel),
|
||||
!rel || rel.startsWith('..') || rel === '..' || path.isAbsolute(rel),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
@@ -72,10 +66,7 @@ describe('ensureWorkspacePath (#121)', () => {
|
||||
|
||||
const rel = path.relative(root, inside)
|
||||
expect(
|
||||
!rel ||
|
||||
rel.startsWith('..') ||
|
||||
rel === '..' ||
|
||||
path.isAbsolute(rel),
|
||||
!rel || rel.startsWith('..') || rel === '..' || path.isAbsolute(rel),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
|
||||
153
src/routes/api/-workspace.test.ts
Normal file
153
src/routes/api/-workspace.test.ts
Normal 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`)
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,3 @@
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import fs from 'node:fs/promises'
|
||||
import { execFile } from 'node:child_process'
|
||||
@@ -16,16 +15,10 @@ import {
|
||||
requireJsonContentType,
|
||||
safeErrorMessage,
|
||||
} from '../../server/rate-limit'
|
||||
import { loadWorkspaceCatalog } from './workspace'
|
||||
|
||||
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 = {
|
||||
name: string
|
||||
path: string
|
||||
@@ -43,14 +36,22 @@ type FileEntry = {
|
||||
* form rejects any candidate that escapes the root via `..` segments or
|
||||
* 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()
|
||||
if (!raw) return WORKSPACE_ROOT
|
||||
if (!raw) return workspaceRoot
|
||||
const resolved = path.isAbsolute(raw)
|
||||
? path.resolve(raw)
|
||||
: path.resolve(WORKSPACE_ROOT, raw)
|
||||
if (resolved === WORKSPACE_ROOT) return resolved
|
||||
const relative = path.relative(WORKSPACE_ROOT, resolved)
|
||||
: path.resolve(workspaceRoot, raw)
|
||||
if (resolved === workspaceRoot) return resolved
|
||||
const relative = path.relative(workspaceRoot, resolved)
|
||||
if (
|
||||
!relative ||
|
||||
relative.startsWith('..') ||
|
||||
@@ -62,8 +63,8 @@ function ensureWorkspacePath(input: string) {
|
||||
return resolved
|
||||
}
|
||||
|
||||
function toRelative(resolvedPath: string) {
|
||||
const relative = path.relative(WORKSPACE_ROOT, resolvedPath)
|
||||
function toRelative(resolvedPath: string, workspaceRoot: string) {
|
||||
const relative = path.relative(workspaceRoot, resolvedPath)
|
||||
return relative || ''
|
||||
}
|
||||
|
||||
@@ -121,6 +122,7 @@ type ReadDirectoryOptions = {
|
||||
maxDepth: number
|
||||
maxEntries: number | null
|
||||
countedEntries: { value: number }
|
||||
workspaceRoot: string
|
||||
}
|
||||
|
||||
function parseMaxDepth(input: string | null): number | null {
|
||||
@@ -163,7 +165,7 @@ async function readDirectory(
|
||||
|
||||
if (IGNORED_DIRS.has(entry.name)) continue
|
||||
const fullPath = path.join(dirPath, entry.name)
|
||||
const relativePath = toRelative(fullPath)
|
||||
const relativePath = toRelative(fullPath, options.workspaceRoot)
|
||||
try {
|
||||
const stats = await fs.stat(fullPath)
|
||||
if (entry.isDirectory()) {
|
||||
@@ -195,9 +197,9 @@ async function readDirectory(
|
||||
return sortEntries(mapped)
|
||||
}
|
||||
|
||||
async function readGlobDirectory(globPath: string) {
|
||||
async function readGlobDirectory(globPath: string, workspaceRoot: string) {
|
||||
const { directoryPath, regex } = parseGlobPattern(globPath)
|
||||
const resolvedDirectory = ensureWorkspacePath(directoryPath)
|
||||
const resolvedDirectory = ensureWorkspacePath(directoryPath, workspaceRoot)
|
||||
const entries = await fs.readdir(resolvedDirectory, { withFileTypes: true })
|
||||
const mapped: Array<FileEntry> = []
|
||||
|
||||
@@ -207,7 +209,7 @@ async function readGlobDirectory(globPath: string) {
|
||||
const stats = await fs.stat(fullPath)
|
||||
mapped.push({
|
||||
name: entry.name,
|
||||
path: toRelative(fullPath),
|
||||
path: toRelative(fullPath, workspaceRoot),
|
||||
type: entry.isDirectory() ? 'folder' : 'file',
|
||||
size: stats.size,
|
||||
modifiedAt: stats.mtime.toISOString(),
|
||||
@@ -215,7 +217,7 @@ async function readGlobDirectory(globPath: string) {
|
||||
}
|
||||
|
||||
return {
|
||||
root: toRelative(resolvedDirectory),
|
||||
root: toRelative(resolvedDirectory, workspaceRoot),
|
||||
entries: sortEntries(mapped),
|
||||
}
|
||||
}
|
||||
@@ -260,16 +262,21 @@ export const Route = createFileRoute('/api/files')({
|
||||
url.searchParams.get('maxEntries'),
|
||||
)
|
||||
|
||||
const workspaceRoot = await getWorkspaceRoot()
|
||||
|
||||
if (action === 'list' && hasGlob(inputPath)) {
|
||||
const globListing = await readGlobDirectory(inputPath)
|
||||
const globListing = await readGlobDirectory(
|
||||
inputPath,
|
||||
workspaceRoot,
|
||||
)
|
||||
return json({
|
||||
root: globListing.root,
|
||||
base: WORKSPACE_ROOT,
|
||||
base: workspaceRoot,
|
||||
entries: globListing.entries,
|
||||
})
|
||||
}
|
||||
|
||||
const resolvedPath = ensureWorkspacePath(inputPath)
|
||||
const resolvedPath = ensureWorkspacePath(inputPath, workspaceRoot)
|
||||
|
||||
if (action === 'read') {
|
||||
const buffer = await fs.readFile(resolvedPath)
|
||||
@@ -277,13 +284,13 @@ export const Route = createFileRoute('/api/files')({
|
||||
const mime = getMimeType(resolvedPath)
|
||||
return json({
|
||||
type: 'image',
|
||||
path: toRelative(resolvedPath),
|
||||
path: toRelative(resolvedPath, workspaceRoot),
|
||||
content: `data:${mime};base64,${buffer.toString('base64')}`,
|
||||
})
|
||||
}
|
||||
return json({
|
||||
type: 'text',
|
||||
path: toRelative(resolvedPath),
|
||||
path: toRelative(resolvedPath, workspaceRoot),
|
||||
content: buffer.toString('utf8'),
|
||||
})
|
||||
}
|
||||
@@ -304,10 +311,11 @@ export const Route = createFileRoute('/api/files')({
|
||||
maxDepth: maxDepthParam ?? MAX_DIRECTORY_DEPTH,
|
||||
maxEntries: maxEntriesParam,
|
||||
countedEntries: { value: 0 },
|
||||
workspaceRoot,
|
||||
})
|
||||
return json({
|
||||
root: toRelative(resolvedPath),
|
||||
base: WORKSPACE_ROOT,
|
||||
root: toRelative(resolvedPath, workspaceRoot),
|
||||
base: workspaceRoot,
|
||||
entries: tree,
|
||||
})
|
||||
} catch (err) {
|
||||
@@ -324,6 +332,7 @@ export const Route = createFileRoute('/api/files')({
|
||||
}
|
||||
|
||||
try {
|
||||
const workspaceRoot = await getWorkspaceRoot()
|
||||
const contentType = request.headers.get('content-type') || ''
|
||||
if (!contentType.includes('multipart/form-data')) {
|
||||
const csrfCheck = requireJsonContentType(request)
|
||||
@@ -340,7 +349,10 @@ export const Route = createFileRoute('/api/files')({
|
||||
if (!(file instanceof File)) {
|
||||
return json({ error: 'Missing file' }, { status: 400 })
|
||||
}
|
||||
const resolvedTarget = ensureWorkspacePath(targetPath)
|
||||
const resolvedTarget = ensureWorkspacePath(
|
||||
targetPath,
|
||||
workspaceRoot,
|
||||
)
|
||||
const isDir = (await fs.stat(resolvedTarget)).isDirectory()
|
||||
const destination = isDir
|
||||
? path.join(resolvedTarget, file.name)
|
||||
@@ -348,7 +360,10 @@ export const Route = createFileRoute('/api/files')({
|
||||
await fs.mkdir(path.dirname(destination), { recursive: true })
|
||||
const buffer = Buffer.from(await file.arrayBuffer())
|
||||
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<
|
||||
@@ -358,24 +373,36 @@ export const Route = createFileRoute('/api/files')({
|
||||
const action = typeof body.action === 'string' ? body.action : 'write'
|
||||
|
||||
if (action === 'mkdir') {
|
||||
const dirPath = ensureWorkspacePath(String(body.path || ''))
|
||||
const dirPath = ensureWorkspacePath(
|
||||
String(body.path || ''),
|
||||
workspaceRoot,
|
||||
)
|
||||
await fs.mkdir(dirPath, { recursive: true })
|
||||
return json({ ok: true, path: toRelative(dirPath) })
|
||||
return json({ ok: true, path: toRelative(dirPath, workspaceRoot) })
|
||||
}
|
||||
|
||||
if (action === 'rename') {
|
||||
const fromPath = ensureWorkspacePath(String(body.from || ''))
|
||||
const toPath = ensureWorkspacePath(String(body.to || ''))
|
||||
const fromPath = ensureWorkspacePath(
|
||||
String(body.from || ''),
|
||||
workspaceRoot,
|
||||
)
|
||||
const toPath = ensureWorkspacePath(
|
||||
String(body.to || ''),
|
||||
workspaceRoot,
|
||||
)
|
||||
await fs.mkdir(path.dirname(toPath), { recursive: true })
|
||||
await fs.rename(fromPath, toPath)
|
||||
return json({ ok: true, path: toRelative(toPath) })
|
||||
return json({ ok: true, path: toRelative(toPath, workspaceRoot) })
|
||||
}
|
||||
|
||||
if (action === 'delete') {
|
||||
if (!requireLocalOrAuth(request)) {
|
||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const targetPath = ensureWorkspacePath(String(body.path || ''))
|
||||
const targetPath = ensureWorkspacePath(
|
||||
String(body.path || ''),
|
||||
workspaceRoot,
|
||||
)
|
||||
try {
|
||||
// Try macOS trash command first
|
||||
await execFileAsync('trash', [targetPath])
|
||||
@@ -386,11 +413,14 @@ export const Route = createFileRoute('/api/files')({
|
||||
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 : ''
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true })
|
||||
await fs.writeFile(filePath, content, 'utf8')
|
||||
return json({ ok: true, path: toRelative(filePath) })
|
||||
return json({ ok: true, path: toRelative(filePath, workspaceRoot) })
|
||||
} catch (err) {
|
||||
return json({ error: safeErrorMessage(err) }, { status: 500 })
|
||||
}
|
||||
|
||||
@@ -1,17 +1,141 @@
|
||||
/**
|
||||
* Phase 2.6: Workspace detection API
|
||||
* Auto-detects workspace from Hermes config, env, or default paths
|
||||
* Hermes workspace API.
|
||||
*
|
||||
* 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 path from 'node:path'
|
||||
import fs from 'node:fs/promises'
|
||||
import YAML from 'yaml'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
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 {
|
||||
const parts = fullPath.replace(/\\/g, '/').split('/')
|
||||
return parts[parts.length - 1] || 'workspace'
|
||||
const parts = fullPath.replace(/\\/g, '/').split('/').filter(Boolean)
|
||||
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> {
|
||||
@@ -23,24 +147,174 @@ async function isValidDirectory(dirPath: string): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
async function detectWorkspace(savedPath?: string): Promise<{
|
||||
path: string
|
||||
folderName: string
|
||||
source: string
|
||||
isValid: boolean
|
||||
}> {
|
||||
// Priority 1: Saved path from localStorage (passed via query param)
|
||||
if (savedPath) {
|
||||
const isValid = await isValidDirectory(savedPath)
|
||||
if (isValid) {
|
||||
return {
|
||||
path: savedPath,
|
||||
folderName: extractFolderName(savedPath),
|
||||
source: 'localStorage',
|
||||
isValid: true,
|
||||
async function readYamlConfig(
|
||||
configPath: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const raw = await fs.readFile(configPath, 'utf-8')
|
||||
const parsed = YAML.parse(raw)
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -55,43 +329,70 @@ async function detectWorkspace(savedPath?: string): Promise<{
|
||||
folderName: extractFolderName(envWorkspace),
|
||||
source: 'env',
|
||||
isValid: true,
|
||||
workspaces,
|
||||
last: envWorkspace,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 3: Default Claude workspace path
|
||||
const defaultPath = process.env.HERMES_HOME ?? process.env.CLAUDE_HOME ?? path.join(os.homedir(), '.hermes')
|
||||
const defaultValid = await isValidDirectory(defaultPath)
|
||||
if (defaultValid) {
|
||||
return {
|
||||
path: defaultPath,
|
||||
folderName: extractFolderName(defaultPath),
|
||||
source: 'default',
|
||||
isValid: true,
|
||||
const savedLast = readString(state.last)
|
||||
const lastFromFile = await (async () => {
|
||||
try {
|
||||
return (await fs.readFile(lastWorkspaceFile(), 'utf-8')).trim()
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
})()
|
||||
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 {
|
||||
path: '',
|
||||
folderName: '',
|
||||
source: 'none',
|
||||
isValid: false,
|
||||
path: activePath,
|
||||
folderName: active ? active.name || extractFolderName(active.path) : '',
|
||||
source:
|
||||
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')({
|
||||
server: {
|
||||
handlers: {
|
||||
@@ -100,12 +401,7 @@ export const Route = createFileRoute('/api/workspace')({
|
||||
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
try {
|
||||
const url = new URL(request.url)
|
||||
const savedPath = url.searchParams.get('saved') || undefined
|
||||
|
||||
const result = await detectWorkspace(savedPath)
|
||||
|
||||
return json(result)
|
||||
return json(await loadWorkspaceCatalog())
|
||||
} catch (err) {
|
||||
return json(
|
||||
{
|
||||
@@ -113,12 +409,36 @@ export const Route = createFileRoute('/api/workspace')({
|
||||
folderName: '',
|
||||
source: 'error',
|
||||
isValid: false,
|
||||
workspaces: [],
|
||||
last: '',
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
{ 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 },
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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']")
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
getZeroForkModelInfoFlags,
|
||||
MODEL_SWITCH_BLOCKED_TOAST,
|
||||
getZeroForkModelInfoFlags,
|
||||
shouldBlockZeroForkModelSwitch,
|
||||
} from './chat-composer-model-switch'
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
10
src/screens/chat/local-model-override.ts
Normal file
10
src/screens/chat/local-model-override.ts
Normal 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
|
||||
}
|
||||
@@ -45,7 +45,11 @@ const TEXT_REWRITE_EXTENSIONS = new Set([
|
||||
])
|
||||
|
||||
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 {
|
||||
@@ -95,9 +99,10 @@ function safeReadText(filePath: string): string {
|
||||
function readYamlConfig(configPath: string): Record<string, unknown> {
|
||||
if (!fs.existsSync(configPath)) return {}
|
||||
try {
|
||||
return (
|
||||
(YAML.parse(safeReadText(configPath)) as Record<string, unknown>) || {}
|
||||
)
|
||||
const parsed = YAML.parse(safeReadText(configPath)) as unknown
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
@@ -312,7 +317,6 @@ export function setActiveProfile(name: string): void {
|
||||
if (!fs.existsSync(profilePath)) throw new Error('Profile not found')
|
||||
fs.mkdirSync(getClaudeRoot(), { recursive: true })
|
||||
fs.writeFileSync(getActiveProfilePath(), `${normalized}\n`, 'utf-8')
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`[profiles] Active profile set to "${normalized}". Restart the Hermes Agent gateway for this profile switch to take effect.`,
|
||||
)
|
||||
|
||||
59
src/stores/session-model-store.ts
Normal file
59
src/stores/session-model-store.ts
Normal 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 }),
|
||||
},
|
||||
),
|
||||
)
|
||||
Reference in New Issue
Block a user