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.
|
// 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)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
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 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 })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 { 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
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 {
|
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.`,
|
||||||
)
|
)
|
||||||
|
|||||||
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