Compare commits
33
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea718a5d69 | ||
|
|
a979ca2a67 | ||
|
|
476f009ff6 | ||
|
|
0c40f00ad1 | ||
|
|
19dc35cf57 | ||
|
|
3399bf28a5 | ||
|
|
23cb26c2e3 | ||
|
|
ccab46ca43 | ||
|
|
bb4765d21c | ||
|
|
2c1a38a3cc | ||
|
|
0d865ddbb1 | ||
|
|
ef6049c215 | ||
|
|
a3421aadda | ||
|
|
a8c7a5f70f | ||
|
|
d372fda6f0 | ||
|
|
0a6fda01e3 | ||
|
|
75e0d52034 | ||
|
|
adecb0d1a9 | ||
|
|
607f647141 | ||
|
|
6e30aa2a3c | ||
|
|
de9d480413 | ||
|
|
40828997a5 | ||
|
|
f1ea4a56c2 | ||
|
|
75afc47baa | ||
|
|
e02170f54a | ||
|
|
411a686de2 | ||
|
|
44649e69f4 | ||
|
|
3f1de1fc4a | ||
|
|
cf35fd6de5 | ||
|
|
efaba061fb | ||
|
|
9f6b2a64e0 | ||
|
|
049af61d64 | ||
|
|
60dbce7c34 |
@@ -1716,7 +1716,7 @@ def _read_nous_auth() -> Optional[dict]:
|
||||
try:
|
||||
if not _AUTH_JSON_PATH.is_file():
|
||||
return None
|
||||
data = json.loads(_AUTH_JSON_PATH.read_text())
|
||||
data = json.loads(_AUTH_JSON_PATH.read_text(encoding="utf-8"))
|
||||
if data.get("active_provider") != "nous":
|
||||
return None
|
||||
provider = data.get("providers", {}).get("nous", {})
|
||||
|
||||
@@ -708,7 +708,7 @@ class CopilotACPClient:
|
||||
if block_error:
|
||||
raise PermissionError(block_error)
|
||||
try:
|
||||
content = path.read_text()
|
||||
content = path.read_text(encoding="utf-8")
|
||||
except FileNotFoundError:
|
||||
content = ""
|
||||
line = params.get("line")
|
||||
@@ -736,7 +736,7 @@ class CopilotACPClient:
|
||||
if denied:
|
||||
raise PermissionError(denied)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(str(params.get("content") or ""))
|
||||
path.write_text(str(params.get("content") or ""), encoding="utf-8")
|
||||
response = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": message_id,
|
||||
|
||||
@@ -164,7 +164,7 @@ def _remove_env_source(provider: str, removed) -> RemovalResult:
|
||||
if env_path.exists():
|
||||
env_in_dotenv = any(
|
||||
line.strip().startswith(f"{env_var}=")
|
||||
for line in env_path.read_text(errors="replace").splitlines()
|
||||
for line in env_path.read_text(errors="replace", encoding="utf-8").splitlines()
|
||||
)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
@@ -117,7 +117,7 @@ def record_nous_rate_limit(
|
||||
# Atomic write: write to temp file + rename
|
||||
fd, tmp_path = tempfile.mkstemp(dir=state_dir, suffix=".tmp")
|
||||
try:
|
||||
with os.fdopen(fd, "w") as f:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(state, f)
|
||||
atomic_replace(tmp_path, path)
|
||||
except Exception:
|
||||
|
||||
@@ -632,7 +632,7 @@ def allowlist_path() -> Path:
|
||||
def load_allowlist() -> Dict[str, Any]:
|
||||
"""Return the parsed allowlist, or an empty skeleton if absent."""
|
||||
try:
|
||||
raw = json.loads(allowlist_path().read_text())
|
||||
raw = json.loads(allowlist_path().read_text(encoding="utf-8"))
|
||||
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
||||
return {"approvals": []}
|
||||
if not isinstance(raw, dict):
|
||||
|
||||
@@ -75,6 +75,7 @@ test('buildDesktopBackendEnv forces PYTHONUTF8 unless the user set it explicitly
|
||||
platform: 'darwin',
|
||||
pathModule: path.posix
|
||||
})
|
||||
|
||||
assert.equal(defaulted.PYTHONUTF8, '1')
|
||||
|
||||
const optedOut = buildDesktopBackendEnv({
|
||||
@@ -83,6 +84,7 @@ test('buildDesktopBackendEnv forces PYTHONUTF8 unless the user set it explicitly
|
||||
platform: 'darwin',
|
||||
pathModule: path.posix
|
||||
})
|
||||
|
||||
assert.equal(optedOut.PYTHONUTF8, '0')
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { DEFAULT_HEALTH_PROBE_TIMEOUT_MS, isMissingHealthEndpointError, waitForHermesReady } from './backend-health'
|
||||
|
||||
test('uses lightweight /api/health for current backends', async () => {
|
||||
const calls: string[][] = []
|
||||
|
||||
await waitForHermesReady('http://127.0.0.1:9000/', {
|
||||
token: 'secret-token',
|
||||
fetchPublicJson: async url => {
|
||||
calls.push(['public', url])
|
||||
|
||||
return { ok: true }
|
||||
},
|
||||
fetchJson: async url => {
|
||||
calls.push(['token', url])
|
||||
throw new Error('status should not be called')
|
||||
},
|
||||
sleep: async () => {},
|
||||
timeoutMs: 100,
|
||||
pollMs: 1
|
||||
})
|
||||
|
||||
assert.deepEqual(calls, [['public', 'http://127.0.0.1:9000/api/health']])
|
||||
})
|
||||
|
||||
test('falls back to /api/status only for old backends without /api/health', async () => {
|
||||
const calls: string[][] = []
|
||||
|
||||
await waitForHermesReady('http://127.0.0.1:9000', {
|
||||
token: 'secret-token',
|
||||
fetchPublicJson: async url => {
|
||||
calls.push(['public', url])
|
||||
|
||||
throw new Error('404: {"detail":"Not Found"}')
|
||||
},
|
||||
fetchJson: async (url, token) => {
|
||||
calls.push(['token', url, token ?? ''])
|
||||
|
||||
return { version: 'old' }
|
||||
},
|
||||
sleep: async () => {},
|
||||
timeoutMs: 100,
|
||||
pollMs: 1
|
||||
})
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
['public', 'http://127.0.0.1:9000/api/health'],
|
||||
['token', 'http://127.0.0.1:9000/api/status', 'secret-token']
|
||||
])
|
||||
})
|
||||
|
||||
test('does not fall back to heavyweight /api/status for transient health failures', async () => {
|
||||
const calls: string[][] = []
|
||||
let currentTime = 0
|
||||
|
||||
await assert.rejects(
|
||||
waitForHermesReady('http://127.0.0.1:9000', {
|
||||
fetchPublicJson: async url => {
|
||||
calls.push(['public', url])
|
||||
throw new Error('Timed out connecting to Hermes backend after 15000ms')
|
||||
},
|
||||
fetchJson: async url => {
|
||||
calls.push(['token', url])
|
||||
},
|
||||
sleep: async () => {},
|
||||
now: () => {
|
||||
currentTime += 20
|
||||
|
||||
return currentTime
|
||||
},
|
||||
timeoutMs: 50,
|
||||
pollMs: 1
|
||||
}),
|
||||
/Timed out connecting/
|
||||
)
|
||||
|
||||
assert.ok(calls.length > 0)
|
||||
assert.ok(calls.every(call => call[0] === 'public' && call[1].endsWith('/api/health')))
|
||||
})
|
||||
|
||||
test('probes health on a short timeout but leaves the legacy fallback its own', async () => {
|
||||
const timeouts: (number | undefined)[] = []
|
||||
|
||||
await waitForHermesReady('http://127.0.0.1:9000', {
|
||||
fetchPublicJson: async (_url, options) => {
|
||||
timeouts.push(options?.timeoutMs)
|
||||
|
||||
throw new Error('404: {"detail":"Not Found"}')
|
||||
},
|
||||
fetchJson: async (_url, _token, options) => {
|
||||
timeouts.push(options?.timeoutMs)
|
||||
|
||||
return { version: 'old' }
|
||||
},
|
||||
sleep: async () => {},
|
||||
timeoutMs: 100,
|
||||
pollMs: 1
|
||||
})
|
||||
|
||||
assert.deepEqual(timeouts, [DEFAULT_HEALTH_PROBE_TIMEOUT_MS, undefined])
|
||||
})
|
||||
|
||||
test('aborts as superseded when the bootstrap signal fires', async () => {
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
|
||||
await assert.rejects(
|
||||
waitForHermesReady('http://127.0.0.1:9000', {
|
||||
signal: controller.signal,
|
||||
fetchPublicJson: async () => {
|
||||
throw new Error('should not probe after abort')
|
||||
},
|
||||
fetchJson: async () => {
|
||||
throw new Error('should not probe after abort')
|
||||
},
|
||||
timeoutMs: 100,
|
||||
pollMs: 1
|
||||
}),
|
||||
(error: any) => error.kind === 'superseded'
|
||||
)
|
||||
})
|
||||
|
||||
test('recognizes missing-route shapes only', () => {
|
||||
assert.equal(isMissingHealthEndpointError(new Error('404: {"detail":"Not Found"}')), true)
|
||||
assert.equal(
|
||||
isMissingHealthEndpointError(
|
||||
new Error('Expected JSON from /api/health but got HTML. The endpoint is likely missing on the Hermes backend.')
|
||||
),
|
||||
true
|
||||
)
|
||||
assert.equal(isMissingHealthEndpointError(new Error('Timed out connecting to Hermes backend after 15000ms')), false)
|
||||
assert.equal(isMissingHealthEndpointError(new Error('500: boom')), false)
|
||||
})
|
||||
@@ -0,0 +1,95 @@
|
||||
export const DEFAULT_BACKEND_READY_TIMEOUT_MS = 45_000
|
||||
export const DEFAULT_BACKEND_READY_POLL_MS = 500
|
||||
// A cold backend can stall its event loop for tens of seconds while Windows
|
||||
// scans and byte-compiles the gateway import tree. At the default 15s socket
|
||||
// timeout only three probes fit in the budget; a short one keeps retrying
|
||||
// across the stall. Health only — the legacy /api/status fallback is genuinely
|
||||
// slow to answer and keeps the caller's default timeout.
|
||||
export const DEFAULT_HEALTH_PROBE_TIMEOUT_MS = 5_000
|
||||
|
||||
type FetchPublicJson = (url: string, options?: { timeoutMs?: number }) => Promise<unknown>
|
||||
type FetchJson = (url: string, token?: string | null, options?: { timeoutMs?: number }) => Promise<unknown>
|
||||
|
||||
export interface HermesReadyOptions {
|
||||
fetchPublicJson: FetchPublicJson
|
||||
fetchJson: FetchJson
|
||||
token?: string | null
|
||||
signal?: AbortSignal
|
||||
timeoutMs?: number
|
||||
pollMs?: number
|
||||
healthProbeTimeoutMs?: number
|
||||
sleep?: (ms: number) => Promise<void>
|
||||
now?: () => number
|
||||
}
|
||||
|
||||
export function isMissingHealthEndpointError(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error ?? '')
|
||||
|
||||
return /^404:/.test(message) || message.includes('endpoint is likely missing')
|
||||
}
|
||||
|
||||
function supersededError() {
|
||||
const error: any = new Error('SSH bootstrap was superseded by newer connection settings.')
|
||||
error.kind = 'superseded'
|
||||
|
||||
return error
|
||||
}
|
||||
|
||||
export async function waitForHermesReady(baseUrl: string, options: HermesReadyOptions): Promise<void> {
|
||||
const timeoutMs = options.timeoutMs ?? DEFAULT_BACKEND_READY_TIMEOUT_MS
|
||||
const pollMs = options.pollMs ?? DEFAULT_BACKEND_READY_POLL_MS
|
||||
const healthProbeTimeoutMs = options.healthProbeTimeoutMs ?? DEFAULT_HEALTH_PROBE_TIMEOUT_MS
|
||||
const now = options.now ?? Date.now
|
||||
const signal = options.signal
|
||||
|
||||
const sleep =
|
||||
options.sleep ??
|
||||
(ms =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(resolve, ms)
|
||||
signal?.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
clearTimeout(timer)
|
||||
reject(supersededError())
|
||||
},
|
||||
{ once: true }
|
||||
)
|
||||
}))
|
||||
|
||||
const base = baseUrl.replace(/\/+$/, '')
|
||||
const deadline = now() + timeoutMs
|
||||
let lastError: unknown = null
|
||||
let useStatusFallback = false
|
||||
|
||||
while (now() < deadline) {
|
||||
if (signal?.aborted) {
|
||||
throw supersededError()
|
||||
}
|
||||
|
||||
try {
|
||||
if (useStatusFallback) {
|
||||
await options.fetchJson(`${base}/api/status`, options.token)
|
||||
} else {
|
||||
await options.fetchPublicJson(`${base}/api/health`, { timeoutMs: healthProbeTimeoutMs })
|
||||
}
|
||||
|
||||
return
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
|
||||
// Only an explicitly missing route means the backend predates
|
||||
// /api/health; timeouts and server errors keep polling health.
|
||||
if (!useStatusFallback && isMissingHealthEndpointError(error)) {
|
||||
useStatusFallback = true
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
await sleep(pollMs)
|
||||
}
|
||||
}
|
||||
|
||||
const detail = lastError instanceof Error ? lastError.message : 'timeout'
|
||||
throw new Error(`Hermes backend did not become ready: ${detail}`)
|
||||
}
|
||||
@@ -34,6 +34,7 @@ import { stopBackendChild as stopBackendChildImpl } from './backend-child'
|
||||
import { dashboardFallbackArgs, sourceDeclaresServe } from './backend-command'
|
||||
import { createBackendConnectionState } from './backend-connection-state'
|
||||
import { buildDesktopBackendEnv, normalizeHermesHomeRoot } from './backend-env'
|
||||
import { waitForHermesReady } from './backend-health'
|
||||
import { canImportHermesCli, shouldTrustHermesOverride, verifyHermesCli } from './backend-probes'
|
||||
import { waitForDashboardPortAnnouncement } from './backend-ready'
|
||||
import { shouldLatchBackendStartFailure } from './backend-start-failure'
|
||||
@@ -2985,6 +2986,7 @@ function preflightStateDb(hermesHome, rememberLog) {
|
||||
|
||||
if (!fileExists(stateDbPath)) {
|
||||
rememberLog('[updates] state.db pre-flight: not found (fresh install?)')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -3015,23 +3017,19 @@ function preflightStateDb(hermesHome, rememberLog) {
|
||||
|
||||
// Emergency timestamped backup, separate from the Python-level snapshot.
|
||||
const ts = new Date().toISOString().replace(/[:.]/g, '-')
|
||||
const emergencyPath = path.join(
|
||||
hermesHome,
|
||||
`state.db.pre-update-emergency-${ts}.bak`
|
||||
)
|
||||
|
||||
const emergencyPath = path.join(hermesHome, `state.db.pre-update-emergency-${ts}.bak`)
|
||||
|
||||
try {
|
||||
fs.copyFileSync(stateDbPath, emergencyPath)
|
||||
const emergStat = fs.statSync(emergencyPath)
|
||||
|
||||
rememberLog(
|
||||
`[updates] emergency state.db backup: ${emergencyPath} ` +
|
||||
`(${emergStat.size} bytes)`
|
||||
)
|
||||
rememberLog(`[updates] emergency state.db backup: ${emergencyPath} ` + `(${emergStat.size} bytes)`)
|
||||
|
||||
// Prune to the 2 most recent emergency backups.
|
||||
try {
|
||||
const homeDir = fs.readdirSync(hermesHome)
|
||||
|
||||
const backups = homeDir
|
||||
.filter(
|
||||
f =>
|
||||
@@ -3053,19 +3051,13 @@ function preflightStateDb(hermesHome, rememberLog) {
|
||||
void 0
|
||||
}
|
||||
} catch (copyErr) {
|
||||
rememberLog(
|
||||
`[updates] emergency state.db backup failed: ${copyErr.message}`
|
||||
)
|
||||
rememberLog(`[updates] emergency state.db backup failed: ${copyErr.message}`)
|
||||
}
|
||||
} else {
|
||||
rememberLog(
|
||||
`[updates] state.db too small (${stat.size} bytes) for a valid SQLite database`
|
||||
)
|
||||
rememberLog(`[updates] state.db too small (${stat.size} bytes) for a valid SQLite database`)
|
||||
}
|
||||
} catch (statErr) {
|
||||
rememberLog(
|
||||
`[updates] could not stat state.db before update: ${statErr.message}`
|
||||
)
|
||||
rememberLog(`[updates] could not stat state.db before update: ${statErr.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3083,6 +3075,7 @@ async function applyUpdatesPosixInApp(opts: any) {
|
||||
|
||||
if (!hermes) {
|
||||
emitUpdateProgress({ stage: 'manual', message: 'hermes update', percent: null })
|
||||
|
||||
return { ok: true, manual: true, command: 'hermes update', hermesRoot: updateRoot }
|
||||
}
|
||||
|
||||
@@ -4823,39 +4816,12 @@ function closePreviewWatchers() {
|
||||
}
|
||||
|
||||
async function waitForHermes(baseUrl, token, signal?) {
|
||||
const deadline = Date.now() + 45_000
|
||||
let lastError = null
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
if (signal?.aborted) {
|
||||
const error: any = new Error('SSH bootstrap was superseded by newer connection settings.')
|
||||
error.kind = 'superseded'
|
||||
throw error
|
||||
}
|
||||
|
||||
try {
|
||||
await fetchJson(`${baseUrl}/api/status`, token)
|
||||
|
||||
return
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
await new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(resolve, 500)
|
||||
signal?.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
clearTimeout(timer)
|
||||
const aborted: any = new Error('SSH bootstrap was superseded by newer connection settings.')
|
||||
aborted.kind = 'superseded'
|
||||
reject(aborted)
|
||||
},
|
||||
{ once: true }
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Hermes backend did not become ready: ${lastError?.message || 'timeout'}`)
|
||||
return waitForHermesReady(baseUrl, {
|
||||
token,
|
||||
signal,
|
||||
fetchPublicJson,
|
||||
fetchJson
|
||||
})
|
||||
}
|
||||
|
||||
function getWindowButtonPosition() {
|
||||
|
||||
@@ -57,7 +57,8 @@
|
||||
* - electronPlatformName: 'win32' | 'darwin' | 'linux'
|
||||
* - arch: Arch enum (0=ia32, 1=x64, 2=armv7l, 3=arm64, 4=universal)
|
||||
*/
|
||||
import { existsSync, rmSync } from 'node:fs'
|
||||
import { existsSync, rmSync, renameSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { Arch } from 'electron-builder'
|
||||
import { stageNodePty } from './stage-native-deps.mjs'
|
||||
|
||||
@@ -75,10 +76,52 @@ export function cleanStaleAppOutDir(appOutDir) {
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Windows rollback material (#69179): before wiping the previous unpacked
|
||||
* tree, preserve it as `<appOutDir>.bak` — but ONLY when it holds the product
|
||||
* exe (i.e. it is a previously-working build, not the corrupted partial state
|
||||
* cleanStaleAppOutDir exists to remove). If the fresh pack then produces a
|
||||
* Hermes.exe that Windows can't load (truncated PE from a corrupt cached
|
||||
* Electron zip, wrong arch), the updater's integrity gate in
|
||||
* `hermes desktop --build-only` (hermes_cli/main.py
|
||||
* `_ensure_desktop_exe_launchable`) restores this .bak instead of leaving the
|
||||
* user with "This app can't run on your computer".
|
||||
*
|
||||
* Returns true when the tree was preserved (appOutDir no longer exists), false
|
||||
* when there was nothing worth preserving (caller falls through to the wipe).
|
||||
* A rename failure (AV holding a handle) also returns false — the wipe is the
|
||||
* safe fallback and matches pre-#69179 behavior exactly.
|
||||
*/
|
||||
export function preserveRollbackBackup(appOutDir, productExeName = 'Hermes.exe') {
|
||||
if (!appOutDir || typeof appOutDir !== 'string' || !existsSync(appOutDir)) {
|
||||
return false
|
||||
}
|
||||
if (!existsSync(path.join(appOutDir, productExeName))) {
|
||||
// Partial/corrupt tree (interrupted prior pack) — not rollback material.
|
||||
return false
|
||||
}
|
||||
const backupDir = `${appOutDir}.bak`
|
||||
try {
|
||||
rmSync(backupDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
|
||||
renameSync(appOutDir, backupDir)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export default async function beforePack(context) {
|
||||
const appOutDir = context && context.appOutDir
|
||||
const platformName = context && context.electronPlatformName
|
||||
try {
|
||||
if (cleanStaleAppOutDir(appOutDir)) {
|
||||
// Windows: keep the previous working build as rollback material for the
|
||||
// post-build integrity gate (#69179) instead of destroying it. Falls
|
||||
// through to the plain wipe when the old tree is partial/corrupt or the
|
||||
// rename fails.
|
||||
const productExe = `${(context && context.packager?.appInfo?.productFilename) || 'Hermes'}.exe`
|
||||
if (platformName === 'win32' && preserveRollbackBackup(appOutDir, productExe)) {
|
||||
console.log(`[before-pack] preserved previous unpacked dir for rollback: ${appOutDir}.bak`)
|
||||
} else if (cleanStaleAppOutDir(appOutDir)) {
|
||||
console.log(`[before-pack] removed stale unpacked dir before staging: ${appOutDir}`)
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -4,7 +4,7 @@ import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { test } from 'vitest'
|
||||
|
||||
import beforePack, { cleanStaleAppOutDir } from '../scripts/before-pack.mjs'
|
||||
import beforePack, { cleanStaleAppOutDir, preserveRollbackBackup } from '../scripts/before-pack.mjs'
|
||||
|
||||
test('cleanStaleAppOutDir removes a populated unpacked directory', () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-before-pack-'))
|
||||
@@ -50,3 +50,106 @@ test('beforePack default export resolves even when cleanup throws', async () =>
|
||||
// remove; the contract under test is that the hook never rejects.
|
||||
await assert.doesNotReject(beforePack({ appOutDir: '', electronPlatformName: 'linux' }))
|
||||
})
|
||||
|
||||
// ─── Windows rollback preservation (#69179) ────────────────────────────────
|
||||
|
||||
test('preserveRollbackBackup moves a working build to .bak', () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-before-pack-'))
|
||||
try {
|
||||
const appOutDir = path.join(tempRoot, 'win-unpacked')
|
||||
fs.mkdirSync(appOutDir, { recursive: true })
|
||||
fs.writeFileSync(path.join(appOutDir, 'Hermes.exe'), 'MZ-old-build', 'utf8')
|
||||
fs.writeFileSync(path.join(appOutDir, 'resources.pak'), 'x', 'utf8')
|
||||
|
||||
const preserved = preserveRollbackBackup(appOutDir, 'Hermes.exe')
|
||||
|
||||
assert.equal(preserved, true)
|
||||
// Original slot vacated so electron-builder stages into a clean tree...
|
||||
assert.equal(fs.existsSync(appOutDir), false)
|
||||
// ...and the previous working build is intact under .bak for rollback.
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(`${appOutDir}.bak`, 'Hermes.exe'), 'utf8'),
|
||||
'MZ-old-build'
|
||||
)
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('preserveRollbackBackup replaces a stale .bak from an older update', () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-before-pack-'))
|
||||
try {
|
||||
const appOutDir = path.join(tempRoot, 'win-unpacked')
|
||||
fs.mkdirSync(appOutDir, { recursive: true })
|
||||
fs.writeFileSync(path.join(appOutDir, 'Hermes.exe'), 'current', 'utf8')
|
||||
fs.mkdirSync(`${appOutDir}.bak`, { recursive: true })
|
||||
fs.writeFileSync(path.join(`${appOutDir}.bak`, 'Hermes.exe'), 'two-updates-ago', 'utf8')
|
||||
|
||||
assert.equal(preserveRollbackBackup(appOutDir, 'Hermes.exe'), true)
|
||||
assert.equal(fs.readFileSync(path.join(`${appOutDir}.bak`, 'Hermes.exe'), 'utf8'), 'current')
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('preserveRollbackBackup refuses a partial tree missing the product exe', () => {
|
||||
// The corrupted partial state (interrupted prior pack) must NOT become
|
||||
// rollback material — it is exactly what cleanStaleAppOutDir exists to wipe.
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-before-pack-'))
|
||||
try {
|
||||
const appOutDir = path.join(tempRoot, 'win-unpacked')
|
||||
fs.mkdirSync(appOutDir, { recursive: true })
|
||||
fs.writeFileSync(path.join(appOutDir, 'LICENSE.electron.txt'), 'x', 'utf8')
|
||||
|
||||
assert.equal(preserveRollbackBackup(appOutDir, 'Hermes.exe'), false)
|
||||
// Tree untouched; the caller's wipe path handles it.
|
||||
assert.equal(fs.existsSync(appOutDir), true)
|
||||
assert.equal(fs.existsSync(`${appOutDir}.bak`), false)
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('preserveRollbackBackup ignores missing or invalid input', () => {
|
||||
assert.equal(preserveRollbackBackup(''), false)
|
||||
assert.equal(preserveRollbackBackup(undefined), false)
|
||||
assert.equal(preserveRollbackBackup(null), false)
|
||||
assert.equal(preserveRollbackBackup(path.join(os.tmpdir(), 'does-not-exist-xyz')), false)
|
||||
})
|
||||
|
||||
test('beforePack on win32 preserves the previous build instead of wiping it', async () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-before-pack-'))
|
||||
try {
|
||||
const appOutDir = path.join(tempRoot, 'win-unpacked')
|
||||
fs.mkdirSync(appOutDir, { recursive: true })
|
||||
fs.writeFileSync(path.join(appOutDir, 'Hermes.exe'), 'MZ-working', 'utf8')
|
||||
|
||||
// No packager info in the context → default 'Hermes.exe' product name.
|
||||
// node-pty staging is skipped because arch is not a number here.
|
||||
await beforePack({ appOutDir, electronPlatformName: 'win32' })
|
||||
|
||||
assert.equal(fs.existsSync(appOutDir), false)
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(`${appOutDir}.bak`, 'Hermes.exe'), 'utf8'),
|
||||
'MZ-working'
|
||||
)
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('beforePack on linux keeps the plain wipe (no .bak)', async () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-before-pack-'))
|
||||
try {
|
||||
const appOutDir = path.join(tempRoot, 'linux-unpacked')
|
||||
fs.mkdirSync(appOutDir, { recursive: true })
|
||||
fs.writeFileSync(path.join(appOutDir, 'Hermes.exe'), 'x', 'utf8')
|
||||
|
||||
await beforePack({ appOutDir, electronPlatformName: 'linux' })
|
||||
|
||||
assert.equal(fs.existsSync(appOutDir), false)
|
||||
assert.equal(fs.existsSync(`${appOutDir}.bak`), false)
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -12,8 +12,7 @@ const mockComposerApi = { setText: vi.fn() }
|
||||
|
||||
vi.mock('@assistant-ui/react', () => ({
|
||||
useAui: () => ({ composer: () => mockComposerApi }),
|
||||
useAuiState: (selector: (state: { composer: { text: string } }) => unknown) =>
|
||||
selector({ composer: { text: '' } }),
|
||||
useAuiState: (selector: (state: { composer: { text: string } }) => unknown) => selector({ composer: { text: '' } }),
|
||||
useComposerRuntime: () => ({
|
||||
getState: () => ({ text: '' }),
|
||||
subscribe: () => () => undefined
|
||||
@@ -60,11 +59,7 @@ describe('useComposerDraft — attachment scope stays coherent with the committe
|
||||
const snapshots: ComposerAttachment[][] = []
|
||||
|
||||
const { rerender } = render(
|
||||
<ProbeHarness
|
||||
activeQueueSessionKey="session-A"
|
||||
onLayoutSnapshot={s => snapshots.push(s)}
|
||||
sessionId="session-A"
|
||||
/>
|
||||
<ProbeHarness activeQueueSessionKey="session-A" onLayoutSnapshot={s => snapshots.push(s)} sessionId="session-A" />
|
||||
)
|
||||
|
||||
// Mount loads session A's stashed attachment into the (module-level) main
|
||||
@@ -98,18 +93,24 @@ describe('useComposerDraft — rehydrate diagnostic log stays redacted', () => {
|
||||
|
||||
it('logs counts/kinds/scope on restore but never the raw url, refText, or label', () => {
|
||||
const secretUrl = 'https://secret.example.com/private-workspace-path'
|
||||
|
||||
const attachment: ComposerAttachment = {
|
||||
id: 'url-secret',
|
||||
kind: 'url',
|
||||
label: 'do-not-leak-label',
|
||||
refText: `@url:${secretUrl}`
|
||||
}
|
||||
|
||||
stashSessionDraft('session-secret', '', [attachment])
|
||||
|
||||
const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => undefined)
|
||||
|
||||
render(
|
||||
<ProbeHarness activeQueueSessionKey="session-secret" onLayoutSnapshot={() => undefined} sessionId="session-secret" />
|
||||
<ProbeHarness
|
||||
activeQueueSessionKey="session-secret"
|
||||
onLayoutSnapshot={() => undefined}
|
||||
sessionId="session-secret"
|
||||
/>
|
||||
)
|
||||
|
||||
const rehydrateCalls = debugSpy.mock.calls.filter(call => call[0] === '[composer-rehydrate]')
|
||||
|
||||
@@ -1,16 +1,6 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { getAutomationBlueprints } from '@/hermes'
|
||||
import type { AutomationBlueprint, AutomationBlueprintField } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { selectableCardClass } from '@/lib/selectable-card'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { PanelDetail, PanelEmpty, PanelPill } from '../overlays/panel'
|
||||
|
||||
// The blueprint catalog is shared with the dashboard, so its deliver slot
|
||||
// defaults to "origin" (the chat/home-channel a dashboard or gateway job was
|
||||
@@ -99,66 +89,3 @@ export function BlueprintSlotControl({
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// A clickable blueprint card — mirrors the app's other selectable cards
|
||||
// (theme/pet/gateway/profile pickers) via selectableCardClass. Clicking opens
|
||||
// the shared cron editor dialog pre-filled with this blueprint's slots; there's
|
||||
// no inline expand form or divider.
|
||||
function BlueprintCard({ blueprint, onSetUp }: { blueprint: AutomationBlueprint; onSetUp: () => void }) {
|
||||
return (
|
||||
<button
|
||||
className={cn(selectableCardClass({ prominent: true }), 'w-full p-2 text-left')}
|
||||
onClick={onSetUp}
|
||||
type="button"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-foreground">{blueprint.title}</p>
|
||||
<p className="mt-0.5 text-xs leading-relaxed text-muted-foreground">{blueprint.description}</p>
|
||||
{blueprint.tags.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{blueprint.tags.map(tag => (
|
||||
<PanelPill key={tag}>{tag}</PanelPill>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// Automation Blueprints gallery — the desktop counterpart to the dashboard's
|
||||
// blueprint tab. Each card opens the shared cron editor dialog pre-filled with
|
||||
// the blueprint's typed slots; submitting POSTs to
|
||||
// /api/cron/blueprints/instantiate, which fills the blueprint and creates the
|
||||
// job via the same create_job path as a hand-written cron.
|
||||
export function BlueprintsPanel({ onSetUp }: { onSetUp: (blueprint: AutomationBlueprint) => void }) {
|
||||
const { t } = useI18n()
|
||||
const c = t.cron
|
||||
|
||||
const blueprints = useQuery({
|
||||
queryKey: ['cron-blueprints'],
|
||||
queryFn: async () => (await getAutomationBlueprints()).blueprints
|
||||
})
|
||||
|
||||
const cards = useMemo(() => blueprints.data ?? [], [blueprints.data])
|
||||
|
||||
if (blueprints.isLoading) {
|
||||
return <PageLoader label={c.blueprints.loading} />
|
||||
}
|
||||
|
||||
if (blueprints.isError) {
|
||||
return <PanelEmpty description={c.blueprints.failedLoad} icon="warning" title={c.blueprints.failedLoad} />
|
||||
}
|
||||
|
||||
if (cards.length === 0) {
|
||||
return <PanelEmpty description={c.blueprints.emptyDesc} icon="lightbulb" title={c.blueprints.emptyTitle} />
|
||||
}
|
||||
|
||||
return (
|
||||
<PanelDetail>
|
||||
{cards.map(blueprint => (
|
||||
<BlueprintCard blueprint={blueprint} key={blueprint.key} onSetUp={() => onSetUp(blueprint)} />
|
||||
))}
|
||||
</PanelDetail>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon, codiconIcon } from '@/components/ui/codicon'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -14,8 +14,8 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { Field, FieldHint } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { SegmentedControl } from '@/components/ui/segmented-control'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
type CronDeliveryTarget,
|
||||
type CronJob,
|
||||
deleteCronJob,
|
||||
getAutomationBlueprints,
|
||||
getCronDeliveryTargets,
|
||||
getCronJobRuns,
|
||||
getCronJobs,
|
||||
@@ -70,27 +71,20 @@ import {
|
||||
} from '../overlays/panel'
|
||||
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
|
||||
|
||||
import {
|
||||
BlueprintSlotControl,
|
||||
blueprintSlotHelp,
|
||||
BlueprintsPanel,
|
||||
cleanBlueprintFieldError,
|
||||
initialBlueprintValues
|
||||
} from './blueprints'
|
||||
import { BlueprintSlotControl, blueprintSlotHelp, cleanBlueprintFieldError, initialBlueprintValues } from './blueprints'
|
||||
import { cronEditorUpdates, jobIsScriptOnly, validateCronEditor } from './cron-job-model'
|
||||
import { jobState, jobTitle, STATE_DOT } from './job-state'
|
||||
|
||||
const DEFAULT_DELIVER = 'local'
|
||||
|
||||
// Two surfaces share the cron panel: the live Jobs list and the Blueprints
|
||||
// gallery (parameterized templates that instantiate a real job). The active tab
|
||||
// is pure view state — it lives here, not in a store.
|
||||
type CronTab = 'blueprints' | 'jobs'
|
||||
|
||||
// Radix <SelectItem> rejects empty-string values, so the "no override" row in
|
||||
// the model picker carries this sentinel and is mapped back to '' on save.
|
||||
const MODEL_DEFAULT_VALUE = '__default__'
|
||||
|
||||
// "Start from" default: the manual editor (blank cron). Any other value is a
|
||||
// blueprint key. Blueprint keys never collide with this sentinel.
|
||||
const CUSTOM_TEMPLATE = 'custom'
|
||||
|
||||
const SCHEDULE_OPTIONS: ReadonlyArray<ScheduleOption> = [
|
||||
{ expr: '0 9 * * *', value: 'daily' },
|
||||
{ expr: '0 9 * * 1-5', value: 'weekdays' },
|
||||
@@ -305,7 +299,6 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt
|
||||
const pendingScrollRef = useRef<null | string>(null)
|
||||
const focusJobId = useStore($cronFocusJobId)
|
||||
|
||||
const [tab, setTab] = useState<CronTab>('jobs')
|
||||
const [editor, setEditor] = useState<EditorState>({ mode: 'closed' })
|
||||
const [pendingDelete, setPendingDelete] = useState<CronJob | null>(null)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
@@ -455,12 +448,11 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt
|
||||
|
||||
// Blueprint instantiation is a distinct backend path (fills typed slots, then
|
||||
// creates the job) so it can't share the raw-cron onSave contract. Merge the
|
||||
// created job into $cronJobs like every other create path.
|
||||
async function handleBlueprintCreate(
|
||||
blueprint: AutomationBlueprint,
|
||||
values: Record<string, string>,
|
||||
profile: string
|
||||
) {
|
||||
// created job into $cronJobs like every other create path. A blueprint writes a
|
||||
// real per-profile job, and "all" is not a writable target — collapse it to
|
||||
// 'default', matching the manual create path in handleEditorSave.
|
||||
async function handleBlueprintCreate(blueprint: AutomationBlueprint, values: Record<string, string>) {
|
||||
const profile = profileScope === ALL_PROFILES ? 'default' : profileScope
|
||||
const job = await instantiateAutomationBlueprint({ blueprint: blueprint.key, values }, profile)
|
||||
|
||||
updateCronJobs(rows => {
|
||||
@@ -472,42 +464,11 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt
|
||||
setEditor({ mode: 'closed' })
|
||||
}
|
||||
|
||||
const tabToggle = (
|
||||
<SegmentedControl
|
||||
onChange={setTab}
|
||||
options={[
|
||||
{ icon: codiconIcon('watch'), id: 'jobs', label: c.tabs.jobs },
|
||||
{ icon: codiconIcon('lightbulb'), id: 'blueprints', label: c.blueprints.tab }
|
||||
]}
|
||||
value={tab}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<Panel closeLabel={c.close} onClose={onClose}>
|
||||
<PanelHeader
|
||||
actions={tabToggle}
|
||||
subtitle={tab === 'jobs' ? c.count(totalCount) : c.blueprints.subtitle}
|
||||
title={c.title}
|
||||
/>
|
||||
<PanelHeader subtitle={c.count(totalCount)} title={c.title} />
|
||||
|
||||
{tab === 'blueprints' ? (
|
||||
// A blueprint instantiates a real per-profile job, and "all" is not a
|
||||
// writable target — collapse it to 'default', matching the create path
|
||||
// in handleEditorSave. A user scoped to all profiles gets the job in
|
||||
// 'default'. The gallery is a single scroll column, so it renders
|
||||
// directly (BlueprintsPanel uses PanelDetail) rather than in PanelBody's
|
||||
// master/detail row.
|
||||
<BlueprintsPanel
|
||||
onSetUp={blueprint =>
|
||||
setEditor({
|
||||
blueprint,
|
||||
mode: 'blueprint',
|
||||
profile: profileScope === ALL_PROFILES ? 'default' : profileScope
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : loading && jobs.length === 0 ? (
|
||||
{loading && jobs.length === 0 ? (
|
||||
<PageLoader label={c.loading} />
|
||||
) : totalCount === 0 ? (
|
||||
<PanelEmpty
|
||||
@@ -663,7 +624,7 @@ function CronJobDetail({
|
||||
<PanelAction disabled={busy} icon={isPaused ? 'play' : 'debug-pause'} onClick={onPauseResume}>
|
||||
{isPaused ? c.resumeTitle : c.pauseTitle}
|
||||
</PanelAction>
|
||||
<PanelAction disabled={busy} icon="zap" onClick={onTrigger}>
|
||||
<PanelAction disabled={busy} icon="zap" onClick={onTrigger} primary>
|
||||
{c.triggerNow}
|
||||
</PanelAction>
|
||||
</div>
|
||||
@@ -847,7 +808,7 @@ function CronEditorDialog({
|
||||
onSave
|
||||
}: {
|
||||
editor: EditorState
|
||||
onBlueprintCreate: (blueprint: AutomationBlueprint, values: Record<string, string>, profile: string) => Promise<void>
|
||||
onBlueprintCreate: (blueprint: AutomationBlueprint, values: Record<string, string>) => Promise<void>
|
||||
onClose: () => void
|
||||
onSave: (values: EditorValues) => Promise<void>
|
||||
}) {
|
||||
@@ -855,8 +816,6 @@ function CronEditorDialog({
|
||||
const c = t.cron
|
||||
const open = editor.mode !== 'closed'
|
||||
const isEdit = editor.mode === 'edit'
|
||||
const isBlueprint = editor.mode === 'blueprint'
|
||||
const blueprint = isBlueprint ? editor.blueprint : null
|
||||
const initial = isEdit ? editor.job : null
|
||||
const scriptOnlyJob = initial ? jobIsScriptOnly(initial) : false
|
||||
|
||||
@@ -868,14 +827,33 @@ function CronEditorDialog({
|
||||
// Per-job model override, encoded as `${providerSlug}:${model}` (split on the
|
||||
// first ':' when saving). MODEL_DEFAULT_VALUE = follow the global default.
|
||||
const [modelChoice, setModelChoice] = useState(MODEL_DEFAULT_VALUE)
|
||||
// Blueprint mode fills typed slots (time/enum/weekdays/text) instead of the
|
||||
// raw cron fields; the backend renders the prompt + schedule from them.
|
||||
// Blueprint fills typed slots (time/enum/weekdays/text) instead of the raw
|
||||
// cron fields; the backend renders the prompt + schedule from them.
|
||||
const [slotValues, setSlotValues] = useState<Record<string, string>>({})
|
||||
// Create mode can start from a ready-made blueprint instead of a blank cron.
|
||||
// CUSTOM_TEMPLATE (default) = the manual editor; any other value is a
|
||||
// blueprint key that swaps the form for that blueprint's typed slots.
|
||||
const [templateChoice, setTemplateChoice] = useState(CUSTOM_TEMPLATE)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<null | string>(null)
|
||||
|
||||
// The blueprint catalog powers the create dialog's "Start from" dropdown; it's
|
||||
// meaningless when editing an existing job, so skip the fetch there.
|
||||
const blueprintsQuery = useQuery({
|
||||
queryKey: ['cron-blueprints'],
|
||||
queryFn: async () => (await getAutomationBlueprints()).blueprints,
|
||||
enabled: open && !isEdit
|
||||
})
|
||||
|
||||
const blueprintList = blueprintsQuery.data ?? []
|
||||
|
||||
const blueprint =
|
||||
templateChoice === CUSTOM_TEMPLATE ? null : (blueprintList.find(item => item.key === templateChoice) ?? null)
|
||||
|
||||
const isBlueprint = blueprint !== null
|
||||
|
||||
// Same catalog the chat model picker uses: configured providers and their
|
||||
// actually-available models only. Script-only + blueprint dialogs never pick a
|
||||
// actually-available models only. Script-only + blueprint forms never pick a
|
||||
// model here, so skip the fetch entirely for them.
|
||||
const modelOptions = useQuery({
|
||||
queryKey: ['model-options', 'global'],
|
||||
@@ -903,10 +881,18 @@ function CronEditorDialog({
|
||||
setSchedulePreset(initial ? scheduleOptionForExpr(jobScheduleExpr(initial)).value : 'daily')
|
||||
setDeliver(initial ? jobDeliver(initial) : DEFAULT_DELIVER)
|
||||
setModelChoice(initial && jobModel(initial) ? `${jobProvider(initial)}:${jobModel(initial)}` : MODEL_DEFAULT_VALUE)
|
||||
setSlotValues(blueprint ? initialBlueprintValues(blueprint) : {})
|
||||
setSlotValues({})
|
||||
setTemplateChoice(CUSTOM_TEMPLATE)
|
||||
setError(null)
|
||||
setSaving(false)
|
||||
}, [blueprint, initial, open])
|
||||
}, [initial, open])
|
||||
|
||||
// Seed the typed slots with the blueprint's defaults whenever a blueprint is
|
||||
// picked from "Start from" (and reset them when switching back to Custom).
|
||||
useEffect(() => {
|
||||
setSlotValues(blueprint ? initialBlueprintValues(blueprint) : {})
|
||||
setError(null)
|
||||
}, [blueprint])
|
||||
|
||||
const selectedScheduleOption =
|
||||
SCHEDULE_OPTIONS.find(candidate => candidate.value === schedulePreset) ?? SCHEDULE_OPTIONS[0]
|
||||
@@ -988,7 +974,7 @@ function CronEditorDialog({
|
||||
async function handleBlueprintSubmit(event: React.FormEvent) {
|
||||
event.preventDefault()
|
||||
|
||||
if (!isBlueprint) {
|
||||
if (!blueprint) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -996,7 +982,7 @@ function CronEditorDialog({
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
await onBlueprintCreate(editor.blueprint, slotValues, editor.profile)
|
||||
await onBlueprintCreate(blueprint, slotValues)
|
||||
} catch (err) {
|
||||
// 422 carries the slot-level validation message; surface it inline.
|
||||
setError(cleanBlueprintFieldError(err instanceof Error ? err.message : String(err)))
|
||||
@@ -1009,12 +995,29 @@ function CronEditorDialog({
|
||||
<Dialog onOpenChange={value => !value && !saving && onClose()} open={open}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isBlueprint ? blueprint?.title : isEdit ? c.editTitle : c.createTitle}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isBlueprint ? blueprint?.description || c.blueprints.dialogDesc : isEdit ? c.editDesc : c.createDesc}
|
||||
</DialogDescription>
|
||||
<DialogTitle>{isEdit ? c.editTitle : c.createTitle}</DialogTitle>
|
||||
<DialogDescription>{isEdit ? c.editDesc : c.createDesc}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{!isEdit && blueprintList.length > 0 && (
|
||||
<Field htmlFor="cron-template" label={c.blueprints.startFrom}>
|
||||
<Select onValueChange={setTemplateChoice} value={templateChoice}>
|
||||
<SelectTrigger className="h-9 rounded-md" id="cron-template">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={CUSTOM_TEMPLATE}>{c.blueprints.custom}</SelectItem>
|
||||
{blueprintList.map(item => (
|
||||
<SelectItem key={item.key} value={item.key}>
|
||||
{item.title}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{blueprint?.description && <FieldHint>{blueprint.description}</FieldHint>}
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{isBlueprint && blueprint ? (
|
||||
<form className="grid gap-4" onSubmit={handleBlueprintSubmit}>
|
||||
{blueprint.fields.map(field => {
|
||||
@@ -1192,39 +1195,7 @@ function CronEditorDialog({
|
||||
)
|
||||
}
|
||||
|
||||
function Field({
|
||||
children,
|
||||
htmlFor,
|
||||
label,
|
||||
optional,
|
||||
optionalLabel
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
htmlFor: string
|
||||
label: string
|
||||
optional?: boolean
|
||||
optionalLabel?: string
|
||||
}) {
|
||||
return (
|
||||
<div className="grid gap-1.5">
|
||||
<label className="flex items-baseline gap-2 text-xs font-medium text-foreground" htmlFor={htmlFor}>
|
||||
{label}
|
||||
{optional && <span className="text-[0.65rem] font-normal text-muted-foreground">{optionalLabel}</span>}
|
||||
</label>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldHint({ children }: { children: React.ReactNode }) {
|
||||
return <p className="text-[0.66rem] leading-4 text-muted-foreground">{children}</p>
|
||||
}
|
||||
|
||||
type EditorState =
|
||||
| { blueprint: AutomationBlueprint; mode: 'blueprint'; profile: string }
|
||||
| { job: CronJob; mode: 'edit' }
|
||||
| { mode: 'closed' }
|
||||
| { mode: 'create' }
|
||||
type EditorState = { job: CronJob; mode: 'edit' } | { mode: 'closed' } | { mode: 'create' }
|
||||
|
||||
interface EditorValues {
|
||||
deliver: string
|
||||
|
||||
@@ -4,7 +4,9 @@ import { TabDropdown } from '@/components/ui/tab-dropdown'
|
||||
import type { IconComponent } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { PAGE_INSET_X, PAGE_MAX_W } from '../layout-constants'
|
||||
import { PAGE_MAX_W } from '../layout-constants'
|
||||
|
||||
import { OVERLAY_TOP_CLEARANCE } from './overlay-view'
|
||||
|
||||
// The wide rail and the narrow dropdown swap at exactly the width where
|
||||
// OverlaySplitLayout drops to a single column, so the rail never stacks.
|
||||
@@ -57,10 +59,12 @@ export function OverlaySidebar({ children, className }: OverlaySidebarProps) {
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
// pt clears the in-card close button (the OverlayView now insets the
|
||||
// whole card below the OS titlebar); the bg fills from the card's top
|
||||
// edge so there's no surface-colored gap above the sidebar.
|
||||
'flex min-h-0 flex-col gap-0.5 overflow-y-auto bg-(--ui-sidebar-surface-background) px-2.5 pb-3 pt-[calc(var(--titlebar-height)/2+1rem)]',
|
||||
// The left links sit beside (not under) the floating close button, so
|
||||
// they ride up via the shorter shared OVERLAY_TOP_CLEARANCE (same line
|
||||
// as a Panel header) instead of main's taller X-clearance. The bg still
|
||||
// fills from the card's top edge, so there's no gap above the sidebar.
|
||||
'flex min-h-0 flex-col gap-0.5 overflow-y-auto bg-(--ui-sidebar-surface-background) px-2.5 pb-3',
|
||||
OVERLAY_TOP_CLEARANCE,
|
||||
className
|
||||
)}
|
||||
>
|
||||
@@ -73,11 +77,15 @@ export function OverlayMain({ children, className }: OverlayMainProps) {
|
||||
return (
|
||||
<main
|
||||
className={cn(
|
||||
// Narrow: the OverlayNav dropdown bar already clears the titlebar, so
|
||||
// drop the tall top pad to a normal gap below it.
|
||||
'mx-auto flex min-h-0 w-full flex-1 flex-col overflow-hidden bg-transparent pb-3 pt-[calc(var(--titlebar-height)/2+1rem)] max-[47.5rem]:pt-2',
|
||||
// Main sits UNDER the floating close button (top-right), so it keeps the
|
||||
// taller top pad to clear the X — unlike the sidebar / Panel header,
|
||||
// which sit to its left and ride up via OVERLAY_TOP_CLEARANCE. All four
|
||||
// paddings are 1/3 tighter than the raw values (×2/3): the wide/narrow
|
||||
// top clearance, the bottom gutter, and the horizontal clamp gutter
|
||||
// (inlined from PAGE_INSET_X so only overlay panes tighten, not the
|
||||
// shared page gutter). Narrow top drops toward the OverlayNav bar.
|
||||
'mx-auto flex min-h-0 w-full flex-1 flex-col overflow-hidden bg-transparent pb-2 pt-[calc((var(--titlebar-height)/2+1rem)*2/3)] max-[47.5rem]:pt-[calc(0.5rem*2/3)] px-[clamp(0.8333rem,2.6667vw,2.6667rem)]',
|
||||
PAGE_MAX_W,
|
||||
PAGE_INSET_X,
|
||||
className
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -9,6 +9,14 @@ import { ESCAPE_PRIORITY, isTopEscapeLayer, pushEscapeLayer } from '@/lib/escape
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// Shared top clearance for overlay content that sits *beside* the floating
|
||||
// close button (which is absolute at `0.1875rem + titlebar/2`, -translate-y-1/2,
|
||||
// so it costs no layout space): a Panel's header and the split layout's left
|
||||
// sidebar links. They ride up next to the X on the same line across every
|
||||
// overlay (settings, system, agents, cron, …) — change it here, not per-surface.
|
||||
// Main content sits *under* the X (top-right) and keeps its own taller pad.
|
||||
export const OVERLAY_TOP_CLEARANCE = 'pt-[calc(var(--titlebar-height)/2-0.4375rem)]'
|
||||
|
||||
interface OverlayViewProps {
|
||||
children: ReactNode
|
||||
onClose: () => void
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Tip } from '@/components/ui/tooltip'
|
||||
import { translateNow } from '@/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { OverlayView } from './overlay-view'
|
||||
import { OVERLAY_TOP_CLEARANCE, OverlayView } from './overlay-view'
|
||||
|
||||
// Overlay "panel" primitive — the centered, capped card + framed chrome lifted
|
||||
// straight from the trace / agents overlay so every non-settings overlay (cron,
|
||||
@@ -47,13 +47,9 @@ export function Panel({
|
||||
return (
|
||||
<OverlayView
|
||||
closeLabel={closeLabel}
|
||||
// Top pad aligns the header title's center with the floating close button
|
||||
// (which sits at 0.1875rem + titlebar/2, -translate-y-1/2). The X is
|
||||
// absolute so it costs no layout space — the header rides up next to it.
|
||||
contentClassName={cn(
|
||||
'flex h-full min-h-0 flex-col px-4 pb-4 pt-[calc(var(--titlebar-height)/2-0.4375rem)] sm:px-5',
|
||||
contentClassName
|
||||
)}
|
||||
// Header title rides up next to the floating close button — see
|
||||
// OVERLAY_TOP_CLEARANCE, the shared clearance every overlay column uses.
|
||||
contentClassName={cn('flex h-full min-h-0 flex-col px-4 pb-4 sm:px-5', OVERLAY_TOP_CLEARANCE, contentClassName)}
|
||||
onClose={onClose}
|
||||
rootClassName={cn('flex h-full w-full flex-col', className)}
|
||||
>
|
||||
@@ -373,25 +369,31 @@ export function PanelAddButton({
|
||||
)
|
||||
}
|
||||
|
||||
// Visible ghost action for a detail header (cron pause/resume/trigger, …).
|
||||
// Visible action for a detail header (cron pause/resume/trigger, …). Ghost by
|
||||
// default; `primary` promotes the header's main action to a filled button.
|
||||
export function PanelAction({
|
||||
children,
|
||||
disabled,
|
||||
icon,
|
||||
onClick
|
||||
onClick,
|
||||
primary
|
||||
}: {
|
||||
children: ReactNode
|
||||
disabled?: boolean
|
||||
icon: string
|
||||
onClick: () => void
|
||||
primary?: boolean
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
className="gap-1.5 text-muted-foreground hover:bg-(--ui-row-hover-background) hover:text-foreground"
|
||||
className={cn(
|
||||
'gap-1.5',
|
||||
!primary && 'text-muted-foreground hover:bg-(--ui-row-hover-background) hover:text-foreground'
|
||||
)}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
variant={primary ? 'default' : 'ghost'}
|
||||
>
|
||||
<Codicon name={icon} size="0.875rem" />
|
||||
{children}
|
||||
|
||||
@@ -10,13 +10,13 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { Field, FieldHint } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { createProfile, updateProfileSoul } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { AlertTriangle } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { ProfileInfo } from '@/types/hermes'
|
||||
|
||||
const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/
|
||||
@@ -100,10 +100,7 @@ export function CreateProfileDialog({
|
||||
</DialogHeader>
|
||||
|
||||
<form className="grid gap-4" onSubmit={handleSubmit}>
|
||||
<div className="grid gap-1.5">
|
||||
<label className="text-xs font-medium" htmlFor="new-profile-name">
|
||||
{p.nameLabel}
|
||||
</label>
|
||||
<Field htmlFor="new-profile-name" label={p.nameLabel}>
|
||||
<Input
|
||||
aria-invalid={invalid}
|
||||
autoFocus
|
||||
@@ -112,15 +109,10 @@ export function CreateProfileDialog({
|
||||
placeholder="my-profile"
|
||||
value={name}
|
||||
/>
|
||||
<p className={cn('text-[0.66rem] leading-4', invalid ? 'text-destructive' : 'text-muted-foreground')}>
|
||||
{p.nameHint}
|
||||
</p>
|
||||
</div>
|
||||
<FieldHint error={invalid}>{p.nameHint}</FieldHint>
|
||||
</Field>
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
<label className="text-xs font-medium" htmlFor="new-profile-clone-from">
|
||||
{p.cloneFrom}
|
||||
</label>
|
||||
<Field htmlFor="new-profile-clone-from" label={p.cloneFrom}>
|
||||
<Select
|
||||
onValueChange={value => setCloneFrom(value === '__none__' ? null : value)}
|
||||
value={cloneFrom ?? '__none__'}
|
||||
@@ -137,13 +129,10 @@ export function CreateProfileDialog({
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">{p.cloneFromDesc}</p>
|
||||
</div>
|
||||
<FieldHint>{p.cloneFromDesc}</FieldHint>
|
||||
</Field>
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
<label className="text-xs font-medium" htmlFor="new-profile-soul">
|
||||
SOUL.md <span className="font-normal text-muted-foreground">- {p.soulOptional}</span>
|
||||
</label>
|
||||
<Field htmlFor="new-profile-soul" label="SOUL.md" optional optionalLabel={p.soulOptional}>
|
||||
<Textarea
|
||||
className="min-h-28 font-mono text-xs leading-5"
|
||||
id="new-profile-soul"
|
||||
@@ -151,7 +140,7 @@ export function CreateProfileDialog({
|
||||
placeholder={p.soulPlaceholder(cloneFrom ? p.soulPlaceholderCloned : p.soulPlaceholderEmpty)}
|
||||
value={soul}
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
|
||||
@@ -10,11 +10,11 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { Field, FieldHint } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { renameProfile } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { AlertTriangle } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { isValidProfileName } from './create-profile-dialog'
|
||||
|
||||
@@ -93,11 +93,8 @@ export function RenameProfileDialog({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form className="grid gap-3" onSubmit={handleSubmit}>
|
||||
<div className="grid gap-1.5">
|
||||
<label className="text-xs font-medium" htmlFor="rename-profile-name">
|
||||
{p.newNameLabel}
|
||||
</label>
|
||||
<form className="grid gap-4" onSubmit={handleSubmit}>
|
||||
<Field htmlFor="rename-profile-name" label={p.newNameLabel}>
|
||||
<Input
|
||||
aria-invalid={invalid}
|
||||
autoFocus
|
||||
@@ -105,10 +102,8 @@ export function RenameProfileDialog({
|
||||
onChange={event => setName(event.target.value)}
|
||||
value={name}
|
||||
/>
|
||||
<p className={cn('text-[0.66rem] leading-4', invalid ? 'text-destructive' : 'text-muted-foreground')}>
|
||||
{p.nameHint}
|
||||
</p>
|
||||
</div>
|
||||
<FieldHint error={invalid}>{p.nameHint}</FieldHint>
|
||||
</Field>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
|
||||
@@ -77,6 +77,7 @@ export function useSessionStateCache({
|
||||
selectedStoredSessionIdPropRef.current = selectedStoredSessionId
|
||||
selectedStoredSessionIdRef.current = selectedStoredSessionId
|
||||
}
|
||||
|
||||
const sessionStateByRuntimeIdRef = useRef(new Map<string, ClientSessionState>())
|
||||
const runtimeIdByStoredSessionIdRef = useRef(new Map<string, string>())
|
||||
const pendingViewStateRef = useRef<{ sessionId: string; state: ClientSessionState } | null>(null)
|
||||
|
||||
@@ -288,7 +288,7 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set
|
||||
<OverlaySplitLayout>
|
||||
<OverlayNav footer={navFooter} groups={navGroups} />
|
||||
|
||||
<OverlayMain className="px-0 pb-0 pt-[calc(var(--titlebar-height)+1rem)]">
|
||||
<OverlayMain className="px-0 pb-0">
|
||||
{activeView === 'config:appearance' ? (
|
||||
<AppearanceSettings />
|
||||
) : activeView === 'about' ? (
|
||||
|
||||
@@ -15,8 +15,10 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { Field } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
createWebhook,
|
||||
@@ -50,7 +52,7 @@ import {
|
||||
PanelRowMenu,
|
||||
PanelSectionLabel
|
||||
} from '../overlays/panel'
|
||||
import { ListRow, ToggleRow } from '../settings/primitives'
|
||||
import { ListRow } from '../settings/primitives'
|
||||
|
||||
const DELIVER_OPTIONS: readonly string[] = ['log', 'telegram', 'discord', 'slack', 'email', 'github_comment']
|
||||
|
||||
@@ -435,100 +437,90 @@ export function WebhooksView({ onClose }: WebhooksViewProps) {
|
||||
</DialogFooter>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-1">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<ListRow
|
||||
action={
|
||||
<Input
|
||||
autoFocus
|
||||
id="webhook-name"
|
||||
onChange={e => setName(e.target.value)}
|
||||
placeholder={w.fieldNamePlaceholder}
|
||||
value={name}
|
||||
/>
|
||||
}
|
||||
title={<label htmlFor="webhook-name">{w.fieldName}</label>}
|
||||
wide
|
||||
/>
|
||||
<ListRow
|
||||
action={
|
||||
<Input
|
||||
id="webhook-description"
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
placeholder={w.fieldDescriptionPlaceholder}
|
||||
value={description}
|
||||
/>
|
||||
}
|
||||
title={<label htmlFor="webhook-description">{w.fieldDescription}</label>}
|
||||
wide
|
||||
/>
|
||||
</div>
|
||||
<ListRow
|
||||
action={
|
||||
<Textarea
|
||||
className="min-h-[80px]"
|
||||
id="webhook-prompt"
|
||||
onChange={e => setPrompt(e.target.value)}
|
||||
placeholder={w.fieldPromptPlaceholder}
|
||||
value={prompt}
|
||||
<form
|
||||
className="grid gap-4"
|
||||
onSubmit={e => {
|
||||
e.preventDefault()
|
||||
void handleCreate()
|
||||
}}
|
||||
>
|
||||
<div className="grid items-start gap-4 sm:grid-cols-2">
|
||||
<Field htmlFor="webhook-name" label={w.fieldName}>
|
||||
<Input
|
||||
autoFocus
|
||||
id="webhook-name"
|
||||
onChange={e => setName(e.target.value)}
|
||||
placeholder={w.fieldNamePlaceholder}
|
||||
value={name}
|
||||
/>
|
||||
}
|
||||
title={<label htmlFor="webhook-prompt">{w.fieldPrompt}</label>}
|
||||
wide
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<ListRow
|
||||
action={
|
||||
<Input
|
||||
id="webhook-events"
|
||||
onChange={e => setEvents(e.target.value)}
|
||||
placeholder={w.fieldEventsPlaceholder}
|
||||
value={events}
|
||||
/>
|
||||
}
|
||||
title={<label htmlFor="webhook-events">{w.fieldEvents}</label>}
|
||||
wide
|
||||
/>
|
||||
<ListRow
|
||||
action={
|
||||
<Input
|
||||
id="webhook-skills"
|
||||
onChange={e => setSkills(e.target.value)}
|
||||
placeholder={w.fieldSkillsPlaceholder}
|
||||
value={skills}
|
||||
/>
|
||||
}
|
||||
title={<label htmlFor="webhook-skills">{w.fieldSkills}</label>}
|
||||
wide
|
||||
/>
|
||||
</Field>
|
||||
<Field htmlFor="webhook-description" label={w.fieldDescription}>
|
||||
<Input
|
||||
id="webhook-description"
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
placeholder={w.fieldDescriptionPlaceholder}
|
||||
value={description}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 items-start gap-4">
|
||||
<ListRow
|
||||
action={
|
||||
<Select onValueChange={setDeliver} value={deliver}>
|
||||
<SelectTrigger className="h-9 rounded-md" id="webhook-deliver">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DELIVER_OPTIONS.map(opt => (
|
||||
<SelectItem key={opt} value={opt}>
|
||||
{w.deliverOptions[opt] ?? opt}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
}
|
||||
title={<label htmlFor="webhook-deliver">{w.fieldDeliver}</label>}
|
||||
wide
|
||||
|
||||
<Field htmlFor="webhook-prompt" label={w.fieldPrompt}>
|
||||
<Textarea
|
||||
className="min-h-24"
|
||||
id="webhook-prompt"
|
||||
onChange={e => setPrompt(e.target.value)}
|
||||
placeholder={w.fieldPromptPlaceholder}
|
||||
value={prompt}
|
||||
/>
|
||||
<ToggleRow checked={deliverOnly} label={w.fieldDeliverOnly} onChange={setDeliverOnly} />
|
||||
</Field>
|
||||
|
||||
<div className="grid items-start gap-4 sm:grid-cols-2">
|
||||
<Field htmlFor="webhook-events" label={w.fieldEvents}>
|
||||
<Input
|
||||
id="webhook-events"
|
||||
onChange={e => setEvents(e.target.value)}
|
||||
placeholder={w.fieldEventsPlaceholder}
|
||||
value={events}
|
||||
/>
|
||||
</Field>
|
||||
<Field htmlFor="webhook-skills" label={w.fieldSkills}>
|
||||
<Input
|
||||
id="webhook-skills"
|
||||
onChange={e => setSkills(e.target.value)}
|
||||
placeholder={w.fieldSkillsPlaceholder}
|
||||
value={skills}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="grid items-start gap-4 sm:grid-cols-2">
|
||||
<Field htmlFor="webhook-deliver" label={w.fieldDeliver}>
|
||||
<Select onValueChange={setDeliver} value={deliver}>
|
||||
<SelectTrigger className="h-9 rounded-md" id="webhook-deliver">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DELIVER_OPTIONS.map(opt => (
|
||||
<SelectItem key={opt} value={opt}>
|
||||
{w.deliverOptions[opt] ?? opt}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field htmlFor="webhook-deliver-only" label={w.fieldDeliverOnly}>
|
||||
<div className="flex h-9 items-center">
|
||||
<Switch checked={deliverOnly} id="webhook-deliver-only" onCheckedChange={setDeliverOnly} />
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button disabled={creating} onClick={() => void handleCreate()} size="sm">
|
||||
<Button disabled={creating} size="sm" type="submit">
|
||||
{creating ? w.creating : w.create}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -565,9 +557,6 @@ function WebhookDetail({ sub }: { sub: WebhookRoute }) {
|
||||
<header className="space-y-3">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<h3 className="text-[0.95rem] font-semibold tracking-tight text-foreground">{sub.name}</h3>
|
||||
<PanelPill tone={sub.enabled ? 'good' : 'muted'}>
|
||||
{sub.enabled ? t.messaging.states.enabled : t.messaging.states.disabled}
|
||||
</PanelPill>
|
||||
{sub.deliver_only && <PanelPill tone="warn">{w.deliverOnly}</PanelPill>}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// Shared form-field primitive for dialog forms: a label stacked above its
|
||||
// control, with an optional inline "(optional)" tag. Pair with FieldHint for
|
||||
// help text below the control. This is the single field language for every form
|
||||
// dialog (cron, webhooks, profiles, …) — don't hand-roll label+control stacks
|
||||
// or reach for the settings-surface ListRow inside a dialog. Stack Fields in a
|
||||
// `grid gap-4` form; pair two across with `grid items-start gap-4 sm:grid-cols-2`.
|
||||
export function Field({
|
||||
children,
|
||||
htmlFor,
|
||||
label,
|
||||
optional,
|
||||
optionalLabel
|
||||
}: {
|
||||
children: ReactNode
|
||||
htmlFor?: string
|
||||
label: ReactNode
|
||||
optional?: boolean
|
||||
optionalLabel?: string
|
||||
}) {
|
||||
return (
|
||||
<div className="grid gap-1.5">
|
||||
<label className="flex items-baseline gap-2 text-xs font-medium text-foreground" htmlFor={htmlFor}>
|
||||
{label}
|
||||
{optional && optionalLabel && (
|
||||
<span className="text-[0.65rem] font-normal text-muted-foreground">{optionalLabel}</span>
|
||||
)}
|
||||
</label>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function FieldHint({ children, error }: { children: ReactNode; error?: boolean }) {
|
||||
return (
|
||||
<p className={cn('text-[0.66rem] leading-4', error ? 'text-destructive' : 'text-muted-foreground')}>{children}</p>
|
||||
)
|
||||
}
|
||||
@@ -1688,6 +1688,8 @@ export const en: Translations = {
|
||||
},
|
||||
blueprints: {
|
||||
tab: 'Blueprints',
|
||||
startFrom: 'Start from',
|
||||
custom: 'Custom',
|
||||
subtitle: 'Ready-made automations',
|
||||
dialogDesc: 'Fill in the details and schedule it.',
|
||||
scheduleIt: 'Schedule it',
|
||||
|
||||
@@ -1560,6 +1560,8 @@ export const ja = defineLocale({
|
||||
},
|
||||
blueprints: {
|
||||
tab: 'ブレーンプリント',
|
||||
startFrom: '開始点',
|
||||
custom: 'カスタム',
|
||||
subtitle: 'すぐに使える自動化',
|
||||
dialogDesc: '詳細を入力してスケジュールします。',
|
||||
scheduleIt: 'スケジュールする',
|
||||
|
||||
@@ -1399,6 +1399,8 @@ export interface Translations {
|
||||
}
|
||||
blueprints: {
|
||||
tab: string
|
||||
startFrom: string
|
||||
custom: string
|
||||
subtitle: string
|
||||
dialogDesc: string
|
||||
scheduleIt: string
|
||||
|
||||
@@ -1509,6 +1509,8 @@ export const zhHant = defineLocale({
|
||||
},
|
||||
blueprints: {
|
||||
tab: '藍圖',
|
||||
startFrom: '從此開始',
|
||||
custom: '自訂',
|
||||
subtitle: '現成的自動化',
|
||||
dialogDesc: '填寫詳細資訊並進行排程。',
|
||||
scheduleIt: '安排工作',
|
||||
|
||||
@@ -1879,6 +1879,8 @@ export const zh: Translations = {
|
||||
},
|
||||
blueprints: {
|
||||
tab: '蓝图',
|
||||
startFrom: '从此开始',
|
||||
custom: '自定义',
|
||||
subtitle: '现成的自动化',
|
||||
dialogDesc: '填写详细信息并进行排程。',
|
||||
scheduleIt: '安排任务',
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { SessionMessage } from '@/types/hermes'
|
||||
|
||||
import type { ChatMessage, ChatMessagePart } from './chat-messages'
|
||||
import {
|
||||
appendAssistantTextPart,
|
||||
@@ -191,6 +193,30 @@ describe('toChatMessages', () => {
|
||||
'background agent work finished'
|
||||
])
|
||||
})
|
||||
|
||||
// A backend older than this app serves display_metadata as unparsed JSON
|
||||
// text. Indexing into that string used to throw and fail the whole resume.
|
||||
it.each([
|
||||
['an object', { delegation_id: 'deleg_1', task_count: 2 }, '2 background agents finished'],
|
||||
['JSON text', JSON.stringify({ delegation_id: 'deleg_1', task_count: 1 }), '1 background agent finished'],
|
||||
['unparseable text', '{not-json', 'background agent work finished'],
|
||||
['text that is not an object', '"deleg_1"', 'background agent work finished'],
|
||||
['a missing task count', { delegation_id: 'deleg_1' }, 'background agent work finished']
|
||||
])('labels a delegation event given %s', (_case, displayMetadata, expected) => {
|
||||
const read = () =>
|
||||
toChatMessages([
|
||||
{
|
||||
role: 'user',
|
||||
content: 'opaque delegation context payload',
|
||||
display_kind: 'async_delegation_complete',
|
||||
display_metadata: displayMetadata as SessionMessage['display_metadata'],
|
||||
timestamp: 1
|
||||
}
|
||||
])
|
||||
|
||||
expect(read).not.toThrow()
|
||||
expect(chatMessageText(read()[0])).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('renderMediaTags', () => {
|
||||
|
||||
@@ -311,16 +311,35 @@ function transcriptContent(displayKind: SessionMessage['display_kind'], content:
|
||||
return displayKind === 'hidden' ? null : content
|
||||
}
|
||||
|
||||
// A remote backend older than this app serves display_metadata as raw JSON text,
|
||||
// and `in` throws on a primitive — which used to fail the whole session resume.
|
||||
function timelineTaskCount(metadata: SessionMessage['display_metadata']): number | undefined {
|
||||
let parsed: unknown = metadata
|
||||
|
||||
if (typeof parsed === 'string') {
|
||||
try {
|
||||
parsed = JSON.parse(parsed)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const count = (parsed as { task_count?: unknown }).task_count
|
||||
|
||||
return typeof count === 'number' ? count : undefined
|
||||
}
|
||||
|
||||
function timelineDisplayContent(message: SessionMessage, content: string): string {
|
||||
if (message.display_kind === 'model_switch') {
|
||||
return 'model changed'
|
||||
}
|
||||
|
||||
if (message.display_kind === 'async_delegation_complete') {
|
||||
const count =
|
||||
message.display_metadata && 'task_count' in message.display_metadata
|
||||
? message.display_metadata.task_count
|
||||
: undefined
|
||||
const count = timelineTaskCount(message.display_metadata)
|
||||
|
||||
return count === undefined
|
||||
? 'background agent work finished'
|
||||
|
||||
@@ -494,7 +494,11 @@ export interface SessionMessage {
|
||||
reasoning_content?: null | string
|
||||
reasoning_details?: unknown
|
||||
display_kind?: 'async_delegation_complete' | 'hidden' | 'model_switch' | string
|
||||
display_metadata?: TimelineDisplayMetadata
|
||||
/**
|
||||
* A backend older than this app can still serve this as unparsed JSON text,
|
||||
* so readers must narrow before indexing into it.
|
||||
*/
|
||||
display_metadata?: string | TimelineDisplayMetadata
|
||||
role: 'assistant' | 'system' | 'tool' | 'user'
|
||||
text?: unknown
|
||||
timestamp?: number
|
||||
|
||||
@@ -1557,7 +1557,15 @@ def _setup_worktree(repo_root: str = None, sync_base: bool = True) -> Optional[D
|
||||
gitignore = Path(repo_root) / ".gitignore"
|
||||
_ignore_entry = ".worktrees/"
|
||||
try:
|
||||
existing = gitignore.read_text() if gitignore.exists() else ""
|
||||
# utf-8-sig: git files are UTF-8 and Notepad prepends a BOM, which
|
||||
# would glue to the first line and defeat the membership check below
|
||||
# (duplicating the entry); the locale default also breaks non-ASCII
|
||||
# patterns on Windows. The append below already writes UTF-8.
|
||||
existing = (
|
||||
gitignore.read_text(encoding="utf-8-sig", errors="replace")
|
||||
if gitignore.exists()
|
||||
else ""
|
||||
)
|
||||
if _ignore_entry not in existing.splitlines():
|
||||
with open(gitignore, "a", encoding="utf-8") as f:
|
||||
if existing and not existing.endswith("\n"):
|
||||
@@ -1607,7 +1615,14 @@ def _setup_worktree(repo_root: str = None, sync_base: bool = True) -> Optional[D
|
||||
try:
|
||||
repo_root_resolved = Path(repo_root).resolve()
|
||||
wt_path_resolved = wt_path.resolve()
|
||||
for line in include_file.read_text().splitlines():
|
||||
# utf-8-sig, not the locale default: on a cp1251/GBK Windows
|
||||
# machine a UTF-8 include list either decodes to mojibake paths
|
||||
# (entries silently not copied) or raises UnicodeDecodeError,
|
||||
# which the enclosing handler swallows at DEBUG — no include is
|
||||
# copied at all. A Notepad BOM likewise glued to the first entry.
|
||||
for line in include_file.read_text(
|
||||
encoding="utf-8-sig", errors="replace"
|
||||
).splitlines():
|
||||
entry = line.strip()
|
||||
if not entry or entry.startswith("#"):
|
||||
continue
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
kael-odin
|
||||
@@ -0,0 +1 @@
|
||||
chancelu
|
||||
@@ -0,0 +1 @@
|
||||
jeeaay
|
||||
+3
-3
@@ -1123,7 +1123,7 @@ def _load_auth_store(auth_file: Optional[Path] = None) -> Dict[str, Any]:
|
||||
return {"version": AUTH_STORE_VERSION, "providers": {}}
|
||||
|
||||
try:
|
||||
raw = json.loads(auth_file.read_text())
|
||||
raw = json.loads(auth_file.read_text(encoding="utf-8"))
|
||||
except Exception as exc:
|
||||
corrupt_path = auth_file.with_suffix(".json.corrupt")
|
||||
try:
|
||||
@@ -3826,7 +3826,7 @@ def _import_codex_cli_tokens() -> Optional[Dict[str, str]]:
|
||||
if not auth_path.is_file():
|
||||
return None
|
||||
try:
|
||||
payload = json.loads(auth_path.read_text())
|
||||
payload = json.loads(auth_path.read_text(encoding="utf-8"))
|
||||
tokens = payload.get("tokens")
|
||||
if not isinstance(tokens, dict):
|
||||
return None
|
||||
@@ -5261,7 +5261,7 @@ def _read_shared_nous_state() -> Optional[Dict[str, Any]]:
|
||||
if not path.is_file():
|
||||
return None
|
||||
try:
|
||||
payload = json.loads(path.read_text())
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError) as exc:
|
||||
logger.debug("Shared Nous auth store at %s is unreadable: %s", path, exc)
|
||||
return None
|
||||
|
||||
@@ -292,7 +292,7 @@ def check_for_updates() -> Optional[int]:
|
||||
now = time.time()
|
||||
try:
|
||||
if cache_file.exists():
|
||||
cached = json.loads(cache_file.read_text())
|
||||
cached = json.loads(cache_file.read_text(encoding="utf-8"))
|
||||
if (
|
||||
now - cached.get("ts", 0) < _UPDATE_CHECK_CACHE_SECONDS
|
||||
and cached.get("rev") == embedded_rev
|
||||
@@ -321,7 +321,8 @@ def check_for_updates() -> Optional[int]:
|
||||
|
||||
try:
|
||||
cache_file.write_text(
|
||||
json.dumps({"ts": now, "behind": behind, "rev": embedded_rev, "ver": VERSION})
|
||||
json.dumps({"ts": now, "behind": behind, "rev": embedded_rev, "ver": VERSION}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -236,7 +236,7 @@ def _maybe_migrate_legacy_gateway_run_state(
|
||||
"desired_state": "running",
|
||||
"timestamp": int(time.time()),
|
||||
"migrated_from": "legacy-container-cmd",
|
||||
}) + "\n")
|
||||
}) + "\n", encoding="utf-8")
|
||||
return "running"
|
||||
|
||||
|
||||
@@ -382,7 +382,7 @@ def _read_desired_state(profile_dir: Path) -> str | None:
|
||||
if not state_file.exists():
|
||||
return None
|
||||
try:
|
||||
data = json.loads(state_file.read_text())
|
||||
data = json.loads(state_file.read_text(encoding="utf-8"))
|
||||
desired_state = data.get("desired_state")
|
||||
if desired_state is not None:
|
||||
return desired_state
|
||||
@@ -450,7 +450,7 @@ def _register_service(scandir: Path, profile: str, *, start: bool) -> None:
|
||||
tmp_dir.mkdir(parents=True)
|
||||
|
||||
try:
|
||||
(tmp_dir / "type").write_text("longrun\n")
|
||||
(tmp_dir / "type").write_text("longrun\n", encoding="utf-8")
|
||||
|
||||
# Reuse the manager's run-script rendering — single source of
|
||||
# truth so register_profile_gateway and reconcile_profile_gateways
|
||||
@@ -458,18 +458,18 @@ def _register_service(scandir: Path, profile: str, *, start: bool) -> None:
|
||||
# per-profile env can set it via the profile's config.yaml
|
||||
# (which the gateway itself loads).
|
||||
run = tmp_dir / "run"
|
||||
run.write_text(S6ServiceManager._render_run_script(profile, extra_env={}))
|
||||
run.write_text(S6ServiceManager._render_run_script(profile, extra_env={}), encoding="utf-8")
|
||||
run.chmod(0o755)
|
||||
|
||||
finish = tmp_dir / "finish"
|
||||
finish.write_text(S6ServiceManager._render_finish_script())
|
||||
finish.write_text(S6ServiceManager._render_finish_script(), encoding="utf-8")
|
||||
finish.chmod(0o755)
|
||||
|
||||
# Persistent log rotation (OQ8-C).
|
||||
log_subdir = tmp_dir / "log"
|
||||
log_subdir.mkdir()
|
||||
log_run = log_subdir / "run"
|
||||
log_run.write_text(S6ServiceManager._render_log_run(profile))
|
||||
log_run.write_text(S6ServiceManager._render_log_run(profile), encoding="utf-8")
|
||||
log_run.chmod(0o755)
|
||||
|
||||
# The presence of a `down` file tells s6-supervise to NOT
|
||||
|
||||
@@ -31,6 +31,11 @@ the SPA should bootstrap it after login instead.
|
||||
from __future__ import annotations
|
||||
|
||||
PUBLIC_API_PATHS: frozenset[str] = frozenset({
|
||||
# Minimal process liveness probe for desktop/backend boot handshakes. It
|
||||
# intentionally avoids gateway config, platform discovery, MCP setup, and
|
||||
# host-local detail so readiness checks cannot spend their budget inside
|
||||
# cold plugin imports.
|
||||
"/api/health",
|
||||
# Liveness probe target. Returns version, gateway state, active
|
||||
# session count, and the dashboard auth-gate shape. No bodies, no
|
||||
# session content, no secrets. Documented as the portal's wildcard
|
||||
|
||||
@@ -887,11 +887,13 @@ def run_doctor(args):
|
||||
if env_path.exists():
|
||||
check_ok(f"{_DHH}/.env file exists")
|
||||
|
||||
# Check for common issues. Pin encoding to UTF-8 because .env files are
|
||||
# written as UTF-8 everywhere in the codebase, while Path.read_text()
|
||||
# defaults to the system locale — which crashes on non-UTF-8 Windows
|
||||
# locales (e.g. GBK) as soon as the file contains any non-ASCII byte.
|
||||
content = env_path.read_text(encoding="utf-8")
|
||||
# Prefer UTF-8 (.env is written as UTF-8 elsewhere). Fall back to
|
||||
# latin-1 for Windows Notepad/cp1252 files that are not valid UTF-8 —
|
||||
# matches hermes_cli.env_loader._load_dotenv_with_fallback.
|
||||
try:
|
||||
content = env_path.read_text(encoding="utf-8")
|
||||
except UnicodeDecodeError:
|
||||
content = env_path.read_text(encoding="latin-1")
|
||||
if _has_provider_env_config(content):
|
||||
check_ok("API key or custom endpoint configured")
|
||||
else:
|
||||
@@ -2441,7 +2443,7 @@ def run_doctor(args):
|
||||
if lock_file.exists():
|
||||
try:
|
||||
import json
|
||||
lock_data = json.loads(lock_file.read_text())
|
||||
lock_data = json.loads(lock_file.read_text(encoding="utf-8"))
|
||||
count = len(lock_data.get("installed", {}))
|
||||
check_ok(f"Lock file OK ({count} hub-installed skill(s))")
|
||||
except Exception:
|
||||
@@ -2607,7 +2609,7 @@ def run_doctor(args):
|
||||
if not wrapper.is_file():
|
||||
continue
|
||||
try:
|
||||
content = wrapper.read_text()
|
||||
content = wrapper.read_text(encoding="utf-8")
|
||||
if "hermes -p" in content:
|
||||
_m = _re.search(r"hermes -p (\S+)", content)
|
||||
if _m and not profile_exists(_m.group(1)):
|
||||
|
||||
@@ -4251,7 +4251,7 @@ def launchd_install(force: bool = False):
|
||||
if _refuse_temp_home_service_write(new_plist, "launchd plist"):
|
||||
return
|
||||
print(f"Installing launchd service to: {plist_path}")
|
||||
plist_path.write_text(new_plist)
|
||||
plist_path.write_text(new_plist, encoding="utf-8")
|
||||
|
||||
try:
|
||||
_launchctl_bootstrap(
|
||||
|
||||
+250
-9
@@ -639,7 +639,7 @@ def _apply_profile_override() -> None:
|
||||
|
||||
active_path = get_default_hermes_root() / "active_profile"
|
||||
if active_path.exists():
|
||||
name = active_path.read_text().strip()
|
||||
name = active_path.read_text(encoding="utf-8").strip()
|
||||
if name and name != "default":
|
||||
profile_name = name
|
||||
consume = 0 # don't strip anything from argv
|
||||
@@ -1021,7 +1021,7 @@ def _has_any_provider_configured() -> bool:
|
||||
try:
|
||||
import json
|
||||
|
||||
auth = json.loads(auth_file.read_text())
|
||||
auth = json.loads(auth_file.read_text(encoding="utf-8"))
|
||||
active = auth.get("active_provider")
|
||||
if active:
|
||||
status = get_auth_status(active)
|
||||
@@ -4806,7 +4806,7 @@ def _gateway_prompt(prompt_text: str, default: str = "", timeout: float = 300.0)
|
||||
"id": str(_uuid.uuid4()),
|
||||
}
|
||||
tmp = prompt_path.with_suffix(".tmp")
|
||||
tmp.write_text(_json.dumps(payload))
|
||||
tmp.write_text(_json.dumps(payload), encoding="utf-8")
|
||||
tmp.replace(prompt_path)
|
||||
|
||||
# Poll for response
|
||||
@@ -4814,7 +4814,7 @@ def _gateway_prompt(prompt_text: str, default: str = "", timeout: float = 300.0)
|
||||
while _time.monotonic() < deadline:
|
||||
if response_path.exists():
|
||||
try:
|
||||
answer = response_path.read_text().strip()
|
||||
answer = response_path.read_text(encoding="utf-8").strip()
|
||||
response_path.unlink(missing_ok=True)
|
||||
prompt_path.unlink(missing_ok=True)
|
||||
return answer if answer else default
|
||||
@@ -5507,9 +5507,227 @@ def _desktop_packaged_executable(desktop_dir: Path) -> Optional[Path]:
|
||||
existing = [p for p in candidates if p.exists()]
|
||||
if not existing:
|
||||
return None
|
||||
if sys.platform == "win32" and len(existing) > 1:
|
||||
# Multiple unpacked trees can coexist (e.g. a stale win-arm64-unpacked
|
||||
# left behind by a cross-arch experiment next to the real win-unpacked).
|
||||
# Picking purely by mtime can then hand a wrong-architecture Hermes.exe
|
||||
# to the launcher, which Windows rejects with "This app can't run on
|
||||
# your computer" (#69179). Prefer candidates whose PE machine field
|
||||
# matches the host; fall back to mtime when none can be parsed.
|
||||
expected = _expected_windows_pe_machines()
|
||||
matching = [p for p in existing if _pe_machine_or_none(p) in expected]
|
||||
if matching:
|
||||
existing = matching
|
||||
return max(existing, key=lambda p: p.stat().st_mtime)
|
||||
|
||||
|
||||
# ─── Desktop exe integrity gate (#69179) ────────────────────────────────────
|
||||
#
|
||||
# The desktop self-update chain (Desktop → hermes-setup --update →
|
||||
# `hermes update` → `hermes desktop --build-only` → relaunch) rebuilds
|
||||
# Hermes.exe on the end user's machine and used to verify only that the file
|
||||
# EXISTS before declaring success. A corrupt cached Electron zip whose
|
||||
# extraction produced a truncated electron.exe, an interrupted rcedit resource
|
||||
# rewrite, a disk-full pack, or a wrong-arch unpacked tree therefore shipped a
|
||||
# broken binary that Windows refuses to load ("This app can't run on your
|
||||
# computer" / 此应用无法在你的电脑上运行). These helpers parse the PE header —
|
||||
# no signature infrastructure required — so a structurally broken or
|
||||
# wrong-architecture Hermes.exe is caught BEFORE the updater replaces the
|
||||
# working app, and the previous build can be restored from the .bak tree that
|
||||
# apps/desktop/scripts/before-pack.mjs now preserves.
|
||||
|
||||
_PE_MACHINE_I386 = 0x014C
|
||||
_PE_MACHINE_AMD64 = 0x8664
|
||||
_PE_MACHINE_ARM64 = 0xAA64
|
||||
|
||||
_PE_MACHINE_NAMES = {
|
||||
_PE_MACHINE_I386: "x86 (32-bit)",
|
||||
_PE_MACHINE_AMD64: "x64 (AMD64)",
|
||||
_PE_MACHINE_ARM64: "ARM64",
|
||||
}
|
||||
|
||||
|
||||
def _expected_windows_pe_machines() -> set:
|
||||
"""PE machine values the current Windows host can natively load.
|
||||
|
||||
AMD64 hosts run x64 and (via WOW64) x86. ARM64 hosts run ARM64 and
|
||||
(Windows 11 emulation) x64. 32-bit x86 hosts run only x86. Unknown
|
||||
machines return the permissive full set so the integrity gate can never
|
||||
brick launch on exotic hosts.
|
||||
"""
|
||||
import platform as _platform
|
||||
|
||||
machine = (_platform.machine() or "").upper()
|
||||
if machine in ("AMD64", "X86_64", "X64"):
|
||||
return {_PE_MACHINE_AMD64, _PE_MACHINE_I386}
|
||||
if machine in ("ARM64", "AARCH64"):
|
||||
return {_PE_MACHINE_ARM64, _PE_MACHINE_AMD64}
|
||||
if machine in ("X86", "I386", "I486", "I586", "I686"):
|
||||
return {_PE_MACHINE_I386}
|
||||
return {_PE_MACHINE_AMD64, _PE_MACHINE_ARM64, _PE_MACHINE_I386}
|
||||
|
||||
|
||||
def _parse_pe_machine(path: Path) -> int:
|
||||
"""Parse ``path`` as a PE executable and return its COFF machine field.
|
||||
|
||||
Raises ``ValueError`` with a human-readable reason when the file is not a
|
||||
structurally complete PE: missing MZ/PE magic (an HTML error page or JSON
|
||||
body saved as .exe), header truncation, or raw section data extending past
|
||||
the end of the file (the truncated-download / interrupted-extraction
|
||||
shape). Purely a header walk — cheap even on a 200 MB Electron exe.
|
||||
"""
|
||||
import struct
|
||||
|
||||
try:
|
||||
file_size = path.stat().st_size
|
||||
except OSError as exc:
|
||||
raise ValueError(f"unreadable: {exc}")
|
||||
if file_size < 512:
|
||||
raise ValueError(
|
||||
f"file is only {file_size} bytes — far too small to be a Windows executable"
|
||||
)
|
||||
with path.open("rb") as fh:
|
||||
head = fh.read(64)
|
||||
if len(head) < 64 or head[:2] != b"MZ":
|
||||
raise ValueError(
|
||||
"missing MZ header — not a Windows executable "
|
||||
"(a truncated or non-binary file saved as .exe?)"
|
||||
)
|
||||
e_lfanew = struct.unpack_from("<I", head, 0x3C)[0]
|
||||
if e_lfanew <= 0 or e_lfanew + 24 > file_size:
|
||||
raise ValueError("corrupt DOS header: PE header offset points past end of file")
|
||||
fh.seek(e_lfanew)
|
||||
pe_head = fh.read(24)
|
||||
if len(pe_head) < 24 or pe_head[:4] != b"PE\x00\x00":
|
||||
raise ValueError("missing PE signature — corrupt executable header")
|
||||
machine, n_sections = struct.unpack_from("<HH", pe_head, 4)
|
||||
size_of_optional = struct.unpack_from("<H", pe_head, 20)[0]
|
||||
fh.seek(e_lfanew + 24 + size_of_optional)
|
||||
max_section_end = 0
|
||||
for _ in range(n_sections):
|
||||
section = fh.read(40)
|
||||
if len(section) < 40:
|
||||
raise ValueError("truncated PE section table")
|
||||
size_of_raw, pointer_to_raw = struct.unpack_from("<II", section, 16)
|
||||
max_section_end = max(max_section_end, pointer_to_raw + size_of_raw)
|
||||
if file_size < max_section_end:
|
||||
raise ValueError(
|
||||
f"truncated executable: file is {file_size} bytes but its PE "
|
||||
f"sections extend to {max_section_end} bytes"
|
||||
)
|
||||
return machine
|
||||
|
||||
|
||||
def _pe_machine_or_none(path: Path) -> Optional[int]:
|
||||
try:
|
||||
return _parse_pe_machine(path)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _desktop_exe_integrity_error(path: Path) -> Optional[str]:
|
||||
"""Return a human-readable reason ``path`` cannot run on this Windows host,
|
||||
or ``None`` when the exe parses as a complete PE of a loadable architecture.
|
||||
"""
|
||||
try:
|
||||
machine = _parse_pe_machine(path)
|
||||
except ValueError as exc:
|
||||
return str(exc)
|
||||
expected = _expected_windows_pe_machines()
|
||||
if machine not in expected:
|
||||
import platform as _platform
|
||||
|
||||
got = _PE_MACHINE_NAMES.get(machine, f"unknown machine 0x{machine:04X}")
|
||||
return (
|
||||
f"architecture mismatch: built a {got} executable but this is a "
|
||||
f"{_platform.machine()} Windows host"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _desktop_backup_unpacked_dir(packaged_executable: Path) -> Path:
|
||||
"""The rollback tree before-pack.mjs preserves: ``<unpacked-dir>.bak``."""
|
||||
unpacked = packaged_executable.parent
|
||||
return unpacked.parent / (unpacked.name + ".bak")
|
||||
|
||||
|
||||
def _rollback_desktop_from_backup(packaged_executable: Path) -> Optional[Path]:
|
||||
"""Restore the previous unpacked desktop app from its ``.bak`` tree.
|
||||
|
||||
Returns the restored executable path, or ``None`` when no usable backup
|
||||
exists (missing, or its exe fails the same integrity probe). The corrupt
|
||||
tree is kept alongside as ``<unpacked-dir>.corrupt`` for diagnostics.
|
||||
Best-effort: never raises.
|
||||
"""
|
||||
unpacked = packaged_executable.parent
|
||||
backup_dir = _desktop_backup_unpacked_dir(packaged_executable)
|
||||
backup_exe = backup_dir / packaged_executable.name
|
||||
if not backup_exe.exists():
|
||||
return None
|
||||
if _desktop_exe_integrity_error(backup_exe) is not None:
|
||||
return None
|
||||
corrupt_dir = unpacked.parent / (unpacked.name + ".corrupt")
|
||||
try:
|
||||
shutil.rmtree(corrupt_dir, ignore_errors=True)
|
||||
try:
|
||||
unpacked.rename(corrupt_dir)
|
||||
except OSError:
|
||||
shutil.rmtree(unpacked, ignore_errors=True)
|
||||
backup_dir.rename(unpacked)
|
||||
except OSError:
|
||||
return None
|
||||
restored = unpacked / packaged_executable.name
|
||||
return restored if restored.exists() else None
|
||||
|
||||
|
||||
def _ensure_desktop_exe_launchable(
|
||||
desktop_dir: Path, packaged_executable: Optional[Path]
|
||||
) -> tuple:
|
||||
"""Windows post-build integrity gate for the self-update rebuild (#69179).
|
||||
|
||||
Returns ``(verified_exe_or_None, rolled_back)``:
|
||||
|
||||
- exe passed the probe → ``(exe, False)``
|
||||
- exe corrupt/wrong-arch, previous build restored → ``(old_exe, True)``
|
||||
- exe corrupt and nothing restorable → ``(None, False)``
|
||||
|
||||
On any integrity failure the corrupt cached Electron zip is purged and the
|
||||
desktop build stamp invalidated, so the updater's retry-once rebuild pulls
|
||||
a fresh, SHASUM-verified Electron download instead of re-staging the same
|
||||
corrupt bytes. No-op off Windows and when there is no executable to check.
|
||||
"""
|
||||
if packaged_executable is None or sys.platform != "win32":
|
||||
return packaged_executable, False
|
||||
|
||||
error = _desktop_exe_integrity_error(packaged_executable)
|
||||
if error is None:
|
||||
return packaged_executable, False
|
||||
|
||||
print(f"✗ The built Hermes.exe failed its integrity check: {error}")
|
||||
print(f" at: {packaged_executable}")
|
||||
|
||||
# Self-heal setup for the retry: drop the (likely corrupt) cached Electron
|
||||
# zip and the content stamp so the next rebuild is a genuine re-download +
|
||||
# re-stage rather than a replay of the same broken extraction.
|
||||
_purge_electron_build_cache(desktop_dir)
|
||||
try:
|
||||
_desktop_stamp_path().unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
restored = _rollback_desktop_from_backup(packaged_executable)
|
||||
if restored is not None:
|
||||
print(" ↩ Update aborted — restored the previous working Hermes.exe from backup.")
|
||||
print(" Your existing version was kept and still works. Run `hermes desktop`")
|
||||
print(" (or the in-app update) again to retry with a fresh Electron download.")
|
||||
return restored, True
|
||||
|
||||
print(" ✗ No usable backup was found to restore.")
|
||||
print(" Run `hermes desktop --force-build` to rebuild, or re-run the Hermes")
|
||||
print(" installer to repair the install.")
|
||||
return None, False
|
||||
|
||||
|
||||
def _electron_download_cache_dirs() -> list[Path]:
|
||||
"""Return the per-user Electron download cache directories for this OS.
|
||||
|
||||
@@ -6151,6 +6369,22 @@ def cmd_gui(args: argparse.Namespace):
|
||||
# damaged"). No-op on non-macOS and on real-identity builds.
|
||||
_desktop_macos_relaunchable_fixup(desktop_dir)
|
||||
|
||||
# Windows integrity gate (#69179): never declare the rebuild a
|
||||
# success on a Hermes.exe Windows cannot load (truncated PE from
|
||||
# a corrupt cached Electron zip, wrong-arch tree, interrupted
|
||||
# rcedit rewrite). Roll back to the .bak tree preserved by
|
||||
# before-pack.mjs when possible, then fail loudly so the
|
||||
# updater's retry-once rebuilds from a fresh Electron download
|
||||
# instead of silently shipping the broken exe.
|
||||
verified_executable, rolled_back = _ensure_desktop_exe_launchable(
|
||||
desktop_dir, packaged_executable
|
||||
)
|
||||
if packaged_executable is not None and (
|
||||
rolled_back or verified_executable is None
|
||||
):
|
||||
sys.exit(1)
|
||||
packaged_executable = verified_executable
|
||||
|
||||
# Build succeeded — write the stamp so next run can skip
|
||||
_write_desktop_build_stamp(PROJECT_ROOT, source_mode=source_mode)
|
||||
|
||||
@@ -9959,7 +10193,7 @@ def _ensure_fhs_path_guard() -> None:
|
||||
if not cfg.is_file():
|
||||
continue
|
||||
try:
|
||||
existing = cfg.read_text(errors="replace")
|
||||
existing = cfg.read_text(errors="replace", encoding="utf-8")
|
||||
except OSError:
|
||||
continue
|
||||
# Idempotency: skip if any uncommented PATH= line already references
|
||||
@@ -11905,7 +12139,7 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
if gateway_mode:
|
||||
_exit_code_path = get_hermes_home() / ".update_exit_code"
|
||||
try:
|
||||
_exit_code_path.write_text("0")
|
||||
_exit_code_path.write_text("0", encoding="utf-8")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
@@ -12535,7 +12769,7 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
if gateway_mode:
|
||||
_exit_code_path = get_hermes_home() / ".update_exit_code"
|
||||
try:
|
||||
_exit_code_path.write_text("1")
|
||||
_exit_code_path.write_text("1", encoding="utf-8")
|
||||
except OSError:
|
||||
pass
|
||||
_warn_incomplete_gateway_fleet_restart(failed_or_stale_units)
|
||||
@@ -13357,7 +13591,11 @@ def _render_distribution_plan(plan) -> None:
|
||||
env_path = plan.target_dir / ".env"
|
||||
if env_path.is_file():
|
||||
try:
|
||||
for raw in env_path.read_text().splitlines():
|
||||
# .env is written as UTF-8 everywhere in the codebase,
|
||||
# but a Notepad-edited file can carry a BOM — read as
|
||||
# utf-8-sig so the first key isn't hidden behind
|
||||
# U+FEFF (#62617).
|
||||
for raw in env_path.read_text(encoding="utf-8-sig").splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
@@ -13365,7 +13603,10 @@ def _render_distribution_plan(plan) -> None:
|
||||
if key == er.name:
|
||||
already = True
|
||||
break
|
||||
except OSError:
|
||||
except (OSError, UnicodeDecodeError):
|
||||
# UnicodeDecodeError is a ValueError, not an OSError, so
|
||||
# the old guard let a mis-encoded .env abort the whole
|
||||
# install preview. Skip the pre-check instead.
|
||||
pass
|
||||
status = "✓ set" if already else ("needs setting" if er.required else "—")
|
||||
line = f" • {er.name} ({tag}, {status})"
|
||||
|
||||
@@ -1601,7 +1601,7 @@ class PluginManager:
|
||||
init_file = plugin_dir / "__init__.py"
|
||||
if init_file.exists():
|
||||
try:
|
||||
source_text = init_file.read_text(errors="replace")[:8192]
|
||||
source_text = init_file.read_text(errors="replace", encoding="utf-8")[:8192]
|
||||
if (
|
||||
"register_memory_provider" in source_text
|
||||
or "MemoryProvider" in source_text
|
||||
|
||||
@@ -414,7 +414,7 @@ def check_alias_collision(name: str) -> Optional[str]:
|
||||
expected = wrapper_dir / (f"{canon}.bat" if is_windows else canon)
|
||||
if existing_path == str(expected):
|
||||
try:
|
||||
content = expected.read_text()
|
||||
content = expected.read_text(encoding="utf-8")
|
||||
if "hermes -p" in content:
|
||||
return None # it's our wrapper, safe to overwrite
|
||||
except Exception:
|
||||
@@ -458,7 +458,7 @@ def create_wrapper_script(name: str, target: Optional[str] = None) -> Optional[P
|
||||
if is_windows:
|
||||
wrapper_path = wrapper_dir / f"{canon}.bat"
|
||||
try:
|
||||
wrapper_path.write_text(f"@echo off\r\nhermes -p {profile} %*\r\n")
|
||||
wrapper_path.write_text(f"@echo off\r\nhermes -p {profile} %*\r\n", encoding="utf-8")
|
||||
return wrapper_path
|
||||
except OSError as e:
|
||||
print(f"⚠ Could not create wrapper at {wrapper_path}: {e}")
|
||||
@@ -467,7 +467,7 @@ def create_wrapper_script(name: str, target: Optional[str] = None) -> Optional[P
|
||||
wrapper_path = wrapper_dir / canon
|
||||
try:
|
||||
hermes_exe = shutil.which("hermes") or "hermes"
|
||||
wrapper_path.write_text(f'#!/bin/sh\nexec {shlex.quote(hermes_exe)} -p {profile} "$@"\n')
|
||||
wrapper_path.write_text(f'#!/bin/sh\nexec {shlex.quote(hermes_exe)} -p {profile} "$@"\n', encoding="utf-8")
|
||||
wrapper_path.chmod(wrapper_path.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
|
||||
return wrapper_path
|
||||
except OSError as e:
|
||||
@@ -496,7 +496,7 @@ def remove_wrapper_script(name: str) -> bool:
|
||||
if wrapper_path.exists():
|
||||
try:
|
||||
# Verify it's our wrapper before removing
|
||||
content = wrapper_path.read_text()
|
||||
content = wrapper_path.read_text(encoding="utf-8")
|
||||
if "hermes -p" in content:
|
||||
wrapper_path.unlink()
|
||||
return True
|
||||
@@ -1755,7 +1755,7 @@ def _stop_gateway_process(profile_dir: Path) -> None:
|
||||
return
|
||||
|
||||
try:
|
||||
raw = pid_file.read_text().strip()
|
||||
raw = pid_file.read_text(encoding="utf-8").strip()
|
||||
data = json.loads(raw) if raw.startswith("{") else {"pid": int(raw)}
|
||||
pid = int(data["pid"])
|
||||
# Route through terminate_pid so Windows uses the appropriate
|
||||
@@ -1796,7 +1796,7 @@ def get_active_profile() -> str:
|
||||
"""
|
||||
path = _get_active_profile_path()
|
||||
try:
|
||||
name = path.read_text().strip()
|
||||
name = path.read_text(encoding="utf-8").strip()
|
||||
if not name:
|
||||
return "default"
|
||||
return name
|
||||
@@ -1825,7 +1825,7 @@ def set_active_profile(name: str) -> None:
|
||||
else:
|
||||
# Atomic write
|
||||
tmp = path.with_suffix(".tmp")
|
||||
tmp.write_text(canon + "\n")
|
||||
tmp.write_text(canon + "\n", encoding="utf-8")
|
||||
tmp.replace(path)
|
||||
|
||||
|
||||
|
||||
@@ -376,7 +376,7 @@ def _write_gateway_desired_state(name: str, desired_state: str) -> None:
|
||||
if not profile_dir.exists():
|
||||
return
|
||||
try:
|
||||
data = json.loads(state_file.read_text()) if state_file.exists() else {}
|
||||
data = json.loads(state_file.read_text(encoding="utf-8")) if state_file.exists() else {}
|
||||
if not isinstance(data, dict):
|
||||
data = {}
|
||||
except (OSError, json.JSONDecodeError):
|
||||
@@ -384,7 +384,7 @@ def _write_gateway_desired_state(name: str, desired_state: str) -> None:
|
||||
data["desired_state"] = desired_state
|
||||
data["updated_at"] = int(time.time())
|
||||
tmp = state_file.with_suffix(state_file.suffix + ".tmp")
|
||||
tmp.write_text(json.dumps(data, separators=(",", ":")) + "\n")
|
||||
tmp.write_text(json.dumps(data, separators=(",", ":")) + "\n", encoding="utf-8")
|
||||
tmp.replace(state_file)
|
||||
except OSError:
|
||||
return
|
||||
@@ -987,22 +987,22 @@ class S6ServiceManager:
|
||||
tmp_dir.mkdir(parents=True)
|
||||
|
||||
try:
|
||||
(tmp_dir / "type").write_text("longrun\n")
|
||||
(tmp_dir / "type").write_text("longrun\n", encoding="utf-8")
|
||||
|
||||
run_script = self._render_run_script(profile, extra_env or {})
|
||||
run_path = tmp_dir / "run"
|
||||
run_path.write_text(run_script)
|
||||
run_path.write_text(run_script, encoding="utf-8")
|
||||
run_path.chmod(0o755)
|
||||
|
||||
finish_path = tmp_dir / "finish"
|
||||
finish_path.write_text(self._render_finish_script())
|
||||
finish_path.write_text(self._render_finish_script(), encoding="utf-8")
|
||||
finish_path.chmod(0o755)
|
||||
|
||||
# Persistent log rotation (OQ8-C).
|
||||
log_subdir = tmp_dir / "log"
|
||||
log_subdir.mkdir()
|
||||
log_run = log_subdir / "run"
|
||||
log_run.write_text(self._render_log_run(profile))
|
||||
log_run.write_text(self._render_log_run(profile), encoding="utf-8")
|
||||
log_run.chmod(0o755)
|
||||
|
||||
# Pre-create the supervise/ skeleton with hermes ownership
|
||||
|
||||
@@ -57,7 +57,7 @@ def remove_path_from_shell_configs():
|
||||
|
||||
for config_path in configs:
|
||||
try:
|
||||
content = config_path.read_text()
|
||||
content = config_path.read_text(encoding="utf-8")
|
||||
original_content = content
|
||||
|
||||
# Remove lines containing hermes-agent or hermes PATH entries
|
||||
@@ -87,7 +87,7 @@ def remove_path_from_shell_configs():
|
||||
new_content = new_content.replace('\n\n\n', '\n\n')
|
||||
|
||||
if new_content != original_content:
|
||||
config_path.write_text(new_content)
|
||||
config_path.write_text(new_content, encoding="utf-8")
|
||||
removed_from.append(config_path)
|
||||
|
||||
except Exception as e:
|
||||
@@ -108,7 +108,7 @@ def remove_wrapper_script():
|
||||
if wrapper.exists():
|
||||
try:
|
||||
# Check if it's our wrapper (contains hermes_cli reference)
|
||||
content = wrapper.read_text()
|
||||
content = wrapper.read_text(encoding="utf-8")
|
||||
if 'hermes_cli' in content or 'hermes-agent' in content:
|
||||
wrapper.unlink()
|
||||
removed.append(wrapper)
|
||||
|
||||
@@ -3036,6 +3036,16 @@ async def get_ssh_ownership(request: Request):
|
||||
return {"ok": True, "sshOwnerNonce": _SSH_OWNER_NONCE, "protocolVersion": 1}
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
async def get_health():
|
||||
"""Lightweight process liveness for desktop/backend readiness probes."""
|
||||
return {
|
||||
"ok": True,
|
||||
"version": __version__,
|
||||
"auth_required": bool(getattr(app.state, "auth_required", False)),
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/status")
|
||||
async def get_status(profile: Optional[str] = None):
|
||||
status_scope = None
|
||||
|
||||
+1
-1
@@ -78,7 +78,7 @@ def _warn_profile_fallback_once() -> None:
|
||||
try:
|
||||
fallback_home = _get_platform_default_hermes_home()
|
||||
active_path = fallback_home / "active_profile"
|
||||
active = active_path.read_text().strip() if active_path.exists() else ""
|
||||
active = active_path.read_text(encoding="utf-8").strip() if active_path.exists() else ""
|
||||
except (UnicodeDecodeError, OSError):
|
||||
active = ""
|
||||
if active and active != "default":
|
||||
|
||||
+64
-7
@@ -5983,6 +5983,58 @@ class SessionDB:
|
||||
return content
|
||||
return content
|
||||
|
||||
@staticmethod
|
||||
def _encode_display_metadata(display_metadata: Any) -> Optional[str]:
|
||||
"""Serialize ``display_metadata`` for its TEXT column without double-encoding.
|
||||
|
||||
Import/replace paths can hand us an already-serialized JSON string (the
|
||||
same hazard ``tool_calls`` guards against above). ``json.dumps`` on that
|
||||
string would store a quoted JSON string, and the single ``json.loads``
|
||||
on read then yields a ``str`` instead of a dict.
|
||||
"""
|
||||
if not display_metadata:
|
||||
return None
|
||||
if isinstance(display_metadata, str):
|
||||
try:
|
||||
parsed = json.loads(display_metadata)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
logger.warning("Ignoring non-JSON display metadata on write")
|
||||
return None
|
||||
if not isinstance(parsed, dict):
|
||||
logger.warning("Ignoring non-object display metadata on write")
|
||||
return None
|
||||
return json.dumps(parsed)
|
||||
if isinstance(display_metadata, dict):
|
||||
return json.dumps(display_metadata)
|
||||
logger.warning(
|
||||
"Ignoring unexpected display metadata type on write: %s",
|
||||
type(display_metadata).__name__,
|
||||
)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _decode_display_metadata(raw: Any) -> Optional[Dict[str, Any]]:
|
||||
"""Decode a ``display_metadata`` column into the dict every reader expects.
|
||||
|
||||
Every message read path must go through this. Returning the raw TEXT
|
||||
instead reaches the desktop as a string, where ``'task_count' in meta``
|
||||
throws and fails the whole resume. Rows written before the encode guard
|
||||
landed are double-encoded, so unwrap a second layer when we find one.
|
||||
"""
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
meta = json.loads(raw) if isinstance(raw, str) else raw
|
||||
if isinstance(meta, str):
|
||||
meta = json.loads(meta)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
logger.warning("Ignoring invalid display metadata on message row")
|
||||
return None
|
||||
if not isinstance(meta, dict):
|
||||
logger.warning("Ignoring non-object display metadata on message row")
|
||||
return None
|
||||
return meta
|
||||
|
||||
def append_message(
|
||||
self,
|
||||
session_id: str,
|
||||
@@ -6029,7 +6081,7 @@ class SessionDB:
|
||||
"""
|
||||
# Display metadata is presentation-only and never changes the model
|
||||
# context role/content replayed to providers.
|
||||
display_metadata_json = json.dumps(display_metadata) if display_metadata else None
|
||||
display_metadata_json = self._encode_display_metadata(display_metadata)
|
||||
# Serialize structured fields to JSON before entering the write txn
|
||||
reasoning_details_json = (
|
||||
json.dumps(reasoning_details)
|
||||
@@ -6168,7 +6220,7 @@ class SessionDB:
|
||||
"UPDATE messages SET display_kind = ?, display_metadata = ? WHERE id = ?",
|
||||
(
|
||||
_scrub_surrogates(display_kind),
|
||||
json.dumps(display_metadata) if display_metadata else None,
|
||||
self._encode_display_metadata(display_metadata),
|
||||
row[0],
|
||||
),
|
||||
)
|
||||
@@ -6262,7 +6314,7 @@ class SessionDB:
|
||||
1,
|
||||
_scrub_surrogates(api_content) if isinstance(api_content, str) else None,
|
||||
_scrub_surrogates(msg.get("display_kind")) if isinstance(msg.get("display_kind"), str) else None,
|
||||
json.dumps(msg["display_metadata"]) if msg.get("display_metadata") else None,
|
||||
self._encode_display_metadata(msg.get("display_metadata")),
|
||||
),
|
||||
)
|
||||
inserted += 1
|
||||
@@ -6476,6 +6528,8 @@ class SessionDB:
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
logger.warning("Failed to deserialize tool_calls in get_messages, falling back to []")
|
||||
msg["tool_calls"] = []
|
||||
if msg.get("display_metadata") is not None:
|
||||
msg["display_metadata"] = self._decode_display_metadata(msg["display_metadata"])
|
||||
result.append(msg)
|
||||
return result
|
||||
|
||||
@@ -6545,6 +6599,8 @@ class SessionDB:
|
||||
"Failed to deserialize tool_calls in get_messages_around, falling back to []"
|
||||
)
|
||||
msg["tool_calls"] = []
|
||||
if msg.get("display_metadata") is not None:
|
||||
msg["display_metadata"] = self._decode_display_metadata(msg["display_metadata"])
|
||||
result.append(msg)
|
||||
|
||||
# before_rows includes the anchor itself; subtract 1 for the count of
|
||||
@@ -6667,6 +6723,8 @@ class SessionDB:
|
||||
"Failed to deserialize tool_calls in get_anchored_view, falling back to []"
|
||||
)
|
||||
msg["tool_calls"] = []
|
||||
if msg.get("display_metadata") is not None:
|
||||
msg["display_metadata"] = self._decode_display_metadata(msg["display_metadata"])
|
||||
return msg
|
||||
|
||||
return {
|
||||
@@ -6865,10 +6923,9 @@ class SessionDB:
|
||||
if row["display_kind"]:
|
||||
msg["display_kind"] = row["display_kind"]
|
||||
if row["display_metadata"]:
|
||||
try:
|
||||
msg["display_metadata"] = json.loads(row["display_metadata"])
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
logger.warning("Ignoring invalid display metadata on message row")
|
||||
decoded = self._decode_display_metadata(row["display_metadata"])
|
||||
if decoded is not None:
|
||||
msg["display_metadata"] = decoded
|
||||
if row["timestamp"]:
|
||||
msg["timestamp"] = row["timestamp"]
|
||||
if row["tool_call_id"]:
|
||||
|
||||
@@ -68,7 +68,7 @@ ASSETS_DIR = Path(__file__).resolve().parent.parent / "assets"
|
||||
|
||||
|
||||
def load_template(name: str) -> str:
|
||||
return (ASSETS_DIR / name).read_text()
|
||||
return (ASSETS_DIR / name).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
PROFILE_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$")
|
||||
@@ -471,7 +471,7 @@ def main():
|
||||
help="Write TEAM.md alongside (default: skipped)")
|
||||
args = ap.parse_args()
|
||||
|
||||
plan = json.loads(Path(args.plan_json).read_text())
|
||||
plan = json.loads(Path(args.plan_json).read_text(encoding="utf-8"))
|
||||
errors = validate_plan(plan)
|
||||
if errors:
|
||||
print("Plan validation failed:", file=sys.stderr)
|
||||
@@ -483,15 +483,15 @@ def main():
|
||||
team = render_team_md(plan)
|
||||
setup = render_setup_sh(plan, brief, team)
|
||||
|
||||
Path(args.out).write_text(setup)
|
||||
Path(args.out).write_text(setup, encoding="utf-8")
|
||||
os.chmod(args.out, 0o755)
|
||||
print(f"Wrote {args.out}")
|
||||
|
||||
if args.brief_out:
|
||||
Path(args.brief_out).write_text(brief)
|
||||
Path(args.brief_out).write_text(brief, encoding="utf-8")
|
||||
print(f"Wrote {args.brief_out}")
|
||||
if args.team_out:
|
||||
Path(args.team_out).write_text(team)
|
||||
Path(args.team_out).write_text(team, encoding="utf-8")
|
||||
print(f"Wrote {args.team_out}")
|
||||
|
||||
|
||||
|
||||
@@ -312,7 +312,7 @@ def sha256_file(path: Path) -> str:
|
||||
|
||||
|
||||
def read_text(path: Path) -> str:
|
||||
return path.read_text(encoding="utf-8")
|
||||
return path.read_text(encoding="utf-8", errors="replace")
|
||||
|
||||
|
||||
def normalize_text(text: str) -> str:
|
||||
@@ -349,7 +349,12 @@ def resolve_secret_input(value: Any, env: Optional[Dict[str, str]] = None) -> Op
|
||||
def load_yaml_file(path: Path) -> Dict[str, Any]:
|
||||
if yaml is None or not path.exists():
|
||||
return {}
|
||||
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||
try:
|
||||
# errors="replace" means read_text() cannot raise UnicodeDecodeError;
|
||||
# OSError covers races like the file disappearing or being unreadable.
|
||||
data = yaml.safe_load(path.read_text(encoding="utf-8", errors="replace"))
|
||||
except (yaml.YAMLError, OSError):
|
||||
return {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
@@ -367,7 +372,13 @@ def parse_env_file(path: Path) -> Dict[str, str]:
|
||||
if not path.exists():
|
||||
return {}
|
||||
data: Dict[str, str] = {}
|
||||
for raw_line in path.read_text(encoding="utf-8").splitlines():
|
||||
try:
|
||||
# errors="replace" means read_text() cannot raise UnicodeDecodeError;
|
||||
# OSError covers races like the file disappearing or being unreadable.
|
||||
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
|
||||
except OSError:
|
||||
return {}
|
||||
for raw_line in lines:
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
@@ -1208,10 +1219,13 @@ class Migrator:
|
||||
return
|
||||
|
||||
try:
|
||||
data = json.loads(source.read_text(encoding="utf-8"))
|
||||
data = json.loads(source.read_text(encoding="utf-8", errors="replace"))
|
||||
except json.JSONDecodeError as exc:
|
||||
self.record("command-allowlist", source, destination, "error", f"Invalid JSON: {exc}")
|
||||
return
|
||||
except OSError as exc:
|
||||
self.record("command-allowlist", source, destination, "error", f"Could not read file: {exc}")
|
||||
return
|
||||
|
||||
patterns: List[str] = []
|
||||
agents = data.get("agents", {})
|
||||
@@ -1262,9 +1276,11 @@ class Migrator:
|
||||
config_path = self.source_root / name
|
||||
if config_path.exists():
|
||||
try:
|
||||
data = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
# errors="replace" means read_text() cannot raise
|
||||
# UnicodeDecodeError; OSError covers unreadable/vanished files.
|
||||
data = json.loads(config_path.read_text(encoding="utf-8", errors="replace"))
|
||||
return data if isinstance(data, dict) else {}
|
||||
except json.JSONDecodeError:
|
||||
except (json.JSONDecodeError, OSError):
|
||||
continue
|
||||
return {}
|
||||
|
||||
@@ -1343,9 +1359,11 @@ class Migrator:
|
||||
allowlist_path = self.source_root / "credentials" / "telegram-default-allowFrom.json"
|
||||
if allowlist_path.exists():
|
||||
try:
|
||||
allow_data = json.loads(allowlist_path.read_text(encoding="utf-8"))
|
||||
allow_data = json.loads(allowlist_path.read_text(encoding="utf-8", errors="replace"))
|
||||
except json.JSONDecodeError:
|
||||
self.record("messaging-settings", allowlist_path, self.target_root / ".env", "error", "Invalid JSON in Telegram allowlist file")
|
||||
except OSError as exc:
|
||||
self.record("messaging-settings", allowlist_path, self.target_root / ".env", "error", f"Could not read Telegram allowlist file: {exc}")
|
||||
else:
|
||||
allow_from = allow_data.get("allowFrom", [])
|
||||
if isinstance(allow_from, list):
|
||||
@@ -1617,7 +1635,7 @@ class Migrator:
|
||||
auth_profiles_path = self.source_root / "agents" / "main" / "agent" / "auth-profiles.json"
|
||||
if auth_profiles_path.exists():
|
||||
try:
|
||||
profiles = json.loads(auth_profiles_path.read_text(encoding="utf-8"))
|
||||
profiles = json.loads(auth_profiles_path.read_text(encoding="utf-8", errors="replace"))
|
||||
if isinstance(profiles, dict):
|
||||
# auth-profiles.json wraps profiles in a "profiles" key
|
||||
profile_entries = profiles.get("profiles", profiles) if isinstance(profiles.get("profiles"), dict) else profiles
|
||||
@@ -1901,7 +1919,12 @@ class Migrator:
|
||||
|
||||
all_incoming: List[str] = []
|
||||
for md_file in md_files:
|
||||
entries = extract_markdown_entries(read_text(md_file))
|
||||
try:
|
||||
# read_text() uses errors="replace" so it cannot raise
|
||||
# UnicodeDecodeError; OSError covers unreadable/vanished files.
|
||||
entries = extract_markdown_entries(read_text(md_file))
|
||||
except OSError:
|
||||
continue
|
||||
all_incoming.extend(entries)
|
||||
|
||||
if not all_incoming:
|
||||
|
||||
@@ -141,7 +141,7 @@ def build_findings(
|
||||
|
||||
# 3. Timing-based findings.
|
||||
if timing_path and Path(timing_path).exists():
|
||||
timing = json.loads(Path(timing_path).read_text())
|
||||
timing = json.loads(Path(timing_path).read_text(encoding="utf-8"))
|
||||
for r in timing.get("results", []):
|
||||
if not r.get("significant"):
|
||||
continue
|
||||
@@ -190,7 +190,7 @@ def build_findings(
|
||||
},
|
||||
"findings": findings,
|
||||
}
|
||||
Path(out_path).write_text(json.dumps(payload, indent=2))
|
||||
Path(out_path).write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
||||
return payload
|
||||
|
||||
|
||||
|
||||
@@ -198,7 +198,7 @@ def analyze(
|
||||
"results": results,
|
||||
}
|
||||
|
||||
Path(out_path).write_text(json.dumps(payload, indent=2))
|
||||
Path(out_path).write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
||||
return payload
|
||||
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ def _is_cron_provider_dir(path: Path) -> bool:
|
||||
if not init_file.exists():
|
||||
return False
|
||||
try:
|
||||
source = init_file.read_text(errors="replace")[:8192]
|
||||
source = init_file.read_text(errors="replace", encoding="utf-8")[:8192]
|
||||
return "register_cron_scheduler" in source or "CronScheduler" in source
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@@ -110,12 +110,12 @@ def load_tracked() -> List[Dict[str, Any]]:
|
||||
return []
|
||||
|
||||
try:
|
||||
return json.loads(tf.read_text())
|
||||
return json.loads(tf.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
bak = tf.with_suffix(".json.bak")
|
||||
if bak.exists():
|
||||
try:
|
||||
data = json.loads(bak.read_text())
|
||||
data = json.loads(bak.read_text(encoding="utf-8"))
|
||||
_log("WARN: tracked.json corrupted — restored from .bak")
|
||||
return data
|
||||
except Exception:
|
||||
@@ -129,7 +129,7 @@ def save_tracked(tracked: List[Dict[str, Any]]) -> None:
|
||||
tf = get_tracked_file()
|
||||
tf.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = tf.with_suffix(".json.tmp")
|
||||
tmp.write_text(json.dumps(tracked, indent=2))
|
||||
tmp.write_text(json.dumps(tracked, indent=2), encoding="utf-8")
|
||||
if tf.exists():
|
||||
shutil.copy2(tf, tf.with_suffix(".json.bak"))
|
||||
tmp.replace(tf)
|
||||
|
||||
@@ -262,7 +262,7 @@ class RealtimeSpeaker:
|
||||
if not self.queue_path.exists():
|
||||
return []
|
||||
out: list[dict] = []
|
||||
for line in self.queue_path.read_text().splitlines():
|
||||
for line in self.queue_path.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
@@ -281,10 +281,10 @@ class RealtimeSpeaker:
|
||||
if not remaining:
|
||||
# Keep the file but empty — consumers may be watching for
|
||||
# new writes via mtime, and delete-then-recreate is a race.
|
||||
self.queue_path.write_text("")
|
||||
self.queue_path.write_text("", encoding="utf-8")
|
||||
return
|
||||
self.queue_path.write_text(
|
||||
"\n".join(json.dumps(e) for e in remaining) + "\n"
|
||||
"\n".join(json.dumps(e) for e in remaining) + "\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
def _append_processed(self, entry: dict, result: dict) -> None:
|
||||
|
||||
@@ -159,7 +159,7 @@ def load_state() -> Dict[str, Any]:
|
||||
if not path.exists():
|
||||
return {"unlocks": {}}
|
||||
try:
|
||||
return json.loads(path.read_text())
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return {"unlocks": {}}
|
||||
|
||||
@@ -167,7 +167,7 @@ def load_state() -> Dict[str, Any]:
|
||||
def save_state(state: Dict[str, Any]) -> None:
|
||||
path = state_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(state, indent=2, sort_keys=True))
|
||||
path.write_text(json.dumps(state, indent=2, sort_keys=True), encoding="utf-8")
|
||||
|
||||
|
||||
def _json_safe(value: Any) -> Any:
|
||||
@@ -185,7 +185,7 @@ def load_snapshot() -> Optional[Dict[str, Any]]:
|
||||
if not path.exists():
|
||||
return None
|
||||
try:
|
||||
data = json.loads(path.read_text())
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
if isinstance(data, dict):
|
||||
return data
|
||||
except Exception:
|
||||
@@ -196,7 +196,7 @@ def load_snapshot() -> Optional[Dict[str, Any]]:
|
||||
def save_snapshot(data: Dict[str, Any]) -> None:
|
||||
path = snapshot_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(_json_safe(data), indent=2, sort_keys=True))
|
||||
path.write_text(json.dumps(_json_safe(data), indent=2, sort_keys=True), encoding="utf-8")
|
||||
|
||||
|
||||
def load_checkpoint() -> Dict[str, Any]:
|
||||
@@ -204,7 +204,7 @@ def load_checkpoint() -> Dict[str, Any]:
|
||||
if not path.exists():
|
||||
return {"schema_version": 1, "generated_at": 0, "sessions": {}}
|
||||
try:
|
||||
data = json.loads(path.read_text())
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
if isinstance(data, dict):
|
||||
data.setdefault("schema_version", 1)
|
||||
data.setdefault("generated_at", 0)
|
||||
@@ -219,7 +219,7 @@ def load_checkpoint() -> Dict[str, Any]:
|
||||
def save_checkpoint(data: Dict[str, Any]) -> None:
|
||||
path = checkpoint_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(_json_safe(data), indent=2, sort_keys=True))
|
||||
path.write_text(json.dumps(_json_safe(data), indent=2, sort_keys=True), encoding="utf-8")
|
||||
|
||||
|
||||
def session_fingerprint(meta: Dict[str, Any]) -> Dict[str, Any]:
|
||||
|
||||
@@ -81,7 +81,7 @@ def _is_memory_provider_dir(path: Path) -> bool:
|
||||
if not init_file.exists():
|
||||
return False
|
||||
try:
|
||||
source = init_file.read_text(errors="replace")[:8192]
|
||||
source = init_file.read_text(errors="replace", encoding="utf-8")[:8192]
|
||||
return "register_memory_provider" in source or "MemoryProvider" in source
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@@ -496,7 +496,9 @@ def _load_simple_env(path) -> dict[str, str]:
|
||||
return {}
|
||||
|
||||
values: dict[str, str] = {}
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
# utf-8-sig, not plain utf-8: this is also used on the Hermes .env during
|
||||
# post_setup, and a Notepad BOM would otherwise stick to the first key.
|
||||
for line in path.read_text(encoding="utf-8-sig", errors="replace").splitlines():
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, value = line.split("=", 1)
|
||||
@@ -746,7 +748,7 @@ class HindsightMemoryProvider(MemoryProvider):
|
||||
existing = {}
|
||||
if config_path.exists():
|
||||
try:
|
||||
existing = json.loads(config_path.read_text())
|
||||
existing = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
pass
|
||||
existing.update(values)
|
||||
@@ -895,7 +897,8 @@ class HindsightMemoryProvider(MemoryProvider):
|
||||
env_path = Path(hermes_home) / ".env"
|
||||
existing_llm_key = ""
|
||||
if env_path.exists():
|
||||
for line in env_path.read_text().splitlines():
|
||||
# utf-8-sig: a Notepad BOM must not hide the first key.
|
||||
for line in env_path.read_text(encoding="utf-8-sig").splitlines():
|
||||
if line.startswith("HINDSIGHT_LLM_API_KEY="):
|
||||
existing_llm_key = line.split("=", 1)[1]
|
||||
break
|
||||
@@ -925,7 +928,10 @@ class HindsightMemoryProvider(MemoryProvider):
|
||||
env_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
existing_lines = []
|
||||
if env_path.exists():
|
||||
existing_lines = env_path.read_text().splitlines()
|
||||
# utf-8-sig: a Notepad BOM would glue U+FEFF onto the first
|
||||
# key, defeating the in-place update below and appending a
|
||||
# duplicate line instead.
|
||||
existing_lines = env_path.read_text(encoding="utf-8-sig").splitlines()
|
||||
updated_keys = set()
|
||||
new_lines = []
|
||||
for line in existing_lines:
|
||||
@@ -938,7 +944,7 @@ class HindsightMemoryProvider(MemoryProvider):
|
||||
for k, v in env_writes.items():
|
||||
if k not in updated_keys:
|
||||
new_lines.append(f"{k}={v}")
|
||||
env_path.write_text("\n".join(new_lines) + "\n")
|
||||
env_path.write_text("\n".join(new_lines) + "\n", encoding="utf-8")
|
||||
|
||||
if mode == "local_embedded":
|
||||
materialized_config = dict(provider_config)
|
||||
|
||||
@@ -317,7 +317,7 @@ class HonchoMemoryProvider(MemoryProvider):
|
||||
existing = {}
|
||||
if config_path.exists():
|
||||
try:
|
||||
existing = json.loads(config_path.read_text())
|
||||
existing = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
pass
|
||||
existing.update(values)
|
||||
|
||||
@@ -240,7 +240,7 @@ class Mem0MemoryProvider(MemoryProvider):
|
||||
existing = {}
|
||||
if config_path.exists():
|
||||
try:
|
||||
existing = json.loads(config_path.read_text())
|
||||
existing = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
pass
|
||||
existing.update(values)
|
||||
|
||||
@@ -193,7 +193,14 @@ def _write_env(env_path: Path, env_writes: dict[str, str]) -> None:
|
||||
env_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
existing_lines: list[str] = []
|
||||
if env_path.exists():
|
||||
existing_lines = env_path.read_text().splitlines()
|
||||
# Read as UTF-8 (BOM-tolerant), matching the canonical .env readers in
|
||||
# hermes_cli/config.py. read_text() with no encoding falls back to the
|
||||
# system locale (cp1252/GBK on Windows): it mangles or crashes on
|
||||
# non-ASCII values while copying existing lines through, and a BOM'd
|
||||
# first line would fail the key match and get duplicated.
|
||||
existing_lines = env_path.read_text(
|
||||
encoding="utf-8-sig"
|
||||
).splitlines()
|
||||
|
||||
updated_keys: set[str] = set()
|
||||
new_lines: list[str] = []
|
||||
@@ -208,7 +215,7 @@ def _write_env(env_path: Path, env_writes: dict[str, str]) -> None:
|
||||
if k not in updated_keys:
|
||||
new_lines.append(f"{k}={v}")
|
||||
|
||||
env_path.write_text("\n".join(new_lines) + "\n")
|
||||
env_path.write_text("\n".join(new_lines) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def _save_mem0_json(hermes_home: str, data: dict) -> None:
|
||||
@@ -221,7 +228,7 @@ def _save_mem0_json(hermes_home: str, data: dict) -> None:
|
||||
except Exception:
|
||||
pass
|
||||
existing.update(data)
|
||||
config_path.write_text(json.dumps(existing, indent=2) + "\n")
|
||||
config_path.write_text(json.dumps(existing, indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def _setup_platform(hermes_home: str, config: dict, flags: dict[str, str]) -> None:
|
||||
@@ -241,7 +248,7 @@ def _setup_platform(hermes_home: str, config: dict, flags: dict[str, str]) -> No
|
||||
config_path = Path(hermes_home) / "mem0.json"
|
||||
if config_path.exists():
|
||||
try:
|
||||
existing_config = json.loads(config_path.read_text())
|
||||
existing_config = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -362,7 +369,7 @@ def _setup_selfhosted(hermes_home: str, config: dict, flags: dict[str, str]) ->
|
||||
config_path = Path(hermes_home) / "mem0.json"
|
||||
if config_path.exists():
|
||||
try:
|
||||
existing_config = json.loads(config_path.read_text())
|
||||
existing_config = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -493,7 +500,12 @@ def _prompt_api_key(label: str, env_var: str, hermes_home: str) -> str:
|
||||
if not existing:
|
||||
env_path = Path(hermes_home) / ".env"
|
||||
if env_path.exists():
|
||||
for line in env_path.read_text().splitlines():
|
||||
# BOM-tolerant read matching the canonical .env readers in
|
||||
# hermes_cli/config.py; a Notepad BOM on the first line would
|
||||
# otherwise defeat the startswith() key match below.
|
||||
for line in env_path.read_text(
|
||||
encoding="utf-8-sig", errors="replace"
|
||||
).splitlines():
|
||||
if line.startswith(f"{env_var}="):
|
||||
existing = line.split("=", 1)[1].strip()
|
||||
break
|
||||
|
||||
@@ -373,6 +373,34 @@ FOOTGUNS: list[Footgun] = [
|
||||
and _is_likely_subprocess_call(line)
|
||||
),
|
||||
),
|
||||
Footgun(
|
||||
name="bare Path.read_text()/write_text() without encoding=",
|
||||
# Match ``.read_text(`` / ``.write_text(`` when the same line does
|
||||
# not pass ``encoding=``. Multi-line calls where encoding= sits on
|
||||
# a later line are handled by the post_filter's lookahead-free
|
||||
# heuristic accepting a small false-negative rate — the AST guard
|
||||
# test in tests/gateway/test_gateway_utf8_encoding.py catches the
|
||||
# gateway/adapters exactly, and this rule catches the common
|
||||
# single-line form everywhere else.
|
||||
pattern=re.compile(r"\.(read_text|write_text)\s*\("),
|
||||
message=(
|
||||
"Path.read_text()/write_text() without encoding= uses "
|
||||
"locale.getpreferredencoding() — cp936/cp1252 on Windows — "
|
||||
"so UTF-8 content (config JSON, session state, skills) "
|
||||
"crashes with UnicodeDecodeError or writes mojibake. "
|
||||
"See issue #37423 and the #71014 / read_text campaign."
|
||||
),
|
||||
fix='path.read_text(encoding="utf-8") / path.write_text(data, encoding="utf-8")',
|
||||
post_filter=lambda m, line: (
|
||||
"encoding=" not in line
|
||||
and "encoding =" not in line
|
||||
and not _looks_like_string_literal(line, m)
|
||||
# Skip calls that continue onto the next line — the closing
|
||||
# paren isn't on this line, so encoding= may follow. AST-level
|
||||
# enforcement for those lives in the gateway guard test.
|
||||
and line.rstrip().endswith(")")
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -181,7 +181,7 @@ def main() -> int:
|
||||
if any(skip.rstrip("/") in parts for skip in SKIP_DIRS):
|
||||
continue
|
||||
|
||||
content = py_file.read_text()
|
||||
content = py_file.read_text(encoding="utf-8")
|
||||
violations = find_subprocess_calls(content, rel)
|
||||
all_violations.extend(violations)
|
||||
|
||||
@@ -205,7 +205,7 @@ def main() -> int:
|
||||
continue
|
||||
|
||||
try:
|
||||
content = py_file.read_text()
|
||||
content = py_file.read_text(encoding="utf-8")
|
||||
except Exception:
|
||||
continue
|
||||
violations = find_subprocess_calls(content, rel)
|
||||
|
||||
@@ -419,7 +419,7 @@ def main() -> int:
|
||||
pending_jobs=pending,
|
||||
)
|
||||
|
||||
args.output.write_text(body)
|
||||
args.output.write_text(body, encoding="utf-8")
|
||||
print(f"Wrote {len(body)} chars to {args.output}")
|
||||
return 0
|
||||
|
||||
|
||||
+17
-1
@@ -2435,7 +2435,23 @@ You are Hermes Agent, an intelligent AI assistant created by Nous Research. You
|
||||
$pythonExe = "$InstallDir\venv\Scripts\python.exe"
|
||||
if (Test-Path $pythonExe) {
|
||||
try {
|
||||
& $pythonExe "$InstallDir\tools\skills_sync.py" 2>$null
|
||||
# Force the child python.exe to emit UTF-8 on its stdout/stderr.
|
||||
# On non-UTF-8 Windows locales (CP936/GBK zh-CN) Python defaults
|
||||
# its stream encoding to the active codepage and crashes on glyphs
|
||||
# like the checkmark (U+2713) that the codepage can't encode; the
|
||||
# resulting non-UTF-8 bytes break this script's JSON result frame on
|
||||
# stdout and abort the config-templates stage. Scope to this call
|
||||
# only. (Comment kept ASCII per this file's PS 5.1 contract above.)
|
||||
$prevPythonioencoding = $env:PYTHONIOENCODING
|
||||
$prevPythonutf8 = $env:PYTHONUTF8
|
||||
$env:PYTHONIOENCODING = "utf-8"
|
||||
$env:PYTHONUTF8 = "1"
|
||||
try {
|
||||
& $pythonExe "$InstallDir\tools\skills_sync.py" 2>$null
|
||||
} finally {
|
||||
$env:PYTHONIOENCODING = $prevPythonioencoding
|
||||
$env:PYTHONUTF8 = $prevPythonutf8
|
||||
}
|
||||
Write-Success "Skills synced to $HermesHome\skills"
|
||||
} catch {
|
||||
# Fallback: simple directory copy
|
||||
|
||||
@@ -30,7 +30,7 @@ def _load_json(path: Path | None) -> list[dict]:
|
||||
if path is None or not path.exists() or path.stat().st_size == 0:
|
||||
return []
|
||||
try:
|
||||
data = json.loads(path.read_text())
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError as exc:
|
||||
print(f"warning: could not parse {path}: {exc}", file=sys.stderr)
|
||||
return []
|
||||
@@ -197,7 +197,7 @@ def main() -> int:
|
||||
|
||||
summary = "\n".join(buf)
|
||||
if args.output:
|
||||
args.output.write_text(summary)
|
||||
args.output.write_text(summary, encoding="utf-8")
|
||||
else:
|
||||
print(summary)
|
||||
return 0
|
||||
|
||||
@@ -516,7 +516,7 @@ def main() -> int:
|
||||
if not path.exists():
|
||||
print(f"\n⚠ no baseline at {path} — run with --save {args.compare} first")
|
||||
else:
|
||||
before = json.loads(path.read_text())
|
||||
before = json.loads(path.read_text(encoding="utf-8"))
|
||||
print(f"\n═══ A/B diff vs /tmp/perf-{args.compare}.json ═══")
|
||||
print(format_diff(before, metrics))
|
||||
|
||||
|
||||
+5
-5
@@ -2138,7 +2138,7 @@ def next_available_tag(base_tag: str) -> tuple[str, str]:
|
||||
|
||||
def get_current_version():
|
||||
"""Read current semver from __init__.py."""
|
||||
content = VERSION_FILE.read_text()
|
||||
content = VERSION_FILE.read_text(encoding="utf-8")
|
||||
match = re.search(r'__version__\s*=\s*"([^"]+)"', content)
|
||||
return match.group(1) if match else "0.0.0"
|
||||
|
||||
@@ -2168,7 +2168,7 @@ def bump_version(current: str, part: str) -> str:
|
||||
def update_version_files(semver: str, calver_date: str):
|
||||
"""Update version strings in source files."""
|
||||
# Update __init__.py
|
||||
content = VERSION_FILE.read_text()
|
||||
content = VERSION_FILE.read_text(encoding="utf-8")
|
||||
content = re.sub(
|
||||
r'__version__\s*=\s*"[^"]+"',
|
||||
f'__version__ = "{semver}"',
|
||||
@@ -2179,17 +2179,17 @@ def update_version_files(semver: str, calver_date: str):
|
||||
f'__release_date__ = "{calver_date}"',
|
||||
content,
|
||||
)
|
||||
VERSION_FILE.write_text(content)
|
||||
VERSION_FILE.write_text(content, encoding="utf-8")
|
||||
|
||||
# Update pyproject.toml
|
||||
pyproject = PYPROJECT_FILE.read_text()
|
||||
pyproject = PYPROJECT_FILE.read_text(encoding="utf-8")
|
||||
pyproject = re.sub(
|
||||
r'^version\s*=\s*"[^"]+"',
|
||||
f'version = "{semver}"',
|
||||
pyproject,
|
||||
flags=re.MULTILINE,
|
||||
)
|
||||
PYPROJECT_FILE.write_text(pyproject)
|
||||
PYPROJECT_FILE.write_text(pyproject, encoding="utf-8")
|
||||
|
||||
# Keep the desktop Electron app's package.json version in lockstep with the
|
||||
# Python package version. The desktop About panel reads the live Hermes
|
||||
|
||||
@@ -533,7 +533,7 @@ def _load_durations(repo_root: Path) -> dict[str, float]:
|
||||
if not path.is_file():
|
||||
return {}
|
||||
try:
|
||||
return json.loads(path.read_text())
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
print("[ERROR] Failed to load json durations file! {e}")
|
||||
return {}
|
||||
@@ -555,7 +555,7 @@ def _save_durations(
|
||||
key = _format_file(f, repo_root)
|
||||
data[key] = round(t, 3)
|
||||
path = repo_root / _DURATIONS_FILE
|
||||
path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n")
|
||||
path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def _compute_lpt_slices(
|
||||
|
||||
@@ -158,7 +158,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||
sources: dict[str, str] = {}
|
||||
if args.models_from_file:
|
||||
try:
|
||||
sources = json.loads(Path(args.models_from_file).read_text())
|
||||
sources = json.loads(Path(args.models_from_file).read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as e:
|
||||
log(f"Could not read --models-from-file: {e}")
|
||||
|
||||
|
||||
@@ -303,7 +303,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||
out = json.dumps(schema, indent=2, default=str)
|
||||
|
||||
if args.output:
|
||||
Path(args.output).write_text(out)
|
||||
Path(args.output).write_text(out, encoding="utf-8")
|
||||
print(f"Schema written to {args.output}", file=sys.stderr)
|
||||
else:
|
||||
print(out)
|
||||
|
||||
@@ -620,7 +620,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||
args_str = args.args
|
||||
if args_str.startswith("@"):
|
||||
try:
|
||||
args_str = Path(args_str[1:]).read_text()
|
||||
args_str = Path(args_str[1:]).read_text(encoding="utf-8")
|
||||
except OSError as e:
|
||||
emit_json({"error": f"Cannot read args file: {e}"})
|
||||
return 1
|
||||
|
||||
@@ -22,17 +22,17 @@ sys.path.insert(0, str(SCRIPTS))
|
||||
|
||||
@pytest.fixture
|
||||
def sd15_workflow() -> dict:
|
||||
return json.loads((WORKFLOWS / "sd15_txt2img.json").read_text())
|
||||
return json.loads((WORKFLOWS / "sd15_txt2img.json").read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def flux_workflow() -> dict:
|
||||
return json.loads((WORKFLOWS / "flux_dev_txt2img.json").read_text())
|
||||
return json.loads((WORKFLOWS / "flux_dev_txt2img.json").read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def video_workflow() -> dict:
|
||||
return json.loads((WORKFLOWS / "wan_video_t2v.json").read_text())
|
||||
return json.loads((WORKFLOWS / "wan_video_t2v.json").read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
@@ -436,7 +436,7 @@ class TestVideoWorkflow:
|
||||
|
||||
def test_animatediff_workflow(self, workflows_dir):
|
||||
import json
|
||||
wf = json.loads((workflows_dir / "animatediff_video.json").read_text())
|
||||
wf = json.loads((workflows_dir / "animatediff_video.json").read_text(encoding="utf-8"))
|
||||
assert looks_like_video_workflow(wf) is True
|
||||
|
||||
def test_wan_workflow(self, video_workflow):
|
||||
|
||||
@@ -16,20 +16,20 @@ from run_workflow import (
|
||||
class TestParseInputImageArg:
|
||||
def test_with_name(self, tmp_path):
|
||||
f = tmp_path / "x.png"
|
||||
f.write_text("x")
|
||||
f.write_text("x", encoding="utf-8")
|
||||
n, p = parse_input_image_arg(f"image={f}")
|
||||
assert n == "image"
|
||||
assert p == f
|
||||
|
||||
def test_without_name_defaults(self, tmp_path):
|
||||
f = tmp_path / "x.png"
|
||||
f.write_text("x")
|
||||
f.write_text("x", encoding="utf-8")
|
||||
n, p = parse_input_image_arg(str(f))
|
||||
assert n == "image"
|
||||
|
||||
def test_custom_name(self, tmp_path):
|
||||
f = tmp_path / "x.png"
|
||||
f.write_text("x")
|
||||
f.write_text("x", encoding="utf-8")
|
||||
n, p = parse_input_image_arg(f"mask_image={f}")
|
||||
assert n == "mask_image"
|
||||
|
||||
|
||||
@@ -92,7 +92,7 @@ def _setup_libreoffice_macro() -> bool:
|
||||
macro_dir = Path(MACRO_DIR)
|
||||
macro_file = macro_dir / "Module1.xba"
|
||||
|
||||
if macro_file.exists() and "AcceptAllTrackedChanges" in macro_file.read_text():
|
||||
if macro_file.exists() and "AcceptAllTrackedChanges" in macro_file.read_text(encoding="utf-8"):
|
||||
return True
|
||||
|
||||
if not macro_dir.exists():
|
||||
@@ -111,7 +111,7 @@ def _setup_libreoffice_macro() -> bool:
|
||||
macro_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
macro_file.write_text(ACCEPT_CHANGES_MACRO)
|
||||
macro_file.write_text(ACCEPT_CHANGES_MACRO, encoding="utf-8")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to setup LibreOffice macro: {e}")
|
||||
|
||||
@@ -64,7 +64,7 @@ def _ensure_shim() -> Path:
|
||||
return _SHIM_SO
|
||||
|
||||
src = Path(tempfile.gettempdir()) / "lo_socket_shim.c"
|
||||
src.write_text(_SHIM_SOURCE)
|
||||
src.write_text(_SHIM_SOURCE, encoding="utf-8")
|
||||
subprocess.run(
|
||||
["gcc", "-shared", "-fPIC", "-o", str(_SHIM_SO), str(src), "-ldl"],
|
||||
check=True,
|
||||
|
||||
@@ -783,7 +783,7 @@ class BaseSchemaValidator:
|
||||
try:
|
||||
schema = _load_schema(str(schema_path))
|
||||
|
||||
with open(xml_file, "r") as f:
|
||||
with open(xml_file, "rb") as f:
|
||||
xml_doc = lxml.etree.parse(f)
|
||||
|
||||
xml_doc, _ = self._remove_template_tags_from_text_nodes(xml_doc)
|
||||
|
||||
@@ -70,7 +70,7 @@ def _ensure_authenticated():
|
||||
|
||||
def _stored_token_scopes() -> list[str]:
|
||||
try:
|
||||
data = json.loads(TOKEN_PATH.read_text())
|
||||
data = json.loads(TOKEN_PATH.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return list(SCOPES)
|
||||
scopes = data.get("scopes")
|
||||
@@ -192,7 +192,7 @@ def get_credentials():
|
||||
json.dumps(
|
||||
_normalize_authorized_user_payload(json.loads(creds.to_json())),
|
||||
indent=2,
|
||||
)
|
||||
), encoding="utf-8"
|
||||
)
|
||||
if not creds.valid:
|
||||
print("Token is invalid. Re-run setup.", file=sys.stderr)
|
||||
|
||||
@@ -69,7 +69,7 @@ def refresh_token(token_data: dict) -> dict:
|
||||
).isoformat()
|
||||
|
||||
get_token_path().write_text(
|
||||
json.dumps(_normalize_authorized_user_payload(token_data), indent=2)
|
||||
json.dumps(_normalize_authorized_user_payload(token_data), indent=2), encoding="utf-8"
|
||||
)
|
||||
return token_data
|
||||
|
||||
@@ -81,7 +81,7 @@ def get_valid_token() -> str:
|
||||
print("ERROR: No Google token found. Run setup.py --auth-url first.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
token_data = json.loads(token_path.read_text())
|
||||
token_data = json.loads(token_path.read_text(encoding="utf-8"))
|
||||
|
||||
expiry = token_data.get("expiry", "")
|
||||
if expiry:
|
||||
|
||||
@@ -71,7 +71,7 @@ def _normalize_authorized_user_payload(payload: dict) -> dict:
|
||||
|
||||
def _load_token_payload(path: Path = TOKEN_PATH) -> dict:
|
||||
try:
|
||||
return json.loads(path.read_text())
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
@@ -220,7 +220,7 @@ def check_auth(quiet: bool = False):
|
||||
json.dumps(
|
||||
_normalize_authorized_user_payload(json.loads(creds.to_json())),
|
||||
indent=2,
|
||||
)
|
||||
), encoding="utf-8"
|
||||
)
|
||||
missing_scopes = _missing_scopes_from_payload(_load_token_payload(TOKEN_PATH))
|
||||
if missing_scopes:
|
||||
@@ -260,7 +260,7 @@ def store_client_secret(path: str):
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
data = json.loads(src.read_text())
|
||||
data = json.loads(src.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError:
|
||||
print("ERROR: File is not valid JSON.")
|
||||
sys.exit(1)
|
||||
@@ -270,7 +270,7 @@ def store_client_secret(path: str):
|
||||
print("Download the correct file from: https://console.cloud.google.com/apis/credentials")
|
||||
sys.exit(1)
|
||||
|
||||
CLIENT_SECRET_PATH.write_text(json.dumps(data, indent=2))
|
||||
CLIENT_SECRET_PATH.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
||||
print(f"OK: Client secret saved to {CLIENT_SECRET_PATH}")
|
||||
|
||||
|
||||
@@ -284,7 +284,7 @@ def _save_pending_auth(*, state: str, code_verifier: str):
|
||||
"redirect_uri": REDIRECT_URI,
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
), encoding="utf-8"
|
||||
)
|
||||
|
||||
|
||||
@@ -295,7 +295,7 @@ def _load_pending_auth() -> dict:
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
data = json.loads(PENDING_AUTH_PATH.read_text())
|
||||
data = json.loads(PENDING_AUTH_PATH.read_text(encoding="utf-8"))
|
||||
except Exception as e:
|
||||
print(f"ERROR: Could not read pending OAuth session: {e}")
|
||||
print("Run --auth-url again to start a fresh OAuth session.")
|
||||
@@ -410,7 +410,7 @@ def exchange_auth_code(code: str):
|
||||
print(f"WARNING: Token missing some Google Workspace scopes: {', '.join(missing_scopes)}")
|
||||
print("Some services may not be available.")
|
||||
|
||||
TOKEN_PATH.write_text(json.dumps(token_payload, indent=2))
|
||||
TOKEN_PATH.write_text(json.dumps(token_payload, indent=2), encoding="utf-8")
|
||||
PENDING_AUTH_PATH.unlink(missing_ok=True)
|
||||
print(f"OK: Authenticated. Token saved to {TOKEN_PATH}")
|
||||
print(f"Profile-scoped token location: {display_hermes_home()}/google_token.json")
|
||||
|
||||
@@ -59,7 +59,7 @@ if __name__ == "__main__":
|
||||
if len(sys.argv) != 2:
|
||||
print("Usage: check_bounding_boxes.py [fields.json]")
|
||||
sys.exit(1)
|
||||
with open(sys.argv[1]) as f:
|
||||
with open(sys.argv[1], encoding="utf-8") as f:
|
||||
messages = get_bounding_box_messages(f)
|
||||
for msg in messages:
|
||||
print(msg)
|
||||
|
||||
@@ -7,7 +7,7 @@ from PIL import Image, ImageDraw
|
||||
|
||||
|
||||
def create_validation_image(page_number, fields_json_path, input_path, output_path):
|
||||
with open(fields_json_path, 'r') as f:
|
||||
with open(fields_json_path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
img = Image.open(input_path)
|
||||
|
||||
@@ -9,7 +9,7 @@ from extract_form_field_info import get_field_info
|
||||
|
||||
|
||||
def fill_pdf_fields(input_pdf_path: str, fields_json_path: str, output_pdf_path: str):
|
||||
with open(fields_json_path) as f:
|
||||
with open(fields_json_path, encoding="utf-8") as f:
|
||||
fields = json.load(f)
|
||||
fields_by_page = {}
|
||||
for field in fields:
|
||||
|
||||
@@ -32,7 +32,7 @@ def transform_from_pdf_coords(bbox, pdf_height):
|
||||
|
||||
def fill_pdf_form(input_pdf_path, fields_json_path, output_pdf_path):
|
||||
|
||||
with open(fields_json_path, "r") as f:
|
||||
with open(fields_json_path, "r", encoding="utf-8") as f:
|
||||
fields_data = json.load(f)
|
||||
|
||||
reader = PdfReader(input_pdf_path)
|
||||
|
||||
@@ -64,7 +64,7 @@ def _ensure_shim() -> Path:
|
||||
return _SHIM_SO
|
||||
|
||||
src = Path(tempfile.gettempdir()) / "lo_socket_shim.c"
|
||||
src.write_text(_SHIM_SOURCE)
|
||||
src.write_text(_SHIM_SOURCE, encoding="utf-8")
|
||||
subprocess.run(
|
||||
["gcc", "-shared", "-fPIC", "-o", str(_SHIM_SO), str(src), "-ldl"],
|
||||
check=True,
|
||||
|
||||
@@ -783,7 +783,7 @@ class BaseSchemaValidator:
|
||||
try:
|
||||
schema = _load_schema(str(schema_path))
|
||||
|
||||
with open(xml_file, "r") as f:
|
||||
with open(xml_file, "rb") as f:
|
||||
xml_doc = lxml.etree.parse(f)
|
||||
|
||||
xml_doc, _ = self._remove_template_tags_from_text_nodes(xml_doc)
|
||||
|
||||
@@ -64,7 +64,7 @@ def _ensure_shim() -> Path:
|
||||
return _SHIM_SO
|
||||
|
||||
src = Path(tempfile.gettempdir()) / "lo_socket_shim.c"
|
||||
src.write_text(_SHIM_SOURCE)
|
||||
src.write_text(_SHIM_SOURCE, encoding="utf-8")
|
||||
subprocess.run(
|
||||
["gcc", "-shared", "-fPIC", "-o", str(_SHIM_SO), str(src), "-ldl"],
|
||||
check=True,
|
||||
|
||||
@@ -71,7 +71,7 @@ def setup_libreoffice_macro(profile_dir: Path, timeout=30):
|
||||
return None, "LibreOffice did not create a usable profile; formulas were NOT recalculated"
|
||||
|
||||
try:
|
||||
(macro_dir / MACRO_FILENAME).write_text(RECALCULATE_MACRO)
|
||||
(macro_dir / MACRO_FILENAME).write_text(RECALCULATE_MACRO, encoding="utf-8")
|
||||
except OSError as e:
|
||||
return None, f"Could not install the recalculation macro: {e}"
|
||||
|
||||
|
||||
@@ -203,6 +203,84 @@ class CopilotACPClientSafetyTests(unittest.TestCase):
|
||||
self.assertNotIn("abc123def456", content)
|
||||
self.assertIn("OPENAI_API_KEY=", content)
|
||||
|
||||
def test_fs_read_text_file_decodes_as_utf8_under_non_utf8_locale(self) -> None:
|
||||
"""Regression for #18637 (bug 2): fs/read_text_file used
|
||||
``path.read_text()`` with no explicit encoding, so on Windows
|
||||
GBK/CP932/CP949 locales the Copilot read_file tool crashed on any
|
||||
source file with non-ASCII content (e.g. a CJK comment, an em dash,
|
||||
or UTF-8 BOM)."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
root = Path(tmpdir)
|
||||
target = root / "note.md"
|
||||
target.write_text("# 中文标题\nem dash — here\n", encoding="utf-8")
|
||||
|
||||
original_read_text = Path.read_text
|
||||
|
||||
def strict_read_text(self, encoding=None, errors=None, **kwargs):
|
||||
if self == target and encoding != "utf-8":
|
||||
raise UnicodeDecodeError(
|
||||
"gbk", b"\x94", 0, 1, "illegal multibyte sequence"
|
||||
)
|
||||
return original_read_text(
|
||||
self, encoding=encoding, errors=errors, **kwargs
|
||||
)
|
||||
|
||||
with patch.object(Path, "read_text", strict_read_text):
|
||||
response = self._dispatch(
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 10,
|
||||
"method": "fs/read_text_file",
|
||||
"params": {"path": str(target)},
|
||||
},
|
||||
cwd=str(root),
|
||||
)
|
||||
|
||||
self.assertNotIn("error", response)
|
||||
content = ((response.get("result") or {}).get("content") or "")
|
||||
self.assertIn("中文标题", content)
|
||||
self.assertIn("em dash —", content)
|
||||
|
||||
def test_fs_write_text_file_encodes_as_utf8(self) -> None:
|
||||
"""Regression for #18637 (bug 2): fs/write_text_file used
|
||||
``path.write_text()`` with no explicit encoding, so on non-UTF-8
|
||||
locales the Copilot write tool could not emit code/config files
|
||||
containing any char outside the platform codec."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
root = Path(tmpdir)
|
||||
target = root / "out.md"
|
||||
payload = "# 中文标题\nem dash — here\n"
|
||||
|
||||
original_write_text = Path.write_text
|
||||
|
||||
def strict_write_text(
|
||||
self, data, encoding=None, errors=None, **kwargs
|
||||
):
|
||||
if self == target and encoding != "utf-8":
|
||||
raise UnicodeEncodeError(
|
||||
"gbk", data, 0, 1, "illegal multibyte sequence"
|
||||
)
|
||||
return original_write_text(
|
||||
self, data, encoding=encoding, errors=errors, **kwargs
|
||||
)
|
||||
|
||||
with patch.object(Path, "write_text", strict_write_text):
|
||||
response = self._dispatch(
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 11,
|
||||
"method": "fs/write_text_file",
|
||||
"params": {
|
||||
"path": str(target),
|
||||
"content": payload,
|
||||
},
|
||||
},
|
||||
cwd=str(root),
|
||||
)
|
||||
|
||||
self.assertNotIn("error", response)
|
||||
self.assertEqual(target.read_text(encoding="utf-8"), payload)
|
||||
|
||||
def test_write_text_file_reuses_write_denylist(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
home = Path(tmpdir) / "home"
|
||||
|
||||
@@ -389,3 +389,48 @@ class TestIsGenuineNousRateLimit:
|
||||
assert is_genuine_nous_rate_limit(
|
||||
headers=None, last_known_state=None
|
||||
) is False
|
||||
|
||||
|
||||
class TestRateGuardStateEncoding:
|
||||
"""Regression for #18637: the cross-session rate-limit state file was
|
||||
opened without ``encoding="utf-8"`` on both the atomic write (os.fdopen)
|
||||
and the read. On Windows Chinese locales the platform default decoder
|
||||
raises on UTF-8 bytes written by a peer process, silently losing the
|
||||
rate-limit guard and re-enabling the retry amplification the module was
|
||||
built to prevent.
|
||||
"""
|
||||
|
||||
def test_read_uses_utf8_under_non_utf8_locale(self, rate_guard_env, monkeypatch):
|
||||
import builtins
|
||||
|
||||
from agent.nous_rate_guard import nous_rate_limit_remaining, _state_path
|
||||
|
||||
path = _state_path()
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
# State JSON written with a UTF-8 provider label. Python's json
|
||||
# module itself escapes non-ASCII by default, but a peer writer
|
||||
# (or human) using ensure_ascii=False produces raw UTF-8 bytes,
|
||||
# which is what this guards against.
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(
|
||||
'{"reset_at": %d, "provider": "中文", "recorded_at": 0}'
|
||||
% (int(time.time()) + 3600)
|
||||
)
|
||||
|
||||
real_open = builtins.open
|
||||
|
||||
def guarded_open(file, mode="r", *args, **kwargs):
|
||||
try:
|
||||
is_target = str(file) == str(path)
|
||||
except Exception:
|
||||
is_target = False
|
||||
if is_target and "b" not in mode and kwargs.get("encoding") != "utf-8":
|
||||
raise UnicodeDecodeError(
|
||||
"gbk", b"\x94", 0, 1, "illegal multibyte sequence"
|
||||
)
|
||||
return real_open(file, mode, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(builtins, "open", guarded_open)
|
||||
|
||||
remaining = nous_rate_limit_remaining()
|
||||
assert remaining is not None and remaining > 0
|
||||
|
||||
@@ -128,3 +128,64 @@ class TestWorktreeIncludeSecurity:
|
||||
assert (linked_dir / "lib" / "marker.txt").read_text() == "venv marker"
|
||||
finally:
|
||||
_force_remove_worktree(info)
|
||||
|
||||
|
||||
class TestWorktreeIncludeEncoding:
|
||||
"""The include list and .gitignore are UTF-8 files; reading them with the
|
||||
locale default breaks Windows (cp1251/GBK mojibake or UnicodeDecodeError,
|
||||
swallowed at DEBUG so no include is copied), and a Notepad BOM glues to
|
||||
the first line on every platform."""
|
||||
|
||||
def test_bom_in_worktreeinclude_does_not_hide_first_entry(self, git_repo):
|
||||
import cli as cli_mod
|
||||
|
||||
(git_repo / ".env").write_text("SECRET=***\n")
|
||||
# Notepad-style UTF-8 with BOM; the BOM must not become part of the
|
||||
# first entry's path.
|
||||
(git_repo / ".worktreeinclude").write_bytes(".env\n".encode("utf-8"))
|
||||
|
||||
info = None
|
||||
try:
|
||||
info = cli_mod._setup_worktree(str(git_repo))
|
||||
assert info is not None
|
||||
assert (Path(info["path"]) / ".env").exists()
|
||||
finally:
|
||||
_force_remove_worktree(info)
|
||||
|
||||
def test_non_ascii_worktreeinclude_entry_copied(self, git_repo):
|
||||
import cli as cli_mod
|
||||
|
||||
secret = git_repo / "секреты.env"
|
||||
secret.write_text("SECRET=***\n", encoding="utf-8")
|
||||
(git_repo / ".worktreeinclude").write_bytes(
|
||||
"# ключи агента\nсекреты.env\n".encode("utf-8")
|
||||
)
|
||||
|
||||
info = None
|
||||
try:
|
||||
info = cli_mod._setup_worktree(str(git_repo))
|
||||
assert info is not None
|
||||
assert (Path(info["path"]) / "секреты.env").exists()
|
||||
finally:
|
||||
_force_remove_worktree(info)
|
||||
|
||||
def test_bom_in_gitignore_does_not_duplicate_worktrees_entry(self, git_repo):
|
||||
import cli as cli_mod
|
||||
|
||||
(git_repo / ".gitignore").write_bytes(".worktrees/\n".encode("utf-8"))
|
||||
|
||||
info = None
|
||||
try:
|
||||
info = cli_mod._setup_worktree(str(git_repo))
|
||||
assert info is not None
|
||||
lines = (
|
||||
(git_repo / ".gitignore")
|
||||
.read_text(encoding="utf-8-sig")
|
||||
.splitlines()
|
||||
)
|
||||
assert lines.count(".worktrees/") == 1, (
|
||||
"BOM glued to the first line must not defeat the membership "
|
||||
f"check and duplicate the entry: {lines!r}"
|
||||
)
|
||||
finally:
|
||||
_force_remove_worktree(info)
|
||||
|
||||
@@ -78,6 +78,7 @@ def test_gated_status_is_public(gated_app):
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", [
|
||||
"/api/health",
|
||||
"/api/config/defaults",
|
||||
"/api/config/schema",
|
||||
"/api/model/info",
|
||||
|
||||
@@ -67,6 +67,22 @@ def test_status_reports_auth_required_in_gated_mode(gated_client):
|
||||
assert body["auth_providers"] == ["stub"]
|
||||
|
||||
|
||||
def test_health_reports_liveness_without_loading_gateway_config(gated_client, monkeypatch):
|
||||
def _boom():
|
||||
raise AssertionError("health must not load gateway config")
|
||||
|
||||
monkeypatch.setattr("gateway.config.load_gateway_config", _boom)
|
||||
|
||||
r = gated_client.get("/api/health")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body == {
|
||||
"ok": True,
|
||||
"version": web_server.__version__,
|
||||
"auth_required": True,
|
||||
}
|
||||
|
||||
|
||||
def test_status_reports_auth_disabled_in_loopback_mode(loopback_client):
|
||||
r = loopback_client.get("/api/status")
|
||||
assert r.status_code == 200
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
"""Behavior tests for the Windows desktop-exe integrity gate (#69179).
|
||||
|
||||
The desktop self-update chain (Desktop → hermes-setup --update →
|
||||
``hermes update`` → ``hermes desktop --build-only`` → relaunch) rebuilds
|
||||
Hermes.exe on the end user's machine. Before this gate, "build succeeded" was
|
||||
just "the file exists", so a truncated PE (corrupt cached Electron zip), a
|
||||
non-PE file, or a wrong-architecture tree shipped as the new app — Windows
|
||||
then refuses to launch it with "This app can't run on your computer"
|
||||
(此应用无法在你的电脑上运行) and the user has no working install left.
|
||||
|
||||
These tests exercise the behavior contract only: synthetic PE files go in,
|
||||
verdicts/rollbacks come out.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli import main as cli_main
|
||||
|
||||
PE_AMD64 = 0x8664
|
||||
PE_ARM64 = 0xAA64
|
||||
PE_I386 = 0x014C
|
||||
|
||||
|
||||
def make_pe(path: Path, machine: int = PE_AMD64, *, truncate_to: int | None = None) -> Path:
|
||||
"""Write a minimal, structurally-complete PE file with one section.
|
||||
|
||||
Layout: DOS header (e_lfanew=0x80) → PE signature + COFF header at 0x80 →
|
||||
one 40-byte section entry whose raw data spans [0x200, 0x400). Total file
|
||||
size 0x400 unless ``truncate_to`` cuts it short (the corrupt-download
|
||||
shape).
|
||||
"""
|
||||
buf = bytearray(0x400)
|
||||
buf[0:2] = b"MZ"
|
||||
struct.pack_into("<I", buf, 0x3C, 0x80)
|
||||
buf[0x80:0x84] = b"PE\x00\x00"
|
||||
# COFF: machine, nsections=1, timestamp, symtab ptr, nsyms, opt-hdr size=0, chars
|
||||
struct.pack_into("<HHIIIHH", buf, 0x84, machine, 1, 0, 0, 0, 0, 0x0002)
|
||||
# Section table entry at 0x80 + 24 = 0x98; SizeOfRawData @+16, PointerToRawData @+20
|
||||
section_off = 0x98
|
||||
struct.pack_into("<II", buf, section_off + 16, 0x200, 0x200)
|
||||
data = bytes(buf if truncate_to is None else buf[:truncate_to])
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(data)
|
||||
return path
|
||||
|
||||
|
||||
# ─── _parse_pe_machine ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_parse_pe_machine_reads_machine_field(tmp_path):
|
||||
exe = make_pe(tmp_path / "Hermes.exe", PE_AMD64)
|
||||
assert cli_main._parse_pe_machine(exe) == PE_AMD64
|
||||
|
||||
exe_arm = make_pe(tmp_path / "arm.exe", PE_ARM64)
|
||||
assert cli_main._parse_pe_machine(exe_arm) == PE_ARM64
|
||||
|
||||
|
||||
def test_parse_pe_machine_rejects_non_pe_file(tmp_path):
|
||||
"""An HTML error page saved as .exe — the classic corrupted-download body."""
|
||||
fake = tmp_path / "Hermes.exe"
|
||||
fake.write_bytes(b"<html><body>404 Not Found</body></html>" + b" " * 600)
|
||||
with pytest.raises(ValueError, match="MZ"):
|
||||
cli_main._parse_pe_machine(fake)
|
||||
|
||||
|
||||
def test_parse_pe_machine_rejects_tiny_file(tmp_path):
|
||||
stub = tmp_path / "Hermes.exe"
|
||||
stub.write_bytes(b"MZ")
|
||||
with pytest.raises(ValueError, match="too small"):
|
||||
cli_main._parse_pe_machine(stub)
|
||||
|
||||
|
||||
def test_parse_pe_machine_rejects_truncated_sections(tmp_path):
|
||||
"""File cut mid-download: headers parse but section data extends past EOF."""
|
||||
exe = make_pe(tmp_path / "Hermes.exe", PE_AMD64, truncate_to=0x300)
|
||||
with pytest.raises(ValueError, match="truncated"):
|
||||
cli_main._parse_pe_machine(exe)
|
||||
|
||||
|
||||
def test_parse_pe_machine_rejects_bad_pe_signature(tmp_path):
|
||||
exe = make_pe(tmp_path / "Hermes.exe", PE_AMD64)
|
||||
data = bytearray(exe.read_bytes())
|
||||
data[0x80:0x84] = b"XX\x00\x00"
|
||||
exe.write_bytes(bytes(data))
|
||||
with pytest.raises(ValueError, match="PE signature"):
|
||||
cli_main._parse_pe_machine(exe)
|
||||
|
||||
|
||||
# ─── _expected_windows_pe_machines ──────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("host", "loadable", "not_loadable"),
|
||||
[
|
||||
("AMD64", {PE_AMD64, PE_I386}, {PE_ARM64}),
|
||||
("ARM64", {PE_ARM64, PE_AMD64}, {PE_I386}),
|
||||
("x86", {PE_I386}, {PE_AMD64, PE_ARM64}),
|
||||
],
|
||||
)
|
||||
def test_expected_machines_per_host(host, loadable, not_loadable):
|
||||
with patch("platform.machine", return_value=host):
|
||||
expected = cli_main._expected_windows_pe_machines()
|
||||
assert loadable <= expected
|
||||
assert not (not_loadable & expected)
|
||||
|
||||
|
||||
def test_expected_machines_unknown_host_is_permissive():
|
||||
"""The gate must never brick launch on hosts we don't recognize."""
|
||||
with patch("platform.machine", return_value="RISCV64"):
|
||||
expected = cli_main._expected_windows_pe_machines()
|
||||
assert {PE_AMD64, PE_ARM64, PE_I386} <= expected
|
||||
|
||||
|
||||
# ─── _desktop_exe_integrity_error ───────────────────────────────────────────
|
||||
|
||||
|
||||
def test_integrity_error_none_for_matching_arch(tmp_path):
|
||||
exe = make_pe(tmp_path / "Hermes.exe", PE_AMD64)
|
||||
with patch("platform.machine", return_value="AMD64"):
|
||||
assert cli_main._desktop_exe_integrity_error(exe) is None
|
||||
|
||||
|
||||
def test_integrity_error_reports_arch_mismatch(tmp_path):
|
||||
"""ARM64 exe on the reporter's 'Windows 10 AMD64' host — the wrong-arch
|
||||
flavor of 'This app can't run on your computer'."""
|
||||
exe = make_pe(tmp_path / "Hermes.exe", PE_ARM64)
|
||||
with patch("platform.machine", return_value="AMD64"):
|
||||
error = cli_main._desktop_exe_integrity_error(exe)
|
||||
assert error is not None and "architecture mismatch" in error
|
||||
assert "ARM64" in error
|
||||
|
||||
|
||||
def test_integrity_error_reports_corruption_reason(tmp_path):
|
||||
exe = make_pe(tmp_path / "Hermes.exe", PE_AMD64, truncate_to=0x300)
|
||||
error = cli_main._desktop_exe_integrity_error(exe)
|
||||
assert error is not None and "truncated" in error
|
||||
|
||||
|
||||
# ─── _desktop_packaged_executable arch preference (win32) ───────────────────
|
||||
|
||||
|
||||
def test_packaged_executable_prefers_host_arch_over_mtime(tmp_path, monkeypatch):
|
||||
"""A newer wrong-arch tree must not shadow the loadable one (#69179)."""
|
||||
monkeypatch.setattr(cli_main.sys, "platform", "win32")
|
||||
desktop_dir = tmp_path / "apps" / "desktop"
|
||||
good = make_pe(desktop_dir / "release" / "win-unpacked" / "Hermes.exe", PE_AMD64)
|
||||
bad = make_pe(desktop_dir / "release" / "win-arm64-unpacked" / "Hermes.exe", PE_ARM64)
|
||||
# Make the wrong-arch tree the newest, which the pure-mtime pick would take.
|
||||
import os
|
||||
|
||||
os.utime(bad, (bad.stat().st_atime + 1000, bad.stat().st_mtime + 1000))
|
||||
|
||||
with patch("platform.machine", return_value="AMD64"):
|
||||
assert cli_main._desktop_packaged_executable(desktop_dir) == good
|
||||
|
||||
|
||||
def test_packaged_executable_falls_back_to_mtime_when_unparseable(tmp_path, monkeypatch):
|
||||
"""Non-PE stubs (dev trees, tests) keep the historical newest-wins pick."""
|
||||
monkeypatch.setattr(cli_main.sys, "platform", "win32")
|
||||
desktop_dir = tmp_path / "apps" / "desktop"
|
||||
a = desktop_dir / "release" / "win-unpacked" / "Hermes.exe"
|
||||
b = desktop_dir / "release" / "win-arm64-unpacked" / "Hermes.exe"
|
||||
for p in (a, b):
|
||||
p.parent.mkdir(parents=True)
|
||||
p.write_text("", encoding="utf-8")
|
||||
import os
|
||||
|
||||
os.utime(b, (b.stat().st_atime + 1000, b.stat().st_mtime + 1000))
|
||||
with patch("platform.machine", return_value="AMD64"):
|
||||
assert cli_main._desktop_packaged_executable(desktop_dir) == b
|
||||
|
||||
|
||||
# ─── rollback ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _win_tree(tmp_path: Path) -> tuple[Path, Path]:
|
||||
desktop_dir = tmp_path / "apps" / "desktop"
|
||||
exe = desktop_dir / "release" / "win-unpacked" / "Hermes.exe"
|
||||
return desktop_dir, exe
|
||||
|
||||
|
||||
def test_rollback_restores_backup_and_keeps_corrupt_copy(tmp_path):
|
||||
desktop_dir, exe = _win_tree(tmp_path)
|
||||
make_pe(exe, PE_AMD64, truncate_to=0x300) # corrupt new build
|
||||
backup_exe = desktop_dir / "release" / "win-unpacked.bak" / "Hermes.exe"
|
||||
make_pe(backup_exe, PE_AMD64) # valid old build
|
||||
|
||||
with patch("platform.machine", return_value="AMD64"):
|
||||
restored = cli_main._rollback_desktop_from_backup(exe)
|
||||
|
||||
assert restored == exe
|
||||
# The restored exe is the old, valid build.
|
||||
assert cli_main._parse_pe_machine(exe) == PE_AMD64
|
||||
assert exe.stat().st_size == 0x400
|
||||
# Corrupt tree preserved for diagnostics; backup consumed.
|
||||
assert (desktop_dir / "release" / "win-unpacked.corrupt" / "Hermes.exe").exists()
|
||||
assert not backup_exe.exists()
|
||||
|
||||
|
||||
def test_rollback_returns_none_without_backup(tmp_path):
|
||||
_, exe = _win_tree(tmp_path)
|
||||
make_pe(exe, PE_AMD64, truncate_to=0x300)
|
||||
assert cli_main._rollback_desktop_from_backup(exe) is None
|
||||
# The corrupt tree is left in place (nothing to restore over it).
|
||||
assert exe.exists()
|
||||
|
||||
|
||||
def test_rollback_refuses_corrupt_backup(tmp_path):
|
||||
"""Never 'restore' a backup that would also fail to launch."""
|
||||
desktop_dir, exe = _win_tree(tmp_path)
|
||||
make_pe(exe, PE_AMD64, truncate_to=0x300)
|
||||
backup_exe = desktop_dir / "release" / "win-unpacked.bak" / "Hermes.exe"
|
||||
make_pe(backup_exe, PE_AMD64, truncate_to=0x280) # backup also corrupt
|
||||
|
||||
assert cli_main._rollback_desktop_from_backup(exe) is None
|
||||
assert backup_exe.exists() # untouched
|
||||
|
||||
|
||||
# ─── _ensure_desktop_exe_launchable (the gate) ──────────────────────────────
|
||||
|
||||
|
||||
def test_gate_passes_valid_exe(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(cli_main.sys, "platform", "win32")
|
||||
desktop_dir, exe = _win_tree(tmp_path)
|
||||
make_pe(exe, PE_AMD64)
|
||||
with patch("platform.machine", return_value="AMD64"):
|
||||
verified, rolled_back = cli_main._ensure_desktop_exe_launchable(desktop_dir, exe)
|
||||
assert verified == exe
|
||||
assert rolled_back is False
|
||||
|
||||
|
||||
def test_gate_noop_off_windows(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(cli_main.sys, "platform", "linux")
|
||||
desktop_dir, exe = _win_tree(tmp_path)
|
||||
exe.parent.mkdir(parents=True)
|
||||
exe.write_text("not a pe at all", encoding="utf-8")
|
||||
verified, rolled_back = cli_main._ensure_desktop_exe_launchable(desktop_dir, exe)
|
||||
assert verified == exe
|
||||
assert rolled_back is False
|
||||
|
||||
|
||||
def test_gate_rolls_back_corrupt_exe_and_purges_cache(tmp_path, monkeypatch, capsys):
|
||||
monkeypatch.setattr(cli_main.sys, "platform", "win32")
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home"))
|
||||
desktop_dir, exe = _win_tree(tmp_path)
|
||||
make_pe(exe, PE_AMD64, truncate_to=0x300)
|
||||
make_pe(desktop_dir / "release" / "win-unpacked.bak" / "Hermes.exe", PE_AMD64)
|
||||
|
||||
stamp = tmp_path / "home" / "desktop-build-stamp.json"
|
||||
stamp.parent.mkdir(parents=True, exist_ok=True)
|
||||
stamp.write_text("{}", encoding="utf-8")
|
||||
|
||||
with patch("platform.machine", return_value="AMD64"), \
|
||||
patch("hermes_cli.main._purge_electron_build_cache", return_value=[]) as mock_purge, \
|
||||
patch("hermes_cli.main._desktop_stamp_path", return_value=stamp):
|
||||
verified, rolled_back = cli_main._ensure_desktop_exe_launchable(desktop_dir, exe)
|
||||
|
||||
assert rolled_back is True
|
||||
assert verified == exe
|
||||
assert cli_main._parse_pe_machine(exe) == PE_AMD64 # restored old build
|
||||
mock_purge.assert_called_once()
|
||||
assert not stamp.exists() # stamp invalidated so retry genuinely rebuilds
|
||||
out = capsys.readouterr().out
|
||||
assert "integrity check" in out
|
||||
assert "Update aborted" in out
|
||||
assert "existing version was kept" in out
|
||||
|
||||
|
||||
def test_gate_fails_clearly_without_backup(tmp_path, monkeypatch, capsys):
|
||||
monkeypatch.setattr(cli_main.sys, "platform", "win32")
|
||||
desktop_dir, exe = _win_tree(tmp_path)
|
||||
fake = exe
|
||||
fake.parent.mkdir(parents=True)
|
||||
fake.write_bytes(b"<html>proxy error</html>" + b" " * 600)
|
||||
|
||||
with patch("hermes_cli.main._purge_electron_build_cache", return_value=[]), \
|
||||
patch("hermes_cli.main._desktop_stamp_path", return_value=tmp_path / "stamp.json"):
|
||||
verified, rolled_back = cli_main._ensure_desktop_exe_launchable(desktop_dir, exe)
|
||||
|
||||
assert verified is None
|
||||
assert rolled_back is False
|
||||
out = capsys.readouterr().out
|
||||
assert "integrity check" in out
|
||||
assert "No usable backup" in out
|
||||
|
||||
|
||||
# ─── end-to-end: `hermes desktop --build-only` exits nonzero on corrupt exe ─
|
||||
|
||||
|
||||
def _ns(**kw):
|
||||
defaults = dict(
|
||||
skip_build=False,
|
||||
build_only=True,
|
||||
force_build=False,
|
||||
source=False,
|
||||
fake_boot=False,
|
||||
ignore_existing=False,
|
||||
hermes_root=None,
|
||||
cwd=None,
|
||||
)
|
||||
defaults.update(kw)
|
||||
return argparse.Namespace(**defaults)
|
||||
|
||||
|
||||
def test_build_only_fails_when_pack_produces_corrupt_exe(tmp_path, monkeypatch, capsys):
|
||||
"""The updater chain's contract: a rebuild whose Hermes.exe cannot launch
|
||||
must exit nonzero (so hermes-setup's retry-once kicks in) and must restore
|
||||
the previous working build instead of leaving the corrupt one."""
|
||||
root = tmp_path / "hermes-agent"
|
||||
desktop_dir = root / "apps" / "desktop"
|
||||
desktop_dir.mkdir(parents=True)
|
||||
(desktop_dir / "package.json").write_text("{}", encoding="utf-8")
|
||||
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
|
||||
monkeypatch.setattr(cli_main.sys, "platform", "win32")
|
||||
|
||||
exe = desktop_dir / "release" / "win-unpacked" / "Hermes.exe"
|
||||
make_pe(exe, PE_AMD64, truncate_to=0x300) # what the failed pack produced
|
||||
make_pe(desktop_dir / "release" / "win-unpacked.bak" / "Hermes.exe", PE_AMD64)
|
||||
|
||||
install_ok = subprocess.CompletedProcess(["npm", "ci"], 0)
|
||||
pack_ok = subprocess.CompletedProcess(["npm", "run", "pack"], 0)
|
||||
|
||||
with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \
|
||||
patch("hermes_cli.main._run_npm_install_deterministic", return_value=install_ok), \
|
||||
patch("hermes_cli.main._desktop_build_needed", return_value=True), \
|
||||
patch("hermes_cli.main._stop_desktop_processes_locking_build", return_value=[]), \
|
||||
patch("hermes_cli.main._purge_electron_build_cache", return_value=[]), \
|
||||
patch("hermes_cli.main._desktop_stamp_path", return_value=tmp_path / "stamp.json"), \
|
||||
patch("hermes_cli.main._write_desktop_build_stamp") as mock_stamp, \
|
||||
patch("platform.machine", return_value="AMD64"), \
|
||||
patch("hermes_cli.main.subprocess.run", return_value=pack_ok), \
|
||||
pytest.raises(SystemExit) as exc:
|
||||
cli_main.cmd_gui(_ns())
|
||||
|
||||
assert exc.value.code == 1
|
||||
# The previous working exe was restored...
|
||||
assert cli_main._parse_pe_machine(exe) == PE_AMD64
|
||||
# ...and the poisoned build was never stamped as good.
|
||||
mock_stamp.assert_not_called()
|
||||
out = capsys.readouterr().out
|
||||
assert "integrity check" in out
|
||||
|
||||
|
||||
def test_build_only_succeeds_with_valid_exe(tmp_path, monkeypatch, capsys):
|
||||
root = tmp_path / "hermes-agent"
|
||||
desktop_dir = root / "apps" / "desktop"
|
||||
desktop_dir.mkdir(parents=True)
|
||||
(desktop_dir / "package.json").write_text("{}", encoding="utf-8")
|
||||
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
|
||||
monkeypatch.setattr(cli_main.sys, "platform", "win32")
|
||||
|
||||
make_pe(desktop_dir / "release" / "win-unpacked" / "Hermes.exe", PE_AMD64)
|
||||
|
||||
install_ok = subprocess.CompletedProcess(["npm", "ci"], 0)
|
||||
pack_ok = subprocess.CompletedProcess(["npm", "run", "pack"], 0)
|
||||
|
||||
with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \
|
||||
patch("hermes_cli.main._run_npm_install_deterministic", return_value=install_ok), \
|
||||
patch("hermes_cli.main._desktop_build_needed", return_value=True), \
|
||||
patch("hermes_cli.main._stop_desktop_processes_locking_build", return_value=[]), \
|
||||
patch("hermes_cli.main._write_desktop_build_stamp") as mock_stamp, \
|
||||
patch("platform.machine", return_value="AMD64"), \
|
||||
patch("hermes_cli.main.subprocess.run", return_value=pack_ok):
|
||||
cli_main.cmd_gui(_ns())
|
||||
|
||||
mock_stamp.assert_called_once()
|
||||
assert "Desktop packaged app ready" in capsys.readouterr().out
|
||||
@@ -126,6 +126,30 @@ class TestDoctorEnvFileEncoding:
|
||||
doctor_mod.run_doctor(Namespace(fix=False))
|
||||
|
||||
|
||||
|
||||
def test_doctor_reads_invalid_utf8_env_via_latin1_fallback(
|
||||
self, monkeypatch, tmp_path
|
||||
):
|
||||
"""cp1252/latin-1 .env with ASCII provider hints must not abort doctor."""
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
env_path = hermes_home / ".env"
|
||||
# 0xff is invalid UTF-8; latin-1 decodes it. Keep an ASCII provider key
|
||||
# so the scan still reports a configured endpoint/key.
|
||||
env_path.write_bytes(b"OPENAI_API_KEY=sk-test\xff\n")
|
||||
|
||||
monkeypatch.setattr(doctor_mod, "HERMES_HOME", hermes_home)
|
||||
|
||||
fake_model_tools = types.SimpleNamespace(
|
||||
check_tool_availability=lambda *a, **kw: (_ for _ in ()).throw(SystemExit(0)),
|
||||
TOOLSET_REQUIREMENTS={},
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
doctor_mod.run_doctor(Namespace(fix=False))
|
||||
|
||||
|
||||
class TestDoctorToolAvailabilityOverrides:
|
||||
def test_marks_honcho_available_when_configured(self, monkeypatch):
|
||||
monkeypatch.setattr(doctor, "_honcho_is_configured_for_doctor", lambda: True)
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Regression: the distribution-install preview must read .env as UTF-8.
|
||||
|
||||
`_render_distribution_plan` inspects the target profile's `.env` to decide
|
||||
whether a required env var is already set, so it doesn't nag the user. It
|
||||
used `Path.read_text()` with no encoding, which:
|
||||
|
||||
1. defaults to the system locale (cp1251/GBK on Windows) and raises
|
||||
`UnicodeDecodeError` on any non-ASCII byte — and the surrounding
|
||||
`except OSError` does NOT catch that (it's a `ValueError`), so a
|
||||
mis-encoded .env aborts the whole install preview; and
|
||||
2. even on a UTF-8 locale, a Notepad-added BOM prefixes the first key,
|
||||
so the very first env var is mis-detected as "needs setting".
|
||||
|
||||
The fix reads `utf-8-sig` and also catches `UnicodeDecodeError`.
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli.main import _render_distribution_plan
|
||||
|
||||
|
||||
def _make_plan(target_dir, env_requires):
|
||||
manifest = SimpleNamespace(
|
||||
name="demo",
|
||||
version="1.0.0",
|
||||
description="",
|
||||
author="",
|
||||
hermes_requires="",
|
||||
env_requires=env_requires,
|
||||
)
|
||||
return SimpleNamespace(
|
||||
manifest=manifest,
|
||||
provenance="local",
|
||||
target_dir=target_dir,
|
||||
existing=False,
|
||||
has_cron=False,
|
||||
)
|
||||
|
||||
|
||||
def test_bom_prefixed_env_first_key_is_detected_as_set(tmp_path, monkeypatch, capsys):
|
||||
"""A required key on the first line of a BOM-prefixed .env reads as set."""
|
||||
monkeypatch.delenv("FOO_TOKEN", raising=False)
|
||||
profile = tmp_path / "profile"
|
||||
profile.mkdir()
|
||||
# utf-8-sig write == UTF-8 with a leading BOM, exactly what Notepad emits.
|
||||
(profile / ".env").write_text("FOO_TOKEN=abc\nBAR=1\n", encoding="utf-8-sig")
|
||||
|
||||
plan = _make_plan(profile, [SimpleNamespace(name="FOO_TOKEN", required=True, description="")])
|
||||
_render_distribution_plan(plan)
|
||||
|
||||
out = capsys.readouterr().out
|
||||
foo_line = next(ln for ln in out.splitlines() if "FOO_TOKEN" in ln)
|
||||
assert "✓ set" in foo_line, f"BOM must not hide the first key: {foo_line!r}"
|
||||
|
||||
|
||||
def test_non_utf8_env_does_not_abort_the_preview(tmp_path, monkeypatch, capsys):
|
||||
"""A .env with invalid UTF-8 bytes must not crash the install preview."""
|
||||
monkeypatch.delenv("FOO_TOKEN", raising=False)
|
||||
profile = tmp_path / "profile"
|
||||
profile.mkdir()
|
||||
# \xff is an invalid UTF-8 start byte -> UnicodeDecodeError on read.
|
||||
(profile / ".env").write_bytes(b"FOO_TOKEN=\xff\xfe bad\n")
|
||||
|
||||
plan = _make_plan(profile, [SimpleNamespace(name="FOO_TOKEN", required=True, description="")])
|
||||
|
||||
# Must return normally (old `except OSError` let UnicodeDecodeError escape).
|
||||
_render_distribution_plan(plan)
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert any("FOO_TOKEN" in ln for ln in out.splitlines())
|
||||
@@ -22,6 +22,7 @@ from plugins.memory.hindsight import (
|
||||
REFLECT_SCHEMA,
|
||||
RETAIN_SCHEMA,
|
||||
_load_config,
|
||||
_load_simple_env,
|
||||
_build_embedded_profile_env,
|
||||
_normalize_observation_scopes,
|
||||
_normalize_retain_tags,
|
||||
@@ -1819,3 +1820,62 @@ def test_save_config_sets_owner_only_permissions(tmp_path):
|
||||
assert config_file.exists()
|
||||
mode = stat.S_IMODE(config_file.stat().st_mode)
|
||||
assert mode == 0o600, f"Expected 0o600 (owner-only), got {oct(mode)}"
|
||||
|
||||
|
||||
class TestLoadSimpleEnv:
|
||||
def test_bom_first_key_is_recognized(self, tmp_path):
|
||||
"""A Notepad-edited .env carries a BOM; the first key must still parse
|
||||
instead of becoming '\ufeffHINDSIGHT_LLM_API_KEY'."""
|
||||
env_path = tmp_path / ".env"
|
||||
env_path.write_bytes("HINDSIGHT_LLM_API_KEY=sk-test\n".encode("utf-8"))
|
||||
values = _load_simple_env(env_path)
|
||||
assert values.get("HINDSIGHT_LLM_API_KEY") == "sk-test"
|
||||
|
||||
def test_non_ascii_values_read_intact(self, tmp_path):
|
||||
env_path = tmp_path / ".env"
|
||||
env_path.write_bytes("PROXY_NOTE=café-zürich-完了\n".encode("utf-8"))
|
||||
values = _load_simple_env(env_path)
|
||||
assert values["PROXY_NOTE"] == "café-zürich-完了"
|
||||
|
||||
|
||||
class TestPostSetupEnvEncoding:
|
||||
def _run_cloud_post_setup(self, tmp_path, monkeypatch):
|
||||
"""Drive post_setup through the cloud path with piped stdin."""
|
||||
import io
|
||||
import shutil as shutil_mod
|
||||
|
||||
monkeypatch.setattr("hermes_cli.memory_setup._curses_select",
|
||||
lambda *a, **kw: 0) # cloud mode
|
||||
monkeypatch.setattr("hermes_cli.config.save_config", lambda c: None)
|
||||
monkeypatch.setattr(shutil_mod, "which", lambda *_: None) # skip uv install
|
||||
# First line: API key prompt (readline). Second line: API URL (input).
|
||||
monkeypatch.setattr(sys, "stdin", io.StringIO("sk-new\n\n"))
|
||||
|
||||
provider = HindsightMemoryProvider()
|
||||
provider.post_setup(str(tmp_path), {"memory": {}})
|
||||
|
||||
def test_bom_first_key_updated_in_place(self, tmp_path, monkeypatch):
|
||||
"""The setup writer reads the existing .env BOM-tolerantly, so a
|
||||
BOM'd first key is matched and rewritten, not duplicated."""
|
||||
env_path = tmp_path / ".env"
|
||||
env_path.write_bytes("HINDSIGHT_API_KEY=old\n".encode("utf-8"))
|
||||
|
||||
self._run_cloud_post_setup(tmp_path, monkeypatch)
|
||||
|
||||
content = env_path.read_text(encoding="utf-8")
|
||||
assert content.count("HINDSIGHT_API_KEY=") == 1
|
||||
assert "HINDSIGHT_API_KEY=sk-new" in content
|
||||
assert "old" not in content
|
||||
assert "" not in content
|
||||
|
||||
def test_non_ascii_lines_survive_round_trip(self, tmp_path, monkeypatch):
|
||||
"""Unrelated non-ASCII .env content must be copied through as UTF-8
|
||||
(the locale codec would crash or mangle it on Windows)."""
|
||||
env_path = tmp_path / ".env"
|
||||
env_path.write_bytes("PROXY_NOTE=café-zürich-完了\n".encode("utf-8"))
|
||||
|
||||
self._run_cloud_post_setup(tmp_path, monkeypatch)
|
||||
|
||||
content = env_path.read_text(encoding="utf-8")
|
||||
assert "PROXY_NOTE=café-zürich-完了" in content
|
||||
assert "HINDSIGHT_API_KEY=sk-new" in content
|
||||
|
||||
@@ -11,6 +11,7 @@ from plugins.memory.mem0._setup import (
|
||||
parse_flags,
|
||||
build_oss_config,
|
||||
_write_env,
|
||||
_prompt_api_key,
|
||||
post_setup,
|
||||
_check_qdrant_path,
|
||||
_check_ollama,
|
||||
@@ -181,6 +182,48 @@ class TestWriteEnv:
|
||||
assert "OTHER=keep" in content
|
||||
assert "old" not in content
|
||||
|
||||
def test_preserves_non_ascii_existing_lines(self, tmp_path):
|
||||
"""Existing non-ASCII .env content must survive the read-modify-write
|
||||
as UTF-8 (the locale codec would crash/mangle it on Windows)."""
|
||||
env_path = tmp_path / ".env"
|
||||
env_path.write_bytes("PROXY_NOTE=café-zürich-完了\n".encode("utf-8"))
|
||||
_write_env(env_path, {"OPENAI_API_KEY": "sk-test"})
|
||||
content = env_path.read_text(encoding="utf-8")
|
||||
assert "PROXY_NOTE=café-zürich-完了" in content
|
||||
assert "OPENAI_API_KEY=sk-test" in content
|
||||
|
||||
def test_updates_first_key_with_bom(self, tmp_path):
|
||||
"""A Notepad-edited .env carries a BOM; the first key must still be
|
||||
matched/updated in place, not duplicated."""
|
||||
env_path = tmp_path / ".env"
|
||||
env_path.write_bytes("OPENAI_API_KEY=old\n".encode("utf-8"))
|
||||
_write_env(env_path, {"OPENAI_API_KEY": "new"})
|
||||
content = env_path.read_text(encoding="utf-8")
|
||||
assert content.count("OPENAI_API_KEY=") == 1
|
||||
assert "OPENAI_API_KEY=new" in content
|
||||
|
||||
|
||||
class TestPromptApiKey:
|
||||
|
||||
def test_existing_key_found_behind_bom(self, tmp_path, monkeypatch):
|
||||
"""The masked-current-value lookup must see a key on the BOM'd first
|
||||
line of a Notepad-edited .env instead of prompting from scratch."""
|
||||
env_path = tmp_path / ".env"
|
||||
env_path.write_bytes("OPENAI_API_KEY=sk-existing\n".encode("utf-8"))
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
|
||||
prompts: list[str] = []
|
||||
|
||||
def _fake_getpass(prompt):
|
||||
prompts.append(prompt)
|
||||
return ""
|
||||
|
||||
monkeypatch.setattr("plugins.memory.mem0._setup.getpass.getpass", _fake_getpass)
|
||||
_prompt_api_key("OpenAI", "OPENAI_API_KEY", str(tmp_path))
|
||||
|
||||
assert len(prompts) == 1
|
||||
assert "current: ...ting" in prompts[0]
|
||||
|
||||
|
||||
class TestPostSetup:
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user