Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b2dd9f6c1 | ||
|
|
13022f3e2a | ||
|
|
9c051f57c3 | ||
|
|
e24c935cf3 | ||
|
|
b1af653bf6 | ||
|
|
e372803554 | ||
|
|
d0e017bac8 |
@@ -679,15 +679,28 @@ def recover_with_credential_pool(
|
||||
# long-running TUI sessions stuck on stale tokens until the user
|
||||
# exited and reopened.
|
||||
is_entitlement = agent._is_entitlement_failure(error_context, status_code)
|
||||
_auth_haystack = " ".join(
|
||||
str(error_context.get(k) or "").lower()
|
||||
for k in ("message", "reason", "code", "error")
|
||||
if isinstance(error_context, dict)
|
||||
)
|
||||
if (
|
||||
not is_entitlement
|
||||
and status_code == 403
|
||||
and "oauth authentication is currently not allowed for this organization" in _auth_haystack
|
||||
):
|
||||
is_entitlement = True
|
||||
if (
|
||||
not is_entitlement
|
||||
and status_code == 403
|
||||
and (agent.provider or "") == "anthropic"
|
||||
and getattr(agent, "api_mode", "") == "anthropic_messages"
|
||||
):
|
||||
is_entitlement = True
|
||||
if not is_entitlement and status_code == 403 and (agent.provider or "") == "xai-oauth":
|
||||
_disambiguator_haystack = " ".join(
|
||||
str(error_context.get(k) or "").lower()
|
||||
for k in ("message", "reason", "code", "error")
|
||||
if isinstance(error_context, dict)
|
||||
)
|
||||
_is_xai_auth_failure = (
|
||||
"[wke=unauthenticated:" in _disambiguator_haystack
|
||||
or "oauth2 access token could not be validated" in _disambiguator_haystack
|
||||
"[wke=unauthenticated:" in _auth_haystack
|
||||
or "oauth2 access token could not be validated" in _auth_haystack
|
||||
)
|
||||
if not _is_xai_auth_failure:
|
||||
is_entitlement = True
|
||||
|
||||
@@ -208,6 +208,41 @@ def is_stale_connection_error(exc: BaseException) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def is_streaming_access_denied_error(exc: BaseException) -> bool:
|
||||
"""Return True when AWS denied the ``bedrock:InvokeModelWithResponseStream`` action.
|
||||
|
||||
IAM policies scoped to ``bedrock:InvokeModel`` only (a common least-privilege
|
||||
setup) reject ``converse_stream()`` with an ``AccessDeniedException`` whose
|
||||
message names the streaming action, e.g.::
|
||||
|
||||
User: arn:aws:iam::123456789012:user/x is not authorized to perform:
|
||||
bedrock:InvokeModelWithResponseStream on resource: ...
|
||||
|
||||
This is permanent for the session — retrying the stream can never succeed —
|
||||
so callers should flip to the non-streaming ``converse()`` path (which maps
|
||||
to ``bedrock:InvokeModel``) instead of burning retries.
|
||||
|
||||
Detection is deliberately message-based: boto3 surfaces this as a
|
||||
``ClientError`` with ``Error.Code == "AccessDeniedException"``, and the
|
||||
AnthropicBedrock SDK wraps the same AWS response in its own exception
|
||||
types, but both preserve the action name in the message.
|
||||
"""
|
||||
msg = str(exc).lower()
|
||||
if "invokemodelwithresponsestream" not in msg:
|
||||
return False
|
||||
# ClientError with an explicit access-denied code is the canonical form.
|
||||
try:
|
||||
from botocore.exceptions import ClientError
|
||||
except ImportError: # pragma: no cover — botocore always present with boto3
|
||||
ClientError = None # type: ignore[assignment]
|
||||
if ClientError is not None and isinstance(exc, ClientError):
|
||||
code = (getattr(exc, "response", None) or {}).get("Error", {}).get("Code", "")
|
||||
return code in ("AccessDeniedException", "UnauthorizedException")
|
||||
# Wrapped forms (e.g. AnthropicBedrock SDK PermissionDeniedError) — match
|
||||
# on the authorization-failure phrasing AWS uses.
|
||||
return "not authorized" in msg or "accessdenied" in msg
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AWS credential detection
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1003,6 +1038,16 @@ def call_converse_stream(
|
||||
try:
|
||||
response = client.converse_stream(**kwargs)
|
||||
except Exception as exc:
|
||||
if is_streaming_access_denied_error(exc):
|
||||
# IAM allows bedrock:InvokeModel but not
|
||||
# InvokeModelWithResponseStream — permanent for this session.
|
||||
# Fall back to the non-streaming converse() path.
|
||||
logger.info(
|
||||
"bedrock: converse_stream denied by IAM on (region=%s, model=%s) — "
|
||||
"falling back to non-streaming converse().",
|
||||
region, model,
|
||||
)
|
||||
return normalize_converse_response(client.converse(**kwargs))
|
||||
if is_stale_connection_error(exc):
|
||||
logger.warning(
|
||||
"bedrock: stale-connection error on converse_stream(region=%s, "
|
||||
|
||||
@@ -1615,6 +1615,8 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
|
||||
_get_bedrock_runtime_client,
|
||||
invalidate_runtime_client,
|
||||
is_stale_connection_error,
|
||||
is_streaming_access_denied_error,
|
||||
normalize_converse_response,
|
||||
stream_converse_with_callbacks,
|
||||
)
|
||||
region = api_kwargs.pop("__bedrock_region__", "us-east-1")
|
||||
@@ -1623,6 +1625,29 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
|
||||
try:
|
||||
raw_response = client.converse_stream(**api_kwargs)
|
||||
except Exception as _bedrock_exc:
|
||||
# IAM policies scoped to bedrock:InvokeModel only (no
|
||||
# InvokeModelWithResponseStream) reject converse_stream()
|
||||
# with AccessDeniedException. That denial is permanent for
|
||||
# the session — fall back to the non-streaming converse()
|
||||
# inline (it maps to bedrock:InvokeModel) and disable
|
||||
# streaming for subsequent calls so we don't re-fail every
|
||||
# turn.
|
||||
if is_streaming_access_denied_error(_bedrock_exc):
|
||||
agent._disable_streaming = True
|
||||
agent._safe_print(
|
||||
"\n⚠ AWS IAM denied bedrock:InvokeModelWithResponseStream — "
|
||||
"falling back to non-streaming InvokeModel.\n"
|
||||
" Grant that action to restore streaming output.\n"
|
||||
)
|
||||
logger.info(
|
||||
"bedrock: converse_stream denied by IAM (%s) — "
|
||||
"using non-streaming converse() for this session.",
|
||||
type(_bedrock_exc).__name__,
|
||||
)
|
||||
result["response"] = normalize_converse_response(
|
||||
client.converse(**api_kwargs)
|
||||
)
|
||||
return
|
||||
# Evict the cached client on stale-connection failures
|
||||
# so the outer retry loop builds a fresh client/pool.
|
||||
if is_stale_connection_error(_bedrock_exc):
|
||||
@@ -2424,9 +2449,34 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
|
||||
"stream" in _err_lower
|
||||
and "not supported" in _err_lower
|
||||
)
|
||||
if _is_stream_unsupported:
|
||||
# AWS Bedrock (AnthropicBedrock SDK path): IAM policies
|
||||
# with bedrock:InvokeModel but not
|
||||
# InvokeModelWithResponseStream reject messages.stream()
|
||||
# with a permission error naming the streaming action.
|
||||
# Permanent for the session — flip to non-streaming
|
||||
# (messages.create() maps to bedrock:InvokeModel).
|
||||
_is_bedrock_stream_denied = False
|
||||
if (
|
||||
not _is_stream_unsupported
|
||||
and "invokemodelwithresponsestream" in _err_lower
|
||||
):
|
||||
# Cheap message pre-check before importing the
|
||||
# adapter — bedrock_adapter triggers a lazy boto3
|
||||
# install at import time, which must not run for
|
||||
# unrelated providers' stream errors.
|
||||
from agent.bedrock_adapter import (
|
||||
is_streaming_access_denied_error,
|
||||
)
|
||||
_is_bedrock_stream_denied = (
|
||||
is_streaming_access_denied_error(e)
|
||||
)
|
||||
if _is_stream_unsupported or _is_bedrock_stream_denied:
|
||||
agent._disable_streaming = True
|
||||
agent._safe_print(
|
||||
"\n⚠ AWS IAM denied bedrock:InvokeModelWithResponseStream. "
|
||||
"Switching to non-streaming.\n"
|
||||
" Grant that action to restore streaming output.\n"
|
||||
if _is_bedrock_stream_denied else
|
||||
"\n⚠ Streaming is not supported for this "
|
||||
"model/provider. Switching to non-streaming.\n"
|
||||
" To avoid this delay, set display.streaming: false "
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
'use strict'
|
||||
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const { resolveDirectoryForIpc } = require('./hardening.cjs')
|
||||
|
||||
const FS_READDIR_STAT_CONCURRENCY = 16
|
||||
|
||||
// Always-hidden noise (covers non-git projects too; gitignore catches many of
|
||||
// these, but the project tree should keep the same hygiene without one).
|
||||
const FS_READDIR_HIDDEN = new Set([
|
||||
'.git',
|
||||
'.hg',
|
||||
'.svn',
|
||||
'.cache',
|
||||
'.next',
|
||||
'.turbo',
|
||||
'.venv',
|
||||
'__pycache__',
|
||||
'build',
|
||||
'dist',
|
||||
'node_modules',
|
||||
'target',
|
||||
'venv'
|
||||
])
|
||||
|
||||
function direntIsDirectory(dirent) {
|
||||
return typeof dirent.isDirectory === 'function' && dirent.isDirectory()
|
||||
}
|
||||
|
||||
function direntIsFile(dirent) {
|
||||
return typeof dirent.isFile === 'function' && dirent.isFile()
|
||||
}
|
||||
|
||||
function direntIsSymbolicLink(dirent) {
|
||||
return typeof dirent.isSymbolicLink === 'function' && dirent.isSymbolicLink()
|
||||
}
|
||||
|
||||
function shouldStatDirent(dirent) {
|
||||
if (direntIsDirectory(dirent)) return false
|
||||
|
||||
return direntIsSymbolicLink(dirent) || !direntIsFile(dirent)
|
||||
}
|
||||
|
||||
async function entryForDirent(dirent, resolved, fsImpl) {
|
||||
const fullPath = path.join(resolved, dirent.name)
|
||||
let isDirectory = direntIsDirectory(dirent)
|
||||
|
||||
if (!isDirectory && shouldStatDirent(dirent)) {
|
||||
try {
|
||||
isDirectory = (await fsImpl.promises.stat(fullPath)).isDirectory()
|
||||
} catch {
|
||||
isDirectory = false
|
||||
}
|
||||
}
|
||||
|
||||
return { name: dirent.name, path: fullPath, isDirectory }
|
||||
}
|
||||
|
||||
async function mapWithStatConcurrency(items, mapper) {
|
||||
const results = new Array(items.length)
|
||||
let nextIndex = 0
|
||||
|
||||
async function runWorker() {
|
||||
while (nextIndex < items.length) {
|
||||
const index = nextIndex
|
||||
nextIndex += 1
|
||||
results[index] = await mapper(items[index])
|
||||
}
|
||||
}
|
||||
|
||||
const workerCount = Math.min(FS_READDIR_STAT_CONCURRENCY, items.length)
|
||||
const workers = Array.from({ length: workerCount }, () => runWorker())
|
||||
await Promise.all(workers)
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
async function readDirForIpc(dirPath, options = {}) {
|
||||
const fsImpl = options.fs || fs
|
||||
let resolved
|
||||
|
||||
try {
|
||||
;({ resolvedPath: resolved } = await resolveDirectoryForIpc(dirPath, {
|
||||
fs: fsImpl,
|
||||
purpose: 'Directory read'
|
||||
}))
|
||||
} catch (error) {
|
||||
return { entries: [], error: error?.code || 'read-error' }
|
||||
}
|
||||
|
||||
try {
|
||||
const dirents = await fsImpl.promises.readdir(resolved, { withFileTypes: true })
|
||||
const visibleDirents = dirents.filter(dirent => !FS_READDIR_HIDDEN.has(dirent.name))
|
||||
const entries = await mapWithStatConcurrency(visibleDirents, dirent =>
|
||||
entryForDirent(dirent, resolved, fsImpl)
|
||||
)
|
||||
|
||||
entries.sort((a, b) => Number(b.isDirectory) - Number(a.isDirectory) || a.name.localeCompare(b.name))
|
||||
|
||||
return { entries }
|
||||
} catch (error) {
|
||||
return { entries: [], error: error?.code || 'read-error' }
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
readDirForIpc
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
'use strict'
|
||||
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
const test = require('node:test')
|
||||
const { pathToFileURL } = require('node:url')
|
||||
|
||||
const { readDirForIpc } = require('./fs-read-dir.cjs')
|
||||
|
||||
function mkTmpDir() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-fs-read-dir-'))
|
||||
}
|
||||
|
||||
function fakeDirent(name, flags = {}) {
|
||||
return {
|
||||
name,
|
||||
isDirectory: () => Boolean(flags.directory),
|
||||
isFile: () => Boolean(flags.file),
|
||||
isSymbolicLink: () => Boolean(flags.symlink)
|
||||
}
|
||||
}
|
||||
|
||||
test('readDirForIpc hides noisy directories and files from the project tree', async () => {
|
||||
const root = mkTmpDir()
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.join(root, 'node_modules'))
|
||||
fs.mkdirSync(path.join(root, 'src'))
|
||||
fs.writeFileSync(path.join(root, 'target'), 'hidden file')
|
||||
fs.writeFileSync(path.join(root, 'README.md'), 'visible file')
|
||||
|
||||
const result = await readDirForIpc(root)
|
||||
|
||||
assert.equal(result.error, undefined)
|
||||
assert.deepEqual(
|
||||
result.entries.map(entry => entry.name),
|
||||
['src', 'README.md']
|
||||
)
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('readDirForIpc filters a hidden basename whether it is a file or directory', async () => {
|
||||
const dirRoot = mkTmpDir()
|
||||
const fileRoot = mkTmpDir()
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.join(dirRoot, 'node_modules'))
|
||||
fs.writeFileSync(path.join(dirRoot, 'visible.txt'), 'visible')
|
||||
fs.writeFileSync(path.join(fileRoot, 'node_modules'), 'hidden file')
|
||||
fs.writeFileSync(path.join(fileRoot, 'visible.txt'), 'visible')
|
||||
|
||||
assert.deepEqual(
|
||||
(await readDirForIpc(dirRoot)).entries.map(entry => entry.name),
|
||||
['visible.txt']
|
||||
)
|
||||
assert.deepEqual(
|
||||
(await readDirForIpc(fileRoot)).entries.map(entry => entry.name),
|
||||
['visible.txt']
|
||||
)
|
||||
} finally {
|
||||
fs.rmSync(dirRoot, { recursive: true, force: true })
|
||||
fs.rmSync(fileRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('readDirForIpc returns directories before files and sorts by name within groups', async () => {
|
||||
const root = mkTmpDir()
|
||||
|
||||
try {
|
||||
fs.writeFileSync(path.join(root, 'z.txt'), 'z')
|
||||
fs.mkdirSync(path.join(root, 'src'))
|
||||
fs.writeFileSync(path.join(root, 'a.txt'), 'a')
|
||||
fs.mkdirSync(path.join(root, 'lib'))
|
||||
|
||||
const result = await readDirForIpc(root)
|
||||
|
||||
assert.equal(result.error, undefined)
|
||||
assert.deepEqual(
|
||||
result.entries.map(entry => entry.name),
|
||||
['lib', 'src', 'a.txt', 'z.txt']
|
||||
)
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('readDirForIpc accepts file URLs for directories', async () => {
|
||||
const root = mkTmpDir()
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.join(root, 'src'))
|
||||
fs.writeFileSync(path.join(root, 'README.md'), 'visible file')
|
||||
|
||||
const result = await readDirForIpc(pathToFileURL(root).toString())
|
||||
|
||||
assert.equal(result.error, undefined)
|
||||
assert.deepEqual(
|
||||
result.entries.map(entry => entry.name),
|
||||
['src', 'README.md']
|
||||
)
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('readDirForIpc returns invalid-path for blank or non-string input', async () => {
|
||||
let readdirCalls = 0
|
||||
const fsImpl = {
|
||||
promises: {
|
||||
readdir: async () => {
|
||||
readdirCalls += 1
|
||||
return []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert.deepEqual(await readDirForIpc('', { fs: fsImpl }), { entries: [], error: 'invalid-path' })
|
||||
assert.deepEqual(await readDirForIpc(' ', { fs: fsImpl }), { entries: [], error: 'invalid-path' })
|
||||
assert.deepEqual(await readDirForIpc(null, { fs: fsImpl }), { entries: [], error: 'invalid-path' })
|
||||
assert.equal(readdirCalls, 0)
|
||||
})
|
||||
|
||||
test('readDirForIpc rejects Windows device paths before readdir', async () => {
|
||||
let readdirCalls = 0
|
||||
const fsImpl = {
|
||||
promises: {
|
||||
readdir: async () => {
|
||||
readdirCalls += 1
|
||||
return []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert.deepEqual(await readDirForIpc('\\\\?\\C:\\secret', { fs: fsImpl }), {
|
||||
entries: [],
|
||||
error: 'device-path'
|
||||
})
|
||||
assert.equal(readdirCalls, 0)
|
||||
})
|
||||
|
||||
test('readDirForIpc returns filesystem error codes instead of throwing', async () => {
|
||||
const root = mkTmpDir()
|
||||
|
||||
try {
|
||||
const result = await readDirForIpc(path.join(root, 'missing'))
|
||||
|
||||
assert.deepEqual(result, { entries: [], error: 'ENOENT' })
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('readDirForIpc marks a symlink to a directory as a directory', async t => {
|
||||
const root = mkTmpDir()
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.join(root, 'actual-dir'))
|
||||
|
||||
try {
|
||||
fs.symlinkSync(path.join(root, 'actual-dir'), path.join(root, 'linked-dir'), 'dir')
|
||||
} catch (error) {
|
||||
if (error?.code === 'EPERM' || error?.code === 'EACCES') {
|
||||
t.skip(`symlink creation is not permitted on this platform (${error.code})`)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
|
||||
const result = await readDirForIpc(root)
|
||||
const linked = result.entries.find(entry => entry.name === 'linked-dir')
|
||||
|
||||
assert.equal(result.error, undefined)
|
||||
assert.equal(linked?.isDirectory, true)
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('readDirForIpc marks a Windows junction to a directory as a directory', async t => {
|
||||
if (process.platform !== 'win32') {
|
||||
t.skip('junctions are a Windows-specific symlink type')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const root = mkTmpDir()
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.join(root, 'actual-dir'))
|
||||
|
||||
try {
|
||||
fs.symlinkSync(path.join(root, 'actual-dir'), path.join(root, 'junction-dir'), 'junction')
|
||||
} catch (error) {
|
||||
if (error?.code === 'EPERM' || error?.code === 'EACCES') {
|
||||
t.skip(`junction creation is not permitted on this platform (${error.code})`)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
|
||||
const result = await readDirForIpc(root)
|
||||
const junction = result.entries.find(entry => entry.name === 'junction-dir')
|
||||
|
||||
assert.equal(result.error, undefined)
|
||||
assert.equal(junction?.isDirectory, true)
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('readDirForIpc allows expanding symlink or junction directories outside the project root', async t => {
|
||||
const root = mkTmpDir()
|
||||
const outside = mkTmpDir()
|
||||
|
||||
try {
|
||||
fs.writeFileSync(path.join(outside, 'outside.txt'), 'ok')
|
||||
|
||||
const linkPath = path.join(root, 'outside-link')
|
||||
try {
|
||||
fs.symlinkSync(outside, linkPath, process.platform === 'win32' ? 'junction' : 'dir')
|
||||
} catch (error) {
|
||||
if (error?.code === 'EPERM' || error?.code === 'EACCES') {
|
||||
t.skip(`directory symlink creation is not permitted on this platform (${error.code})`)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
|
||||
const result = await readDirForIpc(linkPath)
|
||||
|
||||
assert.equal(result.error, undefined)
|
||||
assert.deepEqual(result.entries, [
|
||||
{ name: 'outside.txt', path: path.join(linkPath, 'outside.txt'), isDirectory: false }
|
||||
])
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
fs.rmSync(outside, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('readDirForIpc stats symbolic links and unknown entries without dropping the whole listing', async () => {
|
||||
const input = path.join('virtual-root')
|
||||
const resolved = path.resolve(input)
|
||||
const statCalls = []
|
||||
const fsImpl = {
|
||||
promises: {
|
||||
readdir: async () => [
|
||||
fakeDirent('unknown-entry'),
|
||||
fakeDirent('linked-dir', { symlink: true }),
|
||||
fakeDirent('broken-link', { symlink: true }),
|
||||
fakeDirent('plain.txt', { file: true })
|
||||
],
|
||||
stat: async fullPath => {
|
||||
if (fullPath === resolved) {
|
||||
return { isDirectory: () => true }
|
||||
}
|
||||
|
||||
statCalls.push(fullPath)
|
||||
if (fullPath.endsWith(`${path.sep}linked-dir`)) {
|
||||
return { isDirectory: () => true }
|
||||
}
|
||||
throw Object.assign(new Error('gone'), { code: 'ENOENT' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result = await readDirForIpc(input, { fs: fsImpl })
|
||||
|
||||
assert.equal(result.error, undefined)
|
||||
assert.deepEqual(
|
||||
statCalls.sort(),
|
||||
[path.join(resolved, 'broken-link'), path.join(resolved, 'linked-dir'), path.join(resolved, 'unknown-entry')].sort()
|
||||
)
|
||||
assert.deepEqual(result.entries, [
|
||||
{ name: 'linked-dir', path: path.join(resolved, 'linked-dir'), isDirectory: true },
|
||||
{ name: 'broken-link', path: path.join(resolved, 'broken-link'), isDirectory: false },
|
||||
{ name: 'plain.txt', path: path.join(resolved, 'plain.txt'), isDirectory: false },
|
||||
{ name: 'unknown-entry', path: path.join(resolved, 'unknown-entry'), isDirectory: false }
|
||||
])
|
||||
})
|
||||
|
||||
test('readDirForIpc bounds concurrent stats while preserving complete sorted output', async () => {
|
||||
const input = path.join('virtual-root')
|
||||
const resolved = path.resolve(input)
|
||||
const names = Array.from({ length: 105 }, (_, index) => `entry-${String(104 - index).padStart(3, '0')}`)
|
||||
const failedName = 'entry-100'
|
||||
const directoryNames = new Set(names.filter((_, index) => index % 10 === 4))
|
||||
const successfulDirectoryNames = new Set([...directoryNames].filter(name => name !== failedName))
|
||||
const statCalls = []
|
||||
let active = 0
|
||||
let peak = 0
|
||||
let releaseStats
|
||||
let markFirstStatStarted
|
||||
const statsReleased = new Promise(resolve => {
|
||||
releaseStats = resolve
|
||||
})
|
||||
const firstStatStarted = new Promise(resolve => {
|
||||
markFirstStatStarted = resolve
|
||||
})
|
||||
const fsImpl = {
|
||||
promises: {
|
||||
readdir: async () => [
|
||||
fakeDirent('node_modules', { symlink: true }),
|
||||
...names.map((name, index) => fakeDirent(name, { symlink: index % 2 === 0 }))
|
||||
],
|
||||
stat: async fullPath => {
|
||||
if (fullPath === resolved) {
|
||||
return { isDirectory: () => true }
|
||||
}
|
||||
|
||||
statCalls.push(fullPath)
|
||||
active += 1
|
||||
peak = Math.max(peak, active)
|
||||
markFirstStatStarted()
|
||||
await statsReleased
|
||||
active -= 1
|
||||
|
||||
const name = path.basename(fullPath)
|
||||
if (name === failedName) {
|
||||
throw Object.assign(new Error('gone'), { code: 'ENOENT' })
|
||||
}
|
||||
|
||||
return { isDirectory: () => successfulDirectoryNames.has(name) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const resultPromise = readDirForIpc(input, { fs: fsImpl })
|
||||
await firstStatStarted
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
releaseStats()
|
||||
const result = await resultPromise
|
||||
|
||||
const expectedNames = [
|
||||
...names.filter(name => successfulDirectoryNames.has(name)).sort(),
|
||||
...names.filter(name => !successfulDirectoryNames.has(name)).sort()
|
||||
]
|
||||
|
||||
assert.equal(result.error, undefined)
|
||||
assert.equal(result.entries.length, names.length)
|
||||
assert.equal(statCalls.length, names.length)
|
||||
assert.equal(statCalls.some(fullPath => fullPath.endsWith(`${path.sep}node_modules`)), false)
|
||||
assert.ok(peak > 1, `expected concurrent stats, observed peak ${peak}`)
|
||||
assert.ok(peak <= 16, `expected at most 16 concurrent stats, observed peak ${peak}`)
|
||||
assert.deepEqual(
|
||||
result.entries.map(entry => entry.name),
|
||||
expectedNames
|
||||
)
|
||||
assert.equal(result.entries.find(entry => entry.name === failedName)?.isDirectory, false)
|
||||
assert.equal(
|
||||
result.entries.filter(entry => entry.isDirectory).length,
|
||||
successfulDirectoryNames.size
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,54 @@
|
||||
'use strict'
|
||||
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const { resolveRequestedPathForIpc } = require('./hardening.cjs')
|
||||
|
||||
function findGitRoot(start, fsImpl = fs) {
|
||||
let dir = start
|
||||
|
||||
for (let i = 0; i < 50; i += 1) {
|
||||
try {
|
||||
if (fsImpl.existsSync(path.join(dir, '.git'))) {
|
||||
return dir
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
const parent = path.dirname(dir)
|
||||
|
||||
if (parent === dir) {
|
||||
return null
|
||||
}
|
||||
|
||||
dir = parent
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async function gitRootForIpc(startPath, options = {}) {
|
||||
const fsImpl = options.fs || fs
|
||||
let resolved
|
||||
|
||||
try {
|
||||
resolved = resolveRequestedPathForIpc(startPath, { purpose: 'Git root' })
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = await fsImpl.promises.stat(resolved)
|
||||
const start = stat.isDirectory() ? resolved : path.dirname(resolved)
|
||||
|
||||
return findGitRoot(start, fsImpl)
|
||||
} catch {
|
||||
return findGitRoot(resolved, fsImpl)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
findGitRoot,
|
||||
gitRootForIpc
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
'use strict'
|
||||
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
const test = require('node:test')
|
||||
const { pathToFileURL } = require('node:url')
|
||||
|
||||
const { gitRootForIpc } = require('./git-root.cjs')
|
||||
|
||||
function mkTmpDir() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-git-root-'))
|
||||
}
|
||||
|
||||
test('gitRootForIpc returns null for invalid and device paths', async () => {
|
||||
assert.equal(await gitRootForIpc(''), null)
|
||||
assert.equal(await gitRootForIpc(' '), null)
|
||||
assert.equal(await gitRootForIpc(null), null)
|
||||
assert.equal(await gitRootForIpc('\\\\?\\C:\\secret'), null)
|
||||
assert.equal(await gitRootForIpc('file:///%E0%A4%A'), null)
|
||||
})
|
||||
|
||||
test('gitRootForIpc resolves directories files missing descendants and file URLs', async t => {
|
||||
const root = mkTmpDir()
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }))
|
||||
|
||||
const gitDir = path.join(root, '.git')
|
||||
const srcDir = path.join(root, 'src')
|
||||
const filePath = path.join(srcDir, 'index.ts')
|
||||
fs.mkdirSync(gitDir)
|
||||
fs.mkdirSync(srcDir)
|
||||
fs.writeFileSync(filePath, 'export {}\n', 'utf8')
|
||||
|
||||
assert.equal(await gitRootForIpc(root), root)
|
||||
assert.equal(await gitRootForIpc(srcDir), root)
|
||||
assert.equal(await gitRootForIpc(filePath), root)
|
||||
assert.equal(await gitRootForIpc(pathToFileURL(filePath).toString()), root)
|
||||
assert.equal(await gitRootForIpc(path.join(srcDir, 'missing.ts')), root)
|
||||
})
|
||||
@@ -106,71 +106,155 @@ function sensitiveFileBlockReason(filePath) {
|
||||
return null
|
||||
}
|
||||
|
||||
function resolveRequestedFilePath(filePath, baseDir = process.cwd(), purpose = 'File read') {
|
||||
const raw = String(filePath || '').trim()
|
||||
function ipcPathError(code, message) {
|
||||
const error = new Error(message)
|
||||
error.code = code
|
||||
return error
|
||||
}
|
||||
|
||||
function rejectUnsafePathSyntax(filePath, purpose = 'File read') {
|
||||
if (typeof filePath !== 'string') {
|
||||
throw ipcPathError('invalid-path', `${purpose} failed: file path is required.`)
|
||||
}
|
||||
|
||||
const raw = filePath.trim()
|
||||
|
||||
if (!raw) {
|
||||
throw new Error(`${purpose} failed: file path is required.`)
|
||||
throw ipcPathError('invalid-path', `${purpose} failed: file path is required.`)
|
||||
}
|
||||
|
||||
if (raw.includes('\0')) {
|
||||
throw new Error(`${purpose} failed: file path is invalid.`)
|
||||
throw ipcPathError('invalid-path', `${purpose} failed: file path is invalid.`)
|
||||
}
|
||||
|
||||
const normalized = raw.replace(/\\/g, '/').toLowerCase()
|
||||
if (
|
||||
normalized.startsWith('//?/') ||
|
||||
normalized.startsWith('//./') ||
|
||||
normalized.startsWith('globalroot/device/') ||
|
||||
normalized.includes('/globalroot/device/')
|
||||
) {
|
||||
throw ipcPathError('device-path', `${purpose} blocked: Windows device paths are not allowed.`)
|
||||
}
|
||||
|
||||
return raw
|
||||
}
|
||||
|
||||
function resolveRequestedPathForIpc(filePath, options = {}) {
|
||||
const purpose = String(options.purpose || 'File read')
|
||||
const raw = rejectUnsafePathSyntax(filePath, purpose)
|
||||
|
||||
if (/^file:/i.test(raw)) {
|
||||
let resolvedPath
|
||||
try {
|
||||
return fileURLToPath(raw)
|
||||
const parsed = new URL(raw)
|
||||
if (parsed.protocol !== 'file:') {
|
||||
throw new Error('not a file URL')
|
||||
}
|
||||
resolvedPath = fileURLToPath(parsed)
|
||||
} catch {
|
||||
throw new Error(`${purpose} failed: file URL is invalid.`)
|
||||
throw ipcPathError('invalid-path', `${purpose} failed: file URL is invalid.`)
|
||||
}
|
||||
|
||||
rejectUnsafePathSyntax(resolvedPath, purpose)
|
||||
return path.resolve(resolvedPath)
|
||||
}
|
||||
|
||||
const resolvedBase = path.resolve(String(baseDir || process.cwd()))
|
||||
return path.resolve(resolvedBase, raw)
|
||||
const baseInput = typeof options.baseDir === 'string' && options.baseDir.trim() ? options.baseDir : process.cwd()
|
||||
const safeBaseInput = rejectUnsafePathSyntax(baseInput, purpose)
|
||||
const resolvedBase = path.resolve(safeBaseInput)
|
||||
rejectUnsafePathSyntax(resolvedBase, purpose)
|
||||
const resolvedPath = path.resolve(resolvedBase, raw)
|
||||
rejectUnsafePathSyntax(resolvedPath, purpose)
|
||||
|
||||
return resolvedPath
|
||||
}
|
||||
|
||||
async function statForIpc(fsImpl, resolvedPath, purpose, typeLabel) {
|
||||
try {
|
||||
return await fsImpl.promises.stat(resolvedPath)
|
||||
} catch (error) {
|
||||
const code = error && typeof error === 'object' ? error.code : ''
|
||||
if (code === 'ENOENT' || code === 'ENOTDIR') {
|
||||
throw ipcPathError(code || 'ENOENT', `${purpose} failed: ${typeLabel} does not exist.`)
|
||||
}
|
||||
throw ipcPathError(code || 'read-error', `${purpose} failed: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function realpathForIpc(fsImpl, resolvedPath, purpose) {
|
||||
if (typeof fsImpl.promises.realpath !== 'function') {
|
||||
return resolvedPath
|
||||
}
|
||||
|
||||
try {
|
||||
const realPath = await fsImpl.promises.realpath(resolvedPath)
|
||||
rejectUnsafePathSyntax(realPath, purpose)
|
||||
return realPath
|
||||
} catch (error) {
|
||||
const code = error && typeof error === 'object' ? error.code : ''
|
||||
throw ipcPathError(code || 'read-error', `${purpose} failed: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
function rejectSensitiveFilePath(filePath, purpose) {
|
||||
const blockReason = sensitiveFileBlockReason(filePath)
|
||||
if (blockReason) {
|
||||
throw ipcPathError('sensitive-file', `${purpose} blocked for sensitive file: ${blockReason}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveDirectoryForIpc(dirPath, options = {}) {
|
||||
const purpose = String(options.purpose || 'Directory read')
|
||||
const fsImpl = options.fs || fs
|
||||
const resolvedPath = resolveRequestedPathForIpc(dirPath, { baseDir: options.baseDir, purpose })
|
||||
const stat = await statForIpc(fsImpl, resolvedPath, purpose, 'directory')
|
||||
|
||||
if (!stat.isDirectory()) {
|
||||
throw ipcPathError('ENOTDIR', `${purpose} failed: path is not a directory.`)
|
||||
}
|
||||
|
||||
const realPath = await realpathForIpc(fsImpl, resolvedPath, purpose)
|
||||
|
||||
return { realPath, resolvedPath, stat }
|
||||
}
|
||||
|
||||
async function resolveReadableFileForIpc(filePath, options = {}) {
|
||||
const purpose = String(options.purpose || 'File read')
|
||||
const resolvedPath = resolveRequestedFilePath(filePath, options.baseDir, purpose)
|
||||
const fsImpl = options.fs || fs
|
||||
const resolvedPath = resolveRequestedPathForIpc(filePath, { baseDir: options.baseDir, purpose })
|
||||
|
||||
if (options.blockSensitive !== false) {
|
||||
const blockReason = sensitiveFileBlockReason(resolvedPath)
|
||||
if (blockReason) {
|
||||
throw new Error(`${purpose} blocked for sensitive file: ${blockReason}`)
|
||||
}
|
||||
rejectSensitiveFilePath(resolvedPath, purpose)
|
||||
}
|
||||
|
||||
let stat
|
||||
try {
|
||||
stat = await fs.promises.stat(resolvedPath)
|
||||
} catch (error) {
|
||||
const code = error && typeof error === 'object' ? error.code : ''
|
||||
if (code === 'ENOENT' || code === 'ENOTDIR') {
|
||||
throw new Error(`${purpose} failed: file does not exist.`)
|
||||
}
|
||||
throw new Error(`${purpose} failed: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
const stat = await statForIpc(fsImpl, resolvedPath, purpose, 'file')
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
throw new Error(`${purpose} failed: path points to a directory.`)
|
||||
throw ipcPathError('EISDIR', `${purpose} failed: path points to a directory.`)
|
||||
}
|
||||
|
||||
if (!stat.isFile()) {
|
||||
throw new Error(`${purpose} failed: only regular files can be read.`)
|
||||
throw ipcPathError('EINVAL', `${purpose} failed: only regular files can be read.`)
|
||||
}
|
||||
|
||||
const realPath = await realpathForIpc(fsImpl, resolvedPath, purpose)
|
||||
if (options.blockSensitive !== false) {
|
||||
rejectSensitiveFilePath(realPath, purpose)
|
||||
}
|
||||
|
||||
const maxBytes = Number.isFinite(options.maxBytes) && Number(options.maxBytes) > 0 ? Number(options.maxBytes) : null
|
||||
if (maxBytes && stat.size > maxBytes) {
|
||||
throw new Error(`${purpose} failed: file is too large (${stat.size} bytes; limit ${maxBytes} bytes).`)
|
||||
throw ipcPathError('EFBIG', `${purpose} failed: file is too large (${stat.size} bytes; limit ${maxBytes} bytes).`)
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.promises.access(resolvedPath, fs.constants.R_OK)
|
||||
await fsImpl.promises.access(resolvedPath, fs.constants.R_OK)
|
||||
} catch {
|
||||
throw new Error(`${purpose} failed: file is not readable.`)
|
||||
throw ipcPathError('EACCES', `${purpose} failed: file is not readable.`)
|
||||
}
|
||||
|
||||
return { resolvedPath, stat }
|
||||
return { realPath, resolvedPath, stat }
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
@@ -178,7 +262,10 @@ module.exports = {
|
||||
DEFAULT_FETCH_TIMEOUT_MS,
|
||||
TEXT_PREVIEW_SOURCE_MAX_BYTES,
|
||||
encryptDesktopSecret,
|
||||
rejectUnsafePathSyntax,
|
||||
resolveDirectoryForIpc,
|
||||
resolveReadableFileForIpc,
|
||||
resolveRequestedPathForIpc,
|
||||
resolveTimeoutMs,
|
||||
sensitiveFileBlockReason
|
||||
}
|
||||
|
||||
@@ -8,11 +8,20 @@ const { pathToFileURL } = require('node:url')
|
||||
const {
|
||||
DEFAULT_FETCH_TIMEOUT_MS,
|
||||
encryptDesktopSecret,
|
||||
resolveDirectoryForIpc,
|
||||
resolveReadableFileForIpc,
|
||||
resolveRequestedPathForIpc,
|
||||
resolveTimeoutMs,
|
||||
sensitiveFileBlockReason
|
||||
} = require('./hardening.cjs')
|
||||
|
||||
async function rejectsWithCode(promise, code) {
|
||||
await assert.rejects(promise, error => {
|
||||
assert.equal(error?.code, code)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
test('resolveTimeoutMs falls back to defaults and accepts overrides', () => {
|
||||
assert.equal(resolveTimeoutMs(undefined), DEFAULT_FETCH_TIMEOUT_MS)
|
||||
assert.equal(resolveTimeoutMs(0), DEFAULT_FETCH_TIMEOUT_MS)
|
||||
@@ -51,6 +60,52 @@ test('sensitiveFileBlockReason blocks obvious secret file patterns', () => {
|
||||
assert.match(String(sensitiveFileBlockReason('/tmp/server-cert.pem')), /\.pem/)
|
||||
})
|
||||
|
||||
test('path helpers reject blank non-string NUL and Windows device syntax', async () => {
|
||||
await rejectsWithCode(resolveReadableFileForIpc('', { purpose: 'File preview' }), 'invalid-path')
|
||||
await rejectsWithCode(resolveReadableFileForIpc(' ', { purpose: 'File preview' }), 'invalid-path')
|
||||
await rejectsWithCode(resolveReadableFileForIpc(null, { purpose: 'File preview' }), 'invalid-path')
|
||||
await rejectsWithCode(resolveReadableFileForIpc(`safe${String.fromCharCode(0)}name.txt`), 'invalid-path')
|
||||
|
||||
const devicePaths = [
|
||||
'\\\\?\\C:\\secret.txt',
|
||||
'\\\\.\\C:\\secret.txt',
|
||||
'\\\\?\\UNC\\server\\share\\secret.txt',
|
||||
'GLOBALROOT/Device/HarddiskVolumeShadowCopy1/secret.txt'
|
||||
]
|
||||
|
||||
for (const devicePath of devicePaths) {
|
||||
assert.throws(
|
||||
() => resolveRequestedPathForIpc(devicePath, { purpose: 'File preview' }),
|
||||
error => {
|
||||
assert.equal(error?.code, 'device-path')
|
||||
return true
|
||||
}
|
||||
)
|
||||
await rejectsWithCode(resolveReadableFileForIpc(devicePath, { purpose: 'File preview' }), 'device-path')
|
||||
}
|
||||
|
||||
assert.throws(
|
||||
() => resolveRequestedPathForIpc('file:///%E0%A4%A', { purpose: 'File preview' }),
|
||||
error => {
|
||||
assert.equal(error?.code, 'invalid-path')
|
||||
return true
|
||||
}
|
||||
)
|
||||
await rejectsWithCode(resolveReadableFileForIpc('file:///%E0%A4%A', { purpose: 'File preview' }), 'invalid-path')
|
||||
})
|
||||
|
||||
test('resolveRequestedPathForIpc resolves relative paths from the trimmed base directory', () => {
|
||||
const baseDir = path.join(os.tmpdir(), 'hermes-desktop-base')
|
||||
|
||||
assert.equal(
|
||||
resolveRequestedPathForIpc('notes.txt', {
|
||||
baseDir: ` ${baseDir} `,
|
||||
purpose: 'File preview'
|
||||
}),
|
||||
path.resolve(baseDir, 'notes.txt')
|
||||
)
|
||||
})
|
||||
|
||||
test('resolveReadableFileForIpc validates existence type size and sensitivity', async t => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-hardening-'))
|
||||
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }))
|
||||
@@ -71,6 +126,13 @@ test('resolveReadableFileForIpc validates existence type size and sensitivity',
|
||||
})
|
||||
assert.equal(fromFileUrl.resolvedPath, textPath)
|
||||
|
||||
const spacedPath = path.join(tempDir, 'notes with spaces.txt')
|
||||
fs.writeFileSync(spacedPath, 'space ok', 'utf8')
|
||||
const fromSpacedFileUrl = await resolveReadableFileForIpc(pathToFileURL(spacedPath).toString(), {
|
||||
purpose: 'File preview'
|
||||
})
|
||||
assert.equal(fromSpacedFileUrl.resolvedPath, spacedPath)
|
||||
|
||||
await assert.rejects(
|
||||
resolveReadableFileForIpc('missing.txt', {
|
||||
baseDir: tempDir,
|
||||
@@ -114,3 +176,91 @@ test('resolveReadableFileForIpc validates existence type size and sensitivity',
|
||||
})
|
||||
assert.equal(envTemplate.resolvedPath, envTemplatePath)
|
||||
})
|
||||
|
||||
test('resolveReadableFileForIpc blocks common sensitive files', async t => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-sensitive-'))
|
||||
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }))
|
||||
|
||||
const sshDir = path.join(tempDir, '.ssh')
|
||||
fs.mkdirSync(sshDir)
|
||||
|
||||
const blockedFiles = [
|
||||
path.join(tempDir, '.env'),
|
||||
path.join(tempDir, '.npmrc'),
|
||||
path.join(sshDir, 'id_ed25519'),
|
||||
path.join(tempDir, 'cert.pem'),
|
||||
path.join(tempDir, 'cert.p12'),
|
||||
path.join(tempDir, 'cert.pfx')
|
||||
]
|
||||
|
||||
for (const filePath of blockedFiles) {
|
||||
fs.writeFileSync(filePath, 'secret', 'utf8')
|
||||
await rejectsWithCode(resolveReadableFileForIpc(filePath, { purpose: 'File preview' }), 'sensitive-file')
|
||||
}
|
||||
|
||||
const allowed = path.join(tempDir, '.env.example')
|
||||
fs.writeFileSync(allowed, 'EXAMPLE_TOKEN=value', 'utf8')
|
||||
assert.equal((await resolveReadableFileForIpc(allowed, { purpose: 'File preview' })).resolvedPath, allowed)
|
||||
})
|
||||
|
||||
test('resolveReadableFileForIpc blocks symlinks whose realpath is sensitive', async t => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-realpath-'))
|
||||
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }))
|
||||
|
||||
const envPath = path.join(tempDir, '.env')
|
||||
const linkPath = path.join(tempDir, 'safe-name.txt')
|
||||
fs.writeFileSync(envPath, 'SECRET_TOKEN=123', 'utf8')
|
||||
|
||||
try {
|
||||
fs.symlinkSync(envPath, linkPath, 'file')
|
||||
} catch (error) {
|
||||
if (error?.code === 'EPERM' || error?.code === 'EACCES') {
|
||||
t.skip(`symlink creation is not permitted on this platform (${error.code})`)
|
||||
return
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
await rejectsWithCode(resolveReadableFileForIpc(linkPath, { purpose: 'File preview' }), 'sensitive-file')
|
||||
})
|
||||
|
||||
test('resolveDirectoryForIpc accepts directories and rejects invalid directory targets', async t => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-dir-'))
|
||||
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }))
|
||||
|
||||
const directory = path.join(tempDir, 'project')
|
||||
const filePath = path.join(tempDir, 'file.txt')
|
||||
fs.mkdirSync(directory)
|
||||
fs.writeFileSync(filePath, 'not a directory', 'utf8')
|
||||
|
||||
const resolved = await resolveDirectoryForIpc(directory)
|
||||
assert.equal(resolved.resolvedPath, directory)
|
||||
assert.equal(resolved.stat.isDirectory(), true)
|
||||
|
||||
await rejectsWithCode(resolveDirectoryForIpc(filePath), 'ENOTDIR')
|
||||
await rejectsWithCode(resolveDirectoryForIpc(path.join(tempDir, 'missing')), 'ENOENT')
|
||||
await rejectsWithCode(resolveDirectoryForIpc('\\\\?\\C:\\secret'), 'device-path')
|
||||
})
|
||||
|
||||
test('resolveDirectoryForIpc accepts directory symlinks or junctions', async t => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-dir-link-'))
|
||||
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }))
|
||||
|
||||
const directory = path.join(tempDir, 'actual-project')
|
||||
const linkPath = path.join(tempDir, 'linked-project')
|
||||
fs.mkdirSync(directory)
|
||||
|
||||
try {
|
||||
fs.symlinkSync(directory, linkPath, process.platform === 'win32' ? 'junction' : 'dir')
|
||||
} catch (error) {
|
||||
if (error?.code === 'EPERM' || error?.code === 'EACCES') {
|
||||
t.skip(`directory symlink creation is not permitted on this platform (${error.code})`)
|
||||
return
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
const resolved = await resolveDirectoryForIpc(linkPath)
|
||||
assert.equal(resolved.resolvedPath, linkPath)
|
||||
assert.equal(resolved.stat.isDirectory(), true)
|
||||
})
|
||||
|
||||
+120
-127
@@ -22,7 +22,7 @@ const http = require('node:http')
|
||||
const https = require('node:https')
|
||||
const net = require('node:net')
|
||||
const path = require('node:path')
|
||||
const { fileURLToPath, pathToFileURL } = require('node:url')
|
||||
const { pathToFileURL } = require('node:url')
|
||||
const { execFileSync, spawn } = require('node:child_process')
|
||||
const { detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment } = require('./bootstrap-platform.cjs')
|
||||
const { runBootstrap } = require('./bootstrap-runner.cjs')
|
||||
@@ -31,6 +31,8 @@ const { canImportHermesCli, verifyHermesCli } = require('./backend-probes.cjs')
|
||||
const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs')
|
||||
const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs')
|
||||
const { fetchMarketplaceThemes, searchMarketplaceThemes } = require('./vscode-marketplace.cjs')
|
||||
const { readDirForIpc } = require('./fs-read-dir.cjs')
|
||||
const { gitRootForIpc } = require('./git-root.cjs')
|
||||
const {
|
||||
OFFICIAL_REPO_HTTPS_URL,
|
||||
isOfficialSshRemote
|
||||
@@ -65,6 +67,7 @@ const {
|
||||
TEXT_PREVIEW_SOURCE_MAX_BYTES,
|
||||
encryptDesktopSecret: encryptDesktopSecretStrict,
|
||||
resolveReadableFileForIpc,
|
||||
resolveRequestedPathForIpc,
|
||||
resolveTimeoutMs
|
||||
} = require('./hardening.cjs')
|
||||
|
||||
@@ -246,6 +249,32 @@ function resolveHermesHome() {
|
||||
}
|
||||
|
||||
const HERMES_HOME = resolveHermesHome()
|
||||
|
||||
// Read a profile's gateway_http.json file and return its contents as an object,
|
||||
// or null if the file doesn't exist, is stale (PID not alive), or is corrupted.
|
||||
// This is the JS mirror of gateway/status.py::read_gateway_http_info.
|
||||
function readGatewayHttpInfo(profile) {
|
||||
try {
|
||||
const profileHome = (!profile || profile === 'default')
|
||||
? HERMES_HOME
|
||||
: path.join(HERMES_HOME, 'profiles', profile)
|
||||
const infoPath = path.join(profileHome, 'gateway_http.json')
|
||||
if (!fileExists(infoPath)) return null
|
||||
const data = JSON.parse(fs.readFileSync(infoPath, 'utf8'))
|
||||
if (!data || !data.port || !data.token || !data.base_url) return null
|
||||
// Stale check: is the PID still alive?
|
||||
if (data.pid) {
|
||||
try {
|
||||
process.kill(data.pid, 0) // throws if not alive
|
||||
} catch {
|
||||
return null // stale — gateway crashed without cleanup
|
||||
}
|
||||
}
|
||||
return data
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
// ACTIVE_HERMES_ROOT — the canonical mutable Hermes install. Same path
|
||||
// install.ps1 / install.sh use, so a desktop-only user and a CLI-only user end
|
||||
// up with identical layouts and can share one install.
|
||||
@@ -730,7 +759,7 @@ function openExternalUrl(rawUrl) {
|
||||
if (parsed.protocol === 'file:') {
|
||||
let localPath
|
||||
try {
|
||||
localPath = fileURLToPath(parsed.toString())
|
||||
localPath = resolveRequestedPathForIpc(parsed.toString(), { purpose: 'Open external file' })
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
@@ -1789,14 +1818,15 @@ async function applyUpdatesPosixInApp() {
|
||||
PATH: [extraPath, process.env.PATH].filter(Boolean).join(path.delimiter)
|
||||
}
|
||||
|
||||
// `hermes update` reaps stale `hermes dashboard` backends (a code update
|
||||
// leaves the running process serving old Python against the freshly-updated
|
||||
// JS bundle). But OUR backend is one of those processes, and killing it
|
||||
// mid-update produces the boot→kill→crash loop in #37532 — the desktop
|
||||
// already restarts its own backend via the rebuild+relaunch below, so the
|
||||
// reap must spare it. Hand the live backend's PID to the update process;
|
||||
// _kill_stale_dashboard_processes reads HERMES_DESKTOP_CHILD_PID and excludes
|
||||
// it while still reaping any genuinely-orphaned dashboards. (#37532)
|
||||
// `hermes update` reaps stale `hermes dashboard` and `hermes gateway run`
|
||||
// backends (a code update leaves the running process serving old Python
|
||||
// against the freshly-updated JS bundle). But OUR backend is one of those
|
||||
// processes, and killing it mid-update produces the boot→kill→crash loop
|
||||
// in #37532 — the desktop already restarts its own backend via the
|
||||
// rebuild+relaunch below, so the reap must spare it. Hand the live
|
||||
// backend's PID to the update process; _kill_stale_dashboard_processes
|
||||
// reads HERMES_DESKTOP_CHILD_PID and excludes it while still reaping
|
||||
// any genuinely-orphaned backends. (#37532)
|
||||
// Exclude every desktop-managed backend (primary + all pool profiles) from
|
||||
// the update reaper. _kill_stale_dashboard_processes accepts a comma-separated
|
||||
// list (a single int still parses for back-compat).
|
||||
@@ -2878,10 +2908,10 @@ async function resourceBufferFromUrl(rawUrl) {
|
||||
const buffer = match[2] ? Buffer.from(encoded, 'base64') : Buffer.from(decodeURIComponent(encoded), 'utf8')
|
||||
return { buffer, mimeType }
|
||||
}
|
||||
if (rawUrl.startsWith('file:')) {
|
||||
const filePath = fileURLToPath(rawUrl)
|
||||
const buffer = await fs.promises.readFile(filePath)
|
||||
return { buffer, mimeType: mimeTypeForPath(filePath) }
|
||||
if (/^file:/i.test(rawUrl)) {
|
||||
const { resolvedPath } = await resolveReadableFileForIpc(rawUrl, { purpose: 'Image file' })
|
||||
const buffer = await fs.promises.readFile(resolvedPath)
|
||||
return { buffer, mimeType: mimeTypeForPath(resolvedPath) }
|
||||
}
|
||||
|
||||
const parsed = new URL(rawUrl)
|
||||
@@ -2959,11 +2989,13 @@ function expandUserPath(filePath) {
|
||||
return value
|
||||
}
|
||||
|
||||
function previewFileTarget(rawTarget, baseDir) {
|
||||
async function previewFileTarget(rawTarget, baseDir) {
|
||||
const raw = String(rawTarget || '').trim()
|
||||
const base = baseDir ? path.resolve(expandUserPath(baseDir)) : resolveHermesCwd()
|
||||
const filePath = raw.startsWith('file:') ? fileURLToPath(raw) : path.resolve(base, expandUserPath(raw))
|
||||
let resolved = filePath
|
||||
let resolved = resolveRequestedPathForIpc(/^file:/i.test(raw) ? raw : expandUserPath(raw), {
|
||||
baseDir: base,
|
||||
purpose: 'Preview target'
|
||||
})
|
||||
|
||||
if (directoryExists(resolved)) {
|
||||
resolved = path.join(resolved, 'index.html')
|
||||
@@ -2974,6 +3006,8 @@ function previewFileTarget(rawTarget, baseDir) {
|
||||
return null
|
||||
}
|
||||
|
||||
;({ resolvedPath: resolved } = await resolveReadableFileForIpc(resolved, { purpose: 'Preview target' }))
|
||||
|
||||
const mimeType = mimeTypeForPath(resolved)
|
||||
const metadata = previewFileMetadata(resolved, mimeType)
|
||||
const isHtml = PREVIEW_HTML_EXTENSIONS.has(ext)
|
||||
@@ -3019,7 +3053,7 @@ function previewUrlTarget(rawTarget) {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePreviewTarget(rawTarget, baseDir) {
|
||||
async function normalizePreviewTarget(rawTarget, baseDir) {
|
||||
const raw = String(rawTarget || '').trim()
|
||||
|
||||
if (!raw) {
|
||||
@@ -3031,20 +3065,15 @@ function normalizePreviewTarget(rawTarget, baseDir) {
|
||||
return previewUrlTarget(raw)
|
||||
}
|
||||
|
||||
return previewFileTarget(raw, baseDir)
|
||||
return await previewFileTarget(raw, baseDir)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function filePathFromPreviewUrl(rawUrl) {
|
||||
const filePath = fileURLToPath(String(rawUrl || ''))
|
||||
|
||||
if (!fileExists(filePath)) {
|
||||
throw new Error('Preview file is not readable')
|
||||
}
|
||||
|
||||
return filePath
|
||||
async function filePathFromPreviewUrl(rawUrl) {
|
||||
const { resolvedPath } = await resolveReadableFileForIpc(String(rawUrl || ''), { purpose: 'Preview file' })
|
||||
return resolvedPath
|
||||
}
|
||||
|
||||
function sendPreviewFileChanged(payload) {
|
||||
@@ -3054,8 +3083,8 @@ function sendPreviewFileChanged(payload) {
|
||||
webContents.send('hermes:preview-file-changed', payload)
|
||||
}
|
||||
|
||||
function watchPreviewFile(rawUrl) {
|
||||
const filePath = filePathFromPreviewUrl(rawUrl)
|
||||
async function watchPreviewFile(rawUrl) {
|
||||
const filePath = await filePathFromPreviewUrl(rawUrl)
|
||||
const watchDir = path.dirname(filePath)
|
||||
const targetName = path.basename(filePath)
|
||||
const id = crypto.randomBytes(12).toString('base64url')
|
||||
@@ -4452,6 +4481,30 @@ async function ensureBackend(profile) {
|
||||
return existing.connectionPromise
|
||||
}
|
||||
|
||||
// Before spawning a new gateway process, check if one is already running
|
||||
// (e.g. the user runs `hermes -p worker gateway run` themselves, or the
|
||||
// desktop left a gateway running from a previous session that survived a
|
||||
// renderer restart).
|
||||
const alreadyRunning = readGatewayHttpInfo(key)
|
||||
if (alreadyRunning) {
|
||||
rememberLog(`Profile "${key}" gateway already running on port ${alreadyRunning.port} — reusing`)
|
||||
const conn = {
|
||||
baseUrl: alreadyRunning.base_url,
|
||||
mode: 'local',
|
||||
source: 'local',
|
||||
authMode: 'token',
|
||||
token: alreadyRunning.token,
|
||||
profile: key,
|
||||
wsUrl: `${alreadyRunning.ws_url}?token=${encodeURIComponent(alreadyRunning.token)}`,
|
||||
logs: hermesLog.slice(-80),
|
||||
...getWindowState()
|
||||
}
|
||||
const entry = { process: null, port: alreadyRunning.port, token: alreadyRunning.token, connectionPromise: Promise.resolve(conn), lastActiveAt: Date.now() }
|
||||
backendPool.set(key, entry)
|
||||
startPoolIdleReaper()
|
||||
return conn
|
||||
}
|
||||
|
||||
evictLruPoolBackends(POOL_MAX_BACKENDS - 1)
|
||||
|
||||
const entry = { process: null, port: null, token: null, connectionPromise: null, lastActiveAt: Date.now() }
|
||||
@@ -4536,10 +4589,9 @@ async function spawnPoolBackend(profile, entry) {
|
||||
const token = crypto.randomBytes(32).toString('base64url')
|
||||
// --profile wins over the inherited HERMES_HOME env (see _apply_profile_override
|
||||
// step 3 in hermes_cli/main.py), so the child re-homes to this profile.
|
||||
const dashboardArgs = ['--profile', profile, 'dashboard', '--no-open', '--host', '127.0.0.1', '--port', String(port)]
|
||||
const backend = await ensureRuntime(resolveHermesBackend(dashboardArgs))
|
||||
const backendArgs = ['--profile', profile, 'gateway', 'run', '--http-port', String(port), '--http-host', '127.0.0.1', '--http-token', token]
|
||||
const backend = await ensureRuntime(resolveHermesBackend(backendArgs))
|
||||
const hermesCwd = resolveHermesCwd()
|
||||
const webDist = resolveWebDist()
|
||||
|
||||
rememberLog(`Starting Hermes backend for profile "${profile}" via ${backend.label}`)
|
||||
|
||||
@@ -4553,11 +4605,11 @@ async function spawnPoolBackend(profile, entry) {
|
||||
// the child process. Inherited TERMINAL_CWD (or a stale config bridge)
|
||||
// can still point at the install dir even when spawn cwd is home.
|
||||
TERMINAL_CWD: hermesCwd,
|
||||
GATEWAY_HTTP_TOKEN: token,
|
||||
HERMES_DASHBOARD_SESSION_TOKEN: token,
|
||||
// Marks this dashboard backend as desktop-spawned so it runs the cron
|
||||
// scheduler tick loop (the gateway isn't running under the app).
|
||||
HERMES_DESKTOP: '1',
|
||||
HERMES_WEB_DIST: webDist
|
||||
// Marks this gateway backend as desktop-spawned so it runs the cron
|
||||
// scheduler tick loop.
|
||||
HERMES_DESKTOP: '1'
|
||||
},
|
||||
shell: backend.shell,
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
@@ -4722,23 +4774,43 @@ async function startHermes() {
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the primary profile's gateway is already running (e.g. started
|
||||
// by the user from the CLI before launching the desktop). Reuse it rather
|
||||
// than spawning a second gateway on a different port.
|
||||
const activeProfile = readActiveDesktopProfile()
|
||||
const primaryAlreadyRunning = readGatewayHttpInfo(activeProfile || null)
|
||||
if (primaryAlreadyRunning) {
|
||||
rememberLog(`Primary gateway already running on port ${primaryAlreadyRunning.port} — reusing`)
|
||||
await advanceBootProgress('backend.wait', 'Connecting to existing Hermes gateway', 90)
|
||||
await waitForHermes(primaryAlreadyRunning.base_url, primaryAlreadyRunning.token)
|
||||
updateBootProgress({ phase: 'backend.ready', message: 'Hermes backend is ready', progress: 94, running: true, error: null })
|
||||
return {
|
||||
baseUrl: primaryAlreadyRunning.base_url,
|
||||
mode: 'local',
|
||||
source: 'local',
|
||||
authMode: 'token',
|
||||
token: primaryAlreadyRunning.token,
|
||||
wsUrl: `${primaryAlreadyRunning.ws_url}?token=${encodeURIComponent(primaryAlreadyRunning.token)}`,
|
||||
logs: hermesLog.slice(-80),
|
||||
...getWindowState()
|
||||
}
|
||||
}
|
||||
|
||||
await advanceBootProgress('backend.port', 'Finding an open local port', 16)
|
||||
const port = await pickPort()
|
||||
const token = crypto.randomBytes(32).toString('base64url')
|
||||
const dashboardArgs = ['dashboard', '--no-open', '--host', '127.0.0.1', '--port', String(port)]
|
||||
const backendArgs = ['gateway', 'run', '--http-port', String(port), '--http-host', '127.0.0.1', '--http-token', token]
|
||||
// Pin the desktop's chosen profile via the global --profile flag. This is
|
||||
// deterministic (it wins over the sticky ~/.hermes/active_profile file) and
|
||||
// resolves HERMES_HOME the same way `hermes -p <name>` does on the CLI. An
|
||||
// unset preference keeps the legacy launch so existing installs are
|
||||
// unaffected.
|
||||
const activeProfile = readActiveDesktopProfile()
|
||||
if (activeProfile) {
|
||||
dashboardArgs.unshift('--profile', activeProfile)
|
||||
backendArgs.unshift('--profile', activeProfile)
|
||||
}
|
||||
await advanceBootProgress('backend.runtime', 'Resolving Hermes runtime', 28)
|
||||
const backend = await ensureRuntime(resolveHermesBackend(dashboardArgs))
|
||||
const backend = await ensureRuntime(resolveHermesBackend(backendArgs))
|
||||
const hermesCwd = resolveHermesCwd()
|
||||
const webDist = resolveWebDist()
|
||||
|
||||
await advanceBootProgress('backend.spawn', `Starting Hermes backend via ${backend.label}`, 84)
|
||||
rememberLog(`Starting Hermes backend via ${backend.label}`)
|
||||
@@ -4751,18 +4823,18 @@ async function startHermes() {
|
||||
// resolves to the SAME location our resolveHermesHome() picked. Without
|
||||
// this pin, Python falls back to ~/.hermes on every platform — fine on
|
||||
// mac/linux (where our default matches), but on Windows our default is
|
||||
// %LOCALAPPDATA%\hermes, which differs from C:\Users\<u>\.hermes.
|
||||
// %LOCALAPPDATA%\\hermes, which differs from C:\\Users\\<u>\\.hermes.
|
||||
// Mismatch would split config / sessions / .env / logs across two
|
||||
// directories. install.ps1 sets HERMES_HOME via setx; the desktop
|
||||
// can't reliably do that, so we set it inline for every spawn.
|
||||
HERMES_HOME,
|
||||
...backend.env,
|
||||
TERMINAL_CWD: hermesCwd,
|
||||
GATEWAY_HTTP_TOKEN: token,
|
||||
HERMES_DASHBOARD_SESSION_TOKEN: token,
|
||||
// Marks this dashboard backend as desktop-spawned so it runs the cron
|
||||
// scheduler tick loop (the gateway isn't running under the app).
|
||||
HERMES_DESKTOP: '1',
|
||||
HERMES_WEB_DIST: webDist
|
||||
// Marks this gateway backend as desktop-spawned so it runs the cron
|
||||
// scheduler tick loop.
|
||||
HERMES_DESKTOP: '1'
|
||||
},
|
||||
shell: backend.shell,
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
@@ -5587,48 +5659,6 @@ ipcMain.handle('hermes:logs:reveal', async () => {
|
||||
|
||||
ipcMain.handle('hermes:logs:recent', async () => ({ path: DESKTOP_LOG_PATH, lines: hermesLog.slice(-200) }))
|
||||
|
||||
// Always-hidden noise (covers non-git projects too — gitignore would catch
|
||||
// these anyway when present, but we want the same hygiene without one).
|
||||
const FS_READDIR_HIDDEN = new Set([
|
||||
'.git',
|
||||
'.hg',
|
||||
'.svn',
|
||||
'.cache',
|
||||
'.next',
|
||||
'.turbo',
|
||||
'.venv',
|
||||
'__pycache__',
|
||||
'build',
|
||||
'dist',
|
||||
'node_modules',
|
||||
'target',
|
||||
'venv'
|
||||
])
|
||||
|
||||
function findGitRoot(start) {
|
||||
let dir = start
|
||||
|
||||
for (let i = 0; i < 50; i += 1) {
|
||||
try {
|
||||
if (fs.existsSync(path.join(dir, '.git'))) {
|
||||
return dir
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
const parent = path.dirname(dir)
|
||||
|
||||
if (parent === dir) {
|
||||
return null
|
||||
}
|
||||
|
||||
dir = parent
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function isExecutableFile(filePath) {
|
||||
if (!filePath || !path.isAbsolute(filePath)) {
|
||||
return false
|
||||
@@ -5811,46 +5841,9 @@ function disposeTerminalSession(id) {
|
||||
return true
|
||||
}
|
||||
|
||||
ipcMain.handle('hermes:fs:readDir', async (_event, dirPath) => {
|
||||
const resolved = path.resolve(String(dirPath || ''))
|
||||
ipcMain.handle('hermes:fs:readDir', async (_event, dirPath) => readDirForIpc(dirPath))
|
||||
|
||||
if (!resolved) {
|
||||
return { entries: [], error: 'invalid-path' }
|
||||
}
|
||||
|
||||
try {
|
||||
const dirents = await fs.promises.readdir(resolved, { withFileTypes: true })
|
||||
|
||||
const entries = dirents
|
||||
.filter(d => {
|
||||
if (FS_READDIR_HIDDEN.has(d.name)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
.map(d => ({ name: d.name, path: path.join(resolved, d.name), isDirectory: d.isDirectory() }))
|
||||
.sort((a, b) => Number(b.isDirectory) - Number(a.isDirectory) || a.name.localeCompare(b.name))
|
||||
|
||||
return { entries }
|
||||
} catch (error) {
|
||||
return { entries: [], error: error?.code || 'read-error' }
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('hermes:fs:gitRoot', async (_event, startPath) => {
|
||||
const input = String(startPath || '')
|
||||
const resolved = input.startsWith('file:') ? fileURLToPath(input) : path.resolve(input)
|
||||
|
||||
try {
|
||||
const stat = await fs.promises.stat(resolved)
|
||||
const start = stat.isDirectory() ? resolved : path.dirname(resolved)
|
||||
|
||||
return findGitRoot(start)
|
||||
} catch {
|
||||
return findGitRoot(resolved)
|
||||
}
|
||||
})
|
||||
ipcMain.handle('hermes:fs:gitRoot', async (_event, startPath) => gitRootForIpc(startPath))
|
||||
|
||||
ipcMain.handle('hermes:terminal:start', async (event, payload = {}) => {
|
||||
if (!nodePty) {
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
|
||||
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
|
||||
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
|
||||
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs",
|
||||
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs",
|
||||
"typecheck": "tsc -p . --noEmit",
|
||||
"lint": "eslint src/ electron/",
|
||||
"lint:fix": "eslint src/ electron/ --fix",
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/// <reference types="node" />
|
||||
|
||||
import { Buffer } from 'node:buffer'
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { HermesReadDirEntry, HermesReadDirResult } from '@/global'
|
||||
|
||||
import { clearProjectDirCache, readProjectDir } from './ipc'
|
||||
|
||||
const readDir = vi.fn<(path: string) => Promise<HermesReadDirResult>>()
|
||||
const readFileDataUrl = vi.fn<(path: string) => Promise<string>>()
|
||||
const gitRoot = vi.fn<(path: string) => Promise<string | null>>()
|
||||
|
||||
function ok(entries: HermesReadDirEntry[]): HermesReadDirResult {
|
||||
return { entries }
|
||||
}
|
||||
|
||||
function dataUrl(text: string) {
|
||||
return `data:text/plain;base64,${Buffer.from(text, 'utf8').toString('base64')}`
|
||||
}
|
||||
|
||||
function installBridge() {
|
||||
;(
|
||||
window as unknown as {
|
||||
hermesDesktop: {
|
||||
gitRoot: typeof gitRoot
|
||||
readDir: typeof readDir
|
||||
readFileDataUrl: typeof readFileDataUrl
|
||||
}
|
||||
}
|
||||
).hermesDesktop = { gitRoot, readDir, readFileDataUrl }
|
||||
}
|
||||
|
||||
describe('readProjectDir', () => {
|
||||
beforeEach(() => {
|
||||
clearProjectDirCache()
|
||||
readDir.mockReset()
|
||||
readFileDataUrl.mockReset()
|
||||
gitRoot.mockReset()
|
||||
installBridge()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
clearProjectDirCache()
|
||||
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
|
||||
})
|
||||
|
||||
it('returns no-bridge when the desktop bridge is unavailable', async () => {
|
||||
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
|
||||
|
||||
await expect(readProjectDir('/repo')).resolves.toEqual({ entries: [], error: 'no-bridge' })
|
||||
})
|
||||
|
||||
it('filters gitignored entries when readDir returns Windows-style paths', async () => {
|
||||
gitRoot.mockResolvedValue('C:\\repo')
|
||||
readDir.mockImplementation(async path => {
|
||||
if (path === 'C:\\repo\\src') {
|
||||
return ok([
|
||||
{ name: 'debug.log', path: 'C:\\repo\\src\\debug.log', isDirectory: false },
|
||||
{ name: '临时.txt', path: 'C:\\repo\\src\\临时.txt', isDirectory: false },
|
||||
{ name: 'keep.ts', path: 'C:\\repo\\src\\keep.ts', isDirectory: false }
|
||||
])
|
||||
}
|
||||
|
||||
if (path === 'C:/repo') {
|
||||
return ok([{ name: '.gitignore', path: 'C:/repo/.gitignore', isDirectory: false }])
|
||||
}
|
||||
|
||||
if (path === 'C:/repo/src') {
|
||||
return ok([])
|
||||
}
|
||||
|
||||
return ok([])
|
||||
})
|
||||
readFileDataUrl.mockResolvedValue(dataUrl('# Unicode 路径规则\nsrc/*.log\nsrc/临时.txt\n'))
|
||||
|
||||
const result = await readProjectDir('C:\\repo\\src', 'C:\\repo')
|
||||
|
||||
expect(result.entries.map(entry => entry.name)).toEqual(['keep.ts'])
|
||||
expect(gitRoot).toHaveBeenCalledWith('C:/repo')
|
||||
expect(readFileDataUrl).toHaveBeenCalledWith('C:/repo/.gitignore')
|
||||
})
|
||||
|
||||
it('does not fetch .gitignore contents when listings do not contain .gitignore', async () => {
|
||||
gitRoot.mockResolvedValue('/repo')
|
||||
readDir.mockImplementation(async path => {
|
||||
if (path === '/repo/src') {
|
||||
return ok([{ name: 'debug.log', path: '/repo/src/debug.log', isDirectory: false }])
|
||||
}
|
||||
|
||||
return ok([])
|
||||
})
|
||||
|
||||
const result = await readProjectDir('/repo/src', '/repo')
|
||||
|
||||
expect(result.entries.map(entry => entry.name)).toEqual(['debug.log'])
|
||||
expect(readFileDataUrl).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -27,7 +27,7 @@ function decodeDataUrl(dataUrl: string) {
|
||||
}
|
||||
|
||||
function clean(path: string) {
|
||||
return path.replace(/\/+$/, '') || '/'
|
||||
return path.replace(/\\/g, '/').replace(/\/+$/, '') || '/'
|
||||
}
|
||||
|
||||
/** Strict POSIX-style relative path; null if `child` is not inside `root`. */
|
||||
|
||||
@@ -145,7 +145,8 @@ function ProjectTreeRow({
|
||||
}
|
||||
|
||||
const isFolder = node.data.isDirectory
|
||||
const isPlaceholder = node.data.id.endsWith('::__loading__')
|
||||
const isPlaceholder = Boolean(node.data.placeholder)
|
||||
const isErrorPlaceholder = node.data.placeholder === 'error'
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -210,8 +211,10 @@ function ProjectTreeRow({
|
||||
)}
|
||||
{!isFolder && <span aria-hidden className="w-3 shrink-0" />}
|
||||
<span aria-hidden className="flex w-3.5 items-center justify-center text-(--ui-text-tertiary)">
|
||||
{isPlaceholder ? (
|
||||
{isPlaceholder && !isErrorPlaceholder ? (
|
||||
<Codicon name="loading" size="0.75rem" spinning />
|
||||
) : isErrorPlaceholder ? (
|
||||
<Codicon name="warning" size="0.75rem" />
|
||||
) : isFolder ? (
|
||||
<Codicon name={node.isOpen ? 'folder-opened' : 'folder'} size="0.875rem" />
|
||||
) : (
|
||||
|
||||
@@ -106,7 +106,7 @@ describe('useProjectTree', () => {
|
||||
expect(readDir).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('captures per-folder error code and leaves the folder expandable but empty', async () => {
|
||||
it('captures per-folder error code and shows an error placeholder child', async () => {
|
||||
readDir.mockResolvedValueOnce(ok([{ name: 'priv', path: '/p/priv', isDirectory: true }]))
|
||||
readDir.mockResolvedValueOnce({ entries: [], error: 'EACCES' })
|
||||
|
||||
@@ -119,7 +119,14 @@ describe('useProjectTree', () => {
|
||||
})
|
||||
|
||||
expect(result.current.data[0].error).toBe('EACCES')
|
||||
expect(result.current.data[0].children).toEqual([])
|
||||
expect(result.current.data[0].children).toEqual([
|
||||
{
|
||||
id: '/p/priv::__error__',
|
||||
isDirectory: false,
|
||||
name: 'Unable to read (EACCES)',
|
||||
placeholder: 'error'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('dedupes concurrent loadChildren calls for the same id', async () => {
|
||||
|
||||
@@ -14,11 +14,14 @@ export interface TreeNode {
|
||||
children?: TreeNode[]
|
||||
/** True while a readDir for this folder is in flight. */
|
||||
loading?: boolean
|
||||
/** Synthetic loading/error rows are not real filesystem entries. */
|
||||
placeholder?: 'error' | 'loading'
|
||||
/** Last error code from readDir (e.g. EACCES). Cleared on next successful load. */
|
||||
error?: string
|
||||
}
|
||||
|
||||
const PLACEHOLDER_ID = '__loading__'
|
||||
const ERROR_PLACEHOLDER_ID = '__error__'
|
||||
|
||||
function makeNode(path: string, name: string, isDirectory: boolean): TreeNode {
|
||||
return { id: path, isDirectory, name }
|
||||
@@ -43,7 +46,16 @@ function patchNode(nodes: TreeNode[] | undefined | null, id: string, patch: (n:
|
||||
}
|
||||
|
||||
function placeholderChild(parentId: string): TreeNode {
|
||||
return { id: `${parentId}::${PLACEHOLDER_ID}`, isDirectory: false, name: 'Loading…' }
|
||||
return { id: `${parentId}::${PLACEHOLDER_ID}`, isDirectory: false, name: 'Loading…', placeholder: 'loading' }
|
||||
}
|
||||
|
||||
function errorChild(parentId: string, error: string | undefined): TreeNode {
|
||||
return {
|
||||
id: `${parentId}::${ERROR_PLACEHOLDER_ID}`,
|
||||
isDirectory: false,
|
||||
name: `Unable to read (${error || 'read-error'})`,
|
||||
placeholder: 'error'
|
||||
}
|
||||
}
|
||||
|
||||
export interface UseProjectTreeResult {
|
||||
@@ -227,7 +239,7 @@ export function useProjectTree(cwd: string): UseProjectTreeResult {
|
||||
...n,
|
||||
loading: false,
|
||||
error: error || undefined,
|
||||
children: error ? [] : entries.map(e => makeNode(e.path, e.name, e.isDirectory))
|
||||
children: error ? [errorChild(n.id, error)] : entries.map(e => makeNode(e.path, e.name, e.isDirectory))
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
@@ -64,6 +64,67 @@ interface QueuedStreamDeltas {
|
||||
reasoning: string
|
||||
}
|
||||
|
||||
type SessionRuntimeStatePatch = Partial<
|
||||
Pick<
|
||||
ClientSessionState,
|
||||
| 'branch'
|
||||
| 'cwd'
|
||||
| 'fast'
|
||||
| 'model'
|
||||
| 'personality'
|
||||
| 'provider'
|
||||
| 'reasoningEffort'
|
||||
| 'serviceTier'
|
||||
| 'yolo'
|
||||
>
|
||||
>
|
||||
|
||||
function sessionInfoStatePatch(payload: GatewayEventPayload | undefined): SessionRuntimeStatePatch {
|
||||
const patch: SessionRuntimeStatePatch = {}
|
||||
|
||||
if (typeof payload?.model === 'string') {
|
||||
patch.model = payload.model || ''
|
||||
}
|
||||
|
||||
if (typeof payload?.provider === 'string') {
|
||||
patch.provider = payload.provider || ''
|
||||
}
|
||||
|
||||
if (typeof payload?.cwd === 'string') {
|
||||
patch.cwd = payload.cwd
|
||||
}
|
||||
|
||||
if (typeof payload?.branch === 'string') {
|
||||
patch.branch = payload.branch
|
||||
}
|
||||
|
||||
if (typeof payload?.personality === 'string') {
|
||||
patch.personality = normalizePersonalityValue(payload.personality)
|
||||
}
|
||||
|
||||
if (typeof payload?.reasoning_effort === 'string') {
|
||||
patch.reasoningEffort = payload.reasoning_effort
|
||||
}
|
||||
|
||||
if (typeof payload?.service_tier === 'string') {
|
||||
patch.serviceTier = payload.service_tier
|
||||
}
|
||||
|
||||
if (typeof payload?.fast === 'boolean') {
|
||||
patch.fast = payload.fast
|
||||
}
|
||||
|
||||
if (typeof payload?.yolo === 'boolean') {
|
||||
patch.yolo = payload.yolo
|
||||
}
|
||||
|
||||
return patch
|
||||
}
|
||||
|
||||
function hasSessionInfoStatePatch(patch: SessionRuntimeStatePatch): boolean {
|
||||
return Object.keys(patch).length > 0
|
||||
}
|
||||
|
||||
// Minimum gap between two assistant-text flushes during a stream. Was 16ms
|
||||
// (rAF only), which at typical LLM token rates of ~30-80 tok/sec meant every
|
||||
// token got its own React commit + Streamdown markdown re-parse, scaling
|
||||
@@ -628,36 +689,27 @@ export function useMessageStream({
|
||||
// Apply session-scoped fields when the event targets the active
|
||||
// session, OR when it's a global broadcast and we have no session.
|
||||
const apply = explicitSid ? isActiveEvent : !activeSessionIdRef.current
|
||||
const statePatch = sessionInfoStatePatch(payload)
|
||||
const hasStatePatch = hasSessionInfoStatePatch(statePatch)
|
||||
const modelChanged = typeof payload?.model === 'string'
|
||||
const providerChanged = typeof payload?.provider === 'string'
|
||||
const runningChanged = typeof payload?.running === 'boolean'
|
||||
|
||||
if (apply) {
|
||||
const runtimeInfo: Partial<
|
||||
Pick<
|
||||
ClientSessionState,
|
||||
'branch' | 'cwd' | 'fast' | 'model' | 'provider' | 'reasoningEffort' | 'serviceTier' | 'yolo'
|
||||
>
|
||||
> = {}
|
||||
|
||||
if (modelChanged) {
|
||||
setCurrentModel(payload!.model || '')
|
||||
runtimeInfo.model = payload!.model || ''
|
||||
}
|
||||
|
||||
if (providerChanged) {
|
||||
setCurrentProvider(payload!.provider || '')
|
||||
runtimeInfo.provider = payload!.provider || ''
|
||||
}
|
||||
|
||||
if (typeof payload?.cwd === 'string') {
|
||||
setCurrentCwd(payload.cwd)
|
||||
runtimeInfo.cwd = payload.cwd
|
||||
}
|
||||
|
||||
if (typeof payload?.branch === 'string') {
|
||||
setCurrentBranch(payload.branch)
|
||||
runtimeInfo.branch = payload.branch
|
||||
}
|
||||
|
||||
if (typeof payload?.personality === 'string') {
|
||||
@@ -666,28 +718,31 @@ export function useMessageStream({
|
||||
|
||||
if (typeof payload?.reasoning_effort === 'string') {
|
||||
setCurrentReasoningEffort(payload.reasoning_effort)
|
||||
runtimeInfo.reasoningEffort = payload.reasoning_effort
|
||||
}
|
||||
|
||||
if (typeof payload?.service_tier === 'string') {
|
||||
setCurrentServiceTier(payload.service_tier)
|
||||
runtimeInfo.serviceTier = payload.service_tier
|
||||
}
|
||||
|
||||
if (typeof payload?.fast === 'boolean') {
|
||||
setCurrentFastMode(payload.fast)
|
||||
runtimeInfo.fast = payload.fast
|
||||
}
|
||||
|
||||
if (typeof payload?.yolo === 'boolean') {
|
||||
setYoloActive(payload.yolo)
|
||||
runtimeInfo.yolo = payload.yolo
|
||||
}
|
||||
}
|
||||
|
||||
if (sessionId && Object.keys(runtimeInfo).length > 0) {
|
||||
updateSessionState(sessionId, state => ({ ...state, ...runtimeInfo }))
|
||||
}
|
||||
if (sessionId && hasStatePatch) {
|
||||
updateSessionState(sessionId, state => ({
|
||||
...state,
|
||||
...statePatch,
|
||||
branch: statePatch.branch ?? state.branch,
|
||||
cwd: statePatch.cwd ?? state.cwd
|
||||
}))
|
||||
}
|
||||
|
||||
if (apply) {
|
||||
if (runningChanged && sessionId) {
|
||||
updateSessionState(sessionId, state => {
|
||||
const busy = Boolean(payload!.running)
|
||||
|
||||
@@ -43,7 +43,7 @@ import {
|
||||
workspaceCwdForNewSession
|
||||
} from '@/store/session'
|
||||
import { reportBackendContract } from '@/store/updates'
|
||||
import type { SessionCreateResponse, SessionInfo, SessionResumeResponse, UsageStats } from '@/types/hermes'
|
||||
import type { SessionCreateResponse, SessionInfo, SessionResumeResponse, SessionRuntimeInfo, UsageStats } from '@/types/hermes'
|
||||
|
||||
import { NEW_CHAT_ROUTE, sessionRoute, SETTINGS_ROUTE } from '../../routes'
|
||||
import type { ClientSessionState, SidebarNavItem } from '../../types'
|
||||
@@ -209,16 +209,27 @@ function patchSessionWorkspace(sessionId: string, cwd: string | undefined) {
|
||||
setSessions(prev => prev.map(session => (session.id === sessionId ? { ...session, cwd } : session)))
|
||||
}
|
||||
|
||||
function applyRuntimeInfo(info: SessionCreateResponse['info'] | undefined): Partial<
|
||||
Pick<ClientSessionState, 'branch' | 'cwd' | 'fast' | 'model' | 'provider' | 'reasoningEffort' | 'serviceTier' | 'yolo'>
|
||||
> | null {
|
||||
type SessionRuntimeStatePatch = Partial<
|
||||
Pick<
|
||||
ClientSessionState,
|
||||
| 'branch'
|
||||
| 'cwd'
|
||||
| 'fast'
|
||||
| 'model'
|
||||
| 'personality'
|
||||
| 'provider'
|
||||
| 'reasoningEffort'
|
||||
| 'serviceTier'
|
||||
| 'yolo'
|
||||
>
|
||||
>
|
||||
|
||||
function applyRuntimeInfo(info: SessionRuntimeInfo | undefined): SessionRuntimeStatePatch | null {
|
||||
if (!info) {
|
||||
return null
|
||||
}
|
||||
|
||||
const sessionState: Partial<
|
||||
Pick<ClientSessionState, 'branch' | 'cwd' | 'fast' | 'model' | 'provider' | 'reasoningEffort' | 'serviceTier' | 'yolo'>
|
||||
> = {}
|
||||
const sessionState: SessionRuntimeStatePatch = {}
|
||||
|
||||
reportBackendContract(info.desktop_contract)
|
||||
|
||||
@@ -226,12 +237,12 @@ function applyRuntimeInfo(info: SessionCreateResponse['info'] | undefined): Part
|
||||
requestDesktopOnboarding(info.credential_warning)
|
||||
}
|
||||
|
||||
if (info.model) {
|
||||
if (typeof info.model === 'string') {
|
||||
setCurrentModel(info.model)
|
||||
sessionState.model = info.model
|
||||
}
|
||||
|
||||
if (info.provider) {
|
||||
if (typeof info.provider === 'string') {
|
||||
setCurrentProvider(info.provider)
|
||||
sessionState.provider = info.provider
|
||||
}
|
||||
@@ -247,7 +258,9 @@ function applyRuntimeInfo(info: SessionCreateResponse['info'] | undefined): Part
|
||||
}
|
||||
|
||||
if (typeof info.personality === 'string') {
|
||||
setCurrentPersonality(normalizePersonalityValue(info.personality))
|
||||
const personality = normalizePersonalityValue(info.personality)
|
||||
setCurrentPersonality(personality)
|
||||
sessionState.personality = personality
|
||||
}
|
||||
|
||||
if (typeof info.reasoning_effort === 'string') {
|
||||
@@ -277,6 +290,16 @@ function applyRuntimeInfo(info: SessionCreateResponse['info'] | undefined): Part
|
||||
return sessionState
|
||||
}
|
||||
|
||||
function applyStoredSessionPreviewRuntimeInfo(stored: { model?: null | string } | undefined) {
|
||||
setCurrentModel(stored?.model || '')
|
||||
setCurrentProvider('')
|
||||
setCurrentReasoningEffort('')
|
||||
setCurrentServiceTier('')
|
||||
setCurrentFastMode(false)
|
||||
setYoloActive(false)
|
||||
setCurrentPersonality('')
|
||||
}
|
||||
|
||||
export function useSessionActions({
|
||||
activeSessionId,
|
||||
activeSessionIdRef,
|
||||
@@ -465,15 +488,28 @@ export function useSessionActions({
|
||||
const cachedState = cachedRuntimeId && sessionStateByRuntimeIdRef.current.get(cachedRuntimeId)
|
||||
|
||||
if (cachedRuntimeId && cachedState) {
|
||||
const stored = $sessions.get().find(session => session.id === storedSessionId)
|
||||
const cachedViewState =
|
||||
!cachedState.model && stored?.model != null
|
||||
? {
|
||||
...cachedState,
|
||||
model: stored.model || ''
|
||||
}
|
||||
: cachedState
|
||||
|
||||
if (cachedViewState !== cachedState) {
|
||||
sessionStateByRuntimeIdRef.current.set(cachedRuntimeId, cachedViewState)
|
||||
}
|
||||
|
||||
setFreshDraftReady(false)
|
||||
clearNotifications()
|
||||
setSelectedStoredSessionId(storedSessionId)
|
||||
selectedStoredSessionIdRef.current = storedSessionId
|
||||
setActiveSessionId(cachedRuntimeId)
|
||||
activeSessionIdRef.current = cachedRuntimeId
|
||||
syncSessionStateToView(cachedRuntimeId, cachedState)
|
||||
setCurrentCwd(cachedState.cwd)
|
||||
setCurrentBranch(cachedState.branch)
|
||||
syncSessionStateToView(cachedRuntimeId, cachedViewState)
|
||||
setCurrentCwd(cachedViewState.cwd)
|
||||
setCurrentBranch(cachedViewState.branch)
|
||||
setSessionStartedAt(Date.now())
|
||||
|
||||
try {
|
||||
@@ -514,6 +550,7 @@ export function useSessionActions({
|
||||
selectedStoredSessionIdRef.current = storedSessionId
|
||||
setSessionStartedAt(Date.now())
|
||||
const stored = $sessions.get().find(session => session.id === storedSessionId)
|
||||
applyStoredSessionPreviewRuntimeInfo(stored)
|
||||
|
||||
if (stored) {
|
||||
setCurrentUsage(current => ({
|
||||
|
||||
@@ -2,7 +2,20 @@ import { act, cleanup, render } from '@testing-library/react'
|
||||
import type { MutableRefObject } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { $turnStartedAt, setTurnStartedAt } from '@/store/session'
|
||||
import {
|
||||
$currentFastMode,
|
||||
$currentModel,
|
||||
$currentProvider,
|
||||
$currentReasoningEffort,
|
||||
$currentServiceTier,
|
||||
$turnStartedAt,
|
||||
setCurrentFastMode,
|
||||
setCurrentModel,
|
||||
setCurrentProvider,
|
||||
setCurrentReasoningEffort,
|
||||
setCurrentServiceTier,
|
||||
setTurnStartedAt
|
||||
} from '@/store/session'
|
||||
|
||||
import { useSessionStateCache } from './use-session-state-cache'
|
||||
|
||||
@@ -46,12 +59,22 @@ describe('useSessionStateCache — per-session turn timer', () => {
|
||||
return null as unknown as number
|
||||
})
|
||||
setTurnStartedAt(null)
|
||||
setCurrentModel('')
|
||||
setCurrentProvider('')
|
||||
setCurrentReasoningEffort('')
|
||||
setCurrentServiceTier('')
|
||||
setCurrentFastMode(false)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
setTurnStartedAt(null)
|
||||
setCurrentModel('')
|
||||
setCurrentProvider('')
|
||||
setCurrentReasoningEffort('')
|
||||
setCurrentServiceTier('')
|
||||
setCurrentFastMode(false)
|
||||
})
|
||||
|
||||
it("keeps a background session's running turn clock and never mirrors it to the view", () => {
|
||||
@@ -115,4 +138,78 @@ describe('useSessionStateCache — per-session turn timer', () => {
|
||||
})
|
||||
expect($turnStartedAt.get()).toBeNull()
|
||||
})
|
||||
|
||||
it('mirrors the focused session model metadata when switching from a cached session', () => {
|
||||
let cache!: Cache
|
||||
const { rerender } = render(
|
||||
<Harness activeSessionId="fg-runtime" onReady={c => (cache = c)} selectedStoredSessionId="fg-stored" />
|
||||
)
|
||||
|
||||
act(() => {
|
||||
cache.updateSessionState(
|
||||
'bg-runtime',
|
||||
state => ({
|
||||
...state,
|
||||
fast: true,
|
||||
model: 'anthropic/claude-opus-4.8',
|
||||
provider: 'anthropic',
|
||||
reasoningEffort: 'high',
|
||||
serviceTier: 'priority'
|
||||
}),
|
||||
'bg-stored'
|
||||
)
|
||||
})
|
||||
|
||||
// Background metadata is cached but must not bleed into the visible statusbar.
|
||||
expect($currentModel.get()).toBe('')
|
||||
expect($currentReasoningEffort.get()).toBe('')
|
||||
expect($currentFastMode.get()).toBe(false)
|
||||
|
||||
rerender(<Harness activeSessionId="bg-runtime" onReady={c => (cache = c)} selectedStoredSessionId="bg-stored" />)
|
||||
|
||||
const bgState = cache.sessionStateByRuntimeIdRef.current.get('bg-runtime')
|
||||
expect(bgState).toBeTruthy()
|
||||
|
||||
act(() => {
|
||||
cache.syncSessionStateToView('bg-runtime', bgState!)
|
||||
})
|
||||
|
||||
expect($currentModel.get()).toBe('anthropic/claude-opus-4.8')
|
||||
expect($currentProvider.get()).toBe('anthropic')
|
||||
expect($currentReasoningEffort.get()).toBe('high')
|
||||
expect($currentServiceTier.get()).toBe('priority')
|
||||
expect($currentFastMode.get()).toBe(true)
|
||||
})
|
||||
|
||||
it('clears stale model metadata when the newly focused session has no cached value', () => {
|
||||
setCurrentModel('previous-model')
|
||||
setCurrentProvider('previous-provider')
|
||||
setCurrentReasoningEffort('high')
|
||||
setCurrentServiceTier('priority')
|
||||
setCurrentFastMode(true)
|
||||
|
||||
let cache!: Cache
|
||||
const { rerender } = render(
|
||||
<Harness activeSessionId="fg-runtime" onReady={c => (cache = c)} selectedStoredSessionId="fg-stored" />
|
||||
)
|
||||
|
||||
act(() => {
|
||||
cache.updateSessionState('bg-runtime', state => ({ ...state }), 'bg-stored')
|
||||
})
|
||||
|
||||
rerender(<Harness activeSessionId="bg-runtime" onReady={c => (cache = c)} selectedStoredSessionId="bg-stored" />)
|
||||
|
||||
const bgState = cache.sessionStateByRuntimeIdRef.current.get('bg-runtime')
|
||||
expect(bgState).toBeTruthy()
|
||||
|
||||
act(() => {
|
||||
cache.syncSessionStateToView('bg-runtime', bgState!)
|
||||
})
|
||||
|
||||
expect($currentModel.get()).toBe('')
|
||||
expect($currentProvider.get()).toBe('')
|
||||
expect($currentReasoningEffort.get()).toBe('')
|
||||
expect($currentServiceTier.get()).toBe('')
|
||||
expect($currentFastMode.get()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
noteSessionActivity,
|
||||
setCurrentFastMode,
|
||||
setCurrentModel,
|
||||
setCurrentPersonality,
|
||||
setCurrentProvider,
|
||||
setCurrentReasoningEffort,
|
||||
setCurrentServiceTier,
|
||||
@@ -53,6 +54,16 @@ interface SessionStateCacheOptions {
|
||||
setMessages: (messages: ChatMessage[]) => void
|
||||
}
|
||||
|
||||
function syncRuntimeMetadataToView(state: ClientSessionState) {
|
||||
setCurrentModel(state.model ?? '')
|
||||
setCurrentProvider(state.provider ?? '')
|
||||
setCurrentReasoningEffort(state.reasoningEffort ?? '')
|
||||
setCurrentServiceTier(state.serviceTier ?? '')
|
||||
setCurrentFastMode(state.fast ?? false)
|
||||
setYoloActive(state.yolo ?? false)
|
||||
setCurrentPersonality(state.personality ?? '')
|
||||
}
|
||||
|
||||
export function useSessionStateCache({
|
||||
activeSessionId,
|
||||
busyRef,
|
||||
@@ -137,12 +148,7 @@ export function useSessionStateCache({
|
||||
setMessages(nextMessages)
|
||||
}
|
||||
|
||||
setCurrentModel(pending.state.model)
|
||||
setCurrentProvider(pending.state.provider)
|
||||
setCurrentReasoningEffort(pending.state.reasoningEffort)
|
||||
setCurrentServiceTier(pending.state.serviceTier)
|
||||
setCurrentFastMode(pending.state.fast)
|
||||
setYoloActive(pending.state.yolo)
|
||||
syncRuntimeMetadataToView(pending.state)
|
||||
setBusy(pending.state.busy)
|
||||
setMutableRef(busyRef, pending.state.busy)
|
||||
setAwaitingResponse(pending.state.awaitingResponse)
|
||||
@@ -167,6 +173,7 @@ export function useSessionStateCache({
|
||||
return
|
||||
}
|
||||
|
||||
syncRuntimeMetadataToView(state)
|
||||
pendingViewStateRef.current = { sessionId, state }
|
||||
|
||||
// Terminal / attention transitions (turn finished, error, or the agent is
|
||||
|
||||
@@ -129,6 +129,7 @@ export interface ClientSessionState {
|
||||
serviceTier: string
|
||||
fast: boolean
|
||||
yolo: boolean
|
||||
personality: string
|
||||
busy: boolean
|
||||
awaitingResponse: boolean
|
||||
streamId: string | null
|
||||
|
||||
@@ -46,6 +46,7 @@ export function createClientSessionState(
|
||||
serviceTier: '',
|
||||
fast: false,
|
||||
yolo: false,
|
||||
personality: '',
|
||||
busy: false,
|
||||
awaitingResponse: false,
|
||||
streamId: null,
|
||||
|
||||
@@ -539,6 +539,12 @@ class GatewayConfig:
|
||||
# Streaming configuration
|
||||
streaming: StreamingConfig = field(default_factory=StreamingConfig)
|
||||
|
||||
# HTTP Management API configuration
|
||||
http_enabled: bool = True
|
||||
http_host: str = "127.0.0.1"
|
||||
http_port: int = 0 # 0 = auto-assign
|
||||
http_token: Optional[str] = None # None = auto-generate
|
||||
|
||||
# Session store pruning: drop SessionEntry records older than this many
|
||||
# days from the in-memory dict and sessions.json. Keeps the store from
|
||||
# growing unbounded in gateways serving many chats/threads/users over
|
||||
@@ -641,6 +647,11 @@ class GatewayConfig:
|
||||
"unauthorized_dm_behavior": self.unauthorized_dm_behavior,
|
||||
"streaming": self.streaming.to_dict(),
|
||||
"session_store_max_age_days": self.session_store_max_age_days,
|
||||
# HTTP Management API
|
||||
"http_enabled": self.http_enabled,
|
||||
"http_host": self.http_host,
|
||||
"http_port": self.http_port,
|
||||
"http_token": self.http_token,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@@ -724,6 +735,11 @@ class GatewayConfig:
|
||||
unauthorized_dm_behavior=unauthorized_dm_behavior,
|
||||
streaming=StreamingConfig.from_dict(data.get("streaming", {})),
|
||||
session_store_max_age_days=session_store_max_age_days,
|
||||
# HTTP Management API
|
||||
http_enabled=_coerce_bool(data.get("http_enabled"), True),
|
||||
http_host=data.get("http_host", "127.0.0.1"),
|
||||
http_port=_coerce_optional_positive_int(data.get("http_port"), "http_port") or 0,
|
||||
http_token=data.get("http_token"),
|
||||
)
|
||||
|
||||
def get_unauthorized_dm_behavior(self, platform: Optional[Platform] = None) -> str:
|
||||
@@ -843,6 +859,23 @@ def load_gateway_config() -> GatewayConfig:
|
||||
"pair",
|
||||
)
|
||||
|
||||
# HTTP Management API config
|
||||
http_section = None
|
||||
if "gateway" in yaml_cfg and isinstance(yaml_cfg["gateway"], dict):
|
||||
http_section = yaml_cfg["gateway"].get("http")
|
||||
elif "http" in yaml_cfg:
|
||||
http_section = yaml_cfg.get("http")
|
||||
|
||||
if isinstance(http_section, dict):
|
||||
if "enabled" in http_section:
|
||||
gw_data["http_enabled"] = http_section["enabled"]
|
||||
if "host" in http_section:
|
||||
gw_data["http_host"] = http_section["host"]
|
||||
if "port" in http_section:
|
||||
gw_data["http_port"] = http_section["port"]
|
||||
if "token" in http_section:
|
||||
gw_data["http_token"] = http_section["token"]
|
||||
|
||||
# Merge platform config into gw_data so runtime-only settings under
|
||||
# ``gateway.platforms`` are loaded the same way as top-level
|
||||
# ``platforms``. Merge nested first so top-level config keeps
|
||||
@@ -2078,3 +2111,23 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
|
||||
|
||||
for platform_config in config.platforms.values():
|
||||
platform_config.extra.pop("_enabled_explicit", None)
|
||||
|
||||
# HTTP Management API
|
||||
http_enabled = os.getenv("GATEWAY_HTTP_ENABLED")
|
||||
if http_enabled is not None:
|
||||
config.http_enabled = http_enabled.lower() in {"true", "1", "yes"}
|
||||
|
||||
http_host = os.getenv("GATEWAY_HTTP_HOST")
|
||||
if http_host:
|
||||
config.http_host = http_host
|
||||
|
||||
http_port = os.getenv("GATEWAY_HTTP_PORT")
|
||||
if http_port:
|
||||
try:
|
||||
config.http_port = int(http_port)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
http_token = os.getenv("GATEWAY_HTTP_TOKEN") or os.getenv("HERMES_DASHBOARD_SESSION_TOKEN")
|
||||
if http_token:
|
||||
config.http_token = http_token
|
||||
|
||||
+1328
File diff suppressed because it is too large
Load Diff
@@ -3837,6 +3837,33 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
)
|
||||
return error
|
||||
|
||||
def _telegram_media_too_large_note(self, label: str, file_size: Any, max_bytes: int) -> str:
|
||||
limit_mb = max(1, max_bytes // (1024 * 1024))
|
||||
try:
|
||||
size_mb = int(file_size or 0) / (1024 * 1024)
|
||||
size_text = f"{size_mb:.1f} MB"
|
||||
except (TypeError, ValueError):
|
||||
size_text = "unknown size"
|
||||
return (
|
||||
f"[Telegram {label} skipped: file size {size_text} exceeds the "
|
||||
f"{limit_mb} MB limit. Ask the user to send a shorter voice note "
|
||||
"or a smaller audio file.]"
|
||||
)
|
||||
|
||||
def _telegram_media_size_allowed(self, source: Any, label: str) -> tuple[bool, Optional[str]]:
|
||||
"""Validate Telegram media size before downloading into memory."""
|
||||
max_bytes = int(getattr(self, "_max_doc_bytes", 20 * 1024 * 1024) or 20 * 1024 * 1024)
|
||||
file_size = getattr(source, "file_size", None)
|
||||
try:
|
||||
size = int(file_size or 0)
|
||||
except (TypeError, ValueError):
|
||||
size = 0
|
||||
if size <= 0:
|
||||
return True, None
|
||||
if size <= max_bytes:
|
||||
return True, None
|
||||
return False, self._telegram_media_too_large_note(label, size, max_bytes)
|
||||
|
||||
async def send_voice(
|
||||
self,
|
||||
chat_id: str,
|
||||
@@ -5602,6 +5629,12 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
# Download voice/audio messages to cache for STT transcription
|
||||
if msg.voice:
|
||||
try:
|
||||
allowed, note = self._telegram_media_size_allowed(msg.voice, "voice message")
|
||||
if not allowed:
|
||||
event.text = self._append_observed_note(event.text, note or "")
|
||||
logger.info("[Telegram] Skipped oversized user voice (size=%s)", getattr(msg.voice, "file_size", None))
|
||||
await self.handle_message(event)
|
||||
return
|
||||
file_obj = await msg.voice.get_file()
|
||||
audio_bytes = await file_obj.download_as_bytearray()
|
||||
cached_path = cache_audio_from_bytes(bytes(audio_bytes), ext=".ogg")
|
||||
@@ -5612,6 +5645,12 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
logger.warning("[Telegram] Failed to cache voice: %s", e, exc_info=True)
|
||||
elif msg.audio:
|
||||
try:
|
||||
allowed, note = self._telegram_media_size_allowed(msg.audio, "audio file")
|
||||
if not allowed:
|
||||
event.text = self._append_observed_note(event.text, note or "")
|
||||
logger.info("[Telegram] Skipped oversized user audio (size=%s)", getattr(msg.audio, "file_size", None))
|
||||
await self.handle_message(event)
|
||||
return
|
||||
file_obj = await msg.audio.get_file()
|
||||
audio_bytes = await file_obj.download_as_bytearray()
|
||||
cached_path = cache_audio_from_bytes(bytes(audio_bytes), ext=".mp3")
|
||||
|
||||
+67
-2
@@ -15620,7 +15620,15 @@ def _start_cron_ticker(stop_event: threading.Event, adapters=None, loop=None, in
|
||||
logger.info("Cron ticker stopped")
|
||||
|
||||
|
||||
async def start_gateway(config: Optional[GatewayConfig] = None, replace: bool = False, verbosity: Optional[int] = 0) -> bool:
|
||||
async def start_gateway(
|
||||
config: Optional[GatewayConfig] = None,
|
||||
replace: bool = False,
|
||||
verbosity: Optional[int] = 0,
|
||||
http_port: Optional[int] = None,
|
||||
http_host: Optional[str] = None,
|
||||
http_token: Optional[str] = None,
|
||||
http_enabled: Optional[bool] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Start the gateway and run until interrupted.
|
||||
|
||||
@@ -15633,6 +15641,10 @@ async def start_gateway(config: Optional[GatewayConfig] = None, replace: bool =
|
||||
replace: If True, kill any existing gateway instance before starting.
|
||||
Useful for systemd services to avoid restart-loop deadlocks
|
||||
when the previous process hasn't fully exited yet.
|
||||
http_port: HTTP management API port (0 = auto-assign, None = use config)
|
||||
http_host: HTTP management API bind host (None = use config)
|
||||
http_token: HTTP management API token (None = auto-generate)
|
||||
http_enabled: If False, disable HTTP management API (None = use config)
|
||||
"""
|
||||
# ── Duplicate-instance guard ──────────────────────────────────────
|
||||
# Prevent two gateways from running under the same HERMES_HOME.
|
||||
@@ -15800,6 +15812,20 @@ async def start_gateway(config: Optional[GatewayConfig] = None, replace: bool =
|
||||
if _stderr_level < logging.getLogger().level:
|
||||
logging.getLogger().setLevel(_stderr_level)
|
||||
|
||||
# Ensure config exists and apply HTTP management API CLI overrides
|
||||
if config is None:
|
||||
config = load_gateway_config()
|
||||
|
||||
# Apply HTTP management API CLI overrides
|
||||
if http_enabled is not None:
|
||||
config.http_enabled = http_enabled
|
||||
if http_port is not None:
|
||||
config.http_port = http_port
|
||||
if http_host is not None:
|
||||
config.http_host = http_host
|
||||
if http_token is not None:
|
||||
config.http_token = http_token
|
||||
|
||||
runner = GatewayRunner(config)
|
||||
|
||||
# Track whether an unexpected signal initiated the shutdown. When an
|
||||
@@ -16013,6 +16039,35 @@ async def start_gateway(config: Optional[GatewayConfig] = None, replace: bool =
|
||||
logger.error("Gateway exiting cleanly: %s", runner.exit_reason)
|
||||
return True
|
||||
|
||||
# Start HTTP Management API server
|
||||
http_server = None
|
||||
http_task = None
|
||||
if config.http_enabled:
|
||||
try:
|
||||
import secrets
|
||||
from gateway.http_api import run_http_server as _run_http_server
|
||||
from gateway.status import write_gateway_http_info, remove_gateway_http_info
|
||||
|
||||
# Generate token if not provided
|
||||
http_token = config.http_token or secrets.token_urlsafe(32)
|
||||
config.http_token = http_token
|
||||
|
||||
http_server, actual_port = await _run_http_server(
|
||||
runner=runner,
|
||||
host=config.http_host,
|
||||
port=config.http_port,
|
||||
token=http_token,
|
||||
)
|
||||
config.http_port = actual_port
|
||||
logger.info("HTTP Management API started on %s:%d", config.http_host, actual_port)
|
||||
|
||||
# Publish host/port/token so dashboard, desktop, and other tools
|
||||
# can discover this gateway without needing to spawn it themselves.
|
||||
write_gateway_http_info(config.http_host, actual_port, http_token)
|
||||
atexit.register(remove_gateway_http_info)
|
||||
except Exception as e:
|
||||
logger.error("Failed to start HTTP Management API: %s", e)
|
||||
|
||||
# Start background cron ticker so scheduled jobs fire automatically.
|
||||
# Pass the event loop so cron delivery can use live adapters (E2EE support).
|
||||
cron_stop = threading.Event()
|
||||
@@ -16036,7 +16091,17 @@ async def start_gateway(config: Optional[GatewayConfig] = None, replace: bool =
|
||||
# Stop cron ticker cleanly
|
||||
cron_stop.set()
|
||||
cron_thread.join(timeout=5)
|
||||
|
||||
|
||||
# Stop HTTP Management API server
|
||||
if http_server is not None:
|
||||
try:
|
||||
from gateway.status import remove_gateway_http_info
|
||||
remove_gateway_http_info()
|
||||
await http_server.shutdown()
|
||||
logger.info("HTTP Management API stopped")
|
||||
except Exception as e:
|
||||
logger.debug("HTTP server shutdown error: %s", e)
|
||||
|
||||
# Stop the planned-stop watcher (daemon=True so this is belt-and-suspenders).
|
||||
_planned_stop_watcher_stop.set()
|
||||
_planned_stop_watcher_thread.join(timeout=2)
|
||||
|
||||
@@ -555,6 +555,88 @@ def read_runtime_status() -> Optional[dict[str, Any]]:
|
||||
return _read_json_file(_get_runtime_status_path())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HTTP management API discovery
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# While the gateway is running it writes its HTTP management API address
|
||||
# (host, port, token) to ``{HERMES_HOME}/gateway_http.json``. Any caller
|
||||
# (dashboard, desktop, CLI tool) that wants to proxy a request to a specific
|
||||
# profile's gateway reads this file to find where to connect.
|
||||
#
|
||||
# The file is profile-scoped because HERMES_HOME is profile-scoped: the
|
||||
# "default" profile writes to ``~/.hermes/gateway_http.json`` and a named
|
||||
# profile "worker" writes to ``~/.hermes/profiles/worker/gateway_http.json``.
|
||||
#
|
||||
# The PID in the file is checked for liveness so stale files from crashed
|
||||
# gateways are treated as "not running".
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_GATEWAY_HTTP_FILE = "gateway_http.json"
|
||||
|
||||
|
||||
def _get_gateway_http_path(hermes_home: Optional[Path] = None) -> Path:
|
||||
home = hermes_home if hermes_home is not None else get_hermes_home()
|
||||
return home / _GATEWAY_HTTP_FILE
|
||||
|
||||
|
||||
def write_gateway_http_info(
|
||||
host: str,
|
||||
port: int,
|
||||
token: str,
|
||||
hermes_home: Optional[Path] = None,
|
||||
) -> None:
|
||||
"""Persist this gateway's HTTP management API info so other processes can find it."""
|
||||
path = _get_gateway_http_path(hermes_home)
|
||||
_write_json_file(path, {
|
||||
"host": host,
|
||||
"port": port,
|
||||
"token": token,
|
||||
"pid": os.getpid(),
|
||||
"base_url": f"http://{host}:{port}",
|
||||
"ws_url": f"ws://{host}:{port}/api/ws",
|
||||
})
|
||||
|
||||
|
||||
def remove_gateway_http_info(hermes_home: Optional[Path] = None) -> None:
|
||||
"""Remove this gateway's HTTP management API info on shutdown (best-effort)."""
|
||||
path = _get_gateway_http_path(hermes_home)
|
||||
if not path.exists():
|
||||
return
|
||||
# Only remove if it belongs to this process, to avoid clobbering a
|
||||
# replacement gateway that already wrote its own file.
|
||||
try:
|
||||
data = _read_json_file(path)
|
||||
if data and data.get("pid") == os.getpid():
|
||||
path.unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def read_gateway_http_info(hermes_home: Optional[Path] = None) -> Optional[dict[str, Any]]:
|
||||
"""Read and validate a gateway's HTTP management API info.
|
||||
|
||||
Returns ``None`` when no gateway is running (file absent, stale PID, or
|
||||
corrupt JSON). Callers should fall back to direct file access when this
|
||||
returns ``None``.
|
||||
"""
|
||||
path = _get_gateway_http_path(hermes_home)
|
||||
data = _read_json_file(path)
|
||||
if not data:
|
||||
return None
|
||||
pid = data.get("pid")
|
||||
if pid and not _pid_exists(int(pid)):
|
||||
# Stale file from a crashed gateway — clean up silently.
|
||||
try:
|
||||
path.unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
if not data.get("port") or not data.get("token"):
|
||||
return None
|
||||
return data
|
||||
|
||||
|
||||
def remove_pid_file() -> None:
|
||||
"""Remove the gateway PID file, but only if it belongs to this process.
|
||||
|
||||
|
||||
+10
-15
@@ -270,6 +270,11 @@ _EXTRA_ENV_KEYS = frozenset({
|
||||
"IRC_SERVER", "IRC_PORT", "IRC_NICKNAME", "IRC_CHANNEL",
|
||||
"IRC_USE_TLS", "IRC_SERVER_PASSWORD", "IRC_NICKSERV_PASSWORD",
|
||||
"TERMINAL_ENV", "TERMINAL_SSH_KEY", "TERMINAL_SSH_PORT",
|
||||
# Deprecated tool-progress env vars — replaced by display.tool_progress in
|
||||
# config.yaml. Kept known here so .env sanitization/reload still handle
|
||||
# them for existing users (gateway reads them as a back-compat fallback),
|
||||
# without surfacing them in user-facing OPTIONAL_ENV_VARS listings.
|
||||
"HERMES_TOOL_PROGRESS", "HERMES_TOOL_PROGRESS_MODE",
|
||||
"WHATSAPP_MODE", "WHATSAPP_ENABLED",
|
||||
"MATTERMOST_HOME_CHANNEL", "MATTERMOST_HOME_CHANNEL_NAME", "MATTERMOST_REPLY_MODE",
|
||||
"MATRIX_PASSWORD", "MATRIX_ENCRYPTION", "MATRIX_DEVICE_ID", "MATRIX_HOME_ROOM",
|
||||
@@ -3557,21 +3562,11 @@ OPTIONAL_ENV_VARS = {
|
||||
},
|
||||
# HERMES_TOOL_PROGRESS and HERMES_TOOL_PROGRESS_MODE are deprecated —
|
||||
# now configured via display.tool_progress in config.yaml (off|new|all|verbose).
|
||||
# Gateway falls back to these env vars for backward compatibility.
|
||||
"HERMES_TOOL_PROGRESS": {
|
||||
"description": "(deprecated) Use display.tool_progress in config.yaml instead",
|
||||
"prompt": "Tool progress (deprecated — use config.yaml)",
|
||||
"url": None,
|
||||
"password": False,
|
||||
"category": "setting",
|
||||
},
|
||||
"HERMES_TOOL_PROGRESS_MODE": {
|
||||
"description": "(deprecated) Use display.tool_progress in config.yaml instead",
|
||||
"prompt": "Progress mode (deprecated — use config.yaml)",
|
||||
"url": None,
|
||||
"password": False,
|
||||
"category": "setting",
|
||||
},
|
||||
# The gateway still falls back to these env vars for backward compatibility,
|
||||
# so they live in _EXTRA_ENV_KEYS (known to .env sanitization/reload) but
|
||||
# are intentionally NOT listed here: OPTIONAL_ENV_VARS feeds user-facing
|
||||
# surfaces (dashboard keys page, setup checklists) and deprecated knobs
|
||||
# shouldn't be offered there.
|
||||
"HERMES_PREFILL_MESSAGES_FILE": {
|
||||
"description": "Path to JSON file with ephemeral prefill messages for few-shot priming",
|
||||
"prompt": "Prefill messages file path",
|
||||
|
||||
+14
-2
@@ -14,6 +14,7 @@ import sys
|
||||
import textwrap
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
|
||||
|
||||
@@ -3789,7 +3790,7 @@ def _guard_official_docker_root_gateway() -> None:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def run_gateway(verbose: int = 0, quiet: bool = False, replace: bool = False):
|
||||
def run_gateway(verbose: int = 0, quiet: bool = False, replace: bool = False, http_port: Optional[int] = None, http_host: Optional[str] = None, http_token: Optional[str] = None, no_http: bool = False):
|
||||
"""Run the gateway in foreground.
|
||||
|
||||
Args:
|
||||
@@ -3798,6 +3799,10 @@ def run_gateway(verbose: int = 0, quiet: bool = False, replace: bool = False):
|
||||
replace: If True, kill any existing gateway instance before starting.
|
||||
This prevents systemd restart loops when the old process
|
||||
hasn't fully exited yet.
|
||||
http_port: HTTP management API port (0 = auto-assign, default: from config)
|
||||
http_host: HTTP management API bind host (default: from config)
|
||||
http_token: HTTP management API token (auto-generated if not set)
|
||||
no_http: If True, disable HTTP management API
|
||||
"""
|
||||
_guard_official_docker_root_gateway()
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
@@ -3923,7 +3928,14 @@ def run_gateway(verbose: int = 0, quiet: bool = False, replace: bool = False):
|
||||
|
||||
success = False
|
||||
try:
|
||||
success = asyncio.run(start_gateway(replace=replace, verbosity=verbosity))
|
||||
success = asyncio.run(start_gateway(
|
||||
replace=replace,
|
||||
verbosity=verbosity,
|
||||
http_port=http_port,
|
||||
http_host=http_host,
|
||||
http_token=http_token,
|
||||
http_enabled=not no_http,
|
||||
))
|
||||
_exit_diag("asyncio.run.returned", success=success)
|
||||
except KeyboardInterrupt:
|
||||
# On Windows-detached runs this shouldn't fire (we absorb SIGINT above),
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
"""
|
||||
Shared helper for discovering and communicating with a profile's running
|
||||
gateway HTTP management API.
|
||||
|
||||
Usage
|
||||
-----
|
||||
from hermes_cli.gateway_http import get_profile_gateway, call_profile_gateway
|
||||
|
||||
info = get_profile_gateway("worker")
|
||||
if info:
|
||||
# Gateway is running — talk to it
|
||||
result = await call_profile_gateway("worker", "GET", "/api/config")
|
||||
else:
|
||||
# Not running — fall back to direct file access
|
||||
|
||||
The gateway writes ``{HERMES_HOME}/gateway_http.json`` when it starts.
|
||||
This module reads that file for any profile to obtain the host/port/token.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Token header name — matches the dashboard and the gateway's auth middleware.
|
||||
_TOKEN_HEADER = "X-Hermes-Session-Token"
|
||||
|
||||
|
||||
def _get_profile_home(profile: Optional[str]) -> Optional[Path]:
|
||||
"""Resolve a profile name to its HERMES_HOME directory.
|
||||
|
||||
Returns None for the default/current profile (callers use get_hermes_home()
|
||||
directly).
|
||||
"""
|
||||
if not profile or profile.lower() in ("default", "current", ""):
|
||||
return None
|
||||
try:
|
||||
from hermes_cli.profiles import get_profile_dir, profile_exists
|
||||
if not profile_exists(profile):
|
||||
return None
|
||||
return get_profile_dir(profile)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def get_profile_gateway(profile: Optional[str] = None) -> Optional[dict[str, Any]]:
|
||||
"""Return the running gateway's HTTP info for a profile, or None.
|
||||
|
||||
Returns a dict with ``base_url``, ``ws_url``, and ``token`` when a gateway
|
||||
is running for the given profile — or ``None`` when no gateway is up (file
|
||||
absent, stale PID, or gateway not configured with HTTP).
|
||||
|
||||
Callers must fall back to direct file access when this returns ``None``.
|
||||
|
||||
:param profile: Profile name, or None/'' for the current default profile.
|
||||
"""
|
||||
from gateway.status import read_gateway_http_info
|
||||
|
||||
home = _get_profile_home(profile)
|
||||
return read_gateway_http_info(home)
|
||||
|
||||
|
||||
async def call_profile_gateway(
|
||||
profile: Optional[str],
|
||||
method: str,
|
||||
path: str,
|
||||
**httpx_kwargs: Any,
|
||||
) -> Optional[Any]:
|
||||
"""Call a profile's gateway HTTP API.
|
||||
|
||||
Returns the parsed JSON response, or ``None`` when the gateway isn't
|
||||
running (so callers can fall back to ``_profile_scope``).
|
||||
|
||||
Raises ``httpx.HTTPStatusError`` on HTTP 4xx/5xx.
|
||||
|
||||
:param profile: Profile name, or None for the default profile.
|
||||
:param method: HTTP method (GET, POST, PUT, DELETE, PATCH).
|
||||
:param path: Path including leading slash, e.g. ``"/api/config"``.
|
||||
:param httpx_kwargs: Extra kwargs forwarded to ``httpx.AsyncClient.request``
|
||||
(e.g. ``json=...``, ``params=...``).
|
||||
"""
|
||||
info = get_profile_gateway(profile)
|
||||
if not info:
|
||||
return None
|
||||
|
||||
try:
|
||||
import httpx
|
||||
except ImportError:
|
||||
logger.debug("httpx not available; cannot proxy to profile gateway")
|
||||
return None
|
||||
|
||||
url = f"{info['base_url']}{path}"
|
||||
headers = {_TOKEN_HEADER: info["token"]}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
resp = await client.request(method, url, headers=headers, **httpx_kwargs)
|
||||
resp.raise_for_status()
|
||||
return resp.json() if resp.content else None
|
||||
except httpx.ConnectError:
|
||||
# Gateway reported as running but TCP refused — stale PID surviving a
|
||||
# crash where atexit didn't fire. Don't raise; caller falls back.
|
||||
logger.debug("Gateway HTTP connect failed for profile %r at %s", profile, url)
|
||||
return None
|
||||
except Exception:
|
||||
raise
|
||||
|
||||
|
||||
def call_profile_gateway_sync(
|
||||
profile: Optional[str],
|
||||
method: str,
|
||||
path: str,
|
||||
**httpx_kwargs: Any,
|
||||
) -> Optional[Any]:
|
||||
"""Synchronous wrapper around ``call_profile_gateway`` for non-async contexts.
|
||||
|
||||
Spins up a throwaway event loop. Prefer the async version when already
|
||||
inside an asyncio context.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
# A loop is already running — can't call asyncio.run() from here.
|
||||
# Caller is async and should use call_profile_gateway directly.
|
||||
logger.debug(
|
||||
"call_profile_gateway_sync called from a running loop; use async version"
|
||||
)
|
||||
return None
|
||||
except RuntimeError:
|
||||
pass # no running loop — safe to call asyncio.run()
|
||||
|
||||
return asyncio.run(call_profile_gateway(profile, method, path, **httpx_kwargs))
|
||||
@@ -5347,6 +5347,9 @@ def _find_stale_dashboard_pids(
|
||||
"hermes dashboard",
|
||||
"hermes_cli.main dashboard",
|
||||
"hermes_cli/main.py dashboard",
|
||||
"hermes gateway run",
|
||||
"hermes_cli.main gateway",
|
||||
"hermes_cli/main.py gateway",
|
||||
]
|
||||
self_pid = os.getpid()
|
||||
dashboard_pids: list[int] = []
|
||||
|
||||
@@ -58,6 +58,28 @@ def build_gateway_parser(subparsers, *, cmd_gateway: Callable, cmd_proxy: Callab
|
||||
"gateway's exit code. No effect outside an s6 container."
|
||||
),
|
||||
)
|
||||
# HTTP Management API
|
||||
gateway_run.add_argument(
|
||||
"--http-port",
|
||||
type=int,
|
||||
default=None,
|
||||
help="HTTP management API port (0 = auto-assign, default: 0)",
|
||||
)
|
||||
gateway_run.add_argument(
|
||||
"--http-host",
|
||||
default=None,
|
||||
help="HTTP management API bind host (default: 127.0.0.1)",
|
||||
)
|
||||
gateway_run.add_argument(
|
||||
"--http-token",
|
||||
default=None,
|
||||
help="HTTP management API token (auto-generated if not set)",
|
||||
)
|
||||
gateway_run.add_argument(
|
||||
"--no-http",
|
||||
action="store_true",
|
||||
help="Disable HTTP management API",
|
||||
)
|
||||
add_accept_hooks_flag(gateway_run)
|
||||
add_accept_hooks_flag(gateway_parser)
|
||||
|
||||
|
||||
+159
-49
@@ -2931,8 +2931,10 @@ async def set_model_assignment(body: ModelAssignment, profile: Optional[str] = N
|
||||
"confirm_message": warning.message,
|
||||
}
|
||||
|
||||
effective = body.profile or profile
|
||||
|
||||
def _apply_assignment():
|
||||
with _profile_scope(body.profile or profile):
|
||||
with _profile_scope(effective):
|
||||
return _apply_model_assignment_sync(
|
||||
scope, provider, model, task, base_url
|
||||
)
|
||||
@@ -4468,22 +4470,27 @@ def _truncate_token(value: Optional[str], visible: int = 6) -> str:
|
||||
|
||||
|
||||
def _anthropic_oauth_status() -> Dict[str, Any]:
|
||||
"""Combined status across the three Anthropic credential sources we read.
|
||||
"""Status for the "Anthropic API Key" catalog entry.
|
||||
|
||||
Hermes resolves Anthropic creds in this order at runtime:
|
||||
1. ``~/.hermes/.anthropic_oauth.json`` — Hermes-managed PKCE flow
|
||||
2. ``~/.claude/.credentials.json`` — Claude Code CLI credentials (auto)
|
||||
3. ``ANTHROPIC_TOKEN`` / ``ANTHROPIC_API_KEY`` env vars
|
||||
The dashboard reports the highest-priority source that's actually present.
|
||||
Two sources, in priority order:
|
||||
1. ``~/.hermes/.anthropic_oauth.json`` — Hermes-managed PKCE flow (what
|
||||
this entry's Connect button writes)
|
||||
2. ``ANTHROPIC_API_KEY`` → ``ANTHROPIC_TOKEN`` → ``CLAUDE_CODE_OAUTH_TOKEN``
|
||||
env vars (registry order) — from ``.env``, the shell, or an external
|
||||
secret source like Bitwarden (whose keys are injected into the process
|
||||
env during ``load_hermes_dotenv()``, so the same check covers them)
|
||||
|
||||
Claude Code's ``~/.claude/.credentials.json`` is deliberately NOT read
|
||||
here — it has its own dedicated catalog entry (``claude-code`` →
|
||||
``_claude_code_only_status``). Reporting it under the API-key entry
|
||||
double-counts the token and shadows a real ANTHROPIC_API_KEY.
|
||||
"""
|
||||
try:
|
||||
from agent.anthropic_adapter import (
|
||||
read_hermes_oauth_credentials,
|
||||
read_claude_code_credentials,
|
||||
_HERMES_OAUTH_FILE,
|
||||
)
|
||||
except ImportError:
|
||||
read_claude_code_credentials = None # type: ignore
|
||||
read_hermes_oauth_credentials = None # type: ignore
|
||||
_HERMES_OAUTH_FILE = None # type: ignore
|
||||
|
||||
@@ -4503,29 +4510,33 @@ def _anthropic_oauth_status() -> Dict[str, Any]:
|
||||
"has_refresh_token": bool(hermes_creds.get("refreshToken")),
|
||||
}
|
||||
|
||||
cc_creds = None
|
||||
if read_claude_code_credentials:
|
||||
try:
|
||||
cc_creds = read_claude_code_credentials()
|
||||
except Exception:
|
||||
cc_creds = None
|
||||
if cc_creds and cc_creds.get("accessToken"):
|
||||
return {
|
||||
"logged_in": True,
|
||||
"source": "claude_code",
|
||||
"source_label": "Claude Code (~/.claude/.credentials.json)",
|
||||
"token_preview": _truncate_token(cc_creds.get("accessToken")),
|
||||
"expires_at": cc_creds.get("expiresAt"),
|
||||
"has_refresh_token": bool(cc_creds.get("refreshToken")),
|
||||
}
|
||||
# Env-var / secret-source path. ``get_env_value`` checks the process
|
||||
# environment first (where Bitwarden-sourced secrets land) then .env.
|
||||
env_var_order: tuple = ("ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN")
|
||||
try:
|
||||
from hermes_cli.auth import PROVIDER_REGISTRY
|
||||
env_var_order = PROVIDER_REGISTRY["anthropic"].api_key_env_vars
|
||||
except (ImportError, KeyError):
|
||||
pass
|
||||
try:
|
||||
from hermes_cli.config import get_env_value
|
||||
except ImportError:
|
||||
get_env_value = None # type: ignore
|
||||
try:
|
||||
from hermes_cli.env_loader import format_secret_source_suffix
|
||||
except ImportError:
|
||||
format_secret_source_suffix = None # type: ignore
|
||||
|
||||
env_token = os.getenv("ANTHROPIC_TOKEN") or os.getenv("CLAUDE_CODE_OAUTH_TOKEN")
|
||||
if env_token:
|
||||
for var in env_var_order:
|
||||
value = (get_env_value(var) if get_env_value else None) or os.getenv(var)
|
||||
if not value:
|
||||
continue
|
||||
suffix = format_secret_source_suffix(var) if format_secret_source_suffix else ""
|
||||
return {
|
||||
"logged_in": True,
|
||||
"source": "env_var",
|
||||
"source_label": "ANTHROPIC_TOKEN environment variable",
|
||||
"token_preview": _truncate_token(env_token),
|
||||
"source_label": f"{var}{suffix}",
|
||||
"token_preview": _truncate_token(value),
|
||||
"expires_at": None,
|
||||
"has_refresh_token": False,
|
||||
}
|
||||
@@ -8701,6 +8712,31 @@ def _profile_scope(profile: Optional[str]):
|
||||
reset_hermes_home_override(token)
|
||||
|
||||
|
||||
async def _profile_gateway_write(
|
||||
profile: Optional[str],
|
||||
method: str,
|
||||
path: str,
|
||||
**httpx_kwargs,
|
||||
) -> Optional[Any]:
|
||||
"""Try to proxy a write to a profile's running gateway HTTP API.
|
||||
|
||||
Returns the gateway's JSON response when the gateway is running, or
|
||||
``None`` when it isn't (caller falls back to ``_profile_scope``).
|
||||
|
||||
This is the one-line adapter for phase 4c: every write endpoint calls this
|
||||
first, and only enters ``_profile_scope`` on a ``None`` return.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.gateway_http import call_profile_gateway
|
||||
return await call_profile_gateway(profile, method, path, **httpx_kwargs)
|
||||
except Exception:
|
||||
_log.debug(
|
||||
"Gateway write proxy failed for profile=%r %s %s, falling back",
|
||||
profile, method, path, exc_info=True,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
class SkillToggle(BaseModel):
|
||||
name: str
|
||||
enabled: bool
|
||||
@@ -8723,7 +8759,15 @@ async def get_skills(profile: Optional[str] = None):
|
||||
@app.put("/api/skills/toggle")
|
||||
async def toggle_skill(body: SkillToggle, profile: Optional[str] = None):
|
||||
from hermes_cli.skills_config import get_disabled_skills, save_disabled_skills
|
||||
with _profile_scope(body.profile or profile):
|
||||
effective = body.profile or profile
|
||||
# Try proxying to the profile's running gateway first
|
||||
gw = await _profile_gateway_write(
|
||||
effective, "PUT", "/api/skills/toggle",
|
||||
json={"name": body.name, "enabled": body.enabled},
|
||||
)
|
||||
if gw is not None:
|
||||
return {"ok": True, "name": body.name, "enabled": body.enabled}
|
||||
with _profile_scope(effective):
|
||||
config = load_config()
|
||||
disabled = get_disabled_skills(config)
|
||||
if body.enabled:
|
||||
@@ -8781,15 +8825,16 @@ async def get_skill_content(name: str, profile: Optional[str] = None):
|
||||
|
||||
@app.post("/api/skills")
|
||||
async def create_skill(body: SkillCreate):
|
||||
"""Create a new custom skill (SKILL.md) from the dashboard editor.
|
||||
|
||||
Calls the same validated write path as the agent's ``skill_manage``
|
||||
tool (frontmatter validation, name/category validation, size limit,
|
||||
optional security scan) — but bypasses the agent write-approval gate:
|
||||
a write from the authenticated dashboard IS the user acting directly.
|
||||
"""
|
||||
"""Create a new custom skill (SKILL.md) from the dashboard editor."""
|
||||
from tools.skill_manager_tool import _create_skill
|
||||
|
||||
gw = await _profile_gateway_write(
|
||||
body.profile, "POST", "/api/skills",
|
||||
json={"name": body.name, "content": body.content, "category": body.category},
|
||||
)
|
||||
if gw is not None:
|
||||
_clear_skills_prompt_cache()
|
||||
return gw
|
||||
with _profile_scope(body.profile):
|
||||
result = _create_skill(body.name, body.content, body.category or None)
|
||||
if not result.get("success"):
|
||||
@@ -8803,6 +8848,13 @@ async def update_skill_content(body: SkillContentUpdate):
|
||||
"""Replace the SKILL.md of an existing skill (full rewrite) from the editor."""
|
||||
from tools.skill_manager_tool import _edit_skill
|
||||
|
||||
gw = await _profile_gateway_write(
|
||||
body.profile, "PUT", "/api/skills/content",
|
||||
json={"name": body.name, "content": body.content},
|
||||
)
|
||||
if gw is not None:
|
||||
_clear_skills_prompt_cache()
|
||||
return gw
|
||||
with _profile_scope(body.profile):
|
||||
result = _edit_skill(body.name, body.content)
|
||||
if not result.get("success"):
|
||||
@@ -8873,16 +8925,23 @@ async def toggle_toolset(name: str, body: ToolsetToggle, profile: Optional[str]
|
||||
if name not in valid:
|
||||
raise HTTPException(status_code=400, detail=f"Unknown toolset: {name}")
|
||||
|
||||
with _profile_scope(body.profile or profile):
|
||||
effective = body.profile or profile
|
||||
gw = await _profile_gateway_write(
|
||||
effective, "POST", f"/api/tools/toolsets/{name}/config",
|
||||
params={"enabled": str(body.enabled).lower()},
|
||||
)
|
||||
if gw is not None:
|
||||
return {"ok": True, "name": name, "enabled": body.enabled}
|
||||
with _profile_scope(effective):
|
||||
config = load_config()
|
||||
enabled = set(
|
||||
enabled_set = set(
|
||||
_get_platform_tools(config, "cli", include_default_mcp_servers=False)
|
||||
)
|
||||
if body.enabled:
|
||||
enabled.add(name)
|
||||
enabled_set.add(name)
|
||||
else:
|
||||
enabled.discard(name)
|
||||
_save_platform_tools(config, "cli", enabled)
|
||||
enabled_set.discard(name)
|
||||
_save_platform_tools(config, "cli", enabled_set)
|
||||
return {"ok": True, "name": name, "enabled": body.enabled}
|
||||
|
||||
|
||||
@@ -8975,7 +9034,14 @@ async def select_toolset_provider(
|
||||
if name not in valid:
|
||||
raise HTTPException(status_code=400, detail=f"Unknown toolset: {name}")
|
||||
|
||||
with _profile_scope(body.profile or profile):
|
||||
effective = body.profile or profile
|
||||
gw = await _profile_gateway_write(
|
||||
effective, "POST", f"/api/tools/toolsets/{name}/provider",
|
||||
json={"provider": body.provider},
|
||||
)
|
||||
if gw is not None:
|
||||
return {"ok": True, "name": name, "provider": body.provider}
|
||||
with _profile_scope(effective):
|
||||
config = load_config()
|
||||
try:
|
||||
apply_provider_selection(name, body.provider, config)
|
||||
@@ -9013,7 +9079,16 @@ async def save_toolset_env(name: str, body: ToolsetEnvUpdate, profile: Optional[
|
||||
if name not in valid_ts:
|
||||
raise HTTPException(status_code=400, detail=f"Unknown toolset: {name}")
|
||||
|
||||
with _profile_scope(body.profile or profile):
|
||||
effective = body.profile or profile
|
||||
# Env writes: each key goes to ~/.hermes/.env; proxy to the gateway so it
|
||||
# picks up the new values in its live process environment.
|
||||
gw = await _profile_gateway_write(
|
||||
effective, "PUT", f"/api/tools/toolsets/{name}/env",
|
||||
json={"env": body.env},
|
||||
)
|
||||
if gw is not None:
|
||||
return {"ok": True, "name": name, **gw}
|
||||
with _profile_scope(effective):
|
||||
config = load_config()
|
||||
cat = TOOL_CATEGORIES.get(name)
|
||||
allowed: set[str] = set()
|
||||
@@ -9125,8 +9200,16 @@ async def update_config_raw(body: RawConfigUpdate, profile: Optional[str] = None
|
||||
parsed = yaml.safe_load(body.yaml_text)
|
||||
if not isinstance(parsed, dict):
|
||||
raise HTTPException(status_code=400, detail="YAML must be a mapping")
|
||||
with _profile_scope(body.profile or profile):
|
||||
save_config(parsed)
|
||||
effective = body.profile or profile
|
||||
# Try gateway first so the live process picks up config changes immediately.
|
||||
# Use PUT /api/config/raw which accepts a full YAML string.
|
||||
gw = await _profile_gateway_write(
|
||||
effective, "PUT", "/api/config/raw",
|
||||
params={"yaml_text": body.yaml_text},
|
||||
)
|
||||
if gw is None:
|
||||
with _profile_scope(effective):
|
||||
save_config(parsed)
|
||||
return {"ok": True}
|
||||
except yaml.YAMLError as e:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid YAML: {e}")
|
||||
@@ -9641,13 +9724,40 @@ def _resolve_chat_argv(
|
||||
if sidecar_url:
|
||||
env["HERMES_TUI_SIDECAR_URL"] = sidecar_url
|
||||
|
||||
# Profile-scoped chats must NOT attach to the dashboard's in-memory
|
||||
# gateway — it runs under the dashboard's own profile. Without the
|
||||
# attach URL, gatewayClient spawns its own `tui_gateway.entry`, which
|
||||
# inherits the profile HERMES_HOME set above.
|
||||
# Profile-scoped chats: prefer attaching to the profile's own running
|
||||
# gateway (which already has the right HERMES_HOME, config, skills, etc.)
|
||||
# over the old approach of spawning a fresh tui_gateway.entry subprocess
|
||||
# with HERMES_HOME env-injected.
|
||||
#
|
||||
# When no gateway is running for that profile we fall back to the previous
|
||||
# behaviour: no HERMES_TUI_GATEWAY_URL, so tui_gateway.entry spawns its
|
||||
# own instance inheriting the HERMES_HOME we set above.
|
||||
if profile_dir is None:
|
||||
# Default/current profile: attach to this dashboard's in-memory gateway.
|
||||
if gateway_ws_url := _build_gateway_ws_url():
|
||||
env["HERMES_TUI_GATEWAY_URL"] = gateway_ws_url
|
||||
else:
|
||||
# Named profile: try to attach to that profile's running gateway.
|
||||
try:
|
||||
from hermes_cli.gateway_http import get_profile_gateway
|
||||
gw = get_profile_gateway(requested)
|
||||
if gw:
|
||||
# Gateway is running for this profile — attach directly.
|
||||
# Use ?token= on the ws url so it works with our auth middleware.
|
||||
import urllib.parse as _up
|
||||
ws_url = gw["ws_url"]
|
||||
token = gw["token"]
|
||||
ws_url_with_token = (
|
||||
ws_url + ("&" if "?" in ws_url else "?") +
|
||||
_up.urlencode({"token": token})
|
||||
)
|
||||
env["HERMES_TUI_GATEWAY_URL"] = ws_url_with_token
|
||||
# Gateway process owns HERMES_HOME — no need to override it.
|
||||
env.pop("HERMES_HOME", None)
|
||||
except Exception:
|
||||
_log.debug("Failed to look up gateway for profile %r", requested, exc_info=True)
|
||||
# Fall back: keep HERMES_HOME set, no HERMES_TUI_GATEWAY_URL,
|
||||
# tui_gateway.entry will spawn its own instance.
|
||||
|
||||
return list(argv), str(cwd) if cwd else None, env
|
||||
|
||||
|
||||
+2
-5
@@ -703,7 +703,7 @@ check_git() {
|
||||
}
|
||||
|
||||
# The desktop build runs Vite ^8, which refuses to start on Node outside
|
||||
# `^20.19 || >=22.12` — older Node lacks `node:util.styleText`, so `vite build`
|
||||
# `>=26.0.0` — older Node lacks the required features, so `vite build`
|
||||
# crashes with a SyntaxError that surfaces only as the opaque "Build desktop
|
||||
# app … exit code 1" install failure. Returns 0 when the given `node --version`
|
||||
# string clears that floor; anything below it is replaced with the Hermes-
|
||||
@@ -711,11 +711,8 @@ check_git() {
|
||||
node_satisfies_build() {
|
||||
local ver="${1#v}"
|
||||
local major="${ver%%.*}"
|
||||
local minor="${ver#*.}"; minor="${minor%%.*}"
|
||||
case "$major" in ''|*[!0-9]*) return 1 ;; esac
|
||||
case "$minor" in ''|*[!0-9]*) minor=0 ;; esac
|
||||
if [ "$major" -eq 20 ] && [ "$minor" -ge 19 ]; then return 0; fi
|
||||
if [ "$major" -ge 22 ] && { [ "$major" -gt 22 ] || [ "$minor" -ge 12 ]; }; then return 0; fi
|
||||
if [ "$major" -ge 26 ]; then return 0; fi
|
||||
return 1
|
||||
}
|
||||
|
||||
|
||||
@@ -1471,3 +1471,127 @@ class TestCallConverseInvalidatesOnStaleError:
|
||||
)
|
||||
|
||||
assert _bedrock_runtime_client_cache.get("us-east-1") is live_client
|
||||
|
||||
|
||||
class TestStreamingAccessDeniedDetection:
|
||||
"""is_streaming_access_denied_error() recognizes IAM denials of
|
||||
bedrock:InvokeModelWithResponseStream (InvokeModel-only policies)."""
|
||||
|
||||
def _denied_client_error(self):
|
||||
from botocore.exceptions import ClientError
|
||||
return ClientError(
|
||||
error_response={
|
||||
"Error": {
|
||||
"Code": "AccessDeniedException",
|
||||
"Message": (
|
||||
"User: arn:aws:iam::123456789012:user/x is not "
|
||||
"authorized to perform: "
|
||||
"bedrock:InvokeModelWithResponseStream on resource: "
|
||||
"arn:aws:bedrock:us-east-1::foundation-model/"
|
||||
"anthropic.claude-3-sonnet-20240229-v1:0"
|
||||
),
|
||||
}
|
||||
},
|
||||
operation_name="ConverseStream",
|
||||
)
|
||||
|
||||
def test_matches_access_denied_client_error(self):
|
||||
pytest.importorskip("botocore", reason="botocore required for Bedrock exception tests")
|
||||
from agent.bedrock_adapter import is_streaming_access_denied_error
|
||||
assert is_streaming_access_denied_error(self._denied_client_error()) is True
|
||||
|
||||
def test_ignores_access_denied_for_other_actions(self):
|
||||
"""AccessDenied on InvokeModel itself is NOT a streaming-only denial."""
|
||||
pytest.importorskip("botocore", reason="botocore required for Bedrock exception tests")
|
||||
from agent.bedrock_adapter import is_streaming_access_denied_error
|
||||
from botocore.exceptions import ClientError
|
||||
exc = ClientError(
|
||||
error_response={
|
||||
"Error": {
|
||||
"Code": "AccessDeniedException",
|
||||
"Message": (
|
||||
"User is not authorized to perform: bedrock:InvokeModel"
|
||||
),
|
||||
}
|
||||
},
|
||||
operation_name="Converse",
|
||||
)
|
||||
assert is_streaming_access_denied_error(exc) is False
|
||||
|
||||
def test_ignores_validation_error_mentioning_action(self):
|
||||
"""Non-authz ClientErrors don't match even if the action name appears."""
|
||||
pytest.importorskip("botocore", reason="botocore required for Bedrock exception tests")
|
||||
from agent.bedrock_adapter import is_streaming_access_denied_error
|
||||
from botocore.exceptions import ClientError
|
||||
exc = ClientError(
|
||||
error_response={
|
||||
"Error": {
|
||||
"Code": "ValidationException",
|
||||
"Message": "InvokeModelWithResponseStream input malformed",
|
||||
}
|
||||
},
|
||||
operation_name="ConverseStream",
|
||||
)
|
||||
assert is_streaming_access_denied_error(exc) is False
|
||||
|
||||
def test_matches_wrapped_sdk_permission_error(self):
|
||||
"""Non-ClientError wrappers (AnthropicBedrock SDK) match on message."""
|
||||
from agent.bedrock_adapter import is_streaming_access_denied_error
|
||||
exc = RuntimeError(
|
||||
"PermissionDeniedError: user is not authorized to perform: "
|
||||
"bedrock:InvokeModelWithResponseStream"
|
||||
)
|
||||
assert is_streaming_access_denied_error(exc) is True
|
||||
|
||||
def test_ignores_unrelated_errors(self):
|
||||
from agent.bedrock_adapter import is_streaming_access_denied_error
|
||||
assert is_streaming_access_denied_error(ValueError("boom")) is False
|
||||
assert is_streaming_access_denied_error(
|
||||
RuntimeError("stream not supported")
|
||||
) is False
|
||||
|
||||
|
||||
class TestCallConverseStreamIamFallback:
|
||||
"""call_converse_stream() falls back to converse() when IAM denies the
|
||||
streaming action — InvokeModel-only policies keep working."""
|
||||
|
||||
def test_falls_back_to_converse_on_streaming_denial(self):
|
||||
pytest.importorskip("botocore", reason="botocore required for Bedrock exception tests")
|
||||
from agent.bedrock_adapter import (
|
||||
_bedrock_runtime_client_cache,
|
||||
call_converse_stream,
|
||||
reset_client_cache,
|
||||
)
|
||||
from botocore.exceptions import ClientError
|
||||
|
||||
reset_client_cache()
|
||||
client = MagicMock()
|
||||
client.converse_stream.side_effect = ClientError(
|
||||
error_response={
|
||||
"Error": {
|
||||
"Code": "AccessDeniedException",
|
||||
"Message": (
|
||||
"User is not authorized to perform: "
|
||||
"bedrock:InvokeModelWithResponseStream"
|
||||
),
|
||||
}
|
||||
},
|
||||
operation_name="ConverseStream",
|
||||
)
|
||||
client.converse.return_value = {
|
||||
"output": {"message": {"role": "assistant", "content": [{"text": "hi"}]}},
|
||||
"stopReason": "end_turn",
|
||||
"usage": {"inputTokens": 1, "outputTokens": 1, "totalTokens": 2},
|
||||
}
|
||||
_bedrock_runtime_client_cache["us-east-1"] = client
|
||||
|
||||
result = call_converse_stream(
|
||||
region="us-east-1",
|
||||
model="anthropic.claude-3-sonnet-20240229-v1:0",
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
)
|
||||
|
||||
client.converse.assert_called_once()
|
||||
assert result.choices[0].message.content == "hi"
|
||||
# Not a stale connection — client stays cached.
|
||||
assert _bedrock_runtime_client_cache.get("us-east-1") is client
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from gateway.config import Platform
|
||||
from gateway.platforms.telegram import TelegramAdapter
|
||||
from gateway.run import GatewayRunner
|
||||
from gateway.session import SessionSource
|
||||
|
||||
|
||||
def _source():
|
||||
return SessionSource(platform=Platform.TELEGRAM, chat_id="12345", chat_type="dm")
|
||||
|
||||
|
||||
def _runner(adapter=None):
|
||||
runner = object.__new__(GatewayRunner)
|
||||
runner.config = SimpleNamespace(
|
||||
stt_enabled=True,
|
||||
group_sessions_per_user=True,
|
||||
thread_sessions_per_user=False,
|
||||
)
|
||||
runner.adapters = {Platform.TELEGRAM: adapter} if adapter else {}
|
||||
runner._consume_pending_native_image_paths = lambda _key: []
|
||||
runner._session_key_for_source = lambda _source: "telegram:dm:12345"
|
||||
runner._thread_metadata_for_source = lambda *_args, **_kwargs: {}
|
||||
runner._reply_anchor_for_event = lambda _event: None
|
||||
return runner
|
||||
|
||||
|
||||
def test_telegram_audio_size_gate_rejects_oversized_media_before_download():
|
||||
adapter = object.__new__(TelegramAdapter)
|
||||
adapter._max_doc_bytes = 1024
|
||||
|
||||
allowed, note = adapter._telegram_media_size_allowed(
|
||||
SimpleNamespace(file_size=2048),
|
||||
"voice message",
|
||||
)
|
||||
|
||||
assert allowed is False
|
||||
assert "exceeds" in note
|
||||
assert "voice message" in note
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_voice_tts_is_explicit_audio_reply_opt_in():
|
||||
adapter = SimpleNamespace(
|
||||
_auto_tts_disabled_chats=set(),
|
||||
_auto_tts_enabled_chats=set(),
|
||||
)
|
||||
runner = _runner(adapter)
|
||||
runner._voice_mode = {}
|
||||
runner._voice_provider_mode = {}
|
||||
runner._save_voice_modes = lambda: None
|
||||
runner._save_voice_provider_modes = lambda: None
|
||||
|
||||
event = SimpleNamespace(
|
||||
source=_source(),
|
||||
get_command_args=lambda: "tts",
|
||||
)
|
||||
result = await GatewayRunner._handle_voice_command(runner, event)
|
||||
|
||||
assert runner._voice_mode["telegram:12345"] == "all"
|
||||
assert "12345" in adapter._auto_tts_enabled_chats
|
||||
assert result
|
||||
@@ -1573,3 +1573,87 @@ class TestCopilotACPStreamingDecision:
|
||||
_use_streaming = False
|
||||
|
||||
assert _use_streaming is True
|
||||
|
||||
|
||||
class TestBedrockIamStreamingFallback:
|
||||
"""bedrock_converse streaming branch: IAM denial of
|
||||
InvokeModelWithResponseStream falls back to converse() inline and sets
|
||||
_disable_streaming for the rest of the session."""
|
||||
|
||||
def _make_bedrock_agent(self):
|
||||
from run_agent import AIAgent
|
||||
|
||||
agent = AIAgent(
|
||||
api_key="test-key",
|
||||
base_url="https://openrouter.ai/api/v1",
|
||||
model="anthropic.claude-3-sonnet-20240229-v1:0",
|
||||
quiet_mode=True,
|
||||
skip_context_files=True,
|
||||
skip_memory=True,
|
||||
)
|
||||
agent.api_mode = "bedrock_converse"
|
||||
agent._interrupt_requested = False
|
||||
return agent
|
||||
|
||||
def test_iam_denial_falls_back_inline_and_disables_streaming(self):
|
||||
pytest.importorskip("botocore", reason="botocore required for Bedrock tests")
|
||||
from botocore.exceptions import ClientError
|
||||
|
||||
agent = self._make_bedrock_agent()
|
||||
|
||||
client = MagicMock()
|
||||
client.converse_stream.side_effect = ClientError(
|
||||
error_response={
|
||||
"Error": {
|
||||
"Code": "AccessDeniedException",
|
||||
"Message": (
|
||||
"User is not authorized to perform: "
|
||||
"bedrock:InvokeModelWithResponseStream"
|
||||
),
|
||||
}
|
||||
},
|
||||
operation_name="ConverseStream",
|
||||
)
|
||||
client.converse.return_value = {
|
||||
"output": {"message": {"role": "assistant", "content": [{"text": "hi"}]}},
|
||||
"stopReason": "end_turn",
|
||||
"usage": {"inputTokens": 1, "outputTokens": 1, "totalTokens": 2},
|
||||
}
|
||||
|
||||
with patch(
|
||||
"agent.bedrock_adapter._get_bedrock_runtime_client",
|
||||
return_value=client,
|
||||
):
|
||||
response = agent._interruptible_streaming_api_call(
|
||||
{"modelId": agent.model, "messages": []}
|
||||
)
|
||||
|
||||
client.converse.assert_called_once()
|
||||
assert response.choices[0].message.content == "hi"
|
||||
assert getattr(agent, "_disable_streaming", False) is True
|
||||
|
||||
def test_other_bedrock_errors_still_propagate(self):
|
||||
pytest.importorskip("botocore", reason="botocore required for Bedrock tests")
|
||||
from botocore.exceptions import ClientError
|
||||
|
||||
agent = self._make_bedrock_agent()
|
||||
|
||||
client = MagicMock()
|
||||
client.converse_stream.side_effect = ClientError(
|
||||
error_response={
|
||||
"Error": {"Code": "ThrottlingException", "Message": "slow down"}
|
||||
},
|
||||
operation_name="ConverseStream",
|
||||
)
|
||||
|
||||
with patch(
|
||||
"agent.bedrock_adapter._get_bedrock_runtime_client",
|
||||
return_value=client,
|
||||
):
|
||||
with pytest.raises(ClientError):
|
||||
agent._interruptible_streaming_api_call(
|
||||
{"modelId": agent.model, "messages": []}
|
||||
)
|
||||
|
||||
client.converse.assert_not_called()
|
||||
assert getattr(agent, "_disable_streaming", False) is False
|
||||
|
||||
Reference in New Issue
Block a user