Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions packages/desktop/scripts/apply-hermes-patches.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -339,4 +339,72 @@ function appendSitecustomizePatch(id, marker, body) {
appendSitecustomizePatch('brotlicffi-error-compat', brotlicffiCompatMarker, brotlicffiCompat)
appendSitecustomizePatch('desktop-hidden-subprocess-defaults', desktopHiddenSubprocessMarker, desktopHiddenSubprocessDefaults)

// ── local-parent-path-preserve ────────────────────────────────
// Preserve parent PATH before ``bash -l`` resets it via ``/etc/profile``.
// Without this, user-installed tools under ``~/.local/bin`` disappear from
// the session snapshot and providers like nowledge-mem report "unavailable".
//
// Root cause: ``BaseEnvironment.init_session()`` calls ``_run_bash(...,
// login=True)`` which spawns ``bash -l -c``. On most Linux distros
// ``/etc/profile`` unconditionally overwrites PATH with a static list,
// discarding entries that ``_make_run_env`` added (e.g. ``~/.local/bin``
// from the parent process). The ``export -p`` snapshot then captures
// the degraded PATH.
//
// Fix: stash the pre-login PATH in ``HERMES_INIT_PARENT_PATH`` inside
// ``_run_bash``, then restore it in ``init_session``'s bootstrap script
// before the ``export -p`` snapshot.

const localEnvPath = join(sitePkgs, 'tools', 'environments', 'local.py')
if (existsSync(localEnvPath)) {
console.log(`Patching ${localEnvPath}`)
let localSrc = readFileSync(localEnvPath, 'utf-8')
const localBefore = localSrc

localSrc = patchText(
localSrc,
'local-parent-path-preserve',
'# patch:local-parent-path-preserve',
' run_env = _make_run_env(self.env)\n\n # Recover when the cwd has been deleted',
' run_env = _make_run_env(self.env)\n\n' +
' # patch:local-parent-path-preserve -- stash PATH before bash -l overwrites it\n' +
' _path_key = _path_env_key(run_env)\n' +
' if _path_key is not None and _path_key in run_env:\n' +
' run_env["HERMES_INIT_PARENT_PATH"] = run_env[_path_key]\n\n' +
' # Recover when the cwd has been deleted',
)

if (localSrc !== localBefore) {
writeFileSync(localEnvPath, localSrc)
}
}

// ── base-restore-parent-path ──────────────────────────────────
// Restore the stashed parent PATH before ``export -p`` captures the
// session snapshot, so user-added PATH entries survive login shells.

const baseEnvPath = join(sitePkgs, 'tools', 'environments', 'base.py')
if (existsSync(baseEnvPath)) {
console.log(`Patching ${baseEnvPath}`)
let baseSrc = readFileSync(baseEnvPath, 'utf-8')
const baseBefore = baseSrc

baseSrc = patchText(
baseSrc,
'base-restore-parent-path',
'# patch:base-restore-parent-path',
' bootstrap = (\n f"export -p > {_quoted_snap}\\n"',
' bootstrap = (\n' +
' # patch:base-restore-parent-path -- restore parent PATH before snapshot\n' +
' f\'[ -n "$HERMES_INIT_PARENT_PATH" ] && export PATH="$HERMES_INIT_PARENT_PATH"\\n\'\n' +
' f"unset HERMES_INIT_PARENT_PATH\\n"\n' +
' f"export -p > {_quoted_snap}\\n"',
)

if (baseSrc !== baseBefore) {
writeFileSync(baseEnvPath, baseSrc)
}
}

console.log(`Done. Applied ${applied}, skipped ${skipped}.`)

179 changes: 179 additions & 0 deletions tests/desktop/apply-hermes-patches.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync, readFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'

const tempDirs: string[] = []

function tempDir(): string {
const dir = mkdtempSync(join(tmpdir(), 'hermes-apply-patches-'))
tempDirs.push(dir)
return dir
}

// Minimal fake site-packages layout with the two target files
function createFakeSitePackages(root: string) {
const envDir = join(root, 'tools', 'environments')
mkdirSync(envDir, { recursive: true })

// local.py — snippet around _run_bash's run_env assignment
writeFileSync(join(envDir, 'local.py'), [
'def _run_bash(self, cmd_string, *, login=False, timeout=120, stdin_data=None):',
' args = [bash, "-l", "-c", cmd_string] if login else [bash, "-c", cmd_string]',
' run_env = _make_run_env(self.env)',
'',
' # Recover when the cwd has been deleted',
' safe_cwd = _resolve_safe_cwd(self.cwd)',
'',
].join('\n'))

// base.py — snippet around init_session's bootstrap
writeFileSync(join(envDir, 'base.py'), [
' def init_session(self):',
' _quoted_snap = shlex.quote(self._snapshot_path)',
' _quoted_cwd_file = shlex.quote(self._cwd_file)',
' bootstrap = (',
' f"export -p > {_quoted_snap}\\n"',
' f"declare -f | grep -vE \'^_[^_]\' >> {_quoted_snap}\\n"',
' )',
'',
].join('\n'))

// dingtalk.py (needed as a gate — patch script checks it exists)
const gatewayDir = join(root, 'gateway', 'platforms')
mkdirSync(gatewayDir, { recursive: true })
writeFileSync(join(gatewayDir, 'dingtalk.py'), [
' self._card_template_id: Optional[str] = extra.get("card_template_id")',
' # Check metadata first (for direct webhook sends)',
' session_webhook = metadata.get("session_webhook")',
' if not session_webhook:',
' webhook_info = self._get_valid_webhook(chat_id)',
' if not webhook_info:',
' logger.warning(',
' "[%s] No valid session_webhook for chat_id=%s",',
' self.name, chat_id,',
' )',
' return SendResult(',
' success=False,',
' error="No valid session_webhook available."',
' )',
' session_webhook, _ = webhook_info',
'',
' if not self._http_client:',
' return SendResult(success=False, error="HTTP client not initialized")',
'',
' # Look up the inbound message for this chat (for AI Card routing)',
' current_message = self._message_contexts.get(chat_id)',
' logger.debug("[%s] Sending via webhook", self.name)',
'',
' im_robot_open_deliver_model=(',
' dingtalk_card_models.DeliverCardRequestImRobotOpenDeliverModel(',
' space_type="IM_ROBOT",',
' )',
' ),',
'',
' card_data=dingtalk_card_models.CreateCardRequestCardData(',
' card_param_map={"content": ""},',
' ),',
'',
' logger.warning("[%s] AI Card send failed, falling back to webhook", self.name)',
'',
' logger.debug("[%s] Sending via webhook", self.name)',
].join('\n'))

// sitecustomize.py (empty)
writeFileSync(join(root, 'sitecustomize.py'), '')
}

describe('apply-hermes-patches - PATH snapshot fix', () => {
let sitePkgs: string

beforeEach(() => {
sitePkgs = tempDir()
createFakeSitePackages(sitePkgs)
})

afterEach(() => {
for (const d of tempDirs) {
rmSync(d, { recursive: true, force: true })
}
tempDirs.length = 0
})

it('patches local.py to stash parent PATH in HERMES_INIT_PARENT_PATH', async () => {
const { execFileSync } = await import('node:child_process')
const scriptPath = join(process.cwd(), 'packages/desktop/scripts/apply-hermes-patches.mjs')

const result = execFileSync('node', [scriptPath], {
env: {
...process.env,
HERMES_AGENT_SITE_PACKAGES: sitePkgs,
TARGET_OS: 'linux',
TARGET_ARCH: 'x64',
},
encoding: 'utf-8',
timeout: 15_000,
})

// Verify local.py was patched
const localSrc = readFileSync(join(sitePkgs, 'tools', 'environments', 'local.py'), 'utf-8')
expect(localSrc).toContain('# patch:local-parent-path-preserve')

Check failure on line 120 in tests/desktop/apply-hermes-patches.test.ts

View workflow job for this annotation

GitHub Actions / build

tests/desktop/apply-hermes-patches.test.ts > apply-hermes-patches - PATH snapshot fix > patches local.py to stash parent PATH in HERMES_INIT_PARENT_PATH

AssertionError: expected 'def _run_bash(self, cmd_string, *, lo…' to contain '# patch:local-parent-path-preserve' - Expected + Received - # patch:local-parent-path-preserve + def _run_bash(self, cmd_string, *, login=False, timeout=120, stdin_data=None): + args = [bash, "-l", "-c", cmd_string] if login else [bash, "-c", cmd_string] + run_env = _make_run_env(self.env) + + # Recover when the cwd has been deleted + safe_cwd = _resolve_safe_cwd(self.cwd) + ❯ tests/desktop/apply-hermes-patches.test.ts:120:22
expect(localSrc).toContain('HERMES_INIT_PARENT_PATH')
expect(localSrc).toContain('_path_env_key(run_env)')
// Verify the anchor is still present (not destroyed)
expect(localSrc).toContain('run_env = _make_run_env(self.env)')
expect(localSrc).toContain('# Recover when the cwd has been deleted')
})

it('patches base.py to restore parent PATH before export -p', async () => {
const { execFileSync } = await import('node:child_process')
const scriptPath = join(process.cwd(), 'packages/desktop/scripts/apply-hermes-patches.mjs')

execFileSync('node', [scriptPath], {
env: {
...process.env,
HERMES_AGENT_SITE_PACKAGES: sitePkgs,
TARGET_OS: 'linux',
TARGET_ARCH: 'x64',
},
encoding: 'utf-8',
timeout: 15_000,
})

// Verify base.py was patched
const baseSrc = readFileSync(join(sitePkgs, 'tools', 'environments', 'base.py'), 'utf-8')
expect(baseSrc).toContain('# patch:base-restore-parent-path')
expect(baseSrc).toContain('HERMES_INIT_PARENT_PATH')
expect(baseSrc).toContain('unset HERMES_INIT_PARENT_PATH')
// Verify export -p is still present (not destroyed)
expect(baseSrc).toContain('export -p >')
})

it('is idempotent - re-running skips already-applied patches', async () => {
const { execFileSync } = await import('node:child_process')
const scriptPath = join(process.cwd(), 'packages/desktop/scripts/apply-hermes-patches.mjs')

const env = {
...process.env,
HERMES_AGENT_SITE_PACKAGES: sitePkgs,
TARGET_OS: 'linux',
TARGET_ARCH: 'x64',
}

// First run
execFileSync('node', [scriptPath], { env, encoding: 'utf-8', timeout: 15_000 })

// Second run - should say "already applied"
const out2 = execFileSync('node', [scriptPath], { env, encoding: 'utf-8', timeout: 15_000 })

expect(out2).toContain('local-parent-path-preserve')
expect(out2).toContain('already applied')
expect(out2).toContain('base-restore-parent-path')

// Files should be unchanged after second run
const localSrc = readFileSync(join(sitePkgs, 'tools', 'environments', 'local.py'), 'utf-8')
const baseSrc = readFileSync(join(sitePkgs, 'tools', 'environments', 'base.py'), 'utf-8')
expect(localSrc).toContain('# patch:local-parent-path-preserve')

Check failure on line 176 in tests/desktop/apply-hermes-patches.test.ts

View workflow job for this annotation

GitHub Actions / build

tests/desktop/apply-hermes-patches.test.ts > apply-hermes-patches - PATH snapshot fix > is idempotent - re-running skips already-applied patches

AssertionError: expected 'def _run_bash(self, cmd_string, *, lo…' to contain '# patch:local-parent-path-preserve' - Expected + Received - # patch:local-parent-path-preserve + def _run_bash(self, cmd_string, *, login=False, timeout=120, stdin_data=None): + args = [bash, "-l", "-c", cmd_string] if login else [bash, "-c", cmd_string] + run_env = _make_run_env(self.env) + + # Recover when the cwd has been deleted + safe_cwd = _resolve_safe_cwd(self.cwd) + ❯ tests/desktop/apply-hermes-patches.test.ts:176:22
expect(baseSrc).toContain('# patch:base-restore-parent-path')
})
})
Loading